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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue