View file File name : index.php Content :<?php require 'db_config.php'; // Use folder name to generate a unique session name $folder = basename(dirname($_SERVER['PHP_SELF'])); $session_name = 'SESS_' . $folder; session_name($session_name); // 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); $protocol = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http"); $host = $_SERVER['HTTP_HOST']; $base_path = rtrim(dirname($_SERVER['PHP_SELF']), '/\\'); $base_url = $protocol . "://" . $host . $base_path . "/"; 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; } #install-banner { display: none; /* Hidden by default */ position: fixed; bottom: 1rem; left: 1rem; right: 1rem; max-width: 400px; margin: 0 auto; z-index: 1000; transition: transform 0.3s ease-in-out; transform: translateY(200%); } #install-banner.show { transform: translateY(0); } </style> </head> <body class="bg-app-background text-slate-800 flex flex-col min-h-screen"> <!-- Login Page --> <!-- PWA Install Prompt Banner --> <div id="install-banner" class="bg-white p-4 rounded-lg shadow-2xl flex items-center gap-4"> <div class="logo-placeholder w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center shrink-0"> <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-6 h-6 text-primary"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" x2="12" y1="3" y2="15"/></svg> </div> <div class="flex-grow"> <p class="font-bold text-sm">Install the App!</p> <p class="text-xs text-slate-600">Get a faster, full-screen experience.</p> </div> <div class="flex items-center gap-2"> <button id="install-button" class="bg-primary text-white px-4 py-2 rounded-md text-sm font-semibold hover:bg-opacity-90">Install</button> <button id="close-install-banner" class="w-8 h-8 rounded-md hover:bg-slate-100 flex items-center justify-center text-slate-500"> <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"> <line x1="18" y1="6" x2="6" y2="18"/> <line x1="6" y1="6" x2="18" y2="18"/> </svg> </button> </div> </div> <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"> <div class="text-center p-6"> <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"> <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"> <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"> <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 mt-6"> <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">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"> </div> <div class="space-y-2 text-left"> <label for="customer-password" class="text-sm font-medium">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"> </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"> <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 class="text-center mt-4 text-sm"> <span class="text-slate-600">Not a member?</span> <button id="signup-now-btn" class="font-medium text-primary hover:opacity-80">Signup now</button> </div> </div> <div id="admin-form" class="tab-content mt-6 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">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"> </div> <div class="space-y-2 text-left"> <label for="admin-password" class="text-sm font-medium">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"> </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"> <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> </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-wrap 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"> <i class="fas fa-plus-circle"></i> Add Point </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"> <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"> <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" 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" 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" 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 rounded-md focus:outline-none focus-visible:ring-2 ring-primary" 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 duration-200 flex items-center justify-center gap-2"> <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"> <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"> <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"> <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" 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"></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"> </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"> </div> </div> </div> <div class="mt-6"> <button id="generate-pdf-btn" class="bg-primary text-white font-medium py-2 px-5 rounded-md inline-flex items-center gap-2 hover:bg-opacity-90"> <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"> <button id="mobile-settings-btn" class="flex flex-col items-center text-gray-600 hover:text-primary text-sm w-1/3"> <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"> <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"> <i class="fas fa-plus text-2xl"></i> </div> <span class="mt-1">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"> <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"><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"><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"> <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, }; 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; } } // --- PWA Install Prompt Logic --- const installBanner = document.getElementById('install-banner'); const installButton = document.getElementById('install-button'); const closeInstallBannerButton = document.getElementById('close-install-banner'); let deferredPrompt; window.addEventListener('beforeinstallprompt', (e) => { // Prevent the mini-infobar from appearing on mobile e.preventDefault(); // Stash the event so it can be triggered later. deferredPrompt = e; // Update UI to notify the user they can install the PWA if (installBanner) { installBanner.style.display = 'flex'; // Use a timeout to allow the display property to apply before transitioning setTimeout(() => { installBanner.classList.add('show'); }, 100); } }); if (installButton) { installButton.addEventListener('click', async () => { if (!deferredPrompt) { // The deferred prompt isn't available. return; } // Show the install prompt. deferredPrompt.prompt(); // Wait for the user to respond to the prompt. const { outcome } = await deferredPrompt.userChoice; console.log(`User response to the install prompt: ${outcome}`); // We've used the prompt, and can't use it again, throw it away deferredPrompt = null; // Hide the install banner if (installBanner) { installBanner.classList.remove('show'); } }); } if (closeInstallBannerButton) { closeInstallBannerButton.addEventListener('click', () => { if (installBanner) { installBanner.classList.remove('show'); } }); } window.addEventListener('appinstalled', () => { // Hide the install banner if (installBanner) { installBanner.classList.remove('show'); } deferredPrompt = null; console.log('PWA was installed'); }); 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 }; 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; 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) { document.getElementById('favicon-link').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 '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"><i class="fas fa-times"></i></button> <h3 class="text-lg font-medium">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">Cancel</button> <button class="modal-confirm bg-red-600 text-white px-4 py-2 rounded">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); if (currentCustomer && (currentCustomer.points != latestData.points || currentCustomer.rewards != latestData.rewards)) { await refreshData(true); } } }, 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 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 flex-wrap justify-center"> <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"> <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"> <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">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-lg border border-dashed border-slate-300"> <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">${customer.qrCodeValue}</p> </div> <div class="p-6 border-t"><p class="text-center text-sm text-slate-500 w-full">Show this QR code to the cashier to earn points with your purchase.</p></div> </div> </div> </div> `; container.innerHTML = dashboardHtml; updateAllUIElements(); } 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" 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 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="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" 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" title="Remove Point" data-action="remove-point" data-id="${customer.id}"><i class="fas fa-minus"></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" 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" 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" 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" 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'); 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)).slice(0, 5); resultsContainer.innerHTML = matches.map(c => `<div class="p-2 hover:bg-gray-100 cursor-pointer" data-id="${c.id}" data-name="${c.name}">${c.name}</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.contains(e.target) && !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'; await refreshData(); // This will silently fail if offline showPage('customer-dashboard'); return; // Exit here to show cached data first } catch (e) { console.error("Failed to parse cached customer", e); localStorage.removeItem('omni-cached-customer'); } } // 2. 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 { // 3. No cache, no session, so load fresh data for the login page dataLoaded = await refreshData(true); showPage('login-page'); } if(dataLoaded){ const businessName = state.settings.businessName || 'Omni Rewards'; document.getElementById('login-business-name').textContent = businessName; document.title = businessName; document.getElementById('business-name-footer').textContent = businessName; document.getElementById('current-year').textContent = new Date().getFullYear(); 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">${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">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 flex items-center justify-center hover:bg-slate-100"> <i class="far fa-copy"></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"> <i class="fab fa-whatsapp"></i> WhatsApp </a> <button class="modal-close-btn bg-primary text-white px-4 py-2 rounded-md">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">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"><i class="fas fa-times"></i></button> <h3 class="text-xl font-headline font-bold">Points History</h3> <div id="history-content" class="mt-4 space-y-3 overflow-y-auto flex-grow"> ${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 ${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 ${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')); // 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 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"><i class="fas fa-times"></i></button> <h3 class="text-xl font-headline font-bold">Account Settings</h3> <p class="text-sm text-slate-500 mb-4">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> </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">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">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">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">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">Save Password</button> </div> </form> </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); } }); }); } // 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')); } // Pagination Buttons const paginationBtn = e.target.closest('.pagination-btn'); if (paginationBtn) { state.currentPage = parseInt(paginationBtn.dataset.page, 10); renderCustomerLists(document.getElementById('customer-search').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-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"><i class="fas fa-times"></i></button> <h3 class="text-xl font-headline font-bold">Add Point</h3> <p class="text-sm text-slate-500 mb-4">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"/> <div> <label class="font-medium">Or Find Customer by Name/Phone</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 pl-10 h-10 px-3 py-2 border rounded-md" placeholder="Search..."> </div> <div id="manual-search-results" class="mt-2 space-y-2 max-h-40 overflow-y-auto"></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 isVIP = c.isVIP; const nameHtml = isVIP ? `<i class="fas fa-crown mr-2 text-amber-500"></i>${c.name}` : c.name; 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"> <div> <p class="font-semibold text-sm flex items-center">${nameHtml}</p> <div class="text-xs text-slate-500 mt-1 flex items-center gap-2"> <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-sm px-3 py-1 rounded-md hover:bg-blue-200 shrink-0" data-id="${c.id}">+1 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 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" data-admin-id="${admin.id}" data-admin-name="${admin.name}"><i class="fas fa-trash"></i></button>` : ''} </div> `).join(''); 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"> <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"><i class="fas fa-times"></i></button> <h3 class="text-xl font-headline font-bold">Business Settings</h3> <p class="text-sm text-slate-500">Update your business information and appearance.</p> <form id="settings-form" class="mt-6 space-y-6"> <div> <label class="block text-sm font-medium">Business Name</label> <input type="text" name="businessName" class="w-full mt-1 p-2 border rounded" value="${state.settings.businessName || ''}"> </div> <div> <label class="block text-sm font-medium">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"> <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 rounded-md text-sm hover:bg-slate-50"> <i class="fas fa-upload mr-2"></i>Upload Logo </button> </div> </div> <div> <label class="block text-sm font-medium">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"> <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 rounded-md text-sm hover:bg-slate-50"> <i class="fas fa-upload mr-2"></i>Upload Favicon </button> </div> </div> <hr/> <div> <h4 class="font-semibold">Brand Colors</h4> <p class="text-sm text-slate-500 mb-2">Upload a logo to get color suggestions for your primary theme color.</p> <div id="color-suggestion-container" class="mb-2"></div> <div class="grid grid-cols-2 gap-4"> <div class="space-y-1"> <label for="primary-color-picker" class="text-sm">Primary</label> <input type="color" id="primary-color-picker" class="w-full h-10 p-1 border rounded-md" value="#6d28d9"> </div> <div class="space-y-1"> <label for="accent-color-picker" class="text-sm">Accent</label> <input type="color" id="accent-color-picker" class="w-full h-10 p-1 border rounded-md" value="#f3f4f6"> </div> </div> <div class="mt-2"> <button type="button" id="reset-color-btn" class="text-sm text-slate-600 hover:underline">Reset to default colors</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/> <div> <h4 class="font-semibold">Change Your Credentials</h4> <div class="mt-2 space-y-2"> <label class="block text-sm">Username</label> <input type="text" name="adminUsername" class="w-full p-2 border rounded" value="${state.currentUser.username}"> <label class="block text-sm">New Password</label> <input type="password" name="newPassword" class="w-full p-2 border rounded" placeholder="Leave blank to keep current"> <label class="block text-sm">Confirm New Password</label> <input type="password" name="confirmNewPassword" class="w-full p-2 border rounded"> </div> </div> <hr/> <div> <div class="flex justify-between items-center"> <h4 class="font-semibold">Manage Admin Accounts</h4> <button type="button" id="add-admin-modal-btn" class="text-sm bg-primary text-white px-3 py-1 rounded-md hover:bg-opacity-90"><i class="fas fa-user-plus mr-1"></i> Add Admin</button> </div> <div class="mt-2 space-y-1 border rounded-md p-2 max-h-48 overflow-y-auto"> ${adminsListHtml} </div> </div> <hr/> <div class="p-4 rounded-lg border border-red-300 bg-red-50 space-y-2"> <h4 class="font-semibold text-red-700 flex items-center"><i class="fas fa-exclamation-triangle mr-2"></i>Danger Zone</h4> <p class="text-sm text-red-600">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-3 py-2 rounded-md text-sm hover:bg-red-700"><i class="fas fa-trash mr-2"></i>Delete All Customers</button> </div> </form> <div class="mt-6 flex justify-end gap-2"> <button class="modal-cancel px-4 py-2 rounded-md border hover:bg-slate-100">Cancel</button> <button id="save-settings-btn" class="bg-primary text-white px-4 py-2 rounded-md hover:bg-opacity-90">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-sm">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-8 h-8 rounded-full border-2" style="background-color: ${hex};" data-color-hex="${hex}"></button>`; }).join(''); } }; }; reader.readAsDataURL(file); } }); modalWrapper.querySelector('#favicon-upload-input').addEventListener('change', (e) => { const file = e.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"><i class="fas fa-times"></i></button> <h3 class="text-lg font-medium">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 rounded" placeholder="Full Name" required> <input type="text" name="username" class="w-full p-2 border rounded" placeholder="Username" required> <input type="password" name="password" class="w-full p-2 border rounded" placeholder="Password" required> </form> <div class="mt-4 flex justify-end gap-2"> <button class="modal-cancel px-4 py-2 rounded">Cancel</button> <button class="modal-confirm bg-primary text-white px-4 py-2 rounded">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(); // This is the crucial fix. 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-medium">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">Cancel</button> <button class="modal-confirm bg-red-600 text-white px-4 py-2 rounded">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"><i class="fas fa-times"></i></button> <h3 class="text-lg font-medium">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">Cancel</button> <button class="modal-confirm bg-red-600 text-white px-4 py-2 rounded">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('.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"><i class="fas fa-times"></i></button> <h3 class="text-xl font-headline font-bold">Create Your Account</h3> <p class="text-sm text-slate-500 mb-4">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"></div> <div> <label for="signup-name" class="block text-sm font-medium">Full Name</label> <input type="text" name="name" id="signup-name" required class="w-full mt-1 p-2 border rounded-md focus:outline-none focus-visible:ring-2 ring-primary"> </div> <div> <label for="signup-phone" class="block text-sm font-medium">Phone Number</label> <input type="tel" name="phone" id="signup-phone" placeholder="+501-xxx-xxxx" required class="w-full mt-1 p-2 border rounded-md focus:outline-none focus-visible:ring-2 ring-primary"> </div> <div> <label for="signup-password" class="block text-sm font-medium">Password</label> <input type="password" name="password" id="signup-password" required minlength="6" class="w-full mt-1 p-2 border rounded-md focus:outline-none focus-visible:ring-2 ring-primary"> </div> <button type="submit" class="w-full bg-primary text-white py-2.5 rounded-md hover:bg-opacity-90">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('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"><i class="fas fa-times"></i></button> <h3 class="text-lg font-medium">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">Full Name</label><input type="text" name="name" id="new-customer-name" class="w-full mt-1 p-2 border rounded" required></div> <div class="mb-4"><label for="new-customer-phone" class="block text-sm">Phone</label><input type="tel" name="phone" id="new-customer-phone" placeholder="+501-xxx-xxxx" class="w-full mt-1 p-2 border rounded" required></div> </form> <div class="mt-4 flex justify-end gap-2"> <button class="modal-cancel px-4 py-2 rounded">Cancel</button> <button class="modal-confirm bg-primary text-white px-4 py-2 rounded">Add</button> </div> </div> </div>`; const modalWrapper = openModal(modalHTML); 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>