+
+
+ {t("common.language")}
+
+
+
- Connecté
+ {t("nav.connected")}
{user?.name ?? user?.email ?? "—"}
@@ -76,7 +114,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
onClick={() => logout()}
className="mt-3 w-full flex items-center justify-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium text-slate-600 bg-white hover:bg-red-50 hover:text-red-600 border border-slate-200 transition-colors"
>
- Déconnexion
+ {t("nav.logout")}
@@ -94,13 +132,16 @@ export default function Layout({ children }: { children: React.ReactNode }) {
QueueMed
-
+
+
+
+
{/* Mobile drawer */}
diff --git a/client/src/i18n.ts b/client/src/i18n.ts
new file mode 100644
index 0000000..f9b500c
--- /dev/null
+++ b/client/src/i18n.ts
@@ -0,0 +1,26 @@
+import i18n from "i18next";
+import { initReactI18next } from "react-i18next";
+import LanguageDetector from "i18next-browser-languagedetector";
+
+import fr from "./locales/fr.json";
+import en from "./locales/en.json";
+
+void i18n
+ .use(LanguageDetector)
+ .use(initReactI18next)
+ .init({
+ resources: {
+ fr: { translation: fr },
+ en: { translation: en },
+ },
+ fallbackLng: "fr",
+ supportedLngs: ["fr", "en"],
+ interpolation: { escapeValue: false },
+ detection: {
+ order: ["localStorage", "navigator", "htmlTag"],
+ caches: ["localStorage"],
+ lookupLocalStorage: "queuemed_lang",
+ },
+ });
+
+export default i18n;
diff --git a/client/src/locales/en.json b/client/src/locales/en.json
new file mode 100644
index 0000000..1eea6e8
--- /dev/null
+++ b/client/src/locales/en.json
@@ -0,0 +1,199 @@
+{
+ "common": {
+ "loading": "Loading…",
+ "save": "Save",
+ "cancel": "Cancel",
+ "delete": "Delete",
+ "edit": "Edit",
+ "close": "Close",
+ "back": "Back",
+ "backToHome": "Back to home",
+ "yes": "Yes",
+ "no": "No",
+ "confirm": "Confirm",
+ "next": "Next",
+ "previous": "Previous",
+ "language": "Language",
+ "appName": "QueueMed"
+ },
+ "nav": {
+ "dashboard": "Dashboard",
+ "clinics": "Clinics",
+ "analytics": "Analytics",
+ "subscription": "Subscription",
+ "help": "Help",
+ "logout": "Sign out",
+ "connected": "Signed in"
+ },
+ "home": {
+ "metaTitle": "QueueMed — Virtual waiting room for doctors",
+ "metaDescription": "QueueMed — the virtual waiting room for medical practices. Patients scan a QR code and follow their turn in real time, no app required.",
+ "ogTitle": "QueueMed — Virtual waiting room",
+ "ogDescription": "Never crowd your waiting room again. QueueMed digitises your queue.",
+ "heroTitle": "The",
+ "heroTitleAccent": "virtual waiting room",
+ "heroSubtitle": "QueueMed digitises your queue. Patients scan a QR code and follow their turn in real time — no app required.",
+ "heroCtaPrimary": "Start free",
+ "heroCtaSecondary": "Sign in",
+ "trustedBy": "Over 200 practices trust us"
+ },
+ "login": {
+ "metaTitle": "Sign in — QueueMed",
+ "metaDescription": "Sign in to your QueueMed doctor account.",
+ "backToHome": "Back to home",
+ "welcomeBack": "Welcome back",
+ "doctor": "Doctor",
+ "welcomeNew": "Welcome to",
+ "subtitleLogin": "Sign in to your doctor portal.",
+ "subtitleRegister": "Create your account and start a 30-day free trial.",
+ "tabLogin": "Sign in",
+ "tabRegister": "Sign up",
+ "nameLabel": "Full name",
+ "nameOptional": "(optional)",
+ "namePlaceholder": "Dr. Jane Smith",
+ "emailLabel": "Email",
+ "emailPlaceholder": "doctor@practice.com",
+ "passwordLabel": "Password",
+ "passwordPlaceholder": "At least 8 characters",
+ "submitLogin": "Sign in",
+ "submitRegister": "Create my account",
+ "noAccount": "No account yet?",
+ "registerLink": "Sign up for free",
+ "alreadyAccount": "Already have an account?",
+ "loginLink": "Sign in",
+ "forgotPassword": "Forgot password?",
+ "statSetup": "Setup",
+ "statSetupValue": "2 min",
+ "statTrial": "Trial",
+ "statTrialValue": "30d",
+ "statClinics": "Practices",
+ "statClinicsValue": "200+"
+ },
+ "forgot": {
+ "metaTitle": "Forgot password — QueueMed",
+ "metaDescription": "Reset your QueueMed password.",
+ "title": "Forgot password?",
+ "subtitle": "Enter your email to receive a reset link.",
+ "emailLabel": "Email",
+ "emailPlaceholder": "doctor@practice.com",
+ "submit": "Send reset link",
+ "successTitle": "Email sent",
+ "successMessage": "If an account exists for this email, you'll receive a link to reset your password (valid for 1 hour).",
+ "backToLogin": "Back to sign in"
+ },
+ "reset": {
+ "metaTitle": "Reset password — QueueMed",
+ "metaDescription": "Choose a new password for your QueueMed account.",
+ "title": "New password",
+ "subtitle": "Choose a secure password of at least 8 characters.",
+ "passwordLabel": "New password",
+ "passwordPlaceholder": "At least 8 characters",
+ "confirmLabel": "Confirm",
+ "confirmPlaceholder": "Type again",
+ "submit": "Reset password",
+ "successTitle": "Password updated",
+ "successMessage": "You'll be redirected to sign in…",
+ "errorMismatch": "Passwords don't match",
+ "errorTooShort": "Password must be at least 8 characters",
+ "backToLogin": "Back to sign in"
+ },
+ "dashboard": {
+ "metaTitle": "Dashboard — QueueMed",
+ "title": "Dashboard",
+ "subtitle": "Overview of your practices",
+ "kpiClinics": "Practices",
+ "kpiQueueOpen": "Open queues",
+ "kpiPatients": "Patients today",
+ "kpiAvgWait": "Avg wait",
+ "noClinic": "You don't have a practice yet.",
+ "createClinic": "Create a practice"
+ },
+ "queue": {
+ "metaTitle": "Queue management — QueueMed",
+ "title": "Queue management",
+ "openQueue": "Open queue",
+ "closeQueue": "Close queue",
+ "callNext": "Call next",
+ "markDone": "Done",
+ "markAbsent": "Absent",
+ "noPatients": "No patients waiting",
+ "waiting": "Waiting",
+ "called": "Called",
+ "inConsultation": "In consultation",
+ "addPrintedTicket": "Add printed ticket"
+ },
+ "patient": {
+ "metaTitle": "Your spot — QueueMed",
+ "yourPosition": "Your position",
+ "estimatedWait": "Estimated wait",
+ "minutes": "min",
+ "joinQueue": "Join queue",
+ "yourName": "Your name",
+ "phone": "Phone",
+ "whatsappOptional": "WhatsApp (optional)",
+ "joining": "Joining…",
+ "youAreCalled": "It's your turn!",
+ "pleaseGoTo": "Please go to reception",
+ "leaveQueue": "Leave queue",
+ "thanksForVisit": "Thanks for your visit"
+ },
+ "display": {
+ "metaTitle": "Display screen — QueueMed",
+ "nowCalling": "Now calling",
+ "ticket": "Ticket",
+ "waiting": "waiting",
+ "queueClosed": "Queue closed"
+ },
+ "analytics": {
+ "metaTitle": "Analytics — QueueMed",
+ "title": "Analytics",
+ "subtitle": "Statistics and trends",
+ "totalPatients": "Patients seen",
+ "avgWait": "Avg wait",
+ "avgConsultation": "Avg consultation",
+ "absentRate": "No-show rate",
+ "byHour": "By hour",
+ "byDay": "By day",
+ "exportCsv": "Export CSV",
+ "recommendations": "AI Recommendations"
+ },
+ "clinicSettings": {
+ "metaTitle": "Practice settings — QueueMed",
+ "title": "Practice settings",
+ "general": "General",
+ "openingHours": "Opening hours",
+ "whatsapp": "WhatsApp",
+ "save": "Save"
+ },
+ "whatsapp": {
+ "metaTitle": "WhatsApp — QueueMed",
+ "title": "WhatsApp setup",
+ "connect": "Connect WhatsApp",
+ "disconnect": "Disconnect",
+ "scanQr": "Scan this QR code with WhatsApp",
+ "connected": "Connected",
+ "disconnected": "Disconnected"
+ },
+ "onboarding": {
+ "metaTitle": "Welcome — QueueMed",
+ "title": "Welcome to QueueMed",
+ "step1": "Create your practice",
+ "step2": "Print your QR code",
+ "step3": "Open the queue",
+ "finish": "Finish"
+ },
+ "subscription": {
+ "metaTitle": "Subscription — QueueMed",
+ "title": "Subscription",
+ "trial": "Trial",
+ "active": "Active",
+ "expired": "Expired",
+ "daysLeft": "{{days}} days left",
+ "upgrade": "Upgrade plan"
+ },
+ "help": {
+ "metaTitle": "Help — QueueMed",
+ "title": "Help center",
+ "subtitle": "Find quick answers to your questions"
+ }
+}
diff --git a/client/src/locales/fr.json b/client/src/locales/fr.json
new file mode 100644
index 0000000..1e3be4b
--- /dev/null
+++ b/client/src/locales/fr.json
@@ -0,0 +1,199 @@
+{
+ "common": {
+ "loading": "Chargement…",
+ "save": "Enregistrer",
+ "cancel": "Annuler",
+ "delete": "Supprimer",
+ "edit": "Modifier",
+ "close": "Fermer",
+ "back": "Retour",
+ "backToHome": "Retour à l'accueil",
+ "yes": "Oui",
+ "no": "Non",
+ "confirm": "Confirmer",
+ "next": "Suivant",
+ "previous": "Précédent",
+ "language": "Langue",
+ "appName": "QueueMed"
+ },
+ "nav": {
+ "dashboard": "Tableau de bord",
+ "clinics": "Cabinets",
+ "analytics": "Analytics",
+ "subscription": "Abonnement",
+ "help": "Aide",
+ "logout": "Déconnexion",
+ "connected": "Connecté"
+ },
+ "home": {
+ "metaTitle": "QueueMed — Salle d'attente virtuelle pour médecins",
+ "metaDescription": "QueueMed — la salle d'attente virtuelle pour les cabinets médicaux. Vos patients scannent un QR code, suivent leur tour en temps réel, sans application à installer.",
+ "ogTitle": "QueueMed — Salle d'attente virtuelle",
+ "ogDescription": "Plus jamais de salle d'attente bondée. QueueMed digitalise votre file d'attente.",
+ "heroTitle": "La salle d'attente",
+ "heroTitleAccent": "virtuelle",
+ "heroSubtitle": "QueueMed digitalise votre file d'attente. Vos patients scannent un QR code et suivent leur tour en temps réel, sans application à installer.",
+ "heroCtaPrimary": "Démarrer gratuitement",
+ "heroCtaSecondary": "Se connecter",
+ "trustedBy": "Plus de 200 cabinets nous font confiance"
+ },
+ "login": {
+ "metaTitle": "Connexion — QueueMed",
+ "metaDescription": "Connectez-vous à votre espace médecin QueueMed.",
+ "backToHome": "Retour à l'accueil",
+ "welcomeBack": "Bon retour",
+ "doctor": "Docteur",
+ "welcomeNew": "Bienvenue sur",
+ "subtitleLogin": "Connectez-vous à votre espace médecin.",
+ "subtitleRegister": "Créez votre compte et démarrez 30 jours gratuits.",
+ "tabLogin": "Connexion",
+ "tabRegister": "Inscription",
+ "nameLabel": "Nom complet",
+ "nameOptional": "(optionnel)",
+ "namePlaceholder": "Dr. Marie Dubois",
+ "emailLabel": "Email",
+ "emailPlaceholder": "docteur@cabinet.fr",
+ "passwordLabel": "Mot de passe",
+ "passwordPlaceholder": "Au moins 8 caractères",
+ "submitLogin": "Se connecter",
+ "submitRegister": "Créer mon compte",
+ "noAccount": "Pas encore de compte ?",
+ "registerLink": "Inscrivez-vous gratuitement",
+ "alreadyAccount": "Déjà un compte ?",
+ "loginLink": "Connectez-vous",
+ "forgotPassword": "Mot de passe oublié ?",
+ "statSetup": "Setup",
+ "statSetupValue": "2 min",
+ "statTrial": "Essai",
+ "statTrialValue": "30j",
+ "statClinics": "Cabinets",
+ "statClinicsValue": "200+"
+ },
+ "forgot": {
+ "metaTitle": "Mot de passe oublié — QueueMed",
+ "metaDescription": "Réinitialisez votre mot de passe QueueMed.",
+ "title": "Mot de passe oublié ?",
+ "subtitle": "Saisissez votre email pour recevoir un lien de réinitialisation.",
+ "emailLabel": "Email",
+ "emailPlaceholder": "docteur@cabinet.fr",
+ "submit": "Envoyer le lien",
+ "successTitle": "Email envoyé",
+ "successMessage": "Si un compte existe avec cet email, vous recevrez un lien pour réinitialiser votre mot de passe (valable 1 heure).",
+ "backToLogin": "Retour à la connexion"
+ },
+ "reset": {
+ "metaTitle": "Réinitialiser mot de passe — QueueMed",
+ "metaDescription": "Choisissez un nouveau mot de passe pour votre compte QueueMed.",
+ "title": "Nouveau mot de passe",
+ "subtitle": "Choisissez un mot de passe sécurisé d'au moins 8 caractères.",
+ "passwordLabel": "Nouveau mot de passe",
+ "passwordPlaceholder": "Au moins 8 caractères",
+ "confirmLabel": "Confirmer",
+ "confirmPlaceholder": "Saisir à nouveau",
+ "submit": "Réinitialiser",
+ "successTitle": "Mot de passe modifié",
+ "successMessage": "Vous allez être redirigé vers la connexion…",
+ "errorMismatch": "Les mots de passe ne correspondent pas",
+ "errorTooShort": "Le mot de passe doit contenir au moins 8 caractères",
+ "backToLogin": "Retour à la connexion"
+ },
+ "dashboard": {
+ "metaTitle": "Tableau de bord — QueueMed",
+ "title": "Tableau de bord",
+ "subtitle": "Vue d'ensemble de vos cabinets",
+ "kpiClinics": "Cabinets",
+ "kpiQueueOpen": "Files ouvertes",
+ "kpiPatients": "Patients aujourd'hui",
+ "kpiAvgWait": "Attente moyenne",
+ "noClinic": "Vous n'avez pas encore de cabinet.",
+ "createClinic": "Créer un cabinet"
+ },
+ "queue": {
+ "metaTitle": "Gestion file d'attente — QueueMed",
+ "title": "Gestion de la file",
+ "openQueue": "Ouvrir la file",
+ "closeQueue": "Fermer la file",
+ "callNext": "Appeler le suivant",
+ "markDone": "Terminé",
+ "markAbsent": "Absent",
+ "noPatients": "Aucun patient en attente",
+ "waiting": "En attente",
+ "called": "Appelé",
+ "inConsultation": "En consultation",
+ "addPrintedTicket": "Ajouter un ticket imprimé"
+ },
+ "patient": {
+ "metaTitle": "Ma place — QueueMed",
+ "yourPosition": "Votre position",
+ "estimatedWait": "Attente estimée",
+ "minutes": "min",
+ "joinQueue": "Rejoindre la file",
+ "yourName": "Votre nom",
+ "phone": "Téléphone",
+ "whatsappOptional": "WhatsApp (optionnel)",
+ "joining": "Inscription en cours…",
+ "youAreCalled": "C'est à vous !",
+ "pleaseGoTo": "Présentez-vous à l'accueil",
+ "leaveQueue": "Quitter la file",
+ "thanksForVisit": "Merci de votre visite"
+ },
+ "display": {
+ "metaTitle": "Écran d'affichage — QueueMed",
+ "nowCalling": "On appelle",
+ "ticket": "Ticket",
+ "waiting": "en attente",
+ "queueClosed": "File fermée"
+ },
+ "analytics": {
+ "metaTitle": "Analytics — QueueMed",
+ "title": "Analytics",
+ "subtitle": "Statistiques et tendances",
+ "totalPatients": "Patients reçus",
+ "avgWait": "Attente moyenne",
+ "avgConsultation": "Consultation moyenne",
+ "absentRate": "Taux d'absence",
+ "byHour": "Par heure",
+ "byDay": "Par jour",
+ "exportCsv": "Exporter CSV",
+ "recommendations": "Recommandations IA"
+ },
+ "clinicSettings": {
+ "metaTitle": "Paramètres cabinet — QueueMed",
+ "title": "Paramètres du cabinet",
+ "general": "Général",
+ "openingHours": "Horaires d'ouverture",
+ "whatsapp": "WhatsApp",
+ "save": "Enregistrer"
+ },
+ "whatsapp": {
+ "metaTitle": "WhatsApp — QueueMed",
+ "title": "Configuration WhatsApp",
+ "connect": "Connecter WhatsApp",
+ "disconnect": "Déconnecter",
+ "scanQr": "Scannez ce QR code avec WhatsApp",
+ "connected": "Connecté",
+ "disconnected": "Déconnecté"
+ },
+ "onboarding": {
+ "metaTitle": "Bienvenue — QueueMed",
+ "title": "Bienvenue sur QueueMed",
+ "step1": "Créez votre cabinet",
+ "step2": "Imprimez votre QR code",
+ "step3": "Ouvrez la file d'attente",
+ "finish": "Terminer"
+ },
+ "subscription": {
+ "metaTitle": "Abonnement — QueueMed",
+ "title": "Abonnement",
+ "trial": "Essai",
+ "active": "Actif",
+ "expired": "Expiré",
+ "daysLeft": "{{days}} jours restants",
+ "upgrade": "Passer au plan payant"
+ },
+ "help": {
+ "metaTitle": "Aide — QueueMed",
+ "title": "Centre d'aide",
+ "subtitle": "Trouvez vite la réponse à vos questions"
+ }
+}
diff --git a/client/src/main.tsx b/client/src/main.tsx
index 95beb93..c52d8ae 100644
--- a/client/src/main.tsx
+++ b/client/src/main.tsx
@@ -3,9 +3,20 @@ import ReactDOM from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { trpc, trpcClientConfig } from "@/lib/trpc";
import { getSocket } from "@/lib/socket";
+import "./i18n";
import App from "./App";
import "./styles.css";
+if ("serviceWorker" in navigator) {
+ window.addEventListener("load", () => {
+ import("virtual:pwa-register")
+ .then(({ registerSW }) => registerSW({ immediate: true }))
+ .catch(() => {
+ /* PWA optional in dev */
+ });
+ });
+}
+
// Eagerly initialize the socket connection so it's ready when pages mount.
getSocket();
diff --git a/client/src/pages/ForgotPassword.tsx b/client/src/pages/ForgotPassword.tsx
new file mode 100644
index 0000000..a52cca3
--- /dev/null
+++ b/client/src/pages/ForgotPassword.tsx
@@ -0,0 +1,104 @@
+import { useState } from "react";
+import { Link } from "wouter";
+import { Helmet } from "react-helmet-async";
+import { useTranslation } from "react-i18next";
+import { Stethoscope, Mail, ArrowLeft, Loader2, CheckCircle2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { trpc } from "@/lib/trpc";
+
+export default function ForgotPassword() {
+ const { t } = useTranslation();
+ const [email, setEmail] = useState("");
+ const [submitted, setSubmitted] = useState(false);
+ const forgot = trpc.auth.forgotPassword.useMutation({
+ onSuccess: () => setSubmitted(true),
+ });
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ forgot.mutate({ email });
+ };
+
+ return (
+
+
+ {t("forgot.metaTitle")}
+
+
+
+
+
+
+ );
+}
diff --git a/client/src/pages/Login.tsx b/client/src/pages/Login.tsx
index e778d1a..26e3563 100644
--- a/client/src/pages/Login.tsx
+++ b/client/src/pages/Login.tsx
@@ -1,5 +1,7 @@
import { useState } from "react";
import { Link, useLocation } from "wouter";
+import { Helmet } from "react-helmet-async";
+import { useTranslation } from "react-i18next";
import { Stethoscope, Mail, Lock, User, Sparkles, Loader2, ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -9,6 +11,7 @@ import { useAuth } from "@/_core/hooks/useAuth";
type Mode = "login" | "register";
export default function Login() {
+ const { t } = useTranslation();
const [, navigate] = useLocation();
const [mode, setMode] = useState
("login");
const [email, setEmail] = useState("");
@@ -34,6 +37,10 @@ export default function Login() {
return (
+
+ {t("login.metaTitle")}
+
+
{/* Background blobs */}
{mode === "login" ? (
- <>Bon retour, Docteur>
+ <>{t("login.welcomeBack")}, {t("login.doctor")}>
) : (
- <>Bienvenue sur QueueMed>
+ <>{t("login.welcomeNew")} QueueMed>
)}
- {mode === "login"
- ? "Connectez-vous à votre espace médecin."
- : "Créez votre compte et démarrez 30 jours gratuits."}
+ {mode === "login" ? t("login.subtitleLogin") : t("login.subtitleRegister")}
@@ -79,7 +84,7 @@ export default function Login() {
: "text-slate-500 hover:text-slate-700"
}`}
>
- Connexion
+ {t("login.tabLogin")}