@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.
- 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/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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { normalizeError } from "../../../errors/normalize";
|
|
3
|
+
import { serverLogger } from "../../../monitoring/server-logger";
|
|
3
4
|
import { Anchor, Avatar, Badge, Div, Heading, Row, Section, Span, Stack, Text, TextLink } from "../../../ui";
|
|
4
5
|
import { ROUTES } from "../../../constants";
|
|
5
6
|
import { PAGE_CONTAINER } from "../../../_internal/shared/styles/page";
|
|
@@ -21,8 +22,12 @@ export async function DeveloperView({ labels = {} }) {
|
|
|
21
22
|
milestones = Array.isArray(aboutContent.milestones) ? aboutContent.milestones : [];
|
|
22
23
|
}
|
|
23
24
|
catch (_err) {
|
|
24
|
-
|
|
25
|
-
//
|
|
25
|
+
// Firestore unavailable — render the empty state below rather than crash,
|
|
26
|
+
// but say so: an empty Developers page is indistinguishable from a settings
|
|
27
|
+
// document that genuinely has no `isDeveloper` members.
|
|
28
|
+
serverLogger.warn("about.developerView: siteSettings.aboutContent read failed", {
|
|
29
|
+
error: normalizeError(_err).message,
|
|
30
|
+
});
|
|
26
31
|
}
|
|
27
32
|
const developers = teamMembers.filter((m) => m.isDeveloper);
|
|
28
33
|
return (_jsxs(Div, { className: "-mx-4 md:-mx-6 lg:-mx-8 -mt-6 sm:-mt-8 lg:-mt-10", children: [_jsx(Section, { color: "inverse", tone: "accent-banner", padding: "banner", children: _jsxs(Div, { className: `${page.container.sm}`, children: [_jsx(Heading, { color: "inverse", level: 1, variant: "none", className: "mb-3", children: labels.title ?? "Meet the Developer" }), _jsx(Text, { color: "inverse", variant: "none", className: "/80", size: "sm", children: labels.subtitle ?? "The person building LetItRip, one feature at a time." })] }) }), _jsxs(Div, { className: `${page.container.sm}`, padding: "content-banner", children: [developers.length === 0 ? (_jsx(Text, { variant: "secondary", children: labels.empty ?? "Developer profile coming soon." })) : (_jsx(Stack, { gap: "xl", children: developers.map((dev) => (_jsxs(Stack, { gap: "md", surface: "default", padding: "lg", rounded: "xl", border: "default", children: [_jsxs(Row, { gap: "md", align: "center", children: [_jsx(Avatar, { src: dev.photoUrl, name: dev.name, alt: dev.name, size: "lg" }), _jsxs(Stack, { gap: "xs", children: [_jsx(Heading, { level: 2, children: dev.name }), _jsxs(Row, { gap: "xs", wrap: true, children: [_jsx(Span, { size: "xs", weight: "bold", className: "text-primary tracking-wide", transform: "uppercase", children: dev.role }), dev.isFounder && _jsx(Badge, { variant: "primary", size: "xs", children: "Founder" }), _jsx(Badge, { variant: "secondary", size: "xs", children: "Developer" })] })] })] }), _jsx(Text, { variant: "secondary", className: "leading-relaxed", children: dev.bio }), dev.githubUrl && (_jsx(Anchor, { href: dev.githubUrl, tone: "brand", size: "sm", underline: "hover", children: "GitHub \u2197" }))] }, dev.name))) })), milestones.length > 0 && (_jsxs(Div, { className: "mt-14 border-t", border: "default", padding: "t-xl", children: [_jsx(Heading, { level: 3, className: "mb-6", color: "muted", size: "sm", weight: "semibold", transform: "uppercase", children: labels.journeyTitle ?? "The Journey" }), _jsx(Stack, { className: "relative border-l-2 border-primary/30 max-w-2xl pl-[2rem]", gap: "lg", children: milestones.map(({ year, text }) => (_jsxs(Div, { className: "relative", children: [_jsx(Div, { className: "absolute -left-10 top-1 w-4 h-4 bg-primary border-2 border-white", rounded: "full" }), _jsx(Span, { size: "xs", weight: "bold", className: "text-primary tracking-wide", transform: "uppercase", children: year }), _jsx(Text, { className: "mt-1", children: text })] }, year))) })] })), _jsx(Div, { className: "mt-10", children: _jsx(TextLink, { href: String(ROUTES.PUBLIC.ABOUT), variant: "muted", size: "sm", children: labels.backToTeam ?? "← Meet the whole team" }) })] })] }));
|
|
@@ -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 { Row } from "@mohasinac/appkit/ui";
|
|
4
5
|
import { ROUTES } from "../../../constants";
|
|
5
6
|
import { PAGE_CONTAINER } from "../../../_internal/shared/styles/page";
|
|
@@ -72,7 +73,14 @@ export async function PolicyPageView({ policy, }) {
|
|
|
72
73
|
}
|
|
73
74
|
catch (_err) {
|
|
74
75
|
void normalizeError(_err);
|
|
75
|
-
//
|
|
76
|
+
// Falls back to the bundled i18n copy below, which is a complete policy in
|
|
77
|
+
// its own right — but an admin who has overridden this page would silently
|
|
78
|
+
// see the old text, so the failure has to be visible to an operator.
|
|
79
|
+
serverLogger.warn("about.policyPageView: siteSettings.legalPages read failed", {
|
|
80
|
+
policy,
|
|
81
|
+
field: meta.firestoreField,
|
|
82
|
+
error: normalizeError(_err).message,
|
|
83
|
+
});
|
|
76
84
|
}
|
|
77
85
|
// i18n fallback sections
|
|
78
86
|
const rawSections = t.raw("sections");
|
|
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
2
|
import Link from "next/link";
|
|
3
3
|
import { getPublicUserProfile, getProfileStoreProducts, getReviewsAuthoredBy, getReviewsReceivedBy } from "../../auth/actions/profile-actions";
|
|
4
4
|
import { storeRepository } from "../../stores/repository/store.repository";
|
|
5
|
+
import { safeRead } from "../../../errors/safe-read";
|
|
5
6
|
import { ROUTES } from "../../../constants";
|
|
6
7
|
import { PAGE_CONTAINER } from "../../../_internal/shared/styles/page";
|
|
7
8
|
import { FLEX_CENTER } from "../../../_internal/shared/styles/themed";
|
|
@@ -64,13 +65,30 @@ export async function PublicProfileView({ userId, tab, }) {
|
|
|
64
65
|
const flex = { center: FLEX_CENTER };
|
|
65
66
|
const { getTranslations } = await import("next-intl/server");
|
|
66
67
|
const t = await getTranslations("publicProfile");
|
|
67
|
-
|
|
68
|
+
// The profile IS this page's subject — a read failure must surface, not turn
|
|
69
|
+
// into the same `null` a genuinely missing user produces. The `null` return
|
|
70
|
+
// from getPublicUserProfile still means "no such user".
|
|
71
|
+
const profile = await getPublicUserProfile(userId);
|
|
68
72
|
const storeId = profile?.storeSlug ?? null;
|
|
69
73
|
const [products, store, reviewsAuthored, reviewsReceivedAsBuyer] = await Promise.all([
|
|
70
|
-
storeId
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
+
storeId
|
|
75
|
+
? safeRead(() => getProfileStoreProducts(storeId), {
|
|
76
|
+
route: "/profile/[userId]",
|
|
77
|
+
key: "profile.storeProducts",
|
|
78
|
+
fallback: [],
|
|
79
|
+
})
|
|
80
|
+
: Promise.resolve([]),
|
|
81
|
+
storeId
|
|
82
|
+
? safeRead(() => storeRepository.findById(storeId), {
|
|
83
|
+
route: "/profile/[userId]",
|
|
84
|
+
key: "profile.store",
|
|
85
|
+
fallback: null,
|
|
86
|
+
})
|
|
87
|
+
: Promise.resolve(null),
|
|
88
|
+
// Both already degrade internally via safeRead — anything still thrown here
|
|
89
|
+
// is a mapping bug, which should surface rather than render an empty rail.
|
|
90
|
+
getReviewsAuthoredBy(userId),
|
|
91
|
+
getReviewsReceivedBy(userId),
|
|
74
92
|
]);
|
|
75
93
|
const isSeller = isSellerUser(profile) || isAdminUser(profile);
|
|
76
94
|
// For sellers: lead with store identity (name + logo). For buyers: user identity.
|
|
@@ -102,7 +102,7 @@ export function AddressForm({ initialData, onSubmit, onCancel, isLoading = false
|
|
|
102
102
|
}
|
|
103
103
|
catch (err) {
|
|
104
104
|
void normalizeError(err);
|
|
105
|
-
showToast(
|
|
105
|
+
showToast("Failed to save address", "error");
|
|
106
106
|
}
|
|
107
107
|
}, [formData, onSubmit, showToast, setFieldError, clearErrors, markSubmitAttempted]);
|
|
108
108
|
useEffect(() => {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { normalizeError } from "../../../errors/normalize";
|
|
4
|
+
import { toUserMessage } from "../../../errors/error-display-map";
|
|
4
5
|
import { useCallback, useRef, useState } from "react";
|
|
5
6
|
import { Button, Div, Heading, Li, Row, Span, Spinner, Stack, Text, TextLink, Ul, useToast } from "../../../ui";
|
|
6
7
|
import { useClickOutside, useMessage } from "../../../react";
|
|
@@ -52,7 +53,7 @@ export function NotificationBell({ limit = 10, viewAllHref, labels, icons, rende
|
|
|
52
53
|
}
|
|
53
54
|
catch (err) {
|
|
54
55
|
void normalizeError(err);
|
|
55
|
-
showToast(
|
|
56
|
+
showToast("Failed to mark notification as read", "error");
|
|
56
57
|
}
|
|
57
58
|
}, [markRead, refetch, showToast]);
|
|
58
59
|
const emitSuccess = onMarkAllReadSuccess ?? showSuccess;
|
|
@@ -63,12 +64,12 @@ export function NotificationBell({ limit = 10, viewAllHref, labels, icons, rende
|
|
|
63
64
|
emitSuccess(labels.markAllRead);
|
|
64
65
|
}
|
|
65
66
|
catch (err) {
|
|
66
|
-
|
|
67
|
+
const e = normalizeError(err);
|
|
67
68
|
if (onMarkAllReadError) {
|
|
68
69
|
onMarkAllReadError(labels.error);
|
|
69
70
|
}
|
|
70
71
|
else {
|
|
71
|
-
showToast(
|
|
72
|
+
showToast(toUserMessage(e.code, undefined, { fallback: labels.error }), "error");
|
|
72
73
|
}
|
|
73
74
|
}
|
|
74
75
|
}, [
|
|
@@ -79,7 +79,7 @@ export function NotificationPreferencesPanel({ fetchUrl = ACCOUNT_ENDPOINTS.NOTI
|
|
|
79
79
|
}
|
|
80
80
|
catch (err) {
|
|
81
81
|
void normalizeError(err);
|
|
82
|
-
showToast(
|
|
82
|
+
showToast("Failed to save preferences.", "error");
|
|
83
83
|
}
|
|
84
84
|
finally {
|
|
85
85
|
setSaving(false);
|
|
@@ -40,12 +40,15 @@ export function AdminFulfillmentView({ initialStoreId, initialStoreName, }) {
|
|
|
40
40
|
setOrders(res.orders ?? []);
|
|
41
41
|
}
|
|
42
42
|
catch (err) {
|
|
43
|
-
|
|
43
|
+
// An empty fulfilment queue is a normal, expected state, so a swallowed
|
|
44
|
+
// failure here reads as "nothing to pack" — matching the rest of this
|
|
45
|
+
// file, surface it instead.
|
|
46
|
+
showToast(normalizeError(err).message, "error");
|
|
44
47
|
}
|
|
45
48
|
finally {
|
|
46
49
|
setIsLoading(false);
|
|
47
50
|
}
|
|
48
|
-
}, []);
|
|
51
|
+
}, [showToast]);
|
|
49
52
|
useEffect(() => {
|
|
50
53
|
if (storeId)
|
|
51
54
|
void fetchOrders(storeId);
|
|
@@ -48,7 +48,7 @@ function MediaBrowser({ onCopy }) {
|
|
|
48
48
|
}
|
|
49
49
|
catch (err) {
|
|
50
50
|
void normalizeError(err);
|
|
51
|
-
setError(
|
|
51
|
+
setError("Failed to load");
|
|
52
52
|
}
|
|
53
53
|
finally {
|
|
54
54
|
setIsLoading(false);
|
|
@@ -108,7 +108,7 @@ export function AdminMediaView({ labels = {}, children, ...rest }) {
|
|
|
108
108
|
catch (err) {
|
|
109
109
|
void normalizeError(err);
|
|
110
110
|
setOperationMessage("Failed to discard staged media uploads.");
|
|
111
|
-
showToast(
|
|
111
|
+
showToast("Failed to clear uploads.", "error");
|
|
112
112
|
}
|
|
113
113
|
}, [cleanup, stagedUrls, showToast]);
|
|
114
114
|
const copyToClipboard = React.useCallback(async (url) => {
|
|
@@ -121,7 +121,7 @@ export function AdminMediaView({ labels = {}, children, ...rest }) {
|
|
|
121
121
|
catch (err) {
|
|
122
122
|
void normalizeError(err);
|
|
123
123
|
setOperationMessage("Could not copy URL to clipboard.");
|
|
124
|
-
showToast(
|
|
124
|
+
showToast("Failed to copy.", "error");
|
|
125
125
|
}
|
|
126
126
|
}, [showToast]);
|
|
127
127
|
if (hasChildren) {
|
|
@@ -2,5 +2,5 @@
|
|
|
2
2
|
* page-views.repository.ts (server-only, imports firebase-admin) so the
|
|
3
3
|
* client-side tracker component can import just this without pulling
|
|
4
4
|
* firebase-admin into the client bundle. */
|
|
5
|
-
export declare const PAGE_VIEW_ENTITY_TYPES: readonly ["product", "store", "category", "homepage", "auction", "review", "user-profile", "blog", "event", "pre-order", "prize-draw", "classified", "digital-code", "live", "bundle", "grouped-listing"];
|
|
5
|
+
export declare const PAGE_VIEW_ENTITY_TYPES: readonly ["product", "store", "category", "homepage", "auction", "review", "user-profile", "blog", "event", "pre-order", "prize-draw", "classified", "digital-code", "live", "bundle", "grouped-listing", "listing"];
|
|
6
6
|
export type PageViewEntityType = (typeof PAGE_VIEW_ENTITY_TYPES)[number];
|
|
@@ -21,4 +21,17 @@ export const PAGE_VIEW_ENTITY_TYPES = [
|
|
|
21
21
|
/** A `groupedListings` document's own page. Distinct from "bundle" — a
|
|
22
22
|
* bundle is a priced, all-or-nothing set; a group is pick-as-you-wish. */
|
|
23
23
|
"grouped-listing",
|
|
24
|
+
/**
|
|
25
|
+
* A public BROWSE page — `/products`, `/auctions`, `/stores`, … — as opposed
|
|
26
|
+
* to one entity's detail page.
|
|
27
|
+
*
|
|
28
|
+
* 🛑 `entityId` must be the bare route name ("products"), NEVER the filtered
|
|
29
|
+
* URL. `pageViews` doc ids are `${date}_${entityType}_${entityId}` with a
|
|
30
|
+
* `FieldValue.increment`, so a bounded id costs ONE write per day per page,
|
|
31
|
+
* while keying on a query string would create a new document per filter
|
|
32
|
+
* combination — unbounded doc creation on the highest-traffic pages, against
|
|
33
|
+
* the Firestore write budget in Rule #6. The full URL still travels in the
|
|
34
|
+
* `url` field, which is recorded but not part of the key.
|
|
35
|
+
*/
|
|
36
|
+
"listing",
|
|
24
37
|
];
|
|
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
2
|
import Link from "next/link";
|
|
3
3
|
import { productRepository } from "../../../repositories";
|
|
4
4
|
import { listBidsByProduct } from "../../auctions/actions/bid-actions";
|
|
5
|
+
import { safeRead } from "../../../errors/safe-read";
|
|
5
6
|
const __P = {
|
|
6
7
|
p3: "p-[var(--appkit-space-3)]",
|
|
7
8
|
p4: "p-[var(--appkit-space-4)]",
|
|
@@ -62,11 +63,14 @@ function renderAuctionStoreReviews(storeSlug) {
|
|
|
62
63
|
return (_jsxs(Section, { className: "mt-10", children: [_jsx(Heading, { level: 2, className: "mb-2", color: "primary", size: "xl", weight: "semibold", children: "Store Reviews" }), _jsx(ReviewsListingPanel, { source: { kind: "store", storeSlug }, stateMode: "local", context: "store", emptyLabel: "This store has no reviews yet." })] }));
|
|
63
64
|
}
|
|
64
65
|
export async function AuctionDetailPageView({ id, initialAuction, onPlaceBid, onBuyNow, productFeatures }) {
|
|
66
|
+
// The auction IS this page's subject. A read failure must surface rather than
|
|
67
|
+
// become the same `undefined` a genuinely missing slug produces — the
|
|
68
|
+
// "Auction Not Found" branch below still handles that real case.
|
|
65
69
|
const product = initialAuction !== undefined
|
|
66
70
|
? (initialAuction ?? undefined)
|
|
67
|
-
: await productRepository.findByIdOrSlug(id)
|
|
71
|
+
: await productRepository.findByIdOrSlug(id);
|
|
68
72
|
const bidsResult = product
|
|
69
|
-
? await listBidsByProduct(String(product.id), { pageSize: 5 }).
|
|
73
|
+
? await safeRead(() => listBidsByProduct(String(product.id), { pageSize: 5 }), { route: "/auctions/[slug]", key: "auction.bidHistory", fallback: null })
|
|
70
74
|
: null;
|
|
71
75
|
const storeId = product?.storeId;
|
|
72
76
|
if (!product) {
|
|
@@ -163,8 +163,10 @@ export function MarketplaceAuctionCard({ product, buyoutPrice, className = "", v
|
|
|
163
163
|
await toggle();
|
|
164
164
|
}
|
|
165
165
|
catch (_err) {
|
|
166
|
+
// useWishlistToggle rolls back its optimistic state and raises its own
|
|
167
|
+
// toast on failure, so the heart returns to its true value on screen.
|
|
168
|
+
// Reporting again here would double-notify for one user action.
|
|
166
169
|
void normalizeError(_err);
|
|
167
|
-
// The hook rolls back optimistic state when the mutation fails.
|
|
168
170
|
}
|
|
169
171
|
}, [toggle]);
|
|
170
172
|
const countdownClass = isEnded
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Auth, rate-limiting, and Next.js specifics are handled by the consumer.
|
|
6
6
|
*/
|
|
7
7
|
import { maskPublicReview } from "../../../security";
|
|
8
|
+
import { safeRead } from "../../../errors/safe-read";
|
|
8
9
|
import { finalizeStagedMediaField } from "../../media/finalize";
|
|
9
10
|
import { userRepository } from "../repository/user.repository";
|
|
10
11
|
import { sessionRepository } from "../repository/session.repository";
|
|
@@ -65,7 +66,7 @@ export async function getPublicUserProfile(userId) {
|
|
|
65
66
|
}
|
|
66
67
|
/** Fetch approved reviews for a store. storeId === storeSlug in this project. */
|
|
67
68
|
export async function getSellerReviews(storeId) {
|
|
68
|
-
const snapshot = await reviewRepository.findApprovedByStore(storeId).
|
|
69
|
+
const snapshot = await safeRead(() => reviewRepository.findApprovedByStore(storeId), { route: "/profile/[userId]", key: "reviews.findApprovedByStore", fallback: [] });
|
|
69
70
|
return snapshot.map((r) => ({
|
|
70
71
|
id: r.id,
|
|
71
72
|
productId: r.productId,
|
|
@@ -94,7 +95,7 @@ export async function getProfileStoreProducts(storeId) {
|
|
|
94
95
|
}
|
|
95
96
|
/** Approved product reviews written by this user as a buyer. Excludes seller→buyer ratings. */
|
|
96
97
|
export async function getReviewsAuthoredBy(userId) {
|
|
97
|
-
const all = await reviewRepository.findApprovedByUser(userId).
|
|
98
|
+
const all = await safeRead(() => reviewRepository.findApprovedByUser(userId), { route: "/profile/[userId]", key: "reviews.findApprovedByUser", fallback: [] });
|
|
98
99
|
// reviewerRole:"seller" means this user rated a buyer — not a product review
|
|
99
100
|
const snapshot = all.filter((r) => r.reviewerRole !== "seller");
|
|
100
101
|
return snapshot.map((r) => ({
|
|
@@ -121,7 +122,7 @@ export async function getReviewsAuthoredBy(userId) {
|
|
|
121
122
|
}
|
|
122
123
|
/** Approved seller→buyer reviews received by this buyer. */
|
|
123
124
|
export async function getReviewsReceivedBy(userId) {
|
|
124
|
-
const snapshot = await reviewRepository.findByReviewee(userId).
|
|
125
|
+
const snapshot = await safeRead(() => reviewRepository.findByReviewee(userId), { route: "/profile/[userId]", key: "reviews.findByReviewee", fallback: [] });
|
|
125
126
|
return snapshot.map((r) => ({
|
|
126
127
|
id: r.id,
|
|
127
128
|
productId: r.productId,
|
|
@@ -22,7 +22,7 @@ export function ForgotPasswordView({ onSubmit, isLoading = false, error, success
|
|
|
22
22
|
}
|
|
23
23
|
catch (err) {
|
|
24
24
|
void normalizeError(err);
|
|
25
|
-
showToast(
|
|
25
|
+
showToast("Failed to send reset link", "error");
|
|
26
26
|
}
|
|
27
27
|
};
|
|
28
28
|
}
|
|
@@ -27,7 +27,7 @@ export function LoginForm({ onSubmit, onGoogleLogin, isLoading = false, error, l
|
|
|
27
27
|
}
|
|
28
28
|
catch (err) {
|
|
29
29
|
void normalizeError(err);
|
|
30
|
-
showToast(
|
|
30
|
+
showToast("Sign in failed", "error");
|
|
31
31
|
}
|
|
32
32
|
}, children: isLoading
|
|
33
33
|
? (labels.submittingLabel ?? "Signing in…")
|
|
@@ -39,7 +39,7 @@ export function RegisterForm({ onSubmit, isLoading = false, error, success, labe
|
|
|
39
39
|
}
|
|
40
40
|
catch (err) {
|
|
41
41
|
void normalizeError(err);
|
|
42
|
-
showToast(
|
|
42
|
+
showToast("Registration failed", "error");
|
|
43
43
|
}
|
|
44
44
|
}, children: isLoading
|
|
45
45
|
? (labels.submittingLabel ?? "Creating account…")
|
|
@@ -30,7 +30,7 @@ export function ResetPasswordView({ oobCode, onSubmit, isLoading = false, error,
|
|
|
30
30
|
}
|
|
31
31
|
catch (err) {
|
|
32
32
|
void normalizeError(err);
|
|
33
|
-
showToast(
|
|
33
|
+
showToast("Password reset failed", "error");
|
|
34
34
|
}
|
|
35
35
|
};
|
|
36
36
|
}
|
|
@@ -315,6 +315,9 @@ export function useRegister(options) {
|
|
|
315
315
|
// fail registration — the user can re-request verification later.
|
|
316
316
|
try {
|
|
317
317
|
await authProvider.sendEmailVerification();
|
|
318
|
+
// Non-fatal by design: the account already exists, and the user can
|
|
319
|
+
// re-request verification from Settings at any time. Firebase surfaces
|
|
320
|
+
// its own send failures; failing registration here would be worse.
|
|
318
321
|
}
|
|
319
322
|
catch (err) {
|
|
320
323
|
void normalizeError(err);
|
|
@@ -72,10 +72,12 @@ class BlogRepository extends BaseRepository {
|
|
|
72
72
|
.collection(this.collection)
|
|
73
73
|
.doc(id)
|
|
74
74
|
.update({ [BLOG_POST_FIELDS.VIEWS]: increment(1) });
|
|
75
|
+
// Fire-and-forget analytics: a lost view increment changes nothing that
|
|
76
|
+
// renders and cannot usefully be retried, so it must never fail the blog
|
|
77
|
+
// post read that triggered it — the count is approximate by design.
|
|
75
78
|
}
|
|
76
79
|
catch (_err) {
|
|
77
80
|
void normalizeError(_err);
|
|
78
|
-
// Fire-and-forget by design.
|
|
79
81
|
}
|
|
80
82
|
}
|
|
81
83
|
async findRelated(category, excludeId, limit = 3) {
|
|
@@ -14,6 +14,7 @@ import { getDefaultCurrency } from "../../../core/baseline-resolver";
|
|
|
14
14
|
import { CART_LANE, activeLane, canAddNewItems, } from "../../../_internal/shared/checkout/lanes";
|
|
15
15
|
import { CART_MAX_ITEMS } from "../../../constants/limits";
|
|
16
16
|
import { getExpandedDecrements, validateCartItemStock, } from "../../../_internal/server/features/checkout/bundle-expansion";
|
|
17
|
+
import { safeRead } from "../../../errors/safe-read";
|
|
17
18
|
/**
|
|
18
19
|
* Product documents don't always carry a denormalized `storeName` — the
|
|
19
20
|
* create routes only set it when the caller explicitly passes one, so
|
|
@@ -26,7 +27,9 @@ import { getExpandedDecrements, validateCartItemStock, } from "../../../_interna
|
|
|
26
27
|
async function resolveStoreName(storeId, candidate) {
|
|
27
28
|
if (candidate)
|
|
28
29
|
return candidate;
|
|
29
|
-
const store = await storeRepository.findById(storeId)
|
|
30
|
+
const store = await safeRead(() => storeRepository.findById(storeId), {
|
|
31
|
+
route: "/cart", key: "stores.findById", fallback: null,
|
|
32
|
+
});
|
|
30
33
|
return store?.storeName ?? storeId;
|
|
31
34
|
}
|
|
32
35
|
/**
|
|
@@ -102,7 +105,9 @@ export async function addGroupLineToCart(userId, input) {
|
|
|
102
105
|
const cart = await assertCanAddNewItems(userId);
|
|
103
106
|
// One batched read for the whole selection — capped at 20 above, so well
|
|
104
107
|
// inside the per-request Firestore budget.
|
|
105
|
-
const docs = await Promise.all(members.map((m) => productRepository.findById(m.productId)
|
|
108
|
+
const docs = await Promise.all(members.map((m) => safeRead(() => productRepository.findById(m.productId), {
|
|
109
|
+
route: "/cart", key: "products.findById", fallback: null,
|
|
110
|
+
})));
|
|
106
111
|
const productById = new Map(members.map((m, i) => [m.productId, docs[i]]));
|
|
107
112
|
const { getListingRule } = await import("../../../_internal/shared/checkout/rules");
|
|
108
113
|
const { ProductStatusValues } = await import("../../products/schemas/firestore");
|
|
@@ -209,14 +214,18 @@ export async function addGroupLineToCart(userId, input) {
|
|
|
209
214
|
/** Is this product listed on that `groupedListings` document? */
|
|
210
215
|
async function isGroupedListingMember(groupId, productId) {
|
|
211
216
|
const { groupedListingsRepository } = await import("../../grouped/repository/grouped-listings.repository");
|
|
212
|
-
const group = await groupedListingsRepository.findById(groupId)
|
|
217
|
+
const group = await safeRead(() => groupedListingsRepository.findById(groupId), {
|
|
218
|
+
route: "/cart", key: "groupedListings.findById", fallback: null,
|
|
219
|
+
});
|
|
213
220
|
return Boolean(group?.productIds?.includes(productId));
|
|
214
221
|
}
|
|
215
222
|
/** Display name for the line. Falls back to the parent product's group title. */
|
|
216
223
|
async function resolveGroupTitle(groupId, groupSource, sample) {
|
|
217
224
|
if (groupSource === "grouped-listing") {
|
|
218
225
|
const { groupedListingsRepository } = await import("../../grouped/repository/grouped-listings.repository");
|
|
219
|
-
const group = await groupedListingsRepository.findById(groupId)
|
|
226
|
+
const group = await safeRead(() => groupedListingsRepository.findById(groupId), {
|
|
227
|
+
route: "/cart", key: "groupedListings.title", fallback: null,
|
|
228
|
+
});
|
|
220
229
|
if (group?.title)
|
|
221
230
|
return group.title;
|
|
222
231
|
}
|
|
@@ -72,10 +72,10 @@ function write(key, items) {
|
|
|
72
72
|
return;
|
|
73
73
|
try {
|
|
74
74
|
localStorage.setItem(key, JSON.stringify(items));
|
|
75
|
+
// Quota exceeded or storage blocked. Losing pending ops costs a retry, never data: the cart is canonical in Firestore.
|
|
75
76
|
}
|
|
76
77
|
catch (_err) {
|
|
77
78
|
void normalizeError(_err);
|
|
78
|
-
// storage quota exceeded — best effort
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
81
|
function writeCartOps(items) {
|
|
@@ -16,24 +16,25 @@ import { listingTabCounts, } from "../../../_internal/server/features/products/l
|
|
|
16
16
|
import { CATEGORY_PAGE_TABS } from "../../products/constants/listing-tabs";
|
|
17
17
|
import { enabledCategoryTypes, enabledListingTypes } from "../../../_internal/shared/listing-types/feature-flags";
|
|
18
18
|
import { siteSettingsRepository } from "../../../repositories";
|
|
19
|
+
import { safeRead } from "../../../errors/safe-read";
|
|
19
20
|
const __O = {
|
|
20
21
|
hidden: "overflow-hidden",
|
|
21
22
|
};
|
|
22
23
|
export async function BrandDetailPageView({ slug, initialBrand }) {
|
|
24
|
+
// The brand IS this page's subject — a read failure must surface rather than
|
|
25
|
+
// render an anonymous, empty brand page. `undefined` still means "no such
|
|
26
|
+
// brand slug".
|
|
23
27
|
const brand = (initialBrand ?? (await categoriesRepository
|
|
24
|
-
.getCategoryBySlug(slug)
|
|
25
|
-
.catch(() => undefined)));
|
|
28
|
+
.getCategoryBySlug(slug)) ?? undefined);
|
|
26
29
|
const brandName = brand?.name;
|
|
27
30
|
const [productsResult, tabCounts, allBundles, activeBrands, groupedListings, settings] = await Promise.all([
|
|
28
31
|
brandName
|
|
29
|
-
? productRepository
|
|
30
|
-
.list({
|
|
32
|
+
? safeRead(() => productRepository.list({
|
|
31
33
|
filters: sieveAnd(sieveFilter("status", SIEVE_OP.EQ, "published"), sieveFilter("brand", SIEVE_OP.EQ, brandName), sieveFilter("listingType", SIEVE_OP.EQ, "standard")),
|
|
32
34
|
sorts: sortBy("createdAt", "DESC"),
|
|
33
35
|
page: 1,
|
|
34
36
|
pageSize: 24,
|
|
35
|
-
})
|
|
36
|
-
.catch(() => null)
|
|
37
|
+
}), { route: "/brands/[slug]", key: "brand.products", fallback: null })
|
|
37
38
|
: Promise.resolve(null),
|
|
38
39
|
// One count per tab, derived from CATEGORY_PAGE_TABS. Four of the nine tabs
|
|
39
40
|
// (classifieds, digital codes, live, art) had no count at all and could
|
|
@@ -47,16 +48,31 @@ export async function BrandDetailPageView({ slug, initialBrand }) {
|
|
|
47
48
|
// We pull all bundle categories and filter client-side by brandSlug —
|
|
48
49
|
// no dedicated repository query needed at this catalog's bundle volume.
|
|
49
50
|
brand?.slug
|
|
50
|
-
? categoriesRepository
|
|
51
|
-
.listByType("bundle", { activeOnly: true, limit: 50 })
|
|
52
|
-
.catch(() => [])
|
|
51
|
+
? safeRead(() => categoriesRepository.listByType("bundle", { activeOnly: true, limit: 50 }), { route: "/brands/[slug]", key: "brand.bundles", fallback: [] })
|
|
53
52
|
: Promise.resolve([]),
|
|
54
53
|
// Related brands — every other active brand row, excluding this one.
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
// `as unknown` is load-bearing only because CategoryDocument.createdAt is a
|
|
55
|
+
// Date while CategoryItem.createdAt is a string — a pre-existing mismatch
|
|
56
|
+
// the old `.catch(() => [])` union happened to paper over.
|
|
57
|
+
safeRead(() => categoriesRepository.findActiveBrands(), {
|
|
58
|
+
route: "/brands/[slug]",
|
|
59
|
+
key: "brand.relatedBrands",
|
|
60
|
+
fallback: [],
|
|
61
|
+
}),
|
|
62
|
+
brand?.slug
|
|
63
|
+
? safeRead(() => getGroupsForBrand(brand.slug), {
|
|
64
|
+
route: "/brands/[slug]",
|
|
65
|
+
key: "brand.groupedListings",
|
|
66
|
+
fallback: [],
|
|
67
|
+
})
|
|
68
|
+
: Promise.resolve([]),
|
|
57
69
|
// Feature flags — accepted by BrandDetailTabs all along but never passed,
|
|
58
70
|
// so a disabled listing type still showed its tab here.
|
|
59
|
-
siteSettingsRepository.findById("global")
|
|
71
|
+
safeRead(() => siteSettingsRepository.findById("global"), {
|
|
72
|
+
route: "/brands/[slug]",
|
|
73
|
+
key: "brand.siteSettings",
|
|
74
|
+
fallback: null,
|
|
75
|
+
}),
|
|
60
76
|
]);
|
|
61
77
|
const brandBundles = brand?.slug
|
|
62
78
|
? allBundles.filter((b) => b.brandSlug === brand.slug)
|
|
@@ -15,14 +15,17 @@ import { listingTabCounts, } from "../../../_internal/server/features/products/l
|
|
|
15
15
|
import { CATEGORY_PAGE_TABS } from "../../products/constants/listing-tabs";
|
|
16
16
|
import { enabledCategoryTypes, enabledListingTypes } from "../../../_internal/shared/listing-types/feature-flags";
|
|
17
17
|
import { siteSettingsRepository } from "../../../repositories";
|
|
18
|
+
import { safeRead } from "../../../errors/safe-read";
|
|
18
19
|
const __O = {
|
|
19
20
|
hidden: "overflow-hidden",
|
|
20
21
|
xAuto: "overflow-x-auto",
|
|
21
22
|
};
|
|
22
23
|
export async function CategoryDetailPageView({ slug }) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
// The category IS this page's subject — a read failure must surface rather
|
|
25
|
+
// than render an anonymous, empty category page. `undefined` still means
|
|
26
|
+
// "no such category slug".
|
|
27
|
+
const category = ((await categoriesRepository
|
|
28
|
+
.getCategoryBySlug(slug)) ?? undefined);
|
|
26
29
|
// Products carry their FULL ancestor chain in `categorySlugs`, so matching on
|
|
27
30
|
// this category's own id already returns the whole subtree — no descendant
|
|
28
31
|
// expansion needed. Expanding it was also a latent outage: `categoriesIn` is
|
|
@@ -33,14 +36,12 @@ export async function CategoryDetailPageView({ slug }) {
|
|
|
33
36
|
const categoriesIn = category?.id ? [category.id] : null;
|
|
34
37
|
const [productsResult, tabCounts, bundlesResult, childCategories, rootSiblingCategories, groupedListings, settings] = await Promise.all([
|
|
35
38
|
categoriesIn
|
|
36
|
-
? productRepository
|
|
37
|
-
.list({
|
|
39
|
+
? safeRead(() => productRepository.list({
|
|
38
40
|
filters: sieveAnd(sieveFilter("status", SIEVE_OP.EQ, "published"), sieveFilter("listingType", SIEVE_OP.EQ, "standard")),
|
|
39
41
|
sorts: sortBy("createdAt", "DESC"),
|
|
40
42
|
page: 1,
|
|
41
43
|
pageSize: 24,
|
|
42
|
-
}, { categoriesIn })
|
|
43
|
-
.catch(() => null)
|
|
44
|
+
}, { categoriesIn }), { route: "/categories/[slug]", key: "category.products", fallback: null })
|
|
44
45
|
: Promise.resolve(null),
|
|
45
46
|
// One count per tab, derived from CATEGORY_PAGE_TABS itself — so every
|
|
46
47
|
// listing type is counted and a type with nothing in this category hides.
|
|
@@ -51,24 +52,40 @@ export async function CategoryDetailPageView({ slug }) {
|
|
|
51
52
|
// SB-UNI-D — bundles fetched from the categories collection. We pull
|
|
52
53
|
// all active bundle rows; the carousel filters by category affinity.
|
|
53
54
|
categoriesIn
|
|
54
|
-
? categoriesRepository
|
|
55
|
-
.listByType("bundle", { activeOnly: true, limit: 50 })
|
|
56
|
-
.catch(() => [])
|
|
55
|
+
? safeRead(() => categoriesRepository.listByType("bundle", { activeOnly: true, limit: 50 }), { route: "/categories/[slug]", key: "category.bundles", fallback: [] })
|
|
57
56
|
: Promise.resolve([]),
|
|
58
57
|
category?.id
|
|
59
|
-
? categoriesRepository.getChildren(category.id)
|
|
58
|
+
? safeRead(() => categoriesRepository.getChildren(category.id), {
|
|
59
|
+
route: "/categories/[slug]",
|
|
60
|
+
key: "category.children",
|
|
61
|
+
fallback: [],
|
|
62
|
+
})
|
|
60
63
|
: Promise.resolve([]),
|
|
61
64
|
// Related categories — every other category sharing this category's root
|
|
62
65
|
// (siblings + cousins across the tree, up to the tier-0/1/2 depth the
|
|
63
66
|
// catalog actually uses), not just direct children.
|
|
64
67
|
category?.rootId
|
|
65
|
-
? categoriesRepository.getCategoriesByRootId(category.rootId)
|
|
68
|
+
? safeRead(() => categoriesRepository.getCategoriesByRootId(category.rootId), {
|
|
69
|
+
route: "/categories/[slug]",
|
|
70
|
+
key: "category.rootSiblings",
|
|
71
|
+
fallback: [],
|
|
72
|
+
})
|
|
73
|
+
: Promise.resolve([]),
|
|
74
|
+
category?.slug
|
|
75
|
+
? safeRead(() => getGroupsForCategory(category.slug), {
|
|
76
|
+
route: "/categories/[slug]",
|
|
77
|
+
key: "category.groupedListings",
|
|
78
|
+
fallback: [],
|
|
79
|
+
})
|
|
66
80
|
: Promise.resolve([]),
|
|
67
|
-
category?.slug ? getGroupsForCategory(category.slug).catch(() => []) : Promise.resolve([]),
|
|
68
81
|
// Listing/category-type feature flags. This page accepted the props all
|
|
69
82
|
// along but never passed them, so an admin disabling a listing type still
|
|
70
83
|
// saw its tab here while the store page correctly hid it.
|
|
71
|
-
siteSettingsRepository.findById("global")
|
|
84
|
+
safeRead(() => siteSettingsRepository.findById("global"), {
|
|
85
|
+
route: "/categories/[slug]",
|
|
86
|
+
key: "category.siteSettings",
|
|
87
|
+
fallback: null,
|
|
88
|
+
}),
|
|
72
89
|
]);
|
|
73
90
|
const relatedCategories = rootSiblingCategories.filter((c) => c.id !== category?.id && (!c.categoryType || c.categoryType === "category"));
|
|
74
91
|
// Stores tab — stores whose storeCategory is this category or ANY descendant.
|
|
@@ -82,18 +99,18 @@ export async function CategoryDetailPageView({ slug }) {
|
|
|
82
99
|
// which caps at 30 values, hence the chunking.
|
|
83
100
|
const storeCategorySlugs = [
|
|
84
101
|
slug,
|
|
85
|
-
...(await categoriesRepository
|
|
86
|
-
|
|
87
|
-
.
|
|
102
|
+
...(await safeRead(() => categoriesRepository.getDescendantIds(category?.id ?? ""), {
|
|
103
|
+
route: "/categories/[slug]",
|
|
104
|
+
key: "category.descendantIds",
|
|
105
|
+
fallback: [],
|
|
106
|
+
})),
|
|
88
107
|
].filter(Boolean);
|
|
89
108
|
const SLUG_CHUNK = 30;
|
|
90
109
|
const slugChunks = [];
|
|
91
110
|
for (let i = 0; i < storeCategorySlugs.length; i += SLUG_CHUNK) {
|
|
92
111
|
slugChunks.push(storeCategorySlugs.slice(i, i + SLUG_CHUNK));
|
|
93
112
|
}
|
|
94
|
-
const storeResults = await Promise.all(slugChunks.map((chunk) => storeRepository
|
|
95
|
-
.listStores({ filters: sieveFilter("storeCategory", SIEVE_OP.EQ, chunk.join("|")), page: 1, pageSize: 50 }, true)
|
|
96
|
-
.catch(() => null)));
|
|
113
|
+
const storeResults = await Promise.all(slugChunks.map((chunk) => safeRead(() => storeRepository.listStores({ filters: sieveFilter("storeCategory", SIEVE_OP.EQ, chunk.join("|")), page: 1, pageSize: 50 }, true), { route: "/categories/[slug]", key: "category.stores", fallback: null })));
|
|
97
114
|
const seen = new Set();
|
|
98
115
|
const categoryStores = storeResults
|
|
99
116
|
.flatMap((r) => r?.items ?? [])
|