PageSourceSearch

https://events-trovex.web.app/invitation-rate-ui.js

js events-trovex.web.app collected 2026-10-03 12:23:15 UTC 3,178 bytes, 71 lines download raw bytes

1// The live invitation counter, and the warning every surface shows.
2//
3// The arithmetic is `invitation-rate.js`, which has no React and no Firestore so it can be tested.
4// This file is the listener and the markup.
5
6import { db } from './firebase-init.js';
7import { invitationRateStatus, describeWait } from './invitation-rate.js';
8
9const { createElement: h, useState, useEffect } = React;
10
11// The live counter. One listener on one document, shared by every surface that shows it.
12//
13// It TICKS as well as listening: the document only changes when somebody sends, so without a clock
14// a countdown reading "in 3 minutes" would still say that half an hour later. Thirty seconds is
15// enough for a minute-resolution countdown and costs nothing.
16export function useInvitationRate(isStaff) {
17  const [stored, setStored] = useState(null);
18  const [loaded, setLoaded] = useState(false);
19  const [now, setNow] = useState(() => Date.now());
20
21  useEffect(() => {
22    if (!isStaff) { setLoaded(true); return undefined; }
23    const unsubscribe = db.collection('eventsInvitationRate').doc('current').onSnapshot(
24      (snap) => { setStored(snap.exists ? snap.data() : null); setLoaded(true); },
25      (err) => {
26        // Reported rather than swallowed, and `loaded` still becomes true: a screen that cannot
27        // read the counter must say it does not know rather than imply there is room.
28        console.error('[Events] Could not read the invitation rate:', err);
29        setStored(null);
30        setLoaded(true);
31      }
32    );
33    return unsubscribe;
34  }, [isStaff]);
35
36  useEffect(() => {
37    const id = setInterval(() => setNow(Date.now()), 30000);
38    return () => clearInterval(id);
39  }, []);
40
41  const status = invitationRateStatus(stored, { now, configuredLimit: stored && stored.limit });
42  return { ...status, loaded, wait: describeWait(status.waitMs) };
43}
44
45// The warning itself, so the three surfaces cannot word it differently.
46//
47// `context` changes only the first sentence: on the import screen the useful thing to say is that
48// importing is not inviting, which is the question somebody about to load 400 rows actually has.
49export function InvitationRateNotice({ rate, context = 'default', className = '' }) {
50  if (!rate || !rate.loaded) return null;
51
52  const spent = rate.remaining <= 0;
53  const tone = spent
54    ? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800 text-red-800 dark:text-red-300'
55    : 'bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-800 text-amber-800 dark:text-amber-300';
56
57  const lead = context === 'import'
58    ? 'Importing a list does not invite anybody, so bring in as many people as you like.'
59    : null;
60
61  return h('div', { className: `border rounded-lg px-3 py-2 text-xs ${tone} ${className}` },
62    lead && h('p', { className: 'mb-1' }, lead),
63    h('p', null,
64      h('strong', null, 'Invitations are limited to ' + rate.limit + ' an hour'),
65      ' across everybody, because a high bounce rate damages our sending domain. ',
66      spent
67        ? `The limit has been reached. The next invitation can go ${rate.wait}.`
68        : `${rate.remaining} of ${rate.limit} left this hour.`
69    )
70  );
71}

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.