@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
@@ -57,12 +57,13 @@ export async function getScammerProfilePageData(id) {
57
57
  const scammer = await getPublicScammerById(id);
58
58
  if (!scammer)
59
59
  return null;
60
- const [incidents, comments, relatedScammers] = await Promise.all([
60
+ const [incidents, comments, relatedScammers, similarScamReports] = await Promise.all([
61
61
  scammerRepository.listPublicIncidents(scammer.id),
62
62
  scammerRepository.listPublicComments(scammer.id),
63
63
  scammerRepository.findManyById(scammer.relatedScammerIds),
64
+ scammerRepository.findBySameType(scammer.scamType, scammer.id),
64
65
  ]);
65
- return { scammer, incidents, comments, relatedScammers };
66
+ return { scammer, incidents, comments, relatedScammers, similarScamReports };
66
67
  }
67
68
  const TRUST_STATUS_CLEAR = { status: "clear", matchedProfileSlugs: [] };
68
69
  /**
@@ -7,5 +7,6 @@ export interface ScamProfileViewProps {
7
7
  incidents?: ScammerIncidentDocument[];
8
8
  comments?: ScammerCommentDocument[];
9
9
  relatedScammers?: ScammerDocument[];
10
+ similarScamReports?: ScammerDocument[];
10
11
  }
11
- export declare function ScamProfileView({ scammer, isAuthenticated, incidents, comments, relatedScammers, }: ScamProfileViewProps): React.JSX.Element;
12
+ export declare function ScamProfileView({ scammer, isAuthenticated, incidents, comments, relatedScammers, similarScamReports, }: ScamProfileViewProps): React.JSX.Element;
@@ -46,7 +46,7 @@ function ScammerCommentsSection({ comments, isAuthenticated, scammerId, }) {
46
46
  function ScammerActionsColumn({ scammer, isAuthenticated, reportHref, contestLoginHref, }) {
47
47
  return (_jsxs(Stack, { gap: "md", children: [_jsxs(Card, { variant: "elevated", padding: "md", children: [_jsx(CardHeader, { children: _jsx(Heading, { level: 3, className: "tracking-wide", size: "sm", weight: "semibold", transform: "uppercase", children: "Actions" }) }), _jsx(CardBody, { children: _jsxs(Stack, { gap: "sm", children: [isAuthenticated ? (_jsxs(Link, { href: reportHref, className: CLS_OUTLINE_BTN, children: [_jsx(AlertOctagon, { className: "h-4 w-4 text-[color:var(--appkit-color-danger,theme(colors.red.500))]" }), "Report another incident"] })) : (_jsxs(Link, { href: `${String(ROUTES.AUTH.LOGIN)}?redirect=${encodeURIComponent(reportHref)}`, className: CLS_OUTLINE_BTN, children: [_jsx(AlertOctagon, { className: "h-4 w-4 text-[color:var(--appkit-color-danger,theme(colors.red.500))]" }), "Sign in to report an incident"] })), isAuthenticated ? (_jsxs(Link, { href: `/scams/${scammer.id}/contest`, className: CLS_OUTLINE_BTN, children: [_jsx(Flag, { className: "h-4 w-4 text-[color:var(--appkit-color-warning,theme(colors.amber.500))]" }), "Contest this profile"] })) : (_jsxs(Link, { href: contestLoginHref, className: "appkit-button appkit-button--ghost appkit-button--md flex w-full items-center gap-[var(--appkit-space-2)]", children: [_jsx(Flag, { className: "h-4 w-4" }), "Sign in to contest"] })), _jsx(Text, { variant: "secondary", size: "xs", children: "All contest submissions are reviewed by our moderation team before any changes are made." })] }) })] }), _jsxs(Card, { variant: "outlined", padding: "md", children: [_jsx(CardHeader, { children: _jsx(Heading, { level: 3, className: "tracking-wide", size: "sm", weight: "semibold", transform: "uppercase", children: "Contest Options" }) }), _jsx(CardBody, { children: _jsx(Stack, { gap: "xs", as: "ul", children: Object.entries(CONTEST_TYPE_LABELS).map(([, label]) => (_jsxs(Row, { gap: "sm", align: "start", as: "li", children: [_jsx(Span, { className: "mt-1.5 h-1.5 w-1.5 shrink-0 bg-[color:var(--appkit-color-border,theme(colors.zinc.300))]", rounded: "full" }), _jsx(Text, { variant: "secondary", size: "xs", children: label })] }, label))) }) })] }), scammer.verifiedAt && (_jsx(Alert, { variant: "success", compact: true, children: _jsxs(Row, { gap: "xs", align: "center", children: [_jsx(Shield, { className: "h-3.5 w-3.5 shrink-0" }), _jsxs(Text, { size: "xs", weight: "medium", children: ["Verified by LetItRip moderation on ", formatDate(scammer.verifiedAt)] })] }) }))] }));
48
48
  }
49
- export function ScamProfileView({ scammer, isAuthenticated, incidents = [], comments = [], relatedScammers = [], }) {
49
+ export function ScamProfileView({ scammer, isAuthenticated, incidents = [], comments = [], relatedScammers = [], similarScamReports = [], }) {
50
50
  const reportHref = String(ROUTES.PUBLIC.SCAM_REPORT);
51
51
  const registryHref = String(ROUTES.PUBLIC.SCAMS);
52
52
  const contestLoginHref = `${String(ROUTES.AUTH.LOGIN)}?redirect=${encodeURIComponent(`/scams/${scammer.id}`)}`;
@@ -55,5 +55,5 @@ export function ScamProfileView({ scammer, isAuthenticated, incidents = [], comm
55
55
  { label: "Home", href: "/" },
56
56
  { label: "Scam Registry", href: registryHref },
57
57
  { label: scammer.displayNames[0] ?? "Profile" },
58
- ] }) }) }), _jsx(Section, { padding: "y-2xl", children: _jsx(Container, { size: "xl", children: _jsxs(Grid, { cols: "twoThird", gap: "lg", children: [_jsxs(Stack, { gap: "lg", children: [_jsx(ScammerHeaderCard, { scammer: scammer }), (scammer.phones.length > 0 || scammer.upiIds.length > 0 || scammer.emails.length > 0) && (_jsxs(Stack, { gap: "sm", children: [_jsx(Heading, { level: 2, size: "base", weight: "semibold", children: "Contact Identifiers" }), _jsxs(Grid, { cols: 2, gap: "sm", children: [scammer.phones.map((p) => (_jsx(IdentityChip, { label: "Phone", value: p, icon: _jsx(Phone, { className: "h-4 w-4" }) }, p))), scammer.upiIds.map((u) => (_jsx(IdentityChip, { label: "UPI ID", value: u, icon: _jsx(Wallet, { className: "h-4 w-4" }) }, u))), scammer.emails.map((e) => (_jsx(IdentityChip, { label: "Email", value: e, icon: _jsx(Mail, { className: "h-4 w-4" }) }, e)))] })] })), scammer.socialMedia.length > 0 && (_jsxs(Stack, { gap: "sm", children: [_jsx(Heading, { level: 2, size: "base", weight: "semibold", children: "Social Media Profiles" }), _jsx(Stack, { gap: "xs", children: scammer.socialMedia.map((sm, i) => (_jsxs(Row, { gap: "sm", align: "center", children: [_jsx(ExternalLink, { className: "h-4 w-4 shrink-0 text-[color:var(--appkit-color-text-muted,theme(colors.zinc.400))]" }), _jsxs(Text, { size: "sm", weight: "medium", children: [SOCIAL_PLATFORM_LABELS[sm.platform] ?? sm.platform, ":"] }), sm.url ? (_jsx(Anchor, { href: sm.url, size: "sm", children: sm.handle })) : (_jsx(Text, { variant: "secondary", size: "sm", children: sm.handle }))] }, i))) })] })), scammer.itemInvolved && (_jsxs(Stack, { gap: "xs", children: [_jsx(Heading, { level: 2, size: "base", weight: "semibold", children: "Item Involved" }), _jsx(Text, { variant: "secondary", size: "sm", children: scammer.itemInvolved })] })), _jsxs(Stack, { gap: "sm", children: [_jsx(Heading, { level: 2, size: "base", weight: "semibold", children: "What Happened" }), _jsx(Card, { variant: "flat", padding: "md", children: _jsx(Text, { className: "leading-relaxed", size: "sm", children: scammer.description }) })] }), scamTypeDef && scamTypeDef.howToAvoid.length > 0 && (_jsxs(Stack, { gap: "sm", children: [_jsx(Heading, { level: 2, size: "base", weight: "semibold", children: "How to Avoid This Scam" }), _jsx(Card, { variant: "flat", padding: "md", children: _jsx(Stack, { gap: "xs", as: "ol", children: scamTypeDef.howToAvoid.map((tip, i) => (_jsxs(Row, { gap: "sm", align: "start", as: "li", children: [_jsx(Span, { layout: "flex-center", size: "xs", weight: "bold", className: "h-5 w-5 shrink-0 bg-[color:var(--appkit-color-success,theme(colors.green.600))]/10 text-[color:var(--appkit-color-success,theme(colors.green.700))]", rounded: "full", children: i + 1 }), _jsx(Text, { variant: "secondary", className: "leading-relaxed", size: "sm", children: tip })] }, i))) }) })] })), scammer.evidence.length > 0 && (_jsxs(Stack, { gap: "sm", children: [_jsx(Heading, { level: 2, size: "base", weight: "semibold", children: "Evidence" }), _jsx(Row, { gap: "sm", wrap: true, children: scammer.evidence.map((url, i) => (_jsx(Anchor, { href: url, underline: "none", rounded: "lg", shadow: "sm", className: "block h-32 w-32 overflow-hidden border hover:opacity-90", children: _jsx(MediaImage, { src: url, alt: `Evidence ${i + 1}`, size: "thumbnail" }) }, i))) })] })), _jsx(ScammerIncidentsSection, { incidents: incidents }), _jsx(ScammerCommentsSection, { comments: comments, isAuthenticated: isAuthenticated, scammerId: scammer.id }), relatedScammers.length > 0 && (_jsxs(Stack, { gap: "sm", children: [_jsx(Heading, { level: 2, size: "base", weight: "semibold", children: "Related Profiles" }), _jsx(Stack, { gap: "sm", children: relatedScammers.map((rel) => (_jsx(Link, { href: String(ROUTES.PUBLIC.SCAM_DETAIL(rel.id)), className: "block", children: _jsx(Card, { variant: "outlined", padding: "sm", className: "hover:opacity-80 transition-opacity", children: _jsxs(Row, { gap: "sm", align: "center", justify: "between", children: [_jsxs(Stack, { gap: "none", children: [_jsx(Text, { size: "sm", weight: "medium", children: rel.displayNames[0] }), _jsx(Text, { variant: "secondary", size: "xs", children: SCAM_TYPE_LABELS[rel.scamType] ?? rel.scamType })] }), _jsxs(Row, { gap: "xs", align: "center", children: [_jsx(Badge, { variant: statusVariant(rel.status), children: SCAMMER_STATUS_LABELS[rel.status] ?? rel.status }), _jsx(Link2, { className: "h-4 w-4 text-[color:var(--appkit-color-text-muted,theme(colors.zinc.400))]" })] })] }) }) }, rel.id))) })] }))] }), _jsx(ScammerActionsColumn, { scammer: scammer, isAuthenticated: isAuthenticated, reportHref: reportHref, contestLoginHref: contestLoginHref })] }) }) })] }));
58
+ ] }) }) }), _jsx(Section, { padding: "y-2xl", children: _jsx(Container, { size: "xl", children: _jsxs(Grid, { cols: "twoThird", gap: "lg", children: [_jsxs(Stack, { gap: "lg", children: [_jsx(ScammerHeaderCard, { scammer: scammer }), (scammer.phones.length > 0 || scammer.upiIds.length > 0 || scammer.emails.length > 0) && (_jsxs(Stack, { gap: "sm", children: [_jsx(Heading, { level: 2, size: "base", weight: "semibold", children: "Contact Identifiers" }), _jsxs(Grid, { cols: 2, gap: "sm", children: [scammer.phones.map((p) => (_jsx(IdentityChip, { label: "Phone", value: p, icon: _jsx(Phone, { className: "h-4 w-4" }) }, p))), scammer.upiIds.map((u) => (_jsx(IdentityChip, { label: "UPI ID", value: u, icon: _jsx(Wallet, { className: "h-4 w-4" }) }, u))), scammer.emails.map((e) => (_jsx(IdentityChip, { label: "Email", value: e, icon: _jsx(Mail, { className: "h-4 w-4" }) }, e)))] })] })), scammer.socialMedia.length > 0 && (_jsxs(Stack, { gap: "sm", children: [_jsx(Heading, { level: 2, size: "base", weight: "semibold", children: "Social Media Profiles" }), _jsx(Stack, { gap: "xs", children: scammer.socialMedia.map((sm, i) => (_jsxs(Row, { gap: "sm", align: "center", children: [_jsx(ExternalLink, { className: "h-4 w-4 shrink-0 text-[color:var(--appkit-color-text-muted,theme(colors.zinc.400))]" }), _jsxs(Text, { size: "sm", weight: "medium", children: [SOCIAL_PLATFORM_LABELS[sm.platform] ?? sm.platform, ":"] }), sm.url ? (_jsx(Anchor, { href: sm.url, size: "sm", children: sm.handle })) : (_jsx(Text, { variant: "secondary", size: "sm", children: sm.handle }))] }, i))) })] })), scammer.itemInvolved && (_jsxs(Stack, { gap: "xs", children: [_jsx(Heading, { level: 2, size: "base", weight: "semibold", children: "Item Involved" }), _jsx(Text, { variant: "secondary", size: "sm", children: scammer.itemInvolved })] })), _jsxs(Stack, { gap: "sm", children: [_jsx(Heading, { level: 2, size: "base", weight: "semibold", children: "What Happened" }), _jsx(Card, { variant: "flat", padding: "md", children: _jsx(Text, { className: "leading-relaxed", size: "sm", children: scammer.description }) })] }), scamTypeDef && scamTypeDef.howToAvoid.length > 0 && (_jsxs(Stack, { gap: "sm", children: [_jsx(Heading, { level: 2, size: "base", weight: "semibold", children: "How to Avoid This Scam" }), _jsx(Card, { variant: "flat", padding: "md", children: _jsx(Stack, { gap: "xs", as: "ol", children: scamTypeDef.howToAvoid.map((tip, i) => (_jsxs(Row, { gap: "sm", align: "start", as: "li", children: [_jsx(Span, { layout: "flex-center", size: "xs", weight: "bold", className: "h-5 w-5 shrink-0 bg-[color:var(--appkit-color-success,theme(colors.green.600))]/10 text-[color:var(--appkit-color-success,theme(colors.green.700))]", rounded: "full", children: i + 1 }), _jsx(Text, { variant: "secondary", className: "leading-relaxed", size: "sm", children: tip })] }, i))) }) })] })), scammer.evidence.length > 0 && (_jsxs(Stack, { gap: "sm", children: [_jsx(Heading, { level: 2, size: "base", weight: "semibold", children: "Evidence" }), _jsx(Row, { gap: "sm", wrap: true, children: scammer.evidence.map((url, i) => (_jsx(Anchor, { href: url, underline: "none", rounded: "lg", shadow: "sm", className: "block h-32 w-32 overflow-hidden border hover:opacity-90", children: _jsx(MediaImage, { src: url, alt: `Evidence ${i + 1}`, size: "thumbnail" }) }, i))) })] })), _jsx(ScammerIncidentsSection, { incidents: incidents }), _jsx(ScammerCommentsSection, { comments: comments, isAuthenticated: isAuthenticated, scammerId: scammer.id }), relatedScammers.length > 0 && (_jsxs(Stack, { gap: "sm", children: [_jsx(Heading, { level: 2, size: "base", weight: "semibold", children: "Related Profiles" }), _jsx(Stack, { gap: "sm", children: relatedScammers.map((rel) => (_jsx(Link, { href: String(ROUTES.PUBLIC.SCAM_DETAIL(rel.id)), className: "block", children: _jsx(Card, { variant: "outlined", padding: "sm", className: "hover:opacity-80 transition-opacity", children: _jsxs(Row, { gap: "sm", align: "center", justify: "between", children: [_jsxs(Stack, { gap: "none", children: [_jsx(Text, { size: "sm", weight: "medium", children: rel.displayNames[0] }), _jsx(Text, { variant: "secondary", size: "xs", children: SCAM_TYPE_LABELS[rel.scamType] ?? rel.scamType })] }), _jsxs(Row, { gap: "xs", align: "center", children: [_jsx(Badge, { variant: statusVariant(rel.status), children: SCAMMER_STATUS_LABELS[rel.status] ?? rel.status }), _jsx(Link2, { className: "h-4 w-4 text-[color:var(--appkit-color-text-muted,theme(colors.zinc.400))]" })] })] }) }) }, rel.id))) })] })), similarScamReports.length > 0 && (_jsxs(Stack, { gap: "sm", children: [_jsx(Heading, { level: 2, size: "base", weight: "semibold", children: "Similar Scam Reports" }), _jsxs(Text, { variant: "secondary", size: "xs", children: ["Other profiles reported for the same scam pattern (", SCAM_TYPE_LABELS[scammer.scamType] ?? scammer.scamType, "). This does not mean they are the same person."] }), _jsx(Stack, { gap: "sm", children: similarScamReports.map((rel) => (_jsx(Link, { href: String(ROUTES.PUBLIC.SCAM_DETAIL(rel.id)), className: "block", children: _jsx(Card, { variant: "outlined", padding: "sm", className: "hover:opacity-80 transition-opacity", children: _jsxs(Row, { gap: "sm", align: "center", justify: "between", children: [_jsxs(Stack, { gap: "none", children: [_jsx(Text, { size: "sm", weight: "medium", children: rel.displayNames[0] }), _jsx(Text, { variant: "secondary", size: "xs", children: SCAM_TYPE_LABELS[rel.scamType] ?? rel.scamType })] }), _jsxs(Row, { gap: "xs", align: "center", children: [_jsx(Badge, { variant: statusVariant(rel.status), children: SCAMMER_STATUS_LABELS[rel.status] ?? rel.status }), _jsx(Link2, { className: "h-4 w-4 text-[color:var(--appkit-color-text-muted,theme(colors.zinc.400))]" })] })] }) }) }, rel.id))) })] }))] }), _jsx(ScammerActionsColumn, { scammer: scammer, isAuthenticated: isAuthenticated, reportHref: reportHref, contestLoginHref: contestLoginHref })] }) }) })] }));
59
59
  }
@@ -61,6 +61,12 @@ declare class ScammerRepository extends BaseRepository<ScammerDocument> {
61
61
  findByContactField(field: "phones" | "upiIds" | "emails" | "displayNames", value: string): Promise<ScammerDocument[]>;
62
62
  listPublicIncidents(scammerId: string): Promise<ScammerIncidentDocument[]>;
63
63
  listPublicComments(scammerId: string): Promise<ScammerCommentDocument[]>;
64
+ /**
65
+ * Other verified profiles with the same scamType — a "similar scam pattern"
66
+ * discovery signal, distinct from `relatedScammerIds` (which links profiles
67
+ * confirmed to be the same person). Never implies identity, only pattern similarity.
68
+ */
69
+ findBySameType(scamType: string, excludeId: string, limit?: number): Promise<ScammerDocument[]>;
64
70
  findManyById(ids: string[]): Promise<ScammerDocument[]>;
65
71
  }
66
72
  declare const scammerRepository: ScammerRepository;
@@ -142,6 +142,30 @@ class ScammerRepository extends BaseRepository {
142
142
  return [];
143
143
  }
144
144
  }
145
+ /**
146
+ * Other verified profiles with the same scamType — a "similar scam pattern"
147
+ * discovery signal, distinct from `relatedScammerIds` (which links profiles
148
+ * confirmed to be the same person). Never implies identity, only pattern similarity.
149
+ */
150
+ async findBySameType(scamType, excludeId, limit = 5) {
151
+ try {
152
+ const snap = await this.getCollection()
153
+ .where(SCAMMER_FIELDS.STATUS, "==", "verified")
154
+ .where(SCAMMER_FIELDS.SCAM_TYPE, "==", scamType)
155
+ .orderBy(SCAMMER_FIELDS.CREATED_AT, "desc")
156
+ .limit(limit + 1)
157
+ .get();
158
+ return snap.docs
159
+ .map((d) => this.mapDoc(d))
160
+ .filter((s) => s.id !== excludeId)
161
+ .slice(0, limit);
162
+ }
163
+ catch (err) {
164
+ void normalizeError(err);
165
+ serverLogger.warn("scammer-repo: findBySameType failed — returning empty", { scamType, error: err instanceof Error ? err.message : String(err) });
166
+ return [];
167
+ }
168
+ }
145
169
  async findManyById(ids) {
146
170
  if (!ids.length)
147
171
  return [];
@@ -15,6 +15,7 @@ import { userRepository } from "../../auth/repository/user.repository";
15
15
  import { storeRepository } from "../../stores/repository/store.repository";
16
16
  import { productRepository } from "../../products/repository/products.repository";
17
17
  import { ProductStatusValues } from "../../products/schemas";
18
+ import { assertPrizeDrawNotLocked, assertPrizeDrawWonItemsImmutable, } from "../../../_internal/server/features/products/service";
18
19
  import { orderRepository } from "../../orders/repository/orders.repository";
19
20
  import { OrderStatusValues } from "../../orders/schemas";
20
21
  import { payoutRepository } from "../../payments/repository/payout.repository";
@@ -485,6 +486,12 @@ export async function sellerUpdateProduct(userId, userRole, productId, input) {
485
486
  if (!store || existing.storeId !== store.id)
486
487
  throw new AuthorizationError("You do not own this product");
487
488
  }
489
+ if (typeof input.status === "string" &&
490
+ input.status !== "published" &&
491
+ existing.status === "published") {
492
+ assertPrizeDrawNotLocked(existing, "unpublished or archived");
493
+ }
494
+ assertPrizeDrawWonItemsImmutable(existing, input.prizeDrawItems);
488
495
  const finalizedData = await finalizeProductMediaReferences(input);
489
496
  const updated = await productRepository.updateProduct(productId, finalizedData);
490
497
  serverLogger.info("sellerUpdateProduct", { userId, productId });
@@ -500,6 +507,7 @@ export async function sellerDeleteProduct(userId, userRole, productId) {
500
507
  if (!store || existing.storeId !== store.id)
501
508
  throw new AuthorizationError("You do not own this product");
502
509
  }
510
+ assertPrizeDrawNotLocked(existing, "deleted");
503
511
  await productRepository.delete(productId);
504
512
  serverLogger.info("sellerDeleteProduct", { userId, productId });
505
513
  }
@@ -132,6 +132,10 @@ export function SellerPrizeDrawsView({ children, onDelete, ...props }) {
132
132
  label: ACTIONS.STORE["edit-listing"].label,
133
133
  onClick: () => void dispatch({ type: "NAVIGATE", href: String(ROUTES.STORE.PRIZE_DRAWS_EDIT(row.id)) }),
134
134
  },
135
+ {
136
+ label: "View Entries",
137
+ onClick: () => void dispatch({ type: "NAVIGATE", href: String(ROUTES.STORE.PRIZE_DRAW_ENTRIES(row.id)) }),
138
+ },
135
139
  ...(onDelete
136
140
  ? [
137
141
  {
@@ -120,15 +120,15 @@ export async function StoreDetailLayoutView({ storeSlug, activeTab, children, sc
120
120
  const lt = TAB_LISTING_TYPE[tab.id];
121
121
  return lt ? isListingTypeEnabled(lt, settings) : true;
122
122
  });
123
+ const dropdownTabs = visibleStoreTabs.map((tab) => ({
124
+ value: tab.id,
125
+ label: tabLabel(tab.label, listingCounts[tab.id]),
126
+ href: STORE_LISTING_HREF[tab.id](storeSlug),
127
+ }));
123
128
  const tabs = [
124
- ...visibleStoreTabs.map((tab) => ({
125
- value: tab.id,
126
- label: tabLabel(tab.label, listingCounts[tab.id]),
127
- href: STORE_LISTING_HREF[tab.id](storeSlug),
128
- })),
129
129
  { value: "coupons", label: tabLabel("Coupons", couponsCount), href: String(ROUTES.PUBLIC.STORE_COUPONS(storeSlug)) },
130
130
  { value: "reviews", label: tabLabel("Reviews", reviewsCount), href: String(ROUTES.PUBLIC.STORE_REVIEWS(storeSlug)) },
131
131
  { value: "about", label: "About", href: String(ROUTES.PUBLIC.STORE_ABOUT(storeSlug)) },
132
132
  ];
133
- return (_jsxs(Main, { children: [_jsx(StoreHeader, { store: store, trust: trust }), _jsxs(Container, { size: "xl", className: "mt-6", children: [_jsx(StoreNavTabs, { tabs: tabs, activeValue: activeTab }), _jsx(Section, { padding: "t-lg", children: children })] })] }));
133
+ return (_jsxs(Main, { children: [_jsx(StoreHeader, { store: store, trust: trust }), _jsxs(Container, { size: "xl", className: "mt-6", children: [_jsx(StoreNavTabs, { dropdownTabs: dropdownTabs, dropdownPlaceholder: "Browse listings", tabs: tabs, activeValue: activeTab }), _jsx(Section, { padding: "t-lg", children: children })] })] }));
134
134
  }
@@ -6,10 +6,19 @@ export interface StoreTab {
6
6
  }
7
7
  export interface StoreNavTabsProps {
8
8
  tabs: StoreTab[];
9
+ /**
10
+ * Listing-type tabs (Products/Auctions/Pre-Orders/.../Art & Stickers) —
11
+ * rendered as a single dropdown ahead of `tabs` instead of one row entry
12
+ * each, so the bar can't overflow no matter how many listing types are
13
+ * enabled. Picking an option navigates to its `href`.
14
+ */
15
+ dropdownTabs?: StoreTab[];
16
+ /** Label shown in the dropdown when the active tab isn't a listing type (e.g. on Reviews/About). */
17
+ dropdownPlaceholder?: string;
9
18
  activeValue?: string;
10
19
  onTabChange?: (value: string) => void;
11
20
  /** Render-prop for full custom tab bar */
12
21
  renderTabBar?: (tabs: StoreTab[], activeValue: string | undefined, onChange: (v: string) => void) => React.ReactNode;
13
22
  className?: string;
14
23
  }
15
- export declare function StoreNavTabs({ tabs, activeValue, onTabChange, renderTabBar, className, }: StoreNavTabsProps): React.JSX.Element;
24
+ export declare function StoreNavTabs({ tabs, dropdownTabs, dropdownPlaceholder, activeValue, onTabChange, renderTabBar, className, }: StoreNavTabsProps): React.JSX.Element;
@@ -1,13 +1,25 @@
1
- import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
- import { Button, Div, TextLink } from "../../../ui";
3
- export function StoreNavTabs({ tabs, activeValue, onTabChange, renderTabBar, className = "", }) {
1
+ "use client";
2
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useRouter } from "next/navigation";
4
+ import { Button, Div, Select, TextLink } from "../../../ui";
5
+ export function StoreNavTabs({ tabs, dropdownTabs = [], dropdownPlaceholder = "Browse listings", activeValue, onTabChange, renderTabBar, className = "", }) {
6
+ const router = useRouter();
4
7
  const handleChange = (v) => onTabChange?.(v);
5
8
  if (renderTabBar) {
6
- return _jsx(_Fragment, { children: renderTabBar(tabs, activeValue, handleChange) });
9
+ return _jsx(_Fragment, { children: renderTabBar([...dropdownTabs, ...tabs], activeValue, handleChange) });
7
10
  }
8
- return (_jsx(Div, { layout: "flex", gap: "2", role: "tablist", border: "bottom", className: `overflow-x-auto ${className}`, children: tabs.map((tab) => tab.href ? (_jsx(TextLink, { variant: "bare", href: tab.href, role: "tab", "aria-selected": activeValue === tab.value, paddingX: "md", paddingY: "xs", size: "sm", weight: "medium", className: `whitespace-nowrap border-b-2 -mb-px transition-colors ${activeValue === tab.value
9
- ? "border-primary text-primary"
10
- : "border-transparent text-[var(--appkit-color-text-muted)] text-[var(--appkit-color-text-muted)] hover:text-neutral-800 hover:text-[var(--appkit-color-text-muted)]"}`, children: tab.label }, tab.value)) : (_jsx(Button, { variant: "ghost", type: "button", role: "tab", "aria-selected": activeValue === tab.value, onClick: () => handleChange(tab.value), paddingX: "md", paddingY: "sm", textSize: "sm", weight: "medium", rounded: "none", className: `whitespace-nowrap border-b-2 -mb-px transition-colors ${activeValue === tab.value
11
- ? "border-primary text-primary"
12
- : "border-transparent text-[var(--appkit-color-text-muted)] text-[var(--appkit-color-text-muted)] hover:text-neutral-800 hover:text-[var(--appkit-color-text-muted)]"}`, children: tab.label }, tab.value))) }));
11
+ const activeDropdownTab = dropdownTabs.find((tab) => tab.value === activeValue);
12
+ const handleDropdownChange = (value) => {
13
+ const tab = dropdownTabs.find((t) => t.value === value);
14
+ if (!tab)
15
+ return;
16
+ if (tab.href)
17
+ router.push(tab.href);
18
+ handleChange(tab.value);
19
+ };
20
+ return (_jsxs(Div, { layout: "flex", gap: "2", role: "tablist", border: "bottom", className: `items-center overflow-x-auto ${className}`, children: [dropdownTabs.length > 0 && (_jsx(Select, { bare: true, "aria-label": dropdownPlaceholder, options: dropdownTabs.map((tab) => ({ value: tab.value, label: tab.label })), value: activeDropdownTab?.value ?? "", placeholder: activeDropdownTab ? undefined : dropdownPlaceholder, onValueChange: handleDropdownChange, className: `max-w-[12rem] flex-shrink-0 whitespace-nowrap ${activeDropdownTab ? "border-primary text-primary" : ""}` })), tabs.map((tab) => tab.href ? (_jsx(TextLink, { variant: "bare", href: tab.href, role: "tab", "aria-selected": activeValue === tab.value, paddingX: "md", paddingY: "xs", size: "sm", weight: "medium", className: `whitespace-nowrap border-b-2 -mb-px transition-colors ${activeValue === tab.value
21
+ ? "border-primary text-primary"
22
+ : "border-transparent text-[var(--appkit-color-text-muted)] text-[var(--appkit-color-text-muted)] hover:text-neutral-800 hover:text-[var(--appkit-color-text-muted)]"}`, children: tab.label }, tab.value)) : (_jsx(Button, { variant: "ghost", type: "button", role: "tab", "aria-selected": activeValue === tab.value, onClick: () => handleChange(tab.value), paddingX: "md", paddingY: "sm", textSize: "sm", weight: "medium", rounded: "none", className: `whitespace-nowrap border-b-2 -mb-px transition-colors ${activeValue === tab.value
23
+ ? "border-primary text-primary"
24
+ : "border-transparent text-[var(--appkit-color-text-muted)] text-[var(--appkit-color-text-muted)] hover:text-neutral-800 hover:text-[var(--appkit-color-text-muted)]"}`, children: tab.label }, tab.value)))] }));
13
25
  }
@@ -1 +1,2 @@
1
1
  export * from "./checklist-item-actions";
2
+ export * from "./leaderboard-actions";
@@ -1 +1,2 @@
1
1
  export * from "./checklist-item-actions";
2
+ export * from "./leaderboard-actions";
@@ -0,0 +1,3 @@
1
+ import type { BugHunterLeaderboardEntry } from "../schemas/firestore";
2
+ /** Ranks testers by confirmed-bug count. Mirrors getEventLeaderboard()'s shape. */
3
+ export declare function getBugHunterLeaderboard(limit?: number): Promise<BugHunterLeaderboardEntry[]>;
@@ -0,0 +1,5 @@
1
+ import { testerChecklistItemRepository } from "../repository/tester-checklist-item.repository";
2
+ /** Ranks testers by confirmed-bug count. Mirrors getEventLeaderboard()'s shape. */
3
+ export async function getBugHunterLeaderboard(limit = 50) {
4
+ return testerChecklistItemRepository.getBugHunterLeaderboard(limit);
5
+ }
@@ -1,18 +1,40 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useQuery } from "@tanstack/react-query";
4
- import { Card, Details, Div, Stack, Summary, Text } from "../../../ui";
3
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
4
+ import { Button, Card, Details, Div, Row, Stack, Summary, Text } from "../../../ui";
5
5
  import { MediaImage } from "../../media/MediaImage";
6
6
  import { apiClient } from "../../../http";
7
+ import { useApiMutation } from "../../../client";
7
8
  import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints";
9
+ import { ACTIONS } from "../../../_internal/shared/actions/action-registry";
10
+ const REPORT_QUERY_KEY = ["admin", "tester-feedback", "report"];
8
11
  export function AdminTesterFeedbackIssuesView() {
12
+ const queryClient = useQueryClient();
9
13
  const query = useQuery({
10
- queryKey: ["admin", "tester-feedback", "report"],
14
+ queryKey: REPORT_QUERY_KEY,
11
15
  queryFn: async () => {
12
16
  const res = await apiClient.get(ADMIN_ENDPOINTS.TESTER_FEEDBACK_REPORT);
13
17
  return res?.data ?? res;
14
18
  },
15
19
  });
20
+ const confirmBugMutation = useApiMutation({
21
+ mutationFn: async (responseId) => {
22
+ await apiClient.post(ADMIN_ENDPOINTS.TESTER_FEEDBACK_CONFIRM_BUG(responseId), {});
23
+ },
24
+ successMessage: "Bug confirmed and credited.",
25
+ onSuccess: () => {
26
+ queryClient.invalidateQueries({ queryKey: REPORT_QUERY_KEY });
27
+ },
28
+ });
29
+ const reopenMutation = useApiMutation({
30
+ mutationFn: async (checklistItemId) => {
31
+ await apiClient.post(ADMIN_ENDPOINTS.TESTER_CHECKLIST_ITEM_REOPEN(checklistItemId), {});
32
+ },
33
+ successMessage: "Case reopened for retest.",
34
+ onSuccess: () => {
35
+ queryClient.invalidateQueries({ queryKey: REPORT_QUERY_KEY });
36
+ },
37
+ });
16
38
  const issues = query.data?.issues ?? [];
17
39
  if (query.isLoading)
18
40
  return _jsx(Text, { color: "muted", children: "Loading issues\u2026" });
@@ -28,6 +50,6 @@ export function AdminTesterFeedbackIssuesView() {
28
50
  const sortedPhases = Array.from(byPhase.keys()).sort((a, b) => a - b);
29
51
  return (_jsx(Stack, { gap: "md", children: sortedPhases.map((phase) => {
30
52
  const phaseIssues = byPhase.get(phase);
31
- return (_jsxs(Details, { tone: "card", defaultOpen: sortedPhases.length === 1, children: [_jsxs(Summary, { size: "lg", weight: "bold", children: ["Phase ", phase, " (", phaseIssues.length, " issue", phaseIssues.length === 1 ? "" : "s", ")"] }), _jsx(Div, { padding: "t-sm", children: _jsx(Stack, { gap: "sm", children: phaseIssues.map((issue) => (_jsx(Card, { padding: "md", variant: "default", children: _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { weight: "semibold", children: issue.label }), _jsxs(Text, { size: "sm", color: "muted", children: [issue.groupLabel, " \u203A ", issue.pageLabel] }), _jsxs(Text, { size: "sm", color: "muted", children: ["Reported by ", issue.testerDisplayName] }), issue.comment && _jsx(Text, { size: "sm", children: issue.comment }), issue.screenshotUrl && (_jsx(MediaImage, { src: issue.screenshotUrl, alt: "Tester screenshot", size: "card" }))] }) }, issue.id))) }) })] }, phase));
53
+ return (_jsxs(Details, { tone: "card", defaultOpen: sortedPhases.length === 1, children: [_jsxs(Summary, { size: "lg", weight: "bold", children: ["Phase ", phase, " (", phaseIssues.length, " issue", phaseIssues.length === 1 ? "" : "s", ")"] }), _jsx(Div, { padding: "t-sm", children: _jsx(Stack, { gap: "sm", children: phaseIssues.map((issue) => (_jsx(Card, { padding: "md", variant: "default", children: _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { weight: "semibold", children: issue.label }), _jsxs(Text, { size: "sm", color: "muted", children: [issue.groupLabel, " \u203A ", issue.pageLabel] }), _jsxs(Text, { size: "sm", color: "muted", children: ["Reported by ", issue.testerDisplayName] }), issue.comment && _jsx(Text, { size: "sm", children: issue.comment }), issue.screenshotUrl && (_jsx(MediaImage, { src: issue.screenshotUrl, alt: "Tester screenshot", size: "card" })), issue.bugConfirmed ? (_jsxs(Row, { gap: "sm", align: "center", wrap: true, children: [_jsxs(Text, { size: "sm", weight: "semibold", color: "success", children: ["\uD83D\uDC1B Confirmed \u2014 credited to ", issue.bugHunterName ?? "unknown tester"] }), !issue.supersededByItemId && (_jsx(Button, { size: "sm", action: ACTIONS.ADMIN["reopen-checklist-item"], isLoading: reopenMutation.isPending, onClick: () => reopenMutation.mutate(issue.checklistItemId) }))] })) : (_jsx(Div, { children: _jsx(Button, { size: "sm", action: ACTIONS.ADMIN["confirm-bug"], isLoading: confirmBugMutation.isPending, onClick: () => confirmBugMutation.mutate(issue.id) }) }))] }) }, issue.id))) }) })] }, phase));
32
54
  }) }));
33
55
  }
@@ -47,6 +47,14 @@ export function AdminTesterFeedbackListView(_props) {
47
47
  queryClient.invalidateQueries({ queryKey: ["admin", "tester-feedback"] });
48
48
  },
49
49
  });
50
+ const confirmBugMutation = useApiMutation({
51
+ mutationFn: async (id) => {
52
+ await apiClient.post(ADMIN_ENDPOINTS.TESTER_FEEDBACK_CONFIRM_BUG(id), {});
53
+ },
54
+ onSuccess: () => {
55
+ queryClient.invalidateQueries({ queryKey: ["admin", "tester-feedback"] });
56
+ },
57
+ });
50
58
  const config = {
51
59
  portal: "admin",
52
60
  title: "All Submissions",
@@ -95,6 +103,11 @@ export function AdminTesterFeedbackListView(_props) {
95
103
  disabled: row.status === "reviewed",
96
104
  onClick: () => patchMutation.mutate(row.id),
97
105
  },
106
+ {
107
+ label: ACTIONS.ADMIN["confirm-bug"].label,
108
+ disabled: row.answer !== "no",
109
+ onClick: () => confirmBugMutation.mutate(row.id),
110
+ },
98
111
  ] })),
99
112
  };
100
113
  return _jsx(DataListingView, { config: config });
@@ -0,0 +1,23 @@
1
+ import React from "react";
2
+ import type { BugHunterLeaderboardEntry } from "../schemas/firestore";
3
+ export interface BugHunterLeaderboardViewProps {
4
+ isLoading?: boolean;
5
+ isEmpty?: boolean;
6
+ /** Render the full ranked list — caller provides entries via hook */
7
+ renderList?: () => React.ReactNode;
8
+ /** Render a single row; used when caller wants item-level control */
9
+ renderEntry?: (entry: BugHunterLeaderboardEntry, index: number) => React.ReactNode;
10
+ /** Entries — used only when renderEntry is provided */
11
+ entries?: BugHunterLeaderboardEntry[];
12
+ /** Empty state node */
13
+ renderEmpty?: () => React.ReactNode;
14
+ /** Loading spinner / skeleton */
15
+ renderSkeleton?: () => React.ReactNode;
16
+ labels?: {
17
+ title?: string;
18
+ noEntries?: string;
19
+ bugs?: string;
20
+ };
21
+ className?: string;
22
+ }
23
+ export declare function BugHunterLeaderboardView({ isLoading, isEmpty, renderList, renderEntry, entries, renderEmpty, renderSkeleton, labels, className, }: BugHunterLeaderboardViewProps): React.JSX.Element;
@@ -0,0 +1,18 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React from "react";
3
+ import { Div, Row, Span, Stack, Text } from "../../../ui";
4
+ export function BugHunterLeaderboardView({ isLoading = false, isEmpty = false, renderList, renderEntry, entries = [], renderEmpty, renderSkeleton, labels = {}, className = "", }) {
5
+ if (isLoading) {
6
+ if (renderSkeleton)
7
+ return _jsx(_Fragment, { children: renderSkeleton() });
8
+ return _jsx(Row, { justify: "center", padding: "y-xl", children: "Loading\u2026" });
9
+ }
10
+ if (isEmpty) {
11
+ if (renderEmpty)
12
+ return _jsx(_Fragment, { children: renderEmpty() });
13
+ return (_jsx(Text, { paddingY: "md", variant: "secondary", size: "sm", align: "start", children: labels.noEntries ?? "No confirmed bugs yet." }));
14
+ }
15
+ if (renderList)
16
+ return _jsx(Div, { className: className, children: renderList() });
17
+ return (_jsx(Stack, { className: `${className}`, gap: "sm", children: entries.map((entry, i) => renderEntry ? (_jsx(React.Fragment, { children: renderEntry(entry, i) }, entry.hunterId)) : (_jsxs(Div, { layout: "flex", align: "center", justify: "between", rounded: "xl", padding: "sm", className: "border", "data-section": "bughunterleaderboard-div", children: [_jsxs(Span, { weight: "medium", children: ["#", entry.rank, " ", entry.hunterName] }), _jsxs(Span, { children: [entry.bugCount, " ", labels.bugs ?? "bugs"] })] }, entry.hunterId))) }));
18
+ }
@@ -9,3 +9,5 @@ export { AdminTesterFeedbackReportView } from "./AdminTesterFeedbackReportView";
9
9
  export { AdminTesterFeedbackIssuesView } from "./AdminTesterFeedbackIssuesView";
10
10
  export { AdminTesterFeedbackListView } from "./AdminTesterFeedbackListView";
11
11
  export type { AdminTesterFeedbackListViewProps } from "./AdminTesterFeedbackListView";
12
+ export { BugHunterLeaderboardView } from "./BugHunterLeaderboardView";
13
+ export type { BugHunterLeaderboardViewProps } from "./BugHunterLeaderboardView";
@@ -5,3 +5,4 @@ export { AdminTesterFeedbackView } from "./AdminTesterFeedbackView";
5
5
  export { AdminTesterFeedbackReportView } from "./AdminTesterFeedbackReportView";
6
6
  export { AdminTesterFeedbackIssuesView } from "./AdminTesterFeedbackIssuesView";
7
7
  export { AdminTesterFeedbackListView } from "./AdminTesterFeedbackListView";
8
+ export { BugHunterLeaderboardView } from "./BugHunterLeaderboardView";
@@ -1,6 +1,6 @@
1
1
  import { BaseRepository } from "../../../providers/db-firebase";
2
2
  import type { FirebaseSieveFields, FirebaseSieveResult, SieveModel } from "../../../providers/db-firebase";
3
- import { type TesterChecklistItemDocument, type TesterChecklistItemCreateInput, type TesterChecklistItemUpdateInput } from "../schemas/firestore";
3
+ import { type TesterChecklistItemDocument, type TesterChecklistItemCreateInput, type TesterChecklistItemUpdateInput, type BugHunterLeaderboardEntry } from "../schemas/firestore";
4
4
  export declare class TesterChecklistItemRepository extends BaseRepository<TesterChecklistItemDocument> {
5
5
  static readonly SIEVE_FIELDS: FirebaseSieveFields;
6
6
  constructor();
@@ -9,5 +9,16 @@ export declare class TesterChecklistItemRepository extends BaseRepository<Tester
9
9
  update(id: string, data: TesterChecklistItemUpdateInput): Promise<TesterChecklistItemDocument>;
10
10
  listActive(): Promise<TesterChecklistItemDocument[]>;
11
11
  list(model: SieveModel): Promise<FirebaseSieveResult<TesterChecklistItemDocument>>;
12
+ /** Confirms a reported "No" as a real bug: credits the reporting tester and
13
+ * disables the case for all other testers. Credit is permanent — never
14
+ * touched again by reopenAsNewVersion(). */
15
+ confirmBug(id: string, hunterId: string, hunterName: string): Promise<TesterChecklistItemDocument>;
16
+ /** Reopens a fixed, bug-confirmed case as a new, active version for retest.
17
+ * The old item stays disabled forever with its bug-hunter credit intact. */
18
+ reopenAsNewVersion(oldItemId: string): Promise<TesterChecklistItemDocument>;
19
+ /** Single-query, in-memory aggregation of bug credits per hunter — mirrors
20
+ * EventEntryRepository.getLeaderboard()'s shape. Includes old/disabled/
21
+ * superseded items, since bug credit is permanent. */
22
+ getBugHunterLeaderboard(limit?: number): Promise<BugHunterLeaderboardEntry[]>;
12
23
  }
13
24
  export declare const testerChecklistItemRepository: TesterChecklistItemRepository;
@@ -42,6 +42,85 @@ export class TesterChecklistItemRepository extends BaseRepository {
42
42
  async list(model) {
43
43
  return this.sieveQuery(model, TesterChecklistItemRepository.SIEVE_FIELDS);
44
44
  }
45
+ /** Confirms a reported "No" as a real bug: credits the reporting tester and
46
+ * disables the case for all other testers. Credit is permanent — never
47
+ * touched again by reopenAsNewVersion(). */
48
+ async confirmBug(id, hunterId, hunterName) {
49
+ const current = await this.findById(id);
50
+ if (!current) {
51
+ throw new DatabaseError(`Failed to confirm bug: missing checklist item ${id}`);
52
+ }
53
+ if (current.bugConfirmed) {
54
+ throw new DatabaseError(`Checklist item ${id} already has a confirmed bug`);
55
+ }
56
+ return this.update(id, {
57
+ isActive: false,
58
+ bugConfirmed: true,
59
+ bugHunterId: hunterId,
60
+ bugHunterName: hunterName,
61
+ bugConfirmedAt: new Date(),
62
+ });
63
+ }
64
+ /** Reopens a fixed, bug-confirmed case as a new, active version for retest.
65
+ * The old item stays disabled forever with its bug-hunter credit intact. */
66
+ async reopenAsNewVersion(oldItemId) {
67
+ const old = await this.findById(oldItemId);
68
+ if (!old) {
69
+ throw new DatabaseError(`Failed to reopen checklist item: missing document ${oldItemId}`);
70
+ }
71
+ if (!old.bugConfirmed) {
72
+ throw new DatabaseError(`Checklist item ${oldItemId} has no confirmed bug to reopen`);
73
+ }
74
+ if (old.supersededByItemId) {
75
+ throw new DatabaseError(`Checklist item ${oldItemId} has already been reopened`);
76
+ }
77
+ const nextVersion = (old.version ?? 1) + 1;
78
+ const newId = `${old.id}-v${nextVersion}`;
79
+ const newItem = await this.createWithId(newId, {
80
+ groupKey: old.groupKey,
81
+ groupLabel: old.groupLabel,
82
+ pageKey: old.pageKey,
83
+ pageLabel: old.pageLabel,
84
+ label: old.label,
85
+ description: old.description,
86
+ href: old.href,
87
+ order: old.order,
88
+ phase: old.phase,
89
+ adminOnly: old.adminOnly,
90
+ isActive: true,
91
+ version: nextVersion,
92
+ previousVersionId: old.id,
93
+ searchTokens: this.buildSearchTokens(old),
94
+ });
95
+ await this.update(old.id, { supersededByItemId: newId });
96
+ return newItem;
97
+ }
98
+ /** Single-query, in-memory aggregation of bug credits per hunter — mirrors
99
+ * EventEntryRepository.getLeaderboard()'s shape. Includes old/disabled/
100
+ * superseded items, since bug credit is permanent. */
101
+ async getBugHunterLeaderboard(limit = 50) {
102
+ const snapshot = await this.db
103
+ .collection(this.collection)
104
+ .where(TESTER_CHECKLIST_ITEM_FIELDS.BUG_CONFIRMED, "==", true)
105
+ .get();
106
+ const byHunter = new Map();
107
+ for (const doc of snapshot.docs) {
108
+ const item = this.mapDoc(doc);
109
+ if (!item.bugHunterId)
110
+ continue;
111
+ const entry = byHunter.get(item.bugHunterId) ?? {
112
+ name: item.bugHunterName ?? "Unknown tester",
113
+ count: 0,
114
+ };
115
+ entry.count += 1;
116
+ byHunter.set(item.bugHunterId, entry);
117
+ }
118
+ return Array.from(byHunter.entries())
119
+ .map(([hunterId, v]) => ({ hunterId, hunterName: v.name, bugCount: v.count }))
120
+ .sort((a, b) => b.bugCount - a.bugCount)
121
+ .slice(0, limit)
122
+ .map((entry, index) => ({ ...entry, rank: index + 1 }));
123
+ }
45
124
  }
46
125
  TesterChecklistItemRepository.SIEVE_FIELDS = {
47
126
  groupKey: { canFilter: true, canSort: true },
@@ -52,5 +131,6 @@ TesterChecklistItemRepository.SIEVE_FIELDS = {
52
131
  isActive: { canFilter: true, canSort: false },
53
132
  searchTokens: { canFilter: true, canSort: false },
54
133
  createdAt: { canFilter: true, canSort: true },
134
+ bugConfirmed: { canFilter: true, canSort: false },
55
135
  };
56
136
  export const testerChecklistItemRepository = new TesterChecklistItemRepository();
@@ -27,6 +27,10 @@ export type CoverageIssue = TesterChecklistResponseDocument & {
27
27
  groupLabel: string;
28
28
  pageLabel: string;
29
29
  label: string;
30
+ bugConfirmed?: boolean;
31
+ bugHunterId?: string;
32
+ bugHunterName?: string;
33
+ supersededByItemId?: string;
30
34
  };
31
35
  export interface CoverageReport {
32
36
  itemCoverage: ChecklistItemCoverage[];
@@ -105,6 +105,10 @@ export class TesterChecklistResponseRepository extends BaseRepository {
105
105
  groupLabel,
106
106
  pageLabel,
107
107
  label: catalogItem?.label ?? doc.checklistItemId,
108
+ bugConfirmed: catalogItem?.bugConfirmed,
109
+ bugHunterId: catalogItem?.bugHunterId,
110
+ bugHunterName: catalogItem?.bugHunterName,
111
+ supersededByItemId: catalogItem?.supersededByItemId,
108
112
  });
109
113
  }
110
114
  }
@@ -19,18 +19,32 @@ export interface TesterChecklistItemDocument extends BaseDocument {
19
19
  isActive: boolean;
20
20
  searchTokens: string[];
21
21
  adminOnly?: boolean;
22
+ bugConfirmed?: boolean;
23
+ bugHunterId?: string;
24
+ bugHunterName?: string;
25
+ bugConfirmedAt?: Date;
26
+ version?: number;
27
+ previousVersionId?: string;
28
+ supersededByItemId?: string;
22
29
  }
23
30
  export declare const TESTER_CHECKLIST_ITEM_COLLECTION: "testerChecklistItems";
24
31
  export declare const TESTER_CHECKLIST_ITEM_ID_PREFIX: "checklist-";
25
32
  export declare const TESTER_CHECKLIST_ITEM_INDEXED_FIELDS: readonly ["groupKey", "pageKey", "phase", "order", "isActive", "searchTokens", "createdAt"];
26
33
  export declare const DEFAULT_TESTER_CHECKLIST_ITEM_DATA: Partial<TesterChecklistItemDocument>;
27
34
  export type TesterChecklistItemCreateInput = Omit<TesterChecklistItemDocument, "id" | "createdAt" | "updatedAt" | "searchTokens">;
28
- export type TesterChecklistItemUpdateInput = Partial<Pick<TesterChecklistItemDocument, "groupKey" | "groupLabel" | "pageKey" | "pageLabel" | "label" | "description" | "href" | "order" | "phase" | "isActive" | "adminOnly">>;
35
+ export type TesterChecklistItemUpdateInput = Partial<Pick<TesterChecklistItemDocument, "groupKey" | "groupLabel" | "pageKey" | "pageLabel" | "label" | "description" | "href" | "order" | "phase" | "isActive" | "adminOnly" | "bugConfirmed" | "bugHunterId" | "bugHunterName" | "bugConfirmedAt" | "version" | "previousVersionId" | "supersededByItemId">>;
29
36
  export declare const testerChecklistItemQueryHelpers: {
30
37
  readonly byGroup: (groupKey: string) => readonly ["groupKey", "==", string];
31
38
  readonly byPage: (pageKey: string) => readonly ["pageKey", "==", string];
32
39
  readonly active: () => readonly ["isActive", "==", true];
40
+ readonly confirmedBugs: () => readonly ["bugConfirmed", "==", true];
33
41
  };
42
+ export interface BugHunterLeaderboardEntry {
43
+ rank: number;
44
+ hunterId: string;
45
+ hunterName: string;
46
+ bugCount: number;
47
+ }
34
48
  export declare function slugifyChecklistLabel(label: string): string;
35
49
  export declare function createChecklistItemId(groupKey: string, pageKey: string, label: string): string;
36
50
  export type TesterAnswer = "yes" | "no";
@@ -72,6 +86,13 @@ export declare const TESTER_CHECKLIST_ITEM_FIELDS: {
72
86
  readonly IS_ACTIVE: "isActive";
73
87
  readonly SEARCH_TOKENS: "searchTokens";
74
88
  readonly ADMIN_ONLY: "adminOnly";
89
+ readonly BUG_CONFIRMED: "bugConfirmed";
90
+ readonly BUG_HUNTER_ID: "bugHunterId";
91
+ readonly BUG_HUNTER_NAME: "bugHunterName";
92
+ readonly BUG_CONFIRMED_AT: "bugConfirmedAt";
93
+ readonly VERSION: "version";
94
+ readonly PREVIOUS_VERSION_ID: "previousVersionId";
95
+ readonly SUPERSEDED_BY_ITEM_ID: "supersededByItemId";
75
96
  readonly CREATED_AT: "createdAt";
76
97
  readonly UPDATED_AT: "updatedAt";
77
98
  };