Add printable guest sign studio and refine event imagery
This commit is contained in:
@@ -246,22 +246,24 @@ export function EventSettingsForm({
|
||||
<Button type="button" variant="outline" disabled={bannerBusy || (!formBanner && !formCustomBanner)} onClick={() => { setFormBanner(null); setFormCustomBanner(null); }}>Remove banner</Button>
|
||||
<details className="rounded-lg border p-3">
|
||||
<summary className="cursor-pointer py-2 text-sm font-medium">Choose from approved gallery photos ({bannerPhotos.length})</summary>
|
||||
<fieldset disabled={bannerBusy} aria-labelledby="banner-label" className="mt-3 grid max-h-80 grid-cols-2 gap-3 overflow-y-auto sm:grid-cols-3">
|
||||
<div className="mt-3 max-h-80 overflow-y-auto p-1">
|
||||
<fieldset disabled={bannerBusy} aria-labelledby="banner-label" className="grid min-w-0 auto-rows-max grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{bannerPhotos.map((photo, index) => (
|
||||
<button key={photo.id} type="button" aria-label={`Use photo ${index + 1} as banner`}
|
||||
aria-pressed={formBanner === photo.id} onClick={() => { setFormBanner(photo.id); setFormCustomBanner(null); }}
|
||||
className="relative aspect-[3/2] overflow-hidden rounded-lg border-2 border-transparent aria-pressed:border-primary focus-visible:outline-2 focus-visible:outline-ring">
|
||||
className="relative block aspect-[8/3] w-full min-w-0 overflow-hidden rounded-lg border-2 border-transparent aria-pressed:border-primary focus-visible:outline-2 focus-visible:outline-ring">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={photo.thumbUrl ?? photo.displayUrl ?? ""} alt="" width={300} height={200} loading="lazy" className="size-full object-cover" />
|
||||
<img src={photo.thumbUrl ?? photo.displayUrl ?? ""} alt="" width={320} height={120} loading="lazy" className="absolute inset-0 size-full object-cover" />
|
||||
{formBanner === photo.id ? <span className="absolute right-2 top-2 rounded-full bg-primary p-1 text-primary-foreground"><CheckIcon aria-hidden="true" className="size-4" /></span> : null}
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
</div>
|
||||
{photos.isLoading ? <p className="text-xs text-muted-foreground">Loading photos…</p> : null}
|
||||
{photos.isError ? <p role="alert" className="text-sm text-destructive">Could not load banner photos. Try reopening Settings.</p> : null}
|
||||
{!photos.isLoading && !photos.isError && bannerPhotos.length === 0 ? <p className="text-sm text-muted-foreground">Upload and approve an event photo to use it as a banner.</p> : null}
|
||||
</details>
|
||||
<FieldDescription>Dedicated uploads stay out of the gallery. Gallery banners follow gallery visibility rules. Images are center-cropped for this preview; page layouts may crop differently.</FieldDescription>
|
||||
<FieldDescription>Banners use an 8:3 frame everywhere. Uploads use your chosen crop and stay out of the gallery. Gallery photos are center-cropped and follow gallery visibility rules.</FieldDescription>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="notes-policy">Publish notes</FieldLabel>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { QrCodeIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { createServerCaller } from "@/trpc/server";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { EventWorkspace, type EventWorkspaceTab } from "@/components/event-workspace";
|
||||
@@ -132,10 +135,11 @@ export default async function EventDashboardPage({
|
||||
<Badge variant="outline">Gallery held</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="truncate text-sm text-muted-foreground">{event.guestUrl}</p>
|
||||
<p className="truncate text-sm text-muted-foreground">{event.guestUrl.replace(/^https?:\/\//, "")}</p>
|
||||
{event.location ? <p className="text-sm text-muted-foreground">{event.location}</p> : null}
|
||||
</div>
|
||||
<CopyGuestLink url={event.guestUrl} />
|
||||
<Button asChild variant="outline"><Link href={`/dashboard/events/${event.id}/sign`}><QrCodeIcon data-icon="inline-start" />Guest sign</Link></Button>
|
||||
</div>
|
||||
}
|
||||
tabs={tabs}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import type { SignDesign } from "@/lib/guest-sign";
|
||||
|
||||
const presets = {
|
||||
linenCard: { layout: "compact", paper: "card6x4", background: "#f1eadf", accent: "#473a30", decoration: "minimal", font: "funnel" },
|
||||
indigo: { layout: "typography", background: "#4055b5", accent: "#ffffff", decoration: "minimal", font: "funnel" },
|
||||
linen: { layout: "photo", background: "#f1eadf", accent: "#473a30", decoration: "minimal", font: "funnel" },
|
||||
forest: { layout: "photo", background: "#153e35", accent: "#ffffff", decoration: "minimal", font: "funnel" },
|
||||
mono: { layout: "typography", background: "#f4f2ec", accent: "#171717", decoration: "minimal", font: "funnel" },
|
||||
} as const;
|
||||
|
||||
export function SignDesignControls({ design, onChange, onBusy }: { design: SignDesign; onChange: (patch: Partial<SignDesign>) => void; onBusy: (busy: boolean) => void }) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [preset, setPreset] = useState("indigo");
|
||||
const uploadId = useRef(0);
|
||||
async function upload(file: File, field: "logoImage" | "backgroundImage") {
|
||||
if (!["image/jpeg", "image/png", "image/webp"].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024) {
|
||||
toast.error("Choose a JPEG, PNG, or WebP up to 10 MB."); return;
|
||||
}
|
||||
const id = ++uploadId.current;
|
||||
setBusy(true); onBusy(true);
|
||||
const url = URL.createObjectURL(file);
|
||||
try {
|
||||
const image = new Image();
|
||||
image.src = url;
|
||||
await image.decode();
|
||||
const max = field === "logoImage" ? 1000 : 2200;
|
||||
const scale = Math.min(1, max / Math.max(image.naturalWidth, image.naturalHeight));
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.max(1, Math.round(image.naturalWidth * scale)); canvas.height = Math.max(1, Math.round(image.naturalHeight * scale));
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("Image tools unavailable");
|
||||
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
if (id === uploadId.current) onChange({ [field]: canvas.toDataURL("image/png") });
|
||||
} catch { toast.error("Couldn't open this image. Try another file."); }
|
||||
finally { URL.revokeObjectURL(url); setBusy(false); onBusy(false); }
|
||||
}
|
||||
return <>
|
||||
{design.paper === "custom" ? <>
|
||||
<Field><FieldLabel htmlFor="sign-unit">Units</FieldLabel><Select value={design.unit ?? "in"} onValueChange={value => {
|
||||
const unit = value as "in" | "mm";
|
||||
const factor = unit === design.unit ? 1 : unit === "mm" ? 25.4 : 1 / 25.4;
|
||||
onChange({ unit, customWidth: Number(((design.customWidth ?? 8.5) * factor).toFixed(2)), customHeight: Number(((design.customHeight ?? 11) * factor).toFixed(2)) });
|
||||
}}><SelectTrigger id="sign-unit" className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="in">Inches</SelectItem><SelectItem value="mm">Millimeters</SelectItem></SelectGroup></SelectContent></Select></Field>
|
||||
<div className="grid grid-cols-2 gap-3">{(["customWidth", "customHeight"] as const).map((key, i) => <Field key={key}><FieldLabel htmlFor={`sign-${key}`}>{i === 0 ? "Width" : "Height"}</FieldLabel><Input id={`sign-${key}`} type="number" step="0.01" min={design.unit === "mm" ? 76.2 : 3} max={design.unit === "mm" ? 1219.2 : 48} value={design[key] ?? ""} onChange={e => onChange({ [key]: e.target.value === "" ? 0 : Number(e.target.value) })} /></Field>)}</div>
|
||||
<FieldDescription>Portrait, square, or landscape. 3–48 inches per edge. Small signs should be scanned up close.</FieldDescription>
|
||||
</> : null}
|
||||
<Field><FieldLabel htmlFor="sign-preset">Design preset</FieldLabel><Select value={preset} onValueChange={value => { setPreset(value); onChange(presets[value as keyof typeof presets]); }}><SelectTrigger id="sign-preset" className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="linenCard">Card · Linen (no photo)</SelectItem><SelectItem value="indigo">Typography · Indigo</SelectItem><SelectItem value="linen">Photo · Linen</SelectItem><SelectItem value="forest">Photo · Forest</SelectItem><SelectItem value="mono">Typography · Paper</SelectItem></SelectGroup></SelectContent></Select></Field>
|
||||
<Field><FieldLabel htmlFor="sign-font">Heading font</FieldLabel><Select value={design.font ?? "funnel"} onValueChange={font => onChange({ font: font as SignDesign["font"] })}><SelectTrigger id="sign-font" className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="funnel">Funnel Display</SelectItem><SelectItem value="geologica">Geologica</SelectItem><SelectItem value="inter">Inter</SelectItem></SelectGroup></SelectContent></Select></Field>
|
||||
<FieldDescription>{design.layout === "compact" ? "The Linen text-and-QR design without a photo. Starts at 6 × 4 inches; choose any paper size above." : design.layout === "photo" ? "A large event photo with a compact QR section. Add a photo below to enable sign export." : "Bold typography, a full-color background, and a prominent QR. Your uploaded photo is kept for photo presets."}</FieldDescription>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field><FieldLabel htmlFor="sign-background">Background</FieldLabel><Input id="sign-background" type="color" value={design.background ?? "#eeece5"} onChange={e => onChange({ background: e.target.value })} /></Field>
|
||||
<Field><FieldLabel htmlFor="sign-accent">Accent</FieldLabel><Input id="sign-accent" type="color" value={design.accent ?? "#4055b5"} onChange={e => onChange({ accent: e.target.value })} /><FieldDescription>Text adapts for contrast against the background.</FieldDescription></Field>
|
||||
</div>
|
||||
{([{ key: "backgroundImage", label: "Background photo" }, { key: "logoImage", label: "Your logo" }] as const).map(field => <Field key={field.key}>
|
||||
<FieldLabel htmlFor={`sign-${field.key}`}>{field.label}</FieldLabel><Input id={`sign-${field.key}`} type="file" accept="image/jpeg,image/png,image/webp" disabled={busy} onChange={e => { const file = e.target.files?.[0]; e.target.value = ""; if (file) void upload(file, field.key); }} />
|
||||
{design[field.key] ? <Button type="button" variant="outline" disabled={busy} onClick={() => onChange({ [field.key]: undefined })}>Remove {field.label.toLowerCase()}</Button> : null}
|
||||
<FieldDescription>{field.key === "logoImage" ? "Transparent PNG works best. Replaces the Manyangles logo." : "Used by photo presets. Cropped to fill the photo area; switch presets without losing your upload."} Embedded in your export; never added to the event gallery.</FieldDescription>
|
||||
</Field>)}
|
||||
<Field><FieldLabel htmlFor="sign-brand">Manyangles logo</FieldLabel><Select value={design.showBrand === false ? "hide" : "show"} onValueChange={value => onChange({ showBrand: value === "show" })}><SelectTrigger id="sign-brand" className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="show">Show when no custom logo</SelectItem><SelectItem value="hide">Hide</SelectItem></SelectGroup></SelectContent></Select></Field>
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { createServerCaller } from "@/trpc/server";
|
||||
import { effectiveEvent } from "@/lib/event-lifecycle";
|
||||
import { signQr } from "@/server/sign-qr";
|
||||
import { GuestSignStudio } from "./studio";
|
||||
|
||||
export default async function GuestSignPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const caller = await createServerCaller();
|
||||
let event;
|
||||
try { event = await caller.manager.event({ eventId: id }); } catch { notFound(); }
|
||||
const current = effectiveEvent(event);
|
||||
return <GuestSignStudio eventId={event.id} title={event.title} guestUrl={event.guestUrl} qr={signQr(event.guestUrl)}
|
||||
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} />;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ArrowLeftIcon, DownloadIcon, PrinterIcon, QrCodeIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
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 { SignDesignControls } from "./design-controls";
|
||||
|
||||
export function GuestSignStudio({ eventId, title, guestUrl, qr, warning }: {
|
||||
eventId: string; title: string; guestUrl: string; qr: SignQr; warning: string | null;
|
||||
}) {
|
||||
const [design, setDesign] = useState<SignDesign>({ title, headline: "Share your favorite moments", message: "Help us see the day through your eyes. Add your photos to our shared album.", paper: "letter", ink: "indigo", font: "funnel", layout: "typography", background: "#4055b5", accent: "#ffffff", decoration: "minimal", showBrand: true, customWidth: 8.5, customHeight: 11, unit: "in" });
|
||||
const [fontError, setFontError] = useState(false);
|
||||
const [assetBusy, setAssetBusy] = useState(false);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void Promise.all(["funnel-display", "inter", "geologica"].map(async name => {
|
||||
const response = await fetch(`/fonts/${name}.woff2`);
|
||||
if (!response.ok) throw new Error("Font unavailable");
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
let binary = "";
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return `data:font/woff2;base64,${btoa(binary)}`;
|
||||
})).then(([fontData, interFontData, geologicaFontData]) => {
|
||||
if (active) setDesign(d => ({ ...d, fontData, interFontData, geologicaFontData }));
|
||||
}).catch(() => { if (active) setFontError(true); });
|
||||
return () => { active = false; };
|
||||
}, []);
|
||||
const printFrame = useRef<HTMLIFrameElement | null>(null);
|
||||
const [printing, setPrinting] = useState(false);
|
||||
useEffect(() => () => { printFrame.current?.remove(); }, []);
|
||||
let sizeError: string | null = null;
|
||||
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 fontReady = Boolean(design.fontData && design.interFontData && design.geologicaFontData);
|
||||
const valid = Boolean(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);
|
||||
function downloadSvg(content: string, filename: string) {
|
||||
const url = URL.createObjectURL(new Blob([content], { type: "image/svg+xml;charset=utf-8" }));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
function print() {
|
||||
printFrame.current?.remove();
|
||||
const frame = document.createElement("iframe");
|
||||
frame.title = "Printable guest sign";
|
||||
frame.setAttribute("aria-hidden", "true");
|
||||
frame.tabIndex = -1;
|
||||
frame.style.cssText = "position:fixed;left:-10000px;top:0;width:850px;height:1202px;border:0";
|
||||
printFrame.current = frame;
|
||||
setPrinting(true);
|
||||
frame.onload = async () => {
|
||||
try {
|
||||
if (!frame.contentWindow) throw new Error("Print window unavailable");
|
||||
await frame.contentDocument?.fonts.ready;
|
||||
await Promise.all(Array.from(frame.contentDocument?.images ?? []).map(image => image.decode()));
|
||||
frame.contentWindow.addEventListener("afterprint", () => frame.remove(), { once: true });
|
||||
frame.contentWindow.focus();
|
||||
frame.contentWindow.print();
|
||||
} catch { toast.error("Printing is unavailable here. Download the SVG and open it in your browser to print."); }
|
||||
finally { setPrinting(false); }
|
||||
};
|
||||
frame.srcdoc = signPrintHtml(svg, design);
|
||||
document.body.append(frame);
|
||||
}
|
||||
return <div className="reveal flex min-w-0 flex-col gap-6 sm:gap-8">
|
||||
<div className="flex flex-col gap-4 2xl:flex-row 2xl:items-end 2xl:justify-between">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<Link href={`/dashboard/events/${eventId}`} className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground"><ArrowLeftIcon className="size-4" />Back to event</Link>
|
||||
<h1 className="text-3xl font-semibold tracking-tight sm:text-4xl">Guest sign studio</h1>
|
||||
<p className="text-sm text-muted-foreground">A little sign. Everyone’s photos. Customize, preview, and print.</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => downloadSvg(guestQrSvg(qr), `manyangles-${eventId}-qr.svg`)}><QrCodeIcon data-icon="inline-start" />Download QR SVG</Button>
|
||||
<Button variant="outline" disabled={!valid} onClick={() => downloadSvg(svg, `manyangles-${eventId}-guest-sign-${design.paper}.svg`)}><DownloadIcon data-icon="inline-start" />Download sign SVG</Button>
|
||||
<Button disabled={!valid || printing} onClick={print}><PrinterIcon data-icon="inline-start" />{printing ? "Preparing…" : "Print / Save PDF"}</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid items-start gap-6 xl:grid-cols-[22rem_minmax(0,1fr)]">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Make it yours</CardTitle><CardDescription>Edits update the preview only, not your event. Download your sign before leaving.</CardDescription></CardHeader>
|
||||
<CardContent>
|
||||
<FieldGroup>
|
||||
<Field><FieldLabel htmlFor="sign-paper">Paper size</FieldLabel>
|
||||
<Select value={design.paper} onValueChange={paper => setDesign(d => ({ ...d, paper: paper as SignPaper }))}>
|
||||
<SelectTrigger id="sign-paper" className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectGroup>{Object.entries(SIGN_PAPERS).map(([key, paper]) => <SelectItem key={key} value={key}>{paper.label}</SelectItem>)}<SelectItem value="custom">Custom dimensions</SelectItem></SelectGroup></SelectContent>
|
||||
</Select>
|
||||
</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}
|
||||
{([{ 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()}
|
||||
onChange={e => setDesign(d => ({ ...d, [field.key]: e.target.value }))} />
|
||||
</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 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>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="min-w-0 rounded-xl border bg-muted p-4 sm:p-6 xl:sticky xl:top-24">
|
||||
<div className="mb-4 flex justify-between text-xs text-muted-foreground"><span>Live print preview</span><span>{paper.width} × {paper.height}</span></div>
|
||||
<img src={`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`} alt={`Printable guest sign for ${design.title}, with a QR code and upload instructions`}
|
||||
width={850} height={paper.viewHeight} className="mx-auto block h-auto max-h-[75dvh] w-full object-contain drop-shadow-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
+39
-38
@@ -1,11 +1,11 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import weddingExit from "@/assets/homepage/457677937.webp";
|
||||
import gardenToast from "@/assets/homepage/2157148814.webp";
|
||||
import guestSelfie from "@/assets/homepage/2157148813.webp";
|
||||
import weddingGreetings from "@/assets/homepage/599131260.webp";
|
||||
import guestTunnel from "@/assets/homepage/599129661.webp";
|
||||
import firstDance from "@/assets/homepage/498647922.webp";
|
||||
import toast from "@/assets/homepage/240365594.webp";
|
||||
import guest from "@/assets/homepage/189193781.webp";
|
||||
import reception from "@/assets/homepage/208516668.webp";
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
CheckCircle2Icon,
|
||||
@@ -18,6 +18,7 @@ import { createServerCaller } from "@/trpc/server";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { BrandMark } from "@/components/brand-mark";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
import { cn, formatEventDate } from "@/lib/utils";
|
||||
import {
|
||||
Card,
|
||||
@@ -38,7 +39,7 @@ const features = [
|
||||
number: "02",
|
||||
icon: ShieldCheckIcon,
|
||||
title: "You decide what’s public",
|
||||
body: "Every photo lands in a private review queue before it reaches the shared gallery.",
|
||||
body: "Approve photos before sharing, publish automatically, or keep the gallery private. You choose.",
|
||||
},
|
||||
{
|
||||
number: "03",
|
||||
@@ -51,10 +52,10 @@ const features = [
|
||||
const previewPhotos = [
|
||||
{ src: guest, layout: "row-span-2", position: "50% 40%" },
|
||||
{ src: firstDance, layout: "col-span-2", position: "50% 45%" },
|
||||
{ src: toast, layout: "", position: "50% 50%" },
|
||||
{ src: guestSelfie, layout: "", position: "50% 40%" },
|
||||
{ src: guestTunnel, layout: "row-span-2", position: "50% 50%" },
|
||||
{ src: reception, layout: "", position: "65% 50%" },
|
||||
{ src: weddingExit, layout: "", position: "50% 50%" },
|
||||
{ src: gardenToast, layout: "", position: "50% 35%" },
|
||||
{ src: weddingGreetings, layout: "", position: "50% 50%" },
|
||||
] as const;
|
||||
|
||||
function AlbumPreview() {
|
||||
@@ -84,10 +85,10 @@ function AlbumPreview() {
|
||||
src={photo.src}
|
||||
alt=""
|
||||
fill
|
||||
sizes={index === 1 ? "(max-width: 640px) 60vw, 360px" : "(max-width: 640px) 30vw, 180px"}
|
||||
sizes={index === 0 || index === 3 ? "(max-width: 640px) 80vw, 480px" : index === 1 ? "(max-width: 640px) 60vw, 360px" : "(max-width: 640px) 40vw, 240px"}
|
||||
quality={85}
|
||||
className="object-cover"
|
||||
style={{ objectPosition: photo.position }}
|
||||
placeholder="blur"
|
||||
loading="eager"
|
||||
/>
|
||||
</div>
|
||||
@@ -117,6 +118,7 @@ export default async function HomePage() {
|
||||
const demoEvent = listed[0];
|
||||
|
||||
return (
|
||||
<>
|
||||
<main>
|
||||
<section className="page-pad mx-auto grid min-h-[calc(100svh-4rem)] w-full max-w-6xl items-center gap-12 py-14 sm:py-20 lg:grid-cols-[0.88fr_1.12fr] lg:gap-16 lg:py-24">
|
||||
<div className="reveal flex max-w-xl flex-col gap-6">
|
||||
@@ -152,31 +154,6 @@ export default async function HomePage() {
|
||||
<AlbumPreview />
|
||||
</section>
|
||||
|
||||
<section className="border-y border-border/80 bg-card/80 backdrop-blur-sm" aria-labelledby="how-it-works">
|
||||
<div className="page-pad mx-auto w-full max-w-6xl py-14 sm:py-20">
|
||||
<p className="text-xs font-bold tracking-[0.14em] text-primary uppercase">The whole handoff, handled</p>
|
||||
<h2 id="how-it-works" className="mt-3 max-w-2xl text-4xl font-semibold tracking-[-0.035em] sm:text-5xl">
|
||||
Built for the photos that never make it out of the group chat.
|
||||
</h2>
|
||||
<div className="mt-8 grid gap-3 md:grid-cols-3">
|
||||
{features.map((feature) => {
|
||||
const Icon = feature.icon;
|
||||
return (
|
||||
<Card key={feature.title} className="feature-panel overflow-hidden bg-background/60 shadow-none">
|
||||
<CardHeader>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="flex size-10 items-center justify-center rounded-lg bg-primary text-primary-foreground"><Icon aria-hidden="true" /></span>
|
||||
<span className="font-mono text-xs font-bold tracking-widest text-muted-foreground">{feature.number}</span>
|
||||
</div>
|
||||
<CardTitle className="text-xl font-semibold">{feature.title}</CardTitle>
|
||||
<CardDescription className="leading-6">{feature.body}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{listed.length > 0 ? (
|
||||
<section className="page-pad mx-auto flex w-full max-w-6xl flex-col gap-6 py-14 sm:py-20">
|
||||
@@ -208,11 +185,35 @@ export default async function HomePage() {
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<footer className="border-t border-border/80 bg-foreground text-background">
|
||||
<div className="page-pad mx-auto flex w-full max-w-6xl items-center justify-between gap-4 py-7 text-xs text-background/65">
|
||||
<span className="font-heading text-lg font-semibold text-background">Manyangles</span><span>Keep the moment, not the compression.</span>
|
||||
<section className="border-t bg-card/60" aria-labelledby="how-it-works">
|
||||
<div className="page-pad mx-auto grid w-full max-w-6xl gap-10 py-14 sm:py-20 lg:grid-cols-2 lg:items-center lg:gap-16">
|
||||
<div className="relative overflow-hidden rounded-2xl">
|
||||
<Image src={gardenToast} alt="Wedding guests sharing a champagne toast with the couple in a garden"
|
||||
width={1000} height={1200} quality={85} sizes="(min-width: 1024px) 520px, 100vw"
|
||||
className="aspect-[5/4] w-full object-cover lg:aspect-[5/6]" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-bold tracking-[0.14em] text-primary uppercase">Less chasing. More remembering.</p>
|
||||
<h2 id="how-it-works" className="mt-3 scroll-mt-24 text-4xl font-semibold tracking-[-0.035em] sm:text-5xl">The day flies by.<br />The photos stay together.</h2>
|
||||
<p className="mt-5 text-lg leading-7 text-muted-foreground">From the front row to the dance floor, everyone sees something different. Bring it all home.</p>
|
||||
<ol className="mt-8 flex flex-col divide-y">
|
||||
{features.map((feature) => {
|
||||
const Icon = feature.icon;
|
||||
return <li key={feature.number} className="flex gap-4 py-5 first:pt-0">
|
||||
<span className="flex size-11 shrink-0 items-center justify-center rounded-full border bg-background text-primary"><Icon className="size-5" aria-hidden="true" /></span>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold"><span className="mr-2 font-mono text-xs text-muted-foreground">{feature.number}</span>{feature.title}</h3>
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">{feature.body}</p>
|
||||
</div>
|
||||
</li>;
|
||||
})}
|
||||
</ol>
|
||||
<Button asChild size="lg" className="mt-5"><Link href="/sign-up">Start your shared album<ArrowRightIcon data-icon="inline-end" aria-hidden="true" /></Link></Button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user