diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a0d1ed3..103017d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import { ToastProvider, Button } from './components/ui'; import ErrorBoundary from './components/ErrorBoundary'; import { PwaInstallPrompt, OfflineBanner } from './components/PwaNotices'; import { AuthProvider, useAuth } from './lib/auth'; +import { useI18n } from './lib/i18n'; import { GlosarioProvider } from './lib/glosario'; import { SucursalesProvider } from './lib/sucursales'; import { ServiceCategoriesProvider } from './lib/service-categories'; @@ -52,17 +53,18 @@ const FullScreenLoader: FC = () => ( // Pantalla cuando el servidor no responde al arrancar (red lenta o caída): // en lugar de quedarse cargando para siempre, ofrece reintentar. -const BootErrorScreen: FC<{ onRetry: () => void }> = ({ onRetry }) => ( -
- El servidor no responde en este momento. Revisa tu conexión a internet e intenta de nuevo. -
{t('app.bootBody')}
Sin acceso a módulos
Tu usuario no tiene secciones habilitadas. Contacta al administrador.
{t('app.noAccessTitle')}
{t('app.noAccessBody')}
Accesos rápidos
{t('nav.quickLinks')}
{user?.name || 'Usuario'}
{user ? ROLE_LABELS[user.role] : ''}
{user ? (ROLE_LABELS[user.role] ? t(`role.${user.role}`) : (user.role_name || user.role)) : ''}
Sin resultados para "{query.trim()}". Prueba con otro nombre o teléfono.
{t('search.noResults', { q: query.trim() })}
{opt.group}
{t(`search.group.${opt.group}`)}
- Usa ↑ ↓ para moverte y Enter para abrir + {t('search.hintKeys')}
Define el objetivo de ventas (MXN) para el mes en curso.
{t('user.goalBody')}
- Notificaciones + {t('notif.title')}
Cargando...
{t('notif.loading')}
Sin notificaciones
{t('notif.empty')}
Instalar la app
{t('pwa.installTitle')}
- Accede más rápido desde tu pantalla de inicio. + {t('pwa.installHint')}
- Toca Compartir y luego - «Añadir a pantalla de inicio». + {t('pwa.iosStepPrefix')} {t('pwa.iosStepSuffix')}
- Mostrar accesos rápidos + {t('dock.showModes')}
Define el objetivo de ventas (MXN) para el mes en curso. La barra del header muestra el avance en tiempo real.
- Si el teléfono ya existe, se reutiliza ese paciente; si no, se crea con datos mínimos. + {t('agenda.quickModeHint')}
No tienes permiso para ninguna sección de Configuración.
{t('config.noPermission')}
{t('config.idiomaBody')}
Nombre, pie de página y logos que aparecen en el inicio de sesión, el menú y los documentos impresos. Dejar un campo vacío vuelve al valor por omisión. @@ -760,7 +775,7 @@ const Configuracion: FC = () => { placeholder="Vacío = color del tema" /> {brandForm[key] && ( - setBrandForm((f) => ({ ...f, [key]: '' }))} title="Usar el color del tema"> + setBrandForm((f) => ({ ...f, [key]: '' }))} title={t('config.useThemeColor')}> Limpiar )} @@ -797,7 +812,7 @@ const Configuracion: FC = () => { - Tipo de cambio USD / MXN + {t('config.exchangeRate')} Se usa para convertir ventas en dólares. Vigente: @@ -821,7 +836,7 @@ const Configuracion: FC = () => {
Se usa para convertir ventas en dólares. Vigente: @@ -821,7 +836,7 @@ const Configuracion: FC = () => {
Porcentaje de comisión sobre las ventas atribuidas a cada médico (visible en su página "Mis ventas"). @@ -864,7 +879,7 @@ const Configuracion: FC = () => { - {commissionDefaultSaving ? 'Guardando...' : 'Guardar'} + {commissionDefaultSaving ? t('common.saving') : t('common.save')}
- Ciencia, estética y cuidado personalizado en cada tratamiento. Gestiona tu clínica con calidez y confianza. + {t('dashboard.heroTextNest')}
- Ciencia, estética y cuidado personalizado en cada tratamiento. Gestiona tu clínica con confianza. + {t('dashboard.heroText')}
{todayLabel}
Sin citas activas para hoy.
{t('dashboard.noUpcoming')}
{todayInfo ? (todayInfo.waiting ?? '—') : '—'}
{todayInfo?.waiting != null - ? todayInfo.waiting === 1 - ? 'paciente en espera' - : 'pacientes en espera' - : 'no disponible'} + ? (todayInfo.waiting === 1 ? t('dashboard.waiting_one') : t('dashboard.waiting_other')) + : t('dashboard.notAvailable')}
{cajaStatus.label}
@@ -548,15 +547,15 @@ const Dashboard: FC = () => { } onClick={() => navigate('/ventas')} - ariaLabel="Ver cobros del día en ventas" + ariaLabel={t('dashboard.ariaCharges')} >
{todayInfo ? (todayInfo.cobros != null ? formatCurrency(todayInfo.cobros) : '—') : '—'}
pagos completados hoy
{t('dashboard.completedPayments')}
Pacientes que cumplen años hoy — contáctalos
{t('dashboard.birthdaysSub')}
Ningún paciente cumple años hoy.
{t('dashboard.noBirthdays')}
Citas e ingresos de los últimos 7 días
{t('dashboard.chartWeeklySub')}
Citas programadas para hoy
{t('dashboard.upcomingTodaySub')}
{apt.patient}
{apt.service} • {apt.time}
Por ingresos generados
{t('dashboard.topServicesSub')}
{aptReport ? `${aptReport.total} citas · tasa no-show ${noShowRate}%` : 'Distribución por estado'}
Catálogo activo de tratamientos
{t('dashboard.featuredSub')}
Últimos movimientos en la clínica
{t('dashboard.recentSub')}
- El enlace no es válido o ya expiró. Pide uno nuevo a la clínica. + {t('public.form.unavailableBody')}
- {enviado ? 'Tu formulario quedó registrado.' : 'Este formulario ya había sido llenado.'} + {enviado ? t('public.form.registered') : t('public.form.alreadyFilled')}
Ya puedes cerrar esta página.
{t('public.form.canClose')}
{brand.nombre}
{config.plantilla.tipo_label || 'Formulario'}
{config.plantilla.tipo_label || t('public.form.form')}
Tarda unos {config.plantilla.duracion_estimada_min} minutos.
{t('public.form.takesMinutes', { n: config.plantilla.duracion_estimada_min })}
- Al enviar aceptas que tus datos y tu firma se guarden en el{' '} - {term('record_singular', 'Expediente').toLowerCase()} de la clínica. + {t('public.form.legalRecord', { record: term('record_singular', 'Expediente').toLowerCase() })}
Inicia sesión para continuar
{t('login.subtitle')}
{error}
- F2 buscar · F9 cobrar · +/− cantidad · Esc cerrar + {t('pos.shortcutsHint')} setShortcutsOpen(true)} className="underline hover:text-theme-heading" > - Ver atajos + {t('pos.viewShortcuts')}
- Cambio: + {t('pos.change')} 0 ? 'text-theme-heading' : 'text-theme-muted'}`}> {fmtMoney(cambio)} {moneda === 'USD' && cambio > 0 && ( @@ -1558,7 +1559,7 @@ const Pos: FC = () => { tax: resultado.sale.tax_total ?? resultado.impuesto ?? 0, tax_name: impuestos.nombre, total: resultado.sale.total, - metodo: resultado.payment ? metodoLabel(resultado.payment.payment_method) : 'Puntos', + metodo: resultado.payment ? metodoLabelL(resultado.payment.payment_method) : t('pos.points'), puntos_usados: resultado.puntos_usados, puntos_ganados: resultado.puntos_ganados, wallet_points: resultado.wallet_points, diff --git a/frontend/src/pages/Reportes.tsx b/frontend/src/pages/Reportes.tsx index 73b7ef4..b3985eb 100644 --- a/frontend/src/pages/Reportes.tsx +++ b/frontend/src/pages/Reportes.tsx @@ -50,6 +50,7 @@ import { useReportFavorites, } from '../lib/reportRange'; import { useClinicSettings } from '../lib/clinic-settings'; +import { useI18n } from '../lib/i18n'; const COLORS = ['#57534e', '#a8a29e', '#d6d3d1', '#78716c', '#f59e0b', '#10b981']; @@ -155,6 +156,7 @@ const ReportesContent: FC = () => { const [searchParams, setSearchParams] = useSearchParams(); const { hasRole, canSee, can, brand } = useAuth(); const { membresias: membresiasCfg, recursos: recursosCfg, compras: comprasCfg } = useClinicSettings(); + const { t } = useI18n(); const esAdmin = hasRole('admin'); // PuedeVer una pestana = canSee('/reportes?tab=x'): la matriz guarda claves // por pestana y, cuando no existe la del tab, canSee cae al prefijo @@ -303,22 +305,22 @@ const ReportesContent: FC = () => { }; return ( - - + + {tab === 'general' && can('reportes.exportar') && ( - Exportar + {t('reports.export')} )} {puedeTab('exportar') && can('reportes.exportar') && ( setTab('exportar')} - title="Exportar listas de pacientes (correos, VIP, etiquetas)" + title={t('reports.exportDataTitle')} > - Exportar datos + {t('reports.exportData')} )} @@ -327,7 +329,7 @@ const ReportesContent: FC = () => { - Periodo + {t('reports.period')} {REPORT_PRESETS.map((p) => ( { : 'bg-theme-bg text-theme-muted border-theme-border hover:border-theme-border-strong' }`} > - {p.label} + {t(`reports.preset.${p.value}`)} ))} setRange('custom', e.target.value, end)} className="sm:max-w-[170px]" /> setRange('custom', start, e.target.value)} @@ -412,7 +414,7 @@ const ReportesContent: FC = () => { key={f.key} type="button" onClick={() => setTab(f.tabs[0].key)} - title={f.hint} + title={t(`reports.family.${f.key}.hint`)} className={`inline-flex items-center gap-1.5 px-4 py-2 text-sm font-semibold rounded-xl transition ${ activa ? 'bg-theme-accent text-theme-inverse shadow-card' @@ -420,7 +422,7 @@ const ReportesContent: FC = () => { }`} > - {f.label} + {t(`reports.family.${f.key}`)} ); })} @@ -429,30 +431,30 @@ const ReportesContent: FC = () => { {/* Pestañas de la familia activa */} - {tabsFamilia.map((t) => { - const activo = tab === t.key; + {tabsFamilia.map((tabItem) => { + const activo = tab === tabItem.key; return ( - setTab(t.key)} title={t.desc} className="px-4 py-2"> - {t.label} + setTab(tabItem.key)} title={t(`reports.tab.${tabItem.key}.desc`)} className="px-4 py-2"> + {t(`reports.tab.${tabItem.key}`)} toggle(t.key)} - title={isFav(t.key) ? 'Quitar de favoritos' : 'Fijar en favoritos'} - aria-label={isFav(t.key) ? 'Quitar de favoritos' : 'Fijar en favoritos'} + onClick={() => toggle(tabItem.key)} + title={isFav(tabItem.key) ? t('reports.favRemove') : t('reports.favAdd')} + aria-label={isFav(tabItem.key) ? t('reports.favRemove') : t('reports.favAdd')} className={`px-2.5 py-2 border-l ${ activo ? 'border-theme-inverse/30' : 'border-theme-border' - } ${isFav(t.key) ? 'text-amber-500' : activo ? 'text-theme-inverse/60 hover:text-theme-inverse' : 'text-theme-muted/60 hover:text-theme-heading'}`} + } ${isFav(tabItem.key) ? 'text-amber-500' : activo ? 'text-theme-inverse/60 hover:text-theme-inverse' : 'text-theme-muted/60 hover:text-theme-heading'}`} > - + ); @@ -510,7 +512,7 @@ const ReportesContent: FC = () => { setToday(e.target.value)} diff --git a/frontend/src/pages/Reservar.tsx b/frontend/src/pages/Reservar.tsx index e4a3615..02aaccb 100644 --- a/frontend/src/pages/Reservar.tsx +++ b/frontend/src/pages/Reservar.tsx @@ -10,6 +10,7 @@ import { useBookingConfig, proximosDias, fechaISO, etiquetaDiaCorto, fechaLargaISO, construirIcs, descargarIcs, normalizarTelefono, telefonoValido, } from '../lib/publicBooking'; +import { useI18n, GLOSARIO_EN } from '../lib/i18n'; // Portal público de reservas en línea (sin sesión). Mobile-first: la mayoría // de las clientas reservan del celular. Respeta brand y glosario del backend. @@ -20,6 +21,7 @@ type GrupoServicios = { code: string; nombre: string; servicios: ServicioPortal[ const Reservar: FC = () => { const navigate = useNavigate(); const { config, loading } = useBookingConfig(); + const { t, locale } = useI18n(); const [paso, setPaso] = useState(0); const [servicioId, setServicioId] = useState(null); @@ -60,9 +62,9 @@ const Reservar: FC = () => { if (svcs.length) { out.push({ code: c.code, nombre: c.name, servicios: svcs }); usados.add(c.code); } } const resto = config.servicios.filter((s) => !usados.has(s.categoria)); - if (resto.length) out.push({ code: 'otros', nombre: 'Otros', servicios: resto }); + if (resto.length) out.push({ code: 'otros', nombre: t('public.others'), servicios: resto }); return out; - }, [config]); + }, [config, t]); useEffect(() => { if (!config || pasoActual !== 'fecha' || !servicioId) return; @@ -81,8 +83,8 @@ const Reservar: FC = () => { const enviar = async () => { if (!servicioId || !hora) return; - if (nombre.trim().length < 2) { setError('Escribe tu nombre'); return; } - if (!telefonoValido(telefono)) { setError('Escribe un teléfono válido (10 dígitos)'); return; } + if (nombre.trim().length < 2) { setError(t('public.errName')); return; } + if (!telefonoValido(telefono)) { setError(t('public.errPhone')); return; } setEnviando(true); setError(null); try { @@ -102,13 +104,16 @@ const Reservar: FC = () => { window.scrollTo({ top: 0, behavior: 'smooth' }); } catch (err) { const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; - setError(msg || 'No se pudo completar la reserva. Intenta de nuevo.'); + setError(msg || t('public.errGeneric')); } finally { setEnviando(false); } }; - const term = (k: string, fallback: string) => config?.terms?.[k as keyof typeof config.terms] || fallback; + const term = (k: string, fallback: string) => + locale === 'en' + ? (GLOSARIO_EN[k as keyof typeof GLOSARIO_EN] || fallback) + : (config?.terms?.[k as keyof typeof config.terms] || fallback); if (loading) { return ( @@ -123,9 +128,9 @@ const Reservar: FC = () => { - Reservas en línea no disponibles + {t('public.unavailableTitle')} - Por el momento no puedes agendar desde aquí. Comunícate directamente con la clínica. + {t('public.unavailableBody')} @@ -157,9 +162,9 @@ const Reservar: FC = () => { - {resultado.requiere_confirmacion ? 'Solicitud recibida' : '¡Reserva confirmada!'} + {resultado.requiere_confirmacion ? t('public.requestReceived') : t('public.confirmed')} - Folio {resultado.folio} + {t('public.folioWord')} {resultado.folio} } texto={`${fechaLargaISO(resultado.fecha)} · ${resultado.hora}`} /> } texto={resultado.servicio} /> @@ -171,21 +176,21 @@ const Reservar: FC = () => { )} {resultado.requiere_confirmacion && !resultado.mensaje_confirmacion && ( - Tu reserva quedó registrada y la clínica la confirmará en breve. + {t('public.willConfirm')} )} descargarIcs(`cita-${resultado.folio}`, icalUrl)}> - Agregar al calendario + {t('public.addCalendar')} navigate(`/reservar/cita/${resultado.token_de_gestion}`)}> - Ver o cancelar mi reserva + {t('public.manage')} - Guarda este enlace para gestionar tu reserva + {t('public.saveLink')} { try { await navigator.clipboard.writeText(enlaceGestion); setCopiado(true); } catch { /* ignore */ } }} > - {copiado ? 'Copiado' : 'Copiar'} + {copiado ? t('public.copied') : t('public.copy')} @@ -209,11 +214,11 @@ const Reservar: FC = () => { const tituloPaso = (): string => { switch (pasoActual) { - case 'servicio': return `Elige tu ${term('service_singular', 'servicio').toLowerCase()}`; - case 'sucursal': return `Elige ${term('branch_singular', 'sucursal').toLowerCase()}`; - case 'especialista': return `Elige ${term('doctor_singular', 'especialista').toLowerCase()}`; - case 'fecha': return 'Elige fecha y hora'; - case 'datos': return 'Tus datos'; + case 'servicio': return t('public.stepService', { term: term('service_singular', 'servicio').toLowerCase() }); + case 'sucursal': return t('public.stepBranch', { term: term('branch_singular', 'sucursal').toLowerCase() }); + case 'especialista': return t('public.stepDoctor', { term: term('doctor_singular', 'especialista').toLowerCase() }); + case 'fecha': return t('public.stepDateTime'); + case 'datos': return t('public.stepData'); default: return ''; } }; @@ -242,7 +247,7 @@ const Reservar: FC = () => { {paso > 0 && ( - @@ -315,7 +320,7 @@ const Reservar: FC = () => { medicoId === null ? 'border-theme-accent bg-theme-accent-bg' : 'border-theme-border hover:border-theme-border-strong' }`} > - Cualquiera disponible + {t('public.anyAvailable')} {config.medicos.map((m) => ( @@ -362,7 +367,7 @@ const Reservar: FC = () => { ) : slots.length === 0 ? ( - No hay horarios disponibles ese día. Prueba con otra fecha. + {t('public.noSlots')} ) : ( @@ -381,7 +386,7 @@ const Reservar: FC = () => { )} - Continuar + {t('public.continue')} )} @@ -392,13 +397,13 @@ const Reservar: FC = () => { {servicioSel?.nombre} · {fechaLargaISO(fecha)} · {hora} {medicoSel ? ` · ${medicoSel.nombre}` : ''} - setNombre(e.target.value)} autoComplete="name" /> - setTelefono(e.target.value)} inputMode="tel" autoComplete="tel" /> - setEmail(e.target.value)} inputMode="email" autoComplete="email" /> - setNotas(e.target.value)} rows={3} /> + setNombre(e.target.value)} autoComplete="name" /> + setTelefono(e.target.value)} inputMode="tel" autoComplete="tel" /> + setEmail(e.target.value)} inputMode="email" autoComplete="email" /> + setNotas(e.target.value)} rows={3} /> {/* Honeypot: invisible para personas; los bots suelen rellenarlo. */} - No llenar este campo + {t('public.honeypot')} { {error && {error}} - {config.config.requiere_confirmacion ? 'Solicitar reserva' : 'Confirmar reserva'} + {config.config.requiere_confirmacion ? t('public.requestBooking') : t('public.confirmBooking')} {config.config.telefono_contacto && ( - ¿Dudas? {config.config.telefono_contacto} + {t('public.doubts')} {config.config.telefono_contacto} )} diff --git a/frontend/src/pages/ReservarCita.tsx b/frontend/src/pages/ReservarCita.tsx index bd98c10..e3fb20c 100644 --- a/frontend/src/pages/ReservarCita.tsx +++ b/frontend/src/pages/ReservarCita.tsx @@ -5,6 +5,7 @@ import { AlertTriangle, ArrowLeft, Check, Clock, Loader2, MapPin, User } from 'l import { Button, Input } from '../components/ui'; import { odooApi, type BookingCita } from '../services/odoo'; import { fechaLargaISO } from '../lib/publicBooking'; +import { useI18n } from '../lib/i18n'; // Página de gestión de una reserva hecha en el portal (ver / cancelar) usando // el token HMAC del enlace. Anónima: el token es la única credencial. @@ -12,6 +13,7 @@ import { fechaLargaISO } from '../lib/publicBooking'; const ReservarCita: FC = () => { const { token = '' } = useParams(); const navigate = useNavigate(); + const { t } = useI18n(); const [cita, setCita] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -22,12 +24,12 @@ const ReservarCita: FC = () => { useEffect(() => { let alive = true; - if (!token) { setLoading(false); setError('Enlace no válido'); return; } + if (!token) { setLoading(false); setError(t('public.manage.invalidLink')); return; } odooApi.getBookingCita(token) .then((res) => { if (alive) setCita(res); }) .catch((err) => { const msg = (err as { response?: { data?: { message?: string }; status?: number } }); - if (alive) setError(msg?.response?.data?.message || 'No encontramos esta reserva.'); + if (alive) setError(msg?.response?.data?.message || t('public.manage.notFound')); }) .finally(() => { if (alive) setLoading(false); }); return () => { alive = false; }; @@ -39,10 +41,10 @@ const ReservarCita: FC = () => { try { await odooApi.cancelBooking(token, motivo.trim()); setCancelada(true); - setCita((prev) => (prev ? { ...prev, estado: 'cancelled', estado_label: 'Canceló', puede_cancelar: false } : prev)); + setCita((prev) => (prev ? { ...prev, estado: 'cancelled', estado_label: t('agenda.state.cancelled'), puede_cancelar: false } : prev)); } catch (err) { const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; - setError(msg || 'No se pudo cancelar la reserva.'); + setError(msg || t('public.manage.cancelError')); } finally { setCancelando(false); } @@ -61,9 +63,9 @@ const ReservarCita: FC = () => { - No encontramos tu reserva - {error || 'El enlace no es válido o ya expiró.'} - navigate('/reservar')}>Hacer una reserva + {t('public.manage.notFoundTitle')} + {error || t('public.manage.expired')} + navigate('/reservar')}>{t('public.manage.makeBooking')} ); @@ -74,19 +76,19 @@ const ReservarCita: FC = () => { navigate('/reservar')} className="flex items-center gap-1 text-sm text-theme-muted hover:text-theme-heading"> - Reservar otra cita + {t('public.manage.bookAnother')} - Tu reserva + {t('public.manage.title')} {cita.estado_label} - Folio {cita.folio} + {t('public.folioWord')} {cita.folio} } texto={`${fechaLargaISO(cita.fecha)} · ${cita.hora}`} /> @@ -97,7 +99,7 @@ const ReservarCita: FC = () => { {cancelada && ( - Tu reserva quedó cancelada. Si fue un error, puedes agendar de nuevo. + {t('public.manage.cancelledOk')} )} @@ -105,20 +107,20 @@ const ReservarCita: FC = () => { {!confirmar ? ( setConfirmar(true)}> - Cancelar mi reserva + {t('public.manage.cancelMine')} ) : ( - ¿Seguro que quieres cancelar? Puedes dejarnos un motivo (opcional). + {t('public.manage.confirmQuestion')} - setMotivo(e.target.value)} /> + setMotivo(e.target.value)} /> {error && {error}} - Sí, cancelar + {t('public.manage.yesCancel')} - setConfirmar(false)}>No + setConfirmar(false)}>{t('common.no')} )} @@ -127,9 +129,9 @@ const ReservarCita: FC = () => { {!cancelada && !cita.puede_cancelar && cita.estado !== 'cancelled' && ( - Esta reserva ya no se puede cancelar en línea - {cita.permitir_cancelar ? ` (con menos de ${cita.cancelar_hasta_horas} h de anticipación)` : ''}. - {cita.telefono_contacto ? ` Comunícate al ${cita.telefono_contacto}.` : ' Comunícate con la clínica.'} + {t('public.manage.cannotCancel')} + {cita.permitir_cancelar ? t('public.manage.leadTime', { h: cita.cancelar_hasta_horas }) : ''}. + {cita.telefono_contacto ? t('public.manage.contactPhone', { phone: cita.telefono_contacto }) : t('public.manage.contactClinic')} )} diff --git a/frontend/src/services/odoo.ts b/frontend/src/services/odoo.ts index 5aae684..bc0e020 100644 --- a/frontend/src/services/odoo.ts +++ b/frontend/src/services/odoo.ts @@ -1069,6 +1069,8 @@ export interface ClinicSettings { url_publica: string; moneda_base: string; nota_ticket: string; + /** Idioma por defecto de la clínica ('es' | 'en'). Ver lib/i18n.tsx. */ + idioma_default: string; impuestos: ImpuestosSettings; reservas_online: ReservasOnlineSettings; notificaciones: NotificacionesSettings; @@ -2207,6 +2209,7 @@ export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = { url_publica: '', moneda_base: 'MXN', nota_ticket: '', + idioma_default: 'es', impuestos: IMPUESTOS_DEFAULT, reservas_online: RESERVAS_ONLINE_DEFAULT, notificaciones: NOTIFICACIONES_DEFAULT,
- Por el momento no puedes agendar desde aquí. Comunícate directamente con la clínica. + {t('public.unavailableBody')}
Folio {resultado.folio}
{t('public.folioWord')} {resultado.folio}
- Tu reserva quedó registrada y la clínica la confirmará en breve. + {t('public.willConfirm')}
Guarda este enlace para gestionar tu reserva
{t('public.saveLink')}
- No hay horarios disponibles ese día. Prueba con otra fecha. + {t('public.noSlots')}
- ¿Dudas? {config.config.telefono_contacto} + {t('public.doubts')} {config.config.telefono_contacto}
{error || 'El enlace no es válido o ya expiró.'}
{error || t('public.manage.expired')}
Folio {cita.folio}
{t('public.folioWord')} {cita.folio}
- Tu reserva quedó cancelada. Si fue un error, puedes agendar de nuevo. + {t('public.manage.cancelledOk')}
- ¿Seguro que quieres cancelar? Puedes dejarnos un motivo (opcional). + {t('public.manage.confirmQuestion')}
- Esta reserva ya no se puede cancelar en línea - {cita.permitir_cancelar ? ` (con menos de ${cita.cancelar_hasta_horas} h de anticipación)` : ''}. - {cita.telefono_contacto ? ` Comunícate al ${cita.telefono_contacto}.` : ' Comunícate con la clínica.'} + {t('public.manage.cannotCancel')} + {cita.permitir_cancelar ? t('public.manage.leadTime', { h: cita.cancelar_hasta_horas }) : ''}. + {cita.telefono_contacto ? t('public.manage.contactPhone', { phone: cita.telefono_contacto }) : t('public.manage.contactClinic')}