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

@@ -1,13 +1,21 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="es">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <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.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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"> <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" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SKEEN Derma Experts</title> <title>Panel Clínico</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 662 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

View 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

View 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"
}
]
}

View 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
View 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.
});

View File

@@ -3,6 +3,7 @@ import { BrowserRouter as Router, Routes, Route, Navigate, useSearchParams } fro
import { MENU_ITEMS } from './lib/menu-items'; import { MENU_ITEMS } from './lib/menu-items';
import { ToastProvider, Button } from './components/ui'; import { ToastProvider, Button } from './components/ui';
import ErrorBoundary from './components/ErrorBoundary'; import ErrorBoundary from './components/ErrorBoundary';
import { PwaInstallPrompt, OfflineBanner } from './components/PwaNotices';
import { AuthProvider, useAuth } from './lib/auth'; import { AuthProvider, useAuth } from './lib/auth';
import { GlosarioProvider } from './lib/glosario'; import { GlosarioProvider } from './lib/glosario';
import { SucursalesProvider } from './lib/sucursales'; import { SucursalesProvider } from './lib/sucursales';
@@ -167,6 +168,8 @@ const App: FC = () => {
<QuickLinksProvider> <QuickLinksProvider>
<Router> <Router>
<ToastProvider /> <ToastProvider />
<PwaInstallPrompt />
<OfflineBanner />
<PatientDrawerProvider> <PatientDrawerProvider>
<ChatNotifications /> <ChatNotifications />
<Routes> <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 [ return [
'BEGIN:VCALENDAR', 'BEGIN:VCALENDAR',
'VERSION:2.0', 'VERSION:2.0',
'PRODID:-//SKEEN//Reservas en linea//ES', 'PRODID:-//Panel Clinico//Reservas en linea//ES',
'CALSCALE:GREGORIAN', 'CALSCALE:GREGORIAN',
'METHOD:PUBLISH', 'METHOD:PUBLISH',
'BEGIN:VEVENT', 'BEGIN:VEVENT',
`UID:${esc(opts.folio)}@skeen`, `UID:${esc(opts.folio)}@panel-clinico`,
`DTSTAMP:${fmt(new Date())}`, `DTSTAMP:${fmt(new Date())}`,
`DTSTART:${fmt(inicio)}`, `DTSTART:${fmt(inicio)}`,
`DTEND:${fmt(fin)}`, `DTEND:${fmt(fin)}`,

View File

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

View File

@@ -4,6 +4,18 @@ import './index.css'
import App from './App.tsx' import App from './App.tsx'
import { ThemeProvider } from './lib/theme.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( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<ThemeProvider> <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="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 className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div> <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"> <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. Ciencia, estética y cuidado personalizado en cada tratamiento. Gestiona tu clínica con confianza.
</p> </p>

View File

@@ -1,10 +1,63 @@
import { defineConfig } from 'vite' import { defineConfig, type Plugin, type ResolvedConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite' 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/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss(), pwaServiceWorker()],
server: { server: {
port: 5173, port: 5173,
proxy: { proxy: {

View File

@@ -61,12 +61,17 @@ DEFAULT_TICKET_LAYOUT = {
# cada equipo imprimía una dirección o un teléfono distinto. # cada equipo imprimía una dirección o un teléfono distinto.
TICKET_CLINICA_KEY = 'skeen.ticket_clinica' 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 = { DEFAULT_CLINICA = {
'nombre': 'SKEEN Derma Experts', 'nombre': '',
'direccion': 'Playas de Rosarito, Baja California', 'direccion': '',
'telefono': '+52 (661) 100-2172', 'telefono': '',
'email': 'hola@skeen.mx', 'email': '',
'sitio': 'https://skeen.mx', 'sitio': '',
'rfc': '', 'rfc': '',
'sucursal': '', 'sucursal': '',
'tiene_logo': False, 'tiene_logo': False,