@mohasinac/appkit 4.30.17 → 4.30.19

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 (163) hide show
  1. package/dist/_internal/client/features/digital-code/CodeRevealPanel.js +5 -2
  2. package/dist/_internal/client/features/layout/DashboardLayoutClient.js +3 -3
  3. package/dist/_internal/client/features/lottery/LotteryAdminEditView.js +3 -2
  4. package/dist/_internal/client/features/maintenance/views/AnalysisRunnerView.js +5 -2
  5. package/dist/_internal/client/features/maintenance/views/CloudLogsListView.js +11 -3
  6. package/dist/_internal/client/hand-mode/HandModeProvider.js +2 -0
  7. package/dist/_internal/server/features/auctions/actions.js +14 -3
  8. package/dist/_internal/server/features/auctions/service.js +1 -1
  9. package/dist/_internal/server/features/blog/data.js +2 -2
  10. package/dist/_internal/server/features/blog/service.js +3 -3
  11. package/dist/_internal/server/features/brands/data.js +1 -1
  12. package/dist/_internal/server/features/brands/service.js +3 -3
  13. package/dist/_internal/server/features/bundles/actions.js +6 -1
  14. package/dist/_internal/server/features/bundles/data.js +21 -4
  15. package/dist/_internal/server/features/cart/data.js +3 -1
  16. package/dist/_internal/server/features/cart/service.js +13 -2
  17. package/dist/_internal/server/features/categories/data.js +12 -3
  18. package/dist/_internal/server/features/events/actions.js +17 -4
  19. package/dist/_internal/server/features/events/data.js +1 -1
  20. package/dist/_internal/server/features/events/service.js +1 -1
  21. package/dist/_internal/server/features/grouped/data.js +7 -2
  22. package/dist/_internal/server/features/item-requests/data.js +1 -1
  23. package/dist/_internal/server/features/lottery/actions.js +2 -2
  24. package/dist/_internal/server/features/lottery/data.js +1 -1
  25. package/dist/_internal/server/features/orders/actions.js +7 -7
  26. package/dist/_internal/server/features/orders/data.d.ts +1 -1
  27. package/dist/_internal/server/features/orders/data.js +14 -2
  28. package/dist/_internal/server/features/orders/service.js +1 -1
  29. package/dist/_internal/server/features/pre-orders/service.js +1 -1
  30. package/dist/_internal/server/features/prize-draws/actions.js +1 -1
  31. package/dist/_internal/server/features/products/data.js +7 -2
  32. package/dist/_internal/server/features/products/service.js +1 -1
  33. package/dist/_internal/server/features/promotions/actions.js +1 -1
  34. package/dist/_internal/server/features/promotions/data.js +1 -1
  35. package/dist/_internal/server/features/promotions/service.js +1 -1
  36. package/dist/_internal/server/features/reviews/data.d.ts +1 -1
  37. package/dist/_internal/server/features/reviews/data.js +6 -1
  38. package/dist/_internal/server/features/reviews/og-data.js +1 -1
  39. package/dist/_internal/server/features/shared/listing-data-factory.js +7 -2
  40. package/dist/_internal/server/features/stores/data.js +1 -1
  41. package/dist/_internal/server/features/sublisting-categories/data.js +1 -1
  42. package/dist/_internal/server/jobs/core/auctionSettlement.js +6 -1
  43. package/dist/_internal/server/jobs/core/onOrderStatusChange.js +7 -15
  44. package/dist/_internal/server/jobs/core/revenueRollup.js +7 -1
  45. package/dist/_internal/server/jobs/core/testerSandboxRefresh.js +27 -2
  46. package/dist/client/api/useApiQuery.js +17 -6
  47. package/dist/client.d.ts +1 -0
  48. package/dist/client.js +3 -0
  49. package/dist/contracts/client-realtime.d.ts +6 -0
  50. package/dist/contracts/realtime-session.d.ts +12 -12
  51. package/dist/contracts/realtime-session.js +72 -77
  52. package/dist/errors/index.d.ts +1 -1
  53. package/dist/errors/index.js +1 -1
  54. package/dist/features/about/components/DeveloperView.js +7 -2
  55. package/dist/features/about/components/PolicyPageView.js +9 -1
  56. package/dist/features/about/components/PublicProfileView.js +23 -5
  57. package/dist/features/account/components/AddressForm.js +1 -1
  58. package/dist/features/account/components/NotificationBell.js +4 -3
  59. package/dist/features/account/components/NotificationPreferencesPanel.js +1 -1
  60. package/dist/features/account/components/UserOffersPanel.js +1 -1
  61. package/dist/features/admin/components/AdminFulfillmentView.js +5 -2
  62. package/dist/features/admin/components/AdminMediaView.js +3 -3
  63. package/dist/features/analytics/types.d.ts +1 -1
  64. package/dist/features/analytics/types.js +13 -0
  65. package/dist/features/auctions/components/AuctionDetailPageView.js +6 -2
  66. package/dist/features/auctions/components/MarketplaceAuctionCard.js +3 -1
  67. package/dist/features/auth/actions/profile-actions.js +4 -3
  68. package/dist/features/auth/components/ForgotPasswordView.js +1 -1
  69. package/dist/features/auth/components/LoginForm.js +1 -1
  70. package/dist/features/auth/components/RegisterForm.js +1 -1
  71. package/dist/features/auth/components/ResetPasswordView.js +1 -1
  72. package/dist/features/auth/hooks/useAuth.js +3 -0
  73. package/dist/features/blog/repository/blog.repository.js +3 -1
  74. package/dist/features/cart/actions/cart-actions.js +13 -4
  75. package/dist/features/cart/utils/pending-ops.js +1 -1
  76. package/dist/features/categories/components/BrandDetailPageView.js +28 -12
  77. package/dist/features/categories/components/CategoryDetailPageView.js +37 -20
  78. package/dist/features/categories/repository/categories.repository.js +3 -1
  79. package/dist/features/checkout/actions/checkout-value-otp-actions.js +10 -1
  80. package/dist/features/classified/components/ClassifiedContactSellerPanel.js +1 -1
  81. package/dist/features/classified/components/ClassifiedDetailPageView.js +10 -2
  82. package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +4 -1
  83. package/dist/features/events/components/EventBanner.js +5 -2
  84. package/dist/features/events/components/EventOfferCard.js +3 -1
  85. package/dist/features/events/components/EventPollWidget.js +1 -1
  86. package/dist/features/events/components/SpinWheelView.js +3 -2
  87. package/dist/features/grouped/components/GroupedListingEditorView.js +2 -1
  88. package/dist/features/history/hooks/useHistory.js +29 -27
  89. package/dist/features/homepage/components/AdSlot.js +3 -1
  90. package/dist/features/homepage/components/GoogleReviewsSection.js +8 -2
  91. package/dist/features/homepage/components/MarketplaceHomepageView.js +34 -10
  92. package/dist/features/homepage/components/SocialFeedSection.js +4 -1
  93. package/dist/features/homepage/lib/live-stats.js +8 -1
  94. package/dist/features/homepage/repository/carousel.repository.js +2 -1
  95. package/dist/features/live/components/LiveItemDetailPageView.js +4 -1
  96. package/dist/features/media/finalize.js +3 -1
  97. package/dist/features/media/modals/ImageEditor.js +1 -1
  98. package/dist/features/media/modals/VideoThumbnailSelector.js +5 -2
  99. package/dist/features/media/modals/VideoTrimModal.js +5 -2
  100. package/dist/features/media/upload/MediaUploadField.js +2 -2
  101. package/dist/features/messages/hooks/useConversation.js +18 -4
  102. package/dist/features/messages/hooks/useConversations.js +25 -8
  103. package/dist/features/orders/components/RefundRequestView.js +5 -2
  104. package/dist/features/pre-orders/components/MarketplacePreorderCard.js +3 -1
  105. package/dist/features/pre-orders/components/PreOrderActionsClient.js +1 -1
  106. package/dist/features/pre-orders/components/PreOrderDetailPageView.js +4 -1
  107. package/dist/features/products/components/PrizeDrawDetailPageView.js +4 -1
  108. package/dist/features/products/components/ProductDetailPageView.js +8 -2
  109. package/dist/features/products/components/ShareButton.js +3 -1
  110. package/dist/features/products/repository/loadProductFeatures.js +10 -2
  111. package/dist/features/products/repository/products.repository.js +2 -1
  112. package/dist/features/promotions/components/CouponCard.js +3 -1
  113. package/dist/features/reviews/actions/review-actions.js +8 -1
  114. package/dist/features/reviews/components/ReviewDetailPageView.js +13 -3
  115. package/dist/features/reviews/components/ReviewDetailShell.js +3 -1
  116. package/dist/features/scams/actions/scam-actions.d.ts +9 -1
  117. package/dist/features/scams/actions/scam-actions.js +52 -12
  118. package/dist/features/scams/components/SellerTrustBadge.d.ts +1 -1
  119. package/dist/features/scams/components/SellerTrustBadge.js +13 -0
  120. package/dist/features/scams/repository/scammer.repository.js +2 -1
  121. package/dist/features/seller/actions/offer-actions.js +6 -1
  122. package/dist/features/seller/components/FulfillmentView.js +5 -2
  123. package/dist/features/seller/components/PhysicalLocationModal.js +1 -1
  124. package/dist/features/seller/components/PrintCenterView.js +3 -1
  125. package/dist/features/seller/components/SellerBidsView.js +1 -1
  126. package/dist/features/seller/components/SellerCouponsView.js +1 -1
  127. package/dist/features/seller/components/SellerGoogleReviewsView.js +2 -2
  128. package/dist/features/seller/components/SellerOrdersView.js +3 -3
  129. package/dist/features/seller/components/SellerPayoutMethodsView.js +2 -2
  130. package/dist/features/seller/components/SellerProductShell.d.ts +5 -0
  131. package/dist/features/seller/components/SellerProductShell.js +15 -5
  132. package/dist/features/seller/components/SellerProductsView.js +24 -10
  133. package/dist/features/seller/components/SellerReviewsView.js +47 -16
  134. package/dist/features/seller/components/SellerShippingConfigsView.js +5 -4
  135. package/dist/features/seller/components/SellerStoreCategoriesView.js +3 -2
  136. package/dist/features/stores/components/StoreDetailLayoutView.d.ts +1 -1
  137. package/dist/features/stores/components/StoreDetailLayoutView.js +12 -3
  138. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +16 -1
  139. package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.js +3 -2
  140. package/dist/features/wishlist/hooks/useWishlistToggle.js +8 -1
  141. package/dist/index.d.ts +1 -1
  142. package/dist/index.js +5 -2
  143. package/dist/next/api/routeHandler.js +3 -1
  144. package/dist/providers/auth-firebase/session.js +3 -1
  145. package/dist/providers/db-firebase/admin-app-lite.js +8 -8
  146. package/dist/providers/db-firebase/admin.js +12 -12
  147. package/dist/providers/db-firebase/browser-stub.d.ts +1 -11
  148. package/dist/providers/db-firebase/browser-stub.js +6 -11
  149. package/dist/providers/db-firebase/index.d.ts +0 -1
  150. package/dist/providers/db-firebase/index.js +0 -1
  151. package/dist/providers/db-firebase/rtdb-paths.d.ts +15 -6
  152. package/dist/providers/db-firebase/rtdb-paths.js +15 -6
  153. package/dist/providers/db-firebase/rtdb-url.d.ts +27 -0
  154. package/dist/providers/db-firebase/rtdb-url.js +50 -0
  155. package/dist/providers/firebase-client/realtime.d.ts +20 -0
  156. package/dist/providers/firebase-client/realtime.js +34 -1
  157. package/dist/providers.d.ts +1 -1
  158. package/dist/providers.js +1 -1
  159. package/dist/react/hooks/use-action-dispatch.js +8 -2
  160. package/dist/react/hooks/useFormStatePreservation.js +3 -1
  161. package/dist/react/hooks/useRealtimeEvent.js +20 -9
  162. package/dist/ui/components/toolbar-collapse.js +1 -2
  163. package/package.json +1 -1
@@ -3,6 +3,7 @@ import { sieveFilter, SIEVE_OP } from "@mohasinac/appkit";
3
3
  import { sortBy } from "@mohasinac/appkit";
4
4
  import { Div, Main } from "../../../ui";
5
5
  import { carouselRepository, faqsRepository, siteSettingsRepository } from "../../../repositories";
6
+ import { safeRead } from "../../../errors/safe-read";
6
7
  import { fetchLiveStats } from "../lib/live-stats";
7
8
  import { renderSection, AnnouncementBar } from "../lib/section-renderer";
8
9
  import { homepageSectionsRepository } from "../repository/homepage-sections.repository";
@@ -28,14 +29,25 @@ function toCarouselSlides(docs) {
28
29
  }));
29
30
  }
30
31
  export async function MarketplaceHomepageView({ adSlots, newsletterFormSlot, onBannerDismiss, } = {}) {
31
- const slides = await carouselRepository.getActiveSlides().catch(() => []);
32
- const siteSettings = await siteSettingsRepository.getSingleton().catch(() => null);
32
+ // Every read on this page is a RAIL: the homepage must still render when one
33
+ // fails, but a failure has to be distinguishable from an empty catalogue —
34
+ // that is exactly Root Cause #59, and safeRead is what records it.
35
+ const slides = await safeRead(() => carouselRepository.getActiveSlides(), {
36
+ route: "/", key: "homepage.carouselSlides", fallback: [],
37
+ });
38
+ const siteSettings = await safeRead(() => siteSettingsRepository.getSingleton(), {
39
+ route: "/", key: "homepage.siteSettings", fallback: null,
40
+ });
33
41
  const announcementMessage = siteSettings?.announcementBar?.message?.trim() ||
34
42
  "🎉 Up to 15% Off on Pokémon TCG this week — Use code SAVE15";
35
43
  const showAnnouncement = siteSettings?.announcementBar?.enabled ?? true;
36
44
  const [allSections, rawFaqItems] = await Promise.all([
37
- homepageSectionsRepository.getEnabledSections().catch(() => []),
38
- faqsRepository.getHomepageFAQs().catch(() => []),
45
+ safeRead(() => homepageSectionsRepository.getEnabledSections(), {
46
+ route: "/", key: "homepage.enabledSections", fallback: [],
47
+ }),
48
+ safeRead(() => faqsRepository.getHomepageFAQs(), {
49
+ route: "/", key: "homepage.faqs", fallback: [],
50
+ }),
39
51
  ]);
40
52
  // Respect the feature flags. This gate existed but lived inside a function
41
53
  // with no call sites, so until 2026-08-24 turning off `auctions` hid auctions
@@ -87,12 +99,24 @@ export async function MarketplaceHomepageView({ adSlots, newsletterFormSlot, onB
87
99
  activeTypes.has("products") ? getFeaturedProducts(12) : null,
88
100
  activeTypes.has("auctions") ? getFeaturedAuctions(12) : null,
89
101
  activeTypes.has("pre-orders") ? getFeaturedPreOrders(12) : null,
90
- activeTypes.has("categories") ? listTopLevelCategories(12).catch(() => null) : null,
91
- activeTypes.has("brands") ? listBrandCategories(12).catch(() => null) : null,
92
- activeTypes.has("featured-bundles") ? listFeaturedBundles(8).catch(() => null) : null,
93
- activeTypes.has("stores") ? listStores({ pageSize: 8, sorts: sortBy("averageRating", "DESC") }).catch(() => null) : null,
94
- activeTypes.has("blog-articles") ? getFeaturedBlogPosts(6).catch(() => null) : null,
95
- activeTypes.has("events") ? listPublicEvents({ filters: sieveFilter("status", SIEVE_OP.EQ, "active"), pageSize: 6 }).catch(() => null) : null,
102
+ activeTypes.has("categories")
103
+ ? safeRead(() => listTopLevelCategories(12), { route: "/", key: "homepage.categories", fallback: null })
104
+ : null,
105
+ activeTypes.has("brands")
106
+ ? safeRead(() => listBrandCategories(12), { route: "/", key: "homepage.brands", fallback: null })
107
+ : null,
108
+ activeTypes.has("featured-bundles")
109
+ ? safeRead(() => listFeaturedBundles(8), { route: "/", key: "homepage.featuredBundles", fallback: null })
110
+ : null,
111
+ activeTypes.has("stores")
112
+ ? safeRead(() => listStores({ pageSize: 8, sorts: sortBy("averageRating", "DESC") }), { route: "/", key: "homepage.stores", fallback: null })
113
+ : null,
114
+ activeTypes.has("blog-articles")
115
+ ? safeRead(() => getFeaturedBlogPosts(6), { route: "/", key: "homepage.blogPosts", fallback: null })
116
+ : null,
117
+ activeTypes.has("events")
118
+ ? safeRead(() => listPublicEvents({ filters: sieveFilter("status", SIEVE_OP.EQ, "active"), pageSize: 6 }), { route: "/", key: "homepage.events", fallback: null })
119
+ : null,
96
120
  ]);
97
121
  // ProductDocument / BlogDocument / EventDocument have Date fields and extra Firestore-only
98
122
  // fields absent from the corresponding *Item types. The casts below are safe: section
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { normalizeError } from "../../../errors/normalize";
3
+ import { safeRead } from "../../../errors/safe-read";
3
4
  import { Anchor, Div, GRID_MAP, Heading, HandModeRow, Section, Stack, Text } from "../../../ui";
4
5
  import { SocialPostCard } from "./SocialPostCard";
5
6
  import { fetchInstagramPosts, fetchFacebookPosts, fetchTikTokPosts, fetchDeviantArtPosts, } from "../lib/social-feed-fetcher";
@@ -47,7 +48,9 @@ async function loadPosts(config) {
47
48
  if (!handle)
48
49
  return { posts: [], error: `A handle is required for ${PLATFORM_LABELS[platform]}.` };
49
50
  try {
50
- const credentials = await siteSettingsRepository.getDecryptedCredentials().catch(() => null);
51
+ // A failed credentials read is reported below as "token not configured in
52
+ // Site Settings", which is actionable but wrong — record the real cause.
53
+ const credentials = await safeRead(() => siteSettingsRepository.getDecryptedCredentials(), { route: "/", key: "homepage.socialFeed.credentials", fallback: null });
51
54
  switch (platform) {
52
55
  case "instagram": {
53
56
  const token = credentials?.metaPageAccessToken;
@@ -81,7 +81,14 @@ export async function fetchLiveStats(requests) {
81
81
  }
82
82
  catch (_err) {
83
83
  void normalizeError(_err);
84
- // silently fall back to static value
84
+ // Falling back to the admin-authored static value is correct — but a
85
+ // stale number that looks live is exactly the failure this must not
86
+ // hide, so name the collection that could not be counted.
87
+ serverLogger.warn("homepage.liveStats: collection count failed", {
88
+ statKey: key,
89
+ collection,
90
+ error: normalizeError(_err).message,
91
+ });
85
92
  }
86
93
  })());
87
94
  }
@@ -234,10 +234,11 @@ export class CarouselRepository extends BaseRepository {
234
234
  "stats.views": increment(1),
235
235
  "stats.lastViewed": new Date(),
236
236
  });
237
+ // Fire-and-forget analytics: a lost view increment changes nothing that
238
+ // renders, and it must never break the carousel read it rides along with.
237
239
  }
238
240
  catch (_e) {
239
241
  void normalizeError(_e);
240
- // Swallow errors — analytics failures must not break the carousel response
241
242
  }
242
243
  }
243
244
  }
@@ -33,7 +33,10 @@ function toDescriptionHtml(raw) {
33
33
  export async function LiveItemDetailPageView({ slug, initialProduct, renderActions }) {
34
34
  const product = initialProduct !== undefined
35
35
  ? (initialProduct ?? undefined)
36
- : await getLiveItemForDetail(slug).catch(() => undefined);
36
+ // The listing IS this page's subject — a read failure must surface rather
37
+ // than become the same `undefined` a removed listing produces, which the
38
+ // "Not Found" branch below already handles.
39
+ : await getLiveItemForDetail(slug);
37
40
  if (!product) {
38
41
  return (_jsx(Main, { children: _jsx(Section, { padding: "y-5xl", children: _jsx(Container, { size: "md", children: _jsxs(Stack, { align: "start", gap: "md", className: "text-left", children: [_jsx(Heading, { level: 1, size: "2xl", weight: "semibold", color: "primary", children: "Live Listing Not Found" }), _jsx(Text, { color: "muted", children: "The live listing you are looking for may have been removed." }), _jsx(Link, { href: String(ROUTES.PUBLIC.LIVE), className: "text-[length:var(--appkit-text-sm)] font-medium text-primary-600 hover:underline", children: "Browse Live Listings" })] }) }) }) }));
39
42
  }
@@ -44,10 +44,12 @@ export async function finalizeStagedMediaUrl(url) {
44
44
  const finalPath = await moveToFinalPath(asset.storagePath);
45
45
  await mediaAssetsRepository.promoteToFinalized(shortId, finalPath);
46
46
  }
47
+ // Non-fatal by construction: the returned URL is the same before and after
48
+ // the move, so a failed promotion leaves the asset in tmp/ and the proxy
49
+ // keeps serving it from there until the tmp-cleanup job reclaims it.
47
50
  }
48
51
  catch (_err) {
49
52
  void normalizeError(_err);
50
- // Non-fatal: URL is still valid; proxy will serve whatever storagePath points to.
51
53
  }
52
54
  return url; // URL is stable — same before and after the file move
53
55
  }
@@ -63,7 +63,7 @@ export function ImageEditor({ isOpen, imageUrl, onClose, onSave, aspectRatio, ou
63
63
  }
64
64
  catch (err) {
65
65
  void normalizeError(err);
66
- showToast(err instanceof Error ? err.message : "Failed to save image.", "error");
66
+ showToast("Failed to save image.", "error");
67
67
  }
68
68
  finally {
69
69
  setSaving(false);
@@ -10,6 +10,7 @@ import { useState, useRef } from "react";
10
10
  import { useTranslations } from "next-intl";
11
11
  import { Alert, Button, Div, Modal, Row, Span, Spinner, Stack, Text } from "../../../ui";
12
12
  import { normalizeError } from "../../../errors/normalize";
13
+ import { toUserMessage } from "../../../errors/error-display-map";
13
14
  const __O = {
14
15
  hidden: "overflow-hidden",
15
16
  };
@@ -45,8 +46,10 @@ export function VideoThumbnailSelector({ isOpen, videoUrl, onClose, onSelect, on
45
46
  onClose();
46
47
  }
47
48
  catch (err) {
48
- void normalizeError(err);
49
- setError(err instanceof Error ? err.message : t("thumbnailError"));
49
+ const e = normalizeError(err);
50
+ // `t` is namespaced ("mediaEditor"), so it cannot resolve the absolute
51
+ // `errors.codes.*` keys — omit it and let the authored copy stand.
52
+ setError(toUserMessage(e.code, undefined, { fallback: t("thumbnailError") }));
50
53
  }
51
54
  finally {
52
55
  setIsCapturing(false);
@@ -11,6 +11,7 @@ import { Alert, Button, Div, Modal, Row, Span, Stack, Text } from "../../../ui";
11
11
  import { MediaSlider } from "../components/MediaSlider";
12
12
  import { useMediaTrim } from "../hooks/useMedia";
13
13
  import { normalizeError } from "../../../errors/normalize";
14
+ import { toUserMessage } from "../../../errors/error-display-map";
14
15
  function formatTime(seconds) {
15
16
  const m = Math.floor(seconds / 60);
16
17
  const s = Math.floor(seconds % 60);
@@ -62,8 +63,10 @@ export function VideoTrimModal({ isOpen, videoUrl, onClose, onSave, endpoint, })
62
63
  onClose();
63
64
  }
64
65
  catch (err) {
65
- void normalizeError(err);
66
- setError(err instanceof Error ? err.message : t("trimError"));
66
+ const e = normalizeError(err);
67
+ // `t` is namespaced ("mediaEditor"), so it cannot resolve the absolute
68
+ // `errors.codes.*` keys — omit it and let the authored copy stand.
69
+ setError(toUserMessage(e.code, undefined, { fallback: t("trimError") }));
67
70
  }
68
71
  };
69
72
  const qualityLabels = {
@@ -320,7 +320,7 @@ kind, accept: acceptProp, maxSizeMB: maxSizeMBProp, disabled = false, helperText
320
320
  }
321
321
  catch (err) {
322
322
  void normalizeError(err);
323
- setError(err instanceof Error ? err.message : "Upload failed");
323
+ setError("Upload failed");
324
324
  }
325
325
  finally {
326
326
  setIsLoading(false);
@@ -362,7 +362,7 @@ kind, accept: acceptProp, maxSizeMB: maxSizeMBProp, disabled = false, helperText
362
362
  }
363
363
  catch (err) {
364
364
  void normalizeError(err);
365
- setError(err instanceof Error ? err.message : "Upload failed");
365
+ setError("Upload failed");
366
366
  }
367
367
  finally {
368
368
  setIsLoading(false);
@@ -10,8 +10,18 @@
10
10
  * canonical Firestore message ordering wins.
11
11
  */
12
12
  import { useCallback, useEffect, useState } from "react";
13
- import { getClientRealtimeProvider } from "../../../contracts/client-realtime";
14
13
  import { acquireRealtimeSession } from "../../../contracts/realtime-session";
14
+ // One scope for both message hooks, deliberately: /api/realtime/token issues a
15
+ // SINGLE token covering all of this user's conversationIds, so a per-id scope
16
+ // would make the list and the detail hook re-sign against each other on the
17
+ // same page for no gain.
18
+ //
19
+ // 🛑 Known limitation, not fixed here: because the scope is constant, a
20
+ // conversation created AFTER sign-in is not in the cached token's claims and
21
+ // its subscription is denied until the token's expiry skew forces a re-sign.
22
+ // The denial is now surfaced (see the error callback below) rather than
23
+ // swallowed. The durable fix is to re-acquire on a permission denial.
24
+ const MESSAGES_SCOPE = "messages";
15
25
  import { conversationPingPath, userConversationsPingPath, } from "../realtime";
16
26
  import { normalizeError } from "../../../errors/normalize";
17
27
  import { apiClient, ApiClientError } from "../../../http/ApiClient";
@@ -83,12 +93,16 @@ export function useConversation(conversationId) {
83
93
  // because `send()` calls `refetch()` directly.
84
94
  void (async () => {
85
95
  try {
86
- release = await acquireRealtimeSession("messages", () => apiClient.post(ADMIN_ENDPOINTS.REALTIME_TOKEN, {}));
96
+ const lease = await acquireRealtimeSession(MESSAGES_SCOPE, () => apiClient.post(ADMIN_ENDPOINTS.REALTIME_TOKEN, {}));
97
+ release = lease.release;
87
98
  if (cancelled) {
88
- release?.();
99
+ release();
89
100
  return;
90
101
  }
91
- unsubscribe = getClientRealtimeProvider().subscribe(conversationPingPath(conversationId), () => {
102
+ // Subscribe through the LEASE's provider — it is bound to the app this
103
+ // scope authenticated on. The global provider may be on a different app
104
+ // carrying another channel's claims.
105
+ unsubscribe = lease.provider.subscribe(conversationPingPath(conversationId), () => {
92
106
  setIsConnected(true);
93
107
  void refetch();
94
108
  }, (err) => {
@@ -5,8 +5,18 @@
5
5
  * conversations gets a new message.
6
6
  */
7
7
  import { useCallback, useEffect, useState } from "react";
8
- import { getClientRealtimeProvider } from "../../../contracts/client-realtime";
9
8
  import { acquireRealtimeSession } from "../../../contracts/realtime-session";
9
+ // One scope for both message hooks, deliberately: /api/realtime/token issues a
10
+ // SINGLE token covering all of this user's conversationIds, so a per-id scope
11
+ // would make the list and the detail hook re-sign against each other on the
12
+ // same page for no gain.
13
+ //
14
+ // 🛑 Known limitation, not fixed here: because the scope is constant, a
15
+ // conversation created AFTER sign-in is not in the cached token's claims and
16
+ // its subscription is denied until the token's expiry skew forces a re-sign.
17
+ // The denial is now surfaced (see the error callback below) rather than
18
+ // swallowed. The durable fix is to re-acquire on a permission denial.
19
+ const MESSAGES_SCOPE = "messages";
10
20
  import { userConversationsPingPath } from "../realtime";
11
21
  import { normalizeError } from "../../../errors/normalize";
12
22
  import { apiClient } from "../../../http/ApiClient";
@@ -57,23 +67,30 @@ export function useConversations(userId, opts = {}) {
57
67
  // `// ignore`, so the whole live-list channel was inert while looking wired.
58
68
  void (async () => {
59
69
  try {
60
- release = await acquireRealtimeSession("messages", () => apiClient.post(ADMIN_ENDPOINTS.REALTIME_TOKEN, {}));
70
+ const lease = await acquireRealtimeSession(MESSAGES_SCOPE, () => apiClient.post(ADMIN_ENDPOINTS.REALTIME_TOKEN, {}));
71
+ release = lease.release;
61
72
  if (cancelled) {
62
- release?.();
73
+ release();
63
74
  return;
64
75
  }
65
- unsubscribe = getClientRealtimeProvider().subscribe(userConversationsPingPath(userId), () => {
76
+ // Subscribe through the LEASE's provider — see useConversation.
77
+ unsubscribe = lease.provider.subscribe(userConversationsPingPath(userId), () => {
66
78
  void refetch();
67
79
  }, (err) => {
68
- // Surfaced, not ignored. A denied subscription is a real defect and
69
- // silence here is what hid it for months.
80
+ // A denied subscription is a real defect — silence is what hid this
81
+ // channel being dead for months — so it is logged, not swallowed.
82
+ // (This comment previously claimed "Surfaced, not ignored" while the
83
+ // body only called normalizeError, which classifies without
84
+ // reporting. That was the same documentation-that-lies pattern this
85
+ // workstream exists to remove, so it now actually logs.)
70
86
  void normalizeError(err);
87
+ console.warn("[messages] conversations live-update subscription denied; list falls back to fetch-on-load");
71
88
  });
89
+ // Provider unregistered or token fetch failed — non-fatal; the list
90
+ // still works via the one-shot fetch above, just without live updates.
72
91
  }
73
92
  catch (_err) {
74
93
  void normalizeError(_err);
75
- // Provider unregistered or token fetch failed — non-fatal; the list
76
- // still works via the one-shot fetch above, just without live updates.
77
94
  }
78
95
  })();
79
96
  return () => {
@@ -16,6 +16,7 @@ import { Button, Checkbox, Div, Span, Stack, Text, Textarea } from "../../../ui"
16
16
  import { REFUND_COPY } from "../../../_internal/shared/features/orders/refund-copy";
17
17
  import { formatCurrency } from "../../../utils/number.formatter";
18
18
  import { normalizeError } from "../../../errors/normalize";
19
+ import { toUserMessage } from "../../../errors/error-display-map";
19
20
  const ACK_COUNT = REFUND_COPY.request.acknowledgments.length;
20
21
  const CLS_WARN_PANEL = "rounded-xl border border-warning bg-warning-surface p-[var(--appkit-space-4)] dark:border-warning";
21
22
  export function RefundRequestView({ order, onSubmitRequest, isLoading = false, className = "", }) {
@@ -37,8 +38,10 @@ export function RefundRequestView({ order, onSubmitRequest, isLoading = false, c
37
38
  await onSubmitRequest(reason.trim());
38
39
  }
39
40
  catch (err) {
40
- void normalizeError(err);
41
- setError(err instanceof Error ? err.message : REFUND_COPY.request.errorFallback);
41
+ const e = normalizeError(err);
42
+ setError(toUserMessage(e.code, undefined, {
43
+ fallback: REFUND_COPY.request.errorFallback,
44
+ }));
42
45
  }
43
46
  };
44
47
  return (_jsxs(Stack, { gap: "md", className: className, children: [_jsxs(Text, { size: "sm", color: "muted", children: [REFUND_COPY.request.orderTotalLabel, " ", _jsx(Span, { weight: "bold", children: formatCurrency(order.totalPrice, order.currency ?? "INR") })] }), _jsxs(Stack, { gap: "xs", children: [_jsxs(Text, { size: "sm", weight: "semibold", children: [REFUND_COPY.request.reasonLabel, " ", _jsx(Span, { "aria-hidden": true, children: "*" })] }), _jsx(Textarea, { value: reason, onChange: (e) => setReason(e.target.value), placeholder: REFUND_COPY.request.reasonPlaceholder, rows: 3, "aria-required": "true", "aria-label": REFUND_COPY.request.reasonLabel })] }), _jsxs(Stack, { gap: "sm", children: [_jsx(Text, { size: "sm", weight: "semibold", children: REFUND_COPY.request.acknowledgeHeading }), REFUND_COPY.request.acknowledgments.map((text, i) => (_jsx(Checkbox, { checked: acknowledged[i], onChange: () => handleToggle(i), label: _jsx(Text, { size: "sm", className: "leading-snug", children: text }), "aria-label": `Acknowledgment ${i + 1}` }, i)))] }), error && (_jsx(Text, { size: "sm", color: "danger", role: "alert", children: error })), _jsx(Button, { variant: "danger", onClick: handleSubmit, disabled: !canSubmit, "aria-busy": isLoading, children: isLoading ? REFUND_COPY.request.submittingLabel : REFUND_COPY.request.submitLabel })] }));
@@ -54,8 +54,10 @@ export function MarketplacePreorderCard({ product, className = "", variant = "gr
54
54
  await toggle();
55
55
  }
56
56
  catch (_err) {
57
+ // useWishlistToggle rolls back its optimistic state and raises its own
58
+ // toast on failure, so the heart returns to its true value on screen.
59
+ // Reporting again here would double-notify for one user action.
57
60
  void normalizeError(_err);
58
- // The hook rolls back optimistic state when the mutation fails.
59
61
  }
60
62
  }, [toggle]);
61
63
  return (_jsxs(BaseListingCard, { isSelected: isSelected, variant: variant, className: className, onMouseDown: !isSelected ? longPress.onMouseDown : undefined, onMouseUp: !isSelected ? longPress.onMouseUp : undefined, onMouseLeave: !isSelected ? longPress.onMouseLeave : undefined, onTouchStart: !isSelected ? longPress.onTouchStart : undefined, onTouchEnd: !isSelected ? longPress.onTouchEnd : undefined, onTouchCancel: !isSelected ? longPress.onTouchCancel : undefined, children: [_jsxs(BaseListingCard.Hero, { aspect: "square", variant: variant, children: [_jsx(TextLink, { href: detailHref, className: "absolute inset-0 block", children: _jsx(MediaImage, { src: product.mainImage, alt: product.title, size: "card", fallback: "Product image" }) }), _jsxs(Stack, { className: "absolute right-2 top-2", align: "end", gap: "xs", children: [_jsx(Span, { layout: "inline-flex", color: "inverse", size: "xs", weight: "medium", className: "bg-cobalt", rounded: "full", padding: "pill-xs", children: mergedLabels.preOrderBadge }), product.featured && (_jsx(Span, { size: "xs", weight: "medium", className: CLS_PREORDER_BADGE, children: mergedLabels.featuredBadge }))] }), _jsxs(Stack, { className: "absolute left-2 top-2 pointer-events-none", gap: "xs", children: [product.partOfBundleIds && product.partOfBundleIds.length > 0 && (_jsx(Span, { className: CLS_SALE_BADGE, title: product.partOfBundleTitles?.[0] ? `In bundle: ${product.partOfBundleTitles[0]}` : "In a bundle", children: "Bundled" })), product.groupId && (_jsx(Span, { className: CLS_TRENDING_BADGE, title: product.groupTitle ? `Part of set: ${product.groupTitle}` : "Part of a set", children: product.isGroupParent ? "Set Parent" : "In Set" })), product.sublistingCategoryId && (_jsx(Span, { className: CLS_LIMITED_BADGE, title: "Has variants or sub-listings", children: "Has Variants" }))] }), onSelect && (_jsx(BaseListingCard.Checkbox, { selected: isSelected, onSelect: (event) => {
@@ -24,7 +24,7 @@ export function PreOrderActionsClient({ productId, price, currency, depositAmoun
24
24
  setShowLoginModal(true);
25
25
  }
26
26
  else {
27
- setError(err instanceof Error ? err.message : "Failed to reserve. Please try again.");
27
+ setError("Failed to reserve. Please try again.");
28
28
  }
29
29
  }
30
30
  });
@@ -73,7 +73,10 @@ function PreOrderBuyBarPanel({ reserveTarget, reservedCount, progressPct, produc
73
73
  export async function PreOrderDetailPageView({ id, initialPreOrder, onReserveNow, productFeatures }) {
74
74
  const product = initialPreOrder !== undefined
75
75
  ? (initialPreOrder ?? undefined)
76
- : await productRepository.findByIdOrSlug(id).catch(() => undefined);
76
+ // The pre-order IS this page's subject — a read failure must surface rather
77
+ // than become the same `undefined` a removed listing produces, which the
78
+ // "Not Found" branch below already handles.
79
+ : await productRepository.findByIdOrSlug(id);
77
80
  if (!product) {
78
81
  return (_jsx(Main, { children: _jsx(Section, { padding: "y-5xl", children: _jsx(Container, { size: "md", children: _jsxs(Stack, { align: "start", gap: "md", className: "text-left", children: [_jsx(Heading, { level: 1, size: "2xl", weight: "semibold", color: "primary", children: "Pre-Order Not Found" }), _jsx(Text, { color: "muted", children: "The pre-order item you are looking for may have been removed." }), _jsx(Link, { href: String(ROUTES.PUBLIC.PRE_ORDERS), className: "text-[length:var(--appkit-text-sm)] font-medium text-primary-600 hover:underline", children: "Browse Pre-Orders" })] }) }) }) }));
79
82
  }
@@ -68,7 +68,10 @@ function stripIsWon(items) {
68
68
  export async function PrizeDrawDetailPageView({ id, initialPrizeDraw, currentUserId, }) {
69
69
  const product = initialPrizeDraw !== undefined
70
70
  ? initialPrizeDraw ?? undefined
71
- : await productRepository.findByIdOrSlug(id).catch(() => undefined);
71
+ // The prize draw IS this page's subject — a read failure must surface
72
+ // rather than become the same `undefined` a removed listing produces,
73
+ // which the "Not Found" branch below already handles.
74
+ : await productRepository.findByIdOrSlug(id);
72
75
  if (!product) {
73
76
  return (_jsx(Main, { children: _jsx(Section, { padding: "y-5xl", children: _jsx(Container, { size: "md", children: _jsxs(Stack, { align: "start", gap: "md", className: "text-left", children: [_jsx(Heading, { level: 1, size: "2xl", weight: "semibold", color: "primary", children: "Prize Draw Not Found" }), _jsx(Text, { color: "muted", children: "The prize draw you are looking for may have been removed." }), _jsx(Link, { href: String(ROUTES.PUBLIC.PRIZE_DRAWS), className: "text-[length:var(--appkit-text-sm)] font-medium text-primary-600 hover:underline", children: "Browse Prize Draws" })] }) }) }) }));
74
77
  }
@@ -41,6 +41,7 @@ import { computeRelatedItems, getReviewPageForProduct } from "../../../_internal
41
41
  import { GroupedListingsCarousel } from "../../grouped/components/GroupedListingsCarousel";
42
42
  import { getGroupsWithItemsForProduct } from "../../../_internal/server/features/grouped/data";
43
43
  import { getSiteSettingsGlobal } from "../../admin/utils/getSiteSettingsGlobal";
44
+ import { safeRead } from "../../../errors/safe-read";
44
45
  import { canMakeOffer } from "../../../_internal/shared/listing-types/capabilities";
45
46
  import { normalizeListingType } from "../utils/listing-type";
46
47
  import { DEFAULT_MIN_OFFER_PERCENT } from "../../../_internal/shared/features/offers/config";
@@ -71,7 +72,10 @@ export async function ProductDetailPageView({ slug, initialProduct, renderOfferA
71
72
  // generateMetadata() and this component share the same in-flight promise.
72
73
  const product = initialProduct !== undefined
73
74
  ? (initialProduct ?? undefined)
74
- : await productRepository.findByIdOrSlug(slug).catch(() => undefined);
75
+ // The product IS this page's subject — a read failure must surface rather
76
+ // than become the same `undefined` a removed listing produces, which the
77
+ // "Not Found" branch below already handles.
78
+ : await productRepository.findByIdOrSlug(slug);
75
79
  if (!product) {
76
80
  return (_jsx(Main, { children: _jsx(Section, { padding: "y-5xl", children: _jsx(Container, { size: "md", children: _jsxs(Stack, { align: "start", gap: "md", className: "text-left", children: [_jsx(Heading, { level: 1, size: "2xl", weight: "semibold", color: "primary", children: "Product Not Found" }), _jsx(Text, { color: "muted", children: "The product you are looking for may have been removed or the link is incorrect." }), _jsx(Link, { href: String(ROUTES.PUBLIC.PRODUCTS), className: "text-[length:var(--appkit-text-sm)] font-medium text-primary-600 hover:underline", children: "Browse Products" })] }) }) }) }));
77
81
  }
@@ -180,7 +184,9 @@ export async function ProductDetailPageView({ slug, initialProduct, renderOfferA
180
184
  getReviewPageForProduct(product.id),
181
185
  computeRelatedItems(product),
182
186
  getGroupsWithItemsForProduct(product.id),
183
- getSiteSettingsGlobal().catch(() => null),
187
+ safeRead(() => getSiteSettingsGlobal(), {
188
+ route: "/products/[slug]", key: "product.siteSettings", fallback: null,
189
+ }),
184
190
  ]);
185
191
  // Site-wide kill switch. Absent/unreadable settings mean ON — a failed
186
192
  // settings read must not silently remove a working feature.
@@ -11,10 +11,12 @@ export function ShareButton({ title, text, className = "" }) {
11
11
  try {
12
12
  await navigator.share({ title: title ?? document.title, text, url });
13
13
  return;
14
+ // navigator.share rejects on user cancellation exactly as it does on a
15
+ // real failure, so this is unreportable by construction — either way the
16
+ // clipboard fallback below still gives the user the link.
14
17
  }
15
18
  catch (_err) {
16
19
  void normalizeError(_err);
17
- // user cancelled or browser blocked — fall through to clipboard
18
20
  }
19
21
  }
20
22
  navigator.clipboard.writeText(url).catch(console.error);
@@ -4,11 +4,19 @@
4
4
  * pages to hand `productFeatures` down as a prop and avoid per-card waterfalls.
5
5
  */
6
6
  import { productFeaturesRepository } from "./product-features.repository";
7
+ import { safeRead } from "../../../errors/safe-read";
7
8
  export async function loadProductFeaturesForStore(storeId) {
9
+ // Feature badges decorate a card; a page without them is still correct, so
10
+ // both reads degrade — but a failure here silently strips "Free shipping" /
11
+ // "Authenticity guaranteed" from every card, which nobody would notice.
8
12
  const [platform, store] = await Promise.all([
9
- productFeaturesRepository.listPlatform().catch(() => []),
13
+ safeRead(() => productFeaturesRepository.listPlatform(), {
14
+ route: "productFeatures", key: "productFeatures.platform", fallback: [],
15
+ }),
10
16
  storeId
11
- ? productFeaturesRepository.listForStore(storeId).catch(() => [])
17
+ ? safeRead(() => productFeaturesRepository.listForStore(storeId), {
18
+ route: "productFeatures", key: "productFeatures.store", fallback: [],
19
+ })
12
20
  : Promise.resolve([]),
13
21
  ]);
14
22
  const seen = new Set();
@@ -452,10 +452,11 @@ export class ProductRepository extends BaseRepository {
452
452
  .update({
453
453
  [PRODUCT_FIELDS.VIEW_COUNT]: increment(1),
454
454
  });
455
+ // Fire-and-forget analytics: a lost view increment changes no rendered
456
+ // content, so it must never break the product read path it rides with.
455
457
  }
456
458
  catch (_err) {
457
459
  void normalizeError(_err);
458
- // View analytics must not break product read path.
459
460
  }
460
461
  }
461
462
  /**
@@ -131,10 +131,12 @@ export function CouponCard({ coupon, labels: labelsProp, onCopy, className = "",
131
131
  credentials: "include",
132
132
  body: JSON.stringify({ couponCode: n.code, source: claimSource }),
133
133
  });
134
+ // An anonymous visitor gets a 401 here by design, so a failure cannot
135
+ // be distinguished from the expected case. The redirect below still
136
+ // pre-fills the code, which is what the user actually asked for.
134
137
  }
135
138
  catch (_err) {
136
139
  void normalizeError(_err);
137
- /* anon/network — non-fatal */
138
140
  }
139
141
  if (typeof window !== "undefined") {
140
142
  window.location.href = `/cart?coupon=${encodeURIComponent(n.code)}`;
@@ -10,6 +10,7 @@ import { serverLogger } from "../../../monitoring";
10
10
  import { maskPublicReview } from "../../../security";
11
11
  import { finalizeStagedMediaArray, finalizeStagedMediaUrl, } from "../../media/finalize";
12
12
  import { reviewRepository } from "../repository/reviews.repository";
13
+ import { safeRead } from "../../../errors/safe-read";
13
14
  import { productRepository } from "../../products/repository/products.repository";
14
15
  import { ProductStatusValues } from "../../products/schemas";
15
16
  import { userRepository } from "../../auth/repository/user.repository";
@@ -185,7 +186,13 @@ export async function listReviewsBySeller(sellerId) {
185
186
  }
186
187
  const reviewBatches = await Promise.all(productIds
187
188
  .slice(0, 20)
188
- .map((id) => reviewRepository.findApprovedByProduct(id, 50).catch(() => [])));
189
+ // One product's reviews failing must not empty the whole seller feed —
190
+ // but it silently thins it, so each miss is recorded.
191
+ .map((id) => safeRead(() => reviewRepository.findApprovedByProduct(id, 50), {
192
+ route: "reviews.bySeller",
193
+ key: "reviews.findApprovedByProduct",
194
+ fallback: [],
195
+ })));
189
196
  // Each per-product batch is newest-first, but concatenating them yields product order,
190
197
  // not chronological order — the flattened list has to be re-sorted or callers get an
191
198
  // arbitrarily interleaved "latest reviews" list.
@@ -1,12 +1,16 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import Link from "next/link";
3
3
  import { reviewRepository } from "../../../repositories";
4
+ import { safeRead } from "../../../errors/safe-read";
4
5
  import { Container, Div, Grid, Heading, Main, Nav, Section, Span, Text } from "../../../ui";
5
6
  import { ROUTES } from "../../../next";
6
7
  import { ReviewDetailShell } from "./ReviewDetailShell";
7
8
  import { ReviewCard } from "./ReviewsList";
8
9
  export async function ReviewDetailPageView({ id }) {
9
- const doc = await reviewRepository.findById(id).catch(() => null);
10
+ // The review IS this page's subject — a read failure must surface rather than
11
+ // become the same `null` a removed review produces, which the "Review not
12
+ // found" branch below already handles.
13
+ const doc = await reviewRepository.findById(id);
10
14
  const review = doc;
11
15
  // storeId === storeSlug for stores in this project (pure slug IDs)
12
16
  const storeSlug = doc?.storeId ?? null;
@@ -15,8 +19,14 @@ export async function ReviewDetailPageView({ id }) {
15
19
  }
16
20
  const [sameProductDocs, sameStoreDocs] = await Promise.all([
17
21
  // 12 = the 6 rendered below, with headroom for the current review + any filtered out.
18
- reviewRepository.findApprovedByProduct(review.productId, 12).catch(() => []),
19
- storeSlug ? reviewRepository.findApprovedByStore(storeSlug).catch(() => []) : Promise.resolve([]),
22
+ safeRead(() => reviewRepository.findApprovedByProduct(review.productId, 12), {
23
+ route: "/reviews/[id]", key: "review.sameProduct", fallback: [],
24
+ }),
25
+ storeSlug
26
+ ? safeRead(() => reviewRepository.findApprovedByStore(storeSlug), {
27
+ route: "/reviews/[id]", key: "review.sameStore", fallback: [],
28
+ })
29
+ : Promise.resolve([]),
20
30
  ]);
21
31
  const sameProductReviews = sameProductDocs.filter((r) => r.id !== review.id).slice(0, 6);
22
32
  const sameStoreReviews = sameStoreDocs.filter((r) => r.id !== review.id).slice(0, 6);
@@ -80,10 +80,12 @@ export function ReviewDetailShell({ review, storeHref }) {
80
80
  }
81
81
  setVoted(true);
82
82
  localStorage.setItem(storageKey, "1");
83
+ // The button stays un-voted and the count unchanged, so the user can
84
+ // simply press it again — this shell has no toast host of its own, and a
85
+ // failed helpful-vote costs nothing.
83
86
  }
84
87
  catch (_err) {
85
88
  void normalizeError(_err);
86
- // silently fail
87
89
  }
88
90
  finally {
89
91
  setVoting(false);
@@ -30,7 +30,15 @@ export declare function getPublicScammerById(id: string): Promise<ScammerDocumen
30
30
  * Returns null if not found or not verified.
31
31
  */
32
32
  export declare function getScammerProfilePageData(id: string): Promise<ScammerProfilePageData | null>;
33
- export type SellerTrustStatus = "clear" | "flagged";
33
+ /**
34
+ * `unknown` exists because `clear` is a POSITIVE SAFETY CLAIM — it renders as
35
+ * "✓ Verified Safe" next to a seller's name. Every read in
36
+ * `getSellerTrustStatus` used to fail open to `clear`, so a scam-registry
37
+ * outage actively endorsed a seller nobody had checked. Failing open is still
38
+ * right (an outage must not brand a legitimate seller a scammer), but the
39
+ * honest failure state is "we could not check", not "we checked and it's fine".
40
+ */
41
+ export type SellerTrustStatus = "clear" | "flagged" | "unknown";
34
42
  export interface SellerTrustResult {
35
43
  status: SellerTrustStatus;
36
44
  /** seoSlug of matched verified scammer profiles — links to /scams/[slug]. Empty when clear. */