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
@@ -1,13 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<meta name="theme-color" content="#1f3a4d" />
|
||||
<meta name="description" content="Gestión clínica: agenda, pacientes, ventas, monedero y reportes." />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="Clínico" />
|
||||
<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=Alike&family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=JetBrains+Mono:wght@400;500;600&family=Source+Sans+Pro:wght@400;600;700&display=swap" rel="stylesheet">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SKEEN Derma Experts</title>
|
||||
<title>Panel Clínico</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
BIN
frontend/public/icons/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/icons/icon-192-maskable.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/public/icons/icon-192.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
frontend/public/icons/icon-32.png
Normal file
|
After Width: | Height: | Size: 662 B |
BIN
frontend/public/icons/icon-512-maskable.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
frontend/public/icons/icon-512.png
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
4
frontend/public/icons/icon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="512" height="512">
|
||||
<rect x="0" y="0" width="48" height="48" rx="10" fill="#1f3a4d"/>
|
||||
<path d="M24 13v22M13 24h22" stroke="#ffffff" stroke-width="8" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 251 B |
45
frontend/public/manifest.webmanifest
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "Panel Clínico",
|
||||
"short_name": "Clínico",
|
||||
"description": "Gestión clínica: agenda, pacientes, ventas, monedero y reportes.",
|
||||
"lang": "es",
|
||||
"dir": "ltr",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#F8F6F4",
|
||||
"theme_color": "#1f3a4d",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-192-maskable.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512-maskable.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
}
|
||||
]
|
||||
}
|
||||
84
frontend/public/offline.html
Normal file
@@ -0,0 +1,84 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#1f3a4d" />
|
||||
<title>Sin conexión · Panel Clínico</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/icons/icon.svg" />
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f8f6f4;
|
||||
--surface: #ffffff;
|
||||
--border: #e5e0dc;
|
||||
--text: #2d2d2d;
|
||||
--muted: #6b6b6b;
|
||||
--heading: #1a1a1a;
|
||||
--accent: #1f3a4d;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
text-align: center;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
padding: 32px 28px;
|
||||
}
|
||||
.mark {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin: 0 auto 18px;
|
||||
display: block;
|
||||
border-radius: 14px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 20px;
|
||||
line-height: 1.25;
|
||||
color: var(--heading);
|
||||
}
|
||||
p {
|
||||
margin: 0 0 20px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--muted);
|
||||
}
|
||||
button {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
padding: 11px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
button:active { opacity: 0.9; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="card">
|
||||
<img class="mark" src="/icons/icon.svg" alt="" width="56" height="56" />
|
||||
<h1>Sin conexión</h1>
|
||||
<p>
|
||||
No hay conexión a internet en este momento. Revisa tu red e intenta de nuevo;
|
||||
la aplicación seguirá trabajando en cuanto vuelvas a estar en línea.
|
||||
</p>
|
||||
<button type="button" onclick="location.reload()">Reintentar</button>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
192
frontend/public/sw.js
Normal file
@@ -0,0 +1,192 @@
|
||||
/* =====================================================================
|
||||
Service Worker del Panel Clínico (SKEEN)
|
||||
---------------------------------------------------------------------
|
||||
Sin dependencias ni Workbox. Estrategia:
|
||||
|
||||
- App shell (HTML, manifest, iconos, JS/CSS con hash): precache en
|
||||
install, versionado por build.
|
||||
- Navegación (HTML): network-first. Un deploy nuevo se ve al primer
|
||||
refresh (nginx sirve index.html con Cache-Control: no-cache) y, si
|
||||
no hay red, se devuelve la última copia del shell o /offline.html.
|
||||
- Assets con hash (/assets/*): cache-first (son inmutables).
|
||||
- Otras GET del mismo origen (logos de marca, svg): stale-while-revalidate.
|
||||
- API (/api/*), peticiones con Authorization y cualquier método distinto
|
||||
de GET: NUNCA se cachean. Se dejan pasar tal cual al navegador.
|
||||
|
||||
La versión de caché y la lista de assets se inyectan en el build
|
||||
(vite.config.ts); si no se inyectan, el SW sigue siendo funcional.
|
||||
===================================================================== */
|
||||
|
||||
var VERSION = '__BUILD_VERSION__';
|
||||
|
||||
// Lista de assets con hash generados por el build (JSON inyectado).
|
||||
var PRECACHE_ASSETS = [];
|
||||
try { PRECACHE_ASSETS = JSON.parse('__PRECACHE_ASSETS__'); } catch (e) { PRECACHE_ASSETS = []; }
|
||||
|
||||
var PRECACHE = 'skeen-precache-' + VERSION;
|
||||
var RUNTIME = 'skeen-runtime-' + VERSION;
|
||||
var CACHE_PREFIX = 'skeen-';
|
||||
|
||||
var PRECACHE_URLS = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/offline.html',
|
||||
'/manifest.webmanifest',
|
||||
'/favicon.svg',
|
||||
'/icons/icon.svg',
|
||||
'/icons/icon-32.png',
|
||||
'/icons/icon-192.png',
|
||||
'/icons/icon-512.png',
|
||||
'/icons/icon-192-maskable.png',
|
||||
'/icons/icon-512-maskable.png',
|
||||
'/icons/apple-touch-icon.png',
|
||||
// Brand neutro por defecto: permite pintar el login sin red.
|
||||
'/brand/favicon.svg',
|
||||
'/brand/logo-blanco.svg',
|
||||
'/brand/logo-negro.svg',
|
||||
].concat(PRECACHE_ASSETS);
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// install / activate
|
||||
// --------------------------------------------------------------------
|
||||
self.addEventListener('install', function (event) {
|
||||
event.waitUntil(
|
||||
caches.open(PRECACHE).then(function (cache) {
|
||||
// add() individual: un recurso que falle no aborta toda la instalación.
|
||||
return Promise.all(
|
||||
PRECACHE_URLS.map(function (url) {
|
||||
return cache.add(new Request(url, { cache: 'reload' })).catch(function () { /* ignore */ });
|
||||
})
|
||||
);
|
||||
}).then(function () { return self.skipWaiting(); })
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', function (event) {
|
||||
event.waitUntil(
|
||||
caches.keys().then(function (keys) {
|
||||
return Promise.all(
|
||||
keys.map(function (key) {
|
||||
// Borra cachés de builds anteriores (mismo prefijo, otra versión).
|
||||
if (key.indexOf(CACHE_PREFIX) === 0 && key !== PRECACHE && key !== RUNTIME) {
|
||||
return caches.delete(key);
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
);
|
||||
}).then(function () { return self.clients.claim(); })
|
||||
);
|
||||
});
|
||||
|
||||
// Permite forzar la activación desde la página si se desea.
|
||||
self.addEventListener('message', function (event) {
|
||||
if (event.data && event.data.type === 'SKIP_WAITING') self.skipWaiting();
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// Estrategias
|
||||
// --------------------------------------------------------------------
|
||||
function isSameOrigin(url) {
|
||||
return url.origin === self.location.origin;
|
||||
}
|
||||
|
||||
function isApiRequest(url) {
|
||||
return url.pathname.indexOf('/api/') === 0;
|
||||
}
|
||||
|
||||
function isHashedAsset(url) {
|
||||
return isSameOrigin(url) && url.pathname.indexOf('/assets/') === 0;
|
||||
}
|
||||
|
||||
async function networkFirstNavigation(request) {
|
||||
try {
|
||||
var fresh = await fetch(request);
|
||||
var type = fresh.headers.get('content-type') || '';
|
||||
if (fresh && fresh.ok && type.indexOf('text/html') !== -1) {
|
||||
var cache = await caches.open(PRECACHE);
|
||||
// Se guarda una única copia del shell bajo /index.html.
|
||||
cache.put('/index.html', fresh.clone()).catch(function () { /* ignore */ });
|
||||
}
|
||||
return fresh;
|
||||
} catch (e) {
|
||||
var shell = await caches.open(PRECACHE);
|
||||
var cached = (await shell.match('/index.html')) || (await shell.match('/'));
|
||||
if (cached) return cached;
|
||||
var offline = await shell.match('/offline.html');
|
||||
if (offline) return offline;
|
||||
return new Response('Sin conexión', {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function cacheFirst(request, cacheName) {
|
||||
var cache = await caches.open(cacheName);
|
||||
var hit = await cache.match(request);
|
||||
if (hit) return hit;
|
||||
var res = await fetch(request);
|
||||
if (res && res.ok && res.type === 'basic') {
|
||||
cache.put(request, res.clone()).catch(function () { /* ignore */ });
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
async function staleWhileRevalidate(request, cacheName) {
|
||||
var cache = await caches.open(cacheName);
|
||||
var hit = await cache.match(request);
|
||||
var network = fetch(request).then(function (res) {
|
||||
if (res && res.ok && res.type === 'basic') {
|
||||
cache.put(request, res.clone()).catch(function () { /* ignore */ });
|
||||
}
|
||||
return res;
|
||||
}).catch(function () { return undefined; });
|
||||
if (hit) return hit;
|
||||
var res = await network;
|
||||
if (res) return res;
|
||||
return new Response('', { status: 504, statusText: 'Sin conexión' });
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// fetch
|
||||
// --------------------------------------------------------------------
|
||||
self.addEventListener('fetch', function (event) {
|
||||
var request = event.request;
|
||||
var url;
|
||||
|
||||
try {
|
||||
url = new URL(request.url);
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 1) Nunca interceptar lo que no debe cachearse:
|
||||
// - métodos distintos de GET
|
||||
// - peticiones con Authorization (JWT / datos clínicos)
|
||||
// - API del backend (/api/odoo/... y /api/v1/...)
|
||||
// - backend Odoo directo (/web/)
|
||||
if (request.method !== 'GET') return;
|
||||
if (request.headers.get('authorization')) return;
|
||||
if (isApiRequest(url)) return;
|
||||
if (url.pathname.indexOf('/web/') === 0) return;
|
||||
|
||||
// 2) Navegación HTML: network-first con fallback al shell cacheado.
|
||||
if (request.mode === 'navigate') {
|
||||
event.respondWith(networkFirstNavigation(request));
|
||||
return;
|
||||
}
|
||||
|
||||
// 3) Assets con hash: cache-first (inmutables).
|
||||
if (isHashedAsset(url)) {
|
||||
event.respondWith(cacheFirst(request, RUNTIME));
|
||||
return;
|
||||
}
|
||||
|
||||
// 4) Otras GET del mismo origen (logos, svg): SWR.
|
||||
if (isSameOrigin(url)) {
|
||||
event.respondWith(staleWhileRevalidate(request, RUNTIME));
|
||||
return;
|
||||
}
|
||||
|
||||
// 5) Cross-origin (p. ej. Google Fonts): sin interceptar.
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -1,10 +1,63 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig, type Plugin, type ResolvedConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
// PWA: inyecta en dist/sw.js (1) una versión de caché única por build y
|
||||
// (2) la lista de assets con hash a precachear. La versión es un hash del
|
||||
// index.html, el manifest y los nombres de los assets, así que cambia
|
||||
// exactamente cuando cambia el bundle (y el SW descarta las cachés viejas
|
||||
// en `activate`). No toca el pipeline `tsc -b && vite build`.
|
||||
function pwaServiceWorker(): Plugin {
|
||||
let outDir = 'dist'
|
||||
return {
|
||||
name: 'skeen-pwa-service-worker',
|
||||
apply: 'build',
|
||||
configResolved(config: ResolvedConfig) {
|
||||
outDir = config.build.outDir
|
||||
},
|
||||
closeBundle() {
|
||||
const dir = resolve(process.cwd(), outDir)
|
||||
const swPath = resolve(dir, 'sw.js')
|
||||
if (!existsSync(swPath)) return
|
||||
|
||||
const listAssets = (sub: string): string[] => {
|
||||
const abs = resolve(dir, sub)
|
||||
if (!existsSync(abs)) return []
|
||||
return readdirSync(abs)
|
||||
.filter((f) => /\.(js|css|woff2?|png|jpe?g|svg|webp|ico|json)$/i.test(f))
|
||||
.map((f) => `/${sub}/${f}`)
|
||||
.sort()
|
||||
}
|
||||
// Solo /assets (hash): los iconos ya van listados explícitamente en sw.js.
|
||||
const assets = listAssets('assets')
|
||||
|
||||
const read = (file: string): Buffer =>
|
||||
existsSync(resolve(dir, file)) ? readFileSync(resolve(dir, file)) : Buffer.from('')
|
||||
const version = createHash('sha256')
|
||||
.update(read('index.html'))
|
||||
.update(read('manifest.webmanifest'))
|
||||
.update(assets.join('|'))
|
||||
.digest('hex')
|
||||
.slice(0, 12)
|
||||
|
||||
let sw = readFileSync(swPath, 'utf8')
|
||||
sw = sw.replace('__BUILD_VERSION__', version)
|
||||
// JSON dentro de un literal de una sola comilla: se quitan comillas
|
||||
// simples por seguridad (los nombres de Vite no las contienen).
|
||||
sw = sw.replace('__PRECACHE_ASSETS__', JSON.stringify(assets).replace(/'/g, ''))
|
||||
writeFileSync(swPath, sw)
|
||||
|
||||
this.warn(`PWA service worker: versión de caché ${version}, ${assets.length} assets precacheados`)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
plugins: [react(), tailwindcss(), pwaServiceWorker()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
|
||||
@@ -61,12 +61,17 @@ DEFAULT_TICKET_LAYOUT = {
|
||||
# cada equipo imprimía una dirección o un teléfono distinto.
|
||||
TICKET_CLINICA_KEY = 'skeen.ticket_clinica'
|
||||
|
||||
# Defaults NEUTROS a propósito: cada instancia configura su propio perfil en
|
||||
# Configuración › Tickets. Si el nombre queda vacío, el frontend usa el nombre
|
||||
# del brand de la clínica. Antes estos defaults traían los datos de SKEEN, que
|
||||
# se filtraban a cualquier instancia nueva (dirección, teléfono y correo ajenos
|
||||
# impresos en el ticket).
|
||||
DEFAULT_CLINICA = {
|
||||
'nombre': 'SKEEN Derma Experts',
|
||||
'direccion': 'Playas de Rosarito, Baja California',
|
||||
'telefono': '+52 (661) 100-2172',
|
||||
'email': 'hola@skeen.mx',
|
||||
'sitio': 'https://skeen.mx',
|
||||
'nombre': '',
|
||||
'direccion': '',
|
||||
'telefono': '',
|
||||
'email': '',
|
||||
'sitio': '',
|
||||
'rfc': '',
|
||||
'sucursal': '',
|
||||
'tiene_logo': False,
|
||||
|
||||