1/* ============================================ 2 * ATCHISON AREA CHAMBER OF COMMERCE 3 * Portal Drawer â Membership Sub-Drawer 4 * -------------------------------------------- 5 * Inline membership checkout. Never leaves the 6 * drawer. Uses Square Web Payments SDK to render 7 * a secure card field, tokenizes locally, then 8 * charges via the createChamberMembershipPayment 9 * Firebase Callable Function (visit-atchison-admin). 10 * ============================================ */ 11 12import { auth, db, functions, friendlyCallableError } from './firebase-config.js'; 13import { httpsCallable } from 'https://www.gstatic.com/firebasejs/10.12.2/firebase-functions.js'; 14import { doc, getDoc } from 'https://www.gstatic.com/firebasejs/10.12.2/firebase-firestore.js'; 15 16/* ============================================================ 17 * SQUARE WEB PAYMENTS â fill these in once you give us the 18 * credentials from your Square Dashboard: 19 * ⢠Application ID (public, safe in browser code) 20 * ⢠Location ID (public, safe in browser code) 21 * The Access Token stays SERVER-SIDE in Cloud Functions secrets. 22 * ============================================================ */ 23const SQUARE_CONFIG = { 24 applicationId: 'sq0idp-JQonIPobmZ6L9CCZeWw2nQ', 25 locationId: 'LCHB0GGF03JAR', 26 environment: 'production' 27}; 28 29function resolveSquareEnvironment() { 30 if (SQUARE_CONFIG.environment) return SQUARE_CONFIG.environment; 31 return SQUARE_CONFIG.applicationId.startsWith('sandbox-') 32 ? 'sandbox' 33 : 'production'; 34} 35 36function squareSdkUrl() { 37 return resolveSquareEnvironment() === 'sandbox' 38 ? 'https://sandbox.web.squarecdn.com/v1/square.js' 39 : 'https://web.squarecdn.com/v1/square.js'; 40} 41 42/* Cache the SDK promise so we only load the script once. */ 43let _squareSdkPromise = null; 44function loadSquareSdk() { 45 if (_squareSdkPromise) return _squareSdkPromise; 46 _squareSdkPromise = new Promise((resolve, reject) => { 47 if (window.Square) return resolve(window.Square); 48 const tag = document.createElement('script'); 49 tag.src = squareSdkUrl(); 50 tag.async = true; 51 tag.onload = () => resolve(window.Square); 52 tag.onerror = () => reject(new Error('Square SDK failed to load.')); 53 document.head.appendChild(tag); 54 }); 55 return _squareSdkPromise; 56} 57 58const isSquareConfigured = () => 59 !!(SQUARE_CONFIG.applicationId && SQUARE_CONFIG.locationId); 60 61/* ============================================================ 62 * TIER DATA 63 * 64 * SOURCE OF TRUTH = Firestore doc `chamberConfig/membershipPricing`, 65 * editable from the visit-atchison Admin â Memberships â Pricing tab. 66 * The hardcoded list below is a fallback used while that doc loads 67 * (or if Firestore is unreachable). Changes to the admin Pricing tab 68 * flow into this drawer automatically â no chamber redeploy needed. 69 * ============================================================ */ 70const FALLBACK_TIERS = [ 71 { 72 id: 'bronze', 73 name: 'Bronze', 74 price: 250, 75 priceLabel: '$250', 76 period: 'per year', 77 gradient: 'linear-gradient(135deg, #c9a36b 0%, #8a6e44 100%)', 78 accent: '#b08d57', 79 shadow: 'rgba(176, 141, 87, 0.5)', 80 emoji: 'ð¥', 81 tagline: 'For solo owners ready to plug in.', 82 popular: false, 83 benefits: [ 84 { label: 'Online directory listing', detail: 'Searchable profile with photos, hours & links' }, 85 { label: 'Newsletter shout-outs', detail: 'Featured in monthly Chamber emails' }, 86 { label: 'All networking events', detail: 'Mixers, ribbon cuttings, member-only socials' }, 87 { label: 'Welcome kit & decal', detail: 'Window decal, member packet, certificate' }, 88 { label: 'Visitor Guide listing', detail: 'Printed annually, distributed across NEK' } 89 ], 90 rewards: ['Annual meeting vote', 'Welcome social shout-out', 'Group health insurance access'] 91 }, 92 { 93 id: 'silver', 94 name: 'Silver', 95 price: 1000, 96 priceLabel: '$1,000', 97 period: 'per year', 98 gradient: 'linear-gradient(135deg, #cdd2da 0%, #6b727b 100%)', 99 accent: '#8a939d', 100 shadow: 'rgba(138, 147, 157, 0.5)', 101 emoji: 'ð¥', 102 tagline: 'Step up your visibility.', 103 popular: false, 104 benefits: [ 105 { label: 'Everything in Bronze', detail: 'Plus all of these enhanced perks' }, 106 { label: 'Logo on website & materials', detail: 'Recognized across Chamber publications' }, 107 { label: '2à social spotlights / year', detail: 'Hand-crafted posts across our channels' }, 108 { label: 'Priority event registration', detail: 'First crack at limited-seat experiences' }, 109 { label: 'Discounted ticket pricing', detail: 'Reduced rates on the Golf Tournament & Banquet' } 110 ], 111 rewards: ['Logo on Chamber materials', 'Featured at member mixers'] 112 }, 113 { 114 id: 'gold', 115 name: 'Gold', 116 price: 2500, 117 priceLabel: '$2,500', 118 period: 'per year', 119 gradient: 'linear-gradient(135deg, #f0c75e 0%, #b58a1f 100%)', 120 accent: '#d4af37', 121 shadow: 'rgba(212, 175, 55, 0.55)', 122 emoji: 'ð¥', 123 tagline: 'Most popular â visibility & impact.', 124 popular: true, 125 benefits: [ 126 { label: 'Everything in Silver', detail: 'Plus everything below' }, 127 { label: 'Featured homepage rotation', detail: 'Top-tier logo placement on the chamber site' }, 128 { label: '6à social spotlights / year', detail: 'Bi-monthly, custom-tailored posts' }, 129 { label: '$500 sponsorship credits', detail: 'Use any time across Chamber events' }, 130 { label: 'Press release amplification', detail: 'We help broadcast your big announcements' } 131 ], 132 rewards: ['Recognition on Chamber Solutions Flyer', 'Reserved seat at the Annual Awards'] 133 }, 134 { 135 id: 'platinum', 136 name: 'Platinum', 137 price: 5000, 138 priceLabel: '$5,000', 139 period: 'per year', 140 gradient: 'linear-gradient(135deg, #4a5560 0%, #1c2a33 100%)', 141 accent: '#7a8d9c', 142 shadow: 'rgba(51, 79, 93, 0.55)', 143 emoji: 'ð', 144 tagline: 'Premier presence with major sponsorship perks.', 145 popular: false, 146 benefits: [ 147 { label: 'Everything in Gold', detail: 'Plus everything below' }, 148 { label: 'Premier directory placement', detail: 'Top of every relevant search result' }, 149 { label: 'Monthly social spotlight', detail: 'A featured post every single month' }, 150 { label: 'Major event sponsorship', detail: 'Logo + booth at flagship Chamber events' }, 151 { label: 'Quarterly strategy session', detail: 'One-on-one with Chamber leadership' } 152 ], 153 rewards: ['Title sponsorship at the Annual Banquet', 'Custom promotional package'] 154 }, 155 { 156 id: 'diamond', 157 name: 'Diamond', 158 price: 7500, 159 priceLabel: '$7,500', 160 period: 'per year', 161 gradient: 'linear-gradient(135deg, #5fdce0 0%, #2c8e92 100%)', 162 accent: '#40e0d0', 163 shadow: 'rgba(64, 224, 208, 0.55)', 164 emoji: 'ð', 165 tagline: 'Top billing across all of Atchison.', 166 popular: false, 167 benefits: [ 168 { label: 'Everything in Platinum', detail: 'Plus everything below' }, 169 { label: 'Top billing on all materials', detail: 'Logo featured everywhere we publish' }, 170 { label: 'Weekly social spotlight', detail: 'Custom-tailored posts every week' }, 171 { label: 'Exclusive VIP events', detail: 'Diamond-only mixers & strategy summits' }, 172 { label: 'Custom promotional package', detail: 'Tailored marketing campaigns for your brand' } 173 ], 174 rewards: ['Title sponsor across major Chamber events', 'Board advisory access'] 175 } 176]; 177 178/* The active tier list â swapped out when Firestore pricing loads. */ 179let TIERS = [...FALLBACK_TIERS]; 180let _pricingLoadPromise = null; 181 182/** 183 * Resolution state for pricing. We do NOT let users start checkout until this 184 * is something other than 'pending'. If it's 'unavailable', tier CTAs route 185 * to manual application mode (no card charge) so we never charge the wrong 186 * price using the hardcoded fallback list. 187 * 188 * pending â still asking Firestore (CTAs disabled) 189 * firestore â admin-set prices loaded (CTAs charge) 190 * fallback â Firestore reachable but no admin doc yet (CTAs charge with hardcoded list â this is the bootstrap state) 191 * unavailable â Firestore unreachable / errored / timed out (CTAs go to manual mode, no inline charge) 192 */ 193let pricingResolution = 'pending'; 194 195/** Hard timeout on Firestore pricing fetch â keeps offline users from staring at a dead drawer. */ 196const PRICING_FETCH_TIMEOUT_MS = 5000; 197 198function normalizeTier(raw, idx) { 199 if (!raw || typeof raw !== 'object') return null; 200 const id = String(raw.id || '').trim().toLowerCase(); 201 if (!id) return null; 202 const price = Number(raw.price); 203 return { 204 id, 205 name: String(raw.name || id).trim() || id, 206 price: Number.isFinite(price) ? price : 0, 207 priceLabel: raw.priceLabel || (Number.isFinite(price) ? `$${price.toLocaleStr
207ing()}` : '$0'), 208 period: raw.period || 'per year', 209 gradient: raw.gradient || 'linear-gradient(135deg, #475569 0%, #1e293b 100%)', 210 accent: raw.accent || '#94a3b8', 211 shadow: raw.shadow || 'rgba(148, 163, 184, 0.45)', 212 emoji: raw.emoji || 'â', 213 tagline: raw.tagline || '', 214 popular: !!raw.popular, 215 sortOrder: Number.isFinite(Number(raw.sortOrder)) ? Number(raw.sortOrder) : idx, 216 active: raw.active !== false, 217 benefits: Array.isArray(raw.benefits) 218 ? raw.benefits 219 .filter(Boolean) 220 .map((b) => (typeof b === 'string' 221 ? { label: b, detail: '' } 222 : { label: String(b.label || ''), detail: String(b.detail || '') })) 223 .filter((b) => b.label) 224 : [], 225 rewards: Array.isArray(raw.rewards) 226 ? raw.rewards.filter(Boolean).map(String) 227 : [] 228 }; 229} 230 231/** 232 * Fetch admin-set pricing from Firestore. 233 * 234 * Resolves to one of: 235 * { source: 'firestore', tiers: [...] } â valid admin pricing loaded (TIERS replaced) 236 * { source: 'fallback' } â Firestore reached but no usable doc (TIERS = hardcoded) 237 * { source: 'unavailable' } â Firestore unreachable / timed out (TIERS = hardcoded, but we treat checkout as no-card) 238 * 239 * The cached promise is kept ONLY when we got real Firestore data (source === 'firestore'). 240 * For every other outcome we clear the cache so the next drawer open can retry. 241 */ 242async function loadTiersFromFirestore() { 243 if (_pricingLoadPromise) return _pricingLoadPromise; 244 245 _pricingLoadPromise = (async () => { 246 try { 247 const fetchPromise = getDoc(doc(db, 'chamberConfig', 'membershipPricing')); 248 const timeoutPromise = new Promise((_, reject) => 249 setTimeout(() => reject(new Error('pricing-timeout')), PRICING_FETCH_TIMEOUT_MS) 250 ); 251 const snap = await Promise.race([fetchPromise, timeoutPromise]); 252 253 if (!snap.exists()) { 254 pricingResolution = 'fallback'; 255 return { source: 'fallback' }; 256 } 257 const data = snap.data() || {}; 258 const list = Array.isArray(data.tiers) ? data.tiers : []; 259 const normalized = list 260 .map(normalizeTier) 261 .filter((t) => t && t.active) 262 .sort((a, b) => a.sortOrder - b.sortOrder); 263 if (!normalized.length) { 264 pricingResolution = 'fallback'; 265 return { source: 'fallback' }; 266 } 267 TIERS = normalized; 268 pricingResolution = 'firestore'; 269 return { source: 'firestore', tiers: TIERS }; 270 } catch (err) { 271 console.warn('[portal-membership] Pricing fetch unavailable:', err?.message || err); 272 pricingResolution = 'unavailable'; 273 return { source: 'unavailable' }; 274 } 275 })(); 276 277 const result = await _pricingLoadPromise; 278 /* Only cache the promise when we actually got authoritative pricing. 279 Otherwise let the next drawer open try again. */ 280 if (result.source !== 'firestore') { 281 _pricingLoadPromise = null; 282 } 283 return result; 284} 285 286let initialized = false; 287let openTier = null; 288let activeCard = null; /* current Square Card payment method instance */ 289let invoiceMode = false; /* true when the visitor chose "Prefer to be invoiced?" â forces the no-card application path and tags the submission as an invoice request */ 290let entryInvoiceMode = false; /* the invoice intent the drawer was OPENED with â so a mid-flow "invoice instead" switch doesn't poison a later fresh "Join" after Back */ 291let pendingLogo = null; /* { dataUrl, fileName, contentType, sizeBytes } */ 292 293const LOGO_MAX_BYTES = 2 * 1024 * 1024; // 2 MB 294const LOGO_ALLOWED_TYPES = new Set([ 295 'image/png', 'image/jpeg', 'image/jpg', 'image/webp', 'image/svg+xml' 296]); 297 298function formatFileSize(bytes) { 299 if (!Number.isFinite(bytes) || bytes <= 0) return 'â'; 300 if (bytes < 1024) return `${bytes} B`; 301 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; 302 return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; 303} 304 305function readFileAsDataUrl(file) { 306 return new Promise((resolve, reject) => { 307 const reader = new FileReader(); 308 reader.onload = () => resolve(reader.result); 309 reader.onerror = () => reject(reader.error || new Error('File read failed'));
310 reader.readAsDataURL(file); 311 }); 312} 313 314function makeReferenceId() { 315 const now = new Date(); 316 const ym = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}`; 317 const rand = Math.random().toString(36).slice(2, 7).toUpperCase(); 318 return `MEM-${ym}-${rand}`; 319} 320 321/** 322 * Stable idempotency key derived from email + tier + calendar day. If a charge succeeds but 323 * the response is dropped (network blip, browser crash) and the user retries the same day, 324 * Square will return the original payment instead of charging twice. 325 * 326 * Tradeoff: a member can't intentionally pay for two memberships of the same tier on the 327 * same email within one UTC day â they'd need to wait, use a different email, or call us. 328 * That's the right tradeoff vs. accidentally double-charging. 329 */ 330function makeIdempotencyKey({ email, tierId }) { 331 const normalizedEmail = String(email || '').trim().toLowerCase(); 332 const safeTier = String(tierId || 'tier').trim().toLowerCase(); 333 const now = new Date(); 334 const dayKey = `${now.getUTCFullYear()}${String(now.getUTCMonth() + 1).padStart(2, '0')}${String(now.getUTCDate()).padStart(2, '0')}`; 335 /* Lightweight hash â keep PII out of the key while still being deterministic. */ 336 let h = 0; 337 for (let i = 0; i < normalizedEmail.length; i++) { 338 h = ((h << 5) - h + normalizedEmail.charCodeAt(i)) | 0; 339 } 340 const emailHash = Math.abs(h).toString(36); 341 return `mem-${safeTier}-${dayKey}-${emailHash}`; 342} 343 344function escapeHtml(value = '') { 345 return String(value) 346 .replace(/&/g, '&') 347 .replace(/</g, '<') 348 .replace(/>/g, '>') 349 .replace(/"/g, '"') 350 .replace(/'/g, '''); 351} 352 353const TIER_BADGE_DIR = '/images/tiers'; 354 355/** Official ribbon-on-pin art per tier (same as site + matrix). */ 356function tierBadgeImageHtml(tier) { 357 const id = (tier.id || '').toString().toLowerCase().trim(); 358 if (!id) { 359 return `<div class="pm-tier__icon pm-tier__icon--empty" aria-hidden="true">â</div>`; 360 } 361 const src = `${TIER_BADGE_DIR}/${encodeURIComponent(id)}.png`; 362 return `<div class="pm-tier__icon" aria-hidden="true"> 363 <img class="pm-tier__badge" src="${src}" alt="" width="48" height="48" loading="lazy" /> 364 </div>`; 365} 366 367function checkoutSummaryBadgeHtml(tier) { 368 const id = (tier.id || '').toString().toLowerCase().trim(); 369 if (!id) { 370 return `<div class="pm-checkout__summary-icon" aria-hidden="true">â</div>`; 371 } 372 const src = `${TIER_BADGE_DIR}/${encodeURIComponent(id)}.png`; 373 return `<div class="pm-checkout__summary-icon" aria-hidden="true"> 374 <img class="pm-checkout__summary-badge" src="${src}" alt="" width="40" height="40" loading="lazy" /> 375 </div>`; 376} 377 378/* ------------------------------------------------------------ 379 * RENDER â tier picker 380 * ------------------------------------------------------------ */ 381function renderTierGrid(root) { 382 const isPending = pricingResolution === 'pending'; 383 const isUnavailable = pricingResolution === 'unavailable'; 384 385 const statusBanner = isUnavailable 386 ? `<div class="pm-pricing-banner pm-pricing-banner--warn" role="status"> 387 <strong>Live pricing is temporarily unavailable.</strong> 388 You can still submit an application below and Chamber staff will follow up within one business day to finalize payment, or call <a href="tel:9133672427">(913) 367-2427</a>. 389 </div>` 390 : ''; 391 392 const intro = ` 393 <div class="pm-hero"> 394 <p class="pm-hero__lead"> 395 Atchison’s business community runs on relationships, visibility, and the programs you see around town. 396 Choose the investment level that fits you — then finish your details in a few minutes. 397 </p> 398 <div class="pm-hero__chips"> 399 <span class="pm-hero__chip"><span class="pm-hero__chip-dot" style="--dot:#d4af37"></span>Annual billing</span>
400 <span class="pm-hero__chip"><span class="pm-hero__chip-dot" style="--dot:#40e0d0"></span>Secure checkout</span> 401 <span class="pm-hero__chip"><span class="pm-hero__chip-dot" style="--dot:#ff8d7a"></span>Locally owned</span> 402 </div> 403 </div> 404 ${statusBanner} 405 <div class="pm-tier-grid" data-pm-tier-grid data-pm-pricing-state="${pricingResolution}"></div> 406 <div class="pm-trust"> 407 <div class="pm-trust__item"> 408 <div class="pm-trust__icon">ð</div> 409 <span class="pm-trust__title">Secure checkout</span> 410 <span class="pm-trust__text">Card processing by Square</span> 411 </div> 412 <div class="pm-trust__item"> 413 <div class="pm-trust__icon">â¡</div> 414 <span class="pm-trust__title">Live in minutes</span> 415 <span class="pm-trust__text">Onboarding starts immediately</span> 416 </div> 417 <div class="pm-trust__item"> 418 <div class="pm-trust__icon">ð¤</div> 419 <span class="pm-trust__title">Real humans</span> 420 <span class="pm-trust__text">Email, call, or stop by anytime</span> 421 </div> 422 </div> 423 `; 424 425 root.innerHTML = intro; 426 const grid = root.querySelector('[data-pm-tier-grid]'); 427 428 /* CTA copy + state depends on pricing resolution: 429 pending â disabled, "Loading current pricingâ¦" 430 firestore â enabled, "Choose <Tier>" 431 fallback â enabled, "Choose <Tier>" (hardcoded list IS canonical until admin sets a doc) 432 unavailable â enabled, "Apply for <Tier>" (routes to manual application â no inline charge) */ 433 const ctaCopy = (tierName) => { 434 if (isPending) return 'Loading current pricing…'; 435 if (isUnavailable) return `Apply for ${escapeHtml(tierName)}`; 436 return `Choose ${escapeHtml(tierName)}`; 437 }; 438 439 grid.innerHTML = TIERS.map((tier) => ` 440 <article 441 class="pm-tier${tier.popular ? ' pm-tier--popular' : ''}" 442 data-pm-tier-card="${tier.id}" 443 style="--tier-gradient:${tier.gradient};--tier-accent:${tier.accent};--tier-shadow:${tier.shadow};" 444 > 445 ${tier.popular ? '<span class="pm-tier__popular-badge">Most popular</span>' : ''} 446 <div class="pm-tier__head"> 447 ${tierBadgeImageHtml(tier)} 448 <div class="pm-tier__title"> 449 <span class="pm-tier__name">${escapeHtml(tier.name)}</span> 450 <span class="pm-tier__tagline">${escapeHtml(tier.tagline)}</span> 451 </div> 452 <div class="pm-tier__price"> 453 <span class="pm-tier__price-value">${escapeHtml(tier.priceLabel)}</span> 454 <span class="pm-tier__price-period">${escapeHtml(tier.period)}</span> 455 </div> 456 </div> 457 <ul class="pm-tier__benefits"> 458 ${tier.benefits.map((b) => ` 459 <li class="pm-tier__benefit" title="${escapeHtml(b.detail || '')}"> 460 ${escapeHtml(b.label)} 461 </li> 462 `).join('')} 463 </ul> 464 ${tier.rewards?.length ? ` 465 <div class="pm-tier__rewards"> 466 ${tier.rewards.map((r) => `<span class="pm-tier__reward">${escapeHtml(r)}</span>`).join('')} 467 </div> 468 ` : ''} 469 <button type="button" class="pm-tier__cta" data-pm-choose="${tier.id}" ${isPending ? 'disabled aria-busy="true"' : ''}> 470 ${ctaCopy(tier.name)} 471 ${isPending ? '' : ` 472 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"> 473 <path d="M5 12h14M13 6l6 6-6 6"/> 474 </svg>`} 475 </button> 476 </article> 477 `).join(''); 478 479 grid.querySelectorAll('[data-pm-choose]').forEach((btn) => { 480 btn.addEventListener('click', () => { 481 if (btn.disabled) return; 482 const tierId = btn.dataset.pmChoose; 483 const tier = TIERS.find((t) => t.id === tierId); 484 if (tier) openCheckout(tier); 485 }); 486 });
487} 488 489/* ------------------------------------------------------------ 490 * INLINE CHECKOUT PANEL 491 * ------------------------------------------------------------ */ 492async function openCheckout(tier) { 493 openTier = tier; 494 495 const drawer = document.getElementById('portalMembershipDrawer'); 496 if (!drawer) return; 497 498 drawer.style.setProperty('--tier-accent', tier.accent); 499 drawer.style.setProperty('--tier-gradient', tier.gradient); 500 drawer.style.setProperty('--tier-shadow', tier.shadow); 501 502 let panel = drawer.querySelector('[data-pm-checkout]'); 503 if (!panel) { 504 panel = document.createElement('div'); 505 panel.className = 'pm-checkout'; 506 panel.setAttribute('data-pm-checkout', ''); 507 drawer.appendChild(panel); 508 } 509 510 const user = auth?.currentUser || null; 511 const initialEmail = user?.email || ''; 512 const initialName = user?.displayName || ''; 513 const squareReady = isSquareConfigured(); 514 const showCard = squareReady && !invoiceMode; /* invoice mode never collects a card */ 515 516 panel.innerHTML = ` 517 <header class="pm-checkout__header"> 518 <button type="button" class="pm-checkout__back" data-pm-checkout-back aria-label="Back to tiers"> 519 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"> 520 <path d="M15 18l-6-6 6-6"/> 521 </svg> 522 Back 523 </button> 524 <h3 class="pm-checkout__heading"> 525 ${invoiceMode ? 'Invoice request — ' : 'Join as '}<span class="pm-checkout__heading-tier">${escapeHtml(tier.name)}</span> 526 </h3> 527 </header> 528 <div class="pm-checkout__body"> 529 <div class="pm-checkout__summary" style="--tier-gradient:${tier.gradient};"> 530 ${checkoutSummaryBadgeHtml(tier)} 531 <div> 532 <div class="pm-checkout__summary-name">${escapeHtml(tier.name)} Membership</div> 533 <div class="pm-checkout__summary-period">${escapeHtml(tier.period)}</div> 534 </div> 535 <div class="pm-checkout__summary-price">${escapeHtml(tier.priceLabel)}</div> 536 </div> 537 538 <form class="pm-form" data-pm-form novalidate> 539 <div class="pm-form__row pm-form__row--two"> 540 <label class="pm-form__field"> 541 <span class="pm-form__label">Business name <span class="pm-form__label-req">*</span></span> 542 <input class="pm-form__input" name="businessName" type="text" required placeholder="Your Business, LLC" /> 543 <span class="pm-form__error" data-pm-error="businessName"></span> 544 </label> 545 <label class="pm-form__field"> 546 <span class="pm-form__label">Contact name <span class="pm-form__label-req">*</span></span> 547 <input class="pm-form__input" name="contactName" type="text" required placeholder="Your full name" value="${escapeHtml(initialName)}" /> 548 <span class="pm-form__error" data-pm-error="contactName"></span> 549 </label> 550 </div> 551 552 <div class="pm-form__row pm-form__row--two"> 553 <label class="pm-form__field"> 554 <span class="pm-form__label">Email <span class="pm-form__label-req">*</span></span> 555 <input class="pm-form__input" name="email" type="email" required placeholder="[email protected]" value="${escapeHtml(initialEmail)}" /> 556 <span class="pm-form__error" data-pm-error="email"></span> 557 </label> 558 <label class="pm-form__field"> 559 <span class="pm-form__label">Phone <span class="pm-form__label-req">*</span></span> 560 <input class="pm-form__input" name="phone" type="tel" required placeholder="(913) 555-1234" /> 561 <span class="pm-form__error" data-pm-error="phone"></span> 562 </label> 563 </div> 564 565 <label class="pm-form__field">
566 <span class="pm-form__label">Business address</span> 567 <input class="pm-form__input" name="address" type="text" placeholder="Street, City, ZIP" /> 568 </label> 569 570 <label class="pm-form__field"> 571 <span class="pm-form__label">Website (optional)</span> 572 <input class="pm-form__input" name="website" type="url" placeholder="https://" /> 573 </label> 574 575 <div class="pm-form__field"> 576 <span class="pm-form__label">Business logo <span class="pm-form__label-hint">(optional · PNG, JPG, SVG · 2MB max)</span></span> 577 <div class="pm-logo" data-pm-logo data-pm-logo-state="empty" tabindex="0" role="button" aria-label="Upload business logo"> 578 <input type="file" class="pm-logo__file" data-pm-logo-input accept="image/png,image/jpeg,image/jpg,image/webp,image/svg+xml" hidden /> 579 <div class="pm-logo__empty"> 580 <div class="pm-logo__icon" aria-hidden="true"> 581 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"> 582 <path d="M12 16V4m0 0l-4 4m4-4l4 4"/> 583 <rect x="3" y="14" width="18" height="6" rx="2"/> 584 </svg> 585 </div> 586 <div class="pm-logo__title">Drag & drop your logo</div> 587 <div class="pm-logo__sub">or <span class="pm-logo__browse">browse files</span></div> 588 </div> 589 <div class="pm-logo__preview"> 590 <img class="pm-logo__img" data-pm-logo-img alt="Logo preview" /> 591 <div class="pm-logo__meta"> 592 <div class="pm-logo__name" data-pm-logo-name>logo.png</div> 593 <div class="pm-logo__size" data-pm-logo-size>â</div> 594 </div> 595 <button type="button" class="pm-logo__remove" data-pm-logo-remove aria-label="Remove logo"> 596 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"><path d="M6 6l12 12M18 6L6 18"/></svg> 597 </button> 598 </div> 599 </div> 600 <span class="pm-form__error" data-pm-error="logo"></span> 601 </div> 602 603 <label class="pm-form__field"> 604 <span class="pm-form__label">Anything we should know?</span> 605 <textarea class="pm-form__textarea" name="notes" placeholder="Tell us about your business or any special needs."></textarea> 606 </label> 607 608 ${showCard ? ` 609 <div class="pm-form__field"> 610 <span class="pm-form__label">Card details <span class="pm-form__label-req">*</span></span> 611 <div class="pm-card" data-pm-card-state="loading"> 612 <div class="pm-card__container" id="pmSquareCard"></div> 613 <div class="pm-card__loader"> 614 <span class="pm-card__loader-dot"></span> 615 <span class="pm-card__loader-dot"></span> 616 <span class="pm-card__loader-dot"></span> 617 <span>Loading secure card formâ¦</span> 618 </div> 619 </div> 620 <span class="pm-form__error" data-pm-error="card"></span> 621 <div class="pm-card__badges" aria-hidden="true"> 622 <span class="pm-card__badge">VISA</span> 623 <span class="pm-card__badge">MC</span> 624 <span class="pm-card__badge">AMEX</span> 625 <span class="pm-card__badge">DISC</span>
626 <span class="pm-card__badge pm-card__badge--lock"> 627 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 1 1 8 0v4"/></svg> 628 Secured by Square 629 </span> 630 </div> 631 </div> 632 ` : invoiceMode ? ` 633 <div class="pm-card pm-card--invoice"> 634 <strong>You’re requesting an invoice — no card needed.</strong> 635 Submit your details and the Chamber will email an invoice you can pay by check or online whenever it’s convenient. 636 </div> 637 ` : ` 638 <div class="pm-card pm-card--unconfigured"> 639 <strong>Inline payments aren’t live yet.</strong> 640 Submit your application and Chamber staff will contact you to finalize payment within one business day. 641 </div> 642 `} 643 644 ${showCard ? ` 645 <button type="button" class="pm-form__invoice-switch" data-pm-to-invoice> 646 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8M8 17h5"/></svg> 647 Prefer to be invoiced instead? <span>Pay by check or online later â no card needed.</span> 648 </button> 649 ` : ''} 650 651 <button type="submit" class="pm-form__submit" data-pm-submit ${showCard ? 'disabled' : ''}> 652 <span data-pm-submit-label> 653 ${showCard 654 ? `Loading checkoutâ¦` 655 : `${invoiceMode ? 'Request invoice' : 'Submit application'} 656 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"> 657 <path d="M5 12h14M13 6l6 6-6 6"/> 658 </svg>`} 659 </span> 660 </button> 661 662 <p class="pm-form__notice"> 663 ${showCard 664 ? `Your card is processed securely by <strong>Square</strong>. We never see or store your card number â we just save your application here so the Chamber can onboard you instantly.` 665 : invoiceMode 666 ? `We’ll email an invoice to the address above. Pay by check (mailed to the Chamber) or online — whenever it’s convenient.` 667 : `Chamber staff will reach out within one business day to finalize payment and walk you through onboarding.`} 668 </p> 669 </form> 670 </div> 671 `; 672 673 requestAnimationFrame(() => panel.classList.add('pm-checkout--open')); 674 675 panel.querySelector('[data-pm-checkout-back]').addEventListener('click', () => closeCheckout()); 676 panel.querySelector('[data-pm-form]').addEventListener('submit', handleSubmit); 677 panel.querySelector('[data-pm-to-invoice]')?.addEventListener('click', () => switchToInvoiceMode(panel)); 678 wireLogoDropzone(panel); 679 setTimeout(() => panel.querySelector('input[name="businessName"]')?.focus(), 360); 680 681 /* If pricing came from Firestore (or fallback) AND Square is configured, mount the inline card. 682 If pricing is 'unavailable' we deliberately skip Square so the user always lands in the manual 683 application path (no risk of charging stale fallback prices). */ 684 const allowInlineCharge = (pricingResolution === 'firestore' || pricingResolution === 'fallback'); 685 686 if (showCard && allowInlineCharge) { 687 try { 688 await mountSquareCard(tier); 689 } catch (err) { 690 console.error('[portal-membership] Square card init failed â switching to manual application mode.', err); 691 switchToManualApplicationMode(panel, 692 'Secure card form couldn’t load right now. Submit your application below and Chamber staff will follow up within one business day to finalize payment.'); 693 } 694 } else if (!allowInlineCharge && showCard) { 695 /* Pricing unreachable â present manual application UX from the start. */ 696 switchToManualApplicationMode(panel, 697 'Live pricing isn’t available. Submit your application below and Chamber staff will follow up within one business day to finalize the exact amount and process payment.'); 698 } 699} 700 701/** 702 * Hide the Square card field, re-enable submit, switch the form to manual mode. 703 * The existing `handleSubmit` path uses the manual branch when `activeCard` is null. 704 */ 705function switchToManualApplicationMode(panel, noticeHtml) { 706 if (!panel) return; 707 const cardWrap = panel.querySelector('[data-pm-card-state]')?.closest('.pm-form__field'); 708 if (cardWrap) cardWrap.style.display = 'none'; 709 710 activeCard = null; 711 712 const submit = panel.querySelector('[data-pm-submit]'); 713 const label = panel.querySelector('[data-pm-submit-label]'); 714 if (submit) submit.disabled = false; 715 if (label) { 716 label.innerHTML = `Submit application 717 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"> 718 <path d="M5 12h14M13 6l6 6-6 6"/> 719 </svg>`; 720 } 721 722 const notice = panel.querySelector('.pm-form__notice'); 723 if (notice) { 724 notice.innerHTML = noticeHtml || 'Chamber staff will reach out within one business day to finalize payment and walk you through onboarding.'; 725 } 726 727 const cardError = panel.querySelector('[data-pm-error="card"]'); 728 if (cardError) cardError.textContent = ''; 729} 730 731/** 732 * Switch an in-progress "Join online" checkout over to the invoice path 733 * WITHOUT losing anything the visitor already typed. Tears down the card 734 * field + Square instance, re-titles the panel, and re-labels the submit. 735 * Triggered by the "Prefer to be invoiced instead?" link. 736 */ 737function switchToInvoiceMode(panel) { 738 if (!panel) return; 739 invoiceMode = true; 740 741 /* Remove the card field + Square instance â invoice collects no card. */ 742 const cardWrap = panel.querySelector('[data-pm-card-state]')?.closest('.pm-form__field'); 743 if (cardWrap) cardWrap.remove(); 744 if (activeCard) { 745 try { activeCard.destroy(); } catch (e) { /* noop */ } 746 activeCard = null; 747 } 748 749 /* The link has done its job â hide it. */ 750 panel.querySelector('[data-pm-to-invoice]')?.remove(); 751 752 /* Re-title: "Join as Silver" â "Invoice request â Silver". */
753 const heading = panel.querySelector('.pm-checkout__heading'); 754 if (heading && openTier) { 755 heading.innerHTML = `Invoice request — <span class="pm-checkout__heading-tier">${escapeHtml(openTier.name)}</span>`; 756 } 757 758 /* Drop the same reassuring explainer the dedicated invoice flow shows. */ 759 const submit = panel.querySelector('[data-pm-submit]'); 760 if (submit && !panel.querySelector('.pm-card--invoice')) { 761 const note = document.createElement('div'); 762 note.className = 'pm-card pm-card--invoice'; 763 note.innerHTML = `<strong>You’re requesting an invoice — no card needed.</strong> Submit your details and the Chamber will email an invoice you can pay by check or online whenever it’s convenient.`; 764 submit.parentNode.insertBefore(note, submit); 765 } 766 767 if (submit) submit.disabled = false; 768 const label = panel.querySelector('[data-pm-submit-label]'); 769 if (label) { 770 label.innerHTML = `Request invoice 771 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"> 772 <path d="M5 12h14M13 6l6 6-6 6"/> 773 </svg>`; 774 } 775 776 const notice = panel.querySelector('.pm-form__notice'); 777 if (notice) { 778 notice.innerHTML = `We’ll email an invoice to the address above. Pay by check (mailed to the Chamber) or online — whenever it’s convenient.`; 779 } 780} 781 782/* ------------------------------------------------------------ 783 * LOGO DROP-ZONE 784 * Drag & drop OR click to browse â reads as base64 data URL, 785 * shows a preview, gets sent up to the Cloud Function which 786 * uploads to Firebase Storage server-side and returns a URL. 787 * ------------------------------------------------------------ */ 788function wireLogoDropzone(panel) { 789 const zone = panel.querySelector('[data-pm-logo]'); 790 const input = panel.querySelector('[data-pm-logo-input]'); 791 const removeBtn = panel.querySelector('[data-pm-logo-remove]'); 792 if (!zone || !input) return; 793 794 pendingLogo = null; 795 796 const setLogo = async (file) => { 797 const form = panel.querySelector('[data-pm-form]'); 798 clearLogoError(form); 799 800 if (!file) { 801 pendingLogo = null; 802 zone.setAttribute('data-pm-logo-state', 'empty'); 803 return; 804 } 805 806 const type = (file.type || '').toLowerCase(); 807 if (!LOGO_ALLOWED_TYPES.has(type)) { 808 setError(form, 'logo', 'Logo must be PNG, JPG, WEBP, or SVG.'); 809 return; 810 } 811 if (file.size > LOGO_MAX_BYTES) { 812 setError(form, 'logo', `Logo is too large (${formatFileSize(file.size)}). Max 2MB.`); 813 return; 814 } 815 816 let dataUrl; 817 try { 818 dataUrl = await readFileAsDataUrl(file); 819 } catch (err) { 820 console.error('[portal-membership] logo read failed', err); 821 setError(form, 'logo', "Couldn't read that file â try another."); 822 return; 823 } 824 825 pendingLogo = { 826 dataUrl, 827 fileName: file.name || 'logo', 828 contentType: type, 829 sizeBytes: file.size 830 }; 831 832 const img = panel.querySelector('[data-pm-logo-img]'); 833 const nameEl = panel.querySelector('[data-pm-logo-name]'); 834 const sizeEl = panel.querySelector('[data-pm-logo-size]'); 835 if (img) img.src = dataUrl; 836 if (nameEl) nameEl.textContent = pendingLogo.fileName; 837 if (sizeEl) sizeEl.textContent = formatFileSize(file.size); 838 zone.setAttribute('data-pm-logo-state', 'filled'); 839 }; 840 841 /* Click + keyboard â open native picker (unless removing) */ 842 zone.addEventListener('click', (e) => { 843 if (e.target.closest('[data-pm-logo-remove]')) return; 844 input.click(); 845 }); 846 zone.addEventListener('keydown', (e) => { 847 if (e.key === 'Enter' || e.key === ' ') { 848 e.preventDefault(); 849 input.click(); 850 } 851 }); 852 853 input.addEventListener('change', () => { 854 const f = input.files?.[0]; 855 if (f) setLogo(f); 856 input.value = ''; 857 });
858 859 /* Drag & drop */ 860 ['dragenter', 'dragover'].forEach((ev) => { 861 zone.addEventListener(ev, (e) => { 862 e.preventDefault(); 863 e.stopPropagation(); 864 zone.setAttribute('data-pm-logo-dragging', 'true'); 865 }); 866 }); 867 ['dragleave', 'dragend', 'drop'].forEach((ev) => { 868 zone.addEventListener(ev, (e) => { 869 e.preventDefault(); 870 e.stopPropagation(); 871 zone.removeAttribute('data-pm-logo-dragging'); 872 }); 873 }); 874 zone.addEventListener('drop', (e) => { 875 const f = e.dataTransfer?.files?.[0]; 876 if (f) setLogo(f); 877 }); 878 879 removeBtn?.addEventListener('click', (e) => { 880 e.stopPropagation(); 881 e.preventDefault(); 882 setLogo(null); 883 }); 884} 885 886function clearLogoError(form) { 887 const el = form?.querySelector('[data-pm-error="logo"]'); 888 if (el) el.textContent = ''; 889} 890 891async function mountSquareCard(tier) { 892 const Square = await loadSquareSdk(); 893 if (!Square) throw new Error('Square SDK not present.'); 894 895 const payments = Square.payments(SQUARE_CONFIG.applicationId, SQUARE_CONFIG.locationId); 896 // Square's style API only allows a tight subset of properties (color, 897 // fontWeight, borderColor, borderRadius). We tune for dark text on the 898 // SDK's hardcoded-white iframe so the field blends cleanly into the 899 // light-glass drawer surface. 900 const card = await payments.card({ 901 style: { 902 input: { 903 color: '#0f172a', 904 fontWeight: '500' 905 }, 906 'input::placeholder': { 907 color: '#94a3b8' 908 }, 909 'input.is-error': { 910 color: '#dc2626' 911 }, 912 '.input-container': { 913 borderColor: '#e2e8f0', 914 borderRadius: '10px' 915 }, 916 '.input-container.is-focus': { 917 borderColor: tier.accent || '#1e293b' 918 }, 919 '.input-container.is-error': { 920 borderColor: '#dc2626' 921 }, 922 '.message-text': { 923 color: '#64748b' 924 }, 925 '.message-text.is-error': { 926 color: '#dc2626' 927 }, 928 '.message-icon': { 929 color: '#64748b' 930 }, 931 '.message-icon.is-error': { 932 color: '#dc2626' 933 } 934 } 935 }); 936 await card.attach('#pmSquareCard'); 937 938 activeCard = card; 939 const cardWrap = document.querySelector('[data-pm-card-state]'); 940 if (cardWrap) cardWrap.setAttribute('data-pm-card-state', 'ready'); 941 942 const submit = document.querySelector('[data-pm-submit]'); 943 const label = document.querySelector('[data-pm-submit-label]'); 944 if (submit) submit.disabled = false; 945 if (label) label.innerHTML = ` 946 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true" style="width:18px;height:18px;"> 947 <rect x="3" y="6" width="18" height="13" rx="2"/> 948 <path d="M3 10h18"/> 949 </svg> 950 Pay ${tier.priceLabel} & join 951 `; 952} 953 954/** Module-scope so we can cancel the panel-removal timer when the user reopens within the slide-out window. */ 955let _checkoutCloseTimer = null; 956 957function closeCheckout() { 958 const panel = document.querySelector('[data-pm-checkout]'); 959 if (!panel) return; 960 panel.classList.remove('pm-checkout--open'); 961 962 /* Snapshot the card we're about to destroy so a fast reopen that mounts a NEW card 963 isn't nulled out by this closure firing 460ms later. */ 964 const cardToDestroy = activeCard; 965 activeCard = null; 966 pendingLogo = null; 967 openTier = null; 968 /* A mid-flow "invoice instead" switch only applies to that one attempt â 969 returning to the tier grid restores whatever intent the drawer opened with. */ 970 invoiceMode = entryInvoiceMode; 971 972 if (_checkoutCloseTimer) { 973 clearTimeout(_checkoutCloseTimer); 974 } 975 _checkoutCloseTimer = setTimeout(() => { 976 _checkoutCloseTimer = null; 977 panel.remove(); 978 if (cardToDestroy?.destroy) { 979 try { cardToDestroy.destroy(); } catch (e) { /* noop */ } 980 } 981 }, 460); 982} 983 984/* ------------------------------------------------------------ 985 * VALIDATION + SUBMIT 986 * ------------------------------------------------------------ */ 987function clearErrors(form) { 988 form.querySelectorAll('[data-pm-error]').forEach((el) => { el.textContent = ''; }); 989} 990 991function setError(form, name, message) {
992 const el = form?.querySelector(`[data-pm-error="${name}"]`); 993 if (el) el.innerHTML = message; 994} 995 996function validateForm(form) { 997 clearErrors(form); 998 const data = Object.fromEntries(new FormData(form).entries()); 999 let ok = true; 1000 1001 if (!data.businessName?.trim()) { setError(form, 'businessName', 'Required'); ok = false; } 1002 if (!data.contactName?.trim()) { setError(form, 'contactName', 'Required'); ok = false; } 1003 if (!data.email?.trim()) { 1004 setError(form, 'email', 'Required'); ok = false; 1005 } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) { 1006 setError(form, 'email', 'Enter a valid email'); ok = false; 1007 } 1008 if (!data.phone?.trim()) { setError(form, 'phone', 'Required'); ok = false; } 1009 1010 return { ok, data }; 1011} 1012 1013async function handleSubmit(e) { 1014 e.preventDefault(); 1015 if (!openTier) return; 1016 const form = e.currentTarget; 1017 const submitBtn = form.querySelector('[data-pm-submit]'); 1018 const submitLabel = form.querySelector('[data-pm-submit-label]'); 1019 1020 const { ok, data } = validateForm(form); 1021 if (!ok) return; 1022 1023 submitBtn.disabled = true; 1024 const originalLabel = submitLabel.innerHTML; 1025 1026 const reference = makeReferenceId(); 1027 const user = auth?.currentUser || null; 1028 1029 const baseFields = { 1030 reference, 1031 tier: openTier.id, 1032 tierName: openTier.name, 1033 amount: openTier.price, 1034 priceLabel: openTier.priceLabel, 1035 /* Tells the server how trustworthy `amount` is. Staff/admin should 1036 treat 'unavailable' or 'fallback' as "needs reconciliation". */ 1037 pricingSource: pricingResolution, 1038 contactName: data.contactName.trim(), 1039 businessName: data.businessName.trim(), 1040 email: data.email.trim().toLowerCase(), 1041 phone: data.phone.trim(), 1042 address: (data.address || '').trim(), 1043 website: (data.website || '').trim(), 1044 notes: (data.notes || '').trim(), 1045 submittedBy: user?.uid || null, 1046 submittedByEmail: user?.email || null, 1047 ...(pendingLogo ? { 1048 logoDataUrl: pendingLogo.dataUrl, 1049 logoFileName: pendingLogo.fileName, 1050 logoContentType: pendingLogo.contentType 1051 } : {}) 1052 }; 1053 1054 /* If Square isn't configured yet, fall back to "save application, 1055 staff will reach out" so this drawer still works pre-launch. */ 1056 if (!isSquareConfigured() || !activeCard) { 1057 submitLabel.innerHTML = `<span class="pm-form__submit-spinner"></span><span>Saving your applicationâ¦</span>`; 1058 try { 1059 const callable = httpsCallable(functions, 'sendChamberMembershipApplication'); 1060 await callable({ 1061 ...baseFields, 1062 source: 'chamber-portal-drawer', 1063 paymentMode: invoiceMode ? 'invoice' : 'manual', 1064 paymentPreference: invoiceMode ? 'invoice' : 'contact' 1065 }); 1066 showSuccess(form.closest('[data-pm-checkout]'), openTier, reference, baseFields, { paid: false, invoice: invoiceMode }); 1067 } catch (err) { 1068 console.error('[portal-membership] application submit failed', err); 1069 // Rate-limit / validation rejections carry a specific message; 1070 // everything else keeps the generic copy below. 1071 const specific = friendlyCallableError(err, ''); 1072 showError(form.closest('[data-pm-checkout]'), { 1073 invoice: invoiceMode, 1074 message: specific ? escapeHtml(specific) : invoiceMode 1075 ? 'We couldn’t send your invoice request just now. Nothing was charged. Please try again â and if it keeps happening, give us a call and we’ll set it up for you.' 1076 : 'We couldn’t save your application just now. Please try again â and if it keeps happening, give us a call and we’ll take care of it.' 1077 }); 1078 } 1079 return; 1080 } 1081 1082 /* TOKENIZE the card (Square handles all PCI scope client-side) */ 1083 submitLabel.innerHTML = `<span class="pm-form__submit-spinner"></span><span>Verifying cardâ¦</span>`; 1084 let token; 1085 try { 1086 const result = await activeCard.tokenize(); 1087 if (result.status !== 'OK') { 1088 const message = result.errors?.[0]?.message || 'Please double-check your card details.'; 1089 setError(form, 'card', escapeHtml(message)); 1090 submitLabel.innerHTML = originalLabel; 1091 submitBtn.disabled = false; 1092 return; 1093 } 1094 token = result.token; 1095 } catch (err) { 1096 console.error('[portal-membership] card tokenize failed', err); 1097 setError(form, 'card', 'Card verification failed. Please re-enter the card and try again.'); 1098 submitLabel.innerHTML = originalLabel; 1099 submitBtn.disabled = false; 1100 return; 1101 } 1102 1103 /* CHARGE via Cloud Function */ 1104 submitLabel.innerHTML = `<span class="pm-form__submit-spinner"></span><span>Charging ${openTier.priceLabel}â¦</span>`; 1105 try { 1106 const callable = httpsCallable(functions, 'createChamberMembershipPayment'); 1107 const response = await callable({ 1108 ...baseFields, 1109 sourceId: token, 1110 idempotencyKey: makeIdempotencyKey({ email: baseFields.email, tierId: openTier.id }), 1111 source: 'chamber-portal-drawer' 1112 }); 1113 1114 const payload = response?.data || {}; 1115 if (!payload.success) { 1116 const friendly = payload.error || 'Payment was declined. Please check your card or try another.'; 1117 setError(form, 'card', escapeHtml(friendly)); 1118 submitLabel.innerHTML = originalLabel; 1119 submitBtn.disabled = false; 1120 return; 1121 } 1122 1123 showSuccess( 1124 form.closest('[data-pm-checkout]'), 1125 openTier, 1126 payload.reference || reference, 1127 baseFields, 1128 { 1129 paid: true, 1130 paymentId: payload.paymentId, 1131 receiptUrl: payload.receiptUrl 1132 } 1133 ); 1134 } catch (err) { 1135 console.error('[portal-membership] payment failed', err); 1136 const message = friendlyCallableError(err, err?.message || 'Payment failed. Please try again or call (913) 367-2427.'); 1137 setError(form, 'card', escapeHtml(message)); 1138 submitLabel.innerHTML = originalLabel; 1139 submitBtn.disabled = false; 1140 } 1141} 1142 1143/* ------------------------------------------------------------ 1144 * SUCCESS STATE 1145 * ------------------------------------------------------------ */ 1146function showSuccess(panel, tier, reference, payload, { paid, paymentId, receiptUrl, invoice } = { paid: false }) { 1147 if (!panel) return;
1148 const body = panel.querySelector('.pm-checkout__body'); 1149 if (!body) return; 1150 1151 body.innerHTML = ` 1152 <div class="pm-success"> 1153 <div class="pm-success__icon pm-success__icon--green"> 1154 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> 1155 <path d="M20 6L9 17l-5-5"/> 1156 </svg> 1157 </div> 1158 <h3 class="pm-success__title"> 1159 ${paid ? `Welcome to the Chamber!` : invoice ? `Invoice requested!` : `Application received!`} 1160 </h3> 1161 <p class="pm-success__text"> 1162 ${paid 1163 ? `Your <strong>${escapeHtml(tier.name)}</strong> membership is paid and active. Watch for a welcome email at <strong>${escapeHtml(payload.email)}</strong> with your member kit details.` 1164 : invoice 1165 ? `Thank you, ${escapeHtml(payload.contactName)}! We’ll email an invoice for your <strong>${escapeHtml(tier.name)}</strong> membership to <strong>${escapeHtml(payload.email)}</strong>. Pay by check or online — your spot is reserved.` 1166 : `Thank you, ${escapeHtml(payload.contactName)}! Chamber staff will reach out within one business day to finalize your <strong>${escapeHtml(tier.name)}</strong> membership.`} 1167 </p> 1168 <div class="pm-success__ref">REF: ${escapeHtml(reference)}</div> 1169 <div class="pm-success__btn-row"> 1170 ${receiptUrl ? ` 1171 <a class="pm-success__btn pm-success__btn--ghost" href="${escapeHtml(receiptUrl)}" target="_blank" rel="noopener"> 1172 View receipt 1173 <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"> 1174 <path d="M14 3h7v7M10 14L21 3M21 14v7H3V3h7"/> 1175 </svg> 1176 </a> 1177 ` : ''} 1178 <button type="button" class="pm-success__btn" data-pm-close-success>Done</button> 1179 </div> 1180 </div> 1181 `; 1182 1183 body.querySelector('[data-pm-close-success]')?.addEventListener('click', () => { 1184 closeCheckout(); 1185 if (paid) { 1186 /* Membership active â collapse the membership drawer too. */ 1187 closeMembershipDrawer(); 1188 } 1189 }); 1190} 1191 1192/* ------------------------------------------------------------ 1193 * ERROR STATE â wipes the form and shows one clear, calm screen 1194 * with a Try-again that rebuilds the checkout for the same tier. 1195 * Reuses the .pm-success layout (centered result panel) with an 1196 * error-colored icon so it stays visually consistent with success. 1197 * ------------------------------------------------------------ */ 1198function showError(panel, { invoice, message } = {}) { 1199 if (!panel) return; 1200 const body = panel.querySelector('.pm-checkout__body'); 1201 if (!body) return; 1202 1203 body.innerHTML = ` 1204 <div class="pm-success pm-success--error"> 1205 <div class="pm-success__icon pm-success__icon--error"> 1206 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"> 1207 <circle cx="12" cy="12" r="9"/> 1208 <path d="M12 7.5v5.5"/> 1209 <path d="M12 16.5h.01"/> 1210 </svg> 1211 </div> 1212 <h3 class="pm-success__title">That didn’t go through</h3> 1213 <p class="pm-success__text">${message || 'Something went wrong on our end. Please try again in a moment.'}</p> 1214 <div class="pm-success__btn-row"> 1215 <button type="button" class="pm-success__btn" data-pm-error-retry>Try again</button> 1216 <a class="pm-success__btn pm-success__btn--ghost" href="tel:+19133672427"> 1217 <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.9
12171.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92z"/></svg> 1218 (913) 367-2427 1219 </a> 1220 </div> 1221 </div> 1222 `; 1223 1224 body.querySelector('[data-pm-error-retry]')?.addEventListener('click', () => { 1225 if (openTier) openCheckout(openTier); 1226 }); 1227} 1228 1229/* ------------------------------------------------------------ 1230 * DRAWER OPEN/CLOSE 1231 * ------------------------------------------------------------ */ 1232 1233/** 1234 * Wait until the main portal API and membership drawer shell exist (after partial inject). 1235 */ 1236export async function waitForMembershipPortalInfrastructure({ maxMs = 18000 } = {}) { 1237 const t0 = Date.now(); 1238 while (Date.now() - t0 < maxMs) { 1239 if (document.getElementById('portalMembershipDrawer') 1240 && typeof window.chamberPortal?.open === 'function') { 1241 return true; 1242 } 1243 await new Promise((r) => setTimeout(r, 60)); 1244 } 1245 return false; 1246} 1247 1248/** 1249 * After the tier grid has rendered (and possibly re-rendered from Firestore), activate a tier CTA. 1250 * Waits for the button to be NOT disabled â important because we now disable CTAs while 1251 * pricing is still resolving from Firestore. 1252 */ 1253async function clickMembershipTierWhenReady(tierId) { 1254 const id = (tierId || '').trim().toLowerCase(); 1255 if (!id) return; 1256 /* Up to ~12s â generous enough to cover a slow Firestore + the pending re-render. */ 1257 for (let attempt = 0; attempt < 240; attempt++) { 1258 const drawer = document.getElementById('portalMembershipDrawer');
1259 const choose = drawer?.querySelector(`[data-pm-choose="${CSS.escape(id)}"]`); 1260 if (choose && !choose.disabled) { 1261 choose.click(); 1262 return; 1263 } 1264 await new Promise((r) => setTimeout(r, 50)); 1265 } 1266} 1267 1268/* ------------------------------------------------------------ 1269 * OPENER UX â instant feedback while the portal warms up. 1270 * ------------------------------------------------------------ */ 1271const _openerOriginalText = new WeakMap(); 1272 1273function applyOpeningFeedback(button) { 1274 if (!button || _openerOriginalText.has(button)) return; 1275 _openerOriginalText.set(button, button.innerHTML); 1276 button.setAttribute('aria-busy', 'true'); 1277 button.disabled = true; 1278 button.classList.add('is-opening-portal'); 1279 /* Keep the visual size stable so the layout doesn't jump. */ 1280 button.style.minWidth = `${button.offsetWidth}px`; 1281 button.innerHTML = `<span class="opening-portal-dots" aria-hidden="true"><span></span><span></span><span></span></span><span class="opening-portal-text">Opening…</span>`; 1282} 1283 1284function clearOpeningFeedback(button) { 1285 if (!button) return; 1286 const original = _openerOriginalText.get(button); 1287 if (original !== undefined) { 1288 button.innerHTML = original; 1289 } 1290 button.removeAttribute('aria-busy'); 1291 button.disabled = false; 1292 button.classList.remove('is-opening-portal'); 1293 button.style.minWidth = ''; 1294 _openerOriginalText.delete(button); 1295} 1296 1297function showOpenerError(button, message) { 1298 if (!button) return; 1299 /* Lightweight inline tooltip â no third-party toast lib needed. */ 1300 let tip = button.parentElement?.querySelector('.opening-portal-error'); 1301 if (!tip) { 1302 tip = document.createElement('span'); 1303 tip.className = 'opening-portal-error'; 1304 tip.setAttribute('role', 'alert'); 1305 button.insertAdjacentElement('afterend', tip); 1306 } 1307 tip.textContent = message; 1308 setTimeout(() => { try { tip.remove(); } catch (e) { /* noop */ } }, 6000); 1309} 1310 1311export async function openMembershipDrawer() { 1312 const drawer = document.getElementById('portalMembershipDrawer'); 1313 const root = drawer?.querySelector('[data-portal-membership-root]'); 1314 if (!drawer || !root) return; 1315 1316 /* Cancel any in-flight panel removal â reopening should not GC the new card. */ 1317 if (_checkoutCloseTimer) { 1318 clearTimeout(_checkoutCloseTimer); 1319 _checkoutCloseTimer = null; 1320 } 1321 1322 drawer.classList.add('portal-membership-drawer--open'); 1323 drawer.setAttribute('aria-hidden', 'false'); 1324 document.body.classList.add('portal-membership-open'); 1325 1326 /* Warm up the Square SDK in the background so the card form is 1327 instant when the user picks a tier. */ 1328 if (isSquareConfigured()) loadSquareSdk().catch(() => { /* noop */ }); 1329 1330 setTimeout(() => drawer.focus(), 200); 1331 1332 /* Always render synchronously with the *current* resolution state â that way we 1333 never show stale prices with active CTAs. The first render will show the 1334 hardcoded fallback list with DISABLED CTAs labelled "Loading current pricingâ¦", 1335 then we re-render once Firestore answers (or the timeout fires). */ 1336 if (pricingResolution === 'firestore') { 1337 /* We already have authoritative pricing from a previous open. */ 1338 renderTierGrid(root); 1339 return; 1340 } 1341 1342 /* Force a pending-state render so CTAs are disabled while we ask Firestore. */ 1343 pricingResolution = 'pending'; 1344 renderTierGrid(root); 1345 1346 try { 1347 await loadTiersFromFirestore(); 1348 } catch (e) { 1349 pricingResolution = 'unavailable'; 1350 } 1351 1352 if (drawer.classList.contains('portal-membership-drawer--open')) { 1353 renderTierGrid(root); 1354 } 1355} 1356 1357/** 1358 * Opens the Command Portal, then the nested Become a Member drawer 1359 * (same experience as the portal CTAs). Optionally simulates a tier 1360 * card click so checkout opens for that level (e.g. from /membership#gold). 1361 */ 1362export async function openChamberPortalMembership({ tierId = null, openMainPortal = true } = {}) { 1363 if (openMainPortal && typeof window.chamberPortal?.open === 'function') { 1364 window.chamberPortal.open(); 1365 await new Promise((r) => setTimeout(r, 220)); 1366 } 1367 await openMembershipDrawer(); 1368 await clickMembershipTierWhenReady(tierId); 1369} 1370 1371function closeMembershipDrawer() { 1372 const drawer = document.getElementById('portalMembershipDrawer'); 1373 if (!drawer) return; 1374 drawer.classList.remove('portal-membership-drawer--open'); 1375 drawer.setAttribute('aria-hidden', 'true'); 1376 document.body.classList.remove('portal-membership-open'); 1377 entryInvoiceMode = false; 1378 invoiceMode = false; /* reset so the next open defaults to online checkout */ 1379 closeCheckout(); 1380} 1381 1382/* ------------------------------------------------------------ 1383 * INIT 1384 * ------------------------------------------------------------ */ 1385export function initPortalMembership() { 1386 if (initialized) return; 1387 initialized = true; 1388 1389 document.addEventListener('click', (e) => { 1390 const opener = e.target.closest('[data-portal-membership-open]'); 1391 if (opener) { 1392 e.preventDefault(); 1393 invoiceMode = opener.hasAttribute('data-pm-invoice'); 1394 entryInvoiceMode = invoiceMode; 1395 applyOpeningFeedback(opener); 1396 (async () => { 1397 const ready = await waitForMembershipPortalInfrastru
1397cture({ maxMs: 18000 }); 1398 if (!ready) { 1399 console.warn('[portal-membership] Portal not ready; try again in a moment.'); 1400 showOpenerError(opener, 1401 "We couldn't open the join form. Refresh the page or call (913) 367-2427."); 1402 clearOpeningFeedback(opener); 1403 return; 1404 } 1405 const main = document.getElementById('portalDrawer'); 1406 const mainOpen = main?.classList.contains('portal-drawer--open'); 1407 if (!mainOpen && typeof window.chamberPortal?.open === 'function') { 1408 window.chamberPortal.open(); 1409 await new Promise((r) => setTimeout(r, 200)); 1410 } 1411 await openMembershipDrawer(); 1412 const hint = (opener.getAttribute('data-pm-preselect-tier') || '').trim().toLowerCase(); 1413 if (hint) await clickMembershipTierWhenReady(hint); 1414 clearOpeningFeedback(opener); 1415 })(); 1416 return; 1417 } 1418 const closer = e.target.closest('[data-portal-membership-close]'); 1419 if (closer) { 1420 e.preventDefault(); 1421 closeMembershipDrawer(); 1422 } 1423 }); 1424 1425 document.addEventListener('keydown', (e) => { 1426 if (e.key !== 'Escape') return; 1427 const drawer = document.getElementById('portalMembershipDrawer'); 1428 if (drawer?.classList.contains('portal-membership-drawer--open')) { 1429 e.stopPropagation(); 1430 const checkoutOpen = drawer.querySelector('[data-pm-checkout].pm-checkout--open'); 1431 if (checkoutOpen) { 1432 closeCheckout(); 1433 } else { 1434 closeMembershipDrawer(); 1435 } 1436 } 1437 }, true); 1438 1439 document.addEventListener('portal:closed', () => { 1440 const drawer = document.getElementById('portalMembershipDrawer'); 1441 if (drawer?.classList.contains('portal-membership-drawer--open')) { 1442 closeMembershipDrawer(); 1443 } 1444 }); 1445} 1446 1447export const PORTAL_MEMBERSHIP_TIERS = FALLBACK_TIERS; 1448export { loadTiersFromFirestore };
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.