diff --git a/backlog/tasks/task-123 - rSwag-Feature-Parity-—-Full-8-Phase-Implementation.md b/backlog/tasks/task-123 - rSwag-Feature-Parity-—-Full-8-Phase-Implementation.md new file mode 100644 index 0000000..799e0ea --- /dev/null +++ b/backlog/tasks/task-123 - rSwag-Feature-Parity-—-Full-8-Phase-Implementation.md @@ -0,0 +1,91 @@ +--- +id: TASK-123 +title: rSwag Feature Parity — Full 8-Phase Implementation +status: Done +assignee: [] +created_date: '2026-03-21 06:21' +updated_date: '2026-03-21 06:21' +labels: + - rswag + - feature-parity + - pod + - dithering + - ai-generation +dependencies: [] +references: + - modules/rswag/mod.ts + - modules/rswag/pod/printful.ts + - modules/rswag/pod/prodigi.ts + - modules/rswag/dither.ts + - modules/rswag/mockup.ts + - modules/rswag/fulfillment.ts + - modules/rswag/components/folk-swag-designer.ts + - modules/rswag/components/folk-revenue-sankey.ts +priority: high +--- + +## Description + + +Brought the rspace.online/rswag module to feature parity with the standalone rswag.online (Next.js + FastAPI + PostgreSQL) application. rSwag now owns design tools, product catalog, mockups, POD clients, dithering, and AI generation. rCart owns cart/checkout/payments/order lifecycle. A bridge connects them via catalog ingest and fulfillment routing. + +## What was built + +### Phase 1: POD Provider Clients +- `modules/rswag/pod/types.ts` — shared POD TypeScript interfaces +- `modules/rswag/pod/printful.ts` — Printful v2 API client (catalog variants, mockup generation, order creation, sandbox mode) +- `modules/rswag/pod/prodigi.ts` — Prodigi v4 API client (orders, quotes, status) + +### Phase 2: Enhanced Image Processing +- `modules/rswag/dither.ts` — 11 dithering algorithms (8 error diffusion + 3 ordered), median-cut quantization, screen-print color separations +- `modules/rswag/mockup.ts` — Sharp-based mockup compositor with SVG templates + Printful API fallback + +### Phase 3: AI Design Generation +- Gemini-powered design generation (gemini-2.5-flash-image) +- User artwork upload (PNG/JPEG/WebP, min 500x500, max 10MB) +- Design lifecycle: draft → active → paused → removed + +### Phase 4: Product Catalog & Mockup Routes +- ~15 new API routes for designs, mockups, dithering, storefront, fulfillment +- Filesystem-based design storage with in-memory index +- 24hr cache for images, LRU caches for dithered/mockup results + +### Phase 5: Fulfillment Bridge +- `modules/rswag/fulfillment.ts` — order routing to Printful/Prodigi +- Webhook parsers for shipment tracking updates +- Tracking info lookup + +### Phase 6: Frontend Design Tools UI +- 4-tab layout in folk-swag-designer (Browse, Create, HitherDither, Orders) +- Browse: product grid with search/filter/add-to-cart +- Create: AI Generate, Upload, My Designs sub-modes +- HitherDither: algorithm picker, color count, live preview, screen-print separations +- Orders: fulfillment status and tracking + +### Phase 7: Revenue Sankey & Enhanced Landing +- `folk-revenue-sankey` web component with animated SVG flow + draggable sliders +- Updated landing page with Sankey embed and new feature descriptions + +### Phase 8: Admin & Polish +- Admin routes: design sync, product override, analytics summary +- Schema migration v1→v2 for existing designs +- Extended products.ts with POD SKUs and StorefrontProduct type + + +## Acceptance Criteria + +- [x] #1 POD clients (Printful v2, Prodigi v4) implemented with sandbox mode +- [x] #2 11 dithering algorithms with screen-print color separations +- [x] #3 AI design generation via Gemini + user artwork upload +- [x] #4 ~15 new API routes for designs, mockups, dithering, storefront, fulfillment +- [x] #5 Fulfillment bridge routes orders to correct POD provider +- [x] #6 4-tab frontend UI (Browse, Create, HitherDither, Orders) +- [x] #7 Interactive revenue Sankey on landing page +- [x] #8 TypeScript compiles cleanly (zero errors) + + +## Final Summary + + +All 8 phases implemented in a single session. Created 7 new files (pod/types.ts, pod/printful.ts, pod/prodigi.ts, dither.ts, mockup.ts, fulfillment.ts, folk-revenue-sankey.ts) and modified 6 existing files (schemas.ts, products.ts, mod.ts, folk-swag-designer.ts, landing.ts, swag.css). TypeScript compiles with zero errors. Ported Python reference code (printful_client.py, prodigi_client.py, dither_service.py, design_generator.py) to TypeScript. + diff --git a/modules/crowdsurf/components/crowdsurf.css b/modules/crowdsurf/components/crowdsurf.css deleted file mode 100644 index c2f073a..0000000 --- a/modules/crowdsurf/components/crowdsurf.css +++ /dev/null @@ -1,5 +0,0 @@ -/* CrowdSurf module layout */ -main { - min-height: calc(100vh - 56px); - padding: 0; -} diff --git a/modules/crowdsurf/components/folk-crowdsurf-dashboard.ts b/modules/crowdsurf/components/folk-crowdsurf-dashboard.ts deleted file mode 100644 index b98ac63..0000000 --- a/modules/crowdsurf/components/folk-crowdsurf-dashboard.ts +++ /dev/null @@ -1,781 +0,0 @@ -/** - * — Tinder-style swipe UI for community activity proposals. - * - * Three views: Discover (swipe cards), Create (new prompt form), Profile (stats). - * Multiplayer: uses CrowdSurfLocalFirstClient for real-time sync via Automerge. - */ - -import { CrowdSurfLocalFirstClient } from '../local-first-client'; -import type { CrowdSurfDoc, CrowdSurfPrompt, Contribution } from '../schemas'; -import { getDecayProgress, getTimeRemaining, getRightSwipeCount, isReadyToTrigger, getUrgency, parseContributions } from '../schemas'; - -// ── Auth helpers ── -function getSession(): { accessToken: string; claims: { sub: string; did?: string; username?: string } } | null { - try { - const raw = localStorage.getItem('encryptid_session'); - if (!raw) return null; - const s = JSON.parse(raw); - return s?.accessToken ? s : null; - } catch { return null; } -} -function getMyDid(): string | null { - const s = getSession(); - if (!s) return null; - return (s.claims as any).did || s.claims.sub; -} - -type ViewTab = 'discover' | 'create' | 'profile'; - -class FolkCrowdSurfDashboard extends HTMLElement { - private shadow: ShadowRoot; - private space: string; - - // State - private activeTab: ViewTab = 'discover'; - private loading = true; - private prompts: CrowdSurfPrompt[] = []; - private currentPromptIndex = 0; - - // Swipe state - private isDragging = false; - private startX = 0; - private currentX = 0; - private isAnimating = false; - - // Create form state - private contributionSuggestions: string[] = []; - - // Profile stats - private stats = { joined: 0, created: 0, triggered: 0 }; - - // Multiplayer - private lfClient: CrowdSurfLocalFirstClient | null = null; - private _lfcUnsub: (() => void) | null = null; - - // Expiry timer - private _expiryTimer: number | null = null; - - constructor() { - super(); - this.shadow = this.attachShadow({ mode: 'open' }); - this.space = this.getAttribute('space') || 'demo'; - } - - connectedCallback() { - if (this.space === 'demo') { - this.loadDemoData(); - } else { - this.initMultiplayer(); - } - } - - disconnectedCallback() { - this._lfcUnsub?.(); - this._lfcUnsub = null; - this.lfClient?.disconnect(); - if (this._expiryTimer !== null) clearInterval(this._expiryTimer); - } - - // ── Multiplayer init ── - - private async initMultiplayer() { - this.loading = true; - this.render(); - - try { - this.lfClient = new CrowdSurfLocalFirstClient(this.space); - await this.lfClient.init(); - await this.lfClient.subscribe(); - - this._lfcUnsub = this.lfClient.onChange((doc) => { - this.extractPrompts(doc); - this.render(); - this.bindEvents(); - }); - - const doc = this.lfClient.getDoc(); - if (doc) this.extractPrompts(doc); - } catch (err) { - console.warn('[CrowdSurf] Local-first init failed:', err); - } - - this.loading = false; - this.render(); - this.bindEvents(); - - // Check expiry every 30s - this._expiryTimer = window.setInterval(() => this.checkExpiry(), 30000); - } - - private extractPrompts(doc: CrowdSurfDoc) { - const myDid = getMyDid(); - const all = doc.prompts ? Object.values(doc.prompts) : []; - - // Sort: active first (by creation, newest first), then triggered, then expired - this.prompts = all.sort((a, b) => { - if (a.expired !== b.expired) return a.expired ? 1 : -1; - if (a.triggered !== b.triggered) return a.triggered ? 1 : -1; - return b.createdAt - a.createdAt; - }); - - // Compute profile stats - if (myDid) { - this.stats.created = all.filter(p => p.createdBy === myDid).length; - this.stats.joined = all.filter(p => p.swipes[myDid]?.direction === 'right').length; - this.stats.triggered = all.filter(p => p.triggered).length; - } - } - - private checkExpiry() { - if (!this.lfClient) return; - const doc = this.lfClient.getDoc(); - if (!doc?.prompts) return; - for (const prompt of Object.values(doc.prompts)) { - if (!prompt.expired && getDecayProgress(prompt) >= 1) { - this.lfClient.markExpired(prompt.id); - } - } - } - - // ── Demo data ── - - private loadDemoData() { - const now = Date.now(); - this.prompts = [ - { - id: 'demo-1', text: 'Community Garden Planting Day', location: 'Tempelhof Field, Berlin', - threshold: 5, duration: 4, activityDuration: '3 hours', createdAt: now - 3600000, - createdBy: null, triggered: false, expired: false, - swipes: { 'alice': { direction: 'right', timestamp: now - 2000000, contribution: { bringing: ['seedlings', 'gardening gloves'], needed: ['watering cans'], tags: ['food'], value: 10 } }, 'bob': { direction: 'right', timestamp: now - 1800000 }, 'carol': { direction: 'right', timestamp: now - 1500000 } }, - }, - { - id: 'demo-2', text: 'Open Mic & Jam Session', location: 'Klunkerkranich rooftop', - threshold: 8, duration: 6, activityDuration: '2 hours', createdAt: now - 7200000, - createdBy: null, triggered: false, expired: false, - swipes: { 'dave': { direction: 'right', timestamp: now - 5000000, contribution: { bringing: ['guitar', 'amp'], needed: ['microphone'], tags: ['music'], value: 15 } }, 'eve': { direction: 'right', timestamp: now - 4000000 }, 'frank': { direction: 'right', timestamp: now - 3500000 }, 'grace': { direction: 'right', timestamp: now - 3000000 }, 'hank': { direction: 'right', timestamp: now - 2500000 }, 'iris': { direction: 'right', timestamp: now - 2000000 } }, - }, - { - id: 'demo-3', text: 'Repair Cafe — Fix Your Stuff!', location: 'Maker Space, Kreuzberg', - threshold: 3, duration: 2, activityDuration: '4 hours', createdAt: now - 600000, - createdBy: null, triggered: false, expired: false, - swipes: { 'jack': { direction: 'right', timestamp: now - 400000, contribution: { bringing: ['soldering iron', 'electronics skills'], needed: ['broken gadgets'], tags: ['tech'], value: 15 } } }, - }, - { - id: 'demo-4', text: 'Sunrise Yoga by the Canal', location: 'Landwehr Canal', - threshold: 4, duration: 8, activityDuration: '1 hour', createdAt: now - 1000000, - createdBy: null, triggered: false, expired: false, - swipes: {}, - }, - ]; - this.loading = false; - this.render(); - this.bindEvents(); - } - - // ── Swipe mechanics ── - - private getActivePrompts(): CrowdSurfPrompt[] { - const myDid = getMyDid(); - return this.prompts.filter(p => - !p.expired && !p.triggered && (this.space === 'demo' || !myDid || !p.swipes[myDid]) - ); - } - - private getCurrentPrompt(): CrowdSurfPrompt | null { - const active = this.getActivePrompts(); - if (this.currentPromptIndex >= active.length) this.currentPromptIndex = 0; - return active[this.currentPromptIndex] ?? null; - } - - private handleSwipeEnd(diffX: number) { - if (Math.abs(diffX) < 100) return; // Below threshold - const prompt = this.getCurrentPrompt(); - if (!prompt) return; - - if (diffX > 0) { - this.performSwipe(prompt, 'right'); - } else { - this.performSwipe(prompt, 'left'); - } - } - - private performSwipe(prompt: CrowdSurfPrompt, direction: 'right' | 'left') { - const myDid = getMyDid(); - - if (this.space === 'demo') { - // Demo mode: just advance - if (direction === 'right') { - prompt.swipes['demo-user'] = { direction: 'right', timestamp: Date.now() }; - this.stats.joined++; - if (getRightSwipeCount(prompt) >= prompt.threshold) { - prompt.triggered = true; - this.stats.triggered++; - } - } - this.currentPromptIndex++; - this.isAnimating = true; - setTimeout(() => { - this.isAnimating = false; - this.render(); - this.bindEvents(); - }, 300); - return; - } - - if (!myDid || !this.lfClient) return; - - // TODO: show contribution modal on right-swipe before committing - this.lfClient.swipe(prompt.id, myDid, direction); - this.currentPromptIndex++; - this.isAnimating = true; - setTimeout(() => { - this.isAnimating = false; - this.render(); - this.bindEvents(); - }, 300); - } - - // ── Create prompt ── - - private handleCreate() { - const getText = (id: string) => (this.shadow.getElementById(id) as HTMLInputElement | HTMLTextAreaElement)?.value?.trim() ?? ''; - const getNum = (id: string) => parseInt((this.shadow.getElementById(id) as HTMLInputElement)?.value ?? '0', 10); - - const text = getText('cs-text'); - const location = getText('cs-location'); - const threshold = getNum('cs-threshold') || 3; - const duration = getNum('cs-duration') || 4; - const activityDuration = getText('cs-activity-duration') || '1 hour'; - const bringing = getText('cs-bringing'); - const needed = getText('cs-needed'); - - if (!text || !location) return; - - const prompt: CrowdSurfPrompt = { - id: crypto.randomUUID(), - text, - location, - threshold, - duration, - activityDuration, - createdAt: Date.now(), - createdBy: getMyDid(), - triggered: false, - expired: false, - swipes: {}, - }; - - // Add creator's contribution as a right-swipe - const myDid = getMyDid(); - if (myDid && (bringing || needed)) { - const contribution = parseContributions(bringing, needed); - prompt.swipes[myDid] = { - direction: 'right', - timestamp: Date.now(), - contribution, - }; - } - - if (this.space === 'demo') { - this.prompts.unshift(prompt); - this.stats.created++; - } else if (this.lfClient) { - this.lfClient.createPrompt(prompt); - } - - this.activeTab = 'discover'; - this.render(); - this.bindEvents(); - } - - // ── Render ── - - private render() { - const isLive = this.lfClient?.isConnected ?? false; - - this.shadow.innerHTML = ` - - -
- ${this.loading ? '
Loading...
' : this.renderActiveView(isLive)} - - - -
`; - } - - private renderActiveView(isLive: boolean): string { - switch (this.activeTab) { - case 'discover': return this.renderDiscover(isLive); - case 'create': return this.renderCreateForm(); - case 'profile': return this.renderProfile(); - } - } - - // ── Discover view (swipe cards) ── - - private renderDiscover(isLive: boolean): string { - const prompt = this.getCurrentPrompt(); - const activeCount = this.getActivePrompts().length; - const triggeredPrompts = this.prompts.filter(p => p.triggered); - - return ` -
- CrowdSurf - ${isLive ? 'LIVE' : ''} - ${this.space === 'demo' ? 'DEMO' : ''} -
- -
- ${prompt ? this.renderCard(prompt, activeCount) : this.renderNoCards()} - - ${triggeredPrompts.length > 0 ? ` - -
- ${triggeredPrompts.map(p => ` -
- 🚀 -
-
${this.esc(p.text)}
-
📍 ${this.esc(p.location)} · ${getRightSwipeCount(p)} people
-
-
- `).join('')} -
- ` : ''} -
`; - } - - private renderCard(prompt: CrowdSurfPrompt, totalActive: number): string { - const rightSwipes = getRightSwipeCount(prompt); - const progressPct = Math.min((rightSwipes / prompt.threshold) * 100, 100); - const urgency = getUrgency(prompt); - const timeLeft = getTimeRemaining(prompt); - - // Collect contributions - const allContribs = Object.values(prompt.swipes) - .filter(s => s.direction === 'right' && s.contribution) - .map(s => s.contribution!); - - const bringingAll = allContribs.flatMap(c => c.bringing); - const neededAll = allContribs.flatMap(c => c.needed); - - return ` -
-
-
✗ Pass
-
✓ Join
- -
-
${this.esc(prompt.text)}
- -
📍 ${this.esc(prompt.location)}
- ${prompt.activityDuration ? `
⏱️ ${this.esc(prompt.activityDuration)}
` : ''} - - ${bringingAll.length > 0 ? ` -
-
🎒 People are bringing:
-
${bringingAll.map(b => `${this.esc(b)}`).join('')}
-
- ` : ''} - ${neededAll.length > 0 ? ` -
-
✨ Still needed:
-
${neededAll.map(n => `${this.esc(n)}`).join('')}
-
- ` : ''} - - - - ${isReadyToTrigger(prompt) ? '
🚀 Group ready! Activity happening!
' : ''} -
-
-
${this.currentPromptIndex + 1} / ${totalActive}
-
- -
- - -
`; - } - - private renderNoCards(): string { - return ` -
-
🏄
-

No activities to discover right now.

-

Create one and get the wave started!

- -
`; - } - - // ── Create form ── - - private renderCreateForm(): string { - return ` -
- New Activity -
-
- - - - - - -
-
- - -
-
- - -
-
- - - - -
- - - - - - - - -
`; - } - - // ── Profile view ── - - private renderProfile(): string { - const allTriggered = this.prompts.filter(p => p.triggered); - const totalParticipants = this.prompts.reduce((sum, p) => sum + getRightSwipeCount(p), 0); - - return ` -
- Profile -
-
-
-
-
${this.stats.joined}
-
Joined
-
-
-
${this.stats.created}
-
Created
-
-
-
${this.stats.triggered}
-
Triggered
-
-
- - -
-
Active prompts${this.prompts.filter(p => !p.expired && !p.triggered).length}
-
Total triggered${allTriggered.length}
-
Total participants${totalParticipants}
-
-
`; - } - - // ── Event binding ── - - private bindEvents() { - // Tab navigation - this.shadow.querySelectorAll('.cs-nav-btn').forEach(btn => { - btn.addEventListener('click', () => { - const tab = btn.dataset.tab as ViewTab; - if (tab && tab !== this.activeTab) { - this.activeTab = tab; - this.render(); - this.bindEvents(); - } - }); - }); - - // Swipe gesture on card - const card = this.shadow.getElementById('cs-current-card'); - if (card) this.setupSwipeGestures(card); - - // Swipe buttons - this.shadow.querySelector('[data-action="swipe-left"]')?.addEventListener('click', () => { - const prompt = this.getCurrentPrompt(); - if (prompt) this.performSwipe(prompt, 'left'); - }); - this.shadow.querySelector('[data-action="swipe-right"]')?.addEventListener('click', () => { - const prompt = this.getCurrentPrompt(); - if (prompt) this.performSwipe(prompt, 'right'); - }); - - // Go to create tab - this.shadow.querySelector('[data-action="go-create"]')?.addEventListener('click', () => { - this.activeTab = 'create'; - this.render(); - this.bindEvents(); - }); - - // Create form submit - this.shadow.querySelector('[data-action="create-prompt"]')?.addEventListener('click', () => this.handleCreate()); - } - - private setupSwipeGestures(card: HTMLElement) { - const handleStart = (clientX: number) => { - if (this.isAnimating) return; - this.startX = clientX; - this.currentX = clientX; - this.isDragging = true; - }; - - const handleMove = (clientX: number) => { - if (!this.isDragging || this.isAnimating) return; - this.currentX = clientX; - const diffX = this.currentX - this.startX; - const rotation = diffX * 0.1; - card.style.transform = `translateX(${diffX}px) rotate(${rotation}deg)`; - - const leftInd = card.querySelector('.cs-swipe-left') as HTMLElement; - const rightInd = card.querySelector('.cs-swipe-right') as HTMLElement; - - if (diffX < -50) { - leftInd?.classList.add('show'); - rightInd?.classList.remove('show'); - } else if (diffX > 50) { - rightInd?.classList.add('show'); - leftInd?.classList.remove('show'); - } else { - leftInd?.classList.remove('show'); - rightInd?.classList.remove('show'); - } - }; - - const handleEnd = () => { - if (!this.isDragging || this.isAnimating) return; - this.isDragging = false; - const diffX = this.currentX - this.startX; - - card.querySelector('.cs-swipe-left')?.classList.remove('show'); - card.querySelector('.cs-swipe-right')?.classList.remove('show'); - - if (Math.abs(diffX) > 100) { - // Animate out - const direction = diffX > 0 ? 1 : -1; - card.style.transition = 'transform 0.3s ease-out, opacity 0.3s ease-out'; - card.style.transform = `translateX(${direction * 500}px) rotate(${direction * 30}deg)`; - card.style.opacity = '0'; - this.handleSwipeEnd(diffX); - } else { - card.style.transition = 'transform 0.2s ease-out'; - card.style.transform = ''; - setTimeout(() => { card.style.transition = ''; }, 200); - } - }; - - // Pointer events (unified touch + mouse) - card.addEventListener('pointerdown', (e: PointerEvent) => { - if (e.button !== 0) return; - card.setPointerCapture(e.pointerId); - card.style.touchAction = 'none'; - handleStart(e.clientX); - }); - card.addEventListener('pointermove', (e: PointerEvent) => { - e.preventDefault(); - handleMove(e.clientX); - }); - card.addEventListener('pointerup', () => handleEnd()); - card.addEventListener('pointercancel', () => { - this.isDragging = false; - card.style.transform = ''; - card.style.touchAction = ''; - }); - } - - // ── Styles ── - - private getStyles(): string { - return ` - :host { display: block; height: 100%; -webkit-tap-highlight-color: transparent; } - button, a, input, select, textarea, [role="button"] { touch-action: manipulation; } - * { box-sizing: border-box; } - - .cs-app { - display: flex; flex-direction: column; height: 100%; - min-height: calc(100vh - 56px); - background: var(--rs-bg-page); - color: var(--rs-text-primary); - } - - .cs-loading { text-align: center; padding: 4rem; color: var(--rs-text-muted); flex: 1; display: flex; align-items: center; justify-content: center; } - - /* Header */ - .cs-header { display: flex; align-items: center; gap: 8px; padding: 1rem 1.25rem 0.5rem; } - .cs-title { font-size: 1.1rem; font-weight: 700; flex: 1; } - .cs-live { font-size: 0.7rem; padding: 2px 8px; border-radius: 999px; background: rgba(34,197,94,0.15); color: #22c55e; font-weight: 500; display: flex; align-items: center; gap: 3px; } - .cs-live-dot { width: 6px; height: 6px; border-radius: 50%; background: #22c55e; animation: cs-pulse 2s infinite; } - @keyframes cs-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } } - .cs-demo-badge { font-size: 0.7rem; padding: 2px 8px; border-radius: 999px; background: var(--rs-primary); color: #fff; font-weight: 500; } - - /* Discover */ - .cs-discover { flex: 1; display: flex; flex-direction: column; padding: 0.5rem 1.25rem 1rem; overflow-y: auto; } - - /* Card stack */ - .cs-card-stack { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 320px; position: relative; } - .cs-card { - width: 100%; max-width: 380px; - background: linear-gradient(135deg, rgba(102,126,234,0.15), rgba(118,75,162,0.15)); - border: 1px solid var(--rs-border); - border-radius: 16px; - padding: 1.5rem; - position: relative; - cursor: grab; - user-select: none; - transition: box-shadow 0.2s; - } - .cs-card:active { cursor: grabbing; } - .cs-card:hover { box-shadow: 0 8px 32px rgba(0,0,0,0.2); } - .cs-card-count { font-size: 0.75rem; color: var(--rs-text-muted); margin-top: 0.75rem; text-align: center; } - - /* Swipe indicators */ - .cs-swipe-indicator { - position: absolute; top: 1.5rem; - font-size: 1.25rem; font-weight: 800; - padding: 6px 16px; border-radius: 8px; - opacity: 0; transition: opacity 0.15s; - pointer-events: none; z-index: 2; - } - .cs-swipe-left { left: 1rem; color: #ef4444; border: 2px solid #ef4444; } - .cs-swipe-right { right: 1rem; color: #22c55e; border: 2px solid #22c55e; } - .cs-swipe-indicator.show { opacity: 1; } - - /* Card body */ - .cs-card-body { position: relative; z-index: 1; } - .cs-card-text { font-size: 1.2rem; font-weight: 700; margin-bottom: 1rem; line-height: 1.4; } - .cs-card-location { font-size: 0.9rem; margin-bottom: 0.5rem; opacity: 0.85; } - .cs-card-duration { font-size: 0.85rem; margin-bottom: 1rem; opacity: 0.7; background: rgba(255,255,255,0.08); display: inline-block; padding: 4px 10px; border-radius: 8px; } - - /* Contributions */ - .cs-contrib-section { margin-bottom: 0.75rem; } - .cs-contrib-label { font-size: 0.78rem; color: var(--rs-text-secondary); margin-bottom: 4px; } - .cs-contrib-tags { display: flex; flex-wrap: wrap; gap: 4px; } - .cs-tag { font-size: 0.75rem; padding: 3px 8px; border-radius: 999px; background: rgba(94,234,212,0.12); color: #5eead4; } - .cs-tag.needed { background: rgba(251,191,36,0.12); color: #fbbf24; } - - /* Card footer */ - .cs-card-footer { margin-top: 0.75rem; } - .cs-pool { font-size: 0.85rem; font-weight: 600; margin-bottom: 6px; text-align: center; } - .cs-progress-bar { height: 6px; border-radius: 999px; background: rgba(255,255,255,0.1); overflow: hidden; position: relative; } - .cs-progress-fill { height: 100%; border-radius: 999px; background: linear-gradient(90deg, #667eea, #764ba2); transition: width 0.5s ease-out; } - .cs-urgency-pulse { position: absolute; inset: 0; border-radius: 999px; background: rgba(239,68,68,0.3); animation: cs-urgency 1s infinite; } - @keyframes cs-urgency { 0%,100% { opacity: 0; } 50% { opacity: 1; } } - .cs-time { font-size: 0.78rem; text-align: center; margin-top: 6px; color: var(--rs-text-secondary); } - .cs-time.urgent { color: #ef4444; animation: cs-blink 1s infinite; } - @keyframes cs-blink { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } } - .cs-trigger-msg { text-align: center; margin-top: 0.75rem; padding: 8px; border-radius: 8px; background: rgba(34,197,94,0.15); color: #22c55e; font-weight: 600; font-size: 0.9rem; } - - /* Swipe buttons */ - .cs-swipe-buttons { display: flex; justify-content: center; gap: 2rem; padding: 1rem 0; } - .cs-btn-skip, .cs-btn-join { - width: 56px; height: 56px; border-radius: 50%; border: 2px solid; - font-size: 1.5rem; cursor: pointer; display: flex; align-items: center; justify-content: center; - background: var(--rs-bg-surface); transition: all 0.15s; font-family: inherit; - } - .cs-btn-skip { border-color: #ef4444; color: #ef4444; } - .cs-btn-skip:hover { background: rgba(239,68,68,0.15); } - .cs-btn-join { border-color: #22c55e; color: #22c55e; } - .cs-btn-join:hover { background: rgba(34,197,94,0.15); } - - /* Triggered list */ - .cs-section-label { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--rs-text-muted); margin: 1.25rem 0 0.5rem; font-weight: 600; } - .cs-triggered-list { display: flex; flex-direction: column; gap: 6px; } - .cs-triggered-card { display: flex; align-items: center; gap: 10px; padding: 0.75rem; background: var(--rs-bg-surface); border: 1px solid rgba(34,197,94,0.2); border-radius: 10px; } - .cs-triggered-icon { font-size: 1.25rem; } - .cs-triggered-info { flex: 1; min-width: 0; } - .cs-triggered-title { font-weight: 600; font-size: 0.9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } - .cs-triggered-meta { font-size: 0.78rem; color: var(--rs-text-secondary); } - - /* Empty state */ - .cs-empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; color: var(--rs-text-muted); padding: 2rem; } - .cs-empty-icon { font-size: 3rem; margin-bottom: 1rem; } - .cs-empty p { margin: 0.25rem 0; font-size: 0.9rem; } - .cs-btn-create { margin-top: 1rem; padding: 0.6rem 1.5rem; border-radius: 10px; border: 1px solid var(--rs-primary); background: var(--rs-primary); color: #fff; font-size: 0.9rem; cursor: pointer; font-family: inherit; } - - /* Create form */ - .cs-form { flex: 1; padding: 0.5rem 1.25rem 2rem; overflow-y: auto; } - .cs-label { display: block; font-size: 0.8rem; font-weight: 600; color: var(--rs-text-secondary); margin: 1rem 0 0.35rem; } - .cs-label:first-child { margin-top: 0; } - .cs-input { width: 100%; padding: 0.6rem 0.75rem; border-radius: 8px; border: 1px solid var(--rs-border); background: var(--rs-bg-surface); color: var(--rs-text-primary); font-size: 0.9rem; font-family: inherit; } - .cs-input:focus { outline: none; border-color: var(--rs-primary); } - .cs-textarea { resize: vertical; } - .cs-form-row { display: flex; gap: 0.75rem; } - .cs-form-col { flex: 1; } - .cs-divider { border: none; border-top: 1px solid var(--rs-border); margin: 1.25rem 0; height: 0; } - .cs-btn-submit { width: 100%; padding: 0.75rem; border-radius: 10px; border: none; background: linear-gradient(135deg, #667eea, #764ba2); color: #fff; font-size: 1rem; font-weight: 600; cursor: pointer; margin-top: 1.5rem; font-family: inherit; } - .cs-btn-submit:hover { opacity: 0.9; } - - /* Profile */ - .cs-profile { flex: 1; padding: 0.5rem 1.25rem 2rem; overflow-y: auto; } - .cs-stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem; margin-bottom: 1.5rem; } - .cs-stat-card { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 12px; padding: 1rem; text-align: center; } - .cs-stat-num { font-size: 1.5rem; font-weight: 800; background: linear-gradient(135deg, #667eea, #764ba2); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } - .cs-stat-label { font-size: 0.75rem; color: var(--rs-text-secondary); margin-top: 2px; } - .cs-community-stats { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 10px; overflow: hidden; } - .cs-community-row { display: flex; justify-content: space-between; padding: 0.6rem 1rem; border-bottom: 1px solid var(--rs-border); font-size: 0.85rem; } - .cs-community-row:last-child { border-bottom: none; } - .cs-community-row span:last-child { font-weight: 600; } - - /* Bottom nav */ - .cs-nav { display: flex; border-top: 1px solid var(--rs-border); background: var(--rs-bg-surface); padding: 0.35rem 0 env(safe-area-inset-bottom, 0.35rem); flex-shrink: 0; } - .cs-nav-btn { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 0.4rem 0; border: none; background: none; color: var(--rs-text-muted); cursor: pointer; font-family: inherit; transition: color 0.15s; } - .cs-nav-btn:hover, .cs-nav-btn.active { color: var(--rs-text-primary); } - .cs-nav-btn.active .cs-nav-icon { transform: scale(1.15); } - .cs-nav-icon { font-size: 1.25rem; transition: transform 0.15s; } - .cs-nav-label { font-size: 0.65rem; font-weight: 500; } - - @media (max-width: 480px) { - .cs-card { padding: 1.25rem; } - .cs-card-text { font-size: 1.05rem; } - .cs-form { padding: 0.5rem 1rem 2rem; } - } - `; - } - - private esc(s: string): string { - const d = document.createElement('div'); - d.textContent = s; - return d.innerHTML; - } -} - -customElements.define('folk-crowdsurf-dashboard', FolkCrowdSurfDashboard); diff --git a/modules/crowdsurf/landing.ts b/modules/crowdsurf/landing.ts deleted file mode 100644 index df5f9ba..0000000 --- a/modules/crowdsurf/landing.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * CrowdSurf landing page — swipe to coordinate local activities. - */ -export function renderLanding(): string { - return ` - -
- Coordinate spontaneous activities -

What should your community
do today?

-

- Swipe to discover. Commit to join. When enough people are in, it happens. - No planning committees. No group chat chaos. Just action. -

- -
- - -
-
-

How Crowdsurfing works

-
-
-
1
-

Propose

-

Someone has an idea — community garden day, open mic, repair cafe. They post it with a threshold: “happens when 5 people are in.”

-
-
-
2
-

Swipe

-

Community members discover activities by swiping. Right to join, left to skip. Declare what you’re bringing — skills, gear, food.

-
-
-
3
-

Trigger

-

When enough people commit, the activity triggers. The group forms, contributions are matched, and it just… happens.

-
-
-
-
- - -
-
-

Built for real communities

-
-
-

Threshold triggers

-

Activities only happen when enough people commit. No more “who’s coming?” anxiety. The threshold is the RSVP.

-
-
-

Contribution matching

-

See what people are bringing and what’s still needed. Skills, equipment, food, space — the puzzle assembles itself.

-
-
-

Time urgency

-

Proposals expire. The countdown creates momentum. As the window closes, urgency rises and commitment accelerates.

-
-
-

Real-time sync

-

Powered by rSpace CRDT infrastructure. Every swipe syncs instantly across all participants. Offline-first, multiplayer by default.

-
-
-
-
- - -
-
-

What will your community crowdsurf?

-
-
-
🌱
-

Garden days

-

5 people + seedlings + shovels = community garden magic

-
-
-
🎸
-

Jam sessions

-

Musicians find each other. Instruments match up. Music emerges.

-
-
-
🔧
-

Repair cafes

-

Bring broken stuff, find fixers. Circular economy through coordination.

-
-
-
🍳
-

Community meals

-

Someone cooks, others bring ingredients. Potluck, self-organized.

-
-
-
🧘
-

Wellness

-

Yoga by the canal. Group meditation. Movement in the park.

-
-
-
💻
-

Hackathons

-

Coders + designers + a space + caffeine = build something together.

-
-
-
-
- - -
-
-

Ready to ride the wave?

-

Create a space for your community and start crowdsurfing.

- -
-
- -`; -} diff --git a/modules/crowdsurf/local-first-client.ts b/modules/crowdsurf/local-first-client.ts deleted file mode 100644 index 4d612e0..0000000 --- a/modules/crowdsurf/local-first-client.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * rCrowdSurf Local-First Client - * - * Wraps the shared local-first stack for collaborative activity proposals - * with swipe-based commitment, contributions, and threshold triggers. - */ - -import { DocumentManager } from '../../shared/local-first/document'; -import type { DocumentId } from '../../shared/local-first/document'; -import { EncryptedDocStore } from '../../shared/local-first/storage'; -import { DocSyncManager } from '../../shared/local-first/sync'; -import { DocCrypto } from '../../shared/local-first/crypto'; -import { crowdsurfSchema, crowdsurfDocId } from './schemas'; -import type { CrowdSurfDoc, CrowdSurfPrompt, PromptSwipe, Contribution } from './schemas'; - -export class CrowdSurfLocalFirstClient { - #space: string; - #documents: DocumentManager; - #store: EncryptedDocStore; - #sync: DocSyncManager; - #initialized = false; - - constructor(space: string, docCrypto?: DocCrypto) { - this.#space = space; - this.#documents = new DocumentManager(); - this.#store = new EncryptedDocStore(space, docCrypto); - this.#sync = new DocSyncManager({ - documents: this.#documents, - store: this.#store, - }); - this.#documents.registerSchema(crowdsurfSchema); - } - - get isConnected(): boolean { return this.#sync.isConnected; } - - async init(): Promise { - if (this.#initialized) return; - await this.#store.open(); - const cachedIds = await this.#store.listByModule('crowdsurf', 'prompts'); - const cached = await this.#store.loadMany(cachedIds); - for (const [docId, binary] of cached) { - this.#documents.open(docId, crowdsurfSchema, binary); - } - await this.#sync.preloadSyncStates(cachedIds); - const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsUrl = `${proto}//${location.host}/ws/${this.#space}`; - try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[CrowdSurf] Working offline'); } - this.#initialized = true; - } - - async subscribe(): Promise { - const docId = crowdsurfDocId(this.#space) as DocumentId; - let doc = this.#documents.get(docId); - if (!doc) { - const binary = await this.#store.load(docId); - doc = binary - ? this.#documents.open(docId, crowdsurfSchema, binary) - : this.#documents.open(docId, crowdsurfSchema); - } - await this.#sync.subscribe([docId]); - return doc ?? null; - } - - getDoc(): CrowdSurfDoc | undefined { - return this.#documents.get(crowdsurfDocId(this.#space) as DocumentId); - } - - onChange(cb: (doc: CrowdSurfDoc) => void): () => void { - return this.#sync.onChange(crowdsurfDocId(this.#space) as DocumentId, cb as (doc: any) => void); - } - - onConnect(cb: () => void): () => void { return this.#sync.onConnect(cb); } - - // ── Prompt CRUD ── - - createPrompt(prompt: CrowdSurfPrompt): void { - const docId = crowdsurfDocId(this.#space) as DocumentId; - this.#sync.change(docId, `Create prompt: ${prompt.text}`, (d) => { - d.prompts[prompt.id] = prompt; - }); - } - - deletePrompt(promptId: string): void { - const docId = crowdsurfDocId(this.#space) as DocumentId; - this.#sync.change(docId, `Delete prompt`, (d) => { - delete d.prompts[promptId]; - }); - } - - // ── Swiping ── - - swipe(promptId: string, participantDid: string, direction: 'right' | 'left', contribution?: Contribution): void { - const docId = crowdsurfDocId(this.#space) as DocumentId; - this.#sync.change(docId, `Swipe ${direction} on prompt`, (d) => { - const prompt = d.prompts[promptId]; - if (!prompt) return; - - const swipeData: PromptSwipe = { - direction, - timestamp: Date.now(), - }; - if (contribution) { - swipeData.contribution = contribution; - } - - prompt.swipes[participantDid] = swipeData; - - // Check trigger threshold - const rightSwipes = Object.values(prompt.swipes).filter(s => s.direction === 'right').length; - if (rightSwipes >= prompt.threshold && !prompt.triggered) { - prompt.triggered = true; - } - }); - } - - getMySwipe(promptId: string, myDid: string): PromptSwipe | null { - const doc = this.getDoc(); - return doc?.prompts?.[promptId]?.swipes?.[myDid] ?? null; - } - - // ── Expiry ── - - markExpired(promptId: string): void { - const docId = crowdsurfDocId(this.#space) as DocumentId; - this.#sync.change(docId, `Mark prompt expired`, (d) => { - if (d.prompts[promptId]) d.prompts[promptId].expired = true; - }); - } - - async disconnect(): Promise { - await this.#sync.flush(); - this.#sync.disconnect(); - } -} diff --git a/modules/crowdsurf/mod.ts b/modules/crowdsurf/mod.ts deleted file mode 100644 index 89eb423..0000000 --- a/modules/crowdsurf/mod.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * CrowdSurf module — swipe-based community activity coordination. - * - * Inspired by gospelofchange/Crowdsurfing. Users propose activities with - * commitment thresholds; others swipe to join and declare contributions. - * When enough people commit, the activity triggers. - * - * The folk-crowdsurf-dashboard web component lives in components/. - * This module provides: - * - A dashboard listing active/triggered prompts in the current space - * - API to query crowdsurf prompts from the Automerge store - * - Canvas shape integration for inline prompt cards - */ - -import { Hono } from "hono"; -import { renderShell } from "../../server/shell"; -import type { RSpaceModule } from "../../shared/module"; -import { renderLanding } from "./landing"; -import { getModuleInfoList } from "../../shared/module"; -import { getDocumentData, addShapes } from "../../server/community-store"; - -const routes = new Hono(); - -// GET /api/crowdsurf — list crowdsurf prompt shapes in the current space -routes.get("/api/crowdsurf", async (c) => { - const space = c.req.param("space") || c.req.query("space") || "demo"; - const docData = getDocumentData(space); - if (!docData?.shapes) { - return c.json({ prompts: [], total: 0 }); - } - - const promptTypes = ["folk-crowdsurf-prompt"]; - const prompts: any[] = []; - - for (const [id, shape] of Object.entries(docData.shapes as Record)) { - if (shape.forgotten) continue; - if (promptTypes.includes(shape.type)) { - prompts.push({ - id, - type: shape.type, - text: shape.text || "Untitled", - location: shape.location || "", - threshold: shape.threshold || 3, - swipeCount: Object.keys(shape.swipes || {}).length, - triggered: shape.triggered || false, - createdAt: shape.createdAt, - }); - } - } - - return c.json({ prompts, total: prompts.length }); -}); - -// GET / — crowdsurf dashboard page -routes.get("/", (c) => { - const spaceSlug = c.req.param("space") || "demo"; - return c.html(renderShell({ - title: `${spaceSlug} — CrowdSurf | rSpace`, - moduleId: "crowdsurf", - spaceSlug, - modules: getModuleInfoList(), - theme: "dark", - body: ``, - scripts: ``, - styles: ``, - })); -}); - -// ── Seed template data ── - -function seedTemplateCrowdSurf(space: string) { - const docData = getDocumentData(space); - const promptTypes = ["folk-crowdsurf-prompt"]; - if (docData?.shapes) { - const existing = Object.values(docData.shapes as Record) - .filter((s: any) => !s.forgotten && promptTypes.includes(s.type)); - if (existing.length > 0) return; - } - - const now = Date.now(); - const shapes: Record[] = [ - { - id: `tmpl-crowdsurf-1-${now}`, type: 'folk-crowdsurf-prompt', - x: 50, y: 2200, width: 420, height: 300, rotation: 0, - text: 'Community Garden Planting Day', - location: 'Community Center Garden', - threshold: 5, - duration: 4, - activityDuration: '3 hours', - swipes: {}, - triggered: false, - expired: false, - createdAt: now, - }, - { - id: `tmpl-crowdsurf-2-${now}`, type: 'folk-crowdsurf-prompt', - x: 520, y: 2200, width: 420, height: 300, rotation: 0, - text: 'Open Mic & Jam Session', - location: 'Local Park Bandstand', - threshold: 8, - duration: 6, - activityDuration: '2 hours', - swipes: {}, - triggered: false, - expired: false, - createdAt: now, - }, - { - id: `tmpl-crowdsurf-3-${now}`, type: 'folk-crowdsurf-prompt', - x: 990, y: 2200, width: 420, height: 300, rotation: 0, - text: 'Repair Cafe — Bring Your Broken Stuff', - location: 'Maker Space', - threshold: 3, - duration: 8, - activityDuration: '4 hours', - swipes: {}, - triggered: false, - expired: false, - createdAt: now, - }, - ]; - - addShapes(space, shapes); - console.log(`[CrowdSurf] Template seeded for "${space}": 3 prompt shapes`); -} - -export const crowdsurfModule: RSpaceModule = { - id: "crowdsurf", - name: "CrowdSurf", - icon: "🏄", - description: "Swipe-based community activity coordination", - scoping: { defaultScope: 'space', userConfigurable: false }, - routes, - hidden: true, // CrowdSurf is now a sub-tab of rChoices - standaloneDomain: "crowdsurf.online", - landingPage: renderLanding, - seedTemplate: seedTemplateCrowdSurf, - feeds: [ - { - id: "activity-triggers", - name: "Activity Triggers", - kind: "governance", - description: "Activity proposals and triggered events", - emits: ["folk-crowdsurf-prompt"], - }, - ], - acceptsFeeds: ["data", "governance"], - outputPaths: [ - { path: "prompts", name: "Prompts", icon: "🏄", description: "Active activity proposals" }, - { path: "triggered", name: "Triggered", icon: "🚀", description: "Activities that reached their threshold" }, - ], -}; diff --git a/modules/crowdsurf/schemas.ts b/modules/crowdsurf/schemas.ts deleted file mode 100644 index 6e4f7df..0000000 --- a/modules/crowdsurf/schemas.ts +++ /dev/null @@ -1,168 +0,0 @@ -/** - * rCrowdSurf Automerge document schemas. - * - * Stores collaborative activity proposals ("prompts") with swipe-based - * commitment tracking, contribution tagging, and threshold triggers. - * - * DocId format: {space}:crowdsurf:prompts - */ - -import type { DocSchema } from '../../shared/local-first/document'; - -// ── Contribution types ── - -export type ContributionCategory = 'skill' | 'space' | 'equipment' | 'food' | 'other'; - -export interface Contribution { - bringing: string[]; - needed: string[]; - tags: string[]; - value: number; -} - -// ── Swipe record ── - -export interface PromptSwipe { - direction: 'right' | 'left'; - timestamp: number; - contribution?: Contribution; -} - -// ── Activity prompt ── - -export interface CrowdSurfPrompt { - id: string; - text: string; - location: string; - /** Number of right-swipes needed to trigger */ - threshold: number; - /** Hours until prompt expires */ - duration: number; - /** Human-readable activity duration (e.g. "1 hour", "all day") */ - activityDuration: string; - createdAt: number; - createdBy: string | null; - triggered: boolean; - expired: boolean; - /** Keyed by participant DID */ - swipes: Record; -} - -// ── Document root ── - -export interface CrowdSurfDoc { - meta: { - module: string; - collection: string; - version: number; - spaceSlug: string; - createdAt: number; - }; - prompts: Record; -} - -// ── Schema registration ── - -export const crowdsurfSchema: DocSchema = { - module: 'crowdsurf', - collection: 'prompts', - version: 1, - init: (): CrowdSurfDoc => ({ - meta: { - module: 'crowdsurf', - collection: 'prompts', - version: 1, - spaceSlug: '', - createdAt: Date.now(), - }, - prompts: {}, - }), - migrate: (doc: any, _fromVersion: number) => { - if (!doc.prompts) doc.prompts = {}; - doc.meta.version = 1; - return doc; - }, -}; - -// ── Helpers ── - -export function crowdsurfDocId(space: string) { - return `${space}:crowdsurf:prompts` as const; -} - -/** Calculate decay progress (0-1) based on creation time and duration */ -export function getDecayProgress(prompt: CrowdSurfPrompt): number { - const age = Date.now() - prompt.createdAt; - const durationMs = prompt.duration * 60 * 60 * 1000; - return Math.min(age / durationMs, 1); -} - -/** Get human-readable time remaining */ -export function getTimeRemaining(prompt: CrowdSurfPrompt): string { - const remaining = prompt.duration * 60 * 60 * 1000 - (Date.now() - prompt.createdAt); - if (remaining <= 0) return 'Expired'; - const hours = Math.floor(remaining / (60 * 60 * 1000)); - const minutes = Math.floor((remaining % (60 * 60 * 1000)) / (60 * 1000)); - if (hours > 0) return `${hours}h ${minutes}m left`; - return `${minutes}m left`; -} - -/** Count right-swipes */ -export function getRightSwipeCount(prompt: CrowdSurfPrompt): number { - return Object.values(prompt.swipes).filter(s => s.direction === 'right').length; -} - -/** Check if prompt has met its threshold */ -export function isReadyToTrigger(prompt: CrowdSurfPrompt): boolean { - return getRightSwipeCount(prompt) >= prompt.threshold; -} - -/** Get urgency level based on time decay */ -export function getUrgency(prompt: CrowdSurfPrompt): 'low' | 'medium' | 'high' { - const decay = getDecayProgress(prompt); - if (decay > 0.7) return 'high'; - if (decay > 0.4) return 'medium'; - return 'low'; -} - -/** Parse free-text contribution input into tags and categories */ -export function parseContributions(bringing: string, needed: string): Contribution { - const parseItems = (text: string): string[] => - text.split(/[,\n]/).map(s => s.trim()).filter(s => s.length > 0); - - const bringingItems = parseItems(bringing); - const neededItems = parseItems(needed); - const allItems = [...bringingItems, ...neededItems]; - const tags = new Set(); - - const categoryKeywords: Record = { - food: ['cook', 'food', 'eat', 'meal', 'kitchen', 'bake', 'grill', 'ingredients'], - music: ['music', 'guitar', 'drum', 'sing', 'band', 'dj', 'speaker', 'mic'], - learning: ['teach', 'learn', 'skill', 'knowledge', 'workshop', 'lecture'], - tech: ['code', 'laptop', 'hack', 'build', 'dev', 'tech', 'wifi'], - art: ['art', 'paint', 'draw', 'craft', 'design', 'photo', 'camera'], - }; - - for (const item of allItems) { - const lower = item.toLowerCase(); - for (const [category, keywords] of Object.entries(categoryKeywords)) { - if (keywords.some(kw => lower.includes(kw))) { - tags.add(category); - } - } - } - - // Base value: 5 per item brought, 2 per item needed, +5 bonus for skill keywords - const skillWords = ['skill', 'experience', 'professional', 'advanced', 'expert']; - const value = bringingItems.reduce((sum, item) => { - const hasSkill = skillWords.some(sw => item.toLowerCase().includes(sw)); - return sum + (hasSkill ? 10 : 5); - }, 0) + neededItems.length * 2; - - return { - bringing: bringingItems, - needed: neededItems, - tags: Array.from(tags), - value, - }; -} diff --git a/modules/rchats/landing.ts b/modules/rchats/landing.ts new file mode 100644 index 0000000..2183e94 --- /dev/null +++ b/modules/rchats/landing.ts @@ -0,0 +1,150 @@ +/** + * rChats landing page — rich content for rspace.online/rchats + */ + +export function renderLanding(): string { + return ` + +
+ rChats +

Your conversations, your infrastructure.

+

Encrypted Community Messaging

+

+ Real-time community chat with channels & threads, end-to-end encrypted + by default. Local-first, works offline, and scoped to each rSpace community. +

+ +
+ + +
+
+

Native Encrypted Chat

+

Community messaging built from the ground up for privacy, speed, and self-hosting.

+
+
+
💬
+

Channels & Threads

+

Organize conversations by topic with channels. Dive deeper with threaded replies without cluttering the main feed.

+
+
+
🔒
+

End-to-End Encrypted

+

Messages encrypted with EncryptID passkeys. The server never sees plaintext — only community members can read messages.

+
+
+
📷
+

Local-First (CRDT)

+

Built on Automerge CRDTs. Send messages offline, sync seamlessly when reconnected. Your data lives on your device first.

+
+
+
🏠
+

Space-Scoped

+

Each rSpace community gets its own isolated chat instance. Channels, members, and history — all contained within your space.

+
+
+
+
+ + +
+
+

Connect Your Chats

+

Already using Slack, Discord, or Matrix? Bridge them into rSpace for a unified community view.

+
+
+
🔗
+

Multi-Platform Bridge

+

Aggregate channels from Slack, Discord, Matrix, Telegram, and Mattermost into a single interface.

+
+
+
📥
+

Unified Inbox

+

Read and reply to messages across all connected platforms from one place. Similar pattern to rInbox's multi-IMAP aggregation.

+
+
+
🔃
+

Two-Way Sync

+

Messages flow both directions. Reply from rChats and it appears in the original platform. No context lost.

+
+
+
🌎
+

Community-Wide View

+

Bridge external channels into rSpace channels for a single community view, regardless of where members actually chat.

+
+
+
+
+ + +
+
+

Part of a Self-Hosted Data Ecosystem

+

rChats isn't a standalone tool — it's one node in a wider network of self-hosted services that keep your data under your control.

+
+
+
📅
+

Meeting Integration

+

Start a rMeets video call from any channel. Meeting notes, recordings, and summaries flow back into the chat thread.

+
+
+
📄
+

Tasks & Notes

+

Turn chat messages into rTasks action items or rNotes documents. Keep conversations and decisions linked.

+
+
+
🔐
+

EncryptID SSO

+

One identity across the entire rSpace ecosystem. Passkey-based authentication, no passwords to manage.

+
+
+
+
+ + +
+
+

On the Horizon

+

Features in development for the rChats roadmap.

+
+
+
🤖
+

AI Summaries

+ Coming Soon +

Catch up on long threads with AI-generated summaries. Run locally or via your own LLM endpoint — no data sent externally.

+
+
+
🔌
+

Webhooks & Bots

+ Coming Soon +

Build custom integrations with incoming and outgoing webhooks. Connect CI/CD, monitoring, and other services to channels.

+
+
+
🎤
+

Voice Channels

+ Coming Soon +

Drop into voice channels powered by Jitsi. Persistent rooms for your community — always on, always private.

+
+
+
+
+ + +
+
+

Your conversations belong to you.

+

Start chatting on infrastructure you control, or create a space for your community.

+ +
+
+ + `; +} diff --git a/modules/rchats/mod.ts b/modules/rchats/mod.ts new file mode 100644 index 0000000..3ed4dc4 --- /dev/null +++ b/modules/rchats/mod.ts @@ -0,0 +1,81 @@ +/** + * rChats module — encrypted community messaging. + * + * Stub module: landing page + "Coming Soon" dashboard. + * Real chat functionality (Automerge CRDT, channels, threads) will come later. + */ + +import { Hono } from "hono"; +import { renderShell } from "../../server/shell"; +import { getModuleInfoList } from "../../shared/module"; +import type { RSpaceModule } from "../../shared/module"; +import { renderLanding } from "./landing"; + +const routes = new Hono(); + +// ── Hub page (Coming Soon dashboard) ── + +routes.get("/", (c) => { + const space = c.req.param("space") || "demo"; + return c.html(renderShell({ + title: `rChats — ${space} | rSpace`, + moduleId: "rchats", + spaceSlug: space, + modules: getModuleInfoList(), + styles: ``, + body: `
+

rChats

+

Encrypted community messaging — channels, threads, and bridges

+
+ 🗨️ + Coming Soon +

Encrypted Community Chat

+

Real-time messaging with channels and threads, end-to-end encrypted via EncryptID. Local-first with Automerge CRDTs — works offline, syncs seamlessly.

+
+
+
+

🔐 E2E Encrypted

+

Messages encrypted with EncryptID passkeys. The server never sees plaintext.

+
+
+

💬 Channels & Threads

+

Organize conversations by topic. Threaded replies keep the main feed clean.

+
+
+

🔗 Chat Bridges

+

Connect Slack, Discord, Matrix, Telegram, and Mattermost into one unified view.

+
+
+

📡 Local-First

+

Built on Automerge CRDTs. Send messages offline and sync when reconnected.

+
+
+
`, + })); +}); + +// ── Module export ── + +export const chatsModule: RSpaceModule = { + id: "rchats", + name: "rChats", + icon: "🗨️", + description: "Encrypted community messaging", + scoping: { defaultScope: "space", userConfigurable: false }, + routes, + landingPage: renderLanding, +}; diff --git a/modules/rchoices/components/folk-choices-dashboard.ts b/modules/rchoices/components/folk-choices-dashboard.ts index a14fa98..3a980c9 100644 --- a/modules/rchoices/components/folk-choices-dashboard.ts +++ b/modules/rchoices/components/folk-choices-dashboard.ts @@ -9,16 +9,6 @@ import { TourEngine } from "../../../shared/tour-engine"; import { ChoicesLocalFirstClient } from "../local-first-client"; import type { ChoicesDoc, ChoiceSession, ChoiceVote } from "../schemas"; -// ── CrowdSurf types ── -interface CrowdSurfOption { - optionId: string; - label: string; - color: string; - sessionId: string; - sessionTitle: string; - sessionType: 'vote' | 'rank' | 'score'; -} - // ── Auth helpers ── function getSession(): { accessToken: string; claims: { sub: string; did?: string; username?: string } } | null { try { @@ -41,7 +31,7 @@ class FolkChoicesDashboard extends HTMLElement { private space: string; /* Demo state */ - private demoTab: "spider" | "ranking" | "voting" | "crowdsurf" = "spider"; + private demoTab: "spider" | "ranking" | "voting" = "spider"; private hoveredPerson: string | null = null; private rankItems: { id: number; name: string; emoji: string }[] = []; private rankDragging: number | null = null; @@ -57,28 +47,18 @@ class FolkChoicesDashboard extends HTMLElement { private activeSessionId: string | null = null; private sessionVotes: Map = new Map(); - /* CrowdSurf inline state */ - private csOptions: CrowdSurfOption[] = []; - private csCurrentIndex = 0; - private csSwipedMap: Map = new Map(); - private csIsDragging = false; - private csStartX = 0; - private csCurrentX = 0; - private csIsAnimating = false; - private _csTransitionTimer: number | null = null; - // Guided tour private _tour!: TourEngine; private static readonly TOUR_STEPS = [ - { target: '.demo-content', title: "rChoices", message: "Explore spider charts, rankings, live voting, and CrowdSurf swipe cards. Use the sub-nav above to switch between modes.", advanceOnClick: true }, + { target: '.demo-content', title: "rChoices", message: "Explore spider charts, rankings, and live voting. Use the sub-nav above to switch between modes.", advanceOnClick: true }, ]; constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); this.space = this.getAttribute("space") || "demo"; - const tabAttr = this.getAttribute("tab") as "spider" | "ranking" | "voting" | "crowdsurf" | null; - if (tabAttr && ["spider", "ranking", "voting", "crowdsurf"].includes(tabAttr)) { + const tabAttr = this.getAttribute("tab") as "spider" | "ranking" | "voting" | null; + if (tabAttr && ["spider", "ranking", "voting"].includes(tabAttr)) { this.demoTab = tabAttr; } this._tour = new TourEngine( @@ -105,10 +85,6 @@ class FolkChoicesDashboard extends HTMLElement { clearInterval(this.simTimer); this.simTimer = null; } - if (this._csTransitionTimer !== null) { - clearTimeout(this._csTransitionTimer); - this._csTransitionTimer = null; - } this._lfcUnsub?.(); this._lfcUnsub = null; this.lfClient?.disconnect(); @@ -532,7 +508,6 @@ class FolkChoicesDashboard extends HTMLElement { let content = ""; if (this.demoTab === "spider") content = this.renderSpider(); else if (this.demoTab === "ranking") content = this.renderRanking(); - else if (this.demoTab === "crowdsurf") content = this.renderCrowdSurf(); else content = this.renderVoting(); this.shadow.innerHTML = ` @@ -583,30 +558,6 @@ class FolkChoicesDashboard extends HTMLElement { .vote-reset:hover { border-color: var(--rs-error); color: #fca5a5; } .vote-status { text-align: center; margin-bottom: 1rem; font-size: 0.8rem; color: var(--rs-text-muted); } - /* CrowdSurf inline */ - .cs-inline { max-width: 420px; margin-inline: auto; } - .cs-progress-header { display: flex; justify-content: space-between; margin-bottom: 0.75rem; } - .cs-card-stack { display: flex; flex-direction: column; align-items: center; min-height: 240px; justify-content: center; } - .cs-card { position: relative; width: 100%; background: linear-gradient(135deg, var(--rs-bg-surface) 0%, var(--rs-bg-surface-raised, var(--rs-bg-surface)) 100%); border: 1px solid var(--rs-border); border-radius: 16px; padding: 1.5rem; cursor: grab; user-select: none; touch-action: pan-y; } - .cs-card:active { cursor: grabbing; } - .cs-card-body { display: flex; flex-direction: column; gap: 0.75rem; } - .cs-type-badge { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; width: fit-content; } - .cs-card-session { font-size: 0.85rem; color: var(--rs-text-secondary); } - .cs-card-option { font-size: 1.3rem; font-weight: 700; color: var(--rs-text-primary); display: flex; align-items: center; gap: 10px; } - .cs-color-dot { display: inline-block; width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; } - .cs-swipe-indicator { position: absolute; top: 50%; transform: translateY(-50%); font-size: 1.1rem; font-weight: 700; padding: 6px 14px; border-radius: 8px; opacity: 0; transition: opacity 0.15s; pointer-events: none; z-index: 2; } - .cs-swipe-left { left: 12px; color: #ef4444; background: rgba(239,68,68,0.15); } - .cs-swipe-right { right: 12px; color: #22c55e; background: rgba(34,197,94,0.15); } - .cs-swipe-indicator.show { opacity: 1; } - .cs-swipe-buttons { display: flex; justify-content: center; gap: 2rem; margin-top: 1.25rem; } - .cs-btn-skip, .cs-btn-approve { width: 52px; height: 52px; border-radius: 50%; border: 2px solid; font-size: 1.3rem; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s; background: var(--rs-bg-surface); font-family: inherit; } - .cs-btn-skip { border-color: #ef4444; color: #ef4444; } - .cs-btn-skip:hover { background: rgba(239,68,68,0.15); } - .cs-btn-approve { border-color: #22c55e; color: #22c55e; } - .cs-btn-approve:hover { background: rgba(34,197,94,0.15); } - .cs-btn-reset { padding: 0.5rem 1.25rem; border-radius: 8px; border: 1px solid var(--rs-border); background: var(--rs-bg-surface); color: var(--rs-text-secondary); cursor: pointer; font-size: 0.85rem; font-family: inherit; transition: all 0.15s; margin-top: 0.75rem; } - .cs-btn-reset:hover { border-color: var(--rs-primary); color: var(--rs-text-primary); } - @media (max-width: 768px) { .grid { grid-template-columns: 1fr; } } @@ -618,9 +569,6 @@ class FolkChoicesDashboard extends HTMLElement { .rank-name { font-size: 0.875rem; } .vote-option { padding: 0.625rem 0.75rem; } .spider-svg { max-width: 300px; } - .cs-card { padding: 1.25rem; border-radius: 12px; } - .cs-card-option { font-size: 1.1rem; } - .cs-btn-skip, .cs-btn-approve { width: 46px; height: 46px; font-size: 1.1rem; } } @@ -760,324 +708,6 @@ class FolkChoicesDashboard extends HTMLElement { `; } - /* -- CrowdSurf helpers -- */ - - private static mulberry32(seed: number): () => number { - let s = seed | 0; - return () => { - s = (s + 0x6D2B79F5) | 0; - let t = Math.imul(s ^ (s >>> 15), 1 | s); - t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; - } - - private static hashString(str: string): number { - let hash = 5381; - for (let i = 0; i < str.length; i++) { - hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0; - } - return hash >>> 0; - } - - private buildCrowdSurfOptions() { - const myDid = getMyDid() || 'anon'; - const lsKey = `cs_swiped:${this.space}:${myDid}`; - - // Restore persisted swipes - try { - const saved = localStorage.getItem(lsKey); - if (saved) { - const entries: [string, 'right' | 'left'][] = JSON.parse(saved); - this.csSwipedMap = new Map(entries); - } - } catch { /* ignore */ } - - // Build pool from live sessions or demo data - let pool: CrowdSurfOption[] = []; - - // Try live sessions first - const openSessions = this.sessions.filter(s => !s.closed); - for (const session of openSessions) { - const sType: 'vote' | 'rank' | 'score' = session.type === 'rank' ? 'rank' : session.type === 'score' ? 'score' : 'vote'; - for (const opt of session.options) { - pool.push({ - optionId: opt.id, - label: opt.label, - color: opt.color, - sessionId: session.id, - sessionTitle: session.title, - sessionType: sType, - }); - } - } - - // Demo mode fallback - if (this.space === 'demo' && pool.length === 0) { - for (const opt of this.voteOptions) { - pool.push({ - optionId: opt.id, - label: opt.name, - color: opt.color, - sessionId: 'demo-vote', - sessionTitle: 'Movie Night', - sessionType: 'vote', - }); - } - for (const item of this.rankItems) { - pool.push({ - optionId: String(item.id), - label: item.name, - color: '#f59e0b', - sessionId: 'demo-rank', - sessionTitle: 'Lunch Spot', - sessionType: 'rank', - }); - } - } - - // Filter already swiped - pool = pool.filter(o => !this.csSwipedMap.has(`${o.sessionId}:${o.optionId}`)); - - // Seeded shuffle (Fisher-Yates) - const today = new Date().toISOString().slice(0, 10); - const seed = FolkChoicesDashboard.hashString(`${myDid}:${this.space}:${today}`); - const rng = FolkChoicesDashboard.mulberry32(seed); - for (let i = pool.length - 1; i > 0; i--) { - const j = Math.floor(rng() * (i + 1)); - [pool[i], pool[j]] = [pool[j], pool[i]]; - } - - this.csOptions = pool.slice(0, Math.min(10, pool.length)); - this.csCurrentIndex = 0; - } - - private renderCrowdSurf(): string { - // Build options if empty or all consumed - if (this.csOptions.length === 0 || this.csCurrentIndex >= this.csOptions.length) { - this.buildCrowdSurfOptions(); - } - - // No options at all - if (this.csOptions.length === 0) { - return `
-
-
🏄
-

No open polls to surf yet.

-

Create a poll in the Voting tab, then come back to swipe!

- ${this.csSwipedMap.size > 0 ? `` : ''} -
-
`; - } - - // All swiped — show summary - if (this.csCurrentIndex >= this.csOptions.length) { - return this.renderCrowdSurfSummary(); - } - - // Active card - const opt = this.csOptions[this.csCurrentIndex]; - const approved = Array.from(this.csSwipedMap.values()).filter(v => v === 'right').length; - const typeBadgeColors: Record = { vote: '#3b82f6', rank: '#f59e0b', score: '#10b981' }; - const badgeColor = typeBadgeColors[opt.sessionType] || '#3b82f6'; - - return `
-
- ${this.csCurrentIndex + 1} of ${this.csOptions.length} - ${approved} approved -
-
-
-
✗ Skip
-
✓ Approve
-
-
${opt.sessionType}
-
${this.esc(opt.sessionTitle)}
-
- - ${this.esc(opt.label)} -
-
-
-
-
- - -
-
`; - } - - private renderCrowdSurfSummary(): string { - const approved: CrowdSurfOption[] = []; - this.csSwipedMap.forEach((dir, key) => { - if (dir !== 'right') return; - const [sessionId, optionId] = key.split(':'); - const opt = this.csOptions.find(o => o.sessionId === sessionId && o.optionId === optionId); - if (opt) approved.push(opt); - }); - - // Group by session - const grouped = new Map(); - for (const opt of approved) { - const list = grouped.get(opt.sessionId) || []; - list.push(opt); - grouped.set(opt.sessionId, list); - } - - let groupHtml = ''; - grouped.forEach((opts) => { - const title = opts[0].sessionTitle; - const items = opts.map(o => - `
- - ${this.esc(o.label)} -
` - ).join(''); - groupHtml += `
-
${this.esc(title)}
- ${items} -
`; - }); - - return `
-
-
-

All done!

-

- You approved ${approved.length} of ${this.csSwipedMap.size} options -

- ${groupHtml || `

No approvals this round.

`} - -
-
`; - } - - private setupCrowdSurfSwipe() { - const card = this.shadow.getElementById('cs-inline-card') as HTMLElement | null; - if (!card) return; - - // Clear any pending transition timer from previous card - if (this._csTransitionTimer !== null) { - clearTimeout(this._csTransitionTimer); - this._csTransitionTimer = null; - } - - const handleStart = (clientX: number) => { - if (this.csIsAnimating) return; - // Clear any lingering transition - if (this._csTransitionTimer !== null) { - clearTimeout(this._csTransitionTimer); - this._csTransitionTimer = null; - } - card.style.transition = ''; - this.csStartX = clientX; - this.csCurrentX = clientX; - this.csIsDragging = true; - }; - - const handleMove = (clientX: number) => { - if (!this.csIsDragging || this.csIsAnimating) return; - this.csCurrentX = clientX; - const diffX = this.csCurrentX - this.csStartX; - const rotation = diffX * 0.1; - card.style.transform = `translateX(${diffX}px) rotate(${rotation}deg)`; - - const leftInd = card.querySelector('.cs-swipe-left') as HTMLElement; - const rightInd = card.querySelector('.cs-swipe-right') as HTMLElement; - - if (diffX < -50) { - leftInd?.classList.add('show'); - rightInd?.classList.remove('show'); - } else if (diffX > 50) { - rightInd?.classList.add('show'); - leftInd?.classList.remove('show'); - } else { - leftInd?.classList.remove('show'); - rightInd?.classList.remove('show'); - } - }; - - const handleEnd = () => { - if (!this.csIsDragging || this.csIsAnimating) return; - this.csIsDragging = false; - const diffX = this.csCurrentX - this.csStartX; - - card.querySelector('.cs-swipe-left')?.classList.remove('show'); - card.querySelector('.cs-swipe-right')?.classList.remove('show'); - - if (Math.abs(diffX) > 100) { - const direction = diffX > 0 ? 1 : -1; - this.csIsAnimating = true; - card.style.transition = 'transform 0.3s ease-out, opacity 0.3s ease-out'; - card.style.transform = `translateX(${direction * 500}px) rotate(${direction * 30}deg)`; - card.style.opacity = '0'; - this._csTransitionTimer = window.setTimeout(() => { - card.style.transform = ''; - card.style.opacity = ''; - card.style.transition = ''; - this._csTransitionTimer = null; - this.handleCrowdSurfSwipe(diffX > 0 ? 'right' : 'left'); - }, 300); - } else { - card.style.transition = 'transform 0.2s ease-out'; - card.style.transform = ''; - this._csTransitionTimer = window.setTimeout(() => { - card.style.transition = ''; - this._csTransitionTimer = null; - }, 200); - } - }; - - card.addEventListener('pointerdown', (e: PointerEvent) => { - if (e.button !== 0) return; - card.setPointerCapture(e.pointerId); - card.style.touchAction = 'none'; - handleStart(e.clientX); - }); - card.addEventListener('pointermove', (e: PointerEvent) => { - e.preventDefault(); - handleMove(e.clientX); - }); - card.addEventListener('pointerup', () => handleEnd()); - card.addEventListener('pointercancel', () => { - this.csIsDragging = false; - card.style.transform = ''; - card.style.transition = ''; - card.style.opacity = ''; - card.style.touchAction = ''; - }); - } - - private handleCrowdSurfSwipe(direction: 'right' | 'left') { - this.csIsAnimating = false; - if (this.csCurrentIndex >= this.csOptions.length) return; - - const opt = this.csOptions[this.csCurrentIndex]; - const swipeKey = `${opt.sessionId}:${opt.optionId}`; - this.csSwipedMap.set(swipeKey, direction); - - // Persist to localStorage - const myDid = getMyDid() || 'anon'; - const lsKey = `cs_swiped:${this.space}:${myDid}`; - try { - localStorage.setItem(lsKey, JSON.stringify(Array.from(this.csSwipedMap.entries()))); - } catch { /* quota */ } - - // Cast vote on right swipe (live mode) - if (direction === 'right' && this.lfClient && opt.sessionId !== 'demo-vote' && opt.sessionId !== 'demo-rank') { - const did = getMyDid(); - if (did) { - const existing = this.lfClient.getMyVote(opt.sessionId, did); - const newChoices = { ...(existing?.choices || {}), [opt.optionId]: 1 }; - this.lfClient.castVote(opt.sessionId, did, newChoices); - } - } - - this.csCurrentIndex++; - this.renderDemo(); - this.bindDemoEvents(); - } - /* -- Demo event binding -- */ private bindDemoEvents() { @@ -1188,44 +818,6 @@ class FolkChoicesDashboard extends HTMLElement { }); } - // CrowdSurf swipe + buttons - this.setupCrowdSurfSwipe(); - this.shadow.querySelector('[data-cs-action="skip"]')?.addEventListener('click', () => { - if (this.csIsAnimating) return; - const card = this.shadow.getElementById('cs-inline-card') as HTMLElement | null; - if (card) { - this.csIsAnimating = true; - card.style.transition = 'transform 0.3s ease-out, opacity 0.3s ease-out'; - card.style.transform = 'translateX(-500px) rotate(-30deg)'; - card.style.opacity = '0'; - setTimeout(() => this.handleCrowdSurfSwipe('left'), 300); - } else { - this.handleCrowdSurfSwipe('left'); - } - }); - this.shadow.querySelector('[data-cs-action="approve"]')?.addEventListener('click', () => { - if (this.csIsAnimating) return; - const card = this.shadow.getElementById('cs-inline-card') as HTMLElement | null; - if (card) { - this.csIsAnimating = true; - card.style.transition = 'transform 0.3s ease-out, opacity 0.3s ease-out'; - card.style.transform = 'translateX(500px) rotate(30deg)'; - card.style.opacity = '0'; - setTimeout(() => this.handleCrowdSurfSwipe('right'), 300); - } else { - this.handleCrowdSurfSwipe('right'); - } - }); - this.shadow.querySelector('[data-cs-action="reset"]')?.addEventListener('click', () => { - const myDid = getMyDid() || 'anon'; - const lsKey = `cs_swiped:${this.space}:${myDid}`; - localStorage.removeItem(lsKey); - this.csSwipedMap.clear(); - this.csOptions = []; - this.csCurrentIndex = 0; - this.renderDemo(); - this.bindDemoEvents(); - }); } private esc(s: string): string { diff --git a/modules/rchoices/mod.ts b/modules/rchoices/mod.ts index 5022206..b992192 100644 --- a/modules/rchoices/mod.ts +++ b/modules/rchoices/mod.ts @@ -65,9 +65,9 @@ routes.get("/", (c) => { routes.get("/:tab", (c) => { const spaceSlug = c.req.param("space") || "demo"; const tab = c.req.param("tab"); - const validTabs = ["spider", "ranking", "voting", "crowdsurf"]; + const validTabs = ["spider", "ranking", "voting"]; if (!validTabs.includes(tab)) return c.notFound(); - const tabLabel = tab === "crowdsurf" ? "CrowdSurf" : tab.charAt(0).toUpperCase() + tab.slice(1); + const tabLabel = tab.charAt(0).toUpperCase() + tab.slice(1); return c.html(renderShell({ title: `${spaceSlug} — ${tabLabel} | rChoices`, moduleId: "rchoices", @@ -161,6 +161,5 @@ export const choicesModule: RSpaceModule = { { path: "spider", name: "Spider Chart", icon: "🕸", description: "Multi-criteria radar charts" }, { path: "ranking", name: "Ranking", icon: "📊", description: "Drag-and-drop rankings" }, { path: "voting", name: "Voting", icon: "☑", description: "Live polls and voting" }, - { path: "crowdsurf", name: "CrowdSurf", icon: "🏄", description: "Swipe-based option surfacing" }, ], }; diff --git a/modules/rswag/components/folk-revenue-sankey.ts b/modules/rswag/components/folk-revenue-sankey.ts new file mode 100644 index 0000000..9ea9d1d --- /dev/null +++ b/modules/rswag/components/folk-revenue-sankey.ts @@ -0,0 +1,214 @@ +/** + * — Interactive Sankey diagram showing revenue flow. + * + * Visualizes $29.99 example: Printer cost -> Creator share -> Community share. + * Draggable split sliders, animated flow lines, key metrics. + */ + +class FolkRevenueSankey extends HTMLElement { + private shadow: ShadowRoot; + private totalPrice = 29.99; + private providerPct = 50; + private creatorPct = 35; + private communityPct = 15; + private dragging: "creator" | "community" | null = null; + + constructor() { + super(); + this.shadow = this.attachShadow({ mode: "open" }); + } + + connectedCallback() { + if (this.hasAttribute("price")) { + this.totalPrice = parseFloat(this.getAttribute("price") || "29.99"); + } + this.render(); + } + + private render() { + const providerAmt = (this.totalPrice * this.providerPct / 100).toFixed(2); + const creatorAmt = (this.totalPrice * this.creatorPct / 100).toFixed(2); + const communityAmt = (this.totalPrice * this.communityPct / 100).toFixed(2); + + this.shadow.innerHTML = ` + + +
+
Revenue Flow
+
How $${this.totalPrice.toFixed(2)} flows from customer to community
+ + +
+ + + + Customer + $${this.totalPrice.toFixed(2)} + + + + + + + Print Provider + ${this.providerPct}% / $${providerAmt} + + + + + + + Design Creator + ${this.creatorPct}% / $${creatorAmt} + + + + + + + Community + ${this.communityPct}% / $${communityAmt} + + + $0 + platform fee + +
+ + +
+
+
+ ${this.providerPct}% + $${providerAmt} +
+
+ ${this.creatorPct}% + $${creatorAmt} +
+
+ ${this.communityPct}% + $${communityAmt} +
+
+
+ + +
+ Provider + + ${this.providerPct}% +
+
+ Creator + + ${this.creatorPct}% +
+ +
Adjust sliders to explore different revenue splits
+ + +
+
+
$0
+
Platform Fee
+
+
+
100%
+
To Community
+
+
+
${this.creatorPct + this.communityPct}%
+
Creator + Commons
+
+
+ + +
+ Print Provider (production + shipping) + Design Creator + Community Treasury +
+
+ `; + + this.bindEvents(); + } + + private bindEvents() { + const providerSlider = this.shadow.getElementById("provider-slider") as HTMLInputElement; + const creatorSlider = this.shadow.getElementById("creator-slider") as HTMLInputElement; + + providerSlider?.addEventListener("input", () => { + this.providerPct = parseInt(providerSlider.value, 10); + this.rebalance("provider"); + this.render(); + }); + + creatorSlider?.addEventListener("input", () => { + this.creatorPct = parseInt(creatorSlider.value, 10); + this.rebalance("creator"); + this.render(); + }); + } + + private rebalance(changed: "provider" | "creator") { + if (changed === "provider") { + // Adjust creator and community proportionally + const remaining = 100 - this.providerPct; + const ratio = this.creatorPct / (this.creatorPct + this.communityPct) || 0.7; + this.creatorPct = Math.round(remaining * ratio); + this.communityPct = remaining - this.creatorPct; + } else { + // Adjust community from remaining + this.communityPct = 100 - this.providerPct - this.creatorPct; + } + // Clamp + this.communityPct = Math.max(0, this.communityPct); + if (this.providerPct + this.creatorPct + this.communityPct !== 100) { + this.communityPct = 100 - this.providerPct - this.creatorPct; + } + } +} + +customElements.define("folk-revenue-sankey", FolkRevenueSankey); diff --git a/modules/rswag/components/folk-swag-designer.ts b/modules/rswag/components/folk-swag-designer.ts index dd991fd..1d416da 100644 --- a/modules/rswag/components/folk-swag-designer.ts +++ b/modules/rswag/components/folk-swag-designer.ts @@ -1,10 +1,13 @@ /** - * — upload artwork → generate print-ready files. - * Product selector (sticker, poster, tee, hoodie), image upload with preview, - * generate button, artifact result display with download link. + * — Full-featured merch design tool. * - * Demo mode: 4-step interactive flow with inline SVG mockups, - * provider matching, revenue splits, and pipeline visualization. + * 4-tab layout: + * - Browse: Product catalog with mockups, filtering, add-to-cart + * - Create: AI generate, upload, manage designs + * - HitherDither: Dithering tools + screen-print separations + * - Orders: rCart order status + fulfillment tracking + * + * Demo mode: 4-step interactive flow (preserved from original). */ // --- Demo data (self-contained, zero API calls in demo mode) --- @@ -32,7 +35,7 @@ interface DemoProvider { const DEMO_PRODUCTS: DemoProduct[] = [ { - id: "tee", name: "T-Shirt", printArea: "305×406mm", baseCost: "$9.25–$13.25", + id: "tee", name: "T-Shirt", printArea: "305x406mm", baseCost: "$9.25-$13.25", printful: true, sizes: ["S", "M", "L", "XL", "2XL", "3XL"], colors: [ @@ -44,15 +47,15 @@ const DEMO_PRODUCTS: DemoProduct[] = [ ], }, { - id: "sticker", name: "Sticker Sheet", printArea: "210×297mm", baseCost: "$1.20–$1.50", + id: "sticker", name: "Sticker Sheet", printArea: "210x297mm", baseCost: "$1.20-$1.50", printful: true, }, { - id: "poster", name: "Poster (A3)", printArea: "297×420mm", baseCost: "$4.50–$7.00", + id: "poster", name: "Poster (A3)", printArea: "297x420mm", baseCost: "$4.50-$7.00", printful: false, }, { - id: "hoodie", name: "Hoodie", printArea: "356×406mm", baseCost: "$23.95–$27.95", + id: "hoodie", name: "Hoodie", printArea: "356x406mm", baseCost: "$23.95-$27.95", printful: true, sizes: ["S", "M", "L", "XL", "2XL"], colors: [ @@ -141,7 +144,7 @@ function posterMockupSvg(): string { ${cosmoDesignSvg().replace(/]*>/, "").replace("", "")} COSMOLOCAL NETWORK - A3 — 297×420mm — 300 DPI + A3 - 297x420mm - 300 DPI `; } @@ -151,7 +154,7 @@ import { TourEngine } from "../../../shared/tour-engine"; import { SwagLocalFirstClient } from "../local-first-client"; import type { SwagDoc, SwagDesign } from "../schemas"; -// ── Auth helpers ── +// Auth helpers function getSession(): { accessToken: string; claims: { sub: string; did?: string; username?: string } } | null { try { const raw = localStorage.getItem("encryptid_session"); @@ -166,9 +169,31 @@ function getMyDid(): string | null { return (s.claims as any).did || s.claims.sub; } +// Dithering algorithms for the HitherDither tab +const DITHER_ALGORITHMS = [ + { id: "floyd-steinberg", name: "Floyd-Steinberg", group: "Error Diffusion" }, + { id: "atkinson", name: "Atkinson", group: "Error Diffusion" }, + { id: "stucki", name: "Stucki", group: "Error Diffusion" }, + { id: "burkes", name: "Burkes", group: "Error Diffusion" }, + { id: "sierra", name: "Sierra", group: "Error Diffusion" }, + { id: "sierra-two-row", name: "Sierra Two-Row", group: "Error Diffusion" }, + { id: "sierra-lite", name: "Sierra Lite", group: "Error Diffusion" }, + { id: "jarvis-judice-ninke", name: "Jarvis-Judice-Ninke", group: "Error Diffusion" }, + { id: "bayer", name: "Bayer (Ordered)", group: "Ordered" }, + { id: "ordered", name: "Ordered", group: "Ordered" }, + { id: "cluster-dot", name: "Cluster Dot", group: "Ordered" }, +]; + +type TabId = "browse" | "create" | "dither" | "orders"; + class FolkSwagDesigner extends HTMLElement { private shadow: ShadowRoot; private space = ""; + + // Tab state + private activeTab: TabId = "browse"; + + // Demo state private selectedProduct = "tee"; private selectedSize = "M"; private selectedColor = "black"; @@ -181,14 +206,45 @@ class FolkSwagDesigner extends HTMLElement { private demoStep: 1 | 2 | 3 | 4 = 1; private progressStep = 0; private usedSampleDesign = false; - /* Multiplayer state */ + + // Browse tab state + private catalogProducts: any[] = []; + private catalogLoading = false; + private catalogSearch = ""; + private catalogCategory = ""; + + // Create tab state + private createMode: "ai" | "upload" | "designs" = "designs"; + private aiConcept = ""; + private aiName = ""; + private aiTags = ""; + private aiGenerating = false; + private uploadFile: File | null = null; + private uploadPreview = ""; + private uploadName = ""; + private uploadDescription = ""; + private myDesigns: any[] = []; + + // Dither tab state + private ditherDesignSlug = ""; + private ditherAlgorithm = "floyd-steinberg"; + private ditherNumColors = 8; + private ditherPreviewUrl = ""; + private ditherLoading = false; + private ditherColors: string[] = []; + private separationData: any = null; + + // Orders tab state + private orders: any[] = []; + + // Multiplayer state private lfClient: SwagLocalFirstClient | null = null; private _lfcUnsub: (() => void) | null = null; private sharedDesigns: SwagDesign[] = []; private _tour!: TourEngine; private static readonly TOUR_STEPS = [ - { target: '.product', title: "Choose Product", message: "Select a product type — tee, sticker, poster, or hoodie.", advanceOnClick: true }, + { target: '.product', title: "Choose Product", message: "Select a product type - tee, sticker, poster, or hoodie.", advanceOnClick: true }, { target: '.steps-bar', title: "Design Flow", message: "Follow the 4-step flow: Product, Design, Generate, Pipeline.", advanceOnClick: false }, { target: '.sample-btn', title: "Sample Design", message: "Try the demo with a pre-made sample design to see the full pipeline.", advanceOnClick: true }, { target: '.generate-btn', title: "Generate", message: "Generate print-ready files and see provider matching + revenue splits.", advanceOnClick: false }, @@ -216,6 +272,8 @@ class FolkSwagDesigner extends HTMLElement { this.render(); } else { this.initMultiplayer(); + this.loadCatalog(); + this.loadMyDesigns(); this.render(); } if (!localStorage.getItem("rswag_tour_done")) { @@ -261,6 +319,13 @@ class FolkSwagDesigner extends HTMLElement { title: this.designTitle || 'Untitled Design', productType: this.selectedProduct as SwagDesign['productType'], artifactId, + source: 'artifact', + status: 'active', + imageUrl: null, + products: [], + slug: null, + description: null, + tags: [], createdBy: getMyDid(), createdAt: Date.now(), updatedAt: Date.now(), @@ -281,6 +346,40 @@ class FolkSwagDesigner extends HTMLElement { return match ? match[0] : "/rswag"; } + // ── Data loading ── + + private async loadCatalog() { + this.catalogLoading = true; + try { + const params = new URLSearchParams(); + if (this.catalogSearch) params.set("q", this.catalogSearch); + if (this.catalogCategory) params.set("category", this.catalogCategory); + const resp = await fetch(`${this.getApiBase()}/api/storefront?${params}`); + if (resp.ok) { + const data = await resp.json(); + this.catalogProducts = data.products || []; + } + } catch (e) { + console.warn("[rSwag] Failed to load catalog:", e); + } + this.catalogLoading = false; + this.render(); + } + + private async loadMyDesigns() { + try { + const resp = await fetch(`${this.getApiBase()}/api/designs`); + if (resp.ok) { + const data = await resp.json(); + this.myDesigns = data.designs || []; + } + } catch (e) { + console.warn("[rSwag] Failed to load designs:", e); + } + } + + // ── Demo mode methods (preserved) ── + private getDemoProduct(): DemoProduct { return DEMO_PRODUCTS.find(p => p.id === this.selectedProduct) || DEMO_PRODUCTS[0]; } @@ -312,7 +411,6 @@ class FolkSwagDesigner extends HTMLElement { this.demoStep = 3; this.progressStep = 0; this.render(); - const steps = [1, 2, 3, 4]; const delays = [400, 400, 400, 300]; let elapsed = 0; @@ -338,7 +436,6 @@ class FolkSwagDesigner extends HTMLElement { const d = dims[p.id] || dims.tee; const wpx = Math.round((d.w / 25.4) * 300); const hpx = Math.round((d.h / 25.4) * 300); - const capabilities: Record = { tee: ["dtg-print"], sticker: ["vinyl-cut"], poster: ["inkjet-print"], hoodie: ["dtg-print"], }; @@ -346,15 +443,13 @@ class FolkSwagDesigner extends HTMLElement { tee: ["cotton-standard", "cotton-organic"], sticker: ["vinyl-matte", "vinyl-gloss"], poster: ["paper-160gsm-cover", "paper-100gsm"], hoodie: ["cotton-polyester-blend"], }; - this.artifact = { title: this.designTitle || this.getAutoTitle(), product: p.id, spec: { product_type: p.id === "sticker" ? "sticker-sheet" : p.id, dimensions: { width_mm: d.w, height_mm: d.h }, - dpi: 300, - color_space: "sRGB", + dpi: 300, color_space: "sRGB", required_capabilities: capabilities[p.id], substrates: substrates[p.id], }, @@ -385,35 +480,26 @@ class FolkSwagDesigner extends HTMLElement { .sort((a, b) => a.distance - b.distance); } + // ── Generate (full mode) ── + private async generate() { - if (this.space === "demo") { - this.demoGenerate(); - return; - } + if (this.space === "demo") { this.demoGenerate(); return; } if (!this.imageFile || this.generating) return; this.generating = true; this.error = ""; this.artifact = null; this.render(); - try { const formData = new FormData(); formData.append("image", this.imageFile); formData.append("product", this.selectedProduct); formData.append("title", this.designTitle || "Untitled Design"); - - const res = await fetch(`${this.getApiBase()}/api/artifact`, { - method: "POST", - body: formData, - }); - + const res = await fetch(`${this.getApiBase()}/api/artifact`, { method: "POST", body: formData }); if (!res.ok) { const err = await res.json(); throw new Error(err.error || `Failed: ${res.status}`); } - this.artifact = await res.json(); - // Sync design metadata to other space members if (this.artifact?.id) this.saveDesignToSync(this.artifact.id); } catch (e) { this.error = e instanceof Error ? e.message : "Generation failed"; @@ -423,6 +509,125 @@ class FolkSwagDesigner extends HTMLElement { } } + // ── AI Generate ── + + private async aiGenerate() { + if (this.aiGenerating || !this.aiConcept || !this.aiName) return; + this.aiGenerating = true; + this.error = ""; + this.render(); + try { + const resp = await fetch(`${this.getApiBase()}/api/design/generate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + concept: this.aiConcept, + name: this.aiName, + tags: this.aiTags ? this.aiTags.split(",").map(t => t.trim()) : [], + product_type: "sticker", + }), + }); + if (!resp.ok) { + const err = await resp.json(); + throw new Error(err.error || `Failed: ${resp.status}`); + } + const result = await resp.json(); + this.error = ""; + this.aiConcept = ""; + this.aiName = ""; + this.aiTags = ""; + this.createMode = "designs"; + await this.loadMyDesigns(); + } catch (e) { + this.error = e instanceof Error ? e.message : "AI generation failed"; + } finally { + this.aiGenerating = false; + this.render(); + } + } + + // ── Upload ── + + private async uploadDesign() { + if (!this.uploadFile) return; + this.generating = true; + this.error = ""; + this.render(); + try { + const formData = new FormData(); + formData.append("image", this.uploadFile); + formData.append("name", this.uploadName || "Untitled Upload"); + formData.append("description", this.uploadDescription || ""); + formData.append("product_type", "sticker"); + const resp = await fetch(`${this.getApiBase()}/api/design/upload`, { method: "POST", body: formData }); + if (!resp.ok) { + const err = await resp.json(); + throw new Error(err.error || `Failed: ${resp.status}`); + } + this.uploadFile = null; + this.uploadPreview = ""; + this.uploadName = ""; + this.uploadDescription = ""; + this.createMode = "designs"; + await this.loadMyDesigns(); + } catch (e) { + this.error = e instanceof Error ? e.message : "Upload failed"; + } finally { + this.generating = false; + this.render(); + } + } + + // ── Dither ── + + private async applyDither() { + if (!this.ditherDesignSlug) return; + this.ditherLoading = true; + this.render(); + try { + const params = new URLSearchParams({ + algorithm: this.ditherAlgorithm, + num_colors: String(this.ditherNumColors), + format: "json", + }); + const resp = await fetch(`${this.getApiBase()}/api/designs/${this.ditherDesignSlug}/dither?${params}`); + if (resp.ok) { + const data = await resp.json(); + this.ditherPreviewUrl = data.image_url; + this.ditherColors = data.colors_used || []; + } + } catch (e) { + console.warn("[rSwag] Dither error:", e); + } + this.ditherLoading = false; + this.render(); + } + + private async generateSeparations() { + if (!this.ditherDesignSlug) return; + this.ditherLoading = true; + this.render(); + try { + const resp = await fetch(`${this.getApiBase()}/api/designs/${this.ditherDesignSlug}/screen-print`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + num_colors: this.ditherNumColors, + algorithm: this.ditherAlgorithm, + }), + }); + if (resp.ok) { + this.separationData = await resp.json(); + } + } catch (e) { + console.warn("[rSwag] Separation error:", e); + } + this.ditherLoading = false; + this.render(); + } + + // ── Render ── + private render() { if (this.space === "demo") { this.renderDemo(); @@ -432,11 +637,398 @@ class FolkSwagDesigner extends HTMLElement { this._tour.renderOverlay(); } - startTour() { - this._tour.start(); + startTour() { this._tour.start(); } + + // ──── Full mode (4-tab layout) ──── + + private renderFull() { + const isLive = this.lfClient?.isConnected ?? false; + + this.shadow.innerHTML = ` + + + ${isLive ? `
LIVE
` : ''} + + +
+ ${(["browse", "create", "dither", "orders"] as TabId[]).map(tab => ` + + `).join("")} +
+ + +
+ ${this.activeTab === "browse" ? this.renderBrowseTab() : ""} + ${this.activeTab === "create" ? this.renderCreateTab() : ""} + ${this.activeTab === "dither" ? this.renderDitherTab() : ""} + ${this.activeTab === "orders" ? this.renderOrdersTab() : ""} +
+ `; + + this.bindFullEvents(); } - // ---- Demo mode rendering (4-step flow) ---- + // ── Browse Tab ── + + private renderBrowseTab(): string { + return ` +
+ + +
+ + ${this.catalogLoading ? '
Loading catalog...
' : ''} + + ${this.catalogProducts.length === 0 && !this.catalogLoading ? ` +
+

No products yet. Switch to the Create tab to add designs!

+
` : ''} + +
+ ${this.catalogProducts.map(p => ` +
+
+
+
${this.esc(p.name)}
+
+ ${p.category} + $${p.basePrice.toFixed(2)} +
+ ${p.variants?.length ? `
${p.variants.map((v: string) => `${v}`).join("")}
` : ""} + +
+
`).join("")} +
`; + } + + // ── Create Tab ── + + private renderCreateTab(): string { + return ` +
+ + + +
+ + ${this.error ? `
${this.esc(this.error)}
` : ""} + + ${this.createMode === "ai" ? this.renderAiCreate() : ""} + ${this.createMode === "upload" ? this.renderUploadCreate() : ""} + ${this.createMode === "designs" ? this.renderMyDesigns() : ""} + `; + } + + private renderAiCreate(): string { + return ` +
+

AI Design Generator

+

Describe your design concept and let Gemini create it.

+ + + + +
`; + } + + private renderUploadCreate(): string { + return ` +
+

Upload Artwork

+

Upload PNG, JPEG, or WebP (min 500x500, max 10MB).

+
+ ${this.uploadPreview + ? `Preview` + : `
Click or drag to upload artwork
`} + +
+ + + +
`; + } + + private renderMyDesigns(): string { + return ` +
+

My Designs

+ ${this.myDesigns.length === 0 ? `

No designs yet. Use AI Generate or Upload to create one.

` : ""} +
+ ${this.myDesigns.map(d => ` +
+
+
+
${this.esc(d.name)}
+
+ ${d.status} + ${d.source} +
+
+ ${d.status === "draft" ? `` : ""} + ${d.status === "draft" ? `` : ""} + +
+
+
`).join("")} +
+
+ + ${this.sharedDesigns.length > 0 ? ` +
+

Space Designs (Multiplayer)

+
+ ${this.sharedDesigns.map(d => ` +
+
${this.productIcon(d.productType)}
+
+
${this.esc(d.title)}
+
${d.productType}${d.artifactId ? ' - ready' : ''}
+
+ ${d.artifactId ? `Download` : ''} + ${d.createdBy === getMyDid() ? `` : ''} +
+
+
`).join("")} +
+
` : ""}`; + } + + // ── HitherDither Tab ── + + private renderDitherTab(): string { + const designOptions = this.myDesigns.map(d => ``).join(""); + + return ` +
+

HitherDither

+

Apply dithering algorithms for screen printing and artistic effects.

+ +
+
+ + +
+
+ + +
+
+ + + ${this.ditherNumColors} +
+
+ +
+ + +
+ + ${this.ditherPreviewUrl ? ` +
+

Dithered Preview

+
+
+ Original + Original +
+
+
+ Dithered + ${this.ditherAlgorithm} +
+
+ ${this.ditherColors.length ? ` +
+ Palette: + ${this.ditherColors.map(c => ``).join("")} +
` : ""} + Download Dithered PNG +
` : ""} + + ${this.separationData ? ` +
+

Screen-Print Separations

+
+
+ Composite + Composite +
+ ${(this.separationData.colors || []).map((color: string) => ` +
+ ${color} + #${color} +
`).join("")} +
+
` : ""} +
`; + } + + // ── Orders Tab ── + + private renderOrdersTab(): string { + return ` +
+

Orders

+

Orders containing rSwag products are shown here via rCart.

+ ${this.orders.length === 0 ? ` +
+

No orders yet. Browse products and add them to your cart to get started.

+ +
` : ` +
+ ${this.orders.map(o => ` +
+
${o.id}
+
${o.status}
+ ${o.tracking ? `Track shipment` : ""} +
`).join("")} +
`} +
`; + } + + // ── Event binding (full mode) ── + + private bindFullEvents() { + // Tab switching + this.shadow.querySelectorAll(".tab-btn").forEach(btn => { + btn.addEventListener("click", () => { + this.activeTab = btn.dataset.tab as TabId; + this.render(); + }); + }); + + // Orders tab button in empty state + this.shadow.querySelectorAll("[data-tab]").forEach(btn => { + if (!btn.classList.contains("tab-btn")) { + btn.addEventListener("click", () => { + this.activeTab = btn.dataset.tab as TabId; + this.render(); + }); + } + }); + + // Browse tab + this.shadow.querySelector(".search-input")?.addEventListener("input", (e) => { + this.catalogSearch = (e.target as HTMLInputElement).value; + clearTimeout((this as any)._searchTimeout); + (this as any)._searchTimeout = setTimeout(() => this.loadCatalog(), 300); + }); + this.shadow.querySelector(".category-select")?.addEventListener("change", (e) => { + this.catalogCategory = (e.target as HTMLSelectElement).value; + this.loadCatalog(); + }); + this.shadow.querySelectorAll(".add-to-cart-btn").forEach(btn => { + btn.addEventListener("click", () => { + const slug = btn.dataset.slug; + // TODO: integrate with rCart catalog ingest + alert(`Added ${slug} to cart (rCart integration pending)`); + }); + }); + + // Create tab + this.shadow.querySelectorAll(".mode-btn").forEach(btn => { + btn.addEventListener("click", () => { + this.createMode = btn.dataset.mode as "ai" | "upload" | "designs"; + this.error = ""; + this.render(); + }); + }); + + // AI Generate + this.shadow.querySelector(".ai-name-input")?.addEventListener("input", (e) => { this.aiName = (e.target as HTMLInputElement).value; }); + this.shadow.querySelector(".ai-concept-input")?.addEventListener("input", (e) => { this.aiConcept = (e.target as HTMLTextAreaElement).value; }); + this.shadow.querySelector(".ai-tags-input")?.addEventListener("input", (e) => { this.aiTags = (e.target as HTMLInputElement).value; }); + this.shadow.querySelector(".ai-generate-btn")?.addEventListener("click", () => this.aiGenerate()); + + // Upload + const uploadArea = this.shadow.querySelector(".upload-area"); + const uploadInput = this.shadow.querySelector(".upload-file-input") as HTMLInputElement; + uploadArea?.addEventListener("click", () => uploadInput?.click()); + uploadInput?.addEventListener("change", () => { + const file = uploadInput.files?.[0]; + if (file) { + this.uploadFile = file; + this.uploadPreview = URL.createObjectURL(file); + this.render(); + } + }); + this.shadow.querySelector(".upload-name-input")?.addEventListener("input", (e) => { this.uploadName = (e.target as HTMLInputElement).value; }); + this.shadow.querySelector(".upload-desc-input")?.addEventListener("input", (e) => { this.uploadDescription = (e.target as HTMLInputElement).value; }); + this.shadow.querySelector(".upload-submit-btn")?.addEventListener("click", () => this.uploadDesign()); + + // My designs actions + this.shadow.querySelectorAll(".activate-btn").forEach(btn => { + btn.addEventListener("click", async () => { + const slug = btn.dataset.slug; + await fetch(`${this.getApiBase()}/api/design/${slug}/activate`, { method: "POST" }); + await this.loadMyDesigns(); + this.render(); + }); + }); + this.shadow.querySelectorAll(".delete-design-btn").forEach(btn => { + btn.addEventListener("click", async () => { + const slug = btn.dataset.slug; + if (!confirm(`Delete design "${slug}"?`)) return; + await fetch(`${this.getApiBase()}/api/design/${slug}`, { method: "DELETE" }); + await this.loadMyDesigns(); + this.render(); + }); + }); + this.shadow.querySelectorAll(".dither-btn").forEach(btn => { + btn.addEventListener("click", () => { + this.ditherDesignSlug = btn.dataset.slug || ""; + this.activeTab = "dither"; + this.render(); + }); + }); + this.shadow.querySelectorAll(".shared-delete-btn").forEach(btn => { + btn.addEventListener("click", () => { + const id = btn.dataset.id; + if (id) this.deleteSharedDesign(id); + }); + }); + + // Dither tab + this.shadow.querySelector(".dither-design-select")?.addEventListener("change", (e) => { + this.ditherDesignSlug = (e.target as HTMLSelectElement).value; + this.ditherPreviewUrl = ""; + this.separationData = null; + this.render(); + }); + this.shadow.querySelector(".dither-algo-select")?.addEventListener("change", (e) => { + this.ditherAlgorithm = (e.target as HTMLSelectElement).value; + }); + this.shadow.querySelector(".dither-colors-range")?.addEventListener("input", (e) => { + this.ditherNumColors = parseInt((e.target as HTMLInputElement).value, 10); + const span = this.shadow.querySelector(".range-value"); + if (span) span.textContent = String(this.ditherNumColors); + }); + this.shadow.querySelector(".apply-dither-btn")?.addEventListener("click", () => this.applyDither()); + this.shadow.querySelector(".generate-separations-btn")?.addEventListener("click", () => this.generateSeparations()); + } + + // ──── Demo mode rendering (preserved from original) ──── private renderDemo() { const p = this.getDemoProduct(); @@ -491,7 +1083,7 @@ class FolkSwagDesigner extends HTMLElement { ` : ""} - +
${this.getMockupSvg()}
@@ -503,16 +1095,12 @@ class FolkSwagDesigner extends HTMLElement {
- ${this.usedSampleDesign && this.demoStep < 3 ? `
` : ""} - ${this.demoStep >= 3 ? this.renderStep3() : ""} - - ${this.demoStep >= 4 ? this.renderStep4() : ""} `; @@ -521,18 +1109,10 @@ class FolkSwagDesigner extends HTMLElement { private renderStep3(): string { const progressLabels = ["Processing image...", "Generating artifact...", "Matching providers...", "Done!"]; - let html = `
`; + html += `
+
${progressLabels.map((label, i) => `${this.progressStep > i + 1 ? '✓ ' : ''}${label}`).join("")}
`; - // Progress bar (always show) - html += `
-
-
-
- ${progressLabels.map((label, i) => `${this.progressStep > i + 1 ? '✓ ' : ''}${label}`).join("")} -
`; - - // Only show artifact + providers once done if (this.demoStep >= 4 && this.artifact) { const providers = this.getMatchedProviders(); const selectedProvider = providers[0]; @@ -541,34 +1121,26 @@ class FolkSwagDesigner extends HTMLElement { const creatorAmt = (unitCost * 0.35).toFixed(2); const communityAmt = (unitCost * 0.15).toFixed(2); - // Artifact card html += `

Artifact Envelope

Product${this.esc(this.artifact.spec.product_type)}
-
Dimensions${this.artifact.spec.dimensions.width_mm}×${this.artifact.spec.dimensions.height_mm}mm
+
Dimensions${this.artifact.spec.dimensions.width_mm}x${this.artifact.spec.dimensions.height_mm}mm
DPI${this.artifact.spec.dpi}
Color Space${this.artifact.spec.color_space}
Capabilities${(this.artifact.spec.required_capabilities || []).join(", ")}
Substrates${(this.artifact.spec.substrates || []).join(", ")}
-
- ${Object.values(this.artifact.render_targets).map((t: any) => `${t.label}: ${t.dimensions}`).join("")} -
-
- ${(this.artifact.next_actions || []).map((a: any) => `${a.label}`).join("")} -
+
${Object.values(this.artifact.render_targets).map((t: any) => `${t.label}: ${t.dimensions}`).join("")}
+
${(this.artifact.next_actions || []).map((a: any) => `${a.label}`).join("")}
`; - // Provider match table html += `

Provider Matching (buyer: Berlin)

-
- ProviderTypeCityDistanceCostTurnaround -
+
ProviderTypeCityDistanceCostTurnaround
${providers.map((prov, i) => `
${prov.name} @@ -581,7 +1153,6 @@ class FolkSwagDesigner extends HTMLElement {
`; - // Revenue split bar html += `

Revenue Split (from $${unitCost.toFixed(2)} unit cost)

@@ -593,7 +1164,6 @@ class FolkSwagDesigner extends HTMLElement {
Community 15%
`; } - html += `
`; return html; } @@ -644,213 +1214,163 @@ class FolkSwagDesigner extends HTMLElement { private bindDemoEvents() { this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour()); - - // Product selection this.shadow.querySelectorAll(".product").forEach(el => { el.addEventListener("click", () => this.demoSelectProduct((el as HTMLElement).dataset.product || "tee")); }); - // Size pills this.shadow.querySelectorAll(".pill[data-size]").forEach(el => { el.addEventListener("click", () => { this.selectedSize = (el as HTMLElement).dataset.size || "M"; this.render(); }); }); - // Color swatches this.shadow.querySelectorAll(".swatch[data-color]").forEach(el => { el.addEventListener("click", () => { this.selectedColor = (el as HTMLElement).dataset.color || "black"; this.render(); }); }); - // Sample design button this.shadow.querySelector(".sample-btn")?.addEventListener("click", () => this.demoUseSample()); - // Generate button this.shadow.querySelector(".generate-btn")?.addEventListener("click", () => this.demoGenerate()); - // JSON toggle this.shadow.querySelector(".json-toggle-btn")?.addEventListener("click", () => { - const pre = this.shadow.querySelector(".json-pre"); - pre?.classList.toggle("visible"); + this.shadow.querySelector(".json-pre")?.classList.toggle("visible"); }); } - // ---- Full (non-demo) rendering ---- + // ── Styles ── - private renderFull() { - const products = [ - { id: "sticker", name: "Sticker Sheet", icon: "📋", desc: "A4 vinyl stickers" }, - { id: "poster", name: "Poster (A3)", icon: "🖼", desc: "A3 art print" }, - { id: "tee", name: "T-Shirt", icon: "👕", desc: '12x16" DTG print' }, - { id: "hoodie", name: "Hoodie", icon: "🧥", desc: '14x16" DTG print' }, - ]; + private getFullStyles(): string { + return ` + :host { display: block; padding: 1.5rem; max-width: 960px; margin: 0 auto; } + *, *::before, *::after { box-sizing: border-box; } - const isLive = this.lfClient?.isConnected ?? false; - const productIcons: Record = { sticker: '📋', poster: '🖼', tee: '👕', hoodie: '🧥' }; - - this.shadow.innerHTML = ` - - - ${isLive ? `
LIVE
` : ''} - - ${this.sharedDesigns.length > 0 ? ` - -
- ${this.sharedDesigns.map(d => ` -
-
${productIcons[d.productType] || '📋'}
-
${this.esc(d.title)}
-
${d.productType}${d.artifactId ? ' • ready' : ''}
-
- ${d.artifactId ? `Download` : ''} - ${d.createdBy === getMyDid() ? `` : ''} -
-
`).join('')} -
-
- ` : ''} - - - -
- ${products.map((p) => ` -
-
${p.icon}
-
${p.name}
-
${p.desc}
-
- `).join("")} -
- -
- ${this.imagePreview - ? `Preview` - : `
Click or drag to upload artwork (PNG, JPG, SVG)
`} - -
- - - - - - ${this.error ? `
${this.esc(this.error)}
` : ""} - - ${this.artifact ? ` -
-

${this.esc(this.artifact.payload?.title || this.artifact.title || "Artifact")}

-
- ${this.esc(this.artifact.spec?.product_type || "")} • - ${this.artifact.spec?.dimensions?.width_mm}x${this.artifact.spec?.dimensions?.height_mm}mm • - ${this.artifact.spec?.dpi}dpi -
-
- ${Object.entries(this.artifact.render_targets || {}).map(([key, target]: [string, any]) => ` - Download ${target.format.toUpperCase()} - `).join("")} - -
- Show artifact envelope -
${this.esc(JSON.stringify(this.artifact, null, 2))}
-
` : ""} `; - - // Event listeners - this.shadow.querySelectorAll(".product").forEach((el) => { - el.addEventListener("click", () => { - this.selectedProduct = (el as HTMLElement).dataset.product || "sticker"; - this.render(); - }); - }); - - const uploadArea = this.shadow.querySelector(".upload-area"); - const fileInput = this.shadow.querySelector('input[type="file"]') as HTMLInputElement; - uploadArea?.addEventListener("click", () => fileInput?.click()); - fileInput?.addEventListener("change", () => { - const file = fileInput.files?.[0]; - if (file) { - this.imageFile = file; - this.imagePreview = URL.createObjectURL(file); - this.render(); - } - }); - - this.shadow.querySelector(".title-input")?.addEventListener("input", (e) => { - this.designTitle = (e.target as HTMLInputElement).value; - }); - - this.shadow.querySelector(".generate-btn")?.addEventListener("click", () => this.generate()); - - this.shadow.querySelector(".json-toggle")?.addEventListener("click", () => { - const pre = this.shadow.querySelector(".json-pre"); - pre?.classList.toggle("visible"); - }); - - this.shadow.querySelector('[data-action="copy-json"]')?.addEventListener("click", () => { - navigator.clipboard.writeText(JSON.stringify(this.artifact, null, 2)); - }); - - // Shared design delete buttons - this.shadow.querySelectorAll('[data-delete-design]').forEach(btn => { - btn.addEventListener('click', () => { - const id = btn.dataset.deleteDesign; - if (id) this.deleteSharedDesign(id); - }); - }); } private getDemoStyles(): string { return ` :host { display: block; padding: 1.5rem; max-width: 960px; margin: 0 auto; } *, *::before, *::after { box-sizing: border-box; } - - /* Step bar */ .steps-bar { display: flex; align-items: center; justify-content: center; gap: 0; margin-bottom: 2rem; } .step-dot { display: flex; flex-direction: column; align-items: center; gap: 0.25rem; } .step-num { width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 0.75rem; font-weight: 600; border: 2px solid var(--rs-border); color: var(--rs-text-muted); background: var(--rs-bg-surface); transition: all 0.2s; } @@ -859,8 +1379,6 @@ class FolkSwagDesigner extends HTMLElement { .step-label { font-size: 0.6875rem; color: var(--rs-text-muted); } .step-dot.active .step-label { color: #a5b4fc; } .step-line { width: 40px; height: 2px; background: var(--rs-border); margin: 0 0.5rem; margin-bottom: 1rem; } - - /* Product grid */ .products { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.75rem; margin-bottom: 1.5rem; } .product { padding: 0.875rem 0.5rem; border-radius: 12px; border: 2px solid var(--rs-border); background: var(--rs-bg-surface); cursor: pointer; text-align: center; transition: all 0.15s; } .product:hover { border-color: var(--rs-border-strong); } @@ -869,16 +1387,12 @@ class FolkSwagDesigner extends HTMLElement { .product-name { color: var(--rs-text-primary); font-weight: 600; font-size: 0.8125rem; } .product-specs { color: var(--rs-text-muted); font-size: 0.6875rem; margin-top: 0.125rem; } .product-cost { color: var(--rs-text-secondary); font-size: 0.75rem; margin-top: 0.25rem; } - - /* Badges */ .badge { display: inline-block; padding: 0.125rem 0.5rem; border-radius: 999px; font-size: 0.625rem; font-weight: 600; margin-top: 0.375rem; } .badge-printful { background: rgba(56,189,248,0.15); color: #38bdf8; } .badge-local { background: rgba(34,197,94,0.15); color: #4ade80; } .badge-cosmo { background: rgba(34,197,94,0.15); color: #4ade80; } .badge-global { background: rgba(56,189,248,0.15); color: #38bdf8; } .badge-ok { background: rgba(34,197,94,0.15); color: #4ade80; padding: 0.25rem 0.75rem; font-size: 0.75rem; } - - /* Options */ .option-row { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.75rem; } .option-label { color: var(--rs-text-secondary); font-size: 0.8125rem; font-weight: 500; min-width: 40px; } .pills { display: flex; gap: 0.375rem; flex-wrap: wrap; } @@ -889,36 +1403,26 @@ class FolkSwagDesigner extends HTMLElement { .swatch { width: 28px; height: 28px; border-radius: 50%; border: 2px solid var(--rs-border); cursor: pointer; transition: all 0.15s; } .swatch:hover { border-color: var(--rs-border-strong); } .swatch.active { border-color: var(--rs-primary); box-shadow: 0 0 0 3px rgba(99,102,241,0.3); } - - /* Mockup */ .mockup-area { display: flex; gap: 1.5rem; align-items: center; background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 12px; padding: 1.5rem; margin-bottom: 1.5rem; } .mockup-svg { flex-shrink: 0; } .mockup-svg svg { width: 180px; height: auto; } .mockup-info { flex: 1; } .mockup-title { color: var(--rs-text-primary); font-weight: 600; font-size: 1rem; margin: 0 0 0.25rem; } .mockup-meta { color: var(--rs-text-secondary); font-size: 0.8125rem; margin-bottom: 0.75rem; } - - /* Buttons */ - .btn { padding: 0.625rem 1.25rem; border-radius: 8px; font-size: 0.875rem; font-weight: 600; cursor: pointer; border: none; text-decoration: none; display: inline-block; transition: all 0.15s; } + .btn { padding: 0.625rem 1.25rem; border-radius: 8px; font-size: 0.875rem; font-weight: 600; cursor: pointer; border: none; text-decoration: none; display: inline-block; transition: all 0.15s; font-family: inherit; } .btn-primary { background: var(--rs-primary-hover); color: #fff; } .btn-primary:hover { background: #4338ca; } .btn-secondary { background: var(--rs-bg-surface-raised); color: var(--rs-text-primary); } .btn-secondary:hover { background: var(--rs-bg-hover); } .generate-row { text-align: center; margin-bottom: 1.5rem; } - - /* Step sections */ .step-section { margin-bottom: 1.5rem; } .step-section:not(.visible) { display: none; } - - /* Progress bar */ .progress-bar { height: 6px; background: var(--rs-bg-surface); border-radius: 3px; overflow: hidden; margin-bottom: 0.5rem; } .progress-fill { height: 100%; background: linear-gradient(90deg, var(--rs-primary), var(--rs-primary-hover)); border-radius: 3px; transition: width 0.3s ease; } .progress-steps { display: flex; justify-content: space-between; margin-bottom: 1.5rem; } .prog-label { font-size: 0.6875rem; color: var(--rs-text-muted); transition: color 0.2s; } .prog-label.active { color: var(--rs-primary-hover); font-weight: 600; } .prog-label.done { color: #4ade80; } - - /* Artifact card */ .artifact-card { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 12px; padding: 1.25rem; margin-bottom: 1rem; } .artifact-heading { color: var(--rs-text-primary); font-weight: 600; font-size: 0.9375rem; margin: 0 0 0.75rem; } .artifact-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.5rem 1rem; margin-bottom: 0.75rem; } @@ -929,8 +1433,6 @@ class FolkSwagDesigner extends HTMLElement { .target-chip { background: rgba(99,102,241,0.1); color: #a5b4fc; padding: 0.25rem 0.625rem; border-radius: 6px; font-size: 0.6875rem; } .artifact-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; } .action-chip { background: var(--rs-bg-page); color: var(--rs-text-secondary); padding: 0.25rem 0.625rem; border-radius: 6px; font-size: 0.6875rem; border: 1px solid var(--rs-border); } - - /* Provider table */ .provider-section { margin-bottom: 1rem; } .provider-heading { color: var(--rs-text-primary); font-weight: 600; font-size: 0.9375rem; margin: 0 0 0.75rem; } .buyer-loc { color: var(--rs-text-muted); font-weight: 400; font-size: 0.75rem; } @@ -941,8 +1443,6 @@ class FolkSwagDesigner extends HTMLElement { .pt-row.nearest { background: rgba(99,102,241,0.05); border-left: 3px solid var(--rs-primary); } .pt-name { font-weight: 500; } .pt-cost { color: #4ade80; font-weight: 600; } - - /* Split bar */ .split-section { margin-bottom: 1.5rem; } .split-heading { color: var(--rs-text-secondary); font-size: 0.8125rem; font-weight: 500; margin: 0 0 0.5rem; } .split-total { color: var(--rs-text-muted); font-weight: 400; } @@ -954,8 +1454,6 @@ class FolkSwagDesigner extends HTMLElement { .split-amt { opacity: 0.85; } .split-legend { text-align: right; margin-top: 0.25rem; } .split-legend span { color: var(--rs-text-muted); font-size: 0.6875rem; } - - /* Pipeline */ .pipeline-heading { color: var(--rs-text-primary); font-weight: 600; font-size: 0.9375rem; margin: 0 0 1rem; text-align: center; } .pipeline { display: flex; align-items: center; justify-content: center; gap: 0; flex-wrap: wrap; margin-bottom: 1.5rem; } .pipe-node { display: flex; flex-direction: column; align-items: center; gap: 0.25rem; padding: 0.75rem; background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 10px; position: relative; min-width: 90px; } @@ -964,14 +1462,9 @@ class FolkSwagDesigner extends HTMLElement { .pipe-label { color: var(--rs-text-primary); font-size: 0.6875rem; font-weight: 500; text-align: center; } .pipe-check { position: absolute; top: -6px; right: -6px; background: #16a34a; color: #fff; width: 16px; height: 16px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 0.5625rem; } .pipe-arrow { color: var(--rs-text-muted); font-size: 1.25rem; margin: 0 0.25rem; margin-bottom: 0.75rem; } - - /* Pipeline actions */ .pipeline-actions { display: flex; gap: 0.75rem; justify-content: center; margin-bottom: 1rem; } - - /* JSON */ .json-pre { background: var(--rs-bg-page); border: 1px solid var(--rs-border); border-radius: 8px; padding: 0.75rem; overflow-x: auto; font-size: 0.6875rem; color: var(--rs-text-secondary); max-height: 300px; display: none; white-space: pre-wrap; word-break: break-all; } .json-pre.visible { display: block; } - @media (max-width: 768px) { .products { grid-template-columns: repeat(2, 1fr); } .mockup-area { flex-direction: column; text-align: center; } diff --git a/modules/rswag/components/swag.css b/modules/rswag/components/swag.css index 6ba67bd..0d179a4 100644 --- a/modules/rswag/components/swag.css +++ b/modules/rswag/components/swag.css @@ -3,3 +3,9 @@ main { min-height: calc(100vh - 56px); padding: 0; } + +/* Ensure the Sankey component renders properly on landing page */ +folk-revenue-sankey { + display: block; + margin-top: 1rem; +} diff --git a/modules/rswag/dither.ts b/modules/rswag/dither.ts new file mode 100644 index 0000000..ee8c431 --- /dev/null +++ b/modules/rswag/dither.ts @@ -0,0 +1,575 @@ +/** + * Dithering engine — palette building, dithering algorithms, and color separations. + * + * Ported from Python hitherdither-based service to pure TypeScript with Sharp. + * Supports 11 algorithms (8 error diffusion + 3 ordered). + */ + +import sharp from "sharp"; + +// ── Types ── + +export type DitherAlgorithm = + | "floyd-steinberg" + | "atkinson" + | "stucki" + | "burkes" + | "sierra" + | "sierra-two-row" + | "sierra-lite" + | "jarvis-judice-ninke" + | "bayer" + | "ordered" + | "cluster-dot"; + +export type PaletteMode = "auto" | "grayscale" | "spot" | "custom"; + +export interface DitherOptions { + algorithm?: DitherAlgorithm; + paletteMode?: PaletteMode; + numColors?: number; + customColors?: string[]; + threshold?: number; + order?: number; +} + +export interface DitherResult { + buffer: Buffer; + algorithm: DitherAlgorithm; + paletteMode: PaletteMode; + numColors: number; + colorsUsed: string[]; + cached: boolean; +} + +export interface SeparationResult { + composite: Buffer; + separations: Map; + colors: string[]; +} + +// ── Constants ── + +const MAX_DITHER_DIM = 512; +const MAX_CACHE = 200; + +// ── Cache ── + +const ditherCache = new Map }>(); +const separationCache = new Map(); + +function cacheKey(...parts: (string | number)[]): string { + // Simple hash via string joining (no crypto needed for cache keys) + return parts.join("|"); +} + +function evict(cache: Map, maxSize = MAX_CACHE) { + if (cache.size <= maxSize) return; + const iter = cache.keys(); + while (cache.size > maxSize) { + const next = iter.next(); + if (next.done) break; + cache.delete(next.value); + } +} + +// ── Color helpers ── + +function hexToRgb(hex: string): [number, number, number] { + const h = hex.replace(/^#/, ""); + return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; +} + +function rgbToHex(r: number, g: number, b: number): string { + return `${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`.toUpperCase(); +} + +function colorDistance(r1: number, g1: number, b1: number, r2: number, g2: number, b2: number): number { + return (r1 - r2) ** 2 + (g1 - g2) ** 2 + (b1 - b2) ** 2; +} + +function nearestColor(r: number, g: number, b: number, palette: [number, number, number][]): number { + let minDist = Infinity; + let idx = 0; + for (let i = 0; i < palette.length; i++) { + const d = colorDistance(r, g, b, palette[i][0], palette[i][1], palette[i][2]); + if (d < minDist) { + minDist = d; + idx = i; + } + } + return idx; +} + +// ── Palette building ── + +/** + * Build a color palette from an image using median-cut quantization, + * grayscale ramp, or custom colors. + */ +export async function buildPalette( + inputBuffer: Buffer, + mode: PaletteMode, + numColors = 8, + customColors?: string[], +): Promise<[number, number, number][]> { + if (mode === "custom" && customColors?.length) { + return customColors.map(hexToRgb); + } + + if (mode === "grayscale") { + const step = Math.floor(255 / Math.max(numColors - 1, 1)); + return Array.from({ length: numColors }, (_, i) => { + const v = Math.min(i * step, 255); + return [v, v, v] as [number, number, number]; + }); + } + + if (mode === "spot" && customColors?.length) { + return customColors.slice(0, numColors).map(hexToRgb); + } + + // Auto mode: extract dominant colors via Sharp + quantization + const { data, info } = await sharp(inputBuffer) + .resize(64, 64, { fit: "cover" }) + .removeAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + + // Simple median-cut quantization + const pixels: [number, number, number][] = []; + for (let i = 0; i < data.length; i += 3) { + pixels.push([data[i], data[i + 1], data[i + 2]]); + } + + return medianCut(pixels, numColors); +} + +/** Simple median-cut quantization. */ +function medianCut(pixels: [number, number, number][], numColors: number): [number, number, number][] { + type Box = [number, number, number][]; + + const boxes: Box[] = [pixels]; + + while (boxes.length < numColors) { + // Find the box with the largest range + let maxRange = -1; + let maxIdx = 0; + let splitChannel = 0; + + for (let i = 0; i < boxes.length; i++) { + const box = boxes[i]; + for (let ch = 0; ch < 3; ch++) { + let min = 255, max = 0; + for (const p of box) { + if (p[ch] < min) min = p[ch]; + if (p[ch] > max) max = p[ch]; + } + const range = max - min; + if (range > maxRange) { + maxRange = range; + maxIdx = i; + splitChannel = ch; + } + } + } + + if (maxRange <= 0) break; + + const box = boxes.splice(maxIdx, 1)[0]; + box.sort((a, b) => a[splitChannel] - b[splitChannel]); + const mid = Math.floor(box.length / 2); + boxes.push(box.slice(0, mid), box.slice(mid)); + } + + // Average each box to get palette colors + return boxes.map((box) => { + if (box.length === 0) return [0, 0, 0] as [number, number, number]; + let rSum = 0, gSum = 0, bSum = 0; + for (const [r, g, b] of box) { + rSum += r; gSum += g; bSum += b; + } + const n = box.length; + return [Math.round(rSum / n), Math.round(gSum / n), Math.round(bSum / n)] as [number, number, number]; + }); +} + +// ── Error diffusion kernels ── + +type DiffusionKernel = { offsets: [number, number, number][] }; // [dx, dy, weight] + +const KERNELS: Record = { + "floyd-steinberg": { + offsets: [[1, 0, 7 / 16], [-1, 1, 3 / 16], [0, 1, 5 / 16], [1, 1, 1 / 16]], + }, + atkinson: { + offsets: [ + [1, 0, 1 / 8], [2, 0, 1 / 8], + [-1, 1, 1 / 8], [0, 1, 1 / 8], [1, 1, 1 / 8], + [0, 2, 1 / 8], + ], + }, + stucki: { + offsets: [ + [1, 0, 8 / 42], [2, 0, 4 / 42], + [-2, 1, 2 / 42], [-1, 1, 4 / 42], [0, 1, 8 / 42], [1, 1, 4 / 42], [2, 1, 2 / 42], + [-2, 2, 1 / 42], [-1, 2, 2 / 42], [0, 2, 4 / 42], [1, 2, 2 / 42], [2, 2, 1 / 42], + ], + }, + burkes: { + offsets: [ + [1, 0, 8 / 32], [2, 0, 4 / 32], + [-2, 1, 2 / 32], [-1, 1, 4 / 32], [0, 1, 8 / 32], [1, 1, 4 / 32], [2, 1, 2 / 32], + ], + }, + sierra: { + offsets: [ + [1, 0, 5 / 32], [2, 0, 3 / 32], + [-2, 1, 2 / 32], [-1, 1, 4 / 32], [0, 1, 5 / 32], [1, 1, 4 / 32], [2, 1, 2 / 32], + [-1, 2, 2 / 32], [0, 2, 3 / 32], [1, 2, 2 / 32], + ], + }, + "sierra-two-row": { + offsets: [ + [1, 0, 4 / 16], [2, 0, 3 / 16], + [-2, 1, 1 / 16], [-1, 1, 2 / 16], [0, 1, 3 / 16], [1, 1, 2 / 16], [2, 1, 1 / 16], + ], + }, + "sierra-lite": { + offsets: [[1, 0, 2 / 4], [0, 1, 1 / 4], [-1, 1, 1 / 4]], + }, + "jarvis-judice-ninke": { + offsets: [ + [1, 0, 7 / 48], [2, 0, 5 / 48], + [-2, 1, 3 / 48], [-1, 1, 5 / 48], [0, 1, 7 / 48], [1, 1, 5 / 48], [2, 1, 3 / 48], + [-2, 2, 1 / 48], [-1, 2, 3 / 48], [0, 2, 5 / 48], [1, 2, 3 / 48], [2, 2, 1 / 48], + ], + }, +}; + +// ── Dithering algorithms ── + +function errorDiffusionDither( + data: Uint8Array, + width: number, + height: number, + palette: [number, number, number][], + kernel: DiffusionKernel, +): Uint8Array { + // Work with float copy to handle error accumulation + const pixels = new Float32Array(data.length); + for (let i = 0; i < data.length; i++) pixels[i] = data[i]; + + const output = new Uint8Array(data.length); + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = (y * width + x) * 3; + const r = Math.max(0, Math.min(255, Math.round(pixels[idx]))); + const g = Math.max(0, Math.min(255, Math.round(pixels[idx + 1]))); + const b = Math.max(0, Math.min(255, Math.round(pixels[idx + 2]))); + + const nearest = nearestColor(r, g, b, palette); + const [nr, ng, nb] = palette[nearest]; + + output[idx] = nr; + output[idx + 1] = ng; + output[idx + 2] = nb; + + const errR = r - nr; + const errG = g - ng; + const errB = b - nb; + + for (const [dx, dy, weight] of kernel.offsets) { + const nx = x + dx; + const ny = y + dy; + if (nx >= 0 && nx < width && ny >= 0 && ny < height) { + const nIdx = (ny * width + nx) * 3; + pixels[nIdx] += errR * weight; + pixels[nIdx + 1] += errG * weight; + pixels[nIdx + 2] += errB * weight; + } + } + } + } + + return output; +} + +function bayerDither( + data: Uint8Array, + width: number, + height: number, + palette: [number, number, number][], + threshold: number, + order: number, +): Uint8Array { + const matrix = generateBayerMatrix(order); + const matrixSize = matrix.length; + const output = new Uint8Array(data.length); + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = (y * width + x) * 3; + const bayerVal = matrix[y % matrixSize][x % matrixSize]; + const factor = (bayerVal - 0.5) * threshold; + + const r = Math.max(0, Math.min(255, Math.round(data[idx] + factor))); + const g = Math.max(0, Math.min(255, Math.round(data[idx + 1] + factor))); + const b = Math.max(0, Math.min(255, Math.round(data[idx + 2] + factor))); + + const nearest = nearestColor(r, g, b, palette); + output[idx] = palette[nearest][0]; + output[idx + 1] = palette[nearest][1]; + output[idx + 2] = palette[nearest][2]; + } + } + + return output; +} + +function generateBayerMatrix(order: number): number[][] { + if (order <= 1) return [[0]]; + + const size = 1 << order; // 2^order + const matrix: number[][] = Array.from({ length: size }, () => new Array(size)); + + // Generate recursively + const base = [[0, 2], [3, 1]]; + + function fill(mat: number[][], size: number): void { + if (size === 2) { + for (let y = 0; y < 2; y++) + for (let x = 0; x < 2; x++) + mat[y][x] = base[y][x]; + return; + } + + const half = size / 2; + const sub: number[][] = Array.from({ length: half }, () => new Array(half)); + fill(sub, half); + + for (let y = 0; y < half; y++) { + for (let x = 0; x < half; x++) { + const val = sub[y][x] * 4; + mat[y][x] = val; + mat[y][x + half] = val + 2; + mat[y + half][x] = val + 3; + mat[y + half][x + half] = val + 1; + } + } + } + + fill(matrix, size); + + // Normalize to [0, 1] + const total = size * size; + for (let y = 0; y < size; y++) + for (let x = 0; x < size; x++) + matrix[y][x] = (matrix[y][x] + 0.5) / total; + + return matrix; +} + +function clusterDotDither( + data: Uint8Array, + width: number, + height: number, + palette: [number, number, number][], + threshold: number, +): Uint8Array { + // 4x4 cluster-dot pattern + const cluster = [ + [12, 5, 6, 13], + [4, 0, 1, 7], + [11, 3, 2, 8], + [15, 10, 9, 14], + ]; + const output = new Uint8Array(data.length); + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = (y * width + x) * 3; + const cVal = (cluster[y % 4][x % 4] / 16 - 0.5) * threshold; + + const r = Math.max(0, Math.min(255, Math.round(data[idx] + cVal))); + const g = Math.max(0, Math.min(255, Math.round(data[idx + 1] + cVal))); + const b = Math.max(0, Math.min(255, Math.round(data[idx + 2] + cVal))); + + const nearest = nearestColor(r, g, b, palette); + output[idx] = palette[nearest][0]; + output[idx + 1] = palette[nearest][1]; + output[idx + 2] = palette[nearest][2]; + } + } + + return output; +} + +// ── Main API ── + +/** + * Dither a design image with the specified algorithm and palette. + * Results are cached by slug+params combination. + */ +export async function ditherDesign( + inputBuffer: Buffer, + slug: string, + options: DitherOptions = {}, +): Promise { + const { + algorithm = "floyd-steinberg", + paletteMode = "auto", + numColors = 8, + customColors, + threshold = 64, + order = 3, + } = options; + + const key = cacheKey(slug, algorithm, paletteMode, numColors, (customColors || []).join(","), threshold, order); + + const cached = ditherCache.get(key); + if (cached) { + return { ...cached.meta, buffer: cached.buffer, cached: true }; + } + + // Downscale for performance + const resized = await sharp(inputBuffer) + .resize(MAX_DITHER_DIM, MAX_DITHER_DIM, { fit: "inside", withoutEnlargement: true }) + .removeAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + + const { data, info } = resized; + const { width, height } = info; + + // Build palette + const palette = await buildPalette(inputBuffer, paletteMode, numColors, customColors); + + // Apply dithering + let dithered: Uint8Array; + + if (algorithm === "bayer" || algorithm === "ordered") { + dithered = bayerDither(new Uint8Array(data), width, height, palette, threshold, order); + } else if (algorithm === "cluster-dot") { + dithered = clusterDotDither(new Uint8Array(data), width, height, palette, threshold); + } else { + const kernel = KERNELS[algorithm] || KERNELS["floyd-steinberg"]; + dithered = errorDiffusionDither(new Uint8Array(data), width, height, palette, kernel); + } + + // Convert back to PNG + const buffer = await sharp(Buffer.from(dithered), { + raw: { width, height, channels: 3 }, + }) + .png({ compressionLevel: 9 }) + .toBuffer(); + + const colorsUsed = palette.map(([r, g, b]) => rgbToHex(r, g, b)); + + const meta = { algorithm, paletteMode, numColors: colorsUsed.length, colorsUsed }; + ditherCache.set(key, { buffer, meta }); + evict(ditherCache); + + return { ...meta, buffer, cached: false }; +} + +/** + * Generate color separations for screen printing. + * Returns composite dithered image + individual per-color separation PNGs. + */ +export async function generateColorSeparations( + inputBuffer: Buffer, + slug: string, + numColors = 4, + algorithm: DitherAlgorithm = "floyd-steinberg", + spotColors?: string[], +): Promise { + const paletteMode: PaletteMode = spotColors?.length ? "spot" : "auto"; + const key = cacheKey("sep", slug, numColors, algorithm, (spotColors || []).join(",")); + + const cached = separationCache.get(key); + if (cached) return cached; + + // First dither the image + const ditherResult = await ditherDesign(inputBuffer, `${slug}-sep`, { + algorithm, + paletteMode, + numColors, + customColors: spotColors, + }); + + const composite = ditherResult.buffer; + + // Get raw pixel data from the dithered result + const { data, info } = await sharp(composite) + .removeAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + + const { width, height } = info; + const palette = ditherResult.colorsUsed.map(hexToRgb); + const separations = new Map(); + + // Generate per-color separation images + for (let pi = 0; pi < palette.length; pi++) { + const [pr, pg, pb] = palette[pi]; + const hexColor = ditherResult.colorsUsed[pi]; + const sepData = new Uint8Array(width * height * 3); + + // White background + sepData.fill(255); + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = (y * width + x) * 3; + const r = data[idx], g = data[idx + 1], b = data[idx + 2]; + + // Match with tolerance + if ( + Math.abs(r - pr) < 16 && + Math.abs(g - pg) < 16 && + Math.abs(b - pb) < 16 + ) { + sepData[idx] = pr; + sepData[idx + 1] = pg; + sepData[idx + 2] = pb; + } + } + } + + const sepBuffer = await sharp(Buffer.from(sepData), { + raw: { width, height, channels: 3 }, + }) + .png({ compressionLevel: 9 }) + .toBuffer(); + + separations.set(hexColor, sepBuffer); + } + + const result: SeparationResult = { + composite, + separations, + colors: ditherResult.colorsUsed, + }; + + separationCache.set(key, result); + evict(separationCache); + + return result; +} + +/** List all supported dithering algorithms. */ +export const ALGORITHMS: DitherAlgorithm[] = [ + "floyd-steinberg", + "atkinson", + "stucki", + "burkes", + "sierra", + "sierra-two-row", + "sierra-lite", + "jarvis-judice-ninke", + "bayer", + "ordered", + "cluster-dot", +]; diff --git a/modules/rswag/fulfillment.ts b/modules/rswag/fulfillment.ts new file mode 100644 index 0000000..f918870 --- /dev/null +++ b/modules/rswag/fulfillment.ts @@ -0,0 +1,305 @@ +/** + * Order fulfillment service — routes rCart orders to POD providers. + * + * Flow: + * 1. rCart order → paid status + * 2. This service picks up the order and routes to correct POD provider + * 3. POD webhooks update tracking → pushed back to rCart order doc + */ + +import { PrintfulClient } from "./pod/printful"; +import { ProdigiClient } from "./pod/prodigi"; +import type { PodOrder, PodRecipient, PodTracking } from "./pod/types"; + +// ── Types ── + +export interface FulfillmentRequest { + /** rCart order ID */ + orderId: string; + /** Items to fulfill */ + items: FulfillmentItem[]; + /** Shipping address */ + recipient: PodRecipient; + /** Optional metadata */ + metadata?: Record; +} + +export interface FulfillmentItem { + /** Design image public URL */ + imageUrl: string; + /** POD provider to use */ + provider: "printful" | "prodigi"; + /** Provider-specific SKU */ + sku: string; + /** Quantity */ + quantity: number; + /** Size variant (for apparel) */ + size?: string; + /** Color variant (for apparel) */ + color?: string; + /** Placement on product */ + placement?: string; +} + +export interface FulfillmentResult { + orderId: string; + provider: "printful" | "prodigi"; + providerOrderId: string; + status: string; + items: FulfillmentItem[]; + submittedAt: string; +} + +// ── In-memory fulfillment tracking ── +// Maps rCart orderId → fulfillment results +const fulfillmentLog = new Map(); + +// ── Service ── + +const printful = new PrintfulClient(); +const prodigi = new ProdigiClient(); + +/** + * Submit an order to the appropriate POD provider(s). + * + * If items span multiple providers, creates separate orders per provider. + */ +export async function submitFulfillment( + request: FulfillmentRequest, +): Promise { + const results: FulfillmentResult[] = []; + + // Group items by provider + const printfulItems = request.items.filter((i) => i.provider === "printful"); + const prodigiItems = request.items.filter((i) => i.provider === "prodigi"); + + // Submit Printful items + if (printfulItems.length > 0) { + const result = await submitToPrintful( + request.orderId, + printfulItems, + request.recipient, + ); + results.push(result); + } + + // Submit Prodigi items + if (prodigiItems.length > 0) { + const result = await submitToProdigi( + request.orderId, + prodigiItems, + request.recipient, + request.metadata, + ); + results.push(result); + } + + // Store in tracking log + fulfillmentLog.set(request.orderId, results); + + return results; +} + +async function submitToPrintful( + orderId: string, + items: FulfillmentItem[], + recipient: PodRecipient, +): Promise { + if (!printful.enabled) { + throw new Error("Printful API not configured"); + } + + // Resolve catalog variant IDs for each item + const orderItems = await Promise.all( + items.map(async (item) => { + const productId = parseInt(item.sku, 10); + let catalogVariantId: number | null = null; + + if (item.size) { + catalogVariantId = await printful.resolveVariantId( + productId, + item.size, + item.color || "Black", + ); + } + + if (!catalogVariantId) { + // Fallback: get first available variant + const variants = await printful.getCatalogVariants(productId); + catalogVariantId = variants[0]?.id || 0; + } + + return { + catalogVariantId, + quantity: item.quantity, + imageUrl: item.imageUrl, + placement: item.placement || "front", + }; + }), + ); + + const order = await printful.createOrder(orderItems, recipient); + + return { + orderId, + provider: "printful", + providerOrderId: order.id, + status: order.status, + items, + submittedAt: new Date().toISOString(), + }; +} + +async function submitToProdigi( + orderId: string, + items: FulfillmentItem[], + recipient: PodRecipient, + metadata?: Record, +): Promise { + if (!prodigi.enabled) { + throw new Error("Prodigi API not configured"); + } + + const prodigiItems = items.map((item) => ({ + sku: item.sku, + copies: item.quantity, + sizing: "fillPrintArea" as const, + assets: [{ printArea: "default", url: item.imageUrl }], + })); + + const order = await prodigi.createOrder( + prodigiItems, + recipient, + "Budget", + metadata ? { ...metadata, rCartOrderId: orderId } : { rCartOrderId: orderId }, + ); + + return { + orderId, + provider: "prodigi", + providerOrderId: order.id, + status: order.status, + items, + submittedAt: new Date().toISOString(), + }; +} + +// ── Webhook handlers ── + +export interface WebhookEvent { + provider: "printful" | "prodigi"; + type: string; + orderId: string; + tracking?: PodTracking; + status?: string; + raw: unknown; +} + +/** + * Process a Printful webhook event. + * Returns parsed event data for the caller to update rCart. + */ +export function parsePrintfulWebhook(body: any): WebhookEvent | null { + if (!body?.type || !body?.data) return null; + + const event: WebhookEvent = { + provider: "printful", + type: body.type, + orderId: String(body.data?.order?.id || body.data?.id || ""), + raw: body, + }; + + // Extract tracking info from shipment events + if (body.type === "package_shipped" || body.type === "order_updated") { + const shipment = body.data?.shipment || body.data; + if (shipment?.tracking_number) { + event.tracking = { + carrier: shipment.carrier || "", + trackingNumber: shipment.tracking_number, + trackingUrl: shipment.tracking_url || "", + }; + } + event.status = body.data?.order?.status || body.data?.status; + } + + return event; +} + +/** + * Process a Prodigi webhook event. + */ +export function parseProdigiWebhook(body: any): WebhookEvent | null { + if (!body?.topic) return null; + + const order = body.order || body; + + const event: WebhookEvent = { + provider: "prodigi", + type: body.topic, + orderId: String(order?.id || ""), + raw: body, + }; + + // Extract tracking from shipment events + if (body.topic === "order.shipped") { + const shipment = order?.shipments?.[0]; + if (shipment) { + event.tracking = { + carrier: shipment.carrier || "", + trackingNumber: shipment.trackingNumber || "", + trackingUrl: shipment.trackingUrl || "", + }; + } + } + + event.status = order?.status?.stage; + + return event; +} + +// ── Tracking lookup ── + +/** + * Get fulfillment status for an rCart order. + */ +export function getFulfillmentStatus(orderId: string): FulfillmentResult[] | null { + return fulfillmentLog.get(orderId) || null; +} + +/** + * Get tracking info from the POD provider. + */ +export async function getTrackingInfo( + provider: "printful" | "prodigi", + providerOrderId: string, +): Promise { + try { + if (provider === "printful") { + const order = await printful.getOrder(providerOrderId); + const raw = order.raw as any; + const shipment = raw?.shipments?.[0]; + if (shipment?.tracking_number) { + return { + carrier: shipment.carrier || "", + trackingNumber: shipment.tracking_number, + trackingUrl: shipment.tracking_url || "", + }; + } + } else { + const order = await prodigi.getOrder(providerOrderId); + const raw = order.raw as any; + const shipment = raw?.order?.shipments?.[0]; + if (shipment?.trackingNumber) { + return { + carrier: shipment.carrier || "", + trackingNumber: shipment.trackingNumber, + trackingUrl: shipment.trackingUrl || "", + }; + } + } + } catch (err) { + console.warn(`[rSwag] Failed to get tracking for ${provider} order ${providerOrderId}:`, err); + } + + return null; +} diff --git a/modules/rswag/landing.ts b/modules/rswag/landing.ts index a1e5c57..4182581 100644 --- a/modules/rswag/landing.ts +++ b/modules/rswag/landing.ts @@ -31,23 +31,23 @@ export function renderLanding(): string {
🖨
-

Print-on-Demand Merch Designer

-

Upload artwork and generate print-ready files with correct DPI, bleed margins, and color profiles.

+

AI Design + Upload

+

Generate designs with AI (Gemini) or upload your own artwork. Print-ready files with correct DPI, bleed, and color profiles.

🧾
-

Stickers, Posters, Tees & Patches

-

Four product types with professional print specs built in. Vinyl stickers, A3 posters, DTG tees, and embroidered patches.

+

HitherDither

+

11 dithering algorithms for screen printing. Floyd-Steinberg, Atkinson, Bayer, and more. Auto color separations.

👁
-

Live Preview Mockups

-

See your design on product mockups before committing. Adjust and iterate in real time.

+

POD Fulfillment

+

Printful + Prodigi integration. Auto-route orders to the nearest capable printer. Cosmolocal network first, global fallback.

🛒

rCart Integration

-

Send finished designs directly to rCart for community ordering and fulfillment.

+

Browse, add to cart, and checkout via rCart. x402/Transak/wallet payments. Full order tracking.

@@ -132,26 +132,24 @@ export function renderLanding(): string {
global -

Printful (Global Fallback)

+

Printful + Prodigi (Global Fallback)

DTG apparel, vinyl stickers, and art prints shipped worldwide. Always available. Bella+Canvas blanks. Sandbox mode for testing.

-

SKU 71 (tee), SKU 146 (hoodie), SKU 358 (sticker) — full size and color ranges.

-
- -
-

Revenue Split on Every Order

-
-
Provider 50%
-
Creator 35%
-
15%
-
-
- Community 15% +

Printful SKU 71 (tee), 146 (hoodie), 358 (sticker). Prodigi for fine art prints and stickers.

+ +
+
+

Where the Money Goes

+

$0 platform fee. 100% of revenue flows to the community.

+ +
+
+
diff --git a/modules/rswag/mockup.ts b/modules/rswag/mockup.ts new file mode 100644 index 0000000..8543cf2 --- /dev/null +++ b/modules/rswag/mockup.ts @@ -0,0 +1,239 @@ +/** + * Mockup compositor — generates product mockups using Sharp. + * + * Supports shirt, sticker, poster templates with design overlay. + * Falls back to local compositing when Printful API is unavailable. + */ + +import sharp from "sharp"; +import { PrintfulClient } from "./pod/printful"; +import type { PodMockup } from "./pod/types"; + +// ── Template definitions ── + +interface MockupTemplate { + /** Width/height of the final mockup image */ + width: number; + height: number; + /** Where the design is placed: [x, y, w, h] */ + designBox: [number, number, number, number]; + /** Blend mode for compositing */ + blend: "over" | "screen"; + /** Background color (used when no template image) */ + bgColor: string; +} + +const TEMPLATES: Record = { + shirt: { + width: 1024, + height: 1024, + designBox: [262, 230, 500, 450], + blend: "screen", + bgColor: "#1a1a1a", + }, + sticker: { + width: 1024, + height: 1024, + designBox: [270, 210, 470, 530], + blend: "over", + bgColor: "#1e293b", + }, + print: { + width: 1024, + height: 1024, + designBox: [225, 225, 575, 500], + blend: "over", + bgColor: "#f8fafc", + }, + poster: { + width: 1024, + height: 1024, + designBox: [225, 225, 575, 500], + blend: "over", + bgColor: "#f8fafc", + }, +}; + +// ── Mockup cache ── + +const mockupCache = new Map(); +const MAX_CACHE = 100; + +function evictCache() { + if (mockupCache.size <= MAX_CACHE) return; + const iter = mockupCache.keys(); + while (mockupCache.size > MAX_CACHE) { + const next = iter.next(); + if (next.done) break; + mockupCache.delete(next.value); + } +} + +// ── Local compositing (Sharp-based fallback) ── + +/** + * Generate a product mockup by compositing the design onto a generated template. + * Uses SVG-based template backgrounds for a clean look. + */ +async function localComposite( + designBuffer: Buffer, + type: string, +): Promise { + const template = TEMPLATES[type] || TEMPLATES.shirt; + const [dx, dy, dw, dh] = template.designBox; + + // Resize design to fit the design box + const resizedDesign = await sharp(designBuffer) + .resize(dw, dh, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } }) + .png() + .toBuffer(); + + // Create the base mockup image + let base: sharp.Sharp; + + if (type === "shirt") { + // Generate a shirt silhouette SVG + base = sharp(Buffer.from(generateShirtSvg(template.width, template.height))) + .resize(template.width, template.height); + } else if (type === "sticker") { + base = sharp(Buffer.from(generateStickerSvg(template.width, template.height))) + .resize(template.width, template.height); + } else { + // Poster/print: simple background + base = sharp({ + create: { + width: template.width, + height: template.height, + channels: 4, + background: template.bgColor, + }, + }).png(); + } + + // Composite design onto template + const result = await base + .composite([ + { + input: resizedDesign, + top: dy, + left: dx, + blend: template.blend === "screen" ? "screen" : "over", + }, + ]) + .png() + .toBuffer(); + + return result; +} + +function generateShirtSvg(w: number, h: number): string { + return ` + + + + `; +} + +function generateStickerSvg(w: number, h: number): string { + return ` + + + + `; +} + +// ── Printful API mockup ── + +async function printfulMockup( + designImageUrl: string, + printfulSku: number, + variantIds?: number[], +): Promise { + const client = new PrintfulClient(); + if (!client.enabled) return null; + + try { + const vids = variantIds || [await getDefaultVariant(client, printfulSku)].filter(Boolean) as number[]; + if (!vids.length) return null; + + const technique = printfulSku === 358 ? "sublimation" : "dtg"; + const mockups = await client.generateMockupAndWait( + printfulSku, vids, designImageUrl, "front", technique, 15, 3000, + ); + + if (!mockups?.length) return null; + + const mockupUrl = mockups[0].mockupUrl; + if (!mockupUrl) return null; + + // Download the mockup image + const resp = await fetch(mockupUrl); + if (!resp.ok) return null; + return Buffer.from(await resp.arrayBuffer()); + } catch (err) { + console.warn("[rSwag] Printful mockup failed, using local fallback:", err); + return null; + } +} + +async function getDefaultVariant(client: PrintfulClient, productId: number): Promise { + try { + const variants = await client.getCatalogVariants(productId); + // Pick first black/M variant as default + const preferred = variants.find(v => v.size === "M" && v.color.toLowerCase().includes("black")); + return preferred?.id || variants[0]?.id || null; + } catch { + return null; + } +} + +// ── Public API ── + +/** + * Generate a product mockup for a design. + * + * Tries Printful API first (if available and product has Printful SKU), + * then falls back to local Sharp compositing. + */ +export async function generateMockup( + designBuffer: Buffer, + mockupType: string, + options?: { + /** If provided, attempts Printful API mockup first */ + printfulSku?: number; + /** Public URL to the design (needed for Printful API) */ + designImageUrl?: string; + /** Skip cache */ + fresh?: boolean; + }, +): Promise { + const type = mockupType === "poster" ? "poster" : mockupType; + const cacheKey = `${type}-${designBuffer.length}`; + + if (!options?.fresh) { + const cached = mockupCache.get(cacheKey); + if (cached) return cached; + } + + // Try Printful API if we have a SKU and public URL + if (options?.printfulSku && options?.designImageUrl) { + const pfResult = await printfulMockup( + options.designImageUrl, + options.printfulSku, + ); + if (pfResult) { + mockupCache.set(cacheKey, pfResult); + evictCache(); + return pfResult; + } + } + + // Local fallback + const result = await localComposite(designBuffer, type); + mockupCache.set(cacheKey, result); + evictCache(); + return result; +} + +/** List available mockup types. */ +export const MOCKUP_TYPES = Object.keys(TEMPLATES); diff --git a/modules/rswag/mod.ts b/modules/rswag/mod.ts index 1dbea68..b41c522 100644 --- a/modules/rswag/mod.ts +++ b/modules/rswag/mod.ts @@ -7,10 +7,14 @@ import { Hono } from "hono"; import { randomUUID } from "node:crypto"; -import { mkdir, writeFile, readFile, readdir, stat } from "node:fs/promises"; +import { mkdir, writeFile, readFile, readdir, stat, rm } from "node:fs/promises"; import { join } from "node:path"; -import { getProduct, PRODUCTS } from "./products"; +import sharp from "sharp"; +import { getProduct, PRODUCTS, getPodProducts, type StorefrontProduct } from "./products"; import { processImage } from "./process-image"; +import { ditherDesign, generateColorSeparations, ALGORITHMS, type DitherAlgorithm } from "./dither"; +import { generateMockup } from "./mockup"; +import { submitFulfillment, parsePrintfulWebhook, parseProdigiWebhook, getFulfillmentStatus, getTrackingInfo, type FulfillmentRequest } from "./fulfillment"; import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; @@ -227,6 +231,732 @@ routes.get("/api/artifact/:id", async (c) => { } }); +// ── Design storage (filesystem-based, per-slug) ── + +const DESIGNS_DIR = process.env.SWAG_DESIGNS_DIR || join(ARTIFACTS_DIR, "designs"); + +interface DesignMeta { + slug: string; + name: string; + description: string; + tags: string[]; + category: string; + author: string; + created: string; + source: "ai-generated" | "upload" | "artifact"; + status: "draft" | "active" | "paused" | "removed"; + imageFile: string; + products: { type: string; provider: string; sku: string; variants: string[]; retailPrice: number }[]; +} + +/** In-memory design index (loaded from filesystem) */ +let designIndex: Map = new Map(); +let designIndexLoaded = false; + +async function loadDesignIndex() { + designIndex.clear(); + try { + await stat(DESIGNS_DIR); + } catch { + await mkdir(DESIGNS_DIR, { recursive: true }); + designIndexLoaded = true; + return; + } + + const categories = await readdir(DESIGNS_DIR); + for (const cat of categories) { + const catDir = join(DESIGNS_DIR, cat); + try { + const catStat = await stat(catDir); + if (!catStat.isDirectory()) continue; + } catch { continue; } + + const slugs = await readdir(catDir); + for (const slug of slugs) { + const metaPath = join(catDir, slug, "meta.json"); + try { + const raw = await readFile(metaPath, "utf-8"); + const meta: DesignMeta = JSON.parse(raw); + designIndex.set(slug, meta); + } catch { /* skip invalid designs */ } + } + } + designIndexLoaded = true; +} + +async function ensureDesignIndex() { + if (!designIndexLoaded) await loadDesignIndex(); +} + +async function saveDesignMeta(meta: DesignMeta) { + const dir = join(DESIGNS_DIR, meta.category, meta.slug); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, "meta.json"), JSON.stringify(meta, null, 2)); + designIndex.set(meta.slug, meta); +} + +function getDesignImagePath(slug: string): string | null { + const meta = designIndex.get(slug); + if (!meta) return null; + return join(DESIGNS_DIR, meta.category, meta.slug, meta.imageFile); +} + +function slugify(text: string): string { + return text.toLowerCase().trim() + .replace(/[^\w\s-]/g, "") + .replace(/[\s_-]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +// ── POST /api/design/generate — AI-powered design generation ── +routes.post("/api/design/generate", async (c) => { + try { + const body = await c.req.json(); + const { concept, name, tags = [], product_type = "sticker" } = body; + + if (!concept || !name) { + return c.json({ error: "concept and name are required" }, 400); + } + + const geminiKey = process.env.GEMINI_API_KEY; + if (!geminiKey) { + return c.json({ error: "AI generation not configured. Set GEMINI_API_KEY." }, 503); + } + + let slug = slugify(name); + if (!slug) slug = `design-${randomUUID().slice(0, 8)}`; + + await ensureDesignIndex(); + if (designIndex.has(slug)) { + return c.json({ error: `Design '${slug}' already exists` }, 409); + } + + // Build AI prompt + const stylePrompt = `A striking sticker design for "${name}". +${concept} +The design should have a clean, modern spatial-web aesthetic with interconnected +nodes, network patterns, and a collaborative/commons feel. +Colors: vibrant cyan, warm orange accents on dark background. +High contrast, suitable for vinyl sticker printing. +Square format, clean edges for die-cut sticker.`; + + // Call Gemini API for image generation + const resp = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image:generateContent?key=${geminiKey}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + contents: [{ parts: [{ text: stylePrompt }] }], + generationConfig: { responseModalities: ["image", "text"] }, + }), + }, + ); + + if (!resp.ok) { + const errText = await resp.text(); + return c.json({ error: `AI generation failed (${resp.status}): ${errText.slice(0, 300)}` }, 502); + } + + const result = await resp.json(); + let imageData: string | null = null; + + for (const candidate of result.candidates || []) { + for (const part of candidate.content?.parts || []) { + if (part.inlineData?.data) { + imageData = part.inlineData.data; + break; + } + } + if (imageData) break; + } + + if (!imageData) { + return c.json({ error: "AI did not return an image" }, 502); + } + + // Save design + const imageBuffer = Buffer.from(imageData, "base64"); + const category = product_type === "sticker" ? "stickers" : product_type === "shirt" || product_type === "tee" ? "apparel" : "prints"; + + const meta: DesignMeta = { + slug, + name, + description: concept, + tags: tags.length ? tags : ["rspace", "ai-generated"], + category, + author: "ai-generated", + created: new Date().toISOString().split("T")[0], + source: "ai-generated", + status: "draft", + imageFile: `${slug}.png`, + products: getPodProducts(product_type).map(p => ({ + type: p.type, provider: p.provider, sku: p.sku, + variants: p.variants, retailPrice: p.retailPrice, + })), + }; + + await saveDesignMeta(meta); + const imgDir = join(DESIGNS_DIR, category, slug); + await writeFile(join(imgDir, `${slug}.png`), imageBuffer); + + return c.json({ + slug, + name, + image_url: `/api/designs/${slug}/image`, + status: "draft", + }, 201); + } catch (error) { + console.error("[rSwag] Design generation error:", error); + return c.json({ error: error instanceof Error ? error.message : "Generation failed" }, 500); + } +}); + +// ── POST /api/design/upload — User artwork upload ── +routes.post("/api/design/upload", async (c) => { + try { + const formData = await c.req.formData(); + const imageFile = formData.get("image") as File | null; + const name = (formData.get("name") as string) || "Untitled Upload"; + const description = (formData.get("description") as string) || ""; + const tagsRaw = formData.get("tags") as string | null; + const tags = tagsRaw ? tagsRaw.split(",").map(t => t.trim()) : []; + const productType = (formData.get("product_type") as string) || "sticker"; + + if (!imageFile) return c.json({ error: "image file is required" }, 400); + + // Validate format + const validTypes = ["image/png", "image/jpeg", "image/webp"]; + if (!validTypes.includes(imageFile.type)) { + return c.json({ error: `Invalid format. Accepted: PNG, JPEG, WebP` }, 400); + } + + // Validate size (max 10MB) + if (imageFile.size > 10 * 1024 * 1024) { + return c.json({ error: "Image too large (max 10MB)" }, 400); + } + + const inputBuffer = Buffer.from(await imageFile.arrayBuffer()); + + // Validate dimensions (min 500x500) + const metadata = await sharp(inputBuffer).metadata(); + if (!metadata.width || !metadata.height || metadata.width < 500 || metadata.height < 500) { + return c.json({ error: "Image too small (minimum 500x500 pixels)" }, 400); + } + + let slug = slugify(name); + if (!slug) slug = `upload-${randomUUID().slice(0, 8)}`; + + await ensureDesignIndex(); + // Deduplicate slug + let finalSlug = slug; + let counter = 1; + while (designIndex.has(finalSlug)) { + finalSlug = `${slug}-${counter++}`; + } + slug = finalSlug; + + const category = productType === "sticker" ? "stickers" : productType === "tee" || productType === "shirt" ? "apparel" : "prints"; + + // Process to standard 1024x1024 PNG + const processed = await sharp(inputBuffer) + .resize(1024, 1024, { fit: "contain", background: { r: 255, g: 255, b: 255, alpha: 0 } }) + .png() + .toBuffer(); + + const meta: DesignMeta = { + slug, + name, + description, + tags, + category, + author: "upload", + created: new Date().toISOString().split("T")[0], + source: "upload", + status: "draft", + imageFile: `${slug}.png`, + products: getPodProducts(productType).map(p => ({ + type: p.type, provider: p.provider, sku: p.sku, + variants: p.variants, retailPrice: p.retailPrice, + })), + }; + + await saveDesignMeta(meta); + const imgDir = join(DESIGNS_DIR, category, slug); + await writeFile(join(imgDir, `${slug}.png`), processed); + + return c.json({ + slug, + name, + image_url: `/api/designs/${slug}/image`, + status: "draft", + }, 201); + } catch (error) { + console.error("[rSwag] Upload error:", error); + return c.json({ error: error instanceof Error ? error.message : "Upload failed" }, 500); + } +}); + +// ── POST /api/design/:slug/activate — Publish draft design ── +routes.post("/api/design/:slug/activate", async (c) => { + const slug = c.req.param("slug"); + await ensureDesignIndex(); + const meta = designIndex.get(slug); + if (!meta) return c.json({ error: "Design not found" }, 404); + + meta.status = "active"; + await saveDesignMeta(meta); + return c.json({ status: "activated", slug }); +}); + +// ── DELETE /api/design/:slug — Delete draft design ── +routes.delete("/api/design/:slug", async (c) => { + const slug = c.req.param("slug"); + await ensureDesignIndex(); + const meta = designIndex.get(slug); + if (!meta) return c.json({ error: "Design not found" }, 404); + + if (meta.status === "active") { + return c.json({ error: "Cannot delete active designs. Set to draft first." }, 400); + } + + const designDir = join(DESIGNS_DIR, meta.category, slug); + try { await rm(designDir, { recursive: true }); } catch { /* ok */ } + designIndex.delete(slug); + + return c.json({ status: "deleted", slug }); +}); + +// ── GET /api/designs — List designs with filtering ── +routes.get("/api/designs", async (c) => { + await ensureDesignIndex(); + const statusFilter = c.req.query("status"); + const categoryFilter = c.req.query("category"); + const search = c.req.query("q")?.toLowerCase(); + + let designs = Array.from(designIndex.values()); + + if (statusFilter) designs = designs.filter(d => d.status === statusFilter); + if (categoryFilter) designs = designs.filter(d => d.category === categoryFilter); + if (search) { + designs = designs.filter(d => + d.name.toLowerCase().includes(search) || + d.description.toLowerCase().includes(search) || + d.tags.some(t => t.toLowerCase().includes(search)), + ); + } + + return c.json({ + designs: designs.map(d => ({ + slug: d.slug, + name: d.name, + description: d.description, + category: d.category, + tags: d.tags, + status: d.status, + source: d.source, + image_url: `/api/designs/${d.slug}/image`, + products: d.products, + created: d.created, + })), + }); +}); + +// ── GET /api/designs/:slug — Single design metadata ── +routes.get("/api/designs/:slug", async (c) => { + const slug = c.req.param("slug"); + await ensureDesignIndex(); + const meta = designIndex.get(slug); + if (!meta) return c.json({ error: "Design not found" }, 404); + + return c.json({ + slug: meta.slug, + name: meta.name, + description: meta.description, + category: meta.category, + tags: meta.tags, + status: meta.status, + source: meta.source, + image_url: `/api/designs/${slug}/image`, + products: meta.products, + created: meta.created, + }); +}); + +// ── GET /api/designs/:slug/image — Serve design PNG ── +routes.get("/api/designs/:slug/image", async (c) => { + await ensureDesignIndex(); + const imagePath = getDesignImagePath(c.req.param("slug")); + if (!imagePath) return c.json({ error: "Design not found" }, 404); + + try { + const buffer = await readFile(imagePath); + return new Response(new Uint8Array(buffer), { + headers: { + "Content-Type": "image/png", + "Cache-Control": "public, max-age=86400", + "Content-Length": String(buffer.length), + }, + }); + } catch { + return c.json({ error: "Image file not found" }, 404); + } +}); + +// ── GET /api/designs/:slug/mockup — Photorealistic mockup ── +routes.get("/api/designs/:slug/mockup", async (c) => { + const slug = c.req.param("slug"); + const type = c.req.query("type") || "shirt"; + const fresh = c.req.query("fresh") === "true"; + + await ensureDesignIndex(); + const imagePath = getDesignImagePath(slug); + if (!imagePath) return c.json({ error: "Design not found" }, 404); + + try { + const designBuffer = await readFile(imagePath); + const meta = designIndex.get(slug); + + // Find Printful SKU if available + const printfulProduct = meta?.products?.find(p => p.provider === "printful"); + const printfulSku = printfulProduct ? parseInt(printfulProduct.sku, 10) : undefined; + + // Build public URL for Printful API (needs accessible URL) + const proto = c.req.header("x-forwarded-proto") || "https"; + const host = c.req.header("host") || "rswag.online"; + const designImageUrl = printfulSku ? `${proto}://${host}/api/designs/${slug}/image` : undefined; + + const mockup = await generateMockup(designBuffer, type, { + printfulSku, + designImageUrl, + fresh, + }); + + return new Response(new Uint8Array(mockup), { + headers: { + "Content-Type": "image/png", + "Cache-Control": "public, max-age=86400", + "Content-Length": String(mockup.length), + }, + }); + } catch (error) { + console.error("[rSwag] Mockup error:", error); + return c.json({ error: "Mockup generation failed" }, 500); + } +}); + +// ── GET /api/designs/:slug/dither — Dithered PNG ── +routes.get("/api/designs/:slug/dither", async (c) => { + const slug = c.req.param("slug"); + const algorithm = (c.req.query("algorithm") || "floyd-steinberg") as DitherAlgorithm; + const numColors = parseInt(c.req.query("num_colors") || "8", 10); + const customColors = c.req.query("colors")?.split(",").filter(Boolean); + const threshold = parseInt(c.req.query("threshold") || "64", 10); + const order = parseInt(c.req.query("order") || "3", 10); + const format = c.req.query("format") || "image"; + + if (!ALGORITHMS.includes(algorithm)) { + return c.json({ error: `Unknown algorithm. Available: ${ALGORITHMS.join(", ")}` }, 400); + } + + await ensureDesignIndex(); + const imagePath = getDesignImagePath(slug); + if (!imagePath) return c.json({ error: "Design not found" }, 404); + + try { + const imageBuffer = await readFile(imagePath); + const result = await ditherDesign(imageBuffer, slug, { + algorithm, + numColors: Math.min(Math.max(numColors, 2), 32), + customColors, + threshold, + order, + }); + + if (format === "json") { + return c.json({ + slug, + algorithm: result.algorithm, + palette_mode: result.paletteMode, + num_colors: result.numColors, + colors_used: result.colorsUsed, + cached: result.cached, + image_url: `/api/designs/${slug}/dither?algorithm=${algorithm}&num_colors=${numColors}`, + }); + } + + return new Response(new Uint8Array(result.buffer), { + headers: { + "Content-Type": "image/png", + "Content-Disposition": `inline; filename="${slug}-${algorithm}.png"`, + "X-Colors-Used": result.colorsUsed.join(","), + "X-Algorithm": result.algorithm, + "X-Cached": String(result.cached), + }, + }); + } catch (error) { + console.error("[rSwag] Dither error:", error); + return c.json({ error: "Dithering failed" }, 500); + } +}); + +// ── POST /api/designs/:slug/screen-print — Color separation PNGs ── +routes.post("/api/designs/:slug/screen-print", async (c) => { + const slug = c.req.param("slug"); + + await ensureDesignIndex(); + const imagePath = getDesignImagePath(slug); + if (!imagePath) return c.json({ error: "Design not found" }, 404); + + try { + const body = await c.req.json(); + const numColors = Math.min(Math.max(body.num_colors || 4, 2), 16); + const algorithm = (body.algorithm || "floyd-steinberg") as DitherAlgorithm; + const spotColors = body.spot_colors as string[] | undefined; + + const imageBuffer = await readFile(imagePath); + const result = await generateColorSeparations( + imageBuffer, slug, numColors, algorithm, spotColors, + ); + + const separationUrls: Record = {}; + for (const color of result.colors) { + separationUrls[color] = `/api/designs/${slug}/screen-print/${color}`; + } + + // Cache separations for the GET endpoint + screenPrintCache.set(slug, result); + + return c.json({ + slug, + num_colors: result.colors.length, + algorithm, + colors: result.colors, + composite_url: `/api/designs/${slug}/screen-print/composite`, + separation_urls: separationUrls, + }); + } catch (error) { + console.error("[rSwag] Screen-print error:", error); + return c.json({ error: "Color separation failed" }, 500); + } +}); + +// Screen-print separation cache (slug → result) +const screenPrintCache = new Map; colors: string[] }>(); + +// ── GET /api/designs/:slug/screen-print/:channel — Serve separation channel ── +routes.get("/api/designs/:slug/screen-print/:channel", async (c) => { + const slug = c.req.param("slug"); + const channel = c.req.param("channel"); + + const cached = screenPrintCache.get(slug); + if (!cached) return c.json({ error: "Run POST screen-print first" }, 404); + + let buffer: Buffer; + if (channel === "composite") { + buffer = cached.composite; + } else { + const sep = cached.separations.get(channel.toUpperCase()); + if (!sep) return c.json({ error: `Channel ${channel} not found` }, 404); + buffer = sep; + } + + return new Response(new Uint8Array(buffer), { + headers: { + "Content-Type": "image/png", + "Content-Disposition": `inline; filename="${slug}-${channel}.png"`, + }, + }); +}); + +// ── GET /api/storefront — Storefront product listing ── +routes.get("/api/storefront", async (c) => { + await ensureDesignIndex(); + + const products: StorefrontProduct[] = []; + + for (const meta of designIndex.values()) { + if (meta.status !== "active") continue; + + for (const prod of meta.products) { + products.push({ + slug: `${meta.slug}-${prod.type}`, + designSlug: meta.slug, + name: `${meta.name} ${prod.type}`, + description: meta.description, + category: meta.category, + productType: prod.type, + imageUrl: `/api/designs/${meta.slug}/image`, + basePrice: prod.retailPrice, + variants: prod.variants, + provider: prod.provider, + sku: prod.sku, + isActive: true, + }); + } + } + + return c.json({ products }); +}); + +// ── GET /api/storefront/:slug — Single storefront product ── +routes.get("/api/storefront/:slug", async (c) => { + const slug = c.req.param("slug"); + await ensureDesignIndex(); + + // Parse "designSlug-productType" format + for (const meta of designIndex.values()) { + for (const prod of meta.products) { + const prodSlug = `${meta.slug}-${prod.type}`; + if (prodSlug === slug) { + return c.json({ + slug: prodSlug, + designSlug: meta.slug, + name: `${meta.name} ${prod.type}`, + description: meta.description, + category: meta.category, + productType: prod.type, + imageUrl: `/api/designs/${meta.slug}/image`, + mockupUrl: `/api/designs/${meta.slug}/mockup?type=${prod.type}`, + basePrice: prod.retailPrice, + variants: prod.variants, + provider: prod.provider, + sku: prod.sku, + isActive: meta.status === "active", + }); + } + } + } + + return c.json({ error: "Product not found" }, 404); +}); + +// ── POST /api/fulfill/submit — Submit rCart order to POD provider ── +routes.post("/api/fulfill/submit", async (c) => { + try { + const body = await c.req.json() as FulfillmentRequest; + if (!body.orderId || !body.items?.length || !body.recipient) { + return c.json({ error: "orderId, items, and recipient are required" }, 400); + } + + const results = await submitFulfillment(body); + return c.json({ results }, 201); + } catch (error) { + console.error("[rSwag] Fulfillment error:", error); + return c.json({ error: error instanceof Error ? error.message : "Fulfillment failed" }, 500); + } +}); + +// ── POST /api/webhooks/printful — Printful shipment webhook ── +routes.post("/api/webhooks/printful", async (c) => { + try { + const body = await c.req.json(); + const event = parsePrintfulWebhook(body); + if (!event) return c.json({ error: "Invalid webhook" }, 400); + + console.log(`[rSwag] Printful webhook: ${event.type} for order ${event.orderId}`); + + // TODO: push tracking update to rCart order doc + return c.json({ received: true, type: event.type }); + } catch { + return c.json({ error: "Webhook processing failed" }, 500); + } +}); + +// ── POST /api/webhooks/prodigi — Prodigi shipment webhook ── +routes.post("/api/webhooks/prodigi", async (c) => { + try { + const body = await c.req.json(); + const event = parseProdigiWebhook(body); + if (!event) return c.json({ error: "Invalid webhook" }, 400); + + console.log(`[rSwag] Prodigi webhook: ${event.type} for order ${event.orderId}`); + + // TODO: push tracking update to rCart order doc + return c.json({ received: true, type: event.type }); + } catch { + return c.json({ error: "Webhook processing failed" }, 500); + } +}); + +// ── GET /api/orders/:id/tracking — Tracking info lookup ── +routes.get("/api/orders/:id/tracking", async (c) => { + const orderId = c.req.param("id"); + const fulfillments = getFulfillmentStatus(orderId); + if (!fulfillments) return c.json({ error: "Order not found" }, 404); + + const tracking = []; + for (const f of fulfillments) { + const info = await getTrackingInfo(f.provider, f.providerOrderId); + tracking.push({ + provider: f.provider, + providerOrderId: f.providerOrderId, + status: f.status, + tracking: info, + }); + } + + return c.json({ orderId, fulfillments: tracking }); +}); + +// ── GET /api/dither/algorithms — List available dithering algorithms ── +routes.get("/api/dither/algorithms", (c) => { + return c.json({ algorithms: ALGORITHMS }); +}); + +// ── POST /api/admin/designs/sync — Reload designs from storage ── +routes.post("/api/admin/designs/sync", async (c) => { + designIndexLoaded = false; + await loadDesignIndex(); + return c.json({ synced: true, count: designIndex.size }); +}); + +// ── PUT /api/admin/products/:slug/override — Price/visibility override ── +routes.put("/api/admin/products/:slug/override", async (c) => { + const slug = c.req.param("slug"); + await ensureDesignIndex(); + const meta = designIndex.get(slug); + if (!meta) return c.json({ error: "Design not found" }, 404); + + const body = await c.req.json(); + if (body.status) meta.status = body.status; + if (body.products) meta.products = body.products; + await saveDesignMeta(meta); + + return c.json({ updated: true, slug }); +}); + +// ── GET /api/admin/analytics/summary — Sales metrics ── +routes.get("/api/admin/analytics/summary", async (c) => { + await ensureDesignIndex(); + + const totalDesigns = designIndex.size; + const activeDesigns = Array.from(designIndex.values()).filter(d => d.status === "active").length; + const draftDesigns = Array.from(designIndex.values()).filter(d => d.status === "draft").length; + + // Count artifacts + let totalArtifacts = 0; + try { + const dirs = await readdir(ARTIFACTS_DIR); + totalArtifacts = dirs.filter(d => d.match(/^[0-9a-f-]{36}$/)).length; + } catch { /* ok */ } + + return c.json({ + totalDesigns, + activeDesigns, + draftDesigns, + totalArtifacts, + designsByCategory: Array.from(designIndex.values()).reduce((acc, d) => { + acc[d.category] = (acc[d.category] || 0) + 1; + return acc; + }, {} as Record), + designsBySource: Array.from(designIndex.values()).reduce((acc, d) => { + acc[d.source] = (acc[d.source] || 0) + 1; + return acc; + }, {} as Record), + }); +}); + // ── Page route: swag designer ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; @@ -238,7 +968,8 @@ routes.get("/", (c) => { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, + scripts: ` + `, styles: ``, })); }); diff --git a/modules/rswag/pod/printful.ts b/modules/rswag/pod/printful.ts new file mode 100644 index 0000000..78db72d --- /dev/null +++ b/modules/rswag/pod/printful.ts @@ -0,0 +1,299 @@ +/** + * Printful v2 API client. + * + * Handles catalog lookup, mockup generation, and order submission. + * API v2 docs: https://developers.printful.com/docs/v2-beta/ + * Rate limit: 120 req/60s (leaky bucket), lower for mockups. + */ + +import type { PodOrder, PodRecipient, PodMockup, PodVariant } from "./types"; + +const BASE_URL = "https://api.printful.com/v2"; + +/** In-memory cache for catalog variants: product_id -> { variants, ts } */ +const variantCache = new Map(); +const VARIANT_CACHE_TTL = 86_400_000; // 24 hours in ms + +export class PrintfulClient { + private apiToken: string; + private storeId: string; + private sandbox: boolean; + readonly enabled: boolean; + + constructor() { + this.apiToken = process.env.PRINTFUL_API_TOKEN || ""; + this.storeId = process.env.PRINTFUL_STORE_ID || ""; + this.sandbox = process.env.POD_SANDBOX_MODE !== "false"; + this.enabled = !!this.apiToken; + } + + private get headers(): Record { + const h: Record = { + Authorization: `Bearer ${this.apiToken}`, + "Content-Type": "application/json", + }; + if (this.storeId) h["X-PF-Store-Id"] = this.storeId; + return h; + } + + // ── Catalog ── + + async getCatalogVariants(productId: number): Promise { + const cached = variantCache.get(productId); + if (cached && Date.now() - cached.ts < VARIANT_CACHE_TTL) { + return cached.variants; + } + + const resp = await fetch( + `${BASE_URL}/catalog-products/${productId}/catalog-variants`, + { headers: this.headers }, + ); + if (!resp.ok) throw new Error(`Printful catalog error: ${resp.status}`); + + const data = (await resp.json()) as { data?: any[] }; + const raw = data.data || []; + + const variants: PodVariant[] = raw.map((v: any) => ({ + id: v.id, + size: v.size || "", + color: v.color || "", + colorCode: v.color_code || "", + })); + + variantCache.set(productId, { variants, ts: Date.now() }); + return variants; + } + + /** + * Resolve (product_id, size, color) -> Printful catalog_variant_id. + * Our metadata uses SKU "71" + variants ["S","M","L",...]. + * Printful orders require numeric catalog_variant_id. + */ + async resolveVariantId( + productId: number, + size: string, + color = "Black", + ): Promise { + const variants = await this.getCatalogVariants(productId); + + // Exact match on size + color + for (const v of variants) { + if ( + v.size.toUpperCase() === size.toUpperCase() && + v.color.toLowerCase().includes(color.toLowerCase()) + ) { + return v.id; + } + } + + // Fallback: match size only + for (const v of variants) { + if (v.size.toUpperCase() === size.toUpperCase()) { + return v.id; + } + } + + return null; + } + + // ── Mockup Generation ── + + /** + * Start async mockup generation task (v2 format). + * Returns task_id to poll with getMockupTask(). + */ + async createMockupTask( + productId: number, + variantIds: number[], + imageUrl: string, + placement = "front", + technique = "dtg", + ): Promise { + const payload = { + products: [ + { + source: "catalog", + catalog_product_id: productId, + catalog_variant_ids: variantIds, + placements: [ + { + placement, + technique, + layers: [{ type: "file", url: imageUrl }], + }, + ], + }, + ], + format: "png", + }; + + const resp = await fetch(`${BASE_URL}/mockup-tasks`, { + method: "POST", + headers: this.headers, + body: JSON.stringify(payload), + }); + if (!resp.ok) throw new Error(`Printful mockup task error: ${resp.status}`); + + const result = (await resp.json()) as { data?: any }; + const rawData = result.data; + const data = Array.isArray(rawData) && rawData.length ? rawData[0] : rawData; + const taskId = data?.id || data?.task_key || data?.task_id; + return String(taskId); + } + + /** Poll mockup task status. */ + async getMockupTask(taskId: string): Promise<{ + status: string; + mockups?: PodMockup[]; + failureReasons?: string[]; + }> { + const resp = await fetch( + `${BASE_URL}/mockup-tasks?id=${encodeURIComponent(taskId)}`, + { headers: this.headers }, + ); + if (!resp.ok) throw new Error(`Printful mockup poll error: ${resp.status}`); + + const result = (await resp.json()) as { data?: any }; + const raw = result.data; + const data = Array.isArray(raw) && raw.length ? raw[0] : (typeof raw === "object" ? raw : {}); + + const mockups: PodMockup[] = ( + data.mockups || data.catalog_variant_mockups || [] + ).map((m: any) => ({ + variantId: m.catalog_variant_id || m.variant_id, + mockupUrl: m.mockup_url || m.url || "", + placement: m.placement || "front", + })); + + return { + status: data.status || "", + mockups, + failureReasons: data.failure_reasons, + }; + } + + /** + * Create mockup task and poll until complete. + * Returns mockup list or null on failure/timeout. + */ + async generateMockupAndWait( + productId: number, + variantIds: number[], + imageUrl: string, + placement = "front", + technique = "dtg", + maxPolls = 20, + pollIntervalMs = 3000, + ): Promise { + const taskId = await this.createMockupTask( + productId, variantIds, imageUrl, placement, technique, + ); + + for (let i = 0; i < maxPolls; i++) { + await new Promise((r) => setTimeout(r, pollIntervalMs)); + const result = await this.getMockupTask(taskId); + + if (result.status === "completed") { + return result.mockups || []; + } + if (result.status === "failed") { + console.error(`Printful mockup task ${taskId} failed:`, result.failureReasons); + return null; + } + } + + console.warn(`Printful mockup task ${taskId} timed out after ${maxPolls} polls`); + return null; + } + + // ── Orders ── + + async createOrder( + items: { + catalogVariantId: number; + quantity: number; + imageUrl: string; + placement?: string; + }[], + recipient: PodRecipient, + ): Promise { + if (!this.enabled) throw new Error("Printful API token not configured"); + + const orderItems = items.map((item) => ({ + source: "catalog", + catalog_variant_id: item.catalogVariantId, + quantity: item.quantity, + placements: [ + { + placement: item.placement || "front", + technique: "dtg", + layers: [{ type: "file", url: item.imageUrl }], + }, + ], + })); + + const payload: any = { + recipient: { + name: recipient.name, + address1: recipient.address1, + ...(recipient.address2 ? { address2: recipient.address2 } : {}), + city: recipient.city, + ...(recipient.stateCode ? { state_code: recipient.stateCode } : {}), + country_code: recipient.countryCode, + zip: recipient.zip, + ...(recipient.email ? { email: recipient.email } : {}), + }, + items: orderItems, + }; + + if (this.sandbox) payload.draft = true; + + const resp = await fetch(`${BASE_URL}/orders`, { + method: "POST", + headers: this.headers, + body: JSON.stringify(payload), + }); + if (!resp.ok) { + const text = await resp.text(); + throw new Error(`Printful order error ${resp.status}: ${text.slice(0, 500)}`); + } + + const result = (await resp.json()) as { data?: any }; + const data = result.data || {}; + + return { + id: String(data.id), + provider: "printful", + status: data.status || "draft", + items: items.map((i) => ({ + sku: String(i.catalogVariantId), + quantity: i.quantity, + imageUrl: i.imageUrl, + placement: i.placement || "front", + })), + recipient, + createdAt: new Date().toISOString(), + raw: data, + }; + } + + async getOrder(orderId: string): Promise { + const resp = await fetch(`${BASE_URL}/orders/${orderId}`, { + headers: this.headers, + }); + if (!resp.ok) throw new Error(`Printful get order error: ${resp.status}`); + + const result = (await resp.json()) as { data?: any }; + const data = result.data || {}; + + return { + id: String(data.id), + provider: "printful", + status: data.status || "unknown", + items: [], + recipient: { name: "", address1: "", city: "", countryCode: "", zip: "" }, + createdAt: data.created_at || new Date().toISOString(), + raw: data, + }; + } +} diff --git a/modules/rswag/pod/prodigi.ts b/modules/rswag/pod/prodigi.ts new file mode 100644 index 0000000..78764ea --- /dev/null +++ b/modules/rswag/pod/prodigi.ts @@ -0,0 +1,164 @@ +/** + * Prodigi v4 API client. + * + * Handles order submission, product specs, and quotes. + * Sandbox: https://api.sandbox.prodigi.com/v4.0/ + * Production: https://api.prodigi.com/v4.0/ + */ + +import type { PodOrder, PodRecipient, PodQuote } from "./types"; + +const SANDBOX_URL = "https://api.sandbox.prodigi.com/v4.0"; +const PRODUCTION_URL = "https://api.prodigi.com/v4.0"; + +export class ProdigiClient { + private apiKey: string; + private baseUrl: string; + readonly enabled: boolean; + + constructor() { + this.apiKey = process.env.PRODIGI_API_KEY || ""; + const sandbox = process.env.POD_SANDBOX_MODE !== "false"; + this.baseUrl = sandbox ? SANDBOX_URL : PRODUCTION_URL; + this.enabled = !!this.apiKey; + } + + private get headers(): Record { + return { + "X-API-Key": this.apiKey, + "Content-Type": "application/json", + }; + } + + /** + * Create a Prodigi print order. + * + * Items should include: sku, copies, sizing, assets. + * Recipient uses Prodigi address format. + */ + async createOrder( + items: { + sku: string; + copies: number; + sizing?: "fillPrintArea" | "fitPrintArea" | "stretchToPrintArea"; + assets: { printArea: string; url: string }[]; + }[], + recipient: PodRecipient, + shippingMethod: "Budget" | "Standard" | "Express" = "Budget", + metadata?: Record, + ): Promise { + if (!this.enabled) throw new Error("Prodigi API key not configured"); + + const payload: any = { + shippingMethod, + recipient: { + name: recipient.name, + ...(recipient.email ? { email: recipient.email } : {}), + address: { + line1: recipient.address1, + ...(recipient.address2 ? { line2: recipient.address2 } : {}), + townOrCity: recipient.city, + ...(recipient.stateCode ? { stateOrCounty: recipient.stateCode } : {}), + postalOrZipCode: recipient.zip, + countryCode: recipient.countryCode, + }, + }, + items: items.map((item) => ({ + sku: item.sku, + copies: item.copies, + sizing: item.sizing || "fillPrintArea", + assets: item.assets, + })), + }; + + if (metadata) payload.metadata = metadata; + + const resp = await fetch(`${this.baseUrl}/Orders`, { + method: "POST", + headers: this.headers, + body: JSON.stringify(payload), + }); + if (!resp.ok) { + const text = await resp.text(); + throw new Error(`Prodigi order error ${resp.status}: ${text.slice(0, 500)}`); + } + + const result = await resp.json(); + + return { + id: String(result.id || result.order?.id || ""), + provider: "prodigi", + status: result.status?.stage || "InProgress", + items: items.map((i) => ({ + sku: i.sku, + quantity: i.copies, + imageUrl: i.assets[0]?.url || "", + })), + recipient, + createdAt: new Date().toISOString(), + raw: result, + }; + } + + async getOrder(orderId: string): Promise { + const resp = await fetch(`${this.baseUrl}/Orders/${orderId}`, { + headers: this.headers, + }); + if (!resp.ok) throw new Error(`Prodigi get order error: ${resp.status}`); + + const result = await resp.json(); + const order = result.order || result; + + return { + id: String(order.id || orderId), + provider: "prodigi", + status: order.status?.stage || "unknown", + items: [], + recipient: { name: "", address1: "", city: "", countryCode: "", zip: "" }, + createdAt: order.created || new Date().toISOString(), + raw: result, + }; + } + + async getProduct(sku: string): Promise { + const resp = await fetch(`${this.baseUrl}/products/${sku}`, { + headers: this.headers, + }); + if (!resp.ok) throw new Error(`Prodigi product error: ${resp.status}`); + return resp.json(); + } + + async getQuote( + items: { sku: string; copies: number }[], + shippingMethod: "Budget" | "Standard" | "Express" = "Budget", + destinationCountry = "US", + ): Promise { + const payload = { + shippingMethod, + destinationCountryCode: destinationCountry, + items: items.map((i) => ({ sku: i.sku, copies: i.copies })), + }; + + const resp = await fetch(`${this.baseUrl}/quotes`, { + method: "POST", + headers: this.headers, + body: JSON.stringify(payload), + }); + if (!resp.ok) throw new Error(`Prodigi quote error: ${resp.status}`); + + const result = await resp.json(); + const quotes = result.quotes || [result]; + const q = quotes[0] || {}; + + return { + items: (q.items || []).map((i: any) => ({ + sku: i.sku, + unitCost: parseFloat(i.unitCost?.amount || "0"), + quantity: i.copies || 1, + })), + shippingCost: parseFloat(q.shipmentSummary?.items?.[0]?.cost?.amount || "0"), + totalCost: parseFloat(q.totalCost?.amount || "0"), + currency: q.totalCost?.currency || "USD", + }; + } +} diff --git a/modules/rswag/pod/types.ts b/modules/rswag/pod/types.ts new file mode 100644 index 0000000..9fb1755 --- /dev/null +++ b/modules/rswag/pod/types.ts @@ -0,0 +1,61 @@ +/** + * Shared types for Print-on-Demand provider clients (Printful, Prodigi). + */ + +export interface PodRecipient { + name: string; + email?: string; + address1: string; + address2?: string; + city: string; + stateCode?: string; + countryCode: string; + zip: string; +} + +export interface PodOrderItem { + /** Provider-specific variant or SKU */ + sku: string; + quantity: number; + /** Public URL to the design image */ + imageUrl: string; + /** Placement on the product (e.g. "front", "back") */ + placement?: string; +} + +export interface PodOrder { + id: string; + provider: "printful" | "prodigi"; + status: string; + items: PodOrderItem[]; + recipient: PodRecipient; + tracking?: PodTracking; + createdAt: string; + raw?: unknown; +} + +export interface PodTracking { + carrier: string; + trackingNumber: string; + trackingUrl: string; +} + +export interface PodMockup { + variantId?: number; + mockupUrl: string; + placement: string; +} + +export interface PodVariant { + id: number; + size: string; + color: string; + colorCode: string; +} + +export interface PodQuote { + items: { sku: string; unitCost: number; quantity: number }[]; + shippingCost: number; + totalCost: number; + currency: string; +} diff --git a/modules/rswag/products.ts b/modules/rswag/products.ts index cef06f3..874f204 100644 --- a/modules/rswag/products.ts +++ b/modules/rswag/products.ts @@ -112,3 +112,58 @@ export const PRODUCTS: Record = { export function getProduct(id: string): ProductTemplate | undefined { return PRODUCTS[id]; } + +// ── POD product configurations ── +// Maps design product types to POD provider SKUs and pricing + +export interface PodProductConfig { + type: string; + provider: "printful" | "prodigi"; + sku: string; + variants: string[]; + retailPrice: number; +} + +/** Default POD product configs applied to each design */ +export const POD_PRODUCTS: Record = { + sticker: [ + { type: "sticker", provider: "prodigi", sku: "GLOBAL-STI-KIS-3X3", variants: ["matte", "gloss"], retailPrice: 3.50 }, + ], + poster: [ + { type: "print", provider: "prodigi", sku: "GLOBAL-FAP-A4", variants: ["matte"], retailPrice: 12.99 }, + ], + tee: [ + { type: "shirt", provider: "printful", sku: "71", variants: ["S", "M", "L", "XL", "2XL"], retailPrice: 29.99 }, + ], + hoodie: [ + { type: "hoodie", provider: "printful", sku: "146", variants: ["S", "M", "L", "XL", "2XL"], retailPrice: 39.99 }, + ], +}; + +/** + * Get the POD product configs for a product type. + * Returns an array of purchasable SKU configurations. + */ +export function getPodProducts(productType: string): PodProductConfig[] { + return POD_PRODUCTS[productType] || []; +} + +/** + * Flatten all designs into purchasable storefront products. + * Each design × product config = one storefront listing. + */ +export interface StorefrontProduct { + slug: string; + designSlug: string; + name: string; + description: string; + category: string; + productType: string; + imageUrl: string; + basePrice: number; + variants: string[]; + provider: string; + sku: string; + isActive: boolean; +} + diff --git a/modules/rswag/schemas.ts b/modules/rswag/schemas.ts index 6a04e17..66362bf 100644 --- a/modules/rswag/schemas.ts +++ b/modules/rswag/schemas.ts @@ -12,12 +12,34 @@ import type { DocSchema } from '../../shared/local-first/document'; // ── Document types ── +export interface SwagDesignProduct { + type: string; + provider: 'printful' | 'prodigi' | 'cosmolocal'; + sku: string; + variants: string[]; + retailPrice: number; +} + export interface SwagDesign { id: string; title: string; productType: 'sticker' | 'poster' | 'tee' | 'hoodie'; /** Server artifact ID (if generated) */ artifactId: string | null; + /** How this design was created */ + source: 'upload' | 'ai-generated' | 'artifact'; + /** Design lifecycle status */ + status: 'draft' | 'active' | 'paused' | 'removed'; + /** URL to the design image (server-side path) */ + imageUrl: string | null; + /** POD product configurations for this design */ + products: SwagDesignProduct[]; + /** Design slug (URL-friendly identifier) */ + slug: string | null; + /** Description / concept text */ + description: string | null; + /** Tags for categorization */ + tags: string[]; createdBy: string | null; createdAt: number; updatedAt: number; @@ -41,12 +63,12 @@ export interface SwagDoc { export const swagSchema: DocSchema = { module: 'swag', collection: 'designs', - version: 1, + version: 2, init: (): SwagDoc => ({ meta: { module: 'swag', collection: 'designs', - version: 1, + version: 2, spaceSlug: '', createdAt: Date.now(), }, @@ -56,7 +78,17 @@ export const swagSchema: DocSchema = { migrate: (doc: any, _fromVersion: number) => { if (!doc.designs) doc.designs = {}; if (!('activeDesignId' in doc)) doc.activeDesignId = null; - doc.meta.version = 1; + // v2 migration: add new fields to existing designs + for (const design of Object.values(doc.designs) as any[]) { + if (!('source' in design)) design.source = 'artifact'; + if (!('status' in design)) design.status = design.artifactId ? 'active' : 'draft'; + if (!('imageUrl' in design)) design.imageUrl = null; + if (!('products' in design)) design.products = []; + if (!('slug' in design)) design.slug = null; + if (!('description' in design)) design.description = null; + if (!('tags' in design)) design.tags = []; + } + doc.meta.version = 2; return doc; }, }; diff --git a/modules/rvote/components/folk-vote-dashboard.ts b/modules/rvote/components/folk-vote-dashboard.ts index 383e71a..dd9259d 100644 --- a/modules/rvote/components/folk-vote-dashboard.ts +++ b/modules/rvote/components/folk-vote-dashboard.ts @@ -33,6 +33,9 @@ interface Proposal { final_yes: number; final_no: number; final_abstain: number; + elo: number; + elo_comparisons: number; + elo_wins: number; created_at: string; voting_ends_at: string | null; } @@ -46,7 +49,7 @@ interface ScoreSnapshot { class FolkVoteDashboard extends HTMLElement { private shadow: ShadowRoot; private space = ""; - private view: "spaces" | "proposals" | "proposal" = "spaces"; + private view: "spaces" | "proposals" | "proposal" | "rank" = "spaces"; private spaces: VoteSpace[] = []; private selectedSpace: VoteSpace | null = null; private proposals: Proposal[] = []; @@ -57,7 +60,14 @@ class FolkVoteDashboard extends HTMLElement { private showTrendChart = true; private scoreHistory: ScoreSnapshot[] = []; private _offlineUnsubs: (() => void)[] = []; - private _history = new ViewHistory<"spaces" | "proposals" | "proposal">("spaces"); + private _history = new ViewHistory<"spaces" | "proposals" | "proposal" | "rank">("spaces"); + + // Pairwise ranking state + private rankPairA: Proposal | null = null; + private rankPairB: Proposal | null = null; + private rankCompareCount = 0; + private rankResult: { winnerId: string; delta: number } | null = null; + private rankLoading = false; // Guided tour private _tour!: TourEngine; @@ -110,6 +120,7 @@ class FolkVoteDashboard extends HTMLElement { score: d.proposal.score, vote_count: String(Object.keys(d.votes || {}).length), final_yes: d.proposal.finalYes, final_no: d.proposal.finalNo, final_abstain: d.proposal.finalAbstain, + elo: d.pairwise?.elo ?? 1500, elo_comparisons: d.pairwise?.comparisons ?? 0, elo_wins: d.pairwise?.wins ?? 0, created_at: new Date(d.proposal.createdAt).toISOString(), voting_ends_at: d.proposal.votingEndsAt ? new Date(d.proposal.votingEndsAt).toISOString() : null, }); @@ -151,7 +162,7 @@ class FolkVoteDashboard extends HTMLElement { status: "RANKING", score: 72, vote_count: "9", - final_yes: 0, final_no: 0, final_abstain: 0, + final_yes: 0, final_no: 0, final_abstain: 0, elo: 1500, elo_comparisons: 0, elo_wins: 0, created_at: new Date(now - 3 * day).toISOString(), voting_ends_at: null, }, @@ -162,7 +173,7 @@ class FolkVoteDashboard extends HTMLElement { status: "RANKING", score: 45, vote_count: "6", - final_yes: 0, final_no: 0, final_abstain: 0, + final_yes: 0, final_no: 0, final_abstain: 0, elo: 1500, elo_comparisons: 0, elo_wins: 0, created_at: new Date(now - 5 * day).toISOString(), voting_ends_at: null, }, @@ -173,7 +184,7 @@ class FolkVoteDashboard extends HTMLElement { status: "VOTING", score: 105, vote_count: "14", - final_yes: 5, final_no: 2, final_abstain: 0, + final_yes: 5, final_no: 2, final_abstain: 0, elo: 1568, elo_comparisons: 8, elo_wins: 5, created_at: new Date(now - 10 * day).toISOString(), voting_ends_at: new Date(now + 5 * day).toISOString(), }, @@ -184,7 +195,7 @@ class FolkVoteDashboard extends HTMLElement { status: "PASSED", score: 150, vote_count: "17", - final_yes: 12, final_no: 3, final_abstain: 2, + final_yes: 12, final_no: 3, final_abstain: 2, elo: 1612, elo_comparisons: 12, elo_wins: 9, created_at: new Date(now - 21 * day).toISOString(), voting_ends_at: new Date(now - 7 * day).toISOString(), }, @@ -195,7 +206,7 @@ class FolkVoteDashboard extends HTMLElement { status: "FAILED", score: 30, vote_count: "11", - final_yes: 2, final_no: 8, final_abstain: 1, + final_yes: 2, final_no: 8, final_abstain: 1, elo: 1432, elo_comparisons: 10, elo_wins: 3, created_at: new Date(now - 18 * day).toISOString(), voting_ends_at: new Date(now - 4 * day).toISOString(), }, @@ -358,7 +369,7 @@ class FolkVoteDashboard extends HTMLElement { status: "RANKING", score: 0, vote_count: "0", - final_yes: 0, final_no: 0, final_abstain: 0, + final_yes: 0, final_no: 0, final_abstain: 0, elo: 1500, elo_comparisons: 0, elo_wins: 0, created_at: new Date(now).toISOString(), voting_ends_at: null, }); @@ -610,6 +621,83 @@ class FolkVoteDashboard extends HTMLElement { width: 8px; height: 8px; border-radius: 2px; flex-shrink: 0; } + /* Elo badge on proposal cards */ + .elo-badge { + display: inline-flex; align-items: center; gap: 2px; + padding: 1px 6px; border-radius: 4px; + background: rgba(251,191,36,0.1); color: #fbbf24; + font-size: 11px; font-weight: 600; font-variant-numeric: tabular-nums; + } + + /* Rank button */ + .btn-rank { + padding: 8px 16px; border-radius: 8px; border: 1px solid rgba(251,191,36,0.4); + background: rgba(251,191,36,0.08); color: #fbbf24; cursor: pointer; font-size: 13px; + font-weight: 600; transition: all 0.15s; white-space: nowrap; + } + .btn-rank:hover { border-color: #fbbf24; background: rgba(251,191,36,0.15); } + + /* Rank view — pairwise comparison */ + .rank-header { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; } + .rank-pair { + display: grid; grid-template-columns: 1fr 1fr; gap: 16px; + margin-bottom: 20px; + } + .rank-card { + background: var(--rs-bg-page); border: 2px solid var(--rs-border-subtle); border-radius: 14px; + padding: 20px; cursor: pointer; transition: all 0.2s; position: relative; + } + .rank-card:hover { border-color: var(--rs-primary-hover); transform: translateY(-2px); box-shadow: var(--rs-shadow-md); } + .rank-card.winner { + border-color: #22c55e; background: rgba(34,197,94,0.05); + animation: rankPulse 0.5s ease-out; + } + .rank-card.loser { opacity: 0.5; } + .rank-card-title { font-size: 16px; font-weight: 700; margin-bottom: 6px; color: var(--rs-text-primary); line-height: 1.4; } + .rank-card-desc { font-size: 13px; color: var(--rs-text-muted); margin-bottom: 12px; line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } + .rank-card-stats { display: flex; gap: 12px; flex-wrap: wrap; } + .rank-card-stat { font-size: 12px; color: var(--rs-text-secondary); } + .rank-card-stat strong { font-weight: 700; } + .rank-session-info { + display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; + font-size: 13px; color: var(--rs-text-muted); + } + .rank-skip { + padding: 6px 14px; border-radius: 8px; border: 1px solid var(--rs-border); + background: transparent; color: var(--rs-text-muted); cursor: pointer; font-size: 12px; + transition: all 0.15s; + } + .rank-skip:hover { border-color: var(--rs-border-strong); color: var(--rs-text-secondary); } + + /* Elo Leaderboard */ + .elo-leaderboard { margin-top: 20px; } + .elo-leaderboard-title { + font-size: 12px; font-weight: 600; color: var(--rs-text-muted); text-transform: uppercase; + letter-spacing: 0.05em; margin-bottom: 8px; + } + .elo-row { + display: flex; align-items: center; gap: 10px; padding: 8px 12px; + border-bottom: 1px solid var(--rs-border-subtle); font-size: 13px; + } + .elo-row:last-child { border-bottom: none; } + .elo-rank-num { width: 24px; font-weight: 700; color: var(--rs-text-muted); text-align: center; font-variant-numeric: tabular-nums; } + .elo-row-title { flex: 1; color: var(--rs-text-primary); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .elo-row-rating { font-weight: 700; font-variant-numeric: tabular-nums; color: #fbbf24; min-width: 48px; text-align: right; } + .elo-row-meta { font-size: 11px; color: var(--rs-text-muted); min-width: 60px; text-align: right; } + + .rank-delta { + position: absolute; top: 8px; right: 12px; + font-size: 14px; font-weight: 700; font-variant-numeric: tabular-nums; + } + .rank-delta.positive { color: #22c55e; } + .rank-delta.negative { color: #ef4444; } + + @keyframes rankPulse { + 0% { box-shadow: 0 0 0 0 rgba(34,197,94,0.4); } + 70% { box-shadow: 0 0 0 8px rgba(34,197,94,0); } + 100% { box-shadow: none; } + } + @media (max-width: 768px) { .tally { gap: 12px; } .detail { padding: 16px; } @@ -617,6 +705,7 @@ class FolkVoteDashboard extends HTMLElement { .trend-section { padding: 12px 14px; } } @media (max-width: 640px) { + .rank-pair { grid-template-columns: 1fr; } .tally { gap: 12px; } .tally-value { font-size: 20px; } } @@ -657,6 +746,9 @@ class FolkVoteDashboard extends HTMLElement { startTour() { this._tour.start(); } private renderView(): string { + if (this.view === "rank" && this.selectedSpace) { + return this.renderRank(); + } if (this.view === "proposal" && this.selectedProposal) { return this.renderProposal(); } @@ -723,6 +815,7 @@ class FolkVoteDashboard extends HTMLElement { ${this._history.canGoBack && this.spaces.length > 1 ? '' : ''} ${this.esc(s.name)} ${this.proposals.length} proposal${this.proposals.length !== 1 ? "s" : ""} +
@@ -832,6 +925,7 @@ class FolkVoteDashboard extends HTMLElement {
${p.vote_count} vote${p.vote_count !== "1" ? "s" : ""} + ${(p.elo ?? 0) !== 1500 || (p.elo_comparisons ?? 0) > 0 ? `⚡ ${p.elo ?? 1500}` : ''} ${this.relativeTime(p.created_at)} ${p.status === "VOTING" && p.voting_ends_at ? `${this.daysLeft(p.voting_ends_at)}` : ""} ${p.status === "RANKING" ? `${Math.round(threshold - p.score)} to advance` : ""} @@ -926,6 +1020,167 @@ class FolkVoteDashboard extends HTMLElement { `; } + private async loadPair() { + if (!this.selectedSpace) return; + this.rankLoading = true; + this.rankResult = null; + this.render(); + try { + if (this.space === "demo") { + // Demo mode: pick two random RANKING proposals + const ranking = this.proposals.filter(p => p.status === "RANKING"); + if (ranking.length < 2) { + this.rankPairA = null; + this.rankPairB = null; + } else { + const shuffled = [...ranking].sort(() => Math.random() - 0.5); + this.rankPairA = shuffled[0]; + this.rankPairB = shuffled[1]; + } + } else { + const base = this.getApiBase(); + const res = await fetch(`${base}/api/proposals/pair?space_slug=${this.selectedSpace.slug}`); + if (res.ok) { + const data = await res.json(); + this.rankPairA = data.a; + this.rankPairB = data.b; + } else { + this.rankPairA = null; + this.rankPairB = null; + } + } + } catch { + this.rankPairA = null; + this.rankPairB = null; + } + this.rankLoading = false; + this.render(); + } + + private async submitComparison(winnerId: string, loserId: string) { + this.rankResult = { winnerId, delta: 0 }; + this.render(); + + if (this.space === "demo") { + // Simple demo Elo update + const winner = this.proposals.find(p => p.id === winnerId); + const loser = this.proposals.find(p => p.id === loserId); + if (winner && loser) { + const wElo = winner.elo ?? 1500; + const lElo = loser.elo ?? 1500; + const expected = 1 / (1 + Math.pow(10, (lElo - wElo) / 400)); + const delta = Math.round(32 * (1 - expected)); + winner.elo = wElo + delta; + winner.elo_comparisons = (winner.elo_comparisons ?? 0) + 1; + winner.elo_wins = (winner.elo_wins ?? 0) + 1; + loser.elo = lElo - delta; + loser.elo_comparisons = (loser.elo_comparisons ?? 0) + 1; + this.rankResult = { winnerId, delta }; + this.rankCompareCount++; + this.render(); + } + setTimeout(() => this.loadPair(), 500); + return; + } + + try { + const base = this.getApiBase(); + const res = await authFetch(`${base}/api/proposals/compare`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ winnerId, loserId }), + }); + if (res.ok) { + const data = await res.json(); + this.rankResult = { winnerId, delta: data.winner.delta }; + this.rankCompareCount++; + } + } catch { /* fail silently */ } + + this.render(); + setTimeout(() => this.loadPair(), 500); + } + + private renderRank(): string { + const s = this.selectedSpace!; + const ranking = this.proposals.filter(p => p.status === "RANKING") + .sort((a, b) => (b.elo ?? 1500) - (a.elo ?? 1500)); + + let pairHtml = ''; + if (this.rankLoading) { + pairHtml = '
Loading pair...
'; + } else if (!this.rankPairA || !this.rankPairB) { + pairHtml = `
🎲
Need at least 2 ranking proposals to compare.
`; + } else { + const a = this.rankPairA; + const b = this.rankPairB; + const aIsWinner = this.rankResult?.winnerId === a.id; + const bIsWinner = this.rankResult?.winnerId === b.id; + + pairHtml = ` +
+ Which proposal should be higher priority? + +
+
+
+ ${this.rankResult && aIsWinner ? `+${this.rankResult.delta}` : ''} + ${this.rankResult && !aIsWinner ? `-${this.rankResult.delta}` : ''} +
${this.esc(a.title)}
+
${this.esc(a.description || '')}
+
+ ${a.elo ?? 1500} + ${a.elo_comparisons ?? 0} comparisons + Score: ${Math.round(a.score)} +
+
+
+ ${this.rankResult && bIsWinner ? `+${this.rankResult.delta}` : ''} + ${this.rankResult && !bIsWinner ? `-${this.rankResult.delta}` : ''} +
${this.esc(b.title)}
+
${this.esc(b.description || '')}
+
+ ${b.elo ?? 1500} + ${b.elo_comparisons ?? 0} comparisons + Score: ${Math.round(b.score)} +
+
+
+ `; + } + + // Elo leaderboard + const leaderboard = ranking.map((p, i) => ` +
+ ${i + 1} + ${this.esc(p.title)} + ⚡ ${p.elo ?? 1500} + ${p.elo_comparisons ?? 0} cmp / ${p.elo_wins ?? 0}W +
+ `).join(''); + + return ` +
+ + 🎲 Pairwise Ranking + ${this.rankCompareCount} comparisons this session +
+ + ${this.space === "demo" ? ` +
+ Demo mode — Elo updates are local only. Create a space for persistent rankings. +
+ ` : ""} + + ${pairHtml} + +
+
Elo Leaderboard
+ ${leaderboard || '
No ranking proposals yet.
'} +
+ `; + } + private renderCreateForm(): string { return `
@@ -1087,6 +1342,28 @@ class FolkVoteDashboard extends HTMLElement { }); }); + // Rank view button + this.shadow.querySelector("[data-goto-rank]")?.addEventListener("click", () => { + this._history.push(this.view); + this.view = "rank"; + this.loadPair(); + }); + + // Rank pick (click a card to select winner) + this.shadow.querySelectorAll("[data-rank-pick]").forEach(el => { + el.addEventListener("click", () => { + if (this.rankResult) return; // already picked + const winnerId = (el as HTMLElement).dataset.rankPick!; + const loserId = (el as HTMLElement).dataset.rankLose!; + this.submitComparison(winnerId, loserId); + }); + }); + + // Rank skip + this.shadow.querySelector("[data-rank-skip]")?.addEventListener("click", () => { + this.loadPair(); + }); + // Toggle trend chart this.shadow.querySelector("[data-toggle-trend]")?.addEventListener("click", () => { this.showTrendChart = !this.showTrendChart; diff --git a/modules/rvote/mod.ts b/modules/rvote/mod.ts index 8b66955..86cc446 100644 --- a/modules/rvote/mod.ts +++ b/modules/rvote/mod.ts @@ -18,7 +18,7 @@ import type { RSpaceModule } from "../../shared/module"; import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; import { renderLanding } from "./landing"; import type { SyncServer } from '../../server/local-first/sync-server'; -import { proposalSchema, proposalDocId } from './schemas'; +import { proposalSchema, proposalDocId, computeElo, ELO_DEFAULT } from './schemas'; import type { ProposalDoc, SpaceConfig } from './schemas'; const routes = new Hono(); @@ -142,6 +142,7 @@ function spaceConfigToRest(cfg: SpaceConfig) { function proposalToRest(doc: ProposalDoc) { const p = doc.proposal; const voteCount = Object.keys(doc.votes).length; + const pw = doc.pairwise || { elo: ELO_DEFAULT, comparisons: 0, wins: 0 }; return { id: p.id, space_slug: p.spaceSlug, @@ -155,6 +156,9 @@ function proposalToRest(doc: ProposalDoc) { final_no: p.finalNo, final_abstain: p.finalAbstain, vote_count: String(voteCount), + elo: pw.elo, + elo_comparisons: pw.comparisons, + elo_wins: pw.wins, created_at: new Date(p.createdAt).toISOString(), updated_at: new Date(p.updatedAt).toISOString(), }; @@ -506,6 +510,102 @@ routes.post("/api/proposals/:id/final-vote", async (c) => { return c.json({ ok: true, tally }); }); +// ── Pairwise comparison API ── + +// GET /api/proposals/pair — get a random pair for Elo comparison +routes.get("/api/proposals/pair", (c) => { + const spaceSlug = c.req.query("space_slug"); + if (!spaceSlug) return c.json({ error: "space_slug required" }, 400); + + const docs = listProposalDocs(spaceSlug); + const eligible = docs.filter(d => d.doc.proposal.title && d.doc.proposal.status === "RANKING"); + if (eligible.length < 2) return c.json({ error: "Need at least 2 ranking proposals" }, 400); + + // Sortition: weight toward proposals with fewer comparisons + const weights = eligible.map(d => { + const pw = d.doc.pairwise || { elo: ELO_DEFAULT, comparisons: 0, wins: 0 }; + return 1 / (1 + pw.comparisons); + }); + const totalWeight = weights.reduce((s, w) => s + w, 0); + + // Pick first proposal weighted by comparisons + let r = Math.random() * totalWeight; + let aIdx = 0; + for (let i = 0; i < weights.length; i++) { + r -= weights[i]; + if (r <= 0) { aIdx = i; break; } + } + + // Pick second proposal (different from first), also weighted + const remaining = eligible.filter((_, i) => i !== aIdx); + const rWeights = remaining.map(d => { + const pw = d.doc.pairwise || { elo: ELO_DEFAULT, comparisons: 0, wins: 0 }; + return 1 / (1 + pw.comparisons); + }); + const rTotal = rWeights.reduce((s, w) => s + w, 0); + let r2 = Math.random() * rTotal; + let bIdx = 0; + for (let i = 0; i < rWeights.length; i++) { + r2 -= rWeights[i]; + if (r2 <= 0) { bIdx = i; break; } + } + + return c.json({ + a: proposalToRest(eligible[aIdx].doc), + b: proposalToRest(remaining[bIdx].doc), + }); +}); + +// POST /api/proposals/compare — record a pairwise comparison result +routes.post("/api/proposals/compare", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const body = await c.req.json(); + const { winnerId, loserId } = body; + if (!winnerId || !loserId) return c.json({ error: "winnerId and loserId required" }, 400); + if (winnerId === loserId) return c.json({ error: "Cannot compare proposal to itself" }, 400); + + // Find both proposals + const allDocs = listAllProposalDocs(); + const winnerMatch = allDocs.find(d => d.doc.proposal.id === winnerId); + const loserMatch = allDocs.find(d => d.doc.proposal.id === loserId); + if (!winnerMatch || !loserMatch) return c.json({ error: "Proposal not found" }, 404); + + const winnerPw = winnerMatch.doc.pairwise || { elo: ELO_DEFAULT, comparisons: 0, wins: 0 }; + const loserPw = loserMatch.doc.pairwise || { elo: ELO_DEFAULT, comparisons: 0, wins: 0 }; + + const result = computeElo(winnerPw.elo, loserPw.elo); + const winnerDelta = result.winner - winnerPw.elo; + const loserDelta = result.loser - loserPw.elo; + + // Update winner + _syncServer!.changeDoc(winnerMatch.docId, 'pairwise win', (d) => { + if (!d.pairwise) { + (d as any).pairwise = { elo: ELO_DEFAULT, comparisons: 0, wins: 0 }; + } + d.pairwise.elo = result.winner; + d.pairwise.comparisons += 1; + d.pairwise.wins += 1; + }); + + // Update loser + _syncServer!.changeDoc(loserMatch.docId, 'pairwise loss', (d) => { + if (!d.pairwise) { + (d as any).pairwise = { elo: ELO_DEFAULT, comparisons: 0, wins: 0 }; + } + d.pairwise.elo = result.loser; + d.pairwise.comparisons += 1; + }); + + return c.json({ + ok: true, + winner: { elo: result.winner, delta: winnerDelta }, + loser: { elo: result.loser, delta: loserDelta }, + }); +}); + // ── Demo page body (reused by / when space=demo, and /demo fallback) ── function renderDemoBody(): string { @@ -696,6 +796,7 @@ export const voteModule: RSpaceModule = { outputPaths: [ { path: "proposals", name: "Proposals", icon: "📜", description: "Governance proposals for conviction voting" }, { path: "ballots", name: "Ballots", icon: "🗳️", description: "Voting ballots and results" }, + { path: "rank", name: "Rank", icon: "🎲", description: "Pairwise Elo ranking via sortition" }, ], onboardingActions: [ { label: "Create a Proposal", icon: "🗳️", description: "Start a governance vote", type: 'create', href: '/{space}/rvote' }, diff --git a/modules/rvote/schemas.ts b/modules/rvote/schemas.ts index 4739eeb..397a563 100644 --- a/modules/rvote/schemas.ts +++ b/modules/rvote/schemas.ts @@ -61,6 +61,12 @@ export interface ProposalMeta { updatedAt: number; } +export interface PairwiseData { + elo: number; // Current Elo rating (default 1500) + comparisons: number; // Total times shown in a pair + wins: number; // Times chosen as preferred +} + export interface ProposalDoc { meta: { module: string; @@ -73,6 +79,18 @@ export interface ProposalDoc { proposal: ProposalMeta; votes: Record; finalVotes: Record; + pairwise: PairwiseData; +} + +// ── Elo rating helpers ── + +export const ELO_K = 32; +export const ELO_DEFAULT = 1500; + +export function computeElo(winnerElo: number, loserElo: number): { winner: number; loser: number } { + const expected = 1 / (1 + Math.pow(10, (loserElo - winnerElo) / 400)); + const delta = Math.round(ELO_K * (1 - expected)); + return { winner: winnerElo + delta, loser: loserElo - delta }; } // ── Schema registration ── @@ -107,6 +125,7 @@ export const proposalSchema: DocSchema = { }, votes: {}, finalVotes: {}, + pairwise: { elo: ELO_DEFAULT, comparisons: 0, wins: 0 }, }), }; diff --git a/modules/rwallet/components/folk-wallet-viewer.ts b/modules/rwallet/components/folk-wallet-viewer.ts index b769fac..947b89a 100644 --- a/modules/rwallet/components/folk-wallet-viewer.ts +++ b/modules/rwallet/components/folk-wallet-viewer.ts @@ -262,10 +262,18 @@ class FolkWalletViewer extends HTMLElement { const parsed = JSON.parse(session); if (parsed.claims?.exp > Math.floor(Date.now() / 1000)) { this.isAuthenticated = true; - this.topTab = "my-wallets"; + // Default to visualizer tab — user's wallet auto-populates the address this.passKeyEOA = parsed.claims?.eid?.walletAddress || ""; this.userDID = parsed.claims?.did || ""; - this.loadLinkedWallets().then(() => this.loadMyWalletBalances()); + this.loadLinkedWallets().then(() => { + this.loadMyWalletBalances(); + // If no passkey EOA but linked wallets exist, use first linked wallet + if (!this.address && !this.passKeyEOA && this.linkedWallets.length > 0) { + this.address = this.linkedWallets[0].address; + this.render(); + this.detectChains(); + } + }); this.loadCRDTBalances(); } } diff --git a/server/index.ts b/server/index.ts index 7756f79..f041ea4 100644 --- a/server/index.ts +++ b/server/index.ts @@ -69,12 +69,12 @@ import { splatModule } from "../modules/rsplat/mod"; import { photosModule } from "../modules/rphotos/mod"; import { socialsModule } from "../modules/rsocials/mod"; import { meetsModule } from "../modules/rmeets/mod"; +import { chatsModule } from "../modules/rchats/mod"; // import { docsModule } from "../modules/rdocs/mod"; // import { designModule } from "../modules/rdesign/mod"; import { scheduleModule } from "../modules/rschedule/mod"; import { bnbModule } from "../modules/rbnb/mod"; import { vnbModule } from "../modules/rvnb/mod"; -import { crowdsurfModule } from "../modules/crowdsurf/mod"; import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces"; import type { SpaceRoleString } from "./spaces"; import { renderShell, renderModuleLanding, renderSubPageInfo, renderOnboarding } from "./shell"; @@ -115,9 +115,9 @@ registerModule(photosModule); registerModule(socialsModule); registerModule(scheduleModule); registerModule(meetsModule); +registerModule(chatsModule); registerModule(bnbModule); registerModule(vnbModule); -registerModule(crowdsurfModule); // De-emphasized modules (bottom of menu) registerModule(forumModule); registerModule(tubeModule); diff --git a/server/shell.ts b/server/shell.ts index 02da16f..06e2358 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -12,6 +12,50 @@ import { getDocumentData } from "./community-store"; // ── Browser compatibility polyfills (inline, runs before ES modules) ── const COMPAT_POLYFILLS = ``; +// ── Dynamic per-module favicon (inline, runs after body parse) ── +// Badge map mirrors MODULE_BADGES from rstack-app-switcher.ts — kept in sync manually. +const FAVICON_BADGE_MAP: Record = { + rspace: { badge: "r🎨", color: "#5eead4" }, + rnotes: { badge: "r📝", color: "#fcd34d" }, + rpubs: { badge: "r📖", color: "#fda4af" }, + rswag: { badge: "r👕", color: "#fda4af" }, + rsplat: { badge: "r🔮", color: "#d8b4fe" }, + rcal: { badge: "r📅", color: "#7dd3fc" }, + rtrips: { badge: "r✈️", color: "#6ee7b7" }, + rmaps: { badge: "r🗺", color: "#86efac" }, + rchats: { badge: "r🗨", color: "#6ee7b7" }, + rinbox: { badge: "r📨", color: "#a5b4fc" }, + rmail: { badge: "r✉️", color: "#93c5fd" }, + rforum: { badge: "r💬", color: "#fcd34d" }, + rmeets: { badge: "r📹", color: "#67e8f9" }, + rchoices: { badge: "r☑️", color: "#f0abfc" }, + rvote: { badge: "r🗳", color: "#c4b5fd" }, + rflows: { badge: "r🌊", color: "#bef264" }, + rwallet: { badge: "r💰", color: "#fde047" }, + rcart: { badge: "r🛒", color: "#fdba74" }, + rauctions: { badge: "r🏛", color: "#fca5a5" }, + rtube: { badge: "r🎬", color: "#f9a8d4" }, + rphotos: { badge: "r📸", color: "#f9a8d4" }, + rnetwork: { badge: "r🌐", color: "#93c5fd" }, + rsocials: { badge: "r📢", color: "#7dd3fc" }, + rfiles: { badge: "r📁", color: "#67e8f9" }, + rbooks: { badge: "r📚", color: "#fda4af" }, + rdata: { badge: "r📊", color: "#d8b4fe" }, + rbnb: { badge: "r🏠", color: "#fbbf24" }, + rvnb: { badge: "r🚐", color: "#a5f3fc" }, + rtasks: { badge: "r📋", color: "#cbd5e1" }, + rschedule: { badge: "r⏱", color: "#a5b4fc" }, + rids: { badge: "r🪪", color: "#6ee7b7" }, + rstack: { badge: "r✨", color: "#c4b5fd" }, +}; + +const FAVICON_BADGE_JSON = JSON.stringify(FAVICON_BADGE_MAP); + +/** Generate an inline script that sets the favicon to the module's badge SVG */ +function faviconScript(moduleId: string): string { + return ``; +} + // ── Content-hash cache busting ── let moduleHashes: Record = {}; try { @@ -136,6 +180,7 @@ export function renderShell(opts: ShellOptions): string { + ${faviconScript(moduleId)} @@ -1139,6 +1184,7 @@ export function renderExternalAppShell(opts: ExternalAppShellOptions): string { + ${faviconScript(moduleId)} ${escapeHtml(title)} ${COMPAT_POLYFILLS} @@ -1740,6 +1786,7 @@ export function renderModuleLanding(opts: ModuleLandingOptions): string { + ${faviconScript(mod.id)} @@ -2085,6 +2132,7 @@ export function renderSubPageInfo(opts: SubPageInfoOptions): string { + ${faviconScript(mod.id)} diff --git a/shared/components/rstack-app-switcher.ts b/shared/components/rstack-app-switcher.ts index f4befe6..5eb78db 100644 --- a/shared/components/rstack-app-switcher.ts +++ b/shared/components/rstack-app-switcher.ts @@ -53,9 +53,10 @@ const MODULE_BADGES: Record = { rbooks: { badge: "r📚", color: "#fda4af" }, // rose-300 // Observing rdata: { badge: "r📊", color: "#d8b4fe" }, // purple-300 - // Sharing & Hospitality + // Travel & Stay rbnb: { badge: "r🏠", color: "#fbbf24" }, // amber-300 - // Work & Productivity + rvnb: { badge: "r🚐", color: "#a5f3fc" }, // cyan-200 + // Coordinate rtasks: { badge: "r📋", color: "#cbd5e1" }, // slate-300 rschedule: { badge: "r⏱", color: "#a5b4fc" }, // indigo-200 // Identity & Infrastructure @@ -65,50 +66,59 @@ const MODULE_BADGES: Record = { // Category definitions for the rApp dropdown (display-only grouping) const MODULE_CATEGORIES: Record = { - rspace: "Creating", - rnotes: "Creating", - rpubs: "Creating", - rtube: "Creating", - rswag: "Creating", - rsplat: "Creating", - rcal: "Connecting", - rtrips: "Planning", - rmaps: "Planning", - rchats: "Communicating", - rmail: "Communicating", - rforum: "Communicating", - rmeets: "Communicating", - rchoices: "Deciding", - rvote: "Deciding", - rflows: "Funding & Commerce", - rwallet: "Funding & Commerce", - rcart: "Funding & Commerce", - rauctions: "Funding & Commerce", - rphotos: "Sharing", - rsocials: "Sharing", - rfiles: "Sharing", - rbooks: "Sharing", - rinbox: "Connecting", - rnetwork: "Connecting", - rbnb: "Sharing", - rdata: "Observing", - rtasks: "Work & Productivity", - rschedule: "Work & Productivity", - rids: "Identity & Infrastructure", - rstack: "Identity & Infrastructure", + // Create + rspace: "Create", + rnotes: "Create", + rpubs: "Create", + rsplat: "Create", + rswag: "Create", + // Communicate + rchats: "Communicate", + rinbox: "Communicate", + rmail: "Communicate", + rforum: "Communicate", + rmeets: "Communicate", + // Coordinate + rcal: "Coordinate", + rschedule: "Coordinate", + rtasks: "Coordinate", + rchoices: "Coordinate", + rvote: "Coordinate", + // Connect + rnetwork: "Connect", + rsocials: "Connect", + // Commerce + rflows: "Commerce", + rwallet: "Commerce", + rcart: "Commerce", + rauctions: "Commerce", + // Media + rphotos: "Media", + rfiles: "Media", + rtube: "Media", + rbooks: "Media", + // Travel & Stay + rmaps: "Travel & Stay", + rtrips: "Travel & Stay", + rbnb: "Travel & Stay", + rvnb: "Travel & Stay", + // Observe + rdata: "Observe", + // Platform + rids: "Platform", + rstack: "Platform", }; const CATEGORY_ORDER = [ - "Creating", - "Planning", - "Connecting", - "Communicating", - "Deciding", - "Funding & Commerce", - "Sharing", - "Observing", - "Work & Productivity", - "Identity & Infrastructure", + "Create", + "Communicate", + "Coordinate", + "Connect", + "Commerce", + "Media", + "Travel & Stay", + "Observe", + "Platform", ]; import { rspaceNavUrl, getCurrentSpace, isStandaloneDomain } from "../url-helpers";