@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.
- package/dist/.build-stamp.json +3 -3
- package/dist/_internal/server/features/categories/adapters.d.ts +1 -1
- package/dist/_internal/server/features/categories/adapters.js +13 -0
- package/dist/_internal/server/features/events/like-actions.d.ts +25 -0
- package/dist/_internal/server/features/events/like-actions.js +67 -0
- package/dist/_internal/server/features/events/likes.d.ts +65 -0
- package/dist/_internal/server/features/events/likes.js +146 -0
- package/dist/_internal/server/features/seo/index.d.ts +3 -0
- package/dist/_internal/server/features/seo/index.js +2 -0
- package/dist/_internal/server/features/seo/product-detail-types.d.ts +1 -0
- package/dist/_internal/server/features/seo/product-detail-types.js +26 -0
- package/dist/_internal/server/features/seo/robots.js +20 -1
- package/dist/_internal/server/features/seo/sitemap.js +125 -27
- package/dist/_internal/server/features/seo/static-params.d.ts +107 -0
- package/dist/_internal/server/features/seo/static-params.js +228 -0
- package/dist/_internal/server/features/seo/taxonomy-links.d.ts +44 -0
- package/dist/_internal/server/features/seo/taxonomy-links.js +85 -0
- package/dist/_internal/server/features/stores/adapters.d.ts +2 -2
- package/dist/_internal/server/features/stores/adapters.js +87 -1
- package/dist/_internal/server/functions/scheduled.d.ts +16 -1
- package/dist/_internal/server/functions/scheduled.js +23 -1
- package/dist/_internal/server/jobs/core/sellerStandingRollup.d.ts +63 -0
- package/dist/_internal/server/jobs/core/sellerStandingRollup.js +412 -0
- package/dist/_internal/server/jobs/handlers/index.d.ts +1 -0
- package/dist/_internal/server/jobs/handlers/index.js +1 -0
- package/dist/_internal/server/jobs/handlers/sellerStandingRollup.d.ts +12 -0
- package/dist/_internal/server/jobs/handlers/sellerStandingRollup.js +14 -0
- package/dist/_internal/shared/actions/action-registry.js +61 -0
- package/dist/_internal/shared/features/seller-standing/config.d.ts +130 -0
- package/dist/_internal/shared/features/seller-standing/config.js +153 -0
- package/dist/constants/api-endpoints.d.ts +24 -0
- package/dist/constants/api-endpoints.js +8 -0
- package/dist/features/about/components/HelpPageView.js +9 -1
- package/dist/features/account/components/BecomeSellerView.d.ts +15 -3
- package/dist/features/account/components/BecomeSellerView.js +2 -2
- package/dist/features/admin/components/AdminStoreEditorView.d.ts +12 -1
- package/dist/features/admin/components/AdminStoreEditorView.js +30 -2
- package/dist/features/admin/components/AdminStoresGuideView.js +1 -1
- package/dist/features/admin/components/AdminStoresView.js +59 -2
- package/dist/features/admin/components/AdminTrustGuideView.js +10 -0
- package/dist/features/admin/schemas/admin-editor-forms.d.ts +10 -0
- package/dist/features/admin/schemas/admin-editor-forms.js +34 -0
- package/dist/features/blog/components/BlogIndexPageView.js +2 -2
- package/dist/features/blog/components/BlogListView.js +2 -1
- package/dist/features/blog/components/BlogPostView.js +2 -1
- package/dist/features/blog/constants/category-badge.d.ts +33 -0
- package/dist/features/blog/constants/category-badge.js +40 -0
- package/dist/features/categories/components/TaxonomyLinkIndex.d.ts +39 -0
- package/dist/features/categories/components/TaxonomyLinkIndex.js +11 -0
- package/dist/features/categories/schemas/firestore.d.ts +25 -0
- package/dist/features/categories/utils/load-category-options.d.ts +33 -0
- package/dist/features/categories/utils/load-category-options.js +50 -0
- package/dist/features/events/components/EventCard.js +4 -12
- package/dist/features/events/components/EventInsights.d.ts +27 -0
- package/dist/features/events/components/EventInsights.js +10 -0
- package/dist/features/events/components/EventLikeButton.d.ts +21 -0
- package/dist/features/events/components/EventLikeButton.js +59 -0
- package/dist/features/events/components/EventLikersRail.d.ts +23 -0
- package/dist/features/events/components/EventLikersRail.js +54 -0
- package/dist/features/events/components/EventTypeBadge.d.ts +26 -0
- package/dist/features/events/components/EventTypeBadge.js +7 -0
- package/dist/features/events/components/EventsListPageView.js +2 -2
- package/dist/features/events/components/SpinWheel.d.ts +41 -0
- package/dist/features/events/components/SpinWheel.js +128 -0
- package/dist/features/events/components/SpinWheelView.js +36 -15
- package/dist/features/events/constants/event-type-meta.d.ts +54 -0
- package/dist/features/events/constants/event-type-meta.js +109 -0
- package/dist/features/events/repository/event-likes.repository.d.ts +57 -0
- package/dist/features/events/repository/event-likes.repository.js +151 -0
- package/dist/features/events/schemas/firestore.d.ts +111 -0
- package/dist/features/events/schemas/likes.d.ts +81 -0
- package/dist/features/events/schemas/likes.js +59 -0
- package/dist/features/events/types/index.d.ts +7 -0
- package/dist/features/events/utils/carousel-slide.d.ts +42 -0
- package/dist/features/events/utils/carousel-slide.js +174 -0
- package/dist/features/events/utils/event-charts.d.ts +65 -0
- package/dist/features/events/utils/event-charts.js +172 -0
- package/dist/features/faq/constants/help-guides.js +9 -0
- package/dist/features/faq/schemas/firestore.js +1 -0
- package/dist/features/faq/types/index.d.ts +19 -0
- package/dist/features/homepage/components/BrandsSection.js +25 -1
- package/dist/features/homepage/components/HeroCarousel.js +47 -10
- package/dist/features/homepage/types/index.d.ts +7 -0
- package/dist/features/stores/components/InteractiveStoreCard.js +2 -1
- package/dist/features/stores/components/SellerStandingPanel.d.ts +31 -0
- package/dist/features/stores/components/SellerStandingPanel.js +25 -0
- package/dist/features/stores/components/SellerTierBadge.d.ts +57 -0
- package/dist/features/stores/components/SellerTierBadge.js +19 -0
- package/dist/features/stores/components/StoreCapabilitiesGuideView.js +32 -2
- package/dist/features/stores/components/StoreCreateForm.d.ts +46 -0
- package/dist/features/stores/components/StoreCreateForm.js +107 -0
- package/dist/features/stores/components/StoreGuideHubView.js +17 -4
- package/dist/features/stores/components/StoreHeader.js +2 -1
- package/dist/features/stores/repository/store.repository.js +23 -0
- package/dist/features/stores/schemas/firestore.d.ts +60 -1
- package/dist/features/stores/schemas/firestore.js +18 -0
- package/dist/features/stores/schemas/index.d.ts +2 -2
- package/dist/features/stores/schemas/store-create.d.ts +47 -0
- package/dist/features/stores/schemas/store-create.js +86 -0
- package/dist/features/stores/types/index.d.ts +42 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +21 -0
- package/dist/next/routing/route-map.d.ts +3 -1
- package/dist/next/routing/route-map.js +13 -0
- package/dist/schemas/registry.d.ts +10 -0
- package/dist/server.d.ts +5 -0
- package/dist/server.js +10 -0
- package/dist/styles.css +36 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/HorizontalScroller.js +15 -2
- package/dist/ui/components/PageHeroHeading.d.ts +40 -0
- package/dist/ui/components/PageHeroHeading.js +15 -0
- package/dist/ui/components/Typography.d.ts +3 -3
- package/dist/ui/components/Typography.js +59 -31
- package/dist/ui/components/Typography.style.css +35 -0
- package/dist/ui/index.d.ts +2 -0
- package/dist/ui/index.js +1 -0
- package/package.json +1 -1
- 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:
|
|
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-
|
|
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:
|
|
37
|
-
daysLeft > 0 && _jsxs(Span, { children: ["\u23F1 ", daysLeft, "d remaining"] }), _jsxs(Span, { children: ["\
|
|
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,
|
|
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(
|
|
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
|
}
|