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