wiki-plugin-shoppe 0.0.38 → 0.0.40
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 +264 -64
- package/server/templates/generic-recover-stripe.html +31 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wiki-plugin-shoppe",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.40",
|
|
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>
|
|
@@ -3045,29 +3108,6 @@ async function startServer(params) {
|
|
|
3045
3108
|
}
|
|
3046
3109
|
});
|
|
3047
3110
|
|
|
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
3111
|
// Public directory — name, emojicode, and shoppe URL only
|
|
3072
3112
|
app.get('/plugin/shoppe/directory', (req, res) => {
|
|
3073
3113
|
const tenants = loadTenants();
|
|
@@ -3169,7 +3209,7 @@ async function startServer(params) {
|
|
|
3169
3209
|
? JSON.stringify([{ pubKey: tenant.addieKeys.pubKey, amount: product.price || 0 }])
|
|
3170
3210
|
: '[]';
|
|
3171
3211
|
|
|
3172
|
-
// Forward Shoppere
|
|
3212
|
+
// Forward Shoppere credentials if present in the query string
|
|
3173
3213
|
const buyerPubKey = req.query.pubKey || '';
|
|
3174
3214
|
const buyerTimestamp = req.query.timestamp || '';
|
|
3175
3215
|
const buyerSignature = req.query.signature || '';
|
|
@@ -3180,8 +3220,6 @@ async function startServer(params) {
|
|
|
3180
3220
|
? `${ebookUrl}?pubKey=${encodeURIComponent(buyerPubKey)}×tamp=${encodeURIComponent(buyerTimestamp)}&signature=${encodeURIComponent(buyerSignature)}`
|
|
3181
3221
|
: ebookUrl;
|
|
3182
3222
|
|
|
3183
|
-
const clipUrl = `${wikiOrigin}${req.path}?${new URLSearchParams(req.query).toString()}`;
|
|
3184
|
-
|
|
3185
3223
|
const html = fillTemplate(templateHtml, {
|
|
3186
3224
|
title: product.title || title,
|
|
3187
3225
|
description: product.description || '',
|
|
@@ -3199,7 +3237,8 @@ async function startServer(params) {
|
|
|
3199
3237
|
payees,
|
|
3200
3238
|
tenantUuid: tenant.uuid,
|
|
3201
3239
|
keywords: extractKeywords(product),
|
|
3202
|
-
|
|
3240
|
+
shopName: tenant.name || '',
|
|
3241
|
+
category: product.category || 'book',
|
|
3203
3242
|
});
|
|
3204
3243
|
|
|
3205
3244
|
res.set('Content-Type', 'text/html');
|
|
@@ -3510,7 +3549,8 @@ async function startServer(params) {
|
|
|
3510
3549
|
const tenant = getTenantByIdentifier(req.params.identifier);
|
|
3511
3550
|
if (!tenant) return res.status(404).json({ error: 'Shoppe not found' });
|
|
3512
3551
|
|
|
3513
|
-
const { recoveryKey, pubKey, timestamp: buyerTimestamp, signature: buyerSignature, productId, title, slotDatetime, payees: clientPayees
|
|
3552
|
+
const { recoveryKey, pubKey, timestamp: buyerTimestamp, signature: buyerSignature, productId, title, slotDatetime, payees: clientPayees,
|
|
3553
|
+
affiliatePubKey } = req.body;
|
|
3514
3554
|
if (!productId) return res.status(400).json({ error: 'productId required' });
|
|
3515
3555
|
if (!pubKey && !recoveryKey && !title) return res.status(400).json({ error: 'pubKey (with timestamp+signature) or recoveryKey required' });
|
|
3516
3556
|
|
|
@@ -3588,20 +3628,37 @@ async function startServer(params) {
|
|
|
3588
3628
|
return res.json({ free: true });
|
|
3589
3629
|
}
|
|
3590
3630
|
|
|
3591
|
-
//
|
|
3592
|
-
//
|
|
3593
|
-
//
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
|
|
3601
|
-
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3631
|
+
// Build payees for Addie.
|
|
3632
|
+
// If an affiliate pubKey is present (NFC proximity charge / referral link flow),
|
|
3633
|
+
// split: affiliate gets affiliateCommission %, tenant gets the rest.
|
|
3634
|
+
// Addie enforces its own signing — we just pass pubKeys as payees.
|
|
3635
|
+
let payees;
|
|
3636
|
+
if (affiliatePubKey) {
|
|
3637
|
+
const affiliateCommission = tenant.affiliateCommission ?? 0.10;
|
|
3638
|
+
const affiliateAmount = Math.floor(amount * affiliateCommission);
|
|
3639
|
+
const tenantAmount = amount - affiliateAmount;
|
|
3640
|
+
|
|
3641
|
+
const affiliateAddieUser = await getOrCreateAffiliateAddieUser(affiliatePubKey);
|
|
3642
|
+
payees = tenant.addieKeys
|
|
3643
|
+
? [
|
|
3644
|
+
{ pubKey: tenant.addieKeys.pubKey, amount: tenantAmount },
|
|
3645
|
+
{ pubKey: affiliateAddieUser.pubKey, amount: affiliateAmount }
|
|
3646
|
+
]
|
|
3647
|
+
: [{ pubKey: affiliateAddieUser.pubKey, amount }];
|
|
3648
|
+
} else {
|
|
3649
|
+
// Standard flow — optionally accept client-supplied payees capped at 5% each
|
|
3650
|
+
const maxPayeeAmount = amount * 0.05;
|
|
3651
|
+
const validatedPayees = Array.isArray(clientPayees)
|
|
3652
|
+
? clientPayees.filter(p => {
|
|
3653
|
+
if (p.percent != null && p.percent > 5) return false;
|
|
3654
|
+
if (p.amount != null && p.amount > maxPayeeAmount) return false;
|
|
3655
|
+
return true;
|
|
3656
|
+
})
|
|
3657
|
+
: [];
|
|
3658
|
+
payees = validatedPayees.length > 0
|
|
3659
|
+
? validatedPayees
|
|
3660
|
+
: tenant.addieKeys ? [{ pubKey: tenant.addieKeys.pubKey, amount }] : [];
|
|
3661
|
+
}
|
|
3605
3662
|
const buyerKeys = { pubKey: buyer.pubKey, privateKey: buyer.privateKey };
|
|
3606
3663
|
sessionless.getKeys = () => buyerKeys;
|
|
3607
3664
|
const intentTimestamp = Date.now().toString();
|
|
@@ -3808,6 +3865,149 @@ async function startServer(params) {
|
|
|
3808
3865
|
}
|
|
3809
3866
|
});
|
|
3810
3867
|
|
|
3868
|
+
// ── Stripe Terminal routes ─────────────────────────────────────────────────
|
|
3869
|
+
|
|
3870
|
+
app.get('/plugin/shoppe/:identifier/terminal/connection-token', async (req, res) => {
|
|
3871
|
+
try {
|
|
3872
|
+
const tenant = getTenantByIdentifier(req.params.identifier);
|
|
3873
|
+
if (!tenant) return res.status(404).json({ error: 'Shoppe not found' });
|
|
3874
|
+
const stripe = getStripe();
|
|
3875
|
+
const token = await stripe.terminal.connectionTokens.create();
|
|
3876
|
+
res.json({ secret: token.secret });
|
|
3877
|
+
} catch (err) {
|
|
3878
|
+
const status = err.message.includes('STRIPE_SECRET_KEY') ? 503 : 500;
|
|
3879
|
+
res.status(status).json({ error: err.message });
|
|
3880
|
+
}
|
|
3881
|
+
});
|
|
3882
|
+
|
|
3883
|
+
app.post('/plugin/shoppe/:identifier/terminal/payment-intent', 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
|
+
|
|
3888
|
+
const { amount, currency = 'usd', productId, productTitle, affiliatePubKey } = req.body;
|
|
3889
|
+
if (!amount || amount <= 0) return res.status(400).json({ error: 'amount required' });
|
|
3890
|
+
|
|
3891
|
+
const stripe = getStripe();
|
|
3892
|
+
const paymentIntent = await stripe.paymentIntents.create({
|
|
3893
|
+
amount,
|
|
3894
|
+
currency,
|
|
3895
|
+
payment_method_types: ['card_present'],
|
|
3896
|
+
capture_method: 'manual',
|
|
3897
|
+
description: productTitle || 'Shoppere charge',
|
|
3898
|
+
metadata: {
|
|
3899
|
+
shopId: tenant.uuid,
|
|
3900
|
+
productId: productId || '',
|
|
3901
|
+
affiliatePubKey: affiliatePubKey || '',
|
|
3902
|
+
},
|
|
3903
|
+
});
|
|
3904
|
+
|
|
3905
|
+
res.json({ paymentIntentId: paymentIntent.id, clientSecret: paymentIntent.client_secret });
|
|
3906
|
+
} catch (err) {
|
|
3907
|
+
console.error('[shoppe] terminal payment-intent error:', err);
|
|
3908
|
+
res.status(500).json({ error: err.message });
|
|
3909
|
+
}
|
|
3910
|
+
});
|
|
3911
|
+
|
|
3912
|
+
app.post('/plugin/shoppe/:identifier/terminal/capture', async (req, res) => {
|
|
3913
|
+
try {
|
|
3914
|
+
const tenant = getTenantByIdentifier(req.params.identifier);
|
|
3915
|
+
if (!tenant) return res.status(404).json({ error: 'Shoppe not found' });
|
|
3916
|
+
|
|
3917
|
+
const { paymentIntentId, productId, productTitle, buyerInfo } = req.body;
|
|
3918
|
+
if (!paymentIntentId) return res.status(400).json({ error: 'paymentIntentId required' });
|
|
3919
|
+
|
|
3920
|
+
const stripe = getStripe();
|
|
3921
|
+
|
|
3922
|
+
// Capture the card-present payment
|
|
3923
|
+
const pi = await stripe.paymentIntents.capture(paymentIntentId);
|
|
3924
|
+
const chargeId = typeof pi.latest_charge === 'string' ? pi.latest_charge : pi.latest_charge?.id;
|
|
3925
|
+
const amount = pi.amount_received || pi.amount;
|
|
3926
|
+
|
|
3927
|
+
// Retrieve affiliate info from PI metadata
|
|
3928
|
+
const affiliatePubKey = pi.metadata?.affiliatePubKey || '';
|
|
3929
|
+
|
|
3930
|
+
// Split funds via Stripe Transfers if Stripe account IDs are available
|
|
3931
|
+
const affiliateCommission = tenant.affiliateCommission ?? 0.10;
|
|
3932
|
+
const affiliateAmount = Math.floor(amount * affiliateCommission);
|
|
3933
|
+
const tenantAmount = amount - affiliateAmount;
|
|
3934
|
+
|
|
3935
|
+
const transferBase = chargeId ? { source_transaction: chargeId, transfer_group: paymentIntentId } : { transfer_group: paymentIntentId };
|
|
3936
|
+
|
|
3937
|
+
if (tenant.stripeAccountId && tenantAmount > 0) {
|
|
3938
|
+
stripe.transfers.create({ amount: tenantAmount, currency: 'usd', destination: tenant.stripeAccountId, ...transferBase })
|
|
3939
|
+
.catch(e => console.warn('[shoppe] tenant transfer failed:', e.message));
|
|
3940
|
+
}
|
|
3941
|
+
|
|
3942
|
+
if (affiliatePubKey) {
|
|
3943
|
+
const affiliates = loadAffiliates();
|
|
3944
|
+
const aff = affiliates[affiliatePubKey];
|
|
3945
|
+
if (aff?.stripeAccountId && affiliateAmount > 0) {
|
|
3946
|
+
stripe.transfers.create({ amount: affiliateAmount, currency: 'usd', destination: aff.stripeAccountId, ...transferBase })
|
|
3947
|
+
.catch(e => console.warn('[shoppe] affiliate transfer failed:', e.message));
|
|
3948
|
+
}
|
|
3949
|
+
}
|
|
3950
|
+
|
|
3951
|
+
// Record order in Sanora (fire-and-forget)
|
|
3952
|
+
if (productId && tenant.keys) {
|
|
3953
|
+
const ts = Date.now().toString();
|
|
3954
|
+
sessionless.getKeys = () => tenant.keys;
|
|
3955
|
+
const orderSig = await sessionless.sign(ts + tenant.uuid).catch(() => null);
|
|
3956
|
+
if (orderSig) {
|
|
3957
|
+
const order = {
|
|
3958
|
+
productId,
|
|
3959
|
+
title: productTitle || productId,
|
|
3960
|
+
paidAt: ts,
|
|
3961
|
+
paymentIntentId,
|
|
3962
|
+
channel: 'terminal',
|
|
3963
|
+
buyerInfo: buyerInfo || null,
|
|
3964
|
+
};
|
|
3965
|
+
fetch(`${getSanoraUrl()}/user/orders`, {
|
|
3966
|
+
method: 'PUT',
|
|
3967
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3968
|
+
body: JSON.stringify({ timestamp: ts, order, signature: orderSig }),
|
|
3969
|
+
}).catch(() => {});
|
|
3970
|
+
}
|
|
3971
|
+
}
|
|
3972
|
+
|
|
3973
|
+
res.json({ success: true, amount, affiliateAmount, tenantAmount });
|
|
3974
|
+
} catch (err) {
|
|
3975
|
+
console.error('[shoppe] terminal capture error:', err);
|
|
3976
|
+
res.status(500).json({ error: err.message });
|
|
3977
|
+
}
|
|
3978
|
+
});
|
|
3979
|
+
|
|
3980
|
+
// Called after Stripe Connect onboarding to link a Stripe account to the tenant or affiliate.
|
|
3981
|
+
// body: { type: 'tenant' | 'affiliate', stripeAccountId, pubKey? (for affiliate) }
|
|
3982
|
+
app.post('/plugin/shoppe/:identifier/terminal/stripe-account', async (req, res) => {
|
|
3983
|
+
try {
|
|
3984
|
+
const tenant = getTenantByIdentifier(req.params.identifier);
|
|
3985
|
+
if (!tenant) return res.status(404).json({ error: 'Shoppe not found' });
|
|
3986
|
+
|
|
3987
|
+
const { type, stripeAccountId, pubKey } = req.body;
|
|
3988
|
+
if (!stripeAccountId) return res.status(400).json({ error: 'stripeAccountId required' });
|
|
3989
|
+
|
|
3990
|
+
if (type === 'tenant') {
|
|
3991
|
+
const tenants = loadTenants();
|
|
3992
|
+
tenants[tenant.uuid].stripeAccountId = stripeAccountId;
|
|
3993
|
+
saveTenants(tenants);
|
|
3994
|
+
return res.json({ ok: true });
|
|
3995
|
+
}
|
|
3996
|
+
|
|
3997
|
+
if (type === 'affiliate' && pubKey) {
|
|
3998
|
+
const affiliates = loadAffiliates();
|
|
3999
|
+
if (!affiliates[pubKey]) return res.status(404).json({ error: 'Affiliate not found — they must initiate a charge first' });
|
|
4000
|
+
affiliates[pubKey].stripeAccountId = stripeAccountId;
|
|
4001
|
+
saveAffiliates(affiliates);
|
|
4002
|
+
return res.json({ ok: true });
|
|
4003
|
+
}
|
|
4004
|
+
|
|
4005
|
+
res.status(400).json({ error: 'type must be tenant or affiliate (with pubKey)' });
|
|
4006
|
+
} catch (err) {
|
|
4007
|
+
res.status(500).json({ error: err.message });
|
|
4008
|
+
}
|
|
4009
|
+
});
|
|
4010
|
+
|
|
3811
4011
|
// Ebook download page (reached after successful payment + hash creation)
|
|
3812
4012
|
app.get('/plugin/shoppe/:identifier/download/:title', async (req, res) => {
|
|
3813
4013
|
try {
|
|
@@ -3871,11 +4071,11 @@ async function startServer(params) {
|
|
|
3871
4071
|
const title = decodeURIComponent(req.params.title);
|
|
3872
4072
|
const productsResp = await fetch(`${getSanoraUrl()}/products/${tenant.uuid}`);
|
|
3873
4073
|
const products = await productsResp.json();
|
|
3874
|
-
const product = products[title];
|
|
4074
|
+
const product = products[title] || Object.values(products).find(p => p.title === title);
|
|
3875
4075
|
if (!product) return res.status(404).send('<h1>Post not found</h1>');
|
|
3876
4076
|
|
|
3877
4077
|
// Find the markdown artifact (UUID-named .md file)
|
|
3878
|
-
const mdArtifact = (product.artifacts || []).find(a => a.
|
|
4078
|
+
const mdArtifact = (product.artifacts || []).find(a => a.includes('.md'));
|
|
3879
4079
|
let mdContent = '';
|
|
3880
4080
|
if (mdArtifact) {
|
|
3881
4081
|
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
|
|
|
@@ -452,7 +472,7 @@
|
|
|
452
472
|
const container = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
453
473
|
container.setAttribute('viewBox', `0 0 500 ${h + 100}`);
|
|
454
474
|
container.setAttribute('width', '100%');
|
|
455
|
-
container.
|
|
475
|
+
container.style.height = 'auto';
|
|
456
476
|
container.innerHTML = getBackgroundAndGradients(formJSON) + inputs.join('');
|
|
457
477
|
|
|
458
478
|
setTimeout(() => {
|