@@ -2,7 +2,7 @@
import Link from "next/link" ;
import { useEffect , useRef , useState } from "react" ;
import { ArrowLeftIcon , DownloadIcon , PrinterIcon , QrCodeIcon , SaveIcon } from "lucide-react" ;
import { ArrowLeftIcon , DownloadIcon , PrinterIcon , QrCodeIcon , SaveIcon , RotateCcwIcon } from "lucide-react" ;
import { savedSignSchema } from "@album/contracts" ;
import { api } from "@/trpc/react" ;
import { toast } from "sonner" ;
@@ -11,7 +11,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Field , FieldDescription , FieldGroup , FieldLabel } from "@/components/ui/field" ;
import { Input } from "@/components/ui/input" ;
import { Select , SelectContent , SelectGroup , SelectItem , SelectTrigger , SelectValue } from "@/components/ui/select" ;
import { guestQrSvg , guestSignSvg , signPrintHtml , signPaper , SIGN_PAPERS , type SignDesign , type SignQr , type SignPaper } from "@/lib/guest-sign" ;
import { guestQrSvg , guestSignSvg , signPrintHtml , signPrintMetrics , signPaper , SIGN_PAPERS , type SignDesign , type SignQr , type SignPaper } from "@/lib/guest-sign" ;
import { SignDesignControls } from "./design-controls" ;
function serializedDesign ( design : SignDesign ) {
@@ -31,13 +31,45 @@ export function GuestSignStudio({ eventId, title, guestUrl, qr, warning, savedUr
const [ revision , setRevision ] = useState ( savedRevision ) ;
const [ snapshot , setSnapshot ] = useState ( ( ) = > serializedDesign ( design ) ) ;
const [ saving , setSaving ] = useState ( false ) ;
const [ saveError , setSaveError ] = useState < string | null > ( null ) ;
const [ fontAttempt , setFontAttempt ] = useState ( 0 ) ;
const initialSnapshot = useRef ( snapshot ) ;
const utils = api . useUtils ( ) ;
const prepare = api . signs . prepare . useMutation ( ) ;
const saveMutation = api . signs . save . useMutation ( ) ;
const dirty = serializedDesign ( design ) !== snapshot ;
function applyDesign ( content : string ) {
const restored = savedSignSchema . parse ( JSON . parse ( content ) ) ;
setDesign ( d = > ( { . . . restored , fontData : d.fontData , interFontData : d.interFontData , geologicaFontData : d.geologicaFontData } ) ) ;
}
function discardChanges() {
if ( ! window . confirm ( "Discard your unsaved sign changes?" ) ) return ;
applyDesign ( snapshot ) ;
setSnapshot ( JSON . stringify ( savedSignSchema . parse ( JSON . parse ( snapshot ) ) ) ) ;
setSaveError ( null ) ;
}
async function reloadSaved() {
if ( dirty && ! window . confirm ( "Replace your unsaved changes with the latest saved design? Download your sign first if you want to keep a copy." ) ) return ;
setLoadingDesign ( true ) ;
try {
const latest = await utils . signs . get . fetch ( { eventId } ) ;
let content = initialSnapshot . current ;
if ( latest ) {
const response = await fetch ( latest . url , { signal : AbortSignal.timeout ( 20000 ) } ) ;
if ( ! response . ok ) throw new Error ( "Couldn't download the saved design. Try again." ) ;
content = JSON . stringify ( savedSignSchema . parse ( await response . json ( ) ) ) ;
}
applyDesign ( content ) ; setSnapshot ( content ) ; setRevision ( latest ? . revision ? ? null ) ;
setLoadError ( false ) ; setSaveError ( null ) ;
toast . success ( latest ? "Latest saved design loaded" : "No saved design yet" ) ;
} catch ( error ) {
setSaveError ( error instanceof Error ? error . message : "Couldn't reload the saved design. Your changes are still here." ) ;
} finally { setLoadingDesign ( false ) ; }
}
useEffect ( ( ) = > {
if ( ! savedUrl ) return ;
let active = true ;
void fetch ( savedUrl ) . then ( async response = > {
void fetch ( savedUrl , { signal : AbortSignal.timeout ( 20000 ) } ) . then ( async response = > {
if ( ! response . ok ) throw new Error ( "Could not load saved design" ) ;
const loaded = savedSignSchema . parse ( await response . json ( ) ) as SignDesign ;
if ( active ) {
@@ -65,19 +97,24 @@ export function GuestSignStudio({ eventId, title, guestUrl, qr, warning, savedUr
const body = new Blob ( [ content ] , { type : "application/json" } ) ;
if ( body . size > 20 * 1024 * 1024 ) { toast . error ( "Design is too large. Use a smaller photo or logo." ) ; return ; }
setSaving ( true ) ;
setSaveError ( null ) ;
try {
const upload = await prepare . mutateAsync ( { eventId } ) ;
const response = await fetch ( upload . url , { method : "PUT" , headers : { "Content-Type" : "application/json" } , body } ) ;
const response = await fetch ( upload . url , { method : "PUT" , headers : { "Content-Type" : "application/json" } , body , signal : AbortSignal.timeout ( 60000 ) } ) ;
if ( ! response . ok ) throw new Error ( "Design upload failed. Please try again." ) ;
const result = await saveMutation . mutateAsync ( { eventId , uploadId : upload.uploadId , revision } ) ;
setRevision ( result . revision ) ; setSnapshot ( content ) ; toast . success ( "Design saved to this event" ) ;
} catch ( error ) { toast . error ( error instanceof Error ? error . message : "Could not save design" ) ; }
} catch ( error ) {
const message = error instanceof Error ? error . message : "Could not save design" ;
setSaveError ( message ) ; toast . error ( message ) ;
}
finally { setSaving ( false ) ; }
}
useEffect ( ( ) = > {
let active = true ;
setFontError ( false ) ;
void Promise . all ( [ "funnel-display" , "inter" , "geologica" ] . map ( async name = > {
const response = await fetch ( ` /fonts/ ${ name } .woff2 ` ) ;
const response = await fetch ( ` /fonts/ ${ name } .woff2 ` , { signal : AbortSignal.timeout ( 20000 ) } ) ;
if ( ! response . ok ) throw new Error ( "Font unavailable" ) ;
const bytes = new Uint8Array ( await response . arrayBuffer ( ) ) ;
let binary = "" ;
@@ -87,7 +124,7 @@ export function GuestSignStudio({ eventId, title, guestUrl, qr, warning, savedUr
if ( active ) setDesign ( d = > ( { . . . d , fontData , interFontData , geologicaFontData } ) ) ;
} ) . catch ( ( ) = > { if ( active ) setFontError ( true ) ; } ) ;
return ( ) = > { active = false ; } ;
} , [ ] ) ;
} , [ fontAttempt ] ) ;
const printFrame = useRef < HTMLIFrameElement | null > ( null ) ;
const [ printing , setPrinting ] = useState ( false ) ;
useEffect ( ( ) = > ( ) = > { printFrame . current ? . remove ( ) ; } , [ ] ) ;
@@ -95,6 +132,7 @@ export function GuestSignStudio({ eventId, title, guestUrl, qr, warning, savedUr
let paper = SIGN_PAPERS . letter as ReturnType < typeof signPaper > ;
try { paper = signPaper ( design ) ; } catch ( error ) { sizeError = error instanceof Error ? error . message : "Invalid paper size" ; }
const svg = guestSignSvg ( sizeError ? { . . . design , paper : "letter" } : design , guestUrl , qr ) ;
const printMetrics = sizeError ? null : signPrintMetrics ( design ) ;
const fontReady = Boolean ( design . fontData && design . interFontData && design . geologicaFontData ) ;
const valid = Boolean ( ! loadingDesign && ! loadError && design . title . trim ( ) && design . headline . trim ( ) && design . message . trim ( ) && ! sizeError && fontReady && ! assetBusy && ( design . layout !== "photo" || design . backgroundImage ) ) ;
const localUrl = [ "localhost" , "127.0.0.1" , "[::1]" ] . includes ( new URL ( guestUrl ) . hostname ) ;
@@ -145,8 +183,14 @@ export function GuestSignStudio({ eventId, title, guestUrl, qr, warning, savedUr
< / div >
< div className = "grid items-start gap-6 xl:grid-cols-[22rem_minmax(0,1fr)]" >
< Card >
< CardHeader > < CardTitle > Make it yours < / CardTitle > < CardDescription role = "status" > { loadingDesign ? "Loading saved design…" : loadError ? "Could not load your saved design. Reload this page before edit ing ." : saving ? "Saving design…" : dirty ? "Unsaved changes" : revision ? "Saved to this event" : "Choose a preset to get started." } { ! canSave ? "You can preview and download; an event manager must save changes." : "Your photo, logo, and settings are saved together." } < / CardDescription > < / CardHeader >
< CardHeader > < CardTitle > Make it yours < / CardTitle > < CardDescription role = "status" > { loadingDesign ? "Loading saved design…" : loadError ? "Could not load your saved design. Use Reload saved design to try aga in." : saving ? "Saving design…" : dirty ? "Unsaved changes" : revision ? "Saved to this event" : "Choose a preset to get started." } { ! canSave ? "You can preview and download; an event manager must save changes." : "Your photo, logo, and settings are saved together." } < / CardDescription > < / CardHeader >
< CardContent >
{ dirty || revision || loadError || saveError ? < div className = "mb-4 flex flex-wrap gap-2" >
{ dirty ? < Button type = "button" variant = "outline" size = "sm" disabled = { saving || loadingDesign || assetBusy } onClick = { discardChanges } > < RotateCcwIcon data-icon = "inline-start" / > Discard changes < / Button > : null }
{ revision || loadError || saveError ? < Button type = "button" variant = "outline" size = "sm" disabled = { saving || loadingDesign || assetBusy } onClick = { ( ) = > void reloadSaved ( ) } > < RotateCcwIcon data-icon = "inline-start" / > { loadingDesign ? "Loading…" : "Reload saved design" } < / Button > : null }
< / div > : null }
{ saveError ? < p role = "alert" className = "mb-4 text-sm text-destructive" > { saveError } Your current edits have been kept . Retry saving , or download a copy before reloading the saved design . < / p > : null }
{ fontError ? < Button type = "button" variant = "outline" size = "sm" className = "mb-4" onClick = { ( ) = > setFontAttempt ( value = > value + 1 ) } > Retry fonts < / Button > : null }
< fieldset disabled = { loadingDesign || loadError || saving } className = "min-w-0" >
< FieldGroup >
< Field > < FieldLabel htmlFor = "sign-paper" > Paper size < / FieldLabel >
@@ -157,7 +201,7 @@ export function GuestSignStudio({ eventId, title, guestUrl, qr, warning, savedUr
< / Field >
< SignDesignControls design = { design } onChange = { patch = > setDesign ( d = > ( { . . . d , . . . patch } ) ) } onBusy = { setAssetBusy } / >
{ sizeError ? < p role = "alert" className = "text-sm text-destructive" > { sizeError } Preview uses Letter until corrected . < / p > : null }
{ ! fontReady ? < p role = "status" className = "text-sm text-muted-foreground" > { fontError ? "Our fonts could not load. Reload before exporting." : "Loading embedded brand fonts…" } < / p > : null }
{ ! fontReady ? < p role = "status" className = "text-sm text-muted-foreground" > { fontError ? "Our fonts could not load. Use Retry fonts before exporting." : "Loading embedded brand fonts…" } < / p > : null }
{ ( [ { key : "title" , label : "Event name" , max : 120 } , { key : "headline" , label : "Headline" , max : 44 } , { key : "message" , label : "Welcome message" , max : 120 } ] as const ) . map ( field = >
< Field key = { field . key } data-invalid = { ! design [ field . key ] . trim ( ) } > < FieldLabel htmlFor = { ` sign- ${ field . key } ` } > { field . label } < / FieldLabel >
< Input id = { ` sign- ${ field . key } ` } value = { design [ field . key ] } maxLength = { field . max } aria-invalid = { ! design [ field . key ] . trim ( ) }
@@ -166,6 +210,7 @@ export function GuestSignStudio({ eventId, title, guestUrl, qr, warning, savedUr
< Field > < FieldLabel > QR destination < / FieldLabel > < p className = "break-all text-sm" > { guestUrl . replace ( /^https?:\/\// , "" ) } < / p > < FieldDescription > Uses the saved guest link , not a temporary download URL . Changing the event slug later means reprinting the sign . < / FieldDescription > < / Field >
< p role = "status" className = "text-sm text-muted-foreground" > { localUrl ? "Local preview: this QR points to localhost and will not work on guests’ phones. Generate the final sign on your deployed site." : "Before printing a batch, scan one copy with your phone." } { warning ? ` ${ warning } ` : "" } < / p >
< p className = "text-xs text-muted-foreground" > Print at 100 % on the selected paper size , with browser headers and footers off . The sign includes a safe page margin and a clear border around the QR code . < / p >
{ printMetrics ? < p role = "status" className = "text-xs text-muted-foreground" > QR print size : { printMetrics . qrInches . toFixed ( 2 ) } in ( { Math . round ( printMetrics . qrInches * 25.4 ) } mm ) , including the white margin . { printMetrics . qrInches < 1 ? "Small QR: use a larger card or the no-photo card preset for easier scanning. Test a physical copy before printing a batch." : "Test one physical copy at its final size before printing a batch." } < / p > : null }
< / FieldGroup >
< / fieldset >
< / CardContent >