Match Home, top nav, hero, and logo wall to the current marketing frames.
Puts Workshops in the header, pairs the hero with a static rule card and breakpoint CTAs, and shows the three pilot marks with no wall label. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
e12e06f7ce
commit
33cdaf135c
47 files changed
+728
-527
No files matched your search
@@ -51,9 +51,6 @@ export default function Page() {
|
||||
title: t("pages.home.heroBanner.title"),
|
||||
subtitle: t("pages.home.heroBanner.subtitle"),
|
||||
description: t("pages.home.heroBanner.description"),
|
||||
ctaText: t("pages.home.heroBanner.ctaText"),
|
||||
ctaHref: t("pages.home.heroBanner.ctaHref"),
|
||||
imageAlt: t("heroBanner.imageAlt"),
|
||||
};
|
||||
|
||||
const cardStepsData = {
|
||||
|
||||
@@ -95,11 +95,15 @@ const Logo = memo<LogoProps>(
|
||||
const wordmarkVisibilityClass =
|
||||
size === "topNavHeader"
|
||||
? wordmark
|
||||
? "hidden md:block"
|
||||
? "hidden sm:block"
|
||||
: "hidden"
|
||||
: wordmark
|
||||
? ""
|
||||
: "hidden";
|
||||
: size === "topNavFolderTop"
|
||||
? wordmark
|
||||
? "hidden md:block"
|
||||
: "hidden"
|
||||
: wordmark
|
||||
? ""
|
||||
: "hidden";
|
||||
|
||||
return (
|
||||
<Link href="/" className="group block" aria-label={t("logoAlt")}>
|
||||
|
||||
@@ -43,6 +43,7 @@ const RuleContainer = memo<RuleProps>(
|
||||
logoAlt,
|
||||
communityInitials,
|
||||
hideCategoryAddButton = false,
|
||||
interactive,
|
||||
hasBottomLinks = false,
|
||||
bottomStatusLabel,
|
||||
bottomLinks,
|
||||
@@ -53,12 +54,13 @@ const RuleContainer = memo<RuleProps>(
|
||||
surfaceColor,
|
||||
}) => {
|
||||
const size = sizeProp ?? "L";
|
||||
const isInteractive = interactive ?? !hasBottomLinks;
|
||||
const t = useTranslation("ruleCard");
|
||||
const cardAriaLabel = t("ariaLabel")?.replace("{title}", title) || title;
|
||||
const recommendedLabel = t("recommendedLabel");
|
||||
|
||||
const handleClick = () => {
|
||||
if (hasBottomLinks) return;
|
||||
if (!isInteractive || hasBottomLinks) return;
|
||||
// Basic analytics event tracking
|
||||
if (typeof window !== "undefined" && window.gtag) {
|
||||
window.gtag("event", "template_selected", {
|
||||
@@ -79,7 +81,7 @@ const RuleContainer = memo<RuleProps>(
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (hasBottomLinks) return;
|
||||
if (!isInteractive || hasBottomLinks) return;
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
handleClick();
|
||||
@@ -98,9 +100,11 @@ const RuleContainer = memo<RuleProps>(
|
||||
icon={icon}
|
||||
backgroundColor={backgroundColor}
|
||||
className={className}
|
||||
href={hasBottomLinks ? undefined : href}
|
||||
onClick={hasBottomLinks ? undefined : handleClick}
|
||||
onKeyDown={hasBottomLinks || href ? undefined : handleKeyDown}
|
||||
href={isInteractive && !hasBottomLinks ? href : undefined}
|
||||
onClick={isInteractive && !hasBottomLinks ? handleClick : undefined}
|
||||
onKeyDown={
|
||||
isInteractive && !hasBottomLinks && !href ? handleKeyDown : undefined
|
||||
}
|
||||
expanded={expanded}
|
||||
size={size}
|
||||
categories={categories}
|
||||
@@ -108,6 +112,7 @@ const RuleContainer = memo<RuleProps>(
|
||||
logoAlt={logoAlt}
|
||||
communityInitials={communityInitials}
|
||||
hideCategoryAddButton={hideCategoryAddButton}
|
||||
interactive={isInteractive}
|
||||
hasBottomLinks={hasBottomLinks}
|
||||
bottomStatusLabel={bottomStatusLabel}
|
||||
bottomLinks={bottomLinks}
|
||||
|
||||
@@ -63,6 +63,11 @@ export interface RuleProps {
|
||||
communityInitials?: string;
|
||||
/** Hide the per-category "+" add chip affordance (e.g. read-only template review). */
|
||||
hideCategoryAddButton?: boolean;
|
||||
/**
|
||||
* When false, the card is a static article (Home hero). Default follows
|
||||
* {@link hasBottomLinks}: interactive unless bottom links are shown.
|
||||
*/
|
||||
interactive?: boolean;
|
||||
/**
|
||||
* Figma `Card / Rule` variant: description + optional status chip + text links
|
||||
* (e.g. Duplicate / Delete, or Continue / Start new rule). When set, the card
|
||||
@@ -117,6 +122,7 @@ export interface RuleViewProps {
|
||||
logoAlt?: string;
|
||||
communityInitials?: string;
|
||||
hideCategoryAddButton?: boolean;
|
||||
interactive?: boolean;
|
||||
hasBottomLinks?: boolean;
|
||||
bottomStatusLabel?: string;
|
||||
bottomLinks?: RuleBottomLink[];
|
||||
|
||||
@@ -29,6 +29,7 @@ export function RuleView({
|
||||
logoAlt,
|
||||
communityInitials,
|
||||
hideCategoryAddButton = false,
|
||||
interactive,
|
||||
hasBottomLinks = false,
|
||||
bottomStatusLabel,
|
||||
bottomLinks,
|
||||
@@ -41,7 +42,7 @@ export function RuleView({
|
||||
recommendedLabel,
|
||||
}: RuleViewProps) {
|
||||
const ariaLabel = cardAriaLabel;
|
||||
const interactiveCard = !hasBottomLinks;
|
||||
const interactiveCard = interactive ?? !hasBottomLinks;
|
||||
|
||||
// Size-based styling
|
||||
const isLarge = size === "L";
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Figma: "Navigation / Top" (22078-808559); Folder-top 430–639 (18582:22056),
|
||||
* 640–1023 (18580:17113).
|
||||
* Figma: "Navigation / Top" (5944:17779); Folder-top 320–429 (18583:22098),
|
||||
* 430–639 (18582:22056); standard 320–429 (18411:57843).
|
||||
*/
|
||||
|
||||
import { memo, useCallback } from "react";
|
||||
@@ -82,7 +82,7 @@ const TopContainer = memo<TopProps>(
|
||||
const logoSize = folderTop ? "topNavFolderTop" : "topNavHeader";
|
||||
|
||||
const navigationItems = [
|
||||
{ href: "/use-cases", text: t("navigation.useCases"), extraPadding: true },
|
||||
{ href: "/workshops", text: t("navigation.workshops") },
|
||||
{ href: "/learn", text: t("navigation.learn") },
|
||||
{ href: "/about", text: t("navigation.about") },
|
||||
];
|
||||
@@ -93,30 +93,25 @@ const TopContainer = memo<TopProps>(
|
||||
? FOLDER_NAV_ITEM_RESPONSIVE_CLASS
|
||||
: NAV_ITEM_RESPONSIVE_CLASS;
|
||||
|
||||
return navigationItems.map((item) => {
|
||||
const isUseCases = item.extraPadding === true;
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
size="X Small"
|
||||
mode={mode}
|
||||
state={pathname === item.href ? "selected" : "default"}
|
||||
reducedPadding={isUseCases}
|
||||
className={sizeClass}
|
||||
ariaLabel={t("ariaLabels.navigateToPage").replace(
|
||||
"{text}",
|
||||
item.text,
|
||||
)}
|
||||
>
|
||||
{item.text}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
return navigationItems.map((item) => (
|
||||
<MenuItem
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
size="X Small"
|
||||
mode={mode}
|
||||
state={pathname === item.href ? "selected" : "default"}
|
||||
className={sizeClass}
|
||||
ariaLabel={t("ariaLabels.navigateToPage").replace(
|
||||
"{text}",
|
||||
item.text,
|
||||
)}
|
||||
>
|
||||
{item.text}
|
||||
</MenuItem>
|
||||
));
|
||||
};
|
||||
|
||||
const renderLoginButton = () => {
|
||||
const renderLoginButton = (className = "") => {
|
||||
const label = loggedIn ? t("buttons.profile") : t("buttons.logIn");
|
||||
const ariaLabel = loggedIn
|
||||
? t("ariaLabels.goToProfile")
|
||||
@@ -124,16 +119,23 @@ const TopContainer = memo<TopProps>(
|
||||
const navSelected =
|
||||
(loggedIn && pathname === "/profile") ||
|
||||
(!loggedIn && pathname === "/login");
|
||||
const loginClassName = folderTop
|
||||
? FOLDER_NAV_ITEM_RESPONSIVE_CLASS
|
||||
: NAV_ITEM_RESPONSIVE_CLASS;
|
||||
const loginClassName = [
|
||||
folderTop
|
||||
? FOLDER_NAV_ITEM_RESPONSIVE_CLASS
|
||||
: NAV_ITEM_RESPONSIVE_CLASS,
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const inFolderTab = folderTop && className.includes("md:hidden");
|
||||
const mode = inFolderTab ? "inverse" : "default";
|
||||
|
||||
if (loggedIn) {
|
||||
return (
|
||||
<MenuItem
|
||||
href="/profile"
|
||||
size="X Small"
|
||||
mode="default"
|
||||
mode={mode}
|
||||
state={navSelected ? "selected" : "default"}
|
||||
className={loginClassName}
|
||||
ariaLabel={ariaLabel}
|
||||
@@ -154,7 +156,7 @@ const TopContainer = memo<TopProps>(
|
||||
}
|
||||
href="/login"
|
||||
size="X Small"
|
||||
mode="default"
|
||||
mode={mode}
|
||||
state={navSelected ? "selected" : "default"}
|
||||
className={loginClassName}
|
||||
ariaLabel={ariaLabel}
|
||||
@@ -182,8 +184,8 @@ const TopContainer = memo<TopProps>(
|
||||
size="small"
|
||||
className={
|
||||
folderTop
|
||||
? "md:-space-x-[9px] lg:-space-x-[var(--space-250)] xl:-space-x-[13px]"
|
||||
: "lg:-space-x-[var(--space-250)] xl:-space-x-[13px]"
|
||||
? "max-sm:hidden md:-space-x-[9px] lg:-space-x-[var(--space-250)] xl:-space-x-[13px]"
|
||||
: "max-sm:hidden lg:-space-x-[var(--space-250)] xl:-space-x-[13px]"
|
||||
}
|
||||
>
|
||||
{avatarImageSources.map((src, index) => (
|
||||
|
||||
@@ -28,6 +28,6 @@ export interface TopViewProps {
|
||||
};
|
||||
logoSize: "topNavFolderTop" | "topNavHeader";
|
||||
renderNavigationItems: () => React.ReactNode;
|
||||
renderLoginButton: () => React.ReactNode;
|
||||
renderLoginButton: (_className?: string) => React.ReactNode;
|
||||
renderCreateRuleButton: () => React.ReactNode;
|
||||
}
|
||||
@@ -22,8 +22,9 @@ function TopView({
|
||||
}: TopViewProps) {
|
||||
const t = useTranslation(folderTop ? "homeHeader" : "header");
|
||||
|
||||
const loginControl = logIn ? (
|
||||
<Menu size="X Small" className="lg:gap-[var(--space-300)]">
|
||||
const phoneAuthItem = logIn ? renderLoginButton("md:hidden") : null;
|
||||
const desktopAuthItem = logIn ? (
|
||||
<Menu size="X Small" className="hidden md:flex lg:gap-[var(--space-300)]">
|
||||
{renderLoginButton()}
|
||||
</Menu>
|
||||
) : null;
|
||||
@@ -40,13 +41,14 @@ function TopView({
|
||||
className="w-full overflow-visible bg-transparent"
|
||||
role="banner"
|
||||
aria-label={t("ariaLabels.homePageNavigationHeader")}
|
||||
data-figma-node="5944-17779"
|
||||
>
|
||||
<nav
|
||||
className="relative mx-auto flex h-[50px] items-end justify-between pl-[var(--space-200)] pr-[var(--space-400)] pt-[var(--space-250)] sm:h-[62px] sm:px-[var(--space-250)] sm:pr-[var(--space-500)] sm:pt-[var(--space-250)] md:h-[68px] md:px-[var(--space-400)] md:pr-[var(--space-800)] md:pt-[var(--space-400)] lg:h-[68px] lg:pl-[var(--space-600)] lg:pr-[var(--space-1400)] lg:pt-[var(--space-400)] xl:h-[88px] xl:pl-[var(--space-1200)] xl:pr-[var(--space-1400)] xl:pt-[var(--space-600)]"
|
||||
role="navigation"
|
||||
aria-label={t("ariaLabels.mainNavigation")}
|
||||
>
|
||||
<div className="HeaderTab header-breakpoint-transition relative flex h-[var(--space-1000)] w-fit min-w-0 items-center self-end overflow-visible rounded-tl-[var(--radius-400)] rounded-tr-[var(--radius-400)] bg-[var(--color-surface-invert-brand-primary)] pl-[var(--space-300)] pr-[var(--space-300)] sm:mr-[var(--space-200)] sm:h-[52px] sm:w-auto sm:flex-1 sm:rounded-t-[var(--radius-500)] sm:pr-[var(--space-150)] md:h-[52px] md:min-w-0 md:rounded-t-[var(--radius-500)] md:pl-[var(--space-600)] md:pr-[var(--space-300)] lg:mr-[var(--space-600)] lg:h-[52px] lg:rounded-t-[var(--radius-500)] lg:pl-[var(--space-600)] lg:pr-[var(--space-1200)] xl:mr-[var(--space-800)] xl:h-[64px] xl:rounded-t-[var(--radius-500)] xl:pl-[var(--space-800)] xl:pr-[var(--space-3000)]">
|
||||
<div className="HeaderTab header-breakpoint-transition relative flex h-[var(--space-1000)] min-w-0 flex-1 items-center justify-between self-end overflow-visible rounded-tl-[var(--radius-400)] rounded-tr-[var(--radius-400)] bg-[var(--color-surface-invert-brand-primary)] pl-[var(--space-300)] pr-[var(--space-300)] sm:mr-[var(--space-200)] sm:h-[52px] sm:rounded-t-[var(--radius-500)] sm:pr-[var(--space-150)] md:h-[52px] md:rounded-t-[var(--radius-500)] md:pl-[var(--space-600)] md:pr-[var(--space-300)] lg:mr-[var(--space-600)] lg:h-[52px] lg:rounded-t-[var(--radius-500)] lg:pl-[var(--space-600)] lg:pr-[var(--space-1200)] xl:mr-[var(--space-800)] xl:h-[64px] xl:rounded-t-[var(--radius-500)] xl:pl-[var(--space-800)] xl:pr-[var(--space-3000)]">
|
||||
<div className="relative z-[1] shrink-0">
|
||||
<Logo
|
||||
size={logoSize}
|
||||
@@ -56,7 +58,7 @@ function TopView({
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative z-[1] shrink-0 md:absolute md:top-1/2 md:left-[calc(50vw-var(--space-400))] md:-translate-x-1/2 md:-translate-y-1/2 lg:left-[calc(50vw-var(--space-600))] xl:left-[calc(50vw-var(--space-1200))]"
|
||||
className="relative z-[1] min-w-0 md:absolute md:top-1/2 md:left-[calc(50vw-var(--space-400))] md:-translate-x-1/2 md:-translate-y-1/2 lg:left-[calc(50vw-var(--space-600))] xl:left-[calc(50vw-var(--space-1200))]"
|
||||
data-top="nav"
|
||||
>
|
||||
<Menu
|
||||
@@ -64,6 +66,7 @@ function TopView({
|
||||
className="md:gap-[var(--space-100)] lg:gap-[var(--space-300)]"
|
||||
>
|
||||
{renderNavigationItems()}
|
||||
{phoneAuthItem}
|
||||
</Menu>
|
||||
</div>
|
||||
|
||||
@@ -94,7 +97,7 @@ function TopView({
|
||||
className="flex shrink-0 items-center gap-[var(--space-100)] self-center md:gap-[var(--space-250)]"
|
||||
data-top="auth"
|
||||
>
|
||||
{loginControl}
|
||||
{desktopAuthItem}
|
||||
{renderCreateRuleButton()}
|
||||
</div>
|
||||
</nav>
|
||||
@@ -105,11 +108,10 @@ function TopView({
|
||||
|
||||
/**
|
||||
* Standard marketing / app top nav.
|
||||
* Figma: "Navigation / Top" (Community-Rule-System, node 22078-808559).
|
||||
* Below md: auto | 1fr | auto so the logo cannot paint over the cluster.
|
||||
* From md: 1fr | auto | 1fr so the cluster sits on the same page midline
|
||||
* as Folder-top (Create rule is wider than the logo, so a leftover-column
|
||||
* center sits off-center).
|
||||
* Figma: "Navigation / Top" (Community-Rule-System, node 5944:17779).
|
||||
* Below md: logo | links (with Log in / Profile) | Create rule, so the mark
|
||||
* cannot paint over the cluster. From md: 1fr | auto | 1fr so the cluster
|
||||
* sits on the page midline.
|
||||
*/
|
||||
return (
|
||||
<>
|
||||
@@ -121,13 +123,17 @@ function TopView({
|
||||
className="relative z-50 w-full border-b border-[var(--border-color-default-tertiary)] bg-[var(--color-surface-default-primary)]"
|
||||
role="banner"
|
||||
aria-label={t("ariaLabels.mainNavigationHeader")}
|
||||
data-figma-node="5944-17779"
|
||||
>
|
||||
<nav
|
||||
className="grid w-full grid-cols-[auto_minmax(0,1fr)_auto] items-center px-[var(--space-400)] py-[var(--space-200)] sm:px-[var(--space-400)] md:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] lg:px-[var(--space-1800)] lg:py-[var(--space-400)]"
|
||||
className="flex w-full items-center gap-[var(--space-200)] px-[var(--space-400)] py-[var(--space-200)] sm:px-[var(--space-400)] md:grid md:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] md:gap-0 lg:px-[var(--space-1800)] lg:py-[var(--space-400)]"
|
||||
role="navigation"
|
||||
aria-label={t("ariaLabels.mainNavigation")}
|
||||
>
|
||||
<div className="min-w-0 shrink-0 justify-self-start lg:w-[200px] lg:max-w-[200px]" data-top="logo">
|
||||
<div
|
||||
className="min-w-0 shrink-0 justify-self-start lg:w-[200px] lg:max-w-[200px]"
|
||||
data-top="logo"
|
||||
>
|
||||
<Logo
|
||||
size={logoSize}
|
||||
wordmark
|
||||
@@ -136,7 +142,7 @@ function TopView({
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex min-w-0 items-center justify-end md:justify-center"
|
||||
className="flex min-w-0 flex-1 items-center justify-end md:flex-none md:justify-center"
|
||||
data-top="nav"
|
||||
>
|
||||
<Menu
|
||||
@@ -144,6 +150,7 @@ function TopView({
|
||||
className="min-w-0 lg:gap-[var(--space-300)]"
|
||||
>
|
||||
{renderNavigationItems()}
|
||||
{phoneAuthItem}
|
||||
</Menu>
|
||||
</div>
|
||||
|
||||
@@ -151,7 +158,7 @@ function TopView({
|
||||
className="flex shrink-0 items-center justify-self-end gap-[var(--space-100)] md:gap-[var(--space-250)] lg:gap-[var(--space-100)]"
|
||||
data-top="auth"
|
||||
>
|
||||
{loginControl}
|
||||
{desktopAuthItem}
|
||||
{renderCreateRuleButton()}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -44,6 +44,7 @@ function AskOrganizerView({
|
||||
variant={lockupVariant}
|
||||
alignment={lockupAlignment}
|
||||
titleId={labelledBy}
|
||||
headingLevel="h2"
|
||||
/>
|
||||
|
||||
<div
|
||||
|
||||
@@ -30,13 +30,14 @@ function FeatureGridView({
|
||||
<div className="mx-auto flex w-full flex-col gap-[var(--space-1000)] md:gap-[var(--space-1200)] lg:flex-row lg:items-center lg:gap-[var(--space-1800)]">
|
||||
<div className="lg:min-w-0 lg:flex-1">
|
||||
<ContentLockup
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
variant="feature"
|
||||
linkText={linkText}
|
||||
linkHref={linkHref}
|
||||
titleId={labelledBy}
|
||||
/>
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
variant="feature"
|
||||
linkText={linkText}
|
||||
linkHref={linkHref}
|
||||
titleId={labelledBy}
|
||||
headingLevel="h2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid w-full grid-cols-1 gap-[var(--space-200)] md:grid-cols-2 md:gap-[var(--space-400)] lg:flex-1">
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Figma: "Section / Hero Banner" (18477:15473)
|
||||
*/
|
||||
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMessages, useTranslation } from "../../../contexts/MessagesContext";
|
||||
import { fetchAuthSession } from "../../../../lib/create/api";
|
||||
import { prepareFreshCreateFlowEntrySync } from "../../../(app)/create/utils/prepareFreshCreateFlowEntry";
|
||||
import type { Category } from "../../cards/Rule";
|
||||
import type { ChipOption } from "../../controls/MultiSelect/MultiSelect.types";
|
||||
import HeroBannerView from "./HeroBanner.view";
|
||||
import type { HeroBannerProps } from "./HeroBanner.types";
|
||||
|
||||
function chipsFromLabels(labels: string[]): ChipOption[] {
|
||||
return labels.map((label) => ({
|
||||
id: label.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
|
||||
label,
|
||||
state: "unselected" as const,
|
||||
}));
|
||||
}
|
||||
|
||||
const HeroBannerContainer = memo<HeroBannerProps>(
|
||||
({ title, subtitle, description }) => {
|
||||
const router = useRouter();
|
||||
const messages = useMessages();
|
||||
const tHome = useTranslation("pages.home.heroBanner");
|
||||
const tHero = useTranslation("heroBanner");
|
||||
const [signedIn, setSignedIn] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void fetchAuthSession()
|
||||
.then(({ user }) => {
|
||||
if (!cancelled) setSignedIn(Boolean(user));
|
||||
})
|
||||
.catch(() => {
|
||||
/* guest fallback */
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCreateRule = useCallback(() => {
|
||||
prepareFreshCreateFlowEntrySync({ signedIn });
|
||||
router.push("/create/informational");
|
||||
}, [router, signedIn]);
|
||||
|
||||
const ruleCategories = useMemo<Category[]>(() => {
|
||||
const groups = messages.heroBanner.ruleCard.categories;
|
||||
return [
|
||||
groups.values,
|
||||
groups.communication,
|
||||
groups.membership,
|
||||
groups.decisionMaking,
|
||||
groups.conflictManagement,
|
||||
].map((group) => ({
|
||||
name: group.name,
|
||||
chipOptions: chipsFromLabels(group.chips),
|
||||
}));
|
||||
}, [messages]);
|
||||
|
||||
return (
|
||||
<HeroBannerView
|
||||
title={title ?? tHome("title")}
|
||||
subtitle={subtitle ?? tHome("subtitle")}
|
||||
description={description ?? tHome("description")}
|
||||
facilitatorCta={tHero("facilitatorCta")}
|
||||
facilitatorHref={tHero("facilitatorHref")}
|
||||
createRuleCta={tHero("createRuleCta")}
|
||||
onCreateRule={handleCreateRule}
|
||||
ruleTitle={tHero("ruleCard.title")}
|
||||
ruleDescription={tHero("ruleCard.description")}
|
||||
ruleCategories={ruleCategories}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
HeroBannerContainer.displayName = "HeroBanner";
|
||||
|
||||
export default HeroBannerContainer;
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* Figma: "Sections / Hero" (see registry)
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import Image from "next/image";
|
||||
import ContentLockup from "../../type/ContentLockup";
|
||||
import HeroDecor from "./HeroDecor";
|
||||
import { ASSETS, getAssetPath } from "../../../../lib/assetUtils";
|
||||
|
||||
/**
|
||||
* Intrinsic dimensions of `public/assets/marketing/hero-image.png` (2560×1600,
|
||||
* 16:10). Passed to `next/image` to reserve aspect ratio + drive responsive
|
||||
* srcset generation. Actual rendered size is governed by `sizes`.
|
||||
*/
|
||||
const HERO_IMAGE_WIDTH = 2560;
|
||||
const HERO_IMAGE_HEIGHT = 1600;
|
||||
|
||||
interface HeroBannerProps {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
description?: string;
|
||||
ctaText?: string;
|
||||
ctaHref?: string;
|
||||
imageAlt?: string;
|
||||
}
|
||||
|
||||
const HeroBanner = memo<HeroBannerProps>(
|
||||
({
|
||||
title,
|
||||
subtitle,
|
||||
description,
|
||||
ctaText,
|
||||
ctaHref,
|
||||
imageAlt = "Hero illustration",
|
||||
}) => {
|
||||
return (
|
||||
<section className="bg-transparent px-[var(--space-200)] sm:px-[var(--space-250)] md:px-[var(--space-400)] lg:px-[var(--space-600)] xl:px-[var(--space-1200)]">
|
||||
<div className="flex flex-col gap-[var(--space-250)]">
|
||||
{/* Frame container for content */}
|
||||
<div className="bg-[var(--color-surface-invert-brand-primary)] p-[var(--space-300)] sm:p-[var(--space-400)] md:p-[var(--space-1800)] lg:py-[var(--space-2400)] lg:px-[var(--space-1800)] rounded-tl-none rounded-tr-[var(--radius-400)] rounded-br-[var(--radius-400)] rounded-bl-[var(--radius-400)] flex flex-col gap-[var(--space-600)] sm:gap-[var(--space-600)] md:flex-row md:gap-[var(--space-1200)] relative overflow-hidden">
|
||||
{/* DECORATIONS (behind content) */}
|
||||
<HeroDecor
|
||||
className="pointer-events-none absolute z-0
|
||||
left-0 top-0
|
||||
translate-x-[-72px] translate-y-[26px] sm:translate-x-[-78px] sm:translate-y-[24px] md:translate-x-[-86px] md:translate-y-[16px] lg:translate-x-[-88px] lg:translate-y-[16px]
|
||||
w-[1540px] h-[645px] scale-[1.04]"
|
||||
/>
|
||||
|
||||
{/* Content lockup - Large variant */}
|
||||
<div className="md:flex-1">
|
||||
<ContentLockup
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
description={description}
|
||||
ctaText={ctaText}
|
||||
ctaHref={ctaHref}
|
||||
buttonClassName="shrink-0 whitespace-nowrap min-w-[280px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hero Image Container */}
|
||||
<div className="relative z-10 flex w-full items-center justify-center overflow-hidden rounded-[8px] aspect-[16/10] md:flex-1">
|
||||
<Image
|
||||
src={getAssetPath(ASSETS.HERO_IMAGE)}
|
||||
alt={imageAlt}
|
||||
width={HERO_IMAGE_WIDTH}
|
||||
height={HERO_IMAGE_HEIGHT}
|
||||
priority
|
||||
sizes="(min-width: 768px) 50vw, 100vw"
|
||||
className="size-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
HeroBanner.displayName = "HeroBanner";
|
||||
|
||||
export default HeroBanner;
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Category } from "../../cards/Rule";
|
||||
|
||||
export interface HeroBannerProps {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface HeroBannerViewProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
description: string;
|
||||
facilitatorCta: string;
|
||||
facilitatorHref: string;
|
||||
createRuleCta: string;
|
||||
onCreateRule: () => void;
|
||||
ruleTitle: string;
|
||||
ruleDescription: string;
|
||||
ruleCategories: Category[];
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import Button from "../../buttons/Button";
|
||||
import Rule from "../../cards/Rule";
|
||||
import ContentLockup from "../../type/ContentLockup";
|
||||
import { ASSETS, getAssetPath } from "../../../../lib/assetUtils";
|
||||
import HeroDecor from "./HeroDecor";
|
||||
import type { HeroBannerViewProps } from "./HeroBanner.types";
|
||||
|
||||
function HeroBannerView({
|
||||
title,
|
||||
subtitle,
|
||||
description,
|
||||
facilitatorCta,
|
||||
facilitatorHref,
|
||||
createRuleCta,
|
||||
onCreateRule,
|
||||
ruleTitle,
|
||||
ruleDescription,
|
||||
ruleCategories,
|
||||
}: HeroBannerViewProps) {
|
||||
return (
|
||||
<section
|
||||
className="bg-transparent px-[var(--space-200)] sm:px-[var(--space-250)] md:px-[var(--space-400)] lg:px-[var(--space-600)] xl:px-[var(--space-1200)]"
|
||||
data-figma-node="18477-15473"
|
||||
>
|
||||
<div className="flex flex-col gap-[var(--space-250)]">
|
||||
<div className="relative flex flex-col gap-[var(--space-600)] overflow-hidden rounded-tl-none rounded-tr-[var(--radius-400)] rounded-br-[var(--radius-400)] rounded-bl-[var(--radius-400)] bg-[var(--color-surface-invert-brand-primary)] p-[var(--space-300)] sm:p-[var(--space-400)] md:gap-[var(--space-1200)] md:p-[var(--space-1800)] lg:flex-row lg:items-center lg:gap-[var(--space-1200)] lg:px-[var(--space-1800)] lg:py-[var(--space-1200)] xl:py-[var(--space-1800)]">
|
||||
<HeroDecor
|
||||
className="pointer-events-none absolute z-0
|
||||
left-0 top-0
|
||||
translate-x-[-72px] translate-y-[26px] sm:translate-x-[-78px] sm:translate-y-[24px] md:translate-x-[-86px] md:translate-y-[16px] lg:translate-x-[-88px] lg:translate-y-[16px]
|
||||
w-[1540px] h-[645px] scale-[1.04]"
|
||||
/>
|
||||
|
||||
<div className="relative z-10 flex w-full flex-col gap-[var(--space-150)] sm:gap-[var(--space-300)] md:gap-[var(--space-500)] lg:flex-1">
|
||||
<ContentLockup
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
description={description}
|
||||
/>
|
||||
<div className="relative z-10 flex justify-start">
|
||||
<div className="lg:hidden">
|
||||
<div className="md:hidden">
|
||||
<Button
|
||||
buttonType="filled"
|
||||
palette="inverse"
|
||||
size="small"
|
||||
href={facilitatorHref}
|
||||
>
|
||||
{facilitatorCta}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
<Button
|
||||
buttonType="filled"
|
||||
palette="inverse"
|
||||
size="large"
|
||||
href={facilitatorHref}
|
||||
>
|
||||
{facilitatorCta}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden lg:block">
|
||||
<div className="xl:hidden">
|
||||
<Button
|
||||
buttonType="filled"
|
||||
palette="inverse"
|
||||
size="large"
|
||||
onClick={onCreateRule}
|
||||
>
|
||||
{createRuleCta}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="hidden xl:block">
|
||||
<Button
|
||||
buttonType="filled"
|
||||
palette="inverse"
|
||||
size="xlarge"
|
||||
onClick={onCreateRule}
|
||||
>
|
||||
{createRuleCta}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 flex w-full justify-center lg:w-[568px] lg:shrink-0">
|
||||
<Rule
|
||||
title={ruleTitle}
|
||||
description={ruleDescription}
|
||||
expanded
|
||||
size="L"
|
||||
fluidWidth
|
||||
interactive={false}
|
||||
hideCategoryAddButton
|
||||
descriptionAtBottom
|
||||
backgroundColor="bg-[var(--color-surface-invert-brand-primary)]"
|
||||
className="w-full max-w-[568px] gap-[12px] rounded-[var(--radius-300)] lg:gap-[16px] lg:rounded-[var(--radius-500)]"
|
||||
categories={ruleCategories}
|
||||
icon={
|
||||
// eslint-disable-next-line @next/next/no-img-element -- decorative brand mark beside the title
|
||||
<img
|
||||
src={getAssetPath(ASSETS.LOGO)}
|
||||
alt=""
|
||||
width={88}
|
||||
height={88}
|
||||
className="size-full object-contain brightness-0"
|
||||
role="presentation"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
HeroBannerView.displayName = "HeroBannerView";
|
||||
|
||||
export default memo(HeroBannerView);
|
||||
@@ -1 +1,2 @@
|
||||
export { default } from "./HeroBanner";
|
||||
export { default } from "./HeroBanner.container";
|
||||
export type { HeroBannerProps } from "./HeroBanner.types";
|
||||
@@ -1,9 +1,8 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Figma: "Section / Logo Wall" (18847:22366)
|
||||
* Index frames: 320–429 2×3 grid, 430–639 3×2 grid, 640+ space-between row.
|
||||
* Logo heights from those instances (1× / 1.25 at lg / 1.5625 at xl).
|
||||
* Figma: "Section / Logo Wall" (18483:18425)
|
||||
* Home uses the three pilot marks (street medics, Food Not Bombs, Mutual Aid Colorado).
|
||||
*/
|
||||
|
||||
import { memo, useMemo } from "react";
|
||||
@@ -17,41 +16,20 @@ const LogoWallContainer = memo<LogoWallProps>(({ logos, className = "" }) => {
|
||||
|
||||
const defaultLogos = useMemo(
|
||||
() => [
|
||||
{
|
||||
src: getAssetPath(partnerLogoPath("boulder-street-medics")),
|
||||
alt: t("partners.boulderStreetMedics"),
|
||||
size: "h-[48px] lg:h-[60px] xl:h-[74.6px]",
|
||||
},
|
||||
{
|
||||
src: getAssetPath(partnerLogoPath("food-not-bombs")),
|
||||
alt: t("partners.foodNotBombs"),
|
||||
size: "h-[48px] lg:h-[60px] xl:h-[74.6px]",
|
||||
order: "sm:order-4 md:order-4",
|
||||
},
|
||||
{
|
||||
src: getAssetPath(partnerLogoPath("start-coop")),
|
||||
alt: t("partners.startCoop"),
|
||||
size: "h-[51.4px] lg:h-[64.2px] xl:h-[80.3px]",
|
||||
order: "sm:order-1 md:order-3",
|
||||
},
|
||||
{
|
||||
src: getAssetPath(partnerLogoPath("metagov")),
|
||||
alt: t("partners.metagov"),
|
||||
size: "h-[26.2px] lg:h-[33px] xl:h-[41px]",
|
||||
order: "sm:order-6 md:order-1",
|
||||
},
|
||||
{
|
||||
src: getAssetPath(partnerLogoPath("open-civics")),
|
||||
alt: t("partners.openCivics"),
|
||||
size: "h-[50.1px] lg:h-[61.1px] xl:h-[72px]",
|
||||
order: "sm:order-5 md:order-2",
|
||||
},
|
||||
{
|
||||
src: getAssetPath(partnerLogoPath("mutual-aid-co")),
|
||||
alt: t("partners.mutualAidCo"),
|
||||
size: "h-[51.1px] lg:h-[63.6px] xl:h-[79.5px]",
|
||||
order: "sm:order-2 md:order-5",
|
||||
},
|
||||
{
|
||||
src: getAssetPath(partnerLogoPath("cu-boulder")),
|
||||
alt: t("partners.cuBoulder"),
|
||||
size: "h-[44.6px] lg:h-[55.8px] xl:h-[69.7px]",
|
||||
order: "sm:order-3 md:order-6",
|
||||
},
|
||||
],
|
||||
[t],
|
||||
@@ -63,11 +41,7 @@ const LogoWallContainer = memo<LogoWallProps>(({ logos, className = "" }) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<LogoWallView
|
||||
heading={t("heading")}
|
||||
displayLogos={displayLogos}
|
||||
className={className}
|
||||
/>
|
||||
<LogoWallView displayLogos={displayLogos} className={className} />
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,20 +5,17 @@ export interface LogoWallProps {
|
||||
width?: number;
|
||||
height?: number;
|
||||
size?: string;
|
||||
order?: string;
|
||||
}>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface LogoWallViewProps {
|
||||
heading: string;
|
||||
displayLogos: Array<{
|
||||
src: string;
|
||||
alt: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
size?: string;
|
||||
order?: string;
|
||||
}>;
|
||||
className: string;
|
||||
}
|
||||
@@ -4,41 +4,31 @@ import { memo } from "react";
|
||||
import Image from "next/image";
|
||||
import type { LogoWallViewProps } from "./LogoWall.types";
|
||||
|
||||
function LogoWallView({
|
||||
heading,
|
||||
displayLogos,
|
||||
className,
|
||||
}: LogoWallViewProps) {
|
||||
function LogoWallView({ displayLogos, className }: LogoWallViewProps) {
|
||||
return (
|
||||
<section
|
||||
className={`bg-[var(--color-surface-default-primary)] p-[var(--space-800)] md:px-[var(--space-600)] md:py-[var(--space-800)] lg:px-[var(--space-1800)] lg:py-[var(--space-1200)] xl:px-[160px] xl:py-[var(--space-1800)] ${className}`}
|
||||
data-figma-node="18483-18425"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-[var(--space-800)] md:gap-[var(--space-600)] xl:gap-[var(--space-800)]">
|
||||
<p className="font-inter text-center text-[10px] font-medium uppercase leading-[12px] text-[var(--color-content-default-primary)] xl:text-[14px] xl:leading-[12px]">
|
||||
{heading}
|
||||
</p>
|
||||
<div className="w-full opacity-60">
|
||||
<div className="grid grid-cols-2 grid-rows-3 sm:grid-cols-3 sm:grid-rows-2 md:flex md:w-full md:flex-nowrap md:items-center md:justify-between gap-[38.4px] lg:gap-[var(--space-1200)]">
|
||||
{displayLogos.map((logo, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex items-center justify-center md:shrink-0 ${
|
||||
logo.order || ""
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src={logo.src}
|
||||
alt={logo.alt}
|
||||
className={`${logo.size || "h-8"} w-auto object-contain`}
|
||||
priority={index < 2} // Prioritize first 2 logos for above-the-fold loading
|
||||
unoptimized // Skip optimization for local images
|
||||
width={0}
|
||||
height={0}
|
||||
sizes="100vw"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="w-full opacity-60">
|
||||
<div className="flex w-full flex-col items-center gap-[38.4px] sm:flex-row sm:flex-wrap sm:justify-around md:flex-nowrap md:justify-between lg:gap-[var(--space-1200)]">
|
||||
{displayLogos.map((logo, index) => (
|
||||
<div
|
||||
key={logo.alt || index}
|
||||
className="flex items-center justify-center md:shrink-0"
|
||||
>
|
||||
<Image
|
||||
src={logo.src}
|
||||
alt={logo.alt}
|
||||
className={`${logo.size || "h-8"} w-auto object-contain`}
|
||||
priority={index < 2}
|
||||
unoptimized
|
||||
width={0}
|
||||
height={0}
|
||||
sizes="100vw"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -22,6 +22,7 @@ const ContentLockupContainer = memo<ContentLockupProps>(
|
||||
alignment: alignmentProp = "center",
|
||||
titleId,
|
||||
titleContent,
|
||||
headingLevel: headingLevelProp = "h1",
|
||||
}) => {
|
||||
const variant = variantProp;
|
||||
const alignment = alignmentProp;
|
||||
@@ -179,6 +180,7 @@ const ContentLockupContainer = memo<ContentLockupProps>(
|
||||
alignment={alignment}
|
||||
titleId={titleId}
|
||||
titleContent={titleContent}
|
||||
headingLevel={headingLevelProp}
|
||||
styles={styles}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,8 @@ export type ContentLockupVariantValue =
|
||||
|
||||
export type ContentLockupAlignmentValue = "center" | "left";
|
||||
|
||||
export type ContentLockupHeadingLevel = "h1" | "h2";
|
||||
|
||||
export interface ContentLockupProps {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
@@ -37,6 +39,12 @@ export interface ContentLockupProps {
|
||||
titleId?: string;
|
||||
/** Replaces the default title string when inline title markup is required. */
|
||||
titleContent?: ReactNode;
|
||||
/**
|
||||
* Heading tag for the title. FeatureGrid and AskOrganizer use `h2` so each
|
||||
* page keeps a single `h1`.
|
||||
* @default "h1"
|
||||
*/
|
||||
headingLevel?: ContentLockupHeadingLevel;
|
||||
}
|
||||
|
||||
export interface VariantStyle {
|
||||
@@ -72,5 +80,6 @@ export interface ContentLockupViewProps {
|
||||
alignment: "center" | "left";
|
||||
titleId?: string;
|
||||
titleContent?: ReactNode;
|
||||
headingLevel: ContentLockupHeadingLevel;
|
||||
styles: VariantStyle;
|
||||
}
|
||||
@@ -19,8 +19,11 @@ function ContentLockupView({
|
||||
alignment,
|
||||
titleId,
|
||||
titleContent,
|
||||
headingLevel,
|
||||
styles,
|
||||
}: ContentLockupViewProps) {
|
||||
const TitleTag = headingLevel;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{variant === "ask" ||
|
||||
@@ -43,9 +46,9 @@ function ContentLockupView({
|
||||
}`}
|
||||
>
|
||||
{title ? (
|
||||
<h1 id={titleId} className={styles.title}>
|
||||
<TitleTag id={titleId} className={styles.title}>
|
||||
{title}
|
||||
</h1>
|
||||
</TitleTag>
|
||||
) : null}
|
||||
</div>
|
||||
{subtitle ? <h2 className={styles.subtitle}>{subtitle}</h2> : null}
|
||||
@@ -61,16 +64,16 @@ function ContentLockupView({
|
||||
{/* Title container */}
|
||||
<div className={styles.titleContainer}>
|
||||
{titleContent ? (
|
||||
<h1
|
||||
<TitleTag
|
||||
id={titleId}
|
||||
className={`${styles.title} flex flex-wrap items-center gap-[var(--space-250)] xl:gap-[var(--space-300)]`}
|
||||
>
|
||||
{titleContent}
|
||||
</h1>
|
||||
</TitleTag>
|
||||
) : title ? (
|
||||
<h1 id={titleId} className={styles.title}>
|
||||
<TitleTag id={titleId} className={styles.title}>
|
||||
{title}
|
||||
</h1>
|
||||
</TitleTag>
|
||||
) : null}
|
||||
{variant === "hero" && (
|
||||
<>
|
||||
|
||||
Reference in new issue
Block a user