Compare commits

...

No commits in common. "master" and "main" have entirely different histories.
master ... main

149 changed files with 77 additions and 39627 deletions

View file

@ -1,27 +0,0 @@
node_modules
dist
.git
.github
.gitignore
.env
.env.*
!.env.example
src_ref
docs_ref
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.DS_Store
.idea
.vscode
coverage
.turbo
.next
*.md
!README.md
backend-prompt.md
MANUS_HANDOFF.md
MODE_OPERATOIRE.md
CLAUDE.md

View file

@ -1,61 +0,0 @@
# ─── Database ───────────────────────────────────────────────────────────────
# Local dev (host MySQL):
# DATABASE_URL=mysql://queuemed:queuemed@localhost:3306/queuemed
# Docker compose (uses the "db" service):
# DATABASE_URL=mysql://queuemed:queuemed@db:3306/queuemed
DATABASE_URL=mysql://queuemed:queuemed@localhost:3306/queuemed
# ─── Auth ───────────────────────────────────────────────────────────────────
# REQUIRED. Must be at least 32 characters of high-entropy random data.
# Generate one with: openssl rand -hex 64
# The server refuses to start if this is missing or too short.
JWT_SECRET=replace_me_with_openssl_rand_hex_64_output
# ─── Server ─────────────────────────────────────────────────────────────────
PORT=5000
NODE_ENV=development
# Public URL used to build QR code links (e.g. https://queuemed.example.com).
# In production this should match the public origin allowed by CORS.
PUBLIC_BASE_URL=
# ─── Stripe ─────────────────────────────────────────────────────────────────
# All Stripe vars are OPTIONAL. If STRIPE_SECRET_KEY is not set, the app still
# runs and the subscription UI shows a friendly "not configured" notice.
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
STRIPE_BASIC_PRICE_ID=
STRIPE_PRO_PRICE_ID=
# Client-side price IDs — must be exposed at build time via VITE_ prefix.
# Same values as STRIPE_BASIC_PRICE_ID / STRIPE_PRO_PRICE_ID.
VITE_STRIPE_BASIC_PRICE_ID=
VITE_STRIPE_PRO_PRICE_ID=
# ─── WhatsApp (Baileys) ─────────────────────────────────────────────────────
# Persistent directory used to store Baileys auth credentials per clinic.
# Must live on a Docker volume in production so sessions survive restarts.
WHATSAPP_SESSION_DIR=/app/data/whatsapp-sessions
# ─── Twilio SMS ─────────────────────────────────────────────────────────────
# All Twilio vars are OPTIONAL. If any is missing, SMS sending is disabled
# and the app logs a warning instead of failing. Each clinic also has a
# `smsEnabled` opt-in flag; SMS is never sent without both.
TWILIO_ACCOUNT_SID=
TWILIO_AUTH_TOKEN=
TWILIO_PHONE_NUMBER=
# ─── Docker compose only ────────────────────────────────────────────────────
MYSQL_ROOT_PASSWORD=replace_me_with_a_strong_password
MYSQL_DATABASE=queuemed
MYSQL_USER=queuemed
MYSQL_PASSWORD=replace_me_with_a_strong_password
MYSQL_PORT=3306
APP_PORT=5000
# ─── Backups (used by scripts/backup-db.sh) ─────────────────────────────────
# Inside the `app` container these point at the `db` service.
# Override only if running the script outside docker compose.
# MYSQL_HOST=db
# BACKUP_DIR=/app/data/backups
# BACKUP_KEEP=7

17
.gitignore vendored
View file

@ -1,17 +0,0 @@
node_modules/
dist/
.env
.env.docker
.env.local
*.log
.DS_Store
Thumbs.db
*.md
!README.md
!docs/
.vscode/
.idea/
*.swp
*.swo
*~
.claude/

0
AGENT_CONTEXT.md Normal file
View file

0
AUTHORS.md Normal file
View file

106
CLAUDE.md
View file

@ -1,106 +0,0 @@
# QueueMed — Salle d'attente virtuelle pour cabinets médicaux
## Architecture
- **Frontend**: React 19, Vite 6, Tailwind CSS 4, shadcn/ui, wouter, Framer Motion, Recharts, Socket.io-client
- **Backend**: Express 4, tRPC 11, Socket.io 4, Drizzle ORM
- **Database**: MySQL 8
- **Auth**: JWT + session cookie (simple email/password login, no OAuth)
- **QR Code**: qrcode npm package with rotating anti-cheat tokens
- **Deploy**: Docker + docker-compose, Nginx Proxy Manager for HTTPS
## Theme — MEDICAL LIGHT
- **Primary**: #10b981 (emerald-500) and #06b6d4 (cyan-500)
- **Background**: white / #f0fdf4 (green-50) / #ecfeff (cyan-50)
- **Cards**: white with subtle shadow, glass-morphism (backdrop-blur, bg-white/70)
- **Accents**: #0d9488 (teal-600) for CTAs, #f97316 (orange-500) for alerts
- **Feel**: clean, hygienic, medical — light green/cyan, translucent panels, rounded corners
- **Font**: Inter (Google Fonts)
## Database Schema (see docs_ref/schema.ts)
5 tables: users, subscriptions, clinics, queueEntries, analyticsEvents
## Key Routes
| Route | Page | Access |
|---|---|---|
| / | Landing page (hero, features, pricing, testimonials) | Public |
| /login | Login page | Public |
| /dashboard | Doctor dashboard (KPIs, clinic list, quick actions) | Auth |
| /dashboard/clinics | Manage clinics (CRUD, QR code, settings) | Auth |
| /dashboard/queue/:clinicId | Real-time queue management | Auth |
| /dashboard/analytics | Charts, CSV export, AI recommendations | Auth |
| /dashboard/subscription | Subscription plans, trial, blocking | Auth |
| /display/:clinicId | Display screen for tablet/monitor | Public |
| /queue/:token | Patient interface (live position, alerts) | Public |
| /ticket/:entryId | Printable ticket | Public |
## Project Structure
```
/home/ubuntu/queue-med-deploy/
├── client/
│ ├── src/
│ │ ├── main.tsx # React entry + wouter router
│ │ ├── App.tsx # Layout shell
│ │ ├── lib/
│ │ │ └── trpc.ts # tRPC client setup
│ │ ├── components/
│ │ │ └── ui/ # shadcn/ui components
│ │ ├── _core/
│ │ │ └── hooks/
│ │ │ └── useAuth.ts
│ │ └── pages/
│ │ ├── Home.tsx # Landing
│ │ ├── Login.tsx
│ │ ├── Dashboard.tsx
│ │ ├── DoctorClinics.tsx
│ │ ├── QueueManagement.tsx
│ │ ├── Analytics.tsx
│ │ ├── PatientQueue.tsx
│ │ ├── DisplayScreen.tsx
│ │ ├── SubscriptionPage.tsx
│ │ ├── PrintTicket.tsx
│ │ ├── Onboarding.tsx
│ │ ├── Help.tsx
│ │ └── QrPoster.tsx
│ └── index.html
├── server/
│ ├── _core/
│ │ ├── index.ts # Express + Socket.io server
│ │ ├── trpc.ts # tRPC setup
│ │ └── context.ts # Auth context
│ ├── routers.ts # All tRPC procedures
│ ├── db.ts # Drizzle helpers
│ ├── schema.ts # Drizzle schema
│ └── auth.ts # JWT auth logic
├── shared/
│ └── types.ts # Shared types
├── drizzle.config.ts
├── vite.config.ts
├── tsconfig.json
├── package.json
├── Dockerfile
├── docker-compose.yml
└── .dockerignore
```
## Environment Variables
- DATABASE_URL — MySQL connection string
- JWT_SECRET — Secret for JWT signing
- PORT — Server port (default 5000)
- NODE_ENV — production/development
## Socket.io Rooms
- clinic:{clinicId} — Doctor + display screen
- patient:{patientToken} — Individual patient
- display:{clinicId} — Display screen only
## Commands
- pnpm dev — Start dev server
- pnpm build — Production build
- pnpm db:push — Push Drizzle migrations
- pnpm start — Start production server
## CRITICAL NOTES
- Use existing pages in src_ref/ as reference for UI patterns and tRPC calls
- The QR token rotation system is anti-cheat: tokens expire on schedule
- Subscription middleware blocks sensitive procedures when expired
- Socket.io is initialized in server/_core/index.ts and exposed globally

View file

@ -1,54 +0,0 @@
# syntax=docker/dockerfile:1.7
# ─── Stage 1 — install deps ─────────────────────────────────────────────────
FROM node:22-alpine AS deps
WORKDIR /app
RUN apk add --no-cache git python3 make g++
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
COPY package.json pnpm-lock.yaml* ./
RUN pnpm install --frozen-lockfile || pnpm install
# ─── Stage 2 — build client + server ─────────────────────────────────────────
FROM node:22-alpine AS builder
WORKDIR /app
RUN apk add --no-cache git
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm build
# ─── Stage 3 — runtime ──────────────────────────────────────────────────────
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production \
PORT=5000
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
# Copy production deps + built assets
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package.json tsconfig.json drizzle.config.ts ./
COPY server ./server
COPY shared ./shared
# Persistent app data directory (WhatsApp sessions, DB backups, …).
# In production this should be backed by a Docker named volume.
RUN mkdir -p /app/data/whatsapp-sessions /app/data/backups
# Bundle the operational scripts (DB backup, etc.)
COPY scripts ./scripts
RUN chmod +x ./scripts/*.sh || true
# Tools used by the backup script
RUN apk add --no-cache mysql-client
RUN addgroup -S app && adduser -S app -G app && chown -R app:app /app
USER app
EXPOSE 5000
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -qO- http://127.0.0.1:5000/api/health || exit 1
CMD ["pnpm", "start"]

Binary file not shown.

0
ROADMAP.html Normal file
View file

View file

@ -1,32 +0,0 @@
<!doctype html>
<html lang="fr">
<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 />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap"
rel="stylesheet"
/>
</head>
<body class="antialiased font-sans bg-white text-slate-900">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View file

@ -1,13 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 480 B

View file

@ -1,13 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 490 B

View file

@ -1,13 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 495 B

View file

@ -1,140 +0,0 @@
import { Route, Switch, Redirect } from "wouter";
import { HelmetProvider } from "react-helmet-async";
import { Toaster } from "@/components/ui/toast";
import { useAuth } from "@/_core/hooks/useAuth";
import Layout from "@/components/Layout";
import { Loader2 } from "lucide-react";
import Home from "@/pages/Home";
import Login from "@/pages/Login";
import ForgotPassword from "@/pages/ForgotPassword";
import ResetPassword from "@/pages/ResetPassword";
import Dashboard from "@/pages/Dashboard";
import DoctorClinics from "@/pages/DoctorClinics";
import QueueManagement from "@/pages/QueueManagement";
import Analytics from "@/pages/Analytics";
import PatientQueue from "@/pages/PatientQueue";
import QrJoin from "@/pages/QrJoin";
import DisplayScreen from "@/pages/DisplayScreen";
import SubscriptionPage from "@/pages/SubscriptionPage";
import PrintTicket from "@/pages/PrintTicket";
import Onboarding from "@/pages/Onboarding";
import Help from "@/pages/Help";
import QrPoster from "@/pages/QrPoster";
import ClinicSettings from "@/pages/ClinicSettings";
import ConsultationHistory from "@/pages/ConsultationHistory";
import WhatsAppSetup from "@/pages/WhatsAppSetup";
import SubscriptionBlocked from "@/pages/SubscriptionBlocked";
import SubscriptionSuccess from "@/pages/SubscriptionSuccess";
import SubscriptionCancel from "@/pages/SubscriptionCancel";
import AdminPanel from "@/pages/AdminPanel";
import AdminSettings from "@/pages/AdminSettings";
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, loading } = useAuth();
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="w-8 h-8 text-emerald-500 animate-spin" />
</div>
);
}
if (!isAuthenticated) return <Redirect to="/login" />;
return <Layout>{children}</Layout>;
}
export default function App() {
return (
<HelmetProvider>
<Toaster />
<Switch>
{/* Public marketing & auth */}
<Route path="/" component={Home} />
<Route path="/login" component={Login} />
<Route path="/forgot-password" component={ForgotPassword} />
<Route path="/reset-password/:token" component={ResetPassword} />
<Route path="/help" component={Help} />
{/* Public patient/display routes */}
<Route path="/queue/:token" component={PatientQueue} />
<Route path="/display/:clinicId" component={DisplayScreen} />
<Route path="/ticket/:entryId" component={PrintTicket} />
<Route path="/q/:clinicId/:qrToken" component={QrJoin} />
{/* Authenticated routes (wrapped in Layout) */}
<Route path="/dashboard">
<ProtectedRoute><Dashboard /></ProtectedRoute>
</Route>
<Route path="/dashboard/clinics">
<ProtectedRoute><DoctorClinics /></ProtectedRoute>
</Route>
<Route path="/dashboard/queue/:clinicId">
<ProtectedRoute><QueueManagement /></ProtectedRoute>
</Route>
<Route path="/dashboard/analytics">
<ProtectedRoute><Analytics /></ProtectedRoute>
</Route>
<Route path="/dashboard/subscription">
<ProtectedRoute><SubscriptionPage /></ProtectedRoute>
</Route>
<Route path="/dashboard/poster/:clinicId">
<ProtectedRoute><QrPoster /></ProtectedRoute>
</Route>
<Route path="/onboarding">
<ProtectedRoute><Onboarding /></ProtectedRoute>
</Route>
<Route path="/dashboard/clinic/:clinicId">
<ProtectedRoute><ClinicSettings /></ProtectedRoute>
</Route>
<Route path="/dashboard/history/:clinicId">
<ProtectedRoute><ConsultationHistory /></ProtectedRoute>
</Route>
<Route path="/dashboard/whatsapp">
<ProtectedRoute><WhatsAppSetup /></ProtectedRoute>
</Route>
<Route path="/dashboard/subscription-blocked">
<ProtectedRoute><SubscriptionBlocked /></ProtectedRoute>
</Route>
<Route path="/subscription/success">
<ProtectedRoute><SubscriptionSuccess /></ProtectedRoute>
</Route>
<Route path="/subscription/cancel">
<ProtectedRoute><SubscriptionCancel /></ProtectedRoute>
</Route>
{/* Admin */}
<Route path="/admin/settings">
<ProtectedRoute><AdminSettings /></ProtectedRoute>
</Route>
<Route path="/admin">
<ProtectedRoute><AdminPanel /></ProtectedRoute>
</Route>
{/* Fallback */}
<Route>
<NotFound />
</Route>
</Switch>
</HelmetProvider>
);
}
function NotFound() {
return (
<div className="min-h-screen flex items-center justify-center p-4">
<div className="glass-card rounded-3xl p-10 text-center max-w-md">
<div className="text-7xl font-black gradient-text mb-3">404</div>
<p className="text-slate-600 mb-6">Cette page n'existe pas ou a é déplacée.</p>
<a
href="/"
className="inline-block px-5 py-3 rounded-xl bg-teal-600 text-white font-semibold hover:bg-teal-700 transition-colors"
>
Retour à l'accueil
</a>
</div>
</div>
);
}

View file

@ -1,66 +0,0 @@
import { useCallback } from "react";
import { useLocation } from "wouter";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { trpc } from "@/lib/trpc";
export function useAuth() {
const [, navigate] = useLocation();
const queryClient = useQueryClient();
const meQuery = trpc.auth.me.useQuery(undefined, {
retry: false,
staleTime: 60_000,
});
const loginMutation = trpc.auth.login.useMutation({
onSuccess: async () => {
await meQuery.refetch();
await queryClient.invalidateQueries();
toast.success("Connecté avec succès");
},
onError: (e) => toast.error(e.message),
});
const registerMutation = trpc.auth.register.useMutation({
onSuccess: async () => {
await meQuery.refetch();
await queryClient.invalidateQueries();
toast.success("Compte créé — bienvenue sur QueueMed !");
},
onError: (e) => toast.error(e.message),
});
const logoutMutation = trpc.auth.logout.useMutation({
onSuccess: async () => {
await queryClient.clear();
await meQuery.refetch();
navigate("/");
toast.success("Déconnecté");
},
});
const login = useCallback(
(email: string, password: string) => loginMutation.mutateAsync({ email, password }),
[loginMutation]
);
const register = useCallback(
(email: string, password: string, name?: string) =>
registerMutation.mutateAsync({ email, password, name }),
[registerMutation]
);
const logout = useCallback(() => logoutMutation.mutate(), [logoutMutation]);
return {
user: meQuery.data ?? null,
isAuthenticated: !!meQuery.data,
loading: meQuery.isLoading,
login,
register,
logout,
isLoggingIn: loginMutation.isPending,
isRegistering: registerMutation.isPending,
};
}

View file

@ -1,291 +0,0 @@
import { useState, useMemo } from "react";
import { trpc } from "@/lib/trpc";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import {
Globe,
Search,
CheckCircle,
Circle,
Loader2,
ChevronDown,
ChevronUp,
ToggleLeft,
ToggleRight,
} from "lucide-react";
type CountryCode = {
id: number;
code: string;
dialCode: string;
nameFr: string;
flag: string;
enabled: boolean;
sortOrder: number;
};
// Groupes régionaux pour organiser l'affichage
const REGION_GROUPS: { label: string; codes: string[] }[] = [
{
label: "France & DOM-TOM",
codes: ["FR", "GP", "MQ", "RE", "GF", "PM", "YT", "NC", "PF", "WF"],
},
{
label: "Europe",
codes: ["BE", "CH", "LU", "MC", "DE", "ES", "IT", "PT", "GB", "NL", "PL", "SE", "NO", "DK", "FI", "AT", "GR", "RO", "HU", "CZ", "TR"],
},
{
label: "Afrique francophone",
codes: ["MA", "DZ", "TN", "SN", "CI", "CM", "CD", "CG", "MG", "ML", "BF", "NE", "TD", "GN", "BJ", "TG", "MR", "GA", "GQ", "CF", "KM", "DJ", "MU", "SC", "EG"],
},
{
label: "Amériques",
codes: ["US", "CA", "MX", "BR", "AR", "CO", "CL", "PE", "VE", "EC", "BO", "PY", "UY", "HT"],
},
{
label: "Asie & Océanie",
codes: ["IN", "CN", "JP", "AU", "LB"],
},
];
export default function CountryCodeManager() {
const utils = trpc.useUtils();
const [search, setSearch] = useState("");
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set(["France & DOM-TOM"]));
const [pendingToggles, setPendingToggles] = useState<Set<string>>(new Set());
const { data: allCodes = [], isLoading } = trpc.whatsapp.listAllCountryCodes.useQuery();
const toggleMut = trpc.whatsapp.toggleCountryCode.useMutation({
onMutate: ({ code }) => {
setPendingToggles((prev) => new Set(prev).add(code));
},
onSuccess: (data) => {
setPendingToggles((prev) => {
const next = new Set(prev);
next.delete(data.code);
return next;
});
utils.whatsapp.listAllCountryCodes.invalidate();
utils.whatsapp.listCountryCodes.invalidate();
toast.success(data.enabled ? "Indicatif activé" : "Indicatif désactivé");
},
onError: (err, { code }) => {
setPendingToggles((prev) => {
const next = new Set(prev);
next.delete(code);
return next;
});
toast.error(err.message);
},
});
const bulkMut = trpc.whatsapp.bulkToggleCountryCodes.useMutation({
onSuccess: (data, vars) => {
utils.whatsapp.listAllCountryCodes.invalidate();
utils.whatsapp.listCountryCodes.invalidate();
toast.success(`${data.count} indicatifs ${vars.enabled ? "activés" : "désactivés"}`);
},
onError: (err) => toast.error(err.message),
});
// Map code → entry for quick lookup
const codeMap = useMemo(() => {
const m = new Map<string, CountryCode>();
allCodes.forEach((c) => m.set(c.code, c));
return m;
}, [allCodes]);
// Filtered list for search mode
const searchResults = useMemo(() => {
if (!search.trim()) return [];
const q = search.toLowerCase();
return allCodes.filter(
(c) =>
c.nameFr.toLowerCase().includes(q) ||
c.code.toLowerCase().includes(q) ||
c.dialCode.includes(q)
);
}, [allCodes, search]);
const toggleGroup = (label: string) => {
setExpandedGroups((prev) => {
const next = new Set(prev);
if (next.has(label)) next.delete(label);
else next.add(label);
return next;
});
};
const handleToggle = (code: string, currentEnabled: boolean) => {
toggleMut.mutate({ code, enabled: !currentEnabled });
};
const handleBulkGroup = (codes: string[], enabled: boolean) => {
const validCodes = codes.filter((c) => codeMap.has(c));
bulkMut.mutate({ codes: validCodes, enabled });
};
const enabledCount = allCodes.filter((c) => c.enabled).length;
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 text-primary animate-spin" />
</div>
);
}
const renderCountryRow = (c: CountryCode) => {
const isPending = pendingToggles.has(c.code);
return (
<div
key={c.code}
className={`flex items-center justify-between px-3 py-2 rounded-lg transition-colors ${
c.enabled ? "bg-emerald-500/5 border border-emerald-500/20" : "hover:bg-muted/30"
}`}
>
<div className="flex items-center gap-2.5 min-w-0">
<span className="text-xl shrink-0">{c.flag}</span>
<div className="min-w-0">
<span className="text-sm font-medium text-foreground truncate block">{c.nameFr}</span>
<span className="text-xs text-muted-foreground">+{c.dialCode} · {c.code}</span>
</div>
</div>
<button
onClick={() => handleToggle(c.code, c.enabled)}
disabled={isPending}
className={`shrink-0 ml-3 transition-colors ${
c.enabled ? "text-emerald-400 hover:text-emerald-300" : "text-muted-foreground hover:text-foreground"
}`}
title={c.enabled ? "Désactiver" : "Activer"}
>
{isPending ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : c.enabled ? (
<ToggleRight className="w-6 h-6" />
) : (
<ToggleLeft className="w-6 h-6" />
)}
</button>
</div>
);
};
return (
<Card className="border-border/50 bg-card/50">
<CardHeader>
<div className="flex items-center justify-between flex-wrap gap-2">
<div>
<CardTitle className="text-base flex items-center gap-2">
<Globe className="w-4 h-4" />
Indicatifs pays disponibles
</CardTitle>
<CardDescription className="mt-1">
Choisissez les pays affichés aux patients dans le formulaire WhatsApp.{" "}
<Badge className="bg-emerald-500/20 text-emerald-400 border-emerald-500/30 text-xs">
{enabledCount} activé{enabledCount > 1 ? "s" : ""}
</Badge>
</CardDescription>
</div>
</div>
{/* Search */}
<div className="relative mt-3">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Rechercher un pays, un indicatif…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9 bg-background/50"
/>
</div>
</CardHeader>
<CardContent className="space-y-3">
{/* Search results */}
{search.trim() ? (
<div className="space-y-1">
{searchResults.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">Aucun résultat pour « {search} »</p>
) : (
searchResults.map((c) => renderCountryRow(c))
)}
</div>
) : (
/* Grouped view */
REGION_GROUPS.map((group) => {
const groupCodes = group.codes
.map((code) => codeMap.get(code))
.filter(Boolean) as CountryCode[];
if (groupCodes.length === 0) return null;
const isExpanded = expandedGroups.has(group.label);
const groupEnabled = groupCodes.filter((c) => c.enabled).length;
return (
<div key={group.label} className="border border-border/40 rounded-xl overflow-hidden">
{/* Group header */}
<div
className="flex items-center justify-between px-4 py-3 bg-muted/20 cursor-pointer hover:bg-muted/30 transition-colors"
onClick={() => toggleGroup(group.label)}
>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-foreground">{group.label}</span>
<Badge variant="outline" className="text-xs px-1.5 py-0">
{groupEnabled}/{groupCodes.length}
</Badge>
</div>
<div className="flex items-center gap-2">
{/* Bulk actions */}
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs text-emerald-400 hover:text-emerald-300 hover:bg-emerald-500/10"
onClick={(e) => {
e.stopPropagation();
handleBulkGroup(group.codes, true);
}}
disabled={bulkMut.isPending}
>
<CheckCircle className="w-3 h-3 mr-1" />
Tout activer
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
handleBulkGroup(group.codes, false);
}}
disabled={bulkMut.isPending}
>
<Circle className="w-3 h-3 mr-1" />
Tout désactiver
</Button>
{isExpanded ? (
<ChevronUp className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
)}
</div>
</div>
{/* Group rows */}
{isExpanded && (
<div className="p-2 space-y-1">
{groupCodes.map((c) => renderCountryRow(c))}
</div>
)}
</div>
);
})
)}
</CardContent>
</Card>
);
}

View file

@ -1,210 +0,0 @@
import { useState } from "react";
import { Link, useLocation } from "wouter";
import { useTranslation } from "react-i18next";
import {
LayoutDashboard, Building2, BarChart3, CreditCard,
HelpCircle, LogOut, Stethoscope, Menu, X, Shield, Settings,
} from "lucide-react";
import { useAuth } from "@/_core/hooks/useAuth";
import { cn } from "@/lib/utils";
function LanguageSwitcher({ className = "" }: { className?: string }) {
const { i18n } = useTranslation();
const current = i18n.resolvedLanguage ?? i18n.language ?? "fr";
const change = (lng: "fr" | "en") => i18n.changeLanguage(lng);
return (
<div className={cn("inline-flex rounded-lg overflow-hidden border border-emerald-200 bg-white text-xs font-semibold", className)}>
<button
onClick={() => change("fr")}
className={cn(
"px-2.5 py-1 transition-colors",
current.startsWith("fr") ? "bg-emerald-500 text-white" : "text-slate-600 hover:bg-emerald-50"
)}
aria-pressed={current.startsWith("fr")}
>
FR
</button>
<button
onClick={() => change("en")}
className={cn(
"px-2.5 py-1 transition-colors",
current.startsWith("en") ? "bg-emerald-500 text-white" : "text-slate-600 hover:bg-emerald-50"
)}
aria-pressed={current.startsWith("en")}
>
EN
</button>
</div>
);
}
export default function Layout({ children }: { children: React.ReactNode }) {
const { t } = useTranslation();
const [location] = useLocation();
const { user, logout } = useAuth();
const [mobileOpen, setMobileOpen] = useState(false);
const NAV = [
{ href: "/dashboard", label: t("nav.dashboard"), icon: LayoutDashboard },
{ href: "/dashboard/clinics", label: t("nav.clinics"), icon: Building2 },
{ href: "/dashboard/analytics", label: t("nav.analytics"), icon: BarChart3 },
{ href: "/dashboard/subscription", label: t("nav.subscription"), icon: CreditCard },
{ href: "/help", label: t("nav.help"), icon: HelpCircle },
...(user?.role === "admin"
? [{ href: "/admin", label: "Admin", icon: Shield }, { href: "/admin/settings", label: "Param\u00e8tres", icon: Settings }]
: []),
];
const isActive = (href: string) =>
href === "/dashboard"
? location === "/dashboard"
: location === href || location.startsWith(href + "/");
return (
<div className="min-h-screen flex bg-gradient-to-br from-emerald-50/40 via-white to-cyan-50/40">
{/* ─── Sidebar (desktop) ──────────────────────────────────────────── */}
<aside className="hidden lg:flex lg:w-64 flex-shrink-0 flex-col border-r border-emerald-100/60 bg-white/70 backdrop-blur-xl">
<Link href="/dashboard">
<a className="flex items-center gap-3 px-6 h-16 border-b border-emerald-100/60">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-emerald-500 to-cyan-500 flex items-center justify-center shadow-md">
<Stethoscope className="w-5 h-5 text-white" />
</div>
<div className="font-bold text-lg gradient-text tracking-tight">QueueMed</div>
</a>
</Link>
<nav className="flex-1 px-3 py-6 space-y-1">
{NAV.map((item) => {
const Icon = item.icon;
const active = isActive(item.href);
return (
<Link key={item.href} href={item.href}>
<a
className={cn(
"flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all",
active
? "bg-gradient-to-r from-emerald-500 to-cyan-500 text-white shadow-md"
: "text-slate-600 hover:bg-emerald-50 hover:text-emerald-700"
)}
>
<Icon className="w-4 h-4" />
{item.label}
</a>
</Link>
);
})}
</nav>
{/* User card */}
<div className="p-3 border-t border-emerald-100/60 space-y-3">
<div className="flex items-center justify-between px-1">
<span className="text-xs uppercase tracking-wider text-slate-500 font-semibold">
{t("common.language")}
</span>
<LanguageSwitcher />
</div>
<div className="px-3 py-3 rounded-xl bg-gradient-to-br from-emerald-50 to-cyan-50 border border-emerald-100/80">
<div className="text-xs uppercase tracking-wider text-emerald-700 font-semibold mb-1">
{t("nav.connected")}
</div>
<div className="font-semibold text-slate-900 text-sm truncate">
{user?.name ?? user?.email ?? "—"}
</div>
{user?.email && user?.name && (
<div className="text-xs text-slate-500 truncate">{user.email}</div>
)}
<button
onClick={() => logout()}
className="mt-3 w-full flex items-center justify-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium text-slate-600 bg-white hover:bg-red-50 hover:text-red-600 border border-slate-200 transition-colors"
>
<LogOut className="w-3.5 h-3.5" /> {t("nav.logout")}
</button>
</div>
</div>
</aside>
{/* ─── Main column ───────────────────────────────────────────────── */}
<div className="flex-1 flex flex-col min-w-0">
{/* Top bar (mobile) */}
<header className="lg:hidden sticky top-0 z-40 h-16 border-b border-emerald-100/60 bg-white/80 backdrop-blur-xl flex items-center justify-between px-4">
<Link href="/dashboard">
<a className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-emerald-500 to-cyan-500 flex items-center justify-center shadow-md">
<Stethoscope className="w-4 h-4 text-white" />
</div>
<span className="font-bold gradient-text">QueueMed</span>
</a>
</Link>
<div className="flex items-center gap-3">
<LanguageSwitcher />
<button
onClick={() => setMobileOpen(true)}
className="p-2 rounded-lg text-slate-600 hover:bg-emerald-50"
aria-label="Menu"
>
<Menu className="w-5 h-5" />
</button>
</div>
</header>
{/* Mobile drawer */}
{mobileOpen && (
<div className="lg:hidden fixed inset-0 z-50">
<div
className="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
onClick={() => setMobileOpen(false)}
/>
<div className="absolute left-0 top-0 bottom-0 w-72 bg-white shadow-2xl flex flex-col">
<div className="flex items-center justify-between h-16 px-4 border-b border-emerald-100/60">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-emerald-500 to-cyan-500 flex items-center justify-center">
<Stethoscope className="w-4 h-4 text-white" />
</div>
<span className="font-bold gradient-text">QueueMed</span>
</div>
<button onClick={() => setMobileOpen(false)} className="p-2 rounded-lg text-slate-600 hover:bg-slate-100">
<X className="w-5 h-5" />
</button>
</div>
<nav className="flex-1 px-3 py-4 space-y-1">
{NAV.map((item) => {
const Icon = item.icon;
const active = isActive(item.href);
return (
<Link key={item.href} href={item.href}>
<a
onClick={() => setMobileOpen(false)}
className={cn(
"flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all",
active
? "bg-gradient-to-r from-emerald-500 to-cyan-500 text-white shadow-md"
: "text-slate-600 hover:bg-emerald-50 hover:text-emerald-700"
)}
>
<Icon className="w-4 h-4" />
{item.label}
</a>
</Link>
);
})}
</nav>
<div className="p-3 border-t border-emerald-100/60">
<div className="px-3 py-2 mb-2 text-sm">
<div className="font-semibold text-slate-900 truncate">{user?.name ?? user?.email ?? "—"}</div>
</div>
<button
onClick={() => { setMobileOpen(false); logout(); }}
className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-slate-600 hover:bg-red-50 hover:text-red-600 border border-slate-200"
>
<LogOut className="w-4 h-4" /> Déconnexion
</button>
</div>
</div>
</div>
)}
<main className="flex-1 overflow-y-auto">{children}</main>
</div>
</div>
);
}

View file

@ -1,242 +0,0 @@
/**
* PhoneDialCodePicker
* Sélecteur d'indicatif pays + champ numéro local pour les notifications WhatsApp.
* - Affiche uniquement les pays activés par l'admin (trpc.whatsapp.listCountryCodes)
* - Valide la longueur et le format du numéro selon les règles par pays
* - Expose onValidationChange pour que le parent puisse bloquer la soumission
*/
import { useState, useRef, useEffect, useMemo } from "react";
import { trpc } from "@/lib/trpc";
import { Loader2, ChevronDown, Search, AlertCircle, CheckCircle2 } from "lucide-react";
import {
validateLocalPhone,
getPhonePlaceholder,
getPhoneHint,
} from "@shared/phoneValidation";
type CountryCode = {
id: number;
code: string;
dialCode: string;
nameFr: string;
flag: string;
enabled: boolean;
sortOrder: number;
};
type Props = {
/** Numéro complet au format international sans + (ex: "33612345678") */
value: string;
onChange: (fullNumber: string) => void;
/** Appelé avec null si valide, message d'erreur sinon */
onValidationChange?: (error: string | null) => void;
className?: string;
};
export default function PhoneDialCodePicker({
value,
onChange,
onValidationChange,
className = "",
}: Props) {
const { data: countries = [], isLoading } = trpc.whatsapp.listCountryCodes.useQuery();
const [selectedCode, setSelectedCode] = useState<CountryCode | null>(null);
const [localNumber, setLocalNumber] = useState("");
const [touched, setTouched] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false);
const [search, setSearch] = useState("");
const dropdownRef = useRef<HTMLDivElement>(null);
// Auto-select first country once loaded
useEffect(() => {
if (countries.length > 0 && !selectedCode) {
setSelectedCode(countries[0]);
}
}, [countries, selectedCode]);
// Sync outgoing value + validation
useEffect(() => {
if (!selectedCode) return;
const cleaned = localNumber.replace(/\D/g, "").replace(/^0+/, "");
const full = cleaned ? `${selectedCode.dialCode}${cleaned}` : "";
onChange(full);
if (onValidationChange) {
if (!cleaned) {
// Empty = not yet filled, no error shown until touched
onValidationChange(touched ? "Veuillez saisir votre numéro." : null);
} else {
const err = validateLocalPhone(selectedCode.dialCode, cleaned);
onValidationChange(err);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedCode, localNumber, touched]);
// Close dropdown on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setDropdownOpen(false);
setSearch("");
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
const filtered = useMemo(() => {
if (!search.trim()) return countries;
const q = search.toLowerCase();
return countries.filter(
(c) =>
c.nameFr.toLowerCase().includes(q) ||
c.code.toLowerCase().includes(q) ||
c.dialCode.includes(q)
);
}, [countries, search]);
// Compute validation state for display
const cleaned = localNumber.replace(/\D/g, "").replace(/^0+/, "");
const validationError = selectedCode && cleaned
? validateLocalPhone(selectedCode.dialCode, cleaned)
: null;
const isValid = cleaned.length > 0 && validationError === null;
const showError = touched && cleaned.length > 0 && validationError !== null;
const placeholder = selectedCode ? getPhonePlaceholder(selectedCode.dialCode) : "123456789";
const hint = selectedCode ? getPhoneHint(selectedCode.dialCode) : "";
if (isLoading) {
return (
<div className={`flex items-center gap-2 ${className}`}>
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">Chargement</span>
</div>
);
}
if (countries.length === 0) {
return (
<input
type="tel"
value={localNumber}
onChange={(e) => setLocalNumber(e.target.value)}
placeholder="Numéro international (ex: 33612345678)"
className={`w-full bg-background/50 border border-emerald-500/40 rounded-xl px-4 py-2.5 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-emerald-500/70 ${className}`}
/>
);
}
return (
<div className={`space-y-1.5 ${className}`}>
<div className="relative flex gap-2" ref={dropdownRef}>
{/* Dial code selector */}
<button
type="button"
onClick={() => setDropdownOpen((o) => !o)}
className={`flex items-center gap-1.5 px-3 py-2.5 bg-background/50 border rounded-xl text-sm text-foreground hover:border-emerald-500/70 transition-colors shrink-0 min-w-[96px] ${
showError ? "border-red-500/60" : "border-emerald-500/40"
}`}
>
{selectedCode ? (
<>
<span className="text-base">{selectedCode.flag}</span>
<span className="font-medium">+{selectedCode.dialCode}</span>
</>
) : (
<span className="text-muted-foreground">Pays</span>
)}
<ChevronDown className={`w-3.5 h-3.5 text-muted-foreground ml-auto transition-transform ${dropdownOpen ? "rotate-180" : ""}`} />
</button>
{/* Local number input */}
<div className="relative flex-1">
<input
type="tel"
value={localNumber}
onChange={(e) => setLocalNumber(e.target.value)}
onBlur={() => setTouched(true)}
placeholder={placeholder}
className={`w-full bg-background/50 border rounded-xl px-4 py-2.5 pr-9 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none transition-colors ${
showError
? "border-red-500/60 focus:border-red-500/80"
: isValid
? "border-emerald-500/60 focus:border-emerald-500/80"
: "border-emerald-500/40 focus:border-emerald-500/70"
}`}
/>
{/* Validation icon */}
{cleaned.length > 0 && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
{isValid ? (
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
) : (
<AlertCircle className="w-4 h-4 text-red-400" />
)}
</div>
)}
</div>
{/* Dropdown */}
{dropdownOpen && (
<div className="absolute top-full left-0 mt-1 z-50 w-72 bg-popover border border-border rounded-xl shadow-xl overflow-hidden">
{/* Search */}
<div className="p-2 border-b border-border/50">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<input
autoFocus
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Rechercher…"
className="w-full pl-8 pr-3 py-1.5 text-sm bg-background/50 border border-border/50 rounded-lg text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary/60"
/>
</div>
</div>
{/* Country list */}
<div className="max-h-56 overflow-y-auto">
{filtered.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">Aucun résultat</p>
) : (
filtered.map((c) => (
<button
key={c.code}
type="button"
onClick={() => {
setSelectedCode(c);
setDropdownOpen(false);
setSearch("");
setTouched(false); // reset validation on country change
setLocalNumber(""); // reset number on country change
}}
className={`w-full flex items-center gap-3 px-3 py-2.5 text-left hover:bg-accent transition-colors ${
selectedCode?.code === c.code ? "bg-emerald-500/10" : ""
}`}
>
<span className="text-xl shrink-0">{c.flag}</span>
<span className="text-sm text-foreground flex-1 truncate">{c.nameFr}</span>
<span className="text-sm text-muted-foreground shrink-0">+{c.dialCode}</span>
</button>
))
)}
</div>
</div>
)}
</div>
{/* Validation message or hint */}
{showError ? (
<p className="text-xs text-red-400 flex items-center gap-1">
<AlertCircle className="w-3 h-3 shrink-0" />
{validationError}
</p>
) : (
<p className="text-xs text-muted-foreground">{hint}</p>
)}
</div>
);
}

View file

@ -1,233 +0,0 @@
import { useState } from "react";
import { Loader2, Plus, Trash2, UserPlus, Users } from "lucide-react";
import { trpc } from "@/lib/trpc";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
const PRESET_COLORS = [
"#10b981", // emerald
"#06b6d4", // cyan
"#0d9488", // teal
"#8b5cf6", // violet
"#f97316", // orange
"#ec4899", // pink
"#3b82f6", // blue
"#eab308", // yellow
];
export default function PractitionerManager({ clinicId }: { clinicId: number }) {
const utils = trpc.useUtils();
const membersQuery = trpc.clinic.listMembers.useQuery(
{ clinicId },
{ enabled: clinicId > 0 }
);
const [adding, setAdding] = useState(false);
const [email, setEmail] = useState("");
const [displayName, setDisplayName] = useState("");
const [color, setColor] = useState(PRESET_COLORS[0]);
const addMember = trpc.clinic.addMember.useMutation({
onSuccess: () => {
toast.success("Praticien ajouté");
utils.clinic.listMembers.invalidate({ clinicId });
utils.clinic.listMembersPublic.invalidate({ clinicId });
setEmail("");
setDisplayName("");
setColor(PRESET_COLORS[0]);
setAdding(false);
},
onError: (e) => toast.error(e.message),
});
const removeMember = trpc.clinic.removeMember.useMutation({
onSuccess: () => {
toast.success("Praticien retiré");
utils.clinic.listMembers.invalidate({ clinicId });
utils.clinic.listMembersPublic.invalidate({ clinicId });
},
onError: (e) => toast.error(e.message),
});
const updateMember = trpc.clinic.updateMember.useMutation({
onSuccess: () => {
utils.clinic.listMembers.invalidate({ clinicId });
utils.clinic.listMembersPublic.invalidate({ clinicId });
},
onError: (e) => toast.error(e.message),
});
const handleAdd = () => {
if (!email.trim()) {
toast.error("Email requis");
return;
}
addMember.mutate({
clinicId,
email: email.trim(),
displayName: displayName.trim() || undefined,
color,
});
};
const members = membersQuery.data ?? [];
return (
<div className="glass-card rounded-2xl p-5 space-y-4">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-emerald-100 flex items-center justify-center">
<Users className="w-4 h-4 text-emerald-700" />
</div>
<div>
<h3 className="font-semibold text-slate-900">Praticiens du cabinet</h3>
<p className="text-xs text-slate-500">
Multi-médecins · couleur d'identification
</p>
</div>
</div>
<Button
size="sm"
variant={adding ? "ghost" : "outline"}
onClick={() => setAdding((v) => !v)}
>
{adding ? (
"Annuler"
) : (
<>
<Plus className="w-3.5 h-3.5 mr-1" /> Ajouter
</>
)}
</Button>
</div>
{adding && (
<div className="rounded-xl border border-emerald-200/70 bg-emerald-50/40 p-4 space-y-3">
<div>
<label className="block text-xs font-bold text-slate-600 mb-1">
Email du praticien
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="docteur@example.com"
className="w-full px-3 py-2 rounded-lg border border-slate-200 bg-white text-sm focus:outline-none focus:border-emerald-400"
/>
<p className="text-xs text-slate-500 mt-1">
Le praticien doit déjà avoir un compte QueueMed.
</p>
</div>
<div>
<label className="block text-xs font-bold text-slate-600 mb-1">
Nom affiché (optionnel)
</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Dr. Martin"
className="w-full px-3 py-2 rounded-lg border border-slate-200 bg-white text-sm focus:outline-none focus:border-emerald-400"
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-600 mb-2">
Couleur d'identification
</label>
<div className="flex flex-wrap gap-2">
{PRESET_COLORS.map((c) => (
<button
key={c}
onClick={() => setColor(c)}
className={`w-8 h-8 rounded-lg border-2 transition-all ${
color === c
? "border-slate-900 scale-110 shadow-md"
: "border-white"
}`}
style={{ background: c }}
aria-label={`Couleur ${c}`}
/>
))}
<input
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
className="w-8 h-8 rounded-lg cursor-pointer"
aria-label="Couleur personnalisée"
/>
</div>
</div>
<Button
variant="gradient"
size="sm"
className="w-full"
onClick={handleAdd}
disabled={addMember.isPending}
>
{addMember.isPending ? (
<Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" />
) : (
<UserPlus className="w-3.5 h-3.5 mr-1" />
)}
Ajouter le praticien
</Button>
</div>
)}
{membersQuery.isLoading ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="w-5 h-5 text-emerald-500 animate-spin" />
</div>
) : members.length === 0 ? (
<p className="text-center text-sm text-slate-500 py-6">
Aucun praticien associé. Ajoutez-en un pour activer l'attribution.
</p>
) : (
<ul className="divide-y divide-slate-100">
{members.map((m) => (
<li
key={m.id}
className="flex items-center gap-3 py-3 first:pt-0 last:pb-0"
>
<input
type="color"
value={m.color ?? "#10b981"}
onChange={(e) =>
updateMember.mutate({
clinicId,
memberId: m.id,
color: e.target.value,
})
}
className="w-9 h-9 rounded-lg border border-slate-200 cursor-pointer flex-shrink-0"
aria-label="Modifier la couleur"
/>
<div className="flex-1 min-w-0">
<div className="font-semibold text-slate-900 text-sm truncate">
{m.displayName ?? m.name ?? m.email ?? "—"}
</div>
<div className="text-xs text-slate-500 truncate">
{m.email ?? "—"} · {m.role}
</div>
</div>
<button
onClick={() =>
removeMember.mutate({ clinicId, memberId: m.id })
}
disabled={removeMember.isPending || m.role === "owner"}
className="w-8 h-8 rounded-lg flex items-center justify-center text-red-600 hover:bg-red-50 disabled:opacity-30"
title={
m.role === "owner"
? "Le propriétaire ne peut être retiré"
: "Retirer ce praticien"
}
>
<Trash2 className="w-4 h-4" />
</button>
</li>
))}
</ul>
)}
</div>
);
}

View file

@ -1,352 +0,0 @@
import { useState, useRef, useCallback, useMemo, useEffect } from "react";
import { trpc } from "@/lib/trpc";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import {
DEFAULT_TEMPLATES,
TEMPLATE_VARIABLES,
TEMPLATE_LABELS,
SAMPLE_CONTEXT,
interpolateTemplate,
type TemplateType,
} from "../../../shared/whatsappTemplates";
import { Save, RotateCcw, Eye, EyeOff, MessageSquare, Sparkles } from "lucide-react";
interface WhatsAppTemplateEditorProps {
clinicId: number;
}
export default function WhatsAppTemplateEditor({ clinicId }: WhatsAppTemplateEditorProps) {
const { data: templates, isLoading, refetch } = trpc.clinicSettings.getTemplates.useQuery({ clinicId });
const updateMutation = trpc.clinicSettings.updateTemplates.useMutation({
onSuccess: () => {
toast.success("Templates sauvegardés", { description: "Les modèles de messages ont été mis à jour." });
refetch();
},
onError: (err) => {
toast.error("Erreur", { description: err.message });
},
});
const templateTypes: TemplateType[] = ["joined", "soon", "called", "withdrawn"];
// Local state for each template
const [localTemplates, setLocalTemplates] = useState<Record<TemplateType, string>>({
joined: "",
soon: "",
called: "",
withdrawn: "",
});
// Track which templates have been modified
const [modified, setModified] = useState<Record<TemplateType, boolean>>({
joined: false,
soon: false,
called: false,
withdrawn: false,
});
// Preview toggle per template
const [showPreview, setShowPreview] = useState<Record<TemplateType, boolean>>({
joined: false,
soon: false,
called: false,
withdrawn: false,
});
// Active tab
const [activeTab, setActiveTab] = useState<TemplateType>("joined");
// Refs for textareas
const textareaRefs = useRef<Record<TemplateType, HTMLTextAreaElement | null>>({
joined: null,
soon: null,
called: null,
withdrawn: null,
});
// Initialize local state from server data
useEffect(() => {
if (templates) {
setLocalTemplates({
joined: templates.joined ?? DEFAULT_TEMPLATES.joined,
soon: templates.soon ?? DEFAULT_TEMPLATES.soon,
called: templates.called ?? DEFAULT_TEMPLATES.called,
withdrawn: templates.withdrawn ?? DEFAULT_TEMPLATES.withdrawn,
});
setModified({ joined: false, soon: false, called: false, withdrawn: false });
}
}, [templates]);
const handleChange = useCallback((type: TemplateType, value: string) => {
setLocalTemplates((prev) => ({ ...prev, [type]: value }));
setModified((prev) => ({ ...prev, [type]: true }));
}, []);
const insertVariable = useCallback((type: TemplateType, variable: string) => {
const textarea = textareaRefs.current[type];
if (textarea) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const current = localTemplates[type];
const newValue = current.substring(0, start) + variable + current.substring(end);
setLocalTemplates((prev) => ({ ...prev, [type]: newValue }));
setModified((prev) => ({ ...prev, [type]: true }));
// Restore cursor position after variable
setTimeout(() => {
textarea.focus();
textarea.selectionStart = start + variable.length;
textarea.selectionEnd = start + variable.length;
}, 0);
} else {
// Fallback: append at end
setLocalTemplates((prev) => ({ ...prev, [type]: prev[type] + variable }));
setModified((prev) => ({ ...prev, [type]: true }));
}
}, [localTemplates]);
const resetToDefault = useCallback((type: TemplateType) => {
setLocalTemplates((prev) => ({ ...prev, [type]: DEFAULT_TEMPLATES[type] }));
setModified((prev) => ({ ...prev, [type]: true }));
}, []);
const togglePreview = useCallback((type: TemplateType) => {
setShowPreview((prev) => ({ ...prev, [type]: !prev[type] }));
}, []);
const handleSave = useCallback(() => {
const payload: Record<string, string | null> = { clinicId: clinicId as any };
for (const type of templateTypes) {
if (modified[type]) {
// If it's the same as default, save null (use default)
if (localTemplates[type] === DEFAULT_TEMPLATES[type]) {
(payload as any)[type] = null;
} else {
(payload as any)[type] = localTemplates[type];
}
}
}
updateMutation.mutate(payload as any);
setModified({ joined: false, soon: false, called: false, withdrawn: false });
}, [localTemplates, modified, clinicId, updateMutation]);
const hasAnyModification = Object.values(modified).some(Boolean);
// Preview rendering with highlighted variables
const renderPreview = useCallback((template: string) => {
const rendered = interpolateTemplate(template, SAMPLE_CONTEXT);
return rendered;
}, []);
// Highlight variables in template text
const highlightVariables = useCallback((text: string) => {
const parts = text.split(/(\{\{[a-z]+\}\})/g);
return parts.map((part, i) => {
if (part.match(/^\{\{[a-z]+\}\}$/)) {
return (
<span key={i} className="bg-teal-500/20 text-teal-400 rounded px-1 font-mono text-sm">
{part}
</span>
);
}
return <span key={i}>{part}</span>;
});
}, []);
if (isLoading) {
return (
<Card className="border-border/50 bg-card/50">
<CardContent className="p-6">
<div className="animate-pulse space-y-4">
<div className="h-4 bg-muted rounded w-1/3" />
<div className="h-32 bg-muted rounded" />
</div>
</CardContent>
</Card>
);
}
const activeLabel = TEMPLATE_LABELS[activeTab];
return (
<Card className="border-border/50 bg-card/50">
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2 text-lg">
<MessageSquare className="h-5 w-5 text-teal-400" />
Modèles de messages WhatsApp
</CardTitle>
<CardDescription className="mt-1">
Personnalisez les messages envoyés automatiquement aux patients. Cliquez sur une variable pour l'insérer.
</CardDescription>
</div>
{hasAnyModification && (
<Button
onClick={handleSave}
disabled={updateMutation.isPending}
className="bg-teal-600 hover:bg-teal-700"
>
<Save className="h-4 w-4 mr-2" />
{updateMutation.isPending ? "Sauvegarde..." : "Sauvegarder"}
</Button>
)}
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Variables reference */}
<div className="bg-muted/30 rounded-lg p-4 border border-border/30">
<p className="text-sm font-medium text-muted-foreground mb-2 flex items-center gap-1.5">
<Sparkles className="h-4 w-4 text-amber-400" />
Variables disponibles
</p>
<div className="flex flex-wrap gap-2">
{TEMPLATE_VARIABLES.map((v) => (
<button
key={v.key}
onClick={() => insertVariable(activeTab, v.key)}
className="group relative inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md bg-background/80 border border-border/50 hover:border-teal-500/50 hover:bg-teal-500/10 transition-all cursor-pointer"
title={v.description}
>
<code className="text-xs font-mono text-teal-400">{v.key}</code>
<span className="text-xs text-muted-foreground">{v.label}</span>
</button>
))}
</div>
</div>
{/* Template tabs */}
<div className="flex gap-1 p-1 bg-muted/30 rounded-lg border border-border/30">
{templateTypes.map((type) => {
const label = TEMPLATE_LABELS[type];
return (
<button
key={type}
onClick={() => setActiveTab(type)}
className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-sm font-medium transition-all ${
activeTab === type
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
}`}
>
<span>{label.icon}</span>
<span className="hidden sm:inline">{label.title}</span>
{modified[type] && (
<span className="w-1.5 h-1.5 rounded-full bg-amber-400" />
)}
</button>
);
})}
</div>
{/* Active template editor */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium">{activeLabel.icon} {activeLabel.title}</h4>
<p className="text-sm text-muted-foreground">{activeLabel.description}</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => togglePreview(activeTab)}
className="text-xs"
>
{showPreview[activeTab] ? (
<><EyeOff className="h-3.5 w-3.5 mr-1" /> Éditer</>
) : (
<><Eye className="h-3.5 w-3.5 mr-1" /> Aperçu</>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => resetToDefault(activeTab)}
className="text-xs"
>
<RotateCcw className="h-3.5 w-3.5 mr-1" /> Par défaut
</Button>
</div>
</div>
{showPreview[activeTab] ? (
/* Preview mode WhatsApp-style bubble */
<div className="bg-[#0b141a] rounded-xl p-4 border border-border/30">
<div className="max-w-sm ml-auto">
<div className="bg-[#005c4b] rounded-lg rounded-tr-none p-3 text-sm text-white/90 whitespace-pre-wrap leading-relaxed">
{renderPreview(localTemplates[activeTab])}
</div>
<p className="text-[10px] text-muted-foreground mt-1 text-right">
Aperçu avec données fictives
</p>
</div>
</div>
) : (
/* Edit mode */
<div className="space-y-2">
<Textarea
ref={(el) => { textareaRefs.current[activeTab] = el; }}
value={localTemplates[activeTab]}
onChange={(e) => handleChange(activeTab, e.target.value)}
className="min-h-[200px] font-mono text-sm bg-background/50 border-border/50 focus:border-teal-500/50 resize-y"
placeholder="Saisissez votre message..."
/>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{localTemplates[activeTab].length} / 1000 caractères</span>
{modified[activeTab] && (
<Badge variant="outline" className="text-amber-400 border-amber-400/30">
Modifié
</Badge>
)}
</div>
</div>
)}
</div>
{/* Quick preview of all templates */}
<div className="border-t border-border/30 pt-4">
<p className="text-sm font-medium text-muted-foreground mb-3">Aperçu rapide de tous les messages</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{templateTypes.map((type) => {
const label = TEMPLATE_LABELS[type];
const isCustom = templates && templates[type] !== null;
return (
<div
key={type}
onClick={() => setActiveTab(type)}
className={`p-3 rounded-lg border cursor-pointer transition-all ${
activeTab === type
? "border-teal-500/50 bg-teal-500/5"
: "border-border/30 bg-background/30 hover:border-border/60"
}`}
>
<div className="flex items-center justify-between mb-1.5">
<span className="text-sm font-medium">
{label.icon} {label.title}
</span>
{isCustom ? (
<Badge variant="outline" className="text-teal-400 border-teal-400/30 text-[10px]">
Personnalisé
</Badge>
) : (
<Badge variant="outline" className="text-muted-foreground text-[10px]">
Par défaut
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground line-clamp-2 whitespace-pre-wrap">
{localTemplates[type].substring(0, 80)}...
</p>
</div>
);
})}
</div>
</div>
</CardContent>
</Card>
);
}

View file

@ -1,64 +0,0 @@
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDownIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
);
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View file

@ -1,155 +0,0 @@
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
);
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
);
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
);
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
);
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
);
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
);
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
);
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
);
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View file

@ -1,66 +0,0 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
);
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription };

View file

@ -1,9 +0,0 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
function AspectRatio({
...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />;
}
export { AspectRatio };

View file

@ -1,51 +0,0 @@
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/lib/utils";
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
);
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
);
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
);
}
export { Avatar, AvatarImage, AvatarFallback };

View file

@ -1,46 +0,0 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span";
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };

View file

@ -1,109 +0,0 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/utils";
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className
)}
{...props}
/>
);
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
);
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
);
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
);
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
);
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};

View file

@ -1,83 +0,0 @@
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
const buttonGroupVariants = cva(
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
{
variants: {
orientation: {
horizontal:
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
vertical:
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
},
},
defaultVariants: {
orientation: "horizontal",
},
}
);
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
);
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "div";
return (
<Comp
className={cn(
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto",
className
)}
{...props}
/>
);
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
};

View file

@ -1,63 +0,0 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-transparent shadow-xs hover:bg-accent dark:bg-transparent dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
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",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View file

@ -1,211 +0,0 @@
import * as React from "react";
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react";
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
import { cn } from "@/lib/utils";
import { Button, buttonVariants } from "@/components/ui/button";
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
}) {
const defaultClassNames = getDefaultClassNames();
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: date =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months
),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute bg-popover inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn(
"select-none w-(--cell-size)",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number
),
day: cn(
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
defaultClassNames.day
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
);
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
);
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
);
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
);
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
);
},
...components,
}}
{...props}
/>
);
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus();
}, [modifiers.focused]);
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
);
}
export { Calendar, CalendarDayButton };

View file

@ -1,92 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};

View file

@ -1,239 +0,0 @@
import * as React from "react";
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return;
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext]
);
React.useEffect(() => {
if (!api || !setApi) return;
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) return;
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel();
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
);
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel();
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
);
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
);
}
function CarouselNext({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
);
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
};

View file

@ -1,355 +0,0 @@
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "@/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"];
}) {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}) {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter(item => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}) {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter(item => item.type !== "none")
.map(item => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};

View file

@ -1,30 +0,0 @@
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };

View file

@ -1,31 +0,0 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
);
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
);
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View file

@ -1,184 +0,0 @@
"use client";
import * as React from "react";
import { Command as CommandPrimitive } from "cmdk";
import { SearchIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
);
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string;
description?: string;
className?: string;
showCloseButton?: boolean;
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
);
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
);
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
);
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
);
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
);
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
);
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View file

@ -1,250 +0,0 @@
import * as React from "react";
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
);
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
);
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
);
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />;
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
);
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
);
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
);
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
);
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
);
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
);
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
);
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
);
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};

View file

@ -1,209 +0,0 @@
import { cn } from "@/lib/utils";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import * as React from "react";
// Context to track composition state across dialog children
const DialogCompositionContext = React.createContext<{
isComposing: () => boolean;
setComposing: (composing: boolean) => void;
justEndedComposing: () => boolean;
markCompositionEnd: () => void;
}>({
isComposing: () => false,
setComposing: () => {},
justEndedComposing: () => false,
markCompositionEnd: () => {},
});
export const useDialogComposition = () =>
React.useContext(DialogCompositionContext);
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
const composingRef = React.useRef(false);
const justEndedRef = React.useRef(false);
const endTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const contextValue = React.useMemo(
() => ({
isComposing: () => composingRef.current,
setComposing: (composing: boolean) => {
composingRef.current = composing;
},
justEndedComposing: () => justEndedRef.current,
markCompositionEnd: () => {
justEndedRef.current = true;
if (endTimerRef.current) {
clearTimeout(endTimerRef.current);
}
endTimerRef.current = setTimeout(() => {
justEndedRef.current = false;
}, 150);
},
}),
[]
);
return (
<DialogCompositionContext.Provider value={contextValue}>
<DialogPrimitive.Root data-slot="dialog" {...props} />
</DialogCompositionContext.Provider>
);
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
);
}
DialogOverlay.displayName = "DialogOverlay";
function DialogContent({
className,
children,
showCloseButton = true,
onEscapeKeyDown,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
const { isComposing } = useDialogComposition();
const handleEscapeKeyDown = React.useCallback(
(e: KeyboardEvent) => {
// Check both the native isComposing property and our context state
// This handles Safari's timing issues with composition events
const isCurrentlyComposing = (e as any).isComposing || isComposing();
// If IME is composing, prevent dialog from closing
if (isCurrentlyComposing) {
e.preventDefault();
return;
}
// Call user's onEscapeKeyDown if provided
onEscapeKeyDown?.(e);
},
[isComposing, onEscapeKeyDown]
);
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
onEscapeKeyDown={handleEscapeKeyDown}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger
};

View file

@ -1,133 +0,0 @@
import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "@/lib/utils";
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
);
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
);
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
className
)}
{...props}
/>
);
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
);
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};

View file

@ -1,255 +0,0 @@
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
);
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
);
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
);
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};

View file

@ -1,104 +0,0 @@
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12",
className
)}
{...props}
/>
);
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn(
"flex max-w-sm flex-col items-center gap-2 text-center",
className
)}
{...props}
/>
);
}
const emptyMediaVariants = cva(
"flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
},
},
defaultVariants: {
variant: "default",
},
}
);
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
);
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn("text-lg font-medium tracking-tight", className)}
{...props}
/>
);
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
);
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance",
className
)}
{...props}
/>
);
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
};

View file

@ -1,242 +0,0 @@
import { useMemo } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-6",
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
);
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-3 font-medium",
"data-[variant=legend]:text-base",
"data-[variant=label]:text-sm",
className
)}
{...props}
/>
);
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
className
)}
{...props}
/>
);
}
const fieldVariants = cva(
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
horizontal: [
"flex-row items-center",
"[&>[data-slot=field-label]]:flex-auto",
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
responsive: [
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
},
},
defaultVariants: {
orientation: "vertical",
},
}
);
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
);
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
className
)}
{...props}
/>
);
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
className
)}
{...props}
/>
);
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
);
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
);
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode;
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
);
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>;
}) {
const content = useMemo(() => {
if (children) {
return children;
}
if (!errors) {
return null;
}
if (errors?.length === 1 && errors[0]?.message) {
return errors[0].message;
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{errors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
);
}, [children, errors]);
if (!content) {
return null;
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-destructive text-sm font-normal", className)}
{...props}
>
{content}
</div>
);
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
};

View file

@ -1,168 +0,0 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState } = useFormContext();
const formState = useFormState({ name: fieldContext.name });
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
);
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
);
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField();
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
);
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField();
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message ?? "") : props.children;
if (!body) {
return null;
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
);
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
};

View file

@ -1,42 +0,0 @@
import * as React from "react";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import { cn } from "@/lib/utils";
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
);
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
);
}
export { HoverCard, HoverCardTrigger, HoverCardContent };

View file

@ -1,168 +0,0 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none",
"h-9 min-w-0 has-[>textarea]:h-auto",
// Variants based on alignment.
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
// Focus state.
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
// Error state.
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
className
)}
{...props}
/>
);
}
const inputGroupAddonVariants = cva(
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
{
variants: {
align: {
"inline-start":
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
"inline-end":
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
"block-start":
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
"block-end":
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
},
},
defaultVariants: {
align: "inline-start",
},
}
);
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={e => {
if ((e.target as HTMLElement).closest("button")) {
return;
}
e.currentTarget.parentElement?.querySelector("input")?.focus();
}}
{...props}
/>
);
}
const inputGroupButtonVariants = cva(
"text-sm shadow-none flex gap-2 items-center",
{
variants: {
size: {
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
);
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
);
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
);
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
);
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
};

View file

@ -1,75 +0,0 @@
import * as React from "react";
import { OTPInput, OTPInputContext } from "input-otp";
import { MinusIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string;
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(
"flex items-center gap-2 has-disabled:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
);
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-group"
className={cn("flex items-center", className)}
{...props}
/>
);
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number;
}) {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
)}
</div>
);
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
);
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

View file

@ -1,70 +0,0 @@
import { useDialogComposition } from "@/components/ui/dialog";
import { useComposition } from "@/hooks/useComposition";
import { cn } from "@/lib/utils";
import * as React from "react";
function Input({
className,
type,
onKeyDown,
onCompositionStart,
onCompositionEnd,
...props
}: React.ComponentProps<"input">) {
// Get dialog composition context if available (will be no-op if not inside Dialog)
const dialogComposition = useDialogComposition();
// Add composition event handlers to support input method editor (IME) for CJK languages.
const {
onCompositionStart: handleCompositionStart,
onCompositionEnd: handleCompositionEnd,
onKeyDown: handleKeyDown,
} = useComposition<HTMLInputElement>({
onKeyDown: (e) => {
// Check if this is an Enter key that should be blocked
const isComposing = (e.nativeEvent as any).isComposing || dialogComposition.justEndedComposing();
// If Enter key is pressed while composing or just after composition ended,
// don't call the user's onKeyDown (this blocks the business logic)
if (e.key === "Enter" && isComposing) {
return;
}
// Otherwise, call the user's onKeyDown
onKeyDown?.(e);
},
onCompositionStart: e => {
dialogComposition.setComposing(true);
onCompositionStart?.(e);
},
onCompositionEnd: e => {
// Mark that composition just ended - this helps handle the Enter key that confirms input
dialogComposition.markCompositionEnd();
// Delay setting composing to false to handle Safari's event order
// In Safari, compositionEnd fires before the ESC keydown event
setTimeout(() => {
dialogComposition.setComposing(false);
}, 100);
onCompositionEnd?.(e);
},
});
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onKeyDown={handleKeyDown}
{...props}
/>
);
}
export { Input };

View file

@ -1,193 +0,0 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
role="list"
data-slot="item-group"
className={cn("group/item-group flex flex-col", className)}
{...props}
/>
);
}
function ItemSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="item-separator"
orientation="horizontal"
className={cn("my-0", className)}
{...props}
/>
);
}
const itemVariants = cva(
"group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border-border",
muted: "bg-muted/50",
},
size: {
default: "p-4 gap-4 ",
sm: "py-3 px-4 gap-2.5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
function Item({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div";
return (
<Comp
data-slot="item"
data-variant={variant}
data-size={size}
className={cn(itemVariants({ variant, size, className }))}
{...props}
/>
);
}
const itemMediaVariants = cva(
"flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5",
{
variants: {
variant: {
default: "bg-transparent",
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
image:
"size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover",
},
},
defaultVariants: {
variant: "default",
},
}
);
function ItemMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
return (
<div
data-slot="item-media"
data-variant={variant}
className={cn(itemMediaVariants({ variant, className }))}
{...props}
/>
);
}
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-content"
className={cn(
"flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none",
className
)}
{...props}
/>
);
}
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-title"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium",
className
)}
{...props}
/>
);
}
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="item-description"
className={cn(
"text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
);
}
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-actions"
className={cn("flex items-center gap-2", className)}
{...props}
/>
);
}
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-header"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
);
}
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-footer"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
);
}
export {
Item,
ItemMedia,
ItemContent,
ItemActions,
ItemGroup,
ItemSeparator,
ItemTitle,
ItemDescription,
ItemHeader,
ItemFooter,
};

View file

@ -1,28 +0,0 @@
import { cn } from "@/lib/utils";
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (
<kbd
data-slot="kbd"
className={cn(
"bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none",
"[&_svg:not([class*='size-'])]:size-3",
"[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10",
className
)}
{...props}
/>
);
}
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<kbd
data-slot="kbd-group"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
);
}
export { Kbd, KbdGroup };

View file

@ -1,22 +0,0 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils";
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
);
}
export { Label };

View file

@ -1,274 +0,0 @@
import * as React from "react";
import * as MenubarPrimitive from "@radix-ui/react-menubar";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Menubar({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
return (
<MenubarPrimitive.Root
data-slot="menubar"
className={cn(
"bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs",
className
)}
{...props}
/>
);
}
function MenubarMenu({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />;
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />;
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />;
}
function MenubarRadioGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
return (
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
);
}
function MenubarTrigger({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
return (
<MenubarPrimitive.Trigger
data-slot="menubar-trigger"
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none",
className
)}
{...props}
/>
);
}
function MenubarContent({
className,
align = "start",
alignOffset = -4,
sideOffset = 8,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
return (
<MenubarPortal>
<MenubarPrimitive.Content
data-slot="menubar-content"
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</MenubarPortal>
);
}
function MenubarItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<MenubarPrimitive.Item
data-slot="menubar-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function MenubarCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
return (
<MenubarPrimitive.CheckboxItem
data-slot="menubar-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
);
}
function MenubarRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
return (
<MenubarPrimitive.RadioItem
data-slot="menubar-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
);
}
function MenubarLabel({
className,
inset,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
inset?: boolean;
}) {
return (
<MenubarPrimitive.Label
data-slot="menubar-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
);
}
function MenubarSeparator({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
return (
<MenubarPrimitive.Separator
data-slot="menubar-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function MenubarShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="menubar-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
);
}
function MenubarSub({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />;
}
function MenubarSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<MenubarPrimitive.SubTrigger
data-slot="menubar-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
);
}
function MenubarSubContent({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
return (
<MenubarPrimitive.SubContent
data-slot="menubar-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
);
}
export {
Menubar,
MenubarPortal,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarGroup,
MenubarSeparator,
MenubarLabel,
MenubarItem,
MenubarShortcut,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
};

View file

@ -1,168 +0,0 @@
import * as React from "react";
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import { cva } from "class-variance-authority";
import { ChevronDownIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function NavigationMenu({
className,
children,
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean;
}) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
data-viewport={viewport}
className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
);
}
function NavigationMenuList({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn(
"group flex flex-1 list-none items-center justify-center gap-1",
className
)}
{...props}
/>
);
}
function NavigationMenuItem({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn("relative", className)}
{...props}
/>
);
}
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1"
);
function NavigationMenuTrigger({
className,
children,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDownIcon
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
);
}
function NavigationMenuContent({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
className
)}
{...props}
/>
);
}
function NavigationMenuViewport({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
return (
<div
className={cn(
"absolute top-full left-0 isolate z-50 flex justify-center"
)}
>
<NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport"
className={cn(
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
{...props}
/>
</div>
);
}
function NavigationMenuLink({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
return (
<NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator"
className={cn(
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
className
)}
{...props}
>
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
</NavigationMenuPrimitive.Indicator>
);
}
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
};

View file

@ -1,127 +0,0 @@
import * as React from "react";
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button, buttonVariants } from "@/components/ui/button";
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
);
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
);
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />;
}
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">;
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
);
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props}
>
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
);
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
{...props}
>
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
</PaginationLink>
);
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
);
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
};

View file

@ -1,46 +0,0 @@
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils";
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

View file

@ -1,29 +0,0 @@
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/lib/utils";
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
);
}
export { Progress };

View file

@ -1,43 +0,0 @@
import * as React from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { CircleIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
);
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
}
export { RadioGroup, RadioGroupItem };

View file

@ -1,54 +0,0 @@
import * as React from "react";
import { GripVerticalIcon } from "lucide-react";
import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from "@/lib/utils";
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
return (
<ResizablePrimitive.PanelGroup
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
);
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean;
}) {
return (
<ResizablePrimitive.PanelResizeHandle
data-slot="resizable-handle"
className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
);
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };

View file

@ -1,56 +0,0 @@
import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "@/lib/utils";
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}
export { ScrollArea, ScrollBar };

View file

@ -1,185 +0,0 @@
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "popper",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

View file

@ -1,26 +0,0 @@
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
);
}
export { Separator };

View file

@ -1,139 +0,0 @@
"use client";
import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
);
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left";
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
);
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};

View file

@ -1,734 +0,0 @@
"use client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useIsMobile } from "@/hooks/useMobile";
import { cn } from "@/lib/utils";
import { Slot } from "@radix-ui/react-slot";
import { cva, VariantProps } from "class-variance-authority";
import { PanelLeftIcon } from "lucide-react";
import * as React from "react";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open]
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile(open => !open) : setOpen(open => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
disableTransition = false,
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
disableTransition?: boolean;
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className
)}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent",
disableTransition
? "transition-none"
: "transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) md:flex",
disableTransition
? "transition-none"
: "transition-[left,right,width] duration-200 ease-linear",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
);
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar();
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={event => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar();
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
);
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
);
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
);
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
);
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
);
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
);
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div";
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
);
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
);
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
);
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
);
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
);
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
);
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
)}
{...props}
/>
);
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
);
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
);
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
);
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
);
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}) {
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
);
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar
};

View file

@ -1,13 +0,0 @@
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
);
}
export { Skeleton };

View file

@ -1,61 +0,0 @@
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils";
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max]
);
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
);
}
export { Slider };

View file

@ -1,23 +0,0 @@
import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
);
};
export { Toaster };

View file

@ -1,16 +0,0 @@
import { Loader2Icon } from "lucide-react";
import { cn } from "@/lib/utils";
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon
role="status"
aria-label="Loading"
className={cn("size-4 animate-spin", className)}
{...props}
/>
);
}
export { Spinner };

View file

@ -1,29 +0,0 @@
import * as React from "react";
import * as SwitchPrimitive from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
);
}
export { Switch };

View file

@ -1,114 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
);
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
);
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
);
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
);
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
);
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
);
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
);
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
);
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};

View file

@ -1,64 +0,0 @@
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
);
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
);
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent };

View file

@ -1,67 +0,0 @@
import { useDialogComposition } from "@/components/ui/dialog";
import { useComposition } from "@/hooks/useComposition";
import { cn } from "@/lib/utils";
import * as React from "react";
function Textarea({
className,
onKeyDown,
onCompositionStart,
onCompositionEnd,
...props
}: React.ComponentProps<"textarea">) {
// Get dialog composition context if available (will be no-op if not inside Dialog)
const dialogComposition = useDialogComposition();
// Add composition event handlers to support input method editor (IME) for CJK languages.
const {
onCompositionStart: handleCompositionStart,
onCompositionEnd: handleCompositionEnd,
onKeyDown: handleKeyDown,
} = useComposition<HTMLTextAreaElement>({
onKeyDown: (e) => {
// Check if this is an Enter key that should be blocked
const isComposing = (e.nativeEvent as any).isComposing || dialogComposition.justEndedComposing();
// If Enter key is pressed while composing or just after composition ended,
// don't call the user's onKeyDown (this blocks the business logic)
// Note: For textarea, Shift+Enter should still work for newlines
if (e.key === "Enter" && !e.shiftKey && isComposing) {
return;
}
// Otherwise, call the user's onKeyDown
onKeyDown?.(e);
},
onCompositionStart: e => {
dialogComposition.setComposing(true);
onCompositionStart?.(e);
},
onCompositionEnd: e => {
// Mark that composition just ended - this helps handle the Enter key that confirms input
dialogComposition.markCompositionEnd();
// Delay setting composing to false to handle Safari's event order
// In Safari, compositionEnd fires before the ESC keydown event
setTimeout(() => {
dialogComposition.setComposing(false);
}, 100);
onCompositionEnd?.(e);
},
});
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onKeyDown={handleKeyDown}
{...props}
/>
);
}
export { Textarea };

View file

@ -1,25 +0,0 @@
import { Toaster as SonnerToaster } from "sonner";
export function Toaster() {
return (
<SonnerToaster
position="top-right"
richColors
closeButton
expand
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-white/95 group-[.toaster]:backdrop-blur-md group-[.toaster]:text-slate-900 group-[.toaster]:border-slate-200 group-[.toaster]:shadow-xl group-[.toaster]:rounded-xl",
description: "group-[.toast]:text-slate-500",
actionButton:
"group-[.toast]:bg-teal-600 group-[.toast]:text-white",
cancelButton:
"group-[.toast]:bg-slate-100 group-[.toast]:text-slate-700",
},
}}
/>
);
}
export { toast } from "sonner";

View file

@ -1,73 +0,0 @@
"use client";
import * as React from "react";
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
import { type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { toggleVariants } from "@/components/ui/toggle";
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
});
function ToggleGroup({
className,
variant,
size,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
className={cn(
"group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs",
className
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
);
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
);
}
export { ToggleGroup, ToggleGroupItem };

View file

@ -1,45 +0,0 @@
import * as React from "react";
import * as TogglePrimitive from "@radix-ui/react-toggle";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Toggle, toggleVariants };

View file

@ -1,59 +0,0 @@
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View file

@ -1,81 +0,0 @@
import { useRef } from "react";
import { usePersistFn } from "./usePersistFn";
export interface UseCompositionReturn<
T extends HTMLInputElement | HTMLTextAreaElement,
> {
onCompositionStart: React.CompositionEventHandler<T>;
onCompositionEnd: React.CompositionEventHandler<T>;
onKeyDown: React.KeyboardEventHandler<T>;
isComposing: () => boolean;
}
export interface UseCompositionOptions<
T extends HTMLInputElement | HTMLTextAreaElement,
> {
onKeyDown?: React.KeyboardEventHandler<T>;
onCompositionStart?: React.CompositionEventHandler<T>;
onCompositionEnd?: React.CompositionEventHandler<T>;
}
type TimerResponse = ReturnType<typeof setTimeout>;
export function useComposition<
T extends HTMLInputElement | HTMLTextAreaElement = HTMLInputElement,
>(options: UseCompositionOptions<T> = {}): UseCompositionReturn<T> {
const {
onKeyDown: originalOnKeyDown,
onCompositionStart: originalOnCompositionStart,
onCompositionEnd: originalOnCompositionEnd,
} = options;
const c = useRef(false);
const timer = useRef<TimerResponse | null>(null);
const timer2 = useRef<TimerResponse | null>(null);
const onCompositionStart = usePersistFn((e: React.CompositionEvent<T>) => {
if (timer.current) {
clearTimeout(timer.current);
timer.current = null;
}
if (timer2.current) {
clearTimeout(timer2.current);
timer2.current = null;
}
c.current = true;
originalOnCompositionStart?.(e);
});
const onCompositionEnd = usePersistFn((e: React.CompositionEvent<T>) => {
// 使用两层 setTimeout 来处理 Safari 浏览器中 compositionEnd 先于 onKeyDown 触发的问题
timer.current = setTimeout(() => {
timer2.current = setTimeout(() => {
c.current = false;
});
});
originalOnCompositionEnd?.(e);
});
const onKeyDown = usePersistFn((e: React.KeyboardEvent<T>) => {
// 在 composition 状态下,阻止 ESC 和 Enter非 shift+Enter事件的冒泡
if (
c.current &&
(e.key === "Escape" || (e.key === "Enter" && !e.shiftKey))
) {
e.stopPropagation();
return;
}
originalOnKeyDown?.(e);
});
const isComposing = usePersistFn(() => {
return c.current;
});
return {
onCompositionStart,
onCompositionEnd,
onKeyDown,
isComposing,
};
}

View file

@ -1,21 +0,0 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
}

View file

@ -1,20 +0,0 @@
import { useRef } from "react";
type noop = (...args: any[]) => any;
/**
* usePersistFn instead of useCallback to reduce cognitive load
*/
export function usePersistFn<T extends noop>(fn: T) {
const fnRef = useRef<T>(fn);
fnRef.current = fn;
const persistFn = useRef<T>(null);
if (!persistFn.current) {
persistFn.current = function (this: unknown, ...args) {
return fnRef.current!.apply(this, args);
} as T;
}
return persistFn.current!;
}

View file

@ -1,34 +0,0 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import fr from "./locales/fr.json";
import en from "./locales/en.json";
void i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
fr: { translation: fr },
en: { translation: en },
},
fallbackLng: "fr",
supportedLngs: ["fr", "en"],
interpolation: { escapeValue: false },
detection: {
order: ["localStorage", "navigator", "htmlTag"],
caches: ["localStorage"],
lookupLocalStorage: "queuemed_lang",
},
});
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;

View file

@ -1,22 +0,0 @@
import { io, type Socket } from "socket.io-client";
let socket: Socket | null = null;
export function getSocket(): Socket {
if (!socket) {
socket = io("/", {
path: "/socket.io",
transports: ["websocket", "polling"],
withCredentials: true,
autoConnect: true,
});
}
return socket;
}
export function disconnectSocket(): void {
if (socket) {
socket.disconnect();
socket = null;
}
}

View file

@ -1,20 +0,0 @@
import { createTRPCReact, httpBatchLink } from "@trpc/react-query";
import type { AppRouter } from "../../../server/routers";
export const trpc = createTRPCReact<AppRouter>();
export function getBaseUrl(): string {
if (typeof window !== "undefined") return "";
return `http://localhost:${process.env.PORT ?? 5000}`;
}
export const trpcClientConfig = {
links: [
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
fetch(url, options) {
return fetch(url, { ...options, credentials: "include" });
},
}),
],
};

View file

@ -1,26 +0,0 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
export function formatTime(date: Date | string | null | undefined): string {
if (!date) return "—";
const d = date instanceof Date ? date : new Date(date);
return d.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" });
}
export function formatDate(date: Date | string | null | undefined): string {
if (!date) return "—";
const d = date instanceof Date ? date : new Date(date);
return d.toLocaleDateString("fr-FR", { day: "2-digit", month: "short", year: "numeric" });
}
export function formatTicket(n: number): string {
return String(n).padStart(3, "0");
}
export function pluralize(n: number, singular: string, plural?: string): string {
return n === 1 ? singular : plural ?? `${singular}s`;
}

View file

@ -1,985 +0,0 @@
{
"common": {
"loading": "Loading…",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"close": "Close",
"back": "Back",
"backToHome": "Back to home",
"yes": "Yes",
"no": "No",
"confirm": "Confirm",
"next": "Next",
"previous": "Previous",
"language": "Language",
"appName": "QueueMed"
},
"nav": {
"dashboard": "Dashboard",
"clinics": "Clinics",
"analytics": "Analytics",
"subscription": "Subscription",
"help": "Help",
"logout": "Sign out",
"connected": "Signed in"
},
"home": {
"metaTitle": "QueueMed — Virtual waiting room for doctors",
"metaDescription": "QueueMed — the virtual waiting room for medical practices. Patients scan a QR code and follow their turn in real time, no app required.",
"ogTitle": "QueueMed — Virtual waiting room",
"ogDescription": "Never crowd your waiting room again. QueueMed digitises your queue.",
"heroTitle": "The",
"heroTitleAccent": "virtual waiting room",
"heroSubtitle": "QueueMed digitises your queue. Patients scan a QR code and follow their turn in real time — no app required.",
"heroCtaPrimary": "Start free",
"heroCtaSecondary": "Sign in",
"trustedBy": "Over 200 practices trust us",
"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",
"metaDescription": "Sign in to your QueueMed doctor account.",
"backToHome": "Back to home",
"welcomeBack": "Welcome back",
"doctor": "Doctor",
"welcomeNew": "Welcome to",
"subtitleLogin": "Sign in to your doctor portal.",
"subtitleRegister": "Create your account and start a 30-day free trial.",
"tabLogin": "Sign in",
"tabRegister": "Sign up",
"nameLabel": "Full name",
"nameOptional": "(optional)",
"namePlaceholder": "Dr. Jane Smith",
"emailLabel": "Email",
"emailPlaceholder": "doctor@practice.com",
"passwordLabel": "Password",
"passwordPlaceholder": "At least 8 characters",
"submitLogin": "Sign in",
"submitRegister": "Create my account",
"noAccount": "No account yet?",
"registerLink": "Sign up for free",
"alreadyAccount": "Already have an account?",
"loginLink": "Sign in",
"forgotPassword": "Forgot password?",
"statSetup": "Setup",
"statSetupValue": "2 min",
"statTrial": "Trial",
"statTrialValue": "30d",
"statClinics": "Practices",
"statClinicsValue": "200+"
},
"forgot": {
"metaTitle": "Forgot password — QueueMed",
"metaDescription": "Reset your QueueMed password.",
"title": "Forgot password?",
"subtitle": "Enter your email to receive a reset link.",
"emailLabel": "Email",
"emailPlaceholder": "doctor@practice.com",
"submit": "Send reset link",
"successTitle": "Email sent",
"successMessage": "If an account exists for this email, you'll receive a link to reset your password (valid for 1 hour).",
"backToLogin": "Back to sign in"
},
"reset": {
"metaTitle": "Reset password — QueueMed",
"metaDescription": "Choose a new password for your QueueMed account.",
"title": "New password",
"subtitle": "Choose a secure password of at least 8 characters.",
"passwordLabel": "New password",
"passwordPlaceholder": "At least 8 characters",
"confirmLabel": "Confirm",
"confirmPlaceholder": "Type again",
"submit": "Reset password",
"successTitle": "Password updated",
"successMessage": "You'll be redirected to sign in…",
"errorMismatch": "Passwords don't match",
"errorTooShort": "Password must be at least 8 characters",
"backToLogin": "Back to sign in"
},
"dashboard": {
"metaTitle": "Dashboard — QueueMed",
"title": "Dashboard",
"subtitle": "Overview of your practices",
"kpiClinics": "Practices",
"kpiQueueOpen": "Open queues",
"kpiPatients": "Patients today",
"kpiAvgWait": "Avg wait",
"noClinic": "You don't have a practice yet.",
"createClinic": "Create a practice",
"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",
"title": "Queue management",
"openQueue": "Open queue",
"closeQueue": "Close queue",
"callNext": "Call next",
"markDone": "Done",
"markAbsent": "Absent",
"noPatients": "No patients waiting",
"waiting": "Waiting",
"called": "Called",
"inConsultation": "In consultation",
"addPrintedTicket": "Add printed ticket",
"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",
"yourPosition": "Your position",
"estimatedWait": "Estimated wait",
"minutes": "min",
"joinQueue": "Join queue",
"yourName": "Your name",
"phone": "Phone",
"whatsappOptional": "WhatsApp (optional)",
"joining": "Joining…",
"youAreCalled": "It's your turn",
"pleaseGoTo": "Please go to reception",
"leaveQueue": "Leave queue",
"thanksForVisit": "Thanks for your visit.",
"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",
"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",
"practitionerFallback": "Practitioner",
"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",
"title": "Analytics",
"subtitle": "Statistics and trends",
"totalPatients": "Patients seen",
"avgWait": "Avg wait",
"avgConsultation": "Avg consultation",
"absentRate": "No-show rate",
"byHour": "By hour",
"byDay": "By day",
"exportCsv": "Export CSV",
"recommendations": "AI Recommendations",
"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.",
"kpiNoShowRate": "No-show rate",
"kpiPeakHour": "Peak hour",
"kpiBusiestDay": "Busiest day",
"flowJoined": "Joined",
"flowServed": "Served",
"flowAbsent": "No-shows",
"noShowServed": "Served",
"noShowAbsent": "No-shows",
"chartByHour": "Patient flow by hour",
"chartByDay": "Patient flow by day",
"chartFlow": "Patient flow",
"chartAvgWait": "Average wait time",
"chartWaitTrend": "Average wait per day",
"chartNoShow": "Attendance rate",
"noTrendData": "Not enough data for trend yet",
"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",
"title": "Practice settings",
"general": "General",
"openingHours": "Opening hours",
"whatsapp": "WhatsApp",
"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",
"title": "WhatsApp setup",
"connect": "Connect WhatsApp",
"disconnect": "Disconnect",
"scanQr": "Scan this QR code with WhatsApp",
"connected": "Connected",
"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",
"title": "Welcome to QueueMed",
"step1": "Create your practice",
"step2": "Print your QR code",
"step3": "Open the queue",
"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",
"title": "Subscription",
"trial": "Trial",
"active": "Active",
"expired": "Expired",
"daysLeft": "{{days}} days left",
"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",
"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"
}
}
}
}

View file

@ -1,985 +0,0 @@
{
"common": {
"loading": "Chargement…",
"save": "Enregistrer",
"cancel": "Annuler",
"delete": "Supprimer",
"edit": "Modifier",
"close": "Fermer",
"back": "Retour",
"backToHome": "Retour à l'accueil",
"yes": "Oui",
"no": "Non",
"confirm": "Confirmer",
"next": "Suivant",
"previous": "Précédent",
"language": "Langue",
"appName": "QueueMed"
},
"nav": {
"dashboard": "Tableau de bord",
"clinics": "Cabinets",
"analytics": "Analytics",
"subscription": "Abonnement",
"help": "Aide",
"logout": "Déconnexion",
"connected": "Connecté"
},
"home": {
"metaTitle": "QueueMed — Salle d'attente virtuelle pour médecins",
"metaDescription": "QueueMed — la salle d'attente virtuelle pour les cabinets médicaux. Vos patients scannent un QR code, suivent leur tour en temps réel, sans application à installer.",
"ogTitle": "QueueMed — Salle d'attente virtuelle",
"ogDescription": "Plus jamais de salle d'attente bondée. QueueMed digitalise votre file d'attente.",
"heroTitle": "La salle d'attente",
"heroTitleAccent": "virtuelle",
"heroSubtitle": "QueueMed digitalise votre file d'attente. Vos patients scannent un QR code et suivent leur tour en temps réel, sans application à installer.",
"heroCtaPrimary": "Démarrer gratuitement",
"heroCtaSecondary": "Se connecter",
"trustedBy": "Plus de 200 cabinets nous font confiance",
"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",
"metaDescription": "Connectez-vous à votre espace médecin QueueMed.",
"backToHome": "Retour à l'accueil",
"welcomeBack": "Bon retour",
"doctor": "Docteur",
"welcomeNew": "Bienvenue sur",
"subtitleLogin": "Connectez-vous à votre espace médecin.",
"subtitleRegister": "Créez votre compte et démarrez 30 jours gratuits.",
"tabLogin": "Connexion",
"tabRegister": "Inscription",
"nameLabel": "Nom complet",
"nameOptional": "(optionnel)",
"namePlaceholder": "Dr. Marie Dubois",
"emailLabel": "Email",
"emailPlaceholder": "docteur@cabinet.fr",
"passwordLabel": "Mot de passe",
"passwordPlaceholder": "Au moins 8 caractères",
"submitLogin": "Se connecter",
"submitRegister": "Créer mon compte",
"noAccount": "Pas encore de compte ?",
"registerLink": "Inscrivez-vous gratuitement",
"alreadyAccount": "Déjà un compte ?",
"loginLink": "Connectez-vous",
"forgotPassword": "Mot de passe oublié ?",
"statSetup": "Setup",
"statSetupValue": "2 min",
"statTrial": "Essai",
"statTrialValue": "30j",
"statClinics": "Cabinets",
"statClinicsValue": "200+"
},
"forgot": {
"metaTitle": "Mot de passe oublié — QueueMed",
"metaDescription": "Réinitialisez votre mot de passe QueueMed.",
"title": "Mot de passe oublié ?",
"subtitle": "Saisissez votre email pour recevoir un lien de réinitialisation.",
"emailLabel": "Email",
"emailPlaceholder": "docteur@cabinet.fr",
"submit": "Envoyer le lien",
"successTitle": "Email envoyé",
"successMessage": "Si un compte existe avec cet email, vous recevrez un lien pour réinitialiser votre mot de passe (valable 1 heure).",
"backToLogin": "Retour à la connexion"
},
"reset": {
"metaTitle": "Réinitialiser mot de passe — QueueMed",
"metaDescription": "Choisissez un nouveau mot de passe pour votre compte QueueMed.",
"title": "Nouveau mot de passe",
"subtitle": "Choisissez un mot de passe sécurisé d'au moins 8 caractères.",
"passwordLabel": "Nouveau mot de passe",
"passwordPlaceholder": "Au moins 8 caractères",
"confirmLabel": "Confirmer",
"confirmPlaceholder": "Saisir à nouveau",
"submit": "Réinitialiser",
"successTitle": "Mot de passe modifié",
"successMessage": "Vous allez être redirigé vers la connexion…",
"errorMismatch": "Les mots de passe ne correspondent pas",
"errorTooShort": "Le mot de passe doit contenir au moins 8 caractères",
"backToLogin": "Retour à la connexion"
},
"dashboard": {
"metaTitle": "Tableau de bord — QueueMed",
"title": "Tableau de bord",
"subtitle": "Vue d'ensemble de vos cabinets",
"kpiClinics": "Cabinets",
"kpiQueueOpen": "Files ouvertes",
"kpiPatients": "Patients aujourd'hui",
"kpiAvgWait": "Attente moyenne",
"noClinic": "Vous n'avez pas encore de cabinet.",
"createClinic": "Créer un cabinet",
"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",
"title": "Gestion de la file",
"openQueue": "Ouvrir la file",
"closeQueue": "Fermer la file",
"callNext": "Appeler le suivant",
"markDone": "Terminé",
"markAbsent": "Absent",
"noPatients": "Aucun patient en attente",
"waiting": "En attente",
"called": "Appelé",
"inConsultation": "En consultation",
"addPrintedTicket": "Ajouter un ticket imprimé",
"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",
"yourPosition": "Votre position",
"estimatedWait": "Attente estimée",
"minutes": "min",
"joinQueue": "Rejoindre la file",
"yourName": "Votre nom",
"phone": "Téléphone",
"whatsappOptional": "WhatsApp (optionnel)",
"joining": "Inscription en cours…",
"youAreCalled": "C'est votre tour",
"pleaseGoTo": "Présentez-vous à l'accueil",
"leaveQueue": "Quitter la file",
"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",
"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",
"practitionerFallback": "Praticien",
"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",
"title": "Analytics",
"subtitle": "Statistiques et tendances",
"totalPatients": "Patients reçus",
"avgWait": "Attente moyenne",
"avgConsultation": "Consultation moyenne",
"absentRate": "Taux d'absence",
"byHour": "Par heure",
"byDay": "Par jour",
"exportCsv": "Exporter CSV",
"recommendations": "Recommandations IA",
"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.",
"kpiNoShowRate": "Taux d'absence",
"kpiPeakHour": "Heure de pointe",
"kpiBusiestDay": "Jour le plus chargé",
"flowJoined": "Joints",
"flowServed": "Servis",
"flowAbsent": "Absents",
"noShowServed": "Servis",
"noShowAbsent": "Absents",
"chartByHour": "Affluence par heure",
"chartByDay": "Affluence par jour",
"chartFlow": "Flux patients",
"chartAvgWait": "Temps d'attente moyen",
"chartWaitTrend": "Attente moyenne par jour",
"chartNoShow": "Taux de présence",
"noTrendData": "Pas encore de données pour la tendance",
"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",
"title": "Paramètres du cabinet",
"general": "Général",
"openingHours": "Horaires d'ouverture",
"whatsapp": "WhatsApp",
"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",
"title": "Configuration WhatsApp",
"connect": "Connecter WhatsApp",
"disconnect": "Déconnecter",
"scanQr": "Scannez ce QR code avec WhatsApp",
"connected": "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",
"title": "Bienvenue sur QueueMed",
"step1": "Créez votre cabinet",
"step2": "Imprimez votre QR code",
"step3": "Ouvrez la file d'attente",
"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",
"title": "Abonnement",
"trial": "Essai",
"active": "Actif",
"expired": "Expiré",
"daysLeft": "{{days}} jours restants",
"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",
"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"
}
}
}
}

View file

@ -1,46 +0,0 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { trpc, trpcClientConfig } from "@/lib/trpc";
import { getSocket } from "@/lib/socket";
import "./i18n";
import App from "./App";
import "./styles.css";
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
import("virtual:pwa-register")
.then(({ registerSW }) => registerSW({ immediate: true }))
.catch(() => {
/* PWA optional in dev */
});
});
}
// Eagerly initialize the socket connection so it's ready when pages mount.
getSocket();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
staleTime: 30_000,
},
},
});
const trpcClient = trpc.createClient(trpcClientConfig);
const root = document.getElementById("root");
if (!root) throw new Error("Root element #root not found");
ReactDOM.createRoot(root).render(
<React.StrictMode>
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</trpc.Provider>
</React.StrictMode>
);

View file

@ -1,456 +0,0 @@
import { useState } from "react";
import { Helmet } from "react-helmet-async";
import { Redirect } from "wouter";
import {
Loader2,
Shield,
Users as UsersIcon,
Building2,
Activity,
Search,
CheckCircle2,
XCircle,
MessageCircle,
TrendingUp,
} from "lucide-react";
import { trpc } from "@/lib/trpc";
import { useAuth } from "@/_core/hooks/useAuth";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
type TabId = "overview" | "users" | "clinics";
export default function AdminPanel() {
const { user, loading } = useAuth();
const [tab, setTab] = useState<TabId>("overview");
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<Loader2 className="w-8 h-8 text-emerald-500 animate-spin" />
</div>
);
}
if (!user || user.role !== "admin") {
return <Redirect to="/dashboard" />;
}
const TABS: { id: TabId; label: string; icon: typeof Activity }[] = [
{ id: "overview", label: "Vue d'ensemble", icon: Activity },
{ id: "users", label: "Utilisateurs", icon: UsersIcon },
{ id: "clinics", label: "Cabinets", icon: Building2 },
];
return (
<div className="container py-8">
<Helmet>
<title>Admin QueueMed</title>
<meta name="description" content="Console d'administration QueueMed." />
</Helmet>
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-emerald-500 to-cyan-500 flex items-center justify-center shadow-md">
<Shield className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="font-bold text-2xl">Administration</h1>
<p className="text-slate-500 text-sm">
Console réservée aux administrateurs QueueMed.
</p>
</div>
</div>
<div className="glass-card rounded-2xl p-1.5 mb-6 inline-flex flex-wrap gap-1">
{TABS.map((t) => {
const Icon = t.icon;
const active = tab === t.id;
return (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all ${
active
? "bg-gradient-to-r from-emerald-500 to-cyan-500 text-white shadow-md"
: "text-slate-600 hover:bg-emerald-50"
}`}
>
<Icon className="w-4 h-4" />
{t.label}
</button>
);
})}
</div>
{tab === "overview" && <OverviewTab />}
{tab === "users" && <UsersTab />}
{tab === "clinics" && <ClinicsTab />}
</div>
);
}
function OverviewTab() {
const overviewQuery = trpc.admin.getOverview.useQuery(undefined, {
refetchInterval: 30_000,
});
if (overviewQuery.isLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 text-emerald-500 animate-spin" />
</div>
);
}
const data = overviewQuery.data;
if (!data) {
return (
<div className="glass-card rounded-2xl p-8 text-center text-slate-500">
Impossible de charger les données.
</div>
);
}
const cards: {
label: string;
value: number | string;
icon: typeof UsersIcon;
color: string;
sub?: string;
}[] = [
{
label: "Utilisateurs total",
value: data.totalUsers,
icon: UsersIcon,
color: "from-emerald-500 to-teal-500",
sub: `${data.totalAdmins} admins · ${data.totalDisabled} désactivés`,
},
{
label: "Cabinets total",
value: data.totalClinics,
icon: Building2,
color: "from-cyan-500 to-blue-500",
sub: `${data.totalActiveClinics} actifs`,
},
{
label: "Patients aujourd'hui",
value: data.totalQueueEntriesToday,
icon: Activity,
color: "from-orange-500 to-amber-500",
sub: `${data.totalQueueEntriesAllTime} au total`,
},
{
label: "Sessions WhatsApp",
value: data.activeWhatsAppSessions,
icon: MessageCircle,
color: "from-violet-500 to-purple-500",
sub: "actives en mémoire",
},
];
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{cards.map((c) => {
const Icon = c.icon;
return (
<div key={c.label} className="glass-card rounded-2xl p-5">
<div
className={`w-10 h-10 rounded-xl bg-gradient-to-br ${c.color} flex items-center justify-center mb-4 shadow-md`}
>
<Icon className="w-5 h-5 text-white" />
</div>
<div className="font-bold text-3xl text-slate-900 mb-1 tabular-nums">
{c.value}
</div>
<div className="text-sm text-slate-700 font-medium">{c.label}</div>
{c.sub && <div className="text-xs text-slate-500 mt-1">{c.sub}</div>}
</div>
);
})}
</div>
);
}
function UsersTab() {
const utils = trpc.useUtils();
const { user } = useAuth();
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [roleFilter, setRoleFilter] = useState<"" | "user" | "admin">("");
const usersQuery = trpc.admin.listUsers.useQuery({
page,
perPage: 20,
role: roleFilter || undefined,
search: search || undefined,
});
const updateRole = trpc.admin.updateUserRole.useMutation({
onSuccess: () => {
toast.success("Rôle mis à jour");
utils.admin.listUsers.invalidate();
utils.admin.getOverview.invalidate();
},
onError: (e) => toast.error(e.message),
});
const disableUser = trpc.admin.disableUser.useMutation({
onSuccess: () => {
toast.success("Statut mis à jour");
utils.admin.listUsers.invalidate();
utils.admin.getOverview.invalidate();
},
onError: (e) => toast.error(e.message),
});
const data = usersQuery.data;
const totalPages = data ? Math.max(1, Math.ceil(data.total / data.perPage)) : 1;
return (
<div className="space-y-4">
<div className="glass-card rounded-2xl p-4 flex flex-col sm:flex-row gap-3 sm:items-center">
<div className="relative flex-1">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
type="text"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
placeholder="Rechercher par email ou nom…"
className="w-full pl-9 pr-3 py-2 rounded-xl border border-slate-200 bg-white text-sm focus:outline-none focus:border-emerald-400"
/>
</div>
<select
value={roleFilter}
onChange={(e) => {
setRoleFilter(e.target.value as "" | "user" | "admin");
setPage(1);
}}
className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm focus:outline-none focus:border-emerald-400"
aria-label="Filtrer par rôle"
>
<option value="">Tous les rôles</option>
<option value="user">Utilisateurs</option>
<option value="admin">Administrateurs</option>
</select>
</div>
<div className="glass-card rounded-2xl overflow-hidden">
{usersQuery.isLoading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 text-emerald-500 animate-spin" />
</div>
) : !data || data.users.length === 0 ? (
<div className="text-center py-16 px-6 text-slate-500">
Aucun utilisateur trouvé.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-emerald-50/60 text-slate-600 text-xs uppercase tracking-wider">
<tr>
<th className="text-left px-4 py-3 font-semibold">Email</th>
<th className="text-left px-4 py-3 font-semibold">Nom</th>
<th className="text-left px-4 py-3 font-semibold">Rôle</th>
<th className="text-left px-4 py-3 font-semibold">Statut</th>
<th className="text-left px-4 py-3 font-semibold">Créé le</th>
<th className="text-right px-4 py-3 font-semibold">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{data.users.map((u) => {
const isSelf = u.id === user?.id;
return (
<tr key={u.id} className="hover:bg-emerald-50/30">
<td className="px-4 py-3 font-medium text-slate-900 truncate max-w-[280px]">
{u.email}
</td>
<td className="px-4 py-3 text-slate-600">
{u.name ?? "—"}
</td>
<td className="px-4 py-3">
<select
value={u.role ?? "user"}
onChange={(e) =>
updateRole.mutate({
userId: u.id,
role: e.target.value as "user" | "admin",
})
}
disabled={isSelf || updateRole.isPending}
className="rounded-lg border border-slate-200 bg-white px-2 py-1 text-xs focus:outline-none focus:border-emerald-400 disabled:opacity-60"
aria-label="Rôle"
>
<option value="user">user</option>
<option value="admin">admin</option>
</select>
</td>
<td className="px-4 py-3">
{u.disabled ? (
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-md bg-red-100 text-red-700 text-xs font-bold">
<XCircle className="w-3 h-3" /> Désactivé
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-md bg-emerald-100 text-emerald-700 text-xs font-bold">
<CheckCircle2 className="w-3 h-3" /> Actif
</span>
)}
</td>
<td className="px-4 py-3 text-slate-500 text-xs">
{u.createdAt
? new Date(u.createdAt).toLocaleDateString("fr-FR")
: "—"}
</td>
<td className="px-4 py-3 text-right">
<Button
size="sm"
variant={u.disabled ? "outline" : "ghost"}
disabled={isSelf || disableUser.isPending}
onClick={() =>
disableUser.mutate({
userId: u.id,
disabled: !u.disabled,
})
}
className={
u.disabled
? ""
: "text-red-600 hover:bg-red-50 hover:text-red-700"
}
>
{u.disabled ? "Réactiver" : "Désactiver"}
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{data && data.total > data.perPage && (
<div className="flex items-center justify-between p-4 border-t border-slate-100 text-sm">
<span className="text-slate-500">
{(data.page - 1) * data.perPage + 1}
{Math.min(data.page * data.perPage, data.total)} sur {data.total}
</span>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Précédent
</Button>
<Button
size="sm"
variant="outline"
disabled={page >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
Suivant
</Button>
</div>
</div>
)}
</div>
</div>
);
}
function ClinicsTab() {
const clinicsQuery = trpc.admin.listAllClinics.useQuery();
if (clinicsQuery.isLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 text-emerald-500 animate-spin" />
</div>
);
}
const clinics = clinicsQuery.data ?? [];
if (clinics.length === 0) {
return (
<div className="glass-card rounded-2xl p-8 text-center text-slate-500">
Aucun cabinet enregistré.
</div>
);
}
return (
<div className="glass-card rounded-2xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-emerald-50/60 text-slate-600 text-xs uppercase tracking-wider">
<tr>
<th className="text-left px-4 py-3 font-semibold">Cabinet</th>
<th className="text-left px-4 py-3 font-semibold">Propriétaire</th>
<th className="text-left px-4 py-3 font-semibold">Patients aujourd'hui</th>
<th className="text-left px-4 py-3 font-semibold">Statut</th>
<th className="text-left px-4 py-3 font-semibold">Créé le</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{clinics.map((c) => (
<tr key={c.id} className="hover:bg-emerald-50/30">
<td className="px-4 py-3">
<div className="font-semibold text-slate-900">{c.name}</div>
<div className="text-xs text-slate-500">#{c.id}</div>
</td>
<td className="px-4 py-3">
<div className="text-slate-700 truncate max-w-[260px]">
{c.ownerEmail ?? "—"}
</div>
{c.ownerName && (
<div className="text-xs text-slate-500 truncate max-w-[260px]">
{c.ownerName}
</div>
)}
</td>
<td className="px-4 py-3">
<span className="inline-flex items-center gap-1 font-bold text-emerald-700">
<TrendingUp className="w-3.5 h-3.5" />
{c.patientCountToday}
</span>
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1.5">
<span
className={`px-2 py-0.5 rounded-md text-xs font-bold ${
c.isActive
? "bg-emerald-100 text-emerald-700"
: "bg-slate-100 text-slate-500"
}`}
>
{c.isActive ? "actif" : "inactif"}
</span>
<span
className={`px-2 py-0.5 rounded-md text-xs font-bold ${
c.isQueueOpen
? "bg-cyan-100 text-cyan-700"
: "bg-slate-100 text-slate-500"
}`}
>
{c.isQueueOpen ? "file ouverte" : "file fermée"}
</span>
</div>
</td>
<td className="px-4 py-3 text-slate-500 text-xs">
{c.createdAt
? new Date(c.createdAt).toLocaleDateString("fr-FR")
: "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -1,462 +0,0 @@
import { useState } from "react";
import { Helmet } from "react-helmet-async";
import { Redirect } from "wouter";
import {
Loader2,
CreditCard,
MessageSquare,
Phone,
Settings as SettingsIcon,
Save,
CheckCircle2,
Eye,
EyeOff,
Trash2,
TestTube,
} from "lucide-react";
import { trpc } from "@/lib/trpc";
import { useAuth } from "@/_core/hooks/useAuth";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
type TabId = "integrations" | "whatsapp" | "notifications" | "general";
interface ConfigEntry {
key: string;
value: string;
isSecret: boolean;
category: string;
description?: string;
updatedAt: string;
}
const CONFIG_KEYS = {
stripe: [
{ key: "STRIPE_SECRET_KEY", label: "Clé secrète Stripe", secret: true, desc: "sk_live_... ou sk_test_..." },
{ key: "STRIPE_PUBLISHABLE_KEY", label: "Clé publique Stripe", secret: false, desc: "pk_live_... ou pk_test_..." },
{ key: "STRIPE_WEBHOOK_SECRET", label: "Secret Webhook Stripe", secret: true, desc: "whsec_..." },
{ key: "STRIPE_BASIC_PRICE_ID", label: "Price ID Basique", secret: false, desc: "price_..." },
{ key: "STRIPE_PRO_PRICE_ID", label: "Price ID Pro", secret: false, desc: "price_..." },
],
twilio: [
{ key: "TWILIO_ACCOUNT_SID", label: "Account SID", secret: false, desc: "AC..." },
{ key: "TWILIO_AUTH_TOKEN", label: "Auth Token", secret: true, desc: "Token Twilio" },
{ key: "TWILIO_PHONE_NUMBER", label: "Numéro Twilio", secret: false, desc: "+1..." },
],
};
export default function AdminSettings() {
const { user, loading } = useAuth();
const [tab, setTab] = useState<TabId>("integrations");
const [configMap, setConfigMap] = useState<Record<string, ConfigEntry>>({});
const [editValues, setEditValues] = useState<Record<string, string>>({});
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
const [saving, setSaving] = useState<Record<string, boolean>>({});
const configQuery = trpc.admin.listConfig.useQuery(undefined, {
onSuccess: (data: ConfigEntry[]) => {
const map: Record<string, ConfigEntry> = {};
const vals: Record<string, string> = {};
for (const row of data) {
map[row.key] = row;
vals[row.key] = row.isSecret && row.value === "••••••••" ? "" : row.value;
}
setConfigMap(map);
setEditValues(vals);
},
});
const setConfigMut = trpc.admin.setConfig.useMutation({
onSuccess: () => toast.success("Configuration sauvegardée"),
onError: (err: Error) => toast.error(err.message),
});
const deleteConfigMut = trpc.admin.deleteConfig.useMutation({
onSuccess: () => { toast.success("Clé supprimée"); configQuery.refetch(); },
onError: (err: Error) => toast.error(err.message),
});
const testStripeMut = trpc.admin.testStripeConnection.useMutation({
onSuccess: (r: { success: boolean; error?: string }) =>
r.success ? toast.success("Stripe : connexion OK ✓") : toast.error("Stripe : " + (r.error ?? "erreur")),
onError: (err: Error) => toast.error(err.message),
});
const testSmsMut = trpc.admin.testSmsConnection.useMutation({
onSuccess: (r: { success: boolean; error?: string }) =>
r.success ? toast.success("Twilio : connexion OK ✓") : toast.error("Twilio : " + (r.error ?? "erreur")),
onError: (err: Error) => toast.error(err.message),
});
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<Loader2 className="w-8 h-8 text-emerald-500 animate-spin" />
</div>
);
}
if (!user || user.role !== "admin") {
return <Redirect to="/dashboard" />;
}
async function handleSave(key: string, category: string, isSecret: boolean, desc?: string) {
const val = editValues[key];
if (val === undefined || val === "") {
if (configMap[key]) {
deleteConfigMut.mutate({ key });
}
return;
}
setSaving((p) => ({ ...p, [key]: true }));
try {
await setConfigMut.mutateAsync({ key, value: val, isSecret, category, description: desc });
configQuery.refetch();
} finally {
setSaving((p) => ({ ...p, [key]: false }));
}
}
function handleDelete(key: string) {
if (confirm('Supprimer la clé "' + key + '" ?')) {
deleteConfigMut.mutate({ key });
}
}
const TABS: { id: TabId; label: string; icon: typeof SettingsIcon }[] = [
{ id: "integrations", label: "Intégrations", icon: CreditCard },
{ id: "whatsapp", label: "WhatsApp", icon: MessageSquare },
{ id: "notifications", label: "Notifications", icon: Phone },
{ id: "general", label: "Général", icon: SettingsIcon },
];
function ConfigField({ keyName, label, isSecret, category, desc }: {
keyName: string; label: string; isSecret: boolean; category: string; desc?: string;
}) {
const visible = !isSecret || showSecrets[keyName];
const hasValue = configMap[keyName] && configMap[keyName].value !== "••••••••";
return (
<div className="flex items-center gap-3 py-3 border-b border-slate-100 last:border-0">
<div className="flex-1 min-w-0">
<label className="block text-sm font-medium text-slate-700">{label}</label>
{desc && <p className="text-xs text-slate-400 mt-0.5">{desc}</p>}
<div className="relative mt-1">
<input
type={visible ? "text" : "password"}
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:border-emerald-400 focus:ring-1 focus:ring-emerald-400 outline-none pr-9"
value={editValues[keyName] ?? ""}
placeholder={hasValue ? "•••••••• (laisser vide pour conserver)" : "Non configuré"}
onChange={(e) => setEditValues((p) => ({ ...p, [keyName]: e.target.value }))}
/>
{isSecret && (
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
onClick={() => setShowSecrets((p) => ({ ...p, [keyName]: !p[keyName] }))}
>
{visible ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
)}
</div>
</div>
<div className="flex flex-col gap-1">
<Button
size="sm"
variant="outline"
className="text-emerald-600 border-emerald-200 hover:bg-emerald-50"
disabled={saving[keyName]}
onClick={() => handleSave(keyName, category, isSecret, desc)}
>
{saving[keyName] ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
</Button>
{configMap[keyName] && (
<Button
size="sm"
variant="ghost"
className="text-red-400 hover:text-red-600 hover:bg-red-50"
onClick={() => handleDelete(keyName)}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
)}
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-emerald-50/30">
<Helmet>
<title>Paramètres & Intégrations QueueMed</title>
</Helmet>
<div className="max-w-5xl mx-auto px-4 py-8">
<div className="flex items-center gap-3 mb-6">
<div className="p-2 bg-emerald-100 rounded-xl">
<SettingsIcon className="w-6 h-6 text-emerald-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-slate-900">Paramètres & Intégrations</h1>
<p className="text-sm text-slate-500">Configurez vos services et notifications</p>
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 mb-6 bg-white rounded-xl p-1 shadow-sm border border-slate-100">
{TABS.map((t) => {
const active = tab === t.id;
return (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={
"flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium transition-all " +
(active
? "bg-emerald-500 text-white shadow-sm"
: "text-slate-500 hover:text-slate-700 hover:bg-slate-50")
}
>
<t.icon className="w-4 h-4" />
{t.label}
</button>
);
})}
</div>
{/* Content */}
<div className="bg-white rounded-2xl shadow-sm border border-slate-100 p-6">
{tab === "integrations" && (
<div>
{/* Stripe */}
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<CreditCard className="w-5 h-5 text-violet-500" />
<h2 className="text-lg font-semibold text-slate-800">Stripe Paiements</h2>
</div>
<Button
size="sm"
variant="outline"
className="text-violet-600 border-violet-200 hover:bg-violet-50"
disabled={testStripeMut.isLoading}
onClick={() => testStripeMut.mutate()}
>
<TestTube className="w-3.5 h-3.5 mr-1" />
{testStripeMut.isLoading ? "Test..." : "Tester"}
</Button>
</div>
{CONFIG_KEYS.stripe.map((k) => (
<ConfigField key={k.key} keyName={k.key} label={k.label} isSecret={k.secret} category="stripe" desc={k.desc} />
))}
</div>
{/* Twilio */}
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Phone className="w-5 h-5 text-blue-500" />
<h2 className="text-lg font-semibold text-slate-800">Twilio SMS</h2>
</div>
<Button
size="sm"
variant="outline"
className="text-blue-600 border-blue-200 hover:bg-blue-50"
disabled={testSmsMut.isLoading}
onClick={() => testSmsMut.mutate()}
>
<TestTube className="w-3.5 h-3.5 mr-1" />
{testSmsMut.isLoading ? "Test..." : "Tester"}
</Button>
</div>
{CONFIG_KEYS.twilio.map((k) => (
<ConfigField key={k.key} keyName={k.key} label={k.label} isSecret={k.secret} category="twilio" desc={k.desc} />
))}
</div>
</div>
)}
{tab === "whatsapp" && <WhatsAppTab />}
{tab === "notifications" && <NotificationsTab />}
{tab === "general" && <GeneralTab />}
</div>
</div>
</div>
);
}
function WhatsAppTab() {
const status = trpc.whatsapp.getStatus.useQuery(undefined, { refetchInterval: 5000 });
const connectMut = trpc.whatsapp.connect.useMutation({
onSuccess: () => toast.success("Connexion WhatsApp lancée"),
onError: (err: Error) => toast.error(err.message),
});
const disconnectMut = trpc.whatsapp.disconnect.useMutation({
onSuccess: () => toast.success("WhatsApp déconnecté"),
onError: (err: Error) => toast.error(err.message),
});
const s = status.data as any;
const isConnected = s?.connected;
const qr = s?.qr;
return (
<div>
<div className="flex items-center gap-2 mb-6">
<MessageSquare className="w-5 h-5 text-green-500" />
<h2 className="text-lg font-semibold text-slate-800">WhatsApp Business</h2>
<span
className={
"ml-2 px-2 py-0.5 rounded-full text-xs font-medium " +
(isConnected ? "bg-green-100 text-green-700" : "bg-slate-100 text-slate-500")
}
>
{isConnected ? "Connecté" : "Déconnecté"}
</span>
</div>
{isConnected ? (
<div className="text-center py-8">
<CheckCircle2 className="w-16 h-16 text-green-500 mx-auto mb-4" />
<p className="text-lg font-medium text-slate-700 mb-2">WhatsApp est connecté</p>
<p className="text-sm text-slate-500 mb-6">Les notifications patients seront envoyées via WhatsApp</p>
<Button
variant="outline"
className="text-red-500 border-red-200 hover:bg-red-50"
onClick={() => disconnectMut.mutate()}
disabled={disconnectMut.isLoading}
>
Déconnecter
</Button>
</div>
) : qr ? (
<div className="text-center py-8">
<p className="text-sm text-slate-600 mb-4">
Scannez ce QR code avec WhatsApp Business pour connecter votre compte
</p>
<div className="inline-block p-4 bg-white rounded-xl border-2 border-slate-200 shadow-sm">
<img src={qr} alt="QR Code WhatsApp" className="w-64 h-64" />
</div>
<p className="text-xs text-slate-400 mt-4">
WhatsApp Appareils connectés Connecter un appareil
</p>
</div>
) : (
<div className="text-center py-8">
<MessageSquare className="w-16 h-16 text-slate-300 mx-auto mb-4" />
<p className="text-sm text-slate-500 mb-6">WhatsApp n&apos;est pas encore connecté</p>
<Button
className="bg-green-500 hover:bg-green-600 text-white"
onClick={() => connectMut.mutate()}
disabled={connectMut.isLoading}
>
{connectMut.isLoading ? "Connexion..." : "Connecter WhatsApp"}
</Button>
</div>
)}
</div>
);
}
function NotificationsTab() {
const clinics = trpc.clinic.list.useQuery();
const utils = trpc.useUtils();
const toggleSmsMut = trpc.clinicSettings.toggleSms.useMutation({
onSuccess: () => {
utils.clinic.list.invalidate();
toast.success("SMS mis à jour");
},
onError: (err: Error) => toast.error(err.message),
});
return (
<div>
<div className="flex items-center gap-2 mb-6">
<Phone className="w-5 h-5 text-blue-500" />
<h2 className="text-lg font-semibold text-slate-800">Canaux de notification</h2>
</div>
<p className="text-sm text-slate-500 mb-4">
Activez ou désactivez les canaux de notification par cabinet.
WhatsApp doit être connecté (onglet WhatsApp) pour fonctionner.
</p>
{clinics.isLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="w-6 h-6 text-emerald-500 animate-spin" />
</div>
) : (
<div className="space-y-3">
{(clinics.data as any[])?.map((clinic: any) => (
<div
key={clinic.id}
className="flex items-center justify-between p-4 rounded-xl bg-slate-50 border border-slate-100"
>
<div>
<p className="font-medium text-slate-700">{clinic.name}</p>
<p className="text-xs text-slate-400">{clinic.phone ?? "Pas de téléphone"}</p>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={clinic.smsEnabled ?? false}
onChange={(e) =>
toggleSmsMut.mutate({ clinicId: clinic.id, enabled: e.target.checked })
}
className="rounded border-slate-300 text-emerald-500 focus:ring-emerald-400"
/>
<span className="text-slate-600">SMS</span>
</label>
<span
className={
"text-xs px-2 py-0.5 rounded-full " +
(clinic.whatsappPhone
? "bg-green-100 text-green-700"
: "bg-slate-100 text-slate-400")
}
>
WhatsApp {clinic.whatsappPhone ? "✓" : "non configuré"}
</span>
</div>
</div>
))}
</div>
)}
</div>
);
}
function GeneralTab() {
return (
<div>
<div className="flex items-center gap-2 mb-6">
<SettingsIcon className="w-5 h-5 text-slate-500" />
<h2 className="text-lg font-semibold text-slate-800">Paramètres généraux</h2>
</div>
<div className="space-y-4">
<div className="p-4 rounded-xl bg-amber-50 border border-amber-200">
<p className="text-sm font-medium text-amber-800">💡 Astuce</p>
<p className="text-xs text-amber-700 mt-1">
Les paramètres de chaque cabinet (nom, adresse, horaires, rotation QR, langue patient...)
sont configurables depuis la page <strong>Cabinets</strong> du dashboard.
</p>
</div>
<div className="p-4 rounded-xl bg-blue-50 border border-blue-200">
<p className="text-sm font-medium text-blue-800">📧 Email / SMTP</p>
<p className="text-xs text-blue-700 mt-1">
La configuration SMTP se fait via les variables d&apos;environnement du serveur (non modifiables depuis l&apos;interface).
Contactez l&apos;administrateur système pour modifier les paramètres email.
</p>
</div>
<div className="p-4 rounded-xl bg-slate-50 border border-slate-200">
<p className="text-sm font-medium text-slate-800">🔐 Sécurité</p>
<p className="text-xs text-slate-600 mt-1">
Les clés secrètes (Stripe, Twilio) sont stockées en base de données et masquées dans l&apos;interface.
Seule la dernière valeur saisie est visible. Laissez le champ vide pour conserver la valeur existante.
</p>
</div>
</div>
</div>
);
}

View file

@ -1,327 +0,0 @@
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,
AreaChart, Area, Cell, PieChart, Pie, Legend,
} from "recharts";
import { trpc } from "@/lib/trpc";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
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 });
const advancedQuery = trpc.analytics.getAdvanced.useQuery({ days, clinicId });
const clinics = clinicsQuery.data ?? [];
const summary = summaryQuery.data;
const advanced = advancedQuery.data;
const exportCsv = trpc.analytics.exportCsv.useQuery(
{ clinicId: clinicId ?? clinics[0]?.id ?? 0, days },
{ enabled: false }
);
const handleExport = async () => {
if (!clinicId && !clinics[0]) {
toast.error(t("analytics.toastNoClinic"));
return;
}
const result = await exportCsv.refetch();
if (!result.data) {
toast.error(t("analytics.toastExportFailed"));
return;
}
const blob = new Blob([result.data.csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = result.data.filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success(t("analytics.toastExportSuccess"));
};
const hourSource = advanced?.byHour ?? summary?.byHour ?? [];
const hourData = hourSource.map((count, hour) => ({ hour: `${hour}${t("analytics.hourSuffix")}`, count }));
const dayData = (summary?.byDay ?? []).map((count, dow) => ({ day: DAY_NAMES[dow], count }));
const noShowPct = Math.round((advanced?.noShowRate ?? 0) * 100);
const noShowData = [
{ name: t("analytics.noShowServed"), value: 100 - noShowPct },
{ name: t("analytics.noShowAbsent"), value: noShowPct },
];
const waitTrendData = (advanced?.avgWaitByDay ?? []).map((d) => ({
date: d.date.slice(5),
avgWaitMinutes: d.avgWaitMinutes,
count: d.count,
}));
const advancedPeakHour = advanced?.peakHour ?? -1;
const advancedBusiestDay = advanced?.busiestDayOfWeek ?? -1;
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">{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" />}
{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">{t("analytics.period")}</span>
{[7, 30, 90, 365].map((d) => (
<button
key={d}
onClick={() => setDays(d)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-all ${
days === d
? "bg-teal-600 text-white border-teal-600"
: "bg-white border-slate-200 text-slate-600 hover:border-emerald-400"
}`}
>
{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">{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="">{t("analytics.allClinics")}</option>
{clinics.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
</div>
{summaryQuery.isLoading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 text-emerald-500 animate-spin" />
</div>
) : (
<>
{/* KPI cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{[
{
label: t("analytics.kpiAvgWait"),
value: `${summary?.avgWaitMinutes ?? 0} ${t("analytics.minShort")}`,
icon: Clock,
color: "from-violet-500 to-purple-500",
},
{
label: t("analytics.kpiNoShowRate"),
value: `${noShowPct}%`,
icon: Calendar,
color: "from-orange-500 to-amber-500",
},
{
label: t("analytics.kpiPeakHour"),
value: advancedPeakHour >= 0 ? `${advancedPeakHour}${t("analytics.hourSuffix")}` : "—",
icon: TrendingUp,
color: "from-emerald-500 to-teal-500",
},
{
label: t("analytics.kpiBusiestDay"),
value: advancedBusiestDay >= 0 ? DAY_NAMES[advancedBusiestDay] : "—",
icon: Activity,
color: "from-cyan-500 to-blue-500",
},
].map((s) => {
const Icon = s.icon;
return (
<div key={s.label} className="glass-card rounded-2xl p-4">
<div className={`w-9 h-9 rounded-xl bg-gradient-to-br ${s.color} flex items-center justify-center mb-3 shadow-md`}>
<Icon className="w-4 h-4 text-white" />
</div>
<div className="font-bold text-xl text-slate-900">{s.value}</div>
<div className="text-xs text-slate-500">{s.label}</div>
</div>
);
})}
</div>
{/* Recommendations */}
{summary && summary.recommendations.length > 0 && (
<div className="glass-card rounded-2xl p-6 mb-6 border-2 border-emerald-200/60">
<div className="flex items-start gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-emerald-500 to-cyan-500 flex items-center justify-center shadow-md flex-shrink-0">
<Sparkles className="w-5 h-5 text-white" />
</div>
<div>
<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">
{summary.recommendations.map((r, i) => (
<li key={i} className="flex items-start gap-3 p-3 rounded-xl bg-emerald-50/60 border border-emerald-100">
<span className="w-6 h-6 rounded-full bg-emerald-500 text-white flex items-center justify-center text-xs font-bold flex-shrink-0 mt-0.5">{i + 1}</span>
<span className="text-sm text-slate-700">{r}</span>
</li>
))}
</ul>
</div>
)}
{/* Charts */}
<div className="grid lg:grid-cols-2 gap-6 mb-6">
<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" />
{t("analytics.chartByHour")}
</h3>
<ResponsiveContainer width="100%" height={280}>
<BarChart data={hourData}>
<defs>
<linearGradient id="barGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#10b981" stopOpacity={0.95} />
<stop offset="100%" stopColor="#06b6d4" stopOpacity={0.85} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" vertical={false} />
<XAxis dataKey="hour" stroke="#94a3b8" fontSize={11} tickLine={false} axisLine={false} />
<YAxis stroke="#94a3b8" fontSize={11} tickLine={false} axisLine={false} />
<Tooltip
contentStyle={{ borderRadius: "12px", border: "1px solid #e2e8f0", background: "rgba(255,255,255,0.95)", backdropFilter: "blur(8px)" }}
/>
<Bar dataKey="count" radius={[6, 6, 0, 0]} fill="url(#barGrad)" />
</BarChart>
</ResponsiveContainer>
{advancedPeakHour >= 0 && (
<p className="text-xs text-slate-500 mt-3">
{t("analytics.peakHour")} <strong className="text-emerald-700">{advancedPeakHour}{t("analytics.hourSuffix")}</strong>
</p>
)}
</div>
<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-cyan-600" />
{t("analytics.chartWaitTrend")}
</h3>
<ResponsiveContainer width="100%" height={280}>
<AreaChart data={waitTrendData}>
<defs>
<linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#06b6d4" stopOpacity={0.5} />
<stop offset="100%" stopColor="#06b6d4" stopOpacity={0.05} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" vertical={false} />
<XAxis dataKey="date" stroke="#94a3b8" fontSize={11} tickLine={false} axisLine={false} />
<YAxis stroke="#94a3b8" fontSize={11} tickLine={false} axisLine={false} />
<Tooltip
contentStyle={{ borderRadius: "12px", border: "1px solid #e2e8f0", background: "rgba(255,255,255,0.95)" }}
formatter={(v: number) => [`${v} ${t("analytics.minShort")}`, t("analytics.kpiAvgWait")]}
/>
<Area type="monotone" dataKey="avgWaitMinutes" stroke="#06b6d4" strokeWidth={2.5} fill="url(#areaGrad)" />
</AreaChart>
</ResponsiveContainer>
{waitTrendData.length === 0 && (
<p className="text-xs text-slate-400 mt-3 text-center">{t("analytics.noTrendData")}</p>
)}
</div>
<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" />
{t("analytics.chartNoShow")}
</h3>
<ResponsiveContainer width="100%" height={280}>
<PieChart>
<Pie
data={noShowData}
cx="50%" cy="50%"
innerRadius={60} outerRadius={100}
paddingAngle={4}
dataKey="value"
label={(entry) => `${entry.name}: ${entry.value}%`}
>
<Cell fill="#10b981" />
<Cell fill="#f97316" />
</Pie>
<Tooltip
contentStyle={{ borderRadius: "12px", border: "1px solid #e2e8f0", background: "rgba(255,255,255,0.95)" }}
formatter={(v: number) => [`${v}%`, ""]}
/>
<Legend verticalAlign="bottom" height={28} iconType="circle" />
</PieChart>
</ResponsiveContainer>
</div>
<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-orange-600" />
{t("analytics.chartByDay")}
</h3>
<ResponsiveContainer width="100%" height={280}>
<BarChart data={dayData}>
<defs>
<linearGradient id="dayBarGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#0d9488" stopOpacity={0.95} />
<stop offset="100%" stopColor="#22d3ee" stopOpacity={0.85} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" vertical={false} />
<XAxis dataKey="day" stroke="#94a3b8" fontSize={11} tickLine={false} axisLine={false} />
<YAxis stroke="#94a3b8" fontSize={11} tickLine={false} axisLine={false} />
<Tooltip
contentStyle={{ borderRadius: "12px", border: "1px solid #e2e8f0", background: "rgba(255,255,255,0.95)" }}
/>
<Bar dataKey="count" radius={[6, 6, 0, 0]} fill="url(#dayBarGrad)" />
</BarChart>
</ResponsiveContainer>
{advancedBusiestDay >= 0 && (
<p className="text-xs text-slate-500 mt-3">
{t("analytics.peakDay")} <strong className="text-cyan-700">{DAY_NAMES[advancedBusiestDay]}</strong>
</p>
)}
</div>
</div>
</>
)}
</div>
);
}

View file

@ -1,357 +0,0 @@
/**
* ClinicSettings Paramètres enrichis du cabinet
* 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";
import PractitionerManager from "@/components/PractitionerManager";
import { toast } from "sonner";
import {
Settings, Clock, Globe, MessageSquare, Timer, Users, Save,
Loader2, ChevronDown, ChevronUp, Stethoscope
} from "lucide-react";
const LANGUAGES = [
{ code: "fr", label: "Français", flag: "🇫🇷" },
{ code: "en", label: "English", flag: "🇬🇧" },
{ code: "pt", label: "Português", flag: "🇧🇷" },
{ code: "es", label: "Español", flag: "🇪🇸" },
{ code: "ar", label: "العربية", flag: "🇸🇦" },
];
type DaySchedule = { open: string; close: string; closed: boolean };
type OpeningHours = Record<string, DaySchedule>;
const DEFAULT_HOURS: OpeningHours = {
mon: { open: "08:00", close: "18:00", closed: false },
tue: { open: "08:00", close: "18:00", closed: false },
wed: { open: "08:00", close: "18:00", closed: false },
thu: { open: "08:00", close: "18:00", closed: false },
fri: { open: "08:00", close: "18:00", closed: false },
sat: { open: "09:00", close: "12:00", closed: true },
sun: { open: "09:00", close: "12:00", closed: true },
};
export default function ClinicSettings() {
const { t } = useTranslation();
const { user } = useAuth();
const [selectedClinicId, setSelectedClinicId] = useState<number | null>(null);
const [welcomeMessage, setWelcomeMessage] = useState("");
const [openingHours, setOpeningHours] = useState<OpeningHours>(DEFAULT_HOURS);
const [patientLanguage, setPatientLanguage] = useState("fr");
const [autoAbsentMinutes, setAutoAbsentMinutes] = useState(0);
const [avgConsultationMinutes, setAvgConsultationMinutes] = useState(15);
const [maxQueueSize, setMaxQueueSize] = useState(50);
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;
const { data: settings, isLoading } = trpc.clinicSettings.get.useQuery(
{ clinicId },
{ enabled: clinicId > 0 }
);
const updateMutation = trpc.clinicSettings.update.useMutation({
onSuccess: () => {
toast.success(t("clinicSettings.toastSaved"));
setHasChanges(false);
},
onError: (err) => toast.error(err.message),
});
// Load settings into form when data arrives
useEffect(() => {
if (settings) {
setWelcomeMessage(settings.welcomeMessage ?? "");
setOpeningHours(settings.openingHours ?? DEFAULT_HOURS);
setPatientLanguage(settings.patientLanguage ?? "fr");
setAutoAbsentMinutes(settings.autoAbsentMinutes ?? 0);
setAvgConsultationMinutes(settings.avgConsultationMinutes ?? 15);
setMaxQueueSize(settings.maxQueueSize ?? 50);
setHasChanges(false);
}
}, [settings]);
const handleSave = () => {
if (clinicId <= 0) return;
updateMutation.mutate({
clinicId,
welcomeMessage,
openingHours,
patientLanguage: patientLanguage as "fr" | "en" | "ar" | "pt" | "es",
autoAbsentMinutes,
avgConsultationMinutes,
maxQueueSize,
});
};
const updateHours = (dayKey: string, field: keyof DaySchedule, value: string | boolean) => {
setOpeningHours((prev) => ({
...prev,
[dayKey]: { ...prev[dayKey], [field]: value },
}));
setHasChanges(true);
};
if (!user) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<Loader2 className="w-8 h-8 animate-spin text-emerald-400" />
</div>
);
}
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">
<div className="w-10 h-10 rounded-xl bg-emerald-500/20 flex items-center justify-center">
<Settings className="w-5 h-5 text-emerald-400" />
</div>
<div>
<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>
{clinicsList.length > 1 && (
<select
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>
))}
</select>
)}
</div>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-emerald-400" />
</div>
) : (
<>
{/* Message de bienvenue */}
<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">{t("clinicSettings.welcomeMessage")}</h2>
</div>
<p className="text-xs text-muted-foreground">
{t("clinicSettings.welcomeMessageHelp")}
</p>
<textarea
value={welcomeMessage}
onChange={(e) => { setWelcomeMessage(e.target.value); setHasChanges(true); }}
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"
/>
<p className="text-xs text-muted-foreground text-right">{welcomeMessage.length}/500</p>
</div>
{/* Langue patient */}
<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">{t("clinicSettings.patientLanguage")}</h2>
</div>
<p className="text-xs text-muted-foreground">
{t("clinicSettings.patientLanguageHelp")}
</p>
<div className="flex flex-wrap gap-2">
{LANGUAGES.map((lang) => (
<button
key={lang.code}
onClick={() => { setPatientLanguage(lang.code); setHasChanges(true); }}
className={`flex items-center gap-2 px-4 py-2 rounded-xl border text-sm transition-all ${
patientLanguage === lang.code
? "border-emerald-500/50 bg-emerald-500/10 text-emerald-300"
: "border-border/50 bg-background/30 text-muted-foreground hover:border-border"
}`}
>
<span className="text-lg">{lang.flag}</span>
<span>{lang.label}</span>
</button>
))}
</div>
</div>
{/* Horaires d'ouverture */}
<div className="bg-card/50 border border-border/50 rounded-xl p-5 space-y-3">
<button
onClick={() => setShowHours(!showHours)}
className="flex items-center justify-between w-full"
>
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-amber-400" />
<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">
{t("clinicSettings.openingHoursHelp")}
</p>
{showHours && (
<div className="space-y-2 mt-2">
{DAYS.map(({ key, label }) => {
const day = openingHours[key] ?? { open: "08:00", close: "18:00", closed: false };
return (
<div key={key} className="flex items-center gap-3 py-1.5">
<div className="w-24 text-sm text-foreground">{label}</div>
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={!day.closed}
onChange={(e) => updateHours(key, "closed", !e.target.checked)}
className="rounded border-border accent-emerald-500"
/>
<span className="text-xs text-muted-foreground">{day.closed ? t("clinicSettings.closed") : t("clinicSettings.open")}</span>
</label>
{!day.closed && (
<>
<input
type="time"
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">{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")}
/>
</>
)}
</div>
);
})}
</div>
)}
</div>
{/* Paramètres file d'attente */}
<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">{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" /> {t("clinicSettings.avgConsultation")}
</label>
<div className="flex items-center gap-2">
<input
type="number"
min={1}
max={120}
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">{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" /> {t("clinicSettings.maxQueueSize")}
</label>
<div className="flex items-center gap-2">
<input
type="number"
min={1}
max={200}
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">{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" /> {t("clinicSettings.autoAbsentTimer")}
</label>
<div className="flex items-center gap-2">
<input
type="number"
min={0}
max={30}
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">{t("clinicSettings.minShort")}</span>
</div>
<p className="text-xs text-muted-foreground">
{autoAbsentMinutes === 0
? t("clinicSettings.autoAbsentDisabled")
: t("clinicSettings.autoAbsentEnabled", { minutes: autoAbsentMinutes })}
</p>
</div>
</div>
</div>
{/* Save button */}
<div className="flex justify-end">
<Button
onClick={handleSave}
disabled={!hasChanges || updateMutation.isPending}
className="bg-emerald-600 hover:bg-emerald-700 text-white px-6"
>
{updateMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<Save className="w-4 h-4 mr-2" />
)}
{t("clinicSettings.saveButton")}
</Button>
</div>
{/* Praticiens (multi-praticiens) */}
{clinicId > 0 && <PractitionerManager clinicId={clinicId} />}
</>
)}
</div>
);
}

View file

@ -1,371 +0,0 @@
/**
* ConsultationHistory Historique des consultations par cabinet
* 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";
import {
History, Calendar, Filter, ChevronLeft, ChevronRight,
Users, Clock, CheckCircle2, AlertTriangle, Loader2,
Stethoscope, FileText, Briefcase, ClipboardList, HelpCircle, Heart, XCircle
} from "lucide-react";
export default function ConsultationHistory() {
const { t, i18n } = useTranslation();
const { user } = useAuth();
const [selectedClinicId, setSelectedClinicId] = useState<number | null>(null);
const [page, setPage] = useState(1);
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
const [filterReason, setFilterReason] = useState("");
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
const clinicId = selectedClinicId ?? clinicsList[0]?.id ?? 0;
const { data: historyData, isLoading: historyLoading } = trpc.history.list.useQuery(
{
clinicId,
page,
perPage,
dateFrom: dateFrom || undefined,
dateTo: dateTo || undefined,
visitReason: filterReason || undefined,
},
{ enabled: clinicId > 0 }
);
const { data: statsData, isLoading: statsLoading } = trpc.history.stats.useQuery(
{ clinicId, days: statsDays },
{ enabled: clinicId > 0 }
);
const totalPages = useMemo(() => {
if (!historyData) return 1;
return Math.max(1, Math.ceil(Number(historyData.total) / perPage));
}, [historyData]);
const clearFilters = () => {
setDateFrom("");
setDateTo("");
setFilterReason("");
setPage(1);
};
if (!user) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<Loader2 className="w-8 h-8 animate-spin text-emerald-400" />
</div>
);
}
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">
<div className="w-10 h-10 rounded-xl bg-emerald-500/20 flex items-center justify-center">
<History className="w-5 h-5 text-emerald-400" />
</div>
<div>
<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>
{/* Clinic selector */}
{clinicsList.length > 1 && (
<select
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>
))}
</select>
)}
</div>
{/* Stats cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<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">{t("history.totalConsultations")}</span>
</div>
<p className="text-2xl font-bold text-foreground">
{statsLoading ? "—" : statsData?.totalConsultations ?? 0}
</p>
<div className="flex items-center gap-2 mt-2">
<select
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}>{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>
<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">{t("history.avgDuration")}</span>
</div>
<p className="text-2xl font-bold text-foreground">
{statsLoading ? "—" : `${statsData?.avgDurationMinutes ?? 0} ${t("history.minShort")}`}
</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">{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">{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">{t("history.topReason")}</span>
</div>
<p className="text-lg font-bold text-foreground truncate">
{statsLoading ? "—" : (
statsData?.topReasons?.[0]
? VISIT_REASONS[statsData.topReasons[0].reason]?.label ?? statsData.topReasons[0].reason
: "—"
)}
</p>
{statsData?.topReasons?.[0] && (
<p className="text-xs text-muted-foreground mt-2">
{t("history.consultationsCount", { count: statsData.topReasons[0].count })}
</p>
)}
</div>
</div>
{/* 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">{t("history.reasonsBreakdown")}</h3>
<div className="flex flex-wrap gap-2">
{statsData.topReasons.map((r) => {
const info = VISIT_REASONS[r.reason];
const pct = statsData.totalConsultations > 0
? Math.round((r.count / statsData.totalConsultations) * 100)
: 0;
return (
<div key={r.reason} className="flex items-center gap-2 bg-background/50 rounded-lg px-3 py-1.5">
{info && <info.icon className={`w-3.5 h-3.5 ${info.color}`} />}
<span className="text-xs text-foreground">{info?.label ?? r.reason}</span>
<span className="text-xs text-muted-foreground">{r.count} ({pct}%)</span>
</div>
);
})}
</div>
</div>
)}
{/* Filters */}
<div className="bg-card/50 border border-border/50 rounded-xl p-4">
<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" />{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" />{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" />{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="">{t("history.allReasons")}</option>
{Object.entries(VISIT_REASONS).map(([key, { label }]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
</div>
{(dateFrom || dateTo || filterReason) && (
<Button variant="ghost" size="sm" onClick={clearFilters} className="text-muted-foreground">
<XCircle className="w-4 h-4 mr-1" /> {t("history.clear")}
</Button>
)}
</div>
</div>
{/* Table */}
<div className="bg-card/50 border border-border/50 rounded-xl overflow-hidden">
{historyLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-emerald-400" />
</div>
) : !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">{t("history.noResults")}</p>
{(dateFrom || dateTo || filterReason) && (
<p className="text-xs text-muted-foreground mt-1">{t("history.tryEditingFilters")}</p>
)}
</div>
) : (
<div className="overflow-x-auto">
<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">{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>
{historyData.entries.map((entry) => {
const reasonInfo = VISIT_REASONS[entry.visitReason ?? "consultation"];
const statusInfo = STATUS_LABELS[entry.status] ?? { label: entry.status, color: "bg-gray-500/20 text-gray-300" };
const waitMin = entry.calledAt && entry.joinedAt
? Math.round((new Date(entry.calledAt).getTime() - new Date(entry.joinedAt).getTime()) / 60000)
: null;
const durationMin = entry.consultationStartedAt && entry.consultationEndAt
? Math.round((new Date(entry.consultationEndAt).getTime() - new Date(entry.consultationStartedAt).getTime()) / 60000)
: null;
const ReasonIcon = reasonInfo?.icon ?? HelpCircle;
return (
<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 || t("history.anonymous")}</span>
{entry.visitNote && (
<p className="text-xs text-muted-foreground italic truncate max-w-[200px]">{entry.visitNote}</p>
)}
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center gap-1.5 text-xs ${reasonInfo?.color ?? "text-gray-400"}`}>
<ReasonIcon className="w-3.5 h-3.5" />
{reasonInfo?.label ?? entry.visitReason}
</span>
</td>
<td className="px-4 py-3 text-muted-foreground">
{new Date(entry.joinedAt).toLocaleDateString(dateLocale, { day: "2-digit", month: "short", year: "numeric" })}
<br />
<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} ${t("history.minShort")}` : "—"}
</td>
<td className="px-4 py-3 text-muted-foreground">
{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}`}>
{statusInfo.label}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{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">
{t("history.pageInfo", { page, totalPages, total: Number(historyData?.total ?? 0) })}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
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>
<Button
variant="outline"
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>
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -1,206 +0,0 @@
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,
AlertTriangle,
} from "lucide-react";
import { trpc } from "@/lib/trpc";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/_core/hooks/useAuth";
import { cn } from "@/lib/utils";
export default function Dashboard() {
const { t } = useTranslation();
const [, navigate] = useLocation();
const { user } = useAuth();
const clinicsQuery = trpc.clinic.list.useQuery();
const subQuery = trpc.subscription.check.useQuery();
const summaryQuery = trpc.analytics.summary.useQuery({ days: 7 });
const clinics = clinicsQuery.data ?? [];
const sub = subQuery.data;
const summary = summaryQuery.data;
const isTrialing = sub?.status === "trialing";
const trialDaysLeft = sub?.daysRemaining ?? 0;
const subExpired = !sub?.active;
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">
{t("dashboard.hello")}, <span className="gradient-text">{greeting}</span>
</h1>
<p className="text-slate-600">{t("dashboard.dayStarts")}</p>
</div>
{isTrialing && (
<div
className={cn(
"px-4 py-2 rounded-xl border text-sm font-semibold flex items-center gap-2",
trialDaysLeft > 7
? "bg-emerald-50 border-emerald-200 text-emerald-700"
: "bg-orange-50 border-orange-200 text-orange-700"
)}
>
<Sparkles className="w-4 h-4" />
{trialDaysLeft > 0
? t("dashboard.trialDaysLeft", { count: trialDaysLeft })
: t("dashboard.trialExpired")}
{trialDaysLeft <= 7 && (
<button
onClick={() => navigate("/dashboard/subscription")}
className="ml-1 underline underline-offset-2"
>
{t("dashboard.subscribe")}
</button>
)}
</div>
)}
{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" />
{t("dashboard.subscriptionExpired")}
<button onClick={() => navigate("/dashboard/subscription")} className="ml-1 underline">{t("dashboard.renew")}</button>
</div>
)}
</div>
{/* KPIs */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
{[
{ 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 (
<div key={stat.label} className="glass-card rounded-2xl p-5">
<div className={`w-10 h-10 rounded-xl bg-gradient-to-br ${stat.color} flex items-center justify-center mb-4 shadow-md`}>
<Icon className="w-5 h-5 text-white" />
</div>
<div className="font-bold text-2xl text-slate-900 capitalize">{stat.value}</div>
<div className="text-slate-500 text-xs mt-1">{stat.label}</div>
</div>
);
})}
</div>
{/* Clinics */}
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<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" /> {t("dashboard.manage")}
</Button>
</div>
{clinicsQuery.isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 text-emerald-500 animate-spin" />
</div>
) : clinics.length === 0 ? (
<div className="glass-card rounded-3xl p-10 text-center">
<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">{t("dashboard.welcomeTitle")}</h3>
<p className="text-slate-600 text-sm mb-6 max-w-sm mx-auto">
{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" /> {t("dashboard.startSetup")}
</Button>
<Button variant="outline" onClick={() => navigate("/dashboard/clinics")}>
<Plus className="w-4 h-4 mr-2" /> {t("dashboard.createManually")}
</Button>
</div>
</div>
) : (
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
{clinics.map((clinic) => (
<button
key={clinic.id}
onClick={() => navigate(`/dashboard/queue/${clinic.id}`)}
className="glass-card rounded-2xl p-5 text-left hover:shadow-xl transition-all group"
>
<div className="flex items-start justify-between mb-4">
<div
className="w-11 h-11 rounded-xl flex items-center justify-center shadow-sm"
style={{
backgroundColor: `${clinic.color ?? "#10b981"}20`,
border: `1px solid ${clinic.color ?? "#10b981"}50`,
}}
>
<Building2 className="w-5 h-5" style={{ color: clinic.color ?? "#0d9488" }} />
</div>
<span
className={cn(
"px-2.5 py-0.5 rounded-full text-xs font-semibold border",
clinic.isQueueOpen
? "bg-emerald-100 text-emerald-700 border-emerald-200"
: "bg-slate-100 text-slate-500 border-slate-200"
)}
>
{clinic.isQueueOpen ? t("dashboard.statusOpen") : t("dashboard.statusClosed")}
</span>
</div>
<h3 className="font-bold mb-1 text-slate-900">{clinic.name}</h3>
{clinic.address && (
<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} {t("dashboard.minPerPatient")}</span>
<ChevronRight className="w-4 h-4 text-slate-400 group-hover:text-emerald-600 transition-colors" />
</div>
</button>
))}
</div>
)}
</div>
{/* Quick links */}
<div>
<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: 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 (
<button
key={item.label}
onClick={() => navigate(item.path)}
className="glass-card rounded-2xl p-5 text-left hover:shadow-xl transition-all group"
>
<div className={`w-10 h-10 rounded-xl bg-gradient-to-br ${item.color} flex items-center justify-center mb-3 shadow-md`}>
<Icon className="w-5 h-5 text-white" />
</div>
<div className="font-bold text-sm mb-1 text-slate-900">{item.label}</div>
<div className="text-slate-500 text-xs">{item.desc}</div>
</button>
);
})}
</div>
</div>
</div>
);
}

View file

@ -1,293 +0,0 @@
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";
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();
const [connected, setConnected] = useState(false);
const [now, setNow] = useState(new Date());
const queueQuery = trpc.queue.getPublic.useQuery(
{ clinicId },
{ enabled: !!clinicId, refetchInterval: 30_000 }
);
const membersQuery = trpc.clinic.listMembersPublic.useQuery(
{ clinicId },
{ enabled: !!clinicId, staleTime: 60_000 }
);
// Tick clock
useEffect(() => {
const t = setInterval(() => setNow(new Date()), 30_000);
return () => clearInterval(t);
}, []);
// Socket
useEffect(() => {
if (!clinicId) return;
const s = getSocket();
s.emit("display:subscribe", clinicId);
setConnected(s.connected);
const onConnect = () => setConnected(true);
const onDisconnect = () => setConnected(false);
const onUpdate = () => utils.queue.getPublic.invalidate({ clinicId });
s.on("connect", onConnect);
s.on("disconnect", onDisconnect);
s.on("queue:update", onUpdate);
return () => {
s.emit("display:unsubscribe", clinicId);
s.off("connect", onConnect);
s.off("disconnect", onDisconnect);
s.off("queue:update", onUpdate);
};
}, [clinicId, utils]);
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>
);
}
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">{t("display.clinicNotFound")}</h1>
<p className="text-slate-500">{t("display.clinicNotFoundDesc")}</p>
</div>
</div>
);
}
const { clinic, queue, callingNow, waitingCount } = queueQuery.data;
const upcoming = queue.filter((e) => e.status === "waiting").slice(0, 5);
const accent = clinic.color ?? "#10b981";
const calledPractitionerId =
(callingNow as { practitionerId?: number | null } | null)?.practitionerId ?? null;
const calledPractitioner =
calledPractitionerId && membersQuery.data
? membersQuery.data.find((m) => m.userId === calledPractitionerId) ?? null
: null;
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" />
<div className="absolute -bottom-40 -right-40 w-[40rem] h-[40rem] rounded-full bg-cyan-300/30 blur-3xl animate-pulse-glow" style={{ animationDelay: "1.5s" }} />
</div>
{/* Header */}
<header className="relative z-10 flex items-center justify-between px-10 py-6 border-b border-emerald-100/60 backdrop-blur-md bg-white/40">
<div className="flex items-center gap-3">
<div
className="w-12 h-12 rounded-2xl flex items-center justify-center shadow-md"
style={{ background: `linear-gradient(135deg, ${accent}, #06b6d4)` }}
>
<Stethoscope className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="font-bold text-2xl text-slate-900">{clinic.name}</h1>
<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(dateLocale, { hour: "2-digit", minute: "2-digit" })}
</div>
<div className="text-xs text-slate-500 capitalize">
{now.toLocaleDateString(dateLocale, { weekday: "long", day: "numeric", month: "long" })}
</div>
</div>
<div
className={`px-3 py-1.5 rounded-full text-xs font-bold flex items-center gap-1.5 ${
connected ? "bg-emerald-100 text-emerald-700" : "bg-red-100 text-red-700"
}`}
>
{connected ? <Wifi className="w-3 h-3" /> : <WifiOff className="w-3 h-3" />}
{connected ? t("display.live") : t("display.reconnecting")}
</div>
</div>
</header>
{/* Main */}
<main className="relative z-10 grid lg:grid-cols-2 gap-8 p-10 min-h-[calc(100vh-200px)]">
{/* Calling now (huge) */}
<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">
{t("display.patientCalled")}
</div>
<AnimatePresence mode="wait">
{callingNow ? (
<motion.div
key={callingNow.ticketNumber}
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 1.2, opacity: 0 }}
transition={{ type: "spring", damping: 15 }}
className=""
>
<div
className="font-black leading-none mb-4"
style={{
fontSize: "clamp(8rem, 22vw, 18rem)",
background: `linear-gradient(135deg, ${accent}, #06b6d4)`,
WebkitBackgroundClip: "text",
backgroundClip: "text",
WebkitTextFillColor: "transparent",
}}
>
{formatTicket(callingNow.ticketNumber)}
</div>
{callingNow.patientName && (
<div className="text-3xl font-bold text-slate-700 mt-4">
{callingNow.patientName}
</div>
)}
{calledPractitioner && (
<div className="mt-5 inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white/80 border border-slate-200 shadow-sm">
<span
className="w-3 h-3 rounded-full ring-2 ring-white"
style={{ backgroundColor: calledPractitioner.color ?? "#10b981" }}
aria-hidden="true"
/>
<span className="text-base font-bold text-slate-800">
{calledPractitioner.displayName ?? t("display.practitionerFallback")}
</span>
</div>
)}
<motion.div
animate={{ scale: [1, 1.05, 1] }}
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"
>
{t("display.consultationRoom")}
</motion.div>
</motion.div>
) : (
<motion.div
key="empty"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="py-20"
>
<Users className="w-32 h-32 text-slate-200 mx-auto mb-6" />
<div className="text-3xl font-bold text-slate-400">
{clinic.isQueueOpen ? t("display.noPatientCalled") : t("display.queueClosed")}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* Upcoming */}
<div className="flex flex-col gap-4">
<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">{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 ? t("display.statusOpen") : t("display.statusClosed")}
</div>
</div>
<div className="space-y-3">
<AnimatePresence>
{upcoming.length === 0 ? (
<div className="text-center py-12 text-slate-400">
{t("display.noWaiting")}
</div>
) : (
upcoming.map((e, i) => (
<motion.div
key={e.id}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ delay: i * 0.05 }}
className={`flex items-center gap-4 p-4 rounded-2xl border-2 ${
i === 0
? "bg-gradient-to-r from-emerald-50 to-cyan-50 border-emerald-300"
: "bg-white/50 border-slate-100"
}`}
>
<div
className={`w-16 h-16 rounded-xl flex items-center justify-center font-bold text-2xl flex-shrink-0 ${
i === 0
? "bg-gradient-to-br from-emerald-500 to-cyan-500 text-white shadow-md"
: "bg-slate-100 text-slate-700"
}`}
>
{formatTicket(e.ticketNumber)}
</div>
<div className="flex-1 min-w-0">
<div className="font-bold text-slate-900 truncate">
{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 ?? "?"} {t("display.minShort")}
<span>·</span>
<span>{t("display.position")} {e.position}</span>
</div>
</div>
{i === 0 && (
<div className="text-xs uppercase tracking-wider font-bold text-emerald-700">
{t("display.nextLabel")}
</div>
)}
</motion.div>
))
)}
</AnimatePresence>
</div>
</div>
</div>
</main>
{/* 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">
{t("display.ticker", { clinic: clinic.name })}
</div>
</footer>
</div>
);
}

View file

@ -1,432 +0,0 @@
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,
} from "lucide-react";
import { trpc } from "@/lib/trpc";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from "@/components/ui/dialog";
import { toast } from "sonner";
interface ClinicForm {
name: string;
address: string;
phone: string;
color: string;
avgConsultationMinutes: number;
maxQueueSize: number;
qrRotationMinutes: number;
}
const EMPTY_FORM: ClinicForm = {
name: "",
address: "",
phone: "",
color: "#10b981",
avgConsultationMinutes: 15,
maxQueueSize: 50,
qrRotationMinutes: 30,
};
export default function DoctorClinics() {
const { t } = useTranslation();
const [, navigate] = useLocation();
const utils = trpc.useUtils();
const clinicsQuery = trpc.clinic.list.useQuery();
const [editing, setEditing] = useState<{ id: number | null; form: ClinicForm } | null>(null);
const [qrFor, setQrFor] = useState<number | null>(null);
const [confirmDelete, setConfirmDelete] = useState<number | null>(null);
const createMutation = trpc.clinic.create.useMutation({
onSuccess: () => {
toast.success(t("clinics.toastCreated"));
utils.clinic.list.invalidate();
setEditing(null);
},
onError: (e) => toast.error(e.message),
});
const updateMutation = trpc.clinic.update.useMutation({
onSuccess: () => {
toast.success(t("clinics.toastUpdated"));
utils.clinic.list.invalidate();
setEditing(null);
},
onError: (e) => toast.error(e.message),
});
const deleteMutation = trpc.clinic.delete.useMutation({
onSuccess: () => {
toast.success(t("clinics.toastDeleted"));
utils.clinic.list.invalidate();
setConfirmDelete(null);
},
onError: (e) => toast.error(e.message),
});
const regenMutation = trpc.clinic.regenerateQr.useMutation({
onSuccess: () => {
toast.success(t("clinics.toastQrRegenerated"));
utils.clinic.qrDataUrl.invalidate();
},
onError: (e) => toast.error(e.message),
});
const toggleQueueMutation = trpc.clinic.update.useMutation({
onSuccess: () => utils.clinic.list.invalidate(),
onError: (e) => toast.error(e.message),
});
const clinics = clinicsQuery.data ?? [];
const submit = () => {
if (!editing) return;
const f = editing.form;
if (!f.name.trim() || f.name.trim().length < 2) {
toast.error(t("clinics.errorNameRequired"));
return;
}
if (editing.id) {
updateMutation.mutate({
id: editing.id,
name: f.name.trim(),
address: f.address.trim() || null,
phone: f.phone.trim() || null,
color: f.color,
avgConsultationMinutes: f.avgConsultationMinutes,
maxQueueSize: f.maxQueueSize,
qrRotationMinutes: f.qrRotationMinutes,
});
} else {
createMutation.mutate({
name: f.name.trim(),
address: f.address.trim() || undefined,
phone: f.phone.trim() || undefined,
color: f.color,
avgConsultationMinutes: f.avgConsultationMinutes,
maxQueueSize: f.maxQueueSize,
qrRotationMinutes: f.qrRotationMinutes,
});
}
};
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">{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" /> {t("clinics.newClinic")}
</Button>
</div>
{clinicsQuery.isLoading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 text-emerald-500 animate-spin" />
</div>
) : 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">{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" /> {t("clinics.createClinic")}
</Button>
</div>
) : (
<div className="grid lg:grid-cols-2 gap-5">
{clinics.map((c) => (
<div key={c.id} className="glass-card rounded-2xl p-6">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div
className="w-12 h-12 rounded-xl flex items-center justify-center shadow-sm"
style={{
backgroundColor: `${c.color ?? "#10b981"}20`,
border: `1px solid ${c.color ?? "#10b981"}50`,
}}
>
<Building2 className="w-6 h-6" style={{ color: c.color ?? "#0d9488" }} />
</div>
<div>
<h3 className="font-bold">{c.name}</h3>
{c.address && <div className="text-xs text-slate-500">{c.address}</div>}
</div>
</div>
<span
className={`px-2.5 py-0.5 rounded-full text-xs font-semibold border ${
c.isQueueOpen
? "bg-emerald-100 text-emerald-700 border-emerald-200"
: "bg-slate-100 text-slate-500 border-slate-200"
}`}
>
{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">{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">{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">{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" /> {t("clinics.manageQueue")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() =>
toggleQueueMutation.mutate({ id: c.id, isQueueOpen: !c.isQueueOpen })
}
disabled={toggleQueueMutation.isPending}
>
{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" /> {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" /> {t("clinics.screen")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() =>
setEditing({
id: c.id,
form: {
name: c.name,
address: c.address ?? "",
phone: c.phone ?? "",
color: c.color ?? "#10b981",
avgConsultationMinutes: c.avgConsultationMinutes ?? 15,
maxQueueSize: c.maxQueueSize ?? 50,
qrRotationMinutes: c.qrRotationMinutes ?? 30,
},
})
}
>
<Edit className="w-3.5 h-3.5 mr-1" /> {t("clinics.editAction")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setConfirmDelete(c.id)}
className="text-red-600 border-red-200 hover:bg-red-50"
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
))}
</div>
)}
{/* ─── Edit / Create dialog ─────────────────────────────── */}
<Dialog open={!!editing} onOpenChange={(open) => !open && setEditing(null)}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{editing?.id ? t("clinics.dialogEditTitle") : t("clinics.dialogCreateTitle")}</DialogTitle>
<DialogDescription>
{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">{t("clinics.fieldName")} *</Label>
<Input
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">{t("clinics.fieldAddress")}</Label>
<Input
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("clinics.fieldPhone")}</Label>
<Input
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">{t("clinics.fieldColor")}</Label>
<div className="flex items-center gap-3">
<input
type="color"
value={editing.form.color}
onChange={(e) => setEditing({ ...editing, form: { ...editing.form, color: e.target.value } })}
className="w-12 h-10 rounded-lg border border-slate-200 cursor-pointer"
/>
<span className="text-sm text-slate-500">{editing.form.color}</span>
</div>
</div>
<div>
<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}
onChange={(e) => setEditing({ ...editing, form: { ...editing.form, avgConsultationMinutes: Number(e.target.value) } })}
className="w-full accent-emerald-500"
/>
</div>
<div>
<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}
onChange={(e) => setEditing({ ...editing, form: { ...editing.form, maxQueueSize: Number(e.target.value) } })}
className="w-full accent-emerald-500"
/>
</div>
<div>
<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
key={v}
onClick={() => setEditing({ ...editing, form: { ...editing.form, qrRotationMinutes: v } })}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-all ${
editing.form.qrRotationMinutes === v
? "bg-teal-600 text-white border-teal-600"
: "bg-white border-slate-200 text-slate-600 hover:border-emerald-400"
}`}
>
{v === 0 ? t("clinics.qrDisabled") : v < 60 ? `${v} ${t("clinics.minutesShort")}` : `${v / 60}h`}
</button>
))}
</div>
</div>
</div>
)}
<DialogFooter>
<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 ? t("clinics.save") : t("clinics.create")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ─── QR dialog ────────────────────────────────────────── */}
<Dialog open={qrFor !== null} onOpenChange={(open) => !open && setQrFor(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("clinics.qrDialogTitle")}</DialogTitle>
<DialogDescription>
{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" /> {t("clinics.posterA4")}
</Button>
<Button variant="default" onClick={() => setQrFor(null)}>{t("clinics.closeButton")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ─── Delete confirm ───────────────────────────────────── */}
<Dialog open={confirmDelete !== null} onOpenChange={(open) => !open && setConfirmDelete(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("clinics.deleteDialogTitle")}</DialogTitle>
<DialogDescription>
{t("clinics.deleteDialogDescription")}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<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" />}
{t("clinics.deletePermanently")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
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 });
if (qrQuery.isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 text-emerald-500 animate-spin" />
</div>
);
}
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={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">
{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" />}
{t("clinics.regenerate")}
</Button>
</div>
);
}

View file

@ -1,104 +0,0 @@
import { useState } from "react";
import { Link } from "wouter";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { Stethoscope, Mail, ArrowLeft, Loader2, CheckCircle2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { trpc } from "@/lib/trpc";
export default function ForgotPassword() {
const { t } = useTranslation();
const [email, setEmail] = useState("");
const [submitted, setSubmitted] = useState(false);
const forgot = trpc.auth.forgotPassword.useMutation({
onSuccess: () => setSubmitted(true),
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
forgot.mutate({ email });
};
return (
<div className="min-h-screen flex items-center justify-center p-4 relative overflow-hidden">
<Helmet>
<title>{t("forgot.metaTitle")}</title>
<meta name="description" content={t("forgot.metaDescription")} />
</Helmet>
<div className="absolute inset-0 pointer-events-none overflow-hidden">
<div className="absolute top-1/4 left-1/4 w-96 h-96 rounded-full bg-emerald-300/30 blur-3xl" />
<div className="absolute bottom-1/4 right-1/4 w-[28rem] h-[28rem] rounded-full bg-cyan-300/30 blur-3xl" />
</div>
<div className="relative z-10 w-full max-w-md">
<Link href="/login">
<a className="inline-flex items-center gap-2 text-slate-500 hover:text-emerald-700 mb-6 text-sm">
<ArrowLeft className="w-4 h-4" />
{t("forgot.backToLogin")}
</a>
</Link>
<div className="text-center mb-8">
<div className="inline-flex items-center gap-2 mb-4">
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-emerald-500 to-cyan-500 flex items-center justify-center shadow-lg">
<Stethoscope className="w-6 h-6 text-white" />
</div>
</div>
<h1 className="font-bold text-3xl mb-2">
<span className="gradient-text">{t("forgot.title")}</span>
</h1>
<p className="text-slate-500 text-sm">{t("forgot.subtitle")}</p>
</div>
<div className="glass-card-strong rounded-3xl p-8">
{submitted ? (
<div className="text-center py-6">
<CheckCircle2 className="w-12 h-12 text-emerald-500 mx-auto mb-4" />
<h2 className="font-semibold text-lg mb-2">{t("forgot.successTitle")}</h2>
<p className="text-slate-600 text-sm">{t("forgot.successMessage")}</p>
<Link href="/login">
<a className="inline-block mt-6 text-teal-700 font-semibold hover:underline">
{t("forgot.backToLogin")}
</a>
</Link>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="email" className="mb-1.5 block">
{t("forgot.emailLabel")}
</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
id="email"
type="email"
placeholder={t("forgot.emailPlaceholder")}
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="pl-10"
autoComplete="email"
/>
</div>
</div>
<Button
type="submit"
variant="gradient"
size="lg"
className="w-full"
disabled={forgot.isPending}
>
{forgot.isPending && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
{t("forgot.submit")}
</Button>
</form>
)}
</div>
</div>
</div>
);
}

View file

@ -1,277 +0,0 @@
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,
HelpCircle, AlertCircle, Sparkles, BookOpen, QrCode,
Smartphone, Mail,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
interface FaqItem {
q: string;
a: string;
category: string;
}
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;
const CATEGORY_KEYS = ["all", "gettingStarted", "queueManagement", "patientExperience", "displayScreen", "subscription", "technical"] as const;
const CATEGORY_ICONS: Record<string, React.ElementType> = {
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<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 === "all" || item.category === activeCategory;
const matchTerm =
term === "" ||
item.q.toLowerCase().includes(term) ||
item.a.toLowerCase().includes(term);
return matchCat && matchTerm;
});
}, [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" />
<div className="absolute bottom-1/4 right-1/4 w-80 h-80 rounded-full bg-cyan-300/20 blur-3xl" />
</div>
<div className="relative z-10 max-w-3xl mx-auto px-4 py-12">
{/* Back */}
<button
onClick={() => window.history.length > 1 ? window.history.back() : navigate("/")}
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" />
{t("common.back")}
</button>
{/* Header */}
<div className="text-center mb-10">
<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">
<HelpCircle className="w-8 h-8 text-white" />
</div>
<h1 className="font-bold text-4xl mb-3">
{t("help.headerCenter")} <span className="gradient-text">{t("help.headerHelp")}</span>
</h1>
<p className="text-slate-500 text-lg">
{t("help.headerSubtitle")}
</p>
</div>
{/* Search bar */}
<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={t("help.searchPlaceholder")}
aria-label={t("help.searchPlaceholder")}
value={search}
onChange={(e) => {
setSearch(e.target.value);
setOpenIndex(null);
}}
className="pl-12 h-12 text-base bg-white/80"
/>
</div>
{/* Quick links */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
{quickLinks.map((item) => {
const Icon = item.icon;
const isActive = activeCategory === item.cat;
return (
<button
key={item.cat}
onClick={() => {
setActiveCategory(item.cat);
setOpenIndex(null);
}}
className={cn(
"glass-card rounded-2xl p-4 flex flex-col items-center gap-2 transition-all hover:shadow-md",
isActive && "border-emerald-400 shadow-md ring-2 ring-emerald-200"
)}
>
<Icon className={cn("w-6 h-6", isActive ? "text-emerald-600" : "text-slate-500")} />
<span className={cn("text-xs font-semibold", isActive ? "text-emerald-700" : "text-slate-700")}>
{item.label}
</span>
</button>
);
})}
</div>
{/* Category filter */}
<div className="flex gap-2 flex-wrap mb-6">
{CATEGORY_KEYS.map((cat) => (
<button
key={cat}
onClick={() => {
setActiveCategory(cat);
setOpenIndex(null);
}}
className={cn(
"px-4 py-1.5 rounded-full text-xs font-semibold border transition-all",
activeCategory === cat
? "bg-teal-600 text-white border-teal-600 shadow-md"
: "bg-white/80 border-slate-200 text-slate-600 hover:border-emerald-400 hover:text-emerald-700"
)}
>
{t(`help.categories.${cat}`)}
</button>
))}
</div>
{/* FAQ */}
{filtered.length === 0 ? (
<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">
{t("help.noResults")}
</p>
</div>
) : (
<div className="space-y-3">
{filtered.map((item, i) => {
const CatIcon = CATEGORY_ICONS[item.category] ?? BookOpen;
const isOpen = openIndex === i;
return (
<div
key={`${item.category}-${item.q}`}
className={cn(
"glass-card rounded-2xl overflow-hidden transition-all duration-200",
isOpen && "shadow-md ring-1 ring-emerald-200"
)}
>
<button
onClick={() => setOpenIndex(isOpen ? null : i)}
className="w-full flex items-center gap-4 p-5 text-left hover:bg-emerald-50/40 transition-colors"
>
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-emerald-500 to-cyan-500 flex items-center justify-center flex-shrink-0 shadow-sm">
<CatIcon className="w-4 h-4 text-white" />
</div>
<div className="flex-1 min-w-0">
<div className="text-[10px] uppercase tracking-wider text-emerald-700 font-bold mb-0.5">
{t(`help.categories.${item.category}`)}
</div>
<span className="font-semibold text-sm text-slate-900 leading-snug block">
{item.q}
</span>
</div>
{isOpen ? (
<ChevronUp className="w-4 h-4 text-slate-400 flex-shrink-0" />
) : (
<ChevronDown className="w-4 h-4 text-slate-400 flex-shrink-0" />
)}
</button>
{isOpen && (
<div className="px-5 pb-5">
<div className="ml-13 pl-1 text-sm text-slate-600 leading-relaxed border-t border-slate-200 pt-4">
{item.a}
</div>
</div>
)}
</div>
);
})}
</div>
)}
{/* Contact CTA */}
<div className="mt-12 glass-card-strong rounded-3xl p-8 text-center">
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-orange-400 to-amber-500 flex items-center justify-center mx-auto mb-4 shadow-md">
<AlertCircle className="w-6 h-6 text-white" />
</div>
<h3 className="font-bold text-xl mb-2 text-slate-900">
{t("help.contactTitle")}
</h3>
<p className="text-slate-500 text-sm mb-6 max-w-md mx-auto">
{t("help.contactBody")}
</p>
<div className="flex gap-3 justify-center flex-wrap">
<Button
variant="outline"
onClick={() => navigate("/dashboard")}
>
<Stethoscope className="w-4 h-4 mr-2" />
{t("help.dashboardButton")}
</Button>
<Button
variant="gradient"
onClick={() => window.open("mailto:support@queuemed.fr", "_blank")}
>
<Mail className="w-4 h-4 mr-2" />
{t("help.contactButton")}
</Button>
</div>
</div>
</div>
</div>
);
}

View file

@ -1,488 +0,0 @@
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,
Shield, Sparkles, ChevronRight, Check, Star, Heart,
} from "lucide-react";
import { Button } from "@/components/ui/button";
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">
<Link href="/">
<a className="flex items-center gap-2.5">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-emerald-500 to-cyan-500 flex items-center justify-center shadow-md">
<Stethoscope className="w-5 h-5 text-white" />
</div>
<span className="font-bold text-lg gradient-text">QueueMed</span>
</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">{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">{t("home.nav.login")}</Button>
</Link>
<Link href="/login">
<Button variant="gradient" size="sm" className="hidden sm:inline-flex">
{t("home.nav.freeTrial")} <ChevronRight className="w-3 h-3 ml-1" />
</Button>
</Link>
</div>
</div>
</nav>
{/* ─── Hero ───────────────────────────────────────────────────── */}
<section className="relative pt-20 pb-32 overflow-hidden">
{/* Animated gradient blobs */}
<div className="absolute inset-0 pointer-events-none">
<div className="absolute top-10 left-1/4 w-96 h-96 rounded-full bg-emerald-300/30 blur-3xl animate-pulse-glow" />
<div className="absolute top-32 right-1/4 w-[28rem] h-[28rem] rounded-full bg-cyan-300/30 blur-3xl animate-pulse-glow" style={{ animationDelay: "1s" }} />
<div className="absolute bottom-0 left-1/3 w-80 h-80 rounded-full bg-teal-300/20 blur-3xl animate-pulse-glow" style={{ animationDelay: "2s" }} />
</div>
<div className="container relative z-10">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
className="max-w-3xl mx-auto text-center"
>
<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" />
{t("home.heroBadge")}
</div>
<h1 className="font-bold text-5xl md:text-7xl tracking-tight leading-[1.05] mb-6">
<span className="gradient-text">{t("home.heroH1Part1")}</span>
<br />
{t("home.heroH1Part2")}
<br />
{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">
{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" />
{t("home.heroStartTrial")}
</Button>
</Link>
<a href="#how">
<Button variant="outline" size="xl" className="w-full sm:w-auto">
{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" /> {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>
{/* Hero mock */}
<motion.div
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.3 }}
className="mt-20 max-w-5xl mx-auto"
>
<div className="glass-card-strong rounded-3xl p-2 shadow-2xl">
<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">{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">{t("home.mockRoom")}</div>
</div>
<div className="space-y-3">
<div className="text-xs uppercase tracking-widest text-slate-500 font-bold mb-2">{t("home.mockUpcoming")}</div>
{[
{ 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">
<div className={`font-bold text-lg ${p.active ? "text-emerald-700" : "text-slate-700"}`}>{p.n}</div>
<div className="text-sm text-slate-600">{p.name}</div>
</div>
<div className="text-xs text-slate-500">{p.time}</div>
</div>
))}
</div>
</div>
</div>
</div>
</motion.div>
</div>
</section>
{/* ─── Features ───────────────────────────────────────────────── */}
<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">{t("home.featuresKicker")}</div>
<h2 className="font-bold text-4xl md:text-5xl mb-4 tracking-tight">
{t("home.featuresTitlePart1")} <span className="gradient-text">{t("home.featuresTitleAccent")}</span>
</h2>
<p className="text-lg text-slate-600">
{t("home.featuresSubtitle")}
</p>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{FEATURES.map((f, i) => {
const Icon = f.icon;
return (
<motion.div
key={f.key}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.4, delay: i * 0.05 }}
className="glass-card rounded-2xl p-6 hover:shadow-xl transition-shadow"
>
<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">{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>
);
})}
</div>
</div>
</section>
{/* ─── How it works ──────────────────────────────────────────── */}
<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">{t("home.howKicker")}</div>
<h2 className="font-bold text-4xl md:text-5xl mb-4 tracking-tight">
<span className="gradient-text">{t("home.howTitleAccent")}</span> {t("home.howTitleRest")}
</h2>
</div>
<div className="grid md:grid-cols-3 gap-8">
{STEPS.map((s, i) => (
<motion.div
key={s.num}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.4, delay: i * 0.1 }}
className="relative"
>
<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">{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" />
)}
</motion.div>
))}
</div>
</div>
</section>
{/* ─── Pricing ───────────────────────────────────────────────── */}
<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">{t("home.pricingKicker")}</div>
<h2 className="font-bold text-4xl md:text-5xl mb-4 tracking-tight">
{t("home.pricingTitlePart1")} <span className="gradient-text">{t("home.pricingTitleAccent")}</span>
</h2>
<p className="text-lg text-slate-600">
{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.key}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.4 }}
className={`relative rounded-3xl p-8 ${
p.highlighted
? "bg-gradient-to-br from-emerald-500 to-cyan-500 text-white shadow-2xl scale-105"
: "glass-card-strong"
}`}
>
{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">
{t("home.pricingPopular")}
</div>
)}
<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>
</div>
<ul className="space-y-3 mb-8">
{p.features.map((f) => (
<li key={f} className="flex items-start gap-2 text-sm">
<Check className={`w-4 h-4 flex-shrink-0 mt-0.5 ${p.highlighted ? "text-white" : "text-emerald-500"}`} />
<span className={p.highlighted ? "text-white" : "text-slate-700"}>{f}</span>
</li>
))}
</ul>
<Link href={p.href}>
<Button
size="lg"
className={`w-full ${
p.highlighted
? "bg-white text-emerald-700 hover:bg-emerald-50"
: "bg-teal-600 hover:bg-teal-700 text-white"
}`}
>
{p.cta}
</Button>
</Link>
</motion.div>
))}
</div>
</div>
</section>
{/* ─── Testimonials ──────────────────────────────────────────── */}
<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("home.testimonialsKicker")}</div>
<h2 className="font-bold text-4xl md:text-5xl mb-4 tracking-tight">
{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((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(`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">
{tm.avatar}
</div>
<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>
))}
</div>
</div>
</section>
{/* ─── CTA ───────────────────────────────────────────────────── */}
<section className="py-24">
<div className="container">
<div className="rounded-3xl bg-gradient-to-br from-emerald-500 via-teal-500 to-cyan-500 p-12 md:p-16 text-center text-white shadow-2xl relative overflow-hidden">
<div className="absolute inset-0 opacity-20">
<div className="absolute top-0 left-0 w-96 h-96 rounded-full bg-white/20 blur-3xl" />
<div className="absolute bottom-0 right-0 w-96 h-96 rounded-full bg-white/20 blur-3xl" />
</div>
<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">
{t("home.ctaTitle")}
</h2>
<p className="text-emerald-50 text-lg mb-8">
{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" />
{t("home.ctaButton")}
</Button>
</Link>
</div>
</div>
</div>
</section>
{/* ─── Footer ────────────────────────────────────────────────── */}
<footer className="border-t border-emerald-100/60 py-12 bg-white/50 backdrop-blur-sm">
<div className="container">
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-emerald-500 to-cyan-500 flex items-center justify-center">
<Stethoscope className="w-4 h-4 text-white" />
</div>
<span className="font-bold gradient-text">QueueMed</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">{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>
</div>
</footer>
</div>
);
}

View file

@ -1,212 +0,0 @@
import { useState } from "react";
import { Link, useLocation } from "wouter";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { Stethoscope, Mail, Lock, User, Sparkles, Loader2, ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useAuth } from "@/_core/hooks/useAuth";
type Mode = "login" | "register";
export default function Login() {
const { t } = useTranslation();
const [, navigate] = useLocation();
const [mode, setMode] = useState<Mode>("login");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [name, setName] = useState("");
const { login, register, isLoggingIn, isRegistering } = useAuth();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (mode === "login") {
await login(email, password);
} else {
await register(email, password, name || undefined);
}
navigate("/dashboard");
} catch {
// Toast handled in hook
}
};
const isLoading = isLoggingIn || isRegistering;
return (
<div className="min-h-screen flex items-center justify-center p-4 relative overflow-hidden">
<Helmet>
<title>{t("login.metaTitle")}</title>
<meta name="description" content={t("login.metaDescription")} />
</Helmet>
{/* Background blobs */}
<div className="absolute inset-0 pointer-events-none overflow-hidden">
<div className="absolute top-1/4 left-1/4 w-96 h-96 rounded-full bg-emerald-300/30 blur-3xl animate-pulse-glow" />
<div className="absolute bottom-1/4 right-1/4 w-[28rem] h-[28rem] rounded-full bg-cyan-300/30 blur-3xl animate-pulse-glow" style={{ animationDelay: "1s" }} />
</div>
<div className="relative z-10 w-full max-w-md">
<Link href="/">
<a className="inline-flex items-center gap-2 text-slate-500 hover:text-emerald-700 mb-6 text-sm">
<ArrowLeft className="w-4 h-4" />
{t("login.backToHome")}
</a>
</Link>
<div className="text-center mb-8">
<div className="inline-flex items-center gap-2 mb-4">
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-emerald-500 to-cyan-500 flex items-center justify-center shadow-lg glow-emerald">
<Stethoscope className="w-6 h-6 text-white" />
</div>
</div>
<h1 className="font-bold text-3xl mb-2">
{mode === "login" ? (
<>{t("login.welcomeBack")}, <span className="gradient-text">{t("login.doctor")}</span></>
) : (
<>{t("login.welcomeNew")} <span className="gradient-text">QueueMed</span></>
)}
</h1>
<p className="text-slate-500 text-sm">
{mode === "login" ? t("login.subtitleLogin") : t("login.subtitleRegister")}
</p>
</div>
<div className="glass-card-strong rounded-3xl p-8">
{/* Mode toggle */}
<div className="grid grid-cols-2 p-1 mb-6 rounded-xl bg-slate-100">
<button
onClick={() => setMode("login")}
className={`px-4 py-2 rounded-lg text-sm font-semibold transition-all ${
mode === "login"
? "bg-white text-teal-700 shadow-sm"
: "text-slate-500 hover:text-slate-700"
}`}
>
{t("login.tabLogin")}
</button>
<button
onClick={() => setMode("register")}
className={`px-4 py-2 rounded-lg text-sm font-semibold transition-all ${
mode === "register"
? "bg-white text-teal-700 shadow-sm"
: "text-slate-500 hover:text-slate-700"
}`}
>
{t("login.tabRegister")}
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{mode === "register" && (
<div>
<Label htmlFor="name" className="mb-1.5 block">{t("login.nameLabel")} <span className="text-slate-400 text-xs">{t("login.nameOptional")}</span></Label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
id="name"
placeholder={t("login.namePlaceholder")}
value={name}
onChange={(e) => setName(e.target.value)}
className="pl-10"
autoComplete="name"
/>
</div>
</div>
)}
<div>
<Label htmlFor="email" className="mb-1.5 block">{t("login.emailLabel")}</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
id="email"
type="email"
placeholder={t("login.emailPlaceholder")}
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="pl-10"
autoComplete="email"
/>
</div>
</div>
<div>
<Label htmlFor="password" className="mb-1.5 block">{t("login.passwordLabel")}</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
id="password"
type="password"
placeholder={t("login.passwordPlaceholder")}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="pl-10"
autoComplete={mode === "login" ? "current-password" : "new-password"}
/>
</div>
</div>
<Button
type="submit"
variant="gradient"
size="lg"
className="w-full"
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Sparkles className="w-4 h-4 mr-2" />
)}
{mode === "login" ? t("login.submitLogin") : t("login.submitRegister")}
</Button>
{mode === "login" && (
<div className="text-center">
<Link href="/forgot-password">
<a className="text-sm text-teal-700 hover:underline">
{t("login.forgotPassword")}
</a>
</Link>
</div>
)}
</form>
<p className="text-center text-xs text-slate-500 mt-6">
{mode === "login" ? (
<>{t("login.noAccount")}{" "}
<button onClick={() => setMode("register")} className="text-teal-700 font-semibold underline-offset-2 hover:underline">
{t("login.registerLink")}
</button>
</>
) : (
<>{t("login.alreadyAccount")}{" "}
<button onClick={() => setMode("login")} className="text-teal-700 font-semibold underline-offset-2 hover:underline">
{t("login.loginLink")}
</button>
</>
)}
</p>
</div>
<div className="mt-6 grid grid-cols-3 gap-3 text-center">
{[
{ label: t("login.statSetup"), value: t("login.statSetupValue") },
{ label: t("login.statTrial"), value: t("login.statTrialValue") },
{ label: t("login.statClinics"), value: t("login.statClinicsValue") },
].map((s) => (
<div key={s.label} className="glass-card rounded-xl p-3">
<div className="font-bold text-emerald-700 text-sm">{s.value}</div>
<div className="text-xs text-slate-500">{s.label}</div>
</div>
))}
</div>
</div>
</div>
);
}

View file

@ -1,420 +0,0 @@
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,
LayoutDashboard, Sparkles,
} from "lucide-react";
import { trpc } from "@/lib/trpc";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
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("");
const [phone, setPhone] = useState("");
const [avgConsultation, setAvgConsultation] = useState(15);
const [maxQueue, setMaxQueue] = useState(30);
const baseUrl = typeof window !== "undefined" ? window.location.origin : "";
const createMutation = trpc.clinic.create.useMutation({
onSuccess: (data) => {
setClinicId(data.id);
setStep(2);
toast.success(t("onboarding.toastCreated"));
},
onError: (e) => toast.error(e.message),
});
const qrQuery = trpc.clinic.qrDataUrl.useQuery(
{ id: clinicId ?? 0, baseUrl },
{ enabled: clinicId !== null && step >= 2 }
);
const handleCreate = () => {
if (!name.trim()) {
toast.error(t("onboarding.errorNameRequired"));
return;
}
createMutation.mutate({
name: name.trim(),
address: address.trim() || undefined,
phone: phone.trim() || undefined,
avgConsultationMinutes: avgConsultation,
maxQueueSize: maxQueue,
});
};
const currentStep = STEPS.find((s) => s.id === step)!;
const StepIcon = currentStep.icon;
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" />
<div className="absolute bottom-1/4 right-1/4 w-[28rem] h-[28rem] rounded-full bg-cyan-300/30 blur-3xl animate-pulse-glow" style={{ animationDelay: "1s" }} />
</div>
<div className="relative z-10 w-full max-w-xl">
{/* Header */}
<div className="text-center mb-8">
<div className="inline-flex items-center gap-2 mb-4">
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-emerald-500 to-cyan-500 flex items-center justify-center shadow-lg glow-emerald">
<Stethoscope className="w-6 h-6 text-white" />
</div>
</div>
<h1 className="font-bold text-3xl mb-2">
{t("onboarding.headerPart1")} <span className="gradient-text">{t("onboarding.headerAccent")}</span>
</h1>
<p className="text-slate-500 text-sm">
{t("onboarding.headerSubtitle")}
</p>
</div>
{/* Step indicators */}
<div className="flex items-center justify-center gap-2 mb-6">
{STEPS.map((s, i) => (
<div key={s.id} className="flex items-center gap-2">
<div
className={cn(
"w-9 h-9 rounded-full flex items-center justify-center text-xs font-bold transition-all duration-300",
s.id < step && "bg-emerald-500 text-white shadow-md",
s.id === step && "bg-white border-2 border-emerald-500 text-emerald-700 shadow-md",
s.id > step && "bg-slate-100 text-slate-400 border border-slate-200"
)}
>
{s.id < step ? <CheckCircle2 className="w-4 h-4" /> : s.id}
</div>
{i < STEPS.length - 1 && (
<div
className={cn(
"w-12 h-0.5 transition-all duration-300",
s.id < step ? "bg-emerald-500" : "bg-slate-200"
)}
/>
)}
</div>
))}
</div>
{/* Card */}
<div className="glass-card-strong rounded-3xl p-8">
{/* Step header */}
<div className="flex items-center gap-4 mb-6">
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-emerald-500 to-cyan-500 flex items-center justify-center shadow-md glow-emerald flex-shrink-0">
<StepIcon className="w-6 h-6 text-white" />
</div>
<div>
<h2 className="font-bold text-xl text-slate-900">{currentStep.title}</h2>
<p className="text-slate-500 text-sm">{currentStep.description}</p>
</div>
</div>
{/* Step 1 — Cabinet info + queue settings */}
{step === 1 && (
<div className="space-y-5">
<div>
<Label htmlFor="name" className="mb-1.5 block">
{t("onboarding.fieldName")} <span className="text-red-500">*</span>
</Label>
<Input
id="name"
placeholder={t("onboarding.placeholderName")}
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label htmlFor="address" className="mb-1.5 block">
{t("onboarding.fieldAddress")} <span className="text-slate-400 text-xs">{t("onboarding.optional")}</span>
</Label>
<Input
id="address"
placeholder={t("onboarding.placeholderAddress")}
value={address}
onChange={(e) => setAddress(e.target.value)}
/>
</div>
<div>
<Label htmlFor="phone" className="mb-1.5 block">
{t("onboarding.fieldPhone")} <span className="text-slate-400 text-xs">{t("onboarding.optional")}</span>
</Label>
<Input
id="phone"
placeholder={t("onboarding.placeholderPhone")}
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
</div>
</div>
<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" />
{t("onboarding.queueSettings")}
</div>
<div>
<Label className="mb-1.5 block text-slate-700">
{t("onboarding.avgConsultation")}
</Label>
<div className="flex items-center gap-4">
<input
type="range"
min={5}
max={60}
step={5}
value={avgConsultation}
onChange={(e) => setAvgConsultation(Number(e.target.value))}
className="flex-1 accent-emerald-500"
/>
<span className="text-emerald-700 font-bold w-20 text-right text-sm">
{avgConsultation} {t("onboarding.minutesShort")}
</span>
</div>
<p className="text-slate-500 text-xs mt-1">
{t("onboarding.avgConsultationHelp")}
</p>
</div>
<div>
<Label className="mb-1.5 block text-slate-700">
{t("onboarding.maxQueueSize")}
</Label>
<div className="flex items-center gap-4">
<input
type="range"
min={5}
max={100}
step={5}
value={maxQueue}
onChange={(e) => setMaxQueue(Number(e.target.value))}
className="flex-1 accent-emerald-500"
/>
<span className="text-emerald-700 font-bold w-20 text-right text-sm">
{maxQueue} {t("onboarding.patientsShort")}
</span>
</div>
<p className="text-slate-500 text-xs mt-1">
{t("onboarding.maxQueueHelp")}
</p>
</div>
</div>
</div>
)}
{/* Step 2 — QR Preview */}
{step === 2 && (
<div className="text-center space-y-5">
<p className="text-sm text-slate-600">
{t("onboarding.qrIntroPart1")} <strong className="text-slate-900">{name}</strong>{t("onboarding.qrIntroPart2")}
</p>
<div className="flex justify-center">
<div
className="rounded-2xl bg-white p-5 border-2 border-emerald-200 inline-block"
style={{ boxShadow: "0 12px 32px rgba(13, 148, 136, 0.15)" }}
>
{qrQuery.isLoading ? (
<div className="w-[200px] h-[200px] flex items-center justify-center">
<Loader2 className="w-6 h-6 text-emerald-500 animate-spin" />
</div>
) : qrQuery.data?.dataUrl ? (
<img
src={qrQuery.data.dataUrl}
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">
{t("onboarding.qrUnavailable")}
</div>
)}
</div>
</div>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Button
variant="outline"
onClick={() => clinicId && navigate(`/dashboard/poster/${clinicId}`)}
disabled={!clinicId}
>
<Printer className="w-4 h-4 mr-2" />
{t("onboarding.viewPoster")}
</Button>
<Button
variant="gradient"
onClick={() => setStep(3)}
>
{t("onboarding.continue")}
<ChevronRight className="w-4 h-4 ml-1" />
</Button>
</div>
</div>
)}
{/* Step 3 — Done */}
{step === 3 && (
<div className="text-center space-y-6">
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-emerald-400 to-cyan-400 flex items-center justify-center mx-auto shadow-lg glow-emerald">
<CheckCircle2 className="w-10 h-10 text-white" />
</div>
<div>
<h3 className="font-bold text-2xl text-slate-900 mb-2">
{t("onboarding.doneTitle")}
</h3>
<p className="text-slate-600 text-sm leading-relaxed">
{t("onboarding.donePart1")} <strong className="text-slate-900">"{name}"</strong>{" "}
{t("onboarding.donePart2")}
</p>
</div>
<div className="space-y-3 text-left">
{[
{
icon: Printer,
text: t("onboarding.next1"),
},
{
icon: Monitor,
text: t("onboarding.next2"),
},
{
icon: LayoutDashboard,
text: t("onboarding.next3"),
},
].map((item, i) => {
const Icon = item.icon;
return (
<div
key={i}
className="flex items-start gap-3 p-3 rounded-xl bg-emerald-50/70 border border-emerald-200"
>
<div className="w-8 h-8 rounded-lg bg-white flex items-center justify-center flex-shrink-0 border border-emerald-200">
<Icon className="w-4 h-4 text-emerald-700" />
</div>
<span className="text-sm text-slate-700 mt-1">
{item.text}
</span>
</div>
);
})}
</div>
</div>
)}
{/* Actions */}
<div className="flex gap-3 mt-8">
{step === 1 && (
<Button
variant="gradient"
onClick={handleCreate}
disabled={createMutation.isPending}
className="w-full font-semibold h-12"
>
{createMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{t("onboarding.creating")}
</>
) : (
<>
<Sparkles className="w-4 h-4 mr-2" />
{t("onboarding.createClinic")}
<ChevronRight className="w-4 h-4 ml-1" />
</>
)}
</Button>
)}
{step === 2 && (
<Button
variant="ghost"
onClick={() => setStep(1)}
className="text-slate-500"
>
<ChevronLeft className="w-4 h-4 mr-1" />
{t("onboarding.back")}
</Button>
)}
{step === 3 && (
<div className="flex flex-col sm:flex-row gap-3 w-full">
<Button
variant="outline"
onClick={() => clinicId && navigate(`/dashboard/queue/${clinicId}`)}
className="flex-1"
disabled={!clinicId}
>
<QrCode className="w-4 h-4 mr-2" />
{t("onboarding.viewQueue")}
</Button>
<Button
variant="gradient"
onClick={() => navigate("/dashboard")}
className="flex-1 font-semibold"
>
{t("onboarding.dashboard")}
<ChevronRight className="w-4 h-4 ml-1" />
</Button>
</div>
)}
</div>
</div>
{/* Skip link */}
{step < 3 && (
<p className="text-center mt-4 text-sm text-slate-500">
<button
onClick={() => navigate("/dashboard")}
className="underline underline-offset-2 hover:text-emerald-700 transition-colors"
>
{t("onboarding.skip")}
</button>
</p>
)}
</div>
</div>
);
}

View file

@ -1,287 +0,0 @@
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,
Loader2, MapPin, Building2, ArrowRight, RefreshCw,
} from "lucide-react";
import { trpc } from "@/lib/trpc";
import { Button } from "@/components/ui/button";
import { getSocket } from "@/lib/socket";
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 ?? "";
const utils = trpc.useUtils();
const ticketQuery = trpc.queue.getByToken.useQuery(
{ patientToken },
{ enabled: !!patientToken, refetchInterval: 30_000 }
);
const cancelMutation = trpc.queue.cancel.useMutation({
onSuccess: () => {
toast.success(t("patient.toastTicketCanceled"));
utils.queue.getByToken.invalidate({ patientToken });
},
onError: (e) => toast.error(e.message),
});
const [pulse, setPulse] = useState(false);
// Socket subscription
useEffect(() => {
if (!patientToken) return;
const s = getSocket();
s.emit("patient:subscribe", patientToken);
const onUpdate = () => utils.queue.getByToken.invalidate({ patientToken });
const onCalled = () => {
setPulse(true);
utils.queue.getByToken.invalidate({ patientToken });
// Best-effort browser notification
if ("Notification" in window && Notification.permission === "granted") {
new Notification(t("patient.notifTitle"), {
body: t("patient.notifBody"),
icon: "/favicon.svg",
});
}
try {
navigator.vibrate?.([200, 100, 200, 100, 400]);
} catch {}
toast.success(t("patient.notifTitle"), { duration: 10_000 });
};
const onApproaching = () => {
utils.queue.getByToken.invalidate({ patientToken });
toast(t("patient.toastApproaching"), { duration: 8_000 });
};
const onAbsent = () => utils.queue.getByToken.invalidate({ patientToken });
const onDone = () => utils.queue.getByToken.invalidate({ patientToken });
s.on("patient:update", onUpdate);
s.on("patient:called", onCalled);
s.on("patient:approaching", onApproaching);
s.on("patient:absent", onAbsent);
s.on("patient:done", onDone);
return () => {
s.emit("patient:unsubscribe", patientToken);
s.off("patient:update", onUpdate);
s.off("patient:called", onCalled);
s.off("patient:approaching", onApproaching);
s.off("patient:absent", onAbsent);
s.off("patient:done", onDone);
};
}, [patientToken, utils]);
// Ask for notification permission once
useEffect(() => {
if ("Notification" in window && Notification.permission === "default") {
Notification.requestPermission().catch(() => {});
}
}, []);
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>
);
}
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">{t("patient.ticketNotFound")}</h1>
<p className="text-slate-500 text-sm mb-6">
{t("patient.ticketNotFoundDesc")}
</p>
<Button variant="gradient" onClick={() => navigate("/")}>{t("common.backToHome")}</Button>
</div>
</div>
);
}
const { entry, clinic, callingNow, waitingCount } = ticketQuery.data;
const isCalled = entry.status === "called";
const isInConsult = entry.status === "in_consultation";
const isDone = entry.status === "done";
const isAbsent = entry.status === "absent" || entry.status === "canceled";
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" />
<div className="absolute bottom-0 right-0 w-96 h-96 rounded-full bg-cyan-300/30 blur-3xl animate-pulse-glow" style={{ animationDelay: "1s" }} />
</div>
<div className="relative z-10 w-full max-w-md">
{/* Header */}
<div className="text-center mb-6">
<div className="inline-flex items-center gap-2 mb-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-emerald-500 to-cyan-500 flex items-center justify-center shadow-md">
<Stethoscope className="w-5 h-5 text-white" />
</div>
<span className="font-bold text-lg gradient-text">QueueMed</span>
</div>
{clinic?.name && (
<h1 className="font-bold text-xl mb-1 flex items-center justify-center gap-2">
<Building2 className="w-4 h-4 text-emerald-600" />
{clinic.name}
</h1>
)}
</div>
{/* Status card */}
<AnimatePresence mode="wait">
{isCalled ? (
<motion.div
key="called"
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="rounded-3xl p-8 text-center text-white shadow-2xl bg-gradient-to-br from-emerald-500 to-cyan-500"
style={{ boxShadow: "0 0 50px rgba(16, 185, 129, 0.5)" }}
>
<motion.div
animate={{ rotate: pulse ? [0, -15, 15, -15, 0] : 0 }}
transition={{ duration: 0.6 }}
className="inline-flex w-20 h-20 rounded-2xl bg-white/30 items-center justify-center mb-4"
>
<BellRing className="w-10 h-10 text-white" />
</motion.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">{t("patient.calledDesc")}</p>
</motion.div>
) : isInConsult ? (
<motion.div
key="consult"
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
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">{t("patient.inConsult")}</h2>
<p className="text-slate-600">{t("patient.inConsultDesc")}</p>
</motion.div>
) : isDone ? (
<motion.div
key="done"
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
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">{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
key="absent"
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
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">{t("patient.ticketClosed")}</h2>
<p className="text-slate-600 mb-4">
{entry.status === "absent"
? t("patient.markedAbsentDesc")
: t("patient.ticketCanceledDesc")}
</p>
</motion.div>
) : (
<motion.div
key="waiting"
initial={{ opacity: 0 }}
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">{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 ?? 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">{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">{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">{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">{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" />
{t("patient.currentPatient")} :{" "}
<strong className="text-amber-900">{formatTicket(callingNow.ticketNumber)}</strong>
</div>
)}
<p className="text-xs text-slate-500 mb-4">
{t("patient.keepPageOpen")}
</p>
<Button
variant="outline"
size="sm"
className="w-full text-red-600 border-red-200 hover:bg-red-50"
onClick={() => {
if (confirm(t("patient.cancelConfirm"))) {
cancelMutation.mutate({ patientToken });
}
}}
disabled={cancelMutation.isPending}
>
{t("patient.cancelMyTicket")}
</Button>
</motion.div>
)}
</AnimatePresence>
{/* Footer */}
<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" />
{t("patient.joinedAt", { time: formatTime(entry.joinedAt) })}
</div>
</div>
{!isCalled && !isDone && !isAbsent && (
<button
onClick={() => ticketQuery.refetch()}
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" : ""}`} />
{t("patient.refresh")}
</button>
)}
</div>
</div>
);
}

View file

@ -1,233 +0,0 @@
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,
} from "lucide-react";
import { trpc } from "@/lib/trpc";
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);
const ticketQuery = trpc.queue.getEntryById.useQuery(
{ id: entryId },
{ enabled: entryId > 0 }
);
// Auto-trigger print dialog once data is loaded (helpful when opened from doctor UI)
useEffect(() => {
if (ticketQuery.data && typeof window !== "undefined") {
const t = setTimeout(() => {
try { window.print(); } catch {}
}, 600);
return () => clearTimeout(t);
}
}, [ticketQuery.data]);
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>
);
}
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">{t("ticket.notFound")}</h1>
<p className="text-slate-500 text-sm mb-6">
{t("ticket.notFoundDesc")}
</p>
<Button variant="gradient" onClick={() => navigate("/")}>
{t("common.backToHome")}
</Button>
</div>
</div>
);
}
const { entry, clinic } = ticketQuery.data;
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">
<button
onClick={() => navigate("/dashboard")}
className="flex items-center gap-2 text-slate-500 hover:text-emerald-700 transition-colors text-sm"
>
<ChevronLeft className="w-4 h-4" />
{t("common.back")}
</button>
<Button
onClick={() => window.print()}
variant="gradient"
className="font-semibold"
>
<Printer className="w-4 h-4 mr-2" />
{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">{t("ticket.tipLabel")} :</strong> {t("ticket.tipText")}
</div>
</div>
</div>
{/* Printable ticket */}
<div className="max-w-md mx-auto px-4 pb-12 print:p-0 print:max-w-none print:mx-0">
<div
className="bg-white rounded-3xl print:rounded-none overflow-hidden border border-slate-200 print:border-0 shadow-xl print:shadow-none"
style={{ fontFamily: "'Inter', system-ui, sans-serif" }}
>
{/* Header band */}
<div
style={{
background: "linear-gradient(135deg, #10b981 0%, #06b6d4 100%)",
padding: "20px 28px",
color: "white",
}}
>
<div className="flex items-center gap-2 mb-1">
<div
style={{
width: 32,
height: 32,
borderRadius: 8,
background: "rgba(255,255,255,0.22)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Stethoscope className="w-4 h-4" />
</div>
<span style={{ fontSize: 18, fontWeight: 800, letterSpacing: "-0.02em" }}>
QueueMed
</span>
</div>
<p style={{ fontSize: 12, opacity: 0.9, margin: 0 }}>
{t("ticket.subtitle")}
</p>
</div>
{/* Clinic */}
<div className="px-7 pt-6 text-center">
{clinic?.name && (
<h1 className="font-bold text-xl text-slate-900 flex items-center justify-center gap-2">
<Building2 className="w-4 h-4 text-emerald-600" />
{clinic.name}
</h1>
)}
{clinic?.address && (
<p className="text-slate-500 text-xs mt-1 flex items-center justify-center gap-1">
<MapPin className="w-3 h-3" />
{clinic.address}
</p>
)}
</div>
{/* Ticket number */}
<div className="px-7 py-8 text-center">
<div className="text-xs uppercase tracking-widest text-slate-500 font-bold mb-2">
{t("ticket.yourNumber")}
</div>
<div
className="font-black leading-none"
style={{
fontSize: 96,
background: "linear-gradient(135deg, #10b981 0%, #06b6d4 100%)",
WebkitBackgroundClip: "text",
backgroundClip: "text",
color: "transparent",
WebkitTextFillColor: "transparent",
}}
>
{formatTicket(entry.ticketNumber)}
</div>
{entry.patientName && (
<div className="text-sm text-slate-600 mt-3">
{entry.patientName}
</div>
)}
</div>
{/* Stats */}
<div className="px-7 pb-6 grid grid-cols-2 gap-3">
<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" />
{t("ticket.position")}
</div>
<div className="font-black text-2xl text-emerald-900">
{entry.position ?? "—"}
</div>
</div>
<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" />
{t("ticket.wait")}
</div>
<div className="font-black text-2xl text-cyan-900">
~{entry.estimatedWaitMinutes ?? "?"}
<span className="text-xs font-bold ml-1">{t("ticket.minShort")}</span>
</div>
</div>
</div>
{/* Instructions */}
<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">
{t("ticket.howItWorks")}
</strong>
{t("ticket.howItWorksDesc")}
</div>
</div>
{/* Footer */}
<div
className="border-t border-slate-200 px-7 py-3 flex items-center justify-between text-[10px] text-slate-400"
style={{ background: "#f8fafc" }}
>
<span>{t("ticket.issuedAt", { date: formatDate(entry.joinedAt), time: formatTime(entry.joinedAt) })}</span>
<span>queuemed.fr</span>
</div>
</div>
</div>
<style>{`
@media print {
html, body { background: white !important; }
.print\\:hidden { display: none !important; }
@page { margin: 8mm; size: A6; }
}
`}</style>
</div>
);
}

View file

@ -1,194 +0,0 @@
import { useState } from "react";
import { useParams, useLocation } from "wouter";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { motion } from "framer-motion";
import {
Stethoscope, QrCode, Smartphone, Loader2, XCircle, User, Phone, MessageCircle,
} from "lucide-react";
import { trpc } from "@/lib/trpc";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
const VISIT_REASONS = [
"consultation", "urgence", "certificat_scolaire", "certificat_sportif",
"arret_travail", "administratif", "autre",
] as const;
export default function QrJoin() {
const { t } = useTranslation();
const params = useParams<{ clinicId: string; qrToken: string }>();
const [, navigate] = useLocation();
const clinicId = parseInt(params.clinicId ?? "0", 10);
const qrToken = params.qrToken ?? "";
const [name, setName] = useState("");
const [phone, setPhone] = useState("");
const [whatsappPhone, setWhatsappPhone] = useState("");
const [reason, setReason] = useState<string>("consultation");
const [useSamePhone, setUseSamePhone] = useState(true);
// Verify QR is valid first
const verifyQuery = trpc.queue.verifyQr.useQuery(
{ clinicId, qrToken },
{ enabled: !!qrToken && clinicId > 0, retry: false }
);
const joinMutation = trpc.queue.join.useMutation({
onSuccess: (data) => {
toast.success(t("patient.toastJoined"));
navigate(`/queue/${data.patientToken}`);
},
onError: (e) => {
toast.error(e.message);
},
});
if (verifyQuery.isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Helmet><title>QueueMed</title></Helmet>
<Loader2 className="w-8 h-8 text-emerald-500 animate-spin" />
</div>
);
}
if (verifyQuery.error || !verifyQuery.data) {
return (
<div className="min-h-screen flex items-center justify-center p-4">
<Helmet><title>QueueMed</title></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">{t("patient.ticketNotFound")}</h1>
<p className="text-slate-500 text-sm mb-6">
{t("patient.ticketNotFoundDesc")}
</p>
<Button variant="gradient" onClick={() => navigate("/")}>{t("common.backToHome")}</Button>
</div>
</div>
);
}
const { clinicName, isQueueOpen, whatsappConnected } = verifyQuery.data;
if (!isQueueOpen) {
return (
<div className="min-h-screen flex items-center justify-center p-4">
<Helmet><title>QueueMed {clinicName}</title></Helmet>
<div className="glass-card rounded-3xl p-10 text-center max-w-md">
<XCircle className="w-12 h-12 text-orange-400 mx-auto mb-4" />
<h1 className="font-bold text-2xl mb-2">File d'attente fermée</h1>
<p className="text-slate-500 text-sm mb-6">
Le cabinet {clinicName} n'accepte plus de patients pour le moment.
</p>
<Button variant="gradient" onClick={() => navigate("/")}>{t("common.backToHome")}</Button>
</div>
</div>
);
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
joinMutation.mutate({
clinicId,
qrToken,
patientName: name || undefined,
patientPhone: phone || undefined,
whatsappPhone: useSamePhone ? phone : whatsappPhone || undefined,
visitReason: reason as any,
});
};
return (
<div className="min-h-screen flex items-center justify-center p-4">
<Helmet><title>QueueMed {clinicName}</title></Helmet>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="glass-card rounded-3xl p-8 max-w-md w-full"
>
<div className="text-center mb-6">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-emerald-500 to-cyan-500 flex items-center justify-center">
<QrCode className="w-8 h-8 text-white" />
</div>
<h1 className="font-bold text-2xl text-slate-800">{clinicName}</h1>
<p className="text-slate-500 text-sm mt-1">Rejoignez la salle d'attente virtuelle</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">Nom (optionnel)</label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Votre nom"
className="w-full pl-10 pr-4 py-2.5 rounded-xl border border-slate-200 bg-white/80 focus:outline-none focus:ring-2 focus:ring-emerald-500/30 focus:border-emerald-500 transition"
/>
</div>
</div>
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">Téléphone (optionnel)</label>
<div className="relative">
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="+594..."
className="w-full pl-10 pr-4 py-2.5 rounded-xl border border-slate-200 bg-white/80 focus:outline-none focus:ring-2 focus:ring-emerald-500/30 focus:border-emerald-500 transition"
/>
</div>
</div>
{whatsappConnected && phone && (
<label className="flex items-center gap-2 text-sm text-slate-600">
<input
type="checkbox"
checked={useSamePhone}
onChange={(e) => setUseSamePhone(e.target.checked)}
className="rounded border-slate-300 text-emerald-600 focus:ring-emerald-500"
/>
<MessageCircle className="w-4 h-4 text-emerald-500" />
Recevoir les notifications WhatsApp
</label>
)}
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">Motif de visite</label>
<select
value={reason}
onChange={(e) => setReason(e.target.value)}
className="w-full px-4 py-2.5 rounded-xl border border-slate-200 bg-white/80 focus:outline-none focus:ring-2 focus:ring-emerald-500/30 focus:border-emerald-500 transition"
>
{VISIT_REASONS.map((r) => (
<option key={r} value={r}>
{t(`visitReasons.${r}`, r)}
</option>
))}
</select>
</div>
<Button
type="submit"
variant="gradient"
className="w-full"
disabled={joinMutation.isPending}
>
{joinMutation.isPending ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<>
<Stethoscope className="w-5 h-5 mr-2" />
Prendre un ticket
</>
)}
</Button>
</form>
</motion.div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show more