Skip to content
Merged

Dev #71

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion app/[slug]/retrouver/RecoverClient.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
"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"
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 = {
Expand All @@ -28,6 +29,25 @@ function RecoverClient({ slug, merchantName }: RecoverClientProps) {
const [error, setError] = useState<string | null>(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)
Expand Down
89 changes: 88 additions & 1 deletion app/[slug]/wait/[ticketId]/WaitClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -80,7 +85,30 @@ function WaitClient({ merchant, ticketId }: WaitClientProps) {
}
})

const [ticketDialogOpen, setTicketDialogOpen] = useState(false)
const [isDownloading, setIsDownloading] = useState(false)
const [downloadError, setDownloadError] = useState<string | null>(null)
const ticketCardRef = useRef<HTMLDivElement>(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 ────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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 (
<div className="flex flex-col gap-4">
<CustomerWaitView
Expand Down Expand Up @@ -389,6 +431,51 @@ function WaitClient({ merchant, ticketId }: WaitClientProps) {
</div>
)}

{canDownloadTicket && (
<Button
variant="secondary"
onClick={() => {
setDownloadError(null)
setTicketDialogOpen(true)
}}
>
<Download size={16} aria-hidden="true" />
Enregistrer mon ticket
</Button>
)}

{canDownloadTicket && (
<Dialog open={ticketDialogOpen} onClose={() => setTicketDialogOpen(false)}>
<DialogHeader>Votre ticket</DialogHeader>
<DialogContent>
<div className="flex flex-col items-center gap-4">
<TicketDownloadCard
ref={ticketCardRef}
merchantName={merchant.name}
merchantLogoUrl={merchant.logo_url}
merchantBrandColor={merchant.brand_color}
customerName={ticket.customer_name}
position={ticket.status === "waiting" ? (position ?? null) : null}
arrivalTimeIso={ticket.joined_at}
recoveryCode={ticket.recovery_code ?? ""}
recoverUrl={recoverUrl}
/>
{downloadError ? (
<p className="text-sm text-feedback-error" role="alert">
{downloadError}
</p>
) : null}
</div>
</DialogContent>
<DialogFooter>
<Button onClick={handleDownloadTicket} isLoading={isDownloading}>
<Download size={16} aria-hidden="true" />
Télécharger l&apos;image
</Button>
</DialogFooter>
</Dialog>
)}

{ticket.status === "called" && !calledReminderAcknowledged && (
<Dialog open onClose={() => setCalledReminderAcknowledged(true)}>
<DialogHeader>C&apos;est votre tour</DialogHeader>
Expand Down
4 changes: 3 additions & 1 deletion app/[slug]/wait/[ticketId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
}

Expand Down
138 changes: 138 additions & 0 deletions components/composed/TicketDownloadCard.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement, TicketDownloadCardProps>(
function TicketDownloadCard(
{
merchantName,
merchantLogoUrl,
merchantBrandColor,
customerName,
position,
arrivalTimeIso,
recoveryCode,
recoverUrl,
className,
},
ref,
) {
const brandColor = merchantBrandColor ?? DEFAULT_BRAND_COLOR

return (
<div
ref={ref}
className={cn(
// max-w rather than a fixed width: the ticket must still fit
// the Dialog's own width on a narrow phone (Dialog caps at
// calc(100%-2rem), ~288px on a 320px-wide screen) — a hard
// 340px would overflow there. html-to-image captures whatever
// size actually rendered, so shrinking here is harmless.
"flex w-full max-w-[340px] flex-col overflow-hidden rounded-2xl border border-border-default bg-surface-card",
className,
)}
>
<div
className="flex items-center gap-3 p-5"
style={{ backgroundColor: brandColor }}
>
{merchantLogoUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={merchantLogoUrl}
alt=""
className="h-10 w-10 shrink-0 rounded-lg object-cover"
/>
) : (
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-white/20 text-lg font-bold text-white">
{merchantName.trim().charAt(0).toUpperCase() || "?"}
</span>
)}
<span className="truncate text-lg font-bold text-white">
{merchantName}
</span>
</div>

<div className="flex flex-col gap-4 p-5">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-col">
<span className="text-xs font-semibold uppercase tracking-wider text-text-secondary">
Client
</span>
<span className="truncate text-base font-bold text-text-primary">
{customerName}
</span>
</div>
{position !== null ? (
<div className="flex shrink-0 flex-col items-end">
<span className="text-xs font-semibold uppercase tracking-wider text-text-secondary">
Position
</span>
<span className="text-base font-bold text-text-primary">
#{position}
</span>
</div>
) : null}
</div>

<div className="flex flex-col">
<span className="text-xs font-semibold uppercase tracking-wider text-text-secondary">
Arrivée
</span>
<span className="text-base font-bold text-text-primary">
{formatArrivalTime(arrivalTimeIso)}
</span>
</div>

<div className="my-1 border-t border-dashed border-border-default" />

<div className="flex items-center justify-between gap-4">
<div className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-text-secondary">
Code de suivi
</span>
<span className="font-mono text-2xl font-bold tracking-[0.25em] text-text-primary">
{recoveryCode}
</span>
<span className="text-[11px] text-text-secondary">
Prénom + code sur waitlight.app
</span>
</div>
<QRCodeCanvas value={recoverUrl} size={84} />
</div>
</div>
</div>
)
},
)

export { TicketDownloadCard, type TicketDownloadCardProps }
Loading
Loading