From 10531e278d10a97072f6d5dd1a7043d2eaa46f85 Mon Sep 17 00:00:00 2001 From: atesta103 Date: Fri, 24 Jul 2026 11:22:51 +0200 Subject: [PATCH 01/11] docs(spec): design for downloadable queue ticket Design doc for a downloadable ticket image on /wait, giving customers a device-independent way back to their queue position (name, arrival time, position snapshot, recovery code, QR code) beyond the current localStorage-only auto-redirect. Co-Authored-By: Claude Opus 4.8 --- .../2026-07-24-ticket-download-design.md | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-24-ticket-download-design.md diff --git a/docs/superpowers/specs/2026-07-24-ticket-download-design.md b/docs/superpowers/specs/2026-07-24-ticket-download-design.md new file mode 100644 index 0000000..a8e6c24 --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-ticket-download-design.md @@ -0,0 +1,77 @@ +# Ticket téléchargeable — design + +**Date :** 2026-07-24 +**Statut :** Validé, prêt pour implémentation + +## Contexte + +Un client qui rejoint une file d'attente n'a aujourd'hui aucun moyen fiable de retrouver son ticket s'il perd l'accès à son navigateur d'origine (nouveau téléphone, cache vidé). Le seul filet de sécurité durable est le couple prénom + code de récupération à 4 caractères affiché sur `/wait`, qu'il faut retenir ou noter à la main. + +Cette fonctionnalité ajoute un ticket téléchargeable — une image type carte d'embarquement, enregistrable dans la pellicule photo — qui porte toutes les infos nécessaires pour retrouver sa place depuis n'importe quel appareil, sans dépendre du navigateur d'origine. + +Hors périmètre explicite : aucune information de prix (l'app ne gère pas la facturation côté client final). + +## Architecture + +Un nouveau composant autonome `TicketCard` dans `components/composed/TicketCard.tsx`, qui prend en props les données du ticket et du commerce et rend un visuel fixe (pas de state interne, pas d'appel réseau). Il est monté à l'intérieur d'une `Dialog` (composant déjà présent dans `components/ui/Dialog.tsx`, déjà utilisé ailleurs dans `WaitClient.tsx`), ouverte par un bouton **« Enregistrer mon ticket »** ajouté à `WaitClient.tsx`. + +Le bouton est visible tant que `ticket.status` vaut `waiting` ou `called` — pas après `done`/`cancelled`, où le ticket n'a plus d'utilité à conserver. + +Le téléchargement utilise `html-to-image` (nouvelle dépendance, légère, activement maintenue) pour capturer le nœud DOM de `TicketCard` et déclencher un export `.png`. + +## Contenu du ticket + +| Champ | Source | Notes | +|---|---|---| +| Nom du commerce | `merchant.name` | déjà disponible | +| Logo | `merchant.logo_url` | **à ajouter** au select de `page.tsx` et au type `Merchant` de `WaitClient.tsx` | +| Couleur de marque | `merchant.brand_color` | **à ajouter**, idem. Repli sur la couleur de marque par défaut de l'app si absente | +| Prénom du client | `ticket.customer_name` | déjà disponible | +| Numéro / position | state React existant (position en file) | **instantané figé** au moment du clic sur « Enregistrer » — jamais recalculé après coup, l'image ne ment pas sur une position qui aurait bougé | +| Heure d'arrivée | `ticket.joined_at` | déjà disponible, formatée en heure locale lisible | +| Code de récupération | `ticket.recovery_code` | déjà disponible, affiché en texte, gros, lisible | +| QR code | généré côté client avec `qrcode.react` (déjà une dépendance du projet, utilisée pour le QR de rejoindre la file) | voir section dédiée | + +Repli logo absent : initiale du nom du commerce dans un badge, cohérent avec le motif déjà utilisé sur les pins de `/carte`. + +## QR code et pré-remplissage de /retrouver + +Le QR encode `/{slug}/retrouver?name={prénom}&code={code}` (valeurs encodées en URL). Un scan ouvre directement le formulaire de récupération existant, **pré-rempli** mais **pas auto-soumis** — le client garde la main, confirme, et clique lui-même sur « Retrouver ma place ». Pas de soumission automatique pour éviter tout comportement surprenant si le QR est scanné par erreur ou par un tiers. + +Modification requise dans `RecoverClient.tsx` : lecture de `useSearchParams()` pour initialiser `customerName` et `code` avec les valeurs de la query string, si présentes. Aucun changement nécessaire côté `page.tsx` (server component), le parsing reste client-side. + +Le code de récupération reste imprimé en texte clair sur le ticket, gros et lisible, comme repli si le client ne peut pas scanner (appel téléphonique au commerce, autre appareil sans caméra). + +## Gestion d'erreur + +Si l'export `html-to-image` échoue (navigateur ancien, restriction canvas, `toPng()` qui rejette) : +- Le bouton de téléchargement affiche un message d'erreur inline dans la `Dialog` (« Impossible de générer l'image, réessayez ou notez le code ci-dessous »). +- Le contenu de `TicketCard` reste visible à l'écran dans tous les cas — le client peut toujours lire son code et le noter à la main même si l'export échoue. +- Aucune tentative de repli automatique vers un autre format (pas de PDF, pas de partage natif) — hors périmètre pour cette itération. + +## Composants et fichiers touchés + +**Nouveau :** +- `components/composed/TicketCard.tsx` — le visuel du ticket +- `stories/composed/TicketCard.stories.tsx` — story Storybook, cas avec/sans logo, nom long/court + +**Modifiés :** +- `app/[slug]/wait/[ticketId]/WaitClient.tsx` — bouton « Enregistrer mon ticket », état d'ouverture de la Dialog, logique d'export +- `app/[slug]/wait/[ticketId]/page.tsx` — ajout de `logo_url`, `brand_color` au select des merchants +- `app/[slug]/retrouver/RecoverClient.tsx` — pré-remplissage depuis `useSearchParams()` +- `package.json` — ajout de `html-to-image` + +## Tests + +- Rendu de `TicketCard` avec logo présent / absent (repli initiale) +- Formatage de l'heure d'arrivée +- `RecoverClient` : pré-remplissage correct depuis les query params, absence de soumission automatique, comportement inchangé quand les params sont absents +- Pas de test automatisé sur l'export PNG lui-même (dépendant du DOM réel / canvas, hors de portée raisonnable pour la suite de tests actuelle) — vérification manuelle en navigateur + +## Hors périmètre (explicitement exclu) + +- Apple/Google Wallet +- Envoi par email/SMS du ticket +- Génération côté serveur de l'image +- Toute information de prix ou de facturation +- Auto-soumission du formulaire de récupération au scan du QR From c4f837b17ed9e2b14a2eb917d5c8292155976b95 Mon Sep 17 00:00:00 2001 From: atesta103 Date: Fri, 24 Jul 2026 11:33:14 +0200 Subject: [PATCH 02/11] docs(plan): implementation plan for downloadable queue ticket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 7 tasks: pure time/URL helpers, html-to-image dependency, TicketCard component, /retrouver QR pre-fill, merchant logo/brand-color plumbing, WaitClient wiring, and manual verification on the dev Preview deployment rather than local dev — per this session's explicit request to stop fighting local-only quirks (StrictMode double-invoke, geolocation) that don't reproduce on a real deployed build. Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-07-24-ticket-download.md | 823 ++++++++++++++++++ 1 file changed, 823 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-24-ticket-download.md diff --git a/docs/superpowers/plans/2026-07-24-ticket-download.md b/docs/superpowers/plans/2026-07-24-ticket-download.md new file mode 100644 index 0000000..742b561 --- /dev/null +++ b/docs/superpowers/plans/2026-07-24-ticket-download.md @@ -0,0 +1,823 @@ +# Ticket téléchargeable — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let a customer waiting in a queue download a PNG ticket (merchant identity, name, position snapshot, arrival time, recovery code, QR code) so they can find their way back to their ticket from any device, not just the browser they joined from. + +**Architecture:** A pure-logic utils module (`lib/utils/ticket-download.ts`) backs a presentational `TicketCard` component (`components/composed/TicketCard.tsx`), captured to PNG client-side via `html-to-image` from a button on the existing `/wait` page. The QR code encodes a pre-filled link to the existing `/retrouver` recovery form. + +**Tech Stack:** Next.js App Router, React, TypeScript, Tailwind, `qrcode.react` (existing dependency), `html-to-image` (new dependency), Vitest for unit tests. + +## Global Constraints + +- No price/billing information anywhere on the ticket (explicit spec exclusion). +- No server-side image generation, no email/SMS delivery, no Wallet integration — PNG download only, client-side (spec exclusions). +- The QR code pre-fills `/retrouver` but never auto-submits it — the customer must confirm and click submit themselves. +- French copy throughout, matching the app's existing tone (see `/{slug}/retrouver`, `WaitClient.tsx` recovery-code card for reference phrasing). +- Raw ` + )} + + {canDownloadTicket && ( + setTicketDialogOpen(false)}> + Votre ticket + +
+ + {downloadError ? ( +

+ {downloadError} +

+ ) : null} +
+
+ + + +
+ )} +``` + +- [ ] **Step 5: Typecheck and lint** + +Run: `npx tsc --noEmit && npx eslint "app/[slug]/wait/[ticketId]/WaitClient.tsx"` +Expected: no errors + +- [ ] **Step 6: Run the full test suite** + +Run: `npm run test` +Expected: all tests pass (including the new ones from Task 1), no regressions + +- [ ] **Step 7: Verify the app↔storybook and dead-exports checks** + +Run: `node scripts/check-app-storybook-contract.mjs && node scripts/check-dead-exports.mjs` +Expected: contract check passes; no new dead exports reported + +- [ ] **Step 8: Commit** + +```bash +git add "app/[slug]/wait/[ticketId]/WaitClient.tsx" +git commit -m "feat(wait): add downloadable ticket button and dialog" +git push origin dev +``` + +--- + +### Task 7: Verify on the dev Preview deployment + +**Files:** none — manual verification only. + +This task has no automated test; it is the actual product verification the earlier tasks' unit tests can't reach (rendering, image export, QR scan). Per this session's constraint, do this on the `dev` branch's Vercel Preview URL, not local `npm run dev`. + +- [ ] **Step 1: Get the Preview URL** + +After Task 6's push, run: + +```bash +gh pr checks 2>&1 | grep -i vercel +``` + +Or, if no PR is open yet against `main`, find the latest Preview deployment for the `dev` branch: + +```bash +gh api repos/Atesta103/waitlight/deployments --jq '.[] | select(.environment=="Preview") | "\(.id) \(.created_at)"' | head -1 +``` + +then fetch its URL via the deployment's statuses API, or simply check the Vercel dashboard for the `dev` branch's latest deployment. + +- [ ] **Step 2: Walk the flow end to end** + +On the Preview URL, as a customer: +1. Scan/open a merchant's join QR (or navigate directly to `/{slug}/join`) and join the queue. +2. On `/{slug}/wait/{ticketId}`, confirm the **Enregistrer mon ticket** button appears below the recovery-code card. +3. Click it — confirm the dialog opens showing the ticket: merchant name/logo/brand color, customer name, position, arrival time, recovery code, QR code. +4. Click **Télécharger l'image** — confirm a PNG downloads (check the browser's downloads). +5. Open the downloaded PNG — confirm it visually matches the dialog preview, with all fields legible. +6. Scan the ticket's QR code with a phone camera (or another device) — confirm it opens `/{slug}/retrouver` with the name and code fields **pre-filled but not submitted**. +7. Submit the pre-filled form — confirm it lands back on `/{slug}/wait/{ticketId}`, the same ticket. +8. On a narrow viewport (browser DevTools device toolbar, ~320px wide, or an actual small phone) — confirm the ticket card fits inside the dialog without horizontal overflow. +9. Let the merchant call the ticket (or simulate it) — confirm the **Position** row disappears from a freshly-opened ticket dialog (since `position` is only computed while `status === "waiting"`), and the rest of the ticket still renders correctly. +10. Let the ticket reach `done`/`cancelled` — confirm the **Enregistrer mon ticket** button disappears. + +- [ ] **Step 3: Report back** + +Note any visual or behavioral issues found. Do not open a PR to `main` until this task's flow is confirmed working. + +--- + +## Post-plan: merging to main + +Once Task 7 is confirmed, open a PR from `dev` to `main` following the same pattern used for the `/carte` feature this session (`gh pr create --base main --head dev ...`), summarizing the ticket feature and linking back to this plan and its design doc (`docs/superpowers/specs/2026-07-24-ticket-download-design.md`). From 19bf0b93247f508bc3f620cb3679081a5c47737a Mon Sep 17 00:00:00 2001 From: atesta103 Date: Fri, 24 Jul 2026 11:36:02 +0200 Subject: [PATCH 03/11] feat(ticket): add time formatting and recover-URL helpers --- lib/utils/__tests__/ticket-download.test.ts | 51 ++++++++++++++++++ lib/utils/ticket-download.ts | 60 +++++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 lib/utils/__tests__/ticket-download.test.ts create mode 100644 lib/utils/ticket-download.ts diff --git a/lib/utils/__tests__/ticket-download.test.ts b/lib/utils/__tests__/ticket-download.test.ts new file mode 100644 index 0000000..92accc8 --- /dev/null +++ b/lib/utils/__tests__/ticket-download.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest" +import { + buildRecoverUrl, + formatArrivalTime, + parseRecoverParams, +} from "@/lib/utils/ticket-download" + +describe("formatArrivalTime", () => { + it("formats an ISO timestamp as HH:MM", () => { + const iso = new Date(Date.UTC(2026, 6, 24, 14, 32, 0)).toISOString() + expect(formatArrivalTime(iso)).toMatch(/^\d{2}:\d{2}$/) + }) +}) + +describe("buildRecoverUrl", () => { + it("builds a /{slug}/retrouver URL with the name and code as query params", () => { + const url = buildRecoverUrl({ + baseUrl: "https://waitlight.app", + slug: "testa-crousty", + customerName: "Jean-Paul", + code: "4F2K", + }) + expect(url).toBe( + "https://waitlight.app/testa-crousty/retrouver?name=Jean-Paul&code=4F2K", + ) + }) + + it("encodes special characters in the customer name", () => { + const url = buildRecoverUrl({ + baseUrl: "https://waitlight.app", + slug: "test", + customerName: "Anaïs & Léo", + code: "AB12", + }) + expect(url).toContain("name=Ana%C3%AFs+%26+L%C3%A9o") + }) +}) + +describe("parseRecoverParams", () => { + it("reads name and code from the URL search params", () => { + const params = new URLSearchParams("name=Jean&code=4F2K") + expect(parseRecoverParams(params)).toEqual({ name: "Jean", code: "4F2K" }) + }) + + it("defaults to empty strings when params are absent", () => { + expect(parseRecoverParams(new URLSearchParams(""))).toEqual({ + name: "", + code: "", + }) + }) +}) diff --git a/lib/utils/ticket-download.ts b/lib/utils/ticket-download.ts new file mode 100644 index 0000000..0b07294 --- /dev/null +++ b/lib/utils/ticket-download.ts @@ -0,0 +1,60 @@ +/** + * @module utils/ticket-download + * @category Utils + * + * Pure helpers for the downloadable queue ticket: formatting the arrival + * time shown on the ticket, and building/parsing the recovery URL encoded + * in its QR code. Building and parsing live together so the two query + * param names can only ever go out of sync in one place. + */ + +/** Query param names for the pre-filled /retrouver link. */ +export const RECOVER_NAME_PARAM = "name" +export const RECOVER_CODE_PARAM = "code" + +/** + * Formats an ISO timestamp as a short local time, e.g. "14:32". Uses the + * runtime's local timezone (correct for a client-rendered ticket — the + * runtime is the customer's own device) and the French locale to match the + * rest of the app's copy. + */ +export function formatArrivalTime(isoString: string): string { + return new Intl.DateTimeFormat("fr-FR", { + hour: "2-digit", + minute: "2-digit", + }).format(new Date(isoString)) +} + +/** + * Builds the /{slug}/retrouver URL pre-filled with the customer's name and + * recovery code, encoded into the ticket's QR code. Scanning it opens the + * existing recovery form already filled in — the customer still has to + * confirm and submit; nothing here auto-submits on their behalf. + */ +export function buildRecoverUrl(params: { + baseUrl: string + slug: string + customerName: string + code: string +}): string { + const query = new URLSearchParams({ + [RECOVER_NAME_PARAM]: params.customerName, + [RECOVER_CODE_PARAM]: params.code, + }) + return `${params.baseUrl}/${params.slug}/retrouver?${query.toString()}` +} + +/** + * Reads the pre-fill values a scanned ticket QR code may have put in the + * URL. Missing params come back as empty strings, matching the recovery + * form's own empty-input default. + */ +export function parseRecoverParams(searchParams: URLSearchParams): { + name: string + code: string +} { + return { + name: searchParams.get(RECOVER_NAME_PARAM) ?? "", + code: searchParams.get(RECOVER_CODE_PARAM) ?? "", + } +} From 4af6b1e4a2e2d58c8977e1c43fac4b193417d3f9 Mon Sep 17 00:00:00 2001 From: atesta103 Date: Fri, 24 Jul 2026 11:38:09 +0200 Subject: [PATCH 04/11] chore: add html-to-image for client-side ticket export --- package-lock.json | 7 +++++++ package.json | 1 + 2 files changed, 8 insertions(+) diff --git a/package-lock.json b/package-lock.json index 09c3018..2f9fb4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@vercel/speed-insights": "^2.0.0", "clsx": "^2.1.1", "framer-motion": "^12.34.3", + "html-to-image": "^1.11.13", "lucide-react": "^0.576.0", "maplibre-gl": "^5.24.0", "next": "16.1.6", @@ -12173,6 +12174,12 @@ "node": ">= 12" } }, + "node_modules/html-to-image": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", + "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", + "license": "MIT" + }, "node_modules/html-webpack-plugin": { "version": "5.6.6", "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.6.tgz", diff --git a/package.json b/package.json index 68753dc..d7b1b20 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@vercel/speed-insights": "^2.0.0", "clsx": "^2.1.1", "framer-motion": "^12.34.3", + "html-to-image": "^1.11.13", "lucide-react": "^0.576.0", "maplibre-gl": "^5.24.0", "next": "16.1.6", From f67afe0eca7cc71d3fc9635cff69f4bf98143585 Mon Sep 17 00:00:00 2001 From: atesta103 Date: Fri, 24 Jul 2026 11:41:33 +0200 Subject: [PATCH 05/11] feat(ticket): add TicketCard presentational component --- components/composed/TicketCard.tsx | 264 ++++++++++-------------- stories/composed/TicketCard.stories.tsx | 106 +++------- 2 files changed, 133 insertions(+), 237 deletions(-) diff --git a/components/composed/TicketCard.tsx b/components/composed/TicketCard.tsx index 6cf2712..77d506e 100644 --- a/components/composed/TicketCard.tsx +++ b/components/composed/TicketCard.tsx @@ -1,184 +1,136 @@ "use client" -import { Card } from "@/components/ui/Card" -import { Avatar } from "@/components/ui/Avatar" -import { Button } from "@/components/ui/Button" -import { Dropdown } from "@/components/ui/Dropdown" +import { forwardRef } from "react" +import { QRCodeCanvas } from "qrcode.react" import { cn } from "@/lib/utils/cn" -import { - PhoneCall, - X, - CheckCircle2, - MoreVertical, - ShieldAlert, -} from "lucide-react" - -type TicketStatus = "waiting" | "called" | "done" | "cancelled" +import { formatArrivalTime } from "@/lib/utils/ticket-download" type TicketCardProps = { - id: string + merchantName: string + merchantLogoUrl: string | null + /** Falls back to the app's own brand color when the merchant has none set. */ + merchantBrandColor: string | null customerName: string - entrySource?: "qr" | "manual" | "assisted" - status: TicketStatus - position?: number - joinedAt: string - onCall?: (id: string) => void - onComplete?: (id: string) => void - onCancel?: (id: string) => void - onReportName?: (id: string, name: string) => void + /** + * Live queue position at the moment the ticket was saved — a snapshot, + * never updated after. Null when unavailable (e.g. the ticket has + * already been called, when position is no longer meaningful). + */ + position: number | null + arrivalTimeIso: string + recoveryCode: string + /** Full URL encoded in the QR code — see buildRecoverUrl. */ + recoverUrl: string className?: string } -function TicketCard({ - id, - customerName, - entrySource, - status, - position, - joinedAt, - onCall, - onComplete, - onCancel, - onReportName, - className, -}: TicketCardProps) { - const formattedTime = new Intl.DateTimeFormat("fr-FR", { - hour: "2-digit", - minute: "2-digit", - }).format(new Date(joinedAt)) +const DEFAULT_BRAND_COLOR = "#6366f1" + +/** + * The visual captured to PNG when a customer downloads their ticket. Pure + * presentation — no state, no network calls — so it renders identically + * whether shown live in a dialog or captured off-screen. + */ +const TicketCard = forwardRef(function TicketCard( + { + merchantName, + merchantLogoUrl, + merchantBrandColor, + customerName, + position, + arrivalTimeIso, + recoveryCode, + recoverUrl, + className, + }, + ref, +) { + const brandColor = merchantBrandColor ?? DEFAULT_BRAND_COLOR return ( - - {/* Bloc de gauche : Avatar/Position + Nom/Temps */} -
- {position != null ? ( -
- #{position} -
+
+ {merchantLogoUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + ) : ( - + + {merchantName.trim().charAt(0).toUpperCase() || "?"} + )} + + {merchantName} + +
-
-
-

+

+
+
+ + Client + + {customerName} -

- {entrySource === "manual" && ( - - Ajout manuel + +
+ {position !== null ? ( +
+ + Position - )} - {entrySource === "assisted" && ( - - Scan assisté + + #{position} - )} -
-

- {status === "waiting" ? "Attente depuis " : "Appelé à "} - - {formattedTime} - -

+
+ ) : null}
-
- {/* Bloc de droite : Actions Primaires et au-delà */} -
- {/* Action en attente */} - {status === "waiting" && onCall && ( - - )} +
+ + Arrivée + + + {formatArrivalTime(arrivalTimeIso)} + +
- {/* Action appelé */} - {status === "called" && onComplete && ( - - )} +
- {/* Option "Annuler" et "Signaler" masquées dans le Menu Dropdown */} - {(onCancel || onReportName) && - (status === "waiting" || status === "called") && ( - - - - } - items={[ - ...(onCancel - ? [ - { - label: "Annuler le ticket", - icon: , - variant: "destructive" as const, - onClick: () => onCancel(id), - }, - ] - : []), - ...(onReportName && - customerName !== "…" && - !customerName.startsWith("Client-") - ? [ - { - label: "Signaler le prénom", - icon: , - onClick: () => - onReportName( - id, - customerName, - ), - }, - ] - : []), - ]} - /> - )} +
+
+ + Code de suivi + + + {recoveryCode} + + + Prénom + code sur waitlight.app + +
+ +
- +
) -} +}) -export { TicketCard, type TicketCardProps, type TicketStatus } +export { TicketCard, type TicketCardProps } diff --git a/stories/composed/TicketCard.stories.tsx b/stories/composed/TicketCard.stories.tsx index 2d6f1f3..a437a87 100644 --- a/stories/composed/TicketCard.stories.tsx +++ b/stories/composed/TicketCard.stories.tsx @@ -1,103 +1,47 @@ import type { Meta, StoryObj } from "@storybook/react" import { TicketCard } from "@/components/composed/TicketCard" - -const JOINED_AT = new Date(Date.now() - 12 * 60 * 1000).toISOString() -const CALLED_AT = new Date(Date.now() - 2 * 60 * 1000).toISOString() +import { buildRecoverUrl } from "@/lib/utils/ticket-download" const meta = { title: "Composed/TicketCard", component: TicketCard, tags: ["autodocs"], - parameters: { layout: "padded" }, + parameters: { layout: "centered" }, + args: { + merchantName: "TESTA CROUSTY", + merchantLogoUrl: null, + merchantBrandColor: "#EA580C", + customerName: "Alex", + position: 3, + arrivalTimeIso: new Date().toISOString(), + recoveryCode: "4F2K", + recoverUrl: buildRecoverUrl({ + baseUrl: "https://waitlight.app", + slug: "testa-crousty", + customerName: "Alex", + code: "4F2K", + }), + }, } satisfies Meta export default meta type Story = StoryObj -type StoryRender = StoryObj - -export const Waiting: Story = { - args: { - id: "ticket-1", - customerName: "Marie Dupond", - status: "waiting", - position: 1, - joinedAt: JOINED_AT, - onCall: (id: string) => alert(`Call ${id}`), - onCancel: (id: string) => alert(`Cancel ${id}`), - }, -} -export const Called: Story = { - args: { - id: "ticket-2", - customerName: "Lucas Martin", - status: "called", - position: 2, - joinedAt: CALLED_AT, - onComplete: (id: string) => alert(`Complete ${id}`), - onCancel: (id: string) => alert(`Cancel ${id}`), - }, -} +export const Default: Story = {} -export const Done: Story = { +export const WithLogo: Story = { args: { - id: "ticket-3", - customerName: "Pierre Bernard", - status: "done", - joinedAt: JOINED_AT, + merchantLogoUrl: "https://picsum.photos/seed/waitlight/80/80", }, } -export const Cancelled: Story = { - args: { - id: "ticket-4", - customerName: "Diana Petit", - status: "cancelled", - joinedAt: JOINED_AT, - }, +export const NoPosition: Story = { + args: { position: null }, } -export const WithoutPosition: Story = { +export const LongNames: Story = { args: { - id: "ticket-5", - customerName: "Alice Simon", - status: "waiting", - joinedAt: JOINED_AT, - onCall: () => {}, - onCancel: () => {}, + merchantName: "Boulangerie-Pâtisserie de la Grande Place du Village", + customerName: "Anne-Charlotte", }, } - -export const QueueList: StoryRender = { - render: () => ( -
- {}} - onCancel={() => {}} - /> - {}} - onCancel={() => {}} - /> - {}} - onCancel={() => {}} - /> -
- ), -} From 45d0779c85cb6f9b9fa2aa0217144b1233cfacf2 Mon Sep 17 00:00:00 2001 From: atesta103 Date: Fri, 24 Jul 2026 11:44:53 +0200 Subject: [PATCH 06/11] fix(ticket): rename ticket-download card to avoid collision with the existing merchant TicketCard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 3 named the new customer-facing, downloadable-ticket component "TicketCard" without checking for an existing name — a component with that exact name and path already existed for the merchant dashboard's queue list (components/sections/QueueList.tsx, app/design-system/page.tsx), with an entirely different prop contract (id, status, onCall/onComplete/ onCancel/onReportName). The implementer's commit silently overwrote it, which would have broken the merchant queue dashboard. Restores components/composed/TicketCard.tsx and its story to their pre-Task-3 state, and adds the new component under its own name, TicketDownloadCard, in TicketDownloadCard.tsx / TicketDownloadCard.stories.tsx. Updates the plan document throughout so Tasks 4-6 reference the correct name going forward. Verified: tsc --noEmit clean, eslint clean, app-storybook contract check passes, both merchant-facing importers (QueueList.tsx, design-system/page.tsx) resolve against the restored original with no changes needed on their end. Co-Authored-By: Claude Opus 4.8 --- components/composed/TicketCard.tsx | 264 +++++++++++------- components/composed/TicketDownloadCard.tsx | 138 +++++++++ .../plans/2026-07-24-ticket-download.md | 42 +-- stories/composed/TicketCard.stories.tsx | 106 +++++-- .../composed/TicketDownloadCard.stories.tsx | 47 ++++ 5 files changed, 443 insertions(+), 154 deletions(-) create mode 100644 components/composed/TicketDownloadCard.tsx create mode 100644 stories/composed/TicketDownloadCard.stories.tsx diff --git a/components/composed/TicketCard.tsx b/components/composed/TicketCard.tsx index 77d506e..6cf2712 100644 --- a/components/composed/TicketCard.tsx +++ b/components/composed/TicketCard.tsx @@ -1,136 +1,184 @@ "use client" -import { forwardRef } from "react" -import { QRCodeCanvas } from "qrcode.react" +import { Card } from "@/components/ui/Card" +import { Avatar } from "@/components/ui/Avatar" +import { Button } from "@/components/ui/Button" +import { Dropdown } from "@/components/ui/Dropdown" import { cn } from "@/lib/utils/cn" -import { formatArrivalTime } from "@/lib/utils/ticket-download" +import { + PhoneCall, + X, + CheckCircle2, + MoreVertical, + ShieldAlert, +} from "lucide-react" + +type TicketStatus = "waiting" | "called" | "done" | "cancelled" type TicketCardProps = { - merchantName: string - merchantLogoUrl: string | null - /** Falls back to the app's own brand color when the merchant has none set. */ - merchantBrandColor: string | null + id: string customerName: string - /** - * Live queue position at the moment the ticket was saved — a snapshot, - * never updated after. Null when unavailable (e.g. the ticket has - * already been called, when position is no longer meaningful). - */ - position: number | null - arrivalTimeIso: string - recoveryCode: string - /** Full URL encoded in the QR code — see buildRecoverUrl. */ - recoverUrl: string + entrySource?: "qr" | "manual" | "assisted" + status: TicketStatus + position?: number + joinedAt: string + onCall?: (id: string) => void + onComplete?: (id: string) => void + onCancel?: (id: string) => void + onReportName?: (id: string, name: string) => void className?: string } -const DEFAULT_BRAND_COLOR = "#6366f1" - -/** - * The visual captured to PNG when a customer downloads their ticket. Pure - * presentation — no state, no network calls — so it renders identically - * whether shown live in a dialog or captured off-screen. - */ -const TicketCard = forwardRef(function TicketCard( - { - merchantName, - merchantLogoUrl, - merchantBrandColor, - customerName, - position, - arrivalTimeIso, - recoveryCode, - recoverUrl, - className, - }, - ref, -) { - const brandColor = merchantBrandColor ?? DEFAULT_BRAND_COLOR +function TicketCard({ + id, + customerName, + entrySource, + status, + position, + joinedAt, + onCall, + onComplete, + onCancel, + onReportName, + className, +}: TicketCardProps) { + const formattedTime = new Intl.DateTimeFormat("fr-FR", { + hour: "2-digit", + minute: "2-digit", + }).format(new Date(joinedAt)) return ( -
-
- {merchantLogoUrl ? ( - // eslint-disable-next-line @next/next/no-img-element - + {/* Bloc de gauche : Avatar/Position + Nom/Temps */} +
+ {position != null ? ( +
+ #{position} +
) : ( - - {merchantName.trim().charAt(0).toUpperCase() || "?"} - + )} - - {merchantName} - -
-
-
-
- - Client - - +
+
+

{customerName} - -

- {position !== null ? ( -
- - Position +

+ {entrySource === "manual" && ( + + Ajout manuel - - #{position} + )} + {entrySource === "assisted" && ( + + Scan assisté -
- ) : null} + )} +
+

+ {status === "waiting" ? "Attente depuis " : "Appelé à "} + + {formattedTime} + +

+
-
- - Arrivée - - - {formatArrivalTime(arrivalTimeIso)} - -
+ {/* Bloc de droite : Actions Primaires et au-delà */} +
+ {/* Action en attente */} + {status === "waiting" && onCall && ( + + )} -
+ {/* Action appelé */} + {status === "called" && onComplete && ( + + )} -
-
- - Code de suivi - - - {recoveryCode} - - - Prénom + code sur waitlight.app - -
- -
+ {/* Option "Annuler" et "Signaler" masquées dans le Menu Dropdown */} + {(onCancel || onReportName) && + (status === "waiting" || status === "called") && ( + + + + } + items={[ + ...(onCancel + ? [ + { + label: "Annuler le ticket", + icon: , + variant: "destructive" as const, + onClick: () => onCancel(id), + }, + ] + : []), + ...(onReportName && + customerName !== "…" && + !customerName.startsWith("Client-") + ? [ + { + label: "Signaler le prénom", + icon: , + onClick: () => + onReportName( + id, + customerName, + ), + }, + ] + : []), + ]} + /> + )}
-
+ ) -}) +} -export { TicketCard, type TicketCardProps } +export { TicketCard, type TicketCardProps, type TicketStatus } diff --git a/components/composed/TicketDownloadCard.tsx b/components/composed/TicketDownloadCard.tsx new file mode 100644 index 0000000..5d94627 --- /dev/null +++ b/components/composed/TicketDownloadCard.tsx @@ -0,0 +1,138 @@ +"use client" + +import { forwardRef } from "react" +import { QRCodeCanvas } from "qrcode.react" +import { cn } from "@/lib/utils/cn" +import { formatArrivalTime } from "@/lib/utils/ticket-download" + +type TicketDownloadCardProps = { + merchantName: string + merchantLogoUrl: string | null + /** Falls back to the app's own brand color when the merchant has none set. */ + merchantBrandColor: string | null + customerName: string + /** + * Live queue position at the moment the ticket was saved — a snapshot, + * never updated after. Null when unavailable (e.g. the ticket has + * already been called, when position is no longer meaningful). + */ + position: number | null + arrivalTimeIso: string + recoveryCode: string + /** Full URL encoded in the QR code — see buildRecoverUrl. */ + recoverUrl: string + className?: string +} + +const DEFAULT_BRAND_COLOR = "#6366f1" + +/** + * The visual captured to PNG when a customer downloads their ticket. Pure + * presentation — no state, no network calls — so it renders identically + * whether shown live in a dialog or captured off-screen. + */ +const TicketDownloadCard = forwardRef( + function TicketDownloadCard( + { + merchantName, + merchantLogoUrl, + merchantBrandColor, + customerName, + position, + arrivalTimeIso, + recoveryCode, + recoverUrl, + className, + }, + ref, + ) { + const brandColor = merchantBrandColor ?? DEFAULT_BRAND_COLOR + + return ( +
+
+ {merchantLogoUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + + {merchantName.trim().charAt(0).toUpperCase() || "?"} + + )} + + {merchantName} + +
+ +
+
+
+ + Client + + + {customerName} + +
+ {position !== null ? ( +
+ + Position + + + #{position} + +
+ ) : null} +
+ +
+ + Arrivée + + + {formatArrivalTime(arrivalTimeIso)} + +
+ +
+ +
+
+ + Code de suivi + + + {recoveryCode} + + + Prénom + code sur waitlight.app + +
+ +
+
+
+ ) + }, +) + +export { TicketDownloadCard, type TicketDownloadCardProps } diff --git a/docs/superpowers/plans/2026-07-24-ticket-download.md b/docs/superpowers/plans/2026-07-24-ticket-download.md index 742b561..641718c 100644 --- a/docs/superpowers/plans/2026-07-24-ticket-download.md +++ b/docs/superpowers/plans/2026-07-24-ticket-download.md @@ -4,7 +4,7 @@ **Goal:** Let a customer waiting in a queue download a PNG ticket (merchant identity, name, position snapshot, arrival time, recovery code, QR code) so they can find their way back to their ticket from any device, not just the browser they joined from. -**Architecture:** A pure-logic utils module (`lib/utils/ticket-download.ts`) backs a presentational `TicketCard` component (`components/composed/TicketCard.tsx`), captured to PNG client-side via `html-to-image` from a button on the existing `/wait` page. The QR code encodes a pre-filled link to the existing `/retrouver` recovery form. +**Architecture:** A pure-logic utils module (`lib/utils/ticket-download.ts`) backs a presentational `TicketDownloadCard` component (`components/composed/TicketDownloadCard.tsx`), captured to PNG client-side via `html-to-image` from a button on the existing `/wait` page. The QR code encodes a pre-filled link to the existing `/retrouver` recovery form. **Tech Stack:** Next.js App Router, React, TypeScript, Tailwind, `qrcode.react` (existing dependency), `html-to-image` (new dependency), Vitest for unit tests. @@ -210,21 +210,21 @@ git push origin dev --- -### Task 3: TicketCard component +### Task 3: TicketDownloadCard component **Files:** -- Create: `components/composed/TicketCard.tsx` -- Create: `stories/composed/TicketCard.stories.tsx` +- Create: `components/composed/TicketDownloadCard.tsx` +- Create: `stories/composed/TicketDownloadCard.stories.tsx` **Interfaces:** - Consumes: `formatArrivalTime` from `@/lib/utils/ticket-download` (Task 1), `cn` from `@/lib/utils/cn`, `QRCodeCanvas` from `qrcode.react`. -- Produces: `TicketCard` — a `forwardRef` component; `type TicketCardProps` with fields `merchantName: string`, `merchantLogoUrl: string | null`, `merchantBrandColor: string | null`, `customerName: string`, `position: number | null`, `arrivalTimeIso: string`, `recoveryCode: string`, `recoverUrl: string`, `className?: string`. The forwarded ref attaches to the card's root `
` — Task 6 uses it as the capture target for `html-to-image`. +- Produces: `TicketDownloadCard` — a `forwardRef` component; `type TicketDownloadCardProps` with fields `merchantName: string`, `merchantLogoUrl: string | null`, `merchantBrandColor: string | null`, `customerName: string`, `position: number | null`, `arrivalTimeIso: string`, `recoveryCode: string`, `recoverUrl: string`, `className?: string`. The forwarded ref attaches to the card's root `
` — Task 6 uses it as the capture target for `html-to-image`. No network calls, no internal state — pure presentation, so it's safe to capture identically wherever it's mounted. - [ ] **Step 1: Write the component** -Create `components/composed/TicketCard.tsx`: +Create `components/composed/TicketDownloadCard.tsx`: ```tsx "use client" @@ -234,7 +234,7 @@ import { QRCodeCanvas } from "qrcode.react" import { cn } from "@/lib/utils/cn" import { formatArrivalTime } from "@/lib/utils/ticket-download" -type TicketCardProps = { +type TicketDownloadCardProps = { merchantName: string merchantLogoUrl: string | null /** Falls back to the app's own brand color when the merchant has none set. */ @@ -260,7 +260,7 @@ const DEFAULT_BRAND_COLOR = "#6366f1" * presentation — no state, no network calls — so it renders identically * whether shown live in a dialog or captured off-screen. */ -const TicketCard = forwardRef(function TicketCard( +const TicketDownloadCard = forwardRef(function TicketDownloadCard( { merchantName, merchantLogoUrl, @@ -362,7 +362,7 @@ const TicketCard = forwardRef(function TicketCa ) }) -export { TicketCard, type TicketCardProps } +export { TicketDownloadCard, type TicketDownloadCardProps } ``` - [ ] **Step 2: Write the Storybook story** @@ -373,16 +373,16 @@ every existing composed component (`MerchantsMap`, `AddressAutocomplete`) is verified visually via a Storybook story, not a render test. `Default` (no logo) and `WithLogo` below cover that spec line the same way. -Create `stories/composed/TicketCard.stories.tsx`: +Create `stories/composed/TicketDownloadCard.stories.tsx`: ```tsx import type { Meta, StoryObj } from "@storybook/react" -import { TicketCard } from "@/components/composed/TicketCard" +import { TicketDownloadCard } from "@/components/composed/TicketDownloadCard" import { buildRecoverUrl } from "@/lib/utils/ticket-download" const meta = { - title: "Composed/TicketCard", - component: TicketCard, + title: "Composed/TicketDownloadCard", + component: TicketDownloadCard, tags: ["autodocs"], parameters: { layout: "centered" }, args: { @@ -400,7 +400,7 @@ const meta = { code: "4F2K", }), }, -} satisfies Meta +} satisfies Meta export default meta type Story = StoryObj @@ -427,7 +427,7 @@ export const LongNames: Story = { - [ ] **Step 3: Typecheck and lint** -Run: `npx tsc --noEmit && npx eslint components/composed/TicketCard.tsx` +Run: `npx tsc --noEmit && npx eslint components/composed/TicketDownloadCard.tsx` Expected: no errors - [ ] **Step 4: Verify the app↔storybook contract check passes** @@ -438,8 +438,8 @@ Expected: `✅ [contract] App ↔ design system contract OK` - [ ] **Step 5: Commit** ```bash -git add components/composed/TicketCard.tsx stories/composed/TicketCard.stories.tsx -git commit -m "feat(ticket): add TicketCard presentational component" +git add components/composed/TicketDownloadCard.tsx stories/composed/TicketDownloadCard.stories.tsx +git commit -m "feat(ticket): add TicketDownloadCard presentational component" git push origin dev ``` @@ -477,7 +477,7 @@ import { Search } from "lucide-react" Then, right after the existing `useState` declarations (`customerName`, `code`, `error`, `isLoading`), add: ```tsx - // Pre-fill from a scanned ticket QR code (see buildRecoverUrl / TicketCard). + // Pre-fill from a scanned ticket QR code (see buildRecoverUrl / TicketDownloadCard). // Read directly from window.location rather than useSearchParams(), which // avoids that hook's Suspense-boundary requirement and any hydration // mismatch from differing server/client initial state — this runs once, @@ -621,7 +621,7 @@ git push origin dev - Modify: `app/[slug]/wait/[ticketId]/WaitClient.tsx:1-14` (imports), `:358-390` (JSX) **Interfaces:** -- Consumes: `TicketCard` (Task 3), `buildRecoverUrl` (Task 1), `toPng` from `html-to-image` (Task 2), the `Merchant.logo_url`/`Merchant.brand_color` fields (Task 5). +- Consumes: `TicketDownloadCard` (Task 3), `buildRecoverUrl` (Task 1), `toPng` from `html-to-image` (Task 2), the `Merchant.logo_url`/`Merchant.brand_color` fields (Task 5). This is the integration task — everything else was built and verified independently. `NEXT_PUBLIC_BASE_URL` is read the same way the existing join-QR code already does in `components/composed/QRCodeDisplay.tsx:45`, for consistency and because it's the SSR-safe way to get the app's own origin (`window.location.origin` would crash during this component's server render pass). @@ -640,7 +640,7 @@ import { Spinner } from "@/components/ui/Spinner" import { StatusBanner } from "@/components/composed/StatusBanner" import { Dialog, DialogHeader, DialogContent, DialogFooter } from "@/components/ui/Dialog" import { Button } from "@/components/ui/Button" -import { TicketCard } from "@/components/composed/TicketCard" +import { TicketDownloadCard } from "@/components/composed/TicketDownloadCard" import { type ConnectionState } from "@/components/composed/ConnectionStatus" import { BellRing, Smartphone, MessageSquare, AlertCircle, Download } from "lucide-react" import { playHapticBuzz, playSound, unlockAudio, type SoundChoice } from "@/lib/utils/notifications" @@ -723,7 +723,7 @@ In `app/[slug]/wait/[ticketId]/WaitClient.tsx`, right after the existing recover Votre ticket
- export default meta type Story = StoryObj +type StoryRender = StoryObj + +export const Waiting: Story = { + args: { + id: "ticket-1", + customerName: "Marie Dupond", + status: "waiting", + position: 1, + joinedAt: JOINED_AT, + onCall: (id: string) => alert(`Call ${id}`), + onCancel: (id: string) => alert(`Cancel ${id}`), + }, +} -export const Default: Story = {} +export const Called: Story = { + args: { + id: "ticket-2", + customerName: "Lucas Martin", + status: "called", + position: 2, + joinedAt: CALLED_AT, + onComplete: (id: string) => alert(`Complete ${id}`), + onCancel: (id: string) => alert(`Cancel ${id}`), + }, +} -export const WithLogo: Story = { +export const Done: Story = { args: { - merchantLogoUrl: "https://picsum.photos/seed/waitlight/80/80", + id: "ticket-3", + customerName: "Pierre Bernard", + status: "done", + joinedAt: JOINED_AT, }, } -export const NoPosition: Story = { - args: { position: null }, +export const Cancelled: Story = { + args: { + id: "ticket-4", + customerName: "Diana Petit", + status: "cancelled", + joinedAt: JOINED_AT, + }, } -export const LongNames: Story = { +export const WithoutPosition: Story = { args: { - merchantName: "Boulangerie-Pâtisserie de la Grande Place du Village", - customerName: "Anne-Charlotte", + id: "ticket-5", + customerName: "Alice Simon", + status: "waiting", + joinedAt: JOINED_AT, + onCall: () => {}, + onCancel: () => {}, }, } + +export const QueueList: StoryRender = { + render: () => ( +
+ {}} + onCancel={() => {}} + /> + {}} + onCancel={() => {}} + /> + {}} + onCancel={() => {}} + /> +
+ ), +} diff --git a/stories/composed/TicketDownloadCard.stories.tsx b/stories/composed/TicketDownloadCard.stories.tsx new file mode 100644 index 0000000..6276cc3 --- /dev/null +++ b/stories/composed/TicketDownloadCard.stories.tsx @@ -0,0 +1,47 @@ +import type { Meta, StoryObj } from "@storybook/react" +import { TicketDownloadCard } from "@/components/composed/TicketDownloadCard" +import { buildRecoverUrl } from "@/lib/utils/ticket-download" + +const meta = { + title: "Composed/TicketDownloadCard", + component: TicketDownloadCard, + tags: ["autodocs"], + parameters: { layout: "centered" }, + args: { + merchantName: "TESTA CROUSTY", + merchantLogoUrl: null, + merchantBrandColor: "#EA580C", + customerName: "Alex", + position: 3, + arrivalTimeIso: new Date().toISOString(), + recoveryCode: "4F2K", + recoverUrl: buildRecoverUrl({ + baseUrl: "https://waitlight.app", + slug: "testa-crousty", + customerName: "Alex", + code: "4F2K", + }), + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = {} + +export const WithLogo: Story = { + args: { + merchantLogoUrl: "https://picsum.photos/seed/waitlight/80/80", + }, +} + +export const NoPosition: Story = { + args: { position: null }, +} + +export const LongNames: Story = { + args: { + merchantName: "Boulangerie-Pâtisserie de la Grande Place du Village", + customerName: "Anne-Charlotte", + }, +} From 9b683c09d43ff0821eaf02519eb2a3d786b98528 Mon Sep 17 00:00:00 2001 From: atesta103 Date: Fri, 24 Jul 2026 11:47:43 +0200 Subject: [PATCH 07/11] feat(retrouver): pre-fill name and code from a scanned ticket QR --- app/[slug]/retrouver/RecoverClient.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/app/[slug]/retrouver/RecoverClient.tsx b/app/[slug]/retrouver/RecoverClient.tsx index 57f7498..fd33ae0 100644 --- a/app/[slug]/retrouver/RecoverClient.tsx +++ b/app/[slug]/retrouver/RecoverClient.tsx @@ -1,6 +1,6 @@ "use client" -import { useState } from "react" +import { useEffect, useState } from "react" import { useRouter } from "next/navigation" import Link from "next/link" import { Card } from "@/components/ui/Card" @@ -8,6 +8,7 @@ import { Input } from "@/components/ui/Input" import { Button } from "@/components/ui/Button" import { StatusBanner } from "@/components/composed/StatusBanner" import { findTicketByRecoveryCodeAction } from "@/lib/actions/queue" +import { parseRecoverParams } from "@/lib/utils/ticket-download" import { Search } from "lucide-react" type RecoverClientProps = { @@ -28,6 +29,21 @@ function RecoverClient({ slug, merchantName }: RecoverClientProps) { const [error, setError] = useState(null) const [isLoading, setIsLoading] = useState(false) + // Pre-fill from a scanned ticket QR code (see buildRecoverUrl / TicketDownloadCard). + // Read directly from window.location rather than useSearchParams(), which + // avoids that hook's Suspense-boundary requirement and any hydration + // mismatch from differing server/client initial state — this runs once, + // client-side only, after the form's normal empty state has mounted. + useEffect(() => { + const { name, code: prefillCode } = parseRecoverParams( + new URLSearchParams(window.location.search), + ) + if (name) setCustomerName(name) + if (prefillCode) setCode(prefillCode) + // Only ever want this once, on mount, to seed from a scanned link. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + async function handleSubmit(e: React.FormEvent) { e.preventDefault() setIsLoading(true) From bb2b41649fd482ebb521c6d5c0d2ef03354be48c Mon Sep 17 00:00:00 2001 From: atesta103 Date: Fri, 24 Jul 2026 11:50:27 +0200 Subject: [PATCH 08/11] fix(retrouver): defer prefill setState out of the effect body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review flagged the eslint-disable comment as suppressing the wrong rule: it targeted exhaustive-deps, which wasn't actually firing (no missing dep — setCustomerName/setCode are stable, parseRecoverParams is a stable import), while the real warning was react-hooks/set-state-in-effect ("calling setState synchronously within an effect can trigger cascading renders"). Applies the same fix already established in this codebase for the identical warning (CarteClient.tsx's geolocation effect): defer the setState calls via setTimeout(0), matching the "defer to avoid a synchronous setState in the effect body" pattern used there. No remaining eslint-disable needed either — the deferred version doesn't trigger the exhaustive-deps rule. Co-Authored-By: Claude Opus 4.8 --- app/[slug]/retrouver/RecoverClient.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/app/[slug]/retrouver/RecoverClient.tsx b/app/[slug]/retrouver/RecoverClient.tsx index fd33ae0..9dd452c 100644 --- a/app/[slug]/retrouver/RecoverClient.tsx +++ b/app/[slug]/retrouver/RecoverClient.tsx @@ -38,10 +38,14 @@ function RecoverClient({ slug, merchantName }: RecoverClientProps) { const { name, code: prefillCode } = parseRecoverParams( new URLSearchParams(window.location.search), ) - if (name) setCustomerName(name) - if (prefillCode) setCode(prefillCode) + if (!name && !prefillCode) return + // Defer to avoid a synchronous setState in the effect body. + const t = setTimeout(() => { + if (name) setCustomerName(name) + if (prefillCode) setCode(prefillCode) + }, 0) + return () => clearTimeout(t) // Only ever want this once, on mount, to seed from a scanned link. - // eslint-disable-next-line react-hooks/exhaustive-deps }, []) async function handleSubmit(e: React.FormEvent) { From e8fbb10bdc7ee3a0b7aab4d9f795281747b838b1 Mon Sep 17 00:00:00 2001 From: atesta103 Date: Fri, 24 Jul 2026 11:52:33 +0200 Subject: [PATCH 09/11] feat(wait): fetch merchant logo and brand color Co-Authored-By: Claude Sonnet 5 --- app/[slug]/wait/[ticketId]/WaitClient.tsx | 2 ++ app/[slug]/wait/[ticketId]/page.tsx | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/[slug]/wait/[ticketId]/WaitClient.tsx b/app/[slug]/wait/[ticketId]/WaitClient.tsx index 11ad009..cc76208 100644 --- a/app/[slug]/wait/[ticketId]/WaitClient.tsx +++ b/app/[slug]/wait/[ticketId]/WaitClient.tsx @@ -29,6 +29,8 @@ type Merchant = { default_prep_time_min: number /** Auto-computed average prep time. null = not enough data, fall back to default. */ calculated_avg_prep_time: number | null + logo_url: string | null + brand_color: string | null settings: { notification_channels: NotificationChannels notification_sound: SoundChoice diff --git a/app/[slug]/wait/[ticketId]/page.tsx b/app/[slug]/wait/[ticketId]/page.tsx index b3a9d8f..a713496 100644 --- a/app/[slug]/wait/[ticketId]/page.tsx +++ b/app/[slug]/wait/[ticketId]/page.tsx @@ -25,7 +25,7 @@ export default async function WaitPage({ params }: WaitPageProps) { .from("merchants") .select(` id, name, slug, background_url, default_prep_time_min, calculated_avg_prep_time, - business_type, + business_type, logo_url, brand_color, settings!inner( notification_channels, notification_sound, @@ -53,6 +53,8 @@ export default async function WaitPage({ params }: WaitPageProps) { business_type: data.business_type, default_prep_time_min: data.default_prep_time_min, calculated_avg_prep_time: data.calculated_avg_prep_time, + logo_url: data.logo_url, + brand_color: data.brand_color, settings: Array.isArray(data.settings) ? data.settings[0] : data.settings } From bff7b60b5508994221f3df883263cd2b7ddd2379 Mon Sep 17 00:00:00 2001 From: atesta103 Date: Fri, 24 Jul 2026 11:55:35 +0200 Subject: [PATCH 10/11] feat(wait): add downloadable ticket button and dialog --- app/[slug]/wait/[ticketId]/WaitClient.tsx | 87 ++++++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) diff --git a/app/[slug]/wait/[ticketId]/WaitClient.tsx b/app/[slug]/wait/[ticketId]/WaitClient.tsx index cc76208..e57e33d 100644 --- a/app/[slug]/wait/[ticketId]/WaitClient.tsx +++ b/app/[slug]/wait/[ticketId]/WaitClient.tsx @@ -8,10 +8,13 @@ import { Spinner } from "@/components/ui/Spinner" import { StatusBanner } from "@/components/composed/StatusBanner" import { Dialog, DialogHeader, DialogContent, DialogFooter } from "@/components/ui/Dialog" import { Button } from "@/components/ui/Button" +import { TicketDownloadCard } from "@/components/composed/TicketDownloadCard" import { type ConnectionState } from "@/components/composed/ConnectionStatus" -import { BellRing, Smartphone, MessageSquare, AlertCircle } from "lucide-react" +import { BellRing, Smartphone, MessageSquare, AlertCircle, Download } from "lucide-react" import { playHapticBuzz, playSound, unlockAudio, type SoundChoice } from "@/lib/utils/notifications" import { getBusinessWording } from "@/lib/utils/business-wording" +import { buildRecoverUrl } from "@/lib/utils/ticket-download" +import { toPng } from "html-to-image" type NotificationChannels = { sound: boolean @@ -82,7 +85,30 @@ function WaitClient({ merchant, ticketId }: WaitClientProps) { } }) + const [ticketDialogOpen, setTicketDialogOpen] = useState(false) + const [isDownloading, setIsDownloading] = useState(false) + const [downloadError, setDownloadError] = useState(null) + const ticketCardRef = useRef(null) + async function handleDownloadTicket() { + if (!ticketCardRef.current) return + setIsDownloading(true) + setDownloadError(null) + try { + const dataUrl = await toPng(ticketCardRef.current, { pixelRatio: 2 }) + const link = document.createElement("a") + link.href = dataUrl + link.download = `ticket-${merchant.slug}.png` + link.click() + } catch (err) { + console.error("[WaitClient] Ticket image export failed:", err) + setDownloadError( + "Impossible de générer l'image. Notez le code ci-dessus pour retrouver votre place.", + ) + } finally { + setIsDownloading(false) + } + } // ── TanStack Query ──────────────────────────────────────────────────────── @@ -357,6 +383,20 @@ function WaitClient({ merchant, ticketId }: WaitClientProps) { // Check if we need to show the moderation warning dialog const showModerationWarning = ticket.name_flagged && !acknowledgedFlag + // Same lifecycle as the recovery-code card just below: a ticket can be + // saved while it's still active, not once it's done or cancelled. + const canDownloadTicket = + (ticket.status === "waiting" || ticket.status === "called") && !!ticket.recovery_code + + const recoverUrl = ticket.recovery_code + ? buildRecoverUrl({ + baseUrl: process.env.NEXT_PUBLIC_BASE_URL ?? "https://waitlight.app", + slug: merchant.slug, + customerName: ticket.customer_name, + code: ticket.recovery_code, + }) + : "" + return (
)} + {canDownloadTicket && ( + + )} + + {canDownloadTicket && ( + setTicketDialogOpen(false)}> + Votre ticket + +
+ + {downloadError ? ( +

+ {downloadError} +

+ ) : null} +
+
+ + + +
+ )} + {ticket.status === "called" && !calledReminderAcknowledged && ( setCalledReminderAcknowledged(true)}> C'est votre tour From 7c3bdb2d168404288607446c8bf0bcc3d71fb725 Mon Sep 17 00:00:00 2001 From: atesta103 Date: Fri, 24 Jul 2026 12:04:23 +0200 Subject: [PATCH 11/11] docs(spec): correct position-freezing claim to match shipped behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The final whole-branch review caught that WaitClient.tsx passes the live polled position (WaitClient.tsx:458), not a snapshot frozen at the "Enregistrer" click as originally specced. Confirmed with the user this is the preferred behavior — live is arguably more accurate than a value that could go stale while the dialog sits open — so the code stays as built and the spec is corrected to describe it, rather than leaving a written contradiction. Co-Authored-By: Claude Opus 4.8 --- docs/superpowers/specs/2026-07-24-ticket-download-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-07-24-ticket-download-design.md b/docs/superpowers/specs/2026-07-24-ticket-download-design.md index a8e6c24..f4c7d18 100644 --- a/docs/superpowers/specs/2026-07-24-ticket-download-design.md +++ b/docs/superpowers/specs/2026-07-24-ticket-download-design.md @@ -27,7 +27,7 @@ Le téléchargement utilise `html-to-image` (nouvelle dépendance, légère, act | Logo | `merchant.logo_url` | **à ajouter** au select de `page.tsx` et au type `Merchant` de `WaitClient.tsx` | | Couleur de marque | `merchant.brand_color` | **à ajouter**, idem. Repli sur la couleur de marque par défaut de l'app si absente | | Prénom du client | `ticket.customer_name` | déjà disponible | -| Numéro / position | state React existant (position en file) | **instantané figé** au moment du clic sur « Enregistrer » — jamais recalculé après coup, l'image ne ment pas sur une position qui aurait bougé | +| Numéro / position | state React existant (position en file) | **en direct** tant que la fenêtre reste ouverte — la valeur capturée à l'image est celle affichée au moment du clic sur « Télécharger », donc toujours la plus à jour possible. Décision prise après revue finale du code : préféré à un gel au clic sur « Enregistrer », qui aurait pu figer une position déjà obsolète. `null` si le ticket a déjà été appelé (la position en file n'a alors plus de sens) | | Heure d'arrivée | `ticket.joined_at` | déjà disponible, formatée en heure locale lisible | | Code de récupération | `ticket.recovery_code` | déjà disponible, affiché en texte, gros, lisible | | QR code | généré côté client avec `qrcode.react` (déjà une dépendance du projet, utilisée pour le QR de rejoindre la file) | voir section dédiée |