@mohasinac/appkit 4.42.1 → 4.42.3

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 (182) hide show
  1. package/dist/_internal/server/features/offers/adapters.d.ts +29 -7
  2. package/dist/_internal/server/features/offers/adapters.js +14 -3
  3. package/dist/_internal/server/features/orders/adapters.js +54 -1
  4. package/dist/client.d.ts +1 -0
  5. package/dist/client.js +1 -0
  6. package/dist/features/account/components/UserOrdersView.js +30 -2
  7. package/dist/features/account/hooks/useAddresses.d.ts +21 -6
  8. package/dist/features/account/hooks/useAddresses.js +1 -1
  9. package/dist/features/addresses/hooks/usePostalLookup.js +84 -12
  10. package/dist/features/admin/components/AdminCategoriesView.js +37 -2
  11. package/dist/features/admin/components/AdminOffersView.js +2 -2
  12. package/dist/features/admin/components/AdminSupportTicketsView.js +27 -1
  13. package/dist/features/admin/constants/filter-tabs.d.ts +25 -0
  14. package/dist/features/admin/constants/filter-tabs.js +30 -0
  15. package/dist/features/admin/hooks/useAdminListingData.d.ts +35 -0
  16. package/dist/features/admin/hooks/useAdminListingData.js +45 -9
  17. package/dist/features/cart/schemas/index.d.ts +15 -0
  18. package/dist/features/cart/schemas/index.js +2 -0
  19. package/dist/features/cart/types/index.d.ts +22 -0
  20. package/dist/features/categories/components/CategoriesIndexListing.js +19 -1
  21. package/dist/features/categories/components/CategoriesIndexPageView.js +14 -1
  22. package/dist/features/classified/components/ClassifiedDetailPageView.d.ts +8 -0
  23. package/dist/features/classified/components/ClassifiedDetailPageView.js +1 -0
  24. package/dist/features/events/actions/event-actions.d.ts +13 -0
  25. package/dist/features/events/actions/event-actions.js +75 -1
  26. package/dist/features/events/repository/event-entry.repository.d.ts +17 -0
  27. package/dist/features/events/repository/event-entry.repository.js +36 -0
  28. package/dist/features/homepage/components/BrandsSection.js +1 -1
  29. package/dist/features/homepage/components/CustomerReviewsSection.js +1 -1
  30. package/dist/features/homepage/components/FeaturedBundlesSection.js +1 -1
  31. package/dist/features/homepage/components/ShopByCategorySection.js +1 -1
  32. package/dist/features/media/upload/MediaUploadField.js +21 -3
  33. package/dist/features/orders/components/OrdersList.js +2 -1
  34. package/dist/features/orders/index.d.ts +1 -0
  35. package/dist/features/orders/index.js +1 -0
  36. package/dist/features/orders/types/index.d.ts +2 -0
  37. package/dist/features/orders/utils/order-ref.d.ts +45 -0
  38. package/dist/features/orders/utils/order-ref.js +58 -0
  39. package/dist/features/products/components/MakeOfferButton.d.ts +18 -1
  40. package/dist/features/products/components/MakeOfferButton.js +23 -1
  41. package/dist/features/products/components/ProductDetailPageView.d.ts +5 -0
  42. package/dist/features/products/components/ProductDetailPageView.js +3 -2
  43. package/dist/features/products/repository/products.repository.js +29 -2
  44. package/dist/features/seller/actions/offer-actions.js +32 -0
  45. package/dist/features/seller/components/QuickProductForm.js +15 -1
  46. package/dist/features/seller/components/SellerBidsView.js +3 -1
  47. package/dist/features/seller/components/SellerOffersView.js +2 -2
  48. package/dist/features/seller/components/SellerOrdersView.js +49 -6
  49. package/dist/features/seller/components/SellerProductsCards.js +23 -1
  50. package/dist/features/seller/components/SellerProductsView.js +27 -2
  51. package/dist/features/seller/hooks/useSellerListingData.d.ts +0 -1
  52. package/dist/features/seller/hooks/useSellerListingData.js +17 -19
  53. package/dist/features/shell/SectionForm.js +33 -2
  54. package/dist/features/tester/seed-data/_happy-path.d.ts +23 -0
  55. package/dist/features/tester/seed-data/_happy-path.js +710 -0
  56. package/dist/features/tester/seed-data/_money-flows.js +34 -23
  57. package/dist/features/tester/seed-data/authored/account-auth__profile-settings.js +22 -3
  58. package/dist/features/tester/seed-data/authored/addresses__address-filters.js +17 -12
  59. package/dist/features/tester/seed-data/authored/addresses__postal-lookup.js +7 -6
  60. package/dist/features/tester/seed-data/authored/addresses__postal-validation.js +10 -4
  61. package/dist/features/tester/seed-data/authored/addresses__state-picker.js +13 -8
  62. package/dist/features/tester/seed-data/authored/addresses__unban-request.js +49 -11
  63. package/dist/features/tester/seed-data/authored/admin__admin-detail-round-trips.js +10 -6
  64. package/dist/features/tester/seed-data/authored/admin__bans-and-trust.js +71 -32
  65. package/dist/features/tester/seed-data/authored/admin__blog-faqs.js +80 -31
  66. package/dist/features/tester/seed-data/authored/admin__bug-hunter-rewards.js +62 -25
  67. package/dist/features/tester/seed-data/authored/admin__bulk-actions.js +61 -31
  68. package/dist/features/tester/seed-data/authored/admin__bundles.js +84 -37
  69. package/dist/features/tester/seed-data/authored/admin__buyer-data-admin.js +90 -57
  70. package/dist/features/tester/seed-data/authored/admin__catalog-listings.js +38 -26
  71. package/dist/features/tester/seed-data/authored/admin__category-brand-authoring.js +47 -28
  72. package/dist/features/tester/seed-data/authored/admin__classifieds-digitalcodes-live.js +118 -31
  73. package/dist/features/tester/seed-data/authored/admin__content-deletes.js +30 -24
  74. package/dist/features/tester/seed-data/authored/admin__content-marketing.js +83 -46
  75. package/dist/features/tester/seed-data/authored/admin__coupons.js +8 -8
  76. package/dist/features/tester/seed-data/authored/admin__events-raffles-spin.js +9 -9
  77. package/dist/features/tester/seed-data/authored/admin__firebase-function-effects.js +19 -4
  78. package/dist/features/tester/seed-data/authored/admin__media-watermark.js +77 -33
  79. package/dist/features/tester/seed-data/authored/admin__orders-fulfillment.js +5 -5
  80. package/dist/features/tester/seed-data/authored/admin__prize-draws-lotteries.js +29 -5
  81. package/dist/features/tester/seed-data/authored/admin__site-system.js +45 -23
  82. package/dist/features/tester/seed-data/authored/admin__uncovered-admin-pages.js +53 -28
  83. package/dist/features/tester/seed-data/authored/admin__users-trust.js +20 -20
  84. package/dist/features/tester/seed-data/authored/buying__bidding.js +19 -2
  85. package/dist/features/tester/seed-data/authored/buying__browsing-search.js +20 -5
  86. package/dist/features/tester/seed-data/authored/buying__buying-checkout.js +28 -10
  87. package/dist/features/tester/seed-data/authored/buying__cart.js +10 -3
  88. package/dist/features/tester/seed-data/authored/buying__image-tile-layout.js +0 -16
  89. package/dist/features/tester/seed-data/authored/buying__my-orders.js +27 -5
  90. package/dist/features/tester/seed-data/authored/buying__offers.js +22 -7
  91. package/dist/features/tester/seed-data/authored/buying__order-detail-actions.js +3 -3
  92. package/dist/features/tester/seed-data/authored/buying__product-detail.js +35 -4
  93. package/dist/features/tester/seed-data/authored/buying__return-request.js +19 -6
  94. package/dist/features/tester/seed-data/authored/buying__reviews-pagination.js +36 -18
  95. package/dist/features/tester/seed-data/authored/buying__reviews.js +16 -1
  96. package/dist/features/tester/seed-data/authored/buying__user-dashboard-navigation.js +2 -1
  97. package/dist/features/tester/seed-data/authored/buying__user-uncovered-pages.js +16 -1
  98. package/dist/features/tester/seed-data/authored/community-support__public-profile.js +3 -2
  99. package/dist/features/tester/seed-data/authored/community-support__support-tickets.js +4 -4
  100. package/dist/features/tester/seed-data/authored/content-discovery__blog.js +19 -2
  101. package/dist/features/tester/seed-data/authored/content-discovery__category-brand-relations.js +4 -4
  102. package/dist/features/tester/seed-data/authored/content-discovery__category-counts-and-rollup.js +5 -5
  103. package/dist/features/tester/seed-data/authored/content-discovery__coupons.js +41 -6
  104. package/dist/features/tester/seed-data/authored/content-discovery__event-detail-subroutes.js +63 -8
  105. package/dist/features/tester/seed-data/authored/content-discovery__event-participation.js +29 -29
  106. package/dist/features/tester/seed-data/authored/content-discovery__events.js +2 -2
  107. package/dist/features/tester/seed-data/authored/content-discovery__faq-help.js +23 -1
  108. package/dist/features/tester/seed-data/authored/content-discovery__notifications.js +6 -6
  109. package/dist/features/tester/seed-data/authored/content-discovery__search.js +1 -1
  110. package/dist/features/tester/seed-data/authored/content-discovery__store-reviews-aggregate.js +1 -1
  111. package/dist/features/tester/seed-data/authored/cta-layout__checkout-bottom-bar.js +31 -6
  112. package/dist/features/tester/seed-data/authored/cta-layout__dialog-footers.js +34 -10
  113. package/dist/features/tester/seed-data/authored/cta-layout__editor-action-bar.js +35 -8
  114. package/dist/features/tester/seed-data/authored/cta-layout__navbar-ctas.js +31 -9
  115. package/dist/features/tester/seed-data/authored/cta-layout__product-bottom-bar.js +29 -3
  116. package/dist/features/tester/seed-data/authored/design-ux__back-to-top-button.js +1 -1
  117. package/dist/features/tester/seed-data/authored/design-ux__carousel-arrow-bounds.js +10 -10
  118. package/dist/features/tester/seed-data/authored/design-ux__dashboard-layout.js +3 -3
  119. package/dist/features/tester/seed-data/authored/design-ux__footer-theme.js +2 -2
  120. package/dist/features/tester/seed-data/authored/design-ux__form-validation-errors.js +2 -2
  121. package/dist/features/tester/seed-data/authored/design-ux__general-design.js +4 -4
  122. package/dist/features/tester/seed-data/authored/design-ux__hand-mode-layout.js +1 -1
  123. package/dist/features/tester/seed-data/authored/design-ux__homepage-carousels.js +8 -8
  124. package/dist/features/tester/seed-data/authored/design-ux__status-badge-legibility.js +2 -2
  125. package/dist/features/tester/seed-data/authored/design-ux__sticky-cta-bar.js +2 -2
  126. package/dist/features/tester/seed-data/authored/page-wiring__data-loss.js +39 -16
  127. package/dist/features/tester/seed-data/authored/page-wiring__detail-pages.js +43 -8
  128. package/dist/features/tester/seed-data/authored/page-wiring__drawer-pages.js +40 -14
  129. package/dist/features/tester/seed-data/authored/page-wiring__reachability.js +31 -14
  130. package/dist/features/tester/seed-data/authored/public-pages__auth-error-pages.js +7 -6
  131. package/dist/features/tester/seed-data/authored/public-pages__bug-hunters.js +14 -5
  132. package/dist/features/tester/seed-data/authored/public-pages__core-listing-pages.js +3 -3
  133. package/dist/features/tester/seed-data/authored/public-pages__help-how-it-works.js +1 -1
  134. package/dist/features/tester/seed-data/authored/public-pages__help-scams-guides-subpages.js +4 -2
  135. package/dist/features/tester/seed-data/authored/public-pages__legal-policy-pages.js +3 -3
  136. package/dist/features/tester/seed-data/authored/public-pages__newly-wired-browse-indexes.js +5 -5
  137. package/dist/features/tester/seed-data/authored/public-pages__store-tabs.js +5 -5
  138. package/dist/features/tester/seed-data/authored/public-pages__stores-sellers-directories.js +3 -3
  139. package/dist/features/tester/seed-data/authored/search-and-nav__employee-permissions.js +45 -11
  140. package/dist/features/tester/seed-data/authored/search-and-nav__header-search.js +57 -17
  141. package/dist/features/tester/seed-data/authored/search-and-nav__settings-deep-links.js +36 -5
  142. package/dist/features/tester/seed-data/authored/search-and-nav__sidebar-search.js +29 -4
  143. package/dist/features/tester/seed-data/authored/selling__become-seller.js +23 -0
  144. package/dist/features/tester/seed-data/authored/selling__final-sale-authoring.js +14 -13
  145. package/dist/features/tester/seed-data/authored/selling__listing-a-product.js +14 -9
  146. package/dist/features/tester/seed-data/authored/selling__listing-edit-roundtrip.js +34 -7
  147. package/dist/features/tester/seed-data/authored/selling__listing-lifecycle.js +17 -7
  148. package/dist/features/tester/seed-data/authored/selling__listing-type-fields-roundtrip.js +9 -9
  149. package/dist/features/tester/seed-data/authored/selling__media-limits.js +9 -4
  150. package/dist/features/tester/seed-data/authored/selling__product-upload-details.js +3 -3
  151. package/dist/features/tester/seed-data/authored/selling__quick-add-minimum-details.js +3 -3
  152. package/dist/features/tester/seed-data/authored/selling__sectionised-forms.js +25 -1
  153. package/dist/features/tester/seed-data/authored/selling__seller-analytics-payouts.js +27 -6
  154. package/dist/features/tester/seed-data/authored/selling__seller-bids-bundles-filters.js +19 -1
  155. package/dist/features/tester/seed-data/authored/selling__seller-catalog-org.js +63 -22
  156. package/dist/features/tester/seed-data/authored/selling__seller-custom-brands.js +9 -5
  157. package/dist/features/tester/seed-data/authored/selling__seller-guide.js +7 -6
  158. package/dist/features/tester/seed-data/authored/selling__seller-listing-types.js +3 -2
  159. package/dist/features/tester/seed-data/authored/selling__seller-marketing-extras.js +18 -18
  160. package/dist/features/tester/seed-data/authored/selling__seller-ops-comms.js +13 -12
  161. package/dist/features/tester/seed-data/authored/selling__seller-orders-at-scale.js +19 -3
  162. package/dist/features/tester/seed-data/authored/selling__seller-orders.js +22 -4
  163. package/dist/features/tester/seed-data/authored/selling__seller-shipping-payouts-setup.js +20 -8
  164. package/dist/features/tester/seed-data/authored/selling__store-dashboard-navigation.js +4 -4
  165. package/dist/features/tester/seed-data/authored/selling__store-uncovered-pages.js +2 -2
  166. package/dist/features/tester/seed-data/authored/seo__canonical-and-host.js +5 -5
  167. package/dist/features/tester/seed-data/authored/seo__og-images.js +1 -1
  168. package/dist/features/tester/seed-data/authored/seo__page-metadata.js +7 -7
  169. package/dist/features/tester/seed-data/authored/seo__sitemap-and-robots.js +3 -3
  170. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +123 -21
  171. package/dist/features/tester/utils/phases.d.ts +17 -0
  172. package/dist/features/tester/utils/phases.js +29 -2
  173. package/dist/index.d.ts +1 -0
  174. package/dist/index.js +2 -0
  175. package/dist/seo/json-ld.d.ts +23 -0
  176. package/dist/seo/json-ld.js +3 -2
  177. package/dist/server.d.ts +1 -0
  178. package/dist/server.js +2 -0
  179. package/dist/styles.css +1 -1
  180. package/dist/tailwind-utilities.css +1 -1
  181. package/dist/ui/components/PaginatedSelect.js +0 -0
  182. package/package.json +1 -1
@@ -81,15 +81,37 @@ export interface Offer {
81
81
  }
82
82
  export interface OfferAdapterOptions {
83
83
  /**
84
- * Include `buyerUid`/`buyerName`/`buyerEmail`.
84
+ * What to do with `buyerUid`/`buyerName`/`buyerEmail`.
85
85
  *
86
- * Default **false**, deliberately. The seller-facing route masks buyer
87
- * identity, and a route that forgot to call `maskOfferForSeller` would
88
- * previously have shipped it verbatim — there was no adapter to stop it.
89
- * Making the omission the default means forgetting the flag under-shares
90
- * rather than leaks.
86
+ * | value | emits |
87
+ * |---|---|
88
+ * | `"omit"` (default) | nothing — the three keys are absent |
89
+ * | `"masked"` | `buyerName` `"M*** U***"`, `buyerEmail` `"m***@***.in"`, no uid |
90
+ * | `"full"` | verbatim |
91
+ *
92
+ * 🛑 **This replaced a boolean, because the boolean had no way to say
93
+ * "masked" and the seller route needed exactly that.** It worked around the
94
+ * gap by calling `maskOfferForSeller(o)` and then adapting with the flag
95
+ * OFF — so the mask ran, produced a masked name, and the adapter deleted it
96
+ * on the very next line. Every one of the 13 rows on `/store/offers` read
97
+ * **"Unknown buyer"**, measured live as tyson@beybladearena.in, and so did
98
+ * the detail modal.
99
+ *
100
+ * That is worse than cosmetic: a seller could not tell two offers on the same
101
+ * listing apart, which is the one thing they need when two people are bidding
102
+ * up the same item. It also silently disagreed with the sibling server action
103
+ * `listSellerOffers`, which masks and returns the name — two seller read
104
+ * paths, two answers (Root Cause #38), and a mask whose output was discarded
105
+ * (Root Cause #50).
106
+ *
107
+ * Masking now happens INSIDE the adapter, so a caller cannot apply one half.
108
+ * `"masked"` deliberately omits `buyerUid` as well: a uid is not a display
109
+ * value and there is nothing to partially reveal about it.
110
+ *
111
+ * The default stays the safe one — a caller that says nothing under-shares
112
+ * rather than leaks, which is why the old boolean defaulted false too.
91
113
  */
92
- includeBuyerIdentity?: boolean;
114
+ buyerIdentity?: "omit" | "masked" | "full";
93
115
  }
94
116
  export declare function offerDocumentToOffer(doc: OfferDocument, opts?: OfferAdapterOptions): Offer;
95
117
  /**
@@ -10,12 +10,25 @@
10
10
  *
11
11
  * Mirrors `orders/adapters.ts`, including its `toIsoOrUndefined` helper.
12
12
  */
13
+ // Display masking only — `pii-mask.ts` is the crypto-FREE half that Root Cause
14
+ // #24 split out of `pii-encrypt.ts` precisely so it could be imported freely.
15
+ import { maskEmail, maskName } from "../../../../security/pii-mask";
13
16
  function toIsoOrUndefined(value) {
14
17
  return value instanceof Date ? value.toISOString() : undefined;
15
18
  }
16
19
  function toIso(value) {
17
20
  return value instanceof Date ? value.toISOString() : String(value);
18
21
  }
22
+ /** The three buyer keys, resolved per `buyerIdentity`. Absent keys stay absent. */
23
+ function buyerIdentityFields(doc, mode) {
24
+ if (mode === "full") {
25
+ return { buyerUid: doc.buyerUid, buyerName: doc.buyerName, buyerEmail: doc.buyerEmail };
26
+ }
27
+ if (mode === "masked") {
28
+ return { buyerName: maskName(doc.buyerName), buyerEmail: maskEmail(doc.buyerEmail) };
29
+ }
30
+ return {};
31
+ }
19
32
  export function offerDocumentToOffer(doc, opts = {}) {
20
33
  const superseded = doc.status === "withdrawn" && Boolean(doc.supersededByOfferId);
21
34
  return {
@@ -59,9 +72,7 @@ export function offerDocumentToOffer(doc, opts = {}) {
59
72
  superseded,
60
73
  cancelledByAdminUid: doc.cancelledByAdminUid,
61
74
  cancelReason: doc.cancelReason,
62
- ...(opts.includeBuyerIdentity
63
- ? { buyerUid: doc.buyerUid, buyerName: doc.buyerName, buyerEmail: doc.buyerEmail }
64
- : {}),
75
+ ...buyerIdentityFields(doc, opts.buyerIdentity),
65
76
  };
66
77
  }
67
78
  /**
@@ -1,5 +1,41 @@
1
+ /**
2
+ * 🛑 A date that is not a `Date` INSTANCE used to be silently dropped, and that
3
+ * is not a hypothetical — it blanked the buyer's tracking page.
4
+ *
5
+ * The old body was `value instanceof Date ? value.toISOString() : undefined`,
6
+ * and its parameter was typed `Date | undefined`, so TypeScript had no reason to
7
+ * object: `OrderDocument` declares these fields as `Date`. What Firestore hands
8
+ * back does not always agree. Measured end to end after a seller marked an order
9
+ * shipped: the stored document carried
10
+ * `shippingDate: "2026-09-29T09:06:22.381Z"` — an ISO **string**, because that is
11
+ * what the ship write path stores — while `orderDate`, written at creation, was a
12
+ * real `Date`. So the adapter mapped `orderDate` and dropped `shippingDate`, and
13
+ * `/user/orders/[id]/track` rendered the Shipped step with an em-dash placeholder
14
+ * beside a perfectly correct carrier and tracking number. Root Cause #57's shape:
15
+ * one field lost in an adapter, presenting as a UI that looks merely incomplete.
16
+ *
17
+ * Three shapes are accepted because all three genuinely occur here: a `Date`, a
18
+ * Firestore `Timestamp` (duck-typed on `toDate` rather than imported, so this
19
+ * file stays free of a firebase-admin import — appkit Export Rules), and an ISO
20
+ * string. Anything else, including an unparseable string, still yields
21
+ * `undefined` — a wrong date is worse than a missing one, and the timeline is
22
+ * built to render the em-dash honestly.
23
+ */
1
24
  function toIsoOrUndefined(value) {
2
- return value instanceof Date ? value.toISOString() : undefined;
25
+ if (!value)
26
+ return undefined;
27
+ if (value instanceof Date) {
28
+ return Number.isNaN(value.getTime()) ? undefined : value.toISOString();
29
+ }
30
+ if (typeof value === "object" && typeof value.toDate === "function") {
31
+ const d = value.toDate();
32
+ return d instanceof Date && !Number.isNaN(d.getTime()) ? d.toISOString() : undefined;
33
+ }
34
+ if (typeof value === "string" || typeof value === "number") {
35
+ const d = new Date(value);
36
+ return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
37
+ }
38
+ return undefined;
3
39
  }
4
40
  export function orderDocumentToOrder(doc) {
5
41
  const items = doc.items?.length
@@ -107,6 +143,23 @@ export function orderDocumentToOrder(doc) {
107
143
  shipmentProtectionAddon: doc.shipmentProtectionAddon,
108
144
  shipmentProtectionFee: doc.shipmentProtectionFee,
109
145
  platformFee: doc.platformFee,
146
+ /*
147
+ * 🛑 `gstAmount` -> `tax`, and codHandlingFee alongside it.
148
+ *
149
+ * The comment four lines up is about exactly this and the same block still
150
+ * dropped two fees. The invoice renders its GST line as
151
+ * `order.tax !== undefined && order.tax > 0`, and nothing ever set `tax`,
152
+ * so that line could never appear — Root Cause #57's silent "not
153
+ * applicable" fallback, on a financial document.
154
+ *
155
+ * Measured on a real order: the invoice listed Subtotal ₹899.00 and
156
+ * Shipping ₹77.00 against a stated Total of ₹997.80, leaving ₹21.80 with
157
+ * nothing to explain it (₹10 platform fee + ₹10 WhatsApp updates +
158
+ * ₹1.80 GST). An invoice whose lines do not sum to its own total is a
159
+ * GST-compliance problem, not a cosmetic one.
160
+ */
161
+ tax: doc.gstAmount,
162
+ codHandlingFee: doc.codHandlingFee,
110
163
  trackingNumber: doc.trackingNumber,
111
164
  shippingCarrier: doc.shippingCarrier,
112
165
  trackingUrl: doc.trackingUrl,
package/dist/client.d.ts CHANGED
@@ -462,6 +462,7 @@ export { BecomeSellerView } from "./features/account/index";
462
462
  export type { BidDocument } from "./features/auctions/index";
463
463
  export type { ClaimedCouponDocument } from "./features/promotions/schemas";
464
464
  export { groupOrderItemsByLine } from "./features/orders/index";
465
+ export { shortOrderRef } from "./features/orders/index";
465
466
  export type { LineOrderGroup, OutOfStockPolicy } from "./features/orders/index";
466
467
  export { MediaUploadField } from "./features/media/index";
467
468
  export { PrizeRevealModal } from "./features/products/components/PrizeRevealModal";
package/dist/client.js CHANGED
@@ -467,6 +467,7 @@ export { Card, CardBody } from "./ui/index";
467
467
  export { SellerCouponsView, SellerCreateProductView, SellerEditProductView, useBecomeSeller, } from "./features/seller/index";
468
468
  export { BecomeSellerView } from "./features/account/index";
469
469
  export { groupOrderItemsByLine } from "./features/orders/index";
470
+ export { shortOrderRef } from "./features/orders/index";
470
471
  export { MediaUploadField } from "./features/media/index";
471
472
  export { PrizeRevealModal } from "./features/products/components/PrizeRevealModal";
472
473
  export { useProduct } from "./features/products/index";
@@ -9,6 +9,7 @@ import { PaginatedSelect } from "../../../ui/components/PaginatedSelect";
9
9
  import { DataListingView } from "../../admin/components/DataListingView";
10
10
  import { OrderCard } from "../../orders/components/OrdersList";
11
11
  import { useOrderScope } from "../../orders/components/OrderScopeTabs";
12
+ import { useSearchParams } from "next/navigation";
12
13
  const CANCELLABLE_STATUSES = new Set(["pending", "confirmed", "processing"]);
13
14
  const TRACKABLE_STATUSES = new Set(["shipped"]);
14
15
  const SORT_OPTIONS = [
@@ -45,6 +46,33 @@ const STATUS_OPTIONS = [
45
46
  ];
46
47
  export function UserOrdersView({ onOrderClick }) {
47
48
  const orderScope = useOrderScope();
49
+ /*
50
+ * 🛑 A FILTERED empty result is not an empty account, and saying so is a lie
51
+ * the buyer has no way to check.
52
+ *
53
+ * Both empty states here read "You haven't placed any orders yet." That is
54
+ * correct for a new account and flatly wrong the moment a search or a status
55
+ * chip is what emptied the list. Measured: /user/orders?q=zzzznope on an
56
+ * account with roughly thirty real orders rendered exactly that sentence — a
57
+ * buyer searching for an order they could not find would reasonably conclude
58
+ * their whole history had gone.
59
+ *
60
+ * Same shape as the poll leaderboard fixed earlier in this run, where a
61
+ * withheld tally rendered as "No votes yet." beside a participant count of
62
+ * 365: an empty result must not be reported as an absolute zero when a filter
63
+ * is the reason.
64
+ *
65
+ * Read from the URL rather than threaded through `renderCards`, whose
66
+ * signature is `(rows, view, selection, isLoading)` and carries no filter
67
+ * state. `useUrlTable`/`useSearchParams` hold no local state, so a second
68
+ * reader against the same URL stays in sync with the listing's own
69
+ * (CLAUDE.md, Root Cause #35).
70
+ */
71
+ const searchParams = useSearchParams();
72
+ const hasNarrowingFilter = ["q", "search", "status", "orderType", "filters"].some((k) => Boolean(searchParams.get(k)));
73
+ const emptyMessage = hasNarrowingFilter
74
+ ? "No orders match your search or filters."
75
+ : "You haven't placed any orders yet.";
48
76
  const config = {
49
77
  portal: "user",
50
78
  title: "My Orders",
@@ -56,7 +84,7 @@ export function UserOrdersView({ onOrderClick }) {
56
84
  // match more.
57
85
  fields: ["id"],
58
86
  },
59
- emptyLabel: "You haven't placed any orders yet.",
87
+ emptyLabel: emptyMessage,
60
88
  filterKeys: ["status", "orderType"],
61
89
  defaultSort: sortBy("createdAt", "DESC"),
62
90
  queryKey: ["user", "orders", "listing"],
@@ -82,7 +110,7 @@ export function UserOrdersView({ onOrderClick }) {
82
110
  return (_jsx(Stack, { gap: "md", children: Array.from({ length: 3 }).map((_, i) => (_jsx(Div, { className: "h-24 animate-pulse border border-[var(--appkit-color-border)]", rounded: "xl" }, i))) }));
83
111
  }
84
112
  if (rows.length === 0) {
85
- return _jsx(Text, { color: "muted", children: "You haven't placed any orders yet." });
113
+ return _jsx(Text, { color: "muted", children: emptyMessage });
86
114
  }
87
115
  return (_jsx(Grid, { gap: "md", className: "grid-cols-1", children: rows.map((order) => (_jsx(OrderCard, { order: order, onClick: onOrderClick, renderActions: () => (_jsxs(_Fragment, { children: [_jsx(Button, { asChild: true, variant: "outline", size: "sm", children: _jsx(TextLink, { href: String(ROUTES.USER.ORDER_DETAIL(order.id)), children: ACTIONS.USER["view-order"].label }) }), TRACKABLE_STATUSES.has(order.orderStatus) && (_jsx(Button, { asChild: true, variant: "ghost", size: "sm", children: _jsx(TextLink, { href: String(ROUTES.USER.ORDER_TRACK(order.id)), children: ACTIONS.USER["track-order"].label }) })), CANCELLABLE_STATUSES.has(order.orderStatus) && (_jsx(Button, { asChild: true, variant: "ghost", size: "sm", children: _jsx(TextLink, { href: String(ROUTES.USER.ORDER_CANCEL(order.id)), children: ACTIONS.USER["cancel-order"].label }) }))] })) }, order.id))) }));
88
116
  },
@@ -64,13 +64,28 @@ export declare function useCreateAddress(options?: {
64
64
  /**
65
65
  * Update options.
66
66
  *
67
- * `method` exists because the two portals disagree: `/api/user/addresses/[id]`
68
- * exports PATCH and `/api/store/addresses/[id]` exports PUT, and
69
- * `SellerAddressesView` hardcodes PUT against the latter. Changing the route's
70
- * verb would break that working call site, so the hook bends instead.
67
+ * 🛑 `method` DEFAULTS TO PUT, because both portals take PUT.
71
68
  *
72
- * Caught by `audit-client-verb-match` before the store pages were written —
73
- * this hook would have 405'd the moment they reused it.
69
+ * This defaulted to PATCH on the strength of a comment saying "the two portals
70
+ * disagree: `/api/user/addresses/[id]` exports PATCH and
71
+ * `/api/store/addresses/[id]` exports PUT". Measured against both route files,
72
+ * each exports **GET / PUT / DELETE** and NEITHER exports PATCH — so the
73
+ * premise was stale and the default was the only thing disagreeing with either
74
+ * of them. Every buyer edit sent `PATCH /api/user/addresses/{id}` and took a
75
+ * **405**, surfacing as `ApiClientError: Invalid JSON response`. The form stays
76
+ * on the edit page and the record is unchanged. (`EditAddressClient` does call
77
+ * `showToast("Failed to update address.", "error")` from `onError`; whether the
78
+ * buyer actually sees it was NOT established — every measurement landed after a
79
+ * toast's lifetime, so it is not claimed either way here.)
80
+ *
81
+ * The prop is kept rather than deleted so a portal that genuinely wants PATCH
82
+ * can ask for it, and `SellerAddressesView` keeps passing PUT explicitly.
83
+ *
84
+ * 🛑 That comment also cited `audit-client-verb-match` as proof this could
85
+ * not happen. The audit reports **clean** while the 405 is live: it resolves
86
+ * literal `apiClient.patch("/some/path")` calls, and cannot see a verb chosen
87
+ * at runtime by `method === "PUT" ? apiClient.put(...) : apiClient.patch(...)`.
88
+ * A gate you have not watched fail is not a gate (Root Cause #87).
74
89
  */
75
90
  export interface UpdateAddressOptions {
76
91
  byIdEndpoint?: (id: string) => string;
@@ -46,7 +46,7 @@ export function useCreateAddress(options) {
46
46
  export function useUpdateAddress(id, options) {
47
47
  const queryClient = useQueryClient();
48
48
  const byIdEndpoint = options?.byIdEndpoint ?? DEFAULT_ENDPOINTS.byId;
49
- const method = options?.method ?? "PATCH";
49
+ const method = options?.method ?? "PUT";
50
50
  return useMutation({
51
51
  mutationFn: (data) => method === "PUT"
52
52
  ? apiClient.put(byIdEndpoint(id), data)
@@ -45,7 +45,64 @@ import * as React from "react";
45
45
  import { normalizeError } from "../../../errors/normalize";
46
46
  import { countryFor, isValidPostalCode } from "../../../constants/geo/countries";
47
47
  const ENDPOINT = "https://api.zippopotam.us";
48
+ const INDIA_ENDPOINT = "https://api.postalpincode.in/pincode";
48
49
  const DEBOUNCE_MS = 400;
50
+ /**
51
+ * 🛑 India does not go through Zippopotam, because Zippopotam has no city
52
+ * for an Indian PIN.
53
+ *
54
+ * Its `places[].place name` is the POST OFFICE, and measured against the five
55
+ * largest Indian cities not one of them returned the city:
56
+ *
57
+ * 560001 Bengaluru — "Rajbhavan"
58
+ * 400001 Mumbai — "Haji S Musafarkhana"
59
+ * 110001 Delhi — "Janpath"
60
+ * 700001 Kolkata — "Lalbazar"
61
+ * 452001 Indore — "Indore Jail Road"
62
+ *
63
+ * So the City field was filled with a street or locality on every Indian
64
+ * address, which is worse than leaving it blank: the buyer sees a
65
+ * plausible-looking filled field and has no reason to correct it, and the
66
+ * courier gets an address with no city. This hook's own promise — "typing a
67
+ * zipcode should fill in the city" — was never once kept in the only market
68
+ * this site serves.
69
+ *
70
+ * India Post publishes `District`, which IS the city (Bangalore, Mumbai,
71
+ * Indore). Note it uses the older spellings; that is the authority's own data
72
+ * and not ours to rewrite.
73
+ */
74
+ async function lookupIndia(code, signal) {
75
+ const res = await fetch(`${INDIA_ENDPOINT}/${encodeURIComponent(code)}`, { signal });
76
+ if (!res.ok)
77
+ return null;
78
+ const json = (await res.json());
79
+ const row = json?.[0];
80
+ if (row?.Status !== "Success")
81
+ return null;
82
+ const offices = (row.PostOffice ?? []).filter(Boolean);
83
+ if (offices.length === 0)
84
+ return null;
85
+ /*
86
+ * The MOST COMMON district, not the first. A PIN on a boundary returns post
87
+ * offices from two districts — 400001 lists 'Mumbai' and 'Raigarh(MH)' —
88
+ * and the first row is not reliably the one the buyer means.
89
+ */
90
+ const tally = new Map();
91
+ for (const o of offices) {
92
+ const d = (o.District ?? "").trim();
93
+ if (d)
94
+ tally.set(d, (tally.get(d) ?? 0) + 1);
95
+ }
96
+ let city = "";
97
+ let best = 0;
98
+ for (const [d, n] of tally) {
99
+ if (n > best) {
100
+ city = d;
101
+ best = n;
102
+ }
103
+ }
104
+ return { city, state: (offices[0].State ?? "").trim() };
105
+ }
49
106
  /**
50
107
  * Per-session cache, keyed `{country}:{postal}`.
51
108
  *
@@ -94,18 +151,33 @@ export function usePostalLookup({ onResolved, enabled = true, }) {
94
151
  const controller = new AbortController();
95
152
  abortRef.current = controller;
96
153
  setIsLooking(true);
97
- void fetch(`${ENDPOINT}/${def.code.toLowerCase()}/${encodeURIComponent(code)}`, {
98
- signal: controller.signal,
99
- })
100
- .then((res) => (res.ok ? res.json() : null))
101
- .then((json) => {
102
- const place = json?.places?.[0];
103
- const result = place
104
- ? {
105
- city: place["place name"] ?? "",
106
- state: place.state ?? place["state abbreviation"] ?? "",
107
- }
108
- : null;
154
+ /*
155
+ * India has its own source — see `lookupIndia`. Everywhere else keeps
156
+ * Zippopotam, whose "place name" IS the city outside India.
157
+ */
158
+ const request = def.code.toUpperCase() === "IN"
159
+ ? lookupIndia(code, controller.signal)
160
+ : fetch(`${ENDPOINT}/${def.code.toLowerCase()}/${encodeURIComponent(code)}`, {
161
+ signal: controller.signal,
162
+ })
163
+ .then((res) => (res.ok ? res.json() : null))
164
+ .then((json) => {
165
+ const place = json?.places?.[0];
166
+ return place
167
+ ? {
168
+ city: place["place name"] ?? "",
169
+ state: place.state ?? place["state abbreviation"] ?? "",
170
+ }
171
+ : null;
172
+ });
173
+ void request
174
+ .then((resolved) => {
175
+ /*
176
+ * A result with neither field is a miss, not a hit — handing the
177
+ * form two empty strings would clear nothing and resolve nothing,
178
+ * while caching it as success hides a real answer for the session.
179
+ */
180
+ const result = resolved && (resolved.city || resolved.state) ? resolved : null;
109
181
  CACHE.set(key, result);
110
182
  if (result)
111
183
  onResolvedRef.current(result);
@@ -65,10 +65,45 @@ const ADMIN_CATEGORIES_CONFIG = {
65
65
  columns: COLUMNS,
66
66
  mapRows: (response) => {
67
67
  const sourceItems = extractCategoryRows(response);
68
- return toRecordArray(sourceItems).map((item, index) => ({
68
+ const rows = toRecordArray(sourceItems);
69
+ /*
70
+ * 🛑 THE PARENT COLUMN READ A FIELD THAT DOES NOT EXIST.
71
+ *
72
+ * It was `toStringValue(item.parentId, "root")`, and `CategoryDocument` has
73
+ * no `parentId` — the field is **`parentIds: string[]`**, the full ancestor
74
+ * chain. So the fallback fired on every row: measured on the live list, all
75
+ * **100** categories displayed "Parent: root" while their tiers ranged 0-4
76
+ * and only 22 were actually roots. The tier beside it renders correctly from
77
+ * the same document, which is what makes the wrong value so easy to trust.
78
+ *
79
+ * CLAUDE.md flags this exact confusion in the Seed Data Reference ("NOT a
80
+ * single `parentId`"), and `parentId` does still exist — as an optional
81
+ * field on a create/update INPUT shape, which is why it typechecks here and
82
+ * is undefined at runtime.
83
+ *
84
+ * `parentIds` is ordered ancestors-nearest-LAST ("matching how parentIds is
85
+ * read elsewhere", category-tree.ts), so the immediate parent is the final
86
+ * element. The name is shown where the list already knows it: every
87
+ * category is in this same response, so the lookup costs nothing and an id
88
+ * is a poor answer to "what is this filed under".
89
+ */
90
+ const nameById = new Map();
91
+ for (const r of rows) {
92
+ const id = toStringValue(r.id, "");
93
+ if (id)
94
+ nameById.set(id, toStringValue(r.name, id));
95
+ }
96
+ const parentLabel = (item) => {
97
+ const chain = Array.isArray(item.parentIds) ? item.parentIds : [];
98
+ const immediate = chain.length ? String(chain[chain.length - 1]) : "";
99
+ if (!immediate)
100
+ return "root";
101
+ return nameById.get(immediate) ?? immediate;
102
+ };
103
+ return rows.map((item, index) => ({
69
104
  id: toStringValue(item.id, `category-${index}`),
70
105
  primary: toStringValue(item.name, "Untitled category"),
71
- secondary: `Slug: ${toStringValue(item.slug, "no-slug")} · Tier: ${typeof item.tier === "number" ? String(item.tier) : "-"} · Parent: ${toStringValue(item.parentId, "root")}`,
106
+ secondary: `Slug: ${toStringValue(item.slug, "no-slug")} · Tier: ${typeof item.tier === "number" ? String(item.tier) : "-"} · Parent: ${parentLabel(item)}`,
72
107
  status: typeof item.isActive === "boolean"
73
108
  ? item.isActive
74
109
  ? "Active"
@@ -36,7 +36,7 @@ import React, { useState } from "react";
36
36
  import { useQueryClient } from "@tanstack/react-query";
37
37
  import { FilterChipGroup, ListingLayout, RecordDetailModal, RowActionMenu, useToast, } from "../../../ui";
38
38
  import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints";
39
- import { ADMIN_OFFER_STATUS_TABS } from "../constants/filter-tabs";
39
+ import { ADMIN_OFFER_STATUS_TABS, offerStatusLabel } from "../constants/filter-tabs";
40
40
  import { OfferPhaseTimeline } from "../../seller/components/OfferPhaseTimeline";
41
41
  import { QuickFormDrawer } from "../../shell/QuickFormDrawer";
42
42
  import { cancelOfferFormSchema } from "../../seller/schemas/offer-forms";
@@ -136,7 +136,7 @@ export function AdminOffersView({ children, ...props }) {
136
136
  `Listed: ${toCurrency(item.listedPrice)}`,
137
137
  `${toStringValue(item.buyerName, "Unknown buyer")} → ${toStringValue(item.storeName, "Unknown store")}`,
138
138
  ].join(" · "),
139
- status: toStringValue(item.status, "pending"),
139
+ status: offerStatusLabel(toStringValue(item.status, "pending"), "admin"),
140
140
  updatedAt: toRelativeDate(item.updatedAt ?? item.createdAt),
141
141
  detail: item,
142
142
  })),
@@ -79,7 +79,33 @@ export function AdminSupportTicketsView({ children, ...props }) {
79
79
  { value: sortBy("updatedAt", "DESC"), label: "Recently updated" },
80
80
  ],
81
81
  columns: TICKET_COLUMNS,
82
- mapRows: (response) => toRecordArray(response.tickets).map((item, index) => ({
82
+ /*
83
+ * 🛑 The item array is `items`. This read `response.tickets`, a key the
84
+ * route does not emit, so EVERY admin saw "No support tickets found".
85
+ *
86
+ * `GET /api/admin/support-tickets` ends in `successResponse(result)` where
87
+ * `result` is `supportRepository.listAll(model)` — a PagedResult, i.e.
88
+ * `{ items, total, page, pageSize, totalPages, hasMore }`. Measured against
89
+ * production: the API returns **total 10** while this list rendered its
90
+ * empty state, so nobody on staff could see a single support ticket.
91
+ *
92
+ * 🛑 I NEARLY MISSED THIS, and the reason is worth recording. The same
93
+ * defect was fixed on the BUYER side (`/user/support`) minutes earlier, and
94
+ * the sweep I ran afterwards searched for the `ok`-vs-`success` envelope
95
+ * flag — not for item-array key misreads — so it reported "no further
96
+ * copies" while this one sat one route away. A sweep narrower than the rule
97
+ * it is checking finds what it was shaped to find (Root Cause #84).
98
+ *
99
+ * A grep of the route then said it emitted `tickets`; the live API said
100
+ * `items`. The grep had matched the word elsewhere in the file. The
101
+ * MEASUREMENT is what settled it, which is the same lesson again.
102
+ *
103
+ * `tickets` is kept in the chain rather than deleted, for the same reason as
104
+ * the buyer-side fix: both spellings demonstrably exist in this codebase,
105
+ * and reading one key too many costs nothing while reading the wrong one
106
+ * cost the entire staff-facing queue.
107
+ */
108
+ mapRows: (response) => toRecordArray(response.items ?? response.tickets).map((item, index) => ({
83
109
  id: toStringValue(item.id, `ticket-${index}`),
84
110
  primary: toStringValue(item.subject, "No subject"),
85
111
  secondary: [
@@ -650,6 +650,31 @@ export declare const SELLER_OFFER_STATUS_TABS: readonly [{
650
650
  readonly id: "paid";
651
651
  readonly label: "Paid";
652
652
  }];
653
+ /**
654
+ * The display label for an offer status — the SAME words as that audience's
655
+ * own filter chips.
656
+ *
657
+ * 🛑 Both offer views rendered the raw stored value as the badge, so a seller
658
+ * filtered by a chip reading **"Rejected"** and the matching rows came back
659
+ * badged **"declined"** — two words for one state, on one screen. Every other
660
+ * badge was lowercase too ("pending", "accepted") while the fallback beside it
661
+ * was capitalised `"Pending"`, which is the tell that display-cased values were
662
+ * always the intent.
663
+ *
664
+ * 🛑 **`audience` is not ceremony — the two chip sets DISAGREE on purpose.**
665
+ * `declined` is "Rejected" to a seller (see the header above: kept for
666
+ * continuity with existing seller-facing wording) and "Declined" to an admin.
667
+ * A single shared lookup would therefore have printed "Rejected" on the admin
668
+ * screen beside its own "Declined" chip — reintroducing the exact mismatch
669
+ * this function exists to remove, one audience over.
670
+ *
671
+ * Deriving from the arrays rather than writing a third map is the point: a
672
+ * hand-written copy is how ten enumerations of one union came to disagree
673
+ * (Root Cause #61). An unrecognised value falls through to itself, so an
674
+ * eighth status shows up as its raw name rather than vanishing (Root Cause
675
+ * #72 — unknown must never render as absent).
676
+ */
677
+ export declare function offerStatusLabel(status: string | null | undefined, audience: "seller" | "admin"): string;
653
678
  /** Seller > Bids — bid-state filter chip set. Uses the empty-sentinel
654
679
  * variant (see `EMPTY_TAB`). */
655
680
  export declare const SELLER_BID_STATUS_TABS: readonly [{
@@ -360,6 +360,36 @@ export const SELLER_OFFER_STATUS_TABS = [
360
360
  { id: "withdrawn", label: "Withdrawn" },
361
361
  { id: "paid", label: "Paid" },
362
362
  ];
363
+ /**
364
+ * The display label for an offer status — the SAME words as that audience's
365
+ * own filter chips.
366
+ *
367
+ * 🛑 Both offer views rendered the raw stored value as the badge, so a seller
368
+ * filtered by a chip reading **"Rejected"** and the matching rows came back
369
+ * badged **"declined"** — two words for one state, on one screen. Every other
370
+ * badge was lowercase too ("pending", "accepted") while the fallback beside it
371
+ * was capitalised `"Pending"`, which is the tell that display-cased values were
372
+ * always the intent.
373
+ *
374
+ * 🛑 **`audience` is not ceremony — the two chip sets DISAGREE on purpose.**
375
+ * `declined` is "Rejected" to a seller (see the header above: kept for
376
+ * continuity with existing seller-facing wording) and "Declined" to an admin.
377
+ * A single shared lookup would therefore have printed "Rejected" on the admin
378
+ * screen beside its own "Declined" chip — reintroducing the exact mismatch
379
+ * this function exists to remove, one audience over.
380
+ *
381
+ * Deriving from the arrays rather than writing a third map is the point: a
382
+ * hand-written copy is how ten enumerations of one union came to disagree
383
+ * (Root Cause #61). An unrecognised value falls through to itself, so an
384
+ * eighth status shows up as its raw name rather than vanishing (Root Cause
385
+ * #72 — unknown must never render as absent).
386
+ */
387
+ export function offerStatusLabel(status, audience) {
388
+ if (!status)
389
+ return "Pending";
390
+ const tabs = audience === "admin" ? ADMIN_OFFER_STATUS_TABS : SELLER_OFFER_STATUS_TABS;
391
+ return tabs.find((t) => t.id === status)?.label ?? status;
392
+ }
363
393
  /** Seller > Bids — bid-state filter chip set. Uses the empty-sentinel
364
394
  * variant (see `EMPTY_TAB`). */
365
395
  export const SELLER_BID_STATUS_TABS = [
@@ -41,5 +41,40 @@ export declare function useAdminListingData<TResponse, TRow extends {
41
41
  export declare function toRecordArray(value: unknown): ListingItemRecord[];
42
42
  export declare function toStringValue(value: unknown, fallback?: string): string;
43
43
  export declare function toCurrency(value: unknown): string;
44
+ /**
45
+ * A timestamp as human-relative text — **in either direction**.
46
+ *
47
+ * 🛑 **Every FUTURE date used to render as "1m ago".** `deltaMs` is
48
+ * `now - date`, which is negative ahead of now; `deltaMs < hour` then matched,
49
+ * and `Math.max(1, Math.floor(negative))` clamped the result to exactly `1`.
50
+ * So a deadline 48 hours away and one 5 minutes away both read "1m ago", and
51
+ * the clamp is what hid it — without it the string would have been
52
+ * "-2880m ago", which nobody could have mistaken for working.
53
+ *
54
+ * Measured live on `/store/offers` as tyson@beybladearena.in: an offer accepted
55
+ * seconds earlier showed **"Buyer must pay by 1m ago"** against a real
56
+ * `checkoutDeadline` of `now + 48h` (`OFFER_CHECKOUT_WINDOW_MS`). The data was
57
+ * correct and the sentence told the seller — and on the admin view, the admin —
58
+ * that a buyer had already missed a window that had barely opened. On the
59
+ * buyer's own side that reads as "too late to pay for the thing you just
60
+ * negotiated", i.e. an abandoned purchase caused entirely by a formatter.
61
+ *
62
+ * Five call sites render a future-capable field today (offer `expiresAt` and
63
+ * `checkoutDeadline` on both the seller and admin views, and session
64
+ * `expiresAt`), and any of the other 61 becomes one the moment its field can
65
+ * hold a future date — which is why this is fixed here rather than at the call
66
+ * sites.
67
+ *
68
+ * The `Math.max(1, …)` floors stay for the PAST direction: they keep a
69
+ * 40-second-old row reading "1m ago" instead of "0m ago". They are applied to
70
+ * an absolute magnitude now, so they can no longer mask a sign error.
71
+ *
72
+ * NOTE ON ITS HOME: a pure date formatter belongs in `ui/` or
73
+ * `_internal/client/`, not in an admin hook module that 44 files reach into.
74
+ * Moving it means rewriting all 44 imports, so it stays put for now and the
75
+ * seller-side duplicate was deleted instead (see `useSellerListingData.ts`) —
76
+ * that copy had already drifted, returning "just now" for the same future date
77
+ * this one called "1m ago". Two answers to one question is the whole argument.
78
+ */
44
79
  export declare function toRelativeDate(value: unknown): string;
45
80
  export {};