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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-plugin-shoppe",
3
- "version": "0.0.38",
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 = path.join(DATA_DIR, 'orders.json');
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
- // clipUrl is present for books and no-shipping products — it carries
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
- <!-- Shoppere App Clip — shown as a Smart App Banner on iOS Safari -->
1901
- <meta name="apple-itunes-app"
1902
- content="app-id=6760954663, app-clip-bundle-id=app.foures.shoppere.shoppere, app-clip-display=card">
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.open('\${_escHtml(p.url)}','_self')">
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 App Clip credentials if present in the query string
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)}&timestamp=${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
- clipUrl,
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 } = req.body;
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
- // Sign and create Stripe intent via Addie
3592
- // Client may supply payees parsed from ?payees= URL param (pipe-separated 4-tuples).
3593
- // Each payee is capped at 5% of the product price; any that exceed this are dropped.
3594
- const maxPayeeAmount = amount * 0.05;
3595
- const validatedPayees = Array.isArray(clientPayees)
3596
- ? clientPayees.filter(p => {
3597
- if (p.percent != null && p.percent > 5) return false;
3598
- if (p.amount != null && p.amount > maxPayeeAmount) return false;
3599
- return true;
3600
- })
3601
- : [];
3602
- const payees = validatedPayees.length > 0
3603
- ? validatedPayees
3604
- : tenant.addieKeys ? [{ pubKey: tenant.addieKeys.pubKey, amount }] : [];
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.endsWith('.md'));
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 &nbsp;|&nbsp;
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.setAttribute('height', 'auto');
475
+ container.style.height = 'auto';
456
476
  container.innerHTML = getBackgroundAndGradients(formJSON) + inputs.join('');
457
477
 
458
478
  setTimeout(() => {