diff --git a/app/[slug]/retrouver/RecoverClient.tsx b/app/[slug]/retrouver/RecoverClient.tsx index 57f7498..9dd452c 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,25 @@ 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 && !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. + }, []) + async function handleSubmit(e: React.FormEvent) { e.preventDefault() setIsLoading(true) diff --git a/app/[slug]/wait/[ticketId]/WaitClient.tsx b/app/[slug]/wait/[ticketId]/WaitClient.tsx index 11ad009..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 @@ -29,6 +32,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 @@ -80,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 ──────────────────────────────────────────────────────── @@ -355,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 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 } 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 new file mode 100644 index 0000000..641718c --- /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 `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. + +## 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`). 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..f4c7d18 --- /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) | **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 | + +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 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) ?? "", + } +} 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", 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", + }, +}