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/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
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