@mohasinac/appkit 4.32.6 → 4.33.1
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/server/features/products/actions.js +29 -3
- package/dist/_internal/server/features/products/list-public.d.ts +1 -1
- package/dist/_internal/server/features/products/list-public.js +10 -2
- package/dist/_internal/server/features/stores/denormalize.d.ts +44 -0
- package/dist/_internal/server/features/stores/denormalize.js +58 -0
- package/dist/_internal/server/features/stores/index.d.ts +1 -0
- package/dist/_internal/server/features/stores/index.js +1 -0
- package/dist/_internal/server/jobs/core/jobRunners.js +5 -0
- package/dist/_internal/server/jobs/core/storeNameBackfill.d.ts +27 -0
- package/dist/_internal/server/jobs/core/storeNameBackfill.js +90 -0
- package/dist/_internal/shared/features/products/to-product-item.js +7 -0
- package/dist/client.d.ts +1 -1
- package/dist/client.js +4 -1
- package/dist/features/about/components/PolicyPageView.js +1 -1
- package/dist/features/admin/actions/admin-actions.js +11 -1
- package/dist/features/admin/components/AdminSectionsView.js +17 -20
- package/dist/features/admin/components/sections/adminSectionsBuildParse.js +31 -14
- package/dist/features/admin/components/sections/adminSectionsTypes.d.ts +37 -7
- package/dist/features/admin/components/sections/adminSectionsTypes.js +11 -7
- package/dist/features/auctions/components/AuctionDetailPageView.js +56 -36
- package/dist/features/auctions/components/MarketplaceAuctionCard.d.ts +9 -0
- package/dist/features/auctions/components/MarketplaceAuctionCard.js +3 -2
- package/dist/features/blog/components/BlogFeaturedCard.d.ts +7 -1
- package/dist/features/blog/components/BlogFeaturedCard.js +3 -3
- package/dist/features/cart/repository/cart.repository.d.ts +27 -6
- package/dist/features/cart/repository/cart.repository.js +52 -21
- package/dist/features/cart/utils/guest-cart.js +5 -0
- package/dist/features/cart/utils/pending-ops.d.ts +15 -0
- package/dist/features/cart/utils/pending-ops.js +24 -0
- package/dist/features/homepage/components/BlogArticlesSection.d.ts +7 -1
- package/dist/features/homepage/components/BlogArticlesSection.js +5 -3
- package/dist/features/homepage/components/CTABannerSection.d.ts +18 -1
- package/dist/features/homepage/components/CTABannerSection.js +22 -4
- package/dist/features/homepage/components/CustomerReviewsSection.d.ts +7 -1
- package/dist/features/homepage/components/CustomerReviewsSection.js +2 -2
- package/dist/features/homepage/components/FAQSection.js +2 -11
- package/dist/features/homepage/components/FeaturedAuctionsSection.d.ts +3 -1
- package/dist/features/homepage/components/FeaturedAuctionsSection.js +5 -3
- package/dist/features/homepage/components/FeaturedBundlesSection.d.ts +7 -1
- package/dist/features/homepage/components/FeaturedBundlesSection.js +6 -5
- package/dist/features/homepage/components/FeaturedPreOrdersSection.d.ts +3 -1
- package/dist/features/homepage/components/FeaturedPreOrdersSection.js +4 -2
- package/dist/features/homepage/components/FeaturedProductsSection.d.ts +3 -1
- package/dist/features/homepage/components/FeaturedProductsSection.js +5 -3
- package/dist/features/homepage/components/HeroCarousel.d.ts +23 -2
- package/dist/features/homepage/components/HeroCarousel.js +22 -9
- package/dist/features/homepage/components/HomepageCustomerReviewsSection.d.ts +6 -1
- package/dist/features/homepage/components/HomepageCustomerReviewsSection.js +11 -3
- package/dist/features/homepage/components/MarketplaceHomepageView.d.ts +9 -1
- package/dist/features/homepage/components/MarketplaceHomepageView.js +7 -4
- package/dist/features/homepage/components/StatsCounterSection.d.ts +6 -1
- package/dist/features/homepage/components/StatsCounterSection.js +3 -3
- package/dist/features/homepage/constants/faq-category-labels.d.ts +15 -0
- package/dist/features/homepage/constants/faq-category-labels.js +23 -0
- package/dist/features/homepage/constants/section-copy.d.ts +107 -0
- package/dist/features/homepage/constants/section-copy.js +96 -0
- package/dist/features/homepage/lib/section-renderer.d.ts +16 -1
- package/dist/features/homepage/lib/section-renderer.js +58 -37
- package/dist/features/homepage/repository/homepage-sections.repository.d.ts +23 -0
- package/dist/features/homepage/repository/homepage-sections.repository.js +40 -0
- package/dist/features/homepage/schemas/firestore.d.ts +120 -44
- package/dist/features/media/MediaImage.d.ts +10 -1
- package/dist/features/media/MediaImage.js +6 -3
- package/dist/features/pre-orders/components/MarketplacePreorderCard.js +3 -2
- package/dist/features/products/components/CardStoreLine.d.ts +32 -0
- package/dist/features/products/components/CardStoreLine.js +14 -0
- package/dist/features/products/components/MarketplacePrizeDrawCard.js +2 -1
- package/dist/features/products/components/ProductGrid.js +3 -6
- package/dist/features/products/schemas/firestore.d.ts +17 -0
- package/dist/features/products/types/index.d.ts +2 -0
- package/dist/features/seller/actions/seller-actions.d.ts +17 -0
- package/dist/features/seller/actions/seller-actions.js +54 -0
- package/dist/features/stores/components/StoreAuctionsListing.js +3 -0
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +137 -0
- package/dist/seed/carousel-slides-seed-data.js +154 -22
- package/dist/seed/carousels-seed-data.js +4 -1
- package/dist/seed/homepage-sections-seed-data.js +337 -254
- package/dist/seed/products-preorders-seed-data.d.ts +1 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +4 -0
- package/dist/styles.css +1 -1
- package/dist/tailwind-utilities.css +1 -1
- package/package.json +1 -1
|
@@ -47,6 +47,7 @@ const SECTION_TYPE_PRESENCE = {
|
|
|
47
47
|
export const SECTION_TYPE_OPTIONS = Object.keys(SECTION_TYPE_PRESENCE);
|
|
48
48
|
export const DEFAULT_PRODUCTS_BUILDER = {
|
|
49
49
|
title: "Featured Products",
|
|
50
|
+
viewMoreLabel: "",
|
|
50
51
|
subtitle: "",
|
|
51
52
|
maxItems: 18,
|
|
52
53
|
status: "published",
|
|
@@ -64,6 +65,7 @@ export const DEFAULT_PRODUCTS_BUILDER = {
|
|
|
64
65
|
};
|
|
65
66
|
export const DEFAULT_AUCTIONS_BUILDER = {
|
|
66
67
|
title: "Live Auctions",
|
|
68
|
+
viewMoreLabel: "",
|
|
67
69
|
subtitle: "",
|
|
68
70
|
maxItems: 18,
|
|
69
71
|
status: "active",
|
|
@@ -96,6 +98,7 @@ export const DEFAULT_STATS_BUILDER = {
|
|
|
96
98
|
};
|
|
97
99
|
export const DEFAULT_PRE_ORDERS_BUILDER = {
|
|
98
100
|
title: "Reserve Before It Ships",
|
|
101
|
+
viewMoreLabel: "",
|
|
99
102
|
subtitle: "",
|
|
100
103
|
maxItems: 18,
|
|
101
104
|
status: "active",
|
|
@@ -111,6 +114,7 @@ export const DEFAULT_PRE_ORDERS_BUILDER = {
|
|
|
111
114
|
};
|
|
112
115
|
export const DEFAULT_STORES_BUILDER = {
|
|
113
116
|
title: "Featured Stores",
|
|
117
|
+
viewMoreLabel: "",
|
|
114
118
|
subtitle: "",
|
|
115
119
|
maxItems: 8,
|
|
116
120
|
status: "active",
|
|
@@ -127,6 +131,7 @@ export const DEFAULT_STORES_BUILDER = {
|
|
|
127
131
|
};
|
|
128
132
|
export const DEFAULT_EVENTS_BUILDER = {
|
|
129
133
|
title: "Events & Offers",
|
|
134
|
+
viewMoreLabel: "",
|
|
130
135
|
subtitle: "",
|
|
131
136
|
maxItems: 6,
|
|
132
137
|
status: "active",
|
|
@@ -171,6 +176,7 @@ export const DEFAULT_TRUST_INDICATORS_BUILDER = {
|
|
|
171
176
|
};
|
|
172
177
|
export const DEFAULT_CATEGORIES_BUILDER = {
|
|
173
178
|
title: "Shop by Category",
|
|
179
|
+
viewMoreLabel: "",
|
|
174
180
|
maxCategories: 8,
|
|
175
181
|
autoScroll: false,
|
|
176
182
|
scrollInterval: 5000,
|
|
@@ -178,6 +184,7 @@ export const DEFAULT_CATEGORIES_BUILDER = {
|
|
|
178
184
|
};
|
|
179
185
|
export const DEFAULT_BRANDS_BUILDER = {
|
|
180
186
|
title: "Top Brands",
|
|
187
|
+
viewMoreLabel: "",
|
|
181
188
|
subtitle: "",
|
|
182
189
|
maxBrands: 13,
|
|
183
190
|
autoScroll: true,
|
|
@@ -203,13 +210,12 @@ export const DEFAULT_FEATURES_BUILDER = {
|
|
|
203
210
|
};
|
|
204
211
|
export const DEFAULT_REVIEWS_BUILDER = {
|
|
205
212
|
title: "What Collectors Say",
|
|
213
|
+
viewMoreLabel: "",
|
|
206
214
|
maxReviews: 10,
|
|
207
215
|
itemsPerView: 3,
|
|
208
216
|
autoScroll: true,
|
|
209
217
|
scrollInterval: 5000,
|
|
210
218
|
loop: true,
|
|
211
|
-
source: "platform",
|
|
212
|
-
placeId: "",
|
|
213
219
|
};
|
|
214
220
|
export const DEFAULT_WHATSAPP_BUILDER = {
|
|
215
221
|
title: "Join Our Collector Community",
|
|
@@ -222,6 +228,7 @@ export const DEFAULT_WHATSAPP_BUILDER = {
|
|
|
222
228
|
};
|
|
223
229
|
export const DEFAULT_FAQ_BUILDER = {
|
|
224
230
|
title: "Frequently Asked Questions",
|
|
231
|
+
viewMoreLabel: "",
|
|
225
232
|
subtitle: "",
|
|
226
233
|
showOnHomepage: true,
|
|
227
234
|
displayCount: 5,
|
|
@@ -231,6 +238,7 @@ export const DEFAULT_FAQ_BUILDER = {
|
|
|
231
238
|
};
|
|
232
239
|
export const DEFAULT_BLOG_BUILDER = {
|
|
233
240
|
title: "From the Blog",
|
|
241
|
+
viewMoreLabel: "",
|
|
234
242
|
maxArticles: 3,
|
|
235
243
|
showReadTime: true,
|
|
236
244
|
showAuthor: true,
|
|
@@ -239,8 +247,6 @@ export const DEFAULT_BLOG_BUILDER = {
|
|
|
239
247
|
export const DEFAULT_NEWSLETTER_BUILDER = {
|
|
240
248
|
title: "Stay in the Loop",
|
|
241
249
|
description: "Get the latest drops, auction alerts, and collector news.",
|
|
242
|
-
placeholder: "Enter your email",
|
|
243
|
-
buttonText: "Subscribe",
|
|
244
250
|
privacyText: "We respect your privacy. Unsubscribe anytime.",
|
|
245
251
|
privacyLink: String(ROUTES.PUBLIC.PRIVACY),
|
|
246
252
|
};
|
|
@@ -273,11 +279,9 @@ export const DEFAULT_GOOGLE_REVIEWS_BUILDER = {
|
|
|
273
279
|
};
|
|
274
280
|
export const DEFAULT_FEATURED_BUNDLES_BUILDER = {
|
|
275
281
|
title: "Curated Bundles",
|
|
282
|
+
viewMoreLabel: "",
|
|
276
283
|
subtitle: "Everything you need in one deal",
|
|
277
284
|
maxItems: 8,
|
|
278
|
-
storeId: "",
|
|
279
|
-
categorySlug: "",
|
|
280
|
-
sortBy: "savings-desc",
|
|
281
285
|
showSavingsBadge: true,
|
|
282
286
|
};
|
|
283
287
|
export const DEFAULT_PRIZE_DRAWS_BUILDER = {
|
|
@@ -62,6 +62,61 @@ function renderAuctionStoreReviews(storeSlug) {
|
|
|
62
62
|
return null;
|
|
63
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." })] }));
|
|
64
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Related auction docs → card data, at module scope rather than inline in the
|
|
67
|
+
* view.
|
|
68
|
+
*
|
|
69
|
+
* This is a pure doc→card mapper with no component state, and it is the exact
|
|
70
|
+
* spot Root Cause #48 keeps recurring: a card carries nine correct fields and
|
|
71
|
+
* routes or renders wrong because of the tenth. Naming it makes the field list
|
|
72
|
+
* reviewable on its own instead of buried 400 lines into a render prop.
|
|
73
|
+
*/
|
|
74
|
+
function toRelatedAuctionCards(relatedDocs,
|
|
75
|
+
// The current auction's own id, so it is excluded from its own related list.
|
|
76
|
+
// Typed as the document id it is compared against, not `unknown` — a bare
|
|
77
|
+
// `unknown` here silently permits comparing against anything.
|
|
78
|
+
currentId, now) {
|
|
79
|
+
return relatedDocs
|
|
80
|
+
.filter((r) => {
|
|
81
|
+
if (r.id === currentId || r.listingType !== "auction")
|
|
82
|
+
return false;
|
|
83
|
+
const s = r.status;
|
|
84
|
+
if (s && ["archived", "in_review", "draft"].includes(s))
|
|
85
|
+
return false;
|
|
86
|
+
if (r.isSold === true)
|
|
87
|
+
return false;
|
|
88
|
+
const end = r.auctionEndDate;
|
|
89
|
+
if (!end)
|
|
90
|
+
return true;
|
|
91
|
+
const endDate = typeof end.toDate === "function"
|
|
92
|
+
? end.toDate()
|
|
93
|
+
: end instanceof Date ? end : new Date(String(end));
|
|
94
|
+
return endDate > now;
|
|
95
|
+
})
|
|
96
|
+
.slice(0, 4)
|
|
97
|
+
.map((r) => ({
|
|
98
|
+
id: String(r.id ?? ""),
|
|
99
|
+
title: String(r.title ?? r.name ?? "Auction Item"),
|
|
100
|
+
price: typeof r.price === "number" ? r.price : 0,
|
|
101
|
+
currency: typeof r.currency === "string" ? r.currency : undefined,
|
|
102
|
+
mainImage: Array.isArray(r.images)
|
|
103
|
+
? r.images[0]
|
|
104
|
+
: typeof r.mainImage === "string"
|
|
105
|
+
? r.mainImage
|
|
106
|
+
: undefined,
|
|
107
|
+
listingType: "auction",
|
|
108
|
+
auctionEndDate: r.auctionEndDate,
|
|
109
|
+
startingBid: typeof r.startingBid === "number" ? r.startingBid : undefined,
|
|
110
|
+
currentBid: typeof r.currentBid === "number" ? r.currentBid : undefined,
|
|
111
|
+
bidCount: typeof r.bidCount === "number" ? r.bidCount : undefined,
|
|
112
|
+
slug: typeof r.slug === "string" ? r.slug : undefined,
|
|
113
|
+
// The card renders "by <seller>"; dropping these is how a related-auction
|
|
114
|
+
// card ends up the only one on the page with no seller on it, from data
|
|
115
|
+
// that was on the doc all along.
|
|
116
|
+
storeId: typeof r.storeId === "string" ? r.storeId : undefined,
|
|
117
|
+
storeName: typeof r.storeName === "string" ? r.storeName : undefined,
|
|
118
|
+
}));
|
|
119
|
+
}
|
|
65
120
|
export async function AuctionDetailPageView({ id, initialAuction, onPlaceBid, onBuyNow, productFeatures }) {
|
|
66
121
|
// The auction IS this page's subject. A read failure must surface rather than
|
|
67
122
|
// become the same `undefined` a genuinely missing slug produces — the
|
|
@@ -173,42 +228,7 @@ export async function AuctionDetailPageView({ id, initialAuction, onPlaceBid, on
|
|
|
173
228
|
return null;
|
|
174
229
|
return (_jsx(CollapsibleBidHistory, { productId: String(product.id), initialData: bidsResult, currency: currency }));
|
|
175
230
|
}, renderRelated: () => {
|
|
176
|
-
const
|
|
177
|
-
const related = relatedDocs
|
|
178
|
-
.filter((r) => {
|
|
179
|
-
if (r.id === product.id || r.listingType !== "auction")
|
|
180
|
-
return false;
|
|
181
|
-
const s = r.status;
|
|
182
|
-
if (s && ["archived", "in_review", "draft"].includes(s))
|
|
183
|
-
return false;
|
|
184
|
-
if (r.isSold === true)
|
|
185
|
-
return false;
|
|
186
|
-
const end = r.auctionEndDate;
|
|
187
|
-
if (!end)
|
|
188
|
-
return true;
|
|
189
|
-
const endDate = typeof end.toDate === "function"
|
|
190
|
-
? end.toDate()
|
|
191
|
-
: end instanceof Date ? end : new Date(String(end));
|
|
192
|
-
return endDate > now;
|
|
193
|
-
})
|
|
194
|
-
.slice(0, 4)
|
|
195
|
-
.map((r) => ({
|
|
196
|
-
id: String(r.id ?? ""),
|
|
197
|
-
title: String(r.title ?? r.name ?? "Auction Item"),
|
|
198
|
-
price: typeof r.price === "number" ? r.price : 0,
|
|
199
|
-
currency: typeof r.currency === "string" ? r.currency : undefined,
|
|
200
|
-
mainImage: Array.isArray(r.images)
|
|
201
|
-
? r.images[0]
|
|
202
|
-
: typeof r.mainImage === "string"
|
|
203
|
-
? r.mainImage
|
|
204
|
-
: undefined,
|
|
205
|
-
listingType: "auction",
|
|
206
|
-
auctionEndDate: r.auctionEndDate,
|
|
207
|
-
startingBid: typeof r.startingBid === "number" ? r.startingBid : undefined,
|
|
208
|
-
currentBid: typeof r.currentBid === "number" ? r.currentBid : undefined,
|
|
209
|
-
bidCount: typeof r.bidCount === "number" ? r.bidCount : undefined,
|
|
210
|
-
slug: typeof r.slug === "string" ? r.slug : undefined,
|
|
211
|
-
}));
|
|
231
|
+
const related = toRelatedAuctionCards(relatedDocs, product.id, new Date());
|
|
212
232
|
return (_jsxs(Stack, { gap: "xl", children: [related.length > 0 && (_jsx(RelatedProducts, { labels: { title: "Similar Auctions" }, renderGrid: () => (_jsx(MarketplaceAuctionGrid, { auctions: related })) })), _jsx(GroupedListingsCarousel, { groups: groups }), _jsx(RelatedItemsSection, { relatedItems: relatedItems, relatedByBrand: relatedByBrand, relatedByTags: relatedByTags, relatedByStore: relatedByStore, categoryLabel: categoryName || category || undefined, brandLabel: brand || undefined, storeLabel: storeName || undefined })] }));
|
|
213
233
|
} }), renderAuctionStoreReviews(storeId), onPlaceBid && (_jsx(AuctionBottomActions, { productId: String(product.id), currentBid: currentBid, startingBid: startingBid, tiers: bidIncrementTiers, minBidIncrementOverride: minBidIncrementOverride, currency: currency, isEnded: isEnded, auctionEndDate: endDate, buyNowPrice: buyNowPrice, bidCount: bidCount, tags: tags, onPlaceBid: onPlaceBid, onBuyNow: onBuyNow }))] })] }));
|
|
214
234
|
}
|
|
@@ -29,6 +29,15 @@ export interface MarketplaceAuctionCardData {
|
|
|
29
29
|
* (Root Cause #48).
|
|
30
30
|
*/
|
|
31
31
|
isSold?: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* The seller. Absent from this hand-rolled type entirely until now, so the
|
|
34
|
+
* auction card could not show a seller even where every other card did —
|
|
35
|
+
* the same dropped-field shape the `isSold` note directly above warns about,
|
|
36
|
+
* one field over. Every doc→card mapper must copy BOTH: `storeId` says the
|
|
37
|
+
* listing has a seller at all, `storeName` says who.
|
|
38
|
+
*/
|
|
39
|
+
storeId?: string;
|
|
40
|
+
storeName?: string;
|
|
32
41
|
/** Display name of the auction winner — shown masked when the auction has ended */
|
|
33
42
|
winnerDisplayName?: string;
|
|
34
43
|
}
|
|
@@ -14,6 +14,7 @@ import { formatCurrency } from "../../../utils";
|
|
|
14
14
|
import { getDefaultCurrency } from "../../../core/baseline-resolver";
|
|
15
15
|
import { BaseListingCard, Button, Caption, Div, Row, Span, Stack, Text, TextLink } from "../../../ui";
|
|
16
16
|
import { isBuyNowAvailable } from "../../../_internal/shared/features/auctions/config";
|
|
17
|
+
import { CardStoreLine } from "../../products/components/CardStoreLine";
|
|
17
18
|
const CLS_STAR_ICON = "h-5 w-5 fill-warning text-warning";
|
|
18
19
|
const CLS_LIVE_BADGE = "rounded-full bg-error-solid px-[var(--appkit-space-2)] py-[var(--appkit-space-0-5)] text-error-on-solid";
|
|
19
20
|
const CLS_ENDING_BADGE = "rounded-full bg-warning-solid px-[var(--appkit-space-2)] py-[var(--appkit-space-0-5)] text-warning-on-solid";
|
|
@@ -24,11 +25,11 @@ function renderAuctionCardHero(props) {
|
|
|
24
25
|
}
|
|
25
26
|
function renderAuctionCardInfoList(props) {
|
|
26
27
|
const { product, auctionHref, displayBid, bidCount, isEnded, remaining, inWishlist, wishlistLoading, wishlistActions, countdownClass, mergedLabels, handleWishlist, handleNavigate } = props;
|
|
27
|
-
return (_jsxs(_Fragment, { children: [_jsxs(Row, { className: "min-w-0", align: "start", justify: "between", gap: "sm", children: [_jsx(TextLink, { href: auctionHref ?? "#", className: `flex-1 leading-snug transition-colors hover:text-primary`, color: "primary", truncate: 2, size: "sm", weight: "medium", children: product.title }), wishlistActions ? (_jsx(WishlistHeartButton, { size: "inline", inWishlist: inWishlist, isLoading: wishlistLoading, addLabel: mergedLabels.addToWishlist, removeLabel: mergedLabels.removeFromWishlist, onToggle: handleWishlist })) : null] }), _jsxs(Row, { align: "center", gap: "sm", wrap: true, children: [_jsx(Text, { className: "text-primary", size: "sm", weight: "bold", children: formatCurrency(displayBid, getDefaultCurrency()) }), _jsxs(Div, { layout: "inline-flex", gap: "1", textWeight: "semibold", className: `items-[center] py-[0.125rem] text-[11px] ${countdownClass}`, padding: "x-xs", rounded: "full", children: [_jsx(Clock, { className: "h-2.5 w-2.5" }), _jsx(Span, { children: formatCountdownLabel(remaining, mergedLabels.ended) })] }), bidCount > 0 && _jsx(Caption, { className: "text-[11px]", children: mergedLabels.totalBids(bidCount) })] }), !isEnded && (_jsxs(Button, { gap: "xs", type: "button", variant: "warning", size: "sm", textSize: "xs", className: "self-start px-[0.625rem] mt-0.5", onClick: handleNavigate, children: [_jsx(Gavel, { className: "h-3 w-3" }), _jsx(Span, { children: mergedLabels.placeBid })] }))] }));
|
|
28
|
+
return (_jsxs(_Fragment, { children: [_jsxs(Row, { className: "min-w-0", align: "start", justify: "between", gap: "sm", children: [_jsx(TextLink, { href: auctionHref ?? "#", className: `flex-1 leading-snug transition-colors hover:text-primary`, color: "primary", truncate: 2, size: "sm", weight: "medium", children: product.title }), wishlistActions ? (_jsx(WishlistHeartButton, { size: "inline", inWishlist: inWishlist, isLoading: wishlistLoading, addLabel: mergedLabels.addToWishlist, removeLabel: mergedLabels.removeFromWishlist, onToggle: handleWishlist })) : null] }), _jsx(CardStoreLine, { storeName: product.storeName, storeId: product.storeId }), _jsxs(Row, { align: "center", gap: "sm", wrap: true, children: [_jsx(Text, { className: "text-primary", size: "sm", weight: "bold", children: formatCurrency(displayBid, getDefaultCurrency()) }), _jsxs(Div, { layout: "inline-flex", gap: "1", textWeight: "semibold", className: `items-[center] py-[0.125rem] text-[11px] ${countdownClass}`, padding: "x-xs", rounded: "full", children: [_jsx(Clock, { className: "h-2.5 w-2.5" }), _jsx(Span, { children: formatCountdownLabel(remaining, mergedLabels.ended) })] }), bidCount > 0 && _jsx(Caption, { className: "text-[11px]", children: mergedLabels.totalBids(bidCount) })] }), !isEnded && (_jsxs(Button, { gap: "xs", type: "button", variant: "warning", size: "sm", textSize: "xs", className: "self-start px-[0.625rem] mt-0.5", onClick: handleNavigate, children: [_jsx(Gavel, { className: "h-3 w-3" }), _jsx(Span, { children: mergedLabels.placeBid })] }))] }));
|
|
28
29
|
}
|
|
29
30
|
function renderAuctionCardInfoGrid(props) {
|
|
30
31
|
const { product, auctionHref, displayBid, bidCount, hasCurrentBid, isEnded, remaining, resolvedBuyoutPrice, inWishlist, wishlistLoading, wishlistActions, countdownClass, mergedLabels, handleWishlist, handleNavigate } = props;
|
|
31
|
-
return (_jsxs(_Fragment, { children: [_jsxs(Row, { align: "start", gap: "sm", children: [_jsx(TextLink, { href: auctionHref ?? "#", className: "min-w-0 flex-1 leading-snug transition-colors hover:text-primary", color: "primary", size: "sm", weight: "medium", children: product.title }), wishlistActions ? (_jsx(WishlistHeartButton, { size: "inline", inWishlist: inWishlist, isLoading: wishlistLoading, addLabel: mergedLabels.addToWishlist, removeLabel: mergedLabels.removeFromWishlist, onToggle: handleWishlist })) : null] }), _jsxs(Div, { children: [_jsx(Caption, { children: isEnded && hasCurrentBid ? mergedLabels.winningBid : hasCurrentBid ? mergedLabels.currentBid : mergedLabels.startingBid }), _jsx(Text, { className: "leading-none text-primary", size: "base", weight: "bold", children: formatCurrency(displayBid, getDefaultCurrency()) }), isEnded && product.winnerDisplayName && (_jsxs(Caption, { className: "mt-0.5", color: "muted", children: [mergedLabels.wonBy, ": ", maskDisplayName(product.winnerDisplayName)] }))] }), _jsxs(Row, { wrap: true, justify: "between", gap: "sm", className: "gap-x-2 gap-y-1", children: [_jsxs(Div, { layout: "inline-flex", gap: "1", textWeight: "semibold", textSize: "xs", className: `items-[center] ${countdownClass}`, paddingY: "y-2xs", paddingX: "x-xs", rounded: "full", children: [_jsx(Clock, { className: "h-3 w-3" }), _jsx(Span, { children: formatCountdownLabel(remaining, mergedLabels.ended) })] }), _jsx(Caption, { children: mergedLabels.totalBids(bidCount) })] }), _jsx(Row, { wrap: true, gap: "xs", className: "mt-auto", children: isEnded ? (_jsx(Button, { gap: "xs", type: "button", variant: "ghost", size: "sm", textSize: "xs", paddingX: "xs", className: "flex-1 cursor-not-allowed opacity-60", disabled: true, children: _jsx(Span, { children: mergedLabels.ended }) })) : (_jsxs(_Fragment, { children: [_jsxs(Button, { gap: "xs", type: "button", variant: "warning", size: "sm", textSize: "xs", paddingX: "xs", className: "flex-1", onClick: handleNavigate, children: [_jsx(Gavel, { className: "h-3 w-3" }), _jsx(Span, { children: mergedLabels.placeBid })] }), resolvedBuyoutPrice ? (
|
|
32
|
+
return (_jsxs(_Fragment, { children: [_jsxs(Row, { align: "start", gap: "sm", children: [_jsx(TextLink, { href: auctionHref ?? "#", className: "min-w-0 flex-1 leading-snug transition-colors hover:text-primary", color: "primary", size: "sm", weight: "medium", children: product.title }), wishlistActions ? (_jsx(WishlistHeartButton, { size: "inline", inWishlist: inWishlist, isLoading: wishlistLoading, addLabel: mergedLabels.addToWishlist, removeLabel: mergedLabels.removeFromWishlist, onToggle: handleWishlist })) : null] }), _jsx(CardStoreLine, { storeName: product.storeName, storeId: product.storeId }), _jsxs(Div, { children: [_jsx(Caption, { children: isEnded && hasCurrentBid ? mergedLabels.winningBid : hasCurrentBid ? mergedLabels.currentBid : mergedLabels.startingBid }), _jsx(Text, { className: "leading-none text-primary", size: "base", weight: "bold", children: formatCurrency(displayBid, getDefaultCurrency()) }), isEnded && product.winnerDisplayName && (_jsxs(Caption, { className: "mt-0.5", color: "muted", children: [mergedLabels.wonBy, ": ", maskDisplayName(product.winnerDisplayName)] }))] }), _jsxs(Row, { wrap: true, justify: "between", gap: "sm", className: "gap-x-2 gap-y-1", children: [_jsxs(Div, { layout: "inline-flex", gap: "1", textWeight: "semibold", textSize: "xs", className: `items-[center] ${countdownClass}`, paddingY: "y-2xs", paddingX: "x-xs", rounded: "full", children: [_jsx(Clock, { className: "h-3 w-3" }), _jsx(Span, { children: formatCountdownLabel(remaining, mergedLabels.ended) })] }), _jsx(Caption, { children: mergedLabels.totalBids(bidCount) })] }), _jsx(Row, { wrap: true, gap: "xs", className: "mt-auto", children: isEnded ? (_jsx(Button, { gap: "xs", type: "button", variant: "ghost", size: "sm", textSize: "xs", paddingX: "xs", className: "flex-1 cursor-not-allowed opacity-60", disabled: true, children: _jsx(Span, { children: mergedLabels.ended }) })) : (_jsxs(_Fragment, { children: [_jsxs(Button, { gap: "xs", type: "button", variant: "warning", size: "sm", textSize: "xs", paddingX: "xs", className: "flex-1", onClick: handleNavigate, children: [_jsx(Gavel, { className: "h-3 w-3" }), _jsx(Span, { children: mergedLabels.placeBid })] }), resolvedBuyoutPrice ? (
|
|
32
33
|
/* `secondary`, not `danger` — this button only navigates to the
|
|
33
34
|
PDP, and `danger` is reserved for destructive actions (Rule #7
|
|
34
35
|
kinds). ACTIONS.AUCTION["buy-it-now"] is itself `secondary`. */
|
|
@@ -11,5 +11,11 @@ export interface BlogFeaturedCardProps {
|
|
|
11
11
|
selectable?: boolean;
|
|
12
12
|
isSelected?: boolean;
|
|
13
13
|
onSelect?: (id: string, selected: boolean) => void;
|
|
14
|
+
/** Show the "N min read" chip. Defaults to `true`. */
|
|
15
|
+
showReadTime?: boolean;
|
|
16
|
+
/** Show the author name. Defaults to `true`. */
|
|
17
|
+
showAuthor?: boolean;
|
|
18
|
+
/** Show the cover image. Defaults to `true`. */
|
|
19
|
+
showThumbnail?: boolean;
|
|
14
20
|
}
|
|
15
|
-
export declare function BlogFeaturedCard({ post, href, labels, className, selectable, isSelected, onSelect, }: BlogFeaturedCardProps): React.JSX.Element;
|
|
21
|
+
export declare function BlogFeaturedCard({ post, href, labels, className, selectable, isSelected, onSelect, showReadTime, showAuthor, showThumbnail, }: BlogFeaturedCardProps): React.JSX.Element;
|
|
@@ -20,10 +20,10 @@ const CATEGORY_BADGE = {
|
|
|
20
20
|
community: "bg-warning-surface text-warning dark:bg-warning-surface dark:text-warning",
|
|
21
21
|
};
|
|
22
22
|
const CLS_FEATURED_BADGE = "bg-warning-surface text-warning dark:bg-warning-surface dark:text-warning px-[var(--appkit-space-2)] py-[var(--appkit-space-0-5)] rounded-full";
|
|
23
|
-
export function BlogFeaturedCard({ post, href, labels = {}, className = "", selectable = false, isSelected = false, onSelect, }) {
|
|
23
|
+
export function BlogFeaturedCard({ post, href, labels = {}, className = "", selectable = false, isSelected = false, onSelect, showReadTime = true, showAuthor = true, showThumbnail = true, }) {
|
|
24
24
|
const longPress = useLongPress(() => onSelect?.(post.id, !isSelected));
|
|
25
25
|
const safeTitle = post.title?.trim() || "Untitled post";
|
|
26
|
-
const coverImageUrl = getMediaUrl(post.coverImage);
|
|
26
|
+
const coverImageUrl = showThumbnail ? getMediaUrl(post.coverImage) : undefined;
|
|
27
27
|
const date = post.publishedAt
|
|
28
28
|
? new Date(post.publishedAt).toLocaleDateString(getDefaultLocale(), {
|
|
29
29
|
year: "numeric",
|
|
@@ -31,5 +31,5 @@ export function BlogFeaturedCard({ post, href, labels = {}, className = "", sele
|
|
|
31
31
|
day: "numeric",
|
|
32
32
|
})
|
|
33
33
|
: "";
|
|
34
|
-
return (_jsxs(Article, { rounded: "xl", shadow: "hover-md", className: `group relative h-full overflow-hidden border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] ${isSelected ? "ring-2 ring-primary outline outline-2 outline-primary" : ""} ${className}`, onMouseDown: onSelect && !isSelected ? longPress.onMouseDown : undefined, onMouseUp: onSelect && !isSelected ? longPress.onMouseUp : undefined, onMouseLeave: onSelect && !isSelected ? longPress.onMouseLeave : undefined, onTouchStart: onSelect && !isSelected ? longPress.onTouchStart : undefined, onTouchEnd: onSelect && !isSelected ? longPress.onTouchEnd : undefined, onTouchCancel: onSelect && !isSelected ? longPress.onTouchCancel : undefined, children: [onSelect && (_jsx(BaseListingCard.Checkbox, { selected: isSelected, onSelect: (e) => { e.preventDefault(); onSelect(post.id, !isSelected); }, label: isSelected ? "Deselect post" : "Select post", position: "top-2 left-2", className: selectable || isSelected ? "opacity-100" : "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity" })), _jsxs(TextLink, { href: href, layout: "flex-col", className: "h-full", children: [_jsx(Div, { className: `relative aspect-video ${__O.hidden} flex-shrink-0`, children: coverImageUrl ? (_jsx(MediaImage, { src: coverImageUrl, alt: safeTitle, size: "card", className: "transition-transform duration-300 group-hover:scale-105" })) : (_jsx(Div, { role: "img", "aria-label": safeTitle, surface: "muted", className: "h-full w-full" })) }), _jsxs(Stack, { className: `flex-1 ${__P.p4}`, children: [_jsxs(Row, { gap: "xs", className: "mb-2", wrap: true, children: [_jsx(Span, { size: "xs", weight: "medium", className: `inline-block capitalize ${CATEGORY_BADGE[post.category] ?? "bg-[var(--appkit-color-bg)] text-[var(--appkit-color-text-muted)]"}`, rounded: "full", padding: "pill-xs", children: post.category }), post.isFeatured && (_jsx(Span, { size: "xs", weight: "medium", className: CLS_FEATURED_BADGE, children: labels.featuredBadge ?? "Featured" }))] }), _jsx(Heading, { level: 3, className: `leading-snug mb-1`, color: "primary", truncate: 2, size: "base", weight: "semibold", children: safeTitle }), post.excerpt && (_jsx(Text, { className: `mb-3`, color: "muted", truncate: 2, size: "sm", children: post.excerpt })), _jsxs(Row, { color: "muted", textSize: "xs", className: "mt-auto", gap: "3", wrap: true, children: [post.authorName && (_jsx(Span, { children: safeDisplayName(post.authorName, "Author") })), post.readTimeMinutes != null && (_jsxs(Span, { children: [post.readTimeMinutes, " ", labels.readTime ?? "min read"] })), date && _jsx(Span, { children: date })] })] })] })] }));
|
|
34
|
+
return (_jsxs(Article, { rounded: "xl", shadow: "hover-md", className: `group relative h-full overflow-hidden border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] ${isSelected ? "ring-2 ring-primary outline outline-2 outline-primary" : ""} ${className}`, onMouseDown: onSelect && !isSelected ? longPress.onMouseDown : undefined, onMouseUp: onSelect && !isSelected ? longPress.onMouseUp : undefined, onMouseLeave: onSelect && !isSelected ? longPress.onMouseLeave : undefined, onTouchStart: onSelect && !isSelected ? longPress.onTouchStart : undefined, onTouchEnd: onSelect && !isSelected ? longPress.onTouchEnd : undefined, onTouchCancel: onSelect && !isSelected ? longPress.onTouchCancel : undefined, children: [onSelect && (_jsx(BaseListingCard.Checkbox, { selected: isSelected, onSelect: (e) => { e.preventDefault(); onSelect(post.id, !isSelected); }, label: isSelected ? "Deselect post" : "Select post", position: "top-2 left-2", className: selectable || isSelected ? "opacity-100" : "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity" })), _jsxs(TextLink, { href: href, layout: "flex-col", className: "h-full", children: [showThumbnail && (_jsx(Div, { className: `relative aspect-video ${__O.hidden} flex-shrink-0`, children: coverImageUrl ? (_jsx(MediaImage, { src: coverImageUrl, alt: safeTitle, size: "card", className: "transition-transform duration-300 group-hover:scale-105" })) : (_jsx(Div, { role: "img", "aria-label": safeTitle, surface: "muted", className: "h-full w-full" })) })), _jsxs(Stack, { className: `flex-1 ${__P.p4}`, children: [_jsxs(Row, { gap: "xs", className: "mb-2", wrap: true, children: [_jsx(Span, { size: "xs", weight: "medium", className: `inline-block capitalize ${CATEGORY_BADGE[post.category] ?? "bg-[var(--appkit-color-bg)] text-[var(--appkit-color-text-muted)]"}`, rounded: "full", padding: "pill-xs", children: post.category }), post.isFeatured && (_jsx(Span, { size: "xs", weight: "medium", className: CLS_FEATURED_BADGE, children: labels.featuredBadge ?? "Featured" }))] }), _jsx(Heading, { level: 3, className: `leading-snug mb-1`, color: "primary", truncate: 2, size: "base", weight: "semibold", children: safeTitle }), post.excerpt && (_jsx(Text, { className: `mb-3`, color: "muted", truncate: 2, size: "sm", children: post.excerpt })), _jsxs(Row, { color: "muted", textSize: "xs", className: "mt-auto", gap: "3", wrap: true, children: [showAuthor && post.authorName && (_jsx(Span, { children: safeDisplayName(post.authorName, "Author") })), showReadTime && post.readTimeMinutes != null && (_jsxs(Span, { children: [post.readTimeMinutes, " ", labels.readTime ?? "min read"] })), date && _jsx(Span, { children: date })] })] })] })] }));
|
|
35
35
|
}
|
|
@@ -58,7 +58,34 @@ export declare class CartRepository extends BaseRepository<CartDocument> {
|
|
|
58
58
|
*/
|
|
59
59
|
updateGroupMembers(userId: string, itemId: string, input: UpdateCartGroupMembersInput): Promise<CartDocument>;
|
|
60
60
|
updateItem(userId: string, itemId: string, input: UpdateCartItemInput): Promise<CartDocument>;
|
|
61
|
+
/**
|
|
62
|
+
* Re-derive the three item-dependent fields against the items that survive a
|
|
63
|
+
* removal.
|
|
64
|
+
*
|
|
65
|
+
* `removeItem` and `clearCart` both used to write `{ ...cart, items }`, which
|
|
66
|
+
* carries `selectedItemIds`, `appliedCoupons` and `storeAddons` through
|
|
67
|
+
* verbatim — so after a clear the cart still named items that no longer
|
|
68
|
+
* existed. That is not cosmetic: `previewCheckoutPricing`, `createOrderForGroup`
|
|
69
|
+
* and `/api/payment/create-order` all build `new Set(cart.selectedItemIds)`
|
|
70
|
+
* and filter by it, so a stale id set silently narrows a later checkout.
|
|
71
|
+
*
|
|
72
|
+
* `selectedItemIds` is intersected rather than cleared — `undefined` means
|
|
73
|
+
* "all items", so blanking a partial selection would silently re-select
|
|
74
|
+
* everything the buyer had deliberately unticked. An intersection that empties
|
|
75
|
+
* becomes `undefined` for the same reason it started that way: no items left
|
|
76
|
+
* to have a selection about.
|
|
77
|
+
*/
|
|
78
|
+
private pruneForItems;
|
|
79
|
+
private pruneAddonsMap;
|
|
61
80
|
removeItem(userId: string, itemId: string): Promise<CartDocument>;
|
|
81
|
+
/**
|
|
82
|
+
* Empty the cart, keeping any locked line.
|
|
83
|
+
*
|
|
84
|
+
* A locked line is a won auction or an accepted offer — settlement put it
|
|
85
|
+
* there and the buyer owes payment on it, so "clear" cannot mean "walk away"
|
|
86
|
+
* (see § Checkout Lanes). Callers must tell the buyer that is what happened;
|
|
87
|
+
* a silently-surviving row reads as a failed clear.
|
|
88
|
+
*/
|
|
62
89
|
clearCart(userId: string): Promise<CartDocument>;
|
|
63
90
|
getItemCount(cart: CartDocument): number;
|
|
64
91
|
getSubtotal(cart: CartDocument): number;
|
|
@@ -102,12 +129,6 @@ export declare class CartRepository extends BaseRepository<CartDocument> {
|
|
|
102
129
|
* and keeping both shapes around invites readers that handle only one.
|
|
103
130
|
*/
|
|
104
131
|
setStoreAddons(userId: string, storeId: string, addons: CartStoreAddons | null): Promise<void>;
|
|
105
|
-
/**
|
|
106
|
-
* Drop a store's add-on entry once its last item leaves the cart, so the map
|
|
107
|
-
* doesn't accumulate orphans. Cosmetic — an orphan is already unreachable,
|
|
108
|
-
* because a store with no items forms no order group.
|
|
109
|
-
*/
|
|
110
|
-
pruneStoreAddons(userId: string, keepStoreIds: string[]): Promise<void>;
|
|
111
132
|
/**
|
|
112
133
|
* Cloud Functions: return refs of stale carts not updated within TTL.
|
|
113
134
|
*/
|
|
@@ -323,6 +323,41 @@ export class CartRepository extends BaseRepository {
|
|
|
323
323
|
throw new DatabaseError("Failed to update cart item", error);
|
|
324
324
|
}
|
|
325
325
|
}
|
|
326
|
+
/**
|
|
327
|
+
* Re-derive the three item-dependent fields against the items that survive a
|
|
328
|
+
* removal.
|
|
329
|
+
*
|
|
330
|
+
* `removeItem` and `clearCart` both used to write `{ ...cart, items }`, which
|
|
331
|
+
* carries `selectedItemIds`, `appliedCoupons` and `storeAddons` through
|
|
332
|
+
* verbatim — so after a clear the cart still named items that no longer
|
|
333
|
+
* existed. That is not cosmetic: `previewCheckoutPricing`, `createOrderForGroup`
|
|
334
|
+
* and `/api/payment/create-order` all build `new Set(cart.selectedItemIds)`
|
|
335
|
+
* and filter by it, so a stale id set silently narrows a later checkout.
|
|
336
|
+
*
|
|
337
|
+
* `selectedItemIds` is intersected rather than cleared — `undefined` means
|
|
338
|
+
* "all items", so blanking a partial selection would silently re-select
|
|
339
|
+
* everything the buyer had deliberately unticked. An intersection that empties
|
|
340
|
+
* becomes `undefined` for the same reason it started that way: no items left
|
|
341
|
+
* to have a selection about.
|
|
342
|
+
*/
|
|
343
|
+
pruneForItems(cart, items) {
|
|
344
|
+
const survivingItemIds = new Set(items.map((item) => item.itemId));
|
|
345
|
+
const survivingStoreIds = new Set(items.map((item) => item.storeId));
|
|
346
|
+
const selected = cart.selectedItemIds?.filter((id) => survivingItemIds.has(id));
|
|
347
|
+
return {
|
|
348
|
+
selectedItemIds: selected && selected.length > 0 ? selected : undefined,
|
|
349
|
+
// A coupon is scoped to a store bucket or to the cart as a whole; with no
|
|
350
|
+
// items left there is nothing for either kind to discount.
|
|
351
|
+
appliedCoupons: items.length === 0 ? undefined : cart.appliedCoupons,
|
|
352
|
+
storeAddons: this.pruneAddonsMap(cart.storeAddons, survivingStoreIds),
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
pruneAddonsMap(addons, keepStoreIds) {
|
|
356
|
+
if (!addons)
|
|
357
|
+
return undefined;
|
|
358
|
+
const kept = Object.entries(addons).filter(([storeId]) => keepStoreIds.has(storeId));
|
|
359
|
+
return kept.length > 0 ? Object.fromEntries(kept) : undefined;
|
|
360
|
+
}
|
|
326
361
|
async removeItem(userId, itemId) {
|
|
327
362
|
try {
|
|
328
363
|
const cart = await this.findByUserId(userId);
|
|
@@ -337,6 +372,7 @@ export class CartRepository extends BaseRepository {
|
|
|
337
372
|
const updatedCart = {
|
|
338
373
|
...cart,
|
|
339
374
|
items,
|
|
375
|
+
...this.pruneForItems(cart, items),
|
|
340
376
|
updatedAt: new Date(),
|
|
341
377
|
};
|
|
342
378
|
await this.db
|
|
@@ -352,6 +388,14 @@ export class CartRepository extends BaseRepository {
|
|
|
352
388
|
throw new DatabaseError("Failed to remove cart item", error);
|
|
353
389
|
}
|
|
354
390
|
}
|
|
391
|
+
/**
|
|
392
|
+
* Empty the cart, keeping any locked line.
|
|
393
|
+
*
|
|
394
|
+
* A locked line is a won auction or an accepted offer — settlement put it
|
|
395
|
+
* there and the buyer owes payment on it, so "clear" cannot mean "walk away"
|
|
396
|
+
* (see § Checkout Lanes). Callers must tell the buyer that is what happened;
|
|
397
|
+
* a silently-surviving row reads as a failed clear.
|
|
398
|
+
*/
|
|
355
399
|
async clearCart(userId) {
|
|
356
400
|
try {
|
|
357
401
|
const cart = await this.getOrCreate(userId);
|
|
@@ -359,6 +403,7 @@ export class CartRepository extends BaseRepository {
|
|
|
359
403
|
const clearedCart = {
|
|
360
404
|
...cart,
|
|
361
405
|
items: lockedItems,
|
|
406
|
+
...this.pruneForItems(cart, lockedItems),
|
|
362
407
|
updatedAt: new Date(),
|
|
363
408
|
};
|
|
364
409
|
await this.db
|
|
@@ -517,28 +562,14 @@ export class CartRepository extends BaseRepository {
|
|
|
517
562
|
updatedAt: new Date(),
|
|
518
563
|
}, { merge: true });
|
|
519
564
|
}
|
|
520
|
-
|
|
521
|
-
*
|
|
522
|
-
*
|
|
523
|
-
*
|
|
565
|
+
/*
|
|
566
|
+
* `pruneStoreAddons(userId, keepStoreIds)` used to live here. It had zero
|
|
567
|
+
* callers from the day it was written, and `pruneForItems` now does the same
|
|
568
|
+
* job inside the two write paths that actually remove items — with no extra
|
|
569
|
+
* read and no second write, since both already `set()` the whole document.
|
|
570
|
+
* Removed rather than wired up: two ways to prune the same map is the drift
|
|
571
|
+
* this feature was fixing.
|
|
524
572
|
*/
|
|
525
|
-
async pruneStoreAddons(userId, keepStoreIds) {
|
|
526
|
-
const cart = await this.findByUserId(userId);
|
|
527
|
-
const existing = cart?.storeAddons;
|
|
528
|
-
if (!existing)
|
|
529
|
-
return;
|
|
530
|
-
const keep = new Set(keepStoreIds);
|
|
531
|
-
const stale = Object.keys(existing).filter((storeId) => !keep.has(storeId));
|
|
532
|
-
if (stale.length === 0)
|
|
533
|
-
return;
|
|
534
|
-
await this.db
|
|
535
|
-
.collection(this.collection)
|
|
536
|
-
.doc(userId)
|
|
537
|
-
.set({
|
|
538
|
-
storeAddons: Object.fromEntries(stale.map((storeId) => [storeId, FieldValue.delete()])),
|
|
539
|
-
updatedAt: new Date(),
|
|
540
|
-
}, { merge: true });
|
|
541
|
-
}
|
|
542
573
|
/**
|
|
543
574
|
* Cloud Functions: return refs of stale carts not updated within TTL.
|
|
544
575
|
*/
|
|
@@ -87,6 +87,11 @@ export function clearGuestCart(storage = getDefaultStorage(), key = DEFAULT_GUES
|
|
|
87
87
|
if (!storage)
|
|
88
88
|
return;
|
|
89
89
|
storage.removeItem(key);
|
|
90
|
+
// Every other write in this file broadcasts through `writeItems`; this one
|
|
91
|
+
// used to skip it, so after a login-merge cleared the guest cart every other
|
|
92
|
+
// mounted `useGuestCart()` kept its pre-clear snapshot — a cart that had just
|
|
93
|
+
// been merged away still showing items, and a header badge still counting them.
|
|
94
|
+
dispatchGuestCartChange();
|
|
90
95
|
}
|
|
91
96
|
export function getGuestCartCount(storage = getDefaultStorage(), key = DEFAULT_GUEST_CART_KEY) {
|
|
92
97
|
return readItems(storage, key).reduce((sum, i) => sum + i.quantity, 0);
|
|
@@ -60,6 +60,21 @@ export interface WishlistOp {
|
|
|
60
60
|
export declare function getCartOps(): CartOp[];
|
|
61
61
|
export declare function pushCartOp(op: Omit<CartOp, "ts">): void;
|
|
62
62
|
export declare function clearCartOps(): void;
|
|
63
|
+
/**
|
|
64
|
+
* Drop the queued ops for specific products, after their removal has already
|
|
65
|
+
* been applied server-side.
|
|
66
|
+
*
|
|
67
|
+
* Deliberately NOT `pushCartOp({ op: "remove", … })`. Every call site in the
|
|
68
|
+
* codebase pushes `op: "add"` only, so the queue's `remove` half is dead —
|
|
69
|
+
* and `useSyncManager`'s replay of a remove op calls `BY_ITEM_ID(op.productId)`,
|
|
70
|
+
* handing a productId to a route that looks up by `itemId`. Queueing a remove
|
|
71
|
+
* to express "this is already gone" would activate that latent 404.
|
|
72
|
+
*
|
|
73
|
+
* The op is what makes a cleared row re-appear (the cart page layers the queue
|
|
74
|
+
* over the server response on every render) and what makes `useSyncManager`
|
|
75
|
+
* re-POST it 30s later — so dropping it is the whole point, not bookkeeping.
|
|
76
|
+
*/
|
|
77
|
+
export declare function removeCartOpsFor(productIds: readonly string[]): void;
|
|
63
78
|
/** Net quantity of unsynced "add" ops — layered on top of the server's
|
|
64
79
|
* authoritative itemCount so the header badge updates immediately instead
|
|
65
80
|
* of waiting for the next useSyncManager interval. Cart never queues
|
|
@@ -122,6 +122,30 @@ export function clearCartOps() {
|
|
|
122
122
|
localStorage.removeItem(CART_OPS_KEY());
|
|
123
123
|
dispatchCartOpsChange();
|
|
124
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* Drop the queued ops for specific products, after their removal has already
|
|
127
|
+
* been applied server-side.
|
|
128
|
+
*
|
|
129
|
+
* Deliberately NOT `pushCartOp({ op: "remove", … })`. Every call site in the
|
|
130
|
+
* codebase pushes `op: "add"` only, so the queue's `remove` half is dead —
|
|
131
|
+
* and `useSyncManager`'s replay of a remove op calls `BY_ITEM_ID(op.productId)`,
|
|
132
|
+
* handing a productId to a route that looks up by `itemId`. Queueing a remove
|
|
133
|
+
* to express "this is already gone" would activate that latent 404.
|
|
134
|
+
*
|
|
135
|
+
* The op is what makes a cleared row re-appear (the cart page layers the queue
|
|
136
|
+
* over the server response on every render) and what makes `useSyncManager`
|
|
137
|
+
* re-POST it 30s later — so dropping it is the whole point, not bookkeeping.
|
|
138
|
+
*/
|
|
139
|
+
export function removeCartOpsFor(productIds) {
|
|
140
|
+
if (typeof window === "undefined" || productIds.length === 0)
|
|
141
|
+
return;
|
|
142
|
+
const drop = new Set(productIds);
|
|
143
|
+
const ops = read(CART_OPS_KEY());
|
|
144
|
+
const kept = ops.filter((op) => !drop.has(op.productId));
|
|
145
|
+
if (kept.length === ops.length)
|
|
146
|
+
return;
|
|
147
|
+
writeCartOps(kept);
|
|
148
|
+
}
|
|
125
149
|
/** Net quantity of unsynced "add" ops — layered on top of the server's
|
|
126
150
|
* authoritative itemCount so the header badge updates immediately instead
|
|
127
151
|
* of waiting for the next useSyncManager interval. Cart never queues
|
|
@@ -7,5 +7,11 @@ export interface BlogArticlesSectionProps {
|
|
|
7
7
|
viewMoreLabel?: string;
|
|
8
8
|
className?: string;
|
|
9
9
|
initialItems?: BlogPost[];
|
|
10
|
+
/** Cap the rendered posts. */
|
|
11
|
+
limit?: number;
|
|
12
|
+
/** Per-card display toggles, forwarded to `BlogFeaturedCard`. */
|
|
13
|
+
showReadTime?: boolean;
|
|
14
|
+
showAuthor?: boolean;
|
|
15
|
+
showThumbnails?: boolean;
|
|
10
16
|
}
|
|
11
|
-
export declare function BlogArticlesSection({ title, description, viewMoreHref, viewMoreLabel, className, initialItems, }: BlogArticlesSectionProps): React.JSX.Element;
|
|
17
|
+
export declare function BlogArticlesSection({ title, description, viewMoreHref, viewMoreLabel, className, initialItems, limit, showReadTime, showAuthor, showThumbnails, }: BlogArticlesSectionProps): React.JSX.Element;
|
|
@@ -5,8 +5,10 @@ import { useBlogArticles } from "../hooks/useBlogArticles";
|
|
|
5
5
|
import { BlogFeaturedCard } from "../../blog/components/BlogFeaturedCard";
|
|
6
6
|
import { ROUTES } from "../../../next";
|
|
7
7
|
import { CAROUSEL_PER_VIEW } from "../constants/carousel-per-view";
|
|
8
|
-
|
|
8
|
+
import { SECTION_TITLE, VIEW_MORE_LABEL } from "../constants/section-copy";
|
|
9
|
+
export function BlogArticlesSection({ title = SECTION_TITLE.blogArticles, description, viewMoreHref, viewMoreLabel = VIEW_MORE_LABEL.blogArticles, className = "", initialItems, limit, showReadTime = true, showAuthor = true, showThumbnails = true, }) {
|
|
9
10
|
const { data, isLoading } = useBlogArticles({ initialPosts: initialItems });
|
|
10
|
-
const
|
|
11
|
-
|
|
11
|
+
const fetched = data?.posts ?? [];
|
|
12
|
+
const items = limit && limit > 0 ? fetched.slice(0, limit) : fetched;
|
|
13
|
+
return (_jsx(SectionCarousel, { title: title, description: description, pillLabel: SECTION_TITLE.blogArticles, headingVariant: "editorial", viewMoreHref: viewMoreHref, viewMoreLabel: viewMoreLabel, items: items, isLoading: isLoading, skeletonCount: 4, perView: CAROUSEL_PER_VIEW.standard, gap: 16, keyExtractor: (post) => post.id, renderItem: (post) => (_jsx(BlogFeaturedCard, { post: post, href: ROUTES.BLOG.ARTICLE(post.slug), showReadTime: showReadTime, showAuthor: showAuthor, showThumbnail: showThumbnails })), className: className }));
|
|
12
14
|
}
|
|
@@ -1,11 +1,28 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
+
export type CTABannerHeight = "sm" | "md" | "lg" | "xl";
|
|
2
3
|
export interface CTABannerSectionProps {
|
|
3
4
|
title?: string;
|
|
4
5
|
subtitle?: string;
|
|
6
|
+
/** Longer supporting paragraph below the subtitle. */
|
|
7
|
+
description?: string;
|
|
5
8
|
primaryLabel?: string;
|
|
6
9
|
primaryHref?: string;
|
|
7
10
|
secondaryLabel?: string;
|
|
8
11
|
secondaryHref?: string;
|
|
12
|
+
/** Vertical presence of the band. Defaults to `"md"`. */
|
|
13
|
+
height?: CTABannerHeight;
|
|
14
|
+
/**
|
|
15
|
+
* Full-bleed background image behind the copy. Rendered through
|
|
16
|
+
* `MediaImage` (so it flows through the media proxy and watermark pipeline)
|
|
17
|
+
* with a scrim, because the copy is fixed-inverse ink and needs a
|
|
18
|
+
* guaranteed-dark backdrop to stay readable — Root Cause #67.
|
|
19
|
+
*
|
|
20
|
+
* There is deliberately no `backgroundColor`/`gradient` equivalent: an
|
|
21
|
+
* arbitrary admin-chosen fill under fixed white text is a contrast failure
|
|
22
|
+
* with no safe fallback, and the themed `accent-banner` tone already gives a
|
|
23
|
+
* brand gradient that follows the active theme.
|
|
24
|
+
*/
|
|
25
|
+
backgroundImage?: string;
|
|
9
26
|
className?: string;
|
|
10
27
|
}
|
|
11
|
-
export declare function CTABannerSection({ title, subtitle, primaryLabel, primaryHref, secondaryLabel, secondaryHref, className, }: CTABannerSectionProps): React.JSX.Element;
|
|
28
|
+
export declare function CTABannerSection({ title, subtitle, description, primaryLabel, primaryHref, secondaryLabel, secondaryHref, height, backgroundImage, className, }: CTABannerSectionProps): React.JSX.Element;
|
|
@@ -1,7 +1,25 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import Link from "next/link";
|
|
3
|
-
import { Div, Heading, Row, Section, Span, Text } from "../../../ui";
|
|
3
|
+
import { Div, Heading, Row, Scrim, Section, Span, Text } from "../../../ui";
|
|
4
|
+
import { MediaImage } from "../../media/MediaImage";
|
|
4
5
|
import { ROUTES } from "../../../next/routing/route-map";
|
|
5
|
-
|
|
6
|
-
|
|
6
|
+
import { SECTION_TITLE, SECTION_COPY } from "../constants/section-copy";
|
|
7
|
+
/**
|
|
8
|
+
* `height` maps onto the existing spacing scale rather than emitting a
|
|
9
|
+
* `min-h-[300px]`-style utility.
|
|
10
|
+
*
|
|
11
|
+
* Two reasons. A class generated only inside appkit and absent from the
|
|
12
|
+
* consumer's `src/**` is purged from the production CSS (Root Cause #3), and
|
|
13
|
+
* `BANNER_HEIGHTS`' raw px values would be exactly that. And padding keeps the
|
|
14
|
+
* banner sized by its content, so a long headline grows the box instead of
|
|
15
|
+
* overflowing a fixed height.
|
|
16
|
+
*/
|
|
17
|
+
const HEIGHT_PADDING_Y = {
|
|
18
|
+
sm: "y-2xl",
|
|
19
|
+
md: "y-4xl",
|
|
20
|
+
lg: "y-5xl",
|
|
21
|
+
xl: "y-6xl",
|
|
22
|
+
};
|
|
23
|
+
export function CTABannerSection({ title = SECTION_TITLE.banner, subtitle, description, primaryLabel = SECTION_COPY.bannerPrimaryCta, primaryHref = String(ROUTES.PUBLIC.PRODUCTS), secondaryLabel = SECTION_COPY.bannerSecondaryCta, secondaryHref = String(ROUTES.PUBLIC.AUCTIONS), height = "md", backgroundImage, className = "", }) {
|
|
24
|
+
return (_jsxs(Section, { tone: "accent-banner", className: `relative ${className}`, overflow: "hidden", paddingY: HEIGHT_PADDING_Y[height], paddingX: "x-md", children: [backgroundImage && (_jsxs(_Fragment, { children: [_jsx(Div, { className: "absolute inset-0", children: _jsx(MediaImage, { src: backgroundImage, alt: "", size: "banner", objectFit: "cover" }) }), _jsx(Scrim, { direction: "left-to-right", intensity: "strong", className: "absolute inset-0" })] })), _jsxs(Div, { className: "relative mx-auto max-w-3xl text-left", children: [_jsx(Span, { className: "mb-4 inline-block", size: "2xl", "aria-hidden": "true", children: "\u2728" }), _jsx(Heading, { color: "inverse", level: 2, mdSize: "3xl", lgSize: "4xl", size: "2xl", weight: "bold", children: title }), subtitle && (_jsx(Text, { color: "inverse", className: "mt-3 /80", size: "base", children: subtitle })), description && (_jsx(Text, { color: "inverse", className: "mt-2 /70 max-w-2xl", size: "sm", children: description })), _jsxs(Row, { className: "mt-8", align: "center", justify: "start", gap: "md", wrap: true, children: [_jsx(Link, { href: primaryHref, className: "inline-flex items-center rounded-full bg-white px-[var(--appkit-space-8)] py-[var(--appkit-space-3)] text-[length:var(--appkit-text-sm)] font-semibold text-primary-700 shadow-sm transition-all hover:bg-surface-hover hover:shadow-md focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-primary-600", children: primaryLabel }), secondaryHref && (_jsx(Link, { href: secondaryHref, className: "inline-flex items-center rounded-full border-2 border-white/80 px-[var(--appkit-space-8)] py-[var(--appkit-space-3)] text-[length:var(--appkit-text-sm)] font-semibold text-white transition-all hover:bg-white/10 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-primary-600", children: secondaryLabel }))] })] })] }));
|
|
7
25
|
}
|