feat: Phase 2 WIP — forgot password, i18n FR/EN setup, email service

This commit is contained in:
Hermes 2026-04-25 15:23:57 +00:00
parent 81c6bccf8a
commit 698222dd6f
15 changed files with 4863 additions and 79 deletions

View file

@ -1,4 +1,5 @@
import { Route, Switch, Redirect } from "wouter";
import { HelmetProvider } from "react-helmet-async";
import { Toaster } from "@/components/ui/toast";
import { useAuth } from "@/_core/hooks/useAuth";
import Layout from "@/components/Layout";
@ -6,6 +7,8 @@ import { Loader2 } from "lucide-react";
import Home from "@/pages/Home";
import Login from "@/pages/Login";
import ForgotPassword from "@/pages/ForgotPassword";
import ResetPassword from "@/pages/ResetPassword";
import Dashboard from "@/pages/Dashboard";
import DoctorClinics from "@/pages/DoctorClinics";
import QueueManagement from "@/pages/QueueManagement";
@ -38,12 +41,14 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
export default function App() {
return (
<>
<HelmetProvider>
<Toaster />
<Switch>
{/* Public marketing & auth */}
<Route path="/" component={Home} />
<Route path="/login" component={Login} />
<Route path="/forgot-password" component={ForgotPassword} />
<Route path="/reset-password/:token" component={ResetPassword} />
<Route path="/help" component={Help} />
{/* Public patient/display routes */}
@ -94,7 +99,7 @@ export default function App() {
<NotFound />
</Route>
</Switch>
</>
</HelmetProvider>
);
}

View file

@ -1,5 +1,6 @@
import { useState } from "react";
import { Link, useLocation } from "wouter";
import { useTranslation } from "react-i18next";
import {
LayoutDashboard, Building2, BarChart3, CreditCard,
HelpCircle, LogOut, Stethoscope, Menu, X,
@ -7,19 +8,50 @@ import {
import { useAuth } from "@/_core/hooks/useAuth";
import { cn } from "@/lib/utils";
const NAV = [
{ href: "/dashboard", label: "Tableau de bord", icon: LayoutDashboard },
{ href: "/dashboard/clinics", label: "Cabinets", icon: Building2 },
{ href: "/dashboard/analytics", label: "Analytics", icon: BarChart3 },
{ href: "/dashboard/subscription", label: "Abonnement", icon: CreditCard },
{ href: "/help", label: "Aide", icon: HelpCircle },
];
function LanguageSwitcher({ className = "" }: { className?: string }) {
const { i18n } = useTranslation();
const current = i18n.resolvedLanguage ?? i18n.language ?? "fr";
const change = (lng: "fr" | "en") => i18n.changeLanguage(lng);
return (
<div className={cn("inline-flex rounded-lg overflow-hidden border border-emerald-200 bg-white text-xs font-semibold", className)}>
<button
onClick={() => change("fr")}
className={cn(
"px-2.5 py-1 transition-colors",
current.startsWith("fr") ? "bg-emerald-500 text-white" : "text-slate-600 hover:bg-emerald-50"
)}
aria-pressed={current.startsWith("fr")}
>
FR
</button>
<button
onClick={() => change("en")}
className={cn(
"px-2.5 py-1 transition-colors",
current.startsWith("en") ? "bg-emerald-500 text-white" : "text-slate-600 hover:bg-emerald-50"
)}
aria-pressed={current.startsWith("en")}
>
EN
</button>
</div>
);
}
export default function Layout({ children }: { children: React.ReactNode }) {
const { t } = useTranslation();
const [location] = useLocation();
const { user, logout } = useAuth();
const [mobileOpen, setMobileOpen] = useState(false);
const NAV = [
{ href: "/dashboard", label: t("nav.dashboard"), icon: LayoutDashboard },
{ href: "/dashboard/clinics", label: t("nav.clinics"), icon: Building2 },
{ href: "/dashboard/analytics", label: t("nav.analytics"), icon: BarChart3 },
{ href: "/dashboard/subscription", label: t("nav.subscription"), icon: CreditCard },
{ href: "/help", label: t("nav.help"), icon: HelpCircle },
];
const isActive = (href: string) =>
href === "/dashboard"
? location === "/dashboard"
@ -61,10 +93,16 @@ export default function Layout({ children }: { children: React.ReactNode }) {
</nav>
{/* User card */}
<div className="p-3 border-t border-emerald-100/60">
<div className="p-3 border-t border-emerald-100/60 space-y-3">
<div className="flex items-center justify-between px-1">
<span className="text-xs uppercase tracking-wider text-slate-500 font-semibold">
{t("common.language")}
</span>
<LanguageSwitcher />
</div>
<div className="px-3 py-3 rounded-xl bg-gradient-to-br from-emerald-50 to-cyan-50 border border-emerald-100/80">
<div className="text-xs uppercase tracking-wider text-emerald-700 font-semibold mb-1">
Connecté
{t("nav.connected")}
</div>
<div className="font-semibold text-slate-900 text-sm truncate">
{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"
>
<LogOut className="w-3.5 h-3.5" /> Déconnexion
<LogOut className="w-3.5 h-3.5" /> {t("nav.logout")}
</button>
</div>
</div>
@ -94,13 +132,16 @@ export default function Layout({ children }: { children: React.ReactNode }) {
<span className="font-bold gradient-text">QueueMed</span>
</a>
</Link>
<button
onClick={() => setMobileOpen(true)}
className="p-2 rounded-lg text-slate-600 hover:bg-emerald-50"
aria-label="Menu"
>
<Menu className="w-5 h-5" />
</button>
<div className="flex items-center gap-3">
<LanguageSwitcher />
<button
onClick={() => setMobileOpen(true)}
className="p-2 rounded-lg text-slate-600 hover:bg-emerald-50"
aria-label="Menu"
>
<Menu className="w-5 h-5" />
</button>
</div>
</header>
{/* Mobile drawer */}

26
client/src/i18n.ts Normal file
View file

@ -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;

199
client/src/locales/en.json Normal file
View file

@ -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"
}
}

199
client/src/locales/fr.json Normal file
View file

@ -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"
}
}

View file

@ -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();

View file

@ -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 (
<div className="min-h-screen flex items-center justify-center p-4 relative overflow-hidden">
<Helmet>
<title>{t("forgot.metaTitle")}</title>
<meta name="description" content={t("forgot.metaDescription")} />
</Helmet>
<div className="absolute inset-0 pointer-events-none overflow-hidden">
<div className="absolute top-1/4 left-1/4 w-96 h-96 rounded-full bg-emerald-300/30 blur-3xl" />
<div className="absolute bottom-1/4 right-1/4 w-[28rem] h-[28rem] rounded-full bg-cyan-300/30 blur-3xl" />
</div>
<div className="relative z-10 w-full max-w-md">
<Link href="/login">
<a className="inline-flex items-center gap-2 text-slate-500 hover:text-emerald-700 mb-6 text-sm">
<ArrowLeft className="w-4 h-4" />
{t("forgot.backToLogin")}
</a>
</Link>
<div className="text-center mb-8">
<div className="inline-flex items-center gap-2 mb-4">
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-emerald-500 to-cyan-500 flex items-center justify-center shadow-lg">
<Stethoscope className="w-6 h-6 text-white" />
</div>
</div>
<h1 className="font-bold text-3xl mb-2">
<span className="gradient-text">{t("forgot.title")}</span>
</h1>
<p className="text-slate-500 text-sm">{t("forgot.subtitle")}</p>
</div>
<div className="glass-card-strong rounded-3xl p-8">
{submitted ? (
<div className="text-center py-6">
<CheckCircle2 className="w-12 h-12 text-emerald-500 mx-auto mb-4" />
<h2 className="font-semibold text-lg mb-2">{t("forgot.successTitle")}</h2>
<p className="text-slate-600 text-sm">{t("forgot.successMessage")}</p>
<Link href="/login">
<a className="inline-block mt-6 text-teal-700 font-semibold hover:underline">
{t("forgot.backToLogin")}
</a>
</Link>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="email" className="mb-1.5 block">
{t("forgot.emailLabel")}
</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
id="email"
type="email"
placeholder={t("forgot.emailPlaceholder")}
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="pl-10"
autoComplete="email"
/>
</div>
</div>
<Button
type="submit"
variant="gradient"
size="lg"
className="w-full"
disabled={forgot.isPending}
>
{forgot.isPending && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
{t("forgot.submit")}
</Button>
</form>
)}
</div>
</div>
</div>
);
}

View file

@ -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<Mode>("login");
const [email, setEmail] = useState("");
@ -34,6 +37,10 @@ export default function Login() {
return (
<div className="min-h-screen flex items-center justify-center p-4 relative overflow-hidden">
<Helmet>
<title>{t("login.metaTitle")}</title>
<meta name="description" content={t("login.metaDescription")} />
</Helmet>
{/* Background blobs */}
<div className="absolute inset-0 pointer-events-none overflow-hidden">
<div className="absolute top-1/4 left-1/4 w-96 h-96 rounded-full bg-emerald-300/30 blur-3xl animate-pulse-glow" />
@ -44,7 +51,7 @@ export default function Login() {
<Link href="/">
<a className="inline-flex items-center gap-2 text-slate-500 hover:text-emerald-700 mb-6 text-sm">
<ArrowLeft className="w-4 h-4" />
Retour à l'accueil
{t("login.backToHome")}
</a>
</Link>
@ -56,15 +63,13 @@ export default function Login() {
</div>
<h1 className="font-bold text-3xl mb-2">
{mode === "login" ? (
<>Bon retour, <span className="gradient-text">Docteur</span></>
<>{t("login.welcomeBack")}, <span className="gradient-text">{t("login.doctor")}</span></>
) : (
<>Bienvenue sur <span className="gradient-text">QueueMed</span></>
<>{t("login.welcomeNew")} <span className="gradient-text">QueueMed</span></>
)}
</h1>
<p className="text-slate-500 text-sm">
{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")}
</p>
</div>
@ -79,7 +84,7 @@ export default function Login() {
: "text-slate-500 hover:text-slate-700"
}`}
>
Connexion
{t("login.tabLogin")}
</button>
<button
onClick={() => setMode("register")}
@ -89,19 +94,19 @@ export default function Login() {
: "text-slate-500 hover:text-slate-700"
}`}
>
Inscription
{t("login.tabRegister")}
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{mode === "register" && (
<div>
<Label htmlFor="name" className="mb-1.5 block">Nom complet <span className="text-slate-400 text-xs">(optionnel)</span></Label>
<Label htmlFor="name" className="mb-1.5 block">{t("login.nameLabel")} <span className="text-slate-400 text-xs">{t("login.nameOptional")}</span></Label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
id="name"
placeholder="Dr. Marie Dubois"
placeholder={t("login.namePlaceholder")}
value={name}
onChange={(e) => setName(e.target.value)}
className="pl-10"
@ -112,13 +117,13 @@ export default function Login() {
)}
<div>
<Label htmlFor="email" className="mb-1.5 block">Email</Label>
<Label htmlFor="email" className="mb-1.5 block">{t("login.emailLabel")}</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
id="email"
type="email"
placeholder="docteur@cabinet.fr"
placeholder={t("login.emailPlaceholder")}
value={email}
onChange={(e) => setEmail(e.target.value)}
required
@ -129,13 +134,13 @@ export default function Login() {
</div>
<div>
<Label htmlFor="password" className="mb-1.5 block">Mot de passe</Label>
<Label htmlFor="password" className="mb-1.5 block">{t("login.passwordLabel")}</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
id="password"
type="password"
placeholder="Au moins 8 caractères"
placeholder={t("login.passwordPlaceholder")}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
@ -158,21 +163,31 @@ export default function Login() {
) : (
<Sparkles className="w-4 h-4 mr-2" />
)}
{mode === "login" ? "Se connecter" : "Créer mon compte"}
{mode === "login" ? t("login.submitLogin") : t("login.submitRegister")}
</Button>
{mode === "login" && (
<div className="text-center">
<Link href="/forgot-password">
<a className="text-sm text-teal-700 hover:underline">
{t("login.forgotPassword")}
</a>
</Link>
</div>
)}
</form>
<p className="text-center text-xs text-slate-500 mt-6">
{mode === "login" ? (
<>Pas encore de compte ?{" "}
<>{t("login.noAccount")}{" "}
<button onClick={() => setMode("register")} className="text-teal-700 font-semibold underline-offset-2 hover:underline">
Inscrivez-vous gratuitement
{t("login.registerLink")}
</button>
</>
) : (
<>Déjà un compte ?{" "}
<>{t("login.alreadyAccount")}{" "}
<button onClick={() => setMode("login")} className="text-teal-700 font-semibold underline-offset-2 hover:underline">
Connectez-vous
{t("login.loginLink")}
</button>
</>
)}
@ -181,9 +196,9 @@ export default function Login() {
<div className="mt-6 grid grid-cols-3 gap-3 text-center">
{[
{ label: "Setup", value: "2 min" },
{ label: "Essai", value: "30j" },
{ label: "Cabinets", value: "200+" },
{ label: t("login.statSetup"), value: t("login.statSetupValue") },
{ label: t("login.statTrial"), value: t("login.statTrialValue") },
{ label: t("login.statClinics"), value: t("login.statClinicsValue") },
].map((s) => (
<div key={s.label} className="glass-card rounded-xl p-3">
<div className="font-bold text-emerald-700 text-sm">{s.value}</div>

View file

@ -0,0 +1,146 @@
import { useState } from "react";
import { Link, useLocation, useRoute } from "wouter";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { Stethoscope, Lock, ArrowLeft, Loader2, CheckCircle2, AlertCircle } 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 ResetPassword() {
const { t } = useTranslation();
const [, navigate] = useLocation();
const [, params] = useRoute<{ token: string }>("/reset-password/:token");
const token = params?.token ?? "";
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const reset = trpc.auth.resetPassword.useMutation({
onSuccess: () => {
setSuccess(true);
setTimeout(() => navigate("/login"), 2000);
},
onError: (err) => setError(err.message),
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (password !== confirm) {
setError(t("reset.errorMismatch"));
return;
}
if (password.length < 8) {
setError(t("reset.errorTooShort"));
return;
}
reset.mutate({ token, newPassword: password });
};
return (
<div className="min-h-screen flex items-center justify-center p-4 relative overflow-hidden">
<Helmet>
<title>{t("reset.metaTitle")}</title>
<meta name="description" content={t("reset.metaDescription")} />
</Helmet>
<div className="absolute inset-0 pointer-events-none overflow-hidden">
<div className="absolute top-1/4 left-1/4 w-96 h-96 rounded-full bg-emerald-300/30 blur-3xl" />
<div className="absolute bottom-1/4 right-1/4 w-[28rem] h-[28rem] rounded-full bg-cyan-300/30 blur-3xl" />
</div>
<div className="relative z-10 w-full max-w-md">
<Link href="/login">
<a className="inline-flex items-center gap-2 text-slate-500 hover:text-emerald-700 mb-6 text-sm">
<ArrowLeft className="w-4 h-4" />
{t("reset.backToLogin")}
</a>
</Link>
<div className="text-center mb-8">
<div className="inline-flex items-center gap-2 mb-4">
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-emerald-500 to-cyan-500 flex items-center justify-center shadow-lg">
<Stethoscope className="w-6 h-6 text-white" />
</div>
</div>
<h1 className="font-bold text-3xl mb-2">
<span className="gradient-text">{t("reset.title")}</span>
</h1>
<p className="text-slate-500 text-sm">{t("reset.subtitle")}</p>
</div>
<div className="glass-card-strong rounded-3xl p-8">
{success ? (
<div className="text-center py-6">
<CheckCircle2 className="w-12 h-12 text-emerald-500 mx-auto mb-4" />
<h2 className="font-semibold text-lg mb-2">{t("reset.successTitle")}</h2>
<p className="text-slate-600 text-sm">{t("reset.successMessage")}</p>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="password" className="mb-1.5 block">
{t("reset.passwordLabel")}
</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
id="password"
type="password"
placeholder={t("reset.passwordPlaceholder")}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="pl-10"
autoComplete="new-password"
/>
</div>
</div>
<div>
<Label htmlFor="confirm" className="mb-1.5 block">
{t("reset.confirmLabel")}
</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
id="confirm"
type="password"
placeholder={t("reset.confirmPlaceholder")}
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
required
minLength={8}
className="pl-10"
autoComplete="new-password"
/>
</div>
</div>
{error && (
<div className="flex items-start gap-2 px-3 py-2 rounded-lg bg-red-50 text-red-700 text-sm">
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<span>{error}</span>
</div>
)}
<Button
type="submit"
variant="gradient"
size="lg"
className="w-full"
disabled={reset.isPending}
>
{reset.isPending && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
{t("reset.submit")}
</Button>
</form>
)}
</div>
</div>
</div>
);
}

3979
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -36,6 +36,7 @@
"@trpc/client": "11.0.0-rc.660",
"@trpc/react-query": "11.0.0-rc.660",
"@trpc/server": "11.0.0-rc.660",
"@types/nodemailer": "^8.0.0",
"@whiskeysockets/baileys": "7.0.0-rc.9",
"bcryptjs": "^2.4.3",
"class-variance-authority": "^0.7.1",
@ -49,23 +50,29 @@
"express-rate-limit": "^8.4.1",
"framer-motion": "^11.15.0",
"helmet": "^8.1.0",
"i18next": "^26.0.8",
"i18next-browser-languagedetector": "^8.2.1",
"input-otp": "^1.4.1",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.468.0",
"mysql2": "^3.11.5",
"nanoid": "^5.0.9",
"nodemailer": "^8.0.6",
"p-queue": "^9.1.0",
"pino": "^10.3.1",
"qrcode": "^1.5.4",
"qrcode-terminal": "^0.12.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-helmet-async": "^3.0.0",
"react-i18next": "^17.0.4",
"recharts": "^2.15.0",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
"sonner": "^1.7.1",
"tailwind-merge": "^2.6.0",
"tailwindcss": "^4.0.0",
"vite-plugin-pwa": "^1.2.0",
"wouter": "^3.3.5",
"zod": "^3.24.1"
},

View file

@ -113,6 +113,29 @@ export async function touchUserLogin(userId: number): Promise<void> {
await db.update(users).set({ lastSignedIn: new Date() }).where(eq(users.id, userId));
}
export async function setUserResetToken(
userId: number,
resetToken: string | null,
resetTokenExpiry: Date | null
): Promise<void> {
const db = await getDb();
await db.update(users).set({ resetToken, resetTokenExpiry }).where(eq(users.id, userId));
}
export async function getUserByResetToken(token: string): Promise<User | null> {
const db = await getDb();
const rows = await db.select().from(users).where(eq(users.resetToken, token)).limit(1);
return rows[0] ?? null;
}
export async function updateUserPassword(userId: number, passwordHash: string): Promise<void> {
const db = await getDb();
await db
.update(users)
.set({ passwordHash, resetToken: null, resetTokenExpiry: null })
.where(eq(users.id, userId));
}
// ─── Subscriptions ───────────────────────────────────────────────────────────
const TRIAL_DAYS = 30;

View file

@ -1,3 +1,4 @@
import crypto from "node:crypto";
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import QRCode from "qrcode";
@ -29,6 +30,9 @@ import {
getActiveQueue,
getQueueEntry,
getQueueEntryByToken,
setUserResetToken,
getUserByResetToken,
updateUserPassword,
addToQueue,
updateQueueEntry,
reorderQueue,
@ -48,6 +52,7 @@ import {
setAuthCookie,
clearAuthCookie,
} from "./auth.js";
import { sendMail, buildResetEmail } from "./services/email.js";
import {
connectWhatsApp,
disconnectWhatsApp,
@ -199,6 +204,46 @@ const authRouter = router({
lastSignedIn: fresh.lastSignedIn,
};
}),
forgotPassword: publicProcedure
.input(z.object({ email: z.string().email().max(320) }))
.mutation(async ({ input }) => {
const user = await getUserByEmail(input.email.toLowerCase());
if (user) {
const token = crypto.randomBytes(6).toString("hex");
const expiry = new Date(Date.now() + 60 * 60 * 1000);
await setUserResetToken(user.id, token, expiry);
const baseUrl = process.env.PUBLIC_BASE_URL ?? "";
const resetUrl = `${baseUrl}/reset-password/${token}`;
const { subject, html, text } = buildResetEmail(resetUrl);
try {
await sendMail({ to: user.email, subject, html, text });
} catch (err) {
console.error("[auth.forgotPassword] sendMail failed", err);
}
}
return { success: true };
}),
resetPassword: publicProcedure
.input(
z.object({
token: z.string().min(8).max(255),
newPassword: z.string().min(8).max(128),
})
)
.mutation(async ({ input }) => {
const user = await getUserByResetToken(input.token);
if (!user || !user.resetTokenExpiry || user.resetTokenExpiry.getTime() < Date.now()) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Lien invalide ou expiré",
});
}
const passwordHash = await hashPassword(input.newPassword);
await updateUserPassword(user.id, passwordHash);
return { success: true };
}),
});
// ─── Clinic router ───────────────────────────────────────────────────────────

View file

@ -22,6 +22,8 @@ export const users = mysqlTable(
openId: varchar("openId", { length: 64 }),
loginMethod: varchar("loginMethod", { length: 64 }).default("password").notNull(),
role: mysqlEnum("role", ["user", "admin"]).default("user").notNull(),
resetToken: varchar("resetToken", { length: 255 }),
resetTokenExpiry: timestamp("resetTokenExpiry"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
lastSignedIn: timestamp("lastSignedIn").defaultNow().notNull(),

58
server/services/email.ts Normal file
View file

@ -0,0 +1,58 @@
import nodemailer, { type Transporter } from "nodemailer";
let cachedTransporter: Transporter | null = null;
function getTransporter(): Transporter | null {
if (cachedTransporter) return cachedTransporter;
const host = process.env.SMTP_HOST;
const port = process.env.SMTP_PORT ? Number(process.env.SMTP_PORT) : undefined;
const user = process.env.SMTP_USER;
const pass = process.env.SMTP_PASS;
if (!host || !port) {
console.warn("[email] SMTP_HOST/SMTP_PORT not configured — emails will be logged only");
return null;
}
cachedTransporter = nodemailer.createTransport({
host,
port,
secure: port === 465,
auth: user && pass ? { user, pass } : undefined,
});
return cachedTransporter;
}
export async function sendMail(opts: { to: string; subject: string; html: string; text?: string }): Promise<void> {
const from = process.env.SMTP_FROM ?? process.env.SMTP_USER ?? "no-reply@queuemed.app";
const transporter = getTransporter();
if (!transporter) {
console.info("[email] (dev) Would send email", { to: opts.to, subject: opts.subject });
return;
}
await transporter.sendMail({
from,
to: opts.to,
subject: opts.subject,
html: opts.html,
text: opts.text,
});
}
export function buildResetEmail(resetUrl: string): { subject: string; html: string; text: string } {
const subject = "QueueMed — Réinitialisation de votre mot de passe";
const text = `Bonjour,\n\nVous avez demandé à réinitialiser votre mot de passe QueueMed.\n\nCliquez sur ce lien (valable 1 heure) :\n${resetUrl}\n\nSi vous n'êtes pas à l'origine de cette demande, ignorez ce message.\n\n— L'équipe QueueMed`;
const html = `<!doctype html>
<html><body style="font-family:Inter,Arial,sans-serif;color:#0f172a;background:#f0fdf4;margin:0;padding:24px">
<div style="max-width:540px;margin:0 auto;background:white;border-radius:16px;padding:32px;box-shadow:0 4px 20px rgba(16,185,129,0.1)">
<h1 style="color:#10b981;margin:0 0 16px;font-size:22px">Réinitialisation de votre mot de passe</h1>
<p>Bonjour,</p>
<p>Vous avez demandé à réinitialiser votre mot de passe QueueMed. Cliquez sur le bouton ci-dessous (valable 1 heure) :</p>
<p style="margin:24px 0">
<a href="${resetUrl}" style="display:inline-block;padding:12px 24px;background:linear-gradient(135deg,#10b981,#06b6d4);color:white;text-decoration:none;border-radius:12px;font-weight:600">Réinitialiser mon mot de passe</a>
</p>
<p style="color:#64748b;font-size:13px">Si vous n'êtes pas à l'origine de cette demande, ignorez ce message.</p>
<hr style="border:none;border-top:1px solid #e2e8f0;margin:24px 0" />
<p style="color:#94a3b8;font-size:12px"> L'équipe QueueMed</p>
</div>
</body></html>`;
return { subject, html, text };
}