split-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 9ed962174401
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 20ace674a35b849ffda63b30
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/Home.tsx
// The landing hero's right column: the groups this browser opened, and the form to start a new one.
import { useEffect, useState, type FormEvent, type KeyboardEvent, type MouseEvent } from "react";
import { me, explain, sdk } from "./lib/bounded";
import { navigate } from "./lib/router";
import { savedGroups, forgetGroup, rememberGroup, setMyPersonId } from "./lib/mine";
import { Icon } from "./ui";
import { CURRENCIES, randomId } from "../functions/lib/split";

function guessCurrency(): string {
  try {
    const region = new Intl.Locale(navigator.language).maximize().region || "US";
    const byRegion: Record<string, string> = { US: "USD", GB: "GBP", CA: "CAD", AU: "AUD", NZ: "NZD", JP: "JPY", CH: "CHF", SE: "SEK", NO: "NOK", DK: "DKK", PL: "PLN", IN: "INR", SG: "SGD", HK: "HKD", MX: "MXN", BR: "BRL", ZA: "ZAR", IL: "ILS", KR: "KRW", AE: "AED" };
    const euro = ["DE", "FR", "ES", "IT", "NL", "BE", "AT", "PT", "IE", "FI", "GR", "LU", "SK", "SI", "EE", "LV", "LT", "MT", "CY", "HR"];
    return byRegion[region] || (euro.includes(region) ? "EUR" : "USD");
  } catch {
    return "USD";
  }
}

const go = (href: string) => (e: MouseEvent) => {
  if (e.metaKey || e.ctrlKey || e.shiftKey) return;
  e.preventDefault();
  navigate(href);
};

export function Start() {
  const [groups, setGroups] = useState(savedGroups);
  // Coming back to "/" inside the app shows any group opened since the page loaded.
  useEffect(() => {
    const refresh = () => setGroups(savedGroups());
    window.addEventListener("popstate", refresh);
    return () => window.removeEventListener("popstate", refresh);
  }, []);
  return (
    <div className="start">
      {groups.length > 0 && (
        <section className="card your-groups" aria-labelledby="your-groups">
          <h2 className="label-caps" id="your-groups">Your groups</h2>
          <ul>
            {groups.map((g) => (
              <li key={g.id}>
                <a className="your-group" href={`/g/${g.id}`} onClick={go(`/g/${g.id}`)}>
                  <span>{g.name}</span>
                  <Icon name="right" />
                </a>
                <button className="btn btn-quiet btn-icon btn-sm" type="button" aria-label={`Remove ${g.name} from this list`} title="Remove from this list"
                  onClick={() => { forgetGroup(g.id); setGroups(savedGroups()); }}>
                  <Icon name="close" size={16} />
                </button>
              </li>
            ))}
          </ul>
        </section>
      )}
      <CreateGroup returning={groups.length > 0} />
    </div>
  );
}

function CreateGroup({ returning }: { returning: boolean }) {
  const [name, setName] = useState("");
  const [you, setYou] = useState("");
  const [people, setPeople] = useState<string[]>([]);
  const [draft, setDraft] = useState("");
  const [currency, setCurrency] = useState(guessCurrency);
  const [busy, setBusy] = useState(false);
  const [errors, setErrors] = useState<{ name?: string; you?: string; people?: string; form?: string }>({});

  // Start loading the client and the guest session while the person types, so creating is instant.
  const warm = () => { me().catch(() => {}); import("./Group").catch(() => {}); };

  const addPeople = (raw: string) => {
    const names = raw.split(",").map((s) => s.trim().slice(0, 40)).filter(Boolean);
    if (!names.length) return;
    setPeople((list) => {
      const next = [...list];
      for (const n of names) if (!next.some((x) => x.toLowerCase() === n.toLowerCase())) next.push(n);
      return next.slice(0, 49);
    });
    setDraft("");
    setErrors((e) => ({ ...e, people: undefined }));
  };

  const onKey = (e: KeyboardEvent<HTMLInputElement>) => {
    if (e.key === "Enter" || e.key === ",") {
      if (!draft.trim()) return; // Enter on an empty field submits the form
      e.preventDefault();
      addPeople(draft);
    } else if (e.key === "Backspace" && !draft && people.length) {
      setPeople((l) => l.slice(0, -1));
    }
  };

  const submit = async (e: FormEvent) => {
    e.preventDefault();
    const self = you.trim();
    const others = [...people, ...draft.split(",").map((s) => s.trim().slice(0, 40)).filter(Boolean)]
      .filter((n, i, all) => n.toLowerCase() !== self.toLowerCase() && all.findIndex((x) => x.toLowerCase() === n.toLowerCase()) === i);
    const next = {
      name: name.trim() ? undefined : "Give the group a name.",
      you: self ? undefined : "Add your name, so Split can show what you owe.",
      people: others.length ? undefined : "Add at least one other person.",
    };
    setErrors(next);
    if (next.name || next.you || next.people) return;
    setBusy(true);
    try {
      const [uid, { setMany, serverTimestamp }] = await Promise.all([me(), sdk()]);
      const groupId = randomId(20);
      const selfId = randomId(10);
      await setMany([
        { path: `groups/${groupId}`, document: { name: name.trim(), currency, createdBy: uid, createdAt: serverTimestamp() } },
        { path: `groups/${groupId}/joins/${uid}`, document: { at: serverTimestamp() } },
        { path: `groups/${groupId}/people/${selfId}`, document: { name: self, createdAt: serverTimestamp() } },
        ...others.map((n) => ({ path: `groups/${groupId}/people/${randomId(10)}`, document: { name: n, createdAt: serverTimestamp() } })),
      ]);
      setMyPersonId(groupId, selfId);
      rememberGroup(groupId, name.trim());
      try { localStorage.setItem(`split:new:${groupId}`, "1"); } catch { /* blocked storage */ }
      navigate(`/g/${groupId}`);
    } catch (err) {
      setErrors({ form: explain(err) });
      setBusy(false);
    }
  };

  return (
    <form className="create card" onSubmit={submit} onFocus={warm} noValidate aria-labelledby="create-title">
      <h2 className="create-title" id="create-title">{returning ? "Start another group" : "Start a group"}</h2>
      <div className="field">
        <label htmlFor="group-name">Group name</label>
        <input id="group-name" className="input" value={name} onChange={(e) => { setName(e.target.value); setErrors((x) => ({ ...x, name: undefined })); }} maxLength={80}
          placeholder="Lisbon trip, Flat 4B, Friday dinner" autoComplete="off" enterKeyHint="next"
          aria-invalid={errors.name ? true : undefined} aria-describedby={errors.name ? "group-name-error" : undefined} />
        {errors.name && <p className="field-error" id="group-name-error">{errors.name}</p>}
      </div>
      <div className="field">
        <label htmlFor="your-name">Your name</label>
        <input id="your-name" className="input" value={you} onChange={(e) => { setYou(e.target.value); setErrors((x) => ({ ...x, you: undefined })); }} maxLength={40}
          placeholder="Sam" autoComplete="given-name" enterKeyHint="next"
          aria-invalid={errors.you ? true : undefined} aria-describedby={errors.you ? "your-name-error" : undefined} />
        {errors.you && <p className="field-error" id="your-name-error">{errors.you}</p>}
      </div>
      <div className="field">
        <label htmlFor="people-input">Who else is in it?</label>
        <div className={`chips-input${errors.people ? " invalid" : ""}`} onClick={() => document.getElementById("people-input")?.focus()}>
          {people.map((p) => (
            <span className="person-chip" key={p}>
              {p}
              <button type="button" aria-label={`Remove ${p}`} onClick={() => setPeople((l) => l.filter((x) => x !== p))}><Icon name="close" size={14} /></button>
            </span>
          ))}
          <input id="people-input" value={draft} onChange={(e) => setDraft(e.target.value.replace(/^,/, ""))} onKeyDown={onKey} onBlur={() => addPeople(draft)}
            placeholder={people.length ? "Add another" : "Alex, Jo"} autoComplete="off" enterKeyHint="enter"
            aria-invalid={errors.people ? true : undefined} aria-describedby="people-hint" />
        </div>
        {errors.people
          ? <p className="field-error" id="people-hint">{errors.people}</p>
          : <small id="people-hint">Press Enter or type a comma after each name. You can add people later.</small>}
      </div>
      <div className="field">
        <label htmlFor="currency">Currency</label>
        <select id="currency" className="select" value={currency} onChange={(e) => setCurrency(e.target.value)}>
          {CURRENCIES.map((c) => <option key={c} value={c}>{c}</option>)}
        </select>
      </div>
      {errors.form && <p className="notice notice-error" role="alert">{errors.form}</p>}
      <button className="btn btn-lg btn-block" type="submit" aria-busy={busy || undefined}><span>Create group</span></button>
      <p className="hint center">Free. No sign-up. Anyone with the link can join.</p>
    </form>
  );
}

Change history

Publish quality pass from owner chat
9ed9621744 · 5 hours ago
Bind agent address in policy constants
7effc1cbbd · 5 hours ago
Quality pass: cleaner group page, phone polish, fuller API, sourced comparison
5345219781 · 5 hours ago
Bounded source sync
277fb9ad96 · 1 day ago
Bounded source sync
e944552325 · 1 day ago