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:
@@ -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>
|
||||
|
||||
148
frontend/src/components/PwaNotices.tsx
Normal file
148
frontend/src/components/PwaNotices.tsx
Normal 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;
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user