Show pending account invites and polish sign editor recovery
This commit is contained in:
@@ -6,6 +6,7 @@ import { useState } from "react";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { EventRole } from "@album/contracts";
|
import type { EventRole } from "@album/contracts";
|
||||||
import { api } from "@/trpc/react";
|
import { api } from "@/trpc/react";
|
||||||
|
import { PendingInvites } from "@/components/pending-invites";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -56,7 +57,7 @@ export function EventPeople({
|
|||||||
onError: (error) => toast.error(error.message),
|
onError: (error) => toast.error(error.message),
|
||||||
});
|
});
|
||||||
const invite = api.group.inviteEmail.useMutation({
|
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),
|
onError: (error) => toast.error(error.message),
|
||||||
});
|
});
|
||||||
const event = api.manager.event.useQuery({ eventId });
|
const event = api.manager.event.useQuery({ eventId });
|
||||||
@@ -181,6 +182,7 @@ export function EventPeople({
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
) : null}
|
) : null}
|
||||||
|
{canManage && event.data?.groupId ? <PendingInvites groupId={event.data.groupId} eventId={eventId} /> : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export default async function GuestSignPage({ params }: { params: Promise<{ id:
|
|||||||
try { event = await caller.manager.event({ eventId: id }); } catch { notFound(); }
|
try { event = await caller.manager.event({ eventId: id }); } catch { notFound(); }
|
||||||
const current = effectiveEvent(event);
|
const current = effectiveEvent(event);
|
||||||
const saved = await caller.signs.get({ eventId: id });
|
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")}
|
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} />;
|
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 Link from "next/link";
|
||||||
import { useEffect, useRef, useState } from "react";
|
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 { savedSignSchema } from "@album/contracts";
|
||||||
import { api } from "@/trpc/react";
|
import { api } from "@/trpc/react";
|
||||||
import { toast } from "sonner";
|
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 { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
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";
|
import { SignDesignControls } from "./design-controls";
|
||||||
|
|
||||||
function serializedDesign(design: SignDesign) {
|
function serializedDesign(design: SignDesign) {
|
||||||
@@ -31,13 +31,45 @@ export function GuestSignStudio({ eventId, title, guestUrl, qr, warning, savedUr
|
|||||||
const [revision, setRevision] = useState(savedRevision);
|
const [revision, setRevision] = useState(savedRevision);
|
||||||
const [snapshot, setSnapshot] = useState(() => serializedDesign(design));
|
const [snapshot, setSnapshot] = useState(() => serializedDesign(design));
|
||||||
const [saving, setSaving] = useState(false);
|
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 prepare = api.signs.prepare.useMutation();
|
||||||
const saveMutation = api.signs.save.useMutation();
|
const saveMutation = api.signs.save.useMutation();
|
||||||
const dirty = serializedDesign(design) !== snapshot;
|
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(() => {
|
useEffect(() => {
|
||||||
if (!savedUrl) return;
|
if (!savedUrl) return;
|
||||||
let active = true;
|
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");
|
if (!response.ok) throw new Error("Could not load saved design");
|
||||||
const loaded = savedSignSchema.parse(await response.json()) as SignDesign;
|
const loaded = savedSignSchema.parse(await response.json()) as SignDesign;
|
||||||
if (active) {
|
if (active) {
|
||||||
@@ -65,19 +97,24 @@ export function GuestSignStudio({ eventId, title, guestUrl, qr, warning, savedUr
|
|||||||
const body = new Blob([content], { type: "application/json" });
|
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; }
|
if (body.size > 20 * 1024 * 1024) { toast.error("Design is too large. Use a smaller photo or logo."); return; }
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
setSaveError(null);
|
||||||
try {
|
try {
|
||||||
const upload = await prepare.mutateAsync({ eventId });
|
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.");
|
if (!response.ok) throw new Error("Design upload failed. Please try again.");
|
||||||
const result = await saveMutation.mutateAsync({ eventId, uploadId: upload.uploadId, revision });
|
const result = await saveMutation.mutateAsync({ eventId, uploadId: upload.uploadId, revision });
|
||||||
setRevision(result.revision); setSnapshot(content); toast.success("Design saved to this event");
|
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); }
|
finally { setSaving(false); }
|
||||||
}
|
}
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
|
setFontError(false);
|
||||||
void Promise.all(["funnel-display", "inter", "geologica"].map(async name => {
|
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");
|
if (!response.ok) throw new Error("Font unavailable");
|
||||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||||
let binary = "";
|
let binary = "";
|
||||||
@@ -87,7 +124,7 @@ export function GuestSignStudio({ eventId, title, guestUrl, qr, warning, savedUr
|
|||||||
if (active) setDesign(d => ({ ...d, fontData, interFontData, geologicaFontData }));
|
if (active) setDesign(d => ({ ...d, fontData, interFontData, geologicaFontData }));
|
||||||
}).catch(() => { if (active) setFontError(true); });
|
}).catch(() => { if (active) setFontError(true); });
|
||||||
return () => { active = false; };
|
return () => { active = false; };
|
||||||
}, []);
|
}, [fontAttempt]);
|
||||||
const printFrame = useRef<HTMLIFrameElement | null>(null);
|
const printFrame = useRef<HTMLIFrameElement | null>(null);
|
||||||
const [printing, setPrinting] = useState(false);
|
const [printing, setPrinting] = useState(false);
|
||||||
useEffect(() => () => { printFrame.current?.remove(); }, []);
|
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>;
|
let paper = SIGN_PAPERS.letter as ReturnType<typeof signPaper>;
|
||||||
try { paper = signPaper(design); } catch (error) { sizeError = error instanceof Error ? error.message : "Invalid paper size"; }
|
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 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 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 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);
|
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>
|
||||||
<div className="grid items-start gap-6 xl:grid-cols-[22rem_minmax(0,1fr)]">
|
<div className="grid items-start gap-6 xl:grid-cols-[22rem_minmax(0,1fr)]">
|
||||||
<Card>
|
<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>
|
<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">
|
<fieldset disabled={loadingDesign || loadError || saving} className="min-w-0">
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
<Field><FieldLabel htmlFor="sign-paper">Paper size</FieldLabel>
|
<Field><FieldLabel htmlFor="sign-paper">Paper size</FieldLabel>
|
||||||
@@ -157,7 +201,7 @@ export function GuestSignStudio({ eventId, title, guestUrl, qr, warning, savedUr
|
|||||||
</Field>
|
</Field>
|
||||||
<SignDesignControls design={design} onChange={patch => setDesign(d => ({ ...d, ...patch }))} onBusy={setAssetBusy} />
|
<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}
|
{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 =>
|
{([{ 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>
|
<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()}
|
<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>
|
<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 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>
|
<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>
|
</FieldGroup>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useState } from "react";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { GroupRole } from "@album/contracts";
|
import type { GroupRole } from "@album/contracts";
|
||||||
import { api } from "@/trpc/react";
|
import { api } from "@/trpc/react";
|
||||||
|
import { PendingInvites } from "@/components/pending-invites";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -38,13 +39,14 @@ export function GroupPeople({
|
|||||||
onError: (error) => toast.error(error.message),
|
onError: (error) => toast.error(error.message),
|
||||||
});
|
});
|
||||||
const invite = api.group.inviteEmail.useMutation({
|
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),
|
onError: (error) => toast.error(error.message),
|
||||||
});
|
});
|
||||||
const createCode = api.group.createCode.useMutation({
|
const createCode = api.group.createCode.useMutation({
|
||||||
onSuccess: (result) => {
|
onSuccess: async (result) => {
|
||||||
setCode(result.code);
|
setCode(result.code);
|
||||||
toast.success("Invite code created");
|
toast.success("Invite code created");
|
||||||
|
await utils.group.pendingInvites.invalidate();
|
||||||
},
|
},
|
||||||
onError: (error) => toast.error(error.message),
|
onError: (error) => toast.error(error.message),
|
||||||
});
|
});
|
||||||
@@ -72,7 +74,7 @@ export function GroupPeople({
|
|||||||
<p className="text-xs text-muted-foreground">{member.email}</p>
|
<p className="text-xs text-muted-foreground">{member.email}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="secondary">{member.role}</Badge>
|
<Badge variant="secondary">{member.role === "owner" ? "Owner" : "Member"}</Badge>
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -148,6 +150,7 @@ export function GroupPeople({
|
|||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
{canManage ? <PendingInvites groupId={groupId} /> : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>;
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { expect, test } from "bun:test";
|
|||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import jsQR from "jsqr";
|
import jsQR from "jsqr";
|
||||||
import { signQr } from "../server/sign-qr";
|
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 () => {
|
test("standalone QR export scans to the guest link and keeps its quiet zone", async () => {
|
||||||
const url = "https://ma.hadlock.tech/e/test-wedding";
|
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 & Alex’s wedding", headline: "Share your favorite moments", message: "Add your photos to our shared album.", paper: "letter", ink: "indigo" };
|
const design: SignDesign = { title: "Sam & Alex’s 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 () => {
|
test("Linen cards scan at every preset size with exact physical export dimensions", async () => {
|
||||||
const url = "https://ma.hadlock.tech/e/test-wedding";
|
const url = "https://ma.hadlock.tech/e/test-wedding";
|
||||||
for (const paper of Object.keys(SIGN_PAPERS) as (keyof typeof SIGN_PAPERS)[]) {
|
for (const paper of Object.keys(SIGN_PAPERS) as (keyof typeof SIGN_PAPERS)[]) {
|
||||||
|
|||||||
@@ -25,6 +25,15 @@ export function escapeXml(value: string) {
|
|||||||
return value.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!);
|
return value.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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.
|
// Hard-wrap even unbroken names/URLs; no user input becomes SVG markup.
|
||||||
export function signLines(value: string, limit: number) {
|
export function signLines(value: string, limit: number) {
|
||||||
const words = value.trim().split(/\s+/).flatMap(word => word.match(new RegExp(`.{1,${limit}}`, "gu")) ?? []);
|
const words = value.trim().split(/\s+/).flatMap(word => word.match(new RegExp(`.{1,${limit}}`, "gu")) ?? []);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { and, desc, eq } from "drizzle-orm";
|
import { and, desc, eq, gt, isNull, lt, or } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
auditEvents,
|
auditEvents,
|
||||||
eventMemberships,
|
eventMemberships,
|
||||||
@@ -55,6 +55,34 @@ async function validateInviteGrants(userId: string, input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const groupRouter = createTRPCRouter({
|
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 }) => {
|
list: protectedProcedure.query(async ({ ctx }) => {
|
||||||
return getDb()
|
return getDb()
|
||||||
.select({
|
.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)));
|
const [used] = await db.select().from(invites).where(eq(invites.tokenHash, hashToken(token)));
|
||||||
expect(used!.usedCount).toBe(1);
|
expect(used!.usedCount).toBe(1);
|
||||||
const workspace = groupRouter.createCaller(ctx(0));
|
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");
|
await expect(workspace.createCode({ groupId: group!.id, grantUnlimitedEvents: true })).rejects.toThrow("platform administrators");
|
||||||
const signs = signsRouter.createCaller(ctx(0));
|
const signs = signsRouter.createCaller(ctx(0));
|
||||||
await expect(signsRouter.createCaller(ctx(1)).prepare({ eventId: event!.id })).rejects.toThrow();
|
await expect(signsRouter.createCaller(ctx(1)).prepare({ eventId: event!.id })).rejects.toThrow();
|
||||||
|
|||||||
@@ -8,3 +8,12 @@ test("saved designs reject unsafe assets, invalid settings, and embedded runtime
|
|||||||
expect(savedSignSchema.safeParse({ ...design, ...patch }).success).toBe(false);
|
expect(savedSignSchema.safeParse({ ...design, ...patch }).success).toBe(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("custom paper dimensions enforce print limits in inches and millimeters", () => {
|
||||||
|
const base = { title: "Event", headline: "Share", message: "Welcome", paper: "custom", ink: "indigo" };
|
||||||
|
expect(savedSignSchema.safeParse({ ...base, unit: "mm", customWidth: 152.4, customHeight: 101.6 }).success).toBe(true);
|
||||||
|
expect(savedSignSchema.safeParse({ ...base, customWidth: 6, customHeight: 4 }).success).toBe(true);
|
||||||
|
for (const [customWidth, customHeight] of [[2, 4], [49, 40], [3, 48]]) {
|
||||||
|
expect(savedSignSchema.safeParse({ ...base, customWidth, customHeight }).success).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -23,10 +23,21 @@ export const savedSignSchema = z.object({
|
|||||||
message: z.string().trim().min(1).max(120),
|
message: z.string().trim().min(1).max(120),
|
||||||
paper: z.enum(papers),
|
paper: z.enum(papers),
|
||||||
ink: z.enum(["indigo", "black"]), layout: z.enum(["photo", "typography", "compact"]).optional(),
|
ink: z.enum(["indigo", "black"]), layout: z.enum(["photo", "typography", "compact"]).optional(),
|
||||||
customWidth: z.number().finite().positive().optional(), customHeight: z.number().finite().positive().optional(),
|
customWidth: z.number().finite().optional(), customHeight: z.number().finite().optional(),
|
||||||
unit: z.enum(["in", "mm"]).optional(), background: z.string().regex(/^#[a-f0-9]{6}$/i).optional(),
|
unit: z.enum(["in", "mm"]).optional(), background: z.string().regex(/^#[a-f0-9]{6}$/i).optional(),
|
||||||
accent: z.string().regex(/^#[a-f0-9]{6}$/i).optional(), font: z.enum(["funnel", "inter", "geologica"]).optional(),
|
accent: z.string().regex(/^#[a-f0-9]{6}$/i).optional(), font: z.enum(["funnel", "inter", "geologica"]).optional(),
|
||||||
decoration: z.enum(["arch", "frame", "minimal"]).optional(), showBrand: z.boolean().optional(),
|
decoration: z.enum(["arch", "frame", "minimal"]).optional(), showBrand: z.boolean().optional(),
|
||||||
backgroundImage: image, logoImage: image,
|
backgroundImage: image, logoImage: image,
|
||||||
}).strict();
|
}).strict().superRefine((design, ctx) => {
|
||||||
|
if (design.paper !== "custom") return;
|
||||||
|
const divisor = design.unit === "mm" ? 25.4 : 1;
|
||||||
|
const width = (design.customWidth ?? 8.5) / divisor;
|
||||||
|
const height = (design.customHeight ?? 11) / divisor;
|
||||||
|
if (width < 3 || width > 48 || height < 3 || height > 48) {
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["customWidth"], message: "Choose dimensions between 3 and 48 inches (76.2–1219.2 mm)." });
|
||||||
|
}
|
||||||
|
if (Math.max(width / height, height / width) > 2.5) {
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["customHeight"], message: "Keep the long edge within 2.5 times the short edge." });
|
||||||
|
}
|
||||||
|
});
|
||||||
export type SavedSign = z.infer<typeof savedSignSchema>;
|
export type SavedSign = z.infer<typeof savedSignSchema>;
|
||||||
|
|||||||
Reference in New Issue
Block a user