@mohasinac/appkit 4.30.13 → 4.30.15

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 (28) hide show
  1. package/dist/features/admin/components/AdminBlogView.js +8 -1
  2. package/dist/features/admin/components/AdminCouponsView.js +8 -1
  3. package/dist/features/admin/components/AdminOffersView.js +9 -1
  4. package/dist/features/admin/components/AdminPayoutsView.js +9 -1
  5. package/dist/features/admin/components/AdminReviewsView.js +14 -1
  6. package/dist/features/admin/components/AdminScammersView.js +15 -1
  7. package/dist/features/admin/components/DataListingView.d.ts +22 -1
  8. package/dist/features/admin/components/DataListingView.js +7 -0
  9. package/dist/features/admin/hooks/useAdminListing.d.ts +9 -0
  10. package/dist/features/admin/hooks/useAdminListing.js +30 -2
  11. package/dist/features/events/components/AdminEventsView.js +7 -1
  12. package/dist/features/scams/repository/scammer.repository.d.ts +10 -1
  13. package/dist/features/scams/repository/scammer.repository.js +20 -2
  14. package/dist/features/scams/schemas/firestore.d.ts +10 -0
  15. package/dist/features/scams/schemas/firestore.js +1 -0
  16. package/dist/features/scams/schemas/index.d.ts +10 -10
  17. package/dist/features/seller/components/SellerCouponsView.js +7 -1
  18. package/dist/features/seller/components/SellerOffersView.js +8 -1
  19. package/dist/features/seller/components/SellerOrdersView.js +8 -1
  20. package/dist/schemas/registry.d.ts +8 -8
  21. package/dist/seed/_helpers/search-txt-wrappers.d.ts +2 -0
  22. package/dist/seed/_helpers/search-txt-wrappers.js +4 -1
  23. package/dist/seed/scammers-seed-data.js +10 -1
  24. package/dist/styles.css +1 -1
  25. package/dist/tailwind-utilities.css +1 -1
  26. package/dist/utils/search-txt-builders.d.ts +13 -0
  27. package/dist/utils/search-txt-builders.js +20 -0
  28. package/package.json +2 -9
@@ -23,7 +23,14 @@ export function AdminBlogView({ children, ...props }) {
23
23
  title: "Blog Posts",
24
24
  // buildBlogSearchTxt indexes title + excerpt + category + tags — NOT the post
25
25
  // body, which is not tokenised.
26
- searchPlaceholder: "Search by title, excerpt, category or tag…",
26
+ // buildBlogSearchTxt indexes title + excerpt + category + tags — NOT the
27
+ // post body, which is not tokenised.
28
+ search: {
29
+ placeholder: "Search by title, excerpt, category or tag…",
30
+ mode: "partial",
31
+ fields: ["title", "excerpt", "category", "tags"],
32
+ commit: "debounce",
33
+ },
27
34
  emptyLabel: "No blog posts found",
28
35
  filterKeys: ["status", "isFeatured"],
29
36
  defaultSort: sortBy("publishedAt", "DESC"),
@@ -53,7 +53,14 @@ export function AdminCouponsView({ children, onBulkArchive, onBulkDelete, ...pro
53
53
  portal: "admin",
54
54
  title: "Coupons",
55
55
  // buildCouponSearchTxt indexes code + name + description — nothing else.
56
- searchPlaceholder: "Search by code, name or description…",
56
+ // buildCouponSearchTxt indexes exactly these three. Partial prefix
57
+ // matching, so debounce is the right commit here.
58
+ search: {
59
+ placeholder: "Search by code, name or description…",
60
+ mode: "partial",
61
+ fields: ["code", "name", "description"],
62
+ commit: "debounce",
63
+ },
57
64
  emptyLabel: "No coupons found",
58
65
  filterKeys: ["type", "showExpired"],
59
66
  defaultSort: sortBy("createdAt", "DESC"),
@@ -106,7 +106,15 @@ export function AdminOffersView({ children, ...props }) {
106
106
  // buildOfferSearchTxt indexes productTitle + productSlug + storeName. It does
107
107
  // NOT index buyerName or buyerEmail — those are PII, and the placeholder
108
108
  // must not promise a match it will never make.
109
- searchPlaceholder: "Search by product or store…",
109
+ // buildOfferSearchTxt indexes productTitle + productSlug + storeName.
110
+ // NOT buyerName or buyerEmail — those are PII (D1), and a placeholder
111
+ // naming them would promise a match this corpus cannot make.
112
+ search: {
113
+ placeholder: "Search by product or store…",
114
+ mode: "partial",
115
+ fields: ["productTitle", "productSlug", "storeName"],
116
+ commit: "debounce",
117
+ },
110
118
  emptyLabel: "No offers found",
111
119
  filterKeys: ["status"],
112
120
  defaultSort: sortBy("createdAt", "DESC"),
@@ -81,7 +81,15 @@ export function AdminPayoutsView({ children, ...props }) {
81
81
  const config = {
82
82
  portal: "admin",
83
83
  title: "Payouts",
84
- searchPlaceholder: "Search stores, payout IDs, or order groups",
84
+ // 🛑 This said "Search stores, payout IDs, or order groups". The route
85
+ // resolves `q` as `sellerEmailIndex == piiBlindIndex(q)` when it contains
86
+ // an "@", else `sellerName == q` — both EXACT, and neither is a store, a
87
+ // payout id or an order group. All three promised things were unmatchable.
88
+ search: {
89
+ placeholder: "Seller email or name (exact match)",
90
+ mode: "exact",
91
+ fields: ["sellerEmail", "sellerName"],
92
+ },
85
93
  emptyLabel: "No payouts found",
86
94
  filterKeys: ["status", "showAllPayouts"],
87
95
  defaultSort: sortBy("createdAt", "DESC"),
@@ -69,7 +69,20 @@ export function AdminReviewsView({ children, ...props }) {
69
69
  const config = {
70
70
  portal: "admin",
71
71
  title: "Reviews",
72
- searchPlaceholder: "Search reviews, products, or seller names",
72
+ // 🛑 This said "Search reviews, products, or seller names". The route
73
+ // resolves `q` as `userNameIndex == piiBlindIndex(q)` — an EXACT match on
74
+ // the REVIEWER's name, and nothing else. It matched no review text, no
75
+ // product and no seller, so a partial name returned an empty list that was
76
+ // indistinguishable from "no such reviewer".
77
+ //
78
+ // `mode: "exact"` is not a limitation to apologise for: the field is
79
+ // encrypted and ciphertext has no usable prefix (D1). Say so in the
80
+ // placeholder instead of promising what the corpus cannot do.
81
+ search: {
82
+ placeholder: "Reviewer name (exact match)",
83
+ mode: "exact",
84
+ fields: ["userName"],
85
+ },
73
86
  emptyLabel: "No reviews found",
74
87
  filterKeys: ["status", "rating"],
75
88
  defaultSort: sortBy("createdAt", "DESC"),
@@ -42,7 +42,21 @@ export function AdminScammersView({ children, ...props }) {
42
42
  const config = {
43
43
  portal: "admin",
44
44
  title: "Scammers",
45
- searchPlaceholder: "Search by name, phone, UPI ID",
45
+ // buildScammerSearchTxt indexes all four identity arrays plus scamType.
46
+ // They are stored PLAINTEXT on purpose — a victim googles a UPI id before
47
+ // paying, so SEO is the feature — which is why this collection can have a
48
+ // partial-match box where users/reviews/payouts cannot.
49
+ //
50
+ // The old box promised the same three fields but ran `displayNames@=q`,
51
+ // i.e. array-contains on a WHOLE element: "Vikram" matched nothing
52
+ // against ["Vikram M", "Vikram Mehta"], and phone/UPI were never
53
+ // searched at all.
54
+ search: {
55
+ placeholder: "Search by name, phone, UPI ID or email…",
56
+ mode: "partial",
57
+ fields: ["displayNames", "phones", "upiIds", "emails", "scamType"],
58
+ commit: "debounce",
59
+ },
46
60
  emptyLabel: "No scammer profiles found",
47
61
  filterKeys: ["status"],
48
62
  defaultSort: sortBy("createdAt", "DESC"),
@@ -105,8 +105,29 @@ export interface ListingViewConfig<TResponse, TRow extends {
105
105
  placeholder: string;
106
106
  /** `exact` for a field resolved through an HMAC blind index. */
107
107
  mode?: "partial" | "exact";
108
- /** Fields the endpoint actually matches on. */
108
+ /**
109
+ * Fields the endpoint actually matches on.
110
+ *
111
+ * Not decoration: this is what lets the audit check that the placeholder
112
+ * does not promise a match the corpus cannot make. A box reading "search
113
+ * by buyer" over an index built from productTitle/storeName/trackingNumber
114
+ * is the same lie as a box the endpoint ignores entirely — just harder to
115
+ * notice, because it returns a plausible empty list instead of nothing.
116
+ */
109
117
  fields?: readonly string[];
118
+ /**
119
+ * When the term reaches the URL. Defaults to `"enter"`, which is the
120
+ * behaviour every existing box already had.
121
+ *
122
+ * 🛑 `"debounce"` is invalid with `mode: "exact"` and the audit blocks the
123
+ * pairing. An exact search resolves an HMAC blind index or a whole-value
124
+ * equality, so every keystroke before the last is a query GUARANTEED to
125
+ * match nothing — the user watches "no results" while typing a value that
126
+ * is in fact present, and each of those round trips is a billed read.
127
+ */
128
+ commit?: "enter" | "debounce";
129
+ /** Debounce delay in ms. Ignored unless `commit: "debounce"`. */
130
+ debounceMs?: number;
110
131
  };
111
132
  emptyLabel?: string;
112
133
  sortOptions: ListingSortOption[];
@@ -26,6 +26,13 @@ export function DataListingView({ config, }) {
26
26
  const listing = useAdminListing({
27
27
  ...config,
28
28
  initialView: effectiveInitialView,
29
+ // Mapped explicitly: `...config` cannot reach these, they live one level
30
+ // down under `search`. An exact-match search is never debounced — every
31
+ // keystroke before the last is a query guaranteed to match nothing — so
32
+ // the mode forces "enter" regardless of what the view asked for, and the
33
+ // audit blocks the pairing at author time rather than relying on this.
34
+ searchCommit: config.search?.mode === "exact" ? "enter" : config.search?.commit,
35
+ searchDebounceMs: config.search?.debounceMs,
29
36
  });
30
37
  const { view, setView, table, panel, searchInput, setSearchInput, commitSearch, filterOpen, setFilterOpen, openFilters, applyFilters, clearFilters, pendingFilters, setPendingFilters, activeFilterCount, hasActiveState, resetAll, rows, isLoading, errorMessage, currentPage, totalPages, selection, pageSize, setPageSize, } = listing;
31
38
  const selectionContext = {
@@ -20,6 +20,15 @@ export interface AdminListingConfig<TResponse, TRow extends {
20
20
  * a non-"All" filter state (e.g. hide inactive rows) on first load, while
21
21
  * "Clear filters" (which writes an explicit "") still shows everything. */
22
22
  filterDefaults?: Record<string, string>;
23
+ /**
24
+ * When a typed term reaches the URL. `"enter"` (the default) is what every
25
+ * box already did — commit on Enter or the button, never on keystroke.
26
+ *
27
+ * Passed down from `ListingViewConfig.search.commit`; see the 🛑 there for
28
+ * why `"debounce"` is invalid with `mode: "exact"`.
29
+ */
30
+ searchCommit?: "enter" | "debounce";
31
+ searchDebounceMs?: number;
23
32
  }
24
33
  export declare function useAdminListing<TResponse, TRow extends {
25
34
  id: string;
@@ -1,12 +1,12 @@
1
1
  "use client";
2
- import { useState, useCallback } from "react";
2
+ import { useState, useCallback, useEffect, useRef } from "react";
3
3
  import { useUrlTable } from "../../../react/hooks/useUrlTable";
4
4
  import { useBulkSelection } from "../../../react/hooks/useBulkSelection";
5
5
  import { usePanelUrlSync } from "../../../react/hooks/use-panel-url-sync";
6
6
  import { useDataViewMode } from "../../account/hooks/useDataViewMode";
7
7
  import { useAdminListingData } from "./useAdminListingData";
8
8
  export function useAdminListing(config) {
9
- const { filterKeys, defaultSort, pageSize: defaultPageSize = 25, queryKey, endpoint, mapRows, getTotal, buildFilters, buildExtraParams, filterDefaults = {}, } = config;
9
+ const { filterKeys, defaultSort, pageSize: defaultPageSize = 25, queryKey, endpoint, mapRows, getTotal, buildFilters, buildExtraParams, filterDefaults = {}, searchCommit = "enter", searchDebounceMs = 350, } = config;
10
10
  // Persisted, viewport-aware view-mode: below 768px defaults to "list"
11
11
  // (AdminViewCards' one-full-width-card-per-row layout) unless the caller
12
12
  // explicitly pins hideTableView (grid-only views like coupons); the user's
@@ -53,6 +53,34 @@ export function useAdminListing(config) {
53
53
  const commitSearch = useCallback(() => {
54
54
  table.set("q", searchInput.trim());
55
55
  }, [searchInput, table]);
56
+ // Debounced commit, opt-in per view.
57
+ //
58
+ // Two things here are load-bearing:
59
+ //
60
+ // 1. It compares against what is ALREADY in the URL and skips a no-op.
61
+ // `table.set` issues a router.replace, and replacing the URL with the
62
+ // value it already holds re-runs the query for nothing — on mount, on
63
+ // every unrelated re-render, and on the render caused by the previous
64
+ // commit. That is the double-navigation shape of Root Cause #13.
65
+ //
66
+ // 2. The timer is cleared on every keystroke AND on unmount, so a term is
67
+ // never committed by a view the user has already navigated away from.
68
+ const debounceRef = useRef(null);
69
+ const committed = table.get("q") ?? "";
70
+ useEffect(() => {
71
+ if (searchCommit !== "debounce")
72
+ return;
73
+ const next = searchInput.trim();
74
+ if (next === committed)
75
+ return;
76
+ if (debounceRef.current)
77
+ clearTimeout(debounceRef.current);
78
+ debounceRef.current = setTimeout(() => table.set("q", next), searchDebounceMs);
79
+ return () => {
80
+ if (debounceRef.current)
81
+ clearTimeout(debounceRef.current);
82
+ };
83
+ }, [searchInput, committed, searchCommit, searchDebounceMs, table]);
56
84
  const activeFilterCount = filterKeys.filter((k) => !!table.get(k)).length;
57
85
  const hasActiveState = !!table.get("q") || table.get("sort") !== defaultSort || activeFilterCount > 0;
58
86
  const currentFilterState = Object.fromEntries(filterKeys.map((k) => [k, table.get(k)]));
@@ -34,7 +34,13 @@ export function AdminEventsView({ children, ...props }) {
34
34
  portal: "admin",
35
35
  title: "Events",
36
36
  // buildEventSearchTxt indexes title + description + type + tags.
37
- searchPlaceholder: "Search by title, description, type or tag…",
37
+ // buildEventSearchTxt indexes title + description + type + tags.
38
+ search: {
39
+ placeholder: "Search by title, description, type or tag…",
40
+ mode: "partial",
41
+ fields: ["title", "description", "type", "tags"],
42
+ commit: "debounce",
43
+ },
38
44
  emptyLabel: "No events found",
39
45
  filterKeys: ["status", "type"],
40
46
  defaultSort: sortBy("startsAt", "DESC"),
@@ -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",
@@ -10,11 +10,11 @@ export declare const scammerSocialMediaSchema: z.ZodObject<{
10
10
  handle: z.ZodString;
11
11
  url: z.ZodOptional<z.ZodString>;
12
12
  }, "strip", z.ZodTypeAny, {
13
- platform: "other" | "whatsapp" | "youtube" | "instagram" | "facebook" | "twitter" | "linkedin" | "telegram" | "olx";
13
+ platform: "other" | "whatsapp" | "youtube" | "instagram" | "facebook" | "telegram" | "twitter" | "olx" | "linkedin";
14
14
  handle: string;
15
15
  url?: string | undefined;
16
16
  }, {
17
- platform: "other" | "whatsapp" | "youtube" | "instagram" | "facebook" | "twitter" | "linkedin" | "telegram" | "olx";
17
+ platform: "other" | "whatsapp" | "youtube" | "instagram" | "facebook" | "telegram" | "twitter" | "olx" | "linkedin";
18
18
  handle: string;
19
19
  url?: string | undefined;
20
20
  }>;
@@ -44,11 +44,11 @@ export declare const scammerFirestoreSchema: z.ZodObject<{
44
44
  handle: z.ZodString;
45
45
  url: z.ZodOptional<z.ZodString>;
46
46
  }, "strip", z.ZodTypeAny, {
47
- platform: "other" | "whatsapp" | "youtube" | "instagram" | "facebook" | "twitter" | "linkedin" | "telegram" | "olx";
47
+ platform: "other" | "whatsapp" | "youtube" | "instagram" | "facebook" | "telegram" | "twitter" | "olx" | "linkedin";
48
48
  handle: string;
49
49
  url?: string | undefined;
50
50
  }, {
51
- platform: "other" | "whatsapp" | "youtube" | "instagram" | "facebook" | "twitter" | "linkedin" | "telegram" | "olx";
51
+ platform: "other" | "whatsapp" | "youtube" | "instagram" | "facebook" | "telegram" | "twitter" | "olx" | "linkedin";
52
52
  handle: string;
53
53
  url?: string | undefined;
54
54
  }>, "many">;
@@ -93,7 +93,7 @@ export declare const scammerFirestoreSchema: z.ZodObject<{
93
93
  scamType: string;
94
94
  reportedBy: string;
95
95
  reportedByAnon: boolean;
96
- scamPlatform: "other" | "whatsapp" | "letitrip" | "instagram" | "facebook" | "twitter" | "telegram" | "olx" | "facebook_marketplace" | "in_person" | "phone_call";
96
+ scamPlatform: "other" | "whatsapp" | "letitrip" | "instagram" | "facebook" | "telegram" | "twitter" | "olx" | "facebook_marketplace" | "in_person" | "phone_call";
97
97
  evidence: string[];
98
98
  phones: string[];
99
99
  upiIds: string[];
@@ -102,7 +102,7 @@ export declare const scammerFirestoreSchema: z.ZodObject<{
102
102
  incidentCount: number;
103
103
  seoSlug: string;
104
104
  socialMedia: {
105
- platform: "other" | "whatsapp" | "youtube" | "instagram" | "facebook" | "twitter" | "linkedin" | "telegram" | "olx";
105
+ platform: "other" | "whatsapp" | "youtube" | "instagram" | "facebook" | "telegram" | "twitter" | "olx" | "linkedin";
106
106
  handle: string;
107
107
  url?: string | undefined;
108
108
  }[];
@@ -133,7 +133,7 @@ export declare const scammerFirestoreSchema: z.ZodObject<{
133
133
  scamType: string;
134
134
  reportedBy: string;
135
135
  reportedByAnon: boolean;
136
- scamPlatform: "other" | "whatsapp" | "letitrip" | "instagram" | "facebook" | "twitter" | "telegram" | "olx" | "facebook_marketplace" | "in_person" | "phone_call";
136
+ scamPlatform: "other" | "whatsapp" | "letitrip" | "instagram" | "facebook" | "telegram" | "twitter" | "olx" | "facebook_marketplace" | "in_person" | "phone_call";
137
137
  evidence: string[];
138
138
  phones: string[];
139
139
  upiIds: string[];
@@ -142,7 +142,7 @@ export declare const scammerFirestoreSchema: z.ZodObject<{
142
142
  incidentCount: number;
143
143
  seoSlug: string;
144
144
  socialMedia: {
145
- platform: "other" | "whatsapp" | "youtube" | "instagram" | "facebook" | "twitter" | "linkedin" | "telegram" | "olx";
145
+ platform: "other" | "whatsapp" | "youtube" | "instagram" | "facebook" | "telegram" | "twitter" | "olx" | "linkedin";
146
146
  handle: string;
147
147
  url?: string | undefined;
148
148
  }[];
@@ -201,7 +201,7 @@ export declare const scammerIncidentFirestoreSchema: z.ZodObject<{
201
201
  reportedBy: string;
202
202
  scammerId: string;
203
203
  reportedByAnon: boolean;
204
- scamPlatform: "other" | "whatsapp" | "letitrip" | "instagram" | "facebook" | "twitter" | "telegram" | "olx" | "facebook_marketplace" | "in_person" | "phone_call";
204
+ scamPlatform: "other" | "whatsapp" | "letitrip" | "instagram" | "facebook" | "telegram" | "twitter" | "olx" | "facebook_marketplace" | "in_person" | "phone_call";
205
205
  evidence: string[];
206
206
  verifiedBy?: string | undefined;
207
207
  amountLost?: number | undefined;
@@ -224,7 +224,7 @@ export declare const scammerIncidentFirestoreSchema: z.ZodObject<{
224
224
  reportedBy: string;
225
225
  scammerId: string;
226
226
  reportedByAnon: boolean;
227
- scamPlatform: "other" | "whatsapp" | "letitrip" | "instagram" | "facebook" | "twitter" | "telegram" | "olx" | "facebook_marketplace" | "in_person" | "phone_call";
227
+ scamPlatform: "other" | "whatsapp" | "letitrip" | "instagram" | "facebook" | "telegram" | "twitter" | "olx" | "facebook_marketplace" | "in_person" | "phone_call";
228
228
  evidence: string[];
229
229
  verifiedBy?: string | undefined;
230
230
  amountLost?: number | undefined;
@@ -50,7 +50,13 @@ export function SellerCouponsView({ onCreateClick, onEditClick, onToggle, onDele
50
50
  portal: "seller",
51
51
  title: "Coupons",
52
52
  // buildCouponSearchTxt indexes code + name + description — nothing else.
53
- searchPlaceholder: "Search by code, name or description…",
53
+ // buildCouponSearchTxt indexes exactly these three.
54
+ search: {
55
+ placeholder: "Search by code, name or description…",
56
+ mode: "partial",
57
+ fields: ["code", "name", "description"],
58
+ commit: "debounce",
59
+ },
54
60
  emptyLabel: "No coupons found — create your first coupon",
55
61
  filterKeys: ["isActive"],
56
62
  defaultSort: sortBy("createdAt", "DESC"),
@@ -24,7 +24,14 @@ export function SellerOffersView({ children, onAcceptOffer, onRejectOffer, onCou
24
24
  title: "Offers",
25
25
  // buildOfferSearchTxt indexes productTitle + productSlug + storeName — never
26
26
  // the buyer's name or email.
27
- searchPlaceholder: "Search by product or store…",
27
+ // buildOfferSearchTxt indexes productTitle + productSlug + storeName —
28
+ // never the buyer's name or email.
29
+ search: {
30
+ placeholder: "Search by product or store…",
31
+ mode: "partial",
32
+ fields: ["productTitle", "productSlug", "storeName"],
33
+ commit: "debounce",
34
+ },
28
35
  emptyLabel: "No offers received",
29
36
  filterKeys: ["status"],
30
37
  defaultSort: sortBy("createdAt", "DESC"),
@@ -297,7 +297,14 @@ export function SellerOrdersView({ orderDetailApiBase = SELLER_ENDPOINTS.ORDERS,
297
297
  // buildOrderSearchTxt indexes productTitle, storeName and trackingNumber.
298
298
  // It does NOT index the buyer's name, email or address — those are PII,
299
299
  // and the placeholder must not promise a match it will never make.
300
- searchPlaceholder: "Search by product, store or tracking number…",
300
+ // buildOrderSearchTxt indexes productTitle, storeName and trackingNumber.
301
+ // NOT the buyer's name, email or address — those are PII (D1).
302
+ search: {
303
+ placeholder: "Search by product, store or tracking number…",
304
+ mode: "partial",
305
+ fields: ["productTitle", "storeName", "trackingNumber"],
306
+ commit: "debounce",
307
+ },
301
308
  emptyLabel: "No orders yet",
302
309
  filterKeys: ["status"],
303
310
  defaultSort: DEFAULT_SORT,
@@ -3281,11 +3281,11 @@ export declare const SCHEMAS: {
3281
3281
  handle: import("zod").ZodString;
3282
3282
  url: import("zod").ZodOptional<import("zod").ZodString>;
3283
3283
  }, "strip", import("zod").ZodTypeAny, {
3284
- platform: "other" | "whatsapp" | "youtube" | "instagram" | "facebook" | "twitter" | "linkedin" | "telegram" | "olx";
3284
+ platform: "other" | "whatsapp" | "youtube" | "instagram" | "facebook" | "telegram" | "twitter" | "olx" | "linkedin";
3285
3285
  handle: string;
3286
3286
  url?: string | undefined;
3287
3287
  }, {
3288
- platform: "other" | "whatsapp" | "youtube" | "instagram" | "facebook" | "twitter" | "linkedin" | "telegram" | "olx";
3288
+ platform: "other" | "whatsapp" | "youtube" | "instagram" | "facebook" | "telegram" | "twitter" | "olx" | "linkedin";
3289
3289
  handle: string;
3290
3290
  url?: string | undefined;
3291
3291
  }>, "many">;
@@ -3330,7 +3330,7 @@ export declare const SCHEMAS: {
3330
3330
  scamType: string;
3331
3331
  reportedBy: string;
3332
3332
  reportedByAnon: boolean;
3333
- scamPlatform: "other" | "whatsapp" | "letitrip" | "instagram" | "facebook" | "twitter" | "telegram" | "olx" | "facebook_marketplace" | "in_person" | "phone_call";
3333
+ scamPlatform: "other" | "whatsapp" | "letitrip" | "instagram" | "facebook" | "telegram" | "twitter" | "olx" | "facebook_marketplace" | "in_person" | "phone_call";
3334
3334
  evidence: string[];
3335
3335
  phones: string[];
3336
3336
  upiIds: string[];
@@ -3339,7 +3339,7 @@ export declare const SCHEMAS: {
3339
3339
  incidentCount: number;
3340
3340
  seoSlug: string;
3341
3341
  socialMedia: {
3342
- platform: "other" | "whatsapp" | "youtube" | "instagram" | "facebook" | "twitter" | "linkedin" | "telegram" | "olx";
3342
+ platform: "other" | "whatsapp" | "youtube" | "instagram" | "facebook" | "telegram" | "twitter" | "olx" | "linkedin";
3343
3343
  handle: string;
3344
3344
  url?: string | undefined;
3345
3345
  }[];
@@ -3370,7 +3370,7 @@ export declare const SCHEMAS: {
3370
3370
  scamType: string;
3371
3371
  reportedBy: string;
3372
3372
  reportedByAnon: boolean;
3373
- scamPlatform: "other" | "whatsapp" | "letitrip" | "instagram" | "facebook" | "twitter" | "telegram" | "olx" | "facebook_marketplace" | "in_person" | "phone_call";
3373
+ scamPlatform: "other" | "whatsapp" | "letitrip" | "instagram" | "facebook" | "telegram" | "twitter" | "olx" | "facebook_marketplace" | "in_person" | "phone_call";
3374
3374
  evidence: string[];
3375
3375
  phones: string[];
3376
3376
  upiIds: string[];
@@ -3379,7 +3379,7 @@ export declare const SCHEMAS: {
3379
3379
  incidentCount: number;
3380
3380
  seoSlug: string;
3381
3381
  socialMedia: {
3382
- platform: "other" | "whatsapp" | "youtube" | "instagram" | "facebook" | "twitter" | "linkedin" | "telegram" | "olx";
3382
+ platform: "other" | "whatsapp" | "youtube" | "instagram" | "facebook" | "telegram" | "twitter" | "olx" | "linkedin";
3383
3383
  handle: string;
3384
3384
  url?: string | undefined;
3385
3385
  }[];
@@ -3438,7 +3438,7 @@ export declare const SCHEMAS: {
3438
3438
  reportedBy: string;
3439
3439
  scammerId: string;
3440
3440
  reportedByAnon: boolean;
3441
- scamPlatform: "other" | "whatsapp" | "letitrip" | "instagram" | "facebook" | "twitter" | "telegram" | "olx" | "facebook_marketplace" | "in_person" | "phone_call";
3441
+ scamPlatform: "other" | "whatsapp" | "letitrip" | "instagram" | "facebook" | "telegram" | "twitter" | "olx" | "facebook_marketplace" | "in_person" | "phone_call";
3442
3442
  evidence: string[];
3443
3443
  verifiedBy?: string | undefined;
3444
3444
  amountLost?: number | undefined;
@@ -3461,7 +3461,7 @@ export declare const SCHEMAS: {
3461
3461
  reportedBy: string;
3462
3462
  scammerId: string;
3463
3463
  reportedByAnon: boolean;
3464
- scamPlatform: "other" | "whatsapp" | "letitrip" | "instagram" | "facebook" | "twitter" | "telegram" | "olx" | "facebook_marketplace" | "in_person" | "phone_call";
3464
+ scamPlatform: "other" | "whatsapp" | "letitrip" | "instagram" | "facebook" | "telegram" | "twitter" | "olx" | "facebook_marketplace" | "in_person" | "phone_call";
3465
3465
  evidence: string[];
3466
3466
  verifiedBy?: string | undefined;
3467
3467
  amountLost?: number | undefined;
@@ -1,4 +1,5 @@
1
1
  import type { StoreDocument } from "../../features/stores/schemas/firestore";
2
+ import type { ScammerDocument } from "../../features/scams/schemas/firestore";
2
3
  import type { EventDocument } from "../../features/events/schemas/firestore";
3
4
  import type { BlogPostDocument } from "../../features/blog/schemas/firestore";
4
5
  import type { ReviewDocument } from "../../features/reviews/schemas/firestore";
@@ -12,3 +13,4 @@ export declare function withReviewSearchTxt<T extends Partial<ReviewDocument>>(p
12
13
  export declare function withOrderSearchTxt<T extends Partial<OrderDocument>>(p: T): T;
13
14
  export declare function withCouponSearchTxt<T extends Partial<CouponDocument>>(p: T): T;
14
15
  export declare function withOfferSearchTxt<T extends Partial<OfferDocument>>(p: T): T;
16
+ export declare function withScammerSearchTxt<T extends Partial<ScammerDocument>>(p: T): T;
@@ -1,4 +1,4 @@
1
- import { buildStoreSearchTxt, buildEventSearchTxt, buildBlogSearchTxt, buildReviewSearchTxt, buildOrderSearchTxt, buildCouponSearchTxt, buildOfferSearchTxt, } from "../../utils/search-txt-builders";
1
+ import { buildStoreSearchTxt, buildEventSearchTxt, buildBlogSearchTxt, buildReviewSearchTxt, buildOrderSearchTxt, buildCouponSearchTxt, buildOfferSearchTxt, buildScammerSearchTxt, } from "../../utils/search-txt-builders";
2
2
  export function withStoreSearchTxt(p) {
3
3
  return { ...p, searchTxt: buildStoreSearchTxt(p) };
4
4
  }
@@ -20,3 +20,6 @@ export function withCouponSearchTxt(p) {
20
20
  export function withOfferSearchTxt(p) {
21
21
  return { ...p, searchTxt: buildOfferSearchTxt(p) };
22
22
  }
23
+ export function withScammerSearchTxt(p) {
24
+ return { ...p, searchTxt: buildScammerSearchTxt(p) };
25
+ }
@@ -17,6 +17,7 @@
17
17
  * @tag consumers:seed/index.ts,seed/runner.ts
18
18
  * @tag sideEffects:none
19
19
  */
20
+ import { withScammerSearchTxt } from "./_helpers/search-txt-wrappers";
20
21
  import { SCAMMER_FIELDS } from "../constants/field-names";
21
22
  import { seedExtMedia } from "./_helpers/media";
22
23
  const NOW = new Date("2026-05-10T00:00:00.000Z");
@@ -32,7 +33,12 @@ const daysAgo = (n) => new Date(NOW.getTime() - n * 86400000);
32
33
  function entry(at, actorRole, trigger, changes, extra) {
33
34
  return { at, actorRole, trigger, changes, ...extra };
34
35
  }
35
- export const scammersSeedData = [
36
+ // Annotated HERE, not on the export, so the literal is contextually typed as
37
+ // Partial<ScammerDocument> before `.map` sees it. Without this TS infers a
38
+ // union of the individual object shapes and rejects the wrapper's generic —
39
+ // and the error points at the map call rather than at the field that actually
40
+ // diverged.
41
+ const scammerRows = [
36
42
  // ── 1. Verified — advance payment ghost ──────────────────────────────────────
37
43
  {
38
44
  id: "scammer-fake-lob-seller",
@@ -224,3 +230,6 @@ export const scammersSeedData = [
224
230
  updatedAt: daysAgo(9),
225
231
  },
226
232
  ];
233
+ // Derived through the wrapper, never per record — an inline literal is how
234
+ // five product seed files shipped their last fixture with no tokens.
235
+ export const scammersSeedData = scammerRows.map(withScammerSearchTxt);