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/common.tsx
// Pieces the exchange pages share: loading and missing states, the exchange's facts, live data,
// wish list text with links, and the calendar file.
import { useEffect, useState, type ReactNode } from "react";
import { useQuery } from "@bounded-sh/client";
import { download } from "./ui";
import { SITE_URL } from "../functions/lib/config";
import { exchangeIcs, fileName, formatDate, formatMoment, type Exchange, type Match, type Open, type Person } from "../functions/lib/santa";
export function Loading({ text }: { text: string }) {
return <div className="wrap page-pad"><div className="loading"><span className="spinner" /> {text}</div></div>;
}
export function Missing({ title, children }: { title: string; children?: ReactNode }) {
return (
<div className="wrap page-pad narrow">
<h1 className="h1">{title}</h1>
<div className="lede" style={{ marginTop: 14 }}>{children ?? <>Check that you copied the whole link. Santa links are long and every character counts.</>}</div>
<a className="btn" style={{ marginTop: 24 }} href="/" data-nav>Make a free Secret Santa</a>
</div>
);
}
export function Problem({ text }: { text: string }) {
return <div className="wrap page-pad narrow"><p className="notice notice-error" role="alert">{text}</p></div>;
}
/** The exchange, its people and its open records, live. */
export function useExchange(id: string) {
const { data: ex } = useQuery(`exchanges/${id}`) as { data: Exchange | null | undefined };
const { data: people } = useQuery(`exchanges/${id}/people`, { limit: 200 }) as { data: Person[] | undefined };
const { data: opens } = useQuery(`exchanges/${id}/opens`, { limit: 1000 }) as { data: Open[] | undefined };
const sorted = people ? [...people].sort((a, b) => a.createdAt - b.createdAt || a.name.localeCompare(b.name)) : undefined;
return { ex, people: sorted, opens };
}
/** The current round's matches, live, while `open`. Read rules check the server's clock when a query opens, so a device
* whose clock runs ahead can open it a moment before the reveal and get part of the list; while any are missing, it opens
* the query again on each tick. */
export function useMatches(id: string, open: boolean, round: number, count: number, now: number): Match[] | undefined {
const [reopen, setReopen] = useState(false);
const { data } = useQuery(open && !reopen ? `exchanges/${id}/matches` : null, { limit: 200 }) as { data: Match[] | undefined };
const list = data?.filter((m) => m.round === round);
useEffect(() => { if (list && list.length < count) setReopen(true); }, [now]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { if (reopen) setReopen(false); }, [reopen]);
return list;
}
/** The current time in seconds, ticking every 30 seconds (for the reveal). */
export function useNow(): number {
const [now, setNow] = useState(() => Math.floor(Date.now() / 1000));
useEffect(() => {
const t = window.setInterval(() => setNow(Math.floor(Date.now() / 1000)), 30000);
return () => window.clearInterval(t);
}, []);
return now;
}
export const joinUrl = (id: string) => `${SITE_URL}/x/${id}`;
export function Facts({ ex }: { ex: Exchange }) {
if (!ex.date && !ex.budget) return null;
return (
<ul className="facts">
{ex.date && <li><span className="facts-label">Gift day</span> {formatDate(ex.date)}</li>}
{ex.budget && <li><span className="facts-label">Budget</span> {ex.budget}</li>}
</ul>
);
}
export function revealText(ex: Exchange, now: number): string | null {
if (!ex.revealAt) return null;
return ex.revealAt <= now
? "The reveal has happened: everyone in the exchange can see who gave to whom."
: `On ${formatMoment(ex.revealAt)}, everyone in the exchange sees who gave to whom.`;
}
/** Text with line breaks kept and web addresses as links. */
export function Linked({ text }: { text: string }) {
const parts = text.split(/(https?:\/\/[^\s<>"]+)/g);
return (
<p className="linked">
{parts.map((part, i) => (/^https?:\/\//.test(part)
? <a key={i} href={part} target="_blank" rel="noopener noreferrer nofollow">{part.replace(/^https?:\/\/(www\.)?/, "").slice(0, 60)}</a>
: <span key={i}>{part}</span>))}
</p>
);
}
export function CalendarButton({ id, ex }: { id: string; ex: Exchange }) {
if (!ex.date) return null;
return (
<button className="btn btn-sm btn-line" type="button" onClick={() => download(`${fileName(ex.name)}.ics`, exchangeIcs(id, ex, joinUrl(id)), "text/calendar")}>
Add to calendar
</button>
);
}
/** How many devices opened a person's page, and whether an assistant did through the API. */
export function openedBy(opens: Open[], pid: string) {
const mine = opens.filter((o) => o.pid === pid);
return { devices: mine.filter((o) => o.via === "web"), api: mine.some((o) => o.via === "api") };
}
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