@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.
@@ -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;
@@ -971,10 +1053,6 @@ function createCheckoutEngine(options) {
971
1053
  const res = await binding.client.cart.updateItem(cartProductId, variantId, quantity);
972
1054
  if (res.data) {
973
1055
  set({ cart: res.data });
974
- // Optimistically update summary from cart data, then confirm with server.
975
- const inv = get().invoice;
976
- if (inv)
977
- set({ invoice: applyCartToInvoice(inv, res.data) });
978
1056
  await refreshInvoice();
979
1057
  }
980
1058
  else if (res.error) {
@@ -989,9 +1067,6 @@ function createCheckoutEngine(options) {
989
1067
  const res = await binding.client.cart.removeItem(cartProductId, variantId);
990
1068
  if (res.data) {
991
1069
  set({ cart: res.data });
992
- const inv = get().invoice;
993
- if (inv)
994
- set({ invoice: applyCartToInvoice(inv, res.data) });
995
1070
  await refreshInvoice();
996
1071
  }
997
1072
  else if (res.error) {
@@ -1003,6 +1078,7 @@ function createCheckoutEngine(options) {
1003
1078
  setAddressField(key, value) {
1004
1079
  const before = get().addressForm;
1005
1080
  const changed = before[key] !== value || (key === 'regionId' && before.cityId !== null);
1081
+ const destinationChanged = changed && (key === 'regionId' || key === 'cityId');
1006
1082
  if (changed) {
1007
1083
  addressRevision += 1;
1008
1084
  }
@@ -1016,22 +1092,28 @@ function createCheckoutEngine(options) {
1016
1092
  return {
1017
1093
  addressForm,
1018
1094
  addressDirty,
1019
- // Rates are derived from the address snapshot. Never keep showing or
1020
- // accepting rates fetched for a different form value.
1021
- ...(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: [] } : {})
1022
1098
  };
1023
1099
  });
1024
- // City selection is a discrete destination change, so refresh rates
1025
- // automatically. Text inputs still wait for Save/Continue to avoid an
1026
- // address creation and shipping request on every keystroke.
1027
- 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') {
1028
1104
  const state = get();
1029
- if (isAddressComplete(state.addressForm, state.invoice?.needsShipping ?? true, state.postalCodeMandatory, state.emailMandatory)) {
1030
- 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();
1031
1112
  }
1032
1113
  }
1033
1114
  },
1034
1115
  async submitAddress() {
1116
+ clearAddressAutoSubmit();
1035
1117
  return withLock(submitAddressInternal);
1036
1118
  },
1037
1119
  async selectShippingRate(groupKey, rateId) {
@@ -1153,6 +1235,8 @@ function createCheckoutEngine(options) {
1153
1235
  const state = get();
1154
1236
  const submittedAddressRevision = addressRevision;
1155
1237
  const needsShipping = state.invoice?.needsShipping ?? true;
1238
+ const shouldReloadShipping = needsShipping
1239
+ && (!state.applicable || state.shippingGroups.length === 0);
1156
1240
  const abandonStaleSubmission = (invoice) => {
1157
1241
  set({
1158
1242
  ...(invoice ? { invoice } : {}),
@@ -1197,7 +1281,15 @@ function createCheckoutEngine(options) {
1197
1281
  }
1198
1282
  set({ invoice: linkRes.data, addressDirty: false });
1199
1283
  if (needsShipping) {
1200
- 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
+ }
1201
1293
  if (ok) {
1202
1294
  await assignCurrentShipping();
1203
1295
  if (addressRevision !== submittedAddressRevision) {
@@ -1224,6 +1316,7 @@ function createCheckoutEngine(options) {
1224
1316
  effectExecutor = executor;
1225
1317
  },
1226
1318
  destroy() {
1319
+ clearAddressAutoSubmit();
1227
1320
  effectExecutor = noopEffectExecutor;
1228
1321
  }
1229
1322
  };
@@ -1276,23 +1369,6 @@ function toAddressInput(form) {
1276
1369
  description: form.description.trim() || undefined
1277
1370
  };
1278
1371
  }
1279
- /**
1280
- * Immediately derive updated invoice item quantities and totals from a fresh
1281
- * cart response so the summary panel doesn't wait for the invoice refresh.
1282
- */
1283
- function applyCartToInvoice(invoice, cart) {
1284
- const byVariant = new Map(cart.items.map((i) => [i.productVariantId, i]));
1285
- const newItems = invoice.items
1286
- .filter((item) => byVariant.has(item.productVariantId))
1287
- .map((item) => {
1288
- const ci = byVariant.get(item.productVariantId);
1289
- return { ...item, quantity: ci.quantity, lineTotal: ci.lineTotal };
1290
- });
1291
- const subtotal = newItems.reduce((s, i) => s + i.lineTotal, 0);
1292
- const discount = (invoice.itemsDiscount || 0) + (invoice.discountTotal || 0) + (invoice.couponTotal || 0);
1293
- const finalTotal = Math.max(0, subtotal - discount + (invoice.shippingTotal || 0) + (invoice.vat || 0) - (invoice.creditTotal || 0));
1294
- return { ...invoice, items: newItems, itemsTotalRawPrice: subtotal, netTotal: subtotal, finalTotal };
1295
- }
1296
1372
  // Map raw gateway-return query params into the typed payment-step input.
1297
1373
  // Only the string-valued callback fields are forwarded; `trackingData` /
1298
1374
  // `payload` are parsed from JSON when the gateway sends them encoded.
@@ -1453,7 +1529,8 @@ const fa = {
1453
1529
  shippingMethod: 'روش ارسال',
1454
1530
  shippingMethods: 'روش‌های ارسال',
1455
1531
  shippingMethodsHint: 'پس از ثبت نشانی، روش‌های قابل استفاده نمایش داده می‌شوند.',
1456
- digitalNoShipping: 'محصول دیجیتال بدون نیاز به ارسال',
1532
+ digitalNoShipping: 'محصولات دیجیتال و خدمات',
1533
+ digitalNoShippingHint: 'این موارد به ارسال فیزیکی نیاز ندارند.',
1457
1534
  errorRequired: 'این فیلد اجباری است',
1458
1535
  errorMobilePhone: 'شماره موبایل معتبر وارد کنید (مثلاً ۰۹۱۲۳۴۵۶۷۸۹)',
1459
1536
  errorEmail: 'آدرس ایمیل معتبر نیست',
@@ -1533,7 +1610,8 @@ const en = {
1533
1610
  shippingMethod: 'Shipping method',
1534
1611
  shippingMethods: 'Shipping methods',
1535
1612
  shippingMethodsHint: 'Available delivery options will appear after you save this address.',
1536
- digitalNoShipping: 'Digital product no shipping required',
1613
+ digitalNoShipping: 'Digital products and services',
1614
+ digitalNoShippingHint: 'These items do not require physical delivery.',
1537
1615
  errorRequired: 'This field is required',
1538
1616
  errorMobilePhone: 'Enter a valid mobile number (e.g. 09123456789)',
1539
1617
  errorEmail: 'Enter a valid email address',
@@ -1628,6 +1706,7 @@ exports.formatPrice = formatPrice;
1628
1706
  exports.fromSdkError = fromSdkError;
1629
1707
  exports.isAddressComplete = isAddressComplete;
1630
1708
  exports.isAddressDirty = isAddressDirty;
1709
+ exports.isDigitalServiceGroup = isDigitalServiceGroup;
1631
1710
  exports.isShippingComplete = isShippingComplete;
1632
1711
  exports.isValidIranianMobile = isValidIranianMobile;
1633
1712
  exports.isValidIranianPhone = isValidIranianPhone;
@@ -1640,7 +1719,8 @@ exports.paymentMethodInfo = paymentMethodInfo;
1640
1719
  exports.paymentMethodLabel = paymentMethodLabel;
1641
1720
  exports.selectDigitalItems = selectDigitalItems;
1642
1721
  exports.selectSummary = selectSummary;
1722
+ exports.sortCartItemsNewestFirst = sortCartItemsNewestFirst;
1643
1723
  exports.strings = strings;
1644
1724
  exports.toEnglishDigits = toEnglishDigits;
1645
1725
  exports.toPersianDigits = toPersianDigits;
1646
- //# sourceMappingURL=labels-BlMZkOsV.cjs.map
1726
+ //# sourceMappingURL=labels-DJ_RnkCQ.cjs.map