@mohasinac/appkit 4.2.0 → 4.3.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 (243) hide show
  1. package/dist/_internal/server/features/auth/actions.d.ts +0 -1
  2. package/dist/_internal/server/features/auth/actions.js +0 -1
  3. package/dist/_internal/server/features/bundles/data.d.ts +22 -4
  4. package/dist/_internal/server/features/bundles/data.js +41 -4
  5. package/dist/_internal/server/features/bundles/index.d.ts +1 -1
  6. package/dist/_internal/server/features/bundles/index.js +1 -1
  7. package/dist/_internal/server/features/checkout/actions.js +7 -78
  8. package/dist/_internal/server/features/checkout/prize-bundle-gates.d.ts +5 -16
  9. package/dist/_internal/server/features/checkout/prize-bundle-gates.js +5 -30
  10. package/dist/_internal/server/features/orders/adapters.js +1 -14
  11. package/dist/_internal/server/features/products/actions.js +8 -3
  12. package/dist/_internal/server/features/products/service.d.ts +15 -0
  13. package/dist/_internal/server/features/products/service.js +41 -0
  14. package/dist/_internal/server/functions/firestore.d.ts +3 -1
  15. package/dist/_internal/server/functions/firestore.js +17 -1
  16. package/dist/_internal/server/functions/scheduled.d.ts +2 -5
  17. package/dist/_internal/server/functions/scheduled.js +6 -30
  18. package/dist/_internal/server/jobs/core/bundleStockSync.js +25 -7
  19. package/dist/_internal/server/jobs/core/countersReconcile.js +7 -1
  20. package/dist/_internal/server/jobs/core/index.d.ts +4 -4
  21. package/dist/_internal/server/jobs/core/index.js +4 -4
  22. package/dist/_internal/server/jobs/core/onPrizeDrawPaymentConfirmed.d.ts +19 -0
  23. package/dist/_internal/server/jobs/core/onPrizeDrawPaymentConfirmed.js +30 -0
  24. package/dist/_internal/server/jobs/core/paymentWindowTimeout.d.ts +2 -2
  25. package/dist/_internal/server/jobs/core/paymentWindowTimeout.js +2 -2
  26. package/dist/_internal/server/jobs/core/prizeDrawAssignWinner.d.ts +20 -0
  27. package/dist/_internal/server/jobs/core/prizeDrawAssignWinner.js +113 -0
  28. package/dist/_internal/server/jobs/core/prizeDrawExpiryReveal.d.ts +14 -0
  29. package/dist/_internal/server/jobs/core/prizeDrawExpiryReveal.js +74 -0
  30. package/dist/_internal/server/jobs/core/prizeDrawSoldOutReveal.d.ts +22 -0
  31. package/dist/_internal/server/jobs/core/prizeDrawSoldOutReveal.js +68 -0
  32. package/dist/_internal/server/jobs/handlers/index.d.ts +3 -4
  33. package/dist/_internal/server/jobs/handlers/index.js +8 -6
  34. package/dist/_internal/server/jobs/handlers/onPrizeDrawPaymentConfirmed.d.ts +3 -0
  35. package/dist/_internal/server/jobs/handlers/onPrizeDrawPaymentConfirmed.js +11 -0
  36. package/dist/_internal/server/jobs/handlers/prizeDrawExpiryReveal.d.ts +2 -0
  37. package/dist/_internal/server/jobs/handlers/prizeDrawExpiryReveal.js +2 -0
  38. package/dist/_internal/server/jobs/handlers/prizeDrawSoldOutReveal.d.ts +3 -0
  39. package/dist/_internal/server/jobs/handlers/prizeDrawSoldOutReveal.js +4 -0
  40. package/dist/_internal/shared/actions/action-registry.js +40 -10
  41. package/dist/_internal/shared/checkout/rules/_defaults.js +1 -2
  42. package/dist/_internal/shared/checkout/rules/_limits.d.ts +3 -0
  43. package/dist/_internal/shared/checkout/rules/_limits.js +3 -0
  44. package/dist/_internal/shared/checkout/rules/_registry.d.ts +2 -1
  45. package/dist/_internal/shared/checkout/rules/_registry.js +2 -2
  46. package/dist/_internal/shared/checkout/rules/classified.rule.js +0 -1
  47. package/dist/_internal/shared/checkout/rules/index.d.ts +1 -1
  48. package/dist/_internal/shared/checkout/rules/index.js +1 -1
  49. package/dist/_internal/shared/checkout/rules/prize-draw.rule.js +20 -1
  50. package/dist/_internal/shared/checkout/rules/types.d.ts +17 -7
  51. package/dist/_internal/shared/features/categories/bundle-copy.d.ts +2 -0
  52. package/dist/_internal/shared/features/categories/bundle-copy.js +2 -0
  53. package/dist/_internal/shared/features/categories/bundle-pricing.d.ts +23 -0
  54. package/dist/_internal/shared/features/categories/bundle-pricing.js +25 -0
  55. package/dist/_internal/shared/listing-types/prize-draw/config.js +1 -1
  56. package/dist/constants/api-endpoints.d.ts +6 -9
  57. package/dist/constants/api-endpoints.js +5 -5
  58. package/dist/constants/field-names.d.ts +17 -1
  59. package/dist/constants/field-names.js +17 -1
  60. package/dist/features/account/hooks/useNotifications.js +5 -0
  61. package/dist/features/admin/actions/admin-actions.js +6 -0
  62. package/dist/features/admin/actions/notification-actions.js +1 -2
  63. package/dist/features/admin/components/AdminBlogEditorView.js +1 -1
  64. package/dist/features/admin/components/AdminPrizeDrawsView.js +3 -3
  65. package/dist/features/admin/components/AdminSiteSettingsView.js +33 -6
  66. package/dist/features/admin/components/AdminTesterChecklistView.js +74 -22
  67. package/dist/features/admin/hooks/useAdminListing.d.ts +4 -0
  68. package/dist/features/admin/hooks/useAdminListing.js +4 -2
  69. package/dist/features/admin/schemas/firestore.d.ts +13 -5
  70. package/dist/features/admin/schemas/firestore.js +1 -2
  71. package/dist/features/auctions/actions/bid-actions.js +3 -1
  72. package/dist/features/auctions/components/AuctionBidsTable.d.ts +6 -1
  73. package/dist/features/auctions/components/AuctionBidsTable.js +8 -4
  74. package/dist/features/auctions/components/AuctionDetailPageView.js +10 -13
  75. package/dist/features/auctions/components/CollapsibleBidHistory.d.ts +23 -2
  76. package/dist/features/auctions/components/CollapsibleBidHistory.js +21 -3
  77. package/dist/features/auctions/components/LiveBidPrice.d.ts +19 -0
  78. package/dist/features/auctions/components/LiveBidPrice.js +17 -0
  79. package/dist/features/auctions/components/PlaceBidFormClient.d.ts +1 -1
  80. package/dist/features/auctions/components/PlaceBidFormClient.js +16 -1
  81. package/dist/features/auctions/hooks/useBids.d.ts +33 -0
  82. package/dist/features/auctions/hooks/useBids.js +32 -0
  83. package/dist/features/auctions/hooks/useLiveAuctionBid.d.ts +20 -0
  84. package/dist/features/auctions/hooks/useLiveAuctionBid.js +23 -0
  85. package/dist/features/auth/checkout-value-otp.d.ts +7 -10
  86. package/dist/features/auth/checkout-value-otp.js +14 -10
  87. package/dist/features/auth/server.d.ts +0 -1
  88. package/dist/features/auth/server.js +0 -1
  89. package/dist/features/blog/api/[slug]/route.d.ts +5 -0
  90. package/dist/features/blog/api/[slug]/route.js +12 -3
  91. package/dist/features/blog/components/BlogPostView.d.ts +2 -0
  92. package/dist/features/blog/components/BlogPostView.js +5 -4
  93. package/dist/features/blog/hooks/useBlog.d.ts +2 -0
  94. package/dist/features/blog/hooks/useBlog.js +2 -0
  95. package/dist/features/blog/repository/blog.repository.d.ts +7 -0
  96. package/dist/features/blog/repository/blog.repository.js +44 -0
  97. package/dist/features/blog/schemas/firestore.d.ts +3 -0
  98. package/dist/features/blog/schemas/firestore.js +1 -0
  99. package/dist/features/blog/types/index.d.ts +2 -0
  100. package/dist/features/cart/components/index.d.ts +0 -2
  101. package/dist/features/cart/components/index.js +0 -1
  102. package/dist/features/cart/hooks/useCartCount.js +22 -5
  103. package/dist/features/cart/hooks/useGuestCart.js +11 -1
  104. package/dist/features/cart/utils/guest-cart.d.ts +4 -0
  105. package/dist/features/cart/utils/guest-cart.js +10 -0
  106. package/dist/features/cart/utils/pending-ops.d.ts +17 -0
  107. package/dist/features/cart/utils/pending-ops.js +46 -5
  108. package/dist/features/categories/components/BrandDetailPageView.js +6 -2
  109. package/dist/features/categories/components/BundleDetailView.js +4 -2
  110. package/dist/features/categories/components/CategoryDetailPageView.js +10 -2
  111. package/dist/features/categories/schemas/firestore.d.ts +7 -0
  112. package/dist/features/checkout/actions/index.d.ts +0 -1
  113. package/dist/features/checkout/actions/index.js +0 -1
  114. package/dist/features/checkout/schemas/firestore.d.ts +2 -2
  115. package/dist/features/events/actions/event-actions.d.ts +2 -0
  116. package/dist/features/events/actions/event-actions.js +5 -0
  117. package/dist/features/events/components/EventCard.js +1 -2
  118. package/dist/features/events/components/RelatedEventsCarousel.d.ts +15 -0
  119. package/dist/features/events/components/RelatedEventsCarousel.js +13 -0
  120. package/dist/features/events/components/index.d.ts +2 -0
  121. package/dist/features/events/components/index.js +1 -0
  122. package/dist/features/events/repository/events.repository.d.ts +6 -0
  123. package/dist/features/events/repository/events.repository.js +21 -0
  124. package/dist/features/history/repository/user-history.repository.d.ts +1 -1
  125. package/dist/features/history/utils/guest-history.d.ts +1 -1
  126. package/dist/features/homepage/components/FeaturedBundlesSection.js +4 -2
  127. package/dist/features/homepage/components/FeaturedProductsSection.js +2 -2
  128. package/dist/features/homepage/components/SectionCarousel.js +1 -1
  129. package/dist/features/homepage/components/WelcomeSection.js +1 -1
  130. package/dist/features/layout/AppLayoutShell.js +1 -1
  131. package/dist/features/layout/NavbarLayout.js +1 -1
  132. package/dist/features/layout/TitleBarLayout.js +2 -2
  133. package/dist/features/media/MediaVideo.d.ts +7 -0
  134. package/dist/features/media/MediaVideo.js +36 -5
  135. package/dist/features/orders/components/OrdersList.js +1 -5
  136. package/dist/features/orders/schemas/firestore.d.ts +2 -8
  137. package/dist/features/orders/schemas/index.d.ts +3 -32
  138. package/dist/features/orders/schemas/index.js +1 -4
  139. package/dist/features/orders/types/index.d.ts +6 -9
  140. package/dist/features/pre-orders/components/MarketplacePreorderCard.js +1 -1
  141. package/dist/features/pre-orders/components/PreOrderDetailPageView.js +2 -1
  142. package/dist/features/products/actions/product-actions.js +13 -10
  143. package/dist/features/products/components/MarketplaceBundleCard.d.ts +1 -1
  144. package/dist/features/products/components/MarketplaceBundleCard.js +7 -4
  145. package/dist/features/products/components/PrizeDrawDetailPageView.js +1 -1
  146. package/dist/features/products/components/PrizeDrawItemsEditor.js +2 -2
  147. package/dist/features/products/components/PrizeDrawWinnerMappingView.d.ts +14 -0
  148. package/dist/features/products/components/PrizeDrawWinnerMappingView.js +37 -0
  149. package/dist/features/products/components/PrizeRevealModal.d.ts +10 -23
  150. package/dist/features/products/components/PrizeRevealModal.js +18 -147
  151. package/dist/features/products/components/ProductDetailPageView.js +72 -30
  152. package/dist/features/products/components/ProductForm.js +7 -14
  153. package/dist/features/products/components/ProductGalleryClient.d.ts +10 -1
  154. package/dist/features/products/components/ProductGalleryClient.js +29 -10
  155. package/dist/features/products/components/ProductsIndexListing.js +2 -1
  156. package/dist/features/products/components/RelatedProductsCarousel.js +2 -2
  157. package/dist/features/products/components/ShowGroupSection.js +2 -2
  158. package/dist/features/products/components/SublistingCarouselSection.js +1 -1
  159. package/dist/features/products/components/index.d.ts +4 -2
  160. package/dist/features/products/components/index.js +1 -0
  161. package/dist/features/products/constants/action-defs.d.ts +0 -1
  162. package/dist/features/products/constants/action-defs.js +0 -2
  163. package/dist/features/products/repository/products.repository.d.ts +6 -0
  164. package/dist/features/products/repository/products.repository.js +20 -1
  165. package/dist/features/products/schemas/firestore.d.ts +26 -2
  166. package/dist/features/products/schemas/index.d.ts +11 -3
  167. package/dist/features/products/schemas/index.js +10 -2
  168. package/dist/features/products/types/index.d.ts +2 -1
  169. package/dist/features/reviews/actions/review-actions.js +1 -0
  170. package/dist/features/reviews/components/ReviewDetailPageView.js +8 -1
  171. package/dist/features/reviews/components/ReviewDetailShell.js +2 -1
  172. package/dist/features/reviews/components/ReviewsList.js +2 -1
  173. package/dist/features/reviews/schemas/firestore.d.ts +3 -0
  174. package/dist/features/reviews/schemas/index.d.ts +6 -0
  175. package/dist/features/reviews/schemas/index.js +2 -0
  176. package/dist/features/reviews/types/index.d.ts +3 -0
  177. package/dist/features/scams/actions/scam-actions.d.ts +3 -0
  178. package/dist/features/scams/actions/scam-actions.js +3 -2
  179. package/dist/features/scams/components/ScamProfileView.d.ts +2 -1
  180. package/dist/features/scams/components/ScamProfileView.js +2 -2
  181. package/dist/features/scams/repository/scammer.repository.d.ts +6 -0
  182. package/dist/features/scams/repository/scammer.repository.js +24 -0
  183. package/dist/features/seller/actions/seller-actions.js +8 -0
  184. package/dist/features/seller/components/SellerPrizeDrawsView.js +4 -0
  185. package/dist/features/stores/components/StoreDetailLayoutView.js +6 -6
  186. package/dist/features/stores/components/StoreNavTabs.d.ts +10 -1
  187. package/dist/features/stores/components/StoreNavTabs.js +21 -9
  188. package/dist/features/tester/actions/index.d.ts +1 -0
  189. package/dist/features/tester/actions/index.js +1 -0
  190. package/dist/features/tester/actions/leaderboard-actions.d.ts +3 -0
  191. package/dist/features/tester/actions/leaderboard-actions.js +5 -0
  192. package/dist/features/tester/components/AdminTesterFeedbackIssuesView.js +26 -4
  193. package/dist/features/tester/components/AdminTesterFeedbackListView.js +13 -0
  194. package/dist/features/tester/components/BugHunterLeaderboardView.d.ts +23 -0
  195. package/dist/features/tester/components/BugHunterLeaderboardView.js +18 -0
  196. package/dist/features/tester/components/index.d.ts +2 -0
  197. package/dist/features/tester/components/index.js +1 -0
  198. package/dist/features/tester/repository/tester-checklist-item.repository.d.ts +12 -1
  199. package/dist/features/tester/repository/tester-checklist-item.repository.js +80 -0
  200. package/dist/features/tester/repository/tester-checklist-response.repository.d.ts +4 -0
  201. package/dist/features/tester/repository/tester-checklist-response.repository.js +4 -0
  202. package/dist/features/tester/schemas/firestore.d.ts +22 -1
  203. package/dist/features/tester/schemas/firestore.js +8 -0
  204. package/dist/features/tester/seed-data/categories-tester-seed-data.js +42 -7
  205. package/dist/features/tester/seed-data/events-tester-seed-data.js +23 -0
  206. package/dist/features/tester/seed-data/orders-tester-seed-data.js +69 -31
  207. package/dist/features/tester/seed-data/products-tester-seed-data.js +152 -8
  208. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +249 -14
  209. package/dist/features/wishlist/hooks/useGuestWishlist.js +11 -1
  210. package/dist/features/wishlist/hooks/useWishlistCount.js +16 -2
  211. package/dist/features/wishlist/utils/guest-wishlist.d.ts +5 -0
  212. package/dist/features/wishlist/utils/guest-wishlist.js +11 -0
  213. package/dist/index.d.ts +19 -22
  214. package/dist/index.js +25 -58
  215. package/dist/jobs.d.ts +1 -1
  216. package/dist/jobs.js +2 -2
  217. package/dist/next/routing/route-map.d.ts +2 -0
  218. package/dist/next/routing/route-map.js +1 -0
  219. package/dist/schemas/registry.d.ts +6 -26
  220. package/dist/seed/bids-seed-data.js +14 -2
  221. package/dist/seed/blog-posts-seed-data.js +2 -1
  222. package/dist/seed/categories-seed-data.js +11 -2
  223. package/dist/seed/events-seed-data.js +11 -11
  224. package/dist/seed/faq-seed-data.js +5 -1
  225. package/dist/seed/products-auctions-seed-data.js +2 -2
  226. package/dist/seed/products-preorders-seed-data.d.ts +2 -1
  227. package/dist/seed/products-prize-draws-seed-data.js +56 -10
  228. package/dist/seed/products-standard-seed-data.js +82 -3
  229. package/dist/seed/scammers-seed-data.js +75 -2
  230. package/dist/seed/site-settings-seed-data.js +5 -2
  231. package/dist/server-entry.d.ts +0 -1
  232. package/dist/server-entry.js +0 -1
  233. package/dist/server.d.ts +4 -20
  234. package/dist/server.js +10 -57
  235. package/dist/styles.css +1 -1
  236. package/dist/tailwind-utilities.css +1 -1
  237. package/dist/ui/components/BaseListingCard.js +1 -1
  238. package/dist/ui/components/ImageLightbox.d.ts +9 -0
  239. package/dist/ui/components/ImageLightbox.js +6 -3
  240. package/dist/ui/components/ListingToolbar.d.ts +18 -1
  241. package/dist/ui/components/ListingToolbar.js +5 -2
  242. package/dist/ui/components/StickyToolbar.js +13 -8
  243. package/package.json +1 -1
@@ -12,6 +12,8 @@ const CLS_STOCK_OK = "bg-success-surface text-white";
12
12
  const CLS_VIEW_BTN = "mt-2 w-full cursor-pointer rounded-md bg-violet-600 py-[var(--appkit-space-1-5)] text-center text-[length:var(--appkit-text-xs)] font-semibold text-white transition-colors hover:bg-violet-700 active:scale-[0.98]";
13
13
  import { BaseListingCard, Div, Row, Span, Stack, Text, TextLink } from "../../../ui";
14
14
  import { MediaImage } from "../../media/MediaImage";
15
+ import { computeBundleDiscount } from "../../../_internal/shared/features/categories/bundle-pricing";
16
+ import { BUNDLE_COPY } from "../../../_internal/shared/features/categories/bundle-copy";
15
17
  const DEFAULT_LABELS = {
16
18
  bundleBadge: "Bundle",
17
19
  inStockBadge: "In stock",
@@ -34,6 +36,7 @@ export function MarketplaceBundleCard({ bundle, className = "", variant = "grid"
34
36
  const stock = bundle.bundleStockStatus ?? "in_stock";
35
37
  const cover = bundle.display?.coverImage;
36
38
  const price = bundle.bundlePrice;
39
+ const discount = computeBundleDiscount(bundle.bundlePrice, bundle.bundleOriginalTotal);
37
40
  const collageTiles = (bundle.bundleItemDetails ?? [])
38
41
  .filter((d) => Boolean(d.imageURL))
39
42
  .slice(0, 4);
@@ -46,13 +49,13 @@ export function MarketplaceBundleCard({ bundle, className = "", variant = "grid"
46
49
  }
47
50
  router.push(String(detailHref));
48
51
  }, [detailHref, onNavigate, router]);
49
- return (_jsxs(BaseListingCard, { isSelected: isSelected, variant: variant, className: className, onMouseDown: !isSelected ? longPress.onMouseDown : undefined, onMouseUp: !isSelected ? longPress.onMouseUp : undefined, onMouseLeave: !isSelected ? longPress.onMouseLeave : undefined, onTouchStart: !isSelected ? longPress.onTouchStart : undefined, onTouchEnd: !isSelected ? longPress.onTouchEnd : undefined, onTouchCancel: !isSelected ? longPress.onTouchCancel : undefined, children: [_jsxs(BaseListingCard.Hero, { aspect: "square", variant: variant, children: [_jsx(TextLink, { href: String(detailHref), className: "absolute inset-0 block", children: showCollage ? (_jsx(Div, { layout: "grid", className: `h-full w-full gap-[0.125rem] bg-[var(--appkit-color-surface-muted)] ${collageTiles.length === 2 ? "grid-cols-2 grid-rows-1" : "grid-cols-2 grid-rows-2"}`, children: collageTiles.map((tile, i) => (_jsxs(Div, { className: "relative overflow-hidden bg-[var(--appkit-color-surface-muted)]", children: [_jsx(MediaImage, { src: tile.imageURL, alt: tile.title ?? `${bundle.name} item ${i + 1}`, size: "card" }), i === collageTiles.length - 1 && overflow > 0 && (_jsxs(Row, { textWeight: "semibold", textSize: "sm", surface: "overlay-md", className: "absolute inset-0 text-white", align: "center", justify: "center", children: ["+", overflow] }))] }, `${tile.productId}-${i}`))) })) : cover ? (_jsx(Div, { className: "absolute inset-0 bg-[var(--appkit-color-surface-muted)]", children: _jsx(MediaImage, { src: cover, alt: bundle.name, size: "card" }) })) : (_jsx(Row, { className: "absolute inset-0 bg-[var(--appkit-color-surface-muted)]", align: "center", justify: "center", children: _jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "xs", children: "No image" }) })) }), _jsxs(Stack, { className: "absolute right-2 top-2", align: "end", gap: "xs", children: [_jsx(Span, { size: "xs", weight: "medium", className: CLS_BUNDLE_PILL, children: mergedLabels.bundleBadge }), _jsx(Span, { layout: "inline-flex", color: "inverse", className: `${stock === "out_of_stock" ? "bg-neutral-500 " : CLS_STOCK_OK}`, rounded: "full", padding: "pill-xs", size: "xs", weight: "medium", children: stock === "out_of_stock" ? mergedLabels.outOfStockBadge : mergedLabels.inStockBadge })] }), stock === "out_of_stock" && (_jsx(Row, { surface: "overlay-xs", className: "absolute inset-0", align: "center", justify: "center", children: _jsx(Span, { color: "inverse", size: "xs", weight: "bold", className: "bg-neutral-900/80 tracking-wider", rounded: "default", padding: "pill-md", transform: "uppercase", children: mergedLabels.outOfStockBadge }) })), onSelect && (_jsx(BaseListingCard.Checkbox, { selected: isSelected, onSelect: (event) => {
52
+ return (_jsxs(BaseListingCard, { isSelected: isSelected, variant: variant, className: className, onMouseDown: !isSelected ? longPress.onMouseDown : undefined, onMouseUp: !isSelected ? longPress.onMouseUp : undefined, onMouseLeave: !isSelected ? longPress.onMouseLeave : undefined, onTouchStart: !isSelected ? longPress.onTouchStart : undefined, onTouchEnd: !isSelected ? longPress.onTouchEnd : undefined, onTouchCancel: !isSelected ? longPress.onTouchCancel : undefined, children: [_jsxs(BaseListingCard.Hero, { aspect: "square", variant: variant, children: [_jsx(TextLink, { href: String(detailHref), className: "absolute inset-0 block", children: showCollage ? (_jsx(Div, { layout: "grid", className: `h-full w-full gap-[0.125rem] bg-[var(--appkit-color-surface-muted)] ${collageTiles.length === 2 ? "grid-cols-2 grid-rows-1" : "grid-cols-2 grid-rows-2"}`, children: collageTiles.map((tile, i) => (_jsxs(Div, { className: "relative overflow-hidden bg-[var(--appkit-color-surface-muted)]", children: [_jsx(MediaImage, { src: tile.imageURL, alt: tile.title ?? `${bundle.name} item ${i + 1}`, size: "card" }), i === collageTiles.length - 1 && overflow > 0 && (_jsxs(Row, { textWeight: "semibold", textSize: "sm", surface: "overlay-md", className: "absolute inset-0 text-white", align: "center", justify: "center", children: ["+", overflow] }))] }, `${tile.productId}-${i}`))) })) : cover ? (_jsx(Div, { className: "absolute inset-0 bg-[var(--appkit-color-surface-muted)]", children: _jsx(MediaImage, { src: cover, alt: bundle.name, size: "card" }) })) : (_jsx(Row, { className: "absolute inset-0 bg-[var(--appkit-color-surface-muted)]", align: "center", justify: "center", children: _jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "xs", children: "No image" }) })) }), discount && (_jsx(Span, { size: "xs", weight: "bold", color: "inverse", className: "absolute left-2 top-2 bg-success-surface", rounded: "full", padding: "pill-xs", children: BUNDLE_COPY.detail.discountBadge(discount.percent) })), _jsxs(Stack, { className: "absolute right-2 top-2", align: "end", gap: "xs", children: [_jsx(Span, { size: "xs", weight: "medium", className: CLS_BUNDLE_PILL, children: mergedLabels.bundleBadge }), _jsx(Span, { layout: "inline-flex", color: "inverse", className: `${stock === "out_of_stock" ? "bg-neutral-500 " : CLS_STOCK_OK}`, rounded: "full", padding: "pill-xs", size: "xs", weight: "medium", children: stock === "out_of_stock" ? mergedLabels.outOfStockBadge : mergedLabels.inStockBadge })] }), stock === "out_of_stock" && (_jsx(Row, { surface: "overlay-xs", className: "absolute inset-0", align: "center", justify: "center", children: _jsx(Span, { color: "inverse", size: "xs", weight: "bold", className: "bg-neutral-900/80 tracking-wider", rounded: "default", padding: "pill-md", transform: "uppercase", children: mergedLabels.outOfStockBadge }) })), onSelect && (_jsx(BaseListingCard.Checkbox, { selected: isSelected, onSelect: (event) => {
50
53
  event.stopPropagation();
51
54
  onSelect(bundle.id, !isSelected);
52
55
  }, className: selectable || isSelected
53
56
  ? "opacity-100"
54
- : "opacity-0 group-hover:opacity-100 transition-opacity" }))] }), _jsxs(BaseListingCard.Info, { variant: variant, children: [_jsx(TextLink, { href: String(detailHref), children: _jsx(Text, { size: "sm", weight: "medium", color: "primary", truncate: 2, children: bundle.name }) }), _jsxs(Row, { justify: "between", className: "mt-1", gap: "sm", children: [_jsx(Text, { size: "sm", weight: "semibold", color: "primary", children: price
55
- ? formatCurrency(price, getDefaultCurrency())
56
- : "—" }), _jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "xs", children: mergedLabels.itemsLabel(memberCount) })] }), _jsx(Div, { className: CLS_VIEW_BTN, role: "button", tabIndex: 0, onClick: handleNavigate, onKeyDown: (e) => { if (e.key === "Enter" || e.key === " ")
57
+ : "opacity-0 group-hover:opacity-100 transition-opacity" }))] }), _jsxs(BaseListingCard.Info, { variant: variant, children: [_jsx(TextLink, { href: String(detailHref), children: _jsx(Text, { size: "sm", weight: "medium", color: "primary", truncate: 2, children: bundle.name }) }), _jsxs(Row, { justify: "between", className: "mt-1", gap: "sm", wrap: true, children: [_jsxs(Row, { gap: "xs", align: "center", children: [_jsx(Text, { size: "sm", weight: "semibold", color: "primary", children: price
58
+ ? formatCurrency(price, getDefaultCurrency())
59
+ : "—" }), discount && (_jsx(Text, { size: "xs", color: "muted", className: "line-through", children: formatCurrency(discount.originalTotal, getDefaultCurrency()) }))] }), _jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "xs", children: mergedLabels.itemsLabel(memberCount) })] }), _jsx(Div, { className: CLS_VIEW_BTN, role: "button", tabIndex: 0, onClick: handleNavigate, onKeyDown: (e) => { if (e.key === "Enter" || e.key === " ")
57
60
  handleNavigate(); }, children: "View Bundle" })] })] }));
58
61
  }
@@ -112,7 +112,7 @@ export async function PrizeDrawDetailPageView({ id, initialPrizeDraw, currentUse
112
112
  ? String(ROUTES.PUBLIC.STORE_DETAIL(storeSlug))
113
113
  : null;
114
114
  const descriptionHtml = toDescriptionHtml(p.description);
115
- return (_jsxs(Main, { children: [_jsx(HistoryTracker, { productId: String(p.id ?? p.slug ?? ""), productType: "product", snapshot: {
115
+ return (_jsxs(Main, { children: [_jsx(HistoryTracker, { productId: String(p.id ?? p.slug ?? ""), productType: "prize-draw", snapshot: {
116
116
  title,
117
117
  thumb,
118
118
  price: pricePerEntry,
@@ -8,14 +8,14 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
8
8
  * estimatedValue, 1–2 images per item, optional video, and a locked-overlay
9
9
  * for items already won during a previous reveal.
10
10
  *
11
- * Min 3 items, max 16 (mirrors PrizeDrawItem schema constraints).
11
+ * Min 2 items, max 16 (mirrors PrizeDrawItem schema constraints).
12
12
  */
13
13
  import { useCallback } from "react";
14
14
  import { Button, Div, FormField, Heading, Input, Row, Span, Stack, Text } from "../../../ui";
15
15
  const CLS_WARN_BOX = "rounded border border-warning/40 bg-warning-surface px-[var(--appkit-space-3)] py-[var(--appkit-space-2)] text-[length:var(--appkit-text-sm)] text-warning dark:bg-warning-surface dark:text-warning";
16
16
  const CLS_WON_BADGE = "rounded bg-error-surface px-[var(--appkit-space-3)] py-[var(--appkit-space-1)] text-[length:var(--appkit-text-xs)] font-semibold uppercase tracking-wide text-white";
17
17
  import { ImageUpload } from "../../media";
18
- const MIN_ITEMS = 3;
18
+ const MIN_ITEMS = 2;
19
19
  const MAX_ITEMS = 16;
20
20
  const MAX_IMAGES_PER_ITEM = 2;
21
21
  const CONDITION_OPTIONS = [
@@ -0,0 +1,14 @@
1
+ export interface PrizeDrawWinnerMappingRow {
2
+ itemNumber: number;
3
+ title: string;
4
+ image?: string;
5
+ isWon: boolean;
6
+ wonByOrderId?: string;
7
+ }
8
+ export interface PrizeDrawWinnerMappingViewProps {
9
+ title?: string;
10
+ items: PrizeDrawWinnerMappingRow[];
11
+ isAdmin: boolean;
12
+ }
13
+ export declare function PrizeDrawWinnerMappingView({ title, items, isAdmin, }: PrizeDrawWinnerMappingViewProps): import("react").JSX.Element;
14
+ export default PrizeDrawWinnerMappingView;
@@ -0,0 +1,37 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ /**
4
+ * PrizeDrawWinnerMappingView
5
+ *
6
+ * Read-only item→order mapping for a classic reveal-mode prize draw. Shown
7
+ * only to admin and the owning seller (never publicly) so they know which
8
+ * physical item to ship for which order. Mirrors LotteryEntriesView's choice
9
+ * of a small bounded DataTable (≤16 rows per draw) rather than a paginated
10
+ * DataListingView.
11
+ */
12
+ import { Badge, Container, DataTable, Div, Heading, Row, Section, Stack, Text, TextLink } from "../../../ui";
13
+ import { MediaImage } from "../../media/MediaImage";
14
+ import { ROUTES } from "../../../next";
15
+ export function PrizeDrawWinnerMappingView({ title = "Prize Draw — Winner Mapping", items, isAdmin, }) {
16
+ const columns = [
17
+ {
18
+ key: "itemNumber",
19
+ header: "Item",
20
+ render: (row) => (_jsxs(Row, { gap: "xs", align: "center", children: [row.image ? (_jsx(Div, { className: "relative h-10 w-10 shrink-0", overflow: "hidden", rounded: "default", children: _jsx(MediaImage, { src: row.image, alt: row.title, size: "thumbnail" }) })) : null, _jsxs(Text, { size: "sm", weight: "medium", children: ["#", row.itemNumber, " \u2014 ", row.title] })] })),
21
+ },
22
+ {
23
+ key: "isWon",
24
+ header: "Status",
25
+ render: (row) => row.isWon ? (_jsx(Badge, { variant: "success", size: "sm", children: "Won" })) : (_jsx(Badge, { variant: "secondary", size: "sm", children: "Unclaimed" })),
26
+ },
27
+ {
28
+ key: "wonByOrderId",
29
+ header: "Order",
30
+ render: (row) => row.wonByOrderId ? (_jsx(TextLink, { href: String(isAdmin
31
+ ? ROUTES.ADMIN.ORDER_DETAIL(row.wonByOrderId)
32
+ : ROUTES.STORE.ORDER_DETAIL(row.wonByOrderId)), children: row.wonByOrderId })) : (_jsx(Text, { size: "sm", color: "muted", children: "\u2014" })),
33
+ },
34
+ ];
35
+ return (_jsx(Container, { children: _jsx(Section, { children: _jsxs(Stack, { gap: "lg", children: [_jsx(Heading, { level: 1, size: "2xl", weight: "bold", children: title }), _jsx(Text, { color: "muted", children: "Visible only to you \u2014 buyers never see which item went to which order. Use this to ship the correct physical item to the correct order." }), _jsx(DataTable, { data: items, columns: columns, keyExtractor: (row) => String(row.itemNumber), emptyMessage: "No prize items." })] }) }) }));
36
+ }
37
+ export default PrizeDrawWinnerMappingView;
@@ -1,34 +1,21 @@
1
1
  import type { PrizeDrawItem } from "../schemas/firestore";
2
- export interface PrizeRevealResponse {
3
- prizeWon?: {
4
- itemNumber: number;
5
- title: string;
6
- images: string[];
7
- estimatedValue?: number;
8
- };
9
- alreadyRevealed?: boolean;
10
- rngSourceUrl?: string;
11
- refunded?: true;
12
- reason?: string;
2
+ export interface PrizeRevealResult {
3
+ itemNumber: number;
4
+ title: string;
5
+ images: string[];
6
+ estimatedValue?: number;
13
7
  }
14
8
  export interface PrizeRevealModalProps {
15
9
  open: boolean;
16
10
  onClose: () => void;
17
11
  /** Items to render in the collage (won state hidden — buyers don't see prior wins). */
18
12
  items: PrizeDrawItem[];
19
- /** Order id used by the reveal endpoint. */
20
- orderId: string;
21
- /** Product id used in the reveal URL. */
22
- productId: string;
23
- /** Override the default `/api/prize-draws/[id]/reveal` POST call. */
24
- onReveal?: (args: {
25
- orderId: string;
26
- productId: string;
27
- }) => Promise<PrizeRevealResponse>;
28
- /** Already-revealed prize, passed in if the order has `prizeWon` populated. */
29
- initialPrizeWon?: PrizeRevealResponse["prizeWon"];
13
+ /** Already-assigned prize, from `order.prizeWon`. Undefined while still pending. */
14
+ initialPrizeWon?: PrizeRevealResult;
15
+ /** Drives the pending-state copy "instant" vs "scheduled" draws explain differently. */
16
+ revealMode?: "instant" | "scheduled";
30
17
  /** Public proof-of-fairness URL — shown in the disclaimer. */
31
18
  rngSourceUrl?: string;
32
19
  }
33
- export declare function PrizeRevealModal({ open, onClose, items, orderId, productId, onReveal, initialPrizeWon, rngSourceUrl, }: PrizeRevealModalProps): import("react").JSX.Element;
20
+ export declare function PrizeRevealModal({ open, onClose, items, initialPrizeWon, revealMode, rngSourceUrl, }: PrizeRevealModalProps): import("react").JSX.Element;
34
21
  export default PrizeRevealModal;
@@ -1,160 +1,31 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  /**
4
- * PrizeRevealModal (SB4-I)
4
+ * PrizeRevealModal (SB4-I, redesigned for fully-automatic reveal)
5
5
  *
6
- * Theatrical reveal UI for prize-draw orders. When the buyer clicks "Reveal
7
- * my prize" we call the reveal API (which has already picked the winner via
8
- * crypto.randomInt server-side) and then run a 3-second highlight-cycling
9
- * animation across the collage fast at first, decelerating, finally
10
- * landing on the winning tile. This is pure showmanship: the winner was
11
- * locked in by the server before the first frame rendered.
12
- *
13
- * A permanent disclaimer below the collage explains exactly that — the RNG
14
- * is `crypto.randomInt`, the source code is on GitHub, and neither the store
15
- * nor LetItRip admins can influence the outcome.
6
+ * Displays a prize-draw order's reveal state. Reveal is no longer a buyer
7
+ * action the winner is assigned automatically by assignPrizeDrawWinner(),
8
+ * either the moment payment is confirmed ("instant" mode) or by a scheduled
9
+ * Firebase Function at draw expiry/sellout ("scheduled" mode). This modal is
10
+ * a pure display: "pending" (no result yet) or "won" (order.prizeWon is set).
16
11
  */
17
- import { useCallback, useEffect, useRef, useState } from "react";
18
- import { Anchor, Button, Div, Heading, LoginRequiredModal, Modal, Span, Stack, Text, useToast } from "../../../ui";
12
+ import { Anchor, Div, Heading, Modal, Span, Stack, Text } from "../../../ui";
19
13
  import { MediaImage } from "../../media/MediaImage";
20
- import { isAuthError } from "../../../utils/auth-error";
21
14
  import { PrizeDrawCollage } from "./PrizeDrawCollage";
22
- import { PRIZE_DRAW_ENDPOINTS } from "../../../constants/api-endpoints";
23
- import { normalizeError } from "../../../errors/normalize";
24
15
  const __P = {
25
16
  p4: "p-[var(--appkit-space-4)]",
26
17
  };
27
- const CLS_REFUND_NOTE = "rounded border border-warning/40 bg-warning-surface px-[var(--appkit-space-4)] py-[var(--appkit-space-3)] text-warning dark:bg-warning-surface dark:text-warning";
28
- const REVEAL_DURATION_MS = 3200; // total animation length
29
- const CYCLE_INTERVAL_FAST = 80;
30
- const CYCLE_INTERVAL_SLOW = 360;
31
- function isUnauthenticatedResponse(status) {
32
- return status === 401 || status === 403;
33
- }
34
- async function fetchPrizeReveal(productId, orderId) {
35
- const res = await fetch(PRIZE_DRAW_ENDPOINTS.REVEAL(productId), {
36
- method: "POST",
37
- headers: { "Content-Type": "application/json" },
38
- body: JSON.stringify({ orderId }),
39
- });
40
- const json = (await res.json());
41
- if (!res.ok) {
42
- if (isUnauthenticatedResponse(res.status)) {
43
- return { response: null, unauthenticated: true };
44
- }
45
- throw new Error("Reveal request failed");
46
- }
47
- return { response: json.data ?? {}, unauthenticated: false };
48
- }
49
- export function PrizeRevealModal({ open, onClose, items, orderId, productId, onReveal, initialPrizeWon, rngSourceUrl, }) {
50
- const [phase, setPhase] = useState(initialPrizeWon ? "won" : "idle");
51
- const [highlight, setHighlight] = useState(undefined);
52
- const [winner, setWinner] = useState(initialPrizeWon);
53
- const [errorMessage, setErrorMessage] = useState("");
54
- const [effectiveRngUrl, setEffectiveRngUrl] = useState(rngSourceUrl);
55
- const [showLoginModal, setShowLoginModal] = useState(false);
56
- const { showToast } = useToast();
57
- const cycleTimerRef = useRef(null);
58
- const endTimerRef = useRef(null);
59
- // Clean up timers on close/unmount so we don't leak between sessions.
60
- useEffect(() => {
61
- if (!open) {
62
- if (cycleTimerRef.current)
63
- clearTimeout(cycleTimerRef.current);
64
- if (endTimerRef.current)
65
- clearTimeout(endTimerRef.current);
66
- cycleTimerRef.current = null;
67
- endTimerRef.current = null;
68
- }
69
- return () => {
70
- if (cycleTimerRef.current)
71
- clearTimeout(cycleTimerRef.current);
72
- if (endTimerRef.current)
73
- clearTimeout(endTimerRef.current);
74
- };
75
- }, [open]);
76
- const startAnimation = useCallback((winningItemNumber) => {
77
- const startedAt = Date.now();
78
- const tile = (n) => items[n % items.length]?.itemNumber ?? winningItemNumber;
79
- let step = 0;
80
- const tick = () => {
81
- const elapsed = Date.now() - startedAt;
82
- const remaining = REVEAL_DURATION_MS - elapsed;
83
- if (remaining <= 0) {
84
- setHighlight(winningItemNumber);
85
- return;
86
- }
87
- // Decelerate: as we approach the end, the interval stretches.
88
- const progress = elapsed / REVEAL_DURATION_MS;
89
- const interval = CYCLE_INTERVAL_FAST +
90
- (CYCLE_INTERVAL_SLOW - CYCLE_INTERVAL_FAST) * progress;
91
- step += 1;
92
- setHighlight(tile(step));
93
- cycleTimerRef.current = setTimeout(tick, interval);
94
- };
95
- tick();
96
- }, [items]);
97
- const handleRevealClick = useCallback(async () => {
98
- if (phase !== "idle")
99
- return;
100
- setPhase("revealing");
101
- setErrorMessage("");
102
- let response;
103
- try {
104
- if (onReveal) {
105
- response = await onReveal({ orderId, productId });
106
- }
107
- else {
108
- const result = await fetchPrizeReveal(productId, orderId);
109
- if (result.unauthenticated) {
110
- setPhase("idle");
111
- setShowLoginModal(true);
112
- return;
113
- }
114
- response = result.response ?? {};
115
- }
116
- }
117
- catch (err) {
118
- void normalizeError(err);
119
- if (isAuthError(err)) {
120
- setPhase("idle");
121
- setShowLoginModal(true);
122
- return;
123
- }
124
- setPhase("error");
125
- const msg = err instanceof Error ? err.message : "Reveal request failed";
126
- setErrorMessage(msg);
127
- showToast(msg, "error");
128
- return;
129
- }
130
- if (response.refunded) {
131
- setPhase("refunded");
132
- return;
133
- }
134
- if (!response.prizeWon) {
135
- setPhase("error");
136
- setErrorMessage("Unexpected reveal response");
137
- return;
138
- }
139
- if (response.rngSourceUrl)
140
- setEffectiveRngUrl(response.rngSourceUrl);
141
- setWinner(response.prizeWon);
142
- if (response.alreadyRevealed) {
143
- // No theatrics for re-opens — just show the prize.
144
- setHighlight(response.prizeWon.itemNumber);
145
- setPhase("won");
146
- return;
147
- }
148
- startAnimation(response.prizeWon.itemNumber);
149
- endTimerRef.current = setTimeout(() => {
150
- setPhase("won");
151
- }, REVEAL_DURATION_MS);
152
- }, [onReveal, orderId, phase, productId, startAnimation]);
18
+ const PENDING_COPY = {
19
+ instant: "Your prize is being assigned now that payment is confirmed — refresh in a moment.",
20
+ scheduled: "This draw reveals automatically once it closes (at its expiry date) or sells out — check back here after.",
21
+ };
22
+ export function PrizeRevealModal({ open, onClose, items, initialPrizeWon, revealMode = "scheduled", rngSourceUrl, }) {
23
+ const winner = initialPrizeWon;
153
24
  const winnerImg = winner?.images?.[0];
154
- return (_jsxs(Modal, { isOpen: open, onClose: onClose, title: "Prize Reveal", size: "lg", children: [_jsx(LoginRequiredModal, { isOpen: showLoginModal, onClose: () => setShowLoginModal(false), message: "You need to be signed in to reveal your prize. Please log in or create an account to continue." }), _jsxs(Stack, { gap: "md", children: [phase === "refunded" ? (_jsxs(Div, { className: CLS_REFUND_NOTE, children: [_jsx(Heading, { level: 3, className: "mb-1", children: "Pool exhausted \u2014 you've been refunded" }), _jsx(Text, { size: "sm", children: "Every prize in this draw was already claimed by the time your entry rolled. Your order has been marked refunded automatically." })] })) : null, phase === "error" ? (_jsxs(Div, { className: "border border-error/40", color: "error", surface: "danger-surface", padding: "inline", rounded: "default", children: [_jsx(Text, { size: "sm", weight: "semibold", children: "Something went wrong" }), _jsx(Text, { size: "sm", children: errorMessage })] })) : null, _jsx(PrizeDrawCollage, { items: items, hideWonState: true, highlightItemNumber: highlight }), phase === "idle" ? (_jsx(Button, { type: "button", variant: "primary", size: "lg", onClick: handleRevealClick, children: "\u2728 Reveal my prize" })) : null, phase === "revealing" ? (
155
- // audit-content-alignment-ok: theatrical reveal-modal centerpiece, not marketing content
156
- _jsxs(Div, { className: `bg-[var(--appkit-color-surface-muted)] ${__P.p4} text-center`, rounded: "default", children: [_jsx(Text, { size: "lg", weight: "semibold", children: "Rolling\u2026" }), _jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: "The winner was locked by the server before this animation started. Hang tight \u2014 we're just making it look pretty." })] })) : null, phase === "won" && winner ? (
157
- // audit-content-alignment-ok: theatrical reveal-modal centerpiece, not marketing content
158
- _jsxs(Div, { className: `border-2 border-[var(--appkit-color-primary)] bg-[var(--appkit-color-surface)] ${__P.p4} text-center`, rounded: "lg", children: [_jsx(Text, { className: "tracking-wider text-[var(--appkit-color-text-muted)]", size: "xs", transform: "uppercase", children: "You won" }), _jsxs(Heading, { level: 2, className: "my-2", children: ["#", winner.itemNumber, " \u2014 ", winner.title] }), winnerImg ? (_jsx(Div, { className: "relative mx-auto h-64 w-64 max-w-full", rounded: "md", overflow: "hidden", children: _jsx(MediaImage, { src: winnerImg, alt: winner.title, size: "hero", objectFit: "contain" }) })) : null, winner.estimatedValue != null ? (_jsxs(Text, { className: "mt-2 text-[var(--appkit-color-text-muted)]", size: "sm", children: ["Estimated value: \u20B9", winner.estimatedValue.toLocaleString("en-IN")] })) : null] })) : null, _jsxs(Div, { textSize: "xs", className: "border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface-muted)] text-[var(--appkit-color-text-muted)]", padding: "inlineSm", rounded: "default", children: [_jsx(Span, { weight: "bold", children: "Fairness guarantee:" }), " ", "Winners are picked by ", _jsx("code", { children: "crypto.randomInt" }), " running on LetItRip's server before the animation starts. The animation is theatrical \u2014 neither the store nor LetItRip staff can influence the outcome.", " ", effectiveRngUrl ? (_jsx(Anchor, { href: effectiveRngUrl, tone: "none", underline: "always", children: "View RNG source code \u2192" })) : null] })] })] }));
25
+ return (_jsx(Modal, { isOpen: open, onClose: onClose, title: "Prize Reveal", size: "lg", children: _jsxs(Stack, { gap: "md", children: [_jsx(PrizeDrawCollage, { items: items, hideWonState: true }), !winner ? (
26
+ // audit-content-alignment-ok: informational reveal-status panel, not marketing content
27
+ _jsxs(Div, { className: `bg-[var(--appkit-color-surface-muted)] ${__P.p4} text-center`, rounded: "default", children: [_jsx(Text, { size: "lg", weight: "semibold", children: "Reveal pending" }), _jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: PENDING_COPY[revealMode] })] })) : (
28
+ // audit-content-alignment-ok: theatrical reveal-modal centerpiece, not marketing content
29
+ _jsxs(Div, { className: `border-2 border-[var(--appkit-color-primary)] bg-[var(--appkit-color-surface)] ${__P.p4} text-center`, rounded: "lg", children: [_jsx(Text, { className: "tracking-wider text-[var(--appkit-color-text-muted)]", size: "xs", transform: "uppercase", children: "You won" }), _jsxs(Heading, { level: 2, className: "my-2", children: ["#", winner.itemNumber, " \u2014 ", winner.title] }), winnerImg ? (_jsx(Div, { className: "relative mx-auto h-64 w-64 max-w-full", rounded: "md", overflow: "hidden", children: _jsx(MediaImage, { src: winnerImg, alt: winner.title, size: "hero", objectFit: "contain" }) })) : null, winner.estimatedValue != null ? (_jsxs(Text, { className: "mt-2 text-[var(--appkit-color-text-muted)]", size: "sm", children: ["Estimated value: \u20B9", winner.estimatedValue.toLocaleString("en-IN")] })) : null] })), _jsxs(Div, { textSize: "xs", className: "border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface-muted)] text-[var(--appkit-color-text-muted)]", padding: "inlineSm", rounded: "default", children: [_jsx(Span, { weight: "bold", children: "Fairness guarantee:" }), " ", "Winners are picked by ", _jsx("code", { children: "crypto.randomInt" }), " running on LetItRip's server the moment your prize is assigned. Neither the store nor LetItRip staff can influence the outcome.", " ", rngSourceUrl ? (_jsx(Anchor, { href: rngSourceUrl, tone: "none", underline: "always", children: "View RNG source code \u2192" })) : null] })] }) }));
159
30
  }
160
31
  export default PrizeRevealModal;
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import Link from "next/link";
3
+ import { SIEVE_OP, sieveAnd, sieveFilter, sortBy } from "@mohasinac/appkit";
3
4
  import { productRepository, reviewRepository } from "../../../repositories";
4
5
  const __P = {
5
6
  p3: "p-[var(--appkit-space-3)]",
@@ -125,6 +126,7 @@ export async function ProductDetailPageView({ slug, initialProduct, renderOfferA
125
126
  : typeof p.mainImage === "string"
126
127
  ? [p.mainImage]
127
128
  : [];
129
+ const productVideo = p.video;
128
130
  const stockQuantity = typeof p.stockQuantity === "number" ? p.stockQuantity : null;
129
131
  const availableQuantity = typeof p.availableQuantity === "number"
130
132
  ? p.availableQuantity
@@ -139,6 +141,10 @@ export async function ProductDetailPageView({ slug, initialProduct, renderOfferA
139
141
  const categorySlugs = Array.isArray(p.categorySlugs)
140
142
  ? p.categorySlugs
141
143
  : category ? [category] : [];
144
+ // The real category FK — `category` above is only the deprecated display-name
145
+ // cache (SB-UNI drift, Root Cause #24-class bug: don't route links/queries
146
+ // through it as if it were a slug).
147
+ const primaryCategorySlug = categorySlugs[0] ?? null;
142
148
  const categoryNames = Array.isArray(p.categoryNames)
143
149
  ? p.categoryNames
144
150
  : categoryName ? [categoryName] : [];
@@ -201,49 +207,82 @@ export async function ProductDetailPageView({ slug, initialProduct, renderOfferA
201
207
  const groupId = typeof p.groupId === "string" ? p.groupId : null;
202
208
  const isGroupParent = p.isGroupParent === true;
203
209
  const groupTitle = typeof p.groupTitle === "string" ? p.groupTitle : undefined;
204
- // -- Fetch reviews + related in parallel ------------------------------------
205
- const [reviewDocs, relatedDocs] = await Promise.all([
210
+ const storeId = typeof p.storeId === "string" ? p.storeId : null;
211
+ // -- Fetch reviews + 4 related-listing signals in parallel ------------------
212
+ const [reviewDocs, sameCategoryDocs, sameBrandDocs, tagOverlapDocs, sameStoreDocs] = await Promise.all([
206
213
  reviewRepository
207
214
  .findApprovedByProduct(product.id)
208
215
  .catch(() => []),
209
- category
210
- ? productRepository.findByCategory(category).catch(() => [])
216
+ primaryCategorySlug
217
+ ? productRepository.findByCategory(primaryCategorySlug).catch(() => [])
218
+ : Promise.resolve([]),
219
+ brand
220
+ ? productRepository
221
+ .list({
222
+ filters: sieveAnd(sieveFilter("status", SIEVE_OP.EQ, "published"), sieveFilter("brand", SIEVE_OP.EQ, brand)),
223
+ sorts: sortBy("createdAt", "DESC"),
224
+ page: 1,
225
+ pageSize: 9,
226
+ })
227
+ .then((r) => r.items)
228
+ .catch(() => [])
229
+ : Promise.resolve([]),
230
+ tags.length > 0
231
+ ? productRepository.findByTagsOverlap(tags, 9).catch(() => [])
232
+ : Promise.resolve([]),
233
+ storeId
234
+ ? productRepository
235
+ .list({
236
+ filters: sieveAnd(sieveFilter("status", SIEVE_OP.EQ, "published"), sieveFilter("storeId", SIEVE_OP.EQ, storeId)),
237
+ sorts: sortBy("createdAt", "DESC"),
238
+ page: 1,
239
+ pageSize: 9,
240
+ })
241
+ .then((r) => r.items)
242
+ .catch(() => [])
211
243
  : Promise.resolve([]),
212
244
  ]);
213
245
  const reviews = reviewDocs.map(toReview);
214
246
  const _now = new Date();
215
- const relatedItems = relatedDocs
216
- .filter((r) => {
217
- if (r.id === product.id)
218
- return false;
219
- const s = r.status;
220
- if (s && ["sold", "out_of_stock", "archived", "discontinued", "draft"].includes(s))
221
- return false;
222
- if (r.isSold === true)
223
- return false;
224
- if (r.availableQuantity === 0)
225
- return false;
226
- if (r.listingType === "auction" && r.auctionEndDate) {
227
- const end = r.auctionEndDate;
228
- const endDate = typeof end.toDate === "function"
229
- ? end.toDate()
230
- : end instanceof Date ? end : new Date(String(end));
231
- if (endDate <= _now)
247
+ const currentProductId = product.id;
248
+ function toRelatedItems(docs) {
249
+ return docs
250
+ .filter((r) => {
251
+ if (r.id === currentProductId)
252
+ return false;
253
+ const s = r.status;
254
+ if (s && ["sold", "out_of_stock", "archived", "discontinued", "draft"].includes(s))
232
255
  return false;
233
- }
234
- if (r.listingType === "prize-draw" && r.prizeRevealStatus === "closed")
235
- return false;
236
- return true;
237
- })
238
- .slice(0, 8)
239
- .map(toProductItem);
256
+ if (r.isSold === true)
257
+ return false;
258
+ if (r.availableQuantity === 0)
259
+ return false;
260
+ if (r.listingType === "auction" && r.auctionEndDate) {
261
+ const end = r.auctionEndDate;
262
+ const endDate = typeof end.toDate === "function"
263
+ ? end.toDate()
264
+ : end instanceof Date ? end : new Date(String(end));
265
+ if (endDate <= _now)
266
+ return false;
267
+ }
268
+ if (r.listingType === "prize-draw" && r.prizeRevealStatus === "closed")
269
+ return false;
270
+ return true;
271
+ })
272
+ .slice(0, 8)
273
+ .map(toProductItem);
274
+ }
275
+ const relatedItems = toRelatedItems(sameCategoryDocs);
276
+ const relatedByBrand = toRelatedItems(sameBrandDocs);
277
+ const relatedByTags = toRelatedItems(tagOverlapDocs);
278
+ const relatedByStore = toRelatedItems(sameStoreDocs);
240
279
  return (_jsxs(Main, { children: [_jsx(HistoryTracker, { productId: product.id, productType: "product", snapshot: {
241
280
  title: product.title,
242
281
  thumb: product.mainImage ?? product.images?.[0],
243
282
  price: product.price,
244
283
  storeId: product.storeId,
245
284
  storeName: product.storeName,
246
- } }), _jsxs(Container, { size: "xl", padding: "y-lg", children: [_jsx(ProductDetailView, { renderBreadcrumb: () => (_jsxs(Row, { className: "mb-4", align: "center", justify: "between", gap: "sm", wrap: true, children: [_jsxs(Nav, { "aria-label": "Breadcrumb", layout: "flex-wrap", gap: "2xs", textSize: "xs", color: "muted", children: [_jsx(Link, { href: String(ROUTES.HOME), className: CLS_BREADCRUMB_LINK, children: "Home" }), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.PRODUCTS), className: CLS_BREADCRUMB_LINK, children: "Products" }), category && (_jsxs(_Fragment, { children: [_jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(category)), className: CLS_BREADCRUMB_LINK, children: categoryName || category })] })), subcategory && (_jsxs(_Fragment, { children: [_jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Span, { transform: "capitalize", children: subcategory })] }))] }), _jsx(ShareButton, { title: title || undefined })] })), renderGallery: () => (_jsx(ProductGalleryClient, { images: images, productName: title || undefined })), renderInfo: () => (_jsxs(Stack, { gap: "md", children: [_jsxs(Div, { children: [condition && (_jsx(Span, { size: "xs", weight: "medium", className: "mb-2 inline-block", padding: "pill-sm", rounded: "full", surface: "subtle", color: "muted", transform: "capitalize", children: condition === "new"
285
+ } }), _jsxs(Container, { size: "xl", padding: "y-lg", children: [_jsx(ProductDetailView, { renderBreadcrumb: () => (_jsxs(Row, { className: "mb-4", align: "center", justify: "between", gap: "sm", wrap: true, children: [_jsxs(Nav, { "aria-label": "Breadcrumb", layout: "flex-wrap", gap: "2xs", textSize: "xs", color: "muted", children: [_jsx(Link, { href: String(ROUTES.HOME), className: CLS_BREADCRUMB_LINK, children: "Home" }), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.PRODUCTS), className: CLS_BREADCRUMB_LINK, children: "Products" }), (primaryCategorySlug || category) && (_jsxs(_Fragment, { children: [_jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(primaryCategorySlug ?? category)), className: CLS_BREADCRUMB_LINK, children: categoryName || category })] })), subcategory && (_jsxs(_Fragment, { children: [_jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Span, { transform: "capitalize", children: subcategory })] }))] }), _jsx(ShareButton, { title: title || undefined })] })), renderGallery: () => (_jsx(ProductGalleryClient, { images: images, video: productVideo, productName: title || undefined })), renderInfo: () => (_jsxs(Stack, { gap: "md", children: [_jsxs(Div, { children: [condition && (_jsx(Span, { size: "xs", weight: "medium", className: "mb-2 inline-block", padding: "pill-sm", rounded: "full", surface: "subtle", color: "muted", transform: "capitalize", children: condition === "new"
247
286
  ? "Brand New"
248
287
  : condition === "like_new"
249
288
  ? "Like New"
@@ -302,7 +341,10 @@ export async function ProductDetailPageView({ slug, initialProduct, renderOfferA
302
341
  content: _jsx(CustomSectionTabContent, { section: s }),
303
342
  })) })), renderBundleSection: bundleMemberships.length > 0
304
343
  ? () => (_jsxs(Div, { className: CLS_BUNDLE_BOX, children: [_jsxs(Row, { className: "mb-3", align: "center", gap: "sm", children: [_jsx(Span, { size: "base", className: CLS_BUNDLE_ICON, "aria-hidden": "true", children: "\uD83D\uDCE6" }), _jsxs(Text, { className: CLS_BUNDLE_TITLE, children: ["Part of ", bundleMemberships.length === 1 ? "a bundle" : `${bundleMemberships.length} bundles`] })] }), _jsx(Stack, { gap: "3", children: bundleMemberships.map((b) => (_jsxs(Div, { className: CLS_BUNDLE_ROW, children: [_jsxs(Div, { className: "min-w-0", children: [_jsx(Text, { className: CLS_BUNDLE_LABEL, children: "Included in bundle" }), _jsx(Text, { className: "truncate", color: "primary", size: "sm", weight: "semibold", children: b.title })] }), _jsx(Link, { href: String(ROUTES.PUBLIC.BUNDLE_DETAIL(b.id)), className: CLS_BUNDLE_CTA, children: "View Bundle \u2192" })] }, b.id))) })] }))
305
- : undefined, renderRelated: () => relatedItems.length > 0 ? (_jsx(RelatedProductsCarousel, { items: relatedItems })) : null }), renderPrimaryActions
344
+ : undefined, renderRelated: () => relatedItems.length > 0 ||
345
+ relatedByBrand.length > 0 ||
346
+ relatedByTags.length > 0 ||
347
+ relatedByStore.length > 0 ? (_jsxs(Stack, { gap: "xl", children: [relatedItems.length > 0 && (_jsx(RelatedProductsCarousel, { items: relatedItems, title: categoryName || category ? `More in ${categoryName || category}` : "More in this category" })), relatedByBrand.length > 0 && (_jsx(RelatedProductsCarousel, { items: relatedByBrand, title: brand ? `More by ${brand}` : "More by this brand" })), relatedByTags.length > 0 && (_jsx(RelatedProductsCarousel, { items: relatedByTags, title: "You might also like" })), relatedByStore.length > 0 && (_jsx(RelatedProductsCarousel, { items: relatedByStore, title: storeName ? `More from ${storeName}` : "More from this seller" }))] })) : null }), renderPrimaryActions
306
348
  ? renderPrimaryActions({
307
349
  productId: product.id,
308
350
  productSlug: product.slug ?? slug,
@@ -14,6 +14,11 @@ import { normalizeRichTextHtml } from "../../../utils/string.formatter";
14
14
  import { useListingTypeFlags } from "../../../react/hooks/useListingTypeFlags";
15
15
  import { isAuctionListing, isPreOrderListing, isPrizeDrawListing, normalizeListingType, } from "../utils/listing-type";
16
16
  import { PrizeDrawItemsEditor } from "./PrizeDrawItemsEditor";
17
+ import { PRIZE_DRAW_DURATION_DAYS_MIN, PRIZE_DRAW_DURATION_DAYS_MAX, } from "../../../_internal/shared/checkout/rules/_limits";
18
+ const PRIZE_REVEAL_MODE_OPTIONS = [
19
+ { value: "instant", label: "Reveal immediately on purchase" },
20
+ { value: "scheduled", label: "Reveal at expiry or sellout" },
21
+ ];
17
22
  // ListingType uses the canonical SB1-G spellings ("standard", "pre-order");
18
23
  // ProductFeatureProductType predates it with legacy spellings for 2 values.
19
24
  // Replaces the old 2-case ternary that silently mapped every other type
@@ -174,23 +179,11 @@ export function ProductForm({ product, onChange, isReadonly = false, renderDescr
174
179
  const anyWon = prizeItems.some((it) => it.isWon);
175
180
  const lockedForReveal = anyWon;
176
181
  const fieldDisabled = isReadonly || lockedForReveal;
177
- return (_jsxs(_Fragment, { children: [lockedForReveal ? (_jsx(Alert, { variant: "error", title: "Listing locked", children: "At least one prize has been revealed for this draw \u2014 the listing and its fields are now frozen. To rerun a similar draw, clone it into a new prize-draw listing." })) : null, _jsx(Alert, { variant: "warning", title: "Non-refundable", children: "Prize-draw entries are non-refundable once the reveal window opens. Buyers see and must accept this notice before paying." }), _jsxs(FormGroup, { columns: 2, children: [_jsx(FormField, { name: "pricePerEntry", label: "Price per entry (\u20B9)", type: "number", value: product.pricePerEntry != null
182
+ return (_jsxs(_Fragment, { children: [lockedForReveal ? (_jsx(Alert, { variant: "error", title: "Listing locked", children: "At least one prize has been revealed for this draw \u2014 the listing and its fields are now frozen. To rerun a similar draw, clone it into a new prize-draw listing." })) : null, _jsx(Alert, { variant: "warning", title: "Non-refundable", children: "Prize-draw entries are non-refundable once purchased. Buyers see and must accept this notice before paying." }), _jsxs(FormGroup, { columns: 2, children: [_jsx(FormField, { name: "pricePerEntry", label: "Price per entry (\u20B9)", type: "number", value: product.pricePerEntry != null
178
183
  ? String(product.pricePerEntry)
179
184
  : "", onChange: (value) => update({
180
185
  pricePerEntry: Math.round((parseFloat(value) || 0) * 100) / 100,
181
- }), disabled: fieldDisabled, placeholder: "299" }), _jsx(FormField, { name: "prizeMaxEntries", label: "Max entries (pool size)", type: "number", value: String(product.prizeMaxEntries ?? ""), onChange: (value) => update({ prizeMaxEntries: Number(value) || undefined }), disabled: fieldDisabled, placeholder: "100" })] }), _jsxs(FormGroup, { columns: 2, children: [_jsx(FormField, { name: "prizeRevealWindowStart", label: "Reveal window start", type: "datetime-local", value: (() => {
182
- const d = resolveDate(product.prizeRevealWindowStart);
183
- if (!d)
184
- return "";
185
- return d.toISOString().slice(0, 16);
186
- })(), onChange: (value) => update({ prizeRevealWindowStart: value }), disabled: fieldDisabled }), _jsx(FormField, { name: "prizeRevealWindowEnd", label: "Reveal window end", type: "datetime-local", value: (() => {
187
- const d = resolveDate(product.prizeRevealWindowEnd);
188
- if (!d)
189
- return "";
190
- return d.toISOString().slice(0, 16);
191
- })(), onChange: (value) => update({ prizeRevealWindowEnd: value }), disabled: fieldDisabled })] }), _jsxs(FormGroup, { columns: 2, children: [_jsx(FormField, { name: "prizeRevealDeadlineDays", label: "Reveal deadline (days)", type: "number", value: String(product.prizeRevealDeadlineDays ?? 3), onChange: (value) => update({
192
- prizeRevealDeadlineDays: Number(value) || 3,
193
- }), disabled: fieldDisabled, placeholder: "3", helpText: "After window opens, buyers have this many days to claim." }), _jsx(FormField, { name: "maxPerUser", label: "Max entries per customer (blank = unlimited)", type: "number", value: String(product.maxPerUser ?? ""), onChange: (value) => update({ maxPerUser: Number(value) || undefined }), disabled: fieldDisabled, placeholder: "" })] }), product.prizeGithubFileUrl ? (_jsx(FormField, { name: "prizeGithubFileUrl", label: "RNG Source Code URL (read-only)", type: "text", value: product.prizeGithubFileUrl, onChange: () => { }, disabled: true, helpText: "Auto-set on first save. Public proof-of-fairness link." })) : null, _jsx(PrizeDrawItemsEditor, { items: (product.prizeDrawItems ?? []), onChange: (items) => update({ prizeDrawItems: items }), onUploadImage: (file) =>
186
+ }), disabled: fieldDisabled, placeholder: "299" }), _jsx(FormField, { name: "prizeMaxEntries", label: "Max entries (pool size)", type: "number", value: String(product.prizeMaxEntries ?? ""), onChange: (value) => update({ prizeMaxEntries: Number(value) || undefined }), disabled: fieldDisabled, placeholder: "100" })] }), _jsxs(FormGroup, { columns: 2, children: [_jsx(FormField, { name: "prizeDrawDurationDays", label: "Draw duration (days)", type: "number", value: String(product.prizeDrawDurationDays ?? ""), onChange: (value) => update({ prizeDrawDurationDays: Number(value) || undefined }), disabled: fieldDisabled, placeholder: "7", helpText: `Draw runs this many days from publish, or until sold out — whichever comes first. ${PRIZE_DRAW_DURATION_DAYS_MIN}–${PRIZE_DRAW_DURATION_DAYS_MAX} days.` }), _jsx(FormField, { name: "prizeRevealMode", label: "Reveal mode", type: "select", value: product.prizeRevealMode ?? "scheduled", onChange: (value) => update({ prizeRevealMode: value }), disabled: fieldDisabled, options: PRIZE_REVEAL_MODE_OPTIONS })] }), _jsxs(FormGroup, { columns: 2, children: [_jsx(FormField, { name: "maxPerUser", label: "Max entries per customer (blank = unlimited)", type: "number", value: String(product.maxPerUser ?? ""), onChange: (value) => update({ maxPerUser: Number(value) || undefined }), disabled: fieldDisabled, placeholder: "" }), _jsx(Div, {})] }), product.prizeGithubFileUrl ? (_jsx(FormField, { name: "prizeGithubFileUrl", label: "RNG Source Code URL (read-only)", type: "text", value: product.prizeGithubFileUrl, onChange: () => { }, disabled: true, helpText: "Auto-set on first save. Public proof-of-fairness link." })) : null, _jsx(PrizeDrawItemsEditor, { items: (product.prizeDrawItems ?? []), onChange: (items) => update({ prizeDrawItems: items }), onUploadImage: (file) =>
194
187
  // Caller is expected to wire a real uploader by overriding
195
188
  // renderPrizeDrawImageUpload — fall back to data URL preview
196
189
  // so the editor still functions in unwired admin contexts.
@@ -1,6 +1,15 @@
1
1
  import React from "react";
2
+ /** Product video field shape — mirrors `ProductVideoField` from the products schema. */
3
+ export interface ProductGalleryVideo {
4
+ /** Raw, unwrapped playable video URL — never routed through the (image-only) media proxy. */
5
+ url: string;
6
+ /** Poster frame — a normal image, already resolved/proxied by the caller (e.g. via seedExtMedia). */
7
+ thumbnailUrl?: string;
8
+ }
2
9
  export interface ProductGalleryClientProps {
3
10
  images: string[];
11
+ /** Optional product video — rendered as a trailing slide/thumbnail; opens in theater mode (lightbox) on click. */
12
+ video?: ProductGalleryVideo;
4
13
  productName?: string;
5
14
  }
6
- export declare function ProductGalleryClient({ images, productName }: ProductGalleryClientProps): React.JSX.Element;
15
+ export declare function ProductGalleryClient({ images, video, productName }: ProductGalleryClientProps): React.JSX.Element;