@sazito/checkout 0.1.1 → 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.
@@ -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;
@@ -994,6 +1076,7 @@ function createCheckoutEngine(options) {
994
1076
  setAddressField(key, value) {
995
1077
  const before = get().addressForm;
996
1078
  const changed = before[key] !== value || (key === 'regionId' && before.cityId !== null);
1079
+ const destinationChanged = changed && (key === 'regionId' || key === 'cityId');
997
1080
  if (changed) {
998
1081
  addressRevision += 1;
999
1082
  }
@@ -1007,22 +1090,28 @@ function createCheckoutEngine(options) {
1007
1090
  return {
1008
1091
  addressForm,
1009
1092
  addressDirty,
1010
- // Rates are derived from the address snapshot. Never keep showing or
1011
- // accepting rates fetched for a different form value.
1012
- ...(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: [] } : {})
1013
1096
  };
1014
1097
  });
1015
- // City selection is a discrete destination change, so refresh rates
1016
- // automatically. Text inputs still wait for Save/Continue to avoid an
1017
- // address creation and shipping request on every keystroke.
1018
- 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') {
1019
1102
  const state = get();
1020
- if (isAddressComplete(state.addressForm, state.invoice?.needsShipping ?? true, state.postalCodeMandatory, state.emailMandatory)) {
1021
- 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();
1022
1110
  }
1023
1111
  }
1024
1112
  },
1025
1113
  async submitAddress() {
1114
+ clearAddressAutoSubmit();
1026
1115
  return withLock(submitAddressInternal);
1027
1116
  },
1028
1117
  async selectShippingRate(groupKey, rateId) {
@@ -1144,6 +1233,8 @@ function createCheckoutEngine(options) {
1144
1233
  const state = get();
1145
1234
  const submittedAddressRevision = addressRevision;
1146
1235
  const needsShipping = state.invoice?.needsShipping ?? true;
1236
+ const shouldReloadShipping = needsShipping
1237
+ && (!state.applicable || state.shippingGroups.length === 0);
1147
1238
  const abandonStaleSubmission = (invoice) => {
1148
1239
  set({
1149
1240
  ...(invoice ? { invoice } : {}),
@@ -1188,7 +1279,15 @@ function createCheckoutEngine(options) {
1188
1279
  }
1189
1280
  set({ invoice: linkRes.data, addressDirty: false });
1190
1281
  if (needsShipping) {
1191
- 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
+ }
1192
1291
  if (ok) {
1193
1292
  await assignCurrentShipping();
1194
1293
  if (addressRevision !== submittedAddressRevision) {
@@ -1215,6 +1314,7 @@ function createCheckoutEngine(options) {
1215
1314
  effectExecutor = executor;
1216
1315
  },
1217
1316
  destroy() {
1317
+ clearAddressAutoSubmit();
1218
1318
  effectExecutor = noopEffectExecutor;
1219
1319
  }
1220
1320
  };
@@ -1427,7 +1527,8 @@ const fa = {
1427
1527
  shippingMethod: 'روش ارسال',
1428
1528
  shippingMethods: 'روش‌های ارسال',
1429
1529
  shippingMethodsHint: 'پس از ثبت نشانی، روش‌های قابل استفاده نمایش داده می‌شوند.',
1430
- digitalNoShipping: 'محصول دیجیتال بدون نیاز به ارسال',
1530
+ digitalNoShipping: 'محصولات دیجیتال و خدمات',
1531
+ digitalNoShippingHint: 'این موارد به ارسال فیزیکی نیاز ندارند.',
1431
1532
  errorRequired: 'این فیلد اجباری است',
1432
1533
  errorMobilePhone: 'شماره موبایل معتبر وارد کنید (مثلاً ۰۹۱۲۳۴۵۶۷۸۹)',
1433
1534
  errorEmail: 'آدرس ایمیل معتبر نیست',
@@ -1507,7 +1608,8 @@ const en = {
1507
1608
  shippingMethod: 'Shipping method',
1508
1609
  shippingMethods: 'Shipping methods',
1509
1610
  shippingMethodsHint: 'Available delivery options will appear after you save this address.',
1510
- digitalNoShipping: 'Digital product no shipping required',
1611
+ digitalNoShipping: 'Digital products and services',
1612
+ digitalNoShippingHint: 'These items do not require physical delivery.',
1511
1613
  errorRequired: 'This field is required',
1512
1614
  errorMobilePhone: 'Enter a valid mobile number (e.g. 09123456789)',
1513
1615
  errorEmail: 'Enter a valid email address',
@@ -1585,5 +1687,5 @@ function paymentMethodInfo(code, locale) {
1585
1687
  };
1586
1688
  }
1587
1689
 
1588
- 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 };
1589
- //# sourceMappingURL=labels-BDeABGTD.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