PageSourceSearch

https://events-trovex.web.app/event-admin.js

js events-trovex.web.app collected 2026-10-03 12:22:44 UTC 374,003 bytes, 6,538 lines download raw bytes

1// Events app — admin event management page (extracted from App.js).
2// AdminEventPage is the staff-facing single-event admin surface: registrations, attendance,
3// prospects/CRM, notes, QR check-in, speaker management and more. PROSPECT_STATUSES is the
4// CRM pipeline status lookup it uses for the prospects board; it is internal to this view.
5const { createElement: h, useState, useEffect, useMemo, useRef } = React;
6import { randomToken, VALID_RESTORE_STATUSES, qaSubmitUrl, qaDisplayUrl } from './config.js';
7import { manualAddNote, staffNameFromEmail } from './manual-add.js';
8import { seatLabel, seatCounts, hasConfirmedAttendance, SEAT_LABELS } from './seat-label.js';
9import { db, functions } from './firebase-init.js';
10import { navigateTo } from './router.js';
11import { searchAddressBook, searchContactsDetailed, createManualContact, generateQrCodeDataUrl, ensureMemberQrCode, initialContacts } from './helpers.js';
12import { markAlreadyProspects, prospectEmailSet, registeredEmailSet, registrationStatusByEmail, registrationStatusOf, takeAddable } from './contacts.js';
13import { prospectAction, statusAfterSend, lastSentLabel } from './prospect-actions.js';
14import { pickOriginalInvitation, mergeInvitationDocs, INVITATION_SCAN_LIMIT } from './original-invitation.js';
15import { useEscToClose } from './a11y.js';
16import { isAgendaVisibleToCustomers } from './agenda-sections.js';
17import { chunkedBatchWrite } from './firestore-batch.js';
18import { ProspectImportModal } from './prospect-import-modal.js';
19import { useInvitationRate, InvitationRateNotice } from './invitation-rate-ui.js';
20import { undoRefusedInvitation } from './invite-send.js';
21import { useRegistrations, useSpeakers, useEventSpeakers, useAllEventSpeakers, generateToken } from './hooks.js';
22import { SEND_KINDS, partitionForBulkSend, classifySendResult, summariseBulkSend } from './bulk-send.js';
23import { latestSend, sendHistoryTitle, hasBeenEmailed } from './send-state.js';
24import { actionMenuPosition, estimateMenuHeight } from './action-menu-position.js';
25import { agreedSpeakers, rosterHeadcount, agreedEntryCount } from './speaker-pipeline.js';
26import { EventSpeakersTab } from './event-speakers.js';
27import { loadJsPdf, loadPdfWithQr } from './cdn-lazy.js';
28import { displayNameFrom } from './person-name.js';
29// The admin Q&A tab called this without importing it, so every question carrying a createdAt threw
30// a ReferenceError and took the tab down. Found 2026-09-14 by an AST scope sweep.
31import { formatTimeAgo } from './time-ago.js';
32import {
33  filterProspectsByInvitationRange,
34  isInvitationRangeActive,
35  invitationRangeDescription,
36  invitationSentLabel,
37  sortProspectsByInvitationDate,
38  ANY_INVITER_KEY,
39  NOT_INVITED_KEY,
40  UNKNOWN_INVITER_KEY,
41  buildInviterIndex,
42  inviterKeyForProspect,
43  inviterNameForProspect,
44  otherInviterNamesForProspect,
45  matchesInviter,
46  filterProspectsByInviter,
47  inviterFilterOptions,
48  inviterFilterDescription
49} from './prospect-inviters.js';
50import { AttendeeImportModal } from './attendee-import.js';
51import * as Icons from './icons.js';
52
53// ============================================
54// PROSPECT STATUSES
55// ============================================
56const PROSPECT_STATUSES = {
57  new: { id: 'new', label: 'New', color: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300' },
58  phoned: { id: 'phoned', label: 'Phoned', color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' },
59  emailed: { id: 'emailed', label: 'Emailed', color: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' },
60  invited: { id: 'invited', label: 'Invited', color: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' },
61  // They opened their invitation and did not register. Between Invited and Registered on purpose:
62  // it is the state that most needs a follow-up, because the person showed interest and stopped.
63  // Set automatically when an invitation link is opened, and never downgrades somebody who has
64  // since registered or answered.
65  viewed: { id: 'viewed', label: 'Viewed Page', color: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400' },
66  registered: { id: 'registered', label: 'Registered', color: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' },
67  attended: { id: 'attended', label: 'Attended', color: 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400' },
68  'cant-attend': { id: 'cant-attend', label: "Can't Attend", color: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' },
69  declined: { id: 'declined', label: 'Declined', color: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' }
70};
71
72// Is this prospect assigned to this staff member?
73// assignedTo holds a staff email, written from user.email on some paths and picked from the
74// staff list on others, so its case is not guaranteed consistent: compare case-insensitively.
75// An unassigned prospect (no assignedTo) is never anybody's.
76const isProspectAssignedTo = (prospect, email) => {
77  const assigned = (prospect && prospect.assignedTo || '').trim().toLowerCase();
78  const mine = (email || '').trim().toLowerCase();
79  return !!assigned && !!mine && assigned === mine;
80};
81
82// ============================================
83// ADMIN EVENT MANAGEMENT PAGE
84// ============================================
85// Full-page admin view for managing a single event with tabs
86
87// prospectEmailSet and markAlreadyProspects live in contacts.js: they are pure, they are the
88// rule handleAddProspect refuses on, and contacts.test.js is where that agreement is held.
89
90// These count PEOPLE, not rows, and the two kinds are budgeted separately. Once a blocked row
91// is kept rather than filtered out it occupies a slot, so a plain slice hides real matches
92// behind dead ones - see takeAddable in contacts.js for the ways that bit.
93//
94// A BROWSE list takes NO blocked rows, and that is the point rather than an omission. Marking
95// exists so somebody who searched for a person is told they are already here instead of being
96// told they do not exist; on an unsearched seed list nobody looked for anybody, so there is no
97// such person to mislead. Budgeting blocked rows here made it hunt: the loop only stops when
98// BOTH budgets are met, so it walked the whole 500-member list appending people from deep in
99// the alphabet marked "Already registered", hundreds of rows skipped between them and nothing
100// on screen saying so. Zero restores exactly what master showed - the first ten addable
101// people - and the list refills as they are added.
102//
103// A SEARCH budgets both, because every row in it MATCHED: a blocked row deep in a members
104// search is still somebody the staff member's own query found. The contacts tab cannot reach
105// its budgets anyway - searchContactsDetailed slices to its `limit` of 20 before returning.
106// Rows are never reordered by state: an earlier version demoted blocked rows, which pushed
107// the searched-for person past the cut and made a row jump out of the list when Add was
108// pressed on it. Mark, do not move, do not hide.
109const BROWSE_ADDABLE = 10;
110// How many prospect rows are painted at once, and how many more each "Show more" adds. The QUERY
111// is unlimited; this is the render window. 100 fills a tall screen with room to scroll, so the
112// button is below the fold rather than in the way.
113const PROSPECT_PAGE = 100;
114
115const BROWSE_BLOCKED = 0;
116const SEARCH_ADDABLE = 20;
117const SEARCH_BLOCKED = 20;
118
119function AdminEventPage({ eventId, user, isAdmin, isStaff, events, onSignOut, darkMode, onToggleDarkMode, onEditEvent, onUpdateEvent }) {
120  const [event, setEvent] = useState(null);
121  const [loading, setLoading] = useState(true);
122  const [activeTab, setActiveTab] = useState('details');
123  const [message, setMessage] = useState(null);
124
125  // Registrations
126  const { userRegistration, registrations, loading: registrationsLoading, register, cancelRegistration, counts } = useRegistrations(user, eventId);
127
128  // Speaker roster. Loaded here rather than inside the Speakers tab because three surfaces need
129  // it: the tab label count, the Details tab speakers panel and the agenda PDF, and the PDF is
130  // reachable from the Share menu on any tab. The roster query is filtered to this event.
131  const {
132    entries: speakerEntries,
133    loading: speakerRosterLoading,
134    error: speakerRosterError,
135    addSpeakerToEvent,
136    setEntryStatus: setSpeakerEntryStatus,
137    setEntryNotes: setSpeakerEntryNotes,
138    removeEntry: removeSpeakerEntry
139  } = useEventSpeakers(eventId, isStaff);
140  const { speakers: speakerRecords, loading: speakerRecordsLoading, createSpeaker, updateSpeaker } = useSpeakers(isStaff, isStaff);
141
142  // The whole roster, for the "N events" figure on a Speakers / SMEs row (Adrian, 2026-09-14). The
143  // tab's own query is filtered to THIS event, so it cannot answer "has this person done anything
144  // for us before" - the question the figure exists for. Measured 2026-09-14: 11 roster entries,
145  // so one small subscription, and only while the Speakers tab is open.
146  //
147  // The EVENT list is a PROP rather than a second subscription, and that is the whole correctness
148  // argument rather than a saving. `useEvents(user, isAdmin)` returns every event on its truthy
149  // branch and published, non-private events only on its falsy one - and App passes `isStaff` where
150  // this page was passing `isAdmin`. Since `staffRole` defaults to 'staff', the DEFAULT staff
151  // member is not an admin, so the two surfaces were running different queries: the page would say
152  // "3 events" for somebody whose row on this tab said "1 event", or showed no chip at all. Worst
153  // exactly when it matters, because speakers are lined up while an event is still a DRAFT. Taking
154  // App's own list makes the divergence impossible rather than fixed by convention.
155  const wantsSpeakerHistory = isStaff && activeTab === 'speakers';
156  const { entries: allRosterEntries } = useAllEventSpeakers(isStaff, wantsSpeakerHistory);
157  const allEvents = wantsSpeakerHistory ? (events || []) : [];
158  const speakerRecordsById = useMemo(() => {
159    const map = {};
160    for (const speaker of speakerRecords) map[speaker.id] = speaker;
161    return map;
162  }, [speakerRecords]);
163  // The confirmed speakers, in the { name, title, company, photo } shape the PDF and the Details
164  // panel already expect. Both used to read event.speakers[], which nothing has ever written, so
165  // every event PDF has silently listed no speakers.
166  const confirmedSpeakers = useMemo(
167    () => agreedSpeakers(speakerEntries, speakerRecordsById),
168    [speakerEntries, speakerRecordsById]
169  );
170
171  // Pending registrations
172  const [pendingRegistrations, setPendingRegistrations] = useState([]);
173  const [pendingLoading, setPendingLoading] = useState(false);
174  const [approvingId, setApprovingId] = useState(null);
175  const [rejectingId, setRejectingId] = useState(null);
176
177  // Invite state
178  const [inviteEmail, setInviteEmail] = useState('');
179  const [inviteFirstName, setInviteFirstName] = useState('');
180  const [inviteLastName, setInviteLastName] = useState('');
181  const [inviteCompany, setInviteCompany] = useState('');
182  const [sendingInvite, setSendingInvite] = useState(false);
183  const [inviteMessage, setInviteMessage] = useState(null);
184  const [recentInvites, setRecentInvites] = useState([]);
185
186  // Invite search state
187  const [inviteSearch, setInviteSearch] = useState('');
188  const [inviteSearchResults, setInviteSearchResults] = useState([]);
189  const [inviteSearching, setInviteSearching] = useState(false);
190  const [showInviteDropdown, setShowInviteDropdown] = useState(false);
191  const [members, setMembers] = useState([]);
192
193  // Quick invites (one-off invitations from Invite tab)
194  const [quickInvites, setQuickInvites] = useState([]);
195
196
197  const [resendingInvite, setResendingInvite] = useState(false);
198
199  // Check-in state
200  const [checkingInId, setCheckingInId] = useState(null);
201
202  // Bulk attendee import (admin) — paste a list and register them all for badges
203  const [showAttendeeImport, setShowAttendeeImport] = useState(false);
204
205  // Quick add (last-minute single attendee) + select-to-print
206  const [showQuickAdd, setShowQuickAdd] = useState(false);
207  const [qa, setQa] = useState({ firstName: '', lastName: '', company: '', email: '' });
208  const [quickAdding, setQuickAdding] = useState(false);
209  const [selectedRegIds, setSelectedRegIds] = useState(() => new Set());
210
211  // Action dropdown menus — store { id, top, right } for fixed positioning
212  const [openRegAction, setOpenRegAction] = useState(null);
213  const [openProspectAction, setOpenProspectAction] = useState(null);
214
215  // The menu is PORTALLED to document.body and positioned `fixed`, so nothing clips or scrolls it
216  // back into view - it always opened downward, and on the last rows of a long list it ran off the
217  // bottom of the screen with only the first item reachable. `actionMenuPosition` flips it above
218  // the trigger when there is no room below AND above is roomier, and always returns a maxHeight so
219  // a menu taller than the viewport scrolls internally rather than overflowing either way.
220  //
221  // `itemCount` only decides WHICH SIDE; the maxHeight then bounds whatever is actually rendered,
222  // so a wrong count picks a worse side, never an off-screen one.
223  const openActionMenu = (e, id, setter, otherSetter, itemCount) => {
224    e.stopPropagation();
225    otherSetter(null);
226    if (setter === setOpenRegAction && openRegAction?.id === id) { setter(null); return; }
227    if (setter === setOpenProspectAction && openProspectAction?.id === id) { setter(null); return; }
228    const rect = e.currentTarget.getBoundingClientRect();
229    const placed = actionMenuPosition(rect, { height: window.innerHeight, width: window.innerWidth },
230      estimateMenuHeight(itemCount));
231    setter({ id, ...placed });
232  };
233
234  // Both menus count their DIVIDER as an item, because it occupies real height and the estimate
235  // is about height. The prospect menu draws Copy invite link, Edit, a divider and Delete.
236  const PROSPECT_MENU_ITEMS = 4;
237
238  // How many rows THIS seat's menu will draw. Six are unconditional - Edit, Confirmation,
239  // Reminder, the divider, Can't Attend and Delete - counting the divider for the same reason
240  // PROSPECT_MENU_ITEMS does. The rest depend on the seat, so judging every row by one constant
241  // is what would pick the wrong side on the crowded ones.
242  const regMenuItemCount = (reg) => {
243    const kind = seatLabel(reg).kind;
244    return 6
245      + ((kind === 'staffAdded' || kind === 'selfRegistered') ? 1 : 0)
246      + (kind === 'confirmedByReply' ? 1 : 0)
247      + (reg.status === 'declined' ? 1 : 0)
248      + (reg.status === 'attended' ? 1 : 0);
249  };
250
251  // Registrations search and filter
252  const [registrationSearch, setRegistrationSearch] = useState('');
253  // 'all' or a seatLabel kind: staffAdded, selfRegistered, confirmedByReply, declined, attended.
254  const [registrationStatusFilter, setRegistrationStatusFilter] = useState('all');
255
256  // Prospect inviting
257  const [invitingProspect, setInvitingProspect] = useState(null); // ID of prospect being invited
258  // The email a staff member is about to send, as the recipient will receive it. Every send
259  // from a PROSPECT ROW passes through here first.
260  //
261  // Deliberately not claiming more than that: handleSendInvitation, the quick Invite form on
262  // the Invite tab of this same page, still mints a fresh token and sends with no preview and
263  // no existing-invitation lookup. So the duplicate-token problem this path was rewritten to
264  // fix is still reachable from the other tab, for somebody who may already be a prospect
265  // holding a live invitation. Left alone here because it is a different flow with its own
266  // form, not because it is fine.
267  const [emailPreview, setEmailPreview] = useState(null);
268  // Two previews opened for the SAME prospect could land out of order, leaving one action's
269  // title over the other action's HTML. The row button is disabled while one is loading too,
270  // since a double-click was the easy way in.
271  const emailPreviewSeq = useRef(0);
272  // Escape closes it, like every other modal here. This is now the mandatory gate on every
273  // customer email from this board, so it is the worst one to leave keyboard-inaccessible.
274  // Safe unconditionally: with nothing open this clears a value that is already null.
275  useEscToClose(React.useCallback(() => setEmailPreview(null), []));
276  // The dialog is rendered inside the Prospects tab, so switching tabs makes it VANISH without
277  // being cancelled - and coming back an hour later found it still sitting there with a live
278  // Send button, built from a preview nobody was reading any more. confirmEmailPreview
279  // re-checks the prospect's state but has no opinion about how old the preview is.
280  useEffect(() => { setEmailPreview(null); }, [activeTab]);
281
282  // Resend confirmation emails
283  const [resendingConfirmation, setResendingConfirmation] = useState(null); // ID of registration being resent
284  const [sendingAttendanceReminder, setSendingAttendanceReminder] = useState(null); // ID of registration being sent attendance reminder
285  // Bulk send over the ticked selection. `bulkSend` holds the pending confirmation (what would be
286  // sent, to whom, and who is excluded); `bulkProgress` drives the button while it runs.
287  const [bulkSend, setBulkSend] = useState(null);
288  const [bulkProgress, setBulkProgress] = useState(null);
289  // WHICH ROW the bulk run is on right now. The run is sequential, so this is one id at a time -
290  // that is what lets the spinner appear on the line being sent rather than only in the toolbar.
291  const [bulkSendingId, setBulkSendingId] = useState(null);
292  const [cancellingRegistration, setCancellingRegistration] = useState(null); // ID of registration being cancelled
293  const [resettingRegistration, setResettingRegistration] = useState(null); // ID of registration being reset to registered
294
295  // Badge export
296  const [exportingBadges, setExportingBadges] = useState(false);
297  const [badgeExportProgress, setBadgeExportProgress] = useState({ current: 0, total: 0 });
298  const [showExportMenu, setShowExportMenu] = useState(false);
299  const exportMenuRef = useRef(null);
300
301  // Badge number editing
302  const [editingBadgeNumber, setEditingBadgeNumber] = useState(null); // registration ID being edited
303  const [badgeNumberInput, setBadgeNumberInput] = useState('');
304  const [savingBadgeNumber, setSavingBadgeNumber] = useState(null);
305
306  // Feedback state
307  const [feedback, setFeedback] = useState([]);
308  const [feedbackLoading, setFeedbackLoading] = useState(false);
309  const [requestingFeedback, setRequestingFeedback] = useState(null); // registration id being processed
310  const [expandedFeedbackId, setExpandedFeedbackId] = useState(null); // for expanding feedback details
311
312  // Edit registration modal
313  const [editingRegistration, setEditingRegistration] = useState(null);
314  const [editRegData, setEditRegData] = useState({ userName: '', jobTitle: '', organisation: '' });
315  const [savingRegistration, setSavingRegistration] = useState(false);
316
317
318  // Share menu
319  const [showShareMenu, setShowShareMenu] = useState(false);
320  const shareMenuRef = useRef(null);
321
322  // Expanded sections
323  const [showAgenda, setShowAgenda] = useState(false);
324  const [showSpeakers, setShowSpeakers] = useState(false);
325  const [showPendingRequests, setShowPendingRequests] = useState(false);
326
327  // Tasks tab state
328  const [eventTasks, setEventTasks] = useState([]);
329  const [tasksLoading, setTasksLoading] = useState(false);
330  const [taskFilter, setTaskFilter] = useState('all'); // 'all', 'pending', 'completed', 'mine'
331  const [showAddTask, setShowAddTask] = useState(false);
332  const [newTaskTitle, setNewTaskTitle] = useState('');
333  const [newTaskDescription, setNewTaskDescription] = useState('');
334  const [newTaskAssignee, setNewTaskAssignee] = useState('');
335  const [newTaskDueDate, setNewTaskDueDate] = useState('');
336  const [addingTask, setAddingTask] = useState(false);
337  const [expandedTaskId, setExpandedTaskId] = useState(null);
338  const [newTaskComment, setNewTaskComment] = useState('');
339  const [newSubTaskTitle, setNewSubTaskTitle] = useState('');
340  const [staffMembersList, setStaffMembersList] = useState([]);
341  const [showProspectImport, setShowProspectImport] = useState(false);
342  const invitationRate = useInvitationRate(isStaff);
343  // Show only people whose invitation came back as an automatic reply. These are the rows worth
344  // acting on after a send: an out-of-office from somebody who has left usually names their
345  // replacement, which is the most useful thing an invitation round produces.
346  const [prospectAutoReplyOnly, setProspectAutoReplyOnly] = useState(false);
347  const [autoReplyReading, setAutoReplyReading] = useState(null);
348  // The full message, fetched only when the reader opens. It is deliberately NOT on the prospect
349  // row: the tab loads every prospect for the event (517 on the largest), so a full email body on
350  // each would make that list several megabytes.
351  const [autoReplyFull, setAutoReplyFull] = useState({ state: 'idle', text: '' });
352  // Which request the reader is currently waiting on. WITHOUT this, opening A, closing, then
353  // opening B shows B's name and email above A's message whenever A resolves second - and with
354  // offline persistence on, a cached second read returning while the first is still on the wire
355  // makes that likely rather than theoretical. Triage here is open, read, close, next, so it is
356  // the ordinary path. A rep would email the wrong person's replacement.
357  const autoReplyReq = useRef(0);
358
359  // The key must be built the same way the SERVER built it, or the document is unreachable. Kept
360  // to the shared table in auto-reply-key.cases.js, which both sides read.
361  const autoReplyKey = (email) => {
362    const clean = String(email == null ? '' : email).toLowerCase().trim().replace(/[\/]/g, '_');
363    if (!clean || clean === '.' || clean === '..' || clean.startsWith('__')) return '';
364    return clean.slice(0, 320);
365  };
366
367  // Opens the reader and goes for the whole message. The extract already on the row is shown
368  // immediately, so the useful line is readable before this resolves and remains readable if it
369  // never does - a staff member should not be left with a spinner where the answer was.
370  const openAutoReply = async (prospect) => {
371    const req = ++autoReplyReq.current;
372    setAutoReplyReading({ prospect });
373    const key = autoReplyKey(prospect.email);
374    if (!key) { setAutoReplyFull({ state: 'none', text: '' }); return; }
375    setAutoReplyFull({ state: 'loading', text: '' });
376    try {
377      const snap = await db.collection('prospectAutoReplies').doc(key).get();
378      if (req !== autoReplyReq.current) return;
379      const body = snap.exists ? String(snap.data().body || '') : '';
380      setAutoReplyFull({ state: body ? 'ready' : 'none', text: body });
381    } catch (err) {
382      if (req !== autoReplyReq.current) return;
383      console.error('[AdminEventPage] Could not load the full automatic reply:', err);
384      setAutoReplyFull({ state: 'error', text: '' });
385    }
386  };
387
388  // Closing abandons whatever is in flight, so a late arrival cannot land on the next person.
389  const closeAutoReply = () => {
390    autoReplyReq.current += 1;
391    setAutoReplyReading(null);
392    setAutoReplyFull({ state: 'idle', text: '' });
393  };
394
395  // Q&A tab state
396  const [questions, setQuestions] = useState([]);
397  const [questionsLoading, setQuestionsLoading] = useState(false);
398  const [qaCustomUrl, setQaCustomUrl] = useState('');
399  const [savingQaSettings, setSavingQaSettings] = useState(false);
400
401  // Notes tab state
402  const [eventNotes, setEventNotes] = useState([]);
403  const [eventNotesLoading, setEventNotesLoading] = useState(false);
404  const [expandedNoteId, setExpandedNoteId] = useState(null);
405
406  // Prospects tab state
407  const [prospects, setProspects] = useState([]);
408  const [prospectsLoading, setProspectsLoading] = useState(false);
409  const [prospectSearch, setProspectSearch] = useState('');
410  const [prospectStatusFilter, setProspectStatusFilter] = useState('all');
411  const [prospectMineOnly, setProspectMineOnly] = useState(false);
412  // Which colleague's invitees to show, keyed on the inviter's Auth uid. The fact is not on
413  // the prospect document at all - see prospect-inviters.js for where it does live.
414  const [prospectInviterFilter, setProspectInviterFilter] = useState(ANY_INVITER_KEY);
415  // When they were invited. The date lives on the INVITATION, never on the prospect, so all of
416  // these read through the same join the invited-by filter uses. Both ends of the range are
417  // optional and inclusive; they hold the YYYY-MM-DD strings an <input type="date"> produces.
418  const [prospectDateFrom, setProspectDateFrom] = useState('');
419  const [prospectDateTo, setProspectDateTo] = useState('');
420  const [prospectSort, setProspectSort] = useState('none');
421  // How many prospect rows to actually paint. The list itself is complete (see the loader above);
422  // this is the only thing that is lazy, and it is the half that was making the tab slow. A row
423  // here is a seven-column table row carrying two action menus, so 517 of them is several thousand
424  // DOM nodes laid out before the tab can be used, on every visit and again on every keystroke in
425  // the search box.
426  const [prospectsShown, setProspectsShown] = useState(PROSPECT_PAGE);
427  // The short invite link a staff member pastes into their own Outlook. Held in state rather
428  // than copied and forgotten, because the clipboard write can fail (see handleCopyInviteLink)
429  // and a link nobody can read is worse than no control at all.
430  const [inviteLink, setInviteLink] = useState(null);
431  const [copyingInviteLink, setCopyingInviteLink] = useState(null);
432  const [showAddProspectModal, setShowAddProspectModal] = useState(false);
433  const [prospectModalSearch, setProspectModalSearch] = useState('');
434  const [prospectModalTab, setProspectModalTab] = useState('members'); // 'members', 'contacts', or 'manual'
435  const [prospectContactsSearch, setProspectContactsSearch] = useState('');
436  const [prospectContactsResults, setProspectContactsResults] = useState([]);
437  const [prospectContactsSearching, setProspectContactsSearching] = useState(false);
438  // A search that FAILED is not a search that found nobody. Kept apart so the picker can say
439  // which one happened instead of printing "No matching contacts found" over a query that was
440  // denied, unindexed or dropped - the defect reported 2026-09-07.
441  const [prospectContactsFailed, setProspectContactsFailed] = useState(false);
442  const [manualProspect, setManualProspect] = useState({ firstName: '', lastName: '', email: '', company: '', phone: '', jobTitle: '' });
443  const [addingProspect, setAddingProspect] = useState(false);
444  const [editingProspectNotes, setEditingProspectNotes] = useState(null);
445  const [prospectNotesInput, setProspectNotesInput] = useState('');
446  const [editingProspect, setEditingProspect] = useState(null);
447  const [editProspectData, setEditProspectData] = useState({ firstName: '', lastName: '', email: '', company: '', jobTitle: '', phone: '' });
448  const [savingProspect, setSavingProspect] = useState(false);
449
450  // Load event data
451  useEffect(() => {
452    if (!eventId) return;
453
454    const unsubscribe = firebase.firestore()
455      .collection('events')
456      .doc(eventId)
457      .onSnapshot(doc => {
458        if (doc.exists) {
459          setEvent({ id: doc.id, ...doc.data() });
460        }
461        setLoading(false);
462      }, err => {
463        console.error('[AdminEventPage] Error loading event:', err);
464        setLoading(false);
465      });
466
467    return () => unsubscribe();
468  }, [eventId]);
469
470  // Load pending registrations (all staff can view)
471  useEffect(() => {
472    if (!eventId || !isStaff) return;
473
474    setPendingLoading(true);
475    const unsubscribe = firebase.firestore()
476      .collection('events').doc(eventId)
477      .collection('pendingRegistrations')
478      .where('status', '==', 'pending')
479      .orderBy('createdAt', 'desc')
480      .limit(200)
481      .onSnapshot(snapshot => {
482        setPendingRegistrations(snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })));
483        setPendingLoading(false);
484      }, err => {
485        console.error('[AdminEventPage] Error loading pending:', err);
486        setPendingLoading(false);
487      });
488
489    return () => unsubscribe();
490  }, [eventId, isStaff]);
491
492  // Load feedback for this event (all staff can view)
493  useEffect(() => {
494    if (!eventId || !isStaff) return;
495
496    setFeedbackLoading(true);
497    const unsubscribe = firebase.firestore()
498      .collection('events').doc(eventId)
499      .collection('feedback')
500      .orderBy('submittedAt', 'desc')
501      .limit(200)
502      .onSnapshot(snapshot => {
503        setFeedback(snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })));
504        setFeedbackLoading(false);
505      }, err => {
506        console.error('[AdminEventPage] Error loading feedback:', err);
507        setFeedbackLoading(false);
508      });
509
510    return () => unsubscribe();
511  }, [eventId, isStaff]);
512
513  // Load quick invites (one-off invitations from Invite tab) for this event.
514  //
515  // NO LIMIT, for the same reason as the prospects loader above and with the same evidence. This
516  // carried .limit(300) and the Ventilation event holds 492 invitations (measured 2026-09-16), so
517  // 192 were outside the window. It ordered createdAt DESC, which drops the OLDEST first - and the
518  // oldest is exactly the one buildInviterIndex wants, because a reminder mints a second invitation
519  // and the ORIGINAL is the one that names who actually invited the person.
520  //
521  // So on that event, today: prospects invited early read "Not invited yet", the invited-by filter
522  // misses them, and a colleague's count is understated. Silently, which is the same shape as the
523  // 500-prospect bug this commit exists for. prospect-inviters.js carried a KNOWN LIMIT note saying
524  // "the busiest live event has 44, so there is plenty of headroom" - a measurement that had gone
525  // out of date by an order of magnitude.
526  useEffect(() => {
527    if (!eventId || !isStaff) return;
528
529    const unsubscribe = db.collection('eventsConfig').doc('invitations').collection('list')
530      .where('eventId', '==', eventId)
531      .orderBy('createdAt', 'desc')
532      .onSnapshot(snapshot => {
533        const invites = snapshot.docs.map(doc => ({
534          id: doc.id,
535          ...doc.data(),
536          isQuickInvite: true, // Mark as quick invite for display
537          // Use stored status if declined/cant-attend, otherwise default to 'invited' - actual 'registered' status is determined by registrations collection
538          status: ['declined', 'cant-attend'].includes(doc.data().status) ? doc.data().status : 'invited',
539          invitationAccepted: doc.data().status === 'accepted',
540          createdBy: doc.data().invitedBy || doc.data().inviterEmail,
541          addedByName: doc.data().inviterName
542        }));
543        setQuickInvites(invites);
544      }, err => {
545        console.error('[AdminEventPage] Error loading quick invites:', err);
546      });
547
548    return () => unsubscribe();
549  }, [eventId, isStaff]);
550
551  // Load prospects for this event (staff only).
552  //
553  // NO LIMIT, and it cannot have one. Measured 2026-09-16: the Ventilation event already held 517
554  // prospects against the old .limit(500), so 17 people were invisible - staff could not see them,
555  // could not invite them, and adding somebody new pushed another off the end. That is 
555what was
556  // reported.
557  //
558  // A capped query is also the wrong shape for this screen rather than merely too small. Search,
559  // six filters, the sort and five separate counts all run over this array in memory
560  // (filteredProspects, myProspectCount, prospectInviterScope, the status pills, "Showing X of Y"),
561  // so a query that returned a page would make every one of those describe the page rather than the
562  // event, silently. Paging the QUERY means moving all of that server-side, which is a different
563  // and much larger job. The list is instead complete and the RENDER is windowed - see
564  // visibleProspects below.
565  useEffect(() => {
566    if (!eventId || !isStaff) return;
567
568    setProspectsLoading(true);
569    const unsubscribe = firebase.firestore()
570      .collection('events').doc(eventId)
571      .collection('prospects')
572      .orderBy('createdAt', 'desc')
573      .onSnapshot(snapshot => {
574        setProspects(snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })));
575        setProspectsLoading(false);
576      }, err => {
577        console.error('[AdminEventPage] Error loading prospects:', err);
578        setProspectsLoading(false);
579      });
580
581    return () => unsubscribe();
582  }, [eventId, isStaff]);
583
584  // Load tasks for this event (staff only)
585  useEffect(() => {
586    if (!eventId || !isStaff) return;
587
588    setTasksLoading(true);
589    const unsubscribe = firebase.firestore()
590      .collection('events')
591      .doc(eventId)
592      .collection('tasks')
593      .orderBy('createdAt', 'desc')
594      .limit(100)
595      .onSnapshot(snapshot => {
596        const taskData = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
597        setEventTasks(taskData);
598        setTasksLoading(false);
599      }, err => {
600        console.error('[AdminEventPage] Error fetching tasks:', err);
601        setTasksLoading(false);
602      });
603
604    return () => unsubscribe();
605  }, [eventId, isStaff]);
606
607  // Load staff members for task assignment dropdown
608  useEffect(() => {
609    if (!isStaff || (activeTab !== 'tasks' && activeTab !== 'prospects')) return;
610
611    firebase.firestore()
612      .collection('eventsUsers')
613      .where('userType', '==', 'staff')
614      .limit(200)
615      .get()
616      .then(snapshot => {
617        const staff = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
618        setStaffMembersList(staff);
619      })
620      .catch(err => console.error('[AdminEventPage] Error loading staff for tasks:', err));
621  }, [isStaff, activeTab]);
622
623  // Load questions for Q&A tab (staff only)
624  useEffect(() => {
625    if (!eventId || !isStaff || activeTab !== 'qa') return;
626
627    setQuestionsLoading(true);
628    const unsubscribe = firebase.firestore()
629      .collection('events')
630      .doc(eventId)
631      .collection('questions')
632      .orderBy('createdAt', 'desc')
633      .limit(100)
634      .onSnapshot(snapshot => {
635        const questionData = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
636        setQuestions(questionData);
637        setQuestionsLoading(false);
638      }, err => {
639        console.error('[AdminEventPage] Error fetching questions:', err);
640        setQuestionsLoading(false);
641      });
642
643    return () => unsubscribe();
644  }, [eventId, isStaff, activeTab]);
645
646  // Sync Q&A custom URL from event data
647  useEffect(() => {
648    if (event?.qaCustomUrl !== undefined) {
649      setQaCustomUrl(event.qaCustomUrl || '');
650    }
651  }, [event?.qaCustomUrl]);
652
653  // Load notes for this event (using collectionGroup query)
654  useEffect(() => {
655    if (!eventId || !isStaff || activeTab !== 'notes') return;
656
657    setEventNotesLoading(true);
658
659    // Query all notes subcollections for notes with this eventId
660    const unsubscribe = db.collectionGroup('notes')
661      .where('eventId', '==', eventId)
662      .orderBy('createdAt', 'desc')
663      .limit(100)
664      .onSnapshot(async snapshot => {
665        // De-duplicate parent-user lookups: many notes share the same member, so fetch each
666        // eventsUsers doc once rather than once per note (was up to 100 reads per snapshot).
667        const userRefsById = {};
668        snapshot.docs.forEach(doc => {
669          const ref = doc.ref.parent.parent; // eventsUsers/{userId}/notes/{noteId} -> user doc
670          if (ref) userRefsById[ref.id] = ref;
671        });
672        const userDataById = {};
673        await Promise.all(Object.entries(userRefsById).map(async ([id, ref]) => {
674          try {
675            const userDoc = await ref.get();
676            if (userDoc.exists) userDataById[id] = userDoc.data();
677          } catch (err) {
678            console.warn('[AdminEventPage] Could not fetch user for note:', err);
679          }
680        }));
681        const notesWithUsers = snapshot.docs.map(doc => {
682          const noteData = doc.data();
683          const userRef = doc.ref.parent.parent;
684          const userData = (userRef && userDataById[userRef.id]) || {};
685          return {
686            id: doc.id,
687            ...noteData,
688            userId: userRef?.id,
689            memberName: `${userData.firstName || ''} ${userData.lastName || ''}`.trim() || userData.email || 'Unknown',
690            memberEmail: userData.email || '',
691            memberCompany: userData.organisation || userData.company || ''
692          };
693        });
694        setEventNotes(notesWithUsers);
695        setEventNotesLoading(false);
696      }, err => {
697        console.error('[AdminEventPage] Error loading event notes:', err);
698        setEventNotesLoading(false);
699      });
700
701    return () => unsubscribe();
702  }, [eventId, isStaff, activeTab]);
703
704  // Auto-sync prospect statuses when registrations change
705  useEffect(() => {
706    if (!eventId || !isStaff || prospects.length === 0 || registrations.length === 0) return;
707
708    const updates = [];
709    registrations.forEach(reg => {
710      const regEmail = (reg.userEmail || reg.email || '').toLowerCase();
711      if (!regEmail) return;
712      const matchingProspect = prospects.find(p => (p.email || '').toLowerCase() === regEmail);
713      if (!matchingProspect) return;
714
715      if (reg.status === 'attended' && matchingProspect.status !== 'attended') {
716        updates.push({ id: matchingProspect.id, status: 'attended' });
717      } else if (['registered', 'confirmed'].includes(reg.status) && !['registered', 'attended'].includes(matchingProspect.status)) {
718        updates.push({ id: matchingProspect.id, status: 'registered' });
719      }
720    });
721
722    if (updates.length > 0) {
723      // Chunked: one batch is capped at 500 OPERATIONS and rejects outright past it, writing
724      // nothing. This event's prospect list passed 500 on 2026-09-16, and the catch below swallows
725      // the rejection - so every status would have silently stopped syncing with nothing on screen.
726      chunkedBatchWrite(
727        updates,
728        () => db.batch(),
729        (batch, u) => batch.update(db.collection('events').doc(eventId).collection('prospects').doc(u.id), {
730          status: u.status,
731          updatedAt: firebase.firestore.FieldValue.serverTimestamp()
732        })
733      ).catch(err => console.error('[AdminEventPage] Error syncing prospect statuses:', err));
734    }
735  }, [registrations, prospects, eventId, isStaff]);
736
737  // Close action dropdowns on outside click or scroll
738  useEffect(() => {
739    if (!openRegAction && !openProspectAction) return;
740    const close = () => { setOpenRegAction(null); setOpenProspectAction(null); };
741    const handleClick = (e) => { if (!e.target.closest('[data-action-menu]')) close(); };
742    document.addEventListener('click', handleClick);
743    window.addEventListener('scroll', close, true);
744    return () => { document.removeEventListener('click', handleClick); window.removeEventListener('scroll', close, true); };
745  }, [openRegAction, openProspectAction]);
746
747  // Build phone lookup from loaded members
748  const memberPhones = useMemo(() => {
749    const phones = {};
750    members.forEach(m => {
751      const email = (m.email || '').toLowerCase();
752      const phone = m.phone || m.mobile || '';
753      if (email && phone) phones[email] = phone;
754    });
755    return phones;
756  }, [members]);
757
758  // Who invited each prospect. Joined from this event's invitations, which the Invite tab
759  // already subscribes to, so this costs no extra read.
760  const prospectInviterIndex = useMemo(() => buildInviterIndex(quickInvites), [quickInvites]);
761
762  // Staff records by Auth uid, so an inviter reads as their real name rather than as whatever
763  // name the sending client happened to hold at the time (some invitations stored a first name
764  // alone, for the staff accounts with no Firebase Auth displayName).
765  const staffMembersByUid = useMemo(() => {
766    const byUid = new Map();
767    staffMembersList.forEach(s => { if (s && s.id) byUid.set(s.id, s); });
768    return byUid;
769  }, [staffMembersList]);
770
771  // How many prospects the toggle would leave on screen if pressed RIGHT NOW, which means
772  // honouring the status filter that is already applied. Counting across every status made the
773  // pill row contradict itself: with Contacted selected it could read 'Contacted (2)' beside
774  // 'Assigned to me (12)', and pressing the toggle then produced 2 rows, not 12. The search box
775  // is deliberately NOT applied here, because search is transient text rather than a filter you
776  // leave set, and every pill in this row ignores it. The invited-by filter narrows it for
777  // exactly the same reason as the status filter does.
778  const myProspectCount = useMemo(
779    () => prospects.filter(p =>
780      isProspectAssignedTo(p, user?.email)
781      && (prospectStatusFilter === 'all' || p.status === prospectStatusFilter)
782      && matchesInviter(p, prospectInviterIndex, prospectInviterFilter)
783    ).length,
784    [prospects, user?.email, prospectStatusFilter, prospectInviterIndex, prospectInviterFilter]
785  );
786
787  // Filtered prospects (assigned-to-me toggle + status filter + invited-by + search, combined)
788  const filteredProspects = useMemo(() => {
789    let filtered = prospects;
790    if (prospectMineOnly) {
791      filtered = filtered.filter(p => isProspectAssignedTo(p, user?.email));
792    }
793    if (prospectAutoReplyOnly) {
794      filtered = filtered.filter(p => p.autoReply && p.autoReply.detectedAt);
795    }
796    if (prospectStatusFilter !== 'all') {
797      filtered = filtered.filter(p => p.status === prospectStatusFilter);
798    }
799    filtered = filterProspectsByInviter(filtered, prospectInviterIndex, prospectInviterFilter);
800    filtered = filterProspectsByInvitationRange(filtered, prospectInviterIndex, prospectDateFrom, prospectDateTo);
801    if (prospectSearch.trim()) {
802      const q = prospectSearch.toLowerCase();
803      filtered = filtered.filter(p => {
804        const name = `${p.firstName || ''} ${p.lastName || ''}`.toLowerCase();
805        const email = (p.email || '').toLowerCase();
806        const company = (p.company || '').toLowerCase();
807        return name.includes(q) || email.includes(q) || company.includes(q);
808      });
809    }
810    // Sorted LAST, so it orders whatever the filters left rather than fighting them.
811    if (prospectSort !== 'none') {
812      filtered = sortProspectsByInvitationDate(filtered, prospectInviterIndex, prospectSort);
813    }
814    return filtered;
815  }, [prospects, prospectSearch, prospectStatusFilter, prospectMineOnly, prospectInviterFilter,
816      prospectInviterIndex, prospectDateFrom, prospectDateTo, prospectSort, prospectAutoReplyOnly, user?.email]);
817
818  // Counted over ALL prospects rather than the filtered set, so the pill says how many there are to
819  // look at rather than how many survive whatever else is switched on.
820  // Counted on DETECTION rather than on the extract. An auto-reply whose body opens with a quote
821  // marker extracts to an empty string, and gating on that stamped the row, counted it nowhere and
822  // drew nothing - the subject alone ("Automatic reply: ...") is usually enough to act on.
823  const prospectAutoReplyCount = useMemo(
824    () => prospects.filter(p => p.autoReply && p.autoReply.detectedAt).length,
825    [prospects]
826  );
827
828  // The rows actually painted, and the reset that keeps the window honest.
829  //
830  // Resetting on every change to the filtered set is deliberate: without it, narrowing a search
831  // after pressing "Show more" leaves the window at 300 over a result set of 12, so the button
832  // disappears and reappears for no reason the staff member can see. Keyed on length AND on the
833  // first row's id, because a filter can leave the count identical while changing who is in it.
834  const filteredKey = `${filteredProspects.length}:${filteredProspects[0]?.id || ''}`;
835  useEffect(() => { setProspectsShown(PROSPECT_PAGE); }, [filteredKey]);
836
837  const visibleProspects = useMemo(
838    () => filteredProspects.slice(0, prospectsShown),
839    [filteredProspects, prospectsShown]
840  );
841
842  // The scope the invited-by counts describe: what the OTHER filters have already left, so the
843  // number beside a colleague's name always matches the list you get by choosing them. Its own
844  // filter is excluded (a count of one is no use), and so is the search box, exactly as the
845  // status pills and the assigned-to-me count already do.
846  const prospectInviterScope = useMemo(
847    () => prospects.filter(p =>
848      (!prospectMineOnly || isProspectAssignedTo(p, user?.email))
849      && (prospectStatusFilter === 'all' || p.status === prospectStatusFilter)
850    ),
851    [prospects, prospectMineOnly, prospectStatusFilter, user?.email]
852  );
853
854  const prospectInviterOptions = useMemo(
855    () => inviterFilterOptions(prospectInviterScope, prospectInviterIndex, staffMembersByUid, prospectInviterFilter),
856    [prospectInviterScope, prospectInviterIndex, staffMembersByUid, prospectInviterFilter]
857  );
858
859  // Whether to offer the control at all, judged over EVERY prospect rather than the narrowed
860  // scope, so choosing a status cannot make it disappear mid-use.
861  const anyProspectInvited = useMemo(
862    () => prospects.some(p => inviterKeyForProspect(p, prospectInviterIndex) !== NOT_INVITED_KEY),
863    [prospects, prospectInviterIndex]
864  );
865
866  // Prospect modal - local members search (filters already-loaded eventsUsers)
867  // Shows first 10 by default, filters when searching
868  const [prospectModalSearchResults, setProspectModalSearchResults] = useState([]);
869  useEffect(() => {
870    // MARKED, not filtered, for the reason markAlreadyProspects gives: a hidden row reads as
871    // "this person is not on the platform" when it means "this person is already here".
872    const available = markAlreadyProspects(members, prospects, registrations);
873    if (!prospectModalSearch || prospectModalSearch.length < 2) {
874      setProspectModalSearchResults(takeAddable(available, BROWSE_ADDABLE, BROWSE_BLOCKED));
875      return;
876    }
877    const q = prospectModalSearch.toLowerCase();
878    const filtered = available.filter(m => {
879      const hay = `${m.firstName || ''} ${m.lastName || ''} ${m.email || ''} ${m.organisation || m.company || ''}`.toLowerCase();
880      return hay.includes(q);
881    });
882    setProspectModalSearchResults(takeAddable(filtered, SEARCH_ADDABLE, SEARCH_BLOCKED));
883  }, [prospectModalSearch, prospects, members, registrations]);
884
885  // Prospect modal - async contact search across Gold-Vision + the events address book.
886  // Loads a seed list on open, then searches BOTH collections when typing.
887  const [prospectContactsInitial, setProspectContactsInitial] = useState([]);
888  // Same class as prospectContactsFailed: a seed load that THREW left the tab rendering an
889  // input, a hint and nothing else, with both new messages gated behind a typed search - so
890  // staff would open the tab, see a blank panel and reach for Add New without typing.
891  const [prospectContactsInitialFailed, setProspectContactsInitialFailed] = useState(false);
892  useEffect(() => {
893    if (!showAddProspectModal || prospectModalTab !== 'contacts') return;
894    if (prospectContactsInitial.length > 0) return;
895    // Load first 10 contacts on tab open
896    (async () => {
897      try {
898        // Seeded from Gold-Vision, not addressBook. See initialContacts in helpers.js:
899        // seeding from addressBook returned twenty people with no email address once its
900        // Gold-Vision duplicates were retired, which is no use for inviting anyone.
901        // Marked against the prospects board at render, like the search results.
902        setProspectContactsInitial(await initialContacts(20));
903        setProspectContactsInitialFailed(false);
904      } catch (err) {
905        console.error('[AdminEventPage] Error loading initial contacts:', err);
906        setProspectContactsInitialFailed(true);
907      }
908    })();
909  }, [showAddProspectModal, prospectModalTab]);
910
911  // Two things here are load-bearing.
912  //
913  // `prospects` is NOT a dependency. It is a live snapshot, so every prospect anybody adds or
914  // edits used to re-run this effect, which means a fresh 400ms debounce and then the whole
915  // fan of Firestore probes again for a search box nobody had touched. The already-a-prospect
916  // marking happens at RENDER instead, off the same live snapshot, so it stays current
917  // without costing a re-query.
918  //
919  // The request-ordering guard exists because the search is now a dozen parallel queries whose
920  // completion time varies with the slowest one. Type "abira" then "abiraj" and the first call
921  // can resolve LAST, overwriting the newer results with the older ones. The cleanup only
922  // cancels a timer that has not fired yet; it cannot cancel a request already in flight, so
923  // the answer is to ignore a reply that is no longer the current one.
924  // Already-a-prospect MARKING, done at RENDER so the live prospects snapshot cannot force
925  // the search to re-run (see the effect below). Matched by email where there is one and by
926  // the contact's own id where there is not, matching what handleAddProspect refuses. It used
927  // to EXCLUDE those rows, which is the 2026-09-07 defect: a contact already on the board
928  // rendered as "No matching contacts found. Try Add New to create one."
929  const prospectContactsShown = useMemo(() => {
930    const searching = prospectContactsSearch.length >= 2;
931    const source = searching ? prospectContactsResults : prospectContactsInitial;
932    const marked = markAlreadyProspects(source, prospects, registrations);
933    return searching
934      ? takeAddable(marked, SEARCH_ADDABLE, SEARCH_BLOCKED)
935      : takeAddable(marked, BROWSE_ADDABLE, BROWSE_BLOCKED);
936  }, [prospects, registrations, prospectContactsResults, prospectContactsInitial, prospectContactsSearch]);
937
938  // ONE decision for both tabs, matching handleAddProspect's two refusals in the same order it
939  // makes them: already a prospect is checked before already registered.
940  // A WAITLISTED seat is not a registration, and calling it one is the class of false label
941  // this branch exists to remove. It still blocks: handleAddProspect refuses it, and holding
942  // a place, even a provisional one, is not being a prospect.
943  const addRowState = (c) => (
944    c.alreadyProspect
945      ? { blocked: true, label: 'Added', chip: 'Already a prospect', why: 'Already on this event\'s prospects board' }
946      : c.registrationStatus === 'waitlisted'
947        ? { blocked: true, label: 'Waitlisted', chip: 'On the waitlist', why: 'Already on the waitlist for this event' }
948        : c.alreadyRegistered
949          ? { blocked: true, label: 'Registered', chip: 'Already registered', why: 'Already registered for this event' }
950          : { blocked: false, label: null, chip: null, why: undefined }
951  );
952
953  const prospectSearchSeq = useRef(0);
954  useEffect(() => {
955    if (!prospectContactsSearch || prospectContactsSearch.length < 2) {
956      prospectSearchSeq.current += 1;
957      setProspectContactsResults([]);
958      setProspectContactsFailed(false);
959      return;
960    }
961    setProspectContactsSearching(true);
962    const seq = ++prospectSearchSeq.current;
963    const timeoutId = setTimeout(async () => {
964      try {
965        const { contacts, failed } = await searchContactsDetailed(prospectContactsSearch, { limit: 20 });
966        if (seq !== prospectSearchSeq.current) return; // a newer search has already answered
967        setProspectContactsResults(contacts);
968        setProspectContactsFailed(failed);
969      } catch (err) {
970        console.error('[AdminEventPage] Contacts search error:', err);
971        if (seq === prospectSearchSeq.current) { setProspectContactsResults([]); setProspectContactsFailed(true); }
972      }
973      if (seq === prospectSearchSeq.current) setProspectContactsSearching(false);
974    }, 400);
975    return () => { clearTimeout(timeoutId); setProspectContactsSearching(false); };
976  }, [prospectContactsSearch]);
977
978  // Load members for invite search and phone lookups (all staff can invite)
979  useEffect(() => {
980    if (!isStaff || (activeTab !== 'invite' && activeTab !== 'registrations' && activeTab !== 'prospects')) return;
981
982    const unsubscribe = firebase.firestore()
983      .collection('eventsUsers')
984      .where('userType', '!=', 'staff')
985      .orderBy('userType')
986      .orderBy('firstName')
987      .limit(500)
988      .onSnapshot(snapshot => {
989        setMembers(snapshot.docs.map(doc => ({ id: doc.id, ...doc.data(), source: 'member' })));
990      });
991
992    return () => unsubscribe();
993  }, [isStaff, activeTab]);
994
995  // Search all eventsUsers for invite (debounced, via searchTerms)
996  useEffect(() => {
997    if (!isStaff || activeTab !== 'invite') return;
998    if (!inviteSearch || inviteSearch.length < 2) {
999      setInviteSearchResults([]);
1000      return;
1001    }
1002
1003    const timeoutId = setTimeout(async () => {
1004      setInviteSearching(true);
1005      try {
1006        const results = await searchAddressBook(inviteSearch, { limit: 10 });
1007        setInviteSearchResults(results.map(m => ({ ...m, source: 'member' })));
1008        setShowInviteDropdown(true);
1009      } catch (err) {
1010        console.error('[AdminEventPage] Search error:', err);
1011      }
1012      setInviteSearching(false);
1013    }, 300);
1014
1015    return () => clearTimeout(timeoutId);
1016  }, [inviteSearch, isStaff, activeTab]);
1017
1018  // Close share menu on outside click
1019  useEffect(() => {
1020    const handleClickOutside = (e) => {
1021      if (shareMenuRef.current && !shareMenuRef.current.contains(e.target)) {
1022        setShowShareMenu(false);
1023      }
1024    };
1025    if (showShareMenu) {
1026      document.addEventListener('mousedown', handleClickOutside);
1027      return () => document.removeEventListener('mousedown', handleClickOutside);
1028    }
1029  }, [showShareMenu]);
1030
1031  // Close export menu on outside click
1032  useEffect(() => {
1033    const handleClickOutside = (e) => {
1034      if (exportMenuRef.current && !exportMenuRef.current.contains(e.target)) {
1035        setShowExportMenu(false);
1036      }
1037    };
1038    if (showExportMenu) {
1039      document.addEventListener('mousedown', handleClickOutside);
1040      return () => document.removeEventListener('mousedown', handleClickOutside);
1041    }
1042  }, [showExportMenu]);
1043
1044  // Select contact to fill invite form
1045  const selectInviteContact = (contact) => {
1046    setInviteEmail(contact.email || '');
1047    setInviteFirstName(contact.firstName || '');
1048    setInviteLastName(contact.lastName || '');
1049    setInviteCompany(contact.organisation || contact.company || '');
1050    setInviteSearch('');
1051    setInviteSearchResults([]);
1052    setShowInviteDropdown(false);
1053  };
1054
1055  // ===== PROSPECT HANDLERS =====
1056
1057  // Add existing member as prospect
1058  const handleAddProspect = async (member) => {
1059    if (!eventId || !member) return;
1060    setAddingProspect(true);
1061    try {
1062      const email = (member.email || '').toLowerCase().trim();
1063      // The already-a-prospect and already-registered checks are BY EMAIL, so they can only run
1064      // when there is one. Comparing blanks matched every email-less prospect against every
1065      // email-less contact, so the second contact with no address was always refused as a
1066      // duplicate of the first - two different people, one row.
1067      // With no email there is nothing to compare, so the contact's own id is the fallback -
1068      // it is already stored as `memberId` on every prospect this button creates. Without it
1069      // the Add button stayed live on a row that had just been added and a second press wrote
1070      // a second identical prospect, repeatable indefinitely.
1071      const existing = email
1072        ? prospects.find(p => (p.email || '').toLowerCase().trim() === email)
1073        : (member.id && prospects.find(p => p.memberId === member.id));
1074      if (existing) {
1075        setMessage({ type: 'error', text: `${member.firstName} ${member.lastName} is already a prospect` });
1076        setAddingProspect(false);
1077        return;
1078      }
1079      // Check if already registered
1080      // The SAME set the picker labels from, so the disabled button and the refusal can never
1081      // disagree. A declined or cancelled seat is not a registration - see contacts.js.
1082      const existingReg = email && registeredEmailSet(registrations).has(email);
1083      if (existingReg) {
1084        setMessage({ type: 'error', text: `${member.firstName} ${member.lastName} is already registered for this event` });
1085        setAddingProspect(false);
1086        return;
1087      }
1088      await db.collection('events').doc(eventId).collection('prospects').add({
1089        email,
1090        firstName: member.firstName || '',
1091        lastName: member.lastName || '',
1092        company: member.organisation || member.company || '',
1093        jobTitle: member.jobTitle || member.position || '',
1094        phone: member.phone || member.mobile || '',
1095        status: 'new',
1096        assignedTo: user.email || '',
1097        assignedToName: user.displayName || user.email || '',
1098        notes: '',
1099        memberId: member.id || null,
1100        createdAt: firebase.firestore.FieldValue.serverTimestamp(),
1101        createdBy: user.email || '',
1102        updatedAt: firebase.firestore.FieldValue.serverTimestamp()
1103      });
1104      // Said out loud rather than left to be discovered: the CRM holds thousands of contacts
1105      // with a phone number and no email address, and an invitation on this platform is an
1106      // email. The prospect row is still worth having (staff chase it by phone) but nobody
1107      // should find out at invite time that it could never be sent.
1108      const displayName = `${member.firstName || ''} ${member.lastName || ''}`.trim() || 'Contact';
1109      setMessage(email
1110        ? { type: 'success', text: `Added ${displayName} as prospect` }
1111        : { type: 'error', text: `Added ${displayName} as prospect, but there is no email address on this contact - add one before they can be invited` });
1112      // The search box is deliberately NOT cleared. It used to be, which wiped the Members
1113      // tab's results and made the row vanish at the moment it should have turned into
1114      // "Added" - the same disappearance this branch exists to stop, triggered by the
1115      // success path rather than the search path.
1116    } catch (err) {
1117      console.error('[AdminEventPage] Error adding prospect:', err);
1118      setMessage({ type: 'error', text: 'Failed to add prospect: ' + err.message });
1119    } finally {
1120      setAddingProspect(false);
1121    }
1122  };
1123
1124  // Add manual prospect (creates eventsUsers entry + prospect)
1125  const handleAddManualProspect = async () => {
1126    const { firstName, lastName, email, company, phone, jobTitle } = manualProspect;
1127    if (!firstName.trim() || !email.trim()) {
1128      setMessage({ type: 'error', text: 'First name and email are required' });
1129      return;
1130    }
1131    const emailLower = email.toLowerCase().trim();
1132    // Check if already a prospect
1133    // The SAME set the picker labels from. This half was left untrimmed while its sibling
1134    // below was updated, so a prospect stored with stray whitespace read as "Already a
1135    // prospect" in the picker and could still be re-created from this form.
1136    if (prospectEmailSet(prospects).has(emailLower)) {
1137      setMessage({ type: 'error', text: 'This email is already a prospect' });
1138      return;
1139    }
1140    // The SAME rule the picker labels from, and the SAME wording. Reading only the set said
1141    // "already registered" about a WAITLISTED address while addRowState, three lines away in
1142    // the same modal, called that person "On the waitlist" precisely because the other
1143    // wording is false - two surfaces of one modal stating different facts about one person,
1144    // which is the disagreement this branch exists to remove.
1145    const seatStatus = registrationStatusOf({ email: emailLower }, registrationStatusByEmail(registrations));
1146    if (seatStatus) {
1147      setMessage({ type: 'error', text: seatStatus === 'waitlisted'
1148        ? 'This email is already on the waitlist for this event'
1149        : 'This email is already registered for this event' });
1150      return;
1151    }
1152    setAddingProspect(true);
1153    try {
1154      // Check if member already exists in eventsUsers
1155      const existingSnapshot = await db.collection('eventsUsers')
1156        .where('email', '==', emailLower)
1157        .limit(1)
1158        .get();
1159
1160      let memberId = null;
1161      if (existingSnapshot.empty) {
1162        // Create new eventsUsers member
1163        const memberRef = await db.collection('eventsUsers').add({
1164          email: emailLower,
1165          firstName: firstName.trim(),
1166          lastName: lastName.trim(),
1167          organisation: company.trim(),
1168          phone: phone.trim(),
1169          userType: 'external',
1170          status: 'active',
1171          createdAt: firebase.firestore.FieldValue.serverTimestamp(),
1172          createdBy: user.email || ''
1173        });
1174        memberId = memberRef.id;
1175      } else {
1176        memberId = existingSnapshot.docs[0].id;
1177      }
1178
1179      // Add prospect doc
1180      await db.collection('events').doc(eventId).collection('prospects').add({
1181        email: emailLower,
1182        firstName: firstName.trim(),
1183        lastName: lastName.trim(),
1184        company: company.trim(),
1185        jobTitle: jobTitle.trim(),
1186        phone: phone.trim(),
1187        status: 'new',
1188        assignedTo: user.email || '',
1189        assignedToName: user.displayName || user.email || '',
1190        notes: '',
1191        memberId,
1192        createdAt: firebase.firestore.FieldValue.serverTimestamp(),
1193        createdBy: user.email || '',
1194        updatedAt: firebase.firestore.FieldValue.serverTimestamp()
1195      });
1196
1197      // Also persist to the shared addressBook so this non-GV contact is searchable + flagged
1198      // in future pickers. Best-effort: addressBook writes are admin-only, so don't fail the
1199      // prospect add if this is denied.
1200      createManualContact({ firstName, lastName, email: emailLower, company, phone, jobTitle })
1201        .catch(err => console.warn('[AdminEventPage] addressBook persist skipped:', err?.message));
1202
1203      setMessage({ type: 'success', text: `Added ${firstName} ${lastName} as prospect` });
1204      setManualProspect({ firstName: '', lastName: '', email: '', company: '', phone: '', jobTitle: '' });
1205      setShowAddProspectModal(false);
1206    } catch (err) {
1207      console.error('[AdminEventPage] Error adding manual prospect:', err);
1208      setMessage({ type: 'error', text: 'Failed to add prospect: ' + err.message });
1209    } finally {
1210      setAddingProspect(false);
1211    }
1212  };
1213
1214  // Update prospect status
1215  const handleUpdateProspectStatus = async (prospectId, newStatus) => {
1216    const prospect = prospects.find(p => p.id === prospectId);
1217    if (!prospect) return;
1218
1219    // If changing to 'registered', trigger the full registration flow
1220    if (newStatus === 'registered' && prospect.status !== 'registered') {
1221      try {
1222        const email = (prospect.email || '').toLowerCase().trim();
1223        if (!email) {
1224          setMessage({ type: 'error', text: 'Prospect has no email address' });
1225          return;
1226        }
1227
1228        // 1. Ensure they have an eventsUsers member record
1229        let memberId = prospect.memberId;
1230        let memberRecordError = null;
1231        if (!memberId) {
1232          const existingSnap = await db.collection('eventsUsers')
1233            .where('email', '==', email)
1234            .limit(1)
1235            .get();
1236          if (!existingSnap.empty) {
1237            memberId = existingSnap.docs[0].id;
1238          } else {
1239            const memberRef = await db.collection('eventsUsers').add({
1240              email,
1241              firstName: prospect.firstName || '',
1242              lastName: prospect.lastName || '',
1243              organisation: prospect.company || '',
1244              jobTitle: prospect.jobTitle || '',
1245              phone: prospect.phone || '',
1246              userType: 'member',
1247              status: 'approved',
1248              createdAt: firebase.firestore.FieldValue.serverTimestamp(),
1249              createdBy: user.email || '',
1250              registeredVia: 'prospect-conversion'
1251            });
1252            memberId = memberRef.id;
1253          }
1254        } else {
1255          // Update the member record with the latest prospect data. This uses set with merge,
1256          // not update: a prospect can carry a memberId whose eventsUsers document no longer
1257          // exists (deleted member, stale link), and update throws not-found in that case, so
1258          // the promotion silently left the member record uncreated. Same reasoning as
1259          // rejectUser in hooks.js.
1260          try {
1261            const memberRef = db.collection('eventsUsers').doc(memberId);
1262            const memberSnap = await memberRef.get();
1263            const memberData = {
1264              firstName: prospect.firstName || '',
1265              lastName: prospect.lastName || '',
1266              organisation: prospect.company || '',
1267              jobTitle: prospect.jobTitle || '',
1268              phone: prospect.phone || '',
1269              userType: 'member',
1270              status: 'approved',
1271              updatedAt: firebase.firestore.FieldValue.serverTimestamp()
1272            };
1273            if (!memberSnap.exists) {
1274              // Creating rather than updating, so add the fields the create branch above sets.
1275              // They are added only on create so an existing record keeps its own createdAt.
1276              memberData.email = email;
1277              memberData.createdAt = firebase.firestore.FieldValue.serverTimestamp();
1278              memberData.createdBy = user.email || '';
1279              memberData.registeredVia = 'prospect-conversion';
1280            }
1281            await memberRef.set(memberData, { merge: true });
1282          } catch (err) {
1283            console.error('[AdminEventPage] Could not write eventsUsers record:', err);
1284            memberRecordError = err;
1285          }
1286        }
1287
1288        // A failed member write is not fatal to the registration below, but the staff member
1289        // has to know the member record is missing rather than only seeing it in the console.
1290        const memberWarning = memberRecordError
1291          ? ` (warning: their member record could not be saved - ${memberRecordError.message || 'unknown error'})`
1292          : '';
1293
1294        // 2. Check if already registered for this event.
1295        //
1296        // A WITHDRAWN seat is handled first and separately. Excluding declined and cancelled
1297        // seats from the prospect rules made this path newly reachable - a decliner can now
1298        // be put on the board and chased - and the branch below would then have told staff
1299        // they were already registered AND stamped status:'registered' on the prospect,
1300        // leaving the board asserting a registration that does not exist for somebody who
1301        // said no. Registering them anyway is NOT done here: reviving a seat is
1302        // decideExistingSeat's decision, server-side, not a second client-written row. The
1303        // route that DOES work is naming: inviting them again lets them answer yes, and
1304        // respondToEventInvitation revives the seat through decideExistingSeat. Refusing
1305        // without saying so left staff at a dead end on a capability this branch added.
1306        const liveSeat = registrationStatusOf({ email }, registrationStatusByEmail(registrations));
1307        const withdrawnSeat = !liveSeat && registrations.find(r => (r.userEmail || r.email || '').toLowerCase().trim() === email);
1308        if (withdrawnSeat) {
1309          setMessage({
1310            type: 'error',
1311            text: `${prospect.firstName} ${withdrawnSeat.status === 'cancelled' ? 'had their place cancelled' : 'said they cannot make it'}. Send them an invitation and they can change their answer themselves${memberWarning}`
1312          });
1313          return;
1314        }
1315        if (liveSeat) {
1316          setMessage({
1317            type: memberRecordError ? 'error' : 'info',
1318            text: `${prospect.firstName} is already registered for this event${memberWarning}`
1319          });
1320          // Still update prospect status
1321          await db.collection('events').doc(eventId).collection('prospects').doc(prospectId).update({
1322            status: 'registered',
1323            memberId,
1324            updatedAt: firebase.firestore.FieldValue.serverTimestamp()
1325          });
1326          return;
1327        }
1328
1329        // 3. Create registration
1330        const qrToken = randomToken();
1331        await db.collection('events').doc(eventId).collection('registrations').add({
1332          userId: memberId,
1333          userEmail: email,
1334          userName: `${prospect.firstName || ''} ${prospect.lastName || ''}`.trim() || email,
1335          eventId,
1336          status: 'registered',
1337          registeredAt: firebase.firestore.FieldValue.serverTimestamp(),
1338          qrToken,
1339          organisation: prospect.company || '',
1340          jobTitle: prospect.jobTitle || '',
1341          phone: prospect.phone || '',
1342          registeredVia: 'prospect-conversion',
1343          registeredBy: user.email || ''
1344        });
1345
1346        // 4. Update registration count
1347        await db.collection('events').doc(eventId).update({
1348          registrationCount: firebase.firestore.FieldValue.increment(1)
1349        });
1350
1351        // 5. Update prospect status and link memberId
1352        await db.collection('events').doc(eventId).collection('prospects').doc(prospectId).update({
1353          status: 'registered',
1354          memberId,
1355          updatedAt: firebase.firestore.FieldValue.serverTimestamp()
1356        });
1357
1358        // 6. Send registration confirmation email (non-blocking)
1359        try {
1360          const sendConfirmation = functions.httpsCallable('sendRegistrationConfirmationEmail');
1361          sendConfirmation({
1362            eventId,
1363            email,
1364            firstName: prospect.firstName || ''
1365          }).catch(err => console.warn('[AdminEventPage] Confirmation email failed:', err));
1366        } catch (emailErr) {
1367          console.warn('[AdminEventPage] Could not send confirmation email:', emailErr);
1368        }
1369
1370        setMessage({
1371          type: memberRecordError ? 'error' : 'success',
1372          text: `${prospect.firstName} ${prospect.lastName} registered and confirmation email sent${memberWarning}`
1373        });
1374      } catch (err) {
1375        console.error('[AdminEventPage] Error registering prospect:', err);
1376        setMessage({ type: 'error', text: 'Failed to register prospect: ' + err.message });
1377      }
1378      return;
1379    }
1380
1381    // For all other status changes, just update the prospect doc
1382    try {
1383      await db.collection('events').doc(eventId).collection('prospects').doc(prospectId).update({
1384        status: newStatus,
1385        updatedAt: firebase.firestore.FieldValue.serverTimestamp()
1386      });
1387    } catch (err) {
1388      console.error('[AdminEventPage] Error updating prospect status:', err);
1389      setMessage({ type: 'error', text: 'Failed to update status' });
1390    }
1391  };
1392
1393  // Assign staff to prospect
1394  const handleUpdateProspectAssignment = async (prospectId, staffEmail, staffName) => {
1395    try {
1396      await db.collection('events').doc(eventId).collection('prospects').doc(prospectId).update({
1397        assignedTo: staffEmail,
1398        assignedToName: staffName,
1399        updatedAt: firebase.firestore.FieldValue.serverTimestamp()
1400      });
1401    } catch (err) {
1402      console.error('[AdminEventPage] Error assigning prospect:', err);
1403      setMessage({ type: 'error', text: 'Failed to assign staff' });
1404    }
1405  };
1406
1407  // Update prospect notes
1408  const handleUpdateProspectNotes = async (prospectId, notes) => {
1409    try {
1410      await db.collection('events').doc(eventId).collection('prospects').doc(prospectId).update({
1411        notes,
1412        updatedAt: firebase.firestore.FieldValue.serverTimestamp()
1413      });
1414      setEditingProspectNotes(null);
1415    } catch (err) {
1416      console.error('[AdminEventPage] Error updating prospect notes:', err);
1417      setMessage({ type: 'error', text: 'Failed to update notes' });
1418    }
1419  };
1420
1421  // Delete prospect
1422  const handleDeleteProspect = async (prospectId) => {
1423    if (!confirm('Remove this prospect?')) return;
1424    try {
1425      const prospect = prospects.find(p => p.id === prospectId);
1426      await db.collection('events').doc(eventId).collection('prospects').doc(prospectId).delete();
1427
1428      // Cancel any calendar invite for this prospect
1429      if (prospect?.email) {
1430        try {
1431          const cancelInvite = functions.httpsCallable('cancelCalendarInvite');
1432          await cancelInvite({ eventId, recipientEmail: prospect.email });
1433        } catch (calErr) {
1434          console.warn('[AdminEventPage] Calendar invite cancel failed (non-critical):', calErr);
1435        }
1436      }
1437
1438      setMessage({ type: 'success', text: 'Prospect removed' });
1439    } catch (err) {
1440      console.error('[AdminEventPage] Error deleting prospect:', err);
1441      setMessage({ type: 'error', text: 'Failed to remove prospect' });
1442    }
1443  };
1444
1445  // Save prospect edits — syncs to prospect doc, eventsUsers, and addressBook
1446  const handleSaveProspectEdit = async () => {
1447    if (!editingProspect) return;
1448    setSavingProspect(true);
1449    try {
1450      const updates = {
1451        firstName: editProspectData.firstName.trim(),
1452        lastName: editProspectData.lastName.trim(),
1453        company: editProspectData.company.trim(),
1454        jobTitle: editProspectData.jobTitle.trim(),
1455        phone: editProspectData.phone.trim(),
1456        updatedAt: firebase.firestore.FieldValue.serverTimestamp()
1457      };
1458
1459      // 1. Update prospect doc
1460      await db.collection('events').doc(eventId).collection('prospects').doc(editingProspect.id).update(updates);
1461
1462      // 2. Sync to eventsUsers if linked
1463      if (editingProspect.memberId) {
1464        try {
1465          await db.collection('eventsUsers').doc(editingProspect.memberId).update({
1466            firstName: updates.firstName,
1467            lastName: updates.lastName,
1468            organisation: updates.company,
1469            jobTitle: updates.jobTitle,
1470            phone: updates.phone,
1471            updatedAt: firebase.firestore.FieldValue.serverTimestamp()
1472          });
1473        } catch (err) {
1474          console.warn('[AdminEventPage] Could not sync to eventsUsers:', err);
1475        }
1476      }
1477
1478      // 3. Sync to addressBook by email
1479      const email = (editingProspect.email || '').toLowerCase().trim();
1480      if (email) {
1481        try {
1482          const abSnap = await db.collection('addressBook')
1483            .where('email', '==', email)
1484            .limit(1)
1485            .get();
1486          if (!abSnap.empty) {
1487            await abSnap.docs[0].ref.update({
1488              firstName: updates.firstName,
1489              lastName: updates.lastName,
1490              company: updates.company,
1491              organisation: updates.company,
1492              jobTitle: updates.jobTitle,
1493              phone: updates.phone,
1494              updatedAt: firebase.firestore.FieldValue.serverTimestamp()
1495            });
1496          }
1497        } catch (err) {
1498          console.warn('[AdminEventPage] Could not sync to addressBook:', err);
1499        }
1500      }
1501
1502      // 4. Sync to any other event prospect docs with same email
1503      // (non-blocking, best-effort)
1504      if (email) {
1505        try {
1506          const otherProspects = await db.collectionGroup('prospects')
1507            .where('email', '==', email)
1508            .get();
1509          // Unbounded by construction: a collection-group query over every event's prospects, so
1510          // one person on many events, or one address held by many rows, can exceed a batch.
1511          await chunkedBatchWrite(
1512            otherProspects.docs.filter(doc => doc.id !== editingProspect.id),
1513            () => db.batch(),
1514            (batch, doc) => batch.update(doc.ref, {
1515              firstName: updates.firstName,
1516              lastName: updates.lastName,
1517              company: updates.company,
1518              jobTitle: updates.jobTitle,
1519              phone: updates.phone,
1520              updatedAt: firebase.firestore.FieldValue.serverTimestamp()
1521            })
1522          );
1523        } catch (err) {
1524          console.warn('[AdminEventPage] Could not sync to other prospect docs:', err);
1525        }
1526      }
1527
1528      setMessage({ type: 'success', text: 'Prospect updated across all records' });
1529      setEditingProspect(null);
1530    } catch (err) {
1531      console.error('[AdminEventPage] Error saving prospect:', err);
1532      setMessage({ type: 'error', text: 'Failed to update prospect' });
1533    } finally {
1534      setSavingProspect(false);
1535    }
1536  };
1537
1538  // Invite a prospect (send invitation email and update status)
1539  // ONE send path for the prospects board, because there was never more than one action here.
1540  // handleInviteProspect and handleRemindProspect differed in two things - whether they minted
1541  // a token and which toast they showed - and both called the same email. Splitting them by
1542  // status is what pushed the reminder into the overflow menu; see prospect-actions.js.
1543  //
1544  // IT REUSES AN EXISTING INVITATION and mints only when there is none. A second invitation for
1545  // one person leaves two live tokens and moves the invited-by attribution to whoever pressed
1546  // the button, which prospect-inviters.js has to work around already (35 of 158 live groups
1547  // carry more than one inviter for exactly this reason). isReminder changes WORDING only.
1548  const sendProspectInvitation = async (prospect, { isReminder, previewedAsReminder }) => {
1549    if (!prospect?.email) return;
1550    const address = prospect.email.toLowerCase().trim();
1551    setInvitingProspect(prospect.id);
1552    // Declared outside the try so the CATCH can see it. A const inside the try is block-scoped,
1553    // so the undo would have thrown a ReferenceError on every refusal - which is now the
1554    // ordinary path. Caught by the scope sweep, not by the suite.
1555    let inviteRef = null;
1556    try {
1557      // ONE rule decides which invitation this is, shared with the server and with
1558      // "Copy invite link" - see original-invitation.js. A prospect legitimately holds
1559      // several, and taking whichever a .limit(1) query returned meant the two controls on
1560      // one row could hand out different tokens and name different inviters.
1561      // TWO SCOPED queries, merged - see mergeInvitationDocs. An email-only scan capped at 20
1562      // can miss this event's row entirely once a contact holds more than 20 invitations
1563      // across all events, and the caller then mints a second one.
1564      const list = db.collection('eventsConfig').doc('invitations').collection('list');
1565      const [flatSnap, nestedSnap] = await Promise.all([
1566        list.where('email', '==', address).where('eventId', '==', event.id).limit(INVITATION_SCAN_LIMIT).get(),
1567        list.where('email', '==', address).where('event.id', '==', event.id).limit(INVITATION_SCAN_LIMIT).get()
1568      ]);
1569      const found = pickOriginalInvitation(mergeInvitationDocs(flatSnap, nestedSnap), event.id);
1570
1571      let token;
1572      let originalInviterName = null;
1573      // A found row with no token is a DEAD END if it is trusted: the callable refuses an
1574      // empty invitationToken and the mint branch never runs, so the staff member can press
1575      // send for ever. Fall through and mint instead.
1576      // pickOriginalInvitation only ever returns a row carrying a token, so this reads as
1577      // belt and braces - kept because the mint branch below is what must run when there
1578      // is nothing usable, and that is worth being explicit about.
1579      if (found && found.data().token) {
1580        token = found.data().token;
1581        // A reminder claims something about the FIRST email, so the server names whoever sent
1582        // it rather than whoever is working the board today.
1583        originalInviterName = found.data().inviterName || null;
1584      } else {
1585        token = Array.from(crypto.getRandomValues(new Uint8Array(16)))
1586          .map(b => b.toString(16).padStart(2, '0'))
1587          .join('')
1588          .match(/.{4}/g)
1589          .join('-');
1590        // CAPTURED so the catch can undo it, and ONLY on this branch. A REUSED invitation
1591        // predates this click and must never be deleted by a refusal - undoing it would
1592        // destroy the original record and move the invited-by attribution, which is the very
1593        // thing reusing it exists to protect. `inviteRef` therefore stays null on the reuse
1594        // path and undoRefusedInvitation does nothing.
1595        inviteRef = await db.collection('eventsConfig').doc('invitations').collection('list').add({
1596          email: address,
1597          firstName: prospect.firstName || '',
1598          lastName: prospect.lastName || '',
1599          company: prospect.company || '',
1600          eventId: event.id,
1601          eventTitle: event.title,
1602          token,
1603          status: 'pending',
1604          invitedBy: user.uid,
1605          inviterName: displayNameFrom(user),
1606          createdAt: firebase.firestore.FieldValue.serverTimestamp()
1607        });
1608      }
1609
1610      // A REMINDER CLAIMS THERE WAS A FIRST EMAIL. If nothing was found we are minting the
1611      // first one right now, so the claim would be false - a status set to `invited` by hand
1612      // from the row's dropdown is enough to reach here.
1613      //
1614      // Computed ONCE and used for the payload, the stamp AND the toast. Sending the effective
1615      // value while stamping and announcing the requested one wrote "Reminded today" for a
1616      // reminder that was never sent - the false record the stamp exists to prevent.
1617      const sentAsReminder = !!isReminder && !!found;
1618      // What the staff member actually READ. If the token state moved under them the email
1619      // would say something they never saw, so nothing is sent.
1620      if (previewedAsReminder !== undefined && previewedAsReminder !== sentAsReminder) {
1621        setMessage({ type: 'error', text: 'Their invitation changed while you were reading. Nothing was sent - open it again to see the current email.' });
1622        return;
1623      }
1624
1625      const sendInvitationEmail = functions.httpsCallable('sendEventsInvitationEmail');
1626      const sendResult = await sendInvitationEmail({
1627        email: address,
1628        firstName: prospect.firstName || '',
1629        inviterName: displayNameFrom(user),
1630        invitationToken: token,
1631        isReminder: sentAsReminder,
1632        originalInviterName,
1633        event: {
1634          id: event.id,
1635          title: event.title,
1636          eventDate: event.eventDate?.toDate?.()?.toISOString() || event.eventDate,
1637          location: event.location
1638        }
1639      });
1640
1641      // statusAfterSend promotes ONLY a prospect who had never been asked. A reminder must not
1642      // overwrite `viewed` (that loses the fact they opened it) and re-inviting somebody who
1643      // declined must not erase an answer they have not changed.
1644      const nextStatus = statusAfterSend(prospect);
1645      const patch = { updatedAt: firebase.firestore.FieldValue.serverTimestamp() };
1646      if (nextStatus) patch.status = nextStatus;
1647      // Both are stamped, so the board records that something went out even where the status
1648      // deliberately does not move - an "Invite again" to somebody who declined changed
1649      // nothing visible at all, so it could be sent repeatedly with only a toast as feedback.
1650      patch[sentAsReminder ? 'lastRemindedAt' : 'lastInvitedAt'] = firebase.firestore.FieldValue.serverTimestamp();
1651      // THE EMAIL HAS GONE by this point, so a failure here is not a failed send. Reporting it
1652      // as one invites the staff member to press send again and post a second copy - and the
1653      // likeliest cause is the row having been deleted while the dialog was open, which throws
1654      // NOT_FOUND on this update alone.
1655      const who = `${prospect.firstName || ''} ${prospect.lastName || ''}`.trim() || address;
1656      // The server could not read the event, so the email that went is NOT the one that was
1657      // previewed - plainer wording, no stand offer, a single date on a multi-day show. Said
1658      // out loud, because the dialog promised the opposite.
1659      const fellBack = !!(sendResult && sendResult.data && sendResult.data.usedFallbackWording);
1660      const caveat = fellBack ? ' (the event could not be read, so the wording is plainer than the preview)' : '';
1661      try {
1662        await db.collection('events').doc(eventId).collection('prospects').doc(prospect.id).update(patch);
1663        setMessage({ type: 'success', text: `${sentAsReminder ? 'Reminder' : 'Invitation'} sent to ${who}${caveat}` });
1664      } catch (err) {
1665        console.error('[AdminEventPage] Sent, but the prospect could not be updated:', err);
1666        setMessage({ type: 'error', text: `${sentAsReminder ? 'Reminder' : 'Invitation'} WAS sent to ${who}, but the board could not be updated. Do not send again.` });
1667      }
1668    } catch (err) {
1669      console.error('[AdminEventPage] Error sending to prospect:', err);
1670      // A REFUSAL is not a delivery failure: the hourly cap and a suppressed address both mean
1671      // nothing was sent and nothing will be, so the invitation record is undone. Left behind it
1672      // flips the prospect to "Invited by X" - invited-by is a join on the INVITATION, not on the
1673      // prospect's status - and mints a fresh token on every retry. A genuine send failure keeps
1674      // the record, because that invitation may yet be usable.
1675      //
1676      // `inviteRef` is null whenever this send REUSED an existing invitation, so a refusal can
1677      // only ever undo a record this click created.
1678      await undoRefusedInvitation(inviteRef, err);
1679      setMessage({ type: 'error', text: `Failed to send: ${err.message}` });
1680    } finally {
1681      setInvitingProspect(null);
1682    }
1683  };
1684
1685  // SHOW THE STAFF MEMBER THE ACTUAL EMAIL BEFORE IT GOES.
1686  //
1687  // The preview is rendered by the SAME builder that sends it (functions/lib/
1688  // events-invitation-email.js), so a wording change cannot appear in one and not the other.
1689  // Rebuilding the template on the client to avoid a round trip would be a second copy, and
1690  // a preview that drifts is worse than none: it shows staff an email nobody receives.
1691  //
1692  // It takes an eventId and a prospectId and nothing else that identifies anybody; the name,
1693  // company and address come off the prospect document server-side.
1694  const openEmailPreview = async (prospect, action) => {
1695    if (!prospect?.email) {
1696      setMessage({ type: 'error', text: `${prospect?.firstName || 'That contact'} has no email address, so nothing can be sent to them` });
1697      return;
1698    }
1699    const seq = ++emailPreviewSeq.current;
1700    setEmailPreview({ prospect, action, loading: true, error: null, data: null });
1701    try {
1702      const preview = functions.httpsCallable('previewEventsInvitationEmail');
1703      const res = await preview({
1704        eventId: event.id,
1705        prospectId: prospect.id,
1706        isReminder: !!action.isReminder,
1707        senderName: displayNameFrom(user)
1708      });
1709      // A newer preview may have been opened while this one was in flight.
1710      if (seq !== emailPreviewSeq.current) return; // a newer preview has already answered
1711      setEmailPreview((cur) => (cur ? { ...cur, loading: false, data: res.data } : cur));
1712    } catch (err) {
1713      console.error('[AdminEventPage] Email preview failed:', err);
1714      if (seq !== emailPreviewSeq.current) return;
1715      setEmailPreview((cur) => (cur ? { ...cur, loading: false, error: err.message || 'The preview could not be built' } : cur));
1716    }
1717  };
1718
1719  const confirmEmailPreview = async () => {
1720    if (!emailPreview || emailPreview.loading || emailPreview.error) return;
1721    const { prospect, action } = emailPreview;
1722    setEmailPreview(null);
1723    // RE-READ from the live snapshot. The preview deliberately puts a human reading step in
1724    // the middle, so the row captured when it opened can be minutes stale - long enough for
1725    // the same person to register through another route (the sync writes status 'registered').
1726    // NO `|| prospect` fallback. Falling back to the captured copy defeated the re-check for
1727    // the one case where the row is genuinely gone: a colleague deleting the prospect mid-read
1728    // left the stale copy passing every check, the email going out, and the status write then
1729    // throwing NOT_FOUND on a deleted document.
1730    const live = (prospects || []).find(p => p.id === prospect.id);
1731    if (!live) {
1732      setMessage({ type: 'error', text: 'That prospect is no longer on the board, so nothing was sent' });
1733      return;
1734    }
1735    const stillWanted = prospectAction(live);
1736
1737    // RE-CHECK WHETHER TO SEND AT ALL, not just what status to write afterwards.
1738    // statusAfterSend only guards the status WRITE; it does not stop the email. Somebody who
1739    // registered while the dialog was open has no row button at all, yet pressing Send would
1740    // still have posted them "wanted to make sure it had not been missed" minutes after they
1741    // registered, and stamped lastRemindedAt on top.
1742    if (!stillWanted) {
1743      const who = `${live.firstName || ''} ${live.lastName || ''}`.trim() || 'That contact';
1744      setMessage({ type: 'error', text: `${who} has already ${live.status === 'attended' ? 'attended' : 'registered'}, so nothing was sent` });
1745      return;
1746    }
1747    // And if their state changed enough to change WHICH email this is, the staff member read
1748    // a different one from the one that would go out.
1749    if (stillWanted.isReminder !== action.isReminder) {
1750      setMessage({ type: 'error', text: 'They changed while you were reading. Nothing was sent - open it again to see the current email.' });
1751      return;
1752    }
1753    // The PREVIEWED reminder-ness, not the status-derived one. Whether the email reads as a
1754    // reminder is decided by whether an invitation token exists, and that can change between
1755    // preview and confirm - a colleague pressing Copy invite link mints one. The sender
1756    // refuses rather than send an email nobody read.
1757    await sendProspectInvitation(live, {
1758      isReminder: action.isReminder,
1759      previewedAsReminder: !!(emailPreview.data && emailPreview.data.isReminder)
1760    });
1761  };
1762
1763  // Copy an invitation link for this prospect, to send from the staff member's own Outlook.
1764  //
1765  // NOTHING HERE EMAILS ANYBODY. The whole point of the control is that the staff member writes
1766  // the email themselves, which is the platform rule (every customer communication is
1767  // staff-triggered) taken at its word.
1768  //
1769  // The link is minted server-side (createProspectInviteLink): the callable reuses the prospect's
1770  // ORIGINAL invitation rather than minting a second, so the link they may already have keeps
1771  // working and the invited-by attribution does not move to whoever pressed Copy.
1772  const handleCopyInviteLink = async (prospect) => {
1773    if (!prospect?.id) return;
1774    setCopyingInviteLink(prospect.id);
1775    try {
1776      const call = functions.httpsCallable('createProspectInviteLink');
1777      const res = await call({ eventId: event.id, prospectId: prospect.id });
1778      const data = res.data || {};
1779      if (!data.ok || !data.url) throw new Error('No link returned');
1780
1781      // Safari drops the user activation across an await, so a clipboard write AFTER a round trip
1782      // to the server can be refused outright. Try it, and either way show the link in a panel
1783      // with its own Copy button - a press there is a fresh gesture and always works.
1784      let copied = false;
1785      try {
1786        await navigator.clipboard.writeText(data.url);
1787        copied = true;
1788      } catch (err) {
1789        console.warn('[AdminEventPage] Clipboard refused the invite link, showing it instead:', err.message);
1790      }
1791      setInviteLink({ ...data, prospect, copied });
1792    } catch (err) {
1793      console.error('[AdminEventPage] Error creating an invite link:', err);
1794      setMessage({ type: 'error', text: 'Could not create an invitation link: ' + (err.message || 'unknown error') });
1795    } finally {
1796      setCopyingInviteLink(null);
1797    }
1798  };
1799
1800  // Send reminder to an already-invited prospect
1801  const handleRemindProspect = async (prospect) => {
1802    if (!prospect?.email) return;
1803    // Declared outside the try so the CATCH can see it, and null on the path where an
1804    // invitation ALREADY existed - that one was not created by this press and must not be
1805    // deleted by it.
1806    let inviteRef = null;
1807    try {
1808      // Find the existing invitation for this prospect
1809      const invSnap = await db.collection('eventsConfig').doc('invitations').collection('list')
1810        .where('email', '==', prospect.email.toLowerCase().trim())
1811        .where('eventId', '==', event.id)
1812        .limit(1)
1813        .get();
1814
1815      let token;
1816      if (!invSnap.empty) {
1817        token = invSnap.docs[0].data().token;
1818      } else {
1819        // Fallback: create a new invitation token if original not found
1820        token = Array.from(crypto.getRandomValues(new Uint8Array(16)))
1821          .map(b => b.toString(16).padStart(2, '0'))
1822          .join('')
1823          .match(/.{4}/g)
1824          .join('-');
1825
1826        inviteRef = await db.collection('eventsConfig').doc('invitations').collection('list').add({
1827          email: prospect.email.toLowerCase().trim(),
1828          firstName: prospect.firstName || '',
1829          lastName: prospect.lastName || '',
1830          company: prospect.company || '',
1831          eventId: event.id,
1832          eventTitle: event.title,
1833          token,
1834          status: 'pending',
1835          invitedBy: user.uid,
1836          inviterName: displayNameFrom(user),
1837          createdAt: firebase.firestore.FieldValue.serverTimestamp()
1838        });
1839      }
1840
1841      const sendInvitationEmail = functions.httpsCallable('sendEventsInvitationEmail');
1842      await sendInvitationEmail({
1843        email: prospect.email.toLowerCase().trim(),
1844        firstName: prospect.firstName || '',
1845        inviterName: displayNameFrom(user),
1846        invitationToken: token,
1847        event: {
1848          id: event.id,
1849          title: event.title,
1850          eventDate: event.eventDate?.toDate?.()?.toISOString() || event.eventDate,
1851          location: event.location
1852        }
1853      });
1854
1855      await db.collection('events').doc(eventId).collection('prospects').doc(prospect.id).update({
1856        lastRemindedAt: firebase.firestore.FieldValue.serverTimestamp(),
1857        updatedAt: firebase.firestore.FieldValue.serverTimestamp()
1858      });
1859
1860      setMessage({ type: 'success', text: `Reminder sent to ${prospect.firstName} ${prospect.lastName}` });
1861    } catch (err) {
1862      console.error('[AdminEventPage] Error sending reminder:', err);
1863      // A REFUSAL is not a delivery failure: the hourly cap and a suppressed address both mean
1864      // nothing was sent and nothing will be, so the invitation record is undone. Left behind it
1865      // flips the prospect to "Invited by X" - invited-by is a join on the INVITATION, not on the
1866      // prospect's status - and mints a fresh token on every retry. A genuine send failure keeps
1867      // the record, because that invitation may yet be usable.
1868      await undoRefusedInvitation(inviteRef, err);
1869      setMessage({ type: 'error', text: 'Failed to send reminder: ' + err.message });
1870    }
1871  };
1872
1873  // Send invitation
1874  const handleSendInvitation = async (e) => {
1875    e.preventDefault();
1876    if (!inviteEmail.trim()) {
1877      setInviteMessage({ type: 'error', text: 'Please enter an email address' });
1878      return;
1879    }
1880
1881    setSendingInvite(true);
1882    setInviteMessage(null);
1883
1884    // Declared outside the try so the CATCH can see it. A const inside the try is block-scoped,
1885    // so the undo would have thrown a ReferenceError on every refusal - which is now the
1886    // ordinary path. Caught by the scope sweep, not by the suite.
1887    let inviteRef = null;
1888    try {
1889      const token = Array.from(crypto.getRandomValues(new Uint8Array(16)))
1890        .map(b => b.toString(16).padStart(2, '0'))
1891        .join('')
1892        .match(/.{4}/g)
1893        .join('-');
1894
1895      inviteRef = await db.collection('eventsConfig').doc('invitations').collection('list').add({
1896        email: inviteEmail.toLowerCase().trim(),
1897        firstName: inviteFirstName.trim(),
1898        lastName: inviteLastName.trim(),
1899        company: inviteCompany.trim(),
1900        eventId: event.id,
1901        eventTitle: event.title,
1902        token,
1903        status: 'pending',
1904        invitedBy: user.uid,
1905        inviterName: displayNameFrom(user),
1906        createdAt: firebase.firestore.FieldValue.serverTimestamp()
1907      });
1908
1909      const sendInvitationEmail = functions.httpsCallable('sendEventsInvitationEmail');
1910      await sendInvitationEmail({
1911        email: inviteEmail.toLowerCase().trim(),
1912        firstName: inviteFirstName.trim(),
1913        inviterName: displayNameFrom(user),
1914        invitationToken: token,
1915        event: {
1916          id: event.id,
1917          title: event.title,
1918          eventDate: event.eventDate?.toDate?.()?.toISOString() || event.eventDate,
1919          location: event.location
1920        }
1921      });
1922
1923      setRecentInvites(prev => [{
1924        email: inviteEmail.toLowerCase().trim(),
1925        firstName: inviteFirstName.trim(),
1926        lastName: inviteLastName.trim(),
1927        sentAt: new Date()
1928      }, ...prev.slice(0, 4)]);
1929
1930      // Sync prospect status if this email is a prospect
1931      const invitedEmail = inviteEmail.toLowerCase().trim();
1932      const matchingProspect = prospects.find(p => (p.email || '').toLowerCase() === invitedEmail);
1933      if (matchingProspect && !['registered', 'attended'].includes(matchingProspect.status)) {
1934        await db.collection('events').doc(eventId).collection('prospects').doc(matchingProspect.id).update({
1935          status: 'invited',
1936          updatedAt: firebase.firestore.FieldValue.serverTimestamp()
1937        });
1938      }
1939
1940      setInviteEmail('');
1941      setInviteFirstName('');
1942      setInviteLastName('');
1943      setInviteCompany('');
1944      setInviteMessage({ type: 'success', text: `Invitation sent to ${inviteEmail}` });
1945    } catch (err) {
1946      console.error('[AdminEventPage] Error sending invitation:', err);
1947      // A REFUSAL is not a delivery failure: the hourly cap and a suppressed address both mean
1948      // nothing was sent and nothing will be, so the invitation record is undone. Left behind it
1949      // flips the prospect to "Invited by X" - invited-by is a join on the INVITATION, not on the
1950      // prospect's status - and mints a fresh token on every retry. A genuine send failure keeps
1951      // the record, because that invitation may yet be usable.
1952      await undoRefusedInvitation(inviteRef, err);
1953      setInviteMessage({ type: 'error', text: 'Failed to send invitation: ' + err.message });
1954    } finally {
1955      setSendingInvite(false);
1956    }
1957  };
1958
1959  // Approve pending registration
1960  const handleApprovePending = async (pending) => {
1961    if (!pending?.id || !pending?.userId) return;
1962    setApprovingId(pending.id);
1963
1964    try {
1965      const sendApproval = functions.httpsCallable('sendRegistrationApprovalEmail');
1966      const res = await sendApproval({
1967        eventId: event.id,
1968        userId: pending.userId,
1969        pendingRegistrationId: pending.id
1970      });
1971      // WHICH SEAT, not just "approved". The server runs the shared capacity decision now, so a
1972      // full event puts them on the waitlist - and saying "approved!" would have the approver
1973      // believe they had seated somebody who is queued behind everyone already waiting.
1974      const seat = (res && res.data) || {};
1975      const who = `${pending.firstName} ${pending.lastName}`;
1976      setMessage({ type: 'success', text: seat.status === 'waitlisted'
1977        ? `${who} is approved, but this event is full - they are on the waitlist at position ${seat.waitlistPosition || '?'}.`
1978        : `${who} has been approved!` });
1979    } catch (err) {
1980      console.error('[AdminEventPage] Approval error:', err);
1981      setMessage({ type: 'error', text: 'Failed to approve: ' + err.message });
1982    } finally {
1983      setApprovingId(null);
1984    }
1985  };
1986
1987  // Reject pending registration
1988  const handleRejectPending = async (pending) => {
1989    if (!pending?.id) return;
1990    setRejectingId(pending.id);
1991
1992    try {
1993      await firebase.firestore()
1994        .collection('events').doc(event.id)
1995        .collection('pendingRegistrations').doc(pending.id)
1996        .update({
1997          status: 'rejected',
1998          rejectedAt: firebase.firestore.FieldValue.serverTimestamp(),
1999          rejectedBy: user?.uid
2000        });
2001
2002      if (pending.userId) {
2003        await firebase.firestore()
2004          .collection('eventsUsers').doc(pending.userId)
2005          .set({
2006            status: 'rejected',
2007            rejectedAt: firebase.firestore.FieldValue.serverTimestamp(),
2008            rejectedBy: user?.uid
2009          }, { merge: true });
2010      }
2011
2012      setMessage({ type: 'success', text: `${pending.firstName} ${pending.lastName} has been rejected.` });
2013    } catch (err) {
2014      console.error('[AdminEventPage] Rejection error:', err);
2015      setMessage({ type: 'error', text: 'Failed to reject: ' + err.message });
2016    } finally {
2017      setRejectingId(null);
2018    }
2019  };
2020
2021  // Toggle check-in status (check-in or undo)
2022  const toggleCheckIn = async (registration) => {
2023    setCheckingInId(registration.id);
2024    try {
2025      const isAttended = registration.status === 'attended';
2026      // THE THIRD CHECK-IN WRITER, and the one a grep for `status: 'attended'` cannot see because
2027      // it is a ternary. It carried both halves of the bug the rest of this branch fixed: it
2028      // stamped no `statusBeforeCheckIn` on the way in, and hardcoded `registered` on the way
2029      // out, demoting a confirmed attendee. This is the MAIN staff registrations table.
2030      await db.collection('events').doc(eventId)
2031        .collection('registrations').doc(registration.id)
2032        .update({
2033          status: isAttended
2034            ? (VALID_RESTORE_STATUSES.includes(registration.statusBeforeCheckIn)
2035                ? registration.statusBeforeCheckIn
2036                : 'registered')
2037            : 'attended',
2038          ...(isAttended ? {} : { statusBeforeCheckIn: registration.status }),
2039          attendedAt: isAttended ? null : firebase.firestore.FieldValue.serverTimestamp(),
2040          checkedInBy: isAttended ? null : user?.uid
2041        });
2042      setMessage({
2043        type: 'success',
2044        text: isAttended
2045          ? `${registration.userName || registration.userEmail} check-in undone`
2046          : `${registration.userName || registration.userEmail} checked in!`
2047      });
2048      setTimeout(() => setMessage(null), 2000);
2049
2050      // Post-check-in: update prospect status + send feedback request (non-blocking)
2051      if (!isAttended) {
2052        functions.httpsCallable('processCheckIn')({
2053          eventId,
2054          registrationId: registration.id,
2055          email: registration.userEmail || registration.email,
2056          userName: registration.userName
2057        }).catch(err => console.warn('[AdminEventPage] processCheckIn failed (non-critical):', err));
2058      }
2059    } catch (err) {
2060      console.error('[AdminEventPage] Error toggling check-in:', err);
2061      setMessage({ type: 'error', text: 'Failed to update check-in status' });
2062    } finally {
2063      setCheckingInId(null);
2064    }
2065  };
2066
2067  // Request feedback from an attended user
2068  const handleRequestFeedback = async (registration) => {
2069    if (requestingFeedback) return;
2070
2071    setRequestingFeedback(registration.id);
2072
2073    try {
2074      // Generate unique feedback token
2075      const feedbackToken = generateToken() + '-' + generateToken();
2076
2077      // Update registration with feedback token and status
2078      await db.collection('events').doc(eventId)
2079        .collection('registrations').doc(registration.id)
2080        .update({
2081          feedbackToken: feedbackToken,
2082          feedbackRequested: true,
2083          feedbackRequestedAt: firebase.firestore.FieldValue.serverTimestamp()
2084        });
2085
2086      // Send feedback request email via Cloud Function
2087      const sendFeedbackEmail = functions.httpsCallable('sendEventFeedbackRequestEmail');
2088      await sendFeedbackEmail({
2089        email: registration.userEmail,
2090        firstName: registration.userName?.split(' ')[0] || registration.userEmail,
2091        eventId: eventId,
2092        eventTitle: event.title,
2093        eventDate: event.eventDate?.toDate?.()?.toISOString() || event.eventDate,
2094        feedbackToken: feedbackToken
2095      });
2096
2097      setMessage({ type: 'success', text: `Feedback request sent to ${registration.userName || registration.userEmail}` });
2098      setTimeout(() => setMessage(null), 3000);
2099    } catch (error) {
2100      console.error('[AdminEventPage] Error requesting feedback:', error);
2101      setMessage({ type: 'error', text: 'Failed to send feedback request: ' + error.message });
2102    }
2103
2104    setRequestingFeedback(null);
2105  };
2106
2107  // Delete feedback entry
2108  const deleteFeedback = async (feedbackId) => {
2109    if (!window.confirm('Delete this feedback entry?')) return;
2110
2111    try {
2112      await db.collection('events').doc(eventId)
2113        .collection('feedback').doc(feedbackId)
2114        .delete();
2115      setMessage({ type: 'success', text: 'Feedback deleted' });
2116      setTimeout(() => setMessage(null), 2000);
2117      if (expandedFeedbackId === feedbackId) setExpandedFeedbackId(null);
2118    } catch (err) {
2119      console.error('[AdminEventPage] Error deleting feedback:', err);
2120      setMessage({ type: 'error', text: 'Failed to delete feedback' });
2121    }
2122  };
2123
2124  // Update registration badge number
2125  const updateBadgeNumber = async (registration, newNumber) => {
2126    const num = parseInt(newNumber, 10);
2127    if (isNaN(num) || num < 1) {
2128      setMessage({ type: 'error', text: 'Badge number must be a positive number' });
2129      return;
2130    }
2131
2132    setSavingBadgeNumber(registration.id);
2133    try {
2134      await db.collection('events').doc(eventId)
2135        .collection('registrations').doc(registration.id)
2136        .update({
2137          badgeNumber: num,
2138          badgeNumberUpdatedAt: firebase.firestore.FieldValue.serverTimestamp(),
2139          badgeNumberUpdatedBy: user?.email
2140        });
2141      setEditingBadgeNumber(null);
2142      setBadgeNumberInput('');
2143    } catch (err) {
2144      console.error('[AdminEventPage] Error updating badge number:', err);
2145      setMessage({ type: 'error', text: 'Failed to update badge number' });
2146    } finally {
2147      setSavingBadgeNumber(null);
2148    }
2149  };
2150
2151  // Toggle printed badge status
2152  const togglePrintedBadge = async (registration) => {
2153    try {
2154      const newValue = !registration.badgePrinted;
2155      await db.collection('events').doc(eventId)
2156        .collection('registrations').doc(registration.id)
2157        .update({
2158          badgePrinted: newValue,
2159          badgePrintedAt: newValue ? firebase.firestore.FieldValue.serverTimestamp() : null,
2160          badgePrintedBy: newValue ? user?.email : null
2161        });
2162    } catch (err) {
2163      console.error('[AdminEventPage] Error toggling badge printed:', err);
2164      setMessage({ type: 'error', text: 'Failed to update badge printed status' });
2165    }
2166  };
2167
2168  // Toggle tested status
2169  const toggleTested = async (registration) => {
2170    try {
2171      const newValue = !registration.tested;
2172      await db.collection('events').doc(eventId)
2173        .collection('registrations').doc(registration.id)
2174        .update({
2175          tested: newValue,
2176          testedAt: newValue ? firebase.firestore.FieldValue.serverTimestamp() : null,
2177          testedBy: newValue ? user?.email : null
2178        });
2179    } catch (err) {
2180      console.error('[AdminEventPage] Error toggling tested:', err);
2181      setMessage({ type: 'error', text: 'Failed to update tested status' });
2182    }
2183  };
2184
2185  const deleteRegistration = async (registration) => {
2186    if (!confirm(`Delete registration for ${registration.userName || registration.userEmail}?`)) return;
2187    try {
2188      // Delete the registration document
2189      await db.collection('events').doc(eventId).collection('registrations').doc(registration.id).delete();
2190
2191      // Decrement the registration count on the event
2192      await db.collection('events').doc(eventId).update({
2193        registrationCount: firebase.firestore.FieldValue.increment(-1)
2194      });
2195
2196      setMessage({ type: 'success', text: 'Registration deleted' });
2197      setTimeout(() => setMessage(null), 2000);
2198    } catch (err) {
2199      console.error('[AdminEventPage] Error deleting registration:', err);
2200      setMessage({ type: 'error', text: 'Failed to delete registration' });
2201    }
2202  };
2203
2204  // THE SECOND WAY A SEAT BECOMES CONFIRMED (owner decision 2026-09-19): a rep marks it, having
2205  // spoken to the person. The first is the email reply, which confirmEventAttendance stamps the
2206  // same way. `confirmedBy` distinguishes the two in the tooltip.
2207  //
2208  // THE STATUS IS NOT TOUCHED. A first cut raised it to 'confirmed' as "belt and braces", and
2209  // review showed that to be destructive: status records WHO ACTED, so raising a staff-added
2210  // seat rewrote it as one the member took themselves, and clearing the confirmation afterwards
2211  // left "Registered" above a note reading "Manually added by X" with no control able to put it
2212  // back. The reply fields are the signal and every reader of Confirmed looks at them.
2213  const markConfirmed = async (registration) => {
2214    setResettingRegistration(registration.id);
2215    try {
2216      await db.collection('events').doc(eventId).collection('registrations').doc(registration.id).update({
2217        confirmedAt: firebase.firestore.FieldValue.serverTimestamp(),
2218        // Never empty: the tooltip reads an absent confirmedBy as "by email reply".
2219        confirmedBy: user?.email || user?.uid || 'a staff member',
2220        confirmationResponse: 'confirmed'
2221      });
2222      setMessage({ type: 'success', text: `${registration.userName || 'Registration'} marked as confirmed` });
2223      setTimeout(() => setMessage(null), 2000);
2224    } catch (err) {
2225      console.error('[AdminEventPage] Error marking confirmed:', err);
2226      setMessage({ type: 'error', text: 'Failed to mark as confirmed' });
2227    } finally {
2228      setResettingRegistration(null);
2229    }
2230  };
2231
2232  // Undo a confirmation. STATUS IS NOT TOUCHED: the old "Reset to Registered" wrote
2233  // status 'registered', which under the 2026-09-18 rule means "
2233staff added them" - so clearing
2234  // a self-registered person's confirmation would have silently recorded that they never
2235  // registered. Only the confirmation fields go, and reminderSentAt so they can be asked again.
2236  const clearConfirmation = async (registration) => {
2237    if (!confirm(`Clear ${registration.userName || registration.userEmail}'s confirmation? They will read as not yet confirmed and can be sent the reminder again.`)) return;
2238    setResettingRegistration(registration.id);
2239    try {
2240      await db.collection('events').doc(eventId).collection('registrations').doc(registration.id).update({
2241        confirmedAt: null,
2242        confirmedBy: null,
2243        confirmationResponse: null,
2244        reminderSentAt: null
2245      });
2246      setMessage({ type: 'success', text: `${registration.userName || 'Registration'} confirmation cleared` });
2247      setTimeout(() => setMessage(null), 2000);
2248    } catch (err) {
2249      console.error('[AdminEventPage] Error clearing confirmation:', err);
2250      setMessage({ type: 'error', text: 'Failed to clear confirmation' });
2251    } finally {
2252      setResettingRegistration(null);
2253    }
2254  };
2255
2256  // Revive a DECLINED seat. Kept for that case alone - it writes status 'registered', which is
2257  // right here: staff are putting them back on the list, so they read as added by staff.
2258  const resetToRegistered = async (registration) => {
2259    if (!confirm(`Put ${registration.userName || registration.userEmail} back on the list? They will read as added by staff until they confirm.`)) return;
2260
2261    setResettingRegistration(registration.id);
2262    try {
2263      await db.collection('events').doc(eventId).collection('registrations').doc(registration.id).update({
2264        status: 'registered',
2265        confirmedAt: null,
2266        confirmedBy: null,
2267        confirmationResponse: null,
2268        reminderSentAt: null
2269      });
2270
2271      setMessage({ type: 'success', text: `${registration.userName || 'Registration'} put back on the list` });
2272      setTimeout(() => setMessage(null), 2000);
2273    } catch (err) {
2274      console.error('[AdminEventPage] Error resetting registration:', err);
2275      setMessage({ type: 'error', text: 'Failed to reset registration' });
2276    } finally {
2277      setResettingRegistration(null);
2278    }
2279  };
2280
2281  // Mark registration as "can no longer attend" (soft cancel)
2282  const markCannotAttend = async (registration) => {
2283    if (!confirm(`Mark ${registration.userName || registration.userEmail} as "Can no longer attend"? This will cancel their registration.`)) return;
2284
2285    setCancellingRegistration(registration.id);
2286    try {
2287      const eventRef = db.collection('events').doc(eventId);
2288      const registrationsRef = eventRef.collection('registrations');
2289
2290      const wasRegistered = ['registered', 'confirmed', 'attended'].includes(registration.status);
2291
2292      // Update registration status to cancelled
2293      await registrationsRef.doc(registration.id).update({
2294        status: 'cancelled',
2295        cancelledAt: firebase.firestore.FieldValue.serverTimestamp(),
2296        cancelledBy: user?.email || 'admin',
2297        cancellationReason: 'can_no_longer_attend',
2298        // The reply dies with the seat (mirrors cancelEventRegistration server-side), or a
2299        // revival reads Confirmed on day one.
2300        confirmedAt: null,
2301        confirmedBy: null,
2302        confirmationResponse: null
2303      });
2304
2305      // Decrement registration count
2306      await eventRef.update({
2307        registrationCount: firebase.firestore.FieldValue.increment(-1)
2308      });
2309
2310      // If user was registered (not waitlisted), promote first waitlisted person
2311      if (wasRegistered) {
2312        const waitlisted = await registrationsRef
2313          .where('status', '==', 'waitlisted')
2314          .orderBy('registeredAt', 'asc')
2315          .limit(1)
2316          .get();
2317
2318        if (!waitlisted.empty) {
2319          const firstWaitlisted = waitlisted.docs[0];
2320          await firstWaitlisted.ref.update({
2321            // Mirrors cancelEventRegistration's promotion server-side: a waitlisted seat can
2322            // only have come from the member's own act, so promoting it restores `confirmed`.
2323            status: 'confirmed',
2324            waitlistPosition: null,
2325            promotedAt: firebase.firestore.FieldValue.serverTimestamp()
2326          });
2327          // Increment back since we're promoting someone
2328          await eventRef.update({
2329            registrationCount: firebase.firestore.FieldValue.increment(1)
2330          });
2331        }
2332      }
2333
2334      // Also update quick invite status to 'declined' if they were invited via quick invite
2335      try {
2336        const inviteQuery = await db.collection('eventsConfig').doc('invitations').collection('list')
2337          .where('email', '==', registration.userEmail?.toLowerCase())
2338          .where('eventId', '==', eventId)
2339          .limit(1)
2340          .get();
2341
2342        if (!inviteQuery.empty) {
2343          await inviteQuery.docs[0].ref.update({
2344            status: 'declined',
2345            declinedAt: firebase.firestore.FieldValue.serverTimestamp()
2346          });
2347        }
2348      } catch (inviteErr) {
2349        console.warn('[AdminEventPage] Could not update invitation status:', inviteErr);
2350      }
2351
2352      setMessage({ type: 'success', text: `${registration.userName || 'Registration'} marked as can no longer attend` });
2353      setTimeout(() => setMessage(null), 3000);
2354    } catch (err) {
2355      console.error('[AdminEventPage] Error cancelling registration:', err);
2356      setMessage({ type: 'error', text: 'Failed to cancel registration' });
2357    } finally {
2358      setCancellingRegistration(null);
2359    }
2360  };
2361
2362  // Resend confirmation email to single registration
2363  const resendConfirmationEmail = async (registration) => {
2364    setResendingConfirmation(registration.id);
2365    try {
2366      const functions = firebase.app().functions('europe-west2');
2367      const sendConfirmation = functions.httpsCallable('sendRegistrationConfirmationEmail');
2368      const res = await sendConfirmation({
2369        eventId,
2370        email: registration.userEmail,
2371        firstName: registration.userName?.split(' ')[0] || ''
2372      });
2373      // A SKIP IS NOT A SUCCESS. The callable answers { skipped: true, reason } rather than
2374      // throwing, so awaiting it and toasting success reported an email that was never sent.
2375      const verdict = classifySendResult(res);
2376      if (verdict.outcome === 'skipped') {
2377        setMessage({ type: 'error', text: `Not sent to ${registration.userEmail}: ${verdict.reason}` });
2378        setTimeout(() => setMessage(null), 6000);
2379      } else {
2380        setMessage({ type: 'success', text: `Confirmation email sent to ${registration.userEmail}` });
2381        setTimeout(() => setMessage(null), 3000);
2382      }
2383    } catch (err) {
2384      console.error('[AdminEventPage] Error resending confirmation:', err);
2385      setMessage({ type: 'error', text: 'Failed to send confirmation email' });
2386    } finally {
2387      setResendingConfirmation(null);
2388    }
2389  };
2390
2391  // Send attendance reminder email to single registration (with confirm/decline buttons)
2392  const sendAttendanceReminder = async (registration) => {
2393    setSendingAttendanceReminder(registration.id);
2394
2395    try {
2396      // Generate confirmation token if not present
2397      let confirmationToken = registration.confirmationToken;
2398      if (!confirmationToken) {
2399        confirmationToken = generateToken();
2400        // Save the token to the registration
2401        await db.collection('events').doc(eventId)
2402          .collection('registrations').doc(registration.id)
2403          .update({ confirmationToken });
2404      }
2405
2406      const functions = firebase.app().functions('europe-west2');
2407      const sendReminder = functions.httpsCallable('sendAttendanceReminderEmail');
2408
2409      const res = await sendReminder({
2410        eventId,
2411        registrationId: registration.id,
2412        email: registration.userEmail,
2413        firstName: registration.userName?.split(' ')[0] || '',
2414        confirmationToken
2415      });
2416
2417      const verdict = classifySendResult(res);
2418      if (verdict.outcome === 'skipped') {
2419        setMessage({ type: 'error', text: `Not sent to ${registration.userEmail}: ${verdict.reason}` });
2420        setTimeout(() => setMessage(null), 6000);
2421      } else {
2422        setMessage({ type: 'success', text: `Reminder sent to ${registration.userName || registration.userEmail}` });
2423        setTimeout(() => setMessage(null), 3000);
2424      }
2425    } catch (err) {
2426      console.error('[AdminEventPage] Failed to send reminder:', err);
2427      setMessage({ type: 'error', text: 'Failed to send reminder email' });
2428      setTimeout(() => setMessage(null), 3000);
2429    } finally {
2430      setSendingAttendanceReminder(null);
2431    }
2432  };
2433
2434  // BULK SEND over the ticked rows. Same callable per person as the row menu - never a new server
2435  // path - so one person and twenty-five go through identical gates, and a bulk send can never
2436  // reach somebody a single send would refuse.
2437  //
2438  // SEQUENTIAL, not Promise.all. Twenty-five parallel Postmark calls is a burst against a domain
2439  // whose bounce rate is already the reason the hourly invitation limit exists, and a parallel run
2440  // cannot report honest progress. The cost is that it takes a few seconds; the button says so.
2441  const runBulkSend = async (kindId, rows) => {
2442    const counts = { sent: 0, skipped: 0, failed: 0 };
2443    const problems = [];
2444    setBulkProgress({ current: 0, total: rows.length });
2445    const functions = firebase.app().functions('europe-west2');
2446    const confirmationSender = functions.httpsCallable('sendRegistrationConfirmationEmail');
2447    const reminderSender = functions.httpsCallable('sendAttendanceReminderEmail');
2448
2449    // The per-iteration try/catch keeps one failure from abandoning the rest. This outer finally
2450    // covers the narrower case it cannot: anything throwing OUTSIDE that catch would otherwise
2451    // leave the row it was on spinning for ever, with no way back but a reload.
2452    try {
2453    for (let i = 0; i < rows.length; i++) {
2454      const reg = rows[i];
2455      const who = reg.userName || reg.userEmail;
2456      setBulkSendingId(reg.id);
2457      try {
2458        let res;
2459        if (kindId === 'reminder') {
2460          // The token is what the Yes / can't-make-it links carry, and a seat written by a staff
2461          // add path has none - it is minted at creation and never backfilled onto an older row.
2462          // Measured 2026-09-21 on the Ventilation event: 7 of 25 live seats had no token.
2463          let confirmationToken = reg.confirmationToken;
2464          if (!confirmationToken) {
2465            confirmationToken = generateToken();
2466            await db.collection('events').doc(eventId)
2467              .collection('registrations').doc(reg.id)
2468              .update({ confirmationToken });
2469          }
2470          res = await reminderSender({
2471            eventId,
2472            registrationId: reg.id,
2473            email: reg.userEmail,
2474            firstName: reg.userName?.split(' ')[0] || '',
2475            confirmationToken
2476          });
2477        } else {
2478          res = await confirmationSender({
2479            eventId,
2480            email: reg.userEmail,
2481            firstName: reg.userName?.split(' ')[0] || ''
2482          });
2483        }
2484        const verdict = classifySendResult(res);
2485        if (verdict.outcome === 'skipped') {
2486          counts.skipped++;
2487          problems.push(`${who}: ${verdict.reason}`);
2488        } else {
2489          counts.sent++;
2490        }
2491      } catch (err) {
2492        // One refusal must not abandon the rest of the list. A suppressed address throws
2493        // failed-precondition by design, and that is the commonest case here.
2494        counts.failed++;
2495        problems.push(`${who}: ${err?.message || 'failed'}`);
2496        console.warn('[AdminEventPage] Bulk send failed for', reg.userEmail, err);
2497      }
2498      setBulkProgress({ current: i + 1, total: rows.length });
2499    }
2500    } finally {
2501      setBulkSendingId(null);
2502    }
2503
2504    setBulkProgress(null);
2505    setBulkSend(null);
2506    // Problems are LISTED, not counted away: "3 not sent" with no names leaves a staff member with
2507    // no idea who still needs chasing by hand.
2508    setMessage({
2509      type: counts.failed || counts.skipped ? 'error' : 'success',
2510      text: `${summariseBulkSend(counts)}${problems.length ? ' — ' + problems.slice(0, 5).join('; ') + (problems.length > 5 ? `; and ${problems.length - 5} more` : '') : ''}`
2511    });
2512    setTimeout(() => setMessage(null), problems.length ? 15000 : 4000);
2513  };
2514
2515  // Export all badges as PDF (80x50mm landscape per page)
2516  // filterType: 'all' | 'confirmed'
2517  const exportAllBadgesPDF = async (filterType = 'all') => {
2518    let validRegistrations;
2519    // "Confirmed Only" prints the people the Confirmed chip counts, from the same rule. It used
2520    // to filter on status 'confirmed', which since 2026-09-18 is every self-registered person -
2521    // so a rep seeing "Confirmed (5)" and choosing this would have got dozens of badges.
2522    if (filterType === 'confirmed') {
2523      validRegistrations = registrations.filter(r => seatLabel(r).kind === 'confirmedByReply');
2524    } else {
2525      validRegistrations = registrations.filter(r => ['registered', 'confirmed', 'attended'].includes(r.status));
2526    }
2527
2528    if (validRegistrations.length === 0) {
2529      setMessage({ type: 'error', text: filterType === 'confirmed' ? 'No confirmed registrations to export' : 'No registrations to export' });
2530      return;
2531    }
2532
2533    // Busy FIRST, then fetch the library. The PDF library used to be preloaded, so this click
2534    // was instant; now the first one of a session downloads 116KB. With the flag set after
2535    // that await the button sat in its idle state for the whole download, and a staff member
2536    // on a slow connection would click again and run two full export passes concurrently.
2537    setExportingBadges(true);
2538    setBadgeExportProgress({ current: 0, total: validRegistrations.length });
2539
2540    try {
2541      await loadPdfWithQr();
2542    } catch (err) {
2543      setMessage({ type: 'error', text: err.message });
2544      setExportingBadges(false);
2545      setBadgeExportProgress({ current: 0, total: 0 });
2546      return;
2547    }
2548
2549    try {
2550      const { jsPDF } = window.jspdf;
2551      // Landscape: 80mm wide x 50mm tall
2552      const doc = new jsPDF({ orientation: 'landscape', unit: 'mm', format: [50, 80] });
2553
2554      const pageWidth = 80;
2555      const pageHeight = 50;
2556      const margin = 4;
2557      // Black and white only for laser printing
2558      const black = [0, 0, 0];
2559      const darkGray = [60, 60, 60];
2560      const lightGray = [120, 120, 120];
2561      const qrSize = 20; // Medium QR code
2562
2563      for (let i = 0; i < validRegistrations.length; i++) {
2564        const reg = validRegistrations[i];
2565        setBadgeExportProgress({ current: i + 1, total: validRegistrations.length });
2566
2567        if (i > 0) {
2568          doc.addPage([50, 80], 'landscape');
2569        }
2570
2571        // Get or generate member QR code
2572        let memberQrCode = null;
2573        if (reg.userId) {
2574          memberQrCode = await ensureMemberQrCode(reg.userId);
2575        }
2576
2577        // Generate QR code data URL
2578        let qrDataUrl = null;
2579        if (memberQrCode) {
2580          const qrUrl = `https://events.trovex.com/member/${memberQrCode}`;
2581          qrDataUrl = await generateQrCodeDataUrl(qrUrl, 200);
2582        } else {
2583          // Fallback: use registration token
2584          const fallbackUrl = `https://events.trovex.com/checkin/${eventId}/${reg.qrToken || 'unknown'}`;
2585          qrDataUrl = await generateQrCodeDataUrl(fallbackUrl, 200);
2586        }
2587
2588        // Left side content (name, position, company)
2589        const textAreaWidth = pageWidth - qrSize - margin * 3;
2590        let yPos = margin + 2;
2591
2592        // Name (bold, larger)
2593        doc.setTextColor(...black);
2594        doc.setFontSize(12);
2595        doc.setFont('helvetica', 'bold');
2596        const fullName = reg.userName || reg.userEmail || 'Unknown';
2597        const nameLines = doc.splitTextToSize(fullName, textAreaWidth);
2598        doc.text(nameLines.slice(0, 2), margin, yPos + 4);
2599        yPos += nameLines.slice(0, 2).length * 5 + 2;
2600
2601        // Position/Job Title
2602        doc.setFontSize(9);
2603        doc.setFont('helvetica', 'normal');
2604        doc.setTextColor(...darkGray);
2605        const position = reg.jobTitle || reg.position || '';
2606        if (position) {
2607          const posLines = doc.splitTextToSize(position, textAreaWidth);
2608          doc.text(posLines.slice(0, 1), margin, yPos + 3);
2609          yPos += 5;
2610        }
2611
2612        // Company/Organisation
2613        doc.setFontSize(9);
2614        doc.setTextColor(...darkGray);
2615        const company = reg.organisation || reg.company || '';
2616        if (company) {
2617          const companyLines = doc.splitTextToSize(company, textAreaWidth);
2618          doc.text(companyLines.slice(0, 2), margin, yPos + 3);
2619        }
2620
2621        // Event name (bottom left, smaller)
2622        doc.setFontSize(7);
2623        doc.setTextColor(...lightGray);
2624        const eventTitle = event.title || 'Event';
2625        const eventLines = doc.splitTextToSize(eventTitle, textAreaWidth);
2626        doc.text(eventLines.slice(0, 2), margin, pageHeight - margin - 2);
2627
2628        // QR Code (bottom right)
2629        if (qrDataUrl) {
2630          doc.addImage(qrDataUrl, 'PNG', pageWidth - qrSize - margin, pageHeight - qrSize - margin, qrSize, qrSize);
2631        }
2632      }
2633
2634      const filenameSuffix = filterType === 'confirmed' ? '-confirmed-badges.pdf' : '-badges.pdf';
2635      doc.save(`${event.title.replace(/[^a-z0-9]/gi, '-').toLowerCase()}${filenameSuffix}`);
2636      setMessage({ type: 'success', text: `Exported ${validRegistrations.length} ${filterType === 'confirmed' ? 'confirmed ' : ''}badges` });
2637      setTimeout(() => setMessage(null), 3000);
2638    } catch (err) {
2639      console.error('[AdminEventPage] Error exporting badges:', err);
2640      setMessage({ type: 'error', text: 'Failed to export badges' });
2641    } finally {
2642      setExportingBadges(false);
2643      setBadgeExportProgress({ current: 0, total: 0 });
2644    }
2645  };
2646
2647  // Export badges 6-per-A4-sheet (for cutting). Keeps the SAME 80x50mm badge dimensions as the
2648  // one-per-page export above; just lays them out 2 columns x 3 rows on A4 portrait with light
2649  // cut guides. Reuses the identical per-badge layout (name / position / company / event / QR).
2650  // Quick-add a single last-minute attendee as a registration (no email), and auto-tick them so the
2651  // "Print selected" badge run picks up the people you just added. Admin-only (registration writes
2652  // need isEventsAdmin); reuses the same write as the bulk import.
2653  const handleQuickAddAttendee = async () => {
2654    const email = (qa.email || '').trim().toLowerCase();
2655    const firstName = (qa.firstName || '').trim();
2656    if (!firstName) { setMessage({ type: 'error', text: 'First name is required' }); return; }
2657    if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) { setMessage({ type: 'error', text: 'A val
2657id email is required' }); return; }
2658    if (registrations.some(r => (r.userEmail || '').toLowerCase() === email)) {
2659      setMessage({ type: 'info', text: `${email} is already registered` });
2660      return;
2661    }
2662    setQuickAdding(true);
2663    try {
2664      // Reusable address-book contact (create-only, deduped) — non-fatal if it fails.
2665      try { await createManualContact({ firstName, lastName: qa.lastName, company: qa.company, email }); } catch (e) { /* ignore */ }
2666      const ref = await db.collection('events').doc(eventId).collection('registrations').add({
2667        userId: '',
2668        userEmail: email,
2669        userName: `${firstName} ${qa.lastName || ''}`.trim() || email,
2670        eventId,
2671        status: 'registered',
2672        registeredAt: firebase.firestore.FieldValue.serverTimestamp(),
2673        qrToken: randomToken(),
2674        organisation: (qa.company || '').trim(),
2675        jobTitle: '',
2676        phone: '',
2677        registeredVia: 'admin-quickadd',
2678        registeredBy: user?.email || ''
2679      });
2680      await db.collection('events').doc(eventId).update({ registrationCount: firebase.firestore.FieldValue.increment(1) });
2681      setSelectedRegIds(prev => { const n = new Set(prev); n.add(ref.id); return n; });
2682      setQa({ firstName: '', lastName: '', company: '', email: '' });
2683      setMessage({ type: 'success', text: `Added ${firstName} — ticked for printing` });
2684      setTimeout(() => setMessage(null), 2500);
2685    } catch (err) {
2686      console.error('[AdminEventPage] quick add failed', err);
2687      setMessage({ type: 'error', text: 'Failed to add attendee: ' + (err && err.message) });
2688    } finally {
2689      setQuickAdding(false);
2690    }
2691  };
2692
2693  // Registrations currently ticked for printing (in display order, persists across search filter).
2694  const selectedRegistrations = registrations.filter(r => selectedRegIds.has(r.id));
2695  const toggleRegSelected = (id) => setSelectedRegIds(prev => {
2696    const n = new Set(prev); if (n.has(id)) n.delete(id); else n.add(id); return n;
2697  });
2698
2699  // Export badges up to 6-per-A4-sheet (for cutting). Keeps the SAME 80x50mm badge dimensions as
2700  // the one-per-page export; lays them out on A4 portrait with light cut guides. A partial page is
2701  // CENTRED and balanced (4 -> a centred 2x2, 2 -> a centred row, 3 -> 2 over a centred 1), so any
2702  // count looks tidy. Pass an explicit `regsArg` (e.g. the selected rows) or omit to use filterType.
2703  const exportBadgesA4 = async (regsArg = null, filterType = 'all') => {
2704    const list = regsArg && regsArg.length
2705      ? regsArg
2706      : (filterType === 'confirmed'
2707          ? registrations.filter(r => seatLabel(r).kind === 'confirmedByReply')
2708          : registrations.filter(r => ['registered', 'confirmed', 'attended'].includes(r.status)));
2709
2710    if (list.length === 0) {
2711      setMessage({ type: 'error', text: 'No badges to export' });
2712      return;
2713    }
2714
2715    // Busy before the download, for the double-click reason in exportAllBadgesPDF.
2716    setExportingBadges(true);
2717    setBadgeExportProgress({ current: 0, total: list.length });
2718
2719    try {
2720      await loadPdfWithQr();
2721    } catch (err) {
2722      setMessage({ type: 'error', text: err.message });
2723      setExportingBadges(false);
2724      setBadgeExportProgress({ current: 0, total: 0 });
2725      return;
2726    }
2727
2728    try {
2729      const { jsPDF } = window.jspdf;
2730      const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
2731
2732      // Same badge dimensions as the single-badge export.
2733      const BW = 80, BH = 50, margin = 4, qrSize = 20;
2734      const PER_PAGE = 6, colGap = 14, rowGap = 20;
2735      const black = [0, 0, 0], darkGray = [60, 60, 60], lightGray = [120, 120, 120];
2736
2737      // Centred positions for `k` badges (1..6) on one A4 page: up to 2 per row, each row centred
2738      // horizontally, the whole block centred vertically.
2739      const pagePositions = (k) => {
2740        const rows = Math.ceil(k / 2);
2741        const gridH = rows * BH + (rows - 1) * rowGap;
2742        const startY = (297 - gridH) / 2;
2743        const pos = [];
2744        let placed = 0;
2745        for (let r = 0; r < rows; r++) {
2746          const inRow = Math.min(2, k - placed);
2747          const rowW = inRow * BW + (inRow - 1) * colGap;
2748          const startX = (210 - rowW) / 2;
2749          for (let c = 0; c < inRow; c++) {
2750            pos.push({ x: startX + c * (BW + colGap), y: startY + r * (BH + rowGap) });
2751            placed++;
2752          }
2753        }
2754        return pos;
2755      };
2756
2757      let printed = 0;
2758      for (let p = 0; p * PER_PAGE < list.length; p++) {
2759        if (p > 0) doc.addPage('a4', 'portrait');
2760        const pageRegs = list.slice(p * PER_PAGE, p * PER_PAGE + PER_PAGE);
2761        const positions = pagePositions(pageRegs.length);
2762
2763        for (let j = 0; j < pageRegs.length; j++) {
2764          const reg = pageRegs[j];
2765          printed += 1;
2766          setBadgeExportProgress({ current: printed, total: list.length });
2767          const ox = positions[j].x;
2768          const oy = positions[j].y;
2769
2770          // Light cut guide around the badge.
2771          doc.setDrawColor(210, 210, 210);
2772          doc.setLineWidth(0.1);
2773          doc.rect(ox, oy, BW, BH);
2774
2775          // QR (member QR if available, else check-in token) — same logic as the single export.
2776          let memberQrCode = null;
2777          if (reg.userId) memberQrCode = await ensureMemberQrCode(reg.userId);
2778          const qrDataUrl = memberQrCode
2779            ? await generateQrCodeDataUrl(`https://events.trovex.com/member/${memberQrCode}`, 200)
2780            : await generateQrCodeDataUrl(`https://events.trovex.com/checkin/${eventId}/${reg.qrToken || 'unknown'}`, 200);
2781
2782          const textAreaWidth = BW - qrSize - margin * 3;
2783          let yPos = margin + 2;
2784
2785          // Name
2786          doc.setTextColor(...black);
2787          doc.setFontSize(12);
2788          doc.setFont('helvetica', 'bold');
2789          const fullName = reg.userName || reg.userEmail || 'Unknown';
2790          const nameLines = doc.splitTextToSize(fullName, textAreaWidth);
2791          doc.text(nameLines.slice(0, 2), ox + margin, oy + yPos + 4);
2792          yPos += nameLines.slice(0, 2).length * 5 + 2;
2793
2794          // Position/Job Title
2795          doc.setFontSize(9);
2796          doc.setFont('helvetica', 'normal');
2797          doc.setTextColor(...darkGray);
2798          const position = reg.jobTitle || reg.position || '';
2799          if (position) {
2800            doc.text(doc.splitTextToSize(position, textAreaWidth).slice(0, 1), ox + margin, oy + yPos + 3);
2801            yPos += 5;
2802          }
2803
2804          // Company/Organisation
2805          doc.setFontSize(9);
2806          doc.setTextColor(...darkGray);
2807          const company = reg.organisation || reg.company || '';
2808          if (company) {
2809            doc.text(doc.splitTextToSize(company, textAreaWidth).slice(0, 2), ox + margin, oy + yPos + 3);
2810          }
2811
2812          // Event name (bottom left)
2813          doc.setFontSize(7);
2814          doc.setTextColor(...lightGray);
2815          doc.text(doc.splitTextToSize(event.title || 'Event', textAreaWidth).slice(0, 2), ox + margin, oy + BH - margin - 2);
2816
2817          // QR (bottom right)
2818          if (qrDataUrl) {
2819            doc.addImage(qrDataUrl, 'PNG', ox + BW - qrSize - margin, oy + BH - qrSize - margin, qrSize, qrSize);
2820          }
2821        }
2822      }
2823
2824      doc.save(`${event.title.replace(/[^a-z0-9]/gi, '-').toLowerCase()}-badges-A4.pdf`);
2825      setMessage({ type: 'success', text: `Exported ${list.length} badge${list.length === 1 ? '' : 's'} on A4 (up to 6 per sheet)` });
2826      setTimeout(() => setMessage(null), 3000);
2827    } catch (err) {
2828      console.error('[AdminEventPage] Error exporting A4 badges:', err);
2829      setMessage({ type: 'error', text: 'Failed to export badges' });
2830    } finally {
2831      setExportingBadges(false);
2832      setBadgeExportProgress({ current: 0, total: 0 });
2833    }
2834  };
2835
2836  // Print a single badge
2837  const [printingBadge, setPrintingBadge] = useState(null);
2838  const printSingleBadge = async (reg) => {
2839    // Busy before the download, for the double-click reason in exportAllBadgesPDF.
2840    setPrintingBadge(reg.id);
2841    try {
2842      await loadPdfWithQr();
2843    } catch (err) {
2844      setMessage({ type: 'error', text: err.message });
2845      setPrintingBadge(null);
2846      return;
2847    }
2848    try {
2849      const { jsPDF } = window.jspdf;
2850      const doc = new jsPDF({ orientation: 'landscape', unit: 'mm', format: [50, 80] });
2851
2852      const pageWidth = 80;
2853      const pageHeight = 50;
2854      const margin = 4;
2855      const black = [0, 0, 0];
2856      const darkGray = [60, 60, 60];
2857      const lightGray = [120, 120, 120];
2858      const qrSize = 20;
2859
2860      let memberQrCode = null;
2861      if (reg.userId) {
2862        memberQrCode = await ensureMemberQrCode(reg.userId);
2863      }
2864
2865      let qrDataUrl = null;
2866      if (memberQrCode) {
2867        qrDataUrl = await generateQrCodeDataUrl(`https://events.trovex.com/member/${memberQrCode}`, 200);
2868      } else {
2869        qrDataUrl = await generateQrCodeDataUrl(`https://events.trovex.com/checkin/${eventId}/${reg.qrToken || 'unknown'}`, 200);
2870      }
2871
2872      const textAreaWidth = pageWidth - qrSize - margin * 3;
2873      let yPos = margin + 2;
2874
2875      doc.setTextColor(...black);
2876      doc.setFontSize(12);
2877      doc.setFont('helvetica', 'bold');
2878      const fullName = reg.userName || reg.userEmail || 'Unknown';
2879      const nameLines = doc.splitTextToSize(fullName, textAreaWidth);
2880      doc.text(nameLines.slice(0, 2), margin, yPos + 4);
2881      yPos += nameLines.slice(0, 2).length * 5 + 2;
2882
2883      doc.setFontSize(9);
2884      doc.setFont('helvetica', 'normal');
2885      doc.setTextColor(...darkGray);
2886      const position = reg.jobTitle || reg.position || '';
2887      if (position) {
2888        const posLines = doc.splitTextToSize(position, textAreaWidth);
2889        doc.text(posLines.slice(0, 1), margin, yPos + 3);
2890        yPos += 5;
2891      }
2892
2893      doc.setFontSize(9);
2894      doc.setTextColor(...darkGray);
2895      const company = reg.organisation || reg.company || '';
2896      if (company) {
2897        const companyLines = doc.splitTextToSize(company, textAreaWidth);
2898        doc.text(companyLines.slice(0, 2), margin, yPos + 3);
2899      }
2900
2901      doc.setFontSize(7);
2902      doc.setTextColor(...lightGray);
2903      const eventTitle = event.title || 'Event';
2904      const eventLines = doc.splitTextToSize(eventTitle, textAreaWidth);
2905      doc.text(eventLines.slice(0, 2), margin, pageHeight - margin - 2);
2906
2907      if (qrDataUrl) {
2908        doc.addImage(qrDataUrl, 'PNG', pageWidth - qrSize - margin, pageHeight - qrSize - margin, qrSize, qrSize);
2909      }
2910
2911      // Open print dialog instead of downloading
2912      const pdfBlob = doc.output('blob');
2913      const url = URL.createObjectURL(pdfBlob);
2914      const printWindow = window.open(url);
2915      if (printWindow) {
2916        printWindow.addEventListener('load', () => { printWindow.print(); });
2917      } else {
2918        // Fallback: download if popup blocked
2919        doc.save(`badge-${fullName.replace(/[^a-z0-9]/gi, '-').toLowerCase()}.pdf`);
2920      }
2921
2922      // Mark badge as printed
2923      try {
2924        await db.collection('events').doc(eventId)
2925          .collection('registrations').doc(reg.id)
2926          .update({ badgePrinted: true });
2927      } catch (_) {}
2928
2929    } catch (err) {
2930      console.error('[AdminEventPage] Error printing badge:', err);
2931      setMessage({ type: 'error', text: 'Failed to print badge' });
2932    } finally {
2933      setPrintingBadge(null);
2934    }
2935  };
2936
2937  // Save registration edits
2938  const saveRegistrationEdit = async () => {
2939    if (!editingRegistration) return;
2940    setSavingRegistration(true);
2941    try {
2942      await db.collection('events').doc(eventId)
2943        .collection('registrations').doc(editingRegistration.id)
2944        .update({
2945          userName: editRegData.userName || '',
2946          jobTitle: editRegData.jobTitle || '',
2947          organisation: editRegData.organisation || '',
2948          updatedAt: firebase.firestore.FieldValue.serverTimestamp()
2949        });
2950      setMessage({ type: 'success', text: 'Registration updated' });
2951      setTimeout(() => setMessage(null), 3000);
2952      setEditingRegistration(null);
2953    } catch (err) {
2954      console.error('[AdminEventPage] Error saving registration:', err);
2955      setMessage({ type: 'error', text: 'Failed to update registration' });
2956    } finally {
2957      setSavingRegistration(false);
2958    }
2959  };
2960
2961  // Copy share link
2962  const copyShareLink = (type) => {
2963    let url = '';
2964    if (type === 'public') {
2965      url = `${window.location.origin}/event/${event.id}`;
2966    } else if (type === 'checkin') {
2967      url = `${window.location.origin}/checkin/${event.id}`;
2968    }
2969    navigator.clipboard.writeText(url);
2970    setMessage({ type: 'success', text: 'Link copied to clipboard!' });
2971    setShowShareMenu(false);
2972  };
2973
2974  // Copy email promotional content (Outlook-compatible HTML)
2975  const copyEmailOverview = async () => {
2976    if (!event) return;
2977    const eventDate = event.eventDate?.toDate ? event.eventDate.toDate() : new Date(event.eventDate);
2978    const dateStr = eventDate.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
2979    const timeStr = eventDate.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
2980    const endTimeStr = event.eventEndDate
2981      ? (event.eventEndDate.toDate ? event.eventEndDate.toDate() : new Date(event.eventEndDate)).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
2982      : null;
2983    const eventUrl = `https://events.trovex.com/event/${event.id}`;
2984
2985    // Build Outlook-compatible HTML email content using tables (no CSS that Outlook strips)
2986    const html = `
2987<table cellpadding="0" cellspacing="0" border="0" width="100%" style="max-width:520px;font-family:Arial,Helvetica,sans-serif;">
2988  <tr>
2989    <td style="padding:0;">
2990      <table cellpadding="0" cellspacing="0" border="0" width="100%" style="border:1px solid #e0e0e0;border-radius:8px;overflow:hidden;">
2991        <tr>
2992          <td style="padding:24px 24px 20px 24px;background-color:#ffffff;">
2993            <h2 style="margin:0 0 16px 0;font-size:22px;font-weight:bold;color:#1a1a1a;line-height:1.3;font-family:Arial,Helvetica,sans-serif;">${event.title}</h2>
2994            <table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin-bottom:16px;background-color:#f8f9fa;border-radius:6px;">
2995              <tr>
2996                <td style="padding:14px 16px;">
2997                  <table cellpadding="0" cellspacing="0" border="0" width="100%">
2998                    <tr>
2999                      <td width="24" valign="top" style="padding:2px 0;font-size:14px;">&#128197;</td>
3000                      <td style="padding:2px 0 2px 8px;font-size:14px;color:#333333;font-family:Arial,Helvetica,sans-serif;">
3001                        <strong style="color:#1a1a1a;">${dateStr}</strong><br/>
3002                        <span style="color:#666666;font-size:13px;">${timeStr}${endTimeStr ? ' - ' + endTimeStr : ''}</span>
3003                      </td>
3004                    </tr>
3005                    ${event.location?.name || event.location?.isVirtual ? `
3006                    <tr>
3007                      <td width="24" valign="top" style="padding:10px 0 2px 0;font-size:14px;">&#128205;</td>
3008                      <td style="padding:10px 0 2px 8px;font-size:14px;color:#333333;font-family:Arial,Helvetica,sans-serif;">
3009                        ${event.location.isVirtual
3010                          ? '<strong style="color:#1a1a1a;">Virtual Event</strong><br/><span style="color:#666666;font-size:13px;">Online</span>'
3011                          : `<strong style="color:#1a1a1a;">${event.location.name}</strong>${event.location.address ? '<br/><span style="color:#666666;font-size:13px;">' + event.location.address + (event.location.postcode ? ', ' + event.location.postcode : '') + '</span>' : ''}`
3012                        }
3013                      </td>
3014                    </tr>
3015                    ` : ''}
3016                    ${event.cpdHours > 0 ? `
3017                    <tr>
3018                      <td width="24" valign="top" style="padding:10px 0 2px 0;font-size:14px;">&#127891;</td>
3019                      <td style="padding:10px 0 2px 8px;font-size:14px;color:#333333;font-family:Arial,Helvetica,sans-serif;">
3020                        <strong style="color:#1a1a1a;">${event.cpdHours} CPD Hours</strong>
3021                      </td>
3022                    </tr>
3023                    ` : ''}
3024                  </table>
3025                </td>
3026              </tr>
3027            </table>
3028            ${event.description ? `<p style="margin:0 0 20px 0;font-size:14px;line-height:1.6;color:#4a4a4a;font-family:Arial,Helvetica,sans-serif;">${event.description.substring(0, 350)}${event.description.length > 350 ? '...' : ''}</p>` : ''}
3029            <table cellpadding="0" cellspacing="0" border="0" width="100%">
3030              <tr>
3031                <td align="center" bgcolor="#00897b" style="background-color:#00897b;border-radius:6px;">
3032                  <a href="${eventUrl}" target="_blank" style="display:inline-block;padding:14px 36px;font-size:14px;font-weight:bold;color:#ffffff;text-decoration:none;font-family:Arial,Helvetica,sans-serif;">View Event &amp; Register</a>
3033                </td>
3034              </tr>
3035            </table>
3036          </td>
3037        </tr>
3038        <tr>
3039          <td style="padding:12px 24px;background-color:#f8f9fa;border-top:1px solid #e0e0e0;">
3040            <p style="margin:0;font-size:12px;color:#888888;text-align:center;font-family:Arial,Helvetica,sans-serif;">
3041              <a href="${eventUrl}" style="color:#00897b;text-decoration:none;">${eventUrl}</a>
3042            </p>
3043          </td>
3044        </tr>
3045      </table>
3046    </td>
3047  </tr>
3048</table>
3049    `.trim();
3050
3051    // Plain text fallback
3052    const plainText = `
3053${event.title}
3054
3055Date: ${dateStr}
3056Time: ${timeStr}${endTimeStr ? ' - ' + endTimeStr : ''}
3057${event.location?.name ? 'Location: ' + event.location.name + (event.location.address ? ', ' + event.location.address : '') : ''}
3058${event.location?.isVirtual ? 'Location: Virtual Event (Online)' : ''}
3059${event.cpdHours > 0 ? 'CPD Hours: ' + event.cpdHours : ''}
3060
3061${event.description ? event.description.substring(0, 500) + (event.description.length > 500 ? '...' : '') : ''}
3062
3063View event and register: ${eventUrl}
3064    `.trim();
3065
3066    try {
3067      if (navigator.clipboard && window.ClipboardItem) {
3068        const htmlBlob = new Blob([html], { type: 'text/html' });
3069        const textBlob = new Blob([plainText], { type: 'text/plain' });
3070        await navigator.clipboard.write([
3071          new ClipboardItem({
3072            'text/html': htmlBlob,
3073            'text/plain': textBlob
3074          })
3075        ]);
3076      } else {
3077        await navigator.clipboard.writeText(plainText);
3078      }
3079      setMessage({ type: 'success', text: 'Email content copied! Paste into Outlook or any email client.' });
3080      setShowShareMenu(false);
3081    } catch (error) {
3082      console.error('[Copy] Error:', error);
3083      try {
3084        await navigator.clipboard.writeText(plainText);
3085        setMessage({ type: 'success', text: 'Event details copied (plain text)' });
3086      } catch (e) {
3087        setMessage({ type: 'error', text: 'Could not copy to clipboard' });
3088      }
3089      setShowShareMenu(false);
3090    }
3091  };
3092
3093  // Export Flyer PDF
3094  const exportFlyerPDF = async () => {
3095    if (!event) {
3096      setMessage({ type: 'error', text: 'No event loaded' });
3097      return;
3098    }
3099    try {
3100      await loadJsPdf();
3101    } catch (err) {
3102      setMessage({ type: 'error', text: err.message });
3103      return;
3104    }
3105    const { jsPDF } = window.jspdf;
3106    const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
3107    const pageWidth = 210;
3108    const margin = 18;
3109    const contentWidth = pageWidth - (margin * 2);
3110    let yPos = margin;
3111
3112    const brandColor = [0, 137, 123];
3113    const darkGray = [31, 41, 55];
3114    const mediumGray = [75, 85, 99];
3115    const lightGray = [156, 163, 175];
3116
3117    const loadImage = (src) => new Promise((resolve, reject) => {
3118      const img = new Image();
3119      img.crossOrigin = 'anonymous';
3120      img.onload = () => resolve(img);
3121      img.onerror = reject;
3122      img.src = src;
3123    });
3124
3125    // Logo
3126    try {
3127      const logo = await loadImage('/trovex-logo.png');
3128      const logoHeight = 10;
3129      const logoWidth = (logo.width / logo.height) * logoHeight;
3130      doc.addImage(logo, 'PNG', margin, yPos, logoWidth, logoHeight);
3131      yPos += logoHeight + 8;
3132    } catch (e) { yPos += 6; }
3133
3134    // Hero image
3135    if (event.heroImage) {
3136      try {
3137        const img = await loadImage(event.heroImage);
3138        const imgWidth = contentWidth;
3139        const imgHeight = imgWidth * (9 / 16);
3140        doc.addImage(img, 'JPEG', margin, yPos, imgWidth, imgHeight);
3141        yPos += imgHeight + 10;
3142      } catch (e) { console.log('[PDF] Could not load hero:', e); }
3143    }
3144
3145    // Title
3146    doc.setTextColor(...darkGray);
3147    doc.setFontSize(18);
3148    doc.setFont('helvetica', 'bold');
3149    const titleLines = doc.splitTextToSize(event.title, contentWidth);
3150    titleLines.forEach((line, i) => doc.text(line, margin, yPos + (i * 7.5)));
3151    yPos += titleLines.length * 7.5 + 8;
3152
3153    // Date/Time/Location box
3154    const eventDate = event.eventDate?.toDate ? event.eventDate.toDate() : new Date(event.eventDate);
3155    const dateStr = eventDate.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' });
3156    const timeStr = eventDate.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
3157    const endTimeStr = event.eventEndDate ? (event.eventEndDate.toDate ? event.eventEndDate.toDate() : new Date(event.eventEndDate)).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) : null;
3158
3159    doc.setFillColor(248, 250, 252);
3160    const hasLocation = event.location?.name || event.location?.isVirtual;
3161    const infoBoxHeight = hasLocation ? (event.location?.address ? 24 : 18) : 12;
3162    doc.roundedRect(margin, yPos, contentWidth, infoBoxHeight, 2, 2, 'F');
3163
3164    doc.setTextColor(...brandColor);
3165    doc.setFontSize(9);
3166    doc.setFont('helvetica', 'bold');
3167    doc.text(`${dateStr}  •  ${timeStr}${endTimeStr ? ' – ' + endTimeStr : ''}`, margin + 5, yPos + 7);
3168
3169    if (hasLocation) {
3170      doc.setTextColor(...mediumGray);
3171      doc.setFontSize(8);
3172      doc.setFont('helvetica', 'normal');
3173      if (event.location.isVirtual) {
3174        doc.text('Virtual Event (Online)', margin + 5, yPos + 14);
3175      } else {
3176        doc.text(event.location.name, margin + 5, yPos + 14);
3177        if (event.location.address) {
3178          doc.setTextColor(...lightGray);
3179          const addressText = event.location.address + (event.location.postcode ? ', ' + event.location.postcode : '');
3180          doc.text(addressText.length > 65 ? addressText.substring(0, 65) + '...' : addressText, margin + 5, yPos + 20);
3181        }
3182      }
3183    }
3184    yPos += infoBoxHeight + 5;
3185
3186    // CPD badge
3187    if (event.cpdHours > 0) {
3188      doc.setFillColor(124, 58, 237);
3189      doc.roundedRect(margin, yPos, 30, 5, 2.5, 2.5, 'F');
3190      doc.setTextColor(255, 255, 255);
3191      doc.setFontSize(7);
3192      doc.setFont('helvetica', 'bold');
3193      doc.text(`${event.cpdHours} CPD HRS`, margin + 15, yPos + 3.5, { align: 'center' });
3194      yPos += 8;
3195    }
3196
3197    // Description
3198    if (event.description) {
3199      yPos += 4;
3200      doc.setTextColor(...darkGray);
3201      doc.setFontSize(9);
3202      doc.setFont('helvetica', 'bold');
3203      doc.text('About This Event', margin, yPos);
3204      yPos += 5;
3205      doc.setTextColor(...mediumGray);
3206      doc.setFontSize(8);
3207      doc.setFont('helvetica', 'normal');
3208      const descLines = doc.splitTextToSize(event.description, contentWidth).slice(0, 8);
3209      descLines.forEach((line, i) => doc.text(line, margin, yPos + (i * 4)));
3210      yPos += descLines.length * 4 + 8;
3211    }
3212
3213    // QR Code and URL
3214    const eventUrl = `https://events.trovex.com/event/${event.id}`;
3215    try {
3216      const qrDataUrl = await generateQrCodeDataUrl(eventUrl, 200);
3217      doc.addImage(qrDataUrl, 'PNG', margin, yPos, 25, 25);
3218      doc.setTextColor(...brandColor);
3219      doc.setFontSize(8);
3220      doc.setFont('helvetica', 'bold');
3221      doc.text('Scan to register', margin + 28, yPos + 10);
3222      doc.setTextColor(...mediumGray);
3223      doc.setFontSize(7);
3224      doc.setFont('helvetica', 'normal');
3225      doc.text(eventUrl, margin + 28, yPos + 15);
3226    } catch (e) {
3227      doc.setTextColor(...brandColor);
3228      doc.setFontSize(8);
3229      doc.text('Register: ' + eventUrl, margin, yPos + 5);
3230    }
3231
3232    const fileName = `${event.title.replace(/[^a-z0-9]/gi, '_').substring(0, 30)}_Flyer.pdf`;
3233    doc.save(fileName);
3234    setMessage({ type: 'success', text: 'Flyer PDF downloaded!' });
3235    setShowShareMenu(false);
3236  };
3237
3238  // Export Agenda PDF
3239  const exportAgendaPDF = async () => {
3240    if (!event) {
3241      setMessage({ type: 'error', text: 'No event loaded' });
3242      return;
3243    }
3244    // Three ways confirmedSpeakers can be short, and the PDF must refuse rather than print a
3245    // Speakers list that quietly omits somebody who IS speaking: still loading; the roster
3246    // listener errored (useCollection sets loading:false on error, so the load check alone passes);
3247    // or an Agreed speaker's record is outside the capped, createdAt-ordered speakers page, which
3248    // never resolves by waiting.
3249    if (speakerRecordsLoading || speakerRosterLoading) {
3250      setMessage({ type: 'error', text: 'Speakers are still loading. Generate the PDF again in a moment so it includes them.' });
3251      setShowShareMenu(false);
3252      return;
3253    }
3254    if (speakerRosterError) {
3255      setMessage({ type: 'error', text: 'The speaker list could not be loaded, so the PDF would leave the speakers out. Reload and try again.' });
3256      setShowShareMenu(false);
3257      return;
3258    }
3259    const agreedCount = agreedEntryCount(speakerEntries);
3260    if (agreedCount > confirmedSpeakers.length) {
3261      setMessage({
3262        type: 'error',
3263        text: `${agreedCount - confirmedSpeakers.length} confirmed speaker(s) have no speaker record, so the PDF would leave them out. Check the event's Speakers tab.`
3264      });
3265      setShowShareMenu(false);
3266      return;
3267    }
3268
3269    try {
3270      await loadJsPdf();
3271    } catch (err) {
3272      setMessage({ type: 'error', text: err.message });
3273      return;
3274    }
3275    const { jsPDF } = window.jspdf;
3276    const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
3277    const pageWidth = 210;
3278    const margin = 18;
3279    const contentWidth = pageWidth - (margin * 2);
3280    let yPos = margin;
3281
3282    const brandColor = [0, 137, 123];
3283    const darkGray = [31, 41, 55];
3284    const mediumGray = [75, 85, 99];
3285
3286    const loadImage = (src) => new Promise((resolve, reject) => {
3287      const img = new Image();
3288      img.crossOrigin = 'anonymous';
3289      img.onload = () => resolve(img);
3290      img.onerror = reject;
3291      img.src = src;
3292    });
3293
3294    // Logo
3295    try {
3296      const logo = await loadImage('/trovex-logo.png');
3297      const logoHeight = 10;
3298      const logoWidth = (logo.width / logo.height) * logoHeight;
3299      doc.addImage(logo, 'PNG', margin, yPos, logoWidth, logoHeight);
3300      yPos += logoHeight + 10;
3301    } catch (e) { yPos += 8; }
3302
3303    // Title
3304    doc.setTextColor(...darkGray);
3305    doc.setFontSize(16);
3306    doc.setFont('helvetica', 'bold');
3307    doc.text(event.title, margin, yPos);
3308    yPos += 8;
3309
3310    // Labelled, not blocked: a staffer opening the AGENDA pdf asked for the agenda by
3311    // name and usually wants it for internal review. It says so on the page in case the
3312    // file is forwarded on while the running order is deliberately hidden from customers.
3313    if (!isAgendaVisibleToCustomers(event)) {
3314      doc.setFontSize(9);
3315      doc.setFont('helvetica', 'bold');
3316      doc.setTextColor(180, 83, 9);
3317      doc.text('DRAFT - NOT PUBLISHED TO CUSTOMERS', margin, yPos);
3318      yPos += 6;
3319      doc.setTextColor(...darkGray);
3320    }
3321
3322    // Date
3323    const eventDate = event.eventDate?.toDate ? event.eventDate.toDate() : new Date(event.eventDate);
3324    const dateStr = eventDate.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
3325    doc.setTextColor(...brandColor);
3326    doc.setFontSize(10);
3327    doc.setFont('helvetica', 'normal');
3328    doc.text(dateStr, margin, yPos);
3329    yPos += 12;
3330
3331    // Agenda header
3332    doc.setTextColor(...darkGray);
3333    doc.setFontSize(12);
3334    doc.setFont('helvetica', 'bold');
3335    doc.text('Event Agenda', margin, yPos);
3336    yPos += 8;
3337
3338    // Agenda items
3339    if (event.agenda && event.agenda.length > 0) {
3340      event.agenda.forEach((item, idx) => {
3341        if (yPos > 260) {
3342          doc.addPage();
3343          yPos = margin;
3344        }
3345        // Time
3346        doc.setFillColor(248, 250, 252);
3347        doc.roundedRect(margin, yPos, contentWidth, item.description ? 20 : 12, 2, 2, 'F');
3348        doc.setTextColor(...brandColor);
3349        doc.setFontSize(9);
3350        doc.setFont('helvetica', 'bold');
3351        doc.text(item.time || `Item ${idx + 1}`, margin + 4, yPos + 6);
3352        // Title
3353        doc.setTextColor(...darkGray);
3354        doc.setFontSize(9);
3355        doc.setFont('helvetica', 'normal');
3356        doc.text(item.title || 'Session', margin + 25, yPos + 6);
3357        // Description
3358        if (item.description) {
3359          doc.setTextColor(...mediumGray);
3360          doc.setFontSize(8);
3361          const descLines = doc.splitTextToSize(item.description, contentWidth - 30).slice(0, 2);
3362          descLines.forEach((line, i) => doc.text(line, margin + 25, yPos + 11 + (i * 4)));
3363        }
3364        yPos += item.description ? 24 : 16;
3365      });
3366    } else {
3367      doc.setTextColor(...mediumGray);
3368      doc.setFontSize(9);
3369      doc.text('No agenda items available.', margin, yPos);
3370      yPos += 10;
3371    }
3372
3373    // Speakers section - the Agreed roster for this event. The completeness guard is at the top of
3374    // this function, so by here confirmedSpeakers is known to name every Agreed speaker.
3375    if (confirmedSpeakers.length > 0) {
3376      yPos += 8;
3377      if (yPos > 240) {
3378        doc.addPage();
3379        yPos = margin;
3380      }
3381      doc.setTextColor(...darkGray);
3382      doc.setFontSize(12);
3383      doc.setFont('helvetica', 'bold');
3384      doc.text('Speakers', margin, yPos);
3385      yPos += 8;
3386
3387      confirmedSpeakers.forEach(speaker => {
3388        if (yPos > 260) {
3389          doc.addPage();
3390          yPos = margin;
3391        }
3392        doc.setTextColor(...darkGray);
3393        doc.setFontSize(9);
3394        doc.setFont('helvetica', 'bold');
3395        doc.text(speaker.name || 'Speaker', margin, yPos);
3396        if (speaker.title || speaker.company) {
3397          doc.setTextColor(...mediumGray);
3398          doc.setFontSize(8);
3399          doc.setFont('helvetica', 'normal');
3400          doc.text([speaker.title, speaker.company].filter(Boolean).join(' - '), margin, yPos + 4);
3401        }
3402        yPos += speaker.title || speaker.company ? 12 : 8;
3403      });
3404    }
3405
3406    const fileName = `${event.title.replace(/[^a-z0-9]/gi, '_').substring(0, 30)}_Agenda.pdf`;
3407    doc.save(fileName);
3408    setMessage({ type: 'success', text: 'Agenda PDF downloaded!' });
3409    setShowShareMenu(false);
3410  };
3411
3412  // ===== Task Handler Functions =====
3413
3414  // Format relative time for tasks
3415  const formatRelativeTime = (timestamp) => {
3416    if (!timestamp) return '';
3417    const date = timestamp.toDate ? timestamp.toDate() : new Date(timestamp);
3418    const now = new Date();
3419    const diffMs = now - date;
3420    const diffMins = Math.floor(diffMs / 60000);
3421    const diffHours = Math.floor(diffMs / 3600000);
3422    const diffDays = Math.floor(diffMs / 86400000);
3423
3424    if (diffMins < 1) return 'just now';
3425    if (diffMins < 60) return `${diffMins}m ago`;
3426    if (diffHours < 24) return `${diffHours}h ago`;
3427    if (diffDays < 7) return `${diffDays}d ago`;
3428    return date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
3429  };
3430
3431  // Add a new task
3432  const handleAddTask = async () => {
3433    if (!newTaskTitle.trim()) {
3434      setMessage({ type: 'error', text: 'Task title is required' });
3435      return;
3436    }
3437
3438    setAddingTask(true);
3439    try {
3440      const assigneeName = newTaskAssignee
3441        ? (staffMembersList.find(s => s.email === newTaskAssignee)?.firstName + ' ' +
3442           staffMembersList.find(s => s.email === newTaskAssignee)?.lastName).trim()
3443        : null;
3444
3445      const taskData = {
3446        title: newTaskTitle.trim(),
3447        description: newTaskDescription.trim(),
3448        status: 'pending',
3449        assignedTo: newTaskAssignee || null,
3450        assignedToName: assigneeName,
3451        dueDate: newTaskDueDate ? firebase.firestore.Timestamp.fromDate(new Date(newTaskDueDate)) : null,
3452        createdAt: firebase.firestore.FieldValue.serverTimestamp(),
3453        createdBy: user?.email,
3454        createdByName: user?.displayName || user?.email?.split('@')[0],
3455        completedAt: null,
3456        completedBy: null,
3457        comments: [],
3458        subTasks: []
3459      };
3460
3461      await firebase.firestore()
3462        .collection('events')
3463        .doc(eventId)
3464        .collection('tasks')
3465        .add(taskData);
3466
3467      // Send email notification if task is assigned
3468      if (newTaskAssignee) {
3469        console.log('[AdminEventPage] Sending task notification to:', newTaskAssignee);
3470        try {
3471          const sendNotification = firebase.app().functions('europe-west2').httpsCallable('sendEventTaskAssignmentEmail');
3472          const result = await sendNotification({
3473            assigneeEmail: newTaskAssignee,
3474            assigneeName: assigneeName,
3475            taskTitle: newTaskTitle.trim(),
3476            taskDescription: newTaskDescription.trim(),
3477            dueDate: newTaskDueDate || null,
3478            assignerName: user?.displayName || user?.email?.split('@')[0],
3479            eventId: eventId,
3480            eventTitle: event?.title
3481          });
3482          console.log('[AdminEventPage] Email notification result:', result);
3483        } catch (emailErr) {
3484          console.error('[AdminEventPage] Failed to send task notification email:', emailErr);
3485          // Don't fail task creation if email fails
3486        }
3487      }
3488
3489      // Reset form
3490      setNewTaskTitle('');
3491      setNewTaskDescription('');
3492      setNewTaskAssignee('');
3493      setNewTaskDueDate('');
3494      setShowAddTask(false);
3495      setMessage({ type: 'success', text: 'Task added successfully' });
3496    } catch (err) {
3497      console.error('[AdminEventPage] Error adding task:', err);
3498      setMessage({ type: 'error', text: 'Failed to add task: ' + err.message });
3499    } finally {
3500      setAddingTask(false);
3501    }
3502  };
3503
3504  // Toggle task status
3505  const handleToggleTaskStatus = async (task) => {
3506    const newStatus = task.status === 'pending' ? 'completed' : 'pending';
3507    try {
3508      await firebase.firestore()
3509        .collection('events')
3510        .doc(eventId)
3511        .collection('tasks')
3512        .doc(task.id)
3513        .update({
3514          status: newStatus,
3515          completedAt: newStatus === 'completed' ? firebase.firestore.FieldValue.serverTimestamp() : null,
3516          completedBy: newStatus === 'completed' ? user?.email : null
3517        });
3518    } catch (err) {
3519      console.error('[AdminEventPage] Error updating task:', err);
3520      setMessage({ type: 'error', text: 'Failed to update task: ' + err.message });
3521    }
3522  };
3523
3524  // Delete task
3525  const handleDeleteTask = async (task) => {
3526    if (!confirm('Are you sure you want to delete this task?')) return;
3527
3528    try {
3529      await firebase.firestore()
3530        .collection('events')
3531        .doc(eventId)
3532        .collection('tasks')
3533        .doc(task.id)
3534        .delete();
3535      setMessage({ type: 'success', text: 'Task deleted' });
3536    } catch (err) {
3537      console.error('[AdminEventPage] Error deleting task:', err);
3538      setMessage({ type: 'error', text: 'Failed to delete task: ' + err.message });
3539    }
3540  };
3541
3542  // Add comment to task
3543  const handleAddTaskComment = async (task) => {
3544    if (!newTaskComment.trim()) return;
3545
3546    try {
3547      const comment = {
3548        id: crypto.randomUUID(),
3549        text: newTaskComment.trim(),
3550        author: user?.email,
3551        authorName: user?.displayName || user?.email?.split('@')[0],
3552        createdAt: new Date().toISOString()
3553      };
3554
3555      await firebase.firestore()
3556        .collection('events')
3557        .doc(eventId)
3558        .collection('tasks')
3559        .doc(task.id)
3560        .update({
3561          comments: firebase.firestore.FieldValue.arrayUnion(comment)
3562        });
3563
3564      setNewTaskComment('');
3565    } catch (err) {
3566      console.error('[AdminEventPage] Error adding comment:', err);
3567      setMessage({ type: 'error', text: 'Failed to add comment: ' + err.message });
3568    }
3569  };
3570
3571  // Add sub-task to task
3572  const handleAddSubTask = async (task) => {
3573    if (!newSubTaskTitle.trim()) return;
3574
3575    try {
3576      const subTask = {
3577        id: crypto.randomUUID(),
3578        title: newSubTaskTitle.trim(),
3579        status: 'pending',
3580        createdAt: new Date().toISOString(),
3581        completedAt: null,
3582        completedBy: null
3583      };
3584
3585      await firebase.firestore()
3586        .collection('events')
3587        .doc(eventId)
3588        .collection('tasks')
3589        .doc(task.id)
3590        .update({
3591          subTasks: firebase.firestore.FieldValue.arrayUnion(subTask)
3592        });
3593
3594      setNewSubTaskTitle('');
3595    } catch (err) {
3596      console.error('[AdminEventPage] Error adding sub-task:', err);
3597      setMessage({ type: 'error', text: 'Failed to add sub-task: ' + err.message });
3598    }
3599  };
3600
3601  // Toggle sub-task status
3602  const handleToggleSubTask = async (task, subTaskId) => {
3603    const subTasks = task.subTasks || [];
3604    const updatedSubTasks = subTasks.map(st => {
3605      if (st.id === subTaskId) {
3606        const newStatus = st.status === 'pending' ? 'completed' : 'pending';
3607        return {
3608          ...st,
3609          status: newStatus,
3610          completedAt: newStatus === 'completed' ? new Date().toISOString() : null,
3611          completedBy: newStatus === 'completed' ? user?.email : null
3612        };
3613      }
3614      return st;
3615    });
3616
3617    try {
3618      await firebase.firestore()
3619        .collection('events')
3620        .doc(eventId)
3621        .collection('tasks')
3622        .doc(task.id)
3623        .update({ subTasks: updatedSubTasks });
3624    } catch (err) {
3625      console.error('[AdminEventPage] Error toggling sub-task:', err);
3626      setMessage({ type: 'error', text: 'Failed to update sub-task: ' + err.message });
3627    }
3628  };
3629
3630  // Delete sub-task
3631  const handleDeleteSubTask = async (task, subTaskId) => {
3632    const updatedSubTasks = (task.subTasks || []).filter(st => st.id !== subTaskId);
3633
3634    try {
3635      await firebase.firestore()
3636        .collection('events')
3637        .doc(eventId)
3638        .collection('tasks')
3639        .doc(task.id)
3640        .update({ subTasks: updatedSubTasks });
3641    } catch (err) {
3642      console.error('[AdminEventPage] Error deleting sub-task:', err);
3643      setMessage({ type: 'error', text: 'Failed to delete sub-task: ' + err.message });
3644    }
3645  };
3646
3647  // Filter tasks
3648  const filteredTasks = useMemo(() => {
3649    let filtered = [...eventTasks];
3650
3651    switch (taskFilter) {
3652      case 'pending':
3653        filtered = filtered.filter(t => t.status === 'pending');
3654        break;
3655      case 'completed':
3656        filtered = filtered.filter(t => t.status === 'completed');
3657        break;
3658      case 'mine':
3659        filtered = filtered.filter(t => t.assignedTo === user?.email);
3660        break;
3661    }
3662
3663    return filtered;
3664  }, [eventTasks, taskFilter, user?.email]);
3665
3666  // Format date helper
3667  const formatEventDate = (date) => {
3668    if (!date) return 'Date TBC';
3669    const d = date.toDate ? date.toDate() : new Date(date);
3670    return d.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
3671  };
3672
3673  // Helper to get inviter name from email
3674  const getInviterName = (email) => {
3675    if (!email) return '-';
3676    // Common staff mappings
3677    const staffNames = {
3678      '[email protected]': 'Arran North',
3679      '[email protected]': 'Tighe Rodgers',
3680      '[email protected]': 'Weronika Studenna'
3681    };
3682    const lower = email.toLowerCase();
3683    if (staffNames[lower]) return staffNames[lower];
3684    // Fallback to email prefix, properly formatted
3685    const prefix = email.split('@')[0];
3686    // Convert "howard.mccreadie" to "Howard Mccreadie"
3687    return prefix
3688      .split(/[._-]/)
3689      .map(part => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
3690      .join(' ');
3691  };
3692
3693  // Test accounts to hide (only visible to specific users)
3694  const TEST_ACCOUNTS = ['[email protected]'];
3695  const canSeeTestAccounts = user?.email?.toLowerCase() === '[email protected]';
3696
3697  // Filtered and sorted registrations (newest first with search and status filter)
3698  const filteredRegistrations = useMemo(() => {
3699    let result = [...registrations];
3700
3701    // Hide test accounts unless user can see them
3702    if (!canSeeTestAccounts) {
3703      result = result.filter(r => !TEST_ACCOUNTS.includes((r.userEmail || '').toLowerCase()));
3704    }
3705
3706    // Filter by status
3707    // Filtered by the LABEL'S kind rather than the raw status, because "Confirmed" is no longer a
3708    // status - it is a status plus a confirmedAt (seat-label.js).
3709    if (registrationStatusFilter === 'reminderSent') {
3710      // A DIFFERENT AXIS FROM THE OTHERS, sharing the same control. Every other chip narrows by
3711      // what the seat IS; this one narrows by what we have SENT it, so it deliberately crosses the
3712      // labels - a declined seat we reminded belongs in "who have we chased", which is the whole
3713      // question it answers. It reads through the same hasBeenEmailed rule the pill does, so every
3714      // row it selects carries a pill - and while this filter is active the pill is asked for the
3715      // REMINDER specifically, because the most recent send may be a later confirmation and the
3716      // row would otherwise be selected for one reason and state another.
3717      result = result.filter(r => hasBeenEmailed(r, 'reminder'));
3718    } else if (registrationStatusFilter !== 'all') {
3719      result = result.filter(r => seatLabel(r).kind === registrationStatusFilter);
3720    }
3721
3722    // Filter by search
3723    if (registrationSearch) {
3724      const search = registrationSearch.toLowerCase();
3725      result = result.filter(r =>
3726        (r.userName || '').toLowerCase().includes(search) ||
3727        (r.userEmail || '').toLowerCase().includes(search) ||
3728        (r.organisation || '').toLowerCase().includes(search) ||
3729        (r.jobTitle || '').toLowerCase().includes(search)
3730      );
3731    }
3732
3733    // Sort by registeredAt descending (newest first)
3734    result.sort((a, b) => {
3735      const aDate = a.registeredAt?.toDate ? a.registeredAt.toDate().getTime() : (a.registeredAt ? new Date(a.registeredAt).getTime() : 0);
3736      const bDate = b.registeredAt?.toDate ? b.registeredAt.toDate().getTime() : (b.registeredAt ? new Date(b.registeredAt).getTime() : 0);
3737      return bDate - aDate;
3738    });
3739
3740    return result;
3741  }, [registrations, registrationSearch, registrationStatusFilter, canSeeTestAccounts]);
3742
3743  // Status counts for filter badges
3744  const statusCounts = useMemo(() => {
3745    let regs = registrations;
3746    if (!canSeeTestAccounts) {
3747      regs = regs.filter(r => !TEST_ACCOUNTS.includes((r.userEmail || '').toLowerCase()));
3748    }
3749    // One rule for the chips, the column and the counts: seat-label.js.
3750    return seatCounts(regs);
3751  }, [registrations, canSeeTestAccounts]);
3752
3753  // How many seats we have sent a reminder to. Counted over the same base as statusCounts - test
3754  // accounts excluded on the same rule - or the chip would promise rows the list then hides.
3755  const remindedCount = useMemo(() => {
3756    let regs = registrations;
3757    if (!canSeeTestAccounts) {
3758      regs = regs.filter(r => !TEST_ACCOUNTS.includes((r.userEmail || '').toLowerCase()));
3759    }
3760    return regs.filter(r => hasBeenEmailed(r, 'reminder')).length;
3761  }, [registrations, canSeeTestAccounts]);
3762
3763  // Visible registration count (excluding hidden test accounts, only counting active registrations)
3764  const visibleRegistrationCount = useMemo(() => {
3765    if (canSeeTestAccounts) return counts.registered;
3766    // Count only registered, confirmed, and attended (not declined, cancelled, waitlisted)
3767    return registrations.filter(r =>
3768      ['registered', 'confirmed', 'attended'].includes(r.status) &&
3769      !TEST_ACCOUNTS.includes((r.userEmail || '').toLowerCase())
3770    ).length;
3771  }, [registrations, counts.registered, canSeeTestAccounts]);
3772
3773  // Loading state
3774  if (loading) {
3775    return h('div', { className: 'min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900' },
3776      h('div', { className: 'text-center' },
3777        h('div', { className: 'w-12 h-12 border-4 border-primary-200 border-t-primary-600 rounded-full animate-spin mx-auto mb-4' }),
3778        h('p', { className: 'text-gray-500 dark:text-gray-400' }, 'Loading event...')
3779      )
3780    );
3781  }
3782
3783  // Event not found
3784  if (!event) {
3785    return h('div', { className: 'min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900' },
3786      h('div', { className: 'text-center' },
3787        h(Icons.AlertCircle, { size: 48, className: 'mx-auto text-red-500 mb-4' }),
3788        h('h2', { className: 'text-xl font-bold text-gray-900 dark:text-white mb-2' }, 'Event Not Found'),
3789        h('p', { className: 'text-gray-500 dark:text-gray-400 mb-4' }, 'This event may have been deleted or you don\'t have access.'),
3790        h('a', { href: '/', onClick: (e) => { e.preventDefault(); navigateTo('/'); }, className: 'text-primary-600 hover:text-primary-700' }, '← Back to Events')
3791      )
3792    );
3793  }
3794
3795  // Calculate counts from actual registration/prospect statuses
3796  const invitedProspectEmails = new Set(prospects.filter(p => p.status === 'invited').map(p => (p.email || '').toLowerCase()));
3797  const totalInvitedCount = invitedProspectEmails.size
3798    + quickInvites.filter(i => !['declined', 'cant-attend'].includes(i.status) && !invitedProspectEmails.has((i.email || '').toLowerCase())).length;
3799  // 'cant-attend' and 'declined' are ANSWERS, so they are not active work. Leaving them in made
3800  // this figure grow every time somebody declined, pointing staff back at people who had already
3801  // told us - the exact thing reflecting a decline onto this board exists to prevent.
3802  const activeProspectCount = prospects.filter(p =>
3803    !['invited', 'registered', 'attended', 'cant-attend', 'declined'].includes(p.status)).length;
3804
3805  // Invited and still silent. Since 2026-08-12 an invitation does 
3805NOT reserve a place, so this is
3806  // a planning number for staff rather than something that decides bookings: it is how many people
3807  // could still take a place, which is what capacity planning actually needs.
3808  const awaitingAnswerCount = prospects.filter(p => p.status === 'invited').length
3809    + quickInvites.filter(i => !['declined', 'cant-attend', 'accepted', 'registered', 'attended'].includes(i.status)
3810      && !invitedProspectEmails.has((i.email || '').toLowerCase())).length;
3811
3812  const tabs = [
3813    { id: 'details', label: 'Details', icon: Icons.FileText },
3814    { id: 'registrations', label: `Registrations (${visibleRegistrationCount})${pendingRegistrations.length > 0 ? ` +${pendingRegistrations.length}` : ''}`, icon: Icons.Users },
3815    { id: 'prospects', label: `Prospects${prospects.length > 0 ? ` (${prospects.length})` : ''}`, icon: Icons.Target },
3816    { id: 'speakers', label: `Speakers / SMEs${rosterHeadcount(speakerEntries) > 0 ? ` (${rosterHeadcount(speakerEntries)})` : ''}`, icon: Icons.Mic },
3817    { id: 'notes', label: `Notes${eventNotes.length > 0 ? ` (${eventNotes.length})` : ''}`, icon: Icons.Edit },
3818    { id: 'qa', label: 'Q&A', icon: Icons.MessageSquare },
3819    { id: 'tasks', label: `Tasks${eventTasks.filter(t => t.status === 'pending').length > 0 ? ` (${eventTasks.filter(t => t.status === 'pending').length})` : ''}`, icon: Icons.CheckSquare },
3820    { id: 'feedback', label: `Feedback${feedback.length > 0 ? ` (${feedback.length})` : ''}`, icon: Icons.Star }
3821  ];
3822
3823  return h('div', { className: 'bg-gray-50 dark:bg-gray-900 pb-20 md:pb-0' },
3824    // Header
3825    h('header', { className: 'bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700' },
3826      h('div', { className: 'max-w-screen-2xl mx-auto px-4 md:px-6 lg:px-8' },
3827        h('div', { className: 'flex items-center justify-between h-16' },
3828          // Left: Back + Title
3829          h('div', { className: 'flex items-center gap-4 min-w-0' },
3830            h('a', {
3831              href: '/',
3832              onClick: (e) => { e.preventDefault(); navigateTo('/'); },
3833              className: 'flex items-center gap-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
3834            },
3835              h(Icons.ArrowLeft, { size: 20 }),
3836              h('span', { className: 'hidden sm:inline' }, 'Events')
3837            ),
3838            h('div', { className: 'min-w-0' },
3839              h('h1', { className: 'text-lg font-semibold text-gray-900 dark:text-white truncate' }, event.title),
3840              h('p', { className: 'text-sm text-gray-500 dark:text-gray-400' }, formatEventDate(event.eventDate))
3841            )
3842          ),
3843          // Right: Actions
3844          h('div', { className: 'flex items-center gap-2' },
3845            // Share dropdown
3846            h('div', { className: 'relative', ref: shareMenuRef },
3847              h('button', {
3848                onClick: () => setShowShareMenu(!showShareMenu),
3849                className: 'flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg'
3850              },
3851                h(Icons.Share2, { size: 16 }),
3852                h('span', { className: 'hidden sm:inline' }, 'Share')
3853              ),
3854              showShareMenu && h('div', { className: 'absolute right-0 mt-2 w-56 bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 py-1 z-50' },
3855                // Links section
3856                h('div', { className: 'px-3 py-1.5' },
3857                  h('span', { className: 'text-xs font-medium text-gray-400 dark:text-gray-500 uppercase tracking-wider' }, 'Links')
3858                ),
3859                h('button', {
3860                  onClick: () => copyShareLink('public'),
3861                  className: 'w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-3'
3862                },
3863                  h(Icons.Link, { size: 16, className: 'text-gray-400' }),
3864                  'Copy Public Link'
3865                ),
3866                // Email section
3867                h('div', { className: 'border-t border-gray-200 dark:border-gray-700 mt-1 pt-1' }),
3868                h('div', { className: 'px-3 py-1.5' },
3869                  h('span', { className: 'text-xs font-medium text-gray-400 dark:text-gray-500 uppercase tracking-wider' }, 'Email')
3870                ),
3871                h('button', {
3872                  onClick: () => copyEmailOverview(),
3873                  className: 'w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-3'
3874                },
3875                  h(Icons.Mail, { size: 16, className: 'text-gray-400 flex-shrink-0' }),
3876                  'Copy for Email'
3877                ),
3878                // Export section
3879                h('div', { className: 'border-t border-gray-200 dark:border-gray-700 mt-1 pt-1' }),
3880                h('div', { className: 'px-3 py-1.5' },
3881                  h('span', { className: 'text-xs font-medium text-gray-400 dark:text-gray-500 uppercase tracking-wider' }, 'Export PDF')
3882                ),
3883                h('button', {
3884                  onClick: exportFlyerPDF,
3885                  className: 'w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-3'
3886                },
3887                  h(Icons.FileText, { size: 16, className: 'text-gray-400' }),
3888                  'Download Flyer'
3889                ),
3890                h('button', {
3891                  onClick: exportAgendaPDF,
3892                  className: 'w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-3'
3893                },
3894                  h(Icons.List, { size: 16, className: 'text-gray-400' }),
3895                  'Download Agenda'
3896                )
3897              )
3898            ),
3899            // View public button
3900            h('a', {
3901              href: `/event/${eventId}`,
3902              target: '_blank',
3903              className: 'flex items-center gap-2 px-3 py-2 text-sm bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-200 rounded-lg'
3904            },
3905              h(Icons.ExternalLink, { size: 16 }),
3906              h('span', { className: 'hidden sm:inline' }, 'View')
3907            ),
3908            // Edit button (admin only)
3909            isAdmin && h('button', {
3910              onClick: () => onEditEvent && onEditEvent(event),
3911              className: 'flex items-center gap-2 px-3 py-2 text-sm bg-primary-600 hover:bg-primary-700 text-white rounded-full pill-btn'
3912            },
3913              h(Icons.Edit, { size: 16 }),
3914              h('span', { className: 'hidden sm:inline' }, 'Edit')
3915            )
3916          )
3917        ),
3918        // Tabs
3919        h('div', { className: 'flex gap-1 overflow-x-auto pb-px -mb-px' },
3920          tabs.map(tab =>
3921            h('button', {
3922              key: tab.id,
3923              onClick: () => setActiveTab(tab.id),
3924              className: `flex items-center gap-2 px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 transition-colors ${
3925                activeTab === tab.id
3926                  ? 'border-primary-600 text-primary-600 dark:text-primary-400'
3927                  : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
3928              }`
3929            },
3930              h(tab.icon, { size: 16 }),
3931              tab.label
3932            )
3933          )
3934        )
3935      )
3936    ),
3937
3938    // Message toast
3939    message && h('div', { className: 'fixed top-20 right-4 z-50' },
3940      h('div', {
3941        className: `px-4 py-3 rounded-lg shadow-lg ${
3942          message.type === 'success' ? 'bg-green-100 text-green-800 dark:bg-green-900/50 dark:text-green-400' :
3943          'bg-red-100 text-red-800 dark:bg-red-900/50 dark:text-red-400'
3944        }`
3945      },
3946        h('div', { className: 'flex items-center gap-2' },
3947          message.type === 'success' ? h(Icons.CheckCircle, { size: 16 }) : h(Icons.AlertCircle, { size: 16 }),
3948          message.text,
3949          h('button', { onClick: () => setMessage(null), className: 'ml-2' }, h(Icons.X, { size: 14 }))
3950        )
3951      )
3952    ),
3953
3954    // Content
3955    h('main', { className: 'max-w-screen-2xl mx-auto px-4 md:px-6 lg:px-8 py-6' },
3956      // Details Tab
3957      activeTab === 'details' && h('div', { className: 'space-y-6' },
3958        // Stats bar at top
3959        h('div', { className: 'bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm rounded-2xl border border-gray-200/60 dark:border-white/10 p-4' },
3960          h('div', { className: 'flex flex-wrap items-center gap-6' },
3961            h('div', { className: 'flex items-center gap-2' },
3962              h('span', {
3963                className: `inline-flex px-2.5 py-1 text-xs font-medium rounded-full ${
3964                  event.status === 'published' ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' :
3965                  event.status === 'draft' ? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400' :
3966                  'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400'
3967                }`
3968              }, event.status || 'draft')
3969            ),
3970            h('div', { className: 'h-6 w-px bg-gray-200 dark:bg-gray-700' }),
3971            h('div', { className: 'flex items-center gap-1' },
3972              h('span', { className: 'text-2xl font-bold text-gray-900 dark:text-white' }, visibleRegistrationCount),
3973              h('span', { className: 'text-gray-500 dark:text-gray-400' }, `/ ${event.capacity || 30} registered`)
3974            ),
3975            h('div', { className: 'flex items-center gap-1' },
3976              h('span', { className: 'text-2xl font-bold text-blue-600 dar
3976k:text-blue-400' }, totalInvitedCount),
3977              h('span', { className: 'text-gray-500 dark:text-gray-400' }, 'invited')
3978            ),
3979            h('div', { className: 'flex items-center gap-1' },
3980              h('span', { className: 'text-2xl font-bold text-amber-600 dark:text-amber-400' }, pendingRegistrations.length),
3981              h('span', { className: 'text-gray-500 dark:text-gray-400' }, 'requested')
3982            ),
3983            activeProspectCount > 0 && h('div', { className: 'flex items-center gap-1' },
3984              h('span', { className: 'text-2xl font-bold text-purple-600 dark:text-purple-400' }, activeProspectCount),
3985              h('span', { className: 'text-gray-500 dark:text-gray-400' }, 'prospects')
3986            )
3987          )
3988        ),
3989        // Main content
3990        h('div', { className: 'bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm rounded-2xl border border-gray-200/60 dark:border-white/10 p-6' },
3991          h('div', { className: 'space-y-6' },
3992            // Hero image
3993            (event.heroImage || event.bannerImage) && h('img', {
3994              src: event.heroImage || event.bannerImage,
3995              alt: event.title,
3996              className: 'w-full h-64 object-cover rounded-lg'
3997            }),
3998            // Description
3999            h('div', null,
4000              h('h3', { className: 'text-sm font-medium text-gray-500 dark:text-gray-400 mb-2' }, 'Description'),
4001              h('p', { className: 'text-gray-900 dark:text-white whitespace-pre-wrap' }, event.description || 'No description')
4002            ),
4003            // Location
4004            h('div', null,
4005              h('h3', { className: 'text-sm font-medium text-gray-500 dark:text-gray-400 mb-2' }, 'Location'),
4006              h('p', { className: 'text-gray-900 dark:text-white' },
4007                event.location?.isVirtual ? 'Online Event' : (event.location?.name || 'Location TBC')
4008              ),
4009              event.location?.address && h('p', { className: 'text-sm text-gray-500' }, event.location.address)
4010            ),
4011            // Agenda (collapsible)
4012            h('div', { className: 'border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden' },
4013              h('button', {
4014                onClick: () => setShowAgenda(!showAgenda),
4015                className: 'w-full px-4 py-3 flex items-center justify-between bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors'
4016              },
4017                h('div', { className: 'flex items-center gap-2' },
4018                  h(Icons.List, { size: 18, className: 'text-gray-500' }),
4019                  h('span', { className: 'font-medium text-gray-900 dark:text-white' }, 'Agenda'),
4020                  event.agenda && event.agenda.length > 0 && h('span', { className: 'text-sm text-gray-500 dark:text-gray-400' }, `(${event.agenda.length} items)`)
4021                ),
4022                h(Icons.ChevronDown, { size: 18, className: `text-gray-400 transition-transform ${showAgenda ? 'rotate-180' : ''}` })
4023              ),
4024              showAgenda && h('div', { className: 'p-4 border-t border-gray-200 dark:border-gray-700' },
4025                event.agenda && event.agenda.length > 0
4026                  ? h('div', { className: 'space-y-3' },
4027                      event.agenda.map((item, idx) =>
4028                        h('div', { key: idx, className: 'bg-gray-50 dark:bg-gray-700/30 rounded-lg p-4' },
4029                          h('div', { className: 'flex items-start gap-4' },
4030                            item.time && h('div', { className: 'text-sm font-semibold text-primary-600 dark:text-primary-400 whitespace-nowrap min-w-[60px]' }, item.time),
4031                            h('div', { className: 'flex-1' },
4032                              h('p', { className: 'font-medium text-gray-900 dark:text-white' }, item.title || `Session ${idx + 1}`),
4033                              item.description && h('p', { className: 'text-sm text-gray-500 dark:text-gray-400 mt-1' }, item.description),
4034                              item.speaker?.name && h('p', { className: 'text-sm text-primary-600 dark:text-primary-400 mt-2' }, `Speaker: ${item.speaker.name}`)
4035                            )
4036                          )
4037                        )
4038                      )
4039                    )
4040                  : h('p', { className: 'text-gray-500 dark:text-gray-400 italic' }, 'No agenda items yet')
4041              )
4042            ),
4043            // Speakers (collapsible)
4044            confirmedSpeakers.length > 0 && h('div', { className: 'border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden' },
4045              h('button', {
4046                onClick: () => setShowSpeakers(!showSpeakers),
4047                className: 'w-full px-4 py-3 flex items-center justify-between bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors'
4048              },
4049                h('div', { className: 'flex items-center gap-2' },
4050                  h(Icons.Users, { size: 18, className: 'text-gray-500' }),
4051                  h('span', { className: 'font-medium text-gray-900 dark:text-white' }, 'Speakers'),
4052                  h('span', { className: 'text-sm text-gray-500 dark:text-gray-400' }, `(${confirmedSpeakers.length})`)
4053                ),
4054                h(Icons.ChevronDown, { size: 18, className: `text-gray-400 transition-transform ${showSpeakers ? 'rotate-180' : ''}` })
4055              ),
4056              showSpeakers && h('div', { className: 'p-4 border-t border-gray-200 dark:border-gray-700' },
4057                h('div', { className: 'grid grid-cols-1 sm:grid-cols-2 gap-4' },
4058                  confirmedSpeakers.map((speaker, idx) =>
4059                    h('div', { key: idx, className: 'bg-gray-50 dark:bg-gray-700/30 rounded-lg p-4 flex items-center gap-4' },
4060                      speaker.photo
4061                        ? h('img', { src: speaker.photo, alt: speaker.name, className: 'w-14 h-14 rounded-full object-cover' })
4062                        : h('div', { className: 'w-14 h-14 rounded-full bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center text-primary-600 dark:text-primary-400 font-bold text-lg' },
4063                            (speaker.name || 'S')[0].toUpperCase()
4064                          ),
4065                      h('div', null,
4066                        h('p', { className: 'font-medium text-gray-900 dark:text-white' }, speaker.name || 'Speaker'),
4067                        speaker.title && h('p', { className: 'text-sm text-gray-500 dark:text-gray-400' }, speaker.title),
4068                        speaker.company && h('p', { className: 'text-sm text-primary-600 dark:text-primary-400' }, speaker.company)
4069                      )
4070                    )
4071                  )
4072                )
4073              )
4074            )
4075          )
4076        )
4077      ),
4078
4079      // Import attendees modal (admin) — register a pasted list, ready for badges.
4080      // Top-level (tab-independent) so it shows from the Registrations tab button.
4081      showAttendeeImport && h(AttendeeImportModal, {
4082        eventId,
4083        existingRegistrations: registrations,
4084        registeredBy: user?.email || '',
4085        onClose: () => setShowAttendeeImport(false),
4086        onImported: (n) => setMessage({ type: 'success', text: `Registered ${n} attendee${n === 1 ? '' : 's'} — export badges from the list above.` })
4087      }),
4088
4089      // BULK SEND CONFIRMATION. There is no undo on email, so the count, the recipients and the
4090      // wording of what they will receive are all shown before anything is sent - and anybody the
4091      // selection cannot reach is named with the reason, rather than silently dropped from a total.
4092      bulkSend && h('div', { className: 'fixed inset-0 bg-black/50 flex items-center justify-center z-[10000] p-4' },
4093        h('div', { className: 'bg-white dark:bg-gray-800 rounded-2xl shadow-xl max-w-lg w-full p-6' },
4094          h('h3', { className: 'text-lg font-semibold text-gray-900 dark:text-white mb-1' }, bulkSend.kind.label),
4095          h('p', { className: 'text-sm text-gray-500 dark:text-gray-400 mb-4' },
4096            'Each person gets: ', h('span', { className: 'text-gray-700 dark:text-gray-300' }, bulkSend.kind.describes), '.'),
4097          h('p', { className: 'text-sm text-gray-700 dark:text-gray-300 mb-3' },
4098            h('strong', null, `${bulkSend.sendable.length} ${bulkSend.sendable.length === 1 ? 'person' : 'people'}`),
4099            ' will be emailed. This cannot be undone.'),
4100          bulkSend.sendable.length > 0 && h('div', { className: 'max-h-40 overflow-y-auto rounded-xl border border-gray-200 dark:border-gray-700 p-3 mb-3 text-xs text-gray-600 dark:text-gray-400 space-y-0.5' },
4101            bulkSend.sendable.map((r) => h('div', { key: r.id }, `${r.userName || 'Unnamed'} — ${r.userEmail}`))
4102          ),
4103          bulkSend.excluded.length > 0 && h('div', { className: 'rounded-xl bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 p-3 mb-4 text-xs text-amber-800 dark:text-amber-300' },
4104            h('p', { className: 'font-medium mb-1' }, `${bulkSend.excluded.length} ticked ${bulkSend.excluded.length === 1 ? 'row is' : 'rows are'} not being emailed:`),
4105            bulkSend.excluded.map((e, i) => h('div', { key: e.reg.id || i }, `${e.reg.userName || e.reg.userEmail || 'Unnamed'} — ${e.reason}`))
4106          ),
4107          h('div', { className: 'flex justify-end gap-2' },
4108            h('button', {
4109              onClick: () => setBulkSend(null),
4110              className: 'px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg'
4111            }, 'Cancel'),
4112            h('button', {
4113              onClick: () => runBulkSend(bulkSend.kind.id, bulkSend.sendable),
4114              disabled: bulkSend.sendable.length === 0,
4115              className: 'px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 rounded-lg disabled:opacity-50'
4116            }, `Send to ${bulkSend.sendable.length}`)
4117          )
4118        )
4119      ),
4120
4121      // Registrations Tab
4122      activeTab === 'registrations' && h('div', { className: 'space-y-4' },
4123        // Pending Registration Requests Banner
4124        pendingRegistrations.length > 0 && h('div', { className: 'bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg overflow-hidden' },
4125          h('div', { className: 'p-4 flex items-center justify-between cursor-pointer hover:bg-amber-100/50 dark:hover:bg-amber-900/30 transition-colors', onClick: () => setShowPendingRequests(!showPendingRequests) },
4126            h('div', { className: 'flex items-center gap-3' },
4127              h('div', { className: 'w-10 h-10 rounded-full bg-amber-200 dark:bg-amber-800 flex items-center justify-center' },
4128                h(Icons.UserPlus, { size: 20, className: 'text-amber-700 dark:text-amber-300' })
4129              ),
4130              h('div', null,
4131                h('h4', { className: 'font-semibold text-amber-800 dark:text-amber-200' }, `${pendingRegistrations.length}
4131 Registration Request${pendingRegistrations.length !== 1 ? 's' : ''}`),
4132                h('p', { className: 'text-sm text-amber-600 dark:text-amber-400' }, 'People waiting for approval to attend this event')
4133              )
4134            ),
4135            h('div', { className: 'flex items-center gap-2' },
4136              h('span', { className: 'text-sm text-amber-600 dark:text-amber-400' }, showPendingRequests ? 'Hide' : 'Review'),
4137              h(showPendingRequests ? Icons.ChevronUp : Icons.ChevronDown, { size: 20, className: 'text-amber-600 dark:text-amber-400' })
4138            )
4139          ),
4140          // Expandable list of pending requests
4141          showPendingRequests && h('div', { className: 'border-t border-amber-200 dark:border-amber-800 divide-y divide-amber-200 dark:divide-amber-800' },
4142            pendingRegistrations.map(pending =>
4143              h('div', { key: pending.id, className: 'p-4 flex items-center justify-between bg-white/50 dark:bg-gray-800/50' },
4144                h('div', { className: 'flex items-center gap-3 flex-1 min-w-0' },
4145                  h('div', { className: 'w-10 h-10 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center text-amber-600 dark:text-amber-400 font-medium flex-shrink-0' },
4146                    `${pending.firstName?.[0] || ''}${pending.lastName?.[0] || ''}`.toUpperCase() || '?'
4147                  ),
4148                  h('div', { className: 'min-w-0' },
4149                    h('p', { className: 'font-medium text-gray-900 dark:text-white' }, `${pending.firstName || ''} ${pending.lastName || ''}`.trim() || 'Unknown'),
4150                    h('p', { className: 'text-sm text-gray-500 dark:text-gray-400 truncate' }, pending.email || 'No email'),
4151                    h('div', { className: 'flex flex-wrap gap-x-3 gap-y-0.5 mt-1' },
4152                      (pending.phone || pending.mobile) && h('span', { className: 'text-xs text-gray-400 dark:text-gray-500' }, pending.phone || pending.mobile),
4153                      (pending.organisation || pending.company) && h('span', { className: 'text-xs text-gray-400 dark:text-gray-500' }, pending.organisation || pending.company),
4154                      pending.jobTitle && h('span', { className: 'text-xs text-gray-400 dark:text-gray-500' }, pending.jobTitle)
4155                    )
4156                  )
4157                ),
4158                h('div', { className: 'flex items-center gap-2 flex-shrink-0 ml-4' },
4159                  h('button', {
4160                    onClick: () => handleApprovePending(pending),
4161                    disabled: approvingId === pending.id,
4162                    className: 'px-3 py-1.5 text-sm bg-green-600 hover:bg-green-700 text-white rounded-lg disabled:opacity-50 flex items-center gap-1'
4163                  },
4164                    h(Icons.Check, { size: 14 }),
4165                    approvingId === pending.id ? 'Approving...' : 'Approve'
4166                  ),
4167                  h('button', {
4168                    onClick: () => handleRejectPending(pending),
4169                    disabled: rejectingId === pending.id,
4170                    className: 'px-3 py-1.5 text-sm bg-red-600 hover:bg-red-700 text-white rounded-lg disabled:opacity-50 flex items-center gap-1'
4171                  },
4172                    h(Icons.X, { size: 14 }),
4173                    rejectingId === pending.id ? 'Rejecting...' : 'Reject'
4174                  )
4175                )
4176              )
4177            )
4178          )
4179        ),
4180
4181        // Registrations List
4182        // Header bar — relative z-30 so the Export Badges dropdown paints above the table card
4183        // below it (both cards use backdrop-blur, which creates competing stacking contexts).
4184        h('div', { className: 'relative z-30 bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm rounded-2xl border border-gray-200/60 dark:border-white/10 p-4' },
4185          // Status pills
4186          registrations.length > 0 && h('div', { className: 'flex flex-wrap gap-2 mb-3' },
4187            h('button', {
4188              onClick: () => setRegistrationStatusFilter('all'),
4189              className: `px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${
4190                registrationStatusFilter === 'all'
4191                  ? 'bg-gray-900 text-white dark:bg-white dark:text-gray-900'
4192                  : 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
4193              }`
4194            }, `All (${statusCounts.holdingASeat})`),
4195            // THREE WORDS FOR THREE FACTS (owner decision 2026-09-19, third word revised
4196            // 2026-09-21). "Added by staff" is a seat somebody created FOR the person;
4197            // "User-registered" is one they took themselves;
4198            // "Confirmed" is ONLY a yes to the "are you still coming?" email or a rep marking it
4199            // by hand. The team read the old "Confirmed" as the third of those when it meant the
4200            // second, and the fix is to stop one word doing two jobs. The rule is seat-label.js.
4201            statusCounts.staffAdded > 0 && h('button', {
4202              onClick: () => setRegistrationStatusFilter('staffAdded'),
4203              className: `px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${
4204                registrationStatusFilter === 'staffAdded'
4205                  ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
4206                  : 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 opacity-70 hover:opacity-100'
4207              }`
4208            }, `${SEAT_LABELS.staffAdded} (${statusCounts.staffAdded})`),
4209            statusCounts.selfRegistered > 0 && h('button', {
4210              onClick: () => setRegistrationStatusFilter('selfRegistered'),
4211              className: `px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${
4212                registrationStatusFilter === 'selfRegistered'
4213                  ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
4214                  : 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 opacity-70 hover:opacity-100'
4215              }`
4216            }, `${SEAT_LABELS.selfRegistered} (${statusCounts.selfRegistered})`),
4217            statusCounts.confirmedByReply > 0 && h('button', {
4218              onClick: () => setRegistrationStatusFilter('confirmedByReply'),
4219              className: `px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${
4220                registrationStatusFilter === 'confirmedByReply'
4221                  ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
4222                  : 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 opacity-70 hover:opacity-100'
4223              }`
4224            }, `${SEAT_LABELS.confirmedByReply} (${statusCounts.confirmedByReply})`),
4225            awaitingAnswerCount > 0 && h('span', {
4226              className: 'px-3 py-1.5 text-sm text-gray-500 dark:text-gray-400',
4227              title: 'Invited and not yet answered. These people do not hold a place: an invitation no longer reserves capacity.'
4228            }, `${awaitingAnswerCount} invited, no answer yet`),
4229            statusCounts.declined > 0 && h('button', {
4230              onClick: () => setRegistrationStatusFilter('declined'),
4231              className: `px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${
4232                registrationStatusFilter === 'declined'
4233                  ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
4234                  : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 opacity-70 hover:opacity-100'
4235              }`
4236            }, `Declined (${statusCounts.declined})`),
4237            // Violet, matching the pill on the rows it selects, so the chip and the rows read as
4238            // one thing. Deliberately last of the filters: it is not a seat state.
4239            remindedCount > 0 && h('button', {
4240              onClick: () => setRegistrationStatusFilter('reminderSent'),
4241              title: 'Everyone we have sent a confirm/decline reminder to, whatever they have since said',
4242              className: `px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${
4243                registrationStatusFilter === 'reminderSent'
4244                  ? 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300'
4245                  : 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300 opacity-70 hover:opacity-100'
4246              }`
4247            }, `Reminder sent (${remindedCount})`),
4248            statusCounts.attended > 0 && h('button', {
4249              onClick: () => setRegistrationStatusFilter('attended'),
4250              className: `px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${
4251                registrationStatusFilter === 'attended'
4252                  ? 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400'
4253                  : 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400 opacity-70 hover:opacity-100'
4254              }`
4255            }, `${SEAT_LABELS.attended} (${statusCounts.attended})`)
4256          ),
4257          h('div', { className: 'flex flex-col sm:flex-row items-start sm:items-center gap-3' },
4258            // Search
4259            h('div', { className: 'relative flex-1 w-full' },
4260              h('input', {
4261                type: 'text',
4262                value: registrationSearch,
4263                onChange: (e) => setRegistrationSearch(e.target.value),
4264                placeholder: 'Search registrations by name, email, or company...',
4265                className: 'w-full pl-9 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-primary-500'
4266              }),
4267              h('div', { className: 'absolute left-3 top-1/2 -translate-y-1/2 text-gray-400' },
4268                h(Icons.Search, { size: 16 })
4269              )
4270            ),
4271            // Quick-add a single last-minute attendee (admin)
4272            isAdmin && h('button', {
4273              onClick: () => setShowQuickAdd(v => !v),
4274              className: `flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors whitespace-nowrap ${showQuickAdd ? 'bg-primary-700 text-white' : 'bg-primary-600 hover:bg-primary-700 text-white'}`,
4275              title: 'Add a single attendee now'
4276            },
4277              h(Icons.Plus, { size: 16 }),
4278              'Add attendee'
4279            ),
4280            // Import attendees (admin) — register a pasted list in one go, ready for badges
4281            isAdmin && h('button', {
4282              onClick: () => setShowAttendeeImport(true),
4283              className: 'flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors whitespace-nowrap',
4284              title: 'Import a list of attendees as registrations'
4285            },
4286              h(Icons.Upload, { size: 16 }),
4287              'Import Attendees'
4288            ),
4289            // Send to the ticked attendees. Two kinds: the "you're registered" confirmation, and the
4290            // "please confirm your attendance" email carrying the Yes / can't-make-it buttons.
4291            // Both already existed one row at a time in the three-dot menu.
4292            selectedRegistrations.length > 0 && h('button', {
4293              onClick: () => setBulkSend({ kind: SEND_KINDS.reminder, ...partitionForBulkSend(selectedRegistrations) }),
4294              disabled: Boolean(bulkProgress),
4295              className: 'flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 rounded-lg transition-colors disabled:opacity-50 whitespace-nowrap',
4296              title: 'Ask the ticked attendees to confirm or decline, with the calendar invite attached'
4297            },
4298              bulkProgress
4299                ? h('div', { className: 'w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin' })
4300                : h(Icons.Bell, { size: 16 }),
4301              bulkProgress ? `Sending ${bulkProgress.current}/${bulkProgress.total}` : `Confirm/decline (${selectedRegistrations.length})`
4302            ),
4303            selectedRegistrations.length > 0 && h('button', {
4304              onClick: () => setBulkSend({ kind: SEND_KINDS.confirmation, ...partitionForBulkSend(selectedRegistrations) }),
4305              disabled: Boolean(bulkProgress),
4306              className: 'flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors disabled:opacity-50 whitespace-nowrap',
4307              title: "Send the ticked attendees a \"you're registered\" email with the calendar invite"
4308            },
4309              h(Icons.Mail, { size: 16 }),
4310              `Confirmation (${selectedRegistrations.length})`
4311            ),
4312            // Print badges for just the ticked attendees (adaptive A4, up to 6 per sheet)
4313            selectedRegistrations.length > 0 && h('button', {
4314              onClick: () => exportBadgesA4(selectedRegistrations),
4315              disabled: exportingBadges,
4316              className: 'flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-emerald-600 hover:bg-emerald-700 rounded-lg transition-colors disabled:opacity-50 whitespace-nowrap',
4317              title: 'Print badges for the ticked attendees, 6 per A4 sheet'
4318            },
4319              h(Icons.FileText, { size: 16 }),
4320              `Print selected (${selectedRegistrations.length})`
4321            ),
4322            // Export Badges dropdown
4323            registrations.length > 0 && h('div', { className: 'relative', ref: exportMenuRef },
4324              h('button', {
4325                onClick: () => setShowExportMenu(!showExportMenu),
4326                disabled: exportingBadges,
4327                className: 'flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors disabled:opacity-50 whitespace-nowrap',
4328                title: 'Export badges as PDF'
4329              },
4330                exportingBadges
4331                  ? h(React.Fragment, null,
4332                      h('div', { className: 'w-4 h-4 border-2 border-gray-400/30 border-t-gray-600 rounded-full animate-spin' }),
4333                      `Exporting ${badgeExportProgress.current}/${badgeExportProgress.total}...`
4334                    )
4335                  : h(React.Fragment, null,
4336                      h(Icons.FileText, { size: 16 }),
4337                      'Export Badges',
4338                      h(Icons.ChevronDown, { size: 14 })
4339                    )
4340              ),
4341              showExportMenu && h('div', { className: 'absolute right-0 top-full mt-1 w-64 bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 py-1 z-20' },
4342                h('p', { className: 'px-4 pt-1 pb-0.5 text-[11px] font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500' }, 'One per page (80×50mm)'),
4343                h('button', {
4344                  onClick: () => { setShowExportMenu(false); exportAllBadgesPDF('confirmed'); },
4345                  className: 'w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2'
4346                },
4347                  h(Icons.CheckCircle, { size: 16, className: 'text-blue-500' }),
4348                  `Confirmed Only (${statusCounts.confirmedByReply})`
4349                ),
4350                h('button', {
4351                  onClick: () => { setShowExportMenu(false); exportAllBadgesPDF('all'); },
4352                  className: 'w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2'
4353                },
4354                  h(Icons.Users, { size: 16, className: 'text-gray-500' }),
4355                  `All Registrations (${statusCounts.holdingASeat})`
4356                ),
4357                h('div', { className: 'my-1 border-t border-gray-200 dark:border-gray-700' }),
4358                h('p', { className: 'px-4 pt-1 pb-0.5 text-[11px] font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500' }, '6 per A4 sheet (to cut)'),
4359                h('button', {
4360                  onClick: () => { setShowExportMenu(false); exportBadgesA4(null, 'all'); },
4361                  className: 'w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2'
4362                },
4363                  h(Icons.FileText, { size: 16, className: 'text-gray-500' }),
4364                  `All - 6 per A4 (${statusCounts.holdingASeat})`
4365                )
4366              )
4367            )
4368          )
4369        ),
4370
4371        // Quick-add last-minute attendee (admin) — collapsible panel
4372        showQuickAdd && isAdmin && h('div', { className: 'relative z-20 bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm rounded-2xl border border-primary-200 dark:border-primary-900/40 p-4' },
4373          h('div', { className: 'flex items-center justify-between mb-3' },
4374            h('h4', { className: 'text-sm font-semibold text-gray-800 dark:text-gray-200' }, 'Add attendee'),
4375            h('span', { className: 'text-xs text-gray-500 dark:text-gray-400' }, 'Registers them now (no email) and ticks them for printing')
4376          ),
4377          h('div', { className: 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 ga
4377p-2' },
4378            h('input', { value: qa.firstName, onChange: (e) => setQa({ ...qa, firstName: e.target.value }), placeholder: 'First name', 'aria-label': 'First name', className: 'w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500' }),
4379            h('input', { value: qa.lastName, onChange: (e) => setQa({ ...qa, lastName: e.target.value }), placeholder: 'Last name', 'aria-label': 'Last name', className: 'w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500' }),
4380            h('input', { value: qa.company, onChange: (e) => setQa({ ...qa, company: e.target.value }), placeholder: 'Company', 'aria-label': 'Company', className: 'w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500' }),
4381            h('input', { value: qa.email, type: 'email', onChange: (e) => setQa({ ...qa, email: e.target.value }), onKeyDown: (e) => e.key === 'Enter' && handleQuickAddAttendee(), placeholder: 'Email', 'aria-label': 'Email', className: 'w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500' }),
4382            h('button', { onClick: handleQuickAddAttendee, disabled: quickAdding, className: 'px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 rounded-lg disabled:opacity-50 whitespace-nowrap' }, quickAdding ? 'Adding…' : 'Add')
4383          )
4384        ),
4385
4386        // Registrations table
4387        h('div', { className: 'bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm rounded-2xl border border-gray-200/60 dark:border-white/10 overflow-hidden' },
4388          registrations.length === 0
4389            ? h('div', { className: 'p-8 text-center text-gray-500 dark:text-gray-400' }, 'No registrations yet')
4390            : filteredRegistrations.length === 0
4391              ? h('div', { className: 'p-8 text-center text-gray-500 dark:text-gray-400' }, 'No registrations match your search')
4392              : h('div', { className: 'overflow-x-auto' },
4393                  h('table', { className: 'w-full' },
4394                    h('thead', null,
4395                      h('tr', { className: 'border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/50' },
4396                        h('th', { className: 'pl-4 pr-1 py-3 w-10' },
4397                          h('input', {
4398                            type: 'checkbox',
4399                            'aria-label': 'Select all for printing',
4400                            checked: filteredRegistrations.length > 0 && filteredRegistrations.every(r => selectedRegIds.has(r.id)),
4401                            onChange: (e) => setSelectedRegIds(prev => {
4402                              const n = new Set(prev);
4403                              if (e.target.checked) filteredRegistrations.forEach(r => n.add(r.id));
4404                              else filteredRegistrations.forEach(r => n.delete(r.id));
4405                              return n;
4406                            }),
4407                            className: 'h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 cursor-pointer'
4408                          })
4409                        ),
4410                        h('th', { className: 'px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase w-12' }, '#'),
4411                        h('th', { className: 'px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase' }, 'Name'),
4412                        h('th', { className: 'px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase hidden md:table-cell' }, 'Company'),
4413                        h('th', { className: 'px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase hidden lg:table-cell' }, 'Contact'),
4414                        h('th', { className: 'px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase' }, 'Status'),
4415                        h('th', { className: 'px-4 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase hidden lg:table-cell' }, 'Badge'),
4416                        h('th', { className: 'px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase' }, 'Actions')
4417                      )
4418                    ),
4419                    h('tbody', { className: 'divide-y divide-gray-200 dark:divide-gray-700' },
4420                      filteredRegistrations.map((reg, index) => {
4421                        // ONE definition per row, read by both the spinner and the pill it
4422                        // replaces. Written twice before, which nothing stopped from drifting
4423                        // apart into a row showing both at once.
4424                        const rowIsSending = resendingConfirmation === reg.id
4425                          || sendingAttendanceReminder === reg.id
4426                          || bulkSendingId === reg.id;
4427                        return h('tr', { key: reg.id, className: `hover:bg-gray-50 dark:hover:bg-gray-700/30 ${selectedRegIds.has(reg.id) ? 'bg-primary-50/50 dark:bg-primary-900/10' : ''}` },
4428                          // Select for printing
4429                          h('td', { className: 'pl-4 pr-1 py-3' },
4430                            h('input', {
4431                              type: 'checkbox',
4432                              'aria-label': `Select ${reg.userName || reg.userEmail || 'attendee'} for printing`,
4433                              checked: selectedRegIds.has(reg.id),
4434                              onChange: () => toggleRegSelected(reg.id),
4435                              className: 'h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 cursor-pointer'
4436                            })
4437                          ),
4438                          // Badge number
4439                          h('td', { className: 'px-4 py-3' },
4440                            editingBadgeNumber === reg.id
4441                              ? h('div', { className: 'flex items-center gap-1' },
4442                                  h('input', {
4443                                    type: 'number',
4444                                    value: badgeNumberInput,
4445                                    onChange: (e) => setBadgeNumberInput(e.target.value),
4446                                    onKeyDown: (e) => {
4447                                      if (e.key === 'Enter') updateBadgeNumber(reg, badgeNumberInput);
4448                                      if (e.key === 'Escape') { setEditingBadgeNumber(null); setBadgeNumberInput(''); }
4449                                    },
4450                                    autoFocus: true,
4451                                    min: 1,
4452                                    className: 'w-12 h-8 text-center text-xs font-semibold border border-primary-300 dark:border-primary-600 rounded-full bg-white dark:bg-gray-700 text-primary-600 dark:text-primary-400 focus:ring-2 focus:ring-primary-500'
4453                                  }),
4454                                  h('button', {
4455                                    onClick: () => updateBadgeNumber(reg, badgeNumberInput),
4456                                    disabled: savingBadgeNumber === reg.id,
4457                                    className: 'p-0.5 text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20 rounded'
4458                                  }, savingBadgeNumber === reg.id
4459                                    ? h('div', { className: 'w-3 h-3 border-2 border-green-300 border-t-green-600 rounded-full animate-spin' })
4460                                    : h(Icons.Check, { size: 12 })
4461                                  ),
4462                                  h('button', {
4463                                    onClick: () => { setEditingBadgeNumber(null); setBadgeNumberInput(''); },
4464                                    className: 'p-0.5 text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded'
4465                                  }, h(Icons.X, { size: 12 }))
4466                                )
4467                              : h('button', {
4468                                  onClick: () => { setEditingBadgeNumber(reg.id); setBadgeNumberInput(String(reg.badgeNumber || index + 1)); },
4469                                  className: 'w-8 h-8 rounded-full bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center text-primary-600 dark:text-primary-400 font-semibold text-xs hover:ring-2 hover:ring-primary-300 dark:hover:ring-primary-600 transition-all cursor-pointer',
4470                                  title: 'Click to edit badge number'
4471                                },
4472                                  reg.badgeNumber || index + 1
4473                                )
4474                          ),
4475                          // Name
4476                          h('td', { className: 'px-4 py-3' },
4477                            h('div', { className: 'font-medium text-gray-900 dark:text-white text-sm' }, reg.userName || 'Unknown'),
4478                            h('div', { className: 'text-xs text-gray-500 dark:text-gray-400 md:hidden' }, reg.organisation || reg.company || '')
4479                          ),
4480                          // Company
4481                          h('td', { className: 'px-4 py-3 hidden md:table-cell' },
4482                            h('div', { className: 'text-sm text-gray-600 dark:text-gray-300' }, reg.organisation || reg.company || '-'),
4483                            reg.jobTitle && h('div', { className: 'text-xs text-gray-400 dark:text-gray-500' }, reg.jobTitle)
4484                          ),
4485                          // Contact
4486                          h('td', { className: 'px-4 py-3 hidden lg:table-cell' },
4487                            h('div', { className: 'text-sm text-gray-600 dark:text-gray-300' }, reg.userEmail || '-'),
4488                            (reg.phone || reg.userPhone || reg.mobile || memberPhones[(reg.userEmail || '').toLowerCase()]) &&
4489                              h('div', { className: 'text-xs text-gray-500 dark:text-gray-400' },
4490                                reg.phone || reg.userPhone || reg.mobile || memberPhones[(reg.userEmail || '').toLowerCase()]
4491                              )
4492                          ),
4493                          // Status, and for a seat nobody asked for, WHO PUT THEM ON THE LIST.
4494                          // A `registered` seat means staff created it (the member's own act
4495                          // writes `confirmed`), so the row is a person who may not know they
4496                          // are registered - and the useful question is who added them. Measured
4497                          // on the Ventilation event: 3 of 5 were converted by somebody other
4498                          // than the prospect's own owner, who would not otherwise know.
4499                          h('td', { className: 'px-4 py-3' },
4500                            (() => {
4501                              const { kind, label } = seatLabel(reg);
4502                              const tone = kind === 'attended' ? 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400'
4503                                : kind === 'confirmedByReply' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
4504                                : kind === 'declined' || kind === 'cancelled' ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
4505                                : kind === 'staffAdded' ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
4506                                : kind === 'waitlisted' ? 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
4507                                : 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400';
4508                              const when = hasConfirmedAttendance(reg) && reg.confirmedAt?.toDate
4509                                ? reg.confirmedAt.toDate().toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }) : null;
4510                              return h('span', {
4511                                className: `text-xs font-medium px-2 py-1 rounded-full ${tone}`,
4512                                title: when
4513                                  ? `Confirmed ${when}${reg.confirmedBy ? ` by ${staffNameFromEmail(reg.confirmedBy) || 'a member of staff'}` : ' by email reply'}`
4514                                  : undefined
4515                              }, label);
4516                            })(),
4517                            (() => {
4518                              const note = manualAddNote(reg);
4519                              return note && h('p', {
4520                                className: 'text-xs text-gray-500 dark:text-gray-400 mt-1 leading-tight',
4521                                title: note.detail
4522                              }, note.text);
4523                            })(),
4524                            // WHAT WE HAVE EMAILED THIS SEAT. Distinct from the status badge above,
4525                            // which says what THEY have told us - the two are different facts and
4526                            // the list showed only one of them. Violet, deliberately not green or
4527                            // blue: those already mean "confirmed attendance" on this very row, and
4528                            // a second green pill reading "Confirmation sent" beside a status
4529                            // reading "User-registered" is exactly the confusion the seat-label
4530                            // rules were written to remove.
4531                            //
4532                            // NO PILL WHEN NOTHING IS RECORDED, never a "not emailed" one -
4533                            // confirmationSentAt is new and most existing rows were emailed before
4534                            // it existed, so claiming otherwise would be false on the screen used
4535                            // to decide who still needs chasing.
4536                            // SENDING, ON THE LINE THE PILL WILL APPEAR ON. Until this, a send
4537                            // showed only as a spinner inside the row menu (which closes on click)
4538                            // or as a counter in the toolbar, so on a bulk run nothing on the rows
4539                            // themselves moved and there was no way to see which person was being
4540                            // written to. It occupies the pill's own slot, so the row does not jump
4541                            // when the real pill replaces it.
4542                            rowIsSending && h('p', {
4543                                className: 'inline-flex items-center gap-1.5 mt-1 text-[11px] font-medium px-2 py-0.5 rounded-full bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300',
4544                                title: 'Sending now'
4545                              },
4546                                h('span', { className: 'inline-block w-2.5 h-2.5 border-2 border-violet-400/40 border-t-violet-600 dar
4546k:border-violet-500/40 dark:border-t-violet-300 rounded-full animate-spin' }),
4547                                'Sending'
4548                              ),
4549                            (() => {
4550                              // Hidden while a send is in flight, so the OLD pill does not sit
4551                              // beside the spinner saying "sent 3d ago" about the previous send.
4552                              if (rowIsSending) return null;
4553                              // The active filter's own kind wins over recency, so a row selected
4554                              // by "Reminder sent" states the reminder rather than a later
4555                              // confirmation. Found in review: the two could disagree.
4556                              const sent = latestSend(reg, registrationStatusFilter === 'reminderSent' ? 'reminder' : null);
4557                              return sent && h('p', {
4558                                className: 'inline-flex items-center gap-1 mt-1 text-[11px] font-medium px-2 py-0.5 rounded-full bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300',
4559                                title: sendHistoryTitle(reg)
4560                              }, `${sent.label} sent ${formatTimeAgo(sent.at)}`);
4561                            })()
4562                          ),
4563                          // Badge checkboxes
4564                          h('td', { className: 'px-4 py-3 hidden lg:table-cell' },
4565                            h('div', { className: 'flex items-center justify-center gap-3' },
4566                              h('button', {
4567                                onClick: () => printSingleBadge(reg),
4568                                disabled: printingBadge === reg.id,
4569                                title: 'Print badge',
4570                                className: 'p-1 text-gray-400 hover:text-primary-600 hover:bg-primary-50 dark:hover:bg-primary-900/20 rounded transition-colors disabled:opacity-50'
4571                              },
4572                                printingBadge === reg.id
4573                                  ? h('div', { className: 'w-4 h-4 border-2 border-primary-300 border-t-primary-600 rounded-full animate-spin' })
4574                                  : h(Icons.Printer, { size: 15 })
4575                              ),
4576                              h('label', { className: 'flex items-center gap-1 cursor-pointer', title: 'Badge printed' },
4577                                h('input', {
4578                                  type: 'checkbox',
4579                                  checked: !!reg.badgePrinted,
4580                                  onChange: () => togglePrintedBadge(reg),
4581                                  className: 'w-3.5 h-3.5 text-primary-600 border-gray-300 rounded focus:ring-primary-500 cursor-pointer'
4582                                }),
4583                                h('span', { className: 'text-xs text-gray-500 dark:text-gray-400' }, 'Printed')
4584                              ),
4585                              h('label', { className: 'flex items-center gap-1 cursor-pointer', title: 'Tested' },
4586                                h('input', {
4587                                  type: 'checkbox',
4588                                  checked: !!reg.tested,
4589                                  onChange: () => toggleTested(reg),
4590                                  className: 'w-3.5 h-3.5 text-primary-600 border-gray-300 rounded focus:ring-primary-500 cursor-pointer'
4591                                }),
4592                                h('span', { className: 'text-xs text-gray-500 dark:text-gray-400' }, 'Tested')
4593                              )
4594                            )
4595                          ),
4596                          // Actions
4597                          h('td', { className: 'px-4 py-3 text-right' },
4598                            h('div', { className: 'flex items-center justify-end gap-1' },
4599                              // Check-in / Undo button (primary action, stays visible)
4600                              (reg.status === 'registered' || reg.status === 'confirmed' || reg.status === 'attended') && h('button', {
4601                                onClick: () => toggleCheckIn(reg),
4602                                disabled: checkingInId === reg.id,
4603                                className: `inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-lg transition-colors disabled:opacity-50 ${
4604                                  reg.status === 'attended'
4605                                    ? 'text-gray-700 bg-gray-100 hover:bg-gray-200 dark:text-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600'
4606                                    : 'text-white bg-green-600 hover:bg-green-700'
4607                                }`
4608                              },
4609                                checkingInId === reg.id
4610                                  ? h('div', { className: 'w-3.5 h-3.5 border-2 border-current/30 border-t-current roun
4610ded-full animate-spin' })
4611                                  : reg.status === 'attended' ? h(Icons.X, { size: 13 }) : h(Icons.Check, { size: 13 }),
4612                                reg.status === 'attended' ? 'Undo' : 'Check In'
4613                              ),
4614                              // More actions dropdown
4615                              h('div', { className: 'relative', 'data-action-menu': true },
4616                                h('button', {
4617                                  onClick: (e) => openActionMenu(e, reg.id, setOpenRegAction, setOpenProspectAction, regMenuItemCount(reg)),
4618                                  className: 'p-1.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors'
4619                                }, h(Icons.MoreVertical, { size: 16 })),
4620                                // Portalled to document.body. The table card wrapping this row carries
4621                                // both backdrop-blur-sm and overflow-hidden: backdrop-filter makes that
4622                                // card a containing block for position:fixed descendants, so the viewport
4623                                // coordinates from openActionMenu were measured from the card instead,
4624                                // and overflow-hidden then clipped the menu away entirely.
4625                                openRegAction?.id === reg.id && ReactDOM.createPortal(h('div', {
4626                                  'data-action-menu': true,
4627                                  className: 'fixed w-48 bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 py-1 z-[9999]',
4628                                  style: { top: openRegAction.top, bottom: openRegAction.bottom, right: openRegAction.right,
4629                                    maxHeight: openRegAction.maxHeight, overflowY: 'auto' }
4630                                },
4631                                  // Edit
4632                                  h('button', {
4633                                    onClick: () => {
4634                                      setEditingRegistration(reg);
4635                                      setEditRegData({ userName: reg.userName || '', jobTitle: reg.jobTitle || '', organisation: reg.organisation || '' });
4636                                      setOpenRegAction(null);
4637                                    },
4638                                    className: 'w-full flex items-center gap-2.5 px-3 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50'
4639                                  }, h(Icons.Edit, { size: 14, className: 'text-gray-400' }), 'Edit Details'),
4640                                  // Resend confirmation
4641                                  h('button', {
4642                                    onClick: () => { resendConfirmationEmail(reg); setOpenRegAction(null); },
4643                                    disabled: resendingConfirmation === reg.id,
4644                                    className: 'w-full flex items-center gap-2.5 px-3 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50 disabled:opacity-50'
4645                                  },
4646                                    resendingConfirmation === reg.id
4647                                      ? h('div', { className: 'w-3.5 h-3.5 border-2 border-primary-500/30 border-t-primary-500 rounded-full animate-spin' })
4648                                      : h(Icons.Mail, { size: 14, className: hasBeenEmailed(reg, 'confirmation') ? 'text-violet-500' : 'text-gray-400' }),
4649                                    hasBeenEmailed(reg, 'confirmation') ? 'Resend Confirmation' : 'Send Confirmation'
4650                                  ),
4651                                  // Attendance reminder
4652                                  h('button', {
4653                                    onClick: () => { sendAttendanceReminder(reg); setOpenRegAction(null); },
4654                                    disabled: sendingAttendanceReminder === reg.id,
4655                                    className: 'w-full flex items-center gap-2.5 px-3 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50 disabled:opacity-50'
4656                                  },
4657                                    sendingAttendanceReminder === reg.id
4658                                      ? h('div', { className: 'w-3.5 h-3.5 border-2 border-amber-500/30 border-t-amber-500 rounded-full animate-spin' })
4659                                      : h(Icons.Bell, { size: 14, className: hasBeenEmailed(reg, 'reminder') ? 'text-violet-500' : 'text-gray-400' }),
4660                                    hasBeenEmailed(reg, 'reminder') ? 'Resend Reminder' : 'Send Reminder'
4661                                  ),
4662                                  // Mark as confirmed: a rep has spoken to them. Offered on any seat
4663                                  // holding a place that is not yet confirmed.
4664                                  (seatLabel(reg).kind === 'staffAdded' || seatLabel(reg).kind === 'selfRegistered') && h('button', {
4665                                    onClick: () => { markConfirmed(reg); setOpenRegAction(null); },
4666                                    disabled: resettingRegistration === reg.id,
4667                                    className: 'w-full flex items-center gap-2.5 px-3 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50 disabled:opacity-50'
4668                                  },
4669                                    resettingRegistration === reg.id
4670                                      ? h('div', { className: 'w-3.5 h-3.5 border-2 border-blue-500/30 border-t-blue-500 rounded-full animate-spin' })
4671                                      : h(Icons.Check, { size: 14, className: 'text-blue-500' }),
4672                                    'Mark as confirmed'
4673                                  ),
4674                                  // Clear a confirmation. Status untouched - see clearConfirmation.
4675                                  // Offered where the row READS Confirmed and nowhere else: an
4676                                  // attended or waitlisted seat can carry the fields, but its own
4677                                  // status is what it shows, and "they will read as not yet
4678                                  // confirmed" would be false of it.
4679                                  seatLabel(reg).kind === 'confirmedByReply' && h('button', {
4680                                    onClick: () => { clearConfirmation(reg); setOpenRegAction(null); },
4681                                    disabled: resettingRegistration === reg.id,
4682                                    className: 'w-full flex items-center gap-2.5 px-3 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50 disabled:opacity-50'
4683                                  },
4684                                    resettingRegistration === reg.id
4685                                      ? h('div', { className: 'w-3.5 h-3.5 border-2 border-blue-500/30 border-t-blue-500 rounded-full animate-spin' })
4686                                      : h(Icons.RefreshCw, { size: 14, className: 'text-gray-400' }),
4687                                    'Clear confirmation'
4688                                  ),
4689                                  // Put a DECLINED seat back on the list.
4690                                  reg.status === 'declined' && h('button', {
4691                                    onClick: () => { resetToRegistered(reg); setOpenRegAction(null); },
4692                                    disabled: resettingRegistration === reg.id,
4693                                    className: 'w-full flex items-center gap-2.5 px-3 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50 disabled:opacity-50'
4694                                  },
4695                                    resettingRegistration === reg.id
4696                                      ? h('div', { className: 'w-3.5 h-3.5 border-2 border-blue-500/30 border-t-blue-500 rounded-full animate-spin' })
4697                                      : h(Icons.RefreshCw, { size: 14, className: 'text-gray-400' }),
4698                                    'Put back on the list'
4699                                  ),
4700                                  // Request Feedback (only for attended)
4701                                  reg.status === 'attended' && h('button', {
4702                                    onClick: () => { handleRequestFeedback(reg); setOpenRegAction(null); },
4703                                    disabled: requestingFeedback === reg.id || reg.feedbackRequested,
4704                                    className: 'w-full flex items-center gap-2.5 px-3 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50 disabled:opacity-50'
4705                                  },
4706                                    requestingFeedback === reg.id
4707                                      ? h('div', { className: 'w-3.5 h-3.5 border-2 border-purple-500/30 border-t-purple-500 rounded-full animate-spin' })
4708                                      : h(Icons.Star, { size: 14, className: reg.feedbackSubmitted ? 'text-green-500' : reg.feedbackRequested ? 'text-gray-400' : 'text-purple-400' }),
4709                                    reg.feedbackSubmitted ? 'Feedback Received' : reg.feedbackRequested ? 'Feedback Requested' : 'Request Feedback'
4710                                  ),
4711                                  // Divider
4712                                  h('div', { className: 'border-t border-gray-200 dark:border-gray-700 my-1' }),
4713                                  // Can't attend
4714                                  h('button', {
4715                                    onClick: () => { markCannotAttend(reg); setOpenRegAction(null); },
4716                                    disabled: cancellingRegistration === reg.id,
4717                                    className: 'w-full flex items-center gap-2.5 px-3 py-2 text-sm text-amber-600 dar
4717k:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-900/20 disabled:opacity-50'
4718                                  },
4719                                    cancellingRegistration === reg.id
4720                                      ? h('div', { className: 'w-3.5 h-3.5 border-2 border-amber-500/30 border-t-amber-500 rounded-full animate-spin' })
4721                                      : h(Icons.UserMinus, { size: 14 }),
4722                                    "Can't Attend"
4723                                  ),
4724                                  // Delete
4725                                  h('button', {
4726                                    onClick: () => { deleteRegistration(reg); setOpenRegAction(null); },
4727                                    className: 'w-full flex items-center gap-2.5 px-3 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20'
4728                                  }, h(Icons.Trash, { size: 14 }), 'Delete')
4729                                ), document.body)
4730                              )
4731                            )
4732                          )
4733                        )
4734                      })
4735                    )
4736                  )
4737                )
4738        )
4739      ),
4740
4741
4742      // ===== SPEAKERS TAB =====
4743      activeTab === 'speakers' && isStaff && h(EventSpeakersTab, {
4744        eventId,
4745        event,
4746        isAdmin,
4747        onUpdateEvent,
4748        speakers: speakerRecords,
4749        speakersById: speakerRecordsById,
4750        updateSpeaker,
4751        entries: speakerEntries,
4752        allEntries: allRosterEntries,
4753        allEvents,
4754        senderName: displayNameFrom(user),
4755        loading: speakerRosterLoading || speakerRecordsLoading,
4756        rosterError: speakerRosterError,
4757        createSpeaker,
4758        addSpeakerToEvent,
4759        setEntryStatus: setSpeakerEntryStatus,
4760        setEntryNotes: setSpeakerEntryNotes,
4761        removeEntry: removeSpeakerEntry
4762      }),
4763
4764      // ===== NOTES TAB =====
4765      activeTab === 'notes' && isStaff && h('div', { className: 'bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm rounded-2xl border border-gray-200/60 dark:border-white/10' },
4766        h('div', { className: 'p-4 border-b border-gray-200 dark:border-gray-700' },
4767          h('div', { className: 'flex items-center justify-between' },
4768            h('h3', { className: 'text-lg font-semibold text-gray-900 dark:text-white' }, 'Event Notes'),
4769            h('p', { className: 'text-sm text-gray-500 dark:text-gray-400' },
4770              `${eventNotes.length} note${eventNotes.length !== 1 ? 's' : ''} recorded`
4771            )
4772          )
4773        ),
4774        eventNotesLoading
4775          ? h('div', { className: 'p-8 text-center' },
4776              h('div', { className: 'w-8 h-8 border-4 border-primary-200 border-t-primary-600 rounded-full animate-spin mx-auto mb-3' }),
4777              h('p', { className: 'text-sm text-gray-500 dark:text-gray-400' }, 'Loading notes...')
4778            )
4779          : eventNotes.length === 0
4780            ? h('div', { className: 'p-8 text-center' },
4781                h(Icons.Edit, { size: 32, className: 'mx-auto text-gray-300 dark:text-gray-600 mb-3' }),
4782                h('p', { className: 'text-gray-500 dark:text-gray-400 mb-1' }, 'No notes yet'),
4783                h('p', { className: 'text-sm text-gray-400 dark:text-gray-500' }, 'Notes added via mobile check-in or member profiles will appear here')
4784              )
4785            : h('div', { className: 'divide-y divide-gray-200 dark:divide-gray-700' },
4786                eventNotes.map(note =>
4787                  h('div', {
4788                    key: note.id,
4789                    className: 'p-4 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors'
4790                  },
4791                    h('div', { className: 'flex items-start justify-between gap-4' },
4792                      // Left: Member info and note
4793                      h('div', { className: 'flex-1 min-w-0' },
4794                        // Member name and company
4795                        h('div', { className: 'flex items-center gap-2 mb-1' },
4796                          h('span', { className: 'font-medium text-gray-900 dark:text-white' }, note.memberName),
4797                          note.memberCompany && h('span', { className: 'text-sm text-gray-500 dark:text-gray-400' }, `• ${note.memberCompany}`)
4798                        ),
4799                        // Note content - preview or full
4800                        expandedNoteId === note.id
4801                          ? h('p', { className: 'text-gray-700 dark:text-gray-300 whitespace-pre-wrap' }, note.content)
4802                          : h('p', { className: 'text-gray-600 dark:text-gray-400 line-clamp-2' },
4803                              note.content.length > 150 ? note.content.substring(0, 150) + '...' : note.content
4804                            ),
4805                        // Expand/collapse button if note is long
4806                        note.content.length > 150 && h('button', {
4807                          onClick: () => setExpandedNoteId(expandedNoteId === note.id ? null : note.id),
4808                          className: 'text-sm text-primary-600 hover:text-primary-700 dark:text-primary-400 mt-1'
4809                        }, expandedNoteId === note.id ? 'Show less' : 'Show more'),
4810                        // Meta info
4811                        h('div', { className: 'flex items-center gap-3 mt-2 text-xs text-gray-400 dark:text-gray-500' },
4812                          h('span', null,
4813                            note.createdAt?.toDate?.()
4814                              ? note.createdAt.toDate().toLocaleStr
4814ing('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
4815                              : 'Unknown date'
4816                          ),
4817                          note.createdByName && h('span', null, `by ${note.createdByName}`)
4818                        )
4819                      ),
4820                      // Right: View member button
4821                      note.userId && h('button', {
4822                        onClick: () => {
4823                          // Could open member profile modal here
4824                          window.open(`/admin/users/${note.userId}`, '_blank');
4825                        },
4826                        className: 'flex-shrink-0 p-2 text-gray-400 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors',
4827                        title: 'View member profile'
4828                      },
4829                        h(Icons.ExternalLink, { size: 16 })
4830                      )
4831                    )
4832                  )
4833                )
4834              )
4835      ),
4836
4837      // ===== Q&A TAB =====
4838      activeTab === 'qa' && isStaff && h('div', { className: 'space-y-6' },
4839        // Q&A Settings Card
4840        h('div', { className: 'bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm rounded-2xl border border-gray-200/60 dark:border-white/10' },
4841          h('div', { className: 'p-4 border-b border-gray-200 dark:border-gray-700' },
4842            h('h3', { className: 'text-lg font-semibold text-gray-900 dark:text-white' }, 'Q&A Settings')
4843          ),
4844          h('div', { className: 'p-4 space-y-4' },
4845            // Enable Q&A toggle
4846            h('div', { className: 'flex items-center justify-between' },
4847              h('div', null,
4848                h('p', { className: 'font-medium text-gray-900 dark:text-white' }, 'Enable Live Q&A'),
4849                h('p', { className: 'text-sm text-gray-500 dark:text-gray-400' }, 'Allow attendees to submit questions via QR code')
4850              ),
4851              h('button', {
4852                onClick: async () => {
4853                  setSavingQaSettings(true);
4854                  try {
4855                    await db.collection('events').doc(eventId).update({
4856                      qaEnabled: !event?.qaEnabled
4857                    });
4858                    setMessage({ type: 'success', text: event?.qaEnabled ? 'Q&A disabled' : 'Q&A enabled' });
4859                  } catch (err) {
4860                    console.error('[AdminEventPage] Error toggling Q&A:', err);
4861                    setMessage({ type: 'error', text: 'Failed to update Q&A settings' });
4862                  }
4863                  setSavingQaSettings(false);
4864                },
4865                disabled: savingQaSettings,
4866                className: `relative w-14 h-8 rounded-full transition-colors ${event?.qaEnabled ? 'bg-primary-600' : 'bg-gray-300 dark:bg-gray-600'}`
4867              },
4868                h('div', {
4869                  className: `absolute top-1 left-1 w-6 h-6 bg-white rounded-full shadow transition-transform ${event?.qaEnabled ? 'translate-x-6' : ''}`
4870                })
4871              )
4872            ),
4873            // Custom URL
4874            h('div', null,
4875              h('label', { className: 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1' }, 'Custom URL'),
4876              h('div', { className: 'flex gap-2' },
4877                h('div', { className: 'flex-1 flex items-center bg-gray-100 dark:bg-gray-700 rounded-lg' },
4878                  h('span', { className: 'px-3 text-sm text-gray-500 dark:text-gray-400' }, 'events.trovex.com/qa/'),
4879                  h('input', {
4880                    type: 'text',
4881                    value: qaCustomUrl,
4882                    onChange: (e) => setQaCustomUrl(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '')),
4883                    placeholder: '01-22',
4884                    className: 'flex-1 px-2 py-2 bg-transparent border-0 text-gray-900 dark:text-white focus:ring-0 text-sm'
4885                  })
4886                ),
4887                h('button', {
4888                  onClick: async () => {
4889                    if (!qaCustomUrl.trim()) return;
4890                    setSavingQaSettings(true);
4891                    try {
4892                      await db.collection('events').doc(eventId).update({
4893                        qaCustomUrl: qaCustomUrl.trim()
4894                      });
4895                      setMessage({ type: 'success', text: 'Custom URL saved' });
4896                    } catch (err) {
4897                      console.error('[AdminEventPage] Error saving custom URL:', err);
4898                      setMessage({ type: 'error', text: 'Failed to save custom URL' });
4899                    }
4900                    setSavingQaSettings(false);
4901                  },
4902                  disabled: savingQaSettings || !qaCustomUrl.trim(),
4903                  className: 'px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 rounded-full pill-btn disabled:opacity-50 transition-colors'
4904                }, 'Save')
4905              ),
4906              h('p', { className: 'text-xs text-gray-500 dark:text-gray-400 mt-1' }, 'Letters, numbers and hyphens, e.g. "24-09" or "ventilation"')
4907            ),
4908            // Q&A Links (only show if enabled and has custom URL)
4909            event?.qaEnabled && event?.qaCustomUrl && h('div', { className: 'pt-4 border-t border-gray-200 dark:border-gray-700' },
4910              h('p', { className: 'text-sm font-medium text-gray-700 dark:text-gray-300 mb-3' }, 'Share Links'),
4911              h('div', { className: 'space-y-2' },
4912                h('div', { className: 'flex items-center gap-2' },
4913                  h('input', {
4914                    type: 'text',
4915                    readOnly: true,
4916                    value: qaSubmitUrl(event.qaCustomUrl),
4917                    className: 'flex-1 px-3 py-2 bg-gray-100 dark:bg-gray-700 border-0 rounded-lg text-sm text-gray-600 dark:text-gray-300'
4918                  }),
4919                  h('button', {
4920                    onClick: () => {
4921                      navigator.clipboard.writeText(qaSubmitUrl(event.qaCustomUrl));
4922                      setMessage({ type: 'success', text: 'Submit link copied!' });
4923                    },
4924                    className: 'px-3 py-2 text-sm font-medium text-primary-600 dark:text-primary-400 bg-primary-50 dark:bg-primary-900/20 hover:bg-primary-100 dark:hover:bg-primary-900/30 rounded-lg transition-colors'
4925                  }, 'Copy Submit Link')
4926                ),
4927                h('div', { className: 'flex items-center gap-2' },
4928                  h('input', {
4929                    type: 'text',
4930                    readOnly: true,
4931                    value: qaDisplayUrl(event.qaCustomUrl),
4932                    className: 'flex-1 px-3 py-2 bg-gray-100 dark:bg-gray-700 border-0 rounded-lg text-sm text-gray-600 dark:text-gray-300'
4933                  }),
4934                  h('button', {
4935                    onClick: () => {
4936                      navigator.clipboard.writeText(qaDisplayUrl(event.qaCustomUrl));
4937                      setMessage({ type: 'success', text: 'Display link copied!' });
4938                    },
4939                    className: 'px-3 py-2 text-sm font-medium text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors'
4940                  }
4940, 'Copy Display Link')
4941                )
4942              ),
4943              h('p', { className: 'text-xs text-gray-500 dark:text-gray-400 mt-2' },
4944                'Share the Submit Link with attendees. Open the Display Link on a presenter screen.'
4945              )
4946            )
4947          )
4948        ),
4949
4950        // Questions List
4951        h('div', { className: 'bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm rounded-2xl border border-gray-200/60 dark:border-white/10' },
4952          h('div', { className: 'p-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between' },
4953            h('h3', { className: 'text-lg font-semibold text-gray-900 dark:text-white' }, `Questions (${questions.length})`),
4954            questions.length > 0 && h('button', {
4955              onClick: async () => {
4956                if (!confirm('Delete all questions? This cannot be undone.')) return;
4957                // Chunked for the same reason as the prospect syncs: a busy Q&A session can pass
4958                // 500, and this one is awaited, so past the cap the staff member would get an
4959                // unhandled rejection and no toast at all.
4960                await chunkedBatchWrite(
4961                  questions,
4962                  () => db.batch(),
4963                  (batch, q) => batch.delete(db.collection('events').doc(eventId).collection('questions').doc(q.id))
4964                );
4965                setMessage({ type: 'success', text: 'All questions deleted' });
4966              },
4967              className: 'text-sm text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300'
4968            }, 'Clear All')
4969          ),
4970          questionsLoading
4971            ? h('div', { className: 'p-8 text-center' },
4972                h('div', { className: 'w-8 h-8 border-2 border-primary-500/30 border-t-primary-500 rounded-full animate-spin mx-auto' })
4973              )
4974            : questions.length === 0
4975              ? h('div', { className: 'p-8 text-center text-gray-500 dark:text-gray-400' },
4976                  h(Icons.MessageSquare, { size: 32, className: 'mx-auto mb-2 opacity-50' }),
4977                  h('p', null, 'No questions yet'),
4978                  event?.qaEnabled && event?.qaCustomUrl && h('p', { className: 'text-sm mt-1' },
4979                    'Share the Q&A link with attendees to start receiving questions'
4980                  )
4981                )
4982              : h('div', { className: 'divide-y divide-gray-200 dark:divide-gray-700' },
4983                  questions.map((q, index) =>
4984                    h('div', { key: q.id, className: 'p-4' },
4985                      h('div', { className: 'flex items-start gap-3' },
4986                        h('div', { className: 'w-8 h-8 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center text-sm font-medium text-gray-600 dark:text-gray-400 flex-shrink-0' },
4987                          questions.length - index
4988                        ),
4989                        h('div', { className: 'flex-1 min-w-0' },
4990                          h('p', { className: 'text-gray-900 dark:text-white' }, q.question),
4991                          h('div', { className: 'flex items-center gap-2 mt-2 text-xs text-gray-500 dark:text-gray-400' },
4992                            q.createdAt && h('span', null, formatTimeAgo(q.createdAt)),
4993                            h('span', {
4994                              className: `px-2 py-0.5 rounded-full text-xs ${
4995                                q.status === 'approved' ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' :
4996                                q.status === 'answered' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' :
4997                                q.status === 'hidden' ? 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-500' :
4998                                'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
4999                              }`
5000                            }, q.status || 'pending')
5001                          )
5002                        ),
5003                        h('div', { className: 'flex items-center gap-1 flex-shrink-0' },
5004                          // Approve button
5005                          q.status !== 'approved' && h('button', {
5006                            onClick: async () => {
5007                              await db.collection('events').doc(eventId).collection('questions').doc(q.id).update({ status: 'approved' });
5008                            },
5009                            className: 'p-1.5 text-gray-400 hover:text-green-500 hover:bg-green-50 dark:hover:bg-green-900/20 rounded transition-colors',
5010                            title: 'Approve'
5011                          }, h(Icons.Check, { size: 16 })),
5012                          // Mark answered
5013                          q.status !== 'answered' && h('button', {
5014                            onClick: async () => {
5015                              await db.collection('events').doc(eventId).collection('questions').doc(q.id).update({ status: 'answered' });
5016                            },
5017                            className: 'p-1.5 text-gray-400 hover:text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded transition-colors',
5018                            title: 'Mark as Answered'
5019                          }, h(Icons.MessageSquare, { size: 16 })),
5020                          // Hide button
5021                          q.status !== 'hidden' && h('button', {
5022                            onClick: async () => {
5023                              await db.collection('events').doc(eventId).collection('questions').doc(q.id).update({ status: 'hidden' });
5024                            },
5025                            className: 'p-1.5 text-gray-400 hover:text-amber-500 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded transition-colors',
5026                            title: 'Hide'
5027                          }, h(Icons.EyeOff, { size: 16 })),
5028                          // Delete button
5029                          h('button', {
5030                            onClick: async () => {
5031                              if (!confirm('Delete this question?')) return;
5032                              await db.collection('events').doc(eventId).collection('questions').doc(q.id).delete();
5033                            },
5034                            className: 'p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors',
5035                            title: 'Delete'
5036                          }, h(Icons.Trash, { size: 16 }))
5037                        )
5038                      )
5039                    )
5040                  )
5041                )
5042        )
5043      ),
5044
5045      // ===== TASKS TAB =====
5046      activeTab === 'tasks' && isStaff && h('div', { className: 'bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm rounded-2xl border border-gray-200/60 dark:border-white/10' },
5047      // Tasks Header
5048      h('div', { className: 'p-4 border-b border-gray-200 dark:border-gray-700' },
5049        h('div', { className: 'flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3' },
5050          // Filter buttons
5051          h('div', { className: 'flex items-center gap-2' },
5052            ['all', 'pending', 'completed', 'mine'].map(filter =>
5053              h('button', {
5054                key: filter,
5055                onClick: () => setTaskFilter(filter),
5056                className: `px-3 py-1.5 text-xs font-medium rounded-full transition-colors ${
5057                  taskFilter === filter
5058                    ? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
5059                    : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600'
5060                }`
5061              }, filter === 'all' ? 'All' : filter === 'mine' ? 'My Tasks' : filter.charAt(0).toUpperCase() + filter.slice(1))
5062            )
5063          ),
5064          // Add task button
5065          h('button', {
5066            onClick: () => setShowAddTask(!showAddTask),
5067            className: 'flex items-center gap-2 px-3 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 rounded-full pill-btn transition-colors'
5068          },
5069            h(Icons.Plus, { size: 16 }),
5070            'Add Task'
5071          )
5072        ),
5073
5074        // Add Task Form
5075        showAddTask && h('div', { className: 'mt-4 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600' },
5076          h('div', { className: 'space-y-3' },
5077            // Title
5078            h('div', null,
5079              h('label', { className: 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1' }, 'Task Title *'),
5080              h('input', {
5081                type: 'text',
5082                value: newTaskTitle,
5083                onChange: (e) => setNewTaskTitle(e.target.value),
5084                placeholder: 'Enter task title...',
5085                className: 'w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-primary-500 focus:border-primary-500'
5086              })
5087            ),
5088            // Description
5089            h('div', null,
5090              h('label', { className: 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1' }, 'Description'),
5091              h('textarea', {
5092                value: newTaskDescription,
5093                onChange: (e) => setNewTaskDescription(e.target.value),
5094                placeholder: 'Add details about the task...',
5095                rows: 2,
5096                className: 'w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 resize-none'
5097              })
5098            ),
5099            // Assignee and Due Date row
5100            h('div', { className: 'grid grid-cols-1 sm:grid-cols-2 gap-3' },
5101              // Assignee
5102              h('div', null,
5103                h('label', { className: 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1' }, 'Assign to'),
5104                h('select', {
5105                  value: newTaskAssignee,
5106                  onChange: (e) => setNewTaskAssignee(e.target.value),
5107                  className: 'w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-primary-500'
5108                },
5109                  h('option', { value: '' }, 'Unassigned'),
5110                  staffMembersList.map(s =>
5111                    h('option', { key: s.email, value: s.email }, `${s.firstName || ''} ${s.lastName || ''}`.trim() || s.email)
5112                  )
5113                )
5114              ),
5115              // Due Date
5116              h('div', null,
5117                h('label', { className: 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1' }, 'Due Date'),
5118                h('input', {
5119                  type: 'date',
5120                  value: newTaskDueDate,
5121                  onChange: (e) => setNewTaskDueDate(e.target.value),
5122                  className: 'w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-primary-500'
5123                })
5124              )
5125            ),
5126            // Actions
5127            h('div', { className: 'flex items-center justify-end gap-2 pt-2' },
5128              h('button', {
5129                onClick: () => {
5130                  setShowAddTask(false);
5131                  setNewTaskTitle('');
5132                  setNewTaskDescription('');
5133                  setNewTaskAssignee('');
5134                  setNewTaskDueDate('');
5135                },
5136                className: 'px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded-lg transition-colors'
5137              }, 'Cancel'),
5138              h('button', {
5139                onClick: handleAddTask,
5140                disabled: addingTask || !newTaskTitle.trim(),
5141                className: 'flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 rounded-full pill-btn transition-colors disabled:opacity-50'
5142              },
5143                addingTask && h('div', { className: 'w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin' }),
5144                addingTask ? 'Adding...' : 'Add Task'
5145              )
5146            )
5147          )
5148        )
5149      ),
5150
5151      // Tasks List
5152      h('div', { className: 'divide-y divide-gray-200 dark:divide-gray-700' },
5153        tasksLoading
5154          ? h('div', { className: 'p-8 text-center' },
5155              h('div', { className: 'w-8 h-8 border-4 border-primary-200 border-t-primary-600 rounded-full animate-spin mx-auto mb-2' }),
5156              h('p', { className: 'text-sm text-gray-500 dark:text-gray-400' }, 'Loading tasks...')
5157            )
5158          : filteredTasks.length === 0
5159            ? h('div', { className: 'p-8 text-center' },
5160                h(Icons.CheckSquare, { size: 48, className: 'mx-auto text-gray-300 dark:text-gray-600 mb-3' }),
5161                h('p', { className: 'text-gray-500 dark:text-gray-400' },
5162                  taskFilter === 'all'
5163                    ? 'No tasks yet. Add your first task above.'
5164                    : taskFilter === 'mine'
5165                      ? 'No tasks assigned to you.'
5166                      : `No ${taskFilter} tasks.`
5167                )
5168              )
5169            : filteredTasks.map(task => {
5170                const isExpanded = expandedTaskId === task.id;
5171                const isOverdue = task.dueDate && task.status === 'pending' &&
5172                  (task.dueDate.toDate ? task.dueDate.toDate() : new Date(task.dueDate)) < new Date();
5173
5174                return h('div', {
5175                  key: task.id,
5176                  className: `p-4 transition-colors ${
5177                    task.status === 'completed'
5178                      ? 'bg-green-50/50 dark:bg-green-900/10'
5179                      : isOverdue
5180                        ? 'bg-red-50/50 dark:bg-red-900/10'
5181                        : 'bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700/50'
5182                  }`
5183                },
5184                  // Task Header
5185                  h('div', { className: 'flex items-start gap-3' },
5186                    // Checkbox
5187                    h('button', {
5188                      onClick: () => handleToggleTaskStatus(task),
5189                      className: `flex-shrink-0 w-5 h-5 mt-0.5 rounded border-2 transition-colors ${
5190                        task.status === 'completed'
5191                          ? 'bg-green-500 border-green-500 text-white'
5192                          : 'border-gray-300 dark:border-gray-600 hover:border-primary-500'
5193                      }`
5194                    },
5195                      task.status === 'completed' && h(Icons.Check, { size: 14 })
5196                    ),
5197                    // Task Content
5198                    h('div', {
5199                      className: 'flex-1 min-w-0 cursor-pointer',
5200                      onClick: () => setExpandedTaskId(isExpanded ? null : task.id)
5201                    },
5202                      h('div', { className: 'flex items-center gap-2 flex-wrap' },
5203                        h('span', {
5204                          className: `font-medium ${task.status === 'completed' ? 'line-through text-gray-400 dark:text-gray-500' : 'text-gray-900 dark:text-white'}`
5205                        }, task.title),
5206                        isOverdue && h('span', { className: 'px-2 py-0.5 text-xs font-medium bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 rounded-full' }, 'Overdue')
5207                      ),
5208                      h('div', { className: 'flex items-center gap-3 mt-1 text-xs text-gray-500 dark:text-gray-400' },
5209                        task.assignedToName && h('span', { className: 'flex items-center gap-1' },
5210                          h(Icons.User, { size: 12 }),
5211                          task.assignedToName
5212                        ),
5213          
5213              task.dueDate && h('span', { className: `flex items-center gap-1 ${isOverdue ? 'text-red-600 dark:text-red-400' : ''}` },
5214                          h(Icons.Calendar, { size: 12 }),
5215                          (task.dueDate.toDate ? task.dueDate.toDate() : new Date(task.dueDate)).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })
5216                        ),
5217                        (task.comments?.length > 0) && h('span', { className: 'flex items-center gap-1' },
5218                          h(Icons.MessageSquare, { size: 12 }),
5219                          task.comments.length
5220                        )
5221                      )
5222                    ),
5223                    // Delete button (admin only)
5224                    isAdmin && h('button', {
5225                      onClick: () => handleDeleteTask(task),
5226                      className: 'flex-shrink-0 p-1 text-gray-400 hover:text-red-500 rounded transition-colors'
5227                    }, h(Icons.Trash, { size: 16 }))
5228                  ),
5229
5230                  // Expanded Content
5231                  isExpanded && h('div', { className: 'mt-4 ml-8 space-y-4' },
5232                    // Description
5233                    task.description && h('p', { className: 'text-sm text-gray-600 dark:text-gray-400' }, task.description),
5234
5235                    // Task Meta
5236                    h('div', { className: 'text-xs text-gray-400 dark:text-gray-500' },
5237                      `Created by ${task.createdByName || task.createdBy || 'Unknown'} ${formatRelativeTime(task.createdAt)}`,
5238                      task.status === 'completed' && task.completedBy && ` · Completed by ${task.completedBy.split('@')[0]} ${formatRelativeTime(task.completedAt)}`
5239                    ),
5240
5241                    // Sub-tasks
5242                    h('div', { className: 'space-y-2' },
5243                      h('p', { className: 'text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide' }, 'Sub-tasks'),
5244                      // Existing sub-tasks
5245                      (task.subTasks?.length > 0) && h('div', { className: 'space-y-1' },
5246                        task.subTasks.map(subTask =>
5247                          h('div', { key: subTask.id, className: 'flex items-center gap-2 group' },
5248                            h('button', {
5249                              onClick: () => handleToggleSubTask(task, subTask.id),
5250                              className: `flex-shrink-0 w-4 h-4 rounded border transition-colors ${
5251                                subTask.status === 'completed'
5252                                  ? 'bg-green-500 border-green-500 text-white'
5253                                  : 'border-gray-300 dark:border-gray-600 hover:border-primary-500'
5254                              }`
5255                            },
5256                              subTask.status === 'completed' && h(Icons.Check, { size: 12 })
5257                            ),
5258                            h('span', {
5259                              className: `flex-1 text-sm ${
5260                                subTask.status === 'completed'
5261                                  ? 'line-through text-gray-400 dark:text-gray-500'
5262                                  : 'text-gray-700 dark:text-gray-300'
5263                              }`
5264                            }, subTask.title),
5265                            h('button', {
5266                              onClick: () => handleDeleteSubTask(task, subTask.id),
5267                              className: 'opacity-0 group-hover:opacity-100 p-1 text-gray-400 hover:text-red-500 rounded transition-all'
5268                            }, h(Icons.X, { size: 14 }))
5269                          )
5270                        )
5271                      ),
5272                      // Add sub-task input
5273                      h('div', { className: 'flex items-center gap-2' },
5274                        h('input', {
5275                          type: 'text',
5276                          value: expandedTaskId === task.id ? newSubTaskTitle : '',
5277                          onChange: (e) => setNewSubTaskTitle(e.target.value),
5278                          onKeyDown: (e) => e.key === 'Enter' && handleAddSubTask(task),
5279                          placeholder: 'Add a sub-task...',
5280                          className: 'flex-1 px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-primary-500 focus:border-primary-500'
5281                        }),
5282                        h('button', {
5283                          onClick: () => handleAddSubTask(task),
5284                          disabled: !newSubTaskTitle.trim(),
5285                          className: 'px-3 py-1.5 text-sm font-medium text-primary-600 hover:text-primary-700 dark:text-primary-400 rounded-lg transition-colors disabled:opacity-50'
5286                        }, '+ Add')
5287                      )
5288                    ),
5289
5290                    // Comments
5291                    (task.comments?.length > 0) && h('div', { className: 'space-y-2' },
5292                      h('p', { className: 'text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide' }, 'Comments'),
5293                      task.comments.map((comment, idx) =>
5294                        h('div', { key: comment.id || idx, className: 'flex gap-2 text-sm' },
5295                          h('span', { className: 'font-medium text-gray-700 dark:text-gray-300' }, comment.authorName || comment.author?.split('@')[0] || 'User'),
5296                          h('span', { className: 'text-gray-600 dark:text-gray-400' }, comment.text),
5297                          h('span', { className: 'text-xs text-gray-400 dark:text-gray-500' },
5298                            formatRelativeTime(comment.createdAt)
5299                          )
5300                        )
5301                      )
5302                    ),
5303
5304                    // Add Comment
5305                    h('div', { className: 'flex items-center gap-2' },
5306                      h('input', {
5307                        type: 'text',
5308                        value: expandedTaskId === task.id ? newTaskComment : '',
5309                        onChange: (e) => setNewTaskComment(e.target.value),
5310                        onKeyDown: (e) => e.key === 'Enter' && handleAddTaskComment(task),
5311                        placeholder: 'Add a comment...',
5312                        className: 'flex-1 px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-primary-500 focus:border-primary-500'
5313                      }),
5314                      h('button', {
5315                        onClick: () => handleAddTaskComment(task),
5316                        disabled: !newTaskComment.trim(),
5317                        className: 'px-3 py-1.5 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 rounded-full pill-btn transition-colors disabled:opacity-50'
5318                      }, 'Send')
5319                    )
5320                  )
5321                );
5322              })
5323      )
5324      ),
5325
5326      // ===== PROSPECTS TAB =====
5327      activeTab === 'prospects' && isStaff && (() => {
5328        // Status pill counts follow the assigned-to-me toggle AND the invited-by filter, so the
5329        // numbers always describe the list underneath them rather than a wider set the staff
5330        // member cannot see.
5331        const prospectScope = filterProspectsByInviter(
5332          prospectMineOnly ? prospects.filter(p => isProspectAssignedTo(p, user?.email)) : prospects,
5333          prospectInviterIndex,
5334          prospectInviterFilter
5335        );
5336        const prospectStatusCounts = {};
5337        Object.keys(PROSPECT_STATUSES).forEach(s => { prospectStatusCounts[s] = prospectScope.filter(p => p.status === s).length; });
5338        const prospectFiltersActive = prospectMineOnly || prospectStatusFilter !== 'all'
5339          || prospectInviterFilter !== ANY_INVITER_KEY || !!prospectSearch.trim()
5340          || isInvitationRangeActive(prospectDateFrom, prospectDateTo);
5341        // Names the invited-by filter in the Showing line, so a narrowed list always says why.
5342        // The wording lives in prospect-inviters.js so it cannot drift from the filter labels.
5343        const prospectInviterDescription = inviterFilterDescription(prospectInviterFilter, prospectInviterIndex, staffMembersByUid);
5344        // The dates too, so a range that empties the table says which dates emptied it.
5345        const prospectDateDescription = invitationRangeDescription(prospectDateFrom, prospectDateTo);
5346        return h('div', { className: 'space-y-4' },
5347        // Header bar
5348        h('div', { className: 'bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm rounded-2xl border border-gray-200/60 dark:border-white/10 p-4' },
5349          // Status pills
5350          prospects.length > 0 && h('div', { className: 'flex flex-wrap gap-2 mb-3' },
5351            // 'All' means all, so it clears the mine toggle too and is only the ACTIVE pill
5352            // when nothing is narrowing the list. It used to reset the status filter alone
5353            // while still rendering as active, so with the toggle on it read as the current
5354            // view, showed the narrowed count, and did nothing when pressed.
5355            h('button', {
5356              onClick: () => { setProspectStatusFilter('all'); setProspectMineOnly(false); setProspectInviterFilter(ANY_INVITER_KEY); },
5357              className: `px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${
5358                prospectStatusFilter === 'all' && !prospectMineOnly && prospectInviterFilter === ANY_INVITER_KEY
5359                  ? 'bg-gray-900 text-white dark:bg-white dark:text-gray-900'
5360                  : 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
5361              }`
5362            }, `All (${prospects.length})`),
5363            user?.email && h('button', {
5364              onClick: () => setProspectMineOnly(v => !v),
5365              title: prospectMineOnly
5366                ? 'Show prospects assigned to anyone'
5367                : `Show only prospects assigned to ${user.email}`,
5368              className: `px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${
5369                prospectMineOnly
5370                  ? 'bg-primary-600 text-white'
5371                  : 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
5372              }`
5373            }, `Assigned to me (${myProspectCount})`),
5374            // Only drawn when there is something to see. An event nobody has invited yet carries no
5375            // auto-replies, and a pill reading (0) is a question the staff member did not ask.
5376            (prospectAutoReplyCount > 0 || prospectAutoReplyOnly) && h('button', {
5377              onClick: () => setProspectAutoReplyOnly(v => !v),
5378              title: 'People whose invitation came back as an out-of-office or automatic reply',
5379              className: `px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${
5380                prospectAutoReplyOnly
5381                  ? 'bg-amber-500 text-white'
5382                  : 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 hover:bg-amber-200 dark:hover:bg-amber-900/50'
5383              }`
5384            }, `Auto-reply (${prospectAutoReplyCount})`),
5385            Object.values(PROSPECT_STATUSES).map(s =>
5386              // Keep the selected status pill rendered even when its count drops to zero, so the
5387              // active filter never disappears and an empty list never looks unexplained.
5388              (prospectStatusCounts[s.id] > 0 || prospectStatusFilter === s.id) && h('button', {
5389                key: s.id,
5390                onClick: () => setProspectStatusFilter(s.id),
5391                className: `px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${
5392                  prospectStatusFilter === s.id
5393                    ? s.color
5394                    : s.color + ' opacity-70 hover:opacity-100'
5395                }`
5396              }, `${s.label} (${prospectStatusCounts[s.id]})`)
5397            )
5398          ),
5399          h('div', { className: 'flex flex-col sm:flex-row items-start sm:items-center gap-3' },
5400            // Search
5401            h('div', { className: 'relative flex-1 w-full' },
5402              h('input', {
5403                type: 'text',
5404                value: prospectSearch,
5405                onChange: e => setProspectSearch(e.target.value),
5406                placeholder: 'Search prospects by name, email, or company...',
5407                className: 'w-full pl-9 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-primary-500'
5408              }),
5409              h('div', { className: 'absolute left-3 top-1/2 -translate-y-1/2 text-gray-400' },
5410                h(Icons.Search, { size: 16 })
5411              )
5412            ),
5413            // When they were invited: a range, either end of which may be left empty, because
5414            // "invited before the 1st" and "invited since the 1st" are both real questions. The
5415            // browser is told the two bounds so it will not offer a backwards range in the picker;
5416            // one typed in anyway returns nothing and the Showing line names both dates.
5417            anyProspectInvited && h('div', { className: 'flex items-center gap-2 w-full sm:w-auto' },
5418              h('span', { className: 'text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap' }, 'Invited'),
5419              h('input', {
5420                type: 'date',
5421                'aria-label': 'Invited from',
5422                value: prospectDateFrom,
5423                max: prospectDateTo || undefined,
5424                onChange: e => setProspectDateFrom(e.target.value),
5425                title: 'Show only prospects invited on or after this date',
5426                className: 'px-2 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-primary-500'
5427              }),
5428              h('span', { className: 'text-xs text-gray-500 dark:text-gray-400' }, 'to'),
5429              h('input', {
5430                type: 'date',
5431                'aria-label': 'Invited to',
5432                value: prospectDateTo,
5433                min: prospectDateFrom || undefined,
5434                onChange: e => setProspectDateTo(e.target.value),
5435                title: 'Show only prospects invited on or before this date',
5436                className: 'px-2 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-primary-500'
5437              }),
5438              // Only offered once a date is set, because an always-present clear beside two empty
5439              // fields reads as a control that does nothing.
5440              (prospectDateFrom || prospectDateTo) && h('button', {
5441                onClick: () => { setProspectDateFrom(''); setProspectDateTo(''); },
5442                title: 'Clear the invitation date range',
5443                'aria-label': 'Clear the invitation date range',
5444                className: 'p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200'
5445              }, h(Icons.X, { size: 14 }))
5446            ),
5447            // Order by invitation date. 'none' leaves the list exactly as it arrives, so turning
5448            // sorting on is a deliberate act and nobody's familiar ordering changes underneath them.
5449            anyProspectInvited && h('select', {
5450              'aria-label': 'Sort by invitation date',
5451              value: prospectSort,
5452              onChange: e => setProspectSort(e.target.value),
5453              title: 'Order by when the invitation was sent',
5454              className: 'w-full sm:w-auto px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-primary-500'
5455            },
5456              h('option', { value: 'none' }, 'Default order'),
5457              h('option', { value: 'newest' }, 'Invited most recently first'),
5458              h('option', { value: 'oldest' }, 'Invited longest ago first')
5459            ),
5460            // Invited by. The counts are the point of it: a staff member wants to read off how
5461            // many each colleague has, so every option carries its own number.
5462            anyProspectInvited && h('select', {
5463              'aria-label': 'Invited by',
5464              value: prospectInviterFilter,
5465              onChange: e => setProspectInviterFilter(e.target.value),
5466              title: 'Show only the prospects one colleague invited',
5467              className: 'w-full sm:w-auto px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-primary-500'
5468            },
5469              h('option', { value: ANY_INVITER_KEY }, `Invited by anyone (${prospectInviterScope.length})`),
5470              prospectInviterOptions.map(option =>
5471                h('option', { key: option.key, value: option.key },
5472                  option.key === NOT_INVITED_KEY || option.key === UNKNOWN_INVITER_KEY
5473                    ? `${option.name} (${option.count})`
5474                    : `Invited by ${option.name} (${option.count})`
5475                )
5476              )
5477            ),
5478            // Import, beside Add rather than buried in a menu: it is the answer to "this is slow
5479            // to do one at a time", which is why it was asked for.
5480            h('button', {
5481              onClick: () => setShowProspectImport(true),
5482              className: 'flex items-center gap-2 px-4 py-2 text-sm font-medium bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 rounded-full pill-btn whitespace-nowrap hover:bg-gray-50 dark:hover:bg-gray-600'
5483            },
5484              h(Icons.Upload, { size: 16 }),
5485              'Import'
5486            ),
5487            // Add Prospect button
5488            h('button', {
5489              onClick: () => { setShowAddProspectModal(true); setProspectModalSearch(''); setProspectContactsSearch(''); setProspectContactsInitial([]); setProspectContactsInitialFailed(false); setProspectModalTab('members'); },
5490              className: 'flex items-center gap-2 px-4 py-2 text-sm font-medium bg-primary-600 hover:bg-primary-700 text-white rounded-full pill-btn whitespace-nowrap'
5491            },
5492              h(Icons.Plus, { size: 16 }),
5493              'Add Prospect'
5494            )
5495          ),
5496          // The hourly limit, above the list rather than beside one row: an invitation round is
5497          // done from here, and the number that matters is how many more can go at all.
5498          h(InvitationRateNotice, { rate: invitationRate, className: 'mt-3' }),
5499
5500          // Say what is being shown whenever a filter is narrowing the list, so an empty table
5501          // reads as a filter rather than as no prospects at all.
5502          prospectFiltersActive && prospects.length > 0 && h('div', { className: 'mt-3 text-xs text-gray-500 dark:text-gray-400' },
5503            `Showing ${filteredProspects.length} of ${prospects.length} prospects${prospectMineOnly ? ', assigned to you' : ''}${prospectInviterDescription ? `, ${prospectInviterDescription}` : ''}${prospectDateDescription ? `, ${prospectDateDescription}` : ''}`
5504          )
5505        ),
5506
5507        // Prospects table
5508        h('div', { className: 'bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm rounded-2xl border border-gray-200/60 dark:border-white/10 overflow-hidden' },
5509          prospectsLoading
5510            ? h('div', { className: 'flex justify-center py-12' },
5511                h('div', { className: 'w-8 h-8 border-3 border-primary-200 border-t-primary-600 rounded-full animate-spin' })
5512              )
5513            : filteredProspects.length === 0
5514              ? h('div', { className: 'text-center py-12' },
5515                  h(Icons.Target, { size: 48, className: 'mx-auto text-gray-300 dark:text-gray-600 mb-4' }),
5516                  h('h3', { className: 'text-lg font-medium text-gray-900 dark:text-white mb-1' },
5517                    prospects.length === 0
5518                      ? 'No prospects yet'
5519                      : prospectMineOnly && myProspectCount === 0
5520                        ? 'None assigned to you'
5521                        : 'No matching prospects'
5522                  ),
5523                  h('p', { className: 'text-sm text-gray-500 dark:text-gray-400 mb-4' },
5524                    prospects.length === 0
5525                      ? 'Add prospects to track your outreach pipeline for this event.'
5526                      : prospectMineOnly && myProspectCount === 0
5527                        ? `None of the ${prospects.length} prospects on this event are assigned to you yet.`
5528                        : prospectMineOnly
5529                          ? 'Try adjusting your search or status filter, or turn off Assigned to me.'
5530                          : 'Try adjusting your search or filter.'
5531                  ),
5532                  // Clears EVERY filter, not just the toggle. With a search term that matches
5533                  // nothing, clearing the toggle alone left the list empty and only swapped the
5534                  // wording, which reads as a dead button.
5535                  prospects.length > 0 && (prospectMineOnly || prospectInviterFilter !== ANY_INVITER_KEY
5536                    || isInvitationRangeActive(prospectDateFrom, prospectDateTo)) && h('button', {
5537                    onClick: () => {
5538                      setProspectMineOnly(false); setProspectStatusFilter('all'); setProspectSearch('');
5539                      setProspectInviterFilter(ANY_INVITER_KEY); setProspectDateFrom(''); setProspectDateTo('');
5540                    },
5541                    className: 'inline-flex items-center gap-2 px-4 py-2 text-sm font-medium bg-gray-100 hover:bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 rounded-full pill-btn'
5542                  }, 'Show all prospects'),
5543                  prospects.length === 0 && h('button', {
5544                    onClick: () => { setShowAddProspectModal(true); setProspectModalSearch(''); setProspectContactsSearch(''); setProspectContactsInitial([]); setProspectContactsInitialFailed(false); setProspectModalTab('members'); },
5545                    className: 'inline-flex items-center gap-2 px-4 py-2 text-sm font-medium bg-primary-600 hover:bg-primary-700 text-white rounded-full pill-btn'
5546                  },
5547                    h(Icons.Plus, { size: 16 }),
5548                    'Add First Prospect'
5549                  )
5550                )
5551              // A FRAGMENT, because this branch renders two siblings: the scrolling table
5552              // and the window control under it. Without it, `h(table), h(control)` in a
5553              // ternary branch is the COMMA OPERATOR - it evaluates the table, discards it
5554              // and returns only the control. Valid JavaScript, node --check passes, and the
5555              // prospect table vanishes. Verified by executing the shape, not by reading it.
5556              : h(React.Fragment, null,
5557                h('div', { className: 'overflow-x-auto' },
5558                  h('table', { className: 'w-full' },
5559                    h('thead', null,
5560                      h('tr', { className: 'border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/50' },
5561                        h('th', { className: 'px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase' }, 'Name'),
5562                        h('th', { className: 'px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase hidden md:table-cell' }, 'Company'),
5563                        h('th', { className: 'px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase hidden lg:table-cell' }, 'Contact'),
5564                        h('th', { className: 'px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase' }, 'Status'),
5565                        h('th', { className: 'px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase hidden lg:table-cell' }, 'Assigned To'),
5566                        h('th', { className: 'px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase hidden xl:table-cell' }, 'Invited by'),
5567                        h('th', { className: 'px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase hidden xl:table-cell' }, 'Notes'),
5568                        h('th', { className: 'px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase' }, 'Actions')
5569                      )
5570                    ),
5571                    h('tbody', { className: 'divide-y divide-gray-200 dark:divide-gray-700' },
5572                      visibleProspects.map(prospect =>
5573                        // A red row where the mail provider will not deliver. The WHOLE row is
5574                        // tinted rather than given a chip alone: the point is to be unmissable
5575                        // while scanning hundreds of rows before an invitation round, because
5576                        // inviting a dead address again is what damages the sending domain.
5577                        h('tr', {
5578                          key: prospect.id,
5579                          className: prospect.suppression && prospect.suppression.blocking
5580                            ? 'bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/30'
5581                            : 'hover:bg-gray-50 dark:hover:bg-gray-700/30'
5582                        },
5583                          // Name
5584                          h('td', { className: 'px-4 py-3' },
5585                            h('div', { className: 'font-medium text-gray-900 dark:text-white text-sm' },
5586                              `${prospect.firstName || ''} ${prospect.lastName || ''}`.trim() || 'Unknown'
5587                            ),
5588                            h('div', { className: 'text-xs text-gray-500 dark:text-gray-400 md:hidden' }, prospect.company || ''),
5589                            prospect.suppression && prospect.suppression.blocking && h('div', {
5590                              className: 'inline-flex items-center gap-1 mt-1 px-1.5 py-0.5 text-[10px] font-medium bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300 rounded-full',
5591                              title: `${prospect.suppression.label}. Inviting this address again damages our sending domain.`
5592                            }, h(Icons.AlertCircle, { size: 10, 'aria-hidden': 'true' }), prospect.suppression.label),
5593                            // The invitation came back automatically. Worth surfacing on the row
5594                            // rather than only in a filter, because the useful case - somebody who
5595                            // has left and named their replacement - is invisible otherwise.
5596                            prospect.autoReply && prospect.autoReply.detectedAt && h('button', {
5597                              type: 'button',
5598                              onClick: (e) => { e.stopPropagation(); openAutoReply(prospect); },
5599                              title: 'Read the automatic reply',
5600                              className: 'inline-flex items-center gap-1 mt-1 px-1.5 py-0.5 text-[10px] font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 rounded-full hover:bg-amber-200 dark:hover:bg-amber-900/50'
5601                            }, h(Icons.AlertCircle, { size: 10, 'aria-hidden': 'true' }), 'Auto-reply'),
5602                            // The Invited by column is hidden below xl, and a sort by invitation
5603                            // date reads as arbitrary when the date it sorted on is off screen.
5604                            (() => {
5605                              const sentLabel = invitationSentLabel(prospect, prospectInviterIndex);
5606                              return sentLabel && h('div', { className: 'text-xs text-gray-400 dark:text-gray-500 xl:hidden' }, sentLabel);
5607                            })()
5608                          ),
5609                          // Company
5610                          h('td', { className: 'px-4 py-3 hidden md:table-cell' },
5611                            h('div', { className: 'text-sm text-gray-600 dark:text-gray-300' }, prospect.company || '-'),
5612                            prospect.jobTitle && h('div', { className: 'text-xs text-gray-400 dark:text-gray-500' }, prospect.jobTitle)
5613                          ),
5614                          // Contact
5615                          h('td', { className: 'px-4 py-3 hidden lg:table-cell' },
5616                            h('div', { className: 'text-sm text-gray-600 dark:text-gray-300' }, prospect.email || '-'),
5617                            prospect.phone && h('div', { className: 'text-xs text-gray-500 dark:text-gray-400' }, prospect.phone),
5618                            // The link through to the CRM record this prospect was matched to on
5619                            // import. Only drawn when there IS one: a prospect added by hand, or
5620                            // imported with no match, carries null and gets nothing rather than a
5621                            // link to a contact that does not exist.
5622                            //
5623                            // phone.trovex.com/contacts/{id} takes a voip_contacts document id,
5624                            // which is exactly what the import stored (verified against
5625                            // ContactDetail.jsx, which reads doc(CONTACTS_COLL, contactId)).
5626                            prospect.voipContactId && h('a', {
5627                              href: `https://phone.trovex.com/contacts/${prospect.voipContactId}`,
5628                              target: '_blank',
5629                              rel: 'noopener noreferrer',
5630                              onClick: (e) => e.stopPropagation(),
5631                              title: 'Open this contact in Phone',
5632                              className: 'inline-flex items-center gap-1 mt-1 text-xs text-primary-600 dark:text-primary-400 hover:underline'
5633                            }, h(Icons.Phone, { size: 11, 'aria-hidden': 'true' }), 'View in Phone')
5634                          ),
5635                          // Status
5636                          h('td', { className: 'px-4 py-3' },
5637                            h('select', {
5638                              value: prospect.status || 'new',
5639                              onChange: e => handleUpdateProspectStatus(prospect.id, e.target.value),
5640                              className: `text-xs font-medium px-2 py-1 rounded-full border-0 cursor-pointer ${(PROSPECT_STATUSES[prospect.status] || PROSPECT_STATUSES.new).color}`
5641                            },
5642                              Object.values(PROSPECT_STATUSES).map(s =>
5643                                h('option', { key: s.id, value: s.id }, s.label)
5644                              )
5645                            )
5646                          ),
5647                          // Assigned To
5648                          h('td', { className: 'px-4 py-3 hidden lg:table-cell' },
5649                            h('select', {
5650                              // Resolved against the option list case-insensitively rather than
5651                              // bound raw. assignedTo is stamped from user.email on some paths
5652                              // and picked from this list on others, so a prospect stored as
5653                              // '[email protected]' matched no option and rendered as
5654                              // Unassigned while the Assigned to me filter correctly counted it.
5655                              // The row said one thing and the filter said another.
5656                              value: (staffMembersList.find(st =>
5657                                (st.email || '').trim().toLowerCase()
5658                                  === (prospect.assignedTo || '').trim().toLowerCase()
5659                              ) || {}).email || '',
5660                              onChange: e => {
5661                                const staff = staffMembersList.find(s => s.email === e.target.value);
5662                                handleUpdateProspectAssignment(prospect.id, e.target.value, staff ? `${staff.firstName || ''} ${staff.lastName || ''}`.trim() : '');
5663                              },
5664                              className: 'text-xs px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300'
5665                            },
5666                              h('option', { value: '' }, 'Unassigned'),
5667                              staffMembersList.map(s =>
5668                                h('option', { key: s.email, value: s.email }, `${s.firstName || ''} ${s.lastName || ''}`.trim())
5669                              )
5670                            )
5671                          ),
5672                          // Invited by. Not a field on the prospect: resolved from this event's
5673                          // invitations, and the ORIGINAL inviter rather than whoever last sent a
5674                          // reminder. See prospect-inviters.js.
5675                          h('td', { className: 'px-4 py-3 hidden xl:table-cell' },
5676                            (() => {
5677                              const invitedByName = inviterNameForProspect(prospect, prospectInviterIndex, staffMembersByUid);
5678                              const alsoInvited = otherInviterNamesForProspect(prospect, prospectInviterIndex, staffMembersByUid);
5679                              const notInvited = inviterKeyForProspect(prospect, prospectInviterIndex) === NOT_INVITED_KEY;
5680                              // The date of that same ORIGINAL invitation, so the row shows the
5681                              // value the range filter and the sort are both working from.
5682                              const sentLabel = invitationSentLabel(prospect, prospectInviterIndex);
5683                              return h('div', {
5684                                className: `text-xs ${notInvited ? 'text-gray-400 dark:text-gray-500 italic' : 'text-gray-600 dark:text-gray-300'}`,
5685                                title: alsoInvited.length > 0 ? `Also invited or reminded by ${alsoInvited.join(', ')}` : undefined
5686                              },
5687                                invitedByName,
5688                                alsoInvited.length > 0 && h('span', { className: 'text-gray-400 dark:text-gray-500' }, ` +${alsoInvited.length}`),
5689                                sentLabel && h('div', { className: 'text-gray-400 dark:text-gray-500' }, sentLabel)
5690                              );
5691                            })()
5692                          ),
5693                          // Notes
5694                          h('td', { className: 'px-4 py-3 hidden xl:table-cell max-w-[200px]' },
5695                            editingProspectNotes === prospect.id
5696                              ? h('div', { className: 'flex items-center gap-1' },
5697                                  h('input', {
5698                                    type: 'text',
5699                                    value: prospectNotesInput,
5700                                    onChange: e => setProspectNotesInput(e.target.value),
5701                                    onKeyDown: e => {
5702                                      if (e.key === 'Enter') handleUpdateProspectNotes(prospect.id, prospectNotesInput);
5703                                      if (e.key === 'Escape') setEditingProspectNotes(null);
5704                                    },
5705                                    autoFocus: true,
5706                                    className: 'text-xs px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white w-full'
5707                                  }),
5708                                  h('button', {
5709                                    onClick: () => handleUpdateProspectNotes(prospect.id, prospectNotesInput),
5710                                    className: 'text-green-600 hover:text-green-700 flex-shrink-0'
5711                                  }, h(Icons.Check, { size: 14 })),
5712                                  h('button', {
5713                                    onClick: () => setEditingProspectNotes(null),
5714                                    className: 'text-gray-400 hover:text-gray-600 flex-shrink-0'
5715                                  }, h(Icons.X, { size: 14 }))
5716                                )
5717                              : h('div', {
5718                                  onClick: () => { setEditingProspectNotes(prospect.id); setProspectNotesInput(prospect.notes || ''); },
5719                                  className: 'text-xs text-gray-500 dark:text-gray-400 truncate cursor-pointer hover:text-gray-700 dark:hover:text-gray-200',
5720                                  title: prospect.notes || 'Click to add notes'
5721                                }, prospect.notes || h('span', { className: 'italic text-gray-400' }, 'Add notes...'))
5722                          ),
5723                          // Actions
5724                          h('td', { className: 'px-4 py-3 text-right' },
5725                            h('div', { className: 'flex items-center justify-end gap-1' },
5726                              // ONE action per row, whatever the status - see prospect-actions.js.
5727                              // It opens the PREVIEW rather than sending: nothing reaches a customer
5728                              // until a human has read the actual email and pressed send.
5729                              (() => {
5730                                const action = prospectAction(prospect);
5731                                if (!action) return null;
5732                                // Disabled while a preview for this row is loading, or a
5733                                // double-click opens two and they can land out of order.
5734                                const busy = invitingProspect === prospect.id
5735                                  || !!(emailPreview && emailPreview.loading && emailPreview.prospect.id === prospect.id);
5736                                // The stamp is SHOWN, or writing it achieved nothing: an
5737                                // "Invite again" leaves the status alone deliberately, so
5738                                // without this the row looks identical after sending and can
5739                                // be sent again with only a toast as feedback.
5740                                const lastSent = lastSentLabel(prospect);
5741                                return h('button', {
5742                                  onClick: () => openEmailPreview(prospect, action),
5743                                  disabled: busy,
5744                                  title: lastSent ? `${action.title}\n${lastSent}.` : action.title,
5745                                  className: 'inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-blue-700 bg-blue-50 hover:bg-blue-100 dar
5745k:text-blue-400 dark:bg-blue-900/20 dark:hover:bg-blue-900/40 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed'
5746                                },
5747                                  busy
5748                                    ? h('div', { className: 'w-3 h-3 border-2 border-blue-400/30 border-t-blue-600 rounded-full animate-spin' })
5749                                    : h(action.isReminder ? Icons.Bell : Icons.Mail, { size: 13 }),
5750                                  busy ? action.busyLabel : action.label
5751                                );
5752                              })(),
5753                              // More actions dropdown
5754                              h('div', { className: 'relative', 'data-action-menu': true },
5755                                h('button', {
5756                                  onClick: (e) => openActionMenu(e, prospect.id, setOpenProspectAction, setOpenRegAction, PROSPECT_MENU_ITEMS),
5757                                  className: 'p-1.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors'
5758                                }, h(Icons.MoreVertical, { size: 16 })),
5759                                // Portalled to document.body for the same reason as the registrations
5760                                // menu above: the prospects table card carries backdrop-blur-sm (a
5761                                // containing block for position:fixed) plus overflow-hidden.
5762                                openProspectAction?.id === prospect.id && ReactDOM.createPortal(h('div', {
5763                                  'data-action-menu': true,
5764                                  className: 'fixed w-48 bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 py-1 z-[9999]',
5765                                  style: { top: openProspectAction.top, bottom: openProspectAction.bottom, right: openProspectAction.right,
5766                                    maxHeight: openProspectAction.maxHeight, overflowY: 'auto' }
5767                                },
5768                                  // The reminder used to live HERE, and only for an `invited`
5769                                  // prospect - so the one person most needing a chase was the only
5770                                  // one with no button on the row. It is the row action now.
5771                                  // Copy invite link, for sending from the staff member's own
5772                                  // Outlook. Offered whatever the status: the commonest use is
5773                                  // somebody already invited whose link needs re-sending by hand.
5774                                  h('button', {
5775                                    onClick: () => { handleCopyInviteLink(prospect); setOpenProspectAction(null); },
5776                                    disabled: copyingInviteLink === prospect.id,
5777                                    className: 'w-full flex items-center gap-2.5 px-3 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50 disabled:opacity-50'
5778                                  }, h(Icons.Link, { size: 14, className: 'text-primary-500' }),
5779                                    copyingInviteLink === prospect.id ? 'Creating link...' : 'Copy invite link'),
5780                                  // Edit
5781                                  h('button', {
5782                                    onClick: () => {
5783                                      setEditingProspect(prospect);
5784                                      setEditProspectData({
5785                                        firstName: prospect.firstName || '',
5786                                        lastName: prospect.lastName || '',
5787                                        email: prospect.email || '',
5788                                        company: prospect.company || '',
5789                                        jobTitle: prospect.jobTitle || '',
5790                                        phone: prospect.phone || ''
5791                                      });
5792                                      setOpenProspectAction(null);
5793                                    },
5794                                    className: 'w-full flex items-center gap-2.5 px-3 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50'
5795                                  }, h(Icons.Edit, { size: 14, className: 'text-gray-400' }), 'Edit Details'),
5796                                  // Divider
5797                                  h('div', { className: 'border-t border-gray-200 dark:border-gray-700 my-1' }),
5798                                  // Delete
5799                                  h('button', {
5800                                    onClick: () => { handleDeleteProspect(prospect.id); setOpenProspectAction(null); },
5801                                    className: 'w-full flex items-center gap-2.5 px-3 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20'
5802                                  }, h(Icons.Trash, { size: 14 }), 'Remove')
5803                                ), document.body)
5804                              )
5805                            )
5806                          )
5807                        )
5808                      )
5809                    )
5810                  )
5811                ),
5812                // The window control. Only drawn when there is genuinely more to show, so a short
5813                // list carries nothing extra, and it names the real numbers rather than saying
5814                // "more" - a staff member chasing one person needs to know whether the name they
5815                // cannot see is filtered out or merely unpainted.
5816                visibleProspects.length < filteredProspects.length && h('div', {
5817                  className: 'px-4 py-3 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between gap-3 flex-wrap'
5818                },
5819                  h('p', { className: 'text-xs text-gray-500 dark:text-gray-400' },
5820                    `Showing ${visibleProspects.length} of ${filteredProspects.length}. They are all loaded, this is just what is drawn.`
5821                  ),
5822                  h('div', { className: 'flex items-center gap-2' },
5823                    h('button', {
5824                      type: 'button',
5825                      onClick: () => setProspectsShown(n => n + PROSPECT_PAGE),
5826                      className: 'px-3 py-1.5 text-xs font-medium bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 rounded-full hover:bg-primary-100 dark:hover:bg-primary-900/50'
5827                    }, `Show ${Math.min(PROSPECT_PAGE, filteredProspects.length - visibleProspects.length)} more`),
5828                    h('button', {
5829                      type: 'button',
5830                      onClick: () => setProspectsShown(filteredProspects.length),
5831                      className: 'px-3 py-1.5 text-xs font-medium text-gray-600 dark:text-gray-300 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700'
5832                    }, `Show all ${filteredProspects.length}`)
5833                  )
5834                )
5835                )
5836        ),
5837
5838        // THE EMAIL, AS THEY WILL RECEIVE IT. Rendered in a sandboxed iframe: the markup comes
5839        // from our own builder, but it is email HTML being injected into the staff app, and
5840        // srcDoc with an empty sandbox runs nothing and reaches nothing.
5841        emailPreview && ReactDOM.createPortal(h('div', {
5842          className: 'fixed inset-0 bg-black/50 flex items-center justify-center z-[10000] p-4',
5843          onClick: (e) => { if (e.target === e.currentTarget) setEmailPreview(null); }
5844        },
5845          h('div', {
5846            role: 'dialog',
5847            'aria-modal': 'true',
5848            'aria-label': emailPreview.action.isReminder ? 'Send this reminder?' : 'Send this invitation?',
5849            className: 'bg-white dark:bg-gray-800 rounded-2xl shadow-xl w-full max-w-2xl max-h-[90vh] flex flex-col'
5850          },
5851            h('div', { className: 'px-5 py-4 border-b border-gray-200 dark:border-gray-700 flex items-start justify-between gap-4' },
5852              h('div', null,
5853                h('h3', { className: 'text-lg font-semibold text-gray-900 dark:text-white' },
5854                  emailPreview.action.isReminder ? 'Send this reminder?' : 'Send this invitation?'),
5855                h('p', { className: 'text-xs text-gray-500 dark:text-gray-400 mt-0.5' },
5856                  'This is exactly what they will receive. Nothing has been sent yet.')
5857              ),
5858              h('button', {
5859                onClick: () => setEmailPreview(null),
5860                className: 'p-1.5 text-gray-400 hover:text-gray-600 rounded-lg'
5861              }, h(Icons.X, { size: 18 }))
5862            ),
5863            h('div', { className: 'flex-1 overflow-y-auto' },
5864              emailPreview.loading && h('div', { className: 'flex items-center justify-center gap-2 py-12' },
5865                h('div', { className: 'w-5 h-5 border-2 border-gray-300 border-t-primary-600 rounded-full animate-spin' }),
5866                h('span', { className: 'text-sm text-gray-500' }, 'Building the email...')
5867              ),
5868              emailPreview.error && h('div', { className: 'm-4 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 text-amber-800 dark:text-amber-300' },
5869                h('p', { className: 'text-sm font-medium' }, 'The preview could not be built.'),
5870                h('p', { className: 'text-xs mt-1' }, emailPreview.error),
5871                h('p', { className: 'text-xs mt-1' }, 'Nothing has been sent. Try again rather than sending unseen.')
5872              ),
5873              emailPreview.data && h(React.Fragment, null,
5874                h('dl', { className: 'px-5 py-3 text-sm border-b border-gray-100 dark:border-gray-700 space-y-1' },
5875                  h('div', { className: 'flex gap-2' },
5876                    h('dt', { className: 'text-gray-400 w-16 shrink-0' }, 'To'),
5877                    h('dd', { className: 'text-gray-900 dark:text-white' },
5878                      `${emailPreview.data.recipientName || ''} <${emailPreview.data.to}>`.trim())
5879                  ),
5880                  h('div', { className: 'flex gap-2' },
5881                    h('dt', { className: 'text-gray-400 w-16 shrink-0' }, 'Subject'),
5882                    h('dd', { className: 'text-gray-900 dark:text-white font-medium' }, emailPreview.data.subject)
5883                  )
5884                ),
5885                h('iframe', {
5886                  title: 'Email preview',
5887                  srcDoc: emailPreview.data.html,
5888                  sandbox: '',
5889                  className: 'w-full h-[420px] border-0 bg-white'
5890                }),
5891                h('div', { className: 'px-5 py-3 text-xs text-gray-500 dark:text-gray-400 space-y-1 border-t border-gray-100 dark:border-gray-700' },
5892                  emailPreview.data.willAttachCalendar && h('p', null, 'A calendar invite (invite.ics) is attached.'),
5893                  // A first invitation has no token yet, because minting one for a preview that
5894                  // may never be sent would leave an invitation nobody was sent and move the
5895                  // invited-by attribution. The recipient never sees the URL - it is behind the
5896                  // button - so this is the only place the difference shows.
5897                  h('p', null, emailPreview.data.hasLink
5898                    ? 'The button uses the personal link they already have, so any link already in their inbox keeps working.'
5899                    : 'Their personal link is created when you send. The button above is a placeholder here only.')
5900                )
5901              )
5902            ),
5903            h('div', { className: 'px-5 py-3 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2' },
5904              h('button', {
5905                onClick: () => setEmailPreview(null),
5906                className: 'px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full pill-btn'
5907              }, 'Cancel'),
5908              h('button', {
5909                onClick: confirmEmailPreview,
5910                disabled: emailPreview.loading || !!emailPreview.error || !emailPreview.data,
5911                className: 'px-4 py-2 text-sm font-medium bg-primary-600 hover:bg-primary-700 text-white rounded-full pill-btn disabled:opacity-50 disabled:cursor-not-allowed'
5912              }, emailPreview.action.isReminder ? 'Send reminder' : 'Send invitation')
5913            )
5914          )
5915        ), document.body),
5916        // The automatic reply itself. A modal rather than an expanding row: the useful sentence is
5917        // often several lines in ("I have left, please contact Jane Smith on 0161..."), and a row
5918        // that grows by six lines makes the list harder to scan for everybody else.
5919        autoReplyReading && h('div', {
5920          className: 'fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4',
5921          onClick: (e) => e.target === e.currentTarget && closeAutoReply()
5922        },
5923          h('div', {
5924            role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Automatic reply',
5925            className: 'bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 w-full max-w-lg max-h-[80vh] flex flex-col'
5926          },
5927            h('div', { className: 'flex items-start justify-between gap-3 p-4 border-b border-gray-200 dark:border-gray-700' },
5928              h('div', { className: 'min-w-0' },
5929                h('h3', { className: 'text-base font-semibold text-gray-900 dark:text-white' },
5930                  `${autoReplyReading.prospect.firstName || ''} ${autoReplyReading.prospect.lastName || ''}`.trim() || 'Automatic reply'),
5931                h('p', { className: 'text-xs text-gray-500 dark:text-gray-400 truncate' }, autoReplyReading.prospect.email || '')
5932              ),
5933              h('button', {
5934                onClick: closeAutoReply,
5935                'aria-label': 'Close',
5936                className: 'p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded flex-shrink-0'
5937              }, h(Icons.X, { size: 18, 'aria-hidden': 'true' }))
5938            ),
5939            h('div', { className: 'p-4 space-y-3 overflow-y-auto' },
5940              h('p', { className: 'text-xs text-gray-500 dark:text-gray-400' },
5941                'Their mail system replied to the invitation automatically. If they have moved on it often says who to contact instead.'),
5942              autoReplyReading.prospect.autoReply.subject && h('p', { className: 'text-sm font-medium text-gray-900 dark:text-white' },
5943                autoReplyReading.prospect.autoReply.subject),
5944              // Rendered as TEXT. This came from outside the business and is shown to staff, so it
5945              // is never given to the page as markup.
5946              // The whole message once it arrives, the row's extract until then. Rendered as TEXT
5947              // in both cases: this came from outside the business.
5948              (autoReplyFull.state === 'ready'
5949                ? h('p', { className: 'text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap' }, autoReplyFull.text)
5950                : autoReplyReading.prospect.autoReply.extract
5951                  ? h('p', { className: 'text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap' },
5952                      autoReplyReading.prospect.autoReply.extract)
5953                  : h('p', { className: 'text-sm text-gray-500 dark:text-gray-400 italic' },
5954                      'The message body was only the quoted invitation coming back, so there is nothing to show beyond the subject.')),
5955              autoReplyFull.state === 'loading' && h('p', { className: 'text-xs text-gray-400' }, 'Loading the rest...'),
5956              autoReplyFull.state === 'error' && h('p', { className: 'text-xs text-amber-600 dark:text-amber-400' },
5957                'The rest of the message could not be loaded. What is shown above is the opening of it.'),
5958              autoReplyReading.prospect.autoReply.backfilled && h('p', { className: 'text-xs text-gray-400 dark:text-gray-500' },
5959                'Found in the inbox after the event. Only the opening of the message was kept.')
5960            )
5961          )
5962        ),
5963
5964        // Import prospects from a CSV. `prospects` is the live list, so the modal can skip anybody
5965        // already here rather than creating a duplicate.
5966        showProspectImport && h(ProspectImportModal, {
5967          eventId,
5968          user,
5969          staffMembersList,
5970          existingProspects: prospects,
5971          registrations,
5972          // The import holds until this is true. Without it the seat check fails OPEN on an empty
5973          // list, silently importing people who already have a seat.
5974          registrationsReady: !registrationsLoading,
5975          onClose: () => setShowProspectImport(false),
5976          onImported: (count) => setMessage({ type: 'success', text: `Imported ${count} prospect${count === 1 ? '' : 's'}. Nobody has been invited.` })
5977        }),
5978
5979        // Add Prospect Modal
5980        showAddProspectModal && h('div', { className: 'fixed inset-0 z-50 flex items-center justify-center p-4' },
5981          h('div', { className: 'absolute inset-0 bg-black/50', onClick: () => setShowAddProspectModal(false) }),
5982          h('div', { role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Add prospect', className: 'relative bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm rounded-2xl shadow-xl w-full max-w-lg max-h-[80vh] flex flex-col' },
5983            // Modal header
5984            h('div', { className: 'flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700' },
5985              h('h3', { className: 'text-lg font-semibold text-gray-900 dark:text-white' }, 'Add Prospect'),
5986              h('button', { onClick: () => setShowAddProspectModal(false), className: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-200' },
5987                h(Icons.X, { size: 20 })
5988              )
5989            ),
5990            // Modal tabs
5991            h('div', { className: 'flex border-b border-gray-200 dark:border-gray-700' },
5992              h('button', {
5993                onClick: () => setProspectModalTab('members'),
5994                className: `flex-1 px-4 py-2.5 text-sm font-medium border-b-2 ${prospectModalTab === 'members' ? 'border-primary-600 text-primary-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`
5995              }, 'Search Members'),
5996              h('button', {
5997                onClick: () => setProspectModalTab('contacts'),
5998                className: `flex-1 px-4 py-2.5 text-sm font-medium border-b-2 ${prospectModalTab === 'contacts' ? 'border-primary-600 text-primary-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`
5999              }, 'Search All Contacts'),
6000              h('button', {
6001                onClick: () => setProspectModalTab('manual'),
6002                className: `flex-1 px-4 py-2.5 text-sm font-medium border-b-2 ${prospectModalTab === 'manual' ? 'border-primary-600 text-primary-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`
6003              }, 'Add New')
6004            ),
6005            // Modal content
6006            h('div', { className: 'p-4 overflow-y-auto flex-1' },
6007              // Search Members tab (local filter of eventsUsers)
6008              prospectModalTab === 'members'
6009                ? h('div', { className: 'space-y-3' },
6010                    h('input', {
6011                      type: 'text',
6012                      value: prospectModalSearch,
6013                      onChange: e => setProspectModalSearch(e.target.value),
6014                      placeholder: 'Search registered members by name, email, or company...',
6015                      autoFocus: true,
6016                      className: 'w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white'
6017                    }),
6018                    h('p', { className: 'text-xs text-gray-400 dark:text-gray-500' }, `${members.length} members on the platform`),
6019                    prospectModalSearch.length >= 2 && prospectModalSearchResults.length === 0 &&
6020                      h('p', { className: 'text-sm text-gray-500 dark:text-gray-400 text-center py-4' }, 'No matching members. Try "Search All Contacts" or "Add New".'),
6021                    prospectModalSearchResults.map(member =>
6022                      h('div', {
6023                        key: member.id,
6024                        className: 'flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700'
6025                      },
6026                        h('div', null,
6027                          h('div', { className: 'flex items-center gap-1.5' },
6028                            h('span', { className: 'font-medium text-sm text-gray-900 dark:text-white' },
6029                              `${member.firstName || ''} ${member.lastName || ''}`.trim()
6030                            ),
6031                            member.alreadyProspect && h('span', { className: 'shrink-0 px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' }, 'Already a prospect'),
6032                            !member.alreadyProspect && member.alreadyRegistered && h('span', { className: 'shrink-0 px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-400' }, addRowState(member).chip)
6033                          ),
6034                          h('div', { className: 'text-xs text-gray-500 dark:text-gray-400' },
6035                            `${member.email || ''}${member.organisation || member.company ? ` · ${member.organisation || member.company}` : ''}`
6036                          ),
6037                          (member.jobTitle || member.position) && h('div', { className: 'text-xs text-gray-400 dark:text-gray-500' }, member.jobTitle || member.position)
6038                        ),
6039                        // The title sits on the WRAPPER: WebKit and Blink give a disabled
6040                        // control no pointer events, so a title on the button itself never
6041                        // appears in Chrome or Safari.
6042                        (() => { const st = addRowState(member); return h('span', { title: st.why }, h('button', {
6043                          onClick: () => handleAddProspect(member),
6044                          disabled: addingProspect || st.blocked,
6045                          className: 'px-3 py-1.5 text-xs font-medium bg-primary-600 hover:bg-primary-700 text-white rounded-full pill-btn disabled:opacity-50 disabled:cursor-not-allowed'
6046                        }, st.label || (addingProspect ? 'Adding...' : 'Add'))); })()
6047                      )
6048                    )
6049                  )
6050              // Search All Contacts tab (async addressBook search)
6051              : prospectModalTab === 'contacts'
6052                ? h('div', { className: 'space-y-3' },
6053                    h('input', {
6054                      type: 'text',
6055                      value: prospectContactsSearch,
6056                      onChange: e => setProspectContactsSearch(e.target.value),
6057                      placeholder: 'Search all contacts by name, email, or company...',
6058                      autoFocus: true,
6059                      className: 'w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white'
6060                    }),
6061                    h('p', { className: 'text-xs text-gray-400 dark:text-gray-500' }, 'Searches the full Phone contact database, plus contacts added here'),
6062                    prospectContactsSearching && h('div', { className: 'flex items-center justify-center py-4 gap-2' },
6063                      h('div', { className: 'w-4 h-4 border-2 border-gray-300 border-t-primary-600 rounded-full animate-spin' }),
6064                      h('span', { className: 'text-sm text-gray-500' }, 'Searching...')
6065                    ),
6066                    // A FAILED search and an EMPTY one say different things. Telling somebody to
6067                    // create a contact who is already in the database is how duplicates get made,
6068                    // and it is what happened on 2026-09-07. Parti
6068al and total failure differ too:
6069                    // the Gold-Vision half fires about fifteen parallel probes and one can fail on
6070                    // its own, so "could not be completed" over a list of results would be its own
6071                    // small lie.
6072                    // Before anything is typed, a failed SEED load is the same lie as a failed
6073                    // search: an empty panel that reads as an empty database.
6074                    prospectContactsSearch.length < 2 && prospectContactsInitialFailed &&
6075                      h('div', { className: 'text-sm py-3 px-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 text-amber-800 dark:text-amber-300' },
6076                        h('p', { className: 'font-medium' }, 'The contact list could not be loaded.'),
6077                        h('p', { className: 'text-xs mt-1' }, 'Search by name to try again. This does not mean the database is empty.')
6078                      ),
6079                    !prospectContactsSearching && prospectContactsSearch.length >= 2 && prospectContactsFailed &&
6080                      h('div', { className: 'text-sm py-3 px-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 text-amber-800 dark:text-amber-300' },
6081                        prospectContactsShown.length === 0
6082                          ? h(React.Fragment, null,
6083                              h('p', { className: 'font-medium' }, 'The contact search could not be completed.'),
6084                              h('p', { className: 'text-xs mt-1' }, 'This does not mean the contact is missing. Try again, and do not add them by hand until it works.')
6085                            )
6086                          : h(React.Fragment, null,
6087                              h('p', { className: 'font-medium' }, 'Part of the search did not run, so this list may be incomplete.'),
6088                              h('p', { className: 'text-xs mt-1' }, 'Search again before deciding somebody is not here.')
6089                            )
6090                      ),
6091                    !prospectContactsSearching && !prospectContactsFailed && prospectContactsSearch.length >= 2 && prospectContactsShown.length === 0 &&
6092                      h('p', { className: 'text-sm text-gray-500 dark:text-gray-400 text-center py-4' }, 'No matching contacts found. Try "Add New" to create one.'),
6093                    prospectContactsShown.map(contact =>
6094                      h('div', {
6095                        key: contact.id,
6096                        className: 'flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700'
6097                      },
6098                        h('div', null,
6099                          h('div', { className: 'flex items-center gap-1.5' },
6100                            h('span', { className: 'font-medium text-sm text-gray-900 dark:text-white' },
6101                              `${contact.firstName || ''} ${contact.lastName || ''}`.trim()
6102                            ),
6103                            contact.notInGv && h('span', { className: 'shrink-0 px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' }, 'Not in GV'),
6104                            contact.alreadyProspect && h('span', { className: 'shrink-0 px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' }, 'Already a prospect'),
6105                            !contact.alreadyProspect && contact.alreadyRegistered && h('span', { className: 'shrink-0 px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-400' }, addRowState(contact).chip)
6106                          ),
6107                          h('div', { className: 'text-xs text-gray-500 dark:text-gray-400' },
6108                            contact.email
6109                              ? `${contact.email}${contact.organisation || contact.company ? ` · ${contact.organisation || contact.company}` : ''}`
6110                              : h(React.Fragment, null,
6111                                  h('span', { className: 'text-amber-600 dar
6111k:text-amber-400' }, 'No email address'),
6112                                  (contact.organisation || contact.company) ? ` · ${contact.organisation || contact.company}` : ''
6113                                )
6114                          ),
6115                          (contact.jobTitle || contact.position) && h('div', { className: 'text-xs text-gray-400 dark:text-gray-500' }, contact.jobTitle || contact.position)
6116                        ),
6117                        // Title on the WRAPPER, for the reason the members tab gives above.
6118                        (() => { const st = addRowState(contact); return h('span', { title: st.why }, h('button', {
6119                          onClick: () => handleAddProspect(contact),
6120                          disabled: addingProspect || st.blocked,
6121                          className: 'px-3 py-1.5 text-xs font-medium bg-primary-600 hover:bg-primary-700 text-white rounded-full pill-btn disabled:opacity-50 disabled:cursor-not-allowed'
6122                        }, st.label || (addingProspect ? 'Adding...' : 'Add'))); })()
6123                      )
6124                    )
6125                  )
6126                : h('div', { className: 'space-y-3' },
6127                    h('div', { className: 'grid grid-cols-2 gap-3' },
6128                      h('div', null,
6129                        h('label', { className: 'block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1' }, 'First Name *'),
6130                        h('input', {
6131                          type: 'text',
6132                          value: manualProspect.firstName,
6133                          onChange: e => setManualProspect(p => ({ ...p, firstName: e.target.value })),
6134                          className: 'w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white'
6135                        })
6136                      ),
6137                      h('div', null,
6138                        h('label', { className: 'block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1' }, 'Last Name'),
6139                        h('input', {
6140                          type: 'text',
6141                          value: manualProspect.lastName,
6142                          onChange: e => setManualProspect(p => ({ ...p, lastName: e.target.value })),
6143                          className: 'w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white'
6144                        })
6145                      )
6146                    ),
6147                    h('div', null,
6148                      h('label', { className: 'block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1' }, 'Email *'),
6149                      h('input', {
6150                        type: 'email',
6151                        value: manualProspect.email,
6152                        onChange: e => setManualProspect(p => ({ ...p, email: e.target.value })),
6153                        className: 'w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white'
6154                      })
6155                    ),
6156                    h('div', null,
6157                      h('label', { className: 'block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1' }, 'Company'),
6158                      h('input', {
6159                        type: 'text',
6160                        value: manualProspect.company,
6161                        onChange: e => setManualProspect(p => ({ ...p, company: e.target.value })),
6162                        className: 'w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white'
6163                      })
6164                    ),
6165                    h('div', null,
6166                      h('label', { className: 'block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1' }, 'Job Title'),
6167                      h('input', {
6168                        type: 'text',
6169                        value: manualProspect.jobTitle,
6170                        onChange: e => setManualProspect(p => ({ ...p, jobTitle: e.target.value })),
6171                        className: 'w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white'
6172                      })
6173                    ),
6174                    h('div', null,
6175                      h('label', { className: 'block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1' }, 'Phone'),
6176                      h('input', {
6177                        type: 'tel',
6178                        value: manualProspect.phone,
6179                        onChange: e => setManualProspect(p => ({ ...p, phone: e.target.value })),
6180                        className: 'w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white'
6181                      })
6182                    ),
6183                    h('button', {
6184                      onClick: handleAddManualProspect,
6185                      disabled: addingProspect || !manualProspect.firstName.trim() || !manualProspect.email.trim(),
6186                      className: 'w-full px-4 py-2 text-sm font-medium bg-primary-600 hover:bg-primary-700 text-white rounded-full pill-btn disabled:opacity-50'
6187                    }, addingProspect ? 'Adding...' : 'Add Prospect')
6188                  )
6189            )
6190          )
6191        )
6192      ); })(),
6193
6194      // ===== EDIT REGISTRATION MODAL =====
6195      editingRegistration && h('div', { className: 'fixed inset-0 z-50 flex items-center justify-center p-4' },
6196        h('div', { className: 'absolute inset-0 bg-black/50', onClick: () =>
6196 setEditingRegistration(null) }),
6197        h('div', { role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Edit registration', className: 'relative bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm rounded-2xl shadow-xl w-full max-w-md' },
6198          h('div', { className: 'flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700' },
6199            h('h3', { className: 'text-lg font-semibold text-gray-900 dark:text-white' }, 'Edit Registration'),
6200            h('button', { onClick: () => setEditingRegistration(null), className: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-200' },
6201              h(Icons.X, { size: 20 })
6202            )
6203          ),
6204          h('div', { className: 'p-4 space-y-4' },
6205            h('div', null,
6206              h('label', { className: 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1' }, 'Name'),
6207              h('input', {
6208                type: 'text',
6209                value: editRegData.userName,
6210                onChange: e => setEditRegData(d => ({ ...d, userName: e.target.value })),
6211                className: 'w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500'
6212              })
6213            ),
6214            h('div', null,
6215              h('label', { className: 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1' }, 'Company / Organisation'),
6216              h('input', {
6217                type: 'text',
6218                value: editRegData.organisation,
6219                onChange: e => setEditRegData(d => ({ ...d, organisation: e.target.value })),
6220                className: 'w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500'
6221              })
6222            ),
6223            h('div', null,
6224              h('label', { className: 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1' }, 'Job Title'),
6225              h('input', {
6226                type: 'text',
6227                value: editRegData.jobTitle,
6228                onChange: e => setEditRegData(d => ({ ...d, jobTitle: e.target.value })),
6229                className: 'w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500'
6230              })
6231            )
6232          ),
6233          h('div', { className: 'flex items-center justify-end gap-2 p-4 border-t border-gray-200 dark:border-gray-700' },
6234            h('button', {
6235              onClick: () => setEditingRegistration(null),
6236              className: 'px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors'
6237            }, 'Cancel'),
6238            h('button', {
6239              onClick: saveRegistrationEdit,
6240              disabled: savingRegistration,
6241              className: 'px-4 py-2 text-sm font-medium bg-primary-600 hover:bg-primary-700 text-white rounded-full pill-btn disabled:opacity-50 transition-colors'
6242            }, savingRegistration ? 'Saving...' : 'Save Changes')
6243          )
6244        )
6245      ),
6246
6247      // ===== INVITE LINK =====
6248      // Shown after Copy invite link. The link is on screen whether or not the clipboard write
6249      // worked, and the Copy button here is a FRESH user gesture, so it succeeds in Safari where
6250      // the automatic write after a server round trip does not.
6251      inviteLink && h('div', { className: 'fixed inset-0 z-50 flex items-center justify-center p-4' },
6252        h('div', { className: 'absolute inset-0 bg-black/50', onClick: () => setInviteLink(null) }),
6253        h('div', {
6254          role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Invitation link',
6255          className: 'relative bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm rounded-2xl shadow-xl w-full max-w-lg'
6256        },
6257          h('div', { className: 'flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700' },
6258            h('h3', { className: 'text-lg font-semibold text-gray-900 dark:text-white' }, 'Invitation link'),
6259            h('button', {
6260              onClick: () => setInviteLink(null),
6261              'aria-label': 'Close',
6262              className: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-200'
6263            }, h(Icons.X, { size: 20 }))
6264          ),
6265          h('div', { className: 'p-4 space-y-4' },
6266            h('p', { className: 'text-sm text-gray-600 dark:text-gray-300' },
6267              'Paste this into your own email to ',
6268              h('span', { className: 'font-medium text-gray-900 dark:text-white' },
6269                `${inviteLink.prospect?.firstName || ''} ${inviteLink.prospect?.lastName || ''}`.trim() || inviteLink.email),
6270              '. It takes them straight to the event and asks them to confirm, with no sign-in to get past.'
6271            ),
6272            h('div', { className: 'flex items-center gap-2' },
6273              h('input', {
6274                type: 'text',
6275                readOnly: true,
6276                value: inviteLink.url,
6277                'aria-label': 'Invitation link',
6278                onFocus: (e) => e.target.select(),
6279                ref: (el) => { if (el && !inviteLink.copied) el.select(); },
6280                className: 'flex-1 px-3 py-2 text-sm font-mono border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white'
6281              }),
6282              h('button', {
6283                onClick: async () => {
6284                  try {
6285                    await navigator.clipboard.writeText(inviteLink.url);
6286                    setInviteLink(current => (current ? { ...current, copied: true } : current));
6287                  } catch (err) {
6288                    // Nothing to recover: the link is on screen and selected, so it can be copied
6289                    // by hand. Saying "copied" when it was not would be the only real failure here.
6290                    console.warn('[AdminEventPage] Clipboard refused:', err.message);
6291                  }
6292                },
6293                className: 'inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium bg-primary-600 hover:bg-primary-700 text-white rounded-lg whitespace-nowrap'
6294              }, h(inviteLink.copied ? Icons.Check : Icons.Copy, { size: 14 }), inviteLink.copied ? 'Copied' : 'Copy')
6295            ),
6296            h('p', { className: 'text-xs text-gray-500 dark:text-gray-400' },
6297              inviteLink.expiresAt
6298                ? `This link works until ${new Date(inviteLink.expiresAt).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}.`
6299                : 'This link stays live until the event.',
6300              ' Sending it is up to you - nothing has been emailed.'
6301            )
6302          )
6303        )
6304      ),
6305
6306      // ===== EDIT PROSPECT MODAL =====
6307      editingProspect && h('div', { className: 'fixed inset-0 z-50 flex items-center justify-center p-4' },
6308        h('div', { className: 'absolute inset-0 bg-black/50', onClick: () => setEditingProspect(null) }),
6309        h('div', { role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Edit prospect', className: 'relative bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm rounded-2xl shadow-xl w-full max-w-md' },
6310          h('div', { className: 'flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700' },
6311            h('h3', { className: 'text-lg font-semibold text-gray-900 dark:text-white' }, 'Edit Prospect'),
6312            h('button', { onClick: () => setEditingProspect(null), className: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-200' },
6313              h(Icons.X, { size: 20 })
6314            )
6315          ),
6316          h('div', { className: 'p-4 space-y-4' },
6317            h('div', { className: 'grid grid-cols-2 gap-4' },
6318              h('div', null,
6319                h('label', { className: 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1' }, 'First Name'),
6320                h('input', {
6321                  type: 'text',
6322                  value: editProspectData.firstName,
6323                  onChange: e => setEditProspectData(d => ({ ...d, firstName: e.target.value })),
6324                  className: 'w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500'
6325                })
6326              ),
6327              h('div', null,
6328                h('label', { className: 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1' }, 'Last Name'),
6329                h('input', {
6330                  type: 'text',
6331                  value: editProspectData.lastName,
6332                  onChange: e => setEditProspectData(d => ({ ...d, lastName: e.target.value })),
6333                  className: 'w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500'
6334                })
6335              )
6336            ),
6337            h('div', null,
6338              h('label', { className: 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1' }, 'Email'),
6339              h('input', {
6340                type: 'email',
6341                value: editProspectData.email,
6342                disabled: true,
6343                className: 'w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-100 dark:bg-gray-600 text-gray-500 dark:text-gray-400 text-sm cursor-not-allowed'
6344              })
6345            ),
6346            h('div', null,
6347              h('label', { className: 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1' }, 'Company / Organisation'),
6348              h('input', {
6349                type: 'text',
6350                value: editProspectData.company,
6351                onChange: e => setEditProspectData(d => ({ ...d, company: e.target.value })),
6352                className: 'w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500'
6353              })
6354            ),
6355            h('div', null,
6356              h('label', { className: 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1' }, 'Job Title'),
6357              h('input', {
6358                type: 'text',
6359                value: editProspectData.jobTitle,
6360                onChange: e => setEditProspectData(d => ({ ...d, jobTitle: e.target.value })),
6361                className: 'w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500'
6362              })
6363            ),
6364            h('div', null,
6365              h('label', { className: 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1' }, 'Phone'),
6366              h('input', {
6367                type: 'tel',
6368                value: editProspectData.phone,
6369                onChange: e => setEditProspectData(d => ({ ...d, phone: e.target.value })),
6370                className: 'w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500'
6371              })
6372            ),
6373            // Info about syncing
6374            h('div', { className: 'flex items-start gap-2 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg' },
6375              h(Icons.Info, { size: 16, className: 'text-blue-500 flex-shrink-0 mt-0.5' }),
6376              h('p', { className: 'text-xs text-blue-700 dark:text-blue-400' },
6377                'Changes will sync to this prospect, their member record, address book entry, and any other event prospect records for this person.'
6378              )
6379            )
6380          ),
6381          h('div', { className: 'flex items-center justify-end gap-2 p-4 border-t border-gray-200 dark:border-gray-700' },
6382            h('button', {
6383              onClick: () =>
6383 setEditingProspect(null),
6384              className: 'px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors'
6385            }, 'Cancel'),
6386            h('button', {
6387              onClick: handleSaveProspectEdit,
6388              disabled: savingProspect,
6389              className: 'px-4 py-2 text-sm font-medium bg-primary-600 hover:bg-primary-700 text-white rounded-full pill-btn disabled:opacity-50 transition-colors'
6390            }, savingProspect ? 'Saving...' : 'Save Changes')
6391          )
6392        )
6393      ),
6394
6395      // ===== FEEDBACK TAB =====
6396      activeTab === 'feedback' && isStaff && h('div', { className: 'bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm rounded-2xl border border-gray-200/60 dark:border-white/10' },
6397        // Feedback Header with Stats
6398        h('div', { className: 'p-4 border-b border-gray-200 dark:border-gray-700' },
6399          (() => {
6400            const ratings = feedback.map(f => f.usefulnessRating).filter(r => typeof r === 'number' && r > 0);
6401            const requests = registrations.filter(r => r.feedbackRequested).length;
6402            const responses = feedback.length;
6403            const responseRate = requests > 0 ? Math.round((responses / requests) * 100) : null;
6404            const avg = ratings.length ? (ratings.reduce((s, r) => s + r, 0) / ratings.length) : null;
6405            const sorted = [...ratings].sort((a, b) => a - b);
6406            const median = sorted.length ? (sorted.length % 2 ? sorted[(sorted.length - 1) / 2] : (sorted[sorted.length / 2 - 1] + s
6406orted[sorted.length / 2]) / 2) : null;
6407            const dist = Array.from({ length: 10 }, (_, i) => ratings.filter(r => r === i + 1).length);
6408            const maxBucket = Math.max(1, ...dist);
6409            return h('div', null,
6410              h('div', { className: 'grid grid-cols-2 sm:grid-cols-4 gap-3' },
6411                h('div', { className: 'bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 text-center' },
6412                  h('p', { className: 'text-2xl font-bold text-blue-600' }, requests),
6413                  h('p', { className: 'text-xs text-blue-600/70' }, 'Requests Sent')
6414                ),
6415                h('div', { className: 'bg-green-50 dark:bg-green-900/20 rounded-lg p-4 text-center' },
6416                  h('p', { className: 'text-2xl font-bold text-green-600' }, responses),
6417                  h('p', { className: 'text-xs text-green-600/70' }, 'Responses')
6418                ),
6419                h('div', { className: 'bg-primary-50 dark:bg-primary-900/20 rounded-lg p-4 text-center' },
6420                  h('p', { className: 'text-2xl font-bold text-primary-600' }, responseRate === null ? '–' : `${responseRate}%`),
6421                  h('p', { className: 'text-xs text-primary-600/70' }, 'Response Rate')
6422                ),
6423                h('div', { className: 'bg-amber-50 dark:bg-amber-900/20 rounded-lg p-4 text-center' },
6424                  h('p', { className: 'text-2xl font-bold text-amber-600' }, avg === null ? '–' : avg.toFixed(1)),
6425                  h('p', { className: 'text-xs text-amber-600/70' }, median === null ? 'Avg Rating' : `Avg · Median ${median}`)
6426                )
6427              ),
6428              ratings.length > 0 && h('div', { className: 'mt-4' },
6429                h('p', { className: 'text-xs font-medium text-gray-500 dark:text-gray-400 mb-2' }, 'Rating distribution (1–10)'),
6430                h('div', { className: 'flex items-end gap-1 h-16' },
6431                  dist.map((c, i) => h('div', {
6432                    key: i,
6433                    className: 'flex-1 flex flex-col items-center justify-end gap-0.5',
6434                    title: `Rating ${i + 1}: ${c} response${c === 1 ? '' : 's'}`
6435                  },
6436                    h('div', {
6437                      className: `w-full rounded-t ${i + 1 >= 8 ? 'bg-green-400 dark:bg-green-500' : i + 1 >= 5 ? 'bg-amber-400 dark:bg-amber-500' : 'bg-red-400 dark:bg-red-500'}`,
6438                      style: { height: `${(c / maxBucket) * 100}%`, minHeight: c > 0 ? '4px' : '0px' }
6439                    }),
6440                    h('span', { className: 'text-[10px] text-gray-400 dark:text-gray-500' }, i + 1)
6441                  ))
6442                )
6443              )
6444            );
6445          })()
6446        ),
6447
6448        // Feedback content
6449        h('div', { className: 'p-4' },
6450          feedbackLoading
6451            ? h('div', { className: 'flex justify-center py-8' },
6452                h('div', { className: 'w-8 h-8 border-3 border-primary-200 border-t-primary-600 rounded-full animate-spin' })
6453              )
6454            : feedback.length === 0
6455              ? h('div', { className: 'text-center py-8' },
6456                  h(Icons.Star, { size: 48, className: 'text-gray-300 dark:text-gray-600 mx-auto mb-3' }),
6457                  h('p', { className: 'text-gray-500 dark:text-gray-400 font-medium' }, 'No feedback yet'),
6458                  h('p', { className: 'text-sm text-gray-400 dark:text-gray-500' },
6459                    registrations.filter(r => r.feedbackRequested).length > 0
6460                      ? 'Feedback will appear here once attendees respond'
6461                      : 'Request feedback from attended users in the Registrations tab'
6462                  )
6463                )
6464              : h('div', { className: 'space-y-2' },
6465                  feedback.map(fb =>
6466                    h('div', {
6467                      key: fb.id,
6468                      className: 'bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg overflow-hidden'
6469                    },
6470                      // Condensed header row - clickable to expand
6471                      h('div', {
6472                        className: 'flex items-center gap-3 p-3 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600/50 transition-colors',
6473                        onClick: () => setExpandedFeedbackId(expandedFeedbackId === fb.id ? null : fb.id)
6474                      },
6475                        // Expand/collapse icon
6476                        h('div', { className: 'text-gray-400' },
6477                          h(expandedFeedbackId === fb.id ? Icons.ChevronDown : Icons.ChevronRight, { size: 16 })
6478                        ),
6479                        // User info
6480                        h('div', { className: 'flex-1 min-w-0' },
6481                          h('p', { className: 'font-medium text-gray-900 dark:text-white text-sm truncate' }, fb.userName || 'Anonymous'),
6482                          h('p', { className: 'text-xs text-gray-500 truncate' }, fb.userEmail)
6483                        ),
6484                        // Date
6485                        h('p', { className: 'text-xs text-gray-400 hidden sm:block' },
6486                          fb.submittedAt?.toDate?.()?.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })
6487                        ),
6488                        // Rating badge
6489                        h('div', {
6490                          className: `px-2 py-1 rounded-md font-bold text-sm ${
6491                            fb.usefulnessRating >= 8 ? 'bg-green-100 dark:bg-green-900/30 text-green-600' :
6492                            fb.usefulnessRating >
6492= 5 ? 'bg-amber-100 dark:bg-amber-900/30 text-amber-600' :
6493                            'bg-red-100 dark:bg-red-900/30 text-red-600'
6494                          }`
6495                        }, `${fb.usefulnessRating}/10`),
6496                        // Delete button
6497                        h('button', {
6498                          onClick: (e) => { e.stopPropagation(); deleteFeedback(fb.id); },
6499                          className: 'p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors',
6500                          title: 'Delete feedback'
6501                        }, h(Icons.Trash, { size: 14 }))
6502                      ),
6503
6504                      // Expanded feedback details
6505                      expandedFeedbackId === fb.id && h('div', { className: 'px-4 pb-4 pt-2 border-t border-gray-200 dark:border-gray-600 space-y-3 text-sm' },
6506                        fb.enjoyedMost && h('div', null,
6507                          h('p', { className: 'font-medium text-gray-700 dark:text-gray-300' }, 'What they enjoyed:'),
6508                          h('p', { className: 'text-gray-600 dark:text-gray-400 mt-0.5' }, fb.enjoyedMost)
6509                        ),
6510                        fb.suggestedChanges && h('div', null,
6511                          h('p', { className: 'font-medium text-gray-700 dark:text-gray-300' }, 'Suggested improvements:'),
6512                          h('p', { className: 'text-gray-600 dark:text-gray-400 mt-0.5' }, fb.suggestedChanges)
6513                        ),
6514                        fb.colleagueReferrals && h('div', null,
6515                          h('p', { className: 'font-medium text-gray-700 dark:text-gray-300' }, 'Colleague referrals:'),
6516                          h('p', { className: 'text-gray-600 dark:text-gray-400 mt-0.5' }, fb.colleagueReferrals)
6517                        ),
6518                        fb.topicSuggestions && h('div', null,
6519                          h('p', { className: 'font-medium text-gray-700 dark:text-gray-300' }, 'Topic suggestions:'),
6520                          h('p', { className: 'text-gray-600 dark:text-gray-400 mt-0.5' }, fb.topicSuggestions)
6521                        ),
6522                        h('p', { className: 'text-xs text-gray-400 pt-2' },
6523                          'Submitted ',
6524                          fb.submittedAt?.toDate?.()?.toLocaleDateString('en-GB', {
6525                            day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit'
6526                          })
6527                        )
6528                      )
6529                    )
6530                  )
6531                )
6532        )
6533      )
6534    )
6535  );
6536}
6537
6538export { AdminEventPage };

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.