first draft

This commit is contained in:
Flatlogic Bot 2026-02-23 02:38:46 +00:00
parent 5d1e95ef4f
commit a238062edb
7 changed files with 311 additions and 155 deletions

View File

@ -37,14 +37,17 @@ function isActive($page) {
<link rel="stylesheet" href="../assets/css/custom.css?v=<?= time() ?>">
<style>
body { font-family: 'Inter', sans-serif; background-color: #f8f9fa; }
.sidebar { height: 100vh; position: fixed; top: 0; left: 0; width: 250px; background: #fff; border-right: 1px solid #eee; padding-top: 1rem; z-index: 1000; }
.sidebar .nav-link { color: #555; padding: 0.75rem 1.5rem; font-weight: 500; }
.sidebar { height: 100vh; position: fixed; top: 0; left: 0; width: 250px; background: #fff; border-right: 1px solid #eee; padding-top: 0; z-index: 1000; overflow-y: auto; }
.sidebar-header { padding: 1rem 1.5rem; border-bottom: 1px solid #eee; position: sticky; top: 0; background: #fff; z-index: 10; }
.sidebar .nav-link { color: #555; padding: 0.6rem 1.5rem; font-weight: 500; font-size: 0.95rem; }
.sidebar .nav-link:hover, .sidebar .nav-link.active { color: #FF6B6B; background: #FFF0F0; border-right: 3px solid #FF6B6B; }
.sidebar-heading { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; color: #999; padding: 1.25rem 1.5rem 0.5rem; font-weight: 700; }
.main-content { margin-left: 250px; padding: 2rem; }
@media (max-width: 768px) {
.sidebar { transform: translateX(-100%); transition: transform 0.3s ease; }
.sidebar { transform: translateX(-100%); transition: transform 0.3s ease; top: 56px; height: calc(100vh - 56px); }
.sidebar.show { transform: translateX(0); }
.main-content { margin-left: 0; }
.sidebar-header { display: none !important; } /* Hide duplicate logo on mobile */
}
.stat-card { border: none; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.04); transition: transform 0.2s; }
.stat-card:hover { transform: translateY(-3px); }
@ -71,7 +74,7 @@ function isActive($page) {
<!-- Sidebar -->
<div class="offcanvas-md offcanvas-start sidebar" tabindex="-1" id="sidebarMenu">
<div class="d-flex align-items-center justify-content-center px-4 pb-3 border-bottom d-none d-md-flex" style="min-height: 80px;">
<div class="d-flex align-items-center justify-content-center sidebar-header d-none d-md-flex">
<a href="index.php" class="text-decoration-none">
<?php if ($logoUrl): ?>
<img src="../<?= htmlspecialchars($logoUrl) ?>" alt="Logo" style="max-height: 50px; max-width: 100%;">
@ -84,12 +87,17 @@ function isActive($page) {
<h5 class="fw-bold">Menu</h5>
</div>
<ul class="nav flex-column mt-2">
<div class="sidebar-content">
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link <?= isActive('index.php') ?>" href="index.php">
<i class="bi bi-grid me-2"></i> Dashboard
</a>
</li>
</ul>
<h6 class="sidebar-heading">POS & Operations</h6>
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link" href="../pos.php" target="_blank">
<i class="bi bi-display me-2"></i> POS Terminal
@ -100,6 +108,15 @@ function isActive($page) {
<i class="bi bi-receipt me-2"></i> Orders (POS)
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="../kitchen.php" target="_blank">
<i class="bi bi-fire me-2"></i> Kitchen View
</a>
</li>
</ul>
<h6 class="sidebar-heading">Menu Management</h6>
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link <?= isActive('products.php') ?>" href="products.php">
<i class="bi bi-box-seam me-2"></i> Products
@ -110,6 +127,10 @@ function isActive($page) {
<i class="bi bi-tags me-2"></i> Categories
</a>
</li>
</ul>
<h6 class="sidebar-heading">Restaurant Setup</h6>
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link <?= isActive('outlets.php') ?>" href="outlets.php">
<i class="bi bi-shop me-2"></i> Outlets
@ -125,6 +146,10 @@ function isActive($page) {
<i class="bi bi-ui-checks-grid me-2"></i> Tables
</a>
</li>
</ul>
<h6 class="sidebar-heading">People & Partners</h6>
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link <?= isActive('customers.php') ?>" href="customers.php">
<i class="bi bi-people-fill me-2"></i> Customers
@ -140,6 +165,10 @@ function isActive($page) {
<i class="bi bi-award me-2"></i> Loyalty
</a>
</li>
</ul>
<h6 class="sidebar-heading">Settings</h6>
<ul class="nav flex-column mb-5">
<li class="nav-item">
<a class="nav-link <?= isActive('payment_types.php') ?>" href="payment_types.php">
<i class="bi bi-credit-card me-2"></i> Payment Types
@ -155,12 +184,13 @@ function isActive($page) {
<i class="bi bi-building me-2"></i> Company
</a>
</li>
<li class="nav-item mt-4 border-top pt-2">
<li class="nav-item border-top mt-2 pt-2">
<a class="nav-link text-muted" href="../index.php" target="_blank">
<i class="bi bi-box-arrow-up-right me-2"></i> View Site
</a>
</li>
</ul>
</div>
</div>
<div class="main-content pt-5 pt-md-4">

View File

@ -28,7 +28,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Wablas
if ($provider === 'wablas') {
$keys = ['domain', 'token', 'secret_key'];
$keys = ['domain', 'token', 'secret_key', 'order_template'];
foreach ($keys as $k) {
$val = $_POST[$k] ?? '';
$stmt = $pdo->prepare("INSERT INTO integration_settings (provider, setting_key, setting_value) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)");
@ -72,6 +72,22 @@ $thawaniSec = getSetting($allSettings, 'thawani', 'secret_key');
$wablasDom = getSetting($allSettings, 'wablas', 'domain');
$wablasTok = getSetting($allSettings, 'wablas', 'token');
$wablasSecKey = getSetting($allSettings, 'wablas', 'secret_key');
$wablasTemplate = getSetting($allSettings, 'wablas', 'order_template');
// Default template if empty
if (empty($wablasTemplate)) {
$wablasTemplate = "Dear *{customer_name}*,
Thank you for dining with *{company_name}*! 🍽️
*Order Details:*
{order_details}
Total: *{total_amount}* OMR
You've earned *{points_earned} points* with this order.
💰 *Current Balance: {new_balance} points*";
}
require_once __DIR__ . '/includes/header.php';
?>
@ -148,6 +164,17 @@ require_once __DIR__ . '/includes/header.php';
<input type="password" class="form-control" name="secret_key" value="<?= htmlspecialchars($wablasSecKey) ?>">
</div>
<div class="mb-3">
<label class="form-label">Order Notification Template</label>
<textarea class="form-control font-monospace" name="order_template" rows="8"><?= htmlspecialchars($wablasTemplate) ?></textarea>
<div class="form-text mt-2">
<strong>Available Variables:</strong><br>
<code>{customer_name}</code>, <code>{company_name}</code>, <code>{order_id}</code>,
<code>{order_details}</code> (list of items), <code>{total_amount}</code>,
<code>{points_earned}</code>, <code>{points_redeemed}</code>, <code>{new_balance}</code>.
</div>
</div>
<div class="mb-3 border-top pt-3">
<label class="form-label text-muted small">Test Configuration</label>
<div class="input-group">

12
api/last_msg.txt Normal file
View File

@ -0,0 +1,12 @@
Dear *Moosa Ali Al-Abri*,
Thank you for dining with us! 🍽️
*Order Details:*
2 x Signature Burger
1 x Veggie Delight
Total: *37.480* OMR
You've earned *10 points* with this order.
💰 *Current Balance: 20 points*
You need *50 more points* to unlock a free meal.

View File

@ -122,6 +122,13 @@ try {
$order_id = $pdo->lastInsertId();
$item_stmt = $pdo->prepare("INSERT INTO order_items (order_id, product_id, variant_id, quantity, unit_price) VALUES (?, ?, ?, ?, ?)");
// Pre-prepare statements for name fetching
$prodNameStmt = $pdo->prepare("SELECT name FROM products WHERE id = ?");
$varNameStmt = $pdo->prepare("SELECT name FROM product_variants WHERE id = ?");
$order_items_list = []; // To store item details for notification
if (!empty($data['items']) && is_array($data['items'])) {
foreach ($data['items'] as $item) {
$pid = $item['product_id'] ?? ($item['id'] ?? null);
@ -131,6 +138,20 @@ try {
if ($pid) {
$item_stmt->execute([$order_id, $pid, $vid, $qty, $price]);
// Fetch names for notification
$prodNameStmt->execute([$pid]);
$pRow = $prodNameStmt->fetch(PDO::FETCH_ASSOC);
$pName = $pRow ? $pRow['name'] : 'Item';
if ($vid) {
$varNameStmt->execute([$vid]);
$vRow = $varNameStmt->fetch(PDO::FETCH_ASSOC);
if ($vRow) {
$pName .= " (" . $vRow['name'] . ")";
}
}
$order_items_list[] = "$qty x $pName";
}
}
}
@ -147,28 +168,58 @@ try {
// Determine message content
$wablas = new WablasService($pdo);
$msg = "Dear *$customer_name*,
";
$msg .= "Thank you for dining with us! 🍽️
";
$msg .= "You've earned *$points_awarded points* with this order.
";
if ($points_deducted > 0) {
$msg .= "You also redeemed *$points_deducted points* for a free meal! 🎁
";
// Fetch Company Name
require_once __DIR__ . '/../includes/functions.php';
$companySettings = get_company_settings();
$company_name = $companySettings['company_name'] ?? 'Flatlogic POS';
// Fetch Template
$stmt = $pdo->prepare("SELECT setting_value FROM integration_settings WHERE provider = 'wablas' AND setting_key = 'order_template'");
$stmt->execute();
$template = $stmt->fetchColumn();
// Default Template if not set
if (!$template) {
$template = "Dear *{customer_name}*,
Thank you for dining with *{company_name}*! 🍽️
*Order Details:*
{order_details}
Total: *{total_amount}* OMR
You've earned *{points_earned} points* with this order.
💰 *Current Balance: {new_balance} points*
{loyalty_status}";
}
$msg .= "
💰 *Current Balance: $final_points points*
";
// Calculate Loyalty Status String
$loyalty_status = "";
if ($final_points >= $points_threshold) {
$msg .= "🎉 Congratulations! You have enough points for a *FREE MEAL* on your next visit!";
$loyalty_status = "🎉 Congratulations! You have enough points for a *FREE MEAL* on your next visit!";
} else {
$needed = $points_threshold - $final_points;
$msg .= "You need *$needed more points* to unlock a free meal.";
$loyalty_status = "You need *$needed more points* to unlock a free meal.";
}
// Prepare replacements
$replacements = [
'{customer_name}' => $customer_name,
'{company_name}' => $company_name,
'{order_id}' => $order_id,
'{order_details}' => !empty($order_items_list) ? implode("\n", $order_items_list) : 'N/A',
'{total_amount}' => number_format($total_amount, 3),
'{points_earned}' => $points_awarded,
'{points_redeemed}' => $points_deducted,
'{new_balance}' => $final_points,
'{loyalty_status}' => $loyalty_status
];
$msg = str_replace(array_keys($replacements), array_values($replacements), $template);
// Send (fire and forget, don't block response on failure, just log)
$res = $wablas->sendMessage($customer_phone, $msg);
if (!$res['success']) {

View File

@ -4,7 +4,10 @@ document.addEventListener('DOMContentLoaded', () => {
const cartTotalPrice = document.getElementById('cart-total-price');
const cartSubtotal = document.getElementById('cart-subtotal');
const cartDiscountInput = document.getElementById('cart-discount-input');
const checkoutBtn = document.getElementById('checkout-btn');
// Updated Button References
const quickOrderBtn = document.getElementById('quick-order-btn');
const placeOrderBtn = document.getElementById('place-order-btn');
// Loyalty State
let isLoyaltyRedemption = false;
@ -176,10 +179,6 @@ document.addEventListener('DOMContentLoaded', () => {
// Hide Loyalty
if (loyaltySection) loyaltySection.classList.add('d-none');
isLoyaltyRedemption = false;
// If we had a discount applied via loyalty, remove it?
// Better to just let the user manually adjust if they want, but technically if it was 100% off due to loyalty, we should revert.
// But tracking "discount source" is complex. For now, let's just leave discount as is or reset it if it equals total?
// Safer to reset discount to 0.
cartDiscountInput.value = 0;
updateCart();
@ -322,15 +321,11 @@ document.addEventListener('DOMContentLoaded', () => {
list.innerHTML = '';
// Add "Base" option if needed, but usually variants are mandatory if they exist.
// Assuming mandatory for now or include a base option.
variants.forEach(v => {
const btn = document.createElement('button');
btn.className = 'list-group-item list-group-item-action d-flex justify-content-between align-items-center';
const adj = parseFloat(v.price_adjustment);
const sign = adj > 0 ? '+' : '';
const priceText = adj !== 0 ? `${sign}${formatCurrency(adj)}` : '';
const finalPrice = product.base_price + adj;
btn.innerHTML = `
@ -351,28 +346,13 @@ document.addEventListener('DOMContentLoaded', () => {
}
function addToCart(product) {
// Unique ID for cart item is ProductID + VariantID
const existing = cart.find(item => item.id === product.id && item.variant_id === product.variant_id);
if (existing) {
existing.quantity++;
} else {
// Clone to avoid reference issues
cart.push({...product});
}
updateCart();
// If loyalty was applied, maybe re-apply or warn?
// Simple: If loyalty applied, update discount to match new total?
if (isLoyaltyRedemption) {
// Re-calculate full discount
// Wait, updateCart is called below.
// We need to re-calc discount after update.
// But updateCart uses discount input value.
// Let's just reset loyalty on cart change? Or re-apply 100% discount.
// Better: Reset loyalty on cart modification to avoid confusion?
// Or keep it simple: Just update discount.
}
}
window.changeQuantity = function(index, delta) {
@ -395,7 +375,8 @@ document.addEventListener('DOMContentLoaded', () => {
</div>`;
cartSubtotal.innerText = formatCurrency(0);
cartTotalPrice.innerText = formatCurrency(0);
checkoutBtn.disabled = true;
if (quickOrderBtn) quickOrderBtn.disabled = true;
if (placeOrderBtn) placeOrderBtn.disabled = true;
return;
}
@ -431,10 +412,7 @@ document.addEventListener('DOMContentLoaded', () => {
cartSubtotal.innerText = formatCurrency(subtotal);
// Discount
let discount = parseFloat(cartDiscountInput.value) || 0;
// Auto-update discount if loyalty active
if (isLoyaltyRedemption) {
discount = subtotal;
cartDiscountInput.value = subtotal.toFixed(2);
@ -444,15 +422,12 @@ document.addEventListener('DOMContentLoaded', () => {
if (total < 0) total = 0;
cartTotalPrice.innerText = formatCurrency(total);
checkoutBtn.disabled = false;
if (quickOrderBtn) quickOrderBtn.disabled = false;
if (placeOrderBtn) placeOrderBtn.disabled = false;
}
if (cartDiscountInput) {
cartDiscountInput.addEventListener('input', () => {
// If user manually changes discount, maybe disable loyalty flag?
// But if they just tweak it, it's fine.
// Ideally, if they lower it, loyalty flag might still be true but that's weird.
// Let's assume manual input overrides auto-loyalty.
updateCart();
});
}
@ -493,29 +468,45 @@ document.addEventListener('DOMContentLoaded', () => {
return 'bi-wallet2';
}
// Initialize Payment Methods
renderPaymentMethods();
// --- Checkout Flow ---
checkoutBtn.addEventListener('click', () => {
if (cart.length === 0) return;
// --- Checkout Flow (Quick Order) ---
function validateOrder() {
if (cart.length === 0) return false;
const orderTypeInput = document.querySelector('input[name="order_type"]:checked');
const orderType = orderTypeInput ? orderTypeInput.value : 'dine-in';
const orderType = orderTypeInput ? orderTypeInput.value : 'takeaway';
if (orderType === 'dine-in' && !currentTableId) {
showToast('Please select a table first', 'warning');
openTableSelectionModal();
return;
return false;
}
return true;
}
// Open Payment Modal instead of direct submission
if (quickOrderBtn) {
quickOrderBtn.addEventListener('click', () => {
if (validateOrder()) {
paymentSelectionModal.show();
}
});
}
// --- Place Order (Pay Later) Flow ---
if (placeOrderBtn) {
placeOrderBtn.addEventListener('click', () => {
if (validateOrder()) {
if (confirm("Place order without immediate payment?")) {
processOrder(null, 'Pay Later');
}
}
});
}
window.processOrder = function(paymentTypeId, paymentTypeName) {
const orderTypeInput = document.querySelector('input[name="order_type"]:checked');
const orderType = orderTypeInput ? orderTypeInput.value : 'dine-in';
const orderType = orderTypeInput ? orderTypeInput.value : 'takeaway';
const subtotal = cart.reduce((acc, item) => acc + (item.price * item.quantity), 0);
const discount = parseFloat(cartDiscountInput.value) || 0;
const totalAmount = Math.max(0, subtotal - discount);
@ -529,7 +520,7 @@ document.addEventListener('DOMContentLoaded', () => {
payment_type_id: paymentTypeId,
total_amount: totalAmount,
discount: discount,
redeem_loyalty: isLoyaltyRedemption, // Send flag
redeem_loyalty: isLoyaltyRedemption,
items: cart.map(item => ({
product_id: item.id,
quantity: item.quantity,
@ -538,9 +529,13 @@ document.addEventListener('DOMContentLoaded', () => {
}))
};
// Disable all payment buttons
// Disable buttons
if (paymentMethodsContainer) {
const btns = paymentMethodsContainer.querySelectorAll('button');
btns.forEach(b => b.disabled = true);
}
if (quickOrderBtn) quickOrderBtn.disabled = true;
if (placeOrderBtn) placeOrderBtn.disabled = true;
fetch('api/order.php', {
method: 'POST',
@ -549,7 +544,13 @@ document.addEventListener('DOMContentLoaded', () => {
})
.then(res => res.json())
.then(data => {
if (paymentMethodsContainer) {
const btns = paymentMethodsContainer.querySelectorAll('button');
btns.forEach(b => b.disabled = false);
}
if (quickOrderBtn) quickOrderBtn.disabled = false;
if (placeOrderBtn) placeOrderBtn.disabled = false;
paymentSelectionModal.hide();
if (data.success) {
@ -578,7 +579,13 @@ document.addEventListener('DOMContentLoaded', () => {
}
})
.catch(err => {
if (paymentMethodsContainer) {
const btns = paymentMethodsContainer.querySelectorAll('button');
btns.forEach(b => b.disabled = false);
}
if (quickOrderBtn) quickOrderBtn.disabled = false;
if (placeOrderBtn) placeOrderBtn.disabled = false;
paymentSelectionModal.hide();
showToast('Network Error', 'danger');
});

View File

@ -47,15 +47,27 @@ class WablasService {
return ['success' => false, 'message' => 'Connection failed: ' . $curlError];
}
if ($httpCode >= 200 && $httpCode < 300) {
$data = json_decode($response, true);
if ($httpCode >= 200 && $httpCode < 300) {
// Some APIs return 200 even on logical error, check for status field if exists
if (isset($data['status']) && $data['status'] === false) {
return ['success' => false, 'message' => 'API Error: ' . ($data['message'] ?? 'Unknown error')];
}
// Check for explicit disconnected status if available in data
if (isset($data['data']['status']) && $data['data']['status'] !== 'connected') {
return ['success' => false, 'message' => 'Device Status: ' . $data['data']['status'] . ' (May need re-scan)'];
}
return ['success' => true, 'message' => 'Connection successful! Device info retrieved.'];
}
// Handle error responses with JSON message
if ($data && isset($data['message'])) {
return ['success' => false, 'message' => 'Provider Error (' . $httpCode . '): ' . $data['message']];
}
return ['success' => false, 'message' => 'HTTP Error: ' . $httpCode];
}
@ -64,6 +76,14 @@ class WablasService {
return ['success' => false, 'message' => 'Wablas configuration missing'];
}
// Format phone number: ensure 968 country code if only 8 digits provided
$cleanPhone = preg_replace('/[^0-9]/', '', $phone);
if (strlen($cleanPhone) === 8) {
$phone = '968' . $cleanPhone;
} else {
$phone = $cleanPhone;
}
$payload = [
'phone' => $phone,
'message' => $message,

21
pos.php
View File

@ -18,7 +18,7 @@ foreach ($variants_raw as $v) {
$table_id = $_GET['table'] ?? '1'; // Default table
$outlet_id = isset($_GET['outlet_id']) ? (int)$_GET['outlet_id'] : 1;
$settings = get_company_settings();
$order_type = $_GET['order_type'] ?? 'dine-in';
$order_type = $_GET['order_type'] ?? 'takeaway';
?>
<!doctype html>
<html lang="en">
@ -62,6 +62,10 @@ $order_type = $_GET['order_type'] ?? 'dine-in';
</a>
<div class="d-flex align-items-center gap-3">
<a href="kitchen.php" class="btn btn-sm btn-outline-secondary">Kitchen View</a>
<a href="pos.php?order_type=dine-in" class="btn btn-sm btn-outline-secondary">Waiter View</a>
<a href="admin/orders.php" class="btn btn-sm btn-outline-secondary">Current Orders</a>
<div id="current-table-display" class="badge bg-light text-dark border px-3 py-2" style="display: none; font-size: 0.9rem;">
Table <?= htmlspecialchars($table_id) ?>
</div>
@ -162,12 +166,12 @@ $order_type = $_GET['order_type'] ?? 'dine-in';
<div class="p-3 border-bottom bg-white">
<!-- Order Type -->
<div class="btn-group w-100 mb-3" role="group">
<input type="radio" class="btn-check" name="order_type" id="ot-dine-in" value="dine-in" <?= $order_type === 'dine-in' ? 'checked' : '' ?>>
<label class="btn btn-outline-primary btn-sm" for="ot-dine-in">Dine-In</label>
<input type="radio" class="btn-check" name="order_type" id="ot-takeaway" value="takeaway" <?= $order_type === 'takeaway' ? 'checked' : '' ?>>
<label class="btn btn-outline-primary btn-sm" for="ot-takeaway">Takeaway</label>
<input type="radio" class="btn-check" name="order_type" id="ot-dine-in" value="dine-in" <?= $order_type === 'dine-in' ? 'checked' : '' ?>>
<label class="btn btn-outline-primary btn-sm" for="ot-dine-in">Dine-In</label>
<input type="radio" class="btn-check" name="order_type" id="ot-delivery" value="delivery" <?= $order_type === 'delivery' ? 'checked' : '' ?>>
<label class="btn btn-outline-primary btn-sm" for="ot-delivery">Delivery</label>
</div>
@ -233,9 +237,14 @@ $order_type = $_GET['order_type'] ?? 'dine-in';
<span class="fs-5 fw-bold">Total</span>
<span class="fs-4 fw-bold text-primary" id="cart-total-price"><?= format_currency(0) ?></span>
</div>
<button class="btn btn-primary w-100 btn-lg shadow-sm" id="checkout-btn" disabled>
Place Order <i class="bi bi-arrow-right ms-2"></i>
<div class="d-flex gap-2 w-100">
<button class="btn btn-primary w-50 btn-lg shadow-sm" id="quick-order-btn" disabled>
Quick Order <i class="bi bi-lightning-fill ms-2"></i>
</button>
<button class="btn btn-warning w-50 btn-lg shadow-sm text-white" id="place-order-btn" disabled>
Place Order <i class="bi bi-clock ms-2"></i>
</button>
</div>
</div>
</div>