WhenFree — Find the time. Finally.
Accessibility
Text Size
Increase or decrease text
High Contrast
Boost colors and contrast
Reduce Motion
Disable animations
Focus Highlight
Bold focus outline
ADHD Friendly
Reduce distractions
01Name your event
02Who's organizing this?
Featured on LaunchBuff Browse us on CodeTrendy
WhenFree — Find the time. Finally.
`; } function buildBestTimesEmailHtml() { const slots = S.bestSlots || []; if (!slots.length) return ''; const url = escHtml(window.location.href); const name = escHtml(S.eventName || 'Meeting'); const lang = t(); const isRtl = lang.dir === 'rtl'; const cellDir = `direction:${lang.dir};text-align:${isRtl?'right':'left'};`; const lbl1 = isRtl ? '#1 ★ הזמן הטוב ביותר' : currentLang==='fr' ? '#1 ★ Meilleur créneau' : '#1 ★ Best time'; const heading = isRtl ? 'זמנים מומלצים עבור' : currentLang==='fr' ? 'Meilleurs créneaux pour' : 'Best Times For'; const subtext = isRtl ? 'הנה הזמנים הטובים ביותר על פי כל המשתתפים:' : currentLang==='fr' ? 'Voici les meilleurs créneaux selon les réponses de tous :' : "Here are the top time slots based on everyone's responses:"; const ctaBtn = isRtl ? 'צפה וסמן זמינות ←' : currentLang==='fr' ? 'Voir et marquer les disponibilités →' : 'View & Mark Availability →'; const cardHtml = slots.map((s, i) => i === 0 ? `
${lbl1}
${s.dateLabel}
${s.timeLabel}
${s.availLabel}
` : `
#${s.rank}
${s.dateLabel}
${s.timeLabel}
${s.availLabel}
`).join(''); const body = `

${heading}

${name}

${subtext}

${cardHtml} ${ctaBtn}

${url}

`; return buildEmailTemplate(body, lang.dir); } // ══════════════════════════════════════ // SCREEN B: DRAG INTERACTION // ══════════════════════════════════════ let gridDragging = false, gridDragMode = 'add'; function onGridDown(e) { if (!S.currentUser) return; e.preventDefault(); gridDragging = true; const id = e.currentTarget.dataset.id; gridDragMode = S.currentUser.availability.has(id) ? 'remove' : 'add'; toggleCell(id); } function onGridEnter(e) { if (!S.currentUser) return; if (gridDragging) toggleCell(e.currentTarget.dataset.id); showTooltip(e.currentTarget, e); } function toggleCell(id) { if (!S.currentUser) return; // Optimistic local update — instant UI response if (gridDragMode === 'add') S.currentUser.availability.add(id); else S.currentUser.availability.delete(id); renderHeatmap(); scheduleNotifyOrganizer(S.currentUser.name); // Persist to Firestore atomically const op = gridDragMode === 'add' ? firebase.firestore.FieldValue.arrayUnion(id) : firebase.firestore.FieldValue.arrayRemove(id); db.collection('events').doc(S.eventSlug).update({ [`participants.${S.currentUser.name}.availability`]: op, }).catch(e => console.error('Failed to update availability:', e)); } // ══════════════════════════════════════ // SCREEN B: TOOLTIP // ══════════════════════════════════════ const tooltip = document.getElementById('grid-tooltip'); function showTooltip(cell, e) { const all = allParticipants(); const id = cell.dataset.id; const avail = all.filter(p => p.availability.has(id)); if (!avail.length) { tooltip.style.display = 'none'; return; } const ttSuffix = t().ofPeople(avail.length, all.length).replace(/^\d+\s*/, ''); const header = `
${avail.length} ${ttSuffix}
`; const names = avail.map(p => `
${escHtml(p.name)}
` ).join(''); tooltip.innerHTML = header + names; tooltip.style.display = 'flex'; const rect = cell.getBoundingClientRect(); tooltip.style.left = `${rect.left + rect.width/2}px`; tooltip.style.top = `${rect.top - 6}px`; } function hideTooltip() { tooltip.style.display = 'none'; } // ══════════════════════════════════════ // SCREEN B: NAME OVERLAY // ══════════════════════════════════════ async function joinEvent() { const nameEl = document.getElementById('participant-name'); const nameErr = document.getElementById('name-error'); const name = nameEl.value.trim(); nameErr.style.display = 'none'; if (!name) { nameEl.classList.add('shake'); nameEl.addEventListener('animationend', () => nameEl.classList.remove('shake'), {once:true}); nameEl.focus(); return; } // Check for duplicate name — offer rejoin if the name already exists const existing = S.participants.find(p => p.name.toLowerCase() === name.toLowerCase()); if (existing) { document.getElementById('rejoin-msg').textContent = t().rejoinPrompt(existing.name); document.getElementById('rejoin-prompt').style.display = 'block'; nameErr.style.display = 'none'; return; } const joinBtn = document.getElementById('join-btn'); joinBtn.innerHTML = t().joining; joinBtn.disabled = true; S.currentUser = { name, color: nameToColor(name), availability: new Set() }; sessionStorage.setItem('meteor-user-' + S.eventSlug, name); try { await db.collection('events').doc(S.eventSlug).update({ [`participants.${name}`]: { color: S.currentUser.color, availability: [] }, }); } catch(e) { console.error('Failed to join event:', e); joinBtn.innerHTML = t().joinBtn; joinBtn.disabled = false; return; } const overlay = document.getElementById('name-overlay'); overlay.style.transition = 'opacity 0.4s ease'; overlay.style.opacity = '0'; setTimeout(() => { overlay.style.display = 'none'; }, 400); renderParticipants(); renderHeatmap(); } function resumeAsExisting(name) { const existing = S.participants.find(p => p.name.toLowerCase() === name.toLowerCase()); if (!existing) return; S.currentUser = existing; S.participants = S.participants.filter(p => p.name.toLowerCase() !== name.toLowerCase()); sessionStorage.setItem('meteor-user-' + S.eventSlug, existing.name); const overlay = document.getElementById('name-overlay'); overlay.style.transition = 'opacity 0.4s ease'; overlay.style.opacity = '0'; setTimeout(() => { overlay.style.display = 'none'; }, 400); renderParticipants(); renderHeatmap(); } // ══════════════════════════════════════ // SCREEN B: PARTICIPANT LIST // ══════════════════════════════════════ function renderParticipants() { const container = document.getElementById('participant-list'); const all = allParticipants(); container.innerHTML = all.map(p => { const isYou = p.name === S.currentUser?.name; const parts = p.name.trim().split(/\s+/); const first = parts[0] ? parts[0].charAt(0).toUpperCase() + parts[0].slice(1).toLowerCase() : ''; const rest = parts.slice(1).join(' '); const lastName = rest ? rest.charAt(0).toUpperCase() + rest.slice(1).toLowerCase() : ''; const displayName = lastName ? `${escHtml(first)}
${escHtml(lastName)}` : escHtml(first); const initials = (parts[0]?.charAt(0) || '') + (parts[1]?.charAt(0) || ''); const showRemove = S.isCreator && !isYou; const safeName = p.name.replace(/"/g, '"'); return `
${escHtml(initials.toUpperCase())} ${displayName} ${showRemove ? `` : ''} ${isYou ? `${t().youBadge}` : ''}
${isYou ? `` : ''}`; }).join(''); const clearInline = container.querySelector('.clear-inline-btn'); if (clearInline) clearInline.addEventListener('click', clearMyTimes); container.querySelectorAll('.p-remove-btn').forEach(btn => btn.addEventListener('click', () => removeParticipant(btn.dataset.name))); const countEl = document.getElementById('people-count'); if (countEl) countEl.textContent = all.length; const mobileCount = document.getElementById('mobile-people-count'); if (mobileCount) mobileCount.textContent = all.length; syncActionStates(); } // ── HTML escaping helper (prevents XSS when injecting user content into innerHTML) ── function escHtml(s) { return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,'''); } // ── Custom confirm dialog ───────────────────────────────────────────────────── function showConfirm({ title, body, okLabel, cancelLabel, onOk }) { const overlay = document.getElementById('confirm-overlay'); document.getElementById('confirm-title').textContent = title; document.getElementById('confirm-body').textContent = body || ''; document.getElementById('confirm-ok-btn').textContent = okLabel; document.getElementById('confirm-cancel-btn').textContent = cancelLabel; const okBtn = document.getElementById('confirm-ok-btn'); function close() { overlay.style.opacity = '0'; document.removeEventListener('keydown', onKey); setTimeout(() => { overlay.style.display = 'none'; overlay.classList.remove('visible'); okBtn.disabled = false; }, 200); } function onKey(e) { if (e.key === 'Escape') close(); } okBtn.disabled = false; okBtn.onclick = () => { okBtn.disabled = true; close(); onOk(); }; document.getElementById('confirm-cancel-btn').onclick = close; overlay.onclick = e => { if (e.target === overlay) close(); }; document.addEventListener('keydown', onKey); overlay.style.display = 'flex'; requestAnimationFrame(() => overlay.classList.add('visible')); document.getElementById('confirm-cancel-btn').focus(); } // ══════════════════════════════════════ // SCREEN B: CLEAR MY TIMES // ══════════════════════════════════════ async function removeParticipant(name) { if (!S.isCreator) return; showConfirm({ title: t().removeParticipant, body: t().removeConfirm(name), okLabel: t().confirmRemove || 'Remove', cancelLabel: t().confirmCancel || 'Cancel', onOk: () => db.collection('events').doc(S.eventSlug).update({ [`participants.${name}`]: firebase.firestore.FieldValue.delete() }).catch(e => console.error('Failed to remove participant:', e)), }); } async function clearMyTimes() { if (!S.currentUser) return; S.currentUser.availability = new Set(); renderHeatmap(); await db.collection('events').doc(S.eventSlug).update({ [`participants.${S.currentUser.name}.availability`]: [], }).catch(e => console.error('Failed to clear times:', e)); } function goToStep(n) { const s1 = document.querySelector('.card[data-step="1"]'); const s2 = document.querySelector('.card[data-step="2"]'); const s3 = document.querySelector('.card[data-step="3"]'); const s4 = document.querySelector('.card[data-step="4"]'); if (n === 1) { s1.style.display = ''; s4.style.display = ''; s2.style.display = 'none'; s3.style.display = 'none'; } else { s1.style.display = 'none'; s4.style.display = 'none'; s2.style.display = ''; s3.style.display = ''; } document.querySelectorAll('.step-dot').forEach((d, i) => d.classList.toggle('active', (i + 1 === n))); document.getElementById('back-btn').style.display = (n === 1) ? 'none' : ''; document.getElementById('create-btn').innerHTML = (n === 2) ? t().createBtn : t().nextBtn; S.currentStep = n; document.querySelector('.create-form').scrollTop = 0; } function handleNextOrCreate() { const lang = t(); if (S.currentStep === 1) { // Page 1: Validate event name + creator name + email const nameInput = document.getElementById('event-name'); const eventName = nameInput.value.trim(); const nameErr = document.getElementById('event-name-err') || document.createElement('div'); if (!eventName) { nameErr.id = 'event-name-err'; nameErr.textContent = lang.errorName || 'Please enter an event name.'; nameErr.style.color = 'var(--error,#e53e3e)'; nameErr.style.fontSize = '13px'; nameErr.style.marginTop = '4px'; if (!nameInput.nextElementSibling?.id === 'event-name-err') { nameInput.parentNode.insertBefore(nameErr, nameInput.nextSibling); } nameInput.classList.add('shake'); setTimeout(() => nameInput.classList.remove('shake'), 500); return; } if (nameErr.parentNode) nameErr.parentNode.removeChild(nameErr); // Validate creator name and email const creatorName = document.getElementById('creator-name').value.trim(); const creatorEmail = document.getElementById('creator-email').value.trim(); const creatorNameErrEl = document.getElementById('creator-name-err'); const creatorEmailErrEl = document.getElementById('creator-email-err'); let valid = true; if (!creatorName) { creatorNameErrEl.textContent = lang.creatorNameErr; creatorNameErrEl.style.display = ''; valid = false; } else { creatorNameErrEl.style.display = 'none'; } const emailRegex = /^[^\s@]+@[^\s@]+\.[a-z]{2,}$/i; if (!emailRegex.test(creatorEmail)) { creatorEmailErrEl.textContent = lang.creatorEmailErr; creatorEmailErrEl.style.display = ''; valid = false; } else { creatorEmailErrEl.style.display = 'none'; } if (!valid) { document.getElementById('creator-name').classList.add('shake'); document.getElementById('creator-email').classList.add('shake'); setTimeout(() => { document.getElementById('creator-name').classList.remove('shake'); document.getElementById('creator-email').classList.remove('shake'); }, 500); return; } goToStep(2); } else if (S.currentStep === 2) { // Page 2: Validate date selection then create const hasSelection = S.mode === 'specific' ? S.selectedDates.size > 0 : S.selectedDays.size > 0; const errId = S.mode === 'specific' ? 'cal-error' : 'dow-error'; const errEl = document.getElementById(errId); if (!hasSelection) { errEl.style.display = ''; return; } errEl.style.display = 'none'; // Validation passed, proceed to create event createEvent(); } } // ══════════════════════════════════════ // SCREEN A: INIT // ══════════════════════════════════════ function initScreenA() { goToStep(1); renderCalendar(); document.getElementById('cal-prev').addEventListener('click', () => { S.calMonth--; if (S.calMonth < 0) { S.calMonth = 11; S.calYear--; } renderCalendar(); }); document.getElementById('cal-next').addEventListener('click', () => { S.calMonth++; if (S.calMonth > 11) { S.calMonth = 0; S.calYear++; } renderCalendar(); }); initModeToggle(); buildDOW(); buildTimeSelect('tp-from-list', 'tp-from-btn', 'time-from', 9 * 60); buildTimeSelect('tp-to-list', 'tp-to-btn', 'time-to', 17 * 60); buildTZSelect(); document.getElementById('create-btn').addEventListener('click', handleNextOrCreate); document.getElementById('back-btn').addEventListener('click', () => { if (S.currentStep > 1) goToStep(S.currentStep - 1); }); document.getElementById('event-name').addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('tp-from-btn').focus(); }); // Calendar modal close handlers document.getElementById('cal-close-btn').addEventListener('click', closeCalModal); document.getElementById('cal-overlay').addEventListener('click', e => { if (e.target === document.getElementById('cal-overlay')) closeCalModal(); }); document.addEventListener('keydown', e => { if (e.key === 'Escape' && document.getElementById('cal-overlay').classList.contains('visible')) closeCalModal(); }); } // ══════════════════════════════════════ // CALENDAR MODAL // ══════════════════════════════════════ function buildCalDate(slot) { let year, month, day; if (slot.mode === 'specific') { [year, month, day] = slot.colVal.split('-').map(Number); } else { // days mode — find next upcoming occurrence of the weekday const dayNames = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday']; const targetDay = dayNames.findIndex(n => slot.colVal.startsWith(n.slice(0,3))); const today = new Date(); const diff = ((targetDay - today.getDay() + 7) % 7) || 7; const next = new Date(today.getFullYear(), today.getMonth(), today.getDate() + diff); year = next.getFullYear(); month = next.getMonth() + 1; day = next.getDate(); } const pad = n => String(n).padStart(2,'0'); const startH = Math.floor(slot.startMins / 60), startM = slot.startMins % 60; const endH = Math.floor(slot.endMins / 60) % 24, endM = slot.endMins % 60; return { dateStr: `${year}${pad(month)}${pad(day)}`, startStr: `${pad(startH)}${pad(startM)}00`, endStr: `${pad(endH)}${pad(endM)}00`, }; } function openCalModal(slotIndex) { const slot = S.bestSlots[slotIndex]; if (!slot) return; const { dateStr, startStr, endStr } = buildCalDate(slot); const gcal = `https://calendar.google.com/calendar/render?action=TEMPLATE` + `&text=${encodeURIComponent(S.eventName)}` + `&dates=${dateStr}T${startStr}/${dateStr}T${endStr}` + `&details=${encodeURIComponent(window.location.href)}` + `&location=${encodeURIComponent(window.location.href)}` + `&ctz=${encodeURIComponent(slot.timezone)}`; document.getElementById('cal-event-name').textContent = S.eventName; document.getElementById('cal-event-time').textContent = `${slot.dateLabel} · ${slot.timeLabel}`; document.getElementById('cal-desc').textContent = t().calModalDesc; document.querySelector('#cal-google-btn span').textContent = t().addToGoogleCal; document.querySelector('#cal-ics-btn span').textContent = t().addToAppleCal; document.getElementById('cal-google-btn').onclick = () => window.open(gcal, '_blank', 'noopener'); document.getElementById('cal-ics-btn').onclick = () => downloadIcs(slot, dateStr, startStr, endStr); const overlay = document.getElementById('cal-overlay'); overlay.style.display = 'flex'; requestAnimationFrame(() => overlay.classList.add('visible')); } function closeCalModal() { const overlay = document.getElementById('cal-overlay'); overlay.classList.remove('visible'); setTimeout(() => { overlay.style.display = 'none'; }, 300); } function downloadIcs(slot, dateStr, startStr, endStr) { const now = new Date().toISOString().replace(/[-:.]/g,'').slice(0,15) + 'Z'; const uid = `${S.eventSlug}-${Date.now()}@whenfree.org`; const ics = [ 'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//WhenFree//EN', 'CALSCALE:GREGORIAN', 'METHOD:PUBLISH', 'BEGIN:VEVENT', `UID:${uid}`, `DTSTAMP:${now}`, `DTSTART;TZID=${slot.timezone}:${dateStr}T${startStr}`, `DTEND;TZID=${slot.timezone}:${dateStr}T${endStr}`, `SUMMARY:${S.eventName.replace(/[\\;,]/g, s => '\\' + s)}`, `URL:${window.location.href}`, `DESCRIPTION:${window.location.href}`, 'END:VEVENT', 'END:VCALENDAR', ].join('\r\n'); const blob = new Blob([ics], { type: 'text/calendar;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = (S.eventName.replace(/[^a-z0-9]/gi,'_').toLowerCase() || 'meeting') + '.ics'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } // ══════════════════════════════════════ // INIT // ══════════════════════════════════════ document.addEventListener('DOMContentLoaded', async () => { // Auto-detect language from ?lang= query param or URL path (/en, /fr, /he) const langFromQuery = new URLSearchParams(location.search).get('lang'); const pathMatch = location.pathname.match(/^\/(en|fr|he)(?:\/|$)/); const detectedLang = (langFromQuery && LANGS[langFromQuery]) ? langFromQuery : pathMatch?.[1]; if (detectedLang) { currentLang = detectedLang; localStorage.setItem('lang', detectedLang); } applyLang(); // Mobile scroll hint — hides as soon as best-times-section enters viewport if(window.innerWidth <= 640){ const hint = document.getElementById('scroll-hint'); const bestTimes = document.querySelector('.best-times-section'); if(hint && bestTimes){ const observer = new IntersectionObserver(([entry]) => { const visible = entry.isIntersecting; hint.style.display = visible ? 'none' : ''; document.body.classList.toggle('best-times-visible', visible); }, { threshold: 0.1 }); observer.observe(bestTimes); } window.addEventListener('resize', updateActionBarHeight, {passive:true}); } // Close time pickers when clicking outside document.addEventListener('click', e => { closeAllTimePickers(); }); // Wire up always-present buttons document.getElementById('join-btn').addEventListener('click', joinEvent); document.getElementById('participant-name').addEventListener('keydown', e => { if (e.key === 'Enter') joinEvent(); }); document.getElementById('participant-name').addEventListener('input', () => { document.getElementById('name-error').style.display = 'none'; document.getElementById('rejoin-prompt').style.display = 'none'; }); document.getElementById('rejoin-btn').addEventListener('click', () => { resumeAsExisting(document.getElementById('participant-name').value.trim()); }); document.getElementById('edit-title-btn').addEventListener('click', () => { const h1 = document.getElementById('event-title'); const inp = document.getElementById('title-edit-input'); const btn = document.getElementById('edit-title-btn'); inp.value = S.eventName; h1.style.display = 'none'; inp.style.display = ''; btn.style.display = 'none'; inp.focus(); inp.select(); }); async function saveTitleEdit() { const h1 = document.getElementById('event-title'); const inp = document.getElementById('title-edit-input'); const btn = document.getElementById('edit-title-btn'); const newName = inp.value.trim() || S.eventName; if (newName !== S.eventName) { S.eventName = newName; await db.collection('events').doc(S.eventSlug).update({ name: newName }).catch(e => console.error('Failed to update title:', e)); } h1.textContent = S.eventName; inp.style.display = 'none'; h1.style.display = ''; btn.style.display = ''; } document.getElementById('title-edit-input').addEventListener('blur', saveTitleEdit); document.getElementById('title-edit-input').addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('title-edit-input').blur(); if (e.key === 'Escape') { document.getElementById('title-edit-input').value = S.eventName; document.getElementById('title-edit-input').blur(); } }); document.getElementById('new-event-btn').addEventListener('click', () => { if (unsubscribe) unsubscribe(); location.hash = ''; location.reload(); }); document.getElementById('share-btn').addEventListener('click', async () => { try { await navigator.clipboard.writeText(window.location.href); } catch(e) { const ta = document.createElement('textarea'); ta.value = window.location.href; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); } const btn = document.getElementById('share-btn'); const original = btn.innerHTML; btn.innerHTML = t().copied; btn.disabled = true; setTimeout(() => { btn.innerHTML = original; btn.disabled = false; }, 2000); }); // ─── Contact history ─── const WF_CONTACTS_KEY = 'wf_contacts'; function loadContactHistory() { try { return JSON.parse(localStorage.getItem(WF_CONTACTS_KEY) || '[]'); } catch { return []; } } function saveToContactHistory(email) { const list = [email, ...loadContactHistory().filter(e => e !== email)].slice(0, 100); try { localStorage.setItem(WF_CONTACTS_KEY, JSON.stringify(list)); } catch {} } // ─── Chip management ─── function getChipEmails(chipsId) { return [...document.getElementById(chipsId).querySelectorAll('.email-chip')].map(c => c.dataset.email); } function addEmailChip(chipsId, inputId, email) { email = email.trim().toLowerCase(); if (!email || !email.includes('@') || !email.includes('.')) return false; if (getChipEmails(chipsId).includes(email)) { document.getElementById(inputId).value = ''; return false; } const chip = document.createElement('div'); chip.className = 'email-chip'; chip.dataset.email = email; chip.innerHTML = `${email}`; chip.querySelector('button').onclick = () => chip.remove(); document.getElementById(chipsId).appendChild(chip); document.getElementById(inputId).value = ''; return true; } // ─── Autocomplete ─── function showEmailSuggestions(inputId, suggestionsId, chipsId) { const q = document.getElementById(inputId).value.trim().toLowerCase(); const box = document.getElementById(suggestionsId); if (!q) { box.style.display = 'none'; return; } const existing = getChipEmails(chipsId); const matches = loadContactHistory().filter(e => e.includes(q) && !existing.includes(e)).slice(0, 6); if (!matches.length) { box.style.display = 'none'; return; } box.innerHTML = ''; matches.forEach(email => { const item = document.createElement('div'); item.className = 'email-suggestion'; item.textContent = email; item.onmousedown = ev => { ev.preventDefault(); addEmailChip(chipsId, inputId, email); box.style.display = 'none'; }; box.appendChild(item); }); box.style.display = 'block'; } // ─── Native Contact Picker (mobile, feature-detected) ─── async function pickFromContacts(chipsId, inputId) { try { const contacts = await navigator.contacts.select(['email'], { multiple: true }); contacts.forEach(c => (c.email || []).forEach(e => addEmailChip(chipsId, inputId, e))); } catch {} } // ─── Wire keyboard/autocomplete for one email panel ─── function initEmailPanelInput(inputId, suggestionsId, chipsId, contactsBtnId) { const input = document.getElementById(inputId); const box = document.getElementById(suggestionsId); if ('contacts' in navigator && 'ContactsManager' in window) { const btn = document.getElementById(contactsBtnId); btn.style.display = 'flex'; btn.title = t().fromContacts || 'From contacts'; btn.addEventListener('click', () => pickFromContacts(chipsId, inputId)); } input.addEventListener('input', () => showEmailSuggestions(inputId, suggestionsId, chipsId)); input.addEventListener('blur', () => setTimeout(() => { box.style.display = 'none'; }, 150)); input.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); if (input.value.trim()) { addEmailChip(chipsId, inputId, input.value); box.style.display = 'none'; } } else if (e.key === 'Backspace' && !input.value) { document.getElementById(chipsId).lastElementChild?.remove(); } }); } // ─── Send to all chips ─── async function sendToAllChips(cfg, buildArgs) { // cfg: { inputId, chipsId, statusId, sendBtnId } // buildArgs: (addr) => [subject, body, htmlBody, eventName, url] const input = document.getElementById(cfg.inputId); const statusEl = document.getElementById(cfg.statusId); const btn = document.getElementById(cfg.sendBtnId); if (input.value.trim()) addEmailChip(cfg.chipsId, cfg.inputId, input.value); const recipients = getChipEmails(cfg.chipsId); if (!recipients.length) return; btn.disabled = true; btn.textContent = t().linkBannerSending; let sent = 0, failed = 0; for (const addr of recipients) { if (recipients.length > 1) statusEl.textContent = `${sent + failed + 1} / ${recipients.length}`; try { await sendEmailViaGAS(addr, ...buildArgs(addr)); saveToContactHistory(addr); sent++; } catch { failed++; } } if (failed === 0) { statusEl.innerHTML = sent > 1 ? t().linkBannerSentN(sent) : t().linkBannerSent; btn.textContent = '✓'; document.getElementById(cfg.chipsId).innerHTML = ''; setTimeout(() => { statusEl.innerHTML = ''; btn.disabled = false; btn.textContent = t().linkBannerSend; }, 2500); } else if (sent === 0) { statusEl.style.color = '#DC2626'; statusEl.textContent = t().linkBannerError; btn.disabled = false; btn.textContent = t().linkBannerSend; setTimeout(() => { statusEl.textContent = ''; statusEl.style.color = ''; }, 5000); } else { statusEl.innerHTML = t().linkBannerPartial(sent, failed); btn.disabled = false; btn.textContent = t().linkBannerSend; setTimeout(() => { statusEl.innerHTML = ''; }, 5000); } } function toggleEmailPanel(panelId, btnId, otherPanelId) { const panel = document.getElementById(panelId); const btn = document.getElementById(btnId); const isOpen = panel.style.display === 'flex'; document.getElementById(otherPanelId).style.display = 'none'; if (isOpen) { panel.style.display = 'none'; } else { const r = btn.getBoundingClientRect(); const spaceBelow = window.innerHeight - r.bottom; const pw = Math.max(r.width, 280); const isRtl = document.documentElement.dir === 'rtl'; if (isRtl) { const pr = Math.min(window.innerWidth - r.right, window.innerWidth - pw - 16); panel.style.right = Math.max(pr, 8) + 'px'; panel.style.left = ''; } else { const pl = Math.min(r.left, window.innerWidth - pw - 16); panel.style.left = Math.max(pl, 8) + 'px'; panel.style.right = ''; } panel.style.width = pw + 'px'; panel.style.maxWidth = 'calc(100vw - 32px)'; if (spaceBelow < 100) { panel.style.top = ''; panel.style.bottom = (window.innerHeight - r.top + 6) + 'px'; } else { panel.style.bottom = ''; panel.style.top = (r.bottom + 6) + 'px'; } panel.style.display = 'flex'; panel.querySelector('input').focus(); } } document.getElementById('whatsapp-btn').addEventListener('click', () => { const msg = t().whatsappMsg(S.eventName || '', window.location.href); window.open('https://wa.me/?text=' + encodeURIComponent(msg), '_blank'); }); document.getElementById('email-btn').addEventListener('click', () => toggleEmailPanel('email-panel', 'email-btn', 'best-times-email-panel')); document.getElementById('best-times-email-btn').addEventListener('click', () => toggleEmailPanel('best-times-email-panel', 'best-times-email-btn', 'email-panel')); initEmailPanelInput('email-panel-input', 'email-suggestions', 'email-chips', 'email-contacts-btn'); initEmailPanelInput('best-times-email-input', 'best-times-suggestions', 'best-times-chips', 'best-times-contacts-btn'); document.getElementById('email-panel-send').addEventListener('click', () => sendToAllChips( { inputId:'email-panel-input', chipsId:'email-chips', statusId:'email-panel-status', sendBtnId:'email-panel-send' }, addr => [t().emailSubject(S.eventName), t().emailBody(S.eventName, window.location.href), t().emailHtmlBody ? t().emailHtmlBody(S.eventName, window.location.href) : null, S.eventName, window.location.href] )); document.getElementById('best-times-email-send').addEventListener('click', () => sendToAllChips( { inputId:'best-times-email-input', chipsId:'best-times-chips', statusId:'best-times-email-status', sendBtnId:'best-times-email-send' }, addr => [t().bestTimesSubject(S.eventName), t().bestTimesBody(S.eventName, window.location.href), buildBestTimesEmailHtml(), S.eventName, window.location.href] )); document.addEventListener('click', e => { if (!e.target.closest('#email-panel') && !e.target.closest('#email-btn')) document.getElementById('email-panel').style.display = 'none'; if (!e.target.closest('#best-times-email-panel') && !e.target.closest('#best-times-email-btn')) document.getElementById('best-times-email-panel').style.display = 'none'; }); document.addEventListener('dragstart', e => e.preventDefault()); // ════════════════════════════════════════════════════════════════════ // ACCESSIBILITY WIDGET INITIALIZATION // Must run before the hash-check block to avoid being skipped by its early return // ════════════════════════════════════════════════════════════════════ const a11yPanel = document.getElementById('a11y-panel'); const a11yBtn = document.getElementById('a11y-widget-btn'); const a11yCloseBtn = document.getElementById('a11y-close'); // Load saved preferences function loadA11yPrefs() { const saved = localStorage.getItem('a11y-prefs'); if (!saved) return {}; try { return JSON.parse(saved); } catch(e) { return {}; } } // Save preferences function saveA11yPrefs(prefs) { localStorage.setItem('a11y-prefs', JSON.stringify(prefs)); } // Apply preferences from object function applyA11yPrefs(prefs) { // Text size const sizeSelect = document.getElementById('a11y-text-size'); if (prefs.textSize) { sizeSelect.value = prefs.textSize; if (prefs.textSize === 'lg') { document.body.classList.add('a11y-text-lg'); document.body.classList.remove('a11y-text-xl'); } else if (prefs.textSize === 'xl') { document.body.classList.add('a11y-text-xl'); document.body.classList.remove('a11y-text-lg'); } else { document.body.classList.remove('a11y-text-lg', 'a11y-text-xl'); } } // High contrast const hcBtn = document.getElementById('a11y-high-contrast'); if (prefs.highContrast) { document.body.classList.add('a11y-high-contrast'); hcBtn.setAttribute('aria-pressed', 'true'); hcBtn.classList.add('on'); } else { document.body.classList.remove('a11y-high-contrast'); hcBtn.setAttribute('aria-pressed', 'false'); hcBtn.classList.remove('on'); } // Reduced motion const rmBtn = document.getElementById('a11y-reduced-motion'); if (prefs.reducedMotion) { document.body.classList.add('a11y-reduced-motion'); rmBtn.setAttribute('aria-pressed', 'true'); rmBtn.classList.add('on'); } else { document.body.classList.remove('a11y-reduced-motion'); rmBtn.setAttribute('aria-pressed', 'false'); rmBtn.classList.remove('on'); } // Keyboard focus highlights const kfBtn = document.getElementById('a11y-keyboard-focus'); if (prefs.keyboardFocus) { document.body.classList.add('a11y-keyboard-focus'); kfBtn.setAttribute('aria-pressed', 'true'); kfBtn.classList.add('on'); } else { document.body.classList.remove('a11y-keyboard-focus'); kfBtn.setAttribute('aria-pressed', 'false'); kfBtn.classList.remove('on'); } // ADHD friendly const adhdBtn = document.getElementById('a11y-adhd'); if (prefs.adhd) { document.body.classList.add('a11y-adhd'); adhdBtn.setAttribute('aria-pressed', 'true'); adhdBtn.classList.add('on'); } else { document.body.classList.remove('a11y-adhd'); adhdBtn.setAttribute('aria-pressed', 'false'); adhdBtn.classList.remove('on'); } } // Toggle open/close a11yBtn.addEventListener('click', e => { e.stopPropagation(); a11yPanel.classList.toggle('open'); }); a11yCloseBtn.addEventListener('click', e => { e.stopPropagation(); a11yPanel.classList.remove('open'); }); // Close panel when clicking outside document.addEventListener('click', e => { if (!a11yPanel.contains(e.target) && !a11yBtn.contains(e.target)) { a11yPanel.classList.remove('open'); } }); // Text size change document.getElementById('a11y-text-size').addEventListener('change', e => { const prefs = loadA11yPrefs(); prefs.textSize = e.target.value; saveA11yPrefs(prefs); applyA11yPrefs(prefs); }); // High contrast document.getElementById('a11y-high-contrast').addEventListener('click', e => { e.preventDefault(); const prefs = loadA11yPrefs(); prefs.highContrast = !prefs.highContrast; saveA11yPrefs(prefs); applyA11yPrefs(prefs); }); // Reduced motion document.getElementById('a11y-reduced-motion').addEventListener('click', e => { e.preventDefault(); const prefs = loadA11yPrefs(); prefs.reducedMotion = !prefs.reducedMotion; saveA11yPrefs(prefs); applyA11yPrefs(prefs); }); // Keyboard focus highlights document.getElementById('a11y-keyboard-focus').addEventListener('click', e => { e.preventDefault(); const prefs = loadA11yPrefs(); prefs.keyboardFocus = !prefs.keyboardFocus; saveA11yPrefs(prefs); applyA11yPrefs(prefs); }); // ADHD friendly document.getElementById('a11y-adhd').addEventListener('click', e => { e.preventDefault(); const prefs = loadA11yPrefs(); prefs.adhd = !prefs.adhd; saveA11yPrefs(prefs); applyA11yPrefs(prefs); }); // Reset all document.getElementById('a11y-reset-all').addEventListener('click', e => { e.preventDefault(); localStorage.removeItem('a11y-prefs'); document.body.classList.remove( 'a11y-text-lg', 'a11y-text-xl', 'a11y-high-contrast', 'a11y-reduced-motion', 'a11y-keyboard-focus', 'a11y-adhd' ); document.getElementById('a11y-text-size').value = 'normal'; document.querySelectorAll('.a11y-toggle').forEach(btn => { if (btn !== document.getElementById('a11y-text-size')) { btn.setAttribute('aria-pressed', 'false'); btn.classList.remove('on'); } }); }); // Apply saved preferences on load const savedPrefs = loadA11yPrefs(); applyA11yPrefs(savedPrefs); // Check URL hash for an existing event const hashId = location.hash.slice(1); if (hashId) { const loading = document.getElementById('loading-overlay'); loading.style.display = 'flex'; try { const snap = await db.collection('events').doc(hashId).get(); loading.style.display = 'none'; if (snap.exists) { const data = snap.data(); S.eventName = data.name; S.mode = data.mode; S.selectedDates = new Set(data.selectedDates || []); S.selectedDays = new Set(data.selectedDays || []); S.earlierThan = data.earlierThan; S.laterThan = data.laterThan; S.timezone = data.timezone; S.eventSlug = hashId; S.notifyOnResponse = data.notifyOnResponse || false; S.creatorEmail = data.creatorEmail || ''; S.creatorName = data.creatorName || ''; const storedToken = sessionStorage.getItem('meteor-creator-' + hashId); S.isCreator = !!(storedToken && storedToken === data.creatorToken); // Apply meeting language as default if user has no stored preference if (!localStorage.getItem('lang') && data.lang && LANGS[data.lang]) { currentLang = data.lang; } applyLang(); // Restore participant from sessionStorage if they already joined const savedName = sessionStorage.getItem('meteor-user-' + hashId); if (savedName && data.participants?.[savedName]) { S.currentUser = { name: savedName, color: data.participants[savedName].color, availability: new Set(data.participants[savedName].availability || []), }; } showScreenB(); return; } else { // Event not found — show Screen A with a message const err = document.getElementById('cal-error'); err.textContent = t().notFound; err.style.display = 'block'; } } catch(e) { document.getElementById('loading-overlay').style.display = 'none'; } } initScreenA(); applyLang(); applyTheme(); document.getElementById('onboard-got-it')?.addEventListener('click', () => dismissOnboarding(true)); // Auto-show for first-time visitors if (!localStorage.getItem('meteor_onboarded')) { setTimeout(showOnboarding, 600); } });