wiki-plugin-shoppe 0.0.33 β 0.0.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/shoppe.js +5 -5
- package/package.json +1 -1
- package/server/server.js +927 -25
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);
|
|
@@ -1433,6 +1436,7 @@ async function getShoppeGoods(tenant) {
|
|
|
1433
1436
|
shipping: product.shipping || 0,
|
|
1434
1437
|
image: product.image ? `${getSanoraUrl()}/images/${product.image}` : null,
|
|
1435
1438
|
url: (bucketName && redirects[bucketName]) || defaultUrl,
|
|
1439
|
+
...(isPost && { category: product.category, tags: product.tags || '' }),
|
|
1436
1440
|
...(lucillePlayerUrl && { lucillePlayerUrl }),
|
|
1437
1441
|
...(product.category === 'video' && { shoppeId: tenant.uuid })
|
|
1438
1442
|
};
|
|
@@ -1440,6 +1444,30 @@ async function getShoppeGoods(tenant) {
|
|
|
1440
1444
|
if (bucket) bucket.push(item);
|
|
1441
1445
|
}
|
|
1442
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
|
+
|
|
1443
1471
|
return goods;
|
|
1444
1472
|
}
|
|
1445
1473
|
|
|
@@ -1786,32 +1814,86 @@ function generateShoppeHTML(tenant, goods, uploadAuth = null) {
|
|
|
1786
1814
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1787
1815
|
<title>${tenant.name}</title>
|
|
1788
1816
|
${tenant.keywords ? `<meta name="keywords" content="${escHtml(tenant.keywords)}">` : ''}
|
|
1817
|
+
<script src="https://js.stripe.com/v3/"></script>
|
|
1789
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
|
+
}
|
|
1790
1872
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
1791
|
-
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background:
|
|
1873
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); }
|
|
1792
1874
|
header { background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); color: white; padding: 48px 24px 40px; text-align: center; }
|
|
1793
1875
|
.emojicode { font-size: 30px; letter-spacing: 6px; margin-bottom: 14px; }
|
|
1794
1876
|
header h1 { font-size: 38px; font-weight: 700; margin-bottom: 6px; }
|
|
1795
1877
|
.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:
|
|
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; }
|
|
1801
1883
|
main { max-width: 1200px; margin: 0 auto; padding: 36px 24px; }
|
|
1802
1884
|
.section { display: none; }
|
|
1803
1885
|
.section.active { display: block; }
|
|
1804
1886
|
.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
|
|
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); }
|
|
1807
1889
|
.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:
|
|
1890
|
+
.card-img-placeholder { height: 110px; display: flex; align-items: center; justify-content: center; font-size: 44px; background: var(--placeholder); }
|
|
1809
1891
|
.card-body { padding: 16px; }
|
|
1810
1892
|
.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:
|
|
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; }
|
|
1815
1897
|
.card-video-play { position: relative; }
|
|
1816
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; }
|
|
1817
1899
|
.card:hover .card-video-play::after { opacity: 1; }
|
|
@@ -1823,14 +1905,131 @@ function generateShoppeHTML(tenant, goods, uploadAuth = null) {
|
|
|
1823
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; }
|
|
1824
1906
|
.video-modal-close:hover { background: rgba(0,0,0,0.8); }
|
|
1825
1907
|
.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:
|
|
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; }
|
|
1831
2030
|
</style>
|
|
1832
2031
|
</head>
|
|
1833
|
-
<body>
|
|
2032
|
+
<body${tenant.lightMode ? ' class="light"' : ''}>
|
|
1834
2033
|
<header>
|
|
1835
2034
|
<div class="emojicode">${tenant.emojicode}</div>
|
|
1836
2035
|
<h1>${tenant.name}</h1>
|
|
@@ -1840,17 +2039,56 @@ function generateShoppeHTML(tenant, goods, uploadAuth = null) {
|
|
|
1840
2039
|
<main>
|
|
1841
2040
|
<div id="all" class="section active"><div class="grid">${renderCards(allItems, 'all')}</div></div>
|
|
1842
2041
|
<div id="books" class="section"><div class="grid">${renderCards(goods.books, 'book')}</div></div>
|
|
1843
|
-
<div id="music" class="section"
|
|
1844
|
-
|
|
2042
|
+
<div id="music" class="section">
|
|
2043
|
+
<div id="music-album-grid"></div>
|
|
2044
|
+
<div id="music-album-detail" style="display:none">
|
|
2045
|
+
<button class="music-back-btn" onclick="musicShowGrid()">← Albums</button>
|
|
2046
|
+
<div id="music-detail-header"></div>
|
|
2047
|
+
<div id="music-track-list"></div>
|
|
2048
|
+
</div>
|
|
2049
|
+
</div>
|
|
2050
|
+
<div id="posts" class="section">
|
|
2051
|
+
<div id="posts-grid"></div>
|
|
2052
|
+
<div id="posts-series-detail" style="display:none">
|
|
2053
|
+
<button class="posts-back-btn" onclick="postsShowGrid()">← Posts</button>
|
|
2054
|
+
<div id="posts-series-header"></div>
|
|
2055
|
+
<div id="posts-parts-list"></div>
|
|
2056
|
+
</div>
|
|
2057
|
+
</div>
|
|
1845
2058
|
<div id="albums" class="section"><div class="grid">${renderCards(goods.albums, 'album')}</div></div>
|
|
1846
2059
|
<div id="products" class="section"><div class="grid">${renderCards(goods.products, 'product')}</div></div>
|
|
1847
2060
|
<div id="videos" class="section"><div class="grid">${renderCards(goods.videos, 'video')}</div></div>
|
|
1848
|
-
<div id="appointments" class="section"
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
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>
|
|
1852
2069
|
</div>
|
|
1853
2070
|
</main>
|
|
2071
|
+
<div id="music-player-bar">
|
|
2072
|
+
<div class="music-bar-inner">
|
|
2073
|
+
<img id="music-bar-art" class="music-bar-art" src="" alt="" style="display:none">
|
|
2074
|
+
<div class="music-bar-info">
|
|
2075
|
+
<div class="music-bar-title" id="music-bar-title">β</div>
|
|
2076
|
+
<div class="music-bar-album" id="music-bar-album"></div>
|
|
2077
|
+
</div>
|
|
2078
|
+
<div class="music-bar-controls">
|
|
2079
|
+
<button class="music-bar-btn" onclick="musicBarPrev()" title="Previous">◀◀</button>
|
|
2080
|
+
<button class="music-bar-btn" id="music-bar-play" onclick="musicBarPlayPause()" title="Play/Pause">▶</button>
|
|
2081
|
+
<button class="music-bar-btn" onclick="musicBarNext()" title="Next">▶▶</button>
|
|
2082
|
+
</div>
|
|
2083
|
+
<div class="music-bar-progress">
|
|
2084
|
+
<span class="music-bar-time" id="music-bar-time">0:00</span>
|
|
2085
|
+
<div class="music-bar-track" onclick="musicBarSeek(event)">
|
|
2086
|
+
<div class="music-bar-fill" id="music-bar-fill"></div>
|
|
2087
|
+
</div>
|
|
2088
|
+
<span class="music-bar-time" id="music-bar-dur">0:00</span>
|
|
2089
|
+
</div>
|
|
2090
|
+
</div>
|
|
2091
|
+
</div>
|
|
1854
2092
|
<div id="video-modal" class="video-modal">
|
|
1855
2093
|
<div class="video-modal-backdrop" onclick="closeVideo()"></div>
|
|
1856
2094
|
<div class="video-modal-content">
|
|
@@ -1865,6 +2103,248 @@ function generateShoppeHTML(tenant, goods, uploadAuth = null) {
|
|
|
1865
2103
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
1866
2104
|
document.getElementById(id).classList.add('active');
|
|
1867
2105
|
tab.classList.add('active');
|
|
2106
|
+
if (id === 'music' && !_musicLoaded) initMusic();
|
|
2107
|
+
if (id === 'posts' && !_postsLoaded) initPosts();
|
|
2108
|
+
if (id === 'subscriptions' && !_subsLoaded) initSubscriptions();
|
|
2109
|
+
if (id === 'appointments' && !_apptsLoaded) initAppointments();
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
// ββ Posts browser βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
2113
|
+
const _postsRaw = ${(() => {
|
|
2114
|
+
// Build structured posts data server-side so the client just reads JSON.
|
|
2115
|
+
const seriesMap = {}; // seriesTitle β { item, parts: [] }
|
|
2116
|
+
const standalones = [];
|
|
2117
|
+
// First pass: collect series parents
|
|
2118
|
+
for (const item of goods.posts) {
|
|
2119
|
+
if (item.category === 'post-series') seriesMap[item.title] = { ...item, parts: [] };
|
|
2120
|
+
}
|
|
2121
|
+
// Second pass: attach parts to their series, or collect standalones
|
|
2122
|
+
for (const item of goods.posts) {
|
|
2123
|
+
if (item.category !== 'post') continue;
|
|
2124
|
+
const tagParts = (item.tags || '').split(',');
|
|
2125
|
+
const seriesTag = tagParts.find(t => t.startsWith('series:'));
|
|
2126
|
+
const partTag = tagParts.find(t => t.startsWith('part:'));
|
|
2127
|
+
const seriesTitle = seriesTag ? seriesTag.slice('series:'.length) : null;
|
|
2128
|
+
const partNum = partTag ? parseInt(partTag.slice('part:'.length)) || 0 : 0;
|
|
2129
|
+
if (seriesTitle && seriesMap[seriesTitle]) {
|
|
2130
|
+
seriesMap[seriesTitle].parts.push({ ...item, partNum });
|
|
2131
|
+
} else {
|
|
2132
|
+
standalones.push(item);
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
// Sort parts within each series
|
|
2136
|
+
for (const s of Object.values(seriesMap)) {
|
|
2137
|
+
s.parts.sort((a, b) => (a.partNum || 0) - (b.partNum || 0));
|
|
2138
|
+
}
|
|
2139
|
+
return JSON.stringify({ series: Object.values(seriesMap), standalones });
|
|
2140
|
+
})()};
|
|
2141
|
+
let _postsLoaded = false;
|
|
2142
|
+
|
|
2143
|
+
function initPosts() {
|
|
2144
|
+
_postsLoaded = true;
|
|
2145
|
+
postsRenderGrid();
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
function postsRenderGrid() {
|
|
2149
|
+
const grid = document.getElementById('posts-grid');
|
|
2150
|
+
const { series, standalones } = _postsRaw;
|
|
2151
|
+
if (series.length === 0 && standalones.length === 0) {
|
|
2152
|
+
grid.innerHTML = '<p class="empty">No posts yet.</p>';
|
|
2153
|
+
return;
|
|
2154
|
+
}
|
|
2155
|
+
const seriesHtml = series.length ? \`<div class="posts-grid">\${series.map((s, i) => \`
|
|
2156
|
+
<div class="card" style="cursor:pointer" onclick="postsShowSeries(\${i})">
|
|
2157
|
+
\${s.image ? \`<div class="card-img"><img src="\${_escHtml(s.image)}" alt="" loading="lazy"></div>\` : '<div class="card-img-placeholder">π</div>'}
|
|
2158
|
+
<div class="card-body">
|
|
2159
|
+
<div class="card-title">\${_escHtml(s.title)}</div>
|
|
2160
|
+
\${s.description ? \`<div class="card-desc">\${_escHtml(s.description)}</div>\` : ''}
|
|
2161
|
+
<div style="font-size:12px;color:#0066cc;margin-top:6px;font-weight:600">\${s.parts.length} part\${s.parts.length !== 1 ? 's' : ''}</div>
|
|
2162
|
+
</div>
|
|
2163
|
+
</div>\`).join('')}</div>\` : '';
|
|
2164
|
+
const standaloneHtml = standalones.length ? \`
|
|
2165
|
+
\${series.length ? '<div class="posts-standalones-label">Posts</div>' : ''}
|
|
2166
|
+
<div class="posts-grid">\${standalones.map(p => \`
|
|
2167
|
+
<div class="card" onclick="window.open('\${_escHtml(p.url)}','_self')">
|
|
2168
|
+
\${p.image ? \`<div class="card-img"><img src="\${_escHtml(p.image)}" alt="" loading="lazy"></div>\` : '<div class="card-img-placeholder">π</div>'}
|
|
2169
|
+
<div class="card-body">
|
|
2170
|
+
<div class="card-title">\${_escHtml(p.title)}</div>
|
|
2171
|
+
\${p.description ? \`<div class="card-desc">\${_escHtml(p.description)}</div>\` : ''}
|
|
2172
|
+
</div>
|
|
2173
|
+
</div>\`).join('')}</div>\` : '';
|
|
2174
|
+
grid.innerHTML = seriesHtml + standaloneHtml;
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
function postsShowSeries(idx) {
|
|
2178
|
+
const s = _postsRaw.series[idx];
|
|
2179
|
+
document.getElementById('posts-grid').style.display = 'none';
|
|
2180
|
+
document.getElementById('posts-series-detail').style.display = 'block';
|
|
2181
|
+
document.getElementById('posts-series-header').innerHTML = \`
|
|
2182
|
+
<div class="posts-series-header">
|
|
2183
|
+
\${s.image ? \`<img class="posts-series-cover" src="\${_escHtml(s.image)}" alt="">\` : ''}
|
|
2184
|
+
<div>
|
|
2185
|
+
<div class="posts-series-title">\${_escHtml(s.title)}</div>
|
|
2186
|
+
\${s.description ? \`<div class="posts-series-desc">\${_escHtml(s.description)}</div>\` : ''}
|
|
2187
|
+
</div>
|
|
2188
|
+
</div>\`;
|
|
2189
|
+
document.getElementById('posts-parts-list').innerHTML = s.parts.map((p, i) => \`
|
|
2190
|
+
<a class="posts-part-row" href="\${_escHtml(p.url)}">
|
|
2191
|
+
<div class="posts-part-num">\${p.partNum || i + 1}</div>
|
|
2192
|
+
<div class="posts-part-info">
|
|
2193
|
+
<div class="posts-part-title">\${_escHtml(p.title.replace(s.title + ': ', ''))}</div>
|
|
2194
|
+
\${p.description ? \`<div class="posts-part-desc">\${_escHtml(p.description)}</div>\` : ''}
|
|
2195
|
+
</div>
|
|
2196
|
+
<div class="posts-part-arrow">→</div>
|
|
2197
|
+
</a>\`).join('');
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
function postsShowGrid() {
|
|
2201
|
+
document.getElementById('posts-grid').style.display = '';
|
|
2202
|
+
document.getElementById('posts-series-detail').style.display = 'none';
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
// ββ Music player ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
2206
|
+
let _musicLoaded = false, _musicAlbums = [], _musicTracks = [], _musicAllTracks = [], _musicCurrentIdx = -1;
|
|
2207
|
+
const _musicAudio = new Audio();
|
|
2208
|
+
|
|
2209
|
+
function _escHtml(s) {
|
|
2210
|
+
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
async function initMusic() {
|
|
2214
|
+
_musicLoaded = true;
|
|
2215
|
+
const grid = document.getElementById('music-album-grid');
|
|
2216
|
+
grid.innerHTML = '<p class="empty">Loading music\u2026</p>';
|
|
2217
|
+
try {
|
|
2218
|
+
const resp = await fetch('/plugin/shoppe/${tenant.uuid}/music/feed');
|
|
2219
|
+
const data = await resp.json();
|
|
2220
|
+
_musicAlbums = data.albums || [];
|
|
2221
|
+
_musicTracks = data.tracks || [];
|
|
2222
|
+
_musicAllTracks = [
|
|
2223
|
+
..._musicAlbums.flatMap(a => a.tracks.map(t => ({ ...t, cover: a.cover, albumName: a.name }))),
|
|
2224
|
+
..._musicTracks.map(t => ({ ...t, albumName: '' }))
|
|
2225
|
+
];
|
|
2226
|
+
musicRenderGrid();
|
|
2227
|
+
} catch (e) {
|
|
2228
|
+
grid.innerHTML = '<p class="empty">Could not load music.</p>';
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
function musicRenderGrid() {
|
|
2233
|
+
const grid = document.getElementById('music-album-grid');
|
|
2234
|
+
if (_musicAlbums.length === 0 && _musicTracks.length === 0) {
|
|
2235
|
+
grid.innerHTML = '<p class="empty">No music yet.</p>';
|
|
2236
|
+
return;
|
|
2237
|
+
}
|
|
2238
|
+
const albumsHtml = _musicAlbums.length ? \`<div class="music-grid">\${_musicAlbums.map((a, i) => \`
|
|
2239
|
+
<div class="card music-album-card" onclick="musicShowAlbum(\${i})">
|
|
2240
|
+
\${a.cover ? \`<div class="card-img"><img src="\${_escHtml(a.cover)}" alt="" loading="lazy"></div>\` : '<div class="card-img-placeholder">π΅</div>'}
|
|
2241
|
+
<div class="card-body">
|
|
2242
|
+
<div class="card-title">\${_escHtml(a.name)}</div>
|
|
2243
|
+
<div class="card-desc">\${a.tracks.length} track\${a.tracks.length !== 1 ? 's' : ''}</div>
|
|
2244
|
+
</div>
|
|
2245
|
+
</div>\`).join('')}</div>\` : '';
|
|
2246
|
+
const tracksHtml = _musicTracks.length ? \`
|
|
2247
|
+
<div class="music-singles-label">Singles</div>
|
|
2248
|
+
\${_musicTracks.map((t, i) => \`
|
|
2249
|
+
<div class="music-track-row" id="mts-\${i}" onclick="musicPlayStandalone(\${i})">
|
|
2250
|
+
\${t.cover ? \`<img class="music-track-cover" src="\${_escHtml(t.cover)}" alt="">\` : '<div class="music-track-cover-ph">π΅</div>'}
|
|
2251
|
+
<div class="music-track-info"><div class="music-track-title">\${_escHtml(t.title)}</div></div>
|
|
2252
|
+
<div class="music-play-icon">▶</div>
|
|
2253
|
+
</div>\`).join('')}\` : '';
|
|
2254
|
+
grid.innerHTML = albumsHtml + tracksHtml;
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
function musicShowAlbum(idx) {
|
|
2258
|
+
const a = _musicAlbums[idx];
|
|
2259
|
+
document.getElementById('music-album-grid').style.display = 'none';
|
|
2260
|
+
const det = document.getElementById('music-album-detail');
|
|
2261
|
+
det.style.display = 'block';
|
|
2262
|
+
document.getElementById('music-detail-header').innerHTML = \`
|
|
2263
|
+
<div class="music-detail-header">
|
|
2264
|
+
\${a.cover ? \`<img class="music-detail-cover" src="\${_escHtml(a.cover)}" alt="">\` : ''}
|
|
2265
|
+
<div>
|
|
2266
|
+
<div class="music-detail-title">\${_escHtml(a.name)}</div>
|
|
2267
|
+
\${a.description ? \`<div class="music-detail-desc">\${_escHtml(a.description)}</div>\` : ''}
|
|
2268
|
+
</div>
|
|
2269
|
+
</div>\`;
|
|
2270
|
+
document.getElementById('music-track-list').innerHTML = a.tracks.map((t, i) => \`
|
|
2271
|
+
<div class="music-track-row" id="mta-\${idx}-\${i}" onclick="musicPlayAlbumTrack(\${idx},\${i})">
|
|
2272
|
+
<div class="music-track-num">\${t.number}</div>
|
|
2273
|
+
<div class="music-track-info"><div class="music-track-title">\${_escHtml(t.title)}</div></div>
|
|
2274
|
+
<div class="music-play-icon">▶</div>
|
|
2275
|
+
</div>\`).join('');
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
function musicShowGrid() {
|
|
2279
|
+
document.getElementById('music-album-grid').style.display = '';
|
|
2280
|
+
document.getElementById('music-album-detail').style.display = 'none';
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
function musicPlayAlbumTrack(albumIdx, trackIdx) {
|
|
2284
|
+
const a = _musicAlbums[albumIdx], t = a.tracks[trackIdx];
|
|
2285
|
+
_musicCurrentIdx = _musicAllTracks.findIndex(x => x.src === t.src);
|
|
2286
|
+
_musicDoPlay({ ...t, cover: a.cover, albumName: a.name });
|
|
2287
|
+
document.querySelectorAll('[id^="mta-"]').forEach(el => el.classList.remove('playing'));
|
|
2288
|
+
const el = document.getElementById(\`mta-\${albumIdx}-\${trackIdx}\`);
|
|
2289
|
+
if (el) el.classList.add('playing');
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
function musicPlayStandalone(idx) {
|
|
2293
|
+
const t = _musicTracks[idx];
|
|
2294
|
+
_musicCurrentIdx = _musicAllTracks.findIndex(x => x.src === t.src);
|
|
2295
|
+
_musicDoPlay(t);
|
|
2296
|
+
document.querySelectorAll('[id^="mts-"]').forEach(el => el.classList.remove('playing'));
|
|
2297
|
+
const el = document.getElementById(\`mts-\${idx}\`);
|
|
2298
|
+
if (el) el.classList.add('playing');
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
function _musicDoPlay(track) {
|
|
2302
|
+
_musicAudio.src = track.src;
|
|
2303
|
+
_musicAudio.play();
|
|
2304
|
+
const bar = document.getElementById('music-player-bar');
|
|
2305
|
+
bar.style.display = 'block';
|
|
2306
|
+
document.getElementById('music-bar-title').textContent = track.title;
|
|
2307
|
+
document.getElementById('music-bar-album').textContent = track.albumName || '';
|
|
2308
|
+
const art = document.getElementById('music-bar-art');
|
|
2309
|
+
if (track.cover) { art.src = track.cover; art.style.display = 'block'; }
|
|
2310
|
+
else art.style.display = 'none';
|
|
2311
|
+
document.getElementById('music-bar-play').innerHTML = '▮▮';
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
_musicAudio.addEventListener('ended', () => {
|
|
2315
|
+
if (_musicCurrentIdx < _musicAllTracks.length - 1) {
|
|
2316
|
+
_musicCurrentIdx++;
|
|
2317
|
+
_musicDoPlay(_musicAllTracks[_musicCurrentIdx]);
|
|
2318
|
+
} else {
|
|
2319
|
+
document.getElementById('music-bar-play').innerHTML = '▶';
|
|
2320
|
+
}
|
|
2321
|
+
});
|
|
2322
|
+
_musicAudio.addEventListener('timeupdate', () => {
|
|
2323
|
+
if (!_musicAudio.duration) return;
|
|
2324
|
+
document.getElementById('music-bar-fill').style.width = (_musicAudio.currentTime / _musicAudio.duration * 100) + '%';
|
|
2325
|
+
document.getElementById('music-bar-time').textContent = _musicFmt(_musicAudio.currentTime);
|
|
2326
|
+
});
|
|
2327
|
+
_musicAudio.addEventListener('loadedmetadata', () => {
|
|
2328
|
+
document.getElementById('music-bar-dur').textContent = _musicFmt(_musicAudio.duration);
|
|
2329
|
+
});
|
|
2330
|
+
|
|
2331
|
+
function _musicFmt(s) {
|
|
2332
|
+
if (!s || isNaN(s)) return '0:00';
|
|
2333
|
+
return Math.floor(s / 60) + ':' + String(Math.floor(s % 60)).padStart(2, '0');
|
|
2334
|
+
}
|
|
2335
|
+
function musicBarPlayPause() {
|
|
2336
|
+
if (_musicAudio.paused) { _musicAudio.play(); document.getElementById('music-bar-play').innerHTML = '▮▮'; }
|
|
2337
|
+
else { _musicAudio.pause(); document.getElementById('music-bar-play').innerHTML = '▶'; }
|
|
2338
|
+
}
|
|
2339
|
+
function musicBarPrev() {
|
|
2340
|
+
if (_musicCurrentIdx > 0) { _musicCurrentIdx--; _musicDoPlay(_musicAllTracks[_musicCurrentIdx]); }
|
|
2341
|
+
}
|
|
2342
|
+
function musicBarNext() {
|
|
2343
|
+
if (_musicCurrentIdx < _musicAllTracks.length - 1) { _musicCurrentIdx++; _musicDoPlay(_musicAllTracks[_musicCurrentIdx]); }
|
|
2344
|
+
}
|
|
2345
|
+
function musicBarSeek(e) {
|
|
2346
|
+
const r = e.currentTarget.getBoundingClientRect();
|
|
2347
|
+
_musicAudio.currentTime = _musicAudio.duration * ((e.clientX - r.left) / r.width);
|
|
1868
2348
|
}
|
|
1869
2349
|
function playVideo(url) {
|
|
1870
2350
|
document.getElementById('video-iframe').src = url;
|
|
@@ -1876,6 +2356,396 @@ function generateShoppeHTML(tenant, goods, uploadAuth = null) {
|
|
|
1876
2356
|
}
|
|
1877
2357
|
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeVideo(); });
|
|
1878
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
|
+
|
|
1879
2749
|
async function startVideoUpload(input, shoppeId, title) {
|
|
1880
2750
|
const file = input.files[0];
|
|
1881
2751
|
if (!file) return;
|
|
@@ -2835,6 +3705,38 @@ async function startServer(params) {
|
|
|
2835
3705
|
}
|
|
2836
3706
|
});
|
|
2837
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
|
+
|
|
2838
3740
|
// Shoppe HTML page (public)
|
|
2839
3741
|
app.get('/plugin/shoppe/:identifier', async (req, res) => {
|
|
2840
3742
|
try {
|