@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.
- package/dist/_internal/client/features/digital-code/CodeRevealPanel.js +5 -2
- package/dist/_internal/client/features/layout/DashboardLayoutClient.js +3 -3
- package/dist/_internal/client/features/lottery/LotteryAdminEditView.js +3 -2
- package/dist/_internal/client/features/maintenance/views/AnalysisRunnerView.js +5 -2
- package/dist/_internal/client/features/maintenance/views/CloudLogsListView.js +11 -3
- package/dist/_internal/client/hand-mode/HandModeProvider.js +2 -0
- package/dist/_internal/server/features/auctions/actions.js +14 -3
- package/dist/_internal/server/features/auctions/service.js +1 -1
- package/dist/_internal/server/features/blog/data.js +2 -2
- package/dist/_internal/server/features/blog/service.js +3 -3
- package/dist/_internal/server/features/brands/data.js +1 -1
- package/dist/_internal/server/features/brands/service.js +3 -3
- package/dist/_internal/server/features/bundles/actions.js +6 -1
- package/dist/_internal/server/features/bundles/data.js +21 -4
- package/dist/_internal/server/features/cart/data.js +3 -1
- package/dist/_internal/server/features/cart/service.js +13 -2
- package/dist/_internal/server/features/categories/data.js +12 -3
- package/dist/_internal/server/features/events/actions.js +17 -4
- package/dist/_internal/server/features/events/data.js +1 -1
- package/dist/_internal/server/features/events/service.js +1 -1
- package/dist/_internal/server/features/grouped/data.js +7 -2
- package/dist/_internal/server/features/item-requests/data.js +1 -1
- package/dist/_internal/server/features/lottery/actions.js +2 -2
- package/dist/_internal/server/features/lottery/data.js +1 -1
- package/dist/_internal/server/features/orders/actions.js +7 -7
- package/dist/_internal/server/features/orders/data.d.ts +1 -1
- package/dist/_internal/server/features/orders/data.js +14 -2
- package/dist/_internal/server/features/orders/service.js +1 -1
- package/dist/_internal/server/features/pre-orders/service.js +1 -1
- package/dist/_internal/server/features/prize-draws/actions.js +1 -1
- package/dist/_internal/server/features/products/data.js +7 -2
- package/dist/_internal/server/features/products/service.js +1 -1
- package/dist/_internal/server/features/promotions/actions.js +1 -1
- package/dist/_internal/server/features/promotions/data.js +1 -1
- package/dist/_internal/server/features/promotions/service.js +1 -1
- package/dist/_internal/server/features/reviews/data.d.ts +1 -1
- package/dist/_internal/server/features/reviews/data.js +6 -1
- package/dist/_internal/server/features/reviews/og-data.js +1 -1
- package/dist/_internal/server/features/shared/listing-data-factory.js +7 -2
- package/dist/_internal/server/features/stores/data.js +1 -1
- package/dist/_internal/server/features/sublisting-categories/data.js +1 -1
- package/dist/_internal/server/jobs/core/auctionSettlement.js +6 -1
- package/dist/_internal/server/jobs/core/onOrderStatusChange.js +7 -15
- package/dist/_internal/server/jobs/core/revenueRollup.js +7 -1
- package/dist/_internal/server/jobs/core/testerSandboxRefresh.js +27 -2
- package/dist/client/api/useApiQuery.js +17 -6
- package/dist/client.d.ts +1 -0
- package/dist/client.js +3 -0
- package/dist/contracts/client-realtime.d.ts +6 -0
- package/dist/contracts/realtime-session.d.ts +12 -12
- package/dist/contracts/realtime-session.js +72 -77
- package/dist/errors/index.d.ts +1 -1
- package/dist/errors/index.js +1 -1
- package/dist/features/about/components/DeveloperView.js +7 -2
- package/dist/features/about/components/PolicyPageView.js +9 -1
- package/dist/features/about/components/PublicProfileView.js +23 -5
- package/dist/features/account/components/AddressForm.js +1 -1
- package/dist/features/account/components/NotificationBell.js +4 -3
- package/dist/features/account/components/NotificationPreferencesPanel.js +1 -1
- package/dist/features/account/components/UserOffersPanel.js +1 -1
- package/dist/features/admin/components/AdminFulfillmentView.js +5 -2
- package/dist/features/admin/components/AdminMediaView.js +3 -3
- package/dist/features/analytics/types.d.ts +1 -1
- package/dist/features/analytics/types.js +13 -0
- package/dist/features/auctions/components/AuctionDetailPageView.js +6 -2
- package/dist/features/auctions/components/MarketplaceAuctionCard.js +3 -1
- package/dist/features/auth/actions/profile-actions.js +4 -3
- package/dist/features/auth/components/ForgotPasswordView.js +1 -1
- package/dist/features/auth/components/LoginForm.js +1 -1
- package/dist/features/auth/components/RegisterForm.js +1 -1
- package/dist/features/auth/components/ResetPasswordView.js +1 -1
- package/dist/features/auth/hooks/useAuth.js +3 -0
- package/dist/features/blog/repository/blog.repository.js +3 -1
- package/dist/features/cart/actions/cart-actions.js +13 -4
- package/dist/features/cart/utils/pending-ops.js +1 -1
- package/dist/features/categories/components/BrandDetailPageView.js +28 -12
- package/dist/features/categories/components/CategoryDetailPageView.js +37 -20
- package/dist/features/categories/repository/categories.repository.js +3 -1
- package/dist/features/checkout/actions/checkout-value-otp-actions.js +10 -1
- package/dist/features/classified/components/ClassifiedContactSellerPanel.js +1 -1
- package/dist/features/classified/components/ClassifiedDetailPageView.js +10 -2
- package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +4 -1
- package/dist/features/events/components/EventBanner.js +5 -2
- package/dist/features/events/components/EventOfferCard.js +3 -1
- package/dist/features/events/components/EventPollWidget.js +1 -1
- package/dist/features/events/components/SpinWheelView.js +3 -2
- package/dist/features/grouped/components/GroupedListingEditorView.js +2 -1
- package/dist/features/history/hooks/useHistory.js +29 -27
- package/dist/features/homepage/components/AdSlot.js +3 -1
- package/dist/features/homepage/components/GoogleReviewsSection.js +8 -2
- package/dist/features/homepage/components/MarketplaceHomepageView.js +34 -10
- package/dist/features/homepage/components/SocialFeedSection.js +4 -1
- package/dist/features/homepage/lib/live-stats.js +8 -1
- package/dist/features/homepage/repository/carousel.repository.js +2 -1
- package/dist/features/live/components/LiveItemDetailPageView.js +4 -1
- package/dist/features/media/finalize.js +3 -1
- package/dist/features/media/modals/ImageEditor.js +1 -1
- package/dist/features/media/modals/VideoThumbnailSelector.js +5 -2
- package/dist/features/media/modals/VideoTrimModal.js +5 -2
- package/dist/features/media/upload/MediaUploadField.js +2 -2
- package/dist/features/messages/hooks/useConversation.js +18 -4
- package/dist/features/messages/hooks/useConversations.js +25 -8
- package/dist/features/orders/components/RefundRequestView.js +5 -2
- package/dist/features/pre-orders/components/MarketplacePreorderCard.js +3 -1
- package/dist/features/pre-orders/components/PreOrderActionsClient.js +1 -1
- package/dist/features/pre-orders/components/PreOrderDetailPageView.js +4 -1
- package/dist/features/products/components/PrizeDrawDetailPageView.js +4 -1
- package/dist/features/products/components/ProductDetailPageView.js +8 -2
- package/dist/features/products/components/ShareButton.js +3 -1
- package/dist/features/products/repository/loadProductFeatures.js +10 -2
- package/dist/features/products/repository/products.repository.js +2 -1
- package/dist/features/promotions/components/CouponCard.js +3 -1
- package/dist/features/reviews/actions/review-actions.js +8 -1
- package/dist/features/reviews/components/ReviewDetailPageView.js +13 -3
- package/dist/features/reviews/components/ReviewDetailShell.js +3 -1
- package/dist/features/scams/actions/scam-actions.d.ts +9 -1
- package/dist/features/scams/actions/scam-actions.js +52 -12
- package/dist/features/scams/components/SellerTrustBadge.d.ts +1 -1
- package/dist/features/scams/components/SellerTrustBadge.js +13 -0
- package/dist/features/scams/repository/scammer.repository.js +2 -1
- package/dist/features/seller/actions/offer-actions.js +6 -1
- package/dist/features/seller/components/FulfillmentView.js +5 -2
- package/dist/features/seller/components/PhysicalLocationModal.js +1 -1
- package/dist/features/seller/components/PrintCenterView.js +3 -1
- package/dist/features/seller/components/SellerBidsView.js +1 -1
- package/dist/features/seller/components/SellerCouponsView.js +1 -1
- package/dist/features/seller/components/SellerGoogleReviewsView.js +2 -2
- package/dist/features/seller/components/SellerOrdersView.js +3 -3
- package/dist/features/seller/components/SellerPayoutMethodsView.js +2 -2
- package/dist/features/seller/components/SellerProductShell.d.ts +5 -0
- package/dist/features/seller/components/SellerProductShell.js +15 -5
- package/dist/features/seller/components/SellerProductsView.js +24 -10
- package/dist/features/seller/components/SellerReviewsView.js +47 -16
- package/dist/features/seller/components/SellerShippingConfigsView.js +5 -4
- package/dist/features/seller/components/SellerStoreCategoriesView.js +3 -2
- package/dist/features/stores/components/StoreDetailLayoutView.d.ts +1 -1
- package/dist/features/stores/components/StoreDetailLayoutView.js +12 -3
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +16 -1
- package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.js +3 -2
- package/dist/features/wishlist/hooks/useWishlistToggle.js +8 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +5 -2
- package/dist/next/api/routeHandler.js +3 -1
- package/dist/providers/auth-firebase/session.js +3 -1
- package/dist/providers/db-firebase/admin-app-lite.js +8 -8
- package/dist/providers/db-firebase/admin.js +12 -12
- package/dist/providers/db-firebase/browser-stub.d.ts +1 -11
- package/dist/providers/db-firebase/browser-stub.js +6 -11
- package/dist/providers/db-firebase/index.d.ts +0 -1
- package/dist/providers/db-firebase/index.js +0 -1
- package/dist/providers/db-firebase/rtdb-paths.d.ts +15 -6
- package/dist/providers/db-firebase/rtdb-paths.js +15 -6
- package/dist/providers/db-firebase/rtdb-url.d.ts +27 -0
- package/dist/providers/db-firebase/rtdb-url.js +50 -0
- package/dist/providers/firebase-client/realtime.d.ts +20 -0
- package/dist/providers/firebase-client/realtime.js +34 -1
- package/dist/providers.d.ts +1 -1
- package/dist/providers.js +1 -1
- package/dist/react/hooks/use-action-dispatch.js +8 -2
- package/dist/react/hooks/useFormStatePreservation.js +3 -1
- package/dist/react/hooks/useRealtimeEvent.js +20 -9
- package/dist/ui/components/toolbar-collapse.js +1 -2
- 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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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")
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
activeTypes.has("
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
76
|
+
// Subscribe through the LEASE's provider — see useConversation.
|
|
77
|
+
unsubscribe = lease.provider.subscribe(userConversationsPingPath(userId), () => {
|
|
66
78
|
void refetch();
|
|
67
79
|
}, (err) => {
|
|
68
|
-
//
|
|
69
|
-
//
|
|
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
|
-
|
|
41
|
-
setError(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
13
|
+
safeRead(() => productFeaturesRepository.listPlatform(), {
|
|
14
|
+
route: "productFeatures", key: "productFeatures.platform", fallback: [],
|
|
15
|
+
}),
|
|
10
16
|
storeId
|
|
11
|
-
? productFeaturesRepository.listForStore(storeId)
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
19
|
-
|
|
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
|
-
|
|
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. */
|