wiki-plugin-shoppe 0.0.12 → 0.0.14
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
package/server/server.js
CHANGED
|
@@ -9,6 +9,19 @@ const sessionless = require('sessionless-node');
|
|
|
9
9
|
|
|
10
10
|
const SHOPPE_BASE_EMOJI = process.env.SHOPPE_BASE_EMOJI || '🛍️🎨🎁';
|
|
11
11
|
|
|
12
|
+
const TEMPLATES_DIR = path.join(__dirname, 'templates');
|
|
13
|
+
const RECOVER_STRIPE_TMPL = fs.readFileSync(path.join(TEMPLATES_DIR, 'generic-recover-stripe.html'), 'utf8');
|
|
14
|
+
const ADDRESS_STRIPE_TMPL = fs.readFileSync(path.join(TEMPLATES_DIR, 'generic-address-stripe.html'), 'utf8');
|
|
15
|
+
|
|
16
|
+
function getAllyabaseOrigin() {
|
|
17
|
+
try { return new URL(getSanoraUrl()).origin; } catch { return getSanoraUrl(); }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function fillTemplate(tmpl, vars) {
|
|
21
|
+
return Object.entries(vars).reduce((html, [k, v]) =>
|
|
22
|
+
html.replace(new RegExp(`\\{\\{${k}\\}\\}`, 'g'), v), tmpl);
|
|
23
|
+
}
|
|
24
|
+
|
|
12
25
|
const DATA_DIR = path.join(process.env.HOME || '/root', '.shoppe');
|
|
13
26
|
const TENANTS_FILE = path.join(DATA_DIR, 'tenants.json');
|
|
14
27
|
const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
|
|
@@ -681,7 +694,13 @@ async function getShoppeGoods(tenant) {
|
|
|
681
694
|
image: product.image ? `${getSanoraUrl()}/images/${product.image}` : null,
|
|
682
695
|
url: isPost
|
|
683
696
|
? `/plugin/shoppe/${tenant.uuid}/post/${encodeURIComponent(title)}`
|
|
684
|
-
:
|
|
697
|
+
: product.category === 'book'
|
|
698
|
+
? `/plugin/shoppe/${tenant.uuid}/buy/${encodeURIComponent(title)}`
|
|
699
|
+
: product.category === 'product' && product.shipping > 0
|
|
700
|
+
? `/plugin/shoppe/${tenant.uuid}/buy/${encodeURIComponent(title)}/address`
|
|
701
|
+
: product.category === 'product'
|
|
702
|
+
? `/plugin/shoppe/${tenant.uuid}/buy/${encodeURIComponent(title)}`
|
|
703
|
+
: `${getSanoraUrl()}/products/${tenant.uuid}/${encodeURIComponent(title)}`
|
|
685
704
|
};
|
|
686
705
|
const CATEGORY_BUCKET = { book: 'books', music: 'music', post: 'posts', 'post-series': 'posts', album: 'albums', product: 'products' };
|
|
687
706
|
const bucket = goods[CATEGORY_BUCKET[product.category]];
|
|
@@ -702,7 +721,7 @@ function renderCards(items, category) {
|
|
|
702
721
|
? `<div class="card-img"><img src="${item.image}" alt="" loading="lazy"></div>`
|
|
703
722
|
: `<div class="card-img-placeholder">${CATEGORY_EMOJI[category] || '🎁'}</div>`;
|
|
704
723
|
const priceHtml = (item.price > 0 || category === 'product')
|
|
705
|
-
? `<div class="price">$${item.price}${item.shipping ? ` <span class="shipping">+ $${item.shipping} shipping</span>` : ''}</div>`
|
|
724
|
+
? `<div class="price">$${(item.price / 100).toFixed(2)}${item.shipping ? ` <span class="shipping">+ $${(item.shipping / 100).toFixed(2)} shipping</span>` : ''}</div>`
|
|
706
725
|
: '';
|
|
707
726
|
return `
|
|
708
727
|
<div class="card" onclick="window.open('${item.url}','_blank')">
|
|
@@ -926,6 +945,54 @@ async function startServer(params) {
|
|
|
926
945
|
res.json({ success: true });
|
|
927
946
|
});
|
|
928
947
|
|
|
948
|
+
// Purchase pages — shoppe-hosted versions of the Sanora payment templates
|
|
949
|
+
async function renderPurchasePage(req, res, templateHtml) {
|
|
950
|
+
try {
|
|
951
|
+
const tenant = getTenantByIdentifier(req.params.identifier);
|
|
952
|
+
if (!tenant) return res.status(404).send('<h1>Shoppe not found</h1>');
|
|
953
|
+
|
|
954
|
+
const title = decodeURIComponent(req.params.title);
|
|
955
|
+
const sanoraUrl = getSanoraUrl();
|
|
956
|
+
const productsResp = await fetch(`${sanoraUrl}/products/${tenant.uuid}`);
|
|
957
|
+
const products = await productsResp.json();
|
|
958
|
+
const product = products[title] || Object.values(products).find(p => p.title === title);
|
|
959
|
+
if (!product) return res.status(404).send('<h1>Product not found</h1>');
|
|
960
|
+
|
|
961
|
+
const imageUrl = product.image ? `${sanoraUrl}/images/${product.image}` : '';
|
|
962
|
+
const ebookUrl = `${sanoraUrl}/products/${tenant.uuid}/${encodeURIComponent(title)}/ebook-download`;
|
|
963
|
+
const shoppeUrl = `${req.protocol}://${req.get('host')}/plugin/shoppe/${tenant.uuid}`;
|
|
964
|
+
|
|
965
|
+
const html = fillTemplate(templateHtml, {
|
|
966
|
+
title: product.title || title,
|
|
967
|
+
description: product.description || '',
|
|
968
|
+
image: `"${imageUrl}"`,
|
|
969
|
+
amount: String(product.price || 0),
|
|
970
|
+
formattedAmount: ((product.price || 0) / 100).toFixed(2),
|
|
971
|
+
productId: product.productId || '',
|
|
972
|
+
pubKey: '',
|
|
973
|
+
signature: '',
|
|
974
|
+
sanoraUrl,
|
|
975
|
+
allyabaseOrigin: getAllyabaseOrigin(),
|
|
976
|
+
ebookUrl,
|
|
977
|
+
shoppeUrl
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
res.set('Content-Type', 'text/html');
|
|
981
|
+
res.send(html);
|
|
982
|
+
} catch (err) {
|
|
983
|
+
console.error('[shoppe] purchase page error:', err);
|
|
984
|
+
res.status(500).send(`<h1>Error</h1><p>${err.message}</p>`);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// Books + no-shipping products → recovery key + stripe
|
|
989
|
+
app.get('/plugin/shoppe/:identifier/buy/:title', (req, res) =>
|
|
990
|
+
renderPurchasePage(req, res, RECOVER_STRIPE_TMPL));
|
|
991
|
+
|
|
992
|
+
// Physical products with shipping → address + stripe
|
|
993
|
+
app.get('/plugin/shoppe/:identifier/buy/:title/address', (req, res) =>
|
|
994
|
+
renderPurchasePage(req, res, ADDRESS_STRIPE_TMPL));
|
|
995
|
+
|
|
929
996
|
// Post reader — fetches markdown from Sanora and renders it as HTML
|
|
930
997
|
app.get('/plugin/shoppe/:identifier/post/:title', async (req, res) => {
|
|
931
998
|
try {
|
|
@@ -0,0 +1,572 @@
|
|
|
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.00, maximum-scale=1.00, minimum-scale=1.00">
|
|
7
|
+
|
|
8
|
+
<!--these are the tags that create the web preview card. You can use og:, or twitter: tags or both-->
|
|
9
|
+
<meta name="twitter:title" content="{{title}}">
|
|
10
|
+
<meta name="description" content="{{description}}">
|
|
11
|
+
<meta name="twitter:description" content="{{description}}">
|
|
12
|
+
<meta name="twitter:image" content={{image}}>
|
|
13
|
+
|
|
14
|
+
<meta name="og:title" content="{{title}}">
|
|
15
|
+
<meta name="og:description" content="{{description}}">
|
|
16
|
+
<meta name="og:image" content={{image}}>
|
|
17
|
+
|
|
18
|
+
<title>{{title}}</title>
|
|
19
|
+
</head>
|
|
20
|
+
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; height: 100vh; overflow: hidden; background: #0f0f12;">
|
|
21
|
+
<div id="main-container" style="
|
|
22
|
+
width: 100vw;
|
|
23
|
+
height: 100vh;
|
|
24
|
+
display: flex;
|
|
25
|
+
overflow: hidden;
|
|
26
|
+
flex-direction: row;
|
|
27
|
+
">
|
|
28
|
+
<!-- Product Section -->
|
|
29
|
+
<div id="product-section" style="
|
|
30
|
+
flex: 1;
|
|
31
|
+
display: flex;
|
|
32
|
+
flex-direction: column;
|
|
33
|
+
justify-content: center;
|
|
34
|
+
align-items: center;
|
|
35
|
+
padding: 20px;
|
|
36
|
+
box-sizing: border-box;
|
|
37
|
+
min-height: 0;
|
|
38
|
+
overflow: hidden;
|
|
39
|
+
">
|
|
40
|
+
<teleport pubKey="{{pubKey}}" signature="{{signature}}" message="{{title}}{{description}}{{amount}}" spell="sanora-club" amount="{{amount}}">
|
|
41
|
+
<div style="width: 100%; max-width: 500px; text-align: center; color: white;">
|
|
42
|
+
<img id="product-image" style="width: 100%; height: auto; max-height: 40vh; object-fit: contain; border-radius: 8px;"></img>
|
|
43
|
+
<script type="text/javascript">
|
|
44
|
+
document.getElementById('product-image').src = {{image}};
|
|
45
|
+
</script>
|
|
46
|
+
<h2 style="margin: 20px 0 10px 0; font-size: clamp(1.2rem, 4vw, 2rem);">{{title}}</h2>
|
|
47
|
+
<h4 style="margin: 10px 0; opacity: 0.8; font-weight: normal; font-size: clamp(0.9rem, 2.5vw, 1.1rem);">{{description}}</h4>
|
|
48
|
+
</div>
|
|
49
|
+
</teleport>
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<!-- Forms Section -->
|
|
53
|
+
<div id="forms-section" style="
|
|
54
|
+
flex: 1;
|
|
55
|
+
display: flex;
|
|
56
|
+
flex-direction: column;
|
|
57
|
+
justify-content: center;
|
|
58
|
+
align-items: center;
|
|
59
|
+
padding: 20px;
|
|
60
|
+
box-sizing: border-box;
|
|
61
|
+
min-height: 0;
|
|
62
|
+
overflow: auto;
|
|
63
|
+
">
|
|
64
|
+
<div id="address-form" style="width: 100%; max-width: 500px; margin-bottom: 20px;"></div>
|
|
65
|
+
|
|
66
|
+
<form id="payment-form" class="payment-form" style="
|
|
67
|
+
width: 100%;
|
|
68
|
+
max-width: 500px;
|
|
69
|
+
background: #2a2a2e;
|
|
70
|
+
padding: 20px;
|
|
71
|
+
border-radius: 15px;
|
|
72
|
+
border: 1px solid #444;
|
|
73
|
+
display: none;
|
|
74
|
+
">
|
|
75
|
+
<div id="payment-element" style="margin-bottom: 20px;"></div>
|
|
76
|
+
<button id="submit-button" style="
|
|
77
|
+
width: 100%;
|
|
78
|
+
padding: 15px;
|
|
79
|
+
background: linear-gradient(90deg, green, purple);
|
|
80
|
+
color: white;
|
|
81
|
+
border: none;
|
|
82
|
+
border-radius: 22.5px;
|
|
83
|
+
font-size: 16px;
|
|
84
|
+
font-weight: bold;
|
|
85
|
+
cursor: pointer;
|
|
86
|
+
">Pay now</button>
|
|
87
|
+
<div id="error-message" class="error-message" style="display: none; color: #ff6b6b; margin-top: 10px; text-align: center;"></div>
|
|
88
|
+
<div id="loading" class="loading" style="display: none; color: white; margin-top: 10px; text-align: center;">Processing payment...</div>
|
|
89
|
+
</form>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<script type="text/javascript">
|
|
94
|
+
// Responsive layout logic
|
|
95
|
+
function updateLayout() {
|
|
96
|
+
const mainContainer = document.getElementById('main-container');
|
|
97
|
+
const productSection = document.getElementById('product-section');
|
|
98
|
+
const formsSection = document.getElementById('forms-section');
|
|
99
|
+
|
|
100
|
+
if (window.innerWidth > window.innerHeight) {
|
|
101
|
+
// Landscape: side by side
|
|
102
|
+
mainContainer.style.flexDirection = 'row';
|
|
103
|
+
productSection.style.flex = '1';
|
|
104
|
+
formsSection.style.flex = '1';
|
|
105
|
+
mainContainer.style.overflow = 'hidden';
|
|
106
|
+
} else {
|
|
107
|
+
// Portrait: vertical layout, always show 2 elements
|
|
108
|
+
mainContainer.style.flexDirection = 'column';
|
|
109
|
+
productSection.style.flex = '1';
|
|
110
|
+
formsSection.style.flex = '1';
|
|
111
|
+
mainContainer.style.overflow = 'hidden';
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Initial layout
|
|
116
|
+
updateLayout();
|
|
117
|
+
|
|
118
|
+
// Update on resize
|
|
119
|
+
window.addEventListener('resize', updateLayout);
|
|
120
|
+
</script>
|
|
121
|
+
|
|
122
|
+
<script type="text/javascript">
|
|
123
|
+
(function(window) {
|
|
124
|
+
'use strict';
|
|
125
|
+
|
|
126
|
+
function calculateFormHeight(formConfig) {
|
|
127
|
+
const fieldCount = Object.keys(formConfig).filter(key => key !== 'form').length;
|
|
128
|
+
const headerHeight = 85;
|
|
129
|
+
const fieldHeight = 70;
|
|
130
|
+
const submitButtonHeight = 45;
|
|
131
|
+
const bottomPadding = 30;
|
|
132
|
+
|
|
133
|
+
return headerHeight + (fieldCount * fieldHeight) + submitButtonHeight + bottomPadding;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function getBackgroundAndGradients(formConfig) {
|
|
137
|
+
const dynamicHeight = calculateFormHeight(formConfig);
|
|
138
|
+
|
|
139
|
+
const svg = `<rect width="500" height="${dynamicHeight}" fill="transparent"/>
|
|
140
|
+
|
|
141
|
+
<!-- Form Container with Metallic Background -->
|
|
142
|
+
<linearGradient id="metallicBackground" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
143
|
+
<stop offset="0%" stop-color="#2a2a2e"/>
|
|
144
|
+
<stop offset="50%" stop-color="#323236"/>
|
|
145
|
+
<stop offset="100%" stop-color="#2a2a2e"/>
|
|
146
|
+
</linearGradient>
|
|
147
|
+
<rect x="50" y="50" width="400" height="${dynamicHeight}" rx="15" fill="url(#metallicBackground)"
|
|
148
|
+
stroke="#444" stroke-width="1"/>
|
|
149
|
+
|
|
150
|
+
<!-- Subtle Metallic Highlight -->
|
|
151
|
+
<line x1="51" y1="52" x2="449" y2="52" stroke="#555" stroke-width="1" opacity="0.5"/>
|
|
152
|
+
|
|
153
|
+
<!-- Form Header -->
|
|
154
|
+
<text x="250" y="85" font-family="Arial, sans-serif" font-size="24" font-weight="bold"
|
|
155
|
+
fill="#ffffff" text-anchor="middle">US SHIPPING ADDRESS</text>
|
|
156
|
+
|
|
157
|
+
<!-- Define the gradient for active input borders -->
|
|
158
|
+
<linearGradient id="inputGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
|
159
|
+
<stop offset="0%" stop-color="purple"/>
|
|
160
|
+
<stop offset="100%" stop-color="green"/>
|
|
161
|
+
</linearGradient>
|
|
162
|
+
|
|
163
|
+
<!-- Button Gradient -->
|
|
164
|
+
<linearGradient id="buttonGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
|
165
|
+
<stop id="submitButtonGradientStart" offset="0%" stop-color="green"/>
|
|
166
|
+
<stop id="submitButtonGradientEnd" offset="100%" stop-color="purple"/>
|
|
167
|
+
</linearGradient>
|
|
168
|
+
|
|
169
|
+
<linearGradient id="buttonPressedGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
|
170
|
+
<stop offset="0%" stop-color="purple"/>
|
|
171
|
+
<stop offset="100%" stop-color="green"/>
|
|
172
|
+
</linearGradient>`;
|
|
173
|
+
|
|
174
|
+
return svg;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function getSegmentedSelector(x, y, text, options) {
|
|
178
|
+
const borderId = `${text.replace(/\s+/g, '')}Border`;
|
|
179
|
+
const selectorId = `${text.replace(/\s+/g, '')}Selector`;
|
|
180
|
+
|
|
181
|
+
const segmentWidth = 340 / options.length;
|
|
182
|
+
|
|
183
|
+
let segments = '';
|
|
184
|
+
let segmentTexts = '';
|
|
185
|
+
|
|
186
|
+
options.forEach((option, index) => {
|
|
187
|
+
const segmentX = x + (index * segmentWidth);
|
|
188
|
+
const segmentId = `${selectorId}_${index}`;
|
|
189
|
+
|
|
190
|
+
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"/>`;
|
|
191
|
+
|
|
192
|
+
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>`;
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const svg = `<text x="${x}" y="${y}" font-family="Arial, sans-serif" font-size="14" fill="#bbbbbb">${text}</text>
|
|
196
|
+
<!-- Segmented Container -->
|
|
197
|
+
<rect id="${borderId}" x="${x}" y="${y + 10}" width="340" height="40" rx="8" fill="transparent" stroke="#444" stroke-width="2"/>
|
|
198
|
+
${segments}
|
|
199
|
+
${segmentTexts}
|
|
200
|
+
<!-- Hidden input to store value -->
|
|
201
|
+
<foreignObject x="${x}" y="${y + 55}" width="1" height="1" style="overflow: hidden;">
|
|
202
|
+
<input xmlns="http://www.w3.org/1999/xhtml" id="${selectorId}" type="hidden" data-field="${text}"/>
|
|
203
|
+
</foreignObject>`;
|
|
204
|
+
|
|
205
|
+
return svg;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function getInput(x, y, text, inputType) {
|
|
209
|
+
const borderId = `${text.replace(/\s+/g, '')}Border`;
|
|
210
|
+
const inputId = `${text.replace(/\s+/g, '')}Input`;
|
|
211
|
+
|
|
212
|
+
const svg = `<text x="${x}" y="${y}" font-family="Arial, sans-serif" font-size="14" fill="#bbbbbb">${text}</text>
|
|
213
|
+
<!-- Field Background -->
|
|
214
|
+
<rect id="${borderId}" x="${x}" y="${y + 10}" width="340" height="40" rx="8" fill="#1c1c20"
|
|
215
|
+
stroke="#444" stroke-width="2" class="input-field"/>
|
|
216
|
+
<!-- Inset shadow effect -->
|
|
217
|
+
<rect x="${x + 2}" y="${y + 12}" width="336" height="36" rx="6" fill="none"
|
|
218
|
+
stroke="#000" stroke-width="1" opacity="0.3"/>
|
|
219
|
+
<!-- HTML Input Field -->
|
|
220
|
+
<foreignObject x="${x + 5}" y="${y + 15}" width="330" height="30">
|
|
221
|
+
<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;"/>
|
|
222
|
+
</foreignObject>`;
|
|
223
|
+
|
|
224
|
+
return svg;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function getSubmitButton(x, y) {
|
|
228
|
+
return `<rect id="submitButton" x="${x}" y="${y}" width="300" height="45" rx="22.5" fill="#666666" style="cursor: not-allowed;">
|
|
229
|
+
</rect>
|
|
230
|
+
<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>
|
|
231
|
+
`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function enableSubmitButton() {
|
|
235
|
+
const submitButton = document.getElementById('submitButton');
|
|
236
|
+
const submitButtonText = submitButton.nextElementSibling;
|
|
237
|
+
|
|
238
|
+
submitButton.setAttribute('fill', 'url(#buttonGradient)');
|
|
239
|
+
submitButton.setAttribute('style', 'cursor: pointer;');
|
|
240
|
+
submitButtonText.setAttribute('fill', 'white');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function disableSubmitButton() {
|
|
244
|
+
const submitButton = document.getElementById('submitButton');
|
|
245
|
+
const submitButtonText = submitButton.nextElementSibling;
|
|
246
|
+
|
|
247
|
+
submitButton.setAttribute('fill', '#666666');
|
|
248
|
+
submitButton.setAttribute('style', 'cursor: not-allowed;');
|
|
249
|
+
submitButtonText.setAttribute('fill', '#999999');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function validateForm(formJSON) {
|
|
253
|
+
const requiredFields = Object.keys(formJSON).filter(key =>
|
|
254
|
+
key !== 'form' && formJSON[key].required
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
const allValid = requiredFields.every(key => {
|
|
258
|
+
const fieldConfig = formJSON[key];
|
|
259
|
+
|
|
260
|
+
if (fieldConfig.type === 'segmented') {
|
|
261
|
+
const selectorId = `${key.replace(/\s+/g, '')}Selector`;
|
|
262
|
+
const selector = document.getElementById(selectorId);
|
|
263
|
+
return selector && selector.value && selector.value.trim() !== '';
|
|
264
|
+
} else {
|
|
265
|
+
const inputId = `${key.replace(/\s+/g, '')}Input`;
|
|
266
|
+
const input = document.getElementById(inputId);
|
|
267
|
+
return input && input.value && input.value.trim() !== '';
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
if (allValid) {
|
|
272
|
+
enableSubmitButton();
|
|
273
|
+
} else {
|
|
274
|
+
disableSubmitButton();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return allValid;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function getForm(formJSON, onSubmit) {
|
|
281
|
+
const keys = Object.keys(formJSON);
|
|
282
|
+
const inputs = keys.map((key, index) => {
|
|
283
|
+
if (key === "form") return '';
|
|
284
|
+
|
|
285
|
+
const fieldConfig = formJSON[key];
|
|
286
|
+
if (fieldConfig.type === 'segmented') {
|
|
287
|
+
return getSegmentedSelector(80, 70 * index + 130, key, fieldConfig.options);
|
|
288
|
+
} else {
|
|
289
|
+
return getInput(80, 70 * index + 130, key, fieldConfig.type);
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const svg = getBackgroundAndGradients(formJSON) + inputs.join('') + getSubmitButton(100, 70 * (keys.length) + 130);
|
|
294
|
+
|
|
295
|
+
const dynamicHeight = calculateFormHeight(formJSON);
|
|
296
|
+
|
|
297
|
+
const container = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
298
|
+
container.setAttribute('viewBox', `0 0 500 ${dynamicHeight + 100}`);
|
|
299
|
+
container.setAttribute('width', '100%');
|
|
300
|
+
container.setAttribute('height', 'auto');
|
|
301
|
+
container.innerHTML = svg;
|
|
302
|
+
|
|
303
|
+
setTimeout(() => {
|
|
304
|
+
Object.keys(formJSON).forEach((key, index) => {
|
|
305
|
+
if(key === 'form') {
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const fieldConfig = formJSON[key];
|
|
310
|
+
const borderId = `${key.replace(/\s+/g, '')}Border`;
|
|
311
|
+
|
|
312
|
+
if (fieldConfig.type === 'segmented') {
|
|
313
|
+
const selectorId = `${key.replace(/\s+/g, '')}Selector`;
|
|
314
|
+
const selector = document.getElementById(selectorId);
|
|
315
|
+
|
|
316
|
+
// Add click handlers for segments
|
|
317
|
+
fieldConfig.options.forEach((option, optionIndex) => {
|
|
318
|
+
const segmentId = `${selectorId}_${optionIndex}`;
|
|
319
|
+
const segment = document.getElementById(segmentId);
|
|
320
|
+
|
|
321
|
+
if (segment) {
|
|
322
|
+
segment.addEventListener('click', () => {
|
|
323
|
+
// Clear all segments
|
|
324
|
+
fieldConfig.options.forEach((_, clearIndex) => {
|
|
325
|
+
const clearSegmentId = `${selectorId}_${clearIndex}`;
|
|
326
|
+
const clearSegment = document.getElementById(clearSegmentId);
|
|
327
|
+
if (clearSegment) {
|
|
328
|
+
clearSegment.setAttribute('fill', '#1c1c20');
|
|
329
|
+
clearSegment.setAttribute('data-selected', 'false');
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// Select clicked segment
|
|
334
|
+
segment.setAttribute('fill', '#3eda82');
|
|
335
|
+
segment.setAttribute('data-selected', 'true');
|
|
336
|
+
|
|
337
|
+
// Update hidden input value
|
|
338
|
+
if (selector) {
|
|
339
|
+
selector.value = option;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Update border
|
|
343
|
+
const borderElement = document.getElementById(borderId);
|
|
344
|
+
if (borderElement) {
|
|
345
|
+
borderElement.setAttribute('stroke', 'url(#inputGradient)');
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Validate form
|
|
349
|
+
validateForm(formJSON);
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
} else {
|
|
354
|
+
const inputId = `${key.replace(/\s+/g, '')}Input`;
|
|
355
|
+
const inputElement = document.getElementById(inputId);
|
|
356
|
+
|
|
357
|
+
if (inputElement) {
|
|
358
|
+
inputElement.addEventListener('input', (evt) => {
|
|
359
|
+
const borderElement = document.getElementById(borderId);
|
|
360
|
+
if (borderElement) {
|
|
361
|
+
borderElement.setAttribute('stroke', 'url(#inputGradient)');
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Validate form
|
|
365
|
+
validateForm(formJSON);
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
const submitButton = document.getElementById('submitButton');
|
|
372
|
+
if (submitButton) {
|
|
373
|
+
submitButton.addEventListener('click', () => {
|
|
374
|
+
if (!validateForm(formJSON)) {
|
|
375
|
+
console.log('Form validation failed');
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
console.log('Submit button clicked');
|
|
380
|
+
|
|
381
|
+
// Create button animation
|
|
382
|
+
const animation = document.createElementNS("http://www.w3.org/2000/svg", "animate");
|
|
383
|
+
animation.setAttribute("attributeName", "offset");
|
|
384
|
+
animation.setAttribute("values", "-0.5;2.5");
|
|
385
|
+
animation.setAttribute("dur", "300ms");
|
|
386
|
+
animation.setAttribute("repeatCount", "1");
|
|
387
|
+
|
|
388
|
+
const secondAnimation = document.createElementNS("http://www.w3.org/2000/svg", "animate");
|
|
389
|
+
secondAnimation.setAttribute("attributeName", "offset");
|
|
390
|
+
secondAnimation.setAttribute("values", "0.5;3.5");
|
|
391
|
+
secondAnimation.setAttribute("dur", "300ms");
|
|
392
|
+
secondAnimation.setAttribute("repeatCount", "1");
|
|
393
|
+
|
|
394
|
+
const startGradient = document.getElementById('submitButtonGradientStart');
|
|
395
|
+
const endGradient = document.getElementById('submitButtonGradientEnd');
|
|
396
|
+
|
|
397
|
+
if (startGradient && endGradient) {
|
|
398
|
+
startGradient.appendChild(animation);
|
|
399
|
+
endGradient.appendChild(secondAnimation);
|
|
400
|
+
animation.beginElement();
|
|
401
|
+
secondAnimation.beginElement();
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Collect form values
|
|
405
|
+
const formValues = {};
|
|
406
|
+
Object.keys(formJSON).forEach((key) => {
|
|
407
|
+
if(key === 'form') {
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const fieldConfig = formJSON[key];
|
|
412
|
+
|
|
413
|
+
if (fieldConfig.type === 'segmented') {
|
|
414
|
+
const selectorId = `${key.replace(/\s+/g, '')}Selector`;
|
|
415
|
+
const selector = document.getElementById(selectorId);
|
|
416
|
+
if (selector) {
|
|
417
|
+
formValues[key] = selector.value;
|
|
418
|
+
}
|
|
419
|
+
} else {
|
|
420
|
+
const inputId = `${key.replace(/\s+/g, '')}Input`;
|
|
421
|
+
const inputElement = document.getElementById(inputId);
|
|
422
|
+
if (inputElement) {
|
|
423
|
+
formValues[key] = inputElement.value;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
if (onSubmit) {
|
|
429
|
+
onSubmit(formValues);
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Initial validation
|
|
435
|
+
validateForm(formJSON);
|
|
436
|
+
}, 100);
|
|
437
|
+
|
|
438
|
+
return container;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
window.getForm = getForm;
|
|
442
|
+
|
|
443
|
+
})(window);
|
|
444
|
+
</script>
|
|
445
|
+
|
|
446
|
+
<script>
|
|
447
|
+
const formConfig = {
|
|
448
|
+
"Name": {type: "text", required: true},
|
|
449
|
+
"Address 1": {type: "text", required: true},
|
|
450
|
+
"Address 2": {type: "text", required: false},
|
|
451
|
+
"City": {type: "text", required: true},
|
|
452
|
+
"State": {type: "text", required: true},
|
|
453
|
+
"Zip Code": {type: "text", required: true}
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
function handleSubmit(formData) {
|
|
457
|
+
console.log('Form submitted:', formData);
|
|
458
|
+
// Do whatever you want with the form data
|
|
459
|
+
window.formData = formData;
|
|
460
|
+
document.getElementById('payment-form').style.display = 'block';
|
|
461
|
+
window.addPaymentForm();
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const form = getForm(formConfig, handleSubmit);
|
|
465
|
+
document.getElementById('address-form').appendChild(form);
|
|
466
|
+
</script>
|
|
467
|
+
|
|
468
|
+
<script type="text/javascript">
|
|
469
|
+
let stripe;
|
|
470
|
+
let elements;
|
|
471
|
+
let response;
|
|
472
|
+
|
|
473
|
+
const paymentForm = document.getElementById('payment-form');
|
|
474
|
+
const submitButton = document.getElementById('submit-button');
|
|
475
|
+
const errorMessage = document.getElementById('error-message');
|
|
476
|
+
const loadingMessage = document.getElementById('loading');
|
|
477
|
+
|
|
478
|
+
async function getPaymentIntentWithoutSplits(amount, currency) {
|
|
479
|
+
try {
|
|
480
|
+
const payload = {
|
|
481
|
+
timestamp: new Date().getTime() + '',
|
|
482
|
+
amount: {{amount}},
|
|
483
|
+
currency: 'USD',
|
|
484
|
+
payees: []
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
const res = await fetch(`{{allyabaseOrigin}}/processor/stripe/intent`, {
|
|
488
|
+
method: 'put',
|
|
489
|
+
body: JSON.stringify(payload),
|
|
490
|
+
headers: {'Content-Type': 'application/json'}
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
const response = await res.json();
|
|
494
|
+
console.log('got intent response', response);
|
|
495
|
+
|
|
496
|
+
stripe = Stripe(response.publishableKey);
|
|
497
|
+
elements = stripe.elements({
|
|
498
|
+
clientSecret: response.paymentIntent
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
const paymentElement = elements.create('payment');
|
|
502
|
+
paymentElement.mount('#payment-element');
|
|
503
|
+
} catch(err) {
|
|
504
|
+
console.warn(err);
|
|
505
|
+
}
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
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
|
+
try {
|
|
520
|
+
const { error } = await stripe.confirmPayment({
|
|
521
|
+
elements,
|
|
522
|
+
confirmParams: {
|
|
523
|
+
return_url: '{{shoppeUrl}}',
|
|
524
|
+
},
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
if(error) {
|
|
528
|
+
showError(error.message);
|
|
529
|
+
}
|
|
530
|
+
} catch(err) {
|
|
531
|
+
showError('An unexpected error occurred.');
|
|
532
|
+
console.warn('payment error: ', err);
|
|
533
|
+
}
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
paymentForm.addEventListener('submit', async (event) => {
|
|
537
|
+
event.preventDefault();
|
|
538
|
+
|
|
539
|
+
if (!stripe || !elements) {
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Disable payment form submission while processing
|
|
544
|
+
setLoading(true);
|
|
545
|
+
|
|
546
|
+
await window.confirmPayment();
|
|
547
|
+
|
|
548
|
+
setLoading(false);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
const showError = (message) => {
|
|
552
|
+
errorMessage.textContent = message;
|
|
553
|
+
errorMessage.style.display = 'block';
|
|
554
|
+
setTimeout(() => {
|
|
555
|
+
errorMessage.style.display = 'none';
|
|
556
|
+
errorMessage.textContent = '';
|
|
557
|
+
}, 5000);
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
const setLoading = (isLoading) => {
|
|
561
|
+
submitButton.disabled = isLoading;
|
|
562
|
+
loadingMessage.style.display = isLoading ? 'block' : 'none';
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
const start = () => {
|
|
566
|
+
getPaymentIntentWithoutSplits({{amount}}, 'USD');
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
window.addPaymentForm = start;
|
|
570
|
+
</script>
|
|
571
|
+
</body>
|
|
572
|
+
</html>
|