Merge branch 'dev'
This commit is contained in:
commit
64aef258d1
|
|
@ -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
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
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
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [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)
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
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.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
/* CrowdSurf module layout */
|
|
||||||
main {
|
|
||||||
min-height: calc(100vh - 56px);
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
@ -1,781 +0,0 @@
|
||||||
/**
|
|
||||||
* <folk-crowdsurf-dashboard> — 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 = `
|
|
||||||
<style>${this.getStyles()}</style>
|
|
||||||
|
|
||||||
<div class="cs-app">
|
|
||||||
${this.loading ? '<div class="cs-loading">Loading...</div>' : this.renderActiveView(isLive)}
|
|
||||||
|
|
||||||
<!-- Bottom nav -->
|
|
||||||
<nav class="cs-nav">
|
|
||||||
<button class="cs-nav-btn${this.activeTab === 'discover' ? ' active' : ''}" data-tab="discover">
|
|
||||||
<span class="cs-nav-icon">🏄</span>
|
|
||||||
<span class="cs-nav-label">Discover</span>
|
|
||||||
</button>
|
|
||||||
<button class="cs-nav-btn${this.activeTab === 'create' ? ' active' : ''}" data-tab="create">
|
|
||||||
<span class="cs-nav-icon">✨</span>
|
|
||||||
<span class="cs-nav-label">Create</span>
|
|
||||||
</button>
|
|
||||||
<button class="cs-nav-btn${this.activeTab === 'profile' ? ' active' : ''}" data-tab="profile">
|
|
||||||
<span class="cs-nav-icon">👤</span>
|
|
||||||
<span class="cs-nav-label">Profile</span>
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 `
|
|
||||||
<div class="cs-header">
|
|
||||||
<span class="cs-title">CrowdSurf</span>
|
|
||||||
${isLive ? '<span class="cs-live"><span class="cs-live-dot"></span>LIVE</span>' : ''}
|
|
||||||
${this.space === 'demo' ? '<span class="cs-demo-badge">DEMO</span>' : ''}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cs-discover">
|
|
||||||
${prompt ? this.renderCard(prompt, activeCount) : this.renderNoCards()}
|
|
||||||
|
|
||||||
${triggeredPrompts.length > 0 ? `
|
|
||||||
<div class="cs-section-label">Triggered Activities</div>
|
|
||||||
<div class="cs-triggered-list">
|
|
||||||
${triggeredPrompts.map(p => `
|
|
||||||
<div class="cs-triggered-card">
|
|
||||||
<span class="cs-triggered-icon">🚀</span>
|
|
||||||
<div class="cs-triggered-info">
|
|
||||||
<div class="cs-triggered-title">${this.esc(p.text)}</div>
|
|
||||||
<div class="cs-triggered-meta">📍 ${this.esc(p.location)} · ${getRightSwipeCount(p)} people</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`).join('')}
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 `
|
|
||||||
<div class="cs-card-stack">
|
|
||||||
<div class="cs-card" id="cs-current-card">
|
|
||||||
<div class="cs-swipe-indicator cs-swipe-left">✗ Pass</div>
|
|
||||||
<div class="cs-swipe-indicator cs-swipe-right">✓ Join</div>
|
|
||||||
|
|
||||||
<div class="cs-card-body">
|
|
||||||
<div class="cs-card-text">${this.esc(prompt.text)}</div>
|
|
||||||
|
|
||||||
<div class="cs-card-location">📍 ${this.esc(prompt.location)}</div>
|
|
||||||
${prompt.activityDuration ? `<div class="cs-card-duration">⏱️ ${this.esc(prompt.activityDuration)}</div>` : ''}
|
|
||||||
|
|
||||||
${bringingAll.length > 0 ? `
|
|
||||||
<div class="cs-contrib-section">
|
|
||||||
<div class="cs-contrib-label">🎒 People are bringing:</div>
|
|
||||||
<div class="cs-contrib-tags">${bringingAll.map(b => `<span class="cs-tag">${this.esc(b)}</span>`).join('')}</div>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
${neededAll.length > 0 ? `
|
|
||||||
<div class="cs-contrib-section">
|
|
||||||
<div class="cs-contrib-label">✨ Still needed:</div>
|
|
||||||
<div class="cs-contrib-tags">${neededAll.map(n => `<span class="cs-tag needed">${this.esc(n)}</span>`).join('')}</div>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
|
|
||||||
<div class="cs-card-footer">
|
|
||||||
<div class="cs-pool">${rightSwipes}/${prompt.threshold} interested</div>
|
|
||||||
<div class="cs-progress-bar">
|
|
||||||
<div class="cs-progress-fill" style="width:${progressPct}%"></div>
|
|
||||||
${urgency === 'high' ? '<div class="cs-urgency-pulse"></div>' : ''}
|
|
||||||
</div>
|
|
||||||
<div class="cs-time ${urgency === 'high' ? 'urgent' : ''}">${timeLeft}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${isReadyToTrigger(prompt) ? '<div class="cs-trigger-msg">🚀 Group ready! Activity happening!</div>' : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="cs-card-count">${this.currentPromptIndex + 1} / ${totalActive}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cs-swipe-buttons">
|
|
||||||
<button class="cs-btn-skip" data-action="swipe-left">✗</button>
|
|
||||||
<button class="cs-btn-join" data-action="swipe-right">✓</button>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderNoCards(): string {
|
|
||||||
return `
|
|
||||||
<div class="cs-empty">
|
|
||||||
<div class="cs-empty-icon">🏄</div>
|
|
||||||
<p>No activities to discover right now.</p>
|
|
||||||
<p>Create one and get the wave started!</p>
|
|
||||||
<button class="cs-btn-create" data-action="go-create">Create Activity</button>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Create form ──
|
|
||||||
|
|
||||||
private renderCreateForm(): string {
|
|
||||||
return `
|
|
||||||
<div class="cs-header">
|
|
||||||
<span class="cs-title">New Activity</span>
|
|
||||||
</div>
|
|
||||||
<div class="cs-form">
|
|
||||||
<label class="cs-label">What's happening?</label>
|
|
||||||
<textarea id="cs-text" class="cs-input cs-textarea" rows="3" placeholder="Community garden planting, open mic night, repair cafe..."></textarea>
|
|
||||||
|
|
||||||
<label class="cs-label">Where?</label>
|
|
||||||
<input id="cs-location" type="text" class="cs-input" placeholder="Park, rooftop, community center...">
|
|
||||||
|
|
||||||
<div class="cs-form-row">
|
|
||||||
<div class="cs-form-col">
|
|
||||||
<label class="cs-label">People needed</label>
|
|
||||||
<input id="cs-threshold" type="number" class="cs-input" min="2" max="50" value="3">
|
|
||||||
</div>
|
|
||||||
<div class="cs-form-col">
|
|
||||||
<label class="cs-label">Expires in (hours)</label>
|
|
||||||
<input id="cs-duration" type="number" class="cs-input" min="1" max="168" value="4">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label class="cs-label">Activity duration</label>
|
|
||||||
<select id="cs-activity-duration" class="cs-input">
|
|
||||||
<option value="30 minutes">30 minutes</option>
|
|
||||||
<option value="1 hour" selected>1 hour</option>
|
|
||||||
<option value="2 hours">2 hours</option>
|
|
||||||
<option value="3 hours">3 hours</option>
|
|
||||||
<option value="half day">Half day</option>
|
|
||||||
<option value="all day">All day</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<div class="cs-divider"></div>
|
|
||||||
|
|
||||||
<label class="cs-label">🎒 What are you bringing?</label>
|
|
||||||
<textarea id="cs-bringing" class="cs-input cs-textarea" rows="2" placeholder="Guitar, cooking skills, projector..."></textarea>
|
|
||||||
|
|
||||||
<label class="cs-label">✨ What would be great to have?</label>
|
|
||||||
<textarea id="cs-needed" class="cs-input cs-textarea" rows="2" placeholder="More musicians, ingredients, curious people..."></textarea>
|
|
||||||
|
|
||||||
<button class="cs-btn-submit" data-action="create-prompt">Launch Activity 🚀</button>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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 `
|
|
||||||
<div class="cs-header">
|
|
||||||
<span class="cs-title">Profile</span>
|
|
||||||
</div>
|
|
||||||
<div class="cs-profile">
|
|
||||||
<div class="cs-stats-grid">
|
|
||||||
<div class="cs-stat-card">
|
|
||||||
<div class="cs-stat-num">${this.stats.joined}</div>
|
|
||||||
<div class="cs-stat-label">Joined</div>
|
|
||||||
</div>
|
|
||||||
<div class="cs-stat-card">
|
|
||||||
<div class="cs-stat-num">${this.stats.created}</div>
|
|
||||||
<div class="cs-stat-label">Created</div>
|
|
||||||
</div>
|
|
||||||
<div class="cs-stat-card">
|
|
||||||
<div class="cs-stat-num">${this.stats.triggered}</div>
|
|
||||||
<div class="cs-stat-label">Triggered</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cs-section-label">Community Stats</div>
|
|
||||||
<div class="cs-community-stats">
|
|
||||||
<div class="cs-community-row"><span>Active prompts</span><span>${this.prompts.filter(p => !p.expired && !p.triggered).length}</span></div>
|
|
||||||
<div class="cs-community-row"><span>Total triggered</span><span>${allTriggered.length}</span></div>
|
|
||||||
<div class="cs-community-row"><span>Total participants</span><span>${totalParticipants}</span></div>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Event binding ──
|
|
||||||
|
|
||||||
private bindEvents() {
|
|
||||||
// Tab navigation
|
|
||||||
this.shadow.querySelectorAll<HTMLElement>('.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);
|
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
/**
|
|
||||||
* CrowdSurf landing page — swipe to coordinate local activities.
|
|
||||||
*/
|
|
||||||
export function renderLanding(): string {
|
|
||||||
return `
|
|
||||||
<!-- Hero -->
|
|
||||||
<div class="rl-hero">
|
|
||||||
<span class="rl-tagline">Coordinate spontaneous activities</span>
|
|
||||||
<h1 class="rl-heading">What should <span style="color:#5eead4">your community</span><br>do today?</h1>
|
|
||||||
<p class="rl-subtext">
|
|
||||||
Swipe to discover. Commit to join. When enough people are in, it happens.
|
|
||||||
No planning committees. No group chat chaos. Just action.
|
|
||||||
</p>
|
|
||||||
<div class="rl-cta-row">
|
|
||||||
<a href="https://demo.rspace.online/crowdsurf" class="rl-cta-primary">Start Swiping</a>
|
|
||||||
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- How it works -->
|
|
||||||
<section class="rl-section">
|
|
||||||
<div class="rl-container">
|
|
||||||
<h2 class="rl-heading" style="text-align:center">How Crowdsurfing works</h2>
|
|
||||||
<div class="rl-grid-3">
|
|
||||||
<div class="rl-step">
|
|
||||||
<div class="rl-step__num">1</div>
|
|
||||||
<h3>Propose</h3>
|
|
||||||
<p>Someone has an idea — community garden day, open mic, repair cafe. They post it with a threshold: “happens when 5 people are in.”</p>
|
|
||||||
</div>
|
|
||||||
<div class="rl-step">
|
|
||||||
<div class="rl-step__num">2</div>
|
|
||||||
<h3>Swipe</h3>
|
|
||||||
<p>Community members discover activities by swiping. Right to join, left to skip. Declare what you’re bringing — skills, gear, food.</p>
|
|
||||||
</div>
|
|
||||||
<div class="rl-step">
|
|
||||||
<div class="rl-step__num">3</div>
|
|
||||||
<h3>Trigger</h3>
|
|
||||||
<p>When enough people commit, the activity triggers. The group forms, contributions are matched, and it just… happens.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Features -->
|
|
||||||
<section class="rl-section rl-section--alt">
|
|
||||||
<div class="rl-container">
|
|
||||||
<h2 class="rl-heading" style="text-align:center">Built for real communities</h2>
|
|
||||||
<div class="rl-grid-2" style="margin-top:2rem">
|
|
||||||
<div class="rl-card">
|
|
||||||
<h3 style="margin-bottom:0.35rem">Threshold triggers</h3>
|
|
||||||
<p>Activities only happen when enough people commit. No more “who’s coming?” anxiety. The threshold <em>is</em> the RSVP.</p>
|
|
||||||
</div>
|
|
||||||
<div class="rl-card">
|
|
||||||
<h3 style="margin-bottom:0.35rem">Contribution matching</h3>
|
|
||||||
<p>See what people are bringing and what’s still needed. Skills, equipment, food, space — the puzzle assembles itself.</p>
|
|
||||||
</div>
|
|
||||||
<div class="rl-card">
|
|
||||||
<h3 style="margin-bottom:0.35rem">Time urgency</h3>
|
|
||||||
<p>Proposals expire. The countdown creates momentum. As the window closes, urgency rises and commitment accelerates.</p>
|
|
||||||
</div>
|
|
||||||
<div class="rl-card">
|
|
||||||
<h3 style="margin-bottom:0.35rem">Real-time sync</h3>
|
|
||||||
<p>Powered by rSpace CRDT infrastructure. Every swipe syncs instantly across all participants. Offline-first, multiplayer by default.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Use cases -->
|
|
||||||
<section class="rl-section">
|
|
||||||
<div class="rl-container">
|
|
||||||
<h2 class="rl-heading" style="text-align:center">What will your community crowdsurf?</h2>
|
|
||||||
<div class="rl-grid-3" style="margin-top:2rem">
|
|
||||||
<div class="rl-card" style="text-align:center">
|
|
||||||
<div style="font-size:2rem;margin-bottom:0.5rem">🌱</div>
|
|
||||||
<h3 style="margin-bottom:0.25rem">Garden days</h3>
|
|
||||||
<p style="font-size:0.85rem">5 people + seedlings + shovels = community garden magic</p>
|
|
||||||
</div>
|
|
||||||
<div class="rl-card" style="text-align:center">
|
|
||||||
<div style="font-size:2rem;margin-bottom:0.5rem">🎸</div>
|
|
||||||
<h3 style="margin-bottom:0.25rem">Jam sessions</h3>
|
|
||||||
<p style="font-size:0.85rem">Musicians find each other. Instruments match up. Music emerges.</p>
|
|
||||||
</div>
|
|
||||||
<div class="rl-card" style="text-align:center">
|
|
||||||
<div style="font-size:2rem;margin-bottom:0.5rem">🔧</div>
|
|
||||||
<h3 style="margin-bottom:0.25rem">Repair cafes</h3>
|
|
||||||
<p style="font-size:0.85rem">Bring broken stuff, find fixers. Circular economy through coordination.</p>
|
|
||||||
</div>
|
|
||||||
<div class="rl-card" style="text-align:center">
|
|
||||||
<div style="font-size:2rem;margin-bottom:0.5rem">🍳</div>
|
|
||||||
<h3 style="margin-bottom:0.25rem">Community meals</h3>
|
|
||||||
<p style="font-size:0.85rem">Someone cooks, others bring ingredients. Potluck, self-organized.</p>
|
|
||||||
</div>
|
|
||||||
<div class="rl-card" style="text-align:center">
|
|
||||||
<div style="font-size:2rem;margin-bottom:0.5rem">🧘</div>
|
|
||||||
<h3 style="margin-bottom:0.25rem">Wellness</h3>
|
|
||||||
<p style="font-size:0.85rem">Yoga by the canal. Group meditation. Movement in the park.</p>
|
|
||||||
</div>
|
|
||||||
<div class="rl-card" style="text-align:center">
|
|
||||||
<div style="font-size:2rem;margin-bottom:0.5rem">💻</div>
|
|
||||||
<h3 style="margin-bottom:0.25rem">Hackathons</h3>
|
|
||||||
<p style="font-size:0.85rem">Coders + designers + a space + caffeine = build something together.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- CTA -->
|
|
||||||
<section class="rl-section rl-section--alt">
|
|
||||||
<div class="rl-container" style="text-align:center">
|
|
||||||
<h2 class="rl-heading">Ready to ride the wave?</h2>
|
|
||||||
<p class="rl-subtext">Create a space for your community and start crowdsurfing.</p>
|
|
||||||
<div class="rl-cta-row">
|
|
||||||
<a href="https://demo.rspace.online/crowdsurf" class="rl-cta-primary">Try the Demo</a>
|
|
||||||
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="rl-back">
|
|
||||||
<a href="/">← Back to rSpace</a>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
@ -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<void> {
|
|
||||||
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<CrowdSurfDoc>(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<CrowdSurfDoc | null> {
|
|
||||||
const docId = crowdsurfDocId(this.#space) as DocumentId;
|
|
||||||
let doc = this.#documents.get<CrowdSurfDoc>(docId);
|
|
||||||
if (!doc) {
|
|
||||||
const binary = await this.#store.load(docId);
|
|
||||||
doc = binary
|
|
||||||
? this.#documents.open<CrowdSurfDoc>(docId, crowdsurfSchema, binary)
|
|
||||||
: this.#documents.open<CrowdSurfDoc>(docId, crowdsurfSchema);
|
|
||||||
}
|
|
||||||
await this.#sync.subscribe([docId]);
|
|
||||||
return doc ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
getDoc(): CrowdSurfDoc | undefined {
|
|
||||||
return this.#documents.get<CrowdSurfDoc>(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<CrowdSurfDoc>(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<CrowdSurfDoc>(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<CrowdSurfDoc>(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<CrowdSurfDoc>(docId, `Mark prompt expired`, (d) => {
|
|
||||||
if (d.prompts[promptId]) d.prompts[promptId].expired = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async disconnect(): Promise<void> {
|
|
||||||
await this.#sync.flush();
|
|
||||||
this.#sync.disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<string, any>)) {
|
|
||||||
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: `<folk-crowdsurf-dashboard space="${spaceSlug}"></folk-crowdsurf-dashboard>`,
|
|
||||||
scripts: `<script type="module" src="/modules/crowdsurf/folk-crowdsurf-dashboard.js?v=1"></script>`,
|
|
||||||
styles: `<link rel="stylesheet" href="/modules/crowdsurf/crowdsurf.css">`,
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 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<string, any>)
|
|
||||||
.filter((s: any) => !s.forgotten && promptTypes.includes(s.type));
|
|
||||||
if (existing.length > 0) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
const shapes: Record<string, unknown>[] = [
|
|
||||||
{
|
|
||||||
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" },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
@ -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<string, PromptSwipe>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Document root ──
|
|
||||||
|
|
||||||
export interface CrowdSurfDoc {
|
|
||||||
meta: {
|
|
||||||
module: string;
|
|
||||||
collection: string;
|
|
||||||
version: number;
|
|
||||||
spaceSlug: string;
|
|
||||||
createdAt: number;
|
|
||||||
};
|
|
||||||
prompts: Record<string, CrowdSurfPrompt>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Schema registration ──
|
|
||||||
|
|
||||||
export const crowdsurfSchema: DocSchema<CrowdSurfDoc> = {
|
|
||||||
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<string>();
|
|
||||||
|
|
||||||
const categoryKeywords: Record<string, string[]> = {
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,150 @@
|
||||||
|
/**
|
||||||
|
* rChats landing page — rich content for rspace.online/rchats
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function renderLanding(): string {
|
||||||
|
return `
|
||||||
|
<!-- Hero -->
|
||||||
|
<div class="rl-hero">
|
||||||
|
<span class="rl-tagline">rChats</span>
|
||||||
|
<h1 class="rl-heading">Your conversations, your infrastructure.</h1>
|
||||||
|
<p class="rl-subtitle">Encrypted Community Messaging</p>
|
||||||
|
<p class="rl-subtext">
|
||||||
|
Real-time community chat with channels & threads, end-to-end encrypted
|
||||||
|
by default. Local-first, works offline, and scoped to each rSpace community.
|
||||||
|
</p>
|
||||||
|
<div class="rl-cta-row">
|
||||||
|
<a href="https://demo.rspace.online/rchats" class="rl-cta-primary">Try the Demo</a>
|
||||||
|
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Native Encrypted Chat -->
|
||||||
|
<section class="rl-section">
|
||||||
|
<div class="rl-container">
|
||||||
|
<h2 class="rl-heading" style="text-align:center">Native Encrypted Chat</h2>
|
||||||
|
<p class="rl-subtext" style="text-align:center">Community messaging built from the ground up for privacy, speed, and self-hosting.</p>
|
||||||
|
<div class="rl-grid-4">
|
||||||
|
<div class="rl-card rl-card--center">
|
||||||
|
<div class="rl-icon-box">💬</div>
|
||||||
|
<h3>Channels & Threads</h3>
|
||||||
|
<p>Organize conversations by topic with channels. Dive deeper with threaded replies without cluttering the main feed.</p>
|
||||||
|
</div>
|
||||||
|
<div class="rl-card rl-card--center">
|
||||||
|
<div class="rl-icon-box">🔒</div>
|
||||||
|
<h3>End-to-End Encrypted</h3>
|
||||||
|
<p>Messages encrypted with EncryptID passkeys. The server never sees plaintext — only community members can read messages.</p>
|
||||||
|
</div>
|
||||||
|
<div class="rl-card rl-card--center">
|
||||||
|
<div class="rl-icon-box">📷</div>
|
||||||
|
<h3>Local-First (CRDT)</h3>
|
||||||
|
<p>Built on Automerge CRDTs. Send messages offline, sync seamlessly when reconnected. Your data lives on your device first.</p>
|
||||||
|
</div>
|
||||||
|
<div class="rl-card rl-card--center">
|
||||||
|
<div class="rl-icon-box">🏠</div>
|
||||||
|
<h3>Space-Scoped</h3>
|
||||||
|
<p>Each rSpace community gets its own isolated chat instance. Channels, members, and history — all contained within your space.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Connect Your Chats -->
|
||||||
|
<section class="rl-section rl-section--alt">
|
||||||
|
<div class="rl-container">
|
||||||
|
<h2 class="rl-heading" style="text-align:center">Connect Your Chats</h2>
|
||||||
|
<p class="rl-subtext" style="text-align:center">Already using Slack, Discord, or Matrix? Bridge them into rSpace for a unified community view.</p>
|
||||||
|
<div class="rl-grid-4">
|
||||||
|
<div class="rl-card rl-card--center">
|
||||||
|
<div class="rl-icon-box">🔗</div>
|
||||||
|
<h3>Multi-Platform Bridge</h3>
|
||||||
|
<p>Aggregate channels from Slack, Discord, Matrix, Telegram, and Mattermost into a single interface.</p>
|
||||||
|
</div>
|
||||||
|
<div class="rl-card rl-card--center">
|
||||||
|
<div class="rl-icon-box">📥</div>
|
||||||
|
<h3>Unified Inbox</h3>
|
||||||
|
<p>Read and reply to messages across all connected platforms from one place. Similar pattern to rInbox's multi-IMAP aggregation.</p>
|
||||||
|
</div>
|
||||||
|
<div class="rl-card rl-card--center">
|
||||||
|
<div class="rl-icon-box">🔃</div>
|
||||||
|
<h3>Two-Way Sync</h3>
|
||||||
|
<p>Messages flow both directions. Reply from rChats and it appears in the original platform. No context lost.</p>
|
||||||
|
</div>
|
||||||
|
<div class="rl-card rl-card--center">
|
||||||
|
<div class="rl-icon-box">🌎</div>
|
||||||
|
<h3>Community-Wide View</h3>
|
||||||
|
<p>Bridge external channels into rSpace channels for a single community view, regardless of where members actually chat.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Data Ecosystem -->
|
||||||
|
<section class="rl-section">
|
||||||
|
<div class="rl-container">
|
||||||
|
<h2 class="rl-heading" style="text-align:center">Part of a Self-Hosted Data Ecosystem</h2>
|
||||||
|
<p class="rl-subtext" style="text-align:center">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.</p>
|
||||||
|
<div class="rl-grid-3">
|
||||||
|
<div class="rl-card rl-card--center">
|
||||||
|
<div class="rl-icon-box">📅</div>
|
||||||
|
<h3>Meeting Integration</h3>
|
||||||
|
<p>Start a rMeets video call from any channel. Meeting notes, recordings, and summaries flow back into the chat thread.</p>
|
||||||
|
</div>
|
||||||
|
<div class="rl-card rl-card--center">
|
||||||
|
<div class="rl-icon-box">📄</div>
|
||||||
|
<h3>Tasks & Notes</h3>
|
||||||
|
<p>Turn chat messages into rTasks action items or rNotes documents. Keep conversations and decisions linked.</p>
|
||||||
|
</div>
|
||||||
|
<div class="rl-card rl-card--center">
|
||||||
|
<div class="rl-icon-box">🔐</div>
|
||||||
|
<h3>EncryptID SSO</h3>
|
||||||
|
<p>One identity across the entire rSpace ecosystem. Passkey-based authentication, no passwords to manage.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Roadmap -->
|
||||||
|
<section class="rl-section rl-section--alt">
|
||||||
|
<div class="rl-container">
|
||||||
|
<h2 class="rl-heading" style="text-align:center">On the Horizon</h2>
|
||||||
|
<p class="rl-subtext" style="text-align:center">Features in development for the rChats roadmap.</p>
|
||||||
|
<div class="rl-grid-3">
|
||||||
|
<div class="rl-card rl-card--center">
|
||||||
|
<div class="rl-icon-box">🤖</div>
|
||||||
|
<h3>AI Summaries</h3>
|
||||||
|
<span class="rl-badge">Coming Soon</span>
|
||||||
|
<p>Catch up on long threads with AI-generated summaries. Run locally or via your own LLM endpoint — no data sent externally.</p>
|
||||||
|
</div>
|
||||||
|
<div class="rl-card rl-card--center">
|
||||||
|
<div class="rl-icon-box">🔌</div>
|
||||||
|
<h3>Webhooks & Bots</h3>
|
||||||
|
<span class="rl-badge">Coming Soon</span>
|
||||||
|
<p>Build custom integrations with incoming and outgoing webhooks. Connect CI/CD, monitoring, and other services to channels.</p>
|
||||||
|
</div>
|
||||||
|
<div class="rl-card rl-card--center">
|
||||||
|
<div class="rl-icon-box">🎤</div>
|
||||||
|
<h3>Voice Channels</h3>
|
||||||
|
<span class="rl-badge">Coming Soon</span>
|
||||||
|
<p>Drop into voice channels powered by Jitsi. Persistent rooms for your community — always on, always private.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- CTA -->
|
||||||
|
<section class="rl-section rl-section--alt">
|
||||||
|
<div class="rl-container" style="text-align:center">
|
||||||
|
<h2 class="rl-heading">Your conversations belong to you.</h2>
|
||||||
|
<p class="rl-subtext">Start chatting on infrastructure you control, or create a space for your community.</p>
|
||||||
|
<div class="rl-cta-row">
|
||||||
|
<a href="https://demo.rspace.online/rchats" class="rl-cta-primary">Try the Demo</a>
|
||||||
|
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="rl-back">
|
||||||
|
<a href="/">← Back to rSpace</a>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
@ -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: `<style>
|
||||||
|
.rs-hub{max-width:720px;margin:2rem auto;padding:0 1.5rem}
|
||||||
|
.rs-hub h1{font-size:1.8rem;margin-bottom:.5rem;color:var(--rs-text-primary)}
|
||||||
|
.rs-hub p{color:var(--rs-text-secondary);margin-bottom:2rem}
|
||||||
|
.rs-coming{display:flex;flex-direction:column;align-items:center;gap:1.5rem;padding:3rem 1.5rem;border-radius:16px;background:var(--rs-bg-surface);border:1px solid var(--rs-border);text-align:center}
|
||||||
|
.rs-coming .coming-icon{font-size:3rem}
|
||||||
|
.rs-coming h2{font-size:1.4rem;color:var(--rs-text-primary);margin:0}
|
||||||
|
.rs-coming p{color:var(--rs-text-secondary);max-width:480px;margin:0}
|
||||||
|
.rs-coming .coming-badge{display:inline-block;padding:4px 12px;border-radius:8px;background:rgba(99,102,241,0.15);color:var(--rs-accent,#6366f1);font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em}
|
||||||
|
.rs-features{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:1rem;margin-top:2rem}
|
||||||
|
.rs-feature{padding:1.25rem;border-radius:12px;background:var(--rs-bg-surface);border:1px solid var(--rs-border)}
|
||||||
|
.rs-feature h3{font-size:1rem;margin:0 0 .5rem;color:var(--rs-text-primary)}
|
||||||
|
.rs-feature p{font-size:.85rem;color:var(--rs-text-secondary);margin:0}
|
||||||
|
@media(max-width:600px){.rs-hub{margin:1rem auto;padding:0 .75rem}}
|
||||||
|
</style>`,
|
||||||
|
body: `<div class="rs-hub">
|
||||||
|
<h1>rChats</h1>
|
||||||
|
<p>Encrypted community messaging — channels, threads, and bridges</p>
|
||||||
|
<div class="rs-coming">
|
||||||
|
<span class="coming-icon">🗨️</span>
|
||||||
|
<span class="coming-badge">Coming Soon</span>
|
||||||
|
<h2>Encrypted Community Chat</h2>
|
||||||
|
<p>Real-time messaging with channels and threads, end-to-end encrypted via EncryptID. Local-first with Automerge CRDTs — works offline, syncs seamlessly.</p>
|
||||||
|
</div>
|
||||||
|
<div class="rs-features">
|
||||||
|
<div class="rs-feature">
|
||||||
|
<h3>🔐 E2E Encrypted</h3>
|
||||||
|
<p>Messages encrypted with EncryptID passkeys. The server never sees plaintext.</p>
|
||||||
|
</div>
|
||||||
|
<div class="rs-feature">
|
||||||
|
<h3>💬 Channels & Threads</h3>
|
||||||
|
<p>Organize conversations by topic. Threaded replies keep the main feed clean.</p>
|
||||||
|
</div>
|
||||||
|
<div class="rs-feature">
|
||||||
|
<h3>🔗 Chat Bridges</h3>
|
||||||
|
<p>Connect Slack, Discord, Matrix, Telegram, and Mattermost into one unified view.</p>
|
||||||
|
</div>
|
||||||
|
<div class="rs-feature">
|
||||||
|
<h3>📡 Local-First</h3>
|
||||||
|
<p>Built on Automerge CRDTs. Send messages offline and sync when reconnected.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Module export ──
|
||||||
|
|
||||||
|
export const chatsModule: RSpaceModule = {
|
||||||
|
id: "rchats",
|
||||||
|
name: "rChats",
|
||||||
|
icon: "🗨️",
|
||||||
|
description: "Encrypted community messaging",
|
||||||
|
scoping: { defaultScope: "space", userConfigurable: false },
|
||||||
|
routes,
|
||||||
|
landingPage: renderLanding,
|
||||||
|
};
|
||||||
|
|
@ -9,16 +9,6 @@ import { TourEngine } from "../../../shared/tour-engine";
|
||||||
import { ChoicesLocalFirstClient } from "../local-first-client";
|
import { ChoicesLocalFirstClient } from "../local-first-client";
|
||||||
import type { ChoicesDoc, ChoiceSession, ChoiceVote } from "../schemas";
|
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 ──
|
// ── Auth helpers ──
|
||||||
function getSession(): { accessToken: string; claims: { sub: string; did?: string; username?: string } } | null {
|
function getSession(): { accessToken: string; claims: { sub: string; did?: string; username?: string } } | null {
|
||||||
try {
|
try {
|
||||||
|
|
@ -41,7 +31,7 @@ class FolkChoicesDashboard extends HTMLElement {
|
||||||
private space: string;
|
private space: string;
|
||||||
|
|
||||||
/* Demo state */
|
/* Demo state */
|
||||||
private demoTab: "spider" | "ranking" | "voting" | "crowdsurf" = "spider";
|
private demoTab: "spider" | "ranking" | "voting" = "spider";
|
||||||
private hoveredPerson: string | null = null;
|
private hoveredPerson: string | null = null;
|
||||||
private rankItems: { id: number; name: string; emoji: string }[] = [];
|
private rankItems: { id: number; name: string; emoji: string }[] = [];
|
||||||
private rankDragging: number | null = null;
|
private rankDragging: number | null = null;
|
||||||
|
|
@ -57,28 +47,18 @@ class FolkChoicesDashboard extends HTMLElement {
|
||||||
private activeSessionId: string | null = null;
|
private activeSessionId: string | null = null;
|
||||||
private sessionVotes: Map<string, ChoiceVote[]> = new Map();
|
private sessionVotes: Map<string, ChoiceVote[]> = new Map();
|
||||||
|
|
||||||
/* CrowdSurf inline state */
|
|
||||||
private csOptions: CrowdSurfOption[] = [];
|
|
||||||
private csCurrentIndex = 0;
|
|
||||||
private csSwipedMap: Map<string, 'right' | 'left'> = new Map();
|
|
||||||
private csIsDragging = false;
|
|
||||||
private csStartX = 0;
|
|
||||||
private csCurrentX = 0;
|
|
||||||
private csIsAnimating = false;
|
|
||||||
private _csTransitionTimer: number | null = null;
|
|
||||||
|
|
||||||
// Guided tour
|
// Guided tour
|
||||||
private _tour!: TourEngine;
|
private _tour!: TourEngine;
|
||||||
private static readonly TOUR_STEPS = [
|
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() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.shadow = this.attachShadow({ mode: "open" });
|
this.shadow = this.attachShadow({ mode: "open" });
|
||||||
this.space = this.getAttribute("space") || "demo";
|
this.space = this.getAttribute("space") || "demo";
|
||||||
const tabAttr = this.getAttribute("tab") as "spider" | "ranking" | "voting" | "crowdsurf" | null;
|
const tabAttr = this.getAttribute("tab") as "spider" | "ranking" | "voting" | null;
|
||||||
if (tabAttr && ["spider", "ranking", "voting", "crowdsurf"].includes(tabAttr)) {
|
if (tabAttr && ["spider", "ranking", "voting"].includes(tabAttr)) {
|
||||||
this.demoTab = tabAttr;
|
this.demoTab = tabAttr;
|
||||||
}
|
}
|
||||||
this._tour = new TourEngine(
|
this._tour = new TourEngine(
|
||||||
|
|
@ -105,10 +85,6 @@ class FolkChoicesDashboard extends HTMLElement {
|
||||||
clearInterval(this.simTimer);
|
clearInterval(this.simTimer);
|
||||||
this.simTimer = null;
|
this.simTimer = null;
|
||||||
}
|
}
|
||||||
if (this._csTransitionTimer !== null) {
|
|
||||||
clearTimeout(this._csTransitionTimer);
|
|
||||||
this._csTransitionTimer = null;
|
|
||||||
}
|
|
||||||
this._lfcUnsub?.();
|
this._lfcUnsub?.();
|
||||||
this._lfcUnsub = null;
|
this._lfcUnsub = null;
|
||||||
this.lfClient?.disconnect();
|
this.lfClient?.disconnect();
|
||||||
|
|
@ -532,7 +508,6 @@ class FolkChoicesDashboard extends HTMLElement {
|
||||||
let content = "";
|
let content = "";
|
||||||
if (this.demoTab === "spider") content = this.renderSpider();
|
if (this.demoTab === "spider") content = this.renderSpider();
|
||||||
else if (this.demoTab === "ranking") content = this.renderRanking();
|
else if (this.demoTab === "ranking") content = this.renderRanking();
|
||||||
else if (this.demoTab === "crowdsurf") content = this.renderCrowdSurf();
|
|
||||||
else content = this.renderVoting();
|
else content = this.renderVoting();
|
||||||
|
|
||||||
this.shadow.innerHTML = `
|
this.shadow.innerHTML = `
|
||||||
|
|
@ -583,30 +558,6 @@ class FolkChoicesDashboard extends HTMLElement {
|
||||||
.vote-reset:hover { border-color: var(--rs-error); color: #fca5a5; }
|
.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); }
|
.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) {
|
@media (max-width: 768px) {
|
||||||
.grid { grid-template-columns: 1fr; }
|
.grid { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
|
@ -618,9 +569,6 @@ class FolkChoicesDashboard extends HTMLElement {
|
||||||
.rank-name { font-size: 0.875rem; }
|
.rank-name { font-size: 0.875rem; }
|
||||||
.vote-option { padding: 0.625rem 0.75rem; }
|
.vote-option { padding: 0.625rem 0.75rem; }
|
||||||
.spider-svg { max-width: 300px; }
|
.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; }
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
@ -760,324 +708,6 @@ class FolkChoicesDashboard extends HTMLElement {
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -- 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 `<div class="cs-inline">
|
|
||||||
<div style="text-align:center;padding:2rem 0;">
|
|
||||||
<div style="font-size:2.5rem;margin-bottom:0.75rem;">🏄</div>
|
|
||||||
<p style="color:var(--rs-text-secondary);margin:0 0 1rem;">No open polls to surf yet.</p>
|
|
||||||
<p style="color:var(--rs-text-muted);font-size:0.8rem;margin:0 0 1rem;">Create a poll in the Voting tab, then come back to swipe!</p>
|
|
||||||
${this.csSwipedMap.size > 0 ? `<button class="cs-btn-reset" data-cs-action="reset">Start Over</button>` : ''}
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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<string, string> = { vote: '#3b82f6', rank: '#f59e0b', score: '#10b981' };
|
|
||||||
const badgeColor = typeBadgeColors[opt.sessionType] || '#3b82f6';
|
|
||||||
|
|
||||||
return `<div class="cs-inline">
|
|
||||||
<div class="cs-progress-header">
|
|
||||||
<span style="color:var(--rs-text-muted);font-size:0.8rem;">${this.csCurrentIndex + 1} of ${this.csOptions.length}</span>
|
|
||||||
<span style="color:var(--rs-text-muted);font-size:0.8rem;">${approved} approved</span>
|
|
||||||
</div>
|
|
||||||
<div class="cs-card-stack">
|
|
||||||
<div class="cs-card" id="cs-inline-card">
|
|
||||||
<div class="cs-swipe-indicator cs-swipe-left">✗ Skip</div>
|
|
||||||
<div class="cs-swipe-indicator cs-swipe-right">✓ Approve</div>
|
|
||||||
<div class="cs-card-body">
|
|
||||||
<div class="cs-type-badge" style="background:${badgeColor}20;color:${badgeColor}">${opt.sessionType}</div>
|
|
||||||
<div class="cs-card-session">${this.esc(opt.sessionTitle)}</div>
|
|
||||||
<div class="cs-card-option">
|
|
||||||
<span class="cs-color-dot" style="background:${opt.color}"></span>
|
|
||||||
${this.esc(opt.label)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="cs-swipe-buttons">
|
|
||||||
<button class="cs-btn-skip" data-cs-action="skip" title="Skip">✗</button>
|
|
||||||
<button class="cs-btn-approve" data-cs-action="approve" title="Approve">✓</button>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
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<string, CrowdSurfOption[]>();
|
|
||||||
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 =>
|
|
||||||
`<div style="display:flex;align-items:center;gap:6px;padding:4px 0;">
|
|
||||||
<span class="cs-color-dot" style="background:${o.color}"></span>
|
|
||||||
<span style="color:var(--rs-text-primary);font-size:0.9rem;">${this.esc(o.label)}</span>
|
|
||||||
</div>`
|
|
||||||
).join('');
|
|
||||||
groupHtml += `<div style="margin-bottom:1rem;">
|
|
||||||
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--rs-text-muted);margin-bottom:4px;">${this.esc(title)}</div>
|
|
||||||
${items}
|
|
||||||
</div>`;
|
|
||||||
});
|
|
||||||
|
|
||||||
return `<div class="cs-inline">
|
|
||||||
<div style="text-align:center;padding:1.5rem 0;">
|
|
||||||
<div style="font-size:2rem;margin-bottom:0.5rem;">✓</div>
|
|
||||||
<h3 style="color:var(--rs-text-primary);margin:0 0 0.5rem;font-size:1.1rem;">All done!</h3>
|
|
||||||
<p style="color:var(--rs-text-secondary);margin:0 0 1.25rem;font-size:0.85rem;">
|
|
||||||
You approved ${approved.length} of ${this.csSwipedMap.size} options
|
|
||||||
</p>
|
|
||||||
${groupHtml || `<p style="color:var(--rs-text-muted);font-size:0.85rem;">No approvals this round.</p>`}
|
|
||||||
<button class="cs-btn-reset" data-cs-action="reset">Start Over</button>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 -- */
|
/* -- Demo event binding -- */
|
||||||
|
|
||||||
private bindDemoEvents() {
|
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 {
|
private esc(s: string): string {
|
||||||
|
|
|
||||||
|
|
@ -65,9 +65,9 @@ routes.get("/", (c) => {
|
||||||
routes.get("/:tab", (c) => {
|
routes.get("/:tab", (c) => {
|
||||||
const spaceSlug = c.req.param("space") || "demo";
|
const spaceSlug = c.req.param("space") || "demo";
|
||||||
const tab = c.req.param("tab");
|
const tab = c.req.param("tab");
|
||||||
const validTabs = ["spider", "ranking", "voting", "crowdsurf"];
|
const validTabs = ["spider", "ranking", "voting"];
|
||||||
if (!validTabs.includes(tab)) return c.notFound();
|
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({
|
return c.html(renderShell({
|
||||||
title: `${spaceSlug} — ${tabLabel} | rChoices`,
|
title: `${spaceSlug} — ${tabLabel} | rChoices`,
|
||||||
moduleId: "rchoices",
|
moduleId: "rchoices",
|
||||||
|
|
@ -161,6 +161,5 @@ export const choicesModule: RSpaceModule = {
|
||||||
{ path: "spider", name: "Spider Chart", icon: "🕸", description: "Multi-criteria radar charts" },
|
{ path: "spider", name: "Spider Chart", icon: "🕸", description: "Multi-criteria radar charts" },
|
||||||
{ path: "ranking", name: "Ranking", icon: "📊", description: "Drag-and-drop rankings" },
|
{ path: "ranking", name: "Ranking", icon: "📊", description: "Drag-and-drop rankings" },
|
||||||
{ path: "voting", name: "Voting", icon: "☑", description: "Live polls and voting" },
|
{ path: "voting", name: "Voting", icon: "☑", description: "Live polls and voting" },
|
||||||
{ path: "crowdsurf", name: "CrowdSurf", icon: "🏄", description: "Swipe-based option surfacing" },
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,214 @@
|
||||||
|
/**
|
||||||
|
* <folk-revenue-sankey> — 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 = `
|
||||||
|
<style>
|
||||||
|
:host { display: block; }
|
||||||
|
.sankey-container { background: var(--rs-bg-surface, #1e293b); border: 1px solid var(--rs-border, #334155); border-radius: 12px; padding: 1.5rem; }
|
||||||
|
.sankey-title { font-size: 1rem; font-weight: 600; color: var(--rs-text-primary, #e2e8f0); margin: 0 0 0.25rem; }
|
||||||
|
.sankey-subtitle { font-size: 0.8125rem; color: var(--rs-text-secondary, #94a3b8); margin: 0 0 1.25rem; }
|
||||||
|
|
||||||
|
/* Flow diagram */
|
||||||
|
.flow { position: relative; height: 160px; margin-bottom: 1rem; }
|
||||||
|
.flow svg { width: 100%; height: 100%; }
|
||||||
|
|
||||||
|
/* Split bar */
|
||||||
|
.split-bar-container { margin-bottom: 1rem; }
|
||||||
|
.split-bar { display: flex; height: 40px; border-radius: 10px; overflow: hidden; font-size: 0.75rem; font-weight: 600; cursor: pointer; position: relative; }
|
||||||
|
.split-seg { display: flex; align-items: center; justify-content: center; gap: 0.25rem; color: #fff; transition: flex 0.15s; user-select: none; flex-direction: column; line-height: 1.2; }
|
||||||
|
.split-seg .seg-pct { font-size: 0.875rem; font-weight: 700; }
|
||||||
|
.split-seg .seg-amt { font-size: 0.625rem; opacity: 0.85; }
|
||||||
|
.seg-provider { background: #16a34a; }
|
||||||
|
.seg-creator { background: #4f46e5; }
|
||||||
|
.seg-community { background: #d97706; }
|
||||||
|
|
||||||
|
/* Labels */
|
||||||
|
.split-labels { display: flex; justify-content: space-between; margin-top: 0.5rem; }
|
||||||
|
.split-label { font-size: 0.6875rem; color: var(--rs-text-muted, #64748b); display: flex; align-items: center; gap: 0.375rem; }
|
||||||
|
.label-dot { width: 8px; height: 8px; border-radius: 50%; }
|
||||||
|
|
||||||
|
/* Drag hint */
|
||||||
|
.drag-hint { text-align: center; font-size: 0.6875rem; color: var(--rs-text-muted); margin-bottom: 1rem; }
|
||||||
|
|
||||||
|
/* Metrics */
|
||||||
|
.metrics { display: flex; gap: 1rem; justify-content: center; }
|
||||||
|
.metric { text-align: center; }
|
||||||
|
.metric-value { font-size: 1.25rem; font-weight: 700; color: var(--rs-text-primary); }
|
||||||
|
.metric-label { font-size: 0.6875rem; color: var(--rs-text-muted); }
|
||||||
|
.metric-highlight { color: #4ade80; }
|
||||||
|
|
||||||
|
/* Sliders */
|
||||||
|
.slider-row { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.5rem; }
|
||||||
|
.slider-label { font-size: 0.75rem; color: var(--rs-text-secondary); min-width: 80px; font-weight: 500; }
|
||||||
|
.slider-range { flex: 1; accent-color: var(--rs-primary, #6366f1); }
|
||||||
|
.slider-value { font-size: 0.75rem; color: var(--rs-text-primary); font-weight: 600; min-width: 40px; text-align: right; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="sankey-container">
|
||||||
|
<div class="sankey-title">Revenue Flow</div>
|
||||||
|
<div class="sankey-subtitle">How $${this.totalPrice.toFixed(2)} flows from customer to community</div>
|
||||||
|
|
||||||
|
<!-- Animated flow SVG -->
|
||||||
|
<div class="flow">
|
||||||
|
<svg viewBox="0 0 600 140" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Source: Customer -->
|
||||||
|
<rect x="0" y="20" width="100" height="100" rx="8" fill="#334155" stroke="#475569" stroke-width="1"/>
|
||||||
|
<text x="50" y="65" text-anchor="middle" fill="#e2e8f0" font-size="11" font-weight="600">Customer</text>
|
||||||
|
<text x="50" y="82" text-anchor="middle" fill="#94a3b8" font-size="9">$${this.totalPrice.toFixed(2)}</text>
|
||||||
|
|
||||||
|
<!-- Provider flow -->
|
||||||
|
<path d="M100,50 C200,50 200,30 320,30" fill="none" stroke="#16a34a" stroke-width="${Math.max(2, this.providerPct / 3)}" opacity="0.7">
|
||||||
|
<animate attributeName="stroke-dashoffset" from="20" to="0" dur="1.5s" repeatCount="indefinite"/>
|
||||||
|
</path>
|
||||||
|
<rect x="320" y="10" width="120" height="40" rx="6" fill="#16a34a" opacity="0.15" stroke="#16a34a" stroke-width="1"/>
|
||||||
|
<text x="380" y="28" text-anchor="middle" fill="#4ade80" font-size="9" font-weight="600">Print Provider</text>
|
||||||
|
<text x="380" y="42" text-anchor="middle" fill="#4ade80" font-size="11" font-weight="700">${this.providerPct}% / $${providerAmt}</text>
|
||||||
|
|
||||||
|
<!-- Creator flow -->
|
||||||
|
<path d="M100,70 C200,70 200,80 320,80" fill="none" stroke="#4f46e5" stroke-width="${Math.max(2, this.creatorPct / 3)}" opacity="0.7">
|
||||||
|
<animate attributeName="stroke-dashoffset" from="20" to="0" dur="1.8s" repeatCount="indefinite"/>
|
||||||
|
</path>
|
||||||
|
<rect x="320" y="60" width="120" height="40" rx="6" fill="#4f46e5" opacity="0.15" stroke="#4f46e5" stroke-width="1"/>
|
||||||
|
<text x="380" y="78" text-anchor="middle" fill="#a5b4fc" font-size="9" font-weight="600">Design Creator</text>
|
||||||
|
<text x="380" y="92" text-anchor="middle" fill="#a5b4fc" font-size="11" font-weight="700">${this.creatorPct}% / $${creatorAmt}</text>
|
||||||
|
|
||||||
|
<!-- Community flow -->
|
||||||
|
<path d="M100,90 C200,90 200,125 320,125" fill="none" stroke="#d97706" stroke-width="${Math.max(2, this.communityPct / 3)}" opacity="0.7">
|
||||||
|
<animate attributeName="stroke-dashoffset" from="20" to="0" dur="2.1s" repeatCount="indefinite"/>
|
||||||
|
</path>
|
||||||
|
<rect x="320" y="108" width="120" height="32" rx="6" fill="#d97706" opacity="0.15" stroke="#d97706" stroke-width="1"/>
|
||||||
|
<text x="380" y="123" text-anchor="middle" fill="#fbbf24" font-size="9" font-weight="600">Community</text>
|
||||||
|
<text x="380" y="136" text-anchor="middle" fill="#fbbf24" font-size="11" font-weight="700">${this.communityPct}% / $${communityAmt}</text>
|
||||||
|
|
||||||
|
<!-- Platform fee -->
|
||||||
|
<text x="540" y="75" text-anchor="middle" fill="#4ade80" font-size="14" font-weight="700">$0</text>
|
||||||
|
<text x="540" y="90" text-anchor="middle" fill="#94a3b8" font-size="8">platform fee</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Split bar -->
|
||||||
|
<div class="split-bar-container">
|
||||||
|
<div class="split-bar">
|
||||||
|
<div class="split-seg seg-provider" style="flex:${this.providerPct}">
|
||||||
|
<span class="seg-pct">${this.providerPct}%</span>
|
||||||
|
<span class="seg-amt">$${providerAmt}</span>
|
||||||
|
</div>
|
||||||
|
<div class="split-seg seg-creator" style="flex:${this.creatorPct}">
|
||||||
|
<span class="seg-pct">${this.creatorPct}%</span>
|
||||||
|
<span class="seg-amt">$${creatorAmt}</span>
|
||||||
|
</div>
|
||||||
|
<div class="split-seg seg-community" style="flex:${this.communityPct}">
|
||||||
|
<span class="seg-pct">${this.communityPct}%</span>
|
||||||
|
<span class="seg-amt">$${communityAmt}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sliders -->
|
||||||
|
<div class="slider-row">
|
||||||
|
<span class="slider-label" style="color:#4ade80">Provider</span>
|
||||||
|
<input type="range" class="slider-range" id="provider-slider" min="20" max="80" value="${this.providerPct}">
|
||||||
|
<span class="slider-value">${this.providerPct}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="slider-row">
|
||||||
|
<span class="slider-label" style="color:#a5b4fc">Creator</span>
|
||||||
|
<input type="range" class="slider-range" id="creator-slider" min="5" max="60" value="${this.creatorPct}">
|
||||||
|
<span class="slider-value">${this.creatorPct}%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="drag-hint">Adjust sliders to explore different revenue splits</div>
|
||||||
|
|
||||||
|
<!-- Key metrics -->
|
||||||
|
<div class="metrics">
|
||||||
|
<div class="metric">
|
||||||
|
<div class="metric-value metric-highlight">$0</div>
|
||||||
|
<div class="metric-label">Platform Fee</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="metric-value">100%</div>
|
||||||
|
<div class="metric-label">To Community</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="metric-value">${this.creatorPct + this.communityPct}%</div>
|
||||||
|
<div class="metric-label">Creator + Commons</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Labels -->
|
||||||
|
<div class="split-labels">
|
||||||
|
<span class="split-label"><span class="label-dot" style="background:#16a34a"></span> Print Provider (production + shipping)</span>
|
||||||
|
<span class="split-label"><span class="label-dot" style="background:#4f46e5"></span> Design Creator</span>
|
||||||
|
<span class="split-label"><span class="label-dot" style="background:#d97706"></span> Community Treasury</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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);
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -3,3 +3,9 @@ main {
|
||||||
min-height: calc(100vh - 56px);
|
min-height: calc(100vh - 56px);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ensure the Sankey component renders properly on landing page */
|
||||||
|
folk-revenue-sankey {
|
||||||
|
display: block;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<string, Buffer>;
|
||||||
|
colors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Constants ──
|
||||||
|
|
||||||
|
const MAX_DITHER_DIM = 512;
|
||||||
|
const MAX_CACHE = 200;
|
||||||
|
|
||||||
|
// ── Cache ──
|
||||||
|
|
||||||
|
const ditherCache = new Map<string, { buffer: Buffer; meta: Omit<DitherResult, "buffer" | "cached"> }>();
|
||||||
|
const separationCache = new Map<string, SeparationResult>();
|
||||||
|
|
||||||
|
function cacheKey(...parts: (string | number)[]): string {
|
||||||
|
// Simple hash via string joining (no crypto needed for cache keys)
|
||||||
|
return parts.join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
function evict<V>(cache: Map<string, V>, 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<string, DiffusionKernel> = {
|
||||||
|
"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<DitherResult> {
|
||||||
|
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<SeparationResult> {
|
||||||
|
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<string, Buffer>();
|
||||||
|
|
||||||
|
// 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",
|
||||||
|
];
|
||||||
|
|
@ -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<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, FulfillmentResult[]>();
|
||||||
|
|
||||||
|
// ── 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<FulfillmentResult[]> {
|
||||||
|
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<FulfillmentResult> {
|
||||||
|
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<string, string>,
|
||||||
|
): Promise<FulfillmentResult> {
|
||||||
|
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<PodTracking | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -31,23 +31,23 @@ export function renderLanding(): string {
|
||||||
<div class="rl-grid-4">
|
<div class="rl-grid-4">
|
||||||
<div class="rl-card rl-card--center">
|
<div class="rl-card rl-card--center">
|
||||||
<div class="rl-icon-box">🖨</div>
|
<div class="rl-icon-box">🖨</div>
|
||||||
<h3>Print-on-Demand Merch Designer</h3>
|
<h3>AI Design + Upload</h3>
|
||||||
<p>Upload artwork and generate print-ready files with correct DPI, bleed margins, and color profiles.</p>
|
<p>Generate designs with AI (Gemini) or upload your own artwork. Print-ready files with correct DPI, bleed, and color profiles.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rl-card rl-card--center">
|
<div class="rl-card rl-card--center">
|
||||||
<div class="rl-icon-box">🧾</div>
|
<div class="rl-icon-box">🧾</div>
|
||||||
<h3>Stickers, Posters, Tees & Patches</h3>
|
<h3>HitherDither</h3>
|
||||||
<p>Four product types with professional print specs built in. Vinyl stickers, A3 posters, DTG tees, and embroidered patches.</p>
|
<p>11 dithering algorithms for screen printing. Floyd-Steinberg, Atkinson, Bayer, and more. Auto color separations.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rl-card rl-card--center">
|
<div class="rl-card rl-card--center">
|
||||||
<div class="rl-icon-box">👁</div>
|
<div class="rl-icon-box">👁</div>
|
||||||
<h3>Live Preview Mockups</h3>
|
<h3>POD Fulfillment</h3>
|
||||||
<p>See your design on product mockups before committing. Adjust and iterate in real time.</p>
|
<p>Printful + Prodigi integration. Auto-route orders to the nearest capable printer. Cosmolocal network first, global fallback.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rl-card rl-card--center">
|
<div class="rl-card rl-card--center">
|
||||||
<div class="rl-icon-box">🛒</div>
|
<div class="rl-icon-box">🛒</div>
|
||||||
<h3>rCart Integration</h3>
|
<h3>rCart Integration</h3>
|
||||||
<p>Send finished designs directly to <a href="/rcart" style="color:#14b8a6;">rCart</a> for community ordering and fulfillment.</p>
|
<p>Browse, add to cart, and checkout via <a href="/rcart" style="color:#14b8a6;">rCart</a>. x402/Transak/wallet payments. Full order tracking.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -132,26 +132,24 @@ export function renderLanding(): string {
|
||||||
<div class="rl-card">
|
<div class="rl-card">
|
||||||
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem;">
|
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem;">
|
||||||
<span style="background:rgba(56,189,248,0.15);color:#38bdf8;padding:0.25rem 0.625rem;border-radius:999px;font-size:0.75rem;font-weight:600;">global</span>
|
<span style="background:rgba(56,189,248,0.15);color:#38bdf8;padding:0.25rem 0.625rem;border-radius:999px;font-size:0.75rem;font-weight:600;">global</span>
|
||||||
<h3 style="margin:0;font-size:1rem;">Printful (Global Fallback)</h3>
|
<h3 style="margin:0;font-size:1rem;">Printful + Prodigi (Global Fallback)</h3>
|
||||||
</div>
|
</div>
|
||||||
<p>DTG apparel, vinyl stickers, and art prints shipped worldwide. Always available. Bella+Canvas blanks. Sandbox mode for testing.</p>
|
<p>DTG apparel, vinyl stickers, and art prints shipped worldwide. Always available. Bella+Canvas blanks. Sandbox mode for testing.</p>
|
||||||
<p style="color:#64748b;font-size:0.8125rem;margin-bottom:0;">SKU 71 (tee), SKU 146 (hoodie), SKU 358 (sticker) — full size and color ranges.</p>
|
<p style="color:#64748b;font-size:0.8125rem;margin-bottom:0;">Printful SKU 71 (tee), 146 (hoodie), 358 (sticker). Prodigi for fine art prints and stickers.</p>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style="margin-top:1.5rem;">
|
|
||||||
<p style="text-align:center;color:#94a3b8;font-size:0.8125rem;margin-bottom:0.5rem;">Revenue Split on Every Order</p>
|
|
||||||
<div style="display:flex;height:32px;border-radius:8px;overflow:hidden;font-size:0.75rem;font-weight:600;">
|
|
||||||
<div style="flex:50;background:#16a34a;color:#fff;display:flex;align-items:center;justify-content:center;">Provider 50%</div>
|
|
||||||
<div style="flex:35;background:#4f46e5;color:#fff;display:flex;align-items:center;justify-content:center;">Creator 35%</div>
|
|
||||||
<div style="flex:15;background:#d97706;color:#fff;display:flex;align-items:center;justify-content:center;">15%</div>
|
|
||||||
</div>
|
|
||||||
<div style="display:flex;justify-content:flex-end;margin-top:0.25rem;">
|
|
||||||
<span style="color:#64748b;font-size:0.6875rem;">Community 15%</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Revenue Flow (Interactive Sankey) -->
|
||||||
|
<section class="rl-section">
|
||||||
|
<div class="rl-container">
|
||||||
|
<h2 class="rl-heading" style="text-align:center">Where the Money Goes</h2>
|
||||||
|
<p class="rl-subtext" style="text-align:center">$0 platform fee. 100% of revenue flows to the community.</p>
|
||||||
|
<folk-revenue-sankey price="29.99"></folk-revenue-sankey>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Products (module-specific) -->
|
<!-- Products (module-specific) -->
|
||||||
<section class="rl-section">
|
<section class="rl-section">
|
||||||
<div class="rl-container">
|
<div class="rl-container">
|
||||||
|
|
|
||||||
|
|
@ -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<string, MockupTemplate> = {
|
||||||
|
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<string, Buffer>();
|
||||||
|
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<Buffer> {
|
||||||
|
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 `<svg width="${w}" height="${h}" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="${w}" height="${h}" fill="#0f172a"/>
|
||||||
|
<path d="M${w * 0.25},${h * 0.08} L${w * 0.12},${h * 0.12} L${w * 0.04},${h * 0.3} L${w * 0.16},${h * 0.34} L${w * 0.2},${h * 0.22} L${w * 0.2},${h * 0.88} L${w * 0.8},${h * 0.88} L${w * 0.8},${h * 0.22} L${w * 0.84},${h * 0.34} L${w * 0.96},${h * 0.3} L${w * 0.88},${h * 0.12} L${w * 0.75},${h * 0.08} Q${w * 0.65},${h * 0.15} ${w * 0.5},${h * 0.15} Q${w * 0.35},${h * 0.15} ${w * 0.25},${h * 0.08} Z" fill="#1a1a1a" stroke="#334155" stroke-width="2"/>
|
||||||
|
<rect x="${w * 0.256}" y="${h * 0.225}" width="${w * 0.488}" height="${h * 0.44}" rx="8" fill="none" stroke="#4f46e5" stroke-width="1" stroke-dasharray="6,4" opacity="0.4"/>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateStickerSvg(w: number, h: number): string {
|
||||||
|
return `<svg width="${w}" height="${h}" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="${w}" height="${h}" fill="#0f172a"/>
|
||||||
|
<rect x="${w * 0.08}" y="${h * 0.08}" width="${w * 0.84}" height="${h * 0.84}" rx="32" fill="#1e293b" stroke="#334155" stroke-width="2"/>
|
||||||
|
<rect x="${w * 0.12}" y="${h * 0.12}" width="${w * 0.76}" height="${h * 0.76}" rx="24" fill="none" stroke="#4f46e5" stroke-width="1" stroke-dasharray="6,4" opacity="0.4"/>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Printful API mockup ──
|
||||||
|
|
||||||
|
async function printfulMockup(
|
||||||
|
designImageUrl: string,
|
||||||
|
printfulSku: number,
|
||||||
|
variantIds?: number[],
|
||||||
|
): Promise<Buffer | null> {
|
||||||
|
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<number | null> {
|
||||||
|
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<Buffer> {
|
||||||
|
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);
|
||||||
|
|
@ -7,10 +7,14 @@
|
||||||
|
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { randomUUID } from "node:crypto";
|
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 { 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 { 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 { renderShell } from "../../server/shell";
|
||||||
import { getModuleInfoList } from "../../shared/module";
|
import { getModuleInfoList } from "../../shared/module";
|
||||||
import type { RSpaceModule } 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<string, DesignMeta> = 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<string, string> = {};
|
||||||
|
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<string, { composite: Buffer; separations: Map<string, Buffer>; 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<string, number>),
|
||||||
|
designsBySource: Array.from(designIndex.values()).reduce((acc, d) => {
|
||||||
|
acc[d.source] = (acc[d.source] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, number>),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ── Page route: swag designer ──
|
// ── Page route: swag designer ──
|
||||||
routes.get("/", (c) => {
|
routes.get("/", (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
|
@ -238,7 +968,8 @@ routes.get("/", (c) => {
|
||||||
modules: getModuleInfoList(),
|
modules: getModuleInfoList(),
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
body: `<folk-swag-designer space="${space}"></folk-swag-designer>`,
|
body: `<folk-swag-designer space="${space}"></folk-swag-designer>`,
|
||||||
scripts: `<script type="module" src="/modules/rswag/folk-swag-designer.js?v=2"></script>`,
|
scripts: `<script type="module" src="/modules/rswag/folk-swag-designer.js?v=3"></script>
|
||||||
|
<script type="module" src="/modules/rswag/folk-revenue-sankey.js?v=1"></script>`,
|
||||||
styles: `<link rel="stylesheet" href="/modules/rswag/swag.css">`,
|
styles: `<link rel="stylesheet" href="/modules/rswag/swag.css">`,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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<number, { variants: PodVariant[]; ts: number }>();
|
||||||
|
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<string, string> {
|
||||||
|
const h: Record<string, string> = {
|
||||||
|
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<PodVariant[]> {
|
||||||
|
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<number | null> {
|
||||||
|
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<string> {
|
||||||
|
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<PodMockup[] | null> {
|
||||||
|
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<PodOrder> {
|
||||||
|
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<PodOrder> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<string, string> {
|
||||||
|
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<string, string>,
|
||||||
|
): Promise<PodOrder> {
|
||||||
|
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<PodOrder> {
|
||||||
|
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<any> {
|
||||||
|
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<PodQuote> {
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -112,3 +112,58 @@ export const PRODUCTS: Record<string, ProductTemplate> = {
|
||||||
export function getProduct(id: string): ProductTemplate | undefined {
|
export function getProduct(id: string): ProductTemplate | undefined {
|
||||||
return PRODUCTS[id];
|
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<string, PodProductConfig[]> = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,34 @@ import type { DocSchema } from '../../shared/local-first/document';
|
||||||
|
|
||||||
// ── Document types ──
|
// ── Document types ──
|
||||||
|
|
||||||
|
export interface SwagDesignProduct {
|
||||||
|
type: string;
|
||||||
|
provider: 'printful' | 'prodigi' | 'cosmolocal';
|
||||||
|
sku: string;
|
||||||
|
variants: string[];
|
||||||
|
retailPrice: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SwagDesign {
|
export interface SwagDesign {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
productType: 'sticker' | 'poster' | 'tee' | 'hoodie';
|
productType: 'sticker' | 'poster' | 'tee' | 'hoodie';
|
||||||
/** Server artifact ID (if generated) */
|
/** Server artifact ID (if generated) */
|
||||||
artifactId: string | null;
|
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;
|
createdBy: string | null;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
|
|
@ -41,12 +63,12 @@ export interface SwagDoc {
|
||||||
export const swagSchema: DocSchema<SwagDoc> = {
|
export const swagSchema: DocSchema<SwagDoc> = {
|
||||||
module: 'swag',
|
module: 'swag',
|
||||||
collection: 'designs',
|
collection: 'designs',
|
||||||
version: 1,
|
version: 2,
|
||||||
init: (): SwagDoc => ({
|
init: (): SwagDoc => ({
|
||||||
meta: {
|
meta: {
|
||||||
module: 'swag',
|
module: 'swag',
|
||||||
collection: 'designs',
|
collection: 'designs',
|
||||||
version: 1,
|
version: 2,
|
||||||
spaceSlug: '',
|
spaceSlug: '',
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
},
|
},
|
||||||
|
|
@ -56,7 +78,17 @@ export const swagSchema: DocSchema<SwagDoc> = {
|
||||||
migrate: (doc: any, _fromVersion: number) => {
|
migrate: (doc: any, _fromVersion: number) => {
|
||||||
if (!doc.designs) doc.designs = {};
|
if (!doc.designs) doc.designs = {};
|
||||||
if (!('activeDesignId' in doc)) doc.activeDesignId = null;
|
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;
|
return doc;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,9 @@ interface Proposal {
|
||||||
final_yes: number;
|
final_yes: number;
|
||||||
final_no: number;
|
final_no: number;
|
||||||
final_abstain: number;
|
final_abstain: number;
|
||||||
|
elo: number;
|
||||||
|
elo_comparisons: number;
|
||||||
|
elo_wins: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
voting_ends_at: string | null;
|
voting_ends_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
@ -46,7 +49,7 @@ interface ScoreSnapshot {
|
||||||
class FolkVoteDashboard extends HTMLElement {
|
class FolkVoteDashboard extends HTMLElement {
|
||||||
private shadow: ShadowRoot;
|
private shadow: ShadowRoot;
|
||||||
private space = "";
|
private space = "";
|
||||||
private view: "spaces" | "proposals" | "proposal" = "spaces";
|
private view: "spaces" | "proposals" | "proposal" | "rank" = "spaces";
|
||||||
private spaces: VoteSpace[] = [];
|
private spaces: VoteSpace[] = [];
|
||||||
private selectedSpace: VoteSpace | null = null;
|
private selectedSpace: VoteSpace | null = null;
|
||||||
private proposals: Proposal[] = [];
|
private proposals: Proposal[] = [];
|
||||||
|
|
@ -57,7 +60,14 @@ class FolkVoteDashboard extends HTMLElement {
|
||||||
private showTrendChart = true;
|
private showTrendChart = true;
|
||||||
private scoreHistory: ScoreSnapshot[] = [];
|
private scoreHistory: ScoreSnapshot[] = [];
|
||||||
private _offlineUnsubs: (() => void)[] = [];
|
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
|
// Guided tour
|
||||||
private _tour!: TourEngine;
|
private _tour!: TourEngine;
|
||||||
|
|
@ -110,6 +120,7 @@ class FolkVoteDashboard extends HTMLElement {
|
||||||
score: d.proposal.score, vote_count: String(Object.keys(d.votes || {}).length),
|
score: d.proposal.score, vote_count: String(Object.keys(d.votes || {}).length),
|
||||||
final_yes: d.proposal.finalYes, final_no: d.proposal.finalNo,
|
final_yes: d.proposal.finalYes, final_no: d.proposal.finalNo,
|
||||||
final_abstain: d.proposal.finalAbstain,
|
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(),
|
created_at: new Date(d.proposal.createdAt).toISOString(),
|
||||||
voting_ends_at: d.proposal.votingEndsAt ? new Date(d.proposal.votingEndsAt).toISOString() : null,
|
voting_ends_at: d.proposal.votingEndsAt ? new Date(d.proposal.votingEndsAt).toISOString() : null,
|
||||||
});
|
});
|
||||||
|
|
@ -151,7 +162,7 @@ class FolkVoteDashboard extends HTMLElement {
|
||||||
status: "RANKING",
|
status: "RANKING",
|
||||||
score: 72,
|
score: 72,
|
||||||
vote_count: "9",
|
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(),
|
created_at: new Date(now - 3 * day).toISOString(),
|
||||||
voting_ends_at: null,
|
voting_ends_at: null,
|
||||||
},
|
},
|
||||||
|
|
@ -162,7 +173,7 @@ class FolkVoteDashboard extends HTMLElement {
|
||||||
status: "RANKING",
|
status: "RANKING",
|
||||||
score: 45,
|
score: 45,
|
||||||
vote_count: "6",
|
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(),
|
created_at: new Date(now - 5 * day).toISOString(),
|
||||||
voting_ends_at: null,
|
voting_ends_at: null,
|
||||||
},
|
},
|
||||||
|
|
@ -173,7 +184,7 @@ class FolkVoteDashboard extends HTMLElement {
|
||||||
status: "VOTING",
|
status: "VOTING",
|
||||||
score: 105,
|
score: 105,
|
||||||
vote_count: "14",
|
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(),
|
created_at: new Date(now - 10 * day).toISOString(),
|
||||||
voting_ends_at: new Date(now + 5 * day).toISOString(),
|
voting_ends_at: new Date(now + 5 * day).toISOString(),
|
||||||
},
|
},
|
||||||
|
|
@ -184,7 +195,7 @@ class FolkVoteDashboard extends HTMLElement {
|
||||||
status: "PASSED",
|
status: "PASSED",
|
||||||
score: 150,
|
score: 150,
|
||||||
vote_count: "17",
|
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(),
|
created_at: new Date(now - 21 * day).toISOString(),
|
||||||
voting_ends_at: new Date(now - 7 * day).toISOString(),
|
voting_ends_at: new Date(now - 7 * day).toISOString(),
|
||||||
},
|
},
|
||||||
|
|
@ -195,7 +206,7 @@ class FolkVoteDashboard extends HTMLElement {
|
||||||
status: "FAILED",
|
status: "FAILED",
|
||||||
score: 30,
|
score: 30,
|
||||||
vote_count: "11",
|
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(),
|
created_at: new Date(now - 18 * day).toISOString(),
|
||||||
voting_ends_at: new Date(now - 4 * day).toISOString(),
|
voting_ends_at: new Date(now - 4 * day).toISOString(),
|
||||||
},
|
},
|
||||||
|
|
@ -358,7 +369,7 @@ class FolkVoteDashboard extends HTMLElement {
|
||||||
status: "RANKING",
|
status: "RANKING",
|
||||||
score: 0,
|
score: 0,
|
||||||
vote_count: "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(),
|
created_at: new Date(now).toISOString(),
|
||||||
voting_ends_at: null,
|
voting_ends_at: null,
|
||||||
});
|
});
|
||||||
|
|
@ -610,6 +621,83 @@ class FolkVoteDashboard extends HTMLElement {
|
||||||
width: 8px; height: 8px; border-radius: 2px; flex-shrink: 0;
|
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) {
|
@media (max-width: 768px) {
|
||||||
.tally { gap: 12px; }
|
.tally { gap: 12px; }
|
||||||
.detail { padding: 16px; }
|
.detail { padding: 16px; }
|
||||||
|
|
@ -617,6 +705,7 @@ class FolkVoteDashboard extends HTMLElement {
|
||||||
.trend-section { padding: 12px 14px; }
|
.trend-section { padding: 12px 14px; }
|
||||||
}
|
}
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
|
.rank-pair { grid-template-columns: 1fr; }
|
||||||
.tally { gap: 12px; }
|
.tally { gap: 12px; }
|
||||||
.tally-value { font-size: 20px; }
|
.tally-value { font-size: 20px; }
|
||||||
}
|
}
|
||||||
|
|
@ -657,6 +746,9 @@ class FolkVoteDashboard extends HTMLElement {
|
||||||
startTour() { this._tour.start(); }
|
startTour() { this._tour.start(); }
|
||||||
|
|
||||||
private renderView(): string {
|
private renderView(): string {
|
||||||
|
if (this.view === "rank" && this.selectedSpace) {
|
||||||
|
return this.renderRank();
|
||||||
|
}
|
||||||
if (this.view === "proposal" && this.selectedProposal) {
|
if (this.view === "proposal" && this.selectedProposal) {
|
||||||
return this.renderProposal();
|
return this.renderProposal();
|
||||||
}
|
}
|
||||||
|
|
@ -723,6 +815,7 @@ class FolkVoteDashboard extends HTMLElement {
|
||||||
${this._history.canGoBack && this.spaces.length > 1 ? '<button class="header-back" data-back="spaces">←</button>' : ''}
|
${this._history.canGoBack && this.spaces.length > 1 ? '<button class="header-back" data-back="spaces">←</button>' : ''}
|
||||||
<span class="header-title">${this.esc(s.name)}</span>
|
<span class="header-title">${this.esc(s.name)}</span>
|
||||||
<span class="header-sub">${this.proposals.length} proposal${this.proposals.length !== 1 ? "s" : ""}</span>
|
<span class="header-sub">${this.proposals.length} proposal${this.proposals.length !== 1 ? "s" : ""}</span>
|
||||||
|
<button class="btn-rank" data-goto-rank>🎲 Rank</button>
|
||||||
<button class="btn-new" data-toggle-create>+ New Proposal</button>
|
<button class="btn-new" data-toggle-create>+ New Proposal</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -832,6 +925,7 @@ class FolkVoteDashboard extends HTMLElement {
|
||||||
|
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<span>${p.vote_count} vote${p.vote_count !== "1" ? "s" : ""}</span>
|
<span>${p.vote_count} vote${p.vote_count !== "1" ? "s" : ""}</span>
|
||||||
|
${(p.elo ?? 0) !== 1500 || (p.elo_comparisons ?? 0) > 0 ? `<span class="elo-badge" title="${p.elo_comparisons ?? 0} comparisons, ${p.elo_wins ?? 0} wins">⚡ ${p.elo ?? 1500}</span>` : ''}
|
||||||
<span>${this.relativeTime(p.created_at)}</span>
|
<span>${this.relativeTime(p.created_at)}</span>
|
||||||
${p.status === "VOTING" && p.voting_ends_at ? `<span style="color:#f59e0b">${this.daysLeft(p.voting_ends_at)}</span>` : ""}
|
${p.status === "VOTING" && p.voting_ends_at ? `<span style="color:#f59e0b">${this.daysLeft(p.voting_ends_at)}</span>` : ""}
|
||||||
${p.status === "RANKING" ? `<span style="color:#3b82f6">${Math.round(threshold - p.score)} to advance</span>` : ""}
|
${p.status === "RANKING" ? `<span style="color:#3b82f6">${Math.round(threshold - p.score)} to advance</span>` : ""}
|
||||||
|
|
@ -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 = '<div class="loading">Loading pair...</div>';
|
||||||
|
} else if (!this.rankPairA || !this.rankPairB) {
|
||||||
|
pairHtml = `<div class="empty"><div class="empty-icon">🎲</div><div class="empty-text">Need at least 2 ranking proposals to compare.</div></div>`;
|
||||||
|
} else {
|
||||||
|
const a = this.rankPairA;
|
||||||
|
const b = this.rankPairB;
|
||||||
|
const aIsWinner = this.rankResult?.winnerId === a.id;
|
||||||
|
const bIsWinner = this.rankResult?.winnerId === b.id;
|
||||||
|
|
||||||
|
pairHtml = `
|
||||||
|
<div class="rank-session-info">
|
||||||
|
<span>Which proposal should be higher priority?</span>
|
||||||
|
<button class="rank-skip" data-rank-skip>Skip pair</button>
|
||||||
|
</div>
|
||||||
|
<div class="rank-pair">
|
||||||
|
<div class="rank-card${aIsWinner ? ' winner' : this.rankResult && !aIsWinner ? ' loser' : ''}" data-rank-pick="${a.id}" data-rank-lose="${b.id}">
|
||||||
|
${this.rankResult && aIsWinner ? `<span class="rank-delta positive">+${this.rankResult.delta}</span>` : ''}
|
||||||
|
${this.rankResult && !aIsWinner ? `<span class="rank-delta negative">-${this.rankResult.delta}</span>` : ''}
|
||||||
|
<div class="rank-card-title">${this.esc(a.title)}</div>
|
||||||
|
<div class="rank-card-desc">${this.esc(a.description || '')}</div>
|
||||||
|
<div class="rank-card-stats">
|
||||||
|
<span class="rank-card-stat">⚡ <strong>${a.elo ?? 1500}</strong></span>
|
||||||
|
<span class="rank-card-stat">${a.elo_comparisons ?? 0} comparisons</span>
|
||||||
|
<span class="rank-card-stat">Score: ${Math.round(a.score)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rank-card${bIsWinner ? ' winner' : this.rankResult && !bIsWinner ? ' loser' : ''}" data-rank-pick="${b.id}" data-rank-lose="${a.id}">
|
||||||
|
${this.rankResult && bIsWinner ? `<span class="rank-delta positive">+${this.rankResult.delta}</span>` : ''}
|
||||||
|
${this.rankResult && !bIsWinner ? `<span class="rank-delta negative">-${this.rankResult.delta}</span>` : ''}
|
||||||
|
<div class="rank-card-title">${this.esc(b.title)}</div>
|
||||||
|
<div class="rank-card-desc">${this.esc(b.description || '')}</div>
|
||||||
|
<div class="rank-card-stats">
|
||||||
|
<span class="rank-card-stat">⚡ <strong>${b.elo ?? 1500}</strong></span>
|
||||||
|
<span class="rank-card-stat">${b.elo_comparisons ?? 0} comparisons</span>
|
||||||
|
<span class="rank-card-stat">Score: ${Math.round(b.score)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Elo leaderboard
|
||||||
|
const leaderboard = ranking.map((p, i) => `
|
||||||
|
<div class="elo-row">
|
||||||
|
<span class="elo-rank-num">${i + 1}</span>
|
||||||
|
<span class="elo-row-title">${this.esc(p.title)}</span>
|
||||||
|
<span class="elo-row-rating">⚡ ${p.elo ?? 1500}</span>
|
||||||
|
<span class="elo-row-meta">${p.elo_comparisons ?? 0} cmp / ${p.elo_wins ?? 0}W</span>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="rank-header">
|
||||||
|
<button class="header-back" data-back="proposals">←</button>
|
||||||
|
<span class="header-title">🎲 Pairwise Ranking</span>
|
||||||
|
<span class="header-sub">${this.rankCompareCount} comparisons this session</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${this.space === "demo" ? `
|
||||||
|
<div class="demo-banner">
|
||||||
|
<strong>Demo mode</strong> — Elo updates are local only. Create a space for persistent rankings.
|
||||||
|
</div>
|
||||||
|
` : ""}
|
||||||
|
|
||||||
|
${pairHtml}
|
||||||
|
|
||||||
|
<div class="elo-leaderboard">
|
||||||
|
<div class="elo-leaderboard-title">Elo Leaderboard</div>
|
||||||
|
${leaderboard || '<div class="empty"><div class="empty-text">No ranking proposals yet.</div></div>'}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
private renderCreateForm(): string {
|
private renderCreateForm(): string {
|
||||||
return `
|
return `
|
||||||
<div class="create-form">
|
<div class="create-form">
|
||||||
|
|
@ -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
|
// Toggle trend chart
|
||||||
this.shadow.querySelector("[data-toggle-trend]")?.addEventListener("click", () => {
|
this.shadow.querySelector("[data-toggle-trend]")?.addEventListener("click", () => {
|
||||||
this.showTrendChart = !this.showTrendChart;
|
this.showTrendChart = !this.showTrendChart;
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import type { RSpaceModule } from "../../shared/module";
|
||||||
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
|
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
|
||||||
import { renderLanding } from "./landing";
|
import { renderLanding } from "./landing";
|
||||||
import type { SyncServer } from '../../server/local-first/sync-server';
|
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';
|
import type { ProposalDoc, SpaceConfig } from './schemas';
|
||||||
|
|
||||||
const routes = new Hono();
|
const routes = new Hono();
|
||||||
|
|
@ -142,6 +142,7 @@ function spaceConfigToRest(cfg: SpaceConfig) {
|
||||||
function proposalToRest(doc: ProposalDoc) {
|
function proposalToRest(doc: ProposalDoc) {
|
||||||
const p = doc.proposal;
|
const p = doc.proposal;
|
||||||
const voteCount = Object.keys(doc.votes).length;
|
const voteCount = Object.keys(doc.votes).length;
|
||||||
|
const pw = doc.pairwise || { elo: ELO_DEFAULT, comparisons: 0, wins: 0 };
|
||||||
return {
|
return {
|
||||||
id: p.id,
|
id: p.id,
|
||||||
space_slug: p.spaceSlug,
|
space_slug: p.spaceSlug,
|
||||||
|
|
@ -155,6 +156,9 @@ function proposalToRest(doc: ProposalDoc) {
|
||||||
final_no: p.finalNo,
|
final_no: p.finalNo,
|
||||||
final_abstain: p.finalAbstain,
|
final_abstain: p.finalAbstain,
|
||||||
vote_count: String(voteCount),
|
vote_count: String(voteCount),
|
||||||
|
elo: pw.elo,
|
||||||
|
elo_comparisons: pw.comparisons,
|
||||||
|
elo_wins: pw.wins,
|
||||||
created_at: new Date(p.createdAt).toISOString(),
|
created_at: new Date(p.createdAt).toISOString(),
|
||||||
updated_at: new Date(p.updatedAt).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 });
|
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<ProposalDoc>(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<ProposalDoc>(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) ──
|
// ── Demo page body (reused by / when space=demo, and /demo fallback) ──
|
||||||
|
|
||||||
function renderDemoBody(): string {
|
function renderDemoBody(): string {
|
||||||
|
|
@ -696,6 +796,7 @@ export const voteModule: RSpaceModule = {
|
||||||
outputPaths: [
|
outputPaths: [
|
||||||
{ path: "proposals", name: "Proposals", icon: "📜", description: "Governance proposals for conviction voting" },
|
{ path: "proposals", name: "Proposals", icon: "📜", description: "Governance proposals for conviction voting" },
|
||||||
{ path: "ballots", name: "Ballots", icon: "🗳️", description: "Voting ballots and results" },
|
{ path: "ballots", name: "Ballots", icon: "🗳️", description: "Voting ballots and results" },
|
||||||
|
{ path: "rank", name: "Rank", icon: "🎲", description: "Pairwise Elo ranking via sortition" },
|
||||||
],
|
],
|
||||||
onboardingActions: [
|
onboardingActions: [
|
||||||
{ label: "Create a Proposal", icon: "🗳️", description: "Start a governance vote", type: 'create', href: '/{space}/rvote' },
|
{ label: "Create a Proposal", icon: "🗳️", description: "Start a governance vote", type: 'create', href: '/{space}/rvote' },
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,12 @@ export interface ProposalMeta {
|
||||||
updatedAt: number;
|
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 {
|
export interface ProposalDoc {
|
||||||
meta: {
|
meta: {
|
||||||
module: string;
|
module: string;
|
||||||
|
|
@ -73,6 +79,18 @@ export interface ProposalDoc {
|
||||||
proposal: ProposalMeta;
|
proposal: ProposalMeta;
|
||||||
votes: Record<string, VoteItem>;
|
votes: Record<string, VoteItem>;
|
||||||
finalVotes: Record<string, FinalVoteItem>;
|
finalVotes: Record<string, FinalVoteItem>;
|
||||||
|
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 ──
|
// ── Schema registration ──
|
||||||
|
|
@ -107,6 +125,7 @@ export const proposalSchema: DocSchema<ProposalDoc> = {
|
||||||
},
|
},
|
||||||
votes: {},
|
votes: {},
|
||||||
finalVotes: {},
|
finalVotes: {},
|
||||||
|
pairwise: { elo: ELO_DEFAULT, comparisons: 0, wins: 0 },
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -262,10 +262,18 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
const parsed = JSON.parse(session);
|
const parsed = JSON.parse(session);
|
||||||
if (parsed.claims?.exp > Math.floor(Date.now() / 1000)) {
|
if (parsed.claims?.exp > Math.floor(Date.now() / 1000)) {
|
||||||
this.isAuthenticated = true;
|
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.passKeyEOA = parsed.claims?.eid?.walletAddress || "";
|
||||||
this.userDID = parsed.claims?.did || "";
|
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();
|
this.loadCRDTBalances();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,12 +69,12 @@ import { splatModule } from "../modules/rsplat/mod";
|
||||||
import { photosModule } from "../modules/rphotos/mod";
|
import { photosModule } from "../modules/rphotos/mod";
|
||||||
import { socialsModule } from "../modules/rsocials/mod";
|
import { socialsModule } from "../modules/rsocials/mod";
|
||||||
import { meetsModule } from "../modules/rmeets/mod";
|
import { meetsModule } from "../modules/rmeets/mod";
|
||||||
|
import { chatsModule } from "../modules/rchats/mod";
|
||||||
// import { docsModule } from "../modules/rdocs/mod";
|
// import { docsModule } from "../modules/rdocs/mod";
|
||||||
// import { designModule } from "../modules/rdesign/mod";
|
// import { designModule } from "../modules/rdesign/mod";
|
||||||
import { scheduleModule } from "../modules/rschedule/mod";
|
import { scheduleModule } from "../modules/rschedule/mod";
|
||||||
import { bnbModule } from "../modules/rbnb/mod";
|
import { bnbModule } from "../modules/rbnb/mod";
|
||||||
import { vnbModule } from "../modules/rvnb/mod";
|
import { vnbModule } from "../modules/rvnb/mod";
|
||||||
import { crowdsurfModule } from "../modules/crowdsurf/mod";
|
|
||||||
import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces";
|
import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces";
|
||||||
import type { SpaceRoleString } from "./spaces";
|
import type { SpaceRoleString } from "./spaces";
|
||||||
import { renderShell, renderModuleLanding, renderSubPageInfo, renderOnboarding } from "./shell";
|
import { renderShell, renderModuleLanding, renderSubPageInfo, renderOnboarding } from "./shell";
|
||||||
|
|
@ -115,9 +115,9 @@ registerModule(photosModule);
|
||||||
registerModule(socialsModule);
|
registerModule(socialsModule);
|
||||||
registerModule(scheduleModule);
|
registerModule(scheduleModule);
|
||||||
registerModule(meetsModule);
|
registerModule(meetsModule);
|
||||||
|
registerModule(chatsModule);
|
||||||
registerModule(bnbModule);
|
registerModule(bnbModule);
|
||||||
registerModule(vnbModule);
|
registerModule(vnbModule);
|
||||||
registerModule(crowdsurfModule);
|
|
||||||
// De-emphasized modules (bottom of menu)
|
// De-emphasized modules (bottom of menu)
|
||||||
registerModule(forumModule);
|
registerModule(forumModule);
|
||||||
registerModule(tubeModule);
|
registerModule(tubeModule);
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,50 @@ import { getDocumentData } from "./community-store";
|
||||||
// ── Browser compatibility polyfills (inline, runs before ES modules) ──
|
// ── Browser compatibility polyfills (inline, runs before ES modules) ──
|
||||||
const COMPAT_POLYFILLS = `<script>(function(){if(typeof AbortSignal.timeout!=="function"){AbortSignal.timeout=function(ms){var c=new AbortController();setTimeout(function(){c.abort(new DOMException("The operation was aborted due to timeout","TimeoutError"))},ms);return c.signal}}if(typeof crypto!=="undefined"&&typeof crypto.randomUUID!=="function"){crypto.randomUUID=function(){var b=crypto.getRandomValues(new Uint8Array(16));b[6]=(b[6]&0x0f)|0x40;b[8]=(b[8]&0x3f)|0x80;var h=Array.from(b,function(x){return x.toString(16).padStart(2,"0")}).join("");return h.slice(0,8)+"-"+h.slice(8,12)+"-"+h.slice(12,16)+"-"+h.slice(16,20)+"-"+h.slice(20)}}})()</script>`;
|
const COMPAT_POLYFILLS = `<script>(function(){if(typeof AbortSignal.timeout!=="function"){AbortSignal.timeout=function(ms){var c=new AbortController();setTimeout(function(){c.abort(new DOMException("The operation was aborted due to timeout","TimeoutError"))},ms);return c.signal}}if(typeof crypto!=="undefined"&&typeof crypto.randomUUID!=="function"){crypto.randomUUID=function(){var b=crypto.getRandomValues(new Uint8Array(16));b[6]=(b[6]&0x0f)|0x40;b[8]=(b[8]&0x3f)|0x80;var h=Array.from(b,function(x){return x.toString(16).padStart(2,"0")}).join("");return h.slice(0,8)+"-"+h.slice(8,12)+"-"+h.slice(12,16)+"-"+h.slice(16,20)+"-"+h.slice(20)}}})()</script>`;
|
||||||
|
|
||||||
|
// ── 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<string, { badge: string; color: string }> = {
|
||||||
|
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 `<script data-mod="${moduleId}">(function(){var m=${FAVICON_BADGE_JSON};var id=document.currentScript.dataset.mod;if(!id||!m[id])return;var b=m[id];var s='<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" rx="14" fill="'+b.color+'"/><text x="32" y="44" text-anchor="middle" font-size="26" font-family="sans-serif">'+b.badge+'</text></svg>';var l=document.querySelector('link[rel="icon"]');if(l)l.href="data:image/svg+xml,"+encodeURIComponent(s)})()</script>`;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Content-hash cache busting ──
|
// ── Content-hash cache busting ──
|
||||||
let moduleHashes: Record<string, string> = {};
|
let moduleHashes: Record<string, string> = {};
|
||||||
try {
|
try {
|
||||||
|
|
@ -136,6 +180,7 @@ export function renderShell(opts: ShellOptions): string {
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
<link rel="icon" type="image/png" href="/favicon.png">
|
<link rel="icon" type="image/png" href="/favicon.png">
|
||||||
|
${faviconScript(moduleId)}
|
||||||
<link rel="manifest" href="/manifest.json">
|
<link rel="manifest" href="/manifest.json">
|
||||||
<meta name="theme-color" content="#0f172a" media="(prefers-color-scheme: dark)">
|
<meta name="theme-color" content="#0f172a" media="(prefers-color-scheme: dark)">
|
||||||
<meta name="theme-color" content="#f5f5f0" media="(prefers-color-scheme: light)">
|
<meta name="theme-color" content="#f5f5f0" media="(prefers-color-scheme: light)">
|
||||||
|
|
@ -1139,6 +1184,7 @@ export function renderExternalAppShell(opts: ExternalAppShellOptions): string {
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<link rel="icon" type="image/png" href="/favicon.png">
|
<link rel="icon" type="image/png" href="/favicon.png">
|
||||||
|
${faviconScript(moduleId)}
|
||||||
<title>${escapeHtml(title)}</title>
|
<title>${escapeHtml(title)}</title>
|
||||||
<script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t);var b=localStorage.getItem('canvas-bg')||'grid';document.documentElement.setAttribute('data-canvas-bg',b)})()</script>
|
<script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t);var b=localStorage.getItem('canvas-bg')||'grid';document.documentElement.setAttribute('data-canvas-bg',b)})()</script>
|
||||||
${COMPAT_POLYFILLS}
|
${COMPAT_POLYFILLS}
|
||||||
|
|
@ -1740,6 +1786,7 @@ export function renderModuleLanding(opts: ModuleLandingOptions): string {
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
<link rel="icon" type="image/png" href="/favicon.png">
|
<link rel="icon" type="image/png" href="/favicon.png">
|
||||||
|
${faviconScript(mod.id)}
|
||||||
<link rel="manifest" href="/manifest.json">
|
<link rel="manifest" href="/manifest.json">
|
||||||
<meta name="theme-color" content="#0f172a" media="(prefers-color-scheme: dark)">
|
<meta name="theme-color" content="#0f172a" media="(prefers-color-scheme: dark)">
|
||||||
<meta name="theme-color" content="#f5f5f0" media="(prefers-color-scheme: light)">
|
<meta name="theme-color" content="#f5f5f0" media="(prefers-color-scheme: light)">
|
||||||
|
|
@ -2085,6 +2132,7 @@ export function renderSubPageInfo(opts: SubPageInfoOptions): string {
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
<link rel="icon" type="image/png" href="/favicon.png">
|
<link rel="icon" type="image/png" href="/favicon.png">
|
||||||
|
${faviconScript(mod.id)}
|
||||||
<link rel="manifest" href="/manifest.json">
|
<link rel="manifest" href="/manifest.json">
|
||||||
<meta name="theme-color" content="#0f172a" media="(prefers-color-scheme: dark)">
|
<meta name="theme-color" content="#0f172a" media="(prefers-color-scheme: dark)">
|
||||||
<meta name="theme-color" content="#f5f5f0" media="(prefers-color-scheme: light)">
|
<meta name="theme-color" content="#f5f5f0" media="(prefers-color-scheme: light)">
|
||||||
|
|
|
||||||
|
|
@ -53,9 +53,10 @@ const MODULE_BADGES: Record<string, { badge: string; color: string }> = {
|
||||||
rbooks: { badge: "r📚", color: "#fda4af" }, // rose-300
|
rbooks: { badge: "r📚", color: "#fda4af" }, // rose-300
|
||||||
// Observing
|
// Observing
|
||||||
rdata: { badge: "r📊", color: "#d8b4fe" }, // purple-300
|
rdata: { badge: "r📊", color: "#d8b4fe" }, // purple-300
|
||||||
// Sharing & Hospitality
|
// Travel & Stay
|
||||||
rbnb: { badge: "r🏠", color: "#fbbf24" }, // amber-300
|
rbnb: { badge: "r🏠", color: "#fbbf24" }, // amber-300
|
||||||
// Work & Productivity
|
rvnb: { badge: "r🚐", color: "#a5f3fc" }, // cyan-200
|
||||||
|
// Coordinate
|
||||||
rtasks: { badge: "r📋", color: "#cbd5e1" }, // slate-300
|
rtasks: { badge: "r📋", color: "#cbd5e1" }, // slate-300
|
||||||
rschedule: { badge: "r⏱", color: "#a5b4fc" }, // indigo-200
|
rschedule: { badge: "r⏱", color: "#a5b4fc" }, // indigo-200
|
||||||
// Identity & Infrastructure
|
// Identity & Infrastructure
|
||||||
|
|
@ -65,50 +66,59 @@ const MODULE_BADGES: Record<string, { badge: string; color: string }> = {
|
||||||
|
|
||||||
// Category definitions for the rApp dropdown (display-only grouping)
|
// Category definitions for the rApp dropdown (display-only grouping)
|
||||||
const MODULE_CATEGORIES: Record<string, string> = {
|
const MODULE_CATEGORIES: Record<string, string> = {
|
||||||
rspace: "Creating",
|
// Create
|
||||||
rnotes: "Creating",
|
rspace: "Create",
|
||||||
rpubs: "Creating",
|
rnotes: "Create",
|
||||||
rtube: "Creating",
|
rpubs: "Create",
|
||||||
rswag: "Creating",
|
rsplat: "Create",
|
||||||
rsplat: "Creating",
|
rswag: "Create",
|
||||||
rcal: "Connecting",
|
// Communicate
|
||||||
rtrips: "Planning",
|
rchats: "Communicate",
|
||||||
rmaps: "Planning",
|
rinbox: "Communicate",
|
||||||
rchats: "Communicating",
|
rmail: "Communicate",
|
||||||
rmail: "Communicating",
|
rforum: "Communicate",
|
||||||
rforum: "Communicating",
|
rmeets: "Communicate",
|
||||||
rmeets: "Communicating",
|
// Coordinate
|
||||||
rchoices: "Deciding",
|
rcal: "Coordinate",
|
||||||
rvote: "Deciding",
|
rschedule: "Coordinate",
|
||||||
rflows: "Funding & Commerce",
|
rtasks: "Coordinate",
|
||||||
rwallet: "Funding & Commerce",
|
rchoices: "Coordinate",
|
||||||
rcart: "Funding & Commerce",
|
rvote: "Coordinate",
|
||||||
rauctions: "Funding & Commerce",
|
// Connect
|
||||||
rphotos: "Sharing",
|
rnetwork: "Connect",
|
||||||
rsocials: "Sharing",
|
rsocials: "Connect",
|
||||||
rfiles: "Sharing",
|
// Commerce
|
||||||
rbooks: "Sharing",
|
rflows: "Commerce",
|
||||||
rinbox: "Connecting",
|
rwallet: "Commerce",
|
||||||
rnetwork: "Connecting",
|
rcart: "Commerce",
|
||||||
rbnb: "Sharing",
|
rauctions: "Commerce",
|
||||||
rdata: "Observing",
|
// Media
|
||||||
rtasks: "Work & Productivity",
|
rphotos: "Media",
|
||||||
rschedule: "Work & Productivity",
|
rfiles: "Media",
|
||||||
rids: "Identity & Infrastructure",
|
rtube: "Media",
|
||||||
rstack: "Identity & Infrastructure",
|
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 = [
|
const CATEGORY_ORDER = [
|
||||||
"Creating",
|
"Create",
|
||||||
"Planning",
|
"Communicate",
|
||||||
"Connecting",
|
"Coordinate",
|
||||||
"Communicating",
|
"Connect",
|
||||||
"Deciding",
|
"Commerce",
|
||||||
"Funding & Commerce",
|
"Media",
|
||||||
"Sharing",
|
"Travel & Stay",
|
||||||
"Observing",
|
"Observe",
|
||||||
"Work & Productivity",
|
"Platform",
|
||||||
"Identity & Infrastructure",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
import { rspaceNavUrl, getCurrentSpace, isStandaloneDomain } from "../url-helpers";
|
import { rspaceNavUrl, getCurrentSpace, isStandaloneDomain } from "../url-helpers";
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue