wiki-plugin-shoppe 0.0.39 → 0.0.41
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 +3 -2
- package/server/server.js +277 -64
- package/server/templates/generic-recover-stripe.html +30 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wiki-plugin-shoppe",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.41",
|
|
4
4
|
"description": "Multi-tenant digital goods shoppe for federated wiki, powered by Sanora",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"wiki",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"form-data": "^4.0.0",
|
|
27
27
|
"multer": "^1.4.5-lts.1",
|
|
28
28
|
"node-fetch": "^2.6.1",
|
|
29
|
-
"sessionless-node": "latest"
|
|
29
|
+
"sessionless-node": "latest",
|
|
30
|
+
"stripe": "^17"
|
|
30
31
|
}
|
|
31
32
|
}
|
package/server/server.js
CHANGED
|
@@ -9,6 +9,18 @@ const FormData = require('form-data');
|
|
|
9
9
|
const AdmZip = require('adm-zip');
|
|
10
10
|
const sessionless = require('sessionless-node');
|
|
11
11
|
|
|
12
|
+
// Stripe is used directly for Terminal (card-present) payments.
|
|
13
|
+
// Set STRIPE_SECRET_KEY in the environment. If absent, Terminal endpoints return 503.
|
|
14
|
+
let _stripe = null;
|
|
15
|
+
function getStripe() {
|
|
16
|
+
if (!_stripe) {
|
|
17
|
+
const key = process.env.STRIPE_SECRET_KEY;
|
|
18
|
+
if (!key) throw new Error('STRIPE_SECRET_KEY not set — Terminal payments unavailable');
|
|
19
|
+
_stripe = require('stripe')(key);
|
|
20
|
+
}
|
|
21
|
+
return _stripe;
|
|
22
|
+
}
|
|
23
|
+
|
|
12
24
|
const SHOPPE_BASE_EMOJI = process.env.SHOPPE_BASE_EMOJI || '🛍️🎨🎁';
|
|
13
25
|
|
|
14
26
|
const TEMPLATES_DIR = path.join(__dirname, 'templates');
|
|
@@ -33,7 +45,8 @@ const BUYERS_FILE = path.join(DATA_DIR, 'buyers.json');
|
|
|
33
45
|
const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
|
|
34
46
|
// Shipping addresses are stored locally only — never forwarded to Sanora or any third party.
|
|
35
47
|
// This file contains PII (name, address). Purge individual records once orders ship.
|
|
36
|
-
const ORDERS_FILE
|
|
48
|
+
const ORDERS_FILE = path.join(DATA_DIR, 'orders.json');
|
|
49
|
+
const AFFILIATES_FILE = path.join(DATA_DIR, 'affiliates.json');
|
|
37
50
|
const TMP_DIR = '/tmp/shoppe-uploads';
|
|
38
51
|
|
|
39
52
|
// ============================================================
|
|
@@ -81,6 +94,48 @@ function saveBuyers(buyers) {
|
|
|
81
94
|
fs.writeFileSync(BUYERS_FILE, JSON.stringify(buyers, null, 2));
|
|
82
95
|
}
|
|
83
96
|
|
|
97
|
+
function loadAffiliates() {
|
|
98
|
+
if (!fs.existsSync(AFFILIATES_FILE)) return {};
|
|
99
|
+
try { return JSON.parse(fs.readFileSync(AFFILIATES_FILE, 'utf8')); } catch { return {}; }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function saveAffiliates(affiliates) {
|
|
103
|
+
fs.writeFileSync(AFFILIATES_FILE, JSON.stringify(affiliates, null, 2));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Get or create an Addie user representing an affiliate (Shoppere user who initiates NFC charges).
|
|
107
|
+
// Keyed by the affiliate's Shoppere pubKey. The affiliate's Addie account receives their cut of
|
|
108
|
+
// split payments. Stripe Connect onboarding is a separate step handled outside this function.
|
|
109
|
+
async function getOrCreateAffiliateAddieUser(shopperePublicKey) {
|
|
110
|
+
const affiliates = loadAffiliates();
|
|
111
|
+
if (affiliates[shopperePublicKey]) return affiliates[shopperePublicKey];
|
|
112
|
+
|
|
113
|
+
const addieKeys = await sessionless.generateKeys(() => {}, () => null);
|
|
114
|
+
sessionless.getKeys = () => addieKeys;
|
|
115
|
+
const timestamp = Date.now().toString();
|
|
116
|
+
const signature = await sessionless.sign(timestamp + addieKeys.pubKey);
|
|
117
|
+
|
|
118
|
+
const resp = await fetch(`${getAddieUrl()}/user/create`, {
|
|
119
|
+
method: 'PUT',
|
|
120
|
+
headers: { 'Content-Type': 'application/json' },
|
|
121
|
+
body: JSON.stringify({ timestamp, pubKey: addieKeys.pubKey, signature })
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const addieUser = await resp.json();
|
|
125
|
+
if (addieUser.error) throw new Error(`Addie affiliate create: ${addieUser.error}`);
|
|
126
|
+
|
|
127
|
+
const entry = {
|
|
128
|
+
uuid: addieUser.uuid,
|
|
129
|
+
pubKey: addieKeys.pubKey,
|
|
130
|
+
privateKey: addieKeys.privateKey,
|
|
131
|
+
shoppereKey: shopperePublicKey
|
|
132
|
+
};
|
|
133
|
+
affiliates[shopperePublicKey] = entry;
|
|
134
|
+
saveAffiliates(affiliates);
|
|
135
|
+
return entry;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
84
139
|
async function getOrCreateBuyerAddieUser(recoveryKey, productId) {
|
|
85
140
|
const buyerKey = recoveryKey + productId;
|
|
86
141
|
const buyers = loadBuyers();
|
|
@@ -923,6 +978,10 @@ async function processArchive(zipPath, onProgress = () => {}) {
|
|
|
923
978
|
if (manifest.lightMode !== undefined) {
|
|
924
979
|
tenantUpdates.lightMode = !!manifest.lightMode;
|
|
925
980
|
}
|
|
981
|
+
if (manifest.affiliateCommission != null && typeof manifest.affiliateCommission === 'number') {
|
|
982
|
+
// Clamp to [0, 0.50] — affiliates can earn at most 50% commission
|
|
983
|
+
tenantUpdates.affiliateCommission = Math.max(0, Math.min(0.50, manifest.affiliateCommission));
|
|
984
|
+
}
|
|
926
985
|
if (Object.keys(tenantUpdates).length > 0) {
|
|
927
986
|
const tenants = loadTenants();
|
|
928
987
|
Object.assign(tenants[tenant.uuid], tenantUpdates);
|
|
@@ -1497,17 +1556,6 @@ async function getShoppeGoods(tenant) {
|
|
|
1497
1556
|
|
|
1498
1557
|
const resolvedUrl = (bucketName && redirects[bucketName]) || defaultUrl;
|
|
1499
1558
|
|
|
1500
|
-
// Build clip URL for App Clip invocation — books and no-shipping products only.
|
|
1501
|
-
// Appends product identity params so the App Clip can parse them from the URL.
|
|
1502
|
-
const isClipBuyPage = !redirects[bucketName] && product.productId &&
|
|
1503
|
-
(product.category === 'book' ||
|
|
1504
|
-
(product.category === 'product' && !(product.shipping > 0)));
|
|
1505
|
-
const clipUrl = isClipBuyPage
|
|
1506
|
-
? `${resolvedUrl}?productId=${encodeURIComponent(product.productId)}` +
|
|
1507
|
-
`&price=${product.price || 0}` +
|
|
1508
|
-
`&shopName=${encodeURIComponent(tenant.name || '')}`
|
|
1509
|
-
: null;
|
|
1510
|
-
|
|
1511
1559
|
const item = {
|
|
1512
1560
|
title: product.title || title,
|
|
1513
1561
|
description: product.description || '',
|
|
@@ -1515,7 +1563,6 @@ async function getShoppeGoods(tenant) {
|
|
|
1515
1563
|
shipping: product.shipping || 0,
|
|
1516
1564
|
image: product.image ? `${getSanoraUrl()}/images/${product.image}` : null,
|
|
1517
1565
|
url: resolvedUrl,
|
|
1518
|
-
...(clipUrl && { clipUrl }),
|
|
1519
1566
|
...(isPost && { category: product.category, tags: product.tags || '' }),
|
|
1520
1567
|
...(lucillePlayerUrl && { lucillePlayerUrl }),
|
|
1521
1568
|
...(product.category === 'video' && { shoppeId: tenant.uuid })
|
|
@@ -1853,12 +1900,13 @@ function renderCards(items, category) {
|
|
|
1853
1900
|
</div>
|
|
1854
1901
|
</div>`;
|
|
1855
1902
|
}
|
|
1856
|
-
|
|
1857
|
-
// productId/price/shopName so the iOS App Clip can parse them on invocation.
|
|
1858
|
-
const targetUrl = item.clipUrl || item.url;
|
|
1903
|
+
const targetUrl = item.url;
|
|
1859
1904
|
const clickHandler = isVideo
|
|
1860
1905
|
? `playVideo('${item.lucillePlayerUrl}')`
|
|
1861
1906
|
: `window.open('${escHtml(targetUrl)}','_blank')`;
|
|
1907
|
+
const saveBtn = (item.price > 0 && item.productId)
|
|
1908
|
+
? `<button class="save-shoppere-btn" onclick="event.stopPropagation();saveToShoppere(${JSON.stringify(item.title)},${JSON.stringify(item.productId)},${item.price},${JSON.stringify(category)})" title="Save to Shoppere wallet">📱 Save</button>`
|
|
1909
|
+
: '';
|
|
1862
1910
|
return `
|
|
1863
1911
|
<div class="card" onclick="${clickHandler}">
|
|
1864
1912
|
${imgHtml}
|
|
@@ -1866,6 +1914,7 @@ function renderCards(items, category) {
|
|
|
1866
1914
|
<div class="card-title">${item.title}</div>
|
|
1867
1915
|
${item.description ? `<div class="card-desc">${item.description}</div>` : ''}
|
|
1868
1916
|
${priceHtml}
|
|
1917
|
+
${saveBtn}
|
|
1869
1918
|
</div>
|
|
1870
1919
|
</div>`;
|
|
1871
1920
|
}).join('');
|
|
@@ -1897,9 +1946,21 @@ function generateShoppeHTML(tenant, goods, uploadAuth = null) {
|
|
|
1897
1946
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1898
1947
|
<title>${tenant.name}</title>
|
|
1899
1948
|
${tenant.keywords ? `<meta name="keywords" content="${escHtml(tenant.keywords)}">` : ''}
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1949
|
+
<script>
|
|
1950
|
+
const _shoppeId = ${JSON.stringify(tenant.uuid)};
|
|
1951
|
+
const _shoppeName = ${JSON.stringify(tenant.name)};
|
|
1952
|
+
function saveToShoppere(title, productId, price, category) {
|
|
1953
|
+
const url = 'shoppere://product?'
|
|
1954
|
+
+ 'd=' + encodeURIComponent(window.location.hostname)
|
|
1955
|
+
+ '&s=' + encodeURIComponent(_shoppeId)
|
|
1956
|
+
+ '&n=' + encodeURIComponent(_shoppeName)
|
|
1957
|
+
+ '&t=' + encodeURIComponent(title)
|
|
1958
|
+
+ '&i=' + encodeURIComponent(productId || title)
|
|
1959
|
+
+ '&p=' + price
|
|
1960
|
+
+ '&c=' + encodeURIComponent(category);
|
|
1961
|
+
window.location.href = url;
|
|
1962
|
+
}
|
|
1963
|
+
</script>
|
|
1903
1964
|
<script src="https://js.stripe.com/v3/"></script>
|
|
1904
1965
|
<style>
|
|
1905
1966
|
/* ── Theme variables (dark default) ── */
|
|
@@ -1979,6 +2040,8 @@ function generateShoppeHTML(tenant, goods, uploadAuth = null) {
|
|
|
1979
2040
|
.card-desc { font-size: 13px; color: var(--text-2); margin-bottom: 8px; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
|
1980
2041
|
.price { font-size: 15px; font-weight: 700; color: var(--accent); }
|
|
1981
2042
|
.shipping { font-size: 12px; font-weight: 400; color: var(--text-3); }
|
|
2043
|
+
.save-shoppere-btn { margin-top: 8px; background: none; border: 1px solid var(--border); border-radius: 14px; color: var(--text-3); font-size: 11px; padding: 4px 10px; cursor: pointer; display: inline-block; }
|
|
2044
|
+
.save-shoppere-btn:active { opacity: 0.6; }
|
|
1982
2045
|
.empty { color: var(--text-3); text-align: center; padding: 60px 0; font-size: 15px; }
|
|
1983
2046
|
.card-video-play { position: relative; }
|
|
1984
2047
|
.card-video-play::after { content: '▶'; position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 36px; color: rgba(255,255,255,0.9); background: rgba(0,0,0,0.35); opacity: 0; transition: opacity 0.2s; pointer-events: none; }
|
|
@@ -2250,7 +2313,7 @@ function generateShoppeHTML(tenant, goods, uploadAuth = null) {
|
|
|
2250
2313
|
const standaloneHtml = standalones.length ? \`
|
|
2251
2314
|
\${series.length ? '<div class="posts-standalones-label">Posts</div>' : ''}
|
|
2252
2315
|
<div class="posts-grid">\${standalones.map(p => \`
|
|
2253
|
-
<div class="card" onclick="window.
|
|
2316
|
+
<div class="card" style="cursor:pointer" onclick="window.location.href='\${_escHtml(p.url)}'">
|
|
2254
2317
|
\${p.image ? \`<div class="card-img"><img src="\${_escHtml(p.image)}" alt="" loading="lazy"></div>\` : '<div class="card-img-placeholder">📝</div>'}
|
|
2255
2318
|
<div class="card-body">
|
|
2256
2319
|
<div class="card-title">\${_escHtml(p.title)}</div>
|
|
@@ -2942,6 +3005,19 @@ async function startServer(params) {
|
|
|
2942
3005
|
if (!fs.existsSync(TMP_DIR)) fs.mkdirSync(TMP_DIR, { recursive: true });
|
|
2943
3006
|
console.log('🛍️ wiki-plugin-shoppe starting...');
|
|
2944
3007
|
|
|
3008
|
+
// Allow Shoppere desktop/mobile app (tauri://localhost) to call our JSON API
|
|
3009
|
+
const SHOPPERE_ORIGINS = ['tauri://localhost', 'https://tauri.localhost'];
|
|
3010
|
+
app.use('/plugin/shoppe', (req, res, next) => {
|
|
3011
|
+
const origin = req.headers.origin;
|
|
3012
|
+
if (origin && SHOPPERE_ORIGINS.includes(origin)) {
|
|
3013
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
3014
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
3015
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
3016
|
+
if (req.method === 'OPTIONS') return res.sendStatus(200);
|
|
3017
|
+
}
|
|
3018
|
+
next();
|
|
3019
|
+
});
|
|
3020
|
+
|
|
2945
3021
|
const owner = (req, res, next) => {
|
|
2946
3022
|
if (!app.securityhandler.isAuthorized(req)) {
|
|
2947
3023
|
return res.status(401).json({ error: 'must be owner' });
|
|
@@ -3045,29 +3121,6 @@ async function startServer(params) {
|
|
|
3045
3121
|
}
|
|
3046
3122
|
});
|
|
3047
3123
|
|
|
3048
|
-
// Apple App Clip associated domain verification.
|
|
3049
|
-
// Apple fetches this file from /.well-known/apple-app-site-association on the wiki domain
|
|
3050
|
-
// to confirm the App Clip is authorized to handle URLs on this host.
|
|
3051
|
-
// Replace TEAM_ID with the Apple Developer Team ID (RLJ2FY35FD).
|
|
3052
|
-
app.get('/.well-known/apple-app-site-association', (req, res) => {
|
|
3053
|
-
res.set('Content-Type', 'application/json');
|
|
3054
|
-
res.json({
|
|
3055
|
-
applinks: {
|
|
3056
|
-
details: [
|
|
3057
|
-
{
|
|
3058
|
-
appIDs: ['RLJ2FY35FD.app.foures.shoppere'],
|
|
3059
|
-
components: [
|
|
3060
|
-
{ '/': '/plugin/shoppe/*', comment: 'All shoppe pages' }
|
|
3061
|
-
]
|
|
3062
|
-
}
|
|
3063
|
-
]
|
|
3064
|
-
},
|
|
3065
|
-
appclips: {
|
|
3066
|
-
apps: ['RLJ2FY35FD.app.foures.shoppere.shoppere']
|
|
3067
|
-
}
|
|
3068
|
-
});
|
|
3069
|
-
});
|
|
3070
|
-
|
|
3071
3124
|
// Public directory — name, emojicode, and shoppe URL only
|
|
3072
3125
|
app.get('/plugin/shoppe/directory', (req, res) => {
|
|
3073
3126
|
const tenants = loadTenants();
|
|
@@ -3169,7 +3222,7 @@ async function startServer(params) {
|
|
|
3169
3222
|
? JSON.stringify([{ pubKey: tenant.addieKeys.pubKey, amount: product.price || 0 }])
|
|
3170
3223
|
: '[]';
|
|
3171
3224
|
|
|
3172
|
-
// Forward Shoppere
|
|
3225
|
+
// Forward Shoppere credentials if present in the query string
|
|
3173
3226
|
const buyerPubKey = req.query.pubKey || '';
|
|
3174
3227
|
const buyerTimestamp = req.query.timestamp || '';
|
|
3175
3228
|
const buyerSignature = req.query.signature || '';
|
|
@@ -3180,8 +3233,6 @@ async function startServer(params) {
|
|
|
3180
3233
|
? `${ebookUrl}?pubKey=${encodeURIComponent(buyerPubKey)}×tamp=${encodeURIComponent(buyerTimestamp)}&signature=${encodeURIComponent(buyerSignature)}`
|
|
3181
3234
|
: ebookUrl;
|
|
3182
3235
|
|
|
3183
|
-
const clipUrl = `${wikiOrigin}${req.path}?${new URLSearchParams(req.query).toString()}`;
|
|
3184
|
-
|
|
3185
3236
|
const html = fillTemplate(templateHtml, {
|
|
3186
3237
|
title: product.title || title,
|
|
3187
3238
|
description: product.description || '',
|
|
@@ -3199,7 +3250,8 @@ async function startServer(params) {
|
|
|
3199
3250
|
payees,
|
|
3200
3251
|
tenantUuid: tenant.uuid,
|
|
3201
3252
|
keywords: extractKeywords(product),
|
|
3202
|
-
|
|
3253
|
+
shopName: tenant.name || '',
|
|
3254
|
+
category: product.category || 'book',
|
|
3203
3255
|
});
|
|
3204
3256
|
|
|
3205
3257
|
res.set('Content-Type', 'text/html');
|
|
@@ -3510,7 +3562,8 @@ async function startServer(params) {
|
|
|
3510
3562
|
const tenant = getTenantByIdentifier(req.params.identifier);
|
|
3511
3563
|
if (!tenant) return res.status(404).json({ error: 'Shoppe not found' });
|
|
3512
3564
|
|
|
3513
|
-
const { recoveryKey, pubKey, timestamp: buyerTimestamp, signature: buyerSignature, productId, title, slotDatetime, payees: clientPayees
|
|
3565
|
+
const { recoveryKey, pubKey, timestamp: buyerTimestamp, signature: buyerSignature, productId, title, slotDatetime, payees: clientPayees,
|
|
3566
|
+
affiliatePubKey } = req.body;
|
|
3514
3567
|
if (!productId) return res.status(400).json({ error: 'productId required' });
|
|
3515
3568
|
if (!pubKey && !recoveryKey && !title) return res.status(400).json({ error: 'pubKey (with timestamp+signature) or recoveryKey required' });
|
|
3516
3569
|
|
|
@@ -3588,20 +3641,37 @@ async function startServer(params) {
|
|
|
3588
3641
|
return res.json({ free: true });
|
|
3589
3642
|
}
|
|
3590
3643
|
|
|
3591
|
-
//
|
|
3592
|
-
//
|
|
3593
|
-
//
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
|
|
3601
|
-
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3644
|
+
// Build payees for Addie.
|
|
3645
|
+
// If an affiliate pubKey is present (NFC proximity charge / referral link flow),
|
|
3646
|
+
// split: affiliate gets affiliateCommission %, tenant gets the rest.
|
|
3647
|
+
// Addie enforces its own signing — we just pass pubKeys as payees.
|
|
3648
|
+
let payees;
|
|
3649
|
+
if (affiliatePubKey) {
|
|
3650
|
+
const affiliateCommission = tenant.affiliateCommission ?? 0.10;
|
|
3651
|
+
const affiliateAmount = Math.floor(amount * affiliateCommission);
|
|
3652
|
+
const tenantAmount = amount - affiliateAmount;
|
|
3653
|
+
|
|
3654
|
+
const affiliateAddieUser = await getOrCreateAffiliateAddieUser(affiliatePubKey);
|
|
3655
|
+
payees = tenant.addieKeys
|
|
3656
|
+
? [
|
|
3657
|
+
{ pubKey: tenant.addieKeys.pubKey, amount: tenantAmount },
|
|
3658
|
+
{ pubKey: affiliateAddieUser.pubKey, amount: affiliateAmount }
|
|
3659
|
+
]
|
|
3660
|
+
: [{ pubKey: affiliateAddieUser.pubKey, amount }];
|
|
3661
|
+
} else {
|
|
3662
|
+
// Standard flow — optionally accept client-supplied payees capped at 5% each
|
|
3663
|
+
const maxPayeeAmount = amount * 0.05;
|
|
3664
|
+
const validatedPayees = Array.isArray(clientPayees)
|
|
3665
|
+
? clientPayees.filter(p => {
|
|
3666
|
+
if (p.percent != null && p.percent > 5) return false;
|
|
3667
|
+
if (p.amount != null && p.amount > maxPayeeAmount) return false;
|
|
3668
|
+
return true;
|
|
3669
|
+
})
|
|
3670
|
+
: [];
|
|
3671
|
+
payees = validatedPayees.length > 0
|
|
3672
|
+
? validatedPayees
|
|
3673
|
+
: tenant.addieKeys ? [{ pubKey: tenant.addieKeys.pubKey, amount }] : [];
|
|
3674
|
+
}
|
|
3605
3675
|
const buyerKeys = { pubKey: buyer.pubKey, privateKey: buyer.privateKey };
|
|
3606
3676
|
sessionless.getKeys = () => buyerKeys;
|
|
3607
3677
|
const intentTimestamp = Date.now().toString();
|
|
@@ -3808,6 +3878,149 @@ async function startServer(params) {
|
|
|
3808
3878
|
}
|
|
3809
3879
|
});
|
|
3810
3880
|
|
|
3881
|
+
// ── Stripe Terminal routes ─────────────────────────────────────────────────
|
|
3882
|
+
|
|
3883
|
+
app.get('/plugin/shoppe/:identifier/terminal/connection-token', async (req, res) => {
|
|
3884
|
+
try {
|
|
3885
|
+
const tenant = getTenantByIdentifier(req.params.identifier);
|
|
3886
|
+
if (!tenant) return res.status(404).json({ error: 'Shoppe not found' });
|
|
3887
|
+
const stripe = getStripe();
|
|
3888
|
+
const token = await stripe.terminal.connectionTokens.create();
|
|
3889
|
+
res.json({ secret: token.secret });
|
|
3890
|
+
} catch (err) {
|
|
3891
|
+
const status = err.message.includes('STRIPE_SECRET_KEY') ? 503 : 500;
|
|
3892
|
+
res.status(status).json({ error: err.message });
|
|
3893
|
+
}
|
|
3894
|
+
});
|
|
3895
|
+
|
|
3896
|
+
app.post('/plugin/shoppe/:identifier/terminal/payment-intent', async (req, res) => {
|
|
3897
|
+
try {
|
|
3898
|
+
const tenant = getTenantByIdentifier(req.params.identifier);
|
|
3899
|
+
if (!tenant) return res.status(404).json({ error: 'Shoppe not found' });
|
|
3900
|
+
|
|
3901
|
+
const { amount, currency = 'usd', productId, productTitle, affiliatePubKey } = req.body;
|
|
3902
|
+
if (!amount || amount <= 0) return res.status(400).json({ error: 'amount required' });
|
|
3903
|
+
|
|
3904
|
+
const stripe = getStripe();
|
|
3905
|
+
const paymentIntent = await stripe.paymentIntents.create({
|
|
3906
|
+
amount,
|
|
3907
|
+
currency,
|
|
3908
|
+
payment_method_types: ['card_present'],
|
|
3909
|
+
capture_method: 'manual',
|
|
3910
|
+
description: productTitle || 'Shoppere charge',
|
|
3911
|
+
metadata: {
|
|
3912
|
+
shopId: tenant.uuid,
|
|
3913
|
+
productId: productId || '',
|
|
3914
|
+
affiliatePubKey: affiliatePubKey || '',
|
|
3915
|
+
},
|
|
3916
|
+
});
|
|
3917
|
+
|
|
3918
|
+
res.json({ paymentIntentId: paymentIntent.id, clientSecret: paymentIntent.client_secret });
|
|
3919
|
+
} catch (err) {
|
|
3920
|
+
console.error('[shoppe] terminal payment-intent error:', err);
|
|
3921
|
+
res.status(500).json({ error: err.message });
|
|
3922
|
+
}
|
|
3923
|
+
});
|
|
3924
|
+
|
|
3925
|
+
app.post('/plugin/shoppe/:identifier/terminal/capture', async (req, res) => {
|
|
3926
|
+
try {
|
|
3927
|
+
const tenant = getTenantByIdentifier(req.params.identifier);
|
|
3928
|
+
if (!tenant) return res.status(404).json({ error: 'Shoppe not found' });
|
|
3929
|
+
|
|
3930
|
+
const { paymentIntentId, productId, productTitle, buyerInfo } = req.body;
|
|
3931
|
+
if (!paymentIntentId) return res.status(400).json({ error: 'paymentIntentId required' });
|
|
3932
|
+
|
|
3933
|
+
const stripe = getStripe();
|
|
3934
|
+
|
|
3935
|
+
// Capture the card-present payment
|
|
3936
|
+
const pi = await stripe.paymentIntents.capture(paymentIntentId);
|
|
3937
|
+
const chargeId = typeof pi.latest_charge === 'string' ? pi.latest_charge : pi.latest_charge?.id;
|
|
3938
|
+
const amount = pi.amount_received || pi.amount;
|
|
3939
|
+
|
|
3940
|
+
// Retrieve affiliate info from PI metadata
|
|
3941
|
+
const affiliatePubKey = pi.metadata?.affiliatePubKey || '';
|
|
3942
|
+
|
|
3943
|
+
// Split funds via Stripe Transfers if Stripe account IDs are available
|
|
3944
|
+
const affiliateCommission = tenant.affiliateCommission ?? 0.10;
|
|
3945
|
+
const affiliateAmount = Math.floor(amount * affiliateCommission);
|
|
3946
|
+
const tenantAmount = amount - affiliateAmount;
|
|
3947
|
+
|
|
3948
|
+
const transferBase = chargeId ? { source_transaction: chargeId, transfer_group: paymentIntentId } : { transfer_group: paymentIntentId };
|
|
3949
|
+
|
|
3950
|
+
if (tenant.stripeAccountId && tenantAmount > 0) {
|
|
3951
|
+
stripe.transfers.create({ amount: tenantAmount, currency: 'usd', destination: tenant.stripeAccountId, ...transferBase })
|
|
3952
|
+
.catch(e => console.warn('[shoppe] tenant transfer failed:', e.message));
|
|
3953
|
+
}
|
|
3954
|
+
|
|
3955
|
+
if (affiliatePubKey) {
|
|
3956
|
+
const affiliates = loadAffiliates();
|
|
3957
|
+
const aff = affiliates[affiliatePubKey];
|
|
3958
|
+
if (aff?.stripeAccountId && affiliateAmount > 0) {
|
|
3959
|
+
stripe.transfers.create({ amount: affiliateAmount, currency: 'usd', destination: aff.stripeAccountId, ...transferBase })
|
|
3960
|
+
.catch(e => console.warn('[shoppe] affiliate transfer failed:', e.message));
|
|
3961
|
+
}
|
|
3962
|
+
}
|
|
3963
|
+
|
|
3964
|
+
// Record order in Sanora (fire-and-forget)
|
|
3965
|
+
if (productId && tenant.keys) {
|
|
3966
|
+
const ts = Date.now().toString();
|
|
3967
|
+
sessionless.getKeys = () => tenant.keys;
|
|
3968
|
+
const orderSig = await sessionless.sign(ts + tenant.uuid).catch(() => null);
|
|
3969
|
+
if (orderSig) {
|
|
3970
|
+
const order = {
|
|
3971
|
+
productId,
|
|
3972
|
+
title: productTitle || productId,
|
|
3973
|
+
paidAt: ts,
|
|
3974
|
+
paymentIntentId,
|
|
3975
|
+
channel: 'terminal',
|
|
3976
|
+
buyerInfo: buyerInfo || null,
|
|
3977
|
+
};
|
|
3978
|
+
fetch(`${getSanoraUrl()}/user/orders`, {
|
|
3979
|
+
method: 'PUT',
|
|
3980
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3981
|
+
body: JSON.stringify({ timestamp: ts, order, signature: orderSig }),
|
|
3982
|
+
}).catch(() => {});
|
|
3983
|
+
}
|
|
3984
|
+
}
|
|
3985
|
+
|
|
3986
|
+
res.json({ success: true, amount, affiliateAmount, tenantAmount });
|
|
3987
|
+
} catch (err) {
|
|
3988
|
+
console.error('[shoppe] terminal capture error:', err);
|
|
3989
|
+
res.status(500).json({ error: err.message });
|
|
3990
|
+
}
|
|
3991
|
+
});
|
|
3992
|
+
|
|
3993
|
+
// Called after Stripe Connect onboarding to link a Stripe account to the tenant or affiliate.
|
|
3994
|
+
// body: { type: 'tenant' | 'affiliate', stripeAccountId, pubKey? (for affiliate) }
|
|
3995
|
+
app.post('/plugin/shoppe/:identifier/terminal/stripe-account', async (req, res) => {
|
|
3996
|
+
try {
|
|
3997
|
+
const tenant = getTenantByIdentifier(req.params.identifier);
|
|
3998
|
+
if (!tenant) return res.status(404).json({ error: 'Shoppe not found' });
|
|
3999
|
+
|
|
4000
|
+
const { type, stripeAccountId, pubKey } = req.body;
|
|
4001
|
+
if (!stripeAccountId) return res.status(400).json({ error: 'stripeAccountId required' });
|
|
4002
|
+
|
|
4003
|
+
if (type === 'tenant') {
|
|
4004
|
+
const tenants = loadTenants();
|
|
4005
|
+
tenants[tenant.uuid].stripeAccountId = stripeAccountId;
|
|
4006
|
+
saveTenants(tenants);
|
|
4007
|
+
return res.json({ ok: true });
|
|
4008
|
+
}
|
|
4009
|
+
|
|
4010
|
+
if (type === 'affiliate' && pubKey) {
|
|
4011
|
+
const affiliates = loadAffiliates();
|
|
4012
|
+
if (!affiliates[pubKey]) return res.status(404).json({ error: 'Affiliate not found — they must initiate a charge first' });
|
|
4013
|
+
affiliates[pubKey].stripeAccountId = stripeAccountId;
|
|
4014
|
+
saveAffiliates(affiliates);
|
|
4015
|
+
return res.json({ ok: true });
|
|
4016
|
+
}
|
|
4017
|
+
|
|
4018
|
+
res.status(400).json({ error: 'type must be tenant or affiliate (with pubKey)' });
|
|
4019
|
+
} catch (err) {
|
|
4020
|
+
res.status(500).json({ error: err.message });
|
|
4021
|
+
}
|
|
4022
|
+
});
|
|
4023
|
+
|
|
3811
4024
|
// Ebook download page (reached after successful payment + hash creation)
|
|
3812
4025
|
app.get('/plugin/shoppe/:identifier/download/:title', async (req, res) => {
|
|
3813
4026
|
try {
|
|
@@ -3871,11 +4084,11 @@ async function startServer(params) {
|
|
|
3871
4084
|
const title = decodeURIComponent(req.params.title);
|
|
3872
4085
|
const productsResp = await fetch(`${getSanoraUrl()}/products/${tenant.uuid}`);
|
|
3873
4086
|
const products = await productsResp.json();
|
|
3874
|
-
const product = products[title];
|
|
4087
|
+
const product = products[title] || Object.values(products).find(p => p.title === title);
|
|
3875
4088
|
if (!product) return res.status(404).send('<h1>Post not found</h1>');
|
|
3876
4089
|
|
|
3877
4090
|
// Find the markdown artifact (UUID-named .md file)
|
|
3878
|
-
const mdArtifact = (product.artifacts || []).find(a => a.
|
|
4091
|
+
const mdArtifact = (product.artifacts || []).find(a => a.includes('.md'));
|
|
3879
4092
|
let mdContent = '';
|
|
3880
4093
|
if (mdArtifact) {
|
|
3881
4094
|
const artResp = await fetch(`${getSanoraUrl()}/artifacts/${mdArtifact}`);
|
|
@@ -4,9 +4,6 @@
|
|
|
4
4
|
<script src="https://js.stripe.com/v3/"></script>
|
|
5
5
|
<meta charset="utf-8">
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.00, maximum-scale=1.00, minimum-scale=1.00">
|
|
7
|
-
<!-- Shoppere App Clip — triggers on iOS Safari when user visits this buy page -->
|
|
8
|
-
<meta name="apple-itunes-app"
|
|
9
|
-
content="app-id=6760954663, app-clip-bundle-id=app.foures.shoppere.shoppere, app-clip-display=card, app-clip-url={{clipUrl}}">
|
|
10
7
|
<meta name="twitter:title" content="{{title}}">
|
|
11
8
|
<meta name="description" content="{{description}}">
|
|
12
9
|
<meta name="keywords" content="{{keywords}}">
|
|
@@ -114,6 +111,15 @@
|
|
|
114
111
|
<h1>{{title}}</h1>
|
|
115
112
|
<p>{{description}}</p>
|
|
116
113
|
<button class="buy-btn" id="buy-button">BUY NOW — ${{formattedAmount}}</button>
|
|
114
|
+
<div id="shoppere-btn-wrap" style="margin-top:16px;display:none;">
|
|
115
|
+
<a id="shoppere-open-link" href="#"
|
|
116
|
+
style="display:block;width:100%;max-width:400px;margin:0 auto;padding:18px 40px;background:linear-gradient(135deg,#7ec8e3,#4a90b8);color:#0f0f12;border-radius:50px;font-size:clamp(1rem,3.5vw,1.3rem);font-weight:bold;text-align:center;text-decoration:none;box-shadow:0 8px 32px rgba(126,200,227,0.25);">
|
|
117
|
+
Open in Shoppere
|
|
118
|
+
</a>
|
|
119
|
+
<p style="text-align:center;margin-top:10px;font-size:12px;color:#666;">
|
|
120
|
+
Don't have Shoppere? <a href="https://apps.apple.com/app/id6760954663" style="color:#7ec8e3;">Download on the App Store</a>
|
|
121
|
+
</p>
|
|
122
|
+
</div>
|
|
117
123
|
</div>
|
|
118
124
|
</div>
|
|
119
125
|
|
|
@@ -171,10 +177,6 @@
|
|
|
171
177
|
</div>
|
|
172
178
|
|
|
173
179
|
</div><!-- #main-container -->
|
|
174
|
-
<div id="clip-debug" style="position:fixed;bottom:0;left:0;right:0;background:#111;color:#7ec8e3;font-family:monospace;font-size:10px;padding:6px 10px;z-index:9999;border-top:1px solid #333;word-break:break-all;">
|
|
175
|
-
<strong>clip-bundle-id:</strong> app.foures.shoppere.shoppere |
|
|
176
|
-
<strong>app-clip-url:</strong> {{clipUrl}}
|
|
177
|
-
</div>
|
|
178
180
|
|
|
179
181
|
<!-- ── Shared payees param parser ─────────────────────────────────────────── -->
|
|
180
182
|
<script>
|
|
@@ -196,12 +198,14 @@
|
|
|
196
198
|
|
|
197
199
|
<!-- ── Shoppere (pubKey) path ────────────────────────────────────────────── -->
|
|
198
200
|
<script>
|
|
199
|
-
// Credentials injected by the server from req.query, or read from URL params
|
|
200
|
-
// (the App Clip appends them when redirecting back to the buy page).
|
|
201
|
+
// Credentials injected by the server from req.query, or read from URL params.
|
|
201
202
|
const _buyerPubKey = '{{buyerPubKey}}' || new URLSearchParams(window.location.search).get('pubKey') || '';
|
|
202
203
|
const _buyerTimestamp = '{{buyerTimestamp}}' || new URLSearchParams(window.location.search).get('timestamp') || '';
|
|
203
204
|
const _buyerSignature = '{{buyerSignature}}' || new URLSearchParams(window.location.search).get('signature') || '';
|
|
204
205
|
const IS_SHOPPERE = !!(_buyerPubKey && _buyerTimestamp && _buyerSignature);
|
|
206
|
+
// Affiliate (NFC proximity charge / referral link) param — present when buyer arrived via a referred link
|
|
207
|
+
const _affiliatePubKey = new URLSearchParams(window.location.search).get('affiliatePubKey') || '';
|
|
208
|
+
const IS_AFFILIATE_CHARGE = !!_affiliatePubKey;
|
|
205
209
|
|
|
206
210
|
let _shoppereStripe, _shoppereElements, _shoppereClientSecret;
|
|
207
211
|
|
|
@@ -224,7 +228,8 @@
|
|
|
224
228
|
signature: _buyerSignature,
|
|
225
229
|
productId: '{{productId}}',
|
|
226
230
|
title: '{{title}}',
|
|
227
|
-
...(URL_PAYEES && { payees: URL_PAYEES })
|
|
231
|
+
...(URL_PAYEES && { payees: URL_PAYEES }),
|
|
232
|
+
...(IS_AFFILIATE_CHARGE && { affiliatePubKey: _affiliatePubKey })
|
|
228
233
|
})
|
|
229
234
|
});
|
|
230
235
|
const json = await resp.json();
|
|
@@ -318,6 +323,21 @@
|
|
|
318
323
|
// Route on load
|
|
319
324
|
if (IS_SHOPPERE) {
|
|
320
325
|
document.addEventListener('DOMContentLoaded', initShopperePath);
|
|
326
|
+
} else {
|
|
327
|
+
// Show the "Open in Shoppere" button with the deep link URL
|
|
328
|
+
document.addEventListener('DOMContentLoaded', function() {
|
|
329
|
+
const deepLink = 'shoppere://buy?' + new URLSearchParams({
|
|
330
|
+
domain: window.location.hostname,
|
|
331
|
+
shopId: '{{tenantUuid}}',
|
|
332
|
+
shopName: '{{shopName}}',
|
|
333
|
+
productTitle: '{{title}}',
|
|
334
|
+
productId: '{{productId}}',
|
|
335
|
+
price: '{{amount}}',
|
|
336
|
+
category: '{{category}}',
|
|
337
|
+
}).toString();
|
|
338
|
+
document.getElementById('shoppere-open-link').href = deepLink;
|
|
339
|
+
document.getElementById('shoppere-btn-wrap').style.display = 'block';
|
|
340
|
+
});
|
|
321
341
|
}
|
|
322
342
|
</script>
|
|
323
343
|
|