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;">📅</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;">📍</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;">🎓</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 & 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.