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
SurfaceLocalCloud
Host login (apex)http://show-pony.localhost:4180/loginhttps://show-pony.kumiko.rocks/login
Demo invite (rooftop)http://demo.show-pony.localhost:4180/e/rooftop-launchhttps://demo.show-pony.kumiko.rocks/e/rooftop-launch
Acme invite (offsite)http://acme.show-pony.localhost:4180/e/acme-offsitehttps://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. It uses the same tokens as chapter 2 with a 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). Success appears inline, with no redirect.

// 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 { useLocale, useTranslation } from "@cosmicdrift/kumiko-renderer";
import { type ReactElement, useEffect, useState } from "react";
import { fetchDemoMode } from "../demo-mode-client";
import 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 };
// The gradient itself lives once, in styles.css's `.sp-invite-split-page`
// fallback rule — it reads var(--color-primary)/var(--color-ring), so
// setting just these two custom properties is enough for the CSS rule to
// paint the same gradient with the tenant's accent, no JS-side duplicate.
function splitInviteCanvasStyle(accent: string): Record<string, string> {
return {
"--color-primary": accent,
"--color-ring": accent,
};
}
export function EventPage(): ReactElement {
const t = useTranslation();
const appLocale = useLocale().locale();
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 } : { kind: "missing" }),
)
.catch(() => setLoad({ kind: "missing" }));
}, []);
const splitPage = load.kind === "ready" && load.branding.heroStyle === "split";
const splitAccent = load.kind === "ready" ? load.branding.accentColor : "";
// Syncs split-page body classes + tenant accent CSS vars; the gradient
// itself comes from the CSS fallback rule (styles.css) keyed off these
// same classes, so JS never sets it inline.
// kumiko-lint-ignore no-raw-hooks DOM-side-effect on document.body, not app state
useEffect(() => {
if (!splitPage || !splitAccent) return;
document.body.classList.add("show-pony-public", "sp-invite-split-page");
document.body.style.setProperty("--color-primary", splitAccent);
document.body.style.setProperty("--color-ring", splitAccent);
return () => {
document.body.classList.remove("show-pony-public", "sp-invite-split-page");
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(appLocale, {
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">
<section
className={
event.description
? "rounded-xl border border-[var(--color-border)] bg-[var(--color-card)] p-6"
: "sp-invite-card rounded-xl border border-[var(--color-border)] bg-[var(--color-card)] p-6"
}
>
{event.description ? (
<p className="whitespace-pre-line text-[var(--color-foreground)] leading-relaxed">
{event.description}
</p>
) : null}
<a
href={icsHref(event)}
download={`${event.slug}.ics`}
className={`${event.description ? "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">
<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 };
}
const HEX6_PATTERN = /^#[0-9a-fA-F]{6}$/;
const HEX8_PATTERN = /^#[0-9a-fA-F]{8}$/;
// The alpha suffixes below assume a 6-digit hex. The config field allows
// #rrggbbaa (8-digit, already has alpha), which would otherwise grow into
// an invalid 10-digit color — strip any existing alpha channel first.
function normalizedTint(accent: string): string {
if (HEX6_PATTERN.test(accent)) return accent;
if (HEX8_PATTERN.test(accent)) return accent.slice(0, 7);
return "#7c3aed";
}
function heroOverlayStyle(accent: string): CssVarStyle {
const tint = normalizedTint(accent);
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 { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
import { ModeSwitch } from "@cosmicdrift/kumiko-renderer-web";
import { 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 { Banner, Button, Field, Form, Input } = usePrimitives();
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(): Promise<void> {
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>
);
}
return (
<div className="mt-4">
<Form
onSubmit={(e) => {
e?.preventDefault();
void onSubmit();
}}
>
<Field id="rsvp-name" label={t("showpony:public.rsvp.name")} required>
<Input
kind="text"
id="rsvp-name"
name="rsvp-name"
value={name}
onChange={setName}
placeholder={t("showpony:public.rsvp.name-placeholder")}
required
disabled={state.kind === "submitting"}
/>
</Field>
<ModeSwitch
value={status}
onChange={setStatus}
options={(Object.keys(statusLabels) as RsvpStatus[]).map((s) => ({
value: s,
label: statusLabels[s],
}))}
/>
<Field id="rsvp-plus-n" label={t("showpony:public.rsvp.plus-guests")}>
<Input
kind="number"
id="rsvp-plus-n"
name="rsvp-plus-n"
value={plusN}
onChange={(v) => setPlusN(Math.min(20, Math.max(0, v ?? 0)))}
disabled={state.kind === "submitting"}
/>
</Field>
<Field id="rsvp-email" label={t("showpony:public.rsvp.email")}>
<Input
kind="email"
id="rsvp-email"
name="rsvp-email"
value={email}
onChange={setEmail}
placeholder={t("showpony:public.rsvp.email-placeholder")}
disabled={state.kind === "submitting"}
/>
</Field>
{state.kind === "error" && (
<Banner variant="error">
{t("showpony:public.rsvp.error", { reason: state.reason })}
</Banner>
)}
<Button type="submit" loading={state.kind === "submitting"} disabled={name.length === 0}>
{t("showpony:public.rsvp.submit")}
</Button>
</Form>
</div>
);
}

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