@mohasinac/appkit 4.42.1 → 4.42.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) 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/constants/filter-tabs.d.ts +25 -0
  13. package/dist/features/admin/constants/filter-tabs.js +30 -0
  14. package/dist/features/admin/hooks/useAdminListingData.d.ts +35 -0
  15. package/dist/features/admin/hooks/useAdminListingData.js +45 -9
  16. package/dist/features/cart/schemas/index.d.ts +15 -0
  17. package/dist/features/cart/schemas/index.js +2 -0
  18. package/dist/features/cart/types/index.d.ts +22 -0
  19. package/dist/features/categories/components/CategoriesIndexListing.js +19 -1
  20. package/dist/features/categories/components/CategoriesIndexPageView.js +14 -1
  21. package/dist/features/classified/components/ClassifiedDetailPageView.d.ts +8 -0
  22. package/dist/features/classified/components/ClassifiedDetailPageView.js +1 -0
  23. package/dist/features/events/actions/event-actions.d.ts +13 -0
  24. package/dist/features/events/actions/event-actions.js +75 -1
  25. package/dist/features/events/repository/event-entry.repository.d.ts +17 -0
  26. package/dist/features/events/repository/event-entry.repository.js +36 -0
  27. package/dist/features/homepage/components/BrandsSection.js +1 -1
  28. package/dist/features/homepage/components/CustomerReviewsSection.js +1 -1
  29. package/dist/features/homepage/components/FeaturedBundlesSection.js +1 -1
  30. package/dist/features/homepage/components/ShopByCategorySection.js +1 -1
  31. package/dist/features/media/upload/MediaUploadField.js +21 -3
  32. package/dist/features/orders/components/OrdersList.js +2 -1
  33. package/dist/features/orders/index.d.ts +1 -0
  34. package/dist/features/orders/index.js +1 -0
  35. package/dist/features/orders/types/index.d.ts +2 -0
  36. package/dist/features/orders/utils/order-ref.d.ts +45 -0
  37. package/dist/features/orders/utils/order-ref.js +58 -0
  38. package/dist/features/products/components/MakeOfferButton.d.ts +18 -1
  39. package/dist/features/products/components/MakeOfferButton.js +23 -1
  40. package/dist/features/products/components/ProductDetailPageView.d.ts +5 -0
  41. package/dist/features/products/components/ProductDetailPageView.js +3 -2
  42. package/dist/features/products/repository/products.repository.js +29 -2
  43. package/dist/features/seller/actions/offer-actions.js +32 -0
  44. package/dist/features/seller/components/QuickProductForm.js +15 -1
  45. package/dist/features/seller/components/SellerBidsView.js +3 -1
  46. package/dist/features/seller/components/SellerOffersView.js +2 -2
  47. package/dist/features/seller/components/SellerOrdersView.js +49 -6
  48. package/dist/features/seller/components/SellerProductsCards.js +23 -1
  49. package/dist/features/seller/components/SellerProductsView.js +27 -2
  50. package/dist/features/seller/hooks/useSellerListingData.d.ts +0 -1
  51. package/dist/features/seller/hooks/useSellerListingData.js +17 -19
  52. package/dist/features/shell/SectionForm.js +33 -2
  53. package/dist/features/tester/seed-data/_happy-path.d.ts +23 -0
  54. package/dist/features/tester/seed-data/_happy-path.js +710 -0
  55. package/dist/features/tester/seed-data/_money-flows.js +34 -23
  56. package/dist/features/tester/seed-data/authored/account-auth__profile-settings.js +22 -3
  57. package/dist/features/tester/seed-data/authored/addresses__address-filters.js +17 -12
  58. package/dist/features/tester/seed-data/authored/addresses__postal-lookup.js +7 -6
  59. package/dist/features/tester/seed-data/authored/addresses__postal-validation.js +10 -4
  60. package/dist/features/tester/seed-data/authored/addresses__state-picker.js +13 -8
  61. package/dist/features/tester/seed-data/authored/addresses__unban-request.js +49 -11
  62. package/dist/features/tester/seed-data/authored/admin__admin-detail-round-trips.js +10 -6
  63. package/dist/features/tester/seed-data/authored/admin__bans-and-trust.js +71 -32
  64. package/dist/features/tester/seed-data/authored/admin__blog-faqs.js +80 -31
  65. package/dist/features/tester/seed-data/authored/admin__bug-hunter-rewards.js +62 -25
  66. package/dist/features/tester/seed-data/authored/admin__bulk-actions.js +61 -31
  67. package/dist/features/tester/seed-data/authored/admin__bundles.js +84 -37
  68. package/dist/features/tester/seed-data/authored/admin__buyer-data-admin.js +90 -57
  69. package/dist/features/tester/seed-data/authored/admin__catalog-listings.js +38 -26
  70. package/dist/features/tester/seed-data/authored/admin__category-brand-authoring.js +47 -28
  71. package/dist/features/tester/seed-data/authored/admin__classifieds-digitalcodes-live.js +118 -31
  72. package/dist/features/tester/seed-data/authored/admin__content-deletes.js +30 -24
  73. package/dist/features/tester/seed-data/authored/admin__content-marketing.js +83 -46
  74. package/dist/features/tester/seed-data/authored/admin__coupons.js +8 -8
  75. package/dist/features/tester/seed-data/authored/admin__events-raffles-spin.js +9 -9
  76. package/dist/features/tester/seed-data/authored/admin__firebase-function-effects.js +19 -4
  77. package/dist/features/tester/seed-data/authored/admin__media-watermark.js +77 -33
  78. package/dist/features/tester/seed-data/authored/admin__orders-fulfillment.js +5 -5
  79. package/dist/features/tester/seed-data/authored/admin__prize-draws-lotteries.js +29 -5
  80. package/dist/features/tester/seed-data/authored/admin__site-system.js +45 -23
  81. package/dist/features/tester/seed-data/authored/admin__uncovered-admin-pages.js +53 -28
  82. package/dist/features/tester/seed-data/authored/admin__users-trust.js +20 -20
  83. package/dist/features/tester/seed-data/authored/buying__bidding.js +19 -2
  84. package/dist/features/tester/seed-data/authored/buying__browsing-search.js +20 -5
  85. package/dist/features/tester/seed-data/authored/buying__buying-checkout.js +28 -10
  86. package/dist/features/tester/seed-data/authored/buying__cart.js +10 -3
  87. package/dist/features/tester/seed-data/authored/buying__image-tile-layout.js +0 -16
  88. package/dist/features/tester/seed-data/authored/buying__my-orders.js +27 -5
  89. package/dist/features/tester/seed-data/authored/buying__offers.js +22 -7
  90. package/dist/features/tester/seed-data/authored/buying__order-detail-actions.js +3 -3
  91. package/dist/features/tester/seed-data/authored/buying__product-detail.js +35 -4
  92. package/dist/features/tester/seed-data/authored/buying__return-request.js +19 -6
  93. package/dist/features/tester/seed-data/authored/buying__reviews-pagination.js +36 -18
  94. package/dist/features/tester/seed-data/authored/buying__reviews.js +16 -1
  95. package/dist/features/tester/seed-data/authored/buying__user-dashboard-navigation.js +2 -1
  96. package/dist/features/tester/seed-data/authored/buying__user-uncovered-pages.js +16 -1
  97. package/dist/features/tester/seed-data/authored/community-support__public-profile.js +3 -2
  98. package/dist/features/tester/seed-data/authored/community-support__support-tickets.js +4 -4
  99. package/dist/features/tester/seed-data/authored/content-discovery__blog.js +19 -2
  100. package/dist/features/tester/seed-data/authored/content-discovery__category-brand-relations.js +4 -4
  101. package/dist/features/tester/seed-data/authored/content-discovery__category-counts-and-rollup.js +5 -5
  102. package/dist/features/tester/seed-data/authored/content-discovery__coupons.js +41 -6
  103. package/dist/features/tester/seed-data/authored/content-discovery__event-detail-subroutes.js +63 -8
  104. package/dist/features/tester/seed-data/authored/content-discovery__event-participation.js +29 -29
  105. package/dist/features/tester/seed-data/authored/content-discovery__events.js +2 -2
  106. package/dist/features/tester/seed-data/authored/content-discovery__faq-help.js +23 -1
  107. package/dist/features/tester/seed-data/authored/content-discovery__notifications.js +6 -6
  108. package/dist/features/tester/seed-data/authored/content-discovery__search.js +1 -1
  109. package/dist/features/tester/seed-data/authored/content-discovery__store-reviews-aggregate.js +1 -1
  110. package/dist/features/tester/seed-data/authored/cta-layout__checkout-bottom-bar.js +31 -6
  111. package/dist/features/tester/seed-data/authored/cta-layout__dialog-footers.js +34 -10
  112. package/dist/features/tester/seed-data/authored/cta-layout__editor-action-bar.js +35 -8
  113. package/dist/features/tester/seed-data/authored/cta-layout__navbar-ctas.js +31 -9
  114. package/dist/features/tester/seed-data/authored/cta-layout__product-bottom-bar.js +29 -3
  115. package/dist/features/tester/seed-data/authored/design-ux__back-to-top-button.js +1 -1
  116. package/dist/features/tester/seed-data/authored/design-ux__carousel-arrow-bounds.js +10 -10
  117. package/dist/features/tester/seed-data/authored/design-ux__dashboard-layout.js +3 -3
  118. package/dist/features/tester/seed-data/authored/design-ux__footer-theme.js +2 -2
  119. package/dist/features/tester/seed-data/authored/design-ux__form-validation-errors.js +2 -2
  120. package/dist/features/tester/seed-data/authored/design-ux__general-design.js +4 -4
  121. package/dist/features/tester/seed-data/authored/design-ux__hand-mode-layout.js +1 -1
  122. package/dist/features/tester/seed-data/authored/design-ux__homepage-carousels.js +8 -8
  123. package/dist/features/tester/seed-data/authored/design-ux__status-badge-legibility.js +2 -2
  124. package/dist/features/tester/seed-data/authored/design-ux__sticky-cta-bar.js +2 -2
  125. package/dist/features/tester/seed-data/authored/page-wiring__data-loss.js +39 -16
  126. package/dist/features/tester/seed-data/authored/page-wiring__detail-pages.js +43 -8
  127. package/dist/features/tester/seed-data/authored/page-wiring__drawer-pages.js +40 -14
  128. package/dist/features/tester/seed-data/authored/page-wiring__reachability.js +31 -14
  129. package/dist/features/tester/seed-data/authored/public-pages__auth-error-pages.js +7 -6
  130. package/dist/features/tester/seed-data/authored/public-pages__bug-hunters.js +14 -5
  131. package/dist/features/tester/seed-data/authored/public-pages__core-listing-pages.js +3 -3
  132. package/dist/features/tester/seed-data/authored/public-pages__help-how-it-works.js +1 -1
  133. package/dist/features/tester/seed-data/authored/public-pages__help-scams-guides-subpages.js +4 -2
  134. package/dist/features/tester/seed-data/authored/public-pages__legal-policy-pages.js +3 -3
  135. package/dist/features/tester/seed-data/authored/public-pages__newly-wired-browse-indexes.js +5 -5
  136. package/dist/features/tester/seed-data/authored/public-pages__store-tabs.js +5 -5
  137. package/dist/features/tester/seed-data/authored/public-pages__stores-sellers-directories.js +3 -3
  138. package/dist/features/tester/seed-data/authored/search-and-nav__employee-permissions.js +45 -11
  139. package/dist/features/tester/seed-data/authored/search-and-nav__header-search.js +57 -17
  140. package/dist/features/tester/seed-data/authored/search-and-nav__settings-deep-links.js +36 -5
  141. package/dist/features/tester/seed-data/authored/search-and-nav__sidebar-search.js +29 -4
  142. package/dist/features/tester/seed-data/authored/selling__become-seller.js +23 -0
  143. package/dist/features/tester/seed-data/authored/selling__final-sale-authoring.js +14 -13
  144. package/dist/features/tester/seed-data/authored/selling__listing-a-product.js +14 -9
  145. package/dist/features/tester/seed-data/authored/selling__listing-edit-roundtrip.js +34 -7
  146. package/dist/features/tester/seed-data/authored/selling__listing-lifecycle.js +17 -7
  147. package/dist/features/tester/seed-data/authored/selling__listing-type-fields-roundtrip.js +9 -9
  148. package/dist/features/tester/seed-data/authored/selling__media-limits.js +9 -4
  149. package/dist/features/tester/seed-data/authored/selling__product-upload-details.js +3 -3
  150. package/dist/features/tester/seed-data/authored/selling__quick-add-minimum-details.js +3 -3
  151. package/dist/features/tester/seed-data/authored/selling__sectionised-forms.js +25 -1
  152. package/dist/features/tester/seed-data/authored/selling__seller-analytics-payouts.js +27 -6
  153. package/dist/features/tester/seed-data/authored/selling__seller-bids-bundles-filters.js +19 -1
  154. package/dist/features/tester/seed-data/authored/selling__seller-catalog-org.js +63 -22
  155. package/dist/features/tester/seed-data/authored/selling__seller-custom-brands.js +9 -5
  156. package/dist/features/tester/seed-data/authored/selling__seller-guide.js +7 -6
  157. package/dist/features/tester/seed-data/authored/selling__seller-listing-types.js +3 -2
  158. package/dist/features/tester/seed-data/authored/selling__seller-marketing-extras.js +18 -18
  159. package/dist/features/tester/seed-data/authored/selling__seller-ops-comms.js +13 -12
  160. package/dist/features/tester/seed-data/authored/selling__seller-orders-at-scale.js +19 -3
  161. package/dist/features/tester/seed-data/authored/selling__seller-orders.js +22 -4
  162. package/dist/features/tester/seed-data/authored/selling__seller-shipping-payouts-setup.js +20 -8
  163. package/dist/features/tester/seed-data/authored/selling__store-dashboard-navigation.js +4 -4
  164. package/dist/features/tester/seed-data/authored/selling__store-uncovered-pages.js +2 -2
  165. package/dist/features/tester/seed-data/authored/seo__canonical-and-host.js +5 -5
  166. package/dist/features/tester/seed-data/authored/seo__og-images.js +1 -1
  167. package/dist/features/tester/seed-data/authored/seo__page-metadata.js +7 -7
  168. package/dist/features/tester/seed-data/authored/seo__sitemap-and-robots.js +3 -3
  169. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +123 -21
  170. package/dist/features/tester/utils/phases.d.ts +17 -0
  171. package/dist/features/tester/utils/phases.js +29 -2
  172. package/dist/index.d.ts +1 -0
  173. package/dist/index.js +2 -0
  174. package/dist/seo/json-ld.d.ts +23 -0
  175. package/dist/seo/json-ld.js +3 -2
  176. package/dist/server.d.ts +1 -0
  177. package/dist/server.js +2 -0
  178. package/dist/styles.css +1 -1
  179. package/dist/tailwind-utilities.css +1 -1
  180. package/dist/ui/components/PaginatedSelect.js +0 -0
  181. package/package.json +1 -1
@@ -5,17 +5,20 @@ export declare const cartItemMetaSchema: z.ZodObject<{
5
5
  price: z.ZodNumber;
6
6
  currency: z.ZodDefault<z.ZodString>;
7
7
  slug: z.ZodOptional<z.ZodString>;
8
+ storeName: z.ZodOptional<z.ZodString>;
8
9
  attributes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
9
10
  }, "strip", z.ZodTypeAny, {
10
11
  currency: string;
11
12
  title: string;
12
13
  price: number;
14
+ storeName?: string | undefined;
13
15
  slug?: string | undefined;
14
16
  attributes?: Record<string, string> | undefined;
15
17
  }, {
16
18
  title: string;
17
19
  price: number;
18
20
  currency?: string | undefined;
21
+ storeName?: string | undefined;
19
22
  slug?: string | undefined;
20
23
  attributes?: Record<string, string> | undefined;
21
24
  }>;
@@ -40,17 +43,20 @@ export declare const cartItemSchema: z.ZodObject<{
40
43
  price: z.ZodNumber;
41
44
  currency: z.ZodDefault<z.ZodString>;
42
45
  slug: z.ZodOptional<z.ZodString>;
46
+ storeName: z.ZodOptional<z.ZodString>;
43
47
  attributes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
44
48
  }, "strip", z.ZodTypeAny, {
45
49
  currency: string;
46
50
  title: string;
47
51
  price: number;
52
+ storeName?: string | undefined;
48
53
  slug?: string | undefined;
49
54
  attributes?: Record<string, string> | undefined;
50
55
  }, {
51
56
  title: string;
52
57
  price: number;
53
58
  currency?: string | undefined;
59
+ storeName?: string | undefined;
54
60
  slug?: string | undefined;
55
61
  attributes?: Record<string, string> | undefined;
56
62
  }>>;
@@ -63,6 +69,7 @@ export declare const cartItemSchema: z.ZodObject<{
63
69
  currency: string;
64
70
  title: string;
65
71
  price: number;
72
+ storeName?: string | undefined;
66
73
  slug?: string | undefined;
67
74
  attributes?: Record<string, string> | undefined;
68
75
  } | undefined;
@@ -76,6 +83,7 @@ export declare const cartItemSchema: z.ZodObject<{
76
83
  title: string;
77
84
  price: number;
78
85
  currency?: string | undefined;
86
+ storeName?: string | undefined;
79
87
  slug?: string | undefined;
80
88
  attributes?: Record<string, string> | undefined;
81
89
  } | undefined;
@@ -96,17 +104,20 @@ export declare const cartSummarySchema: z.ZodObject<{
96
104
  price: z.ZodNumber;
97
105
  currency: z.ZodDefault<z.ZodString>;
98
106
  slug: z.ZodOptional<z.ZodString>;
107
+ storeName: z.ZodOptional<z.ZodString>;
99
108
  attributes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
100
109
  }, "strip", z.ZodTypeAny, {
101
110
  currency: string;
102
111
  title: string;
103
112
  price: number;
113
+ storeName?: string | undefined;
104
114
  slug?: string | undefined;
105
115
  attributes?: Record<string, string> | undefined;
106
116
  }, {
107
117
  title: string;
108
118
  price: number;
109
119
  currency?: string | undefined;
120
+ storeName?: string | undefined;
110
121
  slug?: string | undefined;
111
122
  attributes?: Record<string, string> | undefined;
112
123
  }>>;
@@ -119,6 +130,7 @@ export declare const cartSummarySchema: z.ZodObject<{
119
130
  currency: string;
120
131
  title: string;
121
132
  price: number;
133
+ storeName?: string | undefined;
122
134
  slug?: string | undefined;
123
135
  attributes?: Record<string, string> | undefined;
124
136
  } | undefined;
@@ -132,6 +144,7 @@ export declare const cartSummarySchema: z.ZodObject<{
132
144
  title: string;
133
145
  price: number;
134
146
  currency?: string | undefined;
147
+ storeName?: string | undefined;
135
148
  slug?: string | undefined;
136
149
  attributes?: Record<string, string> | undefined;
137
150
  } | undefined;
@@ -153,6 +166,7 @@ export declare const cartSummarySchema: z.ZodObject<{
153
166
  currency: string;
154
167
  title: string;
155
168
  price: number;
169
+ storeName?: string | undefined;
156
170
  slug?: string | undefined;
157
171
  attributes?: Record<string, string> | undefined;
158
172
  } | undefined;
@@ -170,6 +184,7 @@ export declare const cartSummarySchema: z.ZodObject<{
170
184
  title: string;
171
185
  price: number;
172
186
  currency?: string | undefined;
187
+ storeName?: string | undefined;
173
188
  slug?: string | undefined;
174
189
  attributes?: Record<string, string> | undefined;
175
190
  } | undefined;
@@ -6,6 +6,8 @@ export const cartItemMetaSchema = z.object({
6
6
  price: z.number(),
7
7
  currency: z.string().default(getDefaultCurrency()),
8
8
  slug: z.string().optional(),
9
+ /* Named field, not an `attributes` entry — see CartItemMeta.storeName. */
10
+ storeName: z.string().optional(),
9
11
  attributes: z.record(z.string(), z.string()).optional(),
10
12
  });
11
13
  /**
@@ -1,11 +1,33 @@
1
1
  export interface CartItemMeta {
2
2
  productId: string;
3
3
  storeId?: string;
4
+ /**
5
+ * The seller's display name, for grouping lines by seller and for the cart's
6
+ * own search box.
7
+ *
8
+ * 🛑 It lives here and NOT in `attributes`, which is rendered verbatim.
9
+ * `CartItemRow` prints every attribute entry as `` `${key}: ${value}` ``, so
10
+ * putting the store name in that bag printed the literal string
11
+ * `storeName: Beyblade Arena` under the product title on `/cart` — a field
12
+ * name shown to a buyer on the highest-intent page in the funnel, one line
13
+ * below the same value already rendered properly as `SOLD BY BEYBLADE ARENA`.
14
+ */
15
+ storeName?: string;
4
16
  title: string;
5
17
  image?: string;
6
18
  price: number;
7
19
  currency?: string;
8
20
  slug?: string;
21
+ /**
22
+ * Genuine per-line product attributes — the kind a buyer needs in order to
23
+ * tell two lines of the same product apart (size, colour, edition).
24
+ *
25
+ * 🛑 DISPLAY ONLY. Every entry is rendered to the buyer as
26
+ * `` `${key}: ${value}` ``, joined with commas. Never use this to carry
27
+ * plumbing a component needs: a field put here to be *read* is also a field
28
+ * printed with its own name, and the leak looks like a debug string rather
29
+ * than like a bug. Add a named field to this interface instead.
30
+ */
9
31
  attributes?: Record<string, string>;
10
32
  }
11
33
  export interface CartItem {
@@ -38,9 +38,27 @@ function renderCategoryFilterDrawer(props) {
38
38
  function getNumericParam(table, key) {
39
39
  return table.get(key) ? Number(table.get(key)) : undefined;
40
40
  }
41
- const DEFAULT_SORT = sortBy(CATEGORY_FIELDS.NAME, "ASC");
41
+ /*
42
+ * 🛑 TIER FIRST, NOT NAME. Found by
43
+ * checklist-happy-path-guest-browse-categories-index-lists-roots, 2026-09-29.
44
+ *
45
+ * This was `name ASC`, which sorts 47 categories alphabetically with no
46
+ * awareness of the tree — so BOTH roots fell off page 1. "Spinning Tops" sorts
47
+ * under S onto page 3 and "Living Collectibles" under L onto page 2, while
48
+ * their own descendants filled page 1: Bits, Blades, Discs, Drivers, Energy
49
+ * Rings. A visitor opening /categories met component-level leaves, eight of
50
+ * them reading "0 items", and never saw either root.
51
+ *
52
+ * `tier` is already `canSort: true` in the repository's SIEVE_FIELDS, and a
53
+ * single-field sort needs no composite index, so this costs nothing to deploy.
54
+ * Deliberately NOT `tier ASC, name ASC`: that pairing has no
55
+ * `(tier, name)` index and a missing composite fails as an empty page rather
56
+ * than an error (Root Cause #59).
57
+ */
58
+ const DEFAULT_SORT = sortBy(CATEGORY_FIELDS.TIER, "ASC");
42
59
  const FILTER_KEYS = [TABLE_KEYS.IS_FEATURED, "isBrand", "rootOnly", "tier", "minItemCount", "maxItemCount"];
43
60
  const SORT_OPTIONS = [
61
+ { value: sortBy(CATEGORY_FIELDS.TIER, "ASC"), label: "Top level first" },
44
62
  { value: sortBy(CATEGORY_FIELDS.NAME, "ASC"), label: "Name A–Z" },
45
63
  { value: sortBy(CATEGORY_FIELDS.NAME), label: "Name Z–A" },
46
64
  { value: sortBy("metrics.productCount", "DESC"), label: "Most Products" },
@@ -1,5 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { sieveFilter, SIEVE_OP } from "@mohasinac/appkit";
3
+ import { sortBy } from "../../../constants/sort";
4
+ import { CATEGORY_FIELDS } from "../../../constants/field-names";
3
5
  import { categoriesRepository } from "../../../repositories";
4
6
  import { Container, Heading, Main, Section } from "../../../ui";
5
7
  import { AdSlot } from "../../homepage/components/AdSlot";
@@ -38,7 +40,18 @@ function buildCategoryFilters(params) {
38
40
  return parts.join(",");
39
41
  }
40
42
  export async function CategoriesIndexPageView({ searchParams = {} }) {
41
- const sort = sp(searchParams, "sort") || "name";
43
+ /*
44
+ * 🛑 MUST MATCH CategoriesIndexListing's DEFAULT_SORT.
45
+ *
46
+ * This defaulted to "name" while the client listing defaulted to the same,
47
+ * and both were wrong: the roots fell off page 1. Now both default to tier
48
+ * ascending — and they have to agree, because this view hands its result to
49
+ * the listing as initialData and every public listing hook sets
50
+ * `staleTime: Infinity` when given it. A disagreement here is not a
51
+ * transient wrong first paint; it is frozen for that query key
52
+ * (Root Cause #30).
53
+ */
54
+ const sort = sp(searchParams, "sort") || sortBy(CATEGORY_FIELDS.TIER, "ASC");
42
55
  const page = Number(sp(searchParams, "page")) || 1;
43
56
  const filters = buildCategoryFilters(searchParams);
44
57
  const result = await safeRead(() => categoriesRepository.list({
@@ -18,6 +18,14 @@ export interface ClassifiedDetailPageViewProps {
18
18
  currency: string;
19
19
  minOfferPercent: number;
20
20
  bounds: OfferBounds;
21
+ /**
22
+ * The owning store's SLUG, so the control can suppress itself for the
23
+ * seller who owns the listing. Kept identical to `ProductDetailPageView`'s
24
+ * ctx — the comment at the `/classified/[slug]` call site asserts the two
25
+ * contracts match precisely so a consumer snippet can be shared, and an
26
+ * added field on one is drift unless it lands on both.
27
+ */
28
+ listingStoreId?: string;
21
29
  }) => React.ReactNode;
22
30
  }
23
31
  export declare function ClassifiedDetailPageView({ slug, initialProduct, renderOfferAction }: ClassifiedDetailPageViewProps): Promise<import("react").JSX.Element>;
@@ -105,6 +105,7 @@ export async function ClassifiedDetailPageView({ slug, initialProduct, renderOff
105
105
  minOfferPercent: typeof p.minOfferPercent === "number"
106
106
  ? p.minOfferPercent
107
107
  : DEFAULT_MIN_OFFER_PERCENT,
108
+ listingStoreId: typeof p.storeId === "string" ? p.storeId : undefined,
108
109
  bounds: offerBounds,
109
110
  }), _jsx(Text, { size: "xs", color: "muted", children: offerBounds.isBuyRequest
110
111
  ? "Send a request to buy and the seller will confirm. Payment and collection are arranged after they accept."
@@ -101,6 +101,19 @@ export declare function getEventLeaderboard(eventId: string): Promise<import("..
101
101
  * caller can render a generic "Guest" label instead of a raw hash.
102
102
  */
103
103
  export declare function getEventSpinResults(eventId: string, limit?: number): Promise<import("../types").SpinResultEntry[]>;
104
+ /**
105
+ * The CALLER'S OWN winning spins for an event, newest first.
106
+ *
107
+ * The companion to `getEventSpinResults`, which is a public masked feed of
108
+ * everyone's recent spins. That feed cannot answer "what did I win", and a
109
+ * checklist case asserting it did was the thing that surfaced the gap: read as
110
+ * a buyer who had never spun, the page listed three other people's prizes and
111
+ * showed no empty state, because from its point of view nothing was empty.
112
+ *
113
+ * Unmasked on purpose — these are the caller's own results, and `userId` is
114
+ * supplied by the server from a verified session, never by the client.
115
+ */
116
+ export declare function getUserSpinResultsForEvent(eventId: string, userId: string): Promise<import("../types").SpinResultEntry[]>;
104
117
  /**
105
118
  * Per-option vote tally for a poll event — replaces the voter-ranked
106
119
  * leaderboard for event.type === "poll", where ranking "who happened to
@@ -16,6 +16,7 @@ const ERR_EVENT_NOT_FOUND = "Event not found";
16
16
  import { coerceMediaField, getMediaUrl } from "../../../utils";
17
17
  import { finalizeStagedMediaField, finalizeStagedMediaObject, finalizeStagedMediaObjectArray, } from "../../media/finalize";
18
18
  import { hidePublicTestData } from "../../../_internal/server/features/tester/visibility";
19
+ import { maskName } from "../../../security/pii-mask";
19
20
  // --- Admin: Create Event --------------------------------------------------
20
21
  export async function createEvent(adminId, input) {
21
22
  const finalizedCoverUrl = await finalizeStagedMediaField(getMediaUrl(input.coverImage) ??
@@ -194,13 +195,56 @@ export async function getEventSpinResults(eventId, limit = 10) {
194
195
  const entries = await eventEntryRepository.getRecentSpinResults(event.id, limit);
195
196
  return entries.map((entry) => ({
196
197
  id: entry.id,
197
- userDisplayName: entry.userId ? entry.userDisplayName : undefined,
198
+ /*
199
+ * 🛑 MASKED. This is a public feed on a public event page — the route is
200
+ * not viewer-scoped and never has been — so it was publishing real winners'
201
+ * full display names beside the prize each one won, to anybody with the
202
+ * event's URL.
203
+ *
204
+ * Root Cause #50 is the precedent and it is the same shape: a real bidder's
205
+ * display name was going out on public Bid History because `maskPublicBid`
206
+ * spread its input instead of calling `maskName`. Nothing rendered the field
207
+ * conspicuously there either, which is why it sat unnoticed.
208
+ *
209
+ * `maskName` returns "Anonymous" for a missing name, so the guest case still
210
+ * reads sensibly, and `isGuest` below is what the renderer actually branches
211
+ * on for its own label. A caller's OWN spins are served unmasked by
212
+ * `getUserSpinResultsForEvent` — masking is for other people's.
213
+ */
214
+ userDisplayName: entry.userId ? maskName(entry.userDisplayName) : undefined,
198
215
  isGuest: !entry.userId,
199
216
  spinPrizeId: entry.spinPrizeId,
200
217
  spinPrizeTitle: entry.spinPrizeId ? prizeTitleById.get(entry.spinPrizeId) : undefined,
201
218
  spinWonAt: entry.spinWonAt ? new Date(entry.spinWonAt).toISOString() : undefined,
202
219
  }));
203
220
  }
221
+ /**
222
+ * The CALLER'S OWN winning spins for an event, newest first.
223
+ *
224
+ * The companion to `getEventSpinResults`, which is a public masked feed of
225
+ * everyone's recent spins. That feed cannot answer "what did I win", and a
226
+ * checklist case asserting it did was the thing that surfaced the gap: read as
227
+ * a buyer who had never spun, the page listed three other people's prizes and
228
+ * showed no empty state, because from its point of view nothing was empty.
229
+ *
230
+ * Unmasked on purpose — these are the caller's own results, and `userId` is
231
+ * supplied by the server from a verified session, never by the client.
232
+ */
233
+ export async function getUserSpinResultsForEvent(eventId, userId) {
234
+ const event = await eventRepository.findByIdOrSlug(eventId);
235
+ if (!event || event.type !== "spin_wheel")
236
+ return [];
237
+ const prizeTitleById = new Map((event.spinPrizes ?? []).map((p) => [p.id, p.label]));
238
+ const entries = await eventEntryRepository.getUserSpinResults(event.id, userId);
239
+ return entries.map((entry) => ({
240
+ id: entry.id,
241
+ userDisplayName: entry.userDisplayName,
242
+ isGuest: false,
243
+ spinPrizeId: entry.spinPrizeId,
244
+ spinPrizeTitle: entry.spinPrizeId ? prizeTitleById.get(entry.spinPrizeId) : undefined,
245
+ spinWonAt: entry.spinWonAt ? new Date(entry.spinWonAt).toISOString() : undefined,
246
+ }));
247
+ }
204
248
  /**
205
249
  * Per-option vote tally for a poll event — replaces the voter-ranked
206
250
  * leaderboard for event.type === "poll", where ranking "who happened to
@@ -324,6 +368,36 @@ export async function enterEvent(eventId, input, user, request) {
324
368
  throw new ValidationError(ERROR_MESSAGES.EVENT.ALREADY_ENTERED);
325
369
  }
326
370
  }
371
+ /*
372
+ * 🛑 A signed-in user could vote in a poll as many times as they liked, and
373
+ * every vote counted.
374
+ *
375
+ * Three paths reach this point and only two of them guarded. A GUEST is
376
+ * deduped by hashed IP just above; a SURVEY enforces `maxEntriesPerUser`
377
+ * immediately above that; a poll had neither, so the one case that is trivial
378
+ * to abuse — an authenticated voter clicking Cast Vote again — was the
379
+ * unguarded one. Measured on /events/event-favourite-blader-poll: voted for
380
+ * one option, reloaded, voted for a DIFFERENT option, and the second was
381
+ * accepted with the same "Vote recorded!" confirmation. The event's
382
+ * Participants counter went 362 -> 364 on those two votes from one account, so
383
+ * the tally itself was corrupted, not merely the UI.
384
+ *
385
+ * A poll is one vote per person by definition — there is no
386
+ * `maxEntriesPerUser` to consult and no change-your-vote affordance anywhere
387
+ * in the UI — so the rule is simply "not twice". Uses the same
388
+ * `countUserEntries` the survey path already relies on rather than a new
389
+ * query, so the read cost and the index it needs are both already proven.
390
+ *
391
+ * ValidationError, so the caller gets `ALREADY_ENTERED` and a real message:
392
+ * the previous behaviour silently produced a duplicate, which is the failure
393
+ * mode this refusal exists to replace.
394
+ */
395
+ if (user && event.type === "poll") {
396
+ const priorVotes = await eventEntryRepository.countUserEntries(resolvedEventId, user.uid);
397
+ if (priorVotes >= 1) {
398
+ throw new ValidationError(ERROR_MESSAGES.EVENT.ALREADY_ENTERED);
399
+ }
400
+ }
327
401
  if (event.type === "poll" && event.pollConfig) {
328
402
  const validOptionIds = event.pollConfig.options.map((o) => o.id);
329
403
  const votes = input.pollVotes ?? [];
@@ -22,6 +22,23 @@ declare class EventEntryRepository extends BaseRepository<EventEntryDocument> {
22
22
  * (every write path sets `spinWonAt` and `spinPrizeId` together).
23
23
  */
24
24
  getRecentSpinResults(eventId: string, limit?: number): Promise<EventEntryDocument[]>;
25
+ /**
26
+ * One caller's own winning spins for an event, newest first.
27
+ *
28
+ * 🛑 Deliberately the SAME query shape as `countUserEntries` — two equalities
29
+ * on `(eventId, userId)` and no `orderBy` — so it needs no composite index
30
+ * that does not already exist. `getRecentSpinResults` orders by `spinWonAt`,
31
+ * which is fine with its single equality but would demand a new
32
+ * `(eventId, userId, spinWonAt)` index here, and an index that has not been
33
+ * deployed fails with FAILED_PRECONDITION at runtime while looking perfectly
34
+ * correct in source (Root Cause #2).
35
+ *
36
+ * Sorting in memory is safe precisely because this set is tiny and bounded by
37
+ * the event's own `spinMaxPerUser` — it is "my spins", not a feed. Rows with
38
+ * no `spinWonAt` are dropped rather than sorted to an arbitrary end: an entry
39
+ * that never resolved a prize is not a spin result.
40
+ */
41
+ getUserSpinResults(eventId: string, userId: string): Promise<EventEntryDocument[]>;
25
42
  countUserEntries(eventId: string, userId: string): Promise<number>;
26
43
  getLeaderboard(eventId: string, limit?: number): Promise<import("../types").LeaderboardEntry[]>;
27
44
  /**
@@ -92,6 +92,42 @@ class EventEntryRepository extends BaseRepository {
92
92
  throw new DatabaseError(`Failed to get recent spin results for event ${eventId}`, error);
93
93
  }
94
94
  }
95
+ /**
96
+ * One caller's own winning spins for an event, newest first.
97
+ *
98
+ * 🛑 Deliberately the SAME query shape as `countUserEntries` — two equalities
99
+ * on `(eventId, userId)` and no `orderBy` — so it needs no composite index
100
+ * that does not already exist. `getRecentSpinResults` orders by `spinWonAt`,
101
+ * which is fine with its single equality but would demand a new
102
+ * `(eventId, userId, spinWonAt)` index here, and an index that has not been
103
+ * deployed fails with FAILED_PRECONDITION at runtime while looking perfectly
104
+ * correct in source (Root Cause #2).
105
+ *
106
+ * Sorting in memory is safe precisely because this set is tiny and bounded by
107
+ * the event's own `spinMaxPerUser` — it is "my spins", not a feed. Rows with
108
+ * no `spinWonAt` are dropped rather than sorted to an arbitrary end: an entry
109
+ * that never resolved a prize is not a spin result.
110
+ */
111
+ async getUserSpinResults(eventId, userId) {
112
+ try {
113
+ const snapshot = await this.getCollection()
114
+ .where(EVENT_ENTRY_FIELDS.EVENT_ID, "==", eventId)
115
+ .where(EVENT_ENTRY_FIELDS.USER_ID, "==", userId)
116
+ .get();
117
+ return snapshot.docs
118
+ .map((doc) => this.mapDoc(doc))
119
+ .filter((entry) => Boolean(entry.spinWonAt))
120
+ .sort((a, b) => {
121
+ const at = new Date(a.spinWonAt).getTime();
122
+ const bt = new Date(b.spinWonAt).getTime();
123
+ return (Number.isFinite(bt) ? bt : 0) - (Number.isFinite(at) ? at : 0);
124
+ });
125
+ }
126
+ catch (error) {
127
+ void normalizeError(error);
128
+ throw new DatabaseError(`Failed to load spin results for user ${userId} on event ${eventId}`, error);
129
+ }
130
+ }
95
131
  async countUserEntries(eventId, userId) {
96
132
  try {
97
133
  const snapshot = await this.getCollection()
@@ -45,5 +45,5 @@ export function BrandsSection({ title = "Top Brands", subtitle, limit = 12, view
45
45
  });
46
46
  if (!isLoading && allBrands.length === 0)
47
47
  return null;
48
- return (_jsx(Section, { className: `${className}`, surface: "subtle", paddingY: "y-3xl", paddingX: "x-md", children: _jsxs(Div, { className: "mx-auto max-w-7xl", children: [_jsxs(Row, { className: "mb-6", align: "center", justify: "between", reverse: hand === "left", children: [_jsxs(_Fragment, { children: [_jsx(Heading, { level: 2, className: themed.textPrimary, size: "2xl", mdSize: "3xl", weight: "bold", children: title }), subtitle && (_jsx(Text, { variant: "secondary", className: "mt-1", size: "sm", children: subtitle }))] }), !cta && viewMoreHref && (_jsx(Link, { href: viewMoreHref, className: "text-[length:var(--appkit-text-sm)] font-medium text-[var(--appkit-color-primary)] hover:underline", children: viewMoreLabel }))] }), showFeaturedChip && !isLoading && (_jsxs(Row, { wrap: true, gap: "sm", className: "mb-4", children: [_jsx(BrandFilterChip, { label: "All", active: activeFilter === "all", onClick: () => setActiveFilter("all") }), _jsx(BrandFilterChip, { label: "Featured", active: activeFilter === "featured", onClick: () => setActiveFilter(activeFilter === "featured" ? "all" : "featured") })] })), isLoading ? (_jsx(Div, { layout: "flex", gap: "3", className: `${__O.hidden}`, children: Array.from({ length: 8 }).map((_, i) => (_jsx(Div, { className: "h-32 w-36 flex-none animate-pulse md:h-40", surface: "subtle", rounded: "xl" }, i))) })) : (_jsx(HorizontalScroller, { items: brands, renderItem: (brand) => _jsx(BrandLogo, { brand: brand }), keyExtractor: (brand) => brand.id, gap: 12, showArrows: true, showScrollbar: false, autoScroll: autoScroll, autoScrollInterval: scrollInterval, pauseOnHover: true, loop: loop })), cta && !isLoading && (_jsx(Div, { className: "mt-6 text-left", children: _jsx(Link, { href: cta.href, className: CTA_CLASSES[cta.variant], children: cta.label }) }))] }) }));
48
+ return (_jsx(Section, { className: `${className}`, surface: "subtle", paddingY: "y-3xl", paddingX: "x-md", children: _jsxs(Div, { className: "mx-auto max-w-7xl", children: [_jsxs(Row, { className: "mb-6", align: "center", justify: "between", reverse: hand === "left", children: [_jsxs(_Fragment, { children: [_jsx(Heading, { level: 2, className: themed.textPrimary, size: "2xl", mdSize: "3xl", weight: "bold", children: title }), subtitle && (_jsx(Text, { variant: "secondary", className: "mt-1", size: "sm", children: subtitle }))] }), !cta && viewMoreHref && (_jsx(Link, { href: viewMoreHref, className: "inline-flex items-center gap-[0.375rem] rounded-[var(--appkit-radius-md)] px-[var(--appkit-space-3)] py-[var(--appkit-space-1)] text-[length:var(--appkit-text-sm)] font-semibold transition-colors bg-primary text-white hover:bg-primary-600", children: viewMoreLabel }))] }), showFeaturedChip && !isLoading && (_jsxs(Row, { wrap: true, gap: "sm", className: "mb-4", children: [_jsx(BrandFilterChip, { label: "All", active: activeFilter === "all", onClick: () => setActiveFilter("all") }), _jsx(BrandFilterChip, { label: "Featured", active: activeFilter === "featured", onClick: () => setActiveFilter(activeFilter === "featured" ? "all" : "featured") })] })), isLoading ? (_jsx(Div, { layout: "flex", gap: "3", className: `${__O.hidden}`, children: Array.from({ length: 8 }).map((_, i) => (_jsx(Div, { className: "h-32 w-36 flex-none animate-pulse md:h-40", surface: "subtle", rounded: "xl" }, i))) })) : (_jsx(HorizontalScroller, { items: brands, renderItem: (brand) => _jsx(BrandLogo, { brand: brand }), keyExtractor: (brand) => brand.id, gap: 12, showArrows: true, showScrollbar: false, autoScroll: autoScroll, autoScrollInterval: scrollInterval, pauseOnHover: true, loop: loop })), cta && !isLoading && (_jsx(Div, { className: "mt-6 text-left", children: _jsx(Link, { href: cta.href, className: CTA_CLASSES[cta.variant], children: cta.label }) }))] }) }));
49
49
  }
@@ -17,5 +17,5 @@ export function CustomerReviewsSection({ title, subtitle, items, renderItem, vie
17
17
  }
18
18
  if (items.length === 0)
19
19
  return null;
20
- return (_jsx(Section, { className: `${className}`, surface: "muted", padding: "y-3xl", children: _jsxs(Div, { className: "w-full max-w-7xl mx-auto md:px-[2rem]", padding: "x-md", children: [_jsxs(Div, { className: "text-left mb-10", children: [_jsx(Heading, { level: 2, className: `${themed.textPrimary} mb-3`, size: "3xl", mdSize: "4xl", weight: "bold", children: title }), subtitle && (_jsx(Text, { className: `${themed.textSecondary}`, size: "base", children: subtitle }))] }), _jsx(HorizontalScroller, { items: items, renderItem: (item) => renderItem(item), perView: perView ?? CAROUSEL_PER_VIEW.reviews, gap: 24, autoScroll: autoScroll, autoScrollInterval: scrollInterval, pauseOnHover: true, snapToItems: true, showArrows: true, keyExtractor: keyExtractor, loop: loop }), viewMoreHref && (_jsx(Div, { className: "text-left mt-8", children: _jsx(TextLink, { href: viewMoreHref, className: "text-primary hover:text-primary/80", size: "sm", weight: "medium", children: viewMoreLabel }) }))] }) }));
20
+ return (_jsx(Section, { className: `${className}`, surface: "muted", padding: "y-3xl", children: _jsxs(Div, { className: "w-full max-w-7xl mx-auto md:px-[2rem]", padding: "x-md", children: [_jsxs(Div, { className: "text-left mb-10", children: [_jsx(Heading, { level: 2, className: `${themed.textPrimary} mb-3`, size: "3xl", mdSize: "4xl", weight: "bold", children: title }), subtitle && (_jsx(Text, { className: `${themed.textSecondary}`, size: "base", children: subtitle }))] }), _jsx(HorizontalScroller, { items: items, renderItem: (item) => renderItem(item), perView: perView ?? CAROUSEL_PER_VIEW.reviews, gap: 24, autoScroll: autoScroll, autoScrollInterval: scrollInterval, pauseOnHover: true, snapToItems: true, showArrows: true, keyExtractor: keyExtractor, loop: loop }), viewMoreHref && (_jsx(Div, { className: "text-left mt-8", children: _jsx(TextLink, { href: viewMoreHref, rounded: "md", align: "center", gap: "xs", className: "inline-flex px-[var(--appkit-space-3)] py-[var(--appkit-space-1)] transition-colors bg-primary text-white hover:bg-primary-600", size: "sm", weight: "semibold", children: viewMoreLabel }) }))] }) }));
21
21
  }
@@ -30,7 +30,7 @@ export function FeaturedBundlesSection({ title = BUNDLE_COPY.featuredDefaultTitl
30
30
  const items = maxItems && maxItems > 0 ? bundles.slice(0, maxItems) : bundles;
31
31
  if (items.length === 0)
32
32
  return null;
33
- return (_jsx(Section, { className: `${className}`, padding: "y-2xl", children: _jsxs(Stack, { gap: "md", children: [_jsxs(Row, { gap: "sm", align: "center", justify: "between", wrap: true, reverse: hand === "left", children: [_jsxs(Stack, { gap: "xs", children: [_jsx(Heading, { level: 2, size: "2xl", weight: "semibold", color: "primary", children: title }), description && (_jsx(Text, { size: "sm", color: "muted", children: description }))] }), _jsx(Link, { href: String(ROUTES.PUBLIC.BUNDLES), className: "text-[length:var(--appkit-text-sm)] font-medium text-[var(--appkit-color-primary)] hover:underline", children: viewMoreLabel })] }), _jsx(Grid, { cols: "cards", gap: "3", children: items.map((bundle) => (_jsx(FeaturedBundleCard, { bundle: bundle, showSavingsBadge: showSavingsBadge, onBuyNow: onBuyNow }, bundle.id))) })] }) }));
33
+ return (_jsx(Section, { className: `${className}`, padding: "y-2xl", children: _jsxs(Stack, { gap: "md", children: [_jsxs(Row, { gap: "sm", align: "center", justify: "between", wrap: true, reverse: hand === "left", children: [_jsxs(Stack, { gap: "xs", children: [_jsx(Heading, { level: 2, size: "2xl", weight: "semibold", color: "primary", children: title }), description && (_jsx(Text, { size: "sm", color: "muted", children: description }))] }), _jsx(Link, { href: String(ROUTES.PUBLIC.BUNDLES), className: "inline-flex items-center gap-[0.375rem] rounded-[var(--appkit-radius-md)] px-[var(--appkit-space-3)] py-[var(--appkit-space-1)] text-[length:var(--appkit-text-sm)] font-semibold transition-colors bg-primary text-white hover:bg-primary-600", children: viewMoreLabel })] }), _jsx(Grid, { cols: "cards", gap: "3", children: items.map((bundle) => (_jsx(FeaturedBundleCard, { bundle: bundle, showSavingsBadge: showSavingsBadge, onBuyNow: onBuyNow }, bundle.id))) })] }) }));
34
34
  }
35
35
  function FeaturedBundleCard({ bundle, showSavingsBadge = true, onBuyNow, }) {
36
36
  const stock = bundle.bundleStockStatus ?? "in_stock";
@@ -69,5 +69,5 @@ export function ShopByCategorySection({ title = "Shop by Category", subtitle, li
69
69
  if (!cat)
70
70
  return null;
71
71
  return (_jsx(FilterChip, { label: cat.name, active: activeFilter === id, onClick: () => setActiveFilter(activeFilter === id ? "all" : id) }, id));
72
- })] })), isLoading ? (_jsx(Div, { layout: "flex", gap: "3", className: `${__O.hidden} px-[0.25rem]`, children: Array.from({ length: 6 }).map((_, i) => (_jsx(Div, { className: "flex-none h-[104px] w-[108px] animate-pulse", surface: "subtle", rounded: "xl" }, i))) })) : (_jsx(HorizontalScroller, { items: categories, renderItem: (cat) => _jsx(CategoryChip, { category: cat }), perView: CAROUSEL_PER_VIEW.standard, gap: 16, keyExtractor: (cat) => cat.id, autoScroll: autoScroll, autoScrollInterval: scrollInterval, showArrows: true, snapToItems: true, showScrollbar: false, loop: loop, pauseOnHover: true })), cta && !isLoading && (_jsx(Div, { className: "mt-6 text-left", children: _jsx(Link, { href: cta.href, className: CTA_CLASSES[cta.variant], children: cta.label }) })), !cta && viewMoreHref && !isLoading && (_jsx(Div, { className: "mt-6 text-left", children: _jsx(Link, { href: viewMoreHref, className: "inline-flex items-center gap-[var(--appkit-space-1)] text-[length:var(--appkit-text-sm)] font-medium text-[var(--appkit-color-primary)] hover:opacity-80", children: viewMoreLabel }) }))] }) }));
72
+ })] })), isLoading ? (_jsx(Div, { layout: "flex", gap: "3", className: `${__O.hidden} px-[0.25rem]`, children: Array.from({ length: 6 }).map((_, i) => (_jsx(Div, { className: "flex-none h-[104px] w-[108px] animate-pulse", surface: "subtle", rounded: "xl" }, i))) })) : (_jsx(HorizontalScroller, { items: categories, renderItem: (cat) => _jsx(CategoryChip, { category: cat }), perView: CAROUSEL_PER_VIEW.standard, gap: 16, keyExtractor: (cat) => cat.id, autoScroll: autoScroll, autoScrollInterval: scrollInterval, showArrows: true, snapToItems: true, showScrollbar: false, loop: loop, pauseOnHover: true })), cta && !isLoading && (_jsx(Div, { className: "mt-6 text-left", children: _jsx(Link, { href: cta.href, className: CTA_CLASSES[cta.variant], children: cta.label }) })), !cta && viewMoreHref && !isLoading && (_jsx(Div, { className: "mt-6 text-left", children: _jsx(Link, { href: viewMoreHref, className: "inline-flex items-center gap-[0.375rem] rounded-[var(--appkit-radius-md)] px-[var(--appkit-space-3)] py-[var(--appkit-space-1)] text-[length:var(--appkit-text-sm)] font-semibold transition-colors bg-primary text-white hover:bg-primary-600", children: viewMoreLabel }) }))] }) }));
73
73
  }
@@ -101,9 +101,27 @@ kind, accept: acceptProp, maxSizeMB: maxSizeMBProp, disabled = false, helperText
101
101
  const [extInput, setExtInput] = useState("");
102
102
  const [extError, setExtError] = useState("");
103
103
  const hasAlternateSources = showYoutube || showExternal;
104
- // PDF mode: derived from the `accept` prop. Hides camera capture + alternate
105
- // URL tabs (YouTube/External never make sense for invoice/payout-doc fields)
106
- // and trim/thumbnail flow.
104
+ /*
105
+ * PDF mode: derived from the `accept` prop. It forces file-only capture
106
+ * (`effectiveCaptureSource` below) and does nothing else.
107
+ *
108
+ * 🛑 This comment used to claim it also hid "alternate URL tabs (YouTube /
109
+ * External never make sense for invoice/payout-doc fields) and trim/thumbnail
110
+ * flow". Two of those three were never implemented — `pdfMode` has exactly one
111
+ * consumer — so a reader checking whether a document field could be given a
112
+ * YouTube link found a comment saying no and code saying yes. The buyer's
113
+ * payment-proof field was offering "YouTube" and "External URL" as ways to
114
+ * evidence a real payment because of it.
115
+ *
116
+ * The intent in that comment is still right, and closing it properly means
117
+ * deciding one rule for all 11 mounts: `showYoutube`/`showExternal` default to
118
+ * TRUE and NOT ONE mount in the codebase opts out, while `AUTO_KIND_DEFAULTS`
119
+ * accepts `application/pdf`, so gating the tabs on `pdfMode` alone would also
120
+ * strip YouTube from every `kind="auto"` field — including the product video
121
+ * field, where the YouTube source is deliberate (Root Cause #49). Until that
122
+ * rule is chosen, a field that must not accept a URL says so explicitly, as
123
+ * the payment-proof page now does.
124
+ */
107
125
  // SB-UNI-Z5 2026-05-13 — fold `kind` defaults under explicit props.
108
126
  const kindDefaults = kind === "auto"
109
127
  ? AUTO_KIND_DEFAULTS
@@ -1,5 +1,6 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Div, Pagination, Row, Span, Stack, Text } from "../../../ui";
3
+ import { shortOrderRef } from "../utils/order-ref";
3
4
  import { MediaImage } from "../../media/MediaImage";
4
5
  import { formatCurrency } from "../../../utils/number.formatter";
5
6
  import { getDefaultLocale } from "../../../core/baseline-resolver";
@@ -31,7 +32,7 @@ export function OrderCard({ order, onClick, labels = {}, renderActions }) {
31
32
  const revealsRemaining = unrevealedPrizeDraws.length;
32
33
  return (_jsxs(Div, { padding: "5", role: onClick ? "button" : undefined, tabIndex: onClick ? 0 : undefined, onKeyDown: onClick
33
34
  ? (e) => (e.key === "Enter" || e.key === "") && onClick(order)
34
- : undefined, onClick: onClick ? () => onClick(order) : undefined, className: `border border-neutral-200 ${onClick ? "cursor-pointer transition hover:shadow-md" : ""}`, rounded: "xl", surface: "default", children: [_jsxs(Row, { wrap: true, align: "start", justify: "between", gap: "3", children: [_jsxs(Div, { children: [_jsxs(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "xs", children: ["Order #", order.id.slice(-8).toUpperCase()] }), date && (_jsx(Text, { className: "mt-0.5", color: "faint", size: "xs", children: date }))] }), _jsx(Span, { size: "xs", weight: "semibold", className: `capitalize ${statusColor}`, rounded: "full", padding: "pill-md", children: labels[order.orderStatus] ?? order.orderStatus.replace(/_/g, " ") })] }), revealsRemaining > 0 && (_jsx(Row, { gap: "sm", className: "mt-2", children: _jsxs(Span, { size: "xs", weight: "semibold", className: CLS_PRIZE_BADGE, children: [revealsRemaining, " ", revealsRemaining === 1 ? "reveal" : "reveals", " pending"] }) })), _jsxs(Row, { wrap: true, gap: "3", className: "mt-4", children: [order.items.slice(0, 3).map((item, i) => (_jsxs(Row, { gap: "sm", children: [item.image && (_jsx(Div, { className: "relative h-10 w-10 flex-shrink-0", rounded: "lg", overflow: "hidden", children: _jsx(MediaImage, { src: item.image, alt: item.title, size: "thumbnail" }) })), _jsxs(Div, { children: [_jsx(Text, { truncate: true, size: "sm", weight: "medium", color: "primary", children: item.title }), _jsxs(Text, { size: "xs", color: "faint", children: ["\u00D7", item.quantity] })] })] }, i))), order.items.length > 3 && (_jsxs(Span, { size: "xs", className: "self-center", color: "faint", children: ["+", order.items.length - 3, " more"] }))] }), _jsxs(Row, { justify: "between", className: "mt-4 border-t border-neutral-100", padding: "t-sm", children: [_jsxs(Span, { size: "sm", className: "text-[var(--appkit-color-text-muted)]", children: [order.currency ?? "", " Total"] }), _jsx(Span, { weight: "semibold", className: "text-[var(--appkit-color-text)]", children: formatCurrency(order.total, order.currency) })] }), renderActions && (_jsx(Row, { justify: "end", gap: "sm", className: "mt-3", onClick: (e) => e.stopPropagation(), children: renderActions(order) }))] }));
35
+ : undefined, onClick: onClick ? () => onClick(order) : undefined, className: `border border-neutral-200 ${onClick ? "cursor-pointer transition hover:shadow-md" : ""}`, rounded: "xl", surface: "default", children: [_jsxs(Row, { wrap: true, align: "start", justify: "between", gap: "3", children: [_jsxs(Div, { children: [_jsxs(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "xs", children: ["Order #", shortOrderRef(order.id)] }), date && (_jsx(Text, { className: "mt-0.5", color: "faint", size: "xs", children: date }))] }), _jsx(Span, { size: "xs", weight: "semibold", className: `capitalize ${statusColor}`, rounded: "full", padding: "pill-md", children: labels[order.orderStatus] ?? order.orderStatus.replace(/_/g, " ") })] }), revealsRemaining > 0 && (_jsx(Row, { gap: "sm", className: "mt-2", children: _jsxs(Span, { size: "xs", weight: "semibold", className: CLS_PRIZE_BADGE, children: [revealsRemaining, " ", revealsRemaining === 1 ? "reveal" : "reveals", " pending"] }) })), _jsxs(Row, { wrap: true, gap: "3", className: "mt-4", children: [order.items.slice(0, 3).map((item, i) => (_jsxs(Row, { gap: "sm", children: [item.image && (_jsx(Div, { className: "relative h-10 w-10 flex-shrink-0", rounded: "lg", overflow: "hidden", children: _jsx(MediaImage, { src: item.image, alt: item.title, size: "thumbnail" }) })), _jsxs(Div, { children: [_jsx(Text, { truncate: true, size: "sm", weight: "medium", color: "primary", children: item.title }), _jsxs(Text, { size: "xs", color: "faint", children: ["\u00D7", item.quantity] })] })] }, i))), order.items.length > 3 && (_jsxs(Span, { size: "xs", className: "self-center", color: "faint", children: ["+", order.items.length - 3, " more"] }))] }), _jsxs(Row, { justify: "between", className: "mt-4 border-t border-neutral-100", padding: "t-sm", children: [_jsxs(Span, { size: "sm", className: "text-[var(--appkit-color-text-muted)]", children: [order.currency ?? "", " Total"] }), _jsx(Span, { weight: "semibold", className: "text-[var(--appkit-color-text)]", children: formatCurrency(order.total, order.currency) })] }), renderActions && (_jsx(Row, { justify: "end", gap: "sm", className: "mt-3", onClick: (e) => e.stopPropagation(), children: renderActions(order) }))] }));
35
36
  }
36
37
  export function OrdersList({ orders, isLoading, onOrderClick, totalPages = 1, currentPage = 1, onPageChange, emptyLabel = "No orders found", renderActions, }) {
37
38
  if (isLoading) {
@@ -4,5 +4,6 @@ export * from "./components";
4
4
  export * from "./schemas";
5
5
  export * from "./columns";
6
6
  export * from "./utils/order-splitter";
7
+ export { shortOrderRef } from "./utils/order-ref";
7
8
  export { groupOrderItemsByLine, type LineOrderGroup, type OrderItemForLineGrouping, } from "./utils/bundle-grouping";
8
9
  export { manifest } from "./manifest";
@@ -4,6 +4,7 @@ export * from "./components";
4
4
  export * from "./schemas";
5
5
  export * from "./columns";
6
6
  export * from "./utils/order-splitter";
7
+ export { shortOrderRef } from "./utils/order-ref";
7
8
  // S-SBUNI-5 2026-05-13 — bundle-aware order item grouping.
8
9
  export { groupOrderItemsByLine, } from "./utils/bundle-grouping";
9
10
  export { manifest } from "./manifest";
@@ -148,6 +148,8 @@ export interface Order {
148
148
  shipmentProtectionFee?: number;
149
149
  /** Buyer-facing platform commission apportioned to this order. */
150
150
  platformFee?: number;
151
+ /** COD handling fee, charged only on cash-on-delivery orders. */
152
+ codHandlingFee?: number;
151
153
  trackingNumber?: string;
152
154
  shippingCarrier?: string;
153
155
  /** 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,45 @@
1
+ /**
2
+ * The short, buyer-facing reference for an order.
3
+ *
4
+ * 🛑 WHY THIS EXISTS. Four sites independently rendered
5
+ * `order.id.slice(-8).toUpperCase()` — the buyer's order view (twice), the
6
+ * invoice, and `OrdersList`. A fixed-width tail is the wrong shape for these
7
+ * ids: `generateOrderId` produces
8
+ *
9
+ * order-{itemCount}-{YYYYMMDD}-{rand6}
10
+ *
11
+ * whose final segment is **6** characters, so `slice(-8)` takes seven of them
12
+ * plus one extra — and that extra is the last digit of the DATE. Measured on a
13
+ * real order, `order-2-20260929-ezq0pc` rendered as **`#9-EZQ0PC`**.
14
+ *
15
+ * Three things are wrong with that, in increasing order of cost:
16
+ *
17
+ * 1. It straddles a delimiter, so the reference is not a substring of anything
18
+ * meaningful — the leading `9` looks like part of a structured reference and
19
+ * is a fragment of `20260929`.
20
+ * 2. Every order placed on the 9th, 19th or 29th of any month renders the same
21
+ * leading digit, and the 1st/11th/21st/31st share another. The digit adds no
22
+ * distinguishing information while looking like it does.
23
+ * 3. It is the number a buyer reads off their own screen and quotes to support,
24
+ * and it does not appear anywhere in the real document id. The payment page
25
+ * shows the full `order-2-20260929-ezq0pc`, so the same order presents two
26
+ * unrelated-looking references depending on which page you are on.
27
+ *
28
+ * The random suffix alone is the intended unique part, so that is what this
29
+ * returns. Taking the last hyphen-delimited SEGMENT rather than a character
30
+ * count also means the format can change without silently re-slicing.
31
+ *
32
+ * @tag domain:orders
33
+ * @tag layer:utils
34
+ * @tag access:isomorphic
35
+ * @tag sideEffects:none — pure
36
+ */
37
+ /**
38
+ * `order-2-20260929-ezq0pc` → `EZQ0PC`.
39
+ *
40
+ * Falls back to the whole id, uppercased, for anything that is not in the
41
+ * generator's shape (a legacy Firestore auto-id, say) — a reference that is
42
+ * merely long is far better than one that is wrong, and an empty string would
43
+ * render as a bare `#`.
44
+ */
45
+ export declare function shortOrderRef(orderId: string): string;