wiki-plugin-shoppe 0.0.35 → 0.0.37
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/CLAUDE.md +105 -20
- package/package.json +1 -1
- package/server/server.js +300 -15
- package/server/templates/generic-recover-stripe.html +461 -547
|
@@ -4,383 +4,422 @@
|
|
|
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
|
-
|
|
8
|
-
|
|
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}}">
|
|
9
10
|
<meta name="twitter:title" content="{{title}}">
|
|
10
11
|
<meta name="description" content="{{description}}">
|
|
11
12
|
<meta name="keywords" content="{{keywords}}">
|
|
12
13
|
<meta name="twitter:description" content="{{description}}">
|
|
13
14
|
<meta name="twitter:image" content={{image}}>
|
|
14
|
-
|
|
15
15
|
<meta name="og:title" content="{{title}}">
|
|
16
16
|
<meta name="og:description" content="{{description}}">
|
|
17
17
|
<meta name="og:image" content={{image}}>
|
|
18
|
-
|
|
19
18
|
<title>{{title}}</title>
|
|
19
|
+
<style>
|
|
20
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
21
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f0f12; color: white; }
|
|
22
|
+
#main-container { min-height: 100vh; display: flex; flex-direction: column; }
|
|
23
|
+
|
|
24
|
+
/* ── Product hero ── */
|
|
25
|
+
#product-section {
|
|
26
|
+
display: flex; flex-direction: column; justify-content: center; align-items: center;
|
|
27
|
+
padding: 40px 20px; box-sizing: border-box; min-height: 100vh;
|
|
28
|
+
}
|
|
29
|
+
#product-section .inner { width: 100%; max-width: 600px; text-align: center; }
|
|
30
|
+
#product-image { width: 100%; height: auto; max-height: 50vh; object-fit: contain; border-radius: 12px; margin-bottom: 30px; }
|
|
31
|
+
#product-section h1 { margin: 0 0 20px; font-size: clamp(1.8rem, 5vw, 3rem); font-weight: bold; }
|
|
32
|
+
#product-section p { margin: 0 0 40px; opacity: 0.9; font-size: clamp(1rem, 3vw, 1.3rem); line-height: 1.5; }
|
|
33
|
+
.buy-btn {
|
|
34
|
+
width: 100%; max-width: 400px; padding: 20px 40px;
|
|
35
|
+
background: linear-gradient(135deg, #8b5cf6, #10b981);
|
|
36
|
+
color: white; border: none; border-radius: 50px;
|
|
37
|
+
font-size: clamp(1.2rem, 4vw, 1.5rem); font-weight: bold; cursor: pointer;
|
|
38
|
+
transition: all 0.3s ease; box-shadow: 0 8px 32px rgba(139,92,246,0.3);
|
|
39
|
+
}
|
|
40
|
+
.buy-btn:hover { transform: translateY(-2px); box-shadow: 0 12px 40px rgba(139,92,246,0.4); }
|
|
41
|
+
|
|
42
|
+
/* ── Shoppere path ── */
|
|
43
|
+
#shoppere-section {
|
|
44
|
+
display: none; padding: 40px 20px; box-sizing: border-box;
|
|
45
|
+
background: rgba(20,20,24,0.8); backdrop-filter: blur(10px);
|
|
46
|
+
}
|
|
47
|
+
.shoppere-card {
|
|
48
|
+
max-width: 520px; margin: 0 auto;
|
|
49
|
+
background: #18181c; border: 1px solid #333; border-radius: 16px; padding: 32px;
|
|
50
|
+
text-align: center;
|
|
51
|
+
}
|
|
52
|
+
.shoppere-card .icon { font-size: 48px; margin-bottom: 16px; }
|
|
53
|
+
.shoppere-card h2 { font-size: 22px; font-weight: 700; margin-bottom: 8px; }
|
|
54
|
+
.shoppere-card .sub { font-size: 14px; color: #aaa; margin-bottom: 24px; line-height: 1.5; }
|
|
55
|
+
.shoppere-card .price-line { font-size: 28px; font-weight: 700; color: #7ec8e3; margin-bottom: 24px; }
|
|
56
|
+
#shoppere-stripe-wrap { margin-bottom: 20px; }
|
|
57
|
+
#shoppere-pay-btn {
|
|
58
|
+
width: 100%; padding: 16px;
|
|
59
|
+
background: linear-gradient(90deg, #10b981, #8b5cf6);
|
|
60
|
+
color: white; border: none; border-radius: 12px;
|
|
61
|
+
font-size: 16px; font-weight: 700; cursor: pointer; transition: opacity 0.15s;
|
|
62
|
+
}
|
|
63
|
+
#shoppere-pay-btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
64
|
+
#shoppere-pay-btn:hover:not(:disabled) { opacity: 0.88; }
|
|
65
|
+
#shoppere-error { color: #ff6b6b; font-size: 13px; margin-top: 10px; display: none; }
|
|
66
|
+
#shoppere-loading { color: #aaa; font-size: 13px; margin-top: 10px; display: none; }
|
|
67
|
+
|
|
68
|
+
/* ── Shoppere success ── */
|
|
69
|
+
#shoppere-success { display: none; }
|
|
70
|
+
.success-icon { font-size: 64px; margin-bottom: 16px; }
|
|
71
|
+
.success-title { font-size: 24px; font-weight: 700; margin-bottom: 8px; }
|
|
72
|
+
.success-sub { font-size: 14px; color: #aaa; margin-bottom: 24px; line-height: 1.5; }
|
|
73
|
+
.success-back { color: #7ec8e3; font-size: 14px; text-decoration: none; }
|
|
74
|
+
.success-back:hover { text-decoration: underline; }
|
|
75
|
+
|
|
76
|
+
/* ── Legacy recovery key path ── */
|
|
77
|
+
#forms-section {
|
|
78
|
+
display: none; padding: 40px 20px; box-sizing: border-box;
|
|
79
|
+
background: rgba(20,20,24,0.8); backdrop-filter: blur(10px);
|
|
80
|
+
}
|
|
81
|
+
#forms-section h2 { text-align: center; margin-bottom: 40px; font-size: clamp(1.5rem, 4vw, 2rem); }
|
|
82
|
+
#forms-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 40px; align-items: start; }
|
|
83
|
+
#payment-section { display: none; }
|
|
84
|
+
#payment-section h3 { margin-bottom: 20px; color: #10b981; }
|
|
85
|
+
#payment-form {
|
|
86
|
+
background: #2a2a2e; padding: 30px; border-radius: 15px; border: 1px solid #444;
|
|
87
|
+
}
|
|
88
|
+
.amount-display { text-align: center; margin-bottom: 20px; padding: 15px; background: rgba(255,255,255,0.05); border-radius: 8px; }
|
|
89
|
+
.amount-display .label { color: #bbb; font-size: 14px; margin-bottom: 5px; }
|
|
90
|
+
.amount-display .value { color: #3eda82; font-size: 24px; font-weight: bold; }
|
|
91
|
+
#payment-element { margin-bottom: 20px; }
|
|
92
|
+
#submit-button {
|
|
93
|
+
width: 100%; padding: 15px; background: #666; color: #999;
|
|
94
|
+
border: none; border-radius: 22.5px; font-size: 16px; font-weight: bold; cursor: not-allowed;
|
|
95
|
+
}
|
|
96
|
+
#submit-button.ready { background: linear-gradient(90deg, #10b981, #8b5cf6); color: white; cursor: pointer; }
|
|
97
|
+
#error-message { display: none; color: #ff6b6b; margin-top: 10px; text-align: center; }
|
|
98
|
+
#loading { display: none; color: white; margin-top: 10px; text-align: center; }
|
|
99
|
+
|
|
100
|
+
@media (max-width: 768px) {
|
|
101
|
+
#forms-grid { grid-template-columns: 1fr !important; gap: 30px !important; }
|
|
102
|
+
#product-section { min-height: 80vh !important; padding: 20px !important; }
|
|
103
|
+
}
|
|
104
|
+
</style>
|
|
20
105
|
</head>
|
|
21
|
-
<body
|
|
22
|
-
<div id="main-container"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
">
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
">
|
|
37
|
-
<
|
|
38
|
-
|
|
39
|
-
<
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
box-shadow: 0 8px 32px rgba(139, 92, 246, 0.3);
|
|
60
|
-
" onmouseover="this.style.transform='translateY(-2px)'; this.style.boxShadow='0 12px 40px rgba(139, 92, 246, 0.4)';"
|
|
61
|
-
onmouseout="this.style.transform='translateY(0)'; this.style.boxShadow='0 8px 32px rgba(139, 92, 246, 0.3)';">
|
|
62
|
-
BUY NOW - ${{formattedAmount}}
|
|
63
|
-
</button>
|
|
106
|
+
<body>
|
|
107
|
+
<div id="main-container">
|
|
108
|
+
|
|
109
|
+
<!-- ── Product hero ─────────────────────────────────────────────────────── -->
|
|
110
|
+
<div id="product-section">
|
|
111
|
+
<div class="inner">
|
|
112
|
+
<img id="product-image" alt="{{title}}">
|
|
113
|
+
<script>document.getElementById('product-image').src = {{image}};</script>
|
|
114
|
+
<h1>{{title}}</h1>
|
|
115
|
+
<p>{{description}}</p>
|
|
116
|
+
<button class="buy-btn" id="buy-button">BUY NOW — ${{formattedAmount}}</button>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<!-- ── Shoppere (pubKey) path ────────────────────────────────────────────── -->
|
|
121
|
+
<div id="shoppere-section">
|
|
122
|
+
<div class="shoppere-card">
|
|
123
|
+
<!-- Purchase panel -->
|
|
124
|
+
<div id="shoppere-purchase">
|
|
125
|
+
<div class="icon">🛍️</div>
|
|
126
|
+
<h2>{{title}}</h2>
|
|
127
|
+
<div class="sub">Completing your purchase with Shoppere.</div>
|
|
128
|
+
<div class="price-line" id="shoppere-price">${{formattedAmount}}</div>
|
|
129
|
+
<div id="shoppere-stripe-wrap"></div>
|
|
130
|
+
<button id="shoppere-pay-btn" disabled>Pay ${{formattedAmount}}</button>
|
|
131
|
+
<div id="shoppere-error"></div>
|
|
132
|
+
<div id="shoppere-loading">Processing…</div>
|
|
133
|
+
</div>
|
|
134
|
+
<!-- Success panel -->
|
|
135
|
+
<div id="shoppere-success">
|
|
136
|
+
<div class="success-icon">✅</div>
|
|
137
|
+
<div class="success-title">Purchase complete!</div>
|
|
138
|
+
<div class="success-sub">
|
|
139
|
+
Open <strong>Shoppere</strong> to access <em>{{title}}</em>.<br>
|
|
140
|
+
Your purchase is saved to your shop credential.
|
|
141
|
+
</div>
|
|
142
|
+
<a class="success-back" href="{{shoppeUrl}}">← Back to shoppe</a>
|
|
143
|
+
</div>
|
|
64
144
|
</div>
|
|
65
|
-
</teleport>
|
|
66
145
|
</div>
|
|
67
146
|
|
|
68
|
-
<!--
|
|
69
|
-
<div id="forms-section"
|
|
70
|
-
display: none;
|
|
71
|
-
padding: 40px 20px;
|
|
72
|
-
box-sizing: border-box;
|
|
73
|
-
background: rgba(20, 20, 24, 0.8);
|
|
74
|
-
backdrop-filter: blur(10px);
|
|
75
|
-
">
|
|
147
|
+
<!-- ── Legacy recovery key path ─────────────────────────────────────────── -->
|
|
148
|
+
<div id="forms-section">
|
|
76
149
|
<div style="max-width: 1200px; margin: 0 auto;">
|
|
77
|
-
<h2
|
|
78
|
-
|
|
79
|
-
<div id="forms-grid" style="display: grid; grid-template-columns: 1fr 1fr; gap: 40px; align-items: start;">
|
|
80
|
-
<!-- Recovery Form -->
|
|
150
|
+
<h2>Complete Your Purchase</h2>
|
|
151
|
+
<div id="forms-grid">
|
|
81
152
|
<div>
|
|
82
153
|
<h3 style="margin-bottom: 20px; color: #a855f7;">Recovery Information</h3>
|
|
83
154
|
<div id="address-form"></div>
|
|
84
155
|
</div>
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
padding: 30px;
|
|
92
|
-
border-radius: 15px;
|
|
93
|
-
border: 1px solid #444;
|
|
94
|
-
">
|
|
95
|
-
<div style="
|
|
96
|
-
text-align: center;
|
|
97
|
-
margin-bottom: 20px;
|
|
98
|
-
padding: 15px;
|
|
99
|
-
background: rgba(255,255,255,0.05);
|
|
100
|
-
border-radius: 8px;
|
|
101
|
-
">
|
|
102
|
-
<div style="color: #bbbbbb; font-size: 14px; margin-bottom: 5px;">Total</div>
|
|
103
|
-
<div style="color: #3eda82; font-size: 24px; font-weight: bold;">${{formattedAmount}}</div>
|
|
156
|
+
<div id="payment-section">
|
|
157
|
+
<h3>Payment Details</h3>
|
|
158
|
+
<form id="payment-form">
|
|
159
|
+
<div class="amount-display">
|
|
160
|
+
<div class="label">Total</div>
|
|
161
|
+
<div class="value">${{formattedAmount}}</div>
|
|
104
162
|
</div>
|
|
105
|
-
<div id="payment-element"
|
|
106
|
-
<button id="submit-button"
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
background: #666666;
|
|
110
|
-
color: #999999;
|
|
111
|
-
border: none;
|
|
112
|
-
border-radius: 22.5px;
|
|
113
|
-
font-size: 16px;
|
|
114
|
-
font-weight: bold;
|
|
115
|
-
cursor: not-allowed;
|
|
116
|
-
" disabled>Complete Purchase</button>
|
|
117
|
-
<div id="error-message" class="error-message" style="display: none; color: #ff6b6b; margin-top: 10px; text-align: center;"></div>
|
|
118
|
-
<div id="loading" class="loading" style="display: none; color: white; margin-top: 10px; text-align: center;">Processing payment...</div>
|
|
163
|
+
<div id="payment-element"></div>
|
|
164
|
+
<button id="submit-button" disabled>Complete Purchase</button>
|
|
165
|
+
<div id="error-message"></div>
|
|
166
|
+
<div id="loading">Processing payment…</div>
|
|
119
167
|
</form>
|
|
120
168
|
</div>
|
|
121
169
|
</div>
|
|
122
170
|
</div>
|
|
123
171
|
</div>
|
|
124
|
-
</div>
|
|
125
172
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
173
|
+
</div><!-- #main-container -->
|
|
174
|
+
|
|
175
|
+
<!-- ── Shared payees param parser ─────────────────────────────────────────── -->
|
|
176
|
+
<script>
|
|
177
|
+
function parseUrlPayees() {
|
|
178
|
+
const raw = new URLSearchParams(window.location.search).get('payees');
|
|
179
|
+
if (!raw) return null;
|
|
180
|
+
return raw.split('|').map(t => {
|
|
181
|
+
const [pubKey, amount, percent, addieUrl] = t.split(':');
|
|
182
|
+
const p = {};
|
|
183
|
+
if (pubKey) p.pubKey = pubKey;
|
|
184
|
+
if (amount) p.amount = Number(amount);
|
|
185
|
+
if (percent) p.percent = Number(percent);
|
|
186
|
+
if (addieUrl) p.addieUrl = addieUrl;
|
|
187
|
+
return p;
|
|
188
|
+
}).filter(p => p.pubKey);
|
|
131
189
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
190
|
+
const URL_PAYEES = parseUrlPayees();
|
|
191
|
+
</script>
|
|
192
|
+
|
|
193
|
+
<!-- ── Shoppere (pubKey) path ────────────────────────────────────────────── -->
|
|
194
|
+
<script>
|
|
195
|
+
// Credentials injected by the server from req.query, or read from URL params
|
|
196
|
+
// (the App Clip appends them when redirecting back to the buy page).
|
|
197
|
+
const _buyerPubKey = '{{buyerPubKey}}' || new URLSearchParams(window.location.search).get('pubKey') || '';
|
|
198
|
+
const _buyerTimestamp = '{{buyerTimestamp}}' || new URLSearchParams(window.location.search).get('timestamp') || '';
|
|
199
|
+
const _buyerSignature = '{{buyerSignature}}' || new URLSearchParams(window.location.search).get('signature') || '';
|
|
200
|
+
const IS_SHOPPERE = !!(_buyerPubKey && _buyerTimestamp && _buyerSignature);
|
|
201
|
+
|
|
202
|
+
let _shoppereStripe, _shoppereElements, _shoppereClientSecret;
|
|
203
|
+
|
|
204
|
+
async function initShopperePath() {
|
|
205
|
+
document.getElementById('product-section').style.display = 'none';
|
|
206
|
+
document.getElementById('shoppere-section').style.display = 'block';
|
|
207
|
+
|
|
208
|
+
const payBtn = document.getElementById('shoppere-pay-btn');
|
|
209
|
+
const errEl = document.getElementById('shoppere-error');
|
|
210
|
+
const loadEl = document.getElementById('shoppere-loading');
|
|
211
|
+
|
|
212
|
+
// Call purchase/intent immediately — credentials are already verified
|
|
213
|
+
try {
|
|
214
|
+
const resp = await fetch('/plugin/shoppe/{{tenantUuid}}/purchase/intent', {
|
|
215
|
+
method: 'POST',
|
|
216
|
+
headers: { 'Content-Type': 'application/json' },
|
|
217
|
+
body: JSON.stringify({
|
|
218
|
+
pubKey: _buyerPubKey,
|
|
219
|
+
timestamp: _buyerTimestamp,
|
|
220
|
+
signature: _buyerSignature,
|
|
221
|
+
productId: '{{productId}}',
|
|
222
|
+
title: '{{title}}',
|
|
223
|
+
...(URL_PAYEES && { payees: URL_PAYEES })
|
|
224
|
+
})
|
|
225
|
+
});
|
|
226
|
+
const json = await resp.json();
|
|
227
|
+
|
|
228
|
+
if (json.error) { showShoppereError(json.error); return; }
|
|
229
|
+
|
|
230
|
+
if (json.purchased) {
|
|
231
|
+
// Already purchased — skip payment, show success immediately
|
|
232
|
+
showShoppereSuccess();
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (json.free) {
|
|
237
|
+
// Free item — record without Stripe
|
|
238
|
+
await completeShopperePurchase(null);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Mount Stripe payment element
|
|
243
|
+
_shoppereClientSecret = json.clientSecret;
|
|
244
|
+
_shoppereStripe = Stripe(json.publishableKey);
|
|
245
|
+
_shoppereElements = _shoppereStripe.elements({ clientSecret: json.clientSecret });
|
|
246
|
+
const payEl = _shoppereElements.create('payment');
|
|
247
|
+
payEl.mount('#shoppere-stripe-wrap');
|
|
248
|
+
payEl.on('ready', () => { payBtn.disabled = false; });
|
|
249
|
+
|
|
250
|
+
payBtn.addEventListener('click', async () => {
|
|
251
|
+
payBtn.disabled = true;
|
|
252
|
+
loadEl.style.display = 'block';
|
|
253
|
+
errEl.style.display = 'none';
|
|
254
|
+
|
|
255
|
+
const { error } = await _shoppereStripe.confirmPayment({
|
|
256
|
+
elements: _shoppereElements,
|
|
257
|
+
confirmParams: { return_url: window.location.href },
|
|
258
|
+
redirect: 'if_required'
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
if (error) {
|
|
262
|
+
showShoppereError(error.message);
|
|
263
|
+
payBtn.disabled = false;
|
|
264
|
+
loadEl.style.display = 'none';
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const paymentIntentId = _shoppereClientSecret.split('_secret_')[0];
|
|
269
|
+
await completeShopperePurchase(paymentIntentId);
|
|
270
|
+
loadEl.style.display = 'none';
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
} catch (err) {
|
|
274
|
+
showShoppereError('Unexpected error: ' + err.message);
|
|
275
|
+
}
|
|
136
276
|
}
|
|
137
|
-
}
|
|
138
|
-
</style>
|
|
139
|
-
|
|
140
|
-
<script type="text/javascript">
|
|
141
|
-
// Buy button — just reveal the forms section (payment init happens after recovery key submitted)
|
|
142
|
-
document.getElementById('buy-button').addEventListener('click', function() {
|
|
143
|
-
const formsSection = document.getElementById('forms-section');
|
|
144
|
-
formsSection.style.display = 'block';
|
|
145
|
-
formsSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
146
|
-
});
|
|
147
277
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
278
|
+
async function completeShopperePurchase(paymentIntentId) {
|
|
279
|
+
try {
|
|
280
|
+
const resp = await fetch('/plugin/shoppe/{{tenantUuid}}/purchase/complete', {
|
|
281
|
+
method: 'POST',
|
|
282
|
+
headers: { 'Content-Type': 'application/json' },
|
|
283
|
+
body: JSON.stringify({
|
|
284
|
+
pubKey: _buyerPubKey,
|
|
285
|
+
timestamp: _buyerTimestamp,
|
|
286
|
+
signature: _buyerSignature,
|
|
287
|
+
productId: '{{productId}}',
|
|
288
|
+
title: '{{title}}',
|
|
289
|
+
...(paymentIntentId && { paymentIntentId })
|
|
290
|
+
})
|
|
291
|
+
});
|
|
292
|
+
const json = await resp.json();
|
|
293
|
+
if (json.success) {
|
|
294
|
+
showShoppereSuccess();
|
|
295
|
+
} else {
|
|
296
|
+
showShoppereError(json.error || 'Purchase recording failed — contact support');
|
|
297
|
+
}
|
|
298
|
+
} catch (err) {
|
|
299
|
+
showShoppereError('Unexpected error: ' + err.message);
|
|
163
300
|
}
|
|
164
301
|
}
|
|
302
|
+
|
|
303
|
+
function showShoppereSuccess() {
|
|
304
|
+
document.getElementById('shoppere-purchase').style.display = 'none';
|
|
305
|
+
document.getElementById('shoppere-success').style.display = 'block';
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function showShoppereError(msg) {
|
|
309
|
+
const el = document.getElementById('shoppere-error');
|
|
310
|
+
el.textContent = msg;
|
|
311
|
+
el.style.display = 'block';
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Route on load
|
|
315
|
+
if (IS_SHOPPERE) {
|
|
316
|
+
document.addEventListener('DOMContentLoaded', initShopperePath);
|
|
317
|
+
}
|
|
165
318
|
</script>
|
|
166
319
|
|
|
167
|
-
|
|
320
|
+
<!-- ── Legacy recovery key path ─────────────────────────────────────────── -->
|
|
321
|
+
<script>
|
|
322
|
+
// The SVG form system (unchanged from original)
|
|
168
323
|
(function(window) {
|
|
169
324
|
'use strict';
|
|
170
325
|
|
|
171
326
|
function calculateTextBlockHeight(text, width = 340, fontSize = 14, lineHeight = 1.4) {
|
|
172
|
-
// Create a temporary element to measure text height
|
|
173
327
|
const temp = document.createElement('div');
|
|
174
|
-
temp.style.
|
|
175
|
-
temp.style.visibility = 'hidden';
|
|
176
|
-
temp.style.width = `${width}px`;
|
|
177
|
-
temp.style.fontSize = `${fontSize}px`;
|
|
178
|
-
temp.style.lineHeight = `${lineHeight}`;
|
|
179
|
-
temp.style.fontFamily = 'Arial, sans-serif';
|
|
180
|
-
temp.style.padding = '12px';
|
|
328
|
+
temp.style.cssText = `position:absolute;visibility:hidden;width:${width}px;font-size:${fontSize}px;line-height:${lineHeight};font-family:Arial,sans-serif;padding:12px`;
|
|
181
329
|
temp.innerHTML = text;
|
|
182
|
-
|
|
183
330
|
document.body.appendChild(temp);
|
|
184
331
|
const height = temp.offsetHeight;
|
|
185
332
|
document.body.removeChild(temp);
|
|
186
|
-
|
|
187
333
|
return height;
|
|
188
|
-
}
|
|
334
|
+
}
|
|
189
335
|
|
|
190
336
|
function calculateFormHeight(formConfig) {
|
|
191
|
-
const fields = Object.keys(formConfig).filter(
|
|
192
|
-
|
|
193
|
-
const standardFieldHeight = 70;
|
|
194
|
-
const submitButtonHeight = 45;
|
|
195
|
-
const bottomPadding = 30;
|
|
196
|
-
|
|
197
|
-
let totalHeight = headerHeight;
|
|
198
|
-
|
|
337
|
+
const fields = Object.keys(formConfig).filter(k => k !== 'form');
|
|
338
|
+
let total = 85;
|
|
199
339
|
fields.forEach(key => {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
const textHeight = calculateTextBlockHeight(fieldConfig.content || fieldConfig.text);
|
|
205
|
-
totalHeight += textHeight + 20; // Add some spacing
|
|
206
|
-
} else {
|
|
207
|
-
// Standard field height for inputs and selectors
|
|
208
|
-
totalHeight += standardFieldHeight;
|
|
209
|
-
}
|
|
340
|
+
const fc = formConfig[key];
|
|
341
|
+
total += fc.type === 'text-block'
|
|
342
|
+
? calculateTextBlockHeight(fc.content || fc.text) + 20
|
|
343
|
+
: 70;
|
|
210
344
|
});
|
|
211
|
-
|
|
212
|
-
return totalHeight + submitButtonHeight + bottomPadding;
|
|
345
|
+
return total + 45 + 30; // submit + padding
|
|
213
346
|
}
|
|
214
347
|
|
|
215
348
|
function getBackgroundAndGradients(formConfig) {
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
const svg = `<rect width="500" height="${dynamicHeight}" fill="transparent"/>
|
|
219
|
-
|
|
220
|
-
<!-- Form Container with Metallic Background -->
|
|
349
|
+
const h = calculateFormHeight(formConfig);
|
|
350
|
+
return `<rect width="500" height="${h}" fill="transparent"/>
|
|
221
351
|
<linearGradient id="metallicBackground" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
352
|
+
<stop offset="0%" stop-color="#2a2a2e"/>
|
|
353
|
+
<stop offset="50%" stop-color="#323236"/>
|
|
354
|
+
<stop offset="100%" stop-color="#2a2a2e"/>
|
|
225
355
|
</linearGradient>
|
|
226
|
-
<rect x="50" y="50" width="400" height="${
|
|
227
|
-
stroke="#444" stroke-width="1"/>
|
|
228
|
-
|
|
229
|
-
<!-- Subtle Metallic Highlight -->
|
|
356
|
+
<rect x="50" y="50" width="400" height="${h}" rx="15" fill="url(#metallicBackground)" stroke="#444" stroke-width="1"/>
|
|
230
357
|
<line x1="51" y1="52" x2="449" y2="52" stroke="#555" stroke-width="1" opacity="0.5"/>
|
|
231
|
-
|
|
232
|
-
<!-- Form Header -->
|
|
233
|
-
<text x="250" y="85" font-family="Arial, sans-serif" font-size="24" font-weight="bold"
|
|
234
|
-
fill="#ffffff" text-anchor="middle">RECOVERY KEY</text>
|
|
235
|
-
|
|
236
|
-
<!-- Define the gradient for active input borders -->
|
|
358
|
+
<text x="250" y="85" font-family="Arial,sans-serif" font-size="24" font-weight="bold" fill="#ffffff" text-anchor="middle">RECOVERY KEY</text>
|
|
237
359
|
<linearGradient id="inputGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
|
238
|
-
|
|
239
|
-
|
|
360
|
+
<stop offset="0%" stop-color="purple"/>
|
|
361
|
+
<stop offset="100%" stop-color="green"/>
|
|
240
362
|
</linearGradient>
|
|
241
|
-
|
|
242
|
-
<!-- Button Gradient -->
|
|
243
363
|
<linearGradient id="buttonGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
|
244
|
-
|
|
245
|
-
|
|
364
|
+
<stop id="submitButtonGradientStart" offset="0%" stop-color="green"/>
|
|
365
|
+
<stop id="submitButtonGradientEnd" offset="100%" stop-color="purple"/>
|
|
246
366
|
</linearGradient>
|
|
247
|
-
|
|
248
367
|
<linearGradient id="buttonPressedGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
|
249
|
-
|
|
250
|
-
|
|
368
|
+
<stop offset="0%" stop-color="purple"/>
|
|
369
|
+
<stop offset="100%" stop-color="green"/>
|
|
251
370
|
</linearGradient>`;
|
|
252
|
-
|
|
253
|
-
return svg;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
function getSegmentedSelector(x, y, text, options) {
|
|
257
|
-
const borderId = `${text.replace(/\s+/g, '')}Border`;
|
|
258
|
-
const selectorId = `${text.replace(/\s+/g, '')}Selector`;
|
|
259
|
-
|
|
260
|
-
const segmentWidth = 340 / options.length;
|
|
261
|
-
|
|
262
|
-
let segments = '';
|
|
263
|
-
let segmentTexts = '';
|
|
264
|
-
|
|
265
|
-
options.forEach((option, index) => {
|
|
266
|
-
const segmentX = x + (index * segmentWidth);
|
|
267
|
-
const segmentId = `${selectorId}_${index}`;
|
|
268
|
-
|
|
269
|
-
segments += `<rect id="${segmentId}" x="${segmentX}" y="${y + 10}" width="${segmentWidth}" height="40" rx="${index === 0 ? '8 0 0 8' : index === options.length - 1 ? '0 8 8 0' : '0'}" fill="#1c1c20" stroke="#444" stroke-width="1" style="cursor: pointer;" data-option="${option}" data-selected="false"/>`;
|
|
270
|
-
|
|
271
|
-
segmentTexts += `<text x="${segmentX + segmentWidth/2}" y="${y + 33}" font-family="Arial, sans-serif" font-size="14" fill="#bbbbbb" text-anchor="middle" style="pointer-events: none;">${option}</text>`;
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
const svg = `<text x="${x}" y="${y}" font-family="Arial, sans-serif" font-size="14" fill="#bbbbbb">${text}</text>
|
|
275
|
-
<!-- Segmented Container -->
|
|
276
|
-
<rect id="${borderId}" x="${x}" y="${y + 10}" width="340" height="40" rx="8" fill="transparent" stroke="#444" stroke-width="2"/>
|
|
277
|
-
${segments}
|
|
278
|
-
${segmentTexts}
|
|
279
|
-
<!-- Hidden input to store value -->
|
|
280
|
-
<foreignObject x="${x}" y="${y + 55}" width="1" height="1" style="overflow: hidden;">
|
|
281
|
-
<input xmlns="http://www.w3.org/1999/xhtml" id="${selectorId}" type="hidden" data-field="${text}"/>
|
|
282
|
-
</foreignObject>`;
|
|
283
|
-
|
|
284
|
-
return svg;
|
|
285
371
|
}
|
|
286
372
|
|
|
287
373
|
function getInput(x, y, text, inputType) {
|
|
288
|
-
const borderId = `${text.replace(/\s+/g,
|
|
289
|
-
const inputId
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
<rect x="${x + 2}" y="${y + 12}" width="336" height="36" rx="6" fill="none"
|
|
297
|
-
stroke="#000" stroke-width="1" opacity="0.3"/>
|
|
298
|
-
<!-- HTML Input Field -->
|
|
299
|
-
<foreignObject x="${x + 5}" y="${y + 15}" width="330" height="30">
|
|
300
|
-
<input xmlns="http://www.w3.org/1999/xhtml" id="${inputId}" type="text" placeholder="Enter ${text.toLowerCase()}" data-field="${text}" spellcheck="false" style="width:100%; height: 100%; background-color: transparent; color: white; border: none; outline: none; padding: 8px 12px; font-size: 14px; font-family: Arial, sans-serif; border-radius: 6px;"/>
|
|
301
|
-
</foreignObject>`;
|
|
302
|
-
|
|
303
|
-
return svg;
|
|
374
|
+
const borderId = `${text.replace(/\s+/g,'')}Border`;
|
|
375
|
+
const inputId = `${text.replace(/\s+/g,'')}Input`;
|
|
376
|
+
return `<text x="${x}" y="${y}" font-family="Arial,sans-serif" font-size="14" fill="#bbbbbb">${text}</text>
|
|
377
|
+
<rect id="${borderId}" x="${x}" y="${y+10}" width="340" height="40" rx="8" fill="#1c1c20" stroke="#444" stroke-width="2"/>
|
|
378
|
+
<rect x="${x+2}" y="${y+12}" width="336" height="36" rx="6" fill="none" stroke="#000" stroke-width="1" opacity="0.3"/>
|
|
379
|
+
<foreignObject x="${x+5}" y="${y+15}" width="330" height="30">
|
|
380
|
+
<input xmlns="http://www.w3.org/1999/xhtml" id="${inputId}" type="text" placeholder="Enter ${text.toLowerCase()}" data-field="${text}" spellcheck="false" style="width:100%;height:100%;background:transparent;color:white;border:none;outline:none;padding:8px 12px;font-size:14px;font-family:Arial,sans-serif;border-radius:6px;"/>
|
|
381
|
+
</foreignObject>`;
|
|
304
382
|
}
|
|
305
383
|
|
|
306
384
|
function getTextBlock(x, y, text, content) {
|
|
307
385
|
const textHeight = calculateTextBlockHeight(content);
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
font-family: Arial, sans-serif;
|
|
317
|
-
font-size: 14px;
|
|
318
|
-
line-height: 1.4;
|
|
319
|
-
color: #cccccc;
|
|
320
|
-
padding: 0;
|
|
321
|
-
margin: 0;
|
|
322
|
-
width: 100%;
|
|
323
|
-
height: 100%;
|
|
324
|
-
overflow: hidden;
|
|
325
|
-
">${content}</div>
|
|
326
|
-
</foreignObject>`;
|
|
327
|
-
|
|
328
|
-
return { svg, height: textHeight + 20 };
|
|
329
|
-
};
|
|
386
|
+
return {
|
|
387
|
+
svg: `<rect x="${x}" y="${y+10}" width="340" height="${textHeight}" rx="8" fill="#1a1a1e" stroke="#333" stroke-width="1"/>
|
|
388
|
+
<foreignObject x="${x+12}" y="${y+22}" width="316" height="${textHeight-24}">
|
|
389
|
+
<div xmlns="http://www.w3.org/1999/xhtml" style="font-family:Arial,sans-serif;font-size:14px;line-height:1.4;color:#cccccc;">${content}</div>
|
|
390
|
+
</foreignObject>`,
|
|
391
|
+
height: textHeight + 20
|
|
392
|
+
};
|
|
393
|
+
}
|
|
330
394
|
|
|
331
395
|
function getSubmitButton(x, y) {
|
|
332
|
-
return `<rect id="submitButton" x="${x}" y="${y}" width="300" height="45" rx="22.5" fill="#666666" style="cursor:
|
|
333
|
-
|
|
334
|
-
<text x="${x + 150}" y="${y + 28}" font-family="Arial, sans-serif" font-size="16" font-weight="bold" fill="#999999" text-anchor="middle" dominant-baseline="middle" style="pointer-events: none;">SUBMIT</text>
|
|
335
|
-
`;
|
|
396
|
+
return `<rect id="submitButton" x="${x}" y="${y}" width="300" height="45" rx="22.5" fill="#666666" style="cursor:not-allowed;"></rect>
|
|
397
|
+
<text x="${x+150}" y="${y+28}" font-family="Arial,sans-serif" font-size="16" font-weight="bold" fill="#999999" text-anchor="middle" dominant-baseline="middle" style="pointer-events:none;">SUBMIT</text>`;
|
|
336
398
|
}
|
|
337
399
|
|
|
338
400
|
function enableSubmitButton() {
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
submitButton.setAttribute('style', 'cursor: pointer;');
|
|
344
|
-
submitButtonText.setAttribute('fill', 'white');
|
|
401
|
+
const b = document.getElementById('submitButton');
|
|
402
|
+
b.setAttribute('fill', 'url(#buttonGradient)');
|
|
403
|
+
b.setAttribute('style', 'cursor:pointer;');
|
|
404
|
+
b.nextElementSibling.setAttribute('fill', 'white');
|
|
345
405
|
}
|
|
346
406
|
|
|
347
407
|
function disableSubmitButton() {
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
submitButton.setAttribute('style', 'cursor: not-allowed;');
|
|
353
|
-
submitButtonText.setAttribute('fill', '#999999');
|
|
408
|
+
const b = document.getElementById('submitButton');
|
|
409
|
+
b.setAttribute('fill', '#666666');
|
|
410
|
+
b.setAttribute('style', 'cursor:not-allowed;');
|
|
411
|
+
b.nextElementSibling.setAttribute('fill', '#999999');
|
|
354
412
|
}
|
|
355
413
|
|
|
356
414
|
function validateForm(formJSON) {
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
if (fieldConfig.type === 'segmented') {
|
|
365
|
-
const selectorId = `${key.replace(/\s+/g, '')}Selector`;
|
|
366
|
-
const selector = document.getElementById(selectorId);
|
|
367
|
-
return selector && selector.value && selector.value.trim() !== '';
|
|
368
|
-
} else {
|
|
369
|
-
const inputId = `${key.replace(/\s+/g, '')}Input`;
|
|
370
|
-
const input = document.getElementById(inputId);
|
|
371
|
-
return input && input.value && input.value.trim() !== '';
|
|
372
|
-
}
|
|
373
|
-
});
|
|
374
|
-
|
|
375
|
-
if (allValid) {
|
|
376
|
-
enableSubmitButton();
|
|
377
|
-
} else {
|
|
378
|
-
disableSubmitButton();
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
// Also trigger overall validation
|
|
415
|
+
const allValid = Object.keys(formJSON)
|
|
416
|
+
.filter(k => k !== 'form' && formJSON[k].required)
|
|
417
|
+
.every(k => {
|
|
418
|
+
const el = document.getElementById(`${k.replace(/\s+/g,'')}Input`);
|
|
419
|
+
return el && el.value.trim() !== '';
|
|
420
|
+
});
|
|
421
|
+
allValid ? enableSubmitButton() : disableSubmitButton();
|
|
382
422
|
setTimeout(() => validateAllForms(), 100);
|
|
383
|
-
|
|
384
423
|
return allValid;
|
|
385
424
|
}
|
|
386
425
|
|
|
@@ -388,259 +427,138 @@
|
|
|
388
427
|
|
|
389
428
|
function getForm(formJSON, onSubmit) {
|
|
390
429
|
const keys = Object.keys(formJSON);
|
|
391
|
-
let currentY = 130;
|
|
430
|
+
let currentY = 130;
|
|
392
431
|
const inputs = [];
|
|
393
|
-
|
|
394
|
-
keys.forEach(
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
} else {
|
|
406
|
-
inputs.push(getInput(80, currentY, key, fieldConfig.type));
|
|
407
|
-
currentY += 70; // Standard field height
|
|
408
|
-
}
|
|
432
|
+
|
|
433
|
+
keys.forEach(key => {
|
|
434
|
+
if (key === 'form') return;
|
|
435
|
+
const fc = formJSON[key];
|
|
436
|
+
if (fc.type === 'text-block') {
|
|
437
|
+
const tb = getTextBlock(80, currentY, key, fc.content || fc.text);
|
|
438
|
+
inputs.push(tb.svg);
|
|
439
|
+
currentY += tb.height;
|
|
440
|
+
} else {
|
|
441
|
+
inputs.push(getInput(80, currentY, key, fc.type));
|
|
442
|
+
currentY += 70;
|
|
443
|
+
}
|
|
409
444
|
});
|
|
410
|
-
|
|
411
|
-
// Add submit button at the final currentY position
|
|
412
445
|
inputs.push(getSubmitButton(100, currentY + 20));
|
|
413
|
-
|
|
414
|
-
const svg = getBackgroundAndGradients(formJSON) + inputs.join('');
|
|
415
|
-
const dynamicHeight = calculateFormHeight(formJSON);
|
|
416
446
|
|
|
417
|
-
const
|
|
418
|
-
container.
|
|
447
|
+
const h = calculateFormHeight(formJSON);
|
|
448
|
+
const container = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
449
|
+
container.setAttribute('viewBox', `0 0 500 ${h + 100}`);
|
|
419
450
|
container.setAttribute('width', '100%');
|
|
420
451
|
container.setAttribute('height', 'auto');
|
|
421
|
-
container.innerHTML =
|
|
452
|
+
container.innerHTML = getBackgroundAndGradients(formJSON) + inputs.join('');
|
|
422
453
|
|
|
423
|
-
// Rest of your event handler code remains the same...
|
|
424
454
|
setTimeout(() => {
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
});
|
|
453
|
-
|
|
454
|
-
// Select clicked segment
|
|
455
|
-
segment.setAttribute('fill', '#3eda82');
|
|
456
|
-
segment.setAttribute('data-selected', 'true');
|
|
457
|
-
|
|
458
|
-
// Update hidden input value
|
|
459
|
-
if (selector) {
|
|
460
|
-
selector.value = option;
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
// Update border
|
|
464
|
-
const borderElement = document.getElementById(borderId);
|
|
465
|
-
if (borderElement) {
|
|
466
|
-
borderElement.setAttribute('stroke', 'url(#inputGradient)');
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
// Validate form
|
|
470
|
-
validateForm(formJSON);
|
|
471
|
-
});
|
|
472
|
-
}
|
|
473
|
-
});
|
|
474
|
-
} else {
|
|
475
|
-
const inputId = `${key.replace(/\s+/g, '')}Input`;
|
|
476
|
-
const inputElement = document.getElementById(inputId);
|
|
477
|
-
|
|
478
|
-
if (inputElement) {
|
|
479
|
-
inputElement.addEventListener('input', (evt) => {
|
|
480
|
-
const borderElement = document.getElementById(borderId);
|
|
481
|
-
if (borderElement) {
|
|
482
|
-
borderElement.setAttribute('stroke', 'url(#inputGradient)');
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
// Validate form
|
|
486
|
-
validateForm(formJSON);
|
|
487
|
-
});
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
});
|
|
491
|
-
|
|
492
|
-
const submitButton = document.getElementById('submitButton');
|
|
493
|
-
if (submitButton) {
|
|
494
|
-
submitButton.addEventListener('click', () => {
|
|
495
|
-
if (!validateForm(formJSON)) {
|
|
496
|
-
console.log('Form validation failed');
|
|
497
|
-
return;
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
console.log('Submit button clicked');
|
|
501
|
-
|
|
502
|
-
// Create button animation
|
|
503
|
-
const animation = document.createElementNS("http://www.w3.org/2000/svg", "animate");
|
|
504
|
-
animation.setAttribute("attributeName", "offset");
|
|
505
|
-
animation.setAttribute("values", "-0.5;2.5");
|
|
506
|
-
animation.setAttribute("dur", "300ms");
|
|
507
|
-
animation.setAttribute("repeatCount", "1");
|
|
508
|
-
|
|
509
|
-
const secondAnimation = document.createElementNS("http://www.w3.org/2000/svg", "animate");
|
|
510
|
-
secondAnimation.setAttribute("attributeName", "offset");
|
|
511
|
-
secondAnimation.setAttribute("values", "0.5;3.5");
|
|
512
|
-
secondAnimation.setAttribute("dur", "300ms");
|
|
513
|
-
secondAnimation.setAttribute("repeatCount", "1");
|
|
514
|
-
|
|
515
|
-
const startGradient = document.getElementById('submitButtonGradientStart');
|
|
516
|
-
const endGradient = document.getElementById('submitButtonGradientEnd');
|
|
517
|
-
|
|
518
|
-
if (startGradient && endGradient) {
|
|
519
|
-
startGradient.appendChild(animation);
|
|
520
|
-
endGradient.appendChild(secondAnimation);
|
|
521
|
-
animation.beginElement();
|
|
522
|
-
secondAnimation.beginElement();
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
// Collect form values
|
|
526
|
-
const formValues = {};
|
|
527
|
-
Object.keys(formJSON).forEach((key) => {
|
|
528
|
-
if(key === 'form') {
|
|
529
|
-
return;
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
const fieldConfig = formJSON[key];
|
|
533
|
-
|
|
534
|
-
if (fieldConfig.type === 'segmented') {
|
|
535
|
-
const selectorId = `${key.replace(/\s+/g, '')}Selector`;
|
|
536
|
-
const selector = document.getElementById(selectorId);
|
|
537
|
-
if (selector) {
|
|
538
|
-
formValues[key] = selector.value;
|
|
539
|
-
}
|
|
540
|
-
} else {
|
|
541
|
-
const inputId = `${key.replace(/\s+/g, '')}Input`;
|
|
542
|
-
const inputElement = document.getElementById(inputId);
|
|
543
|
-
if (inputElement) {
|
|
544
|
-
formValues[key] = inputElement.value;
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
});
|
|
548
|
-
|
|
549
|
-
if (onSubmit) {
|
|
550
|
-
onSubmit(formValues);
|
|
551
|
-
}
|
|
552
|
-
});
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
// Initial validation
|
|
556
|
-
validateForm(formJSON);
|
|
455
|
+
keys.forEach(key => {
|
|
456
|
+
if (key === 'form') return;
|
|
457
|
+
const borderId = `${key.replace(/\s+/g,'')}Border`;
|
|
458
|
+
const inputId = `${key.replace(/\s+/g,'')}Input`;
|
|
459
|
+
const inputEl = document.getElementById(inputId);
|
|
460
|
+
if (inputEl) {
|
|
461
|
+
inputEl.addEventListener('input', () => {
|
|
462
|
+
document.getElementById(borderId)?.setAttribute('stroke', 'url(#inputGradient)');
|
|
463
|
+
validateForm(formJSON);
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
const submitBtn = document.getElementById('submitButton');
|
|
469
|
+
if (submitBtn) {
|
|
470
|
+
submitBtn.addEventListener('click', () => {
|
|
471
|
+
if (!validateForm(formJSON)) return;
|
|
472
|
+
const vals = {};
|
|
473
|
+
keys.forEach(key => {
|
|
474
|
+
if (key === 'form') return;
|
|
475
|
+
const el = document.getElementById(`${key.replace(/\s+/g,'')}Input`);
|
|
476
|
+
if (el) vals[key] = el.value;
|
|
477
|
+
});
|
|
478
|
+
if (onSubmit) onSubmit(vals);
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
validateForm(formJSON);
|
|
557
482
|
}, 100);
|
|
558
483
|
|
|
559
484
|
return container;
|
|
560
485
|
}
|
|
561
486
|
|
|
562
487
|
window.getForm = getForm;
|
|
563
|
-
|
|
564
488
|
})(window);
|
|
565
489
|
</script>
|
|
566
490
|
|
|
567
491
|
<script>
|
|
568
|
-
//
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
const
|
|
572
|
-
if (!
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
}
|
|
492
|
+
// Legacy path: buy button reveals the recovery key form
|
|
493
|
+
function validateAllForms() {
|
|
494
|
+
const paymentValid = typeof stripe !== 'undefined' && typeof elements !== 'undefined';
|
|
495
|
+
const btn = document.getElementById('submit-button');
|
|
496
|
+
if (!btn) return;
|
|
497
|
+
if (paymentValid) {
|
|
498
|
+
btn.classList.add('ready');
|
|
499
|
+
btn.disabled = false;
|
|
500
|
+
btn.textContent = 'Complete Purchase';
|
|
501
|
+
} else {
|
|
502
|
+
btn.classList.remove('ready');
|
|
503
|
+
btn.disabled = true;
|
|
504
|
+
btn.textContent = 'Complete Purchase';
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (!IS_SHOPPERE) {
|
|
509
|
+
document.getElementById('buy-button').addEventListener('click', function() {
|
|
510
|
+
const sec = document.getElementById('forms-section');
|
|
511
|
+
sec.style.display = 'block';
|
|
512
|
+
sec.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
513
|
+
});
|
|
582
514
|
}
|
|
583
|
-
const URL_PAYEES = parseUrlPayees();
|
|
584
515
|
|
|
585
516
|
const formConfig = {
|
|
586
|
-
|
|
587
|
-
|
|
517
|
+
'Recovery': { type: 'text', required: true },
|
|
518
|
+
'Explanation': { type: 'text-block', content: 'Put whatever you want here, but make sure you remember it. This is how you\'ll be able to download this again.' }
|
|
588
519
|
};
|
|
589
520
|
|
|
590
521
|
async function handleSubmit(formData) {
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
const submitBtn = document.querySelector('[type=submit]');
|
|
595
|
-
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'Checking…'; }
|
|
596
|
-
|
|
597
|
-
try {
|
|
598
|
-
const resp = await fetch('/plugin/shoppe/{{tenantUuid}}/purchase/intent', {
|
|
599
|
-
method: 'POST',
|
|
600
|
-
headers: { 'Content-Type': 'application/json' },
|
|
601
|
-
body: JSON.stringify({ recoveryKey, productId: '{{productId}}', title: '{{title}}',
|
|
602
|
-
...(URL_PAYEES && { payees: URL_PAYEES }) })
|
|
603
|
-
});
|
|
604
|
-
const json = await resp.json();
|
|
522
|
+
window.formData = formData;
|
|
523
|
+
const recoveryKey = formData.Recovery;
|
|
605
524
|
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
525
|
+
try {
|
|
526
|
+
const resp = await fetch('/plugin/shoppe/{{tenantUuid}}/purchase/intent', {
|
|
527
|
+
method: 'POST',
|
|
528
|
+
headers: { 'Content-Type': 'application/json' },
|
|
529
|
+
body: JSON.stringify({
|
|
530
|
+
recoveryKey,
|
|
531
|
+
productId: '{{productId}}',
|
|
532
|
+
title: '{{title}}',
|
|
533
|
+
...(URL_PAYEES && { payees: URL_PAYEES })
|
|
534
|
+
})
|
|
535
|
+
});
|
|
536
|
+
const json = await resp.json();
|
|
615
537
|
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
538
|
+
if (json.purchased) { window.location.href = '{{ebookUrl}}'; return; }
|
|
539
|
+
if (json.error) { alert('Error: ' + json.error); return; }
|
|
540
|
+
|
|
541
|
+
window.clientSecret = json.clientSecret;
|
|
542
|
+
document.getElementById('payment-section').style.display = 'block';
|
|
543
|
+
stripe = Stripe(json.publishableKey);
|
|
544
|
+
elements = stripe.elements({ clientSecret: json.clientSecret });
|
|
545
|
+
const payEl = elements.create('payment');
|
|
546
|
+
payEl.mount('#payment-element');
|
|
547
|
+
payEl.on('ready', () => setTimeout(validateAllForms, 500));
|
|
548
|
+
payEl.on('change', () => setTimeout(validateAllForms, 100));
|
|
549
|
+
} catch (err) {
|
|
550
|
+
alert('Unexpected error: ' + err.message);
|
|
551
|
+
}
|
|
630
552
|
}
|
|
631
553
|
|
|
632
|
-
|
|
633
|
-
|
|
554
|
+
if (!IS_SHOPPERE) {
|
|
555
|
+
const form = getForm(formConfig, handleSubmit);
|
|
556
|
+
document.getElementById('address-form').appendChild(form);
|
|
557
|
+
}
|
|
634
558
|
</script>
|
|
635
559
|
|
|
636
|
-
<script
|
|
637
|
-
let stripe;
|
|
638
|
-
let elements;
|
|
639
|
-
|
|
640
|
-
const paymentForm = document.getElementById('payment-form');
|
|
641
|
-
const submitButton = document.getElementById('submit-button');
|
|
642
|
-
const errorMessage = document.getElementById('error-message');
|
|
643
|
-
const loadingMessage = document.getElementById('loading');
|
|
560
|
+
<script>
|
|
561
|
+
let stripe, elements;
|
|
644
562
|
|
|
645
563
|
window.confirmPayment = async () => {
|
|
646
564
|
try {
|
|
@@ -649,49 +567,45 @@
|
|
|
649
567
|
confirmParams: { return_url: '{{shoppeUrl}}' },
|
|
650
568
|
redirect: 'if_required'
|
|
651
569
|
});
|
|
570
|
+
if (error) { showError(error.message); return; }
|
|
652
571
|
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
const order = { title: '{{title}}', productId: '{{productId}}', formData: window.formData };
|
|
656
|
-
const paymentIntentId = window.clientSecret ? window.clientSecret.split('_secret_')[0] : undefined;
|
|
572
|
+
const paymentIntentId = window.clientSecret?.split('_secret_')[0];
|
|
657
573
|
const resp = await fetch('/plugin/shoppe/{{tenantUuid}}/purchase/complete', {
|
|
658
574
|
method: 'POST',
|
|
659
575
|
headers: { 'Content-Type': 'application/json' },
|
|
660
|
-
body: JSON.stringify({
|
|
576
|
+
body: JSON.stringify({
|
|
577
|
+
recoveryKey: window.formData?.Recovery,
|
|
578
|
+
productId: '{{productId}}',
|
|
579
|
+
paymentIntentId
|
|
580
|
+
})
|
|
661
581
|
});
|
|
662
582
|
const json = await resp.json();
|
|
663
|
-
|
|
664
583
|
if (json.success) {
|
|
665
584
|
window.location.href = '{{ebookUrl}}';
|
|
666
585
|
} else {
|
|
667
|
-
|
|
586
|
+
alert('Payment successful, but error recording purchase. Contact greetings@planetnine.app');
|
|
668
587
|
}
|
|
669
588
|
} catch (err) {
|
|
670
589
|
showError('An unexpected error occurred.');
|
|
671
|
-
console.warn('payment error:', err);
|
|
672
590
|
}
|
|
673
591
|
};
|
|
674
592
|
|
|
675
|
-
|
|
676
|
-
|
|
593
|
+
document.getElementById('payment-form').addEventListener('submit', async (e) => {
|
|
594
|
+
e.preventDefault();
|
|
677
595
|
if (!stripe || !elements) return;
|
|
678
|
-
|
|
596
|
+
document.getElementById('loading').style.display = 'block';
|
|
597
|
+
document.getElementById('submit-button').disabled = true;
|
|
679
598
|
await window.confirmPayment();
|
|
680
|
-
|
|
599
|
+
document.getElementById('loading').style.display = 'none';
|
|
600
|
+
document.getElementById('submit-button').disabled = false;
|
|
681
601
|
});
|
|
682
602
|
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
const setLoading = (isLoading) => {
|
|
690
|
-
submitButton.disabled = isLoading;
|
|
691
|
-
loadingMessage.style.display = isLoading ? 'block' : 'none';
|
|
692
|
-
};
|
|
603
|
+
function showError(msg) {
|
|
604
|
+
const el = document.getElementById('error-message');
|
|
605
|
+
el.textContent = msg;
|
|
606
|
+
el.style.display = 'block';
|
|
607
|
+
setTimeout(() => { el.style.display = 'none'; }, 5000);
|
|
608
|
+
}
|
|
693
609
|
</script>
|
|
694
610
|
</body>
|
|
695
611
|
</html>
|
|
696
|
-
|
|
697
|
-
|