wiki-plugin-shoppe 0.0.22 → 0.0.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +53 -9
- package/client/shoppe.js +40 -12
- package/package.json +1 -1
- package/server/scripts/shoppe-sign.js +369 -0
- package/server/server.js +767 -41
- package/server/templates/appointment-booking.html +23 -2
- package/server/templates/ebook-download.html +1 -0
- package/server/templates/generic-address-stripe.html +23 -2
- package/server/templates/generic-recover-stripe.html +23 -2
- package/server/templates/subscription-subscribe.html +23 -2
package/server/server.js
CHANGED
|
@@ -65,6 +65,12 @@ function getAddieUrl() {
|
|
|
65
65
|
return `http://localhost:${process.env.ADDIE_PORT || 3005}`;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
function getLucilleUrl() {
|
|
69
|
+
const config = loadConfig();
|
|
70
|
+
if (config.lucilleUrl) return config.lucilleUrl.replace(/\/$/, '');
|
|
71
|
+
return `http://localhost:${process.env.LUCILLE_PORT || 5444}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
68
74
|
function loadBuyers() {
|
|
69
75
|
if (!fs.existsSync(BUYERS_FILE)) return {};
|
|
70
76
|
try { return JSON.parse(fs.readFileSync(BUYERS_FILE, 'utf8')); } catch { return {}; }
|
|
@@ -117,6 +123,7 @@ const EMOJI_PALETTE = [
|
|
|
117
123
|
const BOOK_EXTS = new Set(['.epub', '.pdf', '.mobi', '.azw', '.azw3']);
|
|
118
124
|
const MUSIC_EXTS = new Set(['.mp3', '.flac', '.m4a', '.ogg', '.wav']);
|
|
119
125
|
const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg']);
|
|
126
|
+
const VIDEO_EXTS = new Set(['.mp4', '.mov', '.mkv', '.webm', '.avi']);
|
|
120
127
|
|
|
121
128
|
// ============================================================
|
|
122
129
|
// MARKDOWN / FRONT MATTER UTILITIES
|
|
@@ -141,6 +148,147 @@ function escHtml(str) {
|
|
|
141
148
|
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
142
149
|
}
|
|
143
150
|
|
|
151
|
+
// Extract kw:-prefixed entries from a Sanora product's tags string into a comma-separated keyword list.
|
|
152
|
+
function extractKeywords(product) {
|
|
153
|
+
return (product.tags || '').split(',')
|
|
154
|
+
.filter(t => t.startsWith('kw:'))
|
|
155
|
+
.map(t => t.slice(3).trim())
|
|
156
|
+
.join(', ');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Append keyword tags (as kw:word entries) to a base tags string.
|
|
160
|
+
function buildTags(baseTags, keywords) {
|
|
161
|
+
const kwTags = (Array.isArray(keywords) ? keywords : [])
|
|
162
|
+
.map(kw => `kw:${kw.trim()}`).filter(Boolean);
|
|
163
|
+
if (!kwTags.length) return baseTags;
|
|
164
|
+
return baseTags + ',' + kwTags.join(',');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── Owner key pair (secp256k1 via sessionless) ───────────────────────────────
|
|
168
|
+
|
|
169
|
+
async function generateOwnerKeyPair() {
|
|
170
|
+
const keys = await sessionless.generateKeys(() => {}, () => null);
|
|
171
|
+
return { pubKey: keys.pubKey, privateKey: keys.privateKey };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Validate owner signature embedded in a manifest.
|
|
175
|
+
// If the tenant has no ownerPubKey (registered before this feature), validation is skipped.
|
|
176
|
+
function validateOwnerSignature(manifest, tenant) {
|
|
177
|
+
if (!tenant.ownerPubKey) return; // legacy tenant — no signature required
|
|
178
|
+
|
|
179
|
+
if (!manifest.ownerPubKey || !manifest.timestamp || !manifest.signature) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
'Archive is missing owner signature fields. Sign it first:\n' +
|
|
182
|
+
' node shoppe-sign.js'
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
if (manifest.ownerPubKey !== tenant.ownerPubKey) {
|
|
186
|
+
throw new Error('Owner public key does not match the registered key for this shoppe');
|
|
187
|
+
}
|
|
188
|
+
const age = Date.now() - parseInt(manifest.timestamp, 10);
|
|
189
|
+
if (isNaN(age) || age < 0 || age > 10 * 60 * 1000) {
|
|
190
|
+
throw new Error('Signature timestamp is invalid or expired — re-run: node shoppe-sign.js');
|
|
191
|
+
}
|
|
192
|
+
const message = manifest.timestamp + manifest.uuid;
|
|
193
|
+
if (!sessionless.verifySignature(manifest.signature, message, manifest.ownerPubKey)) {
|
|
194
|
+
throw new Error('Owner signature verification failed');
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Single-use bundle tokens: token → { uuid, expiresAt }
|
|
199
|
+
const bundleTokens = new Map();
|
|
200
|
+
|
|
201
|
+
// Build the starter bundle zip for a newly registered tenant.
|
|
202
|
+
function generateBundleBuffer(tenant, ownerPrivateKey, ownerPubKey, wikiOrigin) {
|
|
203
|
+
const SIGN_SCRIPT = fs.readFileSync(
|
|
204
|
+
path.join(__dirname, 'scripts', 'shoppe-sign.js')
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
const manifest = {
|
|
208
|
+
uuid: tenant.uuid,
|
|
209
|
+
emojicode: tenant.emojicode,
|
|
210
|
+
name: tenant.name,
|
|
211
|
+
wikiUrl: `${wikiOrigin}/plugin/shoppe/${tenant.uuid}`
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const keyData = { privateKey: ownerPrivateKey, pubKey: ownerPubKey };
|
|
215
|
+
|
|
216
|
+
const packageJson = JSON.stringify({
|
|
217
|
+
name: 'shoppe',
|
|
218
|
+
version: '1.0.0',
|
|
219
|
+
private: true,
|
|
220
|
+
description: 'Shoppe content folder',
|
|
221
|
+
dependencies: {
|
|
222
|
+
'sessionless-node': '^0.9.12'
|
|
223
|
+
}
|
|
224
|
+
}, null, 2);
|
|
225
|
+
|
|
226
|
+
const readme = [
|
|
227
|
+
`# ${tenant.name} — Shoppe Starter`,
|
|
228
|
+
'',
|
|
229
|
+
'## First-time setup',
|
|
230
|
+
'',
|
|
231
|
+
'1. Install Node.js if needed: https://nodejs.org',
|
|
232
|
+
'2. Run: `npm install` (installs sessionless-node — one time only)',
|
|
233
|
+
'3. Run: `node shoppe-sign.js init`',
|
|
234
|
+
' This moves your private key to ~/.shoppe/keys/ and removes it from this folder.',
|
|
235
|
+
'',
|
|
236
|
+
'## Adding content',
|
|
237
|
+
'',
|
|
238
|
+
'Add your goods to the appropriate folders:',
|
|
239
|
+
'',
|
|
240
|
+
' books/ → .epub / .pdf / .mobi (+ cover.jpg + info.json)',
|
|
241
|
+
' music/ → album subfolders or standalone .mp3 files',
|
|
242
|
+
' posts/ → numbered subfolders with post.md',
|
|
243
|
+
' albums/ → photo album subfolders',
|
|
244
|
+
' products/ → physical products with info.json',
|
|
245
|
+
' videos/ → numbered subfolders with .mp4/.mov/.mkv + cover.jpg + info.json',
|
|
246
|
+
' appointments/ → bookable services with info.json',
|
|
247
|
+
' subscriptions/ → membership tiers with info.json',
|
|
248
|
+
'',
|
|
249
|
+
'Each content folder can have an optional info.json:',
|
|
250
|
+
' { "title": "…", "description": "…", "price": 0, "keywords": ["tag1","tag2"] }',
|
|
251
|
+
'',
|
|
252
|
+
'## Uploading',
|
|
253
|
+
'',
|
|
254
|
+
'Run: `node shoppe-sign.js`',
|
|
255
|
+
'',
|
|
256
|
+
'This signs your manifest and creates a ready-to-upload zip next to this folder.',
|
|
257
|
+
'Drag that zip onto your wiki\'s shoppe plugin.',
|
|
258
|
+
'',
|
|
259
|
+
'## Re-uploading',
|
|
260
|
+
'',
|
|
261
|
+
'Add or update content, then run `node shoppe-sign.js` again.',
|
|
262
|
+
'Each upload overwrites existing items and adds new ones.',
|
|
263
|
+
'',
|
|
264
|
+
'## Viewing orders',
|
|
265
|
+
'',
|
|
266
|
+
'Run: `node shoppe-sign.js orders`',
|
|
267
|
+
'',
|
|
268
|
+
'Opens a signed link to your order dashboard (valid for 5 minutes).',
|
|
269
|
+
'',
|
|
270
|
+
'## Setting up payouts (Stripe)',
|
|
271
|
+
'',
|
|
272
|
+
'Run: `node shoppe-sign.js payouts`',
|
|
273
|
+
'',
|
|
274
|
+
'Opens Stripe Connect onboarding so you can receive payments.',
|
|
275
|
+
'Do this once before your first sale.',
|
|
276
|
+
].join('\n');
|
|
277
|
+
|
|
278
|
+
const zip = new AdmZip();
|
|
279
|
+
zip.addFile('manifest.json', Buffer.from(JSON.stringify(manifest, null, 2)));
|
|
280
|
+
zip.addFile('shoppe-key.json', Buffer.from(JSON.stringify(keyData, null, 2)));
|
|
281
|
+
zip.addFile('shoppe-sign.js', SIGN_SCRIPT);
|
|
282
|
+
zip.addFile('package.json', Buffer.from(packageJson));
|
|
283
|
+
zip.addFile('README.md', Buffer.from(readme));
|
|
284
|
+
|
|
285
|
+
for (const dir of ['books', 'music', 'posts', 'albums', 'products', 'appointments', 'subscriptions']) {
|
|
286
|
+
zip.addFile(`${dir}/.gitkeep`, Buffer.from(''));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return zip.toBuffer();
|
|
290
|
+
}
|
|
291
|
+
|
|
144
292
|
function renderMarkdown(md) {
|
|
145
293
|
// Process code blocks first to avoid mangling their contents
|
|
146
294
|
const codeBlocks = [];
|
|
@@ -253,6 +401,16 @@ async function registerTenant(name) {
|
|
|
253
401
|
console.warn('[shoppe] Could not create addie user (payouts unavailable):', err.message);
|
|
254
402
|
}
|
|
255
403
|
|
|
404
|
+
// Create a dedicated Lucille user for video uploads
|
|
405
|
+
let lucilleKeys = null;
|
|
406
|
+
try {
|
|
407
|
+
lucilleKeys = await lucilleCreateUser();
|
|
408
|
+
} catch (err) {
|
|
409
|
+
console.warn('[shoppe] Could not create lucille user (video uploads unavailable):', err.message);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const ownerKeys = await generateOwnerKeyPair();
|
|
413
|
+
|
|
256
414
|
const tenant = {
|
|
257
415
|
uuid: sanoraUser.uuid,
|
|
258
416
|
emojicode,
|
|
@@ -260,6 +418,8 @@ async function registerTenant(name) {
|
|
|
260
418
|
keys,
|
|
261
419
|
sanoraUser,
|
|
262
420
|
addieKeys,
|
|
421
|
+
lucilleKeys,
|
|
422
|
+
ownerPubKey: ownerKeys.pubKey,
|
|
263
423
|
createdAt: Date.now()
|
|
264
424
|
};
|
|
265
425
|
|
|
@@ -267,7 +427,15 @@ async function registerTenant(name) {
|
|
|
267
427
|
saveTenants(tenants);
|
|
268
428
|
|
|
269
429
|
console.log(`[shoppe] Registered tenant: "${name}" ${emojicode} (${sanoraUser.uuid})`);
|
|
270
|
-
|
|
430
|
+
// ownerPrivateKey is returned once so the caller can include it in the starter bundle.
|
|
431
|
+
// It is NOT persisted server-side.
|
|
432
|
+
return {
|
|
433
|
+
uuid: sanoraUser.uuid,
|
|
434
|
+
emojicode,
|
|
435
|
+
name: tenant.name,
|
|
436
|
+
ownerPrivateKey: ownerKeys.privateKey,
|
|
437
|
+
ownerPubKey: ownerKeys.pubKey
|
|
438
|
+
};
|
|
271
439
|
}
|
|
272
440
|
|
|
273
441
|
function getTenantByIdentifier(identifier) {
|
|
@@ -390,6 +558,91 @@ async function sanoraUploadImage(tenant, title, imageBuffer, filename) {
|
|
|
390
558
|
return result;
|
|
391
559
|
}
|
|
392
560
|
|
|
561
|
+
// ============================================================
|
|
562
|
+
// LUCILLE HELPERS
|
|
563
|
+
// ============================================================
|
|
564
|
+
|
|
565
|
+
async function lucilleCreateUser(lucilleUrl) {
|
|
566
|
+
const url = lucilleUrl || getLucilleUrl();
|
|
567
|
+
const keys = await sessionless.generateKeys(() => {}, () => null);
|
|
568
|
+
sessionless.getKeys = () => keys;
|
|
569
|
+
const timestamp = Date.now().toString();
|
|
570
|
+
const message = timestamp + keys.pubKey;
|
|
571
|
+
const signature = await sessionless.sign(message);
|
|
572
|
+
|
|
573
|
+
const resp = await fetch(`${url}/user/create`, {
|
|
574
|
+
method: 'PUT',
|
|
575
|
+
headers: { 'Content-Type': 'application/json' },
|
|
576
|
+
body: JSON.stringify({ timestamp, pubKey: keys.pubKey, signature })
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
const lucilleUser = await resp.json();
|
|
580
|
+
if (lucilleUser.error) throw new Error(`Lucille: ${lucilleUser.error}`);
|
|
581
|
+
return { uuid: lucilleUser.uuid, pubKey: keys.pubKey, privateKey: keys.privateKey };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async function lucilleGetVideos(lucilleUuid, lucilleUrl) {
|
|
585
|
+
const url = lucilleUrl || getLucilleUrl();
|
|
586
|
+
try {
|
|
587
|
+
const resp = await fetch(`${url}/videos/${lucilleUuid}`);
|
|
588
|
+
if (!resp.ok) return {};
|
|
589
|
+
return await resp.json();
|
|
590
|
+
} catch (err) {
|
|
591
|
+
return {};
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
async function lucilleRegisterVideo(tenant, title, description, tags, lucilleUrl) {
|
|
596
|
+
const url = lucilleUrl || getLucilleUrl();
|
|
597
|
+
const { lucilleKeys } = tenant;
|
|
598
|
+
if (!lucilleKeys) throw new Error('Tenant has no Lucille user — re-register to enable video uploads');
|
|
599
|
+
const timestamp = Date.now().toString();
|
|
600
|
+
sessionless.getKeys = () => lucilleKeys;
|
|
601
|
+
const signature = await sessionless.sign(timestamp + lucilleKeys.pubKey);
|
|
602
|
+
|
|
603
|
+
const resp = await fetch(
|
|
604
|
+
`${url}/user/${lucilleKeys.uuid}/video/${encodeURIComponent(title)}`,
|
|
605
|
+
{
|
|
606
|
+
method: 'PUT',
|
|
607
|
+
headers: { 'Content-Type': 'application/json' },
|
|
608
|
+
body: JSON.stringify({ timestamp, signature, description: description || '', tags: tags || [] })
|
|
609
|
+
}
|
|
610
|
+
);
|
|
611
|
+
|
|
612
|
+
const result = await resp.json();
|
|
613
|
+
if (result.error) throw new Error(`Lucille register video failed: ${result.error}`);
|
|
614
|
+
return result;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
async function lucilleUploadVideo(tenant, title, fileBuffer, filename, lucilleUrl) {
|
|
618
|
+
const url = lucilleUrl || getLucilleUrl();
|
|
619
|
+
const { lucilleKeys } = tenant;
|
|
620
|
+
if (!lucilleKeys) throw new Error('Tenant has no Lucille user');
|
|
621
|
+
const timestamp = Date.now().toString();
|
|
622
|
+
sessionless.getKeys = () => lucilleKeys;
|
|
623
|
+
const signature = await sessionless.sign(timestamp + lucilleKeys.pubKey);
|
|
624
|
+
|
|
625
|
+
const form = new FormData();
|
|
626
|
+
form.append('video', fileBuffer, { filename, contentType: getMimeType(filename) });
|
|
627
|
+
|
|
628
|
+
const resp = await fetch(
|
|
629
|
+
`${url}/user/${lucilleKeys.uuid}/video/${encodeURIComponent(title)}/file`,
|
|
630
|
+
{
|
|
631
|
+
method: 'PUT',
|
|
632
|
+
headers: {
|
|
633
|
+
'x-pn-timestamp': timestamp,
|
|
634
|
+
'x-pn-signature': signature,
|
|
635
|
+
...form.getHeaders()
|
|
636
|
+
},
|
|
637
|
+
body: form
|
|
638
|
+
}
|
|
639
|
+
);
|
|
640
|
+
|
|
641
|
+
const result = await resp.json();
|
|
642
|
+
if (result.error) throw new Error(`Lucille video upload failed: ${result.error}`);
|
|
643
|
+
return result;
|
|
644
|
+
}
|
|
645
|
+
|
|
393
646
|
// ============================================================
|
|
394
647
|
// ARCHIVE PROCESSING
|
|
395
648
|
// ============================================================
|
|
@@ -433,7 +686,25 @@ async function processArchive(zipPath) {
|
|
|
433
686
|
throw new Error('emojicode does not match registered tenant');
|
|
434
687
|
}
|
|
435
688
|
|
|
436
|
-
|
|
689
|
+
// Verify owner signature (required for tenants registered after signing support was added).
|
|
690
|
+
validateOwnerSignature(manifest, tenant);
|
|
691
|
+
|
|
692
|
+
// Store manifest-level keywords and per-category redirect URLs in the tenant record.
|
|
693
|
+
const tenantUpdates = {};
|
|
694
|
+
if (Array.isArray(manifest.keywords) && manifest.keywords.length > 0) {
|
|
695
|
+
tenantUpdates.keywords = manifest.keywords.join(', ');
|
|
696
|
+
}
|
|
697
|
+
if (manifest.redirects && typeof manifest.redirects === 'object') {
|
|
698
|
+
tenantUpdates.redirects = manifest.redirects;
|
|
699
|
+
}
|
|
700
|
+
if (Object.keys(tenantUpdates).length > 0) {
|
|
701
|
+
const tenants = loadTenants();
|
|
702
|
+
Object.assign(tenants[tenant.uuid], tenantUpdates);
|
|
703
|
+
saveTenants(tenants);
|
|
704
|
+
Object.assign(tenant, tenantUpdates);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const results = { books: [], music: [], posts: [], albums: [], products: [], videos: [], appointments: [], subscriptions: [], warnings: [] };
|
|
437
708
|
|
|
438
709
|
function readInfo(entryPath) {
|
|
439
710
|
const infoPath = path.join(entryPath, 'info.json');
|
|
@@ -461,7 +732,7 @@ async function processArchive(zipPath) {
|
|
|
461
732
|
const description = info.description || '';
|
|
462
733
|
const price = info.price || 0;
|
|
463
734
|
|
|
464
|
-
await sanoraCreateProduct(tenant, title, 'book', description, price, 0, 'book');
|
|
735
|
+
await sanoraCreateProduct(tenant, title, 'book', description, price, 0, buildTags('book', info.keywords));
|
|
465
736
|
|
|
466
737
|
// Cover image — use info.cover to pin a specific file, else first image found
|
|
467
738
|
const covers = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
|
|
@@ -503,7 +774,7 @@ async function processArchive(zipPath) {
|
|
|
503
774
|
try {
|
|
504
775
|
const description = info.description || `Album: ${albumTitle}`;
|
|
505
776
|
const price = info.price || 0;
|
|
506
|
-
await sanoraCreateProduct(tenant, albumTitle, 'music', description, price, 0, 'music,album');
|
|
777
|
+
await sanoraCreateProduct(tenant, albumTitle, 'music', description, price, 0, buildTags('music,album', info.keywords));
|
|
507
778
|
const coverFile = info.cover ? (covers.find(f => f === info.cover) || covers[0]) : covers[0];
|
|
508
779
|
if (coverFile) {
|
|
509
780
|
const coverBuf = fs.readFileSync(path.join(entryPath, coverFile));
|
|
@@ -532,7 +803,7 @@ async function processArchive(zipPath) {
|
|
|
532
803
|
const buf = fs.readFileSync(entryPath);
|
|
533
804
|
const description = trackInfo.description || `Track: ${title}`;
|
|
534
805
|
const price = trackInfo.price || 0;
|
|
535
|
-
await sanoraCreateProduct(tenant, title, 'music', description, price, 0, 'music,track');
|
|
806
|
+
await sanoraCreateProduct(tenant, title, 'music', description, price, 0, buildTags('music,track', trackInfo.keywords));
|
|
536
807
|
await sanoraUploadArtifact(tenant, title, buf, entry, 'audio');
|
|
537
808
|
results.music.push({ title, type: 'track' });
|
|
538
809
|
console.log(`[shoppe] 🎵 track: ${title}`);
|
|
@@ -572,7 +843,7 @@ async function processArchive(zipPath) {
|
|
|
572
843
|
// Register the series itself as a parent product
|
|
573
844
|
try {
|
|
574
845
|
const description = info.description || `A ${subDirs.length}-part series`;
|
|
575
|
-
await sanoraCreateProduct(tenant, seriesTitle, 'post-series', description, 0, 0, `post,series,order:${order}
|
|
846
|
+
await sanoraCreateProduct(tenant, seriesTitle, 'post-series', description, 0, 0, buildTags(`post,series,order:${order}`, info.keywords));
|
|
576
847
|
|
|
577
848
|
const covers = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
|
|
578
849
|
if (covers.length > 0) {
|
|
@@ -613,7 +884,7 @@ async function processArchive(zipPath) {
|
|
|
613
884
|
const description = partInfo.description || partFm.body.split('\n\n')[0].replace(/^#+\s*/, '').trim() || resolvedTitle;
|
|
614
885
|
|
|
615
886
|
await sanoraCreateProduct(tenant, productTitle, 'post', description, 0, 0,
|
|
616
|
-
`post,blog,series:${seriesTitle},part:${partIndex + 1},order:${order}
|
|
887
|
+
buildTags(`post,blog,series:${seriesTitle},part:${partIndex + 1},order:${order}`, partInfo.keywords));
|
|
617
888
|
|
|
618
889
|
await sanoraUploadArtifact(tenant, productTitle, mdBuf, partMdFiles[0], 'text');
|
|
619
890
|
|
|
@@ -654,7 +925,7 @@ async function processArchive(zipPath) {
|
|
|
654
925
|
const firstLine = fm.body.split('\n').find(l => l.trim()).replace(/^#+\s*/, '');
|
|
655
926
|
const description = info.description || fm.body.split('\n\n')[0].replace(/^#+\s*/, '').trim() || firstLine || title;
|
|
656
927
|
|
|
657
|
-
await sanoraCreateProduct(tenant, title, 'post', description, 0, 0, `post,blog,order:${order}
|
|
928
|
+
await sanoraCreateProduct(tenant, title, 'post', description, 0, 0, buildTags(`post,blog,order:${order}`, info.keywords));
|
|
658
929
|
await sanoraUploadArtifact(tenant, title, mdBuf, mdFiles[0], 'text');
|
|
659
930
|
|
|
660
931
|
const covers = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
|
|
@@ -728,7 +999,7 @@ async function processArchive(zipPath) {
|
|
|
728
999
|
const price = info.price || 0;
|
|
729
1000
|
const shipping = info.shipping || 0;
|
|
730
1001
|
|
|
731
|
-
await sanoraCreateProduct(tenant, title, 'product', description, price, shipping, `product,physical,order:${order}
|
|
1002
|
+
await sanoraCreateProduct(tenant, title, 'product', description, price, shipping, buildTags(`product,physical,order:${order}`, info.keywords));
|
|
732
1003
|
|
|
733
1004
|
// Hero image: prefer hero.jpg / hero.png, fall back to first image
|
|
734
1005
|
const images = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
|
|
@@ -769,7 +1040,7 @@ async function processArchive(zipPath) {
|
|
|
769
1040
|
renewalDays: info.renewalDays || 30
|
|
770
1041
|
};
|
|
771
1042
|
|
|
772
|
-
await sanoraCreateProduct(tenant, title, 'subscription', description, price, 0, 'subscription');
|
|
1043
|
+
await sanoraCreateProduct(tenant, title, 'subscription', description, price, 0, buildTags('subscription', info.keywords));
|
|
773
1044
|
|
|
774
1045
|
// Upload tier metadata (benefits list, renewal period) as a JSON artifact
|
|
775
1046
|
const tierBuf = Buffer.from(JSON.stringify(tierMeta));
|
|
@@ -800,6 +1071,87 @@ async function processArchive(zipPath) {
|
|
|
800
1071
|
}
|
|
801
1072
|
}
|
|
802
1073
|
|
|
1074
|
+
// ---- videos/ ----
|
|
1075
|
+
// Each subfolder is a video. Contains the video file, optional cover/poster image, and info.json.
|
|
1076
|
+
// info.json: { title, description, price, tags[] }
|
|
1077
|
+
// Video is uploaded to Lucille (DO Spaces + WebTorrent seeder); Sanora holds the catalog entry.
|
|
1078
|
+
//
|
|
1079
|
+
// The manifest may specify a lucilleUrl to override the plugin's global config — this lets
|
|
1080
|
+
// different shoppe tenants point to different Lucille instances.
|
|
1081
|
+
//
|
|
1082
|
+
// Deduplication: Lucille stores a SHA-256 contentHash for each uploaded file. Before uploading,
|
|
1083
|
+
// shoppe computes the local file's hash and skips the upload if it matches what Lucille has.
|
|
1084
|
+
const videosDir = path.join(root, 'videos');
|
|
1085
|
+
if (fs.existsSync(videosDir)) {
|
|
1086
|
+
const effectiveLucilleUrl = (manifest.lucilleUrl || '').replace(/\/$/, '') || null;
|
|
1087
|
+
|
|
1088
|
+
const videoFolders = fs.readdirSync(videosDir)
|
|
1089
|
+
.filter(f => fs.statSync(path.join(videosDir, f)).isDirectory())
|
|
1090
|
+
.sort();
|
|
1091
|
+
|
|
1092
|
+
// Fetch existing Lucille videos once for this tenant so we can dedup
|
|
1093
|
+
let existingLucilleVideos = {};
|
|
1094
|
+
if (tenant.lucilleKeys) {
|
|
1095
|
+
existingLucilleVideos = await lucilleGetVideos(tenant.lucilleKeys.uuid, effectiveLucilleUrl);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
for (const entry of videoFolders) {
|
|
1099
|
+
const entryPath = path.join(videosDir, entry);
|
|
1100
|
+
const folderTitle = entry.replace(/^\d+-/, '');
|
|
1101
|
+
try {
|
|
1102
|
+
const info = readInfo(entryPath);
|
|
1103
|
+
const title = info.title || folderTitle;
|
|
1104
|
+
const description = info.description || '';
|
|
1105
|
+
const price = info.price || 0;
|
|
1106
|
+
const tags = info.tags || [];
|
|
1107
|
+
|
|
1108
|
+
// Compute Lucille videoId deterministically (sha256(lucilleUuid + title))
|
|
1109
|
+
// so we can embed it in the Sanora product tags before calling Lucille.
|
|
1110
|
+
const lucilleBase = (effectiveLucilleUrl || getLucilleUrl()).replace(/\/$/, '');
|
|
1111
|
+
const lucilleVideoId = tenant.lucilleKeys
|
|
1112
|
+
? crypto.createHash('sha256').update(tenant.lucilleKeys.uuid + title).digest('hex')
|
|
1113
|
+
: null;
|
|
1114
|
+
const videoTags = buildTags('video', info.keywords) +
|
|
1115
|
+
(lucilleVideoId ? `,lucille-id:${lucilleVideoId},lucille-url:${lucilleBase}` : '');
|
|
1116
|
+
|
|
1117
|
+
// Sanora catalog entry (for discovery / storefront)
|
|
1118
|
+
await sanoraCreateProduct(tenant, title, 'video', description, price, 0, videoTags);
|
|
1119
|
+
|
|
1120
|
+
// Cover / poster image (optional)
|
|
1121
|
+
const images = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
|
|
1122
|
+
const coverFile = images.find(f => /^(cover|poster|hero|thumbnail)\.(jpg|jpeg|png|webp)$/i.test(f)) || images[0];
|
|
1123
|
+
if (coverFile) {
|
|
1124
|
+
const coverBuf = fs.readFileSync(path.join(entryPath, coverFile));
|
|
1125
|
+
await sanoraUploadImage(tenant, title, coverBuf, coverFile);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// Video file → Lucille (with content-hash deduplication)
|
|
1129
|
+
const videoFiles = fs.readdirSync(entryPath).filter(f => VIDEO_EXTS.has(path.extname(f).toLowerCase()));
|
|
1130
|
+
if (videoFiles.length > 0) {
|
|
1131
|
+
const videoFilename = videoFiles[0];
|
|
1132
|
+
const videoBuf = fs.readFileSync(path.join(entryPath, videoFilename));
|
|
1133
|
+
const localHash = crypto.createHash('sha256').update(videoBuf).digest('hex');
|
|
1134
|
+
|
|
1135
|
+
const existing = existingLucilleVideos[title];
|
|
1136
|
+
if (existing && existing.contentHash && existing.contentHash === localHash) {
|
|
1137
|
+
console.log(`[shoppe] ⏩ video unchanged, skipping upload: ${title}`);
|
|
1138
|
+
results.videos.push({ title, price, skipped: true });
|
|
1139
|
+
} else {
|
|
1140
|
+
await lucilleRegisterVideo(tenant, title, description, tags, effectiveLucilleUrl);
|
|
1141
|
+
await lucilleUploadVideo(tenant, title, videoBuf, videoFilename, effectiveLucilleUrl);
|
|
1142
|
+
results.videos.push({ title, price });
|
|
1143
|
+
console.log(`[shoppe] 🎬 video: ${title}`);
|
|
1144
|
+
}
|
|
1145
|
+
} else {
|
|
1146
|
+
results.warnings.push(`video "${title}": no video file found (expected .mp4/.mov/.mkv/.webm/.avi)`);
|
|
1147
|
+
}
|
|
1148
|
+
} catch (err) {
|
|
1149
|
+
console.warn(`[shoppe] ⚠️ video ${entry}: ${err.message}`);
|
|
1150
|
+
results.warnings.push(`video "${entry}": ${err.message}`);
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
803
1155
|
// ---- appointments/ ----
|
|
804
1156
|
// Each subfolder is a bookable appointment type.
|
|
805
1157
|
// info.json: { title, description, price, duration (mins), timezone, availability[], advanceDays }
|
|
@@ -825,7 +1177,7 @@ async function processArchive(zipPath) {
|
|
|
825
1177
|
advanceDays: info.advanceDays || 30
|
|
826
1178
|
};
|
|
827
1179
|
|
|
828
|
-
await sanoraCreateProduct(tenant, title, 'appointment', description, price, 0, 'appointment');
|
|
1180
|
+
await sanoraCreateProduct(tenant, title, 'appointment', description, price, 0, buildTags('appointment', info.keywords));
|
|
829
1181
|
|
|
830
1182
|
// Upload schedule as a JSON artifact so the booking page can retrieve it
|
|
831
1183
|
const scheduleBuf = Buffer.from(JSON.stringify(schedule));
|
|
@@ -864,33 +1216,55 @@ async function processArchive(zipPath) {
|
|
|
864
1216
|
async function getShoppeGoods(tenant) {
|
|
865
1217
|
const resp = await fetch(`${getSanoraUrl()}/products/${tenant.uuid}`);
|
|
866
1218
|
const products = await resp.json();
|
|
1219
|
+
const redirects = tenant.redirects || {};
|
|
1220
|
+
|
|
1221
|
+
const goods = { books: [], music: [], posts: [], albums: [], products: [], videos: [], appointments: [], subscriptions: [] };
|
|
867
1222
|
|
|
868
|
-
const
|
|
1223
|
+
const CATEGORY_BUCKET = { book: 'books', music: 'music', post: 'posts', 'post-series': 'posts', album: 'albums', product: 'products', video: 'videos', appointment: 'appointments', subscription: 'subscriptions' };
|
|
869
1224
|
|
|
870
1225
|
for (const [title, product] of Object.entries(products)) {
|
|
871
1226
|
const isPost = product.category === 'post' || product.category === 'post-series';
|
|
1227
|
+
const bucketName = CATEGORY_BUCKET[product.category];
|
|
1228
|
+
|
|
1229
|
+
// Extract lucille-id and lucille-url from tags for video products
|
|
1230
|
+
let lucillePlayerUrl = null;
|
|
1231
|
+
if (product.category === 'video' && product.tags) {
|
|
1232
|
+
const tagParts = product.tags.split(',');
|
|
1233
|
+
const idTag = tagParts.find(t => t.startsWith('lucille-id:'));
|
|
1234
|
+
const urlTag = tagParts.find(t => t.startsWith('lucille-url:'));
|
|
1235
|
+
if (idTag && urlTag) {
|
|
1236
|
+
const videoId = idTag.slice('lucille-id:'.length);
|
|
1237
|
+
const lucilleBase = urlTag.slice('lucille-url:'.length);
|
|
1238
|
+
lucillePlayerUrl = `${lucilleBase}/watch/${videoId}`;
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
const defaultUrl = isPost
|
|
1243
|
+
? `/plugin/shoppe/${tenant.uuid}/post/${encodeURIComponent(title)}`
|
|
1244
|
+
: product.category === 'book'
|
|
1245
|
+
? `/plugin/shoppe/${tenant.uuid}/buy/${encodeURIComponent(title)}`
|
|
1246
|
+
: product.category === 'subscription'
|
|
1247
|
+
? `/plugin/shoppe/${tenant.uuid}/subscribe/${encodeURIComponent(title)}`
|
|
1248
|
+
: product.category === 'appointment'
|
|
1249
|
+
? `/plugin/shoppe/${tenant.uuid}/book/${encodeURIComponent(title)}`
|
|
1250
|
+
: product.category === 'product' && product.shipping > 0
|
|
1251
|
+
? `/plugin/shoppe/${tenant.uuid}/buy/${encodeURIComponent(title)}/address`
|
|
1252
|
+
: product.category === 'product'
|
|
1253
|
+
? `/plugin/shoppe/${tenant.uuid}/buy/${encodeURIComponent(title)}`
|
|
1254
|
+
: product.category === 'video' && lucillePlayerUrl
|
|
1255
|
+
? lucillePlayerUrl
|
|
1256
|
+
: `${getSanoraUrl()}/products/${tenant.uuid}/${encodeURIComponent(title)}`;
|
|
1257
|
+
|
|
872
1258
|
const item = {
|
|
873
1259
|
title: product.title || title,
|
|
874
1260
|
description: product.description || '',
|
|
875
1261
|
price: product.price || 0,
|
|
876
1262
|
shipping: product.shipping || 0,
|
|
877
1263
|
image: product.image ? `${getSanoraUrl()}/images/${product.image}` : null,
|
|
878
|
-
url:
|
|
879
|
-
|
|
880
|
-
: product.category === 'book'
|
|
881
|
-
? `/plugin/shoppe/${tenant.uuid}/buy/${encodeURIComponent(title)}`
|
|
882
|
-
: product.category === 'subscription'
|
|
883
|
-
? `/plugin/shoppe/${tenant.uuid}/subscribe/${encodeURIComponent(title)}`
|
|
884
|
-
: product.category === 'appointment'
|
|
885
|
-
? `/plugin/shoppe/${tenant.uuid}/book/${encodeURIComponent(title)}`
|
|
886
|
-
: product.category === 'product' && product.shipping > 0
|
|
887
|
-
? `/plugin/shoppe/${tenant.uuid}/buy/${encodeURIComponent(title)}/address`
|
|
888
|
-
: product.category === 'product'
|
|
889
|
-
? `/plugin/shoppe/${tenant.uuid}/buy/${encodeURIComponent(title)}`
|
|
890
|
-
: `${getSanoraUrl()}/products/${tenant.uuid}/${encodeURIComponent(title)}`
|
|
1264
|
+
url: (bucketName && redirects[bucketName]) || defaultUrl,
|
|
1265
|
+
...(lucillePlayerUrl && { lucillePlayerUrl })
|
|
891
1266
|
};
|
|
892
|
-
const
|
|
893
|
-
const bucket = goods[CATEGORY_BUCKET[product.category]];
|
|
1267
|
+
const bucket = goods[bucketName];
|
|
894
1268
|
if (bucket) bucket.push(item);
|
|
895
1269
|
}
|
|
896
1270
|
|
|
@@ -1015,21 +1389,167 @@ async function getSubscriptionStatus(tenant, productId, recoveryKey) {
|
|
|
1015
1389
|
} catch { return { active: false }; }
|
|
1016
1390
|
}
|
|
1017
1391
|
|
|
1018
|
-
const CATEGORY_EMOJI = { book: '📚', music: '🎵', post: '📝', album: '🖼️', product: '📦', appointment: '📅', subscription: '🎁' };
|
|
1392
|
+
const CATEGORY_EMOJI = { book: '📚', music: '🎵', post: '📝', album: '🖼️', product: '📦', appointment: '📅', subscription: '🎁', video: '🎬' };
|
|
1393
|
+
|
|
1394
|
+
// ============================================================
|
|
1395
|
+
// OWNER ORDERS
|
|
1396
|
+
// ============================================================
|
|
1397
|
+
|
|
1398
|
+
// Validate an owner-signed request (used for browser-facing owner routes).
|
|
1399
|
+
// Expects req.query.timestamp and req.query.signature.
|
|
1400
|
+
// Returns an error string if invalid, null if valid.
|
|
1401
|
+
function checkOwnerSignature(req, tenant) {
|
|
1402
|
+
if (!tenant.ownerPubKey) return 'This shoppe was registered before owner signing was added';
|
|
1403
|
+
const { timestamp, signature } = req.query;
|
|
1404
|
+
if (!timestamp || !signature) return 'Missing timestamp or signature — generate a fresh URL with: node shoppe-sign.js orders';
|
|
1405
|
+
const age = Date.now() - parseInt(timestamp, 10);
|
|
1406
|
+
if (isNaN(age) || age < 0 || age > 5 * 60 * 1000) return 'URL has expired — generate a new one with: node shoppe-sign.js orders';
|
|
1407
|
+
const message = timestamp + tenant.uuid;
|
|
1408
|
+
if (!sessionless.verifySignature(signature, message, tenant.ownerPubKey)) return 'Signature invalid';
|
|
1409
|
+
return null;
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
// Fetch all orders for every product belonging to a tenant.
|
|
1413
|
+
// Returns an array of { product, orders } objects.
|
|
1414
|
+
async function getAllOrders(tenant) {
|
|
1415
|
+
const sanoraUrl = getSanoraUrl();
|
|
1416
|
+
const productsResp = await fetch(`${sanoraUrl}/products/${tenant.uuid}`);
|
|
1417
|
+
const products = await productsResp.json();
|
|
1418
|
+
|
|
1419
|
+
sessionless.getKeys = () => tenant.keys;
|
|
1420
|
+
|
|
1421
|
+
const results = [];
|
|
1422
|
+
for (const [title, product] of Object.entries(products)) {
|
|
1423
|
+
const timestamp = Date.now().toString();
|
|
1424
|
+
const signature = await sessionless.sign(timestamp + tenant.uuid);
|
|
1425
|
+
try {
|
|
1426
|
+
const resp = await fetch(
|
|
1427
|
+
`${sanoraUrl}/user/${tenant.uuid}/orders/${encodeURIComponent(product.productId)}` +
|
|
1428
|
+
`?timestamp=${timestamp}&signature=${encodeURIComponent(signature)}`
|
|
1429
|
+
);
|
|
1430
|
+
if (!resp.ok) continue;
|
|
1431
|
+
const data = await resp.json();
|
|
1432
|
+
const orders = data.orders || [];
|
|
1433
|
+
if (orders.length > 0) results.push({ product, orders });
|
|
1434
|
+
} catch { /* skip products with no order data */ }
|
|
1435
|
+
}
|
|
1436
|
+
return results;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
function fmtDate(ts) {
|
|
1440
|
+
return new Date(ts).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
function generateOrdersHTML(tenant, orderData) {
|
|
1444
|
+
const totalOrders = orderData.reduce((n, p) => n + p.orders.length, 0);
|
|
1445
|
+
const totalRevenue = orderData.reduce((n, p) =>
|
|
1446
|
+
n + p.orders.reduce((m, o) => m + (o.amount || p.product.price || 0), 0), 0);
|
|
1447
|
+
|
|
1448
|
+
const sections = orderData.map(({ product, orders }) => {
|
|
1449
|
+
const emoji = CATEGORY_EMOJI[product.category] || '🛍️';
|
|
1450
|
+
const rows = orders.map(o => {
|
|
1451
|
+
const date = fmtDate(o.paidAt || o.createdAt || Date.now());
|
|
1452
|
+
const amount = o.amount != null ? `$${(o.amount / 100).toFixed(2)}` : `$${((product.price || 0) / 100).toFixed(2)}`;
|
|
1453
|
+
const detail = o.slot
|
|
1454
|
+
? `<span class="tag">📅 ${o.slot}</span>`
|
|
1455
|
+
: o.renewalDays
|
|
1456
|
+
? `<span class="tag">🔄 ${o.renewalDays}d renewal</span>`
|
|
1457
|
+
: '';
|
|
1458
|
+
const keyHint = o.orderKey
|
|
1459
|
+
? `<span class="hash" title="sha256(recoveryKey+productId)">${o.orderKey.slice(0, 12)}…</span>`
|
|
1460
|
+
: '—';
|
|
1461
|
+
return `<tr><td>${date}</td><td>${amount}</td><td>${keyHint}</td><td>${detail}</td></tr>`;
|
|
1462
|
+
}).join('');
|
|
1463
|
+
|
|
1464
|
+
return `
|
|
1465
|
+
<div class="product-section">
|
|
1466
|
+
<div class="product-header">
|
|
1467
|
+
<span class="product-emoji">${emoji}</span>
|
|
1468
|
+
<span class="product-title">${escHtml(product.title || 'Untitled')}</span>
|
|
1469
|
+
<span class="order-count">${orders.length} order${orders.length !== 1 ? 's' : ''}</span>
|
|
1470
|
+
</div>
|
|
1471
|
+
<table>
|
|
1472
|
+
<thead><tr><th>Date</th><th>Amount</th><th>Key hash</th><th>Details</th></tr></thead>
|
|
1473
|
+
<tbody>${rows}</tbody>
|
|
1474
|
+
</table>
|
|
1475
|
+
</div>`;
|
|
1476
|
+
}).join('');
|
|
1477
|
+
|
|
1478
|
+
const empty = totalOrders === 0
|
|
1479
|
+
? '<p class="empty">No orders yet. Share your shoppe link to get started!</p>'
|
|
1480
|
+
: '';
|
|
1481
|
+
|
|
1482
|
+
return `<!DOCTYPE html>
|
|
1483
|
+
<html lang="en">
|
|
1484
|
+
<head>
|
|
1485
|
+
<meta charset="UTF-8">
|
|
1486
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1487
|
+
<title>Orders — ${escHtml(tenant.name)}</title>
|
|
1488
|
+
<style>
|
|
1489
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
1490
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f0f12; color: #e0e0e0; min-height: 100vh; }
|
|
1491
|
+
header { background: linear-gradient(135deg, #1a1a2e, #0f3460); padding: 36px 32px 28px; }
|
|
1492
|
+
header h1 { font-size: 26px; font-weight: 700; margin-bottom: 4px; }
|
|
1493
|
+
header p { font-size: 14px; color: #aaa; }
|
|
1494
|
+
.stats { display: flex; gap: 20px; padding: 24px 32px; border-bottom: 1px solid #222; }
|
|
1495
|
+
.stat { background: #18181c; border: 1px solid #333; border-radius: 12px; padding: 16px 24px; }
|
|
1496
|
+
.stat-val { font-size: 28px; font-weight: 800; color: #7ec8e3; }
|
|
1497
|
+
.stat-lbl { font-size: 12px; color: #888; text-transform: uppercase; letter-spacing: 0.5px; margin-top: 2px; }
|
|
1498
|
+
.main { max-width: 900px; margin: 0 auto; padding: 28px 24px 60px; }
|
|
1499
|
+
.product-section { margin-bottom: 32px; }
|
|
1500
|
+
.product-header { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
|
|
1501
|
+
.product-emoji { font-size: 20px; }
|
|
1502
|
+
.product-title { font-size: 17px; font-weight: 600; flex: 1; }
|
|
1503
|
+
.order-count { font-size: 12px; color: #888; background: #222; border-radius: 10px; padding: 3px 10px; }
|
|
1504
|
+
table { width: 100%; border-collapse: collapse; background: #18181c; border: 1px solid #2a2a2e; border-radius: 12px; overflow: hidden; }
|
|
1505
|
+
thead { background: #222; }
|
|
1506
|
+
th { padding: 10px 14px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: #888; text-align: left; }
|
|
1507
|
+
td { padding: 11px 14px; font-size: 13px; border-top: 1px solid #222; }
|
|
1508
|
+
.hash { font-family: monospace; font-size: 12px; color: #7ec8e3; }
|
|
1509
|
+
.tag { background: #2a2a2e; border-radius: 6px; padding: 2px 8px; font-size: 12px; color: #ccc; }
|
|
1510
|
+
.empty { color: #555; font-size: 15px; text-align: center; padding: 60px 0; }
|
|
1511
|
+
.back { display: inline-block; margin-bottom: 20px; color: #7ec8e3; text-decoration: none; font-size: 13px; }
|
|
1512
|
+
.back:hover { text-decoration: underline; }
|
|
1513
|
+
.warning { background: #2a1f0a; border: 1px solid #665; border-radius: 10px; padding: 12px 16px; font-size: 13px; color: #cc9; margin-bottom: 24px; }
|
|
1514
|
+
</style>
|
|
1515
|
+
</head>
|
|
1516
|
+
<body>
|
|
1517
|
+
<header>
|
|
1518
|
+
<h1>${escHtml(tenant.emojicode)} ${escHtml(tenant.name)}</h1>
|
|
1519
|
+
<p>Order history — this URL is valid for 5 minutes</p>
|
|
1520
|
+
</header>
|
|
1521
|
+
<div class="stats">
|
|
1522
|
+
<div class="stat"><div class="stat-val">${totalOrders}</div><div class="stat-lbl">Total orders</div></div>
|
|
1523
|
+
<div class="stat"><div class="stat-val">$${(totalRevenue / 100).toFixed(2)}</div><div class="stat-lbl">Total revenue</div></div>
|
|
1524
|
+
<div class="stat"><div class="stat-val">${orderData.length}</div><div class="stat-lbl">Products sold</div></div>
|
|
1525
|
+
</div>
|
|
1526
|
+
<div class="main">
|
|
1527
|
+
<a class="back" href="/plugin/shoppe/${tenant.uuid}">← Back to shoppe</a>
|
|
1528
|
+
<div class="warning">🔑 Key hashes are shown (not recovery keys — those never reach this server). Revenue totals are approximate when order amounts aren't stored.</div>
|
|
1529
|
+
${empty}
|
|
1530
|
+
${sections}
|
|
1531
|
+
</div>
|
|
1532
|
+
</body>
|
|
1533
|
+
</html>`;
|
|
1534
|
+
}
|
|
1019
1535
|
|
|
1020
1536
|
function renderCards(items, category) {
|
|
1021
1537
|
if (items.length === 0) {
|
|
1022
1538
|
return '<p class="empty">Nothing here yet.</p>';
|
|
1023
1539
|
}
|
|
1024
1540
|
return items.map(item => {
|
|
1541
|
+
const isVideo = !!item.lucillePlayerUrl;
|
|
1025
1542
|
const imgHtml = item.image
|
|
1026
|
-
? `<div class="card-img"><img src="${item.image}" alt="" loading="lazy"></div>`
|
|
1543
|
+
? `<div class="card-img${isVideo ? ' card-video-play' : ''}"><img src="${item.image}" alt="" loading="lazy"></div>`
|
|
1027
1544
|
: `<div class="card-img-placeholder">${CATEGORY_EMOJI[category] || '🎁'}</div>`;
|
|
1028
1545
|
const priceHtml = (item.price > 0 || category === 'product')
|
|
1029
1546
|
? `<div class="price">$${(item.price / 100).toFixed(2)}${item.shipping ? ` <span class="shipping">+ $${(item.shipping / 100).toFixed(2)} shipping</span>` : ''}</div>`
|
|
1030
1547
|
: '';
|
|
1548
|
+
const clickHandler = isVideo
|
|
1549
|
+
? `playVideo('${item.lucillePlayerUrl}')`
|
|
1550
|
+
: `window.open('${item.url}','_blank')`;
|
|
1031
1551
|
return `
|
|
1032
|
-
<div class="card" onclick="
|
|
1552
|
+
<div class="card" onclick="${clickHandler}">
|
|
1033
1553
|
${imgHtml}
|
|
1034
1554
|
<div class="card-body">
|
|
1035
1555
|
<div class="card-title">${item.title}</div>
|
|
@@ -1049,6 +1569,7 @@ function generateShoppeHTML(tenant, goods) {
|
|
|
1049
1569
|
{ id: 'posts', label: '📝 Posts', count: goods.posts.length },
|
|
1050
1570
|
{ id: 'albums', label: '🖼️ Albums', count: goods.albums.length },
|
|
1051
1571
|
{ id: 'products', label: '📦 Products', count: goods.products.length },
|
|
1572
|
+
{ id: 'videos', label: '🎬 Videos', count: goods.videos.length },
|
|
1052
1573
|
{ id: 'appointments', label: '📅 Appointments', count: goods.appointments.length },
|
|
1053
1574
|
{ id: 'subscriptions', label: '🎁 Infuse', count: goods.subscriptions.length }
|
|
1054
1575
|
]
|
|
@@ -1056,7 +1577,7 @@ function generateShoppeHTML(tenant, goods) {
|
|
|
1056
1577
|
.map((t, i) => `<div class="tab${i === 0 ? ' active' : ''}" onclick="show('${t.id}',this)">${t.label} <span class="badge">${t.count}</span></div>`)
|
|
1057
1578
|
.join('');
|
|
1058
1579
|
|
|
1059
|
-
const allItems = [...goods.books, ...goods.music, ...goods.posts, ...goods.albums, ...goods.products, ...goods.appointments, ...goods.subscriptions];
|
|
1580
|
+
const allItems = [...goods.books, ...goods.music, ...goods.posts, ...goods.albums, ...goods.products, ...goods.videos, ...goods.appointments, ...goods.subscriptions];
|
|
1060
1581
|
|
|
1061
1582
|
return `<!DOCTYPE html>
|
|
1062
1583
|
<html lang="en">
|
|
@@ -1064,6 +1585,7 @@ function generateShoppeHTML(tenant, goods) {
|
|
|
1064
1585
|
<meta charset="UTF-8">
|
|
1065
1586
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1066
1587
|
<title>${tenant.name}</title>
|
|
1588
|
+
${tenant.keywords ? `<meta name="keywords" content="${escHtml(tenant.keywords)}">` : ''}
|
|
1067
1589
|
<style>
|
|
1068
1590
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
1069
1591
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f7; color: #1d1d1f; }
|
|
@@ -1090,6 +1612,16 @@ function generateShoppeHTML(tenant, goods) {
|
|
|
1090
1612
|
.price { font-size: 15px; font-weight: 700; color: #0066cc; }
|
|
1091
1613
|
.shipping { font-size: 12px; font-weight: 400; color: #888; }
|
|
1092
1614
|
.empty { color: #999; text-align: center; padding: 60px 0; font-size: 15px; }
|
|
1615
|
+
.card-video-play { position: relative; }
|
|
1616
|
+
.card-video-play::after { content: '▶'; position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 36px; color: rgba(255,255,255,0.9); background: rgba(0,0,0,0.35); opacity: 0; transition: opacity 0.2s; pointer-events: none; }
|
|
1617
|
+
.card:hover .card-video-play::after { opacity: 1; }
|
|
1618
|
+
.video-modal { display: none; position: fixed; inset: 0; z-index: 1000; align-items: center; justify-content: center; }
|
|
1619
|
+
.video-modal.open { display: flex; }
|
|
1620
|
+
.video-modal-backdrop { position: absolute; inset: 0; background: rgba(0,0,0,0.85); }
|
|
1621
|
+
.video-modal-content { position: relative; z-index: 1; width: 90vw; max-width: 960px; aspect-ratio: 16/9; background: #000; border-radius: 10px; overflow: hidden; box-shadow: 0 24px 80px rgba(0,0,0,0.6); }
|
|
1622
|
+
.video-modal-content iframe { width: 100%; height: 100%; border: none; display: block; }
|
|
1623
|
+
.video-modal-close { position: absolute; top: 10px; right: 12px; z-index: 2; background: rgba(0,0,0,0.5); border: none; color: #fff; font-size: 20px; line-height: 1; padding: 4px 10px; border-radius: 6px; cursor: pointer; }
|
|
1624
|
+
.video-modal-close:hover { background: rgba(0,0,0,0.8); }
|
|
1093
1625
|
</style>
|
|
1094
1626
|
</head>
|
|
1095
1627
|
<body>
|
|
@@ -1106,12 +1638,20 @@ function generateShoppeHTML(tenant, goods) {
|
|
|
1106
1638
|
<div id="posts" class="section"><div class="grid">${renderCards(goods.posts, 'post')}</div></div>
|
|
1107
1639
|
<div id="albums" class="section"><div class="grid">${renderCards(goods.albums, 'album')}</div></div>
|
|
1108
1640
|
<div id="products" class="section"><div class="grid">${renderCards(goods.products, 'product')}</div></div>
|
|
1641
|
+
<div id="videos" class="section"><div class="grid">${renderCards(goods.videos, 'video')}</div></div>
|
|
1109
1642
|
<div id="appointments" class="section"><div class="grid">${renderCards(goods.appointments, 'appointment')}</div></div>
|
|
1110
1643
|
<div id="subscriptions" class="section"><div class="grid">${renderCards(goods.subscriptions, 'subscription')}</div></div>
|
|
1111
1644
|
<div style="text-align:center;padding:24px 0 8px;font-size:14px;color:#888;">
|
|
1112
1645
|
Already infusing? <a href="/plugin/shoppe/${tenant.uuid}/membership" style="color:#0066cc;">Access your membership →</a>
|
|
1113
1646
|
</div>
|
|
1114
1647
|
</main>
|
|
1648
|
+
<div id="video-modal" class="video-modal">
|
|
1649
|
+
<div class="video-modal-backdrop" onclick="closeVideo()"></div>
|
|
1650
|
+
<div class="video-modal-content">
|
|
1651
|
+
<button class="video-modal-close" onclick="closeVideo()">✕</button>
|
|
1652
|
+
<iframe id="video-iframe" src="" allowfullscreen allow="autoplay"></iframe>
|
|
1653
|
+
</div>
|
|
1654
|
+
</div>
|
|
1115
1655
|
<script>
|
|
1116
1656
|
function show(id, tab) {
|
|
1117
1657
|
document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
|
|
@@ -1119,6 +1659,15 @@ function generateShoppeHTML(tenant, goods) {
|
|
|
1119
1659
|
document.getElementById(id).classList.add('active');
|
|
1120
1660
|
tab.classList.add('active');
|
|
1121
1661
|
}
|
|
1662
|
+
function playVideo(url) {
|
|
1663
|
+
document.getElementById('video-iframe').src = url;
|
|
1664
|
+
document.getElementById('video-modal').classList.add('open');
|
|
1665
|
+
}
|
|
1666
|
+
function closeVideo() {
|
|
1667
|
+
document.getElementById('video-modal').classList.remove('open');
|
|
1668
|
+
document.getElementById('video-iframe').src = '';
|
|
1669
|
+
}
|
|
1670
|
+
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeVideo(); });
|
|
1122
1671
|
</script>
|
|
1123
1672
|
</body>
|
|
1124
1673
|
</html>`;
|
|
@@ -1193,14 +1742,54 @@ async function startServer(params) {
|
|
|
1193
1742
|
// Register a new tenant (owner only)
|
|
1194
1743
|
app.post('/plugin/shoppe/register', owner, async (req, res) => {
|
|
1195
1744
|
try {
|
|
1196
|
-
const
|
|
1197
|
-
|
|
1745
|
+
const { uuid, emojicode, name, ownerPrivateKey, ownerPubKey } = await registerTenant(req.body.name);
|
|
1746
|
+
|
|
1747
|
+
// Generate a single-use, short-lived token for the starter bundle download.
|
|
1748
|
+
const wikiOrigin = `${reqProto(req)}://${req.get('host')}`;
|
|
1749
|
+
const token = crypto.randomBytes(24).toString('hex');
|
|
1750
|
+
bundleTokens.set(token, { uuid, ownerPrivateKey, ownerPubKey, wikiOrigin, expiresAt: Date.now() + 15 * 60 * 1000 });
|
|
1751
|
+
|
|
1752
|
+
// Expire tokens automatically after 15 minutes.
|
|
1753
|
+
setTimeout(() => bundleTokens.delete(token), 15 * 60 * 1000);
|
|
1754
|
+
|
|
1755
|
+
res.json({ success: true, tenant: { uuid, emojicode, name }, bundleToken: token });
|
|
1198
1756
|
} catch (err) {
|
|
1199
1757
|
console.error('[shoppe] register error:', err);
|
|
1200
1758
|
res.status(500).json({ success: false, error: err.message });
|
|
1201
1759
|
}
|
|
1202
1760
|
});
|
|
1203
1761
|
|
|
1762
|
+
// Starter bundle download — single-use token acts as the credential.
|
|
1763
|
+
// The zip contains manifest.json, shoppe-key.json (private key), shoppe-sign.js, and empty content folders.
|
|
1764
|
+
app.get('/plugin/shoppe/bundle/:token', (req, res) => {
|
|
1765
|
+
const entry = bundleTokens.get(req.params.token);
|
|
1766
|
+
if (!entry) {
|
|
1767
|
+
return res.status(404).send('<h1>Bundle link expired or invalid</h1><p>Re-register to get a new link.</p>');
|
|
1768
|
+
}
|
|
1769
|
+
if (Date.now() > entry.expiresAt) {
|
|
1770
|
+
bundleTokens.delete(req.params.token);
|
|
1771
|
+
return res.status(410).send('<h1>Bundle link expired</h1><p>Re-register to get a new link.</p>');
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
// Invalidate immediately — single use
|
|
1775
|
+
bundleTokens.delete(req.params.token);
|
|
1776
|
+
|
|
1777
|
+
const tenant = getTenantByIdentifier(entry.uuid);
|
|
1778
|
+
if (!tenant) return res.status(404).send('<h1>Tenant not found</h1>');
|
|
1779
|
+
|
|
1780
|
+
try {
|
|
1781
|
+
const buf = generateBundleBuffer(tenant, entry.ownerPrivateKey, entry.ownerPubKey, entry.wikiOrigin);
|
|
1782
|
+
const filename = `${tenant.name.replace(/[^a-z0-9-]/gi, '-').toLowerCase()}-shoppe-starter.zip`;
|
|
1783
|
+
res.set('Content-Type', 'application/zip');
|
|
1784
|
+
res.set('Content-Disposition', `attachment; filename="${filename}"`);
|
|
1785
|
+
res.send(buf);
|
|
1786
|
+
console.log(`[shoppe] Starter bundle downloaded for "${tenant.name}" (${tenant.uuid})`);
|
|
1787
|
+
} catch (err) {
|
|
1788
|
+
console.error('[shoppe] bundle error:', err);
|
|
1789
|
+
res.status(500).send('<h1>Error generating bundle</h1><p>' + err.message + '</p>');
|
|
1790
|
+
}
|
|
1791
|
+
});
|
|
1792
|
+
|
|
1204
1793
|
// List all tenants (owner only — includes uuid for management)
|
|
1205
1794
|
app.get('/plugin/shoppe/tenants', owner, (req, res) => {
|
|
1206
1795
|
const tenants = loadTenants();
|
|
@@ -1243,16 +1832,17 @@ async function startServer(params) {
|
|
|
1243
1832
|
// Get config (owner only)
|
|
1244
1833
|
app.get('/plugin/shoppe/config', owner, (req, res) => {
|
|
1245
1834
|
const config = loadConfig();
|
|
1246
|
-
res.json({ success: true, sanoraUrl: config.sanoraUrl || '' });
|
|
1835
|
+
res.json({ success: true, sanoraUrl: config.sanoraUrl || '', lucilleUrl: config.lucilleUrl || '' });
|
|
1247
1836
|
});
|
|
1248
1837
|
|
|
1249
1838
|
// Save config (owner only)
|
|
1250
1839
|
app.post('/plugin/shoppe/config', owner, (req, res) => {
|
|
1251
|
-
const { sanoraUrl, addieUrl } = req.body;
|
|
1840
|
+
const { sanoraUrl, addieUrl, lucilleUrl } = req.body;
|
|
1252
1841
|
if (!sanoraUrl) return res.status(400).json({ success: false, error: 'sanoraUrl required' });
|
|
1253
1842
|
const config = loadConfig();
|
|
1254
1843
|
config.sanoraUrl = sanoraUrl;
|
|
1255
1844
|
if (addieUrl) config.addieUrl = addieUrl;
|
|
1845
|
+
if (lucilleUrl) config.lucilleUrl = lucilleUrl;
|
|
1256
1846
|
saveConfig(config);
|
|
1257
1847
|
console.log('[shoppe] Sanora URL set to:', sanoraUrl);
|
|
1258
1848
|
res.json({ success: true });
|
|
@@ -1294,7 +1884,8 @@ async function startServer(params) {
|
|
|
1294
1884
|
ebookUrl,
|
|
1295
1885
|
shoppeUrl,
|
|
1296
1886
|
payees,
|
|
1297
|
-
tenantUuid: tenant.uuid
|
|
1887
|
+
tenantUuid: tenant.uuid,
|
|
1888
|
+
keywords: extractKeywords(product)
|
|
1298
1889
|
});
|
|
1299
1890
|
|
|
1300
1891
|
res.set('Content-Type', 'text/html');
|
|
@@ -1343,7 +1934,8 @@ async function startServer(params) {
|
|
|
1343
1934
|
duration: String(schedule ? schedule.duration : 60),
|
|
1344
1935
|
proceedLabel: price === 0 ? 'Confirm Booking →' : 'Continue to Payment →',
|
|
1345
1936
|
shoppeUrl,
|
|
1346
|
-
tenantUuid: tenant.uuid
|
|
1937
|
+
tenantUuid: tenant.uuid,
|
|
1938
|
+
keywords: extractKeywords(product)
|
|
1347
1939
|
});
|
|
1348
1940
|
|
|
1349
1941
|
res.set('Content-Type', 'text/html');
|
|
@@ -1411,7 +2003,8 @@ async function startServer(params) {
|
|
|
1411
2003
|
benefits,
|
|
1412
2004
|
renewalDays: String(tierInfo ? (tierInfo.renewalDays || 30) : 30),
|
|
1413
2005
|
shoppeUrl,
|
|
1414
|
-
tenantUuid: tenant.uuid
|
|
2006
|
+
tenantUuid: tenant.uuid,
|
|
2007
|
+
keywords: extractKeywords(product)
|
|
1415
2008
|
});
|
|
1416
2009
|
|
|
1417
2010
|
res.set('Content-Type', 'text/html');
|
|
@@ -1422,6 +2015,114 @@ async function startServer(params) {
|
|
|
1422
2015
|
}
|
|
1423
2016
|
});
|
|
1424
2017
|
|
|
2018
|
+
// Owner orders page — authenticated via signed URL from shoppe-sign.js
|
|
2019
|
+
app.get('/plugin/shoppe/:uuid/orders', async (req, res) => {
|
|
2020
|
+
try {
|
|
2021
|
+
const tenant = getTenantByIdentifier(req.params.uuid);
|
|
2022
|
+
if (!tenant) return res.status(404).send('<h1>Shoppe not found</h1>');
|
|
2023
|
+
|
|
2024
|
+
const err = checkOwnerSignature(req, tenant);
|
|
2025
|
+
if (err) {
|
|
2026
|
+
return res.status(403).send(
|
|
2027
|
+
`<!DOCTYPE html><html><body style="font-family:sans-serif;padding:40px;background:#0f0f12;color:#e0e0e0">` +
|
|
2028
|
+
`<h2>Access denied</h2><p style="color:#f66;margin-top:12px">${escHtml(err)}</p></body></html>`
|
|
2029
|
+
);
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
const orderData = await getAllOrders(tenant);
|
|
2033
|
+
res.set('Content-Type', 'text/html');
|
|
2034
|
+
res.send(generateOrdersHTML(tenant, orderData));
|
|
2035
|
+
} catch (err) {
|
|
2036
|
+
console.error('[shoppe] orders page error:', err);
|
|
2037
|
+
res.status(500).send(`<h1>Error</h1><p>${err.message}</p>`);
|
|
2038
|
+
}
|
|
2039
|
+
});
|
|
2040
|
+
|
|
2041
|
+
// Owner payouts setup — validates owner sig, redirects to Stripe Connect Express onboarding
|
|
2042
|
+
app.get('/plugin/shoppe/:uuid/payouts', async (req, res) => {
|
|
2043
|
+
try {
|
|
2044
|
+
const tenant = getTenantByIdentifier(req.params.uuid);
|
|
2045
|
+
if (!tenant) return res.status(404).send('<h1>Shoppe not found</h1>');
|
|
2046
|
+
|
|
2047
|
+
const err = checkOwnerSignature(req, tenant);
|
|
2048
|
+
if (err) {
|
|
2049
|
+
return res.status(403).send(
|
|
2050
|
+
`<!DOCTYPE html><html><body style="font-family:sans-serif;padding:40px;background:#0f0f12;color:#e0e0e0">` +
|
|
2051
|
+
`<h2>Access denied</h2><p style="color:#f66;margin-top:12px">${escHtml(err)}</p></body></html>`
|
|
2052
|
+
);
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
if (!tenant.addieKeys) {
|
|
2056
|
+
return res.status(500).send(
|
|
2057
|
+
`<!DOCTYPE html><html><body style="font-family:sans-serif;padding:40px;background:#0f0f12;color:#e0e0e0">` +
|
|
2058
|
+
`<h2>Payment account not configured</h2><p>This shoppe has no Addie user. Re-register to get one.</p></body></html>`
|
|
2059
|
+
);
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
const addieKeys = { pubKey: tenant.addieKeys.pubKey, privateKey: tenant.addieKeys.privateKey };
|
|
2063
|
+
sessionless.getKeys = () => addieKeys;
|
|
2064
|
+
const timestamp = Date.now().toString();
|
|
2065
|
+
const message = timestamp + tenant.addieKeys.uuid;
|
|
2066
|
+
const signature = await sessionless.sign(message);
|
|
2067
|
+
|
|
2068
|
+
const wikiOrigin = `${reqProto(req)}://${req.get('host')}`;
|
|
2069
|
+
const returnUrl = `${wikiOrigin}/plugin/shoppe/${tenant.uuid}/payouts/return`;
|
|
2070
|
+
|
|
2071
|
+
const resp = await fetch(`${getAddieUrl()}/user/${tenant.addieKeys.uuid}/processor/stripe/express`, {
|
|
2072
|
+
method: 'PUT',
|
|
2073
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2074
|
+
body: JSON.stringify({ timestamp, pubKey: tenant.addieKeys.pubKey, signature, returnUrl })
|
|
2075
|
+
});
|
|
2076
|
+
const json = await resp.json();
|
|
2077
|
+
|
|
2078
|
+
if (json.error) {
|
|
2079
|
+
return res.status(500).send(
|
|
2080
|
+
`<!DOCTYPE html><html><body style="font-family:sans-serif;padding:40px;background:#0f0f12;color:#e0e0e0">` +
|
|
2081
|
+
`<h2>Error setting up payouts</h2><p style="color:#f66;margin-top:12px">${escHtml(json.error)}</p></body></html>`
|
|
2082
|
+
);
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
res.redirect(json.onboardingUrl);
|
|
2086
|
+
} catch (err) {
|
|
2087
|
+
console.error('[shoppe] payouts error:', err);
|
|
2088
|
+
res.status(500).send(`<h1>Error</h1><p>${err.message}</p>`);
|
|
2089
|
+
}
|
|
2090
|
+
});
|
|
2091
|
+
|
|
2092
|
+
// Stripe Connect Express return page — no auth, Stripe redirects here after onboarding
|
|
2093
|
+
app.get('/plugin/shoppe/:uuid/payouts/return', (req, res) => {
|
|
2094
|
+
const tenant = getTenantByIdentifier(req.params.uuid);
|
|
2095
|
+
const name = tenant ? escHtml(tenant.name) : 'your shoppe';
|
|
2096
|
+
const shoppeUrl = tenant ? `/plugin/shoppe/${tenant.uuid}` : '/';
|
|
2097
|
+
res.set('Content-Type', 'text/html');
|
|
2098
|
+
res.send(`<!DOCTYPE html>
|
|
2099
|
+
<html lang="en">
|
|
2100
|
+
<head>
|
|
2101
|
+
<meta charset="UTF-8">
|
|
2102
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
2103
|
+
<title>Payouts connected — ${name}</title>
|
|
2104
|
+
<style>
|
|
2105
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
2106
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f0f12; color: #e0e0e0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
|
2107
|
+
.card { background: #18181c; border: 1px solid #333; border-radius: 16px; padding: 48px 40px; max-width: 480px; text-align: center; }
|
|
2108
|
+
h1 { font-size: 28px; font-weight: 700; margin-bottom: 12px; }
|
|
2109
|
+
p { color: #aaa; font-size: 15px; line-height: 1.6; margin-top: 10px; }
|
|
2110
|
+
a { display: inline-block; margin-top: 28px; color: #7ec8e3; text-decoration: none; font-size: 14px; }
|
|
2111
|
+
a:hover { text-decoration: underline; }
|
|
2112
|
+
</style>
|
|
2113
|
+
</head>
|
|
2114
|
+
<body>
|
|
2115
|
+
<div class="card">
|
|
2116
|
+
<div style="font-size:52px;margin-bottom:20px">✅</div>
|
|
2117
|
+
<h1>Payouts connected!</h1>
|
|
2118
|
+
<p>Your Stripe account is now linked to <strong>${name}</strong>.</p>
|
|
2119
|
+
<p>Payments will be transferred to your account automatically after each sale.</p>
|
|
2120
|
+
<a href="${escHtml(shoppeUrl)}">← Back to shoppe</a>
|
|
2121
|
+
</div>
|
|
2122
|
+
</body>
|
|
2123
|
+
</html>`);
|
|
2124
|
+
});
|
|
2125
|
+
|
|
1425
2126
|
// Membership portal page
|
|
1426
2127
|
app.get('/plugin/shoppe/:identifier/membership', (req, res) => {
|
|
1427
2128
|
const tenant = getTenantByIdentifier(req.params.identifier);
|
|
@@ -1495,7 +2196,7 @@ async function startServer(params) {
|
|
|
1495
2196
|
const tenant = getTenantByIdentifier(req.params.identifier);
|
|
1496
2197
|
if (!tenant) return res.status(404).json({ error: 'Shoppe not found' });
|
|
1497
2198
|
|
|
1498
|
-
const { recoveryKey, productId, title, slotDatetime } = req.body;
|
|
2199
|
+
const { recoveryKey, productId, title, slotDatetime, payees: clientPayees } = req.body;
|
|
1499
2200
|
if (!productId) return res.status(400).json({ error: 'productId required' });
|
|
1500
2201
|
if (!recoveryKey && !title) return res.status(400).json({ error: 'recoveryKey or title required' });
|
|
1501
2202
|
|
|
@@ -1546,7 +2247,19 @@ async function startServer(params) {
|
|
|
1546
2247
|
}
|
|
1547
2248
|
|
|
1548
2249
|
// Sign and create Stripe intent via Addie
|
|
1549
|
-
|
|
2250
|
+
// Client may supply payees parsed from ?payees= URL param (pipe-separated 4-tuples).
|
|
2251
|
+
// Each payee is capped at 5% of the product price; any that exceed this are dropped.
|
|
2252
|
+
const maxPayeeAmount = amount * 0.05;
|
|
2253
|
+
const validatedPayees = Array.isArray(clientPayees)
|
|
2254
|
+
? clientPayees.filter(p => {
|
|
2255
|
+
if (p.percent != null && p.percent > 5) return false;
|
|
2256
|
+
if (p.amount != null && p.amount > maxPayeeAmount) return false;
|
|
2257
|
+
return true;
|
|
2258
|
+
})
|
|
2259
|
+
: [];
|
|
2260
|
+
const payees = validatedPayees.length > 0
|
|
2261
|
+
? validatedPayees
|
|
2262
|
+
: tenant.addieKeys ? [{ pubKey: tenant.addieKeys.pubKey, amount }] : [];
|
|
1550
2263
|
const buyerKeys = { pubKey: buyer.pubKey, privateKey: buyer.privateKey };
|
|
1551
2264
|
sessionless.getKeys = () => buyerKeys;
|
|
1552
2265
|
const intentTimestamp = Date.now().toString();
|
|
@@ -1579,9 +2292,18 @@ async function startServer(params) {
|
|
|
1579
2292
|
const tenant = getTenantByIdentifier(req.params.identifier);
|
|
1580
2293
|
if (!tenant) return res.status(404).json({ error: 'Shoppe not found' });
|
|
1581
2294
|
|
|
1582
|
-
const { recoveryKey, productId, orderRef, address, title, amount, slotDatetime, contactInfo, type, renewalDays } = req.body;
|
|
2295
|
+
const { recoveryKey, productId, orderRef, address, title, amount, slotDatetime, contactInfo, type, renewalDays, paymentIntentId } = req.body;
|
|
1583
2296
|
const sanoraUrlInternal = getSanoraUrl();
|
|
1584
2297
|
|
|
2298
|
+
// Fire transfer after successful payment — fire-and-forget, does not affect response
|
|
2299
|
+
function triggerTransfer() {
|
|
2300
|
+
if (!paymentIntentId || !tenant.addieKeys) return;
|
|
2301
|
+
fetch(`${getAddieUrl()}/payment/${encodeURIComponent(paymentIntentId)}/process-transfers`, {
|
|
2302
|
+
method: 'POST',
|
|
2303
|
+
headers: { 'Content-Type': 'application/json' }
|
|
2304
|
+
}).catch(err => console.warn('[shoppe] transfer trigger failed:', err.message));
|
|
2305
|
+
}
|
|
2306
|
+
|
|
1585
2307
|
if (recoveryKey && type === 'subscription') {
|
|
1586
2308
|
// Subscription payment — record an order with a hashed subscriber key + payment timestamp.
|
|
1587
2309
|
// The recovery key itself is never stored; orderKey = sha256(recoveryKey + productId).
|
|
@@ -1596,6 +2318,7 @@ async function startServer(params) {
|
|
|
1596
2318
|
headers: { 'Content-Type': 'application/json' },
|
|
1597
2319
|
body: JSON.stringify({ timestamp: ts, signature: sig, order })
|
|
1598
2320
|
});
|
|
2321
|
+
triggerTransfer();
|
|
1599
2322
|
return res.json({ success: true });
|
|
1600
2323
|
}
|
|
1601
2324
|
|
|
@@ -1622,6 +2345,7 @@ async function startServer(params) {
|
|
|
1622
2345
|
headers: { 'Content-Type': 'application/json' },
|
|
1623
2346
|
body: JSON.stringify({ timestamp: bookingTimestamp, signature: bookingSignature, order })
|
|
1624
2347
|
});
|
|
2348
|
+
triggerTransfer();
|
|
1625
2349
|
return res.json({ success: true });
|
|
1626
2350
|
}
|
|
1627
2351
|
|
|
@@ -1630,6 +2354,7 @@ async function startServer(params) {
|
|
|
1630
2354
|
const recoveryHash = recoveryKey + productId;
|
|
1631
2355
|
const createResp = await fetch(`${sanoraUrlInternal}/user/create-hash/${encodeURIComponent(recoveryHash)}/product/${encodeURIComponent(productId)}`);
|
|
1632
2356
|
const createJson = await createResp.json();
|
|
2357
|
+
triggerTransfer();
|
|
1633
2358
|
return res.json({ success: createJson.success });
|
|
1634
2359
|
}
|
|
1635
2360
|
|
|
@@ -1661,6 +2386,7 @@ async function startServer(params) {
|
|
|
1661
2386
|
headers: { 'Content-Type': 'application/json' },
|
|
1662
2387
|
body: JSON.stringify({ timestamp: orderTimestamp, signature: orderSignature, order })
|
|
1663
2388
|
});
|
|
2389
|
+
triggerTransfer();
|
|
1664
2390
|
return res.json({ success: true });
|
|
1665
2391
|
}
|
|
1666
2392
|
|