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.