Edit file File name : index.php Content :<?php require 'db_config.php'; // Dynamically set session cookie path for this business's folder $cookie_path = rtrim(dirname($_SERVER['PHP_SELF']), '/\\') . '/'; session_set_cookie_params(0, $cookie_path); session_start(); // Pre-fetch settings for meta tags $og_title = 'Omni Rewards'; $og_description = 'Loyalty Reward System'; $og_image = ''; // Default image URL $og_favicon = ''; // Default favicon $og_primary_color_hsl = '262 83% 57%'; // Default purple $og_background_color_hsl = '240 60% 99%'; $og_accent_color_hsl = '220 14% 96%'; try { $conn_meta = new mysqli($servername, $username, $password, $dbname); if (!$conn_meta->connect_error) { $result = $conn_meta->query("SELECT businessName, logoUrl, faviconUrl, primaryColor, backgroundColor, accentColor FROM business_settings LIMIT 1"); if ($result && $result->num_rows > 0) { $settings_meta = $result->fetch_assoc(); $og_title = htmlspecialchars($settings_meta['businessName'] ?: $og_title); $base_url = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http") . "://$_SERVER[HTTP_HOST]/"; if (!empty($settings_meta['logoUrl'])) { $og_image = $base_url . htmlspecialchars($settings_meta['logoUrl']); } if (!empty($settings_meta['faviconUrl'])) { $og_favicon = $base_url . htmlspecialchars($settings_meta['faviconUrl']); } if (!empty($settings_meta['primaryColor'])) { $og_primary_color_hsl = htmlspecialchars($settings_meta['primaryColor']); } if (!empty($settings_meta['backgroundColor'])) { $og_background_color_hsl = htmlspecialchars($settings_meta['backgroundColor']); } if (!empty($settings_meta['accentColor'])) { $og_accent_color_hsl = htmlspecialchars($settings_meta['accentColor']); } } $conn_meta->close(); } } catch (Exception $e) { // Silently fail, defaults will be used } ?> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Omni Rewards</title> <meta name="description" content="<?php echo $og_description; ?>"> <link rel="manifest" href="manifest.php"> <link id="favicon-link" rel="icon" href="<?php echo $og_favicon; ?>"> <script src="https://cdn.tailwindcss.com"></script> <script src="https://unpkg.com/html5-qrcode"></script> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> <link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&family=Lexend:wght@700&display=swap" rel="stylesheet"> <script src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.3/dist/confetti.browser.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.2/dist/chart.umd.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.5.23/jspdf.plugin.autotable.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/color-thief/2.3.0/color-thief.umd.js"></script> <style> body { font-family: 'Inter', sans-serif; } .font-headline { font-family: 'Lexend', sans-serif; } :root { --primary-hsl: <?php echo $og_primary_color_hsl; ?>; --background-hsl: <?php echo $og_background_color_hsl; ?>; --accent-hsl: <?php echo $og_accent_color_hsl; ?>; } .bg-primary-gradient { background: linear-gradient(to bottom right, hsl(var(--primary-hsl)), hsl(var(--primary-hsl) / 0.8)); } .bg-primary { background-color: hsl(var(--primary-hsl)); } .text-primary { color: hsl(var(--primary-hsl)); } .border-primary { border-color: hsl(var(--primary-hsl)); } .ring-primary:focus-visible { --tw-ring-color: hsl(var(--primary-hsl)); } .bg-app-background { background-color: hsl(var(--background-hsl)); } .bg-app-accent { background-color: hsl(var(--accent-hsl)); } .modal-container { transition: opacity 0.3s ease; } .notification-container { position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%); z-index: 1000; } .dropdown-menu { z-index: 50; } /* Premium Gift Card Background - Diagonal Vignette */ .sleek-card-bg { background-color: hsl(var(--primary-hsl)); background-image: linear-gradient(135deg, rgba(0,0,0,0.18) 0%, transparent 40%, transparent 60%, rgba(0,0,0,0.08) 100%), radial-gradient(circle at 0% 0%, rgba(0,0,0,0.08) 0%, transparent 50%); position: relative; overflow: hidden; border: 1px solid rgba(255,255,255,0.1); } .card-wave { position: absolute; bottom: 0; left: 0; width: 100%; height: 100%; background: radial-gradient(circle at 100% 100%, rgba(255,255,255,0.05) 0%, transparent 50%), radial-gradient(circle at 0% 0%, rgba(255,255,255,0.02) 0%, transparent 40%); pointer-events: none; } /* Metallic Gold Styles */ .metallic-gold { background: linear-gradient(135deg, #bf953f 0%, #fcf6ba 25%, #b38728 50%, #fbf5b7 75%, #aa771c 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; filter: drop-shadow(0 2px 2px rgba(0,0,0,0.4)); } .metallic-gold-fill { fill: url(#gold-gradient); filter: drop-shadow(0 2px 2px rgba(0,0,0,0.4)); } .custom-scrollbar::-webkit-scrollbar { width: 4px; } .custom-scrollbar::-webkit-scrollbar-track { background: #f1f1f1; } .custom-scrollbar::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 10px; } .custom-scrollbar::-webkit-scrollbar-thumb:hover { background: #94a3b8; } </style> </head> <body class="bg-app-background text-slate-800 flex flex-col min-h-screen"> <!-- SVG Defs for Metallic Gold --> <svg width="0" height="0" class="absolute"> <defs> <linearGradient id="gold-gradient" x1="0%" y1="0%" x2="100%" y2="100%"> <stop offset="0%" style="stop-color:#bf953f;stop-opacity:1" /> <stop offset="25%" style="stop-color:#fcf6ba;stop-opacity:1" /> <stop offset="50%" style="stop-color:#b38728;stop-opacity:1" /> <stop offset="75%" style="stop-color:#fbf5b7;stop-opacity:1" /> <stop offset="100%" style="stop-color:#aa771c;stop-opacity:1" /> </linearGradient> </defs> </svg> <!-- Login Page --> <div id="login-page" class="hidden flex-grow flex flex-col items-center justify-center p-4" style="background-image: radial-gradient(circle at top, hsl(var(--primary-hsl) / 0.05), transparent 40%)"> <div class="text-center mb-8 flex flex-col items-center gap-2"> <div class="logo-placeholder w-20 h-20 bg-primary/10 rounded-full flex items-center justify-center"> <i class="fas fa-star text-3xl text-primary"></i> </div> <div class="flex flex-col"> <h1 id="login-business-name" class="text-5xl font-headline font-bold text-slate-800">Omni Rewards</h1> <p class="text-slate-500">Loyalty Reward System</p> </div> </div> <div id="login-tabs" class="w-full max-w-md mb-8"> <div class="shadow-2xl rounded-2xl bg-white overflow-hidden"> <div class="text-center p-6 bg-white"> <h2 class="text-3xl font-bold text-primary font-headline">Welcome Back</h2> <p class="text-sm text-slate-500">Sign in to your account</p> </div> <div class="px-6 pb-6"> <div class="grid w-full grid-cols-2 bg-slate-100 p-1 rounded-full mb-6"> <button id="customer-tab" class="tab-trigger inline-flex items-center justify-center whitespace-nowrap rounded-full px-3 py-1.5 text-sm font-medium transition-all"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4 mr-2"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg> Customer </button> <button id="admin-tab" class="tab-trigger inline-flex items-center justify-center whitespace-nowrap rounded-full px-3 py-1.5 text-sm font-medium transition-all"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4 mr-2"><rect width="18" height="18" x="3" y="4" rx="2" ry="2"/><line x1="16" x2="16" y1="2" y2="4"/><line x1="8" x2="8" y1="2" y2="4"/><line x1="3" x2="21" y1="10" y2="10"/></svg> Business </button> </div> <div id="customer-form" class="tab-content"> <form id="customer-login-form"> <input type="hidden" name="action" value="login"> <input type="hidden" name="type" value="customer"> <div id="customer-login-error" class="hidden mb-4 p-3 bg-red-50 text-red-600 rounded-md text-sm"></div> <div class="space-y-6"> <div class="space-y-2 text-left"> <label for="customer-phone" class="text-sm font-medium text-slate-700 font-bold">Phone Number</label> <input id="customer-phone" name="customer_phone" type="tel" placeholder="+501-xxx-xxxx" required class="flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-base ring-offset-white focus-visible:outline-none focus-visible:ring-2 ring-primary transition-all"> </div> <div class="space-y-2 text-left"> <label for="customer-password" class="text-sm font-medium text-slate-700 font-bold">Password</label> <input id="customer-password" name="customer_password" type="password" placeholder="Enter password" required class="flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-base ring-offset-white focus-visible:outline-none focus-visible:ring-2 ring-primary transition-all"> </div> </div> <button type="submit" class="w-full mt-8 text-base bg-primary text-white h-10 px-4 py-2 inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium hover:bg-opacity-90 transition-all active:scale-95 shadow-sm"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4 mr-2"><line x1="5" x2="19" y1="12" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Sign In as Customer </button> </form> </div> <div id="admin-form" class="tab-content hidden"> <form id="admin-login-form"> <input type="hidden" name="action" value="login"> <input type="hidden" name="type" value="admin"> <div id="admin-login-error" class="hidden mb-4 p-3 bg-red-50 text-red-600 rounded-md text-sm"></div> <div class="space-y-6"> <div class="space-y-2 text-left"> <label for="admin-username" class="text-sm font-medium text-slate-700 font-bold">Username</label> <input id="admin-username" name="admin_username" type="text" placeholder="Enter username" required class="flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-base ring-offset-white focus-visible:outline-none focus-visible:ring-2 ring-primary transition-all"> </div> <div class="space-y-2 text-left"> <label for="admin-password" class="text-sm font-medium text-slate-700 font-bold">Password</label> <input id="admin-password" name="admin_password" type="password" placeholder="Enter password" required class="flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-base ring-offset-white focus-visible:outline-none focus-visible:ring-2 ring-primary transition-all"> </div> </div> <button type="submit" class="w-full mt-8 text-base bg-primary text-white h-10 px-4 py-2 inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium hover:bg-opacity-90 transition-all active:scale-95 shadow-sm"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4 mr-2"><line x1="5" x2="19" y1="12" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Sign In as Business </button> </form> </div> <!-- Easy Biometric Sign-in --> <div id="biometric-login-container" class="hidden mt-6 pt-6 border-t border-slate-100 text-center"> <button id="biometric-login-btn" class="flex items-center justify-center gap-3 w-full py-3.5 px-4 rounded-xl border-2 border-primary text-primary font-bold hover:bg-primary/5 transition-all active:scale-95 shadow-sm"> <i class="fas fa-face-smile text-xl"></i> <span>Sign in with Biometrics</span> </button> <p class="text-[10px] text-slate-400 mt-2 uppercase tracking-widest font-bold">Secure Easy Access</p> </div> <div class="text-center mt-6 text-sm"> <span class="text-slate-600">Not a member?</span> <button id="signup-now-btn" class="font-medium text-primary hover:opacity-80 transition-opacity">Signup now</button> </div> </div> </div> </div> </div> <div id="admin-dashboard" class="hidden flex-grow flex flex-col"> <main class="container mx-auto p-4 md:p-8"> <header class="flex flex-col items-center gap-8 mb-8"> <div class="flex items-center gap-4"> <div id="admin-logo-container" class="logo-placeholder w-20 h-20 bg-primary/10 rounded-full flex items-center justify-center"> <i class="fas fa-star text-3xl text-primary"></i> </div> <div class="text-left"> <h1 id="admin-business-name" class="text-4xl font-bold font-headline text-slate-800">Omni Rewards</h1> <p class="text-slate-500 mt-1">Manage your loyalty program</p> </div> </div> <div class="hidden md:flex flex-nowrap justify-center items-center gap-2"> <button id="qr-scanner-btn" class="bg-green-500 hover:bg-green-600 text-white font-medium py-2 px-4 rounded-md inline-flex items-center gap-2 border border-green-600 transition-all active:scale-95 shadow-sm"> <i class="fas fa-plus-circle"></i> Add Point </button> <button id="add-gift-card-btn" class="bg-amber-500 hover:bg-amber-600 text-white font-medium py-2 px-4 rounded-md inline-flex items-center gap-2 border border-amber-600 transition-all active:scale-95 shadow-sm"> <i class="fas fa-gift"></i> Create Gift Card </button> <button id="settings-btn" class="bg-white hover:bg-slate-100 text-slate-700 font-medium py-2 px-4 rounded-md inline-flex items-center gap-2 border border-slate-200 transition-all active:scale-95 shadow-sm"> <i class="fas fa-cog"></i> Settings </button> <button id="logout-btn" class="bg-white hover:bg-slate-100 text-slate-700 font-medium py-2 px-4 rounded-md inline-flex items-center gap-2 border border-slate-200 transition-all active:scale-95 shadow-sm"> <i class="fas fa-sign-out-alt"></i> Logout </button> </div> </header> <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8"> <div class="p-6 rounded-2xl text-white shadow-lg bg-gradient-to-br from-blue-500 to-blue-600"> <div class="flex justify-between items-start mb-4"><h3 class="font-semibold text-lg">Total Customers</h3><i class="fas fa-users text-2xl opacity-80"></i></div> <p id="total-customers" class="text-5xl font-bold font-headline">0</p> <p class="text-sm opacity-90 mt-1">Registered users</p> </div> <div class="p-6 rounded-2xl text-white shadow-lg bg-primary-gradient"> <div class="flex justify-between items-start mb-4"><h3 class="font-semibold text-lg">Points This Month</h3><i class="fas fa-chart-line text-2xl opacity-80"></i></div> <p id="points-this-month" class="text-5xl font-bold font-headline">0</p> <p class="text-sm opacity-90 mt-1">Earned this month</p> </div> <div class="p-6 rounded-2xl text-white shadow-lg bg-gradient-to-br from-amber-500 to-orange-600"> <div class="flex justify-between items-start mb-4"><h3 class="font-semibold text-lg">Total Points Distributed</h3><i class="fas fa-star text-2xl opacity-80"></i></div> <p id="total-points" class="text-5xl font-bold font-headline">0</p> <p class="text-sm opacity-90 mt-1">Since program start</p> </div> <div class="p-6 rounded-2xl text-white shadow-lg bg-gradient-to-br from-emerald-500 to-green-600"> <div class="flex justify-between items-start mb-4"><h3 class="font-semibold text-lg">Rewards Claimed</h3><i class="fas fa-gift text-2xl opacity-80"></i></div> <p id="rewards-claimed" class="text-5xl font-bold font-headline">0</p> <p class="text-sm opacity-90 mt-1">Happy customers</p> </div> </div> <div id="admin-tabs-container" class="mt-8"> <div class="border-b border-gray-200"> <nav class="-mb-px flex space-x-6" aria-label="Tabs"> <button class="admin-tab-btn whitespace-nowrap py-3 px-1 border-b-2 font-medium text-sm border-primary text-primary transition-all" data-tab="all-customers"> All Customers </button> <button class="admin-tab-btn whitespace-nowrap py-3 px-1 border-b-2 font-medium text-sm border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 transition-all" data-tab="activity"> Monthly Activity </button> <button class="admin-tab-btn whitespace-nowrap py-3 px-1 border-b-2 font-medium text-sm border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 transition-all" data-tab="reports"> Reports </button> </nav> </div> <div id="all-customers-tab-content" class="admin-tab-content py-4"> <div class="flex flex-col sm:flex-row items-center justify-between gap-4 my-6"> <div class="relative w-full sm:max-w-xs"> <i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-slate-400"></i> <input type="text" id="customer-search" class="w-full pl-10 h-10 px-3 py-2 border border-slate-200 bg-white rounded-md focus:outline-none focus-visible:ring-2 ring-primary transition-all" placeholder="Search customers..."> </div> <button id="add-customer-btn" class="w-full sm:w-auto px-4 py-2 bg-primary text-white rounded-md text-sm hover:bg-opacity-90 transition-all active:scale-95 flex items-center justify-center gap-2 shadow-sm"> <i class="fas fa-user-plus"></i> Add Customer </button> </div> <div id="customer-display-area" class="space-y-12"> <!-- Customer cards and table will be rendered here by JavaScript --> </div> </div> <div id="activity-tab-content" class="admin-tab-content hidden py-4"> <div class="bg-white rounded-lg shadow p-4 md:p-6 border border-slate-100"> <h3 class="text-xl font-bold mb-1">Monthly Points Activity</h3> <p class="text-sm text-gray-500 mb-4">(Double-click to zoom)</p> <div class="min-h-[350px] w-full"> <canvas id="activity-chart"></canvas> </div> </div> </div> <div id="reports-tab-content" class="admin-tab-content hidden py-4"> <div class="bg-white rounded-lg shadow p-4 md:p-6 border border-slate-100"> <h3 class="text-xl font-bold mb-4">Generate Reports</h3> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 items-end"> <div> <label for="report-type" class="block text-sm font-medium text-gray-700 mb-1">Report Type</label> <select id="report-type" class="w-full p-2 border border-gray-300 rounded-md focus:ring-2 ring-primary outline-none transition-all"> <option value="monthly_points">Total Points for the Month</option> <option value="yearly_points">All Points for the Year</option> <option value="points_by_customer">Points by Single Customer</option> </select> </div> <div id="customer-selector-container" class="hidden relative"> <label for="report-customer-search" class="block text-sm font-medium text-gray-700 mb-1">Select Customer</label> <input type="text" id="report-customer-search" class="w-full p-2 border border-gray-300 rounded-md focus:ring-2 ring-primary outline-none transition-all" placeholder="Search for customer..."> <input type="hidden" id="report-customer-id"> <div id="report-search-results" class="absolute z-10 w-full bg-white border border-gray-300 rounded-md mt-1 max-h-48 overflow-y-auto hidden custom-scrollbar shadow-lg"></div> </div> <div id="date-range-selector" class="grid grid-cols-1 sm:grid-cols-2 gap-4 col-span-1 lg:col-span-1"> <div> <label for="start-date" class="block text-sm font-medium text-gray-700 mb-1">Start Date</label> <input type="date" id="start-date" class="w-full p-2 border border-gray-300 rounded-md focus:ring-2 ring-primary outline-none transition-all"> </div> <div> <label for="end-date" class="block text-sm font-medium text-gray-700 mb-1">End Date</label> <input type="date" id="end-date" class="w-full p-2 border border-gray-300 rounded-md focus:ring-2 ring-primary outline-none transition-all"> </div> </div> </div> <div class="mt-6"> <button id="generate-pdf-btn" class="bg-primary text-white font-medium py-2.5 px-6 rounded-md inline-flex items-center gap-2 hover:bg-opacity-90 transition-all active:scale-95 shadow-sm"> <i class="fas fa-file-pdf"></i> Generate PDF </button> </div> </div> </div> </div> </main> <!-- Mobile Footer Nav for Admin --> <div id="admin-mobile-footer" class="md:hidden sticky bottom-0 left-0 right-0 bg-white border-t border-gray-200 p-2 flex justify-around items-center h-16 text-nowrap z-40"> <button id="mobile-settings-btn" class="flex flex-col items-center text-gray-600 hover:text-primary text-sm w-1/3 transition-colors"> <i class="fas fa-cog text-xl"></i> <span class="mt-1">Settings</span> </button> <button id="mobile-add-point-btn" class="flex flex-col items-center text-green-600 text-sm font-medium w-1/3 transition-all"> <div class="w-16 h-16 flex items-center justify-center bg-green-500 text-white rounded-full shadow-lg -mt-8 border-4 border-white active:scale-95"> <i class="fas fa-plus text-2xl"></i> </div> <span class="mt-1 text-nowrap">Add Point</span> </button> <button id="mobile-logout-btn" class="flex flex-col items-center text-gray-600 hover:text-primary text-sm w-1/3 transition-colors"> <i class="fas fa-sign-out-alt text-xl"></i> <span class="mt-1">Logout</span> </button> </div> </div> <div id="customer-dashboard" class="hidden flex-grow flex flex-col"> <div class="container mx-auto p-4 md:p-8 flex-grow"> <!-- This container will be filled by renderCustomerDashboard() --> </div> </div> <!-- MODALS CONTAINER --> <div id="modals-container"></div> <!-- NOTIFICATION CONTAINER --> <div class="notification-container"> <div id="success-notification" class="hidden mb-2 transition-all transform animate-bounce"><div class="bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg flex items-center"><i class="fas fa-check-circle mr-3"></i><span id="success-message"></span></div></div> <div id="error-notification" class="hidden transition-all transform"><div class="bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg flex items-center"><i class="fas fa-exclamation-circle mr-3"></i><span id="error-message"></span></div></div> </div> <!-- FOOTER --> <footer id="app-footer" class="py-4 px-2 text-center text-gray-600 text-sm border-t border-gray-100 mt-auto bg-white/50 backdrop-blur-sm"> <div class="flex flex-col sm:flex-row sm:gap-2 justify-center items-center"> <span>Copyright © <span id="current-year"></span> <span id="business-name-footer"></span>. All Rights Reserved</span> <span class="hidden sm:inline">|</span> <span>Developed by Omni Studio</span> </div> </footer> <script> document.addEventListener('DOMContentLoaded', () => { const API_ENDPOINT = 'api.php'; let state = { customers: [], admins: [], settings: { businessName: 'Omni Rewards', logoUrl: '', faviconUrl: '', primaryColor: '262 83% 57%', backgroundColor: '240 60% 99%', accentColor: '220 14% 96%' }, stats: { totalCustomers: 0, pointsThisMonth: 0, totalPoints: 0, rewardsClaimed: 0 }, currentUser: null, userType: null, currentPage: 1, giftCards: [], }; let sessionData = { hasSeenConfettiForRewards: 0, pollingInterval: null }; const CUSTOMERS_PER_PAGE = 10; let activeScanner = null; let activeModal = null; let activityChart = null; // --- CORE API & STATE FUNCTIONS --- async function apiCall(formData) { try { const response = await fetch(API_ENDPOINT, { method: 'POST', body: formData }); const isSessionCheck = formData.get('action') === 'check_session'; if (!response.ok) { if (isSessionCheck && response.status === 401) { return { success: false, message: 'No active session' }; } const result = await response.json().catch(() => ({ message: `Server error: ${response.statusText}` })); throw new Error(result.message || `Server responded with status: ${response.status}`); } const result = await response.json(); if (!result.success) { if (isSessionCheck) { return { success: false, message: result.message }; } throw new Error(result.message || 'An unknown API error occurred.'); } return result; } catch (error) { const isSessionCheck = formData.get('action') === 'check_session'; if (!isSessionCheck) { console.error("API Call Error:", error); showError(error.message); } return null; } } const Biometric = { isSupported: () => window.PublicKeyCredential !== undefined, async isAvailable() { if (!this.isSupported()) return false; try { return await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); } catch(e) { return false; } }, async enable() { if (!await this.isAvailable()) { showError("Biometric authentication is not available on this device/browser."); return false; } // WebAuthn registration (simplified trusted-token approach) const challenge = new Uint8Array(32); window.crypto.getRandomValues(challenge); const userId = state.currentUser.id; const userName = state.currentUser.username || state.currentUser.phone; const createOptions = { publicKey: { challenge: challenge, rp: { name: state.settings.businessName || "Omni Rewards" }, user: { id: Uint8Array.from(userId, c => c.charCodeAt(0)), name: userName, displayName: state.currentUser.name }, pubKeyCredParams: [{ alg: -7, type: "public-key" }], authenticatorSelection: { authenticatorAttachment: "platform", userVerification: "required" }, timeout: 60000 } }; try { const credential = await navigator.credentials.create(createOptions); if (credential) { const fd = new FormData(); fd.append('action', 'enable_biometric'); const res = await apiCall(fd); if (res && res.success) { localStorage.setItem('omni-biometric-token', res.data.token); localStorage.setItem('omni-biometric-user-id', userId); localStorage.setItem('omni-biometric-user-type', state.userType); showSuccess("Biometric login enabled successfully!"); return true; } } } catch (err) { console.error("Biometric Setup Error:", err); if (err.name !== 'NotAllowedError') { showError("Failed to setup biometric login."); } } return false; }, async login() { const token = localStorage.getItem('omni-biometric-token'); const userId = localStorage.getItem('omni-biometric-user-id'); const userType = localStorage.getItem('omni-biometric-user-type'); if (!token || !userId) { showError("Biometric login is not set up on this device."); return; } const challenge = new Uint8Array(32); window.crypto.getRandomValues(challenge); const getOptions = { publicKey: { challenge: challenge, timeout: 60000, userVerification: "required" } }; try { const assertion = await navigator.credentials.get(getOptions); if (assertion) { const fd = new FormData(); fd.append('action', 'login_biometric'); fd.append('token', token); fd.append('userId', userId); fd.append('userType', userType); const result = await apiCall(fd); if (result && result.success) { state.currentUser = result.data.user; state.userType = result.data.userType; if (state.userType === 'customer') { localStorage.setItem('omni-cached-customer', JSON.stringify(result.data.user)); sessionData.hasSeenConfettiForRewards = result.data.user.rewards || 0; } await refreshData(true); showPage(state.userType === 'admin' ? 'admin-dashboard' : 'customer-dashboard'); showSuccess(`Welcome back, ${state.currentUser.name}!`); } } } catch (err) { console.error("Biometric Login Error:", err); if (err.name !== 'NotAllowedError') { showError("Biometric authentication failed."); } } } }; async function refreshData(force = false) { if (!navigator.onLine && !force) { console.log("Offline, skipping data refresh."); return false; } const formData = new FormData(); formData.append('action', 'getData'); const result = await apiCall(formData); if (result && result.data) { state.customers = result.data.customers || []; state.admins = result.data.admins || []; state.settings = result.data.settings || { businessName: 'Omni Rewards', logoUrl: '', faviconUrl: '', primaryColor: '262 83% 57%', backgroundColor: '240 60% 99%', accentColor: '220 14% 96%' }; state.stats = result.data.stats || { totalCustomers: 0, pointsThisMonth: 0, totalPoints: 0, rewardsClaimed: 0 }; // Also fetch gift cards for current user if (state.currentUser && state.userType === 'customer') { const gcFormData = new FormData(); gcFormData.append('action', 'get_gift_cards'); gcFormData.append('customer_id', state.currentUser.id); const gcResult = await apiCall(gcFormData); if (gcResult && gcResult.success) { state.giftCards = gcResult.data; } } window.dispatchEvent(new Event('statechange')); return true; } return false; } // --- NOTIFICATIONS & MODALS --- const modalsContainer = document.getElementById('modals-container'); const handleEscapeKey = (e) => { if (e.key === 'Escape' && activeModal) { // Find if the active modal has the persistent flag const modalContent = activeModal.querySelector('.modal-content'); if (modalContent && modalContent.dataset.persistent === 'true') { return; // Do not close if persistent } closeModal(activeModal); } }; function openModal(htmlContent, options = {}) { if (activeModal) closeModal(activeModal); const modalWrapper = document.createElement('div'); modalWrapper.innerHTML = htmlContent; modalsContainer.appendChild(modalWrapper); activeModal = modalWrapper; document.addEventListener('keydown', handleEscapeKey); const modalElement = modalWrapper.querySelector('.modal-container'); const modalContent = modalWrapper.querySelector('.modal-content'); const modalOverlay = modalWrapper.querySelector('.modal-overlay'); const closeBtn = modalWrapper.querySelector('.modal-close-btn'); if (options.persistent) { if(modalContent) modalContent.dataset.persistent = 'true'; } else { if (modalOverlay) { modalOverlay.addEventListener('click', () => closeModal(modalWrapper)); } } if (closeBtn) { closeBtn.addEventListener('click', () => closeModal(modalWrapper)); } requestAnimationFrame(() => modalElement.classList.remove('opacity-0')); return modalWrapper; } function closeModal(modalWrapper) { if (!modalWrapper || !modalsContainer.contains(modalWrapper)) return; if (activeScanner) { try { if (activeScanner && activeScanner.getState() === 2) { // SCANNING state activeScanner.stop().then(() => { activeScanner.clear(); activeScanner = null; }).catch(err => { console.warn("Could not stop scanner gracefully", err); activeScanner = null; }); } else { activeScanner = null; } } catch(e) { console.warn("Error stopping scanner:", e); activeScanner = null; } } document.removeEventListener('keydown', handleEscapeKey); const modalElement = modalWrapper.querySelector('.modal-container'); if (modalElement) { modalElement.classList.add('opacity-0'); } setTimeout(() => { if (modalsContainer.contains(modalWrapper)) { modalsContainer.removeChild(modalWrapper); } if (activeModal === modalWrapper) { activeModal = null; } }, 300); } function triggerConfetti() { confetti({ particleCount: 150, spread: 180, origin: { y: 0.6 } }); } function showNotification(message, isError = false) { const id = isError ? 'error' : 'success'; const notification = document.getElementById(`${id}-notification`); document.getElementById(`${id}-message`).textContent = message; notification.classList.remove('hidden'); if (!isError && message.includes("reward")) { triggerConfetti(); } setTimeout(() => notification.classList.add('hidden'), 3000); } const showSuccess = (msg) => showNotification(msg, false); const showError = (msg) => showNotification(msg, true); // --- PAGE ROUTING & RENDERING --- function setActiveTab(tabName) { const customerTab = document.getElementById('customer-tab'); const adminTab = document.getElementById('admin-tab'); const customerForm = document.getElementById('customer-form'); const adminForm = document.getElementById('admin-form'); if (tabName === 'customer') { customerTab.classList.add('bg-white', 'text-primary', 'shadow-sm'); customerTab.classList.remove('text-slate-500'); adminTab.classList.remove('bg-white', 'text-primary', 'shadow-sm'); adminTab.classList.add('text-slate-500'); customerForm.classList.remove('hidden'); adminForm.classList.add('hidden'); } else { // admin adminTab.classList.add('bg-white', 'text-primary', 'shadow-sm'); adminTab.classList.remove('text-slate-500'); customerTab.classList.remove('bg-white', 'text-primary', 'shadow-sm'); customerTab.classList.add('text-slate-500'); adminForm.classList.remove('hidden'); customerForm.classList.add('hidden'); } } function showPage(pageId) { ['login-page', 'admin-dashboard', 'customer-dashboard'].forEach(id => { document.getElementById(id).classList.add('hidden'); }); if (pageId !== 'customer-dashboard' && sessionData.pollingInterval) { clearInterval(sessionData.pollingInterval); sessionData.pollingInterval = null; } const pageToShow = document.getElementById(pageId); if (pageToShow) { requestAnimationFrame(() => { pageToShow.classList.remove('hidden'); window.scrollTo(0, 0); }); } } function updateAllUIElements() { if (!state.settings) return; const root = document.documentElement; if (state.settings && state.settings.primaryColor) { root.style.setProperty('--primary-hsl', state.settings.primaryColor); } if (state.settings && state.settings.backgroundColor) { root.style.setProperty('--background-hsl', state.settings.backgroundColor); } if (state.settings && state.settings.accentColor) { root.style.setProperty('--accent-hsl', state.settings.accentColor); } const business = state.settings; const businessName = business.businessName || 'Omni Rewards'; // Update page metadata and common UI strings document.title = businessName; const loginBusName = document.getElementById('login-business-name'); if (loginBusName) loginBusName.textContent = businessName; const footerBusName = document.getElementById('business-name-footer'); if (footerBusName) footerBusName.textContent = businessName; const footerYear = document.getElementById('current-year'); if (footerYear) footerYear.textContent = new Date().getFullYear(); document.querySelectorAll('.logo-placeholder').forEach(container => { if (business.logoUrl) { container.innerHTML = `<img src="${business.logoUrl}" alt="Logo" class="w-full h-full rounded-full object-cover">`; } else { container.innerHTML = `<i class="fas fa-star text-3xl text-primary"></i>`; } }); if (business.faviconUrl) { const favLink = document.getElementById('favicon-link'); if (favLink) favLink.href = business.faviconUrl; } } function calculateStats() { const stats = state.stats || { totalCustomers: 0, pointsThisMonth: 0, totalPoints: 0, rewardsClaimed: 0 }; document.getElementById('total-customers').textContent = stats.totalCustomers; document.getElementById('points-this-month').textContent = stats.pointsThisMonth; document.getElementById('total-points').textContent = stats.totalPoints; document.getElementById('rewards-claimed').textContent = stats.rewardsClaimed; } async function handleQuickAction(action, customerId, method = 'Quick Action') { let customer = state.customers.find(c => c.id === customerId); if (!customer) { await refreshData(true); customer = state.customers.find(c => c.id === customerId); if (!customer) return; } const formData = new FormData(); formData.append('customerId', customerId); formData.append('method', method); let confirmMessage = ''; switch(action) { case 'add-point': formData.append('action', 'add_point'); apiCall(formData).then(result => { if (result) { showSuccess(result.data.message || 'Point added.'); refreshData(); } }); return; case 'remove-point': if (customer.points <= 0) { showError(`${customer.name} has no points to remove.`); return; } formData.append('action', 'remove_point'); apiCall(formData).then(result => { if (result) { showSuccess(result.data.message || 'Point removed.'); refreshData(); } }); return; case 'reset-password': formData.append('action', 'reset_password'); apiCall(formData).then(result => { if (result && result.data.newPassword) { showPasswordModal(customer.name, result.data.newPassword, customer.phone, true); refreshData(); } }); return; case 'history': const historyFormData = new FormData(); historyFormData.append('action', 'get_customer_history'); historyFormData.append('customer_id', customerId); apiCall(historyFormData).then(result => { if (result && result.success) { customer.history = result.data; showHistoryModal(customer); } }); return; case 'redeem-reward': confirmMessage = `Redeem a reward for ${customer.name}? They have ${customer.rewards} available.`; formData.append('action', 'redeem_reward'); break; case 'redeem-gift-card': // This is a special case triggered from the redeem gift card modal return; case 'reset-points': confirmMessage = `Are you sure you want to reset points for ${customer.name}?`; formData.append('action', 'reset_points'); break; case 'delete': confirmMessage = `Delete ${customer.name}? This cannot be undone.`; formData.append('action', 'delete_customer'); break; default: return; } const modalWrapper = openModal(` <div class="modal-container fixed inset-0 z-50 flex items-center justify-center opacity-0 transition-opacity duration-300"> <div class="modal-overlay absolute inset-0 bg-black bg-opacity-50"></div> <div class="modal-content bg-white rounded-lg shadow-xl p-6 w-full max-w-sm relative"> <button class="modal-close-btn absolute top-2 right-2 w-8 h-8 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center transition-colors"><i class="fas fa-times"></i></button> <h3 class="text-lg font-medium text-slate-800">Confirm Action</h3> <p class="mt-2 text-sm text-gray-500">${confirmMessage}</p> <div class="mt-4 flex justify-end gap-2"> <button class="modal-cancel px-4 py-2 rounded transition-colors hover:bg-slate-100 border border-slate-200 text-slate-500 font-medium">Cancel</button> <button class="modal-confirm bg-primary text-white px-4 py-2 rounded transition-all active:scale-95 font-medium shadow-sm">Confirm</button> </div> </div> </div>`); modalWrapper.querySelector('.modal-confirm').onclick = () => { apiCall(formData).then(result => { if (result) { showSuccess(result.data.message || 'Action completed.'); refreshData(); closeModal(modalWrapper); } }); }; modalWrapper.querySelector('.modal-cancel').onclick = () => closeModal(modalWrapper); } function renderAdminDashboard() { calculateStats(); const business = state.settings; document.getElementById('admin-business-name').textContent = business.businessName || 'Dashboard'; updateAllUIElements(); renderCustomerLists(document.getElementById('customer-search').value); renderActivityChart(); initReportLogic(); } function renderCustomerDashboard() { const container = document.getElementById('customer-dashboard').querySelector('.container'); let customer; if (state.currentUser && state.userType === 'customer') { const onlineCustomerData = state.customers.find(c => c.id === state.currentUser.id); if (onlineCustomerData) { // Merge online data with session data, online data takes precedence customer = { ...state.currentUser, ...onlineCustomerData }; state.currentUser = customer; // Update the central state localStorage.setItem('omni-cached-customer', JSON.stringify(customer)); } else { customer = state.currentUser; } } else { showError("Could not load your data. Please log in again."); logout(); return; } if (!customer) { showError("Could not load your data. Please log in again."); logout(); return; } if (!sessionData.pollingInterval) { sessionData.pollingInterval = setInterval(async () => { if (!state.currentUser || state.userType !== 'customer') { clearInterval(sessionData.pollingInterval); sessionData.pollingInterval = null; return; } const formData = new FormData(); formData.append('action', 'get_customer_updates'); formData.append('customer_id', state.currentUser.id); const result = await apiCall(formData); if (result && result.success) { const latestData = result.data; const currentCustomer = state.customers.find(c => c.id === state.currentUser.id); const activeGiftCards = state.giftCards.filter(gc => gc.status === 'active'); const hasPointsChanged = currentCustomer && (currentCustomer.points != latestData.points || currentCustomer.rewards != latestData.rewards); const hasGiftCardsChanged = activeGiftCards.length !== latestData.active_gift_cards_count; if (hasPointsChanged || hasGiftCardsChanged) { const isViewingGiftCards = !document.getElementById('customer-gift-cards-view')?.classList.contains('hidden'); await refreshData(true); if (isViewingGiftCards) { renderGiftCardsPage(); } } } }, 1500); } if (customer.rewards > 0 && customer.rewards > sessionData.hasSeenConfettiForRewards) { triggerConfetti(); sessionData.hasSeenConfettiForRewards = customer.rewards; } const isVIP = customer.isVIP; const loyaltyCardColor = isVIP ? 'bg-gradient-to-br from-amber-500 to-yellow-400 text-amber-950' : 'bg-primary-gradient text-white'; const starColor = isVIP ? 'text-white' : 'text-yellow-300'; const emptyStarColor = isVIP ? 'text-amber-950/30' : 'text-white/30'; const starsHtml = Array.from({ length: 10 }).map((_, i) => { const isFilled = i < customer.points; const isTenth = i === 9; return `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="currentColor" stroke="none" class="${isFilled ? (isTenth ? 'text-amber-400' : starColor) : emptyStarColor}"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>`; }).join(''); let rewardMessage = ''; if (customer.rewards > 0) { rewardMessage = ` <div class="mt-6 bg-white/20 p-4 rounded-lg backdrop-blur-sm"> <p class="font-bold text-lg flex items-center justify-center gap-2 text-white"> <i class="fas fa-gift w-6 h-6"></i> ${customer.rewards} Reward${customer.rewards > 1 ? 's' : ''} Available! </p> <p class="opacity-90 mt-1 text-sm text-white">Ask for your reward at the checkout.</p> </div>`; } else { rewardMessage = `<p class="opacity-90 mt-6">Collect ${10 - customer.points} more points to earn your next reward.</p>`; } const dashboardHtml = ` <div id="customer-main-view" class="space-y-8"> <header class="flex flex-col items-center text-center gap-4"> <div class="flex items-center gap-4"> <div class="logo-placeholder w-20 h-20 bg-primary/10 rounded-full flex items-center justify-center"> <i class="fas fa-star text-3xl text-primary"></i> </div> <div> <h1 class="text-2xl font-bold font-headline text-slate-800">${state.settings.businessName}</h1> <p class="text-slate-500">Welcome, ${customer.name.split(' ')[0]}!</p> </div> </div> <div class="flex items-center gap-2 justify-center"> <button id="view-gift-cards-btn" class="bg-amber-500 hover:bg-amber-600 text-white font-medium py-2 px-4 rounded-md inline-flex items-center gap-2 border border-amber-600 transition-all active:scale-95 shadow-sm"> <i class="fas fa-gift"></i> My Gift Cards </button> <button id="customer-settings-btn" class="bg-white hover:bg-slate-100 text-slate-700 font-medium py-2 px-4 rounded-md inline-flex items-center gap-2 border border-slate-200 transition-all active:scale-95 shadow-sm"> <i class="fas fa-cog"></i> Settings </button> <button id="customer-logout-btn" class="bg-white hover:bg-slate-100 text-slate-700 font-medium py-2 px-4 rounded-md inline-flex items-center gap-2 border border-slate-200 transition-all active:scale-95 shadow-sm"> <i class="fas fa-sign-out-alt"></i> Logout </button> </div> </header> <div class="rounded-2xl p-8 text-center shadow-2xl mb-8 ${loyaltyCardColor}"> <div class="flex items-center justify-center gap-2 text-2xl font-bold font-headline"> ${isVIP ? '<i class="fas fa-crown w-7 h-7 text-amber-800"></i>' : ''} <span>Your Loyalty Status</span> </div> <div class="my-6"> <span class="text-8xl font-bold">${customer.points}</span> <p class="opacity-70">Points</p> </div> <div class="flex items-center justify-center gap-2 mb-4">${starsHtml}</div> ${rewardMessage} </div> <div class="grid grid-cols-1 md:grid-cols-2 gap-8"> <div class="shadow-lg bg-white rounded-lg flex flex-col md:col-span-2"> <div class="p-6 text-center"><h3 class="text-xl font-headline font-bold text-slate-800 uppercase tracking-wider">Your QR Code</h3></div> <div class="p-6 flex-grow flex flex-col items-center justify-center gap-4"> <div class="p-4 bg-white rounded-[2rem] border-4 border-slate-50 flex items-center justify-center shadow-inner"> <img src="https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${encodeURIComponent(customer.qrCodeValue)}" alt="QR Code" width="150" height="150" class="rounded-md"> </div> <p class="font-mono text-sm text-slate-500 bg-slate-100 px-3 py-1 rounded-md shadow-inner">${customer.qrCodeValue}</p> </div> <div class="p-6 border-t border-slate-50"><p class="text-center text-sm text-slate-500 w-full font-medium">Show this QR code to the cashier to earn points with your purchase.</p></div> </div> </div> </div> <div id="customer-gift-cards-view" class="hidden space-y-8"> <!-- Gift Cards UI will be rendered here --> </div> `; container.innerHTML = dashboardHtml; updateAllUIElements(); } function renderGiftCardsPage() { const mainView = document.getElementById('customer-main-view'); const gcView = document.getElementById('customer-gift-cards-view'); if (!mainView || !gcView) return; mainView.classList.add('hidden'); gcView.classList.remove('hidden'); const activeCards = state.giftCards.filter(gc => gc.status === 'active'); const historyCards = state.giftCards.filter(gc => gc.status === 'redeemed'); let gcCurrentPage = 1; const GC_PER_PAGE = 5; const totalGcPages = Math.ceil(historyCards.length / GC_PER_PAGE); const renderHistory = (page) => { const start = (page - 1) * GC_PER_PAGE; const paginatedHistory = historyCards.slice(start, start + GC_PER_PAGE); return paginatedHistory.length > 0 ? paginatedHistory.map(gc => ` <div class="flex items-center justify-between p-5 border-b border-slate-50 last:border-0 hover:bg-slate-50/50 transition-colors"> <div class="flex items-center gap-4"> <div class="w-10 h-10 bg-slate-100 rounded-full flex items-center justify-center text-slate-400"> <i class="fas fa-check"></i> </div> <div> <p class="font-bold text-slate-800">${gc.title}</p> <p class="text-[10px] uppercase font-black text-slate-400 tracking-widest">Redeemed on ${new Date(gc.redeemed_at).toLocaleDateString()}</p> </div> </div> <div class="text-right"> <p class="text-xl font-black text-slate-400 line-through tracking-tighter">$${parseFloat(gc.amount).toFixed(2)}</p> <span class="text-[9px] uppercase font-black px-2 py-0.5 rounded bg-slate-100 text-slate-500 tracking-widest">Completed</span> </div> </div> `).join('') : '<div class="p-12 text-center text-slate-400"><i class="fas fa-history text-3xl mb-2 opacity-20"></i><p class="font-bold uppercase text-xs tracking-widest">No history found</p></div>'; }; const gcHtml = ` <div class="flex flex-col gap-6"> <div class="flex items-center justify-between"> <button id="back-to-dash-btn" class="text-primary font-black uppercase tracking-widest text-xs hover:underline flex items-center gap-2 transition-all active:scale-95"> <i class="fas fa-arrow-left"></i> Home </button> <h2 class="text-2xl font-black font-headline text-slate-800 uppercase tracking-tight">Gift Cards</h2> </div> <div class="space-y-4"> <div class="flex items-center gap-2 mb-4"> <div class="w-8 h-8 bg-primary/10 rounded-lg flex items-center justify-center text-primary"> <i class="fas fa-gift"></i> </div> <h3 class="text-xl font-black text-slate-800 uppercase tracking-tight">Active Gift Cards</h3> </div> <div class="grid grid-cols-1 lg:grid-cols-2 gap-8"> ${activeCards.length > 0 ? activeCards.map(gc => ` <div class="sleek-card-bg rounded-[2.5rem] p-10 text-white shadow-2xl flex flex-col justify-between aspect-[1.6/1] group transition-all duration-500 hover:scale-[1.02] border border-white/10"> <div class="card-wave"></div> <svg class="absolute top-0 left-0 w-full h-full opacity-20 pointer-events-none" viewBox="0 0 100 100" preserveAspectRatio="none"> <path d="M0 30 Q 50 10 100 30" stroke="white" stroke-width="0.2" fill="none" /> <path d="M0 70 Q 50 90 100 70" stroke="white" stroke-width="0.2" fill="none" /> </svg> <div class="relative z-10 flex justify-between items-start"> <div class="metallic-gold-icon drop-shadow-lg"> <svg xmlns="http://www.w3.org/2000/svg" width="56" height="56" viewBox="0 0 24 24" class="metallic-gold-fill"> <path d="M20 12V22H4V12H20M22 6H2V10H22V6M12 4C13.1 4 14 4.9 14 6H10C10 4.9 10.9 4 12 4M12 2C9.79 2 8 3.79 8 6H2V11H22V6H16C16 3.79 14.21 2 12 2Z" /> </svg> </div> <span class="px-4 py-1.5 bg-black/20 backdrop-blur-md rounded-full text-[10px] font-black uppercase tracking-[0.2em] border border-white/20 shadow-lg text-white">Premium Card</span> </div> <div class="relative z-10"> <h4 class="text-5xl font-black font-headline tracking-tight metallic-gold uppercase">Gift Card</h4> <p class="text-white/60 text-[10px] mt-1 font-black tracking-[0.2em] uppercase">${state.settings.businessName}</p> <p class="text-white/80 text-xs mt-4 leading-relaxed font-medium line-clamp-2 max-w-[90%]">${gc.description}</p> </div> <div class="relative z-10 flex items-end justify-between mt-auto pb-4"> <div class="bg-black/20 backdrop-blur-md p-3 rounded-2xl border border-white/10 shadow-inner min-w-[100px]"> <p class="text-[9px] text-white/40 uppercase font-black tracking-[0.2em] mb-0.5">Issue Date</p> <p class="text-xs text-white font-black tracking-tighter">${new Date(gc.created_at).toLocaleDateString('en-US', {month: 'short', year: 'numeric'})}</p> </div> <div class="text-right"> <p class="text-[10px] text-white/50 uppercase font-black tracking-[0.2em] mb-1">Available Funds</p> <p class="text-7xl font-black tracking-tighter leading-none metallic-gold">$${parseFloat(gc.amount).toFixed(0)}</p> </div> </div> </div> `).join('') : ` <div class="col-span-full py-16 text-center bg-white rounded-[2.5rem] border-4 border-dashed border-slate-100 shadow-inner"> <i class="fas fa-gift text-5xl text-slate-200 mb-4 opacity-50"></i> <p class="text-slate-400 font-black text-lg uppercase tracking-tight">No active gift card</p> <p class="text-slate-300 text-xs font-bold mt-1 uppercase tracking-widest">Keep shopping to earn more perks!</p> </div> `} </div> </div> <div class="bg-white rounded-[2rem] shadow-xl border border-slate-100 overflow-hidden mt-8"> <div class="p-6 bg-slate-50/50 border-b border-slate-100 flex items-center justify-between"> <h3 class="text-lg font-black text-slate-800 uppercase tracking-widest">History</h3> <i class="fas fa-history text-slate-300"></i> </div> <div id="gc-history-container"> ${renderHistory(gcCurrentPage)} </div> ${totalGcPages > 1 ? ` <div class="p-4 bg-slate-50/50 border-t border-slate-100 flex justify-between items-center"> <span class="text-xs font-black text-slate-400 uppercase tracking-widest">Page <span id="gc-page-num">${gcCurrentPage}</span> / ${totalGcPages}</span> <div class="flex gap-2"> <button id="gc-prev-btn" class="px-5 py-2 rounded-xl border border-slate-200 bg-white font-black text-[10px] uppercase tracking-widest hover:bg-slate-50 disabled:opacity-30 transition-all active:scale-95" ${gcCurrentPage === 1 ? 'disabled' : ''}>PREV</button> <button id="gc-next-btn" class="px-5 py-2 rounded-xl border border-slate-200 bg-white font-black text-[10px] uppercase tracking-widest hover:bg-slate-50 disabled:opacity-30 transition-all active:scale-95" ${gcCurrentPage === totalGcPages ? 'disabled' : ''}>NEXT</button> </div> </div> ` : ''} </div> </div> `; gcView.innerHTML = gcHtml; gcView.querySelector('#back-to-dash-btn').onclick = () => { gcView.classList.add('hidden'); mainView.classList.remove('hidden'); }; if (totalGcPages > 1) { const prev = gcView.querySelector('#gc-prev-btn'); const next = gcView.querySelector('#gc-next-btn'); const pageNum = gcView.querySelector('#gc-page-num'); const container = gcView.querySelector('#gc-history-container'); prev.onclick = () => { if (gcCurrentPage > 1) { gcCurrentPage--; container.innerHTML = renderHistory(gcCurrentPage); pageNum.textContent = gcCurrentPage; prev.disabled = gcCurrentPage === 1; next.disabled = gcCurrentPage === totalGcPages; } }; next.onclick = () => { if (gcCurrentPage < totalGcPages) { gcCurrentPage++; container.innerHTML = renderHistory(gcCurrentPage); pageNum.textContent = gcCurrentPage; prev.disabled = gcCurrentPage === 1; next.disabled = gcCurrentPage === totalGcPages; } }; } } function renderCustomerLists(searchQuery = '') { const customerDisplayArea = document.getElementById('customer-display-area'); if (!customerDisplayArea) return; const lowercasedQuery = searchQuery.toLowerCase(); const filteredCustomers = searchQuery ? (state.customers || []).filter(c => c.name.toLowerCase().includes(lowercasedQuery) || c.phone.includes(lowercasedQuery) ) : (state.customers || []); const sortedCustomers = [...filteredCustomers].sort((a, b) => (b.rewards * 10 + b.points) - (a.rewards * 10 + a.points)); const topCustomers = sortedCustomers.slice(0, 3); const otherCustomers = sortedCustomers.slice(3); let topCustomersHtml = ''; if (topCustomers.length > 0 && !searchQuery) { topCustomersHtml = ` <div id="top-customers-section"> <h2 class="text-2xl font-bold font-headline text-slate-700 mb-4 border-b pb-2">Top Customers</h2> <div class="grid grid-cols-1 md:grid-cols-3 gap-6"> ${topCustomers.map(customer => { const isVIP = customer.isVIP; const starsHtml = Array(10).fill(0).map((_, i) => `<i class="fas fa-star text-xl ${i < customer.points ? 'text-yellow-400' : 'text-slate-300'}"></i>`).join(''); const rewardButtonHtml = customer.rewards > 0 ? `<button class="quick-action w-full bg-emerald-500 hover:bg-emerald-600 text-white py-2 px-4 rounded-md inline-flex items-center justify-center gap-2 transition-all active:scale-95" data-action="redeem-reward" data-id="${customer.id}"><i class="fas fa-gift"></i> Redeem Reward (${customer.rewards})</button>` : `<span class="text-base py-1 px-3 rounded-full bg-slate-100 text-slate-600">${customer.points} Points</span>`; const cardColor = isVIP ? 'bg-gradient-to-br from-amber-400 to-yellow-500 text-amber-950' : 'bg-primary-gradient text-white'; const nameHtml = isVIP ? `<i class="fas fa-crown mr-2"></i>${customer.name}` : customer.name; return ` <div class="shadow-lg hover:shadow-xl transition-shadow duration-300 rounded-2xl flex flex-col bg-white"> <div class="p-4 text-white flex justify-between items-start relative ${cardColor} rounded-t-2xl"> <div> <h3 class="font-bold text-lg font-headline flex items-center gap-2">${nameHtml}</h3> <div class="text-sm opacity-90 mt-2"><p class="flex items-center gap-2"><i class="fas fa-phone"></i> ${customer.phone}</p></div> </div> <div class="relative"> <button type="button" class="dropdown-toggle p-2 hover:bg-white/20 rounded-full" data-id="${customer.id}"><i class="fas fa-ellipsis-v"></i></button> <div class="dropdown-menu hidden absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg border"> <button class="quick-action text-left w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" data-action="add-point" data-id="${customer.id}"><i class="fas fa-plus-circle text-green-500 w-6"></i>Add Point</button> <button class="quick-action text-left w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" data-action="remove-point" data-id="${customer.id}"><i class="fas fa-minus-circle text-orange-500 w-6"></i>Remove Point</button> <button class="show-redeem-gc-btn text-left w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" data-id="${customer.id}"><i class="fas fa-gift text-amber-500 w-6"></i>Redeem Gift Card</button> <button class="quick-action text-left w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" data-action="history" data-id="${customer.id}"><i class="fas fa-history text-gray-500 w-6"></i>View History</button> <button class="quick-action text-left w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" data-action="reset-points" data-id="${customer.id}"><i class="fas fa-sync-alt text-blue-500 w-6"></i>Reset Points</button> <button class="quick-action text-left w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" data-action="reset-password" data-id="${customer.id}"><i class="fas fa-key text-primary w-6"></i>Reset Password</button> <div class="border-t my-1"></div> <button class="quick-action text-left w-full px-4 py-2 text-sm text-red-600 hover:bg-gray-100" data-action="delete" data-id="${customer.id}"><i class="fas fa-trash w-6"></i>Delete</button> </div> </div> </div> <div class="p-4 bg-white flex-grow flex flex-col justify-between rounded-b-2xl"> <div class="flex items-center justify-center gap-2 mb-3 h-8 flex-wrap">${rewardButtonHtml}</div> <div class="flex items-center gap-1 mb-3 justify-center">${starsHtml}</div> </div> </div>`; }).join('')} </div> </div>`; } let otherCustomersHtml = ''; const customersToList = searchQuery ? sortedCustomers : otherCustomers; const totalPages = Math.ceil(customersToList.length / CUSTOMERS_PER_PAGE); const paginatedCustomers = customersToList.slice((state.currentPage - 1) * CUSTOMERS_PER_PAGE, state.currentPage * CUSTOMERS_PER_PAGE); if (paginatedCustomers.length > 0) { otherCustomersHtml = ` <div id="other-customers-section"> <div class="bg-white shadow-lg rounded-lg p-2 space-y-2"> ${paginatedCustomers.map(customer => { const isVIP = customer.isVIP; const nameHtml = isVIP ? `<div class="flex items-center font-bold text-slate-800"><i class="fas fa-crown mr-2 text-amber-500"></i>${customer.name}</div>` : `<div class="font-bold text-slate-800">${customer.name}</div>`; const starsHtml = Array(10).fill(0).map((_, i) => `<i class="fas fa-star text-xs ${i < customer.points ? 'text-yellow-400' : 'text-slate-300'}"></i>`).join(''); return ` <div class="flex flex-col md:flex-row md:items-center justify-between p-3 rounded-lg hover:bg-slate-50"> <div class="flex-grow mb-4 md:mb-0"> ${nameHtml} <div class="text-sm text-slate-500">${customer.phone}</div> <div class="flex items-center gap-1 mt-1">${starsHtml}</div> </div> <div class="flex items-center justify-between w-full md:w-auto"> <div class="mx-4"> <span class="text-sm py-1 px-3 rounded-full bg-slate-100 text-slate-600">${customer.points} pts</span> </div> <div class="flex items-center space-x-1 md:space-x-2"> <button class="quick-action w-8 h-8 rounded-md flex items-center justify-center bg-green-100 text-green-600 hover:bg-green-200 transition-all active:scale-90" title="Add Point" data-action="add-point" data-id="${customer.id}"><i class="fas fa-plus"></i></button> <button class="quick-action w-8 h-8 rounded-md flex items-center justify-center bg-orange-100 text-orange-600 hover:bg-orange-200 transition-all active:scale-90" title="Remove Point" data-action="remove-point" data-id="${customer.id}"><i class="fas fa-minus"></i></button> <button class="show-redeem-gc-btn w-8 h-8 rounded-md flex items-center justify-center bg-amber-100 text-amber-600 hover:bg-amber-200 transition-all active:scale-90" title="Redeem Gift Card" data-id="${customer.id}"><i class="fas fa-gift"></i></button> <button class="quick-action w-8 h-8 rounded-md flex items-center justify-center bg-gray-100 text-gray-600 hover:bg-gray-200 transition-all active:scale-90" title="View History" data-action="history" data-id="${customer.id}"><i class="fas fa-history"></i></button> <button class="quick-action w-8 h-8 rounded-md flex items-center justify-center bg-blue-100 text-blue-600 hover:bg-blue-200 transition-all active:scale-90" title="Reset Points" data-action="reset-points" data-id="${customer.id}"><i class="fas fa-sync-alt"></i></button> <button class="quick-action w-8 h-8 rounded-md flex items-center justify-center bg-primary/10 text-primary hover:bg-primary/20 transition-all active:scale-90" title="Reset Password" data-action="reset-password" data-id="${customer.id}"><i class="fas fa-key"></i></button> <button class="quick-action w-8 h-8 rounded-md flex items-center justify-center bg-red-100 text-red-600 hover:bg-red-200 transition-all active:scale-90" title="Delete Customer" data-action="delete" data-id="${customer.id}"><i class="fas fa-trash"></i></button> </div> </div> </div> `; }).join('')} </div> ${totalPages > 1 ? ` <div class="flex items-center justify-end space-x-2 py-4 px-4"> <span class="text-sm text-slate-500">Page ${state.currentPage} of ${totalPages}</span> <button class="pagination-btn px-3 py-1 text-sm rounded-md border ${state.currentPage === 1 ? 'cursor-not-allowed opacity-50' : 'hover:bg-slate-100'}" data-page="${state.currentPage - 1}" ${state.currentPage === 1 ? 'disabled' : ''}>Previous</button> <button class="pagination-btn px-3 py-1 text-sm rounded-md border ${state.currentPage === totalPages ? 'cursor-not-allowed opacity-50' : 'hover:bg-slate-100'}" data-page="${state.currentPage + 1}" ${state.currentPage === totalPages ? 'disabled' : ''}>Next</button> </div> ` : ''} </div>`; } else if (searchQuery) { otherCustomersHtml = `<p class="text-center text-slate-500 mt-8">No customers found for "${searchQuery}".</p>`; } customerDisplayArea.innerHTML = topCustomersHtml + otherCustomersHtml; } async function renderActivityChart() { const formData = new FormData(); formData.append('action', 'get_chart_data'); const result = await apiCall(formData); if (!result || !result.success) { console.error("Failed to fetch chart data"); return; } const data = result.data; const ctx = document.getElementById('activity-chart').getContext('2d'); if (activityChart) { activityChart.destroy(); } const hexToHsl = (hex) => { if (!hex) return '262 83% 57%'; hex = hex.replace(/^#/, ''); if (hex.length === 3) { hex = hex.split('').map(char => char + char).join(''); } const r = parseInt(hex.substring(0, 2), 16) / 255; const g = parseInt(hex.substring(2, 4), 16) / 255; const b = parseInt(hex.substring(4, 6), 16) / 255; const max = Math.max(r, g, b), min = Math.min(r, g, b); let h=0, s=0, l = (max + min) / 2; if (max !== min) { const d = max - min; s = l > 0.5 ? d / (2 - max - min) : d / (max + min); switch (max) { case r: h = (g - b) / d + (g < b ? 6 : 0); break; case g: h = (b - r) / d + 2; break; case b: h = (r - g) / d + 4; break; } h /= 6; } return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`; }; const getHslString = (colorValue) => { if (!colorValue) return '262 83% 57%'; if (colorValue.startsWith('#')) { return hexToHsl(colorValue); } return colorValue; }; const primaryColorHsl = getHslString(state.settings.primaryColor); const primaryColorHsla = `hsla(${primaryColorHsl.split(' ').join(', ')}, 0.4)`; const primaryColorHslFull = `hsl(${primaryColorHsl.split(' ').join(', ')})`; const greenHsl = '158 83% 42%'; const greenHsla = `hsla(${greenHsl.split(' ').join(', ')}, 0.4)`; const greenHslFull = `hsl(${greenHsl.split(' ').join(', ')})`; const primaryGradient = ctx.createLinearGradient(0, 0, 0, 350); primaryGradient.addColorStop(0, primaryColorHsla); primaryGradient.addColorStop(1, 'rgba(128, 90, 213, 0)'); const greenGradient = ctx.createLinearGradient(0, 0, 0, 350); greenGradient.addColorStop(0, greenHsla); greenGradient.addColorStop(1, 'hsla(158, 83%, 42%, 0)'); activityChart = new Chart(ctx, { type: 'line', data: { labels: data.map(row => row.month), datasets: [ { label: 'Points Added', data: data.map(row => row.totalPoints), borderColor: primaryColorHslFull, backgroundColor: primaryGradient, fill: true, tension: 0.4, pointBackgroundColor: primaryColorHslFull, pointBorderColor: '#fff', pointHoverBackgroundColor: '#fff', pointHoverBorderColor: primaryColorHslFull, pointRadius: 6, pointBorderWidth: 2, pointHoverRadius: 8, }, { label: 'Rewards Claimed', data: data.map(row => row.rewardsClaimed), borderColor: greenHslFull, backgroundColor: greenGradient, fill: true, tension: 0.4, pointBackgroundColor: greenHslFull, pointBorderColor: '#fff', pointHoverBackgroundColor: '#fff', pointHoverBorderColor: greenHslFull, pointRadius: 6, pointBorderWidth: 2, pointHoverRadius: 8, } ] }, options: { responsive: true, maintainAspectRatio: false, scales: { y: { beginAtZero: true, ticks: { precision: 0 }, border: { display: false }, grid: { color: '#e5e7eb' } }, x: { border: { display: false }, grid: { display: false } } }, plugins: { legend: { display: false }, tooltip: { mode: 'index', intersect: false, backgroundColor: '#fff', titleColor: '#333', bodyColor: '#333', borderColor: '#ddd', borderWidth: 1, padding: 10, displayColors: true, usePointStyle: true, callbacks: { labelPointStyle: function(context) { return { pointStyle: 'rectRounded', rotation: 0 }; } } } }, interaction: { mode: 'nearest', axis: 'x', intersect: false }, onHover: (event, chartElement) => { event.native.target.style.cursor = chartElement[0] ? 'pointer' : 'default'; } } }); ctx.canvas.ondblclick = (evt) => { const points = activityChart.getElementsAtEventForMode(evt, 'nearest', { intersect: true }, true); if (points.length) { const firstPoint = points[0]; const dataIndex = firstPoint.index; const currentMin = activityChart.options.scales.x.min; const currentMax = activityChart.options.scales.x.max; if (currentMin !== undefined || currentMax !== undefined) { // If already zoomed, reset delete activityChart.options.scales.x.min; delete activityChart.options.scales.x.max; } else { // Zoom in const labels = activityChart.data.labels; activityChart.options.scales.x.min = labels[Math.max(0, dataIndex - 1)]; activityChart.options.scales.x.max = labels[Math.min(labels.length - 1, dataIndex + 1)]; } activityChart.update(); } else { // If clicking empty space while zoomed, reset delete activityChart.options.scales.x.min; delete activityChart.options.scales.x.max; activityChart.update(); } }; } function initReportLogic() { const reportTypeSelect = document.getElementById('report-type'); const customerSelector = document.getElementById('customer-selector-container'); const dateRangeSelector = document.getElementById('date-range-selector'); const searchInput = document.getElementById('report-customer-search'); const hiddenInput = document.getElementById('report-customer-id'); const resultsContainer = document.getElementById('report-search-results'); if (!reportTypeSelect) return; reportTypeSelect.addEventListener('change', e => { const type = e.target.value; if (type === 'points_by_customer') { customerSelector.classList.remove('hidden'); } else { customerSelector.classList.add('hidden'); } }); searchInput.addEventListener('input', e => { const query = e.target.value.toLowerCase(); hiddenInput.value = ''; if (query.length < 1) { resultsContainer.innerHTML = ''; resultsContainer.classList.add('hidden'); return; } const matches = state.customers.filter(c => c.name.toLowerCase().includes(query) || c.phone.includes(query)).slice(0, 5); resultsContainer.innerHTML = matches.map(c => `<div class="p-2 hover:bg-gray-100 cursor-pointer text-sm" data-id="${c.id}" data-name="${c.name}">${c.name} (${c.phone})</div>` ).join(''); resultsContainer.classList.remove('hidden'); }); resultsContainer.addEventListener('click', e => { const target = e.target.closest('[data-id]'); if (target) { searchInput.value = target.dataset.name; hiddenInput.value = target.dataset.id; resultsContainer.innerHTML = ''; resultsContainer.classList.add('hidden'); } }); document.addEventListener('click', e => { if (searchInput && !searchInput.contains(e.target) && resultsContainer && !resultsContainer.contains(e.target)) { resultsContainer.classList.add('hidden'); } }); } function handleFormSubmit(e) { e.preventDefault(); const form = e.target; const errorEl = form.querySelector("[id$='-login-error']"); if(errorEl) errorEl.classList.add('hidden'); const formData = new FormData(form); apiCall(formData).then(async (result) => { if (result && result.data) { state.currentUser = result.data.user; state.userType = result.data.userType; if (state.userType === 'customer') { localStorage.setItem('omni-cached-customer', JSON.stringify(result.data.user)); sessionData.hasSeenConfettiForRewards = result.data.user.rewards || 0; } else { localStorage.removeItem('omni-cached-customer'); } await refreshData(true); // Force refresh after login if (state.userType === 'admin') { showPage('admin-dashboard'); } else { showPage('customer-dashboard'); } } else { if (errorEl) { errorEl.textContent = 'Invalid credentials provided.'; errorEl.classList.remove('hidden'); } } }); } async function initializeApp() { // 1. Try to load cached customer data for instant offline UI const cachedCustomer = localStorage.getItem('omni-cached-customer'); if (cachedCustomer) { try { state.currentUser = JSON.parse(cachedCustomer); state.userType = 'customer'; // Immediately show the dashboard from cache showPage('customer-dashboard'); updateAllUIElements(); // Ensure UI strings are set even if offline await refreshData(); // This will silently fail if offline, but update UI if online } catch (e) { console.error("Failed to parse cached customer", e); localStorage.removeItem('omni-cached-customer'); } } // 2. Check for Biometric Setup if (Biometric.isSupported() && localStorage.getItem('omni-biometric-token')) { document.getElementById('biometric-login-container').classList.remove('hidden'); } // 3. If no cached data, check for an active server session const formData = new FormData(); formData.append('action', 'check_session'); const sessionResult = await apiCall(formData); let dataLoaded = false; if (sessionResult && sessionResult.success && sessionResult.data && sessionResult.data.user) { state.currentUser = sessionResult.data.user; state.userType = sessionResult.data.userType; dataLoaded = await refreshData(true); if (dataLoaded) { if (state.userType === 'admin') { localStorage.removeItem('omni-cached-customer'); showPage('admin-dashboard'); } else { localStorage.setItem('omni-cached-customer', JSON.stringify(state.currentUser)); const currentUserData = state.customers.find(c => c.id === state.currentUser.id); sessionData.hasSeenConfettiForRewards = currentUserData ? (currentUserData.rewards || 0) : 0; showPage('customer-dashboard'); } } } else { // 4. No cache, no session, so load fresh data for the login page dataLoaded = await refreshData(true); showPage('login-page'); } if(dataLoaded){ updateAllUIElements(); } else if (!cachedCustomer) { // Only show error if we couldn't even load from cache showError("Could not connect to the server. Please check your connection and try again."); } } function showPasswordModal(customerName, password, customerPhone, isReset = false) { const title = isReset ? "Password Reset Successfully!" : "Customer Added Successfully!"; const description = `${customerName}'s account has been updated. Share their temporary password with them.`; const cleanPhoneNumber = customerPhone.replace(/[^0-9]/g, ''); const message = `Hi ${customerName}, your temporary password is ${password}, be sure to change it in your account settings.`; const whatsappUrl = `https://wa.me/${cleanPhoneNumber}?text=${encodeURIComponent(message)}`; const modalHTML = ` <div class="modal-container fixed inset-0 z-[101] flex items-center justify-center opacity-0 transition-opacity duration-300"> <div class="modal-overlay absolute inset-0 bg-black bg-opacity-50"></div> <div class="modal-content bg-white rounded-lg shadow-xl p-6 w-full max-w-sm relative"> <h3 class="text-xl font-headline font-bold text-slate-800">${title}</h3> <p class="text-sm text-slate-500 mt-2">${description}</p> <div class="py-4 space-y-2"> <label class="text-sm font-medium text-slate-700 font-bold">Temporary Password</label> <div class="flex items-center space-x-2"> <input id="new-password-display" value="${password}" readonly class="font-mono bg-slate-100 flex h-10 w-full rounded-md border border-slate-200 px-3 py-2 text-sm"> <button type="button" id="copy-password-btn" class="w-10 h-10 rounded-md border border-slate-200 flex items-center justify-center hover:bg-slate-50 transition-colors"> <i class="far fa-copy text-slate-400"></i> </button> </div> </div> <div class="flex justify-end gap-2"> <a href="${whatsappUrl}" target="_blank" class="bg-green-500 text-white px-4 py-2 rounded-md inline-flex items-center gap-2 hover:bg-green-600 transition-all active:scale-95"> <i class="fab fa-whatsapp"></i> WhatsApp </a> <button class="modal-close-btn bg-primary text-white px-4 py-2 rounded-md transition-all active:scale-95">Done</button> </div> </div> </div>`; const modalWrapper = openModal(modalHTML); modalWrapper.querySelector('#copy-password-btn').addEventListener('click', () => { navigator.clipboard.writeText(password).then(() => { showSuccess('Password copied to clipboard!'); }).catch(err => { showError('Failed to copy password.'); }); }); } function showHistoryModal(customer) { let currentPage = 1; const HISTORY_ITEMS_PER_PAGE = 5; const history = (customer.history || []).sort((a, b) => new Date(b.date) - new Date(a.date)); const totalPages = Math.ceil(history.length / HISTORY_ITEMS_PER_PAGE); const renderHistoryPage = (page) => { const start = (page - 1) * HISTORY_ITEMS_PER_PAGE; const end = start + HISTORY_ITEMS_PER_PAGE; const paginatedHistory = history.slice(start, end); const historyHtml = paginatedHistory.length > 0 ? paginatedHistory.map(record => { const isReward = record.description.toLowerCase().includes('redeemed'); const isNegative = record.points < 0 && !isReward; let itemBg, iconBg, icon, badgeBg; if (isReward || isNegative) { itemBg = "bg-amber-50 border-amber-200"; iconBg = "bg-amber-100"; icon = isReward ? `<i class="fas fa-gift w-5 h-5 text-amber-600"></i>` : `<i class="fas fa-star w-5 h-5 text-amber-600"></i>`; badgeBg = "bg-amber-100 text-amber-700 border-amber-200"; } else { itemBg = "bg-lime-50 border-lime-200"; iconBg = "bg-lime-100"; icon = `<i class="fas fa-star w-5 h-5 text-lime-600"></i>`; badgeBg = "bg-lime-100 text-lime-700 border-lime-200"; } const pointsText = record.points > 0 ? `+${record.points}` : record.points; const formattedDate = new Date(record.date).toLocaleString('en-US', { month: 'numeric', day: 'numeric', year: '2-digit', hour: 'numeric', minute: '2-digit', hour12: true }); return ` <div class="flex items-center justify-between p-3 rounded-lg border ${itemBg}"> <div class="flex items-center gap-3"> <div class="p-2 rounded-full ${iconBg}">${icon}</div> <div> <p class="font-semibold text-slate-700">${record.description}</p> <p class="text-sm text-slate-500">${formattedDate}</p> </div> </div> <span class="text-xs font-bold py-1 px-2.5 rounded-full border ${badgeBg}">${pointsText}</span> </div>`; }).join('') : '<p class="text-center text-slate-500 py-8 font-medium">No points history for this customer.</p>'; return historyHtml; }; const modalHTML = ` <div class="modal-container fixed inset-0 z-50 flex items-center justify-center opacity-0 transition-opacity duration-300"> <div class="modal-overlay absolute inset-0 bg-black bg-opacity-50"></div> <div class="modal-content bg-white rounded-lg shadow-xl p-6 w-full max-w-lg relative max-h-[80vh] flex flex-col"> <button class="modal-close-btn absolute top-2 right-2 w-8 h-8 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center transition-colors"><i class="fas fa-times"></i></button> <h3 class="text-xl font-headline font-bold text-slate-800">Points History</h3> <div id="history-content" class="mt-4 space-y-3 overflow-y-auto flex-grow custom-scrollbar"> ${renderHistoryPage(currentPage)} </div> ${totalPages > 1 ? ` <div class="flex justify-between items-center pt-4 border-t mt-4"> <span class="text-sm text-slate-500">Page <span id="history-current-page">${currentPage}</span> of ${totalPages}</span> <div> <button id="history-prev-btn" class="px-3 py-1 text-sm rounded-md border border-slate-200 ${currentPage === 1 ? 'cursor-not-allowed opacity-50' : 'hover:bg-slate-100'}" ${currentPage === 1 ? 'disabled' : ''}>Previous</button> <button id="history-next-btn" class="px-3 py-1 text-sm rounded-md border border-slate-200 ${currentPage === totalPages ? 'cursor-not-allowed opacity-50' : 'hover:bg-slate-100'}" ${currentPage === totalPages ? 'disabled' : ''}>Next</button> </div> </div> ` : ''} </div> </div>`; const modalWrapper = openModal(modalHTML); const updatePagination = () => { const prevBtn = modalWrapper.querySelector('#history-prev-btn'); const nextBtn = modalWrapper.querySelector('#history-next-btn'); const pageSpan = modalWrapper.querySelector('#history-current-page'); if (modalWrapper.querySelector('#history-content')) { modalWrapper.querySelector('#history-content').innerHTML = renderHistoryPage(currentPage); } if (pageSpan) { pageSpan.textContent = currentPage; } if (prevBtn) { prevBtn.disabled = currentPage === 1; prevBtn.classList.toggle('cursor-not-allowed', currentPage === 1); prevBtn.classList.toggle('opacity-50', currentPage === 1); } if (nextBtn) { nextBtn.disabled = currentPage === totalPages; nextBtn.classList.toggle('cursor-not-allowed', currentPage === totalPages); nextBtn.classList.toggle('opacity-50', currentPage === totalPages); } }; if (totalPages > 1) { modalWrapper.querySelector('#history-prev-btn').addEventListener('click', () => { if (currentPage > 1) { currentPage--; updatePagination(); } }); modalWrapper.querySelector('#history-next-btn').addEventListener('click', () => { if (currentPage < totalPages) { currentPage++; updatePagination(); } }); } } // --- ATTACH LISTENERS --- document.getElementById('customer-login-form').addEventListener('submit', handleFormSubmit); document.getElementById('admin-login-form').addEventListener('submit', handleFormSubmit); document.getElementById('customer-tab').addEventListener('click', () => setActiveTab('customer')); document.getElementById('admin-tab').addEventListener('click', () => setActiveTab('admin')); document.getElementById('biometric-login-btn').addEventListener('click', () => Biometric.login()); // CENTRALIZED EVENT LISTENER document.body.addEventListener('click', e => { // Logout Buttons const logoutBtn = e.target.closest('#logout-btn, #customer-logout-btn, #mobile-logout-btn'); if (logoutBtn) { const formData = new FormData(); formData.append('action', 'logout'); apiCall(formData).then(() => { state.currentUser = null; state.userType = null; localStorage.removeItem('omni-cached-customer'); document.getElementById('customer-phone').value = ''; document.getElementById('customer-password').value = ''; document.getElementById('admin-username').value = ''; document.getElementById('admin-password').value = ''; initializeApp(); }); } // Customer Settings (in their own dashboard) const customerSettingsBtn = e.target.closest('#customer-settings-btn'); if (customerSettingsBtn) { const customer = state.customers.find(c => c.id === state.currentUser.id); const hasBioEnabled = localStorage.getItem('omni-biometric-token') !== null; const modalHTML = ` <div class="modal-container fixed inset-0 z-50 flex items-center justify-center opacity-0 transition-opacity duration-300"> <div class="modal-overlay absolute inset-0 bg-black bg-opacity-50"></div> <div class="modal-content bg-white rounded-lg shadow-xl p-6 w-full max-w-md relative"> <button class="modal-close-btn absolute top-2 right-2 w-8 h-8 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center transition-colors"><i class="fas fa-times"></i></button> <h3 class="text-xl font-headline font-bold text-slate-800">Account Settings</h3> <p class="text-sm text-slate-500 mb-4 font-medium">Update your account details below.</p> <div class="border-b border-gray-200"> <nav class="-mb-px flex space-x-4" aria-label="Tabs"> <button class="settings-tab-btn whitespace-nowrap py-3 px-1 border-b-2 font-medium text-sm border-primary text-primary" data-tab="phone"> <i class="fas fa-phone mr-2"></i>Phone </button> <button class="settings-tab-btn whitespace-nowrap py-3 px-1 border-b-2 font-medium text-sm border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" data-tab="password"> <i class="fas fa-key mr-2"></i>Password </button> <button class="settings-tab-btn whitespace-nowrap py-3 px-1 border-b-2 font-medium text-sm border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" data-tab="biometric"> <i class="fas fa-face-id mr-2"></i>Security </button> </nav> </div> <div id="phone-settings-tab" class="settings-tab-content mt-4"> <form id="phone-settings-form"> <label for="new-phone" class="block text-sm font-medium text-gray-700 font-bold">New Phone Number</label> <input type="tel" name="new_phone" id="new-phone" value="${customer.phone}" required class="mt-1 block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"> <div class="mt-4 text-right"> <button type="submit" class="bg-primary text-white px-4 py-2 rounded-md transition-all active:scale-95 shadow-sm font-bold">Save Phone</button> </div> </form> </div> <div id="password-settings-tab" class="settings-tab-content mt-4 hidden"> <form id="password-settings-form"> <div class="space-y-4"> <div> <label for="new-password" class="block text-sm font-medium text-gray-700 font-bold">New Password</label> <input type="password" name="new_password" id="new-password" required placeholder="6+ characters" class="mt-1 block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"> </div> <div> <label for="confirm-password" class="block text-sm font-medium text-gray-700 font-bold">Confirm New Password</label> <input type="password" name="confirm_password" id="confirm-password" required class="mt-1 block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"> </div> </div> <div class="mt-4 text-right"> <button type="submit" class="bg-primary text-white px-4 py-2 rounded-md transition-all active:scale-95 shadow-sm font-bold">Save Password</button> </div> </form> </div> <div id="biometric-settings-tab" class="settings-tab-content mt-4 hidden"> <div class="flex items-center justify-between p-4 rounded-2xl bg-slate-50 border border-slate-100"> <div class="flex items-center gap-3"> <div class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary"> <i class="fas fa-face-id"></i> </div> <div> <p class="text-sm font-bold text-slate-800">Biometric Sign-in</p> <p class="text-[10px] text-slate-500 uppercase font-black tracking-widest" id="customer-bio-status">${hasBioEnabled ? 'Enabled on this device' : 'Not enabled'}</p> </div> </div> <button type="button" id="enable-customer-bio-btn" class="px-4 py-1.5 ${hasBioEnabled ? 'bg-slate-200 text-slate-500 cursor-not-allowed' : 'bg-primary text-white'} rounded-lg text-xs font-bold transition-all active:scale-95 shadow-sm" ${hasBioEnabled ? 'disabled' : ''}>${hasBioEnabled ? 'Active' : 'Enable'}</button> </div> <p class="text-[10px] text-slate-400 mt-4 leading-relaxed font-medium italic">Biometric sign-in allows you to access your dashboard using your phone's Face ID or Fingerprint scanner.</p> </div> </div> </div>`; const modalWrapper = openModal(modalHTML); modalWrapper.querySelectorAll('.settings-tab-btn').forEach(btn => { btn.addEventListener('click', (e) => { modalWrapper.querySelectorAll('.settings-tab-btn').forEach(b => { b.classList.remove('border-primary', 'text-primary'); b.classList.add('border-transparent', 'text-gray-500', 'hover:text-gray-700', 'hover:border-gray-300'); }); e.currentTarget.classList.add('border-primary', 'text-primary'); e.currentTarget.classList.remove('border-transparent', 'text-gray-500', 'hover:text-gray-700', 'hover:border-gray-300'); const tabId = e.currentTarget.dataset.tab; modalWrapper.querySelectorAll('.settings-tab-content').forEach(content => { content.classList.add('hidden'); }); modalWrapper.querySelector(`#${tabId}-settings-tab`).classList.remove('hidden'); }); }); modalWrapper.querySelector('#phone-settings-form').addEventListener('submit', (e) => { e.preventDefault(); const formData = new FormData(e.target); formData.append('action', 'update_customer_details'); formData.append('update_type', 'phone'); apiCall(formData).then(result => { if (result) { showSuccess(result.data.message); refreshData(); closeModal(modalWrapper); } }); }); modalWrapper.querySelector('#password-settings-form').addEventListener('submit', (e) => { e.preventDefault(); const formData = new FormData(e.target); formData.append('action', 'update_customer_details'); formData.append('update_type', 'password'); apiCall(formData).then(result => { if (result) { showSuccess(result.data.message); refreshData(); closeModal(modalWrapper); } }); }); modalWrapper.querySelector('#enable-customer-bio-btn')?.addEventListener('click', async (e) => { const success = await Biometric.enable(); if (success) { e.target.textContent = 'Active'; e.target.disabled = true; e.target.classList.replace('bg-primary', 'bg-slate-200'); e.target.classList.replace('text-white', 'text-slate-500'); modalWrapper.querySelector('#customer-bio-status').textContent = 'Enabled on this device'; } }); } // Gift Cards View if (e.target.closest('#view-gift-cards-btn')) { renderGiftCardsPage(); } // Dropdown Toggle const dropdownToggle = e.target.closest('.dropdown-toggle'); if (dropdownToggle) { e.stopPropagation(); const dropdown = dropdownToggle.nextElementSibling; document.querySelectorAll('.dropdown-menu').forEach(menu => { if (menu !== dropdown) menu.classList.add('hidden'); }); dropdown.classList.toggle('hidden'); } else if (!e.target.closest('.dropdown-menu')) { document.querySelectorAll('.dropdown-menu').forEach(menu => menu.classList.add('hidden')); } // Quick Actions (for both top customers and table view) const quickAction = e.target.closest('.quick-action'); if (quickAction) { e.stopPropagation(); handleQuickAction(quickAction.dataset.action, quickAction.dataset.id); document.querySelectorAll('.dropdown-menu').forEach(menu => menu.classList.add('hidden')); } // Show Redeem GC Modal const showRedeemGcBtn = e.target.closest('.show-redeem-gc-btn'); if (showRedeemGcBtn) { const customerId = showRedeemGcBtn.dataset.id; const customer = state.customers.find(c => c.id === customerId); const fd = new FormData(); fd.append('action', 'get_gift_cards'); fd.append('customer_id', customerId); fd.append('status', 'active'); apiCall(fd).then(result => { if (result && result.success) { const activeGcs = result.data; const modalHTML = ` <div class="modal-container fixed inset-0 z-50 flex items-center justify-center opacity-0 transition-opacity duration-300"> <div class="modal-overlay absolute inset-0 bg-black bg-opacity-50"></div> <div class="modal-content bg-white rounded-[2.5rem] shadow-2xl p-8 w-full max-w-lg relative"> <button class="modal-close-btn absolute top-6 right-6 w-10 h-10 rounded-full bg-slate-100 hover:bg-slate-200 flex items-center justify-center transition-colors"><i class="fas fa-times"></i></button> <h3 class="text-3xl font-black font-headline text-slate-800">Redeem Card</h3> <p class="text-sm text-slate-500 mt-1 font-medium">Select an active card for <span class="font-bold text-primary">${customer.name}</span>.</p> <div class="space-y-4 max-h-[400px] overflow-y-auto mt-8 pr-2 custom-scrollbar"> ${activeGcs.length > 0 ? activeGcs.map(gc => ` <div class="sleek-card-bg p-6 rounded-3xl flex flex-col justify-between group transition-all duration-300 hover:scale-[1.02] border border-slate-100 text-white min-h-[180px]"> <div class="card-wave"></div> <svg class="absolute top-0 left-0 w-full h-full opacity-30 pointer-events-none" viewBox="0 0 100 100" preserveAspectRatio="none"> <path d="M0 30 Q 50 10 100 30" stroke="white" stroke-width="0.2" fill="none" /> </svg> <div class="relative z-10 flex justify-between items-start"> <div> <p class="font-black metallic-gold text-lg tracking-tight uppercase">Gift Card</p> <p class="text-xs text-white/60 font-bold uppercase tracking-widest">Premium Rewards</p> </div> <button class="redeem-gc-confirm-btn bg-white text-primary px-6 py-2 rounded-xl font-black text-xs shadow-xl hover:bg-opacity-90 active:scale-95 transition-all" data-id="${gc.id}">Redeem</button> </div> <div class="relative z-10 text-right mt-4"> <p class="text-[10px] text-white/50 uppercase font-black tracking-widest mb-1">Total Value</p> <p class="text-5xl font-black tracking-tighter metallic-gold leading-none">$${parseFloat(gc.amount).toFixed(0)}</p> </div> </div> `).join('') : ` <div class="text-center py-12 bg-slate-50 rounded-2xl border-2 border-dashed border-slate-200"> <i class="fas fa-gift text-4xl text-slate-200 mb-2 opacity-50"></i> <p class="text-slate-400 font-bold px-4">No active gift cards available.</p> </div> `} </div> <div class="mt-8 flex justify-center"> <button class="modal-cancel px-10 py-3 rounded-2xl font-black text-xs uppercase tracking-widest text-slate-400 hover:bg-slate-50 transition-colors">Dismiss</button> </div> </div> </div>`; const modalWrapper = openModal(modalHTML); modalWrapper.querySelectorAll('.redeem-gc-confirm-btn').forEach(btn => { btn.onclick = () => { const gcId = btn.dataset.id; const redeemFd = new FormData(); redeemFd.append('action', 'redeem_gift_card'); redeemFd.append('giftCardId', gcId); apiCall(redeemFd).then(res => { if(res && res.success) { showSuccess(res.data.message); closeModal(modalWrapper); refreshData(); } }); }; }); } }); } // Pagination Buttons const paginationBtn = e.target.closest('.pagination-btn'); if (paginationBtn) { state.currentPage = parseInt(paginationBtn.dataset.page, 10); renderCustomerLists(customerSearchInput.value); } // Admin Dashboard Tabs const adminTabBtn = e.target.closest('.admin-tab-btn'); if (adminTabBtn) { document.querySelectorAll('.admin-tab-btn').forEach(btn => { btn.classList.remove('border-primary', 'text-primary'); btn.classList.add('border-transparent', 'text-gray-500', 'hover:text-gray-700', 'hover:border-gray-300'); }); adminTabBtn.classList.add('border-primary', 'text-primary'); adminTabBtn.classList.remove('border-transparent', 'text-gray-500'); document.querySelectorAll('.admin-tab-content').forEach(content => { content.classList.add('hidden'); }); document.getElementById(`${adminTabBtn.dataset.tab}-tab-content`).classList.remove('hidden'); } }); const handleAddPointClick = () => { const modalHTML = ` <div class="modal-container fixed inset-0 z-[100] flex items-center justify-center opacity-0 transition-opacity duration-300"> <div class="modal-overlay absolute inset-0 bg-black bg-opacity-50"></div> <div class="modal-content bg-white rounded-lg shadow-xl p-6 w-full max-w-sm relative"> <button class="modal-close-btn absolute top-2 right-2 w-8 h-8 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center transition-colors"><i class="fas fa-times"></i></button> <h3 class="text-xl font-headline font-bold text-slate-800">Add Point</h3> <p class="text-sm text-slate-500 mb-4 font-medium">Scan a QR code or search below.</p> <div id="scanner-container" class="w-full h-auto bg-slate-100 rounded-md overflow-hidden"></div> <hr class="my-4 border-slate-100"/> <div> <label class="font-bold text-slate-700 text-xs uppercase tracking-widest">Manual Search</label> <div class="relative mt-2"> <i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"></i> <input type="text" id="manual-search-input" class="w-full h-10 pl-10 px-3 py-2 border rounded-md focus:outline-none focus:ring-2 ring-primary border-slate-200" placeholder="Find by name or phone..."> </div> <div id="manual-search-results" class="mt-2 space-y-2 max-h-40 overflow-y-auto custom-scrollbar"></div> </div> </div> </div>`; const modalWrapper = openModal(modalHTML); const onScanSuccess = (decodedText, decodedResult) => { const formData = new FormData(); formData.append('action', 'add_point_from_scan'); formData.append('qrCodeValue', decodedText); apiCall(formData).then(result => { if (result) { showSuccess(result.data.message); refreshData(); } }); closeModal(modalWrapper); }; try { activeScanner = new Html5Qrcode("scanner-container"); activeScanner.start({ facingMode: "environment" }, { fps: 10, qrbox: { width: 250, height: 250 } }, onScanSuccess, (errorMessage) => {}) .catch(err => { modalWrapper.querySelector('#scanner-container').innerHTML = `<div class="p-4 text-center text-red-600 bg-red-50 rounded-md">Could not start camera. Please grant permission and refresh.</div>`; }); } catch(e) { modalWrapper.querySelector('#scanner-container').innerHTML = `<div class="p-4 text-center text-red-600 bg-red-50 rounded-md">Could not start camera. Please grant permission and refresh.</div>`; } const searchInput = modalWrapper.querySelector('#manual-search-input'); const searchResultsContainer = modalWrapper.querySelector('#manual-search-results'); searchInput.addEventListener('input', (e) => { const query = e.target.value.toLowerCase(); if (!query) { searchResultsContainer.innerHTML = ''; return; } const results = state.customers.filter(c => c.name.toLowerCase().includes(query) || c.phone.includes(query)).slice(0, 5); searchResultsContainer.innerHTML = results.map(c => { const starsHtml = Array(10).fill(0).map((_, i) => `<i class="fas fa-star text-xs ${i < c.points ? 'text-yellow-400' : 'text-slate-300'}"></i>`).join(''); return ` <div class="flex items-center justify-between p-2 rounded-md bg-slate-50 border border-slate-100"> <div> <p class="font-bold text-sm flex items-center text-slate-800">${c.name}</p> <div class="text-[10px] text-slate-500 mt-1 flex items-center gap-2 font-medium"> <span>${c.phone}</span> <span class="flex items-center gap-0.5">${starsHtml}</span> </div> </div> <button class="manual-add-point-btn bg-blue-100 text-blue-700 text-[10px] font-black uppercase tracking-widest px-3 py-1.5 rounded-md hover:bg-blue-200 shrink-0 transition-all active:scale-95 shadow-sm" data-id="${c.id}">Add Point</button> </div>`; }).join(''); }); searchResultsContainer.addEventListener('click', (e) => { const btn = e.target.closest('.manual-add-point-btn'); if (btn) { const customerId = btn.dataset.id; handleQuickAction('add-point', customerId, 'Manual Search'); closeModal(modalWrapper); } }); }; const handleAddGiftCardClick = () => { const modalHTML = ` <div class="modal-container fixed inset-0 z-50 flex items-center justify-center opacity-0 transition-opacity duration-300"> <div class="modal-overlay absolute inset-0 bg-black bg-opacity-50"></div> <div class="modal-content bg-white rounded-lg shadow-xl p-6 w-full max-w-sm relative"> <button class="modal-close-btn absolute top-2 right-2 w-8 h-8 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center transition-colors"><i class="fas fa-times"></i></button> <h3 class="text-xl font-headline font-bold text-slate-800">Create Gift Card</h3> <p class="text-sm text-slate-500 mb-4 font-medium">Assign a digital gift card to a customer.</p> <form id="create-gc-form" class="space-y-4"> <div class="relative"> <label class="block text-sm font-bold text-slate-700">Customer</label> <div class="relative mt-1"> <i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"></i> <input type="text" id="gc-customer-search" class="w-full h-10 pl-10 px-3 py-2 border rounded-md focus:outline-none focus:ring-2 ring-primary border-slate-200" placeholder="Search by name or number..."> </div> <input type="hidden" name="customerId" id="gc-selected-customer-id" required> <div id="gc-customer-results" class="absolute z-10 w-full bg-white border border-slate-200 rounded-md shadow-lg mt-1 max-h-40 overflow-y-auto hidden custom-scrollbar"></div> <div id="gc-selected-customer-display" class="mt-2 text-sm font-bold text-primary hidden"></div> </div> <div> <label class="block text-sm font-bold text-slate-700">Amount ($)</label> <input type="number" name="amount" required step="0.01" min="0.01" class="w-full mt-1 p-2 border rounded-md border-slate-200 focus:outline-none focus:ring-2 ring-primary" placeholder="0.00"> </div> <div> <label class="block text-sm font-bold text-slate-700">Title</label> <input type="text" name="title" required class="w-full mt-1 p-2 border rounded-md border-slate-200 focus:outline-none focus:ring-2 ring-primary" value="Premium Pass"> </div> <div> <label class="block text-sm font-bold text-slate-700">Description</label> <textarea name="description" rows="3" class="w-full mt-1 p-2 border rounded-md border-slate-200 focus:outline-none focus:ring-2 ring-primary">Enjoy your gift card from ${state.settings.businessName}. Thank you for being a valued customer.</textarea> </div> <div class="flex justify-end gap-2 pt-4"> <button type="button" class="modal-cancel px-4 py-2 rounded-md border border-slate-200 hover:bg-slate-100 transition-colors text-slate-500 font-bold text-xs uppercase tracking-widest">Cancel</button> <button type="submit" class="bg-amber-500 text-white px-6 py-2 rounded-md font-bold text-xs uppercase tracking-widest hover:bg-amber-600 transition-all active:scale-95 shadow-sm">Assign Card</button> </div> </form> </div> </div>`; const modalWrapper = openModal(modalHTML); const searchInput = modalWrapper.querySelector('#gc-customer-search'); const resultsContainer = modalWrapper.querySelector('#gc-customer-results'); const selectedIdInput = modalWrapper.querySelector('#gc-selected-customer-id'); const selectedDisplay = modalWrapper.querySelector('#gc-selected-customer-display'); searchInput.addEventListener('input', () => { const query = searchInput.value.toLowerCase(); if (!query) { resultsContainer.innerHTML = ''; resultsContainer.classList.add('hidden'); return; } const matches = state.customers.filter(c => c.name.toLowerCase().includes(query) || c.phone.includes(query)).slice(0, 5); resultsContainer.innerHTML = matches.map(c => `<div class="p-2 hover:bg-slate-100 cursor-pointer text-sm" data-id="${c.id}" data-name="${c.name}">${c.name} (${c.phone})</div>`).join(''); resultsContainer.classList.remove('hidden'); }); resultsContainer.addEventListener('click', (e) => { const item = e.target.closest('[data-id]'); if (item) { selectedIdInput.value = item.dataset.id; selectedDisplay.textContent = `Selected: ${item.dataset.name}`; selectedDisplay.classList.remove('hidden'); resultsContainer.classList.add('hidden'); searchInput.value = ''; } }); modalWrapper.querySelector('#create-gc-form').onsubmit = (e) => { e.preventDefault(); if (!selectedIdInput.value) { showError("Please select a customer."); return; } const fd = new FormData(e.target); fd.append('action', 'create_gift_card'); apiCall(fd).then(result => { if (result && result.success) { showSuccess(result.data.message); closeModal(modalWrapper); refreshData(); } }); }; }; const handleSettingsClick = () => { const adminsListHtml = state.admins.map(admin => ` <div class="flex items-center justify-between p-2 rounded-md hover:bg-slate-100" data-admin-id="${admin.id}"> <div class="flex items-center gap-3"> <i class="fas fa-shield-alt text-slate-500"></i> <div> <p class="font-semibold">${admin.name}</p> <p class="text-sm text-slate-500">@${admin.username}</p> </div> </div> ${state.admins.length > 1 && admin.id !== state.currentUser.id ? `<button type="button" class="delete-admin-btn text-red-500 hover:text-red-700 transition-colors" data-admin-id="${admin.id}" data-admin-name="${admin.name}"><i class="fas fa-trash"></i></button>` : ''} </div> `).join(''); const hasBioEnabled = localStorage.getItem('omni-biometric-token') !== null; const modalHTML = ` <div class="modal-container fixed inset-0 z-50 flex items-center justify-center opacity-0 transition-opacity duration-300"> <div class="modal-overlay absolute inset-0 bg-black bg-opacity-50"></div> <div class="modal-content bg-white rounded-lg shadow-xl p-6 w-full max-w-lg relative max-h-[90vh] overflow-y-auto custom-scrollbar"> <button class="modal-close-btn absolute top-2 right-2 w-8 h-8 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center transition-colors"><i class="fas fa-times"></i></button> <h3 class="text-xl font-headline font-bold text-slate-800">Business Settings</h3> <p class="text-sm text-slate-500 font-medium">Update your business information and appearance.</p> <form id="settings-form" class="mt-6 space-y-6"> <div> <label class="block text-sm font-bold text-slate-700">Business Name</label> <input type="text" name="businessName" class="w-full mt-1 p-2 border rounded border-slate-200 focus:outline-none focus:ring-2 ring-primary transition-all" value="${state.settings.businessName || ''}"> </div> <div> <label class="block text-sm font-bold text-slate-700">Business Logo</label> <div class="mt-1 flex items-center gap-4"> <div class="logo-placeholder w-16 h-16 rounded-full bg-slate-100 flex items-center justify-center overflow-hidden border border-slate-100 shadow-inner"> <i class="fas fa-star text-2xl text-slate-400"></i> </div> <input type="file" name="logoFile" id="logo-upload-input" class="hidden" accept="image/*"> <button type="button" id="logo-upload-btn" class="px-3 py-2 border border-slate-200 rounded-md text-sm hover:bg-slate-50 transition-colors font-bold text-slate-500"> <i class="fas fa-upload mr-2"></i>Upload Logo </button> </div> </div> <div> <label class="block text-sm font-bold text-slate-700">Website Favicon</label> <div class="mt-1 flex items-center gap-4"> <div id="favicon-preview-container" class="w-16 h-16 p-2 rounded-full bg-slate-100 flex items-center justify-center overflow-hidden border border-slate-100 shadow-inner"> <i class="far fa-image text-2xl text-slate-400"></i> </div> <input type="file" name="faviconFile" id="favicon-upload-input" class="hidden" accept="image/x-icon,image/png,image/svg+xml"> <button type="button" id="favicon-upload-btn" class="px-3 py-2 border border-slate-200 rounded-md text-sm hover:bg-slate-50 transition-colors font-bold text-slate-500"> <i class="fas fa-upload mr-2 text-slate-400"></i>Upload Favicon </button> </div> </div> <hr class="border-slate-100"/> <div> <h4 class="font-bold text-slate-800 text-sm uppercase tracking-widest">Brand Colors</h4> <p class="text-xs text-slate-500 mb-2 font-medium">Customize the look of your loyalty portal.</p> <div id="color-suggestion-container" class="mb-4 flex gap-2 overflow-x-auto pb-2"></div> <div class="grid grid-cols-2 gap-4"> <div class="space-y-1"> <label for="primary-color-picker" class="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Primary Color</label> <input type="color" id="primary-color-picker" class="w-full h-10 p-1 border border-slate-200 rounded-md cursor-pointer" value="#6d28d9"> </div> <div class="space-y-1"> <label for="accent-color-picker" class="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Accent Color</label> <input type="color" id="accent-color-picker" class="w-full h-10 p-1 border border-slate-200 rounded-md cursor-pointer" value="#f3f4f6"> </div> </div> <div class="mt-2"> <button type="button" id="reset-color-btn" class="text-[10px] font-bold text-primary uppercase tracking-widest hover:underline">Reset to Default</button> </div> <input type="hidden" name="primaryColor" id="primary-color-input" value="${state.settings.primaryColor || '262 83% 57%'}"> <input type="hidden" name="backgroundColor" id="background-color-input" value="${state.settings.backgroundColor || '240 60% 99%'}"> <input type="hidden" name="accentColor" id="accent-color-input" value="${state.settings.accentColor || '220 14% 96%'}"> </div> <hr class="border-slate-100"/> <div> <h4 class="font-bold text-slate-800 text-sm uppercase tracking-widest mb-3">Security & Biometrics</h4> <div class="flex items-center justify-between p-4 rounded-2xl bg-slate-50 border border-slate-100 mb-4"> <div class="flex items-center gap-3"> <div class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary"> <i class="fas fa-fingerprint"></i> </div> <div> <p class="text-sm font-bold text-slate-800">Biometric Sign-in</p> <p class="text-[10px] text-slate-500 uppercase font-black tracking-widest" id="admin-bio-status">${hasBioEnabled ? 'Enabled' : 'Not enabled'}</p> </div> </div> <button type="button" id="enable-admin-bio-btn" class="px-4 py-1.5 ${hasBioEnabled ? 'bg-slate-200 text-slate-500 cursor-not-allowed' : 'bg-primary text-white'} rounded-lg text-xs font-bold transition-all active:scale-95 shadow-sm" ${hasBioEnabled ? 'disabled' : ''}>${hasBioEnabled ? 'Active' : 'Enable'}</button> </div> <div class="space-y-3"> <div> <label class="block text-[10px] font-bold text-slate-400 uppercase tracking-widest">Username</label> <input type="text" name="adminUsername" class="w-full mt-1 p-2 border border-slate-200 rounded focus:ring-2 ring-primary outline-none transition-all" value="${state.currentUser.username}"> </div> <div> <label class="block text-[10px] font-bold text-slate-400 uppercase tracking-widest">New Password</label> <input type="password" name="newPassword" class="w-full mt-1 p-2 border border-slate-200 rounded focus:ring-2 ring-primary outline-none transition-all" placeholder="Leave blank to keep current"> </div> <div> <label class="block text-[10px] font-bold text-slate-400 uppercase tracking-widest">Confirm New Password</label> <input type="password" name="confirmNewPassword" class="w-full mt-1 p-2 border border-slate-200 rounded focus:ring-2 ring-primary outline-none transition-all"> </div> </div> </div> <hr class="border-slate-100"/> <div> <div class="flex justify-between items-center"> <h4 class="font-bold text-slate-800 text-sm uppercase tracking-widest">Manage Staff</h4> <button type="button" id="add-admin-modal-btn" class="text-[10px] font-bold bg-primary text-white px-3 py-1.5 rounded-lg hover:bg-opacity-90 transition-all active:scale-95 shadow-sm uppercase tracking-widest">Add Admin</button> </div> <div class="mt-3 space-y-1 border border-slate-100 rounded-xl p-2 max-h-48 overflow-y-auto custom-scrollbar bg-slate-50/50"> ${adminsListHtml} </div> </div> <hr class="border-slate-100"/> <div class="p-4 rounded-2xl border border-red-100 bg-red-50/50 space-y-2"> <h4 class="font-bold text-red-700 flex items-center text-sm uppercase tracking-wider"><i class="fas fa-exclamation-triangle mr-2"></i>Danger Zone</h4> <p class="text-xs text-red-600/70 font-medium">This action is permanent and will delete all customer data.</p> <button type="button" id="delete-all-customers-btn" class="bg-red-600 text-white px-4 py-2 rounded-xl font-bold text-[10px] uppercase tracking-widest hover:bg-red-700 transition-all active:scale-95 shadow-lg shadow-red-100">Delete All Customers</button> </div> </form> <div class="mt-8 flex justify-end gap-2"> <button class="modal-cancel px-6 py-2 rounded-xl border border-slate-200 font-bold text-[10px] uppercase tracking-widest text-slate-500 hover:bg-slate-50 transition-colors">Cancel</button> <button id="save-settings-btn" class="bg-primary text-white px-8 py-2 rounded-xl font-bold text-[10px] uppercase tracking-widest hover:bg-opacity-90 transition-all active:scale-95 shadow-lg">Save Changes</button> </div> </div> </div>`; const modalWrapper = openModal(modalHTML); const logoPreviewContainer = modalWrapper.querySelector('.logo-placeholder'); if (state.settings.logoUrl) { logoPreviewContainer.innerHTML = `<img src="${state.settings.logoUrl}" alt="Logo" class="w-full h-full rounded-full object-cover">`; } const faviconPreviewContainer = modalWrapper.querySelector('#favicon-preview-container'); if (state.settings.faviconUrl) { faviconPreviewContainer.innerHTML = `<img src="${state.settings.faviconUrl}" alt="Favicon" class="w-full h-full object-contain">`; } modalWrapper.querySelector('#logo-upload-btn').addEventListener('click', () => modalWrapper.querySelector('#logo-upload-input').click()); modalWrapper.querySelector('#favicon-upload-btn').addEventListener('click', () => modalWrapper.querySelector('#favicon-upload-input').click()); modalWrapper.querySelector('#logo-upload-input').addEventListener('change', (e) => { const file = e.target.files[0]; if (file) { const reader = new FileReader(); reader.onload = (event) => { const imageUrl = event.target.result; logoPreviewContainer.innerHTML = `<img src="${imageUrl}" alt="Logo Preview" class="w-full h-full rounded-full object-cover">`; const img = document.createElement('img'); img.src = imageUrl; img.onload = () => { const colorThief = new ColorThief(); const palette = colorThief.getPalette(img, 3); if (palette) { const suggestionContainer = modalWrapper.querySelector('#color-suggestion-container'); suggestionContainer.innerHTML = '<p class="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] w-full mb-2">Logo Palette Suggestions:</p>' + palette.map(rgb => { const hex = `#${rgb.map(c => c.toString(16).padStart(2, '0')).join('')}`; return `<button type="button" class="color-suggestion w-10 h-10 rounded-xl border-2 border-white shadow-sm transition-all hover:scale-110 active:scale-90" style="background-color: ${hex};" data-color-hex="${hex}"></button>`; }).join(''); } }; }; reader.readAsDataURL(file); } }); modalWrapper.querySelector('#favicon-upload-input').addEventListener('change', (event) => { const file = event.target.files[0]; if (file) { const reader = new FileReader(); reader.onload = (event) => { faviconPreviewContainer.innerHTML = `<img src="${event.target.result}" alt="Favicon Preview" class="w-full h-full object-contain">`; }; reader.readAsDataURL(file); } }); const hexToHsl = (hex) => { if (!hex) return null; hex = hex.replace(/^#/, ''); if (hex.length === 3) { hex = hex.split('').map(char => char + char).join(''); } const r = parseInt(hex.substring(0, 2), 16) / 255; const g = parseInt(hex.substring(2, 4), 16) / 255; const b = parseInt(hex.substring(4, 6), 16) / 255; const max = Math.max(r, g, b), min = Math.min(r, g, b); let h=0, s=0, l = (max + min) / 2; if (max !== min) { const d = max - min; s = l > 0.5 ? d / (2 - max - min) : d / (max + min); switch (max) { case r: h = (g - b) / d + (g < b ? 6 : 0); break; case g: h = (b - r) / d + 2; break; case b: h = (r - g) / d + 4; break; } h /= 6; } return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`; }; modalWrapper.querySelector('#color-suggestion-container').addEventListener('click', e => { if (e.target.classList.contains('color-suggestion')) { const hexColor = e.target.dataset.colorHex; const hslColor = hexToHsl(hexColor); document.documentElement.style.setProperty('--primary-hsl', hslColor); modalWrapper.querySelector('#primary-color-input').value = hslColor; modalWrapper.querySelector('#primary-color-picker').value = hexColor; showSuccess("Primary color applied!"); } }); modalWrapper.querySelector('#reset-color-btn').addEventListener('click', () => { const defaultPrimary = '262 83% 57%'; const defaultAccent = '220 14% 96%'; document.documentElement.style.setProperty('--primary-hsl', defaultPrimary); document.documentElement.style.setProperty('--accent-hsl', defaultAccent); modalWrapper.querySelector('#primary-color-input').value = defaultPrimary; modalWrapper.querySelector('#accent-color-input').value = defaultAccent; modalWrapper.querySelector('#primary-color-picker').value = '#6d28d9'; modalWrapper.querySelector('#accent-color-picker').value = '#f3f4f6'; showSuccess("Colors reset to default."); }); modalWrapper.querySelector('#primary-color-picker').addEventListener('input', e => { modalWrapper.querySelector('#primary-color-input').value = hexToHsl(e.target.value); }); modalWrapper.querySelector('#accent-color-picker').addEventListener('input', e => { modalWrapper.querySelector('#accent-color-input').value = hexToHsl(e.target.value); }); modalWrapper.querySelector('#save-settings-btn').addEventListener('click', () => { const form = modalWrapper.querySelector('#settings-form'); const formData = new FormData(form); formData.append('action', 'update_settings'); if (formData.get('newPassword') !== formData.get('confirmNewPassword')) { showError("New passwords do not match."); return; } apiCall(formData).then(result => { if (result) { showSuccess(result.data.message); refreshData(); closeModal(modalWrapper); } }); }); modalWrapper.querySelector('#add-admin-modal-btn').addEventListener('click', () => { const addAdminModalHTML = ` <div class="modal-container fixed inset-0 z-50 flex items-center justify-center opacity-0 transition-opacity duration-300"> <div class="modal-overlay absolute inset-0 bg-black bg-opacity-50"></div> <div class="modal-content bg-white rounded-lg shadow-xl p-6 w-full max-w-sm relative"> <button class="modal-close-btn absolute top-2 right-2 w-8 h-8 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center transition-colors"><i class="fas fa-times"></i></button> <h3 class="text-lg font-bold text-slate-800">Add New Admin</h3> <form id="add-admin-form" class="mt-4 space-y-2"> <input type="text" name="name" class="w-full p-2 border border-slate-200 rounded focus:ring-2 ring-primary outline-none transition-all" placeholder="Full Name" required> <input type="text" name="username" class="w-full p-2 border border-slate-200 rounded focus:ring-2 ring-primary outline-none transition-all" placeholder="Username" required> <input type="password" name="password" class="w-full p-2 border border-slate-200 rounded focus:ring-2 ring-primary outline-none transition-all" placeholder="Password" required> </form> <div class="mt-4 flex justify-end gap-2"> <button class="modal-cancel px-4 py-2 rounded font-bold text-xs uppercase tracking-widest border border-slate-200 hover:bg-slate-50 transition-colors">Cancel</button> <button class="modal-confirm bg-primary text-white px-4 py-2 rounded font-bold text-xs uppercase tracking-widest hover:bg-opacity-90 transition-all active:scale-95 shadow-sm">Create</button> </div> </div> </div>`; const addAdminModal = openModal(addAdminModalHTML); addAdminModal.querySelector('.modal-confirm').addEventListener('click', () => { const form = addAdminModal.querySelector('#add-admin-form'); if(form.reportValidity()) { const formData = new FormData(form); formData.append('action', 'add_admin'); apiCall(formData).then(result => { if (result) { showSuccess(result.data.message); refreshData(); closeModal(addAdminModal); closeModal(modalWrapper); } }); } }); addAdminModal.querySelector('.modal-cancel').onclick = () => closeModal(addAdminModal); }); modalWrapper.querySelectorAll('.delete-admin-btn').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); e.preventDefault(); const adminId = btn.dataset.adminId; const adminName = btn.dataset.adminName; const confirmModal = openModal(` <div class="modal-container fixed inset-0 z-[102] flex items-center justify-center opacity-0 transition-opacity duration-300"> <div class="modal-overlay absolute inset-0 bg-black bg-opacity-50"></div> <div class="modal-content bg-white rounded-lg shadow-xl p-6 w-full max-w-sm relative"> <h3 class="text-lg font-bold text-slate-800">Are you sure?</h3> <p class="mt-2 text-sm text-gray-500">This will permanently delete the admin account for ${adminName}.</p> <div class="mt-4 flex justify-end gap-2"> <button class="modal-cancel px-4 py-2 rounded border border-slate-200 font-bold text-xs uppercase tracking-widest text-slate-500 transition-colors hover:bg-slate-50">Cancel</button> <button class="modal-confirm bg-red-600 text-white px-4 py-2 rounded font-bold text-xs uppercase tracking-widest transition-all active:scale-95 shadow-sm">Delete Admin</button> </div> </div> </div>`, { persistent: true }); confirmModal.querySelector('.modal-confirm').onclick = () => { const formData = new FormData(); formData.append('action', 'delete_admin'); formData.append('adminId', adminId); apiCall(formData).then(result => { if(result) { showSuccess(result.data.message); refreshData(); closeModal(confirmModal); closeModal(modalWrapper); } }); }; confirmModal.querySelector('.modal-cancel').onclick = () => closeModal(confirmModal); }); }); modalWrapper.querySelector('#delete-all-customers-btn').addEventListener('click', () => { const confirmModal = openModal(` <div class="modal-container fixed inset-0 z-50 flex items-center justify-center opacity-0 transition-opacity duration-300"> <div class="modal-overlay absolute inset-0 bg-black bg-opacity-50"></div> <div class="modal-content bg-white rounded-lg shadow-xl p-6 w-full max-w-sm relative"> <button class="modal-close-btn absolute top-2 right-2 w-8 h-8 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center transition-colors"><i class="fas fa-times"></i></button> <h3 class="text-lg font-bold text-red-600">Are you absolutely sure?</h3> <p class="mt-2 text-sm text-gray-500">This will permanently delete all customer accounts and their history. This cannot be undone.</p> <div class="mt-4 flex justify-end gap-2"> <button class="modal-cancel px-4 py-2 rounded border border-slate-200 font-bold text-xs uppercase tracking-widest text-slate-500 transition-colors hover:bg-slate-50">Cancel</button> <button class="modal-confirm bg-red-600 text-white px-4 py-2 rounded font-bold text-xs uppercase tracking-widest transition-all active:scale-95 shadow-sm">Yes, Delete All</button> </div> </div> </div>`); confirmModal.querySelector('.modal-confirm').onclick = () => { const formData = new FormData(); formData.append('action', 'delete_all_customers'); apiCall(formData).then(result => { if(result) { showSuccess(result.data.message); refreshData(); closeModal(confirmModal); closeModal(modalWrapper); } }); }; confirmModal.querySelector('.modal-cancel').onclick = () => closeModal(confirmModal); }); modalWrapper.querySelector('#enable-admin-bio-btn')?.addEventListener('click', async (e) => { const success = await Biometric.enable(); if (success) { e.target.textContent = 'Active'; e.target.disabled = true; e.target.classList.replace('bg-primary', 'bg-slate-200'); e.target.classList.replace('text-white', 'text-slate-500'); modalWrapper.querySelector('#admin-bio-status').textContent = 'Enabled'; } }); modalWrapper.querySelector('.modal-cancel').addEventListener('click', () => closeModal(modalWrapper)); }; const handleLogoutClick = () => { const formData = new FormData(); formData.append('action', 'logout'); apiCall(formData).then(() => { state.currentUser = null; state.userType = null; localStorage.removeItem('omni-cached-customer'); document.getElementById('customer-phone').value = ''; document.getElementById('customer-password').value = ''; document.getElementById('admin-username').value = ''; document.getElementById('admin-password').value = ''; initializeApp(); }); }; const handleSignupClick = () => { const modalHTML = ` <div class="modal-container fixed inset-0 z-[101] flex items-center justify-center opacity-0 transition-opacity duration-300"> <div class="modal-overlay absolute inset-0 bg-black bg-opacity-50"></div> <div class="modal-content bg-white rounded-lg shadow-xl p-6 w-full max-w-sm relative"> <button class="modal-close-btn absolute top-2 right-2 w-8 h-8 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center transition-colors"><i class="fas fa-times"></i></button> <h3 class="text-xl font-headline font-bold text-slate-800">Create Your Account</h3> <p class="text-sm text-slate-500 mb-4 font-medium">Join our loyalty program to earn rewards!</p> <form id="signup-form" class="space-y-4"> <div id="signup-error" class="hidden mb-4 p-3 bg-red-50 text-red-600 rounded-md text-sm font-medium"></div> <div> <label for="signup-name" class="block text-sm font-bold text-slate-700">Full Name</label> <input type="text" name="name" id="signup-name" required class="w-full mt-1 p-2 border border-slate-200 rounded-md focus:outline-none focus-visible:ring-2 ring-primary transition-all"> </div> <div> <label for="signup-phone" class="block text-sm font-bold text-slate-700">Phone Number</label> <input type="tel" name="phone" id="signup-phone" placeholder="+501-xxx-xxxx" required class="w-full mt-1 p-2 border border-slate-200 rounded-md focus:outline-none focus-visible:ring-2 ring-primary transition-all"> </div> <div> <label for="signup-password" class="block text-sm font-bold text-slate-700">Password</label> <input type="password" name="password" id="signup-password" required minlength="6" class="w-full mt-1 p-2 border border-slate-200 rounded-md focus:outline-none focus-visible:ring-2 ring-primary transition-all"> </div> <button type="submit" class="w-full bg-primary text-white py-3 rounded-md hover:bg-opacity-90 transition-all active:scale-95 shadow-sm font-bold uppercase tracking-widest text-xs mt-4">Create Account</button> </form> </div> </div>`; const modalWrapper = openModal(modalHTML); modalWrapper.querySelector('#signup-form').addEventListener('submit', e => { e.preventDefault(); const form = e.target; const errorEl = modalWrapper.querySelector('#signup-error'); errorEl.classList.add('hidden'); const formData = new FormData(form); formData.append('action', 'register_customer'); apiCall(formData).then(result => { if (result && result.success) { showSuccess('Registration successful! Welcome.'); closeModal(modalWrapper); // Auto-login logic state.currentUser = result.data.user; state.userType = 'customer'; localStorage.setItem('omni-cached-customer', JSON.stringify(result.data.user)); sessionData.hasSeenConfettiForRewards = 0; refreshData(true).then(() => { showPage('customer-dashboard'); }); } else { errorEl.textContent = result ? result.message : 'An unexpected error occurred.'; errorEl.classList.remove('hidden'); } }); }); }; // Desktop and Mobile Button Listeners document.getElementById('qr-scanner-btn').addEventListener('click', handleAddPointClick); document.getElementById('add-gift-card-btn').addEventListener('click', handleAddGiftCardClick); document.getElementById('mobile-add-point-btn').addEventListener('click', handleAddPointClick); document.getElementById('settings-btn').addEventListener('click', handleSettingsClick); document.getElementById('mobile-settings-btn').addEventListener('click', handleSettingsClick); document.getElementById('logout-btn').addEventListener('click', handleLogoutClick); document.getElementById('mobile-logout-btn').addEventListener('click', handleLogoutClick); document.getElementById('signup-now-btn').addEventListener('click', handleSignupClick); document.getElementById('add-customer-btn').addEventListener('click', () => { const modalHTML = ` <div class="modal-container fixed inset-0 z-50 flex items-center justify-center opacity-0 transition-opacity duration-300"> <div class="modal-overlay absolute inset-0 bg-black bg-opacity-50"></div> <div class="modal-content bg-white rounded-lg shadow-xl p-6 w-full max-w-sm relative"> <button class="modal-close-btn absolute top-2 right-2 w-8 h-8 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center transition-colors"><i class="fas fa-times"></i></button> <h3 class="text-lg font-headline font-bold text-slate-800">Add New Customer</h3> <form id="add-customer-form" class="mt-4"> <div class="mb-4"><label for="new-customer-name" class="block text-sm font-bold text-slate-700">Full Name</label><input type="text" name="name" id="new-customer-name" class="w-full mt-1 p-2 border border-slate-200 rounded focus:ring-2 ring-primary outline-none transition-all" required></div> <div class="mb-4"><label for="new-customer-phone" class="block text-sm font-bold text-slate-700">Phone</label><input type="tel" name="phone" id="new-customer-phone" placeholder="+501-xxx-xxxx" class="w-full mt-1 p-2 border border-slate-200 rounded focus:ring-2 ring-primary outline-none transition-all" required></div> </form> <div class="mt-4 flex justify-end gap-2"> <button class="modal-cancel px-4 py-2 rounded transition-colors hover:bg-slate-100 border border-slate-200 text-slate-500 font-bold text-xs uppercase tracking-widest">Cancel</button> <button class="modal-confirm bg-primary text-white px-4 py-2 rounded transition-all active:scale-95 font-bold text-xs uppercase tracking-widest shadow-sm">Add</button> </div> </div> </div>`; const modalWrapper = openModal(modalHTML); const input = modalWrapper.querySelector('#new-customer-name'); if(input) setTimeout(() => input.focus(), 400); modalWrapper.querySelector('.modal-confirm').addEventListener('click', () => { const form = modalWrapper.querySelector('#add-customer-form'); if(form.reportValidity()) { const formData = new FormData(form); const randomPassword = Math.random().toString(36).slice(-8); formData.append('password', randomPassword); formData.append('action', 'add_customer'); apiCall(formData).then(result => { if (result) { refreshData(); closeModal(modalWrapper); showPasswordModal(result.data.customer.name, randomPassword, result.data.customer.phone); } }); } }); modalWrapper.querySelector('.modal-cancel').addEventListener('click', () => closeModal(modalWrapper)); }); const customerSearchInput = document.getElementById('customer-search'); customerSearchInput.addEventListener('input', (e) => { state.currentPage = 1; const searchQuery = e.target.value; renderCustomerLists(searchQuery); if (searchQuery.trim() !== '') { const resultsArea = document.getElementById('customer-display-area'); if (resultsArea) { setTimeout(() => { resultsArea.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, 100); } } }); customerSearchInput.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.target.value = ''; e.target.dispatchEvent(new Event('input')); } }); window.addEventListener('statechange', () => { if (state.userType === 'admin') { renderAdminDashboard(); } else if(state.userType === 'customer') { renderCustomerDashboard(); } updateAllUIElements(); }); // Report generation logic document.getElementById('generate-pdf-btn').addEventListener('click', async (e) => { e.preventDefault(); const { jsPDF } = window.jspdf; const doc = new jsPDF({ orientation: 'p', unit: 'mm', format: 'a4' }); const reportType = document.getElementById('report-type').value; const startDate = document.getElementById('start-date').value; const endDate = document.getElementById('end-date').value; const customerId = document.getElementById('report-customer-id').value; if (reportType === 'points_by_customer' && !customerId) { showError("Please select a customer for this report."); return; } const formData = new FormData(); formData.append('action', 'get_report_data'); formData.append('report_type', reportType); formData.append('start_date', startDate); formData.append('end_date', endDate); formData.append('customer_id', customerId); const result = await apiCall(formData); if (!result || !result.success) { showError('Could not fetch report data.'); return; } const data = result.data; const businessName = state.settings.businessName || "Omni Rewards"; const reportDate = new Date().toLocaleDateString(); const primaryColorHsl = state.settings.primaryColor || '262 83% 57%'; const [h, s, l] = primaryColorHsl.split(' ').map(v => parseInt(v)); const hslToRgb = (h, s, l) => { s /= 100; l /= 100; let c = (1 - Math.abs(2 * l - 1)) * s, x = c * (1 - Math.abs((h / 60) % 2 - 1)), m = l - c/2, r = 0, g = 0, b = 0; if (0 <= h && h < 60) { r = c; g = x; b = 0; } else if (60 <= h && h < 120) { r = x; g = c; b = 0; } else if (120 <= h && h < 180) { r = 0; g = c; b = x; } else if (180 <= h && h < 240) { r = 0; g = x; b = c; } else if (240 <= h && h < 300) { r = x; g = 0; b = c; } else if (300 <= h && h < 360) { r = c; g = 0; b = x; } r = Math.round((r + m) * 255); g = Math.round((g + m) * 255); b = Math.round((b + m) * 255); return [r, g, b]; }; const headerColorRgb = hslToRgb(h, s, l); let finalY = 15; async function addHeader(title, subtitle) { let yPos = 15; const logoWidth = 20; const logoHeight = 20; const logoX = 14; const textX = logoX + logoWidth + 5; if (state.settings.logoUrl) { try { const img = new Image(); img.crossOrigin = 'Anonymous'; img.src = state.settings.logoUrl; await new Promise((res) => { img.onload = res; img.onerror = () => { console.error("Failed to load logo image for PDF."); res(); }; }); const canvas = document.createElement('canvas'); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0); const dataUrl = canvas.toDataURL('image/png'); doc.addImage(dataUrl, 'PNG', logoX, yPos, logoWidth, logoHeight); } catch (e) { console.error("Error adding logo to PDF:", e); } } doc.setFontSize(18); doc.setFont('helvetica', 'bold'); doc.text(businessName, textX, yPos + 7); doc.setDrawColor(200); doc.line(textX, yPos + 9, 200, yPos + 9); doc.setFontSize(11); doc.setFont('helvetica', 'normal'); doc.setTextColor(50); doc.text(title, textX, yPos + 15); if (subtitle) { doc.setFontSize(9); doc.text(subtitle, textX, yPos + 20); } finalY = yPos + 30; } let reportTitle = ''; let subtitle = `Generated on: ${reportDate}`; switch(reportType) { case 'points_by_customer': subtitle = `${new Date(startDate).toLocaleDateString()} - ${new Date(endDate).toLocaleDateString()}`; reportTitle = `Points History for ${data.customer_name}`; await addHeader(reportTitle, subtitle); doc.autoTable({ startY: finalY, head: [['Date & Time', 'Description', 'Admin', 'Points', 'Rewards Redeemed']], body: data.records.map(r => { const date = new Date(r.date).toLocaleString([], { dateStyle: 'short', timeStyle: 'short' }); return [date, r.description, r.admin_name, r.points, r.rewards_redeemed === 1 ? '1' : '0']; }), theme: 'striped', headStyles: { fillColor: headerColorRgb } }); break; case 'yearly_points': reportTitle = `Yearly Points Summary for ${data.year}`; await addHeader(reportTitle, subtitle); data.customers.forEach(customerData => { if (finalY > 250) { doc.addPage(); finalY = 15; // Reset Y position for new page header // Optionally re-add header on new page if needed } doc.setFontSize(12); doc.setFont('helvetica', 'bold'); doc.setTextColor(0); doc.text(customerData.customer_name, 14, finalY); finalY += 2; const tableBody = customerData.monthly_data.map(monthData => [ monthData.month, monthData.points_earned, monthData.rewards_redeemed ]); tableBody.push([ { content: 'Yearly Total', styles: { fontStyle: 'bold' } }, { content: customerData.yearly_total_points, styles: { fontStyle: 'bold' } }, { content: customerData.yearly_total_rewards, styles: { fontStyle: 'bold' } } ]); doc.autoTable({ startY: finalY, head: [['Month', 'Points Earned', 'Rewards Redeemed']], body: tableBody, theme: 'grid', headStyles: { fillColor: headerColorRgb, textColor: 255 }, styles: { fontSize: 9 }, didDrawPage: (data) => { finalY = data.cursor.y; } }); finalY += 10; }); break; case 'monthly_points': reportTitle = `Monthly Points Summary: ${data.month}`; await addHeader(reportTitle, subtitle); data.customers.forEach(customerData => { if (finalY > 260) { doc.addPage(); finalY = 15; } doc.setFontSize(12); doc.setFont('helvetica', 'bold'); doc.setTextColor(0); doc.text(customerData.customer_name, 14, finalY); finalY += 2; doc.autoTable({ startY: finalY, head: [['Date & Time', 'Description', 'Admin', 'Points', 'Rewards Redeemed']], body: customerData.transactions.map(t => { const date = new Date(t.date).toLocaleString([], { dateStyle: 'short', timeStyle: 'short' }); return [date, t.description, t.admin_name, t.points, t.rewards_redeemed === 1 ? '1' : '0']; }), theme: 'grid', headStyles: { fillColor: headerColorRgb, textColor: 255 }, styles: { fontSize: 9 }, didDrawPage: (data) => { finalY = data.cursor.y; } }); finalY += 10; }); break; } const fileName = `${reportTitle.replace(/ /g, '_')}_${new Date().toISOString().slice(0,10)}.pdf`; doc.save(fileName); }); if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker.register('sw.js').then(registration => { console.log('ServiceWorker registration successful with scope: ', registration.scope); }, err => { console.log('ServiceWorker registration failed: ', err); }); }); } initializeApp(); }); </script> </body> </html>Save