@mohasinac/appkit 4.47.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 (173) 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 +39 -17
  38. package/dist/features/categories/components/BreadcrumbTrail.js +46 -50
  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/SellerProductShell.d.ts +1 -1
  103. package/dist/features/seller/components/SellerProductShell.js +2 -2
  104. package/dist/features/seller/repository/offer.repository.js +21 -6
  105. package/dist/features/seller/schemas/product-form.d.ts +3 -3
  106. package/dist/features/seller/schemas/product-form.js +1 -1
  107. package/dist/features/server-errors/repository/server-errors.repository.js +11 -0
  108. package/dist/features/server-errors/schemas/firestore.d.ts +17 -0
  109. package/dist/features/server-errors/schemas/firestore.js +2 -0
  110. package/dist/features/store-extensions/repository/store-extensions.repositories.d.ts +17 -1
  111. package/dist/features/store-extensions/repository/store-extensions.repositories.js +31 -0
  112. package/dist/features/store-extensions/schemas/firestore.d.ts +1 -1
  113. package/dist/features/store-extensions/schemas/firestore.js +4 -0
  114. package/dist/features/stores/repository/store.repository.js +11 -3
  115. package/dist/features/tester/seed-data/authored/community-support__messages.d.ts +2 -0
  116. package/dist/features/tester/seed-data/authored/community-support__messages.js +200 -0
  117. package/dist/features/tester/seed-data/authored/index.js +2 -0
  118. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +64 -1
  119. package/dist/index.d.ts +8 -2
  120. package/dist/index.js +1 -1
  121. package/dist/next/routing/route-map.d.ts +14 -0
  122. package/dist/next/routing/route-map.js +26 -0
  123. package/dist/providers/db-firebase/rtdb-paths.d.ts +8 -0
  124. package/dist/providers/db-firebase/rtdb-paths.js +8 -0
  125. package/dist/repositories/index.d.ts +1 -0
  126. package/dist/repositories/index.js +4 -0
  127. package/dist/schemas/registry.d.ts +3 -3
  128. package/dist/security/index.d.ts +1 -1
  129. package/dist/security/index.js +1 -1
  130. package/dist/security/pii-schemas.d.ts +17 -0
  131. package/dist/security/pii-schemas.js +15 -7
  132. package/dist/seed/_helpers/category-content-generated.d.ts +3 -3
  133. package/dist/seed/_helpers/category-content-generated.js +23 -7
  134. package/dist/seed/_helpers/category-models.d.ts +1 -1
  135. package/dist/seed/_helpers/category-models.js +25 -1
  136. package/dist/seed/_helpers/category-names.d.ts +54 -0
  137. package/dist/seed/_helpers/category-names.js +118 -0
  138. package/dist/seed/_helpers/character-features.d.ts +1 -1
  139. package/dist/seed/_helpers/character-features.js +5 -4
  140. package/dist/seed/_helpers/final-sale-fixtures.js +1 -1
  141. package/dist/seed/categories-seed-data.js +55 -1
  142. package/dist/seed/grouped-listings-seed-data.js +2 -2
  143. package/dist/seed/products-art-seed-data.js +2 -1
  144. package/dist/seed/products-auctions-seed-data.js +42 -22
  145. package/dist/seed/products-classifieds-seed-data.js +21 -3
  146. package/dist/seed/products-digital-codes-seed-data.js +2 -1
  147. package/dist/seed/products-live-items-seed-data.js +2 -1
  148. package/dist/seed/products-preorders-seed-data.js +26 -11
  149. package/dist/seed/products-prize-draws-seed-data.js +8 -1
  150. package/dist/seed/products-standard-seed-data.js +26 -25
  151. package/dist/seed/products-stickers-seed-data.js +12 -11
  152. package/dist/server.d.ts +8 -0
  153. package/dist/server.js +15 -0
  154. package/dist/styles.css +113 -1
  155. package/dist/tailwind-utilities.css +1 -1
  156. package/dist/tokens/themes/sunset.js +17 -0
  157. package/dist/tokens/tokens.css +5 -0
  158. package/dist/ui/components/Breadcrumb.d.ts +8 -1
  159. package/dist/ui/components/Breadcrumb.js +2 -2
  160. package/dist/ui/components/Breadcrumb.style.css +37 -0
  161. package/dist/ui/components/Details.d.ts +11 -1
  162. package/dist/ui/components/Details.js +2 -2
  163. package/dist/ui/components/ImageMosaic.d.ts +43 -0
  164. package/dist/ui/components/ImageMosaic.js +46 -0
  165. package/dist/ui/components/ImageMosaic.style.css +69 -0
  166. package/dist/ui/components/SplitTile.d.ts +34 -0
  167. package/dist/ui/components/SplitTile.js +28 -0
  168. package/dist/ui/components/index.style.css +1 -0
  169. package/dist/ui/index.d.ts +4 -0
  170. package/dist/ui/index.js +4 -0
  171. package/dist/utils/search-txt-query.d.ts +77 -0
  172. package/dist/utils/search-txt-query.js +50 -28
  173. package/package.json +1 -1
@@ -505,7 +505,7 @@ const rawTesterChecklistItems = [
505
505
  {
506
506
  key: "tester-fixtures-hidden-from-the-public",
507
507
  label: "SIGNED OUT, no product on /products has an id containing \"tester\" — the sandbox is invisible to the public",
508
- description: "Fixed 2026-09-01. BEFORE: the public products endpoint had no viewer concept, so an ANONYMOUS caller got 25 tester fixtures in its first 40 rows — stickers-beyblade-original-classic-sheet, prizedraw-beyblade-metal-closed-revealed, live-golden-retriever-puppy — and they filled the public marketplace. AFTER: open /products in a private window and scan the grid; nothing should be named \"Tester …\". Then sign in AS A TESTER and reload — the sandbox items must come back. Both halves matter: hiding them from everyone would break the tester programme, and a fixture that never reappears is the opposite bug.",
508
+ description: "Fixed 2026-09-01. BEFORE: the public products endpoint had no viewer concept, so an ANONYMOUS caller got 25 tester fixtures in its first 40 rows — sticker-beyblade-original-classic-sheet, prizedraw-beyblade-metal-closed-revealed, live-golden-retriever-puppy — and they filled the public marketplace. AFTER: open /products in a private window and scan the grid; nothing should be named \"Tester …\". Then sign in AS A TESTER and reload — the sandbox items must come back. Both halves matter: hiding them from everyone would break the tester programme, and a fixture that never reappears is the opposite bug.",
509
509
  href: "/products",
510
510
  },
511
511
  {
@@ -3519,6 +3519,69 @@ const rawTesterChecklistItems = [
3519
3519
  },
3520
3520
  ]),
3521
3521
  ...group("community-support", "Community & Support", [
3522
+ {
3523
+ /*
3524
+ * D9 chat. 🛑 Chat is OFF by default (`siteSettings.chat.enabled`, and
3525
+ * `undefined` reads as off), so case 1 asserts the off-state and case 2
3526
+ * turns it on — every case after depends on that order. Steps live in
3527
+ * ./authored/community-support__messages.ts.
3528
+ */
3529
+ pageKey: "messages",
3530
+ pageLabel: "Messages (chat)",
3531
+ href: "/messages",
3532
+ cases: [
3533
+ {
3534
+ key: "chat-off-hides-everything",
3535
+ label: "With chat disabled, no Message Seller button, no header icon, and no unread-count request — absent, not disabled",
3536
+ description: "The baseline. `siteSettings.chat.enabled` is undefined on a fresh seed and must read as OFF. A feature that half-renders when disabled is the shape the original chat died of.",
3537
+ },
3538
+ {
3539
+ key: "enable-chat-reveals-entry-points",
3540
+ label: "Turning the Chat setting on makes both entry points appear with no code change or redeploy",
3541
+ },
3542
+ {
3543
+ key: "message-seller-creates-thread",
3544
+ label: "Message Seller creates ONE conversation, and clicking it again reuses that same thread rather than making a second",
3545
+ description: "A conversation is between two USERS, not per listing — a buyer with three questions about three items gets one thread. The storeId→ownerId resolution happens server-side because a product page never knows the owner's Auth UID.",
3546
+ },
3547
+ {
3548
+ key: "send-and-recipient-sees-it",
3549
+ label: "A sent message reaches the other participant with an unread badge, and opening the thread clears it",
3550
+ roles: ["buyer", "seller"],
3551
+ },
3552
+ {
3553
+ key: "pii-is-stripped-from-the-body",
3554
+ label: "A phone number, a dotted VPA and a bare name@bank VPA are ALL redacted before the message is stored",
3555
+ description: "The rule order is the fix: specific before general. A bare `name@bank` has no TLD dot so it matches neither the email nor the phone rule, and running phone first mangles `9876543210@upi` before the UPI rule sees it.",
3556
+ },
3557
+ {
3558
+ key: "retry-does-not-double-post",
3559
+ label: "Retrying a failed send posts the SAME message once, never a duplicate",
3560
+ description: "clientMessageId is generated once at compose time, not per attempt, and is the message's document id — so a replay resolves to the existing document.",
3561
+ },
3562
+ {
3563
+ key: "soft-ban-blocks-sending",
3564
+ label: "A user soft-banned from 'Send messages' is refused with the ban's own reason",
3565
+ description: "send_messages was a live BannedAction with NO reader anywhere — an admin could set it and nothing checked it. The check now lives in the domain function so every caller is covered.",
3566
+ roles: ["admin", "buyer"],
3567
+ },
3568
+ {
3569
+ key: "block-stops-the-other-side",
3570
+ label: "Blocking a participant refuses their next message with a clear reason, and leaves existing history readable",
3571
+ roles: ["buyer", "seller"],
3572
+ },
3573
+ {
3574
+ key: "live-ping-surfaces-failure",
3575
+ label: "When the realtime channel cannot connect, a visible warning says so — the failure is never swallowed",
3576
+ description: "THE control for the bug that killed the old feature: it swallowed permission-denied into setIsConnected(false) and a literal `// ignore`, so the channel looked implemented and was inert for its whole life. The message is still delivered; only the live notification is lost.",
3577
+ },
3578
+ {
3579
+ key: "inbox-read-is-bounded",
3580
+ label: "The inbox and thread reads are both cursor-paginated and capped — open 50 threads and the read count does not scale with them",
3581
+ description: "The negative control. The absence of a .limit() is literally what turned 20 messages into ~1,600 reads the first time this existed. Visible only in the network panel, never on the rendered page.",
3582
+ },
3583
+ ],
3584
+ },
3522
3585
  {
3523
3586
  pageKey: "support-tickets",
3524
3587
  pageLabel: "Support Tickets",
package/dist/index.d.ts CHANGED
@@ -652,8 +652,8 @@ export { groupedListingsSeedData } from "./seed/index";
652
652
  export type { CouponDiscountType } from "./seed/index";
653
653
  export type { FirestoreIndexConfig } from "./seed/index";
654
654
  export type { HomepageSectionType } from "./seed/index";
655
- export type { NotificationType } from "./features/admin/schemas/firestore";
656
- export { NOTIFICATION_TYPE_VALUES } from "./features/admin/schemas/firestore";
655
+ export type { NotificationType, NotificationRelatedType } from "./features/admin/schemas/firestore";
656
+ export { NOTIFICATION_TYPE_VALUES, NOTIFICATION_RELATED_TYPE_VALUES } from "./features/admin/schemas/firestore";
657
657
  export type { PayoutStatus } from "./seed/index";
658
658
  export type { SeedAddressDocument } from "./seed/index";
659
659
  export type { SeedBaseOrderDocument } from "./seed/index";
@@ -3228,3 +3228,9 @@ export { resolveReturnAddress, RTO_ENTRY_CUSTODY, rtoCostBearer, RETURN_ABUSE_SC
3228
3228
  export type { ReturnAddressResolution, ResolveReturnAddressInput, ReturnAbuseSignal, ReturnAbuseCounters, RtoCostBearer, } from "./_internal/shared/features/returns/destination";
3229
3229
  export { COURIER_RATING_MIN_CONFIDENT_SAMPLES, courierRatingDisplay, isValidCourierVote, courierVoteId, rollUpCourierVotes, } from "./_internal/shared/features/shipping/courier-rating";
3230
3230
  export type { CourierRatingPair, CourierRatingDisplay, CourierRatingVote, CourierStats, } from "./_internal/shared/features/shipping/courier-rating";
3231
+ /**
3232
+ * D9 — chat. Plain data types, safe for client components that render a
3233
+ * conversation/message prop — the write path (actions) stays server-only, see
3234
+ * server.ts.
3235
+ */
3236
+ export type { ConversationDocument, ConversationStatus, ConversationLastMessage, ParticipantRole, MessageDocument, MessageProductRef, } from "./features/messages/schemas/firestore";
package/dist/index.js CHANGED
@@ -1364,7 +1364,7 @@ export { historySeedData } from "./seed/index.js";
1364
1364
  // SB-UNI-B â€" sublistingCategoriesSeedData merged into categoriesSeedData with categoryType:"sublisting".
1365
1365
  // groupedListingsSeedData - Model for grouped listings seed data.
1366
1366
  export { groupedListingsSeedData } from "./seed/index.js";
1367
- export { NOTIFICATION_TYPE_VALUES } from "./features/admin/schemas/firestore.js";
1367
+ export { NOTIFICATION_TYPE_VALUES, NOTIFICATION_RELATED_TYPE_VALUES } from "./features/admin/schemas/firestore.js";
1368
1368
  // [UTIL]-Pure utility â€" no framework or runtime dependency; safe to import from any environment.
1369
1369
  // ErrorCategory - Shared export for error category.
1370
1370
  export { ErrorCategory } from "./monitoring/index.js";
@@ -86,6 +86,10 @@ export declare const DEFAULT_ROUTE_MAP: {
86
86
  readonly HOW_REVIEWS_WORK: "/how-reviews-work";
87
87
  readonly HOW_PAYOUTS_WORK: "/how-payouts-work";
88
88
  readonly FEES: "/fees";
89
+ readonly GST: "/gst";
90
+ readonly SPOT_A_FAKE: "/spot-a-fake";
91
+ readonly GUIDES: "/guides";
92
+ readonly CITY_COLLECTIBLES: (city: string) => string;
89
93
  readonly EVENTS: "/events";
90
94
  readonly EVENT_DETAIL: (id: string) => string;
91
95
  readonly EVENT_PARTICIPATE: (id: string) => string;
@@ -175,6 +179,9 @@ export declare const DEFAULT_ROUTE_MAP: {
175
179
  readonly CATALOGUE: "/user/catalogue";
176
180
  readonly CATALOGUE_NEW: "/user/catalogue/new";
177
181
  readonly CATALOGUE_EDIT: (id: string) => string;
182
+ /** D9 — one universal inbox, same real-top-level-path precedent as WISHLIST/CART. */
183
+ readonly MESSAGES: "/messages";
184
+ readonly MESSAGE_THREAD: (conversationId: string) => string;
178
185
  };
179
186
  readonly STORE: {
180
187
  readonly DASHBOARD: "/store";
@@ -493,6 +500,10 @@ export declare const ROUTES: {
493
500
  readonly HOW_REVIEWS_WORK: "/how-reviews-work";
494
501
  readonly HOW_PAYOUTS_WORK: "/how-payouts-work";
495
502
  readonly FEES: "/fees";
503
+ readonly GST: "/gst";
504
+ readonly SPOT_A_FAKE: "/spot-a-fake";
505
+ readonly GUIDES: "/guides";
506
+ readonly CITY_COLLECTIBLES: (city: string) => string;
496
507
  readonly EVENTS: "/events";
497
508
  readonly EVENT_DETAIL: (id: string) => string;
498
509
  readonly EVENT_PARTICIPATE: (id: string) => string;
@@ -582,6 +593,9 @@ export declare const ROUTES: {
582
593
  readonly CATALOGUE: "/user/catalogue";
583
594
  readonly CATALOGUE_NEW: "/user/catalogue/new";
584
595
  readonly CATALOGUE_EDIT: (id: string) => string;
596
+ /** D9 — one universal inbox, same real-top-level-path precedent as WISHLIST/CART. */
597
+ readonly MESSAGES: "/messages";
598
+ readonly MESSAGE_THREAD: (conversationId: string) => string;
585
599
  };
586
600
  readonly STORE: {
587
601
  readonly DASHBOARD: "/store";
@@ -79,6 +79,29 @@ export const DEFAULT_ROUTE_MAP = {
79
79
  HOW_REVIEWS_WORK: "/how-reviews-work",
80
80
  HOW_PAYOUTS_WORK: "/how-payouts-work",
81
81
  FEES: "/fees",
82
+ /*
83
+ * D6b — the GST explainer. A differentiator rather than table stakes:
84
+ * exactly ONE of the seventeen crawled Indian collectibles sites states
85
+ * tax-inclusive pricing to the buyer in words, and sixteen say nothing.
86
+ */
87
+ GST: "/gst",
88
+ /*
89
+ * D6 — the anti-counterfeit guide. Framed on what the crawl actually
90
+ * measured: the biggest copy-seller in this market makes no authenticity
91
+ * claim at all, so the signals are silence + price, then code, then weight.
92
+ */
93
+ SPOT_A_FAKE: "/spot-a-fake",
94
+ /* D6 — the content hub. Fourteen explainers whose only entry point was the
95
+ * footer; a footer link is reachable but it is not a destination, so it
96
+ * cannot rank and gives a crawler no page establishing the cluster. */
97
+ GUIDES: "/guides",
98
+ /*
99
+ * D5 — local SEO, one page per city a classified listing actually names.
100
+ * 🛑 STATIC (`generateStaticParams`): a city page that awaited
101
+ * `searchParams` would be dynamic, and this is the surface most likely to
102
+ * be crawled widely (Root Cause #94).
103
+ */
104
+ CITY_COLLECTIBLES: (city) => `/collectibles/${city}`,
82
105
  EVENTS: "/events",
83
106
  EVENT_DETAIL: (id) => `/events/${id}`,
84
107
  EVENT_PARTICIPATE: (id) => `/events/${id}/participate`,
@@ -197,6 +220,9 @@ export const DEFAULT_ROUTE_MAP = {
197
220
  CATALOGUE: "/user/catalogue",
198
221
  CATALOGUE_NEW: "/user/catalogue/new",
199
222
  CATALOGUE_EDIT: (id) => `/user/catalogue/${id}/edit`,
223
+ /** D9 — one universal inbox, same real-top-level-path precedent as WISHLIST/CART. */
224
+ MESSAGES: "/messages",
225
+ MESSAGE_THREAD: (conversationId) => `/messages/${conversationId}`,
200
226
  },
201
227
  STORE: {
202
228
  DASHBOARD: "/store",
@@ -26,4 +26,12 @@ export declare const RTDB_PATHS: {
26
26
  * `cleanupRtdbEvents` alongside the other three.
27
27
  */
28
28
  readonly EMAIL_EVENTS: "email_events";
29
+ /**
30
+ * D9 — new-message ping per conversation. RTDB-ping-plus-REST: the actual
31
+ * message content never crosses this channel, only "something changed,
32
+ * refetch" — `firestore.rules` forbids client Firestore access entirely, so
33
+ * this plus the bounded REST route is the only option, not a cost choice.
34
+ * Pruned by `cleanupRtdbEvents`'s sixth block.
35
+ */
36
+ readonly CHAT_EVENTS: "chat_events";
29
37
  };
@@ -26,4 +26,12 @@ export const RTDB_PATHS = {
26
26
  * `cleanupRtdbEvents` alongside the other three.
27
27
  */
28
28
  EMAIL_EVENTS: "email_events",
29
+ /**
30
+ * D9 — new-message ping per conversation. RTDB-ping-plus-REST: the actual
31
+ * message content never crosses this channel, only "something changed,
32
+ * refetch" — `firestore.rules` forbids client Firestore access entirely, so
33
+ * this plus the bounded REST route is the only option, not a cost choice.
34
+ * Pruned by `cleanupRtdbEvents`'s sixth block.
35
+ */
36
+ CHAT_EVENTS: "chat_events",
29
37
  };
@@ -64,3 +64,4 @@ export { MediaAssetsRepository, mediaAssetsRepository, } from "../features/media
64
64
  export type { MediaAssetDocument, MediaAssetCreateInput, } from "../features/media/repository/media-assets.repository";
65
65
  export { PageViewsRepository, pageViewsRepository, PAGE_VIEW_ENTITY_TYPES, } from "../features/analytics/repository/page-views.repository";
66
66
  export type { PageViewDocument, PageViewEntityType, RecordPageViewInput, } from "../features/analytics/repository/page-views.repository";
67
+ export { ConversationsRepository, conversationsRepository, } from "../features/messages/repository/conversations.repository";
@@ -77,3 +77,7 @@ export { LotteryEntryRepository, lotteryEntryRepository, } from "../features/lot
77
77
  // Media assets — short ID → storagePath mapping for SEO-friendly /media/{id} URLs
78
78
  export { MediaAssetsRepository, mediaAssetsRepository, } from "../features/media/repository/media-assets.repository.js";
79
79
  export { PageViewsRepository, pageViewsRepository, PAGE_VIEW_ENTITY_TYPES, } from "../features/analytics/repository/page-views.repository.js";
80
+ // D9 — chat. Messages themselves are a subcollection (functional module, see
81
+ // features/messages/repository/messages.repository.ts and server.ts), not a
82
+ // BaseRepository class — there is no subcollection form of BaseRepository.
83
+ export { ConversationsRepository, conversationsRepository, } from "../features/messages/repository/conversations.repository.js";
@@ -4660,7 +4660,7 @@ export declare const SCHEMAS: {
4660
4660
  youtubeId: import("zod").ZodOptional<import("zod").ZodString>;
4661
4661
  externalVideoUrl: import("zod").ZodOptional<import("zod").ZodString>;
4662
4662
  price: import("zod").ZodOptional<import("zod").ZodNumber>;
4663
- compareAtPrice: import("zod").ZodOptional<import("zod").ZodNumber>;
4663
+ originalPrice: import("zod").ZodOptional<import("zod").ZodNumber>;
4664
4664
  stockQuantity: import("zod").ZodOptional<import("zod").ZodNumber>;
4665
4665
  featured: import("zod").ZodOptional<import("zod").ZodBoolean>;
4666
4666
  isPromoted: import("zod").ZodOptional<import("zod").ZodBoolean>;
@@ -4750,7 +4750,7 @@ export declare const SCHEMAS: {
4750
4750
  youtubeId: import("zod").ZodOptional<import("zod").ZodString>;
4751
4751
  externalVideoUrl: import("zod").ZodOptional<import("zod").ZodString>;
4752
4752
  price: import("zod").ZodOptional<import("zod").ZodNumber>;
4753
- compareAtPrice: import("zod").ZodOptional<import("zod").ZodNumber>;
4753
+ originalPrice: import("zod").ZodOptional<import("zod").ZodNumber>;
4754
4754
  stockQuantity: import("zod").ZodOptional<import("zod").ZodNumber>;
4755
4755
  featured: import("zod").ZodOptional<import("zod").ZodBoolean>;
4756
4756
  isPromoted: import("zod").ZodOptional<import("zod").ZodBoolean>;
@@ -4840,7 +4840,7 @@ export declare const SCHEMAS: {
4840
4840
  youtubeId: import("zod").ZodOptional<import("zod").ZodString>;
4841
4841
  externalVideoUrl: import("zod").ZodOptional<import("zod").ZodString>;
4842
4842
  price: import("zod").ZodOptional<import("zod").ZodNumber>;
4843
- compareAtPrice: import("zod").ZodOptional<import("zod").ZodNumber>;
4843
+ originalPrice: import("zod").ZodOptional<import("zod").ZodNumber>;
4844
4844
  stockQuantity: import("zod").ZodOptional<import("zod").ZodNumber>;
4845
4845
  featured: import("zod").ZodOptional<import("zod").ZodBoolean>;
4846
4846
  isPromoted: import("zod").ZodOptional<import("zod").ZodBoolean>;
@@ -6,5 +6,5 @@ export type { UserRole } from "./authorization";
6
6
  export { redactPii, safeDisplayName, safeDisplayEmail, maskIp } from "./pii-redact";
7
7
  export { encryptValue, decryptValue, hmacBlindIndex, encryptPiiFields, decryptPiiFields, encryptPii, decryptPii, piiBlindIndex, piiIndicesFor, getPiiConfigError, encryptPayoutDetails, decryptPayoutDetails, encryptShippingConfig, decryptShippingConfig, encryptPayoutBankAccount, decryptPayoutBankAccount, } from "./pii-encrypt";
8
8
  export { isPiiEncrypted, maskName, maskEmail, maskPublicReview, maskPublicBid, maskPublicEventEntry, maskOfferForSeller, ENC_PREFIX, HMAC_PREFIX, } from "./pii-mask";
9
- export { USER_PII_FIELDS, USER_PII_INDEX_MAP, ADDRESS_PII_FIELDS, ORDER_PII_FIELDS, PAYOUT_PII_FIELDS, PAYOUT_PII_INDEX_MAP, BID_PII_FIELDS, TOKEN_PII_FIELDS, TOKEN_PII_INDEX_MAP, REVIEW_PII_FIELDS, REVIEW_PII_INDEX_MAP, OFFER_PII_FIELDS, EVENT_ENTRY_PII_FIELDS, LOTTERY_ENTRY_PII_FIELDS, PAYMENT_METHOD_PII_FIELDS, } from "./pii-schemas";
9
+ export { USER_PII_FIELDS, USER_PII_INDEX_MAP, ADDRESS_PII_FIELDS, ORDER_PII_FIELDS, PAYOUT_PII_FIELDS, PAYOUT_PII_INDEX_MAP, BID_PII_FIELDS, TOKEN_PII_FIELDS, TOKEN_PII_INDEX_MAP, REVIEW_PII_FIELDS, REVIEW_PII_INDEX_MAP, OFFER_PII_FIELDS, EVENT_ENTRY_PII_FIELDS, LOTTERY_ENTRY_PII_FIELDS, PAYMENT_METHOD_PII_FIELDS, CONVERSATION_PII_FIELDS, MESSAGE_PII_FIELDS, } from "./pii-schemas";
10
10
  export { encryptSecret, decryptSecret, isSecretEncrypted, maskSecret, } from "./settings-encryption";
@@ -7,7 +7,7 @@ export { encryptValue, decryptValue, hmacBlindIndex, encryptPiiFields, decryptPi
7
7
  // pii-encrypt.ts) so this barrel stays safe to re-export from the
8
8
  // universal client+server index.ts. See pii-mask.ts's header comment.
9
9
  export { isPiiEncrypted, maskName, maskEmail, maskPublicReview, maskPublicBid, maskPublicEventEntry, maskOfferForSeller, ENC_PREFIX, HMAC_PREFIX, } from "./pii-mask.js";
10
- export { USER_PII_FIELDS, USER_PII_INDEX_MAP, ADDRESS_PII_FIELDS, ORDER_PII_FIELDS, PAYOUT_PII_FIELDS, PAYOUT_PII_INDEX_MAP, BID_PII_FIELDS, TOKEN_PII_FIELDS, TOKEN_PII_INDEX_MAP, REVIEW_PII_FIELDS, REVIEW_PII_INDEX_MAP, OFFER_PII_FIELDS, EVENT_ENTRY_PII_FIELDS, LOTTERY_ENTRY_PII_FIELDS, PAYMENT_METHOD_PII_FIELDS, } from "./pii-schemas.js";
10
+ export { USER_PII_FIELDS, USER_PII_INDEX_MAP, ADDRESS_PII_FIELDS, ORDER_PII_FIELDS, PAYOUT_PII_FIELDS, PAYOUT_PII_INDEX_MAP, BID_PII_FIELDS, TOKEN_PII_FIELDS, TOKEN_PII_INDEX_MAP, REVIEW_PII_FIELDS, REVIEW_PII_INDEX_MAP, OFFER_PII_FIELDS, EVENT_ENTRY_PII_FIELDS, LOTTERY_ENTRY_PII_FIELDS, PAYMENT_METHOD_PII_FIELDS, CONVERSATION_PII_FIELDS, MESSAGE_PII_FIELDS, } from "./pii-schemas.js";
11
11
  export { encryptSecret, decryptSecret, isSecretEncrypted, maskSecret, } from "./settings-encryption.js";
12
12
  // RBAC system — DELETED 2026-08-29.
13
13
  //
@@ -56,6 +56,23 @@ export declare const BID_PII_FIELDS: readonly ["userEmail"];
56
56
  export declare const TOKEN_PII_FIELDS: readonly ["email"];
57
57
  /** Blind-index mapping for token email queries */
58
58
  export declare const TOKEN_PII_INDEX_MAP: Record<string, string>;
59
+ /**
60
+ * D9 — chat. Re-created against the SUBCOLLECTION redesign, not the old
61
+ * embedded-array one the comment below used to describe.
62
+ *
63
+ * `messages/{id}` is a true Firestore subcollection now (one document per
64
+ * message, not `conversations.messages[]`), so there is no array to walk and
65
+ * the old `messages[].body` `[]`-syntax path does not apply here — `body` is a
66
+ * plain top-level field on each message document. Only `lastMessage.body`, a
67
+ * nested object on the PARENT `conversations` document, needs the dotted-path
68
+ * form `encryptPiiFields` already supports.
69
+ *
70
+ * No blind index on either: nothing queries chat by body content, and a
71
+ * `searchTxt` over an encrypted body would hand back only what the encryption
72
+ * withheld — chat search, if it exists, indexes participant names only.
73
+ */
74
+ export declare const CONVERSATION_PII_FIELDS: readonly ["lastMessage.body"];
75
+ export declare const MESSAGE_PII_FIELDS: readonly ["body"];
59
76
  /** PII fields in reviews */
60
77
  export declare const REVIEW_PII_FIELDS: readonly ["userName"];
61
78
  /** Blind-index mapping for queryable review PII */
@@ -85,15 +85,23 @@ export const TOKEN_PII_FIELDS = ["email"];
85
85
  export const TOKEN_PII_INDEX_MAP = {
86
86
  email: "emailIndex",
87
87
  };
88
- /*
89
- * `CONVERSATION_PII_FIELDS` (`lastMessage`, `messages[].body`) was removed with
90
- * the conversations feature on 2026-08-31.
88
+ /**
89
+ * D9 — chat. Re-created against the SUBCOLLECTION redesign, not the old
90
+ * embedded-array one the comment below used to describe.
91
+ *
92
+ * `messages/{id}` is a true Firestore subcollection now (one document per
93
+ * message, not `conversations.messages[]`), so there is no array to walk and
94
+ * the old `messages[].body` `[]`-syntax path does not apply here — `body` is a
95
+ * plain top-level field on each message document. Only `lastMessage.body`, a
96
+ * nested object on the PARENT `conversations` document, needs the dotted-path
97
+ * form `encryptPiiFields` already supports.
91
98
  *
92
- * 🛑 Its array-path form was the ONLY user of `encryptPiiFields`' `[]` syntax
93
- * outside status history. If a future schema needs to encrypt a field inside an
94
- * array, check that machinery still works before relying on it — a path that no
95
- * caller exercises is a path nothing proves.
99
+ * No blind index on either: nothing queries chat by body content, and a
100
+ * `searchTxt` over an encrypted body would hand back only what the encryption
101
+ * withheld — chat search, if it exists, indexes participant names only.
96
102
  */
103
+ export const CONVERSATION_PII_FIELDS = ["lastMessage.body"];
104
+ export const MESSAGE_PII_FIELDS = ["body"];
97
105
  /** PII fields in reviews */
98
106
  export const REVIEW_PII_FIELDS = ["userName"];
99
107
  /** Blind-index mapping for queryable review PII */
@@ -17,8 +17,8 @@
17
17
  * move; a sentence saying "48 listings" is wrong the next day and nothing
18
18
  * reports it.
19
19
  *
20
- * Model leaves with a body: 213 of 216
21
- * Part leaves with a body: 532 of 752
20
+ * Model leaves with a body: 217 of 220
21
+ * Part leaves with a body: 534 of 752
22
22
  */
23
23
  export declare const GENERATED_CATEGORY_CONTENT: Record<string, string>;
24
- export declare const GENERATED_CATEGORY_CONTENT_COUNT = 745;
24
+ export declare const GENERATED_CATEGORY_CONTENT_COUNT = 751;
@@ -17,12 +17,12 @@
17
17
  * move; a sentence saying "48 listings" is wrong the next day and nothing
18
18
  * reports it.
19
19
  *
20
- * Model leaves with a body: 213 of 216
21
- * Part leaves with a body: 532 of 752
20
+ * Model leaves with a body: 217 of 220
21
+ * Part leaves with a body: 534 of 752
22
22
  */
23
23
  export const GENERATED_CATEGORY_CONTENT = {
24
24
  "category-0": `<p><strong>0</strong> is a Disc.</p>
25
- <p>Shipped with Cho-Z Spriggan 0Wall Zeta, Spriggan Requiem 0 Zeta.</p>`,
25
+ <p>Shipped with Spryzen S2 0Q, Cho-Z Spriggan 0Wall Zeta, Spriggan Requiem 0 Zeta.</p>`,
26
26
  "category-0-80": `<p><strong>0-80</strong> is a Ratchet.</p>
27
27
  <p>Shipped with GhostCircle 0-80GB.</p>`,
28
28
  "category-00": `<p><strong>00</strong> is a Disc.</p>
@@ -419,12 +419,17 @@ export const GENERATED_CATEGORY_CONTENT = {
419
419
  <p>Shipped with Evil Gemios DF145FS, Gemios DF145FS, Libra DF145BS, Night Virgo DF145BS, Thunder Libra DF145BS.</p>`,
420
420
  "category-df145-down-force-145": `<p><strong>DF145 Down Force 145</strong> is a Spin Track.</p>
421
421
  <p>Shipped with Dark Wolf DF145FS.</p>`,
422
+ "category-diablo-nemesis-x-d": `<p>Diablo Nemesis X:D is a balance-type Beyblade, right-spin, built on the 4D System.</p>
423
+ <p>Product code <strong>BB-122</strong>.</p>
424
+ <p>Released as part of Beyblade: Metal Fury.</p>`,
422
425
  "category-disk-ball": `<p><strong>Disk Ball</strong> is a Bit.</p>
423
426
  <p>Shipped with WizardRod 5-70DB.</p>`,
424
427
  "category-dj-spiker": `<p><strong>DJ Spiker</strong> is an Attack Ring.</p>
425
428
  <p>Shipped with Wyvern DJ.</p>`,
426
429
  "category-double-horn": `<p><strong>Double Horn</strong> is an Attack Ring.</p>
427
430
  <p>Shipped with Rock Bison.</p>`,
431
+ "category-double-wing": `<p><strong>Double Wing</strong> is an Attack Ring.</p>
432
+ <p>Shipped with Dranzer S.</p>`,
428
433
  "category-draciel": `<p><strong>Draciel</strong> is a Bit Chip.</p>
429
434
  <p>Shipped with Draciel F, Draciel G, Draciel V, Draciel V2.</p>`,
430
435
  "category-draciel-emblem": `<p><strong>Draciel Emblem</strong> is a Bit Chip.</p>
@@ -512,7 +517,7 @@ export const GENERATED_CATEGORY_CONTENT = {
512
517
  <p>Sold outside Japan as <strong>Sword Dran 3-60F</strong> — the same mould under a different marque.</p>
513
518
  <p>Succeeded by DranDagger 4-60R in the same lineage.</p>`,
514
519
  "category-dranzer": `<p><strong>Dranzer</strong> is a Bit Chip.</p>
515
- <p>Shipped with Dranzer F, Dranzer G, Dranzer GT, Dranzer V, Dranzer V2.</p>`,
520
+ <p>Shipped with Dranzer F, Dranzer G, Dranzer GT, Dranzer S, Dranzer V, Dranzer V2.</p>`,
516
521
  "category-dranzer-emblem": `<p><strong>Dranzer Emblem</strong> is a Bit Chip.</p>
517
522
  <p>Shipped with Dranzer MF, Dranzer MS.</p>`,
518
523
  "category-dranzer-f": `<p>Dranzer F is a balance-type Beyblade, right-spin, built on the Spin Gear System.</p>
@@ -538,6 +543,9 @@ export const GENERATED_CATEGORY_CONTENT = {
538
543
  "category-dranzer-ms": `<p>Dranzer MS is a balance-type Beyblade, dual-spin, built on the Hard Metal System.</p>
539
544
  <p>Product code <strong>A-131</strong> by Takara.</p>
540
545
  <p>Released as part of Beyblade: G-Revolution / manga Volume 13, Chapter 1.</p>`,
546
+ "category-dranzer-s": `<p>Dranzer S is a balance-type Beyblade, right-spin, built on the Spin Gear System.</p>
547
+ <p>Product code <strong>A-2</strong> by Takara.</p>
548
+ <p>Released as part of Bakuten Shoot Beyblade.</p>`,
541
549
  "category-dranzer-v": `<p>Dranzer V is a balance-type Beyblade, right-spin, built on the Magnacore System.</p>
542
550
  <p>Product code <strong>A-47</strong> by Takara.</p>
543
551
  <p>Released as part of Beyblade: V-Force.</p>
@@ -613,7 +621,7 @@ export const GENERATED_CATEGORY_CONTENT = {
613
621
  "category-eight-attacker": `<p><strong>Eight Attacker</strong> is an Attack Ring.</p>
614
622
  <p>Shipped with Dragoon V.</p>`,
615
623
  "category-eight-balance": `<p><strong>Eight Balance</strong> is a Weight Disk.</p>
616
- <p>Shipped with Shadow Driger, Draciel F, Driger F, Griffolyon, Metal Draciel, Trypio, and 2 more.</p>`,
624
+ <p>Shipped with Shadow Driger, Draciel F, Dranzer S, Driger F, Griffolyon, Metal Draciel, and 3 more.</p>`,
617
625
  "category-eight-heavy": `<p><strong>Eight Heavy</strong> is a Weight Disk.</p>
618
626
  <p>Shipped with Dranzer F, Galeon, Galman.</p>`,
619
627
  "category-eight-spike": `<p><strong>Eight Spike</strong> is an Attack Ring.</p>
@@ -1293,7 +1301,7 @@ export const GENERATED_CATEGORY_CONTENT = {
1293
1301
  "category-right-mg-south-core": `<p><strong>Right MG South Core</strong> is a Spin Gear.</p>
1294
1302
  <p>Shipped with Flash Leopard 2.</p>`,
1295
1303
  "category-right-sg": `<p><strong>Right SG</strong> is a Spin Gear.</p>
1296
- <p>Shipped with Shadow Driger, Draciel F, Dranzer F, Galeon, Galman, Galzzly, and 6 more.</p>`,
1304
+ <p>Shipped with Shadow Driger, Draciel F, Dranzer F, Dranzer S, Galeon, Galman, and 7 more.</p>`,
1297
1305
  "category-right-sg-bearing": `<p><strong>Right SG</strong> is a Spin Gear.</p>
1298
1306
  <p>Shipped with Wolborg, Wolborg 2.</p>`,
1299
1307
  "category-right-sg-full-auto": `<p><strong>Right SG</strong> is a Spin Gear.</p>
@@ -1469,6 +1477,8 @@ export const GENERATED_CATEGORY_CONTENT = {
1469
1477
  <p>Shipped with Wyborg.</p>`,
1470
1478
  "category-spike-dragon": `<p><strong>Spike Dragon</strong> is an Attack Ring.</p>
1471
1479
  <p>Shipped with Dragoon V2.</p>`,
1480
+ "category-spiral-change-base": `<p><strong>Spiral Change Base</strong> is a Blade Base.</p>
1481
+ <p>Shipped with Dranzer S.</p>`,
1472
1482
  "category-spiral-upper": `<p><strong>Spiral Upper</strong> is an Attack Ring.</p>
1473
1483
  <p>Shipped with Dranzer MS.</p>`,
1474
1484
  "category-spriggan-requiem-0-zeta": `<p>Spriggan Requiem 0 Zeta is a balance-type Beyblade, dual-spin, built on the God Layer System.</p>
@@ -1478,6 +1488,9 @@ export const GENERATED_CATEGORY_CONTENT = {
1478
1488
  <p>Shipped with Einstein MS.</p>`,
1479
1489
  "category-spryzen-requiem-s3": `<p><strong>Spryzen Requiem S3</strong> is a Layer.</p>
1480
1490
  <p>Shipped with Spriggan Requiem 0 Zeta.</p>`,
1491
+ "category-spryzen-s2-0q": `<p>Spryzen S2 0Q is a balance-type Beyblade, dual-spin, built on the Burst System.</p>
1492
+ <p>Product code <strong>E1048</strong> by Hasbro.</p>
1493
+ <p>Released as part of Beyblade Burst.</p>`,
1481
1494
  "category-sr200": `<p><strong>SR200</strong> is a Spin Track.</p>
1482
1495
  <p>Shipped with Berserker Begirados SR200BWD.</p>`,
1483
1496
  "category-sr200-slide-ride-200": `<p><strong>SR200 SLIDE-RIDE 200</strong> is a Spin Track.</p>
@@ -1748,6 +1761,9 @@ export const GENERATED_CATEGORY_CONTENT = {
1748
1761
  <p>Shipped with Dranzer G.</p>`,
1749
1762
  "category-wing-upper": `<p><strong>Wing Upper</strong> is an Attack Ring.</p>
1750
1763
  <p>Shipped with Thunder Pegasus, Torch Pegasus.</p>`,
1764
+ "category-wizard-fafnir-ratchet-rise": `<p>Wizard Fafnir Ratchet Rise is a stamina-type Beyblade, left-spin, built on the Gatinko Layer System.</p>
1765
+ <p>Product code <strong>B-139</strong> by Takara Tomy.</p>
1766
+ <p>Released as part of Beyblade Burst Turbo / Cho-Z late / Burst Rise transition.</p>`,
1751
1767
  "category-wizardarrow": `<p><strong>WizardArrow</strong> is a Blade.</p>
1752
1768
  <p>Shipped with WizardArrow 4-80B.</p>`,
1753
1769
  "category-wizardarrow-4-80b": `<p>WizardArrow 4-80B is a stamina-type Beyblade, right-spin.</p>
@@ -1834,4 +1850,4 @@ export const GENERATED_CATEGORY_CONTENT = {
1834
1850
  <p>Released as part of Beyblade Burst Turbo.</p>
1835
1851
  <p>Sold outside Japan as <strong>Turbo Luinor L4 Drake Spiral</strong> — the same mould under a different marque.</p>`,
1836
1852
  };
1837
- export const GENERATED_CATEGORY_CONTENT_COUNT = 745;
1853
+ export const GENERATED_CATEGORY_CONTENT_COUNT = 751;
@@ -2,4 +2,4 @@ import type { CategoryTreeNode } from "./category-tree";
2
2
  /** Named-model leaves, keyed by the tier-3 line they belong to. */
3
3
  export declare const MODEL_LEAVES: Record<string, CategoryTreeNode[]>;
4
4
  /** Total named-model leaves — asserted by the seed so a silent drop is loud. */
5
- export declare const MODEL_LEAF_COUNT = 216;
5
+ export declare const MODEL_LEAF_COUNT = 220;
@@ -142,6 +142,12 @@ export const MODEL_LEAVES = {
142
142
  description: "Luinor L2 — an Attack-type, left-spin Burst Classic release, product code E1056. Release note: Lost Lúinor L2 Nine Spiral. Complete tops, loose parts, sticker sheets and pre-owned examples of this model are listed here.",
143
143
  extra: { aliases: ["E1056", "Lost Lúinor L2", "Lost Longinus"], productDefaults: { defaultFeatures: ["feature-blader-lui-shirosagi", "feature-type-attack", "feature-left-spin"] } },
144
144
  },
145
+ {
146
+ id: "category-spryzen-s2-0q",
147
+ name: "Spryzen S2 0Q",
148
+ description: "Spryzen S2 0Q — a Balance-type, dual-spin Burst Classic release, product code E1048, released by Hasbro. Complete tops, loose parts, sticker sheets and pre-owned examples of this model are listed here.",
149
+ extra: { aliases: ["E1048", "Spryzen S2 0Q Layer: Spryzen S2", "Disc: 0", "Driver: Q — later evolves to Spryzen Requiem S3 0Cross"], productDefaults: { defaultFeatures: ["feature-blader-shu-kurenai", "feature-type-balance", "feature-dual-spin"] } },
150
+ },
145
151
  {
146
152
  id: "category-storm-spriggan-7glaive",
147
153
  name: "Storm Spriggan 7Glaive",
@@ -228,6 +234,12 @@ export const MODEL_LEAVES = {
228
234
  description: "Vanish Fafnir Tapered Kick-3 — a Stamina-type, left-spin Dynamite Battle release, product code B-180-class, released by Takara Tomy, ~2021. Complete tops, loose parts, sticker sheets and pre-owned examples of this model are listed here.",
229
235
  extra: { aliases: ["B-180-class"], productDefaults: { defaultFeatures: ["feature-blader-fumiya-kindo", "feature-type-stamina", "feature-left-spin"] } },
230
236
  },
237
+ {
238
+ id: "category-wizard-fafnir-ratchet-rise",
239
+ name: "Wizard Fafnir Ratchet Rise",
240
+ description: "Wizard Fafnir Ratchet Rise — a Stamina-type, left-spin Dynamite Battle release, product code B-139, released by Takara Tomy, S3-S4 transitional. Complete tops, loose parts, sticker sheets and pre-owned examples of this model are listed here.",
241
+ extra: { aliases: ["B-139"], productDefaults: { defaultFeatures: ["feature-blader-fubuki-sumiye", "feature-type-stamina", "feature-left-spin"] } },
242
+ },
231
243
  ],
232
244
  "category-burst-god": [
233
245
  {
@@ -386,6 +398,12 @@ export const MODEL_LEAVES = {
386
398
  description: "Cosmic Pegasis F:D — an Attack-type, right-spin Metal Fury (4D) release, product code BB-128, released by Takara Tomy. Complete tops, loose parts, sticker sheets and pre-owned examples of this model are listed here.",
387
399
  extra: { aliases: ["BB-128"], productDefaults: { defaultFeatures: ["feature-blader-gingka-hagane", "feature-type-attack", "feature-right-spin"] } },
388
400
  },
401
+ {
402
+ id: "category-diablo-nemesis-x-d",
403
+ name: "Diablo Nemesis X:D",
404
+ description: "Diablo Nemesis X:D — a Balance-type, right-spin Metal Fury (4D) release, product code BB-122. Complete tops, loose parts, sticker sheets and pre-owned examples of this model are listed here.",
405
+ extra: { aliases: ["BB-122"], productDefaults: { defaultFeatures: ["feature-blader-rago", "feature-type-balance", "feature-right-spin"] } },
406
+ },
389
407
  {
390
408
  id: "category-fang-leone-130w2d",
391
409
  name: "Fang Leone 130W2D",
@@ -1146,6 +1164,12 @@ export const MODEL_LEAVES = {
1146
1164
  description: "Dranzer F — a Balance-type, right-spin Spin Gear System release, product code A-27, released by Takara Tomy. Complete tops, loose parts, sticker sheets and pre-owned examples of this model are listed here.",
1147
1165
  extra: { aliases: ["A-27", "Dranzer Flame"], productDefaults: { defaultFeatures: ["feature-blader-kai-hiwatari", "feature-type-balance", "feature-right-spin"] } },
1148
1166
  },
1167
+ {
1168
+ id: "category-dranzer-s",
1169
+ name: "Dranzer S",
1170
+ description: "Dranzer S — a Balance-type, right-spin Spin Gear System release, product code A-2, released by Takara. Complete tops, loose parts, sticker sheets and pre-owned examples of this model are listed here.",
1171
+ extra: { aliases: ["A-2", "Dranzer Spiral"], productDefaults: { defaultFeatures: ["feature-blader-kai-hiwatari", "feature-type-balance", "feature-right-spin"] } },
1172
+ },
1149
1173
  {
1150
1174
  id: "category-driger-f",
1151
1175
  name: "Driger F",
@@ -1361,4 +1385,4 @@ export const MODEL_LEAVES = {
1361
1385
  ],
1362
1386
  };
1363
1387
  /** Total named-model leaves — asserted by the seed so a silent drop is loud. */
1364
- export const MODEL_LEAF_COUNT = 216;
1388
+ export const MODEL_LEAF_COUNT = 220;
@@ -0,0 +1,54 @@
1
+ import type { ProductDocument } from "../../features/products/schemas";
2
+ export declare function deriveCategoryNames(slugs: readonly string[] | undefined): string[] | undefined;
3
+ /**
4
+ * Derive the full ancestor closure from the head of an existing chain.
5
+ *
6
+ * ## 🛑 The drift this ends — measured, 6 of 1,039 live products
7
+ *
8
+ * `categorySlugs` is the ancestor closure and it was hand-written per record
9
+ * too, so a chain could SKIP A TIER and nothing noticed:
10
+ *
11
+ * product-beyblade-original-driger-v
12
+ * got driger-v > original-tops > beyblade-original > spinning-tops
13
+ * expected driger-v > original-magnacore > original-tops > …
14
+ *
15
+ * The category page matches `array-contains-any [its own id]` against this
16
+ * array (CLAUDE.md: "a category page matches on its own id alone"), so a
17
+ * missing intermediate means the product is **absent from that node's page**
18
+ * while appearing correctly on every other node in its chain. Six products were
19
+ * invisible on their own system sub-line.
20
+ *
21
+ * `countersReconcile`'s rollup invariant cannot see this: it computes the counts
22
+ * FROM these closures, so a skipped tier is simply a tier with a lower count,
23
+ * and `own + Σ(children.total) === total` still holds exactly. The only way to
24
+ * find it is to re-derive the chain and compare.
25
+ *
26
+ * ## 🛑 A SECOND ASSIGNMENT IS LEFT ALONE, NOT COLLAPSED
27
+ *
28
+ * This derives from `slugs[0]` — the head, which is what `readAssignments`
29
+ * treats as the assignment. A product filed under two roots would carry two
30
+ * concatenated closures, and deriving from the head alone would silently DELETE
31
+ * the second one, turning a drift-fix into data loss.
32
+ *
33
+ * So the rule is: overwrite only when the existing array is a SUBSET of the
34
+ * derived closure. That is precisely the drift case (ids missing from the
35
+ * middle). An id outside the derived closure means either a second assignment
36
+ * or something this helper cannot safely interpret, and both must survive to be
37
+ * reported rather than be quietly rewritten.
38
+ *
39
+ * Measured when this was written: 0 of 71 seeded products carry `categoryIds`
40
+ * and 0 carry a multi-root closure — so the subset rule fires on all six real
41
+ * cases and guards a case that does not exist yet.
42
+ */
43
+ export declare function deriveCategoryClosure(slugs: readonly string[] | undefined): string[] | undefined;
44
+ /**
45
+ * Pipeline step: replace whatever `categorySlugs` and `categoryNames` a record
46
+ * carries with the derivation. Deliberately an OVERWRITE rather than a
47
+ * fill-if-absent — a hand-written value that disagrees IS the defect, so
48
+ * preferring it would preserve every one of them.
49
+ *
50
+ * Order matters: the closure is derived first, then the names from the derived
51
+ * closure. Deriving names from the stale chain and then fixing the chain would
52
+ * leave the two mirrors disagreeing again, one tier apart.
53
+ */
54
+ export declare function withDerivedCategoryNames(p: Partial<ProductDocument>): Partial<ProductDocument>;