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/Join.tsx
// The join link: the exchange, the names in it, and (while the organizer allows it) a form to add
// yourself. Adding yourself makes your key on this device, so nobody else ever holds your page.
import { useEffect, useState, type FormEvent } from "react";
import { setMany } from "@bounded-sh/client";
import { explain } from "./lib/bounded";
import { navigate } from "./lib/router";
import { meIn, ownerKey, rememberPerson } from "./lib/mine";
import { openExchange, personWrites, claimWrites } from "./lib/data";
import { Loading, Missing, Problem, Facts, CalendarButton, useExchange, useNow, revealText } from "./common";
import { randomId, privacyLine, LIMITS, type Exchange, type Person } from "../functions/lib/santa";
export function JoinPage({ id }: { id: string }) {
const [state, setState] = useState<"loading" | "missing" | { uid: string } | string>("loading");
useEffect(() => {
let off = false;
openExchange(id)
.then(({ uid, found }) => !off && setState(found ? { uid } : "missing"))
.catch((err) => !off && setState(explain(err)));
return () => { off = true; };
}, [id]);
if (state === "loading") return <Loading text="Opening the exchange..." />;
if (state === "missing") return <Missing title="This Secret Santa link does not work." />;
if (typeof state === "string") return <Problem text={state} />;
return <JoinView id={id} uid={state.uid} />;
}
function JoinView({ id, uid }: { id: string; uid: string }) {
const { ex, people } = useExchange(id);
const now = useNow();
const mine = meIn(id);
const organizer = Boolean(ownerKey(id));
useEffect(() => {
if (ex) document.title = `${ex.name} | Santa`;
}, [ex?.name]); // eslint-disable-line react-hooks/exhaustive-deps
if (ex === undefined || people === undefined) return <Loading text="Opening the exchange..." />;
if (!ex) return <Missing title="This Secret Santa link does not work." />;
const stillIn = mine && people.some((p) => p.id === mine.pid);
const reveal = revealText(ex, now);
return (
<div className="wrap page-pad xpage">
<header className="xhead">
<p className="eyebrow">Secret Santa</p>
<h1 className="h1">{ex.name}</h1>
<Facts ex={ex} />
{ex.notes && <p className="notes">{ex.notes}</p>}
</header>
{stillIn ? (
<section className="card join-card">
<p className="h3">You are in as {mine.name}.</p>
<p className="muted">{ex.status === "drawn" ? "Names are drawn. Your page shows who you are getting a gift for." : "Names are not drawn yet. Write your wish list on your page while you wait."}</p>
<a className="btn" href={`/x/${id}/p/${mine.pid}`} data-nav>{ex.status === "drawn" ? "See who you are getting a gift for" : "Open your page"}</a>
</section>
) : ex.status === "open" && ex.allowJoin ? (
<JoinForm id={id} uid={uid} ex={ex} people={people} />
) : (
<section className="card join-card">
{ex.status === "drawn" ? (
<>
<p className="h3">Names are drawn.</p>
<p className="muted">If the organizer added you, open the private link they sent you. If you added yourself from this link, open it on the phone or computer you used then.</p>
</>
) : (
<>
<p className="h3">The organizer adds people to this exchange.</p>
<p className="muted">Ask them for your private link. It shows who you are getting a gift for once names are drawn.</p>
</>
)}
</section>
)}
<section className="stack" style={{ "--gap": "10px" } as React.CSSProperties}>
<h2 className="section-title">{people.length === 1 ? "1 person" : `${people.length} people`} in this exchange</h2>
{people.length ? (
<ul className="name-chips">{people.map((p) => <li key={p.id}>{p.name}</li>)}</ul>
) : <p className="muted">Nobody yet. Be the first.</p>}
</section>
<aside className="privacy-box">
<h2 className="h3">Who sees what</h2>
<p>{privacyLine(ex)}</p>
{reveal && <p>{reveal}</p>}
<p className="muted small">Santa sends no emails. Everything happens on these pages.</p>
</aside>
<div className="row" style={{ "--gap": "8px" } as React.CSSProperties}>
<CalendarButton id={id} ex={ex} />
{organizer && <a className="btn btn-sm btn-line" href={`/x/${id}/admin`} data-nav>Organizer view</a>}
</div>
</div>
);
}
function JoinForm({ id, uid, ex, people }: { id: string; uid: string; ex: Exchange; people: Person[] }) {
const [name, setName] = useState("");
const [contact, setContact] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async (e: FormEvent) => {
e.preventDefault();
const n = name.trim();
if (!n) return setError("Type your name.");
if (people.some((p) => p.name.toLowerCase() === n.toLowerCase())) {
return setError(`Someone called ${n} is already in. If the organizer added you, use the private link they sent you. If not, add a last initial.`);
}
setBusy(true);
setError(null);
try {
const pid = randomId(20);
const key = randomId(24);
await setMany([...personWrites(id, uid, { pid, key, name: n, contact: contact.trim(), self: true }), ...claimWrites(id, pid, key, uid)]);
rememberPerson({ exchangeId: id, pid, key, name: n, exchangeName: ex.name });
navigate(`/x/${id}/p/${pid}`);
} catch (err) {
setError(explain(err));
setBusy(false);
}
};
return (
<form className="card join-card" onSubmit={submit} noValidate aria-label="Add yourself">
<p className="h3">Add yourself</p>
<label className="field">
<span>Your name</span>
<input className="input" value={name} onChange={(e) => setName(e.target.value)} maxLength={LIMITS.person} autoComplete="name" placeholder="As the group knows you" />
</label>
<label className="field">
<span>Email or phone <em className="optional">optional</em></span>
<input className="input" value={contact} onChange={(e) => setContact(e.target.value)} maxLength={LIMITS.contact} autoComplete="email" placeholder="So the organizer can reach you" />
<small>Only the organizer sees it. Santa never messages you.</small>
</label>
{error && <p className="notice notice-error" role="alert">{error}</p>}
<button className="btn btn-block" type="submit" disabled={busy}>{busy ? "Adding..." : "Join and write my wish list"}</button>
<p className="hint">Your page opens on this device. You can copy its private link there to use it on another one.</p>
</form>
);
}
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