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/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
- if (tenants[identifier]) return tenants[identifier];
452
- return Object.values(tenants).find(t => t.emojicode === identifier) || null;
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 fetch(
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 fetch(
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 fetch(
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
- async function processArchive(zipPath) {
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: #f5f5f7; color: #1d1d1f; }
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: white; border-bottom: 1px solid #ddd; padding: 0 20px; gap: 0; }
1716
- .tab { padding: 14px 18px; cursor: pointer; font-size: 14px; font-weight: 500; white-space: nowrap; border-bottom: 2px solid transparent; color: #555; transition: color 0.15s, border-color 0.15s; }
1717
- .tab:hover { color: #0066cc; }
1718
- .tab.active { color: #0066cc; border-bottom-color: #0066cc; }
1719
- .badge { background: #e8f0fe; color: #0066cc; border-radius: 10px; padding: 1px 7px; font-size: 11px; margin-left: 5px; }
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: white; border-radius: 14px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.07); cursor: pointer; transition: transform 0.18s, box-shadow 0.18s; }
1725
- .card:hover { transform: translateY(-3px); box-shadow: 0 8px 24px rgba(0,0,0,0.12); }
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: #f0f0f7; }
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: #666; margin-bottom: 8px; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
1731
- .price { font-size: 15px; font-weight: 700; color: #0066cc; }
1732
- .shipping { font-size: 12px; font-weight: 400; color: #888; }
1733
- .empty { color: #999; text-align: center; padding: 60px 0; font-size: 15px; }
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: #0066cc; color: white; border-radius: 8px; padding: 8px 16px; font-size: 13px; font-weight: 600; cursor: pointer; margin-top: 8px; }
1746
- .upload-btn-label:hover { background: #0052a3; }
1747
- .upload-progress { margin-top: 8px; font-size: 12px; color: #555; }
1748
- .upload-progress-bar { height: 4px; background: #e0e0e0; border-radius: 2px; margin-top: 4px; overflow: hidden; }
1749
- .upload-progress-bar-fill { height: 100%; background: #0066cc; border-radius: 2px; transition: width 0.2s; }
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"><div class="grid">${renderCards(goods.music, 'music')}</div></div>
1763
- <div id="posts" class="section"><div class="grid">${renderCards(goods.posts, 'post')}</div></div>
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()">&#8592; 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()">&#8592; 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"><div class="grid">${renderCards(goods.appointments, 'appointment')}</div></div>
1768
- <div id="subscriptions" class="section"><div class="grid">${renderCards(goods.subscriptions, 'subscription')}</div></div>
1769
- <div style="text-align:center;padding:24px 0 8px;font-size:14px;color:#888;">
1770
- Already infusing? <a href="/plugin/shoppe/${tenant.uuid}/membership" style="color:#0066cc;">Access your membership →</a>
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">&#9664;&#9664;</button>
2080
+ <button class="music-bar-btn" id="music-bar-play" onclick="musicBarPlayPause()" title="Play/Pause">&#9654;</button>
2081
+ <button class="music-bar-btn" onclick="musicBarNext()" title="Next">&#9654;&#9654;</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">&#8594;</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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
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">&#9654;</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">&#9654;</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 = '&#9646;&#9646;';
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 = '&#9654;';
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 = '&#9646;&#9646;'; }
2337
+ else { _musicAudio.pause(); document.getElementById('music-bar-play').innerHTML = '&#9654;'; }
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'), async (req, res) => {
2023
- try {
2024
- if (!req.file) {
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 {