wiki-plugin-shoppe 0.0.20 → 0.0.21
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 +56 -0
- package/client/shoppe.js +7 -5
- package/package.json +6 -2
- package/server/server.js +569 -45
- package/server/templates/appointment-booking.html +458 -0
- package/server/templates/generic-address-stripe.html +55 -58
- package/server/templates/subscription-membership.html +290 -0
- package/server/templates/subscription-subscribe.html +258 -0
- package/test/test.js +376 -0
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<script src="https://js.stripe.com/v3/"></script>
|
|
5
|
+
<meta charset="utf-8">
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
|
|
7
|
+
<meta name="twitter:title" content="{{title}}">
|
|
8
|
+
<meta name="description" content="{{description}}">
|
|
9
|
+
<meta name="og:title" content="{{title}}">
|
|
10
|
+
<meta name="og:description" content="{{description}}">
|
|
11
|
+
<title>Book: {{title}}</title>
|
|
12
|
+
<style>
|
|
13
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
14
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f0f12; color: white; min-height: 100vh; }
|
|
15
|
+
|
|
16
|
+
.layout { display: flex; min-height: 100vh; }
|
|
17
|
+
.product-panel { flex: 0 0 380px; background: #18181c; border-right: 1px solid #333; padding: 40px 32px; display: flex; flex-direction: column; gap: 20px; }
|
|
18
|
+
.booking-panel { flex: 1; padding: 40px 40px; overflow-y: auto; }
|
|
19
|
+
|
|
20
|
+
.product-img { width: 100%; border-radius: 12px; object-fit: cover; max-height: 260px; display: block; }
|
|
21
|
+
.product-title { font-size: 22px; font-weight: 700; }
|
|
22
|
+
.product-desc { font-size: 14px; color: #aaa; line-height: 1.5; }
|
|
23
|
+
.product-meta { display: flex; gap: 16px; flex-wrap: wrap; }
|
|
24
|
+
.meta-chip { background: #2a2a2e; border-radius: 20px; padding: 6px 14px; font-size: 13px; color: #ccc; }
|
|
25
|
+
.meta-chip span { color: #7ec8e3; font-weight: 600; }
|
|
26
|
+
|
|
27
|
+
h2 { font-size: 18px; font-weight: 600; margin-bottom: 16px; color: #ddd; }
|
|
28
|
+
|
|
29
|
+
/* Date strip */
|
|
30
|
+
.date-strip { display: flex; gap: 10px; overflow-x: auto; padding-bottom: 8px; margin-bottom: 24px; scrollbar-width: thin; }
|
|
31
|
+
.date-strip::-webkit-scrollbar { height: 4px; }
|
|
32
|
+
.date-strip::-webkit-scrollbar-thumb { background: #444; border-radius: 2px; }
|
|
33
|
+
.date-card { flex: 0 0 72px; background: #2a2a2e; border: 2px solid #444; border-radius: 12px; padding: 10px 6px; text-align: center; cursor: pointer; transition: border-color 0.15s, background 0.15s; }
|
|
34
|
+
.date-card:hover { border-color: #7ec8e3; }
|
|
35
|
+
.date-card.active { border-color: #7ec8e3; background: #1a3040; }
|
|
36
|
+
.date-card .dow { font-size: 11px; color: #888; text-transform: uppercase; letter-spacing: 0.5px; }
|
|
37
|
+
.date-card .dom { font-size: 22px; font-weight: 700; margin: 2px 0; }
|
|
38
|
+
.date-card .mon { font-size: 11px; color: #aaa; }
|
|
39
|
+
|
|
40
|
+
/* Slot grid */
|
|
41
|
+
.slot-section { display: none; margin-bottom: 28px; }
|
|
42
|
+
.slot-section.visible { display: block; }
|
|
43
|
+
.slot-grid { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
44
|
+
.slot-btn { background: #2a2a2e; border: 2px solid #444; border-radius: 8px; padding: 8px 16px; font-size: 14px; color: #ddd; cursor: pointer; transition: border-color 0.15s, background 0.15s; }
|
|
45
|
+
.slot-btn:hover { border-color: #7ec8e3; }
|
|
46
|
+
.slot-btn.active { border-color: #7ec8e3; background: #1a3040; color: white; }
|
|
47
|
+
|
|
48
|
+
/* Contact + recovery form */
|
|
49
|
+
.booking-form { display: none; background: #1e1e22; border: 1px solid #333; border-radius: 16px; padding: 28px; margin-bottom: 24px; }
|
|
50
|
+
.booking-form.visible { display: block; }
|
|
51
|
+
.selected-slot-display { background: #1a3040; border: 1px solid #7ec8e3; border-radius: 8px; padding: 10px 16px; font-size: 14px; color: #7ec8e3; margin-bottom: 20px; }
|
|
52
|
+
.field-group { margin-bottom: 16px; }
|
|
53
|
+
.field-group label { display: block; font-size: 12px; color: #888; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; }
|
|
54
|
+
.field-group input { width: 100%; background: #2a2a2e; border: 1px solid #444; border-radius: 8px; padding: 10px 14px; color: white; font-size: 14px; outline: none; transition: border-color 0.15s; }
|
|
55
|
+
.field-group input:focus { border-color: #7ec8e3; }
|
|
56
|
+
.field-group .hint { font-size: 11px; color: #666; margin-top: 4px; }
|
|
57
|
+
.recovery-note { background: #2a2a1a; border: 1px solid #665; border-radius: 8px; padding: 12px 16px; font-size: 13px; color: #cc9; margin-bottom: 20px; line-height: 1.4; }
|
|
58
|
+
|
|
59
|
+
/* Payment section */
|
|
60
|
+
.payment-section { display: none; }
|
|
61
|
+
.payment-section.visible { display: block; }
|
|
62
|
+
#payment-element { margin-bottom: 16px; }
|
|
63
|
+
|
|
64
|
+
.btn-primary { width: 100%; padding: 14px; background: linear-gradient(90deg, #0066cc, #7ec8e3); color: white; border: none; border-radius: 10px; font-size: 15px; font-weight: 600; cursor: pointer; transition: opacity 0.15s; }
|
|
65
|
+
.btn-primary:hover { opacity: 0.9; }
|
|
66
|
+
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
67
|
+
.btn-outline { width: 100%; padding: 12px; background: transparent; color: #7ec8e3; border: 1px solid #7ec8e3; border-radius: 10px; font-size: 14px; cursor: pointer; margin-bottom: 12px; }
|
|
68
|
+
.btn-outline:hover { background: #1a3040; }
|
|
69
|
+
|
|
70
|
+
.error-msg { color: #ff6b6b; font-size: 13px; margin-top: 8px; display: none; }
|
|
71
|
+
.loading-msg { color: #aaa; font-size: 13px; margin-top: 8px; display: none; }
|
|
72
|
+
.no-slots { color: #888; font-size: 14px; padding: 20px 0; }
|
|
73
|
+
|
|
74
|
+
/* Confirmation */
|
|
75
|
+
.confirmation { display: none; text-align: center; padding: 40px 20px; }
|
|
76
|
+
.confirmation.visible { display: block; }
|
|
77
|
+
.confirmation .icon { font-size: 64px; margin-bottom: 16px; }
|
|
78
|
+
.confirmation h2 { font-size: 24px; margin-bottom: 8px; }
|
|
79
|
+
.confirmation p { color: #aaa; margin-bottom: 6px; }
|
|
80
|
+
.confirmation .slot-confirmed { font-size: 18px; color: #7ec8e3; font-weight: 600; margin: 16px 0; }
|
|
81
|
+
|
|
82
|
+
@media (max-width: 700px) {
|
|
83
|
+
.layout { flex-direction: column; }
|
|
84
|
+
.product-panel { flex: none; border-right: none; border-bottom: 1px solid #333; padding: 24px 20px; }
|
|
85
|
+
.booking-panel { padding: 24px 20px; }
|
|
86
|
+
}
|
|
87
|
+
</style>
|
|
88
|
+
</head>
|
|
89
|
+
<body>
|
|
90
|
+
<div class="layout">
|
|
91
|
+
<!-- Left: product info -->
|
|
92
|
+
<div class="product-panel">
|
|
93
|
+
<script>
|
|
94
|
+
(function() {
|
|
95
|
+
const src = {{image}};
|
|
96
|
+
if (src) {
|
|
97
|
+
document.write('<img class="product-img" src="' + src + '" alt="">');
|
|
98
|
+
}
|
|
99
|
+
})();
|
|
100
|
+
</script>
|
|
101
|
+
<div class="product-title">{{title}}</div>
|
|
102
|
+
<div class="product-desc">{{description}}</div>
|
|
103
|
+
<div class="product-meta">
|
|
104
|
+
<div class="meta-chip">💰 <span>${{formattedAmount}}</span> / session</div>
|
|
105
|
+
<div class="meta-chip">⏱ <span>{{duration}} min</span></div>
|
|
106
|
+
<div class="meta-chip">🕐 <span id="tz-display">{{timezone}}</span></div>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
<!-- Right: booking flow -->
|
|
111
|
+
<div class="booking-panel">
|
|
112
|
+
|
|
113
|
+
<!-- Date selection -->
|
|
114
|
+
<div id="step-dates">
|
|
115
|
+
<h2>Choose a date</h2>
|
|
116
|
+
<div id="date-strip" class="date-strip"></div>
|
|
117
|
+
<div id="loading-slots" class="loading-msg" style="display:block;">Loading availability…</div>
|
|
118
|
+
<div id="no-availability" class="no-slots" style="display:none;">No upcoming availability found.</div>
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
<!-- Time slot selection -->
|
|
122
|
+
<div id="step-slots" class="slot-section">
|
|
123
|
+
<h2 id="slot-heading">Available times</h2>
|
|
124
|
+
<div id="slot-grid" class="slot-grid"></div>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
<!-- Contact info + recovery key form -->
|
|
128
|
+
<div id="booking-form" class="booking-form">
|
|
129
|
+
<div id="selected-slot-display" class="selected-slot-display"></div>
|
|
130
|
+
|
|
131
|
+
<div class="recovery-note">
|
|
132
|
+
🔑 Choose a recovery key — a word or phrase only you know. You'll use it to look up your booking confirmation later.
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
<div class="field-group">
|
|
136
|
+
<label>Recovery Key *</label>
|
|
137
|
+
<input type="text" id="recovery-key" placeholder="e.g. sunflower-2026" autocomplete="off">
|
|
138
|
+
</div>
|
|
139
|
+
<div class="field-group">
|
|
140
|
+
<label>Your Name *</label>
|
|
141
|
+
<input type="text" id="contact-name" placeholder="Full name">
|
|
142
|
+
</div>
|
|
143
|
+
<div class="field-group">
|
|
144
|
+
<label>Email *</label>
|
|
145
|
+
<input type="email" id="contact-email" placeholder="For booking confirmation">
|
|
146
|
+
<div class="hint">Shared with the service provider only.</div>
|
|
147
|
+
</div>
|
|
148
|
+
<div class="field-group">
|
|
149
|
+
<label>Phone (optional)</label>
|
|
150
|
+
<input type="tel" id="contact-phone" placeholder="For appointment reminders">
|
|
151
|
+
<div class="hint">Optional. Shared with the service provider only.</div>
|
|
152
|
+
</div>
|
|
153
|
+
<button class="btn-outline" onclick="backToSlots()">← Change time</button>
|
|
154
|
+
<button class="btn-primary" id="proceed-to-pay" onclick="proceedToPay()">{{proceedLabel}}</button>
|
|
155
|
+
<div id="form-error" class="error-msg"></div>
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
<!-- Stripe payment -->
|
|
159
|
+
<div id="payment-section" class="payment-section">
|
|
160
|
+
<h2>Complete payment</h2>
|
|
161
|
+
<div id="selected-slot-payment" class="selected-slot-display" style="margin-bottom:16px;"></div>
|
|
162
|
+
<div id="payment-element"></div>
|
|
163
|
+
<button class="btn-primary" id="pay-btn" onclick="confirmPayment()">Pay ${{formattedAmount}}</button>
|
|
164
|
+
<div id="pay-error" class="error-msg"></div>
|
|
165
|
+
<div id="pay-loading" class="loading-msg"></div>
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
<!-- Confirmation -->
|
|
169
|
+
<div id="confirmation" class="confirmation">
|
|
170
|
+
<div class="icon">✅</div>
|
|
171
|
+
<h2>You're booked!</h2>
|
|
172
|
+
<div id="confirmation-slot" class="slot-confirmed"></div>
|
|
173
|
+
<p>{{title}}</p>
|
|
174
|
+
<p style="margin-top:16px;">A confirmation has been sent to the service provider.</p>
|
|
175
|
+
<p style="margin-top:8px; font-size:13px; color:#666;">Keep your recovery key safe — you can use it to look up this booking.</p>
|
|
176
|
+
<button class="btn-outline" style="margin-top:24px; max-width:200px;" onclick="window.location.href='{{shoppeUrl}}'">← Back to Shoppe</button>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
<script>
|
|
183
|
+
(function() {
|
|
184
|
+
'use strict';
|
|
185
|
+
|
|
186
|
+
const SHOPPE_URL = '{{shoppeUrl}}';
|
|
187
|
+
const PRODUCT_ID = '{{productId}}';
|
|
188
|
+
const TITLE = '{{title}}';
|
|
189
|
+
const AMOUNT = {{amount}};
|
|
190
|
+
const TIMEZONE = '{{timezone}}';
|
|
191
|
+
|
|
192
|
+
let availableDates = []; // [{ date, dayLabel, slots }]
|
|
193
|
+
let selectedDate = null;
|
|
194
|
+
let selectedSlot = null;
|
|
195
|
+
let stripe = null;
|
|
196
|
+
let elements = null;
|
|
197
|
+
|
|
198
|
+
// ── Slot loading ──────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
async function loadSlots() {
|
|
201
|
+
try {
|
|
202
|
+
const resp = await fetch(`${SHOPPE_URL}/book/${encodeURIComponent(TITLE)}/slots`);
|
|
203
|
+
const data = await resp.json();
|
|
204
|
+
document.getElementById('loading-slots').style.display = 'none';
|
|
205
|
+
|
|
206
|
+
if (!data.available || data.available.length === 0) {
|
|
207
|
+
document.getElementById('no-availability').style.display = 'block';
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
availableDates = data.available;
|
|
212
|
+
renderDateStrip();
|
|
213
|
+
selectDate(availableDates[0]);
|
|
214
|
+
} catch (err) {
|
|
215
|
+
document.getElementById('loading-slots').textContent = 'Could not load availability.';
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── Date strip ────────────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
function renderDateStrip() {
|
|
222
|
+
const strip = document.getElementById('date-strip');
|
|
223
|
+
strip.innerHTML = '';
|
|
224
|
+
availableDates.forEach((d, i) => {
|
|
225
|
+
const parts = d.date.split('-');
|
|
226
|
+
const dateObj = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]));
|
|
227
|
+
const dow = dateObj.toLocaleDateString('en-US', { weekday: 'short' });
|
|
228
|
+
const mon = dateObj.toLocaleDateString('en-US', { month: 'short' });
|
|
229
|
+
const dom = dateObj.getDate();
|
|
230
|
+
|
|
231
|
+
const card = document.createElement('div');
|
|
232
|
+
card.className = 'date-card';
|
|
233
|
+
card.innerHTML = `<div class="dow">${dow}</div><div class="dom">${dom}</div><div class="mon">${mon}</div>`;
|
|
234
|
+
card.addEventListener('click', () => selectDate(d));
|
|
235
|
+
strip.appendChild(card);
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function selectDate(dateData) {
|
|
240
|
+
selectedDate = dateData;
|
|
241
|
+
selectedSlot = null;
|
|
242
|
+
|
|
243
|
+
// Highlight active date card
|
|
244
|
+
document.querySelectorAll('.date-card').forEach((c, i) => {
|
|
245
|
+
c.classList.toggle('active', availableDates[i] === dateData);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// Show slot section
|
|
249
|
+
const slotSection = document.getElementById('step-slots');
|
|
250
|
+
slotSection.classList.add('visible');
|
|
251
|
+
document.getElementById('slot-heading').textContent = `Times on ${dateData.dayLabel}`;
|
|
252
|
+
renderSlotGrid(dateData.slots);
|
|
253
|
+
|
|
254
|
+
// Hide booking form and payment
|
|
255
|
+
document.getElementById('booking-form').classList.remove('visible');
|
|
256
|
+
document.getElementById('payment-section').classList.remove('visible');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ── Slot grid ─────────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
function renderSlotGrid(slots) {
|
|
262
|
+
const grid = document.getElementById('slot-grid');
|
|
263
|
+
grid.innerHTML = '';
|
|
264
|
+
slots.forEach(slotStr => {
|
|
265
|
+
const [, time] = slotStr.split('T');
|
|
266
|
+
const [h, m] = time.split(':').map(Number);
|
|
267
|
+
const ampm = h >= 12 ? 'PM' : 'AM';
|
|
268
|
+
const h12 = h % 12 || 12;
|
|
269
|
+
const label = `${h12}:${m.toString().padStart(2,'0')} ${ampm}`;
|
|
270
|
+
|
|
271
|
+
const btn = document.createElement('button');
|
|
272
|
+
btn.className = 'slot-btn';
|
|
273
|
+
btn.textContent = label;
|
|
274
|
+
btn.addEventListener('click', () => selectSlot(slotStr, label));
|
|
275
|
+
grid.appendChild(btn);
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function selectSlot(slotStr, label) {
|
|
280
|
+
selectedSlot = slotStr;
|
|
281
|
+
document.querySelectorAll('.slot-btn').forEach(b => b.classList.remove('active'));
|
|
282
|
+
event.currentTarget.classList.add('active');
|
|
283
|
+
|
|
284
|
+
const display = formatSlotDisplay(slotStr);
|
|
285
|
+
document.getElementById('selected-slot-display').textContent = `📅 ${display}`;
|
|
286
|
+
document.getElementById('booking-form').classList.add('visible');
|
|
287
|
+
document.getElementById('booking-form').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function formatSlotDisplay(slotStr) {
|
|
291
|
+
const [datePart, timePart] = slotStr.split('T');
|
|
292
|
+
const [y, mo, d] = datePart.split('-').map(Number);
|
|
293
|
+
const [h, m] = timePart.split(':').map(Number);
|
|
294
|
+
const dateObj = new Date(y, mo - 1, d);
|
|
295
|
+
const dateLabel = dateObj.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' });
|
|
296
|
+
const ampm = h >= 12 ? 'PM' : 'AM';
|
|
297
|
+
const h12 = h % 12 || 12;
|
|
298
|
+
const timeLabel = `${h12}:${m.toString().padStart(2,'0')} ${ampm} ${TIMEZONE}`;
|
|
299
|
+
return `${dateLabel} at ${timeLabel}`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ── Navigation ────────────────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
window.backToSlots = function() {
|
|
305
|
+
document.getElementById('booking-form').classList.remove('visible');
|
|
306
|
+
document.getElementById('payment-section').classList.remove('visible');
|
|
307
|
+
selectedSlot = null;
|
|
308
|
+
document.querySelectorAll('.slot-btn').forEach(b => b.classList.remove('active'));
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
// ── Proceed to payment ────────────────────────────────────────────────────
|
|
312
|
+
|
|
313
|
+
window.proceedToPay = async function() {
|
|
314
|
+
const recoveryKey = document.getElementById('recovery-key').value.trim();
|
|
315
|
+
const name = document.getElementById('contact-name').value.trim();
|
|
316
|
+
const email = document.getElementById('contact-email').value.trim();
|
|
317
|
+
const errorEl = document.getElementById('form-error');
|
|
318
|
+
|
|
319
|
+
if (!recoveryKey) { showFormError('Recovery key is required.'); return; }
|
|
320
|
+
if (!name) { showFormError('Name is required.'); return; }
|
|
321
|
+
if (!email) { showFormError('Email is required.'); return; }
|
|
322
|
+
if (!selectedSlot){ showFormError('Please select a time slot.'); return; }
|
|
323
|
+
errorEl.style.display = 'none';
|
|
324
|
+
|
|
325
|
+
document.getElementById('proceed-to-pay').disabled = true;
|
|
326
|
+
|
|
327
|
+
try {
|
|
328
|
+
const resp = await fetch(`${SHOPPE_URL}/purchase/intent`, {
|
|
329
|
+
method: 'POST',
|
|
330
|
+
headers: { 'Content-Type': 'application/json' },
|
|
331
|
+
body: JSON.stringify({ recoveryKey, productId: PRODUCT_ID, title: TITLE, slotDatetime: selectedSlot })
|
|
332
|
+
});
|
|
333
|
+
const data = await resp.json();
|
|
334
|
+
|
|
335
|
+
if (data.error) {
|
|
336
|
+
showFormError(data.error);
|
|
337
|
+
document.getElementById('proceed-to-pay').disabled = false;
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (data.free) {
|
|
342
|
+
// Free booking — skip Stripe and record directly
|
|
343
|
+
const contactInfo = {
|
|
344
|
+
name: document.getElementById('contact-name').value.trim(),
|
|
345
|
+
email: document.getElementById('contact-email').value.trim(),
|
|
346
|
+
phone: document.getElementById('contact-phone').value.trim()
|
|
347
|
+
};
|
|
348
|
+
await fetch(`${SHOPPE_URL}/purchase/complete`, {
|
|
349
|
+
method: 'POST',
|
|
350
|
+
headers: { 'Content-Type': 'application/json' },
|
|
351
|
+
body: JSON.stringify({ recoveryKey, productId: PRODUCT_ID, title: TITLE, slotDatetime: selectedSlot, contactInfo })
|
|
352
|
+
});
|
|
353
|
+
document.getElementById('booking-form').classList.remove('visible');
|
|
354
|
+
showConfirmation();
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Show Stripe payment section
|
|
359
|
+
document.getElementById('booking-form').classList.remove('visible');
|
|
360
|
+
const paySection = document.getElementById('payment-section');
|
|
361
|
+
paySection.classList.add('visible');
|
|
362
|
+
document.getElementById('selected-slot-payment').textContent = `📅 ${formatSlotDisplay(selectedSlot)}`;
|
|
363
|
+
|
|
364
|
+
stripe = Stripe(data.publishableKey);
|
|
365
|
+
elements = stripe.elements({ clientSecret: data.clientSecret });
|
|
366
|
+
elements.create('payment').mount('#payment-element');
|
|
367
|
+
|
|
368
|
+
paySection.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
369
|
+
} catch (err) {
|
|
370
|
+
showFormError('Could not start checkout. Please try again.');
|
|
371
|
+
document.getElementById('proceed-to-pay').disabled = false;
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
// ── Confirm payment ───────────────────────────────────────────────────────
|
|
376
|
+
|
|
377
|
+
window.confirmPayment = async function() {
|
|
378
|
+
const payBtn = document.getElementById('pay-btn');
|
|
379
|
+
const payError = document.getElementById('pay-error');
|
|
380
|
+
const payLoading = document.getElementById('pay-loading');
|
|
381
|
+
|
|
382
|
+
payBtn.disabled = true;
|
|
383
|
+
payLoading.style.display = 'block';
|
|
384
|
+
payLoading.textContent = 'Processing…';
|
|
385
|
+
payError.style.display = 'none';
|
|
386
|
+
|
|
387
|
+
try {
|
|
388
|
+
const { error } = await stripe.confirmPayment({
|
|
389
|
+
elements,
|
|
390
|
+
confirmParams: { return_url: SHOPPE_URL },
|
|
391
|
+
redirect: 'if_required'
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
if (error) {
|
|
395
|
+
payError.textContent = error.message;
|
|
396
|
+
payError.style.display = 'block';
|
|
397
|
+
payBtn.disabled = false;
|
|
398
|
+
payLoading.style.display = 'none';
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Payment succeeded — record booking server-side (contact info never goes direct to Sanora)
|
|
403
|
+
const recoveryKey = document.getElementById('recovery-key').value.trim();
|
|
404
|
+
const contactInfo = {
|
|
405
|
+
name: document.getElementById('contact-name').value.trim(),
|
|
406
|
+
email: document.getElementById('contact-email').value.trim(),
|
|
407
|
+
phone: document.getElementById('contact-phone').value.trim()
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
await fetch(`${SHOPPE_URL}/purchase/complete`, {
|
|
411
|
+
method: 'POST',
|
|
412
|
+
headers: { 'Content-Type': 'application/json' },
|
|
413
|
+
body: JSON.stringify({
|
|
414
|
+
recoveryKey,
|
|
415
|
+
productId: PRODUCT_ID,
|
|
416
|
+
title: TITLE,
|
|
417
|
+
slotDatetime: selectedSlot,
|
|
418
|
+
contactInfo
|
|
419
|
+
})
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
showConfirmation();
|
|
423
|
+
} catch (err) {
|
|
424
|
+
payError.textContent = 'An unexpected error occurred.';
|
|
425
|
+
payError.style.display = 'block';
|
|
426
|
+
payBtn.disabled = false;
|
|
427
|
+
payLoading.style.display = 'none';
|
|
428
|
+
console.warn('payment error:', err);
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
// ── Confirmation screen ───────────────────────────────────────────────────
|
|
433
|
+
|
|
434
|
+
function showConfirmation() {
|
|
435
|
+
document.getElementById('payment-section').classList.remove('visible');
|
|
436
|
+
document.getElementById('step-dates').style.display = 'none';
|
|
437
|
+
document.getElementById('step-slots').classList.remove('visible');
|
|
438
|
+
|
|
439
|
+
const conf = document.getElementById('confirmation');
|
|
440
|
+
conf.classList.add('visible');
|
|
441
|
+
document.getElementById('confirmation-slot').textContent = formatSlotDisplay(selectedSlot);
|
|
442
|
+
conf.scrollIntoView({ behavior: 'smooth' });
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
446
|
+
|
|
447
|
+
function showFormError(msg) {
|
|
448
|
+
const el = document.getElementById('form-error');
|
|
449
|
+
el.textContent = msg;
|
|
450
|
+
el.style.display = 'block';
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Boot
|
|
454
|
+
loadSlots();
|
|
455
|
+
})();
|
|
456
|
+
</script>
|
|
457
|
+
</body>
|
|
458
|
+
</html>
|
|
@@ -454,11 +454,16 @@
|
|
|
454
454
|
};
|
|
455
455
|
|
|
456
456
|
function handleSubmit(formData) {
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
457
|
+
const address = {
|
|
458
|
+
name: formData['Name'],
|
|
459
|
+
line1: formData['Address 1'],
|
|
460
|
+
line2: formData['Address 2'] || '',
|
|
461
|
+
city: formData['City'],
|
|
462
|
+
state: formData['State'],
|
|
463
|
+
zip: formData['Zip Code']
|
|
464
|
+
};
|
|
465
|
+
window.pendingAddress = address;
|
|
466
|
+
window.addPaymentForm(address);
|
|
462
467
|
}
|
|
463
468
|
|
|
464
469
|
const form = getForm(formConfig, handleSubmit);
|
|
@@ -468,83 +473,79 @@
|
|
|
468
473
|
<script type="text/javascript">
|
|
469
474
|
let stripe;
|
|
470
475
|
let elements;
|
|
471
|
-
let
|
|
476
|
+
let orderRef;
|
|
472
477
|
|
|
473
478
|
const paymentForm = document.getElementById('payment-form');
|
|
474
479
|
const submitButton = document.getElementById('submit-button');
|
|
475
480
|
const errorMessage = document.getElementById('error-message');
|
|
476
481
|
const loadingMessage = document.getElementById('loading');
|
|
477
482
|
|
|
478
|
-
|
|
483
|
+
// Request a Stripe payment intent from the shoppe server.
|
|
484
|
+
// Address is held in memory here and only sent to the server after payment succeeds.
|
|
485
|
+
async function requestPaymentIntent(address) {
|
|
479
486
|
try {
|
|
480
|
-
const
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
const res = await fetch(`{{allyabaseOrigin}}/plugin/allyabase/addie/processor/stripe/intent`, {
|
|
488
|
-
method: 'put',
|
|
489
|
-
body: JSON.stringify(payload),
|
|
490
|
-
headers: {'Content-Type': 'application/json'}
|
|
487
|
+
const res = await fetch('{{shoppeUrl}}/purchase/intent', {
|
|
488
|
+
method: 'POST',
|
|
489
|
+
headers: {'Content-Type': 'application/json'},
|
|
490
|
+
body: JSON.stringify({
|
|
491
|
+
productId: '{{productId}}',
|
|
492
|
+
title: '{{title}}'
|
|
493
|
+
})
|
|
491
494
|
});
|
|
492
495
|
|
|
493
|
-
const
|
|
494
|
-
|
|
496
|
+
const data = await res.json();
|
|
497
|
+
if (data.error) { showError(data.error); return; }
|
|
495
498
|
|
|
496
|
-
|
|
497
|
-
elements = stripe.elements({
|
|
498
|
-
clientSecret: response.paymentIntent
|
|
499
|
-
});
|
|
499
|
+
orderRef = data.orderRef;
|
|
500
500
|
|
|
501
|
+
stripe = Stripe(data.publishableKey);
|
|
502
|
+
elements = stripe.elements({ clientSecret: data.clientSecret });
|
|
501
503
|
const paymentElement = elements.create('payment');
|
|
502
504
|
paymentElement.mount('#payment-element');
|
|
505
|
+
document.getElementById('payment-form').style.display = 'block';
|
|
503
506
|
} catch(err) {
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
+
showError('Could not start checkout. Please try again.');
|
|
508
|
+
console.warn(err);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
507
511
|
|
|
512
|
+
// Called after Stripe confirms payment. Sends address to the shoppe server which
|
|
513
|
+
// records the order in Sanora — address never goes directly from browser to Sanora.
|
|
508
514
|
window.confirmPayment = async () => {
|
|
509
|
-
const order = {
|
|
510
|
-
title: "{{title}}",
|
|
511
|
-
productId: "{{productId}}",
|
|
512
|
-
formData
|
|
513
|
-
};
|
|
514
|
-
await fetch('{{sanoraUrl}}/user/orders', {
|
|
515
|
-
method: 'PUT',
|
|
516
|
-
headers: {'Content-Type': 'application/json'},
|
|
517
|
-
body: JSON.stringify({timestamp: new Date().getTime() + '', order})
|
|
518
|
-
});
|
|
519
515
|
try {
|
|
520
516
|
const { error } = await stripe.confirmPayment({
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
},
|
|
517
|
+
elements,
|
|
518
|
+
confirmParams: { return_url: '{{shoppeUrl}}' },
|
|
519
|
+
redirect: 'if_required'
|
|
525
520
|
});
|
|
526
521
|
|
|
527
|
-
if(error) {
|
|
528
|
-
|
|
529
|
-
|
|
522
|
+
if (error) { showError(error.message); return; }
|
|
523
|
+
|
|
524
|
+
// Payment succeeded — now record the order server-side with the shipping address
|
|
525
|
+
await fetch('{{shoppeUrl}}/purchase/complete', {
|
|
526
|
+
method: 'POST',
|
|
527
|
+
headers: {'Content-Type': 'application/json'},
|
|
528
|
+
body: JSON.stringify({
|
|
529
|
+
orderRef,
|
|
530
|
+
productId: '{{productId}}',
|
|
531
|
+
title: '{{title}}',
|
|
532
|
+
amount: {{amount}},
|
|
533
|
+
address: window.pendingAddress
|
|
534
|
+
})
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
window.location.href = '{{shoppeUrl}}';
|
|
530
538
|
} catch(err) {
|
|
531
539
|
showError('An unexpected error occurred.');
|
|
532
|
-
console.warn('payment error: ', err);
|
|
540
|
+
console.warn('payment error: ', err);
|
|
533
541
|
}
|
|
534
542
|
};
|
|
535
543
|
|
|
536
544
|
paymentForm.addEventListener('submit', async (event) => {
|
|
537
545
|
event.preventDefault();
|
|
538
|
-
|
|
539
|
-
if (!stripe || !elements) {
|
|
540
|
-
return;
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
// Disable payment form submission while processing
|
|
546
|
+
if (!stripe || !elements) return;
|
|
544
547
|
setLoading(true);
|
|
545
|
-
|
|
546
548
|
await window.confirmPayment();
|
|
547
|
-
|
|
548
549
|
setLoading(false);
|
|
549
550
|
});
|
|
550
551
|
|
|
@@ -562,11 +563,7 @@ console.warn('payment error: ', err);
|
|
|
562
563
|
loadingMessage.style.display = isLoading ? 'block' : 'none';
|
|
563
564
|
};
|
|
564
565
|
|
|
565
|
-
|
|
566
|
-
getPaymentIntentWithoutSplits({{amount}}, 'USD');
|
|
567
|
-
};
|
|
568
|
-
|
|
569
|
-
window.addPaymentForm = start;
|
|
566
|
+
window.addPaymentForm = requestPaymentIntent;
|
|
570
567
|
</script>
|
|
571
568
|
</body>
|
|
572
569
|
</html>
|