@mohasinac/appkit 4.32.6 → 4.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/dist/_internal/server/features/products/actions.js +29 -3
  2. package/dist/_internal/server/features/products/list-public.d.ts +1 -1
  3. package/dist/_internal/server/features/products/list-public.js +10 -2
  4. package/dist/_internal/server/features/stores/denormalize.d.ts +44 -0
  5. package/dist/_internal/server/features/stores/denormalize.js +58 -0
  6. package/dist/_internal/server/features/stores/index.d.ts +1 -0
  7. package/dist/_internal/server/features/stores/index.js +1 -0
  8. package/dist/_internal/server/jobs/core/jobRunners.js +5 -0
  9. package/dist/_internal/server/jobs/core/storeNameBackfill.d.ts +27 -0
  10. package/dist/_internal/server/jobs/core/storeNameBackfill.js +90 -0
  11. package/dist/_internal/shared/features/products/to-product-item.js +7 -0
  12. package/dist/client.d.ts +1 -1
  13. package/dist/client.js +4 -1
  14. package/dist/features/admin/actions/admin-actions.js +11 -1
  15. package/dist/features/admin/components/AdminSectionsView.js +17 -20
  16. package/dist/features/admin/components/sections/adminSectionsBuildParse.js +31 -14
  17. package/dist/features/admin/components/sections/adminSectionsTypes.d.ts +37 -7
  18. package/dist/features/admin/components/sections/adminSectionsTypes.js +11 -7
  19. package/dist/features/auctions/components/AuctionDetailPageView.js +56 -36
  20. package/dist/features/auctions/components/MarketplaceAuctionCard.d.ts +9 -0
  21. package/dist/features/auctions/components/MarketplaceAuctionCard.js +3 -2
  22. package/dist/features/blog/components/BlogFeaturedCard.d.ts +7 -1
  23. package/dist/features/blog/components/BlogFeaturedCard.js +3 -3
  24. package/dist/features/cart/repository/cart.repository.d.ts +27 -6
  25. package/dist/features/cart/repository/cart.repository.js +52 -21
  26. package/dist/features/cart/utils/guest-cart.js +5 -0
  27. package/dist/features/cart/utils/pending-ops.d.ts +15 -0
  28. package/dist/features/cart/utils/pending-ops.js +24 -0
  29. package/dist/features/homepage/components/BlogArticlesSection.d.ts +7 -1
  30. package/dist/features/homepage/components/BlogArticlesSection.js +5 -3
  31. package/dist/features/homepage/components/CTABannerSection.d.ts +18 -1
  32. package/dist/features/homepage/components/CTABannerSection.js +22 -4
  33. package/dist/features/homepage/components/CustomerReviewsSection.d.ts +7 -1
  34. package/dist/features/homepage/components/CustomerReviewsSection.js +2 -2
  35. package/dist/features/homepage/components/FAQSection.js +2 -11
  36. package/dist/features/homepage/components/FeaturedAuctionsSection.d.ts +3 -1
  37. package/dist/features/homepage/components/FeaturedAuctionsSection.js +5 -3
  38. package/dist/features/homepage/components/FeaturedBundlesSection.d.ts +7 -1
  39. package/dist/features/homepage/components/FeaturedBundlesSection.js +6 -5
  40. package/dist/features/homepage/components/FeaturedPreOrdersSection.d.ts +3 -1
  41. package/dist/features/homepage/components/FeaturedPreOrdersSection.js +4 -2
  42. package/dist/features/homepage/components/FeaturedProductsSection.d.ts +3 -1
  43. package/dist/features/homepage/components/FeaturedProductsSection.js +5 -3
  44. package/dist/features/homepage/components/HeroCarousel.d.ts +23 -2
  45. package/dist/features/homepage/components/HeroCarousel.js +22 -9
  46. package/dist/features/homepage/components/HomepageCustomerReviewsSection.d.ts +6 -1
  47. package/dist/features/homepage/components/HomepageCustomerReviewsSection.js +11 -3
  48. package/dist/features/homepage/components/MarketplaceHomepageView.d.ts +9 -1
  49. package/dist/features/homepage/components/MarketplaceHomepageView.js +7 -4
  50. package/dist/features/homepage/components/StatsCounterSection.d.ts +6 -1
  51. package/dist/features/homepage/components/StatsCounterSection.js +3 -3
  52. package/dist/features/homepage/constants/faq-category-labels.d.ts +15 -0
  53. package/dist/features/homepage/constants/faq-category-labels.js +23 -0
  54. package/dist/features/homepage/constants/section-copy.d.ts +107 -0
  55. package/dist/features/homepage/constants/section-copy.js +96 -0
  56. package/dist/features/homepage/lib/section-renderer.d.ts +16 -1
  57. package/dist/features/homepage/lib/section-renderer.js +58 -37
  58. package/dist/features/homepage/repository/homepage-sections.repository.d.ts +23 -0
  59. package/dist/features/homepage/repository/homepage-sections.repository.js +40 -0
  60. package/dist/features/homepage/schemas/firestore.d.ts +120 -44
  61. package/dist/features/media/MediaImage.d.ts +10 -1
  62. package/dist/features/media/MediaImage.js +6 -3
  63. package/dist/features/pre-orders/components/MarketplacePreorderCard.js +3 -2
  64. package/dist/features/products/components/CardStoreLine.d.ts +32 -0
  65. package/dist/features/products/components/CardStoreLine.js +14 -0
  66. package/dist/features/products/components/MarketplacePrizeDrawCard.js +2 -1
  67. package/dist/features/products/components/ProductGrid.js +3 -6
  68. package/dist/features/products/schemas/firestore.d.ts +17 -0
  69. package/dist/features/products/types/index.d.ts +2 -0
  70. package/dist/features/seller/actions/seller-actions.d.ts +17 -0
  71. package/dist/features/seller/actions/seller-actions.js +54 -0
  72. package/dist/features/stores/components/StoreAuctionsListing.js +3 -0
  73. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +119 -0
  74. package/dist/seed/carousel-slides-seed-data.js +154 -22
  75. package/dist/seed/carousels-seed-data.js +4 -1
  76. package/dist/seed/homepage-sections-seed-data.js +337 -254
  77. package/dist/seed/products-preorders-seed-data.d.ts +1 -0
  78. package/dist/server.d.ts +1 -0
  79. package/dist/server.js +4 -0
  80. package/dist/styles.css +1 -1
  81. package/dist/tailwind-utilities.css +1 -1
  82. 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 now = new Date();
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
- * Drop a store's add-on entry once its last item leaves the cart, so the map
522
- * doesn't accumulate orphans. Cosmetic — an orphan is already unreachable,
523
- * because a store with no items forms no order group.
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
- export function BlogArticlesSection({ title = "From Our Blog", description, viewMoreHref, viewMoreLabel = "View all posts →", className = "", initialItems, }) {
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 items = data?.posts ?? [];
11
- return (_jsx(SectionCarousel, { title: title, description: description, pillLabel: "From Our Blog", 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) })), className: className }));
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
- export function CTABannerSection({ title = "Thousands of collectibles. One marketplace.", subtitle, primaryLabel = "Shop All Products →", primaryHref = String(ROUTES.PUBLIC.PRODUCTS), secondaryLabel = "Browse Auctions →", secondaryHref = String(ROUTES.PUBLIC.AUCTIONS), className = "", }) {
6
- return (_jsx(Section, { tone: "accent-banner", className: `${className}`, paddingY: "y-4xl", paddingX: "x-md", children: _jsxs(Div, { className: "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 })), _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 }))] })] }) }));
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
  }