wiki-plugin-shoppe 0.0.14 → 0.0.16

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-plugin-shoppe",
3
- "version": "0.0.14",
3
+ "version": "0.0.16",
4
4
  "description": "Multi-tenant digital goods shoppe for federated wiki, powered by Sanora",
5
5
  "keywords": [
6
6
  "wiki",
package/server/server.js CHANGED
@@ -10,8 +10,9 @@ const sessionless = require('sessionless-node');
10
10
  const SHOPPE_BASE_EMOJI = process.env.SHOPPE_BASE_EMOJI || '🛍️🎨🎁';
11
11
 
12
12
  const TEMPLATES_DIR = path.join(__dirname, 'templates');
13
- const RECOVER_STRIPE_TMPL = fs.readFileSync(path.join(TEMPLATES_DIR, 'generic-recover-stripe.html'), 'utf8');
14
- const ADDRESS_STRIPE_TMPL = fs.readFileSync(path.join(TEMPLATES_DIR, 'generic-address-stripe.html'), 'utf8');
13
+ const RECOVER_STRIPE_TMPL = fs.readFileSync(path.join(TEMPLATES_DIR, 'generic-recover-stripe.html'), 'utf8');
14
+ const ADDRESS_STRIPE_TMPL = fs.readFileSync(path.join(TEMPLATES_DIR, 'generic-address-stripe.html'), 'utf8');
15
+ const EBOOK_DOWNLOAD_TMPL = fs.readFileSync(path.join(TEMPLATES_DIR, 'ebook-download.html'), 'utf8');
15
16
 
16
17
  function getAllyabaseOrigin() {
17
18
  try { return new URL(getSanoraUrl()).origin; } catch { return getSanoraUrl(); }
@@ -24,6 +25,7 @@ function fillTemplate(tmpl, vars) {
24
25
 
25
26
  const DATA_DIR = path.join(process.env.HOME || '/root', '.shoppe');
26
27
  const TENANTS_FILE = path.join(DATA_DIR, 'tenants.json');
28
+ const BUYERS_FILE = path.join(DATA_DIR, 'buyers.json');
27
29
  const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
28
30
  const TMP_DIR = '/tmp/shoppe-uploads';
29
31
 
@@ -46,6 +48,47 @@ function getSanoraUrl() {
46
48
  return `http://localhost:${process.env.SANORA_PORT || 7243}`;
47
49
  }
48
50
 
51
+ function getAddieUrl() {
52
+ const config = loadConfig();
53
+ if (config.addieUrl) return config.addieUrl.replace(/\/$/, '');
54
+ return `http://localhost:${process.env.ADDIE_PORT || 3005}`;
55
+ }
56
+
57
+ function loadBuyers() {
58
+ if (!fs.existsSync(BUYERS_FILE)) return {};
59
+ try { return JSON.parse(fs.readFileSync(BUYERS_FILE, 'utf8')); } catch { return {}; }
60
+ }
61
+
62
+ function saveBuyers(buyers) {
63
+ fs.writeFileSync(BUYERS_FILE, JSON.stringify(buyers, null, 2));
64
+ }
65
+
66
+ async function getOrCreateBuyerAddieUser(recoveryKey, productId) {
67
+ const buyerKey = recoveryKey + productId;
68
+ const buyers = loadBuyers();
69
+ if (buyers[buyerKey]) return buyers[buyerKey];
70
+
71
+ const addieKeys = await sessionless.generateKeys(() => {}, () => null);
72
+ sessionless.getKeys = () => addieKeys;
73
+ const timestamp = Date.now().toString();
74
+ const message = timestamp + addieKeys.pubKey;
75
+ const signature = await sessionless.sign(message);
76
+
77
+ const resp = await fetch(`${getAddieUrl()}/user/create`, {
78
+ method: 'PUT',
79
+ headers: { 'Content-Type': 'application/json' },
80
+ body: JSON.stringify({ timestamp, pubKey: addieKeys.pubKey, signature })
81
+ });
82
+
83
+ const addieUser = await resp.json();
84
+ if (addieUser.error) throw new Error(`Addie: ${addieUser.error}`);
85
+
86
+ const buyer = { uuid: addieUser.uuid, pubKey: addieKeys.pubKey, privateKey: addieKeys.privateKey };
87
+ buyers[buyerKey] = buyer;
88
+ saveBuyers(buyers);
89
+ return buyer;
90
+ }
91
+
49
92
  // Same diverse palette as BDO emojicoding
50
93
  const EMOJI_PALETTE = [
51
94
  '🌟', '🌙', '🌍', '🌊', '🔥', '💎', '🎨', '🎭', '🎪', '🎯',
@@ -151,6 +194,25 @@ function generateEmojicode(tenants) {
151
194
  throw new Error('Failed to generate unique emojicode after 100 attempts');
152
195
  }
153
196
 
197
+ async function addieCreateUser() {
198
+ const addieKeys = await sessionless.generateKeys(() => {}, () => null);
199
+ sessionless.getKeys = () => addieKeys;
200
+ const timestamp = Date.now().toString();
201
+ const message = timestamp + addieKeys.pubKey;
202
+ const signature = await sessionless.sign(message);
203
+
204
+ const resp = await fetch(`${getAllyabaseOrigin()}/plugin/allyabase/addie/user/create`, {
205
+ method: 'PUT',
206
+ headers: { 'Content-Type': 'application/json' },
207
+ body: JSON.stringify({ timestamp, pubKey: addieKeys.pubKey, signature })
208
+ });
209
+
210
+ const addieUser = await resp.json();
211
+ if (addieUser.error) throw new Error(`Addie: ${addieUser.error}`);
212
+
213
+ return { uuid: addieUser.uuid, pubKey: addieKeys.pubKey, privateKey: addieKeys.privateKey };
214
+ }
215
+
154
216
  async function registerTenant(name) {
155
217
  const tenants = loadTenants();
156
218
 
@@ -172,12 +234,21 @@ async function registerTenant(name) {
172
234
 
173
235
  const emojicode = generateEmojicode(tenants);
174
236
 
237
+ // Create a dedicated Addie user for payee splits
238
+ let addieKeys = null;
239
+ try {
240
+ addieKeys = await addieCreateUser();
241
+ } catch (err) {
242
+ console.warn('[shoppe] Could not create addie user (payouts unavailable):', err.message);
243
+ }
244
+
175
245
  const tenant = {
176
246
  uuid: sanoraUser.uuid,
177
247
  emojicode,
178
248
  name: name || 'Unnamed Shoppe',
179
249
  keys,
180
250
  sanoraUser,
251
+ addieKeys,
181
252
  createdAt: Date.now()
182
253
  };
183
254
 
@@ -936,10 +1007,11 @@ async function startServer(params) {
936
1007
 
937
1008
  // Save config (owner only)
938
1009
  app.post('/plugin/shoppe/config', owner, (req, res) => {
939
- const { sanoraUrl } = req.body;
1010
+ const { sanoraUrl, addieUrl } = req.body;
940
1011
  if (!sanoraUrl) return res.status(400).json({ success: false, error: 'sanoraUrl required' });
941
1012
  const config = loadConfig();
942
1013
  config.sanoraUrl = sanoraUrl;
1014
+ if (addieUrl) config.addieUrl = addieUrl;
943
1015
  saveConfig(config);
944
1016
  console.log('[shoppe] Sanora URL set to:', sanoraUrl);
945
1017
  res.json({ success: true });
@@ -952,15 +1024,20 @@ async function startServer(params) {
952
1024
  if (!tenant) return res.status(404).send('<h1>Shoppe not found</h1>');
953
1025
 
954
1026
  const title = decodeURIComponent(req.params.title);
955
- const sanoraUrl = getSanoraUrl();
956
- const productsResp = await fetch(`${sanoraUrl}/products/${tenant.uuid}`);
1027
+ const sanoraUrlInternal = getSanoraUrl();
1028
+ const wikiOrigin = `${req.protocol}://${req.get('host')}`;
1029
+ const sanoraUrl = `${wikiOrigin}/plugin/allyabase/sanora`;
1030
+ const productsResp = await fetch(`${sanoraUrlInternal}/products/${tenant.uuid}`);
957
1031
  const products = await productsResp.json();
958
1032
  const product = products[title] || Object.values(products).find(p => p.title === title);
959
1033
  if (!product) return res.status(404).send('<h1>Product not found</h1>');
960
1034
 
961
- const imageUrl = product.image ? `${sanoraUrl}/images/${product.image}` : '';
962
- const ebookUrl = `${sanoraUrl}/products/${tenant.uuid}/${encodeURIComponent(title)}/ebook-download`;
963
- const shoppeUrl = `${req.protocol}://${req.get('host')}/plugin/shoppe/${tenant.uuid}`;
1035
+ const imageUrl = product.image ? `${sanoraUrlInternal}/images/${product.image}` : '';
1036
+ const ebookUrl = `${wikiOrigin}/plugin/shoppe/${tenant.uuid}/download/${encodeURIComponent(title)}`;
1037
+ const shoppeUrl = `${wikiOrigin}/plugin/shoppe/${tenant.uuid}`;
1038
+ const payees = tenant.addieKeys
1039
+ ? JSON.stringify([{ pubKey: tenant.addieKeys.pubKey, amount: product.price || 0 }])
1040
+ : '[]';
964
1041
 
965
1042
  const html = fillTemplate(templateHtml, {
966
1043
  title: product.title || title,
@@ -972,9 +1049,11 @@ async function startServer(params) {
972
1049
  pubKey: '',
973
1050
  signature: '',
974
1051
  sanoraUrl,
975
- allyabaseOrigin: getAllyabaseOrigin(),
1052
+ allyabaseOrigin: wikiOrigin,
976
1053
  ebookUrl,
977
- shoppeUrl
1054
+ shoppeUrl,
1055
+ payees,
1056
+ tenantUuid: tenant.uuid
978
1057
  });
979
1058
 
980
1059
  res.set('Content-Type', 'text/html');
@@ -993,6 +1072,122 @@ async function startServer(params) {
993
1072
  app.get('/plugin/shoppe/:identifier/buy/:title/address', (req, res) =>
994
1073
  renderPurchasePage(req, res, ADDRESS_STRIPE_TMPL));
995
1074
 
1075
+ // Purchase intent — creates buyer Addie user, checks recovery hash, returns Stripe client secret
1076
+ app.post('/plugin/shoppe/:identifier/purchase/intent', async (req, res) => {
1077
+ try {
1078
+ const tenant = getTenantByIdentifier(req.params.identifier);
1079
+ if (!tenant) return res.status(404).json({ error: 'Shoppe not found' });
1080
+
1081
+ const { recoveryKey, productId, title } = req.body;
1082
+ if (!recoveryKey || !productId) return res.status(400).json({ error: 'recoveryKey and productId required' });
1083
+
1084
+ const sanoraUrlInternal = getSanoraUrl();
1085
+ const recoveryHash = recoveryKey + productId;
1086
+
1087
+ // Check if already purchased
1088
+ const checkResp = await fetch(`${sanoraUrlInternal}/user/check-hash/${encodeURIComponent(recoveryHash)}/product/${encodeURIComponent(productId)}`);
1089
+ const checkJson = await checkResp.json();
1090
+ if (checkJson.success) return res.json({ purchased: true });
1091
+
1092
+ // Get product price
1093
+ const productsResp = await fetch(`${sanoraUrlInternal}/products/${tenant.uuid}`);
1094
+ const products = await productsResp.json();
1095
+ const product = (title && products[title]) || Object.values(products).find(p => p.productId === productId);
1096
+ const amount = product?.price || 0;
1097
+
1098
+ // Create/retrieve buyer Addie user
1099
+ const buyer = await getOrCreateBuyerAddieUser(recoveryKey, productId);
1100
+
1101
+ // Create Stripe intent via Addie
1102
+ const payees = tenant.addieKeys ? [{ pubKey: tenant.addieKeys.pubKey, amount }] : [];
1103
+ const intentResp = await fetch(`${getAddieUrl()}/user/${buyer.uuid}/processor/stripe/intent`, {
1104
+ method: 'PUT',
1105
+ headers: { 'Content-Type': 'application/json' },
1106
+ body: JSON.stringify({ timestamp: Date.now().toString(), amount, currency: 'USD', payees })
1107
+ });
1108
+
1109
+ const intentJson = await intentResp.json();
1110
+ if (intentJson.error) return res.status(500).json({ error: intentJson.error });
1111
+
1112
+ res.json({ purchased: false, clientSecret: intentJson.paymentIntent, publishableKey: intentJson.publishableKey });
1113
+ } catch (err) {
1114
+ console.error('[shoppe] purchase intent error:', err);
1115
+ res.status(500).json({ error: err.message });
1116
+ }
1117
+ });
1118
+
1119
+ // Purchase complete — creates recovery hash in Sanora after successful payment
1120
+ app.post('/plugin/shoppe/:identifier/purchase/complete', async (req, res) => {
1121
+ try {
1122
+ const tenant = getTenantByIdentifier(req.params.identifier);
1123
+ if (!tenant) return res.status(404).json({ error: 'Shoppe not found' });
1124
+
1125
+ const { recoveryKey, productId, order } = req.body;
1126
+ if (!recoveryKey || !productId) return res.status(400).json({ error: 'recoveryKey and productId required' });
1127
+
1128
+ const sanoraUrlInternal = getSanoraUrl();
1129
+ const recoveryHash = recoveryKey + productId;
1130
+
1131
+ if (order) {
1132
+ await fetch(`${sanoraUrlInternal}/user/orders`, {
1133
+ method: 'PUT',
1134
+ headers: { 'Content-Type': 'application/json' },
1135
+ body: JSON.stringify({ timestamp: Date.now().toString(), order })
1136
+ });
1137
+ }
1138
+
1139
+ const createResp = await fetch(`${sanoraUrlInternal}/user/create-hash/${encodeURIComponent(recoveryHash)}/product/${encodeURIComponent(productId)}`);
1140
+ const createJson = await createResp.json();
1141
+ res.json({ success: createJson.success });
1142
+ } catch (err) {
1143
+ console.error('[shoppe] purchase complete error:', err);
1144
+ res.status(500).json({ error: err.message });
1145
+ }
1146
+ });
1147
+
1148
+ // Ebook download page (reached after successful payment + hash creation)
1149
+ app.get('/plugin/shoppe/:identifier/download/:title', async (req, res) => {
1150
+ try {
1151
+ const tenant = getTenantByIdentifier(req.params.identifier);
1152
+ if (!tenant) return res.status(404).send('<h1>Shoppe not found</h1>');
1153
+
1154
+ const title = decodeURIComponent(req.params.title);
1155
+ const sanoraUrl = getSanoraUrl();
1156
+ const productsResp = await fetch(`${sanoraUrl}/products/${tenant.uuid}`);
1157
+ const products = await productsResp.json();
1158
+ const product = products[title] || Object.values(products).find(p => p.title === title);
1159
+ if (!product) return res.status(404).send('<h1>Book not found</h1>');
1160
+
1161
+ const imageUrl = product.image ? `${sanoraUrl}/images/${product.image}` : '';
1162
+
1163
+ // Map artifact UUIDs to download paths by extension
1164
+ let epubPath = '', pdfPath = '', mobiPath = '';
1165
+ (product.artifacts || []).forEach(artifact => {
1166
+ if (artifact.includes('epub')) epubPath = `${sanoraUrl}/artifacts/${artifact}`;
1167
+ if (artifact.includes('pdf')) pdfPath = `${sanoraUrl}/artifacts/${artifact}`;
1168
+ if (artifact.includes('mobi')) mobiPath = `${sanoraUrl}/artifacts/${artifact}`;
1169
+ });
1170
+
1171
+ const html = fillTemplate(EBOOK_DOWNLOAD_TMPL, {
1172
+ title: product.title || title,
1173
+ description: product.description || '',
1174
+ image: imageUrl,
1175
+ productId: product.productId || '',
1176
+ pubKey: '',
1177
+ signature: '',
1178
+ epubPath,
1179
+ pdfPath,
1180
+ mobiPath
1181
+ });
1182
+
1183
+ res.set('Content-Type', 'text/html');
1184
+ res.send(html);
1185
+ } catch (err) {
1186
+ console.error('[shoppe] download page error:', err);
1187
+ res.status(500).send(`<h1>Error</h1><p>${err.message}</p>`);
1188
+ }
1189
+ });
1190
+
996
1191
  // Post reader — fetches markdown from Sanora and renders it as HTML
997
1192
  app.get('/plugin/shoppe/:identifier/post/:title', async (req, res) => {
998
1193
  try {
@@ -0,0 +1,331 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.00, maximum-scale=1.00, minimum-scale=1.00">
6
+
7
+ <!-- Web preview meta tags -->
8
+ <meta name="twitter:title" content="{{title}}">
9
+ <meta name="description" content="{{description}}">
10
+ <meta name="twitter:description" content="{{description}}">
11
+ <meta name="twitter:image" content="{{image}}">
12
+ <meta name="og:title" content="{{title}}">
13
+ <meta name="og:description" content="{{description}}">
14
+ <meta name="og:image" content="{{image}}">
15
+
16
+ <title>{{title}} - Download</title>
17
+
18
+ <style>
19
+ * {
20
+ margin: 0;
21
+ padding: 0;
22
+ box-sizing: border-box;
23
+ }
24
+
25
+ body {
26
+ font-family: Arial, sans-serif;
27
+ height: 100vh;
28
+ overflow: hidden;
29
+ background: linear-gradient(135deg, #0f0f12 0%, #1a1a1e 100%);
30
+ color: white;
31
+ }
32
+
33
+ .main-container {
34
+ width: 100vw;
35
+ height: 100vh;
36
+ display: flex;
37
+ overflow: hidden;
38
+ }
39
+
40
+ .product-section {
41
+ flex: 1;
42
+ display: flex;
43
+ flex-direction: column;
44
+ justify-content: center;
45
+ align-items: center;
46
+ padding: 20px;
47
+ min-height: 0;
48
+ }
49
+
50
+ .download-section {
51
+ flex: 1;
52
+ display: flex;
53
+ flex-direction: column;
54
+ justify-content: center;
55
+ align-items: center;
56
+ padding: 20px;
57
+ min-height: 0;
58
+ background: rgba(42, 42, 46, 0.3);
59
+ border-left: 1px solid #444;
60
+ }
61
+
62
+ .product-content {
63
+ width: 100%;
64
+ max-width: 500px;
65
+ text-align: center;
66
+ }
67
+
68
+ .product-image {
69
+ width: 100%;
70
+ height: auto;
71
+ max-height: 40vh;
72
+ object-fit: contain;
73
+ border-radius: 12px;
74
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
75
+ margin-bottom: 20px;
76
+ }
77
+
78
+ .product-title {
79
+ font-size: clamp(1.2rem, 4vw, 2rem);
80
+ font-weight: bold;
81
+ margin-bottom: 10px;
82
+ background: linear-gradient(90deg, #3eda82, #a855f7);
83
+ -webkit-background-clip: text;
84
+ -webkit-text-fill-color: transparent;
85
+ background-clip: text;
86
+ }
87
+
88
+ .product-description {
89
+ font-size: clamp(0.9rem, 2.5vw, 1.1rem);
90
+ opacity: 0.8;
91
+ font-weight: normal;
92
+ line-height: 1.4;
93
+ }
94
+
95
+ .download-container {
96
+ width: 100%;
97
+ max-width: 400px;
98
+ }
99
+
100
+ .download-header {
101
+ text-align: center;
102
+ margin-bottom: 30px;
103
+ }
104
+
105
+ .download-title {
106
+ font-size: 1.8rem;
107
+ font-weight: bold;
108
+ margin-bottom: 10px;
109
+ color: #ffffff;
110
+ }
111
+
112
+ .download-subtitle {
113
+ font-size: 1rem;
114
+ opacity: 0.7;
115
+ color: #bbbbbb;
116
+ }
117
+
118
+ .format-grid {
119
+ display: grid;
120
+ gap: 15px;
121
+ margin-bottom: 20px;
122
+ }
123
+
124
+ .format-button {
125
+ background: linear-gradient(135deg, #2a2a2e 0%, #323236 100%);
126
+ border: 2px solid #444;
127
+ border-radius: 12px;
128
+ padding: 20px;
129
+ cursor: pointer;
130
+ transition: all 0.3s ease;
131
+ text-decoration: none;
132
+ color: white;
133
+ display: flex;
134
+ align-items: center;
135
+ justify-content: space-between;
136
+ position: relative;
137
+ overflow: hidden;
138
+ }
139
+
140
+ .format-button:hover {
141
+ border-color: #3eda82;
142
+ transform: translateY(-2px);
143
+ box-shadow: 0 8px 25px rgba(62, 218, 130, 0.2);
144
+ }
145
+
146
+ .format-button:active {
147
+ transform: translateY(0);
148
+ }
149
+
150
+ .format-button::before {
151
+ content: '';
152
+ position: absolute;
153
+ top: 0;
154
+ left: -100%;
155
+ width: 100%;
156
+ height: 100%;
157
+ background: linear-gradient(90deg, transparent, rgba(62, 218, 130, 0.1), transparent);
158
+ transition: left 0.5s ease;
159
+ }
160
+
161
+ .format-button:hover::before {
162
+ left: 100%;
163
+ }
164
+
165
+ .format-info {
166
+ display: flex;
167
+ flex-direction: column;
168
+ align-items: flex-start;
169
+ }
170
+
171
+ .format-name {
172
+ font-size: 1.1rem;
173
+ font-weight: bold;
174
+ margin-bottom: 5px;
175
+ }
176
+
177
+ .format-description {
178
+ font-size: 0.85rem;
179
+ opacity: 0.7;
180
+ }
181
+
182
+ .download-icon {
183
+ font-size: 1.5rem;
184
+ opacity: 0.8;
185
+ }
186
+
187
+ .bulk-download {
188
+ margin-top: 20px;
189
+ padding-top: 20px;
190
+ border-top: 1px solid #444;
191
+ }
192
+
193
+ .bulk-button {
194
+ background: linear-gradient(90deg, #3eda82, #a855f7);
195
+ border: none;
196
+ border-radius: 12px;
197
+ padding: 15px 25px;
198
+ color: white;
199
+ font-size: 1rem;
200
+ font-weight: bold;
201
+ cursor: pointer;
202
+ width: 100%;
203
+ transition: all 0.3s ease;
204
+ text-decoration: none;
205
+ display: inline-block;
206
+ text-align: center;
207
+ }
208
+
209
+ .bulk-button:hover {
210
+ transform: translateY(-2px);
211
+ box-shadow: 0 8px 25px rgba(62, 218, 130, 0.3);
212
+ }
213
+
214
+ @media (max-width: 768px) {
215
+ .main-container {
216
+ flex-direction: column;
217
+ }
218
+
219
+ .download-section {
220
+ border-left: none;
221
+ border-top: 1px solid #444;
222
+ }
223
+
224
+ .product-image {
225
+ max-height: 30vh;
226
+ }
227
+ }
228
+ </style>
229
+ </head>
230
+ <body>
231
+ <div class="main-container">
232
+ <!-- Product Section -->
233
+ <div class="product-section">
234
+ <div class="product-content">
235
+ <img id="product-image" class="product-image" src="{{image}}" alt="{{title}}">
236
+ <h1 class="product-title">{{title}}</h1>
237
+ <p class="product-description">{{description}}</p>
238
+ </div>
239
+ </div>
240
+
241
+ <!-- Download Section -->
242
+ <div class="download-section">
243
+ <div class="download-container">
244
+ <div class="download-header">
245
+ <h2 class="download-title">Download Your Book</h2>
246
+ <p class="download-subtitle">Choose your preferred format</p>
247
+ </div>
248
+
249
+ <div class="format-grid" id="format-grid">
250
+ <!-- Format buttons will be inserted here -->
251
+ </div>
252
+
253
+ <div class="bulk-download">
254
+ <a href="#" class="bulk-button" id="bulk-download">
255
+ 📦 Download All Formats
256
+ </a>
257
+ </div>
258
+ </div>
259
+ </div>
260
+ </div>
261
+
262
+ <script>
263
+ // Configuration - replace with your actual file paths
264
+ const downloadConfig = Object.fromEntries(Object.entries({
265
+ epub: { path: "{{epubPath}}", name: "EPUB", description: "Best for e-readers & mobile devices", icon: "📱" },
266
+ pdf: { path: "{{pdfPath}}", name: "PDF", description: "Universal format, preserves layout", icon: "📄" },
267
+ mobi: { path: "{{mobiPath}}", name: "MOBI", description: "Optimized for Kindle devices", icon: "📚" }
268
+ }).filter(([, c]) => c.path));
269
+
270
+ function createFormatButton(format, config) {
271
+ const button = document.createElement('a');
272
+ button.className = 'format-button';
273
+ button.href = config.path;
274
+ button.download = `{{title}}.${format}`;
275
+
276
+ button.innerHTML = `
277
+ <div class="format-info">
278
+ <div class="format-name">${config.icon} ${config.name}</div>
279
+ <div class="format-description">${config.description}</div>
280
+ </div>
281
+ <div class="download-icon">⬇️</div>
282
+ `;
283
+
284
+ // Add download tracking
285
+ button.addEventListener('click', (e) => {
286
+ console.log(`Downloading ${format.toUpperCase()} format`);
287
+ // You can add analytics or tracking here
288
+ });
289
+
290
+ return button;
291
+ }
292
+
293
+ function setupBulkDownload() {
294
+ const bulkButton = document.getElementById('bulk-download');
295
+ bulkButton.addEventListener('click', (e) => {
296
+ e.preventDefault();
297
+ console.log('Starting bulk download...');
298
+
299
+ // Download each format with a small delay
300
+ Object.entries(downloadConfig).forEach(([format, config], index) => {
301
+ setTimeout(() => {
302
+ const link = document.createElement('a');
303
+ link.href = config.path;
304
+ link.download = `{{title}}.${format}`;
305
+ link.style.display = 'none';
306
+ document.body.appendChild(link);
307
+ link.click();
308
+ document.body.removeChild(link);
309
+ }, index * 500); // 500ms delay between downloads
310
+ });
311
+ });
312
+ }
313
+
314
+ function initializeDownloads() {
315
+ const formatGrid = document.getElementById('format-grid');
316
+
317
+ // Create format buttons
318
+ Object.entries(downloadConfig).forEach(([format, config]) => {
319
+ const button = createFormatButton(format, config);
320
+ formatGrid.appendChild(button);
321
+ });
322
+
323
+ // Setup bulk download
324
+ setupBulkDownload();
325
+ }
326
+
327
+ // Initialize when page loads
328
+ document.addEventListener('DOMContentLoaded', initializeDownloads);
329
+ </script>
330
+ </body>
331
+ </html>
@@ -481,10 +481,10 @@
481
481
  timestamp: new Date().getTime() + '',
482
482
  amount: {{amount}},
483
483
  currency: 'USD',
484
- payees: []
484
+ payees: {{payees}}
485
485
  };
486
486
 
487
- const res = await fetch(`{{allyabaseOrigin}}/processor/stripe/intent`, {
487
+ const res = await fetch(`{{allyabaseOrigin}}/plugin/allyabase/addie/processor/stripe/intent`, {
488
488
  method: 'put',
489
489
  body: JSON.stringify(payload),
490
490
  headers: {'Content-Type': 'application/json'}
@@ -83,7 +83,7 @@
83
83
  </div>
84
84
 
85
85
  <!-- Payment Form -->
86
- <div>
86
+ <div id="payment-section" style="display:none">
87
87
  <h3 style="margin-bottom: 20px; color: #10b981;">Payment Details</h3>
88
88
  <form id="payment-form" class="payment-form" style="
89
89
  background: #2a2a2e;
@@ -137,29 +137,18 @@
137
137
  </style>
138
138
 
139
139
  <script type="text/javascript">
140
- // Buy button functionality
140
+ // Buy button — just reveal the forms section (payment init happens after recovery key submitted)
141
141
  document.getElementById('buy-button').addEventListener('click', function() {
142
142
  const formsSection = document.getElementById('forms-section');
143
143
  formsSection.style.display = 'block';
144
-
145
- // Smooth scroll to forms section
146
- formsSection.scrollIntoView({
147
- behavior: 'smooth',
148
- block: 'start'
149
- });
150
-
151
- // Initialize payment form
152
- window.addPaymentForm();
144
+ formsSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
153
145
  });
154
-
155
- // Form validation for both forms
146
+
147
+ // Enable submit once Stripe payment element is ready
156
148
  function validateAllForms() {
157
- const recoveryValid = validateForm(formConfig);
158
- const paymentValid = stripe && elements; // Basic check that payment is initialized
159
-
149
+ const paymentValid = stripe && elements;
160
150
  const submitButton = document.getElementById('submit-button');
161
-
162
- if (recoveryValid && paymentValid) {
151
+ if (paymentValid) {
163
152
  submitButton.style.background = 'linear-gradient(90deg, #10b981, #8b5cf6)';
164
153
  submitButton.style.color = 'white';
165
154
  submitButton.style.cursor = 'pointer';
@@ -170,7 +159,6 @@
170
159
  submitButton.style.color = '#999999';
171
160
  submitButton.style.cursor = 'not-allowed';
172
161
  submitButton.disabled = true;
173
- submitButton.textContent = 'Complete Purchase';
174
162
  }
175
163
  }
176
164
  </script>
@@ -582,23 +570,43 @@
582
570
  };
583
571
 
584
572
  async function handleSubmit(formData) {
585
- console.log('Form submitted:', formData);
586
- // Do whatever you want with the form data
587
- const recoveryHash = formData.Recovery + "{{productId}}";
588
- const checkHashURL = `{{sanoraUrl}}/user/check-hash/${recoveryHash}/product/{{productId}}`
573
+ window.formData = formData;
574
+ const recoveryKey = formData.Recovery;
575
+
576
+ const submitBtn = document.querySelector('[type=submit]');
577
+ if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'Checking…'; }
578
+
579
+ try {
580
+ const resp = await fetch('/plugin/shoppe/{{tenantUuid}}/purchase/intent', {
581
+ method: 'POST',
582
+ headers: { 'Content-Type': 'application/json' },
583
+ body: JSON.stringify({ recoveryKey, productId: '{{productId}}', title: '{{title}}' })
584
+ });
585
+ const json = await resp.json();
586
+
587
+ if (json.purchased) {
588
+ window.location.href = '{{ebookUrl}}';
589
+ return;
590
+ }
589
591
 
590
- const resp = await fetch(checkHashURL);
591
- const json = await resp.json();
592
+ if (json.error) {
593
+ alert('Error: ' + json.error);
594
+ return;
595
+ }
592
596
 
593
- if(json.success) {
594
- window.location.href = '{{ebookUrl}}';
595
- return;
597
+ // Show payment element
598
+ document.getElementById('payment-section').style.display = 'block';
599
+ stripe = Stripe(json.publishableKey);
600
+ elements = stripe.elements({ clientSecret: json.clientSecret });
601
+ const paymentElement = elements.create('payment');
602
+ paymentElement.mount('#payment-element');
603
+ paymentElement.on('ready', () => setTimeout(validateAllForms, 500));
604
+ paymentElement.on('change', () => setTimeout(validateAllForms, 100));
605
+ } catch (err) {
606
+ alert('Unexpected error: ' + err.message);
607
+ } finally {
608
+ if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = 'Check'; }
596
609
  }
597
-
598
- window.formData = formData;
599
- // Don't show payment form here anymore, it's already visible
600
- // Just trigger validation
601
- validateAllForms();
602
610
  }
603
611
 
604
612
  const form = getForm(formConfig, handleSubmit);
@@ -608,130 +616,59 @@
608
616
  <script type="text/javascript">
609
617
  let stripe;
610
618
  let elements;
611
- let response;
612
619
 
613
620
  const paymentForm = document.getElementById('payment-form');
614
621
  const submitButton = document.getElementById('submit-button');
615
622
  const errorMessage = document.getElementById('error-message');
616
623
  const loadingMessage = document.getElementById('loading');
617
624
 
618
- async function getPaymentIntentWithoutSplits(amount, currency) {
619
- try {
620
- const payload = {
621
- timestamp: new Date().getTime() + '',
622
- amount: {{amount}},
623
- currency: 'USD',
624
- payees: []
625
- };
626
-
627
- const res = await fetch(`{{allyabaseOrigin}}/processor/stripe/intent`, {
628
- method: 'put',
629
- body: JSON.stringify(payload),
630
- headers: {'Content-Type': 'application/json'}
631
- });
632
-
633
- const response = await res.json();
634
- console.log('got intent response', response);
635
-
636
- stripe = Stripe(response.publishableKey);
637
- elements = stripe.elements({
638
- clientSecret: response.paymentIntent
639
- });
640
-
641
- const paymentElement = elements.create('payment');
642
- paymentElement.mount('#payment-element');
643
-
644
- // Trigger validation when payment element is ready
645
- paymentElement.on('ready', () => {
646
- setTimeout(() => validateAllForms(), 500);
647
- });
648
-
649
- paymentElement.on('change', () => {
650
- setTimeout(() => validateAllForms(), 100);
651
- });
652
- } catch(err) {
653
- console.warn(err);
654
- }
655
- };
656
-
657
625
  window.confirmPayment = async () => {
658
- const order = {
659
- title: "{{title}}",
660
- productId: "{{productId}}",
661
- formData
662
- };
663
-
664
626
  try {
665
627
  const { error } = await stripe.confirmPayment({
666
- elements,
667
- confirmParams: {
668
- return_url: 'http://wiki.planetnineisaspaceship.com'
669
- },
670
- redirect: 'if_required'
628
+ elements,
629
+ confirmParams: { return_url: '{{shoppeUrl}}' },
630
+ redirect: 'if_required'
671
631
  });
672
632
 
673
- if(error) {
674
- return showError(error.message);
675
- }
633
+ if (error) return showError(error.message);
676
634
 
677
- await fetch('{{sanoraUrl}}/user/orders', {
678
- method: 'PUT',
679
- headers: {'Content-Type': 'application/json'},
680
- body: JSON.stringify({timestamp: new Date().getTime() + '', order})
635
+ const order = { title: '{{title}}', productId: '{{productId}}', formData: window.formData };
636
+ const resp = await fetch('/plugin/shoppe/{{tenantUuid}}/purchase/complete', {
637
+ method: 'POST',
638
+ headers: { 'Content-Type': 'application/json' },
639
+ body: JSON.stringify({ recoveryKey: window.formData.Recovery, productId: '{{productId}}', order })
681
640
  });
682
- const recoveryHash = formData.Recovery + "{{productId}}";
683
- const createHashURL = `{{sanoraUrl}}/user/create-hash/${recoveryHash}/product/{{productId}}`
684
-
685
- const resp = await fetch(createHashURL);
686
641
  const json = await resp.json();
687
642
 
688
- if(json.success) {
689
- window.location.href = '{{ebookUrl}}';
690
- return;
643
+ if (json.success) {
644
+ window.location.href = '{{ebookUrl}}';
645
+ } else {
646
+ window.alert('Payment successful, but error recording purchase. Contact greetings@planetnine.app');
691
647
  }
692
-
693
- window.alert('Your payment was successful, but there was an error creating this recovery hash. Please contact greetings@planetnine.app for support.');
694
-
695
- } catch(err) {
648
+ } catch (err) {
696
649
  showError('An unexpected error occurred.');
697
- console.warn('payment error: ', err);
650
+ console.warn('payment error:', err);
698
651
  }
699
652
  };
700
653
 
701
654
  paymentForm.addEventListener('submit', async (event) => {
702
655
  event.preventDefault();
703
-
704
- if (!stripe || !elements) {
705
- return;
706
- }
707
-
708
- // Disable payment form submission while processing
656
+ if (!stripe || !elements) return;
709
657
  setLoading(true);
710
-
711
658
  await window.confirmPayment();
712
-
713
659
  setLoading(false);
714
660
  });
715
661
 
716
662
  const showError = (message) => {
717
663
  errorMessage.textContent = message;
718
664
  errorMessage.style.display = 'block';
719
- setTimeout(() => {
720
- errorMessage.style.display = 'none';
721
- errorMessage.textContent = '';
722
- }, 5000);
665
+ setTimeout(() => { errorMessage.style.display = 'none'; errorMessage.textContent = ''; }, 5000);
723
666
  };
724
667
 
725
668
  const setLoading = (isLoading) => {
726
669
  submitButton.disabled = isLoading;
727
670
  loadingMessage.style.display = isLoading ? 'block' : 'none';
728
671
  };
729
-
730
- const start = () => {
731
- getPaymentIntentWithoutSplits({{amount}}, 'USD');
732
- };
733
-
734
- window.addPaymentForm = start;
735
672
  </script>
736
673
  </body>
737
674
  </html>