wiki-plugin-shoppe 0.0.32 → 0.0.34
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/client/shoppe.js +66 -10
- package/package.json +1 -1
- package/server/server.js +1061 -47
package/server/server.js
CHANGED
|
@@ -448,8 +448,13 @@ async function registerTenant(name) {
|
|
|
448
448
|
|
|
449
449
|
function getTenantByIdentifier(identifier) {
|
|
450
450
|
const tenants = loadTenants();
|
|
451
|
-
|
|
452
|
-
|
|
451
|
+
const entry = tenants[identifier];
|
|
452
|
+
if (entry) {
|
|
453
|
+
// String value = alias left behind after a UUID change (Redis reset); follow it.
|
|
454
|
+
if (typeof entry === 'string') return tenants[entry] || null;
|
|
455
|
+
return entry;
|
|
456
|
+
}
|
|
457
|
+
return Object.values(tenants).find(t => typeof t === 'object' && t.emojicode === identifier) || null;
|
|
453
458
|
}
|
|
454
459
|
|
|
455
460
|
// ============================================================
|
|
@@ -506,15 +511,29 @@ async function sanoraEnsureUser(tenant) {
|
|
|
506
511
|
console.log(`[shoppe] Sanora UUID changed ${tenant.uuid} → ${sanoraUser.uuid} (Redis was reset). Updating tenants.json.`);
|
|
507
512
|
const tenants = loadTenants();
|
|
508
513
|
const oldUuid = tenant.uuid;
|
|
509
|
-
delete tenants[oldUuid];
|
|
510
514
|
tenant.uuid = sanoraUser.uuid;
|
|
511
515
|
tenants[sanoraUser.uuid] = tenant;
|
|
516
|
+
// Keep old UUID as a forwarding alias so existing manifest.json / shared URLs still resolve.
|
|
517
|
+
tenants[oldUuid] = sanoraUser.uuid;
|
|
512
518
|
saveTenants(tenants);
|
|
513
519
|
}
|
|
514
520
|
|
|
515
521
|
return tenant; // tenant.uuid is now correct
|
|
516
522
|
}
|
|
517
523
|
|
|
524
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
525
|
+
|
|
526
|
+
// fetch() wrapper that retries on 429 with exponential backoff (1s, 2s, 4s).
|
|
527
|
+
async function fetchWithRetry(url, options, maxRetries = 3) {
|
|
528
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
529
|
+
const resp = await fetch(url, options);
|
|
530
|
+
if (resp.status !== 429 || attempt === maxRetries) return resp;
|
|
531
|
+
const delay = 1000 * Math.pow(2, attempt);
|
|
532
|
+
console.warn(`[shoppe] 429 rate limited on ${new URL(url).pathname}, retrying in ${delay}ms…`);
|
|
533
|
+
await sleep(delay);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
518
537
|
async function sanoraCreateProduct(tenant, title, category, description, price, shipping, tags) {
|
|
519
538
|
const { uuid, keys } = tenant;
|
|
520
539
|
const timestamp = Date.now().toString();
|
|
@@ -524,7 +543,7 @@ async function sanoraCreateProduct(tenant, title, category, description, price,
|
|
|
524
543
|
sessionless.getKeys = () => keys;
|
|
525
544
|
const signature = await sessionless.sign(message);
|
|
526
545
|
|
|
527
|
-
const resp = await
|
|
546
|
+
const resp = await fetchWithRetry(
|
|
528
547
|
`${getSanoraUrl()}/user/${uuid}/product/${encodeURIComponent(title)}`,
|
|
529
548
|
{
|
|
530
549
|
method: 'PUT',
|
|
@@ -579,7 +598,7 @@ async function sanoraUploadArtifact(tenant, title, fileBuffer, filename, artifac
|
|
|
579
598
|
const form = new FormData();
|
|
580
599
|
form.append('artifact', fileBuffer, { filename, contentType: getMimeType(filename) });
|
|
581
600
|
|
|
582
|
-
const resp = await
|
|
601
|
+
const resp = await fetchWithRetry(
|
|
583
602
|
`${getSanoraUrl()}/user/${uuid}/product/${encodeURIComponent(title)}/artifact`,
|
|
584
603
|
{
|
|
585
604
|
method: 'PUT',
|
|
@@ -594,6 +613,10 @@ async function sanoraUploadArtifact(tenant, title, fileBuffer, filename, artifac
|
|
|
594
613
|
}
|
|
595
614
|
);
|
|
596
615
|
|
|
616
|
+
if (!resp.ok) {
|
|
617
|
+
const text = await resp.text().catch(() => '');
|
|
618
|
+
throw new Error(`Artifact upload failed (${resp.status}): ${text.slice(0, 200)}`);
|
|
619
|
+
}
|
|
597
620
|
const result = await resp.json();
|
|
598
621
|
if (result.error) throw new Error(`Artifact upload failed: ${result.error}`);
|
|
599
622
|
return result;
|
|
@@ -609,7 +632,7 @@ async function sanoraUploadImage(tenant, title, imageBuffer, filename) {
|
|
|
609
632
|
const form = new FormData();
|
|
610
633
|
form.append('image', imageBuffer, { filename, contentType: getMimeType(filename) });
|
|
611
634
|
|
|
612
|
-
const resp = await
|
|
635
|
+
const resp = await fetchWithRetry(
|
|
613
636
|
`${getSanoraUrl()}/user/${uuid}/product/${encodeURIComponent(title)}/image`,
|
|
614
637
|
{
|
|
615
638
|
method: 'PUT',
|
|
@@ -623,6 +646,10 @@ async function sanoraUploadImage(tenant, title, imageBuffer, filename) {
|
|
|
623
646
|
}
|
|
624
647
|
);
|
|
625
648
|
|
|
649
|
+
if (!resp.ok) {
|
|
650
|
+
const text = await resp.text().catch(() => '');
|
|
651
|
+
throw new Error(`Image upload failed (${resp.status}): ${text.slice(0, 200)}`);
|
|
652
|
+
}
|
|
626
653
|
const result = await resp.json();
|
|
627
654
|
if (result.error) throw new Error(`Image upload failed: ${result.error}`);
|
|
628
655
|
return result;
|
|
@@ -731,7 +758,46 @@ async function lucilleUploadVideo(tenant, title, fileBuffer, filename, lucilleUr
|
|
|
731
758
|
// ARCHIVE PROCESSING
|
|
732
759
|
// ============================================================
|
|
733
760
|
|
|
734
|
-
|
|
761
|
+
// ── Upload job store ─────────────────────────────────────────────────────────
|
|
762
|
+
// Each job buffers SSE events so the client can replay them if it connects late.
|
|
763
|
+
const uploadJobs = new Map(); // jobId → { sse: res|null, queue: [], done: false }
|
|
764
|
+
|
|
765
|
+
function countItems(root) {
|
|
766
|
+
let count = 0;
|
|
767
|
+
|
|
768
|
+
const booksDir = path.join(root, 'books');
|
|
769
|
+
if (fs.existsSync(booksDir))
|
|
770
|
+
count += fs.readdirSync(booksDir).filter(f => fs.statSync(path.join(booksDir, f)).isDirectory()).length;
|
|
771
|
+
|
|
772
|
+
const musicDir = path.join(root, 'music');
|
|
773
|
+
if (fs.existsSync(musicDir)) {
|
|
774
|
+
for (const entry of fs.readdirSync(musicDir)) {
|
|
775
|
+
const stat = fs.statSync(path.join(musicDir, entry));
|
|
776
|
+
if (stat.isDirectory()) count++;
|
|
777
|
+
else if (MUSIC_EXTS.has(path.extname(entry).toLowerCase())) count++;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const postsDir = path.join(root, 'posts');
|
|
782
|
+
if (fs.existsSync(postsDir)) {
|
|
783
|
+
for (const entry of fs.readdirSync(postsDir)) {
|
|
784
|
+
const entryPath = path.join(postsDir, entry);
|
|
785
|
+
if (!fs.statSync(entryPath).isDirectory()) continue;
|
|
786
|
+
const subDirs = fs.readdirSync(entryPath).filter(f => fs.statSync(path.join(entryPath, f)).isDirectory());
|
|
787
|
+
count += subDirs.length > 0 ? 1 + subDirs.length : 1;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
for (const dirName of ['albums', 'products', 'subscriptions', 'videos', 'appointments']) {
|
|
792
|
+
const dir = path.join(root, dirName);
|
|
793
|
+
if (fs.existsSync(dir))
|
|
794
|
+
count += fs.readdirSync(dir).filter(f => fs.statSync(path.join(dir, f)).isDirectory()).length;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
return count;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
async function processArchive(zipPath, onProgress = () => {}) {
|
|
735
801
|
const tmpDir = path.join(TMP_DIR, `extract-${Date.now()}`);
|
|
736
802
|
fs.mkdirSync(tmpDir, { recursive: true });
|
|
737
803
|
// Use system unzip to stream-extract without loading entire archive into RAM.
|
|
@@ -793,6 +859,9 @@ async function processArchive(zipPath) {
|
|
|
793
859
|
if (manifest.redirects && typeof manifest.redirects === 'object') {
|
|
794
860
|
tenantUpdates.redirects = manifest.redirects;
|
|
795
861
|
}
|
|
862
|
+
if (manifest.lightMode !== undefined) {
|
|
863
|
+
tenantUpdates.lightMode = !!manifest.lightMode;
|
|
864
|
+
}
|
|
796
865
|
if (Object.keys(tenantUpdates).length > 0) {
|
|
797
866
|
const tenants = loadTenants();
|
|
798
867
|
Object.assign(tenants[tenant.uuid], tenantUpdates);
|
|
@@ -804,6 +873,10 @@ async function processArchive(zipPath) {
|
|
|
804
873
|
// If Redis was wiped, this re-creates the user and updates tenant.uuid.
|
|
805
874
|
tenant = await sanoraEnsureUser(tenant);
|
|
806
875
|
|
|
876
|
+
const total = countItems(root);
|
|
877
|
+
let current = 0;
|
|
878
|
+
onProgress({ type: 'start', total, name: manifest.name });
|
|
879
|
+
|
|
807
880
|
const results = { books: [], music: [], posts: [], albums: [], products: [], videos: [], appointments: [], subscriptions: [], warnings: [] };
|
|
808
881
|
|
|
809
882
|
function readInfo(entryPath) {
|
|
@@ -832,6 +905,7 @@ async function processArchive(zipPath) {
|
|
|
832
905
|
const description = info.description || '';
|
|
833
906
|
const price = info.price || 0;
|
|
834
907
|
|
|
908
|
+
onProgress({ type: 'progress', current: ++current, total, label: `📚 ${title}` });
|
|
835
909
|
await sanoraCreateProductResilient(tenant, title, 'book', description, price, 0, buildTags('book', info.keywords));
|
|
836
910
|
|
|
837
911
|
// Cover image — use info.cover to pin a specific file, else first image found
|
|
@@ -874,6 +948,7 @@ async function processArchive(zipPath) {
|
|
|
874
948
|
try {
|
|
875
949
|
const description = info.description || `Album: ${albumTitle}`;
|
|
876
950
|
const price = info.price || 0;
|
|
951
|
+
onProgress({ type: 'progress', current: ++current, total, label: `🎵 ${albumTitle}` });
|
|
877
952
|
await sanoraCreateProductResilient(tenant, albumTitle, 'music', description, price, 0, buildTags('music,album', info.keywords));
|
|
878
953
|
const coverFile = info.cover ? (covers.find(f => f === info.cover) || covers[0]) : covers[0];
|
|
879
954
|
if (coverFile) {
|
|
@@ -903,6 +978,7 @@ async function processArchive(zipPath) {
|
|
|
903
978
|
const buf = fs.readFileSync(entryPath);
|
|
904
979
|
const description = trackInfo.description || `Track: ${title}`;
|
|
905
980
|
const price = trackInfo.price || 0;
|
|
981
|
+
onProgress({ type: 'progress', current: ++current, total, label: `🎵 ${title}` });
|
|
906
982
|
await sanoraCreateProductResilient(tenant, title, 'music', description, price, 0, buildTags('music,track', trackInfo.keywords));
|
|
907
983
|
await sanoraUploadArtifact(tenant, title, buf, entry, 'audio');
|
|
908
984
|
results.music.push({ title, type: 'track' });
|
|
@@ -943,6 +1019,7 @@ async function processArchive(zipPath) {
|
|
|
943
1019
|
// Register the series itself as a parent product
|
|
944
1020
|
try {
|
|
945
1021
|
const description = info.description || `A ${subDirs.length}-part series`;
|
|
1022
|
+
onProgress({ type: 'progress', current: ++current, total, label: `📝 ${seriesTitle} (series)` });
|
|
946
1023
|
await sanoraCreateProductResilient(tenant, seriesTitle, 'post-series', description, 0, 0, buildTags(`post,series,order:${order}`, info.keywords));
|
|
947
1024
|
|
|
948
1025
|
const covers = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
|
|
@@ -983,6 +1060,7 @@ async function processArchive(zipPath) {
|
|
|
983
1060
|
const productTitle = `${seriesTitle}: ${resolvedTitle}`;
|
|
984
1061
|
const description = partInfo.description || partFm.body.split('\n\n')[0].replace(/^#+\s*/, '').trim() || resolvedTitle;
|
|
985
1062
|
|
|
1063
|
+
onProgress({ type: 'progress', current: ++current, total, label: `📝 ${productTitle}` });
|
|
986
1064
|
await sanoraCreateProductResilient(tenant, productTitle, 'post', description, 0, 0,
|
|
987
1065
|
buildTags(`post,blog,series:${seriesTitle},part:${partIndex + 1},order:${order}`, partInfo.keywords));
|
|
988
1066
|
|
|
@@ -1025,6 +1103,7 @@ async function processArchive(zipPath) {
|
|
|
1025
1103
|
const firstLine = fm.body.split('\n').find(l => l.trim()).replace(/^#+\s*/, '');
|
|
1026
1104
|
const description = info.description || fm.body.split('\n\n')[0].replace(/^#+\s*/, '').trim() || firstLine || title;
|
|
1027
1105
|
|
|
1106
|
+
onProgress({ type: 'progress', current: ++current, total, label: `📝 ${title}` });
|
|
1028
1107
|
await sanoraCreateProductResilient(tenant, title, 'post', description, 0, 0, buildTags(`post,blog,order:${order}`, info.keywords));
|
|
1029
1108
|
await sanoraUploadArtifact(tenant, title, mdBuf, mdFiles[0], 'text');
|
|
1030
1109
|
|
|
@@ -1062,6 +1141,7 @@ async function processArchive(zipPath) {
|
|
|
1062
1141
|
if (!fs.statSync(entryPath).isDirectory()) continue;
|
|
1063
1142
|
const images = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
|
|
1064
1143
|
try {
|
|
1144
|
+
onProgress({ type: 'progress', current: ++current, total, label: `🖼️ ${entry}` });
|
|
1065
1145
|
await sanoraCreateProductResilient(tenant, entry, 'album', `Photo album: ${entry}`, 0, 0, 'album,photos');
|
|
1066
1146
|
if (images.length > 0) {
|
|
1067
1147
|
const coverBuf = fs.readFileSync(path.join(entryPath, images[0]));
|
|
@@ -1099,6 +1179,7 @@ async function processArchive(zipPath) {
|
|
|
1099
1179
|
const price = info.price || 0;
|
|
1100
1180
|
const shipping = info.shipping || 0;
|
|
1101
1181
|
|
|
1182
|
+
onProgress({ type: 'progress', current: ++current, total, label: `📦 ${title}` });
|
|
1102
1183
|
await sanoraCreateProductResilient(tenant, title, 'product', description, price, shipping, buildTags(`product,physical,order:${order}`, info.keywords));
|
|
1103
1184
|
|
|
1104
1185
|
// Hero image: prefer hero.jpg / hero.png, fall back to first image
|
|
@@ -1140,6 +1221,7 @@ async function processArchive(zipPath) {
|
|
|
1140
1221
|
renewalDays: info.renewalDays || 30
|
|
1141
1222
|
};
|
|
1142
1223
|
|
|
1224
|
+
onProgress({ type: 'progress', current: ++current, total, label: `🎁 ${title}` });
|
|
1143
1225
|
await sanoraCreateProductResilient(tenant, title, 'subscription', description, price, 0, buildTags('subscription', info.keywords));
|
|
1144
1226
|
|
|
1145
1227
|
// Upload tier metadata (benefits list, renewal period) as a JSON artifact
|
|
@@ -1215,6 +1297,7 @@ async function processArchive(zipPath) {
|
|
|
1215
1297
|
(lucilleVideoId ? `,lucille-id:${lucilleVideoId},lucille-url:${lucilleBase}` : '');
|
|
1216
1298
|
|
|
1217
1299
|
// Sanora catalog entry (for discovery / storefront)
|
|
1300
|
+
onProgress({ type: 'progress', current: ++current, total, label: `🎬 ${title}` });
|
|
1218
1301
|
await sanoraCreateProductResilient(tenant, title, 'video', description, price, 0, videoTags);
|
|
1219
1302
|
|
|
1220
1303
|
// Cover / poster image (optional)
|
|
@@ -1261,6 +1344,7 @@ async function processArchive(zipPath) {
|
|
|
1261
1344
|
advanceDays: info.advanceDays || 30
|
|
1262
1345
|
};
|
|
1263
1346
|
|
|
1347
|
+
onProgress({ type: 'progress', current: ++current, total, label: `📅 ${title}` });
|
|
1264
1348
|
await sanoraCreateProductResilient(tenant, title, 'appointment', description, price, 0, buildTags('appointment', info.keywords));
|
|
1265
1349
|
|
|
1266
1350
|
// Upload schedule as a JSON artifact so the booking page can retrieve it
|
|
@@ -1352,6 +1436,7 @@ async function getShoppeGoods(tenant) {
|
|
|
1352
1436
|
shipping: product.shipping || 0,
|
|
1353
1437
|
image: product.image ? `${getSanoraUrl()}/images/${product.image}` : null,
|
|
1354
1438
|
url: (bucketName && redirects[bucketName]) || defaultUrl,
|
|
1439
|
+
...(isPost && { category: product.category, tags: product.tags || '' }),
|
|
1355
1440
|
...(lucillePlayerUrl && { lucillePlayerUrl }),
|
|
1356
1441
|
...(product.category === 'video' && { shoppeId: tenant.uuid })
|
|
1357
1442
|
};
|
|
@@ -1359,6 +1444,30 @@ async function getShoppeGoods(tenant) {
|
|
|
1359
1444
|
if (bucket) bucket.push(item);
|
|
1360
1445
|
}
|
|
1361
1446
|
|
|
1447
|
+
// Enrich subscription and appointment items with artifact metadata
|
|
1448
|
+
const productsByTitle = {};
|
|
1449
|
+
for (const [key, product] of Object.entries(products)) {
|
|
1450
|
+
productsByTitle[product.title || key] = product;
|
|
1451
|
+
}
|
|
1452
|
+
await Promise.all([
|
|
1453
|
+
...goods.subscriptions.map(async item => {
|
|
1454
|
+
const product = productsByTitle[item.title];
|
|
1455
|
+
if (!product) return;
|
|
1456
|
+
item.productId = product.productId || '';
|
|
1457
|
+
const tierInfo = await getTierInfo(tenant, product).catch(() => null);
|
|
1458
|
+
item.renewalDays = tierInfo ? (tierInfo.renewalDays || 30) : 30;
|
|
1459
|
+
item.benefits = tierInfo ? (tierInfo.benefits || []) : [];
|
|
1460
|
+
}),
|
|
1461
|
+
...goods.appointments.map(async item => {
|
|
1462
|
+
const product = productsByTitle[item.title];
|
|
1463
|
+
if (!product) return;
|
|
1464
|
+
item.productId = product.productId || '';
|
|
1465
|
+
const schedule = await getAppointmentSchedule(tenant, product).catch(() => null);
|
|
1466
|
+
item.timezone = schedule ? (schedule.timezone || 'UTC') : 'UTC';
|
|
1467
|
+
item.duration = schedule ? (schedule.duration || 60) : 60;
|
|
1468
|
+
})
|
|
1469
|
+
]);
|
|
1470
|
+
|
|
1362
1471
|
return goods;
|
|
1363
1472
|
}
|
|
1364
1473
|
|
|
@@ -1705,32 +1814,86 @@ function generateShoppeHTML(tenant, goods, uploadAuth = null) {
|
|
|
1705
1814
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1706
1815
|
<title>${tenant.name}</title>
|
|
1707
1816
|
${tenant.keywords ? `<meta name="keywords" content="${escHtml(tenant.keywords)}">` : ''}
|
|
1817
|
+
<script src="https://js.stripe.com/v3/"></script>
|
|
1708
1818
|
<style>
|
|
1819
|
+
/* ── Theme variables (dark default) ── */
|
|
1820
|
+
:root {
|
|
1821
|
+
--bg: #0f0f12;
|
|
1822
|
+
--card-bg: #18181c;
|
|
1823
|
+
--card-bg-2: #1e1e22;
|
|
1824
|
+
--input-bg: #2a2a2e;
|
|
1825
|
+
--nav-bg: #18181c;
|
|
1826
|
+
--text: #e8e8ea;
|
|
1827
|
+
--text-2: #aaa;
|
|
1828
|
+
--text-3: #888;
|
|
1829
|
+
--accent: #7ec8e3;
|
|
1830
|
+
--border: #333;
|
|
1831
|
+
--hover-bg: #1a3040;
|
|
1832
|
+
--badge-bg: #1a3040;
|
|
1833
|
+
--placeholder: #2a2a2e;
|
|
1834
|
+
--shadow: rgba(0,0,0,0.4);
|
|
1835
|
+
--shadow-hover: rgba(0,0,0,0.65);
|
|
1836
|
+
--row-border: #2a2a2e;
|
|
1837
|
+
--progress-bg: #333;
|
|
1838
|
+
--chip-bg: #2a2a2e;
|
|
1839
|
+
--note-bg: #2a2600;
|
|
1840
|
+
--note-border: #665500;
|
|
1841
|
+
--note-text: #ccaa44;
|
|
1842
|
+
--ok-bg: #0a2a18;
|
|
1843
|
+
--ok-border: #2a7050;
|
|
1844
|
+
--ok-text: #5dd49a;
|
|
1845
|
+
}
|
|
1846
|
+
body.light {
|
|
1847
|
+
--bg: #f5f5f7;
|
|
1848
|
+
--card-bg: white;
|
|
1849
|
+
--card-bg-2: #fafafa;
|
|
1850
|
+
--input-bg: white;
|
|
1851
|
+
--nav-bg: white;
|
|
1852
|
+
--text: #1d1d1f;
|
|
1853
|
+
--text-2: #666;
|
|
1854
|
+
--text-3: #888;
|
|
1855
|
+
--accent: #0066cc;
|
|
1856
|
+
--border: #ddd;
|
|
1857
|
+
--hover-bg: #e8f0fe;
|
|
1858
|
+
--badge-bg: #e8f0fe;
|
|
1859
|
+
--placeholder: #f0f0f7;
|
|
1860
|
+
--shadow: rgba(0,0,0,0.07);
|
|
1861
|
+
--shadow-hover: rgba(0,0,0,0.12);
|
|
1862
|
+
--row-border: #f0f0f0;
|
|
1863
|
+
--progress-bg: #e0e0e0;
|
|
1864
|
+
--chip-bg: #f0f0f7;
|
|
1865
|
+
--note-bg: #fffde7;
|
|
1866
|
+
--note-border: #e0c040;
|
|
1867
|
+
--note-text: #7a6000;
|
|
1868
|
+
--ok-bg: #f0faf4;
|
|
1869
|
+
--ok-border: #48bb78;
|
|
1870
|
+
--ok-text: #276749;
|
|
1871
|
+
}
|
|
1709
1872
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
1710
|
-
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background:
|
|
1873
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); }
|
|
1711
1874
|
header { background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); color: white; padding: 48px 24px 40px; text-align: center; }
|
|
1712
1875
|
.emojicode { font-size: 30px; letter-spacing: 6px; margin-bottom: 14px; }
|
|
1713
1876
|
header h1 { font-size: 38px; font-weight: 700; margin-bottom: 6px; }
|
|
1714
1877
|
.count { opacity: 0.65; font-size: 15px; }
|
|
1715
|
-
nav { display: flex; overflow-x: auto; background:
|
|
1716
|
-
.tab { padding: 14px 18px; cursor: pointer; font-size: 14px; font-weight: 500; white-space: nowrap; border-bottom: 2px solid transparent; color:
|
|
1717
|
-
.tab:hover { color:
|
|
1718
|
-
.tab.active { color:
|
|
1719
|
-
.badge { background:
|
|
1878
|
+
nav { display: flex; overflow-x: auto; background: var(--nav-bg); border-bottom: 1px solid var(--border); padding: 0 20px; gap: 0; }
|
|
1879
|
+
.tab { padding: 14px 18px; cursor: pointer; font-size: 14px; font-weight: 500; white-space: nowrap; border-bottom: 2px solid transparent; color: var(--text-2); transition: color 0.15s, border-color 0.15s; }
|
|
1880
|
+
.tab:hover { color: var(--accent); }
|
|
1881
|
+
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
|
1882
|
+
.badge { background: var(--badge-bg); color: var(--accent); border-radius: 10px; padding: 1px 7px; font-size: 11px; margin-left: 5px; }
|
|
1720
1883
|
main { max-width: 1200px; margin: 0 auto; padding: 36px 24px; }
|
|
1721
1884
|
.section { display: none; }
|
|
1722
1885
|
.section.active { display: block; }
|
|
1723
1886
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 20px; }
|
|
1724
|
-
.card { background:
|
|
1725
|
-
.card:hover { transform: translateY(-3px); box-shadow: 0 8px 24px
|
|
1887
|
+
.card { background: var(--card-bg); border-radius: 14px; overflow: hidden; box-shadow: 0 2px 8px var(--shadow); cursor: pointer; transition: transform 0.18s, box-shadow 0.18s; }
|
|
1888
|
+
.card:hover { transform: translateY(-3px); box-shadow: 0 8px 24px var(--shadow-hover); }
|
|
1726
1889
|
.card-img img { width: 100%; height: 190px; object-fit: cover; display: block; }
|
|
1727
|
-
.card-img-placeholder { height: 110px; display: flex; align-items: center; justify-content: center; font-size: 44px; background:
|
|
1890
|
+
.card-img-placeholder { height: 110px; display: flex; align-items: center; justify-content: center; font-size: 44px; background: var(--placeholder); }
|
|
1728
1891
|
.card-body { padding: 16px; }
|
|
1729
1892
|
.card-title { font-size: 15px; font-weight: 600; margin-bottom: 5px; line-height: 1.3; }
|
|
1730
|
-
.card-desc { font-size: 13px; color:
|
|
1731
|
-
.price { font-size: 15px; font-weight: 700; color:
|
|
1732
|
-
.shipping { font-size: 12px; font-weight: 400; color:
|
|
1733
|
-
.empty { color:
|
|
1893
|
+
.card-desc { font-size: 13px; color: var(--text-2); margin-bottom: 8px; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
|
1894
|
+
.price { font-size: 15px; font-weight: 700; color: var(--accent); }
|
|
1895
|
+
.shipping { font-size: 12px; font-weight: 400; color: var(--text-3); }
|
|
1896
|
+
.empty { color: var(--text-3); text-align: center; padding: 60px 0; font-size: 15px; }
|
|
1734
1897
|
.card-video-play { position: relative; }
|
|
1735
1898
|
.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; }
|
|
1736
1899
|
.card:hover .card-video-play::after { opacity: 1; }
|
|
@@ -1742,14 +1905,131 @@ function generateShoppeHTML(tenant, goods, uploadAuth = null) {
|
|
|
1742
1905
|
.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; }
|
|
1743
1906
|
.video-modal-close:hover { background: rgba(0,0,0,0.8); }
|
|
1744
1907
|
.card-video-upload { cursor: default !important; }
|
|
1745
|
-
.upload-btn-label { display: inline-block; background:
|
|
1746
|
-
.upload-btn-label:hover {
|
|
1747
|
-
.upload-progress { margin-top: 8px; font-size: 12px; color:
|
|
1748
|
-
.upload-progress-bar { height: 4px; background:
|
|
1749
|
-
.upload-progress-bar-fill { height: 100%; background:
|
|
1908
|
+
.upload-btn-label { display: inline-block; background: var(--accent); color: white; border-radius: 8px; padding: 8px 16px; font-size: 13px; font-weight: 600; cursor: pointer; margin-top: 8px; }
|
|
1909
|
+
.upload-btn-label:hover { opacity: 0.85; }
|
|
1910
|
+
.upload-progress { margin-top: 8px; font-size: 12px; color: var(--text-2); }
|
|
1911
|
+
.upload-progress-bar { height: 4px; background: var(--progress-bg); border-radius: 2px; margin-top: 4px; overflow: hidden; }
|
|
1912
|
+
.upload-progress-bar-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.2s; }
|
|
1913
|
+
/* ── Posts browser ── */
|
|
1914
|
+
.posts-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 20px; }
|
|
1915
|
+
.posts-back-btn { background: none; border: 1px solid var(--border); border-radius: 8px; padding: 8px 16px; font-size: 13px; cursor: pointer; margin-bottom: 20px; color: var(--text); }
|
|
1916
|
+
.posts-back-btn:hover { border-color: var(--accent); color: var(--accent); }
|
|
1917
|
+
.posts-series-header { display: flex; gap: 20px; align-items: flex-start; margin-bottom: 24px; }
|
|
1918
|
+
.posts-series-cover { width: 120px; height: 120px; object-fit: cover; border-radius: 10px; flex-shrink: 0; }
|
|
1919
|
+
.posts-series-title { font-size: 24px; font-weight: 700; margin-bottom: 6px; }
|
|
1920
|
+
.posts-series-desc { font-size: 14px; color: var(--text-2); line-height: 1.5; }
|
|
1921
|
+
.posts-part-row { display: flex; align-items: center; gap: 14px; padding: 14px 16px; border-radius: 10px; cursor: pointer; transition: background 0.15s; border-bottom: 1px solid var(--row-border); text-decoration: none; color: inherit; }
|
|
1922
|
+
.posts-part-row:hover { background: var(--hover-bg); }
|
|
1923
|
+
.posts-part-num { font-size: 13px; color: var(--text-3); min-width: 28px; text-align: center; font-weight: 600; }
|
|
1924
|
+
.posts-part-info { flex: 1; min-width: 0; }
|
|
1925
|
+
.posts-part-title { font-size: 15px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
1926
|
+
.posts-part-desc { font-size: 12px; color: var(--text-3); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
1927
|
+
.posts-part-arrow { color: var(--accent); font-size: 14px; }
|
|
1928
|
+
.posts-standalones-label { font-size: 12px; font-weight: 600; color: var(--text-3); text-transform: uppercase; letter-spacing: .5px; margin: 28px 0 12px; }
|
|
1929
|
+
/* ── Music player ── */
|
|
1930
|
+
.music-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 20px; }
|
|
1931
|
+
.music-album-card { cursor: pointer; }
|
|
1932
|
+
.music-back-btn { background: none; border: 1px solid var(--border); border-radius: 8px; padding: 8px 16px; font-size: 13px; cursor: pointer; margin-bottom: 20px; color: var(--text); }
|
|
1933
|
+
.music-back-btn:hover { border-color: var(--accent); color: var(--accent); }
|
|
1934
|
+
.music-detail-header { display: flex; gap: 20px; align-items: flex-start; margin-bottom: 24px; }
|
|
1935
|
+
.music-detail-cover { width: 140px; height: 140px; object-fit: cover; border-radius: 10px; flex-shrink: 0; }
|
|
1936
|
+
.music-detail-title { font-size: 24px; font-weight: 700; margin-bottom: 6px; }
|
|
1937
|
+
.music-detail-desc { font-size: 14px; color: var(--text-2); line-height: 1.5; }
|
|
1938
|
+
.music-track-row { display: flex; align-items: center; gap: 14px; padding: 12px 16px; border-radius: 10px; cursor: pointer; transition: background 0.15s; }
|
|
1939
|
+
.music-track-row:hover, .music-track-row.playing { background: var(--hover-bg); }
|
|
1940
|
+
.music-track-row.playing .music-track-title { color: var(--accent); font-weight: 600; }
|
|
1941
|
+
.music-track-num { font-size: 14px; color: var(--text-3); min-width: 24px; text-align: center; }
|
|
1942
|
+
.music-track-cover { width: 40px; height: 40px; border-radius: 6px; object-fit: cover; flex-shrink: 0; }
|
|
1943
|
+
.music-track-cover-ph { width: 40px; height: 40px; border-radius: 6px; background: var(--placeholder); display: flex; align-items: center; justify-content: center; font-size: 18px; flex-shrink: 0; }
|
|
1944
|
+
.music-track-info { flex: 1; min-width: 0; }
|
|
1945
|
+
.music-track-title { font-size: 14px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
1946
|
+
.music-track-meta { font-size: 12px; color: var(--text-3); }
|
|
1947
|
+
.music-play-icon { font-size: 14px; color: var(--text-3); opacity: 0; transition: opacity 0.15s; }
|
|
1948
|
+
.music-track-row:hover .music-play-icon { opacity: 1; }
|
|
1949
|
+
.music-track-row.playing .music-play-icon { opacity: 1; color: var(--accent); }
|
|
1950
|
+
.music-singles-label { font-size: 12px; font-weight: 600; color: var(--text-3); text-transform: uppercase; letter-spacing: .5px; margin: 28px 0 8px; }
|
|
1951
|
+
/* ── Music player bar (always dark) ── */
|
|
1952
|
+
#music-player-bar { position: fixed; bottom: 0; left: 0; right: 0; background: rgba(15,15,30,0.97); backdrop-filter: blur(12px); border-top: 1px solid #8b5cf6; padding: 12px 20px; z-index: 500; display: none; }
|
|
1953
|
+
.music-bar-inner { max-width: 1200px; margin: 0 auto; display: flex; align-items: center; gap: 16px; }
|
|
1954
|
+
.music-bar-art { width: 48px; height: 48px; border-radius: 6px; object-fit: cover; flex-shrink: 0; }
|
|
1955
|
+
.music-bar-info { flex: 1; min-width: 0; }
|
|
1956
|
+
.music-bar-title { font-size: 14px; font-weight: 600; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
1957
|
+
.music-bar-album { font-size: 12px; color: #8b5cf6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
1958
|
+
.music-bar-controls { display: flex; align-items: center; gap: 10px; }
|
|
1959
|
+
.music-bar-btn { background: none; border: none; cursor: pointer; color: #10b981; font-size: 20px; padding: 4px 8px; line-height: 1; transition: color 0.15s; }
|
|
1960
|
+
.music-bar-btn:hover { color: #fff; }
|
|
1961
|
+
.music-bar-progress { flex: 1; display: flex; align-items: center; gap: 8px; min-width: 120px; }
|
|
1962
|
+
.music-bar-time { font-size: 11px; color: #fbbf24; min-width: 36px; text-align: center; }
|
|
1963
|
+
.music-bar-track { flex: 1; height: 4px; background: rgba(139,92,246,0.3); border-radius: 2px; cursor: pointer; position: relative; }
|
|
1964
|
+
.music-bar-fill { height: 100%; background: linear-gradient(90deg, #10b981, #8b5cf6); border-radius: 2px; width: 0%; transition: width 0.1s linear; }
|
|
1965
|
+
/* ── Inline subscription tiers ── */
|
|
1966
|
+
.sub-tier-card { background: var(--card-bg); border-radius: 14px; overflow: hidden; box-shadow: 0 2px 8px var(--shadow); margin-bottom: 20px; }
|
|
1967
|
+
.sub-tier-header { display: flex; gap: 20px; padding: 20px; }
|
|
1968
|
+
.sub-tier-img { width: 110px; height: 110px; object-fit: cover; border-radius: 10px; flex-shrink: 0; }
|
|
1969
|
+
.sub-tier-img-ph { width: 110px; height: 110px; border-radius: 10px; background: linear-gradient(135deg, #1a1a2e, #0f3460); display: flex; align-items: center; justify-content: center; font-size: 36px; flex-shrink: 0; }
|
|
1970
|
+
.sub-tier-info { flex: 1; min-width: 0; }
|
|
1971
|
+
.sub-tier-name { font-size: 19px; font-weight: 700; margin-bottom: 5px; }
|
|
1972
|
+
.sub-tier-desc { font-size: 13px; color: var(--text-2); line-height: 1.5; margin-bottom: 10px; }
|
|
1973
|
+
.sub-tier-price { display: inline-flex; align-items: baseline; gap: 5px; color: var(--accent); font-size: 20px; font-weight: 700; margin-bottom: 8px; }
|
|
1974
|
+
.sub-tier-price span { font-size: 12px; color: var(--text-3); font-weight: 400; }
|
|
1975
|
+
.sub-benefits { list-style: none; display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; }
|
|
1976
|
+
.sub-benefits li { font-size: 12px; color: var(--text-2); padding-left: 16px; position: relative; }
|
|
1977
|
+
.sub-benefits li::before { content: '✓'; position: absolute; left: 0; color: var(--accent); font-weight: 700; }
|
|
1978
|
+
.sub-btn { background: linear-gradient(90deg, #0f3460, var(--accent)); color: white; border: none; border-radius: 8px; padding: 9px 20px; font-size: 13px; font-weight: 600; cursor: pointer; transition: opacity 0.15s; }
|
|
1979
|
+
.sub-btn:hover { opacity: 0.88; }
|
|
1980
|
+
.sub-btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
1981
|
+
.sub-form-panel { display: none; padding: 24px; border-top: 1px solid var(--border); background: var(--card-bg-2); }
|
|
1982
|
+
.sub-form-panel.open { display: block; }
|
|
1983
|
+
.sub-field-group { margin-bottom: 14px; }
|
|
1984
|
+
.sub-field-group label { display: block; font-size: 11px; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 5px; }
|
|
1985
|
+
.sub-field-group input { width: 100%; max-width: 400px; background: var(--input-bg); border: 1px solid var(--border); border-radius: 8px; padding: 9px 12px; color: var(--text); font-size: 14px; outline: none; transition: border-color 0.15s; }
|
|
1986
|
+
.sub-field-group input:focus { border-color: var(--accent); }
|
|
1987
|
+
.sub-error { color: #ff6b6b; font-size: 13px; margin-top: 8px; display: none; }
|
|
1988
|
+
.sub-recovery-note { background: var(--note-bg); border: 1px solid var(--note-border); border-radius: 8px; padding: 10px 14px; font-size: 12px; color: var(--note-text); margin-bottom: 14px; line-height: 1.5; }
|
|
1989
|
+
.sub-already { background: var(--ok-bg); border: 1px solid var(--ok-border); border-radius: 10px; padding: 14px 16px; margin-bottom: 14px; }
|
|
1990
|
+
.sub-already strong { color: var(--ok-text); font-size: 14px; display: block; margin-bottom: 4px; }
|
|
1991
|
+
.sub-already p { font-size: 13px; color: var(--text-2); }
|
|
1992
|
+
.sub-confirm-box { text-align: center; padding: 24px; }
|
|
1993
|
+
.sub-confirm-box .icon { font-size: 48px; margin-bottom: 10px; }
|
|
1994
|
+
.sub-confirm-box h3 { font-size: 20px; font-weight: 700; margin-bottom: 6px; }
|
|
1995
|
+
.sub-confirm-box .renews { color: var(--accent); font-size: 14px; margin-bottom: 8px; }
|
|
1996
|
+
/* ── Inline appointment booking ── */
|
|
1997
|
+
.appt-card { background: var(--card-bg); border-radius: 14px; overflow: hidden; box-shadow: 0 2px 8px var(--shadow); margin-bottom: 20px; }
|
|
1998
|
+
.appt-card-header { display: flex; gap: 20px; padding: 20px; cursor: pointer; }
|
|
1999
|
+
.appt-card-header:hover { background: var(--card-bg-2); }
|
|
2000
|
+
.appt-img { width: 110px; height: 110px; object-fit: cover; border-radius: 10px; flex-shrink: 0; }
|
|
2001
|
+
.appt-img-ph { width: 110px; height: 110px; border-radius: 10px; background: linear-gradient(135deg, #1a1a2e, #0f3460); display: flex; align-items: center; justify-content: center; font-size: 36px; flex-shrink: 0; }
|
|
2002
|
+
.appt-info { flex: 1; min-width: 0; }
|
|
2003
|
+
.appt-name { font-size: 19px; font-weight: 700; margin-bottom: 5px; }
|
|
2004
|
+
.appt-desc { font-size: 13px; color: var(--text-2); line-height: 1.5; margin-bottom: 10px; }
|
|
2005
|
+
.appt-meta { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
|
|
2006
|
+
.appt-chip { background: var(--chip-bg); border-radius: 20px; padding: 4px 12px; font-size: 12px; color: var(--text-2); }
|
|
2007
|
+
.appt-book-btn { background: linear-gradient(90deg, #0f3460, var(--accent)); color: white; border: none; border-radius: 8px; padding: 9px 20px; font-size: 13px; font-weight: 600; cursor: pointer; transition: opacity 0.15s; }
|
|
2008
|
+
.appt-book-btn:hover { opacity: 0.88; }
|
|
2009
|
+
.appt-book-btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
2010
|
+
.appt-booking-panel { display: none; padding: 24px; border-top: 1px solid var(--border); background: var(--card-bg-2); }
|
|
2011
|
+
.appt-booking-panel.open { display: block; }
|
|
2012
|
+
.appt-date-strip { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 6px; margin-bottom: 16px; scrollbar-width: thin; }
|
|
2013
|
+
.appt-date-card { flex: 0 0 66px; background: var(--input-bg); border: 2px solid var(--border); border-radius: 10px; padding: 8px 4px; text-align: center; cursor: pointer; transition: border-color 0.15s; }
|
|
2014
|
+
.appt-date-card:hover { border-color: var(--accent); }
|
|
2015
|
+
.appt-date-card.active { border-color: var(--accent); background: var(--hover-bg); }
|
|
2016
|
+
.appt-date-card .dow { font-size: 10px; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.5px; }
|
|
2017
|
+
.appt-date-card .dom { font-size: 20px; font-weight: 700; margin: 1px 0; color: var(--text); }
|
|
2018
|
+
.appt-date-card .mon { font-size: 10px; color: var(--text-3); }
|
|
2019
|
+
.appt-slot-grid { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; }
|
|
2020
|
+
.appt-slot-btn { background: var(--input-bg); border: 2px solid var(--border); border-radius: 8px; padding: 6px 14px; font-size: 13px; color: var(--text); cursor: pointer; transition: border-color 0.15s; }
|
|
2021
|
+
.appt-slot-btn:hover { border-color: var(--accent); }
|
|
2022
|
+
.appt-slot-btn.active { border-color: var(--accent); background: var(--hover-bg); color: var(--accent); font-weight: 600; }
|
|
2023
|
+
.appt-selected-slot { background: var(--hover-bg); border: 1px solid var(--accent); border-radius: 8px; padding: 10px 14px; font-size: 13px; color: var(--accent); margin-bottom: 14px; }
|
|
2024
|
+
.appt-back-btn { background: none; border: 1px solid var(--border); border-radius: 8px; padding: 9px 16px; font-size: 13px; cursor: pointer; color: var(--text-2); }
|
|
2025
|
+
.appt-back-btn:hover { border-color: var(--accent); color: var(--accent); }
|
|
2026
|
+
.appt-confirm-box { text-align: center; padding: 24px; }
|
|
2027
|
+
.appt-confirm-box .icon { font-size: 48px; margin-bottom: 10px; }
|
|
2028
|
+
.appt-confirm-box h3 { font-size: 20px; font-weight: 700; margin-bottom: 6px; }
|
|
2029
|
+
.appt-confirm-box .slot-label { color: var(--accent); font-size: 15px; font-weight: 600; margin-bottom: 8px; }
|
|
1750
2030
|
</style>
|
|
1751
2031
|
</head>
|
|
1752
|
-
<body>
|
|
2032
|
+
<body${tenant.lightMode ? ' class="light"' : ''}>
|
|
1753
2033
|
<header>
|
|
1754
2034
|
<div class="emojicode">${tenant.emojicode}</div>
|
|
1755
2035
|
<h1>${tenant.name}</h1>
|
|
@@ -1759,17 +2039,56 @@ function generateShoppeHTML(tenant, goods, uploadAuth = null) {
|
|
|
1759
2039
|
<main>
|
|
1760
2040
|
<div id="all" class="section active"><div class="grid">${renderCards(allItems, 'all')}</div></div>
|
|
1761
2041
|
<div id="books" class="section"><div class="grid">${renderCards(goods.books, 'book')}</div></div>
|
|
1762
|
-
<div id="music" class="section"
|
|
1763
|
-
|
|
2042
|
+
<div id="music" class="section">
|
|
2043
|
+
<div id="music-album-grid"></div>
|
|
2044
|
+
<div id="music-album-detail" style="display:none">
|
|
2045
|
+
<button class="music-back-btn" onclick="musicShowGrid()">← Albums</button>
|
|
2046
|
+
<div id="music-detail-header"></div>
|
|
2047
|
+
<div id="music-track-list"></div>
|
|
2048
|
+
</div>
|
|
2049
|
+
</div>
|
|
2050
|
+
<div id="posts" class="section">
|
|
2051
|
+
<div id="posts-grid"></div>
|
|
2052
|
+
<div id="posts-series-detail" style="display:none">
|
|
2053
|
+
<button class="posts-back-btn" onclick="postsShowGrid()">← Posts</button>
|
|
2054
|
+
<div id="posts-series-header"></div>
|
|
2055
|
+
<div id="posts-parts-list"></div>
|
|
2056
|
+
</div>
|
|
2057
|
+
</div>
|
|
1764
2058
|
<div id="albums" class="section"><div class="grid">${renderCards(goods.albums, 'album')}</div></div>
|
|
1765
2059
|
<div id="products" class="section"><div class="grid">${renderCards(goods.products, 'product')}</div></div>
|
|
1766
2060
|
<div id="videos" class="section"><div class="grid">${renderCards(goods.videos, 'video')}</div></div>
|
|
1767
|
-
<div id="appointments" class="section"
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
2061
|
+
<div id="appointments" class="section">
|
|
2062
|
+
<div id="appointments-list"></div>
|
|
2063
|
+
</div>
|
|
2064
|
+
<div id="subscriptions" class="section">
|
|
2065
|
+
<div id="subscriptions-list"></div>
|
|
2066
|
+
<div style="text-align:center;padding:12px 0 8px;font-size:13px;color:#888;">
|
|
2067
|
+
Already infusing? <a href="/plugin/shoppe/${tenant.uuid}/membership" style="color:#0066cc;">Access your membership →</a>
|
|
2068
|
+
</div>
|
|
1771
2069
|
</div>
|
|
1772
2070
|
</main>
|
|
2071
|
+
<div id="music-player-bar">
|
|
2072
|
+
<div class="music-bar-inner">
|
|
2073
|
+
<img id="music-bar-art" class="music-bar-art" src="" alt="" style="display:none">
|
|
2074
|
+
<div class="music-bar-info">
|
|
2075
|
+
<div class="music-bar-title" id="music-bar-title">—</div>
|
|
2076
|
+
<div class="music-bar-album" id="music-bar-album"></div>
|
|
2077
|
+
</div>
|
|
2078
|
+
<div class="music-bar-controls">
|
|
2079
|
+
<button class="music-bar-btn" onclick="musicBarPrev()" title="Previous">◀◀</button>
|
|
2080
|
+
<button class="music-bar-btn" id="music-bar-play" onclick="musicBarPlayPause()" title="Play/Pause">▶</button>
|
|
2081
|
+
<button class="music-bar-btn" onclick="musicBarNext()" title="Next">▶▶</button>
|
|
2082
|
+
</div>
|
|
2083
|
+
<div class="music-bar-progress">
|
|
2084
|
+
<span class="music-bar-time" id="music-bar-time">0:00</span>
|
|
2085
|
+
<div class="music-bar-track" onclick="musicBarSeek(event)">
|
|
2086
|
+
<div class="music-bar-fill" id="music-bar-fill"></div>
|
|
2087
|
+
</div>
|
|
2088
|
+
<span class="music-bar-time" id="music-bar-dur">0:00</span>
|
|
2089
|
+
</div>
|
|
2090
|
+
</div>
|
|
2091
|
+
</div>
|
|
1773
2092
|
<div id="video-modal" class="video-modal">
|
|
1774
2093
|
<div class="video-modal-backdrop" onclick="closeVideo()"></div>
|
|
1775
2094
|
<div class="video-modal-content">
|
|
@@ -1784,6 +2103,248 @@ function generateShoppeHTML(tenant, goods, uploadAuth = null) {
|
|
|
1784
2103
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
1785
2104
|
document.getElementById(id).classList.add('active');
|
|
1786
2105
|
tab.classList.add('active');
|
|
2106
|
+
if (id === 'music' && !_musicLoaded) initMusic();
|
|
2107
|
+
if (id === 'posts' && !_postsLoaded) initPosts();
|
|
2108
|
+
if (id === 'subscriptions' && !_subsLoaded) initSubscriptions();
|
|
2109
|
+
if (id === 'appointments' && !_apptsLoaded) initAppointments();
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
// ── Posts browser ───────────────────────────────────────────────────────
|
|
2113
|
+
const _postsRaw = ${(() => {
|
|
2114
|
+
// Build structured posts data server-side so the client just reads JSON.
|
|
2115
|
+
const seriesMap = {}; // seriesTitle → { item, parts: [] }
|
|
2116
|
+
const standalones = [];
|
|
2117
|
+
// First pass: collect series parents
|
|
2118
|
+
for (const item of goods.posts) {
|
|
2119
|
+
if (item.category === 'post-series') seriesMap[item.title] = { ...item, parts: [] };
|
|
2120
|
+
}
|
|
2121
|
+
// Second pass: attach parts to their series, or collect standalones
|
|
2122
|
+
for (const item of goods.posts) {
|
|
2123
|
+
if (item.category !== 'post') continue;
|
|
2124
|
+
const tagParts = (item.tags || '').split(',');
|
|
2125
|
+
const seriesTag = tagParts.find(t => t.startsWith('series:'));
|
|
2126
|
+
const partTag = tagParts.find(t => t.startsWith('part:'));
|
|
2127
|
+
const seriesTitle = seriesTag ? seriesTag.slice('series:'.length) : null;
|
|
2128
|
+
const partNum = partTag ? parseInt(partTag.slice('part:'.length)) || 0 : 0;
|
|
2129
|
+
if (seriesTitle && seriesMap[seriesTitle]) {
|
|
2130
|
+
seriesMap[seriesTitle].parts.push({ ...item, partNum });
|
|
2131
|
+
} else {
|
|
2132
|
+
standalones.push(item);
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
// Sort parts within each series
|
|
2136
|
+
for (const s of Object.values(seriesMap)) {
|
|
2137
|
+
s.parts.sort((a, b) => (a.partNum || 0) - (b.partNum || 0));
|
|
2138
|
+
}
|
|
2139
|
+
return JSON.stringify({ series: Object.values(seriesMap), standalones });
|
|
2140
|
+
})()};
|
|
2141
|
+
let _postsLoaded = false;
|
|
2142
|
+
|
|
2143
|
+
function initPosts() {
|
|
2144
|
+
_postsLoaded = true;
|
|
2145
|
+
postsRenderGrid();
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
function postsRenderGrid() {
|
|
2149
|
+
const grid = document.getElementById('posts-grid');
|
|
2150
|
+
const { series, standalones } = _postsRaw;
|
|
2151
|
+
if (series.length === 0 && standalones.length === 0) {
|
|
2152
|
+
grid.innerHTML = '<p class="empty">No posts yet.</p>';
|
|
2153
|
+
return;
|
|
2154
|
+
}
|
|
2155
|
+
const seriesHtml = series.length ? \`<div class="posts-grid">\${series.map((s, i) => \`
|
|
2156
|
+
<div class="card" style="cursor:pointer" onclick="postsShowSeries(\${i})">
|
|
2157
|
+
\${s.image ? \`<div class="card-img"><img src="\${_escHtml(s.image)}" alt="" loading="lazy"></div>\` : '<div class="card-img-placeholder">📝</div>'}
|
|
2158
|
+
<div class="card-body">
|
|
2159
|
+
<div class="card-title">\${_escHtml(s.title)}</div>
|
|
2160
|
+
\${s.description ? \`<div class="card-desc">\${_escHtml(s.description)}</div>\` : ''}
|
|
2161
|
+
<div style="font-size:12px;color:#0066cc;margin-top:6px;font-weight:600">\${s.parts.length} part\${s.parts.length !== 1 ? 's' : ''}</div>
|
|
2162
|
+
</div>
|
|
2163
|
+
</div>\`).join('')}</div>\` : '';
|
|
2164
|
+
const standaloneHtml = standalones.length ? \`
|
|
2165
|
+
\${series.length ? '<div class="posts-standalones-label">Posts</div>' : ''}
|
|
2166
|
+
<div class="posts-grid">\${standalones.map(p => \`
|
|
2167
|
+
<div class="card" onclick="window.open('\${_escHtml(p.url)}','_self')">
|
|
2168
|
+
\${p.image ? \`<div class="card-img"><img src="\${_escHtml(p.image)}" alt="" loading="lazy"></div>\` : '<div class="card-img-placeholder">📝</div>'}
|
|
2169
|
+
<div class="card-body">
|
|
2170
|
+
<div class="card-title">\${_escHtml(p.title)}</div>
|
|
2171
|
+
\${p.description ? \`<div class="card-desc">\${_escHtml(p.description)}</div>\` : ''}
|
|
2172
|
+
</div>
|
|
2173
|
+
</div>\`).join('')}</div>\` : '';
|
|
2174
|
+
grid.innerHTML = seriesHtml + standaloneHtml;
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
function postsShowSeries(idx) {
|
|
2178
|
+
const s = _postsRaw.series[idx];
|
|
2179
|
+
document.getElementById('posts-grid').style.display = 'none';
|
|
2180
|
+
document.getElementById('posts-series-detail').style.display = 'block';
|
|
2181
|
+
document.getElementById('posts-series-header').innerHTML = \`
|
|
2182
|
+
<div class="posts-series-header">
|
|
2183
|
+
\${s.image ? \`<img class="posts-series-cover" src="\${_escHtml(s.image)}" alt="">\` : ''}
|
|
2184
|
+
<div>
|
|
2185
|
+
<div class="posts-series-title">\${_escHtml(s.title)}</div>
|
|
2186
|
+
\${s.description ? \`<div class="posts-series-desc">\${_escHtml(s.description)}</div>\` : ''}
|
|
2187
|
+
</div>
|
|
2188
|
+
</div>\`;
|
|
2189
|
+
document.getElementById('posts-parts-list').innerHTML = s.parts.map((p, i) => \`
|
|
2190
|
+
<a class="posts-part-row" href="\${_escHtml(p.url)}">
|
|
2191
|
+
<div class="posts-part-num">\${p.partNum || i + 1}</div>
|
|
2192
|
+
<div class="posts-part-info">
|
|
2193
|
+
<div class="posts-part-title">\${_escHtml(p.title.replace(s.title + ': ', ''))}</div>
|
|
2194
|
+
\${p.description ? \`<div class="posts-part-desc">\${_escHtml(p.description)}</div>\` : ''}
|
|
2195
|
+
</div>
|
|
2196
|
+
<div class="posts-part-arrow">→</div>
|
|
2197
|
+
</a>\`).join('');
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
function postsShowGrid() {
|
|
2201
|
+
document.getElementById('posts-grid').style.display = '';
|
|
2202
|
+
document.getElementById('posts-series-detail').style.display = 'none';
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
// ── Music player ────────────────────────────────────────────────────────
|
|
2206
|
+
let _musicLoaded = false, _musicAlbums = [], _musicTracks = [], _musicAllTracks = [], _musicCurrentIdx = -1;
|
|
2207
|
+
const _musicAudio = new Audio();
|
|
2208
|
+
|
|
2209
|
+
function _escHtml(s) {
|
|
2210
|
+
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
async function initMusic() {
|
|
2214
|
+
_musicLoaded = true;
|
|
2215
|
+
const grid = document.getElementById('music-album-grid');
|
|
2216
|
+
grid.innerHTML = '<p class="empty">Loading music\u2026</p>';
|
|
2217
|
+
try {
|
|
2218
|
+
const resp = await fetch('/plugin/shoppe/${tenant.uuid}/music/feed');
|
|
2219
|
+
const data = await resp.json();
|
|
2220
|
+
_musicAlbums = data.albums || [];
|
|
2221
|
+
_musicTracks = data.tracks || [];
|
|
2222
|
+
_musicAllTracks = [
|
|
2223
|
+
..._musicAlbums.flatMap(a => a.tracks.map(t => ({ ...t, cover: a.cover, albumName: a.name }))),
|
|
2224
|
+
..._musicTracks.map(t => ({ ...t, albumName: '' }))
|
|
2225
|
+
];
|
|
2226
|
+
musicRenderGrid();
|
|
2227
|
+
} catch (e) {
|
|
2228
|
+
grid.innerHTML = '<p class="empty">Could not load music.</p>';
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
function musicRenderGrid() {
|
|
2233
|
+
const grid = document.getElementById('music-album-grid');
|
|
2234
|
+
if (_musicAlbums.length === 0 && _musicTracks.length === 0) {
|
|
2235
|
+
grid.innerHTML = '<p class="empty">No music yet.</p>';
|
|
2236
|
+
return;
|
|
2237
|
+
}
|
|
2238
|
+
const albumsHtml = _musicAlbums.length ? \`<div class="music-grid">\${_musicAlbums.map((a, i) => \`
|
|
2239
|
+
<div class="card music-album-card" onclick="musicShowAlbum(\${i})">
|
|
2240
|
+
\${a.cover ? \`<div class="card-img"><img src="\${_escHtml(a.cover)}" alt="" loading="lazy"></div>\` : '<div class="card-img-placeholder">🎵</div>'}
|
|
2241
|
+
<div class="card-body">
|
|
2242
|
+
<div class="card-title">\${_escHtml(a.name)}</div>
|
|
2243
|
+
<div class="card-desc">\${a.tracks.length} track\${a.tracks.length !== 1 ? 's' : ''}</div>
|
|
2244
|
+
</div>
|
|
2245
|
+
</div>\`).join('')}</div>\` : '';
|
|
2246
|
+
const tracksHtml = _musicTracks.length ? \`
|
|
2247
|
+
<div class="music-singles-label">Singles</div>
|
|
2248
|
+
\${_musicTracks.map((t, i) => \`
|
|
2249
|
+
<div class="music-track-row" id="mts-\${i}" onclick="musicPlayStandalone(\${i})">
|
|
2250
|
+
\${t.cover ? \`<img class="music-track-cover" src="\${_escHtml(t.cover)}" alt="">\` : '<div class="music-track-cover-ph">🎵</div>'}
|
|
2251
|
+
<div class="music-track-info"><div class="music-track-title">\${_escHtml(t.title)}</div></div>
|
|
2252
|
+
<div class="music-play-icon">▶</div>
|
|
2253
|
+
</div>\`).join('')}\` : '';
|
|
2254
|
+
grid.innerHTML = albumsHtml + tracksHtml;
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
function musicShowAlbum(idx) {
|
|
2258
|
+
const a = _musicAlbums[idx];
|
|
2259
|
+
document.getElementById('music-album-grid').style.display = 'none';
|
|
2260
|
+
const det = document.getElementById('music-album-detail');
|
|
2261
|
+
det.style.display = 'block';
|
|
2262
|
+
document.getElementById('music-detail-header').innerHTML = \`
|
|
2263
|
+
<div class="music-detail-header">
|
|
2264
|
+
\${a.cover ? \`<img class="music-detail-cover" src="\${_escHtml(a.cover)}" alt="">\` : ''}
|
|
2265
|
+
<div>
|
|
2266
|
+
<div class="music-detail-title">\${_escHtml(a.name)}</div>
|
|
2267
|
+
\${a.description ? \`<div class="music-detail-desc">\${_escHtml(a.description)}</div>\` : ''}
|
|
2268
|
+
</div>
|
|
2269
|
+
</div>\`;
|
|
2270
|
+
document.getElementById('music-track-list').innerHTML = a.tracks.map((t, i) => \`
|
|
2271
|
+
<div class="music-track-row" id="mta-\${idx}-\${i}" onclick="musicPlayAlbumTrack(\${idx},\${i})">
|
|
2272
|
+
<div class="music-track-num">\${t.number}</div>
|
|
2273
|
+
<div class="music-track-info"><div class="music-track-title">\${_escHtml(t.title)}</div></div>
|
|
2274
|
+
<div class="music-play-icon">▶</div>
|
|
2275
|
+
</div>\`).join('');
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
function musicShowGrid() {
|
|
2279
|
+
document.getElementById('music-album-grid').style.display = '';
|
|
2280
|
+
document.getElementById('music-album-detail').style.display = 'none';
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
function musicPlayAlbumTrack(albumIdx, trackIdx) {
|
|
2284
|
+
const a = _musicAlbums[albumIdx], t = a.tracks[trackIdx];
|
|
2285
|
+
_musicCurrentIdx = _musicAllTracks.findIndex(x => x.src === t.src);
|
|
2286
|
+
_musicDoPlay({ ...t, cover: a.cover, albumName: a.name });
|
|
2287
|
+
document.querySelectorAll('[id^="mta-"]').forEach(el => el.classList.remove('playing'));
|
|
2288
|
+
const el = document.getElementById(\`mta-\${albumIdx}-\${trackIdx}\`);
|
|
2289
|
+
if (el) el.classList.add('playing');
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
function musicPlayStandalone(idx) {
|
|
2293
|
+
const t = _musicTracks[idx];
|
|
2294
|
+
_musicCurrentIdx = _musicAllTracks.findIndex(x => x.src === t.src);
|
|
2295
|
+
_musicDoPlay(t);
|
|
2296
|
+
document.querySelectorAll('[id^="mts-"]').forEach(el => el.classList.remove('playing'));
|
|
2297
|
+
const el = document.getElementById(\`mts-\${idx}\`);
|
|
2298
|
+
if (el) el.classList.add('playing');
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
function _musicDoPlay(track) {
|
|
2302
|
+
_musicAudio.src = track.src;
|
|
2303
|
+
_musicAudio.play();
|
|
2304
|
+
const bar = document.getElementById('music-player-bar');
|
|
2305
|
+
bar.style.display = 'block';
|
|
2306
|
+
document.getElementById('music-bar-title').textContent = track.title;
|
|
2307
|
+
document.getElementById('music-bar-album').textContent = track.albumName || '';
|
|
2308
|
+
const art = document.getElementById('music-bar-art');
|
|
2309
|
+
if (track.cover) { art.src = track.cover; art.style.display = 'block'; }
|
|
2310
|
+
else art.style.display = 'none';
|
|
2311
|
+
document.getElementById('music-bar-play').innerHTML = '▮▮';
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
_musicAudio.addEventListener('ended', () => {
|
|
2315
|
+
if (_musicCurrentIdx < _musicAllTracks.length - 1) {
|
|
2316
|
+
_musicCurrentIdx++;
|
|
2317
|
+
_musicDoPlay(_musicAllTracks[_musicCurrentIdx]);
|
|
2318
|
+
} else {
|
|
2319
|
+
document.getElementById('music-bar-play').innerHTML = '▶';
|
|
2320
|
+
}
|
|
2321
|
+
});
|
|
2322
|
+
_musicAudio.addEventListener('timeupdate', () => {
|
|
2323
|
+
if (!_musicAudio.duration) return;
|
|
2324
|
+
document.getElementById('music-bar-fill').style.width = (_musicAudio.currentTime / _musicAudio.duration * 100) + '%';
|
|
2325
|
+
document.getElementById('music-bar-time').textContent = _musicFmt(_musicAudio.currentTime);
|
|
2326
|
+
});
|
|
2327
|
+
_musicAudio.addEventListener('loadedmetadata', () => {
|
|
2328
|
+
document.getElementById('music-bar-dur').textContent = _musicFmt(_musicAudio.duration);
|
|
2329
|
+
});
|
|
2330
|
+
|
|
2331
|
+
function _musicFmt(s) {
|
|
2332
|
+
if (!s || isNaN(s)) return '0:00';
|
|
2333
|
+
return Math.floor(s / 60) + ':' + String(Math.floor(s % 60)).padStart(2, '0');
|
|
2334
|
+
}
|
|
2335
|
+
function musicBarPlayPause() {
|
|
2336
|
+
if (_musicAudio.paused) { _musicAudio.play(); document.getElementById('music-bar-play').innerHTML = '▮▮'; }
|
|
2337
|
+
else { _musicAudio.pause(); document.getElementById('music-bar-play').innerHTML = '▶'; }
|
|
2338
|
+
}
|
|
2339
|
+
function musicBarPrev() {
|
|
2340
|
+
if (_musicCurrentIdx > 0) { _musicCurrentIdx--; _musicDoPlay(_musicAllTracks[_musicCurrentIdx]); }
|
|
2341
|
+
}
|
|
2342
|
+
function musicBarNext() {
|
|
2343
|
+
if (_musicCurrentIdx < _musicAllTracks.length - 1) { _musicCurrentIdx++; _musicDoPlay(_musicAllTracks[_musicCurrentIdx]); }
|
|
2344
|
+
}
|
|
2345
|
+
function musicBarSeek(e) {
|
|
2346
|
+
const r = e.currentTarget.getBoundingClientRect();
|
|
2347
|
+
_musicAudio.currentTime = _musicAudio.duration * ((e.clientX - r.left) / r.width);
|
|
1787
2348
|
}
|
|
1788
2349
|
function playVideo(url) {
|
|
1789
2350
|
document.getElementById('video-iframe').src = url;
|
|
@@ -1795,6 +2356,396 @@ function generateShoppeHTML(tenant, goods, uploadAuth = null) {
|
|
|
1795
2356
|
}
|
|
1796
2357
|
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeVideo(); });
|
|
1797
2358
|
|
|
2359
|
+
// ── Inline subscription tiers ────────────────────────────────────────────
|
|
2360
|
+
const _subsData = ${JSON.stringify(goods.subscriptions)};
|
|
2361
|
+
let _subsLoaded = false;
|
|
2362
|
+
|
|
2363
|
+
function initSubscriptions() {
|
|
2364
|
+
_subsLoaded = true;
|
|
2365
|
+
renderSubTiers();
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
function renderSubTiers() {
|
|
2369
|
+
const container = document.getElementById('subscriptions-list');
|
|
2370
|
+
if (!_subsData.length) { container.innerHTML = '<p class="empty">No subscription tiers yet.</p>'; return; }
|
|
2371
|
+
container.innerHTML = _subsData.map((tier, i) => {
|
|
2372
|
+
const fmtPrice = (tier.price / 100).toFixed(2);
|
|
2373
|
+
const benefitsHtml = (tier.benefits && tier.benefits.length)
|
|
2374
|
+
? \`<ul class="sub-benefits">\${tier.benefits.map(b => \`<li>\${_escHtml(b)}</li>\`).join('')}</ul>\`
|
|
2375
|
+
: '';
|
|
2376
|
+
return \`
|
|
2377
|
+
<div class="sub-tier-card">
|
|
2378
|
+
<div class="sub-tier-header">
|
|
2379
|
+
\${tier.image ? \`<img class="sub-tier-img" src="\${_escHtml(tier.image)}" alt="">\` : '<div class="sub-tier-img-ph">🎁</div>'}
|
|
2380
|
+
<div class="sub-tier-info">
|
|
2381
|
+
<div class="sub-tier-name">\${_escHtml(tier.title)}</div>
|
|
2382
|
+
\${tier.description ? \`<div class="sub-tier-desc">\${_escHtml(tier.description)}</div>\` : ''}
|
|
2383
|
+
<div class="sub-tier-price">$\${fmtPrice}<span>/ \${tier.renewalDays || 30} days</span></div>
|
|
2384
|
+
\${benefitsHtml}
|
|
2385
|
+
<button class="sub-btn" onclick="subToggle(\${i})">Subscribe →</button>
|
|
2386
|
+
</div>
|
|
2387
|
+
</div>
|
|
2388
|
+
<div class="sub-form-panel" id="sub-panel-\${i}">
|
|
2389
|
+
<div id="sub-already-\${i}" class="sub-already" style="display:none">
|
|
2390
|
+
<strong>✅ You're already infusing!</strong>
|
|
2391
|
+
<p id="sub-already-desc-\${i}"></p>
|
|
2392
|
+
</div>
|
|
2393
|
+
<div id="sub-recovery-\${i}">
|
|
2394
|
+
<div class="sub-recovery-note">🔑 Choose a recovery key — a word or phrase only you know. You'll use it every time you want to access your membership benefits.</div>
|
|
2395
|
+
<div class="sub-field-group">
|
|
2396
|
+
<label>Recovery Key *</label>
|
|
2397
|
+
<input type="text" id="sub-rkey-\${i}" placeholder="e.g. golden-ticket-2026" autocomplete="off">
|
|
2398
|
+
</div>
|
|
2399
|
+
<button class="sub-btn" onclick="subProceed(\${i})">Continue to Payment →</button>
|
|
2400
|
+
<div id="sub-rkey-error-\${i}" class="sub-error"></div>
|
|
2401
|
+
</div>
|
|
2402
|
+
<div id="sub-payment-\${i}" style="display:none">
|
|
2403
|
+
<div style="font-size:14px;font-weight:600;margin-bottom:12px;">Complete your subscription — $\${fmtPrice} / \${tier.renewalDays || 30} days</div>
|
|
2404
|
+
<div id="sub-stripe-el-\${i}" style="margin-bottom:14px;"></div>
|
|
2405
|
+
<button class="sub-btn" id="sub-pay-btn-\${i}" onclick="subConfirm(\${i})">Pay $\${fmtPrice}</button>
|
|
2406
|
+
<div id="sub-pay-loading-\${i}" style="display:none;font-size:13px;color:#888;margin-top:8px;"></div>
|
|
2407
|
+
<div id="sub-pay-error-\${i}" class="sub-error"></div>
|
|
2408
|
+
</div>
|
|
2409
|
+
<div id="sub-confirm-\${i}" style="display:none">
|
|
2410
|
+
<div class="sub-confirm-box">
|
|
2411
|
+
<div class="icon">🎉</div>
|
|
2412
|
+
<h3>Thank you for infusing!</h3>
|
|
2413
|
+
<div class="renews" id="sub-confirm-renews-\${i}"></div>
|
|
2414
|
+
<p style="font-size:13px;color:#888;margin:8px 0 14px;">Use your recovery key at the <a href="/plugin/shoppe/${tenant.uuid}/membership" style="color:#0066cc;">membership portal</a> to access exclusive content.</p>
|
|
2415
|
+
</div>
|
|
2416
|
+
</div>
|
|
2417
|
+
</div>
|
|
2418
|
+
</div>\`;
|
|
2419
|
+
}).join('');
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
const _subStripeInst = {}, _subEls = {}, _subClientSecrets = {};
|
|
2423
|
+
|
|
2424
|
+
function subToggle(i) {
|
|
2425
|
+
const panel = document.getElementById(\`sub-panel-\${i}\`);
|
|
2426
|
+
const isOpen = panel.classList.contains('open');
|
|
2427
|
+
document.querySelectorAll('.sub-form-panel').forEach(p => p.classList.remove('open'));
|
|
2428
|
+
if (!isOpen) { panel.classList.add('open'); panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
async function subProceed(i) {
|
|
2432
|
+
const tier = _subsData[i];
|
|
2433
|
+
const recoveryKey = document.getElementById(\`sub-rkey-\${i}\`).value.trim();
|
|
2434
|
+
const errEl = document.getElementById(\`sub-rkey-error-\${i}\`);
|
|
2435
|
+
if (!recoveryKey) { errEl.textContent = 'Recovery key is required.'; errEl.style.display = 'block'; return; }
|
|
2436
|
+
errEl.style.display = 'none';
|
|
2437
|
+
try {
|
|
2438
|
+
const resp = await fetch('/plugin/shoppe/${tenant.uuid}/purchase/intent', {
|
|
2439
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
2440
|
+
body: JSON.stringify({ recoveryKey, productId: tier.productId, title: tier.title })
|
|
2441
|
+
});
|
|
2442
|
+
const data = await resp.json();
|
|
2443
|
+
if (data.alreadySubscribed) {
|
|
2444
|
+
document.getElementById(\`sub-recovery-\${i}\`).style.display = 'none';
|
|
2445
|
+
const banner = document.getElementById(\`sub-already-\${i}\`);
|
|
2446
|
+
banner.style.display = 'block';
|
|
2447
|
+
document.getElementById(\`sub-already-desc-\${i}\`).textContent = \`Your subscription is active for \${data.daysLeft} more day\${data.daysLeft !== 1 ? 's' : ''}.\`;
|
|
2448
|
+
return;
|
|
2449
|
+
}
|
|
2450
|
+
if (data.error) { errEl.textContent = data.error; errEl.style.display = 'block'; return; }
|
|
2451
|
+
document.getElementById(\`sub-recovery-\${i}\`).style.display = 'none';
|
|
2452
|
+
document.getElementById(\`sub-payment-\${i}\`).style.display = 'block';
|
|
2453
|
+
_subClientSecrets[i] = data.clientSecret;
|
|
2454
|
+
_subStripeInst[i] = Stripe(data.publishableKey);
|
|
2455
|
+
_subEls[i] = _subStripeInst[i].elements({ clientSecret: data.clientSecret });
|
|
2456
|
+
_subEls[i].create('payment').mount(\`#sub-stripe-el-\${i}\`);
|
|
2457
|
+
} catch (err) {
|
|
2458
|
+
errEl.textContent = 'Could not start checkout. Please try again.';
|
|
2459
|
+
errEl.style.display = 'block';
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
async function subConfirm(i) {
|
|
2464
|
+
const tier = _subsData[i];
|
|
2465
|
+
const payBtn = document.getElementById(\`sub-pay-btn-\${i}\`);
|
|
2466
|
+
const payLoading = document.getElementById(\`sub-pay-loading-\${i}\`);
|
|
2467
|
+
const payError = document.getElementById(\`sub-pay-error-\${i}\`);
|
|
2468
|
+
payBtn.disabled = true; payLoading.style.display = 'block'; payLoading.textContent = 'Processing…'; payError.style.display = 'none';
|
|
2469
|
+
try {
|
|
2470
|
+
const { error } = await _subStripeInst[i].confirmPayment({
|
|
2471
|
+
elements: _subEls[i], confirmParams: { return_url: window.location.href }, redirect: 'if_required'
|
|
2472
|
+
});
|
|
2473
|
+
if (error) { payError.textContent = error.message; payError.style.display = 'block'; payBtn.disabled = false; payLoading.style.display = 'none'; return; }
|
|
2474
|
+
const recoveryKey = document.getElementById(\`sub-rkey-\${i}\`).value.trim();
|
|
2475
|
+
const paymentIntentId = _subClientSecrets[i] ? _subClientSecrets[i].split('_secret_')[0] : undefined;
|
|
2476
|
+
await fetch('/plugin/shoppe/${tenant.uuid}/purchase/complete', {
|
|
2477
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
2478
|
+
body: JSON.stringify({ recoveryKey, productId: tier.productId, title: tier.title, amount: tier.price, type: 'subscription', renewalDays: tier.renewalDays || 30, paymentIntentId })
|
|
2479
|
+
});
|
|
2480
|
+
document.getElementById(\`sub-payment-\${i}\`).style.display = 'none';
|
|
2481
|
+
const conf = document.getElementById(\`sub-confirm-\${i}\`);
|
|
2482
|
+
conf.style.display = 'block';
|
|
2483
|
+
const renewsAt = new Date(Date.now() + (tier.renewalDays || 30) * 24 * 60 * 60 * 1000);
|
|
2484
|
+
document.getElementById(\`sub-confirm-renews-\${i}\`).textContent = 'Active until ' + renewsAt.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
|
|
2485
|
+
} catch (err) {
|
|
2486
|
+
payError.textContent = 'An unexpected error occurred.'; payError.style.display = 'block';
|
|
2487
|
+
payBtn.disabled = false; payLoading.style.display = 'none';
|
|
2488
|
+
}
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
// ── Inline appointment booking ────────────────────────────────────────────
|
|
2492
|
+
const _apptsData = ${JSON.stringify(goods.appointments)};
|
|
2493
|
+
let _apptsLoaded = false;
|
|
2494
|
+
const _apptState = {}; // per-appointment state: { availableDates, selectedSlot }
|
|
2495
|
+
const _apptStripe = {}, _apptElems = {}, _apptSecrets = {};
|
|
2496
|
+
|
|
2497
|
+
function initAppointments() {
|
|
2498
|
+
_apptsLoaded = true;
|
|
2499
|
+
renderAppts();
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
function renderAppts() {
|
|
2503
|
+
const container = document.getElementById('appointments-list');
|
|
2504
|
+
if (!_apptsData.length) { container.innerHTML = '<p class="empty">No appointments yet.</p>'; return; }
|
|
2505
|
+
container.innerHTML = _apptsData.map((appt, i) => {
|
|
2506
|
+
const fmtPrice = appt.price > 0 ? ('$' + (appt.price / 100).toFixed(2) + '/session') : 'Free';
|
|
2507
|
+
return \`
|
|
2508
|
+
<div class="appt-card">
|
|
2509
|
+
<div class="appt-card-header" onclick="apptToggle(\${i})">
|
|
2510
|
+
\${appt.image ? \`<img class="appt-img" src="\${_escHtml(appt.image)}" alt="">\` : '<div class="appt-img-ph">📅</div>'}
|
|
2511
|
+
<div class="appt-info">
|
|
2512
|
+
<div class="appt-name">\${_escHtml(appt.title)}</div>
|
|
2513
|
+
\${appt.description ? \`<div class="appt-desc">\${_escHtml(appt.description)}</div>\` : ''}
|
|
2514
|
+
<div class="appt-meta">
|
|
2515
|
+
<span class="appt-chip">💰 \${fmtPrice}</span>
|
|
2516
|
+
<span class="appt-chip">⏱ \${appt.duration || 60} min</span>
|
|
2517
|
+
</div>
|
|
2518
|
+
<button class="appt-book-btn">Book →</button>
|
|
2519
|
+
</div>
|
|
2520
|
+
</div>
|
|
2521
|
+
<div class="appt-booking-panel" id="appt-panel-\${i}">
|
|
2522
|
+
<div id="appt-step-dates-\${i}">
|
|
2523
|
+
<h3 style="font-size:15px;font-weight:600;margin-bottom:12px;">Choose a date</h3>
|
|
2524
|
+
<div id="appt-date-strip-\${i}" class="appt-date-strip"></div>
|
|
2525
|
+
<div id="appt-loading-\${i}" style="font-size:13px;color:#888;">Loading availability…</div>
|
|
2526
|
+
<div id="appt-no-slots-\${i}" style="display:none;font-size:13px;color:#888;">No upcoming availability.</div>
|
|
2527
|
+
</div>
|
|
2528
|
+
<div id="appt-step-slots-\${i}" style="display:none">
|
|
2529
|
+
<h3 id="appt-slot-heading-\${i}" style="font-size:15px;font-weight:600;margin-bottom:10px;">Available times</h3>
|
|
2530
|
+
<div id="appt-slot-grid-\${i}" class="appt-slot-grid"></div>
|
|
2531
|
+
</div>
|
|
2532
|
+
<div id="appt-form-\${i}" style="display:none">
|
|
2533
|
+
<div id="appt-slot-display-\${i}" class="appt-selected-slot"></div>
|
|
2534
|
+
<div class="sub-recovery-note">🔑 Choose a recovery key — you'll use it to look up your booking later.</div>
|
|
2535
|
+
<div class="sub-field-group"><label>Recovery Key *</label><input type="text" id="appt-rkey-\${i}" placeholder="e.g. sunflower-2026" autocomplete="off"></div>
|
|
2536
|
+
<div class="sub-field-group"><label>Your Name *</label><input type="text" id="appt-name-\${i}" placeholder="Full name"></div>
|
|
2537
|
+
<div class="sub-field-group"><label>Email *</label><input type="email" id="appt-email-\${i}" placeholder="For booking confirmation"></div>
|
|
2538
|
+
<div style="display:flex;gap:10px;margin-top:6px;flex-wrap:wrap;">
|
|
2539
|
+
<button class="appt-back-btn" onclick="apptBackToSlots(\${i})">← Change time</button>
|
|
2540
|
+
<button class="appt-book-btn" id="appt-proceed-btn-\${i}" onclick="apptProceed(\${i})">\${appt.price === 0 ? 'Confirm Booking →' : 'Continue to Payment →'}</button>
|
|
2541
|
+
</div>
|
|
2542
|
+
<div id="appt-form-error-\${i}" class="sub-error"></div>
|
|
2543
|
+
</div>
|
|
2544
|
+
<div id="appt-payment-\${i}" style="display:none">
|
|
2545
|
+
<div id="appt-slot-display-pay-\${i}" class="appt-selected-slot" style="margin-bottom:14px;"></div>
|
|
2546
|
+
<div id="appt-stripe-el-\${i}" style="margin-bottom:14px;"></div>
|
|
2547
|
+
<button class="appt-book-btn" id="appt-pay-btn-\${i}" onclick="apptConfirmPayment(\${i})">Pay $\${(appt.price/100).toFixed(2)}</button>
|
|
2548
|
+
<div id="appt-pay-loading-\${i}" style="display:none;font-size:13px;color:#888;margin-top:8px;"></div>
|
|
2549
|
+
<div id="appt-pay-error-\${i}" class="sub-error"></div>
|
|
2550
|
+
</div>
|
|
2551
|
+
<div id="appt-confirm-\${i}" style="display:none">
|
|
2552
|
+
<div class="appt-confirm-box">
|
|
2553
|
+
<div class="icon">✅</div>
|
|
2554
|
+
<h3>You're booked!</h3>
|
|
2555
|
+
<div class="slot-label" id="appt-confirm-slot-\${i}"></div>
|
|
2556
|
+
<p style="font-size:12px;color:#888;margin-top:8px;">Keep your recovery key safe — it's how you look up this booking.</p>
|
|
2557
|
+
</div>
|
|
2558
|
+
</div>
|
|
2559
|
+
</div>
|
|
2560
|
+
</div>\`;
|
|
2561
|
+
}).join('');
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
function apptToggle(i) {
|
|
2565
|
+
const panel = document.getElementById(\`appt-panel-\${i}\`);
|
|
2566
|
+
const isOpen = panel.classList.contains('open');
|
|
2567
|
+
document.querySelectorAll('.appt-booking-panel').forEach(p => p.classList.remove('open'));
|
|
2568
|
+
if (!isOpen) {
|
|
2569
|
+
panel.classList.add('open');
|
|
2570
|
+
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
2571
|
+
if (!_apptState[i]) { _apptState[i] = {}; apptLoadSlots(i); }
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
async function apptLoadSlots(i) {
|
|
2576
|
+
const appt = _apptsData[i];
|
|
2577
|
+
const loadingEl = document.getElementById(\`appt-loading-\${i}\`);
|
|
2578
|
+
const noSlotsEl = document.getElementById(\`appt-no-slots-\${i}\`);
|
|
2579
|
+
try {
|
|
2580
|
+
const resp = await fetch('/plugin/shoppe/${tenant.uuid}/book/' + encodeURIComponent(appt.title) + '/slots');
|
|
2581
|
+
const data = await resp.json();
|
|
2582
|
+
loadingEl.style.display = 'none';
|
|
2583
|
+
if (!data.available || !data.available.length) { noSlotsEl.style.display = 'block'; return; }
|
|
2584
|
+
_apptState[i].availableDates = data.available;
|
|
2585
|
+
apptRenderDateStrip(i);
|
|
2586
|
+
apptSelectDate(i, data.available[0]);
|
|
2587
|
+
} catch (err) {
|
|
2588
|
+
loadingEl.textContent = 'Could not load availability.';
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
function apptRenderDateStrip(i) {
|
|
2593
|
+
const strip = document.getElementById(\`appt-date-strip-\${i}\`);
|
|
2594
|
+
strip.innerHTML = '';
|
|
2595
|
+
(_apptState[i].availableDates || []).forEach((d, di) => {
|
|
2596
|
+
const parts = d.date.split('-');
|
|
2597
|
+
const dateObj = new Date(+parts[0], +parts[1] - 1, +parts[2]);
|
|
2598
|
+
const dow = dateObj.toLocaleDateString('en-US', { weekday: 'short' });
|
|
2599
|
+
const mon = dateObj.toLocaleDateString('en-US', { month: 'short' });
|
|
2600
|
+
const card = document.createElement('div');
|
|
2601
|
+
card.className = 'appt-date-card';
|
|
2602
|
+
card.innerHTML = \`<div class="dow">\${dow}</div><div class="dom">\${dateObj.getDate()}</div><div class="mon">\${mon}</div>\`;
|
|
2603
|
+
card.addEventListener('click', () => apptSelectDate(i, d));
|
|
2604
|
+
strip.appendChild(card);
|
|
2605
|
+
});
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
function apptSelectDate(i, dateData) {
|
|
2609
|
+
_apptState[i].selectedDate = dateData;
|
|
2610
|
+
_apptState[i].selectedSlot = null;
|
|
2611
|
+
document.querySelectorAll(\`#appt-date-strip-\${i} .appt-date-card\`).forEach((c, di) => {
|
|
2612
|
+
c.classList.toggle('active', (_apptState[i].availableDates || [])[di] === dateData);
|
|
2613
|
+
});
|
|
2614
|
+
const slotsDiv = document.getElementById(\`appt-step-slots-\${i}\`);
|
|
2615
|
+
slotsDiv.style.display = 'block';
|
|
2616
|
+
document.getElementById(\`appt-slot-heading-\${i}\`).textContent = 'Times on ' + dateData.dayLabel;
|
|
2617
|
+
apptRenderSlots(i, dateData.slots);
|
|
2618
|
+
document.getElementById(\`appt-form-\${i}\`).style.display = 'none';
|
|
2619
|
+
document.getElementById(\`appt-payment-\${i}\`).style.display = 'none';
|
|
2620
|
+
}
|
|
2621
|
+
|
|
2622
|
+
function apptRenderSlots(i, slots) {
|
|
2623
|
+
const grid = document.getElementById(\`appt-slot-grid-\${i}\`);
|
|
2624
|
+
grid.innerHTML = '';
|
|
2625
|
+
slots.forEach(slotStr => {
|
|
2626
|
+
const [, time] = slotStr.split('T');
|
|
2627
|
+
const [h, m] = time.split(':').map(Number);
|
|
2628
|
+
const ampm = h >= 12 ? 'PM' : 'AM';
|
|
2629
|
+
const h12 = h % 12 || 12;
|
|
2630
|
+
const label = h12 + ':' + String(m).padStart(2, '0') + ' ' + ampm;
|
|
2631
|
+
const btn = document.createElement('button');
|
|
2632
|
+
btn.className = 'appt-slot-btn';
|
|
2633
|
+
btn.textContent = label;
|
|
2634
|
+
btn.addEventListener('click', () => apptSelectSlot(i, slotStr, label));
|
|
2635
|
+
grid.appendChild(btn);
|
|
2636
|
+
});
|
|
2637
|
+
}
|
|
2638
|
+
|
|
2639
|
+
function apptSelectSlot(i, slotStr, label) {
|
|
2640
|
+
_apptState[i].selectedSlot = slotStr;
|
|
2641
|
+
document.querySelectorAll(\`#appt-slot-grid-\${i} .appt-slot-btn\`).forEach(b => b.classList.remove('active'));
|
|
2642
|
+
event.currentTarget.classList.add('active');
|
|
2643
|
+
const display = apptFormatSlot(i, slotStr);
|
|
2644
|
+
document.getElementById(\`appt-slot-display-\${i}\`).textContent = '📅 ' + display;
|
|
2645
|
+
const formDiv = document.getElementById(\`appt-form-\${i}\`);
|
|
2646
|
+
formDiv.style.display = 'block';
|
|
2647
|
+
formDiv.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2650
|
+
function apptFormatSlot(i, slotStr) {
|
|
2651
|
+
const appt = _apptsData[i];
|
|
2652
|
+
const [datePart, timePart] = slotStr.split('T');
|
|
2653
|
+
const [y, mo, d] = datePart.split('-').map(Number);
|
|
2654
|
+
const [h, m] = timePart.split(':').map(Number);
|
|
2655
|
+
const dateObj = new Date(y, mo - 1, d);
|
|
2656
|
+
const dateLabel = dateObj.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' });
|
|
2657
|
+
const ampm = h >= 12 ? 'PM' : 'AM';
|
|
2658
|
+
const h12 = h % 12 || 12;
|
|
2659
|
+
return dateLabel + ' at ' + h12 + ':' + String(m).padStart(2,'0') + ' ' + ampm + ' ' + (appt.timezone || '');
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
function apptBackToSlots(i) {
|
|
2663
|
+
_apptState[i].selectedSlot = null;
|
|
2664
|
+
document.getElementById(\`appt-form-\${i}\`).style.display = 'none';
|
|
2665
|
+
document.getElementById(\`appt-payment-\${i}\`).style.display = 'none';
|
|
2666
|
+
document.querySelectorAll(\`#appt-slot-grid-\${i} .appt-slot-btn\`).forEach(b => b.classList.remove('active'));
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
async function apptProceed(i) {
|
|
2670
|
+
const appt = _apptsData[i];
|
|
2671
|
+
const recoveryKey = document.getElementById(\`appt-rkey-\${i}\`).value.trim();
|
|
2672
|
+
const name = document.getElementById(\`appt-name-\${i}\`).value.trim();
|
|
2673
|
+
const email = document.getElementById(\`appt-email-\${i}\`).value.trim();
|
|
2674
|
+
const errEl = document.getElementById(\`appt-form-error-\${i}\`);
|
|
2675
|
+
const selectedSlot = _apptState[i] && _apptState[i].selectedSlot;
|
|
2676
|
+
if (!recoveryKey) { errEl.textContent = 'Recovery key is required.'; errEl.style.display = 'block'; return; }
|
|
2677
|
+
if (!name) { errEl.textContent = 'Name is required.'; errEl.style.display = 'block'; return; }
|
|
2678
|
+
if (!email) { errEl.textContent = 'Email is required.'; errEl.style.display = 'block'; return; }
|
|
2679
|
+
if (!selectedSlot) { errEl.textContent = 'Please select a time slot.'; errEl.style.display = 'block'; return; }
|
|
2680
|
+
errEl.style.display = 'none';
|
|
2681
|
+
document.getElementById(\`appt-proceed-btn-\${i}\`).disabled = true;
|
|
2682
|
+
try {
|
|
2683
|
+
const resp = await fetch('/plugin/shoppe/${tenant.uuid}/purchase/intent', {
|
|
2684
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
2685
|
+
body: JSON.stringify({ recoveryKey, productId: appt.productId, title: appt.title, slotDatetime: selectedSlot })
|
|
2686
|
+
});
|
|
2687
|
+
const data = await resp.json();
|
|
2688
|
+
if (data.error) { errEl.textContent = data.error; errEl.style.display = 'block'; document.getElementById(\`appt-proceed-btn-\${i}\`).disabled = false; return; }
|
|
2689
|
+
if (data.free) {
|
|
2690
|
+
await fetch('/plugin/shoppe/${tenant.uuid}/purchase/complete', {
|
|
2691
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
2692
|
+
body: JSON.stringify({ recoveryKey, productId: appt.productId, title: appt.title, slotDatetime: selectedSlot, contactInfo: { name, email } })
|
|
2693
|
+
});
|
|
2694
|
+
document.getElementById(\`appt-form-\${i}\`).style.display = 'none';
|
|
2695
|
+
apptShowConfirm(i, selectedSlot);
|
|
2696
|
+
return;
|
|
2697
|
+
}
|
|
2698
|
+
document.getElementById(\`appt-form-\${i}\`).style.display = 'none';
|
|
2699
|
+
const payDiv = document.getElementById(\`appt-payment-\${i}\`);
|
|
2700
|
+
payDiv.style.display = 'block';
|
|
2701
|
+
document.getElementById(\`appt-slot-display-pay-\${i}\`).textContent = '📅 ' + apptFormatSlot(i, selectedSlot);
|
|
2702
|
+
_apptSecrets[i] = data.clientSecret;
|
|
2703
|
+
_apptStripe[i] = Stripe(data.publishableKey);
|
|
2704
|
+
_apptElems[i] = _apptStripe[i].elements({ clientSecret: data.clientSecret });
|
|
2705
|
+
_apptElems[i].create('payment').mount(\`#appt-stripe-el-\${i}\`);
|
|
2706
|
+
payDiv.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
2707
|
+
} catch (err) {
|
|
2708
|
+
errEl.textContent = 'Could not start checkout. Please try again.';
|
|
2709
|
+
errEl.style.display = 'block';
|
|
2710
|
+
document.getElementById(\`appt-proceed-btn-\${i}\`).disabled = false;
|
|
2711
|
+
}
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
async function apptConfirmPayment(i) {
|
|
2715
|
+
const appt = _apptsData[i];
|
|
2716
|
+
const payBtn = document.getElementById(\`appt-pay-btn-\${i}\`);
|
|
2717
|
+
const payLoading = document.getElementById(\`appt-pay-loading-\${i}\`);
|
|
2718
|
+
const payError = document.getElementById(\`appt-pay-error-\${i}\`);
|
|
2719
|
+
payBtn.disabled = true; payLoading.style.display = 'block'; payLoading.textContent = 'Processing…'; payError.style.display = 'none';
|
|
2720
|
+
try {
|
|
2721
|
+
const { error } = await _apptStripe[i].confirmPayment({
|
|
2722
|
+
elements: _apptElems[i], confirmParams: { return_url: window.location.href }, redirect: 'if_required'
|
|
2723
|
+
});
|
|
2724
|
+
if (error) { payError.textContent = error.message; payError.style.display = 'block'; payBtn.disabled = false; payLoading.style.display = 'none'; return; }
|
|
2725
|
+
const recoveryKey = document.getElementById(\`appt-rkey-\${i}\`).value.trim();
|
|
2726
|
+
const name = document.getElementById(\`appt-name-\${i}\`).value.trim();
|
|
2727
|
+
const email = document.getElementById(\`appt-email-\${i}\`).value.trim();
|
|
2728
|
+
const selectedSlot = _apptState[i] && _apptState[i].selectedSlot;
|
|
2729
|
+
const paymentIntentId = _apptSecrets[i] ? _apptSecrets[i].split('_secret_')[0] : undefined;
|
|
2730
|
+
await fetch('/plugin/shoppe/${tenant.uuid}/purchase/complete', {
|
|
2731
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
2732
|
+
body: JSON.stringify({ recoveryKey, productId: appt.productId, title: appt.title, slotDatetime: selectedSlot, contactInfo: { name, email }, paymentIntentId })
|
|
2733
|
+
});
|
|
2734
|
+
document.getElementById(\`appt-payment-\${i}\`).style.display = 'none';
|
|
2735
|
+
apptShowConfirm(i, selectedSlot);
|
|
2736
|
+
} catch (err) {
|
|
2737
|
+
payError.textContent = 'An unexpected error occurred.'; payError.style.display = 'block';
|
|
2738
|
+
payBtn.disabled = false; payLoading.style.display = 'none';
|
|
2739
|
+
}
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
function apptShowConfirm(i, slotStr) {
|
|
2743
|
+
const conf = document.getElementById(\`appt-confirm-\${i}\`);
|
|
2744
|
+
conf.style.display = 'block';
|
|
2745
|
+
document.getElementById(\`appt-confirm-slot-\${i}\`).textContent = apptFormatSlot(i, slotStr);
|
|
2746
|
+
conf.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
2747
|
+
}
|
|
2748
|
+
|
|
1798
2749
|
async function startVideoUpload(input, shoppeId, title) {
|
|
1799
2750
|
const file = input.files[0];
|
|
1800
2751
|
if (!file) return;
|
|
@@ -2019,22 +2970,53 @@ async function startServer(params) {
|
|
|
2019
2970
|
});
|
|
2020
2971
|
|
|
2021
2972
|
// Upload goods archive (auth via manifest uuid+emojicode)
|
|
2022
|
-
app.post('/plugin/shoppe/upload', upload.single('archive'),
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
return res.status(400).json({ success: false, error: 'No archive uploaded' });
|
|
2026
|
-
}
|
|
2027
|
-
console.log('[shoppe] Processing archive:', req.file.originalname);
|
|
2028
|
-
const result = await processArchive(req.file.path);
|
|
2029
|
-
res.json({ success: true, ...result });
|
|
2030
|
-
} catch (err) {
|
|
2031
|
-
console.error('[shoppe] upload error:', err);
|
|
2032
|
-
res.status(500).json({ success: false, error: err.message });
|
|
2033
|
-
} finally {
|
|
2034
|
-
if (req.file && fs.existsSync(req.file.path)) {
|
|
2035
|
-
try { fs.unlinkSync(req.file.path); } catch (e) {}
|
|
2036
|
-
}
|
|
2973
|
+
app.post('/plugin/shoppe/upload', upload.single('archive'), (req, res) => {
|
|
2974
|
+
if (!req.file) {
|
|
2975
|
+
return res.status(400).json({ success: false, error: 'No archive uploaded' });
|
|
2037
2976
|
}
|
|
2977
|
+
|
|
2978
|
+
const jobId = crypto.randomBytes(8).toString('hex');
|
|
2979
|
+
const job = { sse: null, queue: [], done: false };
|
|
2980
|
+
uploadJobs.set(jobId, job);
|
|
2981
|
+
setTimeout(() => uploadJobs.delete(jobId), 15 * 60 * 1000); // clean up after 15 min
|
|
2982
|
+
|
|
2983
|
+
res.json({ success: true, jobId });
|
|
2984
|
+
|
|
2985
|
+
function emit(type, data) {
|
|
2986
|
+
job.queue.push({ type, data });
|
|
2987
|
+
if (job.sse) job.sse.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
2988
|
+
}
|
|
2989
|
+
|
|
2990
|
+
const zipPath = req.file.path;
|
|
2991
|
+
console.log('[shoppe] Processing archive:', req.file.originalname);
|
|
2992
|
+
processArchive(zipPath, emit)
|
|
2993
|
+
.then(result => emit('complete', { success: true, ...result }))
|
|
2994
|
+
.catch(err => { console.error('[shoppe] upload error:', err); emit('error', { message: err.message }); })
|
|
2995
|
+
.finally(() => {
|
|
2996
|
+
job.done = true;
|
|
2997
|
+
if (job.sse) { job.sse.end(); job.sse = null; }
|
|
2998
|
+
if (fs.existsSync(zipPath)) try { fs.unlinkSync(zipPath); } catch (e) {}
|
|
2999
|
+
});
|
|
3000
|
+
});
|
|
3001
|
+
|
|
3002
|
+
app.get('/plugin/shoppe/upload/progress/:jobId', (req, res) => {
|
|
3003
|
+
const job = uploadJobs.get(req.params.jobId);
|
|
3004
|
+
if (!job) return res.status(404).json({ error: 'Unknown job' });
|
|
3005
|
+
|
|
3006
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
3007
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
3008
|
+
res.setHeader('Connection', 'keep-alive');
|
|
3009
|
+
res.flushHeaders();
|
|
3010
|
+
|
|
3011
|
+
// Replay buffered events for late-connecting clients.
|
|
3012
|
+
for (const evt of job.queue) {
|
|
3013
|
+
res.write(`event: ${evt.type}\ndata: ${JSON.stringify(evt.data)}\n\n`);
|
|
3014
|
+
}
|
|
3015
|
+
|
|
3016
|
+
if (job.done) { res.end(); return; }
|
|
3017
|
+
|
|
3018
|
+
job.sse = res;
|
|
3019
|
+
req.on('close', () => { if (job.sse === res) job.sse = null; });
|
|
2038
3020
|
});
|
|
2039
3021
|
|
|
2040
3022
|
// Get config (owner only)
|
|
@@ -2723,6 +3705,38 @@ async function startServer(params) {
|
|
|
2723
3705
|
}
|
|
2724
3706
|
});
|
|
2725
3707
|
|
|
3708
|
+
// Music feed — adapts Sanora Canimus feed to { albums, tracks }
|
|
3709
|
+
app.get('/plugin/shoppe/:identifier/music/feed', async (req, res) => {
|
|
3710
|
+
try {
|
|
3711
|
+
const tenant = getTenantByIdentifier(req.params.identifier);
|
|
3712
|
+
if (!tenant) return res.status(404).json({ error: 'Shoppe not found' });
|
|
3713
|
+
const feedResp = await fetchWithRetry(`${getSanoraUrl()}/feeds/music/${tenant.uuid}`, { timeout: 10000 });
|
|
3714
|
+
if (!feedResp.ok) return res.status(502).json({ error: 'Feed unavailable' });
|
|
3715
|
+
const feed = await feedResp.json();
|
|
3716
|
+
|
|
3717
|
+
const albums = [];
|
|
3718
|
+
const tracks = [];
|
|
3719
|
+
for (const item of (feed.items || [])) {
|
|
3720
|
+
const cover = (item.images && (item.images.cover?.url || item.images[0]?.url)) || null;
|
|
3721
|
+
const mediaItems = (item.media || []).filter(m => m.url);
|
|
3722
|
+
if (mediaItems.length === 0) continue;
|
|
3723
|
+
if (mediaItems.length > 1) {
|
|
3724
|
+
albums.push({
|
|
3725
|
+
name: item.name,
|
|
3726
|
+
cover,
|
|
3727
|
+
description: item.summary || '',
|
|
3728
|
+
tracks: mediaItems.map((m, i) => ({ number: i + 1, title: `Track ${i + 1}`, src: m.url, type: m.type || 'audio/mpeg' }))
|
|
3729
|
+
});
|
|
3730
|
+
} else {
|
|
3731
|
+
tracks.push({ title: item.name, src: mediaItems[0].url, cover, description: item.summary || '' });
|
|
3732
|
+
}
|
|
3733
|
+
}
|
|
3734
|
+
res.json({ albums, tracks });
|
|
3735
|
+
} catch (err) {
|
|
3736
|
+
res.status(500).json({ error: err.message });
|
|
3737
|
+
}
|
|
3738
|
+
});
|
|
3739
|
+
|
|
2726
3740
|
// Shoppe HTML page (public)
|
|
2727
3741
|
app.get('/plugin/shoppe/:identifier', async (req, res) => {
|
|
2728
3742
|
try {
|