diff --git a/apps/web/src/app/dashboard/events/[id]/event-people.tsx b/apps/web/src/app/dashboard/events/[id]/event-people.tsx index af7d5ab..2ab6778 100644 --- a/apps/web/src/app/dashboard/events/[id]/event-people.tsx +++ b/apps/web/src/app/dashboard/events/[id]/event-people.tsx @@ -6,6 +6,7 @@ import { useState } from "react"; import { toast } from "sonner"; import type { EventRole } from "@album/contracts"; import { api } from "@/trpc/react"; +import { PendingInvites } from "@/components/pending-invites"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; @@ -56,7 +57,7 @@ export function EventPeople({ onError: (error) => toast.error(error.message), }); const invite = api.group.inviteEmail.useMutation({ - onSuccess: () => toast.success("Invite emailed"), + onSuccess: async () => { toast.success("Invite emailed"); setEmail(""); await utils.group.pendingInvites.invalidate(); }, onError: (error) => toast.error(error.message), }); const event = api.manager.event.useQuery({ eventId }); @@ -181,6 +182,7 @@ export function EventPeople({ ) : null} + {canManage && event.data?.groupId ? : null} ); diff --git a/apps/web/src/app/dashboard/events/[id]/sign/page.tsx b/apps/web/src/app/dashboard/events/[id]/sign/page.tsx index 1ac53c1..f416d04 100644 --- a/apps/web/src/app/dashboard/events/[id]/sign/page.tsx +++ b/apps/web/src/app/dashboard/events/[id]/sign/page.tsx @@ -11,7 +11,7 @@ export default async function GuestSignPage({ params }: { params: Promise<{ id: try { event = await caller.manager.event({ eventId: id }); } catch { notFound(); } const current = effectiveEvent(event); const saved = await caller.signs.get({ eventId: id }); - return ; } diff --git a/apps/web/src/app/dashboard/events/[id]/sign/studio.tsx b/apps/web/src/app/dashboard/events/[id]/sign/studio.tsx index 948a45c..97420e6 100644 --- a/apps/web/src/app/dashboard/events/[id]/sign/studio.tsx +++ b/apps/web/src/app/dashboard/events/[id]/sign/studio.tsx @@ -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(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(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; 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
- Make it yours{loadingDesign ? "Loading saved design…" : loadError ? "Could not load your saved design. Reload this page before editing." : 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."} + Make it yours{loadingDesign ? "Loading saved design…" : loadError ? "Could not load your saved design. Use Reload saved design to try again." : 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."} + {dirty || revision || loadError || saveError ?
+ {dirty ? : null} + {revision || loadError || saveError ? : null} +
: null} + {saveError ?

{saveError} Your current edits have been kept. Retry saving, or download a copy before reloading the saved design.

: null} + {fontError ? : null}
Paper size @@ -157,7 +201,7 @@ export function GuestSignStudio({ eventId, title, guestUrl, qr, warning, savedUr setDesign(d => ({ ...d, ...patch }))} onBusy={setAssetBusy} /> {sizeError ?

{sizeError} Preview uses Letter until corrected.

: null} - {!fontReady ?

{fontError ? "Our fonts could not load. Reload before exporting." : "Loading embedded brand fonts…"}

: null} + {!fontReady ?

{fontError ? "Our fonts could not load. Use Retry fonts before exporting." : "Loading embedded brand fonts…"}

: 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.label} QR destination

{guestUrl.replace(/^https?:\/\//, "")}

Uses the saved guest link, not a temporary download URL. Changing the event slug later means reprinting the sign.

{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}` : ""}

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.

+ {printMetrics ?

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."}

: null}
diff --git a/apps/web/src/app/dashboard/people/group-people.tsx b/apps/web/src/app/dashboard/people/group-people.tsx index 7c8536f..8b37bb8 100644 --- a/apps/web/src/app/dashboard/people/group-people.tsx +++ b/apps/web/src/app/dashboard/people/group-people.tsx @@ -6,6 +6,7 @@ import { useState } from "react"; import { toast } from "sonner"; import type { GroupRole } from "@album/contracts"; import { api } from "@/trpc/react"; +import { PendingInvites } from "@/components/pending-invites"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; @@ -38,13 +39,14 @@ export function GroupPeople({ onError: (error) => toast.error(error.message), }); const invite = api.group.inviteEmail.useMutation({ - onSuccess: () => toast.success("Invite emailed"), + onSuccess: async () => { toast.success("Invite emailed"); setEmail(""); await utils.group.pendingInvites.invalidate(); }, onError: (error) => toast.error(error.message), }); const createCode = api.group.createCode.useMutation({ - onSuccess: (result) => { + onSuccess: async (result) => { setCode(result.code); toast.success("Invite code created"); + await utils.group.pendingInvites.invalidate(); }, onError: (error) => toast.error(error.message), }); @@ -72,7 +74,7 @@ export function GroupPeople({

{member.email}

- {member.role} + {member.role === "owner" ? "Owner" : "Member"} {canManage ? (