Restructure with Turborepo
Run Tests on Branches / Backend Tests (push) Successful in 3m38s
Run Tests on Branches / Frontend Tests (push) Successful in 3m28s

This commit is contained in:
Antoni Nuñez Romeu
2026-07-06 15:51:53 +02:00
parent f66cafbbc3
commit 190b3d163d
277 changed files with 53253 additions and 0 deletions
+69
View File
@@ -0,0 +1,69 @@
// Grafana Faro — browser RUM SDK initializer.
// Sends Web Vitals, JS errors, console messages, fetch/XHR, navigation,
// and React component lifecycle events to Grafana Alloy via OTLP HTTP.
//
// Required env vars (Vite injects VITE_* at build time):
// VITE_FARO_ENDPOINT — e.g. http://localhost:4318
// VITE_FARO_APP_NAME — e.g. farmaclic-frontend
// VITE_FARO_ENV — e.g. production | staging | development
// VITE_FARO_APP_VERSION — optional, defaults to 1.0.0
import {
getWebInstrumentations,
ReactIntegration,
initializeFaro,
} from '@grafana/faro-react';
import { TracingInstrumentation } from '@grafana/faro-web-tracing';
import { OtlpHttpTransport } from '@grafana/faro-transport-otlp-http';
let initialized = false;
export function initFaro() {
if (initialized) return;
initialized = true;
const endpoint = import.meta.env.VITE_FARO_ENDPOINT;
if (!endpoint) {
// Faro is optional. If the endpoint is not configured (e.g. local dev
// without an Alloy collector), do nothing so the app still runs.
return;
}
const appName = import.meta.env.VITE_FARO_APP_NAME || 'farmaclic-frontend';
const environment = import.meta.env.VITE_FARO_ENV || 'production';
const version = import.meta.env.VITE_FARO_APP_VERSION || '1.0.0';
try {
initializeFaro({
url: `${endpoint.replace(/\/$/, '')}/v1/traces`,
app: {
name: appName,
environment,
version,
},
instrumentations: [
...getWebInstrumentations({
captureConsole: true,
captureConsoleDisabledLevels: ['debug'],
captureException: true,
captureMessage: true,
captureResource: true,
captureUserInteraction: true,
captureXHRInstrumentation: true,
captureFetchInstrumentation: true,
captureWebVitals: true,
captureNavigation: true,
}),
new ReactIntegration(),
new TracingInstrumentation(),
],
transport: new OtlpHttpTransport({
url: `${endpoint.replace(/\/$/, '')}/v1/traces`,
}),
});
} catch (err) {
// Never let Faro init failure break the app.
// eslint-disable-next-line no-console
console.warn('[faro] init failed', err);
}
}
+38
View File
@@ -0,0 +1,38 @@
export function haversineKm(lat1, lon1, lat2, lon2) {
const R = 6371;
const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLon = ((lon2 - lon1) * Math.PI) / 180;
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos((lat1 * Math.PI) / 180) *
Math.cos((lat2 * Math.PI) / 180) *
Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
export function getUserPosition() {
return new Promise((resolve, reject) => {
if (!navigator.geolocation) {
reject(new Error('Geolocalización no compatible con este navegador'));
return;
}
if (typeof window !== 'undefined' && window.isSecureContext === false) {
reject(new Error('La geolocalización requiere HTTPS (o localhost)'));
return;
}
navigator.geolocation.getCurrentPosition(
pos => resolve({ lat: pos.coords.latitude, lon: pos.coords.longitude }),
err => {
console.error('[geo] getCurrentPosition failed — code:', err && err.code, 'message:', err && err.message);
reject(err);
},
{ timeout: 15000, maximumAge: 60000, enableHighAccuracy: false }
);
});
}
export function formatDistance(km) {
if (km < 1) return `${Math.round(km * 1000)} m`;
if (km < 10) return `${km.toFixed(1)} km`;
return `${Math.round(km)} km`;
}
+73
View File
@@ -0,0 +1,73 @@
const DAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
const DAY_LABELS = {
sun: 'Domingo',
mon: 'Lunes',
tue: 'Martes',
wed: 'Miércoles',
thu: 'Jueves',
fri: 'Viernes',
sat: 'Sábado',
};
export const DAY_KEYS = DAYS;
export const DAY_LABEL = DAY_LABELS;
function parseHM(str) {
const [h, m] = str.split(':').map(Number);
if (!Number.isFinite(h) || !Number.isFinite(m)) return null;
return h * 60 + m;
}
function parseHours(raw) {
if (!raw) return null;
try {
return typeof raw === 'string' ? JSON.parse(raw) : raw;
} catch {
return null;
}
}
function findNextOpen(hours, now) {
for (let offset = 1; offset <= 7; offset++) {
const day = DAYS[(now.getDay() + offset) % 7];
const range = hours[day];
if (Array.isArray(range) && range.length === 2) {
const openStr = range[0];
if (offset === 1) return `mañana a las ${openStr}`;
return `${DAY_LABELS[day]} a las ${openStr}`;
}
}
return null;
}
export function getOpenStatus(rawHours, now = new Date()) {
const hours = parseHours(rawHours);
if (!hours) return null;
const day = DAYS[now.getDay()];
const range = hours[day];
if (!Array.isArray(range) || range.length !== 2) {
const next = findNextOpen(hours, now);
return { status: 'closed', label: next ? `Cerrado · Abre ${next}` : 'Cerrado' };
}
const openMins = parseHM(range[0]);
const closeMins = parseHM(range[1]);
if (openMins == null || closeMins == null) return null;
const nowMins = now.getHours() * 60 + now.getMinutes();
if (nowMins < openMins) {
return { status: 'closed', label: `Cerrado · Abre a las ${range[0]}` };
}
if (nowMins >= closeMins) {
const next = findNextOpen(hours, now);
return { status: 'closed', label: next ? `Cerrado · Abre ${next}` : 'Cerrado' };
}
return { status: 'open', label: `Abierto · Cierra a las ${range[1]}` };
}
export function emptyHours() {
return { sun: null, mon: null, tue: null, wed: null, thu: null, fri: null, sat: null };
}
+38
View File
@@ -0,0 +1,38 @@
// Capacitor bootstrap — no-ops on the web, configures native shell on mobile.
// Plugins are dynamic-imported so the web bundle isn't penalized.
let initStarted = false;
export function isNativeApp() {
return typeof window !== 'undefined' && Boolean(window.Capacitor?.isNativePlatform?.());
}
export async function initNativeShell() {
if (initStarted || !isNativeApp()) return;
initStarted = true;
try {
const { StatusBar, Style } = await import('@capacitor/status-bar');
await StatusBar.setBackgroundColor({ color: '#27633a' }).catch(() => {});
await StatusBar.setStyle({ style: Style.Light }).catch(() => {});
} catch {
/* plugin not present — fine on web */
}
try {
const { SplashScreen } = await import('@capacitor/splash-screen');
await SplashScreen.hide({ fadeOutDuration: 250 }).catch(() => {});
} catch {
/* plugin not present — fine on web */
}
try {
const { App } = await import('@capacitor/app');
App.addListener('backButton', ({ canGoBack }) => {
if (canGoBack) window.history.back();
else App.exitApp();
});
} catch {
/* plugin not present — fine on web */
}
}
+130
View File
@@ -0,0 +1,130 @@
const STORAGE_KEY = 'farmaclic.subscriptions.v1';
export function getStoredSubscriptions() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function storageKey(nregistro, pharmacyId = null) {
return pharmacyId ? `${nregistro}:${pharmacyId}` : nregistro;
}
export function isSubscribedLocally(nregistro, pharmacyId = null) {
return getStoredSubscriptions().includes(storageKey(nregistro, pharmacyId));
}
function setStoredSubscriptions(list) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...new Set(list)]));
} catch {}
}
export function pushSupported() {
return (
typeof window !== 'undefined' &&
'serviceWorker' in navigator &&
'PushManager' in window &&
'Notification' in window
);
}
export async function getVapidPublicKey() {
const res = await fetch('/api/notifications/vapid-public-key', { credentials: 'include' });
if (!res.ok) {
const message = res.status === 503
? 'Las notificaciones push no están configuradas en el servidor'
: `No se pudo obtener la clave VAPID (HTTP ${res.status})`;
throw new Error(message);
}
const { publicKey } = await res.json();
if (!publicKey) throw new Error('El servidor devolvió una clave VAPID vacía');
return publicKey;
}
export function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(base64);
const arr = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
return arr;
}
async function getOrCreateSubscription() {
if (!pushSupported()) {
throw new Error('Las notificaciones push no son compatibles con este navegador');
}
if (window.isSecureContext === false) {
throw new Error('Las notificaciones push requieren HTTPS (o localhost)');
}
const reg = await navigator.serviceWorker.ready;
let sub = await reg.pushManager.getSubscription();
if (sub) return sub;
if (Notification.permission === 'denied') {
throw new Error('Permiso de notificaciones denegado — actívalo en la configuración del navegador');
}
if (Notification.permission === 'default') {
const result = await Notification.requestPermission();
if (result !== 'granted') {
throw new Error('Notification permission was not granted');
}
}
const publicKey = await getVapidPublicKey();
sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
});
return sub;
}
export async function subscribeToPush(medicineNregistro, medicineName, pharmacyId = null) {
const subscription = await getOrCreateSubscription();
const res = await fetch('/api/notifications/subscribe', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
medicine_nregistro: medicineNregistro,
medicine_name: medicineName || null,
subscription,
pharmacy_id: pharmacyId || null,
}),
});
if (!res.ok) {
const detail = await res.text().catch(() => '');
throw new Error(`Suscripción fallida (HTTP ${res.status}) ${detail}`);
}
setStoredSubscriptions([...getStoredSubscriptions(), storageKey(medicineNregistro, pharmacyId)]);
}
export async function unsubscribeFromPush(medicineNregistro, pharmacyId = null) {
let endpoint = null;
if (pushSupported()) {
try {
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
endpoint = sub?.endpoint || null;
} catch {}
}
if (endpoint) {
await fetch('/api/notifications/unsubscribe', {
method: 'DELETE',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
medicine_nregistro: medicineNregistro,
endpoint,
pharmacy_id: pharmacyId || null,
}),
}).catch(() => {});
}
const key = storageKey(medicineNregistro, pharmacyId);
setStoredSubscriptions(getStoredSubscriptions().filter(n => n !== key));
}
@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { subscribeToPush } from './notifications.js';
describe('subscribeToPush', () => {
beforeEach(() => {
vi.restoreAllMocks();
localStorage.clear();
});
it('sends the subscription request with credentials so the session is preserved', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce({ ok: true, json: async () => ({ publicKey: 'test-public-key' }) })
.mockResolvedValueOnce({ ok: true, text: async () => '' });
vi.stubGlobal('fetch', fetchMock);
Object.defineProperty(window, 'isSecureContext', {
configurable: true,
value: true,
});
Object.defineProperty(window, 'PushManager', {
configurable: true,
value: class PushManager {},
});
Object.defineProperty(window, 'Notification', {
configurable: true,
value: { permission: 'granted' },
});
Object.defineProperty(navigator, 'serviceWorker', {
configurable: true,
value: {
ready: Promise.resolve({
pushManager: {
getSubscription: vi.fn().mockResolvedValue(null),
subscribe: vi.fn().mockResolvedValue({ endpoint: 'https://example.test/push' }),
},
}),
},
});
await subscribeToPush('123456', 'Paracetamol');
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[1][1]).toMatchObject({
method: 'POST',
credentials: 'include',
});
});
});