feat: Phase 2 — i18n FR/EN all pages, PWA setup, forgot password, SEO meta
This commit is contained in:
parent
698222dd6f
commit
a7ffcaa181
27 changed files with 2687 additions and 869 deletions
|
|
@ -3,12 +3,20 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/icon-192x192.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#10b981" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="QueueMed" />
|
||||
<meta name="application-name" content="QueueMed" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta
|
||||
name="description"
|
||||
content="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."
|
||||
/>
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<title>QueueMed — Salle d'attente virtuelle</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
|
|
|
|||
13
client/public/favicon.svg
Normal file
13
client/public/favicon.svg
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#10b981"/>
|
||||
<stop offset="100%" stop-color="#06b6d4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="14" fill="url(#g)"/>
|
||||
<g fill="#ffffff">
|
||||
<rect x="27" y="14" width="10" height="36" rx="3"/>
|
||||
<rect x="14" y="27" width="36" height="10" rx="3"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 480 B |
13
client/public/icon-192x192.svg
Normal file
13
client/public/icon-192x192.svg
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192" width="192" height="192">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#10b981"/>
|
||||
<stop offset="100%" stop-color="#06b6d4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="192" height="192" rx="40" fill="url(#g)"/>
|
||||
<g fill="#ffffff">
|
||||
<rect x="80" y="40" width="32" height="112" rx="10"/>
|
||||
<rect x="40" y="80" width="112" height="32" rx="10"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 490 B |
13
client/public/icon-512x512.svg
Normal file
13
client/public/icon-512x512.svg
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#10b981"/>
|
||||
<stop offset="100%" stop-color="#06b6d4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="108" fill="url(#g)"/>
|
||||
<g fill="#ffffff">
|
||||
<rect x="216" y="108" width="80" height="296" rx="24"/>
|
||||
<rect x="108" y="216" width="296" height="80" rx="24"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 495 B |
|
|
@ -19,11 +19,14 @@ const buttonVariants = cva(
|
|||
ghost:
|
||||
"hover:bg-accent dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
gradient:
|
||||
"bg-gradient-to-r from-emerald-500 to-cyan-500 text-white shadow-md hover:shadow-lg hover:from-emerald-600 hover:to-cyan-600",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
xl: "h-12 rounded-xl px-8 text-base has-[>svg]:px-6",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
|
|
|
|||
|
|
@ -23,4 +23,12 @@ void i18n
|
|||
},
|
||||
});
|
||||
|
||||
const syncHtmlLang = (lng: string) => {
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.lang = lng.startsWith("en") ? "en" : "fr";
|
||||
}
|
||||
};
|
||||
syncHtmlLang(i18n.resolvedLanguage ?? i18n.language ?? "fr");
|
||||
i18n.on("languageChanged", syncHtmlLang);
|
||||
|
||||
export default i18n;
|
||||
|
|
|
|||
|
|
@ -35,7 +35,146 @@
|
|||
"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"
|
||||
"trustedBy": "Over 200 practices trust us",
|
||||
"nav": {
|
||||
"features": "Features",
|
||||
"how": "How it works",
|
||||
"pricing": "Pricing",
|
||||
"help": "Help",
|
||||
"login": "Sign in",
|
||||
"freeTrial": "Free trial"
|
||||
},
|
||||
"heroBadge": "Next-generation virtual waiting room",
|
||||
"heroH1Part1": "Your patients",
|
||||
"heroH1Part2": "don't wait anymore,",
|
||||
"heroH1Part3": "they",
|
||||
"heroH1Accent": "live",
|
||||
"heroDescription": "QueueMed turns your medical practice into a seamless experience. QR code, real-time tracking, notifications, display screen — no app required.",
|
||||
"heroStartTrial": "Start free trial (30 days)",
|
||||
"heroSeeHow": "See how it works",
|
||||
"heroCheck1": "No credit card",
|
||||
"heroCheck2": "Setup in 2 minutes",
|
||||
"heroCheck3": "Data hosted in France",
|
||||
"mockCurrent": "Current patient",
|
||||
"mockRoom": "Room 2 — Dr. Martin",
|
||||
"mockUpcoming": "Up next",
|
||||
"mockAnonymous": "Anonymous patient",
|
||||
"mockMin": "min",
|
||||
"featuresKicker": "Features",
|
||||
"featuresTitlePart1": "Everything your practice",
|
||||
"featuresTitleAccent": "needs",
|
||||
"featuresSubtitle": "A platform built by and for doctors. Elegant, fast, compliant.",
|
||||
"features": {
|
||||
"qrCode": {
|
||||
"title": "Rotating QR code",
|
||||
"description": "Patients scan a QR at the entrance — anti-cheat rotating token, no app to install."
|
||||
},
|
||||
"realtime": {
|
||||
"title": "Real-time position",
|
||||
"description": "Each patient sees their position and estimated wait time, updated live via WebSocket."
|
||||
},
|
||||
"alerts": {
|
||||
"title": "Smart alerts",
|
||||
"description": "Push notification when their turn is near — patients can step out of the waiting room."
|
||||
},
|
||||
"displayScreen": {
|
||||
"title": "Waiting room screen",
|
||||
"description": "Full-screen display on tablet with ticker, large called number and live queue."
|
||||
},
|
||||
"stats": {
|
||||
"title": "Precise analytics",
|
||||
"description": "Traffic by hour, day, average duration. AI recommendations to optimise your practice."
|
||||
},
|
||||
"gdpr": {
|
||||
"title": "GDPR & sovereign",
|
||||
"description": "Data hosted in France. No patient tracking. Bank-grade security (TLS, JWT, bcrypt)."
|
||||
}
|
||||
},
|
||||
"howKicker": "How it works",
|
||||
"howTitleAccent": "3 steps",
|
||||
"howTitleRest": "and you're live",
|
||||
"steps": {
|
||||
"step1": {
|
||||
"title": "Set up your practice",
|
||||
"desc": "2 minutes to create your queue. Print the QR code and place it at reception."
|
||||
},
|
||||
"step2": {
|
||||
"title": "Patients scan",
|
||||
"desc": "They open their camera, scan, and join the queue with one tap."
|
||||
},
|
||||
"step3": {
|
||||
"title": "You call the next one",
|
||||
"desc": "One click from your dashboard, the patient is notified, the screen updates."
|
||||
}
|
||||
},
|
||||
"pricingKicker": "Pricing",
|
||||
"pricingTitlePart1": "Simple and",
|
||||
"pricingTitleAccent": "transparent",
|
||||
"pricingSubtitle": "30-day free trial, no commitment, no credit card.",
|
||||
"pricingPopular": "Popular",
|
||||
"pricing": {
|
||||
"trial": {
|
||||
"name": "Trial",
|
||||
"price": "Free",
|
||||
"period": "30 days",
|
||||
"description": "All features, no credit card.",
|
||||
"feature1": "1 practice",
|
||||
"feature2": "Unlimited patients",
|
||||
"feature3": "Basic analytics",
|
||||
"feature4": "Email support",
|
||||
"cta": "Start trial"
|
||||
},
|
||||
"basic": {
|
||||
"name": "Basic",
|
||||
"price": "€29",
|
||||
"period": "/ month",
|
||||
"description": "For a solo practice.",
|
||||
"feature1": "1 practice",
|
||||
"feature2": "Unlimited patients",
|
||||
"feature3": "Display screen",
|
||||
"feature4": "Advanced analytics",
|
||||
"feature5": "Priority support",
|
||||
"cta": "Subscribe"
|
||||
},
|
||||
"pro": {
|
||||
"name": "Pro",
|
||||
"price": "€79",
|
||||
"period": "/ month",
|
||||
"description": "For medical centres.",
|
||||
"feature1": "Unlimited practices",
|
||||
"feature2": "Multi-practitioner",
|
||||
"feature3": "AI recommendations",
|
||||
"feature4": "Advanced CSV export",
|
||||
"feature5": "Phone support",
|
||||
"cta": "Subscribe"
|
||||
}
|
||||
},
|
||||
"testimonialsKicker": "Testimonials",
|
||||
"testimonialsTitlePart1": "Trusted by",
|
||||
"testimonialsTitleAccent": "200+ doctors",
|
||||
"testimonials": {
|
||||
"t1": {
|
||||
"name": "Dr. Marie Dubois",
|
||||
"role": "GP, Lyon",
|
||||
"quote": "My patients love it. No more crowded waiting room, no more stress. I save an hour every day, easily."
|
||||
},
|
||||
"t2": {
|
||||
"name": "Dr. Karim Benali",
|
||||
"role": "Paediatrician, Marseille",
|
||||
"quote": "Setup in 5 minutes. The rotating QR prevents abuse and the display screen is perfect for my waiting room."
|
||||
},
|
||||
"t3": {
|
||||
"name": "Dr. Sophie Lefèvre",
|
||||
"role": "Dentist, Bordeaux",
|
||||
"quote": "The analytics let me optimise my appointment slots. Clear ROI from the first month."
|
||||
}
|
||||
},
|
||||
"ctaTitle": "Ready to transform your practice?",
|
||||
"ctaSubtitle": "30-day free trial. No credit card. Setup in 2 minutes.",
|
||||
"ctaButton": "Get started now",
|
||||
"footerTagline": "Virtual waiting room",
|
||||
"footerHelp": "Help",
|
||||
"footerContact": "Contact"
|
||||
},
|
||||
"login": {
|
||||
"metaTitle": "Sign in — QueueMed",
|
||||
|
|
@ -106,7 +245,50 @@
|
|||
"kpiPatients": "Patients today",
|
||||
"kpiAvgWait": "Avg wait",
|
||||
"noClinic": "You don't have a practice yet.",
|
||||
"createClinic": "Create a practice"
|
||||
"createClinic": "Create a practice",
|
||||
"metaDescription": "Overview of your practices, KPIs and quick links in QueueMed.",
|
||||
"fallbackDoctor": "Doctor",
|
||||
"hello": "Hello",
|
||||
"dayStarts": "Your day starts here.",
|
||||
"trialDaysLeft_one": "Free trial — {{count}} day left",
|
||||
"trialDaysLeft_other": "Free trial — {{count}} days left",
|
||||
"trialExpired": "Trial expired",
|
||||
"subscribe": "Subscribe",
|
||||
"subscriptionExpired": "Subscription expired",
|
||||
"renew": "Renew",
|
||||
"kpiActiveClinics": "Active practices",
|
||||
"kpiPatients7d": "Patients (7d)",
|
||||
"kpiAvgWaitShort": "Avg wait",
|
||||
"kpiPlan": "Plan",
|
||||
"minutesShort": "min",
|
||||
"yourClinics": "Your practices",
|
||||
"manage": "Manage",
|
||||
"welcomeTitle": "Welcome to QueueMed!",
|
||||
"welcomeSubtitle": "Set up your first practice in 2 minutes with our wizard.",
|
||||
"startSetup": "Start setup",
|
||||
"createManually": "Create manually",
|
||||
"statusOpen": "Open",
|
||||
"statusClosed": "Closed",
|
||||
"minPerPatient": "min/patient",
|
||||
"quickAccess": "Quick access",
|
||||
"quick": {
|
||||
"analytics": {
|
||||
"label": "Analytics",
|
||||
"desc": "Stats & AI"
|
||||
},
|
||||
"subscription": {
|
||||
"label": "Subscription",
|
||||
"desc": "Manage your plan"
|
||||
},
|
||||
"display": {
|
||||
"label": "Display",
|
||||
"desc": "Waiting room screen"
|
||||
},
|
||||
"help": {
|
||||
"label": "Help",
|
||||
"desc": "Help center & FAQ"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queue": {
|
||||
"metaTitle": "Queue management — QueueMed",
|
||||
|
|
@ -120,7 +302,49 @@
|
|||
"waiting": "Waiting",
|
||||
"called": "Called",
|
||||
"inConsultation": "In consultation",
|
||||
"addPrintedTicket": "Add printed ticket"
|
||||
"addPrintedTicket": "Add printed ticket",
|
||||
"metaDescription": "Manage your practice queue in real time.",
|
||||
"openShort": "Open",
|
||||
"closeShort": "Close",
|
||||
"clinicNotFound": "Practice not found.",
|
||||
"displayScreen": "Display screen",
|
||||
"headerCounts": "{{waiting}} waiting · {{called}} called",
|
||||
"actions": "Actions",
|
||||
"printTicket": "Print a ticket",
|
||||
"resetQueue": "Reset queue",
|
||||
"resetConfirm": "Are you sure? The entire queue will be cleared.",
|
||||
"resetYes": "Yes, reset",
|
||||
"qrCode": "QR Code",
|
||||
"qrAlt": "Practice QR code",
|
||||
"qrExpires": "Expires",
|
||||
"qrRenew": "Renew",
|
||||
"qrPoster": "Poster",
|
||||
"statsTitle": "Statistics",
|
||||
"statsAvgConsult": "Avg consult.",
|
||||
"statsAvgConsultValue": "~{{minutes}} min",
|
||||
"queueListTitle": "Queue",
|
||||
"patientCount": "{{count}} patient(s)",
|
||||
"openToWelcome": "Open the queue to start welcoming patients.",
|
||||
"patientFallback": "Patient #{{number}}",
|
||||
"printed": "Printed",
|
||||
"posShort": "Pos.",
|
||||
"minShort": "min",
|
||||
"callThisPatient": "Call this patient",
|
||||
"endConsultation": "End consultation",
|
||||
"markAbsentTitle": "Mark as absent",
|
||||
"statusWaiting": "Waiting",
|
||||
"statusCalled": "Called",
|
||||
"statusInConsultation": "In consult.",
|
||||
"statusDone": "Done",
|
||||
"statusAbsent": "Absent",
|
||||
"statusCanceled": "Canceled",
|
||||
"toastTicketCalled": "Ticket #{{number}} called",
|
||||
"toastPatientAbsent": "Patient marked absent",
|
||||
"toastConsultDone": "Consultation completed",
|
||||
"toastPatientCalled": "Patient called",
|
||||
"toastQueueReset": "Queue reset",
|
||||
"toastTicketCreated": "Ticket #{{number}} created",
|
||||
"toastQrRegenerated": "QR regenerated"
|
||||
},
|
||||
"patient": {
|
||||
"metaTitle": "Your spot — QueueMed",
|
||||
|
|
@ -132,17 +356,63 @@
|
|||
"phone": "Phone",
|
||||
"whatsappOptional": "WhatsApp (optional)",
|
||||
"joining": "Joining…",
|
||||
"youAreCalled": "It's your turn!",
|
||||
"youAreCalled": "It's your turn",
|
||||
"pleaseGoTo": "Please go to reception",
|
||||
"leaveQueue": "Leave queue",
|
||||
"thanksForVisit": "Thanks for your visit"
|
||||
"thanksForVisit": "Thanks for your visit.",
|
||||
"metaDescription": "Track your position in the waiting queue in real time.",
|
||||
"minutesFull": "minutes",
|
||||
"calledDesc": "Please go to the consultation room immediately.",
|
||||
"inConsult": "In consultation",
|
||||
"inConsultDesc": "You are currently with your doctor.",
|
||||
"consultDone": "Consultation completed",
|
||||
"seeYouSoon": "See you soon!",
|
||||
"ticketClosed": "Ticket closed",
|
||||
"markedAbsentDesc": "You were marked absent. Rescan the QR at reception to rejoin.",
|
||||
"ticketCanceledDesc": "Your ticket has been canceled.",
|
||||
"ticketNotFound": "Ticket not found",
|
||||
"ticketNotFoundDesc": "This link is invalid or has expired. Please rescan the QR code at reception.",
|
||||
"yourTicket": "Your ticket",
|
||||
"anonymousPatient": "Anonymous patient",
|
||||
"position": "Position",
|
||||
"outOf": "of {{count}}",
|
||||
"wait": "Wait",
|
||||
"currentPatient": "Current patient",
|
||||
"keepPageOpen": "Keep this page open. You'll be notified when your turn approaches.",
|
||||
"cancelConfirm": "Cancel your ticket? You'll have to rescan the QR to come back.",
|
||||
"cancelMyTicket": "Cancel my ticket",
|
||||
"joinedAt": "Joined at {{time}}",
|
||||
"refresh": "Refresh",
|
||||
"notifTitle": "It's your turn!",
|
||||
"notifBody": "Please go to the consultation room.",
|
||||
"toastApproaching": "You're up next — get ready!",
|
||||
"toastTicketCanceled": "Ticket canceled"
|
||||
},
|
||||
"display": {
|
||||
"metaTitle": "Display screen — QueueMed",
|
||||
"nowCalling": "Now calling",
|
||||
"ticket": "Ticket",
|
||||
"waiting": "waiting",
|
||||
"queueClosed": "Queue closed"
|
||||
"queueClosed": "Queue closed",
|
||||
"metaDescription": "Real-time display of the practice waiting queue.",
|
||||
"clinicNotFound": "Practice not found",
|
||||
"clinicNotFoundDesc": "Check the URL or contact the doctor.",
|
||||
"brandTagline": "QueueMed — Live queue",
|
||||
"live": "Live",
|
||||
"reconnecting": "Reconnecting...",
|
||||
"patientCalled": "Patient called",
|
||||
"consultationRoom": "Consultation room",
|
||||
"noPatientCalled": "No patient called",
|
||||
"upcoming": "Upcoming",
|
||||
"waitingCount": "{{count}} waiting",
|
||||
"statusOpen": "OPEN",
|
||||
"statusClosed": "CLOSED",
|
||||
"noWaiting": "No patients waiting",
|
||||
"anonymousPatient": "Anonymous patient",
|
||||
"minShort": "min",
|
||||
"position": "Position",
|
||||
"nextLabel": "NEXT",
|
||||
"ticker": "✨ Welcome to {{clinic}} — Scan the QR code at reception to join the online queue — Track your position in real time on your phone — You'll be notified when your turn approaches"
|
||||
},
|
||||
"analytics": {
|
||||
"metaTitle": "Analytics — QueueMed",
|
||||
|
|
@ -155,7 +425,43 @@
|
|||
"byHour": "By hour",
|
||||
"byDay": "By day",
|
||||
"exportCsv": "Export CSV",
|
||||
"recommendations": "AI Recommendations"
|
||||
"recommendations": "AI Recommendations",
|
||||
"metaDescription": "Patient flow statistics, wait times and AI recommendations for your medical practice.",
|
||||
"headerSubtitle": "Patient flow, wait times and AI recommendations.",
|
||||
"recommendationsSubtitle": "Optimisations identified for the selected period.",
|
||||
"period": "Period",
|
||||
"clinic": "Practice",
|
||||
"allClinics": "All",
|
||||
"daysLabel": "{{count}} days",
|
||||
"hourSuffix": "h",
|
||||
"minShort": "min",
|
||||
"kpiJoined": "Patients joined",
|
||||
"kpiServed": "Served",
|
||||
"kpiAbsent": "No-shows",
|
||||
"kpiAvgWait": "Avg wait",
|
||||
"kpiAvgConsultation": "Avg cons.",
|
||||
"flowJoined": "Joined",
|
||||
"flowServed": "Served",
|
||||
"flowAbsent": "No-shows",
|
||||
"chartByHour": "Patient flow by hour",
|
||||
"chartByDay": "Patient flow by day",
|
||||
"chartFlow": "Patient flow",
|
||||
"chartAvgWait": "Average wait time",
|
||||
"peakHour": "Peak hour:",
|
||||
"peakDay": "Busiest day:",
|
||||
"minutesOnAverage": "minutes on average",
|
||||
"consultationLabel": "Consultation",
|
||||
"totalLabel": "Total",
|
||||
"daySun": "Sun",
|
||||
"dayMon": "Mon",
|
||||
"dayTue": "Tue",
|
||||
"dayWed": "Wed",
|
||||
"dayThu": "Thu",
|
||||
"dayFri": "Fri",
|
||||
"daySat": "Sat",
|
||||
"toastNoClinic": "No practice selected",
|
||||
"toastExportFailed": "Export failed",
|
||||
"toastExportSuccess": "CSV exported"
|
||||
},
|
||||
"clinicSettings": {
|
||||
"metaTitle": "Practice settings — QueueMed",
|
||||
|
|
@ -163,7 +469,38 @@
|
|||
"general": "General",
|
||||
"openingHours": "Opening hours",
|
||||
"whatsapp": "WhatsApp",
|
||||
"save": "Save"
|
||||
"save": "Save",
|
||||
"metaDescription": "Customise the patient experience and queue management for your practice.",
|
||||
"subtitle": "Customise the patient experience and queue management",
|
||||
"openingHoursHelp": "Shown to patients on the queue page.",
|
||||
"saveButton": "Save settings",
|
||||
"selectClinic": "Select a practice",
|
||||
"welcomeMessage": "Welcome message",
|
||||
"welcomeMessageHelp": "Shown to patients when they join the queue. Leave empty to hide the message.",
|
||||
"welcomeMessagePlaceholder": "E.g. Welcome to Dr Smith's practice. Please wait, we'll call you as soon as possible.",
|
||||
"patientLanguage": "Patient interface language",
|
||||
"patientLanguageHelp": "Language shown on the patient screen and in WhatsApp messages.",
|
||||
"queueSettings": "Queue settings",
|
||||
"avgConsultation": "Avg consultation duration",
|
||||
"maxQueueSize": "Max queue size",
|
||||
"autoAbsentTimer": "Auto no-show timer",
|
||||
"patients": "patients",
|
||||
"minShort": "min",
|
||||
"open": "Open",
|
||||
"closed": "Closed",
|
||||
"openingTime": "Opening time",
|
||||
"closingTime": "Closing time",
|
||||
"timeSeparator": "to",
|
||||
"autoAbsentDisabled": "Disabled — the doctor manually marks no-shows",
|
||||
"autoAbsentEnabled": "Patient is marked as no-show after {{minutes}} min without response",
|
||||
"dayMon": "Monday",
|
||||
"dayTue": "Tuesday",
|
||||
"dayWed": "Wednesday",
|
||||
"dayThu": "Thursday",
|
||||
"dayFri": "Friday",
|
||||
"daySat": "Saturday",
|
||||
"daySun": "Sunday",
|
||||
"toastSaved": "Settings saved"
|
||||
},
|
||||
"whatsapp": {
|
||||
"metaTitle": "WhatsApp — QueueMed",
|
||||
|
|
@ -172,7 +509,49 @@
|
|||
"disconnect": "Disconnect",
|
||||
"scanQr": "Scan this QR code with WhatsApp",
|
||||
"connected": "Connected",
|
||||
"disconnected": "Disconnected"
|
||||
"disconnected": "Disconnected",
|
||||
"metaDescription": "Connect WhatsApp to your practice to send automatic notifications to your patients.",
|
||||
"headerTitle": "WhatsApp notifications",
|
||||
"headerSubtitle": "Connect WhatsApp to send automatic alerts to your patients",
|
||||
"statusDisconnected": "Disconnected",
|
||||
"statusConnecting": "Connecting…",
|
||||
"statusQrReady": "Waiting for scan",
|
||||
"statusConnected": "Connected",
|
||||
"disclaimerNote": "Note:",
|
||||
"disclaimerBody": "This feature uses WhatsApp Web (unofficial protocol). Keep sending below 500 messages/day to avoid any risk of restriction. A personal or business WhatsApp number is required.",
|
||||
"clinic": "Practice",
|
||||
"connectionStatus": "Connection status",
|
||||
"qrAltText": "WhatsApp QR code",
|
||||
"howToScan": "How to scan",
|
||||
"scanStep1": "1. Open WhatsApp on your phone",
|
||||
"scanStep2": "2. Tap ⋮ → Linked devices",
|
||||
"scanStep3": "3. Tap Link a device",
|
||||
"scanStep4": "4. Scan this QR code",
|
||||
"refreshStatus": "Refresh status",
|
||||
"connectedTitle": "WhatsApp connected",
|
||||
"connectedBody": "Automatic notifications are active for this practice.",
|
||||
"notConnectedTitle": "Not connected",
|
||||
"notConnectedBody": "Click \"Connect\" to generate a QR code to scan.",
|
||||
"newQr": "New QR code",
|
||||
"cancel": "Cancel",
|
||||
"testMessage": "Test message",
|
||||
"testMessageHelp": "Send a test message to confirm the connection is working.",
|
||||
"testPhonePlaceholder": "International number (e.g. 33612345678)",
|
||||
"testPhoneLabel": "Phone number",
|
||||
"send": "Send",
|
||||
"phoneFormatHint": "Enter the number without the + (e.g. 33612345678 for +33 6 12 34 56 78)",
|
||||
"howItWorks": "How it works",
|
||||
"step1Title": "Sign-up",
|
||||
"step1Desc": "The patient enters their WhatsApp number when signing up via QR code",
|
||||
"step2Title": "Almost-up alert",
|
||||
"step2Desc": "When 2 patients remain ahead, they receive an alert message",
|
||||
"step3Title": "It's their turn",
|
||||
"step3Desc": "When the doctor calls them, they receive a message immediately",
|
||||
"toastQrGenerated": "QR code generated — scan with WhatsApp",
|
||||
"toastConnected": "WhatsApp connected!",
|
||||
"toastDisconnected": "WhatsApp session disconnected",
|
||||
"toastTestSent": "Test message sent!",
|
||||
"toastTestFailed": "Failed: {{error}}"
|
||||
},
|
||||
"onboarding": {
|
||||
"metaTitle": "Welcome — QueueMed",
|
||||
|
|
@ -180,7 +559,59 @@
|
|||
"step1": "Create your practice",
|
||||
"step2": "Print your QR code",
|
||||
"step3": "Open the queue",
|
||||
"finish": "Finish"
|
||||
"finish": "Finish",
|
||||
"metaDescription": "Set up your first QueueMed practice in 2 minutes.",
|
||||
"headerPart1": "Initial",
|
||||
"headerAccent": "setup",
|
||||
"headerSubtitle": "Set up your first practice in 2 minutes.",
|
||||
"steps": {
|
||||
"s1": {
|
||||
"title": "Your practice",
|
||||
"description": "Provide basic information and queue parameters."
|
||||
},
|
||||
"s2": {
|
||||
"title": "Your QR code",
|
||||
"description": "Print or preview the poster to display at reception."
|
||||
},
|
||||
"s3": {
|
||||
"title": "All set!",
|
||||
"description": "Here are the next steps to get started."
|
||||
}
|
||||
},
|
||||
"fieldName": "Practice name",
|
||||
"fieldAddress": "Address",
|
||||
"fieldPhone": "Phone",
|
||||
"optional": "(optional)",
|
||||
"placeholderName": "e.g. Dr. Smith Practice",
|
||||
"placeholderAddress": "12 Main Street, London",
|
||||
"placeholderPhone": "+44 20 1234 5678",
|
||||
"queueSettings": "Queue settings",
|
||||
"avgConsultation": "Average consultation duration",
|
||||
"avgConsultationHelp": "Used to estimate patient wait times.",
|
||||
"maxQueueSize": "Maximum queue size",
|
||||
"maxQueueHelp": "Beyond this, new patients won't be able to join.",
|
||||
"minutesShort": "min",
|
||||
"patientsShort": "pat.",
|
||||
"qrIntroPart1": "Here is the QR code for",
|
||||
"qrIntroPart2": ". Print it and place it at the practice entrance so your patients can join the queue.",
|
||||
"qrAlt": "QR Code",
|
||||
"qrUnavailable": "QR unavailable",
|
||||
"viewPoster": "View / print poster",
|
||||
"continue": "Continue",
|
||||
"doneTitle": "Practice configured!",
|
||||
"donePart1": "Practice",
|
||||
"donePart2": "is ready. Here are your next steps to get started.",
|
||||
"next1": "Print the QR code and display it at reception",
|
||||
"next2": "Set up the display screen on your tablet or monitor",
|
||||
"next3": "Open the queue from the dashboard at the start of each day",
|
||||
"creating": "Creating...",
|
||||
"createClinic": "Create practice",
|
||||
"back": "Back",
|
||||
"viewQueue": "View queue",
|
||||
"dashboard": "Dashboard",
|
||||
"skip": "Skip for now",
|
||||
"toastCreated": "Practice created successfully!",
|
||||
"errorNameRequired": "Practice name is required."
|
||||
},
|
||||
"subscription": {
|
||||
"metaTitle": "Subscription — QueueMed",
|
||||
|
|
@ -189,11 +620,357 @@
|
|||
"active": "Active",
|
||||
"expired": "Expired",
|
||||
"daysLeft": "{{days}} days left",
|
||||
"upgrade": "Upgrade plan"
|
||||
"upgrade": "Upgrade plan",
|
||||
"metaDescription": "Manage your QueueMed subscription and trial period.",
|
||||
"subtitle": "Manage your plan and trial period.",
|
||||
"daysCount_one": "{{count}} day",
|
||||
"daysCount_other": "{{count}} days",
|
||||
"currentPlan": "Current plan",
|
||||
"currentPlanLabel": "Current plan",
|
||||
"expiredMessage": "Your subscription has expired. Renew to keep using QueueMed.",
|
||||
"freeTrial": "Free trial",
|
||||
"nextRenewal": "Next renewal",
|
||||
"in": "in",
|
||||
"untilDate": "(until {{date}})",
|
||||
"subscribeNow": "Subscribe now",
|
||||
"dayN": "Day {{day}}",
|
||||
"choosePlan": "Choose your plan",
|
||||
"popular": "Popular",
|
||||
"current": "Current",
|
||||
"automaticTrial": "Automatic trial",
|
||||
"subscribe": "Subscribe",
|
||||
"commitmentTitle": "Our commitment",
|
||||
"commitmentBody": "Cancel any time. Data hosted in France. GDPR compliant. Free assisted migration and setup.",
|
||||
"toastRedirect": "Redirecting to {{plan}} checkout…",
|
||||
"toastRedirectDescription": "Stripe integration will be enabled soon.",
|
||||
"plans": {
|
||||
"trial": {
|
||||
"name": "Trial",
|
||||
"period": "30 days",
|
||||
"description": "Try QueueMed with no commitment.",
|
||||
"features": {
|
||||
"0": "1 practice",
|
||||
"1": "Unlimited patients",
|
||||
"2": "Basic statistics",
|
||||
"3": "Email support"
|
||||
}
|
||||
},
|
||||
"basic": {
|
||||
"name": "Basic",
|
||||
"period": "/ month",
|
||||
"description": "For a single practice.",
|
||||
"features": {
|
||||
"0": "1 practice",
|
||||
"1": "Unlimited patients",
|
||||
"2": "Display screen",
|
||||
"3": "Advanced statistics",
|
||||
"4": "Priority support"
|
||||
}
|
||||
},
|
||||
"pro": {
|
||||
"name": "Pro",
|
||||
"period": "/ month",
|
||||
"description": "Medical centres and multi-practitioner teams.",
|
||||
"features": {
|
||||
"0": "Unlimited practices",
|
||||
"1": "Multi-practitioner",
|
||||
"2": "AI recommendations",
|
||||
"3": "CSV export",
|
||||
"4": "Phone support"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"help": {
|
||||
"metaTitle": "Help — QueueMed",
|
||||
"title": "Help center",
|
||||
"subtitle": "Find quick answers to your questions"
|
||||
"subtitle": "Find quick answers to your questions",
|
||||
"metaDescription": "QueueMed help centre: find quick answers to your questions.",
|
||||
"headerCenter": "Help",
|
||||
"headerHelp": "center",
|
||||
"headerSubtitle": "Find quick answers to your questions about QueueMed.",
|
||||
"searchPlaceholder": "Search a question...",
|
||||
"noResults": "No question matches your search.",
|
||||
"contactTitle": "Can't find your answer?",
|
||||
"contactBody": "Our team is available to help you set up and use QueueMed in your practice.",
|
||||
"dashboardButton": "Dashboard",
|
||||
"contactButton": "Contact support",
|
||||
"categories": {
|
||||
"all": "All",
|
||||
"gettingStarted": "Getting started",
|
||||
"queueManagement": "Queue management",
|
||||
"patientExperience": "Patient experience",
|
||||
"displayScreen": "Display screen",
|
||||
"subscription": "Subscription",
|
||||
"technical": "Technical"
|
||||
},
|
||||
"quickLinks": {
|
||||
"gettingStarted": "Getting started",
|
||||
"patients": "Patients",
|
||||
"display": "Display",
|
||||
"subscription": "Subscription"
|
||||
},
|
||||
"faq": {
|
||||
"createClinic": {
|
||||
"q": "How do I create my first practice?",
|
||||
"a": "On your first sign-in, follow the setup wizard by clicking 'Start setup' from the dashboard. Enter the name, optional address and queue settings (average consultation duration, maximum size). A unique QR code is generated automatically."
|
||||
},
|
||||
"printPoster": {
|
||||
"q": "How do I print my QR code poster?",
|
||||
"a": "From a practice's management page, click 'QR poster'. The page shows an A4 poster ready to print. Use coloured paper if possible, laminate the poster and place it at eye level at the entrance of the practice."
|
||||
},
|
||||
"setupTime": {
|
||||
"q": "How long does it take to set up QueueMed?",
|
||||
"a": "About 2 minutes: create your account, set up your first practice and print the QR code. You can welcome your first patients in less than 5 minutes total."
|
||||
},
|
||||
"openCloseQueue": {
|
||||
"q": "How do I open and close the queue?",
|
||||
"a": "On the 'Queue management' page, select your practice and click 'Open queue'. Patients can then join. At the end of the day, click 'Close queue' then 'Reset' to start fresh the next day."
|
||||
},
|
||||
"callNext": {
|
||||
"q": "How do I call the next patient?",
|
||||
"a": "Click 'Call next' in the management interface. The number is automatically shown on the waiting-room display screen and the patient receives a push notification on their phone."
|
||||
},
|
||||
"noShow": {
|
||||
"q": "What if a patient doesn't show up?",
|
||||
"a": "Click 'Absent' next to the patient's name. They are removed from the queue and the other patients' positions update automatically. The patient will need to rescan the QR code to rejoin."
|
||||
},
|
||||
"reorder": {
|
||||
"q": "Can I reorder patients?",
|
||||
"a": "Yes. In the queue list, drag and drop patients to change their order. Positions and waiting times recalculate live, and each patient receives the update on their phone."
|
||||
},
|
||||
"printedTicket": {
|
||||
"q": "How do I print a ticket for a patient without a smartphone?",
|
||||
"a": "In the management interface, click 'Add patient' then tick 'No smartphone'. A printable ticket opens in a new tab with the number and position. Hand it to the patient — they can follow their turn on the display screen."
|
||||
},
|
||||
"patientJoin": {
|
||||
"q": "How does a patient join the queue?",
|
||||
"a": "The patient opens their smartphone camera and scans the QR code displayed at reception. A link opens automatically — they tap it to join the queue. No app to install."
|
||||
},
|
||||
"patientLeave": {
|
||||
"q": "Can the patient leave the physical waiting room?",
|
||||
"a": "Yes, that's the main benefit of QueueMed. The patient keeps the page open on their phone and can step away. They receive a push notification when their turn approaches. Recommend they stay within 5 minutes of the practice."
|
||||
},
|
||||
"qrRotation": {
|
||||
"q": "Why does the QR code sometimes stop working?",
|
||||
"a": "The QR code rotates automatically at regular intervals (anti-cheat system) to prevent fraudulent sharing of the link outside the practice. If a patient gets an error, they just need to rescan the QR code at reception."
|
||||
},
|
||||
"notification": {
|
||||
"q": "Does the patient actually receive the notification?",
|
||||
"a": "On first access, the browser asks them for notification permission. If they accept, they'll receive a push notification + vibration when their turn is called. Otherwise, the page stays up to date in real time as long as it's open."
|
||||
},
|
||||
"displaySetup": {
|
||||
"q": "How do I configure the display screen?",
|
||||
"a": "On your practice's page, copy the 'Display screen link' (/display/:clinicId). Open this link on your waiting-room tablet or monitor, then enable full-screen mode (F11 on PC). The screen updates automatically via WebSocket."
|
||||
},
|
||||
"displayHardware": {
|
||||
"q": "What hardware should I use for the display?",
|
||||
"a": "Any tablet, monitor or TV connected to the internet with a modern browser (Chrome, Safari, Edge). A simple 80€ Android tablet does the job perfectly."
|
||||
},
|
||||
"internetOutage": {
|
||||
"q": "What happens if the internet goes down?",
|
||||
"a": "The display screen shows an orange 'Reconnecting...' indicator. Patients already in the queue keep their position. As soon as the connection is restored, syncing resumes automatically."
|
||||
},
|
||||
"trialDuration": {
|
||||
"q": "How long is the free trial?",
|
||||
"a": "The free trial lasts 30 days from your first sign-in. All features are available without restriction during this period, and you can create multiple practices and welcome an unlimited number of patients."
|
||||
},
|
||||
"afterTrial": {
|
||||
"q": "What happens after the free trial?",
|
||||
"a": "Access to management features (open queue, call patients, create practices) is blocked until you subscribe to a paid plan. Your data is preserved and patients can still see their position in active queues."
|
||||
},
|
||||
"cancelSub": {
|
||||
"q": "Can I cancel my subscription?",
|
||||
"a": "Yes, you can cancel any time from the 'Subscription' page in your dashboard. Access to paid features remains active until the end of the period already paid for."
|
||||
},
|
||||
"clinicCount": {
|
||||
"q": "How many practices can I manage?",
|
||||
"a": "The Solo plan includes 1 practice. The Pro plan allows up to 5 practices. The Practice plan includes unlimited practices and gives access to advanced statistics with AI recommendations."
|
||||
},
|
||||
"devices": {
|
||||
"q": "What devices does QueueMed work on?",
|
||||
"a": "QueueMed works on any device with a modern browser: iOS and Android smartphones, tablets, Windows / Mac / Linux computers. No app to install. Recommended: an up-to-date Chrome or Safari."
|
||||
},
|
||||
"dataSecurity": {
|
||||
"q": "Is my patient data secure?",
|
||||
"a": "Yes. Names and ticket numbers are encrypted in transit (HTTPS) and stored on servers hosted in France. No medical data is collected. Patients are identified only by an optional name."
|
||||
},
|
||||
"exportStats": {
|
||||
"q": "Can I export my statistics?",
|
||||
"a": "Yes. From the 'Analytics' page, click 'Export to CSV' to download the full consultation history. The file includes times, waiting durations and consultation durations for each patient."
|
||||
},
|
||||
"offline": {
|
||||
"q": "Does QueueMed work offline?",
|
||||
"a": "No, an internet connection is required for real-time synchronisation between the doctor, the display screen and patients. In case of an outage, the app resumes automatically once the connection returns."
|
||||
}
|
||||
}
|
||||
},
|
||||
"clinics": {
|
||||
"metaTitle": "My practices — QueueMed",
|
||||
"metaDescription": "Manage your practices, their QR codes and settings.",
|
||||
"title": "My practices",
|
||||
"subtitle": "Manage your practices, their QR codes and settings.",
|
||||
"newClinic": "New practice",
|
||||
"emptyTitle": "No practice yet",
|
||||
"emptySubtitle": "Create your first practice to get started.",
|
||||
"createClinic": "Create a practice",
|
||||
"statusOpen": "Open",
|
||||
"statusClosed": "Closed",
|
||||
"statCons": "Cons.",
|
||||
"statMax": "Max",
|
||||
"statQrRot": "QR rot.",
|
||||
"minutesShort": "min",
|
||||
"manageQueue": "Manage queue",
|
||||
"open": "Open",
|
||||
"close": "Close",
|
||||
"qr": "QR",
|
||||
"screen": "Screen",
|
||||
"editAction": "Edit",
|
||||
"dialogEditTitle": "Edit practice",
|
||||
"dialogCreateTitle": "New practice",
|
||||
"dialogDescription": "Set the information and waiting-room parameters.",
|
||||
"fieldName": "Practice name",
|
||||
"fieldAddress": "Address",
|
||||
"fieldPhone": "Phone",
|
||||
"fieldColor": "Color",
|
||||
"fieldAvgConsultation": "Average consultation duration",
|
||||
"fieldMaxQueue": "Max queue size",
|
||||
"fieldQrRotation": "QR rotation (anti-cheat)",
|
||||
"placeholderName": "e.g. Dr. Smith Practice",
|
||||
"placeholderAddress": "e.g. 12 Main Street, London",
|
||||
"placeholderPhone": "+44 20 1234 5678",
|
||||
"qrDisabled": "Disabled",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"create": "Create",
|
||||
"qrDialogTitle": "Practice QR code",
|
||||
"qrDialogDescription": "Display this QR at reception. Patients scan it to join the queue.",
|
||||
"posterA4": "A4 poster",
|
||||
"closeButton": "Close",
|
||||
"deleteDialogTitle": "Delete this practice?",
|
||||
"deleteDialogDescription": "This action cannot be undone. The entire queue and history will be lost.",
|
||||
"deletePermanently": "Delete permanently",
|
||||
"qrAlt": "QR code",
|
||||
"expiresOn": "Expires on",
|
||||
"regenerate": "Regenerate",
|
||||
"toastCreated": "Practice created!",
|
||||
"toastUpdated": "Practice updated",
|
||||
"toastDeleted": "Practice deleted",
|
||||
"toastQrRegenerated": "New QR code generated",
|
||||
"errorNameRequired": "Practice name is required (≥ 2 characters)."
|
||||
},
|
||||
"ticket": {
|
||||
"metaTitle": "Ticket — QueueMed",
|
||||
"metaDescription": "Printable waiting queue ticket.",
|
||||
"notFound": "Ticket not found",
|
||||
"notFoundDesc": "This ticket doesn't exist or has been deleted.",
|
||||
"printTicket": "Print ticket",
|
||||
"tipLabel": "Tip",
|
||||
"tipText": "print on A6 paper or fold in half. Hand this ticket to the patient; they can follow their turn on the display screen.",
|
||||
"subtitle": "Waiting queue ticket",
|
||||
"yourNumber": "Your number",
|
||||
"position": "Position",
|
||||
"wait": "Wait",
|
||||
"minShort": "min",
|
||||
"howItWorks": "How does it work?",
|
||||
"howItWorksDesc": "Watch the display screen in the room. When your number appears, please go to the consultation room immediately.",
|
||||
"issuedAt": "Issued on {{date}} at {{time}}"
|
||||
},
|
||||
"history": {
|
||||
"metaTitle": "Consultation history — QueueMed",
|
||||
"metaDescription": "View the full history and statistics of consultations for your medical practice.",
|
||||
"title": "Consultation history",
|
||||
"subtitle": "View the full history of your patients",
|
||||
"selectClinic": "Select a practice",
|
||||
"totalConsultations": "Total consultations",
|
||||
"avgDuration": "Average duration",
|
||||
"perConsultation": "per consultation",
|
||||
"presenceRate": "Attendance rate",
|
||||
"patientsPresent": "patients present",
|
||||
"topReason": "Top reason",
|
||||
"consultationsCount": "{{count}} consultations",
|
||||
"reasonsBreakdown": "Reason breakdown",
|
||||
"statsRange": "Stats period",
|
||||
"range7": "7 days",
|
||||
"range30": "30 days",
|
||||
"range90": "90 days",
|
||||
"range365": "1 year",
|
||||
"from": "From",
|
||||
"to": "To",
|
||||
"reason": "Reason",
|
||||
"allReasons": "All reasons",
|
||||
"clear": "Clear",
|
||||
"noResults": "No consultations found",
|
||||
"tryEditingFilters": "Try editing your filters",
|
||||
"colTicket": "Ticket",
|
||||
"colPatient": "Patient",
|
||||
"colReason": "Reason",
|
||||
"colDate": "Date",
|
||||
"colWait": "Wait",
|
||||
"colDuration": "Duration",
|
||||
"colStatus": "Status",
|
||||
"anonymous": "Anonymous",
|
||||
"minShort": "min",
|
||||
"pageInfo": "Page {{page}} of {{totalPages}} — {{total}} results",
|
||||
"previousPage": "Previous page",
|
||||
"nextPage": "Next page",
|
||||
"reasonConsultation": "Consultation",
|
||||
"reasonUrgence": "Urgent",
|
||||
"reasonCertificatScolaire": "School certificate",
|
||||
"reasonCertificatSportif": "Sports certificate",
|
||||
"reasonArretTravail": "Sick leave",
|
||||
"reasonAdministratif": "Administrative",
|
||||
"reasonAutre": "Other",
|
||||
"statusDone": "Completed",
|
||||
"statusAbsent": "No-show",
|
||||
"statusCanceled": "Cancelled"
|
||||
},
|
||||
"subscriptionBlocked": {
|
||||
"metaTitle": "Subscription expired — QueueMed",
|
||||
"metaDescription": "Your QueueMed trial has ended. Choose a plan to continue.",
|
||||
"title": "Subscription expired",
|
||||
"description": "Your free trial has ended. Choose a subscription to continue using the waiting room.",
|
||||
"cta": "Choose a subscription",
|
||||
"features": {
|
||||
"0": "Unlimited queue",
|
||||
"1": "Anti-cheat QR code",
|
||||
"2": "Real-time tracking",
|
||||
"3": "Advanced analytics"
|
||||
}
|
||||
},
|
||||
"qrPoster": {
|
||||
"metaTitle": "QR poster — QueueMed",
|
||||
"metaDescription": "Print your QueueMed QR code poster for the waiting room.",
|
||||
"notFoundTitle": "Practice not found",
|
||||
"notFoundBody": "This practice doesn't exist or doesn't belong to you.",
|
||||
"backToClinics": "Back to practices",
|
||||
"backToManagement": "Back to management",
|
||||
"refresh": "Refresh",
|
||||
"printPoster": "Print poster",
|
||||
"tipsTitle": "Printing tips:",
|
||||
"tipsBody": "use A4 paper, in colour if possible. Laminate the poster and place it at eye level at the entrance of the practice.",
|
||||
"tagline": "Virtual waiting room",
|
||||
"scanToJoin": "Scan to join the queue",
|
||||
"followInRealTime": "Track your position in real time on your phone.",
|
||||
"qrAlt": "Queue QR code",
|
||||
"qrUnavailable": "QR code unavailable",
|
||||
"noAppTitle": "No app to install",
|
||||
"noAppBody": "Works in your browser. Free for patients.",
|
||||
"noSmartphoneNote": "No smartphone? Ask for a printed ticket at reception.",
|
||||
"poweredBy": "Powered by QueueMed",
|
||||
"steps": {
|
||||
"scan": {
|
||||
"title": "Scan",
|
||||
"desc": "Point your camera at the QR code"
|
||||
},
|
||||
"join": {
|
||||
"title": "Join",
|
||||
"desc": "Tap the link and enter the queue"
|
||||
},
|
||||
"wait": {
|
||||
"title": "Wait",
|
||||
"desc": "You'll be alerted when your turn is near"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,146 @@
|
|||
"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"
|
||||
"trustedBy": "Plus de 200 cabinets nous font confiance",
|
||||
"nav": {
|
||||
"features": "Fonctionnalités",
|
||||
"how": "Fonctionnement",
|
||||
"pricing": "Tarifs",
|
||||
"help": "Aide",
|
||||
"login": "Connexion",
|
||||
"freeTrial": "Essai gratuit"
|
||||
},
|
||||
"heroBadge": "Salle d'attente virtuelle nouvelle génération",
|
||||
"heroH1Part1": "Vos patients",
|
||||
"heroH1Part2": "n'attendent plus,",
|
||||
"heroH1Part3": "ils",
|
||||
"heroH1Accent": "vivent",
|
||||
"heroDescription": "QueueMed transforme votre cabinet médical en une expérience fluide. QR code, suivi en temps réel, notifications, écran d'affichage — sans application à installer.",
|
||||
"heroStartTrial": "Démarrer l'essai gratuit (30j)",
|
||||
"heroSeeHow": "Voir comment ça marche",
|
||||
"heroCheck1": "Aucune carte bancaire",
|
||||
"heroCheck2": "Setup en 2 minutes",
|
||||
"heroCheck3": "Données en France",
|
||||
"mockCurrent": "Patient en cours",
|
||||
"mockRoom": "Salle 2 — Dr. Martin",
|
||||
"mockUpcoming": "Prochains",
|
||||
"mockAnonymous": "Patient anonyme",
|
||||
"mockMin": "min",
|
||||
"featuresKicker": "Fonctionnalités",
|
||||
"featuresTitlePart1": "Tout ce dont votre cabinet",
|
||||
"featuresTitleAccent": "a besoin",
|
||||
"featuresSubtitle": "Une plateforme pensée par et pour les médecins. Élégante, rapide, conforme.",
|
||||
"features": {
|
||||
"qrCode": {
|
||||
"title": "QR code rotatif",
|
||||
"description": "Vos patients scannent un QR à l'entrée — token tournant anti-triche, aucune appli à installer."
|
||||
},
|
||||
"realtime": {
|
||||
"title": "Position en temps réel",
|
||||
"description": "Chaque patient voit sa position et son temps d'attente estimé, mis à jour en direct via WebSocket."
|
||||
},
|
||||
"alerts": {
|
||||
"title": "Alertes intelligentes",
|
||||
"description": "Notification push quand le tour approche — vos patients peuvent quitter la salle d'attente."
|
||||
},
|
||||
"displayScreen": {
|
||||
"title": "Écran de salle",
|
||||
"description": "Affichage plein écran sur tablette avec ticker, numéro appelé géant, file en direct."
|
||||
},
|
||||
"stats": {
|
||||
"title": "Statistiques précises",
|
||||
"description": "Affluence par heure, jour, durée moyenne. Recommandations IA pour optimiser votre cabinet."
|
||||
},
|
||||
"gdpr": {
|
||||
"title": "RGPD & souverain",
|
||||
"description": "Données hébergées en France. Aucun tracking patient. Sécurité bancaire (TLS, JWT, bcrypt)."
|
||||
}
|
||||
},
|
||||
"howKicker": "Comment ça marche",
|
||||
"howTitleAccent": "3 étapes",
|
||||
"howTitleRest": "et c'est lancé",
|
||||
"steps": {
|
||||
"step1": {
|
||||
"title": "Configurez votre cabinet",
|
||||
"desc": "2 minutes pour créer votre file. Imprimez le QR code et placez-le à l'accueil."
|
||||
},
|
||||
"step2": {
|
||||
"title": "Vos patients scannent",
|
||||
"desc": "Ils ouvrent l'appareil photo, scannent et rejoignent la file en un clic."
|
||||
},
|
||||
"step3": {
|
||||
"title": "Vous appelez le suivant",
|
||||
"desc": "Un clic depuis votre tableau, le patient est notifié, l'écran s'actualise."
|
||||
}
|
||||
},
|
||||
"pricingKicker": "Tarifs",
|
||||
"pricingTitlePart1": "Simples et",
|
||||
"pricingTitleAccent": "transparents",
|
||||
"pricingSubtitle": "30 jours d'essai gratuit, sans engagement, sans carte bancaire.",
|
||||
"pricingPopular": "Populaire",
|
||||
"pricing": {
|
||||
"trial": {
|
||||
"name": "Essai",
|
||||
"price": "Gratuit",
|
||||
"period": "30 jours",
|
||||
"description": "Toutes les fonctionnalités, sans carte bancaire.",
|
||||
"feature1": "1 cabinet",
|
||||
"feature2": "Patients illimités",
|
||||
"feature3": "Statistiques de base",
|
||||
"feature4": "Support email",
|
||||
"cta": "Démarrer l'essai"
|
||||
},
|
||||
"basic": {
|
||||
"name": "Basic",
|
||||
"price": "29€",
|
||||
"period": "/ mois",
|
||||
"description": "Pour un cabinet individuel.",
|
||||
"feature1": "1 cabinet",
|
||||
"feature2": "Patients illimités",
|
||||
"feature3": "Écran d'affichage",
|
||||
"feature4": "Statistiques avancées",
|
||||
"feature5": "Support prioritaire",
|
||||
"cta": "S'abonner"
|
||||
},
|
||||
"pro": {
|
||||
"name": "Pro",
|
||||
"price": "79€",
|
||||
"period": "/ mois",
|
||||
"description": "Pour les centres médicaux.",
|
||||
"feature1": "Cabinets illimités",
|
||||
"feature2": "Multi-praticiens",
|
||||
"feature3": "Recommandations IA",
|
||||
"feature4": "Export CSV avancé",
|
||||
"feature5": "Support téléphonique",
|
||||
"cta": "S'abonner"
|
||||
}
|
||||
},
|
||||
"testimonialsKicker": "Témoignages",
|
||||
"testimonialsTitlePart1": "Approuvé par",
|
||||
"testimonialsTitleAccent": "200+ médecins",
|
||||
"testimonials": {
|
||||
"t1": {
|
||||
"name": "Dr. Marie Dubois",
|
||||
"role": "Médecin généraliste, Lyon",
|
||||
"quote": "Mes patients adorent. Plus de salle d'attente bondée, plus de stress. Je gagne 1h par jour facilement."
|
||||
},
|
||||
"t2": {
|
||||
"name": "Dr. Karim Benali",
|
||||
"role": "Pédiatre, Marseille",
|
||||
"quote": "Setup en 5 minutes. Le QR rotatif évite les abus et l'écran d'affichage est parfait pour ma salle."
|
||||
},
|
||||
"t3": {
|
||||
"name": "Dr. Sophie Lefèvre",
|
||||
"role": "Dentiste, Bordeaux",
|
||||
"quote": "Les statistiques m'ont permis d'optimiser mes plages horaires. ROI évident dès le premier mois."
|
||||
}
|
||||
},
|
||||
"ctaTitle": "Prêt à transformer votre cabinet ?",
|
||||
"ctaSubtitle": "30 jours d'essai gratuit. Aucune carte bancaire. Setup en 2 minutes.",
|
||||
"ctaButton": "Démarrer maintenant",
|
||||
"footerTagline": "Salle d'attente virtuelle",
|
||||
"footerHelp": "Aide",
|
||||
"footerContact": "Contact"
|
||||
},
|
||||
"login": {
|
||||
"metaTitle": "Connexion — QueueMed",
|
||||
|
|
@ -106,7 +245,50 @@
|
|||
"kpiPatients": "Patients aujourd'hui",
|
||||
"kpiAvgWait": "Attente moyenne",
|
||||
"noClinic": "Vous n'avez pas encore de cabinet.",
|
||||
"createClinic": "Créer un cabinet"
|
||||
"createClinic": "Créer un cabinet",
|
||||
"metaDescription": "Vue d'ensemble de vos cabinets, KPIs et accès rapides QueueMed.",
|
||||
"fallbackDoctor": "Docteur",
|
||||
"hello": "Bonjour",
|
||||
"dayStarts": "Votre journée commence ici.",
|
||||
"trialDaysLeft_one": "Essai gratuit — {{count}} jour restant",
|
||||
"trialDaysLeft_other": "Essai gratuit — {{count}} jours restants",
|
||||
"trialExpired": "Essai expiré",
|
||||
"subscribe": "S'abonner",
|
||||
"subscriptionExpired": "Abonnement expiré",
|
||||
"renew": "Renouveler",
|
||||
"kpiActiveClinics": "Cabinets actifs",
|
||||
"kpiPatients7d": "Patients (7j)",
|
||||
"kpiAvgWaitShort": "Attente moy.",
|
||||
"kpiPlan": "Plan",
|
||||
"minutesShort": "min",
|
||||
"yourClinics": "Vos cabinets",
|
||||
"manage": "Gérer",
|
||||
"welcomeTitle": "Bienvenue sur QueueMed !",
|
||||
"welcomeSubtitle": "Configurez votre premier cabinet en 2 minutes avec notre assistant.",
|
||||
"startSetup": "Démarrer la configuration",
|
||||
"createManually": "Créer manuellement",
|
||||
"statusOpen": "Ouvert",
|
||||
"statusClosed": "Fermé",
|
||||
"minPerPatient": "min/patient",
|
||||
"quickAccess": "Accès rapide",
|
||||
"quick": {
|
||||
"analytics": {
|
||||
"label": "Analytics",
|
||||
"desc": "Statistiques & IA"
|
||||
},
|
||||
"subscription": {
|
||||
"label": "Abonnement",
|
||||
"desc": "Gérer votre plan"
|
||||
},
|
||||
"display": {
|
||||
"label": "Affichage",
|
||||
"desc": "Écran salle d'attente"
|
||||
},
|
||||
"help": {
|
||||
"label": "Aide",
|
||||
"desc": "Centre d'aide & FAQ"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queue": {
|
||||
"metaTitle": "Gestion file d'attente — QueueMed",
|
||||
|
|
@ -120,7 +302,49 @@
|
|||
"waiting": "En attente",
|
||||
"called": "Appelé",
|
||||
"inConsultation": "En consultation",
|
||||
"addPrintedTicket": "Ajouter un ticket imprimé"
|
||||
"addPrintedTicket": "Ajouter un ticket imprimé",
|
||||
"metaDescription": "Gérez la file d'attente de votre cabinet en temps réel.",
|
||||
"openShort": "Ouvrir",
|
||||
"closeShort": "Fermer",
|
||||
"clinicNotFound": "Cabinet introuvable.",
|
||||
"displayScreen": "Écran d'affichage",
|
||||
"headerCounts": "{{waiting}} en attente · {{called}} appelé(s)",
|
||||
"actions": "Actions",
|
||||
"printTicket": "Imprimer un ticket",
|
||||
"resetQueue": "Réinitialiser la file",
|
||||
"resetConfirm": "Êtes-vous sûr ? Toute la file sera effacée.",
|
||||
"resetYes": "Oui, réinitialiser",
|
||||
"qrCode": "QR Code",
|
||||
"qrAlt": "QR code du cabinet",
|
||||
"qrExpires": "Expire",
|
||||
"qrRenew": "Renouveler",
|
||||
"qrPoster": "Affiche",
|
||||
"statsTitle": "Statistiques",
|
||||
"statsAvgConsult": "Cons. moy.",
|
||||
"statsAvgConsultValue": "~{{minutes}} min",
|
||||
"queueListTitle": "File d'attente",
|
||||
"patientCount": "{{count}} patient(s)",
|
||||
"openToWelcome": "Ouvrez la file pour commencer à accueillir des patients.",
|
||||
"patientFallback": "Patient #{{number}}",
|
||||
"printed": "Imprimé",
|
||||
"posShort": "Pos.",
|
||||
"minShort": "min",
|
||||
"callThisPatient": "Appeler ce patient",
|
||||
"endConsultation": "Terminer la consultation",
|
||||
"markAbsentTitle": "Marquer absent",
|
||||
"statusWaiting": "En attente",
|
||||
"statusCalled": "Appelé",
|
||||
"statusInConsultation": "En consult.",
|
||||
"statusDone": "Terminé",
|
||||
"statusAbsent": "Absent",
|
||||
"statusCanceled": "Annulé",
|
||||
"toastTicketCalled": "Ticket #{{number}} appelé",
|
||||
"toastPatientAbsent": "Patient marqué absent",
|
||||
"toastConsultDone": "Consultation terminée",
|
||||
"toastPatientCalled": "Patient appelé",
|
||||
"toastQueueReset": "File réinitialisée",
|
||||
"toastTicketCreated": "Ticket #{{number}} créé",
|
||||
"toastQrRegenerated": "QR régénéré"
|
||||
},
|
||||
"patient": {
|
||||
"metaTitle": "Ma place — QueueMed",
|
||||
|
|
@ -132,17 +356,63 @@
|
|||
"phone": "Téléphone",
|
||||
"whatsappOptional": "WhatsApp (optionnel)",
|
||||
"joining": "Inscription en cours…",
|
||||
"youAreCalled": "C'est à vous !",
|
||||
"youAreCalled": "C'est votre tour",
|
||||
"pleaseGoTo": "Présentez-vous à l'accueil",
|
||||
"leaveQueue": "Quitter la file",
|
||||
"thanksForVisit": "Merci de votre visite"
|
||||
"thanksForVisit": "Merci de votre visite.",
|
||||
"metaDescription": "Suivez votre position dans la file d'attente en temps réel.",
|
||||
"minutesFull": "minutes",
|
||||
"calledDesc": "Présentez-vous immédiatement à la salle de consultation.",
|
||||
"inConsult": "En consultation",
|
||||
"inConsultDesc": "Vous êtes actuellement avec votre médecin.",
|
||||
"consultDone": "Consultation terminée",
|
||||
"seeYouSoon": "À bientôt !",
|
||||
"ticketClosed": "Ticket clos",
|
||||
"markedAbsentDesc": "Vous avez été marqué absent. Rescannez le QR à l'accueil pour rejoindre à nouveau.",
|
||||
"ticketCanceledDesc": "Votre ticket a été annulé.",
|
||||
"ticketNotFound": "Ticket introuvable",
|
||||
"ticketNotFoundDesc": "Ce lien est invalide ou a expiré. Veuillez rescanner le QR code à l'accueil.",
|
||||
"yourTicket": "Votre ticket",
|
||||
"anonymousPatient": "Patient anonyme",
|
||||
"position": "Position",
|
||||
"outOf": "sur {{count}}",
|
||||
"wait": "Attente",
|
||||
"currentPatient": "Patient en cours",
|
||||
"keepPageOpen": "Gardez cette page ouverte. Vous serez notifié quand votre tour approche.",
|
||||
"cancelConfirm": "Annuler votre ticket ? Vous devrez rescanner le QR pour revenir.",
|
||||
"cancelMyTicket": "Annuler mon ticket",
|
||||
"joinedAt": "Rejoint à {{time}}",
|
||||
"refresh": "Actualiser",
|
||||
"notifTitle": "C'est votre tour !",
|
||||
"notifBody": "Présentez-vous en salle de consultation.",
|
||||
"toastApproaching": "Vous êtes le prochain — préparez-vous !",
|
||||
"toastTicketCanceled": "Ticket annulé"
|
||||
},
|
||||
"display": {
|
||||
"metaTitle": "Écran d'affichage — QueueMed",
|
||||
"nowCalling": "On appelle",
|
||||
"ticket": "Ticket",
|
||||
"waiting": "en attente",
|
||||
"queueClosed": "File fermée"
|
||||
"queueClosed": "File fermée",
|
||||
"metaDescription": "Affichage en temps réel de la file d'attente du cabinet.",
|
||||
"clinicNotFound": "Cabinet introuvable",
|
||||
"clinicNotFoundDesc": "Vérifiez l'URL ou contactez le médecin.",
|
||||
"brandTagline": "QueueMed — File en direct",
|
||||
"live": "En direct",
|
||||
"reconnecting": "Reconnexion...",
|
||||
"patientCalled": "Patient appelé",
|
||||
"consultationRoom": "Salle de consultation",
|
||||
"noPatientCalled": "Aucun patient appelé",
|
||||
"upcoming": "Prochains",
|
||||
"waitingCount": "{{count}} en attente",
|
||||
"statusOpen": "OUVERT",
|
||||
"statusClosed": "FERMÉ",
|
||||
"noWaiting": "Aucun patient en attente",
|
||||
"anonymousPatient": "Patient anonyme",
|
||||
"minShort": "min",
|
||||
"position": "Position",
|
||||
"nextLabel": "SUIVANT",
|
||||
"ticker": "✨ Bienvenue au {{clinic}} — Scannez le QR code à l'accueil pour rejoindre la file en ligne — Suivez votre position en temps réel sur votre téléphone — Vous serez notifié quand votre tour approche"
|
||||
},
|
||||
"analytics": {
|
||||
"metaTitle": "Analytics — QueueMed",
|
||||
|
|
@ -155,7 +425,43 @@
|
|||
"byHour": "Par heure",
|
||||
"byDay": "Par jour",
|
||||
"exportCsv": "Exporter CSV",
|
||||
"recommendations": "Recommandations IA"
|
||||
"recommendations": "Recommandations IA",
|
||||
"metaDescription": "Statistiques d'affluence, temps d'attente et recommandations IA pour votre cabinet médical.",
|
||||
"headerSubtitle": "Affluence, temps d'attente et recommandations IA.",
|
||||
"recommendationsSubtitle": "Optimisations identifiées sur la période sélectionnée.",
|
||||
"period": "Période",
|
||||
"clinic": "Cabinet",
|
||||
"allClinics": "Tous",
|
||||
"daysLabel": "{{count}} jours",
|
||||
"hourSuffix": "h",
|
||||
"minShort": "min",
|
||||
"kpiJoined": "Patients joints",
|
||||
"kpiServed": "Servis",
|
||||
"kpiAbsent": "Absents",
|
||||
"kpiAvgWait": "Attente moy.",
|
||||
"kpiAvgConsultation": "Cons. moy.",
|
||||
"flowJoined": "Joints",
|
||||
"flowServed": "Servis",
|
||||
"flowAbsent": "Absents",
|
||||
"chartByHour": "Affluence par heure",
|
||||
"chartByDay": "Affluence par jour",
|
||||
"chartFlow": "Flux patients",
|
||||
"chartAvgWait": "Temps d'attente moyen",
|
||||
"peakHour": "Pic d'affluence :",
|
||||
"peakDay": "Jour le plus chargé :",
|
||||
"minutesOnAverage": "minutes en moyenne",
|
||||
"consultationLabel": "Consultation",
|
||||
"totalLabel": "Total",
|
||||
"daySun": "Dim",
|
||||
"dayMon": "Lun",
|
||||
"dayTue": "Mar",
|
||||
"dayWed": "Mer",
|
||||
"dayThu": "Jeu",
|
||||
"dayFri": "Ven",
|
||||
"daySat": "Sam",
|
||||
"toastNoClinic": "Aucun cabinet sélectionné",
|
||||
"toastExportFailed": "Export échoué",
|
||||
"toastExportSuccess": "CSV exporté"
|
||||
},
|
||||
"clinicSettings": {
|
||||
"metaTitle": "Paramètres cabinet — QueueMed",
|
||||
|
|
@ -163,7 +469,38 @@
|
|||
"general": "Général",
|
||||
"openingHours": "Horaires d'ouverture",
|
||||
"whatsapp": "WhatsApp",
|
||||
"save": "Enregistrer"
|
||||
"save": "Enregistrer",
|
||||
"metaDescription": "Personnalisez l'expérience patient et la gestion de la file d'attente de votre cabinet.",
|
||||
"subtitle": "Personnalisez l'expérience patient et la gestion de la file",
|
||||
"openingHoursHelp": "Affichés aux patients sur la page de la file d'attente.",
|
||||
"saveButton": "Sauvegarder les paramètres",
|
||||
"selectClinic": "Sélectionner un cabinet",
|
||||
"welcomeMessage": "Message de bienvenue",
|
||||
"welcomeMessageHelp": "Affiché aux patients lorsqu'ils rejoignent la file d'attente. Laissez vide pour ne pas afficher de message.",
|
||||
"welcomeMessagePlaceholder": "Ex: Bienvenue au cabinet du Dr Martin. Merci de patienter, nous vous appellerons dès que possible.",
|
||||
"patientLanguage": "Langue de l'interface patient",
|
||||
"patientLanguageHelp": "Langue affichée sur l'écran du patient et dans les messages WhatsApp.",
|
||||
"queueSettings": "Paramètres de la file d'attente",
|
||||
"avgConsultation": "Durée moy. consultation",
|
||||
"maxQueueSize": "Taille max. file",
|
||||
"autoAbsentTimer": "Timer absent auto",
|
||||
"patients": "patients",
|
||||
"minShort": "min",
|
||||
"open": "Ouvert",
|
||||
"closed": "Fermé",
|
||||
"openingTime": "Heure d'ouverture",
|
||||
"closingTime": "Heure de fermeture",
|
||||
"timeSeparator": "à",
|
||||
"autoAbsentDisabled": "Désactivé — le médecin marque manuellement les absents",
|
||||
"autoAbsentEnabled": "Le patient est marqué absent après {{minutes}} min sans réponse",
|
||||
"dayMon": "Lundi",
|
||||
"dayTue": "Mardi",
|
||||
"dayWed": "Mercredi",
|
||||
"dayThu": "Jeudi",
|
||||
"dayFri": "Vendredi",
|
||||
"daySat": "Samedi",
|
||||
"daySun": "Dimanche",
|
||||
"toastSaved": "Paramètres sauvegardés"
|
||||
},
|
||||
"whatsapp": {
|
||||
"metaTitle": "WhatsApp — QueueMed",
|
||||
|
|
@ -172,7 +509,49 @@
|
|||
"disconnect": "Déconnecter",
|
||||
"scanQr": "Scannez ce QR code avec WhatsApp",
|
||||
"connected": "Connecté",
|
||||
"disconnected": "Déconnecté"
|
||||
"disconnected": "Déconnecté",
|
||||
"metaDescription": "Connectez WhatsApp à votre cabinet pour envoyer des notifications automatiques à vos patients.",
|
||||
"headerTitle": "Notifications WhatsApp",
|
||||
"headerSubtitle": "Connectez WhatsApp pour envoyer des alertes automatiques à vos patients",
|
||||
"statusDisconnected": "Déconnecté",
|
||||
"statusConnecting": "Connexion en cours…",
|
||||
"statusQrReady": "En attente du scan",
|
||||
"statusConnected": "Connecté",
|
||||
"disclaimerNote": "Note :",
|
||||
"disclaimerBody": "Cette fonctionnalité utilise WhatsApp Web (protocole non officiel). Limitez l'envoi à moins de 500 messages/jour pour éviter tout risque de restriction. Un numéro WhatsApp personnel ou professionnel est requis.",
|
||||
"clinic": "Cabinet",
|
||||
"connectionStatus": "Statut de la connexion",
|
||||
"qrAltText": "QR Code WhatsApp",
|
||||
"howToScan": "Comment scanner",
|
||||
"scanStep1": "1. Ouvrez WhatsApp sur votre téléphone",
|
||||
"scanStep2": "2. Appuyez sur ⋮ → Appareils liés",
|
||||
"scanStep3": "3. Appuyez sur Lier un appareil",
|
||||
"scanStep4": "4. Scannez ce QR code",
|
||||
"refreshStatus": "Actualiser le statut",
|
||||
"connectedTitle": "WhatsApp connecté",
|
||||
"connectedBody": "Les notifications automatiques sont actives pour ce cabinet.",
|
||||
"notConnectedTitle": "Non connecté",
|
||||
"notConnectedBody": "Cliquez sur « Connecter » pour générer un QR code à scanner.",
|
||||
"newQr": "Nouveau QR code",
|
||||
"cancel": "Annuler",
|
||||
"testMessage": "Message de test",
|
||||
"testMessageHelp": "Envoyez un message de test pour vérifier que la connexion fonctionne.",
|
||||
"testPhonePlaceholder": "Numéro international (ex: 33612345678)",
|
||||
"testPhoneLabel": "Numéro de téléphone",
|
||||
"send": "Envoyer",
|
||||
"phoneFormatHint": "Entrez le numéro sans le + (ex: 33612345678 pour +33 6 12 34 56 78)",
|
||||
"howItWorks": "Comment ça fonctionne",
|
||||
"step1Title": "Inscription",
|
||||
"step1Desc": "Le patient entre son numéro WhatsApp lors de l'inscription via QR code",
|
||||
"step2Title": "Alerte bientôt",
|
||||
"step2Desc": "Quand il reste 2 patients avant lui, il reçoit un message d'alerte",
|
||||
"step3Title": "C'est son tour",
|
||||
"step3Desc": "Quand le médecin l'appelle, il reçoit immédiatement un message",
|
||||
"toastQrGenerated": "QR code généré — scannez avec WhatsApp",
|
||||
"toastConnected": "WhatsApp connecté !",
|
||||
"toastDisconnected": "Session WhatsApp déconnectée",
|
||||
"toastTestSent": "Message test envoyé !",
|
||||
"toastTestFailed": "Échec : {{error}}"
|
||||
},
|
||||
"onboarding": {
|
||||
"metaTitle": "Bienvenue — QueueMed",
|
||||
|
|
@ -180,7 +559,59 @@
|
|||
"step1": "Créez votre cabinet",
|
||||
"step2": "Imprimez votre QR code",
|
||||
"step3": "Ouvrez la file d'attente",
|
||||
"finish": "Terminer"
|
||||
"finish": "Terminer",
|
||||
"metaDescription": "Configurez votre premier cabinet QueueMed en 2 minutes.",
|
||||
"headerPart1": "Configuration",
|
||||
"headerAccent": "initiale",
|
||||
"headerSubtitle": "Configurez votre premier cabinet en 2 minutes.",
|
||||
"steps": {
|
||||
"s1": {
|
||||
"title": "Votre cabinet",
|
||||
"description": "Renseignez les informations de base et les paramètres de la file."
|
||||
},
|
||||
"s2": {
|
||||
"title": "Votre QR code",
|
||||
"description": "Imprimez ou prévisualisez l'affiche à apposer à l'accueil."
|
||||
},
|
||||
"s3": {
|
||||
"title": "Tout est prêt !",
|
||||
"description": "Voici les prochaines étapes pour démarrer."
|
||||
}
|
||||
},
|
||||
"fieldName": "Nom du cabinet",
|
||||
"fieldAddress": "Adresse",
|
||||
"fieldPhone": "Téléphone",
|
||||
"optional": "(optionnel)",
|
||||
"placeholderName": "Ex: Cabinet Dr. Martin",
|
||||
"placeholderAddress": "12 rue de la Paix, Paris",
|
||||
"placeholderPhone": "01 23 45 67 89",
|
||||
"queueSettings": "Paramètres de la file",
|
||||
"avgConsultation": "Durée moyenne de consultation",
|
||||
"avgConsultationHelp": "Utilisé pour estimer le temps d'attente des patients.",
|
||||
"maxQueueSize": "Taille maximale de la file",
|
||||
"maxQueueHelp": "Au-delà, les nouveaux patients ne pourront plus rejoindre.",
|
||||
"minutesShort": "min",
|
||||
"patientsShort": "pat.",
|
||||
"qrIntroPart1": "Voici le QR code de",
|
||||
"qrIntroPart2": ". Imprimez-le et placez-le à l'entrée du cabinet pour que vos patients puissent rejoindre la file.",
|
||||
"qrAlt": "QR Code",
|
||||
"qrUnavailable": "QR indisponible",
|
||||
"viewPoster": "Voir / imprimer l'affiche",
|
||||
"continue": "Continuer",
|
||||
"doneTitle": "Cabinet configuré !",
|
||||
"donePart1": "Le cabinet",
|
||||
"donePart2": "est prêt. Voici vos prochaines étapes pour bien démarrer.",
|
||||
"next1": "Imprimez le QR code et affichez-le à l'accueil",
|
||||
"next2": "Configurez l'écran d'affichage sur votre tablette ou moniteur",
|
||||
"next3": "Ouvrez la file depuis le tableau de bord en début de journée",
|
||||
"creating": "Création...",
|
||||
"createClinic": "Créer le cabinet",
|
||||
"back": "Retour",
|
||||
"viewQueue": "Voir la file",
|
||||
"dashboard": "Tableau de bord",
|
||||
"skip": "Passer pour l'instant",
|
||||
"toastCreated": "Cabinet créé avec succès !",
|
||||
"errorNameRequired": "Le nom du cabinet est requis."
|
||||
},
|
||||
"subscription": {
|
||||
"metaTitle": "Abonnement — QueueMed",
|
||||
|
|
@ -189,11 +620,357 @@
|
|||
"active": "Actif",
|
||||
"expired": "Expiré",
|
||||
"daysLeft": "{{days}} jours restants",
|
||||
"upgrade": "Passer au plan payant"
|
||||
"upgrade": "Passer au plan payant",
|
||||
"metaDescription": "Gérez votre abonnement QueueMed et votre période d'essai.",
|
||||
"subtitle": "Gérez votre plan et votre période d'essai.",
|
||||
"daysCount_one": "{{count}} jour",
|
||||
"daysCount_other": "{{count}} jours",
|
||||
"currentPlan": "Plan actuel",
|
||||
"currentPlanLabel": "Plan actuel",
|
||||
"expiredMessage": "Votre abonnement est expiré. Renouvelez pour continuer à utiliser QueueMed.",
|
||||
"freeTrial": "Essai gratuit",
|
||||
"nextRenewal": "Prochain renouvellement",
|
||||
"in": "dans",
|
||||
"untilDate": "(jusqu'au {{date}})",
|
||||
"subscribeNow": "S'abonner maintenant",
|
||||
"dayN": "Jour {{day}}",
|
||||
"choosePlan": "Choisissez votre plan",
|
||||
"popular": "Populaire",
|
||||
"current": "Actuel",
|
||||
"automaticTrial": "Essai automatique",
|
||||
"subscribe": "S'abonner",
|
||||
"commitmentTitle": "Notre engagement",
|
||||
"commitmentBody": "Annulation à tout moment. Données hébergées en France. Conformité RGPD. Migration et configuration assistées gratuites.",
|
||||
"toastRedirect": "Redirection vers le paiement {{plan}}…",
|
||||
"toastRedirectDescription": "L'intégration Stripe sera activée prochainement.",
|
||||
"plans": {
|
||||
"trial": {
|
||||
"name": "Essai",
|
||||
"period": "30 jours",
|
||||
"description": "Découvrez QueueMed sans engagement.",
|
||||
"features": {
|
||||
"0": "1 cabinet",
|
||||
"1": "Patients illimités",
|
||||
"2": "Statistiques de base",
|
||||
"3": "Support email"
|
||||
}
|
||||
},
|
||||
"basic": {
|
||||
"name": "Basic",
|
||||
"period": "/ mois",
|
||||
"description": "Pour un cabinet individuel.",
|
||||
"features": {
|
||||
"0": "1 cabinet",
|
||||
"1": "Patients illimités",
|
||||
"2": "Écran d'affichage",
|
||||
"3": "Statistiques avancées",
|
||||
"4": "Support prioritaire"
|
||||
}
|
||||
},
|
||||
"pro": {
|
||||
"name": "Pro",
|
||||
"period": "/ mois",
|
||||
"description": "Centres médicaux et multi-praticiens.",
|
||||
"features": {
|
||||
"0": "Cabinets illimités",
|
||||
"1": "Multi-praticiens",
|
||||
"2": "Recommandations IA",
|
||||
"3": "Export CSV",
|
||||
"4": "Support téléphonique"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"help": {
|
||||
"metaTitle": "Aide — QueueMed",
|
||||
"title": "Centre d'aide",
|
||||
"subtitle": "Trouvez vite la réponse à vos questions"
|
||||
"subtitle": "Trouvez vite la réponse à vos questions",
|
||||
"metaDescription": "Centre d'aide QueueMed : trouvez rapidement les réponses à vos questions.",
|
||||
"headerCenter": "Centre",
|
||||
"headerHelp": "d'aide",
|
||||
"headerSubtitle": "Trouvez rapidement les réponses à vos questions sur QueueMed.",
|
||||
"searchPlaceholder": "Rechercher une question...",
|
||||
"noResults": "Aucune question ne correspond à votre recherche.",
|
||||
"contactTitle": "Vous ne trouvez pas votre réponse ?",
|
||||
"contactBody": "Notre équipe est disponible pour vous aider à configurer et utiliser QueueMed dans votre cabinet.",
|
||||
"dashboardButton": "Tableau de bord",
|
||||
"contactButton": "Contacter le support",
|
||||
"categories": {
|
||||
"all": "Tous",
|
||||
"gettingStarted": "Démarrage",
|
||||
"queueManagement": "Gestion de la file",
|
||||
"patientExperience": "Expérience patient",
|
||||
"displayScreen": "Écran d'affichage",
|
||||
"subscription": "Abonnement",
|
||||
"technical": "Technique"
|
||||
},
|
||||
"quickLinks": {
|
||||
"gettingStarted": "Démarrage",
|
||||
"patients": "Patients",
|
||||
"display": "Écran",
|
||||
"subscription": "Abonnement"
|
||||
},
|
||||
"faq": {
|
||||
"createClinic": {
|
||||
"q": "Comment créer mon premier cabinet ?",
|
||||
"a": "Lors de votre première connexion, suivez l'assistant de configuration en cliquant sur 'Démarrer la configuration' depuis le tableau de bord. Renseignez le nom, l'adresse optionnelle et les paramètres de la file (durée de consultation moyenne, taille maximale). Un QR code unique est généré automatiquement."
|
||||
},
|
||||
"printPoster": {
|
||||
"q": "Comment imprimer mon affiche QR code ?",
|
||||
"a": "Depuis la page de gestion d'un cabinet, cliquez sur 'Affiche QR'. La page affiche un poster A4 prêt à imprimer. Utilisez du papier couleur si possible, plastifiez l'affiche et placez-la à hauteur des yeux à l'entrée du cabinet."
|
||||
},
|
||||
"setupTime": {
|
||||
"q": "Combien de temps faut-il pour configurer QueueMed ?",
|
||||
"a": "Environ 2 minutes : créez votre compte, configurez votre premier cabinet et imprimez le QR code. Vous pouvez accueillir vos premiers patients en moins de 5 minutes au total."
|
||||
},
|
||||
"openCloseQueue": {
|
||||
"q": "Comment ouvrir et fermer la file d'attente ?",
|
||||
"a": "Dans la page 'Gestion de la file', sélectionnez votre cabinet et cliquez sur 'Ouvrir la file'. Les patients pourront alors rejoindre. En fin de journée, cliquez sur 'Fermer la file' puis 'Réinitialiser' pour repartir à zéro le lendemain."
|
||||
},
|
||||
"callNext": {
|
||||
"q": "Comment appeler le prochain patient ?",
|
||||
"a": "Cliquez sur 'Appeler le suivant' dans l'interface de gestion. Le numéro s'affiche automatiquement sur l'écran d'affichage en salle d'attente et le patient reçoit une notification push sur son téléphone."
|
||||
},
|
||||
"noShow": {
|
||||
"q": "Que faire si un patient ne se présente pas ?",
|
||||
"a": "Cliquez sur 'Absent' à côté du nom du patient. Il est retiré de la file et les positions des autres patients se mettent à jour automatiquement. Le patient devra rescanner le QR code pour rejoindre à nouveau."
|
||||
},
|
||||
"reorder": {
|
||||
"q": "Puis-je réorganiser l'ordre des patients ?",
|
||||
"a": "Oui. Dans la liste de la file, glissez-déposez les patients pour modifier leur ordre. Les positions et temps d'attente se recalculent en direct, et chaque patient reçoit la mise à jour sur son téléphone."
|
||||
},
|
||||
"printedTicket": {
|
||||
"q": "Comment imprimer un ticket pour un patient sans smartphone ?",
|
||||
"a": "Dans l'interface de gestion, cliquez sur 'Ajouter un patient' puis cochez 'Sans smartphone'. Un ticket imprimable s'ouvre dans un nouvel onglet avec le numéro et la position. Donnez-le au patient — il suivra son tour à l'écran d'affichage."
|
||||
},
|
||||
"patientJoin": {
|
||||
"q": "Comment un patient rejoint-il la file ?",
|
||||
"a": "Le patient ouvre l'appareil photo de son smartphone et scanne le QR code affiché à l'accueil. Un lien s'ouvre automatiquement — il appuie dessus pour rejoindre la file. Aucune application à installer."
|
||||
},
|
||||
"patientLeave": {
|
||||
"q": "Le patient peut-il quitter la salle d'attente physique ?",
|
||||
"a": "Oui, c'est l'avantage principal de QueueMed. Le patient garde la page ouverte sur son téléphone et peut s'éloigner. Il reçoit une notification push lorsque son tour approche. Recommandez-lui de rester à moins de 5 minutes du cabinet."
|
||||
},
|
||||
"qrRotation": {
|
||||
"q": "Pourquoi le QR code ne fonctionne plus parfois ?",
|
||||
"a": "Le QR code se renouvelle automatiquement à intervalles réguliers (système anti-triche) pour éviter le partage frauduleux du lien hors du cabinet. Si un patient obtient une erreur, il lui suffit de rescanner le QR code à l'accueil."
|
||||
},
|
||||
"notification": {
|
||||
"q": "Le patient reçoit-il bien la notification ?",
|
||||
"a": "Lors du premier accès, le navigateur lui demande l'autorisation des notifications. S'il accepte, il recevra une notification push + vibration quand son tour est appelé. Sinon, la page reste à jour en temps réel tant qu'elle est ouverte."
|
||||
},
|
||||
"displaySetup": {
|
||||
"q": "Comment configurer l'écran d'affichage ?",
|
||||
"a": "Dans la fiche de votre cabinet, copiez le 'Lien écran d'affichage' (/display/:clinicId). Ouvrez ce lien sur votre tablette ou moniteur de salle d'attente, puis activez le mode plein écran (F11 sur PC). L'écran se met à jour automatiquement via WebSocket."
|
||||
},
|
||||
"displayHardware": {
|
||||
"q": "Quel matériel utiliser pour l'écran ?",
|
||||
"a": "N'importe quelle tablette, moniteur ou TV connectée à internet et dotée d'un navigateur moderne (Chrome, Safari, Edge). Une simple tablette Android à 80 € fait parfaitement l'affaire."
|
||||
},
|
||||
"internetOutage": {
|
||||
"q": "Que se passe-t-il en cas de coupure internet ?",
|
||||
"a": "L'écran d'affichage affiche un indicateur 'Reconnexion...' en orange. Les patients déjà dans la file conservent leur position. Dès que la connexion est rétablie, la synchronisation reprend automatiquement."
|
||||
},
|
||||
"trialDuration": {
|
||||
"q": "Combien de temps dure l'essai gratuit ?",
|
||||
"a": "L'essai gratuit dure 30 jours à compter de votre première connexion. Toutes les fonctionnalités sont disponibles sans restriction pendant cette période, et vous pouvez créer plusieurs cabinets et accueillir un nombre illimité de patients."
|
||||
},
|
||||
"afterTrial": {
|
||||
"q": "Que se passe-t-il après l'essai gratuit ?",
|
||||
"a": "L'accès aux fonctionnalités de gestion (ouvrir la file, appeler des patients, créer des cabinets) est bloqué jusqu'à la souscription d'un plan payant. Vos données sont conservées et les patients peuvent toujours voir leur position dans les files actives."
|
||||
},
|
||||
"cancelSub": {
|
||||
"q": "Puis-je annuler mon abonnement ?",
|
||||
"a": "Oui, vous pouvez annuler à tout moment depuis la page 'Abonnement' de votre tableau de bord. L'accès aux fonctionnalités payantes reste actif jusqu'à la fin de la période déjà payée."
|
||||
},
|
||||
"clinicCount": {
|
||||
"q": "Combien de cabinets puis-je gérer ?",
|
||||
"a": "Le plan Solo inclut 1 cabinet. Le plan Pro permet de créer jusqu'à 5 cabinets. Le plan Cabinet inclut un nombre illimité de cabinets et donne accès à des statistiques avancées avec recommandations IA."
|
||||
},
|
||||
"devices": {
|
||||
"q": "Sur quels appareils fonctionne QueueMed ?",
|
||||
"a": "QueueMed fonctionne sur tous les appareils dotés d'un navigateur moderne : smartphones iOS et Android, tablettes, ordinateurs Windows / Mac / Linux. Aucune application à installer. Recommandé : Chrome ou Safari à jour."
|
||||
},
|
||||
"dataSecurity": {
|
||||
"q": "Mes données patients sont-elles sécurisées ?",
|
||||
"a": "Oui. Les noms et numéros de ticket sont chiffrés en transit (HTTPS) et stockés sur des serveurs hébergés en France. Aucune donnée médicale n'est collectée. Les patients sont identifiés uniquement par un nom optionnel."
|
||||
},
|
||||
"exportStats": {
|
||||
"q": "Puis-je exporter mes statistiques ?",
|
||||
"a": "Oui. Depuis la page 'Analytics', cliquez sur 'Exporter en CSV' pour télécharger l'historique complet des consultations. Le fichier inclut les heures, durées d'attente et durées de consultation pour chaque patient."
|
||||
},
|
||||
"offline": {
|
||||
"q": "QueueMed fonctionne-t-il hors ligne ?",
|
||||
"a": "Non, une connexion internet est nécessaire pour la synchronisation en temps réel entre le médecin, l'écran d'affichage et les patients. En cas de coupure, l'application reprend automatiquement dès le retour de la connexion."
|
||||
}
|
||||
}
|
||||
},
|
||||
"clinics": {
|
||||
"metaTitle": "Mes cabinets — QueueMed",
|
||||
"metaDescription": "Gérez vos cabinets, leurs QR codes et leurs paramètres.",
|
||||
"title": "Mes cabinets",
|
||||
"subtitle": "Gérez vos cabinets, leurs QR codes et leurs paramètres.",
|
||||
"newClinic": "Nouveau cabinet",
|
||||
"emptyTitle": "Aucun cabinet pour le moment",
|
||||
"emptySubtitle": "Créez votre premier cabinet pour démarrer.",
|
||||
"createClinic": "Créer un cabinet",
|
||||
"statusOpen": "Ouvert",
|
||||
"statusClosed": "Fermé",
|
||||
"statCons": "Cons.",
|
||||
"statMax": "Max",
|
||||
"statQrRot": "QR rot.",
|
||||
"minutesShort": "min",
|
||||
"manageQueue": "Gérer la file",
|
||||
"open": "Ouvrir",
|
||||
"close": "Fermer",
|
||||
"qr": "QR",
|
||||
"screen": "Écran",
|
||||
"editAction": "Éditer",
|
||||
"dialogEditTitle": "Modifier le cabinet",
|
||||
"dialogCreateTitle": "Nouveau cabinet",
|
||||
"dialogDescription": "Configurez les informations et paramètres de la salle d'attente.",
|
||||
"fieldName": "Nom du cabinet",
|
||||
"fieldAddress": "Adresse",
|
||||
"fieldPhone": "Téléphone",
|
||||
"fieldColor": "Couleur",
|
||||
"fieldAvgConsultation": "Durée moyenne consultation",
|
||||
"fieldMaxQueue": "Taille max file",
|
||||
"fieldQrRotation": "Rotation QR (anti-triche)",
|
||||
"placeholderName": "Ex: Cabinet Dr. Martin",
|
||||
"placeholderAddress": "Ex: 12 rue de la Paix, Paris",
|
||||
"placeholderPhone": "01 23 45 67 89",
|
||||
"qrDisabled": "Désactivé",
|
||||
"cancel": "Annuler",
|
||||
"save": "Enregistrer",
|
||||
"create": "Créer",
|
||||
"qrDialogTitle": "QR Code du cabinet",
|
||||
"qrDialogDescription": "Affichez ce QR à l'accueil. Vos patients le scannent pour rejoindre la file.",
|
||||
"posterA4": "Affiche A4",
|
||||
"closeButton": "Fermer",
|
||||
"deleteDialogTitle": "Supprimer ce cabinet ?",
|
||||
"deleteDialogDescription": "Cette action est irréversible. Toute la file et l'historique seront perdus.",
|
||||
"deletePermanently": "Supprimer définitivement",
|
||||
"qrAlt": "QR code",
|
||||
"expiresOn": "Expire le",
|
||||
"regenerate": "Régénérer",
|
||||
"toastCreated": "Cabinet créé !",
|
||||
"toastUpdated": "Cabinet mis à jour",
|
||||
"toastDeleted": "Cabinet supprimé",
|
||||
"toastQrRegenerated": "Nouveau QR code généré",
|
||||
"errorNameRequired": "Le nom du cabinet est requis (≥ 2 caractères)."
|
||||
},
|
||||
"ticket": {
|
||||
"metaTitle": "Ticket — QueueMed",
|
||||
"metaDescription": "Ticket imprimable de la file d'attente.",
|
||||
"notFound": "Ticket introuvable",
|
||||
"notFoundDesc": "Ce ticket n'existe pas ou a été supprimé.",
|
||||
"printTicket": "Imprimer le ticket",
|
||||
"tipLabel": "Conseil",
|
||||
"tipText": "imprimez sur du papier A6 ou pliez en deux. Donnez ce ticket au patient ; il pourra suivre son tour à l'écran d'affichage.",
|
||||
"subtitle": "Ticket de file d'attente",
|
||||
"yourNumber": "Votre numéro",
|
||||
"position": "Position",
|
||||
"wait": "Attente",
|
||||
"minShort": "min",
|
||||
"howItWorks": "Comment ça marche ?",
|
||||
"howItWorksDesc": "Surveillez l'écran d'affichage en salle. Lorsque votre numéro s'affiche, présentez-vous immédiatement à la salle de consultation.",
|
||||
"issuedAt": "Émis le {{date}} à {{time}}"
|
||||
},
|
||||
"history": {
|
||||
"metaTitle": "Historique des consultations — QueueMed",
|
||||
"metaDescription": "Consultez l'historique complet et les statistiques des consultations de votre cabinet médical.",
|
||||
"title": "Historique des consultations",
|
||||
"subtitle": "Consultez l'historique complet de vos patients",
|
||||
"selectClinic": "Sélectionner un cabinet",
|
||||
"totalConsultations": "Total consultations",
|
||||
"avgDuration": "Durée moyenne",
|
||||
"perConsultation": "par consultation",
|
||||
"presenceRate": "Taux de présence",
|
||||
"patientsPresent": "patients présents",
|
||||
"topReason": "Top motif",
|
||||
"consultationsCount": "{{count}} consultations",
|
||||
"reasonsBreakdown": "Répartition des motifs",
|
||||
"statsRange": "Période des statistiques",
|
||||
"range7": "7 jours",
|
||||
"range30": "30 jours",
|
||||
"range90": "90 jours",
|
||||
"range365": "1 an",
|
||||
"from": "Du",
|
||||
"to": "Au",
|
||||
"reason": "Motif",
|
||||
"allReasons": "Tous les motifs",
|
||||
"clear": "Effacer",
|
||||
"noResults": "Aucune consultation trouvée",
|
||||
"tryEditingFilters": "Essayez de modifier vos filtres",
|
||||
"colTicket": "Ticket",
|
||||
"colPatient": "Patient",
|
||||
"colReason": "Motif",
|
||||
"colDate": "Date",
|
||||
"colWait": "Attente",
|
||||
"colDuration": "Durée",
|
||||
"colStatus": "Statut",
|
||||
"anonymous": "Anonyme",
|
||||
"minShort": "min",
|
||||
"pageInfo": "Page {{page}} sur {{totalPages}} — {{total}} résultats",
|
||||
"previousPage": "Page précédente",
|
||||
"nextPage": "Page suivante",
|
||||
"reasonConsultation": "Consultation",
|
||||
"reasonUrgence": "Urgence",
|
||||
"reasonCertificatScolaire": "Certificat scolaire",
|
||||
"reasonCertificatSportif": "Certificat sportif",
|
||||
"reasonArretTravail": "Arrêt de travail",
|
||||
"reasonAdministratif": "Administratif",
|
||||
"reasonAutre": "Autre",
|
||||
"statusDone": "Terminé",
|
||||
"statusAbsent": "Absent",
|
||||
"statusCanceled": "Annulé"
|
||||
},
|
||||
"subscriptionBlocked": {
|
||||
"metaTitle": "Abonnement expiré — QueueMed",
|
||||
"metaDescription": "Votre période d'essai QueueMed est terminée. Choisissez un abonnement pour continuer.",
|
||||
"title": "Abonnement expiré",
|
||||
"description": "Votre période d'essai gratuit est terminée. Choisissez un abonnement pour continuer à utiliser Salle d'attente.",
|
||||
"cta": "Choisir un abonnement",
|
||||
"features": {
|
||||
"0": "File d'attente illimitée",
|
||||
"1": "QR code anti-triche",
|
||||
"2": "Suivi temps réel",
|
||||
"3": "Analytics avancés"
|
||||
}
|
||||
},
|
||||
"qrPoster": {
|
||||
"metaTitle": "Affiche QR — QueueMed",
|
||||
"metaDescription": "Imprimez votre affiche QR code QueueMed pour la salle d'attente.",
|
||||
"notFoundTitle": "Cabinet introuvable",
|
||||
"notFoundBody": "Ce cabinet n'existe pas ou ne vous appartient pas.",
|
||||
"backToClinics": "Retour aux cabinets",
|
||||
"backToManagement": "Retour à la gestion",
|
||||
"refresh": "Rafraîchir",
|
||||
"printPoster": "Imprimer l'affiche",
|
||||
"tipsTitle": "Conseils d'impression :",
|
||||
"tipsBody": "utilisez du papier A4, en couleur si possible. Plastifiez l'affiche et placez-la à hauteur des yeux à l'entrée du cabinet.",
|
||||
"tagline": "Salle d'attente virtuelle",
|
||||
"scanToJoin": "Scannez pour rejoindre la file",
|
||||
"followInRealTime": "Suivez votre position en temps réel sur votre téléphone.",
|
||||
"qrAlt": "QR Code file d'attente",
|
||||
"qrUnavailable": "QR Code non disponible",
|
||||
"noAppTitle": "Aucune application à installer",
|
||||
"noAppBody": "Fonctionne dans votre navigateur. Gratuit pour les patients.",
|
||||
"noSmartphoneNote": "Pas de smartphone ? Demandez un ticket imprimé à l'accueil.",
|
||||
"poweredBy": "Propulsé par QueueMed",
|
||||
"steps": {
|
||||
"scan": {
|
||||
"title": "Scannez",
|
||||
"desc": "Pointez votre appareil photo vers le QR code"
|
||||
},
|
||||
"join": {
|
||||
"title": "Rejoignez",
|
||||
"desc": "Appuyez sur le lien et entrez dans la file"
|
||||
},
|
||||
"wait": {
|
||||
"title": "Patientez",
|
||||
"desc": "Vous serez alerté quand votre tour approche"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,35 @@
|
|||
import { useState } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
BarChart3, Users, Clock, Activity, Sparkles, Download, Loader2,
|
||||
TrendingUp, Calendar,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, CartesianGrid,
|
||||
LineChart, Line, AreaChart, Area, Cell, PieChart, Pie,
|
||||
AreaChart, Area, Cell, PieChart, Pie,
|
||||
} from "recharts";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const DAY_NAMES = ["Dim", "Lun", "Mar", "Mer", "Jeu", "Ven", "Sam"];
|
||||
const PIE_COLORS = ["#10b981", "#06b6d4", "#0d9488", "#22d3ee", "#34d399", "#0891b2", "#14b8a6"];
|
||||
|
||||
export default function Analytics() {
|
||||
const { t } = useTranslation();
|
||||
const [days, setDays] = useState<number>(30);
|
||||
const [clinicId, setClinicId] = useState<number | undefined>(undefined);
|
||||
|
||||
const DAY_NAMES = [
|
||||
t("analytics.daySun"),
|
||||
t("analytics.dayMon"),
|
||||
t("analytics.dayTue"),
|
||||
t("analytics.dayWed"),
|
||||
t("analytics.dayThu"),
|
||||
t("analytics.dayFri"),
|
||||
t("analytics.daySat"),
|
||||
];
|
||||
|
||||
const clinicsQuery = trpc.clinic.list.useQuery();
|
||||
const summaryQuery = trpc.analytics.summary.useQuery({ days, clinicId });
|
||||
|
||||
|
|
@ -31,12 +43,12 @@ export default function Analytics() {
|
|||
|
||||
const handleExport = async () => {
|
||||
if (!clinicId && !clinics[0]) {
|
||||
toast.error("Aucun cabinet sélectionné");
|
||||
toast.error(t("analytics.toastNoClinic"));
|
||||
return;
|
||||
}
|
||||
const result = await exportCsv.refetch();
|
||||
if (!result.data) {
|
||||
toast.error("Export échoué");
|
||||
toast.error(t("analytics.toastExportFailed"));
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([result.data.csv], { type: "text/csv;charset=utf-8;" });
|
||||
|
|
@ -48,35 +60,39 @@ export default function Analytics() {
|
|||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success("CSV exporté");
|
||||
toast.success(t("analytics.toastExportSuccess"));
|
||||
};
|
||||
|
||||
const hourData = (summary?.byHour ?? []).map((count, hour) => ({ hour: `${hour}h`, count }));
|
||||
const hourData = (summary?.byHour ?? []).map((count, hour) => ({ hour: `${hour}${t("analytics.hourSuffix")}`, count }));
|
||||
const dayData = (summary?.byDay ?? []).map((count, dow) => ({ day: DAY_NAMES[dow], count }));
|
||||
|
||||
const flowData = [
|
||||
{ name: "Joints", value: summary?.totalJoined ?? 0 },
|
||||
{ name: "Servis", value: summary?.totalServed ?? 0 },
|
||||
{ name: "Absents", value: summary?.totalAbsent ?? 0 },
|
||||
{ name: t("analytics.flowJoined"), value: summary?.totalJoined ?? 0 },
|
||||
{ name: t("analytics.flowServed"), value: summary?.totalServed ?? 0 },
|
||||
{ name: t("analytics.flowAbsent"), value: summary?.totalAbsent ?? 0 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="container py-8">
|
||||
<Helmet>
|
||||
<title>{t("analytics.metaTitle")}</title>
|
||||
<meta name="description" content={t("analytics.metaDescription")} />
|
||||
</Helmet>
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between mb-8 gap-4">
|
||||
<div>
|
||||
<h1 className="font-bold text-3xl mb-1">Analytics</h1>
|
||||
<p className="text-slate-600">Affluence, temps d'attente et recommandations IA.</p>
|
||||
<h1 className="font-bold text-3xl mb-1">{t("analytics.title")}</h1>
|
||||
<p className="text-slate-600">{t("analytics.headerSubtitle")}</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={handleExport} disabled={exportCsv.isFetching}>
|
||||
{exportCsv.isFetching ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Download className="w-4 h-4 mr-2" />}
|
||||
Exporter CSV
|
||||
{t("analytics.exportCsv")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="glass-card rounded-2xl p-4 mb-6 flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-bold text-slate-500 uppercase tracking-widest">Période</span>
|
||||
<span className="text-xs font-bold text-slate-500 uppercase tracking-widest">{t("analytics.period")}</span>
|
||||
{[7, 30, 90, 365].map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
|
|
@ -87,18 +103,19 @@ export default function Analytics() {
|
|||
: "bg-white border-slate-200 text-slate-600 hover:border-emerald-400"
|
||||
}`}
|
||||
>
|
||||
{d} jours
|
||||
{t("analytics.daysLabel", { count: d })}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-bold text-slate-500 uppercase tracking-widest">Cabinet</span>
|
||||
<span className="text-xs font-bold text-slate-500 uppercase tracking-widest">{t("analytics.clinic")}</span>
|
||||
<select
|
||||
value={clinicId ?? ""}
|
||||
onChange={(e) => setClinicId(e.target.value ? Number(e.target.value) : undefined)}
|
||||
className="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm focus:outline-none focus:border-emerald-400"
|
||||
aria-label={t("analytics.clinic")}
|
||||
>
|
||||
<option value="">Tous</option>
|
||||
<option value="">{t("analytics.allClinics")}</option>
|
||||
{clinics.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
|
|
@ -115,11 +132,11 @@ export default function Analytics() {
|
|||
{/* KPI cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4 mb-6">
|
||||
{[
|
||||
{ label: "Patients joints", value: summary?.totalJoined ?? 0, icon: Users, color: "from-emerald-500 to-teal-500" },
|
||||
{ label: "Servis", value: summary?.totalServed ?? 0, icon: Activity, color: "from-cyan-500 to-blue-500" },
|
||||
{ label: "Absents", value: summary?.totalAbsent ?? 0, icon: Calendar, color: "from-orange-500 to-amber-500" },
|
||||
{ label: "Attente moy.", value: `${summary?.avgWaitMinutes ?? 0} min`, icon: Clock, color: "from-violet-500 to-purple-500" },
|
||||
{ label: "Cons. moy.", value: `${summary?.avgConsultationMinutes ?? 0} min`, icon: TrendingUp, color: "from-pink-500 to-rose-500" },
|
||||
{ label: t("analytics.kpiJoined"), value: summary?.totalJoined ?? 0, icon: Users, color: "from-emerald-500 to-teal-500" },
|
||||
{ label: t("analytics.kpiServed"), value: summary?.totalServed ?? 0, icon: Activity, color: "from-cyan-500 to-blue-500" },
|
||||
{ label: t("analytics.kpiAbsent"), value: summary?.totalAbsent ?? 0, icon: Calendar, color: "from-orange-500 to-amber-500" },
|
||||
{ label: t("analytics.kpiAvgWait"), value: `${summary?.avgWaitMinutes ?? 0} ${t("analytics.minShort")}`, icon: Clock, color: "from-violet-500 to-purple-500" },
|
||||
{ label: t("analytics.kpiAvgConsultation"), value: `${summary?.avgConsultationMinutes ?? 0} ${t("analytics.minShort")}`, icon: TrendingUp, color: "from-pink-500 to-rose-500" },
|
||||
].map((s) => {
|
||||
const Icon = s.icon;
|
||||
return (
|
||||
|
|
@ -142,8 +159,8 @@ export default function Analytics() {
|
|||
<Sparkles className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-bold text-lg">Recommandations IA</h2>
|
||||
<p className="text-slate-500 text-sm">Optimisations identifiées sur la période sélectionnée.</p>
|
||||
<h2 className="font-bold text-lg">{t("analytics.recommendations")}</h2>
|
||||
<p className="text-slate-500 text-sm">{t("analytics.recommendationsSubtitle")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
|
|
@ -162,7 +179,7 @@ export default function Analytics() {
|
|||
<div className="glass-card rounded-2xl p-6">
|
||||
<h3 className="font-bold mb-4 flex items-center gap-2">
|
||||
<BarChart3 className="w-4 h-4 text-emerald-600" />
|
||||
Affluence par heure
|
||||
{t("analytics.chartByHour")}
|
||||
</h3>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<BarChart data={hourData}>
|
||||
|
|
@ -183,7 +200,7 @@ export default function Analytics() {
|
|||
</ResponsiveContainer>
|
||||
{summary && summary.peakHour >= 0 && (
|
||||
<p className="text-xs text-slate-500 mt-3">
|
||||
Pic d'affluence : <strong className="text-emerald-700">{summary.peakHour}h</strong>
|
||||
{t("analytics.peakHour")} <strong className="text-emerald-700">{summary.peakHour}{t("analytics.hourSuffix")}</strong>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -191,7 +208,7 @@ export default function Analytics() {
|
|||
<div className="glass-card rounded-2xl p-6">
|
||||
<h3 className="font-bold mb-4 flex items-center gap-2">
|
||||
<Activity className="w-4 h-4 text-cyan-600" />
|
||||
Affluence par jour
|
||||
{t("analytics.chartByDay")}
|
||||
</h3>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<AreaChart data={dayData}>
|
||||
|
|
@ -212,7 +229,7 @@ export default function Analytics() {
|
|||
</ResponsiveContainer>
|
||||
{summary && summary.peakDay >= 0 && (
|
||||
<p className="text-xs text-slate-500 mt-3">
|
||||
Jour le plus chargé : <strong className="text-cyan-700">{DAY_NAMES[summary.peakDay]}</strong>
|
||||
{t("analytics.peakDay")} <strong className="text-cyan-700">{DAY_NAMES[summary.peakDay]}</strong>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -220,7 +237,7 @@ export default function Analytics() {
|
|||
<div className="glass-card rounded-2xl p-6">
|
||||
<h3 className="font-bold mb-4 flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-violet-600" />
|
||||
Flux patients
|
||||
{t("analytics.chartFlow")}
|
||||
</h3>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<PieChart>
|
||||
|
|
@ -246,20 +263,20 @@ export default function Analytics() {
|
|||
<div className="glass-card rounded-2xl p-6">
|
||||
<h3 className="font-bold mb-4 flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-orange-600" />
|
||||
Temps d'attente moyen
|
||||
{t("analytics.chartAvgWait")}
|
||||
</h3>
|
||||
<div className="flex items-center justify-center h-[280px]">
|
||||
<div className="text-center">
|
||||
<div className="font-black text-7xl gradient-text mb-2">{summary?.avgWaitMinutes ?? 0}</div>
|
||||
<div className="text-slate-500 text-sm">minutes en moyenne</div>
|
||||
<div className="text-slate-500 text-sm">{t("analytics.minutesOnAverage")}</div>
|
||||
<div className="mt-6 grid grid-cols-2 gap-3">
|
||||
<div className="p-3 rounded-xl bg-emerald-50 border border-emerald-100">
|
||||
<div className="text-xs text-emerald-700 uppercase font-bold">Consultation</div>
|
||||
<div className="font-bold text-emerald-900 text-2xl mt-1">{summary?.avgConsultationMinutes ?? 0} min</div>
|
||||
<div className="text-xs text-emerald-700 uppercase font-bold">{t("analytics.consultationLabel")}</div>
|
||||
<div className="font-bold text-emerald-900 text-2xl mt-1">{summary?.avgConsultationMinutes ?? 0} {t("analytics.minShort")}</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-xl bg-cyan-50 border border-cyan-100">
|
||||
<div className="text-xs text-cyan-700 uppercase font-bold">Total</div>
|
||||
<div className="font-bold text-cyan-900 text-2xl mt-1">{(summary?.avgWaitMinutes ?? 0) + (summary?.avgConsultationMinutes ?? 0)} min</div>
|
||||
<div className="text-xs text-cyan-700 uppercase font-bold">{t("analytics.totalLabel")}</div>
|
||||
<div className="font-bold text-cyan-900 text-2xl mt-1">{(summary?.avgWaitMinutes ?? 0) + (summary?.avgConsultationMinutes ?? 0)} {t("analytics.minShort")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
* Message de bienvenue, horaires d'ouverture, langue patient, timer absent, etc.
|
||||
*/
|
||||
import { useState, useEffect } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -12,16 +14,6 @@ import {
|
|||
Loader2, ChevronDown, ChevronUp, Stethoscope
|
||||
} from "lucide-react";
|
||||
|
||||
const DAYS = [
|
||||
{ key: "mon", label: "Lundi" },
|
||||
{ key: "tue", label: "Mardi" },
|
||||
{ key: "wed", label: "Mercredi" },
|
||||
{ key: "thu", label: "Jeudi" },
|
||||
{ key: "fri", label: "Vendredi" },
|
||||
{ key: "sat", label: "Samedi" },
|
||||
{ key: "sun", label: "Dimanche" },
|
||||
];
|
||||
|
||||
const LANGUAGES = [
|
||||
{ code: "fr", label: "Français", flag: "🇫🇷" },
|
||||
{ code: "en", label: "English", flag: "🇬🇧" },
|
||||
|
|
@ -44,6 +36,7 @@ const DEFAULT_HOURS: OpeningHours = {
|
|||
};
|
||||
|
||||
export default function ClinicSettings() {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const [selectedClinicId, setSelectedClinicId] = useState<number | null>(null);
|
||||
const [welcomeMessage, setWelcomeMessage] = useState("");
|
||||
|
|
@ -55,6 +48,16 @@ export default function ClinicSettings() {
|
|||
const [showHours, setShowHours] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
const DAYS = [
|
||||
{ key: "mon", label: t("clinicSettings.dayMon") },
|
||||
{ key: "tue", label: t("clinicSettings.dayTue") },
|
||||
{ key: "wed", label: t("clinicSettings.dayWed") },
|
||||
{ key: "thu", label: t("clinicSettings.dayThu") },
|
||||
{ key: "fri", label: t("clinicSettings.dayFri") },
|
||||
{ key: "sat", label: t("clinicSettings.daySat") },
|
||||
{ key: "sun", label: t("clinicSettings.daySun") },
|
||||
];
|
||||
|
||||
const { data: clinicsList = [] } = trpc.clinic.list.useQuery(undefined, { enabled: !!user });
|
||||
const clinicId = selectedClinicId ?? clinicsList[0]?.id ?? 0;
|
||||
|
||||
|
|
@ -65,7 +68,7 @@ export default function ClinicSettings() {
|
|||
|
||||
const updateMutation = trpc.clinicSettings.update.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Paramètres sauvegardés");
|
||||
toast.success(t("clinicSettings.toastSaved"));
|
||||
setHasChanges(false);
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
|
|
@ -115,6 +118,10 @@ export default function ClinicSettings() {
|
|||
|
||||
return (
|
||||
<div className="space-y-6 max-w-3xl">
|
||||
<Helmet>
|
||||
<title>{t("clinicSettings.metaTitle")}</title>
|
||||
<meta name="description" content={t("clinicSettings.metaDescription")} />
|
||||
</Helmet>
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
|
|
@ -122,8 +129,8 @@ export default function ClinicSettings() {
|
|||
<Settings className="w-5 h-5 text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-display font-bold text-foreground">Paramètres du cabinet</h1>
|
||||
<p className="text-sm text-muted-foreground">Personnalisez l'expérience patient et la gestion de la file</p>
|
||||
<h1 className="text-xl font-display font-bold text-foreground">{t("clinicSettings.title")}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t("clinicSettings.subtitle")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -132,6 +139,7 @@ export default function ClinicSettings() {
|
|||
value={clinicId}
|
||||
onChange={(e) => setSelectedClinicId(Number(e.target.value))}
|
||||
className="bg-background/50 border border-border rounded-xl px-3 py-2 text-sm text-foreground"
|
||||
aria-label={t("clinicSettings.selectClinic")}
|
||||
>
|
||||
{clinicsList.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
|
|
@ -150,15 +158,15 @@ export default function ClinicSettings() {
|
|||
<div className="bg-card/50 border border-border/50 rounded-xl p-5 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="w-4 h-4 text-emerald-400" />
|
||||
<h2 className="text-sm font-semibold text-foreground">Message de bienvenue</h2>
|
||||
<h2 className="text-sm font-semibold text-foreground">{t("clinicSettings.welcomeMessage")}</h2>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Affiché aux patients lorsqu'ils rejoignent la file d'attente. Laissez vide pour ne pas afficher de message.
|
||||
{t("clinicSettings.welcomeMessageHelp")}
|
||||
</p>
|
||||
<textarea
|
||||
value={welcomeMessage}
|
||||
onChange={(e) => { setWelcomeMessage(e.target.value); setHasChanges(true); }}
|
||||
placeholder="Ex: Bienvenue au cabinet du Dr Martin. Merci de patienter, nous vous appellerons dès que possible."
|
||||
placeholder={t("clinicSettings.welcomeMessagePlaceholder")}
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
className="w-full bg-background/50 border border-border/50 rounded-lg px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 resize-none focus:outline-none focus:ring-2 focus:ring-emerald-500/30"
|
||||
|
|
@ -170,10 +178,10 @@ export default function ClinicSettings() {
|
|||
<div className="bg-card/50 border border-border/50 rounded-xl p-5 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="w-4 h-4 text-blue-400" />
|
||||
<h2 className="text-sm font-semibold text-foreground">Langue de l'interface patient</h2>
|
||||
<h2 className="text-sm font-semibold text-foreground">{t("clinicSettings.patientLanguage")}</h2>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Langue affichée sur l'écran du patient et dans les messages WhatsApp.
|
||||
{t("clinicSettings.patientLanguageHelp")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{LANGUAGES.map((lang) => (
|
||||
|
|
@ -201,12 +209,12 @@ export default function ClinicSettings() {
|
|||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-amber-400" />
|
||||
<h2 className="text-sm font-semibold text-foreground">Horaires d'ouverture</h2>
|
||||
<h2 className="text-sm font-semibold text-foreground">{t("clinicSettings.openingHours")}</h2>
|
||||
</div>
|
||||
{showHours ? <ChevronUp className="w-4 h-4 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 text-muted-foreground" />}
|
||||
</button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Affichés aux patients sur la page de la file d'attente.
|
||||
{t("clinicSettings.openingHoursHelp")}
|
||||
</p>
|
||||
|
||||
{showHours && (
|
||||
|
|
@ -223,7 +231,7 @@ export default function ClinicSettings() {
|
|||
onChange={(e) => updateHours(key, "closed", !e.target.checked)}
|
||||
className="rounded border-border accent-emerald-500"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{day.closed ? "Fermé" : "Ouvert"}</span>
|
||||
<span className="text-xs text-muted-foreground">{day.closed ? t("clinicSettings.closed") : t("clinicSettings.open")}</span>
|
||||
</label>
|
||||
{!day.closed && (
|
||||
<>
|
||||
|
|
@ -232,13 +240,15 @@ export default function ClinicSettings() {
|
|||
value={day.open}
|
||||
onChange={(e) => updateHours(key, "open", e.target.value)}
|
||||
className="bg-background/50 border border-border/50 rounded px-2 py-1 text-sm text-foreground"
|
||||
aria-label={t("clinicSettings.openingTime")}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">à</span>
|
||||
<span className="text-xs text-muted-foreground">{t("clinicSettings.timeSeparator")}</span>
|
||||
<input
|
||||
type="time"
|
||||
value={day.close}
|
||||
onChange={(e) => updateHours(key, "close", e.target.value)}
|
||||
className="bg-background/50 border border-border/50 rounded px-2 py-1 text-sm text-foreground"
|
||||
aria-label={t("clinicSettings.closingTime")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -253,14 +263,14 @@ export default function ClinicSettings() {
|
|||
<div className="bg-card/50 border border-border/50 rounded-xl p-5 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-purple-400" />
|
||||
<h2 className="text-sm font-semibold text-foreground">Paramètres de la file d'attente</h2>
|
||||
<h2 className="text-sm font-semibold text-foreground">{t("clinicSettings.queueSettings")}</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{/* Durée moyenne consultation */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Stethoscope className="w-3 h-3" /> Durée moy. consultation
|
||||
<Stethoscope className="w-3 h-3" /> {t("clinicSettings.avgConsultation")}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
|
|
@ -270,15 +280,16 @@ export default function ClinicSettings() {
|
|||
value={avgConsultationMinutes}
|
||||
onChange={(e) => { setAvgConsultationMinutes(Number(e.target.value)); setHasChanges(true); }}
|
||||
className="w-20 bg-background/50 border border-border/50 rounded-lg px-3 py-1.5 text-sm text-foreground text-center"
|
||||
aria-label={t("clinicSettings.avgConsultation")}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">min</span>
|
||||
<span className="text-xs text-muted-foreground">{t("clinicSettings.minShort")}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Taille max file */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Users className="w-3 h-3" /> Taille max. file
|
||||
<Users className="w-3 h-3" /> {t("clinicSettings.maxQueueSize")}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
|
|
@ -288,15 +299,16 @@ export default function ClinicSettings() {
|
|||
value={maxQueueSize}
|
||||
onChange={(e) => { setMaxQueueSize(Number(e.target.value)); setHasChanges(true); }}
|
||||
className="w-20 bg-background/50 border border-border/50 rounded-lg px-3 py-1.5 text-sm text-foreground text-center"
|
||||
aria-label={t("clinicSettings.maxQueueSize")}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">patients</span>
|
||||
<span className="text-xs text-muted-foreground">{t("clinicSettings.patients")}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timer absent automatique */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Timer className="w-3 h-3" /> Timer absent auto
|
||||
<Timer className="w-3 h-3" /> {t("clinicSettings.autoAbsentTimer")}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
|
|
@ -306,13 +318,14 @@ export default function ClinicSettings() {
|
|||
value={autoAbsentMinutes}
|
||||
onChange={(e) => { setAutoAbsentMinutes(Number(e.target.value)); setHasChanges(true); }}
|
||||
className="w-20 bg-background/50 border border-border/50 rounded-lg px-3 py-1.5 text-sm text-foreground text-center"
|
||||
aria-label={t("clinicSettings.autoAbsentTimer")}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">min</span>
|
||||
<span className="text-xs text-muted-foreground">{t("clinicSettings.minShort")}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{autoAbsentMinutes === 0
|
||||
? "Désactivé — le médecin marque manuellement les absents"
|
||||
: `Le patient est marqué absent après ${autoAbsentMinutes} min sans réponse`}
|
||||
? t("clinicSettings.autoAbsentDisabled")
|
||||
: t("clinicSettings.autoAbsentEnabled", { minutes: autoAbsentMinutes })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -330,7 +343,7 @@ export default function ClinicSettings() {
|
|||
) : (
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Sauvegarder les paramètres
|
||||
{t("clinicSettings.saveButton")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
* Tableau paginé + filtres date/motif + stats résumées
|
||||
*/
|
||||
import { useState, useMemo } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -12,23 +14,8 @@ import {
|
|||
Stethoscope, FileText, Briefcase, ClipboardList, HelpCircle, Heart, XCircle
|
||||
} from "lucide-react";
|
||||
|
||||
const VISIT_REASONS: Record<string, { label: string; icon: typeof Stethoscope; color: string }> = {
|
||||
consultation: { label: "Consultation", icon: Stethoscope, color: "text-emerald-400" },
|
||||
urgence: { label: "Urgence", icon: AlertTriangle, color: "text-red-400" },
|
||||
certificat_scolaire: { label: "Certificat scolaire", icon: FileText, color: "text-blue-400" },
|
||||
certificat_sportif: { label: "Certificat sportif", icon: Heart, color: "text-pink-400" },
|
||||
arret_travail: { label: "Arrêt de travail", icon: Briefcase, color: "text-amber-400" },
|
||||
administratif: { label: "Administratif", icon: ClipboardList, color: "text-purple-400" },
|
||||
autre: { label: "Autre", icon: HelpCircle, color: "text-gray-400" },
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
||||
done: { label: "Terminé", color: "bg-emerald-500/20 text-emerald-300" },
|
||||
absent: { label: "Absent", color: "bg-red-500/20 text-red-300" },
|
||||
canceled: { label: "Annulé", color: "bg-gray-500/20 text-gray-300" },
|
||||
};
|
||||
|
||||
export default function ConsultationHistory() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const [selectedClinicId, setSelectedClinicId] = useState<number | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
|
|
@ -38,6 +25,24 @@ export default function ConsultationHistory() {
|
|||
const [statsDays, setStatsDays] = useState(30);
|
||||
const perPage = 15;
|
||||
|
||||
const VISIT_REASONS: Record<string, { label: string; icon: typeof Stethoscope; color: string }> = {
|
||||
consultation: { label: t("history.reasonConsultation"), icon: Stethoscope, color: "text-emerald-400" },
|
||||
urgence: { label: t("history.reasonUrgence"), icon: AlertTriangle, color: "text-red-400" },
|
||||
certificat_scolaire: { label: t("history.reasonCertificatScolaire"), icon: FileText, color: "text-blue-400" },
|
||||
certificat_sportif: { label: t("history.reasonCertificatSportif"), icon: Heart, color: "text-pink-400" },
|
||||
arret_travail: { label: t("history.reasonArretTravail"), icon: Briefcase, color: "text-amber-400" },
|
||||
administratif: { label: t("history.reasonAdministratif"), icon: ClipboardList, color: "text-purple-400" },
|
||||
autre: { label: t("history.reasonAutre"), icon: HelpCircle, color: "text-gray-400" },
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
||||
done: { label: t("history.statusDone"), color: "bg-emerald-500/20 text-emerald-300" },
|
||||
absent: { label: t("history.statusAbsent"), color: "bg-red-500/20 text-red-300" },
|
||||
canceled: { label: t("history.statusCanceled"), color: "bg-gray-500/20 text-gray-300" },
|
||||
};
|
||||
|
||||
const dateLocale = i18n.language === "en" ? "en-US" : "fr-FR";
|
||||
|
||||
const { data: clinicsList = [] } = trpc.clinic.list.useQuery(undefined, { enabled: !!user });
|
||||
|
||||
// Auto-select first clinic
|
||||
|
|
@ -82,6 +87,10 @@ export default function ConsultationHistory() {
|
|||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Helmet>
|
||||
<title>{t("history.metaTitle")}</title>
|
||||
<meta name="description" content={t("history.metaDescription")} />
|
||||
</Helmet>
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
|
|
@ -89,8 +98,8 @@ export default function ConsultationHistory() {
|
|||
<History className="w-5 h-5 text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-display font-bold text-foreground">Historique des consultations</h1>
|
||||
<p className="text-sm text-muted-foreground">Consultez l'historique complet de vos patients</p>
|
||||
<h1 className="text-xl font-display font-bold text-foreground">{t("history.title")}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t("history.subtitle")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -100,6 +109,7 @@ export default function ConsultationHistory() {
|
|||
value={clinicId}
|
||||
onChange={(e) => { setSelectedClinicId(Number(e.target.value)); setPage(1); }}
|
||||
className="bg-background/50 border border-border rounded-xl px-3 py-2 text-sm text-foreground"
|
||||
aria-label={t("history.selectClinic")}
|
||||
>
|
||||
{clinicsList.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
|
|
@ -113,7 +123,7 @@ export default function ConsultationHistory() {
|
|||
<div className="bg-card/50 border border-border/50 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Users className="w-4 h-4 text-emerald-400" />
|
||||
<span className="text-xs text-muted-foreground">Total consultations</span>
|
||||
<span className="text-xs text-muted-foreground">{t("history.totalConsultations")}</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-foreground">
|
||||
{statsLoading ? "—" : statsData?.totalConsultations ?? 0}
|
||||
|
|
@ -123,11 +133,12 @@ export default function ConsultationHistory() {
|
|||
value={statsDays}
|
||||
onChange={(e) => setStatsDays(Number(e.target.value))}
|
||||
className="text-xs bg-background/50 border border-border/50 rounded px-1.5 py-0.5 text-muted-foreground"
|
||||
aria-label={t("history.statsRange")}
|
||||
>
|
||||
<option value={7}>7 jours</option>
|
||||
<option value={30}>30 jours</option>
|
||||
<option value={90}>90 jours</option>
|
||||
<option value={365}>1 an</option>
|
||||
<option value={7}>{t("history.range7")}</option>
|
||||
<option value={30}>{t("history.range30")}</option>
|
||||
<option value={90}>{t("history.range90")}</option>
|
||||
<option value={365}>{t("history.range365")}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -135,29 +146,29 @@ export default function ConsultationHistory() {
|
|||
<div className="bg-card/50 border border-border/50 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Clock className="w-4 h-4 text-blue-400" />
|
||||
<span className="text-xs text-muted-foreground">Durée moyenne</span>
|
||||
<span className="text-xs text-muted-foreground">{t("history.avgDuration")}</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-foreground">
|
||||
{statsLoading ? "—" : `${statsData?.avgDurationMinutes ?? 0} min`}
|
||||
{statsLoading ? "—" : `${statsData?.avgDurationMinutes ?? 0} ${t("history.minShort")}`}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">par consultation</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">{t("history.perConsultation")}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-card/50 border border-border/50 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-400" />
|
||||
<span className="text-xs text-muted-foreground">Taux de présence</span>
|
||||
<span className="text-xs text-muted-foreground">{t("history.presenceRate")}</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-foreground">
|
||||
{statsLoading ? "—" : `${statsData?.presenceRate ?? 100}%`}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">patients présents</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">{t("history.patientsPresent")}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-card/50 border border-border/50 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Stethoscope className="w-4 h-4 text-purple-400" />
|
||||
<span className="text-xs text-muted-foreground">Top motif</span>
|
||||
<span className="text-xs text-muted-foreground">{t("history.topReason")}</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold text-foreground truncate">
|
||||
{statsLoading ? "—" : (
|
||||
|
|
@ -168,7 +179,7 @@ export default function ConsultationHistory() {
|
|||
</p>
|
||||
{statsData?.topReasons?.[0] && (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{statsData.topReasons[0].count} consultations
|
||||
{t("history.consultationsCount", { count: statsData.topReasons[0].count })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -177,7 +188,7 @@ export default function ConsultationHistory() {
|
|||
{/* Top reasons breakdown */}
|
||||
{statsData?.topReasons && statsData.topReasons.length > 1 && (
|
||||
<div className="bg-card/50 border border-border/50 rounded-xl p-4">
|
||||
<h3 className="text-sm font-medium text-foreground mb-3">Répartition des motifs</h3>
|
||||
<h3 className="text-sm font-medium text-foreground mb-3">{t("history.reasonsBreakdown")}</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{statsData.topReasons.map((r) => {
|
||||
const info = VISIT_REASONS[r.reason];
|
||||
|
|
@ -201,36 +212,39 @@ export default function ConsultationHistory() {
|
|||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground block mb-1">
|
||||
<Calendar className="w-3 h-3 inline mr-1" />Du
|
||||
<Calendar className="w-3 h-3 inline mr-1" />{t("history.from")}
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => { setDateFrom(e.target.value); setPage(1); }}
|
||||
className="bg-background/50 border border-border/50 rounded-lg px-3 py-1.5 text-sm text-foreground"
|
||||
aria-label={t("history.from")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground block mb-1">
|
||||
<Calendar className="w-3 h-3 inline mr-1" />Au
|
||||
<Calendar className="w-3 h-3 inline mr-1" />{t("history.to")}
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => { setDateTo(e.target.value); setPage(1); }}
|
||||
className="bg-background/50 border border-border/50 rounded-lg px-3 py-1.5 text-sm text-foreground"
|
||||
aria-label={t("history.to")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground block mb-1">
|
||||
<Filter className="w-3 h-3 inline mr-1" />Motif
|
||||
<Filter className="w-3 h-3 inline mr-1" />{t("history.reason")}
|
||||
</label>
|
||||
<select
|
||||
value={filterReason}
|
||||
onChange={(e) => { setFilterReason(e.target.value); setPage(1); }}
|
||||
className="bg-background/50 border border-border/50 rounded-lg px-3 py-1.5 text-sm text-foreground"
|
||||
aria-label={t("history.reason")}
|
||||
>
|
||||
<option value="">Tous les motifs</option>
|
||||
<option value="">{t("history.allReasons")}</option>
|
||||
{Object.entries(VISIT_REASONS).map(([key, { label }]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
|
|
@ -238,7 +252,7 @@ export default function ConsultationHistory() {
|
|||
</div>
|
||||
{(dateFrom || dateTo || filterReason) && (
|
||||
<Button variant="ghost" size="sm" onClick={clearFilters} className="text-muted-foreground">
|
||||
<XCircle className="w-4 h-4 mr-1" /> Effacer
|
||||
<XCircle className="w-4 h-4 mr-1" /> {t("history.clear")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -253,9 +267,9 @@ export default function ConsultationHistory() {
|
|||
) : !historyData?.entries?.length ? (
|
||||
<div className="text-center py-12">
|
||||
<History className="w-10 h-10 text-muted-foreground/30 mx-auto mb-3" />
|
||||
<p className="text-sm text-muted-foreground">Aucune consultation trouvée</p>
|
||||
<p className="text-sm text-muted-foreground">{t("history.noResults")}</p>
|
||||
{(dateFrom || dateTo || filterReason) && (
|
||||
<p className="text-xs text-muted-foreground mt-1">Essayez de modifier vos filtres</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("history.tryEditingFilters")}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -263,13 +277,13 @@ export default function ConsultationHistory() {
|
|||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border/50 bg-background/30">
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground">Ticket</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground">Patient</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground">Motif</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground">Date</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground">Attente</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground">Durée</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground">Statut</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground">{t("history.colTicket")}</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground">{t("history.colPatient")}</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground">{t("history.colReason")}</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground">{t("history.colDate")}</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground">{t("history.colWait")}</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground">{t("history.colDuration")}</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground">{t("history.colStatus")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -288,7 +302,7 @@ export default function ConsultationHistory() {
|
|||
<tr key={entry.id} className="border-b border-border/30 hover:bg-background/20 transition-colors">
|
||||
<td className="px-4 py-3 font-mono font-bold text-foreground">#{entry.ticketNumber}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-foreground">{entry.patientName || "Anonyme"}</span>
|
||||
<span className="text-foreground">{entry.patientName || t("history.anonymous")}</span>
|
||||
{entry.visitNote && (
|
||||
<p className="text-xs text-muted-foreground italic truncate max-w-[200px]">{entry.visitNote}</p>
|
||||
)}
|
||||
|
|
@ -300,15 +314,15 @@ export default function ConsultationHistory() {
|
|||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{new Date(entry.joinedAt).toLocaleDateString("fr-FR", { day: "2-digit", month: "short", year: "numeric" })}
|
||||
{new Date(entry.joinedAt).toLocaleDateString(dateLocale, { day: "2-digit", month: "short", year: "numeric" })}
|
||||
<br />
|
||||
<span className="text-xs">{new Date(entry.joinedAt).toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })}</span>
|
||||
<span className="text-xs">{new Date(entry.joinedAt).toLocaleTimeString(dateLocale, { hour: "2-digit", minute: "2-digit" })}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{waitMin !== null ? `${waitMin} min` : "—"}
|
||||
{waitMin !== null ? `${waitMin} ${t("history.minShort")}` : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{durationMin !== null ? `${durationMin} min` : "—"}
|
||||
{durationMin !== null ? `${durationMin} ${t("history.minShort")}` : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${statusInfo.color}`}>
|
||||
|
|
@ -327,7 +341,7 @@ export default function ConsultationHistory() {
|
|||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-border/50">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Page {page} sur {totalPages} — {Number(historyData?.total ?? 0)} résultats
|
||||
{t("history.pageInfo", { page, totalPages, total: Number(historyData?.total ?? 0) })}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
|
|
@ -335,6 +349,7 @@ export default function ConsultationHistory() {
|
|||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
aria-label={t("history.previousPage")}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
|
|
@ -343,6 +358,7 @@ export default function ConsultationHistory() {
|
|||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
aria-label={t("history.nextPage")}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { useLocation } from "wouter";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Building2, Users, Clock, CreditCard, ChevronRight, Plus,
|
||||
Sparkles, Activity, BarChart3, HelpCircle, Loader2, TrendingUp,
|
||||
Calendar, AlertTriangle, QrCode,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -10,6 +12,7 @@ import { useAuth } from "@/_core/hooks/useAuth";
|
|||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function Dashboard() {
|
||||
const { t } = useTranslation();
|
||||
const [, navigate] = useLocation();
|
||||
const { user } = useAuth();
|
||||
|
||||
|
|
@ -24,17 +27,21 @@ export default function Dashboard() {
|
|||
const isTrialing = sub?.status === "trialing";
|
||||
const trialDaysLeft = sub?.daysRemaining ?? 0;
|
||||
const subExpired = !sub?.active;
|
||||
const greeting = (user?.name?.split(" ")[0] ?? "Docteur").trim();
|
||||
const greeting = (user?.name?.split(" ")[0] ?? t("dashboard.fallbackDoctor")).trim();
|
||||
|
||||
return (
|
||||
<div className="container py-8">
|
||||
<Helmet>
|
||||
<title>{t("dashboard.metaTitle")}</title>
|
||||
<meta name="description" content={t("dashboard.metaDescription")} />
|
||||
</Helmet>
|
||||
{/* Welcome */}
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between mb-8 gap-4">
|
||||
<div>
|
||||
<h1 className="font-bold text-3xl mb-1">
|
||||
Bonjour, <span className="gradient-text">{greeting}</span>
|
||||
{t("dashboard.hello")}, <span className="gradient-text">{greeting}</span>
|
||||
</h1>
|
||||
<p className="text-slate-600">Votre journée commence ici.</p>
|
||||
<p className="text-slate-600">{t("dashboard.dayStarts")}</p>
|
||||
</div>
|
||||
{isTrialing && (
|
||||
<div
|
||||
|
|
@ -47,14 +54,14 @@ export default function Dashboard() {
|
|||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
{trialDaysLeft > 0
|
||||
? `Essai gratuit — ${trialDaysLeft} jour${trialDaysLeft > 1 ? "s" : ""} restant${trialDaysLeft > 1 ? "s" : ""}`
|
||||
: "Essai expiré"}
|
||||
? t("dashboard.trialDaysLeft", { count: trialDaysLeft })
|
||||
: t("dashboard.trialExpired")}
|
||||
{trialDaysLeft <= 7 && (
|
||||
<button
|
||||
onClick={() => navigate("/dashboard/subscription")}
|
||||
className="ml-1 underline underline-offset-2"
|
||||
>
|
||||
S'abonner
|
||||
{t("dashboard.subscribe")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -62,8 +69,8 @@ export default function Dashboard() {
|
|||
{subExpired && !isTrialing && (
|
||||
<div className="px-4 py-2 rounded-xl border border-red-200 bg-red-50 text-red-700 text-sm font-semibold flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
Abonnement expiré
|
||||
<button onClick={() => navigate("/dashboard/subscription")} className="ml-1 underline">Renouveler</button>
|
||||
{t("dashboard.subscriptionExpired")}
|
||||
<button onClick={() => navigate("/dashboard/subscription")} className="ml-1 underline">{t("dashboard.renew")}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -71,10 +78,10 @@ export default function Dashboard() {
|
|||
{/* KPIs */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
{[
|
||||
{ label: "Cabinets actifs", value: clinics.length, icon: Building2, color: "from-emerald-500 to-teal-500" },
|
||||
{ label: "Patients (7j)", value: summary?.totalServed ?? 0, icon: Users, color: "from-cyan-500 to-blue-500" },
|
||||
{ label: "Attente moy.", value: summary ? `${summary.avgWaitMinutes} min` : "—", icon: Clock, color: "from-orange-500 to-amber-500" },
|
||||
{ label: "Plan", value: sub?.plan ?? "—", icon: CreditCard, color: "from-violet-500 to-fuchsia-500" },
|
||||
{ label: t("dashboard.kpiActiveClinics"), value: clinics.length, icon: Building2, color: "from-emerald-500 to-teal-500" },
|
||||
{ label: t("dashboard.kpiPatients7d"), value: summary?.totalServed ?? 0, icon: Users, color: "from-cyan-500 to-blue-500" },
|
||||
{ label: t("dashboard.kpiAvgWaitShort"), value: summary ? `${summary.avgWaitMinutes} ${t("dashboard.minutesShort")}` : "—", icon: Clock, color: "from-orange-500 to-amber-500" },
|
||||
{ label: t("dashboard.kpiPlan"), value: sub?.plan ?? "—", icon: CreditCard, color: "from-violet-500 to-fuchsia-500" },
|
||||
].map((stat) => {
|
||||
const Icon = stat.icon;
|
||||
return (
|
||||
|
|
@ -92,13 +99,13 @@ export default function Dashboard() {
|
|||
{/* Clinics */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-bold text-xl">Vos cabinets</h2>
|
||||
<h2 className="font-bold text-xl">{t("dashboard.yourClinics")}</h2>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate("/dashboard/clinics")}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1.5" /> Gérer
|
||||
<Plus className="w-4 h-4 mr-1.5" /> {t("dashboard.manage")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
|
@ -111,16 +118,16 @@ export default function Dashboard() {
|
|||
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-emerald-500 to-cyan-500 flex items-center justify-center mx-auto mb-4 shadow-lg glow-emerald">
|
||||
<Sparkles className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h3 className="font-bold text-xl mb-2">Bienvenue sur QueueMed !</h3>
|
||||
<h3 className="font-bold text-xl mb-2">{t("dashboard.welcomeTitle")}</h3>
|
||||
<p className="text-slate-600 text-sm mb-6 max-w-sm mx-auto">
|
||||
Configurez votre premier cabinet en 2 minutes avec notre assistant.
|
||||
{t("dashboard.welcomeSubtitle")}
|
||||
</p>
|
||||
<div className="flex gap-3 justify-center flex-wrap">
|
||||
<Button variant="gradient" onClick={() => navigate("/onboarding")}>
|
||||
<Sparkles className="w-4 h-4 mr-2" /> Démarrer la configuration
|
||||
<Sparkles className="w-4 h-4 mr-2" /> {t("dashboard.startSetup")}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => navigate("/dashboard/clinics")}>
|
||||
<Plus className="w-4 h-4 mr-2" /> Créer manuellement
|
||||
<Plus className="w-4 h-4 mr-2" /> {t("dashboard.createManually")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -150,7 +157,7 @@ export default function Dashboard() {
|
|||
: "bg-slate-100 text-slate-500 border-slate-200"
|
||||
)}
|
||||
>
|
||||
{clinic.isQueueOpen ? "Ouvert" : "Fermé"}
|
||||
{clinic.isQueueOpen ? t("dashboard.statusOpen") : t("dashboard.statusClosed")}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="font-bold mb-1 text-slate-900">{clinic.name}</h3>
|
||||
|
|
@ -158,7 +165,7 @@ export default function Dashboard() {
|
|||
<p className="text-slate-500 text-xs mb-3 truncate">{clinic.address}</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-500 text-xs">~{clinic.avgConsultationMinutes ?? 15} min/patient</span>
|
||||
<span className="text-slate-500 text-xs">~{clinic.avgConsultationMinutes ?? 15} {t("dashboard.minPerPatient")}</span>
|
||||
<ChevronRight className="w-4 h-4 text-slate-400 group-hover:text-emerald-600 transition-colors" />
|
||||
</div>
|
||||
</button>
|
||||
|
|
@ -169,13 +176,13 @@ export default function Dashboard() {
|
|||
|
||||
{/* Quick links */}
|
||||
<div>
|
||||
<h2 className="font-bold text-xl mb-4">Accès rapide</h2>
|
||||
<h2 className="font-bold text-xl mb-4">{t("dashboard.quickAccess")}</h2>
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ icon: BarChart3, label: "Analytics", desc: "Statistiques & IA", path: "/dashboard/analytics", color: "from-pink-500 to-rose-500" },
|
||||
{ icon: TrendingUp, label: "Abonnement", desc: "Gérer votre plan", path: "/dashboard/subscription", color: "from-violet-500 to-purple-500" },
|
||||
{ icon: Activity, label: "Affichage", desc: "Écran salle d'attente", path: clinics[0] ? `/display/${clinics[0].id}` : "/dashboard/clinics", color: "from-cyan-500 to-blue-500" },
|
||||
{ icon: HelpCircle, label: "Aide", desc: "Centre d'aide & FAQ", path: "/help", color: "from-amber-500 to-orange-500" },
|
||||
{ icon: BarChart3, label: t("dashboard.quick.analytics.label"), desc: t("dashboard.quick.analytics.desc"), path: "/dashboard/analytics", color: "from-pink-500 to-rose-500" },
|
||||
{ icon: TrendingUp, label: t("dashboard.quick.subscription.label"), desc: t("dashboard.quick.subscription.desc"), path: "/dashboard/subscription", color: "from-violet-500 to-purple-500" },
|
||||
{ icon: Activity, label: t("dashboard.quick.display.label"), desc: t("dashboard.quick.display.desc"), path: clinics[0] ? `/display/${clinics[0].id}` : "/dashboard/clinics", color: "from-cyan-500 to-blue-500" },
|
||||
{ icon: HelpCircle, label: t("dashboard.quick.help.label"), desc: t("dashboard.quick.help.desc"), path: "/help", color: "from-amber-500 to-orange-500" },
|
||||
].map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "wouter";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Stethoscope, Wifi, WifiOff, Loader2, Clock, Users } from "lucide-react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
|
|
@ -7,6 +9,7 @@ import { getSocket } from "@/lib/socket";
|
|||
import { formatTicket } from "@/lib/utils";
|
||||
|
||||
export default function DisplayScreen() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const params = useParams<{ clinicId: string }>();
|
||||
const clinicId = Number(params.clinicId ?? 0);
|
||||
const utils = trpc.useUtils();
|
||||
|
|
@ -47,6 +50,10 @@ export default function DisplayScreen() {
|
|||
if (queueQuery.isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-emerald-50 via-white to-cyan-50">
|
||||
<Helmet>
|
||||
<title>{t("display.metaTitle")}</title>
|
||||
<meta name="description" content={t("display.metaDescription")} />
|
||||
</Helmet>
|
||||
<Loader2 className="w-12 h-12 text-emerald-500 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
|
|
@ -55,9 +62,13 @@ export default function DisplayScreen() {
|
|||
if (queueQuery.error || !queueQuery.data) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-8 bg-gradient-to-br from-emerald-50 via-white to-cyan-50">
|
||||
<Helmet>
|
||||
<title>{t("display.metaTitle")}</title>
|
||||
<meta name="description" content={t("display.metaDescription")} />
|
||||
</Helmet>
|
||||
<div className="glass-card-strong rounded-3xl p-12 text-center max-w-md">
|
||||
<h1 className="font-bold text-3xl mb-3">Cabinet introuvable</h1>
|
||||
<p className="text-slate-500">Vérifiez l'URL ou contactez le médecin.</p>
|
||||
<h1 className="font-bold text-3xl mb-3">{t("display.clinicNotFound")}</h1>
|
||||
<p className="text-slate-500">{t("display.clinicNotFoundDesc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -67,8 +78,14 @@ export default function DisplayScreen() {
|
|||
const upcoming = queue.filter((e) => e.status === "waiting").slice(0, 5);
|
||||
const accent = clinic.color ?? "#10b981";
|
||||
|
||||
const dateLocale = i18n.language?.startsWith("en") ? "en-US" : "fr-FR";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-emerald-50 via-white to-cyan-50 relative overflow-hidden">
|
||||
<Helmet>
|
||||
<title>{t("display.metaTitle")}</title>
|
||||
<meta name="description" content={t("display.metaDescription")} />
|
||||
</Helmet>
|
||||
{/* Animated bg blobs */}
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
<div className="absolute -top-40 -left-40 w-[40rem] h-[40rem] rounded-full bg-emerald-300/30 blur-3xl animate-pulse-glow" />
|
||||
|
|
@ -86,17 +103,17 @@ export default function DisplayScreen() {
|
|||
</div>
|
||||
<div>
|
||||
<h1 className="font-bold text-2xl text-slate-900">{clinic.name}</h1>
|
||||
<p className="text-sm text-slate-500">QueueMed — File en direct</p>
|
||||
<p className="text-sm text-slate-500">{t("display.brandTagline")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="text-right">
|
||||
<div className="text-3xl font-bold text-slate-900 tabular-nums">
|
||||
{now.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })}
|
||||
{now.toLocaleTimeString(dateLocale, { hour: "2-digit", minute: "2-digit" })}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 capitalize">
|
||||
{now.toLocaleDateString("fr-FR", { weekday: "long", day: "numeric", month: "long" })}
|
||||
{now.toLocaleDateString(dateLocale, { weekday: "long", day: "numeric", month: "long" })}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
|
|
@ -105,7 +122,7 @@ export default function DisplayScreen() {
|
|||
}`}
|
||||
>
|
||||
{connected ? <Wifi className="w-3 h-3" /> : <WifiOff className="w-3 h-3" />}
|
||||
{connected ? "En direct" : "Reconnexion..."}
|
||||
{connected ? t("display.live") : t("display.reconnecting")}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
|
@ -116,7 +133,7 @@ export default function DisplayScreen() {
|
|||
<div className="flex items-center justify-center">
|
||||
<div className="glass-card-strong rounded-[3rem] p-12 text-center w-full max-w-2xl shadow-2xl">
|
||||
<div className="text-sm uppercase tracking-[0.3em] text-emerald-700 font-bold mb-6">
|
||||
Patient appelé
|
||||
{t("display.patientCalled")}
|
||||
</div>
|
||||
<AnimatePresence mode="wait">
|
||||
{callingNow ? (
|
||||
|
|
@ -150,7 +167,7 @@ export default function DisplayScreen() {
|
|||
transition={{ duration: 2, repeat: Infinity }}
|
||||
className="mt-8 inline-flex px-6 py-3 rounded-full bg-emerald-100 border-2 border-emerald-300 text-emerald-700 font-bold uppercase tracking-widest"
|
||||
>
|
||||
Salle de consultation
|
||||
{t("display.consultationRoom")}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
) : (
|
||||
|
|
@ -162,7 +179,7 @@ export default function DisplayScreen() {
|
|||
>
|
||||
<Users className="w-32 h-32 text-slate-200 mx-auto mb-6" />
|
||||
<div className="text-3xl font-bold text-slate-400">
|
||||
{clinic.isQueueOpen ? "Aucun patient appelé" : "File fermée"}
|
||||
{clinic.isQueueOpen ? t("display.noPatientCalled") : t("display.queueClosed")}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
|
@ -175,15 +192,15 @@ export default function DisplayScreen() {
|
|||
<div className="glass-card-strong rounded-3xl p-6 flex-1 shadow-xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="font-bold text-2xl text-slate-900">Prochains</h2>
|
||||
<p className="text-sm text-slate-500">{waitingCount} en attente</p>
|
||||
<h2 className="font-bold text-2xl text-slate-900">{t("display.upcoming")}</h2>
|
||||
<p className="text-sm text-slate-500">{t("display.waitingCount", { count: waitingCount })}</p>
|
||||
</div>
|
||||
<div
|
||||
className={`px-4 py-2 rounded-full text-sm font-bold ${
|
||||
clinic.isQueueOpen ? "bg-emerald-500 text-white" : "bg-slate-200 text-slate-500"
|
||||
}`}
|
||||
>
|
||||
{clinic.isQueueOpen ? "OUVERT" : "FERMÉ"}
|
||||
{clinic.isQueueOpen ? t("display.statusOpen") : t("display.statusClosed")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -191,7 +208,7 @@ export default function DisplayScreen() {
|
|||
<AnimatePresence>
|
||||
{upcoming.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
Aucun patient en attente
|
||||
{t("display.noWaiting")}
|
||||
</div>
|
||||
) : (
|
||||
upcoming.map((e, i) => (
|
||||
|
|
@ -218,18 +235,18 @@ export default function DisplayScreen() {
|
|||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-bold text-slate-900 truncate">
|
||||
{e.patientName ?? "Patient anonyme"}
|
||||
{e.patientName ?? t("display.anonymousPatient")}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500 mt-0.5">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
~{e.estimatedWaitMinutes ?? "?"} min
|
||||
~{e.estimatedWaitMinutes ?? "?"} {t("display.minShort")}
|
||||
<span>·</span>
|
||||
<span>Position {e.position}</span>
|
||||
<span>{t("display.position")} {e.position}</span>
|
||||
</div>
|
||||
</div>
|
||||
{i === 0 && (
|
||||
<div className="text-xs uppercase tracking-wider font-bold text-emerald-700">
|
||||
SUIVANT
|
||||
{t("display.nextLabel")}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
|
@ -244,7 +261,7 @@ export default function DisplayScreen() {
|
|||
{/* Ticker */}
|
||||
<footer className="relative z-10 border-t border-emerald-100/60 backdrop-blur-md bg-white/40 overflow-hidden h-12 flex items-center">
|
||||
<div className="whitespace-nowrap text-emerald-700 font-medium text-sm animate-ticker px-8">
|
||||
✨ Bienvenue au {clinic.name} — Scannez le QR code à l'accueil pour rejoindre la file en ligne — Suivez votre position en temps réel sur votre téléphone — Vous serez notifié quand votre tour approche
|
||||
{t("display.ticker", { clinic: clinic.name })}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Building2, Plus, Edit, Trash2, QrCode, Loader2, Power,
|
||||
PowerOff, RefreshCw, Monitor, Printer, Settings,
|
||||
|
|
@ -34,6 +36,7 @@ const EMPTY_FORM: ClinicForm = {
|
|||
};
|
||||
|
||||
export default function DoctorClinics() {
|
||||
const { t } = useTranslation();
|
||||
const [, navigate] = useLocation();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
|
|
@ -45,7 +48,7 @@ export default function DoctorClinics() {
|
|||
|
||||
const createMutation = trpc.clinic.create.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Cabinet créé !");
|
||||
toast.success(t("clinics.toastCreated"));
|
||||
utils.clinic.list.invalidate();
|
||||
setEditing(null);
|
||||
},
|
||||
|
|
@ -54,7 +57,7 @@ export default function DoctorClinics() {
|
|||
|
||||
const updateMutation = trpc.clinic.update.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Cabinet mis à jour");
|
||||
toast.success(t("clinics.toastUpdated"));
|
||||
utils.clinic.list.invalidate();
|
||||
setEditing(null);
|
||||
},
|
||||
|
|
@ -63,7 +66,7 @@ export default function DoctorClinics() {
|
|||
|
||||
const deleteMutation = trpc.clinic.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Cabinet supprimé");
|
||||
toast.success(t("clinics.toastDeleted"));
|
||||
utils.clinic.list.invalidate();
|
||||
setConfirmDelete(null);
|
||||
},
|
||||
|
|
@ -72,7 +75,7 @@ export default function DoctorClinics() {
|
|||
|
||||
const regenMutation = trpc.clinic.regenerateQr.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Nouveau QR code généré");
|
||||
toast.success(t("clinics.toastQrRegenerated"));
|
||||
utils.clinic.qrDataUrl.invalidate();
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
|
|
@ -89,7 +92,7 @@ export default function DoctorClinics() {
|
|||
if (!editing) return;
|
||||
const f = editing.form;
|
||||
if (!f.name.trim() || f.name.trim().length < 2) {
|
||||
toast.error("Le nom du cabinet est requis (≥ 2 caractères).");
|
||||
toast.error(t("clinics.errorNameRequired"));
|
||||
return;
|
||||
}
|
||||
if (editing.id) {
|
||||
|
|
@ -118,13 +121,17 @@ export default function DoctorClinics() {
|
|||
|
||||
return (
|
||||
<div className="container py-8">
|
||||
<Helmet>
|
||||
<title>{t("clinics.metaTitle")}</title>
|
||||
<meta name="description" content={t("clinics.metaDescription")} />
|
||||
</Helmet>
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="font-bold text-3xl mb-1">Mes cabinets</h1>
|
||||
<p className="text-slate-600">Gérez vos cabinets, leurs QR codes et leurs paramètres.</p>
|
||||
<h1 className="font-bold text-3xl mb-1">{t("clinics.title")}</h1>
|
||||
<p className="text-slate-600">{t("clinics.subtitle")}</p>
|
||||
</div>
|
||||
<Button variant="gradient" onClick={() => setEditing({ id: null, form: { ...EMPTY_FORM } })}>
|
||||
<Plus className="w-4 h-4 mr-1.5" /> Nouveau cabinet
|
||||
<Plus className="w-4 h-4 mr-1.5" /> {t("clinics.newClinic")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
|
@ -135,10 +142,10 @@ export default function DoctorClinics() {
|
|||
) : clinics.length === 0 ? (
|
||||
<div className="glass-card rounded-3xl p-12 text-center">
|
||||
<Building2 className="w-12 h-12 text-slate-300 mx-auto mb-4" />
|
||||
<h3 className="font-bold text-xl mb-2">Aucun cabinet pour le moment</h3>
|
||||
<p className="text-slate-500 text-sm mb-6">Créez votre premier cabinet pour démarrer.</p>
|
||||
<h3 className="font-bold text-xl mb-2">{t("clinics.emptyTitle")}</h3>
|
||||
<p className="text-slate-500 text-sm mb-6">{t("clinics.emptySubtitle")}</p>
|
||||
<Button variant="gradient" onClick={() => setEditing({ id: null, form: { ...EMPTY_FORM } })}>
|
||||
<Plus className="w-4 h-4 mr-1.5" /> Créer un cabinet
|
||||
<Plus className="w-4 h-4 mr-1.5" /> {t("clinics.createClinic")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -168,28 +175,28 @@ export default function DoctorClinics() {
|
|||
: "bg-slate-100 text-slate-500 border-slate-200"
|
||||
}`}
|
||||
>
|
||||
{c.isQueueOpen ? "Ouvert" : "Fermé"}
|
||||
{c.isQueueOpen ? t("clinics.statusOpen") : t("clinics.statusClosed")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 text-center mb-4">
|
||||
<div className="p-2 rounded-lg bg-slate-50 border border-slate-100">
|
||||
<div className="text-[10px] uppercase text-slate-500 font-semibold">Cons.</div>
|
||||
<div className="font-bold text-sm">{c.avgConsultationMinutes} min</div>
|
||||
<div className="text-[10px] uppercase text-slate-500 font-semibold">{t("clinics.statCons")}</div>
|
||||
<div className="font-bold text-sm">{c.avgConsultationMinutes} {t("clinics.minutesShort")}</div>
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-slate-50 border border-slate-100">
|
||||
<div className="text-[10px] uppercase text-slate-500 font-semibold">Max</div>
|
||||
<div className="text-[10px] uppercase text-slate-500 font-semibold">{t("clinics.statMax")}</div>
|
||||
<div className="font-bold text-sm">{c.maxQueueSize}</div>
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-slate-50 border border-slate-100">
|
||||
<div className="text-[10px] uppercase text-slate-500 font-semibold">QR rot.</div>
|
||||
<div className="text-[10px] uppercase text-slate-500 font-semibold">{t("clinics.statQrRot")}</div>
|
||||
<div className="font-bold text-sm">{c.qrRotationMinutes ? `${c.qrRotationMinutes}m` : "—"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="default" onClick={() => navigate(`/dashboard/queue/${c.id}`)}>
|
||||
<Settings className="w-3.5 h-3.5 mr-1" /> Gérer la file
|
||||
<Settings className="w-3.5 h-3.5 mr-1" /> {t("clinics.manageQueue")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
@ -199,13 +206,13 @@ export default function DoctorClinics() {
|
|||
}
|
||||
disabled={toggleQueueMutation.isPending}
|
||||
>
|
||||
{c.isQueueOpen ? <><PowerOff className="w-3.5 h-3.5 mr-1" /> Fermer</> : <><Power className="w-3.5 h-3.5 mr-1" /> Ouvrir</>}
|
||||
{c.isQueueOpen ? <><PowerOff className="w-3.5 h-3.5 mr-1" /> {t("clinics.close")}</> : <><Power className="w-3.5 h-3.5 mr-1" /> {t("clinics.open")}</>}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setQrFor(c.id)}>
|
||||
<QrCode className="w-3.5 h-3.5 mr-1" /> QR
|
||||
<QrCode className="w-3.5 h-3.5 mr-1" /> {t("clinics.qr")}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => window.open(`/display/${c.id}`, "_blank")}>
|
||||
<Monitor className="w-3.5 h-3.5 mr-1" /> Écran
|
||||
<Monitor className="w-3.5 h-3.5 mr-1" /> {t("clinics.screen")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
@ -225,7 +232,7 @@ export default function DoctorClinics() {
|
|||
})
|
||||
}
|
||||
>
|
||||
<Edit className="w-3.5 h-3.5 mr-1" /> Éditer
|
||||
<Edit className="w-3.5 h-3.5 mr-1" /> {t("clinics.editAction")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
@ -245,40 +252,40 @@ export default function DoctorClinics() {
|
|||
<Dialog open={!!editing} onOpenChange={(open) => !open && setEditing(null)}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing?.id ? "Modifier le cabinet" : "Nouveau cabinet"}</DialogTitle>
|
||||
<DialogTitle>{editing?.id ? t("clinics.dialogEditTitle") : t("clinics.dialogCreateTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configurez les informations et paramètres de la salle d'attente.
|
||||
{t("clinics.dialogDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{editing && (
|
||||
<div className="space-y-4 max-h-[60vh] overflow-y-auto pr-1">
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Nom du cabinet *</Label>
|
||||
<Label className="mb-1.5 block">{t("clinics.fieldName")} *</Label>
|
||||
<Input
|
||||
placeholder="Ex: Cabinet Dr. Martin"
|
||||
placeholder={t("clinics.placeholderName")}
|
||||
value={editing.form.name}
|
||||
onChange={(e) => setEditing({ ...editing, form: { ...editing.form, name: e.target.value } })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Adresse</Label>
|
||||
<Label className="mb-1.5 block">{t("clinics.fieldAddress")}</Label>
|
||||
<Input
|
||||
placeholder="Ex: 12 rue de la Paix, Paris"
|
||||
placeholder={t("clinics.placeholderAddress")}
|
||||
value={editing.form.address}
|
||||
onChange={(e) => setEditing({ ...editing, form: { ...editing.form, address: e.target.value } })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Téléphone</Label>
|
||||
<Label className="mb-1.5 block">{t("clinics.fieldPhone")}</Label>
|
||||
<Input
|
||||
placeholder="01 23 45 67 89"
|
||||
placeholder={t("clinics.placeholderPhone")}
|
||||
value={editing.form.phone}
|
||||
onChange={(e) => setEditing({ ...editing, form: { ...editing.form, phone: e.target.value } })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Couleur</Label>
|
||||
<Label className="mb-1.5 block">{t("clinics.fieldColor")}</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
|
|
@ -290,7 +297,7 @@ export default function DoctorClinics() {
|
|||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Durée moyenne consultation: <span className="text-emerald-700 font-bold">{editing.form.avgConsultationMinutes} min</span></Label>
|
||||
<Label className="mb-1.5 block">{t("clinics.fieldAvgConsultation")}: <span className="text-emerald-700 font-bold">{editing.form.avgConsultationMinutes} {t("clinics.minutesShort")}</span></Label>
|
||||
<input
|
||||
type="range" min={5} max={60} step={5}
|
||||
value={editing.form.avgConsultationMinutes}
|
||||
|
|
@ -299,7 +306,7 @@ export default function DoctorClinics() {
|
|||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Taille max file: <span className="text-emerald-700 font-bold">{editing.form.maxQueueSize}</span></Label>
|
||||
<Label className="mb-1.5 block">{t("clinics.fieldMaxQueue")}: <span className="text-emerald-700 font-bold">{editing.form.maxQueueSize}</span></Label>
|
||||
<input
|
||||
type="range" min={5} max={200} step={5}
|
||||
value={editing.form.maxQueueSize}
|
||||
|
|
@ -308,7 +315,7 @@ export default function DoctorClinics() {
|
|||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Rotation QR (anti-triche)</Label>
|
||||
<Label className="mb-1.5 block">{t("clinics.fieldQrRotation")}</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[0, 30, 60, 120, 240].map((v) => (
|
||||
<button
|
||||
|
|
@ -320,7 +327,7 @@ export default function DoctorClinics() {
|
|||
: "bg-white border-slate-200 text-slate-600 hover:border-emerald-400"
|
||||
}`}
|
||||
>
|
||||
{v === 0 ? "Désactivé" : v < 60 ? `${v} min` : `${v / 60}h`}
|
||||
{v === 0 ? t("clinics.qrDisabled") : v < 60 ? `${v} ${t("clinics.minutesShort")}` : `${v / 60}h`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -329,14 +336,14 @@ export default function DoctorClinics() {
|
|||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditing(null)}>Annuler</Button>
|
||||
<Button variant="outline" onClick={() => setEditing(null)}>{t("clinics.cancel")}</Button>
|
||||
<Button
|
||||
variant="gradient"
|
||||
onClick={submit}
|
||||
disabled={createMutation.isPending || updateMutation.isPending}
|
||||
>
|
||||
{(createMutation.isPending || updateMutation.isPending) && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
{editing?.id ? "Enregistrer" : "Créer"}
|
||||
{editing?.id ? t("clinics.save") : t("clinics.create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
@ -346,17 +353,17 @@ export default function DoctorClinics() {
|
|||
<Dialog open={qrFor !== null} onOpenChange={(open) => !open && setQrFor(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>QR Code du cabinet</DialogTitle>
|
||||
<DialogTitle>{t("clinics.qrDialogTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Affichez ce QR à l'accueil. Vos patients le scannent pour rejoindre la file.
|
||||
{t("clinics.qrDialogDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{qrFor && <QrPreview clinicId={qrFor} onRegenerate={() => regenMutation.mutate({ id: qrFor })} regenLoading={regenMutation.isPending} />}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => navigate(`/dashboard/poster/${qrFor}`)}>
|
||||
<Printer className="w-4 h-4 mr-2" /> Affiche A4
|
||||
<Printer className="w-4 h-4 mr-2" /> {t("clinics.posterA4")}
|
||||
</Button>
|
||||
<Button variant="default" onClick={() => setQrFor(null)}>Fermer</Button>
|
||||
<Button variant="default" onClick={() => setQrFor(null)}>{t("clinics.closeButton")}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
@ -365,20 +372,20 @@ export default function DoctorClinics() {
|
|||
<Dialog open={confirmDelete !== null} onOpenChange={(open) => !open && setConfirmDelete(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Supprimer ce cabinet ?</DialogTitle>
|
||||
<DialogTitle>{t("clinics.deleteDialogTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Cette action est irréversible. Toute la file et l'historique seront perdus.
|
||||
{t("clinics.deleteDialogDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setConfirmDelete(null)}>Annuler</Button>
|
||||
<Button variant="outline" onClick={() => setConfirmDelete(null)}>{t("clinics.cancel")}</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => confirmDelete && deleteMutation.mutate({ id: confirmDelete })}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
Supprimer définitivement
|
||||
{t("clinics.deletePermanently")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
@ -388,6 +395,7 @@ export default function DoctorClinics() {
|
|||
}
|
||||
|
||||
function QrPreview({ clinicId, onRegenerate, regenLoading }: { clinicId: number; onRegenerate: () => void; regenLoading: boolean }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const baseUrl = typeof window !== "undefined" ? window.location.origin : undefined;
|
||||
const qrQuery = trpc.clinic.qrDataUrl.useQuery({ id: clinicId, baseUrl }, { enabled: !!clinicId });
|
||||
|
||||
|
|
@ -400,22 +408,24 @@ function QrPreview({ clinicId, onRegenerate, regenLoading }: { clinicId: number;
|
|||
}
|
||||
if (!qrQuery.data) return null;
|
||||
|
||||
const locale = i18n.language?.startsWith("fr") ? "fr-FR" : "en-US";
|
||||
|
||||
return (
|
||||
<div className="text-center py-2">
|
||||
<img
|
||||
src={qrQuery.data.dataUrl}
|
||||
alt="QR code"
|
||||
alt={t("clinics.qrAlt")}
|
||||
className="w-56 h-56 mx-auto rounded-xl border border-slate-200 shadow-md mb-4"
|
||||
/>
|
||||
<div className="text-xs text-slate-500 mb-3 break-all max-w-xs mx-auto">{qrQuery.data.url}</div>
|
||||
{qrQuery.data.qrTokenExpiresAt && (
|
||||
<div className="text-xs text-slate-500 mb-4">
|
||||
Expire le {new Date(qrQuery.data.qrTokenExpiresAt).toLocaleString("fr-FR")}
|
||||
{t("clinics.expiresOn")} {new Date(qrQuery.data.qrTokenExpiresAt).toLocaleString(locale)}
|
||||
</div>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={onRegenerate} disabled={regenLoading}>
|
||||
{regenLoading ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5 mr-1" />}
|
||||
Régénérer
|
||||
{t("clinics.regenerate")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ChevronLeft, ChevronDown, ChevronUp, Search,
|
||||
Stethoscope, Users, Wifi, CreditCard, Monitor,
|
||||
|
|
@ -16,174 +18,85 @@ interface FaqItem {
|
|||
category: string;
|
||||
}
|
||||
|
||||
const FAQ: FaqItem[] = [
|
||||
// Getting Started
|
||||
{
|
||||
category: "Démarrage",
|
||||
q: "Comment créer mon premier cabinet ?",
|
||||
a: "Lors de votre première connexion, suivez l'assistant de configuration en cliquant sur 'Démarrer la configuration' depuis le tableau de bord. Renseignez le nom, l'adresse optionnelle et les paramètres de la file (durée de consultation moyenne, taille maximale). Un QR code unique est généré automatiquement.",
|
||||
},
|
||||
{
|
||||
category: "Démarrage",
|
||||
q: "Comment imprimer mon affiche QR code ?",
|
||||
a: "Depuis la page de gestion d'un cabinet, cliquez sur 'Affiche QR'. La page affiche un poster A4 prêt à imprimer. Utilisez du papier couleur si possible, plastifiez l'affiche et placez-la à hauteur des yeux à l'entrée du cabinet.",
|
||||
},
|
||||
{
|
||||
category: "Démarrage",
|
||||
q: "Combien de temps faut-il pour configurer QueueMed ?",
|
||||
a: "Environ 2 minutes : créez votre compte, configurez votre premier cabinet et imprimez le QR code. Vous pouvez accueillir vos premiers patients en moins de 5 minutes au total.",
|
||||
},
|
||||
const FAQ_KEYS = [
|
||||
{ id: "createClinic", catKey: "gettingStarted" },
|
||||
{ id: "printPoster", catKey: "gettingStarted" },
|
||||
{ id: "setupTime", catKey: "gettingStarted" },
|
||||
{ id: "openCloseQueue", catKey: "queueManagement" },
|
||||
{ id: "callNext", catKey: "queueManagement" },
|
||||
{ id: "noShow", catKey: "queueManagement" },
|
||||
{ id: "reorder", catKey: "queueManagement" },
|
||||
{ id: "printedTicket", catKey: "queueManagement" },
|
||||
{ id: "patientJoin", catKey: "patientExperience" },
|
||||
{ id: "patientLeave", catKey: "patientExperience" },
|
||||
{ id: "qrRotation", catKey: "patientExperience" },
|
||||
{ id: "notification", catKey: "patientExperience" },
|
||||
{ id: "displaySetup", catKey: "displayScreen" },
|
||||
{ id: "displayHardware", catKey: "displayScreen" },
|
||||
{ id: "internetOutage", catKey: "displayScreen" },
|
||||
{ id: "trialDuration", catKey: "subscription" },
|
||||
{ id: "afterTrial", catKey: "subscription" },
|
||||
{ id: "cancelSub", catKey: "subscription" },
|
||||
{ id: "clinicCount", catKey: "subscription" },
|
||||
{ id: "devices", catKey: "technical" },
|
||||
{ id: "dataSecurity", catKey: "technical" },
|
||||
{ id: "exportStats", catKey: "technical" },
|
||||
{ id: "offline", catKey: "technical" },
|
||||
] as const;
|
||||
|
||||
// Managing Queue
|
||||
{
|
||||
category: "Gestion de la file",
|
||||
q: "Comment ouvrir et fermer la file d'attente ?",
|
||||
a: "Dans la page 'Gestion de la file', sélectionnez votre cabinet et cliquez sur 'Ouvrir la file'. Les patients pourront alors rejoindre. En fin de journée, cliquez sur 'Fermer la file' puis 'Réinitialiser' pour repartir à zéro le lendemain.",
|
||||
},
|
||||
{
|
||||
category: "Gestion de la file",
|
||||
q: "Comment appeler le prochain patient ?",
|
||||
a: "Cliquez sur 'Appeler le suivant' dans l'interface de gestion. Le numéro s'affiche automatiquement sur l'écran d'affichage en salle d'attente et le patient reçoit une notification push sur son téléphone.",
|
||||
},
|
||||
{
|
||||
category: "Gestion de la file",
|
||||
q: "Que faire si un patient ne se présente pas ?",
|
||||
a: "Cliquez sur 'Absent' à côté du nom du patient. Il est retiré de la file et les positions des autres patients se mettent à jour automatiquement. Le patient devra rescanner le QR code pour rejoindre à nouveau.",
|
||||
},
|
||||
{
|
||||
category: "Gestion de la file",
|
||||
q: "Puis-je réorganiser l'ordre des patients ?",
|
||||
a: "Oui. Dans la liste de la file, glissez-déposez les patients pour modifier leur ordre. Les positions et temps d'attente se recalculent en direct, et chaque patient reçoit la mise à jour sur son téléphone.",
|
||||
},
|
||||
{
|
||||
category: "Gestion de la file",
|
||||
q: "Comment imprimer un ticket pour un patient sans smartphone ?",
|
||||
a: "Dans l'interface de gestion, cliquez sur 'Ajouter un patient' puis cochez 'Sans smartphone'. Un ticket imprimable s'ouvre dans un nouvel onglet avec le numéro et la position. Donnez-le au patient — il suivra son tour à l'écran d'affichage.",
|
||||
},
|
||||
|
||||
// Patient Experience
|
||||
{
|
||||
category: "Expérience patient",
|
||||
q: "Comment un patient rejoint-il la file ?",
|
||||
a: "Le patient ouvre l'appareil photo de son smartphone et scanne le QR code affiché à l'accueil. Un lien s'ouvre automatiquement — il appuie dessus pour rejoindre la file. Aucune application à installer.",
|
||||
},
|
||||
{
|
||||
category: "Expérience patient",
|
||||
q: "Le patient peut-il quitter la salle d'attente physique ?",
|
||||
a: "Oui, c'est l'avantage principal de QueueMed. Le patient garde la page ouverte sur son téléphone et peut s'éloigner. Il reçoit une notification push lorsque son tour approche. Recommandez-lui de rester à moins de 5 minutes du cabinet.",
|
||||
},
|
||||
{
|
||||
category: "Expérience patient",
|
||||
q: "Pourquoi le QR code ne fonctionne plus parfois ?",
|
||||
a: "Le QR code se renouvelle automatiquement à intervalles réguliers (système anti-triche) pour éviter le partage frauduleux du lien hors du cabinet. Si un patient obtient une erreur, il lui suffit de rescanner le QR code à l'accueil.",
|
||||
},
|
||||
{
|
||||
category: "Expérience patient",
|
||||
q: "Le patient reçoit-il bien la notification ?",
|
||||
a: "Lors du premier accès, le navigateur lui demande l'autorisation des notifications. S'il accepte, il recevra une notification push + vibration quand son tour est appelé. Sinon, la page reste à jour en temps réel tant qu'elle est ouverte.",
|
||||
},
|
||||
|
||||
// Display Screen
|
||||
{
|
||||
category: "Écran d'affichage",
|
||||
q: "Comment configurer l'écran d'affichage ?",
|
||||
a: "Dans la fiche de votre cabinet, copiez le 'Lien écran d'affichage' (/display/:clinicId). Ouvrez ce lien sur votre tablette ou moniteur de salle d'attente, puis activez le mode plein écran (F11 sur PC). L'écran se met à jour automatiquement via WebSocket.",
|
||||
},
|
||||
{
|
||||
category: "Écran d'affichage",
|
||||
q: "Quel matériel utiliser pour l'écran ?",
|
||||
a: "N'importe quelle tablette, moniteur ou TV connectée à internet et dotée d'un navigateur moderne (Chrome, Safari, Edge). Une simple tablette Android à 80 € fait parfaitement l'affaire.",
|
||||
},
|
||||
{
|
||||
category: "Écran d'affichage",
|
||||
q: "Que se passe-t-il en cas de coupure internet ?",
|
||||
a: "L'écran d'affichage affiche un indicateur 'Reconnexion...' en orange. Les patients déjà dans la file conservent leur position. Dès que la connexion est rétablie, la synchronisation reprend automatiquement.",
|
||||
},
|
||||
|
||||
// Subscription
|
||||
{
|
||||
category: "Abonnement",
|
||||
q: "Combien de temps dure l'essai gratuit ?",
|
||||
a: "L'essai gratuit dure 30 jours à compter de votre première connexion. Toutes les fonctionnalités sont disponibles sans restriction pendant cette période, et vous pouvez créer plusieurs cabinets et accueillir un nombre illimité de patients.",
|
||||
},
|
||||
{
|
||||
category: "Abonnement",
|
||||
q: "Que se passe-t-il après l'essai gratuit ?",
|
||||
a: "L'accès aux fonctionnalités de gestion (ouvrir la file, appeler des patients, créer des cabinets) est bloqué jusqu'à la souscription d'un plan payant. Vos données sont conservées et les patients peuvent toujours voir leur position dans les files actives.",
|
||||
},
|
||||
{
|
||||
category: "Abonnement",
|
||||
q: "Puis-je annuler mon abonnement ?",
|
||||
a: "Oui, vous pouvez annuler à tout moment depuis la page 'Abonnement' de votre tableau de bord. L'accès aux fonctionnalités payantes reste actif jusqu'à la fin de la période déjà payée.",
|
||||
},
|
||||
{
|
||||
category: "Abonnement",
|
||||
q: "Combien de cabinets puis-je gérer ?",
|
||||
a: "Le plan Solo inclut 1 cabinet. Le plan Pro permet de créer jusqu'à 5 cabinets. Le plan Cabinet inclut un nombre illimité de cabinets et donne accès à des statistiques avancées avec recommandations IA.",
|
||||
},
|
||||
|
||||
// Technical
|
||||
{
|
||||
category: "Technique",
|
||||
q: "Sur quels appareils fonctionne QueueMed ?",
|
||||
a: "QueueMed fonctionne sur tous les appareils dotés d'un navigateur moderne : smartphones iOS et Android, tablettes, ordinateurs Windows / Mac / Linux. Aucune application à installer. Recommandé : Chrome ou Safari à jour.",
|
||||
},
|
||||
{
|
||||
category: "Technique",
|
||||
q: "Mes données patients sont-elles sécurisées ?",
|
||||
a: "Oui. Les noms et numéros de ticket sont chiffrés en transit (HTTPS) et stockés sur des serveurs hébergés en France. Aucune donnée médicale n'est collectée. Les patients sont identifiés uniquement par un nom optionnel.",
|
||||
},
|
||||
{
|
||||
category: "Technique",
|
||||
q: "Puis-je exporter mes statistiques ?",
|
||||
a: "Oui. Depuis la page 'Analytics', cliquez sur 'Exporter en CSV' pour télécharger l'historique complet des consultations. Le fichier inclut les heures, durées d'attente et durées de consultation pour chaque patient.",
|
||||
},
|
||||
{
|
||||
category: "Technique",
|
||||
q: "QueueMed fonctionne-t-il hors ligne ?",
|
||||
a: "Non, une connexion internet est nécessaire pour la synchronisation en temps réel entre le médecin, l'écran d'affichage et les patients. En cas de coupure, l'application reprend automatiquement dès le retour de la connexion.",
|
||||
},
|
||||
];
|
||||
|
||||
const CATEGORIES = [
|
||||
"Tous",
|
||||
"Démarrage",
|
||||
"Gestion de la file",
|
||||
"Expérience patient",
|
||||
"Écran d'affichage",
|
||||
"Abonnement",
|
||||
"Technique",
|
||||
];
|
||||
const CATEGORY_KEYS = ["all", "gettingStarted", "queueManagement", "patientExperience", "displayScreen", "subscription", "technical"] as const;
|
||||
|
||||
const CATEGORY_ICONS: Record<string, React.ElementType> = {
|
||||
Démarrage: Sparkles,
|
||||
"Gestion de la file": Stethoscope,
|
||||
"Expérience patient": Users,
|
||||
"Écran d'affichage": Monitor,
|
||||
Abonnement: CreditCard,
|
||||
Technique: Wifi,
|
||||
gettingStarted: Sparkles,
|
||||
queueManagement: Stethoscope,
|
||||
patientExperience: Users,
|
||||
displayScreen: Monitor,
|
||||
subscription: CreditCard,
|
||||
technical: Wifi,
|
||||
};
|
||||
|
||||
export default function Help() {
|
||||
const { t } = useTranslation();
|
||||
const [, navigate] = useLocation();
|
||||
const [activeCategory, setActiveCategory] = useState("Tous");
|
||||
const [activeCategory, setActiveCategory] = useState<string>("all");
|
||||
const [search, setSearch] = useState("");
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
||||
|
||||
const faq: FaqItem[] = useMemo(
|
||||
() =>
|
||||
FAQ_KEYS.map((entry) => ({
|
||||
category: entry.catKey,
|
||||
q: t(`help.faq.${entry.id}.q`),
|
||||
a: t(`help.faq.${entry.id}.a`),
|
||||
})),
|
||||
[t]
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const term = search.trim().toLowerCase();
|
||||
return FAQ.filter((item) => {
|
||||
const matchCat = activeCategory === "Tous" || item.category === activeCategory;
|
||||
return faq.filter((item) => {
|
||||
const matchCat = activeCategory === "all" || item.category === activeCategory;
|
||||
const matchTerm =
|
||||
term === "" ||
|
||||
item.q.toLowerCase().includes(term) ||
|
||||
item.a.toLowerCase().includes(term);
|
||||
return matchCat && matchTerm;
|
||||
});
|
||||
}, [activeCategory, search]);
|
||||
}, [activeCategory, search, faq]);
|
||||
|
||||
const quickLinks = [
|
||||
{ icon: QrCode, label: t("help.quickLinks.gettingStarted"), cat: "gettingStarted" },
|
||||
{ icon: Smartphone, label: t("help.quickLinks.patients"), cat: "patientExperience" },
|
||||
{ icon: Monitor, label: t("help.quickLinks.display"), cat: "displayScreen" },
|
||||
{ icon: CreditCard, label: t("help.quickLinks.subscription"), cat: "subscription" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen relative overflow-hidden">
|
||||
<Helmet>
|
||||
<title>{t("help.metaTitle")}</title>
|
||||
<meta name="description" content={t("help.metaDescription")} />
|
||||
</Helmet>
|
||||
{/* Background blobs */}
|
||||
<div className="fixed 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/20 blur-3xl" />
|
||||
|
|
@ -197,7 +110,7 @@ export default function Help() {
|
|||
className="flex items-center gap-2 text-slate-500 hover:text-emerald-700 transition-colors mb-8 text-sm"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Retour
|
||||
{t("common.back")}
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
|
|
@ -206,10 +119,10 @@ export default function Help() {
|
|||
<HelpCircle className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 className="font-bold text-4xl mb-3">
|
||||
Centre <span className="gradient-text">d'aide</span>
|
||||
{t("help.headerCenter")} <span className="gradient-text">{t("help.headerHelp")}</span>
|
||||
</h1>
|
||||
<p className="text-slate-500 text-lg">
|
||||
Trouvez rapidement les réponses à vos questions sur QueueMed.
|
||||
{t("help.headerSubtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -217,7 +130,8 @@ export default function Help() {
|
|||
<div className="relative mb-6">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||
<Input
|
||||
placeholder="Rechercher une question..."
|
||||
placeholder={t("help.searchPlaceholder")}
|
||||
aria-label={t("help.searchPlaceholder")}
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
|
|
@ -229,12 +143,7 @@ export default function Help() {
|
|||
|
||||
{/* Quick links */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
|
||||
{[
|
||||
{ icon: QrCode, label: "Démarrage", cat: "Démarrage" },
|
||||
{ icon: Smartphone, label: "Patients", cat: "Expérience patient" },
|
||||
{ icon: Monitor, label: "Écran", cat: "Écran d'affichage" },
|
||||
{ icon: CreditCard, label: "Abonnement", cat: "Abonnement" },
|
||||
].map((item) => {
|
||||
{quickLinks.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = activeCategory === item.cat;
|
||||
return (
|
||||
|
|
@ -260,7 +169,7 @@ export default function Help() {
|
|||
|
||||
{/* Category filter */}
|
||||
<div className="flex gap-2 flex-wrap mb-6">
|
||||
{CATEGORIES.map((cat) => (
|
||||
{CATEGORY_KEYS.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => {
|
||||
|
|
@ -274,7 +183,7 @@ export default function Help() {
|
|||
: "bg-white/80 border-slate-200 text-slate-600 hover:border-emerald-400 hover:text-emerald-700"
|
||||
)}
|
||||
>
|
||||
{cat}
|
||||
{t(`help.categories.${cat}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -284,7 +193,7 @@ export default function Help() {
|
|||
<div className="glass-card rounded-3xl p-10 text-center">
|
||||
<BookOpen className="w-10 h-10 text-slate-300 mx-auto mb-3" />
|
||||
<p className="text-slate-500">
|
||||
Aucune question ne correspond à votre recherche.
|
||||
{t("help.noResults")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -309,7 +218,7 @@ export default function Help() {
|
|||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[10px] uppercase tracking-wider text-emerald-700 font-bold mb-0.5">
|
||||
{item.category}
|
||||
{t(`help.categories.${item.category}`)}
|
||||
</div>
|
||||
<span className="font-semibold text-sm text-slate-900 leading-snug block">
|
||||
{item.q}
|
||||
|
|
@ -340,11 +249,10 @@ export default function Help() {
|
|||
<AlertCircle className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h3 className="font-bold text-xl mb-2 text-slate-900">
|
||||
Vous ne trouvez pas votre réponse ?
|
||||
{t("help.contactTitle")}
|
||||
</h3>
|
||||
<p className="text-slate-500 text-sm mb-6 max-w-md mx-auto">
|
||||
Notre équipe est disponible pour vous aider à configurer et utiliser
|
||||
QueueMed dans votre cabinet.
|
||||
{t("help.contactBody")}
|
||||
</p>
|
||||
<div className="flex gap-3 justify-center flex-wrap">
|
||||
<Button
|
||||
|
|
@ -352,14 +260,14 @@ export default function Help() {
|
|||
onClick={() => navigate("/dashboard")}
|
||||
>
|
||||
<Stethoscope className="w-4 h-4 mr-2" />
|
||||
Tableau de bord
|
||||
{t("help.dashboardButton")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="gradient"
|
||||
onClick={() => window.open("mailto:support@queuemed.fr", "_blank")}
|
||||
>
|
||||
<Mail className="w-4 h-4 mr-2" />
|
||||
Contacter le support
|
||||
{t("help.contactButton")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,114 +1,151 @@
|
|||
import { Link } from "wouter";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
Stethoscope, QrCode, Smartphone, Bell, Monitor, BarChart3,
|
||||
Clock, Shield, Sparkles, ChevronRight, Check, Star, Users,
|
||||
Zap, Heart, Activity,
|
||||
Shield, Sparkles, ChevronRight, Check, Star, Heart,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const FEATURES = [
|
||||
{
|
||||
icon: QrCode,
|
||||
title: "QR code rotatif",
|
||||
description: "Vos patients scannent un QR à l'entrée — token tournant anti-triche, aucune appli à installer.",
|
||||
color: "from-emerald-500 to-teal-500",
|
||||
},
|
||||
{
|
||||
icon: Smartphone,
|
||||
title: "Position en temps réel",
|
||||
description: "Chaque patient voit sa position et son temps d'attente estimé, mis à jour en direct via WebSocket.",
|
||||
color: "from-cyan-500 to-blue-500",
|
||||
},
|
||||
{
|
||||
icon: Bell,
|
||||
title: "Alertes intelligentes",
|
||||
description: "Notification push quand le tour approche — vos patients peuvent quitter la salle d'attente.",
|
||||
color: "from-teal-500 to-emerald-500",
|
||||
},
|
||||
{
|
||||
icon: Monitor,
|
||||
title: "Écran de salle",
|
||||
description: "Affichage plein écran sur tablette avec ticker, numéro appelé géant, file en direct.",
|
||||
color: "from-emerald-500 to-cyan-500",
|
||||
},
|
||||
{
|
||||
icon: BarChart3,
|
||||
title: "Statistiques précises",
|
||||
description: "Affluence par heure, jour, durée moyenne. Recommandations IA pour optimiser votre cabinet.",
|
||||
color: "from-cyan-500 to-teal-500",
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
title: "RGPD & souverain",
|
||||
description: "Données hébergées en France. Aucun tracking patient. Sécurité bancaire (TLS, JWT, bcrypt).",
|
||||
color: "from-teal-500 to-emerald-500",
|
||||
},
|
||||
];
|
||||
|
||||
const STEPS = [
|
||||
{ num: "01", title: "Configurez votre cabinet", desc: "2 minutes pour créer votre file. Imprimez le QR code et placez-le à l'accueil." },
|
||||
{ num: "02", title: "Vos patients scannent", desc: "Ils ouvrent l'appareil photo, scannent et rejoignent la file en un clic." },
|
||||
{ num: "03", title: "Vous appelez le suivant", desc: "Un clic depuis votre tableau, le patient est notifié, l'écran s'actualise." },
|
||||
];
|
||||
|
||||
const PRICES = [
|
||||
{
|
||||
name: "Essai",
|
||||
price: "Gratuit",
|
||||
period: "30 jours",
|
||||
description: "Toutes les fonctionnalités, sans carte bancaire.",
|
||||
features: ["1 cabinet", "Patients illimités", "Statistiques de base", "Support email"],
|
||||
cta: "Démarrer l'essai",
|
||||
href: "/login",
|
||||
highlighted: false,
|
||||
},
|
||||
{
|
||||
name: "Basic",
|
||||
price: "29€",
|
||||
period: "/ mois",
|
||||
description: "Pour un cabinet individuel.",
|
||||
features: ["1 cabinet", "Patients illimités", "Écran d'affichage", "Statistiques avancées", "Support prioritaire"],
|
||||
cta: "S'abonner",
|
||||
href: "/login",
|
||||
highlighted: true,
|
||||
},
|
||||
{
|
||||
name: "Pro",
|
||||
price: "79€",
|
||||
period: "/ mois",
|
||||
description: "Pour les centres médicaux.",
|
||||
features: ["Cabinets illimités", "Multi-praticiens", "Recommandations IA", "Export CSV avancé", "Support téléphonique"],
|
||||
cta: "S'abonner",
|
||||
href: "/login",
|
||||
highlighted: false,
|
||||
},
|
||||
];
|
||||
|
||||
const TESTIMONIALS = [
|
||||
{
|
||||
name: "Dr. Marie Dubois",
|
||||
role: "Médecin généraliste, Lyon",
|
||||
quote: "Mes patients adorent. Plus de salle d'attente bondée, plus de stress. Je gagne 1h par jour facilement.",
|
||||
avatar: "MD",
|
||||
},
|
||||
{
|
||||
name: "Dr. Karim Benali",
|
||||
role: "Pédiatre, Marseille",
|
||||
quote: "Setup en 5 minutes. Le QR rotatif évite les abus et l'écran d'affichage est parfait pour ma salle.",
|
||||
avatar: "KB",
|
||||
},
|
||||
{
|
||||
name: "Dr. Sophie Lefèvre",
|
||||
role: "Dentiste, Bordeaux",
|
||||
quote: "Les statistiques m'ont permis d'optimiser mes plages horaires. ROI évident dès le premier mois.",
|
||||
avatar: "SL",
|
||||
},
|
||||
];
|
||||
|
||||
export default function Home() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const FEATURES = [
|
||||
{
|
||||
icon: QrCode,
|
||||
key: "qrCode",
|
||||
color: "from-emerald-500 to-teal-500",
|
||||
},
|
||||
{
|
||||
icon: Smartphone,
|
||||
key: "realtime",
|
||||
color: "from-cyan-500 to-blue-500",
|
||||
},
|
||||
{
|
||||
icon: Bell,
|
||||
key: "alerts",
|
||||
color: "from-teal-500 to-emerald-500",
|
||||
},
|
||||
{
|
||||
icon: Monitor,
|
||||
key: "displayScreen",
|
||||
color: "from-emerald-500 to-cyan-500",
|
||||
},
|
||||
{
|
||||
icon: BarChart3,
|
||||
key: "stats",
|
||||
color: "from-cyan-500 to-teal-500",
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
key: "gdpr",
|
||||
color: "from-teal-500 to-emerald-500",
|
||||
},
|
||||
];
|
||||
|
||||
const STEPS = [
|
||||
{ num: "01", key: "step1" },
|
||||
{ num: "02", key: "step2" },
|
||||
{ num: "03", key: "step3" },
|
||||
];
|
||||
|
||||
const PRICES = [
|
||||
{
|
||||
key: "trial",
|
||||
price: t("home.pricing.trial.price"),
|
||||
period: t("home.pricing.trial.period"),
|
||||
features: [
|
||||
t("home.pricing.trial.feature1"),
|
||||
t("home.pricing.trial.feature2"),
|
||||
t("home.pricing.trial.feature3"),
|
||||
t("home.pricing.trial.feature4"),
|
||||
],
|
||||
cta: t("home.pricing.trial.cta"),
|
||||
href: "/login",
|
||||
highlighted: false,
|
||||
},
|
||||
{
|
||||
key: "basic",
|
||||
price: t("home.pricing.basic.price"),
|
||||
period: t("home.pricing.basic.period"),
|
||||
features: [
|
||||
t("home.pricing.basic.feature1"),
|
||||
t("home.pricing.basic.feature2"),
|
||||
t("home.pricing.basic.feature3"),
|
||||
t("home.pricing.basic.feature4"),
|
||||
t("home.pricing.basic.feature5"),
|
||||
],
|
||||
cta: t("home.pricing.basic.cta"),
|
||||
href: "/login",
|
||||
highlighted: true,
|
||||
},
|
||||
{
|
||||
key: "pro",
|
||||
price: t("home.pricing.pro.price"),
|
||||
period: t("home.pricing.pro.period"),
|
||||
features: [
|
||||
t("home.pricing.pro.feature1"),
|
||||
t("home.pricing.pro.feature2"),
|
||||
t("home.pricing.pro.feature3"),
|
||||
t("home.pricing.pro.feature4"),
|
||||
t("home.pricing.pro.feature5"),
|
||||
],
|
||||
cta: t("home.pricing.pro.cta"),
|
||||
href: "/login",
|
||||
highlighted: false,
|
||||
},
|
||||
];
|
||||
|
||||
const TESTIMONIALS = [
|
||||
{
|
||||
key: "t1",
|
||||
avatar: "MD",
|
||||
},
|
||||
{
|
||||
key: "t2",
|
||||
avatar: "KB",
|
||||
},
|
||||
{
|
||||
key: "t3",
|
||||
avatar: "SL",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white text-slate-900 overflow-hidden">
|
||||
<Helmet>
|
||||
<title>{t("home.metaTitle")}</title>
|
||||
<meta name="description" content={t("home.metaDescription")} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content={t("home.ogTitle")} />
|
||||
<meta property="og:description" content={t("home.ogDescription")} />
|
||||
<meta property="og:site_name" content="QueueMed" />
|
||||
<meta property="og:image" content="/icon-512x512.svg" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={t("home.ogTitle")} />
|
||||
<meta name="twitter:description" content={t("home.ogDescription")} />
|
||||
<meta name="twitter:image" content="/icon-512x512.svg" />
|
||||
<link rel="canonical" href="/" />
|
||||
<script type="application/ld+json">{JSON.stringify({
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
"name": "QueueMed",
|
||||
"applicationCategory": "HealthApplication",
|
||||
"operatingSystem": "Web",
|
||||
"description": t("home.metaDescription"),
|
||||
"offers": [
|
||||
{ "@type": "Offer", "name": "Trial", "price": "0", "priceCurrency": "EUR" },
|
||||
{ "@type": "Offer", "name": "Basic", "price": "29", "priceCurrency": "EUR" },
|
||||
{ "@type": "Offer", "name": "Pro", "price": "79", "priceCurrency": "EUR" }
|
||||
],
|
||||
"aggregateRating": {
|
||||
"@type": "AggregateRating",
|
||||
"ratingValue": "4.9",
|
||||
"reviewCount": "200"
|
||||
}
|
||||
})}</script>
|
||||
</Helmet>
|
||||
{/* ─── Nav ────────────────────────────────────────────────────── */}
|
||||
<nav className="sticky top-0 z-50 backdrop-blur-xl bg-white/70 border-b border-emerald-100/60">
|
||||
<div className="container flex items-center justify-between h-16">
|
||||
|
|
@ -121,18 +158,18 @@ export default function Home() {
|
|||
</a>
|
||||
</Link>
|
||||
<div className="hidden md:flex items-center gap-8 text-sm text-slate-600 font-medium">
|
||||
<a href="#features" className="hover:text-emerald-700 transition-colors">Fonctionnalités</a>
|
||||
<a href="#how" className="hover:text-emerald-700 transition-colors">Fonctionnement</a>
|
||||
<a href="#pricing" className="hover:text-emerald-700 transition-colors">Tarifs</a>
|
||||
<Link href="/help"><a className="hover:text-emerald-700 transition-colors">Aide</a></Link>
|
||||
<a href="#features" className="hover:text-emerald-700 transition-colors">{t("home.nav.features")}</a>
|
||||
<a href="#how" className="hover:text-emerald-700 transition-colors">{t("home.nav.how")}</a>
|
||||
<a href="#pricing" className="hover:text-emerald-700 transition-colors">{t("home.nav.pricing")}</a>
|
||||
<Link href="/help"><a className="hover:text-emerald-700 transition-colors">{t("home.nav.help")}</a></Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="/login">
|
||||
<Button variant="ghost" size="sm">Connexion</Button>
|
||||
<Button variant="ghost" size="sm">{t("home.nav.login")}</Button>
|
||||
</Link>
|
||||
<Link href="/login">
|
||||
<Button variant="gradient" size="sm" className="hidden sm:inline-flex">
|
||||
Essai gratuit <ChevronRight className="w-3 h-3 ml-1" />
|
||||
{t("home.nav.freeTrial")} <ChevronRight className="w-3 h-3 ml-1" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
|
@ -157,40 +194,39 @@ export default function Home() {
|
|||
>
|
||||
<div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-emerald-100/80 backdrop-blur-sm border border-emerald-200/80 text-emerald-700 text-sm font-semibold mb-8">
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
Salle d'attente virtuelle nouvelle génération
|
||||
{t("home.heroBadge")}
|
||||
</div>
|
||||
|
||||
<h1 className="font-bold text-5xl md:text-7xl tracking-tight leading-[1.05] mb-6">
|
||||
<span className="gradient-text">Vos patients</span>
|
||||
<span className="gradient-text">{t("home.heroH1Part1")}</span>
|
||||
<br />
|
||||
n'attendent plus,
|
||||
{t("home.heroH1Part2")}
|
||||
<br />
|
||||
ils <span className="gradient-text">vivent</span>.
|
||||
{t("home.heroH1Part3")} <span className="gradient-text">{t("home.heroH1Accent")}</span>.
|
||||
</h1>
|
||||
|
||||
<p className="text-lg md:text-xl text-slate-600 mb-10 max-w-2xl mx-auto leading-relaxed">
|
||||
QueueMed transforme votre cabinet médical en une expérience fluide.
|
||||
QR code, suivi en temps réel, notifications, écran d'affichage — sans application à installer.
|
||||
{t("home.heroDescription")}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center items-center">
|
||||
<Link href="/login">
|
||||
<Button variant="gradient" size="xl" className="w-full sm:w-auto shadow-xl">
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
Démarrer l'essai gratuit (30j)
|
||||
{t("home.heroStartTrial")}
|
||||
</Button>
|
||||
</Link>
|
||||
<a href="#how">
|
||||
<Button variant="outline" size="xl" className="w-full sm:w-auto">
|
||||
Voir comment ça marche
|
||||
{t("home.heroSeeHow")}
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 flex flex-wrap justify-center gap-6 text-sm text-slate-500">
|
||||
<div className="flex items-center gap-2"><Check className="w-4 h-4 text-emerald-500" /> Aucune carte bancaire</div>
|
||||
<div className="flex items-center gap-2"><Check className="w-4 h-4 text-emerald-500" /> Setup en 2 minutes</div>
|
||||
<div className="flex items-center gap-2"><Check className="w-4 h-4 text-emerald-500" /> Données en France</div>
|
||||
<div className="flex items-center gap-2"><Check className="w-4 h-4 text-emerald-500" /> {t("home.heroCheck1")}</div>
|
||||
<div className="flex items-center gap-2"><Check className="w-4 h-4 text-emerald-500" /> {t("home.heroCheck2")}</div>
|
||||
<div className="flex items-center gap-2"><Check className="w-4 h-4 text-emerald-500" /> {t("home.heroCheck3")}</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
|
|
@ -205,16 +241,16 @@ export default function Home() {
|
|||
<div className="rounded-2xl bg-gradient-to-br from-emerald-50 via-white to-cyan-50 p-8 md:p-12">
|
||||
<div className="grid md:grid-cols-2 gap-8 items-center">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-widest text-emerald-700 font-bold mb-2">Patient en cours</div>
|
||||
<div className="text-xs uppercase tracking-widest text-emerald-700 font-bold mb-2">{t("home.mockCurrent")}</div>
|
||||
<div className="font-black text-7xl md:text-8xl gradient-text leading-none mb-3">042</div>
|
||||
<div className="text-slate-600">Salle 2 — Dr. Martin</div>
|
||||
<div className="text-slate-600">{t("home.mockRoom")}</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs uppercase tracking-widest text-slate-500 font-bold mb-2">Prochains</div>
|
||||
<div className="text-xs uppercase tracking-widest text-slate-500 font-bold mb-2">{t("home.mockUpcoming")}</div>
|
||||
{[
|
||||
{ n: "043", name: "Patient anonyme", time: "~5 min", active: true },
|
||||
{ n: "044", name: "Patient anonyme", time: "~20 min" },
|
||||
{ n: "045", name: "Patient anonyme", time: "~35 min" },
|
||||
{ n: "043", name: t("home.mockAnonymous"), time: `~5 ${t("home.mockMin")}`, active: true },
|
||||
{ n: "044", name: t("home.mockAnonymous"), time: `~20 ${t("home.mockMin")}` },
|
||||
{ n: "045", name: t("home.mockAnonymous"), time: `~35 ${t("home.mockMin")}` },
|
||||
].map((p) => (
|
||||
<div key={p.n} className={`flex items-center justify-between p-3 rounded-xl border ${p.active ? "bg-emerald-50 border-emerald-200" : "bg-white border-slate-100"}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
|
|
@ -236,12 +272,12 @@ export default function Home() {
|
|||
<section id="features" className="py-24 bg-gradient-to-b from-white to-emerald-50/30">
|
||||
<div className="container">
|
||||
<div className="text-center max-w-2xl mx-auto mb-16">
|
||||
<div className="text-emerald-700 font-semibold text-sm uppercase tracking-widest mb-3">Fonctionnalités</div>
|
||||
<div className="text-emerald-700 font-semibold text-sm uppercase tracking-widest mb-3">{t("home.featuresKicker")}</div>
|
||||
<h2 className="font-bold text-4xl md:text-5xl mb-4 tracking-tight">
|
||||
Tout ce dont votre cabinet <span className="gradient-text">a besoin</span>
|
||||
{t("home.featuresTitlePart1")} <span className="gradient-text">{t("home.featuresTitleAccent")}</span>
|
||||
</h2>
|
||||
<p className="text-lg text-slate-600">
|
||||
Une plateforme pensée par et pour les médecins. Élégante, rapide, conforme.
|
||||
{t("home.featuresSubtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -250,7 +286,7 @@ export default function Home() {
|
|||
const Icon = f.icon;
|
||||
return (
|
||||
<motion.div
|
||||
key={f.title}
|
||||
key={f.key}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
|
|
@ -260,8 +296,8 @@ export default function Home() {
|
|||
<div className={`w-12 h-12 rounded-xl bg-gradient-to-br ${f.color} flex items-center justify-center shadow-md mb-4`}>
|
||||
<Icon className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h3 className="font-bold text-lg mb-2">{f.title}</h3>
|
||||
<p className="text-slate-600 text-sm leading-relaxed">{f.description}</p>
|
||||
<h3 className="font-bold text-lg mb-2">{t(`home.features.${f.key}.title`)}</h3>
|
||||
<p className="text-slate-600 text-sm leading-relaxed">{t(`home.features.${f.key}.description`)}</p>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
|
|
@ -273,9 +309,9 @@ export default function Home() {
|
|||
<section id="how" className="py-24">
|
||||
<div className="container">
|
||||
<div className="text-center max-w-2xl mx-auto mb-16">
|
||||
<div className="text-emerald-700 font-semibold text-sm uppercase tracking-widest mb-3">Comment ça marche</div>
|
||||
<div className="text-emerald-700 font-semibold text-sm uppercase tracking-widest mb-3">{t("home.howKicker")}</div>
|
||||
<h2 className="font-bold text-4xl md:text-5xl mb-4 tracking-tight">
|
||||
<span className="gradient-text">3 étapes</span> et c'est lancé
|
||||
<span className="gradient-text">{t("home.howTitleAccent")}</span> {t("home.howTitleRest")}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
|
|
@ -291,8 +327,8 @@ export default function Home() {
|
|||
>
|
||||
<div className="glass-card rounded-2xl p-8 h-full">
|
||||
<div className="font-black text-6xl gradient-text mb-3">{s.num}</div>
|
||||
<h3 className="font-bold text-xl mb-2">{s.title}</h3>
|
||||
<p className="text-slate-600 text-sm leading-relaxed">{s.desc}</p>
|
||||
<h3 className="font-bold text-xl mb-2">{t(`home.steps.${s.key}.title`)}</h3>
|
||||
<p className="text-slate-600 text-sm leading-relaxed">{t(`home.steps.${s.key}.desc`)}</p>
|
||||
</div>
|
||||
{i < STEPS.length - 1 && (
|
||||
<ChevronRight className="hidden md:block absolute top-1/2 -right-5 -translate-y-1/2 w-8 h-8 text-emerald-400" />
|
||||
|
|
@ -307,19 +343,19 @@ export default function Home() {
|
|||
<section id="pricing" className="py-24 bg-gradient-to-b from-emerald-50/30 to-cyan-50/30">
|
||||
<div className="container">
|
||||
<div className="text-center max-w-2xl mx-auto mb-16">
|
||||
<div className="text-emerald-700 font-semibold text-sm uppercase tracking-widest mb-3">Tarifs</div>
|
||||
<div className="text-emerald-700 font-semibold text-sm uppercase tracking-widest mb-3">{t("home.pricingKicker")}</div>
|
||||
<h2 className="font-bold text-4xl md:text-5xl mb-4 tracking-tight">
|
||||
Simples et <span className="gradient-text">transparents</span>
|
||||
{t("home.pricingTitlePart1")} <span className="gradient-text">{t("home.pricingTitleAccent")}</span>
|
||||
</h2>
|
||||
<p className="text-lg text-slate-600">
|
||||
30 jours d'essai gratuit, sans engagement, sans carte bancaire.
|
||||
{t("home.pricingSubtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-6 max-w-5xl mx-auto">
|
||||
{PRICES.map((p) => (
|
||||
<motion.div
|
||||
key={p.name}
|
||||
key={p.key}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
|
|
@ -332,11 +368,11 @@ export default function Home() {
|
|||
>
|
||||
{p.highlighted && (
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2 px-3 py-1 rounded-full bg-orange-500 text-white text-xs font-bold uppercase tracking-wider shadow-md">
|
||||
Populaire
|
||||
{t("home.pricingPopular")}
|
||||
</div>
|
||||
)}
|
||||
<h3 className={`font-bold text-2xl mb-1 ${p.highlighted ? "text-white" : "text-slate-900"}`}>{p.name}</h3>
|
||||
<p className={`text-sm mb-6 ${p.highlighted ? "text-emerald-50" : "text-slate-500"}`}>{p.description}</p>
|
||||
<h3 className={`font-bold text-2xl mb-1 ${p.highlighted ? "text-white" : "text-slate-900"}`}>{t(`home.pricing.${p.key}.name`)}</h3>
|
||||
<p className={`text-sm mb-6 ${p.highlighted ? "text-emerald-50" : "text-slate-500"}`}>{t(`home.pricing.${p.key}.description`)}</p>
|
||||
<div className="flex items-baseline gap-1 mb-8">
|
||||
<span className={`font-black text-5xl ${p.highlighted ? "text-white" : "gradient-text"}`}>{p.price}</span>
|
||||
<span className={`text-sm ${p.highlighted ? "text-emerald-100" : "text-slate-500"}`}>{p.period}</span>
|
||||
|
|
@ -371,28 +407,28 @@ export default function Home() {
|
|||
<section className="py-24">
|
||||
<div className="container">
|
||||
<div className="text-center max-w-2xl mx-auto mb-16">
|
||||
<div className="text-emerald-700 font-semibold text-sm uppercase tracking-widest mb-3">Témoignages</div>
|
||||
<div className="text-emerald-700 font-semibold text-sm uppercase tracking-widest mb-3">{t("home.testimonialsKicker")}</div>
|
||||
<h2 className="font-bold text-4xl md:text-5xl mb-4 tracking-tight">
|
||||
Approuvé par <span className="gradient-text">200+ médecins</span>
|
||||
{t("home.testimonialsTitlePart1")} <span className="gradient-text">{t("home.testimonialsTitleAccent")}</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-6 max-w-5xl mx-auto">
|
||||
{TESTIMONIALS.map((t) => (
|
||||
<div key={t.name} className="glass-card rounded-2xl p-6">
|
||||
{TESTIMONIALS.map((tm) => (
|
||||
<div key={tm.key} className="glass-card rounded-2xl p-6">
|
||||
<div className="flex gap-1 mb-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Star key={i} className="w-4 h-4 fill-amber-400 text-amber-400" />
|
||||
))}
|
||||
</div>
|
||||
<p className="text-slate-700 text-sm leading-relaxed mb-5 italic">"{t.quote}"</p>
|
||||
<p className="text-slate-700 text-sm leading-relaxed mb-5 italic">"{t(`home.testimonials.${tm.key}.quote`)}"</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-emerald-500 to-cyan-500 flex items-center justify-center text-white font-bold text-sm">
|
||||
{t.avatar}
|
||||
{tm.avatar}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-sm text-slate-900">{t.name}</div>
|
||||
<div className="text-xs text-slate-500">{t.role}</div>
|
||||
<div className="font-semibold text-sm text-slate-900">{t(`home.testimonials.${tm.key}.name`)}</div>
|
||||
<div className="text-xs text-slate-500">{t(`home.testimonials.${tm.key}.role`)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -412,15 +448,15 @@ export default function Home() {
|
|||
<div className="relative z-10 max-w-2xl mx-auto">
|
||||
<Heart className="w-12 h-12 mx-auto mb-6 text-white" />
|
||||
<h2 className="font-bold text-3xl md:text-5xl mb-4 tracking-tight">
|
||||
Prêt à transformer votre cabinet ?
|
||||
{t("home.ctaTitle")}
|
||||
</h2>
|
||||
<p className="text-emerald-50 text-lg mb-8">
|
||||
30 jours d'essai gratuit. Aucune carte bancaire. Setup en 2 minutes.
|
||||
{t("home.ctaSubtitle")}
|
||||
</p>
|
||||
<Link href="/login">
|
||||
<Button size="xl" className="bg-white text-emerald-700 hover:bg-emerald-50 shadow-xl">
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
Démarrer maintenant
|
||||
{t("home.ctaButton")}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
|
@ -437,11 +473,11 @@ export default function Home() {
|
|||
<Stethoscope className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<span className="font-bold gradient-text">QueueMed</span>
|
||||
<span className="text-slate-400 text-sm">— Salle d'attente virtuelle</span>
|
||||
<span className="text-slate-400 text-sm">— {t("home.footerTagline")}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-6 text-sm text-slate-500">
|
||||
<Link href="/help"><a className="hover:text-emerald-700">Aide</a></Link>
|
||||
<a href="mailto:contact@queuemed.fr" className="hover:text-emerald-700">Contact</a>
|
||||
<Link href="/help"><a className="hover:text-emerald-700">{t("home.footerHelp")}</a></Link>
|
||||
<a href="mailto:contact@queuemed.fr" className="hover:text-emerald-700">{t("home.footerContact")}</a>
|
||||
<span className="text-slate-400">© {new Date().getFullYear()} QueueMed</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Stethoscope, Building2, Clock, QrCode, CheckCircle2,
|
||||
ChevronRight, ChevronLeft, Loader2, Printer, Monitor,
|
||||
|
|
@ -12,32 +14,33 @@ import { Label } from "@/components/ui/label";
|
|||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
id: 1,
|
||||
title: "Votre cabinet",
|
||||
description: "Renseignez les informations de base et les paramètres de la file.",
|
||||
icon: Building2,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Votre QR code",
|
||||
description: "Imprimez ou prévisualisez l'affiche à apposer à l'accueil.",
|
||||
icon: QrCode,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Tout est prêt !",
|
||||
description: "Voici les prochaines étapes pour démarrer.",
|
||||
icon: CheckCircle2,
|
||||
},
|
||||
];
|
||||
|
||||
export default function Onboarding() {
|
||||
const { t } = useTranslation();
|
||||
const [, navigate] = useLocation();
|
||||
const [step, setStep] = useState(1);
|
||||
const [clinicId, setClinicId] = useState<number | null>(null);
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
id: 1,
|
||||
title: t("onboarding.steps.s1.title"),
|
||||
description: t("onboarding.steps.s1.description"),
|
||||
icon: Building2,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: t("onboarding.steps.s2.title"),
|
||||
description: t("onboarding.steps.s2.description"),
|
||||
icon: QrCode,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: t("onboarding.steps.s3.title"),
|
||||
description: t("onboarding.steps.s3.description"),
|
||||
icon: CheckCircle2,
|
||||
},
|
||||
];
|
||||
|
||||
// Form state
|
||||
const [name, setName] = useState("");
|
||||
const [address, setAddress] = useState("");
|
||||
|
|
@ -51,7 +54,7 @@ export default function Onboarding() {
|
|||
onSuccess: (data) => {
|
||||
setClinicId(data.id);
|
||||
setStep(2);
|
||||
toast.success("Cabinet créé avec succès !");
|
||||
toast.success(t("onboarding.toastCreated"));
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
|
@ -63,7 +66,7 @@ export default function Onboarding() {
|
|||
|
||||
const handleCreate = () => {
|
||||
if (!name.trim()) {
|
||||
toast.error("Le nom du cabinet est requis.");
|
||||
toast.error(t("onboarding.errorNameRequired"));
|
||||
return;
|
||||
}
|
||||
createMutation.mutate({
|
||||
|
|
@ -80,6 +83,10 @@ export default function Onboarding() {
|
|||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 relative overflow-hidden">
|
||||
<Helmet>
|
||||
<title>{t("onboarding.metaTitle")}</title>
|
||||
<meta name="description" content={t("onboarding.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" />
|
||||
|
|
@ -95,10 +102,10 @@ export default function Onboarding() {
|
|||
</div>
|
||||
</div>
|
||||
<h1 className="font-bold text-3xl mb-2">
|
||||
Configuration <span className="gradient-text">initiale</span>
|
||||
{t("onboarding.headerPart1")} <span className="gradient-text">{t("onboarding.headerAccent")}</span>
|
||||
</h1>
|
||||
<p className="text-slate-500 text-sm">
|
||||
Configurez votre premier cabinet en 2 minutes.
|
||||
{t("onboarding.headerSubtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -146,11 +153,11 @@ export default function Onboarding() {
|
|||
<div className="space-y-5">
|
||||
<div>
|
||||
<Label htmlFor="name" className="mb-1.5 block">
|
||||
Nom du cabinet <span className="text-red-500">*</span>
|
||||
{t("onboarding.fieldName")} <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="Ex: Cabinet Dr. Martin"
|
||||
placeholder={t("onboarding.placeholderName")}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
|
||||
|
|
@ -160,22 +167,22 @@ export default function Onboarding() {
|
|||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="address" className="mb-1.5 block">
|
||||
Adresse <span className="text-slate-400 text-xs">(optionnel)</span>
|
||||
{t("onboarding.fieldAddress")} <span className="text-slate-400 text-xs">{t("onboarding.optional")}</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="address"
|
||||
placeholder="12 rue de la Paix, Paris"
|
||||
placeholder={t("onboarding.placeholderAddress")}
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="phone" className="mb-1.5 block">
|
||||
Téléphone <span className="text-slate-400 text-xs">(optionnel)</span>
|
||||
{t("onboarding.fieldPhone")} <span className="text-slate-400 text-xs">{t("onboarding.optional")}</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
placeholder="01 23 45 67 89"
|
||||
placeholder={t("onboarding.placeholderPhone")}
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
/>
|
||||
|
|
@ -185,12 +192,12 @@ export default function Onboarding() {
|
|||
<div className="rounded-2xl border border-emerald-200 bg-emerald-50/60 p-4 space-y-4">
|
||||
<div className="flex items-center gap-2 text-emerald-800 font-semibold text-sm">
|
||||
<Clock className="w-4 h-4" />
|
||||
Paramètres de la file
|
||||
{t("onboarding.queueSettings")}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-slate-700">
|
||||
Durée moyenne de consultation
|
||||
{t("onboarding.avgConsultation")}
|
||||
</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
|
|
@ -203,17 +210,17 @@ export default function Onboarding() {
|
|||
className="flex-1 accent-emerald-500"
|
||||
/>
|
||||
<span className="text-emerald-700 font-bold w-20 text-right text-sm">
|
||||
{avgConsultation} min
|
||||
{avgConsultation} {t("onboarding.minutesShort")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-slate-500 text-xs mt-1">
|
||||
Utilisé pour estimer le temps d'attente des patients.
|
||||
{t("onboarding.avgConsultationHelp")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-slate-700">
|
||||
Taille maximale de la file
|
||||
{t("onboarding.maxQueueSize")}
|
||||
</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
|
|
@ -226,11 +233,11 @@ export default function Onboarding() {
|
|||
className="flex-1 accent-emerald-500"
|
||||
/>
|
||||
<span className="text-emerald-700 font-bold w-20 text-right text-sm">
|
||||
{maxQueue} pat.
|
||||
{maxQueue} {t("onboarding.patientsShort")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-slate-500 text-xs mt-1">
|
||||
Au-delà, les nouveaux patients ne pourront plus rejoindre.
|
||||
{t("onboarding.maxQueueHelp")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -241,9 +248,7 @@ export default function Onboarding() {
|
|||
{step === 2 && (
|
||||
<div className="text-center space-y-5">
|
||||
<p className="text-sm text-slate-600">
|
||||
Voici le QR code de <strong className="text-slate-900">{name}</strong>.
|
||||
Imprimez-le et placez-le à l'entrée du cabinet pour que vos
|
||||
patients puissent rejoindre la file.
|
||||
{t("onboarding.qrIntroPart1")} <strong className="text-slate-900">{name}</strong>{t("onboarding.qrIntroPart2")}
|
||||
</p>
|
||||
|
||||
<div className="flex justify-center">
|
||||
|
|
@ -258,12 +263,12 @@ export default function Onboarding() {
|
|||
) : qrQuery.data?.dataUrl ? (
|
||||
<img
|
||||
src={qrQuery.data.dataUrl}
|
||||
alt="QR Code"
|
||||
alt={t("onboarding.qrAlt")}
|
||||
style={{ width: 200, height: 200, display: "block" }}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-[200px] h-[200px] flex items-center justify-center text-slate-400 text-sm">
|
||||
QR indisponible
|
||||
{t("onboarding.qrUnavailable")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -276,13 +281,13 @@ export default function Onboarding() {
|
|||
disabled={!clinicId}
|
||||
>
|
||||
<Printer className="w-4 h-4 mr-2" />
|
||||
Voir / imprimer l'affiche
|
||||
{t("onboarding.viewPoster")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="gradient"
|
||||
onClick={() => setStep(3)}
|
||||
>
|
||||
Continuer
|
||||
{t("onboarding.continue")}
|
||||
<ChevronRight className="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -297,11 +302,11 @@ export default function Onboarding() {
|
|||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-2xl text-slate-900 mb-2">
|
||||
Cabinet configuré !
|
||||
{t("onboarding.doneTitle")}
|
||||
</h3>
|
||||
<p className="text-slate-600 text-sm leading-relaxed">
|
||||
Le cabinet <strong className="text-slate-900">"{name}"</strong>{" "}
|
||||
est prêt. Voici vos prochaines étapes pour bien démarrer.
|
||||
{t("onboarding.donePart1")} <strong className="text-slate-900">"{name}"</strong>{" "}
|
||||
{t("onboarding.donePart2")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -309,15 +314,15 @@ export default function Onboarding() {
|
|||
{[
|
||||
{
|
||||
icon: Printer,
|
||||
text: "Imprimez le QR code et affichez-le à l'accueil",
|
||||
text: t("onboarding.next1"),
|
||||
},
|
||||
{
|
||||
icon: Monitor,
|
||||
text: "Configurez l'écran d'affichage sur votre tablette ou moniteur",
|
||||
text: t("onboarding.next2"),
|
||||
},
|
||||
{
|
||||
icon: LayoutDashboard,
|
||||
text: "Ouvrez la file depuis le tableau de bord en début de journée",
|
||||
text: t("onboarding.next3"),
|
||||
},
|
||||
].map((item, i) => {
|
||||
const Icon = item.icon;
|
||||
|
|
@ -351,12 +356,12 @@ export default function Onboarding() {
|
|||
{createMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Création...
|
||||
{t("onboarding.creating")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
Créer le cabinet
|
||||
{t("onboarding.createClinic")}
|
||||
<ChevronRight className="w-4 h-4 ml-1" />
|
||||
</>
|
||||
)}
|
||||
|
|
@ -370,7 +375,7 @@ export default function Onboarding() {
|
|||
className="text-slate-500"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||
Retour
|
||||
{t("onboarding.back")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
|
@ -383,14 +388,14 @@ export default function Onboarding() {
|
|||
disabled={!clinicId}
|
||||
>
|
||||
<QrCode className="w-4 h-4 mr-2" />
|
||||
Voir la file
|
||||
{t("onboarding.viewQueue")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="gradient"
|
||||
onClick={() => navigate("/dashboard")}
|
||||
className="flex-1 font-semibold"
|
||||
>
|
||||
Tableau de bord
|
||||
{t("onboarding.dashboard")}
|
||||
<ChevronRight className="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -405,7 +410,7 @@ export default function Onboarding() {
|
|||
onClick={() => navigate("/dashboard")}
|
||||
className="underline underline-offset-2 hover:text-emerald-700 transition-colors"
|
||||
>
|
||||
Passer pour l'instant
|
||||
{t("onboarding.skip")}
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useParams, useLocation } from "wouter";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
Stethoscope, Bell, BellRing, CheckCircle2, XCircle, Clock,
|
||||
|
|
@ -12,6 +14,7 @@ import { toast } from "sonner";
|
|||
import { formatTicket, formatTime } from "@/lib/utils";
|
||||
|
||||
export default function PatientQueue() {
|
||||
const { t } = useTranslation();
|
||||
const params = useParams<{ token: string }>();
|
||||
const [, navigate] = useLocation();
|
||||
const patientToken = params.token ?? "";
|
||||
|
|
@ -24,7 +27,7 @@ export default function PatientQueue() {
|
|||
|
||||
const cancelMutation = trpc.queue.cancel.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Ticket annulé");
|
||||
toast.success(t("patient.toastTicketCanceled"));
|
||||
utils.queue.getByToken.invalidate({ patientToken });
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
|
|
@ -43,19 +46,19 @@ export default function PatientQueue() {
|
|||
utils.queue.getByToken.invalidate({ patientToken });
|
||||
// Best-effort browser notification
|
||||
if ("Notification" in window && Notification.permission === "granted") {
|
||||
new Notification("C'est votre tour !", {
|
||||
body: "Présentez-vous en salle de consultation.",
|
||||
new Notification(t("patient.notifTitle"), {
|
||||
body: t("patient.notifBody"),
|
||||
icon: "/favicon.svg",
|
||||
});
|
||||
}
|
||||
try {
|
||||
navigator.vibrate?.([200, 100, 200, 100, 400]);
|
||||
} catch {}
|
||||
toast.success("C'est votre tour !", { duration: 10_000 });
|
||||
toast.success(t("patient.notifTitle"), { duration: 10_000 });
|
||||
};
|
||||
const onApproaching = () => {
|
||||
utils.queue.getByToken.invalidate({ patientToken });
|
||||
toast("Vous êtes le prochain — préparez-vous !", { duration: 8_000 });
|
||||
toast(t("patient.toastApproaching"), { duration: 8_000 });
|
||||
};
|
||||
const onAbsent = () => utils.queue.getByToken.invalidate({ patientToken });
|
||||
const onDone = () => utils.queue.getByToken.invalidate({ patientToken });
|
||||
|
|
@ -84,6 +87,10 @@ export default function PatientQueue() {
|
|||
if (ticketQuery.isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Helmet>
|
||||
<title>{t("patient.metaTitle")}</title>
|
||||
<meta name="description" content={t("patient.metaDescription")} />
|
||||
</Helmet>
|
||||
<Loader2 className="w-8 h-8 text-emerald-500 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
|
|
@ -92,13 +99,17 @@ export default function PatientQueue() {
|
|||
if (ticketQuery.error || !ticketQuery.data) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<Helmet>
|
||||
<title>{t("patient.metaTitle")}</title>
|
||||
<meta name="description" content={t("patient.metaDescription")} />
|
||||
</Helmet>
|
||||
<div className="glass-card rounded-3xl p-10 text-center max-w-md">
|
||||
<XCircle className="w-12 h-12 text-red-400 mx-auto mb-4" />
|
||||
<h1 className="font-bold text-2xl mb-2">Ticket introuvable</h1>
|
||||
<h1 className="font-bold text-2xl mb-2">{t("patient.ticketNotFound")}</h1>
|
||||
<p className="text-slate-500 text-sm mb-6">
|
||||
Ce lien est invalide ou a expiré. Veuillez rescanner le QR code à l'accueil.
|
||||
{t("patient.ticketNotFoundDesc")}
|
||||
</p>
|
||||
<Button variant="gradient" onClick={() => navigate("/")}>Retour à l'accueil</Button>
|
||||
<Button variant="gradient" onClick={() => navigate("/")}>{t("common.backToHome")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -112,6 +123,10 @@ export default function PatientQueue() {
|
|||
|
||||
return (
|
||||
<div className="min-h-screen p-4 flex items-center justify-center relative overflow-hidden">
|
||||
<Helmet>
|
||||
<title>{t("patient.metaTitle")}</title>
|
||||
<meta name="description" content={t("patient.metaDescription")} />
|
||||
</Helmet>
|
||||
{/* Animated background */}
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-96 h-96 rounded-full bg-emerald-300/30 blur-3xl animate-pulse-glow" />
|
||||
|
|
@ -152,9 +167,9 @@ export default function PatientQueue() {
|
|||
>
|
||||
<BellRing className="w-10 h-10 text-white" />
|
||||
</motion.div>
|
||||
<div className="text-sm uppercase tracking-widest text-emerald-50 font-bold mb-2">C'est votre tour</div>
|
||||
<div className="text-sm uppercase tracking-widest text-emerald-50 font-bold mb-2">{t("patient.youAreCalled")}</div>
|
||||
<div className="font-black text-7xl mb-3 leading-none">{formatTicket(entry.ticketNumber)}</div>
|
||||
<p className="text-emerald-50 mb-2">Présentez-vous immédiatement à la salle de consultation.</p>
|
||||
<p className="text-emerald-50 mb-2">{t("patient.calledDesc")}</p>
|
||||
</motion.div>
|
||||
) : isInConsult ? (
|
||||
<motion.div
|
||||
|
|
@ -164,8 +179,8 @@ export default function PatientQueue() {
|
|||
className="glass-card-strong rounded-3xl p-8 text-center"
|
||||
>
|
||||
<CheckCircle2 className="w-16 h-16 text-emerald-500 mx-auto mb-4" />
|
||||
<h2 className="font-bold text-2xl mb-2">En consultation</h2>
|
||||
<p className="text-slate-600">Vous êtes actuellement avec votre médecin.</p>
|
||||
<h2 className="font-bold text-2xl mb-2">{t("patient.inConsult")}</h2>
|
||||
<p className="text-slate-600">{t("patient.inConsultDesc")}</p>
|
||||
</motion.div>
|
||||
) : isDone ? (
|
||||
<motion.div
|
||||
|
|
@ -175,9 +190,9 @@ export default function PatientQueue() {
|
|||
className="glass-card-strong rounded-3xl p-8 text-center"
|
||||
>
|
||||
<CheckCircle2 className="w-16 h-16 text-emerald-500 mx-auto mb-4" />
|
||||
<h2 className="font-bold text-2xl mb-2">Consultation terminée</h2>
|
||||
<p className="text-slate-600 mb-2">Merci de votre visite.</p>
|
||||
<p className="text-slate-500 text-sm">À bientôt !</p>
|
||||
<h2 className="font-bold text-2xl mb-2">{t("patient.consultDone")}</h2>
|
||||
<p className="text-slate-600 mb-2">{t("patient.thanksForVisit")}</p>
|
||||
<p className="text-slate-500 text-sm">{t("patient.seeYouSoon")}</p>
|
||||
</motion.div>
|
||||
) : isAbsent ? (
|
||||
<motion.div
|
||||
|
|
@ -187,11 +202,11 @@ export default function PatientQueue() {
|
|||
className="glass-card-strong rounded-3xl p-8 text-center"
|
||||
>
|
||||
<XCircle className="w-16 h-16 text-orange-500 mx-auto mb-4" />
|
||||
<h2 className="font-bold text-2xl mb-2">Ticket clos</h2>
|
||||
<h2 className="font-bold text-2xl mb-2">{t("patient.ticketClosed")}</h2>
|
||||
<p className="text-slate-600 mb-4">
|
||||
{entry.status === "absent"
|
||||
? "Vous avez été marqué absent. Rescannez le QR à l'accueil pour rejoindre à nouveau."
|
||||
: "Votre ticket a été annulé."}
|
||||
? t("patient.markedAbsentDesc")
|
||||
: t("patient.ticketCanceledDesc")}
|
||||
</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
|
|
@ -201,35 +216,35 @@ export default function PatientQueue() {
|
|||
animate={{ opacity: 1 }}
|
||||
className="glass-card-strong rounded-3xl p-8 text-center"
|
||||
>
|
||||
<div className="text-xs uppercase tracking-widest text-slate-500 font-bold mb-2">Votre ticket</div>
|
||||
<div className="text-xs uppercase tracking-widest text-slate-500 font-bold mb-2">{t("patient.yourTicket")}</div>
|
||||
<div className="font-black text-7xl gradient-text leading-none mb-2">
|
||||
{formatTicket(entry.ticketNumber)}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500 mb-6">{entry.patientName ?? "Patient anonyme"}</div>
|
||||
<div className="text-sm text-slate-500 mb-6">{entry.patientName ?? t("patient.anonymousPatient")}</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 mb-6">
|
||||
<div className="p-4 rounded-2xl bg-gradient-to-br from-emerald-50 to-emerald-100/50 border border-emerald-200">
|
||||
<div className="text-xs uppercase tracking-wider text-emerald-700 font-bold mb-1">Position</div>
|
||||
<div className="text-xs uppercase tracking-wider text-emerald-700 font-bold mb-1">{t("patient.position")}</div>
|
||||
<div className="font-black text-3xl text-emerald-900">{entry.position}</div>
|
||||
<div className="text-xs text-emerald-700 mt-1">sur {waitingCount}</div>
|
||||
<div className="text-xs text-emerald-700 mt-1">{t("patient.outOf", { count: waitingCount })}</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-2xl bg-gradient-to-br from-cyan-50 to-cyan-100/50 border border-cyan-200">
|
||||
<div className="text-xs uppercase tracking-wider text-cyan-700 font-bold mb-1">Attente</div>
|
||||
<div className="text-xs uppercase tracking-wider text-cyan-700 font-bold mb-1">{t("patient.wait")}</div>
|
||||
<div className="font-black text-3xl text-cyan-900">~{entry.estimatedWaitMinutes ?? "?"}</div>
|
||||
<div className="text-xs text-cyan-700 mt-1">minutes</div>
|
||||
<div className="text-xs text-cyan-700 mt-1">{t("patient.minutesFull")}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{callingNow && (
|
||||
<div className="p-3 rounded-xl bg-amber-50 border border-amber-200 text-sm flex items-center justify-center gap-2 mb-4">
|
||||
<ArrowRight className="w-4 h-4 text-amber-600" />
|
||||
Patient en cours :{" "}
|
||||
{t("patient.currentPatient")} :{" "}
|
||||
<strong className="text-amber-900">{formatTicket(callingNow.ticketNumber)}</strong>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-slate-500 mb-4">
|
||||
Gardez cette page ouverte. Vous serez notifié quand votre tour approche.
|
||||
{t("patient.keepPageOpen")}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
|
|
@ -237,13 +252,13 @@ export default function PatientQueue() {
|
|||
size="sm"
|
||||
className="w-full text-red-600 border-red-200 hover:bg-red-50"
|
||||
onClick={() => {
|
||||
if (confirm("Annuler votre ticket ? Vous devrez rescanner le QR pour revenir.")) {
|
||||
if (confirm(t("patient.cancelConfirm"))) {
|
||||
cancelMutation.mutate({ patientToken });
|
||||
}
|
||||
}}
|
||||
disabled={cancelMutation.isPending}
|
||||
>
|
||||
Annuler mon ticket
|
||||
{t("patient.cancelMyTicket")}
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
|
@ -253,7 +268,7 @@ export default function PatientQueue() {
|
|||
<div className="text-center mt-6 text-xs text-slate-500">
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<Clock className="w-3 h-3" />
|
||||
Rejoint à {formatTime(entry.joinedAt)}
|
||||
{t("patient.joinedAt", { time: formatTime(entry.joinedAt) })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -263,7 +278,7 @@ export default function PatientQueue() {
|
|||
className="mt-4 w-full text-xs text-slate-400 hover:text-emerald-700 flex items-center justify-center gap-1"
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 ${ticketQuery.isFetching ? "animate-spin" : ""}`} />
|
||||
Actualiser
|
||||
{t("patient.refresh")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { useEffect } from "react";
|
||||
import { useParams, useLocation } from "wouter";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Stethoscope, Building2, MapPin, Clock, Hash,
|
||||
Printer, ChevronLeft, Loader2, XCircle,
|
||||
|
|
@ -9,6 +11,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { formatTicket, formatTime, formatDate } from "@/lib/utils";
|
||||
|
||||
export default function PrintTicket() {
|
||||
const { t } = useTranslation();
|
||||
const params = useParams<{ entryId: string }>();
|
||||
const [, navigate] = useLocation();
|
||||
const entryId = parseInt(params.entryId ?? "0", 10);
|
||||
|
|
@ -31,6 +34,10 @@ export default function PrintTicket() {
|
|||
if (ticketQuery.isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Helmet>
|
||||
<title>{t("ticket.metaTitle")}</title>
|
||||
<meta name="description" content={t("ticket.metaDescription")} />
|
||||
</Helmet>
|
||||
<Loader2 className="w-8 h-8 text-emerald-500 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
|
|
@ -39,14 +46,18 @@ export default function PrintTicket() {
|
|||
if (ticketQuery.error || !ticketQuery.data) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<Helmet>
|
||||
<title>{t("ticket.metaTitle")}</title>
|
||||
<meta name="description" content={t("ticket.metaDescription")} />
|
||||
</Helmet>
|
||||
<div className="glass-card rounded-3xl p-10 text-center max-w-md">
|
||||
<XCircle className="w-12 h-12 text-red-400 mx-auto mb-4" />
|
||||
<h1 className="font-bold text-2xl mb-2">Ticket introuvable</h1>
|
||||
<h1 className="font-bold text-2xl mb-2">{t("ticket.notFound")}</h1>
|
||||
<p className="text-slate-500 text-sm mb-6">
|
||||
Ce ticket n'existe pas ou a été supprimé.
|
||||
{t("ticket.notFoundDesc")}
|
||||
</p>
|
||||
<Button variant="gradient" onClick={() => navigate("/")}>
|
||||
Retour à l'accueil
|
||||
{t("common.backToHome")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -57,6 +68,10 @@ export default function PrintTicket() {
|
|||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<Helmet>
|
||||
<title>{t("ticket.metaTitle")}</title>
|
||||
<meta name="description" content={t("ticket.metaDescription")} />
|
||||
</Helmet>
|
||||
{/* Controls — hidden when printing */}
|
||||
<div className="print:hidden max-w-2xl mx-auto px-4 py-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
|
|
@ -65,7 +80,7 @@ export default function PrintTicket() {
|
|||
className="flex items-center gap-2 text-slate-500 hover:text-emerald-700 transition-colors text-sm"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Retour
|
||||
{t("common.back")}
|
||||
</button>
|
||||
<Button
|
||||
onClick={() => window.print()}
|
||||
|
|
@ -73,15 +88,13 @@ export default function PrintTicket() {
|
|||
className="font-semibold"
|
||||
>
|
||||
<Printer className="w-4 h-4 mr-2" />
|
||||
Imprimer le ticket
|
||||
{t("ticket.printTicket")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="glass-card rounded-2xl p-4 text-sm text-slate-600 flex items-start gap-3">
|
||||
<Printer className="w-5 h-5 text-emerald-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<strong className="text-slate-900">Conseil :</strong> imprimez sur du
|
||||
papier A6 ou pliez en deux. Donnez ce ticket au patient ; il pourra
|
||||
suivre son tour à l'écran d'affichage.
|
||||
<strong className="text-slate-900">{t("ticket.tipLabel")} :</strong> {t("ticket.tipText")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -119,7 +132,7 @@ export default function PrintTicket() {
|
|||
</span>
|
||||
</div>
|
||||
<p style={{ fontSize: 12, opacity: 0.9, margin: 0 }}>
|
||||
Ticket de file d'attente
|
||||
{t("ticket.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -142,7 +155,7 @@ export default function PrintTicket() {
|
|||
{/* Ticket number */}
|
||||
<div className="px-7 py-8 text-center">
|
||||
<div className="text-xs uppercase tracking-widest text-slate-500 font-bold mb-2">
|
||||
Votre numéro
|
||||
{t("ticket.yourNumber")}
|
||||
</div>
|
||||
<div
|
||||
className="font-black leading-none"
|
||||
|
|
@ -169,7 +182,7 @@ export default function PrintTicket() {
|
|||
<div className="rounded-2xl p-4 text-center bg-emerald-50 border border-emerald-200">
|
||||
<div className="text-[10px] uppercase tracking-wider text-emerald-700 font-bold flex items-center justify-center gap-1 mb-1">
|
||||
<Hash className="w-3 h-3" />
|
||||
Position
|
||||
{t("ticket.position")}
|
||||
</div>
|
||||
<div className="font-black text-2xl text-emerald-900">
|
||||
{entry.position ?? "—"}
|
||||
|
|
@ -178,11 +191,11 @@ export default function PrintTicket() {
|
|||
<div className="rounded-2xl p-4 text-center bg-cyan-50 border border-cyan-200">
|
||||
<div className="text-[10px] uppercase tracking-wider text-cyan-700 font-bold flex items-center justify-center gap-1 mb-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
Attente
|
||||
{t("ticket.wait")}
|
||||
</div>
|
||||
<div className="font-black text-2xl text-cyan-900">
|
||||
~{entry.estimatedWaitMinutes ?? "?"}
|
||||
<span className="text-xs font-bold ml-1">min</span>
|
||||
<span className="text-xs font-bold ml-1">{t("ticket.minShort")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -191,11 +204,9 @@ export default function PrintTicket() {
|
|||
<div className="px-7 pb-6">
|
||||
<div className="rounded-xl p-4 bg-slate-50 border border-slate-200 text-xs text-slate-600 leading-relaxed">
|
||||
<strong className="text-slate-900 block mb-1">
|
||||
Comment ça marche ?
|
||||
{t("ticket.howItWorks")}
|
||||
</strong>
|
||||
Surveillez l'écran d'affichage en salle. Lorsque votre numéro
|
||||
s'affiche, présentez-vous immédiatement à la salle de
|
||||
consultation.
|
||||
{t("ticket.howItWorksDesc")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -204,7 +215,7 @@ export default function PrintTicket() {
|
|||
className="border-t border-slate-200 px-7 py-3 flex items-center justify-between text-[10px] text-slate-400"
|
||||
style={{ background: "#f8fafc" }}
|
||||
>
|
||||
<span>Émis le {formatDate(entry.joinedAt)} à {formatTime(entry.joinedAt)}</span>
|
||||
<span>{t("ticket.issuedAt", { date: formatDate(entry.joinedAt), time: formatTime(entry.joinedAt) })}</span>
|
||||
<span>queuemed.fr</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { useParams, useLocation } from "wouter";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Stethoscope, Printer, ChevronLeft, Loader2, QrCode,
|
||||
RefreshCw, MapPin, Phone, Smartphone, Hand, Bell,
|
||||
|
|
@ -7,6 +9,7 @@ import { trpc } from "@/lib/trpc";
|
|||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function QrPoster() {
|
||||
const { t } = useTranslation();
|
||||
const params = useParams<{ clinicId: string }>();
|
||||
const [, navigate] = useLocation();
|
||||
const clinicId = parseInt(params.clinicId ?? "0", 10);
|
||||
|
|
@ -29,6 +32,9 @@ export default function QrPoster() {
|
|||
if (clinicQuery.isLoading || qrQuery.isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Helmet>
|
||||
<title>{t("qrPoster.metaTitle")}</title>
|
||||
</Helmet>
|
||||
<Loader2 className="w-8 h-8 text-emerald-500 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
|
|
@ -37,21 +43,49 @@ export default function QrPoster() {
|
|||
if (clinicQuery.error || !clinic) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<Helmet>
|
||||
<title>{t("qrPoster.metaTitle")}</title>
|
||||
</Helmet>
|
||||
<div className="glass-card rounded-3xl p-10 text-center max-w-md">
|
||||
<h1 className="font-bold text-2xl mb-2">Cabinet introuvable</h1>
|
||||
<h1 className="font-bold text-2xl mb-2">{t("qrPoster.notFoundTitle")}</h1>
|
||||
<p className="text-slate-500 text-sm mb-6">
|
||||
Ce cabinet n'existe pas ou ne vous appartient pas.
|
||||
{t("qrPoster.notFoundBody")}
|
||||
</p>
|
||||
<Button variant="gradient" onClick={() => navigate("/dashboard/clinics")}>
|
||||
Retour aux cabinets
|
||||
{t("qrPoster.backToClinics")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const steps = [
|
||||
{
|
||||
Icon: Smartphone,
|
||||
num: "1",
|
||||
title: t("qrPoster.steps.scan.title"),
|
||||
desc: t("qrPoster.steps.scan.desc"),
|
||||
},
|
||||
{
|
||||
Icon: Hand,
|
||||
num: "2",
|
||||
title: t("qrPoster.steps.join.title"),
|
||||
desc: t("qrPoster.steps.join.desc"),
|
||||
},
|
||||
{
|
||||
Icon: Bell,
|
||||
num: "3",
|
||||
title: t("qrPoster.steps.wait.title"),
|
||||
desc: t("qrPoster.steps.wait.desc"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<Helmet>
|
||||
<title>{t("qrPoster.metaTitle")}</title>
|
||||
<meta name="description" content={t("qrPoster.metaDescription")} />
|
||||
</Helmet>
|
||||
{/* Controls — hidden on print */}
|
||||
<div className="print:hidden max-w-2xl mx-auto px-4 py-6">
|
||||
<div className="flex items-center justify-between mb-4 gap-3 flex-wrap">
|
||||
|
|
@ -60,7 +94,7 @@ export default function QrPoster() {
|
|||
className="flex items-center gap-2 text-slate-500 hover:text-emerald-700 transition-colors text-sm"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Retour à la gestion
|
||||
{t("qrPoster.backToManagement")}
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
|
|
@ -70,7 +104,7 @@ export default function QrPoster() {
|
|||
size="sm"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${qrQuery.isFetching ? "animate-spin" : ""}`} />
|
||||
Rafraîchir
|
||||
{t("qrPoster.refresh")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="gradient"
|
||||
|
|
@ -78,7 +112,7 @@ export default function QrPoster() {
|
|||
className="font-semibold"
|
||||
>
|
||||
<Printer className="w-4 h-4 mr-2" />
|
||||
Imprimer l'affiche
|
||||
{t("qrPoster.printPoster")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -86,9 +120,8 @@ export default function QrPoster() {
|
|||
<div className="glass-card rounded-2xl p-4 flex items-start gap-3 text-sm">
|
||||
<QrCode className="w-5 h-5 text-emerald-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-slate-600">
|
||||
<strong className="text-slate-900">Conseils d'impression :</strong>{" "}
|
||||
utilisez du papier A4, en couleur si possible. Plastifiez
|
||||
l'affiche et placez-la à hauteur des yeux à l'entrée du cabinet.
|
||||
<strong className="text-slate-900">{t("qrPoster.tipsTitle")}</strong>{" "}
|
||||
{t("qrPoster.tipsBody")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -126,7 +159,7 @@ export default function QrPoster() {
|
|||
</span>
|
||||
</div>
|
||||
<p style={{ fontSize: 14, opacity: 0.92, margin: 0 }}>
|
||||
Salle d'attente virtuelle
|
||||
{t("qrPoster.tagline")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -150,10 +183,10 @@ export default function QrPoster() {
|
|||
|
||||
<div className="mt-8 mb-2">
|
||||
<p className="text-xl font-bold text-slate-900">
|
||||
Scannez pour rejoindre la file
|
||||
{t("qrPoster.scanToJoin")}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
Suivez votre position en temps réel sur votre téléphone.
|
||||
{t("qrPoster.followInRealTime")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -172,7 +205,7 @@ export default function QrPoster() {
|
|||
{qrDataUrl ? (
|
||||
<img
|
||||
src={qrDataUrl}
|
||||
alt="QR Code file d'attente"
|
||||
alt={t("qrPoster.qrAlt")}
|
||||
style={{ width: 240, height: 240, display: "block" }}
|
||||
/>
|
||||
) : (
|
||||
|
|
@ -189,7 +222,7 @@ export default function QrPoster() {
|
|||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
QR Code non disponible
|
||||
{t("qrPoster.qrUnavailable")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -197,26 +230,7 @@ export default function QrPoster() {
|
|||
|
||||
{/* Steps */}
|
||||
<div className="mt-10 grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{
|
||||
Icon: Smartphone,
|
||||
num: "1",
|
||||
title: "Scannez",
|
||||
desc: "Pointez votre appareil photo vers le QR code",
|
||||
},
|
||||
{
|
||||
Icon: Hand,
|
||||
num: "2",
|
||||
title: "Rejoignez",
|
||||
desc: "Appuyez sur le lien et entrez dans la file",
|
||||
},
|
||||
{
|
||||
Icon: Bell,
|
||||
num: "3",
|
||||
title: "Patientez",
|
||||
desc: "Vous serez alerté quand votre tour approche",
|
||||
},
|
||||
].map((step) => (
|
||||
{steps.map((step) => (
|
||||
<div
|
||||
key={step.num}
|
||||
className="rounded-2xl p-4 text-left"
|
||||
|
|
@ -260,16 +274,16 @@ export default function QrPoster() {
|
|||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-bold text-cyan-900">
|
||||
Aucune application à installer
|
||||
{t("qrPoster.noAppTitle")}
|
||||
</div>
|
||||
<div className="text-[11px] text-cyan-700">
|
||||
Fonctionne dans votre navigateur. Gratuit pour les patients.
|
||||
{t("qrPoster.noAppBody")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-5 text-[11px] text-slate-400 border-t border-slate-100 pt-4">
|
||||
Pas de smartphone ? Demandez un ticket imprimé à l'accueil.
|
||||
{t("qrPoster.noSmartphoneNote")}
|
||||
</p>
|
||||
|
||||
{qrUrl && (
|
||||
|
|
@ -284,7 +298,7 @@ export default function QrPoster() {
|
|||
className="border-t border-slate-200 px-10 py-3 flex items-center justify-between text-xs text-slate-400"
|
||||
style={{ background: "#f8fafc" }}
|
||||
>
|
||||
<span>Propulsé par QueueMed</span>
|
||||
<span>{t("qrPoster.poweredBy")}</span>
|
||||
<span>queuemed.fr</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useParams, useLocation } from "wouter";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ChevronLeft, Play, UserX, CheckCircle2, Trash2, Monitor, Users, Clock,
|
||||
Printer, RefreshCw, Loader2, Power, PowerOff, QrCode, Sparkles,
|
||||
|
|
@ -12,6 +14,7 @@ import { formatTicket, formatTime } from "@/lib/utils";
|
|||
import type { QueueEntryStatus } from "@shared/types";
|
||||
|
||||
export default function QueueManagement() {
|
||||
const { t } = useTranslation();
|
||||
const params = useParams<{ clinicId: string }>();
|
||||
const [, navigate] = useLocation();
|
||||
const clinicId = Number(params.clinicId ?? 0);
|
||||
|
|
@ -47,36 +50,36 @@ export default function QueueManagement() {
|
|||
// ─── Mutations ───────────────────────────────────────
|
||||
const callNext = trpc.queue.callNext.useMutation({
|
||||
onSuccess: (d) => {
|
||||
if (d.called) toast.success(`Ticket #${formatTicket(d.called.ticketNumber)} appelé`);
|
||||
else toast("Aucun patient en attente");
|
||||
if (d.called) toast.success(t("queue.toastTicketCalled", { number: formatTicket(d.called.ticketNumber) }));
|
||||
else toast(t("queue.noPatients"));
|
||||
utils.queue.getForDoctor.invalidate({ clinicId });
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const markAbsent = trpc.queue.markAbsent.useMutation({
|
||||
onSuccess: () => { toast.success("Patient marqué absent"); utils.queue.getForDoctor.invalidate({ clinicId }); },
|
||||
onSuccess: () => { toast.success(t("queue.toastPatientAbsent")); utils.queue.getForDoctor.invalidate({ clinicId }); },
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const markDone = trpc.queue.markDone.useMutation({
|
||||
onSuccess: () => { toast.success("Consultation terminée"); utils.queue.getForDoctor.invalidate({ clinicId }); },
|
||||
onSuccess: () => { toast.success(t("queue.toastConsultDone")); utils.queue.getForDoctor.invalidate({ clinicId }); },
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const callSpecific = trpc.queue.callSpecific.useMutation({
|
||||
onSuccess: () => { toast.success("Patient appelé"); utils.queue.getForDoctor.invalidate({ clinicId }); },
|
||||
onSuccess: () => { toast.success(t("queue.toastPatientCalled")); utils.queue.getForDoctor.invalidate({ clinicId }); },
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const reset = trpc.queue.reset.useMutation({
|
||||
onSuccess: () => { toast.success("File réinitialisée"); utils.queue.getForDoctor.invalidate({ clinicId }); },
|
||||
onSuccess: () => { toast.success(t("queue.toastQueueReset")); utils.queue.getForDoctor.invalidate({ clinicId }); },
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const printTicket = trpc.queue.joinPrinted.useMutation({
|
||||
onSuccess: (d) => {
|
||||
toast.success(`Ticket #${formatTicket(d.ticketNumber)} créé`);
|
||||
toast.success(t("queue.toastTicketCreated", { number: formatTicket(d.ticketNumber) }));
|
||||
window.open(`/ticket/${d.entryId}`, "_blank");
|
||||
utils.queue.getForDoctor.invalidate({ clinicId });
|
||||
},
|
||||
|
|
@ -94,7 +97,7 @@ export default function QueueManagement() {
|
|||
});
|
||||
|
||||
const regenQr = trpc.clinic.regenerateQr.useMutation({
|
||||
onSuccess: () => { toast.success("QR régénéré"); utils.clinic.qrDataUrl.invalidate({ id: clinicId, baseUrl }); },
|
||||
onSuccess: () => { toast.success(t("queue.toastQrRegenerated")); utils.clinic.qrDataUrl.invalidate({ id: clinicId, baseUrl }); },
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
|
|
@ -132,22 +135,30 @@ export default function QueueManagement() {
|
|||
if (!clinicId) {
|
||||
return (
|
||||
<div className="container py-12 text-center">
|
||||
<p className="text-slate-500">Cabinet introuvable.</p>
|
||||
<Helmet>
|
||||
<title>{t("queue.metaTitle")}</title>
|
||||
<meta name="description" content={t("queue.metaDescription")} />
|
||||
</Helmet>
|
||||
<p className="text-slate-500">{t("queue.clinicNotFound")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container py-6">
|
||||
<Helmet>
|
||||
<title>{t("queue.metaTitle")}</title>
|
||||
<meta name="description" content={t("queue.metaDescription")} />
|
||||
</Helmet>
|
||||
{/* ─── Header ───────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between mb-6 gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate("/dashboard")}>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" /> Retour
|
||||
<ChevronLeft className="w-4 h-4 mr-1" /> {t("common.back")}
|
||||
</Button>
|
||||
<div className="text-center min-w-0 flex-1">
|
||||
<h1 className="font-bold text-xl gradient-text truncate">{clinic?.name ?? "Chargement..."}</h1>
|
||||
<h1 className="font-bold text-xl gradient-text truncate">{clinic?.name ?? t("common.loading")}</h1>
|
||||
<p className="text-slate-500 text-xs">
|
||||
{waiting.length} en attente · {called.length} appelé{called.length > 1 ? "s" : ""}
|
||||
{t("queue.headerCounts", { waiting: waiting.length, called: called.length })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
|
|
@ -155,7 +166,7 @@ export default function QueueManagement() {
|
|||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.open(`/display/${clinicId}`, "_blank")}
|
||||
title="Écran d'affichage"
|
||||
title={t("queue.displayScreen")}
|
||||
>
|
||||
<Monitor className="w-4 h-4" />
|
||||
</Button>
|
||||
|
|
@ -165,7 +176,7 @@ export default function QueueManagement() {
|
|||
onClick={() => toggleQueue.mutate({ id: clinicId, isQueueOpen: !clinic?.isQueueOpen })}
|
||||
disabled={toggleQueue.isPending}
|
||||
>
|
||||
{clinic?.isQueueOpen ? <><PowerOff className="w-4 h-4 mr-1" />Fermer</> : <><Power className="w-4 h-4 mr-1" />Ouvrir</>}
|
||||
{clinic?.isQueueOpen ? <><PowerOff className="w-4 h-4 mr-1" />{t("queue.closeShort")}</> : <><Power className="w-4 h-4 mr-1" />{t("queue.openShort")}</>}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -175,7 +186,7 @@ export default function QueueManagement() {
|
|||
<div className="space-y-4">
|
||||
{/* Actions */}
|
||||
<div className="glass-card rounded-2xl p-6">
|
||||
<h2 className="text-xs font-bold text-slate-500 uppercase tracking-widest mb-4">Actions</h2>
|
||||
<h2 className="text-xs font-bold text-slate-500 uppercase tracking-widest mb-4">{t("queue.actions")}</h2>
|
||||
<Button
|
||||
variant="gradient"
|
||||
size="xl"
|
||||
|
|
@ -184,7 +195,7 @@ export default function QueueManagement() {
|
|||
disabled={callNext.isPending || waiting.length === 0}
|
||||
>
|
||||
{callNext.isPending ? <Loader2 className="w-5 h-5 animate-spin mr-2" /> : <Play className="w-5 h-5 mr-2" />}
|
||||
Appeler le suivant
|
||||
{t("queue.callNext")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -192,7 +203,7 @@ export default function QueueManagement() {
|
|||
onClick={() => printTicket.mutate({ clinicId })}
|
||||
disabled={printTicket.isPending || !clinic?.isQueueOpen}
|
||||
>
|
||||
<Printer className="w-4 h-4 mr-2" /> Imprimer un ticket
|
||||
<Printer className="w-4 h-4 mr-2" /> {t("queue.printTicket")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -200,16 +211,16 @@ export default function QueueManagement() {
|
|||
onClick={() => setConfirmReset(true)}
|
||||
disabled={reset.isPending}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" /> Réinitialiser la file
|
||||
<RefreshCw className="w-4 h-4 mr-2" /> {t("queue.resetQueue")}
|
||||
</Button>
|
||||
{confirmReset && (
|
||||
<div className="mt-3 p-3 rounded-xl bg-red-50 border border-red-200 text-sm text-red-800">
|
||||
<p className="mb-3">Êtes-vous sûr ? Toute la file sera effacée.</p>
|
||||
<p className="mb-3">{t("queue.resetConfirm")}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="destructive" onClick={() => { reset.mutate({ clinicId }); setConfirmReset(false); }} disabled={reset.isPending}>
|
||||
Oui, réinitialiser
|
||||
{t("queue.resetYes")}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setConfirmReset(false)}>Annuler</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setConfirmReset(false)}>{t("common.cancel")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -217,19 +228,19 @@ export default function QueueManagement() {
|
|||
|
||||
{/* QR */}
|
||||
<div className="glass-card rounded-2xl p-6">
|
||||
<h2 className="text-xs font-bold text-slate-500 uppercase tracking-widest mb-4">QR Code</h2>
|
||||
<h2 className="text-xs font-bold text-slate-500 uppercase tracking-widest mb-4">{t("queue.qrCode")}</h2>
|
||||
{qrQuery.data ? (
|
||||
<div className="text-center">
|
||||
<img src={qrQuery.data.dataUrl} alt="QR" className="w-44 h-44 mx-auto rounded-xl border border-slate-200 mb-3" />
|
||||
<img src={qrQuery.data.dataUrl} alt={t("queue.qrAlt")} className="w-44 h-44 mx-auto rounded-xl border border-slate-200 mb-3" />
|
||||
<p className="text-slate-500 text-xs mb-3">
|
||||
Expire : {qrQuery.data.qrTokenExpiresAt ? formatTime(qrQuery.data.qrTokenExpiresAt) : "—"}
|
||||
{t("queue.qrExpires")} : {qrQuery.data.qrTokenExpiresAt ? formatTime(qrQuery.data.qrTokenExpiresAt) : "—"}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" className="flex-1" onClick={() => regenQr.mutate({ id: clinicId })} disabled={regenQr.isPending}>
|
||||
{regenQr.isPending ? <Loader2 className="w-3 h-3 mr-1 animate-spin" /> : <RefreshCw className="w-3 h-3 mr-1" />} Renouveler
|
||||
{regenQr.isPending ? <Loader2 className="w-3 h-3 mr-1 animate-spin" /> : <RefreshCw className="w-3 h-3 mr-1" />} {t("queue.qrRenew")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="flex-1" onClick={() => navigate(`/dashboard/poster/${clinicId}`)}>
|
||||
<Printer className="w-3 h-3 mr-1" /> Affiche
|
||||
<Printer className="w-3 h-3 mr-1" /> {t("queue.qrPoster")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -242,12 +253,12 @@ export default function QueueManagement() {
|
|||
|
||||
{/* Stats */}
|
||||
<div className="glass-card rounded-2xl p-6">
|
||||
<h2 className="text-xs font-bold text-slate-500 uppercase tracking-widest mb-4">Statistiques</h2>
|
||||
<h2 className="text-xs font-bold text-slate-500 uppercase tracking-widest mb-4">{t("queue.statsTitle")}</h2>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ label: "En attente", value: waiting.length, icon: Users },
|
||||
{ label: "Appelé", value: called.length, icon: Play },
|
||||
{ label: "Cons. moy.", value: `~${clinic?.avgConsultationMinutes ?? 15} min`, icon: Clock },
|
||||
{ label: t("queue.waiting"), value: waiting.length, icon: Users },
|
||||
{ label: t("queue.called"), value: called.length, icon: Play },
|
||||
{ label: t("queue.statsAvgConsult"), value: t("queue.statsAvgConsultValue", { minutes: clinic?.avgConsultationMinutes ?? 15 }), icon: Clock },
|
||||
].map((s) => {
|
||||
const Icon = s.icon;
|
||||
return (
|
||||
|
|
@ -267,8 +278,8 @@ export default function QueueManagement() {
|
|||
<div className="lg:col-span-2">
|
||||
<div className="glass-card rounded-2xl overflow-hidden">
|
||||
<div className="p-4 border-b border-slate-100 flex items-center justify-between">
|
||||
<h2 className="font-bold">File d'attente</h2>
|
||||
<span className="text-slate-500 text-sm">{queue.length} patient{queue.length > 1 ? "s" : ""}</span>
|
||||
<h2 className="font-bold">{t("queue.queueListTitle")}</h2>
|
||||
<span className="text-slate-500 text-sm">{t("queue.patientCount", { count: queue.length })}</span>
|
||||
</div>
|
||||
|
||||
{queueQuery.isLoading ? (
|
||||
|
|
@ -278,9 +289,9 @@ export default function QueueManagement() {
|
|||
) : queue.length === 0 ? (
|
||||
<div className="text-center py-16 px-6">
|
||||
<Sparkles className="w-12 h-12 text-emerald-300 mx-auto mb-4" />
|
||||
<p className="text-slate-500 mb-2">Aucun patient en file</p>
|
||||
<p className="text-slate-500 mb-2">{t("queue.noPatients")}</p>
|
||||
{!clinic?.isQueueOpen && (
|
||||
<p className="text-slate-400 text-sm">Ouvrez la file pour commencer à accueillir des patients.</p>
|
||||
<p className="text-slate-400 text-sm">{t("queue.openToWelcome")}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -320,16 +331,16 @@ export default function QueueManagement() {
|
|||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5 flex-wrap">
|
||||
<span className="font-semibold text-sm truncate">
|
||||
{entry.patientName ?? `Patient #${entry.ticketNumber}`}
|
||||
{entry.patientName ?? t("queue.patientFallback", { number: entry.ticketNumber })}
|
||||
</span>
|
||||
{entry.isPrinted && (
|
||||
<span className="text-[10px] text-slate-500 bg-slate-100 rounded px-1.5 py-0.5">Imprimé</span>
|
||||
<span className="text-[10px] text-slate-500 bg-slate-100 rounded px-1.5 py-0.5">{t("queue.printed")}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
<span>Pos. {entry.position}</span>
|
||||
<span>{t("queue.posShort")} {entry.position}</span>
|
||||
<span>·</span>
|
||||
<span>~{entry.estimatedWaitMinutes ?? "?"} min</span>
|
||||
<span>~{entry.estimatedWaitMinutes ?? "?"} {t("queue.minShort")}</span>
|
||||
<span>·</span>
|
||||
<span>{formatTime(entry.joinedAt)}</span>
|
||||
</div>
|
||||
|
|
@ -344,7 +355,7 @@ export default function QueueManagement() {
|
|||
<button
|
||||
onClick={() => callSpecific.mutate({ entryId: entry.id })}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-emerald-600 hover:bg-emerald-100"
|
||||
title="Appeler ce patient"
|
||||
title={t("queue.callThisPatient")}
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
</button>
|
||||
|
|
@ -353,7 +364,7 @@ export default function QueueManagement() {
|
|||
<button
|
||||
onClick={() => markDone.mutate({ entryId: entry.id })}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-emerald-600 hover:bg-emerald-100"
|
||||
title="Terminer la consultation"
|
||||
title={t("queue.endConsultation")}
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
</button>
|
||||
|
|
@ -362,7 +373,7 @@ export default function QueueManagement() {
|
|||
<button
|
||||
onClick={() => markAbsent.mutate({ entryId: entry.id })}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-orange-600 hover:bg-orange-100"
|
||||
title="Marquer absent"
|
||||
title={t("queue.markAbsentTitle")}
|
||||
>
|
||||
<UserX className="w-4 h-4" />
|
||||
</button>
|
||||
|
|
@ -381,13 +392,14 @@ export default function QueueManagement() {
|
|||
}
|
||||
|
||||
function StatusBadge({ status }: { status: QueueEntryStatus }) {
|
||||
const { t } = useTranslation();
|
||||
const map: Record<QueueEntryStatus, { label: string; className: string }> = {
|
||||
waiting: { label: "En attente", className: "badge-waiting" },
|
||||
called: { label: "Appelé", className: "badge-called" },
|
||||
in_consultation: { label: "En consult.", className: "badge-called" },
|
||||
done: { label: "Terminé", className: "badge-done" },
|
||||
absent: { label: "Absent", className: "badge-absent" },
|
||||
canceled: { label: "Annulé", className: "badge-absent" },
|
||||
waiting: { label: t("queue.statusWaiting"), className: "badge-waiting" },
|
||||
called: { label: t("queue.statusCalled"), className: "badge-called" },
|
||||
in_consultation: { label: t("queue.statusInConsultation"), className: "badge-called" },
|
||||
done: { label: t("queue.statusDone"), className: "badge-done" },
|
||||
absent: { label: t("queue.statusAbsent"), className: "badge-absent" },
|
||||
canceled: { label: t("queue.statusCanceled"), className: "badge-absent" },
|
||||
};
|
||||
const m = map[status];
|
||||
return <span className={m.className}>{m.label}</span>;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,24 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { useLocation } from "wouter";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Lock, CreditCard, CheckCircle2 } from "lucide-react";
|
||||
|
||||
export default function SubscriptionBlocked() {
|
||||
const { t } = useTranslation();
|
||||
const [, navigate] = useLocation();
|
||||
const features = [
|
||||
t("subscriptionBlocked.features.0"),
|
||||
t("subscriptionBlocked.features.1"),
|
||||
t("subscriptionBlocked.features.2"),
|
||||
t("subscriptionBlocked.features.3"),
|
||||
];
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<Helmet>
|
||||
<title>{t("subscriptionBlocked.metaTitle")}</title>
|
||||
<meta name="description" content={t("subscriptionBlocked.metaDescription")} />
|
||||
</Helmet>
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 rounded-full bg-destructive/10 blur-3xl" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-80 h-80 rounded-full bg-secondary/10 blur-3xl" />
|
||||
|
|
@ -15,12 +28,12 @@ export default function SubscriptionBlocked() {
|
|||
<div className="w-16 h-16 rounded-2xl bg-destructive/20 border border-destructive/40 flex items-center justify-center mx-auto mb-6">
|
||||
<Lock className="w-8 h-8 text-destructive" />
|
||||
</div>
|
||||
<h1 className="font-display text-2xl font-bold mb-3">Abonnement expiré</h1>
|
||||
<h1 className="font-display text-2xl font-bold mb-3">{t("subscriptionBlocked.title")}</h1>
|
||||
<p className="text-muted-foreground text-sm mb-8 leading-relaxed">
|
||||
Votre période d'essai gratuit est terminée. Choisissez un abonnement pour continuer à utiliser Salle d'attente.
|
||||
{t("subscriptionBlocked.description")}
|
||||
</p>
|
||||
<div className="space-y-3 mb-8 text-left">
|
||||
{["File d'attente illimitée", "QR code anti-triche", "Suivi temps réel", "Analytics avancés"].map(f => (
|
||||
{features.map(f => (
|
||||
<div key={f} className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<CheckCircle2 className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
{f}
|
||||
|
|
@ -28,7 +41,7 @@ export default function SubscriptionBlocked() {
|
|||
))}
|
||||
</div>
|
||||
<Button onClick={() => navigate("/dashboard/subscription")} className="w-full bg-primary text-primary-foreground hover:bg-primary/90 glow-teal h-12 font-semibold">
|
||||
<CreditCard className="w-4 h-4 mr-2" /> Choisir un abonnement
|
||||
<CreditCard className="w-4 h-4 mr-2" /> {t("subscriptionBlocked.cta")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { useLocation } from "wouter";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
CreditCard, Check, Sparkles, Clock, Loader2, AlertTriangle,
|
||||
TrendingUp, Crown, Heart,
|
||||
|
|
@ -7,51 +9,69 @@ import { trpc } from "@/lib/trpc";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const PLANS = [
|
||||
{
|
||||
plan: "trial" as const,
|
||||
name: "Essai",
|
||||
price: "0€",
|
||||
period: "30 jours",
|
||||
description: "Découvrez QueueMed sans engagement.",
|
||||
features: ["1 cabinet", "Patients illimités", "Statistiques de base", "Support email"],
|
||||
icon: Sparkles,
|
||||
color: "from-slate-500 to-slate-600",
|
||||
},
|
||||
{
|
||||
plan: "basic" as const,
|
||||
name: "Basic",
|
||||
price: "29€",
|
||||
period: "/ mois",
|
||||
description: "Pour un cabinet individuel.",
|
||||
features: ["1 cabinet", "Patients illimités", "Écran d'affichage", "Statistiques avancées", "Support prioritaire"],
|
||||
icon: TrendingUp,
|
||||
color: "from-emerald-500 to-teal-500",
|
||||
highlighted: true,
|
||||
},
|
||||
{
|
||||
plan: "pro" as const,
|
||||
name: "Pro",
|
||||
price: "79€",
|
||||
period: "/ mois",
|
||||
description: "Centres médicaux et multi-praticiens.",
|
||||
features: ["Cabinets illimités", "Multi-praticiens", "Recommandations IA", "Export CSV", "Support téléphonique"],
|
||||
icon: Crown,
|
||||
color: "from-violet-500 to-fuchsia-500",
|
||||
},
|
||||
];
|
||||
|
||||
export default function SubscriptionPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [, navigate] = useLocation();
|
||||
const subQuery = trpc.subscription.get.useQuery();
|
||||
const checkQuery = trpc.subscription.check.useQuery();
|
||||
|
||||
const PLANS = [
|
||||
{
|
||||
plan: "trial" as const,
|
||||
name: t("subscription.plans.trial.name"),
|
||||
price: "0€",
|
||||
period: t("subscription.plans.trial.period"),
|
||||
description: t("subscription.plans.trial.description"),
|
||||
features: [
|
||||
t("subscription.plans.trial.features.0"),
|
||||
t("subscription.plans.trial.features.1"),
|
||||
t("subscription.plans.trial.features.2"),
|
||||
t("subscription.plans.trial.features.3"),
|
||||
],
|
||||
icon: Sparkles,
|
||||
color: "from-slate-500 to-slate-600",
|
||||
},
|
||||
{
|
||||
plan: "basic" as const,
|
||||
name: t("subscription.plans.basic.name"),
|
||||
price: "29€",
|
||||
period: t("subscription.plans.basic.period"),
|
||||
description: t("subscription.plans.basic.description"),
|
||||
features: [
|
||||
t("subscription.plans.basic.features.0"),
|
||||
t("subscription.plans.basic.features.1"),
|
||||
t("subscription.plans.basic.features.2"),
|
||||
t("subscription.plans.basic.features.3"),
|
||||
t("subscription.plans.basic.features.4"),
|
||||
],
|
||||
icon: TrendingUp,
|
||||
color: "from-emerald-500 to-teal-500",
|
||||
highlighted: true,
|
||||
},
|
||||
{
|
||||
plan: "pro" as const,
|
||||
name: t("subscription.plans.pro.name"),
|
||||
price: "79€",
|
||||
period: t("subscription.plans.pro.period"),
|
||||
description: t("subscription.plans.pro.description"),
|
||||
features: [
|
||||
t("subscription.plans.pro.features.0"),
|
||||
t("subscription.plans.pro.features.1"),
|
||||
t("subscription.plans.pro.features.2"),
|
||||
t("subscription.plans.pro.features.3"),
|
||||
t("subscription.plans.pro.features.4"),
|
||||
],
|
||||
icon: Crown,
|
||||
color: "from-violet-500 to-fuchsia-500",
|
||||
},
|
||||
];
|
||||
|
||||
const sub = subQuery.data;
|
||||
const check = checkQuery.data;
|
||||
|
||||
const handleSubscribe = (plan: "basic" | "pro") => {
|
||||
toast.info(`Redirection vers le paiement ${plan}…`, {
|
||||
description: "L'intégration Stripe sera activée prochainement.",
|
||||
toast.info(t("subscription.toastRedirect", { plan }), {
|
||||
description: t("subscription.toastRedirectDescription"),
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -67,12 +87,17 @@ export default function SubscriptionPage() {
|
|||
const isActive = sub?.status === "active";
|
||||
const daysLeft = check?.daysRemaining ?? 0;
|
||||
const expired = !check?.active;
|
||||
const locale = i18n.language === "en" ? "en-US" : "fr-FR";
|
||||
|
||||
return (
|
||||
<div className="container py-8">
|
||||
<Helmet>
|
||||
<title>{t("subscription.metaTitle")}</title>
|
||||
<meta name="description" content={t("subscription.metaDescription")} />
|
||||
</Helmet>
|
||||
<div className="mb-8">
|
||||
<h1 className="font-bold text-3xl mb-1">Abonnement</h1>
|
||||
<p className="text-slate-600">Gérez votre plan et votre période d'essai.</p>
|
||||
<h1 className="font-bold text-3xl mb-1">{t("subscription.title")}</h1>
|
||||
<p className="text-slate-600">{t("subscription.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
{/* Current status */}
|
||||
|
|
@ -95,7 +120,7 @@ export default function SubscriptionPage() {
|
|||
|
||||
<div className="relative z-10 flex flex-col md:flex-row items-start md:items-center justify-between gap-6">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-widest text-emerald-700 font-bold mb-2">Plan actuel</div>
|
||||
<div className="text-xs uppercase tracking-widest text-emerald-700 font-bold mb-2">{t("subscription.currentPlan")}</div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h2 className="font-bold text-3xl capitalize">{sub?.plan ?? "—"}</h2>
|
||||
<span
|
||||
|
|
@ -107,23 +132,23 @@ export default function SubscriptionPage() {
|
|||
: "bg-emerald-100 text-emerald-700"
|
||||
}`}
|
||||
>
|
||||
{expired ? "Expiré" : isTrialing ? "Essai" : isActive ? "Actif" : sub?.status}
|
||||
{expired ? t("subscription.expired") : isTrialing ? t("subscription.trial") : isActive ? t("subscription.active") : sub?.status}
|
||||
</span>
|
||||
</div>
|
||||
{expired ? (
|
||||
<p className="text-red-600 text-sm flex items-center gap-1.5">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
Votre abonnement est expiré. Renouvelez pour continuer à utiliser QueueMed.
|
||||
{t("subscription.expiredMessage")}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-slate-600 text-sm flex items-center gap-1.5">
|
||||
<Clock className="w-4 h-4 text-emerald-600" />
|
||||
{isTrialing ? "Essai gratuit" : "Prochain renouvellement"} dans <strong>{daysLeft} jour{daysLeft > 1 ? "s" : ""}</strong>
|
||||
{isTrialing ? t("subscription.freeTrial") : t("subscription.nextRenewal")} {t("subscription.in")} <strong>{t("subscription.daysCount", { count: daysLeft })}</strong>
|
||||
{sub?.trialEndsAt && isTrialing && (
|
||||
<span> (jusqu'au {new Date(sub.trialEndsAt).toLocaleDateString("fr-FR")})</span>
|
||||
<span> {t("subscription.untilDate", { date: new Date(sub.trialEndsAt).toLocaleDateString(locale) })}</span>
|
||||
)}
|
||||
{sub?.currentPeriodEnd && isActive && (
|
||||
<span> (jusqu'au {new Date(sub.currentPeriodEnd).toLocaleDateString("fr-FR")})</span>
|
||||
<span> {t("subscription.untilDate", { date: new Date(sub.currentPeriodEnd).toLocaleDateString(locale) })}</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -136,7 +161,7 @@ export default function SubscriptionPage() {
|
|||
onClick={() => handleSubscribe("basic")}
|
||||
>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
S'abonner maintenant
|
||||
{t("subscription.subscribeNow")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -144,8 +169,8 @@ export default function SubscriptionPage() {
|
|||
{isTrialing && daysLeft > 0 && (
|
||||
<div className="relative z-10 mt-6">
|
||||
<div className="flex justify-between text-xs text-slate-500 mb-1.5">
|
||||
<span>Jour {30 - daysLeft}</span>
|
||||
<span>{daysLeft} jours restants</span>
|
||||
<span>{t("subscription.dayN", { day: 30 - daysLeft })}</span>
|
||||
<span>{t("subscription.daysLeft", { days: daysLeft })}</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-slate-100 overflow-hidden">
|
||||
<div
|
||||
|
|
@ -158,7 +183,7 @@ export default function SubscriptionPage() {
|
|||
</div>
|
||||
|
||||
{/* Plans grid */}
|
||||
<h2 className="font-bold text-2xl mb-4">Choisissez votre plan</h2>
|
||||
<h2 className="font-bold text-2xl mb-4">{t("subscription.choosePlan")}</h2>
|
||||
<div className="grid md:grid-cols-3 gap-6 mb-8">
|
||||
{PLANS.map((p) => {
|
||||
const Icon = p.icon;
|
||||
|
|
@ -174,12 +199,12 @@ export default function SubscriptionPage() {
|
|||
>
|
||||
{p.highlighted && (
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2 px-3 py-1 rounded-full bg-orange-500 text-white text-xs font-bold uppercase tracking-wider shadow-md">
|
||||
Populaire
|
||||
{t("subscription.popular")}
|
||||
</div>
|
||||
)}
|
||||
{isCurrent && (
|
||||
<div className="absolute -top-3 right-4 px-3 py-1 rounded-full bg-white text-emerald-700 text-xs font-bold uppercase tracking-wider shadow-md">
|
||||
Actuel
|
||||
{t("subscription.current")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -210,7 +235,7 @@ export default function SubscriptionPage() {
|
|||
className="w-full"
|
||||
disabled
|
||||
>
|
||||
{isCurrent ? "Plan actuel" : "Essai automatique"}
|
||||
{isCurrent ? t("subscription.currentPlanLabel") : t("subscription.automaticTrial")}
|
||||
</Button>
|
||||
) : isCurrent ? (
|
||||
<Button
|
||||
|
|
@ -218,7 +243,7 @@ export default function SubscriptionPage() {
|
|||
className={`w-full ${p.highlighted ? "bg-white text-emerald-700 hover:bg-emerald-50" : ""}`}
|
||||
disabled
|
||||
>
|
||||
Plan actuel
|
||||
{t("subscription.currentPlanLabel")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
|
|
@ -229,7 +254,7 @@ export default function SubscriptionPage() {
|
|||
}`}
|
||||
onClick={() => handleSubscribe(p.plan)}
|
||||
>
|
||||
S'abonner
|
||||
{t("subscription.subscribe")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -240,10 +265,9 @@ export default function SubscriptionPage() {
|
|||
{/* Guarantee */}
|
||||
<div className="glass-card rounded-2xl p-6 text-center">
|
||||
<Heart className="w-8 h-8 text-rose-500 mx-auto mb-3" />
|
||||
<h3 className="font-bold text-lg mb-1">Notre engagement</h3>
|
||||
<h3 className="font-bold text-lg mb-1">{t("subscription.commitmentTitle")}</h3>
|
||||
<p className="text-sm text-slate-600 max-w-2xl mx-auto">
|
||||
Annulation à tout moment. Données hébergées en France. Conformité RGPD.
|
||||
Migration et configuration assistées gratuites.
|
||||
{t("subscription.commitmentBody")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { useState, useEffect, useRef } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
import Layout from "@/components/Layout";
|
||||
|
|
@ -22,37 +24,14 @@ import {
|
|||
Loader2,
|
||||
Info,
|
||||
Smartphone,
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
import CountryCodeManager from "@/components/CountryCodeManager";
|
||||
import WhatsAppTemplateEditor from "@/components/WhatsAppTemplateEditor";
|
||||
|
||||
type WAStatus = "disconnected" | "connecting" | "qr_ready" | "connected";
|
||||
|
||||
const STATUS_CONFIG: Record<WAStatus, { label: string; color: string; icon: React.ReactNode }> = {
|
||||
disconnected: {
|
||||
label: "Déconnecté",
|
||||
color: "bg-gray-500/20 text-gray-400 border-gray-500/30",
|
||||
icon: <WifiOff className="w-4 h-4" />,
|
||||
},
|
||||
connecting: {
|
||||
label: "Connexion en cours…",
|
||||
color: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30",
|
||||
icon: <Loader2 className="w-4 h-4 animate-spin" />,
|
||||
},
|
||||
qr_ready: {
|
||||
label: "En attente du scan",
|
||||
color: "bg-blue-500/20 text-blue-400 border-blue-500/30",
|
||||
icon: <QrCode className="w-4 h-4" />,
|
||||
},
|
||||
connected: {
|
||||
label: "Connecté",
|
||||
color: "bg-emerald-500/20 text-emerald-400 border-emerald-500/30",
|
||||
icon: <CheckCircle className="w-4 h-4" />,
|
||||
},
|
||||
};
|
||||
|
||||
export default function WhatsAppSetup() {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
|
|
@ -61,6 +40,29 @@ export default function WhatsAppSetup() {
|
|||
const [pollingActive, setPollingActive] = useState(false);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const STATUS_CONFIG: Record<WAStatus, { label: string; color: string; icon: React.ReactNode }> = {
|
||||
disconnected: {
|
||||
label: t("whatsapp.statusDisconnected"),
|
||||
color: "bg-gray-500/20 text-gray-400 border-gray-500/30",
|
||||
icon: <WifiOff className="w-4 h-4" />,
|
||||
},
|
||||
connecting: {
|
||||
label: t("whatsapp.statusConnecting"),
|
||||
color: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30",
|
||||
icon: <Loader2 className="w-4 h-4 animate-spin" />,
|
||||
},
|
||||
qr_ready: {
|
||||
label: t("whatsapp.statusQrReady"),
|
||||
color: "bg-blue-500/20 text-blue-400 border-blue-500/30",
|
||||
icon: <QrCode className="w-4 h-4" />,
|
||||
},
|
||||
connected: {
|
||||
label: t("whatsapp.statusConnected"),
|
||||
color: "bg-emerald-500/20 text-emerald-400 border-emerald-500/30",
|
||||
icon: <CheckCircle className="w-4 h-4" />,
|
||||
},
|
||||
};
|
||||
|
||||
const { data: clinics = [] } = trpc.clinic.list.useQuery();
|
||||
|
||||
const { data: waStatus, refetch: refetchStatus } = trpc.whatsapp.status.useQuery(
|
||||
|
|
@ -73,10 +75,10 @@ export default function WhatsAppSetup() {
|
|||
utils.whatsapp.status.invalidate();
|
||||
if (data.status === "qr_ready") {
|
||||
setPollingActive(true);
|
||||
toast.info("QR code généré — scannez avec WhatsApp");
|
||||
toast.info(t("whatsapp.toastQrGenerated"));
|
||||
} else if (data.status === "connected") {
|
||||
setPollingActive(false);
|
||||
toast.success("WhatsApp connecté !");
|
||||
toast.success(t("whatsapp.toastConnected"));
|
||||
}
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
|
|
@ -86,15 +88,15 @@ export default function WhatsAppSetup() {
|
|||
onSuccess: () => {
|
||||
setPollingActive(false);
|
||||
utils.whatsapp.status.invalidate();
|
||||
toast.success("Session WhatsApp déconnectée");
|
||||
toast.success(t("whatsapp.toastDisconnected"));
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const testMut = trpc.whatsapp.sendTest.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data.success) toast.success("Message test envoyé !");
|
||||
else toast.error(`Échec : ${data.error}`);
|
||||
if (data.success) toast.success(t("whatsapp.toastTestSent"));
|
||||
else toast.error(t("whatsapp.toastTestFailed", { error: data.error ?? "" }));
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
|
|
@ -133,6 +135,10 @@ export default function WhatsAppSetup() {
|
|||
|
||||
return (
|
||||
<Layout>
|
||||
<Helmet>
|
||||
<title>{t("whatsapp.metaTitle")}</title>
|
||||
<meta name="description" content={t("whatsapp.metaDescription")} />
|
||||
</Helmet>
|
||||
<div className="max-w-3xl mx-auto px-4 py-8 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
|
|
@ -140,9 +146,9 @@ export default function WhatsAppSetup() {
|
|||
<MessageSquare className="w-6 h-6 text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Notifications WhatsApp</h1>
|
||||
<h1 className="text-2xl font-bold text-foreground">{t("whatsapp.headerTitle")}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Connectez WhatsApp pour envoyer des alertes automatiques à vos patients
|
||||
{t("whatsapp.headerSubtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -151,9 +157,7 @@ export default function WhatsAppSetup() {
|
|||
<Alert className="border-amber-500/30 bg-amber-500/10">
|
||||
<Info className="h-4 w-4 text-amber-400" />
|
||||
<AlertDescription className="text-amber-300 text-sm">
|
||||
<strong>Note :</strong> Cette fonctionnalité utilise WhatsApp Web (protocole non officiel).
|
||||
Limitez l'envoi à moins de 500 messages/jour pour éviter tout risque de restriction.
|
||||
Un numéro WhatsApp personnel ou professionnel est requis.
|
||||
<strong>{t("whatsapp.disclaimerNote")}</strong> {t("whatsapp.disclaimerBody")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
|
|
@ -161,7 +165,7 @@ export default function WhatsAppSetup() {
|
|||
{clinics.length > 1 && (
|
||||
<Card className="border-border/50 bg-card/50">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Cabinet</CardTitle>
|
||||
<CardTitle className="text-base">{t("whatsapp.clinic")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
|
@ -186,7 +190,7 @@ export default function WhatsAppSetup() {
|
|||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Wifi className="w-4 h-4" />
|
||||
Statut de la connexion
|
||||
{t("whatsapp.connectionStatus")}
|
||||
</CardTitle>
|
||||
<Badge className={`flex items-center gap-1.5 border ${cfg.color}`}>
|
||||
{cfg.icon}
|
||||
|
|
@ -201,20 +205,20 @@ export default function WhatsAppSetup() {
|
|||
<div className="p-4 bg-white rounded-2xl shadow-lg">
|
||||
<img
|
||||
src={waStatus.qrCode}
|
||||
alt="QR Code WhatsApp"
|
||||
alt={t("whatsapp.qrAltText")}
|
||||
className="w-56 h-56"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center space-y-1">
|
||||
<p className="font-medium text-foreground flex items-center gap-2 justify-center">
|
||||
<Smartphone className="w-4 h-4 text-emerald-400" />
|
||||
Comment scanner
|
||||
{t("whatsapp.howToScan")}
|
||||
</p>
|
||||
<ol className="text-sm text-muted-foreground space-y-1 text-left max-w-xs">
|
||||
<li>1. Ouvrez WhatsApp sur votre téléphone</li>
|
||||
<li>2. Appuyez sur ⋮ → <strong>Appareils liés</strong></li>
|
||||
<li>3. Appuyez sur <strong>Lier un appareil</strong></li>
|
||||
<li>4. Scannez ce QR code</li>
|
||||
<li>{t("whatsapp.scanStep1")}</li>
|
||||
<li>{t("whatsapp.scanStep2")}</li>
|
||||
<li>{t("whatsapp.scanStep3")}</li>
|
||||
<li>{t("whatsapp.scanStep4")}</li>
|
||||
</ol>
|
||||
</div>
|
||||
<Button
|
||||
|
|
@ -224,7 +228,7 @@ export default function WhatsAppSetup() {
|
|||
className="gap-2"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
Actualiser le statut
|
||||
{t("whatsapp.refreshStatus")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -234,9 +238,9 @@ export default function WhatsAppSetup() {
|
|||
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
|
||||
<CheckCircle className="w-5 h-5 text-emerald-400 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium text-emerald-300">WhatsApp connecté</p>
|
||||
<p className="font-medium text-emerald-300">{t("whatsapp.connectedTitle")}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Les notifications automatiques sont actives pour ce cabinet.
|
||||
{t("whatsapp.connectedBody")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -247,9 +251,9 @@ export default function WhatsAppSetup() {
|
|||
<div className="flex items-center gap-3 p-4 rounded-xl bg-muted/30 border border-border/50">
|
||||
<AlertCircle className="w-5 h-5 text-muted-foreground shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Non connecté</p>
|
||||
<p className="font-medium text-foreground">{t("whatsapp.notConnectedTitle")}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Cliquez sur « Connecter » pour générer un QR code à scanner.
|
||||
{t("whatsapp.notConnectedBody")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -268,7 +272,7 @@ export default function WhatsAppSetup() {
|
|||
) : (
|
||||
<QrCode className="w-4 h-4" />
|
||||
)}
|
||||
Connecter WhatsApp
|
||||
{t("whatsapp.connect")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
|
@ -281,7 +285,7 @@ export default function WhatsAppSetup() {
|
|||
className="gap-2"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Nouveau QR code
|
||||
{t("whatsapp.newQr")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDisconnect}
|
||||
|
|
@ -289,7 +293,7 @@ export default function WhatsAppSetup() {
|
|||
className="gap-2 text-muted-foreground"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
Annuler
|
||||
{t("whatsapp.cancel")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -297,7 +301,7 @@ export default function WhatsAppSetup() {
|
|||
{status === "connecting" && (
|
||||
<Button disabled className="gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Connexion en cours…
|
||||
{t("whatsapp.statusConnecting")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
|
@ -313,7 +317,7 @@ export default function WhatsAppSetup() {
|
|||
) : (
|
||||
<LogOut className="w-4 h-4" />
|
||||
)}
|
||||
Déconnecter
|
||||
{t("whatsapp.disconnect")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -326,19 +330,20 @@ export default function WhatsAppSetup() {
|
|||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Send className="w-4 h-4" />
|
||||
Message de test
|
||||
{t("whatsapp.testMessage")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Envoyez un message de test pour vérifier que la connexion fonctionne.
|
||||
{t("whatsapp.testMessageHelp")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-3">
|
||||
<Input
|
||||
placeholder="Numéro international (ex: 33612345678)"
|
||||
placeholder={t("whatsapp.testPhonePlaceholder")}
|
||||
value={testPhone}
|
||||
onChange={(e) => setTestPhone(e.target.value)}
|
||||
className="flex-1"
|
||||
aria-label={t("whatsapp.testPhoneLabel")}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleTest}
|
||||
|
|
@ -350,11 +355,11 @@ export default function WhatsAppSetup() {
|
|||
) : (
|
||||
<Send className="w-4 h-4" />
|
||||
)}
|
||||
Envoyer
|
||||
{t("whatsapp.send")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Entrez le numéro sans le + (ex: 33612345678 pour +33 6 12 34 56 78)
|
||||
{t("whatsapp.phoneFormatHint")}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -378,27 +383,27 @@ export default function WhatsAppSetup() {
|
|||
{/* How it works */}
|
||||
<Card className="border-border/50 bg-card/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Comment ça fonctionne</CardTitle>
|
||||
<CardTitle className="text-base">{t("whatsapp.howItWorks")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{[
|
||||
{
|
||||
step: "1",
|
||||
title: "Inscription",
|
||||
desc: "Le patient entre son numéro WhatsApp lors de l'inscription via QR code",
|
||||
title: t("whatsapp.step1Title"),
|
||||
desc: t("whatsapp.step1Desc"),
|
||||
color: "text-teal-400",
|
||||
},
|
||||
{
|
||||
step: "2",
|
||||
title: "Alerte bientôt",
|
||||
desc: "Quand il reste 2 patients avant lui, il reçoit un message d'alerte",
|
||||
title: t("whatsapp.step2Title"),
|
||||
desc: t("whatsapp.step2Desc"),
|
||||
color: "text-orange-400",
|
||||
},
|
||||
{
|
||||
step: "3",
|
||||
title: "C'est son tour",
|
||||
desc: "Quand le médecin l'appelle, il reçoit immédiatement un message",
|
||||
title: t("whatsapp.step3Title"),
|
||||
desc: t("whatsapp.step3Desc"),
|
||||
color: "text-emerald-400",
|
||||
},
|
||||
].map((item) => (
|
||||
|
|
|
|||
2
client/src/vite-env.d.ts
vendored
Normal file
2
client/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
|
|
@ -17,7 +17,7 @@
|
|||
"skipLibCheck": true,
|
||||
"allowImportingTsExtensions": false,
|
||||
"noEmit": true,
|
||||
"types": ["node", "vite/client"],
|
||||
"types": ["node", "vite/client", "vite-plugin-pwa/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["client/src/*"],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
|
|
@ -8,7 +9,87 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|||
|
||||
export default defineConfig({
|
||||
root: path.resolve(__dirname, "client"),
|
||||
plugins: [react(), tailwindcss()],
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
VitePWA({
|
||||
registerType: "autoUpdate",
|
||||
includeAssets: ["favicon.svg", "icon-192x192.svg", "icon-512x512.svg"],
|
||||
manifest: {
|
||||
name: "QueueMed",
|
||||
short_name: "QueueMed",
|
||||
description:
|
||||
"Salle d'attente virtuelle pour cabinets médicaux. Vos patients scannent un QR code et suivent leur tour en temps réel.",
|
||||
theme_color: "#10b981",
|
||||
background_color: "#f0fdf4",
|
||||
display: "standalone",
|
||||
orientation: "portrait",
|
||||
start_url: "/",
|
||||
scope: "/",
|
||||
lang: "fr",
|
||||
icons: [
|
||||
{
|
||||
src: "/icon-192x192.svg",
|
||||
sizes: "192x192",
|
||||
type: "image/svg+xml",
|
||||
purpose: "any maskable",
|
||||
},
|
||||
{
|
||||
src: "/icon-512x512.svg",
|
||||
sizes: "512x512",
|
||||
type: "image/svg+xml",
|
||||
purpose: "any maskable",
|
||||
},
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ["**/*.{js,css,html,svg,png,ico,woff2}"],
|
||||
navigateFallbackDenylist: [/^\/api/, /^\/socket\.io/, /^\/trpc/],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
|
||||
handler: "CacheFirst",
|
||||
options: {
|
||||
cacheName: "google-fonts-stylesheets",
|
||||
expiration: { maxEntries: 10, maxAgeSeconds: 60 * 60 * 24 * 365 },
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i,
|
||||
handler: "CacheFirst",
|
||||
options: {
|
||||
cacheName: "google-fonts-webfonts",
|
||||
expiration: { maxEntries: 30, maxAgeSeconds: 60 * 60 * 24 * 365 },
|
||||
cacheableResponse: { statuses: [0, 200] },
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /\/api\/.*$/i,
|
||||
handler: "NetworkFirst",
|
||||
options: {
|
||||
cacheName: "api-cache",
|
||||
networkTimeoutSeconds: 5,
|
||||
expiration: { maxEntries: 50, maxAgeSeconds: 60 * 5 },
|
||||
cacheableResponse: { statuses: [0, 200] },
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /\/trpc\/.*$/i,
|
||||
handler: "NetworkFirst",
|
||||
options: {
|
||||
cacheName: "trpc-cache",
|
||||
networkTimeoutSeconds: 5,
|
||||
expiration: { maxEntries: 50, maxAgeSeconds: 60 * 5 },
|
||||
cacheableResponse: { statuses: [0, 200] },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
devOptions: {
|
||||
enabled: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "client/src"),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue