Allow hiding the printed URL on guest signs
This commit is contained in:
@@ -6,6 +6,7 @@ import { toast } from "sonner";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
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 { Switch } from "@/components/ui/switch";
|
||||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import type { SignDesign } from "@/lib/guest-sign";
|
import type { SignDesign } from "@/lib/guest-sign";
|
||||||
import { signPaper } 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); }
|
finally { URL.revokeObjectURL(url); setBusy(false); onBusy(false); }
|
||||||
}
|
}
|
||||||
return <>
|
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}
|
{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" ? <>
|
{design.paper === "custom" ? <>
|
||||||
<Field><FieldLabel htmlFor="sign-unit">Units</FieldLabel><Select value={design.unit ?? "in"} onValueChange={value => {
|
<Field><FieldLabel htmlFor="sign-unit">Units</FieldLabel><Select value={design.unit ?? "in"} onValueChange={value => {
|
||||||
|
|||||||
@@ -14,6 +14,19 @@ 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("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", () => {
|
test("text controls change each text independently without resizing the QR", () => {
|
||||||
const url = "https://ma.hadlock.tech/e/demo", qr = signQr(url);
|
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" };
|
const base: SignDesign = { ...design, layout: "compact", paper: "card6x4", title: "Event", headline: "Share", message: "Welcome" };
|
||||||
|
|||||||
@@ -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)}
|
${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>
|
<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("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)}
|
${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 transform="translate(${landscape ? 948 : 598} ${footerY - 21}) scale(.7) translate(${-(cx - 100)} ${-(landscape ? 700 : 1005)})">${brandMarkup}</g>
|
||||||
</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)}
|
${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>
|
<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("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) : ""}
|
${!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("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)}
|
${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"/>
|
<rect width="100%" height="100%" fill="white"/><path d="${qr.path}" transform="translate(4 4)" fill="black"/>
|
||||||
</svg>
|
</svg>
|
||||||
${text("Scan with your phone camera", landscape ? 644 : 820, 20, 40, 600, qx + 185)}
|
${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"/>
|
<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("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)}
|
${text("No app or account needed. Notes welcome, too.", landscape ? 644 : 965, 16, landscape ? 36 : 55)}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { savedSignSchema } from "./sign";
|
|||||||
test("text sizing survives saved design round trips and rejects invalid scales", () => {
|
test("text sizing survives saved design round trips and rejects invalid scales", () => {
|
||||||
const base = { title: "Event", headline: "Share", message: "Welcome", paper: "letter", ink: "indigo" };
|
const base = { title: "Event", headline: "Share", message: "Welcome", paper: "letter", ink: "indigo" };
|
||||||
expect(savedSignSchema.parse(base).headlineScale).toBeUndefined();
|
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);
|
expect(savedSignSchema.parse(JSON.parse(JSON.stringify(sized)))).toEqual(sized);
|
||||||
for (const key of ["headlineScale", "messageScale", "titleScale"]) {
|
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);
|
for (const value of [0, 74, 151, Infinity, NaN, "125"]) expect(savedSignSchema.safeParse({ ...base, [key]: value }).success).toBe(false);
|
||||||
|
|||||||
@@ -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(),
|
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,
|
||||||
|
showUrl: z.boolean().optional(),
|
||||||
}).strict().superRefine((design, ctx) => {
|
}).strict().superRefine((design, ctx) => {
|
||||||
if (design.paper !== "custom") return;
|
if (design.paper !== "custom") return;
|
||||||
const divisor = design.unit === "mm" ? 25.4 : 1;
|
const divisor = design.unit === "mm" ? 25.4 : 1;
|
||||||
|
|||||||
Reference in New Issue
Block a user