@mohasinac/appkit 4.61.0 → 4.62.1

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.
Files changed (281) hide show
  1. package/dist/.build-stamp.json +3 -3
  2. package/dist/_internal/client/features/lottery/LotteryAdminEditView.js +19 -3
  3. package/dist/_internal/client/features/maintenance/views/AnalysisRunnerView.js +1 -1
  4. package/dist/_internal/client/features/maintenance/views/CloudLogsListView.js +1 -1
  5. package/dist/_internal/server/features/checkout/actions.d.ts +80 -0
  6. package/dist/_internal/server/features/checkout/actions.js +542 -26
  7. package/dist/_internal/server/features/invoicing/allocate.d.ts +63 -0
  8. package/dist/_internal/server/features/invoicing/allocate.js +162 -0
  9. package/dist/_internal/server/features/invoicing/credit-note.d.ts +57 -0
  10. package/dist/_internal/server/features/invoicing/credit-note.js +126 -0
  11. package/dist/_internal/server/features/invoicing/gstr1.d.ts +74 -0
  12. package/dist/_internal/server/features/invoicing/gstr1.js +251 -0
  13. package/dist/_internal/server/features/invoicing/issue.d.ts +96 -0
  14. package/dist/_internal/server/features/invoicing/issue.js +202 -0
  15. package/dist/_internal/server/features/orders/actions.d.ts +8 -1
  16. package/dist/_internal/server/features/orders/actions.js +81 -8
  17. package/dist/_internal/server/features/orders/adapters.js +33 -0
  18. package/dist/_internal/server/features/orders/cancellation-refund.d.ts +15 -9
  19. package/dist/_internal/server/features/orders/cancellation-refund.js +15 -9
  20. package/dist/_internal/server/features/points/award.js +46 -4
  21. package/dist/_internal/server/features/refunds/actions.js +26 -0
  22. package/dist/_internal/server/features/shipping/courier-block.d.ts +69 -0
  23. package/dist/_internal/server/features/shipping/courier-block.js +239 -0
  24. package/dist/_internal/server/features/site-settings/adapters.d.ts +1 -1
  25. package/dist/_internal/server/features/site-settings/adapters.js +15 -0
  26. package/dist/_internal/server/features/stores/adapters.d.ts +2 -2
  27. package/dist/_internal/server/features/stores/adapters.js +51 -0
  28. package/dist/_internal/server/functions/scheduled.js +38 -1
  29. package/dist/_internal/server/jobs/core/autoPayoutEligibility.js +9 -2
  30. package/dist/_internal/server/jobs/core/dailyStatusDigest.d.ts +4 -0
  31. package/dist/_internal/server/jobs/core/dailyStatusDigest.js +19 -2
  32. package/dist/_internal/server/jobs/core/index.d.ts +2 -1
  33. package/dist/_internal/server/jobs/core/index.js +2 -1
  34. package/dist/_internal/server/jobs/core/onJobCreated.d.ts +30 -7
  35. package/dist/_internal/server/jobs/core/onJobCreated.js +78 -45
  36. package/dist/_internal/server/jobs/core/onOrderStatusChange.d.ts +7 -0
  37. package/dist/_internal/server/jobs/core/onOrderStatusChange.js +62 -0
  38. package/dist/_internal/server/jobs/core/paymentReconcile.js +87 -97
  39. package/dist/_internal/server/jobs/core/paymentWindowTimeout.js +33 -0
  40. package/dist/_internal/server/jobs/core/runJobOnce.d.ts +48 -0
  41. package/dist/_internal/server/jobs/core/runJobOnce.js +195 -0
  42. package/dist/_internal/server/jobs/core/weeklyPayoutEligibility.js +10 -2
  43. package/dist/_internal/server/jobs/handlers/onOrderStatusChange.js +17 -0
  44. package/dist/_internal/server/notifications/channel-health.d.ts +26 -3
  45. package/dist/_internal/shared/actions/action-registry.js +177 -9
  46. package/dist/_internal/shared/checkout/coupon-discount.d.ts +43 -1
  47. package/dist/_internal/shared/checkout/coupon-discount.js +26 -1
  48. package/dist/_internal/shared/checkout/order-math.d.ts +145 -0
  49. package/dist/_internal/shared/checkout/order-math.js +173 -0
  50. package/dist/_internal/shared/checkout/refundable-base.d.ts +25 -0
  51. package/dist/_internal/shared/checkout/refundable-base.js +13 -1
  52. package/dist/_internal/shared/checkout/split.d.ts +87 -0
  53. package/dist/_internal/shared/checkout/split.js +100 -0
  54. package/dist/_internal/shared/content/disclaimers.d.ts +88 -0
  55. package/dist/_internal/shared/content/disclaimers.js +217 -0
  56. package/dist/_internal/shared/content/published-values.d.ts +88 -0
  57. package/dist/_internal/shared/content/published-values.js +198 -0
  58. package/dist/_internal/shared/features/admin/integrations.d.ts +109 -0
  59. package/dist/_internal/shared/features/admin/integrations.js +253 -0
  60. package/dist/_internal/shared/features/orders/dispute-reasons.d.ts +113 -0
  61. package/dist/_internal/shared/features/orders/dispute-reasons.js +181 -0
  62. package/dist/_internal/shared/features/orders/refund-copy.d.ts +1 -1
  63. package/dist/_internal/shared/features/orders/refund-copy.js +18 -1
  64. package/dist/_internal/shared/features/points/accepts.d.ts +36 -0
  65. package/dist/_internal/shared/features/points/accepts.js +43 -0
  66. package/dist/_internal/shared/features/shipping/shared-purchase.d.ts +91 -0
  67. package/dist/_internal/shared/features/shipping/shared-purchase.js +108 -0
  68. package/dist/_internal/shared/features/site-settings/types.d.ts +11 -0
  69. package/dist/_internal/shared/fees/calculator.d.ts +33 -1
  70. package/dist/_internal/shared/fees/calculator.js +32 -2
  71. package/dist/_internal/shared/media/limits.d.ts +10 -0
  72. package/dist/_internal/shared/media/limits.js +10 -0
  73. package/dist/_internal/shared/types/action-result.js +15 -1
  74. package/dist/client/api/surface-error.js +16 -2
  75. package/dist/client/api/useApiMutation.js +17 -1
  76. package/dist/client.d.ts +12 -1
  77. package/dist/client.js +31 -1
  78. package/dist/constants/api-endpoints.d.ts +45 -0
  79. package/dist/constants/api-endpoints.js +15 -0
  80. package/dist/constants/field-names.d.ts +18 -0
  81. package/dist/constants/field-names.js +28 -0
  82. package/dist/contracts/index.d.ts +1 -1
  83. package/dist/contracts/shipping.d.ts +45 -0
  84. package/dist/contracts/shipping.js +15 -0
  85. package/dist/core/server-action.js +19 -3
  86. package/dist/errors/error-mapping.js +26 -1
  87. package/dist/features/about/components/AboutView.js +1 -1
  88. package/dist/features/account/components/BuyerAuctionsGuideView.js +4 -1
  89. package/dist/features/account/components/UserOffersPanel.js +5 -3
  90. package/dist/features/account/components/UserOrdersView.js +19 -33
  91. package/dist/features/admin/components/AdminActionIndexView.js +100 -5
  92. package/dist/features/admin/components/AdminAddressClustersView.js +21 -2
  93. package/dist/features/admin/components/AdminBlogEditorView.js +8 -8
  94. package/dist/features/admin/components/AdminCarouselEditorView.js +1 -1
  95. package/dist/features/admin/components/AdminCarouselView.js +96 -4
  96. package/dist/features/admin/components/AdminCouponEditorView.js +17 -0
  97. package/dist/features/admin/components/AdminFulfillmentView.js +3 -2
  98. package/dist/features/admin/components/AdminInvoicingView.d.ts +2 -0
  99. package/dist/features/admin/components/AdminInvoicingView.js +65 -0
  100. package/dist/features/admin/components/AdminMediaView.js +36 -3
  101. package/dist/features/admin/components/AdminNavigationView.js +4 -1
  102. package/dist/features/admin/components/AdminOffersView.js +2 -1
  103. package/dist/features/admin/components/AdminOrderEditorView.d.ts +18 -1
  104. package/dist/features/admin/components/AdminOrderEditorView.js +11 -2
  105. package/dist/features/admin/components/AdminOrdersGuideView.js +23 -2
  106. package/dist/features/admin/components/AdminOrdersView.js +67 -5
  107. package/dist/features/admin/components/AdminPaymentClustersView.js +19 -2
  108. package/dist/features/admin/components/AdminProductEditorView.js +2 -2
  109. package/dist/features/admin/components/AdminSectionsView.d.ts +10 -0
  110. package/dist/features/admin/components/AdminSectionsView.js +486 -224
  111. package/dist/features/admin/components/OwnerQuickActions.js +24 -2
  112. package/dist/features/admin/components/ReferenceRecordEditor.js +1 -1
  113. package/dist/features/admin/constants/filter-tabs.d.ts +6 -0
  114. package/dist/features/admin/constants/filter-tabs.js +6 -0
  115. package/dist/features/admin/constants/site-settings-tabs.d.ts +1 -1
  116. package/dist/features/admin/hooks/useAdminListingData.js +16 -0
  117. package/dist/features/admin/hooks/useOptimisticRowFlag.d.ts +33 -0
  118. package/dist/features/admin/hooks/useOptimisticRowFlag.js +87 -0
  119. package/dist/features/admin/repository/notification.repository.d.ts +15 -0
  120. package/dist/features/admin/repository/notification.repository.js +32 -0
  121. package/dist/features/admin/schemas/action-index-form.d.ts +34 -0
  122. package/dist/features/admin/schemas/action-index-form.js +115 -0
  123. package/dist/features/admin/schemas/admin-user-form.d.ts +2 -2
  124. package/dist/features/admin/schemas/firestore.d.ts +30 -0
  125. package/dist/features/admin/schemas/firestore.js +5 -0
  126. package/dist/features/audit-log/schemas/firestore.d.ts +30 -0
  127. package/dist/features/audit-log/schemas/firestore.js +15 -0
  128. package/dist/features/auth/components/RegisterForm.js +2 -1
  129. package/dist/features/auth/permissions/constants.js +13 -0
  130. package/dist/features/cart/components/CartPriceBreakdown.d.ts +13 -0
  131. package/dist/features/cart/components/CartPriceBreakdown.js +6 -2
  132. package/dist/features/cart/components/PointsTenderPicker.d.ts +61 -0
  133. package/dist/features/cart/components/PointsTenderPicker.js +15 -0
  134. package/dist/features/cart/components/StoreAddonsPicker.js +2 -1
  135. package/dist/features/cart/components/index.d.ts +2 -0
  136. package/dist/features/cart/components/index.js +1 -0
  137. package/dist/features/cart/repository/cart.repository.d.ts +19 -0
  138. package/dist/features/cart/repository/cart.repository.js +27 -0
  139. package/dist/features/cart/schemas/firestore.d.ts +92 -0
  140. package/dist/features/catalogue/components/CatalogueItemEditorView.js +3 -3
  141. package/dist/features/contact/email.d.ts +27 -10
  142. package/dist/features/contact/email.js +20 -3
  143. package/dist/features/dispute-reasons/repository/dispute-reasons.repository.d.ts +38 -0
  144. package/dist/features/dispute-reasons/repository/dispute-reasons.repository.js +80 -0
  145. package/dist/features/dispute-reasons/schemas/firestore.d.ts +64 -0
  146. package/dist/features/dispute-reasons/schemas/firestore.js +40 -0
  147. package/dist/features/events/components/AdminEventEditorView.js +8 -6
  148. package/dist/features/faq/components/FAQAccordion.d.ts +13 -1
  149. package/dist/features/faq/components/FAQAccordion.js +29 -4
  150. package/dist/features/invoicing/schemas/firestore.d.ts +112 -0
  151. package/dist/features/invoicing/schemas/firestore.js +45 -0
  152. package/dist/features/invoicing/utils/financial-year.d.ts +43 -0
  153. package/dist/features/invoicing/utils/financial-year.js +53 -0
  154. package/dist/features/jobs/actions/enqueue-job.js +18 -1
  155. package/dist/features/jobs/repository/jobs.repository.d.ts +62 -1
  156. package/dist/features/jobs/repository/jobs.repository.js +84 -1
  157. package/dist/features/jobs/schemas/firestore.d.ts +80 -0
  158. package/dist/features/jobs/schemas/firestore.js +37 -0
  159. package/dist/features/layout/hooks/useFormBottomActions.js +20 -1
  160. package/dist/features/lottery/schemas/zod.d.ts +6 -6
  161. package/dist/features/media/MediaImage.js +22 -1
  162. package/dist/features/media/upload/ImageUpload.js +1 -1
  163. package/dist/features/obligations/constants/policies.js +24 -1
  164. package/dist/features/orders/components/EmiInstallmentsPanel.js +25 -7
  165. package/dist/features/orders/components/OrderPaymentSummary.js +10 -1
  166. package/dist/features/orders/components/OrderProvenanceCard.d.ts +8 -0
  167. package/dist/features/orders/components/OrderProvenanceCard.js +185 -0
  168. package/dist/features/orders/components/RaiseDisputeForm.d.ts +14 -0
  169. package/dist/features/orders/components/RaiseDisputeForm.js +111 -0
  170. package/dist/features/orders/components/RefundRequestView.js +22 -4
  171. package/dist/features/orders/components/index.d.ts +4 -0
  172. package/dist/features/orders/components/index.js +2 -0
  173. package/dist/features/orders/constants/order-lane-tabs.d.ts +69 -0
  174. package/dist/features/orders/constants/order-lane-tabs.js +88 -0
  175. package/dist/features/orders/repository/orders.repository.d.ts +57 -0
  176. package/dist/features/orders/repository/orders.repository.js +145 -0
  177. package/dist/features/orders/schemas/firestore.d.ts +196 -1
  178. package/dist/features/orders/schemas/index.d.ts +60 -12
  179. package/dist/features/orders/schemas/index.js +12 -0
  180. package/dist/features/orders/types/index.d.ts +26 -0
  181. package/dist/features/orders/utils/courier-block.d.ts +25 -0
  182. package/dist/features/orders/utils/courier-block.js +23 -0
  183. package/dist/features/payment-cases/schemas/firestore.d.ts +26 -1
  184. package/dist/features/payment-cases/schemas/firestore.js +1 -0
  185. package/dist/features/products/components/GroupMemberPicker.js +4 -1
  186. package/dist/features/products/components/PrizeDrawDetailPageView.js +2 -2
  187. package/dist/features/products/components/PrizeDrawEntryActions.js +2 -1
  188. package/dist/features/products/components/ProductDetailActions.js +2 -1
  189. package/dist/features/products/components/ProductDetailPageView.d.ts +17 -1
  190. package/dist/features/products/components/ProductDetailPageView.js +2 -2
  191. package/dist/features/products/schemas/product-features.validators.d.ts +10 -10
  192. package/dist/features/promotions/actions/coupon-stacking.d.ts +30 -3
  193. package/dist/features/promotions/actions/coupon-stacking.js +21 -2
  194. package/dist/features/promotions/components/CouponCard.js +13 -0
  195. package/dist/features/promotions/constants/coupon-help.d.ts +2 -2
  196. package/dist/features/promotions/constants/coupon-help.js +9 -1
  197. package/dist/features/promotions/schemas/firestore.d.ts +24 -0
  198. package/dist/features/promotions/schemas/firestore.js +4 -0
  199. package/dist/features/promotions/types/index.d.ts +19 -1
  200. package/dist/features/scams/components/ScamProfileView.js +2 -2
  201. package/dist/features/scams/components/ScamRegistryView.js +2 -2
  202. package/dist/features/search/action-index/tab-entries.js +1 -1
  203. package/dist/features/seller/actions/seller-actions.js +33 -3
  204. package/dist/features/seller/components/FulfillmentView.js +3 -2
  205. package/dist/features/seller/components/SellerAddressesView.js +32 -4
  206. package/dist/features/seller/components/SellerAnalyticsAlertsView.js +62 -3
  207. package/dist/features/seller/components/SellerBundlesView.js +53 -14
  208. package/dist/features/seller/components/SellerGroupedListingsView.js +23 -3
  209. package/dist/features/seller/components/SellerListingTypeView.js +56 -10
  210. package/dist/features/seller/components/SellerOrdersView.js +129 -27
  211. package/dist/features/seller/components/SellerPayoutMethodsView.js +33 -1
  212. package/dist/features/seller/components/SellerPayoutSettingsView.js +48 -9
  213. package/dist/features/seller/components/SellerProductsView.js +5 -1
  214. package/dist/features/seller/components/SellerShippingConfigsView.js +33 -1
  215. package/dist/features/seller/components/SellerShippingView.js +2 -1
  216. package/dist/features/seller/components/SellerStoreCategoriesView.js +33 -1
  217. package/dist/features/seller/components/SellerSupportView.js +69 -2
  218. package/dist/features/seller/hooks/useSellerListingData.js +25 -0
  219. package/dist/features/shell/SectionForm.js +20 -16
  220. package/dist/features/shell/build-sections.js +14 -1
  221. package/dist/features/shipments/components/AdminShipmentLotItemsView.js +54 -1
  222. package/dist/features/shipments/components/AdminShipmentProjectionsView.js +3 -2
  223. package/dist/features/shipments/schemas/validation.d.ts +4 -4
  224. package/dist/features/shipping-boxes/repository/shipping-boxes.repository.d.ts +43 -0
  225. package/dist/features/shipping-boxes/repository/shipping-boxes.repository.js +90 -0
  226. package/dist/features/shipping-boxes/schemas/firestore.d.ts +82 -0
  227. package/dist/features/shipping-boxes/schemas/firestore.js +53 -0
  228. package/dist/features/sticker-trace/components/StickerCommitPanel.js +2 -1
  229. package/dist/features/sticker-trace/components/StickerPrintPanel.js +2 -1
  230. package/dist/features/sticker-trace/components/StickerRetryPanel.js +24 -4
  231. package/dist/features/sticker-trace/components/StickerTraceStudio.js +2 -1
  232. package/dist/features/store-extensions/schemas/report-create-form.d.ts +2 -2
  233. package/dist/features/store-extensions/schemas/shipping-config-form.d.ts +2 -2
  234. package/dist/features/stores/components/StoreOrdersGuideView.js +6 -1
  235. package/dist/features/stores/schemas/firestore.d.ts +71 -0
  236. package/dist/features/stores/types/index.d.ts +19 -0
  237. package/dist/features/support/schemas/index.d.ts +6 -6
  238. package/dist/features/tester/components/AdminTesterFeedbackIssuesView.js +3 -2
  239. package/dist/features/wishlist/hooks/useWishlistWithGuest.d.ts +1 -1
  240. package/dist/index.d.ts +11 -2
  241. package/dist/index.js +17 -2
  242. package/dist/next/routing/route-map.d.ts +24 -0
  243. package/dist/next/routing/route-map.js +19 -0
  244. package/dist/providers/shipping-shiprocket/index.d.ts +27 -1
  245. package/dist/providers/shipping-shiprocket/index.js +84 -1
  246. package/dist/schemas/registry.d.ts +51 -22
  247. package/dist/schemas/sieve.d.ts +5 -5
  248. package/dist/server-entry.d.ts +2 -2
  249. package/dist/server-entry.js +8 -1
  250. package/dist/server.d.ts +11 -2
  251. package/dist/server.js +40 -2
  252. package/dist/styles.css +64 -80
  253. package/dist/tailwind-utilities.css +1 -1
  254. package/dist/tokens/themes/default-dark.js +1 -0
  255. package/dist/tokens/themes/default-light.js +1 -0
  256. package/dist/tokens/themes/required.d.ts +1 -1
  257. package/dist/tokens/themes/required.js +1 -0
  258. package/dist/tokens/tokens.css +15 -0
  259. package/dist/ui/components/Checkbox.js +10 -2
  260. package/dist/ui/components/DateInput.js +8 -1
  261. package/dist/ui/components/Disclaimer.d.ts +19 -0
  262. package/dist/ui/components/Disclaimer.js +74 -0
  263. package/dist/ui/components/Input.js +22 -2
  264. package/dist/ui/components/PaginatedSelect.d.ts +8 -19
  265. package/dist/ui/components/PaginatedSelect.js +43 -9
  266. package/dist/ui/components/Radio.js +8 -1
  267. package/dist/ui/components/Select.js +10 -2
  268. package/dist/ui/components/TagInput.d.ts +8 -1
  269. package/dist/ui/components/TagInput.js +4 -3
  270. package/dist/ui/components/Textarea.js +10 -2
  271. package/dist/ui/components/tabs-overflow.js +64 -11
  272. package/dist/ui/forms/FieldCheckbox.js +1 -1
  273. package/dist/ui/forms/FormErrorList.d.ts +0 -16
  274. package/dist/ui/forms/FormErrorList.js +34 -1
  275. package/dist/ui/forms/apply-zod-issues.d.ts +19 -1
  276. package/dist/ui/forms/apply-zod-issues.js +23 -1
  277. package/dist/ui/index.d.ts +2 -0
  278. package/dist/ui/index.js +6 -0
  279. package/dist/ui/rich-text/RichText.style.css +48 -79
  280. package/package.json +1 -1
  281. package/scripts/seed-cli.mjs +1831 -1822
@@ -250,6 +250,32 @@ export interface Order {
250
250
  platformFee?: number;
251
251
  /** COD handling fee, charged only on cash-on-delivery orders. */
252
252
  codHandlingFee?: number;
253
+ /**
254
+ * D46/D47 — points spent on this order, in points (1 point = ₹1).
255
+ *
256
+ * Absent on every order placed before the tender existed, and absent on any
257
+ * order that used none.
258
+ */
259
+ pointsApplied?: number;
260
+ /**
261
+ * What was actually COLLECTED: `total − pointsApplied`.
262
+ *
263
+ * 🛑 Read it as **`amountDue ?? total`**, never `amountDue ?? 0`. Absent means
264
+ * "no points were used, so the whole total was collected"; zero would mean
265
+ * the order was free. The two are a long way apart on a receipt, and the
266
+ * wrong default turns every historical order into a ₹0 one.
267
+ */
268
+ amountDue?: number;
269
+ /**
270
+ * The invoice number from the gapless FY series, e.g. `LIR/26-27/0001`
271
+ * (Phase 7).
272
+ *
273
+ * Absent until the order is paid, and absent on every order placed before
274
+ * the series existed — so the invoice page must render its own fallback
275
+ * rather than printing "undefined" where a statutory field belongs.
276
+ */
277
+ invoiceNumber?: string;
278
+ invoiceIssuedAt?: string;
253
279
  trackingNumber?: string;
254
280
  shippingCarrier?: string;
255
281
  /** Carrier tracking page URL — rendered as an external link that opens in a new tab. No live-tracking API integration; this is just the passthrough URL a seller/admin entered. */
@@ -0,0 +1,25 @@
1
+ /**
2
+ * "Is this order waiting on OUR courier problem?" — one predicate, so the
3
+ * obligation engine, the seller's UI and the admin digest cannot disagree.
4
+ *
5
+ * Deliberately a function over the document rather than a stored boolean: a
6
+ * denormalised `isCourierBlocked` mirror drifts the first time a write path
7
+ * forgets it (Root Cause #42), and the field it derives from is right there.
8
+ */
9
+ import type { OrderDocument } from "../schemas/firestore";
10
+ /** The subset actually read, so callers need not hold a whole order. */
11
+ export interface CourierBlockBearing {
12
+ courierBlock?: OrderDocument["courierBlock"];
13
+ shippingDate?: Date | null;
14
+ }
15
+ /**
16
+ * True while a courier booking we attempted is still unresolved.
17
+ *
18
+ * 🛑 A block is CLEARED by the parcel actually going out, not by anything
19
+ * clearing the field. So a `shippingDate` wins over a stale `courierBlock`:
20
+ * if the seller gave up waiting and posted it themselves, the order is
21
+ * dispatched and the block is history, even if nobody deleted it. Reading the
22
+ * field alone would pause the dispatch clock forever on an order that had in
23
+ * fact shipped.
24
+ */
25
+ export declare function hasOpenCourierBlock(order: CourierBlockBearing): boolean;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * "Is this order waiting on OUR courier problem?" — one predicate, so the
3
+ * obligation engine, the seller's UI and the admin digest cannot disagree.
4
+ *
5
+ * Deliberately a function over the document rather than a stored boolean: a
6
+ * denormalised `isCourierBlocked` mirror drifts the first time a write path
7
+ * forgets it (Root Cause #42), and the field it derives from is right there.
8
+ */
9
+ /**
10
+ * True while a courier booking we attempted is still unresolved.
11
+ *
12
+ * 🛑 A block is CLEARED by the parcel actually going out, not by anything
13
+ * clearing the field. So a `shippingDate` wins over a stale `courierBlock`:
14
+ * if the seller gave up waiting and posted it themselves, the order is
15
+ * dispatched and the block is history, even if nobody deleted it. Reading the
16
+ * field alone would pause the dispatch clock forever on an order that had in
17
+ * fact shipped.
18
+ */
19
+ export function hasOpenCourierBlock(order) {
20
+ if (!order.courierBlock)
21
+ return false;
22
+ return !order.shippingDate;
23
+ }
@@ -107,7 +107,32 @@ export type PaymentCaseKind = "missing_order" | "unprocessed_event" | "status_co
107
107
  * Keyed on the USER, so a persistently broken mirror is one case with a
108
108
  * rising `observedCount` rather than a new row every night.
109
109
  */
110
- | "ledger_drift";
110
+ | "ledger_drift"
111
+ /**
112
+ * We could not book a courier, so one or more PAID orders cannot be
113
+ * dispatched by us.
114
+ *
115
+ * The commonest cause is our own prepaid courier wallet running dry —
116
+ * Shiprocket has no recharge API, so the top-up is irreducibly a human
117
+ * action, which is exactly what a case is for.
118
+ *
119
+ * 🛑 KEYED ON THE WALLET (the provider), NOT ON THE ORDER. Fifty blocked
120
+ * orders are ONE problem with one fix, and the daily email ceiling applies
121
+ * to staff mail too (`send-guard.ts:92`) — so a per-order case would open
122
+ * fifty rows and spend the whole 80/day budget telling an operator the same
123
+ * thing fifty times, leaving them blind to everything else. `openOrObserve`
124
+ * raises `observedCount` instead, which also makes "how many orders are
125
+ * stuck behind this" readable at a glance.
126
+ *
127
+ * 🛑 Its own kind rather than `shipping_unpriced`, which means we could not
128
+ * work out what shipping COSTS. Here the price is known and the money to pay
129
+ * it is not there. The kind chips are what an operator triages by, and this
130
+ * is the fourth time this file has had to say so — the shortcut is always
131
+ * attractive because a plausible kind already exists.
132
+ *
133
+ * `severity: "error"`: a buyer has paid and their parcel is not moving.
134
+ */
135
+ | "courier_unavailable";
111
136
  /**
112
137
  * Identical to `ReportStatus`, deliberately — see the header. `actioned` means
113
138
  * a human did something; `dismissed` means a human decided nothing was wrong.
@@ -81,6 +81,7 @@ const PAYMENT_CASE_KIND_MAP = {
81
81
  protection_claim: true,
82
82
  manual_refund_due: true,
83
83
  ledger_drift: true,
84
+ courier_unavailable: true,
84
85
  };
85
86
  export const PAYMENT_CASE_KINDS = Object.keys(PAYMENT_CASE_KIND_MAP);
86
87
  const PAYMENT_CASE_STATUS_MAP = {
@@ -29,6 +29,7 @@ import { pluginFor } from "../../../_internal/shared/listing-types/_registry.js"
29
29
  import { normalizeListingType } from "../utils/listing-type.js";
30
30
  import { resolveGroupMemberImage } from "../utils/group-member-image.js";
31
31
  import { checkGroupRules, describeGroupRules, EMPTY_GROUP_RULES, } from "../../../_internal/shared/features/products/group-rules.js";
32
+ import { toUserMessage } from "../../../errors/error-display-map.js";
32
33
  const __O = { xAuto: "overflow-x-auto", hidden: "overflow-hidden" };
33
34
  const memberImage = resolveGroupMemberImage;
34
35
  function memberHref(m) {
@@ -112,9 +113,11 @@ export function GroupMemberPicker({ groupId, groupSource, members, rules = EMPTY
112
113
  onAuthRequired?.();
113
114
  return;
114
115
  }
116
+ // `code` is the stable identifier the error envelope carries; `error` is
117
+ // the server's own sentence and is deliberately no longer read here.
115
118
  const json = (await res.json());
116
119
  if (!res.ok || json.success === false) {
117
- setError(json.error ?? "Couldn't add those items to your cart.");
120
+ setError(toUserMessage(json.code, undefined, { fallback: "Couldn't add those items to your cart." }));
118
121
  return;
119
122
  }
120
123
  setQty({});
@@ -11,7 +11,7 @@ import { ROUTES } from "../../../next/routing/route-map.js";
11
11
  import { getDefaultCurrency } from "../../../core/baseline-resolver.js";
12
12
  import { normalizeRichTextHtml } from "../../../utils/string.formatter.js";
13
13
  import { safeDisplayName } from "../../../security/index.js";
14
- import { Container, Div, Heading, Main, RichText, Row, Section, Span, Stack, Text, } from "../../../ui/index.js";
14
+ import { Container, Disclaimer, Div, Heading, Main, RichText, Row, Section, Span, Stack, Text, } from "../../../ui/index.js";
15
15
  import { PreOrderDetailView } from "./PreOrderDetailView.js";
16
16
  import { PrizeDrawBottomActions } from "./PrizeDrawBottomActions.js";
17
17
  import { ProductTabsShell } from "./ProductTabsShell.js";
@@ -133,5 +133,5 @@ export async function PrizeDrawDetailPageView({ id, initialPrizeDraw, currentUse
133
133
  price: pricePerEntry,
134
134
  storeId: typeof p.storeId === "string" ? p.storeId : undefined,
135
135
  storeName: storeName ?? undefined,
136
- } }), _jsxs(Container, { size: "xl", padding: "y-lg", children: [_jsxs(Row, { className: "mb-4", align: "center", justify: "between", gap: "sm", wrap: true, children: [_jsxs("nav", { "aria-label": "Breadcrumb", className: "flex items-center gap-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-xs)] text-[var(--appkit-color-text-muted)] flex-wrap", children: [_jsx(Link, { href: String(ROUTES.HOME), className: "hover:text-primary-600 dark:hover:text-primary-400 transition-colors", children: "Home" }), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.PRIZE_DRAWS), className: "hover:text-primary-600 dark:hover:text-primary-400 transition-colors", children: "Prize Draws" }), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Span, { className: "truncate max-w-[200px]", color: "muted", children: title })] }), _jsx(ShareButton, { title: title })] }), _jsx(PreOrderDetailView, { renderGallery: () => (_jsx(PrizeDrawCollage, { items: items, hideWonState: true })), renderInfo: () => (_jsxs(Stack, { gap: "md", children: [_jsx(OwnerQuickActions, { resource: "PRODUCT", placement: "detail", recordLabel: String(product.title ?? product.id), storeId: product.storeId ? String(product.storeId) : undefined, editHref: String(ROUTES.STORE.PRODUCTS_EDIT(String(product.id))) }), _jsxs(Div, { children: [_jsxs(Row, { gap: "xs", wrap: true, className: "mb-2", children: [_jsx(Span, { size: "xs", weight: "semibold", className: CLS_PRIZE_BADGE, children: "Prize Draw" }), _jsx(Span, { size: "xs", weight: "semibold", className: `inline-block ${statusClass(revealStatus)}`, padding: "pill-sm", rounded: "full", children: statusLabel(revealStatus) }), maxPerUser !== null && (_jsxs(Span, { size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", surface: "subtle", color: "muted", children: ["Limit: ", maxPerUser, " entries per customer"] })), maxPerUser !== null && userEntriesUsed !== null && (_jsxs(Span, { size: "xs", weight: "semibold", className: CLS_PRIZE_BADGE, children: ["You have used ", userEntriesUsed, "/", maxPerUser] }))] }), _jsx(Heading, { level: 1, className: "leading-snug", smSize: "2xl", color: "primary", size: "xl", weight: "bold", children: title })] }), _jsx(Div, { className: `${__P.p4}`, border: "subtle", rounded: "xl", surface: "muted", children: _jsxs(Stack, { gap: "sm", children: [_jsxs(Row, { justify: "between", align: "center", children: [_jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: "Entries" }), _jsxs(Text, { size: "sm", weight: "semibold", children: [current, " / ", max, " (", remaining, " left)"] })] }), revealStart && (_jsxs(Row, { justify: "between", align: "center", children: [_jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: "Reveal window opens" }), _jsx(Text, { size: "sm", weight: "medium", children: revealStart.toLocaleString() })] })), revealEnd && (_jsxs(Row, { justify: "between", align: "center", children: [_jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: "Reveal window closes" }), _jsx(Text, { size: "sm", weight: "medium", children: revealEnd.toLocaleString() })] }))] }) }), descriptionHtml && (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "text-[length:var(--appkit-text-sm)] leading-relaxed text-[var(--appkit-color-text-muted)] line-clamp-4" })), safeSeller && (_jsx(Div, { className: `${__P.p3}`, border: "subtle", rounded: "xl", surface: "muted", children: _jsxs(Row, { justify: "between", align: "center", children: [_jsxs(Div, { children: [_jsx(Text, { className: "text-[10px] tracking-wide mb-0.5", color: "faint", transform: "uppercase", children: "Sold by" }), _jsx(Text, { size: "sm", weight: "semibold", color: "primary", children: safeSeller })] }), storeHref && (_jsx(Link, { href: storeHref, className: "shrink-0 rounded-lg bg-primary/10 dark:bg-primary/20 px-[var(--appkit-space-3)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-xs)] font-semibold text-primary-700 dark:text-primary-300 hover:bg-primary/20 dark:hover:bg-primary/30 transition-colors", children: "Visit Store \u2192" }))] }) }))] })), renderTabs: () => (_jsx(ProductTabsShell, { descriptionContent: descriptionHtml ? (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm sm:prose max-w-none dark:prose-invert", className: "text-[var(--appkit-color-text-muted)]" })) : undefined })), renderBuyBar: () => (_jsx(Div, { padding: "5", id: "prize-draw-buy-bar", border: "subtle", surface: "muted", rounded: "xl", children: _jsx(PrizeDrawEntryActions, { productId: String(product.id), productSlug: slug, title: title, pricePerEntry: pricePerEntry, currency: currency, remainingEntries: remaining, revealStatus: revealStatus, prizeGithubFileUrl: githubUrl }) })), renderRelated: () => (_jsxs(Stack, { gap: "xl", children: [_jsx(PurchaseReviewAffordance, { productId: String(product.id), storeId: product.storeId ? String(product.storeId) : undefined }), _jsx(GroupedListingsCarousel, { groups: groups }), _jsx(RelatedItemsSection, { relatedItems: relatedItems, relatedByBrand: relatedByBrand, relatedByTags: relatedByTags, relatedByStore: relatedByStore, categoryLabel: categoryName, brandLabel: brand, storeLabel: storeName ?? undefined })] })) }), _jsx(PrizeDrawBottomActions, { pricePerEntry: pricePerEntry, currency: currency, closed: revealStatus === "closed" || remaining === 0 })] })] }));
136
+ } }), _jsxs(Container, { size: "xl", padding: "y-lg", children: [_jsxs(Row, { className: "mb-4", align: "center", justify: "between", gap: "sm", wrap: true, children: [_jsxs("nav", { "aria-label": "Breadcrumb", className: "flex items-center gap-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-xs)] text-[var(--appkit-color-text-muted)] flex-wrap", children: [_jsx(Link, { href: String(ROUTES.HOME), className: "hover:text-primary-600 dark:hover:text-primary-400 transition-colors", children: "Home" }), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.PRIZE_DRAWS), className: "hover:text-primary-600 dark:hover:text-primary-400 transition-colors", children: "Prize Draws" }), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Span, { className: "truncate max-w-[200px]", color: "muted", children: title })] }), _jsx(ShareButton, { title: title })] }), _jsx(PreOrderDetailView, { renderGallery: () => (_jsx(PrizeDrawCollage, { items: items, hideWonState: true })), renderInfo: () => (_jsxs(Stack, { gap: "md", children: [_jsx(OwnerQuickActions, { resource: "PRODUCT", placement: "detail", recordLabel: String(product.title ?? product.id), storeId: product.storeId ? String(product.storeId) : undefined, editHref: String(ROUTES.STORE.PRODUCTS_EDIT(String(product.id))) }), _jsxs(Div, { children: [_jsxs(Row, { gap: "xs", wrap: true, className: "mb-2", children: [_jsx(Span, { size: "xs", weight: "semibold", className: CLS_PRIZE_BADGE, children: "Prize Draw" }), _jsx(Span, { size: "xs", weight: "semibold", className: `inline-block ${statusClass(revealStatus)}`, padding: "pill-sm", rounded: "full", children: statusLabel(revealStatus) }), maxPerUser !== null && (_jsxs(Span, { size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", surface: "subtle", color: "muted", children: ["Limit: ", maxPerUser, " entries per customer"] })), maxPerUser !== null && userEntriesUsed !== null && (_jsxs(Span, { size: "xs", weight: "semibold", className: CLS_PRIZE_BADGE, children: ["You have used ", userEntriesUsed, "/", maxPerUser] }))] }), _jsx(Heading, { level: 1, className: "leading-snug", smSize: "2xl", color: "primary", size: "xl", weight: "bold", children: title })] }), _jsx(Div, { className: `${__P.p4}`, border: "subtle", rounded: "xl", surface: "muted", children: _jsxs(Stack, { gap: "sm", children: [_jsxs(Row, { justify: "between", align: "center", children: [_jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: "Entries" }), _jsxs(Text, { size: "sm", weight: "semibold", children: [current, " / ", max, " (", remaining, " left)"] })] }), revealStart && (_jsxs(Row, { justify: "between", align: "center", children: [_jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: "Reveal window opens" }), _jsx(Text, { size: "sm", weight: "medium", children: revealStart.toLocaleString() })] })), revealEnd && (_jsxs(Row, { justify: "between", align: "center", children: [_jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: "Reveal window closes" }), _jsx(Text, { size: "sm", weight: "medium", children: revealEnd.toLocaleString() })] }))] }) }), descriptionHtml && (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "text-[length:var(--appkit-text-sm)] leading-relaxed text-[var(--appkit-color-text-muted)] line-clamp-4" })), safeSeller && (_jsx(Div, { className: `${__P.p3}`, border: "subtle", rounded: "xl", surface: "muted", children: _jsxs(Row, { justify: "between", align: "center", children: [_jsxs(Div, { children: [_jsx(Text, { className: "text-[10px] tracking-wide mb-0.5", color: "faint", transform: "uppercase", children: "Sold by" }), _jsx(Text, { size: "sm", weight: "semibold", color: "primary", children: safeSeller })] }), storeHref && (_jsx(Link, { href: storeHref, className: "shrink-0 rounded-lg bg-primary/10 dark:bg-primary/20 px-[var(--appkit-space-3)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-xs)] font-semibold text-primary-700 dark:text-primary-300 hover:bg-primary/20 dark:hover:bg-primary/30 transition-colors", children: "Visit Store \u2192" }))] }) })), _jsx(Disclaimer, { id: "prize-draw" })] })), renderTabs: () => (_jsx(ProductTabsShell, { descriptionContent: descriptionHtml ? (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm sm:prose max-w-none dark:prose-invert", className: "text-[var(--appkit-color-text-muted)]" })) : undefined })), renderBuyBar: () => (_jsx(Div, { padding: "5", id: "prize-draw-buy-bar", border: "subtle", surface: "muted", rounded: "xl", children: _jsx(PrizeDrawEntryActions, { productId: String(product.id), productSlug: slug, title: title, pricePerEntry: pricePerEntry, currency: currency, remainingEntries: remaining, revealStatus: revealStatus, prizeGithubFileUrl: githubUrl }) })), renderRelated: () => (_jsxs(Stack, { gap: "xl", children: [_jsx(PurchaseReviewAffordance, { productId: String(product.id), storeId: product.storeId ? String(product.storeId) : undefined }), _jsx(GroupedListingsCarousel, { groups: groups }), _jsx(RelatedItemsSection, { relatedItems: relatedItems, relatedByBrand: relatedByBrand, relatedByTags: relatedByTags, relatedByStore: relatedByStore, categoryLabel: categoryName, brandLabel: brand, storeLabel: storeName ?? undefined })] })) }), _jsx(PrizeDrawBottomActions, { pricePerEntry: pricePerEntry, currency: currency, closed: revealStatus === "closed" || remaining === 0 })] })] }));
137
137
  }
@@ -12,6 +12,7 @@ import { NonRefundableConsentModal } from "./NonRefundableConsentModal.js";
12
12
  import { useAuthGate } from "../../../react/hooks/useAuthGate.js";
13
13
  import { ACTION_ID } from "../constants/action-defs.js";
14
14
  import { ACTIONS } from "../../../_internal/shared/actions/action-registry.js";
15
+ import { toUserMessage } from "../../../errors/error-display-map.js";
15
16
  /**
16
17
  * Client buy panel for a prize-draw detail page (SB4-G).
17
18
  *
@@ -81,7 +82,7 @@ export function PrizeDrawEntryActions({ productId, productSlug, title, pricePerE
81
82
  * a correct refusal look like the same silent nothing this flow did
82
83
  * before it was repaired.
83
84
  */
84
- showToast(n.message, "error");
85
+ showToast(toUserMessage(n.code, undefined, { fallback: "We couldn't enter you into that draw. Please try again." }), "error");
85
86
  return;
86
87
  }
87
88
  router.push(String(ROUTES.USER.CART));
@@ -15,6 +15,7 @@ import { useBottomActions } from "../../layout/hooks/useBottomActions.js";
15
15
  import { formatCurrency } from "../../../utils/number.formatter.js";
16
16
  import { useCanSeePrices } from "../../../react/hooks/useCanSeePrices.js";
17
17
  import { normalizeError } from "../../../errors/normalize.js";
18
+ import { toUserMessage } from "../../../errors/error-display-map.js";
18
19
  /**
19
20
  * Wires the three primary product-detail CTAs (Buy Now / Add to Cart / Wishlist)
20
21
  * to the server APIs with guest-fallback. Replaces the previously-unwired
@@ -35,7 +36,7 @@ export function ProductDetailActions({ productId, productTitle, productImage, pr
35
36
  // itself — centralised so every add-to-cart call site gets the same
36
37
  // richer toast, not just this one.
37
38
  const addCart = useAddToCart({
38
- onError: (err) => showToast(err?.message ?? "Could not add to cart", "error"),
39
+ onError: (err) => showToast(toUserMessage(err?.code, undefined, { fallback: "Could not add to cart" }), "error"),
39
40
  });
40
41
  async function handleAddToCart(then) {
41
42
  setBusy("cart");
@@ -77,5 +77,21 @@ export interface ProductDetailPageViewProps {
77
77
  * Available" feature badge.
78
78
  */
79
79
  emiEnabled?: boolean;
80
+ /**
81
+ * Site-wide returns availability (`siteSettings.returns.windowDays > 0`),
82
+ * threaded from the page's data layer. Drives the "Returnable" badge.
83
+ *
84
+ * 🛑 A SITE feature gate, not a product attribute. The badge used to read
85
+ * `returnable={!finalSale}` — purely the product's own flag — so a platform
86
+ * with returns switched off still promised "Returnable" on every listing
87
+ * that was not a final sale. Same shape as the COD gate beside it: the
88
+ * product may carry the attribute, and we simply do not advertise it while
89
+ * the feature is off. Nothing is written to the product.
90
+ *
91
+ * Defaults to `true` when omitted so the two detail views that do not thread
92
+ * site settings keep their current behaviour rather than silently losing a
93
+ * badge they were correctly showing.
94
+ */
95
+ returnsEnabled?: boolean;
80
96
  }
81
- export declare function ProductDetailPageView({ slug, initialProduct, renderOfferAction, renderPrimaryActions, compositionContent, productFeatures, codEnabled, emiEnabled, }: ProductDetailPageViewProps): Promise<import("react").JSX.Element>;
97
+ export declare function ProductDetailPageView({ slug, initialProduct, renderOfferAction, renderPrimaryActions, compositionContent, productFeatures, codEnabled, emiEnabled, returnsEnabled, }: ProductDetailPageViewProps): Promise<import("react").JSX.Element>;
@@ -79,7 +79,7 @@ function StarRating({ value }) {
79
79
  // ---------------------------------------------------------------------------
80
80
  // Component
81
81
  // ---------------------------------------------------------------------------
82
- export async function ProductDetailPageView({ slug, initialProduct, renderOfferAction, renderPrimaryActions, compositionContent, productFeatures, codEnabled, emiEnabled, }) {
82
+ export async function ProductDetailPageView({ slug, initialProduct, renderOfferAction, renderPrimaryActions, compositionContent, productFeatures, codEnabled, emiEnabled, returnsEnabled = true, }) {
83
83
  // Use pre-fetched data when available to avoid a redundant repository call.
84
84
  // The page layer wraps getProductForDetail in React.cache(), so both
85
85
  // generateMetadata() and this component share the same in-flight promise.
@@ -386,7 +386,7 @@ export async function ProductDetailPageView({ slug, initialProduct, renderOfferA
386
386
  ? "✓ In Stock"
387
387
  : "✗ Out of Stock", inStock && effectiveStock !== null && effectiveStock <= 10
388
388
  ? ` — only ${effectiveStock} left`
389
- : ""] })), (categorySlugs.length > 0 || subcategory || brand) && (_jsxs(Row, { gap: "sm", wrap: true, children: [categorySlugs.map((slug, i) => (_jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(slug)), className: "inline-flex items-center rounded-full border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] px-[var(--appkit-space-2-5)] py-[var(--appkit-space-1)] text-[length:var(--appkit-text-xs)] font-medium text-[var(--appkit-color-text-muted)] transition-colors hover:border-primary-300 hover:bg-primary-surface hover:text-primary-700 dark:hover:border-primary-700/60 dark:hover:text-primary-400", children: categoryNames[i] ?? slug }, slug))), subcategory && (_jsx(Span, { layout: "inline-flex", size: "xs", weight: "medium", border: "subtle", rounded: "full", padding: "pill-sm-tall", surface: "muted", color: "muted", transform: "capitalize", children: subcategory })), brand && brandSlug && (_jsx(Link, { href: String(ROUTES.PUBLIC.BRAND_DETAIL(brandSlug)), className: "inline-flex items-center rounded-full border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] px-[var(--appkit-space-2-5)] py-[var(--appkit-space-1)] text-[length:var(--appkit-text-xs)] font-medium text-[var(--appkit-color-text-muted)] transition-colors hover:border-primary-300 hover:bg-primary-surface hover:text-primary-700 dark:hover:border-primary-700/60 dark:hover:text-primary-400", children: brand })), brand && !brandSlug && (_jsx(Span, { layout: "inline-flex", size: "xs", weight: "medium", border: "subtle", rounded: "full", padding: "pill-sm-tall", surface: "muted", color: "muted", children: brand }))] })), bundleMemberships.length > 0 && (_jsx(Row, { gap: "sm", wrap: true, children: bundleMemberships.map((b) => (_jsxs(Link, { href: String(ROUTES.PUBLIC.BUNDLE_DETAIL(b.id)), className: CLS_BUNDLE_PILL, children: [_jsx(Span, { "aria-hidden": "true", children: "\uD83D\uDCE6" }), "In bundle: ", b.title] }, b.id))) })), _jsx(ProductFeatureBadges, { featured: featured, freeShipping: freeShipping, codAvailable: codEnabled === true, codHref: String(ROUTES.PUBLIC.HOW_CHECKOUT_WORKS), emiAvailable: emiEnabled === true, emiHref: String(ROUTES.PUBLIC.HOW_EMI_WORKS), condition: condition ?? undefined, returnable: !finalSale, finalSale: finalSale, labels: {
389
+ : ""] })), (categorySlugs.length > 0 || subcategory || brand) && (_jsxs(Row, { gap: "sm", wrap: true, children: [categorySlugs.map((slug, i) => (_jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(slug)), className: "inline-flex items-center rounded-full border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] px-[var(--appkit-space-2-5)] py-[var(--appkit-space-1)] text-[length:var(--appkit-text-xs)] font-medium text-[var(--appkit-color-text-muted)] transition-colors hover:border-primary-300 hover:bg-primary-surface hover:text-primary-700 dark:hover:border-primary-700/60 dark:hover:text-primary-400", children: categoryNames[i] ?? slug }, slug))), subcategory && (_jsx(Span, { layout: "inline-flex", size: "xs", weight: "medium", border: "subtle", rounded: "full", padding: "pill-sm-tall", surface: "muted", color: "muted", transform: "capitalize", children: subcategory })), brand && brandSlug && (_jsx(Link, { href: String(ROUTES.PUBLIC.BRAND_DETAIL(brandSlug)), className: "inline-flex items-center rounded-full border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] px-[var(--appkit-space-2-5)] py-[var(--appkit-space-1)] text-[length:var(--appkit-text-xs)] font-medium text-[var(--appkit-color-text-muted)] transition-colors hover:border-primary-300 hover:bg-primary-surface hover:text-primary-700 dark:hover:border-primary-700/60 dark:hover:text-primary-400", children: brand })), brand && !brandSlug && (_jsx(Span, { layout: "inline-flex", size: "xs", weight: "medium", border: "subtle", rounded: "full", padding: "pill-sm-tall", surface: "muted", color: "muted", children: brand }))] })), bundleMemberships.length > 0 && (_jsx(Row, { gap: "sm", wrap: true, children: bundleMemberships.map((b) => (_jsxs(Link, { href: String(ROUTES.PUBLIC.BUNDLE_DETAIL(b.id)), className: CLS_BUNDLE_PILL, children: [_jsx(Span, { "aria-hidden": "true", children: "\uD83D\uDCE6" }), "In bundle: ", b.title] }, b.id))) })), _jsx(ProductFeatureBadges, { featured: featured, freeShipping: freeShipping, codAvailable: codEnabled === true, codHref: String(ROUTES.PUBLIC.HOW_CHECKOUT_WORKS), emiAvailable: emiEnabled === true, emiHref: String(ROUTES.PUBLIC.HOW_EMI_WORKS), condition: condition ?? undefined, returnable: !finalSale && returnsEnabled, finalSale: finalSale, labels: {
390
390
  featured: "Featured",
391
391
  fasterDelivery: "Faster Delivery",
392
392
  ratedSeller: "Rated Seller",
@@ -28,7 +28,7 @@ export declare const productFeatureAdminCreateSchema: z.ZodObject<{
28
28
  category: "shipping" | "seller" | "custom" | "condition" | "auction" | "platform" | "preorder";
29
29
  isActive: boolean;
30
30
  displayOrder: number;
31
- productTypes: ("all" | "classified" | "auction" | "prize-draw" | "digital-code" | "live" | "product" | "preorder")[];
31
+ productTypes: ("all" | "classified" | "auction" | "prize-draw" | "digital-code" | "live" | "preorder" | "product")[];
32
32
  storeId?: string | undefined;
33
33
  description?: string | undefined;
34
34
  iconColor?: string | undefined;
@@ -39,7 +39,7 @@ export declare const productFeatureAdminCreateSchema: z.ZodObject<{
39
39
  category: "shipping" | "seller" | "custom" | "condition" | "auction" | "platform" | "preorder";
40
40
  isActive: boolean;
41
41
  displayOrder: number;
42
- productTypes: ("all" | "classified" | "auction" | "prize-draw" | "digital-code" | "live" | "product" | "preorder")[];
42
+ productTypes: ("all" | "classified" | "auction" | "prize-draw" | "digital-code" | "live" | "preorder" | "product")[];
43
43
  storeId?: string | undefined;
44
44
  description?: string | undefined;
45
45
  iconColor?: string | undefined;
@@ -60,7 +60,7 @@ export declare const productFeatureStoreCreateSchema: z.ZodObject<{
60
60
  category: "shipping" | "seller" | "custom" | "condition" | "auction" | "platform" | "preorder";
61
61
  isActive: boolean;
62
62
  displayOrder: number;
63
- productTypes: ("all" | "classified" | "auction" | "prize-draw" | "digital-code" | "live" | "product" | "preorder")[];
63
+ productTypes: ("all" | "classified" | "auction" | "prize-draw" | "digital-code" | "live" | "preorder" | "product")[];
64
64
  description?: string | undefined;
65
65
  iconColor?: string | undefined;
66
66
  }, {
@@ -69,7 +69,7 @@ export declare const productFeatureStoreCreateSchema: z.ZodObject<{
69
69
  category: "shipping" | "seller" | "custom" | "condition" | "auction" | "platform" | "preorder";
70
70
  isActive: boolean;
71
71
  displayOrder: number;
72
- productTypes: ("all" | "classified" | "auction" | "prize-draw" | "digital-code" | "live" | "product" | "preorder")[];
72
+ productTypes: ("all" | "classified" | "auction" | "prize-draw" | "digital-code" | "live" | "preorder" | "product")[];
73
73
  description?: string | undefined;
74
74
  iconColor?: string | undefined;
75
75
  }>;
@@ -91,7 +91,7 @@ export declare const productFeatureUpdateSchema: z.ZodObject<{
91
91
  isActive?: boolean | undefined;
92
92
  displayOrder?: number | undefined;
93
93
  iconColor?: string | undefined;
94
- productTypes?: ("all" | "classified" | "auction" | "prize-draw" | "digital-code" | "live" | "product" | "preorder")[] | undefined;
94
+ productTypes?: ("all" | "classified" | "auction" | "prize-draw" | "digital-code" | "live" | "preorder" | "product")[] | undefined;
95
95
  }, {
96
96
  label?: string | undefined;
97
97
  icon?: string | undefined;
@@ -100,7 +100,7 @@ export declare const productFeatureUpdateSchema: z.ZodObject<{
100
100
  isActive?: boolean | undefined;
101
101
  displayOrder?: number | undefined;
102
102
  iconColor?: string | undefined;
103
- productTypes?: ("all" | "classified" | "auction" | "prize-draw" | "digital-code" | "live" | "product" | "preorder")[] | undefined;
103
+ productTypes?: ("all" | "classified" | "auction" | "prize-draw" | "digital-code" | "live" | "preorder" | "product")[] | undefined;
104
104
  }>;
105
105
  /**
106
106
  * The FORM schema for the product-feature editor, with UI metadata.
@@ -136,7 +136,7 @@ export declare const productFeatureFormSchema: z.ZodEffects<z.ZodObject<{
136
136
  category: "shipping" | "seller" | "custom" | "condition" | "auction" | "platform" | "preorder";
137
137
  isActive: boolean;
138
138
  displayOrder: number;
139
- productTypes: ("all" | "classified" | "auction" | "prize-draw" | "digital-code" | "live" | "product" | "preorder")[];
139
+ productTypes: ("all" | "classified" | "auction" | "prize-draw" | "digital-code" | "live" | "preorder" | "product")[];
140
140
  storeId?: string | undefined;
141
141
  description?: string | undefined;
142
142
  iconColor?: string | undefined;
@@ -147,7 +147,7 @@ export declare const productFeatureFormSchema: z.ZodEffects<z.ZodObject<{
147
147
  category: "shipping" | "seller" | "custom" | "condition" | "auction" | "platform" | "preorder";
148
148
  isActive: boolean;
149
149
  displayOrder: number;
150
- productTypes: ("all" | "classified" | "auction" | "prize-draw" | "digital-code" | "live" | "product" | "preorder")[];
150
+ productTypes: ("all" | "classified" | "auction" | "prize-draw" | "digital-code" | "live" | "preorder" | "product")[];
151
151
  storeId?: string | undefined;
152
152
  description?: string | undefined;
153
153
  iconColor?: string | undefined;
@@ -158,7 +158,7 @@ export declare const productFeatureFormSchema: z.ZodEffects<z.ZodObject<{
158
158
  category: "shipping" | "seller" | "custom" | "condition" | "auction" | "platform" | "preorder";
159
159
  isActive: boolean;
160
160
  displayOrder: number;
161
- productTypes: ("all" | "classified" | "auction" | "prize-draw" | "digital-code" | "live" | "product" | "preorder")[];
161
+ productTypes: ("all" | "classified" | "auction" | "prize-draw" | "digital-code" | "live" | "preorder" | "product")[];
162
162
  storeId?: string | undefined;
163
163
  description?: string | undefined;
164
164
  iconColor?: string | undefined;
@@ -169,7 +169,7 @@ export declare const productFeatureFormSchema: z.ZodEffects<z.ZodObject<{
169
169
  category: "shipping" | "seller" | "custom" | "condition" | "auction" | "platform" | "preorder";
170
170
  isActive: boolean;
171
171
  displayOrder: number;
172
- productTypes: ("all" | "classified" | "auction" | "prize-draw" | "digital-code" | "live" | "product" | "preorder")[];
172
+ productTypes: ("all" | "classified" | "auction" | "prize-draw" | "digital-code" | "live" | "preorder" | "product")[];
173
173
  storeId?: string | undefined;
174
174
  description?: string | undefined;
175
175
  iconColor?: string | undefined;
@@ -4,21 +4,48 @@
4
4
  * A cart may hold at most one seller-scoped coupon per store, plus at most
5
5
  * one admin (platform-wide) coupon on top. Both scopes stack with each
6
6
  * other freely — the old `combineWithSellerCoupons` opt-out was removed
7
- * (2026-08-21): stacking is now unconditional as long as the two "buckets"
8
- * (per-store, and the single platform-wide slot) don't collide.
7
+ * (2026-08-21): stacking is now unconditional as long as the buckets
8
+ * (per-store, the single platform-wide slot, and the points-earn slot) don't
9
+ * collide.
10
+ *
11
+ * ## 🛑 THREE buckets since 2026-10-22, and the third is not a nicety (D51)
12
+ *
13
+ * `RIPLI05` is a PERMANENT, site-wide, undeletable coupon that awards points
14
+ * rather than discounting. With only the admin bucket it would occupy the one
15
+ * platform-wide slot forever, and **every other platform coupon would be
16
+ * unusable for as long as the points programme existed** — a seasonal sale
17
+ * coupon could never be applied by anyone.
18
+ *
19
+ * The spec also requires it to stack with a discount coupon, because it awards
20
+ * a percentage of the POST-discount amount paid. A design where the two cannot
21
+ * coexist cannot implement that sentence at all.
22
+ *
23
+ * So the bucket is keyed on the coupon's TYPE, not its scope: a `points_earn`
24
+ * coupon is admin-scoped like any platform coupon, and it is the type that
25
+ * decides which slot it competes for.
9
26
  *
10
27
  * Pure function, no I/O — safe to call from both the cart-apply route and
11
28
  * the checkout re-validation pass.
12
29
  */
13
30
  import type { CartAppliedCoupon } from "../../cart/schemas/firestore";
31
+ import type { CouponType } from "../types";
14
32
  export interface IncomingCoupon {
15
33
  code: string;
16
34
  scope: "admin" | "seller";
17
35
  storeId?: string;
36
+ /**
37
+ * The coupon's type, which decides its bucket (D51).
38
+ *
39
+ * Optional, and absence means "a discount coupon" — every cart written
40
+ * before `points_earn` existed carries no type on its applied-coupon
41
+ * entries, and treating those as discount coupons is both correct and what
42
+ * preserves the previous behaviour exactly.
43
+ */
44
+ type?: CouponType;
18
45
  }
19
46
  /**
20
47
  * Returns a human-readable conflict message, or null when `incoming` can be
21
48
  * added alongside `existing` without violating the one-per-store +
22
- * one-global rule.
49
+ * one-global + one-points-earn rule.
23
50
  */
24
51
  export declare function detectCouponConflict(existing: CartAppliedCoupon[], incoming: IncomingCoupon): string | null;
@@ -1,9 +1,14 @@
1
+ /** A points-earn coupon competes for its own slot, never the admin one. */
2
+ function isPointsEarn(coupon) {
3
+ return coupon.type === "points_earn";
4
+ }
1
5
  /**
2
6
  * Returns a human-readable conflict message, or null when `incoming` can be
3
7
  * added alongside `existing` without violating the one-per-store +
4
- * one-global rule.
8
+ * one-global + one-points-earn rule.
5
9
  */
6
10
  export function detectCouponConflict(existing, incoming) {
11
+ const incomingIsPoints = isPointsEarn(incoming);
7
12
  for (const applied of existing) {
8
13
  if (applied.code === incoming.code) {
9
14
  return `Coupon ${incoming.code} is already applied.`;
@@ -13,7 +18,21 @@ export function detectCouponConflict(existing, incoming) {
13
18
  applied.storeId === incoming.storeId) {
14
19
  return `A coupon for this store is already applied (${applied.code}). Remove it first.`;
15
20
  }
16
- if (incoming.scope === "admin" && applied.scope === "admin") {
21
+ const appliedIsPoints = isPointsEarn(applied);
22
+ /*
23
+ * 🛑 The two admin-scoped buckets are checked SEPARATELY, and the order of
24
+ * these two branches does not matter precisely because each requires both
25
+ * sides to be in the same bucket. An earlier shape — testing scope first
26
+ * and type second — would have let a points coupon collide with a discount
27
+ * coupon, which is the whole defect.
28
+ */
29
+ if (incomingIsPoints && appliedIsPoints) {
30
+ return `A points offer is already applied (${applied.code}). Remove it first.`;
31
+ }
32
+ if (!incomingIsPoints &&
33
+ !appliedIsPoints &&
34
+ incoming.scope === "admin" &&
35
+ applied.scope === "admin") {
17
36
  return `Only one platform-wide coupon can be applied at a time (${applied.code}). Remove it first.`;
18
37
  }
19
38
  }
@@ -13,6 +13,19 @@ const TYPE_COLORS = {
13
13
  fixed: { card: "bg-success-surface border-success dark:bg-success-surface dark:border-success", code: "bg-success-surface dark:bg-success-surface text-success dark:text-success border-success dark:border-success" },
14
14
  free_shipping: { card: "bg-info-surface border-info dark:bg-info-surface dark:border-info", code: "bg-info-surface dark:bg-info-surface text-info dark:text-info border-info dark:border-info" },
15
15
  buy_x_get_y: { card: "bg-warning-surface border-warning dark:bg-warning-surface dark:border-warning", code: "bg-warning-surface dark:bg-warning-surface text-warning dark:text-warning border-warning dark:border-warning" },
16
+ /*
17
+ * D51. A `Record<CouponType, …>`, so this entry is a COMPILE requirement
18
+ * rather than a nicety — which is the reason the union was widened first.
19
+ *
20
+ * 🛑 `-surface` + the matching status ink, never a literal `text-white`.
21
+ * A past sweep paired `bg-{status}-surface` with `text-white` on 26
22
+ * surfaces, and since `-surface` is a near-white tint in light themes and a
23
+ * dark tint in dark ones, every one of them was invisible in exactly one
24
+ * theme (Root Cause #67). `primary` rather than a status colour because
25
+ * earning points is neither a warning nor an error — it is the brand's own
26
+ * offer.
27
+ */
28
+ points_earn: { card: "bg-primary-surface border-primary dark:bg-primary-surface dark:border-primary", code: "bg-primary-surface dark:bg-primary-surface text-primary dark:text-primary border-primary dark:border-primary" },
16
29
  };
17
30
  function pick(obj, ...keys) {
18
31
  for (const k of keys) {
@@ -15,10 +15,10 @@ export declare const COUPON_HELP: {
15
15
  readonly title: "How coupons work";
16
16
  /** The stacking rules — the part buyers most often get wrong. */
17
17
  readonly stackingHeading: "You can use more than one coupon";
18
- readonly stackingRules: readonly ["One store coupon per store. If your cart has items from three stores, you can apply each of those stores' coupons at the same time.", "Plus one platform-wide LetItRip coupon, on top of any store coupons.", "A second coupon for the same store, or a second platform-wide coupon, will be declined — remove the first one to swap it."];
18
+ readonly stackingRules: readonly ["One store coupon per store. If your cart has items from three stores, you can apply each of those stores' coupons at the same time.", "Plus one platform-wide LetItRip coupon, on top of any store coupons.", "Plus one points offer, which stacks with everything else — it earns you points rather than discounting, so it never takes the place of a discount coupon.", "A second coupon for the same store, a second platform-wide coupon, or a second points offer will be declined — remove the first one to swap it."];
19
19
  /** How the discount is actually distributed. */
20
20
  readonly applicationHeading: "Where each discount lands";
21
- readonly applicationRules: readonly ["A store coupon only discounts that store's items.", "A platform-wide coupon is spread proportionally across every store in your cart.", "Orders are placed per store, so each store's order shows its own share of the discount."];
21
+ readonly applicationRules: readonly ["A store coupon only discounts that store's items.", "A platform-wide coupon is spread proportionally across every store in your cart.", "Orders are placed per store, so each store's order shows its own share of the discount.", "A points offer earns points on what you pay after any discounts, so the two interact rather than ignoring each other."];
22
22
  /** Reuses the failure reasons already written for the buyer shopping guide. */
23
23
  readonly failureHeading: "Why a coupon might not apply";
24
24
  readonly failureReasons: readonly ["The eligible items in your cart are below the coupon's minimum purchase amount — minimum spend is measured against the items the coupon actually covers, not your whole cart.", "The coupon has expired, or hasn't started yet.", "The coupon is limited to certain products or categories, and your cart has none of them.", "The coupon is first-order-only and you've ordered before.", "You've already used the coupon the maximum number of times allowed.", "The coupon ran out — some coupons have a total limit across all buyers."];
@@ -18,7 +18,11 @@ export const COUPON_HELP = {
18
18
  stackingRules: [
19
19
  "One store coupon per store. If your cart has items from three stores, you can apply each of those stores' coupons at the same time.",
20
20
  "Plus one platform-wide LetItRip coupon, on top of any store coupons.",
21
- "A second coupon for the same store, or a second platform-wide coupon, will be declined — remove the first one to swap it.",
21
+ // D51's third bucket, stated for the buyer. Without this line the rule
22
+ // above reads as "one platform coupon" and a buyer with a points offer
23
+ // applied would reasonably think it was using up that slot.
24
+ "Plus one points offer, which stacks with everything else — it earns you points rather than discounting, so it never takes the place of a discount coupon.",
25
+ "A second coupon for the same store, a second platform-wide coupon, or a second points offer will be declined — remove the first one to swap it.",
22
26
  ],
23
27
  /** How the discount is actually distributed. */
24
28
  applicationHeading: "Where each discount lands",
@@ -26,6 +30,10 @@ export const COUPON_HELP = {
26
30
  "A store coupon only discounts that store's items.",
27
31
  "A platform-wide coupon is spread proportionally across every store in your cart.",
28
32
  "Orders are placed per store, so each store's order shows its own share of the discount.",
33
+ // Stated because the ORDER of operations is what a buyer would otherwise
34
+ // guess wrong — points are earned on what you actually paid, so a discount
35
+ // reduces the points as well as the price.
36
+ "A points offer earns points on what you pay after any discounts, so the two interact rather than ignoring each other.",
29
37
  ],
30
38
  /** Reuses the failure reasons already written for the buyer shopping guide. */
31
39
  failureHeading: "Why a coupon might not apply",
@@ -72,6 +72,29 @@ export interface CouponDocument extends BaseDocument {
72
72
  discount: DiscountConfig;
73
73
  bxgy?: BXGYConfig;
74
74
  tiers?: TieredDiscount[];
75
+ /**
76
+ * D51 — percentage of the amount PAID that a `points_earn` coupon awards as
77
+ * points. Meaningless, and ignored, on every other type.
78
+ *
79
+ * ## 🛑 Why this is not `discount.value`
80
+ *
81
+ * `discount` is read by `computeGroupCouponDiscount` to reduce the order.
82
+ * Overloading its `value` to mean "percent awarded as points" would make a
83
+ * points coupon discount the order by the same number it was supposed to
84
+ * credit — the exact overloading that `bxgy` and `tiers` are separate fields
85
+ * to avoid.
86
+ *
87
+ * `discount` stays REQUIRED on the interface because every existing coupon
88
+ * has one and widening it to optional would ripple through ~30 readers; a
89
+ * points coupon carries `{ value: 0 }`. The protection that matters is in
90
+ * the discount loop, which skips on TYPE rather than trusting that zero
91
+ * (`coupon-discount.ts`) — bad data cannot bypass a type check.
92
+ *
93
+ * The base is the post-discount amount actually paid in money, which is
94
+ * `POINTS_EARN_PERCENT`'s existing rule; this field exists so a campaign can
95
+ * raise it (the festival double event) without editing a constant.
96
+ */
97
+ pointsPercent?: number;
75
98
  usage: UsageConfig;
76
99
  validity: ValidityConfig;
77
100
  restrictions: RestrictionsConfig;
@@ -143,6 +166,7 @@ export declare const COUPON_FIELDS: {
143
166
  readonly FIXED: CouponType;
144
167
  readonly FREE_SHIPPING: CouponType;
145
168
  readonly BUY_X_GET_Y: CouponType;
169
+ readonly POINTS_EARN: CouponType;
146
170
  };
147
171
  readonly SCOPE_VALUES: {
148
172
  readonly ADMIN: "admin";
@@ -48,6 +48,7 @@ export const COUPON_FIELDS = {
48
48
  FIXED: "fixed",
49
49
  FREE_SHIPPING: "free_shipping",
50
50
  BUY_X_GET_Y: "buy_x_get_y",
51
+ POINTS_EARN: "points_earn",
51
52
  },
52
53
  SCOPE_VALUES: {
53
54
  ADMIN: "admin",
@@ -59,6 +60,9 @@ export const COUPON_TYPE_LABELS = {
59
60
  fixed: "Fixed Amount Discount",
60
61
  free_shipping: "Free Shipping",
61
62
  buy_x_get_y: "Buy X Get Y",
63
+ // Named for what the buyer gets, not for the mechanism. "Points earn" is
64
+ // our word for it; "Points back" is what appears on their balance.
65
+ points_earn: "Points Back",
62
66
  };
63
67
  export const DEFAULT_COUPON_DATA = {
64
68
  usage: { currentUsage: 0 },
@@ -1,4 +1,22 @@
1
- export type CouponType = "percentage" | "fixed" | "free_shipping" | "buy_x_get_y";
1
+ export type CouponType = "percentage" | "fixed" | "free_shipping" | "buy_x_get_y"
2
+ /**
3
+ * D51 — a coupon that awards POINTS rather than discounting the order.
4
+ *
5
+ * 🛑 It carries `pointsPercent`, not a discount value, and it must never
6
+ * reach the discount arithmetic: a `points_earn` coupon with a `discount`
7
+ * field read by `computeGroupCouponDiscount` would silently discount the
8
+ * order as well as award points.
9
+ *
10
+ * It exists because `RIPLI05` is a PERMANENT, site-wide coupon, and
11
+ * `detectCouponConflict` permits exactly one admin coupon site-wide — so
12
+ * putting it in the admin slot would make every other platform coupon
13
+ * unusable for as long as it exists. It gets its own stacking bucket.
14
+ *
15
+ * Adding it here is a compile error at `COUPON_TYPE_LABELS`, which is a
16
+ * `Record<CouponType, string>` — that is the point of declaring the union
17
+ * first.
18
+ */
19
+ | "points_earn";
2
20
  export type CouponScope = "admin" | "seller";
3
21
  export interface CouponItem {
4
22
  id: string;