diff --git a/admin/loyalty.php b/admin/loyalty.php
index c76c313..a36b1c7 100644
--- a/admin/loyalty.php
+++ b/admin/loyalty.php
@@ -72,11 +72,11 @@ include 'includes/header.php';
Current Configuration
- Points per Order
+ Points per Loyalty Product
= $settings['points_per_order'] ?> pts
- Free Meal Threshold
+ Free Product Threshold
= $settings['points_for_free_meal'] ?> pts
@@ -127,7 +127,7 @@ include 'includes/header.php';
- Eligible for Free Meal
+ Eligible for Free Product
@@ -190,14 +190,14 @@ include 'includes/header.php';
-
+
- Points awarded for every completed order.
+ Points awarded for every loyalty-participating product in the order.
-
+
- Threshold points required to redeem a free meal.
+ Threshold points required to redeem a free product.
+
+
+
+
+ Include this product in earning and redeeming loyalty points
+
+
+
+
Promotion Settings
diff --git a/api/order.php b/api/order.php
index 6a70db6..1811e8e 100644
--- a/api/order.php
+++ b/api/order.php
@@ -74,7 +74,6 @@ try {
$settingsStmt = $pdo->query("SELECT is_enabled, points_per_order, points_for_free_meal FROM loyalty_settings WHERE id = 1");
$loyaltySettings = $settingsStmt->fetch(PDO::FETCH_ASSOC);
$loyalty_enabled = $loyaltySettings ? (bool)$loyaltySettings['is_enabled'] : true;
- $points_per_order = $loyaltySettings ? intval($loyaltySettings['points_per_order']) : 10;
$points_threshold = $loyaltySettings ? intval($loyaltySettings['points_for_free_meal']) : 70;
$current_points = 0;
@@ -96,58 +95,21 @@ try {
}
}
- // Loyalty Redemption Logic
+ // Loyalty Redemption Logic (initial check)
$redeem_loyalty = !empty($data['redeem_loyalty']) && $loyalty_enabled;
if ($redeem_loyalty && $customer_id) {
if ($current_points < $points_threshold) {
throw new Exception("Insufficient loyalty points for redemption.");
}
- // Deduct points
- $deductStmt = $pdo->prepare("UPDATE customers SET points = points - ? WHERE id = ?");
- $pdo->prepare("UPDATE customers SET loyalty_redemptions_count = loyalty_redemptions_count + 1 WHERE id = ?")->execute([$customer_id]);
- $deductStmt->execute([$points_threshold, $customer_id]);
- $points_deducted = $points_threshold;
-
- // Record Loyalty History (Deduction)
- $historyStmt = $pdo->prepare("INSERT INTO loyalty_points_history (customer_id, points_change, reason) VALUES (?, ?, 'Redeemed Free Meal')");
- $historyStmt->execute([$customer_id, -$points_threshold]);
- $redeem_history_id = $pdo->lastInsertId();
-
- // --- OVERRIDE PAYMENT TYPE ---
- $ptStmt = $pdo->prepare("SELECT id FROM payment_types WHERE name = 'Loyalty Redeem' LIMIT 1");
- $ptStmt->execute();
- $loyaltyPt = $ptStmt->fetchColumn();
- if ($loyaltyPt) {
- $data['payment_type_id'] = $loyaltyPt;
- }
- }
-
- // Award Points (ONLY IF NOT REDEEMING)
- if ($customer_id && $loyalty_enabled && !$redeem_loyalty) {
- $awardStmt = $pdo->prepare("UPDATE customers SET points = points + ? WHERE id = ?");
- $awardStmt->execute([$points_per_order, $customer_id]);
- $points_awarded = $points_per_order;
-
- // Record Loyalty History (Award)
- $historyStmt = $pdo->prepare("INSERT INTO loyalty_points_history (customer_id, points_change, reason) VALUES (?, ?, 'Earned from Order')");
- $historyStmt->execute([$customer_id, $points_per_order]);
- $award_history_id = $pdo->lastInsertId();
}
- // User/Payment info
- $user = get_logged_user();
- $user_id = $user ? $user['id'] : null;
- $payment_type_id = !empty($data['payment_type_id']) ? intval($data['payment_type_id']) : null;
-
- // VAT vs Discount: We repurpose the 'discount' column in the database to store VAT value.
- // If it's a positive value, it's VAT. If negative, it acts as a discount (loyalty).
- $vat = isset($data['vat']) ? floatval($data['vat']) : 0.00;
-
// Total amount will be recalculated on server to be safe
$calculated_total = 0;
- // First, process items to calculate real total
+ // First, process items to calculate real total and handle loyalty
$processed_items = [];
+ $loyalty_items_indices = [];
+
if (!empty($data['items']) && is_array($data['items'])) {
foreach ($data['items'] as $item) {
$pid = $item['product_id'] ?? ($item['id'] ?? null);
@@ -176,8 +138,9 @@ try {
}
}
- $item_total = $unit_price * $qty;
- $calculated_total += $item_total;
+ if ($product['is_loyalty']) {
+ $loyalty_items_indices[] = count($processed_items);
+ }
$processed_items[] = [
'product_id' => $pid,
@@ -185,13 +148,91 @@ try {
'variant_id' => $vid,
'variant_name' => $variant_name,
'quantity' => $qty,
- 'unit_price' => $unit_price
+ 'unit_price' => $unit_price,
+ 'is_loyalty' => (bool)$product['is_loyalty']
];
}
}
+ // Handle Loyalty Redemption (Make one loyalty item free)
+ if ($redeem_loyalty && $customer_id) {
+ if (empty($loyalty_items_indices)) {
+ throw new Exception("No loyalty-eligible products in the order to redeem.");
+ }
+
+ // Find the most expensive loyalty item
+ $max_price = -1;
+ $max_index = -1;
+ foreach ($loyalty_items_indices as $idx) {
+ if ($processed_items[$idx]['unit_price'] > $max_price) {
+ $max_price = $processed_items[$idx]['unit_price'];
+ $max_index = $idx;
+ }
+ }
+
+ if ($max_index !== -1) {
+ // Deduct points from customer
+ $deductStmt = $pdo->prepare("UPDATE customers SET points = points - ? WHERE id = ?");
+ $pdo->prepare("UPDATE customers SET loyalty_redemptions_count = loyalty_redemptions_count + 1 WHERE id = ?")->execute([$customer_id]);
+ $deductStmt->execute([$points_threshold, $customer_id]);
+ $points_deducted = $points_threshold;
+
+ // Record Loyalty History (Deduction)
+ $historyStmt = $pdo->prepare("INSERT INTO loyalty_points_history (customer_id, points_change, reason) VALUES (?, ?, 'Redeemed Free Product')");
+ $historyStmt->execute([$customer_id, -$points_threshold]);
+ $redeem_history_id = $pdo->lastInsertId();
+
+ // Make ONE unit of the most expensive loyalty product free
+ if ($processed_items[$max_index]['quantity'] > 1) {
+ $processed_items[$max_index]['quantity']--;
+ $free_item = $processed_items[$max_index];
+ $free_item['quantity'] = 1;
+ $free_item['unit_price'] = 0;
+ $processed_items[] = $free_item;
+ } else {
+ $processed_items[$max_index]['unit_price'] = 0;
+ }
+
+ // --- OVERRIDE PAYMENT TYPE ---
+ $ptStmt = $pdo->prepare("SELECT id FROM payment_types WHERE name = 'Loyalty Redeem' LIMIT 1");
+ $ptStmt->execute();
+ $loyaltyPt = $ptStmt->fetchColumn();
+ if ($loyaltyPt) {
+ $data['payment_type_id'] = $loyaltyPt;
+ }
+ }
+ }
+
+ // Recalculate Subtotal and Earned Points
+ foreach ($processed_items as $pi) {
+ $calculated_total += $pi['unit_price'] * $pi['quantity'];
+ // Award points for PAID loyalty items (10 points each)
+ if ($pi['is_loyalty'] && $pi['unit_price'] > 0) {
+ $points_awarded += $pi['quantity'] * 10;
+ }
+ }
+
+ // Award Points (ONLY IF NOT REDEEMING or we can award for remaining items)
+ // User said "calculate these products multiply 10 points and will be calculated to the customer"
+ // Usually points are earned on the whole order, but here it's specifically per product.
+ if ($customer_id && $loyalty_enabled && $points_awarded > 0) {
+ $awardStmt = $pdo->prepare("UPDATE customers SET points = points + ? WHERE id = ?");
+ $awardStmt->execute([$points_awarded, $customer_id]);
+
+ // Record Loyalty History (Award)
+ $historyStmt = $pdo->prepare("INSERT INTO loyalty_points_history (customer_id, points_change, reason) VALUES (?, ?, 'Earned from Products')");
+ $historyStmt->execute([$customer_id, $points_awarded]);
+ $award_history_id = $pdo->lastInsertId();
+ }
+
+ $vat = isset($data['vat']) ? floatval($data['vat']) : 0.00;
$final_total = max(0, $calculated_total + $vat);
+ // User/Payment info
+ $user = get_logged_user();
+ $user_id = $user ? $user['id'] : null;
+ $payment_type_id = !empty($data['payment_type_id']) ? intval($data['payment_type_id']) : null;
+
// Commission Calculation
$commission_amount = 0;
$companySettings = get_company_settings();
@@ -200,7 +241,6 @@ try {
$userStmt->execute([$user_id]);
$commission_rate = (float)$userStmt->fetchColumn();
if ($commission_rate > 0) {
- // Commission is usually calculated on the subtotal (before tax/VAT)
$commission_amount = $calculated_total * ($commission_rate / 100);
}
}
@@ -220,7 +260,6 @@ try {
}
if ($is_update) {
- // UPDATE Existing Order
$stmt = $pdo->prepare("UPDATE orders SET
outlet_id = ?, table_id = ?, table_number = ?, order_type = ?,
customer_id = ?, customer_name = ?, customer_phone = ?,
@@ -237,7 +276,6 @@ try {
$delStmt = $pdo->prepare("DELETE FROM order_items WHERE order_id = ?");
$delStmt->execute([$order_id]);
} else {
- // INSERT New Order
$stmt = $pdo->prepare("INSERT INTO orders (outlet_id, table_id, table_number, order_type, customer_id, customer_name, customer_phone, payment_type_id, total_amount, discount, user_id, commission_amount, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending')");
$stmt->execute([$outlet_id, $table_id, $table_number, $order_type, $customer_id, $customer_name, $customer_phone, $payment_type_id, $final_total, $vat, $user_id, $commission_amount]);
$order_id = $pdo->lastInsertId();
@@ -335,4 +373,4 @@ You've earned *{points_earned} points* with this order.
if ($pdo->inTransaction()) $pdo->rollBack();
error_log("Order Error: " . $e->getMessage());
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
-}
+}
\ No newline at end of file
diff --git a/assets/js/main.js b/assets/js/main.js
index e24e61f..664a8f1 100644
--- a/assets/js/main.js
+++ b/assets/js/main.js
@@ -266,7 +266,8 @@ document.addEventListener('DOMContentLoaded', () => {
loyaltySection.classList.remove('d-none');
if (loyaltyPointsDisplay) loyaltyPointsDisplay.textContent = cust.points + ' pts';
if (redeemLoyaltyBtn) {
- redeemLoyaltyBtn.disabled = !cust.eligible_for_free_meal;
+ const hasLoyaltyItem = cart.some(item => item.is_loyalty);
+ redeemLoyaltyBtn.disabled = !cust.eligible_for_free_meal || !hasLoyaltyItem;
if (loyaltyMessage) {
if (cust.eligible_for_free_meal) loyaltyMessage.innerHTML = ' Eligible for Free Meal!';
else loyaltyMessage.textContent = `${cust.points_needed || 0} pts away from a free meal.`;
@@ -476,7 +477,7 @@ document.addEventListener('DOMContentLoaded', () => {
addToCart({
id: product.id, name: product.name, name_ar: product.name_ar || "",
price: parseFloat(product.price), base_price: parseFloat(product.price),
- hasVariants: false, quantity: 1, variant_id: null, variant_name: null
+ hasVariants: false, quantity: 1, variant_id: null, variant_name: null, is_loyalty: parseInt(product.is_loyalty) === 1
});
}
};
@@ -498,7 +499,7 @@ document.addEventListener('DOMContentLoaded', () => {
addToCart({
id: product.id, name: product.name, name_ar: product.name_ar || "",
price: finalPrice, base_price: parseFloat(product.price),
- hasVariants: true, quantity: 1, variant_id: v.id, variant_name: v.name
+ hasVariants: true, quantity: 1, variant_id: v.id, variant_name: v.name, is_loyalty: parseInt(product.is_loyalty) === 1
});
variantSelectionModal.hide();
};
@@ -539,6 +540,7 @@ document.addEventListener('DOMContentLoaded', () => {
function updateCart() {
if (!cartItemsContainer) return;
if (cart.length === 0) {
+ if (redeemLoyaltyBtn) redeemLoyaltyBtn.disabled = true;
cartItemsContainer.innerHTML = ` `;
if (cartSubtotal) cartSubtotal.innerText = formatCurrency(0);
if (cartTotalPrice) cartTotalPrice.innerText = formatCurrency(0);
diff --git a/db/migrations/038_add_is_loyalty_to_products.sql b/db/migrations/038_add_is_loyalty_to_products.sql
new file mode 100644
index 0000000..d8362c5
--- /dev/null
+++ b/db/migrations/038_add_is_loyalty_to_products.sql
@@ -0,0 +1,2 @@
+-- Add is_loyalty column to products table
+ALTER TABLE products ADD COLUMN is_loyalty TINYINT(1) DEFAULT 0;
diff --git a/db/schema.sql b/db/schema.sql
index 78d2335..39ad5e2 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -18,6 +18,7 @@ CREATE TABLE IF NOT EXISTS products (
description TEXT,
price DECIMAL(10, 2) NOT NULL,
image_url VARCHAR(255),
+ is_loyalty TINYINT(1) DEFAULT 0,
FOREIGN KEY (category_id) REFERENCES categories(id)
);
diff --git a/pos.php b/pos.php
index 5d18ffa..11ef67f 100644
--- a/pos.php
+++ b/pos.php
@@ -310,7 +310,7 @@ if (!$loyalty_settings) {
-
+
|