@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.
Files changed (54) hide show
  1. package/dist/_internal/server/functions/https.js +17 -2
  2. package/dist/_internal/server/jobs/core/listingProcessor.js +5 -1
  3. package/dist/features/addresses/repository/addresses.repository.d.ts +12 -1
  4. package/dist/features/addresses/repository/addresses.repository.js +16 -7
  5. package/dist/features/admin/components/AdminBlogView.js +8 -1
  6. package/dist/features/admin/components/AdminCouponsView.js +8 -1
  7. package/dist/features/admin/components/AdminOffersView.js +9 -1
  8. package/dist/features/admin/components/AdminPayoutsView.js +9 -1
  9. package/dist/features/admin/components/AdminReviewsView.js +14 -1
  10. package/dist/features/admin/components/AdminScammersView.js +15 -1
  11. package/dist/features/admin/components/DataListingView.d.ts +22 -1
  12. package/dist/features/admin/components/DataListingView.js +7 -0
  13. package/dist/features/admin/hooks/useAdminListing.d.ts +9 -0
  14. package/dist/features/admin/hooks/useAdminListing.js +30 -2
  15. package/dist/features/auth/repository/user.repository.d.ts +22 -1
  16. package/dist/features/auth/repository/user.repository.js +31 -28
  17. package/dist/features/cart/repository/cart.repository.d.ts +15 -2
  18. package/dist/features/cart/repository/cart.repository.js +15 -13
  19. package/dist/features/events/components/AdminEventsView.js +7 -1
  20. package/dist/features/orders/repository/orders.repository.d.ts +12 -1
  21. package/dist/features/orders/repository/orders.repository.js +14 -5
  22. package/dist/features/payments/repository/payout.repository.d.ts +19 -9
  23. package/dist/features/payments/repository/payout.repository.js +26 -20
  24. package/dist/features/products/components/ProductFilters.d.ts +19 -1
  25. package/dist/features/products/components/ProductFilters.js +11 -3
  26. package/dist/features/products/components/ProductsIndexListing.d.ts +4 -2
  27. package/dist/features/products/components/ProductsIndexListing.js +40 -27
  28. package/dist/features/reviews/repository/reviews.repository.d.ts +10 -8
  29. package/dist/features/reviews/repository/reviews.repository.js +16 -19
  30. package/dist/features/scams/repository/scammer.repository.d.ts +10 -1
  31. package/dist/features/scams/repository/scammer.repository.js +20 -2
  32. package/dist/features/scams/schemas/firestore.d.ts +10 -0
  33. package/dist/features/scams/schemas/firestore.js +1 -0
  34. package/dist/features/scams/schemas/index.d.ts +10 -10
  35. package/dist/features/seller/components/SellerCouponsView.js +7 -1
  36. package/dist/features/seller/components/SellerOffersView.js +8 -1
  37. package/dist/features/seller/components/SellerOrdersView.js +8 -1
  38. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +161 -17
  39. package/dist/providers/db-firebase/base.repository.d.ts +12 -0
  40. package/dist/providers/db-firebase/base.repository.js +14 -2
  41. package/dist/react/hooks/usePendingFilters.d.ts +12 -2
  42. package/dist/react/hooks/usePendingFilters.js +2 -2
  43. package/dist/react/hooks/usePendingTable.d.ts +9 -2
  44. package/dist/schemas/registry.d.ts +8 -8
  45. package/dist/security/pii-schemas.d.ts +19 -1
  46. package/dist/security/pii-schemas.js +19 -1
  47. package/dist/seed/_helpers/search-txt-wrappers.d.ts +2 -0
  48. package/dist/seed/_helpers/search-txt-wrappers.js +4 -1
  49. package/dist/seed/scammers-seed-data.js +10 -1
  50. package/dist/styles.css +1 -1
  51. package/dist/tailwind-utilities.css +1 -1
  52. package/dist/utils/search-txt-builders.d.ts +13 -0
  53. package/dist/utils/search-txt-builders.js +20 -0
  54. 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
- * Encrypt PII, then attach blind indices derived from the plaintext.
23
+ * Declared, not hand-rolled.
24
24
  *
25
- * Previously this reassigned `encrypted = addPiiIndices(data, …)` — which
26
- * re-reads the ORIGINAL plaintext and returns `{...data, ...indices}` — and
27
- * then spread that over the ciphertext, so plaintext won and `sellerEmail`
28
- * and `upiId` were written to Firestore in the clear beside a valid index.
29
- * `piiIndicesFor` returns the index fields alone, so the ciphertext cannot be
30
- * overwritten. The identical bug was fixed in UserRepository and never
31
- * propagated here.
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
- private encryptPayoutData;
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 { encryptPiiFields, decryptPiiFields, piiIndicesFor, PAYOUT_PII_FIELDS, PAYOUT_PII_INDEX_MAP, encryptPayoutBankAccount, decryptPayoutBankAccount, } from "../../../security";
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
- * Encrypt PII, then attach blind indices derived from the plaintext.
37
+ * The base hook, plus the nested objects it cannot see.
25
38
  *
26
- * Previously this reassigned `encrypted = addPiiIndices(data, …)` — which
27
- * re-reads the ORIGINAL plaintext and returns `{...data, ...indices}` — and
28
- * then spread that over the ciphertext, so plaintext won and `sellerEmail`
29
- * and `upiId` were written to Firestore in the clear beside a valid index.
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
- encryptPayoutData(data) {
35
- const encrypted = {
36
- ...encryptPiiFields(data, [...PAYOUT_PII_FIELDS]),
37
- ...piiIndicesFor(data, PAYOUT_PII_INDEX_MAP),
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.encryptPayoutData(data);
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.encryptPayoutData(data);
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
- return (_jsxs(Div, { children: [loadCategoryOptions ? (_jsx(AsyncFacetSection, { title: t("category"), loadOptions: loadCategoryOptions, selected: selectedCategories, onChange: (vals) => table.set(TABLE_KEYS.CATEGORY, vals.join("|")), defaultCollapsed: false })) : categoryOptions.length > 0 ? (_jsx(FilterFacetSection, { title: t("category"), options: categoryOptions, selected: selectedCategories, onChange: (vals) => table.set(TABLE_KEYS.CATEGORY, vals.join("|")), searchable: true, defaultCollapsed: categoryOptions.length > 6 })) : null, _jsx(FilterFacetSection, { title: t("condition"), options: conditionOptions, selected: selectedConditions, onChange: (vals) => table.set(TABLE_KEYS.CONDITION, vals.join("|")), searchable: false, defaultCollapsed: false }), _jsx(RangeFilter, { title: t("priceRange"), minValue: table.get(TABLE_KEYS.MIN_PRICE), maxValue: table.get(TABLE_KEYS.MAX_PRICE), onMinChange: (v) => table.set(TABLE_KEYS.MIN_PRICE, v), onMaxChange: (v) => table.set(TABLE_KEYS.MAX_PRICE, v), prefix: currencyPrefix, showSlider: true, minBound: 0, maxBound: 500000, step: 500, minPlaceholder: t("minPrice"), maxPlaceholder: t("maxPrice"), defaultCollapsed: false }), loadBrandOptions ? (_jsx(AsyncFacetSection, { title: t("brand"), loadOptions: loadBrandOptions, selected: selectedBrands, onChange: (vals) => table.set(TABLE_KEYS.BRAND, vals.join("|")), defaultCollapsed: false })) : brandOptions.length > 0 ? (_jsx(FilterFacetSection, { title: t("brand"), options: brandOptions, selected: selectedBrands, onChange: (vals) => table.set(TABLE_KEYS.BRAND, vals.join("|")), searchable: brandOptions.length > 4, defaultCollapsed: brandOptions.length > 6 })) : null, loadStoreOptions ? (_jsx(AsyncFacetSection, { title: t("store"), loadOptions: loadStoreOptions, selected: selectedSellers, onChange: (vals) => table.set(TABLE_KEYS.STORE_ID, vals.join("|")), defaultCollapsed: false })) : resolvedStoreOptions.length > 0 ? (_jsx(FilterFacetSection, { title: t("store"), options: resolvedStoreOptions, selected: selectedSellers, onChange: (vals) => table.set(TABLE_KEYS.STORE_ID, vals.join("|")), searchable: resolvedStoreOptions.length > 4, defaultCollapsed: resolvedStoreOptions.length > 6 })) : null, showShipping && (_jsx(SwitchFilter, { title: t("shipping"), label: t("freeShippingOnly"), checked: table.get(TABLE_KEYS.FREE_SHIPPING) === "true", onChange: (v) => table.set(TABLE_KEYS.FREE_SHIPPING, v ? "true" : ""), defaultCollapsed: false })), tagOptions.length > 0 && (_jsx(FilterFacetSection, { title: t("tags"), options: tagOptions, selected: selectedTags, onChange: (vals) => table.set(TABLE_KEYS.TAGS, vals.join("|")), searchable: tagOptions.length > 4, defaultCollapsed: tagOptions.length > 6 })), shouldShowStatus && (_jsx(FilterFacetSection, { title: t("status"), options: statusOptions ?? defaultStatusOptions, selected: selectedStatuses, onChange: (vals) => table.set(TABLE_KEYS.STATUS, vals.join("|")), searchable: false, defaultCollapsed: false })), sublistingCategoryOptions.length > 0 && (_jsx(FilterFacetSection, { title: "Sublisting Type", options: sublistingCategoryOptions, selected: selectedSublistings, onChange: (vals) => table.set(TABLE_KEYS.SUBLISTING_CATEGORY, vals.join("|")), searchable: sublistingCategoryOptions.length > 4, defaultCollapsed: false })), showBundleFilter && (_jsx(SwitchFilter, { title: "Bundles", label: "Part of a bundle only", checked: table.get(TABLE_KEYS.IS_PART_OF_BUNDLE) === "true", onChange: (v) => table.set(TABLE_KEYS.IS_PART_OF_BUNDLE, v ? "true" : ""), defaultCollapsed: false })), featureOptions.length > 0 && (_jsx(FilterFacetSection, { title: "Features", options: featureOptions, selected: selectedFeatures, onChange: (vals) => table.set(TABLE_KEYS.FEATURES, vals.join("|")), searchable: featureOptions.length > 4, defaultCollapsed: featureOptions.length > 4 }))] }));
102
+ const selectedListingTypes = splitPipe(TABLE_KEYS.LISTING_TYPE);
103
+ return (_jsxs(Div, { children: [listingTypeOptions.length > 0 && (_jsxs(_Fragment, { children: [_jsx(FilterFacetSection, { title: "Listing type", options: listingTypeOptions, selected: selectedListingTypes, onChange: (vals) =>
104
+ // Serialise in the OPTIONS' order, not the order the boxes were
105
+ // ticked — `auction|art` and `art|auction` are the same result
106
+ // set but two different React Query keys and two different URLs.
107
+ table.set(TABLE_KEYS.LISTING_TYPE, listingTypeOptions
108
+ .filter((o) => vals.includes(o.value))
109
+ .map((o) => o.value)
110
+ .join("|")), searchable: false, defaultCollapsed: false }), listingTypeFooter] })), loadCategoryOptions ? (_jsx(AsyncFacetSection, { title: t("category"), loadOptions: loadCategoryOptions, selected: selectedCategories, onChange: (vals) => table.set(TABLE_KEYS.CATEGORY, vals.join("|")), defaultCollapsed: false })) : categoryOptions.length > 0 ? (_jsx(FilterFacetSection, { title: t("category"), options: categoryOptions, selected: selectedCategories, onChange: (vals) => table.set(TABLE_KEYS.CATEGORY, vals.join("|")), searchable: true, defaultCollapsed: categoryOptions.length > 6 })) : null, _jsx(FilterFacetSection, { title: t("condition"), options: conditionOptions, selected: selectedConditions, onChange: (vals) => table.set(TABLE_KEYS.CONDITION, vals.join("|")), searchable: false, defaultCollapsed: false }), _jsx(RangeFilter, { title: t("priceRange"), minValue: table.get(TABLE_KEYS.MIN_PRICE), maxValue: table.get(TABLE_KEYS.MAX_PRICE), onMinChange: (v) => table.set(TABLE_KEYS.MIN_PRICE, v), onMaxChange: (v) => table.set(TABLE_KEYS.MAX_PRICE, v), prefix: currencyPrefix, showSlider: true, minBound: 0, maxBound: 500000, step: 500, minPlaceholder: t("minPrice"), maxPlaceholder: t("maxPrice"), defaultCollapsed: false }), loadBrandOptions ? (_jsx(AsyncFacetSection, { title: t("brand"), loadOptions: loadBrandOptions, selected: selectedBrands, onChange: (vals) => table.set(TABLE_KEYS.BRAND, vals.join("|")), defaultCollapsed: false })) : brandOptions.length > 0 ? (_jsx(FilterFacetSection, { title: t("brand"), options: brandOptions, selected: selectedBrands, onChange: (vals) => table.set(TABLE_KEYS.BRAND, vals.join("|")), searchable: brandOptions.length > 4, defaultCollapsed: brandOptions.length > 6 })) : null, loadStoreOptions ? (_jsx(AsyncFacetSection, { title: t("store"), loadOptions: loadStoreOptions, selected: selectedSellers, onChange: (vals) => table.set(TABLE_KEYS.STORE_ID, vals.join("|")), defaultCollapsed: false })) : resolvedStoreOptions.length > 0 ? (_jsx(FilterFacetSection, { title: t("store"), options: resolvedStoreOptions, selected: selectedSellers, onChange: (vals) => table.set(TABLE_KEYS.STORE_ID, vals.join("|")), searchable: resolvedStoreOptions.length > 4, defaultCollapsed: resolvedStoreOptions.length > 6 })) : null, showShipping && (_jsx(SwitchFilter, { title: t("shipping"), label: t("freeShippingOnly"), checked: table.get(TABLE_KEYS.FREE_SHIPPING) === "true", onChange: (v) => table.set(TABLE_KEYS.FREE_SHIPPING, v ? "true" : ""), defaultCollapsed: false })), tagOptions.length > 0 && (_jsx(FilterFacetSection, { title: t("tags"), options: tagOptions, selected: selectedTags, onChange: (vals) => table.set(TABLE_KEYS.TAGS, vals.join("|")), searchable: tagOptions.length > 4, defaultCollapsed: tagOptions.length > 6 })), shouldShowStatus && (_jsx(FilterFacetSection, { title: t("status"), options: statusOptions ?? defaultStatusOptions, selected: selectedStatuses, onChange: (vals) => table.set(TABLE_KEYS.STATUS, vals.join("|")), searchable: false, defaultCollapsed: false })), sublistingCategoryOptions.length > 0 && (_jsx(FilterFacetSection, { title: "Sublisting Type", options: sublistingCategoryOptions, selected: selectedSublistings, onChange: (vals) => table.set(TABLE_KEYS.SUBLISTING_CATEGORY, vals.join("|")), searchable: sublistingCategoryOptions.length > 4, defaultCollapsed: false })), showBundleFilter && (_jsx(SwitchFilter, { title: "Bundles", label: "Part of a bundle only", checked: table.get(TABLE_KEYS.IS_PART_OF_BUNDLE) === "true", onChange: (v) => table.set(TABLE_KEYS.IS_PART_OF_BUNDLE, v ? "true" : ""), defaultCollapsed: false })), featureOptions.length > 0 && (_jsx(FilterFacetSection, { title: "Features", options: featureOptions, selected: selectedFeatures, onChange: (vals) => table.set(TABLE_KEYS.FEATURES, vals.join("|")), searchable: featureOptions.length > 4, defaultCollapsed: featureOptions.length > 4 }))] }));
103
111
  }
@@ -6,8 +6,10 @@ export interface ProductsIndexListingProps {
6
6
  * (e.g. `["art", "stickers"]`) to reuse this component for a different
7
7
  * combined browse page. */
8
8
  listingTypes?: readonly string[];
9
- /** Type-filter chip tabs shown above the grid. Defaults to the generic
10
- * Products tabs; pass a matching set when overriding `listingTypes`. */
9
+ /** Options for the "Listing type" checkbox facet in the filter drawer. `id`
10
+ * must be the canonical `ListingType` — it is passed straight into
11
+ * `sieveFilter("listingType", EQ, id)`. Defaults to the generic Products set;
12
+ * pass a matching set when overriding `listingTypes`. */
11
13
  typeTabs?: readonly {
12
14
  id: string;
13
15
  label: string;
@@ -5,7 +5,7 @@ import { ShoppingCart, Heart, Columns } from "lucide-react";
5
5
  import { useRouter } from "next/navigation";
6
6
  import { useUrlTable } from "../../../react/hooks/useUrlTable";
7
7
  import { useProducts } from "../hooks/useProducts";
8
- import { BulkActionBar, Div, FilterChipGroup, FilterDrawer, ListingToolbar, LoginRequiredModal, Pagination, Row, Stack, useToast, StickyToolbar } from "../../../ui";
8
+ import { BulkActionBar, Div, FilterDrawer, ListingToolbar, LoginRequiredModal, Pagination, Row, Stack, useToast, StickyToolbar } from "../../../ui";
9
9
  import { usePendingTable } from "../../../react/hooks/usePendingTable";
10
10
  import { useAuthGate } from "../../../react/hooks/useAuthGate";
11
11
  import { ACTION_ID, ACTION_META, COMPARE_MAX_ITEMS } from "../constants/action-defs";
@@ -40,7 +40,27 @@ const __O = {
40
40
  hidden: "overflow-hidden",
41
41
  };
42
42
  const DEFAULT_SORT = sortBy(PRODUCT_FIELDS.CREATED_AT);
43
- const FILTER_KEYS = [TABLE_KEYS.CATEGORY, TABLE_KEYS.CONDITION, TABLE_KEYS.MIN_PRICE, TABLE_KEYS.MAX_PRICE, TABLE_KEYS.BRAND, TABLE_KEYS.STORE_ID, TABLE_KEYS.FREE_SHIPPING, TABLE_KEYS.TAGS, TABLE_KEYS.FEATURES, TABLE_KEYS.IS_PART_OF_BUNDLE];
43
+ // `LISTING_TYPE` is a managed filter key like any other: it stages in the
44
+ // drawer, counts toward the filter badge, and is cleared by Reset. It used to
45
+ // be driven by a separate always-visible chip row that wrote the URL directly,
46
+ // which was the one facet bypassing the Apply model everything else uses.
47
+ const FILTER_KEYS = [TABLE_KEYS.LISTING_TYPE, TABLE_KEYS.CATEGORY, TABLE_KEYS.CONDITION, TABLE_KEYS.MIN_PRICE, TABLE_KEYS.MAX_PRICE, TABLE_KEYS.BRAND, TABLE_KEYS.STORE_ID, TABLE_KEYS.FREE_SHIPPING, TABLE_KEYS.TAGS, TABLE_KEYS.FEATURES, TABLE_KEYS.IS_PART_OF_BUNDLE];
48
+ /**
49
+ * The dedicated per-type browse page for a selection, when there is exactly
50
+ * one type and that type has its own page — which carries facets this generic
51
+ * one can't (bid range, delivery-date window, reveal status).
52
+ */
53
+ function dedicatedPageFor(types) {
54
+ if (types.length !== 1)
55
+ return null;
56
+ const plugin = pluginFor(types[0]);
57
+ if (!plugin.browseRoute)
58
+ return null;
59
+ return {
60
+ href: plugin.browseRoute,
61
+ label: `Full ${plugin.pluralLabel} filters →`,
62
+ };
63
+ }
44
64
  export function ProductsIndexListing({ initialData, listingTypes = GENERIC_PRODUCT_LISTING_TYPES, typeTabs = PRODUCT_TYPE_FILTER_TABS, searchPlaceholder = "Search products...", }) {
45
65
  const router = useRouter();
46
66
  const table = useUrlTable({ defaults: { pageSize: "24", sort: DEFAULT_SORT } });
@@ -74,36 +94,39 @@ export function ProductsIndexListing({ initialData, listingTypes = GENERIC_PRODU
74
94
  // keeps the SSR paint and the first refetch in agreement (Root Cause #30).
75
95
  const availability = table.get(TABLE_KEYS.AVAILABILITY) ||
76
96
  AVAILABILITY_VALUES.AVAILABLE;
77
- // "Full <type> filters →" — a single selected type with a dedicated browse
78
- // page gets a link to it, since that page carries facets this generic one
79
- // can't (bid range, delivery-date window, reveal status).
80
- const dedicatedPage = selectedTypes.length === 1 && pluginFor(selectedTypes[0]).browseRoute
81
- ? {
82
- href: pluginFor(selectedTypes[0]).browseRoute,
83
- label: `Full ${pluginFor(selectedTypes[0]).pluralLabel} filters →`,
84
- }
85
- : null;
86
97
  const { pendingTable, filterActiveCount, onFilterApply, onFilterClear, onResetAll, onFilterReset } = usePendingTable(table, FILTER_KEYS);
98
+ // The drawer's own, uncommitted type selection. The "Full <type> filters →"
99
+ // link tracks THIS rather than the applied URL value, so it appears the
100
+ // moment the user ticks a single type instead of only after Apply.
101
+ const pendingTypes = parseSelectedListingTypes(pendingTable.get(TABLE_KEYS.LISTING_TYPE)).filter((t) => listingTypes.includes(t));
102
+ const dedicatedPage = dedicatedPageFor(pendingTypes);
103
+ const listingTypeOptions = typeTabs.map((t) => ({ value: t.id, label: t.label }));
87
104
  const openFilters = useCallback(() => {
88
105
  onFilterReset();
89
106
  setFilterOpen(true);
90
107
  }, [onFilterReset]);
91
108
  const applyFilters = useCallback(() => {
92
- onFilterApply();
109
+ // A sort valid for the old type selection (say -auctionEndDate) can be
110
+ // invalid for the new one, so it is blanked in the SAME setMany as the
111
+ // filters — a follow-up table.set would read stale searchParams and
112
+ // overwrite this update (Root Cause #13).
113
+ const key = (types) => [...types].sort().join("|");
114
+ const typesChanged = key(pendingTypes) !== key(selectedTypes);
115
+ onFilterApply(typesChanged ? { [TABLE_KEYS.SORT]: "" } : undefined);
93
116
  setFilterOpen(false);
94
- }, [onFilterApply]);
117
+ }, [onFilterApply, pendingTypes, selectedTypes]);
95
118
  const resetAll = useCallback(() => {
119
+ // `listingType` is a managed filter key now, so onResetAll clears it —
120
+ // repeating it here would be a second write of the same value.
96
121
  onResetAll({
97
122
  [TABLE_KEYS.QUERY]: "",
98
123
  [TABLE_KEYS.SORT]: "",
99
124
  [TABLE_KEYS.AVAILABILITY]: "",
100
- [TABLE_KEYS.LISTING_TYPE]: "",
101
125
  });
102
126
  setSearchInput("");
103
127
  }, [onResetAll]);
104
128
  const hasActiveState = !!table.get(TABLE_KEYS.QUERY) ||
105
129
  availability !== AVAILABILITY_VALUES.AVAILABLE ||
106
- !!typeParam ||
107
130
  table.get(TABLE_KEYS.SORT) !== DEFAULT_SORT ||
108
131
  filterActiveCount > 0;
109
132
  const localCart = useGuestCart();
@@ -263,17 +286,7 @@ export function ProductsIndexListing({ initialData, listingTypes = GENERIC_PRODU
263
286
  // → close for every toggle. The full facet set (category/price/brand/…)
264
287
  // remains in <FilterDrawer> below.
265
288
  { label: "Free shipping", active: table.get(TABLE_KEYS.FREE_SHIPPING) === "true", onChange: (next) => table.set(TABLE_KEYS.FREE_SHIPPING, next ? "true" : "") },
266
- ] }), _jsx(Div, { padding: "y-sm", children: _jsx(AvailabilityTabs, { types: effectiveTypes }) }), _jsx(Div, { padding: "y-sm", children: _jsxs(Row, { justify: "between", align: "center", wrap: true, gap: "sm", children: [_jsx(FilterChipGroup, { multiple: true, label: "Type", tabs: typeTabs, value: selectedTypes.join("|"), onChange: (next) => {
267
- // Clear `sort` alongside the type change: a sort valid for the
268
- // old selection (say -auctionEndDate) can be invalid for the new
269
- // one, and useUrlTable's single router.replace means we must set
270
- // both in ONE call — a follow-up setPage/set would read stale
271
- // searchParams and overwrite this update (Root Cause #13).
272
- table.setMany({
273
- [TABLE_KEYS.LISTING_TYPE]: next,
274
- [TABLE_KEYS.SORT]: "",
275
- });
276
- } }), dedicatedPage && (_jsx(TextLink, { href: dedicatedPage.href, size: "sm", weight: "medium", children: dedicatedPage.label }))] }) }), _jsx(BulkActionBar, { selectedCount: selection.selectedCount, onClearSelection: selection.clearSelection, actions: [
289
+ ] }), _jsx(Div, { padding: "y-sm", children: _jsx(AvailabilityTabs, { types: effectiveTypes }) }), _jsx(BulkActionBar, { selectedCount: selection.selectedCount, onClearSelection: selection.clearSelection, actions: [
277
290
  {
278
291
  id: ACTION_ID.ADD_TO_CART,
279
292
  label: ACTION_META[ACTION_ID.ADD_TO_CART].label,
@@ -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
- * Encrypt PII, then attach blind indices derived from the plaintext.
14
+ * Declared, not hand-rolled.
15
15
  *
16
- * Previously this reassigned `encrypted = addPiiIndices(data, …)` — which
17
- * re-reads the ORIGINAL plaintext and returns `{...data, ...indices}` — and
18
- * then spread that over the ciphertext, so plaintext won and `userName` was
19
- * written to Firestore in the clear beside a valid index. `piiIndicesFor`
20
- * returns the index fields alone, so the ciphertext cannot be overwritten.
21
- * The identical bug was fixed in UserRepository and never propagated here.
16
+ * `BaseRepository.applyWriteHooks` already does exactly what the private
17
+ * `encryptReviewData` did — encrypt the listed fields, then add the mapped blind
18
+ * indices derived from the PLAINTEXT source. Declaring the fields means all
19
+ * nine mutating methods get it, including the `createWithId` / `*InTx` /
20
+ * `*InBatch` family that inherited unhooked base implementations while the
21
+ * encryption lived in a private method only three call sites remembered to
22
+ * call.
22
23
  */
23
- private encryptReviewData;
24
+ protected piiFields: readonly ["userName"];
25
+ protected piiIndexMap: Record<string, string>;
24
26
  protected mapDoc<D = ReviewDocument>(snap: import("../../../providers/db-firebase").DocumentSnapshot): D;
25
27
  create(input: ReviewCreateInput): Promise<ReviewDocument>;
26
28
  update(reviewId: string, data: Partial<ReviewDocument>): Promise<ReviewDocument>;
@@ -1,7 +1,7 @@
1
1
  import { BaseRepository, getFirestoreCount, prepareForFirestore, parseSieveDateValue, } from "../../../providers/db-firebase";
2
2
  import { NotFoundError } from "../../../errors";
3
3
  import { REVIEW_MAX_RATING, REVIEW_MIN_RATING } from "../../../_internal/shared/features/reviews/config";
4
- import { decryptPiiFields, encryptPiiFields, piiIndicesFor, REVIEW_PII_FIELDS, REVIEW_PII_INDEX_MAP, } from "../../../security";
4
+ import { decryptPiiFields, REVIEW_PII_FIELDS, REVIEW_PII_INDEX_MAP, } from "../../../security";
5
5
  import { createReviewId, REVIEW_COLLECTION, } from "../schemas";
6
6
  import { buildReviewSearchTxt } from "../../../utils/search-txt-builders";
7
7
  import { planSearchTxt, refineSearchTxt, emptySearchResult, } from "../../../utils/search-txt-query";
@@ -21,6 +21,19 @@ const REVIEW_FIELDS = {
21
21
  class ReviewRepository extends BaseRepository {
22
22
  constructor() {
23
23
  super(REVIEW_COLLECTION);
24
+ /**
25
+ * Declared, not hand-rolled.
26
+ *
27
+ * `BaseRepository.applyWriteHooks` already does exactly what the private
28
+ * `encryptReviewData` did — encrypt the listed fields, then add the mapped blind
29
+ * indices derived from the PLAINTEXT source. Declaring the fields means all
30
+ * nine mutating methods get it, including the `createWithId` / `*InTx` /
31
+ * `*InBatch` family that inherited unhooked base implementations while the
32
+ * encryption lived in a private method only three call sites remembered to
33
+ * call.
34
+ */
35
+ this.piiFields = REVIEW_PII_FIELDS;
36
+ this.piiIndexMap = REVIEW_PII_INDEX_MAP;
24
37
  }
25
38
  decryptSieveResult(result) {
26
39
  return {
@@ -30,22 +43,6 @@ class ReviewRepository extends BaseRepository {
30
43
  ])),
31
44
  };
32
45
  }
33
- /**
34
- * Encrypt PII, then attach blind indices derived from the plaintext.
35
- *
36
- * Previously this reassigned `encrypted = addPiiIndices(data, …)` — which
37
- * re-reads the ORIGINAL plaintext and returns `{...data, ...indices}` — and
38
- * then spread that over the ciphertext, so plaintext won and `userName` was
39
- * written to Firestore in the clear beside a valid index. `piiIndicesFor`
40
- * returns the index fields alone, so the ciphertext cannot be overwritten.
41
- * The identical bug was fixed in UserRepository and never propagated here.
42
- */
43
- encryptReviewData(data) {
44
- return {
45
- ...encryptPiiFields(data, [...REVIEW_PII_FIELDS]),
46
- ...piiIndicesFor(data, REVIEW_PII_INDEX_MAP),
47
- };
48
- }
49
46
  mapDoc(snap) {
50
47
  const raw = super.mapDoc(snap);
51
48
  // `helpfulVoterIds` is an idempotency key, not review content — publishing it
@@ -71,7 +68,7 @@ class ReviewRepository extends BaseRepository {
71
68
  createdAt: new Date(),
72
69
  updatedAt: new Date(),
73
70
  };
74
- const encrypted = this.encryptReviewData(reviewData);
71
+ const encrypted = this.applyWriteHooks(reviewData);
75
72
  await this.db
76
73
  .collection(this.collection)
77
74
  .doc(id)
@@ -79,7 +76,7 @@ class ReviewRepository extends BaseRepository {
79
76
  return { id, ...reviewData };
80
77
  }
81
78
  async update(reviewId, data) {
82
- const encrypted = this.encryptReviewData(data);
79
+ const encrypted = this.applyWriteHooks(data);
83
80
  return super.update(reviewId, encrypted);
84
81
  }
85
82
  async findByProduct(productId) {
@@ -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
- listAll(model: SieveModel): Promise<FirebaseSieveResult<ScammerDocument>>;
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
- async listAll(model) {
22
- return this.sieveQuery(model, ScammerRepository.SIEVE_FIELDS, {
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";
@@ -170,6 +170,7 @@ export const MAX_PENDING_SCAMMER_REPORTS_PER_USER = 5;
170
170
  export const SCAMMER_FIELDS = {
171
171
  ID: "id",
172
172
  SEO_SLUG: "seoSlug",
173
+ SEARCH_TXT: "searchTxt",
173
174
  DISPLAY_NAMES: "displayNames",
174
175
  PHONES: "phones",
175
176
  UPI_IDS: "upiIds",