feat(rcart): catalog detail view, order queue, and group buy
- Add catalog-detail view with quantity selector and order queue - Add group buy creation flow - Add ?tab=catalog URL param support - Expand catalog item schema with inventory fields Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ac08fb74c8
commit
13f331d72c
|
|
@ -20,17 +20,22 @@ class FolkCartShop extends HTMLElement {
|
|||
private orders: any[] = [];
|
||||
private carts: any[] = [];
|
||||
private payments: any[] = [];
|
||||
private view: "carts" | "cart-detail" | "catalog" | "orders" | "payments" = "carts";
|
||||
private view: "carts" | "cart-detail" | "catalog" | "catalog-detail" | "orders" | "payments" = "carts";
|
||||
private selectedCartId: string | null = null;
|
||||
private selectedCart: any = null;
|
||||
private selectedCatalogItem: any = null;
|
||||
private detailQuantity = 1;
|
||||
private orderQueue: any[] = [];
|
||||
private orderQueueOpen = false;
|
||||
private loading = true;
|
||||
private addingUrl = false;
|
||||
private contributingAmount = false;
|
||||
private extensionInstalled = false;
|
||||
private bannerDismissed = false;
|
||||
private creatingPayment = false;
|
||||
private creatingGroupBuy = false;
|
||||
private _offlineUnsubs: (() => void)[] = [];
|
||||
private _history = new ViewHistory<"carts" | "cart-detail" | "catalog" | "orders" | "payments">("carts");
|
||||
private _history = new ViewHistory<"carts" | "cart-detail" | "catalog" | "catalog-detail" | "orders" | "payments">("carts");
|
||||
|
||||
// Guided tour
|
||||
private _tour!: TourEngine;
|
||||
|
|
@ -65,6 +70,10 @@ class FolkCartShop extends HTMLElement {
|
|||
this.space = parts.length >= 1 ? parts[0] : "default";
|
||||
}
|
||||
|
||||
// Check URL params for initial tab
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('tab') === 'catalog') this.view = 'catalog';
|
||||
|
||||
if (this.space === "demo") {
|
||||
this.loadDemoData();
|
||||
} else {
|
||||
|
|
@ -367,6 +376,8 @@ class FolkCartShop extends HTMLElement {
|
|||
content = this.renderCartDetail();
|
||||
} else if (this.view === "catalog") {
|
||||
content = this.renderCatalog();
|
||||
} else if (this.view === "catalog-detail") {
|
||||
content = this.renderCatalogDetail();
|
||||
} else if (this.view === "payments") {
|
||||
content = this.renderPayments();
|
||||
} else {
|
||||
|
|
@ -384,7 +395,9 @@ class FolkCartShop extends HTMLElement {
|
|||
<button class="tab ${this.view === 'payments' ? 'active' : ''}" data-view="payments">💳 Payments (${this.payments.length})</button>
|
||||
</div>
|
||||
<button class="tab" id="btn-tour" style="margin-left:auto;font-size:0.8rem">Tour</button>
|
||||
${this.orderQueue.length > 0 ? `<button class="queue-badge" data-action="toggle-queue">Queue (${this.orderQueue.reduce((s: number, i: any) => s + i.quantity, 0)})</button>` : ''}
|
||||
</div>
|
||||
${this.orderQueueOpen && this.orderQueue.length > 0 ? this.renderOrderQueue() : ''}
|
||||
${content}
|
||||
`;
|
||||
|
||||
|
|
@ -500,6 +513,54 @@ class FolkCartShop extends HTMLElement {
|
|||
setTimeout(() => { (el as HTMLElement).textContent = 'Copy Link'; }, 2000);
|
||||
});
|
||||
});
|
||||
|
||||
// Catalog card clicks → detail view
|
||||
this.shadow.querySelectorAll("[data-catalog-id]").forEach((el) => {
|
||||
el.addEventListener("click", () => {
|
||||
this.loadCatalogDetail((el as HTMLElement).dataset.catalogId!);
|
||||
});
|
||||
});
|
||||
|
||||
// Catalog detail quantity controls
|
||||
this.shadow.querySelector("[data-action='qty-dec']")?.addEventListener("click", () => {
|
||||
if (this.detailQuantity > 1) { this.detailQuantity--; this.render(); }
|
||||
});
|
||||
this.shadow.querySelector("[data-action='qty-inc']")?.addEventListener("click", () => {
|
||||
this.detailQuantity++; this.render();
|
||||
});
|
||||
this.shadow.querySelector("[data-field='detail-qty']")?.addEventListener("change", (e) => {
|
||||
const val = parseInt((e.target as HTMLInputElement).value) || 1;
|
||||
this.detailQuantity = Math.max(1, val);
|
||||
this.render();
|
||||
});
|
||||
|
||||
// Add to queue
|
||||
this.shadow.querySelector("[data-action='add-to-queue']")?.addEventListener("click", () => {
|
||||
this.addToQueue();
|
||||
});
|
||||
|
||||
// Start group buy
|
||||
this.shadow.querySelector("[data-action='start-group-buy']")?.addEventListener("click", () => {
|
||||
this.startGroupBuy();
|
||||
});
|
||||
|
||||
// Order queue toggle
|
||||
this.shadow.querySelectorAll("[data-action='toggle-queue']").forEach((el) => {
|
||||
el.addEventListener("click", () => {
|
||||
this.orderQueueOpen = !this.orderQueueOpen;
|
||||
this.render();
|
||||
});
|
||||
});
|
||||
|
||||
// Remove from queue
|
||||
this.shadow.querySelectorAll("[data-remove-queue]").forEach((el) => {
|
||||
el.addEventListener("click", () => {
|
||||
const idx = parseInt((el as HTMLElement).dataset.removeQueue!);
|
||||
this.orderQueue.splice(idx, 1);
|
||||
if (this.orderQueue.length === 0) this.orderQueueOpen = false;
|
||||
this.render();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Extension install banner ──
|
||||
|
|
@ -659,7 +720,7 @@ class FolkCartShop extends HTMLElement {
|
|||
|
||||
return `<div class="grid catalog-grid">
|
||||
${this.catalog.map((entry) => `
|
||||
<div class="card catalog-card" data-collab-id="product:${entry.id || entry.title}">
|
||||
<div class="card catalog-card card-clickable" data-catalog-id="${entry.id}" data-collab-id="product:${entry.id || entry.title}">
|
||||
${entry.image_url ? `<div class="catalog-img"><img src="${this.esc(entry.image_url)}" alt="${this.esc(entry.title || '')}" loading="lazy" /></div>` : ""}
|
||||
<div class="catalog-body">
|
||||
<h3 class="card-title">${this.esc(entry.title || "Untitled")}</h3>
|
||||
|
|
@ -680,6 +741,201 @@ class FolkCartShop extends HTMLElement {
|
|||
</div>`;
|
||||
}
|
||||
|
||||
// ── Catalog detail view ──
|
||||
|
||||
private loadCatalogDetail(id: string) {
|
||||
const item = this.catalog.find((e: any) => e.id === id);
|
||||
if (!item) return;
|
||||
this.selectedCatalogItem = item;
|
||||
this.detailQuantity = 1;
|
||||
this._history.push(this.view);
|
||||
this.view = "catalog-detail";
|
||||
this._history.push("catalog-detail");
|
||||
this.render();
|
||||
}
|
||||
|
||||
private buildDemoFulfillOptions(entry: any) {
|
||||
const basePrice = entry.price || 10;
|
||||
return {
|
||||
provider: {
|
||||
name: "Community Print Co-op",
|
||||
city: "Portland, OR",
|
||||
turnaround: "5-7 business days",
|
||||
capabilities: entry.required_capabilities || ["dtg-print"],
|
||||
},
|
||||
tiers: [
|
||||
{ min_qty: 1, per_unit: basePrice, currency: entry.currency || "USD" },
|
||||
{ min_qty: 10, per_unit: +(basePrice * 0.85).toFixed(2), currency: entry.currency || "USD" },
|
||||
{ min_qty: 25, per_unit: +(basePrice * 0.72).toFixed(2), currency: entry.currency || "USD" },
|
||||
{ min_qty: 50, per_unit: +(basePrice * 0.60).toFixed(2), currency: entry.currency || "USD" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private getCurrentTierPrice(): number {
|
||||
if (!this.selectedCatalogItem) return 0;
|
||||
const opts = this.buildDemoFulfillOptions(this.selectedCatalogItem);
|
||||
let price = opts.tiers[0].per_unit;
|
||||
for (const tier of opts.tiers) {
|
||||
if (this.detailQuantity >= tier.min_qty) price = tier.per_unit;
|
||||
}
|
||||
return price;
|
||||
}
|
||||
|
||||
private renderCatalogDetail(): string {
|
||||
const item = this.selectedCatalogItem;
|
||||
if (!item) return `<div class="empty">Item not found.</div>`;
|
||||
|
||||
const opts = this.buildDemoFulfillOptions(item);
|
||||
const unitPrice = this.getCurrentTierPrice();
|
||||
const totalPrice = (unitPrice * this.detailQuantity).toFixed(2);
|
||||
const basePrice = opts.tiers[0].per_unit;
|
||||
|
||||
return `
|
||||
<div class="detail-back">
|
||||
<button class="btn btn-sm" data-action="back">← Back to catalog</button>
|
||||
</div>
|
||||
<div class="catalog-detail-layout">
|
||||
<div class="detail-image">
|
||||
${item.image_url
|
||||
? `<img src="${this.esc(item.image_url)}" alt="${this.esc(item.title)}" />`
|
||||
: `<div class="detail-image-placeholder">No image</div>`}
|
||||
</div>
|
||||
<div class="detail-panel">
|
||||
<h2 class="detail-title">${this.esc(item.title)}</h2>
|
||||
${item.product_type ? `<span class="tag tag-type" style="margin-bottom:0.5rem;display:inline-block">${this.esc(item.product_type)}</span>` : ''}
|
||||
${(item.tags || []).map((t: string) => `<span class="tag tag-cap">${this.esc(t)}</span>`).join(' ')}
|
||||
${item.description ? `<p class="detail-desc">${this.esc(item.description)}</p>` : ''}
|
||||
|
||||
<div class="tier-table">
|
||||
<div class="tier-header">Volume Pricing</div>
|
||||
${opts.tiers.map((t: any, i: number) => {
|
||||
const active = this.detailQuantity >= t.min_qty && (i === opts.tiers.length - 1 || this.detailQuantity < opts.tiers[i + 1].min_qty);
|
||||
const savings = i > 0 ? Math.round((1 - t.per_unit / basePrice) * 100) : 0;
|
||||
return `<div class="tier-row ${active ? 'tier-active' : ''}">
|
||||
<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>`}
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
|
||||
<div class="qty-row">
|
||||
<label>Quantity</label>
|
||||
<div class="qty-controls">
|
||||
<button class="btn btn-sm" data-action="qty-dec">-</button>
|
||||
<input type="number" class="qty-input" data-field="detail-qty" value="${this.detailQuantity}" min="1" />
|
||||
<button class="btn btn-sm" data-action="qty-inc">+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-total">
|
||||
<span>Total</span>
|
||||
<span class="price">$${totalPrice} ${item.currency || 'USD'}</span>
|
||||
</div>
|
||||
|
||||
<div class="provider-info">
|
||||
<div class="provider-name">${this.esc(opts.provider.name)}</div>
|
||||
<div class="provider-meta">${this.esc(opts.provider.city)} • ${this.esc(opts.provider.turnaround)}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-actions">
|
||||
<button class="btn btn-primary" data-action="add-to-queue">Add to Queue</button>
|
||||
<button class="btn" data-action="start-group-buy" ${this.creatingGroupBuy ? 'disabled' : ''}>${this.creatingGroupBuy ? 'Creating...' : 'Start Group Buy'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Order queue drawer ──
|
||||
|
||||
private renderOrderQueue(): string {
|
||||
const total = this.orderQueue.reduce((s: number, i: any) => s + i.unitPrice * i.quantity, 0);
|
||||
return `
|
||||
<div class="queue-drawer">
|
||||
<div class="queue-header">
|
||||
<h3>Order Queue (${this.orderQueue.length})</h3>
|
||||
<button class="btn-icon" data-action="toggle-queue">×</button>
|
||||
</div>
|
||||
${this.orderQueue.map((item: any, idx: number) => `
|
||||
<div class="queue-item">
|
||||
<div class="queue-item-info">
|
||||
<span class="queue-item-title">${this.esc(item.title)}</span>
|
||||
<span class="queue-item-meta">Qty: ${item.quantity} • $${(item.unitPrice * item.quantity).toFixed(2)}</span>
|
||||
</div>
|
||||
<button class="btn-icon" data-remove-queue="${idx}">×</button>
|
||||
</div>
|
||||
`).join('')}
|
||||
<div class="queue-total">
|
||||
<span>Total</span>
|
||||
<span class="price">$${total.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private addToQueue() {
|
||||
if (!this.selectedCatalogItem) return;
|
||||
const item = this.selectedCatalogItem;
|
||||
const unitPrice = this.getCurrentTierPrice();
|
||||
const existing = this.orderQueue.find((q: any) => q.catalogId === item.id);
|
||||
if (existing) {
|
||||
existing.quantity += this.detailQuantity;
|
||||
existing.unitPrice = unitPrice;
|
||||
} else {
|
||||
this.orderQueue.push({
|
||||
catalogId: item.id,
|
||||
title: item.title,
|
||||
productType: item.product_type,
|
||||
imageUrl: item.image_url,
|
||||
quantity: this.detailQuantity,
|
||||
unitPrice,
|
||||
currency: item.currency || 'USD',
|
||||
});
|
||||
}
|
||||
this.render();
|
||||
}
|
||||
|
||||
private async startGroupBuy() {
|
||||
if (!this.selectedCatalogItem || this.creatingGroupBuy) return;
|
||||
const item = this.selectedCatalogItem;
|
||||
const opts = this.buildDemoFulfillOptions(item);
|
||||
|
||||
if (this.space === 'demo') {
|
||||
const demoId = `demo-gb-${Date.now()}`;
|
||||
const host = window.location.host;
|
||||
const shareUrl = `https://${host}/demo/rcart/buy/${demoId}`;
|
||||
try { await navigator.clipboard.writeText(shareUrl); } catch {}
|
||||
alert(`Group buy link copied!\n${shareUrl}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.creatingGroupBuy = true;
|
||||
this.render();
|
||||
try {
|
||||
const res = await fetch(`${this.getApiBase()}/api/group-buys`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(localStorage.getItem('encryptid-token') ? { 'Authorization': `Bearer ${localStorage.getItem('encryptid-token')}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
catalogEntryId: item.id,
|
||||
tiers: opts.tiers,
|
||||
description: item.description || '',
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
try { await navigator.clipboard.writeText(data.shareUrl); } catch {}
|
||||
alert(`Group buy created! Link copied:\n${data.shareUrl}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to create group buy:", e);
|
||||
}
|
||||
this.creatingGroupBuy = false;
|
||||
this.render();
|
||||
}
|
||||
|
||||
// ── Orders view ──
|
||||
|
||||
private renderOrders(): string {
|
||||
|
|
@ -894,10 +1150,57 @@ class FolkCartShop extends HTMLElement {
|
|||
.empty { text-align: center; padding: 3rem; color: var(--rs-text-muted); font-size: 0.875rem; }
|
||||
.loading { text-align: center; padding: 3rem; color: var(--rs-text-secondary); }
|
||||
|
||||
/* Catalog detail view */
|
||||
.detail-back { margin-bottom: 1rem; }
|
||||
.catalog-detail-layout { display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; }
|
||||
.detail-image img { width: 100%; border-radius: 12px; aspect-ratio: 1; object-fit: cover; }
|
||||
.detail-image-placeholder { width: 100%; aspect-ratio: 1; background: var(--rs-bg-surface-raised); border-radius: 12px; display: flex; align-items: center; justify-content: center; color: var(--rs-text-muted); }
|
||||
.detail-title { color: var(--rs-text-primary); font-size: 1.5rem; font-weight: 700; margin: 0 0 0.5rem; }
|
||||
.detail-desc { color: var(--rs-text-secondary); font-size: 0.875rem; line-height: 1.5; margin: 0.75rem 0; }
|
||||
|
||||
.tier-table { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 10px; overflow: hidden; margin: 1rem 0; }
|
||||
.tier-header { padding: 0.625rem 1rem; font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--rs-text-secondary); border-bottom: 1px solid var(--rs-border); }
|
||||
.tier-row { display: flex; align-items: center; padding: 0.5rem 1rem; border-bottom: 1px solid var(--rs-border-subtle); gap: 1rem; transition: background 0.15s; }
|
||||
.tier-row:last-child { border-bottom: none; }
|
||||
.tier-active { background: rgba(99,102,241,0.08); }
|
||||
.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; text-align: right; }
|
||||
|
||||
.qty-row { display: flex; align-items: center; justify-content: space-between; margin: 1rem 0; }
|
||||
.qty-row label { color: var(--rs-text-primary); font-weight: 600; }
|
||||
.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; }
|
||||
|
||||
.detail-total { display: flex; justify-content: space-between; align-items: center; padding: 1rem 0; border-top: 1px solid var(--rs-border); border-bottom: 1px solid var(--rs-border); }
|
||||
.detail-total span:first-child { color: var(--rs-text-primary); font-weight: 600; font-size: 1rem; }
|
||||
|
||||
.provider-info { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 10px; padding: 0.75rem 1rem; margin: 1rem 0; }
|
||||
.provider-name { color: var(--rs-text-primary); font-weight: 600; font-size: 0.875rem; }
|
||||
.provider-meta { color: var(--rs-text-muted); font-size: 0.8125rem; margin-top: 0.25rem; }
|
||||
|
||||
.detail-actions { display: flex; gap: 0.75rem; margin-top: 1rem; }
|
||||
.detail-actions .btn { flex: 1; text-align: center; }
|
||||
|
||||
/* Order queue */
|
||||
.queue-badge { background: var(--rs-primary-hover); color: #fff; border: none; border-radius: 999px; padding: 0.375rem 0.875rem; font-size: 0.8125rem; font-weight: 600; cursor: pointer; }
|
||||
.queue-badge:hover { background: #4338ca; }
|
||||
.queue-drawer { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 12px; padding: 1rem; margin-bottom: 1rem; }
|
||||
.queue-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; }
|
||||
.queue-header h3 { margin: 0; color: var(--rs-text-primary); font-size: 1rem; }
|
||||
.queue-item { display: flex; align-items: center; justify-content: space-between; padding: 0.5rem 0; border-bottom: 1px solid var(--rs-border-subtle); }
|
||||
.queue-item:last-of-type { border-bottom: none; }
|
||||
.queue-item-info { flex: 1; }
|
||||
.queue-item-title { color: var(--rs-text-primary); font-size: 0.875rem; display: block; }
|
||||
.queue-item-meta { color: var(--rs-text-muted); font-size: 0.75rem; }
|
||||
.queue-total { display: flex; justify-content: space-between; padding-top: 0.75rem; margin-top: 0.5rem; border-top: 1px solid var(--rs-border); }
|
||||
|
||||
@media (max-width: 768px) { .catalog-detail-layout { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 600px) {
|
||||
.grid { grid-template-columns: 1fr; }
|
||||
.catalog-grid { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); }
|
||||
.url-input-row { flex-direction: column; }
|
||||
.detail-actions { flex-direction: column; }
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,13 +19,14 @@ import { renderLanding } from "./landing";
|
|||
import type { SyncServer } from '../../server/local-first/sync-server';
|
||||
import {
|
||||
catalogSchema, orderSchema, shoppingCartSchema, shoppingCartIndexSchema,
|
||||
paymentRequestSchema,
|
||||
paymentRequestSchema, groupBuySchema,
|
||||
catalogDocId, orderDocId, shoppingCartDocId, shoppingCartIndexDocId,
|
||||
paymentRequestDocId,
|
||||
paymentRequestDocId, groupBuyDocId,
|
||||
type CatalogDoc, type CatalogEntry,
|
||||
type OrderDoc, type OrderMeta,
|
||||
type ShoppingCartDoc, type ShoppingCartIndexDoc,
|
||||
type PaymentRequestDoc, type PaymentRequestMeta,
|
||||
type GroupBuyDoc,
|
||||
type CartItem, type CartStatus,
|
||||
} from './schemas';
|
||||
import { extractProductFromUrl } from './extract';
|
||||
|
|
@ -1416,6 +1417,179 @@ function paymentToResponse(p: PaymentRequestMeta) {
|
|||
};
|
||||
}
|
||||
|
||||
// ── GROUP BUY ROUTES ──
|
||||
|
||||
// POST /api/group-buys — Create a group buy from a catalog entry
|
||||
routes.post("/api/group-buys", async (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||
|
||||
let claims;
|
||||
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
|
||||
const body = await c.req.json();
|
||||
const { catalogEntryId, tiers, description, closesInDays } = body;
|
||||
if (!catalogEntryId || !tiers?.length) return c.json({ error: "catalogEntryId and tiers[] required" }, 400);
|
||||
|
||||
const catDoc = _syncServer!.getDoc<CatalogDoc>(catalogDocId(space));
|
||||
const entry = catDoc?.items?.[catalogEntryId];
|
||||
if (!entry) return c.json({ error: "Catalog entry not found" }, 404);
|
||||
|
||||
const buyId = crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
const docId = groupBuyDocId(space, buyId);
|
||||
|
||||
const doc = Automerge.change(Automerge.init<GroupBuyDoc>(), 'create group buy', (d) => {
|
||||
const init = groupBuySchema.init();
|
||||
Object.assign(d, init);
|
||||
d.meta.spaceSlug = space;
|
||||
d.meta.createdAt = now;
|
||||
d.buy.id = buyId;
|
||||
d.buy.catalogEntryId = catalogEntryId;
|
||||
d.buy.artifactId = entry.artifactId;
|
||||
d.buy.title = entry.title;
|
||||
d.buy.productType = entry.productType;
|
||||
d.buy.imageUrl = (entry as any).imageUrl || null;
|
||||
d.buy.description = description || '';
|
||||
d.buy.status = 'OPEN';
|
||||
d.buy.totalPledged = 0;
|
||||
d.buy.createdBy = claims.sub || null;
|
||||
d.buy.createdAt = now;
|
||||
d.buy.updatedAt = now;
|
||||
d.buy.closesAt = closesInDays ? now + closesInDays * 86400000 : now + 30 * 86400000;
|
||||
for (const tier of tiers) {
|
||||
d.buy.tiers.push({ min_qty: tier.min_qty, per_unit: tier.per_unit, currency: tier.currency || 'USD' });
|
||||
}
|
||||
});
|
||||
_syncServer!.setDoc(docId, doc);
|
||||
|
||||
const host = c.req.header('host') || 'rspace.online';
|
||||
const shareUrl = `https://${host}/${space}/rcart/buy/${buyId}`;
|
||||
return c.json({ id: buyId, shareUrl }, 201);
|
||||
});
|
||||
|
||||
// GET /api/group-buys — List open group buys for a space
|
||||
routes.get("/api/group-buys", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const prefix = `${space}:cart:group-buys:`;
|
||||
const buys: any[] = [];
|
||||
for (const id of _syncServer!.listDocs()) {
|
||||
if (id.startsWith(prefix)) {
|
||||
const doc = _syncServer!.getDoc<GroupBuyDoc>(id);
|
||||
if (doc?.buy) {
|
||||
buys.push({
|
||||
id: doc.buy.id,
|
||||
title: doc.buy.title,
|
||||
productType: doc.buy.productType,
|
||||
imageUrl: doc.buy.imageUrl,
|
||||
status: doc.buy.status,
|
||||
totalPledged: doc.buy.totalPledged,
|
||||
tiers: doc.buy.tiers,
|
||||
closesAt: new Date(doc.buy.closesAt).toISOString(),
|
||||
createdAt: new Date(doc.buy.createdAt).toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return c.json({ groupBuys: buys });
|
||||
});
|
||||
|
||||
// GET /api/group-buys/:id — Get group buy details
|
||||
routes.get("/api/group-buys/:id", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const buyId = c.req.param("id");
|
||||
const docId = groupBuyDocId(space, buyId);
|
||||
const doc = _syncServer!.getDoc<GroupBuyDoc>(docId);
|
||||
if (!doc) return c.json({ error: "Group buy not found" }, 404);
|
||||
|
||||
const pledges = Object.values(doc.pledges || {}).map(p => ({
|
||||
id: p.id,
|
||||
displayName: p.displayName,
|
||||
quantity: p.quantity,
|
||||
pledgedAt: new Date(p.pledgedAt).toISOString(),
|
||||
}));
|
||||
|
||||
const currentTier = [...doc.buy.tiers].reverse().find(t => doc.buy.totalPledged >= t.min_qty) || doc.buy.tiers[0] || null;
|
||||
|
||||
return c.json({
|
||||
id: doc.buy.id,
|
||||
catalogEntryId: doc.buy.catalogEntryId,
|
||||
title: doc.buy.title,
|
||||
productType: doc.buy.productType,
|
||||
imageUrl: doc.buy.imageUrl,
|
||||
description: doc.buy.description,
|
||||
tiers: doc.buy.tiers,
|
||||
status: doc.buy.status,
|
||||
totalPledged: doc.buy.totalPledged,
|
||||
currentTier,
|
||||
pledges,
|
||||
closesAt: new Date(doc.buy.closesAt).toISOString(),
|
||||
createdAt: new Date(doc.buy.createdAt).toISOString(),
|
||||
updatedAt: new Date(doc.buy.updatedAt).toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/group-buys/:id/pledge — Add a pledge
|
||||
routes.post("/api/group-buys/:id/pledge", async (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const buyId = c.req.param("id");
|
||||
const docId = groupBuyDocId(space, buyId);
|
||||
const doc = _syncServer!.getDoc<GroupBuyDoc>(docId);
|
||||
if (!doc) return c.json({ error: "Group buy not found" }, 404);
|
||||
if (doc.buy.status !== 'OPEN') return c.json({ error: "Group buy is no longer open" }, 400);
|
||||
|
||||
const body = await c.req.json();
|
||||
const { quantity, displayName } = body;
|
||||
if (!quantity || quantity < 1) return c.json({ error: "quantity must be >= 1" }, 400);
|
||||
|
||||
let buyerId: string | null = null;
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
if (token) {
|
||||
try { const claims = await verifyEncryptIDToken(token); buyerId = claims.sub || null; } catch { /* public pledge */ }
|
||||
}
|
||||
|
||||
const pledgeId = crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
|
||||
_syncServer!.changeDoc<GroupBuyDoc>(docId, 'add pledge', (d) => {
|
||||
d.pledges[pledgeId] = {
|
||||
id: pledgeId,
|
||||
buyerId,
|
||||
displayName: displayName || 'Anonymous',
|
||||
quantity,
|
||||
pledgedAt: now,
|
||||
};
|
||||
d.buy.totalPledged += quantity;
|
||||
d.buy.updatedAt = now;
|
||||
});
|
||||
|
||||
const updated = _syncServer!.getDoc<GroupBuyDoc>(docId)!;
|
||||
const currentTier = [...updated.buy.tiers].reverse().find(t => updated.buy.totalPledged >= t.min_qty) || updated.buy.tiers[0] || null;
|
||||
|
||||
return c.json({
|
||||
pledgeId,
|
||||
totalPledged: updated.buy.totalPledged,
|
||||
currentTier,
|
||||
}, 201);
|
||||
});
|
||||
|
||||
// ── Page route: group buy page ──
|
||||
routes.get("/buy/:id", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const buyId = c.req.param("id");
|
||||
return c.html(renderShell({
|
||||
title: `Group Buy | rCart`,
|
||||
moduleId: "rcart",
|
||||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `<folk-group-buy-page space="${space}" buy-id="${buyId}"></folk-group-buy-page>`,
|
||||
scripts: `<script type="module" src="/modules/rcart/folk-group-buy-page.js"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/rcart/cart.css">`,
|
||||
}));
|
||||
});
|
||||
|
||||
// ── Page route: request payment (self-service QR generator) ──
|
||||
routes.get("/request", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
|
|
|
|||
|
|
@ -361,6 +361,83 @@ export const paymentRequestSchema: DocSchema<PaymentRequestDoc> = {
|
|||
}),
|
||||
};
|
||||
|
||||
// ── Group Buy types ──
|
||||
|
||||
export type GroupBuyStatus = 'OPEN' | 'LOCKED' | 'ORDERED' | 'CANCELLED';
|
||||
|
||||
export interface GroupBuyTier {
|
||||
min_qty: number;
|
||||
per_unit: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface GroupBuyPledge {
|
||||
id: string;
|
||||
buyerId: string | null;
|
||||
displayName: string;
|
||||
quantity: number;
|
||||
pledgedAt: number;
|
||||
}
|
||||
|
||||
export interface GroupBuyDoc {
|
||||
meta: {
|
||||
module: string;
|
||||
collection: string;
|
||||
version: number;
|
||||
spaceSlug: string;
|
||||
createdAt: number;
|
||||
};
|
||||
buy: {
|
||||
id: string;
|
||||
catalogEntryId: string;
|
||||
artifactId: string;
|
||||
title: string;
|
||||
productType: string | null;
|
||||
imageUrl: string | null;
|
||||
description: string;
|
||||
tiers: GroupBuyTier[];
|
||||
status: GroupBuyStatus;
|
||||
totalPledged: number;
|
||||
createdBy: string | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
closesAt: number;
|
||||
};
|
||||
pledges: Record<string, GroupBuyPledge>;
|
||||
}
|
||||
|
||||
export const groupBuySchema: DocSchema<GroupBuyDoc> = {
|
||||
module: 'cart',
|
||||
collection: 'group-buys',
|
||||
version: 1,
|
||||
init: (): GroupBuyDoc => ({
|
||||
meta: {
|
||||
module: 'cart',
|
||||
collection: 'group-buys',
|
||||
version: 1,
|
||||
spaceSlug: '',
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
buy: {
|
||||
id: '',
|
||||
catalogEntryId: '',
|
||||
artifactId: '',
|
||||
title: '',
|
||||
productType: null,
|
||||
imageUrl: null,
|
||||
description: '',
|
||||
tiers: [],
|
||||
status: 'OPEN',
|
||||
totalPledged: 0,
|
||||
createdBy: null,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
closesAt: 0,
|
||||
},
|
||||
pledges: {},
|
||||
}),
|
||||
};
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
export function catalogDocId(space: string) {
|
||||
|
|
@ -382,3 +459,7 @@ export function shoppingCartIndexDocId(space: string) {
|
|||
export function paymentRequestDocId(space: string, paymentId: string) {
|
||||
return `${space}:cart:payments:${paymentId}` as const;
|
||||
}
|
||||
|
||||
export function groupBuyDocId(space: string, buyId: string) {
|
||||
return `${space}:cart:group-buys:${buyId}` as const;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue