diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7de4817..f1b791b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -22,7 +22,7 @@ jobs:
- uses: oven-sh/setup-bun@v2
with:
- bun-version: 1.3.14
+ bun-version: 1.4.2
# The Firestore rules emulator (test:rules) runs on the JVM.
- uses: actions/setup-java@v6
diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml
index 33a0c05..8e884f3 100644
--- a/.github/workflows/web.yml
+++ b/.github/workflows/web.yml
@@ -59,7 +59,7 @@ jobs:
- uses: oven-sh/setup-bun@v2
with:
- bun-version: 1.3.14
+ bun-version: 1.4.2
- run: bun install --frozen-lockfile
diff --git a/CLAUDE.md b/CLAUDE.md
index 05f681e..fccc9a4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,7 +1,7 @@
# kip
Friends-only space sharing: list a spare room or your whole place, mark when it's free, and let
-mutual friends book it for nothing. Monorepo with a Next.js web client (`web/`) and shared
+mutual friends book it for nothing. Monorepo with a SvelteKit web client (`web/`) and shared
Firebase rules (`firebase/`); native mobile apps are a later phase. User-facing docs live in
[README.md](./README.md); this file is for contributors and AI sessions.
@@ -79,7 +79,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage
`friends` subcollections. Visibility checks ("can this user see this listing?") are then a
single `exists(/users/$(ownerId)/friends/$(uid))` in rules — no graph traversal, no server.
Accepting a request is one `writeBatch`: write both edges + delete the request, so we never
- persist a half-formed friendship (`utils/friends.ts` `acceptRequest`). Either party deleting
+ persist a half-formed friendship (`src/lib/friends.ts` `acceptRequest`). Either party deleting
unfriends both sides.
- **Friend-request id is `${from}_${to}`.** Deterministic, so the rule authorizing the accepter
to write into the requester's `friends` subcollection can look the request up by id, and
@@ -87,7 +87,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage
- **Public share links = capability-URL portals.** Making something public mints a `portals/{uuid}`
doc whose id IS an unguessable UUID; knowing it is the capability. World-readable BY ID only
(`get: if true`, no `list` → not enumerable), revoke = delete, regenerate = new uuid + delete old
- (kills every old link). Four scopes share one spine (`utils/portals.ts`; the fourth, **ROOM**, is described under *Rooms in a place*): **USER** (all your
+ (kills every old link). Four scopes share one spine (`src/lib/portals.ts`; the fourth, **ROOM**, is described under *Rooms in a place*): **USER** (all your
places, id in `prefs.profilePortalId`, control on your own PersonPage), **LISTING**
(`listing.publicPortalId`, control in the RoomPage owner view's Sharing section), **SLOT**
(`window.publicPortalId`, control in the per-slot Sheet on that same page).
@@ -134,7 +134,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage
`onOwner` callback that fires the moment the portal doc lands, and the page paints the host block
then, with skeletons where the rooms will be. Skeletons and not "Nothing shared here right now" —
that line is a statement about an empty link, and saying it to someone whose rooms are still in
- flight is worse than saying nothing. `layout.tsx` also preconnects the four Firebase hosts, since a
+ flight is worse than saying nothing. `src/routes/+layout.svelte` also preconnects the four Firebase hosts, since a
stranger opening a link pays DNS and TLS for each of them with nothing warmed.
Because a grant needs an identity, the share-link page signs visitors in **anonymously** on load
@@ -144,7 +144,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage
**Two rules keep that from eating a real session**, and both were learned the hard way — opening a
link you'd been sent while signed in silently replaced your account with an empty anonymous one
and then asked you to pick a display name.
- 1. `ensureAnonymous` awaits `authSettled()` (`utils/auth.ts`) before deciding. Firebase restores a
+ 1. `ensureAnonymous` awaits `authSettled()` (`src/lib/auth.ts`) before deciding. Firebase restores a
persisted session ASYNCHRONOUSLY, so `auth().currentUser` is null for a beat after load even
for someone who is signed in; reading it directly mistakes them for a stranger.
@@ -157,7 +157,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage
2. **An anonymous session is a participant, not a signed-out one.** This USED to gate on
`!user || anonymous`, on the reasoning that a ticket is not an account. It no longer holds:
an anonymous account can carry a display name, a live ask and friendships, and every rule it
- meets is blind to how it signed in. `app/page.tsx` gates on `!user` alone. What replaced the
+ meets is blind to how it signed in. `src/routes/(app)/+page.svelte` gates on `!user` alone. What replaced the
old reasoning is `displayName` — `AuthMenu` hides for an ANONYMOUS session until there is one,
because a nameless visitor has no profile to show. A nameless account with a credential keeps
the menu, since Settings and the exit live only there and the name sheet can be dismissed. The
@@ -170,17 +170,18 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage
to `authStateSubscription` inside `if (this.lastNotifiedUid !== currentUid)`. Linking an anonymous
account KEEPS the uid, so signing up from a share link fired nothing at all, and Firebase compounds
it by mutating the `User` object **in place** (`Object.assign(user, updates)` in
- `_reloadWithoutSaving`) — so even a `setUser(next)` would hand React the same reference and bail
- out of the render. The visible bug: a visitor tapped "Ask to be friends", created an account in the
+ `_reloadWithoutSaving`) — so even assigning it to the store's `user` again hands Svelte the same
+ reference, which is no change at all, and nothing reading it re-runs. The visible bug: a visitor
+ tapped "Ask to be friends", created an account in the
sheet, and the sheet stayed sitting over the button, because `identified` was still computing
`false`. Reloading the page fixed it, which is why it looked intermittent.
So the mutable fields the app branches on — `isAnonymous` and `emailVerified` — are **snapshotted
- into store state** (`anonymous`, `emailVerified`) and read from there, never off `user`. Anything
- that branches on a field Firebase can change without a uid change belongs in that pair; reading it
- off `user` at render is the bug, not the exception. Watching the token also fires on hourly
- refreshes, which is free — the `User` reference is unchanged, so `setUser` bails and no listener
- effect re-runs.
+ into store state** (`anonymous`, `emailVerified`) by the listener itself and read from there, never
+ off `user`. Anything that branches on a field Firebase can change without a uid change belongs
+ beside that pair (`email`, `phone` and `doors` already do); reading it off `kip.user` is the bug,
+ not the exception. Watching the token also fires on hourly refreshes, which is free — the `User`
+ reference and every snapshot are unchanged, so no effect re-runs.
**A tap on the share-link page is never a no-op, and that is a rule about the STATE MACHINE, not
about one bug.** Tapping holds the ask in `ask` and lets an effect send it once the sender is
@@ -224,14 +225,16 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage
removing it brings the dead button straight back with every other check still green.
`emailVerified` rides along for the same structural reason, but note what that does NOT fix:
- nothing in `web/` calls `user.reload()`, the proactive token refresh doesn't reload either, and the
- verification link opens on Firebase's own action origin, so **verifying in another tab and coming
- back still leaves the Home prompt up until a reload**. Closing that would mean reloading the auth
- user on focus or visibilitychange; the snapshot is what makes such a fix land on screen at all.
+ the store reloads the auth user on focus and visibilitychange only while the session is ANONYMOUS
+ (that is how an address attached from another browser arrives), the proactive token refresh doesn't
+ reload, and the verification link opens on Firebase's own action origin, so for an account that
+ already has a credential **verifying in another tab and coming back still leaves the Home prompt up
+ until a reload**. Closing that would mean widening that reload to unverified accounts; the snapshot
+ is what makes such a fix land on screen at all.
**A uid-keyed capability must survive the visitor getting an account, and there are two halves to
that.** Creating an account **links** the anonymous one (`linkWithPopup` / `linkWithCredential` in
- `utils/auth.ts`), so the uid doesn't change and the grant still belongs to them. But linking is
+ `src/lib/auth.ts`), so the uid doesn't change and the grant still belongs to them. But linking is
impossible when the credential already belongs to a real account — they're signing IN, not up — so
that path falls back to a normal sign-in and the uid DOES change. Hence the second half: the grant
is re-claimed at the point of use, immediately before the write that needs it. Either half alone
@@ -243,7 +246,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage
*distinct* paths its rule touches, not the number of documents returned. Two consequences:
- **Clause order matters.** `isFriendOf` and the room/profile grants — one shared lookup each,
covering a whole query — come BEFORE the per-document slot grant and guest marker.
- - **Browse is chunked at 20 friends** (`BROWSE_CHUNK`, `utils/listings.ts`), NOT the 30 an `in`
+ - **Browse is chunked at 20 friends** (`BROWSE_CHUNK`, `src/lib/listings.ts`), NOT the 30 an `in`
filter allows. Each friend's listing costs one `exists()` on their friends edge, so 25 distinct
friends in one query is refused outright — 30 places across 3 friends is fine. This was a live
bug (the chunk was 30), found by testing the limit rather than reasoning about it. 20 is the
@@ -335,7 +338,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage
60 days after checkout, the same ISO parsing as `stillCurrent`), which needs no state machine: a
pointer issued while a stay was fresh simply stops matching, with no sweep and nothing to revoke.
The store's counterpart lookup mirrors it (`endedWithin` and `STAY_SIGHT_DAYS` in
- `utils/format.ts`), in UTC like the rule rather than the local date the rest of the client uses,
+ `src/lib/format.ts`), in UTC like the rule rather than the local date the rest of the client uses,
since its whole job is predicting what the rule will allow — a pointer it can't use would leave the
name unresolved.
@@ -410,7 +413,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage
order-free by `bookingJoins`). That third one exists because a share-link guest and their host are
the one pair who demonstrably know each other and the one pair neither of the others can serve —
neither is searchable to the other, and neither holds the other's link. `${from}_${to}` is the
- right key here: one pending friendship ask per pair. `utils/requests.ts` owns it. **A route that
+ right key here: one pending friendship ask per pair. `src/lib/requests.ts` owns it. **A route that
is NAMED must hold even when another would have done**: a `portalId` or `bookingId` on the request
is checked even for a searchable recipient, since the card says "via your link" or "you stayed
together" on the strength of it.
@@ -428,7 +431,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage
only in the sender's own list, on a document only the two parties can read. Nobody to mislead.
- **Discovery is opt-in, and a handle is optional.** A fresh account is **unreachable**: onboarding
asks for a *display name only* — collected just in time by the identity sheet
- (`components/name-gate.tsx`, and the portal page's own copy), never a blocking screen — and
+ (`src/lib/components/name-gate.svelte`, and the portal page's own copy), never a blocking screen — and
nobody can initiate contact until you turn on one of **two independent avenues**
in Settings → *Who can find you*:
- **Searchable** (`users/{uid}.searchable`) — findable by handle. Requires a username, so the
@@ -472,7 +475,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage
what makes going private reversible: your name can't be released and re-squatted while you're
unsearchable, so you can flip searchability back on and still be yourself. (The Settings claim
flow confirms this before writing.) **Uniqueness is functionless:** `claimUsername`
- (`utils/username.ts`) writes the registry entry and the profile in ONE batch; a collision hits the
+ (`src/lib/username.ts`) writes the registry entry and the profile in ONE batch; a collision hits the
registry's owner-only `update` rule and is denied, taking the profile write down with it. **The
batch is required, and it is what caps an account at one handle:** the registry refuses an entry
the profile doesn't name once the commit lands (`getAfter`), and a profile's handle can only go
@@ -533,11 +536,11 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage
Opening a slot also **names it in the URL** (in place, so it adds nothing to go back through) —
tapping a request pushes the booking on top of it, and back returns to the slot still open instead
of a room that has forgotten which one it was. That is why a room's `windowId` is deliberately NOT
- part of `screenKey` in `app/page.tsx`: a sheet is an overlay on the room, not a different screen,
+ part of `screenKey` in `src/routes/(app)/+page.svelte`: a sheet is an overlay on the room, not a different screen,
and counting it scrolled the page under the sheet to the top on open and again on close. For the
- same family of reason `replaceEntry` now carries `historyScroll()` forward — a replace changes what
- an entry POINTS AT, not where the reader is standing in it, and defaulting to 0 meant every in-place
- rewrite silently forgot the position.
+ same family of reason `replaceEntry` keeps the entry's id, which is what its scroll offset is
+ remembered under — a replace changes what an entry POINTS AT, not where the reader is standing in
+ it.
**A slot holds at most one stay, and is born free.** `bookingMatchesOpenSlot` requires the slot to
be `OPEN` as well as to hold the claimed dates, at BOTH ends of a stay's life — asking and
@@ -558,7 +561,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage
direction this schema has spent several passes escaping, and would add an invisible-but-live slot
that still has to block overlaps and still backs a live share link.
- Overlapping slots are rejected in the CLIENT (`findOverlap`, `utils/listings.ts`), not the rules —
+ Overlapping slots are rejected in the CLIENT (`findOverlap`, `src/lib/listings.ts`), not the rules —
a rule can't query sibling documents, and the only person a clash hurts is the owner whose own
calendar it is, so a crafted client gains nothing by skipping the check. `end` is exclusive, so
ranges that merely touch are allowed.
@@ -621,7 +624,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage
after create: an ask is for a room, and moving the slot would move the ask. Ticking several rooms
in the add-dates sheet writes one slot per room (`addRoomWindows`, one batch); the sheet opens
with every room ticked. **A room and the whole place can't be free on the same nights, and that
- is the client's check** (`findOverlap` in `utils/rooms.ts`), for the reason every overlap is: a
+ is the client's check** (`findOverlap` in `src/lib/rooms.ts`), for the reason every overlap is: a
rule can't read sibling slots, and the only calendar it protects is the owner's own. A
whole-place range clashes with everything; a room's clashes with that room's and the whole
place's; two rooms overlap freely. Double booking is unaffected, since a slot still holds one stay.
@@ -654,7 +657,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage
cancelled as `SLOT_CANCELLED`, then the slots, their links, the room's link and photos, and last
the map entry. Search treats a room's dates as type Room and whole-place dates as the listing's
type, so a search for rooms finds a house with a free room. A booking carries no room: surfaces
- that name one read it off the slot (`use-stay-place.ts`), and notification bodies say "Back
+ that name one read it off the slot (`use-stay-place.svelte.ts`), and notification bodies say "Back
bedroom at Erik's house" when the slot and room can still be read — never the subject, which is
also the text message and has no room for an uncapped name.
- **Check-out instructions are their own documents, because they can hold a door code.** A host
@@ -679,7 +682,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage
cancel of a `CONFIRMED` stay reopens the window itself in the same batch (`BOOKED -> OPEN`,
clear `bookingId`) — a rule clause lets the booker, and only the booker, do that release. A
pending `REQUESTED` booking left the window `OPEN`, so guest cancel there just marks the booking
- `CANCELLED` (see `utils/bookings.ts`). Windows carry a free-form `details` string (not tags).
+ `CANCELLED` (see `src/lib/bookings.ts`). Windows carry a free-form `details` string (not tags).
**Taking or freeing a slot travels with the booking's own transition, in the same commit, for
guest and host alike.** A slot's status is `OPEN` or `BOOKED` and nothing else. Marking it
@@ -689,7 +692,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage
(`stayEndedWith`), unless that booking no longer exists, since then there is nothing left to end.
Otherwise the stay stays `CONFIRMED` on nights the slot is now offering to someone else.
- **Dates that have been and gone stop being availability.** A slot is live while `end >= today`,
- today being the LOCAL date (`isExpired`/`todayIso`, `utils/format.ts`) — the same boundary Trips uses to split upcoming from past, so a
+ today being the LOCAL date (`isExpired`/`todayIso`, `src/lib/format.ts`) — the same boundary Trips uses to split upcoming from past, so a
slot and a stay stop being current on the same day. Nothing else ages a window out: `status` only
ever says OPEN or BOOKED, so without this a slot from last year stayed bookable forever, offered
in Browse, on a place card, in a friend's view of a room, and through a share link. Filtered in
@@ -759,10 +762,10 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage
answers nothing, so a plain ask then goes ahead and Firestore queues it.
- **Search is client-side.** Each user only sees friends' listings (a small set), so Browse
fetches all friends' listings + windows once (`fetchFriendListings` chunks the `in` filter at
- 20 uids, `BROWSE_CHUNK` — the rules' lookup budget, see above) and filters by date/type/distance in `utils/search.ts`. This sidesteps Firestore's
+ 20 uids, `BROWSE_CHUNK` — the rules' lookup budget, see above) and filters by date/type/distance in `src/lib/search.ts`. This sidesteps Firestore's
inability to combine a geo range with a date range, and keeps `firestore.indexes.json` empty.
- **Counting a saved search costs no reads, which is the whole reason it exists.** `refreshBrowse`
- runs in the store provider on mount, unconditionally — so every screen already holds every
+ runs from an effect in the store itself, whatever screen is up — so every screen already holds every
friend's listings and windows. A saved search is just a stored `SearchCriteria`, and its count is
one `searchListings` pass over data that is already in memory. Ten of them are ten array passes,
not ten queries. The feature is not literally read-free: `watchSavedSearches` is one more listener
@@ -776,7 +779,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage
unbounded deliberately so a search past the cap is still visible and still removable.
**Two surfaces, and Browse gained no new control.** Home lists them, because that's where you see
- them without going looking. `SavedSearches` (`components/saved-searches.tsx`) is the same list
+ them without going looking. `SavedSearches` (`src/lib/components/saved-searches.svelte`) is the same list
again at the BOTTOM of the filter sheet — the place a search is composed is the place to keep one
or pick one up again. It sits below the sheet's footer on purpose: tweaking dates is the common
reason to open it, so "Show N places" must stay reachable without scrolling past a list.
@@ -818,7 +821,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage
- **Geohash for distance.** Listings store a `geohash` (via `geofire-common`) computed from
lat/lng on write; Browse ranks by `distanceBetween`. Coordinates are optional in the listing
form for now (no geocoder yet) — distance filtering is a no-op until they're filled in.
-- **Live for mine, fetched for theirs.** The store (`utils/store.tsx`) keeps live `onSnapshot`
+- **Live for mine, fetched for theirs.** The store (`src/lib/store.svelte.ts`) keeps live `onSnapshot`
listeners on everything the signed-in user owns (prefs, friends, requests, my
listings + their windows, my trips, incoming bookings) and a manual `refreshBrowse()` fetch
for friends' listings, plus `tripListings` — a by-id fetch of any listing one of your trips points
@@ -839,7 +842,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage
returning user through onboarding, whose `createProfile` write merges over the name they already
have; as a verdict it tells an ordinary successful load that the device can't reach kip. (A plain
listener swallowing it is how a share-link visitor wedged on a permanent splash in production.) So
- `classifySnapshot` (`utils/profile-gate.ts`) sorts every snapshot into an ANSWER — the profile from
+ `classifySnapshot` (`src/lib/profile-gate.ts`) sorts every snapshot into an ANSWER — the profile from
cache or server, or a server-confirmed absence — or UNPROVEN, an absence from cache, which
`watchOwnProfile` settles by asking the cache which of the two raised it. What makes one listener
sufficient is `includeMetadataChanges: true`, load-bearing twice over and probed against the SDK rather than
@@ -864,7 +867,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage
rather than the uid, since a `generation` re-attach opens a new listener for the SAME person and
the straggler carries the right uid. A late `null` read as "no profile" opened the identity sheet
over someone who already had a name; a late silence flipped the can't-reach-kip screen and logged
- a false incident. `attempt()` (`utils/profile-gate.ts`, pinned in `tests/profile-gate.test.ts`)
+ a false incident. `attempt()` (`src/lib/profile-gate.ts`, pinned in `tests/profile-gate.test.ts`)
is one counter serving both ordering and teardown, which is what lets the store set the profile
unconditionally — a second guard there would state the same contract twice and drift.
- **Recovery re-raises.** Confirming a cached absence changes no document DATA, so a plain
@@ -889,8 +892,8 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage
cache lease. Firing late or spuriously costs a no-op event: it can never shut an open gate, and a
later answer still opens a shut one.
- **The transitions are a pure reducer** — `gateStep` in `utils/profile-gate.ts`, pinned by
- `tests/profile-gate.test.ts`, the same split as `reattach.ts`: the effect needs React, Firebase
+ **The transitions are a pure reducer** — `gateStep` in `src/lib/profile-gate.ts`, pinned by
+ `tests/profile-gate.test.ts`, the same split as `reattach.ts`: the effect needs Svelte, Firebase
and a live session to exercise, the state machine needs none of them. Its invariants are each a
bug that existed or nearly did:
- **An opening belongs to a session.** A `generation` re-attach for the uid the gate is open for
@@ -900,10 +903,10 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage
until something answers; clearing it on each attach flashed error → splash → error at every
step of the 500ms/2s/6s ladder. A new uid starts clean.
- **Silence once the gate is open changes nothing** — that is the re-attach machinery's problem.
- - **Never open and unreachable at once**, which is what entitles `page.tsx` to render Unreachable
+ - **Never open and unreachable at once**, which is what entitles `+page.svelte` to render Unreachable
only behind a shut gate.
- **Friends-only, and nothing blocks on enrolment.** Nothing is public, so a visitor with no
- session at all only ever sees `components/welcome-screen.tsx` — the landing page, which is one
+ session at all only ever sees `src/lib/components/welcome-screen.svelte` — the landing page, which is one
screen, fits without scrolling at 390×844 (measured, not eyeballed), and carries no photograph,
no feature list and no second section. **The smallness is the design.** Almost everyone who
arrives was sent a link by a friend, so they are already half-invited: the page's job is to
@@ -914,7 +917,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage
It says a friend's link is how most people start, and offers ONE door for both arriving and
returning. **There is no password**, and nothing anywhere says "sign
- up". Three doors, all passwordless, all wrapped by `utils/auth.ts`: an emailed one-time link
+ up". Three doors, all passwordless, all wrapped by `src/lib/auth.ts`: an emailed one-time link
(`sendReturnLink` to come back, `sendAttachLink` to add an address to the account already
asking), a texted one-time code (US numbers only — that is the SMS region allowlist, and
`parseDestination` refuses anything else rather than letting the server bill for a refusal), and
@@ -953,20 +956,20 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage
**`email` rides across so a lost fragment fails loudly:** the page refuses a link with no address.
Left in the query, an ATTACH link would look exactly like a RETURNING one and sign someone into a
- second account instead of joining the first. `routable()` skips `/continue/` for the same family
- of reason it skips `/portal/` — a route written over either fragment destroys it.
+ second account instead of joining the first. `routable()` is false on `/continue/` for the same
+ family of reason it is false on `/portal/` — a route written over either fragment destroys it.
- **One attempt owns the outcome, and cancelling is a newer attempt rather than a teardown.**
- StrictMode remounts, the second run is correctly refused by `spent`, and a teardown that muted the
- first threw away the only answer and its timer, leaving the page on "working" for ever. Dev-only,
- so only `check:host` — which waits for the redirect instead of navigating itself — ever sees it.
+ **One attempt owns a failure or a stall, and cancelling is a newer attempt rather than a
+ teardown.** The link is opened once, on mount, since its one-time code can be spent a single time;
+ **Try again** starts a newer attempt, and an older one still in flight may no longer report a
+ failure or a stall over it.
**A success is the exception: it belongs to every attempt** (`finished`). A retry after a stall
resends a code the stalled call may yet spend, so the retry's refusal can arrive before or after
the success it lost to, and whichever succeeds wins. A RETURNING link that fails or stalls says so
and offers **Open kip** (plus **Try again** on a stall), rather than stranding someone on a page
with no way into the app.
- **The code step is six boxes over ONE input, and the count is the point.** `components/ui/code-input.tsx`
+ **The code step is six boxes over ONE input, and the count is the point.** `src/lib/components/ui/code-input.svelte`
draws six boxes wearing `Input`'s own shell and lays a single transparent `one-time-code` field
across all of them, because the OS keyboard's "From Messages: 123456" strip, paste, backspace and
select-all are things the platform already does to one field — six real inputs would mean
@@ -980,10 +983,13 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage
that says what a finished code looks like; all four surfaces gate their button on it, where each
used to ask whether `code` was non-empty and so offered to send a single digit.
- One trap worth keeping: **React's `autoFocus` focuses the node itself during commit and never goes
- through `onFocus`**, so `focused` is seeded from the prop. Left at `false` the field opened as six
+ One trap worth keeping: **the focus given on mount lands before the field's own `focus` listener
+ can be relied on to have said so**, so `focused` is seeded from the `autofocus` prop. Left at
+ `false` the field opened as six
identical empty boxes with nothing saying where the next digit lands — proved in a browser, where
- `document.activeElement` was already the input and every box still read `border-border`. A real
+ `document.activeElement` was already the input and every box still read `border-border`. The field
+ is focused by hand in an effect rather than left to the attribute, which only takes while nothing
+ else holds focus — and the code step replaces a field that did. A real
tap fires both events normally; `element.blur()` and `element.click()` from a script do not, which
is why that probe has to be a dispatched mouse event.
@@ -991,20 +997,20 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage
which was a fair trade while standing copy filled it — the phone sheet's said adding a number was
not agreeing to be texted, which the Notifications switch says where it bites. With nothing left
to stand there it spent 32px of empty sheet under the button on every render, which reads as the
- layout having broken. `Problem` stays MOUNTED and drops to zero height instead, in a gapless
+ layout having broken. The message line stays MOUNTED and drops to zero height instead, in a gapless
wrapper with the button: a live region announces a CHANGE, so one that appears together with its
text is one a screen reader never reads out.
- `app/page.tsx` gates in order: `authReady` splash → **`WelcomeScreen`** (no session at all) →
+ `src/routes/(app)/+page.svelte` gates in order: `authReady` splash → **`WelcomeScreen`** (no session at all) →
`profileReady` splash → the app. There is **no onboarding screen**: a missing display name is
- collected by the identity sheet (`components/name-gate.tsx`, and the portal page's own copy) at
+ collected by the identity sheet (`src/lib/components/name-gate.svelte`, and the portal page's own copy) at
the first action that puts your name in front of someone, so nothing blocks. A credentialed
account with no name is offered that sheet unprompted **once per account**: dismissing it sticks,
and after that only an action that needs a name brings it back. `AuthMenu` hides only for a
nameless ANONYMOUS session, and withholds the exit from an account with no credential — see the
sign-out note in the anonymous bullet.
- **Leaving is possible, it dismantles rather than departs, and the SERVER finishes it.** The client
- writes one document — `deletions/{uid}`, `utils/leave.ts` — and `onAccountDeletionRequested`
+ writes one document — `deletions/{uid}`, `src/lib/leave.ts` — and `onAccountDeletionRequested`
(`functions/src/index.ts`, `functions/src/teardown.ts`) tears the account down with the Admin SDK
in five named phases: cancel every future stay in both directions, delete the places (photos,
slots, guest pointers), unfriend both sides and delete requests both ways, then prefs, saved
@@ -1072,16 +1078,16 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage
**The phases are a progress bar, and an ETA would have to lie** — how long this takes depends on
how many stays, places and photos there are, which nothing knows without counting first. So there
- is a fixed list (`DELETION_PHASES`, `utils/types.ts`, mirrored in `teardown.ts` and pinned by
- `tests/drift.test.ts`) and `components/deletion-screen.tsx` draws a determinate bar over it. The
+ is a fixed list (`DELETION_PHASES`, `src/lib/types.ts`, mirrored in `teardown.ts` and pinned by
+ `tests/drift.test.ts`) and `src/lib/components/deletion-screen.svelte` draws a determinate bar over it. The
bar counts the wait BEFORE the first phase as a step of its own and never fills, since the phase
it names is the one still running. That field is also the only way a stuck teardown is visible at
- all. `app/page.tsx` renders this AHEAD of the profile gate — the teardown deletes the profile
+ all. `src/routes/(app)/+page.svelte` renders this AHEAD of the profile gate — the teardown deletes the profile
partway through, and the other reading of a missing profile is onboarding, which would put someone
who asked to leave in front of a form asking their name and write it back. `deletionReady` is what
stops the app being drawn in the beat before that document is answered for. It resets only when
the uid changes, never on a `generation` re-attach, for the same reason `profileReady` doesn't:
- `page.tsx` splashes while it is false.
+ `+page.svelte` splashes while it is false.
**Failure is capped and says so.** Five attempts, counted in the document before the work so a run
that takes the process with it still spends from the budget; past that the function writes `error`
@@ -1091,7 +1097,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage
**And a failure must not be a life sentence, which it was.** The document then stands forever —
nothing else removes one: no TTL, and the reaper skips an account that still has a profile — while
- `app/page.tsx` renders the deletion screen ahead of every other gate, on every device, from a
+ `src/routes/(app)/+page.svelte` renders the deletion screen ahead of every other gate, on every device, from a
screen that had no controls on it at all. The only recovery was an operator with the Admin SDK.
So the delete rule passes for the owner **when the document carries an error**, which is a
@@ -1102,7 +1108,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage
had to move with it. The store treats the document VANISHING as the teardown finishing and signs
out — correct, since the Auth account went with it — so it now only arms that reading while the
request is live: the function never deletes one it wrote an error on, so a cleared failure is
- somebody choosing to stay. And `NameGateProvider` opens its sheet for a credentialed account with
+ somebody choosing to stay. And `NameGate` opens its sheet for a credentialed account with
no display name, which is exactly what an account is once the profile phase lands — so it asked
the person who had just left what to call them, over the screen saying they were leaving, and sat
on top of these controls. It stands down for a deletion now. Both are driven by
@@ -1131,13 +1137,9 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage
THROWS past it, and the queries are single-equality with the rest filtered in memory because
`firestore.indexes.json` is empty and nothing deploys a composite index.
-- **No secrets, runs unconfigured.** The Firebase web config is inlined in `utils/firebase.ts`
- (values are public — security is in the rules). The repo currently **ships a populated
- `hafaio-kip-dev` config**, so `firebaseConfigured()` is true and the real sign-in flow runs.
- The unconfigured fallback still exists for a fresh clone with the config cleared: blank the
- `appId` and `firebaseConfigured()` returns false — `authReady` settles immediately on the
- sign-in screen and the sign-in button shows a "not set up yet" dialog — so the app still
- builds and runs before any Firebase project exists.
+- **No secrets.** The Firebase web config is inlined in `src/lib/firebase.ts`
+ (values are public — security is in the rules), and it is the `hafaio-kip-dev` config, so a
+ fresh clone runs the real sign-in flow. There is no unconfigured mode.
- **An account can hold more than the door it arrived through.** Settings → Account carries a
**How you get in** group — email, phone, Google — each set, absent or in conflict, because an
account with one credential is one lost inbox from unrecoverable and kip has no password to fall
@@ -1159,9 +1161,9 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage
**A credential that already belongs to someone else signs you INTO that account and changes the
uid** (`sameAccount: false`), which has bitten twice; all three doors route it through one alert.
- And the rows read `email`/`phone`/`doors` from **store state, never off `user` at render** —
+ And the rows read `email`/`phone`/`doors` from **store state, never off `user`** —
Firebase mutates the `User` in place, so a link changes the fields without changing the reference
- and React never re-renders. `unlink` is worse: it persists the shortened `providerData` and
+ and nothing reading them re-runs. `unlink` is worse: it persists the shortened `providerData` and
notifies nobody, so `removeDoor` ends with a `reload()` to make the row follow the server.
- **Settings is four sections, and Privacy is one question.** Account (display name, the doors above),
@@ -1169,7 +1171,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage
**Notifications**, Appearance. Discoverability and stay-visibility were separate sections until
they were read as the same question — who sees what about you — and merged.
- **Tailwind v4 semantic tokens (Terra).** Warm palette as `--color-*` in `@theme`
- (`app/globals.css`) with `.dark` overrides; components use `bg-surface`, `text-muted`,
+ (`src/app.css`) with `.dark` overrides; components use `bg-surface`, `text-muted`,
`text-accent-ink` etc. — no raw hex in markup. Terra adds: a terracotta→amber gradient exposed as
the `--gradient-accent` var + a `.bg-gradient-accent` utility (primary CTAs, avatar rings, Instant
chips, badges, the wordmark tile, ON switches); `accent-ink`/`success-ink` darker text tones;
@@ -1179,7 +1181,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage
**tonal elevation** (surfaces lighten, shadows nearly vanish) instead of shadow depth. The canvas
is a warm near-white (`#f6f1ea`) with a faint fixed sunset radial glow; `--radius-*` is bumped to
Terra's rounder scale (pills for controls/chips, `rounded-2xl`/`3xl` for surfaces).
-- **UI primitives = one source of truth for controls (`components/ui/`).** Every interactive
+- **UI primitives = one source of truth for controls (`src/lib/components/ui/`).** Every interactive
control is a pill (`rounded-full`) primitive at a single **44px (`h-11`) control height**
(thumb-friendly on mobile), so nothing looks stranded next to its neighbors: `Button` (variants
`primary` = gradient + `shadow-glow`, `secondary` = tonal accent fill, `ghost`, `danger` =
@@ -1190,50 +1192,61 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage
visible border + an accent focus ring**. `Sheet` is the shared modal surface (bottom sheet on
mobile with a drag-handle bar + `rounded-t-3xl`, centered `rounded-3xl` card on ≥sm; backdrop +
Escape dismiss, scroll-lock); the dialog renders through it. **It manages focus**: opening moves
- focus into the sheet (unless a child's `autoFocus` already has), closing restores it to whatever
+ focus into the sheet — to the child carrying `autofocus` if there is one, which the panel focuses
+ itself, since the attribute alone only takes while nothing else holds focus and the control that
+ opened the sheet does — closing restores it to whatever
opened it, and Tab is trapped inside. Open sheets form a stack, and Escape and Tab belong to the
TOP one only, since a confirm raised over a sheet is its own sheet. A button that is working swaps
- its label for a spinner through `components/ui/busy.tsx`, which keeps the label `sr-only`, or the
- button is announced with no name at all. **It renders through a PORTAL onto
- `
`, and that is load-bearing rather than tidy:** `fixed` resolves against the viewport only
+ its label for a spinner through `src/lib/components/ui/busy.svelte`, which keeps the label `sr-only`, or the
+ button is announced with no name at all. **Its panel is mounted onto
+ `` as a tree of its own** (`sheet.svelte` mounts `sheet-panel.svelte` there while open),
+ **and that is load-bearing rather than tidy:** `fixed` resolves against the viewport only
while no ancestor is a containing block, and `backdrop-filter` makes one — so the desktop `TopBar`,
which is blurred, trapped a sheet opened from `AuthMenu` inside itself and collapsed it to a
zero-height strip across the top. Any transform, filter or `will-change` would do the same, so it
belongs in the primitive rather than at each caller: a modal must not be positioned by whatever
- happens to contain the button that opened it. **Only ≥md could show it** — the mobile header carries
+ happens to contain the button that opened it. Mounted rather than moved, because a sheet that moved
+ its own node there could not be closed by its parent removing it. **Only ≥md could show it** — the mobile header carries
no blur — which is exactly why phone-width screenshots of the feedback sheet looked right while it
was broken, and why `check:feedback` measures the backdrop against the viewport at 1280 wide. `Segmented` (tonal pill track, active
= white thumb) and `Switch` (labeled track/thumb, ON = gradient) round out the set. Lists use
`Group` (a `rounded-3xl bg-surface shadow-card` with near-invisible `divide-y` — the **shadow is
the separator**, no outer border) + `Row` (`min-h-14`) + `Section`/`SectionHeading` — flat grouped
lists (iOS-Settings style), one action per surface, whole-row tap targets, never a row that clips
- at 390px. **Status is a soft tonal `Chip`** (`components/ui/chip.tsx`, replaces the old
+ at 390px. **Status is a soft tonal `Chip`** (`src/lib/components/ui/chip.svelte`, replaces the old
editorial byline): a low-contrast pill — `pending` (amber), `confirmed` (green), `open` (accent),
`booked` (dimmed neutral), `instant` (gradient fill + bolt, white text), `type` (neutral outline
for `Room`/`Flat`/`House`), `neutral` (cancelled). Low-contrast fills so a chip reads as a passive
*label*, never as a button — used for slot/booking state, the booking-detail status, and listing
- type. `CountBadge` (same file) is the gradient count pill on nav destinations. Mobile nav is a
- **floating dock** (`FloatingDock` in `nav.tsx`, `md:hidden`, inset from the edges, `rounded-3xl`,
+ type. `CountBadge` (`count-badge.svelte`) is the gradient count pill on nav destinations. Mobile nav is a
+ **floating dock** (`floating-dock.svelte`, `md:hidden`, inset from the edges, `rounded-3xl`,
translucent + `backdrop-blur`, `shadow-dock`; active tab wrapped in an accent-soft pill, badges
float over the icon); Settings lives in the `AuthMenu` profile menu. (Design was iterated by
rendering real components via a throwaway unguarded route + headless-Chrome screenshots — see the
- `verify-ui-visually` session memory.)
-- **Theming via `next-themes`.** `app/layout.tsx` wraps the app in next-themes'
- `ThemeProvider` (`attribute="class"`, `defaultTheme="system"`, `enableSystem`), so it toggles
- the `.dark` class on `` and injects a pre-paint script (no FOUC); `` carries
- `suppressHydrationWarning`. `theme-button.tsx` (a cycling system → light → dark `IconButton`,
- via `utils/theme.ts` `asThemeChoice`/`nextThemeChoice`/`themeLabel`) sits beside the avatar in
+ `verify-ui-visually` session memory.) Icons are Lucide, compiled in by `unplugin-icons`
+ (`~icons/lucide/…`) from `@iconify-json/lucide`.
+- **Theming is a class on ``, set before first paint.** The first inline script in
+ `src/app.html` reads the stored choice (`localStorage` key `theme`: system, light or dark) and sets
+ `light` or `dark` on `` before anything is drawn (no FOUC). `src/lib/theme.svelte.ts` is its
+ other half: `startTheme()`, run from an effect in `src/routes/+layout.svelte`, reads the same key
+ into `theme` (`choice`, `resolved`, `set()`) and follows the system preference and other tabs from
+ then on. `theme-button.svelte` (a cycling system → light → dark `IconButton`,
+ via `src/lib/theme.ts` `asThemeChoice`/`nextThemeChoice`/`themeLabel`) sits beside the avatar in
BOTH app headers (mobile and the desktop `TopBar`) as well as on the sign-in screen and the
public portal header. Settings keeps the Appearance `Segmented` (System/Light/Dark) too — the
- toggle is the quick reach, the segmented control is where you go to be deliberate. Each is guarded by a `mounted` flag.
-- **Terra identity.** One typeface — **Plus Jakarta Sans** (loaded via `next/font/google` in
- `app/layout.tsx` as `--font-jakarta`, wired to `--font-sans`, self-hosted into the static export)
+ toggle is the quick reach, the segmented control is where you go to be deliberate. `theme.choice` reads
+ "system" until `startTheme` has run, so the first client render agrees with the prerendered HTML.
+- **Terra identity.** One typeface — **Plus Jakarta Sans** (`@fontsource-variable/plus-jakarta-sans`,
+ imported in `src/app.css` and wired to `--font-sans`, self-hosted into the static export)
— for body AND headings; headings just heavier + tighter (`font-extrabold tracking-[-0.03em]`;
`.font-heading` is repointed to that, not a serif). Base font size **16px**; quiet section labels
are `text-sm font-semibold text-muted`; dates/counters use `tabular-nums`. The brand lockup is
- `components/wordmark.tsx` — a gradient disc holding a white "k" beside "kip" extrabold — used in
- the mobile top bar (Home), the desktop top bar, sign-in and portal; its `Mark` export is the same
- disc alone, pulsing, on the splash and the share-link page's loading state.
+ `src/lib/components/wordmark.svelte` — a gradient disc holding a white "k" beside "kip" extrabold — used in
+ the mobile top bar (Home), the desktop top bar, sign-in and portal; `mark.svelte` is the same
+ disc alone, pulsing, on the splash and the share-link page's loading state. Next in the font stack
+ is a `@font-face` of local Arial scaled to Plus Jakarta Sans's metrics (`src/app.css`): it stands in
+ until the real file arrives, so the swap doesn't move the page, and it draws the characters the
+ font has no glyph for — the arrow in "→" links.
**Every mark is a circle, and the classes now say so.** They always rendered as circles — Terra's
radii exceed half of a box this small and CSS clamps border-radius there, so `rounded-xl` on an
@@ -1247,35 +1260,50 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage
only where it earns attention (CTAs, rings, Instant, badges); elevation comes from layered soft
shadows in light and tonal lightening in dark. Chrome is borderless canvas: a **mobile top bar**
(back + screen title or wordmark + `AuthMenu`) and, on `≥md`, a **sticky desktop top app bar**
- (`TopBar` in `nav.tsx` — wordmark + inline nav pills + avatar) that **replaces the old left
+ (`top-bar.svelte` — wordmark + inline nav pills + avatar) that **replaces the old left
sidebar**; content sits in a `max-w-6xl` centered container. Direction: Airbnb-grade structure,
but a friends-first, less commercial feel — distinct from a marketplace.
-- **In-app dialogs, no browser `confirm`/`alert`.** `components/dialog.tsx` provides
- `DialogProvider` + `useDialog()` returning async `confirm()` / `alert()` (mounted in
- `layout.tsx` around the app). The async API mirrors a native action sheet/dialog, and the UI is
+- **In-app dialogs, no browser `confirm`/`alert`.** `src/lib/components/dialog.svelte` exports
+ `dialog`, whose async `confirm()` / `alert()` can be called from anywhere, and draws whichever is
+ open (``, mounted once in `src/routes/(app)/+layout.svelte`). The async API mirrors a
+ native action sheet/dialog, and the UI is
a **bottom sheet on mobile, centered card on desktop**. All destructive actions (delete
listing, unfriend, cancel slot) route through it; nothing calls `window.confirm`/`alert`. Enter
confirms only when focus is NOT on a control, since a focused button answers Enter itself —
otherwise Enter on a focused Cancel would confirm. **A failed action is always said, never only
- logged**: `useAction` runs a fire-and-forget action and turns its rejection into a "That didn't
- work" dialog, and `useFailure` does the same for handlers that hold their own busy state.
-
- `app/error.tsx` is the render-crash boundary: "This page couldn't load", with Reload and a Home
- link that is a full page load, because the app is one route and a router push to it from inside
- it would leave the boundary standing. Next's own fallback would link to `/`, which under a base
- path is outside kip altogether.
+ logged**: `runAction` runs a fire-and-forget action and turns its rejection into a "That didn't
+ work" dialog, and `reportFailure` does the same for handlers that hold their own busy state.
+
+ `src/routes/+error.svelte` is the render-crash boundary: "This page couldn't load", with Reload and a Home
+ link that is a full page load, because the app is one route and a router navigation to it from
+ inside it would leave the boundary standing. The link is built from `BASE_PATH`, since a bare `/`
+ under a base path is outside kip altogether. The same page is exported as `404.html`, which GitHub
+ Pages serves for a path that names no file; there it says the page could not be found.
- **Every screen has a URL, in the fragment.** `#/`, `#/browse`, `#/person/`,
`#/room/`, `#/room//slot/`, `#/room//edit`, `#/new-place`,
- `#/booking/` — `screenHash`/`screenForHash` in `utils/store.tsx` are an inverse pair and the
+ `#/booking/` — `screenHash`/`screenForHash` in `src/lib/screens.ts` are an inverse pair and the
round trip is lossless for every variant of the `Screen` union. `navigate` pushes a real history
- entry, `replace` replaces it, `back` calls `history.back()`, and `popstate` only ever calls
- `setStack` — never a history write, which is what stops the double-entry echo. The fragment names
+ entry, `replace` replaces it, and `back` calls `history.back()`. **Every history write goes through
+ SvelteKit's `goto(hash, { shallow, replace, state })`, never `history.pushState`**, because Kit's
+ router owns history: it numbers every entry, and one written behind its back shares a number with
+ its neighbor, which the router then refuses to traverse between. The stack, a depth and an entry id
+ ride in `page.state` (`kipStack`, `kipDepth`, `kipEntry`), which is also what hands the stack back
+ after a reload. **A traversal is adopted from `page.state`**: `popstate` only raises a flag, the
+ router delivers the state of the entry it landed on, and an effect copies the stack out of it —
+ never a history write, which is what stops the double-entry echo. Scroll offsets live in
+ `sessionStorage` keyed by entry id (`historyScroll`/`rememberScroll`), which survives a reload and
+ a forward; the listener that records them attaches when `` appears, which is only once the
+ gates have opened. The fragment names
the TOP screen only; the stack beneath it is the app's own memory, so a pasted link is seeded as
- `[home, entity]` and `back` at depth 0 replaces in place rather than leaving the site. An
+ `[home, entity]` and `back` at depth 0 replaces in place rather than leaving the site. **A fragment
+ typed into the address bar reloads the page**: the browser has already pushed an entry Kit never
+ numbered, so the page is loaded again on it and it becomes an ordinary arrival — with the depth
+ carried across in session storage, so Back still returns to the screen it was typed over. An
unparseable fragment resolves to Home and is rewritten, so a bad link never renders nothing.
Ids in the URL are fine: every one is enforced by `firestore.rules`, and the only capability-
bearing ids are portal tokens — which live on `/portal/`, whose fragment means something else
- entirely and which every history write skips by pathname — as does `/continue/`, whose fragment
+ entirely. The store routes only on `/` (`routable()` compares the route id), so it never writes
+ history there — nor on `/continue/`, whose fragment
carries the half of a sign-in link that kip adds.
- **Object-model navigation (client SPA).** The domain is four entities — Person, Room (listing),
Slot (window), Booking — and the app is a client-side nav stack in the store (`Screen` =
@@ -1285,14 +1313,14 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage
everything else transient (filters, the slot editor, add-slot, confirms) is a `Sheet` with
component-local state, deliberately NOT in the stack. Each entity has a compact card/row for
lists AND a full page; both are **state-aware** (affordances from viewer-role × state), e.g.
- `SlotRow` shows book/request/pending/booked-by-you. `page.tsx` renders the current screen inside
+ `SlotRow` shows book/request/pending/booked-by-you. `src/routes/(app)/+page.svelte` renders the current screen inside
the `max-w-6xl` container; the mobile top bar carries the back button + the screen title (the
wordmark only on Home) + the `AuthMenu`, and on `≥md` a back row sits above the content (the
desktop nav is the top app bar). Detail/list screens own their desktop layouts — Home and RoomPage
- are 2-col with a right rail / sticky panel, Browse is a card grid, the rest a centered column. The **RoomPage is the single place surface**: owner view (details + an Availability
- grouped list whose rows open a per-slot `Sheet`, a Sharing section, a Guests list, Edit-details →
+ are 2-col with a right rail / sticky panel, Browse is a card grid, the rest a centered column. The **RoomPage is the single place surface**: owner view (`room-owner-view.svelte`: details + an Availability
+ grouped list whose rows open a per-slot `Sheet`, `slot-sheet.svelte`, a Sharing section, a Guests list, Edit-details →
`listing-form`, and a quiet Delete) absorbs the old ManageListing + AvailabilityEditor; friend
- view is host-block + an Open-dates list of bookable `SlotRow`s. Browse/Home/Person list results as
+ view (`room-friend-view.svelte`) is host-block + an Open-dates list of bookable `SlotRow`s. Browse/Home/Person list results as
the compact **`PlaceCard`** (host featured on top → `PersonPage`, except `showHost={false}` on the
host's own page; the whole card taps to the room, no slot rows/buttons inside) — the old
`RoomCard`/`ListingCard` are retired. Browse's filters live in the store's `criteria` (so they
@@ -1321,12 +1349,12 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage
is for: a diagnostic is written BY a failure and is worth having from the anonymous visitor it
happened to, while this is prose somebody sits down and reads. What that costs is the visitor best
placed to report a broken share link, which is the trade the credential gate makes everywhere.
- `credentialed` (`utils/feedback.ts`) mirrors the rule so the menu row can hide rather than be
+ `credentialed` (`src/lib/feedback.ts`) mirrors the rule so the menu row can hide rather than be
offered and then denied; `tests/feedback.test.ts` pins the copy, since the two disagreeing shows
up as a missing control or an unexplained refusal and neither says which half is wrong.
**Its author never reads it back** — a report is written, not held — and the operator reads them
- in the app, on a `feedback` screen reached from the menu (`components/feedback-view.tsx`, one more
+ in the app, on a `feedback` screen reached from the menu (`src/lib/components/feedback-view.svelte`, one more
`View`, so it gets `#/feedback` for free). Fetched rather than watched: nothing writes while it is
open except the operator's own deletes, which are applied locally. Nothing edits one; the only
thing to do with a report that has been dealt with is remove it — with no confirm, deliberately,
@@ -1397,15 +1425,17 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage
- **Failures that throw nothing report themselves, to a collection nobody can read.** The failures
worth diagnosing here are silences — a wait that ended, a listener that went quiet — so there is no
exception and a stack trace would be empty. What matters is the STATE that made the decision, which
- is why `debug` carries a `detail` JSON blob (`utils/debug.ts`, `recordDebugEvent` + `clientState`)
+ is why `debug` carries a `detail` JSON blob (`src/lib/debug.ts`, `recordDebugEvent` + `clientState`)
rather than an error. Three sites write one: the portal ask when it is refused or stalls, the store
when the re-attach budget runs out, and the profile gate when it turns unreachable. The latter two
fire once per incident rather than once per retry, since the retries behind them are automatic and
say nothing new. A portal ask is a person tapping, so every attempt is its own event.
- **The guard vector is snapshotted during render, not read in the effect.** A timer fires up to ten
- seconds after it armed, and reporting the state the effect closed over would describe the moment
- the visitor tapped rather than the moment it failed.
+ **The guard vector is read when the report is made, not when the wait began.** A timer fires up to
+ ten seconds after it armed, and reporting the state captured then would describe the moment
+ the visitor tapped rather than the moment it failed. `report()` on the portal page reads every
+ field as it is called, and untracked, since one caller is an effect that must not re-run on what
+ is only being reported.
**It is client-writable and nobody can read it back, which is exactly what separates it from the
`mail` collection this schema refused.** No recipient, no delivery, nothing to read — so it can't
@@ -1474,7 +1504,7 @@ friends", PersonPage gates on `!friend`, and the portal page on its `Connect` st
not, and is how this case was found), but the rules don't forbid the write and the trigger can't
assume the client behaved.
-**A connect control has three states and a fourth for "not yet".** `Connect` (`app/portal/page.tsx`)
+**A connect control has three states and a fourth for "not yet".** `Connect` (`src/routes/(app)/portal/ask.ts`)
is `ask` | `sent` | `none` | `unknown`, and the control reports whichever it's in WHERE IT STANDS —
a `pending` Chip reading "Friend request sent" in the button's own place, the same swap a slot row
makes between Request and its Requested chip. It used to vanish instead, with a paragraph further
@@ -1521,7 +1551,7 @@ account would otherwise just silently receive nothing.
**Per-event preferences** live at `users/{uid}/settings/prefs.notify`, surfaced as a Notifications
section in Settings. Owner-private, but the sender runs as admin so it reads them regardless.
-**Every event is defined once**, in `NOTIFY_EVENTS` (`utils/types.ts`): label, description, and its
+**Every event is defined once**, in `NOTIFY_EVENTS` (`src/lib/types.ts`): label, description, and its
own default. The `NotifyPrefs` type, `DEFAULT_NOTIFY` and the Settings rows all derive from it, so
adding an event is a single edit and the three can't drift. (The function keeps its own copy of the
KEYS, being a separate package — they must stay in step, since a key that exists on only one side
@@ -1620,9 +1650,11 @@ on that job.
## About, Privacy, Terms and Help
-Four **statically exported routes** — `/about/`, `/privacy/`, `/terms/`, `/help/` — each a plain server
-component importing no store, initialising no Firebase and needing no session, so the full text sits
-in the exported HTML for `curl` with JavaScript off.
+Four **statically exported routes** — `/about/`, `/privacy/`, `/terms/`, `/help/`, under
+`src/routes/(docs)/` — each a prerendered page importing no store, initialising no Firebase and
+needing no session, so the full text sits
+in the exported HTML for `curl` with JavaScript off. That is structural: the store, the dialogs and
+the identity sheet are started by `src/routes/(app)/+layout.svelte`, which these routes sit outside.
**They cannot be fragment screens, and that is not a preference.** Twilio fetches the Privacy and
Terms URLs **server-side** during campaign registration, and a fragment never reaches the server —
@@ -1630,19 +1662,21 @@ Terms URLs **server-side** during campaign registration, and a fragment never re
the required language. It would pass the URL check and fail the content review, which is the worst
shape of failure: the thing looks configured and isn't. `web/tests` cannot catch this; the check is
grepping `web/out/privacy/index.html` for the non-sharing sentence after `bun export`.
+`src/hooks.server.ts` is what lets a whole sentence match: it turns each line break the source was
+wrapped at into the one space it renders as, while prerendering.
Neither are they BOTH — a fragment screen beside a static route is two copies of legal text that
will drift. One canonical URL each, and `screenForHash` already resolves an unknown fragment to
Home, so a stray `#/privacy` link degrades rather than breaking.
-`components/doc-page.tsx` is the shell and about a dozen element classes; deliberately not
+`src/lib/components/doc-page.svelte` is the shell, with `doc-h2`, `doc-p`, `doc-list` and `doc-card` beside it; deliberately not
`@tailwindcss/typography`, which would be a dependency to keep the tokens exact for four pages.
Body text sits on the **warm canvas, not in cards** — a card in kip means a control or a list, and a
wall of card behind two thousand words reads as a form. **Exactly two cards exist** and both earn
it: Privacy's "short version", which is the honesty gesture, and Terms' "Text messaging program",
which makes the required SMS disclosures impossible for a reviewer to miss.
-`utils/contact.ts` holds the contact address as one constant: `support@kip.hafa.cc`, which
+`src/lib/contact.ts` holds the contact address as one constant: `support@kip.hafa.cc`, which
Cloudflare Email Routing forwards to the `kip-app@googlegroups.com` Google Group. It is deliberately
NOT the sending address, which nobody reads. Two group settings are load-bearing, since forwarded
mail still arrives under the stranger's own address: **posting open to anyone on the web**, or every message from a stranger
@@ -1677,7 +1711,7 @@ stored SMS consent record references documents, and until now those links resolv
## Photos
A listing carries up to `MAX_PHOTOS` (8) entries in `photos`, each `{ id, url }`; the objects live
-in Storage at `listings/{ownerId}/{listingId}/{photoId}`. `utils/photos.ts` owns the round trip.
+in Storage at `listings/{ownerId}/{listingId}/{photoId}`. `src/lib/photos.ts` owns the round trip.
`uploadListingPhoto` **shrinks in the browser first** (canvas, 1600px max edge, JPEG q0.82), which
keeps the bucket small and, deliberately, re-encodes away EXIF: a GPS tag on a photo of someone's
home should not ride along with a share link.
@@ -1687,12 +1721,12 @@ of the function rather than an edge case. A canvas with no 2d context, and a sou
`toBlob` won't take, each used to fall back to the original file — so the two paths that delivered
an untouched photo were exactly the two that delivered its GPS tag, silently, while the privacy
page said otherwise. A refused upload is the lesser harm, and it says so in its own words: the two
-callers (`photo-strip.tsx`, `person-page.tsx`) tell a `PhotoEncodeError` from a network failure,
+callers (`photo-strip.svelte`, `profile-photo.svelte`) tell a `PhotoEncodeError` from a network failure,
because nothing about this one gets better on a retry. Proved in `check:exif`, below.
-It then mints the download URL once, at upload, and returns it to be stored. `components/photo-strip.tsx` is the editable strip (owner view of RoomPage
+It then mints the download URL once, at upload, and returns it to be stored. `src/lib/components/photo-strip.svelte` is the editable strip (owner view of RoomPage
and the listing form; drag or the per-thumbnail arrows reorder, and the first photo is the cover),
-`components/cover-photo.tsx` the read-only cover used by `PlaceCard`, the RoomPage hero and the
+`src/lib/components/cover-photo.svelte` the read-only cover used by `PlaceCard` and, through `photo-gallery.svelte`, the RoomPage hero and the
portal page.
**A new place can carry photos, because its id is minted before it is written.** `newListingId()`
@@ -1739,20 +1773,21 @@ listing could name a slot's token; nothing starts from there any more.
## Installing it
-**The worker is TypeScript**, compiled from `sw/sw.ts` to a gitignored `public/sw.js` — the source
-sits outside `public/` so none of it is served. `tsconfig.sw.json` is its own because a worker has no
+**The worker is TypeScript**, compiled from `sw/sw.ts` to a gitignored `static/sw.js`, served at
+`/sw.js` — the source
+sits outside `static/` so none of it is served. `tsconfig.sw.json` is its own because a worker has no
DOM, and `lib: ["dom"]` would type `self` as a Window and accept code that throws the moment it runs.
-**`next.config.js` runs that compile**, because it is the one file every Next command loads: wired
-into `dev`/`export` instead, a bare `next build` shipped a site whose `sw.js` 404s with nothing
-saying so. It resolves `tsc` from its own directory, not the working one, which Next does not set —
-`next build web` from the repo root died with ENOENT before Next printed anything. `build:sw` is the
+**`vite.config.ts` runs that compile**, because it is the one file every Vite command loads: wired
+into `dev`/`export` instead, a bare `vite build` would ship a site whose `sw.js` 404s with nothing
+saying so. It resolves `tsc` from its own directory, not the working one, which a caller is free to
+set to anything. `build:sw` is the
same compile by hand, for when only the worker has changed.
**Nothing with a query or a fragment on it is ever stored.** A navigation's `request.url` carries
both — measured, not assumed, and two reviews of this disagreed about it — so caching the request as
it arrives writes a portal capability and a one-time sign-in code into Cache Storage, which has no
expiry, is readable by any same-origin script, and outlives the revocation meant to kill them. The
-key is the PATH alone, forced to a trailing slash to match `trailingSlash: true` — and the response
+key is the PATH alone, forced to a trailing slash to match `trailingSlash = "always"` — and the response
is stored as a fresh `Response` with no URL on it, since a cloned response carries the URL it came
from, query and all, into Cache Storage. `check:pwa` opens `/portal/#token` and
`/continue/?oobCode=…` and asserts neither reaches a cache key.
@@ -1768,24 +1803,26 @@ mints new hashed names and nothing invalidates the old ones.
that deletes its caches — or unregisters itself — is the only remote lever there will ever be. Worth
knowing before it is needed.
-`app/manifest.ts` plus the compiled worker make kip installable, and `Pwa` (mounted in the layout)
+`src/routes/manifest.webmanifest/+server.ts` (prerendered to a file) plus the compiled worker make
+kip installable, and `Pwa` (mounted in the root layout)
registers the worker after load. Nothing is prefixed for us, so `start_url`, `scope` and every icon
-path read `NEXT_PUBLIC_BASE_PATH` themselves. The release no longer sets it — kip is served at the
+path read `VITE_BASE_PATH` themselves, through `BASE_PATH` in `src/lib/base.ts`. The release no longer sets it — kip is served at the
root of `kip.hafa.cc` — but the support stays, so building under a path is still one env var; `scope` covers the whole app so a share link opens
-inside an installed kip rather than bouncing to a tab. Icons are rendered from `app/icon.svg`, with
+inside an installed kip rather than bouncing to a tab. Icons are rendered from `static/icon.svg`, with
a separate full-bleed maskable one because Android crops the disc otherwise.
**The worker caches the SHELL and nothing else.** Firestore already persists to IndexedDB and
queues writes until it can reach the server, so the only thing between kip and working offline was
-the HTML and JS needed to start it. Content-hashed `/_next/static/**` is cache-first; documents are
+the HTML and JS needed to start it. Content-hashed `/_app/immutable/**` is cache-first; documents are
network-first so a deploy lands immediately and a cache miss only matters with no signal.
Cross-origin requests are never touched — a worker in front of the SDK's own offline machinery
-could only get in its way. It does not register in dev, where `next dev` serves modules a stale
+could only get in its way. It does not register in dev, where the dev server serves modules a stale
cache would hand back.
-**Installing is only ever a deliberate tap.** `utils/install.ts` holds Chrome's `beforeinstallprompt`
+**Installing is only ever a deliberate tap.** `src/lib/install.svelte.ts` holds Chrome's `beforeinstallprompt`
rather than letting it through, so Chrome never puts up its own banner and the only way in is the
-menu row. That row lives in `AuthMenu`, which `/portal/` and `/continue/` do not render — so a
+menu row. Chrome can fire that event before the module has loaded and does not replay it, so the
+inline script in `src/app.html` catches it first and leaves it on `window` for the module to pick up. That row lives in `AuthMenu`, which `/portal/` and `/continue/` do not render — so a
share-link visitor is never offered an install, which is right: they have not joined anything yet.
Safari fires no such event at all, so on an iPhone the row explains Share → Add to Home Screen
instead of offering a button that cannot work, and iPads are found by their Macintosh user agent
@@ -1800,7 +1837,9 @@ reaches a cache key, and then **kills the server** and checks kip still renders.
matters: the first version emulated offline through CDP, which does not apply to the fetches a
service worker makes, so it watched the shell render over the live network and credited the cache.
It passed with the entire offline fallback deleted. It does not cover a signed-in kip offline: that
-is Firestore's persistence, which needs a real session.
+is Firestore's persistence, which needs a real session. Its static server listens with a backlog of
+128, because the build boots by fetching some thirty small modules at once and the default queue of
+5 reset the connections that didn't fit.
The export it builds talks to the REAL project, so the check fails every Firebase Auth request in
the browser and asserts the share link's anonymous sign-up was attempted and blocked: without that,
@@ -1808,10 +1847,21 @@ every run left an account in production.
## Web build
+The client is SvelteKit 3 on Svelte 5 (runes enforced) and Vite 8, exported by `adapter-static`.
`cd web && bun install`, and `cd functions && npm install` once (a separate package on the Node
-runtime). `bun lint` is the gate and covers all THREE: `tsc` for the site, `tsc -p tsconfig.sw.json`
-for the service worker, `biome check`, then `tsc --noEmit -p ../functions`. `bun dev` for local dev. `bun export` runs `next build` → static
-site in `web/out/`.
+runtime). `bun lint` is the gate and covers all THREE: `svelte-kit sync` then
+`svelte-check --fail-on-warnings` for the site, `tsc -p tsconfig.sw.json`
+for the service worker, `biome check`, then `tsc --noEmit -p ../functions`. `bun dev` for local dev. `bun export` runs `vite build` → static
+site in `web/out/`, with a `404.html` for GitHub Pages.
+
+All of Kit's config is in `vite.config.ts` — Kit 3 has no `svelte.config.js` — beside the Tailwind
+and icon plugins and the worker compile (see *Installing it*). There is no `$lib` either: the alias
+is `#lib/*`, declared in `package.json`'s `imports`, and it needs the full file extension
+(`#lib/store.svelte.ts`). Every route is prerendered with a trailing slash (`src/routes/+layout.ts`),
+so anything that runs while a component initializes also runs at build with no `window`; browser
+work goes in an effect or `onMount`. `src/hooks.server.ts` runs in dev and while prerendering only —
+the release has no server — and besides unwrapping line breaks it preloads the Latin font file.
+TypeScript is held at `~6.0.3`, because TypeScript 7 breaks `svelte-kit sync` and `svelte-check`.
## Cloud Functions
@@ -1854,13 +1904,17 @@ says so on screen for exactly this reason; the symptom is otherwise indistinguis
the portal. (Its string is present in a production bundle but unreachable: `usingEmulators()` folds
to false there, so the badge cannot render.)
-`utils/firebase.ts` reads that flag and points BOTH auth and Firestore at the emulators — both or
+`src/lib/firebase.ts` reads that flag and points BOTH auth and Firestore at the emulators — both or
neither, since an emulator-issued token is scoped to the emulator's project and would be refused by
-the real database. The condition is ANDed with `NODE_ENV !== "production"`, and that half is what
-keeps it out of a shipped bundle: a `NEXT_PUBLIC_*` flag alone compiles to a runtime read, so the
+the real database. The flag is `VITE_AUTH_EMULATOR`, and the condition ANDs it with
+`import.meta.env.DEV`; that half is what
+keeps it out of a shipped bundle: the flag alone is replaced with whatever the build's environment
+held, so the
emulator's address travels into the build and only an env var stands between production and
-localhost auth. With NODE_ENV in the condition the whole thing folds to a constant and the branch
-is eliminated — verified by grepping the export, not assumed.
+localhost auth. Vite replaces `DEV` with a literal `false` in a build, so the whole thing folds to a
+constant and the branch
+is eliminated — verified by grepping the export, not assumed. The pair is written out at each site
+rather than read through `usingEmulators()`, so the fold doesn't depend on a function being inlined.
What this is for, and what it caught: the phone path's whole point is that linking preserves the
uid, and the only way to see that is to count the accounts afterwards. One account with the number
@@ -1888,11 +1942,11 @@ cd web && bun run dev:emulated # one shell, serves on 3001
bun run check:portal # another
```
-It serves on **3001**, not Next's default, so an emulated server can never quietly answer for the
+It serves on **3001**, with `--strictPort`, so an emulated server can never quietly answer for the
ordinary `bun dev` on 3000 — the two would be indistinguishable in a browser and the emulated one
has an empty database. Both the script and the check are pinned to it; `KIP_ORIGIN` still overrides.
-Run it after touching the portal page, the identity sheet, or `utils/auth.ts`. Everything this path
+Run it after touching the portal page, the identity sheet, or `src/lib/auth.ts`. Everything this path
has broken — a link that resolved for nobody, a code step that swallowed a wrong code, a returning
door that never signed anyone in — passed lint, the unit suite and the rules suite while broken,
because none of those can open a page.
@@ -1916,7 +1970,7 @@ next page load — and a fixture seeded against it belongs to nobody, which rend
will not load. Ask the emulator instead: `POST
/identitytoolkit.googleapis.com/v1/projects/demo-kip/accounts:query`.
-**And attach to the browser's exceptions from OUTSIDE the page.** `app/error.tsx` catches a render
+**And attach to the browser's exceptions from OUTSIDE the page.** `src/routes/+error.svelte` catches a render
throw and paints "This page couldn't load" over it, so an in-page `console.error` hook installed
after navigation sees nothing — the stack survives only in CDP's own `Runtime.exceptionThrown` and
`Runtime.consoleAPICalled` events. Both checks now collect those and print them before their
@@ -1948,7 +2002,7 @@ component state and a room page that has forgotten which slot was open is where
otherwise.
One thing it caught immediately, worth keeping: **a listing with no `location` crashes the room page
-outright** (`DetailBlock` reads `listing.location.label`). Unreachable in production — every write
+outright** (`room-detail.svelte` reads `listing.location.label`). Unreachable in production — every write
path sets one — so this is a note about FIXTURES, not a bug: seed the field, or spend the time
reading a stack trace for a state that cannot exist.
@@ -2060,7 +2114,7 @@ reachable with no sender, and a check that quietly skips a disabled feature is h
removed by accident — so the run asserts that the switch is off, refuses the press, writes no
consent when pressed anyway, says why on the row rather than sitting greyed out saying nothing, and
still lists the kinds a text cannot carry. Provision a number and the five states run as written.
-It reads the constant out of `utils/sms.ts` rather than being told, so the two cannot disagree.
+It reads the constant out of `src/lib/sms.ts` rather than being told, so the two cannot disagree.
It also pins that **a full code submits itself** — `check:consent` is the only check that types
one. The code goes in and nothing is clicked; the assertion is that the sheet has gone anyway.
@@ -2143,9 +2197,8 @@ If port 8080 is already held by another project's emulator, switch both `firebas
only because email-link sign-in rides on that provider; kip asks for no password anywhere.
3. **Firestore Database →** create (production mode).
4. **Project settings → Your apps → Web →** register an app; copy the config object into the
- `firebaseConfig` in `web/utils/firebase.ts` (replacing the shipped `hafaio-kip-dev` dev
- config). A blank `appId` makes `firebaseConfigured()` false and disables sign-in — the
- unconfigured fallback — so keep a real `appId` for a working build.
+ `firebaseConfig` in `web/src/lib/firebase.ts` (replacing the shipped `hafaio-kip-dev` dev
+ config).
5. **Blaze plan** — required for Cloud Functions (notification email, leaving, the reaper, the text check). Set a
Cloud Billing budget alert while you're there.
6. **Auth providers, and the one switch that must stay off.** Authentication → Sign-in method:
@@ -2196,7 +2249,7 @@ that and nothing in the repo does it. **An origin of its own, not a path under `
(Firestore's offline cache and the auth session) and service-worker scope with every other project
under `hafa.cc`, any of whose scripts could read kip's.
-`auth.kip.hafa.cc` exists because it is the app's `authDomain` (`utils/firebase.ts`): the Google
+`auth.kip.hafa.cc` exists because it is the app's `authDomain` (`src/lib/firebase.ts`): the Google
sign-in popup opens Firebase's reserved `/__/auth/*` pages on it, so the consent screen names kip's
domain instead of a `firebaseapp.com` one. **Nothing is deployed to Firebase Hosting**, and there is
no `hosting` block in `firebase.json`: Firebase serves its reserved `/__/*` pages on a connected
@@ -2264,7 +2317,7 @@ a booking it already cancelled and tells the other party their stay was called o
attempt); and a drift
check that pins the vocabulary the two packages share but can't import across: the notification
kinds, the texted subset and both channels' defaults, the two map paths, the sending number, the
-teardown phases in order, and the cancel reasons — an EXACT set match between `utils/types.ts` and
+teardown phases in order, and the cancel reasons — an EXACT set match between `src/lib/types.ts` and
`messages.ts`, plus a check that every reason `leaving.ts` writes is one the web side knows. It
matches against source with comments stripped, so a string surviving only in a comment doesn't
pass. Drift now fails CLOSED at the sender — an unknown kind reads as not wanted (see the SMS
@@ -2288,7 +2341,7 @@ release — see Notifications), and `SITE_ORIGIN` in `functions/src/index.ts` ma
where Pages actually serves.
**`hafaio-kip-dev` IS production**, despite the name — `.firebaserc` and the config in
-`web/utils/firebase.ts` both point at it, and it carries no fixture data. Standing up a separate
+`web/src/lib/firebase.ts` both point at it, and it carries no fixture data. Standing up a separate
`hafaio-kip` would mean redoing the whole one-time setup:
rules, storage, functions, secrets, WIF, authorized domains.
@@ -2304,7 +2357,7 @@ lines it returns alongside are deploys, not invocations.
## Known limitations / next steps
-- Geocoding is via OpenStreetMap Nominatim (`utils/geocode.ts`): the listing form takes an
+- Geocoding is via OpenStreetMap Nominatim (`src/lib/geocode.ts`): the listing form takes an
address and looks up lat/lng/geohash (free, key-less, low-volume). Swap for Google/Mapbox if
precision/volume demands. No autocomplete yet — it's a single lookup on "Find" or at submit.
- Friends' listings refresh on demand (`refreshBrowse`), not live. Each fetch takes a ticket and
@@ -2313,17 +2366,17 @@ lines it returns alongside are deploys, not invocations.
- **Switching accounts without signing out resets every piece of session state.** A texted code or
credential that already belongs to another account signs INTO it with no sign-out between, so the
uid changes under a live session and every reset keyed on `!user` is skipped. The store resets
- during RENDER when the uid changes, not in an effect — an effect would let that render's own
- effects act for the new uid on the old account's trips and bookings first.
+ inside the auth listener when the uid changes, in the same turn that sets `user` — effects run
+ after it, so none of them can act for the new uid on the old account's trips and bookings first.
- **A lost listener is re-attached, and giving up is said out loud.** A Firestore snapshot error is
- TERMINAL — the SDK drops that listener and never retries — so `onSnapshotError` (`utils/firebase.ts`)
+ TERMINAL — the SDK drops that listener and never retries — so `onSnapshotError` (`src/lib/firebase.ts`)
logging and returning left the screen frozen on its last snapshot, still styled as live. That is
worse than an error: nothing looks wrong, and a place you just added simply never appears. It now
also fires `onListenerLost`, and the store re-attaches every owned listener by bumping a
- `generation` counter that the three subscription effects depend on.
+ `generation` counter that the four subscription effects depend on.
- **The decision of WHEN to re-attach is a pure function** (`utils/reattach.ts`, `decideReattach`),
- split out for the same reason `messages.ts` is: the effect around it needs React, Firebase and a
+ **The decision of WHEN to re-attach is a pure function** (`src/lib/reattach.ts`, `decideReattach`),
+ split out for the same reason `messages.ts` is: the effect around it needs Svelte, Firebase and a
clock to exercise, the decision needs none of them, and `tests/reattach.test.ts` pins it. Backoff
is `REATTACH_DELAYS` (500ms / 2s / 6s) and the burst is **debounced to one re-attach** — the owned
listeners all die together whenever the token is what's refused, so nine losses must buy one retry,
@@ -2343,7 +2396,7 @@ lines it returns alongside are deploys, not invocations.
at once, and since a live timer IS the burst guard, leaving one armed would swallow the new
session's first real loss.
- **A re-attach must not reset `profileReady`** — nor `deletionReady`, which follows the same rule. `page.tsx` renders a full-screen splash whenever
+ **A re-attach must not reset `profileReady`** — nor `deletionReady`, which follows the same rule. `+page.svelte` renders a full-screen splash whenever
that is false, so resetting it mid-session blanks the whole app — unmounting an open sheet and any
half-typed form — which is the exact opposite of the invisible repair this mechanism exists to be.
Which session an opening belongs to is now `gateStep`'s job (see the store bullet) — it reopens
@@ -2577,8 +2630,7 @@ lines it returns alongside are deploys, not invocations.
nothing takes today and which is what an email-only kind will render as when one arrives.
**The whole text section is gated on `SMS_LIVE`**, which is `Boolean(SMS_FROM)` — the same
- ships-able-to-be-off shape as `smsConfigured()` on the sender and `firebaseConfigured()` on the
- client. kip has no number, so nothing can be sent, and a switch that records a TCPA consent for
+ ships-able-to-be-off shape as `smsConfigured()` on the sender. kip has no number, so nothing can be sent, and a switch that records a TCPA consent for
texts that cannot be delivered is a promise the product cannot keep. It gates three things, not
one: the master switch (`disabled`, with copy naming the reason rather than sitting greyed out
saying nothing), `texting` itself (or an account that agreed BEFORE kip lost its number renders
@@ -2592,7 +2644,7 @@ lines it returns alongside are deploys, not invocations.
**TCPA consent is collected on that switch, NOT on the sheet's number field** — the reverse of what this
note used to say, and the reason matters: consent to marketing-adjacent automated texts must be
- UNBUNDLED from anything else. The number in `reach-field.tsx` is collected to sign in, so attaching
+ UNBUNDLED from anything else. The number in `reach-field.svelte` is collected to sign in, so attaching
notification consent there bundles it with account creation, which is the specific pattern the rule
forbids, and loads friction onto the one screen — a stranger on a share link — the whole design
exists to keep frictionless. The switch carries the four required disclosures (automated texts,
@@ -2609,7 +2661,7 @@ lines it returns alongside are deploys, not invocations.
field existed), and removing the phone door stops the texts, so re-adding the same one asks
again — taking a number off the account is the plainest way there is of saying stop texting me.
The sender's check is pinned by `tests/drift.test.ts`, since nothing else can see it.
- `reach-field.tsx` carries one line for it: the code step appends "Standard message rates apply."
+ `reach-field.svelte` carries one line for it: the code step appends "Standard message rates apply."
**Changing your number re-asks in one tap; it does not transfer anything.** Bound to a number, the
consent otherwise lapses the moment the number changes — the switch goes off and stays off until
@@ -2690,7 +2742,7 @@ lines it returns alongside are deploys, not invocations.
**A check that never comes back says so, and the server outlasts the screen.** The trigger's
answer is the only thing that stops the spinner, so a single failed write left the button disabled
across reloads until some unrelated setting was changed. Two halves. `probeState`
- (`utils/sms.ts`, pinned in `tests/sms.test.ts`) derives `idle | checking | stalled | answered` from
+ (`src/lib/sms.ts`, pinned in `tests/sms.test.ts`) derives `idle | checking | stalled | answered` from
the two stamps and the clock — nothing stored, so a reload can't lose a check in flight — and
`CHECK_STALL_MS` is 30s rather than the portal ask's 10, because this wait legitimately contains a
cold start, Twilio's own 10s timeout and a second write. A stall is not terminal: a late answer
@@ -2708,7 +2760,7 @@ lines it returns alongside are deploys, not invocations.
"still blocked" line could not render at all. That line is the whole reason the check is legible,
since the note is otherwise identical before and after.
- **The number is in both packages and pinned.** `SMS_FROM` (`utils/sms.ts`) is a second copy of
+ **The number is in both packages and pinned.** `SMS_FROM` (`src/lib/sms.ts`) is a second copy of
`TWILIO_FROM`, since neither package can import from the other, and `tests/drift.test.ts` pins
them. Wrong, Settings would tell someone to text START to a phone kip has never sent from — the
message goes through, nothing changes, and nobody can see why. Both are empty until a number is
@@ -2740,7 +2792,7 @@ lines it returns alongside are deploys, not invocations.
**It ships able to be off, and the credential is never a deploy-time condition.** One gate —
`smsConfigured()`, the empty `TWILIO_ACCOUNT_SID`/key SID/`TWILIO_FROM` constants — checked before
- anything is read, written or sent, the same shape as `firebaseConfigured()` on the web side.
+ anything is read, written or sent.
The credential itself is **fetched at first use** from Secret Manager's REST API, with the runtime
service account's own metadata-server token and cached for the life of the instance. That is a
@@ -2770,7 +2822,7 @@ lines it returns alongside are deploys, not invocations.
than rejecting — so the consequence is minutes of delay, not lost messages.
**Geography is pinned in three places and they must move together**: `parseDestination`'s US-only
- refusal, `US_DIGITS` in `utils/destination.ts`, and the Firebase Auth SMS region allowlist (already
+ refusal, `US_DIGITS` in `src/lib/destination.ts`, and the Firebase Auth SMS region allowlist (already
US-only on the live project). `textIfWanted` ALSO checks the number starts with `+1` before
spending, since a number can reach an Auth account by routes the web form does not own. 10DLC is US
carrier policy, so international traffic skips TCR entirely but costs 5–20× per segment and carries
@@ -2788,7 +2840,7 @@ lines it returns alongside are deploys, not invocations.
trigger**: availability isn't urgent, a host adding a week of dates writes several windows in one
sitting, and a digest collapses that into one email and one run regardless of write volume.
- **The blocker is duplication, not cost.** `searchListings` lives in `web/utils/search.ts` and the
+ **The blocker is duplication, not cost.** `searchListings` lives in `web/src/lib/search.ts` and the
two packages can't import across each other — the same reason `NotifyKind` needs a drift test.
Reimplementing date overlap, type and `distanceBetween` in `functions/` is a far bigger drift
surface than a handful of string keys, and a divergence is user-visible in the worst way: the
diff --git a/README.md b/README.md
index 1085066..1772da9 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@ one at the point of asking.
Live at .
-Responsive web client (Next.js, static export) synced via Firestore. Native mobile apps come later.
+Responsive web client (SvelteKit, static export) synced via Firestore. Native mobile apps come later.
## Running locally
@@ -26,8 +26,7 @@ bun dev
The repo ships a populated dev Firebase config (`hafaio-kip-dev`), so `bun dev` gives you the real
sign-in flow out of the box — an emailed one-time link, a texted code, or Google. kip has no
password anywhere. To point at your own project, replace the `firebaseConfig` in
-`web/utils/firebase.ts` (see [CLAUDE.md](./CLAUDE.md) for the full setup walkthrough). Blanking its
-`appId` disables sign-in for a config-free build.
+`web/src/lib/firebase.ts` (see [CLAUDE.md](./CLAUDE.md) for the full setup walkthrough).
## License
diff --git a/functions/src/index.ts b/functions/src/index.ts
index 230ff59..b2dfc19 100644
--- a/functions/src/index.ts
+++ b/functions/src/index.ts
@@ -60,7 +60,7 @@ const SENDER = "noreply@kip.hafa.cc";
const RESEND_API_KEY = defineSecret("RESEND_API_KEY");
// Empty, so SMS is off: `smsConfigured()` is checked before anything is read,
-// written or sent, the same shape `firebaseConfigured()` has on the web side.
+// written or sent.
// Filling these in is not enough on its own — until the 10DLC campaign is
// approved every US send is blocked with 30034 and billed anyway.
//
diff --git a/web/.gitignore b/web/.gitignore
index 6153d2a..689216b 100644
--- a/web/.gitignore
+++ b/web/.gitignore
@@ -1,17 +1,7 @@
node_modules/
-.next/
+.svelte-kit/
out/
-*.tsbuildinfo
.DS_Store
-# Generated, and its content depends on which command wrote it last: `next dev`
-# points the route-types import at .next/dev, `next build` at .next. Tracking it
-# meant a permanently dirty file that a bare commit could sweep up. `tsc` passes
-# without it entirely, so it is regenerated locally and needed by nothing.
-next-env.d.ts
-# Written by `next dev`, and re-added if deleted.
-AGENTS.md
-CLAUDE.md
-
-# Compiled from sw/sw.ts (see next.config.js).
-public/sw.js
+# Compiled from sw/sw.ts (see vite.config.ts).
+static/sw.js
diff --git a/web/app/about/page.tsx b/web/app/about/page.tsx
deleted file mode 100644
index 0006fb2..0000000
--- a/web/app/about/page.tsx
+++ /dev/null
@@ -1,76 +0,0 @@
-import type { Metadata } from "next";
-import Link from "next/link";
-import type { ReactElement } from "react";
-import DocPage, { H2, link, P } from "../../components/doc-page";
-import { HAFA_URL, REPO_URL } from "../../utils/contact";
-
-export const metadata: Metadata = {
- title: "About kip",
- description: "A friends-only way to share space. No listings, no payments.",
-};
-
-export default function AboutPage(): ReactElement {
- return (
-
-
- kip is a friends-only way to share space. List a spare room — or your
- whole place while you're away — mark the dates it's free, and let
- friends stay for nothing. No public listings, no payments, no fees.
-
-
-
How it works
-
- Nothing on kip is public. There's no search page, no
- directory, no map of places. A fresh account can't be found by anyone:
- you become reachable only when you choose to — by picking a username
- friends can search for, or by sending someone a share link you can
- revoke at any time.
-
-
- Places are visible to friends only. When you list a
- room, the only people who can ever see it are the friends you've
- connected with — and, if you send one, whoever holds a share link you
- created for that room or those dates. A friend asks for dates, you say
- yes (or mark dates “instant” so friends can book them
- first-come-first-served), and that's a stay.
-
-
- Share links work before someone has an account. Send a
- link to your sister and she can see what's free immediately; she only
- needs an account at the moment she asks for something. No password
- anywhere — you get in by an emailed link, a texted code, or Google.
-
-
- No money changes hands. kip has no payments and no
- service fee.
-
-
-
Who makes it
-
- kip is a{" "}
-
- hafa
- {" "}
- project. It doesn't charge, doesn't show ads, and doesn't sell anything
- — including data. What kip knows about you and why is written down
- plainly in the{" "}
-
- Privacy Policy
-
- . If something's broken, or you just want to reach us, the{" "}
-
- Help page
- {" "}
- says how.
-
-
- The code is open source under the MIT license:{" "}
-
- github.com/hafacc/kip
-
- . Every version of the app — and of these pages — is public in that
- repository's history.
-
-
- );
-}
diff --git a/web/app/continue/page.tsx b/web/app/continue/page.tsx
deleted file mode 100644
index ed36ebe..0000000
--- a/web/app/continue/page.tsx
+++ /dev/null
@@ -1,331 +0,0 @@
-"use client";
-
-import { signInWithEmailLink } from "firebase/auth";
-import {
- type ReactElement,
- useCallback,
- useEffect,
- useRef,
- useState,
-} from "react";
-import ThemeButton from "../../components/theme-button";
-import { Mark } from "../../components/wordmark";
-import { tokenExpired } from "../../utils/auth";
-import { auth, firebaseConfig } from "../../utils/firebase";
-
-// Its own route rather than a mode of `/portal/`, because that page mints an
-// anonymous account on load — this one must not, since it has no use for a
-// session and every landing would leave a throwaway behind. A skip flag would
-// have been an invariant held by a comment.
-//
-// Two jobs behind one URL, told apart by whether an ID token rides along.
-// ATTACHING (token present) links the address to the anonymous account already
-// asking somewhere else and never signs in here — which is what makes it survive
-// a mail app's throwaway browser. RETURNING (no token) is a real sign-in on this
-// device, for someone coming back with no session at all.
-
-// One HTTPS round trip to a warm endpoint, with a person watching a page that
-// offers nothing else. Deliberately shorter than the portal's ten seconds, which
-// covers a dependency chain with the SDK's own retries behind it.
-const CONTINUE_TIMEOUT_MS = 6_000;
-
-// `connectAuthEmulator` points the SDK, and nothing points a raw fetch — so
-// attaching was the one door a local run could not open, while returning (an SDK
-// call) worked. Written out rather than read off a helper for the reason the
-// same pair is written out in `utils/firebase.ts`: Next inlines NODE_ENV, so the
-// whole ternary folds and no localhost address reaches a production bundle.
-function identityToolkit(): string {
- return process.env.NODE_ENV !== "production" &&
- process.env.NEXT_PUBLIC_AUTH_EMULATOR === "1"
- ? "http://127.0.0.1:9099/identitytoolkit.googleapis.com"
- : "https://identitytoolkit.googleapis.com";
-}
-
-type Outcome = "working" | "done" | "expired" | "stalled" | "taken" | "failed";
-
-// The one refusal that is worth naming, because it is the one nobody can fix
-// from anywhere else: kip cannot merge two accounts, and the ask this link
-// belongs to is held by the account that was asking, not by the address.
-const EMAIL_EXISTS = "EMAIL_EXISTS";
-
-// What the link carried.
-type Link = {
- // The URL as it arrived. `signInWithEmailLink` parses the code out of it
- // itself, so it has to be kept rather than rebuilt.
- href: string;
- idToken: string;
- email: string;
- oobCode: string;
- host: string | null;
-};
-
-function readLink(): Link | null {
- // kip's half in the fragment, Firebase's `oobCode` in the query — see
- // `continueUrl` in `utils/auth.ts`.
- const carried = new URLSearchParams(window.location.hash.replace(/^#/, ""));
- const query = new URLSearchParams(window.location.search);
- const email = carried.get("email");
- if (!email) {
- // No fragment. Refused rather than mistaken for a returning link, which
- // would sign them into a second account.
- return null;
- } else {
- return {
- href: window.location.href,
- idToken: carried.get("idToken") ?? "",
- email,
- oobCode: query.get("oobCode") ?? "",
- host: carried.get("host"),
- };
- }
-}
-
-export default function ContinuePage(): ReactElement {
- const [outcome, setOutcome] = useState("working");
- const [mode, setMode] = useState<"attach" | "return">("attach");
- const [host, setHost] = useState(null);
-
- // The one-time code may be spent ONCE. React's StrictMode mounts every effect
- // twice in dev, and the cleanup below only stops the first run from writing
- // state — it cannot recall a request already in flight. Without this the
- // second mount always found the code spent, so the email door was the one
- // path a local run could never verify.
- const spent = useRef(null);
- // Which attempt owns a failure or a stall. Cancelling is a newer attempt
- // starting, not a teardown: StrictMode's remount is refused by `spent`, so a
- // teardown that muted the first left the page on "working" with no answer and
- // no timer. A success belongs to every attempt — see `finished`.
- const attempt = useRef(0);
- // Set by the first attempt that succeeds, whichever it was. A retry after a
- // stall resends a code the stalled call may yet spend, so the retry's refusal
- // can arrive before, or after, the success it lost to.
- const finished = useRef(false);
- const link = useRef(null);
-
- const attach = useCallback((retrying = false): void => {
- link.current ??= readLink();
- const held = link.current;
-
- if (!held) {
- setOutcome("failed");
- return;
- }
- const { idToken, email } = held;
- setHost(held.host);
- // The discriminator between the two modes. An ID token means an anonymous
- // account is asking for this address to be attached to it; its absence means
- // someone is coming back to an account they already have, and the same call
- // signs them in instead of linking.
- const returning = !idToken;
- setMode(returning ? "return" : "attach");
-
- // Before spending the one-time code, never after: the code outlives our
- // token, so an expired open must not burn one that still works. A returning
- // link carries no token, so only the code's own lifetime applies.
- if (!returning && tokenExpired(idToken)) {
- setOutcome("expired");
- return;
- }
-
- const code = held.oobCode;
- // Only refuses the second MOUNT; a retry deliberately resends. Checked
- // before claiming an attempt, or the refused run takes ownership from the
- // one still in flight.
- if (!retrying && spent.current === code) return;
- spent.current = code;
-
- const mine = ++attempt.current;
- const live = () => mine === attempt.current;
- // A stall is only ever provisional: the call may still be in flight, and if
- // it lands the answer replaces this. Retry is offered on the timeout alone,
- // because a call that ANSWERED has already spent the one-time code — posting
- // it again would report failure for a flow that worked.
- const timer = setTimeout(() => {
- if (live() && !finished.current) setOutcome("stalled");
- }, CONTINUE_TIMEOUT_MS);
-
- // Two calls, because the modes want different things from the answer.
- // Returning needs a SESSION in this browser, and only the SDK can persist
- // one — the raw REST call hands back tokens with nowhere to put them, so the
- // page would say "you're signed in" over a browser that is not, and every
- // attempt would burn a one-time code. Attaching wants no session at all:
- // passing an idToken makes the endpoint LINK the address to that account
- // rather than mint one, and this browser is disposable.
- const work: Promise = returning
- ? signInWithEmailLink(auth(), email, held.href)
- : fetch(
- `${identityToolkit()}/v1/accounts:signInWithEmailLink?key=${firebaseConfig.apiKey}`,
- {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ email, oobCode: code, idToken }),
- },
- ).then(async (response) => {
- if (response.ok) return;
- const refusal = await response
- .json()
- .then((body) => body?.error?.message)
- .catch(() => null);
- throw new Error(refusal === EMAIL_EXISTS ? EMAIL_EXISTS : "refused");
- });
-
- work
- .then((): Outcome => "done")
- .catch(
- (error: Error): Outcome =>
- error.message === EMAIL_EXISTS ? "taken" : "failed",
- )
- .then((result) => {
- if (finished.current) return;
- if (result === "done") finished.current = true;
- else if (!live()) return;
- clearTimeout(timer);
- setOutcome(result);
- });
- }, []);
-
- useEffect(() => {
- attach();
- }, [attach]);
-
- // A new attempt is the cancellation: the old one no longer owns a failure.
- function retry(): void {
- setOutcome("working");
- attach(true);
- }
-
- // Returning ends in the app itself: the sign-in has happened and this is the
- // device they want it on, so a panel with a button on it is a step between
- // someone and the thing they asked for. Attaching stays where it is — that
- // browser is disposable and the session it belongs to is somewhere else.
- const arriving = mode === "return" && outcome === "done";
- const left = useRef(false);
- useEffect(() => {
- // StrictMode runs this twice, and the guard costs less than reasoning about
- // whether a second navigation to the same URL is harmless.
- if (!arriving || left.current) return;
- left.current = true;
- // Replace, not assign: the code in this URL has been spent, so Back must
- // not lead back to it, and the app is entered on a URL that never held it.
- window.location.replace(`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/`);
- }, [arriving]);
-
- // Empty when the address was added from inside the app rather than beside an
- // ask, where there is no request to mention at all.
- const who = host || null;
-
- return (
-
-
-
-
-
-
-
- {/* Static, and true whichever way the call goes. A script that never
- runs at all has still said the thing that matters — which a timeout
- cannot do, because a timeout is also script. It cannot name the host
- for the same reason: one exported page, and the query is unreadable
- before hydration. */}
-
- This page only finishes what the email started — nothing you've
- already done depends on it.
-
-
- {/* `arriving` holds the page on its quiet state while the browser
- leaves, rather than flashing a panel nobody is meant to read. */}
- {outcome === "working" || arriving ? null : outcome === "done" ? (
-
-
You're set
-
- {who
- ? `Your email is attached, and your request to ${who} is already on its way.`
- : "Your email is attached."}{" "}
- Your kip lives in the browser you started from — you can close
- this page and head back there.
-
-
- ) : outcome === "expired" ? (
-
-
- This link expired
-
-
- Links like this only work for about an hour. Open the link your
- friend sent you again and kip will offer a fresh one — your
- request is unaffected.
-
-
- ) : outcome === "taken" ? (
-
-
- That address already has a kip
-
-
- It belongs to another account, and kip can't combine two. Open kip
- where you already use that address, or sign back in with it from
- kip's front page. Whatever you asked for still stands where you
- asked it.
-
-
- ) : mode === "return" ? (
-
-
- {outcome === "stalled"
- ? "This page can't get through"
- : "That link didn't sign you in"}
-
-
- {outcome === "stalled"
- ? "Try again, or open kip and ask for a fresh link."
- : "It may have been used already, or be too old. Open kip and ask for a fresh one."}
-
- {outcome === "stalled"
- ? "Nothing is lost. Try again, or close this and finish from where you started."
- : "This link can't be used. Nothing is lost: whatever you asked for still stands where you asked it."}
-
- {/* Only after a timeout, where the call may never have run. A call
- that ANSWERED has spent the one-time code, so retrying it fails
- identically every press — the refusal is the answer, not a
- hiccup. */}
- {outcome === "stalled" ? (
-
- ) : null}
-
- )}
-
-
- );
-}
diff --git a/web/app/error.tsx b/web/app/error.tsx
deleted file mode 100644
index 950ff79..0000000
--- a/web/app/error.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-"use client";
-
-import { type ReactElement, useEffect } from "react";
-import Button from "../components/ui/button";
-import { Mark } from "../components/wordmark";
-
-// Without this, Next's own fallback offers a way "back" to `/`, which under a
-// base path is outside kip altogether.
-export default function ErrorPage({
- error,
-}: {
- error: Error & { digest?: string };
-}): ReactElement {
- useEffect(() => {
- console.error(error);
- }, [error]);
-
- return (
-
-
-
- This page couldn't load
-
-
- Something went wrong on our side. Reloading usually fixes it.
-
-
-
- {/* A full load, not a router push: the app is one route, so a push to
- it from inside it would leave this boundary standing. */}
-
- Home
-
-
-
- );
-}
diff --git a/web/app/help/page.tsx b/web/app/help/page.tsx
deleted file mode 100644
index f552298..0000000
--- a/web/app/help/page.tsx
+++ /dev/null
@@ -1,81 +0,0 @@
-import type { Metadata } from "next";
-import Link from "next/link";
-import type { ReactElement } from "react";
-import DocPage, { H2, List, link, P } from "../../components/doc-page";
-import { CONTACT_EMAIL, ISSUES_URL } from "../../utils/contact";
-
-export const metadata: Metadata = {
- title: "Help — kip",
- description:
- "What kip is, how to stop its texts, and where to reach a person.",
-};
-
-export default function HelpPage(): ReactElement {
- return (
-
-
Did kip text you?
-
- kip is a small, friends-only app for lending spare rooms between friends
- — no money, no public listings. It's at{" "}
-
- kip.hafa.cc
-
- .
-
-
- kip only ever texts people about their own account, and only if they
- turned texts on: someone asked to stay at a place you listed, a host
- answered your request, a confirmed stay was called off, or someone
- agreed to be friends. It never sends marketing.
-
-
- To stop the texts, reply STOP to the message. That
- works instantly, whoever you are — you don't need a kip account. (If you
- use kip, you can also turn texts off in Settings → Notifications.) If a
- text reached you and you've never used kip, the number may have
- previously belonged to someone who did; STOP ends it either way.
-
-
-
Something's broken, or you have a question
-
-
- The quickest route: tap your picture in kip, then{" "}
- Send feedback. It goes straight to us, needs no
- account anywhere else, and isn't posted publicly.
-
-
- Bugs and questions:{" "}
-
- open an issue on GitHub
- {" "}
- — public, and the fastest way to get something fixed.
-
-
- Private matters — a privacy request, a safety
- concern, anything you'd rather not post publicly: email{" "}
-
- {CONTACT_EMAIL}
-
- . It goes straight to us, not onto the public issue tracker.
-
-
-
-
The fine print
-
- What kip is:{" "}
-
- About
-
- . What it stores and why:{" "}
-
- Privacy Policy
-
- . The rules:{" "}
-
- Terms
-
- .
-
-
- );
-}
diff --git a/web/app/layout.tsx b/web/app/layout.tsx
deleted file mode 100644
index a99a5df..0000000
--- a/web/app/layout.tsx
+++ /dev/null
@@ -1,93 +0,0 @@
-import type { Metadata, Viewport } from "next";
-import { Plus_Jakarta_Sans } from "next/font/google";
-import { ThemeProvider } from "next-themes";
-import type { ReactElement, ReactNode } from "react";
-import DialogProvider from "../components/dialog";
-import EmulatorBadge from "../components/emulator-badge";
-import NameGateProvider from "../components/name-gate";
-import Pwa from "../components/pwa";
-import ThemeColor from "../components/theme-color";
-import { KipProvider } from "../utils/store";
-import "./globals.css";
-
-const jakarta = Plus_Jakarta_Sans({
- subsets: ["latin"],
- weight: ["400", "500", "600", "700", "800"],
- variable: "--font-jakarta",
- display: "swap",
-});
-
-export const metadata: Metadata = {
- title: "kip",
- description: "Share a spare room or your whole place with friends, for free.",
- // Safari reads none of the manifest for Add to Home Screen; it wants these.
- appleWebApp: { capable: true, title: "kip", statusBarStyle: "default" },
- // Setting `icons` at all stops Next adding app/icon.svg on its own, so it is
- // named here too.
- icons: {
- icon: `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/icon.svg`,
- apple: `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/apple-touch-icon.png`,
- },
-};
-
-// The first paint only, and keyed on the SYSTEM preference because static HTML
-// has nothing else to key on — `ThemeColor` corrects both to the theme actually
-// resolved as soon as it runs. Values are `--color-bg` from globals.css.
-export const viewport: Viewport = {
- themeColor: [
- { media: "(prefers-color-scheme: light)", color: "#f6f1ea" },
- { media: "(prefers-color-scheme: dark)", color: "#161009" },
- ],
-};
-
-export default function RootLayout({
- children,
-}: {
- children: ReactNode;
-}): ReactElement {
- return (
-
- {/* crossOrigin must match the SDK's CORS fetch or the preconnected
- socket isn't reused; photos are plain , so none. */}
-
-
-
-
-
-
-
-
-
- {/* Inside KipProvider: it reads the profile to know whether a name
- is needed at all. It gates ITSELF to the app's own routes —
- `/portal/` and `/continue/` carry their own sheets, and its
- auto-open would stack a second, undismissable one over them. */}
-
- {children}
-
-
-
-
-
-
-
-
- );
-}
diff --git a/web/app/manifest.ts b/web/app/manifest.ts
deleted file mode 100644
index dc148b0..0000000
--- a/web/app/manifest.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import type { MetadataRoute } from "next";
-
-// Nothing here is prefixed for us — `start_url`, `scope` and every icon path
-// are taken literally — so a build under a base path has to add it itself.
-const base = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
-
-// A static export has no request to vary on.
-export const dynamic = "force-static";
-
-// `scope` deliberately covers the whole app, so /portal/ and /continue/ open
-// inside an installed kip too — a share link handed to someone who has it
-// installed should not bounce them out to a browser tab.
-export default function manifest(): MetadataRoute.Manifest {
- return {
- name: "kip",
- short_name: "kip",
- description:
- "Share a spare room or your whole place with friends, for free.",
- start_url: `${base}/`,
- scope: `${base}/`,
- display: "standalone",
- background_color: "#f6f1ea",
- theme_color: "#f6f1ea",
- icons: [
- { src: `${base}/icon-192.png`, sizes: "192x192", type: "image/png" },
- { src: `${base}/icon-512.png`, sizes: "512x512", type: "image/png" },
- // Full-bleed, with the mark inside the safe circle: Android crops a
- // non-maskable icon to whatever shape the launcher uses, which would take
- // the edges off the disc.
- {
- src: `${base}/icon-maskable-512.png`,
- sizes: "512x512",
- type: "image/png",
- purpose: "maskable",
- },
- ],
- };
-}
diff --git a/web/app/page.tsx b/web/app/page.tsx
deleted file mode 100644
index f0b6ff1..0000000
--- a/web/app/page.tsx
+++ /dev/null
@@ -1,304 +0,0 @@
-"use client";
-
-import { type ReactElement, useEffect, useRef } from "react";
-import { LuArrowLeft } from "react-icons/lu";
-import AuthMenu from "../components/auth-menu";
-import BookingPage from "../components/booking-page";
-import BrowseView from "../components/browse-view";
-import DeletionScreen from "../components/deletion-screen";
-import FeedbackView from "../components/feedback-view";
-import FriendsPanel from "../components/friends-panel";
-import HomeView from "../components/home-view";
-import ListingFormScreen from "../components/listing-form-screen";
-import { FloatingDock, TopBar } from "../components/nav";
-import PersonPage from "../components/person-page";
-import PlacesView from "../components/places-view";
-import RoomPage from "../components/room-page";
-import SettingsView from "../components/settings-view";
-import ThemeButton from "../components/theme-button";
-import TripsView from "../components/trips-view";
-import Button from "../components/ui/button";
-import IconButton from "../components/ui/icon-button";
-import WelcomeScreen from "../components/welcome-screen";
-import Wordmark, { Mark } from "../components/wordmark";
-import { historyScroll, rememberScroll, useKip } from "../utils/store";
-import type { Screen, View } from "../utils/types";
-
-const TITLES: Record = {
- home: "Home",
- browse: "Browse",
- places: "Places",
- friends: "Friends",
- trips: "Trips",
- settings: "Settings",
- feedback: "Feedback inbox",
-};
-
-function CurrentScreen({ screen }: { screen: Screen }): ReactElement {
- switch (screen.kind) {
- case "person":
- return ;
- case "room":
- return ;
- case "booking":
- return ;
- case "listing-form":
- return ;
- case "tab":
- switch (screen.tab) {
- case "home":
- return ;
- case "browse":
- return ;
- case "places":
- return ;
- case "friends":
- return ;
- case "trips":
- return ;
- case "settings":
- return ;
- // Guessing the fragment renders an empty list rather than a leak: every
- // read behind it is refused by the rules for anyone but the operator.
- case "feedback":
- return ;
- }
- }
-}
-
-// Reload rather than a retry button: the store has already retried and given up,
-// and a reload is what re-runs auth from scratch, which covers the likeliest
-// causes. A client cannot talk a server out of a refusal, so this is disclosure.
-function StaleDataNotice(): ReactElement {
- return (
-
-
- kip stopped receiving updates, so this screen may be out of date.
-
-
-
- );
-}
-
-// In place of the splash once the profile gate has given up — the splash claims
-// something is still on its way. Deliberately BEHIND the gate: the other reading
-// of an unanswered profile is "no profile", which is onboarding and an overwrite.
-// Nothing here is terminal: a late answer opens the gate and this unmounts itself.
-function Unreachable({ canSignOut }: { canSignOut: boolean }): ReactElement {
- const { signOut } = useKip();
-
- async function doSignOut() {
- try {
- await signOut();
- } catch (error) {
- console.error(error);
- }
- }
-
- return (
-
-
- Can't reach kip right now
-
-
- Nothing is lost — this device just can't get through to kip. Check your
- connection and try again.
-
-
-
- {/* A session the server refuses outright — a disabled account, a
- revoked token — lands here too, and reloading hits the same wall.
- Withheld from a session with nothing to sign back in with: there it
- destroys the account outright, dressed as recovery advice. */}
- {canSignOut ? (
-
- ) : null}
-
- );
-}
-
-function useScreenTitle(screen: Screen): string {
- const { user, friends } = useKip();
- switch (screen.kind) {
- case "tab":
- return TITLES[screen.tab];
- case "room":
- return "Place";
- case "booking":
- return "Booking";
- case "listing-form":
- return screen.id ? "Edit place" : "Add place";
- case "person": {
- if (screen.id === user?.uid) return "You";
- const friend = friends.find((candidate) => candidate.uid === screen.id);
- return friend ? friend.displayName.split(" ")[0] : "Profile";
- }
- }
-}
-
-export default function Page(): ReactElement {
- const {
- authReady,
- listenersLost,
- user,
- anonymous,
- profileReady,
- profileUnreachable,
- deletion,
- deletionReady,
- screen,
- canGoBack,
- popped,
- back,
- setView,
- } = useKip();
- const title = useScreenTitle(screen);
- const scroller = useRef(null);
- // True when the change came from history rather than a tap.
- const restoring = useRef(false);
- const lastPopped = useRef(popped);
- if (lastPopped.current !== popped) {
- lastPopped.current = popped;
- restoring.current = true;
- }
-
- // The screen's identity, not the object, which is rebuilt on every render.
- // windowId excluded: a slot is a sheet over the room and must not reset scroll.
- const screenKey = `${screen.kind}:${"id" in screen ? screen.id : ""}:${
- screen.kind === "tab" ? screen.tab : ""
- }`;
- // Written continuously, not on the way out: a browser Back gives no chance to
- // save anything first.
- useEffect(() => {
- const element = scroller.current;
- if (!element) return;
- let queued = 0;
- const onScroll = () => {
- window.clearTimeout(queued);
- queued = window.setTimeout(
- () => rememberScroll(element.scrollTop || window.scrollY),
- 150,
- );
- };
- element.addEventListener("scroll", onScroll, { passive: true });
- window.addEventListener("scroll", onScroll, { passive: true });
- return () => {
- window.clearTimeout(queued);
- element.removeEventListener("scroll", onScroll);
- window.removeEventListener("scroll", onScroll);
- };
- }, []);
-
- // Opening a screen starts at its top; returning puts it back where you left it.
- // Instant, never smooth — an animation on something that has only just appeared
- // reads as the page moving under you. Both the scroller and `window`, since
- // which one actually scrolls depends on the viewport.
- // biome-ignore lint/correctness/useExhaustiveDependencies: the keys ARE the dependencies — the effect reads nothing from them and exists only to fire when they change.
- useEffect(() => {
- const to = restoring.current ? historyScroll() : 0;
- restoring.current = false;
- const apply = () => {
- scroller.current?.scrollTo({ top: to, behavior: "instant" });
- window.scrollTo({ top: to, behavior: "instant" });
- return (scroller.current?.scrollTop ?? window.scrollY) >= to;
- };
- if (apply() || to === 0) return;
- // You can't scroll to 900px until something is 900px tall, and a list you're
- // returning to may not have refetched yet — so retry while it fills in.
- // Landing short is the honest failure: too high, never somewhere unvisited.
- const timers = [60, 160, 320, 640].map((delay) =>
- window.setTimeout(apply, delay),
- );
- return () => {
- for (const timer of timers) window.clearTimeout(timer);
- };
- }, [screenKey, popped]);
-
- if (!authReady) return ;
- // Only a session-less visitor is turned away. An anonymous one is a
- // participant or about to be: they may hold a name, an ask, even friendships,
- // and every rule they meet is blind to how they signed in.
- if (!user) return ;
- // Ahead of the profile gate, because the teardown DELETES the profile partway
- // through and the other reading of a missing one is onboarding — which would
- // put someone who asked to leave in front of a form asking their name, and
- // write it back. `deletionReady` is what stops the app being drawn in the beat
- // before the answer arrives.
- if (deletion) return ;
- if (!profileReady || !deletionReady)
- return profileUnreachable ? (
-
- ) : (
-
- );
-
- const isHome = screen.kind === "tab" && screen.tab === "home";
-
- return (
-
- );
-}
diff --git a/web/app/portal/page.tsx b/web/app/portal/page.tsx
deleted file mode 100644
index 4eb1afb..0000000
--- a/web/app/portal/page.tsx
+++ /dev/null
@@ -1,976 +0,0 @@
-"use client";
-
-import {
- type ReactElement,
- useCallback,
- useEffect,
- useRef,
- useState,
-} from "react";
-import { FaGoogle } from "react-icons/fa";
-import { LuBed, LuMapPin } from "react-icons/lu";
-import Avatar from "../../components/avatar";
-import { PhotoGallery } from "../../components/cover-photo";
-import ReachField, {
- codeReady,
- confirmReach,
- EMPTY_REACH,
- type ReachState,
- reachError,
- sendReach,
-} from "../../components/reach-field";
-import { groupByOffer, OfferHeading } from "../../components/rooms";
-import SiteFooter from "../../components/site-footer";
-import ThemeButton from "../../components/theme-button";
-import Busy from "../../components/ui/busy";
-import Button from "../../components/ui/button";
-import Chip from "../../components/ui/chip";
-import Input from "../../components/ui/input";
-import Sheet from "../../components/ui/sheet";
-import Wordmark, { Mark } from "../../components/wordmark";
-import {
- fetchMyBookingsWith,
- requestStayViaPortal,
- SlotGone,
-} from "../../utils/bookings";
-import {
- clientState,
- type DebugDetail,
- recordDebugEvent,
-} from "../../utils/debug";
-import { auth, errorCode } from "../../utils/firebase";
-import { formatDateRange, nights } from "../../utils/format";
-import { areFriends } from "../../utils/friends";
-import {
- listingTypeIcon,
- listingTypeLabel,
- placeTypeLabel,
-} from "../../utils/listings";
-import { claimGrant, fetchPortalPage } from "../../utils/portals";
-import {
- fetchMyConnectRequest,
- sendPortalConnectRequest,
-} from "../../utils/requests";
-import { useKip } from "../../utils/store";
-import type {
- Portal,
- PortalContent,
- PortalListing,
- PortalWindow,
-} from "../../utils/types";
-import { validateDisplayName } from "../../utils/username";
-
-type LoadState = "loading" | "ready" | "missing" | "unreachable";
-
-// A read that never reached the server is not an answer about the link. Offline
-// the two are indistinguishable to the code and opposite to the reader: one says
-// their friend revoked it, the other that they are in a tunnel. Same distinction
-// the profile gate draws, for the same reason — a cached absence proves nothing.
-function unreachable(error: unknown): boolean {
- if (typeof navigator !== "undefined" && navigator.onLine === false) {
- return true;
- }
- const code = (error as { code?: string })?.code ?? "";
- return (
- code === "unavailable" ||
- code === "auth/network-request-failed" ||
- code === "deadline-exceeded"
- );
-}
-
-// Stands in for a window id when the ask carries no dates.
-const FRIEND_ONLY = "__friend__";
-
-// What the visitor tapped, held while they make an account. A null window means
-// they asked to connect rather than for specific dates.
-type Ask = { listingId: string | null; window: PortalWindow | null };
-
-// Why an ask never went out, and the advice differs for every one: a refused
-// write may mean a revoked link, a stall never reached the server, and the four
-// slot causes are the world having moved rather than anything being wrong.
-type Failure = "refused" | "stalled" | "taken" | "moved" | "removed" | "past";
-
-// Said in the visitor's terms, never the rule's. The four slot causes read as
-// news about the dates rather than as something they did wrong, because that is
-// what they are — and each names what happened, since "unavailable" would leave
-// someone wondering whether to wait or ask for something else.
-const FAILURE_COPY: Record = {
- refused: "That didn't go through — the link may have been turned off.",
- stalled:
- "Couldn't reach kip just now, so nothing was sent. Check your connection.",
- taken: "Someone else took those dates while you were deciding.",
- moved: "Those dates changed, so nothing was sent. Take another look.",
- removed: "Those dates aren't offered any more.",
- past: "Those dates have already been and gone.",
-};
-
-// The only timeout in the flow: this page is outside the app's gate, so the
-// Unreachable screen never renders here. A stall nobody else names ends here.
-const ASK_TIMEOUT_MS = 10_000;
-
-// Everything about the visitor's relationship with this host, answered in one
-// go. Dates stay a LIST rather than a flag, or one pending friend request
-// suppresses every Request button on the page — bookings have auto-ids and
-// nothing stops a visitor asking for several ranges.
-//
-// Null means not answered YET, which is why the connect control waits for it
-// rather than guessing: the three states are mutually exclusive, and the wrong
-// guess offers an ask that can only be refused.
-type Standing = {
- windowIds: readonly string[];
- confirmed: boolean;
- connectPending: boolean;
- friend: boolean;
-};
-
-// What the connect control has to say. `none` covers the host themselves and
-// anyone already connected — neither has anything left to ask for. `unknown` is
-// the lookup not having answered yet, where drawing an ask would be a guess.
-type Connect = "ask" | "sent" | "none" | "unknown";
-
-// The one screen outside the auth gate. Nothing here needs an account: browsing
-// needs no identity at all, and asking needs only a name. For a visitor with no
-// name yet every button is live from the first paint; a named one's connect
-// control waits on the standing lookup. Tapping holds the ask and asks who they
-// are in place.
-export default function PortalPage(): ReactElement {
- const {
- user,
- anonymous,
- profile,
- profileReady,
- profileUnreachable,
- ensureAnonymous,
- } = useKip();
- const [page, setPage] = useState(null);
- // The portal doc on its own, a round trip ahead of `page`. A SLOT link carries
- // its room here too, but only `page` has the DATES, and a room rendered
- // without them claims "No open dates right now" — so nothing below the host
- // block is drawn from this. Superseded the moment `page` lands.
- const [owner, setOwner] = useState(null);
- const [state, setState] = useState("loading");
- const [standing, setStanding] = useState(null);
- const [busy, setBusy] = useState(null);
- const [ask, setAsk] = useState(null);
- // Carries the ask it came from, so trying again is one tap rather than a hunt
- // back to a button that may now be scrolled off or behind the error.
- const [failed, setFailed] = useState<{ reason: Failure; ask: Ask } | null>(
- null,
- );
- // Lifted out of the form: the form unmounts the moment the profile lands, and
- // these are the two things that still have something to say afterwards.
- const [sentTo, setSentTo] = useState(null);
- const [notice, setNotice] = useState(null);
- // The rooms didn't load, but the host block did — a different failure from a
- // link that never resolved, and it must not overwrite what's already on screen.
- const [roomsFailed, setRoomsFailed] = useState(false);
- // Everything that needs the ROOMS reads `page`; everything that needs only the
- // host reads this, so the header can render while the rest is still arriving.
- const portal = page?.portal ?? owner;
-
- // Anonymous sign-in gives the live reads an identity to hang a grant on. The
- // hashchange listener matters because pasting a different link changes only the
- // fragment, which the browser treats as the same document — no reload.
- const [token, setToken] = useState("");
- // For async work that outlives a fragment change and must not land on the
- // next link's page.
- const currentToken = useRef("");
- useEffect(() => {
- const read = () => {
- currentToken.current = window.location.hash.slice(1);
- setToken(currentToken.current);
- };
- read();
- window.addEventListener("hashchange", read);
- return () => window.removeEventListener("hashchange", read);
- }, []);
-
- useEffect(() => {
- if (!token) {
- setState("missing");
- return;
- }
- let live = true;
- // Tracked here rather than off `owner`, which this closure captured as null.
- let painted = false;
- setState("loading");
- setPage(null);
- setOwner(null);
- setRoomsFailed(false);
- setStanding(null);
- // A held ask names a portal that is about to stop existing, and the send
- // effect would refuse it forever without saying so.
- setAsk(null);
- setFailed(null);
- // Handed over in flight: the portal doc needs no identity to read.
- fetchPortalPage(token, ensureAnonymous(), (found) => {
- if (!live) return;
- painted = true;
- setOwner(found);
- setState("ready");
- })
- .then((found) => {
- if (!live) return;
- setPage(found);
- setState(found ? "ready" : "missing");
- })
- .catch((error: unknown) => {
- console.error(error);
- if (!live) return;
- // Once the host block is up, the link demonstrably resolved, so
- // replacing the page with "this link isn't active" would be a lie about
- // what went wrong — and a jarring one, since they can already see it did.
- if (painted) setRoomsFailed(true);
- else setState(unreachable(error) ? "unreachable" : "missing");
- });
- return () => {
- live = false;
- };
- }, [token, ensureAnonymous]);
-
- // Reported separately, so neither ask can hide the other's affordance. Keyed
- // on the host's uid rather than on `portal`, which is now a DIFFERENT object
- // each time it improves — first the portal doc, then the fuller one — and
- // would otherwise run this, and its three reads, twice on every load.
- const hostId = portal?.ownerId ?? null;
- useEffect(() => {
- if (!user || !hostId) return;
- let live = true;
- Promise.all([
- fetchMyBookingsWith(user.uid, hostId),
- fetchMyConnectRequest(user.uid, hostId),
- areFriends(user.uid, hostId),
- ])
- .then(([bookings, request, friend]) => {
- if (!live) return;
- const current = bookings.filter(
- (booking) => booking.status !== "CANCELLED",
- );
- setStanding({
- windowIds: current.map((booking) => booking.windowId),
- confirmed: current.some((booking) => booking.status === "CONFIRMED"),
- connectPending: request !== null,
- friend,
- });
- })
- // Offer the ask rather than wait for ever: a redundant ask is refused
- // or harmless, while `unknown` has no control on it at all.
- .catch((error: unknown) => {
- console.error(error);
- if (!live) return;
- setStanding({
- windowIds: [],
- confirmed: false,
- connectPending: false,
- friend: false,
- });
- });
- // Runs for an anonymous visitor too, and must: they are the ones who ask.
- // Someone returning to their own pending ask hours later is signed in as the
- // same account (Firebase persists to IndexedDB), so this is what recognises
- // them instead of offering an ask they already made.
- return () => {
- live = false;
- };
- }, [user, hostId]);
-
- // Written during render so a failure reports the state it actually failed in,
- // rather than whatever the effect closed over when it armed — for the timer
- // that is up to ten seconds of drift. Same shape as `lastPopped` in page.tsx.
- const vector = useRef({});
- vector.current = {
- anonymous,
- profileReady,
- profileUnreachable,
- hasProfile: profile !== null,
- hasName: Boolean(profile?.displayName),
- hostLoaded: portal !== null,
- roomsLoaded: page !== null,
- };
- // These failures throw nothing, so the state that decided them is the whole
- // report. Stable, so effects can call it without re-running on every render.
- const report = useCallback((reason: Failure, extra: DebugDetail = {}) => {
- recordDebugEvent("portal-ask", {
- reason,
- ...vector.current,
- ...clientState(),
- ...extra,
- });
- }, []);
-
- // Cleared first, so a later dependency change can't fire a second request. The
- // name comes off the kip profile, never `user.email` — that would write their
- // address into a document the host can read.
- useEffect(() => {
- if (!ask || !portal || !user || !profileReady || !profile?.displayName) {
- return;
- }
- const { listingId, window: slot } = ask;
- setAsk(null);
- setBusy(slot?.id ?? FRIEND_ONLY);
- setFailed(null);
- const sender = {
- uid: user.uid,
- username: profile.username,
- displayName: profile.displayName,
- photoURL: profile.photoURL,
- };
-
- // Re-claimed as whoever we are NOW: signing in to an existing account mints
- // a fresh uid, orphaning the grant written on load.
- claimGrant(portal.id, user.uid)
- // Asking for dates IS a booking — the same document a friend creates.
- .then(() =>
- listingId && slot
- ? requestStayViaPortal(
- sender.uid,
- portal.ownerId,
- listingId,
- slot,
- // The rules check a room link on its own, and only when told.
- portal.scope === "ROOM" ? "ROOM" : null,
- )
- : sendPortalConnectRequest(portal, sender),
- )
- // Never confirmed: a link is not friendship, whatever the slot allows.
- .then(() =>
- setStanding((current) => ({
- windowIds: slot
- ? [...(current?.windowIds ?? []), slot.id]
- : (current?.windowIds ?? []),
- confirmed: current?.confirmed ?? false,
- connectPending: slot ? (current?.connectPending ?? false) : true,
- // Asking is only ever offered to someone who isn't one yet, so this
- // settles `unknown` for a visitor whose lookup never ran at all.
- friend: current?.friend ?? false,
- })),
- )
- .catch((error: unknown) => {
- console.error(error);
- // A slot that moved is not a refusal — it is news, and the visitor is
- // owed which news. Everything else reaching here really was refused.
- const gone = error instanceof SlotGone ? error.why : null;
- report(gone ?? "refused", { code: errorCode(error) });
- setFailed({
- reason: gone ?? "refused",
- ask: { listingId, window: slot },
- });
- // The dates on screen are the ones that just proved stale.
- if (gone) {
- fetchPortalPage(portal.id, ensureAnonymous())
- .then((found) => {
- if (found && found.portal.id === currentToken.current) {
- setPage(found);
- }
- })
- .catch((refetch: unknown) => console.error(refetch));
- }
- })
- .finally(() => setBusy(null));
- }, [ask, portal, user, profileReady, profile, report, ensureAnonymous]);
-
- // A name is what an ask needs — not an account. Someone who has never typed
- // one has nothing to look up either, so their ask is live from the first
- // paint, which is the visitor this whole page exists for.
- const named = Boolean(profile?.displayName);
- const isOwner = Boolean(user) && user?.uid === portal?.ownerId;
- const connect: Connect = isOwner
- ? "none"
- : !named
- ? "ask"
- : standing === null
- ? "unknown"
- : standing.friend
- ? "none"
- : standing.connectPending
- ? "sent"
- : "ask";
- const needsName = ask !== null && profileReady && !named;
- // A held ask spins the control it came from, so a tap is never a no-op. The
- // sheet covers "no name"; it does not cover the gap between the page loading
- // and the profile answering, where a tap used to show nothing at all.
- const holding = ask === null ? busy : (ask.window?.id ?? FRIEND_ONLY);
-
- // An ask held with NEITHER sheet up is waiting on the profile. Deliberately
- // not extended to the send: that write is Firestore's, which queues offline
- // and lands on reconnect.
- useEffect(() => {
- if (ask === null || needsName) return;
- const give = (): void => {
- report("stalled");
- setAsk(null);
- setFailed({ reason: "stalled", ask });
- };
- if (profileUnreachable) {
- give();
- return;
- }
- const timer = setTimeout(give, ASK_TIMEOUT_MS);
- return () => clearTimeout(timer);
- }, [ask, needsName, profileUnreachable, report]);
-
- return (
-
-
- {/* An anchor, not a router push — this page sits outside the nav stack. */}
-
-
-
-
-
-
-
-
-
- {state === "loading" ? (
- // The same mark the app boots behind.
-
-
-
- ) : state === "unreachable" ? (
-
-
- Can't reach kip right now
-
-
- The link is probably fine — kip just can't check it from here. Try
- again once you're back online.
-
-
- ) : state === "missing" ? (
-
-
- This link isn't active
-
-
- It may have been turned off or regenerated. Ask whoever shared it
- for a fresh link.
-
-
- ) : portal ? (
- {
- if (!failed) return;
- setNotice(null);
- setAsk(failed.ask);
- setFailed(null);
- }}
- onAsk={(listingId, slot) => {
- // Both clear: a fresh ask must not sit under the explanation of
- // why the last one didn't go.
- setNotice(null);
- setFailed(null);
- setAsk({ listingId, window: slot });
- }}
- />
- ) : null}
-
-
-
-
- {/* Closing it abandons the held ask, which is the only way out and needs
- no separate control. */}
- {/* Held open past the profile write when an address was given: writing it
- flips `named` and the ask flies, but a one-time send has no other
- guard against a typo than the address read back. */}
- {
- setAsk(null);
- setSentTo(null);
- }}
- title={sentTo ? "Check your email" : "What should we call you?"}
- >
- {sentTo ? (
-
-
- Your request is already on its way. Open the link at {sentTo} and
- your kip will work on any device — nothing here is waiting on it.
-
- );
-}
-
-// The host has to see a name rather than a blank. No handle, and no account —
-// the ask goes out on this alone, and keeping it is a separate offer made after.
-function NameForm({
- host,
- onSent,
- onAbandon,
-}: {
- host: string | null;
- onSent: (email: string) => void;
- // Drops the held ask AND carries the reason up. Needed when the visitor turns
- // out to already have an account: the send effect would otherwise lodge the
- // request from an identity they never asked as — and dropping it unmounts
- // this form, so anything said down here would never be read.
- onAbandon: (why: string) => void;
-}): ReactElement {
- const { user, signIn, completeOnboarding } = useKip();
- const [name, setName] = useState(user?.displayName ?? "");
- const [reach, setReach] = useState(EMPTY_REACH);
- const recaptcha = useRef(null);
- const [busy, setBusy] = useState(false);
- const [error, setError] = useState(null);
-
- const invalid = name ? validateDisplayName(name) : null;
- const reachInvalid = reachError(reach.raw);
- const problem = invalid ?? reachInvalid ?? error;
-
- // The one path that identifies you BEFORE the ask, and the only one that needs
- // no name typed: Google already knows it. The other two send on the name in the
- // field and prove the address or number afterwards.
- async function googleAsk(): Promise {
- setBusy(true);
- setError(null);
- try {
- const { sameAccount } = await signIn();
- // Landing in an account they already had: it has its own name and photo,
- // which must not be overwritten, and the ask must not fly from it as a
- // side effect of adding a way to be reached.
- if (!sameAccount) {
- onAbandon(
- "That Google account is already on kip — you're in it now. Ask again and it'll go from this account.",
- );
- return;
- }
- const known = auth().currentUser?.displayName?.trim();
- if (!known) {
- // Nothing to write, so fall back to the field rather than sending an ask
- // that would reach the host as a blank.
- setError("Google didn't share a name. Type one.");
- return;
- }
- await completeOnboarding(known);
- } catch (caught) {
- console.error(caught);
- setError("That didn't work. Try again, or use email.");
- } finally {
- setBusy(false);
- }
- }
-
- async function submit(): Promise {
- if (validateDisplayName(name) || reachInvalid) return;
- setBusy(true);
- setError(null);
- let emailed: string | null = null;
- // Two failures, two remedies: a bad destination is corrected here, a failed
- // write is retried. One catch blamed the destination for both.
- if (reach.pending) {
- try {
- const { sameAccount } = await confirmReach(reach.pending, reach.code);
- // That number already had a kip account, so they are in it now — with
- // its own name and photo, which the sheet's must not overwrite. The ask
- // stays with the account that made it; only that browser can act on it.
- if (!sameAccount) {
- // Dropping the held ask is what makes this true: otherwise the send
- // effect fires the moment the new account's profile lands, lodging
- // the request from an identity they never asked as. The message goes
- // UP, because dropping the ask unmounts this form.
- setBusy(false);
- onAbandon(
- "That number is already on kip — you're in it now. Ask again and it'll go from this account.",
- );
- return;
- }
- } catch (caught) {
- console.error(caught);
- setError("Wrong code. Check it, or ask for another.");
- setBusy(false);
- return;
- }
- } else if (reach.raw) {
- try {
- // Sent FIRST, and an emailed address handed UP before the profile write,
- // which unmounts this form the moment it lands.
- const holder = recaptcha.current;
- if (!holder) throw new Error("no element for the check to bind to");
- const sent = await sendReach(reach.raw, host ?? "your host", holder);
- if (sent.pending) {
- // A code is on its way; the ask waits for it to be typed.
- setReach({ ...reach, pending: sent.pending, sentTo: sent.sentTo });
- setBusy(false);
- return;
- }
- // NOT handed up yet: doing so swaps the sheet's body and unmounts this
- // form, so a profile write that then failed would have nowhere to say
- // so — under a line claiming the request was already on its way.
- emailed = sent.sentTo;
- } catch (caught) {
- console.error(caught);
- // The profile is unwritten, so the ask has not gone: they can correct
- // it or clear it and send without one.
- setError("Couldn't send that. Check it, or clear it.");
- setBusy(false);
- return;
- }
- }
-
- try {
- await completeOnboarding(name.trim());
- } catch (caught) {
- console.error(caught);
- setError("Couldn't save that. Check your connection.");
- setBusy(false);
- return;
- }
- setBusy(false);
- if (emailed) onSent(emailed);
- }
-
- return (
-
- );
-}
-
-function PortalView({
- portal,
- windows,
- roomsPending,
- roomsFailed,
- isOwner,
- connect,
- standing,
- busy,
- notice,
- failed,
- onRetry,
- onAsk,
-}: {
- portal: Portal;
- windows: Readonly>;
- // True while only the portal doc has arrived, so the host block is all that
- // can be drawn honestly.
- roomsPending: boolean;
- roomsFailed: boolean;
- // Why an ask was dropped rather than sent — the visitor turned out to have an
- // account already. Lives up here because dropping the ask unmounts the sheet
- // that would otherwise have said so.
- notice: string | null;
- failed: Failure | null;
- isOwner: boolean;
- connect: Connect;
- standing: Standing | null;
- busy: string | null;
- onRetry: () => void;
- onAsk: (listingId: string | null, window: PortalWindow | null) => void;
-}): ReactElement {
- const firstName = portal.ownerName.split(" ")[0] || portal.ownerName;
-
- return (
-
-
-
-
-
Shared by
-
{portal.ownerName}
-
-
-
- {roomsFailed ? (
-
- Couldn't load what's shared here. Check your connection and try again.
-
- ) : roomsPending ? (
- // "Nothing shared here right now" is a statement about an empty link,
- // and saying it to someone whose rooms are in flight is worse than
- // saying nothing at all.
-
-
-
-
- ) : portal.listings.length === 0 ? (
-
Nothing shared here right now.
- ) : (
- portal.listings.map((listing) => (
-
- ))
- )}
-
- {/* The only route when a link has nothing free on it, and it reports its
- own state where it stands rather than vanishing — the same swap a slot
- row makes between Request and a Requested chip. Nothing at all once
- they're connected: a link is an ordinary way to reach a friend's
- places, so there is simply nothing left to ask for. */}
- {connect === "sent" ? (
-
- Friend request sent
-
- ) : connect === "ask" ? (
-
- ) : null}
-
- {/* Dates only — the connect chip above says its own piece, and this line
- used to speak for both without naming which. */}
- {standing && standing.windowIds.length > 0 ? (
-
- {standing.confirmed
- ? `${firstName} confirmed your dates — they're yours.`
- : `Sent — ${firstName} will get back to you.`}
-
- ) : null}
-
- {notice ? (
-
{notice}
- ) : null}
-
- {/* Without this the button just reappears and a failure is invisible. The
- retry re-sends the ask that failed, so nothing has to be found again. */}
- {failed ? (
-
-
- {FAILURE_COPY[failed]}
-
- {/* Retrying a slot that has gone would only fail again the same way,
- so the four of those offer nothing and say so instead. */}
- {failed === "refused" || failed === "stalled" ? (
-
- ) : null}
-
- ) : null}
-
- );
-}
-
-function ListingBlock({
- listing,
- roomLink,
- windows,
- canAsk,
- requestedWindowIds,
- busy,
- onAsk,
-}: {
- listing: PortalListing;
- // A link to one room: the card is that room, and the place is only where it is.
- roomLink: boolean;
- windows: readonly PortalWindow[];
- canAsk: boolean;
- requestedWindowIds: readonly string[];
- busy: string | null;
- onAsk: (listingId: string | null, window: PortalWindow | null) => void;
-}): ReactElement {
- const only = roomLink ? (listing.rooms[0] ?? null) : null;
- const TypeIcon = only ? LuBed : listingTypeIcon(listing.type);
- const note = only ? only.note : listing.description;
- // Under a room link every date is that room's, so there is nothing to group.
- const groups =
- !only && listing.rooms.length > 0
- ? groupByOffer(listing.rooms, windows)
- : null;
-
- const dates = (shown: readonly PortalWindow[]): ReactElement => (
-
- {/* A range the visitor holds is listed only because it's theirs, so a
- stranger's grey chip would tell them nothing. */}
- {window.bookedByMe ? (
- Booked by you
- ) : window.booked ? (
- Booked
- ) : requestedWindowIds.includes(window.id) ? (
- Requested
- ) : canAsk ? (
-
- ) : null}
-
- ))}
-
- );
-
- return (
-
- {/* The token in each URL is what opens the object, so a link-holder
- browses the same photos a friend would. */}
-
-
-
- {only
- ? listingTypeLabel("ROOM")
- : // A slot link carries only the room its dates are in, so a count
- // off it would say "1 room" of a house that has three.
- placeTypeLabel(
- listing.type,
- listing.windowIds ? 0 : listing.rooms.length,
- )}
-
-
- );
-}
diff --git a/web/app/privacy/page.tsx b/web/app/privacy/page.tsx
deleted file mode 100644
index 55840cd..0000000
--- a/web/app/privacy/page.tsx
+++ /dev/null
@@ -1,339 +0,0 @@
-import type { Metadata } from "next";
-import Link from "next/link";
-import type { ReactElement } from "react";
-import DocPage, { Card, H2, List, link, P } from "../../components/doc-page";
-import {
- CONTACT_EMAIL,
- HAFA_URL,
- ISSUES_URL,
- REPO_URL,
-} from "../../utils/contact";
-
-export const metadata: Metadata = {
- title: "Privacy Policy — kip",
- description:
- "What kip stores, what it deliberately doesn't, and who sees it.",
-};
-
-const UPDATED = "August 23, 2026";
-
-export default function PrivacyPage(): ReactElement {
- return (
-
-
- kip is a friends-only space-sharing app, made and run by{" "}
-
- hafa
-
- . kip collects the minimum it needs to work, shows it only to the people
- you choose, and sells nothing to anyone.
-
-
-
-
The short version
-
-
- Nothing on kip is public. Nobody can find you, or anything you list,
- unless you choose to be found.
-
-
- Your email address and phone number live on your sign-in account,
- not in kip's database. The one exception is the number you agree to
- be texted at, which is kept as part of your consent record.
-
-
- kip has no ads and no analytics, and does not sell or share your
- data for marketing — including your phone number and your consent to
- be texted. The one tracker it loads is Google reCAPTCHA, only on the
- phone door, because signing in by text needs it.
-
-
Leaving kip deletes your account and your data.
-
-
-
-
What kip stores
-
To work at all, kip keeps:
-
-
- Your profile — a display name you choose, an optional
- username, an optional photo.
-
-
- Your places — titles, descriptions, photos, the dates
- they're free, a location if you add one, and any check-out
- instructions you write. You choose how precise that location is; it's
- shown only to people who can see the place. Check-out instructions are
- shown only to guests with a confirmed stay.
-
-
- Your friendships and requests — who you're connected
- to, and pending asks in either direction.
-
-
- Your stays — requests, confirmations, and
- cancellations, with their dates.
-
-
- Your settings — notification preferences, saved
- searches, and privacy switches. A saved search keeps whatever you
- searched by, which can include a place's coordinates — and if you used
- “Use my location”, those are your device's, as your browser reported
- them. kip asks for that only when you press it, and only Browse ever
- asks.
-
-
-
- All of it lives in Google Firebase (Firestore and Cloud Storage), which
- is kip's database and file store.
-
-
-
What kip deliberately does not store
-
-
-
- Your email address and phone number are not in the database.
- {" "}
- They exist only on your Firebase sign-in account, where they're used
- to let you back in. When kip sends you a notification, it reads the
- address off that account for that one send and puts it in no database
- — the delivery log described below is the one place it is written
- down. One exception, and it exists to protect you: turning on texts
- stores the number you agreed to be texted at, because a consent record
- that doesn't say which phone it was given for can't stop kip texting a
- different one.
-
-
-
- No analytics, no ad tech, no tracking pixels, no cookies of kip's
- own.
- {" "}
- What kip keeps in your browser's own storage is for you, not about
- you: your signed-in session, your light/dark choice, a copy of what
- you've been shown — your profile, friends, places and stays, and the
- places and people kip drew for you — and a copy of the app itself, so
- it opens without waiting for the network and keeps working when you
- have none. Clearing your browser's data for kip removes all of it.
-
-
- Photo location data is removed. Photos you upload are
- re-encoded in your browser before they're stored, which strips
- metadata — including any GPS position your camera embedded. A photo of
- your home shouldn't carry its coordinates.
-
-
-
-
Who can see what
-
-
- Discovery is off until you turn it on. A new account
- is unreachable. You can opt in to being found by username, or create a
- share link — and turn either off again.
-
-
- Share links are unguessable URLs. Knowing the link is
- what grants access; anyone you send one to can see what it covers.
- Revoking or regenerating a link cuts off every copy of the old one,
- instantly. The secret part of the link is carried in the URL fragment,
- which browsers don't send to servers — so it stays out of kip's
- hosting logs and out of other sites' referrer headers.
-
-
- Opening a share link creates a temporary session.{" "}
- Visitors are signed in anonymously behind the scenes so the link can
- show live dates. A note recording that a visitor opened a link expires
- on its own within about thirty days, wherever it sits. An unused
- session and its traces are deleted after about thirty days of
- inactivity — as is any account left with nothing attached to it at
- all: no profile, no stay, no friend, no share link.
-
-
- Asking to stay somewhere shows the host who you are.{" "}
- When you request dates, the host can see your profile — they're being
- asked to give a stranger their keys, and they should know whose. It is
- one-way until they say yes: being asked is not agreeing to be looked
- up, so you can't see them until the stay is confirmed. After that each
- of you can see the other's profile, up to about two months after the
- stay ends. Then it lapses on its own.
-
-
- Photos are protected by unguessable URLs. Whoever can
- see a place can see its photos. Like any image on the web, someone who
- could see a photo can save it.
-
-
- Where you're staying is private by default. Friends
- see your confirmed stays only if you turn on that switch in Settings.
-
-
-
-
Text messages (SMS)
-
- If you turn on texts in Settings, kip sends automated notification texts
- to the phone number on your account — things like a request for your
- dates, an answer to yours, or a cancelled stay. This is optional and off
- by default; agreeing to texts is never a condition of using kip. Message
- frequency varies. Message and data rates may apply. Reply STOP to any
- text to stop them, or HELP for help; you can also turn them off in
- Settings.
-
-
-
- kip does not share, sell, or provide your mobile phone number, or your
- consent to receive text messages, to any third party or affiliate for
- marketing or promotional purposes.
- {" "}
- No mobile information is shared with third parties or affiliates for
- marketing. The only party that handles your number besides kip is
- Twilio, the messaging provider that delivers the texts, and it does so
- solely to deliver them. Twilio keeps its own delivery logs of messages
- sent.
-
-
- When you turn texts on, kip records the date, the phone number you
- agreed to be texted at, and which version of the consent wording you
- saw, so there's an honest record of exactly what you agreed to. That
- record is kept even if you later turn texts off or change your number —
- and it goes when your account does.
-
-
-
Email
-
- kip emails you about things that happen to you — a request, an answer, a
- cancellation — and only to an address you've confirmed. Each kind of
- email can be turned off in Settings, and every email carries a one-click
- unsubscribe for its kind. Notification email is delivered via Resend,
- which processes the message in transit.
-
-
-
Services kip relies on
-
- kip runs on a small number of services, each of which processes some of
- your data to do its job — none of which is marketing:
-
-
-
- Google Firebase — sign-in, database, file storage,
- and the small server functions that send notifications and finish
- account deletions. Your data is stored on Google Cloud.
-
-
- Google reCAPTCHA — checks that a phone sign-in is a
- real person, which Firebase requires before it will text a code. It
- inspects your browser to score the request, which makes it the one
- tracker on kip — so it is loaded only at the moment you submit a phone
- number, and never if you sign in by email or with Google.
-
-
- GitHub Pages — hosts the app itself. Like any web
- host, GitHub sees standard request logs (such as IP addresses) when
- you load the page.
-
-
- Resend — delivers notification email.
-
-
- Twilio — delivers notification texts, if you've
- turned them on.
-
-
- OpenStreetMap (Nominatim) — when you type a place
- name or address, either while listing somewhere or in Browse's
- location box, that text is sent to OpenStreetMap's free geocoding
- service to find its coordinates. Only the text you typed, and only
- when you type it.
-
-
-
-
Logs and diagnostics
-
- kip doesn't log what you do in the app. Two narrow exceptions. When
- something goes wrong in a way that throws no error — a page that
- silently stalls — kip may write a small record describing the app's
- state at that moment, so the bug can be fixed; no user of the app can
- read those, only the operator, and they're deleted automatically — after
- about a week, or two for a failure the server itself recorded. And the
- machinery that sends notifications keeps routine operational logs in
- Google Cloud — that a message was sent, or why it wasn't. Those name the
- address or number it went to, and Google keeps them about a month before
- deleting them. Both exist to keep kip working, nothing else.
-
-
- Separately, if you send feedback from the menu, what
- you write is stored so it can be read and acted on. It goes to the
- people who make kip and nowhere else — it isn't published, forwarded, or
- posted anywhere public. Only the operator can read it, and it's kept
- alongside your account ID so we can follow it up. Nobody else can,
- including you: once a report is sent you have no copy of it here. Unlike
- the diagnostics above it isn't deleted on a timer, and it isn't deleted
- when you leave — a bug report is still worth fixing after the person who
- sent it has gone. Write to us if you'd like one removed.
-
-
-
Leaving, and what remains
-
- You can delete your account from Settings at any time. Leaving cancels
- your upcoming stays in both directions, disconnects you from your
- friends, and deletes your photos, your places, your share links, your
- profile, and your sign-in account.
-
-
Two honest footnotes:
-
-
- Your username is retired, not recycled. Usernames are
- never released for someone else to claim — that's what prevents
- impersonation — so a deleted account's name stays reserved forever,
- including from you if you return.
-
-
- The other side keeps their record. A stay involves
- two people, and a record of it remains in the other person's history —
- dates and place, but not your name or photo, which are deleted with
- your account.
-
-
- Feedback you sent stays. A report about something
- broken is still worth fixing afterwards, so it isn't deleted with your
- account — see above, and write to us if you'd like one removed.
-
-
-
-
Children
-
- kip is for adults: you must be 18 or older to use it, and it is not
- directed at children. kip doesn't knowingly keep an account for anyone
- under 18 — if you believe one exists, email{" "}
-
- {CONTACT_EMAIL}
- {" "}
- and it will be removed.
-
-
-
Changes to this policy
-
- If kip's practices change, this page changes with them and the date at
- the top is updated. kip is open source, so{" "}
-
- every previous version of this page
- {" "}
- is public.
-
-
- );
-}
diff --git a/web/app/terms/page.tsx b/web/app/terms/page.tsx
deleted file mode 100644
index ffbe9ae..0000000
--- a/web/app/terms/page.tsx
+++ /dev/null
@@ -1,199 +0,0 @@
-import type { Metadata } from "next";
-import Link from "next/link";
-import type { ReactElement } from "react";
-import DocPage, { Card, H2, List, link, P } from "../../components/doc-page";
-import { CONTACT_EMAIL, HAFA_URL, ISSUES_URL } from "../../utils/contact";
-
-export const metadata: Metadata = {
- title: "Terms of Service — kip",
- description: "The rules for using kip, written to be read.",
-};
-
-const UPDATED = "September 23, 2026";
-
-export default function TermsPage(): ReactElement {
- return (
-
-
- These are the terms for using kip (
-
- https://kip.hafa.cc
-
- ), a friends-only space-sharing app operated by{" "}
-
- hafa
-
- . Using kip means you agree to them. They're written to be read.
-
-
-
What kip is, and isn't
-
- kip lets you list space you have the right to offer, mark when it's
- free, and let friends stay there for nothing. kip is free to use,
- handles no money, and takes no fees. It is not a marketplace, a rental
- platform, or a booking agent — it's a calendar between friends.
-
-
-
Your account
-
-
You must be at least 18 to use kip.
-
- Your account is yours: keep access to the email address, phone number,
- or Google account you sign in with, because they're the only ways back
- in.
-
-
- Give your real name or the name your friends know you by — identity
- between friends is the entire basis of the app.
-
-
- You can leave at any time from Settings, which deletes your account
- and data as described in the{" "}
-
- Privacy Policy
-
- . Usernames are never recycled, so a deleted account's username stays
- permanently retired.
-
-
-
-
What you agree to
-
-
- Only list a place you actually have the right to offer for stays.
-
-
Be honest — in your listings, your name, and your requests.
-
- Don't use kip to harass anyone, to break the law, or to do anything to
- another person's home or account that you wouldn't do to their face.
-
-
- Don't probe, scrape, or interfere with the service. (The code is open
- source; read it there instead.)
-
-
-
- You keep ownership of everything you post — photos, descriptions, your
- name. You give kip permission to store it and show it to the people
- you've chosen to share it with, which is what the app is. That
- permission ends when you delete the content or your account.
-
-
-
Stays are between friends
-
- kip introduces no strangers and vouches for no one: everyone who can see
- your place is someone you connected with or handed a link to. A stay is
- an arrangement between the two of you. kip is not a party to it, doesn't
- guarantee it, doesn't insure it, and can't mediate it. Hosts are
- responsible for their places and for being allowed to offer them; guests
- are responsible for their conduct. Whether either of you owes the other
- anything — a favour, a bottle of wine, an apology — is between you.
-
-
-
Text messaging program
-
-
- kip offers optional automated account-notification texts (“kip
- notifications”), sent by kip via its messaging provider to the phone
- number on your account.
-
-
-
- What it sends: automated notifications when
- something needs you or answers you — someone asks to stay at your
- place, a host answers your request, a confirmed stay is called off,
- or someone agrees to be friends. No marketing, ever.
-
-
- Opting in: you consent by turning on “Text me” in
- Settings, where these disclosures are shown. Consent is specific to
- this program, is not a condition of any purchase, and is never a
- condition of using kip or any part of it.
-
-
- Message frequency varies. How often you're texted
- depends entirely on how much happens to you.
-
-
- Message and data rates may apply.
-
-
- Opting out: reply STOP to any
- message to stop receiving texts, or turn them off in Settings. Reply{" "}
- HELP for help, or contact{" "}
-
- {CONTACT_EMAIL}
-
- .
-
-
kip supports US phone numbers only.
-
Carriers are not liable for delayed or undelivered messages.
-
-
- Your phone number and your opt-in are never shared with, sold to,
- or bought by third parties or affiliates.
- {" "}
- See the{" "}
-
- Privacy Policy
-
- .
-
-
-
-
-
Email notifications
-
- kip emails confirmed addresses about account events. Every kind can be
- switched off in Settings, and every email carries a one-click
- unsubscribe.
-
-
-
Ending things
-
- You can stop using kip, or delete your account, whenever you like. kip
- may suspend or remove an account that breaks these terms or abuses the
- service or other people on it. Since stays are free and kip holds no
- money, neither of us owes the other anything on the way out.
-
-
-
The honest disclaimers
-
- kip is provided as it is, free of charge. It's built with care, but it
- comes with no warranty of any kind — not that it will be available,
- uninterrupted, or error-free. To the fullest extent permitted by law,
- kip and its operator are not liable for indirect, incidental, or
- consequential damages arising from the service or from any stay arranged
- through it; where liability can't be excluded, it is limited to the
- amount you paid to use kip, which is nothing. Nothing in these terms
- excludes liability that cannot lawfully be excluded.
-
-
- These terms are governed by the laws of New York, and any dispute that
- somehow reaches a court belongs in the state or federal courts of Kings
- County, New York.
-
-
-
Changes
-
- If these terms change in substance, this page and its date change, and
- material changes will be called out. Previous versions are public in
- kip's repository history.
-
-
-
Contact
-
-
- Open an issue on GitHub
- {" "}
- — public, and the fastest way to get an answer. For anything you'd
- rather not post publicly, the{" "}
-
- Help page
- {" "}
- has an address. The SMS program has its own contact above, which is the
- one carriers require.
-
- {rooms.map((room) => {
- const on = ticked.includes(room.id);
- return (
- // biome-ignore lint/a11y/useSemanticElements: a native checkbox can't hold the two-line label and the drawn tick as one tap target
-
- );
- })}
-
-
- );
-}
diff --git a/web/components/auth-menu.tsx b/web/components/auth-menu.tsx
deleted file mode 100644
index bdcc332..0000000
--- a/web/components/auth-menu.tsx
+++ /dev/null
@@ -1,255 +0,0 @@
-"use client";
-
-import { type ReactElement, useEffect, useRef, useState } from "react";
-import {
- LuChevronDown,
- LuDownload,
- LuInbox,
- LuLogOut,
- LuMessageSquare,
- LuSettings,
- LuUser,
-} from "react-icons/lu";
-import { credentialed } from "../utils/feedback";
-import { useInstall } from "../utils/install";
-import { useKip } from "../utils/store";
-import Avatar from "./avatar";
-import { useDialog } from "./dialog";
-import FeedbackSheet from "./feedback-sheet";
-import { useLeave } from "./use-leave";
-
-// Only shown once signed in (the app gates on auth). Sign-in itself lives on
-// the WelcomeScreen, so this is the profile menu: your profile, Settings (the
-// dock has no room for it), feedback, installing kip where that is possible,
-// and sign-out.
-export default function AuthMenu(): ReactElement | null {
- const {
- user,
- anonymous,
- email,
- doors,
- emailVerified,
- admin,
- unreadFeedback,
- profile,
- signOut,
- setView,
- navigate,
- } = useKip();
- const { leave, leaving } = useLeave();
- const { ready, byHand, install } = useInstall();
- const { alert } = useDialog();
- const [open, setOpen] = useState(false);
- const [feedback, setFeedback] = useState(false);
-
- const name = profile?.displayName ?? user?.displayName ?? null;
- const photoURL = profile?.photoURL ?? user?.photoURL ?? null;
-
- async function doSignOut() {
- // Leaving is a teardown, not a sign-out, so it shares Settings' flow. The
- // menu stays open meanwhile, so "Leaving…" has somewhere to show until the
- // sign-out unmounts it.
- if (anonymous) {
- await leave();
- return;
- }
- setOpen(false);
- try {
- await signOut();
- } catch (error) {
- console.error(error);
- }
- }
-
- const trigger = useRef(null);
- const menu = useRef(null);
- useEffect(() => {
- if (!open) return;
- const items = () =>
- Array.from(
- menu.current?.querySelectorAll('[role="menuitem"]') ?? [],
- );
- items()[0]?.focus();
- function onKey(event: KeyboardEvent): void {
- if (event.key === "Escape") {
- setOpen(false);
- trigger.current?.focus();
- } else if (event.key === "ArrowDown" || event.key === "ArrowUp") {
- event.preventDefault();
- const all = items();
- const at = all.indexOf(document.activeElement as HTMLElement);
- const step = event.key === "ArrowDown" ? 1 : -1;
- all[(at + step + all.length) % all.length]?.focus();
- }
- }
- window.addEventListener("keydown", onKey);
- return () => window.removeEventListener("keydown", onKey);
- }, [open]);
-
- // portal/continue render neither the nav stack nor Settings, so every row
- // would be a dead tap.
- if (
- typeof window !== "undefined" &&
- /\/(portal|continue)\/?$/.test(window.location.pathname)
- ) {
- return null;
- }
- // A nameless ANONYMOUS session gets no menu, and wants none: it holds no
- // profile, no ask and no friends, so leaving would swap one empty anonymous
- // account for another. A nameless account with a credential keeps it —
- // Settings and the exit live only here, and the name sheet can be dismissed.
- if (!user || (anonymous && !name)) return null;
-
- return (
-
-
- {open ? (
- <>
-
- );
-}
diff --git a/web/components/auth-panel.tsx b/web/components/auth-panel.tsx
deleted file mode 100644
index 89c31cc..0000000
--- a/web/components/auth-panel.tsx
+++ /dev/null
@@ -1,206 +0,0 @@
-"use client";
-
-import { type ReactElement, useRef, useState } from "react";
-import { FaGoogle } from "react-icons/fa";
-import { authErrorMessage } from "../utils/auth";
-import { useKip } from "../utils/store";
-import { useDialog } from "./dialog";
-import ReachField, {
- codeReady,
- confirmReach,
- EMPTY_REACH,
- type ReachState,
- reachError,
- sendReach,
-} from "./reach-field";
-import Busy from "./ui/busy";
-import Button from "./ui/button";
-
-// The door — the whole of it, since the same one-time link both makes an account
-// and returns you to one, and the flow never asks which you are. No password: a
-// one-time link or code is fewer things to remember and fewer to lose, and
-// retiring it took the whole reset flow — its own screen, its own failure
-// states, its own careful "if an account exists" notice — out of the product.
-//
-// It offers the SAME three doors as the identity sheet, and that is the point
-// rather than a nicety: an account whose only credential is a phone number could
-// not get back in on a new device while this offered email and Google alone.
-// Anything addable must be returnable.
-//
-// Nothing here says sign in or sign up, and the omission is deliberate: the same
-// link works whether or not kip has met this address, so there is no question to
-// answer and no wrong door to pick. Nor does anything spell out the accepted
-// formats — the placeholder and the "Use phone" link carry them, and the US-only
-// limit is said by `reachError` at the moment it bites.
-//
-// It owns the CARD as well as the form, so that the standing notice under the
-// card and the error that replaces it are one node rather than the same string
-// synchronised into two components. `notice` is what that line says when
-// nothing is wrong.
-export default function AuthPanel({
- notice,
-}: {
- notice: string;
-}): ReactElement {
- const { configured, signIn } = useKip();
- const { alert } = useDialog();
- const [reach, setReach] = useState(EMPTY_REACH);
- const [busy, setBusy] = useState(false);
- const [error, setError] = useState(null);
- const [sentTo, setSentTo] = useState(null);
- const recaptcha = useRef(null);
-
- const invalid = reachError(reach.raw);
- const problem = invalid ?? error;
-
- async function guardConfigured(): Promise {
- if (configured) return true;
- await alert({
- title: "Firebase isn't set up yet",
- body: "Create a Firebase project, enable Email link, Phone and Google sign-in, and paste its config into web/utils/firebase.ts to continue.",
- });
- return false;
- }
-
- async function run(action: () => Promise): Promise {
- if (!(await guardConfigured())) return;
- setBusy(true);
- setError(null);
- try {
- await action();
- } catch (caught) {
- console.error(caught);
- // Remedy-shaped, matching the other two surfaces: `authErrorMessage`
- // ends at "Something went wrong. Try again." for most codes, which tells
- // nobody what to do differently. Its specific cases still speak.
- const mapped = authErrorMessage(caught);
- setError(
- mapped === "Something went wrong. Try again."
- ? "Couldn't send that. Check or change it."
- : mapped,
- );
- } finally {
- setBusy(false);
- }
- }
-
- async function submit(): Promise {
- if (invalid || !reach.raw) return;
- // A code already sent is waiting to be typed, so this finishes it.
- if (reach.pending) {
- // Caught apart from the send, and worded like its siblings: routed
- // through `run`, a refused code came back as "couldn't send that" while
- // the code step in front of them was asking for exactly that code.
- setBusy(true);
- setError(null);
- try {
- await confirmReach(reach.pending, reach.code);
- } catch (caught) {
- console.error(caught);
- setError("Wrong code. Check it, or ask for another.");
- } finally {
- setBusy(false);
- }
- return;
- }
- const holder = recaptcha.current;
- // Reported, not swallowed — the two sheets throw here and say so, and a
- // dead Continue with no spinner and nothing in the console is the exact
- // divergence these four surfaces exist to have eliminated.
- if (!holder) {
- setError("Couldn't start that. Reload and try again.");
- return;
- }
- await run(async () => {
- const sent = await sendReach(reach.raw, "", holder, "return");
- if (sent.pending) setReach({ ...reach, ...sent });
- else setSentTo(sent.sentTo);
- });
- }
-
- // Deliberately identical whether or not kip knows the address — that is what
- // preserves the non-enumeration the old reset notice had to spell out.
- const body = sentTo ? (
-
-
- Open the link we sent to {sentTo} and you're in. It works for about an
- hour.
-
- setSentTo(null)}>
- Use something else
-
-
- ) : (
-
- );
-
- return (
- <>
- {/* No heading and no label. An email field over a Continue button is
- self-evidently the way in, and "no sign-up, no password" is said by
- the absence of a toggle and a password field rather than by a line
- claiming it. */}
-
- {body}
-
- {/* Two lines reserved whatever speaks: the page is a centred column, so
- a height change here would shift the door. One line of error is
- pinned by `tests/auth-copy.test.ts`. */}
-
- {problem ?? notice}
-
- >
- );
-}
diff --git a/web/components/avatar.tsx b/web/components/avatar.tsx
deleted file mode 100644
index cd8afe3..0000000
--- a/web/components/avatar.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-"use client";
-
-import type { ReactElement } from "react";
-import { photoSrc } from "../utils/photos";
-
-// Pass sizing via className (e.g. "h-9 w-9 text-sm") — Tailwind needs literal
-// classes, so size can't be a number prop. `ring` wraps the avatar in a 2px
-// terracotta→amber gradient ring, used to feature a host.
-export default function Avatar({
- name,
- photoURL,
- className = "h-9 w-9 text-sm",
- ring = false,
-}: {
- name: string;
- photoURL: string | null;
- className?: string;
- ring?: boolean;
-}): ReactElement {
- // Almost every avatar drawn here comes from a copy the person it describes
- // wrote — a friend edge, a booking, a share link — so the address is theirs to
- // choose and `photoSrc` is what keeps it from being an arbitrary one. A URL
- // that fails the check falls back to the initial, same as no photo at all.
- const src = photoURL === null ? null : photoSrc(photoURL);
- const inner = src ? (
- // biome-ignore lint/performance/noImgElement: static export, no next/image loader
-
- ) : (
-
- {(name || "?").charAt(0).toUpperCase()}
-
- );
-
- if (!ring) return inner;
- return (
-
- {inner}
-
- );
-}
diff --git a/web/components/booking-page.tsx b/web/components/booking-page.tsx
deleted file mode 100644
index 63ff9f3..0000000
--- a/web/components/booking-page.tsx
+++ /dev/null
@@ -1,454 +0,0 @@
-"use client";
-
-import { type ReactElement, type ReactNode, useEffect, useState } from "react";
-import {
- LuCalendarDays,
- LuCheck,
- LuChevronRight,
- LuClock,
- LuMapPin,
- LuX,
-} from "react-icons/lu";
-import { fetchBookingIfVisible } from "../utils/bookings";
-import {
- checkoutParts,
- type StayCheckout,
- stayOpensCheckout,
-} from "../utils/checkout";
-import { formatDateRange, isExpired, nights } from "../utils/format";
-import { fetchStayCheckout, listingTypeIcon } from "../utils/listings";
-import { useKip } from "../utils/store";
-import type { Booking, CancelReason } from "../utils/types";
-import Avatar from "./avatar";
-import CoverPhoto from "./cover-photo";
-import { useAction, useDialog, useFailure } from "./dialog";
-import Button from "./ui/button";
-import Chip, { type ChipTone } from "./ui/chip";
-import { Group, Row, Section } from "./ui/list";
-import { useStayPlace } from "./use-stay-place";
-
-// `byMe` is the only axis needed, because the reason and the side always agree —
-// every reason but STAY_CANCELLED belongs to exactly one party.
-function cancelNote(
- reason: CancelReason | null,
- byMe: boolean,
- otherName: string,
-): string {
- switch (reason) {
- case "DECLINED":
- return byMe
- ? "You declined this request"
- : `${otherName} couldn't host these dates`;
- case "WITHDRAWN":
- return byMe
- ? "You took this request back"
- : `${otherName} took this request back`;
- case "SLOT_MOVED":
- return byMe
- ? "You moved these dates, so this request was cancelled"
- : `${otherName} moved these dates, so this request was cancelled`;
- case "SLOT_CANCELLED":
- return byMe
- ? "You called these dates off"
- : `${otherName} called these dates off`;
- case "STAY_CANCELLED":
- return byMe
- ? "You cancelled this stay"
- : `${otherName} cancelled this stay`;
- default:
- return "This booking was cancelled";
- }
-}
-
-// The stay, guest side, or the request, owner side.
-export default function BookingPage({ id }: { id: string }): ReactElement {
- const {
- user,
- trips,
- incomingBookings,
- knownPerson,
- cancelTrip,
- confirmBooking,
- declineBooking,
- hideBooking,
- navigate,
- back,
- } = useKip();
- const run = useAction();
- const { alert, confirm } = useDialog();
- const fail = useFailure();
- const [busy, setBusy] = useState(false);
- // Neither subscription carries a stay you're only a spectator of, so a friend
- // arriving from a held slot has to fetch it. `looked` keeps the not-available
- // line from flashing before the answer is back.
- const [fetchedBooking, setFetchedBooking] = useState(
- undefined,
- );
- const [looked, setLooked] = useState(false);
- const found: Booking | undefined =
- trips.find((candidate) => candidate.id === id) ??
- incomingBookings.find((candidate) => candidate.id === id) ??
- // Pinned to the id being rendered: the state outlives a move to another
- // booking, so without this the previous one shows under the new URL.
- (fetchedBooking?.id === id ? fetchedBooking : undefined);
- const { listing: room, room: stayRoom } = useStayPlace(found);
- const iAmPartyTo =
- trips.some((candidate) => candidate.id === id) ||
- incomingBookings.some((candidate) => candidate.id === id);
-
- useEffect(() => {
- if (iAmPartyTo) return;
- let live = true;
- setLooked(false);
- fetchBookingIfVisible(id)
- .then((found) => {
- if (!live) return;
- setFetchedBooking(found ?? undefined);
- })
- .catch((error) => console.error("visitBooking", error))
- .finally(() => {
- if (live) setLooked(true);
- });
- return () => {
- live = false;
- };
- }, [id, iAmPartyTo]);
-
- // Only the guest of a confirmed stay may read these, and only until the
- // rules' cut-off after check-out; they refuse everyone else, so nobody else
- // asks.
- const [checkout, setCheckout] = useState<{
- id: string;
- found: StayCheckout;
- } | null>(null);
- const guestUid = found?.guestId === user?.uid ? user?.uid : undefined;
- const staying =
- found !== undefined && stayOpensCheckout(found) && guestUid !== undefined;
- const stayListingId = found?.listingId;
- const stayWindowId = found?.windowId;
- useEffect(() => {
- if (!staying || !guestUid || !stayListingId || !stayWindowId) return;
- let live = true;
- fetchStayCheckout(
- { id, listingId: stayListingId, windowId: stayWindowId },
- guestUid,
- )
- .then((answer) => {
- if (live) setCheckout({ id, found: answer });
- })
- .catch((error) => console.error("stayCheckout", error));
- return () => {
- live = false;
- };
- }, [id, staying, guestUid, stayListingId, stayWindowId]);
-
- // The ordinary double-click only; the transaction is the real protection.
- async function confirmStay(): Promise {
- if (!booking) return;
- setBusy(true);
- try {
- const outcome = await confirmBooking(booking);
- if (outcome === "unavailable") {
- // Either another stay took the dates or the ask was withdrawn, and the
- // transaction can't tell which without claiming more than it knows.
- await alert({
- title: "Too late for this one",
- body: "Either those dates went to another stay or the guest took their request back — either way there's nothing left to confirm. This page will catch up in a moment.",
- });
- }
- } catch (error) {
- fail(error, "Couldn't confirm this stay. Please try again.");
- } finally {
- setBusy(false);
- }
- }
-
- const booking = found;
- if (!booking) {
- return (
-
- );
- }
-
- // A stay in one room names it ahead of the place it is in.
- const title = room
- ? stayRoom
- ? `${stayRoom.name} · ${room.title}`
- : room.title
- : "A place";
- const cover = stayRoom?.photos[0] ?? room?.photos[0];
- const address = room?.location.label || "Address unavailable";
- const iAmGuest = booking.guestId === user?.uid;
- const iAmHost = booking.ownerId === user?.uid;
- // A friend of the guest can be here without being either party — the stay is
- // theirs to see, none of it is theirs to change.
- const iAmParty = iAmGuest || iAmHost;
- // A spectator came for the guest; a guest came for their host.
- const otherUid = iAmGuest ? booking.ownerId : booking.guestId;
- // A pending ask authorises no read, so an unanswered stranger stays "Someone"
- // with the role beneath carrying what is actually known.
- const other = knownPerson(otherUid);
- const otherName = other?.displayName || "Someone";
- const otherPhoto = other?.photoURL ?? null;
- const PlaceIcon = room ? listingTypeIcon(room.type) : LuMapPin;
- // A round 40px crop of a room is barely a picture.
- const hero = (
-
- );
- // Every row in this card leads with an icon, so dropping it for the place
- // alone left the title out of line with the rows beneath.
- const placeThumb = (
-
-
-
- );
-
- // Pinned to this stay and its current state: the answer outlives a move to
- // another booking, and a cancel that lands while the page is open.
- const stayCheckout =
- iAmGuest && stayOpensCheckout(booking) && checkout?.id === id
- ? checkout.found
- : null;
- const checkingOut = stayCheckout
- ? checkoutParts(
- stayCheckout.place,
- stayCheckout.room,
- (stayCheckout.roomId
- ? room?.rooms[stayCheckout.roomId]?.name
- : undefined) ?? null,
- )
- : [];
-
- // The page it's on has nothing left to show once hidden.
- async function clearFromList(cleared: Booking): Promise {
- const agreed = await confirm({
- title: "Clear this from your list?",
- body: `It disappears from your ${iAmGuest ? "trips" : "bookings"}. ${otherName} still has their copy — nothing is deleted.`,
- confirmLabel: "Clear",
- });
- if (!agreed) return;
- await hideBooking(cleared.id);
- back();
- }
-
- const moment: {
- tone: ChipTone;
- label: string;
- note: string;
- circle: string;
- icon: ReactNode;
- } =
- booking.status === "CONFIRMED"
- ? {
- tone: "confirmed",
- label: "Confirmed",
- note: iAmGuest
- ? "Your stay is all set"
- : iAmHost
- ? `You're hosting ${otherName}`
- : `${otherName} is staying here`,
- circle: "bg-success-soft text-success",
- icon: ,
- }
- : booking.status === "REQUESTED"
- ? {
- tone: "pending",
- label: "Pending",
- note: iAmGuest
- ? `Waiting on ${otherName} to confirm`
- : iAmHost
- ? `${otherName} wants to book`
- : `${otherName} has asked to stay here`,
- circle: "bg-pending-soft text-pending",
- icon: ,
- }
- : {
- tone: "neutral",
- label: "Cancelled",
- note: cancelNote(
- booking.cancelReason,
- booking.cancelledBy === user?.uid,
- otherName,
- ),
- circle: "bg-surface-muted text-muted",
- icon: ,
- };
-
- return (
-
- {/* With a photo the whole status — icon and label — sits ON it, because
- the state is about that stay and the image is what the stay IS. The
- header keeps only the sentence. Without one it all falls back to the
- header, which is where it used to live. */}
- {cover ? (
-
-
- {/* Tappable whether or not they're a friend: it's the one place a
- share-link guest and their host can ask to connect, since neither is
- searchable to the other and neither holds the other's link. */}
- navigate({ kind: "person", id: otherUid })}
- ariaLabel={otherName}
- >
-
-
- ) : (
- run(() => clearFromList(booking))}
- >
- Clear from my list
-
- )}
-
- );
-}
diff --git a/web/components/booking-row.tsx b/web/components/booking-row.tsx
deleted file mode 100644
index 28dac03..0000000
--- a/web/components/booking-row.tsx
+++ /dev/null
@@ -1,122 +0,0 @@
-"use client";
-
-import type { ReactElement } from "react";
-import { LuChevronRight, LuMapPin } from "react-icons/lu";
-import { formatDateRange } from "../utils/format";
-import { listingTypeIcon } from "../utils/listings";
-import { wholePlaceLabel } from "../utils/rooms";
-import { useKip } from "../utils/store";
-import type { Booking, BookingStatus } from "../utils/types";
-import CoverPhoto from "./cover-photo";
-import { hasRooms, RoomThumb } from "./rooms";
-import Chip, { type ChipTone } from "./ui/chip";
-import { Row } from "./ui/list";
-import { useStayPlace } from "./use-stay-place";
-
-const STATUS: Record = {
- REQUESTED: { label: "Pending", tone: "pending" },
- CONFIRMED: { label: "Confirmed", tone: "confirmed" },
- CANCELLED: { label: "Cancelled", tone: "neutral" },
-};
-
-const PIECE =
- "relative max-w-full truncate pl-3 before:absolute before:left-[0.2rem] before:content-['·']";
-
-// `lead` says which of the place and the person comes first: the place tells
-// rows apart on a list spanning places, and repeats uselessly on one place's own
-// Guests list. The cover follows it for the same reason.
-export default function BookingRow({
- booking,
- lead = "place",
- showCounterpart = true,
- showDates = true,
-}: {
- booking: Booking;
- lead?: "place" | "person";
- // Off on a list that is already about one person — naming them on every row
- // says nothing, and on a stay you're only watching there is no "with" to it.
- showCounterpart?: boolean;
- // Off inside one slot's own sheet, where the dates are the title. An ask is
- // written against the slot's dates and `updateWindow` cancels any ask the
- // dates move out from under, so repeating them there says nothing. Note the
- // rules pin the two together only at create and at confirm, not in between —
- // so this is about noise, and not a claim that they cannot differ.
- showDates?: boolean;
-}): ReactElement {
- const { user, knownPerson, navigate } = useKip();
- const { listing: known, room } = useStayPlace(booking);
-
- const iAmGuest = booking.guestId === user?.uid;
- const otherUid = iAmGuest ? booking.ownerId : booking.guestId;
- // A pending ask authorises no read, so an unanswered stranger is "Someone".
- const otherName = knownPerson(otherUid)?.displayName || "Someone";
- const title = known?.title || "A place";
- // An unreadable place falls back to a pin: the leading slot exists either way,
- // and an empty one breaks the row rhythm.
- const PlaceIcon = known ? listingTypeIcon(known.type) : LuMapPin;
- const status = STATUS[booking.status];
- const person = iAmGuest ? `with ${otherName}` : otherName;
- // A stay in one room leads with the room and names the house beneath, since
- // the two side by side don't fit a phone's width.
- const headline = lead === "person" ? otherName : room ? room.name : title;
- const detail = lead === "person" || !showCounterpart ? "" : `${person} · `;
- // On one place's own Guests list the place is given, so what tells two rows
- // apart is the room — or that it was the whole place, where there are rooms.
- const offered =
- lead === "person" && showDates && known && hasRooms(known)
- ? `${room ? room.name : wholePlaceLabel(known.type)} · `
- : "";
- const when = showDates ? formatDateRange(booking.start, booking.end) : null;
-
- return (
- navigate({ kind: "booking", id: booking.id })}
- ariaLabel={headline}
- >
- {lead !== "place" ? null : room ? (
- // The room's own picture or none: the house's would pass for the room.
-
- ) : (
-
-
-
- }
- />
- )}
-
-
- {headline}
-
- {lead === "place" && room ? (
- // The house and the rest share a line where they fit and break
- // between the two where they don't. Each piece carries its own
- // leading dot, hung in a gutter the outer box clips — so a piece
- // that starts a line shows none.
-
-
- {title}
- {detail || when ? (
-
- {detail}
- {when}
-
- ) : null}
-
-
- ) : showDates || detail || offered ? (
-
- {offered}
- {detail}
- {when}
-
- ) : null}
-
-
- {/* Below the footer, not above it: picking a saved search is the rarer
- reason to be here, and the primary action must stay reachable
- without scrolling past a list. */}
-
-
-
- );
-}
diff --git a/web/components/checkout-field.tsx b/web/components/checkout-field.tsx
deleted file mode 100644
index 3155682..0000000
--- a/web/components/checkout-field.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-"use client";
-
-import { type ReactElement, useId } from "react";
-import { CHECKOUT_MAX } from "../utils/checkout";
-import FieldNote from "./ui/field-note";
-import { Textarea } from "./ui/input";
-
-/** The check-out instructions field, shared by the place form and the room sheet. */
-export default function CheckoutField({
- value,
- onChange,
- disabled = false,
-}: {
- value: string;
- onChange: (value: string) => void;
- disabled?: boolean;
-}): ReactElement {
- const fieldId = useId();
- return (
-
- );
-}
diff --git a/web/components/cover-photo.tsx b/web/components/cover-photo.tsx
deleted file mode 100644
index 78d612e..0000000
--- a/web/components/cover-photo.tsx
+++ /dev/null
@@ -1,212 +0,0 @@
-"use client";
-
-import {
- type ReactElement,
- type RefObject,
- useEffect,
- useRef,
- useState,
-} from "react";
-import { LuChevronLeft, LuChevronRight } from "react-icons/lu";
-import { photoSrc } from "../utils/photos";
-import type { ListingPhoto } from "../utils/types";
-import IconButton from "./ui/icon-button";
-
-// How far a masked rail edge fades.
-const FADE = "1.5rem";
-
-// No photo is the ordinary case, so `fallback` differs by context: nothing on a
-// card, where a grey gap would be worse, but an icon in a row, which needs its
-// leading slot filled either way.
-export default function CoverPhoto({
- photo,
- fallback = null,
- className = "",
-}: {
- photo: ListingPhoto | undefined;
- fallback?: ReactElement | null;
- className?: string;
-}): ReactElement | null {
- const src = photo ? photoSrc(photo.url) : null;
- if (!src) return fallback;
-
- return (
-
- );
-}
-
-// Masked rather than overlaid with a gradient, because the same rail sits on the
-// canvas in one place and on a card in another, and an overlay would need to know.
-export function useRailFade(count: number): {
- ref: RefObject;
- maskImage: string | undefined;
-} {
- const ref = useRef(null);
- const [edges, setEdges] = useState({ before: false, after: false });
-
- // biome-ignore lint/correctness/useExhaustiveDependencies: `count` is not read below — it re-measures when a photo is added or removed, which the observer can't see, since the rail's own box doesn't change size when its contents do.
- useEffect(() => {
- const node = ref.current;
- if (!node) return;
- // An arrow, not a declaration: a hoisted one predates the guard above, so
- // `node` would still be nullable inside it.
- const measure = (): void => {
- const before = node.scrollLeft > 1;
- const after = node.scrollWidth - node.clientWidth - node.scrollLeft > 1;
- // Fires every frame while scrolling, so identity has to be stable.
- setEdges((current) =>
- current.before === before && current.after === after
- ? current
- : { before, after },
- );
- };
- measure();
- // What fits changes on rotate and any layout shift, not only on add.
- const observer = new ResizeObserver(measure);
- observer.observe(node);
- node.addEventListener("scroll", measure);
- return () => {
- observer.disconnect();
- node.removeEventListener("scroll", measure);
- };
- }, [count]);
-
- const stops = [
- edges.before ? "transparent 0" : "black 0",
- edges.before ? `black ${FADE}` : null,
- edges.after ? `black calc(100% - ${FADE})` : null,
- edges.after ? "transparent 100%" : "black 100%",
- ].filter((stop): stop is string => stop !== null);
-
- return {
- ref,
- maskImage:
- edges.before || edges.after
- ? `linear-gradient(to right, ${stops.join(", ")})`
- : undefined,
- };
-}
-
-// Scroll-snap, so touch costs no gesture code. The rail is optional because the
-// owner already has one that reorders, and one target can't carry two gestures.
-export function PhotoGallery({
- photos,
- thumbnails = true,
- heroClassName = "",
-}: {
- photos: readonly ListingPhoto[];
- thumbnails?: boolean;
- heroClassName?: string;
-}): ReactElement | null {
- const hero = useRef(null);
- const [index, setIndex] = useState(0);
- // A hole in the middle would put every arrow press one photo out.
- const shown = photos.filter((photo) => photoSrc(photo.url) !== null);
- const { ref: rail, maskImage } = useRailFade(shown.length);
-
- // Scrolls the rail itself, not the thumbnail into view: the gallery can sit
- // below the fold, and scrollIntoView would drag the page up to it.
- useEffect(() => {
- const node = rail.current;
- const thumb = node?.querySelector(
- `[data-photo-index="${index}"]`,
- );
- if (!node || !thumb) return;
- node.scrollTo({
- left: thumb.offsetLeft - (node.clientWidth - thumb.clientWidth) / 2,
- behavior: "smooth",
- });
- }, [index, rail]);
-
- if (shown.length === 0) return null;
-
- function show(position: number): void {
- const node = hero.current;
- if (!node) return;
- node.scrollTo({ left: node.clientWidth * position, behavior: "smooth" });
- }
-
- const many = shown.length > 1;
- return (
-
-
- {thumbnails && many ? (
- // `relative` so a thumbnail's offsetLeft is measured against the rail.
-
- {shown.map((photo, position) => (
- show(position)}
- className={`shrink-0 rounded-2xl transition ${
- position === index
- ? "ring-2 ring-accent"
- : "opacity-70 hover:opacity-100"
- }`}
- >
- {/* The same 96px the editing strip uses. */}
-
-
- ))}
-
- ) : null}
-
- );
-}
diff --git a/web/components/deletion-screen.tsx b/web/components/deletion-screen.tsx
deleted file mode 100644
index 157e27c..0000000
--- a/web/components/deletion-screen.tsx
+++ /dev/null
@@ -1,134 +0,0 @@
-"use client";
-
-import { type ReactElement, useState } from "react";
-import { CONTACT_EMAIL } from "../utils/contact";
-import { clearDeletion, requestDeletion } from "../utils/leave";
-import { useKip } from "../utils/store";
-import {
- DELETION_LABELS,
- DELETION_PHASES,
- type DeletionRequest,
- deletionProgress,
-} from "../utils/types";
-import Button from "./ui/button";
-import FieldNote from "./ui/field-note";
-import { Mark } from "./wordmark";
-
-// Both buttons clear the doc; rules allow that only once `error` is set.
-function Stalled({ request }: { request: DeletionRequest }): ReactElement {
- const { user } = useKip();
- const [busy, setBusy] = useState(false);
- const [error, setError] = useState(null);
- async function act(again: boolean): Promise {
- if (!user || busy) return;
- setBusy(true);
- setError(null);
- try {
- await clearDeletion(user.uid);
- if (again) await requestDeletion(user.uid);
- } catch (caught) {
- console.error(caught);
- setBusy(false);
- setError("Couldn't reach kip just now. Try that again in a minute.");
- }
- }
-
- return (
- <>
-
- kip couldn't finish this
-
-
- It stopped after {request.attempts}{" "}
- {request.attempts === 1 ? "attempt" : "attempts"}
- {request.phase
- ? `, at ${DELETION_LABELS[request.phase].toLowerCase()}`
- : ""}
- . What it got through has already happened and doesn't come back —
- cancelled stays, and friends whose lists you have gone from. The rest of
- your account is still here.
-
-
- act(true)} disabled={busy}>
- Try deleting again
-
- act(false)}
- disabled={busy}
- >
- Keep my account for now
-
-
- {error ? {error} : null}
-
- Stuck again? Someone will finish it by hand — email{" "}
-
- {CONTACT_EMAIL}
-
- .
-
- >
- );
-}
-
-// What a signed-in account looks like while the trigger dismantles it. Calm on
-// purpose: this is something they asked for, and the one thing worth saying is
-// that it no longer needs them — a browser closed halfway is exactly the bug
-// moving this to a function fixed.
-export default function DeletionScreen({
- request,
-}: {
- request: DeletionRequest;
-}): ReactElement {
- const step = request.phase ? DELETION_PHASES.indexOf(request.phase) + 1 : 0;
- const fraction = deletionProgress(request.phase);
-
- return (
-
-
- {request.failed ? (
-
- ) : (
- <>
-
- Deleting your kip
-
-
- This is running on kip's side now, so you can close this — it
- finishes on its own.
-
-
-
- {/* Width, not a transform: the fill is a gradient, and scaling one
- stretches its colours along with the bar. */}
-
-
-
- );
-}
diff --git a/web/components/doc-page.tsx b/web/components/doc-page.tsx
deleted file mode 100644
index 50944c4..0000000
--- a/web/components/doc-page.tsx
+++ /dev/null
@@ -1,75 +0,0 @@
-import Link from "next/link";
-import type { ReactElement, ReactNode } from "react";
-import SiteFooter, { type DocRoute } from "./site-footer";
-import ThemeButton from "./theme-button";
-import Wordmark from "./wordmark";
-
-// Twilio fetches the Privacy and Terms URLs server-side during campaign
-// registration, so every word has to be in the exported HTML for a reader with
-// no JavaScript.
-
-export const link = "font-semibold text-accent-ink break-words";
-
-export function H2({ children }: { children: ReactNode }): ReactElement {
- return (
-
{children}
- );
-}
-
-export function P({ children }: { children: ReactNode }): ReactElement {
- return
{children}
;
-}
-
-export function List({ children }: { children: ReactNode }): ReactElement {
- return (
-
- {children}
-
- );
-}
-
-export function Card({ children }: { children: ReactNode }): ReactElement {
- return (
-
- );
-}
diff --git a/web/components/emulator-badge.tsx b/web/components/emulator-badge.tsx
deleted file mode 100644
index 04bedda..0000000
--- a/web/components/emulator-badge.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-"use client";
-
-import type { ReactElement } from "react";
-import { usingEmulators } from "../utils/firebase";
-
-// Says which backend this build is talking to, because the difference is
-// otherwise invisible and its symptoms look like bugs: an emulated Firestore
-// starts empty, so every real share link resolves to nothing and reads as
-// "this link isn't active". Folded out of production entirely by the NODE_ENV
-// half of `usingEmulators`.
-export default function EmulatorBadge(): ReactElement | null {
- if (!usingEmulators()) return null;
- return (
-
- Emulated — real share links won't resolve
-
- );
-}
diff --git a/web/components/feedback-sheet.tsx b/web/components/feedback-sheet.tsx
deleted file mode 100644
index 323b9da..0000000
--- a/web/components/feedback-sheet.tsx
+++ /dev/null
@@ -1,104 +0,0 @@
-"use client";
-
-import { type ReactElement, useState } from "react";
-import { MAX_FEEDBACK, sendFeedback } from "../utils/feedback";
-import Button from "./ui/button";
-import { Textarea } from "./ui/input";
-import Sheet from "./ui/sheet";
-
-export default function FeedbackSheet({
- open,
- onClose,
-}: {
- open: boolean;
- onClose: () => void;
-}): ReactElement {
- const [text, setText] = useState("");
- const [busy, setBusy] = useState(false);
- const [sent, setSent] = useState(false);
- const [problem, setProblem] = useState(null);
-
- function close(): void {
- onClose();
- // Only once it is out of sight: resetting while the sheet is still on
- // screen plays the whole form back in the last frame of the dismissal.
- setTimeout(() => {
- setText("");
- setSent(false);
- setProblem(null);
- }, 200);
- }
-
- async function submit(): Promise {
- setBusy(true);
- setProblem(null);
- try {
- await sendFeedback(text);
- setSent(true);
- } catch (error) {
- console.warn("feedback", error);
- setProblem("That didn't send. Have another go in a moment.");
- } finally {
- setBusy(false);
- }
- }
-
- return (
-
- {sent ? (
- <>
-
- That's with us. We read every one, though we can't always write back
- — if you'd like an answer, say how to reach you.
-
-
- Done
-
- >
- ) : (
- <>
- {/* One box and nothing else. Asking someone to pick a category first
- makes them sort their own report before they can write it, and
- sorting them is the reader's job. */}
-
- );
-}
diff --git a/web/components/feedback-view.tsx b/web/components/feedback-view.tsx
deleted file mode 100644
index 03df5b8..0000000
--- a/web/components/feedback-view.tsx
+++ /dev/null
@@ -1,155 +0,0 @@
-"use client";
-
-import {
- type ReactElement,
- useCallback,
- useEffect,
- useRef,
- useState,
-} from "react";
-import { LuRefreshCw, LuTrash2 } from "react-icons/lu";
-import {
- deleteFeedback,
- fetchFeedback,
- markFeedbackSeen,
- type Report,
-} from "../utils/feedback";
-import { useKip } from "../utils/store";
-import IconButton from "./ui/icon-button";
-import { Group, Section } from "./ui/list";
-
-// When it arrived, to the minute. Not a relative age: a report read weeks later
-// is the normal case here, and "3 weeks ago" is worse than a date for that.
-function when(at: number): string {
- if (!at) return "just now";
- return new Date(at).toLocaleString(undefined, {
- day: "numeric",
- month: "short",
- hour: "2-digit",
- minute: "2-digit",
- });
-}
-
-export default function FeedbackView(): ReactElement {
- const { user, admin, prefs } = useKip();
- // Frozen when the reports land, just before this screen marks them seen:
- // read live, the mark would clear every dot a beat after drawing it, and read
- // on mount it is the cold default on a reload straight into this screen.
- const seenAt = useRef(prefs.feedbackSeenAt);
- seenAt.current = prefs.feedbackSeenAt;
- const [wasSeenAt, setWasSeenAt] = useState(null);
- const [reports, setReports] = useState(null);
- const [problem, setProblem] = useState(null);
- const [busy, setBusy] = useState(false);
-
- const load = useCallback(async () => {
- setBusy(true);
- setProblem(null);
- try {
- const fetched = await fetchFeedback();
- setWasSeenAt(seenAt.current);
- setReports(fetched);
- // Marked seen HERE, not on mount, and only for someone the reports were
- // actually shown to. On mount it fired for anyone who guessed the
- // fragment and was refused every report — clearing their dot for reports
- // they had never been shown, which for the operator meant arriving at a
- // full inbox with nothing marked new. A failed load leaves the mark alone
- // for the same reason: nothing was read.
- const uid = user?.uid;
- if (uid && admin) await markFeedbackSeen(uid);
- } catch (error) {
- console.warn("feedback", error);
- setProblem("Couldn't load these. Try again in a moment.");
- } finally {
- setBusy(false);
- }
- }, [user, admin]);
-
- useEffect(() => {
- void load();
- }, [load]);
-
- // No confirm. A report is one line of somebody's prose that has been read and
- // dealt with, and clearing it is the ordinary end of that — an "are you sure?"
- // on every one of them is a tax on the common case to guard a rare mis-tap.
- async function remove(report: Report): Promise {
- // Dropped locally rather than by reloading: the list is a fetch, and a
- // round trip to learn what this client already knows would blank the screen
- // for the length of it.
- setReports((held) => (held ?? []).filter((one) => one.id !== report.id));
- try {
- await deleteFeedback(report.id);
- } catch (error) {
- console.warn("feedback", error);
- setProblem("That one didn't delete. Reload to see where things stand.");
- }
- }
-
- return (
-
- ) : (
-
- {reports.map((report) => (
- // Not a Row: these are whole paragraphs of someone's prose, and a
- // Row is a fixed-height line with one tap target. Nothing here
- // navigates, so there is no whole-row target to preserve.
-
- {/* Their words, wrapped as typed — a report is often a list of
- steps, and collapsing the newlines loses the order. */}
-
- {report.text}
-
-
- ))}
-
- )}
-
-
- );
-}
diff --git a/web/components/friends-panel.tsx b/web/components/friends-panel.tsx
deleted file mode 100644
index c735067..0000000
--- a/web/components/friends-panel.tsx
+++ /dev/null
@@ -1,256 +0,0 @@
-"use client";
-
-import { type ReactElement, useState } from "react";
-import { LuChevronRight, LuSearch, LuUserPlus } from "react-icons/lu";
-import { findUserByUsername } from "../utils/friends";
-import { useKip } from "../utils/store";
-import type { ConnectRequest, Profile } from "../utils/types";
-import { normalizeUsername, validateUsername } from "../utils/username";
-import Avatar from "./avatar";
-import { useAction } from "./dialog";
-import RequestCard from "./request-card";
-import Button from "./ui/button";
-import FieldNote from "./ui/field-note";
-import Input from "./ui/input";
-import { Group, Row, Section } from "./ui/list";
-
-// How to address an ask you've sent. A handle is the one thing only the by-handle
-// route has, and `toName` is absent on anything written before it existed, so the
-// label falls through what's actually there rather than rendering a bare "@".
-function recipientLabel(request: ConnectRequest): {
- name: string;
- handle: string | null;
-} {
- if (request.toName)
- return { name: request.toName, handle: request.toUsername || null };
- else if (request.toUsername)
- return { name: `@${request.toUsername}`, handle: null };
- else return { name: "Someone", handle: null };
-}
-
-export default function FriendsPanel(): ReactElement {
- const {
- profile,
- friends,
- incomingRequests,
- outgoingRequests,
- sendFriendRequest,
- cancelRequest,
- navigate,
- } = useKip();
-
- const run = useAction();
- const [username, setUsername] = useState("");
- const [status, setStatus] = useState(null);
- const [busy, setBusy] = useState(false);
- const [found, setFound] = useState(null);
-
- // Finding someone is its own step, so you see WHO a handle belongs to before
- // asking them anything. Only possible because a handle that matches means the
- // profile is searchable, and so readable — the name is visible, never indexed:
- // `usernames/{handle}` still maps to a uid and nothing else, and nothing here
- // can be searched by name.
- async function find(): Promise {
- const handle = normalizeUsername(username);
- if (!handle) return;
- const invalid = validateUsername(handle);
- if (invalid) {
- setFound(null);
- setStatus(invalid);
- return;
- }
- setBusy(true);
- setStatus(null);
- setFound(null);
- try {
- const target = await findUserByUsername(handle);
- // A private account reads exactly like one that was never there, which is
- // the point of the discovery gate.
- if (target) setFound(target);
- else setStatus("No kip user with that username.");
- } catch (error) {
- console.error(error);
- setStatus("Something went wrong. Try again.");
- } finally {
- setBusy(false);
- }
- }
-
- async function send(target: Profile): Promise {
- setBusy(true);
- setStatus(null);
- try {
- const result = await sendFriendRequest(target.username);
- const messages: Record = {
- sent: `Request sent to ${target.displayName}.`,
- "not-found": "No kip user with that username.",
- "already-friends": "You're already friends.",
- self: "That's you!",
- };
- setStatus(messages[result]);
- if (result === "sent") {
- setUsername("");
- setFound(null);
- }
- } catch (error) {
- console.error(error);
- setStatus("Something went wrong. Try again.");
- } finally {
- setBusy(false);
- }
- }
-
- // Why there's nothing to send: the store re-checks all of this at send time,
- // but saying so up front beats offering a button that only ever refuses.
- const settled =
- found === null
- ? null
- : found.uid === profile?.uid
- ? "That's you"
- : friends.some((friend) => friend.uid === found.uid)
- ? "Already friends"
- : outgoingRequests.some((request) => request.to === found.uid)
- ? "Already asked"
- : null;
-
- return (
-
- {/* Two asks, two sections: one hands over a set of dates, the other
- hands over everything you share from then on, and a single stack
- made the reader tell them apart by card shape. Stays lead because
- they're the dated ones — a request expires with its nights — and
- because the uncapped list has to sit above the capped one. */}
- {bookingRequests.length > 0 ? (
-
-
- {bookingRequests.map((booking) => (
-
- ))}
-
-
- ) : null}
-
- {/* Uncapped above, capped here: a friend request keeps until you answer
- it and Friends lists every one, whereas someone waiting on specific
- nights can't be sent elsewhere to be found. */}
- {incomingRequests.length > 0 ? (
- REQUEST_PREVIEW ? (
- setView("friends")}
- className="text-sm font-semibold text-accent-ink hover:opacity-80"
- >
- See all {incomingRequests.length}
-
- ) : null
- }
- >
- {incomingRequests.slice(0, REQUEST_PREVIEW).map((request) => (
-
- ))}
-
- ) : null}
-
- {comingUp.length > 0 ? (
- COMING_UP_PREVIEW ? (
- setView("trips")}
- className="text-sm font-semibold text-accent-ink hover:opacity-80"
- >
- See all upcoming
-
- ) : null
- }
- >
-
- {comingUp.slice(0, COMING_UP_PREVIEW).map((booking) => (
-
- ))}
-
-
- ) : null}
-
- {/* Above the general list because it's the same thing narrowed to
- what you actually asked for. */}
- {searchHits.length > 0 ? (
-
-
- {searchHits.map((hit) => (
-
- openSearch(hit.search.id, hit.search.criteria)
- }
- onRemove={() =>
- removeSearch(hit.search.id, hit.search.label)
- }
- />
- ))}
-
-
- ) : null}
-
- 0 ? (
- setView("browse")}
- className="text-sm font-semibold text-accent-ink hover:opacity-80"
- >
- Browse all
-
- ) : null
- }
- >
- {available.length === 0 ? (
-
- {!hasAttention && comingUp.length === 0
- ? "Nothing here yet. Add friends and browse the places they share."
- : "No friends' places free right now."}
-
- );
-}
-
-// An unverified account receives nothing at all, and the only explanation would
-// otherwise sit in Settings, which they have no reason to visit.
-function VerifyEmailPrompt(): ReactElement | null {
- const { user, email, emailVerified, resendVerification } = useKip();
- const [sent, setSent] = useState(false);
- const [failed, setFailed] = useState(false);
-
- if (!user || emailVerified || !email) return null;
-
- async function resend(): Promise {
- setFailed(false);
- try {
- await resendVerification();
- setSent(true);
- } catch (error) {
- console.error(error);
- setFailed(true);
- }
- }
-
- return (
-
-
- {sent
- ? `Confirmation sent to ${email}. Open it, then reload kip.`
- : `Confirm ${email} to get notified about bookings — until you do, kip can't email you.`}
-
- );
-}
diff --git a/web/components/listing-form-screen.tsx b/web/components/listing-form-screen.tsx
deleted file mode 100644
index 967162d..0000000
--- a/web/components/listing-form-screen.tsx
+++ /dev/null
@@ -1,137 +0,0 @@
-"use client";
-
-import { type ReactElement, useEffect, useRef, useState } from "react";
-import { PLACE_KEY } from "../utils/checkout";
-import {
- type ListingInput,
- type NewRoom,
- newListingId,
-} from "../utils/listings";
-import { deleteListingPhoto } from "../utils/photos";
-import { useKip } from "../utils/store";
-import type { ListingPhoto } from "../utils/types";
-import ListingForm from "./listing-form";
-
-// The listing editor as a stacked screen. `id` null mints a new listing (then
-// replaces this screen with the new room page); an id edits in place (then pops
-// back to wherever the editor was opened from).
-export default function ListingFormScreen({
- id,
-}: {
- id: string | null;
-}): ReactElement {
- const {
- user,
- myListings,
- myCheckout,
- createListing,
- updateListing,
- setListingPhotos,
- setCheckout,
- replace,
- back,
- } = useKip();
- const initial = id
- ? myListings.find((listing) => listing.id === id)
- : undefined;
-
- // Held, not re-minted per render: photos upload against it before submit.
- const [draftId] = useState(newListingId);
- const [draftPhotos, setDraftPhotos] = useState([]);
- // Rooms drawn up alongside a new place, created with it. Their photos are
- // already uploaded under the same draft id, so they are abandoned with it too.
- const [draftRooms, setDraftRooms] = useState([]);
- // Read by the abandon cleanup, which must not re-run as either changes.
- const created = useRef(false);
- const uploaded = useRef([]);
- uploaded.current = [
- ...draftPhotos,
- ...draftRooms.flatMap((room) => room.photos),
- ];
-
- // Leaving without submitting strands whatever was uploaded, and nothing else
- // will ever collect it. A closed tab still leaks, which is owner-only and
- // invisible — not worth a beforeunload prompt.
- const ownerId = user?.uid;
- useEffect(() => {
- if (id || !ownerId) return;
- return () => {
- if (created.current) return;
- for (const photo of uploaded.current) {
- deleteListingPhoto(ownerId, draftId, photo.id).catch((error) =>
- console.warn("abandoned photo", error),
- );
- }
- };
- }, [id, ownerId, draftId]);
-
- if (!user) {
- return
You need to be signed in.
;
- }
- if (id && !initial) {
- return
This place isn't available right now.
;
- }
-
- // A place being created has none stored; an existing one's arrive live.
- const storedCheckout = id ? myCheckout[id]?.[PLACE_KEY] : "";
- const checkoutKnown = !id || myCheckout[id] !== undefined;
-
- async function submit(
- input: ListingInput,
- checkout: string | null,
- ): Promise {
- if (id) {
- await updateListing(id, input);
- if (checkout !== null && checkout !== (storedCheckout ?? "")) {
- await setCheckout(id, PLACE_KEY, checkout);
- }
- back();
- } else {
- await createListing(
- draftId,
- input,
- draftPhotos,
- draftRooms,
- checkout ?? "",
- );
- created.current = true;
- replace({ kind: "room", id: draftId });
- }
- }
-
- return (
-
- setDraftRooms((current) =>
- current.some((candidate) => candidate.id === room.id)
- ? current.map((candidate) =>
- candidate.id === room.id ? room : candidate,
- )
- : [...current, room],
- )
- }
- onDropDraftRoom={(room) => {
- setDraftRooms((current) =>
- current.filter((candidate) => candidate.id !== room.id),
- );
- for (const photo of room.photos) {
- deleteListingPhoto(user.uid, draftId, photo.id).catch((error) =>
- console.warn("dropped room photo", error),
- );
- }
- }}
- onSubmit={submit}
- onPhotos={
- id
- ? (photos) => setListingPhotos(id, photos)
- : async (photos) => setDraftPhotos(photos)
- }
- />
- );
-}
diff --git a/web/components/listing-form.tsx b/web/components/listing-form.tsx
deleted file mode 100644
index 1976ef6..0000000
--- a/web/components/listing-form.tsx
+++ /dev/null
@@ -1,337 +0,0 @@
-"use client";
-
-import { type ReactElement, useState } from "react";
-import { LuLoaderCircle, LuMapPin, LuPlus } from "react-icons/lu";
-import { type GeocodeResult, geocodeMatches } from "../utils/geocode";
-import {
- type ListingInput,
- type NewRoom,
- RoomsNotAllowedError,
-} from "../utils/listings";
-import { canHaveRooms, roomList, wholePlaceLabel } from "../utils/rooms";
-import type { Listing, ListingPhoto, ListingType } from "../utils/types";
-import CheckoutField from "./checkout-field";
-import PhotoStrip from "./photo-strip";
-import { DraftRoomSheet, RoomSheet } from "./room-sheet";
-import { RoomRow } from "./rooms";
-import Button from "./ui/button";
-import FieldNote from "./ui/field-note";
-import Input from "./ui/input";
-import { Group } from "./ui/list";
-import Segmented from "./ui/segmented";
-
-type GeoState = "idle" | "searching" | "found" | "notfound";
-
-// The one control `Input` can't be: a textarea grows, so it can't sit at the
-// shared 44px height. Everything else about it is the same white surface, border
-// and focus ring, kept in step by hand.
-const TEXTAREA =
- "w-full rounded-xl border border-border bg-surface px-3.5 text-base outline-none transition focus:border-accent focus:ring-2 focus:ring-accent/20";
-
-const ROOMS_BLOCK_TYPE =
- "Remove this place's rooms before changing it to a room.";
-
-// The listing editor, laid out as a full-screen stacked screen. The parent
-// (ListingFormScreen) wires submit/cancel to the nav stack.
-export default function ListingForm({
- initial,
- ownerId,
- listingId,
- photos,
- checkout,
- draftRooms,
- onSubmit,
- onPhotos,
- onDraftRoom,
- onDropDraftRoom,
-}: {
- initial?: Listing;
- ownerId: string;
- listingId: string;
- photos: readonly ListingPhoto[];
- // The place's stored check-out instructions; undefined until they are known.
- checkout: string | undefined;
- // The rooms of a place not yet created. An existing place's are read off
- // `initial` and edited in place, since they already exist.
- draftRooms: readonly NewRoom[];
- // `checkout` is null when the instructions were left untouched.
- onSubmit: (input: ListingInput, checkout: string | null) => Promise;
- onPhotos: (photos: ListingPhoto[]) => Promise;
- onDraftRoom: (room: NewRoom) => void;
- onDropDraftRoom: (room: NewRoom) => void;
-}): ReactElement {
- const [title, setTitle] = useState(initial?.title ?? "");
- const [type, setType] = useState(initial?.type ?? "ROOM");
- const [description, setDescription] = useState(initial?.description ?? "");
- const [label, setLabel] = useState(initial?.location.label ?? "");
- const hasInitialCoords = Boolean(
- initial && (initial.location.lat || initial.location.lng),
- );
- const [coords, setCoords] = useState<{ lat: number; lng: number } | null>(
- hasInitialCoords && initial
- ? { lat: initial.location.lat, lng: initial.location.lng }
- : null,
- );
- const [geo, setGeo] = useState(hasInitialCoords ? "found" : "idle");
- const [matches, setMatches] = useState([]);
- const [busy, setBusy] = useState(false);
- const [uploading, setUploading] = useState(false);
- // `roomId` null is the sheet that adds one.
- const [openRoom, setOpenRoom] = useState<{ roomId: string | null } | null>(
- null,
- );
- const [typeRefused, setTypeRefused] = useState(false);
- // Null until typed in, so instructions that load after the form opens still
- // show rather than being overwritten by an empty field.
- const [checkoutDraft, setCheckoutDraft] = useState(null);
- const rooms: readonly NewRoom[] = initial ? roomList(initial) : draftRooms;
-
- function chooseType(next: ListingType): void {
- // Refused here rather than at Save, where the reason would arrive late.
- const refused = !canHaveRooms(next) && rooms.length > 0;
- setTypeRefused(refused);
- if (!refused) setType(next);
- }
-
- async function lookup(): Promise {
- if (!label.trim()) return;
- setGeo("searching");
- setMatches([]);
- const results = await geocodeMatches(label);
- setMatches(results);
- setGeo(results.length === 0 ? "notfound" : "idle");
- }
-
- function selectMatch(match: GeocodeResult): void {
- setLabel(match.label);
- setCoords({ lat: match.lat, lng: match.lng });
- setGeo("found");
- setMatches([]);
- }
-
- async function submit(): Promise {
- if (!title.trim() || !label.trim()) return;
- setBusy(true);
- try {
- // Resolve coordinates from the address if the owner didn't pick a match.
- let resolved = coords;
- if (!resolved) {
- const result = (await geocodeMatches(label))[0];
- if (result) resolved = { lat: result.lat, lng: result.lng };
- }
- await onSubmit(
- {
- title: title.trim(),
- type,
- description: description.trim(),
- location: {
- label: label.trim(),
- lat: resolved?.lat ?? 0,
- lng: resolved?.lng ?? 0,
- },
- },
- checkoutDraft === null ? null : checkoutDraft.trim(),
- );
- } catch (error) {
- // A room added in another tab since this form opened.
- if (error instanceof RoomsNotAllowedError) setTypeRefused(true);
- else throw error;
- } finally {
- setBusy(false);
- }
- }
-
- return (
-
- );
-}
diff --git a/web/components/name-gate.tsx b/web/components/name-gate.tsx
deleted file mode 100644
index d6ad31f..0000000
--- a/web/components/name-gate.tsx
+++ /dev/null
@@ -1,409 +0,0 @@
-"use client";
-
-import {
- createContext,
- type ReactElement,
- type ReactNode,
- useCallback,
- useContext,
- useEffect,
- useRef,
- useState,
-} from "react";
-import { FaGoogle } from "react-icons/fa";
-import { PhoneAlreadySet } from "../utils/auth";
-import { auth } from "../utils/firebase";
-import { useKip } from "../utils/store";
-import { validateDisplayName } from "../utils/username";
-import { useDialog } from "./dialog";
-import ReachField, {
- codeReady,
- confirmReach,
- EMPTY_REACH,
- type ReachState,
- reachError,
- sendReach,
-} from "./reach-field";
-import Busy from "./ui/busy";
-import Button from "./ui/button";
-import Input from "./ui/input";
-import Sheet from "./ui/sheet";
-
-type NameGateValue = {
- // Runs an action that will put your name in front of another person,
- // collecting the name first if there isn't one. Nothing is written until the
- // action runs, so dismissing the sheet abandons it and costs nothing.
- runNamed: (action: () => Promise, label: string) => Promise;
- // Opens the same sheet with nothing held, for the surfaces that ask someone to
- // finish rather than to do something — the hosting card, the reach card.
- askIdentity: () => void;
-};
-
-const NameGateContext = createContext(null);
-
-// Said the same way wherever a link turns out to be a sign-in: the sheet here,
-// and the Settings rows that add a way in. It names the ONE thing nobody can
-// fix from inside kip — there is no merge — so it has to end with a choice
-// rather than an apology.
-export function otherAccountAlert(door: string): {
- title: string;
- body: string;
-} {
- return {
- title: "You're in your other account",
- body: `That ${door} was already on kip, so you're signed into it now. kip can't combine two accounts — keep whichever has more history, and delete the other from its own Settings. Whatever you were doing, start it again from here.`,
- };
-}
-
-// Routes that own their identity flow. Matched on a trailing segment so the
-// base path, when there is one, doesn't change the answer.
-function ownRoute(pathname: string): boolean {
- return /\/(portal|continue)\/?$/.test(pathname);
-}
-
-export function useNameGate(): NameGateValue {
- const ctx = useContext(NameGateContext);
- if (!ctx) throw new Error("useNameGate must be used within NameGateProvider");
- return ctx;
-}
-
-// One sheet that collects whatever is missing — a name, a way to be reached, or
-// both — so no surface has to know which of the two a person lacks.
-//
-// The portal page keeps its own copy of this pattern rather than calling in: its
-// hold reports failures into `debug` and races a timeout, neither of which the
-// in-app verbs need. Two implementations of one WRITTEN-DOWN pattern is fine;
-// two patterns is not.
-export default function NameGateProvider({
- children,
-}: {
- children: ReactNode;
-}): ReactElement {
- const {
- user,
- profile,
- profileReady,
- anonymous,
- deletion,
- email,
- signIn,
- completeOnboarding,
- } = useKip();
- const { alert } = useDialog();
- const [held, setHeld] = useState<{
- action: () => Promise;
- label: string;
- unprompted: boolean;
- } | null>(null);
- const [name, setName] = useState("");
- const [reach, setReach] = useState(EMPTY_REACH);
- const recaptcha = useRef(null);
- const [busy, setBusy] = useState(false);
- const [sentTo, setSentTo] = useState(null);
- const [error, setError] = useState(null);
- // The uid whose unprompted sheet was dismissed.
- const [dismissedFor, setDismissedFor] = useState(null);
-
- const open = useCallback(
- (action: () => Promise, label: string, unprompted = false) => {
- setName("");
- setReach(EMPTY_REACH);
- setSentTo(null);
- setError(null);
- setHeld({ action, label, unprompted });
- },
- [],
- );
-
- // A brand-new credentialed account — someone continuing on a new device, or a
- // Google arrival carrying no name — has no ask to hold and no friend request
- // to accept, so nothing else would ever open this. Offered once per account:
- // dismissing it sticks, and the next action that needs a name asks again
- // through `runNamed`.
- useEffect(() => {
- // Only on the app's own routes. `/portal/` and `/continue/` render their own
- // sheets and reach that state legitimately — a Google sign-in returning no
- // name, a phone sign-in into a profileless account — where this would stack
- // a second sheet over theirs.
- if (typeof window !== "undefined" && ownRoute(window.location.pathname)) {
- return;
- }
- // A teardown deletes the profile as its second-to-last act, so an account
- // on its way out reaches exactly this state — and asked the person who had
- // just left what to call them, over the screen saying they were leaving.
- if (deletion) return;
- if (!profileReady || anonymous || profile?.displayName || held) return;
- if (!user || dismissedFor === user.uid) return;
- open(async () => undefined, "Continue", true);
- }, [
- profileReady,
- anonymous,
- deletion,
- profile,
- held,
- open,
- user,
- dismissedFor,
- ]);
-
- const dismiss = useCallback(() => {
- setHeld(null);
- setDismissedFor(user?.uid ?? null);
- }, [user]);
-
- const askIdentity = useCallback(
- () => open(async () => undefined, "Continue"),
- [open],
- );
-
- const runNamed = useCallback(
- async (action: () => Promise, label: string): Promise => {
- if (profile?.displayName) {
- await action();
- return;
- }
- open(action, label);
- },
- [profile, open],
- );
-
- const needsName = !profile?.displayName;
- // Credentialed is not the same as reachable: a phone-only account has a way
- // back in and still no address kip can write to, and gating on `anonymous`
- // handed exactly that person a sheet with nothing in it.
- const needsReach = anonymous || !email;
- const reachInvalid = reachError(reach.raw);
-
- // The name can arrive some other way (another tab, a sign-in carrying one),
- // leaving an unprompted sheet with nothing to ask.
- useEffect(() => {
- if (held?.unprompted && !needsName && !needsReach && !busy) setHeld(null);
- }, [held, needsName, needsReach, busy]);
- const invalid = name && needsName ? validateDisplayName(name) : null;
- const problem = invalid ?? reachInvalid ?? error;
- const message = "Only so kip can reach you. Nobody else sees it.";
-
- // Two failures with two different remedies — correct an address, or retry a
- // write — so they are caught separately. One catch around both blamed the
- // address for a Firestore outage, sometimes under a note saying that same
- // address had just worked.
- // Google identifies you outright, so there is nothing to prove afterwards and
- // no name to type — it carries one. The held action runs straight after.
- async function googleAttach(): Promise {
- if (!held) return;
- setBusy(true);
- setError(null);
- try {
- const { sameAccount } = await signIn();
- // That Google account already existed and they are in it now. It has its
- // own name and photo, which this sheet's must not overwrite, and the held
- // action belongs to a uid they have just left — so neither runs, and the
- // sheet stays up to say why rather than vanishing.
- if (!sameAccount) {
- // Closed, not merely explained. The held action belongs to the uid they
- // just left, and for the account they landed in both fields compute
- // away — leaving a sentence, a dead submit and a divider.
- setHeld(null);
- await alert(otherAccountAlert("Google account"));
- return;
- }
- const known = auth().currentUser?.displayName?.trim();
- if (needsName && known) await completeOnboarding(known);
- else if (needsName) {
- setError("Google didn't share a name. Type one.");
- return;
- }
- await held.action();
- setHeld(null);
- } catch (caught) {
- console.error(caught);
- setError("That didn't work. Try again, or use email.");
- } finally {
- setBusy(false);
- }
- }
-
- async function submit(): Promise {
- if (!held || reachInvalid || sentTo) return;
- if (needsName && validateDisplayName(name)) return;
- setBusy(true);
- setError(null);
- let emailed: string | null = null;
-
- // A code that has been sent is waiting to be typed, so this submit finishes
- // that rather than starting again.
- if (reach.pending) {
- try {
- const { sameAccount } = await confirmReach(reach.pending, reach.code);
- // The number belonged to an account they already had, so they are now
- // IN it — and it has its own name and photo. Writing the sheet's over
- // them would be destructive, and the held action belongs to a uid they
- // are no longer, so neither runs.
- if (!sameAccount) {
- setBusy(false);
- setHeld(null);
- await alert(otherAccountAlert("number"));
- return;
- }
- } catch (caught) {
- console.error(caught);
- setError("Wrong code. Check it, or ask for another.");
- setBusy(false);
- return;
- }
- } else if (reach.raw) {
- try {
- // First, while this sheet is still mounted to report it: writing the
- // profile can close it, and a send awaited afterwards could neither echo
- // the address back nor show a failure to anyone.
- const holder = recaptcha.current;
- if (!holder) throw new Error("no element for the check to bind to");
- // No host: this reach was collected in Settings or beside an accept,
- // where there is no request for the landing page to name.
- const sent = await sendReach(reach.raw, "", holder);
- // Recorded either way: a code needs the second step, and an emailed
- // link needs the confirmation panel to have something to name.
- setReach({ ...reach, pending: sent.pending, sentTo: sent.sentTo });
- if (sent.pending) {
- // The phone door has a second step, so nothing else runs yet.
- setBusy(false);
- return;
- }
- emailed = sent.sentTo;
- } catch (caught) {
- console.error(caught);
- setError(
- caught instanceof PhoneAlreadySet
- ? "This account has a number. Add an email."
- : "Couldn't send that. Check it, or clear it.",
- );
- setBusy(false);
- return;
- }
- }
-
- try {
- // Before the action, because the rules read the COMMITTED profile: an edge
- // write is pinned against the name Firestore holds, not the one in hand.
- if (needsName) await completeOnboarding(name.trim());
- await held.action();
- } catch (caught) {
- console.error(caught);
- setError("Couldn't save that. Check your connection.");
- setBusy(false);
- return;
- }
-
- setBusy(false);
- // An emailed link is still to be opened, so the sheet says so. A code has
- // been typed by now, and a bare name has nothing left to report.
- if (emailed) setSentTo(emailed);
- else setHeld(null);
- }
-
- return (
-
- {children}
-
- {/* Once the email is away the form has nothing left to do, and leaving
- it submittable would re-run a held action whose request may already
- be gone — painting an error over a flow that worked. */}
- {sentTo ? (
-
-
- Check {sentTo} and open the link — that's what keeps your kip if
- you switch phones. Nothing is waiting on it; you can close this.
-
- setHeld(null)}>
- Done
-
-
- ) : (
-
- )}
-
-
- );
-}
diff --git a/web/components/nav.tsx b/web/components/nav.tsx
deleted file mode 100644
index 3b4bc70..0000000
--- a/web/components/nav.tsx
+++ /dev/null
@@ -1,143 +0,0 @@
-"use client";
-
-import type { ReactElement, ReactNode } from "react";
-import {
- LuDoorOpen,
- LuHouse,
- LuLuggage,
- LuSearch,
- LuUsers,
-} from "react-icons/lu";
-import { useKip } from "../utils/store";
-import type { View } from "../utils/types";
-import AuthMenu from "./auth-menu";
-import ThemeButton from "./theme-button";
-import { CountBadge } from "./ui/chip";
-import Wordmark from "./wordmark";
-
-type NavItem = {
- readonly view: View;
- readonly label: string;
- readonly icon: ReactNode;
- readonly badge: number;
-};
-
-// The primary destinations, shared by the desktop top bar and the mobile dock.
-// Settings lives in the profile menu (AuthMenu) instead, to keep it to five
-// thumb-sized tabs.
-function useNavItems(): NavItem[] {
- const { incomingRequests, incomingBookings } = useKip();
- const pendingBookings = incomingBookings.filter(
- (booking) => booking.status === "REQUESTED",
- ).length;
-
- return [
- { view: "home", label: "Home", icon: , badge: 0 },
- { view: "browse", label: "Browse", icon: , badge: 0 },
- {
- view: "places",
- label: "Places",
- icon: ,
- // Stays people are asking for. Friend asks are counted on Friends, not
- // here — they were double-counted when the two request kinds merged names.
- badge: pendingBookings,
- },
- { view: "trips", label: "Trips", icon: , badge: 0 },
- {
- view: "friends",
- label: "Friends",
- icon: ,
- badge: incomingRequests.length,
- },
- ];
-}
-
-// Desktop: a sticky top app bar replacing the old left sidebar. Wordmark left,
-// nav as inline pill links (active = tonal accent pill), avatar right. Canvas
-// background with a blur so content scrolls under it.
-export function TopBar(): ReactElement {
- const { view, setView } = useKip();
- const items = useNavItems();
-
- return (
-
-
- ) : null}
-
- {/* The one route in for two people who met through a link. */}
- {!friend && !incoming && !outgoing && sharedStay ? (
- run(askToConnect)}
- disabled={asking}
- className="w-full"
- >
- Ask to be friends
-
- ) : null}
- >
- )}
-
- {upcomingStays.length > 0 ? (
-
-
- {upcomingStays.map((stay) => (
-
- ))}
-
-
- ) : null}
-
- {/* Their trips generally, as against "Stays" above, which is only the ones
- involving you. Capped: this is a glance at what they're up to, and
- Trips is nobody's second inbox. */}
- {elsewhere.length > 0 ? (
-
-
- {elsewhere.slice(0, ELSEWHERE_PREVIEW).map((stay) => (
-
- ))}
-
-
- ) : null}
-
- {pastStays.length > 0 ? (
-
-
- {pastStays.map((stay) => (
-
- ))}
-
-
- ) : null}
-
-
- {!isSelf && !friend ? (
- // An empty list would claim they share nothing, when the truth is that
- // you can't see.
-
- Only friends can see the places {firstName} shares.
-
- ) : rooms.length === 0 ? (
-
- {isSelf
- ? "You're not sharing any places yet."
- : `${firstName} isn't sharing any places right now.`}
-
- ) : (
-
- {rooms.map((room) => (
-
-
-
- ))}
-
- )}
-
-
- {!isSelf && friend ? (
-
-
- Remove friend
-
-
- ) : null}
-
- );
-}
diff --git a/web/components/photo-strip.tsx b/web/components/photo-strip.tsx
deleted file mode 100644
index e4a8d6f..0000000
--- a/web/components/photo-strip.tsx
+++ /dev/null
@@ -1,373 +0,0 @@
-"use client";
-
-import {
- type DragEvent as ReactDragEvent,
- type ReactElement,
- useEffect,
- useRef,
- useState,
-} from "react";
-import {
- LuCamera,
- LuChevronLeft,
- LuChevronRight,
- LuLoaderCircle,
- LuTrash2,
-} from "react-icons/lu";
-import {
- deleteListingPhoto,
- MAX_PHOTOS,
- PhotoEncodeError,
- photoSrc,
- uploadListingPhoto,
-} from "../utils/photos";
-import type { ListingPhoto } from "../utils/types";
-import { useRailFade } from "./cover-photo";
-
-// A drag exposes its `types` but not its data until the drop, so a reorder needs
-// a type of its own to be told apart from a file drop.
-const PHOTO_DRAG_TYPE = "application/x-kip-photo";
-
-function isPhotoDrag(event: ReactDragEvent): boolean {
- return event.dataTransfer.types.includes(PHOTO_DRAG_TYPE);
-}
-
-// On top of a photo, so it carries its own contrast rather than a surface token.
-const OVERLAY_CONTROL =
- "grid h-7 w-7 place-items-center rounded-full bg-black/55 text-white transition hover:bg-black/75 disabled:opacity-50";
-
-// Removing first is what makes a rightward move land after its target.
-function reordered(
- photos: readonly ListingPhoto[],
- photoId: string,
- to: number,
-): ListingPhoto[] {
- const moved = photos.filter((photo) => photo.id === photoId);
- const rest = photos.filter((photo) => photo.id !== photoId);
- return [...rest.slice(0, to), ...moved, ...rest.slice(to)];
-}
-
-// Order is user-visible: the first photo is the cover everywhere it appears.
-export default function PhotoStrip({
- ownerId,
- listingId,
- photos,
- editable = false,
- onChange,
- onBusyChange,
-}: {
- ownerId: string;
- listingId: string;
- photos: readonly ListingPhoto[];
- editable?: boolean;
- onChange?: (photos: ListingPhoto[]) => Promise;
- // Lets a form refuse to submit mid-upload, which would drop the photo in
- // flight with nothing on screen to say so.
- onBusyChange?: (busy: boolean) => void;
-}): ReactElement | null {
- const [busy, setBusy] = useState(false);
- const [error, setError] = useState(null);
- const [dropping, setDropping] = useState(false);
- // Shown before it's stored, so the strip moves under the finger rather than
- // after the round trip.
- const [pendingOrder, setPendingOrder] = useState<
- readonly ListingPhoto[] | null
- >(null);
- const [draggingId, setDraggingId] = useState(null);
- const [overId, setOverId] = useState(null);
- const fileInput = useRef(null);
- // A full strip runs past a phone's width, and a hard edge would read as
- // "that's all of them".
- const { ref: strip, maskImage } = useRailFade(photos.length);
-
- const order = pendingOrder ?? photos;
-
- // A file dropped outside a drop target navigates the browser to it, replacing
- // the app. The strip's own handlers still see their drop first, by bubbling.
- useEffect(() => {
- if (!editable) return;
- function swallow(event: DragEvent): void {
- event.preventDefault();
- }
- window.addEventListener("dragover", swallow);
- window.addEventListener("drop", swallow);
- return () => {
- window.removeEventListener("dragover", swallow);
- window.removeEventListener("drop", swallow);
- };
- }, [editable]);
-
- useEffect(() => {
- onBusyChange?.(busy);
- }, [busy, onBusyChange]);
-
- if (!editable && photos.length === 0) return null;
-
- const full = photos.length >= MAX_PHOTOS;
- const accepting = editable && !full && !busy;
- // A write in flight takes the affordances out of service, not off the screen.
- const sortable = editable && onChange !== undefined && order.length > 1;
-
- // Both the picker and a drop land here, so the cap and the image filter are
- // applied once — `accept` is only a hint, and pickers let anything through.
- async function add(files: readonly File[]): Promise {
- const images = files.filter((file) => file.type.startsWith("image/"));
- if (images.length === 0 || !onChange) return;
- setBusy(true);
- setError(null);
- try {
- const room = MAX_PHOTOS - photos.length;
- const added: ListingPhoto[] = [];
- for (const file of images.slice(0, room)) {
- // Upload first: a listing pointing at a missing object renders a
- // permanent gap, where an orphaned object is merely invisible.
- added.push(await uploadListingPhoto(ownerId, listingId, file));
- }
- if (added.length > 0) await onChange([...photos, ...added]);
- } catch (caught) {
- console.error(caught);
- setError(
- caught instanceof PhotoEncodeError
- ? "kip couldn't read that image. Try a different photo."
- : "That didn't upload. Check your connection and try again.",
- );
- } finally {
- setBusy(false);
- if (fileInput.current) fileInput.current.value = "";
- }
- }
-
- async function remove(photoId: string): Promise {
- if (!onChange) return;
- setBusy(true);
- setError(null);
- try {
- // Drop the photo first here — the reverse of adding. If deleting the object
- // then fails, the photo is already invisible, which is what was asked for.
- await onChange(photos.filter((photo) => photo.id !== photoId));
- await deleteListingPhoto(ownerId, listingId, photoId);
- } catch (caught) {
- console.error(caught);
- setError("Couldn't remove that photo. Try again.");
- } finally {
- setBusy(false);
- }
- }
-
- // Both routes to a new order — a drop and the arrows — land here. The strip
- // shows the result immediately and hands `pendingOrder` back afterwards: on
- // success the listener already carries the same order, and on failure the
- // stored one is still the truth, so either way the prop is what to render.
- async function reorder(next: readonly ListingPhoto[]): Promise {
- if (!onChange) return;
- setPendingOrder(next);
- setBusy(true);
- setError(null);
- try {
- await onChange([...next]);
- } catch (caught) {
- console.error(caught);
- setError("Couldn't save that order. Try again.");
- } finally {
- setPendingOrder(null);
- setBusy(false);
- }
- }
-
- function move(photoId: string, delta: number): void {
- const to = order.findIndex((photo) => photo.id === photoId) + delta;
- if (to < 0 || to >= order.length) return;
- reorder(reordered(order, photoId, to)).catch((caught) =>
- console.error("reorder", caught),
- );
- }
-
- function onDrop(event: ReactDragEvent): void {
- event.preventDefault();
- setDropping(false);
- if (!accepting) return;
- add(Array.from(event.dataTransfer.files)).catch((caught) =>
- console.error("add", caught),
- );
- }
-
- return (
-
- {/* biome-ignore lint/a11y/noStaticElementInteractions: dropping is an
- addition to the picker button below, which stays keyboard-reachable */}
-
{
- // Only files: a photo being dragged within the strip, or text, or a
- // link, must not arm the target for an upload.
- if (accepting && event.dataTransfer.types.includes("Files")) {
- setDropping(true);
- }
- }}
- onDragOver={(event) => {
- // Without cancelling the drag-over the browser never fires a drop here.
- // A photo drag is the thumbnails' business, so it is left alone: one
- // released between them carries no files and lands as a no-op rather
- // than being taken for an upload.
- if (!accepting || isPhotoDrag(event)) return;
- event.preventDefault();
- event.dataTransfer.dropEffect = "copy";
- }}
- // Crossing a child fires dragleave for the one being left, so ignore any
- // whose destination is still inside the strip (null = left the window).
- onDragLeave={(event) => {
- const next = event.relatedTarget;
- if (!(next instanceof Node) || !event.currentTarget.contains(next)) {
- setDropping(false);
- setOverId(null);
- }
- }}
- onDrop={onDrop}
- // The border is always there, transparent, so arming the target can't
- // shift the row by a pixel.
- className={`flex gap-2 overflow-x-auto rounded-2xl border px-1 py-1 transition ${
- dropping ? "border-accent bg-accent-soft" : "border-transparent"
- }`}
- >
- {order.map(({ id: photoId, url }, index) => (
- /* biome-ignore lint/a11y/noStaticElementInteractions: dragging is an
- addition to the arrow buttons below, which are keyboard-reachable */
-
{
- // The id rides in a type of our own so a drop anywhere else reads
- // as nothing, and so the file-drop handlers can ignore it.
- event.dataTransfer.setData(PHOTO_DRAG_TYPE, photoId);
- event.dataTransfer.effectAllowed = "move";
- setDraggingId(photoId);
- }}
- onDragEnd={() => {
- setDraggingId(null);
- setOverId(null);
- }}
- onDragOver={(event) => {
- if (!sortable || busy || !isPhotoDrag(event)) return;
- event.preventDefault();
- event.dataTransfer.dropEffect = "move";
- setOverId(photoId);
- }}
- onDrop={(event) => {
- if (!isPhotoDrag(event)) return;
- event.preventDefault();
- // The strip's own drop handler adds files; this one is already
- // dealt with, so don't let it get there.
- event.stopPropagation();
- setOverId(null);
- setDraggingId(null);
- const dragged = event.dataTransfer.getData(PHOTO_DRAG_TYPE);
- if (dragged && dragged !== photoId) {
- reorder(reordered(order, dragged, index)).catch((caught) =>
- console.error("reorder", caught),
- );
- }
- }}
- className={`relative h-24 w-24 shrink-0 overflow-hidden rounded-2xl bg-surface-muted transition ${
- draggingId === photoId ? "opacity-40" : ""
- } ${overId === photoId && draggingId !== photoId ? "ring-2 ring-accent" : ""}`}
- >
- {/* biome-ignore lint/performance/noImgElement: static export, no next/image loader */}
-
- {editable && index === 0 ? (
-
- Cover
-
- ) : null}
- {editable ? (
- remove(photoId)}
- disabled={busy}
- aria-label="Remove photo"
- className={`absolute right-1 top-1 ${OVERLAY_CONTROL}`}
- >
-
-
- ) : null}
- {sortable && index > 0 ? (
- move(photoId, -1)}
- disabled={busy}
- aria-label={`Move photo ${index + 1} left`}
- className={`absolute bottom-1 left-1 ${OVERLAY_CONTROL}`}
- >
-
-
- ) : null}
- {sortable && index < order.length - 1 ? (
- move(photoId, 1)}
- disabled={busy}
- aria-label={`Move photo ${index + 1} right`}
- className={`absolute bottom-1 right-1 ${OVERLAY_CONTROL}`}
- >
-
-
- ) : null}
-
-
- );
-}
diff --git a/web/components/places-view.tsx b/web/components/places-view.tsx
deleted file mode 100644
index 755839d..0000000
--- a/web/components/places-view.tsx
+++ /dev/null
@@ -1,170 +0,0 @@
-"use client";
-
-import type { ReactElement } from "react";
-import { LuChevronRight, LuPlus } from "react-icons/lu";
-import { todayIso } from "../utils/format";
-import { listingTypeIcon, listingTypeLabel } from "../utils/listings";
-import { useKip } from "../utils/store";
-import type { Listing } from "../utils/types";
-import BookingRow from "./booking-row";
-import CoverPhoto from "./cover-photo";
-import { useNameGate } from "./name-gate";
-import Button from "./ui/button";
-import Chip from "./ui/chip";
-import { Group, Row, Section } from "./ui/list";
-
-function ListingRow({ listing }: { listing: Listing }): ReactElement {
- const { myWindows, incomingBookings, navigate } = useKip();
- const windows = myWindows[listing.id] ?? [];
- const open = windows.filter((window) => window.status === "OPEN").length;
- const booked = windows.filter((window) => window.status === "BOOKED").length;
- // The same boundary the list below uses: an ask for dates that have gone can
- // never be confirmed, so counting it here would promise a request the section
- // no longer holds.
- const today = todayIso();
- const pending = incomingBookings.filter(
- (booking) =>
- booking.listingId === listing.id &&
- booking.status === "REQUESTED" &&
- booking.end >= today,
- ).length;
-
- const meta = [
- listingTypeLabel(listing.type),
- `${open} open`,
- booked > 0 ? `${booked} booked` : null,
- ]
- .filter(Boolean)
- .join(" · ");
-
- const TypeIcon = listingTypeIcon(listing.type);
- return (
- navigate({ kind: "room", id: listing.id })}
- ariaLabel={listing.title}
- >
- {/* A place you'd recognise on sight beats a bed icon, so the cover takes
- the leading slot when there is one. Same 40px square either way — the
- row keeps its rhythm whether or not a place has photos. */}
-
-
-
- }
- />
-
-
- {listing.title}
-
- {meta}
-
- {pending > 0 ? (
-
- {pending} {pending === 1 ? "request" : "requests"}
-
- ) : null}
-
-
- );
-}
-
-export default function PlacesView(): ReactElement | null {
- const { user, anonymous, myListings, incomingBookings, navigate } = useKip();
- const { askIdentity } = useNameGate();
-
- if (!user) {
- return null;
- }
-
- // Places is the host's side of kip, so it carries the two things that happen
- // TO your places: someone waiting on an answer, and someone arriving. Home
- // shows these too, mixed in with your own trips and connect requests — it's the
- // digest you land on. This is the tab that owns places, and an ask about one of
- // them had no route from here at all; a per-place count on the row above says
- // which place, and these say who and when.
- const today = todayIso();
- const pendingAsks = incomingBookings
- .filter((booking) => booking.status === "REQUESTED" && booking.end >= today)
- .sort((left, right) => left.start.localeCompare(right.start));
- const upcomingGuests = incomingBookings
- .filter((booking) => booking.status === "CONFIRMED" && booking.end >= today)
- .sort((left, right) => left.start.localeCompare(right.start));
-
- return (
-
- 0 && !anonymous ? (
- navigate({ kind: "listing-form", id: null })}
- className="inline-flex items-center gap-1.5 text-sm font-semibold text-accent-ink hover:opacity-80"
- >
-
- Add place
-
- ) : null
- }
- >
- {/* Hosting is the one thing a browser-local account can't do, and the
- reason belongs to the guest rather than to us: a host who can't be
- reached leaves people holding stays nobody can call off. Mirrors the
- rule; the rule is the enforcement. */}
- {anonymous ? (
-
-
- Hosting starts with an email — your guests need a host kip can
- reach. Add yours and you can list a place.
-
-
- Add email
-
-
- ) : myListings.length === 0 ? (
-
-
- You haven't listed anything yet. Add a room you're not using or
- your whole place while you're away.
-
- );
-}
diff --git a/web/components/reach-field.tsx b/web/components/reach-field.tsx
deleted file mode 100644
index d9f0316..0000000
--- a/web/components/reach-field.tsx
+++ /dev/null
@@ -1,209 +0,0 @@
-"use client";
-
-import type { ConfirmationResult } from "firebase/auth";
-import type { ReactElement, RefObject } from "react";
-import {
- confirmPhoneCode,
- sendAttachLink,
- sendPhoneCode,
- sendReturnLink,
-} from "../utils/auth";
-import { isForeignNumber, parseDestination } from "../utils/destination";
-import { auth } from "../utils/firebase";
-import CodeInput, { CODE_LENGTH } from "./ui/code-input";
-import Input from "./ui/input";
-
-// One field for both doors, shared by the two sheets that collect a way to
-// reach someone. The Segmented sets only the keyboard and the autofill hint —
-// `parseDestination` decides the route from what was actually typed, so being in
-// the "wrong" mode can never turn a good address into an error.
-//
-// Email defaults because the failure modes are asymmetric: a number typed on an
-// email keyboard is awkward, an address typed on a numeric keypad is impossible.
-export type ReachState = {
- raw: string;
- mode: "email" | "phone";
- pending: ConfirmationResult | null;
- code: string;
- sentTo: string | null;
-};
-
-export const EMPTY_REACH: ReachState = {
- raw: "",
- mode: "email",
- pending: null,
- code: "",
- sentTo: null,
-};
-
-// `only` is the caller saying its surface names one door — the Settings row that
-// adds a number — so an address typed into it is a mistake rather than the other
-// route, and saying "or an email address" there would offer something the sheet
-// around it cannot do.
-export function reachError(raw: string, only?: "phone"): string | null {
- if (!raw) return null;
- const parsed = parseDestination(raw);
- if (only && parsed.kind === "email") return "That's an email, not a number.";
- if (parsed.kind !== "unknown") return null;
- if (isForeignNumber(raw)) {
- return "US numbers only — email works anywhere.";
- }
- return only
- ? "That's not a US phone number."
- : "That's not an email or a US number.";
-}
-
-// Sends whichever kind was typed. Email returns immediately (the link lands in
-// an inbox); phone hands back a pending confirmation the caller must finish with
-// a code, which is the one asymmetry between the doors that reaches the UI.
-//
-// `mode` is the only thing that differs between attaching an identity to the
-// account already asking and coming back to one you already have — and only for
-// email, since a texted code signs in or links off the same call. Parameterised
-// rather than duplicated: the two paths drifting is precisely how phone ended up
-// offered on one and not the other.
-export async function sendReach(
- raw: string,
- host: string,
- container: HTMLElement,
- mode: "attach" | "return" = "attach",
-): Promise<{ pending: ConfirmationResult | null; sentTo: string }> {
- const parsed = parseDestination(raw);
- if (parsed.kind === "email") {
- if (mode === "return") await sendReturnLink(parsed.value);
- else await sendAttachLink(parsed.value, host);
- return { pending: null, sentTo: parsed.value };
- }
- if (parsed.kind === "phone") {
- const wasUid = auth().currentUser?.uid ?? null;
- const pending = await sendPhoneCode(parsed.value, container);
- // Stashed on the object so the confirm step can tell whether the uid
- // survived without threading it through the sheet's state.
- return {
- pending: Object.assign(pending, { wasUid }),
- sentTo: parsed.value,
- };
- }
- throw new Error("nothing to send to");
-}
-
-// The one place that says what a finished code looks like. Four surfaces gate a
-// button on it, and each of them used to ask whether `code` was non-empty —
-// which offers to send a single digit, and now also disagrees with the field,
-// since it submits itself on the sixth.
-export function codeReady(state: ReachState): boolean {
- return state.code.length === CODE_LENGTH;
-}
-
-export async function confirmReach(
- pending: ConfirmationResult,
- code: string,
-): Promise<{ sameAccount: boolean }> {
- const wasUid = (pending as { wasUid?: string | null }).wasUid ?? null;
- return confirmPhoneCode(pending, code, wasUid);
-}
-
-export default function ReachField({
- state,
- onChange,
- hostRef,
- only,
- invalid = false,
- busy = false,
-}: {
- state: ReachState;
- onChange: (next: ReachState) => void;
- // Owned by the caller because the SEND needs it, and the send lives there.
- hostRef: RefObject;
- // Pins the field to one door and drops the offer of the other. See
- // `reachError`.
- only?: "phone";
- // Reddens whichever step's box is up, so it and the message the caller is
- // showing read as one object. The caller owns it because the caller owns the
- // message: what is wrong may be the typed address (`reachError`) or the send
- // that followed it, and only the caller has both.
- invalid?: boolean;
- // Passed straight through to the code step, where it freezes the field. Every
- // caller already holds it for its own button; see `CodeInput`.
- busy?: boolean;
-}): ReactElement {
- // The code step replaces the field rather than sitting under it: at that point
- // the number is settled and the only thing left to type is six digits. It
- // carries the SAME message line, or a refused code has nowhere to be said and
- // the sheet just stops.
- if (state.pending) {
- return (
- <>
- {/* The message line above describes the PREVIOUS step and cannot know
- about this one, so the code step names its own: a bare six-digit box
- with nothing saying anything was texted, or where to, was the one
- step the restructure was meant to make legible. */}
-
- We texted a code to {state.sentTo}. Standard message rates apply.
-
- onChange({ ...state, code })}
- />
- {/* A code that never arrives must not trap the ask. Backing out returns
- to the field with everything else intact, so the request can still
- go with no way to be reached — which is what "optional" has to mean
- for it to be true. */}
- onChange({ ...EMPTY_REACH, mode: state.mode })}
- className="self-start text-sm font-semibold text-accent-ink hover:opacity-80"
- >
- {only
- ? "Didn't get it? Start over"
- : "Didn't get it? Use something else"}
-
- >
- );
- }
-
- const phoneMode = state.mode === "phone";
-
- return (
- <>
- {/* The switch lives in the field's tail and only while the field is
- empty: everything it does — keyboard, autofill hint, placeholder — is
- settled at focus, before a character exists. Words rather than an icon
- because a stranger on a share link meets this once, and naming the
- ALTERNATIVE removes the is-this-a-state-or-an-action ambiguity every
- icon toggle carries. */}
- onChange({ ...state, raw: event.target.value })}
- placeholder={phoneMode ? "(415) 555-0123" : "you@example.com"}
- aria-label={phoneMode ? "Phone number" : "Email"}
- wideSuffix
- suffix={
- state.raw || only ? undefined : (
-
- onChange({ ...state, mode: phoneMode ? "email" : "phone" })
- }
- className="text-sm font-semibold text-accent-ink hover:opacity-80"
- >
- {phoneMode ? "Use email" : "Use phone"}
-
- )
- }
- />
-
- {/* Invisible reCAPTCHA still binds to a real element, so one has to exist
- before the send rather than being conjured during it. Out of flow: at
- zero height it still collected a full gap from the column, which is
- 12px of nothing between the field and the button. */}
-
- >
- );
-}
diff --git a/web/components/request-card.tsx b/web/components/request-card.tsx
deleted file mode 100644
index 267c1ab..0000000
--- a/web/components/request-card.tsx
+++ /dev/null
@@ -1,257 +0,0 @@
-"use client";
-
-import { type ReactElement, useState } from "react";
-import { formatDateRange } from "../utils/format";
-import { useKip } from "../utils/store";
-import type {
- AvailabilityWindow,
- ConnectRequest,
- Listing,
- Room,
-} from "../utils/types";
-import Avatar from "./avatar";
-import { useAction, useDialog, useFailure } from "./dialog";
-import { useNameGate } from "./name-gate";
-import Button from "./ui/button";
-import Chip from "./ui/chip";
-
-// From your own live state, not the portal doc: you own every link that can
-// reach you, so a token you don't hold is one already revoked.
-type LinkTarget =
- | { readonly scope: "USER" }
- | { readonly scope: "LISTING"; readonly listing: Listing }
- | { readonly scope: "ROOM"; readonly listing: Listing; readonly room: Room }
- | {
- readonly scope: "SLOT";
- readonly listing: Listing;
- readonly window: AvailabilityWindow;
- };
-
-function findLink(
- portalId: string | null,
- profilePortalId: string | null,
- listings: readonly Listing[],
- windowsByListing: Readonly>,
-): LinkTarget | null {
- if (!portalId) return null;
- if (portalId === profilePortalId) return { scope: "USER" };
- const room = listings.find((listing) => listing.publicPortalId === portalId);
- if (room) return { scope: "LISTING", listing: room };
- for (const listing of listings) {
- const inside = Object.values(listing.rooms).find(
- (candidate) => candidate.publicPortalId === portalId,
- );
- if (inside) return { scope: "ROOM", listing, room: inside };
- const window = (windowsByListing[listing.id] ?? []).find(
- (candidate) => candidate.publicPortalId === portalId,
- );
- if (window) return { scope: "SLOT", listing, window };
- }
- return null;
-}
-
-// Asking to STAY is a different thing and renders as a BookingRow. A link-borne
-// request says so, since how they reached you is the main thing you need to
-// answer — and the link is the only way to stop them asking again. The person
-// taps through, because for a stranger from a link this card is the only place
-// they appear at all.
-export default function RequestCard({
- request,
-}: {
- request: ConnectRequest;
-}): ReactElement {
- const {
- acceptRequest,
- declineRequest,
- prefs,
- myListings,
- myWindows,
- navigate,
- screen,
- revokeUserPortal,
- revokeListingPortal,
- revokeSlotPortal,
- revokeRoomPortal,
- } = useKip();
- const { confirm } = useDialog();
- const { runNamed } = useNameGate();
- const run = useAction();
- const fail = useFailure();
- const [busy, setBusy] = useState(false);
- const viaLink = request.portalId !== null;
- // This card also sits ON the requester's page, where linking through would
- // push a second copy of the screen it's already on.
- const onTheirPage = screen.kind === "person" && screen.id === request.from;
- const link = findLink(
- request.portalId,
- prefs.profilePortalId,
- myListings,
- myWindows,
- );
-
- // Accepting writes an edge into the REQUESTER's list, pinned by the rules to
- // the accepter's own profile — so a nameless accept would either be refused or
- // install a blank where a name belongs. The name is collected first.
- async function accept(): Promise {
- setBusy(true);
- try {
- await runNamed(() => acceptRequest(request), "Accept");
- } catch (error) {
- fail(error, "Couldn't accept that request. Please try again.");
- } finally {
- setBusy(false);
- }
- }
-
- // Each link is controlled where it was made. A slot's sheet isn't a screen,
- // so the room screen names the slot and the page opens it.
- function openLink(target: LinkTarget): void {
- if (target.scope === "USER") {
- navigate({ kind: "person", id: request.to });
- } else if (target.scope === "LISTING" || target.scope === "ROOM") {
- // A room's link is on its sheet, one tap in from the place's Rooms list.
- navigate({ kind: "room", id: target.listing.id });
- } else {
- navigate({
- kind: "room",
- id: target.listing.id,
- windowId: target.window.id,
- });
- }
- }
-
- function revokeLink(target: LinkTarget): Promise {
- if (target.scope === "USER") return revokeUserPortal();
- else if (target.scope === "LISTING")
- return revokeListingPortal(target.listing);
- else if (target.scope === "ROOM")
- return revokeRoomPortal(target.listing, target.room.id);
- else return revokeSlotPortal(target.listing.id, target.window);
- }
-
- // Declining leaves the link live, so this is the moment to say so. The prompt
- // names the scope, because a profile link and a date link are not the same
- // hammer at all.
- function revokePrompt(target: LinkTarget) {
- const closing =
- "Declining doesn't stop them asking again — turning the link off is what does.";
- if (target.scope === "USER") {
- return {
- title: "Turn off your profile link?",
- body: `This is the link you send to everyone, and it covers every place you share. Turning it off revokes it for all of them at once, not just for this person. ${closing}`,
- confirmLabel: "Turn off",
- tone: "danger" as const,
- };
- } else if (target.scope === "LISTING") {
- return {
- title: `Turn off the link to ${target.listing.title}?`,
- body: `Everyone you've sent that room's link to loses it, along with every date you open there. ${closing}`,
- confirmLabel: "Turn off",
- tone: "danger" as const,
- };
- } else if (target.scope === "ROOM") {
- return {
- title: `Turn off the link to ${target.room.name}?`,
- body: `Just ${target.room.name} at ${target.listing.title}: everyone you've sent that link to loses it, along with every date you open in that room. ${closing}`,
- confirmLabel: "Turn off",
- tone: "danger" as const,
- };
- } else {
- return {
- title: "Turn off the link to those dates?",
- body: `Just ${formatDateRange(target.window.start, target.window.end)} at ${target.listing.title}: anyone else holding that link loses those nights and nothing else. ${closing}`,
- confirmLabel: "Turn off",
- tone: "danger" as const,
- };
- }
- }
-
- async function decline(): Promise {
- setBusy(true);
- try {
- await declineRequest(request);
- } catch (error) {
- fail(error, "Couldn't decline that request. Please try again.");
- return;
- } finally {
- setBusy(false);
- }
- if (!link) return;
- const ok = await confirm(revokePrompt(link));
- if (ok) run(() => revokeLink(link));
- }
-
- const identity = (
- <>
-
-
- {onTheirPage ? (
- identity
- ) : (
- navigate({ kind: "person", id: request.from })}
- className="-m-1 flex min-w-0 flex-1 items-center gap-3 rounded-2xl p-1 text-left"
- >
- {identity}
-
- )}
- {/* Passive label: a status pill that took a tap would read as a button. */}
- {viaLink ? (
-
- via your link
- {link ? (
- openLink(link)}
- className="text-sm font-semibold text-accent-ink hover:opacity-80"
- >
- Manage link
-
- ) : null}
-
- ) : null}
-
-
- {/* Beside the buttons from sm up; at 390px the sentence needs the width. */}
-
-
- Friends can see every place you share, whenever it's free.
-
-
-
- Decline
-
-
- Add friend
-
-
-
-
-
- );
-}
diff --git a/web/components/room-page.tsx b/web/components/room-page.tsx
deleted file mode 100644
index cafdc01..0000000
--- a/web/components/room-page.tsx
+++ /dev/null
@@ -1,1054 +0,0 @@
-"use client";
-
-import { type ReactElement, useEffect, useMemo, useState } from "react";
-import { LuChevronRight, LuMapPin, LuPlus, LuZap } from "react-icons/lu";
-import { fetchBookingIfVisible } from "../utils/bookings";
-import { PLACE_KEY } from "../utils/checkout";
-import { formatDateRange, isExpired, nights, todayIso } from "../utils/format";
-import { fetchRoom, placeTypeLabel } from "../utils/listings";
-import {
- canHaveRooms,
- findOverlap,
- offerLabel,
- roomList,
- roomOf,
- toPortalRoom,
- wholePlaceLabel,
-} from "../utils/rooms";
-import { useKip } from "../utils/store";
-import type { AvailabilityWindow, Booking, Listing } from "../utils/types";
-import AddDatesSheet, { DATE_FIELD } from "./add-dates-sheet";
-import Avatar from "./avatar";
-import BookingRow from "./booking-row";
-import CoverPhoto, { PhotoGallery } from "./cover-photo";
-import { useAction, useDialog, useFailure } from "./dialog";
-import PhotoStrip from "./photo-strip";
-import { RoomSheet } from "./room-sheet";
-import {
- clashNote,
- groupByOffer,
- hasRooms,
- Offer,
- OfferHeading,
- RoomRow,
- sortForHost,
-} from "./rooms";
-import ShareLink from "./share-link";
-import SlotRow from "./slot-row";
-import Button from "./ui/button";
-import Chip from "./ui/chip";
-import FieldNote from "./ui/field-note";
-import { Group, Row, Section } from "./ui/list";
-import Sheet from "./ui/sheet";
-import Switch from "./ui/switch";
-
-// The single place surface: owner console, or the bookable friend view.
-export default function RoomPage({ id }: { id: string }): ReactElement {
- const { user, myListings, friendListings, tripListings, friendWindows } =
- useKip();
- const [fetched, setFetched] = useState<{
- listing: Listing;
- windows: readonly AvailabilityWindow[];
- } | null>(null);
- const [looked, setLooked] = useState(false);
-
- // A guest pointer opens the place and not its calendar, so dates come back
- // empty for a stay at a non-friend's.
- const loaded =
- myListings.find((listing) => listing.id === id) ??
- friendListings.find((listing) => listing.id === id) ??
- tripListings.find((listing) => listing.id === id);
- const haveLoaded = loaded !== undefined;
-
- // Arriving straight here finds nothing loaded, so ask for THIS place rather
- // than every friend's — and don't call it missing until the answer is in.
- useEffect(() => {
- if (haveLoaded) return;
- let live = true;
- setLooked(false);
- fetchRoom(id)
- .then((room) => {
- if (live) setFetched(room);
- })
- .catch((error) => console.error("fetchRoom", error))
- .finally(() => {
- if (live) setLooked(true);
- });
- return () => {
- live = false;
- };
- }, [id, haveLoaded]);
-
- // The state outlives a move to another room, so only this room's fetch counts.
- const local = fetched?.listing.id === id ? fetched : null;
- const room = loaded ?? local?.listing;
- if (!room) {
- return looked ? (
-
This place isn't available right now.
- ) : (
-
Loading…
- );
- }
-
- const isMine = room.ownerId === user?.uid;
- return isMine ? (
-
- ) : (
-
- );
-}
-
-// `thumbnails` is the one difference between the views: the owner already has a
-// rail further down that reorders, so a second one would mean two things at once.
-function DetailBlock({
- listing,
- thumbnails,
-}: {
- listing: Listing;
- thumbnails: boolean;
-}): ReactElement {
- return (
-
- );
-}
-
-function OwnerView({ listing }: { listing: Listing }): ReactElement {
- const {
- myWindows,
- myCheckout,
- incomingBookings,
- deleteListing,
- hideBookingsById,
- setListingPhotos,
- publishListingPortal,
- revokeListingPortal,
- navigate,
- replace,
- screen,
- } = useKip();
- const { confirm } = useDialog();
- const run = useAction();
- const placeCheckout = myCheckout[listing.id]?.[PLACE_KEY];
- // Owner-only, which is why the friend view can ignore it entirely.
- const focusedWindowId =
- screen.kind === "room" && screen.id === listing.id
- ? (screen.windowId ?? null)
- : null;
- const [editingWindowId, setEditingWindowId] = useState(
- focusedWindowId,
- );
- const [addingSlot, setAddingSlot] = useState(false);
- // `roomId` null is the sheet that adds one.
- const [openRoom, setOpenRoom] = useState<{ roomId: string | null } | null>(
- null,
- );
- const rooms = roomList(listing);
-
- // Only ever OPENS — closing clears the argument, so the two never fight over
- // a sheet the user just dismissed.
- useEffect(() => {
- if (focusedWindowId) setEditingWindowId(focusedWindowId);
- }, [focusedWindowId]);
-
- // In place, not pushed: a pushed entry would make browser-back reopen the
- // sheet just closed.
- function closeSlotSheet(): void {
- setEditingWindowId(null);
- if (focusedWindowId) replace({ kind: "room", id: listing.id });
- }
-
- // Opening it names it in the URL — in place, so it adds no entry to go back
- // through. Without this, tapping a request inside the sheet pushed the
- // booking on top of a room that had forgotten which slot was open, and back
- // returned to a bare room page. The deep-link variant already existed; this
- // just makes an ordinary tap arrive at the same address.
- function openSlotSheet(windowId: string): void {
- setEditingWindowId(windowId);
- replace({ kind: "room", id: listing.id, windowId });
- }
-
- // Two rooms offering the same nights sit together, in the Rooms list's order.
- const allWindows = sortForHost(listing, myWindows[listing.id] ?? []);
- // The same boundary Trips uses, so a slot and a stay stop being current on the
- // same day.
- const windows = allWindows.filter((window) => !isExpired(window.end));
- // Newest first, so recent dates aren't buried under last year's.
- const expired = sortForHost(
- listing,
- allWindows.filter((window) => isExpired(window.end)),
- "latest",
- );
- const bookings = incomingBookings
- .filter((booking) => booking.listingId === listing.id)
- .sort((left, right) => {
- if (left.status === right.status)
- return left.start.localeCompare(right.start);
- return left.status === "REQUESTED" ? -1 : 1;
- });
- const activeBookings = bookings.filter(
- (booking) => booking.status !== "CANCELLED",
- );
- // How many are still waiting on an answer for one slot. The sheet filters
- // `incomingBookings` again for itself rather than being handed this — both
- // read the one subscription and both take `REQUESTED`, which is what keeps the
- // count and the rows behind it in step.
- const askedOn = (windowId: string): number =>
- bookings.filter(
- (booking) =>
- booking.windowId === windowId && booking.status === "REQUESTED",
- ).length;
- // Not a guest any more, but this is the only way in to it.
- const cancelledBookings = bookings
- .filter((booking) => booking.status === "CANCELLED")
- .sort((left, right) => right.start.localeCompare(left.start));
-
- async function remove(): Promise {
- const ok = await confirm({
- title: `Delete "${listing.title}"?`,
- body: "This removes the listing and all its availability. Cancel any booked slots first so guests are notified.",
- confirmLabel: "Delete",
- tone: "danger",
- });
- if (ok) run(() => deleteListing(listing));
- }
-
- // Per-party: the guest's own record of the cancellation is untouched.
- async function clearCancelled(): Promise {
- const agreed = await confirm({
- title: "Clear cancelled bookings?",
- body: "They disappear from this place's list. Each guest still has their copy — nothing is deleted.",
- confirmLabel: "Clear all",
- });
- if (!agreed) return;
- await hideBookingsById(cancelledBookings.map((booking) => booking.id));
- }
-
- // The id survives, so a sheet named before the listener catches up opens when
- // it does, and one naming a removed slot just leaves you on the room.
- const editingWindow = editingWindowId
- ? allWindows.find((window) => window.id === editingWindowId)
- : undefined;
-
- return (
-
-
-
- Manage place
-
-
- {placeCheckout ? (
-
-
- Check-out instructions
-
-
- {placeCheckout}
-
-
- ) : null}
-
-
-
-
-
-
-
-
- The first one leads the page — put another first to change that.
-
-
- {formatDateRange(window.start, window.end)}
-
-
- {offerOf(listing, window)}
-
- {window.status === "BOOKED"
- ? "Someone stayed"
- : "Nobody booked these"}
-
- {/* Here too, and this is where it matters most: nothing ages an ask
- out, so a request for dates that have gone sits in someone's list
- until it is declined, and a past slot is the last place a host
- would think to look for one. */}
- {asked > 0 ? (
-
- {asked === 1 ? "1 asked" : `${asked} asked`}
-
- ) : null}
-
-
-
-
- );
-}
-
-function SlotSheet({
- listing,
- window,
- existing,
- onClose,
-}: {
- listing: Listing;
- window: AvailabilityWindow;
- existing: readonly AvailabilityWindow[];
- onClose: () => void;
-}): ReactElement {
- const listingId = listing.id;
- const {
- incomingBookings,
- updateWindow,
- setWindowAutoAccept,
- cancelWindow,
- publishSlotPortal,
- revokeSlotPortal,
- } = useKip();
- const { confirm } = useDialog();
- const run = useAction();
- const fail = useFailure();
- const [start, setStart] = useState(window.start);
- const [end, setEnd] = useState(window.end);
- const [details, setDetails] = useState(window.details);
-
- const booked = window.status === "BOOKED";
- // Reviving these would be a new set of dates wearing an old slot's history —
- // its share link, and whatever was asked of it — so they can only be cleared.
- const expired = isExpired(window.end);
- // Listed in the sheet AND consulted by Save: a pending ask can never be
- // confirmed onto different nights, so moving the dates cancels it.
- const pending = incomingBookings
- .filter(
- (booking) =>
- booking.windowId === window.id && booking.status === "REQUESTED",
- )
- // Oldest first: these are queueing for one slot, and who asked first is the
- // only ordering the host has any reason to read.
- .sort((left, right) => left.createdAt - right.createdAt);
- // By id, not by "the first live booking on this slot". A slot that lost a race
- // keeps the losers' asks alongside the winner, so a `find` on status-not-
- // cancelled returned whichever the array happened to hold first — naming a
- // pending asker as the person staying, and offering their booking as the stay
- // to cancel. The slot itself names its holder; nothing else has to be guessed.
- const guestBooking = window.bookingId
- ? incomingBookings.find((booking) => booking.id === window.bookingId)
- : undefined;
-
- const dirty =
- start !== window.start || end !== window.end || details !== window.details;
- const clash =
- start && end && end > start
- ? findOverlap(existing, { start, end, roomId: window.roomId }, window.id)
- : null;
- const room = roomOf(listing, window.roomId);
- const gone = Boolean(end) && isExpired(end);
- const valid = Boolean(start && end && end > start) && !clash && !gone;
-
- const datesMoved = start !== window.start || end !== window.end;
-
- async function save(): Promise {
- if (!valid) return;
- if (datesMoved && pending.length > 0) {
- const ok = await confirm({
- title:
- pending.length === 1
- ? "Cancel the pending request?"
- : `Cancel ${pending.length} pending requests?`,
- body: "They asked for the dates as they are now, so moving them cancels what they asked for. They'll be told, and can ask again.",
- confirmLabel: "Move dates",
- cancelLabel: "Keep dates",
- tone: "danger",
- });
- if (!ok) return;
- }
- try {
- await updateWindow(listingId, window.id, {
- start,
- end,
- details: details.trim(),
- });
- onClose();
- } catch (error) {
- fail(error, "Couldn't save those dates. Please try again.");
- }
- }
-
- async function cancelSlot(): Promise {
- const ok = await confirm({
- title: expired ? "Remove these dates?" : "Cancel this slot?",
- body: expired
- ? "They've already passed, so this only clears them off your calendar — a stay that already happened isn't affected."
- : booked
- ? "The guest's booking will be cancelled and they'll be notified."
- : "This removes these open dates.",
- confirmLabel: expired ? "Remove" : "Cancel slot",
- cancelLabel: "Keep",
- tone: "danger",
- });
- if (!ok) return;
- run(() => cancelWindow(listingId, window.id));
- onClose();
- }
-
- // Who has asked for these nights, which is the thing that decides what to do
- // with them: moving the dates cancels every one of these, and the confirm that
- // says so should not be the first the host hears of it.
- //
- // Placed per branch rather than above all three, because the right position
- // differs. On an OPEN slot it belongs over the date fields, for the reason
- // just given. On a BOOKED one the stay that actually holds the nights outranks
- // the asks that missed them. It appears in both, and in expired, deliberately:
- // confirming does not cancel the losers and nothing ages an ask out, so those
- // are still sitting in someone's list waiting for an answer, and this is the
- // only surface that reaches them from the dates they are about.
- const asks =
- pending.length > 0 ? (
-
-
- {pending.length === 1
- ? "1 person asked"
- : `${pending.length} people asked`}
-
-
- {pending.map((booking) => (
-
- ))}
-
- {/* Only where it is a dead end. On an open slot the rows lead to a page
- that can answer them, which needs no caption. */}
- {booked || expired ? (
-
- {/* Expired wins: a slot that is both has a stay that already
- happened, and "went to someone else" is present-tense race
- language for a race that finished long ago. */}
- {expired
- ? "These dates have passed, so these can only be declined."
- : "These dates went to someone else, so these can only be declined."}
-
- ) : null}
-
- ) : null;
-
- return (
-
-
- {/* The place these dates belong to, so a slot opened from a link or a
- deep URL isn't just a pair of dates with no context. */}
-
- {/* Plain text, not a control: which room a set of dates offers is fixed
- once it exists, since every ask on it is an ask for that room. */}
- {hasRooms(listing) ? (
-
- What's free
-
-
- ) : null}
-
- {expired ? (
- <>
-
- These dates have passed —{" "}
- {booked ? "someone stayed" : "nobody booked them"}. There's
- nothing left to change about the dates; new availability means new
- dates. You can still clear these off your calendar.
-
- {window.details ? (
-
{window.details}
- ) : null}
- {asks}
- {/* A link minted while these dates were live is still live. Removing
- the slot deletes it, but turning it off must not require that. */}
- {window.publicPortalId ? (
-
-
- Link to these dates
-
- {
- await publishSlotPortal(listingId, window);
- }}
- onRevoke={() => revokeSlotPortal(listingId, window)}
- />
-
- ) : null}
- >
- ) : booked ? (
- <>
- {/* A sentence naming the guest led nowhere: owner-cancel and
- everything else about the stay live on its own page, reachable
- from here only by closing the sheet and finding the Guests list.
- The row names them now, so the sentence keeps only what the row
- cannot say. */}
- {guestBooking ? (
-
-
-
- ) : null}
- {/* Nameless on purpose in the second case: a slot reads BOOKED for
- a beat before the booking holding it reaches this client, and
- there is nobody to name until it does. */}
-
- {guestBooking
- ? "Cancelling frees these dates and notifies your guest."
- : "Cancelling frees these dates and notifies whoever booked them."}
-
- Nothing saved yet. Set some filters, then keep them here.
-
- )}
-
- );
-}
diff --git a/web/components/settings-view.tsx b/web/components/settings-view.tsx
deleted file mode 100644
index 6b7ec5f..0000000
--- a/web/components/settings-view.tsx
+++ /dev/null
@@ -1,1059 +0,0 @@
-"use client";
-
-import Link from "next/link";
-import { useTheme } from "next-themes";
-import {
- type ReactElement,
- type ReactNode,
- useEffect,
- useRef,
- useState,
-} from "react";
-import { LuChevronRight, LuX } from "react-icons/lu";
-import {
- authErrorMessage,
- EMAIL_DOOR,
- GOOGLE_DOOR,
- PHONE_DOOR,
- PhoneAlreadySet,
- removeDoor,
- StaleSession,
- sendAttachLink,
-} from "../utils/auth";
-import { parseDestination } from "../utils/destination";
-import { requestDeletion } from "../utils/leave";
-import { standingConsent } from "../utils/settings";
-import {
- CHECK_STALL_MS,
- formatUsNumber,
- probeState,
- SMS_FROM,
- startTextLink,
-} from "../utils/sms";
-import { useKip } from "../utils/store";
-import { asThemeChoice, type ThemeChoice } from "../utils/theme";
-import {
- NOTIFY_EVENTS,
- type NotifyKind,
- type NotifySmsKind,
-} from "../utils/types";
-import { useDialog } from "./dialog";
-import { otherAccountAlert, useNameGate } from "./name-gate";
-import ReachField, {
- codeReady,
- confirmReach,
- EMPTY_REACH,
- type ReachState,
- reachError,
- sendReach,
-} from "./reach-field";
-import Busy from "./ui/busy";
-import Button from "./ui/button";
-import Chip from "./ui/chip";
-import FieldNote from "./ui/field-note";
-import IconButton from "./ui/icon-button";
-import Input from "./ui/input";
-import { Group, Row, Section } from "./ui/list";
-import Segmented from "./ui/segmented";
-import Sheet from "./ui/sheet";
-import Switch from "./ui/switch";
-import { useLeave } from "./use-leave";
-
-// One way in. The value is the address it carries once it's set, and the sub-
-// line otherwise says what adding it buys — a Google account already supplies an
-// address, so "no email" would be a lie on the row that most needs to be true.
-function DoorRow({
- name,
- value,
- note,
- chip,
- busy,
- onAdd,
- // Absent when this is the only way in, which must never be removable.
- onRemove,
-}: {
- name: string;
- value: string | null;
- note: string;
- chip?: ReactNode;
- busy: boolean;
- onAdd: () => void;
- onRemove: (() => void) | null;
-}): ReactElement {
- return (
-
-
- {name}
- {/* A long address wraps and clips at 390px, so it ends in an ellipsis
- instead — the same treatment the profile page gives the same
- string. */}
- {value ?? note}
-
- {chip}
- {value === null ? (
-
- Add
-
- ) : onRemove ? (
-
-
-
- ) : null}
-
- );
-}
-
-// kip has no number to text from, so nothing can be sent and nothing may be
-// agreed to. The same "ships able to be off" shape as `smsConfigured()` on the
-// sender and `firebaseConfigured()` on the client: one constant, checked before
-// anything is offered. Provisioning a number is what turns the section on.
-const SMS_LIVE = Boolean(SMS_FROM);
-
-// The field defaults to email, and this sheet's door is a keypad from the first
-// tap — `only` pins the route, this only picks the keyboard.
-const PHONE_REACH: ReachState = { ...EMPTY_REACH, mode: "phone" };
-
-// Removing one of these two takes the reach it carried with it: unlinking clears
-// the address or the number off the Auth account, which is the only copy kip
-// keeps, so notifications on that channel stop. Probed against the Auth
-// emulator, including the case where a Google door carries the same address —
-// the address still goes. Google is absent because it is the exception: it
-// leaves the address behind, and kip keeps mailing it.
-const LOSES: Record = {
- [EMAIL_DOOR]: "The address goes with it, so kip's email stops. ",
- [PHONE_DOOR]: "The number goes with it, so kip's texts stop. ",
-};
-
-// The one message line both sheets end on, mounted whether or not it has
-// anything to say: a live region announces a CHANGE, so one that appears
-// together with its text is one a screen reader never reads out. It carries its
-// own top margin rather than taking a gap from the column, so an empty one costs
-// no height at all — which is why it sits in a gapless wrapper with the button
-// it follows rather than beside it.
-//
-// It used to stand at one line always, reserved so a refusal could not lift the
-// field off the thumb of whoever was typing into it. What made that a fair trade
-// was standing copy filling the line the rest of the time; both sheets have
-// since run out of anything to say there, and 32px of empty sheet under the
-// button on every render reads as the layout having broken rather than as room
-// being kept.
-function Problem({ message }: { message: string | null }): ReactElement {
- return (
-
- {message}
-
- );
-}
-
-// What this account can be reached and re-entered by. kip has no password, so
-// there is no reset to fall back on: one credential is one lost inbox from
-// unrecoverable, which is the whole reason this is a list rather than a line.
-//
-// Every row reads the store's snapshot rather than `user`, because linking and
-// unlinking change those fields without changing the uid — Firebase hands React
-// the same object and a row reading it never re-renders.
-function DoorsSection(): ReactElement {
- const { doors, emailVerified, keepTexts, prefs, setTexts, signIn } = useKip();
- const { confirm, alert } = useDialog();
- // Which sheet is up, rather than one flag each: they draw over the same note
- // and only one can be answered at a time.
- const [sheet, setSheet] = useState<"email" | "phone" | null>(null);
- const [address, setAddress] = useState("");
- const [sentTo, setSentTo] = useState(null);
- const [reach, setReach] = useState(PHONE_REACH);
- const recaptcha = useRef(null);
- const [busy, setBusy] = useState(false);
- const [error, setError] = useState(null);
-
- const emailDoor = doors.find((door) => door.providerId === EMAIL_DOOR);
- const phoneDoor = doors.find((door) => door.providerId === PHONE_DOOR);
- const googleDoor = doors.find((door) => door.providerId === GOOGLE_DOOR);
- const spare = doors.length > 1;
- const numberProblem = reachError(reach.raw, "phone");
-
- function openEmail(): void {
- setAddress("");
- setSentTo(null);
- setError(null);
- setSheet("email");
- }
-
- function openPhone(): void {
- setReach(PHONE_REACH);
- setError(null);
- setSheet("phone");
- }
-
- // Clearing on the way out, because the note under the group shows the same
- // `error` — so a rejected address would follow the sheet back onto a screen
- // with no field on it.
- function close(): void {
- setError(null);
- setSheet(null);
- }
-
- // No conflict can surface here: an address that belongs to someone else is
- // indistinguishable at send time — `enableImprovedEmailPrivacy` is what makes
- // it so — and `/continue/` is where it finally refuses and says so.
- async function sendLink(): Promise {
- const parsed = parseDestination(address);
- if (parsed.kind !== "email") {
- setError("That doesn't look like an email address.");
- return;
- }
- setBusy(true);
- setError(null);
- try {
- // No host: this was added from Settings, where there is no request for
- // the landing page to name.
- await sendAttachLink(parsed.value, "");
- setSentTo(parsed.value);
- } catch (caught) {
- console.error(caught);
- setError(authErrorMessage(caught));
- } finally {
- setBusy(false);
- }
- }
-
- // Both steps of the phone door, since the sheet's one button finishes
- // whichever is showing: a code that has been sent is waiting to be typed.
- async function submitNumber(): Promise {
- if (numberProblem) return;
- setBusy(true);
- setError(null);
- try {
- if (reach.pending) {
- const { sameAccount } = await confirmReach(reach.pending, reach.code);
- setSheet(null);
- if (!sameAccount) {
- // The number was already on kip, so they are IN that account now — a
- // different uid, with its own places, friends and stays. Nothing here
- // can merge the two.
- await alert(otherAccountAlert("number"));
- } else if (SMS_LIVE && reach.sentTo && standingConsent(prefs)) {
- // Gated too, not just the switch: this is the OTHER way a consent gets
- // written, and re-presenting the disclosures for texts kip cannot send
- // takes an agreement it has no use for.
- await offerTexts(reach.sentTo);
- }
- return;
- }
- const holder = recaptcha.current;
- if (!holder) throw new Error("no element for the check to bind to");
- // No host: this was added from Settings, where there is no request for
- // a landing page to name.
- const sent = await sendReach(reach.raw, "", holder);
- setReach({ ...reach, pending: sent.pending, sentTo: sent.sentTo });
- } catch (caught) {
- console.error(caught);
- if (reach.pending) {
- setError("Wrong code. Check it, or ask for another.");
- } else if (caught instanceof PhoneAlreadySet) {
- // The row offered Add, so this account grew a number somewhere else —
- // another tab, or the sheet that collects one beside an ask.
- setError("A number was added. Reload kip to see it.");
- } else {
- setError(authErrorMessage(caught));
- }
- } finally {
- setBusy(false);
- }
- }
-
- async function addGoogle(): Promise {
- setBusy(true);
- setError(null);
- try {
- const { sameAccount } = await signIn();
- // That Google account already existed, so they have MOVED to it — the
- // listings, friends and stays on screen a second ago belong to a uid they
- // no longer are, and nothing here can merge the two.
- if (!sameAccount) await alert(otherAccountAlert("Google account"));
- } catch (caught) {
- console.error(caught);
- setError(authErrorMessage(caught));
- } finally {
- setBusy(false);
- }
- }
-
- // Changing your number is remove-then-add, and the texts you had turned on
- // must not just stop without you noticing. Not a transfer: the disclosures are
- // shown again, so what this writes is a fresh consent naming the NEW phone.
- // Only where a consent already stands — otherwise adding a number would nag
- // about texts nobody asked for, which is the bundling this design refuses.
- async function offerTexts(number: string): Promise {
- const keep = await confirm({
- title: "Keep texts on?",
- body: `You had kip's texts turned on for your old number. kip can text ${number} instead — the same kinds you chose, as automated texts. Message frequency varies, and message and data rates may apply. Reply STOP to stop, HELP for help.`,
- confirmLabel: "Text this number",
- cancelLabel: "No texts",
- });
- try {
- if (keep) {
- await keepTexts(number);
- } else if (Object.values(prefs.notifySms).some(Boolean)) {
- // Removing the old number already zeroed the map, but a number can
- // change by routes this screen doesn't own. Declining has to mean off
- // wherever it was reached from.
- await setTexts(false);
- }
- } catch (caught) {
- console.error(caught);
- setError("Couldn't save that. Try again.");
- }
- }
-
- async function remove(providerId: string, name: string): Promise {
- const sure = await confirm({
- title: `Remove ${name}?`,
- body: `${LOSES[providerId] ?? ""}You'll still get in the other ways listed here, and you can add this one back anytime.`,
- confirmLabel: "Remove",
- tone: "danger",
- });
- if (!sure) return;
- setBusy(true);
- setError(null);
- try {
- await removeDoor(providerId);
- // Taking a number off the account is the plainest way there is of saying
- // stop texting me, so re-adding one asks again rather than resuming. The
- // consent record stays: it is what explains texts already sent, and what
- // tells a change of number from someone who never wanted texts at all.
- if (providerId === PHONE_DOOR) await setTexts(false);
- } catch (caught) {
- console.error(caught);
- if (caught instanceof StaleSession) {
- await alert({
- title: "Come back in first",
- body: "Firebase only changes what an account signs in with on a fresh sign-in. Sign out, come back in, and remove it then.",
- });
- } else {
- setError("Couldn't remove that. Try again.");
- }
- } finally {
- setBusy(false);
- }
- }
-
- return (
-
-
- Unconfirmed
- ) : undefined
- }
- busy={busy}
- onAdd={openEmail}
- onRemove={spare ? () => remove(EMAIL_DOOR, "email") : null}
- />
- remove(PHONE_DOOR, "phone") : null}
- />
- remove(GOOGLE_DOOR, "Google") : null}
- />
-
-
- {error && !sheet ? (
- {error}
- ) : (
-
- {doors.length > 1
- ? "Never shown to other users or used to find you."
- : "kip has no password, so these are the only ways back in — a second one means losing the first isn't losing your kip."}
-
- )}
-
-
- {sentTo ? (
-
-
- Check {sentTo} and open the link — that's what attaches it. It
- lands on this account whichever device opens it. Reload kip
- afterwards to see it here.
-
-
- Done
-
-
- ) : (
-
- )}
-
-
-
-
-
-
- );
-}
-
-// Your name and handle are edited on your profile, where they're actually shown;
-// this section is what's left — the ways into the account, and the way out.
-function AccountSection(): ReactElement | null {
- const { user, anonymous, navigate } = useKip();
- const { confirm, alert } = useDialog();
- // Shared with the menu, so the two exits cannot say different things or do
- // different amounts of work.
- const { leave, leaving: leavingDevice } = useLeave();
- const [leaving, setLeaving] = useState(false);
-
- // Deletion, not sign-out, and it says what other people lose too — a host
- // cancelling stays and a guest cancelling trips is what the first phase of it
- // does, and nobody should discover that after the fact.
- async function deleteAccount(): Promise {
- if (!user || leaving) return;
- const sure = await confirm({
- title: "Delete your kip?",
- body: "Your stays and any stays at your places will be cancelled, and your friends will lose you from their lists. Past visits stay as a record, without your name. This can't be undone.",
- confirmLabel: "Delete everything",
- tone: "danger",
- });
- if (!sure) return;
- setLeaving(true);
- try {
- // Asking is the whole of it: a Cloud Function does the teardown and
- // retries on its own, so this tab is free to be closed. It used to be a
- // chain of writes from here, and closing the tab partway through left an
- // account nothing would ever finish.
- await requestDeletion(user.uid);
- } catch (error) {
- console.error(error);
- setLeaving(false);
- await alert({
- title: "Couldn't start that",
- body: "Nothing has been deleted. Check your connection and try again.",
- });
- }
- }
-
- if (!user) return null;
-
- return (
-
-
- navigate({ kind: "person", id: user.uid })}
- ariaLabel="Name and photo"
- >
-
-
- Name and photo
-
-
- Edit them on your profile
-
-
-
-
-
-
-
-
- {/* The deliberate exit, and the ONLY one an account with no credential is
- offered. It is destruction rather than sign-out — the uid lives in this
- browser and nowhere else — so it says so, twice, and never sits in a
- casual menu. */}
-
- {anonymous ? (
-
-
-
- {leavingDevice ? "Leaving…" : "Leave kip"}
-
-
- Cancels your stays and removes you from friends' lists
-
-
-
- ) : (
-
-
-
- {leaving ? "Deleting…" : "Delete your kip"}
-
-
- Cancels your stays and removes you from friends' lists
-
-
-
- )}
-
-
- );
-}
-
-// The two independent ways someone can reach you: a handle they can search for,
-// and a share link they can open. Neither is on by default — a fresh account is
-// unreachable until you choose to be found. Both switches are mirrors: the handle
-// itself is claimed on your profile, and the link is copied there.
-function DiscoverabilitySection(): ReactElement | null {
- const { askIdentity } = useNameGate();
- const {
- profile,
- anonymous,
- setSearchable,
- setShareStays,
- prefs,
- navigate,
- publishUserPortal,
- revokeUserPortal,
- user,
- } = useKip();
- const { confirm } = useDialog();
- const [busy, setBusy] = useState(false);
- const [error, setError] = useState(null);
-
- if (!profile || !user) return null;
- const handle = profile.username;
- const uid = user.uid;
-
- async function toggle(next: boolean): Promise {
- // Becoming findable needs something to be found by, and a handle is claimed
- // on your profile — permanently — so that decision is made there, not here.
- if (next && !handle) {
- navigate({ kind: "person", id: uid });
- return;
- }
- setError(null);
- try {
- await setSearchable(next);
- } catch (caught) {
- console.error(caught);
- setError("Couldn't save that. Try again.");
- }
- }
-
- // Turning the link off revokes it, which kills every link already sent — the
- // same irreversible act as ShareLink's own "Turn off", so it asks the same way.
- async function togglePortal(next: boolean): Promise {
- if (!next) {
- const ok = await confirm({
- title: "Turn off the public link?",
- body: "Anyone holding the link loses access. You can make a new one anytime.",
- confirmLabel: "Turn off",
- tone: "danger",
- });
- if (!ok) return;
- }
- setError(null);
- setBusy(true);
- try {
- if (next) await publishUserPortal();
- else await revokeUserPortal();
- } catch (caught) {
- console.error(caught);
- setError("Couldn't save that. Try again.");
- } finally {
- setBusy(false);
- }
- }
-
- return (
-
-
- {/* A username is permanent, so it needs an account someone can get
- back into — the rule refuses the claim outright, and this says why
- rather than letting the switch fail. */}
-
- {anonymous ? (
-
-
- Add email or number
-
-
- ) : null}
-
-
-
- setShareStays(next)}
- label="Let friends see where I'll be staying"
- description="When on, confirmed stays can appear to your friends. When off, your trips stay private to you and the host."
- />
-
- {prefs.profilePortalId ? (
- navigate({ kind: "person", id: uid })}
- ariaLabel="Copy your profile link"
- >
-
-
- Copy your link
-
-
- Copy or regenerate it on your profile
-
-
-
-
- ) : null}
-
-
- {error ? {error} : null}
-
- );
-}
-
-// One list, walked by both channels, so the two read label for label. The kinds
-// a text cannot carry are still ROWS on the text side — saying "email only"
-// where the toggle would be — because a channel that silently drops two of six
-// looks like it forgot them. That replaces a sentence under the group naming the
-// missing kinds, which was four rows away from the gap it explained.
-const EVENTS = Object.entries(NOTIFY_EVENTS);
-
-// Two channels, each with its own way of reaching nobody: email needs a verified
-// address, and a text needs a number on the account plus consent that was given
-// beside the disclosures. Both gaps are silent, so both are said here.
-function NotificationsSection(): ReactElement | null {
- const { askIdentity } = useNameGate();
- const {
- user,
- email,
- emailVerified,
- phone,
- prefs,
- setNotify,
- setTexts,
- checkTexts,
- setTextNotify,
- resendVerification,
- } = useKip();
- const [sent, setSent] = useState(false);
- const [error, setError] = useState(null);
- const [textError, setTextError] = useState(null);
- // Derived from the two stamps and the clock rather than held in state, so a
- // reload can't lose a check in flight and a second press still means "ask
- // again". One timer, armed only while a check could still turn into a stall —
- // an interval would re-render the section to advance a clock nothing reads.
- const [now, setNow] = useState(() => Date.now());
- const probe = probeState(prefs.smsProbeAt, prefs.smsProbeDoneAt, now);
- const askedAt = prefs.smsProbeAt;
- useEffect(() => {
- if (probe !== "checking" || askedAt === null) return;
- const timer = setTimeout(
- () => setNow(Date.now()),
- askedAt + CHECK_STALL_MS - now,
- );
- return () => clearTimeout(timer);
- }, [probe, askedAt, now]);
-
- if (!user) return null;
-
- // One switch over the whole map: what people decide is "text me the important
- // stuff", and per-kind granularity later is a UI change with no migration.
- //
- // `some`, not `every`: a kind added to the table later is absent from a stored
- // map and reads false, and that is meant to stay false — the disclosures name
- // the kinds, so a new one is wording nobody agreed to and wants the switch
- // turned on again. `every` would meanwhile read "off" while four kinds still
- // text, which is the lie worth avoiding.
- //
- // AND bound to the number it was given for. Read off the map alone, the row
- // rendered ON and greyed out over an account whose phone door had been
- // removed — nothing to turn it off with — and a different number added later
- // resumed texting on an agreement about the old one.
- //
- // ANDed with `SMS_LIVE`, or an account that agreed before kip lost its number
- // renders every text row ON while nothing can be sent — a switch claiming kip
- // texts about this is the one thing the section must never say.
- const texting =
- SMS_LIVE &&
- prefs.smsConsentNumber !== null &&
- prefs.smsConsentNumber === phone &&
- Object.values(prefs.notifySms).some(Boolean);
- // Carrier-enforced, so kip only observes it — and only by attempting a send,
- // which needs the switch left on. Disabling it here stranded anyone whose STOP
- // landed as they were switching texts off: nothing to attempt, nothing to
- // clear the flag, and a dead control. Turning it on clears it instead, and the
- // next refused send writes it straight back.
- const blocked = prefs.smsStopped;
- const checking = probe === "checking";
- // Only once the sender has actually answered. Gated on the state rather than
- // on the stamp being non-null, or a STALL would show "kip tried a text" over
- // an older answer while this ask was still unaccounted for.
- const checkedStillBlocked = probe === "answered" && blocked;
-
- async function runCheck(): Promise {
- setTextError(null);
- setNow(Date.now());
- try {
- await checkTexts();
- } catch (caught) {
- console.error(caught);
- setTextError("Couldn't check just now. Try again.");
- }
- }
-
- async function toggleTexts(next: boolean): Promise {
- setTextError(null);
- try {
- await setTexts(next);
- } catch (caught) {
- console.error(caught);
- setTextError("Couldn't save that. Try again.");
- }
- }
-
- async function toggleKind(kind: NotifySmsKind, next: boolean): Promise {
- setTextError(null);
- try {
- await setTextNotify(kind, next);
- } catch (caught) {
- console.error(caught);
- setTextError("Couldn't save that. Try again.");
- }
- }
-
- async function resend(): Promise {
- setError(null);
- try {
- await resendVerification();
- setSent(true);
- } catch (caught) {
- console.error(caught);
- setError("Couldn't send that just now. Try again in a minute.");
- }
- }
-
- return (
- // Channel-major: two labelled blocks, each heading tight against the group
- // it names. Nested so the outer gap separates the channels while the inner
- // one keeps a heading with its list.
-
-
- {/* Two different gaps, and they used to be one. An address kip has but
- can't trust is a confirm; no address at all is an ask — and without
- this split the second rendered "Confirm undefined", which nobody saw
- only because these sessions could not reach Settings. */}
- {email ? (
- emailVerified ? null : (
-
-
- kip only emails an address that's been confirmed — otherwise
- anyone could enter someone else's and have kip mail them.
- Confirm {email} to start receiving these.
-
- {phone && SMS_LIVE
- ? "kip has no address for you, so none of this arrives by email. A text can still reach you — turn those on below."
- : "kip has no way to reach you when you're not looking at it. Add an address to hear when things happen."}
-
-
- Add email
-
-
- )}
-
-
- {EVENTS.map(([key, event]) => (
- setNotify(key as NotifyKind, next)}
- label={event.label}
- description={event.note}
- srSuffix="by email"
- />
- ))}
-
-
-
-
- {/* Consent to be texted is collected on the first row and nowhere else: it
- must not ride along with signing in or with adding a number, which is
- what bundling it onto the reach field would have done. Off by default,
- and the disclosures are on the row being turned on.
-
- The rows under it carry no descriptions. Each is the same event as a
- row forty pixels up, whose note already says what it is, and four more
- paragraphs would double the section to repeat them. */}
-
-
- {EVENTS.map(([key, event]) =>
- event.sms ? (
- toggleKind(key as NotifySmsKind, next)}
- label={event.label}
- srSuffix="by text"
- />
- ) : (
- // Nothing takes this branch today — every kind is textable — and it
- // is kept because the next one may not be: a saved-search digest is
- // not caused by a person acting on you and belongs in an inbox. The
- // table decides, so adding such a kind is one flag rather than a
- // second list here.
- {}}
- label={event.label}
- unavailable="Email only"
- />
- ),
- )}
-
-
- {textError ? {textError} : null}
-
- {/* kip can only ever OBSERVE this: the carrier holds the block, and the
- only way out is the person texting START themselves. So the number is
- named and made tappable rather than described — "the number kip texted
- you from" is in a message they may well have deleted.
-
- The check is here because nothing else would tell them it worked: the
- block lifts silently, and kip finds out only by trying. Without it the
- answer is "wait until kip next has something to text you about", which
- for a quiet week is indistinguishable from still being blocked. */}
- {SMS_LIVE && blocked ? (
-
-
- Your carrier is blocking kip's texts: STOP was replied from{" "}
- {phone ?? "your number"}. kip can't lift that.{" "}
- {SMS_FROM ? (
- <>
- Text START to{" "}
-
- {formatUsNumber(SMS_FROM)}
-
- , then check below.
- >
- ) : (
- <>
- Text START to the number kip texted you from, and this clears
- the next time a text gets through.
- >
- )}
-
- {SMS_FROM ? (
-
-
- {checking ? : "Check now"}
-
- {checkedStillBlocked ? (
- kip tried a text; the block is still there.
- ) : probe === "stalled" ? (
- That check didn't run. Try again.
- ) : null}
-
- ) : null}
-
- ) : null}
-
- {/* Required beside an SMS consent, and they stay whether or not kip can
- send today. */}
-
- See our{" "}
-
- Privacy Policy
- {" "}
- and{" "}
-
- Terms
-
- .
-
-
- {/* Across both channels: an email switch left on still tells you, and a
- text switch is no comfort while texts are off as a whole. */}
- {!prefs.notify.stayCancelled &&
- !(texting && prefs.notifySms.stayCancelled) ? (
-
- Nothing will tell you if a stay you're counting on is called off —
- no email, no text. You'd only find out by opening kip.
-
- ) : null}
-
-
- );
-}
-
-export default function SettingsView(): ReactElement {
- const { theme, setTheme } = useTheme();
- const [mounted, setMounted] = useState(false);
- useEffect(() => setMounted(true), []);
- const current = mounted ? asThemeChoice(theme) : "system";
-
- return (
-
- );
-}
diff --git a/web/components/share-link.tsx b/web/components/share-link.tsx
deleted file mode 100644
index fc9fbc0..0000000
--- a/web/components/share-link.tsx
+++ /dev/null
@@ -1,140 +0,0 @@
-"use client";
-
-import { type ReactElement, useState } from "react";
-import {
- LuCheck,
- LuCopy,
- LuGlobe,
- LuPowerOff,
- LuRotateCw,
-} from "react-icons/lu";
-import { useDialog, useFailure } from "./dialog";
-import Button from "./ui/button";
-import IconButton from "./ui/icon-button";
-
-// How much of the URL's tail is kept whole when the row runs out of room. Two
-// links differ only in their token, so truncating at the end would leave every
-// one of yours reading identically — the last few characters are what tells you
-// which link you're looking at.
-const TOKEN_TAIL = 6;
-
-// Build the shareable URL. The token lives in the URL fragment (after #), which
-// browsers never send to servers — so the capability isn't leaked via referrers
-// or access logs.
-function portalUrl(portalId: string): string {
- const base = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
- const origin = typeof window !== "undefined" ? window.location.origin : "";
- return `${origin}${base}/portal/#${portalId}`;
-}
-
-// Presentational control to mint / copy / regenerate / revoke a public link.
-// The parent supplies the current token and the create/revoke actions, so the
-// same control drives a listing link and a whole-profile link.
-export default function ShareLink({
- portalId,
- createLabel,
- onCreate,
- onRevoke,
-}: {
- portalId: string | null;
- createLabel: string;
- onCreate: () => Promise;
- onRevoke: () => Promise;
-}): ReactElement {
- const { confirm } = useDialog();
- const fail = useFailure();
- const [busy, setBusy] = useState(false);
- const [copied, setCopied] = useState(false);
-
- async function create(): Promise {
- setBusy(true);
- try {
- await onCreate();
- } catch (error) {
- fail(error, "Couldn't make the link. Please try again.");
- } finally {
- setBusy(false);
- }
- }
-
- async function regenerate(): Promise {
- const ok = await confirm({
- title: "Regenerate the link?",
- body: "The current link stops working immediately; you'll get a fresh one.",
- confirmLabel: "Regenerate",
- tone: "danger",
- });
- if (ok) await create();
- }
-
- async function revoke(): Promise {
- const ok = await confirm({
- title: "Turn off the public link?",
- body: "Anyone holding the link loses access. You can make a new one anytime.",
- confirmLabel: "Turn off",
- tone: "danger",
- });
- if (!ok) return;
- setBusy(true);
- try {
- await onRevoke();
- } catch (error) {
- fail(error, "Couldn't turn off the link. Please try again.");
- } finally {
- setBusy(false);
- }
- }
-
- async function copy(): Promise {
- if (!portalId) return;
- try {
- await navigator.clipboard.writeText(portalUrl(portalId));
- } catch (error) {
- fail(error, "Couldn't copy the link. Select it and copy it by hand.");
- return;
- }
- setCopied(true);
- window.setTimeout(() => setCopied(false), 1500);
- }
-
- if (!portalId) {
- return (
-
-
- {createLabel}
-
- );
- }
-
- const url = portalUrl(portalId);
- return (
-
- {/* Every control holds its size and the address gives way, so this is
- still one row at 390px — which is also why all three are icons. The
- head truncates against its own overflow while the tail rides beside
- it. */}
-
- {url.slice(0, -TOKEN_TAIL)}
- {url.slice(-TOKEN_TAIL)}
-
-
- {copied ? (
-
- ) : (
-
- )}
-
-
-
-
-
-
-
-
- );
-}
diff --git a/web/components/site-footer.tsx b/web/components/site-footer.tsx
deleted file mode 100644
index c548587..0000000
--- a/web/components/site-footer.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import Link from "next/link";
-import { Fragment, type ReactElement } from "react";
-import { REPO_URL } from "../utils/contact";
-
-const PAGES = [
- { href: "/about/", label: "About" },
- { href: "/privacy/", label: "Privacy" },
- { href: "/terms/", label: "Terms" },
- { href: "/help/", label: "Help" },
-] as const;
-
-export type DocRoute = (typeof PAGES)[number]["href"];
-
-// `next/link` applies the base path; a bare anchor would not.
-export default function SiteFooter({
- current,
- className = "",
-}: {
- current?: DocRoute;
- className?: string;
-}): ReactElement {
- return (
-
- );
-}
diff --git a/web/components/slot-row.tsx b/web/components/slot-row.tsx
deleted file mode 100644
index 982473e..0000000
--- a/web/components/slot-row.tsx
+++ /dev/null
@@ -1,153 +0,0 @@
-"use client";
-
-import { type ReactElement, useState } from "react";
-import { LuChevronRight, LuZap } from "react-icons/lu";
-import { formatDateRange, nights } from "../utils/format";
-import { useKip } from "../utils/store";
-import type { AvailabilityWindow, Booking, Listing } from "../utils/types";
-import CoverPhoto from "./cover-photo";
-import Button from "./ui/button";
-import Chip from "./ui/chip";
-import { Row } from "./ui/list";
-
-export default function SlotRow({
- listing,
- window,
- stay = null,
- thumbnail = true,
-}: {
- listing: Listing;
- window: AvailabilityWindow;
- // Passing this is what makes a taken row worth showing: it's the route to who
- // is there, and having it at all is the permission to know.
- stay?: Booking | null;
- // Off under a room's own heading, where the place's cover would pass for the
- // room's.
- thumbnail?: boolean;
-}): ReactElement {
- const { trips, requestBooking, refreshWindows, navigate } = useKip();
- const [busy, setBusy] = useState(false);
- const [note, setNote] = useState(null);
-
- const myBooking = trips.find(
- (booking) =>
- booking.windowId === window.id && booking.status !== "CANCELLED",
- );
-
- async function book(): Promise {
- setBusy(true);
- setNote(null);
- try {
- const outcome = await requestBooking(listing, window);
- // Friends' dates aren't live, so without this the row goes on offering a
- // slot that has just been taken.
- if (outcome === "unavailable") {
- setNote("Just taken by someone else.");
- await refreshWindows(listing.id);
- } else if (outcome === "changed") {
- setNote("These dates have changed — take another look.");
- await refreshWindows(listing.id);
- } else if (outcome === "confirmed") {
- await refreshWindows(listing.id);
- }
- } catch (error) {
- console.error(error);
- setNote("Couldn't book — try again.");
- } finally {
- setBusy(false);
- }
- }
-
- // Holding the window, not merely having a booking on it: two friends can both
- // have REQUESTED one, and the loser must not see "Booked by you".
- const iHoldWindow =
- window.bookingId != null && window.bookingId === myBooking?.id;
- const pendingRequest = myBooking?.status === "REQUESTED";
- const showInstant = window.autoAccept && window.status === "OPEN";
-
- const dates = (
-
- {formatDateRange(window.start, window.end)}
-
- );
- const meta = (
-
- {nights(window.start, window.end)} nights
- {window.details ? ` · ${window.details}` : ""}
-
- );
- // Repeats down a list, but a date range with no picture reads as an abstraction.
- const thumb = thumbnail ? (
-
- ) : null;
-
- // Booked-by-you or pending: the row is a link to my booking page.
- if (myBooking && (iHoldWindow || pendingRequest)) {
- return (
- navigate({ kind: "booking", id: myBooking.id })}>
- {thumb}
-
- {dates}
- {meta}
-
- {iHoldWindow ? (
- Booked by you
- ) : (
- Pending
- )}
-
-
- );
- }
-
- // Unattributed either way: the chip says a slot is taken, and who by is a
- // deliberate second step through the stay. The non-interactive branch is a
- // fallback — today's caller filters out any taken slot it can't hand a stay.
- if (window.status === "BOOKED") {
- const taken = (
- <>
- {thumb}
-
- );
-}
diff --git a/web/components/theme-button.tsx b/web/components/theme-button.tsx
deleted file mode 100644
index 3a5d39c..0000000
--- a/web/components/theme-button.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-"use client";
-
-import { useTheme } from "next-themes";
-import { type ReactElement, useEffect, useState } from "react";
-import { LuMonitor, LuMoon, LuSun } from "react-icons/lu";
-import { asThemeChoice, nextThemeChoice, themeLabel } from "../utils/theme";
-import IconButton from "./ui/icon-button";
-
-export default function ThemeButton(): ReactElement {
- const { theme, setTheme } = useTheme();
- const [mounted, setMounted] = useState(false);
- useEffect(() => setMounted(true), []);
-
- // Until mounted, next-themes hasn't resolved the stored choice; render the
- // neutral "system" state so server and client markup match.
- const current = mounted ? asThemeChoice(theme) : "system";
- const title = `${themeLabel(current)} theme`;
- const icon =
- current === "dark" ? (
-
- ) : current === "light" ? (
-
- ) : (
-
- );
-
- return (
- setTheme(nextThemeChoice(current))}
- >
- {icon}
-
- );
-}
diff --git a/web/components/theme-color.tsx b/web/components/theme-color.tsx
deleted file mode 100644
index 5413344..0000000
--- a/web/components/theme-color.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-"use client";
-
-import { useTheme } from "next-themes";
-import { type ReactElement, useEffect } from "react";
-
-// What the browser paints around the app — Android's status bar, an installed
-// window's title bar. Renders nothing.
-//
-// `layout.tsx` declares two of these keyed on `prefers-color-scheme`, which is
-// right for the first paint and wrong from then on: kip's theme is a THREE-state
-// choice, so someone who picked Dark on a light system got a dark app under a
-// cream bar. The static pair stays for the paint before this runs; from there
-// both are set to whatever the app actually resolved to, so whichever one the
-// media query picks is the same answer.
-export default function ThemeColor(): ReactElement | null {
- const { resolvedTheme } = useTheme();
-
- useEffect(() => {
- if (!resolvedTheme) return;
- const color = resolvedTheme === "dark" ? "#161009" : "#f6f1ea";
- for (const tag of document.querySelectorAll('meta[name="theme-color"]')) {
- tag.setAttribute("content", color);
- }
- }, [resolvedTheme]);
-
- return null;
-}
diff --git a/web/components/trips-view.tsx b/web/components/trips-view.tsx
deleted file mode 100644
index d248175..0000000
--- a/web/components/trips-view.tsx
+++ /dev/null
@@ -1,93 +0,0 @@
-"use client";
-
-import type { ReactElement } from "react";
-import { isExpired } from "../utils/format";
-import { useKip } from "../utils/store";
-import BookingRow from "./booking-row";
-import { useAction, useDialog } from "./dialog";
-import { Group, Section } from "./ui/list";
-
-export default function TripsView(): ReactElement | null {
- const { user, trips, hideCancelledTrips } = useKip();
- const run = useAction();
- const { confirm } = useDialog();
-
- // Clearing is per-party: these rows go from THIS list, and each host's own
- // record of the cancellation is untouched.
- async function clearCancelled(): Promise {
- const agreed = await confirm({
- title: "Clear cancelled trips?",
- body: "They disappear from your list. Each host still has their copy — nothing is deleted.",
- confirmLabel: "Clear all",
- });
- if (!agreed) return;
- await hideCancelledTrips();
- }
-
- if (!user) {
- return null;
- }
-
- const upcoming = trips
- .filter((trip) => trip.status !== "CANCELLED" && !isExpired(trip.end))
- .sort((left, right) => left.start.localeCompare(right.start));
- const past = trips
- .filter((trip) => trip.status !== "CANCELLED" && isExpired(trip.end))
- .sort((left, right) => right.start.localeCompare(left.start));
- // Cancelled stays get their own section rather than being folded into Past:
- // most of them are still in the future, and filing next month's called-off trip
- // under "Past" reads as a mistake. They can't sit under Upcoming either — they
- // aren't coming — and this is the only route in to who cancelled and why.
- const cancelled = trips
- .filter((trip) => trip.status === "CANCELLED")
- .sort((left, right) => right.start.localeCompare(left.start));
-
- return (
-
-
- {upcoming.length === 0 ? (
-
- No upcoming trips. Find a place in Browse and request some dates.
-
- );
-}
diff --git a/web/components/ui/busy.tsx b/web/components/ui/busy.tsx
deleted file mode 100644
index 7d2929b..0000000
--- a/web/components/ui/busy.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import type { ReactElement } from "react";
-import { LuLoaderCircle } from "react-icons/lu";
-
-// Stands in for a button's label while it works. The label stays for screen
-// readers, or the button is announced with no name at all.
-export default function Busy({ label }: { label: string }): ReactElement {
- return (
- <>
-
- {label}
- >
- );
-}
diff --git a/web/components/ui/button.tsx b/web/components/ui/button.tsx
deleted file mode 100644
index 160f5a1..0000000
--- a/web/components/ui/button.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-"use client";
-
-import type { ButtonHTMLAttributes, ReactElement } from "react";
-
-// Every button in the app is one of these Terra variants: a pill at the shared
-// 44px (h-11) control height, or the chunky 48px (h-12) `size="lg"` used for
-// full-width CTAs on sheets and detail surfaces. primary is the gradient CTA;
-// secondary a tonal accent fill; ghost a quiet text button; danger a soft-red
-// fill (the dialog raises it to a solid danger for the destructive confirm).
-type ButtonVariant =
- | "primary"
- | "secondary"
- | "danger"
- | "dangerSolid"
- | "ghost";
-type ButtonSize = "md" | "lg";
-
-const VARIANTS: Record = {
- primary: "bg-gradient-accent text-white shadow-glow hover:opacity-95",
- secondary: "bg-accent-soft text-accent-ink hover:opacity-80",
- danger: "bg-danger-soft text-danger hover:opacity-80",
- dangerSolid: "bg-danger text-white hover:opacity-90",
- ghost: "text-muted hover:bg-surface-hover hover:text-text",
-};
-
-const SIZES: Record = {
- md: "h-11 px-5 text-[0.9375rem]",
- lg: "h-12 px-6 text-base",
-};
-
-export default function Button({
- variant = "primary",
- size = "md",
- className = "",
- type = "button",
- ...props
-}: ButtonHTMLAttributes & {
- variant?: ButtonVariant;
- size?: ButtonSize;
-}): ReactElement {
- return (
-
- );
-}
diff --git a/web/components/ui/chip.tsx b/web/components/ui/chip.tsx
deleted file mode 100644
index c19c14a..0000000
--- a/web/components/ui/chip.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-import type { ReactElement, ReactNode } from "react";
-
-// A soft tonal status chip: a small pill with a low-contrast fill and a semibold
-// label, so it reads as a passive label — never as a button, even beside one.
-// Replaces the old editorial `Status` byline. Tones map to the app's states:
-// pending (amber), confirmed (green), open and instant (accent), booked (dimmed
-// neutral), type (neutral fill for ROOM/FLAT/HOUSE), neutral (cancelled /
-// muted). Colour carries the meaning and adapts to dark mode through the
-// semantic tokens.
-export type ChipTone =
- | "pending"
- | "confirmed"
- | "open"
- | "booked"
- | "instant"
- | "type"
- | "neutral";
-
-const TONES: Record = {
- pending: "bg-pending-soft text-pending",
- confirmed: "bg-success-soft text-success-ink",
- open: "bg-accent-soft text-accent-ink",
- booked: "bg-surface-muted text-muted",
- // The gradient belongs to controls, and this chip sits right beside the
- // gradient Book button on a slot row — two identical pills, one of which does
- // nothing. Tonal instead; the bolt carries the meaning.
- instant: "bg-accent-soft text-accent-ink",
- type: "bg-surface-muted text-muted uppercase tracking-[0.04em]",
- neutral: "bg-surface-muted text-muted",
-};
-
-export default function Chip({
- tone = "neutral",
- icon,
- className = "",
- children,
-}: {
- tone?: ChipTone;
- icon?: ReactNode;
- className?: string;
- children: ReactNode;
-}): ReactElement {
- return (
-
- {icon}
- {children}
-
- );
-}
-
-// The gradient count badge used on nav destinations (dock + top bar) — a filled
-// pill with a white number, no dot.
-export function CountBadge({ count }: { count: number }): ReactElement | null {
- if (count <= 0) return null;
- return (
-
- {count}
-
- );
-}
diff --git a/web/components/ui/code-input.tsx b/web/components/ui/code-input.tsx
deleted file mode 100644
index a93058a..0000000
--- a/web/components/ui/code-input.tsx
+++ /dev/null
@@ -1,112 +0,0 @@
-"use client";
-
-import { type ReactElement, useEffect, useRef, useState } from "react";
-
-// Keys for a row that is never inserted into, removed from or reordered, so
-// they can be names rather than positions.
-const BOXES = ["one", "two", "three", "four", "five", "six"] as const;
-
-export const CODE_LENGTH = BOXES.length;
-
-// Six boxes drawn under ONE real input, never six inputs. That is the whole
-// design decision: the OS keyboard's "From Messages: 123456" strip, paste,
-// backspace and select-all are all things the platform already does to a single
-// `one-time-code` field, and six would mean imitating every one of them with
-// index-juggling refs — while still losing the autofill, which fills one field
-// and gives up. The boxes are the shell `Input` wears (h-11, rounded-xl, the
-// same border and accent ring), so this reads as kip's field rather than a
-// widget that wandered in.
-//
-// The input is transparent rather than hidden, because an `opacity-0` field is
-// skipped by some autofill, and the caret is drawn as the ring on whichever box
-// comes next.
-//
-// It submits its own form on the sixth digit. A code is the one field with
-// nothing left to decide once it is full, so the tap that follows exists only
-// because the form has a button. Fired from an effect, or the caller's submit
-// would read the state from before the digit that completed it.
-export default function CodeInput({
- value,
- onChange,
- invalid = false,
- autoFocus = false,
- busy = false,
-}: {
- value: string;
- onChange: (next: string) => void;
- invalid?: boolean;
- autoFocus?: boolean;
- // The caller's in-flight flag. See `readOnly` below — this is what stops one
- // code being confirmed twice.
- busy?: boolean;
-}): ReactElement {
- const field = useRef(null);
- // Seeded from autoFocus: React's autoFocus doesn't fire onFocus.
- const [focused, setFocused] = useState(autoFocus);
-
- useEffect(() => {
- if (value.length === CODE_LENGTH) field.current?.form?.requestSubmit?.();
- }, [value]);
-
- // Everything here assumes the caret is at the end — the ring names the next
- // box, backspace takes the last digit — so a tap that would land it in the
- // middle is walked back to the end instead.
- function toEnd(): void {
- field.current?.setSelectionRange(value.length, value.length);
- }
-
- const next = Math.min(value.length, CODE_LENGTH - 1);
-
- return (
-
-
- {BOXES.map((name, index) => {
- const live = focused && index === next;
- return (
-
- {value[index] ?? ""}
-
- );
- })}
-
- 5 -> 6, the effect fires again, and the
- // same single-use code is confirmed twice. `requestSubmit` does not
- // consult the submit button's `disabled`, so the button being greyed is
- // no guard at all.
- readOnly={busy}
- value={value}
- onChange={(event) =>
- onChange(event.target.value.replace(/\D/g, "").slice(0, CODE_LENGTH))
- }
- onFocus={() => {
- setFocused(true);
- toEnd();
- }}
- onBlur={() => setFocused(false)}
- onClick={toEnd}
- className="absolute inset-0 h-full w-full bg-transparent text-transparent caret-transparent outline-none selection:bg-transparent"
- />
-
- );
-}
diff --git a/web/components/ui/field-note.tsx b/web/components/ui/field-note.tsx
deleted file mode 100644
index c026903..0000000
--- a/web/components/ui/field-note.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-"use client";
-
-import type { ReactElement, ReactNode } from "react";
-
-// The small helper/error/success line under a field or form. One style source
-// for the note copy repeated across every form surface — auth, settings, the
-// listing form, onboarding, and the profile.
-type FieldNoteTone = "muted" | "danger" | "success";
-
-const TONES: Record = {
- muted: "text-muted",
- danger: "text-danger",
- success: "text-success-ink",
-};
-
-export default function FieldNote({
- tone = "muted",
- children,
-}: {
- tone?: FieldNoteTone;
- children: ReactNode;
-}): ReactElement {
- return
{children}
;
-}
diff --git a/web/components/ui/icon-button.tsx b/web/components/ui/icon-button.tsx
deleted file mode 100644
index 797fd31..0000000
--- a/web/components/ui/icon-button.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-"use client";
-
-import type { ButtonHTMLAttributes, ReactElement, ReactNode } from "react";
-
-type IconButtonVariant = "ghost" | "surface" | "danger" | "success";
-
-const VARIANTS: Record = {
- ghost: "text-muted hover:bg-surface-hover hover:text-text",
- surface: "bg-surface text-text shadow-soft hover:bg-surface-hover",
- danger: "text-muted hover:bg-surface-hover hover:text-danger",
- success: "bg-success-soft text-success hover:opacity-90",
-};
-
-export default function IconButton({
- label,
- variant = "ghost",
- className = "",
- type = "button",
- children,
- ...props
-}: ButtonHTMLAttributes & {
- label: string;
- variant?: IconButtonVariant;
- children: ReactNode;
-}): ReactElement {
- return (
-
- {children}
-
- );
-}
diff --git a/web/components/ui/input.tsx b/web/components/ui/input.tsx
deleted file mode 100644
index 28000a5..0000000
--- a/web/components/ui/input.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-"use client";
-
-import type { ComponentPropsWithRef, ReactElement, ReactNode } from "react";
-
-// text-base (≥16px) so iOS Safari doesn't zoom on focus. A suffix holding WORDS
-// rather than a glyph needs the text kept further clear of it, which is what
-// `wideSuffix` is for — the gutter can't just be widened for everyone, or the
-// handle field's tick sits in a hole.
-export default function Input({
- prefix,
- suffix,
- wideSuffix = false,
- invalid = false,
- className = "",
- ...props
- // `ComponentPropsWithRef` rather than `InputHTMLAttributes` so a caller can
- // hold the element — React 19 passes `ref` through as an ordinary prop.
-}: ComponentPropsWithRef<"input"> & {
- prefix?: ReactNode;
- suffix?: ReactNode;
- wideSuffix?: boolean;
- invalid?: boolean;
-}): ReactElement {
- const base =
- "h-11 w-full rounded-xl border border-border bg-surface text-base outline-none transition focus:ring-2";
- const tone = invalid
- ? "border-danger focus:border-danger focus:ring-danger/20"
- : "border-border focus:border-accent focus:ring-accent/20";
- // Always the same tree, adorned or not. Branching on `prefix || suffix` gave
- // the input two different positions in the element tree, so an adornment that
- // comes and goes — one shown only while the field is empty, say — REMOUNTED
- // the input on the first keystroke and dropped focus to the body. On a phone
- // that closes the keyboard after one character.
- return (
-
- );
-}
-
-// The same shell for prose. Not a variant of the input above: a textarea has no
-// adornments, and its height is the caller's business rather than the shared
-// 44px every control otherwise stands at.
-export function Textarea({
- className = "",
- ...props
-}: ComponentPropsWithRef<"textarea">): ReactElement {
- return (
-
- );
-}
diff --git a/web/components/ui/list.tsx b/web/components/ui/list.tsx
deleted file mode 100644
index 06b9784..0000000
--- a/web/components/ui/list.tsx
+++ /dev/null
@@ -1,84 +0,0 @@
-"use client";
-
-import type { ReactElement, ReactNode } from "react";
-
-export function SectionHeading({
- children,
- action,
-}: {
- children: ReactNode;
- action?: ReactNode;
-}): ReactElement {
- return (
-
- {/* Never wraps to make room for its action — the action truncates instead,
- since titles here are short and fixed while an action can carry a
- user-chosen name. */}
-
;
-}
diff --git a/web/components/ui/segmented.tsx b/web/components/ui/segmented.tsx
deleted file mode 100644
index b8136f1..0000000
--- a/web/components/ui/segmented.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-"use client";
-
-import type { ReactElement } from "react";
-
-// An inline segmented control: two or more equal-width options in a tonal pill
-// track, the active one raised as a white thumb. Each option is flex-1 so it
-// fits narrow screens — drives the listing type toggle and the theme picker.
-export type SegmentedOption = {
- readonly value: T;
- readonly label: string;
-};
-
-export default function Segmented({
- options,
- value,
- onChange,
- ariaLabel,
-}: {
- options: readonly SegmentedOption[];
- value: T;
- onChange: (next: T) => void;
- ariaLabel?: string;
-}): ReactElement {
- return (
-
- );
-}
diff --git a/web/components/ui/sheet.tsx b/web/components/ui/sheet.tsx
deleted file mode 100644
index f5a3138..0000000
--- a/web/components/ui/sheet.tsx
+++ /dev/null
@@ -1,116 +0,0 @@
-"use client";
-
-import { type ReactNode, useEffect, useRef } from "react";
-import { createPortal } from "react-dom";
-
-// Open sheets, innermost last: a confirm raised over a sheet is its own sheet,
-// and Escape or Tab belongs to the top one only.
-const stack: symbol[] = [];
-
-const FOCUSABLE =
- 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
-
-// Portaled to : a blurred/transformed ancestor becomes the containing
-// block for `fixed`.
-export default function Sheet({
- open,
- onClose,
- title,
- children,
-}: {
- open: boolean;
- onClose: () => void;
- title?: ReactNode;
- children: ReactNode;
-}): ReactNode {
- const panel = useRef(null);
- const opener = useRef(null);
- const wasOpen = useRef(false);
- // Held rather than depended on: callers pass inline closures, and re-running
- // the effect would bounce focus out to the opener and back on every render.
- const close = useRef(onClose);
- close.current = onClose;
- // Read during render because a child's `autoFocus` has already moved focus
- // into the sheet by the time any effect of this one runs.
- if (open && !wasOpen.current && typeof document !== "undefined") {
- opener.current = document.activeElement;
- }
- wasOpen.current = open;
-
- useEffect(() => {
- if (!open) return;
- const token = Symbol("sheet");
- stack.push(token);
- const node = panel.current;
- if (node && !node.contains(document.activeElement)) node.focus();
-
- function onKey(event: KeyboardEvent): void {
- if (stack[stack.length - 1] !== token || !node) return;
- if (event.key === "Escape") {
- close.current();
- } else if (event.key === "Tab") {
- const focusable = Array.from(
- node.querySelectorAll(FOCUSABLE),
- );
- if (focusable.length === 0) {
- event.preventDefault();
- node.focus();
- return;
- }
- const first = focusable[0];
- const last = focusable[focusable.length - 1];
- const current = document.activeElement;
- if (event.shiftKey && (current === first || current === node)) {
- event.preventDefault();
- last.focus();
- } else if (!event.shiftKey && current === last) {
- event.preventDefault();
- first.focus();
- } else if (!node.contains(current)) {
- event.preventDefault();
- first.focus();
- }
- }
- }
- const previousOverflow = document.body.style.overflow;
- document.body.style.overflow = "hidden";
- window.addEventListener("keydown", onKey);
- return () => {
- document.body.style.overflow = previousOverflow;
- window.removeEventListener("keydown", onKey);
- stack.splice(stack.indexOf(token), 1);
- const back = opener.current;
- if (back instanceof HTMLElement && back.isConnected) back.focus();
- };
- }, [open]);
-
- // `document` is absent while the static export is rendered; `open` is false
- // there anyway, so this only guards the type.
- if (!open || typeof document === "undefined") return null;
-
- return createPortal(
-
-
-
-
- {title ? (
-
{title}
- ) : null}
- {children}
-
-
,
- document.body,
- );
-}
diff --git a/web/components/ui/switch.tsx b/web/components/ui/switch.tsx
deleted file mode 100644
index 64f93e7..0000000
--- a/web/components/ui/switch.tsx
+++ /dev/null
@@ -1,71 +0,0 @@
-"use client";
-
-import type { ReactElement } from "react";
-
-export default function Switch({
- checked,
- onChange,
- label,
- description,
- srSuffix,
- disabled = false,
- unavailable,
-}: {
- checked: boolean;
- onChange: (next: boolean) => void;
- label: string;
- description?: string;
- // Read aloud after the label, for a list where the same labels appear once
- // per channel and the heading that tells them apart is not part of any name.
- srSuffix?: string;
- disabled?: boolean;
- // Words in place of the toggle, for a row this channel cannot carry at all.
- // A row rather than an omission, so two channels' lists line up label for
- // label — a missing row reads as an oversight, and the footnote that used to
- // explain the gap sat several rows away from the gap it was about. Not a
- // button: there is nothing here to press.
- unavailable?: string;
-}): ReactElement {
- if (unavailable) {
- return (
-
-
- {label}
-
- {unavailable}
-
- );
- }
-
- return (
- onChange(!checked)}
- className="flex w-full items-center gap-3 px-4 py-3 text-left disabled:opacity-50"
- >
-
-
- {label}
- {srSuffix ? {srSuffix} : null}
-
- {description ? (
- {description}
- ) : null}
-
-
-
-
-
- );
-}
diff --git a/web/components/use-stay-place.ts b/web/components/use-stay-place.ts
deleted file mode 100644
index 099b855..0000000
--- a/web/components/use-stay-place.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-"use client";
-
-import { useEffect, useState } from "react";
-import { fetchWindow } from "../utils/listings";
-import { roomOf } from "../utils/rooms";
-import { useKip } from "../utils/store";
-import type { Booking, Listing, Room } from "../utils/types";
-
-// Which room a set of dates offers never changes, so a found answer holds for
-// the session. A miss is not kept: the read may be allowed a moment later.
-const roomIds = new Map();
-
-/**
- * Where a stay is: the place when it can be read, and the room when the dates
- * are a room's.
- *
- * A booking names its dates, not its room, so the room comes off the slot —
- * from the store when it is already loaded, otherwise with one read, and only
- * for a place that has rooms. `room` stays null when the slot is gone or can't
- * be read, which callers render as the place alone.
- */
-export function useStayPlace(booking: Booking | undefined): {
- listing: Listing | undefined;
- room: Room | null;
-} {
- const { friendListings, myListings, tripListings, myWindows, friendWindows } =
- useKip();
- const listing = booking
- ? [...friendListings, ...myListings, ...tripListings].find(
- (candidate) => candidate.id === booking.listingId,
- )
- : undefined;
- const listingId = booking?.listingId ?? "";
- const windowId = booking?.windowId ?? "";
- const key = `${listingId}/${windowId}`;
- const loaded = [
- ...(myWindows[listingId] ?? []),
- ...(friendWindows[listingId] ?? []),
- ].find((window) => window.id === windowId);
- const [fetched, setFetched] = useState<{
- key: string;
- roomId: string | null;
- } | null>(null);
- const known = loaded
- ? loaded.roomId
- : roomIds.has(key)
- ? roomIds.get(key)
- : fetched?.key === key
- ? fetched.roomId
- : undefined;
- const wanted =
- known === undefined &&
- listing !== undefined &&
- Object.keys(listing.rooms).length > 0;
-
- useEffect(() => {
- if (!wanted) return;
- let live = true;
- fetchWindow(listingId, windowId)
- .then((window) => {
- if (!window) return;
- roomIds.set(key, window.roomId);
- if (live) setFetched({ key, roomId: window.roomId });
- })
- .catch((error) => console.error("stayPlace", error));
- return () => {
- live = false;
- };
- }, [wanted, listingId, windowId, key]);
-
- return {
- listing,
- room: listing ? roomOf(listing, known ?? null) : null,
- };
-}
diff --git a/web/components/welcome-screen.tsx b/web/components/welcome-screen.tsx
deleted file mode 100644
index f6c2ead..0000000
--- a/web/components/welcome-screen.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-"use client";
-
-import type { ReactElement } from "react";
-import AuthPanel from "./auth-panel";
-import SiteFooter from "./site-footer";
-import ThemeButton from "./theme-button";
-import Wordmark from "./wordmark";
-
-export default function WelcomeScreen(): ReactElement {
- return (
-
-
-
-
-
-
-
-
- Spare rooms and empty flats — shared between friends, for free.
-
-
-
- {/* The card AND the line under it, because that line is also where a
- problem with the field is said — one node, so the two can never
- disagree about how tall they are. */}
-
-
-
-
-
- );
-}
diff --git a/web/components/wordmark.tsx b/web/components/wordmark.tsx
deleted file mode 100644
index 7a1faa8..0000000
--- a/web/components/wordmark.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import type { ReactElement } from "react";
-
-// The "k" standing on its own — loading the app, and loading a share link. Was
-// hand-copied into both screens, which is how the two would have drifted.
-export function Mark(): ReactElement {
- return (
-
- k
-
- );
-}
-
-// The Terra wordmark: the gradient disc holding a white "k", set beside "kip" in
-// a heavy tight-tracked sans. One source of truth for the brand lockup — header,
-// sign-in, portal all render this.
-export default function Wordmark({
- size = "md",
-}: {
- size?: "md" | "lg";
-}): ReactElement {
- // `rounded-full`, not a radius from the scale. Terra's radii already exceed
- // half of a box this small and CSS clamps there, so both sizes drew circles
- // while their classes said otherwise — one scale change from squaring off.
- const tile =
- size === "lg"
- ? "h-11 w-11 rounded-full text-2xl"
- : "h-8 w-8 rounded-full text-lg";
- const word = size === "lg" ? "text-3xl" : "text-xl";
- return (
-
-
- k
-
- kip
-
- );
-}
diff --git a/web/next.config.js b/web/next.config.js
deleted file mode 100644
index 7bc1e51..0000000
--- a/web/next.config.js
+++ /dev/null
@@ -1,35 +0,0 @@
-import { execFileSync } from "node:child_process";
-import { dirname, join } from "node:path";
-import { fileURLToPath } from "node:url";
-
-// The service worker is its own compile — `sw/sw.ts` to `public/sw.js` — and it
-// happens HERE because this file is the one thing every Next command loads.
-// Wired into the `dev` and `export` scripts instead, a bare `next build` shipped
-// a site whose sw.js 404s: no worker, no offline, and nothing said so.
-//
-// Anchored to this file rather than the working directory, which Next does NOT
-// set to the project: `next build web` from the repo root died with ENOENT out
-// of top-level evaluation, before Next printed anything — a worse failure than
-// the missing worker this replaces.
-const here = dirname(fileURLToPath(import.meta.url));
-execFileSync(
- join(here, "node_modules", ".bin", "tsc"),
- ["-p", "tsconfig.sw.json"],
- {
- cwd: here,
- stdio: "inherit",
- },
-);
-
-// Unset in the release, which serves kip at the root of its own domain. Set it
-// to serve under a path (e.g. `/kip`); everything that builds a URL reads it.
-const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
-
-export default {
- output: "export",
- reactStrictMode: true,
- images: { unoptimized: true },
- basePath,
- assetPrefix: basePath || undefined,
- trailingSlash: true,
-};
diff --git a/web/package.json b/web/package.json
index a6a7cda..941838d 100644
--- a/web/package.json
+++ b/web/package.json
@@ -5,15 +5,20 @@
"license": "MIT",
"type": "module",
"engines": {
- "bun": ">=1.3.0"
+ "bun": ">=1.4.0"
+ },
+ "imports": {
+ "#lib": "./src/lib",
+ "#lib/*": "./src/lib/*"
},
"scripts": {
- "dev": "next dev --turbo",
- "dev:emulated": "firebase emulators:exec --config ../firebase.json --only auth,firestore --project demo-kip 'NEXT_PUBLIC_AUTH_EMULATOR=1 next dev --turbo --port 3001'",
+ "prepare": "svelte-kit sync",
+ "dev": "vite dev",
+ "dev:emulated": "firebase emulators:exec --config ../firebase.json --only auth,firestore --project demo-kip 'VITE_AUTH_EMULATOR=1 vite dev --port 3001 --strictPort'",
"check:portal": "node scripts/portal-check.mjs",
- "export": "next build",
- "fmt": "biome format --write",
- "lint": "tsc && tsc --noEmit -p tsconfig.sw.json && biome check && tsc --noEmit -p ../functions",
+ "export": "vite build",
+ "fmt": "biome check --write",
+ "lint": "svelte-kit sync && svelte-check --fail-on-warnings && tsc --noEmit -p tsconfig.sw.json && biome check && tsc --noEmit -p ../functions",
"test": "bun test --path-ignore-patterns '**/rules.test.ts'",
"test:rules": "firebase emulators:exec --config ../firebase.json --only firestore,auth --project demo-kip-rules 'bun test tests/rules.test.ts'",
"check:host": "node scripts/host-check.mjs",
@@ -27,27 +32,26 @@
"admin": "bun run scripts/admin.ts",
"build:sw": "tsc -p tsconfig.sw.json"
},
- "dependencies": {
- "firebase": "^12.19.0",
- "geofire-common": "^6.0.0",
- "next": "^16.3.6",
- "next-themes": "^0.4.6",
- "react": "^19.3.0",
- "react-dom": "^19.3.0",
- "react-icons": "^5.7.0"
- },
"devDependencies": {
- "@biomejs/biome": "^2.5.14",
+ "@biomejs/biome": "^2.5.15",
"@firebase/rules-unit-testing": "^5.0.2",
- "@tailwindcss/postcss": "^4.3.3",
+ "@fontsource-variable/plus-jakarta-sans": "^5.3.0",
+ "@iconify-json/fa6-brands": "^1.2.6",
+ "@iconify-json/lucide": "^1.2.141",
+ "@sveltejs/adapter-static": "^4.0.0",
+ "@sveltejs/kit": "^3.0.1",
+ "@sveltejs/vite-plugin-svelte": "^7.3.1",
+ "@tailwindcss/vite": "^4.3.3",
"@types/bun": "^1.4.2",
"@types/node": "^26.6.3",
- "@types/react": "^19.3.0",
- "@types/react-dom": "^19.3.0",
- "autoprefixer": "^10.6.1",
+ "firebase": "^12.19.0",
"firebase-admin": "^14.5.0",
- "postcss": "^8.5.28",
+ "geofire-common": "^6.0.0",
+ "svelte": "^5.57.2",
+ "svelte-check": "^4.7.6",
"tailwindcss": "^4.3.3",
- "typescript": "~7.0.2"
+ "typescript": "~6.0.3",
+ "unplugin-icons": "^24.0.0",
+ "vite": "^8.3.3"
}
}
diff --git a/web/postcss.config.js b/web/postcss.config.js
deleted file mode 100644
index a9b5c43..0000000
--- a/web/postcss.config.js
+++ /dev/null
@@ -1 +0,0 @@
-export default { plugins: { "@tailwindcss/postcss": {} } };
diff --git a/web/scripts/consent-check.mjs b/web/scripts/consent-check.mjs
index e13adb3..f0e4205 100644
--- a/web/scripts/consent-check.mjs
+++ b/web/scripts/consent-check.mjs
@@ -28,7 +28,7 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
// gate is the behaviour while it stands, and a check that quietly passes over a
// disabled feature is how the gate would get removed by accident.
const SMS_LIVE = /SMS_FROM\s*=\s*"([^"]*)"/.exec(
- await readFile(new URL("../utils/sms.ts", import.meta.url), "utf8"),
+ await readFile(new URL("../src/lib/sms.ts", import.meta.url), "utf8"),
)?.[1];
const APP = "http://localhost:3001";
@@ -183,7 +183,7 @@ async function browser() {
await send("Runtime.enable");
return {
thrown,
- // A REAL paste. `setValue` below dispatches a synthetic React input event,
+ // A REAL paste. `setValue` below dispatches a synthetic input event,
// which is not the path a paste takes — and the path a paste takes is the
// one `maxLength` used to truncate.
paste: async (text) => {
diff --git a/web/scripts/exif-check.mjs b/web/scripts/exif-check.mjs
index ea6c2f3..f9df90d 100644
--- a/web/scripts/exif-check.mjs
+++ b/web/scripts/exif-check.mjs
@@ -112,7 +112,7 @@ const bundle = (() => {
const out = join(dir, "bundle.js");
writeFileSync(
entry,
- `import { shrink, PhotoEncodeError } from ${JSON.stringify(join(WEB, "utils", "photos.ts"))};\n` +
+ `import { shrink, PhotoEncodeError } from ${JSON.stringify(join(WEB, "src", "lib", "photos.ts"))};\n` +
"globalThis.__shrink = shrink;\n" +
"globalThis.__PhotoEncodeError = PhotoEncodeError;\n",
);
diff --git a/web/scripts/feedback-check.mjs b/web/scripts/feedback-check.mjs
index d71c456..5e04570 100644
--- a/web/scripts/feedback-check.mjs
+++ b/web/scripts/feedback-check.mjs
@@ -71,7 +71,7 @@ async function browser() {
await new Promise((r) => (ws.onopen = r));
let id = 0;
const waiting = new Map();
- // `app/error.tsx` paints over a render throw, so the stack survives only in
+ // `+error.svelte` paints over a render throw, so the stack survives only in
// these page-level events.
const thrown = [];
ws.onmessage = (event) => {
diff --git a/web/scripts/host-check.mjs b/web/scripts/host-check.mjs
index fb33455..d4d857c 100644
--- a/web/scripts/host-check.mjs
+++ b/web/scripts/host-check.mjs
@@ -570,7 +570,7 @@ async function houseWindows() {
}
// Helpers the page-side snippets share: find a control by its words, type into
-// a field the way React hears it, and read whichever sheet is on top.
+// a field the way the app hears it, and read whichever sheet is on top.
const IN_PAGE = `
const nap = (ms) => new Promise(r => setTimeout(r, ms));
const type = (el, v) => {
diff --git a/web/scripts/portal-check.mjs b/web/scripts/portal-check.mjs
index c7eb500..9c1871f 100644
--- a/web/scripts/portal-check.mjs
+++ b/web/scripts/portal-check.mjs
@@ -5,7 +5,7 @@
//
// It cannot gate CI — it wants a browser and a dev server — and that is not a
// reason to skip it. Run it by hand after touching the portal, the identity
-// sheet, or anything in `utils/auth.ts`:
+// sheet, or anything in `src/lib/auth.ts`:
//
// cd web && bun run dev:emulated # in one shell (serves on 3001)
// bun run check:portal # in another
@@ -17,7 +17,7 @@
import { spawn, spawnSync } from "node:child_process";
import { rm } from "node:fs/promises";
-// 3001, not Next's default — Erik's own dev server lives on 3000, and an
+// 3001, not the dev server's default — Erik's own dev server lives on 3000, and an
// emulated server there would quietly answer for it. `dev:emulated` pins the
// same port, so the two cannot drift apart.
const APP = process.env.KIP_ORIGIN ?? "http://localhost:3001";
@@ -142,7 +142,7 @@ async function browser() {
await new Promise((r) => (ws.onopen = r));
let id = 0;
const waiting = new Map();
- // Page-level events, not an in-page hook: `app/error.tsx` catches a render
+ // Page-level events, not an in-page hook: `+error.svelte` catches a render
// throw before any hook installed after navigation could see it.
const thrown = [];
ws.onmessage = (event) => {
diff --git a/web/scripts/pwa-check.mjs b/web/scripts/pwa-check.mjs
index 3ac62ae..efe6dff 100644
--- a/web/scripts/pwa-check.mjs
+++ b/web/scripts/pwa-check.mjs
@@ -4,7 +4,7 @@
// cd web && bun run check:pwa
//
// It builds the export and serves it itself, because both claims are about the
-// PRODUCTION bundle: `next dev` serves modules a cache would hand back stale, so
+// PRODUCTION bundle: the dev server serves modules a cache would hand back stale, so
// the worker deliberately does not register there and none of this is reachable
// from the dev server the other checks use. No emulator: the export carries the
// real project's config, so every request to Firebase Auth is failed in the
@@ -42,7 +42,7 @@ function expect(what, ok, detail = "") {
function runExport() {
const done = spawnSync("bun", ["run", "export"], {
stdio: "inherit",
- env: { ...process.env, NEXT_PUBLIC_BASE_PATH: BASE },
+ env: { ...process.env, VITE_BASE_PATH: BASE },
});
if (done.status !== 0) throw new Error("export failed");
}
@@ -59,10 +59,19 @@ if (BASE) {
// Serving `out/` rather than pointing at Pages: the worker needs a secure
// context, and localhost is one.
-const server = spawn("python3", ["-m", "http.server", String(PORT)], {
- cwd: SERVE,
- stdio: "ignore",
-});
+//
+// With a listen backlog raised from Python's default of 5: the build boots by
+// fetching some thirty small modules at once, and a queue that short resets
+// whichever connections don't fit — a cold load then fails on a missing chunk
+// about half the time, for a reason that has nothing to do with kip.
+const server = spawn(
+ "python3",
+ [
+ "-c",
+ `import http.server as h; h.ThreadingHTTPServer.request_queue_size = 128; h.test(HandlerClass=h.SimpleHTTPRequestHandler, ServerClass=h.ThreadingHTTPServer, port=${PORT})`,
+ ],
+ { cwd: SERVE, stdio: "ignore" },
+);
process.on("exit", () => server.kill());
await new Promise((done) => setTimeout(done, 2500));
@@ -267,12 +276,12 @@ expect(
"the pages are cached, keyed on the path alone",
keys.some((key) => key.endsWith(`${BASE}/portal/`)) &&
keys.some((key) => key.endsWith(`${BASE}/continue/`)),
- keys.filter((key) => !key.includes("/_next/")).join(" "),
+ keys.filter((key) => !key.includes("/_app/")).join(" "),
);
console.log(
" documents held:",
- keys.filter((key) => !key.includes("/_next/")).join(" ") || "(none)",
+ keys.filter((key) => !key.includes("/_app/")).join(" ") || "(none)",
);
console.log("\nand it opens with the network pulled");
diff --git a/web/app/globals.css b/web/src/app.css
similarity index 68%
rename from web/app/globals.css
rename to web/src/app.css
index 2dff850..14f1971 100644
--- a/web/app/globals.css
+++ b/web/src/app.css
@@ -1,5 +1,23 @@
@import "tailwindcss";
-@config "../tailwind.config.js";
+/* Plus Jakarta Sans, self-hosted: one variable file per script carries every
+ weight the app sets, and a browser fetches only the scripts a page uses. */
+@import "@fontsource-variable/plus-jakarta-sans/wght.css";
+
+/* Arial scaled to Plus Jakarta Sans's metrics. It stands in until the real file
+ arrives, so the swap doesn't move the page, and it draws the few characters
+ the font has no glyph for (the arrow in "→" links). */
+@font-face {
+ font-family: "Plus Jakarta Sans Fallback";
+ src: local("Arial");
+ ascent-override: 98.88%;
+ descent-override: 21.15%;
+ line-gap-override: 0%;
+ size-adjust: 104.98%;
+}
+
+/* The theme is a class on , set before first paint by the script in
+ app.html and kept current by `lib/theme.svelte.ts`. */
+@custom-variant dark (&:where(.dark, .dark *));
/* Terra design language. Warm near-white canvas, true-white cards elevated by
layered soft shadows (borders disappear except on inputs), a terracotta→amber
@@ -29,17 +47,26 @@
--color-danger: #c0492f;
--color-danger-soft: rgba(192, 73, 47, 0.1);
- --gradient-accent: linear-gradient(135deg, #e2582f 0%, #ee8438 55%, #f6b24a 100%);
+ --gradient-accent: linear-gradient(
+ 135deg,
+ #e2582f 0%,
+ #ee8438 55%,
+ #f6b24a 100%
+ );
--shadow-soft: 0 1px 2px rgba(50, 25, 8, 0.05);
- --shadow-card: 0 1px 2px rgba(50, 25, 8, 0.04), 0 10px 26px rgba(50, 25, 8, 0.07);
- --shadow-panel: 0 2px 6px rgba(50, 25, 8, 0.05), 0 22px 48px rgba(50, 25, 8, 0.12);
- --shadow-dock: 0 8px 20px rgba(50, 25, 8, 0.12), 0 20px 44px rgba(50, 25, 8, 0.16);
+ --shadow-card:
+ 0 1px 2px rgba(50, 25, 8, 0.04), 0 10px 26px rgba(50, 25, 8, 0.07);
+ --shadow-panel:
+ 0 2px 6px rgba(50, 25, 8, 0.05), 0 22px 48px rgba(50, 25, 8, 0.12);
+ --shadow-dock:
+ 0 8px 20px rgba(50, 25, 8, 0.12), 0 20px 44px rgba(50, 25, 8, 0.16);
--shadow-glow: 0 6px 18px rgba(221, 95, 56, 0.34);
--font-sans:
- var(--font-jakarta), ui-sans-serif, system-ui, -apple-system, "Segoe UI",
- Roboto, "Helvetica Neue", Arial, sans-serif;
+ "Plus Jakarta Sans Variable", "Plus Jakarta Sans Fallback", ui-sans-serif,
+ system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial,
+ sans-serif;
/* Terra's rounder scale: pills for controls, soft 2xl/3xl for surfaces. */
--radius-md: 0.75rem;
@@ -94,13 +121,24 @@ body {
/* A very faint sunset glow on the canvas — subtle, fixed, behind everything. */
body {
background-image:
- radial-gradient(1200px 620px at 100% -5%, rgba(246, 178, 74, 0.14), transparent 60%),
- radial-gradient(1000px 640px at -5% 0%, rgba(221, 95, 56, 0.08), transparent 55%);
+ radial-gradient(
+ 1200px 620px at 100% -5%,
+ rgba(246, 178, 74, 0.14),
+ transparent 60%
+ ),
+ radial-gradient(
+ 1000px 640px at -5% 0%,
+ rgba(221, 95, 56, 0.08),
+ transparent 55%
+ );
background-attachment: fixed;
}
.dark body {
- background-image:
- radial-gradient(1200px 620px at 100% -5%, rgba(246, 178, 74, 0.08), transparent 60%);
+ background-image: radial-gradient(
+ 1200px 620px at 100% -5%,
+ rgba(246, 178, 74, 0.08),
+ transparent 60%
+ );
}
/* Heading style: the sans at a tighter track + heavier weight. Kept as a class
diff --git a/web/src/app.d.ts b/web/src/app.d.ts
new file mode 100644
index 0000000..cf07df4
--- /dev/null
+++ b/web/src/app.d.ts
@@ -0,0 +1,17 @@
+import type { Screen } from "$lib/types";
+
+declare global {
+ interface Window {
+ // An install prompt that arrived before the app did; see `app.html`.
+ kipInstallPrompt?: Event;
+ }
+
+ namespace App {
+ // What each history entry of the app carries; see `$lib/store.svelte.ts`.
+ interface PageState {
+ kipStack?: readonly Screen[];
+ kipDepth?: number;
+ kipEntry?: string;
+ }
+ }
+}
diff --git a/web/src/app.html b/web/src/app.html
new file mode 100644
index 0000000..939779d
--- /dev/null
+++ b/web/src/app.html
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+ %sveltekit.head%
+
+
+
%sveltekit.body%
+
+
diff --git a/web/src/hooks.server.ts b/web/src/hooks.server.ts
new file mode 100644
index 0000000..361ff68
--- /dev/null
+++ b/web/src/hooks.server.ts
@@ -0,0 +1,33 @@
+import type { Handle } from "@sveltejs/kit/hooks";
+
+const VERBATIM = /<(script|style)\b[\s\S]*?<\/\1>/g;
+const LINE_BREAK = /\s*\n\s*/g;
+
+// The exported HTML keeps the line breaks its source was wrapped at, which a
+// browser collapses and a `grep` does not — and the Privacy and Terms pages are
+// checked, here and by the carriers' reviewers, by searching the served file
+// for whole sentences. Each break becomes the one space it renders as, so no
+// node is added or removed and hydration finds what it expects. Scripts and
+// styles are left exactly as written.
+function unwrap(html: string): string {
+ let unwrapped = "";
+ let copied = 0;
+ for (const match of html.matchAll(VERBATIM)) {
+ unwrapped += html.slice(copied, match.index).replace(LINE_BREAK, " ");
+ unwrapped += match[0];
+ copied = match.index + match[0].length;
+ }
+ return unwrapped + html.slice(copied).replace(LINE_BREAK, " ");
+}
+
+// Runs in dev and while prerendering; the release has no server at all.
+export const handle: Handle = ({ event, resolve }) =>
+ resolve(event, {
+ transformPageChunk: ({ html }) => unwrap(html),
+ // The Latin file is the one every page draws its first text in; the other
+ // scripts are fetched only by a page that uses them.
+ preload: ({ type, path }) =>
+ type === "js" ||
+ type === "css" ||
+ (type === "font" && path.includes("-latin-wght-")),
+ });
diff --git a/web/utils/auth.ts b/web/src/lib/auth.ts
similarity index 99%
rename from web/utils/auth.ts
rename to web/src/lib/auth.ts
index 5ae8716..3903606 100644
--- a/web/utils/auth.ts
+++ b/web/src/lib/auth.ts
@@ -1,5 +1,3 @@
-"use client";
-
import {
type AuthError,
type ConfirmationResult,
@@ -16,6 +14,7 @@ import {
type User,
unlink,
} from "firebase/auth";
+import { BASE_PATH } from "./base";
import { auth, errorCode } from "./firebase";
// Every door here LINKS onto whoever is signed in, so the uid survives and a
@@ -237,9 +236,8 @@ export type ContinuePayload = {
// query would have left an ATTACH link looking exactly like a RETURNING one, and
// signed someone into a second account instead of joining the first.
function continueUrl(payload: ContinuePayload): string {
- const base = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
const carried = new URLSearchParams(payload).toString();
- return `${window.location.origin}${base}/continue/#${carried}`;
+ return `${window.location.origin}${BASE_PATH}/continue/#${carried}`;
}
// Attaches an address to the account that is ALREADY asking, rather than making
diff --git a/web/src/lib/base.ts b/web/src/lib/base.ts
new file mode 100644
index 0000000..7f7ebe9
--- /dev/null
+++ b/web/src/lib/base.ts
@@ -0,0 +1,5 @@
+// Unset in the release, which serves kip at the root of its own domain. Set
+// `VITE_BASE_PATH` to serve under a path (e.g. `/kip`): `vite.config.ts` reads
+// the same variable, and everything that builds a URL by hand reads it here.
+// Not `$app/paths`, so the modules that need it stay importable under `bun test`.
+export const BASE_PATH: string = import.meta.env.VITE_BASE_PATH ?? "";
diff --git a/web/utils/bookings.ts b/web/src/lib/bookings.ts
similarity index 99%
rename from web/utils/bookings.ts
rename to web/src/lib/bookings.ts
index ed2d0ee..db5274b 100644
--- a/web/utils/bookings.ts
+++ b/web/src/lib/bookings.ts
@@ -1,5 +1,3 @@
-"use client";
-
import {
addDoc,
arrayUnion,
diff --git a/web/utils/checkout.ts b/web/src/lib/checkout.ts
similarity index 100%
rename from web/utils/checkout.ts
rename to web/src/lib/checkout.ts
diff --git a/web/src/lib/components/abandoned-photos.svelte.ts b/web/src/lib/components/abandoned-photos.svelte.ts
new file mode 100644
index 0000000..cba6426
--- /dev/null
+++ b/web/src/lib/components/abandoned-photos.svelte.ts
@@ -0,0 +1,30 @@
+import { deleteListingPhoto } from "../photos";
+import type { ListingPhoto } from "../types";
+
+/**
+ * Delete photos uploaded for a room that was never saved.
+ *
+ * Photos upload before a new room is saved, so closing the sheet without saving
+ * strands them — the same leak the new-place form cleans up after itself. Call
+ * while a component initializes, passing the photos as a function so the ones
+ * held when it goes away are the ones deleted; call the returned function once
+ * they have been saved, to keep them.
+ */
+export function abandonedPhotos(
+ ownerId: string,
+ listingId: string,
+ photos: () => readonly ListingPhoto[],
+): () => void {
+ let kept = false;
+ $effect(() => () => {
+ if (kept) return;
+ for (const photo of photos()) {
+ deleteListingPhoto(ownerId, listingId, photo.id).catch((error) =>
+ console.warn("abandoned room photo", error),
+ );
+ }
+ });
+ return () => {
+ kept = true;
+ };
+}
diff --git a/web/src/lib/components/account-section.svelte b/web/src/lib/components/account-section.svelte
new file mode 100644
index 0000000..7a86368
--- /dev/null
+++ b/web/src/lib/components/account-section.svelte
@@ -0,0 +1,104 @@
+
+
+{#if kip.user}
+ {@const uid = kip.user.uid}
+
+
+ kip.navigate({ kind: "person", id: uid })}
+ ariaLabel="Name and photo"
+ >
+
+
+ Name and photo
+
+
+ Edit them on your profile
+
+
+
+
+
+
+
+
+
+
+ {#if kip.anonymous}
+
+
+
+ {leaver.leaving ? "Leaving…" : "Leave kip"}
+
+
+ Cancels your stays and removes you from friends' lists
+
+
+
+ {:else}
+
+
+
+ {leaving ? "Deleting…" : "Delete your kip"}
+
+
+ Cancels your stays and removes you from friends' lists
+
+
+
+ {/if}
+
+
+{/if}
diff --git a/web/src/lib/components/add-dates-sheet.svelte b/web/src/lib/components/add-dates-sheet.svelte
new file mode 100644
index 0000000..bc0a445
--- /dev/null
+++ b/web/src/lib/components/add-dates-sheet.svelte
@@ -0,0 +1,206 @@
+
+
+
+
+
+
+ Add some friends first — when they share a place, you'll see it here.
+
+ {:else if matches.length === 0}
+
+ {kip.criteria.near
+ ? "No friends' places match — try a wider radius or dates."
+ : "No friends' places free right now. Try widening your dates."}
+
+ It stopped after {request.attempts}
+ {request.attempts === 1 ? "attempt" : "attempts"}{request.phase
+ ? `, at ${DELETION_LABELS[request.phase].toLowerCase()}`
+ : ""}. What it got through has already happened and doesn't come back —
+ cancelled stays, and friends whose lists you have gone from. The rest of your
+ account is still here.
+
+
+ act(true)} disabled={busy}>
+ Try deleting again
+
+ act(false)} disabled={busy}>
+ Keep my account for now
+
+
+{#if error}
+ {error}
+{/if}
+
+ Stuck again? Someone will finish it by hand — email
+ {CONTACT_EMAIL}.
+
diff --git a/web/src/lib/components/dialog.svelte b/web/src/lib/components/dialog.svelte
new file mode 100644
index 0000000..3031890
--- /dev/null
+++ b/web/src/lib/components/dialog.svelte
@@ -0,0 +1,128 @@
+
+
+
+
+{#if active}
+ close(false)} title={active.options.title}>
+ {#if active.options.body}
+
+ {/if}
+
+
+
+ kip.setShareStays(next)}
+ label="Let friends see where I'll be staying"
+ description="When on, confirmed stays can appear to your friends. When off, your trips stay private to you and the host."
+ />
+
+ {#if kip.prefs.profilePortalId}
+
+
+
+ Copy your link
+
+
+ Copy or regenerate it on your profile
+
+
+
+
+ {/if}
+
+
+ {#if error}
+ {error}
+ {/if}
+
+{/if}
diff --git a/web/src/lib/components/doc-card.svelte b/web/src/lib/components/doc-card.svelte
new file mode 100644
index 0000000..9bace75
--- /dev/null
+++ b/web/src/lib/components/doc-card.svelte
@@ -0,0 +1,9 @@
+
+
+
+{/snippet}
+
+
+
+ remove(EMAIL_DOOR, "email") : null}
+ />
+ remove(PHONE_DOOR, "phone") : null}
+ />
+ remove(GOOGLE_DOOR, "Google") : null}
+ />
+
+
+ {#if error && !sheet}
+ {error}
+ {:else}
+
+ {kip.doors.length > 1
+ ? "Never shown to other users or used to find you."
+ : "kip has no password, so these are the only ways back in — a second one means losing the first isn't losing your kip."}
+
+ {/if}
+
+
+ {#if sentTo}
+
+
+ Check {sentTo} and open the link — that's what attaches it. It lands
+ on this account whichever device opens it. Reload kip afterwards to
+ see it here.
+
+ {!hasAttention && comingUp.length === 0
+ ? "Nothing here yet. Add friends and browse the places they share."
+ : "No friends' places free right now."}
+
+ {:else}
+
+ {#each availablePreview as match (match.listing.id)}
+
+ Check {sentTo} and open the link — that's what keeps your kip if you
+ switch phones. Nothing is waiting on it; you can close this.
+
+ {
+ held = null;
+ }}
+ >
+ Done
+
+
+ {:else}
+
+ {/if}
+
diff --git a/web/src/lib/components/nav-items.ts b/web/src/lib/components/nav-items.ts
new file mode 100644
index 0000000..8909441
--- /dev/null
+++ b/web/src/lib/components/nav-items.ts
@@ -0,0 +1,44 @@
+import LuDoorOpen from "~icons/lucide/door-open";
+import LuHouse from "~icons/lucide/house";
+import LuLuggage from "~icons/lucide/luggage";
+import LuSearch from "~icons/lucide/search";
+import LuUsers from "~icons/lucide/users";
+import type { Icon } from "../listing-icons";
+import { kip } from "../store.svelte";
+import type { View } from "../types";
+
+export type NavItem = {
+ readonly view: View;
+ readonly label: string;
+ readonly icon: Icon;
+ readonly badge: number;
+};
+
+// The primary destinations, shared by the desktop top bar and the mobile dock.
+// Settings lives in the profile menu (AuthMenu) instead, to keep it to five
+// thumb-sized tabs. Reads the store, so call it from a `$derived`.
+export function navItems(): NavItem[] {
+ const pendingBookings = kip.incomingBookings.filter(
+ (booking) => booking.status === "REQUESTED",
+ ).length;
+
+ return [
+ { view: "home", label: "Home", icon: LuHouse, badge: 0 },
+ { view: "browse", label: "Browse", icon: LuSearch, badge: 0 },
+ {
+ view: "places",
+ label: "Places",
+ icon: LuDoorOpen,
+ // Stays people are asking for. Friend asks are counted on Friends, not
+ // here — they were double-counted when the two request kinds merged names.
+ badge: pendingBookings,
+ },
+ { view: "trips", label: "Trips", icon: LuLuggage, badge: 0 },
+ {
+ view: "friends",
+ label: "Friends",
+ icon: LuUsers,
+ badge: kip.incomingRequests.length,
+ },
+ ];
+}
diff --git a/web/src/lib/components/notifications-section.svelte b/web/src/lib/components/notifications-section.svelte
new file mode 100644
index 0000000..671eb36
--- /dev/null
+++ b/web/src/lib/components/notifications-section.svelte
@@ -0,0 +1,335 @@
+
+
+
+
+{#if kip.user}
+
+
+
+
+ {#if kip.email}
+ {#if !kip.emailVerified}
+
+
+ kip only emails an address that's been confirmed — otherwise
+ anyone could enter someone else's and have kip mail them. Confirm
+ {kip.email}
+ to start receiving these.
+
+ {kip.phone && SMS_LIVE
+ ? "kip has no address for you, so none of this arrives by email. A text can still reach you — turn those on below."
+ : "kip has no way to reach you when you're not looking at it. Add an address to hear when things happen."}
+
+ Add email
+
+ {/if}
+
+
+ {#each EVENTS as [key, event] (key)}
+ kip.setNotify(key as NotifyKind, next)}
+ label={event.label}
+ description={event.note}
+ srSuffix="by email"
+ />
+ {/each}
+
+
+
+
+
+
+
+ {#each EVENTS as [key, event] (key)}
+ {#if event.sms}
+
+ toggleKind(key as NotifySmsKind, next)}
+ label={event.label}
+ srSuffix="by text"
+ />
+ {:else}
+
+ {}}
+ label={event.label}
+ unavailable="Email only"
+ />
+ {/if}
+ {/each}
+
+
+ {#if textError}
+ {textError}
+ {/if}
+
+
+ {#if SMS_LIVE && blocked}
+
+
+ Your carrier is blocking kip's texts: STOP was replied from
+ {kip.phone ?? "your number"}. kip can't lift that.
+ {#if SMS_FROM}
+ Text START to
+ {formatUsNumber(SMS_FROM)}, then check below.
+ {:else}
+ Text START to the number kip texted you from, and this clears the
+ next time a text gets through.
+ {/if}
+
+ {#if SMS_FROM}
+
+
+ {#if checking}
+
+ {:else}
+ Check now
+ {/if}
+
+ {#if checkedStillBlocked}
+ kip tried a text; the block is still there.
+ {:else if probe === "stalled"}
+ That check didn't run. Try again.
+ {/if}
+
+ {/if}
+
+ {/if}
+
+
+
+ See our
+ Privacy Policy{" "}and{" "}Terms.
+
+
+
+ {#if !kip.prefs.notify.stayCancelled &&
+ !(texting && kip.prefs.notifySms.stayCancelled)}
+
+ Nothing will tell you if a stay you're counting on is called off — no
+ email, no text. You'd only find out by opening kip.
+
+ {/if}
+
+
+{/if}
diff --git a/web/src/lib/components/offer-heading.svelte b/web/src/lib/components/offer-heading.svelte
new file mode 100644
index 0000000..551209e
--- /dev/null
+++ b/web/src/lib/components/offer-heading.svelte
@@ -0,0 +1,32 @@
+
+
+
{
+ // Only files: a photo being dragged within the strip, or text, or a
+ // link, must not arm the target for an upload.
+ if (accepting && event.dataTransfer?.types.includes("Files")) {
+ dropping = true;
+ }
+ }}
+ ondragover={(event) => {
+ // Without cancelling the drag-over the browser never fires a drop here.
+ // A photo drag is the thumbnails' business, so it is left alone: one
+ // released between them carries no files and lands as a no-op rather
+ // than being taken for an upload.
+ if (!accepting || isPhotoDrag(event)) return;
+ event.preventDefault();
+ if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
+ }}
+ ondragleave={(event) => {
+ // Crossing a child fires dragleave for the one being left, so ignore
+ // any whose destination is still inside the strip (null = left the
+ // window).
+ const next = event.relatedTarget;
+ if (!(next instanceof Node) || !event.currentTarget.contains(next)) {
+ dropping = false;
+ overId = null;
+ }
+ }}
+ {ondrop}
+ class="flex gap-2 overflow-x-auto rounded-2xl border px-1 py-1 transition {dropping
+ ? "border-accent bg-accent-soft"
+ : "border-transparent"}"
+ >
+ {#each order as { id: photoId, url }, index (photoId)}
+
+
+
{
+ // The id rides in a type of our own so a drop anywhere else reads
+ // as nothing, and so the file-drop handlers can ignore it.
+ event.dataTransfer?.setData(PHOTO_DRAG_TYPE, photoId);
+ if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
+ draggingId = photoId;
+ }}
+ ondragend={() => {
+ draggingId = null;
+ overId = null;
+ }}
+ ondragover={(event) => {
+ if (!sortable || busy || !isPhotoDrag(event)) return;
+ event.preventDefault();
+ if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
+ overId = photoId;
+ }}
+ ondrop={(event) => {
+ if (!isPhotoDrag(event)) return;
+ event.preventDefault();
+ // The strip's own drop handler adds files; this one is already
+ // dealt with, so don't let it get there.
+ event.stopPropagation();
+ overId = null;
+ draggingId = null;
+ const dragged = event.dataTransfer?.getData(PHOTO_DRAG_TYPE);
+ if (dragged && dragged !== photoId) {
+ reorder(reordered(order, dragged, index)).catch((caught) =>
+ console.error("reorder", caught),
+ );
+ }
+ }}
+ class="relative h-24 w-24 shrink-0 overflow-hidden rounded-2xl bg-surface-muted transition {draggingId ===
+ photoId
+ ? "opacity-40"
+ : ""} {overId === photoId && draggingId !== photoId
+ ? "ring-2 ring-accent"
+ : ""}"
+ >
+
+
+ {#if editable && index === 0}
+
+ Cover
+
+ {/if}
+ {#if editable}
+ remove(photoId)}
+ disabled={busy}
+ aria-label="Remove photo"
+ class="absolute right-1 top-1 {OVERLAY_CONTROL}"
+ >
+
+
+ {/if}
+ {#if sortable && index > 0}
+ move(photoId, -1)}
+ disabled={busy}
+ aria-label={`Move photo ${index + 1} left`}
+ class="absolute bottom-1 left-1 {OVERLAY_CONTROL}"
+ >
+
+
+ {/if}
+ {#if sortable && index < order.length - 1}
+ move(photoId, 1)}
+ disabled={busy}
+ aria-label={`Move photo ${index + 1} right`}
+ class="absolute bottom-1 right-1 {OVERLAY_CONTROL}"
+ >
+
+
+ {/if}
+
+
+ {#if photoURL && photoURL !== kip.providerPhotoURL}
+ change(null)}
+ disabled={busy}
+ aria-label="Remove your photo"
+ title="Remove your photo"
+ class="absolute bottom-0 left-0 {PHOTO_CONTROL}"
+ >
+
+
+ {/if}
+ fileInput?.click()}
+ disabled={busy}
+ aria-label="Change your photo"
+ title="Change your photo"
+ class="absolute bottom-0 right-0 {PHOTO_CONTROL}"
+ >
+ {#if busy}
+
+ {:else}
+
+ {/if}
+
+ {
+ // Cancelling the picker fires nothing, so null would mean "remove".
+ const chosen = event.currentTarget.files?.[0];
+ if (chosen) void change(chosen);
+ }}
+ >
+
+ {#if error}
+ {error}
+ {/if}
+
diff --git a/web/components/pwa.tsx b/web/src/lib/components/pwa.svelte
similarity index 55%
rename from web/components/pwa.tsx
rename to web/src/lib/components/pwa.svelte
index c54677e..ce9924c 100644
--- a/web/components/pwa.tsx
+++ b/web/src/lib/components/pwa.svelte
@@ -1,26 +1,25 @@
-"use client";
+
diff --git a/web/src/lib/components/rail-fade.svelte.ts b/web/src/lib/components/rail-fade.svelte.ts
new file mode 100644
index 0000000..80d0df9
--- /dev/null
+++ b/web/src/lib/components/rail-fade.svelte.ts
@@ -0,0 +1,52 @@
+// How far a masked rail edge fades.
+const FADE = "1.5rem";
+
+/**
+ * Fades whichever edge of a horizontally scrolling rail has more beyond it.
+ *
+ * Masked rather than overlaid with a gradient, because the same rail sits on
+ * the canvas in one place and on a card in another, and an overlay would need
+ * to know. Create one while a component initializes, bind `node` to the rail
+ * and set `maskImage` as its `mask-image`. `count` re-measures when an item is
+ * added or removed, which the observer can't see, since the rail's own box
+ * doesn't change size when its contents do.
+ */
+export class RailFade {
+ /** The scrolling element; bind it with `bind:this`. */
+ node = $state();
+ #before = $state(false);
+ #after = $state(false);
+
+ constructor(count: () => number) {
+ $effect(() => {
+ count();
+ const { node } = this;
+ if (!node) return;
+ const measure = (): void => {
+ this.#before = node.scrollLeft > 1;
+ this.#after = node.scrollWidth - node.clientWidth - node.scrollLeft > 1;
+ };
+ measure();
+ // What fits changes on rotate and any layout shift, not only on add.
+ const observer = new ResizeObserver(measure);
+ observer.observe(node);
+ node.addEventListener("scroll", measure);
+ return () => {
+ observer.disconnect();
+ node.removeEventListener("scroll", measure);
+ };
+ });
+ }
+
+ /** The mask to apply, or undefined while neither edge has more beyond it. */
+ get maskImage(): string | undefined {
+ if (!this.#before && !this.#after) return undefined;
+ const stops = [
+ this.#before ? "transparent 0" : "black 0",
+ this.#before ? `black ${FADE}` : null,
+ this.#after ? `black calc(100% - ${FADE})` : null,
+ this.#after ? "transparent 100%" : "black 100%",
+ ].filter((stop): stop is string => stop !== null);
+ return `linear-gradient(to right, ${stops.join(", ")})`;
+ }
+}
diff --git a/web/src/lib/components/reach-field.svelte b/web/src/lib/components/reach-field.svelte
new file mode 100644
index 0000000..9dfa1c4
--- /dev/null
+++ b/web/src/lib/components/reach-field.svelte
@@ -0,0 +1,104 @@
+
+
+
+{#if reach.pending}
+
+
+ We texted a code to {reach.sentTo}. Standard message rates apply.
+
+ Nothing saved yet. Set some filters, then keep them here.
+
+ {/if}
+
diff --git a/web/src/lib/components/self-identity.svelte b/web/src/lib/components/self-identity.svelte
new file mode 100644
index 0000000..aa73199
--- /dev/null
+++ b/web/src/lib/components/self-identity.svelte
@@ -0,0 +1,165 @@
+
+
+{#snippet verdict()}
+ {#if checking}
+
+ {:else if available === true}
+
+ {:else if available === false}
+
+ {/if}
+{/snippet}
+
+{#if kip.profile}
+
+
+
+
+ {#if claiming && !handle}
+
+
+ {#if formatError}
+ {formatError}
+ {:else if available === false}
+ @{normalized} is taken.
+ {:else if available === true}
+ @{normalized} is available.
+ {:else}
+
+ Letters, numbers and _, starting with a letter. Permanent once
+ claimed.
+
+ {/if}
+
+ These dates have passed —
+ {booked ? "someone stayed" : "nobody booked them"}. There's nothing left
+ to change about the dates; new availability means new dates. You can
+ still clear these off your calendar.
+
+ {#each BOXES as name, index (name)}
+ {@const live = focused && index === next}
+ {@const ring = live
+ ? "border-accent ring-2 ring-accent/20"
+ : "border-border"}
+
+ {value[index] ?? ""}
+
+ {/each}
+
+
+ {
+ const cleaned = event.currentTarget.value
+ .replace(/\D/g, "")
+ .slice(0, CODE_LENGTH);
+ // Written back at once: when the cleaned value is what the caller already
+ // holds, nothing would otherwise put it back in the field.
+ event.currentTarget.value = cleaned;
+ onchange(cleaned);
+ }}
+ onfocus={() => {
+ focused = true;
+ toEnd();
+ }}
+ onblur={() => {
+ focused = false;
+ }}
+ onclick={toEnd}
+ class="absolute inset-0 h-full w-full bg-transparent text-transparent caret-transparent outline-none selection:bg-transparent"
+ >
+
+{:else}
+ onchange(!checked)}
+ class="flex w-full items-center gap-3 px-4 py-3 text-left disabled:opacity-50"
+ >
+
+
+ {label}
+ {#if srSuffix}
+ {` ${srSuffix}`}
+ {/if}
+
+ {#if description}
+ {description}
+ {/if}
+
+
+
+
+
+{/if}
diff --git a/web/src/lib/components/ui/textarea.svelte b/web/src/lib/components/ui/textarea.svelte
new file mode 100644
index 0000000..08234b2
--- /dev/null
+++ b/web/src/lib/components/ui/textarea.svelte
@@ -0,0 +1,18 @@
+
+
+
diff --git a/web/components/use-leave.ts b/web/src/lib/components/use-leave.svelte.ts
similarity index 58%
rename from web/components/use-leave.ts
rename to web/src/lib/components/use-leave.svelte.ts
index d8756b5..52f20bf 100644
--- a/web/components/use-leave.ts
+++ b/web/src/lib/components/use-leave.svelte.ts
@@ -1,24 +1,28 @@
-"use client";
+import { auth } from "../firebase";
+import { requestDeletion } from "../leave";
+import { kip } from "../store.svelte";
+import { dialog } from "./dialog.svelte";
-import { useState } from "react";
-import { auth } from "../utils/firebase";
-import { requestDeletion } from "../utils/leave";
-import { useKip } from "../utils/store";
-import { useDialog } from "./dialog";
-
-// The one exit for an account with no credential, shared by the menu and
-// Settings so the two cannot drift. Leaving IS deletion here — there is no way
-// back in — so both surfaces ask for the teardown rather than merely signing
-// out. The menu used to sign out alone while its confirm promised the friends
-// and stays were going, which left a permanent ghost: a dead row in every
-// friend's list, a phantom guest for every host, and a profile the reaper will
-// never collect.
-export function useLeave(): { leave: () => Promise; leaving: boolean } {
- const { user } = useKip();
- const { confirm, alert } = useDialog();
- const [leaving, setLeaving] = useState(false);
+/**
+ * The one exit for an account with no credential, shared by the menu and
+ * Settings so the two cannot drift.
+ *
+ * Leaving IS deletion here — there is no way back in — so both surfaces ask
+ * for the teardown rather than merely signing out. The menu used to sign out
+ * alone while its confirm promised the friends and stays were going, which
+ * left a permanent ghost: a dead row in every friend's list, a phantom guest
+ * for every host, and a profile the reaper will never collect.
+ *
+ * `leaving` is a getter: read it off the returned object where it is used.
+ */
+export function useLeave(): {
+ leave: () => Promise;
+ readonly leaving: boolean;
+} {
+ let leaving = $state(false);
async function leave(): Promise {
+ const { user } = kip;
if (!user || leaving) return;
// `anonymous` is a snapshot, and the email door completes in ANOTHER
@@ -29,34 +33,39 @@ export function useLeave(): { leave: () => Promise; leaving: boolean } {
const current = auth().currentUser;
await current?.reload().catch(() => undefined);
if (current && current.providerData.length > 0) {
- await alert({
+ await dialog.alert({
title: "You're all set",
body: "This account has a way back in now, so there's nothing to delete here. Sign out instead if you want to leave this device.",
});
return;
}
- const sure = await confirm({
+ const sure = await dialog.confirm({
title: "Leave kip?",
body: "You haven't added an email or a number, so there's no way back in. Your stays will be cancelled, your friends will lose you, and everything this browser holds goes for good.",
confirmLabel: "Delete and leave",
tone: "danger",
});
if (!sure) return;
- setLeaving(true);
+ leaving = true;
try {
// One write, and the screen it puts up belongs to the store: the trigger
// reports its progress into that document and deletes it when it is done.
await requestDeletion(user.uid);
} catch (error) {
console.error(error);
- setLeaving(false);
- await alert({
+ leaving = false;
+ await dialog.alert({
title: "Couldn't start that",
body: "Nothing has been deleted. Check your connection and try again.",
});
}
}
- return { leave, leaving };
+ return {
+ leave,
+ get leaving() {
+ return leaving;
+ },
+ };
}
diff --git a/web/src/lib/components/use-stay-place.svelte.ts b/web/src/lib/components/use-stay-place.svelte.ts
new file mode 100644
index 0000000..90e6041
--- /dev/null
+++ b/web/src/lib/components/use-stay-place.svelte.ts
@@ -0,0 +1,83 @@
+import { fetchWindow } from "../listings";
+import { roomOf } from "../rooms";
+import { kip } from "../store.svelte";
+import type { Booking, Listing, Room } from "../types";
+
+// Which room a set of dates offers never changes, so a found answer holds for
+// the session. A miss is not kept: the read may be allowed a moment later.
+const roomIds = new Map();
+
+/**
+ * Where a stay is: the place when it can be read, and the room when the dates
+ * are a room's.
+ *
+ * A booking names its dates, not its room, so the room comes off the slot —
+ * from the store when it is already loaded, otherwise with one read, and only
+ * for a place that has rooms. `room` stays null when the slot is gone or can't
+ * be read, which callers render as the place alone.
+ *
+ * Call while a component initializes, passing the booking as a function so a
+ * change is followed; read `listing` and `room` off the result where they are
+ * used.
+ */
+export function useStayPlace(booking: () => Booking | undefined): {
+ readonly listing: Listing | undefined;
+ readonly room: Room | null;
+} {
+ const listingId = $derived(booking()?.listingId ?? "");
+ const windowId = $derived(booking()?.windowId ?? "");
+ const listing = $derived(
+ listingId
+ ? [...kip.friendListings, ...kip.myListings, ...kip.tripListings].find(
+ (candidate) => candidate.id === listingId,
+ )
+ : undefined,
+ );
+ const key = $derived(`${listingId}/${windowId}`);
+ const loaded = $derived(
+ [
+ ...(kip.myWindows[listingId] ?? []),
+ ...(kip.friendWindows[listingId] ?? []),
+ ].find((window) => window.id === windowId),
+ );
+ let fetched = $state.raw<{ key: string; roomId: string | null } | null>(null);
+ const known = $derived.by(() => {
+ if (loaded) {
+ return loaded.roomId;
+ } else if (roomIds.has(key)) {
+ return roomIds.get(key);
+ } else {
+ return fetched?.key === key ? fetched.roomId : undefined;
+ }
+ });
+ const wanted = $derived(
+ known === undefined &&
+ listing !== undefined &&
+ Object.keys(listing.rooms).length > 0,
+ );
+
+ $effect(() => {
+ if (!wanted) return;
+ const asked = key;
+ let live = true;
+ fetchWindow(listingId, windowId)
+ .then((window) => {
+ if (!window) return;
+ roomIds.set(asked, window.roomId);
+ if (live) fetched = { key: asked, roomId: window.roomId };
+ })
+ .catch((error) => console.error("stayPlace", error));
+ return () => {
+ live = false;
+ };
+ });
+
+ return {
+ get listing() {
+ return listing;
+ },
+ get room() {
+ return listing ? roomOf(listing, known ?? null) : null;
+ },
+ };
+}
diff --git a/web/src/lib/components/verify-email-prompt.svelte b/web/src/lib/components/verify-email-prompt.svelte
new file mode 100644
index 0000000..33a0c73
--- /dev/null
+++ b/web/src/lib/components/verify-email-prompt.svelte
@@ -0,0 +1,37 @@
+
+
+{#if kip.user && !kip.emailVerified && kip.email}
+
+
+ {sent
+ ? `Confirmation sent to ${kip.email}. Open it, then reload kip.`
+ : `Confirm ${kip.email} to get notified about bookings — until you do, kip can't email you.`}
+
+{/if}
diff --git a/web/src/lib/components/visible-stays.svelte.ts b/web/src/lib/components/visible-stays.svelte.ts
new file mode 100644
index 0000000..24fb46f
--- /dev/null
+++ b/web/src/lib/components/visible-stays.svelte.ts
@@ -0,0 +1,72 @@
+import { fetchBookingIfVisible } from "../bookings";
+import { isExpired } from "../format";
+import { kip } from "../store.svelte";
+import type { AvailabilityWindow, Booking } from "../types";
+
+/**
+ * The stays behind a place's taken dates that the reader may see, by booking id.
+ *
+ * One read per taken slot, never a query: a query returning even one unreadable
+ * document is refused whole, so the misses have to be taken individually. The
+ * reader's own stays come from `trips` instead of costing a read each.
+ *
+ * Call while a component initializes, passing the dates as a function so a
+ * change is followed; read `held` off the result where it is used.
+ */
+export function visibleStays(windows: () => readonly AvailabilityWindow[]): {
+ readonly held: ReadonlyMap;
+} {
+ let fetched = $state.raw>(new Map());
+ const mine = $derived(
+ new Map(
+ kip.trips
+ .filter((trip) => trip.status === "CONFIRMED")
+ .map((trip) => [trip.id, trip] as const),
+ ),
+ );
+ // Joined into one string so the effect follows the SET of ids, not the array,
+ // which is rebuilt on every snapshot.
+ const wanted = $derived(
+ windows()
+ .filter(
+ (window) =>
+ window.status !== "OPEN" &&
+ window.bookingId != null &&
+ !mine.has(window.bookingId) &&
+ !isExpired(window.end),
+ )
+ .map((window) => window.bookingId as string)
+ .sort()
+ .join(","),
+ );
+
+ $effect(() => {
+ if (!wanted) {
+ fetched = new Map();
+ return;
+ }
+ let live = true;
+ Promise.all(
+ wanted
+ .split(",")
+ .map(async (id) => [id, await fetchBookingIfVisible(id)] as const),
+ )
+ .then((pairs) => {
+ if (!live) return;
+ fetched = new Map(
+ pairs.filter((pair): pair is [string, Booking] => pair[1] !== null),
+ );
+ })
+ .catch((error) => console.error("visibleStays", error));
+ return () => {
+ live = false;
+ };
+ });
+
+ const held = $derived(new Map([...mine, ...fetched]));
+ return {
+ get held() {
+ return held;
+ },
+ };
+}
diff --git a/web/src/lib/components/welcome-screen.svelte b/web/src/lib/components/welcome-screen.svelte
new file mode 100644
index 0000000..5072499
--- /dev/null
+++ b/web/src/lib/components/welcome-screen.svelte
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+ Spare rooms and empty flats — shared between friends, for free.
+
+
+
+
+
+
+
+
+
diff --git a/web/src/lib/components/wordmark.svelte b/web/src/lib/components/wordmark.svelte
new file mode 100644
index 0000000..8e23a28
--- /dev/null
+++ b/web/src/lib/components/wordmark.svelte
@@ -0,0 +1,25 @@
+
+
+
+
+ k
+
+ kip
+
diff --git a/web/utils/contact.ts b/web/src/lib/contact.ts
similarity index 100%
rename from web/utils/contact.ts
rename to web/src/lib/contact.ts
diff --git a/web/utils/debug.ts b/web/src/lib/debug.ts
similarity index 99%
rename from web/utils/debug.ts
rename to web/src/lib/debug.ts
index 0063e2d..457deea 100644
--- a/web/utils/debug.ts
+++ b/web/src/lib/debug.ts
@@ -1,5 +1,3 @@
-"use client";
-
import {
addDoc,
collection,
diff --git a/web/utils/destination.ts b/web/src/lib/destination.ts
similarity index 96%
rename from web/utils/destination.ts
rename to web/src/lib/destination.ts
index e4e6316..e84c8bb 100644
--- a/web/utils/destination.ts
+++ b/web/src/lib/destination.ts
@@ -5,7 +5,7 @@
// email rather than failing validation.
//
// Pure and tested, like `gateStep` and `decideReattach`, for the same reason: the
-// sheet around it needs React and Firebase to exercise, and this needs neither.
+// sheet around it needs Svelte and Firebase to exercise, and this needs neither.
export type Destination =
| { kind: "email"; value: string }
diff --git a/web/utils/feedback.ts b/web/src/lib/feedback.ts
similarity index 99%
rename from web/utils/feedback.ts
rename to web/src/lib/feedback.ts
index e58a5ab..4f1b2f9 100644
--- a/web/utils/feedback.ts
+++ b/web/src/lib/feedback.ts
@@ -1,5 +1,3 @@
-"use client";
-
import type { User } from "firebase/auth";
import {
addDoc,
diff --git a/web/utils/firebase.ts b/web/src/lib/firebase.ts
similarity index 84%
rename from web/utils/firebase.ts
rename to web/src/lib/firebase.ts
index cf418b8..6a3f85f 100644
--- a/web/utils/firebase.ts
+++ b/web/src/lib/firebase.ts
@@ -1,5 +1,3 @@
-"use client";
-
import { type FirebaseApp, getApps, initializeApp } from "firebase/app";
import { type Auth, connectAuthEmulator, getAuth } from "firebase/auth";
import {
@@ -14,8 +12,7 @@ import {
import { type Functions, getFunctions } from "firebase/functions";
import { type FirebaseStorage, getStorage } from "firebase/storage";
-// Public by design — security is in the rules, not in hiding this. Blanking
-// `appId` makes `firebaseConfigured()` false, so the app still runs unconfigured.
+// Public by design — security is in the rules, not in hiding this.
export const firebaseConfig = {
apiKey: "AIzaSyDvsK-HqXYuHuYlxO_IFh8aGWly6c7_yDI",
// A Firebase Hosting custom domain; the Google sign-in popup shows this name.
@@ -45,15 +42,13 @@ export function auth(): Auth {
// phone door automatically, because it skips reCAPTCHA — which exists to stop
// exactly the automation a test is. It sends no message and bills nothing.
//
- // The NODE_ENV half is what keeps it out of a production bundle. Next inlines
- // that one as a literal, so the whole condition folds to false and the branch
- // is eliminated; the public flag alone compiles to a RUNTIME read, which ships
- // the emulator's address in the bundle and leaves a build one env var away
- // from pointing at localhost.
- if (
- process.env.NODE_ENV !== "production" &&
- process.env.NEXT_PUBLIC_AUTH_EMULATOR === "1"
- ) {
+ // The DEV half is what keeps it out of a production bundle. Vite replaces it
+ // with a literal `false` in a build, so the whole condition folds and the
+ // branch is eliminated; the flag alone is replaced with whatever the build's
+ // environment held, which leaves a build one env var away from pointing at
+ // localhost. Written out at each site rather than through `usingEmulators`,
+ // so the fold doesn't depend on a function being inlined.
+ if (import.meta.env.DEV && import.meta.env.VITE_AUTH_EMULATOR === "1") {
connectAuthEmulator(cachedAuth, "http://127.0.0.1:9099", {
disableWarnings: true,
});
@@ -79,10 +74,7 @@ export function db(): Firestore {
// Both or neither: an emulator-issued token is scoped to the emulator's
// project, so leaving Firestore on the real backend would refuse every write
// the flow under test makes.
- if (
- process.env.NODE_ENV !== "production" &&
- process.env.NEXT_PUBLIC_AUTH_EMULATOR === "1"
- ) {
+ if (import.meta.env.DEV && import.meta.env.VITE_AUTH_EMULATOR === "1") {
connectFirestoreEmulator(cachedDb, "127.0.0.1", 8080);
}
return cachedDb;
@@ -104,14 +96,7 @@ export function functions(): Functions {
// makes every real share link read as revoked — a symptom with no visible cause
// unless something on screen admits which backend is behind it.
export function usingEmulators(): boolean {
- return (
- process.env.NODE_ENV !== "production" &&
- process.env.NEXT_PUBLIC_AUTH_EMULATOR === "1"
- );
-}
-
-export function firebaseConfigured(): boolean {
- return firebaseConfig.appId !== "";
+ return import.meta.env.DEV && import.meta.env.VITE_AUTH_EMULATOR === "1";
}
// Every Firebase error carries one, auth and Firestore alike, and it is the only
diff --git a/web/utils/format.ts b/web/src/lib/format.ts
similarity index 100%
rename from web/utils/format.ts
rename to web/src/lib/format.ts
diff --git a/web/utils/friends.ts b/web/src/lib/friends.ts
similarity index 99%
rename from web/utils/friends.ts
rename to web/src/lib/friends.ts
index 64a03d2..4406a29 100644
--- a/web/utils/friends.ts
+++ b/web/src/lib/friends.ts
@@ -1,5 +1,3 @@
-"use client";
-
import {
collection,
type DocumentData,
diff --git a/web/utils/geocode.ts b/web/src/lib/geocode.ts
similarity index 99%
rename from web/utils/geocode.ts
rename to web/src/lib/geocode.ts
index 749f475..9654451 100644
--- a/web/utils/geocode.ts
+++ b/web/src/lib/geocode.ts
@@ -1,5 +1,3 @@
-"use client";
-
// Turn a typed address into coordinates so listings power distance search,
// instead of asking owners to enter lat/lng by hand. Uses OpenStreetMap's
// Nominatim — free and key-less, which suits a low-volume friends app. Swap the
diff --git a/web/utils/install.ts b/web/src/lib/install.svelte.ts
similarity index 51%
rename from web/utils/install.ts
rename to web/src/lib/install.svelte.ts
index c671af6..30e58b5 100644
--- a/web/utils/install.ts
+++ b/web/src/lib/install.svelte.ts
@@ -1,7 +1,3 @@
-"use client";
-
-import { useEffect, useState } from "react";
-
// The only way to open the install dialog yourself, and Chrome hands it over
// once. Kept until it is used.
type InstallPrompt = Event & {
@@ -9,12 +5,11 @@ type InstallPrompt = Event & {
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
};
-let waiting: InstallPrompt | null = null;
-const watchers = new Set<() => void>();
-
-function tell(): void {
- for (const watcher of watchers) watcher();
-}
+let waiting = $state.raw(null);
+// Both answers come from the browser, and the prerendered HTML is drawn on a
+// machine that has neither — so they start false and `startInstall` asks.
+let installed = $state(false);
+let apple = $state(false);
// Safari offers no equivalent event: an iPhone installs through Share → Add to
// Home Screen and nothing can trigger that from a page. Detected so the menu
@@ -46,67 +41,53 @@ function isInstalled(): boolean {
);
}
-// At module scope, not from an effect. Chrome can fire `beforeinstallprompt`
-// before React has mounted anything, and does not replay it — so a listener
-// attached in `useEffect` misses it and the row never appears that session.
+// Chrome can fire `beforeinstallprompt` before this module has loaded, and does
+// not replay it — so `app.html` listens from the first byte and leaves what it
+// caught on `window`. The listener here is for one that fires later.
if (typeof window !== "undefined") {
+ waiting = (window.kipInstallPrompt as InstallPrompt | undefined) ?? null;
window.addEventListener("beforeinstallprompt", (event) => {
// Always held, so installing only happens when someone asks: Chrome shows
// its own banner whenever it likes, which is nagging where kip means to
// offer.
event.preventDefault();
waiting = event as InstallPrompt;
- tell();
});
window.addEventListener("appinstalled", () => {
waiting = null;
- tell();
});
}
-export type InstallState = {
- // A real prompt is available, so the control can be a button.
- ready: boolean;
- // No prompt will ever come, but it CAN be installed by hand.
- byHand: boolean;
- install: () => Promise;
-};
-
-export function useInstall(): InstallState {
- const [, bump] = useState(0);
- const [installed, setInstalled] = useState(false);
- const [apple, setApple] = useState(false);
-
- // Read after mount, never during render: both answers come from the browser,
- // and a static export renders this HTML on a machine that has neither.
- useEffect(() => {
- setInstalled(isInstalled());
- setApple(isApple());
- const watcher = () => bump((n) => n + 1);
- watchers.add(watcher);
- return () => {
- watchers.delete(watcher);
- };
- }, []);
-
- return {
- ready: !installed && waiting !== null,
- byHand: !installed && apple,
- install: async () => {
- const held = waiting;
- if (!held) return;
- try {
- await held.prompt();
- } catch (error) {
- // Refused rather than shown — no user gesture behind the call, say —
- // which leaves the event unspent, so it is kept and the row stays.
- console.warn("install", error);
- return;
- }
- // Shown, so it is spent: a second `prompt()` on the same event throws,
- // and only a fresh `beforeinstallprompt` can offer again.
- waiting = null;
- tell();
- },
- };
+/** Ask the browser whether kip is installed already and how it could be. */
+export function startInstall(): void {
+ installed = isInstalled();
+ apple = isApple();
}
+
+/** Whether kip can be installed from here, and the way to do it. */
+export const install = {
+ /** A real prompt is available, so the control can be a button. */
+ get ready(): boolean {
+ return !installed && waiting !== null;
+ },
+ /** No prompt will ever come, but it CAN be installed by hand. */
+ get byHand(): boolean {
+ return !installed && apple;
+ },
+ /** Open the browser's install dialog. */
+ async prompt(): Promise {
+ const held = waiting;
+ if (!held) return;
+ try {
+ await held.prompt();
+ } catch (error) {
+ // Refused rather than shown — no user gesture behind the call, say —
+ // which leaves the event unspent, so it is kept and the row stays.
+ console.warn("install", error);
+ return;
+ }
+ // Shown, so it is spent: a second `prompt()` on the same event throws,
+ // and only a fresh `beforeinstallprompt` can offer again.
+ waiting = null;
+ },
+};
diff --git a/web/utils/leave.ts b/web/src/lib/leave.ts
similarity index 99%
rename from web/utils/leave.ts
rename to web/src/lib/leave.ts
index 90a284e..ba04c21 100644
--- a/web/utils/leave.ts
+++ b/web/src/lib/leave.ts
@@ -1,5 +1,3 @@
-"use client";
-
import {
deleteDoc,
doc,
diff --git a/web/src/lib/listing-icons.ts b/web/src/lib/listing-icons.ts
new file mode 100644
index 0000000..265f6b9
--- /dev/null
+++ b/web/src/lib/listing-icons.ts
@@ -0,0 +1,22 @@
+import type { Component } from "svelte";
+import type { SvelteHTMLElements } from "svelte/elements";
+import LuBed from "~icons/lucide/bed";
+import LuBuilding2 from "~icons/lucide/building-2";
+import LuHouse from "~icons/lucide/house";
+import type { ListingType } from "./types";
+
+/** An icon component, as `~icons/*` exports them. */
+export type Icon = Component;
+
+// Apart from `listings.ts` because an icon is a virtual module only Vite can
+// resolve, and `listings.ts` is reached by suites that run under plain `bun test`.
+export function listingTypeIcon(type: ListingType): Icon {
+ switch (type) {
+ case "ROOM":
+ return LuBed;
+ case "FLAT":
+ return LuBuilding2;
+ case "HOUSE":
+ return LuHouse;
+ }
+}
diff --git a/web/utils/listings.ts b/web/src/lib/listings.ts
similarity index 98%
rename from web/utils/listings.ts
rename to web/src/lib/listings.ts
index 9621ab1..69d19cd 100644
--- a/web/utils/listings.ts
+++ b/web/src/lib/listings.ts
@@ -1,5 +1,3 @@
-"use client";
-
import {
addDoc,
collection,
@@ -23,8 +21,6 @@ import {
writeBatch,
} from "firebase/firestore";
import { geohashForLocation } from "geofire-common";
-import type { IconType } from "react-icons";
-import { LuBed, LuBuilding2, LuHouse } from "react-icons/lu";
import { claimGuestAccess } from "./bookings";
import { type CheckoutMap, PLACE_KEY, type StayCheckout } from "./checkout";
import { db, onSnapshotError } from "./firebase";
@@ -63,17 +59,6 @@ export function placeTypeLabel(type: ListingType, roomCount: number): string {
}
}
-export function listingTypeIcon(type: ListingType): IconType {
- switch (type) {
- case "ROOM":
- return LuBed;
- case "FLAT":
- return LuBuilding2;
- case "HOUSE":
- return LuHouse;
- }
-}
-
// "WHOLE_PLACE" is the legacy name for "HOUSE".
function normalizeType(raw: unknown): ListingType {
if (raw === "WHOLE_PLACE" || raw === "HOUSE") return "HOUSE";
diff --git a/web/utils/photos.ts b/web/src/lib/photos.ts
similarity index 93%
rename from web/utils/photos.ts
rename to web/src/lib/photos.ts
index 3345ba6..9b2dfef 100644
--- a/web/utils/photos.ts
+++ b/web/src/lib/photos.ts
@@ -1,12 +1,10 @@
-"use client";
-
import {
deleteObject,
getDownloadURL,
ref,
uploadBytes,
} from "firebase/storage";
-import { firebaseConfigured, storage } from "./firebase";
+import { storage } from "./firebase";
import type { ListingPhoto } from "./types";
@@ -75,7 +73,6 @@ export async function uploadListingPhoto(
listingId: string,
file: Blob,
): Promise {
- if (!firebaseConfigured()) throw new Error("Firebase isn't configured");
const id = crypto.randomUUID();
const object = ref(storage(), photoPath(ownerId, listingId, id));
await uploadBytes(object, await shrink(file, MAX_EDGE));
@@ -83,14 +80,12 @@ export async function uploadListingPhoto(
}
export async function uploadAvatar(uid: string, file: Blob): Promise {
- if (!firebaseConfigured()) throw new Error("Firebase isn't configured");
const object = ref(storage(), avatarPath(uid));
await uploadBytes(object, await shrink(file, AVATAR_MAX_EDGE));
return getDownloadURL(object);
}
export async function deleteAvatar(uid: string): Promise {
- if (!firebaseConfigured()) return;
// Someone wearing only their provider's photo has no object to delete.
await deleteObject(ref(storage(), avatarPath(uid))).catch((error) =>
console.warn("deleteAvatar", error),
@@ -114,7 +109,6 @@ export async function deleteListingPhoto(
listingId: string,
photoId: string,
): Promise {
- if (!firebaseConfigured()) return;
// Already gone is the desired end state; the listing drops it either way.
await deleteObject(
ref(storage(), photoPath(ownerId, listingId, photoId)),
diff --git a/web/utils/portals.ts b/web/src/lib/portals.ts
similarity index 99%
rename from web/utils/portals.ts
rename to web/src/lib/portals.ts
index 8bbf4d5..7a61967 100644
--- a/web/utils/portals.ts
+++ b/web/src/lib/portals.ts
@@ -1,5 +1,3 @@
-"use client";
-
import {
type CollectionReference,
collection,
diff --git a/web/utils/profile-gate.ts b/web/src/lib/profile-gate.ts
similarity index 98%
rename from web/utils/profile-gate.ts
rename to web/src/lib/profile-gate.ts
index 4483067..d1ca331 100644
--- a/web/utils/profile-gate.ts
+++ b/web/src/lib/profile-gate.ts
@@ -1,5 +1,5 @@
// When the profile gate opens, shuts, and gives up. Split from the store's
-// effect because that needs React, Firebase and a live session to exercise,
+// effect because that needs Svelte, Firebase and a live session to exercise,
// and this needs none of them — the same split as `reattach.ts`.
export type GateState = {
diff --git a/web/src/lib/reach.ts b/web/src/lib/reach.ts
new file mode 100644
index 0000000..35523e2
--- /dev/null
+++ b/web/src/lib/reach.ts
@@ -0,0 +1,103 @@
+import type { ConfirmationResult } from "firebase/auth";
+import {
+ confirmPhoneCode,
+ sendAttachLink,
+ sendPhoneCode,
+ sendReturnLink,
+} from "./auth";
+import { isForeignNumber, parseDestination } from "./destination";
+import { auth } from "./firebase";
+
+// How many digits a texted code has, and so how many boxes the field draws.
+export const CODE_LENGTH = 6;
+
+// One field for both doors, shared by the surfaces that collect a way to reach
+// someone (`components/reach-field.svelte` draws it). The mode sets only the
+// keyboard and the autofill hint — `parseDestination` decides the route from
+// what was actually typed, so being in the "wrong" mode can never turn a good
+// address into an error.
+//
+// Email defaults because the failure modes are asymmetric: a number typed on an
+// email keyboard is awkward, an address typed on a numeric keypad is impossible.
+export type ReachState = {
+ raw: string;
+ mode: "email" | "phone";
+ pending: ConfirmationResult | null;
+ code: string;
+ sentTo: string | null;
+};
+
+export const EMPTY_REACH: ReachState = {
+ raw: "",
+ mode: "email",
+ pending: null,
+ code: "",
+ sentTo: null,
+};
+
+// `only` is the caller saying its surface names one door — the Settings row that
+// adds a number — so an address typed into it is a mistake rather than the other
+// route, and saying "or an email address" there would offer something the sheet
+// around it cannot do.
+export function reachError(raw: string, only?: "phone"): string | null {
+ if (!raw) return null;
+ const parsed = parseDestination(raw);
+ if (only && parsed.kind === "email") return "That's an email, not a number.";
+ if (parsed.kind !== "unknown") return null;
+ if (isForeignNumber(raw)) {
+ return "US numbers only — email works anywhere.";
+ }
+ return only
+ ? "That's not a US phone number."
+ : "That's not an email or a US number.";
+}
+
+// Sends whichever kind was typed. Email returns immediately (the link lands in
+// an inbox); phone hands back a pending confirmation the caller must finish with
+// a code, which is the one asymmetry between the doors that reaches the UI.
+//
+// `mode` is the only thing that differs between attaching an identity to the
+// account already asking and coming back to one you already have — and only for
+// email, since a texted code signs in or links off the same call. Parameterised
+// rather than duplicated: the two paths drifting is precisely how phone ended up
+// offered on one and not the other.
+export async function sendReach(
+ raw: string,
+ host: string,
+ container: HTMLElement,
+ mode: "attach" | "return" = "attach",
+): Promise<{ pending: ConfirmationResult | null; sentTo: string }> {
+ const parsed = parseDestination(raw);
+ if (parsed.kind === "email") {
+ if (mode === "return") await sendReturnLink(parsed.value);
+ else await sendAttachLink(parsed.value, host);
+ return { pending: null, sentTo: parsed.value };
+ }
+ if (parsed.kind === "phone") {
+ const wasUid = auth().currentUser?.uid ?? null;
+ const pending = await sendPhoneCode(parsed.value, container);
+ // Stashed on the object so the confirm step can tell whether the uid
+ // survived without threading it through the sheet's state.
+ return {
+ pending: Object.assign(pending, { wasUid }),
+ sentTo: parsed.value,
+ };
+ }
+ throw new Error("nothing to send to");
+}
+
+// The one place that says what a finished code looks like. Four surfaces gate a
+// button on it, and each of them used to ask whether `code` was non-empty —
+// which offers to send a single digit, and now also disagrees with the field,
+// since it submits itself on the sixth.
+export function codeReady(state: ReachState): boolean {
+ return state.code.length === CODE_LENGTH;
+}
+
+export async function confirmReach(
+ pending: ConfirmationResult,
+ code: string,
+): Promise<{ sameAccount: boolean }> {
+ const wasUid = (pending as { wasUid?: string | null }).wasUid ?? null;
+ return confirmPhoneCode(pending, code, wasUid);
+}
diff --git a/web/utils/reattach.ts b/web/src/lib/reattach.ts
similarity index 96%
rename from web/utils/reattach.ts
rename to web/src/lib/reattach.ts
index 8287176..fe846ca 100644
--- a/web/utils/reattach.ts
+++ b/web/src/lib/reattach.ts
@@ -1,5 +1,5 @@
// When to re-attach a listener the server dropped. Split from the store because
-// the effect around it needs React, Firebase and a clock, and this needs none of
+// the effect around it needs Svelte, Firebase and a clock, and this needs none of
// them — the same reason `messages.ts` is separate from its triggers.
// Backing off rather than retrying flat: the first failure is usually a race
diff --git a/web/utils/requests.ts b/web/src/lib/requests.ts
similarity index 99%
rename from web/utils/requests.ts
rename to web/src/lib/requests.ts
index 4d22719..c530e84 100644
--- a/web/utils/requests.ts
+++ b/web/src/lib/requests.ts
@@ -1,5 +1,3 @@
-"use client";
-
import {
collection,
type DocumentData,
diff --git a/web/utils/rooms.ts b/web/src/lib/rooms.ts
similarity index 100%
rename from web/utils/rooms.ts
rename to web/src/lib/rooms.ts
diff --git a/web/src/lib/screens.ts b/web/src/lib/screens.ts
new file mode 100644
index 0000000..5d0d998
--- /dev/null
+++ b/web/src/lib/screens.ts
@@ -0,0 +1,100 @@
+import type { Screen, View } from "./types";
+
+export const HOME_SCREEN: Screen = { kind: "tab", tab: "home" };
+
+// Its own word, not `room/new`, so no listing id can be mistaken for it.
+const NEW_PLACE = "new-place";
+
+// A record, so adding a `View` without a route fails to compile.
+const TAB_ROUTES: Readonly> = {
+ home: true,
+ browse: true,
+ places: true,
+ friends: true,
+ trips: true,
+ settings: true,
+ feedback: true,
+};
+
+function isTab(name: string): name is View {
+ return name in TAB_ROUTES;
+}
+
+// The fragment rather than a path, because the app is one statically exported
+// route with no server to route with. The ids are not secrets — every read they
+// name is gated by the rules. The one id that IS a capability lives on
+// /portal/, which the router stays off entirely (see `routable` in the store).
+export function screenHash(screen: Screen): string {
+ switch (screen.kind) {
+ case "tab":
+ return screen.tab === "home" ? "#/" : `#/${screen.tab}`;
+ case "person":
+ return `#/person/${encodeURIComponent(screen.id)}`;
+ case "room":
+ return screen.windowId === undefined
+ ? `#/room/${encodeURIComponent(screen.id)}`
+ : `#/room/${encodeURIComponent(screen.id)}/slot/${encodeURIComponent(
+ screen.windowId,
+ )}`;
+ case "booking":
+ return `#/booking/${encodeURIComponent(screen.id)}`;
+ case "listing-form":
+ return screen.id === null
+ ? `#/${NEW_PLACE}`
+ : `#/room/${encodeURIComponent(screen.id)}/edit`;
+ }
+}
+
+// The inverse. Null for anything unrecognized, which callers read as "go home"
+// rather than "render nothing".
+export function screenForHash(hash: string): Screen | null {
+ let segments: string[];
+ try {
+ segments = hash
+ .replace(/^#/, "")
+ .split("/")
+ .filter(Boolean)
+ .map(decodeURIComponent);
+ } catch {
+ return null; // a malformed %-escape names no screen either
+ }
+ const [first, second, third, fourth] = segments;
+ switch (segments.length) {
+ case 0:
+ return HOME_SCREEN;
+ case 1:
+ if (isTab(first)) {
+ return { kind: "tab", tab: first };
+ } else {
+ return first === NEW_PLACE ? { kind: "listing-form", id: null } : null;
+ }
+ case 2:
+ switch (first) {
+ case "person":
+ return { kind: "person", id: second };
+ case "room":
+ return { kind: "room", id: second };
+ case "booking":
+ return { kind: "booking", id: second };
+ default:
+ return null;
+ }
+ case 3:
+ return first === "room" && third === "edit"
+ ? { kind: "listing-form", id: second }
+ : null;
+ case 4:
+ return first === "room" && third === "slot"
+ ? { kind: "room", id: second, windowId: fourth }
+ : null;
+ default:
+ return null;
+ }
+}
+
+// A fragment names only the top screen, so a pasted link gets Home seeded
+// beneath it — otherwise arriving on a room means arriving with no way back.
+export function stackForHash(hash: string): Screen[] {
+ const screen = screenForHash(hash) ?? HOME_SCREEN;
+ return screen.kind === "tab" ? [screen] : [HOME_SCREEN, screen];
+}
diff --git a/web/utils/search.ts b/web/src/lib/search.ts
similarity index 99%
rename from web/utils/search.ts
rename to web/src/lib/search.ts
index 072ad60..93405f4 100644
--- a/web/utils/search.ts
+++ b/web/src/lib/search.ts
@@ -1,5 +1,3 @@
-"use client";
-
import { distanceBetween } from "geofire-common";
import { formatDate, isExpired } from "./format";
import { listingTypeLabel } from "./listings";
diff --git a/web/utils/searches.ts b/web/src/lib/searches.ts
similarity index 99%
rename from web/utils/searches.ts
rename to web/src/lib/searches.ts
index 75e2097..9f7f7e9 100644
--- a/web/utils/searches.ts
+++ b/web/src/lib/searches.ts
@@ -1,5 +1,3 @@
-"use client";
-
import {
addDoc,
collection,
diff --git a/web/utils/settings.ts b/web/src/lib/settings.ts
similarity index 99%
rename from web/utils/settings.ts
rename to web/src/lib/settings.ts
index a0a46f4..a1dcde2 100644
--- a/web/utils/settings.ts
+++ b/web/src/lib/settings.ts
@@ -1,5 +1,3 @@
-"use client";
-
import {
arrayUnion,
doc,
diff --git a/web/utils/sms.ts b/web/src/lib/sms.ts
similarity index 100%
rename from web/utils/sms.ts
rename to web/src/lib/sms.ts
diff --git a/web/src/lib/store.svelte.ts b/web/src/lib/store.svelte.ts
new file mode 100644
index 0000000..a67f9df
--- /dev/null
+++ b/web/src/lib/store.svelte.ts
@@ -0,0 +1,1686 @@
+import {
+ signOut as fbSignOut,
+ onIdTokenChanged,
+ sendEmailVerification,
+ signInAnonymously,
+ type User,
+ updateProfile,
+} from "firebase/auth";
+import { afterNavigate, goto } from "$app/navigation";
+import { page } from "$app/state";
+import {
+ authSettled,
+ type Door,
+ doorsOf,
+ googleSignIn,
+ sameDoors,
+} from "./auth";
+import {
+ type BookingOutcome,
+ cancelBookingAsGuest,
+ cancelBookingAsOwner,
+ cancelWindowAsOwner,
+ claimGuestAccess,
+ claimKnownBy,
+ confirmBooking,
+ hideBooking as fbHideBooking,
+ requestBooking as fbRequestBooking,
+ hideBookings,
+ watchIncomingBookings,
+ watchMyTrips,
+} from "./bookings";
+import type { CheckoutMap } from "./checkout";
+import { clientState, recordDebugEvent } from "./debug";
+import { hasUnreadFeedback, readAdmin } from "./feedback";
+import { auth, errorCode, onListenerLost } from "./firebase";
+import { endedWithin, STAY_SIGHT_DAYS } from "./format";
+import {
+ setSearchable as fbSetSearchable,
+ unfriend as fbUnfriend,
+ fetchUserProfile,
+ findUserByUsername,
+ healFriendEdges,
+ updateProfileIdentity,
+ watchFriends,
+ watchOwnProfile,
+} from "./friends";
+import { watchDeletion } from "./leave";
+import {
+ addRoom,
+ addRoomWindows,
+ addWindow,
+ createListing as fbCreateListing,
+ deleteListing as fbDeleteListing,
+ removeRoom as fbRemoveRoom,
+ setListingPhotos as fbSetListingPhotos,
+ setRoomPhotos as fbSetRoomPhotos,
+ updateListing as fbUpdateListing,
+ updateRoom as fbUpdateRoom,
+ updateWindow as fbUpdateWindow,
+ fetchFriendListings,
+ fetchListing,
+ fetchWindows,
+ type ListingInput,
+ type NewRoom,
+ type RoomInput,
+ reorderRooms,
+ setCheckout,
+ setWindowAutoAccept,
+ watchCheckout,
+ watchMyListings,
+ watchWindows,
+} from "./listings";
+import { deleteAvatar, uploadAvatar } from "./photos";
+import {
+ publishListingPortal as fbPublishPortal,
+ publishRoomPortal as fbPublishRoomPortal,
+ publishSlotPortal as fbPublishSlotPortal,
+ publishUserPortal as fbPublishUserPortal,
+ revokeListingPortal as fbRevokePortal,
+ revokeRoomPortal as fbRevokeRoomPortal,
+ revokeSlotPortal as fbRevokeSlotPortal,
+ revokeUserPortal as fbRevokeUserPortal,
+ ownedPortalIds,
+ propagateListing,
+ propagateProfile,
+} from "./portals";
+import {
+ GATE_BACKSTOP_MS,
+ type GateEvent,
+ type GateState,
+ gateStep,
+ NO_SESSION,
+} from "./profile-gate";
+import { decideReattach, NO_LOSSES } from "./reattach";
+import {
+ declineRequest,
+ acceptRequest as fbAcceptRequest,
+ sendRequest as fbSendRequest,
+ watchIncomingConnectRequests,
+ watchOutgoingConnectRequests,
+} from "./requests";
+import { HOME_SCREEN, screenHash, stackForHash } from "./screens";
+import {
+ EMPTY_CRITERIA,
+ type SavedSearch,
+ type SearchCriteria,
+ sameCriteria,
+} from "./search";
+import {
+ deleteSavedSearch as fbDeleteSavedSearch,
+ markSearchSeen as fbMarkSearchSeen,
+ saveSearch as fbSaveSearch,
+ MAX_SAVED_SEARCHES,
+ watchSavedSearches,
+} from "./searches";
+import {
+ setTextNotify as fbSetTextNotify,
+ giveSmsConsent,
+ requestTextCheck,
+ setPrefs,
+ standingConsent,
+ stopTexts,
+ watchPrefs,
+} from "./settings";
+import {
+ type AvailabilityWindow,
+ type Booking,
+ type ConnectRequest,
+ DEFAULT_PREFS,
+ type DeletionRequest,
+ type Friend,
+ type Listing,
+ type ListingPhoto,
+ type NotifyKind,
+ type NotifySmsKind,
+ type Party,
+ type Prefs,
+ type Profile,
+ type Screen,
+ SMS_CONSENT_VERSION,
+ type View,
+} from "./types";
+import {
+ createProfile,
+ claimUsername as fbClaimUsername,
+ normalizeUsername,
+} from "./username";
+
+type WindowMap = Readonly>;
+type CheckoutByListing = Readonly>;
+
+const EMPTY_FRIENDS: Friend[] = [];
+const EMPTY_REQUESTS: ConnectRequest[] = [];
+const EMPTY_LISTINGS: Listing[] = [];
+const EMPTY_BOOKINGS: Booking[] = [];
+const EMPTY_PROFILES: Profile[] = [];
+const EMPTY_SEARCHES: SavedSearch[] = [];
+const EMPTY_WINDOWS: WindowMap = {};
+const EMPTY_CHECKOUT: CheckoutByListing = {};
+
+// Applied at the two subscriptions rather than per list, so every surface
+// honours a hide without each one remembering to. Only a CANCELLED booking can
+// be hidden — the rules allow the hide only then — so a live stay carrying a
+// stray uid is still shown rather than vanishing while it binds someone.
+function shownTo(
+ uid: string,
+ onChange: (bookings: Booking[]) => void,
+): (bookings: Booking[]) => void {
+ return (bookings) =>
+ onChange(
+ bookings.filter(
+ (booking) =>
+ booking.status !== "CANCELLED" || !booking.hiddenBy.includes(uid),
+ ),
+ );
+}
+
+// Everything below is module state: one store for the page, read through `kip`
+// and brought to life by `startKip`. Whole values are swapped in rather than
+// mutated, so `$state.raw` throughout — nothing here needs a deep proxy, and a
+// Firebase `User` must not be wrapped in one.
+let user = $state.raw(null);
+// Snapshots of the User fields the app branches on. They can't be read off
+// `user`: Firebase mutates that object IN PLACE and hands back the same
+// reference after linking, unlinking or verifying, so assigning it again tells
+// nothing that depends on it to update.
+let anonymous = $state(false);
+let admin = $state(false);
+let unreadFeedback = $state(false);
+let emailVerified = $state(false);
+let email = $state(null);
+let phone = $state(null);
+let doors = $state.raw([]);
+// Lives in Firestore, not on the Auth user, so it's subscribed not derived.
+let profile = $state.raw(null);
+let profileReady = $state(false);
+let profileUnreachable = $state(false);
+let deletion = $state.raw(null);
+let deletionReady = $state(false);
+// The uid whose teardown this session has actually SEEN, so the document
+// going away can be told from never having been there.
+let leaving: string | null = null;
+// The uid `deletionReady` was last reset for.
+let deletionFor: string | null = null;
+// Firebase restores a session asynchronously, so this stops the gate flashing
+// the sign-in screen at someone already signed in.
+let authReady = $state(false);
+// Re-attaching listeners the server dropped. Bumping `generation` re-subscribes
+// all of them; `reattachTimer` being non-null doubles as the guard that
+// collapses a burst of losses into one retry; `gate` (profile-gate.ts) stops a
+// re-attach being mistaken for a new session.
+let generation = $state(0);
+let listenersLost = $state(false);
+let reattachState = NO_LOSSES;
+let reattachTimer: ReturnType | null = null;
+let gate: GateState = NO_SESSION;
+let prefs = $state.raw(DEFAULT_PREFS);
+let savedSearches = $state.raw(EMPTY_SEARCHES);
+let friends = $state.raw(EMPTY_FRIENDS);
+let incomingRequests = $state.raw(EMPTY_REQUESTS);
+let outgoingRequests = $state.raw(EMPTY_REQUESTS);
+let myListings = $state.raw(EMPTY_LISTINGS);
+let myWindows = $state.raw(EMPTY_WINDOWS);
+let myCheckout = $state.raw(EMPTY_CHECKOUT);
+let friendListings = $state.raw(EMPTY_LISTINGS);
+let friendWindows = $state.raw(EMPTY_WINDOWS);
+let trips = $state.raw(EMPTY_BOOKINGS);
+let tripListings = $state.raw(EMPTY_LISTINGS);
+let incomingBookings = $state.raw(EMPTY_BOOKINGS);
+let counterparts = $state.raw(EMPTY_PROFILES);
+let criteria = $state.raw(EMPTY_CRITERIA);
+// The uid all of the above belongs to.
+let stateUid: string | null = null;
+// Once per stay per page load; the rules can't see the booking as CONFIRMED
+// inside either confirm commit.
+let claimedStays = new Set();
+// Each browse fetch takes a ticket, and only the newest may land: an older one
+// finishing late would otherwise overwrite a newer friend set, or fill in the
+// last session's places after a sign-out.
+let browseTicket = 0;
+
+// Home is only the starting guess — `seedStack` reads the real stack out of the
+// URL, which can't happen here because this also runs at prerender.
+let stack = $state.raw([HOME_SCREEN]);
+let popped = $state(0);
+// How many history entries of ours lie behind the current one, which is how
+// `back` tells "a screen of ours is behind this" from "leaving the site".
+let depth = 0;
+// Names the current history entry, so its scroll offset can be kept beside it.
+let entry = "";
+// Set by a traversal and cleared once the router has delivered the state of
+// the entry it landed on.
+let popPending = false;
+
+// A texted code or a credential that already belongs to another account signs
+// INTO it without signing out, so the uid changes under a live session with no
+// sign-out in between. Called from the auth listener itself, in the same turn
+// that sets `user`: effects run after it, so none of them can act for the new
+// uid on the old account's trips and bookings first.
+function resetSession(uid: string | null): void {
+ stateUid = uid;
+ friends = EMPTY_FRIENDS;
+ incomingRequests = EMPTY_REQUESTS;
+ outgoingRequests = EMPTY_REQUESTS;
+ myListings = EMPTY_LISTINGS;
+ myWindows = EMPTY_WINDOWS;
+ myCheckout = EMPTY_CHECKOUT;
+ friendListings = EMPTY_LISTINGS;
+ friendWindows = EMPTY_WINDOWS;
+ trips = EMPTY_BOOKINGS;
+ tripListings = EMPTY_LISTINGS;
+ incomingBookings = EMPTY_BOOKINGS;
+ counterparts = EMPTY_PROFILES;
+ prefs = DEFAULT_PREFS;
+ savedSearches = EMPTY_SEARCHES;
+ admin = false;
+ unreadFeedback = false;
+ claimedStays = new Set();
+}
+
+// The pending timer has to be cleared alongside the counters: sign-out kills
+// every listener by itself, so an armed timer would re-attach against the new
+// session — and, being the burst guard, swallow its first real loss.
+function resetReattach(): void {
+ if (reattachTimer !== null) clearTimeout(reattachTimer);
+ reattachTimer = null;
+ reattachState = NO_LOSSES;
+ listenersLost = false;
+}
+
+// The id of `src/routes/(app)/+page.svelte`.
+const APP_ROUTE = "/(app)";
+
+// The app's one route. /portal/ and /continue/ mean something else by their
+// fragment — a capability token, a sign-in link — and a route written over
+// either one destroys it, within a second of load; the document pages have no
+// screens at all.
+function routable(): boolean {
+ return page.route.id === APP_ROUTE;
+}
+
+const SCROLL_KEY = "kip.scroll";
+// Set across the reload that adopts a hand-edited fragment; see `onHashChange`.
+const ADOPTED_KEY = "kip.adopted";
+
+// Whether this load is that reload. Read once: the mark is for one arrival.
+function takeAdopted(): boolean {
+ try {
+ const adopted = sessionStorage.getItem(ADOPTED_KEY) !== null;
+ sessionStorage.removeItem(ADOPTED_KEY);
+ return adopted;
+ } catch {
+ return false;
+ }
+}
+
+// Offsets for more entries than anyone goes back through in one tab.
+const SCROLL_KEPT = 60;
+
+function scrollOffsets(): Record {
+ try {
+ return JSON.parse(sessionStorage.getItem(SCROLL_KEY) ?? "{}");
+ } catch {
+ return {};
+ }
+}
+
+/**
+ * The scroll offset remembered for the current history entry.
+ *
+ * Per history entry rather than in store state, and in session storage, since
+ * that's what survives a reload and a forward.
+ */
+export function historyScroll(): number {
+ return scrollOffsets()[entry] ?? 0;
+}
+
+/** Remember a scroll offset; called by the page, the only place that knows which element scrolls. */
+export function rememberScroll(offset: number): void {
+ if (!routable() || !entry) return;
+ const offsets = scrollOffsets();
+ // Re-inserted so the entry in use is the newest, and the oldest go first.
+ delete offsets[entry];
+ offsets[entry] = offset;
+ const kept = Object.entries(offsets).slice(-SCROLL_KEPT);
+ try {
+ sessionStorage.setItem(
+ SCROLL_KEY,
+ JSON.stringify(Object.fromEntries(kept)),
+ );
+ } catch {
+ // Storage refused: returning lands at the top, which is where it started.
+ }
+}
+
+// Through the router's own shallow navigation, never `history.pushState`: the
+// router numbers every entry, and one written behind its back shares a number
+// with its neighbour, which it then refuses to traverse between.
+// `persistState` is what hands the stack back after a reload.
+function writeEntry(next: readonly Screen[], replace: boolean): void {
+ goto(screenHash(next[next.length - 1]), {
+ shallow: true,
+ replace,
+ persistState: true,
+ state: { kipStack: next, kipDepth: depth, kipEntry: entry },
+ }).catch((error) => console.error("navigate", error));
+}
+
+function pushEntry(next: readonly Screen[]): void {
+ if (!routable()) return;
+ depth += 1;
+ entry = crypto.randomUUID();
+ writeEntry(next, false);
+}
+
+// The entry keeps its name, and so its scroll: a replace changes what this
+// entry POINTS AT and not where the reader is standing in it.
+function replaceEntry(next: readonly Screen[]): void {
+ if (!routable()) return;
+ writeEntry(next, true);
+}
+
+// Every forward move pushes a real history entry and back() delegates to
+// history.back(), so the OS back button and the in-app one are one button.
+function setView(tab: View): void {
+ const next: Screen[] = [{ kind: "tab", tab }];
+ pushEntry(next);
+ stack = next;
+}
+
+function navigate(target: Screen): void {
+ const next = [...stack, target];
+ pushEntry(next);
+ stack = next;
+}
+
+// Same history entry, so after creating a listing back returns to where the
+// form was opened from rather than to an empty form.
+function replace(target: Screen): void {
+ const next = [...stack.slice(0, -1), target];
+ replaceEntry(next);
+ stack = next;
+}
+
+function back(): void {
+ if (depth > 0) {
+ window.history.back();
+ } else {
+ // Nothing of ours behind this entry, so Back means "up a level" rather
+ // than "leave the site".
+ const [base] = stack;
+ const next: Screen[] = [base.kind === "tab" ? base : HOME_SCREEN];
+ replaceEntry(next);
+ stack = next;
+ }
+}
+
+// Arriving on the app's route by anything but a traversal: a load, a reload,
+// or a link from one of the document pages.
+function seedStack(): void {
+ // The fragment wins when the two disagree, being the half a user can edit.
+ const saved = page.state.kipStack ?? [];
+ const restored =
+ saved.length > 0 &&
+ screenHash(saved[saved.length - 1]) === window.location.hash
+ ? [...saved]
+ : stackForHash(window.location.hash);
+ // An adopted entry has the screen the fragment was typed over behind it, so
+ // Back is a real step back rather than "up a level".
+ const adopted = takeAdopted();
+ depth = page.state.kipDepth ?? (adopted ? 1 : 0);
+ entry = page.state.kipEntry ?? crypto.randomUUID();
+ // Never push: the arrival entry is ours to annotate, and pushing would leave
+ // a phantom under the first Back.
+ replaceEntry(restored);
+ stack = restored;
+}
+
+// The owner's public identity as copied into share links. Read at CALL time:
+// an action can be HELD — captured at the tap, run after the identity sheet
+// writes a profile — and it must see the profile that sheet wrote.
+function asParty(): Party {
+ return {
+ uid: profile?.uid ?? "",
+ username: profile?.username ?? "",
+ displayName: profile?.displayName ?? "",
+ photoURL: profile?.photoURL ?? null,
+ };
+}
+
+function findListing(listingId: string): Listing | undefined {
+ return myListings.find((candidate) => candidate.id === listingId);
+}
+
+async function refreshBrowse(): Promise {
+ browseTicket += 1;
+ const ticket = browseTicket;
+ const uids = friendUidsKey ? friendUidsKey.split(",") : [];
+ if (!user || uids.length === 0) {
+ friendListings = EMPTY_LISTINGS;
+ friendWindows = EMPTY_WINDOWS;
+ return;
+ }
+ const listings = await fetchFriendListings(uids);
+ const windowLists = await Promise.all(
+ listings.map((listing) => fetchWindows(listing.id)),
+ );
+ if (ticket !== browseTicket) return;
+ const windows: Record = {};
+ listings.forEach((listing, index) => {
+ windows[listing.id] = windowLists[index];
+ });
+ friendListings = listings;
+ friendWindows = windows;
+}
+
+// One room's dates, for the screen that just changed them.
+async function refreshWindows(listingId: string): Promise {
+ const asked = auth().currentUser?.uid;
+ const windows = await fetchWindows(listingId);
+ if (auth().currentUser?.uid !== asked) return;
+ friendWindows = { ...friendWindows, [listingId]: windows };
+}
+
+function knownPerson(uid: string): Party | null {
+ return (
+ friends.find((friend) => friend.uid === uid) ??
+ counterparts.find((person) => person.uid === uid) ??
+ null
+ );
+}
+
+// Passes the verdict through: a caller that writes a profile afterwards has
+// to know whether it landed in someone else's account.
+function signIn(): Promise<{ sameAccount: boolean }> {
+ return googleSignIn();
+}
+
+// Signing out of a session with no credential does not end it, it DESTROYS
+// it: the uid lives in this browser and nowhere else, so there is nothing to
+// sign back in with. The guard lives here rather than at each surface, because
+// the failure mode is a future screen adding a sign-out without the check —
+// callers that mean it (the Settings exit, behind its confirm) pass `force`.
+async function signOut(force = false): Promise {
+ if (auth().currentUser?.isAnonymous && !force) {
+ throw new Error("signOut would destroy an account with no way back in");
+ }
+ await fbSignOut(auth());
+}
+
+// Reading `auth().currentUser` directly replaced a real session with an empty
+// anonymous one: it is null for a beat after load while Firebase restores a
+// persisted session. `authSettled` is that same read, held until the restore.
+async function ensureAnonymous(): Promise {
+ const restored = await authSettled();
+ // A restored session is not proof the account still EXISTS. Firebase reaps
+ // idle anonymous accounts, and the browser keeps its tokens either way — so
+ // a returning visitor could hold a session for a uid that is gone, claim a
+ // grant as it, be refused, and see a live share link reported as turned off.
+ // Forcing a refresh is what asks the server; only a real answer is trusted.
+ if (restored) {
+ if (!restored.isAnonymous) return restored.uid;
+ try {
+ await restored.getIdToken(true);
+ return restored.uid;
+ } catch (error) {
+ // ONLY a server saying this account is gone. A network blip is not that,
+ // and treating it as one hands them a brand-new uid — losing the pending
+ // ask, the grant and the name this function exists to preserve.
+ const code = errorCode(error);
+ const gone =
+ code === "auth/user-token-expired" ||
+ code === "auth/user-not-found" ||
+ code === "auth/user-disabled";
+ if (!gone) return restored.uid;
+ console.warn("ensureAnonymous: account is gone, starting a new one");
+ }
+ }
+ return (await signInAnonymously(auth())).user.uid;
+}
+
+// Write the profile with the chosen display name, and mirror it onto the Auth
+// user so anything still reading that (fallbacks) stays consistent. No handle
+// is claimed here — that happens later, from Settings, and only to turn
+// searchability on.
+async function completeOnboarding(displayName: string): Promise {
+ const current = auth().currentUser;
+ if (!current) throw new Error("not signed in");
+ await createProfile(current.uid, {
+ displayName,
+ photoURL: current.photoURL ?? null,
+ });
+ await updateProfile(current, { displayName }).catch((error) =>
+ console.error("updateProfile", error),
+ );
+}
+
+// A handle already taken fails on the registry's owner-only update rule. Your
+// friends' copies of you then still hold the old handle (usually ''), so they
+// are healed here — after the claim, since the edge rule reads the committed
+// profile. The claim has landed either way, so a failed heal is only logged.
+async function claimUsername(username: string): Promise