@mohasinac/appkit 4.27.0 → 4.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (271) hide show
  1. package/dist/_internal/client/features/lottery/LotteryAdminEditView.d.ts +16 -16
  2. package/dist/_internal/client/features/lottery/LotteryAdminEditView.js +29 -6
  3. package/dist/_internal/client/features/lottery/LotteryListView.js +13 -1
  4. package/dist/_internal/server/features/checkout/actions.js +6 -4
  5. package/dist/_internal/server/features/classified/metadata.js +3 -1
  6. package/dist/_internal/server/features/digital-code/metadata.js +3 -1
  7. package/dist/_internal/server/features/live/metadata.js +3 -1
  8. package/dist/_internal/server/features/refunds/actions.js +3 -2
  9. package/dist/_internal/server/features/reviews/actions.js +3 -2
  10. package/dist/_internal/server/features/seo/sitemap.js +189 -40
  11. package/dist/_internal/server/jobs/core/listingProcessor.d.ts +0 -1
  12. package/dist/_internal/server/jobs/core/listingProcessor.js +19 -1
  13. package/dist/_internal/shared/actions/action-registry.js +14 -0
  14. package/dist/_internal/shared/checkout/order-math.d.ts +25 -3
  15. package/dist/_internal/shared/features/checkout/config.d.ts +3 -3
  16. package/dist/_internal/shared/features/checkout/config.js +6 -3
  17. package/dist/client.d.ts +32 -2
  18. package/dist/client.js +37 -2
  19. package/dist/constants/api-endpoints.d.ts +21 -3
  20. package/dist/constants/api-endpoints.js +14 -1
  21. package/dist/constants/field-names.d.ts +2 -2
  22. package/dist/constants/field-names.js +2 -2
  23. package/dist/core/newsletter.repository.js +14 -1
  24. package/dist/features/about/components/FAQPageView.js +1 -1
  25. package/dist/features/about/components/FeesView.js +1 -1
  26. package/dist/features/account/components/AddressForm.d.ts +2 -0
  27. package/dist/features/account/components/AddressForm.js +5 -1
  28. package/dist/features/account/components/UserBidsView.js +1 -1
  29. package/dist/features/account/components/UserSidebar.js +1 -1
  30. package/dist/features/account/components/UserSupportView.js +24 -2
  31. package/dist/features/account/hooks/useAddresses.d.ts +29 -1
  32. package/dist/features/account/hooks/useAddresses.js +4 -1
  33. package/dist/features/account/hooks/useProfile.d.ts +1 -1
  34. package/dist/features/account/hooks/useProfile.js +1 -1
  35. package/dist/features/addresses/repository/addresses.repository.d.ts +13 -0
  36. package/dist/features/addresses/repository/addresses.repository.js +19 -0
  37. package/dist/features/admin/actions/chat-actions.js +5 -1
  38. package/dist/features/admin/components/AdminAdsView.js +1 -1
  39. package/dist/features/admin/components/AdminBidsView.js +6 -1
  40. package/dist/features/admin/components/AdminCarouselGroupEditorView.d.ts +9 -1
  41. package/dist/features/admin/components/AdminCarouselGroupEditorView.js +42 -10
  42. package/dist/features/admin/components/AdminCarouselView.js +1 -1
  43. package/dist/features/admin/components/AdminFeatureEditorView.js +1 -1
  44. package/dist/features/admin/components/AdminGroupedListingsView.js +14 -2
  45. package/dist/features/admin/components/AdminSidebar.js +1 -1
  46. package/dist/features/admin/components/AdminViewCards.js +2 -2
  47. package/dist/features/admin/components/DataTable.js +2 -2
  48. package/dist/features/admin/schemas/firestore.js +9 -3
  49. package/dist/features/auctions/actions/bid-actions.js +45 -4
  50. package/dist/features/auctions/components/AuctionBottomActions.d.ts +21 -6
  51. package/dist/features/auctions/components/AuctionBottomActions.js +42 -11
  52. package/dist/features/auctions/components/AuctionDetailPageView.d.ts +8 -3
  53. package/dist/features/auctions/components/AuctionDetailPageView.js +2 -2
  54. package/dist/features/auctions/components/MarketplaceAuctionCard.js +4 -5
  55. package/dist/features/auctions/components/PlaceBidFormClient.d.ts +27 -2
  56. package/dist/features/auctions/components/PlaceBidFormClient.js +42 -20
  57. package/dist/features/auth/components/SocialAuthButtons.js +1 -1
  58. package/dist/features/auth/hooks/useAuth.d.ts +23 -1
  59. package/dist/features/auth/hooks/useAuth.js +27 -1
  60. package/dist/features/auth/permissions/constants.d.ts +1 -1
  61. package/dist/features/auth/permissions/constants.js +3 -0
  62. package/dist/features/auth/repository/session.repository.d.ts +16 -1
  63. package/dist/features/auth/repository/session.repository.js +23 -0
  64. package/dist/features/auth/repository/user.repository.js +13 -2
  65. package/dist/features/blog/components/BlogListView.js +2 -2
  66. package/dist/features/cart/components/CartPriceBreakdown.d.ts +11 -1
  67. package/dist/features/cart/components/CartPriceBreakdown.js +3 -3
  68. package/dist/features/cart/components/StoreAddonsPicker.d.ts +9 -0
  69. package/dist/features/cart/components/StoreAddonsPicker.js +15 -5
  70. package/dist/features/cart/components/index.d.ts +1 -1
  71. package/dist/features/cart/components/index.js +1 -1
  72. package/dist/features/cart/hooks/useGuestCartMerge.d.ts +8 -1
  73. package/dist/features/cart/hooks/useGuestCartMerge.js +2 -1
  74. package/dist/features/cart/utils/line-total.d.ts +25 -0
  75. package/dist/features/cart/utils/line-total.js +26 -0
  76. package/dist/features/catalogue/repository/catalogue.repository.d.ts +12 -0
  77. package/dist/features/catalogue/repository/catalogue.repository.js +15 -0
  78. package/dist/features/categories/components/CategoryTree.js +2 -2
  79. package/dist/features/contact/components/ContactForm.js +1 -1
  80. package/dist/features/copilot/hooks/useCopilotFeedback.d.ts +1 -1
  81. package/dist/features/copilot/hooks/useCopilotFeedback.js +1 -1
  82. package/dist/features/events/components/AdminEventEditorView.js +16 -9
  83. package/dist/features/events/components/AdminEventEntriesView.js +7 -13
  84. package/dist/features/events/components/EventRafflesSection.js +14 -2
  85. package/dist/features/events/components/EventStatusBadge.js +1 -1
  86. package/dist/features/events/components/event-entry-detail-fields.d.ts +24 -0
  87. package/dist/features/events/components/event-entry-detail-fields.js +13 -0
  88. package/dist/features/faq/components/ContactCTA.js +1 -1
  89. package/dist/features/faq/components/FAQHelpfulButtons.js +2 -2
  90. package/dist/features/faq/repository/faqs.repository.d.ts +16 -1
  91. package/dist/features/faq/repository/faqs.repository.js +64 -54
  92. package/dist/features/faq/schemas/firestore.d.ts +3 -2
  93. package/dist/features/faq/schemas/firestore.js +2 -2
  94. package/dist/features/faq/schemas/index.d.ts +3 -3
  95. package/dist/features/faq/schemas/index.js +1 -1
  96. package/dist/features/filters/FilterFacetSection.js +2 -2
  97. package/dist/features/forms/Toggle.js +1 -1
  98. package/dist/features/grouped/components/GroupedListingEditorView.d.ts +18 -0
  99. package/dist/features/grouped/components/GroupedListingEditorView.js +192 -0
  100. package/dist/features/homepage/components/AdvertisementBanner.js +2 -2
  101. package/dist/features/homepage/components/BrandsSection.js +1 -1
  102. package/dist/features/homepage/components/CTABannerSection.js +1 -1
  103. package/dist/features/homepage/components/HomepageCustomerReviewsSection.js +1 -1
  104. package/dist/features/homepage/components/HomepageSectionBoundary.d.ts +39 -0
  105. package/dist/features/homepage/components/HomepageSectionBoundary.js +49 -0
  106. package/dist/features/homepage/components/ShopByCategorySection.js +1 -1
  107. package/dist/features/homepage/components/WelcomeSection.js +1 -1
  108. package/dist/features/homepage/lib/section-renderer.js +2 -1
  109. package/dist/features/layout/AppLayoutShell.js +5 -5
  110. package/dist/features/layout/BottomActions.js +5 -5
  111. package/dist/features/layout/BottomSheet.js +1 -1
  112. package/dist/features/layout/MainNavbar.js +1 -1
  113. package/dist/features/layout/NavbarLayout.js +1 -1
  114. package/dist/features/layout/TitleBarLayout.js +3 -3
  115. package/dist/features/layout/hooks/useFormBottomActions.d.ts +11 -1
  116. package/dist/features/layout/hooks/useFormBottomActions.js +16 -2
  117. package/dist/features/lottery/schemas/config-write.d.ts +141 -0
  118. package/dist/features/lottery/schemas/config-write.js +139 -0
  119. package/dist/features/media/MediaPickerModal.js +3 -1
  120. package/dist/features/media/MediaVideo.js +4 -2
  121. package/dist/features/media/components/MediaSlider.js +1 -1
  122. package/dist/features/orders/components/OrdersList.js +2 -2
  123. package/dist/features/payments/repository/payout.repository.d.ts +11 -1
  124. package/dist/features/payments/repository/payout.repository.js +14 -6
  125. package/dist/features/pre-orders/components/MarketplacePreorderCard.js +3 -2
  126. package/dist/features/pre-orders/components/PreOrderBadge.js +1 -1
  127. package/dist/features/pre-orders/components/PreOrderDetailPageView.js +1 -1
  128. package/dist/features/products/columns/productTableColumns.js +3 -3
  129. package/dist/features/products/components/CompareOverlay.js +1 -1
  130. package/dist/features/products/components/PrizeDrawDetailPageView.js +1 -1
  131. package/dist/features/products/components/PrizeDrawEntryActions.js +2 -2
  132. package/dist/features/products/components/PrizeDrawsSection.js +5 -1
  133. package/dist/features/products/components/ProductDetailActions.js +11 -7
  134. package/dist/features/products/components/ProductDetailPageView.js +3 -3
  135. package/dist/features/products/components/ProductFeatureBadges.js +2 -2
  136. package/dist/features/products/components/ProductFeaturesSelector.js +1 -1
  137. package/dist/features/products/components/ProductFilters.js +15 -8
  138. package/dist/features/products/components/ProductGrid.js +8 -17
  139. package/dist/features/products/components/ProductTabs.js +7 -4
  140. package/dist/features/products/components/ProductTabsShell.js +2 -7
  141. package/dist/features/products/constants/action-defs.d.ts +21 -4
  142. package/dist/features/products/constants/action-defs.js +7 -3
  143. package/dist/features/products/hooks/useRelatedProducts.js +2 -1
  144. package/dist/features/products/repository/products.repository.d.ts +1 -1
  145. package/dist/features/products/repository/products.repository.js +27 -13
  146. package/dist/features/products/schemas/firestore.d.ts +3 -2
  147. package/dist/features/products/schemas/firestore.js +1 -1
  148. package/dist/features/reviews/actions/review-actions.d.ts +8 -1
  149. package/dist/features/reviews/actions/review-actions.js +14 -3
  150. package/dist/features/reviews/components/ReviewDetailShell.js +10 -4
  151. package/dist/features/reviews/components/ReviewModal.js +1 -1
  152. package/dist/features/reviews/repository/reviews.repository.d.ts +25 -1
  153. package/dist/features/reviews/repository/reviews.repository.js +53 -13
  154. package/dist/features/reviews/schemas/firestore.d.ts +6 -0
  155. package/dist/features/scams/components/ScamRegistryView.js +2 -2
  156. package/dist/features/search/components/Search.js +3 -3
  157. package/dist/features/search/components/SearchResultsSection.js +1 -1
  158. package/dist/features/seller/components/SellerAddressesView.js +3 -1
  159. package/dist/features/seller/components/SellerBidsView.js +6 -1
  160. package/dist/features/seller/components/SellerSidebar.js +1 -1
  161. package/dist/features/shell/SectionForm.d.ts +21 -1
  162. package/dist/features/shell/SectionForm.js +15 -3
  163. package/dist/features/shell/build-sections.js +10 -1
  164. package/dist/features/shell/field-ui-meta.d.ts +21 -0
  165. package/dist/features/shipments/components/AdminShipmentLotItemsView.js +23 -6
  166. package/dist/features/site-settings/components/ActionPermissionsManager.js +1 -1
  167. package/dist/features/store-extensions/repository/store-extensions.repositories.d.ts +21 -4
  168. package/dist/features/store-extensions/repository/store-extensions.repositories.js +24 -4
  169. package/dist/features/store-extensions/schemas/shipping-config-form.js +13 -0
  170. package/dist/features/store-extensions/schemas/store-category-form.js +1 -1
  171. package/dist/features/stores/actions/store-query-actions.js +25 -1
  172. package/dist/features/stores/components/InteractiveStoreCard.js +1 -1
  173. package/dist/features/stores/components/StoreHeader.js +1 -1
  174. package/dist/features/stores/components/StoreNavTabs.js +20 -8
  175. package/dist/features/support/repository/support.repository.d.ts +12 -0
  176. package/dist/features/support/repository/support.repository.js +15 -0
  177. package/dist/features/support/schemas/ticket-create-form.d.ts +33 -0
  178. package/dist/features/support/schemas/ticket-create-form.js +82 -0
  179. package/dist/features/tester/seed-data/products-tester-seed-data.js +2 -2
  180. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +138 -1
  181. package/dist/features/wishlist/components/WishlistHeartButton.d.ts +55 -0
  182. package/dist/features/wishlist/components/WishlistHeartButton.js +52 -0
  183. package/dist/features/wishlist/components/index.d.ts +2 -1
  184. package/dist/features/wishlist/components/index.js +1 -1
  185. package/dist/index.d.ts +10 -3
  186. package/dist/index.js +19 -4
  187. package/dist/monitoring/error-tracking.js +39 -5
  188. package/dist/monitoring/server-logger.js +70 -32
  189. package/dist/next/components/ErrorView.js +1 -1
  190. package/dist/next/routing/route-map.d.ts +39 -7
  191. package/dist/next/routing/route-map.js +34 -4
  192. package/dist/providers/db-firebase/base.repository.d.ts +30 -0
  193. package/dist/providers/db-firebase/base.repository.js +56 -9
  194. package/dist/react/ErrorBoundary.js +9 -1
  195. package/dist/react/contexts/SessionContext.js +6 -2
  196. package/dist/react/hooks/useAuthGate.d.ts +22 -0
  197. package/dist/react/hooks/useAuthGate.js +86 -7
  198. package/dist/schemas/registry.d.ts +3 -3
  199. package/dist/security/index.d.ts +1 -1
  200. package/dist/security/index.js +1 -1
  201. package/dist/security/pii-encrypt.d.ts +13 -5
  202. package/dist/security/pii-encrypt.js +132 -6
  203. package/dist/security/pii-schemas.d.ts +20 -4
  204. package/dist/security/pii-schemas.js +23 -5
  205. package/dist/seed/_helpers/product-search-txt.d.ts +18 -0
  206. package/dist/seed/_helpers/product-search-txt.js +33 -0
  207. package/dist/seed/faq-seed-data.d.ts +16 -0
  208. package/dist/seed/faq-seed-data.js +22 -1
  209. package/dist/seed/products-art-seed-data.js +49 -7
  210. package/dist/seed/products-auctions-seed-data.js +12 -4
  211. package/dist/seed/products-classifieds-seed-data.d.ts +5 -0
  212. package/dist/seed/products-classifieds-seed-data.js +65 -9
  213. package/dist/seed/products-digital-codes-seed-data.d.ts +5 -0
  214. package/dist/seed/products-digital-codes-seed-data.js +65 -9
  215. package/dist/seed/products-live-items-seed-data.d.ts +5 -0
  216. package/dist/seed/products-live-items-seed-data.js +33 -5
  217. package/dist/seed/products-preorders-seed-data.d.ts +1 -1
  218. package/dist/seed/products-preorders-seed-data.js +6 -2
  219. package/dist/seed/products-prize-draws-seed-data.d.ts +5 -0
  220. package/dist/seed/products-prize-draws-seed-data.js +57 -8
  221. package/dist/seed/products-standard-seed-data.js +6 -2
  222. package/dist/seed/products-stickers-seed-data.js +49 -7
  223. package/dist/seed/site-settings-seed-data.js +5 -3
  224. package/dist/seo/json-ld.d.ts +44 -2
  225. package/dist/seo/json-ld.js +57 -11
  226. package/dist/styles.css +368 -90
  227. package/dist/tailwind-utilities.css +1 -1
  228. package/dist/tokens/index.d.ts +1 -1
  229. package/dist/tokens/index.js +1 -1
  230. package/dist/tokens/themes/default-dark.js +6 -0
  231. package/dist/tokens/themes/default-light.js +5 -0
  232. package/dist/tokens/tokens.css +84 -0
  233. package/dist/ui/DataTable.js +1 -1
  234. package/dist/ui/DataTable.style.css +3 -3
  235. package/dist/ui/components/BulkActionBar.style.css +1 -3
  236. package/dist/ui/components/Button.js +15 -6
  237. package/dist/ui/components/Dropdown.style.css +1 -4
  238. package/dist/ui/components/FilterChipGroup.js +1 -1
  239. package/dist/ui/components/FilterDrawer.style.css +1 -1
  240. package/dist/ui/components/Icon.d.ts +53 -0
  241. package/dist/ui/components/Icon.js +41 -0
  242. package/dist/ui/components/IconButton.d.ts +13 -0
  243. package/dist/ui/components/IconButton.js +13 -0
  244. package/dist/ui/components/IconButton.style.css +18 -10
  245. package/dist/ui/components/ListingLayout.style.css +2 -10
  246. package/dist/ui/components/ListingToolbar.js +6 -6
  247. package/dist/ui/components/Modal.js +1 -1
  248. package/dist/ui/components/PaginatedSelect.style.css +3 -3
  249. package/dist/ui/components/Pagination.style.css +1 -6
  250. package/dist/ui/components/RichTextEditor.js +1 -1
  251. package/dist/ui/components/RowActionMenu.style.css +1 -1
  252. package/dist/ui/components/SideDrawer.style.css +1 -1
  253. package/dist/ui/components/Slider.js +1 -1
  254. package/dist/ui/components/TabBarShell.d.ts +78 -0
  255. package/dist/ui/components/TabBarShell.js +95 -0
  256. package/dist/ui/components/Tabs.js +12 -8
  257. package/dist/ui/components/Tabs.style.css +249 -41
  258. package/dist/ui/components/ViewToggle.style.css +1 -5
  259. package/dist/ui/components/tabs-overflow.d.ts +17 -0
  260. package/dist/ui/components/tabs-overflow.js +142 -0
  261. package/dist/ui/icons/icon-registry.d.ts +38 -0
  262. package/dist/ui/icons/icon-registry.js +37 -0
  263. package/dist/ui/index.d.ts +6 -0
  264. package/dist/ui/index.js +3 -0
  265. package/dist/utils/index.d.ts +1 -1
  266. package/dist/utils/index.js +1 -1
  267. package/dist/utils/media-url.d.ts +27 -4
  268. package/dist/utils/media-url.js +37 -6
  269. package/dist/utils/search-txt.d.ts +71 -0
  270. package/dist/utils/search-txt.js +118 -0
  271. package/package.json +1 -1
@@ -2,6 +2,7 @@ import { BaseRepository, prepareForFirestore, parseSieveDateValue, } from "../..
2
2
  import { DatabaseError } from "../../../errors";
3
3
  import { increment } from "../../../contracts/field-ops";
4
4
  import { FAQ_FIELDS } from "../../../constants/field-names";
5
+ import { buildSearchTxt, matchesAllSearchTerms, parseSearchTxtQuery, } from "../../../utils/search-txt";
5
6
  import { FAQS_COLLECTION, createFAQId, slugifyQuestion, } from "../schemas";
6
7
  export class FAQsRepository {
7
8
  constructor(repo) {
@@ -51,38 +52,39 @@ export class FAQsRepository {
51
52
  return this.repo.delete(id);
52
53
  }
53
54
  }
55
+ /**
56
+ * FAQ `searchTxt`. Exported so the seed derives byte-identical tokens — the
57
+ * seed writes documents raw via batch.set() and never goes through this
58
+ * repository, which is exactly why all 63 seeded FAQs had no tokens at all.
59
+ *
60
+ * EVERY field is prefix-expanded, including the answer body. An earlier version
61
+ * whole-worded the body to protect the token budget, and a seed test caught what
62
+ * that costs: searching "ship" returned FEWER FAQs than "shipping", because an
63
+ * answer mentioning shipping had only the whole word indexed. Partial match that
64
+ * silently excludes the body is not partial match.
65
+ *
66
+ * Measured over the real seed corpus: max 449 tokens, average 200, nothing near
67
+ * the 600 cap — so nothing is truncated. Re-measure before reusing this shape on
68
+ * a long-form collection (blog posts), where bodies are far larger.
69
+ */
70
+ export function buildFaqSearchTxt(input) {
71
+ const answer = typeof input.answer === "string" ? input.answer : (input.answer?.text ?? "");
72
+ return buildSearchTxt([
73
+ input.question,
74
+ input.category,
75
+ ...(input.tags ?? []),
76
+ answer.replace(/<[^>]+>/g, " "),
77
+ ]);
78
+ }
54
79
  export class FirebaseFAQsRepository extends BaseRepository {
55
80
  constructor() {
56
81
  super(FAQS_COLLECTION);
57
82
  }
58
- buildSearchTokens(input) {
59
- const rawText = [
60
- input.question,
61
- typeof input.answer === "string" ? input.answer : input.answer?.text,
62
- input.category,
63
- ...(input.tags ?? []),
64
- ]
65
- .filter(Boolean)
66
- .join(" ")
67
- .toLowerCase();
68
- return Array.from(new Set(rawText
69
- .split(/[^a-z0-9]+/i)
70
- .map((token) => token.trim())
71
- .filter((token) => token.length >= 2))).slice(0, 50);
72
- }
73
83
  async create(data) {
74
- const searchTokens = this.buildSearchTokens({
75
- question: data.question ?? "",
76
- answer: data.answer ?? {
77
- text: "",
78
- format: "plain",
79
- },
80
- tags: data.tags ?? [],
81
- category: (data.category ?? "general"),
82
- });
84
+ const searchTxt = buildFaqSearchTxt(data);
83
85
  return super.create({
84
86
  ...data,
85
- searchTokens,
87
+ searchTxt,
86
88
  });
87
89
  }
88
90
  async update(id, data) {
@@ -96,39 +98,52 @@ export class FirebaseFAQsRepository extends BaseRepository {
96
98
  };
97
99
  return super.update(id, {
98
100
  ...data,
99
- searchTokens: this.buildSearchTokens({
100
- question: merged.question,
101
- answer: merged.answer,
102
- tags: merged.tags ?? [],
103
- category: merged.category,
104
- }),
101
+ searchTxt: buildFaqSearchTxt(merged),
105
102
  });
106
103
  }
107
104
  async list(model, opts) {
108
105
  let baseQuery = this.getCollection();
109
106
  const tags = opts?.tags?.filter(Boolean) ?? [];
110
- const searchTokens = (opts?.search ?? "")
111
- .toLowerCase()
112
- .split(/[^a-z0-9]+/i)
113
- .map((token) => token.trim())
114
- .filter((token) => token.length >= 2)
115
- .slice(0, 10);
116
- if (tags.length > 0 && searchTokens.length > 0) {
117
- throw new DatabaseError("Combining FAQ tag filters and token search requires a dedicated search index");
107
+ // Longest term first — only ONE may become the array-contains clause
108
+ // (Firestore permits a single array operator per query), and the longest is
109
+ // the cheapest proxy for "narrows the most" without cardinality stats.
110
+ const terms = parseSearchTxtQuery(opts?.search ?? "");
111
+ const rawSearch = (opts?.search ?? "").trim();
112
+ // A query that produced no usable term must return NOTHING, not everything.
113
+ // The old guard dropped tokens under 2 characters, so a 1-character search
114
+ // silently fell through to the entire unfiltered list.
115
+ if (rawSearch && terms.length === 0) {
116
+ return { items: [], total: 0, page: 1, pageSize: 0, totalPages: 0, hasMore: false };
118
117
  }
119
- if (tags.length === 1) {
118
+ // Firestore allows one array operator per query, so tags and search cannot
119
+ // both be pushed down. Search takes the clause and tags refine in memory —
120
+ // this used to throw a DatabaseError and fail the request outright.
121
+ const pushTags = terms.length === 0;
122
+ if (pushTags && tags.length === 1) {
120
123
  baseQuery = baseQuery.where(FAQ_FIELDS.TAGS, "array-contains", tags[0]);
121
124
  }
122
- else if (tags.length > 1) {
125
+ else if (pushTags && tags.length > 1) {
123
126
  baseQuery = baseQuery.where(FAQ_FIELDS.TAGS, "array-contains-any", tags.slice(0, 10));
124
127
  }
125
- if (searchTokens.length === 1) {
126
- baseQuery = baseQuery.where(FAQ_FIELDS.SEARCH_TOKENS, "array-contains", searchTokens[0]);
128
+ if (terms.length > 0) {
129
+ baseQuery = baseQuery.where(FAQ_FIELDS.SEARCH_TXT, "array-contains", terms[0]);
127
130
  }
128
- else if (searchTokens.length > 1) {
129
- baseQuery = baseQuery.where(FAQ_FIELDS.SEARCH_TOKENS, "array-contains-any", searchTokens);
130
- }
131
- return this.sieveQuery(model, FirebaseFAQsRepository.SIEVE_FIELDS, { baseQuery });
131
+ const result = await this.sieveQuery(model, FirebaseFAQsRepository.SIEVE_FIELDS, { baseQuery });
132
+ // Refine the remaining terms (AND) and any tags that could not be pushed
133
+ // down. `array-contains-any` would have been OR — "shipping cost" returning
134
+ // everything matching either word is why search felt like it did nothing.
135
+ const extraTerms = terms.slice(1);
136
+ if (extraTerms.length === 0 && (pushTags || tags.length === 0))
137
+ return result;
138
+ const items = result.items.filter((faq) => matchesAllSearchTerms(faq.searchTxt, extraTerms) &&
139
+ (pushTags || tags.length === 0 || tags.some((t) => faq.tags?.includes(t))));
140
+ return {
141
+ ...result,
142
+ items,
143
+ total: items.length,
144
+ totalPages: items.length === 0 ? 0 : 1,
145
+ hasMore: false,
146
+ };
132
147
  }
133
148
  async getFAQBySlug(slug) {
134
149
  const snapshot = await this.db
@@ -248,12 +263,7 @@ export class FirebaseFAQsRepository extends BaseRepository {
248
263
  ...input.seo,
249
264
  slug,
250
265
  },
251
- searchTokens: this.buildSearchTokens({
252
- question: input.question,
253
- answer: input.answer,
254
- tags: input.tags ?? [],
255
- category: input.category,
256
- }),
266
+ searchTxt: buildFaqSearchTxt(input),
257
267
  stats: {
258
268
  views: 0,
259
269
  helpful: 0,
@@ -293,7 +303,7 @@ FirebaseFAQsRepository.SIEVE_FIELDS = {
293
303
  order: { canFilter: true, canSort: true },
294
304
  priority: { canFilter: true, canSort: true },
295
305
  tags: { canFilter: true, canSort: false },
296
- searchTokens: { canFilter: true, canSort: false },
306
+ searchTxt: { canFilter: true, canSort: false },
297
307
  "stats.helpful": { canFilter: false, canSort: true },
298
308
  createdAt: { canFilter: true, canSort: true, parseValue: parseSieveDateValue },
299
309
  };
@@ -19,7 +19,8 @@ export interface FAQDocument extends BaseDocument {
19
19
  order: number;
20
20
  priority: number;
21
21
  tags: string[];
22
- searchTokens?: string[];
22
+ /** Normalized edge-n-gram search field. Replaced `searchTokens`. */
23
+ searchTxt?: string[];
23
24
  relatedFAQs: string[];
24
25
  useSiteSettings: boolean;
25
26
  variables?: Record<string, string>;
@@ -31,7 +32,7 @@ export interface FAQDocument extends BaseDocument {
31
32
  createdBy: string;
32
33
  }
33
34
  export declare const FAQS_COLLECTION: "faqs";
34
- export declare const FAQS_INDEXED_FIELDS: readonly ["slug", "seo.slug", "category", "order", "showOnHomepage", "showInFooter", "isPinned", "priority", "tags", "searchTokens", "isActive", "stats.helpful", "createdBy", "createdAt"];
35
+ export declare const FAQS_INDEXED_FIELDS: readonly ["slug", "seo.slug", "category", "order", "showOnHomepage", "showInFooter", "isPinned", "priority", "tags", "searchTxt", "isActive", "stats.helpful", "createdBy", "createdAt"];
35
36
  export declare const FAQ_CATEGORY_LABELS: Record<FAQCategory, string>;
36
37
  export declare const DEFAULT_FAQ_DATA: Partial<FAQDocument>;
37
38
  export declare const FAQS_PUBLIC_FIELDS: readonly ["id", "question", "answer", "category", "showOnHomepage", "showInFooter", "isPinned", "order", "priority", "tags", "relatedFAQs", "stats.views", "stats.helpful", "stats.notHelpful", "seo.slug", "seo.metaTitle", "seo.metaDescription", "isActive", "createdAt"];
@@ -13,7 +13,7 @@ export const FAQS_INDEXED_FIELDS = [
13
13
  "isPinned",
14
14
  "priority",
15
15
  "tags",
16
- "searchTokens",
16
+ "searchTxt",
17
17
  "isActive",
18
18
  "stats.helpful",
19
19
  "createdBy",
@@ -36,7 +36,7 @@ export const DEFAULT_FAQ_DATA = {
36
36
  order: 0,
37
37
  priority: 0,
38
38
  tags: [],
39
- searchTokens: [],
39
+ searchTxt: [],
40
40
  relatedFAQs: [],
41
41
  useSiteSettings: true,
42
42
  variables: {},
@@ -228,7 +228,7 @@ export declare const faqFirestoreSchema: z.ZodObject<{
228
228
  order: z.ZodNumber;
229
229
  priority: z.ZodNumber;
230
230
  tags: z.ZodArray<z.ZodString, "many">;
231
- searchTokens: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
231
+ searchTxt: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
232
232
  relatedFAQs: z.ZodArray<z.ZodString, "many">;
233
233
  useSiteSettings: z.ZodBoolean;
234
234
  variables: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
@@ -310,7 +310,7 @@ export declare const faqFirestoreSchema: z.ZodObject<{
310
310
  metaTitle?: string | undefined;
311
311
  metaDescription?: string | undefined;
312
312
  };
313
- searchTokens?: string[] | undefined;
313
+ searchTxt?: string[] | undefined;
314
314
  variables?: Record<string, string> | undefined;
315
315
  }, {
316
316
  id: string;
@@ -349,7 +349,7 @@ export declare const faqFirestoreSchema: z.ZodObject<{
349
349
  metaTitle?: string | undefined;
350
350
  metaDescription?: string | undefined;
351
351
  };
352
- searchTokens?: string[] | undefined;
352
+ searchTxt?: string[] | undefined;
353
353
  variables?: Record<string, string> | undefined;
354
354
  }>;
355
355
  export type FAQFromSchema = z.infer<typeof faqFirestoreSchema>;
@@ -85,7 +85,7 @@ export const faqFirestoreSchema = z.object({
85
85
  order: z.number().int(),
86
86
  priority: z.number().int(),
87
87
  tags: z.array(z.string()),
88
- searchTokens: z.array(z.string()).optional(),
88
+ searchTxt: z.array(z.string()).optional(),
89
89
  relatedFAQs: z.array(z.string()),
90
90
  useSiteSettings: z.boolean(),
91
91
  variables: z.record(z.string(), z.string()).optional(),
@@ -77,9 +77,9 @@ export function FilterFacetSection({ title, options, selected, onChange, selecti
77
77
  if (displayAs === "dropdown") {
78
78
  return (_jsxs(Div, { ref: dropdownRef, className: cn("relative inline-block", className), children: [_jsxs(Button, { type: "button", onClick: () => setDropdownOpen((o) => !o), variant: "ghost", className: cn("inline-flex items-center gap-[var(--appkit-space-1-5)] rounded-full border px-[var(--appkit-space-3)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-sm)] font-medium transition-colors", hasValue
79
79
  ? CLS_OPTION_SELECTED
80
- : "border-zinc-200 bg-[var(--appkit-color-surface)] text-zinc-700 hover:border-zinc-400 border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] text-[var(--appkit-color-text-muted)] hover:border-[var(--appkit-color-border)]"), children: [title, hasValue && (_jsx(Span, { weight: "bold", className: CLS_OPTION_DOT, children: selected.length })), _jsx("svg", { className: cn("h-3.5 w-3.5 text-zinc-400 transition-transform", dropdownOpen ? "rotate-180" : ""), fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", strokeWidth: 2, "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M19 9l-7 7-7-7" }) })] }), dropdownOpen && (_jsxs(Div, { className: "absolute left-0 top-full z-[var(--appkit-z-dropdown,50)] mt-1 min-w-[180px] max-w-[260px]", surface: "default", rounded: "xl", shadow: "lg", border: "default", children: [searchable && (_jsx(Div, { border: "subtle", className: "border-b", padding: "xs", children: _jsx(Input, { type: "search", placeholder: "Search\u2026", value: search, onChange: (e) => handleSearch(e.target.value), className: "w-full rounded-md border px-[var(--appkit-space-2)] py-[var(--appkit-space-1)] text-[length:var(--appkit-text-xs)] focus:outline-none border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface-input)] text-[var(--appkit-color-text)]" }) })), _jsxs(Div, { className: `max-h-56 ${__O.yAuto}`, padding: "y-2xs", children: [filtered.map((option) => {
80
+ : " bg-[var(--appkit-color-surface)] hover:border-zinc-400 border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] text-[var(--appkit-color-text-muted)] hover:border-[var(--appkit-color-border)]"), children: [title, hasValue && (_jsx(Span, { weight: "bold", className: CLS_OPTION_DOT, children: selected.length })), _jsx("svg", { className: cn("h-3.5 w-3.5 text-zinc-400 transition-transform", dropdownOpen ? "rotate-180" : ""), fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", strokeWidth: 2, "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M19 9l-7 7-7-7" }) })] }), dropdownOpen && (_jsxs(Div, { className: "absolute left-0 top-full z-[var(--appkit-z-dropdown,50)] mt-1 min-w-[180px] max-w-[260px]", surface: "default", rounded: "xl", shadow: "lg", border: "default", children: [searchable && (_jsx(Div, { border: "subtle", className: "border-b", padding: "xs", children: _jsx(Input, { type: "search", placeholder: "Search\u2026", value: search, onChange: (e) => handleSearch(e.target.value), className: "w-full rounded-md border px-[var(--appkit-space-2)] py-[var(--appkit-space-1)] text-[length:var(--appkit-text-xs)] focus:outline-none border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface-input)] text-[var(--appkit-color-text)]" }) })), _jsxs(Div, { className: `max-h-56 ${__O.yAuto}`, padding: "y-2xs", children: [filtered.map((option) => {
81
81
  const isSelected = selected.includes(option.value);
82
- return (_jsxs(Row, { gap: "xs", className: "cursor-pointer py-[0.375rem] hover:bg-[var(--appkit-color-bg)] mx-1", padding: "x-sm", rounded: "lg", onClick: () => toggle(option.value), children: [_jsx(Checkbox, { bare: true, type: selectionMode === "single" ? "radio" : "checkbox", checked: isSelected, onChange: () => toggle(option.value), "aria-label": option.label, className: "h-3.5 w-3.5 flex-shrink-0 cursor-pointer rounded border-zinc-300 text-primary-600 border-[var(--appkit-color-border)] dark:text-secondary-500" }), _jsx(Span, { size: "xs", className: "flex-1 truncate", color: "muted", children: option.label }), option.count !== undefined && (_jsx(Span, { className: "flex-shrink-0 text-[10px] tabular-nums", color: "faint", children: option.count }))] }, option.value));
82
+ return (_jsxs(Row, { gap: "xs", className: "cursor-pointer py-[0.375rem] hover:bg-[var(--appkit-color-bg)] mx-1", padding: "x-sm", rounded: "lg", onClick: () => toggle(option.value), children: [_jsx(Checkbox, { bare: true, type: selectionMode === "single" ? "radio" : "checkbox", checked: isSelected, onChange: () => toggle(option.value), "aria-label": option.label, className: "h-3.5 w-3.5 flex-shrink-0 cursor-pointer rounded text-primary-600 border-[var(--appkit-color-border)] dark:text-secondary-500" }), _jsx(Span, { size: "xs", className: "flex-1 truncate", color: "muted", children: option.label }), option.count !== undefined && (_jsx(Span, { className: "flex-shrink-0 text-[10px] tabular-nums", color: "faint", children: option.count }))] }, option.value));
83
83
  }), filtered.length === 0 && (_jsx(Text, { paddingY: "xs", paddingX: "x-sm", color: "faint", size: "xs", children: "No results" }))] }), hasValue && (_jsx(Div, { border: "subtle", className: "border-t p-[0.25rem]", children: _jsx(Button, { type: "button", variant: "ghost", size: "sm", className: CLS_CLEAR_BTN, onClick: () => { onChange([]); onClear?.(); setDropdownOpen(false); }, children: "Clear" }) }))] }))] }));
84
84
  }
85
85
  // ── Accordion variant (default) ─────────────────────────────────────────
@@ -45,7 +45,7 @@ export function Toggle({ checked: controlledChecked, defaultChecked = false, onC
45
45
  const cfg = SIZE_CONFIG[size];
46
46
  return (_jsxs(Row, { className: cn("gap-3", className), children: [_jsx(Button, { type: "button", role: "switch", id: toggleId, "aria-checked": checked, disabled: disabled, onClick: handleChange, className: cn("relative inline-flex items-center rounded-full transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-primary-500/30 dark:focus:ring-secondary-400/30 focus:ring-offset-2 dark:focus:ring-offset-slate-900", cfg.track, checked
47
47
  ? "bg-primary-600 dark:bg-secondary-500"
48
- : "bg-zinc-200 bg-[var(--appkit-color-border)]", disabled && "opacity-50 cursor-not-allowed"), children: _jsx(Span, { className: cn("inline-block rounded-full bg-[var(--appkit-color-surface)] shadow-sm transition-transform duration-200", cfg.thumb, checked ? cfg.translateOn : cfg.translateOff) }) }), label && (_jsx("label", { htmlFor: toggleId, className: cn("text-sm font-medium cursor-pointer select-none", checked
48
+ : " bg-[var(--appkit-color-border)]", disabled && "opacity-50 cursor-not-allowed"), children: _jsx(Span, { className: cn("inline-block rounded-full bg-[var(--appkit-color-surface)] shadow-sm transition-transform duration-200", cfg.thumb, checked ? cfg.translateOn : cfg.translateOff) }) }), label && (_jsx("label", { htmlFor: toggleId, className: cn("text-sm font-medium cursor-pointer select-none", checked
49
49
  ? "text-[var(--appkit-color-text)]"
50
50
  : "text-[var(--appkit-color-text-muted)]", disabled && "opacity-50 cursor-not-allowed"), children: label }))] }));
51
51
  }
@@ -0,0 +1,18 @@
1
+ import React from "react";
2
+ type Scope = "admin" | "store";
3
+ export interface GroupedListingEditorViewProps {
4
+ scope: Scope;
5
+ /** Present → edit that group; absent → create a new one. */
6
+ groupId?: string;
7
+ /**
8
+ * Store the group belongs to. Required for admin CREATE (an admin owns no
9
+ * store, so there is nothing to derive it from); ignored on the seller side,
10
+ * where the route reads it from the session and a body value would let a
11
+ * seller file a group under someone else's store.
12
+ */
13
+ fixedStoreId?: string;
14
+ onSaved?: (id: string) => void;
15
+ onCancel?: () => void;
16
+ }
17
+ export declare function GroupedListingEditorView({ scope, groupId, fixedStoreId, onSaved, onCancel, }: GroupedListingEditorViewProps): React.JSX.Element;
18
+ export {};
@@ -0,0 +1,192 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ /*
4
+ * WHY: There were TWO hand-rolled grouped-listing forms (the seller's `new` and
5
+ * `[id]/edit` pages), between them covering 5 of the entity's 8 fields —
6
+ * `productIds` was hardcoded to `[]` on create, and `minActiveMembers` and
7
+ * `coverImage` could not be set anywhere at all. Admin had no form.
8
+ * WHAT: One editor for both portals, deriving its sections from
9
+ * `groupedListingFormSchema` so a field added to the schema appears here
10
+ * without a second edit.
11
+ *
12
+ * ## The admin API was the blocker, not this component
13
+ *
14
+ * Pointing an editor at the admin endpoints used to give: create → 405 (there
15
+ * was no admin POST), and every save of `title`/`groupTheme`/`isActive` → a
16
+ * 200 that wrote nothing, because the admin PATCH schema was
17
+ * `z.object({ productIds })` and `z.object()` strips unknown keys. That is
18
+ * Root Cause #40's exact shape — a success response is what the broken version
19
+ * returns, so only a reload reveals it. Both were fixed alongside this file;
20
+ * an `endpointOverride` on the seller form could never have worked.
21
+ *
22
+ * ## Two fields are overridden, the other six are derived
23
+ *
24
+ * `productIds` needs the real product search (`ProductInlineSelect`), and admin
25
+ * create needs a store picker — a grouped listing is store-owned and the seller
26
+ * route takes `storeId` from the session, which an admin has no equivalent of.
27
+ * Everything else comes from `buildSectionsFromSchema`.
28
+ *
29
+ * EXPORTS: GroupedListingEditorView, type GroupedListingEditorViewProps
30
+ *
31
+ * @tag domain:grouped
32
+ * @tag layer:feature
33
+ * @tag pattern:slot-shell
34
+ * @tag access:client
35
+ * @tag consumers:admin/grouped-listings pages,store/grouped-listings pages
36
+ * @tag sideEffects:network
37
+ */
38
+ import React from "react";
39
+ import { useQuery } from "@tanstack/react-query";
40
+ import { useRouter } from "next/navigation";
41
+ import { Stack, Text, useToast } from "../../../ui";
42
+ import { FieldSelect, FormShellContext, FormErrorSummary, applyZodIssues, useFormShellState, } from "../../../ui/forms";
43
+ import { SectionForm, useSectionFormNav } from "../../shell";
44
+ import { buildSectionsFromSchema } from "../../shell/build-sections";
45
+ import { apiClient } from "../../../http";
46
+ import { ADMIN_ENDPOINTS, SELLER_ENDPOINTS } from "../../../constants/api-endpoints";
47
+ import { ROUTES } from "../../../next/routing/route-map";
48
+ import { normalizeError } from "../../../errors/normalize";
49
+ import { ProductInlineSelect } from "../../seller/components/ProductInlineSelect";
50
+ import { groupedListingFormSchema, } from "../schemas/grouped-listing-form";
51
+ const EMPTY = {
52
+ title: "",
53
+ description: "",
54
+ groupTheme: "generic",
55
+ productIds: [],
56
+ minActiveMembers: 1,
57
+ coverImage: "",
58
+ isActive: true,
59
+ isFeatured: false,
60
+ };
61
+ export function GroupedListingEditorView({ scope, groupId, fixedStoreId, onSaved, onCancel, }) {
62
+ const router = useRouter();
63
+ const { showToast } = useToast();
64
+ const isEdit = Boolean(groupId);
65
+ const isAdmin = scope === "admin";
66
+ const listEndpoint = isAdmin ? ADMIN_ENDPOINTS.GROUPED_LISTINGS : SELLER_ENDPOINTS.GROUPED_LISTINGS;
67
+ const byIdEndpoint = isAdmin
68
+ ? ADMIN_ENDPOINTS.GROUPED_LISTING_BY_ID
69
+ : SELLER_ENDPOINTS.GROUPED_LISTING_BY_ID;
70
+ const [values, setValues] = React.useState(EMPTY);
71
+ const [storeId, setStoreId] = React.useState(fixedStoreId ?? "");
72
+ const [saving, setSaving] = React.useState(false);
73
+ /*
74
+ * Seeded from the single-item GET, never from a cached list row — a list
75
+ * projection is narrower than the document, and an editor seeded from one
76
+ * re-sends the fields it never received as their defaults (Root Cause #38).
77
+ */
78
+ const existing = useQuery({
79
+ queryKey: [scope, "grouped-listing", groupId],
80
+ queryFn: async () => {
81
+ const res = await apiClient.get(byIdEndpoint(groupId));
82
+ const body = res?.data ?? res;
83
+ // The admin GET wraps in `{ item }`; the seller GET returns the doc.
84
+ return (body?.item ?? body);
85
+ },
86
+ enabled: isEdit,
87
+ });
88
+ // Admin create only: an admin has no session store to derive the owner from.
89
+ const stores = useQuery({
90
+ queryKey: ["admin", "grouped-listing-editor", "stores"],
91
+ queryFn: async () => {
92
+ const res = await apiClient.get(`${ADMIN_ENDPOINTS.STORES}?pageSize=200&sorts=storeName`);
93
+ const body = res?.data ?? res;
94
+ return (body?.items ?? []);
95
+ },
96
+ enabled: isAdmin && !isEdit && !fixedStoreId,
97
+ });
98
+ React.useEffect(() => {
99
+ const doc = existing.data;
100
+ if (!doc)
101
+ return;
102
+ setValues({
103
+ title: String(doc.title ?? ""),
104
+ description: String(doc.description ?? ""),
105
+ groupTheme: doc.groupTheme ?? "generic",
106
+ productIds: Array.isArray(doc.productIds) ? doc.productIds.map(String) : [],
107
+ minActiveMembers: Number(doc.minActiveMembers ?? 1),
108
+ coverImage: String(doc.coverImage ?? ""),
109
+ isActive: doc.isActive !== false,
110
+ isFeatured: doc.isFeatured === true,
111
+ });
112
+ if (doc.storeId)
113
+ setStoreId(String(doc.storeId));
114
+ }, [existing.data]);
115
+ const update = React.useCallback((partial) => {
116
+ setValues((prev) => ({ ...prev, ...partial }));
117
+ }, []);
118
+ const needsStorePicker = isAdmin && !isEdit && !fixedStoreId;
119
+ const sections = React.useMemo(() => buildSectionsFromSchema(groupedListingFormSchema, {
120
+ renderers: {
121
+ productIds: ({ values: v, onChange }) => (_jsxs(Stack, { gap: "xs", children: [_jsx(ProductInlineSelect, { scope: scope, multiple: true, value: v.productIds ?? [], onChange: (ids) => onChange({ productIds: ids }), placeholder: "Search products to add\u2026" }), _jsxs(Text, { size: "xs", color: "muted", children: [(v.productIds ?? []).length, " product", (v.productIds ?? []).length === 1 ? "" : "s", " in this group."] })] })),
122
+ },
123
+ }), [scope]);
124
+ /*
125
+ * The nav seam — without it `FormErrorSummary`'s "jump to section" resolves
126
+ * to undefined, which is what it did for every form before W0.
127
+ */
128
+ const { openIds, setOpenIds, goToSection, fieldToSectionIndex, sectionMeta } = useSectionFormNav(sections, values);
129
+ const { shellCtx, setFieldError, validate } = useFormShellState(groupedListingFormSchema, {
130
+ sections: sectionMeta,
131
+ onGoToSection: goToSection,
132
+ fieldToSectionIndex,
133
+ });
134
+ React.useEffect(() => {
135
+ validate(values);
136
+ // eslint-disable-next-line react-hooks/exhaustive-deps
137
+ }, [values, validate]);
138
+ const handleSubmit = async () => {
139
+ const parsed = groupedListingFormSchema.safeParse(values);
140
+ if (!parsed.success) {
141
+ applyZodIssues(parsed.error.issues, setFieldError);
142
+ return;
143
+ }
144
+ if (needsStorePicker && !storeId) {
145
+ showToast("Pick the store this group belongs to.", "error");
146
+ return;
147
+ }
148
+ setSaving(true);
149
+ try {
150
+ if (isEdit) {
151
+ await apiClient.patch(byIdEndpoint(groupId), parsed.data);
152
+ }
153
+ else {
154
+ /*
155
+ * `storeId` is sent ONLY by admin. The seller route derives it from the
156
+ * session and rejects it in the body, which is what stops a seller
157
+ * filing a group under another store.
158
+ */
159
+ const payload = isAdmin ? { ...parsed.data, storeId } : parsed.data;
160
+ const res = await apiClient.post(listEndpoint, payload);
161
+ const created = res?.data?.id;
162
+ if (created && onSaved) {
163
+ showToast("Group created.", "success");
164
+ onSaved(created);
165
+ return;
166
+ }
167
+ }
168
+ showToast(isEdit ? "Group updated." : "Group created.", "success");
169
+ if (onSaved)
170
+ onSaved(groupId ?? "");
171
+ else
172
+ router.push(String(isAdmin ? ROUTES.ADMIN.GROUPED_LISTINGS : ROUTES.STORE.GROUPED_LISTINGS));
173
+ }
174
+ catch (err) {
175
+ const e = normalizeError(err);
176
+ showToast(e.message || "Failed to save the group.", "error");
177
+ }
178
+ finally {
179
+ setSaving(false);
180
+ }
181
+ };
182
+ if (isEdit && existing.isLoading) {
183
+ return _jsx(Text, { color: "muted", children: "Loading\u2026" });
184
+ }
185
+ return (_jsxs(Stack, { gap: "md", children: [needsStorePicker ? (_jsx(FieldSelect, { name: "storeId", label: "Store", required: true, value: storeId, onChange: setStoreId, options: [
186
+ { value: "", label: "Select a store…" },
187
+ ...(stores.data ?? []).map((s) => ({
188
+ value: s.id,
189
+ label: s.storeName ?? s.id,
190
+ })),
191
+ ] })) : null, _jsxs(FormShellContext.Provider, { value: shellCtx, children: [_jsx(FormErrorSummary, {}), _jsx(SectionForm, { sections: sections, values: values, onChange: update, onSubmit: handleSubmit, schema: groupedListingFormSchema, openIds: openIds, onOpenChange: setOpenIds, submitLabel: isEdit ? "Save changes" : "Create group", isLoading: saving, onCancel: onCancel })] })] }));
192
+ }
@@ -11,8 +11,8 @@ const __O = {
11
11
  hidden: "overflow-hidden",
12
12
  };
13
13
  const CLS_GRADIENT_BG = "absolute inset-0 bg-[image:var(--appkit-gradient-promotion)]";
14
- const CLS_CTA_BTN_SM = "bg-[var(--appkit-color-surface)] bg-[var(--appkit-color-surface)] text-indigo-700 hover:bg-zinc-50 dark:hover:bg-zinc-200 font-semibold gap-[var(--appkit-space-1-5)] flex-shrink-0";
15
- const CLS_CTA_BTN_LG = "bg-[var(--appkit-color-surface)] bg-[var(--appkit-color-surface)] text-indigo-700 hover:bg-zinc-50 dark:hover:bg-zinc-200 font-semibold shadow-lg gap-[var(--appkit-space-2)]";
14
+ const CLS_CTA_BTN_SM = "bg-[var(--appkit-color-surface)] bg-[var(--appkit-color-surface)] text-indigo-700 hover:bg-surface-hover font-semibold gap-[var(--appkit-space-1-5)] flex-shrink-0";
15
+ const CLS_CTA_BTN_LG = "bg-[var(--appkit-color-surface)] bg-[var(--appkit-color-surface)] text-indigo-700 hover:bg-surface-hover font-semibold shadow-lg gap-[var(--appkit-space-2)]";
16
16
  // --- Constants ---------------------------------------------------------------
17
17
  const CLS_CONTAINER = "w-full max-w-7xl mx-auto";
18
18
  // --- Component ---------------------------------------------------------------
@@ -29,7 +29,7 @@ function BrandLogo({ brand }) {
29
29
  const iconSrc = brand.display?.icon;
30
30
  const coverImage = brand.display?.coverImage;
31
31
  const initial = brand.name[0]?.toUpperCase() ?? "?";
32
- return (_jsxs(Link, { href: ROUTES.PUBLIC.CATEGORY_DETAIL(brand.slug), className: "flex h-32 w-36 flex-col items-center justify-center gap-[var(--appkit-space-2)] rounded-xl border border-zinc-200 bg-[var(--appkit-color-surface)] p-[var(--appkit-space-3)] shadow-sm transition-all hover:border-primary-300 hover:shadow-md border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] md:h-40", children: [iconSrc || coverImage ? (_jsx(Image, { src: resolveMediaUrl(iconSrc ?? coverImage), alt: brand.name, width: 64, height: 64, className: "h-16 w-16 rounded object-contain" })) : (_jsx(Row, { textWeight: "bold", textSize: "sm", className: "h-16 w-16 bg-primary-100 text-primary-700 dark:bg-primary-900 dark:text-primary-300", align: "center", justify: "center", rounded: "lg", children: initial })), _jsx(Text, { className: "w-full truncate", color: "muted", size: "xs", weight: "medium", align: "start", children: brand.name })] }));
32
+ return (_jsxs(Link, { href: ROUTES.PUBLIC.CATEGORY_DETAIL(brand.slug), className: "flex h-32 w-36 flex-col items-center justify-center gap-[var(--appkit-space-2)] rounded-xl border bg-[var(--appkit-color-surface)] p-[var(--appkit-space-3)] shadow-sm transition-all hover:border-primary-300 hover:shadow-md border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] md:h-40", children: [iconSrc || coverImage ? (_jsx(Image, { src: resolveMediaUrl(iconSrc ?? coverImage), alt: brand.name, width: 64, height: 64, className: "h-16 w-16 rounded object-contain" })) : (_jsx(Row, { textWeight: "bold", textSize: "sm", className: "h-16 w-16 bg-primary-100 text-primary-700 dark:bg-primary-900 dark:text-primary-300", align: "center", justify: "center", rounded: "lg", children: initial })), _jsx(Text, { className: "w-full truncate", color: "muted", size: "xs", weight: "medium", align: "start", children: brand.name })] }));
33
33
  }
34
34
  export function BrandsSection({ title = "Top Brands", subtitle, limit = 12, viewMoreHref, viewMoreLabel = "All brands →", className = "", initialItems, cta, filters, autoScroll = true, scrollInterval = 4000, loop = true, }) {
35
35
  const themed = { textPrimary: THEMED_TEXT_PRIMARY };
@@ -3,5 +3,5 @@ import Link from "next/link";
3
3
  import { Div, Heading, Row, Section, Span, Text } from "../../../ui";
4
4
  import { ROUTES } from "../../../next/routing/route-map";
5
5
  export function CTABannerSection({ title = "Thousands of collectibles. One marketplace.", subtitle, primaryLabel = "Shop All Products →", primaryHref = String(ROUTES.PUBLIC.PRODUCTS), secondaryLabel = "Browse Auctions →", secondaryHref = String(ROUTES.PUBLIC.AUCTIONS), className = "", }) {
6
- return (_jsx(Section, { tone: "accent-banner", className: `${className}`, paddingY: "y-4xl", paddingX: "x-md", children: _jsxs(Div, { className: "mx-auto max-w-3xl text-left", children: [_jsx(Span, { className: "mb-4 inline-block", size: "2xl", "aria-hidden": "true", children: "\u2728" }), _jsx(Heading, { color: "inverse", level: 2, mdSize: "3xl", lgSize: "4xl", size: "2xl", weight: "bold", children: title }), subtitle && (_jsx(Text, { color: "inverse", className: "mt-3 /80", size: "base", children: subtitle })), _jsxs(Row, { className: "mt-8", align: "center", justify: "start", gap: "md", wrap: true, children: [_jsx(Link, { href: primaryHref, className: "inline-flex items-center rounded-full bg-white px-[var(--appkit-space-8)] py-[var(--appkit-space-3)] text-[length:var(--appkit-text-sm)] font-semibold text-primary-700 shadow-sm transition-all hover:bg-zinc-50 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-primary-600", children: primaryLabel }), secondaryHref && (_jsx(Link, { href: secondaryHref, className: "inline-flex items-center rounded-full border-2 border-white/80 px-[var(--appkit-space-8)] py-[var(--appkit-space-3)] text-[length:var(--appkit-text-sm)] font-semibold text-white transition-all hover:bg-white/10 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-primary-600", children: secondaryLabel }))] })] }) }));
6
+ return (_jsx(Section, { tone: "accent-banner", className: `${className}`, paddingY: "y-4xl", paddingX: "x-md", children: _jsxs(Div, { className: "mx-auto max-w-3xl text-left", children: [_jsx(Span, { className: "mb-4 inline-block", size: "2xl", "aria-hidden": "true", children: "\u2728" }), _jsx(Heading, { color: "inverse", level: 2, mdSize: "3xl", lgSize: "4xl", size: "2xl", weight: "bold", children: title }), subtitle && (_jsx(Text, { color: "inverse", className: "mt-3 /80", size: "base", children: subtitle })), _jsxs(Row, { className: "mt-8", align: "center", justify: "start", gap: "md", wrap: true, children: [_jsx(Link, { href: primaryHref, className: "inline-flex items-center rounded-full bg-white px-[var(--appkit-space-8)] py-[var(--appkit-space-3)] text-[length:var(--appkit-text-sm)] font-semibold text-primary-700 shadow-sm transition-all hover:bg-surface-hover hover:shadow-md focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-primary-600", children: primaryLabel }), secondaryHref && (_jsx(Link, { href: secondaryHref, className: "inline-flex items-center rounded-full border-2 border-white/80 px-[var(--appkit-space-8)] py-[var(--appkit-space-3)] text-[length:var(--appkit-text-sm)] font-semibold text-white transition-all hover:bg-white/10 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-primary-600", children: secondaryLabel }))] })] }) }));
7
7
  }
@@ -5,5 +5,5 @@ import { useHomepageReviews } from "../hooks/useHomepageReviews";
5
5
  import { ReviewCard } from "../../reviews/components";
6
6
  export function HomepageCustomerReviewsSection({ title = "What Our Customers Say", subtitle, viewMoreHref, viewMoreLabel = "See all reviews →", className = "", autoScroll = true, scrollInterval = 4500, loop = true, }) {
7
7
  const { data: reviews = [], isLoading } = useHomepageReviews();
8
- return (_jsx(CustomerReviewsSection, { title: title, subtitle: subtitle, items: reviews, renderItem: (review) => (_jsx(ReviewCard, { review: review, className: "h-full border-zinc-200 shadow-sm border-[var(--appkit-color-border)]" })), viewMoreHref: viewMoreHref, viewMoreLabel: viewMoreLabel, isLoading: isLoading, keyExtractor: (review) => review.id, className: className, autoScroll: autoScroll, scrollInterval: scrollInterval, loop: loop }));
8
+ return (_jsx(CustomerReviewsSection, { title: title, subtitle: subtitle, items: reviews, renderItem: (review) => (_jsx(ReviewCard, { review: review, className: "h-full shadow-sm border-[var(--appkit-color-border)]" })), viewMoreHref: viewMoreHref, viewMoreLabel: viewMoreLabel, isLoading: isLoading, keyExtractor: (review) => review.id, className: className, autoScroll: autoScroll, scrollInterval: scrollInterval, loop: loop }));
9
9
  }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Crash containment for a single homepage section.
3
+ *
4
+ * The homepage renders ~26 independently-fetched sections. Before this existed,
5
+ * a throw inside ANY one of them propagated to `[locale]/error.tsx` and replaced
6
+ * the entire page with "Something went wrong" — which is exactly what one bad
7
+ * import in `PrizeDrawsSection` did in production on 2026-08-26.
8
+ *
9
+ * A section is not load-bearing for the page. Losing one should cost that strip
10
+ * and nothing else, so the fallback renders NOTHING: a visitor sees a homepage
11
+ * missing one row instead of a homepage that is entirely an error message.
12
+ *
13
+ * The failure is still reported — `ErrorBoundary` calls `trackError`, which the
14
+ * app wires to the client-error beacon, so the row lands in `serverErrors` and
15
+ * shows up at /admin/maintenance/server-errors. Silent to the visitor, loud to
16
+ * the operator; the inverse would be the Root Cause #59 swallow.
17
+ *
18
+ * `sectionId` is a plain string on purpose. This is a Client Component rendered
19
+ * from a Server Component, so a function prop (e.g. `onError`) would not be
20
+ * serialisable across that boundary.
21
+ */
22
+ import { Component, type ErrorInfo, type ReactNode } from "react";
23
+ export interface HomepageSectionBoundaryProps {
24
+ /** Homepage section document id — identifies the strip in the error report. */
25
+ sectionId: string;
26
+ /** Section type, e.g. "prize-draws". */
27
+ sectionType: string;
28
+ children: ReactNode;
29
+ }
30
+ interface State {
31
+ hasError: boolean;
32
+ }
33
+ export declare class HomepageSectionBoundary extends Component<HomepageSectionBoundaryProps, State> {
34
+ constructor(props: HomepageSectionBoundaryProps);
35
+ static getDerivedStateFromError(): State;
36
+ componentDidCatch(error: Error, errorInfo: ErrorInfo): void;
37
+ render(): ReactNode;
38
+ }
39
+ export {};
@@ -0,0 +1,49 @@
1
+ "use client";
2
+ /**
3
+ * Crash containment for a single homepage section.
4
+ *
5
+ * The homepage renders ~26 independently-fetched sections. Before this existed,
6
+ * a throw inside ANY one of them propagated to `[locale]/error.tsx` and replaced
7
+ * the entire page with "Something went wrong" — which is exactly what one bad
8
+ * import in `PrizeDrawsSection` did in production on 2026-08-26.
9
+ *
10
+ * A section is not load-bearing for the page. Losing one should cost that strip
11
+ * and nothing else, so the fallback renders NOTHING: a visitor sees a homepage
12
+ * missing one row instead of a homepage that is entirely an error message.
13
+ *
14
+ * The failure is still reported — `ErrorBoundary` calls `trackError`, which the
15
+ * app wires to the client-error beacon, so the row lands in `serverErrors` and
16
+ * shows up at /admin/maintenance/server-errors. Silent to the visitor, loud to
17
+ * the operator; the inverse would be the Root Cause #59 swallow.
18
+ *
19
+ * `sectionId` is a plain string on purpose. This is a Client Component rendered
20
+ * from a Server Component, so a function prop (e.g. `onError`) would not be
21
+ * serialisable across that boundary.
22
+ */
23
+ import { Component } from "react";
24
+ import { trackError, ErrorCategory, ErrorSeverity, } from "../../../monitoring/error-tracking";
25
+ export class HomepageSectionBoundary extends Component {
26
+ constructor(props) {
27
+ super(props);
28
+ this.state = { hasError: false };
29
+ }
30
+ static getDerivedStateFromError() {
31
+ return { hasError: true };
32
+ }
33
+ componentDidCatch(error, errorInfo) {
34
+ trackError(error, ErrorCategory.UI, ErrorSeverity.HIGH, {
35
+ component: "HomepageSection",
36
+ metadata: {
37
+ sectionId: this.props.sectionId,
38
+ sectionType: this.props.sectionType,
39
+ componentStack: errorInfo.componentStack ?? null,
40
+ digest: error.digest ?? null,
41
+ },
42
+ });
43
+ }
44
+ render() {
45
+ if (this.state.hasError)
46
+ return null;
47
+ return this.props.children;
48
+ }
49
+ }