Allow hiding the printed URL on guest signs

This commit is contained in:
2026-09-10 13:50:32 -04:00
parent 8544c4128b
commit 2c22ff550f
5 changed files with 24 additions and 4 deletions
@@ -6,6 +6,7 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import type { SignDesign } from "@/lib/guest-sign";
import { signPaper } from "@/lib/guest-sign";
@@ -63,6 +64,11 @@ export function SignDesignControls({ design, onChange, onBusy }: { design: SignD
finally { URL.revokeObjectURL(url); setBusy(false); onBusy(false); }
}
return <>
<Field>
<FieldLabel htmlFor="sign-show-url">Show URL on sign</FieldLabel>
<Switch id="sign-show-url" checked={design.showUrl !== false} onCheckedChange={showUrl => onChange({ showUrl })} aria-describedby="sign-show-url-help" />
<FieldDescription id="sign-show-url-help">Hide the printed web address for a cleaner card. The QR still works. Save design to keep this setting.</FieldDescription>
</Field>
{cropFile ? <BannerCropDialog key={`${cropFile.name}-${cropAspect}`} file={cropFile} aspect={cropAspect} title="Frame your background photo" description="Drag the crop frame to reposition it, or resize it to zoom in. The shaded area will be cut off." hint="Matches the photo area in your current sign orientation. Apply to preview it on the sign, then save your design. Changing orientation may require another crop." confirmLabel="Apply crop" onCancel={() => setCropFile(null)} onConfirm={crop => { const file = cropFile; setCropFile(null); void upload(file, "backgroundImage", crop); }} /> : null}
{design.paper === "custom" ? <>
<Field><FieldLabel htmlFor="sign-unit">Units</FieldLabel><Select value={design.unit ?? "in"} onValueChange={value => {
+13
View File
@@ -14,6 +14,19 @@ 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("URL can be hidden in every layout without changing QR content", async () => {
const url = "https://ma.hadlock.tech/e/demo", qr = signQr(url);
for (const layout of [undefined, "compact", "typography", "photo"] as const) {
const visible = guestSignSvg({ ...design, layout }, url, qr);
const hidden = guestSignSvg({ ...design, layout, showUrl: false }, url, qr);
expect(visible).toContain("ma.hadlock.tech");
expect(hidden).not.toContain("ma.hadlock.tech");
expect(hidden).toContain(qr.path);
expect(guestSignSvg({ ...design, layout, showUrl: true }, url, qr)).toBe(visible);
const { data, info } = await sharp(Buffer.from(hidden), { density: 120 }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
expect(jsQR(new Uint8ClampedArray(data), info.width, info.height)?.data).toBe(url);
}
});
test("text controls change each text independently without resizing the QR", () => {
const url = "https://ma.hadlock.tech/e/demo", qr = signQr(url);
const base: SignDesign = { ...design, layout: "compact", paper: "card6x4", title: "Event", headline: "Share", message: "Welcome" };
+3 -3
View File
@@ -117,7 +117,7 @@ export function guestSignSvg(design: SignDesign, guestUrl: string, qr: SignQr) {
${sizedText(design.message, textX, landscape ? 450 : 355, 29, design.messageScale ?? 100, textWidth, landscape ? 150 : 100)}
<svg x="${codeX}" y="${codeY}" width="${qrSize}" height="${qrSize}" viewBox="0 0 ${qr.size + 8} ${qr.size + 8}" shape-rendering="crispEdges"><rect width="100%" height="100%" fill="white"/><path d="${qr.path}" transform="translate(4 4)" fill="black"/></svg>
${sizedText("Scan to upload", codeX + qrSize / 2, codeY + qrSize + 45, 25, 100, qrSize, 40, 500, true)}
${sizedText(guestUrl.replace(/^https?:\/\//, ""), codeX + qrSize / 2, codeY + qrSize + 78, 16, 100, qrSize + 40, 50, 400, true)}
${design.showUrl === false ? "" : sizedText(guestUrl.replace(/^https?:\/\//, ""), codeX + qrSize / 2, codeY + qrSize + 78, 16, 100, qrSize + 40, 50, 400, true)}
${sizedText(design.title, textX, footerY, 23, design.titleScale ?? 100, landscape ? 530 : 480, 65, 500)}
<g transform="translate(${landscape ? 948 : 598} ${footerY - 21}) scale(.7) translate(${-(cx - 100)} ${-(landscape ? 700 : 1005)})">${brandMarkup}</g>
</g>
@@ -139,7 +139,7 @@ export function guestSignSvg(design: SignDesign, guestUrl: string, qr: SignQr) {
${design.messageScale === undefined ? lines(design.message, left, messageY, compact ? 26 : 18, compact ? (landscape ? 38 : 24) : photoLed ? (landscape ? 42 : 35) : (landscape ? 48 : 32)) : sizedText(design.message, left, messageY, compact ? 26 : 18, design.messageScale, landscape && photoLed ? column : Math.min(column, qrX - left - 40), photoLed ? (landscape ? 65 : 85) : landscape ? 230 : 280)}
<svg x="${qrX}" y="${qrY}" width="${qrSize}" height="${qrSize}" viewBox="0 0 ${qr.size + 8} ${qr.size + 8}" shape-rendering="crispEdges"><rect width="100%" height="100%" fill="white"/><path d="${qr.path}" transform="translate(4 4)" fill="black"/></svg>
${lines("Scan to share", qrX, qrY - 22, compact ? 24 : 18, 30, 600)}
${lines(guestUrl.replace(/^https?:\/\//, ""), qrX, qrY + qrSize + 24, compact ? 15 : 11, Math.floor(qrSize / (compact ? 9 : 7)))}
${design.showUrl === false ? "" : lines(guestUrl.replace(/^https?:\/\//, ""), qrX, qrY + qrSize + 24, compact ? 15 : 11, Math.floor(qrSize / (compact ? 9 : 7)))}
${!photoLed && !compact ? lines("Your photos. Our story.", left, landscape ? 665 : 850, 24, 30, 600) : ""}
${lines("No app or account needed.", left, photoLed ? (landscape ? 665 : 950) : (landscape ? 705 : 890), compact ? 22 : 16, photoLed && landscape ? 22 : 32)}
${lines("Photos and notes welcome.", left, photoLed ? (landscape ? 710 : 976) : (landscape ? 735 : 920), compact ? 22 : 16, photoLed && landscape ? 22 : 32)}
@@ -164,7 +164,7 @@ export function guestSignSvg(design: SignDesign, guestUrl: string, qr: SignQr) {
<rect width="100%" height="100%" fill="white"/><path d="${qr.path}" transform="translate(4 4)" fill="black"/>
</svg>
${text("Scan with your phone camera", landscape ? 644 : 820, 20, 40, 600, qx + 185)}
${text(guestUrl.replace(/^https?:\/\//, ""), landscape ? 675 : 854, guestUrl.length > 160 ? 10 : 14, guestUrl.length > 160 ? (landscape ? 68 : 110) : (landscape ? 46 : 78), 400, qx + 185)}
${design.showUrl === false ? "" : text(guestUrl.replace(/^https?:\/\//, ""), landscape ? 675 : 854, guestUrl.length > 160 ? 10 : 14, guestUrl.length > 160 ? (landscape ? 68 : 110) : (landscape ? 46 : 78), 400, qx + 185)}
<path d="M${landscape ? 90 : 85} ${landscape ? 575 : 901}h${landscape ? 470 : 680}" stroke="${headingInk}" stroke-opacity=".25" stroke-width="1"/>
${text("Scan. Choose your photos. Upload.", landscape ? 611 : 935, 21, 50, 500)}
${text("No app or account needed. Notes welcome, too.", landscape ? 644 : 965, 16, landscape ? 36 : 55)}
+1 -1
View File
@@ -4,7 +4,7 @@ import { savedSignSchema } from "./sign";
test("text sizing survives saved design round trips and rejects invalid scales", () => {
const base = { title: "Event", headline: "Share", message: "Welcome", paper: "letter", ink: "indigo" };
expect(savedSignSchema.parse(base).headlineScale).toBeUndefined();
const sized = { ...base, headlineScale: 150, messageScale: 125, titleScale: 75 };
const sized = { ...base, headlineScale: 150, messageScale: 125, titleScale: 75, showUrl: false };
expect(savedSignSchema.parse(JSON.parse(JSON.stringify(sized)))).toEqual(sized);
for (const key of ["headlineScale", "messageScale", "titleScale"]) {
for (const value of [0, 74, 151, Infinity, NaN, "125"]) expect(savedSignSchema.safeParse({ ...base, [key]: value }).success).toBe(false);
+1
View File
@@ -39,6 +39,7 @@ export const savedSignSchema = z.object({
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(),
backgroundImage: image, logoImage: image,
showUrl: z.boolean().optional(),
}).strict().superRefine((design, ctx) => {
if (design.paper !== "custom") return;
const divisor = design.unit === "mm" ? 25.4 : 1;