@mohasinac/appkit 4.30.13 → 4.30.16
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/features/addresses/repository/addresses.repository.d.ts +12 -1
- package/dist/features/addresses/repository/addresses.repository.js +16 -7
- package/dist/features/admin/components/AdminBlogView.js +8 -1
- package/dist/features/admin/components/AdminCouponsView.js +8 -1
- package/dist/features/admin/components/AdminOffersView.js +9 -1
- package/dist/features/admin/components/AdminPayoutsView.js +9 -1
- package/dist/features/admin/components/AdminReviewsView.js +14 -1
- package/dist/features/admin/components/AdminScammersView.js +15 -1
- package/dist/features/admin/components/DataListingView.d.ts +22 -1
- package/dist/features/admin/components/DataListingView.js +7 -0
- package/dist/features/admin/hooks/useAdminListing.d.ts +9 -0
- package/dist/features/admin/hooks/useAdminListing.js +30 -2
- package/dist/features/auth/repository/user.repository.d.ts +22 -1
- package/dist/features/auth/repository/user.repository.js +31 -28
- package/dist/features/cart/repository/cart.repository.d.ts +15 -2
- package/dist/features/cart/repository/cart.repository.js +15 -13
- package/dist/features/events/components/AdminEventsView.js +7 -1
- 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/products/components/ProductFilters.d.ts +19 -1
- package/dist/features/products/components/ProductFilters.js +11 -3
- package/dist/features/products/components/ProductsIndexListing.d.ts +4 -2
- package/dist/features/products/components/ProductsIndexListing.js +40 -27
- package/dist/features/reviews/repository/reviews.repository.d.ts +10 -8
- package/dist/features/reviews/repository/reviews.repository.js +16 -19
- package/dist/features/scams/repository/scammer.repository.d.ts +10 -1
- package/dist/features/scams/repository/scammer.repository.js +20 -2
- package/dist/features/scams/schemas/firestore.d.ts +10 -0
- package/dist/features/scams/schemas/firestore.js +1 -0
- package/dist/features/scams/schemas/index.d.ts +10 -10
- package/dist/features/seller/components/SellerCouponsView.js +7 -1
- package/dist/features/seller/components/SellerOffersView.js +8 -1
- package/dist/features/seller/components/SellerOrdersView.js +8 -1
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +161 -17
- package/dist/providers/db-firebase/base.repository.d.ts +12 -0
- package/dist/providers/db-firebase/base.repository.js +14 -2
- 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/schemas/registry.d.ts +8 -8
- package/dist/security/pii-schemas.d.ts +19 -1
- package/dist/security/pii-schemas.js +19 -1
- package/dist/seed/_helpers/search-txt-wrappers.d.ts +2 -0
- package/dist/seed/_helpers/search-txt-wrappers.js +4 -1
- package/dist/seed/scammers-seed-data.js +10 -1
- package/dist/styles.css +1 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/utils/search-txt-builders.d.ts +13 -0
- package/dist/utils/search-txt-builders.js +20 -0
- package/package.json +2 -9
|
@@ -20,17 +20,27 @@ export declare class PayoutRepository extends BaseRepository<PayoutDocument> {
|
|
|
20
20
|
/** Decrypt PII fields on a payout document after Firestore read */
|
|
21
21
|
private decryptPayout;
|
|
22
22
|
/**
|
|
23
|
-
*
|
|
23
|
+
* Declared, not hand-rolled.
|
|
24
24
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* `
|
|
30
|
-
*
|
|
31
|
-
*
|
|
25
|
+
* `BaseRepository.applyWriteHooks` already does exactly what the private
|
|
26
|
+
* `encryptPayoutData` did — encrypt the listed fields, then add the mapped blind
|
|
27
|
+
* indices derived from the PLAINTEXT source. Declaring the fields means all
|
|
28
|
+
* nine mutating methods get it, including the `createWithId` / `*InTx` /
|
|
29
|
+
* `*InBatch` family that inherited unhooked base implementations while the
|
|
30
|
+
* encryption lived in a private method only three call sites remembered to
|
|
31
|
+
* call.
|
|
32
32
|
*/
|
|
33
|
-
|
|
33
|
+
protected piiFields: readonly ["sellerEmail", "upiId"];
|
|
34
|
+
protected piiIndexMap: Record<string, string>;
|
|
35
|
+
/**
|
|
36
|
+
* The base hook, plus the nested objects it cannot see.
|
|
37
|
+
*
|
|
38
|
+
* `piiFields` is a flat list of top-level names, so a nested blob has to be
|
|
39
|
+
* encrypted separately. Calling `super.applyWriteHooks` FIRST keeps a single
|
|
40
|
+
* encryption path with one extra step on top, rather than two parallel paths
|
|
41
|
+
* that have to agree — which is what the hand-rolled override was.
|
|
42
|
+
*/
|
|
43
|
+
protected applyWriteHooks<D extends object>(data: D): D;
|
|
34
44
|
/** Override mapDoc to auto-decrypt PII on every Firestore read */
|
|
35
45
|
protected mapDoc<D = PayoutDocument>(snap: import("../../../providers/db-firebase").DocumentSnapshot): D;
|
|
36
46
|
/**
|
|
@@ -7,10 +7,23 @@
|
|
|
7
7
|
import { BaseRepository, prepareForFirestore, parseSieveDateValue, } from "../../../providers/db-firebase";
|
|
8
8
|
import { PAYOUT_COLLECTION, PAYOUT_FIELDS, PAYOUT_TRACKED_FIELDS, createPayoutId } from "../schemas";
|
|
9
9
|
import { withHistory } from "../../../_internal/shared/history/index";
|
|
10
|
-
import {
|
|
10
|
+
import { decryptPiiFields, PAYOUT_PII_FIELDS, PAYOUT_PII_INDEX_MAP, encryptPayoutBankAccount, decryptPayoutBankAccount, } from "../../../security";
|
|
11
11
|
export class PayoutRepository extends BaseRepository {
|
|
12
12
|
constructor() {
|
|
13
13
|
super(PAYOUT_COLLECTION);
|
|
14
|
+
/**
|
|
15
|
+
* Declared, not hand-rolled.
|
|
16
|
+
*
|
|
17
|
+
* `BaseRepository.applyWriteHooks` already does exactly what the private
|
|
18
|
+
* `encryptPayoutData` did — encrypt the listed fields, then add the mapped blind
|
|
19
|
+
* indices derived from the PLAINTEXT source. Declaring the fields means all
|
|
20
|
+
* nine mutating methods get it, including the `createWithId` / `*InTx` /
|
|
21
|
+
* `*InBatch` family that inherited unhooked base implementations while the
|
|
22
|
+
* encryption lived in a private method only three call sites remembered to
|
|
23
|
+
* call.
|
|
24
|
+
*/
|
|
25
|
+
this.piiFields = PAYOUT_PII_FIELDS;
|
|
26
|
+
this.piiIndexMap = PAYOUT_PII_INDEX_MAP;
|
|
14
27
|
}
|
|
15
28
|
/** Decrypt PII fields on a payout document after Firestore read */
|
|
16
29
|
decryptPayout(doc) {
|
|
@@ -21,26 +34,19 @@ export class PayoutRepository extends BaseRepository {
|
|
|
21
34
|
return decrypted;
|
|
22
35
|
}
|
|
23
36
|
/**
|
|
24
|
-
*
|
|
37
|
+
* The base hook, plus the nested objects it cannot see.
|
|
25
38
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* `piiIndicesFor` returns the index fields alone, so the ciphertext cannot be
|
|
31
|
-
* overwritten. The identical bug was fixed in UserRepository and never
|
|
32
|
-
* propagated here.
|
|
39
|
+
* `piiFields` is a flat list of top-level names, so a nested blob has to be
|
|
40
|
+
* encrypted separately. Calling `super.applyWriteHooks` FIRST keeps a single
|
|
41
|
+
* encryption path with one extra step on top, rather than two parallel paths
|
|
42
|
+
* that have to agree — which is what the hand-rolled override was.
|
|
33
43
|
*/
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
};
|
|
39
|
-
const access = encrypted;
|
|
40
|
-
if (access.bankAccount) {
|
|
44
|
+
applyWriteHooks(data) {
|
|
45
|
+
const out = super.applyWriteHooks(data);
|
|
46
|
+
const access = out;
|
|
47
|
+
if (access.bankAccount)
|
|
41
48
|
access.bankAccount = encryptPayoutBankAccount(access.bankAccount);
|
|
42
|
-
|
|
43
|
-
return encrypted;
|
|
49
|
+
return out;
|
|
44
50
|
}
|
|
45
51
|
/** Override mapDoc to auto-decrypt PII on every Firestore read */
|
|
46
52
|
mapDoc(snap) {
|
|
@@ -61,7 +67,7 @@ export class PayoutRepository extends BaseRepository {
|
|
|
61
67
|
updatedAt: now,
|
|
62
68
|
};
|
|
63
69
|
// Encrypt PII before persisting
|
|
64
|
-
const encrypted = this.
|
|
70
|
+
const encrypted = this.applyWriteHooks(data);
|
|
65
71
|
await this.db
|
|
66
72
|
.collection(this.collection)
|
|
67
73
|
.doc(id)
|
|
@@ -70,7 +76,7 @@ export class PayoutRepository extends BaseRepository {
|
|
|
70
76
|
}
|
|
71
77
|
/** Override base update to preserve encryption/indexing for changed PII fields. */
|
|
72
78
|
async update(payoutId, data) {
|
|
73
|
-
const encrypted = this.
|
|
79
|
+
const encrypted = this.applyWriteHooks(data);
|
|
74
80
|
return super.update(payoutId, encrypted);
|
|
75
81
|
}
|
|
76
82
|
/**
|
|
@@ -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
|
}
|
|
@@ -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, 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,
|
|
@@ -321,5 +334,5 @@ export function ProductsIndexListing({ initialData, listingTypes = GENERIC_PRODU
|
|
|
321
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(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: () => {
|
|
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", className: "pb-[var(--appkit-space-3)]", 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
|
}
|
|
@@ -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) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { BaseRepository, parseSieveDateValue, type FirebaseSieveResult, type SieveModel } from "../../../providers/db-firebase";
|
|
2
2
|
import { type HistoryActor } from "../../../_internal/shared/history/index";
|
|
3
|
+
import type { JsonValue } from "../../../schemas/types";
|
|
3
4
|
/** Who/why for a scammer write that lands in `statusHistory`. */
|
|
4
5
|
export interface ScammerWriteContext {
|
|
5
6
|
actor?: HistoryActor;
|
|
@@ -11,6 +12,10 @@ import { type ScammerDocument, type ScammerIncidentDocument, type ScammerComment
|
|
|
11
12
|
declare class ScammerRepository extends BaseRepository<ScammerDocument> {
|
|
12
13
|
constructor();
|
|
13
14
|
static readonly SIEVE_FIELDS: {
|
|
15
|
+
searchTxt: {
|
|
16
|
+
canFilter: boolean;
|
|
17
|
+
canSort: boolean;
|
|
18
|
+
};
|
|
14
19
|
id: {
|
|
15
20
|
canFilter: boolean;
|
|
16
21
|
canSort: boolean;
|
|
@@ -58,7 +63,11 @@ declare class ScammerRepository extends BaseRepository<ScammerDocument> {
|
|
|
58
63
|
};
|
|
59
64
|
};
|
|
60
65
|
listVerified(model: SieveModel): Promise<FirebaseSieveResult<ScammerDocument>>;
|
|
61
|
-
|
|
66
|
+
/** Derived on every write path via `applyWriteHooks`. */
|
|
67
|
+
protected buildSearchTxtFor(data: Record<string, JsonValue>): string[] | null;
|
|
68
|
+
listAll(model: SieveModel, opts?: {
|
|
69
|
+
search?: string;
|
|
70
|
+
}): Promise<FirebaseSieveResult<ScammerDocument>>;
|
|
62
71
|
findBySeoSlug(seoSlug: string): Promise<ScammerDocument | null>;
|
|
63
72
|
create(input: ScammerCreateInput): Promise<ScammerDocument>;
|
|
64
73
|
/**
|
|
@@ -5,6 +5,8 @@ import { serverLogger } from "../../../monitoring/server-logger";
|
|
|
5
5
|
import { increment } from "../../../contracts/field-ops";
|
|
6
6
|
import { withHistory } from "../../../_internal/shared/history/index";
|
|
7
7
|
import { SCAMMER_TRACKED_FIELDS, SCAMMER_HISTORY_PII_FIELDS } from "../schemas/firestore";
|
|
8
|
+
import { buildScammerSearchTxt } from "../../../utils/search-txt-builders";
|
|
9
|
+
import { planSearchTxt, refineSearchTxt, emptySearchResult, } from "../../../utils/search-txt-query";
|
|
8
10
|
import { SCAMMER_COLLECTION, SCAMMER_FIELDS, SCAMMER_INCIDENTS_SUBCOLLECTION, SCAMMER_COMMENTS_SUBCOLLECTION, DEFAULT_SCAMMER_DATA, } from "../schemas/firestore";
|
|
9
11
|
class ScammerRepository extends BaseRepository {
|
|
10
12
|
constructor() {
|
|
@@ -18,11 +20,26 @@ class ScammerRepository extends BaseRepository {
|
|
|
18
20
|
maxPageSize: 50,
|
|
19
21
|
});
|
|
20
22
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
+
/** Derived on every write path via `applyWriteHooks`. */
|
|
24
|
+
buildSearchTxtFor(data) {
|
|
25
|
+
return buildScammerSearchTxt(data);
|
|
26
|
+
}
|
|
27
|
+
async listAll(model, opts) {
|
|
28
|
+
const plan = planSearchTxt(opts?.search);
|
|
29
|
+
// A search that narrowed to no usable term must return NOTHING, not the
|
|
30
|
+
// entire scam registry.
|
|
31
|
+
if (plan.empty)
|
|
32
|
+
return emptySearchResult();
|
|
33
|
+
let baseQuery = this.getCollection();
|
|
34
|
+
if (plan.head) {
|
|
35
|
+
baseQuery = baseQuery.where(SCAMMER_FIELDS.SEARCH_TXT, "array-contains", plan.head);
|
|
36
|
+
}
|
|
37
|
+
const result = await this.sieveQuery(model, ScammerRepository.SIEVE_FIELDS, {
|
|
38
|
+
baseQuery,
|
|
23
39
|
defaultPageSize: 50,
|
|
24
40
|
maxPageSize: 200,
|
|
25
41
|
});
|
|
42
|
+
return refineSearchTxt(result, plan.rest);
|
|
26
43
|
}
|
|
27
44
|
async findBySeoSlug(seoSlug) {
|
|
28
45
|
try {
|
|
@@ -203,6 +220,7 @@ class ScammerRepository extends BaseRepository {
|
|
|
203
220
|
}
|
|
204
221
|
}
|
|
205
222
|
ScammerRepository.SIEVE_FIELDS = {
|
|
223
|
+
searchTxt: { canFilter: true, canSort: false },
|
|
206
224
|
id: { canFilter: true, canSort: false },
|
|
207
225
|
status: { canFilter: true, canSort: true },
|
|
208
226
|
scamType: { canFilter: true, canSort: true },
|
|
@@ -105,6 +105,15 @@ export interface ScammerDocument extends BaseDocument {
|
|
|
105
105
|
upiIds: string[];
|
|
106
106
|
/** Email addresses used by the scammer. Stored plaintext for SEO. */
|
|
107
107
|
emails: string[];
|
|
108
|
+
/**
|
|
109
|
+
* Word-prefix n-grams over the identity fields, for `array-contains`
|
|
110
|
+
* search. Derived by `buildScammerSearchTxt` on every write.
|
|
111
|
+
*
|
|
112
|
+
* Optional because documents written before this field existed do not have
|
|
113
|
+
* it. Never filter with an inequality on it — that would exclude exactly
|
|
114
|
+
* those rows, which is the whole pre-existing registry.
|
|
115
|
+
*/
|
|
116
|
+
searchTxt?: string[];
|
|
108
117
|
/** Social media profiles where the scammer operated. */
|
|
109
118
|
socialMedia: ScammerSocialMedia[];
|
|
110
119
|
/** Primary scam type for this profile (most common across incidents). */
|
|
@@ -338,6 +347,7 @@ export type ScammerAdminUpdateInput = Partial<Pick<ScammerDocument, "status" | "
|
|
|
338
347
|
export declare const SCAMMER_FIELDS: {
|
|
339
348
|
readonly ID: "id";
|
|
340
349
|
readonly SEO_SLUG: "seoSlug";
|
|
350
|
+
readonly SEARCH_TXT: "searchTxt";
|
|
341
351
|
readonly DISPLAY_NAMES: "displayNames";
|
|
342
352
|
readonly PHONES: "phones";
|
|
343
353
|
readonly UPI_IDS: "upiIds";
|