santa-free.openapps.xyz

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.
Owned app · rev b8b257a837b1
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.
← All files
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
b8b257a837 · 18 hours ago
Bind agent address in policy constants
9a72d0200b · 20 hours ago
Use functions.openapps.xyz alias, add OpenApp contribution strip
60038fa63d · 20 hours ago
Bounded source sync
a2e0247232 · 1 day ago
Bounded source sync
c84c736f8b · 1 day ago
Bounded source sync
3a54a80cdc · 1 day ago
Bounded source sync
2eed41b4de · 1 day ago