@sazito/checkout 0.1.1 → 0.2.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.
@@ -202,6 +202,30 @@ function createBrowserEffectExecutor(config) {
202
202
  const noopEffectExecutor = () => { };
203
203
 
204
204
  const idStr = (value) => String(value);
205
+ function cartItemCreatedAt(item) {
206
+ if (!item.createdAt)
207
+ return null;
208
+ const timestamp = Date.parse(item.createdAt);
209
+ return Number.isFinite(timestamp) ? timestamp : null;
210
+ }
211
+ /** Return cart lines newest-first without mutating SDK state. */
212
+ function sortCartItemsNewestFirst(items) {
213
+ return [...items].sort((a, b) => {
214
+ const aCreatedAt = cartItemCreatedAt(a);
215
+ const bCreatedAt = cartItemCreatedAt(b);
216
+ if (aCreatedAt != null || bCreatedAt != null) {
217
+ if (aCreatedAt == null)
218
+ return 1;
219
+ if (bCreatedAt == null)
220
+ return -1;
221
+ if (aCreatedAt !== bCreatedAt)
222
+ return bCreatedAt - aCreatedAt;
223
+ }
224
+ const aId = Number(a.id);
225
+ const bId = Number(b.id);
226
+ return Number.isFinite(aId) && Number.isFinite(bId) ? bId - aId : 0;
227
+ });
228
+ }
205
229
  function emptyAddressForm() {
206
230
  return {
207
231
  firstName: '',
@@ -300,6 +324,28 @@ function uniqueRates(rates) {
300
324
  }
301
325
  return out;
302
326
  }
327
+ function isDigitalServiceLabel(value) {
328
+ if (!value)
329
+ return false;
330
+ const normalized = value.trim().toLocaleLowerCase('en-US');
331
+ return (normalized.includes('digital') ||
332
+ normalized.includes('service') ||
333
+ normalized.includes('دیجیتال') ||
334
+ normalized.includes('خدمات') ||
335
+ normalized.includes('خدمت'));
336
+ }
337
+ function isDigitalServiceItem(item) {
338
+ return isDigitalServiceLabel(item.productType);
339
+ }
340
+ /** True for the API's automatic digital/service delivery group. */
341
+ function isDigitalServiceGroup(group) {
342
+ if (isDigitalServiceLabel(group.key) || isDigitalServiceLabel(group.title)) {
343
+ return true;
344
+ }
345
+ const ratesAreDigitalService = group.rates.length > 0 &&
346
+ group.rates.every((rate) => isDigitalServiceLabel(rate.type) || isDigitalServiceLabel(rate.name));
347
+ return ratesAreDigitalService || (group.items.length > 0 && group.items.every(isDigitalServiceItem));
348
+ }
303
349
  /**
304
350
  * Group invoice items into shippable bundles with their switchable rates and
305
351
  * the currently selected rate. Digital-only invoices yield an empty list.
@@ -319,10 +365,12 @@ function deriveShippingGroups(invoice, applicable) {
319
365
  return [];
320
366
  }
321
367
  const itemById = new Map(invoice.items.map((item) => [idStr(item.id), item]));
322
- // Exclude digital items they never go through physical shipping groups.
368
+ // The API's plain `digital` products never receive a shipping assignment.
369
+ // Other digital/service variants may arrive through an automatic fulfillment
370
+ // rate; retain those groups for the API assignment and hide them in the UI.
323
371
  const physicalItemsRate = (applicable.itemsShippingRate ?? []).filter((isr) => {
324
372
  const item = itemById.get(idStr(isr.invoiceItemId));
325
- return !item || item.productType !== 'digital';
373
+ return !item || item.productType?.trim().toLocaleLowerCase('en-US') !== 'digital';
326
374
  });
327
375
  if (physicalItemsRate.length === 0)
328
376
  return [];
@@ -421,24 +469,27 @@ function deriveShippingGroups(invoice, applicable) {
421
469
  }
422
470
  ];
423
471
  }
424
- /** Build the API payload from the current group selections. */
472
+ /** Build the API payload from physical shipping selections. */
425
473
  function buildShippingAssignments(groups) {
426
474
  return groups
427
- .filter((group) => group.selectedRateId != null)
475
+ .filter((group) => !isDigitalServiceGroup(group) && group.selectedRateId != null)
428
476
  .map((group) => ({
429
477
  rateId: group.selectedRateId,
430
478
  invoiceItemIds: group.itemIds
431
479
  }));
432
480
  }
433
- /** True once every shippable group has a selected rate. */
481
+ /** True once every physical shipping group has a selected rate. */
434
482
  function isShippingComplete(invoice, groups) {
435
483
  if (!invoice?.needsShipping) {
436
484
  return true;
437
485
  }
438
- if (groups.length === 0) {
439
- return false;
486
+ const physicalGroups = groups.filter((group) => !isDigitalServiceGroup(group));
487
+ if (physicalGroups.length === 0) {
488
+ // The API may set needsShipping for an automatic Digital/Service group.
489
+ // It is fulfillment metadata, not a customer-selectable shipping method.
490
+ return groups.length > 0 && groups.every(isDigitalServiceGroup);
440
491
  }
441
- return groups.every((group) => group.selectedRateId != null);
492
+ return physicalGroups.every((group) => group.selectedRateId != null);
442
493
  }
443
494
  const codeDiscountTotal = (invoice) => (invoice.couponTotal || 0) + (invoice.discountTotal || 0);
444
495
  /**
@@ -542,14 +593,22 @@ function selectDigitalItems(invoice, groups, applicable) {
542
593
  // Before shipping methods are loaded, only explicitly-digital items are known.
543
594
  // Showing everything as "digital" when applicable is null would be wrong.
544
595
  if (applicable == null) {
545
- return invoice.items.filter((item) => item.productType === 'digital');
596
+ return invoice.items.filter(isDigitalServiceItem);
546
597
  }
547
- // After shipping methods load: items absent from all groups are non-shippable.
548
- const shippable = new Set(groups.flatMap((g) => g.itemIds.map(idStr)));
549
- return invoice.items.filter((item) => !shippable.has(idStr(item.id)));
598
+ // The backend may model digital/service fulfillment as an automatic zero-cost
599
+ // shipping group. Treat its products as non-shippable in the UI while keeping
600
+ // the group in state so its assignment can still be submitted.
601
+ const digitalGroupItems = new Set(groups
602
+ .filter(isDigitalServiceGroup)
603
+ .flatMap((group) => group.itemIds.map(idStr)));
604
+ const groupedItems = new Set(groups.flatMap((group) => group.itemIds.map(idStr)));
605
+ return invoice.items.filter((item) => isDigitalServiceItem(item) ||
606
+ digitalGroupItems.has(idStr(item.id)) ||
607
+ !groupedItems.has(idStr(item.id)));
550
608
  }
551
609
 
552
610
  const STEP_ORDER = ['cart', 'shipping', 'payment', 'result'];
611
+ const ADDRESS_AUTOSUBMIT_DEBOUNCE_MS = 350;
553
612
  function initialFlags() {
554
613
  return {
555
614
  bootstrapping: false,
@@ -596,6 +655,7 @@ function createCheckoutEngine(options) {
596
655
  let effectExecutor = noopEffectExecutor;
597
656
  let lock = Promise.resolve();
598
657
  let addressRevision = 0;
658
+ let addressAutoSubmitTimer = null;
599
659
  // ---- internal helpers -------------------------------------------------
600
660
  const get = () => store.getState();
601
661
  const set = store.setState;
@@ -616,6 +676,19 @@ function createCheckoutEngine(options) {
616
676
  lock = next.then(() => undefined, () => undefined);
617
677
  return next;
618
678
  }
679
+ function clearAddressAutoSubmit() {
680
+ if (addressAutoSubmitTimer) {
681
+ clearTimeout(addressAutoSubmitTimer);
682
+ addressAutoSubmitTimer = null;
683
+ }
684
+ }
685
+ function scheduleAddressAutoSubmit(delay = ADDRESS_AUTOSUBMIT_DEBOUNCE_MS) {
686
+ clearAddressAutoSubmit();
687
+ addressAutoSubmitTimer = setTimeout(() => {
688
+ addressAutoSubmitTimer = null;
689
+ void withLock(submitAddressInternal);
690
+ }, delay);
691
+ }
619
692
  function recomputeGroups() {
620
693
  const { invoice, applicable } = get();
621
694
  const groups = deriveShippingGroups(invoice, applicable);
@@ -811,13 +884,17 @@ function createCheckoutEngine(options) {
811
884
  }
812
885
  // ---- public actions ---------------------------------------------------
813
886
  function goToStep(step) {
887
+ if (step !== 'shipping') {
888
+ clearAddressAutoSubmit();
889
+ }
814
890
  set({ step, error: null });
815
891
  emit('step_viewed', { step });
816
892
  // When landing on shipping with a complete address but no methods fetched yet,
817
893
  // auto-submit so methods appear without requiring a manual Continue press.
818
894
  if (step === 'shipping') {
819
- const { addressForm, invoice, applicable } = get();
820
- if (!applicable && isAddressComplete(addressForm, invoice?.needsShipping ?? true, get().postalCodeMandatory, get().emailMandatory)) {
895
+ const { addressForm, addressDirty, invoice, applicable, shippingGroups } = get();
896
+ const methodsUnresolved = Boolean(invoice?.needsShipping) && shippingGroups.length === 0;
897
+ if ((addressDirty || !applicable || methodsUnresolved) && isAddressComplete(addressForm, invoice?.needsShipping ?? true, get().postalCodeMandatory, get().emailMandatory)) {
821
898
  void withLock(submitAddressInternal);
822
899
  }
823
900
  }
@@ -920,13 +997,18 @@ function createCheckoutEngine(options) {
920
997
  // the Shipping step becomes visible.
921
998
  const invoice = get().invoice;
922
999
  if (isUsableSavedAddress(invoice?.shippingAddress)) {
923
- set({ addressForm: addressFormFromInvoice(invoice) });
1000
+ const addressForm = reconcileAddressWithRegions(addressFormFromInvoice(invoice), get().regions, savedAddressCityName(invoice?.shippingAddress));
1001
+ set({
1002
+ addressForm,
1003
+ addressDirty: isAddressDirty(addressForm, invoice)
1004
+ });
924
1005
  }
925
1006
  goToStep('shipping');
926
1007
  }
927
1008
  return;
928
1009
  }
929
1010
  if (state.step === 'shipping') {
1011
+ clearAddressAutoSubmit();
930
1012
  // Phase 1: address not saved yet (or changed) → save it and reveal the
931
1013
  // shipping methods, staying on the shipping step.
932
1014
  const needsSave = state.addressDirty || !state.applicable;
@@ -996,6 +1078,7 @@ function createCheckoutEngine(options) {
996
1078
  setAddressField(key, value) {
997
1079
  const before = get().addressForm;
998
1080
  const changed = before[key] !== value || (key === 'regionId' && before.cityId !== null);
1081
+ const destinationChanged = changed && (key === 'regionId' || key === 'cityId');
999
1082
  if (changed) {
1000
1083
  addressRevision += 1;
1001
1084
  }
@@ -1009,22 +1092,28 @@ function createCheckoutEngine(options) {
1009
1092
  return {
1010
1093
  addressForm,
1011
1094
  addressDirty,
1012
- // Rates are derived from the address snapshot. Never keep showing or
1013
- // accepting rates fetched for a different form value.
1014
- ...(addressDirty ? { applicable: null, shippingGroups: [] } : {})
1095
+ // Shipping rates depend on region/city. Contact and street edits must
1096
+ // not discard valid methods for the same destination.
1097
+ ...(destinationChanged ? { applicable: null, shippingGroups: [] } : {})
1015
1098
  };
1016
1099
  });
1017
- // City selection is a discrete destination change, so refresh rates
1018
- // automatically. Text inputs still wait for Save/Continue to avoid an
1019
- // address creation and shipping request on every keystroke.
1020
- if (changed && key === 'cityId' && value != null && get().step === 'shipping') {
1100
+ // Browser autofill often populates the selects before the final required
1101
+ // text field. Once the form becomes complete, debounce text changes and
1102
+ // submit automatically; a discrete city selection can submit immediately.
1103
+ if (changed && get().step === 'shipping') {
1021
1104
  const state = get();
1022
- if (isAddressComplete(state.addressForm, state.invoice?.needsShipping ?? true, state.postalCodeMandatory, state.emailMandatory)) {
1023
- void withLock(submitAddressInternal);
1105
+ const methodsUnresolved = !state.applicable
1106
+ || (Boolean(state.invoice?.needsShipping) && state.shippingGroups.length === 0);
1107
+ if (isAddressComplete(state.addressForm, state.invoice?.needsShipping ?? true, state.postalCodeMandatory, state.emailMandatory) && (destinationChanged || methodsUnresolved)) {
1108
+ scheduleAddressAutoSubmit(key === 'cityId' ? 0 : undefined);
1109
+ }
1110
+ else if (!isAddressComplete(state.addressForm, state.invoice?.needsShipping ?? true, state.postalCodeMandatory, state.emailMandatory)) {
1111
+ clearAddressAutoSubmit();
1024
1112
  }
1025
1113
  }
1026
1114
  },
1027
1115
  async submitAddress() {
1116
+ clearAddressAutoSubmit();
1028
1117
  return withLock(submitAddressInternal);
1029
1118
  },
1030
1119
  async selectShippingRate(groupKey, rateId) {
@@ -1146,6 +1235,8 @@ function createCheckoutEngine(options) {
1146
1235
  const state = get();
1147
1236
  const submittedAddressRevision = addressRevision;
1148
1237
  const needsShipping = state.invoice?.needsShipping ?? true;
1238
+ const shouldReloadShipping = needsShipping
1239
+ && (!state.applicable || state.shippingGroups.length === 0);
1149
1240
  const abandonStaleSubmission = (invoice) => {
1150
1241
  set({
1151
1242
  ...(invoice ? { invoice } : {}),
@@ -1190,7 +1281,15 @@ function createCheckoutEngine(options) {
1190
1281
  }
1191
1282
  set({ invoice: linkRes.data, addressDirty: false });
1192
1283
  if (needsShipping) {
1193
- const ok = await loadApplicableShipping(submittedAddressRevision);
1284
+ let ok = true;
1285
+ if (shouldReloadShipping) {
1286
+ ok = await loadApplicableShipping(submittedAddressRevision);
1287
+ }
1288
+ else if (recomputeGroups().length === 0) {
1289
+ // Invoice item IDs can change when the new address snapshot is linked.
1290
+ // Reload only if the existing applicable data can no longer form groups.
1291
+ ok = await loadApplicableShipping(submittedAddressRevision);
1292
+ }
1194
1293
  if (ok) {
1195
1294
  await assignCurrentShipping();
1196
1295
  if (addressRevision !== submittedAddressRevision) {
@@ -1217,6 +1316,7 @@ function createCheckoutEngine(options) {
1217
1316
  effectExecutor = executor;
1218
1317
  },
1219
1318
  destroy() {
1319
+ clearAddressAutoSubmit();
1220
1320
  effectExecutor = noopEffectExecutor;
1221
1321
  }
1222
1322
  };
@@ -1429,7 +1529,8 @@ const fa = {
1429
1529
  shippingMethod: 'روش ارسال',
1430
1530
  shippingMethods: 'روش‌های ارسال',
1431
1531
  shippingMethodsHint: 'پس از ثبت نشانی، روش‌های قابل استفاده نمایش داده می‌شوند.',
1432
- digitalNoShipping: 'محصول دیجیتال بدون نیاز به ارسال',
1532
+ digitalNoShipping: 'محصولات دیجیتال و خدمات',
1533
+ digitalNoShippingHint: 'این موارد به ارسال فیزیکی نیاز ندارند.',
1433
1534
  errorRequired: 'این فیلد اجباری است',
1434
1535
  errorMobilePhone: 'شماره موبایل معتبر وارد کنید (مثلاً ۰۹۱۲۳۴۵۶۷۸۹)',
1435
1536
  errorEmail: 'آدرس ایمیل معتبر نیست',
@@ -1509,7 +1610,8 @@ const en = {
1509
1610
  shippingMethod: 'Shipping method',
1510
1611
  shippingMethods: 'Shipping methods',
1511
1612
  shippingMethodsHint: 'Available delivery options will appear after you save this address.',
1512
- digitalNoShipping: 'Digital product no shipping required',
1613
+ digitalNoShipping: 'Digital products and services',
1614
+ digitalNoShippingHint: 'These items do not require physical delivery.',
1513
1615
  errorRequired: 'This field is required',
1514
1616
  errorMobilePhone: 'Enter a valid mobile number (e.g. 09123456789)',
1515
1617
  errorEmail: 'Enter a valid email address',
@@ -1604,6 +1706,7 @@ exports.formatPrice = formatPrice;
1604
1706
  exports.fromSdkError = fromSdkError;
1605
1707
  exports.isAddressComplete = isAddressComplete;
1606
1708
  exports.isAddressDirty = isAddressDirty;
1709
+ exports.isDigitalServiceGroup = isDigitalServiceGroup;
1607
1710
  exports.isShippingComplete = isShippingComplete;
1608
1711
  exports.isValidIranianMobile = isValidIranianMobile;
1609
1712
  exports.isValidIranianPhone = isValidIranianPhone;
@@ -1616,7 +1719,8 @@ exports.paymentMethodInfo = paymentMethodInfo;
1616
1719
  exports.paymentMethodLabel = paymentMethodLabel;
1617
1720
  exports.selectDigitalItems = selectDigitalItems;
1618
1721
  exports.selectSummary = selectSummary;
1722
+ exports.sortCartItemsNewestFirst = sortCartItemsNewestFirst;
1619
1723
  exports.strings = strings;
1620
1724
  exports.toEnglishDigits = toEnglishDigits;
1621
1725
  exports.toPersianDigits = toPersianDigits;
1622
- //# sourceMappingURL=labels-CWfNSAYm.cjs.map
1726
+ //# sourceMappingURL=labels-DJ_RnkCQ.cjs.map