Compare commits
No commits in common. "master" and "main" have entirely different histories.
149 changed files with 77 additions and 39627 deletions
|
|
@ -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
|
||||
61
.env.example
61
.env.example
|
|
@ -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
17
.gitignore
vendored
|
|
@ -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
0
AGENT_CONTEXT.md
Normal file
0
AUTHORS.md
Normal file
0
AUTHORS.md
Normal file
106
CLAUDE.md
106
CLAUDE.md
|
|
@ -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
|
||||
54
Dockerfile
54
Dockerfile
|
|
@ -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"]
|
||||
BIN
MODE_OPERATOIRE_QueueMed.pdf
Normal file
BIN
MODE_OPERATOIRE_QueueMed.pdf
Normal file
Binary file not shown.
0
ROADMAP.html
Normal file
0
ROADMAP.html
Normal 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>
|
||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 été 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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
|
||||
};
|
||||
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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
|
||||
};
|
||||
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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";
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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!;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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" });
|
||||
},
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
|
@ -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`;
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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'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'environnement du serveur (non modifiables depuis l'interface).
|
||||
Contactez l'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'interface.
|
||||
Seule la dernière valeur saisie est visible. Laissez le champ vide pour conserver la valeur existante.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue