PageSourceSearch

https://www.atchisonkansas.net/js/portal-membership.js?v=20260515v13

js atchisonkansas.net collected 2026-10-02 11:19:54 UTC 67,181 bytes, 1,448 lines download raw bytes

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, '&amp;')
347        .replace(/</g, '&lt;')
348        .replace(/>/g, '&gt;')
349        .replace(/"/g, '&quot;')
350        .replace(/'/g, '&#39;');
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&rsquo;s business community runs on relationships, visibility, and the programs you see around town.
396                Choose the investment level that fits you &mdash; 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&hellip;';
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 &mdash; ' : '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 &amp; 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&rsquo;re requesting an invoice &mdash; no card needed.</strong>
635                        Submit your details and the Chamber will email an invoice you can pay by check or online whenever it&rsquo;s convenient.
636                    </div>
637                ` : `
638                    <div class="pm-card pm-card--unconfigured">
639                        <strong>Inline payments aren&rsquo;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&rsquo;ll email an invoice to the address above. Pay by check (mailed to the Chamber) or online &mdash; whenever it&rsquo;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&rsquo;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&rsquo;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 &mdash; <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&rsquo;re requesting an invoice &mdash; no card needed.</strong> Submit your details and the Chamber will email an invoice you can pay by check or online whenever it&rsquo;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&rsquo;ll email an invoice to the address above. Pay by check (mailed to the Chamber) or online &mdash; whenever it&rsquo;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&rsquo;t send your invoice request just now. Nothing was charged. Please try again — and if it keeps happening, give us a call and we&rsquo;ll set it up for you.'
1076                    : 'We couldn&rsquo;t save your application just now. Please try again — and if it keeps happening, give us a call and we&rsquo;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&rsquo;ll email an invoice for your <strong>${escapeHtml(tier.name)}</strong> membership to <strong>${escapeHtml(payload.email)}</strong>. Pay by check or online &mdash; 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&rsquo;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&hellip;</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.