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.