PageSourceSearch

https://mygroucho.club/js/club-state.js?v=20260928a

js mygroucho.club collected 2026-10-05 19:24:27 UTC 13,909 bytes, 382 lines download raw bytes

1// Shared club state for the Groucho website.
2//
3// The web equivalent of the app's UserProfileContext: it answers "which clubs
4// does this member belong to, which one are we showing, and what is their
5// subscription FOR THAT CLUB". Kept in its own module because every club-scoped
6// page (/account, and /club with its schedule, members and messages) needs
7// exactly this and must agree with the others.
8//
9// Why membership can't be read off the user doc: `users/{uid}.clubId` holds a
10// single club (the legacy scalar, set on first placement). A second club is
11// granted through `clubs/{id}.memberUserIds` alone, so the authoritative
12// question is "which club docs contain me", which is the array-contains query
13// below. Firestore rules permit exactly that query.
14//
15// Likewise `subscriptionStatus` / `subscriptionTier` / `subscriptionExpiresAt`
16// on the user doc are last-write-wins across every subscription the member
17// holds, so they cannot describe a particular club. Per-club truth lives in
18// `users/{uid}/memberships/{stripeSubscriptionId}`, whose `clubId` says what
19// the money is for.
20//
21// Seat reservations (RESERVATIONS.md) live in `users/{uid}/reservations`,
22// backend-written and owner-readable. A reservation never makes anyone paid:
23// it opens one gathering (the meeting's `reservedUserIds`), nothing more.
24
25import {
26  collection,
27  doc,
28  onSnapshot,
29  query,
30  where,
31} from 'https://www.gstatic.com/firebasejs/10.13.2/firebase-firestore.js';
32
33const ACTIVE_CLUB_KEY = 'groucho.activeClubId';
34
35// Stage timings in the console, so a slow load can be attributed to auth,
36// a particular listener, or the network rather than guessed at.
37// Times are seconds since the page started loading.
38export function stage(label) {
39  console.info(`[groucho] ${(performance.now() / 1000).toFixed(1)}s ${label}`);
40}
41
42function toDate(value) {
43  if (!value) return null;
44  if (typeof value.toDate === 'function') return value.toDate();
45  const d = new Date(value);
46  return isNaN(d.getTime()) ? null : d;
47}
48
49// A membership still grants access while live or inside the paid grace period.
50function membershipGrantsAccess(m, now) {
51  if (m.status === 'active' || m.status === 'pending_cancellation') return true;
52  const expiresAt = toDate(m.expiresAt);
53  return !!expiresAt && expiresAt > now;
54}
55
56/**
57 * Any live subscription at all, regardless of club. Per-club membership docs
58 * decide when present; the user-doc scalars only for members who have none
59 * (pre-backfill). Exported for pages that don't run watchClubState (the
60 * interview's "already done" card).
61 *
62 * @param {object} profile      the user doc data
63 * @param {object[]} memberships docs from users/{uid}/memberships
64 * @param {Date} [now]
65 */
66export function hasAnyPaidMembership(profile, memberships, now = new Date()) {
67  if (memberships && memberships.length > 0) {
68    return memberships.some((m) => membershipGrantsAccess(m, now));
69  }
70  return (
71    !!profile &&
72    (profile.subscriptionStatus === 'active' ||
73      profile.subscriptionStatus === 'pending_cancellation')
74  );
75}
76
77// Statuses in the order a page should prefer them when a member holds more
78// than one reservation for the same gathering (a released hold followed by a
79// paid recovery, say): the live one wins over the history.
80const RESERVATION_PRIORITY = [
81  'paid',
82  'credited',
83  'payment_required',
84  'card_held',
85  'refunded',
86  'released',
87  'expired',
88];
89
90function reservationRank(r) {
91  const i = RESERVATION_PRIORITY.indexOf(r && r.status);
92  return i === -1 ? RESERVATION_PRIORITY.length : i;
93}
94
95/**
96 * The most relevant of several reservations: the live status first, then the
97 * most recently created.
98 *
99 * @param {object[]} list reservation docs
100 * @returns {object|null}
101 */
102export function pickReservation(list) {
103  if (!list || list.length === 0) return null;
104  return [...list].sort((a, b) => {
105    const byStatus = reservationRank(a) - reservationRank(b);
106    if (byStatus !== 0) return byStatus;
107    const at = toDate(a.createdAt);
108    const bt = toDate(b.createdAt);
109    return (bt ? bt.getTime() : 0) - (at ? at.getTime() : 0);
110  })[0];
111}
112
113function readStoredClubId() {
114  try {
115    return window.localStorage.getItem(ACTIVE_CLUB_KEY) || null;
116  } catch (err) {
117    return null; // private browsing / blocked storage
118  }
119}
120
121function writeStoredClubId(clubId) {
122  try {
123    window.localStorage.setItem(ACTIVE_CLUB_KEY, clubId);
124  } catch (err) {
125    /* selection just won't persist across reloads */
126  }
127}
128
129// Label a club the way the app's ClubSwitcher does, so the two never disagree.
130export function clubLabel(club) {
131  if (!club) return 'Your club';
132  if (club.type === 'online') return club.city ? `Online — ${club.city}` : 'Online club';
133  return club.city || 'In-person club';
134}
135
136/**
137 * Everything a club-scoped page needs, recomputed whenever the user doc, the
138 * club list or the memberships change.
139 *
140 * @param {object} db        Firestore instance
141 * @param {string} uid       signed-in user id
142 * @param {function} onState called with the state object below
143 * @returns {function} unsubscribe
144 */
145export function watchClubState(db, uid, onState) {
146  let profile = null;
147  let profileLoaded = false;
148  let clubs = []; // [{ id, type, city, ... }]
149  let clubsLoaded = false;
150  let memberships = []; // [{ id, tier, status, expiresAt, clubId }]
151  let membershipsLoaded = false;
152  let reservations = []; // [{ id, status, clubId, meetingId, amount, chargedAt, recoveryUrl, ... }]
153  let reservationsLoaded = false;
154  let selectedClubId = readStoredClubId();
155
156  function emit() {
157    // Wait for the user doc; clubs/memberships resolve moments later and an
158    // empty result is indistinguishable from "not yet loaded" without this.
159    if (!profileLoaded || !profile) return;
160
161    // Order: the legacy primary club first (it is the one the member has had
162    // longest), then any other club that lists them.
163    const clubIds = [];
164    if (profile.clubId) clubIds.push(profile.clubId);
165    for (const club of clubs) {
166      if (!clubIds.includes(club.id)) clubIds.push(club.id);
167    }
168
169    const activeClubId =
170      selectedClubId && clubIds.includes(selectedClubId) ? selectedClubId : clubIds[0] || null;
171    const activeClub = clubs.find((c) => c.id === activeClubId) || null;
172
173    const now = new Date();
174
175    // The membership backing the active club: bound to it, or unbound while the
176    // user doc still points at it (paid before the binding was written) AND the
177    // tier bought matches the club's type. Without the tier test an Either
178    // subscriber's online membership would unlock an in-person club it never
179    // paid for. Mirrors isPaidMemberOfClub in the Cloud Functions and the app's
180    // unboundMembershipCountsForActiveClub.
181    //
182    // Among memberships bound to the club, prefer one that grants access: an
183    // abandoned PaymentSheet can leave a stale `inactive` doc bound to the same
184    // club, and picking that one first would lock a paid member out.
185    const activeClubTier = activeClub && activeClub.type === 'online' ? 'online' : 'in-person';
186    const bound = activeClubId ? memberships.filter((m) => m.clubId === activeClubId) : [];
187    const membership =
188      activeClubId && memberships.length > 0
189        ? bound.find((m) => membershipGrantsAccess(m, now)) ||
190          bound[0] ||
191          memberships.find(
192            (m) =>
193              !m.clubId &&
194              profile.clubId === activeClubId &&
195              (m.tier || 'in-person') === activeClubTier
196          ) ||
197          null
198        : null;
199
200    // Holding memberships but none for this club means the member simply has
201    // not paid for THIS club. The user-doc scalars describe a different club and
202    // must not be consulted, or a paid first club makes an unpaid second club
203    // report "Active".
204    const unpaidForThisClub = !membership && memberships.length > 0;
205
206    const status = membership
207      ? membership.status || 'inactive'
208      : unpaidForThisClub
209        ? 'inactive'
210        : profile.subscriptionStatus || null;
211
212    const expiresAt = membership
213      ? toDate(membership.expiresAt)
214      : unpaidForThisClub
215        ? null
216        : toDate(profile.subscriptionExpiresAt);
217
218    // A club nobody has paid for has no tier of its own yet, so fall back to the
219    // club's own type rather than a tier bought for some other club.
220    const tier = membership
221      ? membership.tier || activeClubTier
222      : unpaidForThisClub
223        ? activeClubTier
224        : profile.subscriptionTier || activeClubTier;
225
226    const isPaid = membership
227      ? membershipGrantsAccess(membership, now)
228      : unpaidForThisClub
229        ? false
230        : status === 'active' ||
231          status === 'pending_cancellation' ||
232          (expiresAt !== null && expiresAt > now);
233
234    const isPendingCancellation = status === 'pending_cancellation';
235
236    // Shown on the subscribe prompt ("3 members have already joined!").
237    const paidMemberCount = (activeClub && activeClub.paidMemberCount) || 0;
238
239    const hasScheduleAccess = !!activeClubId && isPaid;
240
241    // Seat helpers. Snapshotted with this emit, like everything else here.
242    const reservationList = reservations;
243    const reservationForMeeting = (meetingId, clubId) =>
244      pickReservation(
245        reservationList.filter(
246          (r) => r.meetingId === meetingId && (!clubId || !r.clubId || r.clubId === clubId)
247        )
248      );
249    // The one first gathering a member gets for $15 is spent once a seat in
250    // this club has been paid for (or already credited to a subscription).
251    const hasUsedSeat = (clubId) =>
252      reservationList.some(
253        (r) => r.clubId === clubId && (r.status === 'paid' || r.status === 'credited')
254      );
255    // A paid member sees every gathering. Anyone else sees the ones they hold
256    // a seat at, which the backend records on the meeting itself.
257    const canViewMeeting = (meeting) =>
258      hasScheduleAccess ||
259      (!!meeting && Array.isArray(meeting.reservedUserIds) && meeting.reservedUserIds.includes(uid));
260
261    onState({
262      profile,
263      clubs,
264      clubIds,
265      activeClubId,
266      activeClub,
267      // True once every listener has reported, so pages can distinguish
268      // "no clubs" from "still loading".
269      ready: profileLoaded && clubsLoaded && membershipsLoaded && reservationsLoaded,
270      memberships,
271      paidMemberCount,
272      // Schedule access, as the app computes it: paid FOR THIS CLUB, nothing
273      // more. The club's minPaidMembersRequired no longer gates anyone. A
274      // reserved seat opens single gatherings through canViewMeeting instead.
275      hasScheduleAccess,
276      // Every seat reservation the member holds, in any club or draft.
277      reservations: reservationList,
278      reservationForMeeting,
279      hasUsedSeat,
280      canViewMeeting,
281      // Chat and the member list open on placement, before payment (seeing the
282      // club is what persuades people to join it). Only a fully lapsed
283      // subscription closes them again.
284      canAccessSocialFeatures: !!activeClubId && status !== 'cancelled',
285      // The subscription AS IT APPLIES TO THE ACTIVE CLUB.
286      subscription: {
287        membership,
288        status,
289        tier,
290        expiresAt,
291        isPaid,
292        isPastDue: status === 'past_due',
293        isCancelled: status === 'cancelled' || isPendingCancellation,
294        isPendingCancellation,
295      },
296      // Any live subscription at all, for entitlements that aren't club-scoped
297      // (the Discord invite, for instance).
298      hasAnyPaidMembership: hasAnyPaidMembership(profile, memberships, now),
299    });
300  }
301
302  const unsubProfile = onSnapshot(
303    doc(db, 'users', uid),
304    (snap) => {
305      profile = snap.exists() ? snap.data() : null;
306      profileLoaded = true;
307      stage(`user doc arrived (${snap.metadata.fromCache ? 'cache' : 'server'})`);
308      if (!profile) {
309        onState(null); // caller shows its error view
310        return;
311      }
312      emit();
313    },
314    (err) => {
315      console.error('[club-state] profile listener error:', err);
316      onState(null);
317    }
318  );
319
320  const unsubClubs = onSnapshot(
321    query(collection(db, 'clubs'), where('memberUserIds', 'array-contains', uid)),
322    (snap) => {
323      clubs = snap.docs.map((d) => ({ id: d.id, ...d.data() }));
324      clubsLoaded = true;
325      stage(`clubs arrived: ${clubs.length} (${snap.metadata.fromCache ? 'cache' : 'server'})`);
326      emit();
327    },
328    (err) => {
329      console.error('[club-state] clubs listener error:', err);
330      clubs = [];
331      clubsLoaded = true;
332      emit(); // fall back to the legacy scalar club
333    }
334  );
335
336  const unsubMemberships = onSnapshot(
337    collection(db, 'users', uid, 'memberships'),
338    (snap) => {
339      memberships = snap.docs.map((d) => ({ id: d.id, ...d.data() }));
340      membershipsLoaded = true;
341      stage(`memberships arrived: ${memberships.length}`);
342      emit();
343    },
344    (err) => {
345      console.error('[club-state] memberships listener error:', err);
346      memberships = []; // empty list falls back to the user-doc scalars
347      membershipsLoaded = true;
348      emit();
349    }
350  );
351
352  const unsubReservations = onSnapshot(
353    collection(db, 'users', uid, 'reservations'),
354    (snap) => {
355      reservations = snap.docs.map((d) => ({ id: d.id, ...d.data() }));
356      reservationsLoaded = true;
357      stage(`reservations arrived: ${reservations.length}`);
358      emit();
359    },
360    (err) => {
361      // Denied before the rules ship, or offline: no seats, nothing else lost.
362      console.error('[club-state] reservations listener error:', err);
363      reservations = [];
364      reservationsLoaded = true;
365      emit();
366    }
367  );
368
369  return {
370    setActiveClub(clubId) {
371      selectedClubId = clubId;
372      writeStoredClubId(clubId);
373      emit();
374    },
375    unsubscribe() {
376      unsubProfile();
377      unsubClubs();
378      unsubMemberships();
379      unsubReservations();
380    },
381  };
382}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.