wiki-plugin-shoppe 0.0.20 → 0.0.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +56 -0
- package/client/shoppe.js +7 -5
- package/package.json +6 -2
- package/server/server.js +569 -45
- package/server/templates/appointment-booking.html +458 -0
- package/server/templates/generic-address-stripe.html +55 -58
- package/server/templates/subscription-membership.html +290 -0
- package/server/templates/subscription-subscribe.html +258 -0
- package/test/test.js +376 -0
package/server/server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
(function() {
|
|
2
2
|
const fs = require('fs');
|
|
3
3
|
const path = require('path');
|
|
4
|
+
const crypto = require('crypto');
|
|
4
5
|
const fetch = require('node-fetch');
|
|
5
6
|
const multer = require('multer');
|
|
6
7
|
const FormData = require('form-data');
|
|
@@ -10,9 +11,14 @@ const sessionless = require('sessionless-node');
|
|
|
10
11
|
const SHOPPE_BASE_EMOJI = process.env.SHOPPE_BASE_EMOJI || '🛍️🎨🎁';
|
|
11
12
|
|
|
12
13
|
const TEMPLATES_DIR = path.join(__dirname, 'templates');
|
|
13
|
-
const RECOVER_STRIPE_TMPL
|
|
14
|
-
const ADDRESS_STRIPE_TMPL
|
|
15
|
-
const EBOOK_DOWNLOAD_TMPL
|
|
14
|
+
const RECOVER_STRIPE_TMPL = fs.readFileSync(path.join(TEMPLATES_DIR, 'generic-recover-stripe.html'), 'utf8');
|
|
15
|
+
const ADDRESS_STRIPE_TMPL = fs.readFileSync(path.join(TEMPLATES_DIR, 'generic-address-stripe.html'), 'utf8');
|
|
16
|
+
const EBOOK_DOWNLOAD_TMPL = fs.readFileSync(path.join(TEMPLATES_DIR, 'ebook-download.html'), 'utf8');
|
|
17
|
+
const APPOINTMENT_BOOKING_TMPL = fs.readFileSync(path.join(TEMPLATES_DIR, 'appointment-booking.html'), 'utf8');
|
|
18
|
+
const SUBSCRIPTION_SUBSCRIBE_TMPL = fs.readFileSync(path.join(TEMPLATES_DIR, 'subscription-subscribe.html'), 'utf8');
|
|
19
|
+
const SUBSCRIPTION_MEMBERSHIP_TMPL = fs.readFileSync(path.join(TEMPLATES_DIR, 'subscription-membership.html'), 'utf8');
|
|
20
|
+
|
|
21
|
+
const SUBSCRIPTION_PERIOD_MS = 30 * 24 * 60 * 60 * 1000; // default 30-day billing period
|
|
16
22
|
|
|
17
23
|
|
|
18
24
|
function fillTemplate(tmpl, vars) {
|
|
@@ -24,6 +30,9 @@ const DATA_DIR = path.join(process.env.HOME || '/root', '.shoppe');
|
|
|
24
30
|
const TENANTS_FILE = path.join(DATA_DIR, 'tenants.json');
|
|
25
31
|
const BUYERS_FILE = path.join(DATA_DIR, 'buyers.json');
|
|
26
32
|
const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
|
|
33
|
+
// Shipping addresses are stored locally only — never forwarded to Sanora or any third party.
|
|
34
|
+
// This file contains PII (name, address). Purge individual records once orders ship.
|
|
35
|
+
const ORDERS_FILE = path.join(DATA_DIR, 'orders.json');
|
|
27
36
|
const TMP_DIR = '/tmp/shoppe-uploads';
|
|
28
37
|
|
|
29
38
|
// ============================================================
|
|
@@ -418,7 +427,7 @@ async function processArchive(zipPath) {
|
|
|
418
427
|
throw new Error('emojicode does not match registered tenant');
|
|
419
428
|
}
|
|
420
429
|
|
|
421
|
-
const results = { books: [], music: [], posts: [], albums: [], products: [], warnings: [] };
|
|
430
|
+
const results = { books: [], music: [], posts: [], albums: [], products: [], appointments: [], subscriptions: [], warnings: [] };
|
|
422
431
|
|
|
423
432
|
function readInfo(entryPath) {
|
|
424
433
|
const infoPath = path.join(entryPath, 'info.json');
|
|
@@ -731,6 +740,107 @@ async function processArchive(zipPath) {
|
|
|
731
740
|
}
|
|
732
741
|
}
|
|
733
742
|
|
|
743
|
+
// ---- subscriptions/ ----
|
|
744
|
+
// Each subfolder defines one support tier (Patreon-style).
|
|
745
|
+
// info.json: { title, description, price (cents/month), benefits: [], renewalDays: 30 }
|
|
746
|
+
// cover.jpg / hero.jpg → product image. All other files → exclusive member artifacts.
|
|
747
|
+
const subscriptionsDir = path.join(root, 'subscriptions');
|
|
748
|
+
if (fs.existsSync(subscriptionsDir)) {
|
|
749
|
+
const subFolders = fs.readdirSync(subscriptionsDir)
|
|
750
|
+
.filter(f => fs.statSync(path.join(subscriptionsDir, f)).isDirectory())
|
|
751
|
+
.sort();
|
|
752
|
+
|
|
753
|
+
for (const entry of subFolders) {
|
|
754
|
+
const entryPath = path.join(subscriptionsDir, entry);
|
|
755
|
+
const folderTitle = entry.replace(/^\d+-/, '');
|
|
756
|
+
try {
|
|
757
|
+
const info = readInfo(entryPath);
|
|
758
|
+
const title = info.title || folderTitle;
|
|
759
|
+
const description = info.description || '';
|
|
760
|
+
const price = info.price || 0;
|
|
761
|
+
const tierMeta = {
|
|
762
|
+
benefits: info.benefits || [],
|
|
763
|
+
renewalDays: info.renewalDays || 30
|
|
764
|
+
};
|
|
765
|
+
|
|
766
|
+
await sanoraCreateProduct(tenant, title, 'subscription', description, price, 0, 'subscription');
|
|
767
|
+
|
|
768
|
+
// Upload tier metadata (benefits list, renewal period) as a JSON artifact
|
|
769
|
+
const tierBuf = Buffer.from(JSON.stringify(tierMeta));
|
|
770
|
+
await sanoraUploadArtifact(tenant, title, tierBuf, 'tier-info.json', 'application/json');
|
|
771
|
+
|
|
772
|
+
// Cover image (optional)
|
|
773
|
+
const allFiles = fs.readdirSync(entryPath);
|
|
774
|
+
const coverFile = allFiles.find(f => /^(cover|hero)\.(jpg|jpeg|png|webp)$/i.test(f));
|
|
775
|
+
if (coverFile) {
|
|
776
|
+
const buf = fs.readFileSync(path.join(entryPath, coverFile));
|
|
777
|
+
await sanoraUploadImage(tenant, title, buf, coverFile);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// Every other non-JSON, non-cover file is an exclusive member artifact
|
|
781
|
+
const exclusiveFiles = allFiles.filter(f =>
|
|
782
|
+
f !== 'info.json' && f !== coverFile && !f.endsWith('.json')
|
|
783
|
+
);
|
|
784
|
+
for (const ef of exclusiveFiles) {
|
|
785
|
+
const buf = fs.readFileSync(path.join(entryPath, ef));
|
|
786
|
+
await sanoraUploadArtifact(tenant, title, buf, ef, getMimeType(ef));
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
results.subscriptions.push({ title, price, renewalDays: tierMeta.renewalDays });
|
|
790
|
+
console.log(`[shoppe] 🎁 subscription tier: ${title} ($${price}/mo, ${exclusiveFiles.length} exclusive files)`);
|
|
791
|
+
} catch (err) {
|
|
792
|
+
console.warn(`[shoppe] ⚠️ subscription ${entry}: ${err.message}`);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// ---- appointments/ ----
|
|
798
|
+
// Each subfolder is a bookable appointment type.
|
|
799
|
+
// info.json: { title, description, price, duration (mins), timezone, availability[], advanceDays }
|
|
800
|
+
// availability: [{ day: "monday", start: "09:00", end: "17:00" }, ...]
|
|
801
|
+
const appointmentsDir = path.join(root, 'appointments');
|
|
802
|
+
if (fs.existsSync(appointmentsDir)) {
|
|
803
|
+
const apptFolders = fs.readdirSync(appointmentsDir)
|
|
804
|
+
.filter(f => fs.statSync(path.join(appointmentsDir, f)).isDirectory())
|
|
805
|
+
.sort();
|
|
806
|
+
|
|
807
|
+
for (const entry of apptFolders) {
|
|
808
|
+
const entryPath = path.join(appointmentsDir, entry);
|
|
809
|
+
const folderTitle = entry.replace(/^\d+-/, '');
|
|
810
|
+
try {
|
|
811
|
+
const info = readInfo(entryPath);
|
|
812
|
+
const title = info.title || folderTitle;
|
|
813
|
+
const description = info.description || '';
|
|
814
|
+
const price = info.price || 0;
|
|
815
|
+
const schedule = {
|
|
816
|
+
duration: info.duration || 60,
|
|
817
|
+
timezone: info.timezone || 'America/New_York',
|
|
818
|
+
availability: info.availability || [],
|
|
819
|
+
advanceDays: info.advanceDays || 30
|
|
820
|
+
};
|
|
821
|
+
|
|
822
|
+
await sanoraCreateProduct(tenant, title, 'appointment', description, price, 0, 'appointment');
|
|
823
|
+
|
|
824
|
+
// Upload schedule as a JSON artifact so the booking page can retrieve it
|
|
825
|
+
const scheduleBuf = Buffer.from(JSON.stringify(schedule));
|
|
826
|
+
await sanoraUploadArtifact(tenant, title, scheduleBuf, 'schedule.json', 'application/json');
|
|
827
|
+
|
|
828
|
+
// Cover image (optional)
|
|
829
|
+
const images = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
|
|
830
|
+
const coverFile = images.find(f => /^(cover|hero)\.(jpg|jpeg|png|webp)$/i.test(f)) || images[0];
|
|
831
|
+
if (coverFile) {
|
|
832
|
+
const coverBuf = fs.readFileSync(path.join(entryPath, coverFile));
|
|
833
|
+
await sanoraUploadImage(tenant, title, coverBuf, coverFile);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
results.appointments.push({ title, price, duration: schedule.duration });
|
|
837
|
+
console.log(`[shoppe] 📅 appointment: ${title} ($${price}/session, ${schedule.duration}min)`);
|
|
838
|
+
} catch (err) {
|
|
839
|
+
console.warn(`[shoppe] ⚠️ appointment ${entry}: ${err.message}`);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
734
844
|
return {
|
|
735
845
|
tenant: { uuid: tenant.uuid, emojicode: tenant.emojicode, name: tenant.name },
|
|
736
846
|
results
|
|
@@ -749,7 +859,7 @@ async function getShoppeGoods(tenant) {
|
|
|
749
859
|
const resp = await fetch(`${getSanoraUrl()}/products/${tenant.uuid}`);
|
|
750
860
|
const products = await resp.json();
|
|
751
861
|
|
|
752
|
-
const goods = { books: [], music: [], posts: [], albums: [], products: [] };
|
|
862
|
+
const goods = { books: [], music: [], posts: [], albums: [], products: [], appointments: [], subscriptions: [] };
|
|
753
863
|
|
|
754
864
|
for (const [title, product] of Object.entries(products)) {
|
|
755
865
|
const isPost = product.category === 'post' || product.category === 'post-series';
|
|
@@ -763,13 +873,17 @@ async function getShoppeGoods(tenant) {
|
|
|
763
873
|
? `/plugin/shoppe/${tenant.uuid}/post/${encodeURIComponent(title)}`
|
|
764
874
|
: product.category === 'book'
|
|
765
875
|
? `/plugin/shoppe/${tenant.uuid}/buy/${encodeURIComponent(title)}`
|
|
766
|
-
: product.category === '
|
|
767
|
-
? `/plugin/shoppe/${tenant.uuid}/
|
|
768
|
-
: product.category === '
|
|
769
|
-
? `/plugin/shoppe/${tenant.uuid}/
|
|
770
|
-
|
|
876
|
+
: product.category === 'subscription'
|
|
877
|
+
? `/plugin/shoppe/${tenant.uuid}/subscribe/${encodeURIComponent(title)}`
|
|
878
|
+
: product.category === 'appointment'
|
|
879
|
+
? `/plugin/shoppe/${tenant.uuid}/book/${encodeURIComponent(title)}`
|
|
880
|
+
: product.category === 'product' && product.shipping > 0
|
|
881
|
+
? `/plugin/shoppe/${tenant.uuid}/buy/${encodeURIComponent(title)}/address`
|
|
882
|
+
: product.category === 'product'
|
|
883
|
+
? `/plugin/shoppe/${tenant.uuid}/buy/${encodeURIComponent(title)}`
|
|
884
|
+
: `${getSanoraUrl()}/products/${tenant.uuid}/${encodeURIComponent(title)}`
|
|
771
885
|
};
|
|
772
|
-
const CATEGORY_BUCKET = { book: 'books', music: 'music', post: 'posts', 'post-series': 'posts', album: 'albums', product: 'products' };
|
|
886
|
+
const CATEGORY_BUCKET = { book: 'books', music: 'music', post: 'posts', 'post-series': 'posts', album: 'albums', product: 'products', appointment: 'appointments', subscription: 'subscriptions' };
|
|
773
887
|
const bucket = goods[CATEGORY_BUCKET[product.category]];
|
|
774
888
|
if (bucket) bucket.push(item);
|
|
775
889
|
}
|
|
@@ -777,7 +891,130 @@ async function getShoppeGoods(tenant) {
|
|
|
777
891
|
return goods;
|
|
778
892
|
}
|
|
779
893
|
|
|
780
|
-
|
|
894
|
+
// ============================================================
|
|
895
|
+
// APPOINTMENT UTILITIES
|
|
896
|
+
// ============================================================
|
|
897
|
+
|
|
898
|
+
// Fetch and parse the schedule JSON artifact for an appointment product.
|
|
899
|
+
async function getAppointmentSchedule(tenant, product) {
|
|
900
|
+
const sanoraUrl = getSanoraUrl();
|
|
901
|
+
const scheduleArtifact = (product.artifacts || []).find(a => a.includes('schedule'));
|
|
902
|
+
if (!scheduleArtifact) return null;
|
|
903
|
+
const resp = await fetch(`${sanoraUrl}/artifacts/${scheduleArtifact}`);
|
|
904
|
+
if (!resp.ok) return null;
|
|
905
|
+
try { return await resp.json(); } catch { return null; }
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// Fetch booked slot strings for an appointment product from Sanora orders.
|
|
909
|
+
async function getBookedSlots(tenant, productId) {
|
|
910
|
+
const sanoraUrl = getSanoraUrl();
|
|
911
|
+
const tenantKeys = tenant.keys;
|
|
912
|
+
sessionless.getKeys = () => tenantKeys;
|
|
913
|
+
const timestamp = Date.now().toString();
|
|
914
|
+
const signature = await sessionless.sign(timestamp + tenant.uuid);
|
|
915
|
+
const resp = await fetch(
|
|
916
|
+
`${sanoraUrl}/user/${tenant.uuid}/orders/${encodeURIComponent(productId)}?timestamp=${timestamp}&signature=${encodeURIComponent(signature)}`
|
|
917
|
+
);
|
|
918
|
+
if (!resp.ok) return [];
|
|
919
|
+
try {
|
|
920
|
+
const data = await resp.json();
|
|
921
|
+
return (data.orders || []).map(o => o.slot).filter(Boolean);
|
|
922
|
+
} catch { return []; }
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// Generate available slot strings grouped by date.
|
|
926
|
+
// Slot strings are "YYYY-MM-DDTHH:MM" in the appointment's local timezone.
|
|
927
|
+
// Returns: [{ date: "YYYY-MM-DD", dayLabel: "Monday", slots: ["YYYY-MM-DDTHH:MM", ...] }]
|
|
928
|
+
function generateAvailableSlots(schedule, bookedSlots) {
|
|
929
|
+
const DAY_NAMES = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
|
|
930
|
+
const timezone = schedule.timezone || 'UTC';
|
|
931
|
+
const advanceDays = schedule.advanceDays || 30;
|
|
932
|
+
const duration = schedule.duration || 60;
|
|
933
|
+
const bookedSet = new Set(bookedSlots);
|
|
934
|
+
|
|
935
|
+
const dateFmt = new Intl.DateTimeFormat('en-CA', { timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit' });
|
|
936
|
+
const timeFmt = new Intl.DateTimeFormat('en-GB', { timeZone: timezone, hour: '2-digit', minute: '2-digit', hour12: false });
|
|
937
|
+
const weekdayFmt = new Intl.DateTimeFormat('en-US', { timeZone: timezone, weekday: 'long' });
|
|
938
|
+
const dayLabelFmt= new Intl.DateTimeFormat('en-US', { timeZone: timezone, weekday: 'long', month: 'short', day: 'numeric' });
|
|
939
|
+
|
|
940
|
+
const nowStr = timeFmt.format(new Date());
|
|
941
|
+
const nowMins = parseInt(nowStr.split(':')[0]) * 60 + parseInt(nowStr.split(':')[1]);
|
|
942
|
+
|
|
943
|
+
const available = [];
|
|
944
|
+
const now = new Date();
|
|
945
|
+
|
|
946
|
+
for (let d = 0; d < advanceDays; d++) {
|
|
947
|
+
const date = new Date(now.getTime() + d * 86400000);
|
|
948
|
+
const dateStr = dateFmt.format(date);
|
|
949
|
+
const dayName = weekdayFmt.format(date).toLowerCase();
|
|
950
|
+
const rule = (schedule.availability || []).find(a => a.day.toLowerCase() === dayName);
|
|
951
|
+
if (!rule) continue;
|
|
952
|
+
|
|
953
|
+
const [startH, startM] = rule.start.split(':').map(Number);
|
|
954
|
+
const [endH, endM] = rule.end.split(':').map(Number);
|
|
955
|
+
const startMins = startH * 60 + startM;
|
|
956
|
+
const endMins = endH * 60 + endM;
|
|
957
|
+
|
|
958
|
+
const slots = [];
|
|
959
|
+
for (let m = startMins; m + duration <= endMins; m += duration) {
|
|
960
|
+
// For today, skip slots within the next hour
|
|
961
|
+
if (d === 0 && m <= nowMins + 60) continue;
|
|
962
|
+
const h = Math.floor(m / 60).toString().padStart(2, '0');
|
|
963
|
+
const min = (m % 60).toString().padStart(2, '0');
|
|
964
|
+
const slotStr = `${dateStr}T${h}:${min}`;
|
|
965
|
+
if (!bookedSet.has(slotStr)) slots.push(slotStr);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
if (slots.length > 0) {
|
|
969
|
+
available.push({ date: dateStr, dayLabel: dayLabelFmt.format(date), slots });
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
return available;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// ============================================================
|
|
976
|
+
// SUBSCRIPTION UTILITIES
|
|
977
|
+
// ============================================================
|
|
978
|
+
|
|
979
|
+
// Fetch tier metadata (benefits list, renewalDays) from the tier-info artifact.
|
|
980
|
+
async function getTierInfo(tenant, product) {
|
|
981
|
+
const sanoraUrl = getSanoraUrl();
|
|
982
|
+
const tierArtifact = (product.artifacts || []).find(a => a.includes('tier-info'));
|
|
983
|
+
if (!tierArtifact) return null;
|
|
984
|
+
const resp = await fetch(`${sanoraUrl}/artifacts/${tierArtifact}`);
|
|
985
|
+
if (!resp.ok) return null;
|
|
986
|
+
try { return await resp.json(); } catch { return null; }
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// Check whether a subscriber (identified by recoveryKey) has an active subscription
|
|
990
|
+
// for a given subscription product. Uses Sanora orders only — no session-based hash.
|
|
991
|
+
// The recovery key itself is never stored; the order records sha256(recoveryKey+productId).
|
|
992
|
+
async function getSubscriptionStatus(tenant, productId, recoveryKey) {
|
|
993
|
+
const orderKey = crypto.createHash('sha256').update(recoveryKey + productId).digest('hex');
|
|
994
|
+
const sanoraUrl = getSanoraUrl();
|
|
995
|
+
const tenantKeys = tenant.keys;
|
|
996
|
+
sessionless.getKeys = () => tenantKeys;
|
|
997
|
+
const timestamp = Date.now().toString();
|
|
998
|
+
const signature = await sessionless.sign(timestamp + tenant.uuid);
|
|
999
|
+
try {
|
|
1000
|
+
const resp = await fetch(
|
|
1001
|
+
`${sanoraUrl}/user/${tenant.uuid}/orders/${encodeURIComponent(productId)}?timestamp=${timestamp}&signature=${encodeURIComponent(signature)}`
|
|
1002
|
+
);
|
|
1003
|
+
if (!resp.ok) return { active: false };
|
|
1004
|
+
const data = await resp.json();
|
|
1005
|
+
const myOrders = (data.orders || []).filter(o => o.orderKey === orderKey);
|
|
1006
|
+
if (!myOrders.length) return { active: false };
|
|
1007
|
+
const latest = myOrders.reduce((a, b) => (b.paidAt > a.paidAt ? b : a));
|
|
1008
|
+
const period = (latest.renewalDays || 30) * 24 * 60 * 60 * 1000;
|
|
1009
|
+
const renewsAt = latest.paidAt + period;
|
|
1010
|
+
const now = Date.now();
|
|
1011
|
+
const active = renewsAt > now;
|
|
1012
|
+
const daysLeft = Math.max(0, Math.floor((renewsAt - now) / (24 * 60 * 60 * 1000)));
|
|
1013
|
+
return { active, paidAt: latest.paidAt, renewsAt, daysLeft };
|
|
1014
|
+
} catch { return { active: false }; }
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
const CATEGORY_EMOJI = { book: '📚', music: '🎵', post: '📝', album: '🖼️', product: '📦', appointment: '📅', subscription: '🎁' };
|
|
781
1018
|
|
|
782
1019
|
function renderCards(items, category) {
|
|
783
1020
|
if (items.length === 0) {
|
|
@@ -805,18 +1042,20 @@ function renderCards(items, category) {
|
|
|
805
1042
|
function generateShoppeHTML(tenant, goods) {
|
|
806
1043
|
const total = Object.values(goods).flat().length;
|
|
807
1044
|
const tabs = [
|
|
808
|
-
{ id: 'all',
|
|
809
|
-
{ id: 'books',
|
|
810
|
-
{ id: 'music',
|
|
811
|
-
{ id: 'posts',
|
|
812
|
-
{ id: 'albums',
|
|
813
|
-
{ id: 'products',
|
|
1045
|
+
{ id: 'all', label: 'All', count: total, always: true },
|
|
1046
|
+
{ id: 'books', label: '📚 Books', count: goods.books.length },
|
|
1047
|
+
{ id: 'music', label: '🎵 Music', count: goods.music.length },
|
|
1048
|
+
{ id: 'posts', label: '📝 Posts', count: goods.posts.length },
|
|
1049
|
+
{ id: 'albums', label: '🖼️ Albums', count: goods.albums.length },
|
|
1050
|
+
{ id: 'products', label: '📦 Products', count: goods.products.length },
|
|
1051
|
+
{ id: 'appointments', label: '📅 Appointments', count: goods.appointments.length },
|
|
1052
|
+
{ id: 'subscriptions', label: '🎁 Support', count: goods.subscriptions.length }
|
|
814
1053
|
]
|
|
815
1054
|
.filter(t => t.always || t.count > 0)
|
|
816
1055
|
.map((t, i) => `<div class="tab${i === 0 ? ' active' : ''}" onclick="show('${t.id}',this)">${t.label} <span class="badge">${t.count}</span></div>`)
|
|
817
1056
|
.join('');
|
|
818
1057
|
|
|
819
|
-
const allItems = [...goods.books, ...goods.music, ...goods.posts, ...goods.albums, ...goods.products];
|
|
1058
|
+
const allItems = [...goods.books, ...goods.music, ...goods.posts, ...goods.albums, ...goods.products, ...goods.appointments, ...goods.subscriptions];
|
|
820
1059
|
|
|
821
1060
|
return `<!DOCTYPE html>
|
|
822
1061
|
<html lang="en">
|
|
@@ -866,6 +1105,11 @@ function generateShoppeHTML(tenant, goods) {
|
|
|
866
1105
|
<div id="posts" class="section"><div class="grid">${renderCards(goods.posts, 'post')}</div></div>
|
|
867
1106
|
<div id="albums" class="section"><div class="grid">${renderCards(goods.albums, 'album')}</div></div>
|
|
868
1107
|
<div id="products" class="section"><div class="grid">${renderCards(goods.products, 'product')}</div></div>
|
|
1108
|
+
<div id="appointments" class="section"><div class="grid">${renderCards(goods.appointments, 'appointment')}</div></div>
|
|
1109
|
+
<div id="subscriptions" class="section"><div class="grid">${renderCards(goods.subscriptions, 'subscription')}</div></div>
|
|
1110
|
+
<div style="text-align:center;padding:24px 0 8px;font-size:14px;color:#888;">
|
|
1111
|
+
Already a supporter? <a href="/plugin/shoppe/${tenant.uuid}/membership" style="color:#0066cc;">Access your membership →</a>
|
|
1112
|
+
</div>
|
|
869
1113
|
</main>
|
|
870
1114
|
<script>
|
|
871
1115
|
function show(id, tab) {
|
|
@@ -1068,22 +1312,193 @@ async function startServer(params) {
|
|
|
1068
1312
|
app.get('/plugin/shoppe/:identifier/buy/:title/address', (req, res) =>
|
|
1069
1313
|
renderPurchasePage(req, res, ADDRESS_STRIPE_TMPL));
|
|
1070
1314
|
|
|
1071
|
-
//
|
|
1315
|
+
// Appointment booking page
|
|
1316
|
+
app.get('/plugin/shoppe/:identifier/book/:title', async (req, res) => {
|
|
1317
|
+
try {
|
|
1318
|
+
const tenant = getTenantByIdentifier(req.params.identifier);
|
|
1319
|
+
if (!tenant) return res.status(404).send('<h1>Shoppe not found</h1>');
|
|
1320
|
+
|
|
1321
|
+
const title = decodeURIComponent(req.params.title);
|
|
1322
|
+
const sanoraUrl = getSanoraUrl();
|
|
1323
|
+
const productsResp = await fetch(`${sanoraUrl}/products/${tenant.uuid}`);
|
|
1324
|
+
const products = await productsResp.json();
|
|
1325
|
+
const product = products[title] || Object.values(products).find(p => p.title === title);
|
|
1326
|
+
if (!product) return res.status(404).send('<h1>Appointment not found</h1>');
|
|
1327
|
+
|
|
1328
|
+
const schedule = await getAppointmentSchedule(tenant, product);
|
|
1329
|
+
const wikiOrigin = `${req.protocol}://${req.get('host')}`;
|
|
1330
|
+
const shoppeUrl = `${wikiOrigin}/plugin/shoppe/${tenant.uuid}`;
|
|
1331
|
+
const imageUrl = product.image ? `${sanoraUrl}/images/${product.image}` : '';
|
|
1332
|
+
|
|
1333
|
+
const price = product.price || 0;
|
|
1334
|
+
const html = fillTemplate(APPOINTMENT_BOOKING_TMPL, {
|
|
1335
|
+
title: product.title || title,
|
|
1336
|
+
description: product.description || '',
|
|
1337
|
+
image: `"${imageUrl}"`,
|
|
1338
|
+
amount: String(price),
|
|
1339
|
+
formattedAmount: (price / 100).toFixed(2),
|
|
1340
|
+
productId: product.productId || '',
|
|
1341
|
+
timezone: schedule ? schedule.timezone : 'UTC',
|
|
1342
|
+
duration: String(schedule ? schedule.duration : 60),
|
|
1343
|
+
proceedLabel: price === 0 ? 'Confirm Booking →' : 'Continue to Payment →',
|
|
1344
|
+
shoppeUrl,
|
|
1345
|
+
tenantUuid: tenant.uuid
|
|
1346
|
+
});
|
|
1347
|
+
|
|
1348
|
+
res.set('Content-Type', 'text/html');
|
|
1349
|
+
res.send(html);
|
|
1350
|
+
} catch (err) {
|
|
1351
|
+
console.error('[shoppe] appointment booking page error:', err);
|
|
1352
|
+
res.status(500).send(`<h1>Error</h1><p>${err.message}</p>`);
|
|
1353
|
+
}
|
|
1354
|
+
});
|
|
1355
|
+
|
|
1356
|
+
// Available slots JSON for an appointment
|
|
1357
|
+
app.get('/plugin/shoppe/:identifier/book/:title/slots', async (req, res) => {
|
|
1358
|
+
try {
|
|
1359
|
+
const tenant = getTenantByIdentifier(req.params.identifier);
|
|
1360
|
+
if (!tenant) return res.status(404).json({ error: 'Shoppe not found' });
|
|
1361
|
+
|
|
1362
|
+
const title = decodeURIComponent(req.params.title);
|
|
1363
|
+
const sanoraUrl = getSanoraUrl();
|
|
1364
|
+
const productsResp = await fetch(`${sanoraUrl}/products/${tenant.uuid}`);
|
|
1365
|
+
const products = await productsResp.json();
|
|
1366
|
+
const product = products[title] || Object.values(products).find(p => p.title === title);
|
|
1367
|
+
if (!product) return res.status(404).json({ error: 'Appointment not found' });
|
|
1368
|
+
|
|
1369
|
+
const schedule = await getAppointmentSchedule(tenant, product);
|
|
1370
|
+
if (!schedule) return res.status(404).json({ error: 'Schedule not found' });
|
|
1371
|
+
|
|
1372
|
+
const bookedSlots = await getBookedSlots(tenant, product.productId);
|
|
1373
|
+
const available = generateAvailableSlots(schedule, bookedSlots);
|
|
1374
|
+
|
|
1375
|
+
res.json({ available, timezone: schedule.timezone, duration: schedule.duration });
|
|
1376
|
+
} catch (err) {
|
|
1377
|
+
console.error('[shoppe] slots error:', err);
|
|
1378
|
+
res.status(500).json({ error: err.message });
|
|
1379
|
+
}
|
|
1380
|
+
});
|
|
1381
|
+
|
|
1382
|
+
// Subscription sign-up / renew page
|
|
1383
|
+
app.get('/plugin/shoppe/:identifier/subscribe/:title', async (req, res) => {
|
|
1384
|
+
try {
|
|
1385
|
+
const tenant = getTenantByIdentifier(req.params.identifier);
|
|
1386
|
+
if (!tenant) return res.status(404).send('<h1>Shoppe not found</h1>');
|
|
1387
|
+
|
|
1388
|
+
const title = decodeURIComponent(req.params.title);
|
|
1389
|
+
const sanoraUrl = getSanoraUrl();
|
|
1390
|
+
const productsResp = await fetch(`${sanoraUrl}/products/${tenant.uuid}`);
|
|
1391
|
+
const products = await productsResp.json();
|
|
1392
|
+
const product = products[title] || Object.values(products).find(p => p.title === title);
|
|
1393
|
+
if (!product) return res.status(404).send('<h1>Tier not found</h1>');
|
|
1394
|
+
|
|
1395
|
+
const tierInfo = await getTierInfo(tenant, product);
|
|
1396
|
+
const wikiOrigin = `${req.protocol}://${req.get('host')}`;
|
|
1397
|
+
const shoppeUrl = `${wikiOrigin}/plugin/shoppe/${tenant.uuid}`;
|
|
1398
|
+
const imageUrl = product.image ? `${sanoraUrl}/images/${product.image}` : '';
|
|
1399
|
+
const benefits = tierInfo && tierInfo.benefits
|
|
1400
|
+
? tierInfo.benefits.map(b => `<li>${escHtml(b)}</li>`).join('')
|
|
1401
|
+
: '';
|
|
1402
|
+
|
|
1403
|
+
const html = fillTemplate(SUBSCRIPTION_SUBSCRIBE_TMPL, {
|
|
1404
|
+
title: product.title || title,
|
|
1405
|
+
description: product.description || '',
|
|
1406
|
+
image: `"${imageUrl}"`,
|
|
1407
|
+
amount: String(product.price || 0),
|
|
1408
|
+
formattedAmount: ((product.price || 0) / 100).toFixed(2),
|
|
1409
|
+
productId: product.productId || '',
|
|
1410
|
+
benefits,
|
|
1411
|
+
renewalDays: String(tierInfo ? (tierInfo.renewalDays || 30) : 30),
|
|
1412
|
+
shoppeUrl,
|
|
1413
|
+
tenantUuid: tenant.uuid
|
|
1414
|
+
});
|
|
1415
|
+
|
|
1416
|
+
res.set('Content-Type', 'text/html');
|
|
1417
|
+
res.send(html);
|
|
1418
|
+
} catch (err) {
|
|
1419
|
+
console.error('[shoppe] subscribe page error:', err);
|
|
1420
|
+
res.status(500).send(`<h1>Error</h1><p>${err.message}</p>`);
|
|
1421
|
+
}
|
|
1422
|
+
});
|
|
1423
|
+
|
|
1424
|
+
// Membership portal page
|
|
1425
|
+
app.get('/plugin/shoppe/:identifier/membership', (req, res) => {
|
|
1426
|
+
const tenant = getTenantByIdentifier(req.params.identifier);
|
|
1427
|
+
if (!tenant) return res.status(404).send('<h1>Shoppe not found</h1>');
|
|
1428
|
+
const wikiOrigin = `${req.protocol}://${req.get('host')}`;
|
|
1429
|
+
const shoppeUrl = `${wikiOrigin}/plugin/shoppe/${tenant.uuid}`;
|
|
1430
|
+
const html = fillTemplate(SUBSCRIPTION_MEMBERSHIP_TMPL, { shoppeUrl, tenantUuid: tenant.uuid });
|
|
1431
|
+
res.set('Content-Type', 'text/html');
|
|
1432
|
+
res.send(html);
|
|
1433
|
+
});
|
|
1434
|
+
|
|
1435
|
+
// Check subscription status for all tiers — used by the membership portal
|
|
1436
|
+
app.post('/plugin/shoppe/:identifier/membership/check', async (req, res) => {
|
|
1437
|
+
try {
|
|
1438
|
+
const tenant = getTenantByIdentifier(req.params.identifier);
|
|
1439
|
+
if (!tenant) return res.status(404).json({ error: 'Shoppe not found' });
|
|
1440
|
+
|
|
1441
|
+
const { recoveryKey } = req.body;
|
|
1442
|
+
if (!recoveryKey) return res.status(400).json({ error: 'recoveryKey required' });
|
|
1443
|
+
|
|
1444
|
+
const sanoraUrl = getSanoraUrl();
|
|
1445
|
+
const productsResp = await fetch(`${sanoraUrl}/products/${tenant.uuid}`);
|
|
1446
|
+
const products = await productsResp.json();
|
|
1447
|
+
const wikiOrigin = `${req.protocol}://${req.get('host')}`;
|
|
1448
|
+
const shoppeUrl = `${wikiOrigin}/plugin/shoppe/${tenant.uuid}`;
|
|
1449
|
+
|
|
1450
|
+
const subscriptions = [];
|
|
1451
|
+
for (const [title, product] of Object.entries(products)) {
|
|
1452
|
+
if (product.category !== 'subscription') continue;
|
|
1453
|
+
|
|
1454
|
+
const [status, tierInfo] = await Promise.all([
|
|
1455
|
+
getSubscriptionStatus(tenant, product.productId, recoveryKey),
|
|
1456
|
+
getTierInfo(tenant, product)
|
|
1457
|
+
]);
|
|
1458
|
+
|
|
1459
|
+
// Only expose exclusive artifact URLs to active subscribers
|
|
1460
|
+
const exclusiveArtifacts = status.active
|
|
1461
|
+
? (product.artifacts || [])
|
|
1462
|
+
.filter(a => !a.includes('tier-info'))
|
|
1463
|
+
.map(a => ({ name: a.split('-').slice(1).join('-'), url: `${sanoraUrl}/artifacts/${a}` }))
|
|
1464
|
+
: [];
|
|
1465
|
+
|
|
1466
|
+
subscriptions.push({
|
|
1467
|
+
title: product.title || title,
|
|
1468
|
+
productId: product.productId,
|
|
1469
|
+
description: product.description || '',
|
|
1470
|
+
price: product.price || 0,
|
|
1471
|
+
image: product.image ? `${sanoraUrl}/images/${product.image}` : null,
|
|
1472
|
+
benefits: tierInfo ? (tierInfo.benefits || []) : [],
|
|
1473
|
+
renewalDays: tierInfo ? (tierInfo.renewalDays || 30) : 30,
|
|
1474
|
+
active: status.active,
|
|
1475
|
+
daysLeft: status.daysLeft || 0,
|
|
1476
|
+
renewsAt: status.renewsAt || null,
|
|
1477
|
+
exclusiveArtifacts,
|
|
1478
|
+
subscribeUrl: `${shoppeUrl}/subscribe/${encodeURIComponent(product.title || title)}`
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
res.json({ subscriptions });
|
|
1483
|
+
} catch (err) {
|
|
1484
|
+
console.error('[shoppe] membership check error:', err);
|
|
1485
|
+
res.status(500).json({ error: err.message });
|
|
1486
|
+
}
|
|
1487
|
+
});
|
|
1488
|
+
|
|
1489
|
+
// Purchase intent — creates buyer Addie user, returns Stripe client secret.
|
|
1490
|
+
// Digital products (recoveryKey): checks if already purchased first.
|
|
1491
|
+
// Physical products (no recoveryKey): generates an orderRef the client carries to purchase/complete.
|
|
1072
1492
|
app.post('/plugin/shoppe/:identifier/purchase/intent', async (req, res) => {
|
|
1073
1493
|
try {
|
|
1074
1494
|
const tenant = getTenantByIdentifier(req.params.identifier);
|
|
1075
1495
|
if (!tenant) return res.status(404).json({ error: 'Shoppe not found' });
|
|
1076
1496
|
|
|
1077
|
-
const { recoveryKey, productId, title } = req.body;
|
|
1078
|
-
if (!
|
|
1497
|
+
const { recoveryKey, productId, title, slotDatetime } = req.body;
|
|
1498
|
+
if (!productId) return res.status(400).json({ error: 'productId required' });
|
|
1499
|
+
if (!recoveryKey && !title) return res.status(400).json({ error: 'recoveryKey or title required' });
|
|
1079
1500
|
|
|
1080
1501
|
const sanoraUrlInternal = getSanoraUrl();
|
|
1081
|
-
const recoveryHash = recoveryKey + productId;
|
|
1082
|
-
|
|
1083
|
-
// Check if already purchased
|
|
1084
|
-
const checkResp = await fetch(`${sanoraUrlInternal}/user/check-hash/${encodeURIComponent(recoveryHash)}/product/${encodeURIComponent(productId)}`);
|
|
1085
|
-
const checkJson = await checkResp.json();
|
|
1086
|
-
if (checkJson.success) return res.json({ purchased: true });
|
|
1087
1502
|
|
|
1088
1503
|
// Get product price
|
|
1089
1504
|
const productsResp = await fetch(`${sanoraUrlInternal}/products/${tenant.uuid}`);
|
|
@@ -1091,16 +1506,50 @@ async function startServer(params) {
|
|
|
1091
1506
|
const product = (title && products[title]) || Object.values(products).find(p => p.productId === productId);
|
|
1092
1507
|
const amount = product?.price || 0;
|
|
1093
1508
|
|
|
1094
|
-
|
|
1095
|
-
|
|
1509
|
+
let buyer;
|
|
1510
|
+
let orderRef;
|
|
1511
|
+
|
|
1512
|
+
if (recoveryKey && product?.category === 'subscription') {
|
|
1513
|
+
// Subscription flow — check if already actively subscribed
|
|
1514
|
+
const status = await getSubscriptionStatus(tenant, productId, recoveryKey);
|
|
1515
|
+
if (status.active) {
|
|
1516
|
+
return res.json({ alreadySubscribed: true, renewsAt: status.renewsAt, daysLeft: status.daysLeft });
|
|
1517
|
+
}
|
|
1518
|
+
buyer = await getOrCreateBuyerAddieUser(recoveryKey, productId);
|
|
1519
|
+
} else if (recoveryKey && slotDatetime) {
|
|
1520
|
+
// Appointment flow — verify slot is still open before charging
|
|
1521
|
+
const schedule = await getAppointmentSchedule(tenant, product);
|
|
1522
|
+
if (schedule) {
|
|
1523
|
+
const bookedSlots = await getBookedSlots(tenant, productId);
|
|
1524
|
+
if (bookedSlots.includes(slotDatetime)) {
|
|
1525
|
+
return res.status(409).json({ error: 'That time slot is no longer available.' });
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
buyer = await getOrCreateBuyerAddieUser(recoveryKey, productId);
|
|
1529
|
+
} else if (recoveryKey) {
|
|
1530
|
+
// Digital product flow — check if already purchased
|
|
1531
|
+
const recoveryHash = recoveryKey + productId;
|
|
1532
|
+
const checkResp = await fetch(`${sanoraUrlInternal}/user/check-hash/${encodeURIComponent(recoveryHash)}/product/${encodeURIComponent(productId)}`);
|
|
1533
|
+
const checkJson = await checkResp.json();
|
|
1534
|
+
if (checkJson.success) return res.json({ purchased: true });
|
|
1535
|
+
buyer = await getOrCreateBuyerAddieUser(recoveryKey, productId);
|
|
1536
|
+
} else {
|
|
1537
|
+
// Physical product flow — generate an orderRef to link intent → complete
|
|
1538
|
+
orderRef = crypto.randomBytes(16).toString('hex');
|
|
1539
|
+
buyer = await getOrCreateBuyerAddieUser(orderRef, productId);
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
// Free items (price = 0) skip Stripe entirely
|
|
1543
|
+
if (amount === 0) {
|
|
1544
|
+
return res.json({ free: true });
|
|
1545
|
+
}
|
|
1096
1546
|
|
|
1097
|
-
//
|
|
1547
|
+
// Sign and create Stripe intent via Addie
|
|
1098
1548
|
const payees = tenant.addieKeys ? [{ pubKey: tenant.addieKeys.pubKey, amount }] : [];
|
|
1099
1549
|
const buyerKeys = { pubKey: buyer.pubKey, privateKey: buyer.privateKey };
|
|
1100
1550
|
sessionless.getKeys = () => buyerKeys;
|
|
1101
1551
|
const intentTimestamp = Date.now().toString();
|
|
1102
|
-
const
|
|
1103
|
-
const intentSignature = await sessionless.sign(intentMessage);
|
|
1552
|
+
const intentSignature = await sessionless.sign(intentTimestamp + buyer.uuid + amount + 'USD');
|
|
1104
1553
|
const intentResp = await fetch(`${getAddieUrl()}/user/${buyer.uuid}/processor/stripe/intent`, {
|
|
1105
1554
|
method: 'POST',
|
|
1106
1555
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -1110,36 +1559,111 @@ async function startServer(params) {
|
|
|
1110
1559
|
const intentJson = await intentResp.json();
|
|
1111
1560
|
if (intentJson.error) return res.status(500).json({ error: intentJson.error });
|
|
1112
1561
|
|
|
1113
|
-
|
|
1562
|
+
const response = { purchased: false, clientSecret: intentJson.paymentIntent, publishableKey: intentJson.publishableKey };
|
|
1563
|
+
if (orderRef) response.orderRef = orderRef;
|
|
1564
|
+
res.json(response);
|
|
1114
1565
|
} catch (err) {
|
|
1115
1566
|
console.error('[shoppe] purchase intent error:', err);
|
|
1116
1567
|
res.status(500).json({ error: err.message });
|
|
1117
1568
|
}
|
|
1118
1569
|
});
|
|
1119
1570
|
|
|
1120
|
-
// Purchase complete —
|
|
1571
|
+
// Purchase complete — called after Stripe payment confirms.
|
|
1572
|
+
// Digital: creates a recovery hash in Sanora.
|
|
1573
|
+
// Physical: records the order (including shipping address) in Sanora, signed by the tenant.
|
|
1574
|
+
// Address is routed through the shoppe server so it never goes directly
|
|
1575
|
+
// from the browser to Sanora. It is only stored after payment succeeds.
|
|
1121
1576
|
app.post('/plugin/shoppe/:identifier/purchase/complete', async (req, res) => {
|
|
1122
1577
|
try {
|
|
1123
1578
|
const tenant = getTenantByIdentifier(req.params.identifier);
|
|
1124
1579
|
if (!tenant) return res.status(404).json({ error: 'Shoppe not found' });
|
|
1125
1580
|
|
|
1126
|
-
const { recoveryKey, productId,
|
|
1127
|
-
if (!recoveryKey || !productId) return res.status(400).json({ error: 'recoveryKey and productId required' });
|
|
1128
|
-
|
|
1581
|
+
const { recoveryKey, productId, orderRef, address, title, amount, slotDatetime, contactInfo, type, renewalDays } = req.body;
|
|
1129
1582
|
const sanoraUrlInternal = getSanoraUrl();
|
|
1130
|
-
const recoveryHash = recoveryKey + productId;
|
|
1131
1583
|
|
|
1132
|
-
if (
|
|
1133
|
-
|
|
1584
|
+
if (recoveryKey && type === 'subscription') {
|
|
1585
|
+
// Subscription payment — record an order with a hashed subscriber key + payment timestamp.
|
|
1586
|
+
// The recovery key itself is never stored; orderKey = sha256(recoveryKey + productId).
|
|
1587
|
+
const orderKey = crypto.createHash('sha256').update(recoveryKey + productId).digest('hex');
|
|
1588
|
+
const tenantKeys = tenant.keys;
|
|
1589
|
+
sessionless.getKeys = () => tenantKeys;
|
|
1590
|
+
const ts = Date.now().toString();
|
|
1591
|
+
const sig = await sessionless.sign(ts + tenant.uuid);
|
|
1592
|
+
const order = { orderKey, paidAt: Date.now(), title, productId, renewalDays: renewalDays || 30, status: 'active' };
|
|
1593
|
+
await fetch(`${sanoraUrlInternal}/user/${tenant.uuid}/orders`, {
|
|
1594
|
+
method: 'PUT',
|
|
1595
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1596
|
+
body: JSON.stringify({ timestamp: ts, signature: sig, order })
|
|
1597
|
+
});
|
|
1598
|
+
return res.json({ success: true });
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
if (recoveryKey && slotDatetime) {
|
|
1602
|
+
// Appointment — create recovery hash + record booking in Sanora
|
|
1603
|
+
const recoveryHash = recoveryKey + productId;
|
|
1604
|
+
const createResp = await fetch(`${sanoraUrlInternal}/user/create-hash/${encodeURIComponent(recoveryHash)}/product/${encodeURIComponent(productId)}`);
|
|
1605
|
+
await createResp.json();
|
|
1606
|
+
|
|
1607
|
+
// Record the booking in Sanora (contact info flows through the server, never direct from browser)
|
|
1608
|
+
const tenantKeys = tenant.keys;
|
|
1609
|
+
sessionless.getKeys = () => tenantKeys;
|
|
1610
|
+
const bookingTimestamp = Date.now().toString();
|
|
1611
|
+
const bookingSignature = await sessionless.sign(bookingTimestamp + tenant.uuid);
|
|
1612
|
+
const order = {
|
|
1613
|
+
productId,
|
|
1614
|
+
title,
|
|
1615
|
+
slot: slotDatetime,
|
|
1616
|
+
contactInfo: contactInfo || {},
|
|
1617
|
+
status: 'booked'
|
|
1618
|
+
};
|
|
1619
|
+
await fetch(`${sanoraUrlInternal}/user/${tenant.uuid}/orders`, {
|
|
1620
|
+
method: 'PUT',
|
|
1621
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1622
|
+
body: JSON.stringify({ timestamp: bookingTimestamp, signature: bookingSignature, order })
|
|
1623
|
+
});
|
|
1624
|
+
return res.json({ success: true });
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
if (recoveryKey) {
|
|
1628
|
+
// Digital product — create recovery hash so buyer can re-download
|
|
1629
|
+
const recoveryHash = recoveryKey + productId;
|
|
1630
|
+
const createResp = await fetch(`${sanoraUrlInternal}/user/create-hash/${encodeURIComponent(recoveryHash)}/product/${encodeURIComponent(productId)}`);
|
|
1631
|
+
const createJson = await createResp.json();
|
|
1632
|
+
return res.json({ success: createJson.success });
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
if (orderRef && address) {
|
|
1636
|
+
// Physical product — record order in Sanora signed by the tenant.
|
|
1637
|
+
// The shippingAddress is collected here (post-payment) and sent once, server-side.
|
|
1638
|
+
const tenantKeys = tenant.keys;
|
|
1639
|
+
sessionless.getKeys = () => tenantKeys;
|
|
1640
|
+
const orderTimestamp = Date.now().toString();
|
|
1641
|
+
const orderSignature = await sessionless.sign(orderTimestamp + tenant.uuid);
|
|
1642
|
+
const order = {
|
|
1643
|
+
productId,
|
|
1644
|
+
title,
|
|
1645
|
+
amount,
|
|
1646
|
+
orderRef,
|
|
1647
|
+
shippingAddress: {
|
|
1648
|
+
recipientName: address.name,
|
|
1649
|
+
street: address.line1,
|
|
1650
|
+
street2: address.line2 || '',
|
|
1651
|
+
city: address.city,
|
|
1652
|
+
state: address.state,
|
|
1653
|
+
zip: address.zip,
|
|
1654
|
+
country: 'US'
|
|
1655
|
+
},
|
|
1656
|
+
status: 'pending'
|
|
1657
|
+
};
|
|
1658
|
+
await fetch(`${sanoraUrlInternal}/user/${tenant.uuid}/orders`, {
|
|
1134
1659
|
method: 'PUT',
|
|
1135
1660
|
headers: { 'Content-Type': 'application/json' },
|
|
1136
|
-
body: JSON.stringify({ timestamp:
|
|
1661
|
+
body: JSON.stringify({ timestamp: orderTimestamp, signature: orderSignature, order })
|
|
1137
1662
|
});
|
|
1663
|
+
return res.json({ success: true });
|
|
1138
1664
|
}
|
|
1139
1665
|
|
|
1140
|
-
|
|
1141
|
-
const createJson = await createResp.json();
|
|
1142
|
-
res.json({ success: createJson.success });
|
|
1666
|
+
res.status(400).json({ error: 'recoveryKey or (orderRef + address) required' });
|
|
1143
1667
|
} catch (err) {
|
|
1144
1668
|
console.error('[shoppe] purchase complete error:', err);
|
|
1145
1669
|
res.status(500).json({ error: err.message });
|