@shoppexio/storefront-react 0.0.0 → 0.1.0
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/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/README.md +137 -0
- package/dist/account.css +1171 -0
- package/dist/account.d.ts +23 -0
- package/dist/account.js +12685 -0
- package/dist/account.js.map +1 -0
- package/dist/chunk-D6XKDVAV.js +181 -0
- package/dist/chunk-D6XKDVAV.js.map +1 -0
- package/dist/index.d.ts +3087 -0
- package/dist/index.js +2801 -0
- package/dist/index.js.map +1 -0
- package/eject/account/account-availability.ts +171 -0
- package/eject/account/account-downloads.ts +118 -0
- package/eject/account/account-favorites.ts +99 -0
- package/eject/account/account-loyalty.ts +241 -0
- package/eject/account/account-page.tsx +410 -0
- package/eject/account/account-payment-method.ts +99 -0
- package/eject/account/account-referrals.ts +68 -0
- package/eject/account/account-reseller-invite.ts +88 -0
- package/eject/account/account-reseller.ts +685 -0
- package/eject/account/account-rewards.ts +104 -0
- package/eject/account/account-settings.ts +181 -0
- package/eject/account/account-signin-link.ts +122 -0
- package/eject/account/account-subscriptions.ts +130 -0
- package/eject/account/account-support.ts +155 -0
- package/eject/account/account-warranties.ts +118 -0
- package/eject/account/customer-account.ts +93 -0
- package/eject/account/dashboard.tsx +139 -0
- package/eject/account/downloads.tsx +343 -0
- package/eject/account/favorites.tsx +201 -0
- package/eject/account/i18n.ts +175 -0
- package/eject/account/icons.tsx +218 -0
- package/eject/account/login.tsx +253 -0
- package/eject/account/messages/ar.json +464 -0
- package/eject/account/messages/de.json +464 -0
- package/eject/account/messages/en.json +464 -0
- package/eject/account/messages/es.json +464 -0
- package/eject/account/messages/fi.json +464 -0
- package/eject/account/messages/fr.json +464 -0
- package/eject/account/messages/hi.json +464 -0
- package/eject/account/messages/it.json +464 -0
- package/eject/account/messages/lt.json +464 -0
- package/eject/account/messages/nl.json +464 -0
- package/eject/account/messages/pl.json +464 -0
- package/eject/account/messages/pt.json +464 -0
- package/eject/account/messages/ru.json +464 -0
- package/eject/account/messages/tr.json +464 -0
- package/eject/account/messages/uk.json +464 -0
- package/eject/account/messages/zh.json +464 -0
- package/eject/account/orders.tsx +791 -0
- package/eject/account/overview.tsx +147 -0
- package/eject/account/referrals.tsx +327 -0
- package/eject/account/reseller.tsx +1365 -0
- package/eject/account/rewards.tsx +641 -0
- package/eject/account/settings.tsx +699 -0
- package/eject/account/shared.tsx +398 -0
- package/eject/account/subscriptions.tsx +421 -0
- package/eject/account/support.tsx +453 -0
- package/package.json +75 -1
package/dist/index.js
ADDED
|
@@ -0,0 +1,2801 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ACCOUNT_ARTIFACT_CAPABILITIES,
|
|
3
|
+
ACCOUNT_PATH,
|
|
4
|
+
ACCOUNT_PATH_ALIAS,
|
|
5
|
+
ACCOUNT_STOREFRONT_ROUTES,
|
|
6
|
+
ACCOUNT_TABS,
|
|
7
|
+
DEFAULT_ACCOUNT_TAB,
|
|
8
|
+
IMPLEMENTED_ACCOUNT_TABS,
|
|
9
|
+
SERVING_CONTEXT_HINT,
|
|
10
|
+
accountOrderHref,
|
|
11
|
+
accountTabHref,
|
|
12
|
+
accountTabLabelKey,
|
|
13
|
+
canonicalAccountPath,
|
|
14
|
+
getBuyerCurrency,
|
|
15
|
+
getShoppex,
|
|
16
|
+
isAccountTab,
|
|
17
|
+
isImplementedAccountTab,
|
|
18
|
+
parseAccountPath,
|
|
19
|
+
resolveAccountView
|
|
20
|
+
} from "./chunk-D6XKDVAV.js";
|
|
21
|
+
|
|
22
|
+
// src/account-route-messages.ts
|
|
23
|
+
var ACCOUNT_ROUTE_MESSAGE_KEYS = [
|
|
24
|
+
"account.checkingSession",
|
|
25
|
+
"common.notLoaded",
|
|
26
|
+
"common.genericError",
|
|
27
|
+
"common.retry"
|
|
28
|
+
];
|
|
29
|
+
var ACCOUNT_ROUTE_MESSAGES = {
|
|
30
|
+
ar: ["\u062C\u0627\u0631\u064D \u0627\u0644\u062A\u062D\u0642\u0642 \u0645\u0646 \u062C\u0644\u0633\u062A\u0643", "\u062A\u0639\u0630\u0651\u0631 \u062A\u062D\u0645\u064A\u0644 \u0647\u0630\u0627", "\u062A\u0639\u0630\u0651\u0631 \u062A\u062D\u0645\u064A\u0644 \u0647\u0630\u0627. \u064A\u064F\u0631\u062C\u0649 \u0627\u0644\u0645\u062D\u0627\u0648\u0644\u0629 \u0628\u0639\u062F \u0642\u0644\u064A\u0644.", "\u0623\u0639\u062F \u0627\u0644\u0645\u062D\u0627\u0648\u0644\u0629"],
|
|
31
|
+
de: ["Deine Sitzung wird gepr\xFCft", "Das wurde nicht geladen", "Das konnte nicht geladen werden. Bitte versuche es gleich noch einmal.", "Erneut versuchen"],
|
|
32
|
+
en: ["Checking your session", "This did not load", "This could not be loaded. Please try again in a moment.", "Try again"],
|
|
33
|
+
es: ["Comprobando tu sesi\xF3n", "Esto no se carg\xF3", "No se pudo cargar. Int\xE9ntalo de nuevo en un momento.", "Int\xE9ntalo de nuevo"],
|
|
34
|
+
fi: ["Tarkistetaan istuntoasi", "T\xE4m\xE4 ei latautunut", "T\xE4t\xE4 ei voitu ladata. Yrit\xE4 hetken kuluttua uudelleen.", "Yrit\xE4 uudelleen"],
|
|
35
|
+
fr: ["V\xE9rification de votre session", "Le chargement a \xE9chou\xE9", "Le chargement a \xE9chou\xE9. R\xE9essayez dans un instant.", "R\xE9essayer"],
|
|
36
|
+
hi: ["\u0906\u092A\u0915\u093E \u0938\u0924\u094D\u0930 \u091C\u093E\u0901\u091A\u093E \u091C\u093E \u0930\u0939\u093E \u0939\u0948", "\u092F\u0939 \u0932\u094B\u0921 \u0928\u0939\u0940\u0902 \u0939\u0941\u0906", "\u092F\u0939 \u0932\u094B\u0921 \u0928\u0939\u0940\u0902 \u0939\u094B \u0938\u0915\u093E\u0964 \u0915\u0943\u092A\u092F\u093E \u0925\u094B\u0921\u093C\u0940 \u0926\u0947\u0930 \u092C\u093E\u0926 \u092B\u093F\u0930 \u0915\u094B\u0936\u093F\u0936 \u0915\u0930\u0947\u0902\u0964", "\u092B\u093F\u0930 \u0915\u094B\u0936\u093F\u0936 \u0915\u0930\u0947\u0902"],
|
|
37
|
+
it: ["Verifica della tua sessione", "Caricamento non riuscito", "Non \xE8 stato possibile caricare questo contenuto. Riprova tra poco.", "Riprova"],
|
|
38
|
+
lt: ["Tikriname j\u016Bs\u0173 seans\u0105", "\u0160is turinys ne\u012Fsik\u0117l\u0117", "Nepavyko to \u012Fkelti. Po akimirkos bandykite dar kart\u0105.", "Bandykite dar kart\u0105"],
|
|
39
|
+
nl: ["Je sessie wordt gecontroleerd", "Dit is niet geladen", "Dit kon niet worden geladen. Probeer het zo meteen opnieuw.", "Probeer opnieuw"],
|
|
40
|
+
pl: ["Sprawdzamy Twoj\u0105 sesj\u0119", "To si\u0119 nie wczyta\u0142o", "Nie uda\u0142o si\u0119 tego wczyta\u0107. Spr\xF3buj ponownie za chwil\u0119.", "Spr\xF3buj ponownie"],
|
|
41
|
+
pt: ["A verificar a tua sess\xE3o", "Isto n\xE3o carregou", "N\xE3o foi poss\xEDvel carregar isto. Tenta de novo daqui a pouco.", "Tentar de novo"],
|
|
42
|
+
ru: ["\u041F\u0440\u043E\u0432\u0435\u0440\u044F\u0435\u043C \u0432\u0430\u0448\u0443 \u0441\u0435\u0441\u0441\u0438\u044E", "\u042D\u0442\u043E \u043D\u0435 \u0437\u0430\u0433\u0440\u0443\u0437\u0438\u043B\u043E\u0441\u044C", "\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044C \u044D\u0442\u043E. \u041F\u043E\u0432\u0442\u043E\u0440\u0438\u0442\u0435 \u043F\u043E\u043F\u044B\u0442\u043A\u0443 \u0447\u0435\u0440\u0435\u0437 \u043C\u0438\u043D\u0443\u0442\u0443.", "\u041F\u043E\u0432\u0442\u043E\u0440\u0438\u0442\u044C"],
|
|
43
|
+
tr: ["Oturumunuz kontrol ediliyor", "Bu y\xFCklenemedi", "Bu y\xFCklenemedi. L\xFCtfen birazdan tekrar deneyin.", "Tekrar deneyin"],
|
|
44
|
+
uk: ["\u041F\u0435\u0440\u0435\u0432\u0456\u0440\u044F\u0454\u043C\u043E \u0432\u0430\u0448\u0443 \u0441\u0435\u0441\u0456\u044E", "\u0426\u0435 \u043D\u0435 \u0437\u0430\u0432\u0430\u043D\u0442\u0430\u0436\u0438\u043B\u043E\u0441\u044F", "\u041D\u0435 \u0432\u0434\u0430\u043B\u043E\u0441\u044F \u0437\u0430\u0432\u0430\u043D\u0442\u0430\u0436\u0438\u0442\u0438. \u0421\u043F\u0440\u043E\u0431\u0443\u0439\u0442\u0435 \u0449\u0435 \u0440\u0430\u0437 \u0437\u0430 \u0445\u0432\u0438\u043B\u0438\u043D\u0443.", "\u0421\u043F\u0440\u043E\u0431\u0443\u0432\u0430\u0442\u0438 \u0449\u0435 \u0440\u0430\u0437"],
|
|
45
|
+
zh: ["\u6B63\u5728\u68C0\u67E5\u767B\u5F55\u72B6\u6001", "\u6B64\u5185\u5BB9\u672A\u52A0\u8F7D", "\u65E0\u6CD5\u52A0\u8F7D\u6B64\u5185\u5BB9\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002", "\u91CD\u8BD5"]
|
|
46
|
+
};
|
|
47
|
+
function resolveAccountRouteLocale(lang) {
|
|
48
|
+
const base = (lang ?? "").trim().toLowerCase().split(/[-_]/)[0] ?? "";
|
|
49
|
+
return base in ACCOUNT_ROUTE_MESSAGES ? base : "en";
|
|
50
|
+
}
|
|
51
|
+
function accountRouteMessage(key, lang) {
|
|
52
|
+
const activeLang = lang === void 0 && typeof document !== "undefined" ? document.documentElement.getAttribute("lang") : lang;
|
|
53
|
+
const messageIndex = ACCOUNT_ROUTE_MESSAGE_KEYS.indexOf(key);
|
|
54
|
+
return ACCOUNT_ROUTE_MESSAGES[resolveAccountRouteLocale(activeLang)][messageIndex];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// src/cart.tsx
|
|
58
|
+
import {
|
|
59
|
+
createContext,
|
|
60
|
+
useCallback,
|
|
61
|
+
useContext,
|
|
62
|
+
useEffect,
|
|
63
|
+
useMemo,
|
|
64
|
+
useRef,
|
|
65
|
+
useState
|
|
66
|
+
} from "react";
|
|
67
|
+
|
|
68
|
+
// src/parse.ts
|
|
69
|
+
function isRecord(value) {
|
|
70
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
71
|
+
}
|
|
72
|
+
function requireUnknownRecord(value, context) {
|
|
73
|
+
if (!isRecord(value)) {
|
|
74
|
+
throw new Error(`Invalid Shoppex ${context}: expected an object.`);
|
|
75
|
+
}
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
function requireRecord(record, key) {
|
|
79
|
+
return requireUnknownRecord(record[key], key);
|
|
80
|
+
}
|
|
81
|
+
function requireArray(record, key) {
|
|
82
|
+
const value = record[key];
|
|
83
|
+
if (!Array.isArray(value)) {
|
|
84
|
+
throw new Error(`Invalid Shoppex config: ${key} must be an array.`);
|
|
85
|
+
}
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
function requireString(record, key, context) {
|
|
89
|
+
const value = record[key];
|
|
90
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
91
|
+
throw new Error(`Invalid Shoppex ${context}: ${key} must be a non-empty string.`);
|
|
92
|
+
}
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
95
|
+
function requireStringFromKeys(record, keys, context) {
|
|
96
|
+
for (const key of keys) {
|
|
97
|
+
const value = optionalString(record[key]);
|
|
98
|
+
if (value) return value;
|
|
99
|
+
}
|
|
100
|
+
throw new Error(`Invalid Shoppex ${context}: ${keys.join(" or ")} must be a non-empty string.`);
|
|
101
|
+
}
|
|
102
|
+
function requireNumber(record, key, context) {
|
|
103
|
+
const value = record[key];
|
|
104
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
105
|
+
throw new Error(`Invalid Shoppex ${context}: ${key} must be a finite number.`);
|
|
106
|
+
}
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
function optionalString(value) {
|
|
110
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
111
|
+
}
|
|
112
|
+
function firstOptionalString(record, keys) {
|
|
113
|
+
for (const key of keys) {
|
|
114
|
+
const value = optionalString(record[key]);
|
|
115
|
+
if (value) return value;
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
function optionalStringWithDefault(value, fallback, context) {
|
|
120
|
+
if (value === void 0 || value === null || value === "") return fallback;
|
|
121
|
+
if (typeof value !== "string") {
|
|
122
|
+
throw new Error(`Invalid Shoppex ${context}: expected a string.`);
|
|
123
|
+
}
|
|
124
|
+
return value.trim() || fallback;
|
|
125
|
+
}
|
|
126
|
+
function requireBoolean(value, context) {
|
|
127
|
+
if (typeof value === "boolean") return value;
|
|
128
|
+
if (value === 0 || value === 1) return value === 1;
|
|
129
|
+
throw new Error(`Invalid Shoppex ${context}: expected a boolean.`);
|
|
130
|
+
}
|
|
131
|
+
function optionalBoolean(value, fallback, context) {
|
|
132
|
+
if (value === void 0 || value === null) return fallback;
|
|
133
|
+
if (typeof value === "boolean") return value;
|
|
134
|
+
if (value === 0 || value === 1) return value === 1;
|
|
135
|
+
throw new Error(`Invalid Shoppex ${context}: expected a boolean.`);
|
|
136
|
+
}
|
|
137
|
+
function parseFiniteNumber(value, context) {
|
|
138
|
+
const parsed = typeof value === "number" ? value : Number(value);
|
|
139
|
+
if (!Number.isFinite(parsed)) {
|
|
140
|
+
throw new Error(`Invalid Shoppex ${context}: expected a finite number.`);
|
|
141
|
+
}
|
|
142
|
+
return parsed;
|
|
143
|
+
}
|
|
144
|
+
function parseEnum(value, allowed, fallback, context) {
|
|
145
|
+
if (value === void 0 || value === null || value === "") return fallback;
|
|
146
|
+
if (typeof value !== "string" || !allowed.includes(value)) {
|
|
147
|
+
throw new Error(`Invalid Shoppex ${context}: expected one of ${allowed.join(", ")}.`);
|
|
148
|
+
}
|
|
149
|
+
return value;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// src/product-badges.ts
|
|
153
|
+
var MAX_PRODUCT_BADGES = 4;
|
|
154
|
+
var MAX_PRODUCT_BADGE_LABEL_LENGTH = 24;
|
|
155
|
+
var PRODUCT_BADGE_ICON_NAMES = [
|
|
156
|
+
"sparkles",
|
|
157
|
+
"flame",
|
|
158
|
+
"star",
|
|
159
|
+
"zap",
|
|
160
|
+
"tag",
|
|
161
|
+
"percent",
|
|
162
|
+
"trending-up",
|
|
163
|
+
"megaphone",
|
|
164
|
+
"party-popper",
|
|
165
|
+
"crown",
|
|
166
|
+
"gem",
|
|
167
|
+
"trophy",
|
|
168
|
+
"award",
|
|
169
|
+
"medal",
|
|
170
|
+
"badge-check",
|
|
171
|
+
"thumbs-up",
|
|
172
|
+
"heart",
|
|
173
|
+
"shield",
|
|
174
|
+
"shield-check",
|
|
175
|
+
"lock",
|
|
176
|
+
"handshake",
|
|
177
|
+
"rocket",
|
|
178
|
+
"truck",
|
|
179
|
+
"clock",
|
|
180
|
+
"timer",
|
|
181
|
+
"infinity",
|
|
182
|
+
"refresh-ccw",
|
|
183
|
+
"download",
|
|
184
|
+
"package",
|
|
185
|
+
"send",
|
|
186
|
+
"headphones",
|
|
187
|
+
"message-circle",
|
|
188
|
+
"life-buoy",
|
|
189
|
+
"users",
|
|
190
|
+
"gamepad-2",
|
|
191
|
+
"monitor",
|
|
192
|
+
"smartphone",
|
|
193
|
+
"key",
|
|
194
|
+
"file-text",
|
|
195
|
+
"book-open",
|
|
196
|
+
"music",
|
|
197
|
+
"video",
|
|
198
|
+
"image",
|
|
199
|
+
"palette",
|
|
200
|
+
"code",
|
|
201
|
+
"wrench",
|
|
202
|
+
"wallet",
|
|
203
|
+
"credit-card",
|
|
204
|
+
"globe",
|
|
205
|
+
"gift",
|
|
206
|
+
"bot"
|
|
207
|
+
];
|
|
208
|
+
var HEX_SHORT = /^#[0-9a-f]{3}$/i;
|
|
209
|
+
var HEX_LONG = /^#[0-9a-f]{6}$/i;
|
|
210
|
+
function normalizeBadgeColor(value) {
|
|
211
|
+
if (typeof value !== "string") return null;
|
|
212
|
+
const trimmed = value.trim();
|
|
213
|
+
if (trimmed.length === 0) return null;
|
|
214
|
+
const candidate = trimmed.startsWith("#") ? trimmed : `#${trimmed}`;
|
|
215
|
+
if (HEX_LONG.test(candidate)) return candidate.toLowerCase();
|
|
216
|
+
if (HEX_SHORT.test(candidate)) {
|
|
217
|
+
const [, r, g, b] = candidate.toLowerCase();
|
|
218
|
+
return `#${r}${r}${g}${g}${b}${b}`;
|
|
219
|
+
}
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
function channelLuminance(value) {
|
|
223
|
+
const channel = value / 255;
|
|
224
|
+
return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
|
|
225
|
+
}
|
|
226
|
+
function relativeLuminance(hex) {
|
|
227
|
+
const r = Number.parseInt(hex.slice(1, 3), 16);
|
|
228
|
+
const g = Number.parseInt(hex.slice(3, 5), 16);
|
|
229
|
+
const b = Number.parseInt(hex.slice(5, 7), 16);
|
|
230
|
+
return 0.2126 * channelLuminance(r) + 0.7152 * channelLuminance(g) + 0.0722 * channelLuminance(b);
|
|
231
|
+
}
|
|
232
|
+
var BADGE_DARK_FOREGROUND = "#0b0b0f";
|
|
233
|
+
var BADGE_LIGHT_FOREGROUND = "#ffffff";
|
|
234
|
+
function resolveBadgeForeground(backgroundColor) {
|
|
235
|
+
if (backgroundColor === null) return null;
|
|
236
|
+
const background = relativeLuminance(backgroundColor);
|
|
237
|
+
const contrastWithWhite = 1.05 / (background + 0.05);
|
|
238
|
+
const contrastWithBlack = (background + 0.05) / (relativeLuminance(BADGE_DARK_FOREGROUND) + 0.05);
|
|
239
|
+
return contrastWithWhite >= contrastWithBlack ? BADGE_LIGHT_FOREGROUND : BADGE_DARK_FOREGROUND;
|
|
240
|
+
}
|
|
241
|
+
function isRecord2(value) {
|
|
242
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
243
|
+
}
|
|
244
|
+
function readVisibility(value) {
|
|
245
|
+
if (typeof value === "boolean") return value;
|
|
246
|
+
if (value === 1 || value === "1" || value === "true") return true;
|
|
247
|
+
if (value === 0 || value === "0" || value === "false") return false;
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
function readField(entry, snake, camel) {
|
|
251
|
+
return entry[snake] !== void 0 ? entry[snake] : entry[camel];
|
|
252
|
+
}
|
|
253
|
+
function parseProductBadges(value) {
|
|
254
|
+
if (!Array.isArray(value)) return [];
|
|
255
|
+
const badges = [];
|
|
256
|
+
for (const raw of value) {
|
|
257
|
+
if (badges.length >= MAX_PRODUCT_BADGES) break;
|
|
258
|
+
if (!isRecord2(raw)) continue;
|
|
259
|
+
const rawLabel = raw.label;
|
|
260
|
+
if (typeof rawLabel !== "string") continue;
|
|
261
|
+
const label = rawLabel.trim().slice(0, MAX_PRODUCT_BADGE_LABEL_LENGTH);
|
|
262
|
+
if (label.length === 0) continue;
|
|
263
|
+
const rawIcon = raw.icon;
|
|
264
|
+
const icon = typeof rawIcon === "string" && PRODUCT_BADGE_ICON_NAMES.includes(rawIcon) ? rawIcon : null;
|
|
265
|
+
const backgroundColor = normalizeBadgeColor(
|
|
266
|
+
readField(raw, "background_color", "backgroundColor")
|
|
267
|
+
);
|
|
268
|
+
badges.push({
|
|
269
|
+
label,
|
|
270
|
+
icon,
|
|
271
|
+
backgroundColor,
|
|
272
|
+
foregroundColor: resolveBadgeForeground(backgroundColor),
|
|
273
|
+
showOnProductCard: readVisibility(readField(raw, "show_on_product_card", "showOnProductCard")),
|
|
274
|
+
showOnProductPage: readVisibility(readField(raw, "show_on_product_page", "showOnProductPage"))
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
return badges;
|
|
278
|
+
}
|
|
279
|
+
function badgesForCard(badges) {
|
|
280
|
+
return badges.filter((badge) => badge.showOnProductCard);
|
|
281
|
+
}
|
|
282
|
+
function badgesForProductPage(badges) {
|
|
283
|
+
return badges.filter((badge) => badge.showOnProductPage);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// src/video-embed.ts
|
|
287
|
+
var ALLOWED_VIDEO_EMBED_HOSTS = /* @__PURE__ */ new Set([
|
|
288
|
+
"www.youtube-nocookie.com",
|
|
289
|
+
"www.youtube.com",
|
|
290
|
+
"player.vimeo.com",
|
|
291
|
+
"streamable.com"
|
|
292
|
+
]);
|
|
293
|
+
function isAllowedVideoEmbedSrc(src) {
|
|
294
|
+
if (!src.startsWith("https://")) return false;
|
|
295
|
+
const host = src.slice("https://".length).split("/")[0].split("?")[0].split("#")[0];
|
|
296
|
+
return ALLOWED_VIDEO_EMBED_HOSTS.has(host);
|
|
297
|
+
}
|
|
298
|
+
var YOUTUBE_VIDEO_ID = /^[\w-]{11}$/;
|
|
299
|
+
var STREAMABLE_CODE = /^[a-zA-Z0-9]+$/;
|
|
300
|
+
var VIMEO_ID = /^\d+$/;
|
|
301
|
+
var VIMEO_HASH = /^[a-zA-Z0-9]+$/;
|
|
302
|
+
function toUrl(input) {
|
|
303
|
+
try {
|
|
304
|
+
return input.startsWith("http://") || input.startsWith("https://") ? new URL(input) : new URL(`https://${input}`);
|
|
305
|
+
} catch {
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
function parseYouTubeVideoId(input) {
|
|
310
|
+
const trimmed = input.trim();
|
|
311
|
+
if (!trimmed) return null;
|
|
312
|
+
if (YOUTUBE_VIDEO_ID.test(trimmed)) return trimmed;
|
|
313
|
+
const url = toUrl(trimmed);
|
|
314
|
+
if (!url) return null;
|
|
315
|
+
const host = url.hostname.replace(/^www\./, "");
|
|
316
|
+
if (host === "youtu.be") {
|
|
317
|
+
const candidate = url.pathname.split("/").filter(Boolean)[0] ?? "";
|
|
318
|
+
return YOUTUBE_VIDEO_ID.test(candidate) ? candidate : null;
|
|
319
|
+
}
|
|
320
|
+
if (host !== "youtube.com" && host !== "m.youtube.com" && host !== "music.youtube.com") {
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
const watchId = url.searchParams.get("v");
|
|
324
|
+
if (watchId && YOUTUBE_VIDEO_ID.test(watchId)) return watchId;
|
|
325
|
+
for (const pattern of [/^\/embed\/([\w-]{11})/, /^\/shorts\/([\w-]{11})/, /^\/live\/([\w-]{11})/]) {
|
|
326
|
+
const match = url.pathname.match(pattern);
|
|
327
|
+
if (match?.[1] && YOUTUBE_VIDEO_ID.test(match[1])) return match[1];
|
|
328
|
+
}
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
function parseStreamableCode(url) {
|
|
332
|
+
const segments = url.pathname.split("/").filter(Boolean);
|
|
333
|
+
const code = segments[0] === "e" ? segments[1] : segments[0];
|
|
334
|
+
return code && STREAMABLE_CODE.test(code) ? code : null;
|
|
335
|
+
}
|
|
336
|
+
function parseVimeo(url) {
|
|
337
|
+
const segments = url.pathname.split("/").filter(Boolean);
|
|
338
|
+
for (let index = segments.length - 1; index >= 0; index -= 1) {
|
|
339
|
+
const segment = segments[index];
|
|
340
|
+
if (!VIMEO_ID.test(segment)) continue;
|
|
341
|
+
const queryHash = url.searchParams.get("h")?.trim();
|
|
342
|
+
const pathHash = segments[index + 1];
|
|
343
|
+
const hash = queryHash && VIMEO_HASH.test(queryHash) ? queryHash : pathHash && VIMEO_HASH.test(pathHash) ? pathHash : void 0;
|
|
344
|
+
return hash ? { id: segment, hash } : { id: segment };
|
|
345
|
+
}
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
function resolveProductVideoEmbedSrc(value) {
|
|
349
|
+
if (typeof value !== "string") return null;
|
|
350
|
+
const trimmed = value.trim();
|
|
351
|
+
if (!trimmed) return null;
|
|
352
|
+
const url = toUrl(trimmed);
|
|
353
|
+
let src = null;
|
|
354
|
+
if (url) {
|
|
355
|
+
const host = url.hostname.replace(/^www\./, "");
|
|
356
|
+
if (host === "streamable.com") {
|
|
357
|
+
const code = parseStreamableCode(url);
|
|
358
|
+
src = code ? `https://streamable.com/e/${code}` : null;
|
|
359
|
+
} else if (host === "vimeo.com" || host === "player.vimeo.com") {
|
|
360
|
+
const parsed = parseVimeo(url);
|
|
361
|
+
src = parsed ? parsed.hash ? `https://player.vimeo.com/video/${parsed.id}?h=${parsed.hash}` : `https://player.vimeo.com/video/${parsed.id}` : null;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (src === null) {
|
|
365
|
+
const youtubeId = parseYouTubeVideoId(trimmed);
|
|
366
|
+
src = youtubeId ? `https://www.youtube-nocookie.com/embed/${youtubeId}` : null;
|
|
367
|
+
}
|
|
368
|
+
return src !== null && isAllowedVideoEmbedSrc(src) ? src : null;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// src/storefront-data.ts
|
|
372
|
+
var PRODUCT_TYPES = ["PRODUCT", "SUBSCRIPTION", "INFO_CARD", "SERIALS", "PHYSICAL"];
|
|
373
|
+
function resolveDeliveryPromise(product) {
|
|
374
|
+
if (product.productType === "PHYSICAL") return { kind: "shipped" };
|
|
375
|
+
if (product.deliveryTimeCount !== null) {
|
|
376
|
+
return { kind: "timed", count: product.deliveryTimeCount, unit: product.deliveryTimeUnit };
|
|
377
|
+
}
|
|
378
|
+
return { kind: "instant" };
|
|
379
|
+
}
|
|
380
|
+
var SUBSCRIPTION_INTERVAL_UNITS = ["DAY", "WEEK", "MONTH", "YEAR"];
|
|
381
|
+
var SUBSCRIPTION_INTERVAL_NOUNS = {
|
|
382
|
+
DAY: "day",
|
|
383
|
+
WEEK: "week",
|
|
384
|
+
MONTH: "month",
|
|
385
|
+
YEAR: "year"
|
|
386
|
+
};
|
|
387
|
+
function formatSubscriptionCadence(subscription) {
|
|
388
|
+
const unit = subscription.intervalUnit;
|
|
389
|
+
if (unit === null) return "";
|
|
390
|
+
const noun = SUBSCRIPTION_INTERVAL_NOUNS[unit];
|
|
391
|
+
return subscription.intervalCount === 1 ? `per ${noun}` : `every ${subscription.intervalCount} ${noun}s`;
|
|
392
|
+
}
|
|
393
|
+
function formatSubscriptionTrial(subscription) {
|
|
394
|
+
const days = subscription.trialDays;
|
|
395
|
+
return days > 0 ? `${days}-day free trial` : "";
|
|
396
|
+
}
|
|
397
|
+
function parseSubscription(product, productType) {
|
|
398
|
+
if (productType !== "SUBSCRIPTION") return null;
|
|
399
|
+
const rawUnit = typeof product.recurring_interval === "string" ? product.recurring_interval.toUpperCase() : "";
|
|
400
|
+
const unit = SUBSCRIPTION_INTERVAL_UNITS.find((entry) => entry === rawUnit) ?? null;
|
|
401
|
+
const count = normalizePositiveDays(product.recurring_interval_count) ?? 1;
|
|
402
|
+
return {
|
|
403
|
+
intervalUnit: unit,
|
|
404
|
+
intervalCount: count,
|
|
405
|
+
trialDays: normalizePositiveDays(product.trial_period) ?? 0
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
function hasInjectedStorefrontData() {
|
|
409
|
+
return window.__SHOPPEX_INITIAL__ !== void 0;
|
|
410
|
+
}
|
|
411
|
+
function getAuthoredContent() {
|
|
412
|
+
return window.__SHOPPEX_INITIAL__?.store?.builder_settings?.theme?.content;
|
|
413
|
+
}
|
|
414
|
+
function getProducts() {
|
|
415
|
+
const initialData = window.__SHOPPEX_INITIAL__;
|
|
416
|
+
if (initialData === void 0 || initialData.products === void 0) {
|
|
417
|
+
return [];
|
|
418
|
+
}
|
|
419
|
+
if (!Array.isArray(initialData.products)) {
|
|
420
|
+
throw new Error("Invalid Shoppex initial data: products must be an array.");
|
|
421
|
+
}
|
|
422
|
+
return initialData.products.map(parseProduct);
|
|
423
|
+
}
|
|
424
|
+
function getProductGroups() {
|
|
425
|
+
const groups = window.__SHOPPEX_INITIAL__?.groups;
|
|
426
|
+
if (groups === void 0) return [];
|
|
427
|
+
if (!Array.isArray(groups)) {
|
|
428
|
+
throw new Error("Invalid Shoppex initial data: groups must be an array.");
|
|
429
|
+
}
|
|
430
|
+
return groups.map((value, index) => {
|
|
431
|
+
const context = `group at index ${index}`;
|
|
432
|
+
const group = requireUnknownRecord(value, context);
|
|
433
|
+
const productIds = Array.isArray(group.product_uniqids) ? group.product_uniqids.filter((entry) => typeof entry === "string" && entry.length > 0) : [];
|
|
434
|
+
return {
|
|
435
|
+
id: requireString(group, "uniqid", context),
|
|
436
|
+
title: requireString(group, "title", context),
|
|
437
|
+
description: optionalString(group.description),
|
|
438
|
+
imageUrl: firstOptionalString(group, ["cdn_image_url", "image_url"]),
|
|
439
|
+
categoryId: optionalString(group.category_id),
|
|
440
|
+
productIds,
|
|
441
|
+
productsCount: normalizeOptionalNonNegativeNumber(group.products_count) ?? productIds.length,
|
|
442
|
+
sortPriority: normalizeOptionalNonNegativeNumber(group.sort_priority) ?? 0
|
|
443
|
+
};
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
function getStorefrontCategories() {
|
|
447
|
+
const categories = window.__SHOPPEX_INITIAL__?.categories;
|
|
448
|
+
if (categories === void 0) return [];
|
|
449
|
+
if (!Array.isArray(categories)) {
|
|
450
|
+
throw new Error("Invalid Shoppex initial data: categories must be an array.");
|
|
451
|
+
}
|
|
452
|
+
return categories.map((value, index) => {
|
|
453
|
+
const context = `category at index ${index}`;
|
|
454
|
+
const category = requireUnknownRecord(value, context);
|
|
455
|
+
const id = requireString(category, "uniqid", context);
|
|
456
|
+
return {
|
|
457
|
+
id,
|
|
458
|
+
slug: optionalString(category.slug) ?? id,
|
|
459
|
+
title: requireString(category, "title", context),
|
|
460
|
+
// Same pair of keys the group records use — `transformStorefrontCategory`
|
|
461
|
+
// emits the CDN URL, and the older spelling is what a cached payload has.
|
|
462
|
+
imageUrl: firstOptionalString(category, ["cdn_image_url", "image_url"]),
|
|
463
|
+
productsCount: normalizeOptionalNonNegativeNumber(category.products_count) ?? 0,
|
|
464
|
+
groupsCount: normalizeOptionalNonNegativeNumber(category.groups_count) ?? 0
|
|
465
|
+
};
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
function getStorefrontListing() {
|
|
469
|
+
const items = window.__SHOPPEX_INITIAL__?.items;
|
|
470
|
+
if (items === void 0) return [];
|
|
471
|
+
if (!Array.isArray(items)) {
|
|
472
|
+
throw new Error("Invalid Shoppex initial data: items must be an array.");
|
|
473
|
+
}
|
|
474
|
+
return items.map((value, index) => {
|
|
475
|
+
const context = `listing item at index ${index}`;
|
|
476
|
+
const item = requireUnknownRecord(value, context);
|
|
477
|
+
return {
|
|
478
|
+
type: item.item_type === "GROUP" ? "group" : "product",
|
|
479
|
+
id: requireString(item, "uniqid", context),
|
|
480
|
+
sortPriority: normalizeOptionalNonNegativeNumber(item.sort_priority) ?? index
|
|
481
|
+
};
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
var SOCIAL_NETWORKS = [
|
|
485
|
+
"discord",
|
|
486
|
+
"telegram",
|
|
487
|
+
"youtube",
|
|
488
|
+
"twitter",
|
|
489
|
+
"instagram",
|
|
490
|
+
"tiktok",
|
|
491
|
+
"facebook",
|
|
492
|
+
"reddit",
|
|
493
|
+
"email"
|
|
494
|
+
];
|
|
495
|
+
function getStorefrontShop(productCount) {
|
|
496
|
+
const store = window.__SHOPPEX_INITIAL__?.store;
|
|
497
|
+
if (!store) {
|
|
498
|
+
throw new Error("Invalid Shoppex initial data: store is required.");
|
|
499
|
+
}
|
|
500
|
+
const social = isRecord(store.social) ? store.social : {};
|
|
501
|
+
const feedback = isRecord(store.feedback) ? store.feedback : {};
|
|
502
|
+
const ratingRaw = Number(store.average_score ?? store.rating);
|
|
503
|
+
const rating = Number.isFinite(ratingRaw) && ratingRaw > 0 ? ratingRaw : null;
|
|
504
|
+
const termsHtml = store.tos_enabled === true ? optionalString(store.terms_of_service) : null;
|
|
505
|
+
return {
|
|
506
|
+
name: requireString(store, "name", "store"),
|
|
507
|
+
logoUrl: firstOptionalString(store, ["logo", "avatar"]),
|
|
508
|
+
avatarUrl: firstOptionalString(store, ["avatar", "logo"]),
|
|
509
|
+
socialLinks: SOCIAL_NETWORKS.flatMap((network) => {
|
|
510
|
+
const value = optionalString(social[network]);
|
|
511
|
+
if (!value) return [];
|
|
512
|
+
const href = network === "email" && !value.includes(":") ? `mailto:${value}` : value;
|
|
513
|
+
return [{ network, href }];
|
|
514
|
+
}),
|
|
515
|
+
termsHtml,
|
|
516
|
+
hasPublishedTerms: termsHtml !== null,
|
|
517
|
+
rewardProgram: parseRewardProgram(store.reward_program_summary),
|
|
518
|
+
resellerProgram: parseResellerProgram(store.reseller_program_summary),
|
|
519
|
+
stats: {
|
|
520
|
+
productsSold: normalizeOptionalNonNegativeNumber(store.products_sold_count) ?? 0,
|
|
521
|
+
customers: normalizeOptionalNonNegativeNumber(store.customers_count ?? store.buyers_count) ?? 0,
|
|
522
|
+
reviewCount: normalizeOptionalNonNegativeNumber(feedback.total) ?? 0,
|
|
523
|
+
averageRating: rating,
|
|
524
|
+
productCount
|
|
525
|
+
}
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
function parseRewardProgram(value) {
|
|
529
|
+
if (!isRecord(value) || value.enabled !== true) return null;
|
|
530
|
+
const headline = optionalString(value.headline);
|
|
531
|
+
if (!headline || !Array.isArray(value.items)) return null;
|
|
532
|
+
const items = [];
|
|
533
|
+
for (const entry of value.items) {
|
|
534
|
+
if (!isRecord(entry)) continue;
|
|
535
|
+
const triggerType = firstOptionalString(entry, ["trigger_type", "triggerType"]);
|
|
536
|
+
const actionType = firstOptionalString(entry, ["action_type", "actionType"]);
|
|
537
|
+
const label = optionalString(entry.label);
|
|
538
|
+
if (!triggerType || !actionType || !label) continue;
|
|
539
|
+
items.push({ triggerType, actionType, label });
|
|
540
|
+
}
|
|
541
|
+
return items.length > 0 ? { headline, items } : null;
|
|
542
|
+
}
|
|
543
|
+
var RESELLER_ENROLLMENT_MODES = ["OPEN", "APPLICATION"];
|
|
544
|
+
function parseResellerProgram(value) {
|
|
545
|
+
if (!isRecord(value)) return null;
|
|
546
|
+
const mode = optionalString(value.enrollment_mode);
|
|
547
|
+
return mode !== null && RESELLER_ENROLLMENT_MODES.includes(mode) ? { enrollmentMode: mode } : null;
|
|
548
|
+
}
|
|
549
|
+
var CURRENCY_CODE = /^[A-Z]{3}$/;
|
|
550
|
+
function parseCurrencyList(value, leading) {
|
|
551
|
+
const entries = Array.isArray(value) ? value : [];
|
|
552
|
+
const codes = [...leading, ...entries].map((entry) => typeof entry === "string" ? entry.trim().toUpperCase() : "").filter((entry) => CURRENCY_CODE.test(entry));
|
|
553
|
+
return [...new Set(codes)];
|
|
554
|
+
}
|
|
555
|
+
var STOREFRONT_SORT_KEYS = ["featured", "newest", "price-asc", "price-desc"];
|
|
556
|
+
function parseStorefrontSortKey(value) {
|
|
557
|
+
const raw = optionalString(value);
|
|
558
|
+
if (!raw) return "featured";
|
|
559
|
+
if (STOREFRONT_SORT_KEYS.includes(raw)) return raw;
|
|
560
|
+
switch (raw.toUpperCase()) {
|
|
561
|
+
case "NEWEST":
|
|
562
|
+
return "newest";
|
|
563
|
+
case "PRICE":
|
|
564
|
+
case "PRICE_ASC":
|
|
565
|
+
return "price-asc";
|
|
566
|
+
case "PRICE_DESC":
|
|
567
|
+
return "price-desc";
|
|
568
|
+
default:
|
|
569
|
+
return "featured";
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
function getStorefrontSettings() {
|
|
573
|
+
const store = window.__SHOPPEX_INITIAL__?.store;
|
|
574
|
+
if (!store) {
|
|
575
|
+
throw new Error("Invalid Shoppex initial data: store is required.");
|
|
576
|
+
}
|
|
577
|
+
const activeCurrency = (optionalString(store.currency) ?? "").toUpperCase();
|
|
578
|
+
const defaultCurrency = (optionalString(store.default_currency) ?? activeCurrency).toUpperCase();
|
|
579
|
+
return {
|
|
580
|
+
availableCurrencies: parseCurrencyList(store.available_currencies, [
|
|
581
|
+
activeCurrency,
|
|
582
|
+
defaultCurrency
|
|
583
|
+
]),
|
|
584
|
+
defaultCurrency,
|
|
585
|
+
activeCurrency,
|
|
586
|
+
availableGateways: (Array.isArray(store.available_gateways) ? store.available_gateways : []).filter((entry) => typeof entry === "string" && entry.trim() !== ""),
|
|
587
|
+
cartEnabled: optionalBoolean(store.cart_enabled, true, "store.cart_enabled"),
|
|
588
|
+
hideOutOfStock: optionalBoolean(store.hide_out_of_stock, false, "store.hide_out_of_stock"),
|
|
589
|
+
hideStockCounter: optionalBoolean(store.hide_stock_counter, false, "store.hide_stock_counter"),
|
|
590
|
+
searchEnabled: optionalBoolean(store.search_enabled, true, "store.search_enabled"),
|
|
591
|
+
sortEnabled: optionalBoolean(store.sort_enabled, true, "store.sort_enabled"),
|
|
592
|
+
// The three presentation switches, at the defaults their columns carry.
|
|
593
|
+
centerGroupTitles: optionalBoolean(
|
|
594
|
+
store.center_group_titles,
|
|
595
|
+
false,
|
|
596
|
+
"store.center_group_titles"
|
|
597
|
+
),
|
|
598
|
+
descriptionSkipDefaultImage: optionalBoolean(
|
|
599
|
+
store.description_skip_default_image,
|
|
600
|
+
false,
|
|
601
|
+
"store.description_skip_default_image"
|
|
602
|
+
),
|
|
603
|
+
descriptionImage: optionalBoolean(store.description_image, true, "store.description_image"),
|
|
604
|
+
defaultSort: parseStorefrontSortKey(store.default_sort),
|
|
605
|
+
// Both contact switches are MERCHANT settings the serializer always emits.
|
|
606
|
+
// Defaulting them meant a shop that had turned the form off still showed
|
|
607
|
+
// one, and a shop that required an invoice number never asked for it — the
|
|
608
|
+
// visitor only found out from the backend's refusal. A payload without them
|
|
609
|
+
// is broken, so it fails here instead.
|
|
610
|
+
contactFormEnabled: requireBoolean(store.contact_form_enabled, "store.contact_form_enabled"),
|
|
611
|
+
requireInvoiceForContact: requireBoolean(
|
|
612
|
+
store.require_invoice_for_contact,
|
|
613
|
+
"store.require_invoice_for_contact"
|
|
614
|
+
)
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
function getStorefrontAddons() {
|
|
618
|
+
const addons = window.__SHOPPEX_INITIAL__?.addons;
|
|
619
|
+
if (addons === void 0) return [];
|
|
620
|
+
if (!isRecord(addons) || !Array.isArray(addons.items)) {
|
|
621
|
+
throw new Error("Invalid Shoppex initial data: addons.items must be an array.");
|
|
622
|
+
}
|
|
623
|
+
return [...addons.items].sort((a, b) => a.sort_order - b.sort_order);
|
|
624
|
+
}
|
|
625
|
+
function resolveDisplayStock(product) {
|
|
626
|
+
if (product.productType === "SERIALS" || product.priceVariants.length === 0) {
|
|
627
|
+
return product.stock;
|
|
628
|
+
}
|
|
629
|
+
if (product.priceVariants.some((variant) => variant.stock === null)) return null;
|
|
630
|
+
return product.priceVariants.reduce((total, variant) => total + Math.max(variant.stock ?? 0, 0), 0);
|
|
631
|
+
}
|
|
632
|
+
function isProductSoldOut(product) {
|
|
633
|
+
return resolveDisplayStock(product) === 0;
|
|
634
|
+
}
|
|
635
|
+
function resolveVariantStock(product, variant) {
|
|
636
|
+
const useProductStock = product.priceVariants.length > 0 && (product.stock ?? 0) !== 0 && product.priceVariants.every((entry) => (entry.stock ?? 0) === 0);
|
|
637
|
+
if (useProductStock || variant.stock === null) return product.stock;
|
|
638
|
+
return variant.stock;
|
|
639
|
+
}
|
|
640
|
+
function isSelectionSoldOut(product, variant) {
|
|
641
|
+
if (variant === null) {
|
|
642
|
+
if (isProductSoldOut(product)) return true;
|
|
643
|
+
if (product.priceVariants.length > 0) return false;
|
|
644
|
+
} else if (resolveVariantStock(product, variant) === 0) {
|
|
645
|
+
return true;
|
|
646
|
+
}
|
|
647
|
+
const { min, max } = resolveQuantityBounds(product, variant);
|
|
648
|
+
return max !== null && min > max;
|
|
649
|
+
}
|
|
650
|
+
function resolveHideStockCounter(product, settings) {
|
|
651
|
+
return product.hideStockCounter ?? settings.hideStockCounter;
|
|
652
|
+
}
|
|
653
|
+
function resolveQuantityBounds(product, variant) {
|
|
654
|
+
const min = variant?.quantityMin ?? product.quantityMin;
|
|
655
|
+
const configuredMax = variant?.quantityMax ?? product.quantityMax;
|
|
656
|
+
const stockMax = variant === null ? resolveDisplayStock(product) : resolveVariantStock(product, variant);
|
|
657
|
+
const max = configuredMax === null ? stockMax : stockMax === null ? configuredMax : Math.min(configuredMax, stockMax);
|
|
658
|
+
return { min, max };
|
|
659
|
+
}
|
|
660
|
+
function parseProduct(value, index) {
|
|
661
|
+
const context = `product at index ${index}`;
|
|
662
|
+
const product = requireUnknownRecord(value, context);
|
|
663
|
+
const id = requireString(product, "uniqid", context);
|
|
664
|
+
const productId = optionalString(product.id) ?? id;
|
|
665
|
+
const discountPercent = product.pay_what_you_want === true ? 0 : normalizeDiscountPercent(product.price_discount);
|
|
666
|
+
const applyDiscount = (amount) => discountPercent > 0 ? roundCurrency(amount * (1 - discountPercent / 100)) : amount;
|
|
667
|
+
const rawVariants = parsePriceVariants(product.price_variants, context);
|
|
668
|
+
const priceVariants = rawVariants.map((variant) => ({
|
|
669
|
+
...variant,
|
|
670
|
+
baseUnitPrice: variant.price,
|
|
671
|
+
price: applyDiscount(variant.price)
|
|
672
|
+
}));
|
|
673
|
+
const basePrice = parseFiniteNumber(product.price_display ?? product.price, `${context}.price`);
|
|
674
|
+
const undiscountedPrice = rawVariants.length ? Math.min(...rawVariants.map((variant) => variant.price)) : basePrice;
|
|
675
|
+
const price = applyDiscount(undiscountedPrice);
|
|
676
|
+
const quantityMin = normalizeMinimum(product.quantity_min, 1, `${context}.quantity_min`);
|
|
677
|
+
const rating = deriveProductRating(product.feedback);
|
|
678
|
+
const productType = parseProductType(product.type);
|
|
679
|
+
return {
|
|
680
|
+
id,
|
|
681
|
+
productId,
|
|
682
|
+
productType,
|
|
683
|
+
name: requireString(product, "title", context),
|
|
684
|
+
price,
|
|
685
|
+
currency: requireString(product, "currency", context),
|
|
686
|
+
description: optionalString(product.description),
|
|
687
|
+
slug: optionalString(product.slug),
|
|
688
|
+
imageUrl: resolveProductImage(product),
|
|
689
|
+
imageUrls: parseProductImageUrls(product.images),
|
|
690
|
+
stock: applyOrderable(
|
|
691
|
+
normalizeStock(product.stock, `${context}.stock`),
|
|
692
|
+
product.orderable,
|
|
693
|
+
product.supplier_backed
|
|
694
|
+
),
|
|
695
|
+
lowStockThreshold: normalizeLowStockThreshold(
|
|
696
|
+
normalizeOptionalNonNegativeNumber(product.quantity_warning)
|
|
697
|
+
),
|
|
698
|
+
hideStockCounter: typeof product.hide_stock_counter === "boolean" ? product.hide_stock_counter : null,
|
|
699
|
+
volumeDiscounts: parseVolumeDiscounts(product.volume_discounts),
|
|
700
|
+
payWhatYouWant: product.pay_what_you_want === true,
|
|
701
|
+
discountPercent,
|
|
702
|
+
baseUnitPrice: undiscountedPrice,
|
|
703
|
+
basePrice,
|
|
704
|
+
priceVariants,
|
|
705
|
+
quantityMin,
|
|
706
|
+
quantityMax: normalizeMaximum(product.quantity_max, quantityMin, `${context}.quantity_max`),
|
|
707
|
+
soldCount: normalizeOptionalNonNegativeNumber(product.sold_count) ?? 0,
|
|
708
|
+
bestseller: product.bestseller === true,
|
|
709
|
+
badges: parseProductBadges(product.badges),
|
|
710
|
+
sortPriority: normalizeOptionalNonNegativeNumber(product.sort_priority) ?? 0,
|
|
711
|
+
createdAt: optionalString(product.created_at),
|
|
712
|
+
categories: parseCategories(product.categories),
|
|
713
|
+
bundles: parseBundles(product.bundles),
|
|
714
|
+
automaticDeals: parseAutomaticDeals(product.automatic_deals),
|
|
715
|
+
videoEmbedSrc: resolveProductVideoEmbedSrc(product.video_link),
|
|
716
|
+
addons: parseAddons(product.addons),
|
|
717
|
+
customFields: parseCustomFields(product.custom_fields, context),
|
|
718
|
+
// Strict on purpose: the serializer always emits `on_hold`, so an absent
|
|
719
|
+
// flag is the only tolerated shape (false) and a present non-boolean is a
|
|
720
|
+
// changed contract, not a paused product. Guessing here sells a product the
|
|
721
|
+
// merchant paused.
|
|
722
|
+
onHold: optionalBoolean(product.on_hold, false, `${context}.on_hold`),
|
|
723
|
+
compareAtPrice: price < undiscountedPrice ? undiscountedPrice : null,
|
|
724
|
+
rating: rating?.average ?? null,
|
|
725
|
+
ratingCount: rating?.count ?? 0,
|
|
726
|
+
ratingTally: rating?.tally ?? null,
|
|
727
|
+
highlights: parseHighlights(product.product_highlights),
|
|
728
|
+
metadata: parseMetadata(product.metadata),
|
|
729
|
+
descriptionTabs: parseContentTabs(product.description_tabs),
|
|
730
|
+
faqs: parseFaqs(product.faqs),
|
|
731
|
+
license: parseProductLicense(product),
|
|
732
|
+
licensingEnabled: product.licensing_enabled === true || product.licensing_enabled === 1,
|
|
733
|
+
deliveryTimeCount: normalizePositiveDays(product.delivery_time),
|
|
734
|
+
deliveryTimeUnit: normalizeDeliveryTimeUnit(product.delivery_time_unit),
|
|
735
|
+
subscription: parseSubscription(product, productType)
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
function readLicensePeriodDays(value) {
|
|
739
|
+
if (typeof value !== "number" && typeof value !== "string") return null;
|
|
740
|
+
const days = Number(value);
|
|
741
|
+
return Number.isInteger(days) && days > 0 ? days : null;
|
|
742
|
+
}
|
|
743
|
+
function readLicenseMode(value) {
|
|
744
|
+
return typeof value === "string" && value.trim() !== "" ? value.trim().toUpperCase() : null;
|
|
745
|
+
}
|
|
746
|
+
function licenseStatementFromMode(mode, record) {
|
|
747
|
+
if (mode === "LIFETIME") return { kind: "lifetime" };
|
|
748
|
+
if (mode !== "PERIOD") return { kind: "unresolved" };
|
|
749
|
+
const days = readLicensePeriodDays(record.license_period_days);
|
|
750
|
+
return days === null ? { kind: "unresolved" } : { kind: "period", days };
|
|
751
|
+
}
|
|
752
|
+
function parseProductLicense(record) {
|
|
753
|
+
const mode = readLicenseMode(record.license_mode);
|
|
754
|
+
if (mode !== null) return licenseStatementFromMode(mode, record);
|
|
755
|
+
const legacy = record.license_period;
|
|
756
|
+
if (typeof legacy === "string" && legacy.trim().toUpperCase() === "LIFETIME") return { kind: "lifetime" };
|
|
757
|
+
if (legacy === 0 || typeof legacy === "string" && legacy.trim() === "0") return { kind: "lifetime" };
|
|
758
|
+
const days = readLicensePeriodDays(legacy);
|
|
759
|
+
return days === null ? { kind: "unresolved" } : { kind: "period", days };
|
|
760
|
+
}
|
|
761
|
+
function parseVariantLicense(record) {
|
|
762
|
+
const mode = readLicenseMode(record.license_mode);
|
|
763
|
+
if (mode === "INHERIT") return { kind: "inherit" };
|
|
764
|
+
if (mode !== null) return licenseStatementFromMode(mode, record);
|
|
765
|
+
const legacy = record.license_period;
|
|
766
|
+
if (legacy === null || legacy === void 0) return { kind: "inherit" };
|
|
767
|
+
if (legacy === 0 || typeof legacy === "string" && legacy.trim() === "0") return { kind: "lifetime" };
|
|
768
|
+
const days = readLicensePeriodDays(legacy);
|
|
769
|
+
return days === null ? { kind: "unresolved" } : { kind: "period", days };
|
|
770
|
+
}
|
|
771
|
+
function resolveLicenseTerm(product, variant) {
|
|
772
|
+
if (!product.licensingEnabled) return null;
|
|
773
|
+
const stated = variant ? variant.license : { kind: "inherit" };
|
|
774
|
+
if (stated.kind === "lifetime" || stated.kind === "period") return stated;
|
|
775
|
+
if (stated.kind === "unresolved") return null;
|
|
776
|
+
const inherited = product.license;
|
|
777
|
+
return inherited.kind === "lifetime" || inherited.kind === "period" ? inherited : null;
|
|
778
|
+
}
|
|
779
|
+
function formatLicenseTerm(term) {
|
|
780
|
+
return term.kind === "lifetime" ? "Lifetime" : `${term.days} ${term.days === 1 ? "day" : "days"}`;
|
|
781
|
+
}
|
|
782
|
+
function parsePriceVariants(value, productContext) {
|
|
783
|
+
if (value === null || value === void 0) return [];
|
|
784
|
+
const source = Array.isArray(value) ? value : isRecord(value) && Array.isArray(value.price_variants) ? value.price_variants : null;
|
|
785
|
+
if (!source) {
|
|
786
|
+
throw new Error(`Invalid Shoppex ${productContext}.price_variants: expected an array.`);
|
|
787
|
+
}
|
|
788
|
+
return source.map((entry, index) => {
|
|
789
|
+
const context = `${productContext}.price_variants.${index}`;
|
|
790
|
+
const variant = requireUnknownRecord(entry, context);
|
|
791
|
+
const quantityMin = normalizeOptionalMinimum(variant.quantity_min, context);
|
|
792
|
+
const unitPrice = parseFiniteNumber(
|
|
793
|
+
variant.price_display ?? variant.price,
|
|
794
|
+
`${context}.price`
|
|
795
|
+
);
|
|
796
|
+
return {
|
|
797
|
+
id: requireString(variant, "id", context),
|
|
798
|
+
label: requireStringFromKeys(variant, ["label", "title"], context),
|
|
799
|
+
license: parseVariantLicense(variant),
|
|
800
|
+
price: unitPrice,
|
|
801
|
+
baseUnitPrice: unitPrice,
|
|
802
|
+
stock: applyOrderable(
|
|
803
|
+
normalizeStock(variant.stock, `${context}.stock`),
|
|
804
|
+
variant.orderable,
|
|
805
|
+
variant.supplier_backed
|
|
806
|
+
),
|
|
807
|
+
quantityMin,
|
|
808
|
+
quantityMax: normalizeMaximum(variant.quantity_max, quantityMin ?? 1, `${context}.quantity_max`),
|
|
809
|
+
imageUrl: optionalString(variant.image_url),
|
|
810
|
+
isFeatured: variant.is_featured === true || variant.isFeatured === true
|
|
811
|
+
};
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
function parseCategories(value) {
|
|
815
|
+
if (!Array.isArray(value)) return [];
|
|
816
|
+
const categories = [];
|
|
817
|
+
for (const entry of value) {
|
|
818
|
+
if (!isRecord(entry)) continue;
|
|
819
|
+
const id = optionalString(entry.uniqid ?? entry.id);
|
|
820
|
+
const title = optionalString(entry.title);
|
|
821
|
+
if (!id || !title || categories.some((category) => category.id === id)) continue;
|
|
822
|
+
categories.push({ id, title });
|
|
823
|
+
}
|
|
824
|
+
return categories;
|
|
825
|
+
}
|
|
826
|
+
function parseProductType(value) {
|
|
827
|
+
const normalized = typeof value === "string" ? value.trim().toUpperCase() : "";
|
|
828
|
+
return PRODUCT_TYPES.includes(normalized) ? normalized : "PRODUCT";
|
|
829
|
+
}
|
|
830
|
+
function parseBundles(value) {
|
|
831
|
+
if (!Array.isArray(value)) return [];
|
|
832
|
+
const bundles = [];
|
|
833
|
+
for (const entry of value) {
|
|
834
|
+
if (!isRecord(entry)) continue;
|
|
835
|
+
const rawItems = Array.isArray(entry.products) ? entry.products : [];
|
|
836
|
+
const items = [];
|
|
837
|
+
for (const rawItem of rawItems) {
|
|
838
|
+
if (!isRecord(rawItem)) continue;
|
|
839
|
+
const id = typeof rawItem.uniqid === "string" ? rawItem.uniqid.trim() : "";
|
|
840
|
+
const title = typeof rawItem.title === "string" ? rawItem.title.trim() : "";
|
|
841
|
+
if (!id || !title) continue;
|
|
842
|
+
const unitPrice = Number(rawItem.unit_price);
|
|
843
|
+
items.push({
|
|
844
|
+
id,
|
|
845
|
+
title,
|
|
846
|
+
variantId: typeof rawItem.default_variant_id === "string" && rawItem.default_variant_id ? rawItem.default_variant_id : "default",
|
|
847
|
+
unitPrice: Number.isFinite(unitPrice) && unitPrice >= 0 ? unitPrice : 0,
|
|
848
|
+
imageUrl: optionalString(rawItem.image_url),
|
|
849
|
+
variantTitle: optionalString(rawItem.variant_title)
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
if (items.length < 2) continue;
|
|
853
|
+
const amount = Number(entry.discount_amount);
|
|
854
|
+
bundles.push({
|
|
855
|
+
id: typeof entry.uniqid === "string" && entry.uniqid ? entry.uniqid : String(entry.id ?? ""),
|
|
856
|
+
title: typeof entry.title === "string" && entry.title.trim() ? entry.title.trim() : "Bundle offer",
|
|
857
|
+
discountType: entry.discount_type === "FIXED" ? "FIXED" : "PERCENTAGE",
|
|
858
|
+
discountAmount: Number.isFinite(amount) && amount > 0 ? amount : 0,
|
|
859
|
+
items
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
return bundles;
|
|
863
|
+
}
|
|
864
|
+
function parseAddons(value) {
|
|
865
|
+
if (!Array.isArray(value)) return [];
|
|
866
|
+
const addons = [];
|
|
867
|
+
for (const entry of value) {
|
|
868
|
+
if (!isRecord(entry)) continue;
|
|
869
|
+
const id = optionalString(entry.id);
|
|
870
|
+
const title = optionalString(entry.title ?? entry.name);
|
|
871
|
+
if (!id || !title || addons.some((addon) => addon.id === id)) continue;
|
|
872
|
+
const price = Number(entry.price);
|
|
873
|
+
addons.push({
|
|
874
|
+
id,
|
|
875
|
+
title,
|
|
876
|
+
price: Number.isFinite(price) && price > 0 ? price : 0,
|
|
877
|
+
required: entry.required === true
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
return addons;
|
|
881
|
+
}
|
|
882
|
+
function parseProductImageUrls(value) {
|
|
883
|
+
if (!Array.isArray(value)) return [];
|
|
884
|
+
const urls = [];
|
|
885
|
+
for (const entry of value) {
|
|
886
|
+
const url = typeof entry === "string" ? optionalString(entry) : isRecord(entry) ? optionalString(entry.url) : null;
|
|
887
|
+
if (url && !urls.includes(url)) urls.push(url);
|
|
888
|
+
}
|
|
889
|
+
return urls;
|
|
890
|
+
}
|
|
891
|
+
function parseMetadata(value) {
|
|
892
|
+
if (!isRecord(value)) return {};
|
|
893
|
+
const result = {};
|
|
894
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
895
|
+
if (typeof entry === "string") result[key] = entry;
|
|
896
|
+
}
|
|
897
|
+
return result;
|
|
898
|
+
}
|
|
899
|
+
function parseHighlights(value) {
|
|
900
|
+
if (!Array.isArray(value)) return [];
|
|
901
|
+
return value.map(
|
|
902
|
+
(entry) => typeof entry === "string" ? entry.trim() : isRecord(entry) ? optionalString(entry.text ?? entry.title ?? entry.label) : null
|
|
903
|
+
).filter((entry) => Boolean(entry));
|
|
904
|
+
}
|
|
905
|
+
function parseContentTabs(value) {
|
|
906
|
+
if (!Array.isArray(value)) return [];
|
|
907
|
+
const tabs = [];
|
|
908
|
+
for (const entry of value) {
|
|
909
|
+
if (!isRecord(entry)) continue;
|
|
910
|
+
const title = optionalString(entry.title);
|
|
911
|
+
const html = optionalString(entry.content ?? entry.html ?? entry.contentHtml);
|
|
912
|
+
if (title && html) tabs.push({ title, html });
|
|
913
|
+
}
|
|
914
|
+
return tabs;
|
|
915
|
+
}
|
|
916
|
+
function parseCustomFields(value, context) {
|
|
917
|
+
const source = readCustomFieldList(value, context);
|
|
918
|
+
const fields = [];
|
|
919
|
+
for (const entry of source) {
|
|
920
|
+
if (!isRecord(entry)) continue;
|
|
921
|
+
const name = optionalString(entry.name);
|
|
922
|
+
if (!name) continue;
|
|
923
|
+
const rawType = typeof entry.type === "string" ? entry.type.trim().toLowerCase() : "";
|
|
924
|
+
const type = rawType || "text";
|
|
925
|
+
if (type === "hidden") continue;
|
|
926
|
+
const defaultValue = firstOptionalString(entry, ["default_value", "default", "value"]);
|
|
927
|
+
fields.push({
|
|
928
|
+
name,
|
|
929
|
+
type,
|
|
930
|
+
// The dashboard has stored this flag as a boolean, a number and a string
|
|
931
|
+
// over time; the SDK accepts all three, and reading only one of them
|
|
932
|
+
// would turn a required field into an optional one — which is exactly the
|
|
933
|
+
// failure that lets a product reach checkout without its values.
|
|
934
|
+
required: entry.required === true || entry.required === 1 || entry.required === "1" || entry.required === "true",
|
|
935
|
+
defaultValue: defaultValue ?? "",
|
|
936
|
+
placeholder: typeof entry.placeholder === "string" ? entry.placeholder : "",
|
|
937
|
+
regex: optionalString(entry.regex)
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
return fields;
|
|
941
|
+
}
|
|
942
|
+
function readCustomFieldList(value, context) {
|
|
943
|
+
if (Array.isArray(value)) return value;
|
|
944
|
+
if (typeof value === "string") {
|
|
945
|
+
const text = value.trim();
|
|
946
|
+
if (!text) return [];
|
|
947
|
+
try {
|
|
948
|
+
return readCustomFieldList(JSON.parse(text), context);
|
|
949
|
+
} catch {
|
|
950
|
+
throw new Error(`Invalid Shoppex ${context}.custom_fields: expected valid JSON.`);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
if (isRecord(value)) {
|
|
954
|
+
const fields = value.custom_fields ?? value.customFields;
|
|
955
|
+
return Array.isArray(fields) ? fields : [];
|
|
956
|
+
}
|
|
957
|
+
return [];
|
|
958
|
+
}
|
|
959
|
+
function parseAutomaticDeals(value) {
|
|
960
|
+
if (!Array.isArray(value)) return [];
|
|
961
|
+
const deals = [];
|
|
962
|
+
for (const entry of value) {
|
|
963
|
+
if (!isRecord(entry)) continue;
|
|
964
|
+
const requiredQuantity = Number(entry.required_quantity);
|
|
965
|
+
const discountedQuantity = Number(entry.discounted_quantity);
|
|
966
|
+
if (!Number.isInteger(requiredQuantity) || requiredQuantity < 1 || !Number.isInteger(discountedQuantity) || discountedQuantity < 1) {
|
|
967
|
+
continue;
|
|
968
|
+
}
|
|
969
|
+
const isFree = optionalString(entry.discount_type)?.toUpperCase() === "FREE";
|
|
970
|
+
const discountValue = Number(entry.discount_value);
|
|
971
|
+
if (!isFree && !(Number.isFinite(discountValue) && discountValue > 0)) continue;
|
|
972
|
+
const cap = Number(entry.max_applications_per_cart);
|
|
973
|
+
deals.push({
|
|
974
|
+
id: optionalString(entry.uniqid) ?? `deal-${deals.length}`,
|
|
975
|
+
title: optionalString(entry.title) ?? "",
|
|
976
|
+
requiredQuantity,
|
|
977
|
+
discountedQuantity,
|
|
978
|
+
discountType: isFree ? "FREE" : "PERCENTAGE",
|
|
979
|
+
discountValue: isFree ? 0 : discountValue,
|
|
980
|
+
maxApplicationsPerCart: Number.isInteger(cap) && cap > 0 ? cap : null,
|
|
981
|
+
endsAt: optionalString(entry.ends_at)
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
return deals;
|
|
985
|
+
}
|
|
986
|
+
function parseFaqs(value) {
|
|
987
|
+
if (!Array.isArray(value)) return [];
|
|
988
|
+
const faqs = [];
|
|
989
|
+
for (const entry of value) {
|
|
990
|
+
if (!isRecord(entry)) continue;
|
|
991
|
+
const question = optionalString(entry.question);
|
|
992
|
+
const answer = optionalString(entry.answer);
|
|
993
|
+
if (question && answer) faqs.push({ question, answer });
|
|
994
|
+
}
|
|
995
|
+
return faqs;
|
|
996
|
+
}
|
|
997
|
+
function parseVolumeDiscounts(value) {
|
|
998
|
+
const source = Array.isArray(value) ? value : isRecord(value) && Array.isArray(value.volume_discounts) ? value.volume_discounts : [];
|
|
999
|
+
const rules = [];
|
|
1000
|
+
for (const entry of source) {
|
|
1001
|
+
if (!isRecord(entry)) continue;
|
|
1002
|
+
const quantity = Number(entry.quantity ?? entry.min_quantity);
|
|
1003
|
+
const amount = Number(entry.value ?? entry.discount ?? entry.amount);
|
|
1004
|
+
const typeRaw = String(entry.type ?? entry.discount_type ?? "").toUpperCase();
|
|
1005
|
+
const type = typeRaw.includes("PERCENT") ? "PERCENTAGE" : typeRaw.includes("FIX") || typeRaw.includes("AMOUNT") ? "FIXED" : null;
|
|
1006
|
+
if (!Number.isFinite(quantity) || quantity < 1 || !Number.isFinite(amount) || amount <= 0 || !type) {
|
|
1007
|
+
continue;
|
|
1008
|
+
}
|
|
1009
|
+
rules.push({ quantity: Math.floor(quantity), type, value: amount });
|
|
1010
|
+
}
|
|
1011
|
+
return rules.sort((a, b) => a.quantity - b.quantity);
|
|
1012
|
+
}
|
|
1013
|
+
function toCents(value) {
|
|
1014
|
+
return Math.round(Number((value * 100).toPrecision(15)));
|
|
1015
|
+
}
|
|
1016
|
+
function roundCurrency(value) {
|
|
1017
|
+
return toCents(value) / 100;
|
|
1018
|
+
}
|
|
1019
|
+
function calculateDisplayLineTotal(baseUnitPrice, quantity, discountPercent, rules) {
|
|
1020
|
+
const subtotalCents = toCents(baseUnitPrice) * quantity;
|
|
1021
|
+
const afterPercentCents = discountPercent > 0 ? subtotalCents * (1 - discountPercent / 100) : subtotalCents;
|
|
1022
|
+
return applyVolumeDiscountCents(afterPercentCents, quantity, rules) / 100;
|
|
1023
|
+
}
|
|
1024
|
+
function applyVolumeDiscountCents(subtotalCents, quantity, rules) {
|
|
1025
|
+
let matched = null;
|
|
1026
|
+
for (const rule of rules) {
|
|
1027
|
+
if (quantity >= rule.quantity) matched = rule;
|
|
1028
|
+
}
|
|
1029
|
+
if (!matched) return Math.max(0, Math.round(subtotalCents));
|
|
1030
|
+
const discountCents = matched.type === "PERCENTAGE" ? subtotalCents * Math.min(Math.max(matched.value, 0), 100) / 100 : toCents(matched.value);
|
|
1031
|
+
return Math.max(0, Math.round(subtotalCents - Math.min(discountCents, subtotalCents)));
|
|
1032
|
+
}
|
|
1033
|
+
function deriveProductRating(value) {
|
|
1034
|
+
if (!isRecord(value)) return null;
|
|
1035
|
+
const total = Number(value.total);
|
|
1036
|
+
if (!Number.isFinite(total) || total <= 0) return null;
|
|
1037
|
+
const positive = Number(value.positive) || 0;
|
|
1038
|
+
const neutral = Number(value.neutral) || 0;
|
|
1039
|
+
const negative = Number(value.negative) || 0;
|
|
1040
|
+
const counted = positive + neutral + negative;
|
|
1041
|
+
if (counted <= 0) return null;
|
|
1042
|
+
const average = (positive * 5 + neutral * 3 + negative * 1) / counted;
|
|
1043
|
+
return { average: Math.round(average * 10) / 10, count: counted, tally: { positive, neutral, negative } };
|
|
1044
|
+
}
|
|
1045
|
+
function resolveProductImage(product) {
|
|
1046
|
+
const direct = firstOptionalString(product, [
|
|
1047
|
+
"image_url",
|
|
1048
|
+
"imageUrl",
|
|
1049
|
+
"cdn_image_url",
|
|
1050
|
+
"detail_image_url",
|
|
1051
|
+
"thumbnail_url",
|
|
1052
|
+
"thumbnailUrl"
|
|
1053
|
+
]);
|
|
1054
|
+
if (direct) return direct;
|
|
1055
|
+
if (!Array.isArray(product.images)) return null;
|
|
1056
|
+
for (const image of product.images) {
|
|
1057
|
+
if (!isRecord(image)) continue;
|
|
1058
|
+
const url = optionalString(image.url);
|
|
1059
|
+
if (url) return url;
|
|
1060
|
+
}
|
|
1061
|
+
return null;
|
|
1062
|
+
}
|
|
1063
|
+
function normalizeStock(value, context) {
|
|
1064
|
+
if (value === null || value === void 0 || value === "") return null;
|
|
1065
|
+
const stock = parseFiniteNumber(value, context);
|
|
1066
|
+
return stock < 0 ? null : Math.floor(stock);
|
|
1067
|
+
}
|
|
1068
|
+
function applyOrderable(stock, orderable, supplierBacked) {
|
|
1069
|
+
if (orderable !== true) return stock;
|
|
1070
|
+
if (supplierBacked === true) return null;
|
|
1071
|
+
return stock === 0 ? null : stock;
|
|
1072
|
+
}
|
|
1073
|
+
function normalizeOptionalNonNegativeNumber(value) {
|
|
1074
|
+
if (value === null || value === void 0 || value === "") return null;
|
|
1075
|
+
const parsed = Number(value);
|
|
1076
|
+
return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : null;
|
|
1077
|
+
}
|
|
1078
|
+
function normalizeMinimum(value, fallback, context) {
|
|
1079
|
+
if (value === null || value === void 0 || value === "") return fallback;
|
|
1080
|
+
const minimum = Math.floor(parseFiniteNumber(value, context));
|
|
1081
|
+
if (minimum < 1) {
|
|
1082
|
+
throw new Error(`Invalid Shoppex ${context}: expected a positive integer.`);
|
|
1083
|
+
}
|
|
1084
|
+
return minimum;
|
|
1085
|
+
}
|
|
1086
|
+
function normalizeOptionalMinimum(value, context) {
|
|
1087
|
+
if (value === null || value === void 0 || value === "") return null;
|
|
1088
|
+
return normalizeMinimum(value, 1, `${context}.quantity_min`);
|
|
1089
|
+
}
|
|
1090
|
+
function normalizeMaximum(value, minimum, context) {
|
|
1091
|
+
if (value === null || value === void 0 || value === "") return null;
|
|
1092
|
+
const maximum = Math.floor(parseFiniteNumber(value, context));
|
|
1093
|
+
if (maximum < 0) return null;
|
|
1094
|
+
if (maximum < minimum) {
|
|
1095
|
+
throw new Error(`Invalid Shoppex ${context}: maximum cannot be below minimum.`);
|
|
1096
|
+
}
|
|
1097
|
+
return maximum;
|
|
1098
|
+
}
|
|
1099
|
+
function normalizeLowStockThreshold(value) {
|
|
1100
|
+
return value !== null && value > 0 ? value : null;
|
|
1101
|
+
}
|
|
1102
|
+
function normalizeDiscountPercent(value) {
|
|
1103
|
+
if (value === null || value === void 0 || value === "") return 0;
|
|
1104
|
+
const percent = Number(value);
|
|
1105
|
+
if (!Number.isFinite(percent) || percent <= 0) return 0;
|
|
1106
|
+
return Math.min(percent, 100);
|
|
1107
|
+
}
|
|
1108
|
+
function normalizePositiveDays(value) {
|
|
1109
|
+
if (value === null || value === void 0 || value === "") return null;
|
|
1110
|
+
const parsed = Number(value);
|
|
1111
|
+
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null;
|
|
1112
|
+
}
|
|
1113
|
+
var DELIVERY_TIME_UNITS = ["minutes", "hours", "days"];
|
|
1114
|
+
function normalizeDeliveryTimeUnit(value) {
|
|
1115
|
+
return DELIVERY_TIME_UNITS.includes(value) ? value : "days";
|
|
1116
|
+
}
|
|
1117
|
+
function formatDeliveryTime(count, unit) {
|
|
1118
|
+
return `${count} ${count === 1 ? unit.slice(0, -1) : unit}`;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// src/checkout-fields.ts
|
|
1122
|
+
var NO_FIELDS = { resolved: true, groups: [], hasRequired: false };
|
|
1123
|
+
function resolveCheckoutFields(lines) {
|
|
1124
|
+
if (lines.length === 0) return NO_FIELDS;
|
|
1125
|
+
const products = getProducts();
|
|
1126
|
+
const groups = [];
|
|
1127
|
+
const unresolvedProductIds = [];
|
|
1128
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1129
|
+
for (const line of lines) {
|
|
1130
|
+
const productId = line.product_id;
|
|
1131
|
+
if (seen.has(productId)) continue;
|
|
1132
|
+
seen.add(productId);
|
|
1133
|
+
const product = products.find((candidate) => candidate.id === productId);
|
|
1134
|
+
if (product === void 0) {
|
|
1135
|
+
unresolvedProductIds.push(productId);
|
|
1136
|
+
continue;
|
|
1137
|
+
}
|
|
1138
|
+
if (product.customFields.length === 0) continue;
|
|
1139
|
+
groups.push({ productId, productName: product.name, fields: product.customFields });
|
|
1140
|
+
}
|
|
1141
|
+
if (unresolvedProductIds.length > 0) return { resolved: false, unresolvedProductIds };
|
|
1142
|
+
return {
|
|
1143
|
+
resolved: true,
|
|
1144
|
+
groups,
|
|
1145
|
+
hasRequired: groups.some((group) => group.fields.some((field) => field.required))
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
function checkoutNeedsDetails(fields) {
|
|
1149
|
+
return !fields.resolved || fields.hasRequired;
|
|
1150
|
+
}
|
|
1151
|
+
function buildInitialCheckoutFieldValues(groups, lines) {
|
|
1152
|
+
const values = {};
|
|
1153
|
+
for (const group of groups) {
|
|
1154
|
+
const stored = lines.find((line) => line.product_id === group.productId)?.custom_fields ?? {};
|
|
1155
|
+
const entries = {};
|
|
1156
|
+
for (const field of group.fields) {
|
|
1157
|
+
const value = stored[field.name];
|
|
1158
|
+
entries[field.name] = typeof value === "string" && value !== "" ? value : field.defaultValue;
|
|
1159
|
+
}
|
|
1160
|
+
values[group.productId] = entries;
|
|
1161
|
+
}
|
|
1162
|
+
return values;
|
|
1163
|
+
}
|
|
1164
|
+
function isCheckboxValueChecked(value) {
|
|
1165
|
+
const normalized = value?.trim().toLowerCase() ?? "";
|
|
1166
|
+
return normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on";
|
|
1167
|
+
}
|
|
1168
|
+
function validateCheckoutFieldValue(field, value) {
|
|
1169
|
+
if (field.type === "checkbox") {
|
|
1170
|
+
return field.required && !isCheckboxValueChecked(value) ? `${field.name} is required.` : null;
|
|
1171
|
+
}
|
|
1172
|
+
const normalized = value.trim();
|
|
1173
|
+
if (field.required && !normalized) return `${field.name} is required.`;
|
|
1174
|
+
if (field.regex && normalized) {
|
|
1175
|
+
let pattern;
|
|
1176
|
+
try {
|
|
1177
|
+
pattern = new RegExp(field.regex);
|
|
1178
|
+
} catch {
|
|
1179
|
+
return null;
|
|
1180
|
+
}
|
|
1181
|
+
if (!pattern.test(normalized)) return `${field.name} has an invalid format.`;
|
|
1182
|
+
}
|
|
1183
|
+
return null;
|
|
1184
|
+
}
|
|
1185
|
+
function validateCheckoutFields(groups, values) {
|
|
1186
|
+
const errors = {};
|
|
1187
|
+
for (const group of groups) {
|
|
1188
|
+
for (const field of group.fields) {
|
|
1189
|
+
const message = validateCheckoutFieldValue(
|
|
1190
|
+
field,
|
|
1191
|
+
values[group.productId]?.[field.name] ?? ""
|
|
1192
|
+
);
|
|
1193
|
+
if (message) errors[checkoutFieldKey(group.productId, field.name)] = message;
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
return errors;
|
|
1197
|
+
}
|
|
1198
|
+
function checkoutFieldKey(productId, fieldName) {
|
|
1199
|
+
return `${productId}.${fieldName}`;
|
|
1200
|
+
}
|
|
1201
|
+
function buildCustomFieldPayload(fields, values) {
|
|
1202
|
+
const payload = {};
|
|
1203
|
+
for (const field of fields) {
|
|
1204
|
+
const raw = values?.[field.name] ?? field.defaultValue;
|
|
1205
|
+
if (field.type === "checkbox") {
|
|
1206
|
+
if (isCheckboxValueChecked(raw)) payload[field.name] = "true";
|
|
1207
|
+
continue;
|
|
1208
|
+
}
|
|
1209
|
+
const normalized = raw.trim();
|
|
1210
|
+
if (normalized) payload[field.name] = normalized;
|
|
1211
|
+
}
|
|
1212
|
+
return payload;
|
|
1213
|
+
}
|
|
1214
|
+
function missingRequiredCheckoutValues(groups, lines) {
|
|
1215
|
+
const messages = [];
|
|
1216
|
+
for (const group of groups) {
|
|
1217
|
+
const required = group.fields.filter((field) => field.required);
|
|
1218
|
+
if (required.length === 0) continue;
|
|
1219
|
+
for (const line of lines) {
|
|
1220
|
+
if (line.product_id !== group.productId) continue;
|
|
1221
|
+
for (const field of required) {
|
|
1222
|
+
const message = validateCheckoutFieldValue(field, line.custom_fields?.[field.name] ?? "");
|
|
1223
|
+
if (message && !messages.includes(message)) messages.push(message);
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
return messages;
|
|
1228
|
+
}
|
|
1229
|
+
function checkoutFieldWriteRepriced(before, after) {
|
|
1230
|
+
const shape = (lines) => lines.map((line) => `${line.product_id} ${line.variant_id} ${line.quantity}`).sort().join("");
|
|
1231
|
+
return shape(before) !== shape(after);
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
// src/cart-quote-bounds.ts
|
|
1235
|
+
function readOptionalCount(value) {
|
|
1236
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return null;
|
|
1237
|
+
return Math.floor(value);
|
|
1238
|
+
}
|
|
1239
|
+
function parseCartQuoteLines(raw) {
|
|
1240
|
+
if (!Array.isArray(raw)) return [];
|
|
1241
|
+
const parsed = [];
|
|
1242
|
+
for (const entry of raw) {
|
|
1243
|
+
const row = entry && typeof entry === "object" ? entry : {};
|
|
1244
|
+
const productId = typeof row.product_id === "string" ? row.product_id : "";
|
|
1245
|
+
if (!productId) continue;
|
|
1246
|
+
parsed.push({
|
|
1247
|
+
productId,
|
|
1248
|
+
variantId: typeof row.variant_id === "string" && row.variant_id ? row.variant_id : "default",
|
|
1249
|
+
quantity: typeof row.quantity === "number" && Number.isFinite(row.quantity) ? row.quantity : 0,
|
|
1250
|
+
minQuantity: readOptionalCount(row.min_quantity),
|
|
1251
|
+
maxQuantity: readOptionalCount(row.max_quantity),
|
|
1252
|
+
// Fail closed on a malformed row: only an explicit `true` is sellable. A
|
|
1253
|
+
// row this runtime could not read must not re-enable a refused line.
|
|
1254
|
+
sellable: row.sellable === true
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
return parsed;
|
|
1258
|
+
}
|
|
1259
|
+
function matchesOption(line, quoted) {
|
|
1260
|
+
const variantId = line.variant_id ? line.variant_id : "default";
|
|
1261
|
+
return line.product_id === quoted.productId && variantId === quoted.variantId;
|
|
1262
|
+
}
|
|
1263
|
+
function matchesQuotedLine(line, quoted) {
|
|
1264
|
+
if (!matchesOption(line, quoted)) return false;
|
|
1265
|
+
const quantity = Number.isFinite(line.quantity) ? Math.floor(line.quantity) : null;
|
|
1266
|
+
return quantity !== null && quantity === quoted.quantity;
|
|
1267
|
+
}
|
|
1268
|
+
function resolveCartBoundsHeals(lines, quoted) {
|
|
1269
|
+
const heals = [];
|
|
1270
|
+
for (const line of lines) {
|
|
1271
|
+
const lineId = typeof line.line_id === "string" ? line.line_id : "";
|
|
1272
|
+
if (!lineId) continue;
|
|
1273
|
+
const match = quoted.find((entry) => matchesQuotedLine(line, entry));
|
|
1274
|
+
if (!match) continue;
|
|
1275
|
+
const nextMin = match.minQuantity !== null && match.minQuantity > 1 ? match.minQuantity : null;
|
|
1276
|
+
const nextMax = match.maxQuantity;
|
|
1277
|
+
const storedMin = readOptionalCount(line.min_quantity);
|
|
1278
|
+
const storedMax = readOptionalCount(line.max_quantity);
|
|
1279
|
+
const currentMin = storedMin !== null && storedMin > 1 ? storedMin : null;
|
|
1280
|
+
if (currentMin === nextMin && storedMax === nextMax) continue;
|
|
1281
|
+
heals.push({ lineId, minQuantity: nextMin, maxQuantity: nextMax });
|
|
1282
|
+
}
|
|
1283
|
+
return heals;
|
|
1284
|
+
}
|
|
1285
|
+
function resolveUnsellableLineIds(lines, quoted) {
|
|
1286
|
+
const ids = [];
|
|
1287
|
+
for (const line of lines) {
|
|
1288
|
+
const lineId = typeof line.line_id === "string" ? line.line_id : "";
|
|
1289
|
+
if (!lineId) continue;
|
|
1290
|
+
const match = quoted.find((entry) => matchesOption(line, entry));
|
|
1291
|
+
if (match && !match.sellable) ids.push(lineId);
|
|
1292
|
+
}
|
|
1293
|
+
return ids;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
// src/checkout-error-state.ts
|
|
1297
|
+
var QUOTE_UNAVAILABLE_CHECKOUT_ERROR = "Cart pricing is unavailable, so this order cannot be priced yet. Try again in a moment.";
|
|
1298
|
+
function clearRecoveredQuoteCheckoutError(error) {
|
|
1299
|
+
return error === QUOTE_UNAVAILABLE_CHECKOUT_ERROR ? null : error;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
// src/sanitize-html.ts
|
|
1303
|
+
import DOMPurify from "dompurify";
|
|
1304
|
+
var HEX_COLOR = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
|
1305
|
+
var TEXT_ALIGN = /^(left|right|center|justify)$/;
|
|
1306
|
+
var CDN_IMAGE_SRC = /^https:\/\/imagedelivery\.net\/[^/]+\/[^/]+\/[^/?#]+(?:\?.*)?$/i;
|
|
1307
|
+
function filterStyle(style) {
|
|
1308
|
+
const allowed = [];
|
|
1309
|
+
for (const declaration of style.split(";")) {
|
|
1310
|
+
const [rawProperty, rawValue] = declaration.split(":");
|
|
1311
|
+
if (!rawProperty || !rawValue) continue;
|
|
1312
|
+
const property = rawProperty.trim().toLowerCase();
|
|
1313
|
+
const value = rawValue.trim();
|
|
1314
|
+
if ((property === "color" || property === "background-color") && HEX_COLOR.test(value)) {
|
|
1315
|
+
allowed.push(`${property}: ${value}`);
|
|
1316
|
+
continue;
|
|
1317
|
+
}
|
|
1318
|
+
if (property === "text-align" && TEXT_ALIGN.test(value.toLowerCase())) {
|
|
1319
|
+
allowed.push(`${property}: ${value.toLowerCase()}`);
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
return allowed.join("; ");
|
|
1323
|
+
}
|
|
1324
|
+
var ALLOWED_TAGS = [
|
|
1325
|
+
"h1",
|
|
1326
|
+
"h2",
|
|
1327
|
+
"h3",
|
|
1328
|
+
"p",
|
|
1329
|
+
"br",
|
|
1330
|
+
"hr",
|
|
1331
|
+
"ul",
|
|
1332
|
+
"ol",
|
|
1333
|
+
"li",
|
|
1334
|
+
"details",
|
|
1335
|
+
"summary",
|
|
1336
|
+
"strong",
|
|
1337
|
+
"em",
|
|
1338
|
+
"u",
|
|
1339
|
+
"s",
|
|
1340
|
+
"del",
|
|
1341
|
+
"code",
|
|
1342
|
+
"pre",
|
|
1343
|
+
"blockquote",
|
|
1344
|
+
"a",
|
|
1345
|
+
"mark",
|
|
1346
|
+
"span",
|
|
1347
|
+
"img",
|
|
1348
|
+
"table",
|
|
1349
|
+
"thead",
|
|
1350
|
+
"tbody",
|
|
1351
|
+
"tr",
|
|
1352
|
+
"th",
|
|
1353
|
+
"td"
|
|
1354
|
+
];
|
|
1355
|
+
var ALLOWED_ATTR = [
|
|
1356
|
+
"href",
|
|
1357
|
+
"target",
|
|
1358
|
+
"rel",
|
|
1359
|
+
"class",
|
|
1360
|
+
"style",
|
|
1361
|
+
"data-color",
|
|
1362
|
+
"data-type",
|
|
1363
|
+
"colspan",
|
|
1364
|
+
"rowspan",
|
|
1365
|
+
"src",
|
|
1366
|
+
"alt",
|
|
1367
|
+
"title",
|
|
1368
|
+
"width",
|
|
1369
|
+
"height",
|
|
1370
|
+
"loading",
|
|
1371
|
+
"open"
|
|
1372
|
+
];
|
|
1373
|
+
function sanitizeMerchantHtml(value) {
|
|
1374
|
+
if (typeof value !== "string" || !value) return "";
|
|
1375
|
+
const hook = (node, data) => {
|
|
1376
|
+
if (data.attrName === "data-color") {
|
|
1377
|
+
if (typeof data.attrValue !== "string" || !HEX_COLOR.test(data.attrValue)) {
|
|
1378
|
+
data.keepAttr = false;
|
|
1379
|
+
}
|
|
1380
|
+
return;
|
|
1381
|
+
}
|
|
1382
|
+
if (data.attrName === "src") {
|
|
1383
|
+
const isImage = typeof node === "object" && node !== null && "tagName" in node && String(node.tagName).toUpperCase() === "IMG";
|
|
1384
|
+
if (isImage && (typeof data.attrValue !== "string" || !CDN_IMAGE_SRC.test(data.attrValue))) {
|
|
1385
|
+
data.keepAttr = false;
|
|
1386
|
+
}
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1389
|
+
if (data.attrName !== "style" || typeof data.attrValue !== "string") return;
|
|
1390
|
+
const filtered = filterStyle(data.attrValue);
|
|
1391
|
+
if (!filtered) {
|
|
1392
|
+
data.keepAttr = false;
|
|
1393
|
+
return;
|
|
1394
|
+
}
|
|
1395
|
+
data.attrValue = filtered;
|
|
1396
|
+
};
|
|
1397
|
+
DOMPurify.addHook("uponSanitizeAttribute", hook);
|
|
1398
|
+
try {
|
|
1399
|
+
return DOMPurify.sanitize(value, { ALLOWED_TAGS, ALLOWED_ATTR });
|
|
1400
|
+
} finally {
|
|
1401
|
+
DOMPurify.removeHook("uponSanitizeAttribute", hook);
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
var BLOCK_BOUNDARY_SELECTOR = "address,article,aside,blockquote,br,dd,div,dl,dt,figcaption,figure,footer,h1,h2,h3,h4,h5,h6,header,hr,li,main,nav,ol,p,pre,section,table,tbody,td,tfoot,th,thead,tr,ul";
|
|
1405
|
+
function flattenMerchantHtmlToText(value) {
|
|
1406
|
+
const sanitized = sanitizeMerchantHtml(value);
|
|
1407
|
+
if (!sanitized) return "";
|
|
1408
|
+
const container = document.createElement("div");
|
|
1409
|
+
container.innerHTML = sanitized;
|
|
1410
|
+
for (const element of container.querySelectorAll(BLOCK_BOUNDARY_SELECTOR)) {
|
|
1411
|
+
element.insertAdjacentText("beforebegin", " ");
|
|
1412
|
+
element.insertAdjacentText("afterend", " ");
|
|
1413
|
+
}
|
|
1414
|
+
return (container.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
1415
|
+
}
|
|
1416
|
+
function stripMerchantHtmlImages(html) {
|
|
1417
|
+
return html.replace(/<img\b[^>]*>/gi, "").trim();
|
|
1418
|
+
}
|
|
1419
|
+
function isBlankMerchantHtml(html) {
|
|
1420
|
+
if (!html) return true;
|
|
1421
|
+
const container = document.createElement("div");
|
|
1422
|
+
container.innerHTML = html;
|
|
1423
|
+
return (container.textContent ?? "").trim().length === 0 && container.querySelector("img, hr, table") === null;
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
// src/catalog.ts
|
|
1427
|
+
function formatMoney(amount, currency) {
|
|
1428
|
+
const code = currency && currency !== "AUTO" ? currency : "USD";
|
|
1429
|
+
let format;
|
|
1430
|
+
try {
|
|
1431
|
+
format = new Intl.NumberFormat("en-US", { style: "currency", currency: code });
|
|
1432
|
+
} catch {
|
|
1433
|
+
const fixed = amount.toFixed(2);
|
|
1434
|
+
return `${code} ${fixed.endsWith(".00") ? fixed.slice(0, -3) : fixed}`;
|
|
1435
|
+
}
|
|
1436
|
+
const decimals = format.resolvedOptions().maximumFractionDigits ?? 2;
|
|
1437
|
+
if (decimals === 0 || !Number.isInteger(Number(amount.toFixed(decimals)))) return format.format(amount);
|
|
1438
|
+
return new Intl.NumberFormat("en-US", {
|
|
1439
|
+
style: "currency",
|
|
1440
|
+
currency: code,
|
|
1441
|
+
minimumFractionDigits: 0,
|
|
1442
|
+
maximumFractionDigits: 0
|
|
1443
|
+
}).format(Math.round(amount));
|
|
1444
|
+
}
|
|
1445
|
+
function formatCompactCount(value) {
|
|
1446
|
+
if (value >= 1e6) return `${(value / 1e6).toFixed(1)}M`;
|
|
1447
|
+
if (value >= 1e3) return `${(value / 1e3).toFixed(value >= 1e4 ? 0 : 1)}k`;
|
|
1448
|
+
return value.toLocaleString("en-US");
|
|
1449
|
+
}
|
|
1450
|
+
var DEFAULT_LOW_STOCK_THRESHOLD = 10;
|
|
1451
|
+
function resolveStockChip(stock, hideCounter, lowStockThreshold) {
|
|
1452
|
+
if (stock === 0) return { tone: "sold_out", label: "Out of Stock" };
|
|
1453
|
+
if (hideCounter || stock === null) return { tone: "in_stock", label: "In Stock" };
|
|
1454
|
+
const threshold = lowStockThreshold ?? DEFAULT_LOW_STOCK_THRESHOLD;
|
|
1455
|
+
return { tone: stock <= threshold ? "low_stock" : "in_stock", label: `${stock} In Stock` };
|
|
1456
|
+
}
|
|
1457
|
+
function resolveBadgeLabel(product) {
|
|
1458
|
+
if (product.productType === "INFO_CARD") return "Info";
|
|
1459
|
+
if (product.productType === "SUBSCRIPTION") return "Sub";
|
|
1460
|
+
if (product.priceVariants.length > 1) return "Options";
|
|
1461
|
+
return null;
|
|
1462
|
+
}
|
|
1463
|
+
function resolvePriceLabel(product) {
|
|
1464
|
+
const variantPrices = product.priceVariants.map((variant) => variant.price);
|
|
1465
|
+
const positive = variantPrices.filter((price) => price > 0);
|
|
1466
|
+
const candidates = positive.length > 0 ? positive : variantPrices;
|
|
1467
|
+
if (product.basePrice <= 0 && candidates.length >= 2) {
|
|
1468
|
+
const min = Math.min(...candidates);
|
|
1469
|
+
const max = Math.max(...candidates);
|
|
1470
|
+
if (max > min) {
|
|
1471
|
+
return `${formatMoney(min, product.currency)} - ${formatMoney(max, product.currency)}`;
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
return formatMoney(product.price, product.currency);
|
|
1475
|
+
}
|
|
1476
|
+
function buildProductCard(product, settings, sortPriority) {
|
|
1477
|
+
const soldOut = isProductSoldOut(product);
|
|
1478
|
+
const isInfoCard = product.productType === "INFO_CARD";
|
|
1479
|
+
const stock = resolveDisplayStock(product);
|
|
1480
|
+
const hideCounter = resolveHideStockCounter(product, settings);
|
|
1481
|
+
return {
|
|
1482
|
+
kind: "product",
|
|
1483
|
+
key: product.id,
|
|
1484
|
+
product,
|
|
1485
|
+
href: `/product/${product.slug ?? product.id}`,
|
|
1486
|
+
priceLabel: resolvePriceLabel(product),
|
|
1487
|
+
badgeLabel: resolveBadgeLabel(product),
|
|
1488
|
+
badges: badgesForCard(product.badges),
|
|
1489
|
+
highlights: product.highlights.slice(0, 3),
|
|
1490
|
+
// Flattened, not raw: the description is rich text and the card renders it
|
|
1491
|
+
// as TEXT, so the merchant's own markup used to print as literal tags. See
|
|
1492
|
+
// `flattenMerchantHtmlToText` for why plain `textContent` is not enough.
|
|
1493
|
+
description: product.highlights.length > 0 ? null : flattenMerchantHtmlToText(product.description) || null,
|
|
1494
|
+
rating: product.rating,
|
|
1495
|
+
// A paused product's stock is beside the point — announcing "999 IN STOCK"
|
|
1496
|
+
// over a card that cannot be bought states the opposite of what the action
|
|
1497
|
+
// row says. Checked before stock for the same reason the document lane's
|
|
1498
|
+
// `isProductOutOfStock` checks it first.
|
|
1499
|
+
stockChip: isInfoCard || soldOut || product.onHold ? null : resolveStockChip(stock, hideCounter, product.lowStockThreshold),
|
|
1500
|
+
soldOut,
|
|
1501
|
+
onHold: product.onHold,
|
|
1502
|
+
categoryIds: product.categories.map((category) => category.id),
|
|
1503
|
+
sortPriority,
|
|
1504
|
+
createdAt: product.createdAt,
|
|
1505
|
+
price: product.price
|
|
1506
|
+
};
|
|
1507
|
+
}
|
|
1508
|
+
function buildGroupCard(group, byId, sortPriority) {
|
|
1509
|
+
const members = group.productIds.map((id) => byId.get(id)).filter((product) => product !== void 0);
|
|
1510
|
+
const prices = members.map((product) => product.price);
|
|
1511
|
+
const currency = members[0]?.currency ?? "USD";
|
|
1512
|
+
const min = prices.length > 0 ? Math.min(...prices) : 0;
|
|
1513
|
+
const max = prices.length > 0 ? Math.max(...prices) : 0;
|
|
1514
|
+
const memberCount = Math.max(group.productsCount, members.length);
|
|
1515
|
+
return {
|
|
1516
|
+
kind: "group",
|
|
1517
|
+
key: group.id,
|
|
1518
|
+
group,
|
|
1519
|
+
priceLabel: prices.length === 0 ? "" : min === max ? formatMoney(min, currency) : `${formatMoney(min, currency)} - ${formatMoney(max, currency)}`,
|
|
1520
|
+
members,
|
|
1521
|
+
memberCountLabel: `${memberCount} ${memberCount === 1 ? "product" : "products"}`,
|
|
1522
|
+
categoryIds: [
|
|
1523
|
+
.../* @__PURE__ */ new Set([
|
|
1524
|
+
...group.categoryId ? [group.categoryId] : [],
|
|
1525
|
+
...members.flatMap((product) => product.categories.map((category) => category.id))
|
|
1526
|
+
])
|
|
1527
|
+
],
|
|
1528
|
+
sortPriority,
|
|
1529
|
+
price: min
|
|
1530
|
+
};
|
|
1531
|
+
}
|
|
1532
|
+
function findStorefrontProduct(catalog, slugOrId) {
|
|
1533
|
+
return catalog.products.find((product) => product.slug === slugOrId) ?? catalog.products.find((product) => product.id === slugOrId) ?? null;
|
|
1534
|
+
}
|
|
1535
|
+
function buildStorefrontCatalog(settings) {
|
|
1536
|
+
const products = getProducts();
|
|
1537
|
+
const groups = getProductGroups();
|
|
1538
|
+
const listing = getStorefrontListing();
|
|
1539
|
+
const productsById = new Map(products.map((product) => [product.id, product]));
|
|
1540
|
+
const groupsById = new Map(groups.map((group) => [group.id, group]));
|
|
1541
|
+
const cards = listing.map((item, index) => {
|
|
1542
|
+
if (item.type === "group") {
|
|
1543
|
+
const group = groupsById.get(item.id);
|
|
1544
|
+
return group ? buildGroupCard(group, productsById, item.sortPriority ?? index) : null;
|
|
1545
|
+
}
|
|
1546
|
+
const product = productsById.get(item.id);
|
|
1547
|
+
if (!product) return null;
|
|
1548
|
+
if (settings.hideOutOfStock && isProductSoldOut(product)) return null;
|
|
1549
|
+
return buildProductCard(product, settings, item.sortPriority ?? index);
|
|
1550
|
+
}).filter((card) => card !== null);
|
|
1551
|
+
return {
|
|
1552
|
+
cards,
|
|
1553
|
+
categories: getStorefrontCategories(),
|
|
1554
|
+
products,
|
|
1555
|
+
listedProductCount: products.length
|
|
1556
|
+
};
|
|
1557
|
+
}
|
|
1558
|
+
var CATALOG_SORTS = STOREFRONT_SORT_KEYS;
|
|
1559
|
+
var CATALOG_SORT_LABELS = {
|
|
1560
|
+
featured: "Featured",
|
|
1561
|
+
newest: "Newest",
|
|
1562
|
+
"price-asc": "Price: Low to High",
|
|
1563
|
+
"price-desc": "Price: High to Low"
|
|
1564
|
+
};
|
|
1565
|
+
function sortCatalogCards(cards, sort) {
|
|
1566
|
+
if (sort === "featured") return cards;
|
|
1567
|
+
const sorted = [...cards];
|
|
1568
|
+
if (sort === "price-asc") return sorted.sort((a, b) => a.price - b.price);
|
|
1569
|
+
if (sort === "price-desc") return sorted.sort((a, b) => b.price - a.price);
|
|
1570
|
+
return sorted.sort((a, b) => {
|
|
1571
|
+
const left = a.kind === "product" && a.createdAt ? Date.parse(a.createdAt) : Number.NaN;
|
|
1572
|
+
const right = b.kind === "product" && b.createdAt ? Date.parse(b.createdAt) : Number.NaN;
|
|
1573
|
+
if (Number.isNaN(left) && Number.isNaN(right)) return a.sortPriority - b.sortPriority;
|
|
1574
|
+
if (Number.isNaN(left)) return 1;
|
|
1575
|
+
if (Number.isNaN(right)) return -1;
|
|
1576
|
+
return right - left;
|
|
1577
|
+
});
|
|
1578
|
+
}
|
|
1579
|
+
function resolveCategorySelection(value, categories) {
|
|
1580
|
+
const normalized = value?.trim().toLowerCase();
|
|
1581
|
+
if (!normalized) return null;
|
|
1582
|
+
const match = categories.find((category) => category.id.toLowerCase() === normalized || category.slug.toLowerCase() === normalized || category.title.toLowerCase() === normalized);
|
|
1583
|
+
return match?.id ?? null;
|
|
1584
|
+
}
|
|
1585
|
+
function filterCatalogCards(cards, categoryId) {
|
|
1586
|
+
if (!categoryId) return cards;
|
|
1587
|
+
return cards.filter((card) => card.categoryIds.includes(categoryId));
|
|
1588
|
+
}
|
|
1589
|
+
var CATALOG_PAGE_SIZE = 24;
|
|
1590
|
+
function paginateCatalogCards(cards, page) {
|
|
1591
|
+
const products = cards.filter((card) => card.kind === "product");
|
|
1592
|
+
const firstPage = [];
|
|
1593
|
+
let productsOnFirstPage = 0;
|
|
1594
|
+
for (const card of cards) {
|
|
1595
|
+
if (card.kind === "group") {
|
|
1596
|
+
firstPage.push(card);
|
|
1597
|
+
continue;
|
|
1598
|
+
}
|
|
1599
|
+
if (productsOnFirstPage < CATALOG_PAGE_SIZE) {
|
|
1600
|
+
firstPage.push(card);
|
|
1601
|
+
productsOnFirstPage += 1;
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
const remaining = products.length - productsOnFirstPage;
|
|
1605
|
+
const totalPages = remaining > 0 ? 1 + Math.ceil(remaining / CATALOG_PAGE_SIZE) : 1;
|
|
1606
|
+
const resolved = Math.min(Math.max(page, 1), totalPages);
|
|
1607
|
+
if (resolved === 1) return { cards: firstPage, page: 1, totalPages };
|
|
1608
|
+
const offset = productsOnFirstPage + (resolved - 2) * CATALOG_PAGE_SIZE;
|
|
1609
|
+
return {
|
|
1610
|
+
cards: products.slice(offset, offset + CATALOG_PAGE_SIZE),
|
|
1611
|
+
page: resolved,
|
|
1612
|
+
totalPages
|
|
1613
|
+
};
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
// src/product-detail.ts
|
|
1617
|
+
var RELATED_PRODUCT_LIMIT = 3;
|
|
1618
|
+
function resolveBuyBoxStock(stock, hideCounter, lowStockThreshold) {
|
|
1619
|
+
if (stock === 0) return { tone: "sold_out", label: "Out of Stock", soldOut: true };
|
|
1620
|
+
if (stock === null || hideCounter) return { tone: "in_stock", label: "Available", soldOut: false };
|
|
1621
|
+
const threshold = lowStockThreshold ?? DEFAULT_LOW_STOCK_THRESHOLD;
|
|
1622
|
+
if (stock <= threshold) return { tone: "low_stock", label: `Only ${stock} left`, soldOut: false };
|
|
1623
|
+
return { tone: "in_stock", label: `${stock} in stock`, soldOut: false };
|
|
1624
|
+
}
|
|
1625
|
+
function buildVariants(product, settings) {
|
|
1626
|
+
const hideCounter = resolveHideStockCounter(product, settings);
|
|
1627
|
+
return product.priceVariants.map((variant) => {
|
|
1628
|
+
const stock = resolveVariantStock(product, variant);
|
|
1629
|
+
const { tone, label, soldOut } = isSelectionSoldOut(product, variant) ? { tone: "sold_out", label: "Out of Stock", soldOut: true } : resolveBuyBoxStock(stock, hideCounter, product.lowStockThreshold);
|
|
1630
|
+
return {
|
|
1631
|
+
variant,
|
|
1632
|
+
price: variant.price,
|
|
1633
|
+
compareAtPrice: variant.price < variant.baseUnitPrice ? variant.baseUnitPrice : null,
|
|
1634
|
+
stockTone: tone,
|
|
1635
|
+
stockLabel: label,
|
|
1636
|
+
soldOut
|
|
1637
|
+
};
|
|
1638
|
+
});
|
|
1639
|
+
}
|
|
1640
|
+
function buildRating(product) {
|
|
1641
|
+
if (product.rating === null || product.ratingTally === null || product.ratingCount === 0) return null;
|
|
1642
|
+
const filled = Math.round(product.rating);
|
|
1643
|
+
return {
|
|
1644
|
+
stars: [1, 2, 3, 4, 5].map((slot) => slot <= filled),
|
|
1645
|
+
count: product.ratingCount,
|
|
1646
|
+
average: product.rating,
|
|
1647
|
+
positive: product.ratingTally.positive,
|
|
1648
|
+
neutral: product.ratingTally.neutral,
|
|
1649
|
+
negative: product.ratingTally.negative
|
|
1650
|
+
};
|
|
1651
|
+
}
|
|
1652
|
+
function buildRelated(cards, currentId) {
|
|
1653
|
+
return cards.filter((card) => card.kind === "product" && card.key !== currentId).slice(0, RELATED_PRODUCT_LIMIT);
|
|
1654
|
+
}
|
|
1655
|
+
function buildProductDetail(product, settings, cards) {
|
|
1656
|
+
const images = settings.descriptionSkipDefaultImage ? [] : product.imageUrls.length > 0 ? product.imageUrls : product.imageUrl ? [product.imageUrl] : [];
|
|
1657
|
+
return {
|
|
1658
|
+
product,
|
|
1659
|
+
images,
|
|
1660
|
+
rating: buildRating(product),
|
|
1661
|
+
variants: buildVariants(product, settings),
|
|
1662
|
+
addons: product.addons,
|
|
1663
|
+
// A variant-less product can be unsellable for the same reason an option can — see
|
|
1664
|
+
// `isSelectionSoldOut`, which folds the empty-range case into the product-level read.
|
|
1665
|
+
soldOut: isSelectionSoldOut(product, null),
|
|
1666
|
+
related: buildRelated(cards, product.id)
|
|
1667
|
+
};
|
|
1668
|
+
}
|
|
1669
|
+
function calculateLinePricing(product, variant, addons, quantity, payWhatYouWantPrice) {
|
|
1670
|
+
const addonsTotal = addons.reduce((sum, addon) => sum + addon.price, 0);
|
|
1671
|
+
if (payWhatYouWantPrice !== void 0) {
|
|
1672
|
+
const effectiveUnitPrice = payWhatYouWantPrice + addonsTotal;
|
|
1673
|
+
const total2 = roundCurrency(effectiveUnitPrice * quantity);
|
|
1674
|
+
return {
|
|
1675
|
+
total: total2,
|
|
1676
|
+
originalSubtotal: total2,
|
|
1677
|
+
effectiveUnitPrice,
|
|
1678
|
+
discountAmount: 0,
|
|
1679
|
+
hasDiscount: false
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1682
|
+
const unitPrice = (variant?.baseUnitPrice ?? product.baseUnitPrice) + addonsTotal;
|
|
1683
|
+
const total = calculateDisplayLineTotal(
|
|
1684
|
+
unitPrice,
|
|
1685
|
+
quantity,
|
|
1686
|
+
product.discountPercent,
|
|
1687
|
+
product.volumeDiscounts
|
|
1688
|
+
);
|
|
1689
|
+
const originalSubtotal = calculateDisplayLineTotal(unitPrice, quantity, 0, []);
|
|
1690
|
+
return {
|
|
1691
|
+
total,
|
|
1692
|
+
originalSubtotal,
|
|
1693
|
+
effectiveUnitPrice: quantity > 0 ? total / quantity : total,
|
|
1694
|
+
discountAmount: Math.max(0, originalSubtotal - total),
|
|
1695
|
+
hasDiscount: total < originalSubtotal
|
|
1696
|
+
};
|
|
1697
|
+
}
|
|
1698
|
+
function resolvePayWhatYouWantMinimum(product, variant) {
|
|
1699
|
+
return variant?.baseUnitPrice ?? product.baseUnitPrice;
|
|
1700
|
+
}
|
|
1701
|
+
var MAX_PAY_WHAT_YOU_WANT_PRICE = 1e9;
|
|
1702
|
+
function clampQuantity(product, variant, quantity) {
|
|
1703
|
+
const { min, max } = resolveQuantityBounds(product, variant);
|
|
1704
|
+
if (!Number.isFinite(quantity)) return min;
|
|
1705
|
+
const floored = Math.floor(quantity);
|
|
1706
|
+
if (floored < min) return min;
|
|
1707
|
+
return max !== null && floored > max ? max : floored;
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
// src/cart.tsx
|
|
1711
|
+
import { jsx } from "react/jsx-runtime";
|
|
1712
|
+
function readCheckoutNeedsDetails() {
|
|
1713
|
+
return checkoutNeedsDetails(resolveCheckoutFields(getShoppex().getCart()));
|
|
1714
|
+
}
|
|
1715
|
+
var CartContext = createContext(null);
|
|
1716
|
+
var SELF_REFERRAL_REFUSAL_CODE = "errors.checkout.affiliate_code_not_applicable";
|
|
1717
|
+
var SELF_REFERRAL_MESSAGE = "This referral code cannot be used on your own order.";
|
|
1718
|
+
var CHECKOUT_REFUSAL_MESSAGES = {
|
|
1719
|
+
"errors.checkout.shop_does_not_ship": "This shop does not ship physical products yet."
|
|
1720
|
+
};
|
|
1721
|
+
var QUOTE_PROOF_REFUSAL_MESSAGES = {
|
|
1722
|
+
"errors.checkout.quote_token_invalid": "We could not verify the price you approved. The cart has been re-priced \u2014 review the total and check out again.",
|
|
1723
|
+
"errors.checkout.quote_token_expired": "The price you approved has expired. Review the updated total, then check out again.",
|
|
1724
|
+
"errors.checkout.quote_token_stale": "Your cart changed after the price was approved. Review the updated total, then check out again.",
|
|
1725
|
+
"errors.checkout.price_increased_since_quote": "The price of your cart went up after you approved the total. Review the updated total, then check out again."
|
|
1726
|
+
};
|
|
1727
|
+
function resolveCartMaximumWarning(quote) {
|
|
1728
|
+
if (quote.cart_max_exceeded !== true) return null;
|
|
1729
|
+
const maximum = Number(quote.cart_maximum_checkout);
|
|
1730
|
+
return Number.isFinite(maximum) && maximum > 0 ? `This cart is over the ${formatMoney(maximum, quote.currency)} checkout limit. Remove some items to continue.` : "This cart is over this shop\u2019s checkout limit. Remove some items to continue.";
|
|
1731
|
+
}
|
|
1732
|
+
function errorMessage(error, fallback) {
|
|
1733
|
+
return error instanceof Error && error.message.trim() ? error.message : fallback;
|
|
1734
|
+
}
|
|
1735
|
+
function readAcceptedCouponSource(quote) {
|
|
1736
|
+
if (quote.coupon_applied !== true) return null;
|
|
1737
|
+
return quote.coupon_source === "COUPON" || quote.coupon_source === "AFFILIATE" ? quote.coupon_source : null;
|
|
1738
|
+
}
|
|
1739
|
+
function normalizeAmbientAffiliate(value) {
|
|
1740
|
+
return value?.trim().toLowerCase() ?? "";
|
|
1741
|
+
}
|
|
1742
|
+
function resolveSubmittedCode(sdk) {
|
|
1743
|
+
const code = sdk.getCartCoupon()?.trim() ?? "";
|
|
1744
|
+
if (!code) return { coupon: "" };
|
|
1745
|
+
const source = sdk.getCartCouponSource();
|
|
1746
|
+
if (source === "COUPON") return { coupon: code };
|
|
1747
|
+
if (source === "AFFILIATE") return { coupon: "", affiliateCode: code };
|
|
1748
|
+
return null;
|
|
1749
|
+
}
|
|
1750
|
+
function cartLineIdentity(input) {
|
|
1751
|
+
const addons = [...input.addons ?? []].map((addon) => ({ id: addon.id, quantity: addon.quantity ?? 1 })).sort((a, b) => a.id.localeCompare(b.id));
|
|
1752
|
+
const customFields = Object.fromEntries(
|
|
1753
|
+
Object.entries(input.custom_fields ?? {}).filter(([, value]) => typeof value === "string").sort(([a], [b]) => a.localeCompare(b))
|
|
1754
|
+
);
|
|
1755
|
+
const unitPrice = input.price_data?.unit_price;
|
|
1756
|
+
const namedPrice = input.pay_what_you_want_price;
|
|
1757
|
+
return JSON.stringify({
|
|
1758
|
+
product_id: input.product_id,
|
|
1759
|
+
variant_id: input.variant_id,
|
|
1760
|
+
price_variant_id: input.price_variant_id ?? null,
|
|
1761
|
+
addons,
|
|
1762
|
+
custom_fields: customFields,
|
|
1763
|
+
unit_price: typeof unitPrice === "number" && Number.isFinite(unitPrice) ? unitPrice : null,
|
|
1764
|
+
pay_what_you_want_price: typeof namedPrice === "number" && Number.isFinite(namedPrice) ? namedPrice : null
|
|
1765
|
+
});
|
|
1766
|
+
}
|
|
1767
|
+
function resolveBundleMember(item) {
|
|
1768
|
+
const product = getProducts().find((candidate) => candidate.id === item.id);
|
|
1769
|
+
if (product === void 0) return null;
|
|
1770
|
+
if (item.variantId === "default") {
|
|
1771
|
+
return { product, variant: null, bounds: resolveQuantityBounds(product, null) };
|
|
1772
|
+
}
|
|
1773
|
+
const variant = product.priceVariants.find((candidate) => candidate.id === item.variantId);
|
|
1774
|
+
if (variant === void 0) return null;
|
|
1775
|
+
return { product, variant, bounds: resolveQuantityBounds(product, variant) };
|
|
1776
|
+
}
|
|
1777
|
+
function clampToLineBounds(quantity, minimum, maximum) {
|
|
1778
|
+
let clamped = Math.floor(quantity);
|
|
1779
|
+
if (clamped < minimum) clamped = Math.floor(minimum);
|
|
1780
|
+
if (maximum !== null && clamped > maximum) clamped = Math.floor(maximum);
|
|
1781
|
+
return Math.max(1, clamped);
|
|
1782
|
+
}
|
|
1783
|
+
function unwrittenAdd(serializedBefore, identity, minimum, maximum, message) {
|
|
1784
|
+
const lines = getShoppex().getCart();
|
|
1785
|
+
if (JSON.stringify(lines) !== serializedBefore) return null;
|
|
1786
|
+
if (maximum === null) return message;
|
|
1787
|
+
const ceiling = clampToLineBounds(maximum, minimum, maximum);
|
|
1788
|
+
return lines.some((line) => cartLineIdentity(line) === identity && line.quantity === ceiling) ? null : message;
|
|
1789
|
+
}
|
|
1790
|
+
function writeCheckoutFieldsToCart(values) {
|
|
1791
|
+
const sdk = getShoppex();
|
|
1792
|
+
const fields = resolveCheckoutFields(sdk.getCart());
|
|
1793
|
+
if (!fields.resolved) {
|
|
1794
|
+
return "Some products in your cart are no longer available. Please review your cart.";
|
|
1795
|
+
}
|
|
1796
|
+
if (fields.groups.length === 0) return null;
|
|
1797
|
+
const payloads = new Map(fields.groups.map((group) => [
|
|
1798
|
+
group.productId,
|
|
1799
|
+
buildCustomFieldPayload(group.fields, values[group.productId])
|
|
1800
|
+
]));
|
|
1801
|
+
const before = sdk.getCart();
|
|
1802
|
+
for (const line of before) {
|
|
1803
|
+
const payload = payloads.get(line.product_id);
|
|
1804
|
+
if (payload === void 0) continue;
|
|
1805
|
+
if (!sdk.getCart().some((entry) => entry.line_id === line.line_id)) continue;
|
|
1806
|
+
sdk.updateCartItem(line.line_id, { custom_fields: payload });
|
|
1807
|
+
}
|
|
1808
|
+
const after = sdk.getCart();
|
|
1809
|
+
const missing = missingRequiredCheckoutValues(fields.groups, after);
|
|
1810
|
+
if (missing.length > 0) {
|
|
1811
|
+
return `Your details could not be saved to the cart: ${missing.join(" ")}`;
|
|
1812
|
+
}
|
|
1813
|
+
return checkoutFieldWriteRepriced(before, after) ? "Two lines of the same item were combined once your details were saved. Review the updated total, then check out again." : null;
|
|
1814
|
+
}
|
|
1815
|
+
function snapshotLines(lines) {
|
|
1816
|
+
return lines.map((line) => ({ lineId: line.line_id, quantity: line.quantity }));
|
|
1817
|
+
}
|
|
1818
|
+
function diffBundleMemberAdd(before, after) {
|
|
1819
|
+
const created = after.find((line) => !before.some((entry) => entry.lineId === line.line_id));
|
|
1820
|
+
if (created !== void 0) return { lineId: created.line_id, quantity: null };
|
|
1821
|
+
for (const line of after) {
|
|
1822
|
+
const previous = before.find((entry) => entry.lineId === line.line_id);
|
|
1823
|
+
if (previous !== void 0 && line.quantity > previous.quantity) {
|
|
1824
|
+
return { lineId: line.line_id, quantity: previous.quantity };
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
return null;
|
|
1828
|
+
}
|
|
1829
|
+
function CartProvider({ children }) {
|
|
1830
|
+
const [lines, setLines] = useState([]);
|
|
1831
|
+
const [count, setCount] = useState(0);
|
|
1832
|
+
const [quote, setQuote] = useState(null);
|
|
1833
|
+
const [quoteError, setQuoteError] = useState(null);
|
|
1834
|
+
const [mutationError, setMutationError] = useState(null);
|
|
1835
|
+
const [couponRejected, setCouponRejected] = useState(false);
|
|
1836
|
+
const [quotedAmbientAffiliate, setQuotedAmbientAffiliate] = useState(false);
|
|
1837
|
+
const [cartMaxWarning, setCartMaxWarning] = useState(null);
|
|
1838
|
+
const [checkoutError, setCheckoutError] = useState(null);
|
|
1839
|
+
const [selfReferralBlocked, setSelfReferralBlocked] = useState(false);
|
|
1840
|
+
const quotedAffiliate = useRef(null);
|
|
1841
|
+
const unsellableLines = useRef([]);
|
|
1842
|
+
const cartMaximumWarning = useRef(null);
|
|
1843
|
+
const [busyCount, setBusyCount] = useState(0);
|
|
1844
|
+
const [couponInput, setCouponInput] = useState("");
|
|
1845
|
+
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
1846
|
+
const couponPendingEdit = useRef(false);
|
|
1847
|
+
const refreshVersion = useRef(0);
|
|
1848
|
+
const refreshTimer = useRef(null);
|
|
1849
|
+
const refreshResolvers = useRef([]);
|
|
1850
|
+
const rememberQuotedAffiliate = useCallback((code) => {
|
|
1851
|
+
quotedAffiliate.current = code;
|
|
1852
|
+
setQuotedAmbientAffiliate(code !== null && code !== "");
|
|
1853
|
+
}, []);
|
|
1854
|
+
const rememberCartMaximum = useCallback((warning) => {
|
|
1855
|
+
cartMaximumWarning.current = warning;
|
|
1856
|
+
setCartMaxWarning(warning);
|
|
1857
|
+
}, []);
|
|
1858
|
+
const settleQuotedCart = useCallback((data, quotedAmbient, version, pricedCart) => {
|
|
1859
|
+
const sdk = getShoppex();
|
|
1860
|
+
if (JSON.stringify(sdk.getCart()) !== pricedCart) return "stale-cart";
|
|
1861
|
+
if (version !== refreshVersion.current) return "superseded";
|
|
1862
|
+
const quotedLines = parseCartQuoteLines(data.lines);
|
|
1863
|
+
let unsellable = null;
|
|
1864
|
+
let healClamped = false;
|
|
1865
|
+
if (quotedLines.length > 0) {
|
|
1866
|
+
const beforeHeal = sdk.getCart();
|
|
1867
|
+
for (const heal of resolveCartBoundsHeals(beforeHeal, quotedLines)) {
|
|
1868
|
+
sdk.updateCartItem(heal.lineId, {
|
|
1869
|
+
min_quantity: heal.minQuantity,
|
|
1870
|
+
max_quantity: heal.maxQuantity
|
|
1871
|
+
});
|
|
1872
|
+
}
|
|
1873
|
+
const healedCart = sdk.getCart();
|
|
1874
|
+
healClamped = healedCart.length !== beforeHeal.length || healedCart.some((line, index) => line.quantity !== beforeHeal[index]?.quantity);
|
|
1875
|
+
unsellable = resolveUnsellableLineIds(healedCart, quotedLines);
|
|
1876
|
+
}
|
|
1877
|
+
if (healClamped) {
|
|
1878
|
+
setLines(sdk.getCart());
|
|
1879
|
+
setCount(sdk.getCartItemCount());
|
|
1880
|
+
setQuote(null);
|
|
1881
|
+
rememberQuotedAffiliate(null);
|
|
1882
|
+
rememberCartMaximum(null);
|
|
1883
|
+
unsellableLines.current = unsellable ?? [];
|
|
1884
|
+
return "clamped";
|
|
1885
|
+
}
|
|
1886
|
+
setQuote(data);
|
|
1887
|
+
setQuoteError(null);
|
|
1888
|
+
setCheckoutError(clearRecoveredQuoteCheckoutError);
|
|
1889
|
+
rememberQuotedAffiliate(quotedAmbient);
|
|
1890
|
+
rememberCartMaximum(resolveCartMaximumWarning(data));
|
|
1891
|
+
if (unsellable !== null) unsellableLines.current = unsellable;
|
|
1892
|
+
if (quotedLines.length > 0) setLines(sdk.getCart());
|
|
1893
|
+
return "settled";
|
|
1894
|
+
}, [rememberCartMaximum, rememberQuotedAffiliate]);
|
|
1895
|
+
const refreshNow = useCallback(async (retriesLeft = 1) => {
|
|
1896
|
+
const sdk = getShoppex();
|
|
1897
|
+
const version = ++refreshVersion.current;
|
|
1898
|
+
const nextLines = sdk.getCart();
|
|
1899
|
+
const nextCount = sdk.getCartItemCount();
|
|
1900
|
+
const pricedCart = JSON.stringify(nextLines);
|
|
1901
|
+
const coupon = sdk.getCartCoupon()?.trim() ?? "";
|
|
1902
|
+
const ambientAffiliate = coupon ? "" : sdk.getAffiliateCode()?.trim() ?? "";
|
|
1903
|
+
if (version === refreshVersion.current) {
|
|
1904
|
+
setLines(nextLines);
|
|
1905
|
+
setCount(nextCount);
|
|
1906
|
+
if (!couponPendingEdit.current) setCouponInput(coupon);
|
|
1907
|
+
}
|
|
1908
|
+
if (nextLines.length === 0) {
|
|
1909
|
+
if (version === refreshVersion.current) {
|
|
1910
|
+
setQuote(null);
|
|
1911
|
+
setQuoteError(null);
|
|
1912
|
+
setCheckoutError(clearRecoveredQuoteCheckoutError);
|
|
1913
|
+
rememberQuotedAffiliate(null);
|
|
1914
|
+
rememberCartMaximum(null);
|
|
1915
|
+
unsellableLines.current = [];
|
|
1916
|
+
}
|
|
1917
|
+
return;
|
|
1918
|
+
}
|
|
1919
|
+
try {
|
|
1920
|
+
const result = await sdk.quoteCart(coupon || void 0, getBuyerCurrency());
|
|
1921
|
+
if (version !== refreshVersion.current) return;
|
|
1922
|
+
if (!result.success || !result.data) {
|
|
1923
|
+
setQuote(null);
|
|
1924
|
+
setQuoteError(result.message?.trim() || "Cart pricing is unavailable.");
|
|
1925
|
+
rememberQuotedAffiliate(null);
|
|
1926
|
+
rememberCartMaximum(null);
|
|
1927
|
+
unsellableLines.current = [];
|
|
1928
|
+
return;
|
|
1929
|
+
}
|
|
1930
|
+
if (settleQuotedCart(result.data, ambientAffiliate, version, pricedCart) === "clamped" && retriesLeft > 0) {
|
|
1931
|
+
await refreshNow(retriesLeft - 1);
|
|
1932
|
+
}
|
|
1933
|
+
} catch (error) {
|
|
1934
|
+
if (version !== refreshVersion.current) return;
|
|
1935
|
+
setQuote(null);
|
|
1936
|
+
setQuoteError(errorMessage(error, "Cart pricing is unavailable."));
|
|
1937
|
+
rememberQuotedAffiliate(null);
|
|
1938
|
+
rememberCartMaximum(null);
|
|
1939
|
+
unsellableLines.current = [];
|
|
1940
|
+
}
|
|
1941
|
+
}, [rememberCartMaximum, rememberQuotedAffiliate, settleQuotedCart]);
|
|
1942
|
+
const scheduleRefresh = useCallback(() => new Promise((resolve) => {
|
|
1943
|
+
refreshResolvers.current.push(resolve);
|
|
1944
|
+
if (refreshTimer.current !== null) {
|
|
1945
|
+
window.clearTimeout(refreshTimer.current);
|
|
1946
|
+
}
|
|
1947
|
+
refreshTimer.current = window.setTimeout(() => {
|
|
1948
|
+
refreshTimer.current = null;
|
|
1949
|
+
const resolvers = refreshResolvers.current.splice(0);
|
|
1950
|
+
void refreshNow().finally(() => {
|
|
1951
|
+
resolvers.forEach((finish) => finish());
|
|
1952
|
+
});
|
|
1953
|
+
}, 60);
|
|
1954
|
+
}), [refreshNow]);
|
|
1955
|
+
useEffect(() => {
|
|
1956
|
+
void refreshNow();
|
|
1957
|
+
return () => {
|
|
1958
|
+
if (refreshTimer.current !== null) window.clearTimeout(refreshTimer.current);
|
|
1959
|
+
refreshResolvers.current.splice(0).forEach((resolve) => resolve());
|
|
1960
|
+
};
|
|
1961
|
+
}, [refreshNow]);
|
|
1962
|
+
useEffect(() => {
|
|
1963
|
+
const onExternalChange = () => {
|
|
1964
|
+
void scheduleRefresh();
|
|
1965
|
+
};
|
|
1966
|
+
window.addEventListener("storage", onExternalChange);
|
|
1967
|
+
window.addEventListener("shoppex:cart-changed", onExternalChange);
|
|
1968
|
+
return () => {
|
|
1969
|
+
window.removeEventListener("storage", onExternalChange);
|
|
1970
|
+
window.removeEventListener("shoppex:cart-changed", onExternalChange);
|
|
1971
|
+
};
|
|
1972
|
+
}, [scheduleRefresh]);
|
|
1973
|
+
const mutate = useCallback(async (mutation, verifyWrite) => {
|
|
1974
|
+
setBusyCount((value2) => value2 + 1);
|
|
1975
|
+
setCheckoutError(null);
|
|
1976
|
+
setMutationError(null);
|
|
1977
|
+
setCouponRejected(false);
|
|
1978
|
+
try {
|
|
1979
|
+
const serializedBefore = verifyWrite ? JSON.stringify(getShoppex().getCart()) : "";
|
|
1980
|
+
mutation();
|
|
1981
|
+
const unwritten = verifyWrite?.(serializedBefore) ?? null;
|
|
1982
|
+
await scheduleRefresh();
|
|
1983
|
+
if (unwritten) throw new Error(unwritten);
|
|
1984
|
+
} catch (error) {
|
|
1985
|
+
setMutationError(errorMessage(error, "The cart could not be updated."));
|
|
1986
|
+
throw error;
|
|
1987
|
+
} finally {
|
|
1988
|
+
setBusyCount((value2) => Math.max(0, value2 - 1));
|
|
1989
|
+
}
|
|
1990
|
+
}, [scheduleRefresh]);
|
|
1991
|
+
const add = useCallback(async (product, variant, quantity, options) => {
|
|
1992
|
+
if (product.onHold) {
|
|
1993
|
+
throw new Error(`${product.name} is not on sale yet.`);
|
|
1994
|
+
}
|
|
1995
|
+
if (isSelectionSoldOut(product, variant)) {
|
|
1996
|
+
throw new Error(
|
|
1997
|
+
`${variant === null ? product.name : `${product.name} (${variant.label})`} is out of stock.`
|
|
1998
|
+
);
|
|
1999
|
+
}
|
|
2000
|
+
const { min: minimum, max: maximum } = resolveQuantityBounds(product, variant);
|
|
2001
|
+
const addons = options?.addons ?? [];
|
|
2002
|
+
const payWhatYouWantPrice = options?.payWhatYouWantPrice ?? (product.payWhatYouWant ? resolvePayWhatYouWantMinimum(product, variant) : void 0);
|
|
2003
|
+
const addonsUnitPrice = addons.reduce((sum, addon) => sum + addon.price, 0);
|
|
2004
|
+
const unitPriceSnapshot = payWhatYouWantPrice !== void 0 ? payWhatYouWantPrice + addonsUnitPrice : calculateLinePricing(product, variant, addons, quantity).effectiveUnitPrice;
|
|
2005
|
+
const variantId = variant?.id ?? "default";
|
|
2006
|
+
const addOptions = {
|
|
2007
|
+
...variant ? { price_variant_id: variant.id } : {},
|
|
2008
|
+
price_data: { unit_price: unitPriceSnapshot },
|
|
2009
|
+
...payWhatYouWantPrice !== void 0 ? { pay_what_you_want_price: payWhatYouWantPrice } : {},
|
|
2010
|
+
// Addon ids are part of the line's identity — the SDK hashes them into
|
|
2011
|
+
// `line_id`, so a line added with extras never merges with a plain one.
|
|
2012
|
+
...addons.length > 0 ? {
|
|
2013
|
+
addons: addons.map((addon) => ({ id: addon.id })),
|
|
2014
|
+
addon_labels: addons.map((addon) => addon.title)
|
|
2015
|
+
} : {},
|
|
2016
|
+
title: product.name,
|
|
2017
|
+
variant_title: variant?.label,
|
|
2018
|
+
image_url: variant?.imageUrl ?? product.imageUrl ?? void 0,
|
|
2019
|
+
max_quantity: maximum ?? void 0,
|
|
2020
|
+
min_quantity: minimum > 1 ? minimum : void 0,
|
|
2021
|
+
replace_display_snapshot: true
|
|
2022
|
+
};
|
|
2023
|
+
const identity = cartLineIdentity({ product_id: product.id, variant_id: variantId, ...addOptions });
|
|
2024
|
+
await mutate(
|
|
2025
|
+
() => getShoppex().addToCart(product.id, variantId, quantity, addOptions),
|
|
2026
|
+
(serializedBefore) => unwrittenAdd(
|
|
2027
|
+
serializedBefore,
|
|
2028
|
+
identity,
|
|
2029
|
+
minimum,
|
|
2030
|
+
maximum,
|
|
2031
|
+
"This product could not be added to your cart."
|
|
2032
|
+
)
|
|
2033
|
+
);
|
|
2034
|
+
}, [mutate]);
|
|
2035
|
+
const addBundleMember = useCallback(async (item) => {
|
|
2036
|
+
const member = resolveBundleMember(item);
|
|
2037
|
+
if (member === null || member.product.onHold || isSelectionSoldOut(member.product, member.variant)) {
|
|
2038
|
+
throw new Error(`${item.title} is not available and was not added to your cart.`);
|
|
2039
|
+
}
|
|
2040
|
+
const { bounds } = member;
|
|
2041
|
+
const payWhatYouWantPrice = member.product.payWhatYouWant ? resolvePayWhatYouWantMinimum(member.product, member.variant) : void 0;
|
|
2042
|
+
const addOptions = {
|
|
2043
|
+
...item.variantId !== "default" ? { price_variant_id: item.variantId } : {},
|
|
2044
|
+
// The offer states each member's unit price; a line without that
|
|
2045
|
+
// snapshot shows no money in the cart until the quote answers. A named
|
|
2046
|
+
// price overrides it, because that is the amount the backend charges.
|
|
2047
|
+
...payWhatYouWantPrice !== void 0 ? { price_data: { unit_price: payWhatYouWantPrice }, pay_what_you_want_price: payWhatYouWantPrice } : item.unitPrice > 0 ? { price_data: { unit_price: item.unitPrice } } : {},
|
|
2048
|
+
title: item.title,
|
|
2049
|
+
...item.variantTitle ? { variant_title: item.variantTitle } : {},
|
|
2050
|
+
...item.imageUrl ? { image_url: item.imageUrl } : {},
|
|
2051
|
+
// The member's OWN bounds, sent for the same reason the buy box sends
|
|
2052
|
+
// them: this write is a full re-snapshot, so whatever it leaves out is
|
|
2053
|
+
// CLEARED off the line it merges into.
|
|
2054
|
+
max_quantity: bounds.max ?? void 0,
|
|
2055
|
+
min_quantity: bounds.min > 1 ? bounds.min : void 0,
|
|
2056
|
+
replace_display_snapshot: true
|
|
2057
|
+
};
|
|
2058
|
+
const identity = cartLineIdentity({
|
|
2059
|
+
product_id: item.id,
|
|
2060
|
+
variant_id: item.variantId,
|
|
2061
|
+
...addOptions
|
|
2062
|
+
});
|
|
2063
|
+
await mutate(
|
|
2064
|
+
() => getShoppex().addToCart(item.id, item.variantId, 1, addOptions),
|
|
2065
|
+
(serializedBefore) => unwrittenAdd(
|
|
2066
|
+
serializedBefore,
|
|
2067
|
+
identity,
|
|
2068
|
+
bounds.min,
|
|
2069
|
+
bounds.max,
|
|
2070
|
+
"This item could not be added to your cart."
|
|
2071
|
+
)
|
|
2072
|
+
);
|
|
2073
|
+
}, [mutate]);
|
|
2074
|
+
const addBundle = useCallback(async (items) => {
|
|
2075
|
+
const undo = [];
|
|
2076
|
+
setBusyCount((value2) => value2 + 1);
|
|
2077
|
+
setCheckoutError(null);
|
|
2078
|
+
setMutationError(null);
|
|
2079
|
+
try {
|
|
2080
|
+
for (const item of items) {
|
|
2081
|
+
const before = snapshotLines(getShoppex().getCart());
|
|
2082
|
+
try {
|
|
2083
|
+
await addBundleMember(item);
|
|
2084
|
+
} catch (error) {
|
|
2085
|
+
const partial = diffBundleMemberAdd(before, getShoppex().getCart());
|
|
2086
|
+
if (partial !== null) undo.push(partial);
|
|
2087
|
+
const message = "This bundle could not be added to your cart.";
|
|
2088
|
+
setMutationError(message);
|
|
2089
|
+
for (const entry of [...undo].reverse()) {
|
|
2090
|
+
try {
|
|
2091
|
+
if (entry.quantity === null) getShoppex().removeFromCart(entry.lineId);
|
|
2092
|
+
else getShoppex().updateCartItem(entry.lineId, { quantity: entry.quantity });
|
|
2093
|
+
} catch {
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
await scheduleRefresh();
|
|
2097
|
+
throw error instanceof Error ? error : new Error(message);
|
|
2098
|
+
}
|
|
2099
|
+
const applied = diffBundleMemberAdd(before, getShoppex().getCart());
|
|
2100
|
+
if (applied !== null) undo.push(applied);
|
|
2101
|
+
}
|
|
2102
|
+
} finally {
|
|
2103
|
+
setBusyCount((value2) => Math.max(0, value2 - 1));
|
|
2104
|
+
}
|
|
2105
|
+
}, [addBundleMember, scheduleRefresh]);
|
|
2106
|
+
const update = useCallback(
|
|
2107
|
+
async (lineId, quantity) => mutate(() => getShoppex().updateCartItem(lineId, { quantity })),
|
|
2108
|
+
[mutate]
|
|
2109
|
+
);
|
|
2110
|
+
const remove = useCallback(
|
|
2111
|
+
async (lineId) => mutate(() => getShoppex().removeFromCart(lineId)),
|
|
2112
|
+
[mutate]
|
|
2113
|
+
);
|
|
2114
|
+
const clear = useCallback(async () => mutate(() => getShoppex().clearCart()), [mutate]);
|
|
2115
|
+
const editCouponInput = useCallback((value2) => {
|
|
2116
|
+
couponPendingEdit.current = true;
|
|
2117
|
+
setCouponInput(value2);
|
|
2118
|
+
}, []);
|
|
2119
|
+
const applyCoupon = useCallback(async () => {
|
|
2120
|
+
const coupon = couponInput.trim();
|
|
2121
|
+
couponPendingEdit.current = false;
|
|
2122
|
+
if (!coupon) {
|
|
2123
|
+
await mutate(() => getShoppex().clearCartCoupon());
|
|
2124
|
+
return;
|
|
2125
|
+
}
|
|
2126
|
+
setBusyCount((value2) => value2 + 1);
|
|
2127
|
+
setCheckoutError(null);
|
|
2128
|
+
setMutationError(null);
|
|
2129
|
+
setCouponRejected(false);
|
|
2130
|
+
try {
|
|
2131
|
+
const sdk = getShoppex();
|
|
2132
|
+
const storedBefore = sdk.getCartCoupon();
|
|
2133
|
+
let pricedCart = JSON.stringify(sdk.getCart());
|
|
2134
|
+
let result = await sdk.quoteCart(coupon, getBuyerCurrency());
|
|
2135
|
+
if (sdk.getCartCoupon() !== storedBefore) return;
|
|
2136
|
+
if (JSON.stringify(sdk.getCart()) !== pricedCart) {
|
|
2137
|
+
pricedCart = JSON.stringify(sdk.getCart());
|
|
2138
|
+
result = await sdk.quoteCart(coupon, getBuyerCurrency());
|
|
2139
|
+
if (sdk.getCartCoupon() !== storedBefore) return;
|
|
2140
|
+
if (JSON.stringify(sdk.getCart()) !== pricedCart) {
|
|
2141
|
+
couponPendingEdit.current = true;
|
|
2142
|
+
await refreshNow();
|
|
2143
|
+
return;
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
if (!result.success || !result.data) {
|
|
2147
|
+
setQuote(null);
|
|
2148
|
+
setQuoteError(result.message?.trim() || "Cart pricing is unavailable.");
|
|
2149
|
+
rememberQuotedAffiliate(null);
|
|
2150
|
+
rememberCartMaximum(null);
|
|
2151
|
+
return;
|
|
2152
|
+
}
|
|
2153
|
+
const source = readAcceptedCouponSource(result.data);
|
|
2154
|
+
if (source === null) {
|
|
2155
|
+
sdk.clearCartCoupon();
|
|
2156
|
+
setCouponInput("");
|
|
2157
|
+
setCouponRejected(true);
|
|
2158
|
+
await refreshNow();
|
|
2159
|
+
return;
|
|
2160
|
+
}
|
|
2161
|
+
sdk.setCartCoupon(coupon, source);
|
|
2162
|
+
refreshVersion.current += 1;
|
|
2163
|
+
const settlement = settleQuotedCart(result.data, "", refreshVersion.current, pricedCart);
|
|
2164
|
+
setCouponInput(sdk.getCartCoupon() ?? coupon);
|
|
2165
|
+
if (settlement === "clamped") await refreshNow();
|
|
2166
|
+
} catch (error) {
|
|
2167
|
+
setQuote(null);
|
|
2168
|
+
setQuoteError(errorMessage(error, "Cart pricing is unavailable."));
|
|
2169
|
+
rememberQuotedAffiliate(null);
|
|
2170
|
+
rememberCartMaximum(null);
|
|
2171
|
+
} finally {
|
|
2172
|
+
setBusyCount((value2) => Math.max(0, value2 - 1));
|
|
2173
|
+
}
|
|
2174
|
+
}, [couponInput, mutate, refreshNow, rememberCartMaximum, rememberQuotedAffiliate, settleQuotedCart]);
|
|
2175
|
+
const removeCoupon = useCallback(async () => {
|
|
2176
|
+
couponPendingEdit.current = false;
|
|
2177
|
+
await mutate(() => getShoppex().clearCartCoupon());
|
|
2178
|
+
}, [mutate]);
|
|
2179
|
+
const clearBlockedReferral = useCallback(async () => {
|
|
2180
|
+
const sdk = getShoppex();
|
|
2181
|
+
sdk.clearAffiliateCode();
|
|
2182
|
+
if (sdk.getCartCouponSource() === "AFFILIATE") {
|
|
2183
|
+
sdk.clearCartCoupon();
|
|
2184
|
+
couponPendingEdit.current = false;
|
|
2185
|
+
setCouponInput("");
|
|
2186
|
+
}
|
|
2187
|
+
setSelfReferralBlocked(false);
|
|
2188
|
+
setCheckoutError(null);
|
|
2189
|
+
setCouponRejected(false);
|
|
2190
|
+
await refreshNow();
|
|
2191
|
+
}, [refreshNow]);
|
|
2192
|
+
const checkout = useCallback(async (customFieldValues) => {
|
|
2193
|
+
setBusyCount((value2) => value2 + 1);
|
|
2194
|
+
setCheckoutError(null);
|
|
2195
|
+
setSelfReferralBlocked(false);
|
|
2196
|
+
try {
|
|
2197
|
+
const sdk = getShoppex();
|
|
2198
|
+
if (quotedAffiliate.current === null) {
|
|
2199
|
+
setCheckoutError(QUOTE_UNAVAILABLE_CHECKOUT_ERROR);
|
|
2200
|
+
return;
|
|
2201
|
+
}
|
|
2202
|
+
if (unsellableLines.current.length > 0) {
|
|
2203
|
+
setCheckoutError(
|
|
2204
|
+
"Some items in your cart are not available in the quantity requested. Reduce or remove them, then check out again."
|
|
2205
|
+
);
|
|
2206
|
+
return;
|
|
2207
|
+
}
|
|
2208
|
+
if (cartMaximumWarning.current !== null) {
|
|
2209
|
+
setCheckoutError(cartMaximumWarning.current);
|
|
2210
|
+
return;
|
|
2211
|
+
}
|
|
2212
|
+
if (customFieldValues !== void 0) {
|
|
2213
|
+
const failure = writeCheckoutFieldsToCart(customFieldValues);
|
|
2214
|
+
if (failure !== null) {
|
|
2215
|
+
setCheckoutError(failure);
|
|
2216
|
+
await scheduleRefresh();
|
|
2217
|
+
return;
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
const submitted = resolveSubmittedCode(sdk);
|
|
2221
|
+
if (submitted === null) {
|
|
2222
|
+
setCheckoutError(
|
|
2223
|
+
"Your discount code could not be verified. Remove it, or apply it again, to continue."
|
|
2224
|
+
);
|
|
2225
|
+
return;
|
|
2226
|
+
}
|
|
2227
|
+
const quotedAmbient = quotedAffiliate.current;
|
|
2228
|
+
const isAmbientLane = submitted.coupon === "" && submitted.affiliateCode === void 0;
|
|
2229
|
+
const ambientNow = normalizeAmbientAffiliate(sdk.getAffiliateCode());
|
|
2230
|
+
if (isAmbientLane && ambientNow !== normalizeAmbientAffiliate(quotedAmbient)) {
|
|
2231
|
+
setCheckoutError(
|
|
2232
|
+
"The referral discount on this cart changed. Review the updated total, then check out again."
|
|
2233
|
+
);
|
|
2234
|
+
await scheduleRefresh();
|
|
2235
|
+
return;
|
|
2236
|
+
}
|
|
2237
|
+
const pinnedAffiliateCode = submitted.affiliateCode ?? (isAmbientLane ? normalizeAmbientAffiliate(quotedAmbient) : ambientNow);
|
|
2238
|
+
const result = await sdk.checkout({
|
|
2239
|
+
...submitted,
|
|
2240
|
+
affiliateCode: pinnedAffiliateCode,
|
|
2241
|
+
currency: getBuyerCurrency()
|
|
2242
|
+
});
|
|
2243
|
+
if (!result.success) {
|
|
2244
|
+
if (result.code === SELF_REFERRAL_REFUSAL_CODE) {
|
|
2245
|
+
setSelfReferralBlocked(true);
|
|
2246
|
+
setCheckoutError(SELF_REFERRAL_MESSAGE);
|
|
2247
|
+
return;
|
|
2248
|
+
}
|
|
2249
|
+
const quoteProofMessage = result.code ? QUOTE_PROOF_REFUSAL_MESSAGES[result.code] : void 0;
|
|
2250
|
+
if (quoteProofMessage) {
|
|
2251
|
+
setCheckoutError(quoteProofMessage);
|
|
2252
|
+
await scheduleRefresh();
|
|
2253
|
+
return;
|
|
2254
|
+
}
|
|
2255
|
+
const refusalMessage = result.code ? CHECKOUT_REFUSAL_MESSAGES[result.code] : void 0;
|
|
2256
|
+
setCheckoutError(refusalMessage ?? (result.message?.trim() || "Checkout could not be started."));
|
|
2257
|
+
return;
|
|
2258
|
+
}
|
|
2259
|
+
if (!result.redirectUrl) {
|
|
2260
|
+
setCheckoutError("Checkout could not be started.");
|
|
2261
|
+
}
|
|
2262
|
+
} catch (error) {
|
|
2263
|
+
setCheckoutError(errorMessage(error, "Checkout could not be started."));
|
|
2264
|
+
} finally {
|
|
2265
|
+
setBusyCount((value2) => Math.max(0, value2 - 1));
|
|
2266
|
+
}
|
|
2267
|
+
}, [scheduleRefresh]);
|
|
2268
|
+
const checkoutFields = useMemo(() => resolveCheckoutFields(lines), [lines]);
|
|
2269
|
+
const value = useMemo(() => ({
|
|
2270
|
+
lines,
|
|
2271
|
+
count,
|
|
2272
|
+
quote,
|
|
2273
|
+
quoteError,
|
|
2274
|
+
mutationError,
|
|
2275
|
+
couponRejected,
|
|
2276
|
+
quotedAmbientAffiliate,
|
|
2277
|
+
cartMaxWarning,
|
|
2278
|
+
requiresShipping: quote?.requires_shipping === true,
|
|
2279
|
+
checkoutError,
|
|
2280
|
+
selfReferralBlocked,
|
|
2281
|
+
clearBlockedReferral,
|
|
2282
|
+
checkoutFields,
|
|
2283
|
+
checkoutNeedsDetails: checkoutNeedsDetails(checkoutFields),
|
|
2284
|
+
busy: busyCount > 0,
|
|
2285
|
+
couponInput,
|
|
2286
|
+
setCouponInput: editCouponInput,
|
|
2287
|
+
drawerOpen,
|
|
2288
|
+
setDrawerOpen,
|
|
2289
|
+
add,
|
|
2290
|
+
addBundle,
|
|
2291
|
+
update,
|
|
2292
|
+
remove,
|
|
2293
|
+
clear,
|
|
2294
|
+
applyCoupon,
|
|
2295
|
+
removeCoupon,
|
|
2296
|
+
checkout
|
|
2297
|
+
}), [
|
|
2298
|
+
add,
|
|
2299
|
+
addBundle,
|
|
2300
|
+
applyCoupon,
|
|
2301
|
+
busyCount,
|
|
2302
|
+
cartMaxWarning,
|
|
2303
|
+
checkout,
|
|
2304
|
+
checkoutError,
|
|
2305
|
+
checkoutFields,
|
|
2306
|
+
clear,
|
|
2307
|
+
clearBlockedReferral,
|
|
2308
|
+
count,
|
|
2309
|
+
couponInput,
|
|
2310
|
+
couponRejected,
|
|
2311
|
+
drawerOpen,
|
|
2312
|
+
editCouponInput,
|
|
2313
|
+
lines,
|
|
2314
|
+
mutationError,
|
|
2315
|
+
quote,
|
|
2316
|
+
quoteError,
|
|
2317
|
+
quotedAmbientAffiliate,
|
|
2318
|
+
remove,
|
|
2319
|
+
removeCoupon,
|
|
2320
|
+
selfReferralBlocked,
|
|
2321
|
+
update
|
|
2322
|
+
]);
|
|
2323
|
+
return /* @__PURE__ */ jsx(CartContext.Provider, { value, children });
|
|
2324
|
+
}
|
|
2325
|
+
function useCart() {
|
|
2326
|
+
const context = useContext(CartContext);
|
|
2327
|
+
if (!context) throw new Error("useCart must be used inside CartProvider.");
|
|
2328
|
+
return context;
|
|
2329
|
+
}
|
|
2330
|
+
function startCartMutation(mutation) {
|
|
2331
|
+
void mutation.catch(() => void 0);
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
// src/cart-view.ts
|
|
2335
|
+
function positiveInteger(value, fallback) {
|
|
2336
|
+
return typeof value === "number" && Number.isFinite(value) ? Math.max(1, Math.trunc(value)) : fallback;
|
|
2337
|
+
}
|
|
2338
|
+
function snapshotUnitPrice(line) {
|
|
2339
|
+
const price = line.price_data?.unit_price;
|
|
2340
|
+
return typeof price === "number" && Number.isFinite(price) ? Math.max(price, 0) : null;
|
|
2341
|
+
}
|
|
2342
|
+
function lineSubtitle(line) {
|
|
2343
|
+
const parts = [];
|
|
2344
|
+
const variantTitle = line.variant_title?.trim();
|
|
2345
|
+
if (variantTitle) parts.push(variantTitle);
|
|
2346
|
+
const addons = (line.addon_labels ?? []).map((label) => label.trim()).filter(Boolean);
|
|
2347
|
+
if (addons.length > 0) parts.push(addons.join(", "));
|
|
2348
|
+
return parts.join(" \xB7 ");
|
|
2349
|
+
}
|
|
2350
|
+
function buildCartView(lines) {
|
|
2351
|
+
const rows = lines.map((line) => {
|
|
2352
|
+
const quantity = positiveInteger(line.quantity, 1);
|
|
2353
|
+
const unitPrice = snapshotUnitPrice(line);
|
|
2354
|
+
const minimumQuantity = positiveInteger(line.min_quantity, 1);
|
|
2355
|
+
const maximumQuantity = typeof line.max_quantity === "number" && Number.isFinite(line.max_quantity) ? Math.max(1, Math.trunc(line.max_quantity)) : Number.POSITIVE_INFINITY;
|
|
2356
|
+
return {
|
|
2357
|
+
lineId: line.line_id,
|
|
2358
|
+
// A line without a display snapshot still has to name itself; the
|
|
2359
|
+
// product id is the only truthful stand-in the cart holds.
|
|
2360
|
+
title: line.title?.trim() || line.product_id,
|
|
2361
|
+
subtitle: lineSubtitle(line),
|
|
2362
|
+
imageUrl: line.image_url?.trim() || null,
|
|
2363
|
+
quantity,
|
|
2364
|
+
lineTotal: roundCurrency((unitPrice ?? 0) * quantity),
|
|
2365
|
+
canDecrease: quantity > minimumQuantity,
|
|
2366
|
+
canIncrease: quantity < maximumQuantity,
|
|
2367
|
+
minimumQuantity
|
|
2368
|
+
};
|
|
2369
|
+
});
|
|
2370
|
+
return {
|
|
2371
|
+
lines: rows,
|
|
2372
|
+
subtotal: roundCurrency(rows.reduce((sum, row) => sum + row.lineTotal, 0)),
|
|
2373
|
+
subtotalIsEstimate: lines.length > 0 && lines.some((line) => snapshotUnitPrice(line) === null),
|
|
2374
|
+
isEmpty: rows.length === 0
|
|
2375
|
+
};
|
|
2376
|
+
}
|
|
2377
|
+
function parseAmount(value) {
|
|
2378
|
+
const amount = Number(value);
|
|
2379
|
+
return Number.isFinite(amount) ? amount : 0;
|
|
2380
|
+
}
|
|
2381
|
+
function buildCartTotals(view, quote, fallbackCurrency) {
|
|
2382
|
+
if (!quote) {
|
|
2383
|
+
return {
|
|
2384
|
+
subtotal: view.subtotal,
|
|
2385
|
+
discounts: [],
|
|
2386
|
+
aggregateDiscount: null,
|
|
2387
|
+
total: view.subtotal,
|
|
2388
|
+
currency: fallbackCurrency,
|
|
2389
|
+
isEstimate: true
|
|
2390
|
+
};
|
|
2391
|
+
}
|
|
2392
|
+
const discount = parseAmount(quote.discount);
|
|
2393
|
+
const itemised = quote.applied_discounts ?? [];
|
|
2394
|
+
return {
|
|
2395
|
+
subtotal: parseAmount(quote.subtotal),
|
|
2396
|
+
discounts: itemised.map((entry) => ({ label: entry.label, amount: entry.amount })),
|
|
2397
|
+
// The quote states a reduction it did not break down: show it as one row
|
|
2398
|
+
// rather than letting the arithmetic of the block silently not add up.
|
|
2399
|
+
aggregateDiscount: itemised.length === 0 && discount > 0 ? discount : null,
|
|
2400
|
+
total: parseAmount(quote.total),
|
|
2401
|
+
currency: quote.currency || fallbackCurrency,
|
|
2402
|
+
isEstimate: view.subtotalIsEstimate
|
|
2403
|
+
};
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
// src/contact.ts
|
|
2407
|
+
var MESSAGE_MAX_LENGTH = 2e3;
|
|
2408
|
+
var MIN_SUBJECT_LENGTH = 2;
|
|
2409
|
+
var MAX_TITLE_LENGTH = 30;
|
|
2410
|
+
var DEFAULT_TICKET_TITLE = "Contact Request";
|
|
2411
|
+
function validateContactSubmission(values, context) {
|
|
2412
|
+
if (!context.contactEnabled) return "The contact form is disabled for this store.";
|
|
2413
|
+
if (!values.name.trim() || !values.email.trim() || !values.message.trim()) {
|
|
2414
|
+
return "Name, email and message are required.";
|
|
2415
|
+
}
|
|
2416
|
+
if (context.requireInvoice && !values.invoiceId.trim()) {
|
|
2417
|
+
return "An order or invoice ID is required.";
|
|
2418
|
+
}
|
|
2419
|
+
return null;
|
|
2420
|
+
}
|
|
2421
|
+
function requiredGlobal(name) {
|
|
2422
|
+
const value = window[name];
|
|
2423
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
2424
|
+
throw new Error(
|
|
2425
|
+
`Shoppex bootstrap global "${name}" is missing. This storefront must be served by the Shoppex storefront (or preview) worker.`
|
|
2426
|
+
);
|
|
2427
|
+
}
|
|
2428
|
+
return value.trim().replace(/\/+$/, "");
|
|
2429
|
+
}
|
|
2430
|
+
function buildTicketMessage(values) {
|
|
2431
|
+
const name = values.name.trim();
|
|
2432
|
+
const body = values.message.trim();
|
|
2433
|
+
return `${name ? `Name: ${name}
|
|
2434
|
+
|
|
2435
|
+
` : ""}${body}`.slice(0, MESSAGE_MAX_LENGTH);
|
|
2436
|
+
}
|
|
2437
|
+
async function submitContactTicket(values, context) {
|
|
2438
|
+
const error = validateContactSubmission(values, context);
|
|
2439
|
+
if (error) throw new Error(error);
|
|
2440
|
+
const shopSlug = requiredGlobal("shopSlug");
|
|
2441
|
+
const apiBaseUrl = requiredGlobal("apiBaseUrl");
|
|
2442
|
+
const subject = values.subject.trim();
|
|
2443
|
+
const invoiceId = values.invoiceId.trim();
|
|
2444
|
+
const response = await fetch(
|
|
2445
|
+
`${apiBaseUrl}/v1/storefront/shops/name/${encodeURIComponent(shopSlug)}/tickets`,
|
|
2446
|
+
{
|
|
2447
|
+
method: "POST",
|
|
2448
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
2449
|
+
body: JSON.stringify({
|
|
2450
|
+
email: values.email.trim().toLowerCase(),
|
|
2451
|
+
title: subject.length >= MIN_SUBJECT_LENGTH ? subject.slice(0, MAX_TITLE_LENGTH) : DEFAULT_TICKET_TITLE,
|
|
2452
|
+
message: buildTicketMessage(values),
|
|
2453
|
+
...invoiceId ? { invoice_id: invoiceId } : {}
|
|
2454
|
+
})
|
|
2455
|
+
}
|
|
2456
|
+
);
|
|
2457
|
+
const payload = await response.json().catch(() => null);
|
|
2458
|
+
if (!response.ok) {
|
|
2459
|
+
throw new Error(payload?.message || payload?.error || "Failed to send your message.");
|
|
2460
|
+
}
|
|
2461
|
+
if (!payload?.data?.uniqid) {
|
|
2462
|
+
throw new Error("Your message was accepted, but the store did not confirm a ticket.");
|
|
2463
|
+
}
|
|
2464
|
+
}
|
|
2465
|
+
function resolveSupportChannels(links) {
|
|
2466
|
+
const byNetwork = new Map(links.map((link) => [link.network, link.href]));
|
|
2467
|
+
return ["discord", "telegram", "email"].flatMap((network) => {
|
|
2468
|
+
const href = byNetwork.get(network);
|
|
2469
|
+
if (!href) return [];
|
|
2470
|
+
return [{
|
|
2471
|
+
label: network === "email" ? "Email" : network === "discord" ? "Discord" : "Telegram",
|
|
2472
|
+
href,
|
|
2473
|
+
isMail: network === "email"
|
|
2474
|
+
}];
|
|
2475
|
+
});
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
// src/menus.ts
|
|
2479
|
+
import { useEffect as useEffect2, useMemo as useMemo2, useState as useState2 } from "react";
|
|
2480
|
+
function toNavLink(item) {
|
|
2481
|
+
const label = typeof item.title === "string" ? item.title.trim() : "";
|
|
2482
|
+
if (!label) return null;
|
|
2483
|
+
const pageSlug = typeof item.page_slug === "string" ? item.page_slug.trim() : "";
|
|
2484
|
+
if (pageSlug) return { label, href: `/page/${pageSlug}`, target: null };
|
|
2485
|
+
const url = typeof item.url === "string" ? item.url.trim() : "";
|
|
2486
|
+
if (!url) return null;
|
|
2487
|
+
return { label, href: url, target: item.target ?? null };
|
|
2488
|
+
}
|
|
2489
|
+
function resolveMenuLinksForSlot(menus, slot) {
|
|
2490
|
+
const menu = menus.find((entry) => entry.slot === slot);
|
|
2491
|
+
if (!menu || !Array.isArray(menu.items)) return [];
|
|
2492
|
+
return menu.items.flatMap((item) => {
|
|
2493
|
+
const link = toNavLink(item);
|
|
2494
|
+
return link ? [{ link, sortOrder: item.sort_order ?? 0 }] : [];
|
|
2495
|
+
}).sort((a, b) => a.sortOrder - b.sortOrder).map((entry) => entry.link);
|
|
2496
|
+
}
|
|
2497
|
+
var menusPromise = null;
|
|
2498
|
+
function loadMerchantMenus() {
|
|
2499
|
+
menusPromise ??= getShoppex().getMenus().then((result) => result.success && Array.isArray(result.data) ? result.data : []).catch(() => []);
|
|
2500
|
+
return menusPromise;
|
|
2501
|
+
}
|
|
2502
|
+
function useNavigationLinks(slot, themeLinks, authored) {
|
|
2503
|
+
const themeNavLinks = useMemo2(
|
|
2504
|
+
() => themeLinks.map((link) => ({ ...link, target: null })),
|
|
2505
|
+
[themeLinks]
|
|
2506
|
+
);
|
|
2507
|
+
const [menuLinks, setMenuLinks] = useState2(null);
|
|
2508
|
+
useEffect2(() => {
|
|
2509
|
+
if (authored) return void 0;
|
|
2510
|
+
let active = true;
|
|
2511
|
+
void loadMerchantMenus().then((menus) => {
|
|
2512
|
+
if (active) setMenuLinks(resolveMenuLinksForSlot(menus, slot));
|
|
2513
|
+
});
|
|
2514
|
+
return () => {
|
|
2515
|
+
active = false;
|
|
2516
|
+
};
|
|
2517
|
+
}, [authored, slot]);
|
|
2518
|
+
if (!authored && menuLinks !== null && menuLinks.length > 0) {
|
|
2519
|
+
return { links: menuLinks, fromTheme: false };
|
|
2520
|
+
}
|
|
2521
|
+
return { links: themeNavLinks, fromTheme: true };
|
|
2522
|
+
}
|
|
2523
|
+
|
|
2524
|
+
// src/reviews.ts
|
|
2525
|
+
import { useEffect as useEffect3, useState as useState3 } from "react";
|
|
2526
|
+
var SHOP_REVIEW_PAGE_SIZE = 12;
|
|
2527
|
+
function toTimestamp(value) {
|
|
2528
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
2529
|
+
return value < 1e12 ? value * 1e3 : value;
|
|
2530
|
+
}
|
|
2531
|
+
if (typeof value !== "string" || value.trim() === "") return 0;
|
|
2532
|
+
const numeric = Number(value);
|
|
2533
|
+
if (Number.isFinite(numeric)) {
|
|
2534
|
+
return numeric < 1e12 ? numeric * 1e3 : numeric;
|
|
2535
|
+
}
|
|
2536
|
+
const parsed = Date.parse(value);
|
|
2537
|
+
return Number.isNaN(parsed) ? 0 : parsed;
|
|
2538
|
+
}
|
|
2539
|
+
function formatDateLabel(timestamp) {
|
|
2540
|
+
if (!timestamp) return "";
|
|
2541
|
+
return new Date(timestamp).toLocaleDateString("en-US", {
|
|
2542
|
+
day: "numeric",
|
|
2543
|
+
month: "short",
|
|
2544
|
+
year: "numeric"
|
|
2545
|
+
});
|
|
2546
|
+
}
|
|
2547
|
+
function toStarCount(rating) {
|
|
2548
|
+
if (!Number.isFinite(rating)) return 0;
|
|
2549
|
+
return Math.max(0, Math.min(5, Math.round(rating)));
|
|
2550
|
+
}
|
|
2551
|
+
function toShopReviews(rows, labels) {
|
|
2552
|
+
return rows.filter((row) => typeof row.rating === "number" && Number.isFinite(row.rating) && row.rating > 0).map((row, index) => {
|
|
2553
|
+
const timestamp = toTimestamp(row.created_at);
|
|
2554
|
+
const comment = (row.comment ?? row.message ?? "").trim();
|
|
2555
|
+
const author = (row.author ?? row.customer_name ?? "").trim();
|
|
2556
|
+
return {
|
|
2557
|
+
id: row.id || `review:${index}`,
|
|
2558
|
+
rating: row.rating,
|
|
2559
|
+
starCount: toStarCount(row.rating),
|
|
2560
|
+
comment: comment.length > 0 ? comment : labels.emptyComment,
|
|
2561
|
+
author: author.length > 0 ? author : labels.anonymousAuthor,
|
|
2562
|
+
dateLabel: formatDateLabel(timestamp),
|
|
2563
|
+
timestamp,
|
|
2564
|
+
productTitle: row.product_title?.trim() || null,
|
|
2565
|
+
verified: row.verified === true
|
|
2566
|
+
};
|
|
2567
|
+
}).sort((a, b) => b.timestamp - a.timestamp);
|
|
2568
|
+
}
|
|
2569
|
+
function reviewsForProduct(reviews, productTitle) {
|
|
2570
|
+
const title = productTitle.trim();
|
|
2571
|
+
if (!title) return [];
|
|
2572
|
+
return reviews.filter((review) => review.productTitle === title);
|
|
2573
|
+
}
|
|
2574
|
+
function averageRating(reviews) {
|
|
2575
|
+
if (reviews.length === 0) return null;
|
|
2576
|
+
return reviews.reduce((total, review) => total + review.rating, 0) / reviews.length;
|
|
2577
|
+
}
|
|
2578
|
+
function useShopReviews(labels) {
|
|
2579
|
+
const [state, setState] = useState3({ status: "loading" });
|
|
2580
|
+
const { anonymousAuthor, emptyComment } = labels;
|
|
2581
|
+
useEffect3(() => {
|
|
2582
|
+
let active = true;
|
|
2583
|
+
getShoppex().getShopReviews().then((result) => {
|
|
2584
|
+
if (!active) return;
|
|
2585
|
+
if (!result.success || !result.data) {
|
|
2586
|
+
setState({ status: "error" });
|
|
2587
|
+
return;
|
|
2588
|
+
}
|
|
2589
|
+
const reviews = toShopReviews(result.data, { anonymousAuthor, emptyComment });
|
|
2590
|
+
setState({ status: "ready", reviews, average: averageRating(reviews) });
|
|
2591
|
+
}).catch(() => {
|
|
2592
|
+
if (active) setState({ status: "error" });
|
|
2593
|
+
});
|
|
2594
|
+
return () => {
|
|
2595
|
+
active = false;
|
|
2596
|
+
};
|
|
2597
|
+
}, [anonymousAuthor, emptyComment]);
|
|
2598
|
+
return state;
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
// src/shop-pages.ts
|
|
2602
|
+
function parseFaqItems(value) {
|
|
2603
|
+
if (!Array.isArray(value)) return [];
|
|
2604
|
+
const items = [];
|
|
2605
|
+
for (const entry of value) {
|
|
2606
|
+
if (entry === null || typeof entry !== "object") continue;
|
|
2607
|
+
const record = entry;
|
|
2608
|
+
const question = typeof record.question === "string" ? record.question.trim() : "";
|
|
2609
|
+
const answer = typeof record.answer === "string" ? record.answer.trim() : "";
|
|
2610
|
+
if (question && answer) items.push({ question, answer });
|
|
2611
|
+
}
|
|
2612
|
+
return items;
|
|
2613
|
+
}
|
|
2614
|
+
async function renderShopPageMarkdownHtml(content) {
|
|
2615
|
+
const trimmed = typeof content === "string" ? content.trim() : "";
|
|
2616
|
+
if (!trimmed) return "";
|
|
2617
|
+
const { marked } = await import("marked");
|
|
2618
|
+
return marked.parse(trimmed, { async: false, gfm: true, breaks: true });
|
|
2619
|
+
}
|
|
2620
|
+
async function buildShopPageModel(page) {
|
|
2621
|
+
const faqItems = page.template_type === "faq" ? parseFaqItems(page.settings?.faq_items) : [];
|
|
2622
|
+
return {
|
|
2623
|
+
title: page.name,
|
|
2624
|
+
// A FAQ page never shows its body, so its markdown is never parsed and the
|
|
2625
|
+
// parser is never fetched for one.
|
|
2626
|
+
bodyMarkdownHtml: faqItems.length > 0 ? "" : await renderShopPageMarkdownHtml(page.content),
|
|
2627
|
+
faqItems
|
|
2628
|
+
};
|
|
2629
|
+
}
|
|
2630
|
+
async function loadShopPage(slug) {
|
|
2631
|
+
try {
|
|
2632
|
+
const result = await getShoppex().getPage(slug);
|
|
2633
|
+
const page = result.success ? result.data : void 0;
|
|
2634
|
+
if (!page || typeof page.name !== "string" || !page.name.trim()) {
|
|
2635
|
+
return { status: "missing" };
|
|
2636
|
+
}
|
|
2637
|
+
return { status: "ready", page: await buildShopPageModel(page) };
|
|
2638
|
+
} catch {
|
|
2639
|
+
return { status: "missing" };
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2643
|
+
// src/storefront-context.tsx
|
|
2644
|
+
import { createContext as createContext2, useContext as useContext2, useEffect as useEffect4, useState as useState4 } from "react";
|
|
2645
|
+
import { jsx as jsx2 } from "react/jsx-runtime";
|
|
2646
|
+
function readStorefrontData() {
|
|
2647
|
+
const settings = getStorefrontSettings();
|
|
2648
|
+
const catalog = buildStorefrontCatalog(settings);
|
|
2649
|
+
return { settings, shop: getStorefrontShop(catalog.listedProductCount), catalog };
|
|
2650
|
+
}
|
|
2651
|
+
var StorefrontContext = createContext2(null);
|
|
2652
|
+
function StorefrontProvider({ data, children }) {
|
|
2653
|
+
return /* @__PURE__ */ jsx2(StorefrontContext.Provider, { value: data, children });
|
|
2654
|
+
}
|
|
2655
|
+
function useStorefront() {
|
|
2656
|
+
const data = useContext2(StorefrontContext);
|
|
2657
|
+
if (!data) throw new Error("useStorefront must be used inside StorefrontProvider.");
|
|
2658
|
+
return data;
|
|
2659
|
+
}
|
|
2660
|
+
function useOnlineUserCount() {
|
|
2661
|
+
const [count, setCount] = useState4(null);
|
|
2662
|
+
useEffect4(() => {
|
|
2663
|
+
let active = true;
|
|
2664
|
+
getShoppex().getStorefrontOnlineUsers().then((result) => {
|
|
2665
|
+
if (!active || !result.success || !result.data) return;
|
|
2666
|
+
const value = result.data.count;
|
|
2667
|
+
if (Number.isFinite(value) && value > 0) setCount(Math.floor(value));
|
|
2668
|
+
}).catch(() => void 0);
|
|
2669
|
+
return () => {
|
|
2670
|
+
active = false;
|
|
2671
|
+
};
|
|
2672
|
+
}, []);
|
|
2673
|
+
return count;
|
|
2674
|
+
}
|
|
2675
|
+
export {
|
|
2676
|
+
ACCOUNT_ARTIFACT_CAPABILITIES,
|
|
2677
|
+
ACCOUNT_PATH,
|
|
2678
|
+
ACCOUNT_PATH_ALIAS,
|
|
2679
|
+
ACCOUNT_ROUTE_MESSAGE_KEYS,
|
|
2680
|
+
ACCOUNT_STOREFRONT_ROUTES,
|
|
2681
|
+
ACCOUNT_TABS,
|
|
2682
|
+
CATALOG_PAGE_SIZE,
|
|
2683
|
+
CATALOG_SORTS,
|
|
2684
|
+
CATALOG_SORT_LABELS,
|
|
2685
|
+
CartProvider,
|
|
2686
|
+
DEFAULT_ACCOUNT_TAB,
|
|
2687
|
+
DEFAULT_LOW_STOCK_THRESHOLD,
|
|
2688
|
+
DELIVERY_TIME_UNITS,
|
|
2689
|
+
IMPLEMENTED_ACCOUNT_TABS,
|
|
2690
|
+
MAX_PAY_WHAT_YOU_WANT_PRICE,
|
|
2691
|
+
PRODUCT_BADGE_ICON_NAMES,
|
|
2692
|
+
QUOTE_UNAVAILABLE_CHECKOUT_ERROR,
|
|
2693
|
+
SERVING_CONTEXT_HINT,
|
|
2694
|
+
SHOP_REVIEW_PAGE_SIZE,
|
|
2695
|
+
STOREFRONT_SORT_KEYS,
|
|
2696
|
+
StorefrontProvider,
|
|
2697
|
+
accountOrderHref,
|
|
2698
|
+
accountRouteMessage,
|
|
2699
|
+
accountTabHref,
|
|
2700
|
+
accountTabLabelKey,
|
|
2701
|
+
averageRating,
|
|
2702
|
+
badgesForCard,
|
|
2703
|
+
badgesForProductPage,
|
|
2704
|
+
buildCartTotals,
|
|
2705
|
+
buildCartView,
|
|
2706
|
+
buildCustomFieldPayload,
|
|
2707
|
+
buildInitialCheckoutFieldValues,
|
|
2708
|
+
buildProductDetail,
|
|
2709
|
+
buildStorefrontCatalog,
|
|
2710
|
+
calculateDisplayLineTotal,
|
|
2711
|
+
calculateLinePricing,
|
|
2712
|
+
canonicalAccountPath,
|
|
2713
|
+
checkoutFieldKey,
|
|
2714
|
+
checkoutFieldWriteRepriced,
|
|
2715
|
+
checkoutNeedsDetails,
|
|
2716
|
+
clampQuantity,
|
|
2717
|
+
clearRecoveredQuoteCheckoutError,
|
|
2718
|
+
filterCatalogCards,
|
|
2719
|
+
findStorefrontProduct,
|
|
2720
|
+
firstOptionalString,
|
|
2721
|
+
flattenMerchantHtmlToText,
|
|
2722
|
+
formatCompactCount,
|
|
2723
|
+
formatDeliveryTime,
|
|
2724
|
+
formatLicenseTerm,
|
|
2725
|
+
formatMoney,
|
|
2726
|
+
formatSubscriptionCadence,
|
|
2727
|
+
formatSubscriptionTrial,
|
|
2728
|
+
getAuthoredContent,
|
|
2729
|
+
getBuyerCurrency,
|
|
2730
|
+
getProductGroups,
|
|
2731
|
+
getProducts,
|
|
2732
|
+
getShoppex,
|
|
2733
|
+
getStorefrontAddons,
|
|
2734
|
+
getStorefrontCategories,
|
|
2735
|
+
getStorefrontListing,
|
|
2736
|
+
getStorefrontSettings,
|
|
2737
|
+
getStorefrontShop,
|
|
2738
|
+
hasInjectedStorefrontData,
|
|
2739
|
+
isAccountTab,
|
|
2740
|
+
isBlankMerchantHtml,
|
|
2741
|
+
isCheckboxValueChecked,
|
|
2742
|
+
isImplementedAccountTab,
|
|
2743
|
+
isProductSoldOut,
|
|
2744
|
+
isRecord,
|
|
2745
|
+
isSelectionSoldOut,
|
|
2746
|
+
loadShopPage,
|
|
2747
|
+
missingRequiredCheckoutValues,
|
|
2748
|
+
optionalBoolean,
|
|
2749
|
+
optionalString,
|
|
2750
|
+
optionalStringWithDefault,
|
|
2751
|
+
paginateCatalogCards,
|
|
2752
|
+
parseAccountPath,
|
|
2753
|
+
parseCartQuoteLines,
|
|
2754
|
+
parseEnum,
|
|
2755
|
+
parseFiniteNumber,
|
|
2756
|
+
parseProductBadges,
|
|
2757
|
+
parseStorefrontSortKey,
|
|
2758
|
+
readCheckoutNeedsDetails,
|
|
2759
|
+
readStorefrontData,
|
|
2760
|
+
requireArray,
|
|
2761
|
+
requireBoolean,
|
|
2762
|
+
requireNumber,
|
|
2763
|
+
requireRecord,
|
|
2764
|
+
requireString,
|
|
2765
|
+
requireStringFromKeys,
|
|
2766
|
+
requireUnknownRecord,
|
|
2767
|
+
resolveAccountView,
|
|
2768
|
+
resolveBadgeForeground,
|
|
2769
|
+
resolveCartBoundsHeals,
|
|
2770
|
+
resolveCategorySelection,
|
|
2771
|
+
resolveCheckoutFields,
|
|
2772
|
+
resolveDeliveryPromise,
|
|
2773
|
+
resolveDisplayStock,
|
|
2774
|
+
resolveHideStockCounter,
|
|
2775
|
+
resolveLicenseTerm,
|
|
2776
|
+
resolveMenuLinksForSlot,
|
|
2777
|
+
resolvePayWhatYouWantMinimum,
|
|
2778
|
+
resolveProductVideoEmbedSrc,
|
|
2779
|
+
resolveQuantityBounds,
|
|
2780
|
+
resolveSupportChannels,
|
|
2781
|
+
resolveUnsellableLineIds,
|
|
2782
|
+
resolveVariantStock,
|
|
2783
|
+
reviewsForProduct,
|
|
2784
|
+
roundCurrency,
|
|
2785
|
+
sanitizeMerchantHtml,
|
|
2786
|
+
sortCatalogCards,
|
|
2787
|
+
startCartMutation,
|
|
2788
|
+
stripMerchantHtmlImages,
|
|
2789
|
+
submitContactTicket,
|
|
2790
|
+
toShopReviews,
|
|
2791
|
+
toStarCount,
|
|
2792
|
+
useCart,
|
|
2793
|
+
useNavigationLinks,
|
|
2794
|
+
useOnlineUserCount,
|
|
2795
|
+
useShopReviews,
|
|
2796
|
+
useStorefront,
|
|
2797
|
+
validateCheckoutFieldValue,
|
|
2798
|
+
validateCheckoutFields,
|
|
2799
|
+
validateContactSubmission
|
|
2800
|
+
};
|
|
2801
|
+
//# sourceMappingURL=index.js.map
|