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 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.
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
Bind agent address in policy constants
Quality pass: cleaner group page, phone polish, fuller API, sourced comparison
Bounded source sync
Bounded source sync