/** * — 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; private simQty = 0; // "What If" simulator quantity (0 = off) private commonsSharePct = 20; // % of revenue to commons 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)); // "What If" simulator calculations const simTotal = this.simQty > 0 ? d.totalPledged + this.simQty : d.totalPledged; const simTier = [...d.tiers].reverse().find((t: any) => simTotal >= t.min_qty) || d.tiers[0]; const simNextTier = d.tiers.find((t: any) => t.min_qty > simTotal); const simProgressPct = simNextTier ? Math.min(100, Math.round((simTotal / simNextTier.min_qty) * 100)) : 100; const maxTierQty = d.tiers[d.tiers.length - 1]?.min_qty || 50; // Revenue & commons calculations const currentRevenue = d.totalPledged * (d.currentTier?.per_unit || d.tiers[0]?.per_unit || 0); const simRevenue = simTotal * simTier.per_unit; const commonsRevenue = simRevenue * (this.commonsSharePct / 100); const currentCommons = currentRevenue * (this.commonsSharePct / 100); 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 simReached = simTotal >= t.min_qty; const simActive = this.simQty > 0 && simTier && t.min_qty === simTier.min_qty; const savings = i > 0 ? Math.round((1 - t.per_unit / d.tiers[0].per_unit) * 100) : 0; const tierRevenue = t.min_qty * t.per_unit; const tierCommons = tierRevenue * (this.commonsSharePct / 100); return `
${t.min_qty}+ $${t.per_unit.toFixed(2)}/ea ${savings > 0 ? `-${savings}%` : ``} $${tierCommons.toFixed(0)} commons ${reached ? `` : this.simQty > 0 && simReached ? `` : ''}
`; }).join('')}
${nextTier ? `
${remaining} more to unlock $${nextTier.per_unit.toFixed(2)}/ea
${d.totalPledged} / ${nextTierQty}
` : `
Best tier unlocked!
`}

What If...?

Explore how more pledges unlock better pricing and grow the commons.

${this.simQty > 0 ? `
Total pledged ${d.totalPledged} + ${this.simQty} = ${simTotal}
Tier unlocked $${simTier.per_unit.toFixed(2)}/ea${simTier.per_unit < (d.currentTier?.per_unit || d.tiers[0].per_unit) ? ` (better!)` : ''}
Total revenue $${simRevenue.toFixed(2)}
${simNextTier ? `
Next unlock ${simNextTier.min_qty - simTotal} more for $${simNextTier.per_unit.toFixed(2)}/ea
` : `
Best tier unlocked!
`}
` : ''}

Commons Revenue

${this.commonsSharePct}%
$${currentCommons.toFixed(2)}
Current
${this.simQty > 0 ? '→' : ''}
${this.simQty > 0 ? `
$${commonsRevenue.toFixed(2)}
Projected
+$${(commonsRevenue - currentCommons).toFixed(2)}
Growth
` : ''}

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); } }); // "What If" simulator controls const simSlider = this.shadow.querySelector("[data-field='sim-qty']") as HTMLInputElement; const simNum = this.shadow.querySelector("[data-field='sim-qty-num']") as HTMLInputElement; simSlider?.addEventListener("input", (e) => { this.simQty = parseInt((e.target as HTMLInputElement).value) || 0; this.render(); }); simNum?.addEventListener("change", (e) => { this.simQty = Math.max(0, parseInt((e.target as HTMLInputElement).value) || 0); this.render(); }); // Commons share slider this.shadow.querySelector("[data-field='commons-pct']")?.addEventListener("input", (e) => { this.commonsSharePct = parseInt((e.target as HTMLInputElement).value) || 0; this.render(); }); } 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; } /* Tier commons column */ .tier-commons { color: var(--rs-text-muted); font-size: 0.6875rem; min-width: 5rem; text-align: right; } /* Simulated tier states */ .tier-sim-active { background: rgba(168,85,247,0.1); border-left: 3px solid #a855f7; } .tier-sim-reached { } .tier-check-sim { color: #a855f7; opacity: 0.7; } /* "What If" simulator */ .sim-section { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 12px; padding: 1.25rem; margin: 1.5rem 0; } .sim-desc { color: var(--rs-text-secondary); font-size: 0.8125rem; margin: 0 0 1rem; line-height: 1.4; } .sim-controls { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.75rem; } .sim-label { color: var(--rs-text-secondary); font-size: 0.8125rem; font-weight: 500; white-space: nowrap; } .sim-slider { flex: 1; accent-color: #a855f7; height: 6px; cursor: pointer; } .sim-qty-input { width: 60px; } .sim-results { background: rgba(168,85,247,0.05); border: 1px solid rgba(168,85,247,0.2); border-radius: 8px; padding: 0.75rem; margin-bottom: 1rem; } .sim-row { display: flex; justify-content: space-between; padding: 0.25rem 0; color: var(--rs-text-primary); font-size: 0.875rem; } .sim-value { text-align: right; } .sim-better { color: #4ade80; font-weight: 600; font-size: 0.8125rem; } .sim-fill { background: linear-gradient(90deg, #a855f7, #d946ef); } /* Commons revenue */ .commons-section { border-top: 1px solid var(--rs-border-subtle); padding-top: 1rem; margin-top: 0.5rem; } .commons-section h4 { color: var(--rs-text-primary); font-size: 0.9375rem; font-weight: 600; margin: 0 0 0.75rem; } .commons-controls { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.75rem; } .commons-slider { flex: 1; accent-color: #4ade80; height: 6px; cursor: pointer; } .commons-pct-label { color: #4ade80; font-weight: 600; font-size: 0.875rem; min-width: 2.5rem; text-align: right; } .commons-stats { display: flex; align-items: center; gap: 1rem; } .commons-stat { text-align: center; } .commons-stat-value { color: var(--rs-text-primary); font-weight: 700; font-size: 1.125rem; } .commons-stat-label { color: var(--rs-text-muted); font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.05em; margin-top: 0.125rem; } .commons-stat-projected .commons-stat-value { color: #a855f7; } .commons-stat-arrow { color: var(--rs-text-muted); font-size: 1.25rem; } @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; } .commons-stats { flex-wrap: wrap; gap: 0.5rem; } } `; } private esc(s: string): string { const d = document.createElement("div"); d.textContent = s; return d.innerHTML; } } customElements.define("folk-group-buy-page", FolkGroupBuyPage);