@mohasinac/appkit 4.53.1 → 4.54.0-next.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 (220) hide show
  1. package/dist/.build-stamp.json +5 -5
  2. package/dist/_internal/client/theme/ThemeProvider.js +15 -1
  3. package/dist/_internal/server/features/auth/capabilities.js +7 -5
  4. package/dist/_internal/server/features/categories/price-guidance-resolve.d.ts +66 -0
  5. package/dist/_internal/server/features/categories/price-guidance-resolve.js +138 -0
  6. package/dist/_internal/server/features/checkout/supplier-identity.js +28 -6
  7. package/dist/_internal/server/features/price-index/market.d.ts +57 -0
  8. package/dist/_internal/server/features/price-index/market.js +120 -0
  9. package/dist/_internal/server/features/products/list-public.d.ts +12 -0
  10. package/dist/_internal/server/features/products/list-public.js +10 -1
  11. package/dist/_internal/server/features/products/listing-tab-counts.d.ts +25 -1
  12. package/dist/_internal/server/features/products/listing-tab-counts.js +184 -10
  13. package/dist/_internal/server/features/search/index-build/sources.js +43 -2
  14. package/dist/_internal/server/features/stores/tabs.d.ts +7 -2
  15. package/dist/_internal/server/features/stores/tabs.js +5 -3
  16. package/dist/_internal/server/jobs/core/jobRunners.js +0 -10
  17. package/dist/_internal/server/jobs/core/onOrderCreate.js +10 -104
  18. package/dist/_internal/shared/actions/action-registry.d.ts +1 -1
  19. package/dist/_internal/shared/actions/action-registry.js +128 -0
  20. package/dist/_internal/shared/features/categories/bundle-copy.d.ts +1 -1
  21. package/dist/_internal/shared/features/site-settings/supplier-of-record.d.ts +56 -0
  22. package/dist/_internal/shared/features/site-settings/supplier-of-record.js +57 -0
  23. package/dist/_internal/shared/listing-types/tiers.d.ts +94 -0
  24. package/dist/_internal/shared/listing-types/tiers.js +150 -0
  25. package/dist/_internal/shared/search/match.js +28 -2
  26. package/dist/cli/index.js +0 -2
  27. package/dist/client.d.ts +21 -2
  28. package/dist/client.js +23 -4
  29. package/dist/constants/api-endpoints.d.ts +28 -0
  30. package/dist/constants/api-endpoints.js +14 -0
  31. package/dist/features/account/components/UserOrdersView.js +26 -7
  32. package/dist/features/addresses/utils/postal-autofill.d.ts +39 -0
  33. package/dist/features/addresses/utils/postal-autofill.js +54 -0
  34. package/dist/features/admin/components/AdminStoreEditorView.js +73 -34
  35. package/dist/features/admin/components/OwnerQuickActions.d.ts +29 -0
  36. package/dist/features/admin/components/OwnerQuickActions.js +142 -0
  37. package/dist/features/admin/schemas/admin-editor-forms.d.ts +1 -1
  38. package/dist/features/admin/schemas/admin-editor-forms.js +4 -1
  39. package/dist/features/auctions/components/AuctionDetailPageView.js +8 -7
  40. package/dist/features/auctions/components/MarketplaceAuctionCard.d.ts +11 -1
  41. package/dist/features/auctions/components/MarketplaceAuctionCard.js +9 -2
  42. package/dist/features/auth/permissions/constants.d.ts +29 -2
  43. package/dist/features/auth/permissions/constants.js +68 -1
  44. package/dist/features/blog/components/BlogFeaturedCard.d.ts +11 -1
  45. package/dist/features/blog/components/BlogFeaturedCard.js +9 -2
  46. package/dist/features/cart/components/CartDrawer.js +3 -2
  47. package/dist/features/cart/components/CartGroupLineRow.js +2 -1
  48. package/dist/features/cart/hooks/useAddToCart.js +10 -5
  49. package/dist/features/cart/utils/pending-ops.d.ts +11 -1
  50. package/dist/features/cart/utils/pending-ops.js +14 -2
  51. package/dist/features/categories/api/[id]/price-guidance/route.js +1 -24
  52. package/dist/features/categories/components/BrandDetailPageView.js +15 -6
  53. package/dist/features/categories/components/BrandDetailTabs.d.ts +7 -1
  54. package/dist/features/categories/components/BrandDetailTabs.js +18 -3
  55. package/dist/features/categories/components/BundleCollage.js +3 -2
  56. package/dist/features/categories/components/BundleItemsPicker.js +3 -4
  57. package/dist/features/categories/components/CategoryDetailPageView.js +19 -8
  58. package/dist/features/categories/components/CategoryDetailTabs.d.ts +7 -1
  59. package/dist/features/categories/components/CategoryDetailTabs.js +29 -7
  60. package/dist/features/categories/components/CategoryGrid.d.ts +11 -1
  61. package/dist/features/categories/components/CategoryGrid.js +9 -2
  62. package/dist/features/checkout/actions/checkout-value-otp-actions.d.ts +10 -5
  63. package/dist/features/checkout/actions/checkout-value-otp-actions.js +2 -88
  64. package/dist/features/classified/components/ClassifiedDetailPageView.js +10 -1
  65. package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +10 -1
  66. package/dist/features/events/components/EventCard.d.ts +11 -1
  67. package/dist/features/events/components/EventCard.js +9 -2
  68. package/dist/features/layout/AppLayoutShell.js +2 -1
  69. package/dist/features/layout/BottomActions.d.ts +1 -0
  70. package/dist/features/layout/BottomActions.js +330 -0
  71. package/dist/features/layout/BottomChrome.d.ts +45 -0
  72. package/dist/features/layout/BottomChrome.js +5 -17
  73. package/dist/features/layout/SideActionDock.js +36 -13
  74. package/dist/features/layout/index.d.ts +2 -7
  75. package/dist/features/layout/index.js +7 -7
  76. package/dist/features/layout/useIsDesktop.d.ts +36 -0
  77. package/dist/features/layout/useIsDesktop.js +48 -0
  78. package/dist/features/live/components/LiveItemDetailPageView.js +10 -1
  79. package/dist/features/media/messages/en.json +67 -67
  80. package/dist/features/orders/components/YourPurchaseStrip.d.ts +24 -0
  81. package/dist/features/orders/components/YourPurchaseStrip.js +77 -0
  82. package/dist/features/orders/components/index.d.ts +2 -1
  83. package/dist/features/orders/components/index.js +1 -1
  84. package/dist/features/orders/utils/order-announcement-message.d.ts +18 -0
  85. package/dist/features/orders/utils/order-announcement-message.js +16 -0
  86. package/dist/features/orders/utils/payment-proof-message.d.ts +20 -0
  87. package/dist/features/orders/utils/payment-proof-message.js +17 -0
  88. package/dist/features/parts/schemas/firestore.d.ts +227 -0
  89. package/dist/features/parts/schemas/firestore.js +6 -6
  90. package/dist/features/pre-orders/components/MarketplacePreorderCard.d.ts +11 -1
  91. package/dist/features/pre-orders/components/MarketplacePreorderCard.js +10 -3
  92. package/dist/features/pre-orders/components/PreOrderDetailPageView.js +8 -7
  93. package/dist/features/price-index/components/PriceIndexToolView.d.ts +21 -0
  94. package/dist/features/price-index/components/PriceIndexToolView.js +230 -27
  95. package/dist/features/price-index/lookup-target.d.ts +48 -0
  96. package/dist/features/price-index/lookup-target.js +107 -0
  97. package/dist/features/products/api/[id]/route.js +8 -1
  98. package/dist/features/products/api/route.js +16 -4
  99. package/dist/features/products/components/AvailabilityScopeHint.d.ts +22 -0
  100. package/dist/features/products/components/AvailabilityScopeHint.js +27 -0
  101. package/dist/features/products/components/CompareOverlay.d.ts +2 -1
  102. package/dist/features/products/components/CompareOverlay.js +2 -1
  103. package/dist/features/products/components/GroupSettingsPanel.js +2 -1
  104. package/dist/features/products/components/InteractiveProductCard.d.ts +7 -1
  105. package/dist/features/products/components/InteractiveProductCard.js +16 -4
  106. package/dist/features/products/components/MarketplaceBundleCard.d.ts +11 -1
  107. package/dist/features/products/components/MarketplaceBundleCard.js +9 -2
  108. package/dist/features/products/components/MarketplacePrizeDrawCard.d.ts +11 -1
  109. package/dist/features/products/components/MarketplacePrizeDrawCard.js +9 -2
  110. package/dist/features/products/components/ProductDetailPageView.js +34 -4
  111. package/dist/features/products/components/ProductGrid.d.ts +14 -1
  112. package/dist/features/products/components/ProductGrid.js +22 -5
  113. package/dist/features/products/components/ProductsIndexListing.js +2 -2
  114. package/dist/features/products/components/SublistingCarouselSection.js +2 -1
  115. package/dist/features/products/repository/products.repository.d.ts +1 -0
  116. package/dist/features/products/repository/products.repository.js +5 -1
  117. package/dist/features/products/utils/group-member-image.d.ts +9 -2
  118. package/dist/features/products/utils/group-member-image.js +2 -1
  119. package/dist/features/reviews/api/[id]/route.js +83 -23
  120. package/dist/features/reviews/components/PurchaseReviewAffordance.d.ts +6 -0
  121. package/dist/features/reviews/components/PurchaseReviewAffordance.js +42 -0
  122. package/dist/features/reviews/components/ReviewFormDrawer.d.ts +34 -0
  123. package/dist/features/reviews/components/ReviewFormDrawer.js +127 -0
  124. package/dist/features/reviews/components/index.d.ts +4 -0
  125. package/dist/features/reviews/components/index.js +2 -0
  126. package/dist/features/seller/actions/seller-actions.d.ts +1 -1
  127. package/dist/features/seller/actions/seller-actions.js +49 -3
  128. package/dist/features/seller/components/SellerPayoutSettingsView.js +30 -2
  129. package/dist/features/seller/components/SellerProductShell.d.ts +17 -1
  130. package/dist/features/seller/components/SellerProductsView.js +3 -2
  131. package/dist/features/seller/hooks/useStoreListingEntitlements.d.ts +8 -0
  132. package/dist/features/seller/hooks/useStoreListingEntitlements.js +70 -0
  133. package/dist/features/stores/components/InteractiveStoreCard.d.ts +11 -1
  134. package/dist/features/stores/components/InteractiveStoreCard.js +9 -2
  135. package/dist/features/stores/components/StoreAuctionsListing.js +2 -1
  136. package/dist/features/stores/components/index.d.ts +0 -2
  137. package/dist/features/stores/components/index.js +0 -1
  138. package/dist/features/stores/schemas/index.d.ts +1 -1
  139. package/dist/features/stores/schemas/index.js +10 -1
  140. package/dist/features/tester/seed-data/authored/content-discovery__authenticity-checker.js +114 -2
  141. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +415 -0
  142. package/dist/index.d.ts +8 -27
  143. package/dist/index.js +14 -35
  144. package/dist/next/routing/route-map.d.ts +2 -0
  145. package/dist/next/routing/route-map.js +9 -0
  146. package/dist/providers/shipping-shiprocket/index.d.ts +98 -0
  147. package/dist/providers/shipping-shiprocket/index.js +353 -0
  148. package/dist/schemas/registry.d.ts +2 -2
  149. package/dist/security/csp.d.ts +16 -1
  150. package/dist/security/csp.js +5 -3
  151. package/dist/seed/_helpers/category-forest.js +27 -0
  152. package/dist/seed/_helpers/category-parts-glossary-map.js +1816 -86
  153. package/dist/seed/_helpers/category-parts.d.ts +1 -1
  154. package/dist/seed/_helpers/category-parts.js +13412 -3020
  155. package/dist/seed/categories-seed-data.js +48 -6
  156. package/dist/seed/homepage-sections-seed-data.js +41 -28
  157. package/dist/seed/model-composition-seed-data.js +6 -8
  158. package/dist/seed/part-glossary/chunk0.d.ts +9 -0
  159. package/dist/seed/part-glossary/chunk0.js +35044 -0
  160. package/dist/seed/part-glossary/chunk1.d.ts +9 -0
  161. package/dist/seed/part-glossary/chunk1.js +39955 -0
  162. package/dist/seed/part-glossary/chunk2.d.ts +9 -0
  163. package/dist/seed/part-glossary/chunk2.js +50335 -0
  164. package/dist/seed/part-glossary/chunk3.d.ts +9 -0
  165. package/dist/seed/part-glossary/chunk3.js +52386 -0
  166. package/dist/seed/part-glossary/chunk4.d.ts +9 -0
  167. package/dist/seed/part-glossary/chunk4.js +50549 -0
  168. package/dist/seed/part-glossary-seed-data.d.ts +1 -1
  169. package/dist/seed/part-glossary-seed-data.js +23 -47483
  170. package/dist/seed/products-beyblade-x-seed-data.js +36 -1
  171. package/dist/server.d.ts +6 -8
  172. package/dist/server.js +16 -20
  173. package/dist/styles.css +516 -358
  174. package/dist/tailwind-utilities.css +1 -1
  175. package/dist/tokens/themes/index.d.ts +3 -1
  176. package/dist/tokens/themes/index.js +11 -1
  177. package/dist/tokens/themes/technical-dark.d.ts +30 -0
  178. package/dist/tokens/themes/technical-dark.js +103 -0
  179. package/dist/tokens/themes/technical-light.d.ts +22 -0
  180. package/dist/tokens/themes/technical-light.js +89 -0
  181. package/dist/tokens/tokens.css +155 -0
  182. package/dist/ui/components/GatedPrice.js +30 -3
  183. package/dist/ui/components/GatedPrice.style.css +22 -19
  184. package/dist/ui/components/QuickCreateModal.style.css +78 -78
  185. package/dist/ui/forms/FormShell.style.css +260 -260
  186. package/package.json +1 -1
  187. package/scripts/seed-cli.mjs +133 -27
  188. package/dist/_internal/server/jobs/core/whatsappCatalogImport.d.ts +0 -27
  189. package/dist/_internal/server/jobs/core/whatsappCatalogImport.js +0 -172
  190. package/dist/_internal/server/jobs/core/whatsappNotify.d.ts +0 -35
  191. package/dist/_internal/server/jobs/core/whatsappNotify.js +0 -140
  192. package/dist/features/admin/components/AdminWhatsAppGuideView.d.ts +0 -2
  193. package/dist/features/admin/components/AdminWhatsAppGuideView.js +0 -35
  194. package/dist/features/orders/components/MarketplaceOrderCard.d.ts +0 -36
  195. package/dist/features/orders/components/MarketplaceOrderCard.js +0 -57
  196. package/dist/features/stores/components/StoreWhatsAppGuideView.d.ts +0 -3
  197. package/dist/features/stores/components/StoreWhatsAppGuideView.js +0 -12
  198. package/dist/features/whatsapp-bot/api/send-status/route.d.ts +0 -12
  199. package/dist/features/whatsapp-bot/api/send-status/route.js +0 -38
  200. package/dist/features/whatsapp-bot/api/webhook/route.d.ts +0 -12
  201. package/dist/features/whatsapp-bot/api/webhook/route.js +0 -43
  202. package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.d.ts +0 -6
  203. package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.js +0 -270
  204. package/dist/features/whatsapp-bot/components/WhatsAppChatButton.d.ts +0 -9
  205. package/dist/features/whatsapp-bot/components/WhatsAppChatButton.js +0 -7
  206. package/dist/features/whatsapp-bot/components/index.d.ts +0 -3
  207. package/dist/features/whatsapp-bot/components/index.js +0 -2
  208. package/dist/features/whatsapp-bot/helpers/payment-proof-message.d.ts +0 -14
  209. package/dist/features/whatsapp-bot/helpers/payment-proof-message.js +0 -17
  210. package/dist/features/whatsapp-bot/helpers/whatsapp.d.ts +0 -52
  211. package/dist/features/whatsapp-bot/helpers/whatsapp.js +0 -283
  212. package/dist/features/whatsapp-bot/index.d.ts +0 -3
  213. package/dist/features/whatsapp-bot/index.js +0 -3
  214. package/dist/features/whatsapp-bot/manifest.d.ts +0 -2
  215. package/dist/features/whatsapp-bot/manifest.js +0 -13
  216. package/dist/features/whatsapp-bot/messages/en.json +0 -7
  217. package/dist/features/whatsapp-bot/server.d.ts +0 -7
  218. package/dist/features/whatsapp-bot/server.js +0 -7
  219. package/dist/features/whatsapp-bot/types/index.d.ts +0 -126
  220. package/dist/features/whatsapp-bot/types/index.js +0 -1
@@ -5,6 +5,7 @@ import { categoryCrumbs, withTrailHead } from "../utils/breadcrumb-chain.js";
5
5
  import { ownTotalFor, descendantTotalFor } from "../schemas/category-counts.js";
6
6
  import { categoriesRepository, productRepository, storeRepository, } from "../../../repositories/index.js";
7
7
  import { Container, Div, Heading, ImageMosaic, Main, MOSAIC_MIN, Section, Span, SplitTile, Stack, Text } from "../../../ui/index.js";
8
+ import { OwnerQuickActions } from "../../admin/components/OwnerQuickActions.js";
8
9
  import { MediaImage } from "../../media/MediaImage.js";
9
10
  import { RichTextRenderer } from "../../../ui/rich-text/RichTextRenderer.js";
10
11
  import { resolveTaxonomyVisual } from "../utils/taxonomy-visual.js";
@@ -27,6 +28,7 @@ import { safeRead } from "../../../errors/safe-read.js";
27
28
  import { breadcrumbJsonLd, collectionPageJsonLd, faqJsonLd, } from "../../../seo/json-ld.js";
28
29
  import { toStoreListItem } from "../../../_internal/server/features/stores/adapters.js";
29
30
  import { hidePublicTestData } from "../../../_internal/server/features/tester/visibility.js";
31
+ import { productImageUrls } from "../../products/schemas/firestore.js";
30
32
  const __O = {
31
33
  hidden: "overflow-hidden",
32
34
  };
@@ -170,7 +172,7 @@ export async function CategoryDetailPageView({ slug }) {
170
172
  // Four of the ten tabs used to have no count at all and could never hide.
171
173
  categoriesIn
172
174
  ? listingTabCounts(CATEGORY_PAGE_TABS, { categoriesIn })
173
- : Promise.resolve({}),
175
+ : Promise.resolve({ available: {}, total: {} }),
174
176
  // SB-UNI-D — bundles fetched from the categories collection. We pull
175
177
  // all active bundle rows; the carousel filters by category affinity.
176
178
  categoriesIn
@@ -419,13 +421,22 @@ export async function CategoryDetailPageView({ slug }) {
419
421
  // `tabCounts` is keyed by tabSlug and already covers every listing type plus
420
422
  // bundles; `stores` is added here because this page resolves its store list
421
423
  // eagerly to render the tab body anyway.
422
- const counts = { ...tabCounts, stores: storeCount };
424
+ // Two maps, not one: the BADGE is what the default (available) scope will
425
+ // render, while HIDING must stay keyed on the total — a tab whose rows have
426
+ // all ended still has a browsable archive behind the Ended/All scopes.
427
+ // `stores` is added to both because this page resolves its store list
428
+ // eagerly to render the tab body anyway, and a store has no availability
429
+ // scope of its own.
430
+ const counts = { ...tabCounts.available, stores: storeCount };
431
+ const totals = { ...tabCounts.total, stores: storeCount };
423
432
  // Header pills read the same numbers the tabs do. `?? 0` is safe here (a pill
424
433
  // simply doesn't render) — unlike the tab bar, where an unknown count must
425
434
  // stay visible rather than collapse to "hide me".
426
- const productCount = counts.products ?? category?.metrics?.totalProductCount ?? category?.metrics?.productCount ?? 0;
427
- const auctionCount = counts.auctions ?? category?.metrics?.totalAuctionCount ?? category?.metrics?.auctionCount ?? 0;
428
- const preOrderCount = counts["pre-orders"] ?? 0;
435
+ // Header pills read TOTALS, deliberately: "47 products" is a statement about
436
+ // what this category holds, not about what one scope is currently showing.
437
+ const productCount = totals.products ?? category?.metrics?.totalProductCount ?? category?.metrics?.productCount ?? 0;
438
+ const auctionCount = totals.auctions ?? category?.metrics?.totalAuctionCount ?? category?.metrics?.auctionCount ?? 0;
439
+ const preOrderCount = totals["pre-orders"] ?? 0;
429
440
  /*
430
441
  * G3b — OWN vs CHILDREN, so one number stops answering two questions.
431
442
  *
@@ -465,7 +476,7 @@ export async function CategoryDetailPageView({ slug }) {
465
476
  const mosaicImages = hasCover
466
477
  ? []
467
478
  : (productsResult?.items ?? [])
468
- .map((row) => row.mainImage ?? row.images?.[0] ?? null)
479
+ .map((row) => row.mainImage || productImageUrls(row.images)[0] || null)
469
480
  .filter((src) => !!src)
470
481
  .slice(0, 5);
471
482
  const hasMosaic = mosaicImages.length >= MOSAIC_MIN;
@@ -562,7 +573,7 @@ export async function CategoryDetailPageView({ slug }) {
562
573
  const ldFaq = category?.faqs && category.faqs.length > 0
563
574
  ? faqJsonLd(category.faqs.map((f) => ({ question: f.question, answer: f.answer })))
564
575
  : null;
565
- return (_jsxs(Main, { children: [_jsx("script", { type: "application/ld+json", dangerouslySetInnerHTML: { __html: JSON.stringify(ldBreadcrumb) } }), _jsx("script", { type: "application/ld+json", dangerouslySetInnerHTML: { __html: JSON.stringify(ldCollection) } }), ldFaq && (_jsx("script", { type: "application/ld+json", dangerouslySetInnerHTML: { __html: JSON.stringify(ldFaq) } })), _jsxs(Section, { className: `relative ${__O.hidden} ${hasCover ? "min-h-[220px] md:min-h-[280px]" : "bg-[var(--appkit-color-bg)]"}`, children: [hasCover && (_jsxs(_Fragment, { children: [_jsx(Div, { className: "absolute inset-0", children: _jsx(MediaImage, { src: coverImage, alt: "", size: "banner" }) }), _jsx(Div, { surface: "overlay-md", className: "absolute inset-0" })] })), !hasCover && hasMosaic && (_jsx(Div, { className: "relative z-10 max-w-7xl mx-auto", padding: "x-md", children: _jsx(Div, { padding: "t-lg", children: _jsx(ImageMosaic, { images: mosaicImages, alt: `${category?.name ?? slug} on LetItRip` }) }) })), _jsxs(Div, { className: "relative z-10 max-w-7xl mx-auto", paddingY: hasCover ? "y-3xl" : "y-xl", padding: "x-md", children: [_jsx(BreadcrumbTrail, { crumbs: trail, className: "mb-4", tone: hasCover ? "on-media" : "default" }), category?.id && _jsx(LastCategoryBeacon, { categoryId: category.id }), _jsx(Heading, { color: "inverse", level: 1, className: `mb-2 ${hasCover ? "" : "text-[var(--appkit-color-text)]"}`, size: "3xl", mdSize: "4xl", weight: "bold", children: category?.name ?? slug }), category?.description && typeof category.description === "string" && !category.description.startsWith("{") && (_jsx(Text, { color: hasCover ? "inverse" : "muted", className: `max-w-2xl mb-4 ${hasCover ? "/80" : ""}`, size: "base", children: category.description })), _jsxs(Row, { wrap: true, gap: "sm", children: [productCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: "inverse", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "bg-primary/10 text-primary-700 dark:text-primary-400", rounded: "full", padding: "pill-sm-tall", children: [productCount.toLocaleString(), " ", productCount === 1 ? "product" : "products"] })), showCountSplit && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "muted", size: "xs", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [ownHere.toLocaleString(), " here \u00B7 ", inSubcategories.toLocaleString(), " in subcategories"] })), auctionCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "warning", surface: hasCover ? undefined : "warning-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [auctionCount.toLocaleString(), " ", auctionCount === 1 ? "auction" : "auctions"] })), preOrderCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "info", surface: hasCover ? undefined : "info-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [preOrderCount.toLocaleString(), " ", preOrderCount === 1 ? "pre-order" : "pre-orders"] })), storeCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "success", surface: hasCover ? undefined : "success-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [storeCount.toLocaleString(), " ", storeCount === 1 ? "store" : "stores"] }))] })] })] }), category?.hasbroName && (_jsx(Section, { padding: "y-md", children: _jsx(Container, { size: "xl", children: _jsxs(Row, { gap: "md", align: "center", wrap: true, children: [category.hasbroCoverImage && (_jsx(MediaImage, { src: category.hasbroCoverImage, alt: `${category.hasbroName} logo`, size: "thumbnail", className: "h-12 w-auto flex-shrink-0" })), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "xs", color: "muted", transform: "uppercase", weight: "medium", children: "Also known as" }), _jsx(Text, { weight: "semibold", children: category.hasbroName }), _jsxs(Text, { size: "xs", color: "muted", children: ["The same wave, named differently outside Japan \u2014 Takara-Tomy releases it as ", category.name, ", Hasbro as ", category.hasbroName, ". Both names find these listings."] })] })] }) }) })), contentBody && contentBody.trim().length > 0 && (_jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "xl", children: _jsx(RichTextRenderer, { html: contentBody }) }) })), _jsx(CategoryHighlightsAndFaqSection, { highlights: category?.highlights, faqs: category?.faqs }), compositions.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-b", children: _jsx(Div, { className: __W, padding: "section", children: _jsxs(Stack, { gap: "md", className: "min-w-0", children: [_jsx(Heading, { level: 2, size: "lg", children: "What it is made of" }), _jsx(ModelCompositionPanel, { compositions: compositions, hrefForPart: (slot, tail) => String(ROUTES.PUBLIC.PART_DETAIL(slot, tail)) })] }) }) })), (() => {
576
+ return (_jsxs(Main, { children: [_jsx("script", { type: "application/ld+json", dangerouslySetInnerHTML: { __html: JSON.stringify(ldBreadcrumb) } }), _jsx("script", { type: "application/ld+json", dangerouslySetInnerHTML: { __html: JSON.stringify(ldCollection) } }), ldFaq && (_jsx("script", { type: "application/ld+json", dangerouslySetInnerHTML: { __html: JSON.stringify(ldFaq) } })), _jsxs(Section, { className: `relative ${__O.hidden} ${hasCover ? "min-h-[220px] md:min-h-[280px]" : "bg-[var(--appkit-color-bg)]"}`, children: [hasCover && (_jsxs(_Fragment, { children: [_jsx(Div, { className: "absolute inset-0", children: _jsx(MediaImage, { src: coverImage, alt: "", size: "banner" }) }), _jsx(Div, { surface: "overlay-md", className: "absolute inset-0" })] })), !hasCover && hasMosaic && (_jsx(Div, { className: "relative z-10 max-w-7xl mx-auto", padding: "x-md", children: _jsx(Div, { padding: "t-lg", children: _jsx(ImageMosaic, { images: mosaicImages, alt: `${category?.name ?? slug} on LetItRip` }) }) })), _jsxs(Div, { className: "relative z-10 max-w-7xl mx-auto", paddingY: hasCover ? "y-3xl" : "y-xl", padding: "x-md", children: [_jsx(BreadcrumbTrail, { crumbs: trail, className: "mb-4", tone: hasCover ? "on-media" : "default" }), category?.id && _jsx(LastCategoryBeacon, { categoryId: category.id }), category?.id && (_jsx(OwnerQuickActions, { resource: "CATEGORY", placement: "detail", className: "mb-3", recordLabel: category.name ?? slug, editHref: String(ROUTES.ADMIN.CATEGORIES_EDIT(category.id)) })), _jsx(Heading, { color: "inverse", level: 1, className: `mb-2 ${hasCover ? "" : "text-[var(--appkit-color-text)]"}`, size: "3xl", mdSize: "4xl", weight: "bold", children: category?.name ?? slug }), category?.description && typeof category.description === "string" && !category.description.startsWith("{") && (_jsx(Text, { color: hasCover ? "inverse" : "muted", className: `max-w-2xl mb-4 ${hasCover ? "/80" : ""}`, size: "base", children: category.description })), _jsxs(Row, { wrap: true, gap: "sm", children: [productCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: "inverse", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "bg-primary/10 text-primary-700 dark:text-primary-400", rounded: "full", padding: "pill-sm-tall", children: [productCount.toLocaleString(), " ", productCount === 1 ? "product" : "products"] })), showCountSplit && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "muted", size: "xs", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [ownHere.toLocaleString(), " here \u00B7 ", inSubcategories.toLocaleString(), " in subcategories"] })), auctionCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "warning", surface: hasCover ? undefined : "warning-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [auctionCount.toLocaleString(), " ", auctionCount === 1 ? "auction" : "auctions"] })), preOrderCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "info", surface: hasCover ? undefined : "info-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [preOrderCount.toLocaleString(), " ", preOrderCount === 1 ? "pre-order" : "pre-orders"] })), storeCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "success", surface: hasCover ? undefined : "success-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [storeCount.toLocaleString(), " ", storeCount === 1 ? "store" : "stores"] }))] })] })] }), category?.hasbroName && (_jsx(Section, { padding: "y-md", children: _jsx(Container, { size: "xl", children: _jsxs(Row, { gap: "md", align: "center", wrap: true, children: [category.hasbroCoverImage && (_jsx(MediaImage, { src: category.hasbroCoverImage, alt: `${category.hasbroName} logo`, size: "thumbnail", className: "h-12 w-auto flex-shrink-0" })), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "xs", color: "muted", transform: "uppercase", weight: "medium", children: "Also known as" }), _jsx(Text, { weight: "semibold", children: category.hasbroName }), _jsxs(Text, { size: "xs", color: "muted", children: ["The same wave, named differently outside Japan \u2014 Takara-Tomy releases it as ", category.name, ", Hasbro as ", category.hasbroName, ". Both names find these listings."] })] })] }) }) })), contentBody && contentBody.trim().length > 0 && (_jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "xl", children: _jsx(RichTextRenderer, { html: contentBody }) }) })), _jsx(CategoryHighlightsAndFaqSection, { highlights: category?.highlights, faqs: category?.faqs }), compositions.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-b", children: _jsx(Div, { className: __W, padding: "section", children: _jsxs(Stack, { gap: "md", className: "min-w-0", children: [_jsx(Heading, { level: 2, size: "lg", children: "What it is made of" }), _jsx(ModelCompositionPanel, { compositions: compositions, hrefForPart: (slot, tail) => String(ROUTES.PUBLIC.PART_DETAIL(slot, tail)) })] }) }) })), (() => {
566
577
  if (category?.tier !== 0)
567
578
  return null;
568
579
  const branches = childCategories.filter((c) => c.tier === 1);
@@ -587,5 +598,5 @@ export async function CategoryDetailPageView({ slug }) {
587
598
  imageUrl: coverOf(b),
588
599
  countLabel: countOf(b),
589
600
  } }) }) }));
590
- })(), directChildrenWithListings.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-b", children: _jsx(Div, { className: __W, padding: "inline", children: _jsxs(Div, { padding: "b-2xs", children: [parentLadder.length > 0 && (_jsx(Div, { padding: "b-2xs", children: _jsx(SubcategoryCardRow, { items: parentLadder, label: "Parent category" }) })), _jsx(SubcategoryCardRow, { items: toSubcategoryItems(directChildrenWithListings, childCategories) })] }) }) })), _jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "xl", children: _jsx(CategoryDetailTabs, { categorySlug: slug, categoryId: category?.id, initialProductsData: productsResult ?? undefined, initialBundles: bundlesResult ?? [], initialStores: categoryStores, counts: counts, enabledListingTypes: enabledListingTypes(settings), enabledCategoryTypes: enabledCategoryTypes(settings) }) }) }), groupedListings.length > 0 && (_jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "xl", children: _jsx(GroupedListingsCarousel, { groups: groupedListings }) }) })), relatedCategories.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-t", padding: "y-lg", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 2, className: "mb-4", size: "xl", weight: "semibold", children: "Related Categories" }), _jsx(CategoryGrid, { categories: relatedCategories, getHref: (c) => String(ROUTES.PUBLIC.CATEGORY_DETAIL(c.slug)) })] }) }))] }));
601
+ })(), directChildrenWithListings.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-b", children: _jsx(Div, { className: __W, padding: "inline", children: _jsxs(Div, { padding: "b-2xs", children: [parentLadder.length > 0 && (_jsx(Div, { padding: "b-2xs", children: _jsx(SubcategoryCardRow, { items: parentLadder, label: "Parent category" }) })), _jsx(SubcategoryCardRow, { items: toSubcategoryItems(directChildrenWithListings, childCategories) })] }) }) })), _jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "xl", children: _jsx(CategoryDetailTabs, { categorySlug: slug, categoryId: category?.id, initialProductsData: productsResult ?? undefined, initialBundles: bundlesResult ?? [], initialStores: categoryStores, counts: counts, totals: totals, enabledListingTypes: enabledListingTypes(settings), enabledCategoryTypes: enabledCategoryTypes(settings) }) }) }), groupedListings.length > 0 && (_jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "xl", children: _jsx(GroupedListingsCarousel, { groups: groupedListings }) }) })), relatedCategories.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-t", padding: "y-lg", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 2, className: "mb-4", size: "xl", weight: "semibold", children: "Related Categories" }), _jsx(CategoryGrid, { categories: relatedCategories, getHref: (c) => String(ROUTES.PUBLIC.CATEGORY_DETAIL(c.slug)) })] }) }))] }));
591
602
  }
@@ -17,9 +17,15 @@ export interface CategoryDetailTabsProps {
17
17
  * were structurally uncountable and could never hide (Root Cause #61).
18
18
  */
19
19
  counts?: Record<string, number | undefined>;
20
+ /**
21
+ * Every published row per tab, all availability scopes. Drives hide-at-zero
22
+ * and the "N ended — show all" hint; see `ListingTabCountResult`. Omitted,
23
+ * it falls back to `counts`, i.e. the pre-split behaviour.
24
+ */
25
+ totals?: Record<string, number | undefined>;
20
26
  /** Enabled listing types (e.g. ["standard","auction","pre-order"]). When omitted, all tabs shown. */
21
27
  enabledListingTypes?: string[];
22
28
  /** Enabled category types (e.g. ["category","brand","bundle"]). When omitted, all tabs shown. */
23
29
  enabledCategoryTypes?: string[];
24
30
  }
25
- export declare function CategoryDetailTabs({ categorySlug, categoryId, initialProductsData, initialBundles, initialStores, counts, enabledListingTypes, enabledCategoryTypes, }: CategoryDetailTabsProps): React.JSX.Element;
31
+ export declare function CategoryDetailTabs({ categorySlug, categoryId, initialProductsData, initialBundles, initialStores, counts, totals, enabledListingTypes, enabledCategoryTypes, }: CategoryDetailTabsProps): React.JSX.Element;
@@ -2,6 +2,7 @@
2
2
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useState } from "react";
4
4
  import { EmptyState, Tabs, TabsList, TabsTrigger } from "../../../ui/index.js";
5
+ import { AvailabilityScopeHint } from "../../products/components/AvailabilityScopeHint.js";
5
6
  import { CategoryProductsListing } from "./CategoryProductsListing.js";
6
7
  import { AuctionsIndexListing } from "../../products/components/AuctionsIndexListing.js";
7
8
  import { PreOrdersIndexListing } from "../../pre-orders/components/PreOrdersIndexListing.js";
@@ -29,6 +30,21 @@ const TAB_TYPE_MAP = {
29
30
  bundles: { kind: "category", type: "bundle" },
30
31
  stores: { kind: "entity", type: "stores" },
31
32
  };
33
+ /**
34
+ * The listing types a tab spans, for the availability-scope hint.
35
+ *
36
+ * Derived from `TAB_TYPE_MAP` rather than hand-written: that map is already
37
+ * built from `ALL_LISTING_TYPES`, so a tenth type is covered without a second
38
+ * enumeration (Root Cause #61). The combined Art & Stickers tab is the one
39
+ * entry that spans two, and `GENERIC_TAB_LISTING_TYPES` already says so.
40
+ */
41
+ function typesForTab(id) {
42
+ const generic = GENERIC_TAB_LISTING_TYPES[id];
43
+ if (generic)
44
+ return generic;
45
+ const mapping = TAB_TYPE_MAP[id];
46
+ return mapping?.kind === "listing" ? [mapping.type] : [];
47
+ }
32
48
  /** Listing types rendered by the shared products listing rather than a dedicated one. */
33
49
  const GENERIC_TAB_LISTING_TYPES = {
34
50
  [pluginFor("standard").tabSlug]: ["standard"],
@@ -50,8 +66,12 @@ const GENERIC_TAB_LISTING_TYPES = {
50
66
  */
51
67
  [pluginFor("spares").tabSlug]: ["spares"],
52
68
  };
53
- export function CategoryDetailTabs({ categorySlug, categoryId, initialProductsData, initialBundles = [], initialStores = [], counts, enabledListingTypes, enabledCategoryTypes, }) {
69
+ export function CategoryDetailTabs({ categorySlug, categoryId, initialProductsData, initialBundles = [], initialStores = [], counts, totals, enabledListingTypes, enabledCategoryTypes, }) {
54
70
  const countFor = (id) => counts?.[id];
71
+ // Falls back to the badge when no totals map is supplied, so a caller that
72
+ // has not been migrated keeps today's hide behaviour rather than hiding
73
+ // everything.
74
+ const totalFor = (id) => totals?.[id] ?? counts?.[id];
55
75
  const visibleTabs = CATEGORY_PAGE_TABS.filter((t) => {
56
76
  const mapping = TAB_TYPE_MAP[t.id];
57
77
  if (mapping) {
@@ -64,11 +84,13 @@ export function CategoryDetailTabs({ categorySlug, categoryId, initialProductsDa
64
84
  return false;
65
85
  }
66
86
  }
67
- // Hide a tab only when its count is known and explicitly zero. `undefined`
68
- // now means the count query FAILED (every tab is counted), so keeping it
69
- // visible is the fail-open choice: an empty tab is a far smaller error than
70
- // hiding a tab that holds real listings.
71
- const count = countFor(t.id);
87
+ // 🛑 TOTAL, not the badge. The badge counts what the default (available)
88
+ // scope renders, and a tab whose rows have all ended badges 0 while still
89
+ // holding a browsable archive behind the Ended/All scopes — hiding it
90
+ // would strand that archive. `undefined` still means the count query
91
+ // FAILED, so the tab stays visible: an empty tab is a far smaller error
92
+ // than hiding a tab that holds real listings (Root Cause #59).
93
+ const count = totalFor(t.id);
72
94
  return count === undefined || count > 0;
73
95
  });
74
96
  const firstTabId = (visibleTabs[0]?.id ?? "products");
@@ -78,5 +100,5 @@ export function CategoryDetailTabs({ categorySlug, categoryId, initialProductsDa
78
100
  if (visibleTabs.length === 0) {
79
101
  return (_jsx(EmptyState, { title: "Nothing listed here yet", description: "This category has no active listings right now. Check back soon." }));
80
102
  }
81
- return (_jsxs(_Fragment, { children: [_jsx(Tabs, { value: activeTab, onChange: (v) => setActiveTab(v), className: "mb-6", children: _jsx(TabsList, { children: visibleTabs.map((t) => (_jsx(TabsTrigger, { value: t.id, badge: countFor(t.id), children: t.label }, t.id))) }) }), GENERIC_TAB_LISTING_TYPES[activeTab] && (_jsx(CategoryProductsListing, { categorySlug: categorySlug, categoryId: categoryId, listingTypes: GENERIC_TAB_LISTING_TYPES[activeTab], initialData: activeTab === pluginFor("standard").tabSlug ? initialProductsData : undefined })), activeTab === pluginFor("auction").tabSlug && (_jsx(AuctionsIndexListing, { categorySlug: categorySlug })), activeTab === pluginFor("pre-order").tabSlug && (_jsx(PreOrdersIndexListing, { categorySlug: categorySlug })), activeTab === pluginFor("prize-draw").tabSlug && (_jsx(PrizeDrawsIndexListing, { categorySlug: categorySlug })), activeTab === "bundles" && (_jsx(CategoryBundlesListing, { initialBundles: initialBundles })), activeTab === "stores" && (_jsx(CategoryStoresListing, { stores: initialStores }))] }));
103
+ return (_jsxs(_Fragment, { children: [_jsx(Tabs, { value: activeTab, onChange: (v) => setActiveTab(v), className: "mb-6", children: _jsx(TabsList, { children: visibleTabs.map((t) => (_jsx(TabsTrigger, { value: t.id, badge: countFor(t.id), children: t.label }, t.id))) }) }), _jsx(AvailabilityScopeHint, { total: totalFor(activeTab), available: countFor(activeTab), types: typesForTab(activeTab) }), GENERIC_TAB_LISTING_TYPES[activeTab] && (_jsx(CategoryProductsListing, { categorySlug: categorySlug, categoryId: categoryId, listingTypes: GENERIC_TAB_LISTING_TYPES[activeTab], initialData: activeTab === pluginFor("standard").tabSlug ? initialProductsData : undefined })), activeTab === pluginFor("auction").tabSlug && (_jsx(AuctionsIndexListing, { categorySlug: categorySlug })), activeTab === pluginFor("pre-order").tabSlug && (_jsx(PreOrdersIndexListing, { categorySlug: categorySlug })), activeTab === pluginFor("prize-draw").tabSlug && (_jsx(PrizeDrawsIndexListing, { categorySlug: categorySlug })), activeTab === "bundles" && (_jsx(CategoryBundlesListing, { initialBundles: initialBundles })), activeTab === "stores" && (_jsx(CategoryStoresListing, { stores: initialStores }))] }));
82
104
  }
@@ -5,8 +5,18 @@ export interface CategoryCardProps {
5
5
  href?: string;
6
6
  onClick?: (category: CategoryItem) => void;
7
7
  className?: string;
8
+ /**
9
+ * Owner-only pencil / clone / bin, for an admin (categories have no owner,
10
+ * so only an admin ever sees it). Pass `<OwnerQuickActions>`.
11
+ *
12
+ * 🛑 Rendered OUTSIDE this card's `<Link>`, as a sibling. A `<button>`
13
+ * inside an `<a>` is invalid HTML that browsers resolve by closing the
14
+ * anchor early, which breaks the CARD's navigation rather than just the
15
+ * button's.
16
+ */
17
+ renderOwnerActions?: () => React.ReactNode;
8
18
  }
9
- export declare function CategoryCard({ category, href, onClick, className, }: CategoryCardProps): React.JSX.Element;
19
+ export declare function CategoryCard({ category, href, onClick, className, renderOwnerActions, }: CategoryCardProps): React.JSX.Element;
10
20
  export interface CategoryGridProps {
11
21
  categories: CategoryItem[];
12
22
  getHref?: (category: CategoryItem) => string;
@@ -12,7 +12,7 @@ const __O = {
12
12
  hidden: "overflow-hidden",
13
13
  };
14
14
  const CLS_FEATURED_DOT = "absolute left-2 top-2 rounded-full bg-warning-surface p-[var(--appkit-space-1)] leading-none";
15
- export function CategoryCard({ category, href, onClick, className = "", }) {
15
+ export function CategoryCard({ category, href, onClick, className = "", renderOwnerActions, }) {
16
16
  // totalProductCount is the rollup (self + all descendant categories),
17
17
  // maintained incrementally by onProductWrite — prefer it over the
18
18
  // own-node-only productCount so a parent category's card shows the same
@@ -32,9 +32,16 @@ export function CategoryCard({ category, href, onClick, className = "", }) {
32
32
  if (href) {
33
33
  return (_jsx(Link, { href: href, className: cardClass, children: inner }));
34
34
  }
35
- return (_jsx(Div, { role: onClick ? "button" : undefined, tabIndex: onClick ? 0 : undefined, onKeyDown: onClick
35
+ const card = (_jsx(Div, { role: onClick ? "button" : undefined, tabIndex: onClick ? 0 : undefined, onKeyDown: onClick
36
36
  ? (e) => (e.key === "Enter" || e.key === " ") && onClick(category)
37
37
  : undefined, onClick: onClick ? () => onClick(category) : undefined, className: cardClass, children: inner }));
38
+ if (!renderOwnerActions)
39
+ return card;
40
+ /*
41
+ * Sibling of the card, never a child — see the prop's docstring. `z-20`
42
+ * beats the featured dot at `left-2 top-2`.
43
+ */
44
+ return (_jsxs(Div, { className: "relative h-full", children: [card, _jsx(Div, { className: "absolute right-2 top-2 z-20", children: renderOwnerActions() })] }));
38
45
  }
39
46
  export function CategoryGrid({ categories, getHref, onCategoryClick, emptyLabel = "No categories found", className = "", }) {
40
47
  if (categories.length === 0) {
@@ -6,12 +6,17 @@
6
6
  * purpose from `checkout-actions.ts`'s third-party shipping consent OTP —
7
7
  * same crypto primitives, separate Firestore namespace and copy.
8
8
  */
9
- export type CheckoutValueOtpChannel = "email" | "whatsapp";
10
9
  /**
11
- * Sends a checkout-value OTP either by email (default) or WhatsApp
12
- * (opt-in — the buyer must explicitly choose it in the UI). WhatsApp phone
13
- * is always resolved server-side from the selected shipping address —
14
- * never trust a client-supplied phone number.
10
+ * Email is the only checkout-OTP channel. WhatsApp was the other arm until
11
+ * 2026-10-20, when the Cloud API integration was removed — see
12
+ * `features/orders/utils/payment-proof-message.ts` for why the `wa.me`
13
+ * links survived and the API did not. Kept as a single-member union rather
14
+ * than deleted so the parameter stays self-documenting and a second channel
15
+ * has an obvious seam.
16
+ */
17
+ export type CheckoutValueOtpChannel = "email";
18
+ /**
19
+ * Sends a checkout-value OTP by email.
15
20
  *
16
21
  * If the chosen channel (and, for email, its skip-worthy fallback) is
17
22
  * currently circuit-broken or unconfigured, the OTP gate is treated as
@@ -9,17 +9,12 @@
9
9
  import { timingSafeEqual } from "crypto";
10
10
  import { ValidationError } from "../../../errors/index.js";
11
11
  import { normalizeError } from "../../../errors/normalize.js";
12
- import { safeRead } from "../../../errors/safe-read.js";
13
12
  import { serverLogger } from "../../../monitoring/index.js";
14
13
  import { sendEmail } from "../../contact/email.js";
15
14
  import { getAdminDb } from "../../../providers/db-firebase/index.js";
16
15
  import { resolveDate } from "../../../utils/index.js";
17
- import { addressesRepository } from "../../addresses/repository/addresses.repository.js";
18
- import { siteSettingsRepository } from "../../admin/repository/site-settings.repository.js";
19
- import { sendWhatsAppBusinessMessage } from "../../whatsapp-bot/helpers/whatsapp.js";
20
16
  import { isChannelHealthy, withChannelRetry } from "../../../_internal/server/notifications/channel-health.js";
21
- import { guardSend, logSuppressedSend } from "../../../_internal/server/notifications/send-guard.js";
22
- import { CHECKOUT_VALUE_OTP_EXPIRY_MS, CHECKOUT_VALUE_OTP_MAX_ATTEMPTS, hashOtp, generateOtpCode, checkoutValueOtpRef, enforceCheckoutValueOtpRateLimit, buildCheckoutValueOtpMessage, } from "../../auth/checkout-value-otp.js";
17
+ import { CHECKOUT_VALUE_OTP_EXPIRY_MS, CHECKOUT_VALUE_OTP_MAX_ATTEMPTS, hashOtp, generateOtpCode, checkoutValueOtpRef, enforceCheckoutValueOtpRateLimit, } from "../../auth/checkout-value-otp.js";
23
18
  function maskEmail(userEmail) {
24
19
  const [local, domain] = userEmail.split("@");
25
20
  return domain
@@ -27,10 +22,7 @@ function maskEmail(userEmail) {
27
22
  : "***";
28
23
  }
29
24
  /**
30
- * Sends a checkout-value OTP either by email (default) or WhatsApp
31
- * (opt-in — the buyer must explicitly choose it in the UI). WhatsApp phone
32
- * is always resolved server-side from the selected shipping address —
33
- * never trust a client-supplied phone number.
25
+ * Sends a checkout-value OTP by email.
34
26
  *
35
27
  * If the chosen channel (and, for email, its skip-worthy fallback) is
36
28
  * currently circuit-broken or unconfigured, the OTP gate is treated as
@@ -45,84 +37,6 @@ export async function sendCheckoutValueOtp(userId, userEmail, channel = "email",
45
37
  const codeHash = hashOtp(code);
46
38
  const expiresAt = new Date(Date.now() + CHECKOUT_VALUE_OTP_EXPIRY_MS);
47
39
  const siteName = process.env.NEXT_PUBLIC_SITE_NAME || "LetItRip";
48
- if (channel === "whatsapp") {
49
- if (!addressId) {
50
- throw new ValidationError("Select a delivery address before sending the code via WhatsApp.");
51
- }
52
- // A failed settings read makes the WhatsApp channel look deliberately
53
- // switched off ("send the code by email instead") — a defensible fallback,
54
- // but one an operator has to be able to see, since it is indistinguishable
55
- // from an admin having disabled it.
56
- const settings = await safeRead(() => siteSettingsRepository.getSingleton(), {
57
- route: "checkout/value-otp",
58
- key: "checkoutValueOtp.siteSettings",
59
- fallback: null,
60
- });
61
- const creds = settings?.credentials ?? {};
62
- const whatsappOtpEnabled = settings?.notificationChannels?.whatsapp?.otpEnabled === true;
63
- const phoneNumberId = creds.whatsappPhoneNumberId?.trim() ?? "";
64
- const accessToken = creds.whatsappCloudApiToken?.trim() ?? "";
65
- const credsReady = phoneNumberId && accessToken && !phoneNumberId.includes("PLACEHOLDER") && !accessToken.includes("PLACEHOLDER");
66
- if (!whatsappOtpEnabled || !credsReady) {
67
- throw new ValidationError("WhatsApp verification isn't available right now — send the code by email instead.");
68
- }
69
- const address = await addressesRepository.findById(addressId);
70
- /*
71
- * REVEALED: this phone receives the high-value checkout OTP, so it has to
72
- * be the real number. `addressesRepository` is ciphertext-by-default, and
73
- * `.trim()` on the stored value is what the nominal brand caught — the
74
- * old intersection form let `Encrypted<string>` call string methods.
75
- */
76
- const addressPlain = address ? addressesRepository.revealAddress(address) : null;
77
- const phone = addressPlain?.phone?.trim();
78
- if (!phone) {
79
- throw new ValidationError("The selected address has no phone number on file — send the code by email instead.");
80
- }
81
- if (!(await isChannelHealthy("whatsapp"))) {
82
- serverLogger.warn("sendCheckoutValueOtp: WhatsApp channel unhealthy — skipping OTP gate", { uid: userId, channel });
83
- return { skipped: true };
84
- }
85
- await checkoutValueOtpRef(db, userId).set({
86
- codeHash,
87
- expiresAt,
88
- attempts: 0,
89
- verified: false,
90
- createdAt: new Date(),
91
- });
92
- const message = buildCheckoutValueOtpMessage(code, siteName);
93
- // Same guard as the email branch below, same reasoning: transactional, so
94
- // the kill switch does not apply (the buyer cannot check out without the
95
- // code), but the daily ceiling does.
96
- const waGuard = {
97
- channel: "whatsapp",
98
- feature: "checkout_value_otp",
99
- audience: "user",
100
- transactional: true,
101
- };
102
- const waDecision = await guardSend(waGuard);
103
- if (!waDecision.allow) {
104
- logSuppressedSend(waGuard, waDecision);
105
- throw new ValidationError("We couldn't send the verification code via WhatsApp — please try again, or send it by email instead.");
106
- }
107
- try {
108
- const sent = await withChannelRetry("whatsapp", async () => {
109
- const ok = await sendWhatsAppBusinessMessage({ toPhone: phone, message, phoneNumberId, accessToken });
110
- if (!ok)
111
- throw new Error("WhatsApp Cloud API returned a non-OK response");
112
- return ok;
113
- });
114
- if (!sent)
115
- throw new Error("WhatsApp send failed");
116
- }
117
- catch (err) {
118
- void normalizeError(err);
119
- serverLogger.error("Failed to send checkout value OTP via WhatsApp", { uid: userId, err });
120
- throw new ValidationError("We couldn't send the verification code via WhatsApp — please try again, or send it by email instead.");
121
- }
122
- const maskedPhone = phone.length > 4 ? `${"*".repeat(phone.length - 4)}${phone.slice(-4)}` : "***";
123
- serverLogger.info(`Checkout value OTP sent via WhatsApp: uid=${userId}`);
124
- return { maskedPhone };
125
- }
126
40
  if (!(await isChannelHealthy("email"))) {
127
41
  serverLogger.warn("sendCheckoutValueOtp: email channel unhealthy — skipping OTP gate", { uid: userId, channel });
128
42
  return { skipped: true };
@@ -23,6 +23,7 @@ import { getSiteSettingsGlobal } from "../../admin/utils/getSiteSettingsGlobal.j
23
23
  import { safeRead } from "../../../errors/safe-read.js";
24
24
  import { DEFAULT_MIN_OFFER_PERCENT, offersEnabledFor, resolveOfferBounds, } from "../../../_internal/shared/features/offers/config.js";
25
25
  import { ListingBottomActions } from "../../products/components/ListingBottomActions.js";
26
+ import { productImageUrls } from "../../products/schemas/firestore.js";
26
27
  function toDescriptionHtml(raw) {
27
28
  if (!raw)
28
29
  return "";
@@ -43,7 +44,15 @@ export async function ClassifiedDetailPageView({ slug, initialProduct, renderOff
43
44
  const currency = p.currency || getDefaultCurrency();
44
45
  const title = String(p.title ?? p.name ?? "Classified Listing");
45
46
  const price = typeof p.price === "number" ? p.price : null;
46
- const images = Array.isArray(p.images) ? p.images : typeof p.mainImage === "string" ? [p.mainImage] : [];
47
+ // `images` is `ProductImage[]` since A6 — `productImageUrls` is the one
48
+ // normaliser (it also accepts the legacy `string[]`). Casting it to
49
+ // `string[]` handed the gallery objects and rendered a placeholder.
50
+ const galleryUrls = productImageUrls(p.images);
51
+ const images = galleryUrls.length > 0
52
+ ? galleryUrls
53
+ : typeof p.mainImage === "string"
54
+ ? [p.mainImage]
55
+ : [];
47
56
  const meta = product.classified;
48
57
  const location = meta?.meetupArea ? [meta.meetupArea.locality, meta.meetupArea.city].filter(Boolean).join(", ") : null;
49
58
  const category = Array.isArray(p.categorySlugs) && p.categorySlugs.length > 0 ? String(p.categorySlugs[0]) : (typeof p.category === "string" ? p.category : null);
@@ -21,6 +21,7 @@ import { GroupedListingsCarousel } from "../../grouped/components/GroupedListing
21
21
  import { getGroupsWithItemsForProduct } from "../../../_internal/server/features/grouped/data.js";
22
22
  import { ReviewsListingPanel } from "../../reviews/components/ReviewsListingPanel.js";
23
23
  import { ListingBottomActions } from "../../products/components/ListingBottomActions.js";
24
+ import { productImageUrls } from "../../products/schemas/firestore.js";
24
25
  function toDescriptionHtml(raw) {
25
26
  if (!raw)
26
27
  return "";
@@ -41,7 +42,15 @@ export async function DigitalCodeDetailPageView({ slug, initialProduct, renderPr
41
42
  const currency = p.currency || getDefaultCurrency();
42
43
  const title = String(p.title ?? p.name ?? "Digital Code");
43
44
  const price = typeof p.price === "number" ? p.price : null;
44
- const images = Array.isArray(p.images) ? p.images : typeof p.mainImage === "string" ? [p.mainImage] : [];
45
+ // `images` is `ProductImage[]` since A6 — `productImageUrls` is the one
46
+ // normaliser (it also accepts the legacy `string[]`). Casting it to
47
+ // `string[]` handed the gallery objects and rendered a placeholder.
48
+ const galleryUrls = productImageUrls(p.images);
49
+ const images = galleryUrls.length > 0
50
+ ? galleryUrls
51
+ : typeof p.mainImage === "string"
52
+ ? [p.mainImage]
53
+ : [];
45
54
  const meta = product.digitalCode;
46
55
  const codesLeft = meta?.codesAvailable ?? 0;
47
56
  const category = Array.isArray(p.categorySlugs) && p.categorySlugs.length > 0 ? String(p.categorySlugs[0]) : (typeof p.category === "string" ? p.category : null);
@@ -12,6 +12,16 @@ interface EventCardProps {
12
12
  selectable?: boolean;
13
13
  isSelected?: boolean;
14
14
  onSelect?: (id: string, selected: boolean) => void;
15
+ /**
16
+ * Owner-only pencil / clone / bin, for an admin or whoever owns this record.
17
+ * Pass `<OwnerQuickActions>`; it renders nothing for anyone else.
18
+ *
19
+ * 🛑 Rendered OUTSIDE this card's own `<Link>`/`<TextLink>`, as a sibling.
20
+ * A `<button>` inside an `<a>` is invalid HTML that browsers resolve by
21
+ * closing the anchor early, which breaks the CARD's navigation rather than
22
+ * just the button's — `stopPropagation` stops a click but cannot fix a parse.
23
+ */
24
+ renderOwnerActions?: () => React.ReactNode;
15
25
  }
16
- export declare function EventCard({ event, labels, onParticipate, className, selectable, isSelected, onSelect, }: EventCardProps): import("react").JSX.Element;
26
+ export declare function EventCard({ event, labels, onParticipate, className, selectable, isSelected, onSelect, renderOwnerActions, }: EventCardProps): import("react").JSX.Element;
17
27
  export {};
@@ -24,7 +24,7 @@ const TYPE_ICONS = {
24
24
  spin_wheel: "🎡",
25
25
  lottery: "🎰",
26
26
  };
27
- export function EventCard({ event, labels = {}, onParticipate, className = "", selectable = false, isSelected = false, onSelect, }) {
27
+ export function EventCard({ event, labels = {}, onParticipate, className = "", selectable = false, isSelected = false, onSelect, renderOwnerActions, }) {
28
28
  const longPress = useLongPress(() => onSelect?.(event.id, !isSelected));
29
29
  const safeTitle = event.title?.trim() || "Untitled event";
30
30
  const now = new Date();
@@ -32,6 +32,13 @@ export function EventCard({ event, labels = {}, onParticipate, className = "", s
32
32
  const msLeft = endsAt.getTime() - now.getTime();
33
33
  const daysLeft = Math.max(0, Math.ceil(msLeft / (1000 * 60 * 60 * 24)));
34
34
  const detailHref = String(ROUTES.PUBLIC.EVENT_DETAIL(event.slug ?? event.id));
35
- return (_jsxs(Stack, { as: "article", border: "default", rounded: "xl", shadow: "hover-md", gap: "none", className: `group relative h-full overflow-hidden bg-[var(--appkit-color-surface)] ${isSelected ? "border-primary outline outline-2 outline-primary" : " "} ${className}`, onMouseDown: onSelect && !isSelected ? longPress.onMouseDown : undefined, onMouseUp: onSelect && !isSelected ? longPress.onMouseUp : undefined, onMouseLeave: onSelect && !isSelected ? longPress.onMouseLeave : undefined, onTouchStart: onSelect && !isSelected ? longPress.onTouchStart : undefined, onTouchEnd: onSelect && !isSelected ? longPress.onTouchEnd : undefined, onTouchCancel: onSelect && !isSelected ? longPress.onTouchCancel : undefined, children: [onSelect && (_jsx(BaseListingCard.Checkbox, { selected: isSelected, onSelect: (e) => { e.preventDefault(); onSelect(event.id, !isSelected); }, label: isSelected ? "Deselect event" : "Select event", position: "top-2 left-2", className: selectable || isSelected ? "opacity-100" : "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity" })), _jsx(Link, { href: detailHref, className: "block flex-shrink-0", children: event.coverImageUrl || event.coverImage?.url ? (_jsx(Div, { className: `relative aspect-video ${__O.hidden}`, children: _jsx(MediaImage, { src: event.coverImageUrl || event.coverImage?.url || "", alt: safeTitle, size: "card", className: "transition-transform duration-300 group-hover:scale-105" }) })) : (_jsx(Row, { surface: "muted", className: "aspect-video", align: "center", justify: "center", children: _jsx(Span, { className: "opacity-40", size: "5xl", "aria-hidden": "true", children: TYPE_ICONS[event.type] }) })) }), _jsxs(Stack, { className: `flex-1 ${__P.p4}`, children: [_jsxs(Row, { className: "mb-2", align: "start", justify: "between", gap: "sm", children: [_jsx(Span, { size: "lg", "aria-hidden": "true", children: TYPE_ICONS[event.type] }), _jsx(EventStatusBadge, { status: event.status })] }), _jsx(Link, { href: detailHref, className: "block", children: _jsx(Heading, { level: 3, className: "text-[var(--appkit-color-text)] leading-snug mb-1 group-hover:text-primary transition-colors", size: "base", weight: "semibold", children: safeTitle }) }), _jsx(RichText, { html: normalizeRichTextHtml(event.description ?? ""), proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "mb-3 line-clamp-3 text-[length:var(--appkit-text-sm)] text-[var(--appkit-color-text-muted)]" }), _jsxs(Row, { color: "muted", textSize: "xs", className: "mb-3 mt-auto", align: "center", justify: "between", children: [event.status === EVENT_FIELDS.STATUS_VALUES.ACTIVE &&
35
+ const card = (_jsxs(Stack, { as: "article", border: "default", rounded: "xl", shadow: "hover-md", gap: "none", className: `group relative h-full overflow-hidden bg-[var(--appkit-color-surface)] ${isSelected ? "border-primary outline outline-2 outline-primary" : " "} ${className}`, onMouseDown: onSelect && !isSelected ? longPress.onMouseDown : undefined, onMouseUp: onSelect && !isSelected ? longPress.onMouseUp : undefined, onMouseLeave: onSelect && !isSelected ? longPress.onMouseLeave : undefined, onTouchStart: onSelect && !isSelected ? longPress.onTouchStart : undefined, onTouchEnd: onSelect && !isSelected ? longPress.onTouchEnd : undefined, onTouchCancel: onSelect && !isSelected ? longPress.onTouchCancel : undefined, children: [onSelect && (_jsx(BaseListingCard.Checkbox, { selected: isSelected, onSelect: (e) => { e.preventDefault(); onSelect(event.id, !isSelected); }, label: isSelected ? "Deselect event" : "Select event", position: "top-2 left-2", className: selectable || isSelected ? "opacity-100" : "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity" })), _jsx(Link, { href: detailHref, className: "block flex-shrink-0", children: event.coverImageUrl || event.coverImage?.url ? (_jsx(Div, { className: `relative aspect-video ${__O.hidden}`, children: _jsx(MediaImage, { src: event.coverImageUrl || event.coverImage?.url || "", alt: safeTitle, size: "card", className: "transition-transform duration-300 group-hover:scale-105" }) })) : (_jsx(Row, { surface: "muted", className: "aspect-video", align: "center", justify: "center", children: _jsx(Span, { className: "opacity-40", size: "5xl", "aria-hidden": "true", children: TYPE_ICONS[event.type] }) })) }), _jsxs(Stack, { className: `flex-1 ${__P.p4}`, children: [_jsxs(Row, { className: "mb-2", align: "start", justify: "between", gap: "sm", children: [_jsx(Span, { size: "lg", "aria-hidden": "true", children: TYPE_ICONS[event.type] }), _jsx(EventStatusBadge, { status: event.status })] }), _jsx(Link, { href: detailHref, className: "block", children: _jsx(Heading, { level: 3, className: "text-[var(--appkit-color-text)] leading-snug mb-1 group-hover:text-primary transition-colors", size: "base", weight: "semibold", children: safeTitle }) }), _jsx(RichText, { html: normalizeRichTextHtml(event.description ?? ""), proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "mb-3 line-clamp-3 text-[length:var(--appkit-text-sm)] text-[var(--appkit-color-text-muted)]" }), _jsxs(Row, { color: "muted", textSize: "xs", className: "mb-3 mt-auto", align: "center", justify: "between", children: [event.status === EVENT_FIELDS.STATUS_VALUES.ACTIVE &&
36
36
  daysLeft > 0 && _jsxs(Span, { children: ["\u23F1 ", daysLeft, "d remaining"] }), _jsxs(Span, { children: ["\uD83D\uDC65 ", event.stats.totalEntries, " ", labels.entries ?? "entries"] })] }), event.status === EVENT_FIELDS.STATUS_VALUES.ACTIVE && onParticipate ? (_jsx(Button, { rounded: "lg", type: "button", onClick: () => onParticipate(event), className: "w-full bg-primary py-[var(--appkit-space-2)] text-[length:var(--appkit-text-sm)] font-medium text-white transition-colors hover:bg-primary-600", children: labels.participate ?? "Participate" })) : (_jsxs(TextLink, { rounded: "lg", paddingX: "sm", paddingY: "xs", href: detailHref, layout: "inline-flex", align: "center", justify: "center", gap: "xs", className: "w-full border border-[var(--appkit-color-border)] transition-colors hover:bg-[var(--appkit-color-bg)]", color: "primary", size: "sm", weight: "medium", children: [labels.viewDetails ?? "View details", " \u2192"] }))] })] }));
37
+ if (!renderOwnerActions)
38
+ return card;
39
+ /*
40
+ * Sibling of the card, never a child — see the prop's docstring. `z-20`
41
+ * beats this card's own overlays, which all sit at `z-10`.
42
+ */
43
+ return (_jsxs(Div, { className: "relative h-full", children: [card, _jsx(Div, { className: "absolute right-2 top-2 z-20", children: renderOwnerActions() })] }));
37
44
  }
@@ -20,6 +20,7 @@ import { useVisualViewportInset } from "../../react/hooks/useVisualViewportInset
20
20
  import { isBuyerUser } from "../auth/role-predicates.js";
21
21
  import { NavbarWithSettings } from "./NavbarWithSettings.js";
22
22
  import SideActionDock from "./SideActionDock.js";
23
+ import BottomActions from "./BottomActions.js";
23
24
  import { BottomChrome } from "./BottomChrome.js";
24
25
  import { AutoBreadcrumbs } from "./AutoBreadcrumbs.js";
25
26
  import { BottomNavbar } from "./BottomNavbar.js";
@@ -259,5 +260,5 @@ export function AppLayoutShell({ children, navItems, sidebarItems = [], sidebarS
259
260
  // is 0 below `lg` and 0 whenever no bar is published, so this
260
261
  // reserves exactly nothing until there is something to reserve —
261
262
  // which is why it can be unconditional rather than route-gated.
262
- className: "w-full flex-1 flex flex-col pt-[var(--top-chrome-height,0px)] transition-[padding-top] duration-300 ease-out", children: _jsx(Div, { padding: "y-lg", className: `flex-1 ${contentClassName ?? "mx-auto w-full max-w-screen-xl px-[var(--appkit-space-5)] md:px-[var(--appkit-space-6)] lg:px-[var(--appkit-space-8)]"}`, children: children }) })] }), _jsx(BackToTop, {}), _jsx(FooterLayout, { ...footer }), _jsx(BottomChrome, { children: null }), _jsx(SideActionDock, {}), showBottomNav && (_jsx(BottomNavbar, { user: user, userId: userId, homeHref: homeHref, shopHref: shopHref, cartHref: cartHref, wishlistHref: wishlistHref, profileHref: profileHref, loginHref: loginHref, onSearchToggle: () => setSearchOpen((prev) => !prev), navItems: navItems, onMoreToggle: handleTogglePublicSidebar })), _jsx(UnsavedChangesModal, {})] }) }));
263
+ className: "w-full flex-1 flex flex-col pt-[var(--top-chrome-height,0px)] transition-[padding-top] duration-300 ease-out", children: _jsx(Div, { padding: "y-lg", className: `flex-1 ${contentClassName ?? "mx-auto w-full max-w-screen-xl px-[var(--appkit-space-5)] md:px-[var(--appkit-space-6)] lg:px-[var(--appkit-space-8)]"}`, children: children }) })] }), _jsx(BackToTop, {}), _jsx(FooterLayout, { ...footer }), _jsx(BottomChrome, { children: _jsx(BottomActions, {}) }), _jsx(SideActionDock, {}), showBottomNav && (_jsx(BottomNavbar, { user: user, userId: userId, homeHref: homeHref, shopHref: shopHref, cartHref: cartHref, wishlistHref: wishlistHref, profileHref: profileHref, loginHref: loginHref, onSearchToggle: () => setSearchOpen((prev) => !prev), navItems: navItems, onMoreToggle: handleTogglePublicSidebar })), _jsx(UnsavedChangesModal, {})] }) }));
263
264
  }
@@ -0,0 +1 @@
1
+ export default function BottomActions(): import("react").JSX.Element | null;