/* Shared store — used by both customer site and admin dashboard. Persists in localStorage. Seeded with demo data on first load. */ const STORE_KEYS = { requests: 'prestige.requests', session: 'prestige.adminSession', users: 'prestige.users', seedVersion: 'prestige.seedVersion', }; const SEED_ADMIN = { username: 'admin', password: 'prestige2026' }; // Bump this whenever buildSeed() changes so the demo data regenerates once. const SEED_VERSION = '2026-06-catalog-ampm'; const SESSION_TTL_HOURS = 12; const STATUS = { PENDING: 'pending', CONFIRMED: 'confirmed', COMPLETED: 'completed', CANCELLED: 'cancelled', }; const STATUS_LABEL = { pending: 'Pending review', confirmed: 'Confirmed', completed: 'Completed', cancelled: 'Cancelled', }; /* --- Date helpers ----------------------------------------- */ const ymd = (d) => { const dt = (d instanceof Date) ? d : new Date(d); const y = dt.getFullYear(); const m = String(dt.getMonth()+1).padStart(2,'0'); const day = String(dt.getDate()).padStart(2,'0'); return `${y}-${m}-${day}`; }; const fromYmd = (s) => { const [y, m, d] = s.split('-').map(Number); return new Date(y, m-1, d); }; const today = () => { const d = new Date(); d.setHours(0,0,0,0); return d; }; const addDays = (d, n) => { const r = new Date(d); r.setDate(r.getDate()+n); return r; }; const fmtDate = (d) => (d instanceof Date ? d : fromYmd(d)).toLocaleDateString('en-US',{ weekday:'short', month:'short', day:'numeric' }); const fmtDateLong = (d) => (d instanceof Date ? d : fromYmd(d)).toLocaleDateString('en-US',{ weekday:'long', year:'numeric', month:'long', day:'numeric' }); /* --- Seed mock data --------------------------------------- */ const VEHICLE_OPTIONS = [ { id:'sedan', label:'Sedan' }, { id:'suv', label:'SUV' }, { id:'pickup', label:'Pick-Up' }, { id:'van', label:'Van' }, { id:'comm', label:'Commercial Van' }, { id:'truck', label:'Trucks' }, ]; const SERVICE_OPTIONS = [ { id:'regular', label:'Regular Wash', duration:45, prices:{ sedan:50, suv:80, pickup:80, van:99, comm:159, truck:199 } }, { id:'detailing', label:'Detailing', duration:150, prices:{ sedan:299, suv:349, pickup:349, van:349, comm:499, truck:649 } }, { id:'luxury', label:'Luxury Wash', duration:210, flat:699 }, { id:'headlight', label:'Headlight Polishing', duration:60, flat:99 }, ]; const seedPrice = (svc, vehId) => (svc.flat != null ? svc.flat : (svc.prices?.[vehId] ?? 0)); const SLOT_OPTIONS = [ '7:00 AM','8:00 AM','9:00 AM','10:00 AM','11:00 AM','12:00 PM','1:00 PM', '2:00 PM','3:00 PM','4:00 PM','5:00 PM','6:00 PM','7:00 PM', ]; const buildSeed = () => { const t = today(); const make = (offset, slot, vehId, svcId, status, customer) => { const veh = VEHICLE_OPTIONS.find(v => v.id === vehId); const svc = SERVICE_OPTIONS.find(s => s.id === svcId); const subtotal = seedPrice(svc, vehId); return { id: `REQ-${Date.now().toString(36).slice(-4)}-${Math.random().toString(36).slice(2,6).toUpperCase()}`, createdAt: addDays(t, offset - 2).toISOString(), status, paid: status !== 'cancelled', customer, address: { line: customer.addr || '1245 Sunset Blvd', city: customer.city || 'Beverly Hills', zip: customer.zip || '90210', notes: '' }, vehicle: veh, service: svc, date: ymd(addDays(t, offset)), slot, payment: 'card', promo: null, pricing: { subtotal, total: Math.round(subtotal * 1.04) }, emailSent: status === 'confirmed' || status === 'completed', emailSentAt: status === 'confirmed' || status === 'completed' ? addDays(t, offset - 1).toISOString() : null, notes: '', }; }; return [ make(-3, '11:00 AM', 'suv', 'luxury', 'completed', { name:'Marisa Velasquez', email:'marisa.v@example.com', phone:'(310) 555-0142', addr:'9100 Wilshire Blvd', city:'Beverly Hills', zip:'90212' }), make(-1, '2:00 PM', 'sedan', 'detailing', 'completed', { name:'Daniel Kim', email:'dkim@example.com', phone:'(424) 555-0188', addr:'500 Ocean Ave', city:'Santa Monica', zip:'90402' }), make(0, '9:00 AM', 'suv', 'detailing', 'confirmed', { name:'Sofia Ramos', email:'sofia.r@example.com', phone:'(310) 555-0177', addr:'250 Newport Center Dr', city:'Newport Beach', zip:'92660' }), make(0, '1:00 PM', 'sedan', 'luxury', 'pending', { name:'James Lambert', email:'jlambert@example.com', phone:'(310) 555-0103', addr:'27900 Pacific Coast Hwy', city:'Malibu', zip:'90265' }), make(1, '8:00 AM', 'pickup', 'regular', 'pending', { name:'Carla Mendoza', email:'cmendoza@example.com', phone:'(323) 555-0150', addr:'1100 Sunset Plaza Dr', city:'West Hollywood', zip:'90069' }), make(1, '12:00 PM', 'sedan', 'detailing', 'confirmed', { name:'Ethan Park', email:'epark@example.com', phone:'(310) 555-0166', addr:'620 Manhattan Ave', city:'Manhattan Beach', zip:'90266' }), make(2, '11:00 AM', 'van', 'detailing', 'pending', { name:'Priya Shah', email:'pshah@example.com', phone:'(310) 555-0124', addr:'1800 Avenue of the Stars', city:'Los Angeles', zip:'90067' }), make(3, '9:00 AM', 'suv', 'detailing', 'confirmed', { name:'Aaron Reyes', email:'areyes@example.com', phone:'(424) 555-0192', addr:'200 Pier Ave', city:'Hermosa Beach', zip:'90254' }), make(4, '3:00 PM', 'comm', 'regular', 'pending', { name:'Logistics Co.', email:'ops@logisticsco.example.com', phone:'(310) 555-0145', addr:'5000 W Century Blvd', city:'Los Angeles', zip:'90045' }), make(7, '11:00 AM', 'truck', 'luxury', 'pending', { name:'Maya Brennan', email:'mb@example.com', phone:'(310) 555-0118', addr:'1500 Stone Canyon Rd', city:'Bel Air', zip:'90077' }), ]; }; /* --- Storage primitives ---------------------------------- */ const readJSON = (key, fallback) => { try { const v = localStorage.getItem(key); return v ? JSON.parse(v) : fallback; } catch { return fallback; } }; const writeJSON = (key, value) => { try { localStorage.setItem(key, JSON.stringify(value)); } catch {} }; /* --- Public store API ------------------------------------ */ const Store = { STATUS, STATUS_LABEL, SLOT_OPTIONS, ensureSeed(){ const cur = readJSON(STORE_KEYS.requests, null); const ver = readJSON(STORE_KEYS.seedVersion, null); if (!cur || !Array.isArray(cur) || ver !== SEED_VERSION) { writeJSON(STORE_KEYS.requests, buildSeed()); writeJSON(STORE_KEYS.seedVersion, SEED_VERSION); } }, resetSeed(){ writeJSON(STORE_KEYS.requests, buildSeed()); writeJSON(STORE_KEYS.seedVersion, SEED_VERSION); }, getRequests(){ Store.ensureSeed(); return readJSON(STORE_KEYS.requests, []); }, setRequests(list){ writeJSON(STORE_KEYS.requests, list); window.dispatchEvent(new CustomEvent('prestige:requests-changed')); }, upsert(req){ const list = Store.getRequests(); const idx = list.findIndex(r => r.id === req.id); if (idx >= 0) list[idx] = req; else list.unshift(req); Store.setRequests(list); }, updateStatus(id, status, extra={}){ const list = Store.getRequests(); const idx = list.findIndex(r => r.id === id); if (idx < 0) return null; list[idx] = { ...list[idx], status, ...extra }; Store.setRequests(list); return list[idx]; }, /* Slot blocking — takes a Date or YYYY-MM-DD, returns set of taken slot strings */ takenSlotsForDate(date){ const key = (typeof date === 'string') ? date : ymd(date); const blockingStatuses = new Set([STATUS.PENDING, STATUS.CONFIRMED, STATUS.COMPLETED]); const reqs = Store.getRequests(); return new Set( reqs .filter(r => r.date === key && r.paid && blockingStatuses.has(r.status)) .map(r => r.slot) ); }, /* --- Users / accounts --------------------------------- */ ensureUsers(){ const cur = readJSON(STORE_KEYS.users, null); if (!cur || !Array.isArray(cur) || cur.length === 0) { writeJSON(STORE_KEYS.users, [{ id: 'usr-seed-admin', username: SEED_ADMIN.username, password: SEED_ADMIN.password, role: 'admin', createdAt: new Date().toISOString(), createdBy: null, lastLogin: null, }]); } }, getUsers(){ Store.ensureUsers(); return readJSON(STORE_KEYS.users, []); }, setUsers(list){ writeJSON(STORE_KEYS.users, list); window.dispatchEvent(new CustomEvent('prestige:users-changed')); }, validateUserPayload({ username, password, confirm }, { existing = [] } = {}){ const u = (username || '').trim().toLowerCase(); const p = password || ''; const c = confirm || ''; if (u.length < 3) return 'Username must be at least 3 characters'; if (!/^[a-z0-9._-]+$/.test(u)) return 'Username can only have letters, numbers, dot, dash or underscore'; if (existing.some(x => x.username.toLowerCase() === u)) return 'That username already exists'; if (p.length < 6) return 'Password must be at least 6 characters'; if (c !== undefined && p !== c) return 'Passwords don’t match'; return null; }, createUser({ username, password, role = 'admin', createdBy = null }){ const list = Store.getUsers(); const err = Store.validateUserPayload({ username, password, confirm: password }, { existing: list }); if (err) return { ok: false, error: err }; const user = { id: `usr-${Date.now().toString(36)}-${Math.random().toString(36).slice(2,6)}`, username: username.trim().toLowerCase(), password, role, createdAt: new Date().toISOString(), createdBy, lastLogin: null, }; Store.setUsers([...list, user]); return { ok: true, user }; }, deleteUser(id, { actingUser } = {}){ const list = Store.getUsers(); const target = list.find(u => u.id === id); if (!target) return { ok: false, error: 'User not found' }; if (actingUser && target.username === actingUser) return { ok: false, error: 'You cannot delete your own account while signed in' }; if (list.length <= 1) return { ok: false, error: 'Cannot delete the last admin account' }; Store.setUsers(list.filter(u => u.id !== id)); return { ok: true }; }, updatePassword(id, newPassword){ if (!newPassword || newPassword.length < 6) return { ok: false, error: 'Password must be at least 6 characters' }; const list = Store.getUsers(); const idx = list.findIndex(u => u.id === id); if (idx < 0) return { ok: false, error: 'User not found' }; list[idx] = { ...list[idx], password: newPassword }; Store.setUsers(list); return { ok: true }; }, /* Auth — checks against users list, session in localStorage */ login(username, password){ Store.ensureUsers(); const u = (username || '').trim().toLowerCase(); const p = (password || '').trim(); const list = Store.getUsers(); const user = list.find(x => x.username.toLowerCase() === u && x.password === p); if (!user) return { ok: false, error: 'Invalid username or password' }; // touch lastLogin Store.setUsers(list.map(x => x.id === user.id ? { ...x, lastLogin: new Date().toISOString() } : x)); const session = { user: user.username, userId: user.id, role: user.role, issuedAt: Date.now(), expiresAt: Date.now() + SESSION_TTL_HOURS * 60 * 60 * 1000, }; writeJSON(STORE_KEYS.session, session); return { ok: true, session }; }, logout(){ try { localStorage.removeItem(STORE_KEYS.session); } catch {} }, getSession(){ const s = readJSON(STORE_KEYS.session, null); if (!s || s.expiresAt < Date.now()) return null; return s; }, /* Email mock — replace with EmailJS/backend call later */ sendConfirmationEmail(req){ return new Promise((resolve) => { setTimeout(() => { Store.updateStatus(req.id, STATUS.CONFIRMED, { emailSent: true, emailSentAt: new Date().toISOString(), }); resolve({ ok: true, to: req.customer.email }); }, 700); }); }, buildEmailPreview(req){ const dStr = fmtDateLong(req.date); const total = req.pricing?.total ? `$${req.pricing.total.toFixed(2)}` : '—'; return { to: req.customer.email, subject: `Your Prestige Auto Spa booking is confirmed — ${dStr} · ${req.slot}`, body: [ `Hi ${req.customer.name.split(' ')[0]},`, ``, `Your booking with Prestige Auto Spa Mobile is confirmed.`, ``, `Service: ${req.service.label}`, `Vehicle: ${req.vehicle.label}`, `Date: ${dStr}`, `Time: ${req.slot}`, `Where: ${req.address.line}, ${req.address.city} ${req.address.zip}`, `Total: ${total}`, ``, `Our technician will arrive on time and ready. If anything changes, reply to this email or call (424) 415-0208.`, ``, `— Prestige Auto Spa Mobile`, ].join('\n'), }; }, }; /* --- Date utils exposed for components ------------------- */ const DateUtils = { ymd, fromYmd, today, addDays, fmtDate, fmtDateLong }; window.PrestigeStore = Store; window.PrestigeDate = DateUtils;