Restructure with Turborepo
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user