@mohasinac/appkit 4.46.0 → 4.48.0

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 (175) hide show
  1. package/dist/.build-stamp.json +4 -4
  2. package/dist/_internal/server/features/checkout/expand-order-items.js +3 -0
  3. package/dist/_internal/server/features/messages/actions.d.ts +68 -0
  4. package/dist/_internal/server/features/messages/actions.js +249 -0
  5. package/dist/_internal/server/features/parts/buildable.d.ts +47 -0
  6. package/dist/_internal/server/features/parts/buildable.js +159 -0
  7. package/dist/_internal/server/features/products/list-public.d.ts +17 -0
  8. package/dist/_internal/server/features/products/list-public.js +92 -5
  9. package/dist/_internal/server/features/products/og.d.ts +7 -0
  10. package/dist/_internal/server/features/products/og.js +38 -0
  11. package/dist/_internal/server/features/site-settings/adapters.d.ts +1 -1
  12. package/dist/_internal/server/features/site-settings/adapters.js +6 -0
  13. package/dist/_internal/server/jobs/core/cleanupRtdbEvents.js +28 -0
  14. package/dist/_internal/server/jobs/core/notificationPrune.js +87 -3
  15. package/dist/_internal/server/jobs/core/priceIndexRollup.js +36 -1
  16. package/dist/_internal/shared/actions/action-registry.js +8 -0
  17. package/dist/_internal/shared/features/notifications/action-url.js +8 -0
  18. package/dist/_internal/shared/features/notifications/email-eligibility.js +3 -0
  19. package/dist/_internal/shared/features/site-settings/types.d.ts +4 -0
  20. package/dist/client.d.ts +10 -0
  21. package/dist/client.js +8 -0
  22. package/dist/constants/api-endpoints.d.ts +48 -0
  23. package/dist/constants/api-endpoints.js +30 -0
  24. package/dist/constants/notification-types.d.ts +1 -1
  25. package/dist/features/about/components/GstExplainerView.d.ts +28 -0
  26. package/dist/features/about/components/GstExplainerView.js +59 -0
  27. package/dist/features/about/components/GuidesHubView.d.ts +28 -0
  28. package/dist/features/about/components/GuidesHubView.js +58 -0
  29. package/dist/features/about/components/PublicProfileView.js +5 -0
  30. package/dist/features/about/components/SpotAFakeView.d.ts +29 -0
  31. package/dist/features/about/components/SpotAFakeView.js +49 -0
  32. package/dist/features/admin/actions/notification-actions.js +4 -0
  33. package/dist/features/admin/schemas/firestore.d.ts +13 -3
  34. package/dist/features/admin/schemas/firestore.js +8 -0
  35. package/dist/features/blog/repository/blog.repository.js +21 -7
  36. package/dist/features/categories/api/route.js +41 -1
  37. package/dist/features/categories/components/BreadcrumbTrail.d.ts +58 -13
  38. package/dist/features/categories/components/BreadcrumbTrail.js +63 -26
  39. package/dist/features/categories/components/CategoryDetailPageView.js +92 -38
  40. package/dist/features/categories/components/ContextualTrail.d.ts +62 -0
  41. package/dist/features/categories/components/ContextualTrail.js +81 -0
  42. package/dist/features/categories/components/LastCategoryBeacon.d.ts +18 -0
  43. package/dist/features/categories/components/LastCategoryBeacon.js +33 -0
  44. package/dist/features/categories/repository/categories.repository.d.ts +21 -0
  45. package/dist/features/categories/repository/categories.repository.js +36 -3
  46. package/dist/features/categories/repository/price-index.repository.d.ts +25 -0
  47. package/dist/features/email/notification-templates.js +4 -0
  48. package/dist/features/events/repository/events.repository.js +10 -3
  49. package/dist/features/faq/constants/read-caps.d.ts +29 -0
  50. package/dist/features/faq/constants/read-caps.js +29 -0
  51. package/dist/features/faq/repository/faqs.repository.d.ts +0 -2
  52. package/dist/features/faq/repository/faqs.repository.js +19 -16
  53. package/dist/features/homepage/components/AdSlot.js +10 -4
  54. package/dist/features/homepage/hooks/useActiveAd.d.ts +18 -1
  55. package/dist/features/homepage/hooks/useActiveAd.js +42 -2
  56. package/dist/features/homepage/lib/section-renderer.js +12 -2
  57. package/dist/features/layout/AppLayoutShell.d.ts +4 -1
  58. package/dist/features/layout/AppLayoutShell.js +2 -2
  59. package/dist/features/layout/TitleBarLayout.d.ts +4 -1
  60. package/dist/features/layout/TitleBarLayout.js +8 -4
  61. package/dist/features/media/AvatarUpload.js +8 -1
  62. package/dist/features/media/MediaAvatar.js +1 -1
  63. package/dist/features/media/MediaImage.js +3 -1
  64. package/dist/features/media/MediaLightbox.js +4 -1
  65. package/dist/features/media/MediaPickerModal.js +6 -1
  66. package/dist/features/media/MediaVideo.js +3 -1
  67. package/dist/features/messages/components/ConversationThreadView.d.ts +3 -0
  68. package/dist/features/messages/components/ConversationThreadView.js +75 -0
  69. package/dist/features/messages/components/ConversationsInboxView.d.ts +1 -0
  70. package/dist/features/messages/components/ConversationsInboxView.js +39 -0
  71. package/dist/features/messages/components/MessageSellerButton.d.ts +17 -0
  72. package/dist/features/messages/components/MessageSellerButton.js +33 -0
  73. package/dist/features/messages/hooks/useConversationChannel.d.ts +24 -0
  74. package/dist/features/messages/hooks/useConversationChannel.js +89 -0
  75. package/dist/features/messages/hooks/useMessages.d.ts +347 -0
  76. package/dist/features/messages/hooks/useMessages.js +113 -0
  77. package/dist/features/messages/repository/conversations.repository.d.ts +44 -0
  78. package/dist/features/messages/repository/conversations.repository.js +173 -0
  79. package/dist/features/messages/repository/messages.repository.d.ts +42 -0
  80. package/dist/features/messages/repository/messages.repository.js +142 -0
  81. package/dist/features/messages/schemas/firestore.d.ts +99 -0
  82. package/dist/features/messages/schemas/firestore.js +46 -0
  83. package/dist/features/orders/repository/orders.repository.js +21 -7
  84. package/dist/features/orders/schemas/firestore.d.ts +11 -0
  85. package/dist/features/products/components/CityCollectiblesView.d.ts +31 -0
  86. package/dist/features/products/components/CityCollectiblesView.js +82 -0
  87. package/dist/features/products/components/MarketPriceAnchor.d.ts +45 -0
  88. package/dist/features/products/components/MarketPriceAnchor.js +62 -0
  89. package/dist/features/products/components/ProductDetailPageView.js +71 -5
  90. package/dist/features/products/constants/meetup-cities.d.ts +47 -0
  91. package/dist/features/products/constants/meetup-cities.js +49 -0
  92. package/dist/features/products/repository/category-assignment.d.ts +82 -11
  93. package/dist/features/products/repository/category-assignment.js +93 -12
  94. package/dist/features/products/repository/products.repository.d.ts +12 -1
  95. package/dist/features/products/repository/products.repository.js +66 -34
  96. package/dist/features/products/schemas/firestore.d.ts +24 -2
  97. package/dist/features/products/schemas/firestore.js +13 -1
  98. package/dist/features/products/schemas/index.d.ts +2 -2
  99. package/dist/features/promotions/repository/coupons.repository.js +11 -4
  100. package/dist/features/reviews/repository/reviews.repository.js +11 -4
  101. package/dist/features/scams/repository/scammer.repository.js +21 -7
  102. package/dist/features/seller/components/CategoryInlineSelect.d.ts +17 -1
  103. package/dist/features/seller/components/CategoryInlineSelect.js +14 -3
  104. package/dist/features/seller/components/SellerProductShell.d.ts +27 -2
  105. package/dist/features/seller/components/SellerProductShell.js +32 -3
  106. package/dist/features/seller/repository/offer.repository.js +21 -6
  107. package/dist/features/seller/schemas/product-form.d.ts +3 -3
  108. package/dist/features/seller/schemas/product-form.js +1 -1
  109. package/dist/features/server-errors/repository/server-errors.repository.js +11 -0
  110. package/dist/features/server-errors/schemas/firestore.d.ts +17 -0
  111. package/dist/features/server-errors/schemas/firestore.js +2 -0
  112. package/dist/features/store-extensions/repository/store-extensions.repositories.d.ts +17 -1
  113. package/dist/features/store-extensions/repository/store-extensions.repositories.js +31 -0
  114. package/dist/features/store-extensions/schemas/firestore.d.ts +1 -1
  115. package/dist/features/store-extensions/schemas/firestore.js +4 -0
  116. package/dist/features/stores/repository/store.repository.js +11 -3
  117. package/dist/features/tester/seed-data/authored/community-support__messages.d.ts +2 -0
  118. package/dist/features/tester/seed-data/authored/community-support__messages.js +200 -0
  119. package/dist/features/tester/seed-data/authored/index.js +2 -0
  120. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +64 -1
  121. package/dist/index.d.ts +8 -2
  122. package/dist/index.js +1 -1
  123. package/dist/next/routing/route-map.d.ts +14 -0
  124. package/dist/next/routing/route-map.js +26 -0
  125. package/dist/providers/db-firebase/rtdb-paths.d.ts +8 -0
  126. package/dist/providers/db-firebase/rtdb-paths.js +8 -0
  127. package/dist/repositories/index.d.ts +1 -0
  128. package/dist/repositories/index.js +4 -0
  129. package/dist/schemas/registry.d.ts +3 -3
  130. package/dist/security/index.d.ts +1 -1
  131. package/dist/security/index.js +1 -1
  132. package/dist/security/pii-schemas.d.ts +17 -0
  133. package/dist/security/pii-schemas.js +15 -7
  134. package/dist/seed/_helpers/category-content-generated.d.ts +24 -0
  135. package/dist/seed/_helpers/category-content-generated.js +1853 -0
  136. package/dist/seed/_helpers/category-models.d.ts +1 -1
  137. package/dist/seed/_helpers/category-models.js +25 -1
  138. package/dist/seed/_helpers/category-names.d.ts +54 -0
  139. package/dist/seed/_helpers/category-names.js +118 -0
  140. package/dist/seed/_helpers/character-features.d.ts +1 -1
  141. package/dist/seed/_helpers/character-features.js +5 -4
  142. package/dist/seed/_helpers/final-sale-fixtures.js +1 -1
  143. package/dist/seed/categories-seed-data.js +55 -1
  144. package/dist/seed/grouped-listings-seed-data.js +2 -2
  145. package/dist/seed/products-art-seed-data.js +2 -1
  146. package/dist/seed/products-auctions-seed-data.js +42 -22
  147. package/dist/seed/products-classifieds-seed-data.js +21 -3
  148. package/dist/seed/products-digital-codes-seed-data.js +2 -1
  149. package/dist/seed/products-live-items-seed-data.js +2 -1
  150. package/dist/seed/products-preorders-seed-data.js +26 -11
  151. package/dist/seed/products-prize-draws-seed-data.js +8 -1
  152. package/dist/seed/products-standard-seed-data.js +26 -25
  153. package/dist/seed/products-stickers-seed-data.js +12 -11
  154. package/dist/server.d.ts +8 -0
  155. package/dist/server.js +15 -0
  156. package/dist/styles.css +113 -1
  157. package/dist/tailwind-utilities.css +1 -1
  158. package/dist/tokens/themes/sunset.js +17 -0
  159. package/dist/tokens/tokens.css +5 -0
  160. package/dist/ui/components/Breadcrumb.d.ts +8 -1
  161. package/dist/ui/components/Breadcrumb.js +2 -2
  162. package/dist/ui/components/Breadcrumb.style.css +37 -0
  163. package/dist/ui/components/Details.d.ts +11 -1
  164. package/dist/ui/components/Details.js +2 -2
  165. package/dist/ui/components/ImageMosaic.d.ts +43 -0
  166. package/dist/ui/components/ImageMosaic.js +46 -0
  167. package/dist/ui/components/ImageMosaic.style.css +69 -0
  168. package/dist/ui/components/SplitTile.d.ts +34 -0
  169. package/dist/ui/components/SplitTile.js +28 -0
  170. package/dist/ui/components/index.style.css +1 -0
  171. package/dist/ui/index.d.ts +4 -0
  172. package/dist/ui/index.js +4 -0
  173. package/dist/utils/search-txt-query.d.ts +77 -0
  174. package/dist/utils/search-txt-query.js +50 -28
  175. package/package.json +1 -1
@@ -381,6 +381,35 @@ export const AD_ENDPOINTS = {
381
381
  ACTIVE_BY_SLOT: (slotId) => `/api/ads?slot=${encodeURIComponent(slotId)}`,
382
382
  };
383
383
  // ---------------------------------------------------------------------------
384
+ // Messages — D9 chat
385
+ // ---------------------------------------------------------------------------
386
+ export const MESSAGE_ENDPOINTS = {
387
+ LIST: (opts) => {
388
+ const params = new URLSearchParams();
389
+ if (opts?.limit !== undefined)
390
+ params.set("limit", String(opts.limit));
391
+ if (opts?.cursor)
392
+ params.set("cursor", opts.cursor);
393
+ const qs = params.toString();
394
+ return `/api/messages${qs ? `?${qs}` : ""}`;
395
+ },
396
+ OPEN: "/api/messages",
397
+ OPEN_WITH_SELLER: "/api/messages/with-seller",
398
+ THREAD: (conversationId, opts) => {
399
+ const params = new URLSearchParams();
400
+ if (opts?.limit !== undefined)
401
+ params.set("limit", String(opts.limit));
402
+ if (opts?.cursor)
403
+ params.set("cursor", opts.cursor);
404
+ const qs = params.toString();
405
+ return `/api/messages/${conversationId}${qs ? `?${qs}` : ""}`;
406
+ },
407
+ READ: (conversationId) => `/api/messages/${conversationId}/read`,
408
+ BLOCK: (conversationId) => `/api/messages/${conversationId}/block`,
409
+ TOKEN: (conversationId) => `/api/messages/${conversationId}/token`,
410
+ UNREAD_COUNT: "/api/messages/unread-count",
411
+ };
412
+ // ---------------------------------------------------------------------------
384
413
  // Events
385
414
  // ---------------------------------------------------------------------------
386
415
  export const EVENT_ENDPOINTS = {
@@ -680,6 +709,7 @@ export const API_ENDPOINTS = {
680
709
  CORPORATE: CORPORATE_ENDPOINTS,
681
710
  CONTACT: CONTACT_ENDPOINTS,
682
711
  ADS: AD_ENDPOINTS,
712
+ MESSAGES: MESSAGE_ENDPOINTS,
683
713
  EVENTS: EVENT_ENDPOINTS,
684
714
  LOTTERY: LOTTERY_ENDPOINTS,
685
715
  FAQS: FAQ_ENDPOINTS,
@@ -29,7 +29,7 @@ import type { NotificationType } from "../features/admin/schemas/firestore";
29
29
  export { NOTIFICATION_TYPE_VALUES };
30
30
  export type { NotificationType };
31
31
  /** Alias kept for the two existing importers. */
32
- export declare const NOTIFICATION_TYPES: readonly ["order_placed", "order_confirmed", "order_shipped", "order_delivered", "order_cancelled", "bid_placed", "bid_outbid", "bid_won", "bid_lost", "auction_ended", "review_approved", "review_replied", "product_available", "promotion", "system", "welcome", "account_action", "support_ticket_update", "scam_report_update", "offer_received", "offer_responded", "offer_expired", "offer_counter_accepted", "refund_initiated", "prize_won", "prize_reveal_expired", "emi_installment_due_soon", "emi_installment_overdue", "payment_review", "catalogue_images_stale"];
32
+ export declare const NOTIFICATION_TYPES: readonly ["order_placed", "order_confirmed", "order_shipped", "order_delivered", "order_cancelled", "bid_placed", "bid_outbid", "bid_won", "bid_lost", "auction_ended", "review_approved", "review_replied", "product_available", "promotion", "system", "welcome", "account_action", "support_ticket_update", "scam_report_update", "offer_received", "offer_responded", "offer_expired", "offer_counter_accepted", "refund_initiated", "prize_won", "prize_reveal_expired", "emi_installment_due_soon", "emi_installment_overdue", "payment_review", "catalogue_images_stale", "message_received"];
33
33
  /**
34
34
  * `order_shipped` → `Order shipped`.
35
35
  *
@@ -0,0 +1,28 @@
1
+ export interface GstExplainerViewProps {
2
+ }
3
+ /**
4
+ * D6b — how GST works on LetItRip, for buyers and for sellers.
5
+ *
6
+ * ## 🛑 Why this page is a differentiator rather than table stakes
7
+ *
8
+ * The round-2 crawl checked seventeen Indian collectibles sites. Exactly ONE
9
+ * (`otakuisland.in`) states tax-inclusive pricing to the buyer in words — *"All
10
+ * prices include GST and there are no hidden charges."* The other sixteen say
11
+ * nothing at all, which means a buyer cannot tell whether the number on the card
12
+ * is what they will pay. Saying it plainly, with the rate bands and who bears
13
+ * what, is the cheapest trust signal available here.
14
+ *
15
+ * ## 🛑 What this page deliberately does NOT claim
16
+ *
17
+ * It does not say "your invoice carries GST today". Charging GST requires a
18
+ * registered GSTIN on `siteSettings.gst`, and `validateGstEnablement` runs a
19
+ * real checksum and refuses an invented one — so until that is entered, orders
20
+ * compute tax at 0 and the Tax row does not render. A page asserting otherwise
21
+ * would be a false statement about a compliance matter, which is the one place
22
+ * this codebase cannot afford confident prose. Everything below is written as
23
+ * "when GST applies", which is true either way.
24
+ *
25
+ * Every figure traces to the model written down in the plan's C0b/C0c and to
26
+ * `taxCodes` — nothing here is an invented rate.
27
+ */
28
+ export declare function GstExplainerView({}?: GstExplainerViewProps): Promise<import("react").JSX.Element>;
@@ -0,0 +1,59 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ROUTES } from "../../../constants/index.js";
3
+ import { PAGE_CONTAINER } from "../../../_internal/shared/styles/page.js";
4
+ import { THEMED_BG_PRIMARY, THEMED_BG_SECONDARY } from "../../../_internal/shared/styles/themed.js";
5
+ import { Alert, Caption, Div, Heading, Row, Section, Stack, Table, Tbody, Td, Text, TextLink, Th, Thead, Tr, } from "../../../ui/index.js";
6
+ const __P = {
7
+ p5: "p-[var(--appkit-space-5)]",
8
+ };
9
+ const __O = {
10
+ xAuto: "overflow-x-auto",
11
+ };
12
+ /**
13
+ * D6b — how GST works on LetItRip, for buyers and for sellers.
14
+ *
15
+ * ## 🛑 Why this page is a differentiator rather than table stakes
16
+ *
17
+ * The round-2 crawl checked seventeen Indian collectibles sites. Exactly ONE
18
+ * (`otakuisland.in`) states tax-inclusive pricing to the buyer in words — *"All
19
+ * prices include GST and there are no hidden charges."* The other sixteen say
20
+ * nothing at all, which means a buyer cannot tell whether the number on the card
21
+ * is what they will pay. Saying it plainly, with the rate bands and who bears
22
+ * what, is the cheapest trust signal available here.
23
+ *
24
+ * ## 🛑 What this page deliberately does NOT claim
25
+ *
26
+ * It does not say "your invoice carries GST today". Charging GST requires a
27
+ * registered GSTIN on `siteSettings.gst`, and `validateGstEnablement` runs a
28
+ * real checksum and refuses an invented one — so until that is entered, orders
29
+ * compute tax at 0 and the Tax row does not render. A page asserting otherwise
30
+ * would be a false statement about a compliance matter, which is the one place
31
+ * this codebase cannot afford confident prose. Everything below is written as
32
+ * "when GST applies", which is true either way.
33
+ *
34
+ * Every figure traces to the model written down in the plan's C0b/C0c and to
35
+ * `taxCodes` — nothing here is an invented rate.
36
+ */
37
+ export async function GstExplainerView({} = {}) {
38
+ const page = { container: PAGE_CONTAINER };
39
+ const themed = { bgPrimary: THEMED_BG_PRIMARY, bgSecondary: THEMED_BG_SECONDARY };
40
+ const { getTranslations } = await import("next-intl/server");
41
+ const t = await getTranslations("gstGuide");
42
+ /*
43
+ * The rate bands, which are a property of the HSN code a category carries —
44
+ * not of the seller and not of the buyer's state. Stated as bands rather than
45
+ * as one number because the catalogue genuinely spans three.
46
+ */
47
+ const RATE_ROWS = [
48
+ { band: t("band1Label"), rate: "0%", examples: t("band1Examples"), hsn: "—" },
49
+ { band: t("band2Label"), rate: "5%", examples: t("band2Examples"), hsn: "9503 0020" },
50
+ { band: t("band3Label"), rate: "18%", examples: t("band3Examples"), hsn: "9503 0010" },
51
+ ];
52
+ const WHO_PAYS = [
53
+ { line: t("whoPays1Item"), who: t("whoPays1Who"), note: t("whoPays1Note") },
54
+ { line: t("whoPays2Item"), who: t("whoPays2Who"), note: t("whoPays2Note") },
55
+ { line: t("whoPays3Item"), who: t("whoPays3Who"), note: t("whoPays3Note") },
56
+ { line: t("whoPays4Item"), who: t("whoPays4Who"), note: t("whoPays4Note") },
57
+ ];
58
+ return (_jsxs(Div, { children: [_jsx(Section, { className: themed.bgPrimary, padding: "y-2xl", children: _jsxs(Div, { className: page.container.lg, children: [_jsx(Heading, { level: 1, size: "3xl", mdSize: "4xl", weight: "bold", className: "mb-3", children: t("title") }), _jsx(Text, { size: "lg", color: "muted", className: "max-w-3xl", children: t("intro") })] }) }), _jsx(Section, { padding: "y-xl", children: _jsxs(Div, { className: page.container.lg, children: [_jsxs(Alert, { variant: "info", className: "mb-8", children: [_jsx(Text, { weight: "semibold", children: t("inclusiveTitle") }), _jsx(Text, { size: "sm", children: t("inclusiveText") })] }), _jsx(Heading, { level: 2, size: "xl", weight: "semibold", className: "mb-4", children: t("ratesTitle") }), _jsx(Text, { color: "muted", className: "mb-4 max-w-3xl", children: t("ratesIntro") }), _jsx(Div, { className: __O.xAuto, children: _jsxs(Table, { size: "sm", children: [_jsx(Thead, { children: _jsxs(Tr, { border: "default", children: [_jsx(Th, { children: t("colBand") }), _jsx(Th, { children: t("colRate") }), _jsx(Th, { children: t("colExamples") }), _jsx(Th, { children: t("colHsn") })] }) }), _jsx(Tbody, { children: RATE_ROWS.map((r) => (_jsxs(Tr, { border: "subtle", children: [_jsx(Td, { padding: "sm", children: r.band }), _jsx(Td, { padding: "sm", children: _jsx(Text, { weight: "semibold", children: r.rate }) }), _jsx(Td, { padding: "sm", color: "muted", children: r.examples }), _jsx(Td, { padding: "sm", color: "muted", children: r.hsn })] }, r.band))) })] }) }), _jsx(Caption, { className: "mt-2", children: t("ratesCaption") })] }) }), _jsx(Section, { className: themed.bgSecondary, padding: "y-xl", children: _jsxs(Div, { className: page.container.lg, children: [_jsx(Heading, { level: 2, size: "xl", weight: "semibold", className: "mb-4", children: t("whoPaysTitle") }), _jsx(Div, { className: __O.xAuto, children: _jsxs(Table, { size: "sm", children: [_jsx(Thead, { children: _jsxs(Tr, { border: "default", children: [_jsx(Th, { children: t("colLine") }), _jsx(Th, { children: t("colWho") }), _jsx(Th, { children: t("colNote") })] }) }), _jsx(Tbody, { children: WHO_PAYS.map((r) => (_jsxs(Tr, { border: "subtle", children: [_jsx(Td, { padding: "sm", children: r.line }), _jsx(Td, { padding: "sm", children: _jsx(Text, { weight: "semibold", children: r.who }) }), _jsx(Td, { padding: "sm", color: "muted", children: r.note })] }, r.line))) })] }) })] }) }), _jsx(Section, { padding: "y-xl", children: _jsxs(Div, { className: page.container.lg, children: [_jsxs(Stack, { gap: "lg", children: [_jsxs(Div, { className: __P.p5, surface: "card", rounded: "xl", border: "subtle", children: [_jsx(Heading, { level: 3, size: "lg", weight: "semibold", className: "mb-2", children: t("splitTitle") }), _jsx(Text, { color: "muted", children: t("splitText") })] }), _jsxs(Div, { className: __P.p5, surface: "card", rounded: "xl", border: "subtle", children: [_jsx(Heading, { level: 3, size: "lg", weight: "semibold", className: "mb-2", children: t("invoiceTitle") }), _jsx(Text, { color: "muted", children: t("invoiceText") })] }), _jsxs(Div, { className: __P.p5, surface: "card", rounded: "xl", border: "subtle", children: [_jsx(Heading, { level: 3, size: "lg", weight: "semibold", className: "mb-2", children: t("sellerTitle") }), _jsx(Text, { color: "muted", children: t("sellerText") })] })] }), _jsxs(Row, { gap: "md", wrap: true, className: "mt-8", children: [_jsx(TextLink, { href: String(ROUTES.PUBLIC.FEES), children: t("linkFees") }), _jsx(TextLink, { href: String(ROUTES.PUBLIC.HOW_PAYOUTS_WORK), children: t("linkPayouts") }), _jsx(TextLink, { href: String(ROUTES.PUBLIC.FAQS), children: t("linkFaqs") })] })] }) })] }));
59
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * D6 — the content hub: every explainer this site owns, in one place.
3
+ *
4
+ * ## 🛑 Why an index page is worth its own route here
5
+ *
6
+ * There are **fourteen** explainers (`/how-auctions-work` … `/fees`, `/gst`,
7
+ * `/spot-a-fake`, the two seller guides) and the only way to reach most of them
8
+ * is the footer. A footer link is reachable but it is not a *destination*: it
9
+ * cannot rank, it cannot be linked to from a blog post as "our guides", and it
10
+ * gives a crawler no page that establishes the topical cluster these fourteen
11
+ * form. worldhobbyshop's measured SEO edge is category bodies plus internal
12
+ * linking depth, and this is the cheapest internal-linking structure available
13
+ * — one page that links fourteen, each of which links back.
14
+ *
15
+ * ## 🛑 The list is hand-curated and that is deliberate
16
+ *
17
+ * It would be tidier to derive it from `FOOTER_LINK_GROUPS`, and wrong: that
18
+ * array mixes browse surfaces, legal pages and dashboards with the explainers,
19
+ * so deriving would need a filter which is itself a second hand-maintained
20
+ * list — one keyed on nothing. A guide either belongs in a learning index or it
21
+ * does not, and that is an editorial judgement, not a derivation.
22
+ *
23
+ * Every href is a `ROUTES` constant, so `audit-nav-page-wiring` fails the build
24
+ * if any of them stops resolving to a real page.
25
+ */
26
+ export interface GuidesHubViewProps {
27
+ }
28
+ export declare function GuidesHubView({}?: GuidesHubViewProps): Promise<import("react").JSX.Element>;
@@ -0,0 +1,58 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ROUTES } from "../../../constants/index.js";
3
+ import { PAGE_CONTAINER } from "../../../_internal/shared/styles/page.js";
4
+ import { THEMED_BG_PRIMARY } from "../../../_internal/shared/styles/themed.js";
5
+ import { Div, Grid, Heading, Section, Text, TextLink } from "../../../ui/index.js";
6
+ const __P = {
7
+ p5: "p-[var(--appkit-space-5)]",
8
+ };
9
+ export async function GuidesHubView({} = {}) {
10
+ const page = { container: PAGE_CONTAINER };
11
+ const { getTranslations } = await import("next-intl/server");
12
+ const t = await getTranslations("guidesHub");
13
+ const GROUPS = [
14
+ {
15
+ heading: t("groupBuyingTitle"),
16
+ blurb: t("groupBuyingBlurb"),
17
+ items: [
18
+ { href: String(ROUTES.PUBLIC.HOW_AUCTIONS_WORK), label: t("howAuctions"), text: t("howAuctionsText") },
19
+ { href: String(ROUTES.PUBLIC.HOW_OFFERS_WORK), label: t("howOffers"), text: t("howOffersText") },
20
+ { href: String(ROUTES.PUBLIC.HOW_PRE_ORDERS_WORK), label: t("howPreOrders"), text: t("howPreOrdersText") },
21
+ { href: String(ROUTES.PUBLIC.HOW_CHECKOUT_WORKS), label: t("howCheckout"), text: t("howCheckoutText") },
22
+ { href: String(ROUTES.PUBLIC.HOW_EMI_WORKS), label: t("howEmi"), text: t("howEmiText") },
23
+ { href: String(ROUTES.PUBLIC.HOW_ORDERS_WORK), label: t("howOrders"), text: t("howOrdersText") },
24
+ ],
25
+ },
26
+ {
27
+ heading: t("groupMoneyTitle"),
28
+ blurb: t("groupMoneyBlurb"),
29
+ items: [
30
+ { href: String(ROUTES.PUBLIC.FEES), label: t("fees"), text: t("feesText") },
31
+ { href: String(ROUTES.PUBLIC.GST), label: t("gst"), text: t("gstText") },
32
+ { href: String(ROUTES.PUBLIC.HOW_PAYOUTS_WORK), label: t("howPayouts"), text: t("howPayoutsText") },
33
+ { href: String(ROUTES.PUBLIC.SHIPPING_POLICY), label: t("shipping"), text: t("shippingText") },
34
+ { href: String(ROUTES.PUBLIC.REFUND_POLICY), label: t("refunds"), text: t("refundsText") },
35
+ ],
36
+ },
37
+ {
38
+ heading: t("groupTrustTitle"),
39
+ blurb: t("groupTrustBlurb"),
40
+ items: [
41
+ { href: String(ROUTES.PUBLIC.SPOT_A_FAKE), label: t("spotAFake"), text: t("spotAFakeText") },
42
+ { href: String(ROUTES.PUBLIC.SCAMS), label: t("scams"), text: t("scamsText") },
43
+ { href: String(ROUTES.PUBLIC.HOW_REVIEWS_WORK), label: t("howReviews"), text: t("howReviewsText") },
44
+ { href: String(ROUTES.PUBLIC.SECURITY), label: t("security"), text: t("securityText") },
45
+ ],
46
+ },
47
+ {
48
+ heading: t("groupSellingTitle"),
49
+ blurb: t("groupSellingBlurb"),
50
+ items: [
51
+ { href: String(ROUTES.PUBLIC.SELLER_GUIDE), label: t("sellerGuide"), text: t("sellerGuideText") },
52
+ { href: String(ROUTES.PUBLIC.SELLER_GUIDE_BUNDLES), label: t("bundleGuide"), text: t("bundleGuideText") },
53
+ { href: String(ROUTES.PUBLIC.SELLER_GUIDE_PRIZE_DRAWS), label: t("prizeDrawGuide"), text: t("prizeDrawGuideText") },
54
+ ],
55
+ },
56
+ ];
57
+ return (_jsxs(Div, { children: [_jsx(Section, { className: THEMED_BG_PRIMARY, padding: "y-2xl", children: _jsxs(Div, { className: page.container.lg, children: [_jsx(Heading, { level: 1, size: "3xl", mdSize: "4xl", weight: "bold", className: "mb-3", children: t("title") }), _jsx(Text, { size: "lg", color: "muted", className: "max-w-3xl", children: t("intro") })] }) }), GROUPS.map((g) => (_jsx(Section, { padding: "y-xl", children: _jsxs(Div, { className: page.container.lg, children: [_jsx(Heading, { level: 2, size: "xl", weight: "semibold", className: "mb-1", children: g.heading }), _jsx(Text, { color: "muted", className: "mb-4", children: g.blurb }), _jsx(Grid, { cols: "cards", gap: "md", children: g.items.map((i) => (_jsxs(Div, { className: __P.p5, surface: "card", rounded: "xl", border: "subtle", children: [_jsx(TextLink, { href: i.href, weight: "semibold", children: i.label }), _jsx(Text, { size: "sm", color: "muted", className: "mt-1", children: i.text })] }, i.href))) })] }) }, g.heading)))] }));
58
+ }
@@ -30,6 +30,11 @@ function toProductItem(p) {
30
30
  slug: p.slug,
31
31
  description: p.description,
32
32
  price: p.price,
33
+ /* The strike-through "was" price. The SHARED `toProductItem` already
34
+ * carried it and this local copy did not, so a discounted listing lost
35
+ * its saving on a public profile — the exact drift the comment below
36
+ * warns about, demonstrated once. */
37
+ originalPrice: p.originalPrice,
33
38
  currency: p.currency,
34
39
  mainImage: p.mainImage,
35
40
  images: p.images,
@@ -0,0 +1,29 @@
1
+ export interface SpotAFakeViewProps {
2
+ }
3
+ /**
4
+ * D6 — the spot-a-fake guide.
5
+ *
6
+ * ## 🛑 The framing is the finding, and it is not the obvious one
7
+ *
8
+ * The crawl's counterfeit intelligence (plan section 3.7) measured something
9
+ * counter-intuitive: the largest seller of copies in this market makes **ZERO
10
+ * authenticity claims**. It sells itself as an "aftermarket brand" at a
11
+ * fraction of the real price and leaves the inference to the buyer. So a guide
12
+ * written as "here is how they pretend to be real" would be describing a
13
+ * deception that is not happening, and would teach the wrong signals.
14
+ *
15
+ * What actually distinguishes them is silence plus price, then code, then
16
+ * weight — which is why this page is ordered that way and why the authenticity
17
+ * section explains that **"unknown" is a legitimate answer**. A guide that
18
+ * treated every non-original as a fraud would push honest second-hand sellers
19
+ * into overclaiming, which makes the catalogue less truthful, not more.
20
+ *
21
+ * ## 🛑 Every signal here is one the SITE can back up
22
+ *
23
+ * The price band comes from the realised-price index, the printed codes come
24
+ * from the canonical corpus, and the part weights come from the part glossary —
25
+ * 963 slots carrying their weight as fitted. A guide that said "check the
26
+ * weight" without somewhere to check it against would be advice shaped like
27
+ * help, so each signal links to the surface that answers it.
28
+ */
29
+ export declare function SpotAFakeView({}?: SpotAFakeViewProps): Promise<import("react").JSX.Element>;
@@ -0,0 +1,49 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ROUTES } from "../../../constants/index.js";
3
+ import { PAGE_CONTAINER } from "../../../_internal/shared/styles/page.js";
4
+ import { THEMED_BG_PRIMARY, THEMED_BG_SECONDARY } from "../../../_internal/shared/styles/themed.js";
5
+ import { Alert, Div, Heading, Row, Section, Stack, Text, TextLink, } from "../../../ui/index.js";
6
+ const __P = {
7
+ p5: "p-[var(--appkit-space-5)]",
8
+ };
9
+ /**
10
+ * D6 — the spot-a-fake guide.
11
+ *
12
+ * ## 🛑 The framing is the finding, and it is not the obvious one
13
+ *
14
+ * The crawl's counterfeit intelligence (plan section 3.7) measured something
15
+ * counter-intuitive: the largest seller of copies in this market makes **ZERO
16
+ * authenticity claims**. It sells itself as an "aftermarket brand" at a
17
+ * fraction of the real price and leaves the inference to the buyer. So a guide
18
+ * written as "here is how they pretend to be real" would be describing a
19
+ * deception that is not happening, and would teach the wrong signals.
20
+ *
21
+ * What actually distinguishes them is silence plus price, then code, then
22
+ * weight — which is why this page is ordered that way and why the authenticity
23
+ * section explains that **"unknown" is a legitimate answer**. A guide that
24
+ * treated every non-original as a fraud would push honest second-hand sellers
25
+ * into overclaiming, which makes the catalogue less truthful, not more.
26
+ *
27
+ * ## 🛑 Every signal here is one the SITE can back up
28
+ *
29
+ * The price band comes from the realised-price index, the printed codes come
30
+ * from the canonical corpus, and the part weights come from the part glossary —
31
+ * 963 slots carrying their weight as fitted. A guide that said "check the
32
+ * weight" without somewhere to check it against would be advice shaped like
33
+ * help, so each signal links to the surface that answers it.
34
+ */
35
+ export async function SpotAFakeView({} = {}) {
36
+ const page = { container: PAGE_CONTAINER };
37
+ const themed = { bgPrimary: THEMED_BG_PRIMARY, bgSecondary: THEMED_BG_SECONDARY };
38
+ const { getTranslations } = await import("next-intl/server");
39
+ const t = await getTranslations("spotAFake");
40
+ const SIGNALS = [
41
+ { n: 1, title: t("signal1Title"), text: t("signal1Text"), href: String(ROUTES.PUBLIC.TOOL_PRICE_INDEX) },
42
+ { n: 2, title: t("signal2Title"), text: t("signal2Text"), href: null },
43
+ { n: 3, title: t("signal3Title"), text: t("signal3Text"), href: String(ROUTES.PUBLIC.PARTS) },
44
+ { n: 4, title: t("signal4Title"), text: t("signal4Text"), href: null },
45
+ { n: 5, title: t("signal5Title"), text: t("signal5Text"), href: null },
46
+ { n: 6, title: t("signal6Title"), text: t("signal6Text"), href: null },
47
+ ];
48
+ return (_jsxs(Div, { children: [_jsx(Section, { className: themed.bgPrimary, padding: "y-2xl", children: _jsxs(Div, { className: page.container.lg, children: [_jsx(Heading, { level: 1, size: "3xl", mdSize: "4xl", weight: "bold", className: "mb-3", children: t("title") }), _jsx(Text, { size: "lg", color: "muted", className: "max-w-3xl", children: t("intro") })] }) }), _jsx(Section, { padding: "y-xl", children: _jsxs(Div, { className: page.container.lg, children: [_jsxs(Alert, { variant: "warning", className: "mb-8", children: [_jsx(Text, { weight: "semibold", children: t("whyTitle") }), _jsx(Text, { size: "sm", children: t("whyText") })] }), _jsx(Heading, { level: 2, size: "xl", weight: "semibold", className: "mb-4", children: t("signalsTitle") }), _jsx(Stack, { gap: "md", children: SIGNALS.map((s) => (_jsxs(Div, { className: __P.p5, surface: "card", rounded: "xl", border: "subtle", children: [_jsx(Heading, { level: 3, size: "lg", weight: "semibold", className: "mb-1", children: `${s.n}. ${s.title}` }), _jsx(Text, { color: "muted", children: s.text }), s.href && (_jsx(Div, { padding: "t-xs", children: _jsx(TextLink, { href: s.href, size: "sm", children: s.n === 1 ? t("linkTools") : t("linkParts") }) }))] }, s.n))) })] }) }), _jsx(Section, { className: themed.bgSecondary, padding: "y-xl", children: _jsx(Div, { className: page.container.lg, children: _jsxs(Stack, { gap: "lg", children: [_jsxs(Div, { className: __P.p5, surface: "card", rounded: "xl", border: "subtle", children: [_jsx(Heading, { level: 3, size: "lg", weight: "semibold", className: "mb-2", children: t("authenticityTitle") }), _jsx(Text, { color: "muted", children: t("authenticityText") })] }), _jsxs(Div, { className: __P.p5, surface: "card", rounded: "xl", border: "subtle", children: [_jsx(Heading, { level: 3, size: "lg", weight: "semibold", className: "mb-2", children: t("reportTitle") }), _jsx(Text, { color: "muted", children: t("reportText") }), _jsxs(Row, { gap: "md", wrap: true, className: "mt-3", children: [_jsx(TextLink, { href: String(ROUTES.PUBLIC.REPORT_ENTITY), children: t("linkReport") }), _jsx(TextLink, { href: String(ROUTES.PUBLIC.SCAMS), children: t("linkScams") })] })] })] }) }) })] }));
49
+ }
@@ -246,6 +246,10 @@ export async function sendNotification(input) {
246
246
  */
247
247
  support_ticket_update: "system",
248
248
  scam_report_update: "system",
249
+ // D9 — same reasoning: a tenth key would have no stored value on any
250
+ // existing user, and a chat message is a reply to a thread the user is
251
+ // already a participant in, not something to silence independently.
252
+ message_received: "system",
249
253
  };
250
254
  const typeKey = typeToPrefsKey[type];
251
255
  // If the user has explicitly disabled this notification type, skip all external channels.
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import type { AboutContentDocument } from "../../about/schemas/firestore";
6
6
  export declare const ADMIN_CHECKOUT_BYPASS_FLAG_KEY: "adminCheckoutBypass";
7
- export declare const NOTIFICATION_TYPE_VALUES: readonly ["order_placed", "order_confirmed", "order_shipped", "order_delivered", "order_cancelled", "bid_placed", "bid_outbid", "bid_won", "bid_lost", "auction_ended", "review_approved", "review_replied", "product_available", "promotion", "system", "welcome", "account_action", "support_ticket_update", "scam_report_update", "offer_received", "offer_responded", "offer_expired", "offer_counter_accepted", "refund_initiated", "prize_won", "prize_reveal_expired", "emi_installment_due_soon", "emi_installment_overdue", "payment_review", "catalogue_images_stale"];
7
+ export declare const NOTIFICATION_TYPE_VALUES: readonly ["order_placed", "order_confirmed", "order_shipped", "order_delivered", "order_cancelled", "bid_placed", "bid_outbid", "bid_won", "bid_lost", "auction_ended", "review_approved", "review_replied", "product_available", "promotion", "system", "welcome", "account_action", "support_ticket_update", "scam_report_update", "offer_received", "offer_responded", "offer_expired", "offer_counter_accepted", "refund_initiated", "prize_won", "prize_reveal_expired", "emi_installment_due_soon", "emi_installment_overdue", "payment_review", "catalogue_images_stale", "message_received"];
8
8
  export type NotificationType = (typeof NOTIFICATION_TYPE_VALUES)[number];
9
9
  /**
10
10
  * What a notification points AT. Drives the shared `actionUrl` resolver, so a
@@ -14,7 +14,7 @@ export type NotificationType = (typeof NOTIFICATION_TYPE_VALUES)[number];
14
14
  * Runtime array for the same reason as the type union above — and
15
15
  * `catalogueItem` was being sent with an `as never` cast rather than added.
16
16
  */
17
- export declare const NOTIFICATION_RELATED_TYPE_VALUES: readonly ["order", "product", "bid", "review", "blog", "user", "offer", "support_ticket", "scammer", "catalogueItem", "payout", "store"];
17
+ export declare const NOTIFICATION_RELATED_TYPE_VALUES: readonly ["order", "product", "bid", "review", "blog", "user", "offer", "support_ticket", "scammer", "catalogueItem", "payout", "store", "conversation"];
18
18
  export type NotificationRelatedType = (typeof NOTIFICATION_RELATED_TYPE_VALUES)[number];
19
19
  import type { BaseDocument } from "../../../_internal/shared/types/base-document";
20
20
  export type NotificationPriority = "low" | "normal" | "high";
@@ -77,7 +77,7 @@ export type NotificationUpdateInput = Partial<Pick<NotificationDocument, "isRead
77
77
  export declare const notificationQueryHelpers: {
78
78
  readonly byUser: (userId: string) => readonly ["userId", "==", string];
79
79
  readonly unread: (userId: string) => readonly [readonly ["userId", "==", string], readonly ["isRead", "==", false]];
80
- readonly byType: (type: NotificationType) => readonly ["type", "==", "welcome" | "promotion" | "order_placed" | "order_confirmed" | "order_shipped" | "order_delivered" | "order_cancelled" | "bid_placed" | "bid_outbid" | "bid_won" | "bid_lost" | "auction_ended" | "review_approved" | "review_replied" | "product_available" | "system" | "account_action" | "support_ticket_update" | "scam_report_update" | "offer_received" | "offer_responded" | "offer_expired" | "offer_counter_accepted" | "refund_initiated" | "prize_won" | "prize_reveal_expired" | "emi_installment_due_soon" | "emi_installment_overdue" | "payment_review" | "catalogue_images_stale"];
80
+ readonly byType: (type: NotificationType) => readonly ["type", "==", "welcome" | "promotion" | "order_placed" | "order_confirmed" | "order_shipped" | "order_delivered" | "order_cancelled" | "bid_placed" | "bid_outbid" | "bid_won" | "bid_lost" | "auction_ended" | "review_approved" | "review_replied" | "product_available" | "system" | "account_action" | "support_ticket_update" | "scam_report_update" | "offer_received" | "offer_responded" | "offer_expired" | "offer_counter_accepted" | "refund_initiated" | "prize_won" | "prize_reveal_expired" | "emi_installment_due_soon" | "emi_installment_overdue" | "payment_review" | "catalogue_images_stale" | "message_received"];
81
81
  };
82
82
  export interface TrustBarItem {
83
83
  icon: string;
@@ -515,6 +515,16 @@ export interface SiteSettingsDocument extends BaseDocument {
515
515
  sms: number;
516
516
  };
517
517
  };
518
+ /**
519
+ * D9 — chat. Off by default, and `undefined` reads as OFF (never a
520
+ * default-true `??`) — a real setting with a real cost: every conversation
521
+ * is a Firestore subcollection plus an RTDB ping channel, and the feature
522
+ * was deleted once already for shipping broken and earning low usage it
523
+ * never had a chance to prove.
524
+ */
525
+ chat?: {
526
+ enabled: boolean;
527
+ };
518
528
  emailSettings: {
519
529
  fromName: string;
520
530
  fromEmail: string;
@@ -84,6 +84,12 @@ export const NOTIFICATION_TYPE_VALUES = [
84
84
  // it stayed off this union: the cast silenced the one signal that would
85
85
  // have said so, and with it every other field's type-check on that call.
86
86
  "catalogue_images_stale",
87
+ /**
88
+ * D9 — a new chat message. Bell-only — `EMAIL_ELIGIBLE_TYPES` is `false`,
89
+ * following `support_ticket_update`'s recorded reasoning: the conversation
90
+ * lives in the UI, not the inbox.
91
+ */
92
+ "message_received",
87
93
  ];
88
94
  /**
89
95
  * What a notification points AT. Drives the shared `actionUrl` resolver, so a
@@ -106,6 +112,8 @@ export const NOTIFICATION_RELATED_TYPE_VALUES = [
106
112
  "catalogueItem",
107
113
  "payout",
108
114
  "store",
115
+ // D9 — the chat thread the message belongs to.
116
+ "conversation",
109
117
  ];
110
118
  export const NOTIFICATIONS_COLLECTION = "notifications";
111
119
  export const NOTIFICATIONS_INDEXED_FIELDS = [
@@ -5,7 +5,7 @@ import { BLOG_POST_FIELDS, BLOG_POSTS_COLLECTION, } from "../schemas/index.js";
5
5
  import { createBlogPostId } from "../schemas/index.js";
6
6
  import { increment } from "../../../contracts/field-ops.js";
7
7
  import { buildBlogSearchTxt } from "../../../utils/search-txt-builders.js";
8
- import { planSearchTxt, refineSearchTxt, emptySearchResult, } from "../../../utils/search-txt-query.js";
8
+ import { planSearchTxt, beginSearchWindow, emptySearchResult, } from "../../../utils/search-txt-query.js";
9
9
  class BlogRepository extends BaseRepository {
10
10
  constructor() {
11
11
  super(BLOG_POSTS_COLLECTION);
@@ -172,12 +172,19 @@ class BlogRepository extends BaseRepository {
172
172
  if (plan.head) {
173
173
  baseQuery = baseQuery.where(BLOG_POST_FIELDS.SEARCH_TXT, "array-contains", plan.head);
174
174
  }
175
- const result = await this.sieveQuery(model, BlogRepository.SIEVE_FIELDS, {
175
+ /*
176
+ * One bounded window, one raised `maxPageSize`, one page slice — see
177
+ * `beginSearchWindow`. Hand-writing the three is how the other ten
178
+ * repositories each ended up with two of them, which reads as a working
179
+ * multi-word search and silently cannot reach page 2.
180
+ */
181
+ const win = beginSearchWindow(model, plan);
182
+ const result = await this.sieveQuery(win.model, BlogRepository.SIEVE_FIELDS, {
176
183
  baseQuery,
177
184
  defaultPageSize: 12,
178
- maxPageSize: 50,
185
+ maxPageSize: win.maxPageSize(50),
179
186
  });
180
- return refineSearchTxt(result, plan.rest);
187
+ return win.finish(result);
181
188
  }
182
189
  /** Derived on every write path via `applyWriteHooks`. */
183
190
  buildSearchTxtFor(data) {
@@ -191,12 +198,19 @@ class BlogRepository extends BaseRepository {
191
198
  if (plan.head) {
192
199
  baseQuery = baseQuery.where(BLOG_POST_FIELDS.SEARCH_TXT, "array-contains", plan.head);
193
200
  }
194
- const result = await this.sieveQuery(model, BlogRepository.SIEVE_FIELDS, {
201
+ /*
202
+ * One bounded window, one raised `maxPageSize`, one page slice — see
203
+ * `beginSearchWindow`. Hand-writing the three is how the other ten
204
+ * repositories each ended up with two of them, which reads as a working
205
+ * multi-word search and silently cannot reach page 2.
206
+ */
207
+ const win = beginSearchWindow(model, plan);
208
+ const result = await this.sieveQuery(win.model, BlogRepository.SIEVE_FIELDS, {
195
209
  baseQuery,
196
210
  defaultPageSize: 50,
197
- maxPageSize: 200,
211
+ maxPageSize: win.maxPageSize(200),
198
212
  });
199
- return refineSearchTxt(result, plan.rest);
213
+ return win.finish(result);
200
214
  }
201
215
  }
202
216
  BlogRepository.SIEVE_FIELDS = {
@@ -18,11 +18,50 @@ import { mediaUrlSchema } from "../../../validation/schemas.js";
18
18
  import { getProviders } from "../../../contracts/index.js";
19
19
  import { createRouteHandler } from "../../../next/api/routeHandler.js";
20
20
  import { toCategoryListItem } from "../../../_internal/server/features/categories/adapters.js";
21
+ import { categoriesRepository } from "../repository/categories.repository.js";
21
22
  import { isListingCategory } from "../constants/listing-categories.js";
22
23
  import { planSearchTxt } from "../../../utils/search-txt-query.js";
23
24
  import { matchesAllSearchTerms } from "../../../utils/search-txt.js";
24
25
  import { normalizeError } from "../../../errors/normalize.js";
25
26
  const CACHE_CONTROL_PUBLIC = "public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400";
27
+ /**
28
+ * Overlay the LIVE rolled-up count onto a projected page of categories.
29
+ *
30
+ * 🛑 Why this route and not the grid component: `/categories` renders entirely
31
+ * client-side from this endpoint (its page.tsx does no Firestore read at all,
32
+ * deliberately, so the route can stay static), so this is the only server-side
33
+ * place the numbers it shows pass through. `metrics.totalProductCount` is
34
+ * written by the nightly reconciler ALONE, so every multi-category surface was
35
+ * up to a day behind the category's own page, which counts live.
36
+ *
37
+ * 🛑 An id with no counts document keeps its `metrics` value. It does NOT
38
+ * become 0 — a category whose counter has never been written would otherwise
39
+ * render a confident zero, which is the regression Root Cause #72 names and
40
+ * would make this change worse than leaving it stale.
41
+ *
42
+ * Bounded by construction: the caller has already paginated, and
43
+ * `getLiveCategoryCounts` hard-caps the batch at 200 ids in ONE `getAll`.
44
+ */
45
+ async function withLiveTotals(items) {
46
+ if (items.length === 0)
47
+ return items;
48
+ const live = await categoriesRepository.liveTotalsFor(items.map((i) => i.id));
49
+ if (live.size === 0)
50
+ return items;
51
+ return items.map((item) => {
52
+ const total = live.get(item.id);
53
+ /*
54
+ * `metrics` is optional on `CategoryItem`, and an item without it is left
55
+ * exactly as it is rather than gaining a half-built one — a metrics object
56
+ * carrying only `totalProductCount` would read as "0 products, N in
57
+ * total", which is a worse answer than the absent object the renderer
58
+ * already handles.
59
+ */
60
+ if (total === undefined || !item.metrics)
61
+ return item;
62
+ return { ...item, metrics: { ...item.metrics, totalProductCount: total } };
63
+ });
64
+ }
26
65
  function buildTreeFromFlat(items, rootId) {
27
66
  const byId = new Map(items.map((i) => [i.id, i]));
28
67
  // Roots: tier === 0 or no parentIds
@@ -251,6 +290,7 @@ export async function GET(request) {
251
290
  .sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
252
291
  .map(toCategoryListItem);
253
292
  }
293
+ items = await withLiveTotals(items);
254
294
  const totalPages = total === 0 ? 0 : Math.max(1, Math.ceil(total / perPage));
255
295
  /*
256
296
  * Shape switches on `?page=`, deliberately, and only there.
@@ -287,7 +327,7 @@ export async function GET(request) {
287
327
  // Project BEFORE nesting — `CategoryTreeNode extends CategoryItem`, so the
288
328
  // children carry the public shape too rather than being raw documents
289
329
  // reachable one level down.
290
- const allItems = allResult.data.map(toCategoryListItem);
330
+ const allItems = await withLiveTotals(allResult.data.map(toCategoryListItem));
291
331
  if (tree === "true" || !flat) {
292
332
  const treeNodes = buildTreeFromFlat(allItems, rootId);
293
333
  const res = NextResponse.json({ success: true, data: treeNodes });