Show pending account invites and polish sign editor recovery

This commit is contained in:
2026-09-10 10:10:07 -04:00
parent 6f400ac2cd
commit 74592718c1
11 changed files with 177 additions and 18 deletions
@@ -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({
</Button>
</form>
) : null}
{canManage && event.data?.groupId ? <PendingInvites groupId={event.data.groupId} eventId={eventId} /> : null}
</CardContent>
</Card>
);
@@ -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 <GuestSignStudio eventId={event.id} title={event.title} guestUrl={event.guestUrl} qr={signQr(event.guestUrl)}
return <GuestSignStudio key={event.id} eventId={event.id} title={event.title} guestUrl={event.guestUrl} qr={signQr(event.guestUrl)}
savedUrl={saved?.url ?? null} savedRevision={saved?.revision ?? null} canSave={event.permissions.includes("settings.manage")}
warning={current.status === "draft" ? "This event is not public yet. Publish it before guests scan this sign." : !current.uploadEnabled ? "Submissions are currently closed. Enable uploads or check the event schedule before using this sign." : null} />;
}
@@ -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 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."}</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 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."}</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>
@@ -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({
<p className="text-xs text-muted-foreground">{member.email}</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary">{member.role}</Badge>
<Badge variant="secondary">{member.role === "owner" ? "Owner" : "Member"}</Badge>
{canManage ? (
<Button
size="sm"
@@ -148,6 +150,7 @@ export function GroupPeople({
) : null}
</>
) : null}
{canManage ? <PendingInvites groupId={groupId} /> : null}
</CardContent>
</Card>
);
@@ -0,0 +1,35 @@
"use client";
import { api } from "@/trpc/react";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
import { Empty, EmptyHeader, EmptyTitle, EmptyDescription } from "@/components/ui/empty";
import { Skeleton } from "@/components/ui/skeleton";
const label = (role: string) => role.charAt(0).toUpperCase() + role.slice(1);
export function PendingInvites({ groupId, eventId }: { groupId: string; eventId?: string }) {
const invites = api.group.pendingInvites.useQuery({ groupId, eventId }, { refetchInterval: 30000 });
return <section aria-label="Pending invites" className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold">Pending invites</h3>
{invites.data ? <Badge variant="secondary">{invites.data.length}</Badge> : null}
</div>
<p className="text-sm text-muted-foreground">Account invitations awaiting acceptance, not guest gallery notifications. Accepted, expired, and revoked invites are excluded.</p>
{invites.isPending ? <Skeleton className="h-20 w-full" aria-label="Loading pending invites" /> : invites.isError ?
<Alert variant="destructive"><AlertTitle>Could not load invites</AlertTitle><AlertDescription>{invites.error.message}</AlertDescription></Alert> :
!invites.data.length ? <Empty><EmptyHeader><EmptyTitle>No pending invites</EmptyTitle><EmptyDescription>New account invitations will appear here.</EmptyDescription></EmptyHeader></Empty> :
<ul className="flex flex-col gap-3">{invites.data.map(invite => <li key={invite.id} className="flex flex-wrap items-center justify-between gap-3 rounded-lg border p-3">
<div className="min-w-0 flex-1">
<p className="break-words font-medium">{invite.kind === "email" ? invite.email : invite.reusable ? "Reusable invite code" : "Single-use invite code"}</p>
<p className="text-xs text-muted-foreground">{invite.eventTitle ?? "Workspace access"} · Created {new Date(invite.createdAt).toLocaleDateString()}</p>
<p className="text-xs text-muted-foreground">{invite.expiresAt ? `Expires ${new Date(invite.expiresAt).toLocaleDateString()}` : "No expiry"}{invite.kind !== "email" ? ` · ${invite.usedCount} of ${invite.maxUses} uses` : ""}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{invite.groupRole ? <Badge variant="outline">Workspace {label(invite.groupRole)}</Badge> : null}
{invite.eventRole ? <Badge variant="outline">Event {label(invite.eventRole)}</Badge> : null}
<Badge variant="secondary">{invite.kind === "email" ? "Awaiting acceptance" : "Active code"}</Badge>
</div>
</li>)}</ul>}
</section>;
}
+6 -1
View File
@@ -2,7 +2,7 @@ import { expect, test } from "bun:test";
import sharp from "sharp";
import jsQR from "jsqr";
import { signQr } from "../server/sign-qr";
import { SIGN_PAPERS, guestQrSvg, guestSignSvg, signLines, signPrintHtml, signPaper, type SignDesign } from "./guest-sign";
import { SIGN_PAPERS, guestQrSvg, guestSignSvg, signLines, signPrintHtml, signPrintMetrics, signPaper, type SignDesign } from "./guest-sign";
test("standalone QR export scans to the guest link and keeps its quiet zone", async () => {
const url = "https://ma.hadlock.tech/e/test-wedding";
@@ -14,6 +14,11 @@ test("standalone QR export scans to the guest link and keeps its quiet zone", as
});
const design: SignDesign = { title: "Sam & Alexs wedding", headline: "Share your favorite moments", message: "Add your photos to our shared album.", paper: "letter", ink: "indigo" };
test("print guidance reflects physical QR size, orientation, and units", () => {
expect(signPrintMetrics({ ...design, layout: "compact", paper: "card6x4" }).qrInches).toBeCloseTo(1.6);
expect(signPrintMetrics({ ...design, layout: "photo", paper: "card3x5" }).qrInches).toBeLessThan(1);
expect(signPrintMetrics({ ...design, layout: "compact", paper: "custom", unit: "mm", customWidth: 152.4, customHeight: 101.6 }).qrInches).toBeCloseTo(1.6);
});
test("Linen cards scan at every preset size with exact physical export dimensions", async () => {
const url = "https://ma.hadlock.tech/e/test-wedding";
for (const paper of Object.keys(SIGN_PAPERS) as (keyof typeof SIGN_PAPERS)[]) {
+9
View File
@@ -25,6 +25,15 @@ export function escapeXml(value: string) {
return value.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" })[c]!);
}
export function signPrintMetrics(design: SignDesign) {
const paper = signPaper(design);
const landscape = paper.viewHeight < 850;
const scale = Math.min(850 / (landscape ? 1200 : 850), paper.viewHeight / (landscape ? 800 : 1100));
const widthInches = parseFloat(paper.width) / (paper.width.endsWith("mm") ? 25.4 : 1);
const qrUnits = design.layout === "compact" ? 320 : design.layout === "photo" ? 240 : design.layout === "typography" ? 340 : 370;
return { qrInches: widthInches / 850 * scale * qrUnits };
}
// Hard-wrap even unbroken names/URLs; no user input becomes SVG markup.
export function signLines(value: string, limit: number) {
const words = value.trim().split(/\s+/).flatMap(word => word.match(new RegExp(`.{1,${limit}}`, "gu")) ?? []);
+29 -1
View File
@@ -1,5 +1,5 @@
import { TRPCError } from "@trpc/server";
import { and, desc, eq } from "drizzle-orm";
import { and, desc, eq, gt, isNull, lt, or } from "drizzle-orm";
import {
auditEvents,
eventMemberships,
@@ -55,6 +55,34 @@ async function validateInviteGrants(userId: string, input: {
}
export const groupRouter = createTRPCRouter({
pendingInvites: protectedProcedure
.input(z.object({ groupId: z.string().uuid(), eventId: z.string().uuid().optional() }))
.query(async ({ ctx, input }) => {
const platformRole = await getPlatformRole(ctx.session.user.id);
if (input.eventId) {
const { event, access } = await loadEventAccess(ctx.session.user.id, input.eventId, platformRole);
if (event.groupId !== input.groupId) throw new TRPCError({ code: "NOT_FOUND" });
requireEventPermission(access.permissions, EVENT_PERMISSIONS.PEOPLE_MANAGE);
} else {
const { access } = await loadGroupAccess(ctx.session.user.id, input.groupId, platformRole);
requireGroupPermission(access.permissions, GROUP_PERMISSIONS.PEOPLE_MANAGE);
}
return getDb().select({
id: invites.id, kind: invites.kind, email: invites.email,
groupRole: invites.groupRole, eventRole: invites.eventRole,
eventId: invites.eventId, eventTitle: events.title,
reusable: invites.reusable, usedCount: invites.usedCount, maxUses: invites.maxUses,
createdAt: invites.createdAt, expiresAt: invites.expiresAt,
}).from(invites).leftJoin(events, and(eq(events.id, invites.eventId), eq(events.groupId, input.groupId)))
.where(and(
eq(invites.groupId, input.groupId),
input.eventId ? eq(invites.eventId, input.eventId) : undefined,
eq(invites.status, "pending"),
or(isNull(invites.expiresAt), gt(invites.expiresAt, new Date())),
lt(invites.usedCount, invites.maxUses),
)).orderBy(desc(invites.createdAt));
}),
list: protectedProcedure.query(async ({ ctx }) => {
return getDb()
.select({
@@ -28,6 +28,18 @@ test.skipIf(process.env.POLISH_INTEGRATION !== "1")("invitation isolation, atomi
const [used] = await db.select().from(invites).where(eq(invites.tokenHash, hashToken(token)));
expect(used!.usedCount).toBe(1);
const workspace = groupRouter.createCaller(ctx(0));
const [pending] = await db.insert(invites).values({ kind: "email", email: people[1]!.email, tokenHash: hashToken(crypto.randomUUID()), groupId: group!.id, eventId: event!.id, eventRole: "manager" }).returning();
await db.insert(invites).values([
{ kind: "email", tokenHash: hashToken(crypto.randomUUID()), groupId: group!.id, expiresAt: new Date(0) },
{ kind: "email", tokenHash: hashToken(crypto.randomUUID()), groupId: group!.id, usedCount: 1, maxUses: 1 },
{ kind: "email", tokenHash: hashToken(crypto.randomUUID()), groupId: group!.id, status: "revoked" },
]);
const listed = await workspace.pendingInvites({ groupId: group!.id, eventId: event!.id });
expect(listed.map(invite => invite.id)).toEqual([pending!.id]);
expect(listed[0]).not.toHaveProperty("tokenHash");
expect(await workspace.pendingInvites({ groupId: group!.id })).toHaveLength(1);
await expect(groupRouter.createCaller(ctx(1)).pendingInvites({ groupId: group!.id })).rejects.toThrow();
await expect(workspace.pendingInvites({ groupId: crypto.randomUUID(), eventId: event!.id })).rejects.toThrow();
await expect(workspace.createCode({ groupId: group!.id, grantUnlimitedEvents: true })).rejects.toThrow("platform administrators");
const signs = signsRouter.createCaller(ctx(0));
await expect(signsRouter.createCaller(ctx(1)).prepare({ eventId: event!.id })).rejects.toThrow();