@mohasinac/appkit 4.30.15 → 4.30.17

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 (120) hide show
  1. package/dist/_internal/server/functions/https.js +17 -2
  2. package/dist/_internal/server/jobs/core/listingProcessor.js +5 -1
  3. package/dist/_internal/shared/types/action-error-reporter.d.ts +40 -0
  4. package/dist/_internal/shared/types/action-error-reporter.js +27 -0
  5. package/dist/_internal/shared/types/action-result.d.ts +10 -0
  6. package/dist/_internal/shared/types/action-result.js +26 -0
  7. package/dist/client/api/surface-error.d.ts +6 -4
  8. package/dist/client/api/surface-error.js +19 -8
  9. package/dist/contracts/realtime-session.d.ts +27 -0
  10. package/dist/contracts/realtime-session.js +118 -0
  11. package/dist/core/unit-of-work.js +21 -13
  12. package/dist/errors/base-error.js +14 -1
  13. package/dist/errors/database-error.js +11 -1
  14. package/dist/errors/describe-cause.d.ts +5 -0
  15. package/dist/errors/describe-cause.js +76 -0
  16. package/dist/errors/error-display-map.d.ts +32 -1
  17. package/dist/errors/error-display-map.js +46 -0
  18. package/dist/errors/error-mapping.d.ts +47 -7
  19. package/dist/errors/error-mapping.js +46 -5
  20. package/dist/errors/index.d.ts +1 -0
  21. package/dist/errors/index.js +4 -0
  22. package/dist/features/about/components/PublicProfileView.js +4 -4
  23. package/dist/features/account/components/AddressBook.js +2 -2
  24. package/dist/features/account/components/AddressesIndexListing.js +2 -2
  25. package/dist/features/addresses/repository/addresses.repository.d.ts +12 -1
  26. package/dist/features/addresses/repository/addresses.repository.js +16 -7
  27. package/dist/features/admin/components/AdminAnalyticsView.js +2 -2
  28. package/dist/features/admin/components/AdminCouponsView.js +2 -2
  29. package/dist/features/admin/components/AdminGuideHubView.js +2 -2
  30. package/dist/features/admin/components/AdminMediaView.js +2 -2
  31. package/dist/features/admin/components/AdminViewCards.js +2 -2
  32. package/dist/features/auctions/components/AuctionDetailPageView.js +1 -1
  33. package/dist/features/auctions/components/MarketplaceAuctionGrid.d.ts +1 -2
  34. package/dist/features/auctions/components/MarketplaceAuctionGrid.js +8 -7
  35. package/dist/features/auctions/components/PlaceBidFormClient.js +32 -15
  36. package/dist/features/auth/repository/user.repository.d.ts +22 -1
  37. package/dist/features/auth/repository/user.repository.js +31 -28
  38. package/dist/features/before-after/components/BeforeAfterGallery.js +2 -2
  39. package/dist/features/blog/components/BlogIndexListing.js +3 -3
  40. package/dist/features/blog/components/BlogListView.js +3 -3
  41. package/dist/features/blog/components/BlogPostView.js +2 -2
  42. package/dist/features/cart/repository/cart.repository.d.ts +15 -2
  43. package/dist/features/cart/repository/cart.repository.js +15 -13
  44. package/dist/features/categories/components/BundleCollage.js +2 -2
  45. package/dist/features/categories/components/CategoriesIndexListing.js +3 -3
  46. package/dist/features/categories/components/CategoryBundlesListing.js +2 -2
  47. package/dist/features/categories/components/CategoryGrid.js +2 -2
  48. package/dist/features/categories/components/CategoryProductsListing.js +2 -2
  49. package/dist/features/categories/components/CategoryStoresListing.js +2 -2
  50. package/dist/features/collections/components/CollectionCard.js +2 -2
  51. package/dist/features/events/components/EventRafflesSection.js +2 -2
  52. package/dist/features/events/components/EventsIndexListing.js +2 -2
  53. package/dist/features/events/components/EventsListView.js +3 -3
  54. package/dist/features/homepage/components/FeaturedBundlesSection.js +2 -2
  55. package/dist/features/homepage/components/GoogleReviewsSection.js +2 -2
  56. package/dist/features/homepage/components/HomepageSkeleton.js +1 -1
  57. package/dist/features/homepage/components/SecurityHighlightsSection.js +1 -1
  58. package/dist/features/homepage/components/SocialFeedSection.js +2 -2
  59. package/dist/features/homepage/components/StatsCounterSection.js +1 -1
  60. package/dist/features/homepage/components/TrustBadges.js +2 -2
  61. package/dist/features/homepage/components/TrustFeaturesSection.js +1 -1
  62. package/dist/features/messages/hooks/useConversation.js +39 -15
  63. package/dist/features/messages/hooks/useConversations.js +36 -14
  64. package/dist/features/messages/repository/conversations.repository.d.ts +23 -0
  65. package/dist/features/messages/repository/conversations.repository.js +53 -0
  66. package/dist/features/orders/repository/orders.repository.d.ts +12 -1
  67. package/dist/features/orders/repository/orders.repository.js +14 -5
  68. package/dist/features/payments/repository/payout.repository.d.ts +19 -9
  69. package/dist/features/payments/repository/payout.repository.js +26 -20
  70. package/dist/features/pre-orders/components/PreOrdersIndexListing.js +2 -3
  71. package/dist/features/products/components/AuctionsIndexListing.js +2 -3
  72. package/dist/features/products/components/PrizeDrawCollage.js +2 -2
  73. package/dist/features/products/components/PrizeDrawsIndexListing.js +2 -3
  74. package/dist/features/products/components/PrizeDrawsSection.js +2 -2
  75. package/dist/features/products/components/ProductFilters.d.ts +19 -1
  76. package/dist/features/products/components/ProductFilters.js +11 -3
  77. package/dist/features/products/components/ProductGrid.js +2 -7
  78. package/dist/features/products/components/ProductsIndexListing.d.ts +4 -2
  79. package/dist/features/products/components/ProductsIndexListing.js +41 -28
  80. package/dist/features/products/components/RelatedProducts.js +2 -2
  81. package/dist/features/promotions/components/CouponsIndexListing.js +1 -1
  82. package/dist/features/reviews/components/ReviewDetailPageView.js +2 -2
  83. package/dist/features/reviews/components/ReviewsListingPanel.js +2 -2
  84. package/dist/features/reviews/repository/reviews.repository.d.ts +10 -8
  85. package/dist/features/reviews/repository/reviews.repository.js +16 -19
  86. package/dist/features/seller/components/SellerCouponsView.js +2 -2
  87. package/dist/features/seller/components/SellerDashboardView.js +3 -3
  88. package/dist/features/server-errors/schemas/firestore.d.ts +9 -0
  89. package/dist/features/stores/components/StoreAuctionsListing.js +2 -3
  90. package/dist/features/stores/components/StoreClassifiedsListing.js +2 -3
  91. package/dist/features/stores/components/StoreDigitalCodesListing.js +2 -3
  92. package/dist/features/stores/components/StoreGuideHubView.js +2 -2
  93. package/dist/features/stores/components/StoreLiveItemsListing.js +2 -3
  94. package/dist/features/stores/components/StorePreOrdersListing.js +2 -3
  95. package/dist/features/stores/components/StoreProductsListing.js +2 -2
  96. package/dist/features/stores/components/StoresIndexListing.js +3 -3
  97. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +215 -18
  98. package/dist/next/api/routeHandler.js +27 -7
  99. package/dist/providers/db-firebase/base.repository.d.ts +12 -0
  100. package/dist/providers/db-firebase/base.repository.js +48 -20
  101. package/dist/react/hooks/usePendingFilters.d.ts +12 -2
  102. package/dist/react/hooks/usePendingFilters.js +2 -2
  103. package/dist/react/hooks/usePendingTable.d.ts +9 -2
  104. package/dist/react/hooks/useRealtimeEvent.js +22 -4
  105. package/dist/security/pii-schemas.d.ts +19 -1
  106. package/dist/security/pii-schemas.js +19 -1
  107. package/dist/server.d.ts +2 -0
  108. package/dist/server.js +5 -0
  109. package/dist/styles.css +84 -20
  110. package/dist/tailwind-utilities.css +1 -1
  111. package/dist/tokens/tokens.css +7 -2
  112. package/dist/ui/components/Layout.d.ts +31 -18
  113. package/dist/ui/components/Layout.js +30 -19
  114. package/dist/ui/components/Layout.style.css +76 -17
  115. package/dist/ui/components/ListingLayout.d.ts +2 -0
  116. package/dist/ui/components/ListingLayout.js +16 -2
  117. package/dist/ui/components/StickyToolbar.js +3 -48
  118. package/dist/ui/components/toolbar-collapse.d.ts +44 -0
  119. package/dist/ui/components/toolbar-collapse.js +93 -0
  120. package/package.json +1 -1
@@ -3,7 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useState, useCallback } from "react";
4
4
  import { useUrlTable } from "../../../react/hooks/useUrlTable";
5
5
  import { useProducts } from "../hooks/useProducts";
6
- import { BulkActionBar, Div, FilterDrawer, ListingToolbar, LoginRequiredModal, Pagination, Row, Stack, useToast, StickyToolbar } from "../../../ui";
6
+ import { BulkActionBar, Div, FilterDrawer, Grid, ListingToolbar, LoginRequiredModal, Pagination, Row, Stack, useToast, StickyToolbar } from "../../../ui";
7
7
  import { usePendingTable } from "../../../react/hooks/usePendingTable";
8
8
  import { useBulkSelection } from "../../../react/hooks/useBulkSelection";
9
9
  import { MarketplaceAuctionGrid } from "../../auctions/components/MarketplaceAuctionGrid";
@@ -112,7 +112,6 @@ export function AuctionsIndexListing({ initialData, categorySlug, brandName }) {
112
112
  },
113
113
  isWishlisted: (productId) => wishlistedIds.has(productId),
114
114
  };
115
- const gridClass = "grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-3 gap-[var(--appkit-space-4)]";
116
115
  useBottomActions(selection.selectedCount > 0 ? { bulk: { selectedCount: selection.selectedCount, onClearSelection: selection.clearSelection, actions: [
117
116
  {
118
117
  id: ACTION_ID.WATCH_AUCTION,
@@ -156,5 +155,5 @@ export function AuctionsIndexListing({ initialData, categorySlug, brandName }) {
156
155
  selection.clearSelection();
157
156
  },
158
157
  },
159
- ] }), totalPages > 1 && (_jsx(StickyToolbar, { offset: "header+pagination", tone: "translucent", border: true, padding: "toolbar", children: _jsx(Row, { justify: "center", children: _jsx(Pagination, { currentPage: page, totalPages: totalPages, onPageChange: (p) => table.setPage(p) }) }) })), _jsx(Div, { padding: "y-lg", children: isLoading ? (_jsx(Div, { className: gridClass, children: Array.from({ length: 8 }).map((_, i) => (_jsxs(Div, { className: `${__O.hidden} animate-pulse`, border: "subtle", rounded: "xl", children: [_jsx(Div, { className: "aspect-square", surface: "subtle" }), _jsxs(Stack, { className: `${__P.p3}`, gap: "sm", children: [_jsx(Div, { className: "h-3 w-3/4", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-4 w-1/2", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-3 w-full", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-8", surface: "subtle", rounded: "default" })] })] }, i))) })) : (_jsx(MarketplaceAuctionGrid, { auctions: auctions, variant: view === "list" ? "list" : "grid", gridClassName: view === "list" ? "flex flex-col gap-[var(--appkit-space-4)]" : gridClass, wishlistActions: wishlistActions })) }), _jsx(FilterDrawer, { open: filterOpen, onOpen: openFilters, onClose: () => setFilterOpen(false), onApply: applyFilters, onReset: onFilterClear, activeCount: filterActiveCount, hideTrigger: true, children: _jsx(AuctionFilters, { table: pendingTable, currencyPrefix: "\u20B9", categoryOptions: categoryOptions, brandOptions: brandOptions }) }), _jsx(LoginRequiredModal, { isOpen: modalOpen, onClose: closeModal, message: modalMessage })] }));
158
+ ] }), totalPages > 1 && (_jsx(StickyToolbar, { offset: "header+pagination", tone: "translucent", border: true, padding: "toolbar", children: _jsx(Row, { justify: "center", children: _jsx(Pagination, { currentPage: page, totalPages: totalPages, onPageChange: (p) => table.setPage(p) }) }) })), _jsx(Div, { padding: "y-lg", children: isLoading ? (_jsx(Grid, { cols: "cards", gap: "md", children: Array.from({ length: 8 }).map((_, i) => (_jsxs(Div, { className: `${__O.hidden} animate-pulse`, border: "subtle", rounded: "xl", children: [_jsx(Div, { className: "aspect-square", surface: "subtle" }), _jsxs(Stack, { className: `${__P.p3}`, gap: "sm", children: [_jsx(Div, { className: "h-3 w-3/4", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-4 w-1/2", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-3 w-full", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-8", surface: "subtle", rounded: "default" })] })] }, i))) })) : (_jsx(MarketplaceAuctionGrid, { auctions: auctions, variant: view === "list" ? "list" : "grid", wishlistActions: wishlistActions })) }), _jsx(FilterDrawer, { open: filterOpen, onOpen: openFilters, onClose: () => setFilterOpen(false), onApply: applyFilters, onReset: onFilterClear, activeCount: filterActiveCount, hideTrigger: true, children: _jsx(AuctionFilters, { table: pendingTable, currencyPrefix: "\u20B9", categoryOptions: categoryOptions, brandOptions: brandOptions }) }), _jsx(LoginRequiredModal, { isOpen: modalOpen, onClose: closeModal, message: modalMessage })] }));
160
159
  }
@@ -16,7 +16,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
16
16
  */
17
17
  import { Row } from "@mohasinac/appkit/client";
18
18
  import { useState } from "react";
19
- import { Button, Div, Scrim, Text } from "../../../ui";
19
+ import { Button, Div, Grid, Scrim, Text } from "../../../ui";
20
20
  import { MediaImage } from "../../media/MediaImage";
21
21
  import { ImageLightbox } from "../../../ui/components/ImageLightbox";
22
22
  const __P = {
@@ -51,7 +51,7 @@ export function PrizeDrawCollage({ items, highlightItemNumber, onItemClick, wonL
51
51
  return (_jsx(Div, { className: `border border-dashed border-[var(--appkit-color-border)] ${__P.p6} text-left`, rounded: "default", children: _jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: "No prizes configured yet." }) }));
52
52
  }
53
53
  const galleryImages = toGalleryImages(items);
54
- return (_jsxs(_Fragment, { children: [_jsx(Div, { layout: "grid", gap: "3", className: "grid-cols-2 sm:grid-cols-3 md:grid-cols-4", children: items.map((it, idx) => {
54
+ return (_jsxs(_Fragment, { children: [_jsx(Grid, { cols: "categoryCards", gap: "3", children: items.map((it, idx) => {
55
55
  const cover = it.images?.[0];
56
56
  const isHighlight = highlightItemNumber === it.itemNumber;
57
57
  return (_jsxs(Button, { variant: "ghost", type: "button", onClick: makePrizeItemClickHandler(it, idx, onItemClick, setLightboxIndex), rounded: "lg", paddingX: "none", paddingY: "none", className: [
@@ -4,7 +4,7 @@ import { useState, useCallback } from "react";
4
4
  import { useUrlTable } from "../../../react/hooks/useUrlTable";
5
5
  import { usePendingTable } from "../../../react/hooks/usePendingTable";
6
6
  import { useProducts } from "../hooks/useProducts";
7
- import { Div, FilterDrawer, Label, ListingToolbar, Pagination, Row, Select, Stack, Text, StickyToolbar } from "../../../ui";
7
+ import { Div, FilterDrawer, Grid, Label, ListingToolbar, Pagination, Row, Select, Stack, Text, StickyToolbar } from "../../../ui";
8
8
  import { useCategoryTree, categoriesToFacetOptions } from "../../categories/hooks/useCategoryTree";
9
9
  import { useBrands } from "../hooks/useBrands";
10
10
  import { MarketplacePrizeDrawCard } from "./MarketplacePrizeDrawCard";
@@ -82,7 +82,6 @@ export function PrizeDrawsIndexListing({ initialData, categorySlug, brandName, s
82
82
  if (e.key === "Enter")
83
83
  commitSearch();
84
84
  };
85
- const gridClass = "grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-[var(--appkit-space-4)]";
86
85
  return (_jsxs(Div, { className: "min-h-screen", children: [_jsx(ListingToolbar, { filterCount: filterActiveCount, onFiltersClick: openFilters, searchValue: searchInput, searchPlaceholder: "Search prize draws...", onSearchChange: setSearchInput, onSearchCommit: commitSearch, onSearchKeyDown: handleSearchKeyDown, sortValue: table.get(TABLE_KEYS.SORT) || DEFAULT_SORT, sortOptions: PRIZE_DRAW_SORT_OPTIONS, onSortChange: (v) => {
87
86
  table.set(TABLE_KEYS.SORT, v);
88
87
  }, view: view, onViewChange: (v) => {
@@ -90,7 +89,7 @@ export function PrizeDrawsIndexListing({ initialData, categorySlug, brandName, s
90
89
  return;
91
90
  setView(v);
92
91
  table.set(TABLE_KEYS.VIEW, v);
93
- }, onResetAll: resetAll, hasActiveState: hasActiveState }), _jsx(Div, { padding: "y-sm", children: _jsx(AvailabilityTabs, { types: PRIZE_DRAW_LISTING_TYPES }) }), totalPages > 1 && (_jsx(StickyToolbar, { offset: "header+pagination", tone: "translucent", border: true, padding: "toolbar", children: _jsx(Row, { justify: "center", children: _jsx(Pagination, { currentPage: page, totalPages: totalPages, onPageChange: (p) => table.setPage(p) }) }) })), _jsx(Div, { padding: "y-lg", children: isLoading ? (_jsx(Div, { className: gridClass, children: Array.from({ length: 8 }).map((_, i) => (_jsxs(Div, { className: "overflow-hidden animate-pulse", border: "subtle", rounded: "xl", children: [_jsx(Div, { className: "aspect-square", surface: "subtle" }), _jsxs(Stack, { className: `${__P.p3}`, gap: "sm", children: [_jsx(Div, { className: "h-3 w-3/4", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-3 w-1/2", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-8", surface: "subtle", rounded: "default" })] })] }, i))) })) : filteredDraws.length === 0 ? (_jsx(Text, { paddingY: "3xl", color: "muted", size: "sm", align: "start", children: "No prize draws found." })) : (_jsx(Div, { className: gridClass, children: filteredDraws.map((product) => (_jsx(MarketplacePrizeDrawCard, { product: product, variant: view }, product.id))) })) }), _jsx(FilterDrawer, { open: filterOpen, onOpen: openFilters, onClose: () => setFilterOpen(false), onApply: applyFilters, onReset: onFilterClear, activeCount: filterActiveCount, hideTrigger: true, children: _jsxs(Stack, { gap: "md", children: [_jsxs(_Fragment, { children: [_jsx(Label, { htmlFor: "prizeRevealStatusFilter", size: "xs", weight: "semibold", color: "muted", className: "block mb-1.5", children: "Reveal status" }), _jsx(Select, { id: "prizeRevealStatusFilter", options: [
92
+ }, onResetAll: resetAll, hasActiveState: hasActiveState }), _jsx(Div, { padding: "y-sm", children: _jsx(AvailabilityTabs, { types: PRIZE_DRAW_LISTING_TYPES }) }), totalPages > 1 && (_jsx(StickyToolbar, { offset: "header+pagination", tone: "translucent", border: true, padding: "toolbar", children: _jsx(Row, { justify: "center", children: _jsx(Pagination, { currentPage: page, totalPages: totalPages, onPageChange: (p) => table.setPage(p) }) }) })), _jsx(Div, { padding: "y-lg", children: isLoading ? (_jsx(Grid, { cols: "cards", gap: "md", children: Array.from({ length: 8 }).map((_, i) => (_jsxs(Div, { className: "overflow-hidden animate-pulse", border: "subtle", rounded: "xl", children: [_jsx(Div, { className: "aspect-square", surface: "subtle" }), _jsxs(Stack, { className: `${__P.p3}`, gap: "sm", children: [_jsx(Div, { className: "h-3 w-3/4", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-3 w-1/2", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-8", surface: "subtle", rounded: "default" })] })] }, i))) })) : filteredDraws.length === 0 ? (_jsx(Text, { paddingY: "3xl", color: "muted", size: "sm", align: "start", children: "No prize draws found." })) : (_jsx(Grid, { cols: "cards", gap: "md", children: filteredDraws.map((product) => (_jsx(MarketplacePrizeDrawCard, { product: product, variant: view }, product.id))) })) }), _jsx(FilterDrawer, { open: filterOpen, onOpen: openFilters, onClose: () => setFilterOpen(false), onApply: applyFilters, onReset: onFilterClear, activeCount: filterActiveCount, hideTrigger: true, children: _jsxs(Stack, { gap: "md", children: [_jsxs(_Fragment, { children: [_jsx(Label, { htmlFor: "prizeRevealStatusFilter", size: "xs", weight: "semibold", color: "muted", className: "block mb-1.5", children: "Reveal status" }), _jsx(Select, { id: "prizeRevealStatusFilter", options: [
94
93
  { value: "", label: "Any" },
95
94
  { value: "pending", label: "Reveal pending" },
96
95
  { value: "open", label: "Reveal open" },
@@ -4,7 +4,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
4
  // a client-reference proxy and calling it throws during the server render
5
5
  // (Root Cause #18). That is exactly what took `section-prize-draws` down.
6
6
  import { sortBy } from "../../../constants/sort";
7
- import { Container, Div, Heading, Section, Stack, Text, } from "../../../ui";
7
+ import { Container, Grid, Heading, Section, Stack, Text } from "../../../ui";
8
8
  import { ROUTES } from "../../../next";
9
9
  import { listPublicProducts } from "../../../_internal/server/features/products/list-public";
10
10
  import { AVAILABILITY_VALUES, PRODUCT_FIELDS } from "../../../constants/field-names";
@@ -38,5 +38,5 @@ export async function PrizeDrawsSection({ config, }) {
38
38
  const draws = (result?.items ?? []);
39
39
  if (draws.length === 0)
40
40
  return null;
41
- return (_jsx(Section, { padding: "y-2xl", children: _jsx(Container, { size: "xl", children: _jsxs(Stack, { gap: "md", children: [_jsxs(Stack, { gap: "xs", children: [_jsx(Heading, { level: 2, size: "2xl", weight: "semibold", color: "primary", children: title }), subtitle ? (_jsx(Text, { size: "sm", color: "muted", children: subtitle })) : null] }), _jsx(Div, { gap: "3", className: "fluid-grid-card", children: draws.map((draw) => (_jsx(InteractiveProductCard, { product: draw, href: String(ROUTES.PUBLIC.PRODUCT_DETAIL(draw.slug ?? draw.id ?? "")) }, draw.id))) })] }) }) }));
41
+ return (_jsx(Section, { padding: "y-2xl", children: _jsx(Container, { size: "xl", children: _jsxs(Stack, { gap: "md", children: [_jsxs(Stack, { gap: "xs", children: [_jsx(Heading, { level: 2, size: "2xl", weight: "semibold", color: "primary", children: title }), subtitle ? (_jsx(Text, { size: "sm", color: "muted", children: subtitle })) : null] }), _jsx(Grid, { cols: "cardsWide", gap: "3", children: draws.map((draw) => (_jsx(InteractiveProductCard, { product: draw, href: String(ROUTES.PUBLIC.PRODUCT_DETAIL(draw.slug ?? draw.id ?? "")) }, draw.id))) })] }) }) }));
42
42
  }
@@ -1,3 +1,4 @@
1
+ import type { ReactNode } from "react";
1
2
  import type { FacetOption } from "../../filters/FilterFacetSection";
2
3
  import type { UrlTable } from "../../filters/FilterPanel";
3
4
  import type { AsyncFacetSectionProps } from "../../filters/AsyncFacetSection";
@@ -115,6 +116,23 @@ export interface ProductFiltersProps {
115
116
  sublistingCategoryOptions?: FacetOption[];
116
117
  /** Feature badge options for filtering by product features */
117
118
  featureOptions?: FacetOption[];
119
+ /**
120
+ * Listing-type facet options — `{ value: <canonical ListingType>, label }`.
121
+ *
122
+ * Opt-in: only the combined browse pages (`/products`, `/art`) span more than
123
+ * one listing type, so every other caller leaves this empty and the section
124
+ * does not render. Derive the options from `PRODUCT_TYPE_FILTER_TABS` /
125
+ * `ART_STICKERS_TYPE_FILTER_TABS` — never a hand-written list, which is what
126
+ * `audit-listing-type-tab-coverage.mjs` exists to prevent (Root Cause #61).
127
+ */
128
+ listingTypeOptions?: FacetOption[];
129
+ /**
130
+ * Rendered directly beneath the listing-type section. The caller owns it so
131
+ * this shared component does not have to know about the listing-type plugin
132
+ * registry — `/products` uses it for the "Full <type> filters →" link to a
133
+ * single selected type's dedicated browse page.
134
+ */
135
+ listingTypeFooter?: ReactNode;
118
136
  showStatus?: boolean;
119
137
  variant?: ProductFilterVariant;
120
138
  statusOptions?: FacetOption[];
@@ -124,4 +142,4 @@ export interface ProductFiltersProps {
124
142
  /** Show "part of a bundle" switch filter (default true) */
125
143
  showBundleFilter?: boolean;
126
144
  }
127
- export declare function ProductFilters({ table, categoryOptions, brandOptions, storeOptions, sellerOptions, tagOptions, sublistingCategoryOptions, featureOptions, showStatus, variant, statusOptions, currencyPrefix, showShipping, showBundleFilter, loadCategoryOptions, loadBrandOptions, loadStoreOptions, }: ProductFiltersProps): import("react").JSX.Element;
145
+ export declare function ProductFilters({ table, categoryOptions, brandOptions, storeOptions, sellerOptions, tagOptions, sublistingCategoryOptions, featureOptions, listingTypeOptions, listingTypeFooter, showStatus, variant, statusOptions, currencyPrefix, showShipping, showBundleFilter, loadCategoryOptions, loadBrandOptions, loadStoreOptions, }: ProductFiltersProps): import("react").JSX.Element;
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useTranslations } from "next-intl";
4
4
  import { FilterFacetSection } from "../../filters/FilterFacetSection";
5
5
  import { AsyncFacetSection } from "../../filters/AsyncFacetSection";
@@ -66,7 +66,7 @@ export function getProductSortOptions(variant) {
66
66
  return PRODUCT_PUBLIC_SORT_OPTIONS;
67
67
  }
68
68
  }
69
- export function ProductFilters({ table, categoryOptions = [], brandOptions = [], storeOptions = [], sellerOptions, tagOptions = [], sublistingCategoryOptions = [], featureOptions = [], showStatus = false, variant, statusOptions, currencyPrefix = "", showShipping = true, showBundleFilter = true, loadCategoryOptions, loadBrandOptions, loadStoreOptions, }) {
69
+ export function ProductFilters({ table, categoryOptions = [], brandOptions = [], storeOptions = [], sellerOptions, tagOptions = [], sublistingCategoryOptions = [], featureOptions = [], listingTypeOptions = [], listingTypeFooter, showStatus = false, variant, statusOptions, currencyPrefix = "", showShipping = true, showBundleFilter = true, loadCategoryOptions, loadBrandOptions, loadStoreOptions, }) {
70
70
  const resolvedStoreOptions = storeOptions.length > 0 ? storeOptions : (sellerOptions ?? []);
71
71
  const t = useTranslations("filters");
72
72
  const conditionOptions = [
@@ -99,5 +99,13 @@ export function ProductFilters({ table, categoryOptions = [], brandOptions = [],
99
99
  const selectedStatuses = splitPipe(TABLE_KEYS.STATUS);
100
100
  const selectedSublistings = splitPipe(TABLE_KEYS.SUBLISTING_CATEGORY);
101
101
  const selectedFeatures = splitPipe(TABLE_KEYS.FEATURES);
102
- return (_jsxs(Div, { children: [loadCategoryOptions ? (_jsx(AsyncFacetSection, { title: t("category"), loadOptions: loadCategoryOptions, selected: selectedCategories, onChange: (vals) => table.set(TABLE_KEYS.CATEGORY, vals.join("|")), defaultCollapsed: false })) : categoryOptions.length > 0 ? (_jsx(FilterFacetSection, { title: t("category"), options: categoryOptions, selected: selectedCategories, onChange: (vals) => table.set(TABLE_KEYS.CATEGORY, vals.join("|")), searchable: true, defaultCollapsed: categoryOptions.length > 6 })) : null, _jsx(FilterFacetSection, { title: t("condition"), options: conditionOptions, selected: selectedConditions, onChange: (vals) => table.set(TABLE_KEYS.CONDITION, vals.join("|")), searchable: false, defaultCollapsed: false }), _jsx(RangeFilter, { title: t("priceRange"), minValue: table.get(TABLE_KEYS.MIN_PRICE), maxValue: table.get(TABLE_KEYS.MAX_PRICE), onMinChange: (v) => table.set(TABLE_KEYS.MIN_PRICE, v), onMaxChange: (v) => table.set(TABLE_KEYS.MAX_PRICE, v), prefix: currencyPrefix, showSlider: true, minBound: 0, maxBound: 500000, step: 500, minPlaceholder: t("minPrice"), maxPlaceholder: t("maxPrice"), defaultCollapsed: false }), loadBrandOptions ? (_jsx(AsyncFacetSection, { title: t("brand"), loadOptions: loadBrandOptions, selected: selectedBrands, onChange: (vals) => table.set(TABLE_KEYS.BRAND, vals.join("|")), defaultCollapsed: false })) : brandOptions.length > 0 ? (_jsx(FilterFacetSection, { title: t("brand"), options: brandOptions, selected: selectedBrands, onChange: (vals) => table.set(TABLE_KEYS.BRAND, vals.join("|")), searchable: brandOptions.length > 4, defaultCollapsed: brandOptions.length > 6 })) : null, loadStoreOptions ? (_jsx(AsyncFacetSection, { title: t("store"), loadOptions: loadStoreOptions, selected: selectedSellers, onChange: (vals) => table.set(TABLE_KEYS.STORE_ID, vals.join("|")), defaultCollapsed: false })) : resolvedStoreOptions.length > 0 ? (_jsx(FilterFacetSection, { title: t("store"), options: resolvedStoreOptions, selected: selectedSellers, onChange: (vals) => table.set(TABLE_KEYS.STORE_ID, vals.join("|")), searchable: resolvedStoreOptions.length > 4, defaultCollapsed: resolvedStoreOptions.length > 6 })) : null, showShipping && (_jsx(SwitchFilter, { title: t("shipping"), label: t("freeShippingOnly"), checked: table.get(TABLE_KEYS.FREE_SHIPPING) === "true", onChange: (v) => table.set(TABLE_KEYS.FREE_SHIPPING, v ? "true" : ""), defaultCollapsed: false })), tagOptions.length > 0 && (_jsx(FilterFacetSection, { title: t("tags"), options: tagOptions, selected: selectedTags, onChange: (vals) => table.set(TABLE_KEYS.TAGS, vals.join("|")), searchable: tagOptions.length > 4, defaultCollapsed: tagOptions.length > 6 })), shouldShowStatus && (_jsx(FilterFacetSection, { title: t("status"), options: statusOptions ?? defaultStatusOptions, selected: selectedStatuses, onChange: (vals) => table.set(TABLE_KEYS.STATUS, vals.join("|")), searchable: false, defaultCollapsed: false })), sublistingCategoryOptions.length > 0 && (_jsx(FilterFacetSection, { title: "Sublisting Type", options: sublistingCategoryOptions, selected: selectedSublistings, onChange: (vals) => table.set(TABLE_KEYS.SUBLISTING_CATEGORY, vals.join("|")), searchable: sublistingCategoryOptions.length > 4, defaultCollapsed: false })), showBundleFilter && (_jsx(SwitchFilter, { title: "Bundles", label: "Part of a bundle only", checked: table.get(TABLE_KEYS.IS_PART_OF_BUNDLE) === "true", onChange: (v) => table.set(TABLE_KEYS.IS_PART_OF_BUNDLE, v ? "true" : ""), defaultCollapsed: false })), featureOptions.length > 0 && (_jsx(FilterFacetSection, { title: "Features", options: featureOptions, selected: selectedFeatures, onChange: (vals) => table.set(TABLE_KEYS.FEATURES, vals.join("|")), searchable: featureOptions.length > 4, defaultCollapsed: featureOptions.length > 4 }))] }));
102
+ const selectedListingTypes = splitPipe(TABLE_KEYS.LISTING_TYPE);
103
+ return (_jsxs(Div, { children: [listingTypeOptions.length > 0 && (_jsxs(_Fragment, { children: [_jsx(FilterFacetSection, { title: "Listing type", options: listingTypeOptions, selected: selectedListingTypes, onChange: (vals) =>
104
+ // Serialise in the OPTIONS' order, not the order the boxes were
105
+ // ticked — `auction|art` and `art|auction` are the same result
106
+ // set but two different React Query keys and two different URLs.
107
+ table.set(TABLE_KEYS.LISTING_TYPE, listingTypeOptions
108
+ .filter((o) => vals.includes(o.value))
109
+ .map((o) => o.value)
110
+ .join("|")), searchable: false, defaultCollapsed: false }), listingTypeFooter] })), loadCategoryOptions ? (_jsx(AsyncFacetSection, { title: t("category"), loadOptions: loadCategoryOptions, selected: selectedCategories, onChange: (vals) => table.set(TABLE_KEYS.CATEGORY, vals.join("|")), defaultCollapsed: false })) : categoryOptions.length > 0 ? (_jsx(FilterFacetSection, { title: t("category"), options: categoryOptions, selected: selectedCategories, onChange: (vals) => table.set(TABLE_KEYS.CATEGORY, vals.join("|")), searchable: true, defaultCollapsed: categoryOptions.length > 6 })) : null, _jsx(FilterFacetSection, { title: t("condition"), options: conditionOptions, selected: selectedConditions, onChange: (vals) => table.set(TABLE_KEYS.CONDITION, vals.join("|")), searchable: false, defaultCollapsed: false }), _jsx(RangeFilter, { title: t("priceRange"), minValue: table.get(TABLE_KEYS.MIN_PRICE), maxValue: table.get(TABLE_KEYS.MAX_PRICE), onMinChange: (v) => table.set(TABLE_KEYS.MIN_PRICE, v), onMaxChange: (v) => table.set(TABLE_KEYS.MAX_PRICE, v), prefix: currencyPrefix, showSlider: true, minBound: 0, maxBound: 500000, step: 500, minPlaceholder: t("minPrice"), maxPlaceholder: t("maxPrice"), defaultCollapsed: false }), loadBrandOptions ? (_jsx(AsyncFacetSection, { title: t("brand"), loadOptions: loadBrandOptions, selected: selectedBrands, onChange: (vals) => table.set(TABLE_KEYS.BRAND, vals.join("|")), defaultCollapsed: false })) : brandOptions.length > 0 ? (_jsx(FilterFacetSection, { title: t("brand"), options: brandOptions, selected: selectedBrands, onChange: (vals) => table.set(TABLE_KEYS.BRAND, vals.join("|")), searchable: brandOptions.length > 4, defaultCollapsed: brandOptions.length > 6 })) : null, loadStoreOptions ? (_jsx(AsyncFacetSection, { title: t("store"), loadOptions: loadStoreOptions, selected: selectedSellers, onChange: (vals) => table.set(TABLE_KEYS.STORE_ID, vals.join("|")), defaultCollapsed: false })) : resolvedStoreOptions.length > 0 ? (_jsx(FilterFacetSection, { title: t("store"), options: resolvedStoreOptions, selected: selectedSellers, onChange: (vals) => table.set(TABLE_KEYS.STORE_ID, vals.join("|")), searchable: resolvedStoreOptions.length > 4, defaultCollapsed: resolvedStoreOptions.length > 6 })) : null, showShipping && (_jsx(SwitchFilter, { title: t("shipping"), label: t("freeShippingOnly"), checked: table.get(TABLE_KEYS.FREE_SHIPPING) === "true", onChange: (v) => table.set(TABLE_KEYS.FREE_SHIPPING, v ? "true" : ""), defaultCollapsed: false })), tagOptions.length > 0 && (_jsx(FilterFacetSection, { title: t("tags"), options: tagOptions, selected: selectedTags, onChange: (vals) => table.set(TABLE_KEYS.TAGS, vals.join("|")), searchable: tagOptions.length > 4, defaultCollapsed: tagOptions.length > 6 })), shouldShowStatus && (_jsx(FilterFacetSection, { title: t("status"), options: statusOptions ?? defaultStatusOptions, selected: selectedStatuses, onChange: (vals) => table.set(TABLE_KEYS.STATUS, vals.join("|")), searchable: false, defaultCollapsed: false })), sublistingCategoryOptions.length > 0 && (_jsx(FilterFacetSection, { title: "Sublisting Type", options: sublistingCategoryOptions, selected: selectedSublistings, onChange: (vals) => table.set(TABLE_KEYS.SUBLISTING_CATEGORY, vals.join("|")), searchable: sublistingCategoryOptions.length > 4, defaultCollapsed: false })), showBundleFilter && (_jsx(SwitchFilter, { title: "Bundles", label: "Part of a bundle only", checked: table.get(TABLE_KEYS.IS_PART_OF_BUNDLE) === "true", onChange: (v) => table.set(TABLE_KEYS.IS_PART_OF_BUNDLE, v ? "true" : ""), defaultCollapsed: false })), featureOptions.length > 0 && (_jsx(FilterFacetSection, { title: "Features", options: featureOptions, selected: selectedFeatures, onChange: (vals) => table.set(TABLE_KEYS.FEATURES, vals.join("|")), searchable: featureOptions.length > 4, defaultCollapsed: featureOptions.length > 4 }))] }));
103
111
  }
@@ -123,10 +123,6 @@ export function ProductCard({ product, href, onClick, onAddToWishlist, isWishlis
123
123
  }
124
124
  return cardBody;
125
125
  }
126
- // --- Grid class maps ---------------------------------------------------------
127
- const GRID_CLASSES = {
128
- card: "grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-[var(--appkit-space-6)]",
129
- };
130
126
  function ProductListRow({ product, onClick, onAddToWishlist, isWishlisted, }) {
131
127
  const discount = product.originalPrice && product.originalPrice > product.price
132
128
  ? Math.round(((product.originalPrice - product.price) / product.originalPrice) *
@@ -196,7 +192,7 @@ export function ProductGrid({ products, renderCard, onProductClick, getProductHr
196
192
  return (_jsx(Stack, { className: `divide-y divide-zinc-100 divide-[var(--appkit-color-border-subtle)] ${className}`, border: "subtle", rounded: "xl", children: products.map((p) => (_jsx(ProductListRow, { product: p, onClick: onProductClick, onAddToWishlist: onWishlistToggle, isWishlisted: wishlistedIds?.has(p.id) ?? false }, p.id))) }));
197
193
  }
198
194
  if (view === "fluid") {
199
- return (_jsx(Grid, { cols: "productCardsCompact", className: className, children: products.map((p, i) => {
195
+ return (_jsx(Grid, { cols: "cards", className: className, children: products.map((p, i) => {
200
196
  const ctx = buildProductCardContext(p);
201
197
  const cardRenderer = renderCard ?? slots?.renderCard;
202
198
  return cardRenderer ? (_jsx(React.Fragment, { children: cardRenderer === renderCard
@@ -204,8 +200,7 @@ export function ProductGrid({ products, renderCard, onProductClick, getProductHr
204
200
  : slots.renderCard(p, i) }, p.id)) : (_jsx(ProductCard, { product: p, href: safeHref(getProductHref ? getProductHref(p) : undefined), onClick: onProductClick, onAddToWishlist: onWishlistToggle, onAddToCart: onAddToCart, onBuyNow: onBuyNow, isWishlisted: ctx.isWishlisted, selectionMode: selectionMode, isSelected: selectedIds?.has(p.id) ?? false, onSelect: onToggleSelect }, p.id));
205
201
  }) }));
206
202
  }
207
- const gridClass = GRID_CLASSES.card;
208
- return (_jsx(Div, { className: `${gridClass} ${className}`, children: products.map((p, i) => {
203
+ return (_jsx(Grid, { cols: "cards", gap: "lg", className: className, children: products.map((p, i) => {
209
204
  const ctx = buildProductCardContext(p);
210
205
  const cardRenderer = renderCard ?? slots?.renderCard;
211
206
  return cardRenderer ? (_jsx(React.Fragment, { children: cardRenderer === renderCard
@@ -6,8 +6,10 @@ export interface ProductsIndexListingProps {
6
6
  * (e.g. `["art", "stickers"]`) to reuse this component for a different
7
7
  * combined browse page. */
8
8
  listingTypes?: readonly string[];
9
- /** Type-filter chip tabs shown above the grid. Defaults to the generic
10
- * Products tabs; pass a matching set when overriding `listingTypes`. */
9
+ /** Options for the "Listing type" checkbox facet in the filter drawer. `id`
10
+ * must be the canonical `ListingType` — it is passed straight into
11
+ * `sieveFilter("listingType", EQ, id)`. Defaults to the generic Products set;
12
+ * pass a matching set when overriding `listingTypes`. */
11
13
  typeTabs?: readonly {
12
14
  id: string;
13
15
  label: string;
@@ -5,7 +5,7 @@ import { ShoppingCart, Heart, Columns } from "lucide-react";
5
5
  import { useRouter } from "next/navigation";
6
6
  import { useUrlTable } from "../../../react/hooks/useUrlTable";
7
7
  import { useProducts } from "../hooks/useProducts";
8
- import { BulkActionBar, Div, FilterChipGroup, FilterDrawer, ListingToolbar, LoginRequiredModal, Pagination, Row, Stack, useToast, StickyToolbar } from "../../../ui";
8
+ import { BulkActionBar, Div, FilterDrawer, Grid, ListingToolbar, LoginRequiredModal, Pagination, Row, Stack, useToast, StickyToolbar } from "../../../ui";
9
9
  import { usePendingTable } from "../../../react/hooks/usePendingTable";
10
10
  import { useAuthGate } from "../../../react/hooks/useAuthGate";
11
11
  import { ACTION_ID, ACTION_META, COMPARE_MAX_ITEMS } from "../constants/action-defs";
@@ -40,7 +40,27 @@ const __O = {
40
40
  hidden: "overflow-hidden",
41
41
  };
42
42
  const DEFAULT_SORT = sortBy(PRODUCT_FIELDS.CREATED_AT);
43
- const FILTER_KEYS = [TABLE_KEYS.CATEGORY, TABLE_KEYS.CONDITION, TABLE_KEYS.MIN_PRICE, TABLE_KEYS.MAX_PRICE, TABLE_KEYS.BRAND, TABLE_KEYS.STORE_ID, TABLE_KEYS.FREE_SHIPPING, TABLE_KEYS.TAGS, TABLE_KEYS.FEATURES, TABLE_KEYS.IS_PART_OF_BUNDLE];
43
+ // `LISTING_TYPE` is a managed filter key like any other: it stages in the
44
+ // drawer, counts toward the filter badge, and is cleared by Reset. It used to
45
+ // be driven by a separate always-visible chip row that wrote the URL directly,
46
+ // which was the one facet bypassing the Apply model everything else uses.
47
+ const FILTER_KEYS = [TABLE_KEYS.LISTING_TYPE, TABLE_KEYS.CATEGORY, TABLE_KEYS.CONDITION, TABLE_KEYS.MIN_PRICE, TABLE_KEYS.MAX_PRICE, TABLE_KEYS.BRAND, TABLE_KEYS.STORE_ID, TABLE_KEYS.FREE_SHIPPING, TABLE_KEYS.TAGS, TABLE_KEYS.FEATURES, TABLE_KEYS.IS_PART_OF_BUNDLE];
48
+ /**
49
+ * The dedicated per-type browse page for a selection, when there is exactly
50
+ * one type and that type has its own page — which carries facets this generic
51
+ * one can't (bid range, delivery-date window, reveal status).
52
+ */
53
+ function dedicatedPageFor(types) {
54
+ if (types.length !== 1)
55
+ return null;
56
+ const plugin = pluginFor(types[0]);
57
+ if (!plugin.browseRoute)
58
+ return null;
59
+ return {
60
+ href: plugin.browseRoute,
61
+ label: `Full ${plugin.pluralLabel} filters →`,
62
+ };
63
+ }
44
64
  export function ProductsIndexListing({ initialData, listingTypes = GENERIC_PRODUCT_LISTING_TYPES, typeTabs = PRODUCT_TYPE_FILTER_TABS, searchPlaceholder = "Search products...", }) {
45
65
  const router = useRouter();
46
66
  const table = useUrlTable({ defaults: { pageSize: "24", sort: DEFAULT_SORT } });
@@ -74,36 +94,39 @@ export function ProductsIndexListing({ initialData, listingTypes = GENERIC_PRODU
74
94
  // keeps the SSR paint and the first refetch in agreement (Root Cause #30).
75
95
  const availability = table.get(TABLE_KEYS.AVAILABILITY) ||
76
96
  AVAILABILITY_VALUES.AVAILABLE;
77
- // "Full <type> filters →" — a single selected type with a dedicated browse
78
- // page gets a link to it, since that page carries facets this generic one
79
- // can't (bid range, delivery-date window, reveal status).
80
- const dedicatedPage = selectedTypes.length === 1 && pluginFor(selectedTypes[0]).browseRoute
81
- ? {
82
- href: pluginFor(selectedTypes[0]).browseRoute,
83
- label: `Full ${pluginFor(selectedTypes[0]).pluralLabel} filters →`,
84
- }
85
- : null;
86
97
  const { pendingTable, filterActiveCount, onFilterApply, onFilterClear, onResetAll, onFilterReset } = usePendingTable(table, FILTER_KEYS);
98
+ // The drawer's own, uncommitted type selection. The "Full <type> filters →"
99
+ // link tracks THIS rather than the applied URL value, so it appears the
100
+ // moment the user ticks a single type instead of only after Apply.
101
+ const pendingTypes = parseSelectedListingTypes(pendingTable.get(TABLE_KEYS.LISTING_TYPE)).filter((t) => listingTypes.includes(t));
102
+ const dedicatedPage = dedicatedPageFor(pendingTypes);
103
+ const listingTypeOptions = typeTabs.map((t) => ({ value: t.id, label: t.label }));
87
104
  const openFilters = useCallback(() => {
88
105
  onFilterReset();
89
106
  setFilterOpen(true);
90
107
  }, [onFilterReset]);
91
108
  const applyFilters = useCallback(() => {
92
- onFilterApply();
109
+ // A sort valid for the old type selection (say -auctionEndDate) can be
110
+ // invalid for the new one, so it is blanked in the SAME setMany as the
111
+ // filters — a follow-up table.set would read stale searchParams and
112
+ // overwrite this update (Root Cause #13).
113
+ const key = (types) => [...types].sort().join("|");
114
+ const typesChanged = key(pendingTypes) !== key(selectedTypes);
115
+ onFilterApply(typesChanged ? { [TABLE_KEYS.SORT]: "" } : undefined);
93
116
  setFilterOpen(false);
94
- }, [onFilterApply]);
117
+ }, [onFilterApply, pendingTypes, selectedTypes]);
95
118
  const resetAll = useCallback(() => {
119
+ // `listingType` is a managed filter key now, so onResetAll clears it —
120
+ // repeating it here would be a second write of the same value.
96
121
  onResetAll({
97
122
  [TABLE_KEYS.QUERY]: "",
98
123
  [TABLE_KEYS.SORT]: "",
99
124
  [TABLE_KEYS.AVAILABILITY]: "",
100
- [TABLE_KEYS.LISTING_TYPE]: "",
101
125
  });
102
126
  setSearchInput("");
103
127
  }, [onResetAll]);
104
128
  const hasActiveState = !!table.get(TABLE_KEYS.QUERY) ||
105
129
  availability !== AVAILABILITY_VALUES.AVAILABLE ||
106
- !!typeParam ||
107
130
  table.get(TABLE_KEYS.SORT) !== DEFAULT_SORT ||
108
131
  filterActiveCount > 0;
109
132
  const localCart = useGuestCart();
@@ -263,17 +286,7 @@ export function ProductsIndexListing({ initialData, listingTypes = GENERIC_PRODU
263
286
  // → close for every toggle. The full facet set (category/price/brand/…)
264
287
  // remains in <FilterDrawer> below.
265
288
  { label: "Free shipping", active: table.get(TABLE_KEYS.FREE_SHIPPING) === "true", onChange: (next) => table.set(TABLE_KEYS.FREE_SHIPPING, next ? "true" : "") },
266
- ] }), _jsx(Div, { padding: "y-sm", children: _jsx(AvailabilityTabs, { types: effectiveTypes }) }), _jsx(Div, { padding: "y-sm", children: _jsxs(Row, { justify: "between", align: "center", wrap: true, gap: "sm", children: [_jsx(FilterChipGroup, { multiple: true, label: "Type", tabs: typeTabs, value: selectedTypes.join("|"), onChange: (next) => {
267
- // Clear `sort` alongside the type change: a sort valid for the
268
- // old selection (say -auctionEndDate) can be invalid for the new
269
- // one, and useUrlTable's single router.replace means we must set
270
- // both in ONE call — a follow-up setPage/set would read stale
271
- // searchParams and overwrite this update (Root Cause #13).
272
- table.setMany({
273
- [TABLE_KEYS.LISTING_TYPE]: next,
274
- [TABLE_KEYS.SORT]: "",
275
- });
276
- } }), dedicatedPage && (_jsx(TextLink, { href: dedicatedPage.href, size: "sm", weight: "medium", children: dedicatedPage.label }))] }) }), _jsx(BulkActionBar, { selectedCount: selection.selectedCount, onClearSelection: selection.clearSelection, actions: [
289
+ ] }), _jsx(Div, { padding: "y-sm", children: _jsx(AvailabilityTabs, { types: effectiveTypes }) }), _jsx(BulkActionBar, { selectedCount: selection.selectedCount, onClearSelection: selection.clearSelection, actions: [
277
290
  {
278
291
  id: ACTION_ID.ADD_TO_CART,
279
292
  label: ACTION_META[ACTION_ID.ADD_TO_CART].label,
@@ -318,8 +331,8 @@ export function ProductsIndexListing({ initialData, listingTypes = GENERIC_PRODU
318
331
  setCompareIds(ids);
319
332
  },
320
333
  },
321
- ] }), totalPages > 1 && (_jsx(StickyToolbar, { offset: "header+pagination", tone: "translucent", border: true, padding: "toolbar", children: _jsx(Row, { justify: "center", children: _jsx(Pagination, { currentPage: page, totalPages: totalPages, onPageChange: (p) => table.setPage(p) }) }) })), _jsx(Div, { padding: "y-lg", children: isLoading ? (_jsx(Div, { layout: "grid", gap: "4", className: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-4", children: Array.from({ length: 10 }).map((_, i) => (_jsxs(Div, { className: `${__O.hidden} animate-pulse`, border: "subtle", rounded: "xl", children: [_jsx(Div, { className: "aspect-square", surface: "subtle" }), _jsxs(Stack, { className: `${__P.p3}`, gap: "sm", children: [_jsx(Div, { className: "h-3 w-3/4", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-3 w-1/2", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-4 w-1/3", surface: "subtle", rounded: "default" })] })] }, i))) })) : (_jsx(ProductGrid, { products: products, getProductHref: (p) => pluginFor(p.listingType ?? "standard").detailRoute(p.slug || p.id), view: view === "grid" ? "card" : "list", onWishlistToggle: handleWishlistToggle, wishlistedIds: wishlistedIds, onAddToCart: handleAddToCart, onBuyNow: handleBuyNow, selectionMode: selection.isSelecting, selectedIds: selection.selectedIdSet, onToggleSelect: selection.toggle })) }), _jsx(CompareOverlay, { isOpen: compareIds.length > 0, productIds: compareIds, productType: "product", onClose: () => {
334
+ ] }), totalPages > 1 && (_jsx(StickyToolbar, { offset: "header+pagination", tone: "translucent", border: true, padding: "toolbar", children: _jsx(Row, { justify: "center", children: _jsx(Pagination, { currentPage: page, totalPages: totalPages, onPageChange: (p) => table.setPage(p) }) }) })), _jsx(Div, { padding: "y-lg", children: isLoading ? (_jsx(Grid, { cols: "cards", gap: "md", children: Array.from({ length: 10 }).map((_, i) => (_jsxs(Div, { className: `${__O.hidden} animate-pulse`, border: "subtle", rounded: "xl", children: [_jsx(Div, { className: "aspect-square", surface: "subtle" }), _jsxs(Stack, { className: `${__P.p3}`, gap: "sm", children: [_jsx(Div, { className: "h-3 w-3/4", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-3 w-1/2", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-4 w-1/3", surface: "subtle", rounded: "default" })] })] }, i))) })) : (_jsx(ProductGrid, { products: products, getProductHref: (p) => pluginFor(p.listingType ?? "standard").detailRoute(p.slug || p.id), view: view === "grid" ? "card" : "list", onWishlistToggle: handleWishlistToggle, wishlistedIds: wishlistedIds, onAddToCart: handleAddToCart, onBuyNow: handleBuyNow, selectionMode: selection.isSelecting, selectedIds: selection.selectedIdSet, onToggleSelect: selection.toggle })) }), _jsx(CompareOverlay, { isOpen: compareIds.length > 0, productIds: compareIds, productType: "product", onClose: () => {
322
335
  setCompareIds([]);
323
336
  selection.clearSelection();
324
- }, onRemove: (id) => setCompareIds((ids) => ids.filter((i) => i !== id)) }), _jsx(FilterDrawer, { open: filterOpen, onOpen: openFilters, onClose: () => setFilterOpen(false), onApply: applyFilters, onReset: onFilterClear, activeCount: filterActiveCount, hideTrigger: true, children: _jsx(ProductFilters, { table: pendingTable, currencyPrefix: "\u20B9", categoryOptions: categoryOptions, brandOptions: brandOptions, featureOptions: featureOptions }) }), _jsx(LoginRequiredModal, { isOpen: modalOpen, onClose: closeModal, message: modalMessage })] }));
337
+ }, onRemove: (id) => setCompareIds((ids) => ids.filter((i) => i !== id)) }), _jsx(FilterDrawer, { open: filterOpen, onOpen: openFilters, onClose: () => setFilterOpen(false), onApply: applyFilters, onReset: onFilterClear, activeCount: filterActiveCount, hideTrigger: true, children: _jsx(ProductFilters, { table: pendingTable, currencyPrefix: "\u20B9", listingTypeOptions: listingTypeOptions, listingTypeFooter: dedicatedPage ? (_jsx(Div, { padding: "x-md", paddingY: "b-sm", children: _jsx(TextLink, { href: dedicatedPage.href, size: "sm", weight: "medium", children: dedicatedPage.label }) })) : null, categoryOptions: categoryOptions, brandOptions: brandOptions, featureOptions: featureOptions }) }), _jsx(LoginRequiredModal, { isOpen: modalOpen, onClose: closeModal, message: modalMessage })] }));
325
338
  }
@@ -1,10 +1,10 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Div, Heading, Stack } from "../../../ui";
2
+ import { Div, Grid, Heading, Stack } from "../../../ui";
3
3
  export function RelatedProducts({ isLoading = false, isEmpty = false, renderGrid, renderSkeleton, renderTitle, labels = {}, className = "", }) {
4
4
  if (isLoading) {
5
5
  if (renderSkeleton)
6
6
  return _jsx(_Fragment, { children: renderSkeleton() });
7
- return (_jsx(Div, { layout: "grid", gap: "4", className: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 animate-pulse", children: Array.from({ length: 4 }).map((_, i) => (_jsx(Div, { className: "aspect-square", surface: "subtle", rounded: "xl" }, i))) }));
7
+ return (_jsx(Grid, { cols: "cards", gap: "md", className: "animate-pulse", children: Array.from({ length: 4 }).map((_, i) => (_jsx(Div, { className: "aspect-square", surface: "subtle", rounded: "xl" }, i))) }));
8
8
  }
9
9
  if (isEmpty)
10
10
  return null;
@@ -107,7 +107,7 @@ export function CouponsIndexListing({ initialCoupons, storeId, }) {
107
107
  const clearFilters = () => {
108
108
  table.setMany({ type: "", [TABLE_KEYS.DATE_FROM]: "", [TABLE_KEYS.DATE_TO]: "" });
109
109
  };
110
- return (_jsxs(Div, { className: "min-h-[40vh]", children: [_jsxs(StickyToolbar, { offset: "header", tone: "default", border: true, padding: "md", z: "above-toolbar", dismissible: true, id: "coupons-toolbar", children: [_jsxs(Row, { gap: "xs", className: "max-w-full", children: [_jsxs(Button, { type: "button", variant: hasActiveFilters ? "outline" : "ghost", onClick: openFilters, gap: "sm", rounded: "lg", paddingX: "md", paddingY: "sm", textSize: "sm", weight: "medium", className: hasActiveFilters ? "border-primary bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300 shrink-0" : "border-[var(--appkit-color-border)] text-[var(--appkit-color-text-muted)] shrink-0", children: [_jsx(SlidersHorizontal, { className: "h-4 w-4" }), _jsxs(Span, { className: "hidden sm:inline", children: ["Filters", hasActiveFilters ? " •" : ""] })] }), _jsxs(Row, { surface: "default", className: `flex-1 ${__O.hidden}`, border: "strong", rounded: "lg", children: [_jsx(Input, { bare: true, type: "text", value: searchInput, onChange: (e) => setSearchInput(e.target.value), onKeyDown: handleKeyDown, placeholder: "Search by name or description\u2026", className: "min-w-0 flex-1 bg-transparent px-[var(--appkit-space-3)] py-[var(--appkit-space-2)] text-[length:var(--appkit-text-sm)] text-[var(--appkit-color-text)] placeholder-zinc-400 outline-none" }), searchInput && (_jsx(IconButton, { type: "button", onClick: () => { setSearchInput(""); table.set(TABLE_KEYS.QUERY, ""); }, "aria-label": "Clear search", variant: "ghost", size: "sm", children: _jsx(X, { className: "h-3.5 w-3.5" }) })), _jsx(IconButton, { type: "button", onClick: commitSearch, "aria-label": "Search", variant: "ghost", size: "sm", children: _jsx(Search, { className: "h-4 w-4" }) })] }), _jsxs(Row, { color: "muted", textSize: "sm", gap: "xs", className: "shrink-0", children: [_jsx(Span, { className: "hidden md:inline whitespace-nowrap", children: "Sort by" }), _jsx(SortDropdown, { value: table.get(TABLE_KEYS.SORT) || DEFAULT_SORT, onChange: (v) => { table.set(TABLE_KEYS.SORT, v); }, options: COUPON_SORT_OPTIONS })] })] }), hasActiveFilters && (_jsxs(Row, { gap: "xs", wrap: true, className: "mt-2", children: [activeType && (_jsxs(Span, { layout: "flex", gap: "xs", size: "xs", weight: "medium", className: "bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300", rounded: "full", padding: "pill-sm-tall", children: [COUPON_TYPES.find((t) => t.value === activeType)?.label ?? activeType, _jsx(Button, { variant: "ghost", type: "button", onClick: () => { table.set(TABLE_KEYS.TYPE, ""); }, "aria-label": "Remove type filter", className: CLS_CHIP_BTN, children: _jsx(X, { className: "h-3 w-3" }) })] })), table.get(TABLE_KEYS.DATE_FROM) && (_jsxs(Span, { layout: "flex", gap: "xs", size: "xs", weight: "medium", className: "bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300", rounded: "full", padding: "pill-sm-tall", children: ["From: ", table.get(TABLE_KEYS.DATE_FROM), _jsx(Button, { variant: "ghost", type: "button", onClick: () => { table.set(TABLE_KEYS.DATE_FROM, ""); }, "aria-label": "Remove from-date filter", className: CLS_CHIP_BTN, children: _jsx(X, { className: "h-3 w-3" }) })] })), table.get(TABLE_KEYS.DATE_TO) && (_jsxs(Span, { layout: "flex", gap: "xs", size: "xs", weight: "medium", className: "bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300", rounded: "full", padding: "pill-sm-tall", children: ["To: ", table.get(TABLE_KEYS.DATE_TO), _jsx(Button, { variant: "ghost", type: "button", onClick: () => { table.set(TABLE_KEYS.DATE_TO, ""); }, "aria-label": "Remove to-date filter", className: CLS_CHIP_BTN, children: _jsx(X, { className: "h-3 w-3" }) })] })), _jsx(Button, { variant: "ghost", type: "button", onClick: clearFilters, textSize: "xs", textColor: "muted", className: "underline", children: "Clear all" })] }))] }), _jsx(Div, { paddingY: "y-md", paddingX: "x-md", children: _jsx(CouponHelpDetails, {}) }), _jsxs(Div, { paddingY: "y-lg", paddingX: "x-md", children: [isLoading ? (_jsx(Grid, { gap: "sm", className: "md:grid-cols-2 lg:grid-cols-3", children: Array.from({ length: 6 }).map((_, i) => (_jsxs(Stack, { border: "skeleton", gap: "sm", rounded: "xl", padding: "md", className: "animate-pulse", children: [_jsx(Div, { className: "h-6 w-2/3", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-4 w-full", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-3 w-1/2", surface: "subtle", rounded: "default" })] }, i))) })) : displayCoupons.length === 0 ? (_jsx(Div, { className: "text-left", padding: "y-4xl", children: _jsx(Text, { color: "faint", children: "No coupons match your search." }) })) : (_jsx(Grid, { gap: "sm", className: "md:grid-cols-2 lg:grid-cols-3", children: displayCoupons.map((coupon) => (_jsx(CouponCard, { coupon: coupon, labels: {
110
+ return (_jsxs(Div, { className: "min-h-[40vh]", children: [_jsxs(StickyToolbar, { offset: "header", tone: "default", border: true, padding: "md", z: "above-toolbar", dismissible: true, id: "coupons-toolbar", children: [_jsxs(Row, { gap: "xs", className: "max-w-full", children: [_jsxs(Button, { type: "button", variant: hasActiveFilters ? "outline" : "ghost", onClick: openFilters, gap: "sm", rounded: "lg", paddingX: "md", paddingY: "sm", textSize: "sm", weight: "medium", className: hasActiveFilters ? "border-primary bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300 shrink-0" : "border-[var(--appkit-color-border)] text-[var(--appkit-color-text-muted)] shrink-0", children: [_jsx(SlidersHorizontal, { className: "h-4 w-4" }), _jsxs(Span, { className: "hidden sm:inline", children: ["Filters", hasActiveFilters ? " •" : ""] })] }), _jsxs(Row, { surface: "default", className: `flex-1 ${__O.hidden}`, border: "strong", rounded: "lg", children: [_jsx(Input, { bare: true, type: "text", value: searchInput, onChange: (e) => setSearchInput(e.target.value), onKeyDown: handleKeyDown, placeholder: "Search by name or description\u2026", className: "min-w-0 flex-1 bg-transparent px-[var(--appkit-space-3)] py-[var(--appkit-space-2)] text-[length:var(--appkit-text-sm)] text-[var(--appkit-color-text)] placeholder-zinc-400 outline-none" }), searchInput && (_jsx(IconButton, { type: "button", onClick: () => { setSearchInput(""); table.set(TABLE_KEYS.QUERY, ""); }, "aria-label": "Clear search", variant: "ghost", size: "sm", children: _jsx(X, { className: "h-3.5 w-3.5" }) })), _jsx(IconButton, { type: "button", onClick: commitSearch, "aria-label": "Search", variant: "ghost", size: "sm", children: _jsx(Search, { className: "h-4 w-4" }) })] }), _jsxs(Row, { color: "muted", textSize: "sm", gap: "xs", className: "shrink-0", children: [_jsx(Span, { className: "hidden md:inline whitespace-nowrap", children: "Sort by" }), _jsx(SortDropdown, { value: table.get(TABLE_KEYS.SORT) || DEFAULT_SORT, onChange: (v) => { table.set(TABLE_KEYS.SORT, v); }, options: COUPON_SORT_OPTIONS })] })] }), hasActiveFilters && (_jsxs(Row, { gap: "xs", wrap: true, className: "mt-2", children: [activeType && (_jsxs(Span, { layout: "flex", gap: "xs", size: "xs", weight: "medium", className: "bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300", rounded: "full", padding: "pill-sm-tall", children: [COUPON_TYPES.find((t) => t.value === activeType)?.label ?? activeType, _jsx(Button, { variant: "ghost", type: "button", onClick: () => { table.set(TABLE_KEYS.TYPE, ""); }, "aria-label": "Remove type filter", className: CLS_CHIP_BTN, children: _jsx(X, { className: "h-3 w-3" }) })] })), table.get(TABLE_KEYS.DATE_FROM) && (_jsxs(Span, { layout: "flex", gap: "xs", size: "xs", weight: "medium", className: "bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300", rounded: "full", padding: "pill-sm-tall", children: ["From: ", table.get(TABLE_KEYS.DATE_FROM), _jsx(Button, { variant: "ghost", type: "button", onClick: () => { table.set(TABLE_KEYS.DATE_FROM, ""); }, "aria-label": "Remove from-date filter", className: CLS_CHIP_BTN, children: _jsx(X, { className: "h-3 w-3" }) })] })), table.get(TABLE_KEYS.DATE_TO) && (_jsxs(Span, { layout: "flex", gap: "xs", size: "xs", weight: "medium", className: "bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300", rounded: "full", padding: "pill-sm-tall", children: ["To: ", table.get(TABLE_KEYS.DATE_TO), _jsx(Button, { variant: "ghost", type: "button", onClick: () => { table.set(TABLE_KEYS.DATE_TO, ""); }, "aria-label": "Remove to-date filter", className: CLS_CHIP_BTN, children: _jsx(X, { className: "h-3 w-3" }) })] })), _jsx(Button, { variant: "ghost", type: "button", onClick: clearFilters, textSize: "xs", textColor: "muted", className: "underline", children: "Clear all" })] }))] }), _jsx(Div, { paddingY: "y-md", paddingX: "x-md", children: _jsx(CouponHelpDetails, {}) }), _jsxs(Div, { paddingY: "y-lg", paddingX: "x-md", children: [isLoading ? (_jsx(Grid, { cols: "cardsWide", gap: "sm", children: Array.from({ length: 6 }).map((_, i) => (_jsxs(Stack, { border: "skeleton", gap: "sm", rounded: "xl", padding: "md", className: "animate-pulse", children: [_jsx(Div, { className: "h-6 w-2/3", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-4 w-full", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-3 w-1/2", surface: "subtle", rounded: "default" })] }, i))) })) : displayCoupons.length === 0 ? (_jsx(Div, { className: "text-left", padding: "y-4xl", children: _jsx(Text, { color: "faint", children: "No coupons match your search." }) })) : (_jsx(Grid, { cols: "cardsWide", gap: "sm", children: displayCoupons.map((coupon) => (_jsx(CouponCard, { coupon: coupon, labels: {
111
111
  copy: "Copy",
112
112
  copied: "Copied!",
113
113
  expires: "Expires",
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import Link from "next/link";
3
3
  import { reviewRepository } from "../../../repositories";
4
- import { Container, Div, Heading, Main, Nav, Section, Span, Text } from "../../../ui";
4
+ import { Container, Div, Grid, Heading, Main, Nav, Section, Span, Text } from "../../../ui";
5
5
  import { ROUTES } from "../../../next";
6
6
  import { ReviewDetailShell } from "./ReviewDetailShell";
7
7
  import { ReviewCard } from "./ReviewsList";
@@ -20,5 +20,5 @@ export async function ReviewDetailPageView({ id }) {
20
20
  ]);
21
21
  const sameProductReviews = sameProductDocs.filter((r) => r.id !== review.id).slice(0, 6);
22
22
  const sameStoreReviews = sameStoreDocs.filter((r) => r.id !== review.id).slice(0, 6);
23
- return (_jsxs(Main, { children: [_jsx(Div, { paddingY: "y-xs-tall", className: "border-b border-neutral-100 bg-neutral-50", padding: "x-md", children: _jsxs(Nav, { layout: "flex", gap: "2xs", textSize: "xs", color: "faint", className: "mx-auto max-w-3xl", "aria-label": "Breadcrumb", children: [_jsx(Link, { href: String(ROUTES.HOME), className: "hover:text-primary transition-colors", children: "Home" }), _jsx(Span, { children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.REVIEWS), className: "hover:text-primary transition-colors", children: "Reviews" }), _jsx(Span, { children: "/" }), _jsx(Span, { className: "text-neutral-700 truncate max-w-[200px]", children: review.title ?? review.id })] }) }), _jsx(ReviewDetailShell, { review: review, storeHref: storeSlug ? String(ROUTES.PUBLIC.STORE_DETAIL(storeSlug)) : null }), sameProductReviews.length > 0 && (_jsx(Section, { padding: "y-lg", border: "subtle", className: "border-t", children: _jsxs(Container, { size: "sm", children: [_jsxs(Heading, { level: 2, className: "mb-4", size: "lg", weight: "semibold", children: ["More reviews for ", review.productTitle ?? "this product"] }), _jsx(Div, { layout: "grid", gap: "4", className: "sm:grid-cols-2", children: sameProductReviews.map((r) => (_jsx(ReviewCard, { review: r, context: "listing" }, r.id))) })] }) })), sameStoreReviews.length > 0 && (_jsx(Section, { padding: "y-lg", border: "subtle", className: "border-t", children: _jsxs(Container, { size: "sm", children: [_jsxs(Heading, { level: 2, className: "mb-4", size: "lg", weight: "semibold", children: ["More reviews for ", review.storeName ?? "this store"] }), _jsx(Div, { layout: "grid", gap: "4", className: "sm:grid-cols-2", children: sameStoreReviews.map((r) => (_jsx(ReviewCard, { review: r, context: "store" }, r.id))) })] }) }))] }));
23
+ return (_jsxs(Main, { children: [_jsx(Div, { paddingY: "y-xs-tall", className: "border-b border-neutral-100 bg-neutral-50", padding: "x-md", children: _jsxs(Nav, { layout: "flex", gap: "2xs", textSize: "xs", color: "faint", className: "mx-auto max-w-3xl", "aria-label": "Breadcrumb", children: [_jsx(Link, { href: String(ROUTES.HOME), className: "hover:text-primary transition-colors", children: "Home" }), _jsx(Span, { children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.REVIEWS), className: "hover:text-primary transition-colors", children: "Reviews" }), _jsx(Span, { children: "/" }), _jsx(Span, { className: "text-neutral-700 truncate max-w-[200px]", children: review.title ?? review.id })] }) }), _jsx(ReviewDetailShell, { review: review, storeHref: storeSlug ? String(ROUTES.PUBLIC.STORE_DETAIL(storeSlug)) : null }), sameProductReviews.length > 0 && (_jsx(Section, { padding: "y-lg", border: "subtle", className: "border-t", children: _jsxs(Container, { size: "sm", children: [_jsxs(Heading, { level: 2, className: "mb-4", size: "lg", weight: "semibold", children: ["More reviews for ", review.productTitle ?? "this product"] }), _jsx(Grid, { cols: "cardsWide", gap: "md", children: sameProductReviews.map((r) => (_jsx(ReviewCard, { review: r, context: "listing" }, r.id))) })] }) })), sameStoreReviews.length > 0 && (_jsx(Section, { padding: "y-lg", border: "subtle", className: "border-t", children: _jsxs(Container, { size: "sm", children: [_jsxs(Heading, { level: 2, className: "mb-4", size: "lg", weight: "semibold", children: ["More reviews for ", review.storeName ?? "this store"] }), _jsx(Grid, { cols: "cardsWide", gap: "md", children: sameStoreReviews.map((r) => (_jsx(ReviewCard, { review: r, context: "store" }, r.id))) })] }) }))] }));
24
24
  }
@@ -2,7 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useCallback, useMemo, useState } from "react";
4
4
  import { useUrlTable } from "../../../react/hooks/useUrlTable";
5
- import { Div, ListingFilterDrawer, ListingToolbar, Pagination, Row, Span, Stack, StickyToolbar, Text, } from "../../../ui";
5
+ import { Div, Grid, ListingFilterDrawer, ListingToolbar, Pagination, Row, Span, Stack, StickyToolbar, Text } from "../../../ui";
6
6
  import { ReviewCard } from "./ReviewsList";
7
7
  import { ReviewFilters, REVIEW_PUBLIC_SORT_OPTIONS } from "./ReviewFilters";
8
8
  import { useReviews } from "../hooks/useReviews";
@@ -222,5 +222,5 @@ export function ReviewsListingPanel({ source, pageSize, context = "general", sta
222
222
  const paginationBlock = totalPages > 1 ? (_jsx(Row, { justify: "center", children: _jsx(Pagination, { currentPage: currentPage, totalPages: totalPages, onPageChange: (p) => table.setPage(p) }) })) : null;
223
223
  return (_jsxs(Div, { className: className, children: [showSummary && (totalReviews ?? 0) > 0 && (_jsxs(Row, { border: "default", align: "center", gap: "sm", className: "border-b", padding: "inline", children: [_jsx(Span, { weight: "bold", size: "2xl", color: "primary", children: (averageRating ?? 0).toFixed(1) }), _jsxs(Span, { size: "sm", color: "muted", children: ["/ 5 \u00B7 ", totalReviews, " review", totalReviews === 1 ? "" : "s"] })] })), _jsx(ListingToolbar, { filterCount: activeFilterCount, onFiltersClick: openFilters, searchValue: searchEnabled ? searchInput : undefined, searchPlaceholder: "Search reviews by product name...", onSearchChange: searchEnabled ? setSearchInput : undefined, onSearchCommit: searchEnabled ? commitSearch : undefined, sortValue: effectiveSort, sortOptions: sortOptions, onSortChange: (v) => {
224
224
  table.set(TABLE_KEYS.SORT, v);
225
- }, view: view, onViewChange: handleViewToggle, onResetAll: resetAll, hasActiveState: hasActiveState }), stateMode === "url" && paginationBlock && (_jsx(StickyToolbar, { offset: "header+pagination", tone: "translucent", border: true, padding: "toolbar", children: paginationBlock })), _jsx(Div, { padding: "y-lg", children: isLoading ? (_jsx(Div, { layout: "grid", gap: "6", className: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3", children: Array.from({ length: 6 }).map((_, i) => (_jsx(Div, { className: `${__O.hidden} animate-pulse`, border: "subtle", rounded: "xl", children: _jsxs(Stack, { className: `${__P.p4}`, gap: "3", children: [_jsx(Div, { className: "h-4 w-3/4", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-3 w-full", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-3 w-2/3", surface: "subtle", rounded: "default" })] }) }, i))) })) : reviews.length === 0 ? (_jsx(Text, { paddingY: "3xl", color: "muted", size: "sm", align: "start", children: emptyLabel })) : view === "list" ? (_jsx(Stack, { className: "divide-y divide-zinc-100 divide-[var(--appkit-color-border-subtle)]", border: "subtle", rounded: "xl", children: reviews.map((review) => (_jsx(ReviewCard, { review: review, context: context }, review.id))) })) : (_jsx(Div, { layout: "grid", gap: "6", className: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3", children: reviews.map((review) => (_jsx(ReviewCard, { review: review, context: context }, review.id))) })) }), stateMode === "local" && paginationBlock, _jsx(ListingFilterDrawer, { open: filterOpen, onClose: () => setFilterOpen(false), onApply: applyFilters, onClear: clearFilters, activeCount: activeFilterCount, children: _jsx(ReviewFilters, { table: pendingTable, variant: variant, keys: filterKeys }) })] }));
225
+ }, view: view, onViewChange: handleViewToggle, onResetAll: resetAll, hasActiveState: hasActiveState }), stateMode === "url" && paginationBlock && (_jsx(StickyToolbar, { offset: "header+pagination", tone: "translucent", border: true, padding: "toolbar", children: paginationBlock })), _jsx(Div, { padding: "y-lg", children: isLoading ? (_jsx(Grid, { cols: "cards", gap: "lg", children: Array.from({ length: 6 }).map((_, i) => (_jsx(Div, { className: `${__O.hidden} animate-pulse`, border: "subtle", rounded: "xl", children: _jsxs(Stack, { className: `${__P.p4}`, gap: "3", children: [_jsx(Div, { className: "h-4 w-3/4", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-3 w-full", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-3 w-2/3", surface: "subtle", rounded: "default" })] }) }, i))) })) : reviews.length === 0 ? (_jsx(Text, { paddingY: "3xl", color: "muted", size: "sm", align: "start", children: emptyLabel })) : view === "list" ? (_jsx(Stack, { className: "divide-y divide-zinc-100 divide-[var(--appkit-color-border-subtle)]", border: "subtle", rounded: "xl", children: reviews.map((review) => (_jsx(ReviewCard, { review: review, context: context }, review.id))) })) : (_jsx(Grid, { cols: "cards", gap: "lg", children: reviews.map((review) => (_jsx(ReviewCard, { review: review, context: context }, review.id))) })) }), stateMode === "local" && paginationBlock, _jsx(ListingFilterDrawer, { open: filterOpen, onClose: () => setFilterOpen(false), onApply: applyFilters, onClear: clearFilters, activeCount: activeFilterCount, children: _jsx(ReviewFilters, { table: pendingTable, variant: variant, keys: filterKeys }) })] }));
226
226
  }
@@ -11,16 +11,18 @@ declare class ReviewRepository extends BaseRepository<ReviewDocument> {
11
11
  constructor();
12
12
  private decryptSieveResult;
13
13
  /**
14
- * Encrypt PII, then attach blind indices derived from the plaintext.
14
+ * Declared, not hand-rolled.
15
15
  *
16
- * Previously this reassigned `encrypted = addPiiIndices(data, …)` — which
17
- * re-reads the ORIGINAL plaintext and returns `{...data, ...indices}` — and
18
- * then spread that over the ciphertext, so plaintext won and `userName` was
19
- * written to Firestore in the clear beside a valid index. `piiIndicesFor`
20
- * returns the index fields alone, so the ciphertext cannot be overwritten.
21
- * The identical bug was fixed in UserRepository and never propagated here.
16
+ * `BaseRepository.applyWriteHooks` already does exactly what the private
17
+ * `encryptReviewData` did — encrypt the listed fields, then add the mapped blind
18
+ * indices derived from the PLAINTEXT source. Declaring the fields means all
19
+ * nine mutating methods get it, including the `createWithId` / `*InTx` /
20
+ * `*InBatch` family that inherited unhooked base implementations while the
21
+ * encryption lived in a private method only three call sites remembered to
22
+ * call.
22
23
  */
23
- private encryptReviewData;
24
+ protected piiFields: readonly ["userName"];
25
+ protected piiIndexMap: Record<string, string>;
24
26
  protected mapDoc<D = ReviewDocument>(snap: import("../../../providers/db-firebase").DocumentSnapshot): D;
25
27
  create(input: ReviewCreateInput): Promise<ReviewDocument>;
26
28
  update(reviewId: string, data: Partial<ReviewDocument>): Promise<ReviewDocument>;
@@ -1,7 +1,7 @@
1
1
  import { BaseRepository, getFirestoreCount, prepareForFirestore, parseSieveDateValue, } from "../../../providers/db-firebase";
2
2
  import { NotFoundError } from "../../../errors";
3
3
  import { REVIEW_MAX_RATING, REVIEW_MIN_RATING } from "../../../_internal/shared/features/reviews/config";
4
- import { decryptPiiFields, encryptPiiFields, piiIndicesFor, REVIEW_PII_FIELDS, REVIEW_PII_INDEX_MAP, } from "../../../security";
4
+ import { decryptPiiFields, REVIEW_PII_FIELDS, REVIEW_PII_INDEX_MAP, } from "../../../security";
5
5
  import { createReviewId, REVIEW_COLLECTION, } from "../schemas";
6
6
  import { buildReviewSearchTxt } from "../../../utils/search-txt-builders";
7
7
  import { planSearchTxt, refineSearchTxt, emptySearchResult, } from "../../../utils/search-txt-query";
@@ -21,6 +21,19 @@ const REVIEW_FIELDS = {
21
21
  class ReviewRepository extends BaseRepository {
22
22
  constructor() {
23
23
  super(REVIEW_COLLECTION);
24
+ /**
25
+ * Declared, not hand-rolled.
26
+ *
27
+ * `BaseRepository.applyWriteHooks` already does exactly what the private
28
+ * `encryptReviewData` did — encrypt the listed fields, then add the mapped blind
29
+ * indices derived from the PLAINTEXT source. Declaring the fields means all
30
+ * nine mutating methods get it, including the `createWithId` / `*InTx` /
31
+ * `*InBatch` family that inherited unhooked base implementations while the
32
+ * encryption lived in a private method only three call sites remembered to
33
+ * call.
34
+ */
35
+ this.piiFields = REVIEW_PII_FIELDS;
36
+ this.piiIndexMap = REVIEW_PII_INDEX_MAP;
24
37
  }
25
38
  decryptSieveResult(result) {
26
39
  return {
@@ -30,22 +43,6 @@ class ReviewRepository extends BaseRepository {
30
43
  ])),
31
44
  };
32
45
  }
33
- /**
34
- * Encrypt PII, then attach blind indices derived from the plaintext.
35
- *
36
- * Previously this reassigned `encrypted = addPiiIndices(data, …)` — which
37
- * re-reads the ORIGINAL plaintext and returns `{...data, ...indices}` — and
38
- * then spread that over the ciphertext, so plaintext won and `userName` was
39
- * written to Firestore in the clear beside a valid index. `piiIndicesFor`
40
- * returns the index fields alone, so the ciphertext cannot be overwritten.
41
- * The identical bug was fixed in UserRepository and never propagated here.
42
- */
43
- encryptReviewData(data) {
44
- return {
45
- ...encryptPiiFields(data, [...REVIEW_PII_FIELDS]),
46
- ...piiIndicesFor(data, REVIEW_PII_INDEX_MAP),
47
- };
48
- }
49
46
  mapDoc(snap) {
50
47
  const raw = super.mapDoc(snap);
51
48
  // `helpfulVoterIds` is an idempotency key, not review content — publishing it
@@ -71,7 +68,7 @@ class ReviewRepository extends BaseRepository {
71
68
  createdAt: new Date(),
72
69
  updatedAt: new Date(),
73
70
  };
74
- const encrypted = this.encryptReviewData(reviewData);
71
+ const encrypted = this.applyWriteHooks(reviewData);
75
72
  await this.db
76
73
  .collection(this.collection)
77
74
  .doc(id)
@@ -79,7 +76,7 @@ class ReviewRepository extends BaseRepository {
79
76
  return { id, ...reviewData };
80
77
  }
81
78
  async update(reviewId, data) {
82
- const encrypted = this.encryptReviewData(data);
79
+ const encrypted = this.applyWriteHooks(data);
83
80
  return super.update(reviewId, encrypted);
84
81
  }
85
82
  async findByProduct(productId) {
@@ -4,7 +4,7 @@ import { SIEVE_OP, Stack, sieveFilter } from "@mohasinac/appkit/client";
4
4
  import { sortBy } from "@mohasinac/appkit/client";
5
5
  import React, { useState, useCallback } from "react";
6
6
  import { useEntityDelete } from "../../../react/hooks/useEntityDelete";
7
- import { Div, FilterChipGroup, ListingLayout, useToast } from "../../../ui";
7
+ import { Div, Grid, FilterChipGroup, ListingLayout, useToast } from "../../../ui";
8
8
  import { SELLER_ENDPOINTS } from "../../../constants/api-endpoints";
9
9
  import { toRecordArray, toStringValue, } from "../../admin/hooks/useAdminListingData";
10
10
  import { normalizeError } from "../../../errors/normalize";
@@ -83,7 +83,7 @@ export function SellerCouponsView({ onCreateClick, onEditClick, onToggle, onDele
83
83
  { id: "true", label: "Active" },
84
84
  { id: "false", label: "Inactive" },
85
85
  ], value: pendingFilters.isActive || "", onChange: (v) => setPendingFilters((p) => ({ ...p, isActive: v })) })),
86
- renderCards: (rows, _view, _selection, isLoading) => isLoading ? (_jsx(Div, { gap: "3", className: "fluid-grid-card", children: Array.from({ length: 6 }).map((_, i) => (_jsxs(Stack, { border: "skeleton", className: "animate-pulse", gap: "3", rounded: "xl", padding: "md", children: [_jsx(Div, { className: "h-6 w-2/3", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-4 w-full", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-3 w-1/2", surface: "subtle", rounded: "default" })] }, i))) })) : (_jsx(Div, { gap: "3", className: "fluid-grid-card", children: rows.map((row) => (_jsx(CouponCard, { coupon: row.raw, onEdit: onEditClick ? handleEdit : undefined, onToggleActive: onToggle ? handleToggle : undefined, onDelete: onDelete ? handleDelete : undefined, className: togglingId === row.id || deletingId === row.id
86
+ renderCards: (rows, _view, _selection, isLoading) => isLoading ? (_jsx(Grid, { cols: "cardsWide", gap: "3", children: Array.from({ length: 6 }).map((_, i) => (_jsxs(Stack, { border: "skeleton", className: "animate-pulse", gap: "3", rounded: "xl", padding: "md", children: [_jsx(Div, { className: "h-6 w-2/3", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-4 w-full", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-3 w-1/2", surface: "subtle", rounded: "default" })] }, i))) })) : (_jsx(Grid, { cols: "cardsWide", gap: "3", children: rows.map((row) => (_jsx(CouponCard, { coupon: row.raw, onEdit: onEditClick ? handleEdit : undefined, onToggleActive: onToggle ? handleToggle : undefined, onDelete: onDelete ? handleDelete : undefined, className: togglingId === row.id || deletingId === row.id
87
87
  ? "pointer-events-none opacity-60"
88
88
  : undefined }, row.id))) })),
89
89
  };