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
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
|
|
14
|
-
const ADDRESS_STRIPE_TMPL
|
|
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
|
|
956
|
-
const
|
|
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 ? `${
|
|
962
|
-
const ebookUrl = `${
|
|
963
|
-
const shoppeUrl = `${
|
|
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:
|
|
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
|
|
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
|
-
//
|
|
146
|
+
|
|
147
|
+
// Enable submit once Stripe payment element is ready
|
|
156
148
|
function validateAllForms() {
|
|
157
|
-
const
|
|
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
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
const
|
|
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
|
-
|
|
591
|
-
|
|
592
|
+
if (json.error) {
|
|
593
|
+
alert('Error: ' + json.error);
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
592
596
|
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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
|
-
|
|
690
|
-
|
|
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:
|
|
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>
|