@mohasinac/appkit 4.63.5 → 4.64.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.
Files changed (119) hide show
  1. package/dist/.build-stamp.json +3 -3
  2. package/dist/_internal/server/features/categories/adapters.d.ts +1 -1
  3. package/dist/_internal/server/features/categories/adapters.js +13 -0
  4. package/dist/_internal/server/features/events/like-actions.d.ts +25 -0
  5. package/dist/_internal/server/features/events/like-actions.js +67 -0
  6. package/dist/_internal/server/features/events/likes.d.ts +65 -0
  7. package/dist/_internal/server/features/events/likes.js +146 -0
  8. package/dist/_internal/server/features/seo/index.d.ts +3 -0
  9. package/dist/_internal/server/features/seo/index.js +2 -0
  10. package/dist/_internal/server/features/seo/product-detail-types.d.ts +1 -0
  11. package/dist/_internal/server/features/seo/product-detail-types.js +26 -0
  12. package/dist/_internal/server/features/seo/robots.js +20 -1
  13. package/dist/_internal/server/features/seo/sitemap.js +125 -27
  14. package/dist/_internal/server/features/seo/static-params.d.ts +107 -0
  15. package/dist/_internal/server/features/seo/static-params.js +228 -0
  16. package/dist/_internal/server/features/seo/taxonomy-links.d.ts +44 -0
  17. package/dist/_internal/server/features/seo/taxonomy-links.js +85 -0
  18. package/dist/_internal/server/features/stores/adapters.d.ts +2 -2
  19. package/dist/_internal/server/features/stores/adapters.js +87 -1
  20. package/dist/_internal/server/functions/scheduled.d.ts +16 -1
  21. package/dist/_internal/server/functions/scheduled.js +23 -1
  22. package/dist/_internal/server/jobs/core/sellerStandingRollup.d.ts +63 -0
  23. package/dist/_internal/server/jobs/core/sellerStandingRollup.js +412 -0
  24. package/dist/_internal/server/jobs/handlers/index.d.ts +1 -0
  25. package/dist/_internal/server/jobs/handlers/index.js +1 -0
  26. package/dist/_internal/server/jobs/handlers/sellerStandingRollup.d.ts +12 -0
  27. package/dist/_internal/server/jobs/handlers/sellerStandingRollup.js +14 -0
  28. package/dist/_internal/shared/actions/action-registry.js +61 -0
  29. package/dist/_internal/shared/features/seller-standing/config.d.ts +130 -0
  30. package/dist/_internal/shared/features/seller-standing/config.js +153 -0
  31. package/dist/constants/api-endpoints.d.ts +24 -0
  32. package/dist/constants/api-endpoints.js +8 -0
  33. package/dist/features/about/components/HelpPageView.js +9 -1
  34. package/dist/features/account/components/BecomeSellerView.d.ts +15 -3
  35. package/dist/features/account/components/BecomeSellerView.js +2 -2
  36. package/dist/features/admin/components/AdminStoreEditorView.d.ts +12 -1
  37. package/dist/features/admin/components/AdminStoreEditorView.js +30 -2
  38. package/dist/features/admin/components/AdminStoresGuideView.js +1 -1
  39. package/dist/features/admin/components/AdminStoresView.js +59 -2
  40. package/dist/features/admin/components/AdminTrustGuideView.js +10 -0
  41. package/dist/features/admin/schemas/admin-editor-forms.d.ts +10 -0
  42. package/dist/features/admin/schemas/admin-editor-forms.js +34 -0
  43. package/dist/features/blog/components/BlogIndexPageView.js +2 -2
  44. package/dist/features/blog/components/BlogListView.js +2 -1
  45. package/dist/features/blog/components/BlogPostView.js +2 -1
  46. package/dist/features/blog/constants/category-badge.d.ts +33 -0
  47. package/dist/features/blog/constants/category-badge.js +40 -0
  48. package/dist/features/categories/components/TaxonomyLinkIndex.d.ts +39 -0
  49. package/dist/features/categories/components/TaxonomyLinkIndex.js +11 -0
  50. package/dist/features/categories/schemas/firestore.d.ts +25 -0
  51. package/dist/features/categories/utils/load-category-options.d.ts +33 -0
  52. package/dist/features/categories/utils/load-category-options.js +50 -0
  53. package/dist/features/events/components/EventCard.js +4 -12
  54. package/dist/features/events/components/EventInsights.d.ts +27 -0
  55. package/dist/features/events/components/EventInsights.js +10 -0
  56. package/dist/features/events/components/EventLikeButton.d.ts +21 -0
  57. package/dist/features/events/components/EventLikeButton.js +59 -0
  58. package/dist/features/events/components/EventLikersRail.d.ts +23 -0
  59. package/dist/features/events/components/EventLikersRail.js +54 -0
  60. package/dist/features/events/components/EventTypeBadge.d.ts +26 -0
  61. package/dist/features/events/components/EventTypeBadge.js +7 -0
  62. package/dist/features/events/components/EventsListPageView.js +2 -2
  63. package/dist/features/events/components/SpinWheel.d.ts +41 -0
  64. package/dist/features/events/components/SpinWheel.js +128 -0
  65. package/dist/features/events/components/SpinWheelView.js +36 -15
  66. package/dist/features/events/constants/event-type-meta.d.ts +54 -0
  67. package/dist/features/events/constants/event-type-meta.js +109 -0
  68. package/dist/features/events/repository/event-likes.repository.d.ts +57 -0
  69. package/dist/features/events/repository/event-likes.repository.js +151 -0
  70. package/dist/features/events/schemas/firestore.d.ts +111 -0
  71. package/dist/features/events/schemas/likes.d.ts +81 -0
  72. package/dist/features/events/schemas/likes.js +59 -0
  73. package/dist/features/events/types/index.d.ts +7 -0
  74. package/dist/features/events/utils/carousel-slide.d.ts +42 -0
  75. package/dist/features/events/utils/carousel-slide.js +174 -0
  76. package/dist/features/events/utils/event-charts.d.ts +65 -0
  77. package/dist/features/events/utils/event-charts.js +172 -0
  78. package/dist/features/faq/constants/help-guides.js +9 -0
  79. package/dist/features/faq/schemas/firestore.js +1 -0
  80. package/dist/features/faq/types/index.d.ts +19 -0
  81. package/dist/features/homepage/components/BrandsSection.js +25 -1
  82. package/dist/features/homepage/components/HeroCarousel.js +47 -10
  83. package/dist/features/homepage/types/index.d.ts +7 -0
  84. package/dist/features/stores/components/InteractiveStoreCard.js +2 -1
  85. package/dist/features/stores/components/SellerStandingPanel.d.ts +31 -0
  86. package/dist/features/stores/components/SellerStandingPanel.js +25 -0
  87. package/dist/features/stores/components/SellerTierBadge.d.ts +57 -0
  88. package/dist/features/stores/components/SellerTierBadge.js +19 -0
  89. package/dist/features/stores/components/StoreCapabilitiesGuideView.js +32 -2
  90. package/dist/features/stores/components/StoreCreateForm.d.ts +46 -0
  91. package/dist/features/stores/components/StoreCreateForm.js +107 -0
  92. package/dist/features/stores/components/StoreGuideHubView.js +17 -4
  93. package/dist/features/stores/components/StoreHeader.js +2 -1
  94. package/dist/features/stores/repository/store.repository.js +23 -0
  95. package/dist/features/stores/schemas/firestore.d.ts +60 -1
  96. package/dist/features/stores/schemas/firestore.js +18 -0
  97. package/dist/features/stores/schemas/index.d.ts +2 -2
  98. package/dist/features/stores/schemas/store-create.d.ts +47 -0
  99. package/dist/features/stores/schemas/store-create.js +86 -0
  100. package/dist/features/stores/types/index.d.ts +42 -0
  101. package/dist/index.d.ts +2 -0
  102. package/dist/index.js +21 -0
  103. package/dist/next/routing/route-map.d.ts +3 -1
  104. package/dist/next/routing/route-map.js +13 -0
  105. package/dist/schemas/registry.d.ts +10 -0
  106. package/dist/server.d.ts +5 -0
  107. package/dist/server.js +10 -0
  108. package/dist/styles.css +36 -1
  109. package/dist/tailwind-utilities.css +1 -1
  110. package/dist/ui/components/HorizontalScroller.js +15 -2
  111. package/dist/ui/components/PageHeroHeading.d.ts +40 -0
  112. package/dist/ui/components/PageHeroHeading.js +15 -0
  113. package/dist/ui/components/Typography.d.ts +3 -3
  114. package/dist/ui/components/Typography.js +59 -31
  115. package/dist/ui/components/Typography.style.css +35 -0
  116. package/dist/ui/index.d.ts +2 -0
  117. package/dist/ui/index.js +1 -0
  118. package/package.json +1 -1
  119. package/scripts/seed-cli.mjs +4 -2
@@ -3,6 +3,7 @@ import React from "react";
3
3
  import Link from "next/link.js";
4
4
  import { Article, Div, Grid, Heading, OverflowRow, Pagination, Row, Span, Stack, Text } from "../../../ui/index.js";
5
5
  import { MediaImage } from "../../media/MediaImage.js";
6
+ import { blogCategoryBadge } from "../constants/category-badge.js";
6
7
  import { getDefaultLocale } from "../../../core/baseline-resolver.js";
7
8
  import { getMediaUrl } from "../../media/types/index.js";
8
9
  import { safeDisplayName } from "../../../security/index.js";
@@ -25,7 +26,7 @@ export function BlogCard({ post, href, onClick, className = "" }) {
25
26
  const isInteractive = !!(href || onClick);
26
27
  const card = (_jsxs(Article, { role: onClick && !href ? "button" : undefined, tabIndex: onClick && !href ? 0 : undefined, onKeyDown: onClick && !href
27
28
  ? (e) => (e.key === "Enter" || e.key === "") && onClick(post)
28
- : undefined, onClick: onClick && !href ? () => onClick(post) : undefined, className: `group flex flex-col h-full overflow-hidden rounded-xl border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] shadow-sm transition hover:shadow-md ${isInteractive ? "cursor-pointer" : ""} ${className}`, children: [_jsx(Div, { className: `relative aspect-video w-full ${__O.hidden} bg-[var(--appkit-color-surface)] dark:bg-[var(--appkit-color-surface-elevated)] flex-shrink-0`, children: coverImageUrl ? (_jsx(MediaImage, { src: coverImageUrl, alt: post.title, size: "card", className: "transition-transform duration-300 group-hover:scale-105" })) : (_jsx(Row, { surface: "muted", className: "h-full w-full", align: "center", justify: "center", children: _jsx(Span, { className: "opacity-30", size: "4xl", "aria-hidden": "true", children: "\u270D\uFE0F" }) })) }), _jsxs(Stack, { className: `flex-1 ${__P.p5}`, children: [_jsxs(Row, { className: "mb-2", gap: "sm", wrap: true, children: [_jsx(Span, { size: "xs", weight: "medium", className: "bg-primary/10 text-primary", rounded: "full", padding: "pill-xs", transform: "capitalize", children: post.category }), post.isFeatured && (_jsx(Span, { size: "xs", weight: "medium", className: CLS_FEATURED_BADGE, children: "Featured" })), post.readTimeMinutes && (_jsxs(Span, { size: "xs", color: "muted", children: [post.readTimeMinutes, " min read"] }))] }), _jsx(Heading, { level: 3, className: `group-hover:text-primary`, color: "primary", truncate: 2, size: "base", weight: "semibold", children: post.title }), post.excerpt && (_jsx(Text, { className: `mt-2 flex-1`, color: "muted", truncate: 3, size: "sm", children: post.excerpt })), _jsxs(Row, { className: "mt-auto", padding: "t-sm", gap: "3", children: [post.authorAvatar ? (_jsx(Div, { className: "relative h-7 w-7 flex-shrink-0", rounded: "full", overflow: "hidden", children: _jsx(MediaImage, { src: post.authorAvatar, alt: post.authorName ?? "author", size: "avatar" }) })) : post.authorName ? (_jsx(Row, { textWeight: "bold", textSize: "xs", className: "h-7 w-7 flex-shrink-0 bg-primary/10 text-primary", align: "center", justify: "center", rounded: "full", children: post.authorName.charAt(0).toUpperCase() })) : null, _jsxs(Text, { className: "text-[var(--appkit-color-text-muted)] min-w-0", size: "xs", children: [post.authorName && (_jsx(Span, { weight: "medium", className: "text-neutral-700", children: safeDisplayName(post.authorName, "Author") })), date && _jsxs(Span, { className: "ml-1 text-neutral-400", children: ["\u00B7 ", date] })] })] })] })] }));
29
+ : undefined, onClick: onClick && !href ? () => onClick(post) : undefined, className: `group flex flex-col h-full overflow-hidden rounded-xl border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] shadow-sm transition hover:shadow-md ${isInteractive ? "cursor-pointer" : ""} ${className}`, children: [_jsx(Div, { className: `relative aspect-video w-full ${__O.hidden} bg-[var(--appkit-color-surface)] dark:bg-[var(--appkit-color-surface-elevated)] flex-shrink-0`, children: coverImageUrl ? (_jsx(MediaImage, { src: coverImageUrl, alt: post.title, size: "card", className: "transition-transform duration-300 group-hover:scale-105" })) : (_jsx(Row, { surface: "muted", className: "h-full w-full", align: "center", justify: "center", children: _jsx(Span, { className: "opacity-30", size: "4xl", "aria-hidden": "true", children: "\u270D\uFE0F" }) })) }), _jsxs(Stack, { className: `flex-1 ${__P.p5}`, children: [_jsxs(Row, { className: "mb-2", gap: "sm", wrap: true, children: [_jsx(Span, { size: "xs", weight: "medium", className: blogCategoryBadge(post.category), rounded: "full", padding: "pill-xs", transform: "capitalize", children: post.category }), post.isFeatured && (_jsx(Span, { size: "xs", weight: "medium", className: CLS_FEATURED_BADGE, children: "Featured" })), post.readTimeMinutes && (_jsxs(Span, { size: "xs", color: "muted", children: [post.readTimeMinutes, " min read"] }))] }), _jsx(Heading, { level: 3, className: `group-hover:text-primary`, color: "primary", truncate: 2, size: "base", weight: "semibold", children: post.title }), post.excerpt && (_jsx(Text, { className: `mt-2 flex-1`, color: "muted", truncate: 3, size: "sm", children: post.excerpt })), _jsxs(Row, { className: "mt-auto", padding: "t-sm", gap: "3", children: [post.authorAvatar ? (_jsx(Div, { className: "relative h-7 w-7 flex-shrink-0", rounded: "full", overflow: "hidden", children: _jsx(MediaImage, { src: post.authorAvatar, alt: post.authorName ?? "author", size: "avatar" }) })) : post.authorName ? (_jsx(Row, { textWeight: "bold", textSize: "xs", className: "h-7 w-7 flex-shrink-0 bg-primary/10 text-primary", align: "center", justify: "center", rounded: "full", children: post.authorName.charAt(0).toUpperCase() })) : null, _jsxs(Text, { className: "text-[var(--appkit-color-text-muted)] min-w-0", size: "xs", children: [post.authorName && (_jsx(Span, { weight: "medium", className: "text-neutral-700", children: safeDisplayName(post.authorName, "Author") })), date && _jsxs(Span, { className: "ml-1 text-neutral-400", children: ["\u00B7 ", date] })] })] }), _jsxs(Row, { align: "center", gap: "xs", className: "mt-3 text-primary", textSize: "sm", textWeight: "medium", children: [_jsx(Span, { children: "Read" }), _jsx(Span, { "aria-hidden": "true", className: "transition-transform duration-200 group-hover:translate-x-1", children: "\u2192" })] })] })] }));
29
30
  if (href) {
30
31
  return (_jsx(Link, { href: href, className: "block h-full", children: card }));
31
32
  }
@@ -5,6 +5,7 @@ import Link from "next/link.js";
5
5
  import { Div, Grid, Heading, RichText, Row, Scrim, Span, Stack, Text } from "../../../ui/index.js";
6
6
  import { Iframe } from "../../../ui/components/Iframe.js";
7
7
  import { MediaImage } from "../../media/MediaImage.js";
8
+ import { blogCategoryBadge } from "../constants/category-badge.js";
8
9
  import { useBlogPost } from "../hooks/useBlog.js";
9
10
  import { BlogCard } from "./BlogListView.js";
10
11
  import { getMediaUrl } from "../../media/types/index.js";
@@ -56,5 +57,5 @@ export function BlogPostView({ slug, initialData, labels = {}, renderImage, rend
56
57
  })
57
58
  : "";
58
59
  const coverImageUrl = getMediaUrl(post.coverImage);
59
- return (_jsxs(Div, { className: `min-h-screen ${className}`, children: [coverImageUrl && (_jsxs(Div, { className: `relative h-72 md:h-96 ${__O.hidden}`, children: [renderImage ? (renderImage(post)) : (_jsx(MediaImage, { src: coverImageUrl, alt: post.title, size: "hero" })), _jsx(Scrim, { direction: "bottom-up", intensity: "subtle", className: "absolute inset-0" })] })), _jsxs(Div, { className: "max-w-3xl mx-auto", paddingY: "y-3xl", paddingX: "x-md", children: [renderBlogPostHeader(post, date, labels), post.youtubeId && /^[A-Za-z0-9_-]{6,20}$/.test(post.youtubeId) && (_jsx(Div, { className: "mb-8", children: _jsx(Iframe, { src: `https://www.youtube-nocookie.com/embed/${post.youtubeId}`, title: `${post.title} — video`, aspect: "video", rounded: "lg" }) })), post.tags && post.tags.length > 0 && (_jsx(Row, { wrap: true, gap: "sm", className: "mb-8", children: post.tags.map((tag) => (_jsxs(Link, { href: `${String(ROUTES.BLOG.LIST)}?tags=${encodeURIComponent(tag)}`, className: "inline-block px-[var(--appkit-space-3)] py-[var(--appkit-space-1)] rounded-full bg-[var(--appkit-color-surface-elevated)] hover:bg-primary/10 text-[var(--appkit-color-text-muted)] hover:text-primary text-[length:var(--appkit-text-xs)] font-medium transition-colors", children: ["#", tag] }, tag))) })), renderAuthorBio && _jsx(Div, { className: "mb-6", children: renderAuthorBio(post) }), _jsx(Div, { surface: "card", padding: "xl", className: "mb-12", children: renderContent ? renderContent(post) : (_jsx(RichText, { html: normalizeRichTextHtml(post.content ?? ""), proseClass: "prose max-w-none dark:prose-invert" })) }), _jsxs(Stack, { gap: "xl", children: [renderBlogPostRelated(related, labels?.relatedTitle ?? "Related Posts", renderRelatedCard), renderBlogPostRelated(relatedByTags, labels?.relatedByTagsTitle ?? "You might also like", renderRelatedCard), renderBlogPostRelated(relatedByAuthor, labels?.relatedByAuthorTitle ?? `More from ${post.authorName}`, renderRelatedCard)] }), renderBackButton && _jsx(Div, { border: "default", className: "mt-10 border-t", padding: "t-xl", children: renderBackButton() })] })] }));
60
+ return (_jsxs(Div, { className: `min-h-screen ${className}`, children: [coverImageUrl && (_jsxs(Div, { className: `relative h-72 md:h-[28rem] ${__O.hidden}`, children: [renderImage ? (renderImage(post)) : (_jsx(MediaImage, { src: coverImageUrl, alt: post.title, size: "hero", objectFit: "cover" })), _jsx(Scrim, { direction: "bottom-up", intensity: "strong", className: "absolute inset-0" }), _jsx(Div, { className: "absolute inset-x-0 bottom-0", children: _jsxs(Stack, { className: "mx-auto max-w-3xl", gap: "sm", paddingX: "x-md", paddingY: "y-xl", children: [_jsxs(Row, { gap: "sm", wrap: true, children: [_jsx(Span, { size: "xs", weight: "medium", className: `inline-block capitalize ${blogCategoryBadge(post.category)}`, rounded: "full", padding: "pill-xs", children: post.category }), post.isFeatured && (_jsx(Span, { size: "xs", weight: "medium", className: CLS_FEATURED_BADGE, children: labels?.featured ?? "Featured" }))] }), _jsx(Heading, { level: 1, size: "3xl", mdSize: "4xl", weight: "bold", color: "inverse", shadow: "lg", children: post.title }), _jsxs(Row, { color: "inverse", textSize: "sm", wrap: true, gap: "md", className: "opacity-90", children: [post.authorName && (_jsxs(Span, { children: [labels?.author ?? "By", " ", _jsx(Span, { weight: "medium", children: post.authorName })] })), post.readTimeMinutes != null && (_jsxs(Span, { children: [post.readTimeMinutes, " ", labels?.readTime ?? "min read"] })), date && _jsx(Span, { children: date })] })] }) })] })), _jsxs(Div, { className: "max-w-3xl mx-auto", paddingY: "y-3xl", paddingX: "x-md", children: [!coverImageUrl && renderBlogPostHeader(post, date, labels), post.youtubeId && /^[A-Za-z0-9_-]{6,20}$/.test(post.youtubeId) && (_jsx(Div, { className: "mb-8", children: _jsx(Iframe, { src: `https://www.youtube-nocookie.com/embed/${post.youtubeId}`, title: `${post.title} — video`, aspect: "video", rounded: "lg" }) })), post.tags && post.tags.length > 0 && (_jsx(Row, { wrap: true, gap: "sm", className: "mb-8", children: post.tags.map((tag) => (_jsxs(Link, { href: `${String(ROUTES.BLOG.LIST)}?tags=${encodeURIComponent(tag)}`, className: "inline-block px-[var(--appkit-space-3)] py-[var(--appkit-space-1)] rounded-full bg-[var(--appkit-color-surface-elevated)] hover:bg-primary/10 text-[var(--appkit-color-text-muted)] hover:text-primary text-[length:var(--appkit-text-xs)] font-medium transition-colors", children: ["#", tag] }, tag))) })), renderAuthorBio && _jsx(Div, { className: "mb-6", children: renderAuthorBio(post) }), _jsx(Div, { surface: "card", padding: "xl", className: "mb-12", children: renderContent ? renderContent(post) : (_jsx(RichText, { html: normalizeRichTextHtml(post.content ?? ""), proseClass: "prose max-w-none dark:prose-invert" })) }), renderBackButton && _jsx(Div, { border: "default", className: "mt-10 border-t", padding: "t-xl", children: renderBackButton() })] }), _jsx(Div, { className: "mx-auto max-w-6xl", paddingX: "x-md", paddingY: "y-2xl", children: _jsxs(Stack, { gap: "xl", children: [renderBlogPostRelated(related, labels?.relatedTitle ?? "Related Posts", renderRelatedCard), renderBlogPostRelated(relatedByTags, labels?.relatedByTagsTitle ?? "You might also like", renderRelatedCard), renderBlogPostRelated(relatedByAuthor, labels?.relatedByAuthorTitle ?? `More from ${post.authorName}`, renderRelatedCard)] }) })] }));
60
61
  }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The per-category chip palette, shared by every blog surface.
3
+ *
4
+ * ## Why this moved out of `BlogFeaturedCard`
5
+ *
6
+ * There were two palettes and only one of them had colours. `BlogFeaturedCard`
7
+ * carried this real five-way map — and that component is mounted ONLY on the
8
+ * homepage rail. `BlogCard`, which is what `/blog` and every related-posts
9
+ * grid actually render, painted all five categories one flat
10
+ * `bg-primary/10 text-primary`.
11
+ *
12
+ * So the index — the page where telling a guide from a release note is worth
13
+ * something, because you are scanning twenty of them — was the one surface
14
+ * with no category colour at all.
15
+ *
16
+ * 🛑 `Record<BlogPostCategory, string>`, so a sixth category is a compile
17
+ * error rather than a card that silently falls back to grey.
18
+ *
19
+ * @tag domain:blog
20
+ * @tag layer:constants
21
+ */
22
+ import type { BlogPostCategory } from "../types";
23
+ export declare const BLOG_CATEGORY_BADGE: Record<BlogPostCategory, string>;
24
+ /**
25
+ * Neutral fallback for a value that is not a known category.
26
+ *
27
+ * 🛑 Both halves are theme-relative tokens that invert together. Never pair a
28
+ * `-surface` tint with a literal `text-white`: `-surface` is a near-white tint
29
+ * in light themes and a dark tint in dark ones, so fixed-white ink is
30
+ * invisible in exactly one of them (Root Cause #67).
31
+ */
32
+ export declare const BLOG_CATEGORY_BADGE_FALLBACK = "bg-[var(--appkit-color-bg)] text-[var(--appkit-color-text-muted)]";
33
+ export declare function blogCategoryBadge(category: string | undefined): string;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The per-category chip palette, shared by every blog surface.
3
+ *
4
+ * ## Why this moved out of `BlogFeaturedCard`
5
+ *
6
+ * There were two palettes and only one of them had colours. `BlogFeaturedCard`
7
+ * carried this real five-way map — and that component is mounted ONLY on the
8
+ * homepage rail. `BlogCard`, which is what `/blog` and every related-posts
9
+ * grid actually render, painted all five categories one flat
10
+ * `bg-primary/10 text-primary`.
11
+ *
12
+ * So the index — the page where telling a guide from a release note is worth
13
+ * something, because you are scanning twenty of them — was the one surface
14
+ * with no category colour at all.
15
+ *
16
+ * 🛑 `Record<BlogPostCategory, string>`, so a sixth category is a compile
17
+ * error rather than a card that silently falls back to grey.
18
+ *
19
+ * @tag domain:blog
20
+ * @tag layer:constants
21
+ */
22
+ export const BLOG_CATEGORY_BADGE = {
23
+ news: "bg-info-surface text-info",
24
+ tips: "bg-success-surface text-success",
25
+ guides: "bg-indigo-100 text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-300",
26
+ updates: "bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300",
27
+ community: "bg-warning-surface text-warning",
28
+ };
29
+ /**
30
+ * Neutral fallback for a value that is not a known category.
31
+ *
32
+ * 🛑 Both halves are theme-relative tokens that invert together. Never pair a
33
+ * `-surface` tint with a literal `text-white`: `-surface` is a near-white tint
34
+ * in light themes and a dark tint in dark ones, so fixed-white ink is
35
+ * invisible in exactly one of them (Root Cause #67).
36
+ */
37
+ export const BLOG_CATEGORY_BADGE_FALLBACK = "bg-[var(--appkit-color-bg)] text-[var(--appkit-color-text-muted)]";
38
+ export function blogCategoryBadge(category) {
39
+ return (BLOG_CATEGORY_BADGE[category] ?? BLOG_CATEGORY_BADGE_FALLBACK);
40
+ }
@@ -0,0 +1,39 @@
1
+ import React from "react";
2
+ /**
3
+ * A plain, server-rendered index of entity links.
4
+ *
5
+ * ## Why this exists
6
+ *
7
+ * Measured on the prerendered HTML 2026-10-24: `/categories`, `/brands` and
8
+ * `/parts` each rendered **zero** outbound entity links, and `/franchise`
9
+ * rendered one. All three hubs filter and paginate entirely client-side
10
+ * (`useCategoriesFiltered`), which is a deliberate choice that keeps them
11
+ * static — but it means a crawler that lands on `/brands` finds nothing to
12
+ * follow, and the 28 brand pages are reachable only from the homepage strip.
13
+ * Franchise detail pages were reachable by no link at all.
14
+ *
15
+ * This renders the full set as real `<a href>`s in the server HTML, below the
16
+ * interactive grid. It is additive: the client grid above is untouched, so the
17
+ * filtering UX is unchanged and the page stays static (a Firestore read in a
18
+ * Server Component does not force dynamic rendering — only `searchParams`,
19
+ * `cookies()` and `headers()` do, Root Cause #94).
20
+ *
21
+ * It is also genuinely useful rather than an SEO artefact — an A–Z index is
22
+ * what a visitor who wants "all of them" is looking for, and hiding every
23
+ * entity behind a JS filter serves nobody. Nothing here is cloaked: the links
24
+ * are visible to a person exactly as they are to a crawler.
25
+ *
26
+ * 🛑 Deliberately NOT a `"use client"` component and deliberately not
27
+ * paginated. Its whole job is to be in the HTML.
28
+ */
29
+ export interface TaxonomyLink {
30
+ href: string;
31
+ label: string;
32
+ }
33
+ export interface TaxonomyLinkIndexProps {
34
+ heading: string;
35
+ /** One short sentence saying what the list is. Rendered above the links. */
36
+ blurb?: string;
37
+ items: readonly TaxonomyLink[];
38
+ }
39
+ export declare function TaxonomyLinkIndex({ heading, blurb, items }: TaxonomyLinkIndexProps): React.JSX.Element | null;
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { TextLink } from "../../../ui/components/TextLink.js";
3
+ import { Section } from "../../../ui/components/Semantic.js";
4
+ import { Container, Row } from "../../../ui/components/Layout.js";
5
+ import { Heading, Text } from "../../../ui/components/Typography.js";
6
+ export function TaxonomyLinkIndex({ heading, blurb, items }) {
7
+ // An empty index is worse than none — it reads as a broken section.
8
+ if (items.length === 0)
9
+ return null;
10
+ return (_jsx(Section, { padding: "y-xl", surface: "muted", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 2, size: "xl", weight: "semibold", color: "primary", className: "mb-2", children: heading }), blurb ? (_jsx(Text, { size: "sm", color: "muted", className: "mb-4", children: blurb })) : null, _jsx(Row, { wrap: true, gap: "comfortable", as: "nav", "aria-label": heading, children: items.map((item) => (_jsx(TextLink, { href: item.href, children: item.label }, item.href))) })] }) }));
11
+ }
@@ -185,6 +185,31 @@ export interface CategoryDocument extends BaseDocument {
185
185
  * ranking mechanism; no Shopify competitor in the crawl has any.
186
186
  */
187
187
  contentBody?: string;
188
+ /**
189
+ * Where this category's SEO signals belong, when that is NOT its own URL.
190
+ *
191
+ * A part-leaf category and a `/parts/{slot}/{code}` page are the same
192
+ * entity seen twice: the leaf is a (usually empty) shopping surface, the
193
+ * parts page is the reference entry with the weight, code, aliases, WBO
194
+ * legality and the models that use it. Measured 2026-10-24 the taxonomy
195
+ * held 4,698 categories of which exactly **20** had any stock, so for the
196
+ * overwhelming majority the parts page is the only one with content.
197
+ *
198
+ * Setting this makes the category page declare the parts page canonical, so
199
+ * the two stop competing and the signals consolidate onto the one a visitor
200
+ * actually wants.
201
+ *
202
+ * 🛑 Written by `scripts/gen-category-parts.mjs`, which is the ONLY place
203
+ * that knows both ids — it assigns the category id and the glossary id in
204
+ * the same loop (the pairing `PART_CATEGORY_TO_GLOSSARY_ID` records). Do not
205
+ * re-derive it at a call site from the slug: the id schemes differ by
206
+ * construction and the collision-qualification rule is not invertible.
207
+ *
208
+ * 🛑 A root-relative path (`/parts/disc/0-0wall`), never absolute — the
209
+ * canonical HOST has exactly one definition (appkit.config.js `seo.siteUrl`)
210
+ * and a second one here is Root Cause #81 waiting to happen.
211
+ */
212
+ seoCanonicalPath?: string;
188
213
  /** Condition-keyed description bodies. A SET, because WHS reuses two per category. */
189
214
  descriptionTemplates?: CategoryDescriptionTemplate[];
190
215
  /** What this category hands down to a product filed under it. */
@@ -0,0 +1,33 @@
1
+ /**
2
+ * `loadOptions` adapters for `<PaginatedSelect>` over the PUBLIC taxonomy
3
+ * endpoints.
4
+ *
5
+ * 🛑 Extracted 2026-10-24 because there were already THREE independent copies
6
+ * of this function — `AdminCategoryEditorView`, `AdminProductEditorView` and
7
+ * `CatalogueItemEditorView` — which is the Duplication Framework's Rule of
8
+ * Three trigger, and a fourth was about to be written for the store-creation
9
+ * form. The body below is lifted from the catalogue copy, which is the only
10
+ * one that got both details right (see the two 🛑 notes).
11
+ *
12
+ * The three existing copies are deliberately NOT migrated here: each lives in
13
+ * an editor another session may hold open, and a shared helper with one honest
14
+ * consumer is a better starting point than a four-way edit. They remain as the
15
+ * migration backlog.
16
+ */
17
+ export interface TaxonomyOptionPage {
18
+ items: {
19
+ value: string;
20
+ label: string;
21
+ }[];
22
+ hasMore: boolean;
23
+ }
24
+ /**
25
+ * 🛑 `listingOnly=true` is load-bearing. Without it the picker offers brand
26
+ * and bundle rows as categories — measured against production: "Takara-Tomy",
27
+ * "Original Collector's Set", "Metal Fusion Duo". They share the `categories`
28
+ * collection, and a plain listing category OMITS `categoryType` entirely, so
29
+ * the test cannot be expressed as a query clause; the route applies it in
30
+ * memory.
31
+ */
32
+ export declare const loadCategoryOptions: (q: string, p: number) => Promise<TaxonomyOptionPage>;
33
+ export declare const loadBrandOptions: (q: string, p: number) => Promise<TaxonomyOptionPage>;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * `loadOptions` adapters for `<PaginatedSelect>` over the PUBLIC taxonomy
3
+ * endpoints.
4
+ *
5
+ * 🛑 Extracted 2026-10-24 because there were already THREE independent copies
6
+ * of this function — `AdminCategoryEditorView`, `AdminProductEditorView` and
7
+ * `CatalogueItemEditorView` — which is the Duplication Framework's Rule of
8
+ * Three trigger, and a fourth was about to be written for the store-creation
9
+ * form. The body below is lifted from the catalogue copy, which is the only
10
+ * one that got both details right (see the two 🛑 notes).
11
+ *
12
+ * The three existing copies are deliberately NOT migrated here: each lives in
13
+ * an editor another session may hold open, and a shared helper with one honest
14
+ * consumer is a better starting point than a four-way edit. They remain as the
15
+ * migration backlog.
16
+ */
17
+ import { apiClient } from "../../../http/index.js";
18
+ import { CATEGORY_ENDPOINTS, BRAND_ENDPOINTS } from "../../../constants/api-endpoints.js";
19
+ /**
20
+ * 🛑 PUBLIC endpoints only. A seller-facing picker that reached
21
+ * `/api/admin/categories` would 403 for the very users it exists for, and the
22
+ * failure renders as an empty dropdown rather than an error.
23
+ */
24
+ async function loadOptionsFrom(endpoint, query, page,
25
+ /**
26
+ * Extra query params, passed as an object rather than baked into `endpoint`:
27
+ * this builds its own `?`, so a pre-existing query string would produce
28
+ * `...?listingOnly=true?page=1`.
29
+ */
30
+ extra) {
31
+ const params = new URLSearchParams({ page: String(page), pageSize: "25", ...extra });
32
+ if (query)
33
+ params.set("q", query);
34
+ const res = await apiClient.get(`${endpoint}?${params.toString()}`);
35
+ const data = res ?? {};
36
+ return {
37
+ items: (data.items ?? []).map((c) => ({ value: c.id, label: c.name })),
38
+ hasMore: data.hasMore ?? false,
39
+ };
40
+ }
41
+ /**
42
+ * 🛑 `listingOnly=true` is load-bearing. Without it the picker offers brand
43
+ * and bundle rows as categories — measured against production: "Takara-Tomy",
44
+ * "Original Collector's Set", "Metal Fusion Duo". They share the `categories`
45
+ * collection, and a plain listing category OMITS `categoryType` entirely, so
46
+ * the test cannot be expressed as a query clause; the route applies it in
47
+ * memory.
48
+ */
49
+ export const loadCategoryOptions = (q, p) => loadOptionsFrom(CATEGORY_ENDPOINTS.LIST, q, p, { listingOnly: "true" });
50
+ export const loadBrandOptions = (q, p) => loadOptionsFrom(BRAND_ENDPOINTS.LIST, q, p);
@@ -6,6 +6,8 @@ import { MediaImage } from "../../media/MediaImage.js";
6
6
  import { normalizeRichTextHtml } from "../../../utils/string.formatter.js";
7
7
  import { EVENT_FIELDS } from "../schemas/index.js";
8
8
  import { EventStatusBadge } from "./EventStatusBadge.js";
9
+ import { EventTypeBadge } from "./EventTypeBadge.js";
10
+ import { eventTypeMeta } from "../constants/event-type-meta.js";
9
11
  import { ROUTES } from "../../../next/routing/route-map.js";
10
12
  import { useLongPress } from "../../../react/hooks/useLongPress.js";
11
13
  import { RecordOwnerQuickActions } from "../../admin/components/RecordOwnerQuickActions.js";
@@ -15,16 +17,6 @@ const __P = {
15
17
  const __O = {
16
18
  hidden: "overflow-hidden",
17
19
  };
18
- const TYPE_ICONS = {
19
- sale: "🏷️",
20
- offer: "🎁",
21
- poll: "📊",
22
- survey: "📝",
23
- feedback: "💬",
24
- raffle: "🎟️",
25
- spin_wheel: "🎡",
26
- lottery: "🎰",
27
- };
28
20
  export function EventCard({ event, labels = {}, onParticipate, className = "", selectable = false, isSelected = false, onSelect, renderOwnerActions, }) {
29
21
  const longPress = useLongPress(() => onSelect?.(event.id, !isSelected));
30
22
  const safeTitle = event.title?.trim() || "Untitled event";
@@ -33,8 +25,8 @@ export function EventCard({ event, labels = {}, onParticipate, className = "", s
33
25
  const msLeft = endsAt.getTime() - now.getTime();
34
26
  const daysLeft = Math.max(0, Math.ceil(msLeft / (1000 * 60 * 60 * 24)));
35
27
  const detailHref = String(ROUTES.PUBLIC.EVENT_DETAIL(event.slug ?? event.id));
36
- const card = (_jsxs(Stack, { as: "article", border: "default", rounded: "xl", shadow: "hover-md", gap: "none", className: `group relative h-full overflow-hidden bg-[var(--appkit-color-surface)] ${isSelected ? "border-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(event.id, !isSelected); }, label: isSelected ? "Deselect event" : "Select event", 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" })), _jsx(Link, { href: detailHref, className: "block flex-shrink-0", children: event.coverImageUrl || event.coverImage?.url ? (_jsx(Div, { className: `relative aspect-video ${__O.hidden}`, children: _jsx(MediaImage, { src: event.coverImageUrl || event.coverImage?.url || "", alt: safeTitle, size: "card", className: "transition-transform duration-300 group-hover:scale-105" }) })) : (_jsx(Row, { surface: "muted", className: "aspect-video", align: "center", justify: "center", children: _jsx(Span, { className: "opacity-40", size: "5xl", "aria-hidden": "true", children: TYPE_ICONS[event.type] }) })) }), _jsxs(Stack, { className: `flex-1 ${__P.p4}`, children: [_jsxs(Row, { className: "mb-2", align: "start", justify: "between", gap: "sm", children: [_jsx(Span, { size: "lg", "aria-hidden": "true", children: TYPE_ICONS[event.type] }), _jsx(EventStatusBadge, { status: event.status })] }), _jsx(Link, { href: detailHref, className: "block", children: _jsx(Heading, { level: 3, className: "text-[var(--appkit-color-text)] leading-snug mb-1 group-hover:text-primary transition-colors", size: "base", weight: "semibold", children: safeTitle }) }), _jsx(RichText, { html: normalizeRichTextHtml(event.description ?? ""), proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "mb-3 line-clamp-3 text-[length:var(--appkit-text-sm)] text-[var(--appkit-color-text-muted)]" }), _jsxs(Row, { color: "muted", textSize: "xs", className: "mb-3 mt-auto", align: "center", justify: "between", children: [event.status === EVENT_FIELDS.STATUS_VALUES.ACTIVE &&
37
- daysLeft > 0 && _jsxs(Span, { children: ["\u23F1 ", daysLeft, "d remaining"] }), _jsxs(Span, { children: ["\uD83D\uDC65 ", event.stats.totalEntries, " ", labels.entries ?? "entries"] })] }), event.status === EVENT_FIELDS.STATUS_VALUES.ACTIVE && onParticipate ? (_jsx(Button, { rounded: "lg", type: "button", onClick: () => onParticipate(event), className: "w-full bg-primary py-[var(--appkit-space-2)] text-[length:var(--appkit-text-sm)] font-medium text-white transition-colors hover:bg-primary-600", children: labels.participate ?? "Participate" })) : (_jsxs(TextLink, { rounded: "lg", paddingX: "sm", paddingY: "xs", href: detailHref, layout: "inline-flex", align: "center", justify: "center", gap: "xs", className: "w-full border border-[var(--appkit-color-border)] transition-colors hover:bg-[var(--appkit-color-bg)]", color: "primary", size: "sm", weight: "medium", children: [labels.viewDetails ?? "View details", " \u2192"] }))] })] }));
28
+ const card = (_jsxs(Stack, { as: "article", border: "default", rounded: "xl", shadow: "hover-md", gap: "none", className: `group relative h-full overflow-hidden bg-[var(--appkit-color-surface)] ${isSelected ? "border-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(event.id, !isSelected); }, label: isSelected ? "Deselect event" : "Select event", 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" })), _jsx(Link, { href: detailHref, className: "block flex-shrink-0", children: event.coverImageUrl || event.coverImage?.url ? (_jsx(Div, { className: `relative aspect-video ${__O.hidden}`, children: _jsx(MediaImage, { src: event.coverImageUrl || event.coverImage?.url || "", alt: safeTitle, size: "card", className: "transition-transform duration-300 group-hover:scale-105" }) })) : (_jsx(Row, { surface: "muted", className: "aspect-video", align: "center", justify: "center", children: _jsx(Span, { className: "opacity-40", size: "5xl", "aria-hidden": "true", children: eventTypeMeta(event.type).emoji }) })) }), _jsxs(Stack, { className: `flex-1 ${__P.p4}`, children: [_jsxs(Row, { className: "mb-2", align: "start", justify: "between", gap: "sm", children: [_jsx(EventTypeBadge, { type: event.type }), _jsx(EventStatusBadge, { status: event.status })] }), _jsx(Link, { href: detailHref, className: "block", children: _jsx(Heading, { level: 3, className: "text-[var(--appkit-color-text)] leading-snug mb-1 group-hover:text-primary transition-colors", size: "base", weight: "semibold", children: safeTitle }) }), _jsx(RichText, { html: normalizeRichTextHtml(event.description ?? ""), proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "mb-3 line-clamp-3 text-[length:var(--appkit-text-sm)] text-[var(--appkit-color-text-muted)]" }), _jsxs(Row, { color: "muted", textSize: "xs", className: "mb-3 mt-auto", align: "center", justify: "between", children: [event.status === EVENT_FIELDS.STATUS_VALUES.ACTIVE &&
29
+ daysLeft > 0 && _jsxs(Span, { children: ["\u23F1 ", daysLeft, "d remaining"] }), _jsxs(Span, { children: ["\u2665 ", event.stats.likeCount ?? 0, " ", (event.stats.likeCount ?? 0) === 1 ? "like" : "likes"] })] }), event.status === EVENT_FIELDS.STATUS_VALUES.ACTIVE && onParticipate ? (_jsx(Button, { rounded: "lg", type: "button", onClick: () => onParticipate(event), className: "w-full bg-primary py-[var(--appkit-space-2)] text-[length:var(--appkit-text-sm)] font-medium text-white transition-colors hover:bg-primary-600", children: labels.participate ?? "Participate" })) : (_jsxs(TextLink, { rounded: "lg", paddingX: "sm", paddingY: "xs", href: detailHref, layout: "inline-flex", align: "center", justify: "center", gap: "xs", className: "w-full border border-[var(--appkit-color-border)] transition-colors hover:bg-[var(--appkit-color-bg)]", color: "primary", size: "sm", weight: "medium", children: [labels.viewDetails ?? "View details", " \u2192"] }))] })] }));
38
30
  /*
39
31
  * 🛑 Default, not opt-in. Nothing in either repo ever passed
40
32
  * `renderOwnerActions`, so this toolbar was unreachable on every grid that
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The charts on an event page — whichever ones have real data.
3
+ *
4
+ * 🛑 THE WHOLE SECTION DISAPPEARS WHEN NOTHING QUALIFIES, and each chart
5
+ * independently returns `null` from its own builder rather than rendering an
6
+ * empty frame. A chart says "we measured this"; an axis pair with a flat line
7
+ * says it too, and says something false. An absent chart says "not yet",
8
+ * which is both true and the common case for a new event.
9
+ *
10
+ * `<Chart>` declares its own `"use client"` internally and loads recharts via
11
+ * `dynamic(..., { ssr: false })`, so this file needs no client boundary and a
12
+ * Server Component can mount it directly.
13
+ *
14
+ * @tag domain:events
15
+ * @tag layer:component
16
+ */
17
+ import React from "react";
18
+ import type { ChartSpec } from "../../../ui/components/chart-spec";
19
+ import type { SaleProgress } from "../utils/event-charts";
20
+ export interface EventInsightsProps {
21
+ pollChart?: ChartSpec | null;
22
+ savingsChart?: ChartSpec | null;
23
+ likeTrendChart?: ChartSpec | null;
24
+ saleProgress?: SaleProgress | null;
25
+ heading?: string;
26
+ }
27
+ export declare function EventInsights({ pollChart, savingsChart, likeTrendChart, saleProgress, heading, }: EventInsightsProps): React.JSX.Element | null;
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Chart } from "../../../ui/components/Chart.js";
3
+ import { Grid, Heading, Progress, Row, Stack, Text } from "../../../ui/index.js";
4
+ import { CountdownDisplay } from "../../../ui/components/CountdownDisplay.js";
5
+ export function EventInsights({ pollChart, savingsChart, likeTrendChart, saleProgress, heading = "By the numbers", }) {
6
+ const charts = [savingsChart, pollChart, likeTrendChart].filter((c) => Boolean(c));
7
+ if (charts.length === 0 && !saleProgress)
8
+ return null;
9
+ return (_jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 2, size: "lg", weight: "semibold", color: "primary", children: heading }), saleProgress && (_jsxs(Stack, { gap: "sm", surface: "card", padding: "md", rounded: "xl", border: "default", children: [_jsxs(Row, { align: "center", justify: "between", gap: "sm", wrap: true, children: [_jsx(Text, { size: "sm", weight: "medium", children: saleProgress.endsAt ? "Time remaining" : "This has ended" }), saleProgress.endsAt && (_jsx(Text, { size: "sm", color: "muted", children: _jsx(CountdownDisplay, { targetDate: saleProgress.endsAt, format: "auto" }) }))] }), _jsx(Progress, { value: saleProgress.elapsedPercent, variant: saleProgress.elapsedPercent > 85 ? "warning" : "primary", size: "sm", label: "Sale window elapsed" }), saleProgress.claimedPercent !== null && (_jsxs(_Fragment, { children: [_jsxs(Row, { align: "center", justify: "between", gap: "sm", wrap: true, children: [_jsx(Text, { size: "sm", weight: "medium", children: "Claimed" }), _jsx(Text, { size: "sm", color: "muted", children: saleProgress.claimedLabel })] }), _jsx(Progress, { value: saleProgress.claimedPercent, variant: saleProgress.claimedPercent > 85 ? "error" : "success", size: "sm", label: "Redemptions against the cap" })] }))] })), charts.length > 0 && (_jsx(Grid, { cols: "cardsWide", gap: "lg", children: charts.map((spec) => (_jsx(Chart, { spec: spec, height: 260 }, spec.title ?? spec.caption))) }))] }));
10
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The like toggle. Optimistic, signed-in only, and safe to double-click.
3
+ *
4
+ * @tag domain:events
5
+ * @tag layer:component
6
+ */
7
+ import React from "react";
8
+ export interface EventLikeButtonProps {
9
+ eventId: string;
10
+ /** Server-rendered starting state. */
11
+ initialLiked: boolean;
12
+ initialCount: number;
13
+ /** POSTs the toggle and resolves with the authoritative new state. */
14
+ onToggle: (eventId: string) => Promise<{
15
+ liked: boolean;
16
+ likeCount: number;
17
+ }>;
18
+ size?: "sm" | "md";
19
+ className?: string;
20
+ }
21
+ export declare function EventLikeButton({ eventId, initialLiked, initialCount, onToggle, size, className, }: EventLikeButtonProps): React.JSX.Element;
@@ -0,0 +1,59 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ /**
4
+ * The like toggle. Optimistic, signed-in only, and safe to double-click.
5
+ *
6
+ * @tag domain:events
7
+ * @tag layer:component
8
+ */
9
+ import { useCallback, useState, useTransition } from "react";
10
+ import { Button, Row, Span, Icon } from "../../../ui/index.js";
11
+ import { PressScale } from "../../../ui/components/Motion.js";
12
+ import { useOptionalSession } from "../../../react/contexts/SessionContext.js";
13
+ import { LoginRequiredModal } from "../../../ui/components/LoginRequiredModal.js";
14
+ import { normalizeError } from "../../../errors/normalize.js";
15
+ export function EventLikeButton({ eventId, initialLiked, initialCount, onToggle, size = "md", className = "", }) {
16
+ const session = useOptionalSession();
17
+ const [liked, setLiked] = useState(initialLiked);
18
+ const [count, setCount] = useState(initialCount);
19
+ const [showLogin, setShowLogin] = useState(false);
20
+ const [pending, startTransition] = useTransition();
21
+ const handleClick = useCallback(() => {
22
+ /*
23
+ * 🛑 THREE STATES, NOT TWO. `SessionProvider` mounts `initialUser={null}`
24
+ * because the root locale layout must stay static (Root Cause #82), so
25
+ * `loading` is true on every hard load FOR SIGNED-IN USERS TOO. Testing
26
+ * only `!user` would open the sign-in modal at a buyer who is already
27
+ * signed in, on every first click after a page load — the exact defect
28
+ * Root Cause #79 records for the wishlist heart.
29
+ */
30
+ if (session?.loading)
31
+ return;
32
+ if (!session?.user?.uid) {
33
+ setShowLogin(true);
34
+ return;
35
+ }
36
+ // Optimistic: flip now, reconcile from the server response. A like is
37
+ // cheap to be briefly wrong about and the latency is what people notice.
38
+ const nextLiked = !liked;
39
+ setLiked(nextLiked);
40
+ setCount((c) => Math.max(0, c + (nextLiked ? 1 : -1)));
41
+ startTransition(() => {
42
+ void onToggle(eventId)
43
+ .then((res) => {
44
+ // The server is authoritative — it holds the real count, which may
45
+ // have moved for other reasons between paint and click.
46
+ setLiked(res.liked);
47
+ setCount(res.likeCount);
48
+ })
49
+ .catch((err) => {
50
+ void normalizeError(err);
51
+ // Roll back rather than leaving a lie on screen.
52
+ setLiked(liked);
53
+ setCount(initialCount);
54
+ });
55
+ });
56
+ }, [session, liked, initialCount, eventId, onToggle]);
57
+ const label = liked ? "Liked" : "Like";
58
+ return (_jsxs(_Fragment, { children: [_jsx(PressScale, { children: _jsx(Button, { type: "button", variant: liked ? "primary" : "outline", size: size, onClick: handleClick, isLoading: pending, "aria-pressed": liked, "aria-label": `${label} this event — ${count} ${count === 1 ? "person likes" : "people like"} it`, className: className, children: _jsxs(Row, { align: "center", gap: "xs", children: [_jsx(Icon, { name: "wishlist", filled: liked }), _jsx(Span, { weight: "medium", children: label }), _jsx(Span, { numeric: true, children: count })] }) }) }), _jsx(LoginRequiredModal, { isOpen: showLogin, onClose: () => setShowLogin(false), message: "Sign in to like this event and see who else has." })] }));
59
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * "Liked by" — the last ten faces, then a way to see the rest.
3
+ *
4
+ * @tag domain:events
5
+ * @tag layer:component
6
+ */
7
+ import React from "react";
8
+ import type { PublicLiker } from "../schemas/likes";
9
+ export interface EventLikersRailProps {
10
+ /** The most recent likers, server-rendered. */
11
+ likers: PublicLiker[];
12
+ /** The event's total like count — usually larger than `likers.length`. */
13
+ totalCount: number;
14
+ /** True when more exist past the preview. */
15
+ hasMore: boolean;
16
+ /** Fetches one more page for the modal. */
17
+ loadMore?: (offset: number) => Promise<{
18
+ likers: PublicLiker[];
19
+ hasMore: boolean;
20
+ }>;
21
+ className?: string;
22
+ }
23
+ export declare function EventLikersRail({ likers, totalCount, hasMore, loadMore, className, }: EventLikersRailProps): React.JSX.Element | null;
@@ -0,0 +1,54 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ /**
4
+ * "Liked by" — the last ten faces, then a way to see the rest.
5
+ *
6
+ * @tag domain:events
7
+ * @tag layer:component
8
+ */
9
+ import { useCallback, useState } from "react";
10
+ import { AvatarGroup, Button, Modal, Row, Stack, Text, Span, Avatar } from "../../../ui/index.js";
11
+ import { LIKERS_PAGE_SIZE } from "../schemas/likes.js";
12
+ import { normalizeError } from "../../../errors/normalize.js";
13
+ /** "Aarav, Riya and 7 others" — names first, count only for the remainder. */
14
+ function summarise(likers, total) {
15
+ if (likers.length === 0)
16
+ return "";
17
+ const shown = likers.slice(0, 2).map((l) => l.displayName);
18
+ const others = Math.max(0, total - shown.length);
19
+ if (others === 0)
20
+ return shown.join(" and ");
21
+ return `${shown.join(", ")} and ${others} other${others === 1 ? "" : "s"}`;
22
+ }
23
+ export function EventLikersRail({ likers, totalCount, hasMore, loadMore, className = "", }) {
24
+ const [open, setOpen] = useState(false);
25
+ const [all, setAll] = useState(likers);
26
+ const [more, setMore] = useState(hasMore);
27
+ const [loading, setLoading] = useState(false);
28
+ const handleLoadMore = useCallback(() => {
29
+ if (!loadMore || loading)
30
+ return;
31
+ setLoading(true);
32
+ void loadMore(all.length)
33
+ .then((page) => {
34
+ setAll((prev) => [...prev, ...page.likers]);
35
+ setMore(page.hasMore);
36
+ })
37
+ .catch((err) => {
38
+ void normalizeError(err);
39
+ // Stop offering a button that is not working, rather than letting it
40
+ // sit there doing nothing on every press.
41
+ setMore(false);
42
+ })
43
+ .finally(() => setLoading(false));
44
+ }, [loadMore, loading, all.length]);
45
+ /*
46
+ * Nothing to show. Deliberately renders null rather than "Be the first to
47
+ * like this" — an empty social proof row advertises that nobody has, which
48
+ * is worse for the event than saying nothing at all. The like BUTTON is
49
+ * still there; this is only the rail beside it.
50
+ */
51
+ if (likers.length === 0)
52
+ return null;
53
+ return (_jsxs(_Fragment, { children: [_jsxs(Row, { align: "center", gap: "sm", className: className, children: [_jsx(AvatarGroup, { size: "sm", max: likers.length, avatars: likers.map((l) => ({ src: l.photoURL, name: l.displayName, alt: l.displayName })) }), _jsxs(Text, { size: "sm", color: "muted", children: ["Liked by ", _jsx(Span, { weight: "medium", children: summarise(likers, totalCount) })] }), (hasMore || totalCount > likers.length) && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => setOpen(true), children: "View all" }))] }), _jsx(Modal, { isOpen: open, onClose: () => setOpen(false), title: `Liked by ${totalCount}`, children: _jsxs(Stack, { gap: "sm", children: [all.map((l) => (_jsxs(Row, { align: "center", gap: "sm", children: [_jsx(Avatar, { size: "sm", src: l.photoURL, name: l.displayName, alt: l.displayName }), _jsx(Text, { size: "sm", children: l.displayName })] }, l.uid))), more && loadMore && (_jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: handleLoadMore, isLoading: loading, children: ["Show ", LIKERS_PAGE_SIZE, " more"] }))] }) })] }));
54
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The event-type chip. Emoji plus a real label, always both.
3
+ *
4
+ * 🛑 THE LABEL IS NOT OPTIONAL, and that is the accessibility half of this
5
+ * component. `EventCard` rendered the type as a bare `aria-hidden` emoji with
6
+ * no text companion anywhere on the card, so a screen-reader user got no
7
+ * event type at all; the detail hero rendered the raw enum. Neither surface
8
+ * could reach the other's map. This is the shared one.
9
+ *
10
+ * @tag domain:events
11
+ * @tag layer:component
12
+ */
13
+ import React from "react";
14
+ export interface EventTypeBadgeProps {
15
+ type: string | undefined;
16
+ /**
17
+ * Hide the text and keep only the glyph — for a dense card corner where the
18
+ * label is already rendered nearby.
19
+ *
20
+ * 🛑 Even then an `aria-label` carries the real label, so the type is never
21
+ * lost to a screen reader. A silent emoji is not a badge.
22
+ */
23
+ compact?: boolean;
24
+ className?: string;
25
+ }
26
+ export declare function EventTypeBadge({ type, compact, className }: EventTypeBadgeProps): React.JSX.Element;
@@ -0,0 +1,7 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Span } from "../../../ui/index.js";
3
+ import { eventTypeMeta } from "../constants/event-type-meta.js";
4
+ export function EventTypeBadge({ type, compact = false, className = "" }) {
5
+ const meta = eventTypeMeta(type);
6
+ return (_jsxs(Span, { layout: "inline-flex", align: "center", gap: "xs", padding: "pill-sm", rounded: "full", size: "xs", weight: "semibold", className: `${meta.chipClass} ${className}`, "aria-label": compact ? meta.label : undefined, children: [_jsx(Span, { "aria-hidden": "true", children: meta.emoji }), !compact && meta.label] }));
7
+ }
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { eventRepository } from "../../../repositories/index.js";
3
3
  import { AdSlot } from "../../homepage/components/AdSlot.js";
4
- import { Container, Heading, Main, Section } from "../../../ui/index.js";
4
+ import { Container, Main, Section, PageHeroHeading } from "../../../ui/index.js";
5
5
  import { EventsIndexListing } from "./EventsIndexListing.js";
6
6
  import { EVENT_FIELDS } from "../../../constants/field-names.js";
7
7
  import { sieveFilter, sieveMultiEq, sieveAnd, SIEVE_OP } from "../../../utils/sieve-builder.js";
@@ -72,5 +72,5 @@ export async function EventsListPageView({ searchParams = {} }) {
72
72
  const publicResult = result
73
73
  ? { ...result, items: sortPinnedFirst(hidePublicTestData(result.items)) }
74
74
  : result;
75
- return (_jsx(Main, { children: _jsx(Section, { padding: "y-2xl", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 1, className: "mb-8", color: "primary", size: "3xl", weight: "semibold", children: "Events" }), _jsx(AdSlot, { id: "listing-sidebar-top", className: "mb-6" }), _jsx(EventsIndexListing, { initialData: publicResult, facetCounts: facetCounts }), _jsx(AdSlot, { id: "listing-sidebar-bottom", className: "mt-8" })] }) }) }));
75
+ return (_jsx(Main, { children: _jsx(Section, { padding: "y-2xl", children: _jsxs(Container, { size: "xl", children: [_jsx(PageHeroHeading, { className: "mb-8", pill: "Events & Offers", title: "Events", description: "Sales, spin wheels, raffles and polls \u2014 everything running right now, and what is opening next.", meta: publicResult?.total ? `${publicResult.total} running` : undefined }), _jsx(AdSlot, { id: "listing-sidebar-top", className: "mb-6" }), _jsx(EventsIndexListing, { initialData: publicResult, facetCounts: facetCounts }), _jsx(AdSlot, { id: "listing-sidebar-bottom", className: "mt-8" })] }) }) }));
76
76
  }