From ab9d84cb67e0d817b61b4b3a7a655f3a899e40a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Consultor=C3=ADa=20Alcaraz=20Salazar?= Date: Sun, 11 Oct 2026 13:23:56 +0000 Subject: [PATCH] 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 --- frontend/index.html | 12 +- frontend/public/icons/apple-touch-icon.png | Bin 0 -> 1855 bytes frontend/public/icons/icon-192-maskable.png | Bin 0 -> 1611 bytes frontend/public/icons/icon-192.png | Bin 0 -> 3141 bytes frontend/public/icons/icon-32.png | Bin 0 -> 662 bytes frontend/public/icons/icon-512-maskable.png | Bin 0 -> 5804 bytes frontend/public/icons/icon-512.png | Bin 0 -> 9771 bytes frontend/public/icons/icon.svg | 4 + frontend/public/manifest.webmanifest | 45 ++++ frontend/public/offline.html | 84 ++++++++ frontend/public/sw.js | 192 ++++++++++++++++++ frontend/src/App.tsx | 3 + frontend/src/components/PwaNotices.tsx | 148 ++++++++++++++ frontend/src/lib/publicBooking.ts | 4 +- frontend/src/lib/ticketConfig.ts | 13 +- frontend/src/main.tsx | 12 ++ frontend/src/pages/Dashboard.tsx | 2 +- frontend/vite.config.ts | 57 +++++- .../skeen_whatsapp/models/ticket_config.py | 15 +- 19 files changed, 574 insertions(+), 17 deletions(-) create mode 100644 frontend/public/icons/apple-touch-icon.png create mode 100644 frontend/public/icons/icon-192-maskable.png create mode 100644 frontend/public/icons/icon-192.png create mode 100644 frontend/public/icons/icon-32.png create mode 100644 frontend/public/icons/icon-512-maskable.png create mode 100644 frontend/public/icons/icon-512.png create mode 100644 frontend/public/icons/icon.svg create mode 100644 frontend/public/manifest.webmanifest create mode 100644 frontend/public/offline.html create mode 100644 frontend/public/sw.js create mode 100644 frontend/src/components/PwaNotices.tsx diff --git a/frontend/index.html b/frontend/index.html index 8a04141..73794a9 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,13 +1,21 @@ - + + + + + + + + + - SKEEN Derma Experts + Panel Clínico
diff --git a/frontend/public/icons/apple-touch-icon.png b/frontend/public/icons/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..da4b6b5384fdfd8f488b1d119587d5999ecacbb1 GIT binary patch literal 1855 zcmb`IeLNEg7{^D-yhPrj8IwA~QB}u~Q2)GdTCt8%4HtbywK0Z6m z?2oLoAyUISIc^}BuZXs|x+Q%f|7ZE&;d*5JpL6sD�oXmz*dEotPkbKfCgfAA*r8 zlcJ2Mi{Luj)}bS1H_+C*( zvnf|w`1*4x{B*NaI@D;g@2CKWkLh_4&e-3EY1V-5kdSWy8OrHvyY)vLn{-=~_;!b#2`h!$ zaX6+-c-R7#-%D7!;YVcct&{HYK!c~N8K$}9_NBOLxGXMY zht>Nt`5i!`3_V#yH~=be~9asD6?w!TQ8TH zUD%fm=?8^F%rA1gO!w#tYOf#_i|;Gc+Y$%)Y)mZ0?85Zp$)9H4>|9R#z3(h4>lyWG zHkb?4*YBDb_2~&d_;G(k{4DA~*^i|0M0iZ!pmzMW-`xC0N(xs&JS^`HL>q z_wsNzpIYf>lFu@wtZI(s+V9s)*6>w15QJ+(3FEg`s5U_i^g7c-YkKO=8}3i5BRw}E&urN^eFd&4 z8t~bq^0d7I;l-G8oIhMX;@psOGGdc6icxn@wg_IvEsZ`cm3TbciuCW&J5_Ve0NWH# zoIZnvon-tfxg_dVGr@l60}eLO4C(kEaa`r52T2+4ZPm=NH090{z)pd=87-1>e9@A0 zpf;A#bRPC9QgL)fm|J(DuIWXEAoBYbi10b-i5fBCw{96Szs+a!DY5kRd6C3~{Y^3d zEdTjj#65hG$%*Jn(K?UWfKV^5uKsz5-rb>&=)nGi7uZG-9n>V>z7`odlEejGHA^xA zKYG|nG2HoNm`Pl_KG0s^CJqhNw>VhK8sOTE7v<=iD-of%+XL;TWAg`N9UvKm5%~hG z+$-7ja+)WhVU8ZQ2bkZt?fWO)jd-7+`Ix|r)T$5RL>@-rpG5romxu(3s>)`+9Ut0k zdh>+mY&M~!WTSp2H9qXAb!_LFkEWjGPGuNK_@OYW|Ik4XNA~H}z$!RgtJZWqGU9m~ z21c3pkmwe>zub#*U`w0);!z|nCp*?8@f)17EFHg)afbBmbs6n-qGM5@_;R?jKHTL8 zsJj(j1Fwe(@3WetBS~Jq_fMO|G1_M<-tWcS@v`tR!3TwsDh$t8hVC~hj5Y6wUbSw4 z>a}sL`4rkDXN8*L8=!Bts;oUnU9gwfGELEg^e}Qg7y?QCjgH)l#A$1P{ol9yk8KG> zvf#?GQ8W5TI~mNiIkLQ|bT%DTpGn^)!C5|`yoKy|`e9I#ae6Qf1 zAmK``!=d1L(VN^^rqs=tC?9Rtgk-cMwm8PZ+ z*ahSKMJ@iR`O%aP0@g3I`fn+Qda#~~xUW5HL$S$zH4xyYq@!BAulbx_MPXp&QXiyY z63cP~|5qcRedJ{x0vm=@L8@!12r$Iv9RcbEUPD1NeMU-j%P|XNr9oE*pt-D~lC(rY zfDcJU1Zc!?yszb{cm^zRld#(Pl`0^8m|nc-gPcxewM(W1a{%#R4aJ3t;;JC~d;cYx z0b&LqW}KV+)2BM$+cP@FXVZce?yto;!}NJmUMuIsp#4}Qcv~)9g7H&M#`HMKP5YR< zO?c+Q*45HPkv(I4aywz}&39>bO_`Zald2H&MEIWzn9a@wG+dC>NOI$M+!E(3MSYm` zs~cKI-*%%?neo%8=lk_J>*z_C{YOWlZO?rOUo+r>y0qA z&-BnK@_{`5%o6%a7g8xsPkYIdy(q0ZkX`mbBmF#bp(ztCJgIuz8ZG+xaLjt#5AJUJ zQ+`cSMLbqM)!nAEn`lwmyXQnw+;*i$cX#*ov%dF~ywWukc`->yHRA}9WewX}w1z&m zBB5Y&39;Q*t}}RMJCWNx{Rt@jyS_iZqzvLOM%mi24YRfQYvD%K*o3Ka@`4!O1icrc+zEZMi zIe)a|<^d!U*J!!(e;AOT4c>J#5J;uE+liI{D*>k71sb3d7nuSV7@P$tAUu}f%ESbw F{sSSl!wvud literal 0 HcmV?d00001 diff --git a/frontend/public/icons/icon-192.png b/frontend/public/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..f534522d37050b65db55d4db874be127c5e29b51 GIT binary patch literal 3141 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 SJ^QR30K&jb|HE~si2nhq829V| literal 0 HcmV?d00001 diff --git a/frontend/public/icons/icon-32.png b/frontend/public/icons/icon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..1c2fb0f770781daf13017102b921201ef5ac2bed GIT binary patch literal 662 zcmV;H0%`q;P)?7a!4LQ5_v86J--G9Qe!njj zkH=w_uj3PUAGwBo0k8kF5RSm&VHvHZ&{VR1t)tp*%rVR*i8+A>uD%ObZ}TAmlt9O^ zZB54y9oRcB(DmfSxz6jLG#zJ79;w<-1i$~-SLX}~VNe>5WnOpRB#oBR{x=>_fmm!h zL&uU?Ys}*lKS3QBnkET-P9z$YT}o3_tMgcAOVf$NRoVKL14AR?^;>``ULZMx2{3Tf z*c$6=GdLD~TS_dy{`?#M=$EbH1%U!Q&;?HnHx?D()d8*mdPIp~rjEm_0}ZIm(ib4| zw%d|YTAHe_v2U{$qo*h)3-fccS7nLe@4x^3!Ea)S8o|I@&%K`BNHiudaHXxevV0dT zo~M28#`a4|Nr_vuyKFP0#m=qP)TD#}K@gA@l7$k7>>eC~v~Ul;OHE3g0i&glR@=%- zi&;jULzd)P$JvTKJH+tWtNv517jX)gU$QK?-89K?@FCn8jw|2`1QWxN(E#8<>T}IF z>pqNrT9K(Ut~N|g@m-If-?-m{GcplGWkd6O?-;1VCd)ca(kxUK>jtx??qEe?TUHRX zZLPgrQ&f=Oa@qkZfVMT8^5)HNckj#g%eNFXzvrC|==VW%*D!0}?>uyQykow=mvBVh wilriH-)8^-0RR6zHyx$`000I_L_t&o0FANEGVo(M?*IS*07*qoM6N<$g0Pr1ApigX literal 0 HcmV?d00001 diff --git a/frontend/public/icons/icon-512-maskable.png b/frontend/public/icons/icon-512-maskable.png new file mode 100644 index 0000000000000000000000000000000000000000..c3d9f7fee0098f1aebcd54888acf24fa4a86359d GIT binary patch literal 5804 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 literal 0 HcmV?d00001 diff --git a/frontend/public/icons/icon-512.png b/frontend/public/icons/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..8dcd5a44d886729f8c039aace1518f3399b38c95 GIT binary patch literal 9771 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 + + + diff --git a/frontend/public/manifest.webmanifest b/frontend/public/manifest.webmanifest new file mode 100644 index 0000000..9d153a2 --- /dev/null +++ b/frontend/public/manifest.webmanifest @@ -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" + } + ] +} diff --git a/frontend/public/offline.html b/frontend/public/offline.html new file mode 100644 index 0000000..c465211 --- /dev/null +++ b/frontend/public/offline.html @@ -0,0 +1,84 @@ + + + + + + + Sin conexión · Panel Clínico + + + + +
+ +

Sin conexión

+

+ 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. +

+ +
+ + diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 0000000..63281a9 --- /dev/null +++ b/frontend/public/sw.js @@ -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. +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f02c100..a0d1ed3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 = () => { + + diff --git a/frontend/src/components/PwaNotices.tsx b/frontend/src/components/PwaNotices.tsx new file mode 100644 index 0000000..bc6d088 --- /dev/null +++ b/frontend/src/components/PwaNotices.tsx @@ -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; + 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(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 ( +
+
+
+ +
+
+

Instalar la app

+ {deferred ? ( +

+ Accede más rápido desde tu pantalla de inicio. +

+ ) : ( +

+ Toca Compartir y luego + «Añadir a pantalla de inicio». +

+ )} + {deferred && ( +
+ +
+ )} +
+ +
+
+ ); +}; + +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 ( +
+
+ + Sin conexión — los cambios se guardarán al recuperar la red +
+
+ ); +}; + +export default PwaInstallPrompt; diff --git a/frontend/src/lib/publicBooking.ts b/frontend/src/lib/publicBooking.ts index bf80128..693ff32 100644 --- a/frontend/src/lib/publicBooking.ts +++ b/frontend/src/lib/publicBooking.ts @@ -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)}`, diff --git a/frontend/src/lib/ticketConfig.ts b/frontend/src/lib/ticketConfig.ts index 3d256de..6d628f9 100644 --- a/frontend/src/lib/ticketConfig.ts +++ b/frontend/src/lib/ticketConfig.ts @@ -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, diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index f48c8e7..43a2cfe 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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( diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 4faf0f5..4945cdf 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -467,7 +467,7 @@ const Dashboard: FC = () => {
-

SKEEN Derma Experts

+

{brand?.nombre || 'Panel Clínico'}

Ciencia, estética y cuidado personalizado en cada tratamiento. Gestiona tu clínica con confianza.

diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 5831268..2d2c470 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -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: { diff --git a/odoo-addons/skeen_whatsapp/models/ticket_config.py b/odoo-addons/skeen_whatsapp/models/ticket_config.py index 3c5131c..b91a875 100644 --- a/odoo-addons/skeen_whatsapp/models/ticket_config.py +++ b/odoo-addons/skeen_whatsapp/models/ticket_config.py @@ -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,