${entry.image_url ? `
` : ""}
${this.esc(entry.title || "Untitled")}
@@ -680,6 +741,201 @@ class FolkCartShop extends HTMLElement {
`;
}
+ // ── 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 `
Item not found.
`;
+
+ const opts = this.buildDemoFulfillOptions(item);
+ const unitPrice = this.getCurrentTierPrice();
+ const totalPrice = (unitPrice * this.detailQuantity).toFixed(2);
+ const basePrice = opts.tiers[0].per_unit;
+
+ return `
+
+
+
+
+
+ ${item.image_url
+ ? `
})
`
+ : `
No image
`}
+
+
+
${this.esc(item.title)}
+ ${item.product_type ? `
${this.esc(item.product_type)}` : ''}
+ ${(item.tags || []).map((t: string) => `
${this.esc(t)}`).join(' ')}
+ ${item.description ? `
${this.esc(item.description)}
` : ''}
+
+
+
+ ${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 `
+ ${t.min_qty}+
+ $${t.per_unit.toFixed(2)}/ea
+ ${savings > 0 ? `-${savings}%` : ``}
+
`;
+ }).join('')}
+
+
+
+
+
+
+
+
+
+
+
+
+ Total
+ $${totalPrice} ${item.currency || 'USD'}
+
+
+
+
${this.esc(opts.provider.name)}
+
${this.esc(opts.provider.city)} • ${this.esc(opts.provider.turnaround)}
+
+
+
+
+
+
+
+
`;
+ }
+
+ // ── Order queue drawer ──
+
+ private renderOrderQueue(): string {
+ const total = this.orderQueue.reduce((s: number, i: any) => s + i.unitPrice * i.quantity, 0);
+ return `
+
+
+ ${this.orderQueue.map((item: any, idx: number) => `
+
+
+ ${this.esc(item.title)}
+ Qty: ${item.quantity} • $${(item.unitPrice * item.quantity).toFixed(2)}
+
+
+
+ `).join('')}
+
+ Total
+ $${total.toFixed(2)}
+
+
`;
+ }
+
+ 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; }
}
`;
}
diff --git a/modules/rcart/mod.ts b/modules/rcart/mod.ts
index 07a1f47..dff08b8 100644
--- a/modules/rcart/mod.ts
+++ b/modules/rcart/mod.ts
@@ -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
(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(), '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(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(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(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(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(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: ``,
+ scripts: ``,
+ styles: ``,
+ }));
+});
+
// ── Page route: request payment (self-service QR generator) ──
routes.get("/request", (c) => {
const space = c.req.param("space") || "demo";
diff --git a/modules/rcart/schemas.ts b/modules/rcart/schemas.ts
index f46a01e..f20bcfe 100644
--- a/modules/rcart/schemas.ts
+++ b/modules/rcart/schemas.ts
@@ -361,6 +361,83 @@ export const paymentRequestSchema: DocSchema = {
}),
};
+// ── 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;
+}
+
+export const groupBuySchema: DocSchema = {
+ 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;
+}