diff --git a/app/[slug]/wait/[ticketId]/WaitClient.tsx b/app/[slug]/wait/[ticketId]/WaitClient.tsx index 2433d30..f703c9d 100644 --- a/app/[slug]/wait/[ticketId]/WaitClient.tsx +++ b/app/[slug]/wait/[ticketId]/WaitClient.tsx @@ -14,7 +14,7 @@ import { BellRing, Smartphone, MessageSquare, AlertCircle, Download } from "luci 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" +import { toBlob } from "html-to-image" type NotificationChannels = { sound: boolean @@ -95,11 +95,21 @@ function WaitClient({ merchant, ticketId }: WaitClientProps) { setIsDownloading(true) setDownloadError(null) try { - const dataUrl = await toPng(ticketCardRef.current, { pixelRatio: 2 }) + const blob = await toBlob(ticketCardRef.current, { pixelRatio: 2 }) + if (!blob) throw new Error("toBlob returned null") + // A blob: object URL triggers a direct save reliably; a raw data: + // URL on the same does not — Safari treats an image + // data: URL as content to preview and offers "View or Download" + // instead of saving it, which is what surfaced this in testing. + const objectUrl = URL.createObjectURL(blob) const link = document.createElement("a") - link.href = dataUrl + link.href = objectUrl link.download = `ticket-${merchant.slug}.png` link.click() + // Revoking immediately can race the browser actually reading the + // blob (Safari in particular can be slow to pick it up) — give it + // a moment before freeing the URL. + setTimeout(() => URL.revokeObjectURL(objectUrl), 1000) } catch (err) { console.error("[WaitClient] Ticket image export failed:", err) setDownloadError( @@ -445,7 +455,11 @@ function WaitClient({ merchant, ticketId }: WaitClientProps) { )} {canDownloadTicket && ( - setTicketDialogOpen(false)}> + setTicketDialogOpen(false)} + closeOnBackdropClick + > setTicketDialogOpen(false)}>Votre ticket
diff --git a/components/ui/Dialog.tsx b/components/ui/Dialog.tsx index 2c74092..d069c12 100644 --- a/components/ui/Dialog.tsx +++ b/components/ui/Dialog.tsx @@ -9,9 +9,17 @@ type DialogProps = { onClose: () => void children: React.ReactNode className?: string + /** + * Lets a click on the backdrop dismiss the dialog, in addition to Escape + * and any explicit close control. Off by default: several dialogs in this + * app (e.g. the "called" reminder, the moderation warning) deliberately + * force an explicit acknowledgment, and a stray backdrop click must not + * dismiss those. Opt in only for dialogs that are purely informational. + */ + closeOnBackdropClick?: boolean } -function Dialog({ open, onClose, children, className }: DialogProps) { +function Dialog({ open, onClose, children, className, closeOnBackdropClick = false }: DialogProps) { const dialogRef = useRef(null) const previousFocusRef = useRef(null) @@ -48,6 +56,17 @@ function Dialog({ open, onClose, children, className }: DialogProps) { return ( { + // A click on the ::backdrop registers as a click on the + // element itself (target === currentTarget); + // a click on any actual content is a click on a + // descendant, so this only fires for the backdrop. + if (e.target === e.currentTarget) handleClose() + } + : undefined + } className={cn( "m-auto w-[calc(100%-2rem)] max-w-md rounded-lg border border-border-default bg-surface-card p-0 shadow-xl", "backdrop:bg-surface-overlay",