// Postcard — a 2-sided virtual postcard the user can send to a friend.
// Front: a photo (landscape or portrait). Back: message + stamp + postmark.
// Tap card to flip; pill below the card is the non-gestural fallback.
//
// Visual system is "midday" (warm white / deep ink / ultramarine / tomato).
const PC_INK = "#1a1814";
const PC_INK_SOFT = "#5b554a";
const PC_CREAM = "#f5f2e8";
const PC_PAPER = "#fbf9f0";
const PC_ULTRA = "#2240ff";
const PC_TOMATO = "#ff4430";
const PC_SERIF = '"Source Serif 4", "Source Serif Pro", Georgia, serif';
const PC_MONO = 'ui-monospace, "SF Mono", Menlo, monospace';
// PhotoPlaceholder — striped fallback when no real photo is attached.
// Two tones so landscape vs portrait reads as distinct artwork.
function PhotoPlaceholder({ caption, tone = "warm" }) {
const palette = tone === "cool"
? { a: "#3a4a6b", b: "#2c3a55", text: "rgba(245,242,232,0.78)" }
: { a: "#c98a4b", b: "#b6743a", text: "rgba(26,24,20,0.62)" };
return (
{caption && (
{caption}
)}
);
}
// Stamp — typographic city stamp with a perforated edge and a 4×4 azulejo
// tile motif. Country word top, city + value bottom; value is the only
// place tomato appears here.
function Stamp({
scale = 1,
city = "LISBOA",
country = "PORTUGAL",
value = "€1,20",
}) {
const W = 96 * scale;
const H = 116 * scale;
const perfSize = 6 * scale;
const perfBg = `radial-gradient(circle at center, ${PC_CREAM} ${perfSize / 2}px, transparent ${perfSize / 2 + 0.5}px)`;
return (
{country}
{Array.from({ length: 16 }).map((_, i) => {
const r = Math.floor(i / 4), c = i % 4;
const fill = (r + c) % 2 === 0 ? PC_ULTRA : "transparent";
const ring = (r + c) % 2 === 0 ? "transparent" : PC_ULTRA;
return (
);
})}
{city}
{value}
);
}
// Postmark — concentric rings + arc-set city/agency + center date.
// Rendered in tomato ink with multiply blend so it sits on the stamp.
function Postmark({
size = 84,
city = "LISBOA",
agency = "CORREIOS",
date = "17 · APR · 26",
rotate = -8,
}) {
// Unique ids per instance so two Postmarks on the same page don't collide.
const uid = React.useId();
const topId = `pm-arc-top-${uid}`;
const botId = `pm-arc-bot-${uid}`;
return (
);
}
// CardSurface — the physical card chrome: rounded corners, layered shadow,
// warm paper background. Wraps a face.
function CardSurface({ width, height, children, padding = 0, bg = PC_PAPER }) {
return (
{children}
);
}
// Front face — the photo fills edge-to-edge regardless of orientation.
// `photoUrl` overrides the placeholder when present.
function PostcardFront({ photoUrl, caption, tone = "warm" }) {
if (photoUrl) {
return (
);
}
return (
);
}
// Back face — landscape: message on the left, address + stamp + postmark on
// the right, classic center rule splitting them.
function PostcardBackLandscape({
width, height,
date = "17 April · Lisboa",
message = [
"Mara — sat at a tile bench above the river",
"all afternoon. The trams keep going by but",
"I don't get on any of them. Saving the pastel",
"de nata place for when you're here.",
"— J",
],
to = { name: "Mara Halloran", lines: ["14 Greenwood Place", "Brooklyn NY 11215"] },
stamp,
postmark,
}) {
const lineColor = "rgba(26,24,20,0.10)";
const lineGap = 26;
const messageText = Array.isArray(message) ? message.join("\n") : (message || "");
return (
{date}
{messageText}
To
{to.name}
{(to.lines || []).map((l, i) => (
{l}
))}
);
}
// Back face — portrait: header strip with date / address / stamp on top,
// then ruled message lines below.
function PostcardBackPortrait({
width, height,
date = "17 April · Lisboa",
message = [
"Mara — sat at a tile bench above",
"the river all afternoon. The trams",
"keep going by but I don't get on",
"any of them. Saving the pastel de",
"nata place for when you're here.",
"— J",
],
to = { name: "Mara Halloran", lines: ["14 Greenwood Pl · Brooklyn NY"] },
stamp,
postmark,
}) {
const lineColor = "rgba(26,24,20,0.10)";
const lineGap = 24;
return (