@sazito/checkout 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -83,6 +83,40 @@ export default function Checkout() {
83
83
  The component **inherits the host font** (`--szc-font: inherit`). The demo app
84
84
  loads Vazirmatn on `<body>`; that's all it takes for a Persian/RTL checkout.
85
85
 
86
+ Theme values may reference host application tokens directly. Changes to those
87
+ tokens, including dark-mode changes, are reflected without rerendering:
88
+
89
+ ```tsx
90
+ config={{
91
+ theme: {
92
+ accent: 'var(--color-primary)',
93
+ accentForeground: 'var(--color-on-primary)',
94
+ background: 'var(--color-background)',
95
+ foreground: 'var(--color-foreground)',
96
+ card: 'var(--color-card)',
97
+ border: 'var(--color-border)',
98
+ fontFamily: 'var(--font-sans)',
99
+ },
100
+ }}
101
+ ```
102
+
103
+ Alternatively, pass `className="store-checkout"` and map the native variables
104
+ in CSS imported after the checkout stylesheet:
105
+
106
+ ```css
107
+ .szc-root.store-checkout {
108
+ --szc-accent: var(--color-primary);
109
+ --szc-bg: var(--color-background);
110
+ --szc-fg: var(--color-foreground);
111
+ --szc-card: var(--color-card);
112
+ --szc-border: var(--color-border);
113
+ --szc-radius: var(--radius-lg);
114
+ }
115
+ ```
116
+
117
+ Avoid configuring the same token through both methods: `config.theme` writes
118
+ inline variables and therefore wins over ordinary stylesheet declarations.
119
+
86
120
  ## Flow (v1 scope)
87
121
 
88
122
  4 states — `cart → shipping → payment → result`:
@@ -200,6 +200,30 @@ function createBrowserEffectExecutor(config) {
200
200
  const noopEffectExecutor = () => { };
201
201
 
202
202
  const idStr = (value) => String(value);
203
+ function cartItemCreatedAt(item) {
204
+ if (!item.createdAt)
205
+ return null;
206
+ const timestamp = Date.parse(item.createdAt);
207
+ return Number.isFinite(timestamp) ? timestamp : null;
208
+ }
209
+ /** Return cart lines newest-first without mutating SDK state. */
210
+ function sortCartItemsNewestFirst(items) {
211
+ return [...items].sort((a, b) => {
212
+ const aCreatedAt = cartItemCreatedAt(a);
213
+ const bCreatedAt = cartItemCreatedAt(b);
214
+ if (aCreatedAt != null || bCreatedAt != null) {
215
+ if (aCreatedAt == null)
216
+ return 1;
217
+ if (bCreatedAt == null)
218
+ return -1;
219
+ if (aCreatedAt !== bCreatedAt)
220
+ return bCreatedAt - aCreatedAt;
221
+ }
222
+ const aId = Number(a.id);
223
+ const bId = Number(b.id);
224
+ return Number.isFinite(aId) && Number.isFinite(bId) ? bId - aId : 0;
225
+ });
226
+ }
203
227
  function emptyAddressForm() {
204
228
  return {
205
229
  firstName: '',
@@ -298,6 +322,28 @@ function uniqueRates(rates) {
298
322
  }
299
323
  return out;
300
324
  }
325
+ function isDigitalServiceLabel(value) {
326
+ if (!value)
327
+ return false;
328
+ const normalized = value.trim().toLocaleLowerCase('en-US');
329
+ return (normalized.includes('digital') ||
330
+ normalized.includes('service') ||
331
+ normalized.includes('دیجیتال') ||
332
+ normalized.includes('خدمات') ||
333
+ normalized.includes('خدمت'));
334
+ }
335
+ function isDigitalServiceItem(item) {
336
+ return isDigitalServiceLabel(item.productType);
337
+ }
338
+ /** True for the API's automatic digital/service delivery group. */
339
+ function isDigitalServiceGroup(group) {
340
+ if (isDigitalServiceLabel(group.key) || isDigitalServiceLabel(group.title)) {
341
+ return true;
342
+ }
343
+ const ratesAreDigitalService = group.rates.length > 0 &&
344
+ group.rates.every((rate) => isDigitalServiceLabel(rate.type) || isDigitalServiceLabel(rate.name));
345
+ return ratesAreDigitalService || (group.items.length > 0 && group.items.every(isDigitalServiceItem));
346
+ }
301
347
  /**
302
348
  * Group invoice items into shippable bundles with their switchable rates and
303
349
  * the currently selected rate. Digital-only invoices yield an empty list.
@@ -317,10 +363,12 @@ function deriveShippingGroups(invoice, applicable) {
317
363
  return [];
318
364
  }
319
365
  const itemById = new Map(invoice.items.map((item) => [idStr(item.id), item]));
320
- // Exclude digital items they never go through physical shipping groups.
366
+ // The API's plain `digital` products never receive a shipping assignment.
367
+ // Other digital/service variants may arrive through an automatic fulfillment
368
+ // rate; retain those groups for the API assignment and hide them in the UI.
321
369
  const physicalItemsRate = (applicable.itemsShippingRate ?? []).filter((isr) => {
322
370
  const item = itemById.get(idStr(isr.invoiceItemId));
323
- return !item || item.productType !== 'digital';
371
+ return !item || item.productType?.trim().toLocaleLowerCase('en-US') !== 'digital';
324
372
  });
325
373
  if (physicalItemsRate.length === 0)
326
374
  return [];
@@ -419,24 +467,27 @@ function deriveShippingGroups(invoice, applicable) {
419
467
  }
420
468
  ];
421
469
  }
422
- /** Build the API payload from the current group selections. */
470
+ /** Build the API payload from physical shipping selections. */
423
471
  function buildShippingAssignments(groups) {
424
472
  return groups
425
- .filter((group) => group.selectedRateId != null)
473
+ .filter((group) => !isDigitalServiceGroup(group) && group.selectedRateId != null)
426
474
  .map((group) => ({
427
475
  rateId: group.selectedRateId,
428
476
  invoiceItemIds: group.itemIds
429
477
  }));
430
478
  }
431
- /** True once every shippable group has a selected rate. */
479
+ /** True once every physical shipping group has a selected rate. */
432
480
  function isShippingComplete(invoice, groups) {
433
481
  if (!invoice?.needsShipping) {
434
482
  return true;
435
483
  }
436
- if (groups.length === 0) {
437
- return false;
484
+ const physicalGroups = groups.filter((group) => !isDigitalServiceGroup(group));
485
+ if (physicalGroups.length === 0) {
486
+ // The API may set needsShipping for an automatic Digital/Service group.
487
+ // It is fulfillment metadata, not a customer-selectable shipping method.
488
+ return groups.length > 0 && groups.every(isDigitalServiceGroup);
438
489
  }
439
- return groups.every((group) => group.selectedRateId != null);
490
+ return physicalGroups.every((group) => group.selectedRateId != null);
440
491
  }
441
492
  const codeDiscountTotal = (invoice) => (invoice.couponTotal || 0) + (invoice.discountTotal || 0);
442
493
  /**
@@ -540,14 +591,22 @@ function selectDigitalItems(invoice, groups, applicable) {
540
591
  // Before shipping methods are loaded, only explicitly-digital items are known.
541
592
  // Showing everything as "digital" when applicable is null would be wrong.
542
593
  if (applicable == null) {
543
- return invoice.items.filter((item) => item.productType === 'digital');
594
+ return invoice.items.filter(isDigitalServiceItem);
544
595
  }
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)));
596
+ // The backend may model digital/service fulfillment as an automatic zero-cost
597
+ // shipping group. Treat its products as non-shippable in the UI while keeping
598
+ // the group in state so its assignment can still be submitted.
599
+ const digitalGroupItems = new Set(groups
600
+ .filter(isDigitalServiceGroup)
601
+ .flatMap((group) => group.itemIds.map(idStr)));
602
+ const groupedItems = new Set(groups.flatMap((group) => group.itemIds.map(idStr)));
603
+ return invoice.items.filter((item) => isDigitalServiceItem(item) ||
604
+ digitalGroupItems.has(idStr(item.id)) ||
605
+ !groupedItems.has(idStr(item.id)));
548
606
  }
549
607
 
550
608
  const STEP_ORDER = ['cart', 'shipping', 'payment', 'result'];
609
+ const ADDRESS_AUTOSUBMIT_DEBOUNCE_MS = 350;
551
610
  function initialFlags() {
552
611
  return {
553
612
  bootstrapping: false,
@@ -594,6 +653,7 @@ function createCheckoutEngine(options) {
594
653
  let effectExecutor = noopEffectExecutor;
595
654
  let lock = Promise.resolve();
596
655
  let addressRevision = 0;
656
+ let addressAutoSubmitTimer = null;
597
657
  // ---- internal helpers -------------------------------------------------
598
658
  const get = () => store.getState();
599
659
  const set = store.setState;
@@ -614,6 +674,19 @@ function createCheckoutEngine(options) {
614
674
  lock = next.then(() => undefined, () => undefined);
615
675
  return next;
616
676
  }
677
+ function clearAddressAutoSubmit() {
678
+ if (addressAutoSubmitTimer) {
679
+ clearTimeout(addressAutoSubmitTimer);
680
+ addressAutoSubmitTimer = null;
681
+ }
682
+ }
683
+ function scheduleAddressAutoSubmit(delay = ADDRESS_AUTOSUBMIT_DEBOUNCE_MS) {
684
+ clearAddressAutoSubmit();
685
+ addressAutoSubmitTimer = setTimeout(() => {
686
+ addressAutoSubmitTimer = null;
687
+ void withLock(submitAddressInternal);
688
+ }, delay);
689
+ }
617
690
  function recomputeGroups() {
618
691
  const { invoice, applicable } = get();
619
692
  const groups = deriveShippingGroups(invoice, applicable);
@@ -809,13 +882,17 @@ function createCheckoutEngine(options) {
809
882
  }
810
883
  // ---- public actions ---------------------------------------------------
811
884
  function goToStep(step) {
885
+ if (step !== 'shipping') {
886
+ clearAddressAutoSubmit();
887
+ }
812
888
  set({ step, error: null });
813
889
  emit('step_viewed', { step });
814
890
  // When landing on shipping with a complete address but no methods fetched yet,
815
891
  // auto-submit so methods appear without requiring a manual Continue press.
816
892
  if (step === 'shipping') {
817
- const { addressForm, invoice, applicable } = get();
818
- if (!applicable && isAddressComplete(addressForm, invoice?.needsShipping ?? true, get().postalCodeMandatory, get().emailMandatory)) {
893
+ const { addressForm, addressDirty, invoice, applicable, shippingGroups } = get();
894
+ const methodsUnresolved = Boolean(invoice?.needsShipping) && shippingGroups.length === 0;
895
+ if ((addressDirty || !applicable || methodsUnresolved) && isAddressComplete(addressForm, invoice?.needsShipping ?? true, get().postalCodeMandatory, get().emailMandatory)) {
819
896
  void withLock(submitAddressInternal);
820
897
  }
821
898
  }
@@ -918,13 +995,18 @@ function createCheckoutEngine(options) {
918
995
  // the Shipping step becomes visible.
919
996
  const invoice = get().invoice;
920
997
  if (isUsableSavedAddress(invoice?.shippingAddress)) {
921
- set({ addressForm: addressFormFromInvoice(invoice) });
998
+ const addressForm = reconcileAddressWithRegions(addressFormFromInvoice(invoice), get().regions, savedAddressCityName(invoice?.shippingAddress));
999
+ set({
1000
+ addressForm,
1001
+ addressDirty: isAddressDirty(addressForm, invoice)
1002
+ });
922
1003
  }
923
1004
  goToStep('shipping');
924
1005
  }
925
1006
  return;
926
1007
  }
927
1008
  if (state.step === 'shipping') {
1009
+ clearAddressAutoSubmit();
928
1010
  // Phase 1: address not saved yet (or changed) → save it and reveal the
929
1011
  // shipping methods, staying on the shipping step.
930
1012
  const needsSave = state.addressDirty || !state.applicable;
@@ -969,10 +1051,6 @@ function createCheckoutEngine(options) {
969
1051
  const res = await binding.client.cart.updateItem(cartProductId, variantId, quantity);
970
1052
  if (res.data) {
971
1053
  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
1054
  await refreshInvoice();
977
1055
  }
978
1056
  else if (res.error) {
@@ -987,9 +1065,6 @@ function createCheckoutEngine(options) {
987
1065
  const res = await binding.client.cart.removeItem(cartProductId, variantId);
988
1066
  if (res.data) {
989
1067
  set({ cart: res.data });
990
- const inv = get().invoice;
991
- if (inv)
992
- set({ invoice: applyCartToInvoice(inv, res.data) });
993
1068
  await refreshInvoice();
994
1069
  }
995
1070
  else if (res.error) {
@@ -1001,6 +1076,7 @@ function createCheckoutEngine(options) {
1001
1076
  setAddressField(key, value) {
1002
1077
  const before = get().addressForm;
1003
1078
  const changed = before[key] !== value || (key === 'regionId' && before.cityId !== null);
1079
+ const destinationChanged = changed && (key === 'regionId' || key === 'cityId');
1004
1080
  if (changed) {
1005
1081
  addressRevision += 1;
1006
1082
  }
@@ -1014,22 +1090,28 @@ function createCheckoutEngine(options) {
1014
1090
  return {
1015
1091
  addressForm,
1016
1092
  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: [] } : {})
1093
+ // Shipping rates depend on region/city. Contact and street edits must
1094
+ // not discard valid methods for the same destination.
1095
+ ...(destinationChanged ? { applicable: null, shippingGroups: [] } : {})
1020
1096
  };
1021
1097
  });
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') {
1098
+ // Browser autofill often populates the selects before the final required
1099
+ // text field. Once the form becomes complete, debounce text changes and
1100
+ // submit automatically; a discrete city selection can submit immediately.
1101
+ if (changed && get().step === 'shipping') {
1026
1102
  const state = get();
1027
- if (isAddressComplete(state.addressForm, state.invoice?.needsShipping ?? true, state.postalCodeMandatory, state.emailMandatory)) {
1028
- void withLock(submitAddressInternal);
1103
+ const methodsUnresolved = !state.applicable
1104
+ || (Boolean(state.invoice?.needsShipping) && state.shippingGroups.length === 0);
1105
+ if (isAddressComplete(state.addressForm, state.invoice?.needsShipping ?? true, state.postalCodeMandatory, state.emailMandatory) && (destinationChanged || methodsUnresolved)) {
1106
+ scheduleAddressAutoSubmit(key === 'cityId' ? 0 : undefined);
1107
+ }
1108
+ else if (!isAddressComplete(state.addressForm, state.invoice?.needsShipping ?? true, state.postalCodeMandatory, state.emailMandatory)) {
1109
+ clearAddressAutoSubmit();
1029
1110
  }
1030
1111
  }
1031
1112
  },
1032
1113
  async submitAddress() {
1114
+ clearAddressAutoSubmit();
1033
1115
  return withLock(submitAddressInternal);
1034
1116
  },
1035
1117
  async selectShippingRate(groupKey, rateId) {
@@ -1151,6 +1233,8 @@ function createCheckoutEngine(options) {
1151
1233
  const state = get();
1152
1234
  const submittedAddressRevision = addressRevision;
1153
1235
  const needsShipping = state.invoice?.needsShipping ?? true;
1236
+ const shouldReloadShipping = needsShipping
1237
+ && (!state.applicable || state.shippingGroups.length === 0);
1154
1238
  const abandonStaleSubmission = (invoice) => {
1155
1239
  set({
1156
1240
  ...(invoice ? { invoice } : {}),
@@ -1195,7 +1279,15 @@ function createCheckoutEngine(options) {
1195
1279
  }
1196
1280
  set({ invoice: linkRes.data, addressDirty: false });
1197
1281
  if (needsShipping) {
1198
- const ok = await loadApplicableShipping(submittedAddressRevision);
1282
+ let ok = true;
1283
+ if (shouldReloadShipping) {
1284
+ ok = await loadApplicableShipping(submittedAddressRevision);
1285
+ }
1286
+ else if (recomputeGroups().length === 0) {
1287
+ // Invoice item IDs can change when the new address snapshot is linked.
1288
+ // Reload only if the existing applicable data can no longer form groups.
1289
+ ok = await loadApplicableShipping(submittedAddressRevision);
1290
+ }
1199
1291
  if (ok) {
1200
1292
  await assignCurrentShipping();
1201
1293
  if (addressRevision !== submittedAddressRevision) {
@@ -1222,6 +1314,7 @@ function createCheckoutEngine(options) {
1222
1314
  effectExecutor = executor;
1223
1315
  },
1224
1316
  destroy() {
1317
+ clearAddressAutoSubmit();
1225
1318
  effectExecutor = noopEffectExecutor;
1226
1319
  }
1227
1320
  };
@@ -1274,23 +1367,6 @@ function toAddressInput(form) {
1274
1367
  description: form.description.trim() || undefined
1275
1368
  };
1276
1369
  }
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
1370
  // Map raw gateway-return query params into the typed payment-step input.
1295
1371
  // Only the string-valued callback fields are forwarded; `trackingData` /
1296
1372
  // `payload` are parsed from JSON when the gateway sends them encoded.
@@ -1451,7 +1527,8 @@ const fa = {
1451
1527
  shippingMethod: 'روش ارسال',
1452
1528
  shippingMethods: 'روش‌های ارسال',
1453
1529
  shippingMethodsHint: 'پس از ثبت نشانی، روش‌های قابل استفاده نمایش داده می‌شوند.',
1454
- digitalNoShipping: 'محصول دیجیتال بدون نیاز به ارسال',
1530
+ digitalNoShipping: 'محصولات دیجیتال و خدمات',
1531
+ digitalNoShippingHint: 'این موارد به ارسال فیزیکی نیاز ندارند.',
1455
1532
  errorRequired: 'این فیلد اجباری است',
1456
1533
  errorMobilePhone: 'شماره موبایل معتبر وارد کنید (مثلاً ۰۹۱۲۳۴۵۶۷۸۹)',
1457
1534
  errorEmail: 'آدرس ایمیل معتبر نیست',
@@ -1531,7 +1608,8 @@ const en = {
1531
1608
  shippingMethod: 'Shipping method',
1532
1609
  shippingMethods: 'Shipping methods',
1533
1610
  shippingMethodsHint: 'Available delivery options will appear after you save this address.',
1534
- digitalNoShipping: 'Digital product no shipping required',
1611
+ digitalNoShipping: 'Digital products and services',
1612
+ digitalNoShippingHint: 'These items do not require physical delivery.',
1535
1613
  errorRequired: 'This field is required',
1536
1614
  errorMobilePhone: 'Enter a valid mobile number (e.g. 09123456789)',
1537
1615
  errorEmail: 'Enter a valid email address',
@@ -1609,5 +1687,5 @@ function paymentMethodInfo(code, locale) {
1609
1687
  };
1610
1688
  }
1611
1689
 
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
1690
+ export { paymentMethodInfo as A, paymentMethodLabel as B, selectDigitalItems as C, selectSummary as D, sortCartItemsNewestFirst as E, strings as F, toEnglishDigits as G, toPersianDigits as H, 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, isDigitalServiceGroup as r, isShippingComplete as s, isValidIranianMobile as t, isValidIranianPhone as u, makeError as v, makeEvent as w, messageForCode as x, noopEffectExecutor as y, normalizeIranianPhone as z };
1691
+ //# sourceMappingURL=labels-ByA_yGKl.js.map