fase 3.4: PWA instalable (manifest e iconos, service worker propio con app shell precacheado y cache versionada por build, API nunca cacheada, fallback offline, aviso de instalacion con guia iOS y banner sin conexion) + branding: perfiles de ticket por instancia (defaults neutros, sin filtrar datos de SKEEN) y Dashboard sin nombre hardcodeado

This commit is contained in:
2026-10-11 13:23:56 +00:00
parent 3c4f1b14d9
commit ab9d84cb67
19 changed files with 574 additions and 17 deletions

View File

@@ -3,6 +3,7 @@ import { BrowserRouter as Router, Routes, Route, Navigate, useSearchParams } fro
import { MENU_ITEMS } from './lib/menu-items';
import { ToastProvider, Button } from './components/ui';
import ErrorBoundary from './components/ErrorBoundary';
import { PwaInstallPrompt, OfflineBanner } from './components/PwaNotices';
import { AuthProvider, useAuth } from './lib/auth';
import { GlosarioProvider } from './lib/glosario';
import { SucursalesProvider } from './lib/sucursales';
@@ -167,6 +168,8 @@ const App: FC = () => {
<QuickLinksProvider>
<Router>
<ToastProvider />
<PwaInstallPrompt />
<OfflineBanner />
<PatientDrawerProvider>
<ChatNotifications />
<Routes>

View File

@@ -0,0 +1,148 @@
import type { FC } from 'react';
import { useEffect, useState } from 'react';
import { Download, Share, WifiOff, X } from 'lucide-react';
import { Button } from './ui';
// Avisos de PWA: invitación a instalar (Android/desktop vía
// `beforeinstallprompt`, iOS con guía manual) y banner de sin conexión.
// Son discretos, descartables y no bloquean la operación.
const DISMISS_KEY = 'skeen_pwa_install_dismissed';
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
const estaInstalada = (): boolean => {
if (typeof window === 'undefined') return false;
const standalone = window.matchMedia?.('(display-mode: standalone)').matches;
const iosStandalone = (window.navigator as Navigator & { standalone?: boolean }).standalone === true;
return Boolean(standalone || iosStandalone);
};
const esIOS = (): boolean =>
typeof navigator !== 'undefined' && /iphone|ipad|ipod/i.test(navigator.userAgent);
export const PwaInstallPrompt: FC = () => {
const [deferred, setDeferred] = useState<BeforeInstallPromptEvent | null>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
if (estaInstalada()) return;
try {
if (localStorage.getItem(DISMISS_KEY) === '1') return;
} catch {
return;
}
const onBeforeInstall = (e: Event) => {
e.preventDefault();
// Si ya se descartó (incluso en esta misma sesión), no volver a insistir.
try { if (localStorage.getItem(DISMISS_KEY) === '1') return; } catch { /* ignore */ }
setDeferred(e as BeforeInstallPromptEvent);
setVisible(true);
};
const onInstalled = () => {
setVisible(false);
try { localStorage.setItem(DISMISS_KEY, '1'); } catch { /* ignore */ }
};
window.addEventListener('beforeinstallprompt', onBeforeInstall);
window.addEventListener('appinstalled', onInstalled);
// Safari/iOS no dispara `beforeinstallprompt`: se muestra la guía manual.
let timer: number | undefined;
if (esIOS() && !('onbeforeinstallprompt' in window)) {
timer = window.setTimeout(() => setVisible(true), 6000);
}
return () => {
window.removeEventListener('beforeinstallprompt', onBeforeInstall);
window.removeEventListener('appinstalled', onInstalled);
if (timer) window.clearTimeout(timer);
};
}, []);
const descartar = () => {
setVisible(false);
try { localStorage.setItem(DISMISS_KEY, '1'); } catch { /* ignore */ }
};
const instalar = async () => {
if (!deferred) return;
try {
await deferred.prompt();
await deferred.userChoice;
} catch {
/* ignore */
}
setDeferred(null);
descartar();
};
if (!visible) return null;
return (
<div className="fixed inset-x-0 bottom-0 z-50 flex justify-center px-4 pb-4 pointer-events-none">
<div className="pointer-events-auto flex w-full max-w-sm items-start gap-3 rounded-2xl border border-theme-border bg-theme-surface p-3 shadow-soft">
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-theme-accent-bg text-theme-accent">
<Download size={18} />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-theme-heading">Instalar la app</p>
{deferred ? (
<p className="mt-0.5 text-xs text-theme-muted">
Accede más rápido desde tu pantalla de inicio.
</p>
) : (
<p className="mt-0.5 text-xs text-theme-muted inline-flex flex-wrap items-center gap-1">
Toca <Share size={12} className="inline" /> Compartir y luego
«Añadir a pantalla de inicio».
</p>
)}
{deferred && (
<div className="mt-2">
<Button size="sm" onClick={instalar}>Instalar</Button>
</div>
)}
</div>
<button
type="button"
aria-label="Descartar"
onClick={descartar}
className="rounded-lg p-1 text-theme-muted transition-colors hover:bg-theme-accent-bg hover:text-theme-heading"
>
<X size={16} />
</button>
</div>
</div>
);
};
export const OfflineBanner: FC = () => {
const [online, setOnline] = useState(() => (typeof navigator === 'undefined' ? true : navigator.onLine));
useEffect(() => {
const on = () => setOnline(true);
const off = () => setOnline(false);
window.addEventListener('online', on);
window.addEventListener('offline', off);
return () => {
window.removeEventListener('online', on);
window.removeEventListener('offline', off);
};
}, []);
if (online) return null;
return (
<div className="fixed inset-x-0 top-0 z-50 flex justify-center px-4 pt-3 pointer-events-none">
<div className="pointer-events-auto inline-flex items-center gap-2 rounded-full border border-theme-border bg-theme-surface px-3 py-1.5 text-xs text-theme-muted shadow-soft">
<WifiOff size={14} className="text-theme-accent" />
Sin conexión — los cambios se guardarán al recuperar la red
</div>
</div>
);
};
export default PwaInstallPrompt;

View File

@@ -83,11 +83,11 @@ export const construirIcs = (opts: {
return [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//SKEEN//Reservas en linea//ES',
'PRODID:-//Panel Clinico//Reservas en linea//ES',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
'BEGIN:VEVENT',
`UID:${esc(opts.folio)}@skeen`,
`UID:${esc(opts.folio)}@panel-clinico`,
`DTSTAMP:${fmt(new Date())}`,
`DTSTART:${fmt(inicio)}`,
`DTEND:${fmt(fin)}`,

View File

@@ -89,11 +89,14 @@ export const FALLBACK_CONFIG: TicketConfig = {
},
},
clinica: {
nombre: 'SKEEN Derma Experts',
direccion: 'Playas de Rosarito, Baja California',
telefono: '+52 (661) 100-2172',
email: 'hola@skeen.mx',
sitio: 'https://skeen.mx',
// Neutro a propósito: cada clínica configura su perfil en
// Configuración › Tickets. Si `nombre` queda vacío se usa el nombre del
// brand. Antes traía los datos de SKEEN y se filtraban a otras instancias.
nombre: '',
direccion: '',
telefono: '',
email: '',
sitio: '',
rfc: '',
sucursal: '',
tiene_logo: false,

View File

@@ -4,6 +4,18 @@ import './index.css'
import App from './App.tsx'
import { ThemeProvider } from './lib/theme.tsx'
// PWA: registra el service worker solo en producción (en dev rompería el HMR)
// y nunca deja que un fallo del SW rompa el arranque de la app.
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
try {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => { /* PWA opcional */ })
})
} catch {
/* PWA opcional */
}
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ThemeProvider>

View File

@@ -467,7 +467,7 @@ const Dashboard: FC = () => {
<div className="bg-gradient-to-r from-[#EDA588] to-[#C7BCDD] rounded-2xl p-6 sm:p-8 text-white mb-6 sm:mb-8 shadow-sm">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="font-heading text-2xl sm:text-3xl mb-2">SKEEN Derma Experts</h1>
<h1 className="font-heading text-2xl sm:text-3xl mb-2">{brand?.nombre || 'Panel Clínico'}</h1>
<p className="text-white/90 text-sm sm:text-base max-w-xl">
Ciencia, estética y cuidado personalizado en cada tratamiento. Gestiona tu clínica con confianza.
</p>