@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.
- package/dist/_internal/server/functions/https.js +17 -2
- package/dist/_internal/server/jobs/core/listingProcessor.js +5 -1
- package/dist/_internal/shared/types/action-error-reporter.d.ts +40 -0
- package/dist/_internal/shared/types/action-error-reporter.js +27 -0
- package/dist/_internal/shared/types/action-result.d.ts +10 -0
- package/dist/_internal/shared/types/action-result.js +26 -0
- package/dist/client/api/surface-error.d.ts +6 -4
- package/dist/client/api/surface-error.js +19 -8
- package/dist/contracts/realtime-session.d.ts +27 -0
- package/dist/contracts/realtime-session.js +118 -0
- package/dist/core/unit-of-work.js +21 -13
- package/dist/errors/base-error.js +14 -1
- package/dist/errors/database-error.js +11 -1
- package/dist/errors/describe-cause.d.ts +5 -0
- package/dist/errors/describe-cause.js +76 -0
- package/dist/errors/error-display-map.d.ts +32 -1
- package/dist/errors/error-display-map.js +46 -0
- package/dist/errors/error-mapping.d.ts +47 -7
- package/dist/errors/error-mapping.js +46 -5
- package/dist/errors/index.d.ts +1 -0
- package/dist/errors/index.js +4 -0
- package/dist/features/about/components/PublicProfileView.js +4 -4
- package/dist/features/account/components/AddressBook.js +2 -2
- package/dist/features/account/components/AddressesIndexListing.js +2 -2
- package/dist/features/addresses/repository/addresses.repository.d.ts +12 -1
- package/dist/features/addresses/repository/addresses.repository.js +16 -7
- package/dist/features/admin/components/AdminAnalyticsView.js +2 -2
- package/dist/features/admin/components/AdminCouponsView.js +2 -2
- package/dist/features/admin/components/AdminGuideHubView.js +2 -2
- package/dist/features/admin/components/AdminMediaView.js +2 -2
- package/dist/features/admin/components/AdminViewCards.js +2 -2
- package/dist/features/auctions/components/AuctionDetailPageView.js +1 -1
- package/dist/features/auctions/components/MarketplaceAuctionGrid.d.ts +1 -2
- package/dist/features/auctions/components/MarketplaceAuctionGrid.js +8 -7
- package/dist/features/auctions/components/PlaceBidFormClient.js +32 -15
- package/dist/features/auth/repository/user.repository.d.ts +22 -1
- package/dist/features/auth/repository/user.repository.js +31 -28
- package/dist/features/before-after/components/BeforeAfterGallery.js +2 -2
- package/dist/features/blog/components/BlogIndexListing.js +3 -3
- package/dist/features/blog/components/BlogListView.js +3 -3
- package/dist/features/blog/components/BlogPostView.js +2 -2
- package/dist/features/cart/repository/cart.repository.d.ts +15 -2
- package/dist/features/cart/repository/cart.repository.js +15 -13
- package/dist/features/categories/components/BundleCollage.js +2 -2
- package/dist/features/categories/components/CategoriesIndexListing.js +3 -3
- package/dist/features/categories/components/CategoryBundlesListing.js +2 -2
- package/dist/features/categories/components/CategoryGrid.js +2 -2
- package/dist/features/categories/components/CategoryProductsListing.js +2 -2
- package/dist/features/categories/components/CategoryStoresListing.js +2 -2
- package/dist/features/collections/components/CollectionCard.js +2 -2
- package/dist/features/events/components/EventRafflesSection.js +2 -2
- package/dist/features/events/components/EventsIndexListing.js +2 -2
- package/dist/features/events/components/EventsListView.js +3 -3
- package/dist/features/homepage/components/FeaturedBundlesSection.js +2 -2
- package/dist/features/homepage/components/GoogleReviewsSection.js +2 -2
- package/dist/features/homepage/components/HomepageSkeleton.js +1 -1
- package/dist/features/homepage/components/SecurityHighlightsSection.js +1 -1
- package/dist/features/homepage/components/SocialFeedSection.js +2 -2
- package/dist/features/homepage/components/StatsCounterSection.js +1 -1
- package/dist/features/homepage/components/TrustBadges.js +2 -2
- package/dist/features/homepage/components/TrustFeaturesSection.js +1 -1
- package/dist/features/messages/hooks/useConversation.js +39 -15
- package/dist/features/messages/hooks/useConversations.js +36 -14
- package/dist/features/messages/repository/conversations.repository.d.ts +23 -0
- package/dist/features/messages/repository/conversations.repository.js +53 -0
- package/dist/features/orders/repository/orders.repository.d.ts +12 -1
- package/dist/features/orders/repository/orders.repository.js +14 -5
- package/dist/features/payments/repository/payout.repository.d.ts +19 -9
- package/dist/features/payments/repository/payout.repository.js +26 -20
- package/dist/features/pre-orders/components/PreOrdersIndexListing.js +2 -3
- package/dist/features/products/components/AuctionsIndexListing.js +2 -3
- package/dist/features/products/components/PrizeDrawCollage.js +2 -2
- package/dist/features/products/components/PrizeDrawsIndexListing.js +2 -3
- package/dist/features/products/components/PrizeDrawsSection.js +2 -2
- package/dist/features/products/components/ProductFilters.d.ts +19 -1
- package/dist/features/products/components/ProductFilters.js +11 -3
- package/dist/features/products/components/ProductGrid.js +2 -7
- package/dist/features/products/components/ProductsIndexListing.d.ts +4 -2
- package/dist/features/products/components/ProductsIndexListing.js +41 -28
- package/dist/features/products/components/RelatedProducts.js +2 -2
- package/dist/features/promotions/components/CouponsIndexListing.js +1 -1
- package/dist/features/reviews/components/ReviewDetailPageView.js +2 -2
- package/dist/features/reviews/components/ReviewsListingPanel.js +2 -2
- package/dist/features/reviews/repository/reviews.repository.d.ts +10 -8
- package/dist/features/reviews/repository/reviews.repository.js +16 -19
- package/dist/features/seller/components/SellerCouponsView.js +2 -2
- package/dist/features/seller/components/SellerDashboardView.js +3 -3
- package/dist/features/server-errors/schemas/firestore.d.ts +9 -0
- package/dist/features/stores/components/StoreAuctionsListing.js +2 -3
- package/dist/features/stores/components/StoreClassifiedsListing.js +2 -3
- package/dist/features/stores/components/StoreDigitalCodesListing.js +2 -3
- package/dist/features/stores/components/StoreGuideHubView.js +2 -2
- package/dist/features/stores/components/StoreLiveItemsListing.js +2 -3
- package/dist/features/stores/components/StorePreOrdersListing.js +2 -3
- package/dist/features/stores/components/StoreProductsListing.js +2 -2
- package/dist/features/stores/components/StoresIndexListing.js +3 -3
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +215 -18
- package/dist/next/api/routeHandler.js +27 -7
- package/dist/providers/db-firebase/base.repository.d.ts +12 -0
- package/dist/providers/db-firebase/base.repository.js +48 -20
- package/dist/react/hooks/usePendingFilters.d.ts +12 -2
- package/dist/react/hooks/usePendingFilters.js +2 -2
- package/dist/react/hooks/usePendingTable.d.ts +9 -2
- package/dist/react/hooks/useRealtimeEvent.js +22 -4
- package/dist/security/pii-schemas.d.ts +19 -1
- package/dist/security/pii-schemas.js +19 -1
- package/dist/server.d.ts +2 -0
- package/dist/server.js +5 -0
- package/dist/styles.css +84 -20
- package/dist/tailwind-utilities.css +1 -1
- package/dist/tokens/tokens.css +7 -2
- package/dist/ui/components/Layout.d.ts +31 -18
- package/dist/ui/components/Layout.js +30 -19
- package/dist/ui/components/Layout.style.css +76 -17
- package/dist/ui/components/ListingLayout.d.ts +2 -0
- package/dist/ui/components/ListingLayout.js +16 -2
- package/dist/ui/components/StickyToolbar.js +3 -48
- package/dist/ui/components/toolbar-collapse.d.ts +44 -0
- package/dist/ui/components/toolbar-collapse.js +93 -0
- 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(
|
|
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(
|
|
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(
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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: "
|
|
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
|
-
|
|
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
|
-
/**
|
|
10
|
-
*
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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, {
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
*
|
|
14
|
+
* Declared, not hand-rolled.
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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.
|
|
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(
|
|
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
|
};
|