@mohasinac/appkit 4.32.0 → 4.32.2

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 (29) hide show
  1. package/dist/_internal/server/features/bundles/data.js +13 -6
  2. package/dist/_internal/server/features/stores/data.js +6 -3
  3. package/dist/features/account/components/UserBidsView.js +6 -1
  4. package/dist/features/account/components/UserOrdersView.js +8 -1
  5. package/dist/features/account/components/UserReturnsView.js +6 -1
  6. package/dist/features/admin/components/AdminAuditLogView.js +12 -1
  7. package/dist/features/admin/components/AdminBundlesView.js +5 -1
  8. package/dist/features/admin/components/AdminFaqsView.js +10 -1
  9. package/dist/features/admin/components/AdminNotificationsView.js +7 -1
  10. package/dist/features/admin/components/AdminPrizeDrawsView.js +11 -1
  11. package/dist/features/admin/components/AdminStoresView.js +13 -1
  12. package/dist/features/admin/components/AdminSupportTicketsView.js +11 -1
  13. package/dist/features/admin/components/AdminTeamView.js +11 -1
  14. package/dist/features/admin/components/AdminTesterChecklistView.js +6 -1
  15. package/dist/features/admin/components/analytics/AdminPageViewsReportView.js +6 -1
  16. package/dist/features/auctions/actions/bid-actions.js +4 -3
  17. package/dist/features/auctions/components/AuctionDetailPageView.js +5 -3
  18. package/dist/features/blog/components/BlogIndexPageView.js +2 -3
  19. package/dist/features/categories/components/BundlesListView.js +5 -3
  20. package/dist/features/reviews/components/ReviewsIndexPageView.js +2 -3
  21. package/dist/features/seller/components/SellerBundlesView.js +5 -1
  22. package/dist/features/seller/components/SellerProductsView.js +10 -1
  23. package/dist/features/stores/actions/store-query-actions.js +5 -3
  24. package/dist/features/stores/components/StoreBundlesPageView.js +7 -3
  25. package/dist/features/stores/components/StoresIndexPageView.js +2 -3
  26. package/dist/features/stores/repository/store.repository.d.ts +3 -1
  27. package/dist/features/stores/repository/store.repository.js +6 -2
  28. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +12 -0
  29. package/package.json +1 -1
@@ -149,9 +149,11 @@ export async function resolveBundleCategorySlugs(productIds) {
149
149
  */
150
150
  export const listFeaturedBundles = cache(async (limit = 8, _opts) => {
151
151
  void _opts;
152
- const all = await categoriesRepository
153
- .listByType("bundle", { activeOnly: true, limit })
154
- .catch(() => []);
152
+ const all = await safeRead(() => categoriesRepository.listByType("bundle", { activeOnly: true, limit }), {
153
+ route: "bundles/getFeaturedBundles",
154
+ key: "categories.listByType(bundle)",
155
+ fallback: [],
156
+ });
155
157
  return all;
156
158
  });
157
159
  /**
@@ -163,8 +165,13 @@ export const listFeaturedBundles = cache(async (limit = 8, _opts) => {
163
165
  * detail pages had no related section at all before this.
164
166
  */
165
167
  export const getRelatedBundles = cache(async (bundle, limit = 8) => {
166
- const all = await categoriesRepository
167
- .listByType("bundle", { activeOnly: true, limit: limit + 1 })
168
- .catch(() => []);
168
+ const all = await safeRead(() => categoriesRepository.listByType("bundle", {
169
+ activeOnly: true,
170
+ limit: limit + 1,
171
+ }), {
172
+ route: "bundles/getRelatedBundles",
173
+ key: "categories.listByType(bundle)",
174
+ fallback: [],
175
+ });
169
176
  return all.filter((b) => b.id !== bundle.id).slice(0, limit);
170
177
  });
@@ -3,6 +3,7 @@ import { cache } from "react";
3
3
  import { storeRepository } from "../../../../repositories";
4
4
  import { STORES_PRODUCTS_PAGE_SIZE, STORES_SITEMAP_LIMIT, } from "../../../shared/features/stores/config";
5
5
  import { makeGetStoreListingsInitial } from "../shared/listing-data-factory";
6
+ import { safeRead } from "../../../../errors/safe-read";
6
7
  /** Full store document by slug — deduped per request via React.cache(). */
7
8
  export const getStoreForDetail = cache(async (slug) => {
8
9
  return (await storeRepository.findBySlug(slug)) ?? null;
@@ -13,8 +14,10 @@ export const listStoreAuctionsInitial = makeGetStoreListingsInitial("auction", S
13
14
  export const listStorePreOrdersInitial = makeGetStoreListingsInitial("pre-order", STORES_PRODUCTS_PAGE_SIZE);
14
15
  /** Stores for sitemap generation. */
15
16
  export const listSitemapStores = cache(async () => {
16
- const result = await storeRepository
17
- .listStores({ filters: "status==active", sorts: "-updatedAt", page: 1, pageSize: STORES_SITEMAP_LIMIT }, true)
18
- .catch(() => null);
17
+ /*
18
+ * The SITEMAP. A swallowed failure here publishes a sitemap missing every
19
+ * store — which is not an error to any crawler, just a smaller site.
20
+ */
21
+ const result = await safeRead(() => storeRepository.listStores({ filters: "status==active", sorts: "-updatedAt", page: 1, pageSize: STORES_SITEMAP_LIMIT }, true), { route: "sitemap/stores", key: "stores.listStores", fallback: null });
19
22
  return (result?.items ?? []).map(({ storeSlug, updatedAt }) => ({ storeSlug, updatedAt }));
20
23
  });
@@ -31,7 +31,12 @@ export function UserBidsView() {
31
31
  const config = {
32
32
  portal: "user",
33
33
  title: "My Bids",
34
- searchPlaceholder: "Search by auction title…",
34
+ search: {
35
+ placeholder: "Search by auction title…",
36
+ // `/api/user/bids` filters in memory: `(b.productTitle ?? "").includes(q)`.
37
+ // The bid document carries no other text.
38
+ fields: ["productTitle"],
39
+ },
35
40
  emptyLabel: "You haven't placed any bids yet.",
36
41
  filterKeys: ["status"],
37
42
  defaultSort: sortBy("bidDate", "DESC"),
@@ -48,7 +48,14 @@ export function UserOrdersView({ onOrderClick }) {
48
48
  const config = {
49
49
  portal: "user",
50
50
  title: "My Orders",
51
- searchPlaceholder: "Search by order id…",
51
+ search: {
52
+ placeholder: "Search by order id…",
53
+ // `/api/user/orders` filters in memory on `o.id` and nothing else — not
54
+ // the product titles the rows display, which is what a buyer would try
55
+ // first. Saying "order id" is the honest promise until the route can
56
+ // match more.
57
+ fields: ["id"],
58
+ },
52
59
  emptyLabel: "You haven't placed any orders yet.",
53
60
  filterKeys: ["status", "orderType"],
54
61
  defaultSort: sortBy("createdAt", "DESC"),
@@ -21,7 +21,12 @@ export function UserReturnsView({ onOrderClick }) {
21
21
  const config = {
22
22
  portal: "user",
23
23
  title: "Returns & Refunds",
24
- searchPlaceholder: "Search by order id…",
24
+ search: {
25
+ placeholder: "Search by order id…",
26
+ // Same route as /user/orders, scoped to returned statuses — so the same
27
+ // single in-memory match on `o.id`.
28
+ fields: ["id"],
29
+ },
25
30
  emptyLabel: "You have no return or refund requests.",
26
31
  filterKeys: [],
27
32
  defaultSort: sortBy("updatedAt", "DESC"),
@@ -50,7 +50,18 @@ export function AdminAuditLogView({ children, ...props }) {
50
50
  const config = {
51
51
  portal: "admin",
52
52
  title: "Audit Log",
53
- searchPlaceholder: "Search by actor uid",
53
+ search: {
54
+ placeholder: "Search by actor uid (exact)",
55
+ /*
56
+ * `/api/admin/audit-log` builds `actorUid==${q}` — an EQUALITY, stated in
57
+ * that route's own comment because `@=` is prefix-only against Firestore
58
+ * anyway. A partial uid matches nothing, so the box commits on Enter and
59
+ * says "exact" rather than letting someone type half a uid and watch an
60
+ * empty list.
61
+ */
62
+ mode: "exact",
63
+ fields: ["actorUid"],
64
+ },
54
65
  emptyLabel: "No audit log entries found",
55
66
  filterKeys: ["action", "actorUid"],
56
67
  defaultSort: sortBy("createdAt", "DESC"),
@@ -62,7 +62,11 @@ export function AdminBundlesView({ editHrefTemplate, newHref }) {
62
62
  const config = {
63
63
  portal: "admin",
64
64
  title: "Bundles",
65
- searchPlaceholder: "Search bundles by name or slug…",
65
+ search: {
66
+ placeholder: "Search bundles by name or slug…",
67
+ // `/api/admin/bundles` filters in memory on `b.name` and `b.slug`.
68
+ fields: ["name", "slug"],
69
+ },
66
70
  emptyLabel: BUNDLE_COPY.adminList.empty,
67
71
  filterKeys: ["isActive", "bundleStockStatus"],
68
72
  defaultSort: sortBy("name", "ASC"),
@@ -33,7 +33,16 @@ export function AdminFaqsView({ children, onBulkArchive, onBulkDelete, ...props
33
33
  const config = useMemo(() => ({
34
34
  portal: "admin",
35
35
  title: "FAQs",
36
- searchPlaceholder: "Search questions, categories, or tokens",
36
+ search: {
37
+ placeholder: "Search questions, answers, categories or tags",
38
+ /*
39
+ * `buildFaqSearchTxt` indexes the question, the category, every tag AND
40
+ * the answer body, all prefix-expanded. The old placeholder said
41
+ * "tokens" — the name of the implementation — and omitted the answer,
42
+ * which is the largest thing it searches.
43
+ */
44
+ fields: ["question", "answer", "category", "tags"],
45
+ },
37
46
  emptyLabel: "No FAQs found",
38
47
  filterKeys: ["isActive"],
39
48
  defaultSort: sortBy("priority", "ASC"),
@@ -81,7 +81,13 @@ export function AdminNotificationsView({ children, ...props }) {
81
81
  // Title search is not expressible here — `title` is not in the repository's
82
82
  // SIEVE_FIELDS and Sieve→Firestore cannot OR across two fields. The route
83
83
  // treats `q` as an exact userId lookup, so the placeholder says exactly that.
84
- searchPlaceholder: "Search by user ID",
84
+ search: {
85
+ placeholder: "Search by user ID (exact)",
86
+ // `/api/admin/notifications` builds `userId==${q}`. An equality, so a
87
+ // partial id matches nothing and the box commits on Enter.
88
+ mode: "exact",
89
+ fields: ["userId"],
90
+ },
85
91
  emptyLabel: "No notifications found",
86
92
  filterKeys: ["type", "readState"],
87
93
  defaultSort: sortBy("createdAt", "DESC"),
@@ -75,7 +75,17 @@ export function AdminPrizeDrawsView({ children, ...props }) {
75
75
  const config = {
76
76
  portal: "admin",
77
77
  title: "Prize Draws",
78
- searchPlaceholder: "Search prize draws by name or store…",
78
+ search: {
79
+ placeholder: "Search prize draws by name, brand or tag…",
80
+ /*
81
+ * 🛑 "or store" was a promise the corpus cannot keep.
82
+ * `buildProductSearchTxt` indexes title, description, brand, brandSlug,
83
+ * category names, tags, features, condition and card/grading details —
84
+ * and NO store name or id. Typing a seller's name returned an empty list
85
+ * that reads as "this seller runs no prize draws".
86
+ */
87
+ fields: ["title", "description", "brand", "categoryNames", "tags", "features"],
88
+ },
79
89
  emptyLabel: "No prize draws found",
80
90
  filterKeys: ["status"],
81
91
  defaultSort: sortBy("createdAt", "DESC"),
@@ -43,7 +43,19 @@ export function AdminStoresView({ children, ...props }) {
43
43
  const config = {
44
44
  portal: "admin",
45
45
  title: "Stores",
46
- searchPlaceholder: "Search stores, slugs, or owner names",
46
+ search: {
47
+ placeholder: "Search stores by name, description or category",
48
+ /*
49
+ * 🛑 "slugs, or owner names" was a promise nothing could keep — neither
50
+ * is in `buildStoreSearchTxt`, and the owner's name lives on another
51
+ * collection entirely.
52
+ *
53
+ * The route now searches `searchTxt` rather than `storeName_=`, so this
54
+ * is genuinely partial across all three fields; it used to be a
55
+ * STARTS-WITH match on the name alone.
56
+ */
57
+ fields: ["storeName", "storeDescription", "storeCategory"],
58
+ },
47
59
  emptyLabel: "No stores found",
48
60
  filterKeys: ["status"],
49
61
  defaultSort: sortBy("createdAt", "DESC"),
@@ -57,7 +57,17 @@ export function AdminSupportTicketsView({ children, ...props }) {
57
57
  const config = {
58
58
  portal: "admin",
59
59
  title: "Support Tickets",
60
- searchPlaceholder: "Search by subject",
60
+ search: {
61
+ placeholder: "Search by subject (starts with)",
62
+ /*
63
+ * `/api/admin/support-tickets` builds `subject@=${q}`, and `@=` is
64
+ * PREFIX-only against Firestore. `searchTxt` on this collection is built
65
+ * from the subject alone on purpose — the description and every message
66
+ * body are PII-encrypted, and indexing them in plaintext would hand back
67
+ * exactly what the encryption withholds.
68
+ */
69
+ fields: ["subject"],
70
+ },
61
71
  emptyLabel: "No support tickets found",
62
72
  filterKeys: ["status", "priority", "showResolved"],
63
73
  defaultSort: sortBy("createdAt", "DESC"),
@@ -57,7 +57,17 @@ export function AdminTeamView({ children, onBulkRemove, ...props }) {
57
57
  const config = {
58
58
  portal: "admin",
59
59
  title: "Team",
60
- searchPlaceholder: "Search by name or email",
60
+ search: {
61
+ placeholder: "Search by name or email",
62
+ /*
63
+ * Genuinely partial, and that is worth stating: `/api/admin/team`
64
+ * filters AFTER `mapDoc` has decrypted, with
65
+ * `(u.email ?? "").toLowerCase().includes(q)`. Most PII search in this
66
+ * codebase resolves an HMAC blind index and matches exactly; this one
67
+ * does not, which is why `mode` stays partial here.
68
+ */
69
+ fields: ["displayName", "email"],
70
+ },
61
71
  emptyLabel: "No employees found",
62
72
  filterKeys: ["group"],
63
73
  defaultSort: sortBy("createdAt", "DESC"),
@@ -54,7 +54,12 @@ export function AdminTesterChecklistView({ children, onBulkDelete, ...props }) {
54
54
  const config = useMemo(() => ({
55
55
  portal: "admin",
56
56
  title: "Tester Checklist",
57
- searchPlaceholder: "Search test cases, groups, or pages",
57
+ search: {
58
+ placeholder: "Search test cases, descriptions, groups or pages",
59
+ // `TesterChecklistItemRepository` builds `searchTxt` from label,
60
+ // description, groupLabel and pageLabel.
61
+ fields: ["label", "description", "groupLabel", "pageLabel"],
62
+ },
58
63
  emptyLabel: "No checklist items found",
59
64
  filterKeys: ["isActive", "bugConfirmed"],
60
65
  // Bug-confirmed (and reopened-away) cases are always isActive:false —
@@ -33,7 +33,12 @@ export function AdminPageViewsReportView() {
33
33
  const config = {
34
34
  portal: "admin",
35
35
  title: "Page Views",
36
- searchPlaceholder: "Search by entity or URL",
36
+ search: {
37
+ placeholder: "Search by entity or URL",
38
+ // `/api/admin/analytics/pageviews` filters in memory on `row.entityId`
39
+ // and `row.url`.
40
+ fields: ["entityId", "url"],
41
+ },
37
42
  emptyLabel: "No page views recorded for this range.",
38
43
  filterKeys: ["entityType"],
39
44
  defaultSort: sortBy("count", "DESC"),
@@ -28,6 +28,7 @@ import { getDefaultCurrency } from "../../../core/baseline-resolver";
28
28
  import { cartRepository } from "../../cart/repository/cart.repository";
29
29
  import { AUCTION_BUYOUT_WINDOW_MINUTES, AUCTION_BUYOUT_WINDOW_MS, AUCTION_CHECKOUT_URL, } from "../../../_internal/shared/checkout/lanes";
30
30
  import { resolveDate } from "../../../utils";
31
+ import { safeRead } from "../../../errors/safe-read";
31
32
  // --- Domain Functions ----------------------------------------------------------
32
33
  /**
33
34
  * Load the bidder's profile and refuse if they carry an active `place_bids`
@@ -110,9 +111,9 @@ export async function placeBid(userId, userEmail, input) {
110
111
  : BID_ERROR_CODES.TOO_LOW,
111
112
  });
112
113
  }
113
- const previousWinner = await bidRepository
114
- .getWinningBid(productId)
115
- .catch(() => null);
114
+ // A failure here is not "no previous winner" — it silently uncaps the
115
+ // auto-bid ceiling below, which is a money path.
116
+ const previousWinner = await safeRead(() => bidRepository.getWinningBid(productId), { route: "bid-actions/placeBid", key: "bids.getWinningBid", fallback: null });
116
117
  const prevCap = previousWinner?.data
117
118
  ? (previousWinner.data.autoMaxBid ?? previousWinner.data.bidAmount ?? baseBid)
118
119
  : baseBid;
@@ -132,9 +132,11 @@ export async function AuctionDetailPageView({ id, initialAuction, onPlaceBid, on
132
132
  : [];
133
133
  const descriptionHtml = toDescriptionHtml(p.description);
134
134
  const sublistingCategoryId = typeof p.sublistingCategoryId === "string" ? p.sublistingCategoryId : null;
135
- const relatedDocs = await productRepository
136
- .findByCategory(String(p.category ?? ""))
137
- .catch(() => []);
135
+ const relatedDocs = (await safeRead(() => productRepository.findByCategory(String(p.category ?? "")), {
136
+ route: "/auctions/[slug]",
137
+ key: "products.findByCategory",
138
+ fallback: [],
139
+ }));
138
140
  const [{ relatedItems, relatedByBrand, relatedByTags, relatedByStore }, groups, siteSettings] = await Promise.all([
139
141
  computeRelatedItems(product),
140
142
  getGroupsWithItemsForProduct(product.id),
@@ -3,6 +3,7 @@ import { blogRepository } from "../../../repositories";
3
3
  import { Container, Heading, Main, Section } from "../../../ui";
4
4
  import { AdSlot } from "../../homepage/components/AdSlot";
5
5
  import { BlogIndexListing } from "./BlogIndexListing";
6
+ import { safeRead } from "../../../errors/safe-read";
6
7
  function sp(params, key) {
7
8
  const v = params[key];
8
9
  return Array.isArray(v) ? v[0] ?? "" : v ?? "";
@@ -12,9 +13,7 @@ export async function BlogIndexPageView({ searchParams = {} }) {
12
13
  const page = Number(sp(searchParams, "page")) || 1;
13
14
  const pageSize = Number(sp(searchParams, "pageSize")) || 24;
14
15
  const category = sp(searchParams, "category");
15
- const sieveResult = await blogRepository
16
- .listPublished({ ...(category ? { category } : {}) }, { sorts: sort, page, pageSize })
17
- .catch(() => null);
16
+ const sieveResult = await safeRead(() => blogRepository.listPublished({ ...(category ? { category } : {}) }, { sorts: sort, page, pageSize }), { route: "/blog", key: "blogPosts.listPublished", fallback: null });
18
17
  const initialData = sieveResult
19
18
  ? {
20
19
  posts: sieveResult.items,
@@ -3,6 +3,7 @@ import { categoriesRepository } from "../../../repositories";
3
3
  import { Container, Heading, Main, Section, Text } from "../../../ui";
4
4
  import { AdSlot } from "../../homepage/components/AdSlot";
5
5
  import { CategoryBundlesListing } from "./CategoryBundlesListing";
6
+ import { safeRead } from "../../../errors/safe-read";
6
7
  const DEFAULT_LIMIT = 200;
7
8
  function sp(params, key) {
8
9
  const v = params[key];
@@ -17,9 +18,10 @@ function sp(params, key) {
17
18
  */
18
19
  export async function BundlesListView({ searchParams = {}, onBuyNow, }) {
19
20
  const storeId = sp(searchParams, "storeId");
20
- const all = await categoriesRepository
21
- .listByType("bundle", { activeOnly: true, limit: DEFAULT_LIMIT })
22
- .catch(() => []);
21
+ const all = await safeRead(() => categoriesRepository.listByType("bundle", {
22
+ activeOnly: true,
23
+ limit: DEFAULT_LIMIT,
24
+ }), { route: "/bundles", key: "categories.listByType(bundle)", fallback: [] });
23
25
  const bundles = storeId
24
26
  ? all.filter((c) => c.createdByStoreId === storeId)
25
27
  : all;
@@ -4,6 +4,7 @@ import { reviewRepository } from "../../../repositories";
4
4
  import { Container, Heading, Main, Section } from "../../../ui";
5
5
  import { AdSlot } from "../../homepage/components/AdSlot";
6
6
  import { ReviewsIndexListing } from "./ReviewsIndexListing";
7
+ import { safeRead } from "../../../errors/safe-read";
7
8
  function sp(params, key) {
8
9
  const v = params[key];
9
10
  return Array.isArray(v) ? v[0] ?? "" : v ?? "";
@@ -39,9 +40,7 @@ export async function ReviewsIndexPageView({ searchParams = {} }) {
39
40
  const sort = sp(searchParams, "sort") || "-createdAt";
40
41
  const page = Number(sp(searchParams, "page")) || 1;
41
42
  const filters = buildReviewFilters(searchParams);
42
- const result = await reviewRepository
43
- .listAll({ filters, sorts: sort, page, pageSize: 12 })
44
- .catch(() => null);
43
+ const result = await safeRead(() => reviewRepository.listAll({ filters, sorts: sort, page, pageSize: 12 }), { route: "/reviews", key: "reviews.listAll", fallback: null });
45
44
  const initialData = result
46
45
  ? {
47
46
  items: result.items,
@@ -41,7 +41,11 @@ export function SellerBundlesView({ editHrefTemplate, newHref }) {
41
41
  const config = {
42
42
  portal: "seller",
43
43
  title: "Bundles",
44
- searchPlaceholder: "Search bundles by name or slug…",
44
+ search: {
45
+ placeholder: "Search bundles by name or slug…",
46
+ // `/api/store/bundles` filters in memory on `b.name` and `b.slug`.
47
+ fields: ["name", "slug"],
48
+ },
45
49
  emptyLabel: "No bundles yet — create a bundle to group multiple of your products together",
46
50
  filterKeys: ["isActive", "bundleStockStatus"],
47
51
  defaultSort: sortBy("name", "ASC"),
@@ -212,7 +212,16 @@ export function SellerProductsView({ onDeleteProduct, onCreateClick, }) {
212
212
  const config = {
213
213
  portal: "seller",
214
214
  title: "Products",
215
- searchPlaceholder: "Search products by name…",
215
+ search: {
216
+ placeholder: "Search by name, description, brand or tag…",
217
+ /*
218
+ * The box understated itself: `/api/store/products` routes `q` through
219
+ * `buildProductSearchTxt`, which indexes far more than the title. A
220
+ * seller searching a brand or a tag got results and had no reason to
221
+ * expect them.
222
+ */
223
+ fields: ["title", "description", "brand", "categoryNames", "tags", "features"],
224
+ },
216
225
  emptyLabel: listingKind !== "all" ? `No ${listingKind} listings found` : "No products listed yet",
217
226
  filterKeys: FILTER_KEYS,
218
227
  defaultSort: DEFAULT_SORT,
@@ -19,13 +19,15 @@ import { PRODUCT_FIELDS } from "../../../constants/field-names";
19
19
  export async function listStores(params = {}, viewer) {
20
20
  const { filters, sorts = "-createdAt", page = 1, pageSize = 24, q } = params;
21
21
  const model = { filters, sorts, page, pageSize };
22
+ // The same fix as the admin route: `q` travels as the searchTxt term, not as
23
+ // `storeName_=`, which was a prefix match on the name alone.
22
24
  const filtersArr = [];
23
- if (q)
24
- filtersArr.push(`storeName_=${q}`);
25
25
  if (filters)
26
26
  filtersArr.push(filters);
27
27
  model.filters = filtersArr.join(",") || undefined;
28
- const result = await storeRepository.listStores(model);
28
+ const result = await storeRepository.listStores(model, true, {
29
+ search: q || undefined,
30
+ });
29
31
  return { ...result, items: filterTestDataForViewer(result.items, viewer) };
30
32
  }
31
33
  /**
@@ -2,6 +2,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { categoriesRepository } from "../../../repositories";
3
3
  import { getStoreBySlug } from "./StoreDetailLayoutView";
4
4
  import { CategoryBundlesListing } from "../../categories/components/CategoryBundlesListing";
5
+ import { safeRead } from "../../../errors/safe-read";
5
6
  /**
6
7
  * Public store → Bundles tab.
7
8
  *
@@ -16,9 +17,12 @@ export async function StoreBundlesPageView({ storeSlug, onBuyNow, }) {
16
17
  if (!storeId) {
17
18
  return null;
18
19
  }
19
- const bundles = await categoriesRepository
20
+ const bundles = await safeRead(() => categoriesRepository
20
21
  .listByType("bundle", { activeOnly: true, limit: 50 })
21
- .then((rows) => rows.filter((c) => c.createdByStoreId === storeId))
22
- .catch(() => []);
22
+ .then((rows) => rows.filter((c) => c.createdByStoreId === storeId)), {
23
+ route: "/stores/[storeSlug]/bundles",
24
+ key: "categories.listByType(bundle)",
25
+ fallback: [],
26
+ });
23
27
  return _jsx(CategoryBundlesListing, { initialBundles: bundles, onBuyNow: onBuyNow });
24
28
  }
@@ -3,6 +3,7 @@ import { storeRepository } from "../../../repositories";
3
3
  import { Container, Heading, Main, Section } from "../../../ui";
4
4
  import { AdSlot } from "../../homepage/components/AdSlot";
5
5
  import { StoresIndexListing } from "./StoresIndexListing";
6
+ import { safeRead } from "../../../errors/safe-read";
6
7
  function sp(params, key) {
7
8
  const v = params[key];
8
9
  return Array.isArray(v) ? v[0] ?? "" : v ?? "";
@@ -11,8 +12,6 @@ export async function StoresIndexPageView({ searchParams = {} }) {
11
12
  const sort = sp(searchParams, "sort") || "-createdAt";
12
13
  const page = Number(sp(searchParams, "page")) || 1;
13
14
  const pageSize = Number(sp(searchParams, "pageSize")) || 24;
14
- const result = await storeRepository
15
- .listStores({ page, pageSize, sorts: sort }, true)
16
- .catch(() => null);
15
+ const result = await safeRead(() => storeRepository.listStores({ page, pageSize, sorts: sort }, true), { route: "/stores", key: "stores.listStores", fallback: null });
17
16
  return (_jsx(Main, { children: _jsx(Section, { padding: "y-2xl", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 1, className: "mb-8", color: "primary", size: "3xl", weight: "semibold", children: "Stores" }), _jsx(AdSlot, { id: "listing-sidebar-top", className: "mb-6" }), _jsx(StoresIndexListing, { initialData: result ?? undefined }), _jsx(AdSlot, { id: "listing-sidebar-bottom", className: "mt-8" })] }) }) }));
18
17
  }
@@ -98,7 +98,9 @@ export declare class StoreRepository extends BaseRepository<StoreDocument> {
98
98
  /**
99
99
  * Admin: list all stores regardless of status.
100
100
  */
101
- listAllStores(model: SieveModel): Promise<FirebaseSieveResult<StoreDocument>>;
101
+ listAllStores(model: SieveModel, opts?: {
102
+ search?: string;
103
+ }): Promise<FirebaseSieveResult<StoreDocument>>;
102
104
  /**
103
105
  * Cloud Functions: atomically increment `stats.totalProducts` counter.
104
106
  */
@@ -230,8 +230,12 @@ export class StoreRepository extends BaseRepository {
230
230
  /**
231
231
  * Admin: list all stores regardless of status.
232
232
  */
233
- async listAllStores(model) {
234
- return this.listStores(model, false);
233
+ async listAllStores(model, opts) {
234
+ // 🛑 `opts` is forwarded. It was not, so the admin store list had no way to
235
+ // reach the `searchTxt` path `listStores` already implements — and both its
236
+ // caller and the public one fell back to `storeName_=`, a PREFIX match on
237
+ // the name alone. "Beyblade Arena" was unfindable by "arena".
238
+ return this.listStores(model, false, opts);
235
239
  }
236
240
  /**
237
241
  * Cloud Functions: atomically increment `stats.totalProducts` counter.
@@ -2077,6 +2077,18 @@ const rawTesterChecklistItems = [
2077
2077
  description: "/store/print-center is now a redirect, and it is deep-linked from the orders and products lists as ?type=order&ids=…&autoprint=1. Select two orders on /store/orders, use the print action, and confirm the Print Centre tab opens with those two orders loaded. Arriving at an EMPTY Print Centre is the failure — it looks like a normal empty state, which is why this case names the before.",
2078
2078
  href: "/store/orders",
2079
2079
  },
2080
+ {
2081
+ key: "search-box-promises-match-corpus",
2082
+ label: "Every dashboard search box matches what its placeholder promises",
2083
+ description: "BEFORE: four boxes promised a match the data could not make. /admin/prize-draws, /admin/deals and /admin/featured each read \"or seller\" while the product search index carries no store name at all, and /admin/stores read \"slugs, or owner names\" while the route ran a STARTS-WITH match on the store name alone. Each returned an empty list that reads as \"there are none\", not as \"this box cannot search that\". AFTER: type a seller's name into /admin/prize-draws — the placeholder no longer offers it. On /admin/stores type a word from the MIDDLE of a store's name (\"arena\", for Beyblade Arena) and a word from its description: both must now return it.",
2084
+ href: "/admin/stores",
2085
+ },
2086
+ {
2087
+ key: "exact-search-boxes-commit-on-enter",
2088
+ label: "The two exact-match boxes say so and do not search while you type",
2089
+ description: "/admin/audit-log matches `actorUid` and /admin/notifications matches `userId`, both by EQUALITY — a partial id matches nothing. Both placeholders now say \"(exact)\". Type half a uid: the list must not churn per keystroke, and must not show \"no results\" until you press Enter. Each intermediate query would be a billed read that cannot match.",
2090
+ href: "/admin/audit-log",
2091
+ },
2080
2092
  {
2081
2093
  key: "consolidated-tabs-command-palette",
2082
2094
  label: "The absorbed surfaces are still findable by name in search",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohasinac/appkit",
3
- "version": "4.32.0",
3
+ "version": "4.32.2",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"