/** * — Public shareable group buy page. * * Attributes: space, buy-id * Fetches GET /api/group-buys/:id, polls every 10s. * Shows product hero, tier progress, pledge panel. * Demo mode when buyId starts with 'demo-'. */ class FolkGroupBuyPage extends HTMLElement { private shadow: ShadowRoot; private space = 'default'; private buyId = ''; private data: any = null; private loading = true; private error = ''; private pollTimer: ReturnType | null = null; private pledgeQty = 1; private pledgeName = ''; private pledging = false; private pledged = false; constructor() { super(); this.shadow = this.attachShadow({ mode: 'open' }); } connectedCallback() { this.space = this.getAttribute('space') || 'default'; this.buyId = this.getAttribute('buy-id') || ''; this.loadData(); this.startPolling(); } disconnectedCallback() { this.stopPolling(); } private getApiBase(): string { const path = window.location.pathname; const match = path.match(/^(\/[^/]+)?\/rcart/); return match ? match[0] : '/rcart'; } private async loadData() { this.loading = true; this.render(); if (this.buyId.startsWith('demo-')) { this.data = this.getDemoData(); this.loading = false; this.render(); return; } try { const res = await fetch(`${this.getApiBase()}/api/group-buys/${this.buyId}`); if (!res.ok) throw new Error('Group buy not found'); this.data = await res.json(); } catch (e) { this.error = e instanceof Error ? e.message : 'Failed to load group buy'; } this.loading = false; this.render(); } private startPolling() { this.pollTimer = setInterval(async () => { if (!this.buyId || this.buyId.startsWith('demo-')) return; try { const res = await fetch(`${this.getApiBase()}/api/group-buys/${this.buyId}`); if (res.ok) { const updated = await res.json(); if (updated.totalPledged !== this.data?.totalPledged) { this.data = updated; this.render(); } } } catch { /* silent poll fail */ } }, 10000); } private stopPolling() { if (this.pollTimer) { clearInterval(this.pollTimer); this.pollTimer = null; } } private getDemoData() { return { id: this.buyId, title: 'Cosmolocal Network Tee', productType: 'tee', imageUrl: '/images/catalog/catalog-cosmolocal-tee.jpg', description: 'Bella+Canvas 3001 tee with the Cosmolocal Network radial design in teal and coral.', tiers: [ { min_qty: 1, per_unit: 25, currency: 'USD' }, { min_qty: 10, per_unit: 21.25, currency: 'USD' }, { min_qty: 25, per_unit: 18, currency: 'USD' }, { min_qty: 50, per_unit: 15, currency: 'USD' }, ], status: 'OPEN', totalPledged: 17, currentTier: { min_qty: 10, per_unit: 21.25, currency: 'USD' }, pledges: [ { id: 'p1', displayName: 'Alice', quantity: 5, pledgedAt: new Date(Date.now() - 86400000 * 3).toISOString() }, { id: 'p2', displayName: 'Bob', quantity: 3, pledgedAt: new Date(Date.now() - 86400000 * 2).toISOString() }, { id: 'p3', displayName: 'Cooperative Hub', quantity: 6, pledgedAt: new Date(Date.now() - 86400000).toISOString() }, { id: 'p4', displayName: 'Dana', quantity: 3, pledgedAt: new Date(Date.now() - 3600000).toISOString() }, ], closesAt: new Date(Date.now() + 86400000 * 14).toISOString(), createdAt: new Date(Date.now() - 86400000 * 5).toISOString(), }; } private async submitPledge() { if (this.pledging || this.pledgeQty < 1) return; if (this.buyId.startsWith('demo-')) { this.data.totalPledged += this.pledgeQty; this.data.pledges.push({ id: `p-${Date.now()}`, displayName: this.pledgeName || 'Anonymous', quantity: this.pledgeQty, pledgedAt: new Date().toISOString(), }); // Recalculate current tier const tiers = this.data.tiers; this.data.currentTier = [...tiers].reverse().find((t: any) => this.data.totalPledged >= t.min_qty) || tiers[0]; this.pledged = true; this.render(); return; } this.pledging = true; this.render(); try { const res = await fetch(`${this.getApiBase()}/api/group-buys/${this.buyId}/pledge`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(localStorage.getItem('encryptid-token') ? { 'Authorization': `Bearer ${localStorage.getItem('encryptid-token')}` } : {}), }, body: JSON.stringify({ quantity: this.pledgeQty, displayName: this.pledgeName || 'Anonymous' }), }); if (res.ok) { this.pledged = true; await this.loadData(); } } catch (e) { console.error('Failed to pledge:', e); } this.pledging = false; this.render(); } private render() { const styles = this.getStyles(); if (this.loading) { this.shadow.innerHTML = `
Loading group buy...
`; return; } if (this.error) { this.shadow.innerHTML = `
${this.esc(this.error)}
`; return; } const d = this.data; if (!d) return; const nextTier = d.tiers.find((t: any) => t.min_qty > d.totalPledged); const nextTierQty = nextTier ? nextTier.min_qty : d.tiers[d.tiers.length - 1]?.min_qty || 0; const progressPct = nextTier ? Math.min(100, Math.round((d.totalPledged / nextTier.min_qty) * 100)) : 100; const remaining = nextTier ? nextTier.min_qty - d.totalPledged : 0; const closesDate = new Date(d.closesAt); const daysLeft = Math.max(0, Math.ceil((closesDate.getTime() - Date.now()) / 86400000)); this.shadow.innerHTML = `
${d.imageUrl ? `${this.esc(d.title)}` : ''}

${this.esc(d.title)}

${d.productType ? `${this.esc(d.productType)}` : ''} ${d.description ? `

${this.esc(d.description)}

` : ''}
${d.status} ${daysLeft} days left ${d.totalPledged} pledged

Volume Pricing Tiers

${d.tiers.map((t: any, i: number) => { const active = d.currentTier && t.min_qty === d.currentTier.min_qty; const reached = d.totalPledged >= t.min_qty; const savings = i > 0 ? Math.round((1 - t.per_unit / d.tiers[0].per_unit) * 100) : 0; return `
${t.min_qty}+ $${t.per_unit.toFixed(2)}/ea ${savings > 0 ? `-${savings}%` : ``} ${reached ? `` : ''}
`; }).join('')}
${nextTier ? `
${remaining} more to unlock $${nextTier.per_unit.toFixed(2)}/ea
${d.totalPledged} / ${nextTierQty}
` : `
Best tier unlocked!
`}

Pledges (${d.pledges.length})

${d.pledges.map((p: any) => `
${this.esc(p.displayName)} ${p.quantity}
`).join('')}

Join this Group Buy

${this.pledged ? `

Pledge submitted! Share this link to bring more people in.

` : `
${d.currentTier ? `
$${(d.currentTier.per_unit * this.pledgeQty).toFixed(2)} at current tier
` : ''}
`}
`; this.bindEvents(); } private bindEvents() { this.shadow.querySelector("[data-action='qty-dec']")?.addEventListener("click", () => { if (this.pledgeQty > 1) { this.pledgeQty--; this.render(); } }); this.shadow.querySelector("[data-action='qty-inc']")?.addEventListener("click", () => { this.pledgeQty++; this.render(); }); this.shadow.querySelector("[data-field='pledge-qty']")?.addEventListener("change", (e) => { this.pledgeQty = Math.max(1, parseInt((e.target as HTMLInputElement).value) || 1); this.render(); }); this.shadow.querySelector("[data-field='pledge-name']")?.addEventListener("input", (e) => { this.pledgeName = (e.target as HTMLInputElement).value; }); this.shadow.querySelector("[data-action='submit-pledge']")?.addEventListener("click", () => { this.submitPledge(); }); this.shadow.querySelector("[data-action='copy-link']")?.addEventListener("click", () => { navigator.clipboard.writeText(window.location.href); const btn = this.shadow.querySelector("[data-action='copy-link']") as HTMLElement; if (btn) { btn.textContent = 'Copied!'; setTimeout(() => { btn.textContent = 'Copy Share Link'; }, 2000); } }); } private getStyles(): string { return ` :host { display: block; padding: 1.5rem; max-width: 900px; margin: 0 auto; } * { box-sizing: border-box; } .loading { text-align: center; padding: 3rem; color: var(--rs-text-secondary); } .error { background: rgba(239,68,68,0.1); border: 1px solid var(--rs-error); border-radius: 8px; padding: 1rem; color: #fca5a5; text-align: center; } .hero { display: flex; gap: 1.5rem; margin-bottom: 2rem; } .hero-img { width: 200px; height: 200px; border-radius: 12px; object-fit: cover; flex-shrink: 0; } .hero-title { color: var(--rs-text-primary); font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem; } .hero-desc { color: var(--rs-text-secondary); font-size: 0.9375rem; line-height: 1.5; margin: 0.5rem 0; } .hero-meta { display: flex; gap: 1rem; align-items: center; color: var(--rs-text-muted); font-size: 0.8125rem; margin-top: 0.75rem; } .tag { display: inline-block; padding: 0.125rem 0.5rem; border-radius: 4px; font-size: 0.6875rem; } .tag-type { background: rgba(99,102,241,0.1); color: var(--rs-primary-hover); } .status { padding: 0.125rem 0.5rem; border-radius: 999px; font-size: 0.6875rem; font-weight: 500; } .status-open { background: rgba(34,197,94,0.15); color: #4ade80; } .status-locked { background: rgba(251,191,36,0.15); color: #fbbf24; } .status-ordered { background: rgba(99,102,241,0.15); color: #a5b4fc; } .status-cancelled { background: rgba(239,68,68,0.15); color: #f87171; } .main-grid { display: grid; grid-template-columns: 1fr 320px; gap: 2rem; } h3 { color: var(--rs-text-primary); font-size: 1rem; font-weight: 600; margin: 0 0 0.75rem; } .tier-table { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 10px; overflow: hidden; margin-bottom: 1rem; } .tier-row { display: flex; align-items: center; padding: 0.625rem 1rem; border-bottom: 1px solid var(--rs-border-subtle); gap: 1rem; } .tier-row:last-child { border-bottom: none; } .tier-current { background: rgba(99,102,241,0.1); border-left: 3px solid var(--rs-primary-hover); } .tier-reached { } .tier-qty { color: var(--rs-text-primary); font-weight: 600; min-width: 3rem; } .tier-price { color: var(--rs-text-primary); flex: 1; } .tier-savings { color: #4ade80; font-size: 0.8125rem; font-weight: 500; min-width: 3rem; } .tier-check { color: #4ade80; font-size: 0.875rem; } .progress-section { margin-bottom: 1.5rem; } .progress-label { color: var(--rs-text-primary); font-size: 0.875rem; margin-bottom: 0.5rem; } .progress-bar { background: var(--rs-bg-surface-raised); border-radius: 999px; height: 10px; overflow: hidden; } .progress-fill { background: linear-gradient(90deg, var(--rs-primary), #8b5cf6); height: 100%; border-radius: 999px; transition: width 0.3s; } .progress-meta { color: var(--rs-text-muted); font-size: 0.75rem; margin-top: 0.25rem; text-align: right; } .text-green { color: #4ade80; } .pledges-list { margin-bottom: 1rem; } .pledge-row { display: flex; justify-content: space-between; padding: 0.375rem 0; border-bottom: 1px solid var(--rs-border-subtle); } .pledge-name { color: var(--rs-text-primary); font-size: 0.875rem; } .pledge-qty { color: var(--rs-text-secondary); font-size: 0.875rem; font-weight: 600; } .pledge-panel { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 12px; padding: 1.25rem; height: fit-content; position: sticky; top: 1.5rem; } .pledge-form { display: flex; flex-direction: column; gap: 0.75rem; } .pledge-form label { color: var(--rs-text-secondary); font-size: 0.8125rem; font-weight: 500; } .input { padding: 0.5rem 0.75rem; border-radius: 8px; border: 1px solid var(--rs-input-border); background: var(--rs-input-bg); color: var(--rs-input-text); font-size: 0.875rem; } .input:focus { outline: none; border-color: var(--rs-primary); } .qty-controls { display: flex; align-items: center; gap: 0.5rem; } .qty-input { width: 60px; text-align: center; padding: 0.375rem; border-radius: 8px; border: 1px solid var(--rs-input-border); background: var(--rs-input-bg); color: var(--rs-input-text); font-size: 0.875rem; } .pledge-price { color: var(--rs-text-secondary); font-size: 0.875rem; } .btn { padding: 0.5rem 1rem; border-radius: 8px; border: 1px solid var(--rs-border); background: var(--rs-bg-surface); color: var(--rs-text-primary); cursor: pointer; font-size: 0.875rem; } .btn:hover { border-color: var(--rs-border-strong); } .btn-primary { background: var(--rs-primary-hover); border-color: var(--rs-primary); color: #fff; } .btn-primary:hover { background: #4338ca; } .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } .btn-sm { padding: 0.375rem 0.75rem; font-size: 0.8125rem; } .btn-lg { padding: 0.75rem; font-size: 1rem; width: 100%; } .pledge-success { text-align: center; } .pledge-success-icon { font-size: 2rem; color: #4ade80; margin-bottom: 0.5rem; } .pledge-success p { color: var(--rs-text-secondary); font-size: 0.875rem; margin: 0.5rem 0 1rem; } @media (max-width: 768px) { .hero { flex-direction: column; } .hero-img { width: 100%; height: auto; aspect-ratio: 1; } .main-grid { grid-template-columns: 1fr; } .pledge-panel { position: static; } } `; } private esc(s: string): string { const d = document.createElement("div"); d.textContent = s; return d.innerHTML; } } customElements.define("folk-group-buy-page", FolkGroupBuyPage);