@mohasinac/appkit 4.30.18 → 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 (162) 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/client/api/useApiQuery.js +17 -6
  46. package/dist/client.d.ts +1 -0
  47. package/dist/client.js +3 -0
  48. package/dist/contracts/client-realtime.d.ts +6 -0
  49. package/dist/contracts/realtime-session.d.ts +12 -12
  50. package/dist/contracts/realtime-session.js +72 -77
  51. package/dist/errors/index.d.ts +1 -1
  52. package/dist/errors/index.js +1 -1
  53. package/dist/features/about/components/DeveloperView.js +7 -2
  54. package/dist/features/about/components/PolicyPageView.js +9 -1
  55. package/dist/features/about/components/PublicProfileView.js +23 -5
  56. package/dist/features/account/components/AddressForm.js +1 -1
  57. package/dist/features/account/components/NotificationBell.js +4 -3
  58. package/dist/features/account/components/NotificationPreferencesPanel.js +1 -1
  59. package/dist/features/account/components/UserOffersPanel.js +1 -1
  60. package/dist/features/admin/components/AdminFulfillmentView.js +5 -2
  61. package/dist/features/admin/components/AdminMediaView.js +3 -3
  62. package/dist/features/analytics/types.d.ts +1 -1
  63. package/dist/features/analytics/types.js +13 -0
  64. package/dist/features/auctions/components/AuctionDetailPageView.js +6 -2
  65. package/dist/features/auctions/components/MarketplaceAuctionCard.js +3 -1
  66. package/dist/features/auth/actions/profile-actions.js +4 -3
  67. package/dist/features/auth/components/ForgotPasswordView.js +1 -1
  68. package/dist/features/auth/components/LoginForm.js +1 -1
  69. package/dist/features/auth/components/RegisterForm.js +1 -1
  70. package/dist/features/auth/components/ResetPasswordView.js +1 -1
  71. package/dist/features/auth/hooks/useAuth.js +3 -0
  72. package/dist/features/blog/repository/blog.repository.js +3 -1
  73. package/dist/features/cart/actions/cart-actions.js +13 -4
  74. package/dist/features/cart/utils/pending-ops.js +1 -1
  75. package/dist/features/categories/components/BrandDetailPageView.js +28 -12
  76. package/dist/features/categories/components/CategoryDetailPageView.js +37 -20
  77. package/dist/features/categories/repository/categories.repository.js +3 -1
  78. package/dist/features/checkout/actions/checkout-value-otp-actions.js +10 -1
  79. package/dist/features/classified/components/ClassifiedContactSellerPanel.js +1 -1
  80. package/dist/features/classified/components/ClassifiedDetailPageView.js +10 -2
  81. package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +4 -1
  82. package/dist/features/events/components/EventBanner.js +5 -2
  83. package/dist/features/events/components/EventOfferCard.js +3 -1
  84. package/dist/features/events/components/EventPollWidget.js +1 -1
  85. package/dist/features/events/components/SpinWheelView.js +3 -2
  86. package/dist/features/grouped/components/GroupedListingEditorView.js +2 -1
  87. package/dist/features/history/hooks/useHistory.js +29 -27
  88. package/dist/features/homepage/components/AdSlot.js +3 -1
  89. package/dist/features/homepage/components/GoogleReviewsSection.js +8 -2
  90. package/dist/features/homepage/components/MarketplaceHomepageView.js +34 -10
  91. package/dist/features/homepage/components/SocialFeedSection.js +4 -1
  92. package/dist/features/homepage/lib/live-stats.js +8 -1
  93. package/dist/features/homepage/repository/carousel.repository.js +2 -1
  94. package/dist/features/live/components/LiveItemDetailPageView.js +4 -1
  95. package/dist/features/media/finalize.js +3 -1
  96. package/dist/features/media/modals/ImageEditor.js +1 -1
  97. package/dist/features/media/modals/VideoThumbnailSelector.js +5 -2
  98. package/dist/features/media/modals/VideoTrimModal.js +5 -2
  99. package/dist/features/media/upload/MediaUploadField.js +2 -2
  100. package/dist/features/messages/hooks/useConversation.js +18 -4
  101. package/dist/features/messages/hooks/useConversations.js +25 -8
  102. package/dist/features/orders/components/RefundRequestView.js +5 -2
  103. package/dist/features/pre-orders/components/MarketplacePreorderCard.js +3 -1
  104. package/dist/features/pre-orders/components/PreOrderActionsClient.js +1 -1
  105. package/dist/features/pre-orders/components/PreOrderDetailPageView.js +4 -1
  106. package/dist/features/products/components/PrizeDrawDetailPageView.js +4 -1
  107. package/dist/features/products/components/ProductDetailPageView.js +8 -2
  108. package/dist/features/products/components/ShareButton.js +3 -1
  109. package/dist/features/products/repository/loadProductFeatures.js +10 -2
  110. package/dist/features/products/repository/products.repository.js +2 -1
  111. package/dist/features/promotions/components/CouponCard.js +3 -1
  112. package/dist/features/reviews/actions/review-actions.js +8 -1
  113. package/dist/features/reviews/components/ReviewDetailPageView.js +13 -3
  114. package/dist/features/reviews/components/ReviewDetailShell.js +3 -1
  115. package/dist/features/scams/actions/scam-actions.d.ts +9 -1
  116. package/dist/features/scams/actions/scam-actions.js +52 -12
  117. package/dist/features/scams/components/SellerTrustBadge.d.ts +1 -1
  118. package/dist/features/scams/components/SellerTrustBadge.js +13 -0
  119. package/dist/features/scams/repository/scammer.repository.js +2 -1
  120. package/dist/features/seller/actions/offer-actions.js +6 -1
  121. package/dist/features/seller/components/FulfillmentView.js +5 -2
  122. package/dist/features/seller/components/PhysicalLocationModal.js +1 -1
  123. package/dist/features/seller/components/PrintCenterView.js +3 -1
  124. package/dist/features/seller/components/SellerBidsView.js +1 -1
  125. package/dist/features/seller/components/SellerCouponsView.js +1 -1
  126. package/dist/features/seller/components/SellerGoogleReviewsView.js +2 -2
  127. package/dist/features/seller/components/SellerOrdersView.js +3 -3
  128. package/dist/features/seller/components/SellerPayoutMethodsView.js +2 -2
  129. package/dist/features/seller/components/SellerProductShell.d.ts +5 -0
  130. package/dist/features/seller/components/SellerProductShell.js +15 -5
  131. package/dist/features/seller/components/SellerProductsView.js +24 -10
  132. package/dist/features/seller/components/SellerReviewsView.js +47 -16
  133. package/dist/features/seller/components/SellerShippingConfigsView.js +5 -4
  134. package/dist/features/seller/components/SellerStoreCategoriesView.js +3 -2
  135. package/dist/features/stores/components/StoreDetailLayoutView.d.ts +1 -1
  136. package/dist/features/stores/components/StoreDetailLayoutView.js +12 -3
  137. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +16 -1
  138. package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.js +3 -2
  139. package/dist/features/wishlist/hooks/useWishlistToggle.js +8 -1
  140. package/dist/index.d.ts +1 -1
  141. package/dist/index.js +5 -2
  142. package/dist/next/api/routeHandler.js +3 -1
  143. package/dist/providers/auth-firebase/session.js +3 -1
  144. package/dist/providers/db-firebase/admin-app-lite.js +8 -8
  145. package/dist/providers/db-firebase/admin.js +12 -12
  146. package/dist/providers/db-firebase/browser-stub.d.ts +1 -11
  147. package/dist/providers/db-firebase/browser-stub.js +6 -11
  148. package/dist/providers/db-firebase/index.d.ts +0 -1
  149. package/dist/providers/db-firebase/index.js +0 -1
  150. package/dist/providers/db-firebase/rtdb-paths.d.ts +15 -6
  151. package/dist/providers/db-firebase/rtdb-paths.js +15 -6
  152. package/dist/providers/db-firebase/rtdb-url.d.ts +27 -0
  153. package/dist/providers/db-firebase/rtdb-url.js +50 -0
  154. package/dist/providers/firebase-client/realtime.d.ts +20 -0
  155. package/dist/providers/firebase-client/realtime.js +34 -1
  156. package/dist/providers.d.ts +1 -1
  157. package/dist/providers.js +1 -1
  158. package/dist/react/hooks/use-action-dispatch.js +8 -2
  159. package/dist/react/hooks/useFormStatePreservation.js +3 -1
  160. package/dist/react/hooks/useRealtimeEvent.js +20 -9
  161. package/dist/ui/components/toolbar-collapse.js +1 -2
  162. package/package.json +1 -1
@@ -480,10 +480,12 @@ export class CategoriesRepository extends BaseRepository {
480
480
  .collection(this.collection)
481
481
  .doc(categoryId)
482
482
  .update({ [CATEGORY_FIELDS.VIEW_COUNT]: increment(1) });
483
+ // Fire-and-forget analytics: a lost view increment changes nothing that
484
+ // renders, so it must never break the category read path it rides along
485
+ // with — the count is approximate by design.
483
486
  }
484
487
  catch (_err) {
485
488
  void normalizeError(_err);
486
- // View analytics must not break category read path.
487
489
  }
488
490
  }
489
491
  generateSublistingId(name) {
@@ -9,6 +9,7 @@
9
9
  import { timingSafeEqual } from "crypto";
10
10
  import { ValidationError } from "../../../errors";
11
11
  import { normalizeError } from "../../../errors/normalize";
12
+ import { safeRead } from "../../../errors/safe-read";
12
13
  import { serverLogger } from "../../../monitoring";
13
14
  import { sendEmail } from "../../contact/email";
14
15
  import { getAdminDb } from "../../../providers/db-firebase";
@@ -47,7 +48,15 @@ export async function sendCheckoutValueOtp(userId, userEmail, channel = "email",
47
48
  if (!addressId) {
48
49
  throw new ValidationError("Select a delivery address before sending the code via WhatsApp.");
49
50
  }
50
- const settings = await siteSettingsRepository.getSingleton().catch(() => null);
51
+ // A failed settings read makes the WhatsApp channel look deliberately
52
+ // switched off ("send the code by email instead") — a defensible fallback,
53
+ // but one an operator has to be able to see, since it is indistinguishable
54
+ // from an admin having disabled it.
55
+ const settings = await safeRead(() => siteSettingsRepository.getSingleton(), {
56
+ route: "checkout/value-otp",
57
+ key: "checkoutValueOtp.siteSettings",
58
+ fallback: null,
59
+ });
51
60
  const creds = settings?.credentials ?? {};
52
61
  const whatsappOtpEnabled = settings?.notificationChannels?.whatsapp?.otpEnabled === true;
53
62
  const phoneNumberId = creds.whatsappPhoneNumberId?.trim() ?? "";
@@ -36,7 +36,7 @@ export function ClassifiedContactSellerPanel({ productId, onContactSeller }) {
36
36
  }
37
37
  catch (e) {
38
38
  void normalizeError(e);
39
- setError(e instanceof Error ? e.message : "Something went wrong");
39
+ setError("Something went wrong");
40
40
  }
41
41
  finally {
42
42
  setPending(false);
@@ -22,6 +22,7 @@ import { GroupedListingsCarousel } from "../../grouped/components/GroupedListing
22
22
  import { getGroupsWithItemsForProduct } from "../../../_internal/server/features/grouped/data";
23
23
  import { ClassifiedContactSellerPanel } from "./ClassifiedContactSellerPanel";
24
24
  import { getSiteSettingsGlobal } from "../../admin/utils/getSiteSettingsGlobal";
25
+ import { safeRead } from "../../../errors/safe-read";
25
26
  import { canMakeOffer } from "../../../_internal/shared/listing-types/capabilities";
26
27
  import { DEFAULT_MIN_OFFER_PERCENT } from "../../../_internal/shared/features/offers/config";
27
28
  import { ListingBottomActions } from "../../products/components/ListingBottomActions";
@@ -34,7 +35,10 @@ function toDescriptionHtml(raw) {
34
35
  export async function ClassifiedDetailPageView({ slug, initialProduct, renderOfferAction }) {
35
36
  const product = initialProduct !== undefined
36
37
  ? (initialProduct ?? undefined)
37
- : await getClassifiedForDetail(slug).catch(() => undefined);
38
+ // The listing IS this page's subject — a read failure must surface rather
39
+ // than become the same `undefined` a removed listing produces, which the
40
+ // "Not Found" branch below already handles.
41
+ : await getClassifiedForDetail(slug);
38
42
  if (!product) {
39
43
  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: "Classified Listing Not Found" }), _jsx(Text, { color: "muted", children: "The classified listing you are looking for may have been removed." }), _jsx(Link, { href: String(ROUTES.PUBLIC.CLASSIFIED), className: "text-[length:var(--appkit-text-sm)] font-medium text-primary-600 hover:underline", children: "Browse Classifieds" })] }) }) }) }));
40
44
  }
@@ -58,7 +62,11 @@ export async function ClassifiedDetailPageView({ slug, initialProduct, renderOff
58
62
  const [{ relatedItems, relatedByBrand, relatedByTags, relatedByStore }, groups, siteSettings] = await Promise.all([
59
63
  computeRelatedItems(product),
60
64
  getGroupsWithItemsForProduct(product.id),
61
- getSiteSettingsGlobal().catch(() => null),
65
+ safeRead(() => getSiteSettingsGlobal(), {
66
+ route: "/classified/[slug]",
67
+ key: "classified.siteSettings",
68
+ fallback: null,
69
+ }),
62
70
  ]);
63
71
  // Classifieds are the one non-standard type where haggling is the norm, so
64
72
  // `canMakeOffer` is true for them — but the seller still opts in per listing
@@ -30,7 +30,10 @@ function toDescriptionHtml(raw) {
30
30
  export async function DigitalCodeDetailPageView({ slug, initialProduct, renderPrimaryActions }) {
31
31
  const product = initialProduct !== undefined
32
32
  ? (initialProduct ?? undefined)
33
- : await getDigitalCodeForDetail(slug).catch(() => undefined);
33
+ // The listing IS this page's subject — a read failure must surface rather
34
+ // than become the same `undefined` a removed listing produces, which the
35
+ // "Not Found" branch below already handles.
36
+ : await getDigitalCodeForDetail(slug);
34
37
  if (!product) {
35
38
  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: "Digital Code Listing Not Found" }), _jsx(Text, { color: "muted", children: "The digital code listing you are looking for may have been removed." }), _jsx(Link, { href: String(ROUTES.PUBLIC.DIGITAL_CODES), className: "text-[length:var(--appkit-text-sm)] font-medium text-primary-600 hover:underline", children: "Browse Digital Codes" })] }) }) }) }));
36
39
  }
@@ -13,10 +13,12 @@ export function EventBanner({ storageKey = "dismissed_event_banners", className
13
13
  const stored = sessionStorage.getItem(storageKey);
14
14
  if (stored)
15
15
  setDismissedIds(JSON.parse(stored));
16
+ // sessionStorage is unavailable in private mode and over quota in some
17
+ // embedded browsers. Losing the dismissal list only means the banner
18
+ // reappears — nothing an operator can act on, and nothing the user loses.
16
19
  }
17
20
  catch (_err) {
18
21
  void normalizeError(_err);
19
- // ignore session storage failures
20
22
  }
21
23
  setMounted(true);
22
24
  }, [storageKey]);
@@ -38,10 +40,11 @@ export function EventBanner({ storageKey = "dismissed_event_banners", className
38
40
  setDismissedIds(next);
39
41
  try {
40
42
  sessionStorage.setItem(storageKey, JSON.stringify(next));
43
+ // Same as the read above: the banner is already hidden in local state, so
44
+ // a failed persist only means it returns on the next page load.
41
45
  }
42
46
  catch (_err) {
43
47
  void normalizeError(_err);
44
- // ignore
45
48
  }
46
49
  };
47
50
  return (_jsxs(Section, { tone: "accent-banner", className: [
@@ -24,10 +24,12 @@ export function EventOfferCard({ couponCode, offerDescription, title = "Exclusiv
24
24
  await navigator.clipboard.writeText(couponCode);
25
25
  setCopied(true);
26
26
  setTimeout(() => setCopied(false), 2000);
27
+ // Clipboard access is denied outright on insecure origins and by user
28
+ // permission. The button simply never flips to "Copied", and the code is
29
+ // still visible on screen to select by hand.
27
30
  }
28
31
  catch (_err) {
29
32
  void normalizeError(_err);
30
- /* clipboard unavailable — leave button as-is */
31
33
  }
32
34
  };
33
35
  return (_jsx(Div, { className: `${__P.p6}`, rounded: "2xl", border: "default", children: _jsxs(Stack, { gap: "md", children: [_jsxs(Stack, { gap: "xs", children: [_jsx(Heading, { level: 2, weight: "semibold", size: "2xl", children: title }), offerDescription ? (_jsx(Text, { size: "sm", color: "muted", children: offerDescription })) : null] }), _jsxs(Row, { surface: "muted", padding: "inline", align: "center", gap: "3", rounded: "lg", children: [_jsx(Code, { className: "flex-1", weight: "bold", size: "base", color: "primary", children: couponCode }), _jsx(Button, { type: "button", variant: "outline", onClick: copyCode, children: copied ? "Copied!" : "Copy code" })] }), _jsx(Div, { children: _jsx(Link, { href: href, children: _jsx(Button, { variant: "primary", children: "Apply at checkout" }) }) })] }) }));
@@ -53,7 +53,7 @@ export function EventPollWidget({ eventId, pollConfig, eventStatus, totalEntries
53
53
  }
54
54
  catch (err) {
55
55
  void normalizeError(err);
56
- setError(err instanceof Error ? err.message : "Something went wrong");
56
+ setError("Something went wrong");
57
57
  }
58
58
  finally {
59
59
  setIsLoading(false);
@@ -4,6 +4,7 @@ import { useCallback, useMemo, useState } from "react";
4
4
  import { Button, ClaimCouponButton, Div, Heading, LoginRequiredModal, Row, Span, Stack, Text, useToast } from "../../../ui";
5
5
  import { isAuthError } from "../../../utils/auth-error";
6
6
  import { normalizeError } from "../../../errors/normalize";
7
+ import { toUserMessage } from "../../../errors/error-display-map";
7
8
  const CLS_WHEEL = "relative mx-auto aspect-square w-64 overflow-hidden rounded-full border-4 border-warning bg-[image:var(--appkit-gradient-promotion)]";
8
9
  // audit-content-alignment-ok: self-contained circular spin-wheel game widget, all text centered under the wheel by design
9
10
  const CLS_PRIZE_BOX = "rounded-xl border border-success bg-success-surface dark:border-success p-[var(--appkit-space-4)] text-center";
@@ -55,14 +56,14 @@ export function SpinWheelView({ eventId, prizes, alreadyUsed, initialPrizeId, in
55
56
  }, ANIMATION_MS);
56
57
  }
57
58
  catch (err) {
58
- void normalizeError(err);
59
+ const e = normalizeError(err);
59
60
  setSpinning(false);
60
61
  if (isAuthError(err)) {
61
62
  setShowLoginModal(true);
62
63
  }
63
64
  else {
64
65
  setError(l.errorFallback);
65
- showToast(err instanceof Error ? err.message : l.errorFallback, "error");
66
+ showToast(toUserMessage(e.code, undefined, { fallback: l.errorFallback }), "error");
66
67
  }
67
68
  }
68
69
  }, [disabled, eventId, l.errorFallback, onSpin, showToast]);
@@ -46,6 +46,7 @@ import { apiClient } from "../../../http";
46
46
  import { ADMIN_ENDPOINTS, SELLER_ENDPOINTS } from "../../../constants/api-endpoints";
47
47
  import { ROUTES } from "../../../next/routing/route-map";
48
48
  import { normalizeError } from "../../../errors/normalize";
49
+ import { toUserMessage } from "../../../errors/error-display-map";
49
50
  import { ProductInlineSelect } from "../../seller/components/ProductInlineSelect";
50
51
  import { groupedListingFormSchema, } from "../schemas/grouped-listing-form";
51
52
  const EMPTY = {
@@ -173,7 +174,7 @@ export function GroupedListingEditorView({ scope, groupId, fixedStoreId, onSaved
173
174
  }
174
175
  catch (err) {
175
176
  const e = normalizeError(err);
176
- showToast(e.message || "Failed to save the group.", "error");
177
+ showToast(toUserMessage(e.code, undefined, { fallback: "Failed to save the group." }), "error");
177
178
  }
178
179
  finally {
179
180
  setSaving(false);
@@ -38,35 +38,28 @@ async function postAuthTrack(args) {
38
38
  headers: { "Content-Type": "application/json" },
39
39
  body: JSON.stringify(args),
40
40
  });
41
+ // Recently-viewed tracking is a side effect of browsing, fired from a
42
+ // debounced timer with no UI attached. A lost entry costs the user nothing
43
+ // and there is no caller in a position to retry or report it.
41
44
  }
42
45
  catch (_err) {
43
46
  void normalizeError(_err);
44
- // best-effort tracking
45
47
  }
46
48
  }
49
+ // Both deletes below are user-initiated and deliberately let failures throw:
50
+ // swallowing them made `remove`/`clear` report success for a DELETE that never
51
+ // landed, and the row would silently return on the next load.
47
52
  async function deleteAuthItem(productId) {
48
- try {
49
- await fetch(ACCOUNT_ENDPOINTS.HISTORY_ITEM(productId), {
50
- method: "DELETE",
51
- credentials: "include",
52
- });
53
- }
54
- catch (_err) {
55
- void normalizeError(_err);
56
- // ignore
57
- }
53
+ await fetch(ACCOUNT_ENDPOINTS.HISTORY_ITEM(productId), {
54
+ method: "DELETE",
55
+ credentials: "include",
56
+ });
58
57
  }
59
58
  async function deleteAuthAll() {
60
- try {
61
- await fetch(ACCOUNT_ENDPOINTS.HISTORY, {
62
- method: "DELETE",
63
- credentials: "include",
64
- });
65
- }
66
- catch (_err) {
67
- void normalizeError(_err);
68
- // ignore
69
- }
59
+ await fetch(ACCOUNT_ENDPOINTS.HISTORY, {
60
+ method: "DELETE",
61
+ credentials: "include",
62
+ });
70
63
  }
71
64
  export function useHistory(userId) {
72
65
  const isAuth = !!userId;
@@ -81,6 +74,9 @@ export function useHistory(userId) {
81
74
  else {
82
75
  setItems(getGuestHistory());
83
76
  }
77
+ // fetchAuthHistory already degrades to [] on its own, and getGuestHistory
78
+ // is a localStorage read that fails only in private mode — either way the
79
+ // list stays as it was, which is the correct visible outcome here.
84
80
  }
85
81
  catch (err) {
86
82
  void normalizeError(err);
@@ -131,14 +127,20 @@ export function useHistory(userId) {
131
127
  trackTimers.current.set(key, timer);
132
128
  }, [isAuth]);
133
129
  const remove = useCallback(async (productId) => {
134
- if (isAuth) {
135
- await deleteAuthItem(productId);
136
- await loadItems();
130
+ try {
131
+ if (isAuth) {
132
+ await deleteAuthItem(productId);
133
+ await loadItems();
134
+ }
135
+ else {
136
+ setItems(removeGuestHistoryItem(productId));
137
+ }
137
138
  }
138
- else {
139
- setItems(removeGuestHistoryItem(productId));
139
+ catch (_err) {
140
+ void normalizeError(_err);
141
+ showToast("Failed to remove this item from history.", "error");
140
142
  }
141
- }, [isAuth, loadItems]);
143
+ }, [isAuth, loadItems, showToast]);
142
144
  const clear = useCallback(async () => {
143
145
  try {
144
146
  if (isAuth) {
@@ -16,10 +16,12 @@ function AdsenseAd({ config }) {
16
16
  return;
17
17
  try {
18
18
  (window.adsbygoogle = window.adsbygoogle || []).push({});
19
+ // adsbygoogle is injected by a third-party script that ad blockers and
20
+ // consent walls routinely prevent from loading. The <ins> below simply
21
+ // stays unfilled — there is no failure the site can act on.
19
22
  }
20
23
  catch (_err) {
21
24
  void normalizeError(_err);
22
- // AdSense not loaded yet — no-op
23
25
  }
24
26
  }, []);
25
27
  return (_jsx(Div, { ref: ref, children: _jsx("ins", { className: "adsbygoogle block", "data-ad-client": config.adsenseClient ?? "", "data-ad-slot": config.adsenseSlot ?? "", "data-ad-format": config.adsenseFormat ?? "auto", "data-full-width-responsive": "true" }) }));
@@ -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 { serverLogger } from "../../../monitoring/server-logger";
3
4
  import { Anchor, Div, GRID_MAP, Heading, HandModeRow, Row, Section, Span, Stack, Text } from "../../../ui";
4
5
  import { MediaImage } from "../../media/MediaImage";
5
6
  import { siteSettingsRepository } from "../../admin/repository/site-settings.repository";
@@ -60,8 +61,13 @@ export async function GoogleReviewsSection(config) {
60
61
  totalRatings = result.totalRatings;
61
62
  }
62
63
  catch (_err) {
63
- void normalizeError(_err);
64
- // Fail silently — empty state shown
64
+ // The section renders its empty state below, which is indistinguishable
65
+ // from a place that genuinely has no reviews — and Google Places calls are
66
+ // billed, so a persistent failure is worth an operator seeing.
67
+ serverLogger.warn("homepage.googleReviews: Places fetch failed", {
68
+ placeId,
69
+ error: normalizeError(_err).message,
70
+ });
65
71
  }
66
72
  const gridClass = layout === "carousel"
67
73
  ? "flex gap-[var(--appkit-space-4)] overflow-x-auto pb-[var(--appkit-space-2)] snap-x snap-mandatory"
@@ -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) => {