feat: Phase 2 WIP — forgot password, i18n FR/EN setup, email service
This commit is contained in:
parent
81c6bccf8a
commit
698222dd6f
15 changed files with 4863 additions and 79 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
26
client/src/i18n.ts
Normal 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
199
client/src/locales/en.json
Normal 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
199
client/src/locales/fr.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
104
client/src/pages/ForgotPassword.tsx
Normal file
104
client/src/pages/ForgotPassword.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
146
client/src/pages/ResetPassword.tsx
Normal file
146
client/src/pages/ResetPassword.tsx
Normal 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
3979
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
23
server/db.ts
23
server/db.ts
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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
58
server/services/email.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue