Skip to content

The public surface

The guest sees one invite page per event: violet hero, date/location pills, description, calendar link, and an RSVP card. No login. This is where the anonymous write from chapter 7 becomes something you can click.

Invite page — hero, copy, RSVP section
Guest typing a name and picking a status
Acme tenant invite — separate subdomain from demo
Surface Local Cloud
Host login (apex) http://show-pony.localhost:4180/login https://show-pony.kumiko.rocks/login
Demo invite (rooftop) http://demo.show-pony.localhost:4180/e/rooftop-launch https://demo.show-pony.kumiko.rocks/e/rooftop-launch
Acme invite (offsite) http://acme.show-pony.localhost:4180/e/acme-offsite https://acme.show-pony.kumiko.rocks/e/acme-offsite
Guest fills the RSVP form on the public page

EventPage is custom React (not schema-driven): hero band with --color-primary, meta pills, description card, then RsvpForm. The guest bundle adds class="show-pony-public" on <body> for a slightly warmer canvas — same tokens as chapter 2, different surface layout.

The guest bundle mounts a single React tree:

import { mountPublic } from "./public/mount";
mountPublic();
— from src/client-public.tsx

Routing by host is in chapter 4 — the guest never loads admin JavaScript.

export async function submitRsvp(input: RsvpInput): Promise<SubmitResult> {
const res = await fetch("/api/write", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: "showpony:write:rsvp:submit", payload: input }),
});
...
}
— from src/public/api.ts

The client does not send the tenant — tenantResolver derives it from the subdomain.

RsvpForm is ordinary React: name (required), status, plus-guests, email (optional). Inline success — no redirect. One tap, done.

// The public event page. Loads the event by the slug in the URL
// (<key>.show-pony.<domain>/e/<slug>) and shows it plus the RSVP form.
// Anonymous — no login, no account.
import { useTranslation } from "@cosmicdrift/kumiko-renderer";
import { type ReactElement, useEffect, useState } from "react";
import { fetchDemoMode } from "../demo-mode-client";
import {
EMPTY_INVITE_BRANDING,
type InviteBranding,
} from "../features/show-pony/invite-branding.shared";
import { fetchEventBySlug, fetchInviteBranding, type PublicEvent } from "./api";
import { DemoPublicNotice } from "./DemoPublicNotice";
import { InviteHero, inviteBrandingCssVars } from "./InviteHero";
import { icsHref } from "./ics";
import { RsvpForm } from "./RsvpForm";
function slugFromPath(): string {
const segments = window.location.pathname.split("/").filter(Boolean);
return segments[segments.length - 1] ?? "";
}
type Load =
| { kind: "loading" }
| { kind: "missing" }
| { kind: "ready"; event: PublicEvent; branding: InviteBranding };
function splitInviteCanvasStyle(accent: string): Record<string, string> {
return {
"--color-primary": accent,
"--color-ring": accent,
backgroundColor: `color-mix(in srgb, ${accent} 10%, var(--color-background))`,
backgroundImage: [
`radial-gradient(ellipse 90% 60% at 0% 0%, color-mix(in srgb, ${accent} 38%, transparent), transparent 55%)`,
`radial-gradient(ellipse 75% 55% at 100% 100%, color-mix(in srgb, ${accent} 28%, transparent), transparent 50%)`,
`linear-gradient(180deg, color-mix(in srgb, ${accent} 18%, var(--color-background)) 0%, color-mix(in srgb, ${accent} 10%, var(--color-background)) 50%, var(--color-background) 95%)`,
].join(", "),
};
}
export function EventPage(): ReactElement {
const t = useTranslation();
const [load, setLoad] = useState<Load>({ kind: "loading" });
const [readOnly, setReadOnly] = useState(false);
// kumiko-lint-ignore no-raw-hooks anonymous public bundle — one-shot fetch by slug, no dispatcher
useEffect(() => {
void fetchDemoMode().then((demo) => setReadOnly(demo.readOnly));
const slug = slugFromPath();
void Promise.all([fetchEventBySlug(slug), fetchInviteBranding()])
.then(([event, branding]) =>
setLoad(
event
? { kind: "ready", event, branding: branding ?? EMPTY_INVITE_BRANDING }
: { kind: "missing" },
),
)
.catch(() => setLoad({ kind: "missing" }));
}, []);
const splitPage = load.kind === "ready" && load.branding.heroStyle === "split";
const splitAccent = load.kind === "ready" ? load.branding.accentColor : "";
// kumiko-lint-ignore no-raw-hooks sync split-page canvas + tenant accent onto body for page-wide gradient
useEffect(() => {
if (!splitPage || !splitAccent) return;
const canvas = splitInviteCanvasStyle(splitAccent);
document.body.classList.add("sp-invite-split-page");
document.body.style.backgroundColor = canvas.backgroundColor ?? "";
document.body.style.backgroundImage = canvas.backgroundImage ?? "";
document.body.style.setProperty("--color-primary", splitAccent);
document.body.style.setProperty("--color-ring", splitAccent);
return () => {
document.body.classList.remove("sp-invite-split-page");
document.body.style.backgroundColor = "";
document.body.style.backgroundImage = "";
document.body.style.removeProperty("--color-primary");
document.body.style.removeProperty("--color-ring");
};
}, [splitPage, splitAccent]);
if (load.kind === "loading") {
return (
<main className="mx-auto max-w-2xl p-8 text-[var(--color-muted-foreground)] show-pony-public">
</main>
);
}
if (load.kind === "missing") {
return (
<main className="mx-auto max-w-2xl p-8 show-pony-public">
{t("showpony:public.event.missing")}
</main>
);
}
const { event, branding } = load;
const when = new Date(event.startsAt).toLocaleString(undefined, {
dateStyle: "long",
timeStyle: "short",
});
const guestLimit = event.guestLimit > 0 ? event.guestLimit : null;
const canvasStyle =
splitPage && splitAccent
? splitInviteCanvasStyle(splitAccent)
: inviteBrandingCssVars(branding);
return (
// kumiko-lint-ignore no-inline-styles tenant accent color from branding config
<div
className={`min-h-screen show-pony-public${splitPage ? " sp-invite-split-page" : ""}`}
style={canvasStyle}
>
<InviteHero
branding={branding}
title={event.title}
when={when}
location={event.location || null}
guestLimit={guestLimit}
/>
<main className="relative z-10 mx-auto max-w-2xl px-4 pb-12 pt-6 sm:px-6">
<div className="space-y-6">
{event.description ? (
<section className="rounded-xl border border-[var(--color-border)] bg-[var(--color-card)] p-6">
<p className="whitespace-pre-line text-[var(--color-foreground)] leading-relaxed">
{event.description}
</p>
<a
href={icsHref(event)}
download={`${event.slug}.ics`}
className="mt-5 inline-flex items-center gap-1 text-sm font-medium text-[var(--color-primary)] hover:underline"
>
{t("showpony:public.event.add-to-calendar")}
</a>
</section>
) : (
<section className="sp-invite-card rounded-xl border border-[var(--color-border)] bg-[var(--color-card)] p-6">
<a
href={icsHref(event)}
download={`${event.slug}.ics`}
className="inline-flex items-center gap-1 text-sm font-medium text-[var(--color-primary)] hover:underline"
>
{t("showpony:public.event.add-to-calendar")}
</a>
</section>
)}
<section className="sp-invite-card rounded-xl border border-[var(--color-border)] bg-[var(--color-card)] p-6">
<h2 className="text-lg font-semibold text-[var(--color-foreground)]">
{t("showpony:public.rsvp.heading")}
</h2>
<p className="mt-1 text-sm text-[var(--color-muted-foreground)]">
{t("showpony:public.rsvp.subheading")}
</p>
{readOnly ? <DemoPublicNotice /> : <RsvpForm eventId={event.id} />}
</section>
</div>
</main>
</div>
);
}
import { useTranslation } from "@cosmicdrift/kumiko-renderer";
import type { ReactElement } from "react";
import type { InviteBranding } from "../features/show-pony/invite-branding.shared";
type InviteHeroProps = {
readonly branding: InviteBranding;
readonly title: string;
readonly when: string;
readonly location: string | null;
readonly guestLimit: number | null;
};
type CssVarStyle = Record<string, string>;
function brandingThemeStyle(accent: string): CssVarStyle | undefined {
if (!accent) return undefined;
return { "--color-primary": accent, "--color-ring": accent };
}
function heroOverlayStyle(accent: string): CssVarStyle {
const tint = accent || "#7c3aed";
return {
background: `linear-gradient(135deg, ${tint}99 0%, ${tint}55 45%, ${tint}22 100%)`,
};
}
function MetaPill({
icon,
children,
variant,
}: {
icon: string;
children: string;
variant: "hero" | "card";
}): ReactElement {
const surface =
variant === "hero"
? "bg-[var(--color-primary-foreground)]/15 backdrop-blur-sm"
: "bg-[var(--color-muted)]";
return (
<span className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm ${surface}`}>
<span aria-hidden>{icon}</span>
{children}
</span>
);
}
function MetaPills({
when,
location,
guestLimit,
variant,
}: {
when: string;
location: string | null;
guestLimit: number | null;
variant: "hero" | "card";
}): ReactElement {
const t = useTranslation();
return (
<div className="mt-5 flex flex-wrap gap-2">
<MetaPill icon="📅" variant={variant}>
{when}
</MetaPill>
{location ? (
<MetaPill icon="📍" variant={variant}>
{location}
</MetaPill>
) : null}
{guestLimit != null && guestLimit > 0 ? (
<MetaPill icon="👥" variant={variant}>
{t("showpony:public.event.guest-limit", { limit: String(guestLimit) })}
</MetaPill>
) : null}
</div>
);
}
function HeroCopy({
branding,
title,
when,
location,
guestLimit,
className,
metaVariant,
}: InviteHeroProps & {
readonly className?: string;
readonly metaVariant: "hero" | "card";
}): ReactElement {
const t = useTranslation();
return (
<div className={className}>
{branding.logoUrl ? (
<img src={branding.logoUrl} alt="" className="mb-4 h-10 w-auto object-contain" />
) : branding.title ? (
<p className="text-sm font-semibold uppercase tracking-widest opacity-90">
{branding.title}
</p>
) : (
<p className="text-sm font-medium uppercase tracking-widest opacity-90">
{t("showpony:public.event.invited")}
</p>
)}
<h1 className="mt-2 text-3xl font-semibold tracking-tight sm:text-4xl">{title}</h1>
{branding.description ? (
<p className="mt-3 max-w-xl text-sm leading-relaxed opacity-90">{branding.description}</p>
) : null}
<MetaPills when={when} location={location} guestLimit={guestLimit} variant={metaVariant} />
</div>
);
}
function HeroImage({
url,
alt,
focus = "center",
}: {
url: string;
alt: string;
focus?: "center" | "bottom" | "right";
}): ReactElement {
const focusClass =
focus === "bottom" ? "sp-hero-focus-bottom" : focus === "right" ? "sp-hero-focus-right" : "";
return (
<div className="sp-hero-media absolute inset-0">
<img
src={url}
alt={alt}
className={`sp-hero-ken-burns h-full w-full object-cover ${focusClass}`}
/>
</div>
);
}
function ImmersiveHeroBackdrop({
heroUrl,
accent,
themeStyle,
}: {
heroUrl: string;
accent: string;
themeStyle: CssVarStyle | undefined;
}): ReactElement {
if (heroUrl) {
return (
<>
<HeroImage url={heroUrl} alt="" focus="bottom" />
{/* kumiko-lint-ignore no-inline-styles tenant hero gradient from branding config */}
<div className="sp-hero-grain absolute inset-0" style={heroOverlayStyle(accent)} />
</>
);
}
// kumiko-lint-ignore no-inline-styles tenant accent fallback when no hero image
return <div className="absolute inset-0 bg-[var(--color-primary)]" style={themeStyle} />;
}
export function InviteHero(props: InviteHeroProps): ReactElement {
const { branding } = props;
const themeStyle = brandingThemeStyle(branding.accentColor);
const heroUrl = branding.heroImageUrl;
if (branding.heroStyle === "split") {
return (
// kumiko-lint-ignore no-inline-styles tenant accent color from branding config
<header
className="sp-hero-split border-b border-[var(--color-border)] text-[var(--color-foreground)]"
style={themeStyle}
>
<div className="sp-hero-split-row">
<div className="sp-hero-split-copy flex flex-col justify-center px-6 py-10 sm:px-10 lg:px-12 lg:py-14">
<HeroCopy {...props} metaVariant="card" />
</div>
{heroUrl ? (
<div className="sp-hero-split-media relative overflow-hidden">
<HeroImage url={heroUrl} alt="" focus="right" />
</div>
) : (
<div className="sp-hero-split-media bg-[var(--color-primary)]/20" aria-hidden />
)}
</div>
</header>
);
}
return (
// kumiko-lint-ignore no-inline-styles tenant accent color + hero overlay from branding config
<header
className="sp-hero-immersive relative isolate z-0 overflow-hidden px-6 py-12 text-[var(--color-primary-foreground)] sm:px-10 sm:py-14"
style={themeStyle}
>
<ImmersiveHeroBackdrop
heroUrl={heroUrl}
accent={branding.accentColor}
themeStyle={themeStyle}
/>
<div className="relative z-10 mx-auto flex min-h-[inherit] max-w-3xl flex-col justify-end pb-2">
<HeroCopy {...props} metaVariant="hero" />
</div>
</header>
);
}
export function inviteBrandingCssVars(branding: InviteBranding): CssVarStyle | undefined {
return brandingThemeStyle(branding.accentColor);
}
// The RSVP form — the anonymous write from the public page. Name is required,
// email optional. Inline success on submit, no redirect.
import { useTranslation } from "@cosmicdrift/kumiko-renderer";
import { type FormEvent, type ReactElement, useState } from "react";
import { type RsvpStatus, submitRsvp } from "./api";
type FormState =
| { kind: "idle" }
| { kind: "submitting" }
| { kind: "success"; name: string }
| { kind: "error"; reason: string };
export function RsvpForm({ eventId }: { readonly eventId: string }): ReactElement {
const t = useTranslation();
const [name, setName] = useState("");
const [status, setStatus] = useState<RsvpStatus>("yes");
const [plusN, setPlusN] = useState(0);
const [email, setEmail] = useState("");
const [state, setState] = useState<FormState>({ kind: "idle" });
const statusLabels: Record<RsvpStatus, string> = {
yes: t("showpony:public.rsvp.status.yes"),
maybe: t("showpony:public.rsvp.status.maybe"),
no: t("showpony:public.rsvp.status.no"),
};
async function onSubmit(e: FormEvent<HTMLFormElement>): Promise<void> {
e.preventDefault();
if (name.length === 0) return;
setState({ kind: "submitting" });
const result = await submitRsvp({
eventId,
name,
status,
plusN,
...(email.length > 0 ? { email } : {}),
});
setState(result.ok ? { kind: "success", name } : { kind: "error", reason: result.reason });
}
if (state.kind === "success") {
return (
<div className="sp-rsvp-success mt-6 rounded-xl border border-[var(--color-primary)]/40 bg-[var(--color-card)] p-5 text-sm">
<div className="flex items-start gap-3">
<span
className="sp-rsvp-check flex size-9 shrink-0 items-center justify-center rounded-full bg-[var(--color-primary)] text-lg text-[var(--color-primary-foreground)]"
aria-hidden
>
</span>
<div>
<strong className="text-base">
{t("showpony:public.rsvp.thanks", { name: state.name })}
</strong>
<p className="mt-1 text-[var(--color-muted-foreground)]">
{t("showpony:public.rsvp.on-list")}
</p>
</div>
</div>
</div>
);
}
const field =
"w-full rounded-md border border-[var(--color-border)] bg-[var(--color-background)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-ring)]";
return (
<form onSubmit={onSubmit} className="mt-4">
<div className="grid gap-3">
<input
aria-label={t("showpony:public.rsvp.name")}
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("showpony:public.rsvp.name-placeholder")}
className={field}
/>
<div className="flex gap-2">
{(Object.keys(statusLabels) as RsvpStatus[]).map((s) => (
<button
key={s}
type="button"
onClick={() => setStatus(s)}
className={`flex-1 rounded-md border px-3 py-2 text-sm transition-colors duration-150 ${
status === s
? "border-[var(--color-primary)] bg-[var(--color-primary)] text-[var(--color-primary-foreground)]"
: "border-[var(--color-border)] bg-[var(--color-background)] hover:border-[var(--color-primary)]/40"
}`}
>
{statusLabels[s]}
</button>
))}
</div>
<label className="text-sm text-[var(--color-muted-foreground)]">
{t("showpony:public.rsvp.plus-guests")}
<input
type="number"
min={0}
max={20}
value={plusN}
onChange={(e) => setPlusN(Number.parseInt(e.target.value, 10) || 0)}
className={`${field} mt-1`}
/>
</label>
<input
aria-label={t("showpony:public.rsvp.email")}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t("showpony:public.rsvp.email-placeholder")}
className={field}
/>
<button
type="submit"
disabled={state.kind === "submitting" || name.length === 0}
className="rounded-md bg-[var(--color-primary)] px-4 py-2 text-sm font-medium text-[var(--color-primary-foreground)] disabled:opacity-50"
>
{state.kind === "submitting" ? "…" : t("showpony:public.rsvp.submit")}
</button>
{state.kind === "error" && (
<p className="text-sm text-[var(--color-destructive)]">
{t("showpony:public.rsvp.error", { reason: state.reason })}
</p>
)}
</div>
</form>
);
}

📄 On GitHub: src/public/EventPage.tsx · src/public/InviteHero.tsx