@mohasinac/appkit 4.1.3 → 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 (276) 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.d.ts +15 -0
  8. package/dist/_internal/server/features/checkout/actions.js +249 -257
  9. package/dist/_internal/server/features/checkout/prize-bundle-gates.d.ts +5 -16
  10. package/dist/_internal/server/features/checkout/prize-bundle-gates.js +5 -30
  11. package/dist/_internal/server/features/orders/adapters.js +1 -14
  12. package/dist/_internal/server/features/products/actions.js +8 -3
  13. package/dist/_internal/server/features/products/service.d.ts +15 -0
  14. package/dist/_internal/server/features/products/service.js +41 -0
  15. package/dist/_internal/server/functions/firestore.d.ts +3 -1
  16. package/dist/_internal/server/functions/firestore.js +17 -1
  17. package/dist/_internal/server/functions/scheduled.d.ts +3 -5
  18. package/dist/_internal/server/functions/scheduled.js +14 -30
  19. package/dist/_internal/server/jobs/core/bundleStockSync.js +25 -7
  20. package/dist/_internal/server/jobs/core/countersReconcile.js +7 -1
  21. package/dist/_internal/server/jobs/core/index.d.ts +4 -4
  22. package/dist/_internal/server/jobs/core/index.js +4 -4
  23. package/dist/_internal/server/jobs/core/jobRunners.js +10 -0
  24. package/dist/_internal/server/jobs/core/newsletterExport.d.ts +15 -0
  25. package/dist/_internal/server/jobs/core/newsletterExport.js +43 -0
  26. package/dist/_internal/server/jobs/core/onOrderStatusChange.d.ts +2 -0
  27. package/dist/_internal/server/jobs/core/onOrderStatusChange.js +1 -0
  28. package/dist/_internal/server/jobs/core/onPrizeDrawPaymentConfirmed.d.ts +19 -0
  29. package/dist/_internal/server/jobs/core/onPrizeDrawPaymentConfirmed.js +30 -0
  30. package/dist/_internal/server/jobs/core/paymentWindowTimeout.d.ts +2 -2
  31. package/dist/_internal/server/jobs/core/paymentWindowTimeout.js +3 -2
  32. package/dist/_internal/server/jobs/core/pendingOrderTimeout.js +1 -0
  33. package/dist/_internal/server/jobs/core/prizeDrawAssignWinner.d.ts +20 -0
  34. package/dist/_internal/server/jobs/core/prizeDrawAssignWinner.js +113 -0
  35. package/dist/_internal/server/jobs/core/prizeDrawExpiryReveal.d.ts +14 -0
  36. package/dist/_internal/server/jobs/core/prizeDrawExpiryReveal.js +74 -0
  37. package/dist/_internal/server/jobs/core/prizeDrawSoldOutReveal.d.ts +22 -0
  38. package/dist/_internal/server/jobs/core/prizeDrawSoldOutReveal.js +68 -0
  39. package/dist/_internal/server/jobs/core/revenueRollup.d.ts +13 -0
  40. package/dist/_internal/server/jobs/core/revenueRollup.js +22 -0
  41. package/dist/_internal/server/jobs/core/whatsappNotify.d.ts +35 -0
  42. package/dist/_internal/server/jobs/core/whatsappNotify.js +82 -0
  43. package/dist/_internal/server/jobs/handlers/index.d.ts +4 -4
  44. package/dist/_internal/server/jobs/handlers/index.js +9 -6
  45. package/dist/_internal/server/jobs/handlers/onPrizeDrawPaymentConfirmed.d.ts +3 -0
  46. package/dist/_internal/server/jobs/handlers/onPrizeDrawPaymentConfirmed.js +11 -0
  47. package/dist/_internal/server/jobs/handlers/prizeDrawExpiryReveal.d.ts +2 -0
  48. package/dist/_internal/server/jobs/handlers/prizeDrawExpiryReveal.js +2 -0
  49. package/dist/_internal/server/jobs/handlers/prizeDrawSoldOutReveal.d.ts +3 -0
  50. package/dist/_internal/server/jobs/handlers/prizeDrawSoldOutReveal.js +4 -0
  51. package/dist/_internal/server/jobs/handlers/revenueRollup.d.ts +2 -0
  52. package/dist/_internal/server/jobs/handlers/revenueRollup.js +2 -0
  53. package/dist/_internal/shared/actions/action-registry.js +40 -10
  54. package/dist/_internal/shared/checkout/rules/_defaults.js +1 -2
  55. package/dist/_internal/shared/checkout/rules/_limits.d.ts +3 -0
  56. package/dist/_internal/shared/checkout/rules/_limits.js +3 -0
  57. package/dist/_internal/shared/checkout/rules/_registry.d.ts +2 -1
  58. package/dist/_internal/shared/checkout/rules/_registry.js +2 -2
  59. package/dist/_internal/shared/checkout/rules/classified.rule.js +0 -1
  60. package/dist/_internal/shared/checkout/rules/index.d.ts +1 -1
  61. package/dist/_internal/shared/checkout/rules/index.js +1 -1
  62. package/dist/_internal/shared/checkout/rules/prize-draw.rule.js +20 -1
  63. package/dist/_internal/shared/checkout/rules/types.d.ts +17 -7
  64. package/dist/_internal/shared/features/categories/bundle-copy.d.ts +2 -0
  65. package/dist/_internal/shared/features/categories/bundle-copy.js +2 -0
  66. package/dist/_internal/shared/features/categories/bundle-pricing.d.ts +23 -0
  67. package/dist/_internal/shared/features/categories/bundle-pricing.js +25 -0
  68. package/dist/_internal/shared/features/checkout/config.d.ts +7 -0
  69. package/dist/_internal/shared/features/checkout/config.js +7 -0
  70. package/dist/_internal/shared/fees/calculator.d.ts +26 -0
  71. package/dist/_internal/shared/fees/calculator.js +25 -0
  72. package/dist/_internal/shared/listing-types/prize-draw/config.js +1 -1
  73. package/dist/constants/api-endpoints.d.ts +6 -9
  74. package/dist/constants/api-endpoints.js +5 -5
  75. package/dist/constants/field-names.d.ts +17 -1
  76. package/dist/constants/field-names.js +17 -1
  77. package/dist/features/about/components/FeesView.js +18 -0
  78. package/dist/features/account/hooks/useNotifications.js +5 -0
  79. package/dist/features/admin/actions/admin-actions.js +6 -0
  80. package/dist/features/admin/actions/notification-actions.d.ts +4 -1
  81. package/dist/features/admin/actions/notification-actions.js +57 -12
  82. package/dist/features/admin/components/AdminAdsView.js +1 -1
  83. package/dist/features/admin/components/AdminBlogEditorView.js +1 -1
  84. package/dist/features/admin/components/AdminNewsletterView.js +42 -11
  85. package/dist/features/admin/components/AdminPrizeDrawsView.js +3 -3
  86. package/dist/features/admin/components/AdminSiteSettingsView.js +115 -11
  87. package/dist/features/admin/components/AdminTesterChecklistView.js +74 -22
  88. package/dist/features/admin/hooks/useAdminListing.d.ts +4 -0
  89. package/dist/features/admin/hooks/useAdminListing.js +4 -2
  90. package/dist/features/admin/repository/analytics-rollup.repository.d.ts +25 -0
  91. package/dist/features/admin/repository/analytics-rollup.repository.js +31 -0
  92. package/dist/features/admin/repository/site-settings.repository.js +8 -1
  93. package/dist/features/admin/schemas/firestore.d.ts +54 -5
  94. package/dist/features/admin/schemas/firestore.js +63 -26
  95. package/dist/features/auctions/actions/bid-actions.js +3 -1
  96. package/dist/features/auctions/components/AuctionBidsTable.d.ts +6 -1
  97. package/dist/features/auctions/components/AuctionBidsTable.js +8 -4
  98. package/dist/features/auctions/components/AuctionDetailPageView.js +10 -13
  99. package/dist/features/auctions/components/CollapsibleBidHistory.d.ts +23 -2
  100. package/dist/features/auctions/components/CollapsibleBidHistory.js +21 -3
  101. package/dist/features/auctions/components/LiveBidPrice.d.ts +19 -0
  102. package/dist/features/auctions/components/LiveBidPrice.js +17 -0
  103. package/dist/features/auctions/components/PlaceBidFormClient.d.ts +1 -1
  104. package/dist/features/auctions/components/PlaceBidFormClient.js +16 -1
  105. package/dist/features/auctions/hooks/useBids.d.ts +33 -0
  106. package/dist/features/auctions/hooks/useBids.js +32 -0
  107. package/dist/features/auctions/hooks/useLiveAuctionBid.d.ts +20 -0
  108. package/dist/features/auctions/hooks/useLiveAuctionBid.js +23 -0
  109. package/dist/features/auth/checkout-value-otp.d.ts +7 -10
  110. package/dist/features/auth/checkout-value-otp.js +14 -10
  111. package/dist/features/auth/server.d.ts +0 -1
  112. package/dist/features/auth/server.js +0 -1
  113. package/dist/features/blog/api/[slug]/route.d.ts +5 -0
  114. package/dist/features/blog/api/[slug]/route.js +12 -3
  115. package/dist/features/blog/components/BlogPostView.d.ts +2 -0
  116. package/dist/features/blog/components/BlogPostView.js +5 -4
  117. package/dist/features/blog/hooks/useBlog.d.ts +2 -0
  118. package/dist/features/blog/hooks/useBlog.js +2 -0
  119. package/dist/features/blog/repository/blog.repository.d.ts +7 -0
  120. package/dist/features/blog/repository/blog.repository.js +44 -0
  121. package/dist/features/blog/schemas/firestore.d.ts +3 -0
  122. package/dist/features/blog/schemas/firestore.js +1 -0
  123. package/dist/features/blog/types/index.d.ts +2 -0
  124. package/dist/features/cart/components/index.d.ts +0 -2
  125. package/dist/features/cart/components/index.js +0 -1
  126. package/dist/features/cart/hooks/useCartCount.js +22 -5
  127. package/dist/features/cart/hooks/useGuestCart.js +11 -1
  128. package/dist/features/cart/utils/guest-cart.d.ts +4 -0
  129. package/dist/features/cart/utils/guest-cart.js +10 -0
  130. package/dist/features/cart/utils/pending-ops.d.ts +17 -0
  131. package/dist/features/cart/utils/pending-ops.js +46 -5
  132. package/dist/features/categories/components/BrandDetailPageView.js +6 -2
  133. package/dist/features/categories/components/BundleDetailView.js +4 -2
  134. package/dist/features/categories/components/CategoryDetailPageView.js +10 -2
  135. package/dist/features/categories/schemas/firestore.d.ts +7 -0
  136. package/dist/features/checkout/actions/index.d.ts +0 -1
  137. package/dist/features/checkout/actions/index.js +0 -1
  138. package/dist/features/checkout/schemas/firestore.d.ts +2 -2
  139. package/dist/features/events/actions/event-actions.d.ts +2 -0
  140. package/dist/features/events/actions/event-actions.js +5 -0
  141. package/dist/features/events/components/EventCard.js +1 -2
  142. package/dist/features/events/components/RelatedEventsCarousel.d.ts +15 -0
  143. package/dist/features/events/components/RelatedEventsCarousel.js +13 -0
  144. package/dist/features/events/components/index.d.ts +2 -0
  145. package/dist/features/events/components/index.js +1 -0
  146. package/dist/features/events/repository/events.repository.d.ts +6 -0
  147. package/dist/features/events/repository/events.repository.js +21 -0
  148. package/dist/features/history/repository/user-history.repository.d.ts +1 -1
  149. package/dist/features/history/utils/guest-history.d.ts +1 -1
  150. package/dist/features/homepage/components/FeaturedBundlesSection.js +4 -2
  151. package/dist/features/homepage/components/FeaturedProductsSection.js +2 -2
  152. package/dist/features/homepage/components/SectionCarousel.js +1 -1
  153. package/dist/features/homepage/components/WelcomeSection.js +1 -1
  154. package/dist/features/layout/AppLayoutShell.js +1 -1
  155. package/dist/features/layout/BackToTop.d.ts +9 -5
  156. package/dist/features/layout/BackToTop.js +17 -21
  157. package/dist/features/layout/NavbarLayout.js +1 -1
  158. package/dist/features/layout/TitleBarLayout.js +2 -2
  159. package/dist/features/media/MediaVideo.d.ts +7 -0
  160. package/dist/features/media/MediaVideo.js +36 -5
  161. package/dist/features/orders/actions/refund-actions.js +1 -0
  162. package/dist/features/orders/components/OrdersList.js +1 -5
  163. package/dist/features/orders/schemas/firestore.d.ts +16 -8
  164. package/dist/features/orders/schemas/index.d.ts +3 -32
  165. package/dist/features/orders/schemas/index.js +1 -4
  166. package/dist/features/orders/types/index.d.ts +6 -9
  167. package/dist/features/pre-orders/components/MarketplacePreorderCard.js +1 -1
  168. package/dist/features/pre-orders/components/PreOrderDetailPageView.js +2 -1
  169. package/dist/features/products/actions/product-actions.js +13 -10
  170. package/dist/features/products/components/MarketplaceBundleCard.d.ts +1 -1
  171. package/dist/features/products/components/MarketplaceBundleCard.js +7 -4
  172. package/dist/features/products/components/PrizeDrawDetailPageView.js +1 -1
  173. package/dist/features/products/components/PrizeDrawItemsEditor.js +2 -2
  174. package/dist/features/products/components/PrizeDrawWinnerMappingView.d.ts +14 -0
  175. package/dist/features/products/components/PrizeDrawWinnerMappingView.js +37 -0
  176. package/dist/features/products/components/PrizeRevealModal.d.ts +10 -23
  177. package/dist/features/products/components/PrizeRevealModal.js +18 -147
  178. package/dist/features/products/components/ProductDetailPageView.js +72 -30
  179. package/dist/features/products/components/ProductForm.js +7 -14
  180. package/dist/features/products/components/ProductGalleryClient.d.ts +10 -1
  181. package/dist/features/products/components/ProductGalleryClient.js +29 -10
  182. package/dist/features/products/components/ProductsIndexListing.js +2 -1
  183. package/dist/features/products/components/RelatedProductsCarousel.js +2 -2
  184. package/dist/features/products/components/ShowGroupSection.js +2 -2
  185. package/dist/features/products/components/SublistingCarouselSection.js +1 -1
  186. package/dist/features/products/components/index.d.ts +4 -2
  187. package/dist/features/products/components/index.js +1 -0
  188. package/dist/features/products/constants/action-defs.d.ts +28 -4
  189. package/dist/features/products/constants/action-defs.js +76 -3
  190. package/dist/features/products/repository/products.repository.d.ts +6 -0
  191. package/dist/features/products/repository/products.repository.js +20 -1
  192. package/dist/features/products/schemas/firestore.d.ts +26 -2
  193. package/dist/features/products/schemas/index.d.ts +11 -3
  194. package/dist/features/products/schemas/index.js +10 -2
  195. package/dist/features/products/types/index.d.ts +2 -1
  196. package/dist/features/reviews/actions/review-actions.js +1 -0
  197. package/dist/features/reviews/components/ReviewDetailPageView.js +8 -1
  198. package/dist/features/reviews/components/ReviewDetailShell.js +2 -1
  199. package/dist/features/reviews/components/ReviewsList.js +2 -1
  200. package/dist/features/reviews/schemas/firestore.d.ts +3 -0
  201. package/dist/features/reviews/schemas/index.d.ts +6 -0
  202. package/dist/features/reviews/schemas/index.js +2 -0
  203. package/dist/features/reviews/types/index.d.ts +3 -0
  204. package/dist/features/scams/actions/scam-actions.d.ts +3 -0
  205. package/dist/features/scams/actions/scam-actions.js +3 -2
  206. package/dist/features/scams/components/ScamProfileView.d.ts +2 -1
  207. package/dist/features/scams/components/ScamProfileView.js +2 -2
  208. package/dist/features/scams/repository/scammer.repository.d.ts +6 -0
  209. package/dist/features/scams/repository/scammer.repository.js +24 -0
  210. package/dist/features/seller/actions/seller-actions.js +8 -0
  211. package/dist/features/seller/components/SellerOrdersView.js +1 -1
  212. package/dist/features/seller/components/SellerPrizeDrawsView.js +4 -0
  213. package/dist/features/stores/components/StoreDetailLayoutView.js +6 -6
  214. package/dist/features/stores/components/StoreNavTabs.d.ts +10 -1
  215. package/dist/features/stores/components/StoreNavTabs.js +21 -9
  216. package/dist/features/tester/actions/index.d.ts +1 -0
  217. package/dist/features/tester/actions/index.js +1 -0
  218. package/dist/features/tester/actions/leaderboard-actions.d.ts +3 -0
  219. package/dist/features/tester/actions/leaderboard-actions.js +5 -0
  220. package/dist/features/tester/components/AdminTesterFeedbackIssuesView.js +26 -4
  221. package/dist/features/tester/components/AdminTesterFeedbackListView.js +13 -0
  222. package/dist/features/tester/components/BugHunterLeaderboardView.d.ts +23 -0
  223. package/dist/features/tester/components/BugHunterLeaderboardView.js +18 -0
  224. package/dist/features/tester/components/index.d.ts +2 -0
  225. package/dist/features/tester/components/index.js +1 -0
  226. package/dist/features/tester/repository/tester-checklist-item.repository.d.ts +12 -1
  227. package/dist/features/tester/repository/tester-checklist-item.repository.js +80 -0
  228. package/dist/features/tester/repository/tester-checklist-response.repository.d.ts +4 -0
  229. package/dist/features/tester/repository/tester-checklist-response.repository.js +4 -0
  230. package/dist/features/tester/schemas/firestore.d.ts +22 -1
  231. package/dist/features/tester/schemas/firestore.js +8 -0
  232. package/dist/features/tester/seed-data/categories-tester-seed-data.js +42 -7
  233. package/dist/features/tester/seed-data/events-tester-seed-data.js +23 -0
  234. package/dist/features/tester/seed-data/orders-tester-seed-data.js +69 -31
  235. package/dist/features/tester/seed-data/products-tester-seed-data.js +152 -8
  236. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +249 -14
  237. package/dist/features/whatsapp-bot/helpers/whatsapp.d.ts +10 -1
  238. package/dist/features/whatsapp-bot/helpers/whatsapp.js +47 -0
  239. package/dist/features/whatsapp-bot/types/index.d.ts +19 -0
  240. package/dist/features/wishlist/hooks/useGuestWishlist.js +11 -1
  241. package/dist/features/wishlist/hooks/useWishlistCount.js +16 -2
  242. package/dist/features/wishlist/utils/guest-wishlist.d.ts +5 -0
  243. package/dist/features/wishlist/utils/guest-wishlist.js +11 -0
  244. package/dist/index.d.ts +23 -24
  245. package/dist/index.js +29 -60
  246. package/dist/jobs.d.ts +1 -1
  247. package/dist/jobs.js +2 -2
  248. package/dist/next/routing/route-map.d.ts +4 -0
  249. package/dist/next/routing/route-map.js +2 -0
  250. package/dist/repositories/index.d.ts +2 -0
  251. package/dist/repositories/index.js +1 -0
  252. package/dist/schemas/registry.d.ts +6 -26
  253. package/dist/seed/bids-seed-data.js +14 -2
  254. package/dist/seed/blog-posts-seed-data.js +2 -1
  255. package/dist/seed/categories-seed-data.js +11 -2
  256. package/dist/seed/events-seed-data.js +11 -11
  257. package/dist/seed/faq-seed-data.js +5 -1
  258. package/dist/seed/products-auctions-seed-data.js +2 -2
  259. package/dist/seed/products-preorders-seed-data.d.ts +2 -1
  260. package/dist/seed/products-prize-draws-seed-data.js +56 -10
  261. package/dist/seed/products-standard-seed-data.js +82 -3
  262. package/dist/seed/scammers-seed-data.js +75 -2
  263. package/dist/seed/site-settings-seed-data.js +12 -2
  264. package/dist/server-entry.d.ts +0 -1
  265. package/dist/server-entry.js +0 -1
  266. package/dist/server.d.ts +6 -22
  267. package/dist/server.js +12 -59
  268. package/dist/styles.css +1 -1
  269. package/dist/tailwind-utilities.css +1 -1
  270. package/dist/ui/components/BaseListingCard.js +1 -1
  271. package/dist/ui/components/ImageLightbox.d.ts +9 -0
  272. package/dist/ui/components/ImageLightbox.js +6 -3
  273. package/dist/ui/components/ListingToolbar.d.ts +18 -1
  274. package/dist/ui/components/ListingToolbar.js +5 -2
  275. package/dist/ui/components/StickyToolbar.js +13 -8
  276. package/package.json +1 -1
@@ -7,6 +7,7 @@ import { maskName } from "../../../security";
7
7
  import { getDefaultLocale } from "../../../core/baseline-resolver";
8
8
  import { normalizeRichTextHtml } from "../../../utils/string.formatter";
9
9
  import { ROUTES } from "../../../next";
10
+ import { pluginFor } from "../../../_internal/shared/listing-types/_registry";
10
11
  export function ReviewCard({ review, context = "general", className = "" }) {
11
12
  const date = review.createdAt
12
13
  ? new Date(review.createdAt).toLocaleDateString(getDefaultLocale(), {
@@ -19,7 +20,7 @@ export function ReviewCard({ review, context = "general", className = "" }) {
19
20
  const initials = displayName.charAt(0).toUpperCase();
20
21
  const reviewHref = String(ROUTES.PUBLIC.REVIEW_DETAIL(review.id));
21
22
  const productHref = review.productId
22
- ? String(ROUTES.PUBLIC.PRODUCT_DETAIL(review.productId))
23
+ ? pluginFor(review.listingType ?? "standard").detailRoute(review.productId)
23
24
  : null;
24
25
  const profileHref = !review.isAnonymous && review.userId
25
26
  ? String(ROUTES.PUBLIC.PROFILE(review.userId))
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import type { ReviewStatus } from "../types";
5
5
  import type { BaseDocument } from "../../../_internal/shared/types/base-document";
6
+ import type { ListingType } from "../../products/types/index";
6
7
  /** Runtime-accessible review status values — use instead of bare string literals. */
7
8
  export declare const ReviewStatusValues: {
8
9
  readonly PENDING: "pending";
@@ -19,6 +20,8 @@ export interface ReviewVideoField {
19
20
  export interface ReviewDocument extends BaseDocument {
20
21
  productId: string;
21
22
  productTitle: string;
23
+ /** Denormalized from the product at review-creation time — drives the correct detail-page link (e.g. prize-draw items link to /prize-draws/[slug], not /products/[slug]). */
24
+ listingType?: ListingType;
22
25
  storeId?: string;
23
26
  storeName?: string;
24
27
  userId: string;
@@ -37,6 +37,7 @@ export declare const reviewFirestoreSchema: z.ZodObject<{
37
37
  id: z.ZodString;
38
38
  productId: z.ZodString;
39
39
  productTitle: z.ZodString;
40
+ listingType: z.ZodOptional<z.ZodString>;
40
41
  storeId: z.ZodOptional<z.ZodString>;
41
42
  storeName: z.ZodOptional<z.ZodString>;
42
43
  userId: z.ZodString;
@@ -126,6 +127,7 @@ export declare const reviewFirestoreSchema: z.ZodObject<{
126
127
  } | undefined;
127
128
  storeId?: string | undefined;
128
129
  featured?: boolean | undefined;
130
+ listingType?: string | undefined;
129
131
  images?: string[] | undefined;
130
132
  storeName?: string | undefined;
131
133
  userAvatar?: string | undefined;
@@ -173,6 +175,7 @@ export declare const reviewFirestoreSchema: z.ZodObject<{
173
175
  } | undefined;
174
176
  storeId?: string | undefined;
175
177
  featured?: boolean | undefined;
178
+ listingType?: string | undefined;
176
179
  images?: string[] | undefined;
177
180
  storeName?: string | undefined;
178
181
  userAvatar?: string | undefined;
@@ -241,6 +244,7 @@ export declare const reviewSchema: z.ZodObject<{
241
244
  id: z.ZodString;
242
245
  productId: z.ZodString;
243
246
  productTitle: z.ZodOptional<z.ZodString>;
247
+ listingType: z.ZodOptional<z.ZodString>;
244
248
  storeSlug: z.ZodOptional<z.ZodString>;
245
249
  storeName: z.ZodOptional<z.ZodString>;
246
250
  userId: z.ZodString;
@@ -303,6 +307,7 @@ export declare const reviewSchema: z.ZodObject<{
303
307
  createdAt?: string | undefined;
304
308
  updatedAt?: string | undefined;
305
309
  featured?: boolean | undefined;
310
+ listingType?: string | undefined;
306
311
  images?: {
307
312
  url: string;
308
313
  thumbnailUrl?: string | undefined;
@@ -333,6 +338,7 @@ export declare const reviewSchema: z.ZodObject<{
333
338
  createdAt?: string | undefined;
334
339
  updatedAt?: string | undefined;
335
340
  featured?: boolean | undefined;
341
+ listingType?: string | undefined;
336
342
  images?: {
337
343
  url: string;
338
344
  thumbnailUrl?: string | undefined;
@@ -14,6 +14,7 @@ export const reviewFirestoreSchema = z.object({
14
14
  id: z.string(),
15
15
  productId: z.string(),
16
16
  productTitle: z.string(),
17
+ listingType: z.string().optional(),
17
18
  storeId: z.string().optional(),
18
19
  storeName: z.string().optional(),
19
20
  userId: z.string(),
@@ -72,6 +73,7 @@ export const reviewSchema = z.object({
72
73
  id: z.string(),
73
74
  productId: z.string(),
74
75
  productTitle: z.string().optional(),
76
+ listingType: z.string().optional(),
75
77
  storeSlug: z.string().optional(),
76
78
  storeName: z.string().optional(),
77
79
  userId: z.string(),
@@ -1,3 +1,4 @@
1
+ import type { ListingType } from "../../products/types/index";
1
2
  export type ReviewStatus = "pending" | "approved" | "rejected";
2
3
  export interface ReviewImage {
3
4
  url: string;
@@ -14,6 +15,8 @@ export interface Review {
14
15
  id: string;
15
16
  productId: string;
16
17
  productTitle?: string;
18
+ /** Denormalized from the product at review-creation time — drives the correct detail-page link. */
19
+ listingType?: ListingType;
17
20
  storeSlug?: string;
18
21
  storeName?: string;
19
22
  userId: string;
@@ -3,7 +3,10 @@ export interface ScammerProfilePageData {
3
3
  scammer: ScammerDocument;
4
4
  incidents: ScammerIncidentDocument[];
5
5
  comments: ScammerCommentDocument[];
6
+ /** Admin-curated cross-links — confirmed to be the same person under a different alias. */
6
7
  relatedScammers: ScammerDocument[];
8
+ /** Other verified profiles with the same scamType — pattern similarity only, never identity. */
9
+ similarScamReports: ScammerDocument[];
7
10
  }
8
11
  export interface ScammerListResult {
9
12
  items: ScammerDocument[];
@@ -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
  }
@@ -151,7 +151,7 @@ function OrderDetailDrawer({ orderId, apiBase, onClose, }) {
151
151
  };
152
152
  const addr = order?.shippingAddress ?? {};
153
153
  const addrLine = [addr.addressLine1, addr.city, addr.state, addr.pincode].filter(Boolean).join(", ");
154
- return (_jsxs(SideDrawer, { isOpen: true, title: `Order ${order?.id ?? orderId}`, onClose: onClose, children: [loading && (_jsx(Row, { align: "center", justify: "center", padding: "y-4xl", children: _jsx(Div, { className: "h-6 w-6 animate-spin border-2 border-[var(--appkit-color-primary)] border-t-transparent", rounded: "full" }) })), fetchError && (_jsx(Div, { textSize: "sm", className: "mx-4 mt-4 border border-error/20", color: "error", surface: "danger-surface", padding: "inline", rounded: "lg", children: fetchError })), order && !loading && (_jsxs(Stack, { gap: "none", children: [_jsxs(Stack, { className: `flex-1 ${__O.yAuto}`, gap: "5", padding: "md", children: [_jsxs(Row, { align: "center", justify: "between", children: [_jsx(Badge, { variant: STATUS_BADGE_VARIANT[order.status?.toUpperCase()] ?? "default", children: order.status ?? "Unknown" }), _jsx(Text, { size: "sm", className: "text-[var(--appkit-color-text-secondary)]", children: toRelativeDate(order.createdAt) })] }), (order.items ?? []).length > 0 && (_jsxs(Div, { children: [_jsx(Text, { size: "sm", className: "text-[var(--appkit-color-text-primary)] mb-2", weight: "semibold", children: "Items" }), _jsx(Div, { className: "divide-y divide-[var(--appkit-color-border)] divide-[var(--appkit-color-border)] border border-[var(--appkit-color-border)]", rounded: "lg", children: (order.items ?? []).map((item, i) => (_jsxs(Row, { paddingY: "y-xs-tall", padding: "x-sm", align: "center", justify: "between", gap: "3", children: [_jsxs(Div, { className: "min-w-0", children: [_jsx(Text, { size: "sm", className: "truncate", weight: "medium", children: item.title ?? item.productId ?? "Item" }), _jsxs(Text, { size: "xs", className: "text-[var(--appkit-color-text-secondary)]", children: ["Qty: ", item.quantity ?? 1] })] }), _jsx(Text, { size: "sm", className: "shrink-0", weight: "medium", children: toCurrency(item.price ?? 0) })] }, i))) })] })), _jsxs(Row, { surface: "muted", padding: "inline", align: "center", justify: "between", rounded: "lg", children: [_jsx(Text, { size: "sm", weight: "semibold", children: "Total" }), _jsx(Text, { size: "sm", className: "text-[var(--appkit-color-primary)]", weight: "bold", children: toCurrency(order.totalAmount ?? 0) })] }), addrLine && (_jsxs(Div, { children: [_jsx(Text, { size: "sm", className: "mb-1", weight: "semibold", children: "Shipping address" }), _jsx(Text, { size: "sm", className: "text-[var(--appkit-color-text-secondary)]", children: [String(addr.fullName ?? ""), addrLine].filter(Boolean).join(" · ") })] })), order.paymentMethod && (_jsxs(Div, { children: [_jsx(Text, { size: "sm", className: "mb-1", weight: "semibold", children: "Payment" }), _jsx(Text, { size: "sm", className: "text-[var(--appkit-color-text-secondary)]", transform: "capitalize", children: order.paymentMethod })] })), order.emiEnabled && (_jsxs(Div, { className: "border-t border-[var(--appkit-color-border)]", padding: "t-md", children: [_jsxs(Row, { align: "center", justify: "between", className: "mb-2", children: [_jsxs(Text, { size: "sm", weight: "semibold", children: ["EMI plan ", order.emiTenureMonths ? `· ${order.emiTenureMonths} months` : ""] }), _jsx(Badge, { variant: order.emiComplete ? "success" : "warning", children: order.emiComplete ? "Fully paid" : "In progress" })] }), _jsxs(Row, { align: "center", justify: "between", className: "mb-2", children: [_jsx(Text, { size: "xs", color: "muted", children: "Token collected" }), _jsx(Text, { size: "xs", weight: "medium", children: toCurrency(order.emiTokenAmount ?? 0) })] }), _jsxs(Row, { align: "center", justify: "between", className: "mb-3", children: [_jsx(Text, { size: "xs", color: "muted", children: "Remaining balance" }), _jsx(Text, { size: "xs", weight: "medium", children: toCurrency(order.emiRemainingBalance ?? 0) })] }), _jsx(Div, { className: "divide-y divide-[var(--appkit-color-border)] border border-[var(--appkit-color-border)]", rounded: "lg", children: (order.emiInstallments ?? []).map((inst) => (_jsxs(Row, { paddingY: "y-xs-tall", padding: "x-sm", align: "center", justify: "between", gap: "3", children: [_jsxs(Div, { className: "min-w-0", children: [_jsxs(Text, { size: "sm", weight: "medium", children: ["Installment ", inst.index, " \u00B7 ", toCurrency(inst.amount)] }), _jsx(Text, { size: "xs", color: "muted", children: inst.status === "paid" && inst.paidAt
154
+ return (_jsxs(SideDrawer, { isOpen: true, title: `Order ${order?.id ?? orderId}`, onClose: onClose, children: [loading && (_jsx(Row, { align: "center", justify: "center", padding: "y-4xl", children: _jsx(Div, { className: "h-6 w-6 animate-spin border-2 border-[var(--appkit-color-primary)] border-t-transparent", rounded: "full" }) })), fetchError && (_jsx(Div, { textSize: "sm", className: "mx-4 mt-4 border border-error/20", color: "error", surface: "danger-surface", padding: "inline", rounded: "lg", children: fetchError })), order && !loading && (_jsxs(Stack, { gap: "none", children: [_jsxs(Stack, { className: `flex-1 ${__O.yAuto}`, gap: "5", padding: "md", children: [_jsxs(Row, { align: "center", justify: "between", children: [_jsx(Badge, { variant: STATUS_BADGE_VARIANT[order.status?.toUpperCase()] ?? "default", children: order.status ?? "Unknown" }), _jsx(Text, { size: "sm", className: "text-[var(--appkit-color-text-secondary)]", children: toRelativeDate(order.createdAt) })] }), (order.items ?? []).length > 0 && (_jsxs(Div, { children: [_jsx(Text, { size: "sm", className: "text-[var(--appkit-color-text-primary)] mb-2", weight: "semibold", children: "Items" }), _jsx(Div, { className: "divide-y divide-[var(--appkit-color-border)] divide-[var(--appkit-color-border)] border border-[var(--appkit-color-border)]", rounded: "lg", children: (order.items ?? []).map((item, i) => (_jsxs(Row, { paddingY: "y-xs-tall", padding: "x-sm", align: "center", justify: "between", gap: "3", children: [_jsxs(Div, { className: "min-w-0", children: [_jsx(Text, { size: "sm", className: "truncate", weight: "medium", children: item.title ?? item.productId ?? "Item" }), _jsxs(Text, { size: "xs", className: "text-[var(--appkit-color-text-secondary)]", children: ["Qty: ", item.quantity ?? 1] })] }), _jsx(Text, { size: "sm", className: "shrink-0", weight: "medium", children: toCurrency(item.price ?? 0) })] }, i))) })] })), order.giftWrapAddon && (_jsxs(Div, { className: "border border-[var(--appkit-color-primary-200)] dark:border-[var(--appkit-color-primary-800)]", surface: "subtle", padding: "inline", rounded: "lg", children: [_jsx(Text, { size: "sm", weight: "semibold", children: "\uD83C\uDF81 Gift wrap requested" }), order.giftWrapMessage && (_jsxs(Text, { size: "sm", className: "mt-1 text-[var(--appkit-color-text-secondary)]", children: ["\u201C", order.giftWrapMessage, "\u201D"] }))] })), _jsxs(Row, { surface: "muted", padding: "inline", align: "center", justify: "between", rounded: "lg", children: [_jsx(Text, { size: "sm", weight: "semibold", children: "Total" }), _jsx(Text, { size: "sm", className: "text-[var(--appkit-color-primary)]", weight: "bold", children: toCurrency(order.totalAmount ?? 0) })] }), addrLine && (_jsxs(Div, { children: [_jsx(Text, { size: "sm", className: "mb-1", weight: "semibold", children: "Shipping address" }), _jsx(Text, { size: "sm", className: "text-[var(--appkit-color-text-secondary)]", children: [String(addr.fullName ?? ""), addrLine].filter(Boolean).join(" · ") })] })), order.paymentMethod && (_jsxs(Div, { children: [_jsx(Text, { size: "sm", className: "mb-1", weight: "semibold", children: "Payment" }), _jsx(Text, { size: "sm", className: "text-[var(--appkit-color-text-secondary)]", transform: "capitalize", children: order.paymentMethod })] })), order.emiEnabled && (_jsxs(Div, { className: "border-t border-[var(--appkit-color-border)]", padding: "t-md", children: [_jsxs(Row, { align: "center", justify: "between", className: "mb-2", children: [_jsxs(Text, { size: "sm", weight: "semibold", children: ["EMI plan ", order.emiTenureMonths ? `· ${order.emiTenureMonths} months` : ""] }), _jsx(Badge, { variant: order.emiComplete ? "success" : "warning", children: order.emiComplete ? "Fully paid" : "In progress" })] }), _jsxs(Row, { align: "center", justify: "between", className: "mb-2", children: [_jsx(Text, { size: "xs", color: "muted", children: "Token collected" }), _jsx(Text, { size: "xs", weight: "medium", children: toCurrency(order.emiTokenAmount ?? 0) })] }), _jsxs(Row, { align: "center", justify: "between", className: "mb-3", children: [_jsx(Text, { size: "xs", color: "muted", children: "Remaining balance" }), _jsx(Text, { size: "xs", weight: "medium", children: toCurrency(order.emiRemainingBalance ?? 0) })] }), _jsx(Div, { className: "divide-y divide-[var(--appkit-color-border)] border border-[var(--appkit-color-border)]", rounded: "lg", children: (order.emiInstallments ?? []).map((inst) => (_jsxs(Row, { paddingY: "y-xs-tall", padding: "x-sm", align: "center", justify: "between", gap: "3", children: [_jsxs(Div, { className: "min-w-0", children: [_jsxs(Text, { size: "sm", weight: "medium", children: ["Installment ", inst.index, " \u00B7 ", toCurrency(inst.amount)] }), _jsx(Text, { size: "xs", color: "muted", children: inst.status === "paid" && inst.paidAt
155
155
  ? `Paid ${toRelativeDate(inst.paidAt)}`
156
156
  : inst.dueDate
157
157
  ? `Due ${toRelativeDate(inst.dueDate)}`
@@ -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;