// Portal JOXE — Sistema de turnos // Shared store + components // ============================================================ // STORE — Turso (via /api/store) + localStorage cache // ============================================================ const STORE_KEY = "joxe_turnos_v1"; const STORE_DEFAULT = () => ({ appointments: [], active: [], completed: [], blockedSlots: [] }); const loadCache = () => { try { const s = JSON.parse(localStorage.getItem(STORE_KEY)); return s ? { ...STORE_DEFAULT(), ...s } : STORE_DEFAULT(); } catch { return STORE_DEFAULT(); } }; const broadcastUpdate = () => { try { new BroadcastChannel("joxe_turnos").postMessage({ type: "update" }); } catch {} }; const useStore = () => { const [store, setStore] = React.useState(loadCache); // Fetch from Turso and update local cache const pull = React.useCallback(async () => { try { const t = sessionStorage.getItem("joxe_admin_session") || sessionStorage.getItem("joxe_emp_token") || ""; const headers = t ? { "Authorization": `Bearer ${t}` } : {}; const res = await fetch("/api/store", { headers }); if (!res.ok) return; const data = await res.json(); localStorage.setItem(STORE_KEY, JSON.stringify(data)); setStore(data); } catch {} }, []); React.useEffect(() => { pull(); const interval = setInterval(pull, 5000); // live sync every 5 s let bc; try { bc = new BroadcastChannel("joxe_turnos"); bc.addEventListener("message", pull); } catch {} window.addEventListener("storage", () => setStore(loadCache())); return () => { clearInterval(interval); try { bc?.close(); } catch {} }; }, [pull]); const update = React.useCallback(async (updater, bookingAppt) => { const current = loadCache(); const next = typeof updater === "function" ? updater(current) : updater; // Optimistic: apply immediately setStore(next); localStorage.setItem(STORE_KEY, JSON.stringify(next)); // Persist to Turso const token = sessionStorage.getItem("joxe_admin_session") || ""; try { if (bookingAppt && !token) { // Client booking without session: use append-only endpoint await fetch("/api/book", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(bookingAppt), }); } else { // Admin / employee: full store write (with auth if available) const headers = { "Content-Type": "application/json" }; if (token) headers["Authorization"] = `Bearer ${token}`; await fetch("/api/store", { method: "POST", headers, body: JSON.stringify(next), }); } broadcastUpdate(); } catch (err) { console.warn("[store] save failed, using local cache", err.message); } }, []); return [store, update]; }; const genTicket = () => { const n = Math.floor(Math.random() * 9000) + 1000; return `JX-${n}`; }; // ============================================================ // SHARED UI // ============================================================ const PMono = ({ children, style }) => ( {children} ); const PortalShell = ({ children, tone = "noir", header }) => (
{header} {children}
); const PortalHeader = ({ title, subtitle, right, tone = "noir" }) => { const hasRole = !!( sessionStorage.getItem("joxe_admin_session") || sessionStorage.getItem("joxe_agenda_session") ); const logoHref = hasRole ? "Portal.html" : "Asesores de Imagen.html"; return (
JOXE
{subtitle}
{title}
{right}
); }; // ============================================================ // Dialog (confirm/alert replacement) — branded, accessible, focus-trap. // Usage: // const dlg = useDialog(); // const ok = await dlg.confirm({ title, body, confirmLabel, danger }); // await dlg.alert({ title, body }); // ============================================================ const Dialog = ({ open, title, body, confirmLabel = "Confirmar", cancelLabel = "Cancelar", danger = false, hideCancel = false, onConfirm, onCancel }) => { const cancelRef = React.useRef(null); const confirmRef = React.useRef(null); React.useEffect(() => { if (!open) return; // Focus the safe action: cancel for confirm dialogs, confirm for alerts (hideCancel). const target = hideCancel ? confirmRef.current : (cancelRef.current || confirmRef.current); setTimeout(() => target?.focus(), 0); const onKey = (e) => { if (e.key === "Escape") { e.preventDefault(); onCancel?.(); } if (e.key === "Enter" && document.activeElement === confirmRef.current) onConfirm?.(); if (e.key === "Tab") { const focusables = [cancelRef.current, confirmRef.current].filter(Boolean); if (focusables.length === 0) return; const idx = focusables.indexOf(document.activeElement); e.preventDefault(); const next = e.shiftKey ? focusables[(idx - 1 + focusables.length) % focusables.length] : focusables[(idx + 1) % focusables.length]; next.focus(); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [open, hideCancel, onCancel, onConfirm]); if (!open) return null; return (
{ if (e.target === e.currentTarget) onCancel?.(); }} >
{title && (

{title}

)} {body && (
{body}
)}
{!hideCancel && ( )}
); }; const useDialog = () => { const [state, setState] = React.useState(null); // { resolve, opts } const ask = React.useCallback((opts, hideCancel) => new Promise((resolve) => { setState({ resolve, opts: { ...opts, hideCancel: !!hideCancel } }); }), [] ); const close = (result) => { state?.resolve(result); setState(null); }; const node = ( close(true)} onCancel={() => close(false)} /> ); return { confirm: (opts) => ask(opts, false), alert: (opts) => ask({ confirmLabel: "Entendido", ...opts }, true), node, }; }; // ============================================================ // QR RENDERER — real, scannable QR using `qrcode-generator` (UMD via CDN) // The library exposes a global `qrcode(typeNumber, errorCorrectionLevel)` factory. // We pick the smallest type that fits the payload and use error level "M". // ============================================================ const buildQRMatrix = (text) => { if (typeof window.qrcode !== "function") return null; // typeNumber=0 lets the lib auto-pick the smallest version that fits. const qr = window.qrcode(0, "M"); qr.addData(String(text)); qr.make(); const n = qr.getModuleCount(); const grid = Array.from({ length: n }, (_, y) => Array.from({ length: n }, (_, x) => qr.isDark(y, x)) ); return grid; }; const QRCode = ({ value, size = 220, fg = "#0C0C0C", bg = "#F5F1EA" }) => { // Re-render when the lib finishes loading (the script is async on the page). const [, force] = React.useReducer(x => x + 1, 0); React.useEffect(() => { if (typeof window.qrcode === "function") return; const i = setInterval(() => { if (typeof window.qrcode === "function") { force(); clearInterval(i); } }, 80); return () => clearInterval(i); }, []); const grid = buildQRMatrix(value); if (!grid) { return (
cargando QR…
); } const n = grid.length; const pad = 2; // quiet zone in modules (spec recommends 4; 2 is enough for screens) const total = n + pad * 2; const cell = size / total; return ( {grid.map((row, y) => row.map((on, x) => on && ( )))} ); }; // ============================================================ // PAGE 1 — AGENDAR CITA // ============================================================ const PENDING_EXPIRE_MS = 60 * 60 * 1000; // 1 hora const isPendingExpired = (a) => a.status === "pending" && (Date.now() - (a.createdAt || 0)) > PENDING_EXPIRE_MS; // Helpers — hora Colombia (COT = UTC-5) const nowCOT = () => new Date(new Date().toLocaleString("en-US", { timeZone: "America/Bogota" })); const todayStr = () => { const d = nowCOT(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`; }; // Retorna true si el slot "HH:MM" ya pasó para el día de hoy en COT const isTimePast = (date, time) => { if (date !== todayStr()) return false; const [h, m] = time.split(":").map(Number); const now = nowCOT(); return now.getHours() > h || (now.getHours() === h && now.getMinutes() >= m); }; const addDays = (d, n) => { const dt = new Date(d + "T12:00"); dt.setDate(dt.getDate() + n); return dt.toISOString().split("T")[0]; }; const fmtDateLabel = (d) => { const t = todayStr(); if (d === t) return "Hoy"; if (d === addDays(t, 1)) return "Mañana"; if (d === addDays(t, 2)) return "Pasado mañana"; return fmtDateSub(d); }; const timeToMin = (t) => { const [h, m] = t.split(":").map(Number); return h * 60 + (m || 0); }; const fmtDateSub = (d) => { try { return new Date(d + "T12:00").toLocaleDateString("es-CO", { weekday:"short", day:"numeric", month:"short" }); } catch { return d; } }; const fmtCOP = (n) => n == null ? "" : "$" + Number(n).toLocaleString("es-CO"); // Business hours — JS getDay(): 0=dom, 1=lun, 2=mar, ..., 6=sab // JOXE: Mar—Vie 9-21, Sáb 8-19, Dom—Lun cerrado const BUSINESS_HOURS = { 0: null, // dom — cerrado 1: null, // lun — cerrado 2: ["9:00","10:00","11:00","12:00","13:00","14:00","15:00","16:00","17:00","18:00","19:00","20:00"], // mar 3: ["9:00","10:00","11:00","12:00","13:00","14:00","15:00","16:00","17:00","18:00","19:00","20:00"], // mie 4: ["9:00","10:00","11:00","12:00","13:00","14:00","15:00","16:00","17:00","18:00","19:00","20:00"], // jue 5: ["9:00","10:00","11:00","12:00","13:00","14:00","15:00","16:00","17:00","18:00","19:00","20:00"], // vie 6: ["8:00","9:00","10:00","11:00","12:00","13:00","14:00","15:00","16:00","17:00","18:00"], // sab }; // Closing time per day in minutes — upper bound for service duration fit check const CLOSE_TIME_MIN = { 2: 21 * 60, 3: 21 * 60, 4: 21 * 60, 5: 21 * 60, // mar—vie 6: 19 * 60, // sab }; const dayOfWeek = (dateStr) => new Date(dateStr + "T12:00").getDay(); const isClosedDay = (dateStr) => !BUSINESS_HOURS[dayOfWeek(dateStr)]; const slotsForDate = (dateStr) => BUSINESS_HOURS[dayOfWeek(dateStr)] || []; const closesAtMin = (dateStr) => CLOSE_TIME_MIN[dayOfWeek(dateStr)] ?? 0; // Maps JS getDay() index to workHours key stored in each employee profile const WORK_DAY_KEYS = ["dom","lun","mar","mie","jue","vie","sab"]; // Returns false if the slot falls outside the employee's configured work hours for that day const empWorksOnSlot = (emp, date, timeStr, dur) => { if (!emp?.workHours) return true; // no schedule configured — no restriction const dayKey = WORK_DAY_KEYS[new Date(date + "T12:00").getDay()]; const day = emp.workHours[dayKey]; if (!day?.active) return false; // employee doesn't work this day const startMin = timeToMin(day.start); const endMin = timeToMin(day.end); const slotStart = timeToMin(timeStr); const slotEnd = slotStart + dur; return slotStart >= startMin && slotEnd <= endMin; }; const DEFAULT_SERVICES = [ { id:"s1", name:"Corte mujer", price:85000, dur:60 }, { id:"s2", name:"Corte hombre", price:45000, dur:40 }, { id:"s3", name:"Balayage", price:280000, dur:180, note:"desde" }, { id:"s4", name:"Color correction", price:320000, dur:240, note:"desde" }, { id:"s5", name:"Color raíz", price:120000, dur:90 }, { id:"s6", name:"Keratina", price:260000, dur:180, note:"desde" }, { id:"s7", name:"Asesoría de imagen", price:180000, dur:90 }, { id:"s8", name:"Peinado novia", price:220000, dur:120, note:"desde" }, ]; const DEFAULT_EMPLOYEES = [ { id:"e1", name:"Joxe", role:"Estilista", services:["s1","s2","s3","s4","s5","s6","s7","s8"] }, { id:"e2", name:"Laura M.", role:"Estilista", services:["s1","s2","s3","s5","s6","s8"] }, { id:"e3", name:"Camila R.", role:"Colorista", services:["s3","s4","s5","s6"] }, ]; const useCatalog = () => { const [catalog, setCatalog] = React.useState({ services: DEFAULT_SERVICES, employees: DEFAULT_EMPLOYEES }); React.useEffect(() => { fetch("/api/catalog") .then(r => r.ok ? r.json() : null) .then(d => { if (d) setCatalog(d); }) .catch(() => {}); }, []); return catalog; }; const BOOKING_DRAFT_KEY = "joxe_booking_draft_v1"; const EMPTY_BOOKING_FORM = { service: "", serviceId: "", stylist: "", stylistId: "", date: "", time: "", name: "", phone: "", cedula: "", }; const ADMIN_KEY_BOOKING = "joxe_admin_v1"; const loadBookingAdmin = () => { try { return JSON.parse(localStorage.getItem(ADMIN_KEY_BOOKING)) || {}; } catch { return {}; } }; const BookingPortal = () => { const [store, setStore] = useStore(); const catalog = useCatalog(); const waAdminRaw = loadBookingAdmin().whatsappAdminNumber || "573124499862"; const waAdminFormatted = (() => { const d = waAdminRaw.replace(/\D/g, ""); if (d.startsWith("57") && d.length === 12) { return `+57 ${d.slice(2,5)} ${d.slice(5,8)} ${d.slice(8)}`; } return `+${d}`; })(); // Restore draft from sessionStorage (cleared on submit) const initial = React.useMemo(() => { try { const raw = sessionStorage.getItem(BOOKING_DRAFT_KEY); if (raw) { const d = JSON.parse(raw); return { step: typeof d.step === "number" && d.step >= 1 && d.step <= 4 ? d.step : 1, form: { ...EMPTY_BOOKING_FORM, ...(d.form || {}) }, }; } } catch {} return { step: 1, form: EMPTY_BOOKING_FORM }; }, []); const [step, setStep] = React.useState(initial.step); const [form, setForm] = React.useState(initial.form); const [ticket, setTicket] = React.useState(null); const [secsLeft, setSecsLeft] = React.useState(null); // Auto-save form/step to sessionStorage on every change (so refresh keeps the draft) React.useEffect(() => { try { sessionStorage.setItem(BOOKING_DRAFT_KEY, JSON.stringify({ form, step })); } catch {} }, [form, step]); // 15-min countdown once ticket is created const CLIENT_DEADLINE_MS = 15 * 60 * 1000; React.useEffect(() => { if (!ticket) return; const tick = () => { const remaining = Math.max(0, CLIENT_DEADLINE_MS - (Date.now() - ticket.createdAt)); setSecsLeft(Math.floor(remaining / 1000)); }; tick(); const id = setInterval(tick, 1000); return () => clearInterval(id); }, [ticket]); const services = catalog.services; const employees = catalog.employees; // Employees that offer the currently selected service. // Employees with an empty services array are treated as available for all services. const eligibleEmployees = React.useMemo(() => { if (!form.serviceId) return employees; return employees.filter(e => !(e.services || []).length || (e.services || []).includes(form.serviceId) ); }, [form.serviceId, employees]); // Show next 14 days, skipping closed days (Dom/Lun) const availableDates = React.useMemo(() => { const out = []; const today = todayStr(); for (let i = 0; i < 21 && out.length < 14; i++) { const d = addDays(today, i); if (!isClosedDay(d)) out.push(d); } return out; }, []); // Duration of the currently selected service in minutes const selectedDur = React.useMemo(() => { if (!form.serviceId) return 60; return services.find(s => s.id === form.serviceId)?.dur || 60; }, [form.serviceId, services]); // Check if a time slot is available for a given date + stylist // Takes into account service duration — a long service blocks subsequent slots // and must finish before the salon closes for that day. const isSlotTaken = (date, time, stylistName, newDur) => { if (!date) return false; if (isClosedDay(date)) return true; if (isTimePast(date, time)) return true; const adminBlocked = (store.blockedSlots || []).some(b => b.date === date && b.time === time); if (adminBlocked) return true; const dur = newDur ?? selectedDur; const newStart = timeToMin(time); const newEnd = newStart + dur; // Service must finish before closing time if (newEnd > closesAtMin(date)) return true; const aptsOnDay = (store.appointments || []).filter( a => a.date === date && !["cancelled"].includes(a.status) && !isPendingExpired(a) ); const conflictsFor = (stylist) => aptsOnDay .filter(a => a.stylist === stylist) .some(a => { const aStart = timeToMin(a.time); const aEnd = aStart + (a.serviceDur || 60); // Overlap: existing ends after new starts AND new ends after existing starts return aStart < newEnd && newStart < aEnd; }); if (stylistName === "Sin preferencia") { return eligibleEmployees.every(e => conflictsFor(e.name) || !empWorksOnSlot(e, date, time, dur) ); } const emp = employees.find(e => e.name === stylistName); if (emp && !empWorksOnSlot(emp, date, time, dur)) return true; return conflictsFor(stylistName); }; const submit = () => { let assignedStylist = form.stylist; // Assign least-busy eligible stylist when "Sin preferencia" if (form.stylist === "Sin preferencia") { const counts = eligibleEmployees.map(e => ({ name: e.name, count: (store.appointments || []).filter( a => a.date === form.date && a.stylist === e.name && !["cancelled"].includes(a.status) && !isPendingExpired(a) ).length, })); const free = counts.filter(e => !isSlotTaken(form.date, form.time, e.name)); assignedStylist = free.length > 0 ? free.sort((a, b) => a.count - b.count)[0].name : counts.sort((a, b) => a.count - b.count)[0].name; } const id = crypto.randomUUID ? crypto.randomUUID() : String(Math.random()); const code = genTicket(); const appt = { id, code, service: form.service, serviceDur: selectedDur, stylist: assignedStylist, date: form.date, time: form.time, name: form.name, phone: form.phone, cedula: form.cedula, createdAt: Date.now(), status: "pending", }; setStore(s => ({ ...s, appointments: [...s.appointments, appt] }), appt); setTicket(appt); setStep(5); try { sessionStorage.removeItem(BOOKING_DRAFT_KEY); } catch {} }; const TOTAL_STEPS = 4; // Inline field validation for step 4 const cleanDigits = (s) => (s || "").replace(/\D/g, ""); const trimmedName = form.name.trim(); const phoneDigits = cleanDigits(form.phone); const errors = { name: trimmedName.length === 0 ? "" : trimmedName.length < 3 ? "Ingresa al menos 3 caracteres." : "", phone: phoneDigits.length === 0 ? "" : phoneDigits.length !== 10 ? "Debe tener 10 dígitos (ej: 300 123 4567)." : "", cedula: form.cedula.length === 0 ? "" : (form.cedula.length < 6 || form.cedula.length > 12) ? "Cédula entre 6 y 12 dígitos." : "", }; const step4Valid = trimmedName.length >= 3 && phoneDigits.length === 10 && form.cedula.length >= 6 && form.cedula.length <= 12; const canNext = (step === 1 && !!form.service) || (step === 2 && !!form.stylist) || (step === 3 && !!form.date && !!form.time) || (step === 4 && step4Valid); const hasRole = !!( sessionStorage.getItem("joxe_admin_session") || sessionStorage.getItem("joxe_agenda_session") ); const homeHref = hasRole ? "Portal.html" : "Asesores de Imagen.html"; return ( ← Inicio } /> }>
{/* Progress bar */} {step < 5 && (
{Array.from({ length: TOTAL_STEPS }, (_, i) => i + 1).map(n => (
))}
)} {/* ── STEP 1: Service ── */} {step === 1 && ( <> 01 — Servicio

¿Qué necesitas hoy?

{services.map(s => { const sel = form.serviceId === s.id; return ( ); })}
)} {/* ── STEP 2: Stylist ── */} {step === 2 && ( <> 02 — Tu estilista

¿Con quién prefieres?

Servicio seleccionado: {form.service}

{/* "Sin preferencia" always shown first */} {(() => { const sp = "Sin preferencia"; const sel = form.stylist === sp; return ( ); })()} {eligibleEmployees.map(e => { const sel = form.stylistId === e.id; // Count today's appointments for this stylist (availability hint) const todayApts = (store.appointments || []).filter( a => a.date === todayStr() && a.stylist === e.name && !["cancelled"].includes(a.status) ).length; const availSlots = availableDates.reduce((acc, d) => acc + slotsForDate(d).filter(t => !isSlotTaken(d, t, e.name, selectedDur)).length, 0 ); const todayFreeSlots = isClosedDay(todayStr()) ? 0 : slotsForDate(todayStr()).filter(t => !isSlotTaken(todayStr(), t, e.name, selectedDur)).length; return ( ); })} {eligibleEmployees.length === 0 && (
No hay profesionales disponibles para este servicio en este momento.
)}
)} {/* ── STEP 3: Agenda (date + time) ── */} {step === 3 && ( <> 03 — Elige tu momento

¿Cuándo te vemos?

{form.stylist !== "Sin preferencia" ? form.stylist : "Cualquier profesional"} · {form.service}

{availableDates.map(date => { const isSelectedDate = form.date === date; return (
{/* Date header */}
{fmtDateLabel(date)}
{fmtDateSub(date)}
{/* Time slots */}
{slotsForDate(date).map(t => { const blocked = isSlotTaken(date, t, form.stylist); const isSelected = form.date === date && form.time === t; return ( ); })}
); })}
{form.date && form.time && (
Turno seleccionado
{fmtDateLabel(form.date)} · {form.time}
{fmtDateSub(form.date)}
)} )} {/* ── STEP 4: Personal data ── */} {step === 4 && ( <> 04 — Tus datos

Un último paso.

setForm({ ...form, name: e.target.value })} placeholder="María Pérez" aria-invalid={!!errors.name} aria-describedby={errors.name ? "bk-name-err" : undefined} style={{ width: "100%", padding: "18px 20px", border: `1px solid ${errors.name ? "#C46666" : "rgba(12,12,12,0.2)"}`, background: "#FFF", fontFamily: "'Outfit', sans-serif", fontSize: 15, color: "#0C0C0C", }} /> {errors.name && ( )}
setForm({ ...form, phone: e.target.value })} placeholder="300 123 4567" inputMode="tel" aria-invalid={!!errors.phone} aria-describedby={errors.phone ? "bk-phone-err" : undefined} style={{ width: "100%", padding: "18px 20px", border: `1px solid ${errors.phone ? "#C46666" : "rgba(12,12,12,0.2)"}`, background: "#FFF", fontFamily: "'Outfit', sans-serif", fontSize: 15, color: "#0C0C0C", }} /> {errors.phone && ( )}
setForm({ ...form, cedula: e.target.value.replace(/\D/g, "").slice(0, 12) })} placeholder="1234567890" inputMode="numeric" aria-invalid={!!errors.cedula} aria-describedby={errors.cedula ? "bk-cedula-err" : "bk-cedula-hint"} style={{ width: "100%", padding: "18px 20px", border: `1px solid ${errors.cedula ? "#C46666" : "rgba(12,12,12,0.2)"}`, background: "#FFF", fontFamily: "'JetBrains Mono', monospace", fontSize: 15, color: "#0C0C0C", letterSpacing: "0.08em", }} /> {errors.cedula ? ( ) : (
Con tu cédula podrás consultar tu historial de visitas en cualquier momento.
)}
{/* Summary card */}
Resumen de tu cita
{form.service}
{fmtDateLabel(form.date)} {fmtDateSub(form.date)} · {form.time}
{form.stylist}
Importante: al finalizar deberás confirmar tu cita por WhatsApp. Sin este paso, la reserva no quedará agendada.
)} {step === 5 && ticket && (
Solicitud recibida

Casi listo, {ticket.name.split(" ")[0]}.

Tu cita está guardada pero pendiente de confirmar.

{/* Countdown warning */} {secsLeft !== null && (
0 ? "rgba(196,102,102,0.07)" : "rgba(196,102,102,0.15)", border: `1px solid rgba(196,102,102,${secsLeft > 0 ? 0.35 : 0.6})`, display: "flex", alignItems: "center", gap: 14, }}>
0 ? "#C46666" : "#C46666", flexShrink: 0, minWidth: 64, textAlign: "center", }}> {secsLeft > 0 ? `${String(Math.floor(secsLeft / 60)).padStart(2,"0")}:${String(secsLeft % 60).padStart(2,"0")}` : "00:00" }
{secsLeft > 0 ? <>Tienes {Math.floor(secsLeft / 60)} min {secsLeft % 60} seg para enviar el comprobante. Pasado este tiempo, la reserva será cancelada automáticamente. : <>Tiempo agotado. Esta reserva ya no puede confirmarse. Si aún deseas tu cita, vuelve a agendar. }
)} {/* Resumen */}
Resumen
{[ ["Servicio", ticket.service], ["Estilista", ticket.stylist], ["Fecha", ticket.date], ["Hora", ticket.time], ].map(([k, v]) => (
{k}
{v}
))}
{secsLeft > 0 && ( <> {/* Abono */}
Para confirmar tu cita

Realiza un abono de $10.000 y envía el comprobante por WhatsApp. El estilista confirmará tu cita al recibirlo.

{/* Botón WhatsApp */} Enviar comprobante

Se abrirá WhatsApp con el mensaje listo — solo adjunta el comprobante y envía.

¿No puedes abrir el botón? Envía el comprobante directamente a{" "} {waAdminFormatted} {" "}por WhatsApp.

)} {/* Volver al inicio */} { e.currentTarget.style.borderColor = "#0C0C0C"; e.currentTarget.style.color = "#0C0C0C"; }} onMouseLeave={e => { e.currentTarget.style.borderColor = "rgba(12,12,12,0.15)"; e.currentTarget.style.color = "rgba(12,12,12,0.55)"; }} >← Volver al sitio
)} {step < 5 && (
)}
); }; // ============================================================ // PAGE 2 — ESCANEAR QR (RECEPCIÓN) // Uses the real camera via getUserMedia + jsQR (loaded as a global in Scan.html) // ============================================================ const ScanPortal = () => { const [store, setStore] = useStore(); const [scanned, setScanned] = React.useState(null); const [error, setError] = React.useState(""); const [camStatus, setCamStatus] = React.useState("idle"); // idle | requesting | active | denied | unsupported const videoRef = React.useRef(null); const canvasRef = React.useRef(null); const streamRef = React.useRef(null); const rafRef = React.useRef(0); const lookupAppt = (id) => { const s = loadCache(); const appt = s.appointments.find(a => a.id === id); if (!appt) return { error: "Ticket no encontrado. Verifica tu QR." }; if (s.active.find(a => a.id === id) || s.completed.find(a => a.id === id)) { return { error: "Este turno ya fue activado." }; } return { appt }; }; const handleScan = React.useCallback((id) => { const r = lookupAppt(id); if (r.error) { setError(r.error); return; } setError(""); setScanned(r.appt); stopCamera(); }, []); const stopCamera = () => { cancelAnimationFrame(rafRef.current); rafRef.current = 0; const s = streamRef.current; if (s) { s.getTracks().forEach(t => t.stop()); streamRef.current = null; } if (videoRef.current) videoRef.current.srcObject = null; }; const startCamera = React.useCallback(async () => { if (typeof window.jsQR !== "function") { setCamStatus("unsupported"); setError("La biblioteca de escaneo no cargó. Usa la lista de abajo o recarga."); return; } if (!navigator.mediaDevices?.getUserMedia) { setCamStatus("unsupported"); setError("Tu navegador no soporta acceso a cámara. Usa la lista de abajo."); return; } setCamStatus("requesting"); setError(""); try { const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: "environment" } }, audio: false, }); streamRef.current = stream; const video = videoRef.current; if (!video) { stream.getTracks().forEach(t => t.stop()); return; } video.srcObject = stream; video.setAttribute("playsinline", "true"); await video.play(); setCamStatus("active"); tick(); } catch (err) { console.warn("[scan] camera denied/failed", err.name, err.message); setCamStatus("denied"); setError(err.name === "NotAllowedError" ? "Permiso de cámara denegado. Usa la lista de abajo o habilítala en el navegador." : "No se pudo abrir la cámara. Usa la lista de abajo."); } }, []); const tick = () => { const video = videoRef.current; const canvas = canvasRef.current; if (!video || !canvas || !streamRef.current) return; if (video.readyState === video.HAVE_ENOUGH_DATA) { const w = video.videoWidth, h = video.videoHeight; if (w && h) { canvas.width = w; canvas.height = h; const ctx = canvas.getContext("2d", { willReadFrequently: true }); ctx.drawImage(video, 0, 0, w, h); const imageData = ctx.getImageData(0, 0, w, h); const code = window.jsQR(imageData.data, w, h, { inversionAttempts: "dontInvert" }); if (code?.data) { handleScan(code.data); return; } } } rafRef.current = requestAnimationFrame(tick); }; // Auto-detect from hash (QR may also be opened as a deep link) React.useEffect(() => { const id = window.location.hash.slice(1); if (id) handleScan(id); }, [handleScan]); // Start camera on mount; cleanup on unmount React.useEffect(() => { startCamera(); return stopCamera; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const rescan = () => { setScanned(null); setError(""); startCamera(); }; const activateTurn = () => { setStore(s => ({ ...s, active: [...s.active, { ...scanned, activatedAt: Date.now(), status: "waiting", position: s.active.length + 1 }], })); setScanned({ ...scanned, activated: true }); }; const recent = store.appointments.filter(a => a.date === todayStr() && !["cancelled"].includes(a.status) && !store.active.find(x => x.id === a.id) && !store.completed.find(x => x.id === a.id) ).slice(-5).reverse(); return ( ← Inicio } /> }>
{/* Scanner */}
Escáner

Apunta al QR.

{/* Real camera feed */}
); }; // ============================================================ // PAGE 3 — LOBBY (pantalla grande con cola) // ============================================================ const LobbyPortal = () => { const [store, setStore] = useStore(); const [now, setNow] = React.useState(new Date()); const dlg = useDialog(); React.useEffect(() => { const t = setInterval(() => setNow(new Date()), 1000); return () => clearInterval(t); }, []); const inChair = store.active.find(a => a.status === "in-service"); const queue = store.active.filter(a => a.status === "waiting"); const callNext = () => { const next = queue[0]; if (!next) return; setStore(s => { const updated = s.active.map(a => { if (a.id === next.id) return { ...a, status: "in-service", startedAt: Date.now() }; return a; }); // move previous in-chair to completed const prevChair = s.active.find(a => a.status === "in-service"); const completed = prevChair ? [...s.completed, { ...prevChair, completedAt: Date.now() }] : s.completed; const remaining = updated.filter(a => a.id !== (prevChair ? prevChair.id : null)); return { ...s, active: remaining, completed }; }); }; const reset = async () => { const ok = await dlg.confirm({ title: "¿Limpiar la sala?", body: "Esto borra todos los turnos activos y completados de hoy. Las citas agendadas no se ven afectadas.", confirmLabel: "Limpiar", danger: true, }); if (ok) setStore(s => ({ ...s, active: [], completed: [] })); }; const timeStr = now.toLocaleTimeString("es-CO", { hour: "2-digit", minute: "2-digit" }); return ( {timeStr} Inicio } /> }>
{/* Now serving */}
Atendiendo {inChair ? (
{inChair.name}
{inChair.code}
Servicio
{inChair.service}
Estilista
{inChair.stylist}
) : (
Nadie en silla {queue.length > 0 && ( )}
)}
{/* Queue */}
En espera {queue.length} {queue.length === 1 ? "persona" : "personas"}
{queue.length === 0 && (
Cola vacía
)} {queue.map((a, i) => (
{String(i + 1).padStart(2, "0")}
{a.name}
{a.service} · {a.stylist}
{a.code}
))}
{store.completed.length > 0 && (
Completados hoy · {store.completed.length}
{store.completed.slice(-3).reverse().map(a => (
{a.code} · {a.name}
))}
)}
{dlg.node}
); }; // ============================================================ // PAGE 4 — MI CUENTA (cliente) // ============================================================ const ACCT_KEY = "joxe_cuenta_cedula"; const CuentaPortal = () => { const [cedula, setCedula] = React.useState(() => localStorage.getItem(ACCT_KEY) || ""); const [input, setInput] = React.useState(""); const [data, setData] = React.useState(null); const [loading, setLoading] = React.useState(false); const [error, setError] = React.useState(""); const [showQR, setShowQR] = React.useState(null); const fetchData = React.useCallback(async (cc, silent = false) => { try { const res = await fetch(`/api/client?cedula=${encodeURIComponent(cc)}`); if (!res.ok) throw new Error("error"); const d = await res.json(); setData(d); } catch { if (!silent) setError("No pudimos conectarnos. Intenta de nuevo."); } }, []); // Auto-load + live poll React.useEffect(() => { const saved = localStorage.getItem(ACCT_KEY); if (saved) { setCedula(saved); fetchData(saved, true); } }, [fetchData]); React.useEffect(() => { if (!cedula) return; const t = setInterval(() => fetchData(cedula, true), 8000); return () => clearInterval(t); }, [cedula, fetchData]); const login = async () => { const clean = input.replace(/\D/g, ""); if (clean.length < 6) { setError("Ingresa una cédula válida."); return; } setLoading(true); setError(""); await fetchData(clean); setCedula(clean); localStorage.setItem(ACCT_KEY, clean); setLoading(false); }; const logout = () => { setCedula(""); setData(null); setInput(""); localStorage.removeItem(ACCT_KEY); }; const todayStr = (() => { const d = nowCOT(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`; })(); const fmtDate = d => !d ? "—" : new Date(d + "T12:00").toLocaleDateString("es-CO", { weekday: "long", day: "numeric", month: "long" }); const fmtShort = d => !d ? "—" : new Date(d + "T12:00").toLocaleDateString("es-CO", { day: "numeric", month: "short" }); const STATUS = { scheduled: { label: "Agendada", color: "#C29E66", bg: "rgba(194,158,102,0.1)" }, waiting: { label: "En sala", color: "#8ab0ff", bg: "rgba(138,176,255,0.1)" }, "in-service":{ label: "¡En silla!", color: "#66C499", bg: "rgba(102,196,153,0.15)" }, completed: { label: "Completada", color: "rgba(245,241,234,0.4)", bg: "rgba(245,241,234,0.05)"}, cancelled: { label: "Cancelada", color: "#C46666", bg: "rgba(196,102,102,0.1)" }, }; // ── LOGIN SCREEN ────────────────────────────────────────── if (!cedula || !data) { return ( ← Inicio } /> }>
Tu espacio personal

Consulta tus visitas.

Ingresa tu cédula de ciudadanía para ver tus citas, historial de visitas y puntos de lealtad.

{ setInput(e.target.value.replace(/\D/g, "")); setError(""); }} onKeyDown={e => e.key === "Enter" && login()} placeholder="1234567890" inputMode="numeric" aria-invalid={!!error} aria-describedby={error ? "cuenta-cedula-err" : undefined} style={{ width: "100%", padding: "18px 20px", background: "#0C0C0C", border: "1px solid rgba(245,241,234,0.15)", color: "#F5F1EA", fontFamily: "'JetBrains Mono', monospace", fontSize: 16, letterSpacing: "0.1em", }} /> {error && ( )}
¿Primera vez? Reservar cita →
); } // ── ACCOUNT SCREEN ──────────────────────────────────────── const appts = data.appointments || []; const loyalty = data.loyalty; const clientName = appts.length > 0 ? appts.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0))[0]?.name || "" : ""; const upcoming = appts .filter(a => !["cancelled","completed"].includes(a.computedStatus) && (a.date >= todayStr || a.computedStatus === "waiting" || a.computedStatus === "in-service")) .sort((a, b) => (a.date || "").localeCompare(b.date || "") || (a.time || "").localeCompare(b.time || "")); const history = appts .filter(a => ["completed","cancelled"].includes(a.computedStatus)) .sort((a, b) => (b.date || "").localeCompare(a.date || "")); const liveAppt = appts.find(a => a.computedStatus === "waiting" || a.computedStatus === "in-service"); return ( + Nueva cita } /> }>
{/* Live status banner */} {liveAppt && (
{liveAppt.computedStatus === "in-service" ? "¡Estás en silla ahora!" : "Estás en sala · en espera"}
{liveAppt.service}
{liveAppt.stylist && (
{liveAppt.stylist}
)}
Ver sala →
)} {/* Loyalty card */} {loyalty && loyalty.enabled && (
= loyalty.target ? "rgba(102,196,153,0.3)" : "rgba(245,241,234,0.1)"}`, padding: "28px 32px", }}> Programa de lealtad · {loyalty.reward}
= loyalty.target ? "#66C499" : "#C29E66", lineHeight: 1, }}> {loyalty.visits} /{loyalty.target}
{loyalty.redeemed > 0 && ( {loyalty.redeemed} canje{loyalty.redeemed !== 1 ? "s" : ""} previos )}
{loyalty.visits >= loyalty.target ? ( ¡Felicidades! Tienes un {loyalty.reward}. Avisa en recepción cuando llegues. ) : ( <> Te faltan{" "} {loyalty.target - loyalty.visits} visita{loyalty.target - loyalty.visits !== 1 ? "s" : ""} {" "} para tu {loyalty.reward}. )}
{/* Progress bar */}
= loyalty.target ? "#66C499" : "#C29E66", width: `${Math.min(loyalty.visits / loyalty.target * 100, 100)}%`, transition: "width 0.5s ease", }} />
{/* Dots */}
{Array.from({ length: Math.min(loyalty.target, 20) }).map((_, i) => (
= loyalty.target ? "#66C499" : "#C29E66") : "rgba(245,241,234,0.08)", border: `1px solid ${i < loyalty.visits ? (loyalty.visits >= loyalty.target ? "#66C499" : "#C29E66") : "rgba(245,241,234,0.12)"}`, transition: "background 0.3s", }} /> ))} {loyalty.visits > 20 && ( +{loyalty.visits - 20} )}
)} {/* Upcoming appointments */}
Próximas citas {upcoming.length > 0 && ` · ${upcoming.length}`} + Agendar →
{upcoming.length === 0 ? (
No tienes citas próximas.
Reserva una ahora →
) : (
{upcoming.map(a => { const s = STATUS[a.computedStatus] || STATUS.scheduled; const isQROpen = showQR === a.id; const isLive = a.computedStatus === "waiting" || a.computedStatus === "in-service"; return (
{s.label} {a.code}
{a.service}
{a.stylist && <>{a.stylist} · } {fmtDate(a.date)} {a.time && <> · {a.time}}
{/* Actions for upcoming appointments */}
{a.computedStatus === "scheduled" && ( )} {a.date === todayStr && (a.computedStatus === "scheduled" || a.computedStatus === "waiting") && ( Check-In → )}
{isQROpen && (
Muestra este código en recepción para activar tu turno
{a.code}
)}
); })}
)}
{/* History */} {history.length > 0 && (
Historial · {history.length} visita{history.length !== 1 ? "s" : ""}
{history.map(a => { const s = STATUS[a.computedStatus] || STATUS.completed; return (
{fmtShort(a.date)}
{a.service}
{a.stylist && (
{a.stylist}
)}
{a.time} {s.label}
); })}
)} {/* Empty state */} {appts.length === 0 && (
No encontramos citas con esta cédula.
Reserva tu primera cita →
)}
); }; // ============================================================ // PAGE 0 — HOME // ============================================================ // ── Portal auth gate ───────────────────────────────────────── const PORTAL_SES_ADMIN = "joxe_admin_session"; const PORTAL_SES_EMP = "joxe_agenda_session"; const isPortalAuthed = () => !!sessionStorage.getItem(PORTAL_SES_ADMIN) || !!sessionStorage.getItem(PORTAL_SES_EMP); const PortalLoginGate = ({ onAuth }) => { const [tab, setTab] = React.useState("admin"); // "admin" | "empleado" const [pw, setPw] = React.useState(""); const [empList, setEmpList] = React.useState([]); const [selId, setSelId] = React.useState(""); const [pin, setPin] = React.useState(""); const [loading, setLoading] = React.useState(false); const [err, setErr] = React.useState(""); React.useEffect(() => { fetch("/api/catalog") .then(r => r.ok ? r.json() : null) .then(d => { if (d?.employees) setEmpList(d.employees.filter(e => e.active !== false)); }) .catch(() => {}); }, []); const loginAdmin = async () => { if (!pw) return; setLoading(true); setErr(""); try { const res = await fetch("/api/auth", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: pw }), }); const data = await res.json(); if (!res.ok || !data.ok) { setErr("Contraseña incorrecta."); setPw(""); setLoading(false); return; } sessionStorage.setItem(PORTAL_SES_ADMIN, pw); onAuth(); } catch { setErr("Error de conexión."); } setLoading(false); }; const loginEmp = async () => { if (!selId || !pin) return; setLoading(true); setErr(""); try { const res = await fetch("/api/agenda", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "login", empId: selId, pin }), }); const data = await res.json(); if (!res.ok) { setErr(data.error || "PIN incorrecto."); setPin(""); setLoading(false); return; } sessionStorage.setItem(PORTAL_SES_EMP, JSON.stringify(data.employee)); if (data.token) sessionStorage.setItem("joxe_emp_token", data.token); onAuth(); } catch { setErr("Error de conexión."); } setLoading(false); }; const tabStyle = (active) => ({ flex: 1, padding: "10px 0", background: "none", border: "none", borderBottom: active ? "2px solid #C29E66" : "2px solid transparent", color: active ? "#C29E66" : "rgba(245,241,234,0.45)", fontFamily: "'JetBrains Mono', monospace", fontSize: 10, letterSpacing: "0.2em", textTransform: "uppercase", cursor: "pointer", transition: "color 0.2s", }); return ( Sitio web ↗ } /> }>
Acceso restringido

Portal de equipo.

Ingresa con tu cuenta de administrador o empleado para continuar.

{/* Tabs */}
{tab === "admin" && (
{ setPw(e.target.value); setErr(""); }} onKeyDown={e => e.key === "Enter" && loginAdmin()} placeholder="••••••••" style={{ width: "100%", padding: "14px 16px", marginBottom: 20, background: "#0C0C0C", border: "1px solid rgba(245,241,234,0.15)", color: "#F5F1EA", fontFamily: "'JetBrains Mono', monospace", fontSize: 15, }} />
)} {tab === "empleado" && (
{ setPin(e.target.value.replace(/\D/g, "")); setErr(""); }} onKeyDown={e => e.key === "Enter" && selId && pin && loginEmp()} placeholder="••••" inputMode="numeric" maxLength={8} style={{ width: "100%", padding: "14px 16px", marginBottom: 20, background: "#0C0C0C", border: "1px solid rgba(245,241,234,0.15)", color: "#F5F1EA", fontFamily: "'JetBrains Mono', monospace", fontSize: 20, letterSpacing: "0.3em", }} />
)} {err && (

{err}

)}
); }; const HomePortal = () => { const [store] = useStore(); const [authed, setAuthed] = React.useState(isPortalAuthed); const doLogout = () => { sessionStorage.removeItem(PORTAL_SES_ADMIN); sessionStorage.removeItem(PORTAL_SES_EMP); setAuthed(false); }; if (!authed) return setAuthed(true)} />; const empSession = (() => { try { return JSON.parse(sessionStorage.getItem(PORTAL_SES_EMP)); } catch { return null; } })(); const isAdmin = !!sessionStorage.getItem(PORTAL_SES_ADMIN); const label = isAdmin ? "Admin" : (empSession?.name ?? "Empleado"); return ( Sitio web ↗ Check-In {empSession && !isAdmin && ( Mi Agenda ◈ )} {isAdmin && ( Admin ⊛ )} } /> }>
Bienvenido

Reserva. Escanea. Entra.

Un sistema simple en tres tiempos. Agenda tu cita desde casa, escanea el QR al llegar, y espera cómodo mientras te llamamos por nombre.

{[ { n: "01", title: "Agendar cita", subtitle: "Cliente", desc: "Elige servicio, fecha y estilista. Recibe tu QR personal.", href: "Booking.html", cta: "Reservar ahora", primary: true, }, { n: "02", title: "Mi Cuenta", subtitle: "Cliente", desc: "Consulta tus citas, el estado en sala y tus puntos de lealtad.", href: "Cuenta.html", cta: "Ver mi cuenta", }, ...(empSession ? [{ n: "03", title: "Mi Agenda", subtitle: "Empleado", desc: "Revisa las solicitudes de citas asignadas a ti y confírmalas al recibir el comprobante.", href: "Agenda.html", cta: "Ver solicitudes →", accent: true, }] : []), { n: empSession ? "04" : "03", title: "Escanear QR", subtitle: "Recepción", desc: "Valida el ticket y activa el turno al llegar al salón.", href: "Scan.html", cta: "Abrir escáner", }, { n: empSession ? "05" : "04", title: "Pantalla de sala", subtitle: "Lobby", desc: "Muestra la cola en vivo — proyecta en pantalla grande.", href: "Lobby.html", cta: "Ver sala", }, { n: empSession ? "06" : "05", title: "Check-In", subtitle: "Cliente · Silla", desc: "Escanea el QR de tu silla para confirmar asistencia o cerrar el servicio.", href: "CheckIn.html", cta: "Ir a Check-In", }, ].map(c => ( e.currentTarget.style.transform = "translateY(-4px)"} onMouseLeave={e => e.currentTarget.style.transform = "translateY(0)"} >
{c.n} · {c.subtitle}
{c.title}
{c.desc}
{c.cta} →
))}
{/* Stats */}
{[ ["En espera", store.active.filter(a => a.status === "waiting").length], ["En silla", store.active.filter(a => a.status === "in-service").length], ["Agendados", store.appointments.length], ["Atendidos hoy", store.completed.length], ].map(([l, n]) => (
{String(n).padStart(2, "0")}
{l}
))}
); }; // ============================================================ // PAGE 5 — CHECK-IN (silla de estilista) // Admin: ve la agenda del día con acciones. // Todos los demás: confirman asistencia con cédula. // ============================================================ // Helpers locales para leer/escribir el estado de admin sin importar admin.jsx const ADMIN_KEY_CI = "joxe_admin_v1"; const loadAdminCI = () => { try { return JSON.parse(localStorage.getItem(ADMIN_KEY_CI)) || {}; } catch { return {}; } }; const saveAdminCI = (next) => localStorage.setItem(ADMIN_KEY_CI, JSON.stringify(next)); // Calcula el status visible de una cita igual que getAllAppts en admin.jsx const resolveApptStatus = (appt, activeIds, completedIds, noShowIds, cancelledIds) => { if (noShowIds.includes(appt.id)) return "no-show"; if (cancelledIds.includes(appt.id)) return "cancelled"; if (completedIds.has(appt.id)) return "completed"; if (activeIds.has(appt.id)) return "waiting"; return appt.status || "scheduled"; }; const CheckInPortal = () => { const [store, setStore] = useStore(); const catalog = useCatalog(); const hash = window.location.hash.replace('#', ''); const chairNum = hash.startsWith('puesto-') ? Number(hash.replace('puesto-', '')) : null; const legacyId = !chairNum && hash.startsWith('chair-') ? hash.replace('chair-', '') : null; const resolvedEmpId = chairNum ? (catalog.chairAssignments?.[chairNum] || null) : legacyId; const employee = resolvedEmpId ? (catalog.employees.find(e => e.id === resolvedEmpId) || null) : null; const isAdmin = !!sessionStorage.getItem("joxe_admin_session"); const headerRight = ( ← Inicio ); // ── MODO ADMIN: agenda del estilista ─────────────────────── if (isAdmin) { return ; } // ── MODO CLIENTE / ESTILISTA / ANÓNIMO: check-in por cédula ─ return ; }; // ── Vista admin ───────────────────────────────────────────── const CheckInAdminView = ({ store, setStore, employee, headerRight }) => { const [adminState, setAdminState] = React.useState(loadAdminCI); const [checkoutId, setCheckoutId] = React.useState(null); const [price, setPrice] = React.useState(''); const [note, setNote] = React.useState(''); const [done, setDone] = React.useState(false); const [confirm, setConfirm] = React.useState(null); // { appt, action: 'noshow'|'checkin' } const noShowIds = adminState.noShowIds || []; const cancelledIds = adminState.cancelledIds || []; const activeIds = new Set(store.active.map(a => a.id)); const completedIds = new Set(store.completed.map(a => a.id)); const today = todayStr(); // Todas las citas de hoy para este estilista (o todas si no hay silla) const todayAppts = React.useMemo(() => { const all = [ ...store.appointments.map(a => ({ ...a, _src: 'scheduled' })), ...store.active.map(a => ({ ...a, _src: 'active' })), ...store.completed.map(a => ({ ...a, _src: 'completed' })), ]; // Dedup por id: active/completed override scheduled (last wins) const seen = new Map(); all.forEach(a => seen.set(a.id, a)); return [...seen.values()] .filter(a => a.date === today && (!employee || a.stylist === employee.name)) .map(a => ({ ...a, computedStatus: resolveApptStatus(a, activeIds, completedIds, noShowIds, cancelledIds), })) .sort((a, b) => (a.time || '').localeCompare(b.time || '')); }, [store, employee, today, noShowIds, cancelledIds]); // Cliente activo en silla (para checkout) const inService = checkoutId ? store.active.find(a => a.id === checkoutId) || null : null; const manualCheckIn = (appt) => { setStore(s => { const alreadyActive = s.active.some(a => a.id === appt.id); const rawAppt = s.appointments.find(a => a.id === appt.id) || appt; return { ...s, appointments: s.appointments.map(a => a.id === appt.id ? { ...a, checkedIn: true, checkedInAt: Date.now() } : a ), active: alreadyActive ? s.active : [ ...s.active, { ...rawAppt, checkedIn: true, checkedInAt: Date.now(), activatedAt: Date.now(), status: "waiting", position: s.active.length + 1 }, ], }; }); setConfirm(null); }; const CI_DAY_KEYS = ["dom","lun","mar","mie","jue","vie","sab"]; const markNoShow = async (appt) => { const admin = loadAdminCI(); const ids = [...(admin.noShowIds || []), appt.id]; const fine = admin.noShowFine; let next = { ...admin, noShowIds: ids }; if (fine?.enabled) { // Use same day-key format as admin.jsx ("lun","mar"...) to match byDay config const day = CI_DAY_KEYS[new Date(appt.date + "T12:00").getDay()]; const amount = (fine.byDay?.[day] > 0 ? fine.byDay[day] : fine.defaultAmount) || 0; if (amount > 0) { const fineId = `ns-${appt.id}`; // Guard: prevent double-registering the same fine const alreadyExists = (admin.revenue || []).some(r => r.id === fineId); if (!alreadyExists) { next = { ...next, revenue: [...(admin.revenue || []), { id: fineId, date: appt.date, type: 'no-show-fine', client: appt.name, service: appt.service, phone: appt.phone || "", stylist: appt.stylist || "", method: "Multa", note: `Incumplimiento · ${appt.code || appt.id}`, amount, createdAt: Date.now(), }], }; } } } saveAdminCI(next); setAdminState(next); setConfirm(null); // Persist to server so admin panel syncs correctly const token = sessionStorage.getItem("joxe_admin_session") || ""; if (token) { try { await fetch("/api/admin", { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` }, body: JSON.stringify(next), }); } catch (err) { console.warn("[checkin] admin save failed", err.message); } } }; const checkout = () => { if (!inService) return; const finalPrice = price ? Number(price.replace(/\D/g, '')) : undefined; setStore(s => ({ ...s, active: s.active.filter(a => a.id !== inService.id), completed: [...s.completed, { ...inService, completedAt: Date.now(), ...(finalPrice ? { finalPrice } : {}), ...(note.trim() ? { note: note.trim() } : {}), }], })); setDone(true); }; const STATUS_CHIP = { scheduled: { label: "Pendiente", bg: "rgba(138,176,255,0.1)", color: "#8ab0ff", border: "rgba(138,176,255,0.3)" }, waiting: { label: "En sala", bg: "rgba(194,158,102,0.12)", color: "#C29E66", border: "rgba(194,158,102,0.4)" }, "in-service":{ label: "En silla", bg: "rgba(102,196,153,0.12)", color: "#66C499", border: "rgba(102,196,153,0.4)" }, completed: { label: "Completada", bg: "rgba(102,196,153,0.06)", color: "#66C499", border: "rgba(102,196,153,0.2)" }, "no-show": { label: "Incumplida", bg: "rgba(196,102,102,0.12)", color: "#e07070", border: "rgba(196,102,102,0.3)" }, cancelled: { label: "Cancelada", bg: "rgba(245,241,234,0.04)", color: "rgba(245,241,234,0.35)", border: "rgba(245,241,234,0.1)" }, }; // ── Checkout overlay ────────────────────────────────────── if (checkoutId) { return ( { setCheckoutId(null); setDone(false); setPrice(''); setNote(''); }} style={{ background: "none", border: "none", color: "#F5F1EA", cursor: "pointer", fontFamily: "'Outfit', sans-serif", fontSize: 12, letterSpacing: "0.15em", textTransform: "uppercase", opacity: 0.6 }}>← Volver } /> }>
{done ? ( <>
Servicio completado

Cerrado.

El cliente fue movido al historial.

) : !inService ? ( <>

Cita no activa.

Esta cita no está en cola activa. Actívala desde recepción primero.

) : ( <> {inService.code}

{inService.name}

{[["Servicio", inService.service], ["Estilista", inService.stylist], ["Teléfono", inService.phone], ["Cédula", inService.cedula]].map(([label, val]) => (
{label}
{val || "—"}
))}
setPrice(e.target.value.replace(/\D/g, ''))} placeholder="85.000" style={{ width: "100%", background: "#141212", border: "1px solid rgba(245,241,234,0.12)", color: "#F5F1EA", padding: "14px 16px", fontFamily: "'JetBrains Mono', monospace", fontSize: 16, letterSpacing: "0.08em" }} />