372 lines
16 KiB
TypeScript
372 lines
16 KiB
TypeScript
/**
|
|
* <folk-group-buy-page> — 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<typeof setInterval> | 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 = `<style>${styles}</style><div class="loading">Loading group buy...</div>`;
|
|
return;
|
|
}
|
|
if (this.error) {
|
|
this.shadow.innerHTML = `<style>${styles}</style><div class="error">${this.esc(this.error)}</div>`;
|
|
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 = `
|
|
<style>${styles}</style>
|
|
<div class="page">
|
|
<div class="hero">
|
|
${d.imageUrl ? `<img class="hero-img" src="${this.esc(d.imageUrl)}" alt="${this.esc(d.title)}" />` : ''}
|
|
<div class="hero-info">
|
|
<h1 class="hero-title">${this.esc(d.title)}</h1>
|
|
${d.productType ? `<span class="tag tag-type">${this.esc(d.productType)}</span>` : ''}
|
|
${d.description ? `<p class="hero-desc">${this.esc(d.description)}</p>` : ''}
|
|
<div class="hero-meta">
|
|
<span class="status status-${d.status.toLowerCase()}">${d.status}</span>
|
|
<span>${daysLeft} days left</span>
|
|
<span>${d.totalPledged} pledged</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="main-grid">
|
|
<div class="tiers-section">
|
|
<h3>Volume Pricing Tiers</h3>
|
|
<div class="tier-table">
|
|
${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 `<div class="tier-row ${active ? 'tier-current' : ''} ${reached ? 'tier-reached' : ''}">
|
|
<span class="tier-qty">${t.min_qty}+</span>
|
|
<span class="tier-price">$${t.per_unit.toFixed(2)}/ea</span>
|
|
${savings > 0 ? `<span class="tier-savings">-${savings}%</span>` : `<span class="tier-savings"></span>`}
|
|
${reached ? `<span class="tier-check">✓</span>` : ''}
|
|
</div>`;
|
|
}).join('')}
|
|
</div>
|
|
|
|
${nextTier ? `
|
|
<div class="progress-section">
|
|
<div class="progress-label">${remaining} more to unlock $${nextTier.per_unit.toFixed(2)}/ea</div>
|
|
<div class="progress-bar"><div class="progress-fill" style="width:${progressPct}%"></div></div>
|
|
<div class="progress-meta">${d.totalPledged} / ${nextTierQty}</div>
|
|
</div>` : `<div class="progress-section"><div class="progress-label text-green">Best tier unlocked!</div></div>`}
|
|
|
|
<h3>Pledges (${d.pledges.length})</h3>
|
|
<div class="pledges-list">
|
|
${d.pledges.map((p: any) => `
|
|
<div class="pledge-row">
|
|
<span class="pledge-name">${this.esc(p.displayName)}</span>
|
|
<span class="pledge-qty">${p.quantity}</span>
|
|
</div>`).join('')}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="pledge-panel">
|
|
<h3>Join this Group Buy</h3>
|
|
${this.pledged ? `
|
|
<div class="pledge-success">
|
|
<div class="pledge-success-icon">✓</div>
|
|
<p>Pledge submitted! Share this link to bring more people in.</p>
|
|
<button class="btn btn-primary" data-action="copy-link">Copy Share Link</button>
|
|
</div>
|
|
` : `
|
|
<div class="pledge-form">
|
|
<label>Your name (optional)</label>
|
|
<input class="input" type="text" data-field="pledge-name" placeholder="Anonymous" value="${this.esc(this.pledgeName)}" />
|
|
<label>Quantity</label>
|
|
<div class="qty-controls">
|
|
<button class="btn btn-sm" data-action="qty-dec">-</button>
|
|
<input class="qty-input" type="number" data-field="pledge-qty" value="${this.pledgeQty}" min="1" />
|
|
<button class="btn btn-sm" data-action="qty-inc">+</button>
|
|
</div>
|
|
${d.currentTier ? `<div class="pledge-price">$${(d.currentTier.per_unit * this.pledgeQty).toFixed(2)} at current tier</div>` : ''}
|
|
<button class="btn btn-primary btn-lg" data-action="submit-pledge" ${this.pledging ? 'disabled' : ''}>
|
|
${this.pledging ? 'Pledging...' : `Pledge ${this.pledgeQty} unit${this.pledgeQty > 1 ? 's' : ''}`}
|
|
</button>
|
|
</div>
|
|
`}
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
|
|
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);
|