wiki-plugin-shoppe 0.0.33 β 0.0.35
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 +5 -5
- package/package.json +1 -1
- package/server/server.js +945 -27
package/client/shoppe.js
CHANGED
|
@@ -375,11 +375,11 @@
|
|
|
375
375
|
const result = JSON.parse(e.data);
|
|
376
376
|
const r = result.results;
|
|
377
377
|
const counts = [
|
|
378
|
-
r.books.length
|
|
379
|
-
r.music.length
|
|
380
|
-
r.posts.length
|
|
381
|
-
r.albums.length
|
|
382
|
-
r.products.length
|
|
378
|
+
r.books && r.books.length && `π ${r.books.length} book${r.books.length !== 1 ? 's' : ''}`,
|
|
379
|
+
r.music && r.music.length && `π΅ ${r.music.length} music item${r.music.length !== 1 ? 's' : ''}`,
|
|
380
|
+
r.posts && r.posts.length && `π ${r.posts.length} post${r.posts.length !== 1 ? 's' : ''}`,
|
|
381
|
+
r.albums && r.albums.length && `πΌοΈ ${r.albums.length} album${r.albums.length !== 1 ? 's' : ''}`,
|
|
382
|
+
r.products && r.products.length && `π¦ ${r.products.length} product${r.products.length !== 1 ? 's' : ''}`,
|
|
383
383
|
r.appointments && r.appointments.length && `π
${r.appointments.length} appointment${r.appointments.length !== 1 ? 's' : ''}`,
|
|
384
384
|
r.subscriptions && r.subscriptions.length && `π ${r.subscriptions.length} subscription tier${r.subscriptions.length !== 1 ? 's' : ''}`
|
|
385
385
|
].filter(Boolean).join(' Β· ') || 'no items found';
|
package/package.json
CHANGED
package/server/server.js
CHANGED
|
@@ -859,6 +859,9 @@ async function processArchive(zipPath, onProgress = () => {}) {
|
|
|
859
859
|
if (manifest.redirects && typeof manifest.redirects === 'object') {
|
|
860
860
|
tenantUpdates.redirects = manifest.redirects;
|
|
861
861
|
}
|
|
862
|
+
if (manifest.lightMode !== undefined) {
|
|
863
|
+
tenantUpdates.lightMode = !!manifest.lightMode;
|
|
864
|
+
}
|
|
862
865
|
if (Object.keys(tenantUpdates).length > 0) {
|
|
863
866
|
const tenants = loadTenants();
|
|
864
867
|
Object.assign(tenants[tenant.uuid], tenantUpdates);
|
|
@@ -1183,8 +1186,13 @@ async function processArchive(zipPath, onProgress = () => {}) {
|
|
|
1183
1186
|
const images = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
|
|
1184
1187
|
const heroFile = images.find(f => /^hero\.(jpg|jpeg|png|webp)$/i.test(f)) || images[0];
|
|
1185
1188
|
if (heroFile) {
|
|
1186
|
-
|
|
1187
|
-
|
|
1189
|
+
try {
|
|
1190
|
+
const heroBuf = fs.readFileSync(path.join(entryPath, heroFile));
|
|
1191
|
+
await sanoraUploadImage(tenant, title, heroBuf, heroFile);
|
|
1192
|
+
} catch (imgErr) {
|
|
1193
|
+
console.warn(`[shoppe] β οΈ image upload for ${title}: ${imgErr.message}`);
|
|
1194
|
+
results.warnings.push(`Image upload for "${title}" failed: ${imgErr.message}`);
|
|
1195
|
+
}
|
|
1188
1196
|
}
|
|
1189
1197
|
|
|
1190
1198
|
results.products.push({ title, order, price, shipping });
|
|
@@ -1433,6 +1441,7 @@ async function getShoppeGoods(tenant) {
|
|
|
1433
1441
|
shipping: product.shipping || 0,
|
|
1434
1442
|
image: product.image ? `${getSanoraUrl()}/images/${product.image}` : null,
|
|
1435
1443
|
url: (bucketName && redirects[bucketName]) || defaultUrl,
|
|
1444
|
+
...(isPost && { category: product.category, tags: product.tags || '' }),
|
|
1436
1445
|
...(lucillePlayerUrl && { lucillePlayerUrl }),
|
|
1437
1446
|
...(product.category === 'video' && { shoppeId: tenant.uuid })
|
|
1438
1447
|
};
|
|
@@ -1440,6 +1449,30 @@ async function getShoppeGoods(tenant) {
|
|
|
1440
1449
|
if (bucket) bucket.push(item);
|
|
1441
1450
|
}
|
|
1442
1451
|
|
|
1452
|
+
// Enrich subscription and appointment items with artifact metadata
|
|
1453
|
+
const productsByTitle = {};
|
|
1454
|
+
for (const [key, product] of Object.entries(products)) {
|
|
1455
|
+
productsByTitle[product.title || key] = product;
|
|
1456
|
+
}
|
|
1457
|
+
await Promise.all([
|
|
1458
|
+
...goods.subscriptions.map(async item => {
|
|
1459
|
+
const product = productsByTitle[item.title];
|
|
1460
|
+
if (!product) return;
|
|
1461
|
+
item.productId = product.productId || '';
|
|
1462
|
+
const tierInfo = await getTierInfo(tenant, product).catch(() => null);
|
|
1463
|
+
item.renewalDays = tierInfo ? (tierInfo.renewalDays || 30) : 30;
|
|
1464
|
+
item.benefits = tierInfo ? (tierInfo.benefits || []) : [];
|
|
1465
|
+
}),
|
|
1466
|
+
...goods.appointments.map(async item => {
|
|
1467
|
+
const product = productsByTitle[item.title];
|
|
1468
|
+
if (!product) return;
|
|
1469
|
+
item.productId = product.productId || '';
|
|
1470
|
+
const schedule = await getAppointmentSchedule(tenant, product).catch(() => null);
|
|
1471
|
+
item.timezone = schedule ? (schedule.timezone || 'UTC') : 'UTC';
|
|
1472
|
+
item.duration = schedule ? (schedule.duration || 60) : 60;
|
|
1473
|
+
})
|
|
1474
|
+
]);
|
|
1475
|
+
|
|
1443
1476
|
return goods;
|
|
1444
1477
|
}
|
|
1445
1478
|
|
|
@@ -1786,32 +1819,86 @@ function generateShoppeHTML(tenant, goods, uploadAuth = null) {
|
|
|
1786
1819
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1787
1820
|
<title>${tenant.name}</title>
|
|
1788
1821
|
${tenant.keywords ? `<meta name="keywords" content="${escHtml(tenant.keywords)}">` : ''}
|
|
1822
|
+
<script src="https://js.stripe.com/v3/"></script>
|
|
1789
1823
|
<style>
|
|
1824
|
+
/* ββ Theme variables (dark default) ββ */
|
|
1825
|
+
:root {
|
|
1826
|
+
--bg: #0f0f12;
|
|
1827
|
+
--card-bg: #18181c;
|
|
1828
|
+
--card-bg-2: #1e1e22;
|
|
1829
|
+
--input-bg: #2a2a2e;
|
|
1830
|
+
--nav-bg: #18181c;
|
|
1831
|
+
--text: #e8e8ea;
|
|
1832
|
+
--text-2: #aaa;
|
|
1833
|
+
--text-3: #888;
|
|
1834
|
+
--accent: #7ec8e3;
|
|
1835
|
+
--border: #333;
|
|
1836
|
+
--hover-bg: #1a3040;
|
|
1837
|
+
--badge-bg: #1a3040;
|
|
1838
|
+
--placeholder: #2a2a2e;
|
|
1839
|
+
--shadow: rgba(0,0,0,0.4);
|
|
1840
|
+
--shadow-hover: rgba(0,0,0,0.65);
|
|
1841
|
+
--row-border: #2a2a2e;
|
|
1842
|
+
--progress-bg: #333;
|
|
1843
|
+
--chip-bg: #2a2a2e;
|
|
1844
|
+
--note-bg: #2a2600;
|
|
1845
|
+
--note-border: #665500;
|
|
1846
|
+
--note-text: #ccaa44;
|
|
1847
|
+
--ok-bg: #0a2a18;
|
|
1848
|
+
--ok-border: #2a7050;
|
|
1849
|
+
--ok-text: #5dd49a;
|
|
1850
|
+
}
|
|
1851
|
+
body.light {
|
|
1852
|
+
--bg: #f5f5f7;
|
|
1853
|
+
--card-bg: white;
|
|
1854
|
+
--card-bg-2: #fafafa;
|
|
1855
|
+
--input-bg: white;
|
|
1856
|
+
--nav-bg: white;
|
|
1857
|
+
--text: #1d1d1f;
|
|
1858
|
+
--text-2: #666;
|
|
1859
|
+
--text-3: #888;
|
|
1860
|
+
--accent: #0066cc;
|
|
1861
|
+
--border: #ddd;
|
|
1862
|
+
--hover-bg: #e8f0fe;
|
|
1863
|
+
--badge-bg: #e8f0fe;
|
|
1864
|
+
--placeholder: #f0f0f7;
|
|
1865
|
+
--shadow: rgba(0,0,0,0.07);
|
|
1866
|
+
--shadow-hover: rgba(0,0,0,0.12);
|
|
1867
|
+
--row-border: #f0f0f0;
|
|
1868
|
+
--progress-bg: #e0e0e0;
|
|
1869
|
+
--chip-bg: #f0f0f7;
|
|
1870
|
+
--note-bg: #fffde7;
|
|
1871
|
+
--note-border: #e0c040;
|
|
1872
|
+
--note-text: #7a6000;
|
|
1873
|
+
--ok-bg: #f0faf4;
|
|
1874
|
+
--ok-border: #48bb78;
|
|
1875
|
+
--ok-text: #276749;
|
|
1876
|
+
}
|
|
1790
1877
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
1791
|
-
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background:
|
|
1878
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); }
|
|
1792
1879
|
header { background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); color: white; padding: 48px 24px 40px; text-align: center; }
|
|
1793
1880
|
.emojicode { font-size: 30px; letter-spacing: 6px; margin-bottom: 14px; }
|
|
1794
1881
|
header h1 { font-size: 38px; font-weight: 700; margin-bottom: 6px; }
|
|
1795
1882
|
.count { opacity: 0.65; font-size: 15px; }
|
|
1796
|
-
nav { display: flex; overflow-x: auto; background:
|
|
1797
|
-
.tab { padding: 14px 18px; cursor: pointer; font-size: 14px; font-weight: 500; white-space: nowrap; border-bottom: 2px solid transparent; color:
|
|
1798
|
-
.tab:hover { color:
|
|
1799
|
-
.tab.active { color:
|
|
1800
|
-
.badge { background:
|
|
1883
|
+
nav { display: flex; overflow-x: auto; background: var(--nav-bg); border-bottom: 1px solid var(--border); padding: 0 20px; gap: 0; }
|
|
1884
|
+
.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; }
|
|
1885
|
+
.tab:hover { color: var(--accent); }
|
|
1886
|
+
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
|
1887
|
+
.badge { background: var(--badge-bg); color: var(--accent); border-radius: 10px; padding: 1px 7px; font-size: 11px; margin-left: 5px; }
|
|
1801
1888
|
main { max-width: 1200px; margin: 0 auto; padding: 36px 24px; }
|
|
1802
1889
|
.section { display: none; }
|
|
1803
1890
|
.section.active { display: block; }
|
|
1804
1891
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 20px; }
|
|
1805
|
-
.card { background:
|
|
1806
|
-
.card:hover { transform: translateY(-3px); box-shadow: 0 8px 24px
|
|
1892
|
+
.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; }
|
|
1893
|
+
.card:hover { transform: translateY(-3px); box-shadow: 0 8px 24px var(--shadow-hover); }
|
|
1807
1894
|
.card-img img { width: 100%; height: 190px; object-fit: cover; display: block; }
|
|
1808
|
-
.card-img-placeholder { height: 110px; display: flex; align-items: center; justify-content: center; font-size: 44px; background:
|
|
1895
|
+
.card-img-placeholder { height: 110px; display: flex; align-items: center; justify-content: center; font-size: 44px; background: var(--placeholder); }
|
|
1809
1896
|
.card-body { padding: 16px; }
|
|
1810
1897
|
.card-title { font-size: 15px; font-weight: 600; margin-bottom: 5px; line-height: 1.3; }
|
|
1811
|
-
.card-desc { font-size: 13px; color:
|
|
1812
|
-
.price { font-size: 15px; font-weight: 700; color:
|
|
1813
|
-
.shipping { font-size: 12px; font-weight: 400; color:
|
|
1814
|
-
.empty { color:
|
|
1898
|
+
.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; }
|
|
1899
|
+
.price { font-size: 15px; font-weight: 700; color: var(--accent); }
|
|
1900
|
+
.shipping { font-size: 12px; font-weight: 400; color: var(--text-3); }
|
|
1901
|
+
.empty { color: var(--text-3); text-align: center; padding: 60px 0; font-size: 15px; }
|
|
1815
1902
|
.card-video-play { position: relative; }
|
|
1816
1903
|
.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; }
|
|
1817
1904
|
.card:hover .card-video-play::after { opacity: 1; }
|
|
@@ -1823,14 +1910,131 @@ function generateShoppeHTML(tenant, goods, uploadAuth = null) {
|
|
|
1823
1910
|
.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; }
|
|
1824
1911
|
.video-modal-close:hover { background: rgba(0,0,0,0.8); }
|
|
1825
1912
|
.card-video-upload { cursor: default !important; }
|
|
1826
|
-
.upload-btn-label { display: inline-block; background:
|
|
1827
|
-
.upload-btn-label:hover {
|
|
1828
|
-
.upload-progress { margin-top: 8px; font-size: 12px; color:
|
|
1829
|
-
.upload-progress-bar { height: 4px; background:
|
|
1830
|
-
.upload-progress-bar-fill { height: 100%; background:
|
|
1913
|
+
.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; }
|
|
1914
|
+
.upload-btn-label:hover { opacity: 0.85; }
|
|
1915
|
+
.upload-progress { margin-top: 8px; font-size: 12px; color: var(--text-2); }
|
|
1916
|
+
.upload-progress-bar { height: 4px; background: var(--progress-bg); border-radius: 2px; margin-top: 4px; overflow: hidden; }
|
|
1917
|
+
.upload-progress-bar-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.2s; }
|
|
1918
|
+
/* ββ Posts browser ββ */
|
|
1919
|
+
.posts-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 20px; }
|
|
1920
|
+
.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); }
|
|
1921
|
+
.posts-back-btn:hover { border-color: var(--accent); color: var(--accent); }
|
|
1922
|
+
.posts-series-header { display: flex; gap: 20px; align-items: flex-start; margin-bottom: 24px; }
|
|
1923
|
+
.posts-series-cover { width: 120px; height: 120px; object-fit: cover; border-radius: 10px; flex-shrink: 0; }
|
|
1924
|
+
.posts-series-title { font-size: 24px; font-weight: 700; margin-bottom: 6px; }
|
|
1925
|
+
.posts-series-desc { font-size: 14px; color: var(--text-2); line-height: 1.5; }
|
|
1926
|
+
.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; }
|
|
1927
|
+
.posts-part-row:hover { background: var(--hover-bg); }
|
|
1928
|
+
.posts-part-num { font-size: 13px; color: var(--text-3); min-width: 28px; text-align: center; font-weight: 600; }
|
|
1929
|
+
.posts-part-info { flex: 1; min-width: 0; }
|
|
1930
|
+
.posts-part-title { font-size: 15px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
1931
|
+
.posts-part-desc { font-size: 12px; color: var(--text-3); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
1932
|
+
.posts-part-arrow { color: var(--accent); font-size: 14px; }
|
|
1933
|
+
.posts-standalones-label { font-size: 12px; font-weight: 600; color: var(--text-3); text-transform: uppercase; letter-spacing: .5px; margin: 28px 0 12px; }
|
|
1934
|
+
/* ββ Music player ββ */
|
|
1935
|
+
.music-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 20px; }
|
|
1936
|
+
.music-album-card { cursor: pointer; }
|
|
1937
|
+
.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); }
|
|
1938
|
+
.music-back-btn:hover { border-color: var(--accent); color: var(--accent); }
|
|
1939
|
+
.music-detail-header { display: flex; gap: 20px; align-items: flex-start; margin-bottom: 24px; }
|
|
1940
|
+
.music-detail-cover { width: 140px; height: 140px; object-fit: cover; border-radius: 10px; flex-shrink: 0; }
|
|
1941
|
+
.music-detail-title { font-size: 24px; font-weight: 700; margin-bottom: 6px; }
|
|
1942
|
+
.music-detail-desc { font-size: 14px; color: var(--text-2); line-height: 1.5; }
|
|
1943
|
+
.music-track-row { display: flex; align-items: center; gap: 14px; padding: 12px 16px; border-radius: 10px; cursor: pointer; transition: background 0.15s; }
|
|
1944
|
+
.music-track-row:hover, .music-track-row.playing { background: var(--hover-bg); }
|
|
1945
|
+
.music-track-row.playing .music-track-title { color: var(--accent); font-weight: 600; }
|
|
1946
|
+
.music-track-num { font-size: 14px; color: var(--text-3); min-width: 24px; text-align: center; }
|
|
1947
|
+
.music-track-cover { width: 40px; height: 40px; border-radius: 6px; object-fit: cover; flex-shrink: 0; }
|
|
1948
|
+
.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; }
|
|
1949
|
+
.music-track-info { flex: 1; min-width: 0; }
|
|
1950
|
+
.music-track-title { font-size: 14px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
1951
|
+
.music-track-meta { font-size: 12px; color: var(--text-3); }
|
|
1952
|
+
.music-play-icon { font-size: 14px; color: var(--text-3); opacity: 0; transition: opacity 0.15s; }
|
|
1953
|
+
.music-track-row:hover .music-play-icon { opacity: 1; }
|
|
1954
|
+
.music-track-row.playing .music-play-icon { opacity: 1; color: var(--accent); }
|
|
1955
|
+
.music-singles-label { font-size: 12px; font-weight: 600; color: var(--text-3); text-transform: uppercase; letter-spacing: .5px; margin: 28px 0 8px; }
|
|
1956
|
+
/* ββ Music player bar (always dark) ββ */
|
|
1957
|
+
#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; }
|
|
1958
|
+
.music-bar-inner { max-width: 1200px; margin: 0 auto; display: flex; align-items: center; gap: 16px; }
|
|
1959
|
+
.music-bar-art { width: 48px; height: 48px; border-radius: 6px; object-fit: cover; flex-shrink: 0; }
|
|
1960
|
+
.music-bar-info { flex: 1; min-width: 0; }
|
|
1961
|
+
.music-bar-title { font-size: 14px; font-weight: 600; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
1962
|
+
.music-bar-album { font-size: 12px; color: #8b5cf6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
1963
|
+
.music-bar-controls { display: flex; align-items: center; gap: 10px; }
|
|
1964
|
+
.music-bar-btn { background: none; border: none; cursor: pointer; color: #10b981; font-size: 20px; padding: 4px 8px; line-height: 1; transition: color 0.15s; }
|
|
1965
|
+
.music-bar-btn:hover { color: #fff; }
|
|
1966
|
+
.music-bar-progress { flex: 1; display: flex; align-items: center; gap: 8px; min-width: 120px; }
|
|
1967
|
+
.music-bar-time { font-size: 11px; color: #fbbf24; min-width: 36px; text-align: center; }
|
|
1968
|
+
.music-bar-track { flex: 1; height: 4px; background: rgba(139,92,246,0.3); border-radius: 2px; cursor: pointer; position: relative; }
|
|
1969
|
+
.music-bar-fill { height: 100%; background: linear-gradient(90deg, #10b981, #8b5cf6); border-radius: 2px; width: 0%; transition: width 0.1s linear; }
|
|
1970
|
+
/* ββ Inline subscription tiers ββ */
|
|
1971
|
+
.sub-tier-card { background: var(--card-bg); border-radius: 14px; overflow: hidden; box-shadow: 0 2px 8px var(--shadow); margin-bottom: 20px; }
|
|
1972
|
+
.sub-tier-header { display: flex; gap: 20px; padding: 20px; }
|
|
1973
|
+
.sub-tier-img { width: 110px; height: 110px; object-fit: cover; border-radius: 10px; flex-shrink: 0; }
|
|
1974
|
+
.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; }
|
|
1975
|
+
.sub-tier-info { flex: 1; min-width: 0; }
|
|
1976
|
+
.sub-tier-name { font-size: 19px; font-weight: 700; margin-bottom: 5px; }
|
|
1977
|
+
.sub-tier-desc { font-size: 13px; color: var(--text-2); line-height: 1.5; margin-bottom: 10px; }
|
|
1978
|
+
.sub-tier-price { display: inline-flex; align-items: baseline; gap: 5px; color: var(--accent); font-size: 20px; font-weight: 700; margin-bottom: 8px; }
|
|
1979
|
+
.sub-tier-price span { font-size: 12px; color: var(--text-3); font-weight: 400; }
|
|
1980
|
+
.sub-benefits { list-style: none; display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; }
|
|
1981
|
+
.sub-benefits li { font-size: 12px; color: var(--text-2); padding-left: 16px; position: relative; }
|
|
1982
|
+
.sub-benefits li::before { content: 'β'; position: absolute; left: 0; color: var(--accent); font-weight: 700; }
|
|
1983
|
+
.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; }
|
|
1984
|
+
.sub-btn:hover { opacity: 0.88; }
|
|
1985
|
+
.sub-btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
1986
|
+
.sub-form-panel { display: none; padding: 24px; border-top: 1px solid var(--border); background: var(--card-bg-2); }
|
|
1987
|
+
.sub-form-panel.open { display: block; }
|
|
1988
|
+
.sub-field-group { margin-bottom: 14px; }
|
|
1989
|
+
.sub-field-group label { display: block; font-size: 11px; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 5px; }
|
|
1990
|
+
.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; }
|
|
1991
|
+
.sub-field-group input:focus { border-color: var(--accent); }
|
|
1992
|
+
.sub-error { color: #ff6b6b; font-size: 13px; margin-top: 8px; display: none; }
|
|
1993
|
+
.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; }
|
|
1994
|
+
.sub-already { background: var(--ok-bg); border: 1px solid var(--ok-border); border-radius: 10px; padding: 14px 16px; margin-bottom: 14px; }
|
|
1995
|
+
.sub-already strong { color: var(--ok-text); font-size: 14px; display: block; margin-bottom: 4px; }
|
|
1996
|
+
.sub-already p { font-size: 13px; color: var(--text-2); }
|
|
1997
|
+
.sub-confirm-box { text-align: center; padding: 24px; }
|
|
1998
|
+
.sub-confirm-box .icon { font-size: 48px; margin-bottom: 10px; }
|
|
1999
|
+
.sub-confirm-box h3 { font-size: 20px; font-weight: 700; margin-bottom: 6px; }
|
|
2000
|
+
.sub-confirm-box .renews { color: var(--accent); font-size: 14px; margin-bottom: 8px; }
|
|
2001
|
+
/* ββ Inline appointment booking ββ */
|
|
2002
|
+
.appt-card { background: var(--card-bg); border-radius: 14px; overflow: hidden; box-shadow: 0 2px 8px var(--shadow); margin-bottom: 20px; }
|
|
2003
|
+
.appt-card-header { display: flex; gap: 20px; padding: 20px; cursor: pointer; }
|
|
2004
|
+
.appt-card-header:hover { background: var(--card-bg-2); }
|
|
2005
|
+
.appt-img { width: 110px; height: 110px; object-fit: cover; border-radius: 10px; flex-shrink: 0; }
|
|
2006
|
+
.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; }
|
|
2007
|
+
.appt-info { flex: 1; min-width: 0; }
|
|
2008
|
+
.appt-name { font-size: 19px; font-weight: 700; margin-bottom: 5px; }
|
|
2009
|
+
.appt-desc { font-size: 13px; color: var(--text-2); line-height: 1.5; margin-bottom: 10px; }
|
|
2010
|
+
.appt-meta { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
|
|
2011
|
+
.appt-chip { background: var(--chip-bg); border-radius: 20px; padding: 4px 12px; font-size: 12px; color: var(--text-2); }
|
|
2012
|
+
.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; }
|
|
2013
|
+
.appt-book-btn:hover { opacity: 0.88; }
|
|
2014
|
+
.appt-book-btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
2015
|
+
.appt-booking-panel { display: none; padding: 24px; border-top: 1px solid var(--border); background: var(--card-bg-2); }
|
|
2016
|
+
.appt-booking-panel.open { display: block; }
|
|
2017
|
+
.appt-date-strip { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 6px; margin-bottom: 16px; scrollbar-width: thin; }
|
|
2018
|
+
.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; }
|
|
2019
|
+
.appt-date-card:hover { border-color: var(--accent); }
|
|
2020
|
+
.appt-date-card.active { border-color: var(--accent); background: var(--hover-bg); }
|
|
2021
|
+
.appt-date-card .dow { font-size: 10px; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.5px; }
|
|
2022
|
+
.appt-date-card .dom { font-size: 20px; font-weight: 700; margin: 1px 0; color: var(--text); }
|
|
2023
|
+
.appt-date-card .mon { font-size: 10px; color: var(--text-3); }
|
|
2024
|
+
.appt-slot-grid { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; }
|
|
2025
|
+
.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; }
|
|
2026
|
+
.appt-slot-btn:hover { border-color: var(--accent); }
|
|
2027
|
+
.appt-slot-btn.active { border-color: var(--accent); background: var(--hover-bg); color: var(--accent); font-weight: 600; }
|
|
2028
|
+
.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; }
|
|
2029
|
+
.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); }
|
|
2030
|
+
.appt-back-btn:hover { border-color: var(--accent); color: var(--accent); }
|
|
2031
|
+
.appt-confirm-box { text-align: center; padding: 24px; }
|
|
2032
|
+
.appt-confirm-box .icon { font-size: 48px; margin-bottom: 10px; }
|
|
2033
|
+
.appt-confirm-box h3 { font-size: 20px; font-weight: 700; margin-bottom: 6px; }
|
|
2034
|
+
.appt-confirm-box .slot-label { color: var(--accent); font-size: 15px; font-weight: 600; margin-bottom: 8px; }
|
|
1831
2035
|
</style>
|
|
1832
2036
|
</head>
|
|
1833
|
-
<body>
|
|
2037
|
+
<body${tenant.lightMode ? ' class="light"' : ''}>
|
|
1834
2038
|
<header>
|
|
1835
2039
|
<div class="emojicode">${tenant.emojicode}</div>
|
|
1836
2040
|
<h1>${tenant.name}</h1>
|
|
@@ -1840,17 +2044,56 @@ function generateShoppeHTML(tenant, goods, uploadAuth = null) {
|
|
|
1840
2044
|
<main>
|
|
1841
2045
|
<div id="all" class="section active"><div class="grid">${renderCards(allItems, 'all')}</div></div>
|
|
1842
2046
|
<div id="books" class="section"><div class="grid">${renderCards(goods.books, 'book')}</div></div>
|
|
1843
|
-
<div id="music" class="section"
|
|
1844
|
-
|
|
2047
|
+
<div id="music" class="section">
|
|
2048
|
+
<div id="music-album-grid"></div>
|
|
2049
|
+
<div id="music-album-detail" style="display:none">
|
|
2050
|
+
<button class="music-back-btn" onclick="musicShowGrid()">← Albums</button>
|
|
2051
|
+
<div id="music-detail-header"></div>
|
|
2052
|
+
<div id="music-track-list"></div>
|
|
2053
|
+
</div>
|
|
2054
|
+
</div>
|
|
2055
|
+
<div id="posts" class="section">
|
|
2056
|
+
<div id="posts-grid"></div>
|
|
2057
|
+
<div id="posts-series-detail" style="display:none">
|
|
2058
|
+
<button class="posts-back-btn" onclick="postsShowGrid()">← Posts</button>
|
|
2059
|
+
<div id="posts-series-header"></div>
|
|
2060
|
+
<div id="posts-parts-list"></div>
|
|
2061
|
+
</div>
|
|
2062
|
+
</div>
|
|
1845
2063
|
<div id="albums" class="section"><div class="grid">${renderCards(goods.albums, 'album')}</div></div>
|
|
1846
2064
|
<div id="products" class="section"><div class="grid">${renderCards(goods.products, 'product')}</div></div>
|
|
1847
2065
|
<div id="videos" class="section"><div class="grid">${renderCards(goods.videos, 'video')}</div></div>
|
|
1848
|
-
<div id="appointments" class="section"
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
2066
|
+
<div id="appointments" class="section">
|
|
2067
|
+
<div id="appointments-list"></div>
|
|
2068
|
+
</div>
|
|
2069
|
+
<div id="subscriptions" class="section">
|
|
2070
|
+
<div id="subscriptions-list"></div>
|
|
2071
|
+
<div style="text-align:center;padding:12px 0 8px;font-size:13px;color:#888;">
|
|
2072
|
+
Already infusing? <a href="/plugin/shoppe/${tenant.uuid}/membership" style="color:#0066cc;">Access your membership β</a>
|
|
2073
|
+
</div>
|
|
1852
2074
|
</div>
|
|
1853
2075
|
</main>
|
|
2076
|
+
<div id="music-player-bar">
|
|
2077
|
+
<div class="music-bar-inner">
|
|
2078
|
+
<img id="music-bar-art" class="music-bar-art" src="" alt="" style="display:none">
|
|
2079
|
+
<div class="music-bar-info">
|
|
2080
|
+
<div class="music-bar-title" id="music-bar-title">β</div>
|
|
2081
|
+
<div class="music-bar-album" id="music-bar-album"></div>
|
|
2082
|
+
</div>
|
|
2083
|
+
<div class="music-bar-controls">
|
|
2084
|
+
<button class="music-bar-btn" onclick="musicBarPrev()" title="Previous">◀◀</button>
|
|
2085
|
+
<button class="music-bar-btn" id="music-bar-play" onclick="musicBarPlayPause()" title="Play/Pause">▶</button>
|
|
2086
|
+
<button class="music-bar-btn" onclick="musicBarNext()" title="Next">▶▶</button>
|
|
2087
|
+
</div>
|
|
2088
|
+
<div class="music-bar-progress">
|
|
2089
|
+
<span class="music-bar-time" id="music-bar-time">0:00</span>
|
|
2090
|
+
<div class="music-bar-track" onclick="musicBarSeek(event)">
|
|
2091
|
+
<div class="music-bar-fill" id="music-bar-fill"></div>
|
|
2092
|
+
</div>
|
|
2093
|
+
<span class="music-bar-time" id="music-bar-dur">0:00</span>
|
|
2094
|
+
</div>
|
|
2095
|
+
</div>
|
|
2096
|
+
</div>
|
|
1854
2097
|
<div id="video-modal" class="video-modal">
|
|
1855
2098
|
<div class="video-modal-backdrop" onclick="closeVideo()"></div>
|
|
1856
2099
|
<div class="video-modal-content">
|
|
@@ -1865,6 +2108,248 @@ function generateShoppeHTML(tenant, goods, uploadAuth = null) {
|
|
|
1865
2108
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
1866
2109
|
document.getElementById(id).classList.add('active');
|
|
1867
2110
|
tab.classList.add('active');
|
|
2111
|
+
if (id === 'music' && !_musicLoaded) initMusic();
|
|
2112
|
+
if (id === 'posts' && !_postsLoaded) initPosts();
|
|
2113
|
+
if (id === 'subscriptions' && !_subsLoaded) initSubscriptions();
|
|
2114
|
+
if (id === 'appointments' && !_apptsLoaded) initAppointments();
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
// ββ Posts browser βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
2118
|
+
const _postsRaw = ${(() => {
|
|
2119
|
+
// Build structured posts data server-side so the client just reads JSON.
|
|
2120
|
+
const seriesMap = {}; // seriesTitle β { item, parts: [] }
|
|
2121
|
+
const standalones = [];
|
|
2122
|
+
// First pass: collect series parents
|
|
2123
|
+
for (const item of goods.posts) {
|
|
2124
|
+
if (item.category === 'post-series') seriesMap[item.title] = { ...item, parts: [] };
|
|
2125
|
+
}
|
|
2126
|
+
// Second pass: attach parts to their series, or collect standalones
|
|
2127
|
+
for (const item of goods.posts) {
|
|
2128
|
+
if (item.category !== 'post') continue;
|
|
2129
|
+
const tagParts = (item.tags || '').split(',');
|
|
2130
|
+
const seriesTag = tagParts.find(t => t.startsWith('series:'));
|
|
2131
|
+
const partTag = tagParts.find(t => t.startsWith('part:'));
|
|
2132
|
+
const seriesTitle = seriesTag ? seriesTag.slice('series:'.length) : null;
|
|
2133
|
+
const partNum = partTag ? parseInt(partTag.slice('part:'.length)) || 0 : 0;
|
|
2134
|
+
if (seriesTitle && seriesMap[seriesTitle]) {
|
|
2135
|
+
seriesMap[seriesTitle].parts.push({ ...item, partNum });
|
|
2136
|
+
} else {
|
|
2137
|
+
standalones.push(item);
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
// Sort parts within each series
|
|
2141
|
+
for (const s of Object.values(seriesMap)) {
|
|
2142
|
+
s.parts.sort((a, b) => (a.partNum || 0) - (b.partNum || 0));
|
|
2143
|
+
}
|
|
2144
|
+
return JSON.stringify({ series: Object.values(seriesMap), standalones });
|
|
2145
|
+
})()};
|
|
2146
|
+
let _postsLoaded = false;
|
|
2147
|
+
|
|
2148
|
+
function initPosts() {
|
|
2149
|
+
_postsLoaded = true;
|
|
2150
|
+
postsRenderGrid();
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
function postsRenderGrid() {
|
|
2154
|
+
const grid = document.getElementById('posts-grid');
|
|
2155
|
+
const { series, standalones } = _postsRaw;
|
|
2156
|
+
if (series.length === 0 && standalones.length === 0) {
|
|
2157
|
+
grid.innerHTML = '<p class="empty">No posts yet.</p>';
|
|
2158
|
+
return;
|
|
2159
|
+
}
|
|
2160
|
+
const seriesHtml = series.length ? \`<div class="posts-grid">\${series.map((s, i) => \`
|
|
2161
|
+
<div class="card" style="cursor:pointer" onclick="postsShowSeries(\${i})">
|
|
2162
|
+
\${s.image ? \`<div class="card-img"><img src="\${_escHtml(s.image)}" alt="" loading="lazy"></div>\` : '<div class="card-img-placeholder">π</div>'}
|
|
2163
|
+
<div class="card-body">
|
|
2164
|
+
<div class="card-title">\${_escHtml(s.title)}</div>
|
|
2165
|
+
\${s.description ? \`<div class="card-desc">\${_escHtml(s.description)}</div>\` : ''}
|
|
2166
|
+
<div style="font-size:12px;color:#0066cc;margin-top:6px;font-weight:600">\${s.parts.length} part\${s.parts.length !== 1 ? 's' : ''}</div>
|
|
2167
|
+
</div>
|
|
2168
|
+
</div>\`).join('')}</div>\` : '';
|
|
2169
|
+
const standaloneHtml = standalones.length ? \`
|
|
2170
|
+
\${series.length ? '<div class="posts-standalones-label">Posts</div>' : ''}
|
|
2171
|
+
<div class="posts-grid">\${standalones.map(p => \`
|
|
2172
|
+
<div class="card" onclick="window.open('\${_escHtml(p.url)}','_self')">
|
|
2173
|
+
\${p.image ? \`<div class="card-img"><img src="\${_escHtml(p.image)}" alt="" loading="lazy"></div>\` : '<div class="card-img-placeholder">π</div>'}
|
|
2174
|
+
<div class="card-body">
|
|
2175
|
+
<div class="card-title">\${_escHtml(p.title)}</div>
|
|
2176
|
+
\${p.description ? \`<div class="card-desc">\${_escHtml(p.description)}</div>\` : ''}
|
|
2177
|
+
</div>
|
|
2178
|
+
</div>\`).join('')}</div>\` : '';
|
|
2179
|
+
grid.innerHTML = seriesHtml + standaloneHtml;
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
function postsShowSeries(idx) {
|
|
2183
|
+
const s = _postsRaw.series[idx];
|
|
2184
|
+
document.getElementById('posts-grid').style.display = 'none';
|
|
2185
|
+
document.getElementById('posts-series-detail').style.display = 'block';
|
|
2186
|
+
document.getElementById('posts-series-header').innerHTML = \`
|
|
2187
|
+
<div class="posts-series-header">
|
|
2188
|
+
\${s.image ? \`<img class="posts-series-cover" src="\${_escHtml(s.image)}" alt="">\` : ''}
|
|
2189
|
+
<div>
|
|
2190
|
+
<div class="posts-series-title">\${_escHtml(s.title)}</div>
|
|
2191
|
+
\${s.description ? \`<div class="posts-series-desc">\${_escHtml(s.description)}</div>\` : ''}
|
|
2192
|
+
</div>
|
|
2193
|
+
</div>\`;
|
|
2194
|
+
document.getElementById('posts-parts-list').innerHTML = s.parts.map((p, i) => \`
|
|
2195
|
+
<a class="posts-part-row" href="\${_escHtml(p.url)}">
|
|
2196
|
+
<div class="posts-part-num">\${p.partNum || i + 1}</div>
|
|
2197
|
+
<div class="posts-part-info">
|
|
2198
|
+
<div class="posts-part-title">\${_escHtml(p.title.replace(s.title + ': ', ''))}</div>
|
|
2199
|
+
\${p.description ? \`<div class="posts-part-desc">\${_escHtml(p.description)}</div>\` : ''}
|
|
2200
|
+
</div>
|
|
2201
|
+
<div class="posts-part-arrow">→</div>
|
|
2202
|
+
</a>\`).join('');
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
function postsShowGrid() {
|
|
2206
|
+
document.getElementById('posts-grid').style.display = '';
|
|
2207
|
+
document.getElementById('posts-series-detail').style.display = 'none';
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
// ββ Music player ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
2211
|
+
let _musicLoaded = false, _musicAlbums = [], _musicTracks = [], _musicAllTracks = [], _musicCurrentIdx = -1;
|
|
2212
|
+
const _musicAudio = new Audio();
|
|
2213
|
+
|
|
2214
|
+
function _escHtml(s) {
|
|
2215
|
+
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
async function initMusic() {
|
|
2219
|
+
_musicLoaded = true;
|
|
2220
|
+
const grid = document.getElementById('music-album-grid');
|
|
2221
|
+
grid.innerHTML = '<p class="empty">Loading music\u2026</p>';
|
|
2222
|
+
try {
|
|
2223
|
+
const resp = await fetch('/plugin/shoppe/${tenant.uuid}/music/feed');
|
|
2224
|
+
const data = await resp.json();
|
|
2225
|
+
_musicAlbums = data.albums || [];
|
|
2226
|
+
_musicTracks = data.tracks || [];
|
|
2227
|
+
_musicAllTracks = [
|
|
2228
|
+
..._musicAlbums.flatMap(a => a.tracks.map(t => ({ ...t, cover: a.cover, albumName: a.name }))),
|
|
2229
|
+
..._musicTracks.map(t => ({ ...t, albumName: '' }))
|
|
2230
|
+
];
|
|
2231
|
+
musicRenderGrid();
|
|
2232
|
+
} catch (e) {
|
|
2233
|
+
grid.innerHTML = '<p class="empty">Could not load music.</p>';
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
function musicRenderGrid() {
|
|
2238
|
+
const grid = document.getElementById('music-album-grid');
|
|
2239
|
+
if (_musicAlbums.length === 0 && _musicTracks.length === 0) {
|
|
2240
|
+
grid.innerHTML = '<p class="empty">No music yet.</p>';
|
|
2241
|
+
return;
|
|
2242
|
+
}
|
|
2243
|
+
const albumsHtml = _musicAlbums.length ? \`<div class="music-grid">\${_musicAlbums.map((a, i) => \`
|
|
2244
|
+
<div class="card music-album-card" onclick="musicShowAlbum(\${i})">
|
|
2245
|
+
\${a.cover ? \`<div class="card-img"><img src="\${_escHtml(a.cover)}" alt="" loading="lazy"></div>\` : '<div class="card-img-placeholder">π΅</div>'}
|
|
2246
|
+
<div class="card-body">
|
|
2247
|
+
<div class="card-title">\${_escHtml(a.name)}</div>
|
|
2248
|
+
<div class="card-desc">\${a.tracks.length} track\${a.tracks.length !== 1 ? 's' : ''}</div>
|
|
2249
|
+
</div>
|
|
2250
|
+
</div>\`).join('')}</div>\` : '';
|
|
2251
|
+
const tracksHtml = _musicTracks.length ? \`
|
|
2252
|
+
<div class="music-singles-label">Singles</div>
|
|
2253
|
+
\${_musicTracks.map((t, i) => \`
|
|
2254
|
+
<div class="music-track-row" id="mts-\${i}" onclick="musicPlayStandalone(\${i})">
|
|
2255
|
+
\${t.cover ? \`<img class="music-track-cover" src="\${_escHtml(t.cover)}" alt="">\` : '<div class="music-track-cover-ph">π΅</div>'}
|
|
2256
|
+
<div class="music-track-info"><div class="music-track-title">\${_escHtml(t.title)}</div></div>
|
|
2257
|
+
<div class="music-play-icon">▶</div>
|
|
2258
|
+
</div>\`).join('')}\` : '';
|
|
2259
|
+
grid.innerHTML = albumsHtml + tracksHtml;
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
function musicShowAlbum(idx) {
|
|
2263
|
+
const a = _musicAlbums[idx];
|
|
2264
|
+
document.getElementById('music-album-grid').style.display = 'none';
|
|
2265
|
+
const det = document.getElementById('music-album-detail');
|
|
2266
|
+
det.style.display = 'block';
|
|
2267
|
+
document.getElementById('music-detail-header').innerHTML = \`
|
|
2268
|
+
<div class="music-detail-header">
|
|
2269
|
+
\${a.cover ? \`<img class="music-detail-cover" src="\${_escHtml(a.cover)}" alt="">\` : ''}
|
|
2270
|
+
<div>
|
|
2271
|
+
<div class="music-detail-title">\${_escHtml(a.name)}</div>
|
|
2272
|
+
\${a.description ? \`<div class="music-detail-desc">\${_escHtml(a.description)}</div>\` : ''}
|
|
2273
|
+
</div>
|
|
2274
|
+
</div>\`;
|
|
2275
|
+
document.getElementById('music-track-list').innerHTML = a.tracks.map((t, i) => \`
|
|
2276
|
+
<div class="music-track-row" id="mta-\${idx}-\${i}" onclick="musicPlayAlbumTrack(\${idx},\${i})">
|
|
2277
|
+
<div class="music-track-num">\${t.number}</div>
|
|
2278
|
+
<div class="music-track-info"><div class="music-track-title">\${_escHtml(t.title)}</div></div>
|
|
2279
|
+
<div class="music-play-icon">▶</div>
|
|
2280
|
+
</div>\`).join('');
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
function musicShowGrid() {
|
|
2284
|
+
document.getElementById('music-album-grid').style.display = '';
|
|
2285
|
+
document.getElementById('music-album-detail').style.display = 'none';
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
function musicPlayAlbumTrack(albumIdx, trackIdx) {
|
|
2289
|
+
const a = _musicAlbums[albumIdx], t = a.tracks[trackIdx];
|
|
2290
|
+
_musicCurrentIdx = _musicAllTracks.findIndex(x => x.src === t.src);
|
|
2291
|
+
_musicDoPlay({ ...t, cover: a.cover, albumName: a.name });
|
|
2292
|
+
document.querySelectorAll('[id^="mta-"]').forEach(el => el.classList.remove('playing'));
|
|
2293
|
+
const el = document.getElementById(\`mta-\${albumIdx}-\${trackIdx}\`);
|
|
2294
|
+
if (el) el.classList.add('playing');
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
function musicPlayStandalone(idx) {
|
|
2298
|
+
const t = _musicTracks[idx];
|
|
2299
|
+
_musicCurrentIdx = _musicAllTracks.findIndex(x => x.src === t.src);
|
|
2300
|
+
_musicDoPlay(t);
|
|
2301
|
+
document.querySelectorAll('[id^="mts-"]').forEach(el => el.classList.remove('playing'));
|
|
2302
|
+
const el = document.getElementById(\`mts-\${idx}\`);
|
|
2303
|
+
if (el) el.classList.add('playing');
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
function _musicDoPlay(track) {
|
|
2307
|
+
_musicAudio.src = track.src;
|
|
2308
|
+
_musicAudio.play();
|
|
2309
|
+
const bar = document.getElementById('music-player-bar');
|
|
2310
|
+
bar.style.display = 'block';
|
|
2311
|
+
document.getElementById('music-bar-title').textContent = track.title;
|
|
2312
|
+
document.getElementById('music-bar-album').textContent = track.albumName || '';
|
|
2313
|
+
const art = document.getElementById('music-bar-art');
|
|
2314
|
+
if (track.cover) { art.src = track.cover; art.style.display = 'block'; }
|
|
2315
|
+
else art.style.display = 'none';
|
|
2316
|
+
document.getElementById('music-bar-play').innerHTML = '▮▮';
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
_musicAudio.addEventListener('ended', () => {
|
|
2320
|
+
if (_musicCurrentIdx < _musicAllTracks.length - 1) {
|
|
2321
|
+
_musicCurrentIdx++;
|
|
2322
|
+
_musicDoPlay(_musicAllTracks[_musicCurrentIdx]);
|
|
2323
|
+
} else {
|
|
2324
|
+
document.getElementById('music-bar-play').innerHTML = '▶';
|
|
2325
|
+
}
|
|
2326
|
+
});
|
|
2327
|
+
_musicAudio.addEventListener('timeupdate', () => {
|
|
2328
|
+
if (!_musicAudio.duration) return;
|
|
2329
|
+
document.getElementById('music-bar-fill').style.width = (_musicAudio.currentTime / _musicAudio.duration * 100) + '%';
|
|
2330
|
+
document.getElementById('music-bar-time').textContent = _musicFmt(_musicAudio.currentTime);
|
|
2331
|
+
});
|
|
2332
|
+
_musicAudio.addEventListener('loadedmetadata', () => {
|
|
2333
|
+
document.getElementById('music-bar-dur').textContent = _musicFmt(_musicAudio.duration);
|
|
2334
|
+
});
|
|
2335
|
+
|
|
2336
|
+
function _musicFmt(s) {
|
|
2337
|
+
if (!s || isNaN(s)) return '0:00';
|
|
2338
|
+
return Math.floor(s / 60) + ':' + String(Math.floor(s % 60)).padStart(2, '0');
|
|
2339
|
+
}
|
|
2340
|
+
function musicBarPlayPause() {
|
|
2341
|
+
if (_musicAudio.paused) { _musicAudio.play(); document.getElementById('music-bar-play').innerHTML = '▮▮'; }
|
|
2342
|
+
else { _musicAudio.pause(); document.getElementById('music-bar-play').innerHTML = '▶'; }
|
|
2343
|
+
}
|
|
2344
|
+
function musicBarPrev() {
|
|
2345
|
+
if (_musicCurrentIdx > 0) { _musicCurrentIdx--; _musicDoPlay(_musicAllTracks[_musicCurrentIdx]); }
|
|
2346
|
+
}
|
|
2347
|
+
function musicBarNext() {
|
|
2348
|
+
if (_musicCurrentIdx < _musicAllTracks.length - 1) { _musicCurrentIdx++; _musicDoPlay(_musicAllTracks[_musicCurrentIdx]); }
|
|
2349
|
+
}
|
|
2350
|
+
function musicBarSeek(e) {
|
|
2351
|
+
const r = e.currentTarget.getBoundingClientRect();
|
|
2352
|
+
_musicAudio.currentTime = _musicAudio.duration * ((e.clientX - r.left) / r.width);
|
|
1868
2353
|
}
|
|
1869
2354
|
function playVideo(url) {
|
|
1870
2355
|
document.getElementById('video-iframe').src = url;
|
|
@@ -1876,6 +2361,396 @@ function generateShoppeHTML(tenant, goods, uploadAuth = null) {
|
|
|
1876
2361
|
}
|
|
1877
2362
|
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeVideo(); });
|
|
1878
2363
|
|
|
2364
|
+
// ββ Inline subscription tiers ββββββββββββββββββββββββββββββββββββββββββββ
|
|
2365
|
+
const _subsData = ${JSON.stringify(goods.subscriptions)};
|
|
2366
|
+
let _subsLoaded = false;
|
|
2367
|
+
|
|
2368
|
+
function initSubscriptions() {
|
|
2369
|
+
_subsLoaded = true;
|
|
2370
|
+
renderSubTiers();
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
function renderSubTiers() {
|
|
2374
|
+
const container = document.getElementById('subscriptions-list');
|
|
2375
|
+
if (!_subsData.length) { container.innerHTML = '<p class="empty">No subscription tiers yet.</p>'; return; }
|
|
2376
|
+
container.innerHTML = _subsData.map((tier, i) => {
|
|
2377
|
+
const fmtPrice = (tier.price / 100).toFixed(2);
|
|
2378
|
+
const benefitsHtml = (tier.benefits && tier.benefits.length)
|
|
2379
|
+
? \`<ul class="sub-benefits">\${tier.benefits.map(b => \`<li>\${_escHtml(b)}</li>\`).join('')}</ul>\`
|
|
2380
|
+
: '';
|
|
2381
|
+
return \`
|
|
2382
|
+
<div class="sub-tier-card">
|
|
2383
|
+
<div class="sub-tier-header">
|
|
2384
|
+
\${tier.image ? \`<img class="sub-tier-img" src="\${_escHtml(tier.image)}" alt="">\` : '<div class="sub-tier-img-ph">π</div>'}
|
|
2385
|
+
<div class="sub-tier-info">
|
|
2386
|
+
<div class="sub-tier-name">\${_escHtml(tier.title)}</div>
|
|
2387
|
+
\${tier.description ? \`<div class="sub-tier-desc">\${_escHtml(tier.description)}</div>\` : ''}
|
|
2388
|
+
<div class="sub-tier-price">$\${fmtPrice}<span>/ \${tier.renewalDays || 30} days</span></div>
|
|
2389
|
+
\${benefitsHtml}
|
|
2390
|
+
<button class="sub-btn" onclick="subToggle(\${i})">Subscribe β</button>
|
|
2391
|
+
</div>
|
|
2392
|
+
</div>
|
|
2393
|
+
<div class="sub-form-panel" id="sub-panel-\${i}">
|
|
2394
|
+
<div id="sub-already-\${i}" class="sub-already" style="display:none">
|
|
2395
|
+
<strong>β
You're already infusing!</strong>
|
|
2396
|
+
<p id="sub-already-desc-\${i}"></p>
|
|
2397
|
+
</div>
|
|
2398
|
+
<div id="sub-recovery-\${i}">
|
|
2399
|
+
<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>
|
|
2400
|
+
<div class="sub-field-group">
|
|
2401
|
+
<label>Recovery Key *</label>
|
|
2402
|
+
<input type="text" id="sub-rkey-\${i}" placeholder="e.g. golden-ticket-2026" autocomplete="off">
|
|
2403
|
+
</div>
|
|
2404
|
+
<button class="sub-btn" onclick="subProceed(\${i})">Continue to Payment β</button>
|
|
2405
|
+
<div id="sub-rkey-error-\${i}" class="sub-error"></div>
|
|
2406
|
+
</div>
|
|
2407
|
+
<div id="sub-payment-\${i}" style="display:none">
|
|
2408
|
+
<div style="font-size:14px;font-weight:600;margin-bottom:12px;">Complete your subscription β $\${fmtPrice} / \${tier.renewalDays || 30} days</div>
|
|
2409
|
+
<div id="sub-stripe-el-\${i}" style="margin-bottom:14px;"></div>
|
|
2410
|
+
<button class="sub-btn" id="sub-pay-btn-\${i}" onclick="subConfirm(\${i})">Pay $\${fmtPrice}</button>
|
|
2411
|
+
<div id="sub-pay-loading-\${i}" style="display:none;font-size:13px;color:#888;margin-top:8px;"></div>
|
|
2412
|
+
<div id="sub-pay-error-\${i}" class="sub-error"></div>
|
|
2413
|
+
</div>
|
|
2414
|
+
<div id="sub-confirm-\${i}" style="display:none">
|
|
2415
|
+
<div class="sub-confirm-box">
|
|
2416
|
+
<div class="icon">π</div>
|
|
2417
|
+
<h3>Thank you for infusing!</h3>
|
|
2418
|
+
<div class="renews" id="sub-confirm-renews-\${i}"></div>
|
|
2419
|
+
<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>
|
|
2420
|
+
</div>
|
|
2421
|
+
</div>
|
|
2422
|
+
</div>
|
|
2423
|
+
</div>\`;
|
|
2424
|
+
}).join('');
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
const _subStripeInst = {}, _subEls = {}, _subClientSecrets = {};
|
|
2428
|
+
|
|
2429
|
+
function subToggle(i) {
|
|
2430
|
+
const panel = document.getElementById(\`sub-panel-\${i}\`);
|
|
2431
|
+
const isOpen = panel.classList.contains('open');
|
|
2432
|
+
document.querySelectorAll('.sub-form-panel').forEach(p => p.classList.remove('open'));
|
|
2433
|
+
if (!isOpen) { panel.classList.add('open'); panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
async function subProceed(i) {
|
|
2437
|
+
const tier = _subsData[i];
|
|
2438
|
+
const recoveryKey = document.getElementById(\`sub-rkey-\${i}\`).value.trim();
|
|
2439
|
+
const errEl = document.getElementById(\`sub-rkey-error-\${i}\`);
|
|
2440
|
+
if (!recoveryKey) { errEl.textContent = 'Recovery key is required.'; errEl.style.display = 'block'; return; }
|
|
2441
|
+
errEl.style.display = 'none';
|
|
2442
|
+
try {
|
|
2443
|
+
const resp = await fetch('/plugin/shoppe/${tenant.uuid}/purchase/intent', {
|
|
2444
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
2445
|
+
body: JSON.stringify({ recoveryKey, productId: tier.productId, title: tier.title })
|
|
2446
|
+
});
|
|
2447
|
+
const data = await resp.json();
|
|
2448
|
+
if (data.alreadySubscribed) {
|
|
2449
|
+
document.getElementById(\`sub-recovery-\${i}\`).style.display = 'none';
|
|
2450
|
+
const banner = document.getElementById(\`sub-already-\${i}\`);
|
|
2451
|
+
banner.style.display = 'block';
|
|
2452
|
+
document.getElementById(\`sub-already-desc-\${i}\`).textContent = \`Your subscription is active for \${data.daysLeft} more day\${data.daysLeft !== 1 ? 's' : ''}.\`;
|
|
2453
|
+
return;
|
|
2454
|
+
}
|
|
2455
|
+
if (data.error) { errEl.textContent = data.error; errEl.style.display = 'block'; return; }
|
|
2456
|
+
document.getElementById(\`sub-recovery-\${i}\`).style.display = 'none';
|
|
2457
|
+
document.getElementById(\`sub-payment-\${i}\`).style.display = 'block';
|
|
2458
|
+
_subClientSecrets[i] = data.clientSecret;
|
|
2459
|
+
_subStripeInst[i] = Stripe(data.publishableKey);
|
|
2460
|
+
_subEls[i] = _subStripeInst[i].elements({ clientSecret: data.clientSecret });
|
|
2461
|
+
_subEls[i].create('payment').mount(\`#sub-stripe-el-\${i}\`);
|
|
2462
|
+
} catch (err) {
|
|
2463
|
+
errEl.textContent = 'Could not start checkout. Please try again.';
|
|
2464
|
+
errEl.style.display = 'block';
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
async function subConfirm(i) {
|
|
2469
|
+
const tier = _subsData[i];
|
|
2470
|
+
const payBtn = document.getElementById(\`sub-pay-btn-\${i}\`);
|
|
2471
|
+
const payLoading = document.getElementById(\`sub-pay-loading-\${i}\`);
|
|
2472
|
+
const payError = document.getElementById(\`sub-pay-error-\${i}\`);
|
|
2473
|
+
payBtn.disabled = true; payLoading.style.display = 'block'; payLoading.textContent = 'Processingβ¦'; payError.style.display = 'none';
|
|
2474
|
+
try {
|
|
2475
|
+
const { error } = await _subStripeInst[i].confirmPayment({
|
|
2476
|
+
elements: _subEls[i], confirmParams: { return_url: window.location.href }, redirect: 'if_required'
|
|
2477
|
+
});
|
|
2478
|
+
if (error) { payError.textContent = error.message; payError.style.display = 'block'; payBtn.disabled = false; payLoading.style.display = 'none'; return; }
|
|
2479
|
+
const recoveryKey = document.getElementById(\`sub-rkey-\${i}\`).value.trim();
|
|
2480
|
+
const paymentIntentId = _subClientSecrets[i] ? _subClientSecrets[i].split('_secret_')[0] : undefined;
|
|
2481
|
+
await fetch('/plugin/shoppe/${tenant.uuid}/purchase/complete', {
|
|
2482
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
2483
|
+
body: JSON.stringify({ recoveryKey, productId: tier.productId, title: tier.title, amount: tier.price, type: 'subscription', renewalDays: tier.renewalDays || 30, paymentIntentId })
|
|
2484
|
+
});
|
|
2485
|
+
document.getElementById(\`sub-payment-\${i}\`).style.display = 'none';
|
|
2486
|
+
const conf = document.getElementById(\`sub-confirm-\${i}\`);
|
|
2487
|
+
conf.style.display = 'block';
|
|
2488
|
+
const renewsAt = new Date(Date.now() + (tier.renewalDays || 30) * 24 * 60 * 60 * 1000);
|
|
2489
|
+
document.getElementById(\`sub-confirm-renews-\${i}\`).textContent = 'Active until ' + renewsAt.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
|
|
2490
|
+
} catch (err) {
|
|
2491
|
+
payError.textContent = 'An unexpected error occurred.'; payError.style.display = 'block';
|
|
2492
|
+
payBtn.disabled = false; payLoading.style.display = 'none';
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
// ββ Inline appointment booking ββββββββββββββββββββββββββββββββββββββββββββ
|
|
2497
|
+
const _apptsData = ${JSON.stringify(goods.appointments)};
|
|
2498
|
+
let _apptsLoaded = false;
|
|
2499
|
+
const _apptState = {}; // per-appointment state: { availableDates, selectedSlot }
|
|
2500
|
+
const _apptStripe = {}, _apptElems = {}, _apptSecrets = {};
|
|
2501
|
+
|
|
2502
|
+
function initAppointments() {
|
|
2503
|
+
_apptsLoaded = true;
|
|
2504
|
+
renderAppts();
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
function renderAppts() {
|
|
2508
|
+
const container = document.getElementById('appointments-list');
|
|
2509
|
+
if (!_apptsData.length) { container.innerHTML = '<p class="empty">No appointments yet.</p>'; return; }
|
|
2510
|
+
container.innerHTML = _apptsData.map((appt, i) => {
|
|
2511
|
+
const fmtPrice = appt.price > 0 ? ('$' + (appt.price / 100).toFixed(2) + '/session') : 'Free';
|
|
2512
|
+
return \`
|
|
2513
|
+
<div class="appt-card">
|
|
2514
|
+
<div class="appt-card-header" onclick="apptToggle(\${i})">
|
|
2515
|
+
\${appt.image ? \`<img class="appt-img" src="\${_escHtml(appt.image)}" alt="">\` : '<div class="appt-img-ph">π
</div>'}
|
|
2516
|
+
<div class="appt-info">
|
|
2517
|
+
<div class="appt-name">\${_escHtml(appt.title)}</div>
|
|
2518
|
+
\${appt.description ? \`<div class="appt-desc">\${_escHtml(appt.description)}</div>\` : ''}
|
|
2519
|
+
<div class="appt-meta">
|
|
2520
|
+
<span class="appt-chip">π° \${fmtPrice}</span>
|
|
2521
|
+
<span class="appt-chip">β± \${appt.duration || 60} min</span>
|
|
2522
|
+
</div>
|
|
2523
|
+
<button class="appt-book-btn">Book β</button>
|
|
2524
|
+
</div>
|
|
2525
|
+
</div>
|
|
2526
|
+
<div class="appt-booking-panel" id="appt-panel-\${i}">
|
|
2527
|
+
<div id="appt-step-dates-\${i}">
|
|
2528
|
+
<h3 style="font-size:15px;font-weight:600;margin-bottom:12px;">Choose a date</h3>
|
|
2529
|
+
<div id="appt-date-strip-\${i}" class="appt-date-strip"></div>
|
|
2530
|
+
<div id="appt-loading-\${i}" style="font-size:13px;color:#888;">Loading availabilityβ¦</div>
|
|
2531
|
+
<div id="appt-no-slots-\${i}" style="display:none;font-size:13px;color:#888;">No upcoming availability.</div>
|
|
2532
|
+
</div>
|
|
2533
|
+
<div id="appt-step-slots-\${i}" style="display:none">
|
|
2534
|
+
<h3 id="appt-slot-heading-\${i}" style="font-size:15px;font-weight:600;margin-bottom:10px;">Available times</h3>
|
|
2535
|
+
<div id="appt-slot-grid-\${i}" class="appt-slot-grid"></div>
|
|
2536
|
+
</div>
|
|
2537
|
+
<div id="appt-form-\${i}" style="display:none">
|
|
2538
|
+
<div id="appt-slot-display-\${i}" class="appt-selected-slot"></div>
|
|
2539
|
+
<div class="sub-recovery-note">π Choose a recovery key β you'll use it to look up your booking later.</div>
|
|
2540
|
+
<div class="sub-field-group"><label>Recovery Key *</label><input type="text" id="appt-rkey-\${i}" placeholder="e.g. sunflower-2026" autocomplete="off"></div>
|
|
2541
|
+
<div class="sub-field-group"><label>Your Name *</label><input type="text" id="appt-name-\${i}" placeholder="Full name"></div>
|
|
2542
|
+
<div class="sub-field-group"><label>Email *</label><input type="email" id="appt-email-\${i}" placeholder="For booking confirmation"></div>
|
|
2543
|
+
<div style="display:flex;gap:10px;margin-top:6px;flex-wrap:wrap;">
|
|
2544
|
+
<button class="appt-back-btn" onclick="apptBackToSlots(\${i})">β Change time</button>
|
|
2545
|
+
<button class="appt-book-btn" id="appt-proceed-btn-\${i}" onclick="apptProceed(\${i})">\${appt.price === 0 ? 'Confirm Booking β' : 'Continue to Payment β'}</button>
|
|
2546
|
+
</div>
|
|
2547
|
+
<div id="appt-form-error-\${i}" class="sub-error"></div>
|
|
2548
|
+
</div>
|
|
2549
|
+
<div id="appt-payment-\${i}" style="display:none">
|
|
2550
|
+
<div id="appt-slot-display-pay-\${i}" class="appt-selected-slot" style="margin-bottom:14px;"></div>
|
|
2551
|
+
<div id="appt-stripe-el-\${i}" style="margin-bottom:14px;"></div>
|
|
2552
|
+
<button class="appt-book-btn" id="appt-pay-btn-\${i}" onclick="apptConfirmPayment(\${i})">Pay $\${(appt.price/100).toFixed(2)}</button>
|
|
2553
|
+
<div id="appt-pay-loading-\${i}" style="display:none;font-size:13px;color:#888;margin-top:8px;"></div>
|
|
2554
|
+
<div id="appt-pay-error-\${i}" class="sub-error"></div>
|
|
2555
|
+
</div>
|
|
2556
|
+
<div id="appt-confirm-\${i}" style="display:none">
|
|
2557
|
+
<div class="appt-confirm-box">
|
|
2558
|
+
<div class="icon">β
</div>
|
|
2559
|
+
<h3>You're booked!</h3>
|
|
2560
|
+
<div class="slot-label" id="appt-confirm-slot-\${i}"></div>
|
|
2561
|
+
<p style="font-size:12px;color:#888;margin-top:8px;">Keep your recovery key safe β it's how you look up this booking.</p>
|
|
2562
|
+
</div>
|
|
2563
|
+
</div>
|
|
2564
|
+
</div>
|
|
2565
|
+
</div>\`;
|
|
2566
|
+
}).join('');
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
function apptToggle(i) {
|
|
2570
|
+
const panel = document.getElementById(\`appt-panel-\${i}\`);
|
|
2571
|
+
const isOpen = panel.classList.contains('open');
|
|
2572
|
+
document.querySelectorAll('.appt-booking-panel').forEach(p => p.classList.remove('open'));
|
|
2573
|
+
if (!isOpen) {
|
|
2574
|
+
panel.classList.add('open');
|
|
2575
|
+
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
2576
|
+
if (!_apptState[i]) { _apptState[i] = {}; apptLoadSlots(i); }
|
|
2577
|
+
}
|
|
2578
|
+
}
|
|
2579
|
+
|
|
2580
|
+
async function apptLoadSlots(i) {
|
|
2581
|
+
const appt = _apptsData[i];
|
|
2582
|
+
const loadingEl = document.getElementById(\`appt-loading-\${i}\`);
|
|
2583
|
+
const noSlotsEl = document.getElementById(\`appt-no-slots-\${i}\`);
|
|
2584
|
+
try {
|
|
2585
|
+
const resp = await fetch('/plugin/shoppe/${tenant.uuid}/book/' + encodeURIComponent(appt.title) + '/slots');
|
|
2586
|
+
const data = await resp.json();
|
|
2587
|
+
loadingEl.style.display = 'none';
|
|
2588
|
+
if (!data.available || !data.available.length) { noSlotsEl.style.display = 'block'; return; }
|
|
2589
|
+
_apptState[i].availableDates = data.available;
|
|
2590
|
+
apptRenderDateStrip(i);
|
|
2591
|
+
apptSelectDate(i, data.available[0]);
|
|
2592
|
+
} catch (err) {
|
|
2593
|
+
loadingEl.textContent = 'Could not load availability.';
|
|
2594
|
+
}
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
function apptRenderDateStrip(i) {
|
|
2598
|
+
const strip = document.getElementById(\`appt-date-strip-\${i}\`);
|
|
2599
|
+
strip.innerHTML = '';
|
|
2600
|
+
(_apptState[i].availableDates || []).forEach((d, di) => {
|
|
2601
|
+
const parts = d.date.split('-');
|
|
2602
|
+
const dateObj = new Date(+parts[0], +parts[1] - 1, +parts[2]);
|
|
2603
|
+
const dow = dateObj.toLocaleDateString('en-US', { weekday: 'short' });
|
|
2604
|
+
const mon = dateObj.toLocaleDateString('en-US', { month: 'short' });
|
|
2605
|
+
const card = document.createElement('div');
|
|
2606
|
+
card.className = 'appt-date-card';
|
|
2607
|
+
card.innerHTML = \`<div class="dow">\${dow}</div><div class="dom">\${dateObj.getDate()}</div><div class="mon">\${mon}</div>\`;
|
|
2608
|
+
card.addEventListener('click', () => apptSelectDate(i, d));
|
|
2609
|
+
strip.appendChild(card);
|
|
2610
|
+
});
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2613
|
+
function apptSelectDate(i, dateData) {
|
|
2614
|
+
_apptState[i].selectedDate = dateData;
|
|
2615
|
+
_apptState[i].selectedSlot = null;
|
|
2616
|
+
document.querySelectorAll(\`#appt-date-strip-\${i} .appt-date-card\`).forEach((c, di) => {
|
|
2617
|
+
c.classList.toggle('active', (_apptState[i].availableDates || [])[di] === dateData);
|
|
2618
|
+
});
|
|
2619
|
+
const slotsDiv = document.getElementById(\`appt-step-slots-\${i}\`);
|
|
2620
|
+
slotsDiv.style.display = 'block';
|
|
2621
|
+
document.getElementById(\`appt-slot-heading-\${i}\`).textContent = 'Times on ' + dateData.dayLabel;
|
|
2622
|
+
apptRenderSlots(i, dateData.slots);
|
|
2623
|
+
document.getElementById(\`appt-form-\${i}\`).style.display = 'none';
|
|
2624
|
+
document.getElementById(\`appt-payment-\${i}\`).style.display = 'none';
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
function apptRenderSlots(i, slots) {
|
|
2628
|
+
const grid = document.getElementById(\`appt-slot-grid-\${i}\`);
|
|
2629
|
+
grid.innerHTML = '';
|
|
2630
|
+
slots.forEach(slotStr => {
|
|
2631
|
+
const [, time] = slotStr.split('T');
|
|
2632
|
+
const [h, m] = time.split(':').map(Number);
|
|
2633
|
+
const ampm = h >= 12 ? 'PM' : 'AM';
|
|
2634
|
+
const h12 = h % 12 || 12;
|
|
2635
|
+
const label = h12 + ':' + String(m).padStart(2, '0') + ' ' + ampm;
|
|
2636
|
+
const btn = document.createElement('button');
|
|
2637
|
+
btn.className = 'appt-slot-btn';
|
|
2638
|
+
btn.textContent = label;
|
|
2639
|
+
btn.addEventListener('click', () => apptSelectSlot(i, slotStr, label));
|
|
2640
|
+
grid.appendChild(btn);
|
|
2641
|
+
});
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2644
|
+
function apptSelectSlot(i, slotStr, label) {
|
|
2645
|
+
_apptState[i].selectedSlot = slotStr;
|
|
2646
|
+
document.querySelectorAll(\`#appt-slot-grid-\${i} .appt-slot-btn\`).forEach(b => b.classList.remove('active'));
|
|
2647
|
+
event.currentTarget.classList.add('active');
|
|
2648
|
+
const display = apptFormatSlot(i, slotStr);
|
|
2649
|
+
document.getElementById(\`appt-slot-display-\${i}\`).textContent = 'π
' + display;
|
|
2650
|
+
const formDiv = document.getElementById(\`appt-form-\${i}\`);
|
|
2651
|
+
formDiv.style.display = 'block';
|
|
2652
|
+
formDiv.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
function apptFormatSlot(i, slotStr) {
|
|
2656
|
+
const appt = _apptsData[i];
|
|
2657
|
+
const [datePart, timePart] = slotStr.split('T');
|
|
2658
|
+
const [y, mo, d] = datePart.split('-').map(Number);
|
|
2659
|
+
const [h, m] = timePart.split(':').map(Number);
|
|
2660
|
+
const dateObj = new Date(y, mo - 1, d);
|
|
2661
|
+
const dateLabel = dateObj.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' });
|
|
2662
|
+
const ampm = h >= 12 ? 'PM' : 'AM';
|
|
2663
|
+
const h12 = h % 12 || 12;
|
|
2664
|
+
return dateLabel + ' at ' + h12 + ':' + String(m).padStart(2,'0') + ' ' + ampm + ' ' + (appt.timezone || '');
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
function apptBackToSlots(i) {
|
|
2668
|
+
_apptState[i].selectedSlot = null;
|
|
2669
|
+
document.getElementById(\`appt-form-\${i}\`).style.display = 'none';
|
|
2670
|
+
document.getElementById(\`appt-payment-\${i}\`).style.display = 'none';
|
|
2671
|
+
document.querySelectorAll(\`#appt-slot-grid-\${i} .appt-slot-btn\`).forEach(b => b.classList.remove('active'));
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
async function apptProceed(i) {
|
|
2675
|
+
const appt = _apptsData[i];
|
|
2676
|
+
const recoveryKey = document.getElementById(\`appt-rkey-\${i}\`).value.trim();
|
|
2677
|
+
const name = document.getElementById(\`appt-name-\${i}\`).value.trim();
|
|
2678
|
+
const email = document.getElementById(\`appt-email-\${i}\`).value.trim();
|
|
2679
|
+
const errEl = document.getElementById(\`appt-form-error-\${i}\`);
|
|
2680
|
+
const selectedSlot = _apptState[i] && _apptState[i].selectedSlot;
|
|
2681
|
+
if (!recoveryKey) { errEl.textContent = 'Recovery key is required.'; errEl.style.display = 'block'; return; }
|
|
2682
|
+
if (!name) { errEl.textContent = 'Name is required.'; errEl.style.display = 'block'; return; }
|
|
2683
|
+
if (!email) { errEl.textContent = 'Email is required.'; errEl.style.display = 'block'; return; }
|
|
2684
|
+
if (!selectedSlot) { errEl.textContent = 'Please select a time slot.'; errEl.style.display = 'block'; return; }
|
|
2685
|
+
errEl.style.display = 'none';
|
|
2686
|
+
document.getElementById(\`appt-proceed-btn-\${i}\`).disabled = true;
|
|
2687
|
+
try {
|
|
2688
|
+
const resp = await fetch('/plugin/shoppe/${tenant.uuid}/purchase/intent', {
|
|
2689
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
2690
|
+
body: JSON.stringify({ recoveryKey, productId: appt.productId, title: appt.title, slotDatetime: selectedSlot })
|
|
2691
|
+
});
|
|
2692
|
+
const data = await resp.json();
|
|
2693
|
+
if (data.error) { errEl.textContent = data.error; errEl.style.display = 'block'; document.getElementById(\`appt-proceed-btn-\${i}\`).disabled = false; return; }
|
|
2694
|
+
if (data.free) {
|
|
2695
|
+
await fetch('/plugin/shoppe/${tenant.uuid}/purchase/complete', {
|
|
2696
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
2697
|
+
body: JSON.stringify({ recoveryKey, productId: appt.productId, title: appt.title, slotDatetime: selectedSlot, contactInfo: { name, email } })
|
|
2698
|
+
});
|
|
2699
|
+
document.getElementById(\`appt-form-\${i}\`).style.display = 'none';
|
|
2700
|
+
apptShowConfirm(i, selectedSlot);
|
|
2701
|
+
return;
|
|
2702
|
+
}
|
|
2703
|
+
document.getElementById(\`appt-form-\${i}\`).style.display = 'none';
|
|
2704
|
+
const payDiv = document.getElementById(\`appt-payment-\${i}\`);
|
|
2705
|
+
payDiv.style.display = 'block';
|
|
2706
|
+
document.getElementById(\`appt-slot-display-pay-\${i}\`).textContent = 'π
' + apptFormatSlot(i, selectedSlot);
|
|
2707
|
+
_apptSecrets[i] = data.clientSecret;
|
|
2708
|
+
_apptStripe[i] = Stripe(data.publishableKey);
|
|
2709
|
+
_apptElems[i] = _apptStripe[i].elements({ clientSecret: data.clientSecret });
|
|
2710
|
+
_apptElems[i].create('payment').mount(\`#appt-stripe-el-\${i}\`);
|
|
2711
|
+
payDiv.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
2712
|
+
} catch (err) {
|
|
2713
|
+
errEl.textContent = 'Could not start checkout. Please try again.';
|
|
2714
|
+
errEl.style.display = 'block';
|
|
2715
|
+
document.getElementById(\`appt-proceed-btn-\${i}\`).disabled = false;
|
|
2716
|
+
}
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2719
|
+
async function apptConfirmPayment(i) {
|
|
2720
|
+
const appt = _apptsData[i];
|
|
2721
|
+
const payBtn = document.getElementById(\`appt-pay-btn-\${i}\`);
|
|
2722
|
+
const payLoading = document.getElementById(\`appt-pay-loading-\${i}\`);
|
|
2723
|
+
const payError = document.getElementById(\`appt-pay-error-\${i}\`);
|
|
2724
|
+
payBtn.disabled = true; payLoading.style.display = 'block'; payLoading.textContent = 'Processingβ¦'; payError.style.display = 'none';
|
|
2725
|
+
try {
|
|
2726
|
+
const { error } = await _apptStripe[i].confirmPayment({
|
|
2727
|
+
elements: _apptElems[i], confirmParams: { return_url: window.location.href }, redirect: 'if_required'
|
|
2728
|
+
});
|
|
2729
|
+
if (error) { payError.textContent = error.message; payError.style.display = 'block'; payBtn.disabled = false; payLoading.style.display = 'none'; return; }
|
|
2730
|
+
const recoveryKey = document.getElementById(\`appt-rkey-\${i}\`).value.trim();
|
|
2731
|
+
const name = document.getElementById(\`appt-name-\${i}\`).value.trim();
|
|
2732
|
+
const email = document.getElementById(\`appt-email-\${i}\`).value.trim();
|
|
2733
|
+
const selectedSlot = _apptState[i] && _apptState[i].selectedSlot;
|
|
2734
|
+
const paymentIntentId = _apptSecrets[i] ? _apptSecrets[i].split('_secret_')[0] : undefined;
|
|
2735
|
+
await fetch('/plugin/shoppe/${tenant.uuid}/purchase/complete', {
|
|
2736
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
2737
|
+
body: JSON.stringify({ recoveryKey, productId: appt.productId, title: appt.title, slotDatetime: selectedSlot, contactInfo: { name, email }, paymentIntentId })
|
|
2738
|
+
});
|
|
2739
|
+
document.getElementById(\`appt-payment-\${i}\`).style.display = 'none';
|
|
2740
|
+
apptShowConfirm(i, selectedSlot);
|
|
2741
|
+
} catch (err) {
|
|
2742
|
+
payError.textContent = 'An unexpected error occurred.'; payError.style.display = 'block';
|
|
2743
|
+
payBtn.disabled = false; payLoading.style.display = 'none';
|
|
2744
|
+
}
|
|
2745
|
+
}
|
|
2746
|
+
|
|
2747
|
+
function apptShowConfirm(i, slotStr) {
|
|
2748
|
+
const conf = document.getElementById(\`appt-confirm-\${i}\`);
|
|
2749
|
+
conf.style.display = 'block';
|
|
2750
|
+
document.getElementById(\`appt-confirm-slot-\${i}\`).textContent = apptFormatSlot(i, slotStr);
|
|
2751
|
+
conf.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
2752
|
+
}
|
|
2753
|
+
|
|
1879
2754
|
async function startVideoUpload(input, shoppeId, title) {
|
|
1880
2755
|
const file = input.files[0];
|
|
1881
2756
|
if (!file) return;
|
|
@@ -2835,6 +3710,49 @@ async function startServer(params) {
|
|
|
2835
3710
|
}
|
|
2836
3711
|
});
|
|
2837
3712
|
|
|
3713
|
+
// Music feed β builds { albums, tracks } from Sanora products directly
|
|
3714
|
+
app.get('/plugin/shoppe/:identifier/music/feed', async (req, res) => {
|
|
3715
|
+
try {
|
|
3716
|
+
const tenant = getTenantByIdentifier(req.params.identifier);
|
|
3717
|
+
if (!tenant) return res.status(404).json({ error: 'Shoppe not found' });
|
|
3718
|
+
const sanoraUrl = getSanoraUrl();
|
|
3719
|
+
const productsResp = await fetchWithRetry(`${sanoraUrl}/products/${tenant.uuid}`, { timeout: 15000 });
|
|
3720
|
+
if (!productsResp.ok) return res.status(502).json({ error: 'Could not load products' });
|
|
3721
|
+
const products = await productsResp.json();
|
|
3722
|
+
|
|
3723
|
+
const albums = [];
|
|
3724
|
+
const tracks = [];
|
|
3725
|
+
for (const [key, product] of Object.entries(products)) {
|
|
3726
|
+
if (product.category !== 'music') continue;
|
|
3727
|
+
const cover = product.image ? `${sanoraUrl}/images/${product.image}` : null;
|
|
3728
|
+
const artifacts = product.artifacts || [];
|
|
3729
|
+
if (artifacts.length > 1) {
|
|
3730
|
+
albums.push({
|
|
3731
|
+
name: product.title || key,
|
|
3732
|
+
cover,
|
|
3733
|
+
description: product.description || '',
|
|
3734
|
+
tracks: artifacts.map((a, i) => ({
|
|
3735
|
+
number: i + 1,
|
|
3736
|
+
title: `Track ${i + 1}`,
|
|
3737
|
+
src: `${sanoraUrl}/artifacts/${a}`,
|
|
3738
|
+
type: 'audio/mpeg'
|
|
3739
|
+
}))
|
|
3740
|
+
});
|
|
3741
|
+
} else if (artifacts.length === 1) {
|
|
3742
|
+
tracks.push({
|
|
3743
|
+
title: product.title || key,
|
|
3744
|
+
src: `${sanoraUrl}/artifacts/${artifacts[0]}`,
|
|
3745
|
+
cover,
|
|
3746
|
+
description: product.description || ''
|
|
3747
|
+
});
|
|
3748
|
+
}
|
|
3749
|
+
}
|
|
3750
|
+
res.json({ albums, tracks });
|
|
3751
|
+
} catch (err) {
|
|
3752
|
+
res.status(500).json({ error: err.message });
|
|
3753
|
+
}
|
|
3754
|
+
});
|
|
3755
|
+
|
|
2838
3756
|
// Shoppe HTML page (public)
|
|
2839
3757
|
app.get('/plugin/shoppe/:identifier', async (req, res) => {
|
|
2840
3758
|
try {
|