Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone c7891873549c9ca1004894b3
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
src/Person.tsx
// One person's private page: who they give to, that person's wish list and anonymous questions,
// their own wish list, questions from their Secret Santa, the reveal, and every device that opened it.
// "/x/<id>/p/<pid>#k=<key>" (the private link) opens it on any device, after asking "is this you?",
// because each device that opens a page is listed on it.
import { useEffect, useState, type FormEvent } from "react";
import { get, set, setMany, useQuery, serverTimestamp } from "@bounded-sh/client";
import { explain, callApi } from "./lib/bounded";
import { navigate } from "./lib/router";
import { ownerKey, personKey, rememberPerson } from "./lib/mine";
import { openExchange, claimWrites } from "./lib/data";
import { copy } from "./ui";
import { Loading, Missing, Problem, Facts, Linked, CalendarButton, useExchange, useMatches, useNow, revealText, openedBy } from "./common";
import { SITE_URL } from "../functions/lib/config";
import { randomId, privacyLine, formatDate, formatMoment, LIMITS, type Ask, type Exchange, type Match, type Open, type Person, type Wish } from "../functions/lib/santa";
type Gate = "loading" | "missing" | "needs-link" | "bad-link" | { confirm: string; key: string; uid: string } | { uid: string } | string;
export function PersonPage({ id, pid }: { id: string; pid: string }) {
const [state, setState] = useState<Gate>("loading");
const claim = async (uid: string, key: string, name: string) => {
try {
await setMany(claimWrites(id, pid, key, uid));
} catch {
setState("bad-link");
return;
}
const ex = (await get(`exchanges/${id}`)) as Exchange | null;
rememberPerson({ exchangeId: id, pid, key, name, exchangeName: ex?.name ?? "" });
history.replaceState(null, "", `/x/${id}/p/${pid}`);
setState({ uid });
};
useEffect(() => {
let off = false;
(async () => {
try {
const { uid, found } = await openExchange(id);
if (!found) return void (!off && setState("missing"));
const fromLink = new URLSearchParams(location.hash.slice(1)).get("k");
const person = (await get(`exchanges/${id}/people/${pid}`)) as Person | null;
if (!person) return void (!off && setState("missing"));
if (await get(`exchanges/${id}/opens/${pid}_${uid}`)) {
if (fromLink) {
rememberPerson({ exchangeId: id, pid, key: fromLink, name: person.name });
history.replaceState(null, "", `/x/${id}/p/${pid}`);
}
return void (!off && setState({ uid }));
}
const saved = personKey(pid);
if (saved) return void (!off && (await claim(uid, saved, person.name)));
if (!fromLink) return void (!off && setState("needs-link"));
if (!off) setState({ confirm: person.name, key: fromLink, uid });
} catch (err) {
if (!off) setState(explain(err));
}
})();
return () => { off = true; };
}, [id, pid]); // eslint-disable-line react-hooks/exhaustive-deps
if (state === "loading") return <Loading text="Opening your page..." />;
if (state === "missing") return <Missing title="This page does not exist." >The organizer may have removed this person, or the link is cut short. <a href={`/x/${id}`} data-nav>Open the exchange</a> to see who is in.</Missing>;
if (state === "needs-link") return <Missing title="This page needs its private link.">Each person's page opens with the private link the organizer sent them, or on the device they joined with. Check that you copied all of it, including the part after the #.</Missing>;
if (state === "bad-link") return <Missing title="This private link does not work.">Check that you copied all of it, including the part after the #. If it still fails, ask the organizer to send it again.</Missing>;
if (typeof state === "string") return <Problem text={state} />;
if ("confirm" in state) {
return (
<div className="wrap page-pad narrow stack" style={{ "--gap": "18px" } as React.CSSProperties}>
<p className="eyebrow">Private page</p>
<h1 className="h1">This is {state.confirm}'s Secret Santa page.</h1>
<p className="lede">It shows who {state.confirm} is getting a gift for. If you are not {state.confirm}, please close it: every device that opens this page is listed on it, so {state.confirm} would see that someone else looked.</p>
<div className="row">
<button className="btn btn-lg" type="button" onClick={() => claim(state.uid, state.key, state.confirm)}>I am {state.confirm}</button>
<button className="btn btn-lg btn-line" type="button" onClick={() => navigate(`/x/${id}`)}>I am not {state.confirm}</button>
</div>
</div>
);
}
return <PersonView id={id} pid={pid} uid={state.uid} />;
}
function PersonView({ id, pid, uid }: { id: string; pid: string; uid: string }) {
const { ex, people, opens } = useExchange(id);
const drawn = ex?.status === "drawn";
const { data: match } = useQuery(drawn ? `exchanges/${id}/matches/${pid}` : null) as { data: Match | null | undefined };
const now = useNow();
const me = people?.find((p) => p.id === pid);
useEffect(() => {
if (ex) document.title = `Your Secret Santa page: ${ex.name} | Santa`;
}, [ex?.name]); // eslint-disable-line react-hooks/exhaustive-deps
if (ex === undefined || people === undefined || opens === undefined) return <Loading text="Opening your page..." />;
if (!ex) return <Missing title="This exchange could not be loaded." />;
if (!me) return <Missing title="You are no longer in this exchange.">The organizer removed you. <a href={`/x/${id}`} data-nav>Open the exchange</a> to see who is in.</Missing>;
const to = drawn && match && match.round === ex.round ? match.to : null;
const giftee = to ? people.find((p) => p.id === to) : undefined;
const revealed = drawn && ex.revealAt > 0 && ex.revealAt <= now;
const key = personKey(pid);
const link = key ? `${SITE_URL}/x/${id}/p/${pid}#k=${key}` : "";
return (
<div className="wrap page-pad xpage">
<header className="xhead">
<p className="eyebrow">{ex.name}</p>
<h1 className="h1">Hi {me.name}.</h1>
<Facts ex={ex} />
{ex.notes && <p className="notes">{ex.notes}</p>}
</header>
{drawn && ex.round > 1 && <p className="notice">Names were drawn again{ex.drawnAt ? ` on ${new Date(ex.drawnAt * 1000).toLocaleDateString(undefined, { month: "long", day: "numeric" })}` : ""}. If you saw a name before, it may have changed.</p>}
<section className="card match-card" aria-live="polite">
{!drawn ? (
<>
<img className="art match-art" src="/art/card-santa.webp" alt="" width="180" height="134" />
<p className="h3">Names are not drawn yet.</p>
<p className="muted">{ex.round > 0 ? "The organizer is making changes and will draw names again." : "When the organizer draws names, this page shows who you are getting a gift for."} Write your wish list below so your Secret Santa knows what you would like.</p>
</>
) : giftee ? (
<>
<p className="eyebrow">You are getting a gift for</p>
<p className="match-name">{giftee.name}</p>
<p className="muted">Keep it a secret.{ex.budget ? ` The budget is ${ex.budget}.` : ""}{ex.date ? ` Gifts on ${formatDate(ex.date)}.` : ""}</p>
</>
) : match === undefined ? (
<div className="loading"><span className="spinner" /> Finding your match...</div>
) : (
<p className="muted">Your match is not here yet. Reload the page in a moment.</p>
)}
</section>
{giftee && <Giftee key={`${to}-${ex.round}`} id={id} pid={pid} uid={uid} to={giftee.id} name={giftee.name} round={ex.round} />}
<MyWishList id={id} pid={pid} />
{drawn && <ForMe id={id} pid={pid} round={ex.round} />}
{revealed && <Reveal id={id} pid={pid} people={people} round={ex.round} now={now} />}
<Devices ex={ex} opens={opens} pid={pid} uid={uid} now={now} />
<section className="keep-box">
<h2 className="h3">Keep your page</h2>
{link ? (
<>
<p className="muted">This private link opens your page on any device. Anyone with it can see who you are getting a gift for, so keep it to yourself.</p>
<div className="link-row"><code className="link-code">{link}</code><button className="btn btn-sm btn-line" type="button" onClick={() => copy(link, "Private link copied. Keep it to yourself.")}>Copy</button></div>
</>
) : <p className="muted">This device remembers your page. To open it somewhere else, use the private link the organizer sent you.</p>}
<div className="row" style={{ "--gap": "8px" } as React.CSSProperties}>
<CalendarButton id={id} ex={ex} />
<a className="btn btn-sm btn-quiet" href={`/x/${id}`} data-nav>Everyone in the exchange</a>
{ownerKey(id) && <a className="btn btn-sm btn-quiet" href={`/x/${id}/admin`} data-nav>Organizer view</a>}
</div>
</section>
</div>
);
}
/** The giftee's wish list and anonymous questions. First proves this device is their Santa this round. */
function Giftee({ id, pid, uid, to, name, round }: { id: string; pid: string; uid: string; to: string; name: string; round: number }) {
const [ready, setReady] = useState<boolean | string>(false);
useEffect(() => {
let off = false;
set(`exchanges/${id}/santas/${to}/s/${uid}`, { from: pid, round })
.then(() => !off && setReady(true))
.catch((err) => !off && setReady(explain(err)));
return () => { off = true; };
}, [id, to, uid, pid, round]);
if (ready === false) return <div className="loading"><span className="spinner" /> Loading {name}'s wish list...</div>;
if (typeof ready === "string") return <p className="notice notice-error">{ready}</p>;
return <GifteeView id={id} to={to} name={name} round={round} />;
}
function GifteeView({ id, to, name, round }: { id: string; to: string; name: string; round: number }) {
const { data: wish } = useQuery(`exchanges/${id}/wishes/${to}`) as { data: Wish | null | undefined };
const { data: asks } = useQuery(`exchanges/${id}/asks/${to}/q`, { limit: 200 }) as { data: Ask[] | undefined };
const [text, setText] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const mine = (asks || []).filter((q) => q.round === round).sort((a, b) => a.askedAt - b.askedAt);
const ask = async (e: FormEvent) => {
e.preventDefault();
const t = text.trim();
if (!t) return;
setBusy(true);
setError(null);
try {
// Every reader sees who wrote a document, so the API writes the question: this device only leaves it for the API.
const qid = randomId(12);
await set(`askrequests/${qid}`, { exchangeId: id, to, text: t, round });
await callApi(`/questions/${qid}`, undefined, "POST");
setText("");
} catch (err) {
setError(explain(err));
}
setBusy(false);
};
return (
<section className="panel-section">
<h2 className="section-title">{name}'s wish list</h2>
{wish === undefined ? <div className="loading"><span className="spinner" /></div>
: wish?.text ? <div className="wish"><Linked text={wish.text} /></div>
: <p className="muted">{name} has not written a wish list yet. Ask them something below.</p>}
<h3 className="h3" style={{ marginTop: 10 }}>Ask {name} a question</h3>
<p className="muted small">It is anonymous: {name} sees the question on their page, not who asked. Their answer shows here.</p>
{mine.length > 0 && (
<ul className="qa-list">
{mine.map((q) => (
<li key={q.id}>
<p className="q">{q.text}</p>
<p className={q.answer ? "a" : "a muted"}>{q.answer || "No answer yet."}</p>
</li>
))}
</ul>
)}
<form className="ask-row" onSubmit={ask}>
<label className="sr-only" htmlFor="ask-input">Your question for {name}</label>
<input id="ask-input" className="input" value={text} onChange={(e) => setText(e.target.value)} maxLength={LIMITS.question} placeholder="Do you prefer tea or coffee?" autoComplete="off" />
<button className="btn" type="submit" disabled={busy || !text.trim()}>{busy ? "Asking..." : "Ask anonymously"}</button>
</form>
{error && <p className="notice notice-error" role="alert">{error}</p>}
</section>
);
}
function MyWishList({ id, pid }: { id: string; pid: string }) {
const { data: wish } = useQuery(`exchanges/${id}/wishes/${pid}`) as { data: Wish | null | undefined };
const [text, setText] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
const value = text ?? wish?.text ?? "";
const changed = text !== null && text !== (wish?.text ?? "");
const save = async (e: FormEvent) => {
e.preventDefault();
setBusy(true);
setError(null);
try {
await set(`exchanges/${id}/wishes/${pid}`, { text: value.trim(), at: serverTimestamp() });
setText(null);
setSaved(true);
} catch (err) {
setError(explain(err));
}
setBusy(false);
};
return (
<form className="panel-section" onSubmit={save}>
<h2 className="section-title"><label htmlFor="wish-input">Your wish list</label></h2>
<p className="muted small">Only you and your Secret Santa see it. One idea per line; links work.</p>
<textarea id="wish-input" className="textarea" value={value} disabled={wish === undefined} maxLength={LIMITS.wish}
onChange={(e) => { setText(e.target.value); setSaved(false); }}
placeholder={"Wool socks, size 9\nAnything by Ursula K. Le Guin\nhttps://example.com/the-mug-i-want"} rows={5} />
<div className="row" style={{ "--gap": "10px" } as React.CSSProperties}>
<button className="btn" type="submit" disabled={busy || !changed}>{busy ? "Saving..." : "Save wish list"}</button>
{saved && !changed && <span className="saved" role="status">Saved</span>}
</div>
{error && <p className="notice notice-error" role="alert">{error}</p>}
</form>
);
}
function ForMe({ id, pid, round }: { id: string; pid: string; round: number }) {
const { data: asks } = useQuery(`exchanges/${id}/asks/${pid}/q`, { limit: 200 }) as { data: Ask[] | undefined };
const list = (asks || []).filter((q) => q.round === round).sort((a, b) => a.askedAt - b.askedAt);
if (!list.length) return null;
return (
<section className="panel-section">
<h2 className="section-title">Questions from your Secret Santa</h2>
<p className="muted small">You do not see who asked. Your answers show on their page.</p>
<ul className="qa-list">{list.map((q) => <AnswerRow key={q.id} id={id} pid={pid} q={q} />)}</ul>
</section>
);
}
function AnswerRow({ id, pid, q }: { id: string; pid: string; q: Ask }) {
const [text, setText] = useState(q.answer);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const save = async (e: FormEvent) => {
e.preventDefault();
setBusy(true);
setError(null);
try {
await set(`exchanges/${id}/asks/${pid}/q/${q.id}`, { answer: text.trim(), answeredAt: serverTimestamp() });
} catch (err) {
setError(explain(err));
}
setBusy(false);
};
return (
<li>
<p className="q">{q.text}</p>
<form className="ask-row" onSubmit={save}>
<label className="sr-only" htmlFor={`answer-${q.id}`}>Your answer</label>
<input id={`answer-${q.id}`} className="input" value={text} onChange={(e) => setText(e.target.value)} maxLength={LIMITS.answer} placeholder="Your answer" autoComplete="off" />
<button className="btn btn-line" type="submit" disabled={busy || !text.trim() || text.trim() === q.answer}>{q.answer ? "Change answer" : "Answer"}</button>
</form>
{error && <p className="notice notice-error" role="alert">{error}</p>}
</li>
);
}
function Reveal({ id, pid, people, round, now }: { id: string; pid: string; people: Person[]; round: number; now: number }) {
const matches = useMatches(id, true, round, people.length, now);
const name = new Map(people.map((p) => [p.id, p.name]));
const list = (matches || []).filter((m) => name.has(m.id));
const santa = list.find((m) => m.to === pid);
return (
<section className="card reveal-card">
<p className="eyebrow">The reveal</p>
{santa && <p className="reveal-line">Your Secret Santa was <strong>{name.get(santa.id)}</strong>.</p>}
{list.length > 0 && (
<ul className="pairs">
{list.map((m) => <li key={m.id}><span>{name.get(m.id)}</span><span className="muted">gave to</span><span>{name.get(m.to) ?? "someone who left"}</span></li>)}
</ul>
)}
</section>
);
}
function Devices({ ex, opens, pid, uid, now }: { ex: Exchange; opens: Open[]; pid: string; uid: string; now: number }) {
const { devices, api } = openedBy(opens, pid);
const others = devices.filter((o) => o.by !== uid).length;
const apiAt = opens.find((o) => o.pid === pid && o.via === "api")?.at;
const reveal = revealText(ex, now);
return (
<aside className="privacy-box">
<h2 className="h3">Who sees what</h2>
<p>{privacyLine(ex)}</p>
{reveal && <p>{reveal}</p>}
{others === 0 && !api ? (
<p className="ok-line">Only this device has opened your page.</p>
) : (
<p className="warn-line">
Your page was also opened {others > 0 ? `on ${others === 1 ? "1 other device" : `${others} other devices`}` : ""}{others > 0 && api ? " and " : ""}{api ? `by an assistant through the API${apiAt ? `, most recently ${formatMoment(apiAt)}` : ""}` : ""}.
{" "}If that was not you, someone else may have seen who you are getting a gift for. Tell the organizer: they can draw names again.
</p>
)}
</aside>
);
}
Change history
Point OpenApps links to openapps.free
Bind agent address in policy constants
Use functions.openapps.xyz alias, add OpenApp contribution strip
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync