@sazito/checkout 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/LICENSE +21 -0
- package/README.md +121 -0
- package/dist/chunks/labels-BlMZkOsV.cjs +1646 -0
- package/dist/chunks/labels-BlMZkOsV.cjs.map +1 -0
- package/dist/chunks/labels-ChywPk2i.js +1613 -0
- package/dist/chunks/labels-ChywPk2i.js.map +1 -0
- package/dist/chunks/use-checkout-Cvp_K3UW.cjs +69 -0
- package/dist/chunks/use-checkout-Cvp_K3UW.cjs.map +1 -0
- package/dist/chunks/use-checkout-E0O8HsSs.js +63 -0
- package/dist/chunks/use-checkout-E0O8HsSs.js.map +1 -0
- package/dist/core/index.cjs +40 -0
- package/dist/core/index.cjs.map +1 -0
- package/dist/core/index.d.cts +482 -0
- package/dist/core/index.d.ts +482 -0
- package/dist/core/index.js +3 -0
- package/dist/core/index.js.map +1 -0
- package/dist/next/index.cjs +845 -0
- package/dist/next/index.cjs.map +1 -0
- package/dist/next/index.d.cts +427 -0
- package/dist/next/index.d.ts +427 -0
- package/dist/next/index.js +834 -0
- package/dist/next/index.js.map +1 -0
- package/dist/react/index.cjs +17 -0
- package/dist/react/index.cjs.map +1 -0
- package/dist/react/index.d.cts +376 -0
- package/dist/react/index.d.ts +376 -0
- package/dist/react/index.js +7 -0
- package/dist/react/index.js.map +1 -0
- package/dist/styles.css +1696 -0
- package/package.json +98 -0
|
@@ -0,0 +1,1613 @@
|
|
|
1
|
+
import { CredentialsManager } from '@sazito/client-sdk';
|
|
2
|
+
|
|
3
|
+
function createStore(initialState) {
|
|
4
|
+
let state = initialState;
|
|
5
|
+
const listeners = new Set();
|
|
6
|
+
return {
|
|
7
|
+
getState() {
|
|
8
|
+
return state;
|
|
9
|
+
},
|
|
10
|
+
setState(partial) {
|
|
11
|
+
const patch = typeof partial === 'function' ? partial(state) : partial;
|
|
12
|
+
state = { ...state, ...patch };
|
|
13
|
+
listeners.forEach((listener) => listener());
|
|
14
|
+
},
|
|
15
|
+
subscribe(listener) {
|
|
16
|
+
listeners.add(listener);
|
|
17
|
+
return () => {
|
|
18
|
+
listeners.delete(listener);
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function createSdkBinding(options) {
|
|
25
|
+
const client = options.client;
|
|
26
|
+
if (!client) {
|
|
27
|
+
throw new Error('[@sazito/checkout] No client found. Wrap your app with <SazitoProvider client={sazito}> or pass a `client` prop to CheckoutProvider.');
|
|
28
|
+
}
|
|
29
|
+
const credentials = getClientCredentialsManager(client) ?? new CredentialsManager();
|
|
30
|
+
seedCredentials(credentials, options.credentials);
|
|
31
|
+
return {
|
|
32
|
+
client,
|
|
33
|
+
credentials,
|
|
34
|
+
hasCart() {
|
|
35
|
+
return credentials.getCartCredentials() !== null;
|
|
36
|
+
},
|
|
37
|
+
hasInvoice() {
|
|
38
|
+
return credentials.getInvoiceCredentials() !== null;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function getClientCredentialsManager(client) {
|
|
43
|
+
const candidate = client;
|
|
44
|
+
if (typeof candidate.getCredentialsManager === 'function') {
|
|
45
|
+
return candidate.getCredentialsManager();
|
|
46
|
+
}
|
|
47
|
+
if (candidate.credentialsManager instanceof CredentialsManager) {
|
|
48
|
+
return candidate.credentialsManager;
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
function seedCredentials(credentials, seed) {
|
|
53
|
+
if (!seed)
|
|
54
|
+
return;
|
|
55
|
+
if (seed.cart?.identifier) {
|
|
56
|
+
credentials.setCartCredentials({ identifier: seed.cart.identifier });
|
|
57
|
+
}
|
|
58
|
+
if (seed.invoice?.identifier && typeof seed.invoice.id === 'number') {
|
|
59
|
+
credentials.setInvoiceCredentials({ id: seed.invoice.id, identifier: seed.invoice.identifier });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Map an HTTP status (per SALES_FLOW docs) to a checkout error code. */
|
|
64
|
+
function codeForStatus(status) {
|
|
65
|
+
switch (status) {
|
|
66
|
+
case 416:
|
|
67
|
+
return 'min_basket';
|
|
68
|
+
case 418:
|
|
69
|
+
return 'rate_limited';
|
|
70
|
+
case 422:
|
|
71
|
+
return 'cart_invalid';
|
|
72
|
+
case 423:
|
|
73
|
+
return 'invoice_locked';
|
|
74
|
+
default:
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const MESSAGES = {
|
|
79
|
+
no_cart: {
|
|
80
|
+
fa: 'سبد خریدی یافت نشد. لطفاً ابتدا یک سبد خرید ایجاد کنید.',
|
|
81
|
+
en: 'No cart found. Please create a cart first.'
|
|
82
|
+
},
|
|
83
|
+
no_invoice: {
|
|
84
|
+
fa: 'فاکتوری یافت نشد.',
|
|
85
|
+
en: 'No invoice found.'
|
|
86
|
+
},
|
|
87
|
+
min_basket: {
|
|
88
|
+
fa: 'حداقل مبلغ سبد خرید رعایت نشده است.',
|
|
89
|
+
en: 'Minimum basket amount not reached.'
|
|
90
|
+
},
|
|
91
|
+
rate_limited: {
|
|
92
|
+
fa: 'درخواستهای زیادی ارسال شد. کمی صبر کنید.',
|
|
93
|
+
en: 'Too many requests. Please wait a moment.'
|
|
94
|
+
},
|
|
95
|
+
cart_invalid: {
|
|
96
|
+
fa: 'سبد خرید نامعتبر است و بازنشانی شد.',
|
|
97
|
+
en: 'Cart is invalid and was reset.'
|
|
98
|
+
},
|
|
99
|
+
invoice_locked: {
|
|
100
|
+
fa: 'فاکتور قفل شده است. لطفاً صفحه را بازخوانی کنید.',
|
|
101
|
+
en: 'Invoice is locked. Please reload the page.'
|
|
102
|
+
},
|
|
103
|
+
stock_violated: {
|
|
104
|
+
fa: 'موجودی برخی از محصولات سبد خرید رزرو شده یا کافی نیست.',
|
|
105
|
+
en: 'Some products are out of stock or reserved.'
|
|
106
|
+
},
|
|
107
|
+
shipping_required: {
|
|
108
|
+
fa: 'لطفاً روش ارسال را انتخاب کنید.',
|
|
109
|
+
en: 'Please select a shipping method.'
|
|
110
|
+
},
|
|
111
|
+
address_required: {
|
|
112
|
+
fa: 'لطفاً اطلاعات ارسال را کامل وارد کنید.',
|
|
113
|
+
en: 'Please complete the shipping information.'
|
|
114
|
+
},
|
|
115
|
+
payment_failed: {
|
|
116
|
+
fa: 'پرداخت ناموفق بود. لطفاً دوباره تلاش کنید.',
|
|
117
|
+
en: 'Payment failed. Please try again.'
|
|
118
|
+
},
|
|
119
|
+
network: {
|
|
120
|
+
fa: 'خطای ارتباط با سرور. لطفاً دوباره تلاش کنید.',
|
|
121
|
+
en: 'Network error. Please try again.'
|
|
122
|
+
},
|
|
123
|
+
validation: {
|
|
124
|
+
fa: 'اطلاعات واردشده نامعتبر است.',
|
|
125
|
+
en: 'The provided information is invalid.'
|
|
126
|
+
},
|
|
127
|
+
unknown: {
|
|
128
|
+
fa: 'خطای ناشناختهای رخ داد.',
|
|
129
|
+
en: 'An unexpected error occurred.'
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
function messageForCode(code, locale) {
|
|
133
|
+
return MESSAGES[code][locale];
|
|
134
|
+
}
|
|
135
|
+
function fromSdkError(error, locale, step) {
|
|
136
|
+
const code = codeForStatus(error.status) ??
|
|
137
|
+
(error.type === 'network' ? 'network' : error.type === 'validation' ? 'validation' : 'unknown');
|
|
138
|
+
// Prefer the server message when present, otherwise the localized default.
|
|
139
|
+
const message = error.message?.trim() || messageForCode(code, locale);
|
|
140
|
+
return { message, code, status: error.status, step };
|
|
141
|
+
}
|
|
142
|
+
function makeError(code, locale, step) {
|
|
143
|
+
return { message: messageForCode(code, locale), code, step };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function makeEvent(name, data) {
|
|
147
|
+
return {
|
|
148
|
+
name,
|
|
149
|
+
step: data?.step,
|
|
150
|
+
value: data?.value,
|
|
151
|
+
metadata: data?.metadata,
|
|
152
|
+
timestamp: Date.now()
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Performs a gateway POST by building and submitting a hidden HTML form,
|
|
158
|
+
* matching the legacy `action: 'POST'` payment behavior.
|
|
159
|
+
*/
|
|
160
|
+
function submitPostForm(url, fields) {
|
|
161
|
+
if (typeof document === 'undefined') {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const form = document.createElement('form');
|
|
165
|
+
form.method = 'POST';
|
|
166
|
+
form.action = url;
|
|
167
|
+
form.style.display = 'none';
|
|
168
|
+
Object.entries(fields).forEach(([name, value]) => {
|
|
169
|
+
const input = document.createElement('input');
|
|
170
|
+
input.type = 'hidden';
|
|
171
|
+
input.name = name;
|
|
172
|
+
input.value = value == null ? '' : String(value);
|
|
173
|
+
form.appendChild(input);
|
|
174
|
+
});
|
|
175
|
+
document.body.appendChild(form);
|
|
176
|
+
form.submit();
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Build the default browser executor. `onEvent` (from config) receives `emit`
|
|
180
|
+
* effects so analytics adapters can subscribe.
|
|
181
|
+
*/
|
|
182
|
+
function createBrowserEffectExecutor(config) {
|
|
183
|
+
return (effect) => {
|
|
184
|
+
switch (effect.type) {
|
|
185
|
+
case 'redirect':
|
|
186
|
+
if (typeof window !== 'undefined') {
|
|
187
|
+
window.location.href = effect.url;
|
|
188
|
+
}
|
|
189
|
+
return;
|
|
190
|
+
case 'post-form':
|
|
191
|
+
submitPostForm(effect.url, effect.fields);
|
|
192
|
+
return;
|
|
193
|
+
case 'emit':
|
|
194
|
+
config?.onEvent?.(effect.event);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
/** A no-op executor for SSR / tests. */
|
|
200
|
+
const noopEffectExecutor = () => { };
|
|
201
|
+
|
|
202
|
+
const idStr = (value) => String(value);
|
|
203
|
+
function emptyAddressForm() {
|
|
204
|
+
return {
|
|
205
|
+
firstName: '',
|
|
206
|
+
lastName: '',
|
|
207
|
+
mobilePhone: '',
|
|
208
|
+
email: '',
|
|
209
|
+
phoneNumber: '',
|
|
210
|
+
regionId: null,
|
|
211
|
+
cityId: null,
|
|
212
|
+
postalCode: '',
|
|
213
|
+
address: '',
|
|
214
|
+
description: ''
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* After regions load, the city ID stored in the invoice address may not match
|
|
219
|
+
* any city in the regions list (different ID schemes). Try to match by city
|
|
220
|
+
* name; if no match, reset cityId so the user can pick again.
|
|
221
|
+
*/
|
|
222
|
+
function reconcileAddressWithRegions(form, regions, savedCityName) {
|
|
223
|
+
if (form.regionId == null)
|
|
224
|
+
return form;
|
|
225
|
+
const region = regions.find((r) => r.id === form.regionId);
|
|
226
|
+
if (!region)
|
|
227
|
+
return { ...form, regionId: null, cityId: null };
|
|
228
|
+
if (form.cityId != null && region.cities.some((c) => c.id === form.cityId)) {
|
|
229
|
+
return form; // already valid
|
|
230
|
+
}
|
|
231
|
+
if (savedCityName) {
|
|
232
|
+
const match = region.cities.find((c) => c.name === savedCityName);
|
|
233
|
+
if (match)
|
|
234
|
+
return { ...form, cityId: match.id };
|
|
235
|
+
}
|
|
236
|
+
return { ...form, cityId: null };
|
|
237
|
+
}
|
|
238
|
+
/* Accepts both saved-address shapes: standalone addresses carry `city` at the
|
|
239
|
+
top level, invoice addresses nest it under `region.city`. */
|
|
240
|
+
function addressFormFromSavedAddress(addr) {
|
|
241
|
+
const base = emptyAddressForm();
|
|
242
|
+
if (!addr) {
|
|
243
|
+
return base;
|
|
244
|
+
}
|
|
245
|
+
const nestedCity = addr.region?.city;
|
|
246
|
+
const topLevelCity = addr.city;
|
|
247
|
+
return {
|
|
248
|
+
firstName: addr.firstName ?? '',
|
|
249
|
+
lastName: addr.lastName ?? '',
|
|
250
|
+
mobilePhone: addr.mobilePhone ?? '',
|
|
251
|
+
email: addr.email ?? '',
|
|
252
|
+
phoneNumber: addr.phoneNumber ?? '',
|
|
253
|
+
regionId: addr.region?.id ?? null,
|
|
254
|
+
cityId: nestedCity?.id ?? topLevelCity?.id ?? null,
|
|
255
|
+
postalCode: addr.postalCode ?? '',
|
|
256
|
+
address: addr.address ?? '',
|
|
257
|
+
description: addr.description ?? ''
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
function addressFormFromInvoice(invoice) {
|
|
261
|
+
return addressFormFromSavedAddress(invoice?.shippingAddress);
|
|
262
|
+
}
|
|
263
|
+
/** City name of a saved address regardless of shape — used to reconcile
|
|
264
|
+
region/city ids against the loaded regions list. */
|
|
265
|
+
function savedAddressCityName(addr) {
|
|
266
|
+
if (!addr)
|
|
267
|
+
return undefined;
|
|
268
|
+
return (addr.region?.city?.name ??
|
|
269
|
+
addr.city?.name);
|
|
270
|
+
}
|
|
271
|
+
function isAddressComplete(form, needsShipping, postalCodeMandatory = false, emailMandatory = false) {
|
|
272
|
+
const hasContact = form.firstName.trim() !== '' &&
|
|
273
|
+
form.lastName.trim() !== '' &&
|
|
274
|
+
form.mobilePhone.trim() !== '' &&
|
|
275
|
+
(!emailMandatory || form.email.trim() !== '');
|
|
276
|
+
if (!needsShipping) {
|
|
277
|
+
return hasContact;
|
|
278
|
+
}
|
|
279
|
+
return (hasContact &&
|
|
280
|
+
form.regionId != null &&
|
|
281
|
+
form.cityId != null &&
|
|
282
|
+
(!postalCodeMandatory || form.postalCode.trim() !== '') &&
|
|
283
|
+
form.address.trim() !== '');
|
|
284
|
+
}
|
|
285
|
+
/** Has the form diverged from what's saved on the invoice? */
|
|
286
|
+
function isAddressDirty(form, invoice) {
|
|
287
|
+
const saved = addressFormFromInvoice(invoice);
|
|
288
|
+
return Object.keys(form).some((key) => form[key] !== saved[key]);
|
|
289
|
+
}
|
|
290
|
+
function uniqueRates(rates) {
|
|
291
|
+
const seen = new Set();
|
|
292
|
+
const out = [];
|
|
293
|
+
for (const rate of rates) {
|
|
294
|
+
if (!seen.has(rate.id)) {
|
|
295
|
+
seen.add(rate.id);
|
|
296
|
+
out.push(rate);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Group invoice items into shippable bundles with their switchable rates and
|
|
303
|
+
* the currently selected rate. Digital-only invoices yield an empty list.
|
|
304
|
+
*
|
|
305
|
+
* The Sazito API returns:
|
|
306
|
+
* groupedShippingRates — all applicable rates per "bundle" (keyed by group ID)
|
|
307
|
+
* itemsShippingRate — one entry per item with its DEFAULT/current rate
|
|
308
|
+
*
|
|
309
|
+
* The default rate ID in itemsShippingRate may NOT match any rate in
|
|
310
|
+
* groupedShippingRates (different IDs for the same method type). We therefore
|
|
311
|
+
* assign items by the currently ASSIGNED rate from invoice.shippingItems first,
|
|
312
|
+
* then fall back to the default rate, and finally use positional assignment
|
|
313
|
+
* (all physical items in the single group when there is only one group).
|
|
314
|
+
*/
|
|
315
|
+
function deriveShippingGroups(invoice, applicable) {
|
|
316
|
+
if (!invoice || !invoice.needsShipping || !applicable) {
|
|
317
|
+
return [];
|
|
318
|
+
}
|
|
319
|
+
const itemById = new Map(invoice.items.map((item) => [idStr(item.id), item]));
|
|
320
|
+
// Exclude digital items — they never go through physical shipping groups.
|
|
321
|
+
const physicalItemsRate = (applicable.itemsShippingRate ?? []).filter((isr) => {
|
|
322
|
+
const item = itemById.get(idStr(isr.invoiceItemId));
|
|
323
|
+
return !item || item.productType !== 'digital';
|
|
324
|
+
});
|
|
325
|
+
if (physicalItemsRate.length === 0)
|
|
326
|
+
return [];
|
|
327
|
+
const physicalItemIds = physicalItemsRate.map((isr) => idStr(isr.invoiceItemId));
|
|
328
|
+
// item -> currently assigned rate id (from the saved invoice assignment)
|
|
329
|
+
const assignedRateById = new Map();
|
|
330
|
+
for (const si of invoice.shippingItems ?? []) {
|
|
331
|
+
for (const itemId of si.invoiceItemIds) {
|
|
332
|
+
assignedRateById.set(idStr(itemId), si.rate.id);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
// item -> default rate from applicable methods
|
|
336
|
+
const defaultRateByItem = new Map();
|
|
337
|
+
for (const isr of physicalItemsRate) {
|
|
338
|
+
defaultRateByItem.set(idStr(isr.invoiceItemId), isr.shippingRate);
|
|
339
|
+
}
|
|
340
|
+
const resolveItems = (ids) => ids.map((id) => itemById.get(id)).filter((i) => Boolean(i));
|
|
341
|
+
// Pick selected rate: prefer the saved assignment if it's in the group's rates,
|
|
342
|
+
// otherwise use the default, otherwise the first rate.
|
|
343
|
+
const resolveSelected = (itemIds, rates) => {
|
|
344
|
+
const rateSet = new Set(rates.map((r) => r.id));
|
|
345
|
+
for (const id of itemIds) {
|
|
346
|
+
const assigned = assignedRateById.get(id);
|
|
347
|
+
if (assigned != null && rateSet.has(assigned))
|
|
348
|
+
return assigned;
|
|
349
|
+
}
|
|
350
|
+
for (const id of itemIds) {
|
|
351
|
+
const def = defaultRateByItem.get(id);
|
|
352
|
+
if (def && rateSet.has(def.id))
|
|
353
|
+
return def.id;
|
|
354
|
+
}
|
|
355
|
+
// assigned or default rate isn't in this group's rate list — still prefer it
|
|
356
|
+
for (const id of itemIds) {
|
|
357
|
+
const assigned = assignedRateById.get(id);
|
|
358
|
+
if (assigned != null)
|
|
359
|
+
return assigned;
|
|
360
|
+
}
|
|
361
|
+
return rates[0]?.id ?? null;
|
|
362
|
+
};
|
|
363
|
+
const groupedEntries = Object.entries(applicable.groupedShippingRates ?? {});
|
|
364
|
+
// Single group → all physical items belong to it, show ALL its rates.
|
|
365
|
+
if (groupedEntries.length === 1) {
|
|
366
|
+
const [key, rates] = groupedEntries[0];
|
|
367
|
+
const allRates = uniqueRates(rates);
|
|
368
|
+
return [
|
|
369
|
+
{
|
|
370
|
+
key,
|
|
371
|
+
title: '',
|
|
372
|
+
itemIds: physicalItemIds,
|
|
373
|
+
items: resolveItems(physicalItemIds),
|
|
374
|
+
rates: allRates,
|
|
375
|
+
selectedRateId: resolveSelected(physicalItemIds, allRates)
|
|
376
|
+
}
|
|
377
|
+
];
|
|
378
|
+
}
|
|
379
|
+
// Multiple groups — try to assign items by assigned/default rate membership.
|
|
380
|
+
if (groupedEntries.length > 1) {
|
|
381
|
+
const groups = [];
|
|
382
|
+
for (const [key, rates] of groupedEntries) {
|
|
383
|
+
const rateSet = new Set(rates.map((r) => r.id));
|
|
384
|
+
const itemIds = physicalItemsRate
|
|
385
|
+
.filter((isr) => {
|
|
386
|
+
const itemId = idStr(isr.invoiceItemId);
|
|
387
|
+
const assigned = assignedRateById.get(itemId);
|
|
388
|
+
return (assigned != null && rateSet.has(assigned)) || rateSet.has(isr.shippingRate.id);
|
|
389
|
+
})
|
|
390
|
+
.map((isr) => idStr(isr.invoiceItemId));
|
|
391
|
+
if (itemIds.length === 0)
|
|
392
|
+
continue;
|
|
393
|
+
const allRates = uniqueRates(rates);
|
|
394
|
+
groups.push({
|
|
395
|
+
key,
|
|
396
|
+
title: '',
|
|
397
|
+
itemIds,
|
|
398
|
+
items: resolveItems(itemIds),
|
|
399
|
+
rates: allRates,
|
|
400
|
+
selectedRateId: resolveSelected(itemIds, allRates)
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
if (groups.length > 0)
|
|
404
|
+
return groups;
|
|
405
|
+
}
|
|
406
|
+
// Fallback: one group with all physical items and ALL rates from every group.
|
|
407
|
+
const allRates = uniqueRates([
|
|
408
|
+
...Object.values(applicable.groupedShippingRates ?? {}).flat(),
|
|
409
|
+
...physicalItemsRate.map((isr) => isr.shippingRate)
|
|
410
|
+
]);
|
|
411
|
+
return [
|
|
412
|
+
{
|
|
413
|
+
key: 'all',
|
|
414
|
+
title: 'shipping',
|
|
415
|
+
itemIds: physicalItemIds,
|
|
416
|
+
items: resolveItems(physicalItemIds),
|
|
417
|
+
rates: allRates,
|
|
418
|
+
selectedRateId: resolveSelected(physicalItemIds, allRates)
|
|
419
|
+
}
|
|
420
|
+
];
|
|
421
|
+
}
|
|
422
|
+
/** Build the API payload from the current group selections. */
|
|
423
|
+
function buildShippingAssignments(groups) {
|
|
424
|
+
return groups
|
|
425
|
+
.filter((group) => group.selectedRateId != null)
|
|
426
|
+
.map((group) => ({
|
|
427
|
+
rateId: group.selectedRateId,
|
|
428
|
+
invoiceItemIds: group.itemIds
|
|
429
|
+
}));
|
|
430
|
+
}
|
|
431
|
+
/** True once every shippable group has a selected rate. */
|
|
432
|
+
function isShippingComplete(invoice, groups) {
|
|
433
|
+
if (!invoice?.needsShipping) {
|
|
434
|
+
return true;
|
|
435
|
+
}
|
|
436
|
+
if (groups.length === 0) {
|
|
437
|
+
return false;
|
|
438
|
+
}
|
|
439
|
+
return groups.every((group) => group.selectedRateId != null);
|
|
440
|
+
}
|
|
441
|
+
const codeDiscountTotal = (invoice) => (invoice.couponTotal || 0) + (invoice.discountTotal || 0);
|
|
442
|
+
/**
|
|
443
|
+
* The documented API contract has no discount "type" field — codes only show
|
|
444
|
+
* up as totals on the invoice. Classify by comparing the invoice before/after
|
|
445
|
+
* the code was applied:
|
|
446
|
+
* - shipping got cheaper, items total unchanged → free shipping
|
|
447
|
+
* - items total dropped by a near-integer % → percentage
|
|
448
|
+
* - items total dropped by anything else → fixed amount
|
|
449
|
+
* If the backend ever sends an explicit type on the discount usage
|
|
450
|
+
* (`discount_type` / `amount_type`), trust it over the heuristics.
|
|
451
|
+
* `before` is null when the code was restored from a saved invoice; deltas
|
|
452
|
+
* then fall back to the absolute totals and free shipping is undetectable.
|
|
453
|
+
*/
|
|
454
|
+
function classifyAppliedDiscount(before, after, code) {
|
|
455
|
+
const amount = Math.max(0, codeDiscountTotal(after) - (before ? codeDiscountTotal(before) : 0));
|
|
456
|
+
const shippingSaved = before
|
|
457
|
+
? Math.max(0, (before.shippingTotal || 0) - (after.shippingTotal || 0))
|
|
458
|
+
: 0;
|
|
459
|
+
const base = { code, kind: 'unknown', amount, shippingSaved };
|
|
460
|
+
const rawUsage = (after.discountUsages?.[0]?.discountCode ?? {});
|
|
461
|
+
const rawType = [rawUsage.discountType, rawUsage.amountType]
|
|
462
|
+
.find((v) => typeof v === 'string')
|
|
463
|
+
?.toLowerCase();
|
|
464
|
+
if (rawType) {
|
|
465
|
+
if (rawType.includes('percent')) {
|
|
466
|
+
const rawPercent = Number(rawUsage.percent ?? rawUsage.percentage);
|
|
467
|
+
const percent = Number.isFinite(rawPercent) && rawPercent > 0
|
|
468
|
+
? rawPercent
|
|
469
|
+
: detectPercent(after, amount) ?? undefined;
|
|
470
|
+
return { ...base, kind: 'percentage', percent };
|
|
471
|
+
}
|
|
472
|
+
if (rawType.includes('ship'))
|
|
473
|
+
return { ...base, kind: 'free_shipping' };
|
|
474
|
+
if (rawType.includes('fix') || rawType.includes('amount')) {
|
|
475
|
+
return { ...base, kind: 'fixed_amount' };
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (amount <= 0) {
|
|
479
|
+
return shippingSaved > 0 ? { ...base, kind: 'free_shipping' } : base;
|
|
480
|
+
}
|
|
481
|
+
const percent = detectPercent(after, amount);
|
|
482
|
+
return percent != null
|
|
483
|
+
? { ...base, kind: 'percentage', percent }
|
|
484
|
+
: { ...base, kind: 'fixed_amount' };
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* A percentage code produces an amount that is a near-exact integer percent of
|
|
488
|
+
* the items total (raw, or net of item-level discounts). A round fixed amount
|
|
489
|
+
* can coincidentally match — the misclassification only changes the badge
|
|
490
|
+
* label, and both readings are true statements of the saving.
|
|
491
|
+
*/
|
|
492
|
+
function detectPercent(invoice, amount) {
|
|
493
|
+
const raw = invoice.itemsTotalRawPrice || 0;
|
|
494
|
+
for (const basis of [raw - (invoice.itemsDiscount || 0), raw]) {
|
|
495
|
+
if (basis <= 0)
|
|
496
|
+
continue;
|
|
497
|
+
const percent = (amount / basis) * 100;
|
|
498
|
+
const rounded = Math.round(percent);
|
|
499
|
+
if (rounded >= 1 && rounded <= 100 && Math.abs(percent - rounded) < 0.05) {
|
|
500
|
+
return rounded;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return null;
|
|
504
|
+
}
|
|
505
|
+
function selectSummary(invoice) {
|
|
506
|
+
if (!invoice) {
|
|
507
|
+
return { lines: [], total: 0 };
|
|
508
|
+
}
|
|
509
|
+
const subtotal = invoice.itemsTotalRawPrice || invoice.netTotal || 0;
|
|
510
|
+
const discount = (invoice.itemsDiscount || 0) + (invoice.discountTotal || 0) + (invoice.couponTotal || 0);
|
|
511
|
+
const lines = [{ key: 'subtotal', amount: subtotal }];
|
|
512
|
+
if (discount > 0) {
|
|
513
|
+
// Backend savings percentage for the "your savings" line; fall back to amount/subtotal.
|
|
514
|
+
// Keep it fractional — a 5,000 discount on a 23M order is ~0.02%, not 0.
|
|
515
|
+
const percent = (invoice.customerProfitPercentage || 0) > 0
|
|
516
|
+
? invoice.customerProfitPercentage
|
|
517
|
+
: subtotal > 0
|
|
518
|
+
? (discount / subtotal) * 100
|
|
519
|
+
: 0;
|
|
520
|
+
lines.push({ key: 'discount', amount: discount, negative: true, percent });
|
|
521
|
+
}
|
|
522
|
+
// Only show shipping line once a method has been assigned.
|
|
523
|
+
if (invoice.needsShipping && (invoice.shippingItems?.length ?? 0) > 0) {
|
|
524
|
+
lines.push({ key: 'shipping', amount: invoice.shippingTotal || 0, free: !invoice.shippingTotal });
|
|
525
|
+
}
|
|
526
|
+
if ((invoice.creditTotal || 0) > 0) {
|
|
527
|
+
lines.push({ key: 'credit', amount: invoice.creditTotal, negative: true });
|
|
528
|
+
}
|
|
529
|
+
if ((invoice.vat || 0) > 0) {
|
|
530
|
+
lines.push({ key: 'vat', amount: invoice.vat, percent: Math.round(invoice.vatPercent || 0) });
|
|
531
|
+
}
|
|
532
|
+
return { lines, total: invoice.finalTotal || 0 };
|
|
533
|
+
}
|
|
534
|
+
/** Items that don't require shipping (digital), for display in the shipping step. */
|
|
535
|
+
function selectDigitalItems(invoice, groups, applicable) {
|
|
536
|
+
if (!invoice)
|
|
537
|
+
return [];
|
|
538
|
+
if (!invoice.needsShipping)
|
|
539
|
+
return invoice.items;
|
|
540
|
+
// Before shipping methods are loaded, only explicitly-digital items are known.
|
|
541
|
+
// Showing everything as "digital" when applicable is null would be wrong.
|
|
542
|
+
if (applicable == null) {
|
|
543
|
+
return invoice.items.filter((item) => item.productType === 'digital');
|
|
544
|
+
}
|
|
545
|
+
// After shipping methods load: items absent from all groups are non-shippable.
|
|
546
|
+
const shippable = new Set(groups.flatMap((g) => g.itemIds.map(idStr)));
|
|
547
|
+
return invoice.items.filter((item) => !shippable.has(idStr(item.id)));
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const STEP_ORDER = ['cart', 'shipping', 'payment', 'result'];
|
|
551
|
+
function initialFlags() {
|
|
552
|
+
return {
|
|
553
|
+
bootstrapping: false,
|
|
554
|
+
updatingCart: false,
|
|
555
|
+
savingAddress: false,
|
|
556
|
+
loadingShipping: false,
|
|
557
|
+
selectingRate: false,
|
|
558
|
+
applyingDiscount: false,
|
|
559
|
+
loadingPayments: false,
|
|
560
|
+
placingOrder: false
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
function initialState(config) {
|
|
564
|
+
const locale = config.locale ?? 'fa';
|
|
565
|
+
return {
|
|
566
|
+
step: 'cart',
|
|
567
|
+
status: 'idle',
|
|
568
|
+
locale,
|
|
569
|
+
direction: config.direction ?? (locale === 'fa' ? 'rtl' : 'ltr'),
|
|
570
|
+
cart: null,
|
|
571
|
+
invoice: null,
|
|
572
|
+
regions: [],
|
|
573
|
+
addressForm: emptyAddressForm(),
|
|
574
|
+
postalCodeMandatory: false,
|
|
575
|
+
emailMandatory: false,
|
|
576
|
+
addressDirty: true,
|
|
577
|
+
applicable: null,
|
|
578
|
+
shippingGroups: [],
|
|
579
|
+
paymentMethods: [],
|
|
580
|
+
selectedPaymentMethodId: null,
|
|
581
|
+
discountCode: '',
|
|
582
|
+
appliedDiscountCode: null,
|
|
583
|
+
appliedDiscount: null,
|
|
584
|
+
discountError: null,
|
|
585
|
+
result: null,
|
|
586
|
+
error: null,
|
|
587
|
+
flags: initialFlags()
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
function createCheckoutEngine(options) {
|
|
591
|
+
const config = options.config ?? {};
|
|
592
|
+
const binding = createSdkBinding(options);
|
|
593
|
+
const store = createStore(initialState(config));
|
|
594
|
+
let effectExecutor = noopEffectExecutor;
|
|
595
|
+
let lock = Promise.resolve();
|
|
596
|
+
let addressRevision = 0;
|
|
597
|
+
// ---- internal helpers -------------------------------------------------
|
|
598
|
+
const get = () => store.getState();
|
|
599
|
+
const set = store.setState;
|
|
600
|
+
function runEffect(effect) {
|
|
601
|
+
effectExecutor(effect);
|
|
602
|
+
}
|
|
603
|
+
function emit(...args) {
|
|
604
|
+
runEffect({ type: 'emit', event: makeEvent(...args) });
|
|
605
|
+
}
|
|
606
|
+
function setFlag(flag, value) {
|
|
607
|
+
set((prev) => ({ flags: { ...prev.flags, [flag]: value } }));
|
|
608
|
+
}
|
|
609
|
+
function setError(error) {
|
|
610
|
+
set({ error, status: error ? 'error' : 'idle' });
|
|
611
|
+
}
|
|
612
|
+
function withLock(fn) {
|
|
613
|
+
const next = lock.then(fn, fn);
|
|
614
|
+
lock = next.then(() => undefined, () => undefined);
|
|
615
|
+
return next;
|
|
616
|
+
}
|
|
617
|
+
function recomputeGroups() {
|
|
618
|
+
const { invoice, applicable } = get();
|
|
619
|
+
const groups = deriveShippingGroups(invoice, applicable);
|
|
620
|
+
set({ shippingGroups: groups });
|
|
621
|
+
return groups;
|
|
622
|
+
}
|
|
623
|
+
// ---- SDK steps --------------------------------------------------------
|
|
624
|
+
/** Fetch cart; returns false (and sets error) when unavailable. */
|
|
625
|
+
async function loadCart() {
|
|
626
|
+
if (!binding.hasCart()) {
|
|
627
|
+
setError(makeError('no_cart', get().locale, 'cart'));
|
|
628
|
+
return false;
|
|
629
|
+
}
|
|
630
|
+
const res = await binding.client.cart.get();
|
|
631
|
+
if (res.error || !res.data) {
|
|
632
|
+
setError(fromSdkError(res.error ?? { message: '', type: 'api' }, get().locale, 'cart'));
|
|
633
|
+
return false;
|
|
634
|
+
}
|
|
635
|
+
set({ cart: res.data });
|
|
636
|
+
return true;
|
|
637
|
+
}
|
|
638
|
+
/** Create invoice when missing (checked via state), otherwise refresh it. */
|
|
639
|
+
async function ensureInvoice() {
|
|
640
|
+
const res = get().invoice != null
|
|
641
|
+
? await binding.client.invoices.refresh()
|
|
642
|
+
: await binding.client.invoices.create();
|
|
643
|
+
if (res.error || !res.data) {
|
|
644
|
+
setError(fromSdkError(res.error ?? { message: '', type: 'api' }, get().locale, 'shipping'));
|
|
645
|
+
return false;
|
|
646
|
+
}
|
|
647
|
+
set({ invoice: res.data });
|
|
648
|
+
return true;
|
|
649
|
+
}
|
|
650
|
+
async function loadRegions() {
|
|
651
|
+
const res = await binding.client.regions.list();
|
|
652
|
+
if (res.data) {
|
|
653
|
+
set({ regions: res.data });
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
async function loadGeneralInfo() {
|
|
657
|
+
const getInfo = binding.client.general?.getInfo;
|
|
658
|
+
if (!getInfo) {
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
const res = await getInfo.call(binding.client.general);
|
|
662
|
+
if (res.data) {
|
|
663
|
+
const postalCodeMandatory = readPostalCodeMandatory(res.data);
|
|
664
|
+
if (postalCodeMandatory !== undefined) {
|
|
665
|
+
set({ postalCodeMandatory });
|
|
666
|
+
}
|
|
667
|
+
const emailMandatory = readEmailMandatory(res.data);
|
|
668
|
+
if (emailMandatory !== undefined) {
|
|
669
|
+
set({ emailMandatory });
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
async function refreshInvoice() {
|
|
674
|
+
const res = await binding.client.invoices.refresh();
|
|
675
|
+
if (res.data) {
|
|
676
|
+
set({ invoice: res.data });
|
|
677
|
+
recomputeGroups();
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
async function loadApplicableShipping(expectedAddressRevision) {
|
|
681
|
+
setFlag('loadingShipping', true);
|
|
682
|
+
const res = await binding.client.invoices.getApplicableShippingMethods();
|
|
683
|
+
setFlag('loadingShipping', false);
|
|
684
|
+
if (addressRevision !== expectedAddressRevision) {
|
|
685
|
+
return false;
|
|
686
|
+
}
|
|
687
|
+
if (res.error || !res.data) {
|
|
688
|
+
setError(fromSdkError(res.error ?? { message: '', type: 'api' }, get().locale, 'shipping'));
|
|
689
|
+
return false;
|
|
690
|
+
}
|
|
691
|
+
set({ applicable: res.data });
|
|
692
|
+
recomputeGroups();
|
|
693
|
+
return true;
|
|
694
|
+
}
|
|
695
|
+
async function assignCurrentShipping() {
|
|
696
|
+
const groups = get().shippingGroups;
|
|
697
|
+
const assignments = buildShippingAssignments(groups);
|
|
698
|
+
if (assignments.length === 0) {
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
const res = await binding.client.invoices.assignShippingMethod(assignments);
|
|
702
|
+
if (res.error || !res.data) {
|
|
703
|
+
setError(fromSdkError(res.error ?? { message: '', type: 'api' }, get().locale, 'shipping'));
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
set({ invoice: res.data });
|
|
707
|
+
recomputeGroups();
|
|
708
|
+
}
|
|
709
|
+
async function loadPaymentMethods() {
|
|
710
|
+
setFlag('loadingPayments', true);
|
|
711
|
+
const res = await binding.client.payments.getMethods();
|
|
712
|
+
setFlag('loadingPayments', false);
|
|
713
|
+
if (res.error || !res.data) {
|
|
714
|
+
setError(fromSdkError(res.error ?? { message: '', type: 'api' }, get().locale, 'payment'));
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
const methods = res.data;
|
|
718
|
+
const current = get().selectedPaymentMethodId;
|
|
719
|
+
const preferred = current ?? methods.find((m) => m.isDefault)?.id ?? methods[0]?.id ?? null;
|
|
720
|
+
set({ paymentMethods: methods, selectedPaymentMethodId: preferred });
|
|
721
|
+
}
|
|
722
|
+
// ---- payment resolution ----------------------------------------------
|
|
723
|
+
// Push a terminal failure onto the result step (step 4) with a visible message.
|
|
724
|
+
function failResult(message, err) {
|
|
725
|
+
set({
|
|
726
|
+
step: 'result',
|
|
727
|
+
status: 'idle',
|
|
728
|
+
result: { status: 'failed', message },
|
|
729
|
+
error: err ?? null
|
|
730
|
+
});
|
|
731
|
+
emit('payment_failed', { step: 'result' });
|
|
732
|
+
}
|
|
733
|
+
function handlePaymentAction(action) {
|
|
734
|
+
switch (action.action) {
|
|
735
|
+
case 'REDIRECT':
|
|
736
|
+
if (!action.address) {
|
|
737
|
+
console.error('[Sazito Checkout] REDIRECT action has no address:', action);
|
|
738
|
+
failResult(makeError('payment_failed', get().locale, 'result').message);
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
set({ status: 'redirecting' });
|
|
742
|
+
runEffect({ type: 'redirect', url: action.address });
|
|
743
|
+
return;
|
|
744
|
+
case 'POST':
|
|
745
|
+
if (!action.address) {
|
|
746
|
+
console.error('[Sazito Checkout] POST action has no address:', action);
|
|
747
|
+
failResult(makeError('payment_failed', get().locale, 'result').message);
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
set({ status: 'redirecting' });
|
|
751
|
+
runEffect({
|
|
752
|
+
type: 'post-form',
|
|
753
|
+
url: action.address,
|
|
754
|
+
fields: stringifyFields(action.payload)
|
|
755
|
+
});
|
|
756
|
+
return;
|
|
757
|
+
case 'showOrder':
|
|
758
|
+
set({
|
|
759
|
+
step: 'result',
|
|
760
|
+
status: 'idle',
|
|
761
|
+
result: { status: 'success', order: action.order }
|
|
762
|
+
});
|
|
763
|
+
emit('payment_succeeded', { step: 'result' });
|
|
764
|
+
return;
|
|
765
|
+
case 'FAIL':
|
|
766
|
+
set({
|
|
767
|
+
step: 'result',
|
|
768
|
+
status: 'idle',
|
|
769
|
+
result: { status: 'failed', message: action.message }
|
|
770
|
+
});
|
|
771
|
+
emit('payment_failed', { step: 'result' });
|
|
772
|
+
return;
|
|
773
|
+
case 'StockViolated':
|
|
774
|
+
set({
|
|
775
|
+
step: 'result',
|
|
776
|
+
status: 'idle',
|
|
777
|
+
result: { status: 'stock_violated', message: action.message }
|
|
778
|
+
});
|
|
779
|
+
setError(makeError('stock_violated', get().locale, 'result'));
|
|
780
|
+
return;
|
|
781
|
+
case 'pending':
|
|
782
|
+
set({ step: 'result', status: 'polling', result: { status: 'pending' } });
|
|
783
|
+
emit('payment_pending', { step: 'result' });
|
|
784
|
+
void pollPending();
|
|
785
|
+
return;
|
|
786
|
+
case 'UPLOAD':
|
|
787
|
+
// Card-to-card upload is out of scope for v1; surface a clear failure.
|
|
788
|
+
set({
|
|
789
|
+
step: 'result',
|
|
790
|
+
status: 'idle',
|
|
791
|
+
result: { status: 'failed', message: action.message }
|
|
792
|
+
});
|
|
793
|
+
return;
|
|
794
|
+
default:
|
|
795
|
+
// Unknown/unhandled action from the gateway — never stall silently.
|
|
796
|
+
console.error('[Sazito Checkout] Unhandled payment action:', action);
|
|
797
|
+
failResult(action.message || makeError('payment_failed', get().locale, 'result').message);
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
async function pollPending() {
|
|
802
|
+
const interval = config.pollIntervalMs ?? 15000;
|
|
803
|
+
const res = await binding.client.payments.pollUntilSettled(undefined, interval);
|
|
804
|
+
if (res.error || !res.data) {
|
|
805
|
+
setError(fromSdkError(res.error ?? { message: '', type: 'api' }, get().locale, 'result'));
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
handlePaymentAction(res.data);
|
|
809
|
+
}
|
|
810
|
+
// ---- public actions ---------------------------------------------------
|
|
811
|
+
function goToStep(step) {
|
|
812
|
+
set({ step, error: null });
|
|
813
|
+
emit('step_viewed', { step });
|
|
814
|
+
// When landing on shipping with a complete address but no methods fetched yet,
|
|
815
|
+
// auto-submit so methods appear without requiring a manual Continue press.
|
|
816
|
+
if (step === 'shipping') {
|
|
817
|
+
const { addressForm, invoice, applicable } = get();
|
|
818
|
+
if (!applicable && isAddressComplete(addressForm, invoice?.needsShipping ?? true, get().postalCodeMandatory, get().emailMandatory)) {
|
|
819
|
+
void withLock(submitAddressInternal);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
function isUsableSavedAddress(addr) {
|
|
824
|
+
return Boolean(addr && (addr.firstName || addr.lastName || addr.address));
|
|
825
|
+
}
|
|
826
|
+
/* Authenticated users load their latest account address. Stored credentials
|
|
827
|
+
are also a safe fallback because createAddress refreshes them every time. */
|
|
828
|
+
async function loadSavedAddress() {
|
|
829
|
+
try {
|
|
830
|
+
if (binding.client.isAuthenticated()) {
|
|
831
|
+
const res = await binding.client.shipping.listAddresses();
|
|
832
|
+
const latest = res.data?.find(isUsableSavedAddress);
|
|
833
|
+
if (latest) {
|
|
834
|
+
return latest;
|
|
835
|
+
}
|
|
836
|
+
if (res.error) {
|
|
837
|
+
console.warn('[Sazito Checkout] account-address prefill failed:', res.error.message);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
if (!binding.credentials.getShippingCredentials()) {
|
|
841
|
+
return null;
|
|
842
|
+
}
|
|
843
|
+
const guestRes = await binding.client.shipping.getAddress();
|
|
844
|
+
if (guestRes.data && isUsableSavedAddress(guestRes.data)) {
|
|
845
|
+
return guestRes.data;
|
|
846
|
+
}
|
|
847
|
+
console.warn('[Sazito Checkout] guest-address prefill skipped:', guestRes.error?.message ?? 'address response missing usable fields', guestRes.data ?? null);
|
|
848
|
+
}
|
|
849
|
+
catch (e) {
|
|
850
|
+
console.warn('[Sazito Checkout] saved-address prefill failed:', e);
|
|
851
|
+
}
|
|
852
|
+
return null;
|
|
853
|
+
}
|
|
854
|
+
const actions = {
|
|
855
|
+
async start() {
|
|
856
|
+
await withLock(async () => {
|
|
857
|
+
setFlag('bootstrapping', true);
|
|
858
|
+
set({ status: 'bootstrapping', error: null });
|
|
859
|
+
const [cartOk, , , invoiceOk] = await Promise.all([
|
|
860
|
+
loadCart(),
|
|
861
|
+
loadRegions(),
|
|
862
|
+
loadGeneralInfo(),
|
|
863
|
+
ensureInvoice()
|
|
864
|
+
]);
|
|
865
|
+
setFlag('bootstrapping', false);
|
|
866
|
+
if (!cartOk || !invoiceOk) {
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
const invoice = get().invoice;
|
|
870
|
+
const hasInvoiceAddress = isUsableSavedAddress(invoice?.shippingAddress);
|
|
871
|
+
let rawForm = addressFormFromInvoice(invoice);
|
|
872
|
+
let savedCityName = invoice?.shippingAddress?.region?.city?.name;
|
|
873
|
+
// Fresh invoices carry no address. Authenticated users get their latest
|
|
874
|
+
// account address; guests get the address persisted by SDK credentials.
|
|
875
|
+
if (!hasInvoiceAddress) {
|
|
876
|
+
const saved = await loadSavedAddress();
|
|
877
|
+
if (saved) {
|
|
878
|
+
rawForm = addressFormFromSavedAddress(saved);
|
|
879
|
+
savedCityName = savedAddressCityName(saved);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
// A code applied in an earlier session survives on the invoice — reflect
|
|
883
|
+
// it as applied (no before-invoice, so the type falls back to totals).
|
|
884
|
+
const savedCode = invoice?.discountCode?.toUpperCase();
|
|
885
|
+
set({
|
|
886
|
+
status: 'idle',
|
|
887
|
+
addressForm: reconcileAddressWithRegions(rawForm, get().regions, savedCityName),
|
|
888
|
+
addressDirty: !hasInvoiceAddress,
|
|
889
|
+
...(savedCode && invoice
|
|
890
|
+
? {
|
|
891
|
+
appliedDiscountCode: savedCode,
|
|
892
|
+
appliedDiscount: classifyAppliedDiscount(null, invoice, savedCode)
|
|
893
|
+
}
|
|
894
|
+
: {})
|
|
895
|
+
});
|
|
896
|
+
emit('checkout_viewed', { step: 'cart' });
|
|
897
|
+
emit('step_viewed', { step: 'cart' });
|
|
898
|
+
});
|
|
899
|
+
},
|
|
900
|
+
goToStep,
|
|
901
|
+
back() {
|
|
902
|
+
const idx = STEP_ORDER.indexOf(get().step);
|
|
903
|
+
if (idx > 0) {
|
|
904
|
+
goToStep(STEP_ORDER[idx - 1]);
|
|
905
|
+
}
|
|
906
|
+
},
|
|
907
|
+
async next() {
|
|
908
|
+
await withLock(async () => {
|
|
909
|
+
const state = get();
|
|
910
|
+
setError(null);
|
|
911
|
+
if (state.step === 'cart') {
|
|
912
|
+
set({ status: 'working' });
|
|
913
|
+
const ok = await ensureInvoice();
|
|
914
|
+
set({ status: 'idle' });
|
|
915
|
+
if (ok) {
|
|
916
|
+
// A fresh invoice has no address yet. Keep the form hydrated from
|
|
917
|
+
// the base SDK's saved address instead of clearing it just before
|
|
918
|
+
// the Shipping step becomes visible.
|
|
919
|
+
const invoice = get().invoice;
|
|
920
|
+
if (isUsableSavedAddress(invoice?.shippingAddress)) {
|
|
921
|
+
set({ addressForm: addressFormFromInvoice(invoice) });
|
|
922
|
+
}
|
|
923
|
+
goToStep('shipping');
|
|
924
|
+
}
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
if (state.step === 'shipping') {
|
|
928
|
+
// Phase 1: address not saved yet (or changed) → save it and reveal the
|
|
929
|
+
// shipping methods, staying on the shipping step.
|
|
930
|
+
const needsSave = state.addressDirty || !state.applicable;
|
|
931
|
+
if (needsSave) {
|
|
932
|
+
const saved = await submitAddressInternal();
|
|
933
|
+
if (!saved)
|
|
934
|
+
return;
|
|
935
|
+
// Stay on the step so the customer can pick / switch a method.
|
|
936
|
+
if (state.invoice?.needsShipping) {
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
// Phase 2: methods resolved → validate and proceed to payment.
|
|
941
|
+
const invoice = get().invoice;
|
|
942
|
+
if (!isShippingComplete(invoice, get().shippingGroups)) {
|
|
943
|
+
setError(makeError('shipping_required', state.locale, 'shipping'));
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
set({ status: 'working' });
|
|
947
|
+
await loadPaymentMethods();
|
|
948
|
+
set({ status: 'idle' });
|
|
949
|
+
if (!get().error)
|
|
950
|
+
goToStep('payment');
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
if (state.step === 'payment') {
|
|
954
|
+
if (state.selectedPaymentMethodId == null) {
|
|
955
|
+
setError(makeError('validation', state.locale, 'payment'));
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
// No review step — finalize directly from payment.
|
|
959
|
+
await placeOrderInternal();
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
});
|
|
963
|
+
},
|
|
964
|
+
async updateItemQuantity(cartProductId, variantId, quantity) {
|
|
965
|
+
await withLock(async () => {
|
|
966
|
+
if (quantity < 1)
|
|
967
|
+
return;
|
|
968
|
+
setFlag('updatingCart', true);
|
|
969
|
+
const res = await binding.client.cart.updateItem(cartProductId, variantId, quantity);
|
|
970
|
+
if (res.data) {
|
|
971
|
+
set({ cart: res.data });
|
|
972
|
+
// Optimistically update summary from cart data, then confirm with server.
|
|
973
|
+
const inv = get().invoice;
|
|
974
|
+
if (inv)
|
|
975
|
+
set({ invoice: applyCartToInvoice(inv, res.data) });
|
|
976
|
+
await refreshInvoice();
|
|
977
|
+
}
|
|
978
|
+
else if (res.error) {
|
|
979
|
+
setError(fromSdkError(res.error, get().locale, 'cart'));
|
|
980
|
+
}
|
|
981
|
+
setFlag('updatingCart', false);
|
|
982
|
+
});
|
|
983
|
+
},
|
|
984
|
+
async removeItem(cartProductId, variantId) {
|
|
985
|
+
await withLock(async () => {
|
|
986
|
+
setFlag('updatingCart', true);
|
|
987
|
+
const res = await binding.client.cart.removeItem(cartProductId, variantId);
|
|
988
|
+
if (res.data) {
|
|
989
|
+
set({ cart: res.data });
|
|
990
|
+
const inv = get().invoice;
|
|
991
|
+
if (inv)
|
|
992
|
+
set({ invoice: applyCartToInvoice(inv, res.data) });
|
|
993
|
+
await refreshInvoice();
|
|
994
|
+
}
|
|
995
|
+
else if (res.error) {
|
|
996
|
+
setError(fromSdkError(res.error, get().locale, 'cart'));
|
|
997
|
+
}
|
|
998
|
+
setFlag('updatingCart', false);
|
|
999
|
+
});
|
|
1000
|
+
},
|
|
1001
|
+
setAddressField(key, value) {
|
|
1002
|
+
const before = get().addressForm;
|
|
1003
|
+
const changed = before[key] !== value || (key === 'regionId' && before.cityId !== null);
|
|
1004
|
+
if (changed) {
|
|
1005
|
+
addressRevision += 1;
|
|
1006
|
+
}
|
|
1007
|
+
set((prev) => {
|
|
1008
|
+
const addressForm = { ...prev.addressForm, [key]: value };
|
|
1009
|
+
// Reset city when region changes.
|
|
1010
|
+
if (key === 'regionId') {
|
|
1011
|
+
addressForm.cityId = null;
|
|
1012
|
+
}
|
|
1013
|
+
const addressDirty = isAddressDirty(addressForm, prev.invoice);
|
|
1014
|
+
return {
|
|
1015
|
+
addressForm,
|
|
1016
|
+
addressDirty,
|
|
1017
|
+
// Rates are derived from the address snapshot. Never keep showing or
|
|
1018
|
+
// accepting rates fetched for a different form value.
|
|
1019
|
+
...(addressDirty ? { applicable: null, shippingGroups: [] } : {})
|
|
1020
|
+
};
|
|
1021
|
+
});
|
|
1022
|
+
// City selection is a discrete destination change, so refresh rates
|
|
1023
|
+
// automatically. Text inputs still wait for Save/Continue to avoid an
|
|
1024
|
+
// address creation and shipping request on every keystroke.
|
|
1025
|
+
if (changed && key === 'cityId' && value != null && get().step === 'shipping') {
|
|
1026
|
+
const state = get();
|
|
1027
|
+
if (isAddressComplete(state.addressForm, state.invoice?.needsShipping ?? true, state.postalCodeMandatory, state.emailMandatory)) {
|
|
1028
|
+
void withLock(submitAddressInternal);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
},
|
|
1032
|
+
async submitAddress() {
|
|
1033
|
+
return withLock(submitAddressInternal);
|
|
1034
|
+
},
|
|
1035
|
+
async selectShippingRate(groupKey, rateId) {
|
|
1036
|
+
await withLock(async () => {
|
|
1037
|
+
setFlag('selectingRate', true);
|
|
1038
|
+
set((prev) => ({
|
|
1039
|
+
shippingGroups: prev.shippingGroups.map((group) => group.key === groupKey ? { ...group, selectedRateId: rateId } : group)
|
|
1040
|
+
}));
|
|
1041
|
+
await assignCurrentShipping();
|
|
1042
|
+
await refreshInvoice();
|
|
1043
|
+
setFlag('selectingRate', false);
|
|
1044
|
+
emit('shipping_rate_selected', { step: 'shipping', metadata: { groupKey, rateId } });
|
|
1045
|
+
});
|
|
1046
|
+
},
|
|
1047
|
+
setDiscountCode(code) {
|
|
1048
|
+
set({ discountCode: code, discountError: null });
|
|
1049
|
+
},
|
|
1050
|
+
async applyDiscount() {
|
|
1051
|
+
await withLock(async () => {
|
|
1052
|
+
const code = get().discountCode.trim();
|
|
1053
|
+
if (!code)
|
|
1054
|
+
return;
|
|
1055
|
+
setFlag('applyingDiscount', true);
|
|
1056
|
+
set({ discountError: null });
|
|
1057
|
+
const before = get().invoice;
|
|
1058
|
+
const res = await binding.client.invoices.addDiscountCode(code);
|
|
1059
|
+
setFlag('applyingDiscount', false);
|
|
1060
|
+
if (res.error || !res.data) {
|
|
1061
|
+
// Inline error on the discount field instead of the global banner.
|
|
1062
|
+
const err = fromSdkError(res.error ?? { message: '', type: 'api' }, get().locale, 'payment');
|
|
1063
|
+
set({ discountError: err.message });
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
const applied = classifyAppliedDiscount(before, res.data, code.toUpperCase());
|
|
1067
|
+
set({
|
|
1068
|
+
invoice: res.data,
|
|
1069
|
+
appliedDiscountCode: applied.code,
|
|
1070
|
+
appliedDiscount: applied,
|
|
1071
|
+
discountError: null
|
|
1072
|
+
});
|
|
1073
|
+
recomputeGroups();
|
|
1074
|
+
emit('discount_applied', { step: 'payment', metadata: { code, kind: applied.kind } });
|
|
1075
|
+
});
|
|
1076
|
+
},
|
|
1077
|
+
async removeDiscount() {
|
|
1078
|
+
await withLock(async () => {
|
|
1079
|
+
// No dedicated remove endpoint; clear the saved code and re-sync.
|
|
1080
|
+
setFlag('applyingDiscount', true);
|
|
1081
|
+
binding.credentials.clearDiscountCode();
|
|
1082
|
+
set({ appliedDiscountCode: null, appliedDiscount: null, discountCode: '', discountError: null });
|
|
1083
|
+
await refreshInvoice();
|
|
1084
|
+
setFlag('applyingDiscount', false);
|
|
1085
|
+
emit('discount_removed', { step: 'payment' });
|
|
1086
|
+
});
|
|
1087
|
+
},
|
|
1088
|
+
selectPaymentMethod(id) {
|
|
1089
|
+
set({ selectedPaymentMethodId: id });
|
|
1090
|
+
emit('payment_method_selected', { step: 'payment', metadata: { id } });
|
|
1091
|
+
},
|
|
1092
|
+
async placeOrder() {
|
|
1093
|
+
await withLock(placeOrderInternal);
|
|
1094
|
+
},
|
|
1095
|
+
async resolvePaymentReturn(params) {
|
|
1096
|
+
await withLock(async () => {
|
|
1097
|
+
set({ step: 'result', status: 'polling', result: { status: 'pending' } });
|
|
1098
|
+
const action = await binding.client.payments.verify(paymentReturnInput(params));
|
|
1099
|
+
if (action.error || !action.data) {
|
|
1100
|
+
setError(fromSdkError(action.error ?? { message: '', type: 'api' }, get().locale, 'result'));
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
handlePaymentAction(action.data);
|
|
1104
|
+
});
|
|
1105
|
+
},
|
|
1106
|
+
reset() {
|
|
1107
|
+
addressRevision += 1;
|
|
1108
|
+
set(initialState(config));
|
|
1109
|
+
}
|
|
1110
|
+
};
|
|
1111
|
+
// Internal (lock-free) order placement shared by `placeOrder` & `next` (payment step).
|
|
1112
|
+
async function placeOrderInternal() {
|
|
1113
|
+
const { selectedPaymentMethodId, locale } = get();
|
|
1114
|
+
if (selectedPaymentMethodId == null) {
|
|
1115
|
+
setError(makeError('validation', locale, 'payment'));
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
setFlag('placingOrder', true);
|
|
1119
|
+
setError(null);
|
|
1120
|
+
try {
|
|
1121
|
+
const created = await binding.client.payments.create(selectedPaymentMethodId);
|
|
1122
|
+
if (created.error || !created.data) {
|
|
1123
|
+
console.error('[Sazito Checkout] payments.create failed:', created.error);
|
|
1124
|
+
const err = fromSdkError(created.error ?? { message: '', type: 'api' }, locale, 'result');
|
|
1125
|
+
failResult(err.message, err);
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
emit('payment_initiated', { step: 'payment', value: get().invoice?.finalTotal });
|
|
1129
|
+
const action = await binding.client.payments.initialize();
|
|
1130
|
+
if (action.error || !action.data) {
|
|
1131
|
+
console.error('[Sazito Checkout] payments.initialize failed:', action.error);
|
|
1132
|
+
const err = fromSdkError(action.error ?? { message: '', type: 'api' }, locale, 'result');
|
|
1133
|
+
failResult(err.message, err);
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
console.debug('[Sazito Checkout] payment init action:', action.data);
|
|
1137
|
+
handlePaymentAction(action.data);
|
|
1138
|
+
}
|
|
1139
|
+
catch (e) {
|
|
1140
|
+
// Network/unexpected throw — never leave the button spinning silently.
|
|
1141
|
+
console.error('[Sazito Checkout] place order threw:', e);
|
|
1142
|
+
const err = fromSdkError({ message: e?.message || '', type: 'network' }, locale, 'result');
|
|
1143
|
+
failResult(err.message, err);
|
|
1144
|
+
}
|
|
1145
|
+
finally {
|
|
1146
|
+
setFlag('placingOrder', false);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
// Internal (lock-free) address submission shared by `submitAddress` & `next`.
|
|
1150
|
+
async function submitAddressInternal() {
|
|
1151
|
+
const state = get();
|
|
1152
|
+
const submittedAddressRevision = addressRevision;
|
|
1153
|
+
const needsShipping = state.invoice?.needsShipping ?? true;
|
|
1154
|
+
const abandonStaleSubmission = (invoice) => {
|
|
1155
|
+
set({
|
|
1156
|
+
...(invoice ? { invoice } : {}),
|
|
1157
|
+
addressDirty: true,
|
|
1158
|
+
applicable: null,
|
|
1159
|
+
shippingGroups: []
|
|
1160
|
+
});
|
|
1161
|
+
setFlag('savingAddress', false);
|
|
1162
|
+
return false;
|
|
1163
|
+
};
|
|
1164
|
+
if (!isAddressComplete(state.addressForm, needsShipping, state.postalCodeMandatory, state.emailMandatory)) {
|
|
1165
|
+
setError(makeError('address_required', state.locale, 'shipping'));
|
|
1166
|
+
return false;
|
|
1167
|
+
}
|
|
1168
|
+
// Skip the round-trip if nothing changed and shipping is already resolved.
|
|
1169
|
+
if (!state.addressDirty && state.applicable && isShippingComplete(state.invoice, state.shippingGroups)) {
|
|
1170
|
+
return true;
|
|
1171
|
+
}
|
|
1172
|
+
setFlag('savingAddress', true);
|
|
1173
|
+
const input = toAddressInput(state.addressForm);
|
|
1174
|
+
// Addresses attached to invoices are immutable order snapshots. Updating
|
|
1175
|
+
// the previous address would also rewrite historical orders, so checkout
|
|
1176
|
+
// always creates a new address and persists its new guest credentials.
|
|
1177
|
+
const addrRes = await binding.client.shipping.createAddress(input);
|
|
1178
|
+
if (addrRes.error || !addrRes.data) {
|
|
1179
|
+
setFlag('savingAddress', false);
|
|
1180
|
+
setError(fromSdkError(addrRes.error ?? { message: '', type: 'api' }, state.locale, 'shipping'));
|
|
1181
|
+
return false;
|
|
1182
|
+
}
|
|
1183
|
+
if (addressRevision !== submittedAddressRevision) {
|
|
1184
|
+
return abandonStaleSubmission();
|
|
1185
|
+
}
|
|
1186
|
+
const address = addrRes.data;
|
|
1187
|
+
const linkRes = await binding.client.invoices.addShippingAddress(address.id, address.identifier);
|
|
1188
|
+
if (linkRes.error || !linkRes.data) {
|
|
1189
|
+
setFlag('savingAddress', false);
|
|
1190
|
+
setError(fromSdkError(linkRes.error ?? { message: '', type: 'api' }, state.locale, 'shipping'));
|
|
1191
|
+
return false;
|
|
1192
|
+
}
|
|
1193
|
+
if (addressRevision !== submittedAddressRevision) {
|
|
1194
|
+
return abandonStaleSubmission(linkRes.data);
|
|
1195
|
+
}
|
|
1196
|
+
set({ invoice: linkRes.data, addressDirty: false });
|
|
1197
|
+
if (needsShipping) {
|
|
1198
|
+
const ok = await loadApplicableShipping(submittedAddressRevision);
|
|
1199
|
+
if (ok) {
|
|
1200
|
+
await assignCurrentShipping();
|
|
1201
|
+
if (addressRevision !== submittedAddressRevision) {
|
|
1202
|
+
return abandonStaleSubmission();
|
|
1203
|
+
}
|
|
1204
|
+
await refreshInvoice();
|
|
1205
|
+
if (addressRevision !== submittedAddressRevision) {
|
|
1206
|
+
return abandonStaleSubmission();
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
else if (addressRevision !== submittedAddressRevision) {
|
|
1210
|
+
return abandonStaleSubmission();
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
setFlag('savingAddress', false);
|
|
1214
|
+
emit('address_submitted', { step: 'shipping' });
|
|
1215
|
+
return !get().error;
|
|
1216
|
+
}
|
|
1217
|
+
return {
|
|
1218
|
+
getState: store.getState,
|
|
1219
|
+
subscribe: store.subscribe,
|
|
1220
|
+
actions,
|
|
1221
|
+
setEffectExecutor(executor) {
|
|
1222
|
+
effectExecutor = executor;
|
|
1223
|
+
},
|
|
1224
|
+
destroy() {
|
|
1225
|
+
effectExecutor = noopEffectExecutor;
|
|
1226
|
+
}
|
|
1227
|
+
};
|
|
1228
|
+
}
|
|
1229
|
+
function readEmailMandatory(data) {
|
|
1230
|
+
const root = asRecord(data);
|
|
1231
|
+
const general = asRecord(root.general);
|
|
1232
|
+
const generalInfo = asRecord(general.generalInfo ?? general.general_info ?? root.generalInfo ?? root.general_info);
|
|
1233
|
+
const normalizedSettings = asRecord(root.settings);
|
|
1234
|
+
const checkout = asRecord(generalInfo.checkout ?? root.checkout ?? normalizedSettings.checkout);
|
|
1235
|
+
const optional = readCheckoutSettingBoolean(checkout.emailOptional ?? checkout.email_optional);
|
|
1236
|
+
if (optional !== undefined)
|
|
1237
|
+
return !optional;
|
|
1238
|
+
return readCheckoutSettingBoolean(checkout.emailMandatory ?? checkout.email_mandatory);
|
|
1239
|
+
}
|
|
1240
|
+
function readPostalCodeMandatory(data) {
|
|
1241
|
+
const root = asRecord(data);
|
|
1242
|
+
const general = asRecord(root.general);
|
|
1243
|
+
const generalInfo = asRecord(general.generalInfo ?? general.general_info ?? root.generalInfo ?? root.general_info);
|
|
1244
|
+
const normalizedSettings = asRecord(root.settings);
|
|
1245
|
+
const checkout = asRecord(generalInfo.checkout ?? root.checkout ?? normalizedSettings.checkout);
|
|
1246
|
+
return readCheckoutSettingBoolean(checkout.postalCodeMandatory ?? checkout.postal_code_mandatory);
|
|
1247
|
+
}
|
|
1248
|
+
function asRecord(value) {
|
|
1249
|
+
return value && typeof value === 'object'
|
|
1250
|
+
? value
|
|
1251
|
+
: {};
|
|
1252
|
+
}
|
|
1253
|
+
function readCheckoutSettingBoolean(value) {
|
|
1254
|
+
if (value && typeof value === 'object' && 'enabled' in value) {
|
|
1255
|
+
return readCheckoutSettingBoolean(value.enabled);
|
|
1256
|
+
}
|
|
1257
|
+
if (value === true || value === 1 || value === '1' || value === 'true')
|
|
1258
|
+
return true;
|
|
1259
|
+
if (value === false || value === 0 || value === '0' || value === 'false')
|
|
1260
|
+
return false;
|
|
1261
|
+
return undefined;
|
|
1262
|
+
}
|
|
1263
|
+
function toAddressInput(form) {
|
|
1264
|
+
return {
|
|
1265
|
+
firstName: form.firstName.trim(),
|
|
1266
|
+
lastName: form.lastName.trim(),
|
|
1267
|
+
mobilePhone: form.mobilePhone.trim(),
|
|
1268
|
+
email: form.email.trim() || undefined,
|
|
1269
|
+
phoneNumber: form.phoneNumber.trim() || undefined,
|
|
1270
|
+
regionId: form.regionId ?? undefined,
|
|
1271
|
+
cityId: form.cityId ?? undefined,
|
|
1272
|
+
address: form.address.trim(),
|
|
1273
|
+
postalCode: form.postalCode.trim() || undefined,
|
|
1274
|
+
description: form.description.trim() || undefined
|
|
1275
|
+
};
|
|
1276
|
+
}
|
|
1277
|
+
/**
|
|
1278
|
+
* Immediately derive updated invoice item quantities and totals from a fresh
|
|
1279
|
+
* cart response so the summary panel doesn't wait for the invoice refresh.
|
|
1280
|
+
*/
|
|
1281
|
+
function applyCartToInvoice(invoice, cart) {
|
|
1282
|
+
const byVariant = new Map(cart.items.map((i) => [i.productVariantId, i]));
|
|
1283
|
+
const newItems = invoice.items
|
|
1284
|
+
.filter((item) => byVariant.has(item.productVariantId))
|
|
1285
|
+
.map((item) => {
|
|
1286
|
+
const ci = byVariant.get(item.productVariantId);
|
|
1287
|
+
return { ...item, quantity: ci.quantity, lineTotal: ci.lineTotal };
|
|
1288
|
+
});
|
|
1289
|
+
const subtotal = newItems.reduce((s, i) => s + i.lineTotal, 0);
|
|
1290
|
+
const discount = (invoice.itemsDiscount || 0) + (invoice.discountTotal || 0) + (invoice.couponTotal || 0);
|
|
1291
|
+
const finalTotal = Math.max(0, subtotal - discount + (invoice.shippingTotal || 0) + (invoice.vat || 0) - (invoice.creditTotal || 0));
|
|
1292
|
+
return { ...invoice, items: newItems, itemsTotalRawPrice: subtotal, netTotal: subtotal, finalTotal };
|
|
1293
|
+
}
|
|
1294
|
+
// Map raw gateway-return query params into the typed payment-step input.
|
|
1295
|
+
// Only the string-valued callback fields are forwarded; `trackingData` /
|
|
1296
|
+
// `payload` are parsed from JSON when the gateway sends them encoded.
|
|
1297
|
+
function paymentReturnInput(params) {
|
|
1298
|
+
if (!params || Object.keys(params).length === 0) {
|
|
1299
|
+
return undefined;
|
|
1300
|
+
}
|
|
1301
|
+
const input = {};
|
|
1302
|
+
if (params.tatoken != null)
|
|
1303
|
+
input.tatoken = params.tatoken;
|
|
1304
|
+
if (params.isFailed != null)
|
|
1305
|
+
input.isFailed = params.isFailed;
|
|
1306
|
+
if (params.code != null)
|
|
1307
|
+
input.code = params.code;
|
|
1308
|
+
if (params.imageUrl != null)
|
|
1309
|
+
input.imageUrl = params.imageUrl;
|
|
1310
|
+
if (params.id != null && Number.isFinite(Number(params.id)))
|
|
1311
|
+
input.id = Number(params.id);
|
|
1312
|
+
if (params.paymentIdentifier != null)
|
|
1313
|
+
input.paymentIdentifier = params.paymentIdentifier;
|
|
1314
|
+
const trackingData = parseJsonObject(params.trackingData);
|
|
1315
|
+
if (trackingData)
|
|
1316
|
+
input.trackingData = trackingData;
|
|
1317
|
+
const payload = parseJsonObject(params.payload);
|
|
1318
|
+
if (payload)
|
|
1319
|
+
input.payload = payload;
|
|
1320
|
+
return Object.keys(input).length > 0 ? input : undefined;
|
|
1321
|
+
}
|
|
1322
|
+
function parseJsonObject(value) {
|
|
1323
|
+
if (!value)
|
|
1324
|
+
return undefined;
|
|
1325
|
+
try {
|
|
1326
|
+
const parsed = JSON.parse(value);
|
|
1327
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : undefined;
|
|
1328
|
+
}
|
|
1329
|
+
catch {
|
|
1330
|
+
return undefined;
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
function stringifyFields(payload) {
|
|
1334
|
+
const fields = {};
|
|
1335
|
+
if (payload && typeof payload === 'object') {
|
|
1336
|
+
for (const [key, value] of Object.entries(payload)) {
|
|
1337
|
+
fields[key] = value == null ? '' : String(value);
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
return fields;
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
const FA_DIGITS = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'];
|
|
1344
|
+
function toPersianDigits(input) {
|
|
1345
|
+
return input.replace(/\d/g, (d) => FA_DIGITS[Number(d)]);
|
|
1346
|
+
}
|
|
1347
|
+
function toEnglishDigits(input) {
|
|
1348
|
+
return input.replace(/[۰-۹٠-٩]/g, (digit) => {
|
|
1349
|
+
const code = digit.charCodeAt(0);
|
|
1350
|
+
const value = code >= 0x06f0 ? code - 0x06f0 : code - 0x0660;
|
|
1351
|
+
return String(value);
|
|
1352
|
+
});
|
|
1353
|
+
}
|
|
1354
|
+
function formatNumber(value, locale) {
|
|
1355
|
+
const grouped = Math.round(value || 0).toLocaleString('en-US');
|
|
1356
|
+
return locale === 'fa' ? toPersianDigits(grouped) : grouped;
|
|
1357
|
+
}
|
|
1358
|
+
/**
|
|
1359
|
+
* Format a percentage with up to `maxDecimals` digits, trimming trailing zeros.
|
|
1360
|
+
* Keeps small fractions visible (e.g. 0.02%) where `formatNumber` would round to 0.
|
|
1361
|
+
*/
|
|
1362
|
+
function formatPercent(value, locale, maxDecimals = 2) {
|
|
1363
|
+
const safe = value || 0;
|
|
1364
|
+
const rounded = Number(safe.toFixed(maxDecimals));
|
|
1365
|
+
const text = String(rounded);
|
|
1366
|
+
return locale === 'fa' ? toPersianDigits(text) : text;
|
|
1367
|
+
}
|
|
1368
|
+
function defaultCurrencyLabel(locale) {
|
|
1369
|
+
return locale === 'fa' ? 'تومان' : 'Toman';
|
|
1370
|
+
}
|
|
1371
|
+
function formatMoney(value, locale, currencyLabel) {
|
|
1372
|
+
const label = currencyLabel ?? defaultCurrencyLabel(locale);
|
|
1373
|
+
return `${formatNumber(value, locale)} ${label}`;
|
|
1374
|
+
}
|
|
1375
|
+
/**
|
|
1376
|
+
* Normalize an Iranian phone number to the 11-digit 0XXXXXXXXXX format.
|
|
1377
|
+
* Accepts: 09..., +989..., 00989..., 989..., 9... (10 digits without leading 0)
|
|
1378
|
+
*/
|
|
1379
|
+
function normalizeIranianPhone(raw) {
|
|
1380
|
+
let s = toEnglishDigits(raw).replace(/[\s\-().]/g, '');
|
|
1381
|
+
if (s.startsWith('+98'))
|
|
1382
|
+
s = '0' + s.slice(3);
|
|
1383
|
+
else if (s.startsWith('0098'))
|
|
1384
|
+
s = '0' + s.slice(4);
|
|
1385
|
+
else if (/^98\d{9}$/.test(s))
|
|
1386
|
+
s = '0' + s.slice(2);
|
|
1387
|
+
else if (/^9\d{9}$/.test(s))
|
|
1388
|
+
s = '0' + s;
|
|
1389
|
+
return s;
|
|
1390
|
+
}
|
|
1391
|
+
function isValidIranianPhone(s) {
|
|
1392
|
+
return /^0[0-9]{10}$/.test(s);
|
|
1393
|
+
}
|
|
1394
|
+
function isValidIranianMobile(s) {
|
|
1395
|
+
return /^09[0-9]{9}$/.test(s);
|
|
1396
|
+
}
|
|
1397
|
+
/** Free price shown when a rate / delivery costs nothing. */
|
|
1398
|
+
function formatPrice(value, locale, currencyLabel) {
|
|
1399
|
+
if (!value || value <= 0) {
|
|
1400
|
+
return locale === 'fa' ? 'رایگان' : 'Free';
|
|
1401
|
+
}
|
|
1402
|
+
return formatMoney(value, locale, currencyLabel);
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
const fa = {
|
|
1406
|
+
stepCart: 'سبد خرید',
|
|
1407
|
+
stepShipping: 'ارسال',
|
|
1408
|
+
stepPayment: 'پرداخت',
|
|
1409
|
+
stepReview: 'بازبینی',
|
|
1410
|
+
stepResult: 'پایان خرید',
|
|
1411
|
+
stepShippingInfo: 'اطلاعات ارسال',
|
|
1412
|
+
stepOf: (current, total) => `مرحله ${current} از ${total}`,
|
|
1413
|
+
next: 'ادامه',
|
|
1414
|
+
placeOrder: 'ثبت سفارش',
|
|
1415
|
+
saveShippingDetails: 'ثبت اطلاعات ارسال',
|
|
1416
|
+
continueToPayment: 'ادامه به پرداخت',
|
|
1417
|
+
finalizeOrder: 'نهاییسازی سفارش',
|
|
1418
|
+
finishPurchase: 'پایان خرید',
|
|
1419
|
+
back: 'بازگشت',
|
|
1420
|
+
continueShopping: 'ادامه خرید',
|
|
1421
|
+
orderSummary: 'خلاصه سفارش',
|
|
1422
|
+
subtotal: 'جمع کل',
|
|
1423
|
+
shipping: 'هزینه ارسال',
|
|
1424
|
+
discount: 'تخفیف',
|
|
1425
|
+
credit: 'اعتبار',
|
|
1426
|
+
vat: 'مالیات',
|
|
1427
|
+
total: 'مبلغ قابل پرداخت',
|
|
1428
|
+
totalAmount: 'مبلغ کل',
|
|
1429
|
+
free: 'رایگان',
|
|
1430
|
+
quantity: 'تعداد',
|
|
1431
|
+
optional: 'اختیاری',
|
|
1432
|
+
cartTitle: 'سبد خرید شما',
|
|
1433
|
+
cartEmpty: 'سبد خرید شما خالی است',
|
|
1434
|
+
cartEmptyHint: 'محصولات موردعلاقه را اضافه کنید تا اینجا ببینید.',
|
|
1435
|
+
remove: 'حذف',
|
|
1436
|
+
itemDiscount: (amount) => `${amount} تخفیف`,
|
|
1437
|
+
yourSavings: 'سود شما از این خرید',
|
|
1438
|
+
contactInfo: 'اطلاعات تماس',
|
|
1439
|
+
firstName: 'نام',
|
|
1440
|
+
lastName: 'نام خانوادگی',
|
|
1441
|
+
mobilePhone: 'شماره موبایل',
|
|
1442
|
+
email: 'ایمیل',
|
|
1443
|
+
phoneNumber: 'تلفن ثابت',
|
|
1444
|
+
region: 'استان',
|
|
1445
|
+
city: 'شهر',
|
|
1446
|
+
postalCode: 'کد پستی',
|
|
1447
|
+
addressLine: 'نشانی کامل',
|
|
1448
|
+
description: 'توضیحات',
|
|
1449
|
+
selectRegion: 'انتخاب استان',
|
|
1450
|
+
selectCity: 'انتخاب شهر',
|
|
1451
|
+
shippingMethod: 'روش ارسال',
|
|
1452
|
+
shippingMethods: 'روشهای ارسال',
|
|
1453
|
+
shippingMethodsHint: 'پس از ثبت نشانی، روشهای قابل استفاده نمایش داده میشوند.',
|
|
1454
|
+
digitalNoShipping: 'محصول دیجیتال — بدون نیاز به ارسال',
|
|
1455
|
+
errorRequired: 'این فیلد اجباری است',
|
|
1456
|
+
errorMobilePhone: 'شماره موبایل معتبر وارد کنید (مثلاً ۰۹۱۲۳۴۵۶۷۸۹)',
|
|
1457
|
+
errorEmail: 'آدرس ایمیل معتبر نیست',
|
|
1458
|
+
changeTo: 'تغییر به',
|
|
1459
|
+
productCount: (n) => `${n} محصول`,
|
|
1460
|
+
paymentMethod: 'روش پرداخت',
|
|
1461
|
+
discountCode: 'کد تخفیف',
|
|
1462
|
+
discountPlaceholder: 'کد تخفیف را وارد کنید',
|
|
1463
|
+
apply: 'اعمال',
|
|
1464
|
+
applied: 'اعمال شد',
|
|
1465
|
+
discountPercentOff: (percent) => `${percent}٪ تخفیف`,
|
|
1466
|
+
discountAmountOff: (amount) => `${amount} تخفیف`,
|
|
1467
|
+
discountFreeShipping: 'ارسال رایگان',
|
|
1468
|
+
reviewTitle: 'بازبینی سفارش',
|
|
1469
|
+
reviewContact: 'مشخصات گیرنده',
|
|
1470
|
+
reviewAddress: 'آدرس ارسال',
|
|
1471
|
+
reviewShipping: 'روش ارسال',
|
|
1472
|
+
reviewPayment: 'روش پرداخت',
|
|
1473
|
+
payNow: 'پرداخت',
|
|
1474
|
+
edit: 'ویرایش',
|
|
1475
|
+
paymentSuccess: 'پرداخت با موفقیت انجام شد',
|
|
1476
|
+
paymentFailed: 'پرداخت ناموفق بود',
|
|
1477
|
+
paymentPending: 'در حال بررسی پرداخت',
|
|
1478
|
+
paymentPendingHint: 'پرداخت شما در حال پردازش است. لطفاً صبر کنید.',
|
|
1479
|
+
orderNumber: 'شماره سفارش',
|
|
1480
|
+
tryAgain: 'تلاش دوباره',
|
|
1481
|
+
loading: 'در حال بارگذاری…',
|
|
1482
|
+
processing: 'در حال پردازش…',
|
|
1483
|
+
redirecting: 'در حال انتقال به درگاه پرداخت…'
|
|
1484
|
+
};
|
|
1485
|
+
const en = {
|
|
1486
|
+
stepCart: 'Cart',
|
|
1487
|
+
stepShipping: 'Shipping',
|
|
1488
|
+
stepPayment: 'Payment',
|
|
1489
|
+
stepReview: 'Review',
|
|
1490
|
+
stepResult: 'Finish',
|
|
1491
|
+
stepShippingInfo: 'Shipping details',
|
|
1492
|
+
stepOf: (current, total) => `Step ${current} of ${total}`,
|
|
1493
|
+
next: 'Continue',
|
|
1494
|
+
placeOrder: 'Place order',
|
|
1495
|
+
saveShippingDetails: 'Save shipping details',
|
|
1496
|
+
continueToPayment: 'Continue to payment',
|
|
1497
|
+
finalizeOrder: 'Finalize order',
|
|
1498
|
+
finishPurchase: 'Finish purchase',
|
|
1499
|
+
back: 'Back',
|
|
1500
|
+
continueShopping: 'Continue shopping',
|
|
1501
|
+
orderSummary: 'Order Summary',
|
|
1502
|
+
subtotal: 'Subtotal',
|
|
1503
|
+
shipping: 'Shipping',
|
|
1504
|
+
discount: 'Discount',
|
|
1505
|
+
credit: 'Credit',
|
|
1506
|
+
vat: 'VAT',
|
|
1507
|
+
total: 'Total',
|
|
1508
|
+
totalAmount: 'Total amount',
|
|
1509
|
+
free: 'Free',
|
|
1510
|
+
quantity: 'Qty',
|
|
1511
|
+
optional: 'Optional',
|
|
1512
|
+
cartTitle: 'Your cart',
|
|
1513
|
+
cartEmpty: 'Your cart is empty',
|
|
1514
|
+
cartEmptyHint: 'Add a few items and they’ll show up here.',
|
|
1515
|
+
remove: 'Remove',
|
|
1516
|
+
itemDiscount: (amount) => `Save ${amount}`,
|
|
1517
|
+
yourSavings: 'Your savings',
|
|
1518
|
+
contactInfo: 'Contact information',
|
|
1519
|
+
firstName: 'First name',
|
|
1520
|
+
lastName: 'Last name',
|
|
1521
|
+
mobilePhone: 'Mobile phone',
|
|
1522
|
+
phoneNumber: 'Phone number',
|
|
1523
|
+
email: 'Email',
|
|
1524
|
+
region: 'Province',
|
|
1525
|
+
city: 'City',
|
|
1526
|
+
postalCode: 'Postal code',
|
|
1527
|
+
addressLine: 'Full address',
|
|
1528
|
+
description: 'Description',
|
|
1529
|
+
selectRegion: 'Select province',
|
|
1530
|
+
selectCity: 'Select city',
|
|
1531
|
+
shippingMethod: 'Shipping method',
|
|
1532
|
+
shippingMethods: 'Shipping methods',
|
|
1533
|
+
shippingMethodsHint: 'Available delivery options will appear after you save this address.',
|
|
1534
|
+
digitalNoShipping: 'Digital product — no shipping required',
|
|
1535
|
+
errorRequired: 'This field is required',
|
|
1536
|
+
errorMobilePhone: 'Enter a valid mobile number (e.g. 09123456789)',
|
|
1537
|
+
errorEmail: 'Enter a valid email address',
|
|
1538
|
+
changeTo: 'Switch to',
|
|
1539
|
+
productCount: (n) => `${n} item(s)`,
|
|
1540
|
+
paymentMethod: 'Payment method',
|
|
1541
|
+
discountCode: 'Discount code',
|
|
1542
|
+
discountPlaceholder: 'Enter discount code',
|
|
1543
|
+
apply: 'Apply',
|
|
1544
|
+
applied: 'Applied',
|
|
1545
|
+
discountPercentOff: (percent) => `${percent}% off`,
|
|
1546
|
+
discountAmountOff: (amount) => `${amount} off`,
|
|
1547
|
+
discountFreeShipping: 'Free shipping',
|
|
1548
|
+
reviewTitle: 'Review your order',
|
|
1549
|
+
reviewContact: 'Recipient',
|
|
1550
|
+
reviewAddress: 'Shipping address',
|
|
1551
|
+
reviewShipping: 'Shipping method',
|
|
1552
|
+
reviewPayment: 'Payment method',
|
|
1553
|
+
payNow: 'Pay',
|
|
1554
|
+
edit: 'Edit',
|
|
1555
|
+
paymentSuccess: 'Payment completed successfully',
|
|
1556
|
+
paymentFailed: 'Payment failed',
|
|
1557
|
+
paymentPending: 'Verifying payment',
|
|
1558
|
+
paymentPendingHint: 'Your payment is being processed. Please wait.',
|
|
1559
|
+
orderNumber: 'Order number',
|
|
1560
|
+
tryAgain: 'Try again',
|
|
1561
|
+
loading: 'Loading…',
|
|
1562
|
+
processing: 'Processing…',
|
|
1563
|
+
redirecting: 'Redirecting to the payment gateway…'
|
|
1564
|
+
};
|
|
1565
|
+
const DICTS = { fa, en };
|
|
1566
|
+
function strings(locale) {
|
|
1567
|
+
return DICTS[locale];
|
|
1568
|
+
}
|
|
1569
|
+
/** Brand/title + family per gateway. Unlisted codes fall back to a generic online gateway. */
|
|
1570
|
+
const GATEWAYS = {
|
|
1571
|
+
cardtocardpayment: { kind: 'card', title: { fa: 'کارت به کارت', en: 'Card to card' } },
|
|
1572
|
+
paymentinplace: { kind: 'cod', title: { fa: 'پرداخت در محل', en: 'Pay on delivery' } },
|
|
1573
|
+
freepayment: { kind: 'free', title: { fa: 'پرداخت رایگان', en: 'Free' } },
|
|
1574
|
+
// BNPL / installment
|
|
1575
|
+
snapppayment: { kind: 'bnpl', title: { fa: 'اسنپپی', en: 'SnappPay' } },
|
|
1576
|
+
torobpaypayment: { kind: 'bnpl', title: { fa: 'تربپی', en: 'TorobPay' } },
|
|
1577
|
+
azkipayment: { kind: 'bnpl', title: { fa: 'ازکیوام', en: 'Azki' } },
|
|
1578
|
+
digipaypayment: { kind: 'bnpl', title: { fa: 'دیجیپی', en: 'DigiPay' } },
|
|
1579
|
+
tarapayment: { kind: 'bnpl', title: { fa: 'تارا', en: 'Tara' } },
|
|
1580
|
+
tomanpayment: { kind: 'bnpl', title: { fa: 'تومان', en: 'Toman' } },
|
|
1581
|
+
novapaypayment: { kind: 'bnpl', title: { fa: 'نوادپی', en: 'NovaPay' } },
|
|
1582
|
+
// wallets
|
|
1583
|
+
vandarpayment: { kind: 'wallet', title: { fa: 'کیف پول وندار', en: 'Vandar wallet' } },
|
|
1584
|
+
zarinpalpayment: { kind: 'wallet', title: { fa: 'زرینپال', en: 'ZarinPal' } },
|
|
1585
|
+
zarinpluspayment: { kind: 'wallet', title: { fa: 'زرینپلاس', en: 'ZarinPlus' } },
|
|
1586
|
+
paypingpayment: { kind: 'wallet', title: { fa: 'پیپینگ', en: 'PayPing' } }
|
|
1587
|
+
};
|
|
1588
|
+
const KIND_DESC = {
|
|
1589
|
+
card: { fa: 'انتقال مستقیم به شمارهکارت فروشنده', en: 'Transfer directly to the seller’s card' },
|
|
1590
|
+
cod: { fa: 'هنگام تحویل سفارش پرداخت کنید', en: 'Pay when your order is delivered' },
|
|
1591
|
+
bnpl: { fa: 'خرید اعتباری و پرداخت اقساطی', en: 'Buy now, pay later in installments' },
|
|
1592
|
+
wallet: { fa: 'پرداخت از موجودی کیف پول', en: 'Pay from your wallet balance' },
|
|
1593
|
+
gateway: { fa: 'انتقال امن به درگاه بانکی', en: 'Secure redirect to the bank gateway' },
|
|
1594
|
+
free: { fa: 'مبلغی برای پرداخت وجود ندارد', en: 'Nothing to pay for this order' }
|
|
1595
|
+
};
|
|
1596
|
+
const FALLBACK_TITLE = { fa: 'پرداخت آنلاین', en: 'Online payment' };
|
|
1597
|
+
/** Human label for a payment gateway code. Online gateways fall back generically. */
|
|
1598
|
+
function paymentMethodLabel(code, locale) {
|
|
1599
|
+
return GATEWAYS[code]?.title[locale] ?? FALLBACK_TITLE[locale];
|
|
1600
|
+
}
|
|
1601
|
+
/** Title + description + visual family for a payment gateway code. */
|
|
1602
|
+
function paymentMethodInfo(code, locale) {
|
|
1603
|
+
const meta = GATEWAYS[code];
|
|
1604
|
+
const kind = meta?.kind ?? 'gateway';
|
|
1605
|
+
return {
|
|
1606
|
+
title: meta?.title[locale] ?? FALLBACK_TITLE[locale],
|
|
1607
|
+
description: KIND_DESC[kind][locale],
|
|
1608
|
+
kind
|
|
1609
|
+
};
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
export { paymentMethodLabel as A, selectDigitalItems as B, selectSummary as C, strings as D, toEnglishDigits as E, toPersianDigits as F, addressFormFromInvoice as a, buildShippingAssignments as b, classifyAppliedDiscount as c, createBrowserEffectExecutor as d, createCheckoutEngine as e, createSdkBinding as f, createStore as g, defaultCurrencyLabel as h, deriveShippingGroups as i, emptyAddressForm as j, formatMoney as k, formatNumber as l, formatPercent as m, formatPrice as n, fromSdkError as o, isAddressComplete as p, isAddressDirty as q, isShippingComplete as r, isValidIranianMobile as s, isValidIranianPhone as t, makeError as u, makeEvent as v, messageForCode as w, noopEffectExecutor as x, normalizeIranianPhone as y, paymentMethodInfo as z };
|
|
1613
|
+
//# sourceMappingURL=labels-ChywPk2i.js.map
|