Resolve errors with migrated components

This commit is contained in:
adilallo
2026-01-29 19:07:59 -07:00
parent 539f6c62e3
commit f7e0b5f517
13 changed files with 675 additions and 305 deletions
@@ -1,71 +1,180 @@
"use client"; "use client";
import { memo, useMemo } from "react"; import { memo } from "react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useSchemaData } from "../../hooks"; import MenuBarItem from "../MenuBarItem";
import Button from "../Button";
import AvatarContainer from "../AvatarContainer";
import Avatar from "../Avatar";
import Logo from "../Logo";
import { getAssetPath, ASSETS } from "../../../lib/assetUtils";
import HomeHeaderView from "./HomeHeader.view"; import HomeHeaderView from "./HomeHeader.view";
import type { HomeHeaderProps } from "./HomeHeader.types"; import type { HomeHeaderProps, NavSize } from "./HomeHeader.types";
// Configuration data for testing
export const navigationItems = [
{ href: "#", text: "Use cases", extraPadding: true },
{ href: "/learn", text: "Learn" },
{ href: "#", text: "About" },
];
export const avatarImages = [
{ src: getAssetPath(ASSETS.AVATAR_1), alt: "Avatar 1" },
{ src: getAssetPath(ASSETS.AVATAR_2), alt: "Avatar 2" },
{ src: getAssetPath(ASSETS.AVATAR_3), alt: "Avatar 3" },
];
export const logoConfig = [
{
breakpoint: "block sm:hidden",
size: "homeHeaderXsmall" as const,
showText: false,
},
{
breakpoint: "hidden sm:block md:hidden",
size: "homeHeaderSm" as const,
showText: true,
},
{
breakpoint: "hidden md:block lg:hidden",
size: "homeHeaderMd" as const,
showText: true,
},
{
breakpoint: "hidden lg:block xl:hidden",
size: "homeHeaderLg" as const,
showText: true,
},
{
breakpoint: "hidden xl:block",
size: "homeHeaderXl" as const,
showText: true,
},
];
const HomeHeaderContainer = memo<HomeHeaderProps>(() => { const HomeHeaderContainer = memo<HomeHeaderProps>(() => {
const pathname = usePathname(); const pathname = usePathname();
const { schemaData } = useSchemaData();
// Navigation items configuration // Schema markup for site navigation (home page specific)
const navigationItems = useMemo( const schemaData = {
() => [ "@context": "https://schema.org",
{ "@type": "WebSite",
label: "Home", name: "CommunityRule",
href: "/", url: "https://communityrule.com",
isActive: pathname === "/", description: "Build operating manuals for successful communities",
}, potentialAction: {
{ "@type": "SearchAction",
label: "Learn", target: "https://communityrule.com/search?q={search_term_string}",
href: "/learn", "query-input": "required name=search_term_string",
isActive: pathname === "/learn", },
}, };
{
label: "Monitor",
href: "/monitor",
isActive: pathname === "/monitor",
},
{
label: "Blog",
href: "/blog",
isActive: pathname?.startsWith("/blog") ?? false,
},
],
[pathname],
);
// Avatar images configuration const renderNavigationItems = (size: NavSize) => {
const avatarImages = useMemo( return navigationItems.map((item, index) => (
() => [ <MenuBarItem
{ key={index}
src: "/assets/avatar-1.svg", href={item.href}
alt: "User avatar 1", size={
}, item.extraPadding &&
{ (size === "xsmall" ||
src: "/assets/avatar-2.svg", size === "default" ||
alt: "User avatar 2", size === "home" ||
}, size === "homeMd" ||
{ size === "large" ||
src: "/assets/avatar-3.svg", size === "homeXlarge")
alt: "User avatar 3", ? size === "home" || size === "homeMd"
}, ? "homeMd"
], : size === "large"
[], ? "large"
); : size === "homeXlarge"
? "homeXlarge"
: "xsmallUseCases"
: size
}
variant={
size === "xsmall" ||
size === "default" ||
size === "home" ||
size === "homeMd" ||
size === "large" ||
size === "homeXlarge"
? "home"
: "default"
}
isActive={pathname === item.href}
ariaLabel={`Navigate to ${item.text} page`}
>
{item.text}
</MenuBarItem>
));
};
// Logo configuration const renderAvatarGroup = (
const logoConfig = useMemo( containerSize: "small" | "medium" | "large" | "xlarge",
() => ({ avatarSize: "small" | "medium" | "large" | "xlarge",
src: "/assets/logo.svg", ) => {
alt: "Community Rule Logo", return (
width: 120, <AvatarContainer size={containerSize}>
height: 32, {avatarImages.map((avatar, index) => (
}), <Avatar
[], key={index}
); src={avatar.src}
alt={avatar.alt}
size={avatarSize}
/>
))}
</AvatarContainer>
);
};
const renderLoginButton = (size: NavSize) => {
return (
<MenuBarItem
href="#"
size={size}
variant={size === "xsmall" || size === "default" ? "home" : "default"}
ariaLabel="Log in to your account"
>
Log in
</MenuBarItem>
);
};
const renderCreateRuleButton = (
buttonSize: "xsmall" | "small" | "medium" | "large" | "xlarge",
containerSize: "small" | "medium" | "large" | "xlarge",
avatarSize: "small" | "medium" | "large" | "xlarge",
) => {
return (
<Button
size={buttonSize}
variant="secondary"
ariaLabel="Create a new rule with avatar decoration"
>
{renderAvatarGroup(containerSize, avatarSize)}
<span>Create rule</span>
</Button>
);
};
const renderLogo = (
size:
| "default"
| "homeHeaderXsmall"
| "homeHeaderSm"
| "homeHeaderMd"
| "homeHeaderLg"
| "homeHeaderXl"
| "header"
| "headerMd"
| "headerLg"
| "headerXl"
| "footer"
| "footerLg",
showText: boolean,
) => {
return <Logo size={size} showText={showText} />;
};
return ( return (
<HomeHeaderView <HomeHeaderView
@@ -74,6 +183,11 @@ const HomeHeaderContainer = memo<HomeHeaderProps>(() => {
navigationItems={navigationItems} navigationItems={navigationItems}
avatarImages={avatarImages} avatarImages={avatarImages}
logoConfig={logoConfig} logoConfig={logoConfig}
renderNavigationItems={renderNavigationItems}
renderAvatarGroup={renderAvatarGroup}
renderLoginButton={renderLoginButton}
renderCreateRuleButton={renderCreateRuleButton}
renderLogo={renderLogo}
/> />
); );
}); });
+60 -8
View File
@@ -1,23 +1,75 @@
import type React from "react";
export interface HomeHeaderProps { export interface HomeHeaderProps {
// Currently no props, but keeping interface for future extensibility // Currently no props, but keeping interface for future extensibility
} }
export type NavSize =
| "default"
| "xsmall"
| "xsmallUseCases"
| "home"
| "homeMd"
| "homeUseCases"
| "large"
| "largeUseCases"
| "homeXlarge"
| "xlarge";
export interface HomeHeaderViewProps { export interface HomeHeaderViewProps {
pathname: string; pathname: string;
schemaData: object; schemaData: object;
navigationItems: Array<{ navigationItems: Array<{
label: string;
href: string; href: string;
isActive: boolean; text: string;
extraPadding?: boolean;
}>; }>;
avatarImages: Array<{ avatarImages: Array<{
src: string; src: string;
alt: string; alt: string;
}>; }>;
logoConfig: { logoConfig: Array<{
src: string; breakpoint: string;
alt: string; size:
width: number; | "default"
height: number; | "homeHeaderXsmall"
}; | "homeHeaderSm"
| "homeHeaderMd"
| "homeHeaderLg"
| "homeHeaderXl"
| "header"
| "headerMd"
| "headerLg"
| "headerXl"
| "footer"
| "footerLg";
showText: boolean;
}>;
renderNavigationItems: (size: NavSize) => React.ReactNode;
renderAvatarGroup: (
containerSize: "small" | "medium" | "large" | "xlarge",
avatarSize: "small" | "medium" | "large" | "xlarge",
) => React.ReactNode;
renderLoginButton: (size: NavSize) => React.ReactNode;
renderCreateRuleButton: (
buttonSize: "xsmall" | "small" | "medium" | "large" | "xlarge",
containerSize: "small" | "medium" | "large" | "xlarge",
avatarSize: "small" | "medium" | "large" | "xlarge",
) => React.ReactNode;
renderLogo: (
size:
| "default"
| "homeHeaderXsmall"
| "homeHeaderSm"
| "homeHeaderMd"
| "homeHeaderLg"
| "homeHeaderXl"
| "header"
| "headerMd"
| "headerLg"
| "headerXl"
| "footer"
| "footerLg",
showText: boolean,
) => React.ReactNode;
} }
+92 -34
View File
@@ -3,9 +3,12 @@
import { memo } from "react"; import { memo } from "react";
import Script from "next/script"; import Script from "next/script";
import Logo from "../Logo"; import Logo from "../Logo";
import NavigationItem from "../NavigationItem"; import HeaderTab from "../HeaderTab";
import AvatarContainer from "../AvatarContainer"; import MenuBar from "../MenuBar";
import MenuBarItem from "../MenuBarItem";
import Button from "../Button"; import Button from "../Button";
import AvatarContainer from "../AvatarContainer";
import Avatar from "../Avatar";
import type { HomeHeaderViewProps } from "./HomeHeader.types"; import type { HomeHeaderViewProps } from "./HomeHeader.types";
function HomeHeaderView({ function HomeHeaderView({
@@ -14,6 +17,11 @@ function HomeHeaderView({
navigationItems, navigationItems,
avatarImages, avatarImages,
logoConfig, logoConfig,
renderNavigationItems,
renderAvatarGroup,
renderLoginButton,
renderCreateRuleButton,
renderLogo,
}: HomeHeaderViewProps) { }: HomeHeaderViewProps) {
return ( return (
<> <>
@@ -22,41 +30,91 @@ function HomeHeaderView({
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaData) }} dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaData) }}
/> />
<header className="sticky top-0 z-50 bg-[var(--color-surface-default-primary)] border-b border-[var(--color-border-default-tertiary)]"> <header
<div className="max-w-[1440px] mx-auto px-[var(--spacing-scale-016)] md:px-[var(--spacing-scale-032)] lg:px-[var(--spacing-scale-064)]"> className="w-full bg-transparent overflow-hidden"
<div className="flex items-center justify-between h-[var(--measures-sizing-064)] md:h-[var(--measures-sizing-080)]"> role="banner"
<div className="flex items-center gap-[var(--spacing-scale-040)]"> aria-label="Home page navigation header"
<Logo >
src={logoConfig.src} <nav
alt={logoConfig.alt} className="relative flex items-center justify-between mx-auto h-[50px] sm:h-[62px] md:h-[68px] lg:h-[68px] xl:h-[88px] px-[var(--spacing-scale-008)] pr-[var(--spacing-scale-016)] pt-[var(--spacing-scale-010)] sm:px-[var(--spacing-scale-010)] sm:pr-[var(--spacing-scale-020)] sm:pt-[var(--spacing-scale-010)] md:px-[var(--spacing-scale-016)] md:pr-[var(--spacing-scale-032)] md:pt-[var(--spacing-scale-016)] lg:pl-[var(--spacing-scale-024)] lg:pt-[var(--spacing-scale-016)] lg:pr-[var(--spacing-scale-056)] xl:pl-[var(--spacing-scale-048)] xl:pt-[var(--spacing-scale-024)] xl:pr-[var(--spacing-scale-056)]"
width={logoConfig.width} role="navigation"
height={logoConfig.height} aria-label="Main navigation"
/> >
<nav className="hidden md:flex items-center gap-[var(--spacing-scale-016)]"> <HeaderTab className="flex items-center self-end" stretch={true}>
{navigationItems.map((item) => ( {/* Logo - Consistent left positioning within HeaderTab */}
<NavigationItem <div>
key={item.href} {logoConfig.map((config, index) => (
href={item.href} <div key={index} className={config.breakpoint}>
isActive={item.isActive} {renderLogo(config.size, config.showText)}
> </div>
{item.label} ))}
</NavigationItem>
))}
</nav>
</div> </div>
<div className="flex items-center gap-[var(--spacing-scale-016)]">
<AvatarContainer avatars={avatarImages} /> {/* XSmall menu bar - positioned next to logo */}
<Button <div className="block sm:hidden -me-[2px]">
href="/learn" <MenuBar size="default">
variant="primary" {renderNavigationItems("xsmall")}
size="medium" {renderLoginButton("xsmall")}
className="hidden md:inline-flex" </MenuBar>
> </div>
Get Started </HeaderTab>
</Button>
{/* Navigation Links - Centered in header for SM and up */}
<div className="absolute left-1/2 transform -translate-x-1/2 hidden sm:block">
<div className="hidden sm:block md:hidden">
<MenuBar size="default">
{renderNavigationItems("xsmall")}
{renderLoginButton("xsmall")}
</MenuBar>
</div>
<div className="hidden md:block lg:hidden">
<MenuBar size="medium">{renderNavigationItems("homeMd")}</MenuBar>
</div>
<div className="hidden lg:block xl:hidden">
<MenuBar size="large">{renderNavigationItems("large")}</MenuBar>
</div>
<div className="hidden xl:block">
<MenuBar size="large">
{renderNavigationItems("homeXlarge")}
</MenuBar>
</div> </div>
</div> </div>
</div>
{/* Authentication Elements - Consistent right alignment outside HeaderTab */}
<div className="flex items-center">
{/* XSmall and Small breakpoints - create rule button outside HeaderTab */}
<div className="block md:hidden">
{renderCreateRuleButton("xsmall", "small", "small")}
</div>
{/* Medium breakpoint - login outside HeaderTab, create rule outside */}
<div className="hidden md:block lg:hidden absolute right-[var(--spacing-measures-spacing-016)]">
<div className="flex items-center gap-[var(--spacing-scale-010)]">
{renderLoginButton("homeMd")}
{renderCreateRuleButton("small", "medium", "medium")}
</div>
</div>
{/* Large breakpoint */}
<div className="hidden lg:flex xl:hidden items-center">
<div className="flex items-center gap-[var(--spacing-scale-004)]">
{renderLoginButton("large")}
{renderCreateRuleButton("large", "large", "large")}
</div>
</div>
{/* XLarge breakpoint */}
<div className="hidden xl:flex items-center">
<div className="flex items-center gap-[var(--spacing-scale-004)]">
{renderLoginButton("homeXlarge")}
{renderCreateRuleButton("xlarge", "xlarge", "xlarge")}
</div>
</div>
</div>
</nav>
</header> </header>
</> </>
); );
+28 -25
View File
@@ -6,40 +6,40 @@ import type { LogoWallProps } from "./LogoWall.types";
const defaultLogos = [ const defaultLogos = [
{ {
src: "/assets/logo-1.svg", src: "/assets/Section/Logo_FoodNotBombs.png",
alt: "Partner Logo 1", alt: "Food Not Bombs",
width: 120, size: "h-11 lg:h-14 xl:h-[70px]",
height: 40, order: "order-1 sm:order-4", // Mobile: row 1 col 1, SM: row 2 col 1 (bottom left)
}, },
{ {
src: "/assets/logo-2.svg", src: "/assets/Section/Logo_StartCOOP.png",
alt: "Partner Logo 2", alt: "Start COOP",
width: 120, size: "h-[42px] lg:h-[53px] xl:h-[66px]",
height: 40, order: "order-2 sm:order-2", // Mobile: row 1 col 2, SM: row 1 col 2 (top middle)
}, },
{ {
src: "/assets/logo-3.svg", src: "/assets/Section/Logo_Metagov.png",
alt: "Partner Logo 3", alt: "Metagov",
width: 120, size: "h-6 lg:h-8 xl:h-[41px]",
height: 40, order: "order-3 sm:order-1", // Mobile: row 2 col 1, SM: row 1 col 1 (top left)
}, },
{ {
src: "/assets/logo-4.svg", src: "/assets/Section/Logo_OpenCivics.png",
alt: "Partner Logo 4", alt: "Open Civics",
width: 120, size: "h-8 lg:h-10 xl:h-[50px]",
height: 40, order: "order-4 sm:order-5 md:order-6", // Mobile: row 2 col 2, SM: row 2 col 2, MD: swapped with Mutual Aid CO
}, },
{ {
src: "/assets/logo-5.svg", src: "/assets/Section/Logo_MutualAidCO.png",
alt: "Partner Logo 5", alt: "Mutual Aid CO",
width: 120, size: "h-11 lg:h-14 xl:h-[70px]",
height: 40, order: "order-5 sm:order-6 md:order-5", // Mobile: row 3 col 1, SM: row 2 col 3, MD: swapped with OpenCivics
}, },
{ {
src: "/assets/logo-6.svg", src: "/assets/Section/Logo_CUBoulder.png",
alt: "Partner Logo 6", alt: "CU Boulder",
width: 120, size: "h-10 lg:h-12 xl:h-[60px]",
height: 40, order: "order-6 sm:order-3", // Mobile: row 3 col 2, SM: row 1 col 3 (top right)
}, },
]; ];
@@ -47,7 +47,10 @@ const LogoWallContainer = memo<LogoWallProps>(
({ logos, className = "" }) => { ({ logos, className = "" }) => {
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
const displayLogos = useMemo(() => logos || defaultLogos, [logos]); const displayLogos = useMemo(
() => (logos && logos.length > 0 ? logos : defaultLogos),
[logos],
);
useEffect(() => { useEffect(() => {
// Trigger fade-in animation after component mounts // Trigger fade-in animation after component mounts
@@ -4,6 +4,8 @@ export interface LogoWallProps {
alt: string; alt: string;
width?: number; width?: number;
height?: number; height?: number;
size?: string;
order?: string;
}>; }>;
className?: string; className?: string;
} }
@@ -15,6 +17,8 @@ export interface LogoWallViewProps {
alt: string; alt: string;
width?: number; width?: number;
height?: number; height?: number;
size?: string;
order?: string;
}>; }>;
className: string; className: string;
} }
+44 -18
View File
@@ -1,30 +1,56 @@
"use client";
import { memo } from "react"; import { memo } from "react";
import Image from "next/image"; import Image from "next/image";
import type { LogoWallViewProps } from "./LogoWall.types"; import type { LogoWallViewProps } from "./LogoWall.types";
function LogoWallView({ isVisible, displayLogos, className }: LogoWallViewProps) { function LogoWallView({
isVisible,
displayLogos,
className,
}: LogoWallViewProps) {
return ( return (
<div <section
className={`flex flex-wrap items-center justify-center gap-[var(--spacing-scale-032)] md:gap-[var(--spacing-scale-048)] transition-opacity duration-1000 ${ className={`p-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-024)] md:py-[var(--spacing-scale-032)] lg:px-[var(--spacing-scale-064)] lg:py-[var(--spacing-scale-048)] xl:px-[160px] xl:py-[var(--spacing-scale-064)] ${className}`}
isVisible ? "opacity-100" : "opacity-0"
} ${className}`}
> >
{displayLogos.map((logo, index) => ( <div className="flex flex-col gap-[var(--spacing-scale-032)] md:gap-[var(--spacing-scale-024)] xl:gap-[var(--spacing-scale-032)]">
{/* Label */}
<p className="font-inter font-medium text-[10px] leading-[12px] xl:text-[14px] xl:leading-[12px] uppercase text-[var(--color-content-default-secondary)] text-center">
Trusted by leading cooperators
</p>
{/* Logo Grid Container */}
<div <div
key={`${logo.src}-${index}`} className={`transition-opacity duration-500 ${
className="flex items-center justify-center grayscale hover:grayscale-0 transition-all duration-300" isVisible ? "opacity-60" : "opacity-0"
}`}
> >
<Image <div className="grid grid-cols-2 grid-rows-3 sm:grid-cols-3 sm:grid-rows-2 md:flex md:justify-between md:items-center gap-x-[var(--spacing-scale-032)] gap-y-[var(--spacing-scale-032)] sm:gap-y-[var(--spacing-scale-048)]">
src={logo.src} {displayLogos.map((logo, index) => (
alt={logo.alt} <div
width={logo.width || 120} key={index}
height={logo.height || 40} className={`flex items-center justify-center transition-opacity duration-500 hover:opacity-100 ${
className="object-contain" logo.order || ""
loading="lazy" }`}
/> >
<Image
src={logo.src}
alt={logo.alt}
className={`${
logo.size || "h-8"
} w-auto object-contain transition-transform duration-500 hover:scale-105`}
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> </div>
))} </div>
</div> </section>
); );
} }
@@ -12,6 +12,7 @@ const NavigationItemContainer = memo<NavigationItemProps>(
size = "default", size = "default",
className = "", className = "",
disabled = false, disabled = false,
isActive = false,
...props ...props
}) => { }) => {
// Variant styles // Variant styles
@@ -43,7 +44,12 @@ const NavigationItemContainer = memo<NavigationItemProps>(
finalVariant = "default"; // The disabled state is handled by disabled: utilities finalVariant = "default"; // The disabled state is handled by disabled: utilities
} }
const combinedStyles = `${baseStyles} ${variantStyles[finalVariant]} ${className}`; // Active state styling
const activeStyles = isActive
? "!border-[var(--color-content-default-brand-primary)] !text-[var(--color-content-default-brand-primary)]"
: "";
const combinedStyles = `${baseStyles} ${variantStyles[finalVariant]} ${activeStyles} ${className}`;
return ( return (
<NavigationItemView <NavigationItemView
@@ -1,11 +1,12 @@
export interface NavigationItemProps export interface NavigationItemProps
extends React.AnchorHTMLAttributes<HTMLAnchorElement> { extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "isActive"> {
href?: string; href?: string;
children?: React.ReactNode; children?: React.ReactNode;
variant?: "default"; variant?: "default";
size?: "default" | "xsmall"; size?: "default" | "xsmall";
className?: string; className?: string;
disabled?: boolean; disabled?: boolean;
isActive?: boolean;
} }
export interface NavigationItemViewProps { export interface NavigationItemViewProps {
@@ -1,134 +1,137 @@
"use client"; "use client";
import { memo, useState, useId } from "react"; import { memo, useState } from "react";
import { logger } from "../../lib/logger"; import { logger } from "../../../lib/logger";
import QuoteBlockView from "./QuoteBlock.view"; import QuoteBlockView from "./QuoteBlock.view";
import type { QuoteBlockProps } from "./QuoteBlock.types"; import type { QuoteBlockProps, VariantConfig } from "./QuoteBlock.types";
const QuoteBlockContainer = memo<QuoteBlockProps>( const QuoteBlockContainer = memo<QuoteBlockProps>(
({ ({
quote, variant = "standard",
author,
authorRole,
authorImage,
variant = "default",
className = "", className = "",
quote = "The rules of decision-making must be open and available to everyone, and this can happen only if they are formalized.",
author = "Jo Freeman",
source = "The Tyranny of Structurelessness",
avatarSrc = "/assets/Quote_Avatar.svg",
id,
fallbackAvatarSrc = "/assets/Quote_Avatar.svg",
onError,
}) => { }) => {
const [imageError, setImageError] = useState(false); const [imageError, setImageError] = useState(false);
const [imageLoading, setImageLoading] = useState(true); const [imageLoading, setImageLoading] = useState(true);
const quoteId = useId();
// Variant configuration // Variant configurations
const variantConfig = { const variants: Record<string, VariantConfig> = {
default: {
container:
"py-[var(--spacing-scale-032)] px-[var(--spacing-scale-032)] md:py-[var(--spacing-scale-064)] md:px-[var(--spacing-scale-064)]",
quote: "text-[var(--color-content-default-primary)]",
author: "text-[var(--color-content-default-secondary)]",
authorRole: "text-[var(--color-content-default-tertiary)]",
},
inverse: {
container:
"py-[var(--spacing-scale-032)] px-[var(--spacing-scale-032)] md:py-[var(--spacing-scale-064)] md:px-[var(--spacing-scale-064)] bg-[var(--color-surface-inverse-primary)]",
quote: "text-[var(--color-content-inverse-primary)]",
author: "text-[var(--color-content-inverse-secondary)]",
authorRole: "text-[var(--color-content-inverse-tertiary)]",
},
compact: { compact: {
container: container:
"py-[var(--spacing-scale-016)] px-[var(--spacing-scale-016)] md:py-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-032)]", "py-[var(--spacing-scale-032)] px-[var(--spacing-scale-016)]",
quote: "text-[var(--color-content-default-primary)]", card: "py-[var(--spacing-scale-032)] px-[var(--spacing-scale-016)] md:py-[var(--spacing-scale-040)] md:px-[var(--spacing-scale-024)] rounded-[var(--radius-measures-radius-small)]",
author: "text-[var(--color-content-default-secondary)]", gap: "gap-[var(--spacing-scale-016)] md:gap-[var(--spacing-scale-024)]",
authorRole: "text-[var(--color-content-default-tertiary)]", avatarGap: "gap-[var(--spacing-scale-012)]",
avatar: "w-[48px] h-[48px] md:w-[64px] md:h-[64px]",
quote: "text-[16px] leading-[120%] md:text-[20px] md:leading-[110%]",
author: "text-[10px] leading-[120%] md:text-[12px]",
source: "text-[10px] leading-[120%] md:text-[12px]",
showDecor: false,
},
standard: {
container:
"md:py-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-016)] lg:p-[var(--spacing-scale-064)]",
card: "py-[var(--spacing-scale-064)] px-[var(--spacing-scale-020)] md:py-[var(--spacing-scale-064)] md:px-[var(--spacing-scale-048)] md:rounded-[var(--radius-measures-radius-medium)] lg:py-[var(--spacing-scale-064)] lg:pl-[120px] lg:pr-[320px]",
gap: "gap-[var(--spacing-scale-024)] md:gap-[var(--spacing-scale-048)] lg:gap-[var(--spacing-scale-064)] xl:gap-[105px]",
avatarGap:
"gap-[var(--spacing-scale-020)] lg:gap-[var(--spacing-scale-018)] xl:gap-[var(--spacing-scale-032)]",
avatar:
"md:w-[120px] md:h-[120px] lg:w-[150px] lg:h-[150px] xl:w-[200px] xl:h-[200px]",
quote:
"text-[18px] leading-[120%] md:text-[36px] md:leading-[110%] md:tracking-[0px] lg:text-[52px] xl:text-[64px]",
author:
"text-[12px] leading-[120%] md:text-[18px] md:leading-[120%] md:tracking-[0.24px] lg:text-[24px] xl:text-[32px]",
source:
"text-[12px] leading-[120%] md:text-[18px] md:leading-[120%] md:tracking-[0.24px] lg:text-[24px] xl:text-[32px]",
showDecor: true,
},
extended: {
container:
"py-[var(--spacing-scale-048)] px-[var(--spacing-scale-024)] md:py-[var(--spacing-scale-064)] md:px-[var(--spacing-scale-032)] lg:py-[var(--spacing-scale-080)] lg:px-[var(--spacing-scale-048)]",
card: "py-[var(--spacing-scale-080)] px-[var(--spacing-scale-032)] md:py-[var(--spacing-scale-096)] md:px-[var(--spacing-scale-064)] md:rounded-[var(--radius-measures-radius-large)] lg:py-[var(--spacing-scale-112)] lg:pl-[160px] lg:pr-[400px]",
gap: "gap-[var(--spacing-scale-032)] md:gap-[var(--spacing-scale-064)] lg:gap-[var(--spacing-scale-080)] xl:gap-[140px]",
avatarGap:
"gap-[var(--spacing-scale-032)] lg:gap-[var(--spacing-scale-040)] xl:gap-[var(--spacing-scale-048)]",
avatar:
"w-[80px] h-[80px] md:w-[140px] md:h-[140px] lg:w-[180px] lg:h-[180px] xl:w-[240px] xl:h-[240px]",
quote:
"text-[20px] leading-[120%] md:text-[40px] md:leading-[110%] md:tracking-[0px] lg:text-[60px] xl:text-[72px]",
author:
"text-[14px] leading-[120%] md:text-[20px] md:leading-[120%] md:tracking-[0.24px] lg:text-[28px] xl:text-[36px]",
source:
"text-[14px] leading-[120%] md:text-[20px] md:leading-[120%] md:tracking-[0.24px] lg:text-[28px] xl:text-[36px]",
showDecor: true,
}, },
}; };
const config = variantConfig[variant]; const config = variants[variant] || variants.standard;
const containerClasses = ` // Use provided ID or generate a stable one based on content
relative const baseId = id || `quote-${author.toLowerCase().replace(/\s+/g, "-")}`;
flex const quoteId = `${baseId}-content`;
flex-col const authorId = `${baseId}-author`;
gap-[var(--spacing-scale-024)]
${config.container}
`
.trim()
.replace(/\s+/g, " ");
const quoteClasses = ` // Error handling functions
text-[18px] const handleImageError = (error: unknown) => {
md:text-[24px] logger.warn(
leading-[28px] `QuoteBlock: Failed to load avatar image for ${author}:`,
md:leading-[36px] error,
font-medium );
${config.quote} setImageError(true);
` setImageLoading(false);
.trim()
.replace(/\s+/g, " ");
const authorClasses = ` // Call error callback if provided
text-[14px] if (onError) {
md:text-[16px] onError({
leading-[20px] type: "image_load_error",
md:leading-[24px] message: `Failed to load avatar for ${author}`,
font-semibold author,
not-italic avatarSrc,
${config.author} error,
` });
.trim() }
.replace(/\s+/g, " "); };
const authorRoleClasses = `
text-[12px]
md:text-[14px]
leading-[16px]
md:leading-[20px]
font-normal
${config.authorRole}
`
.trim()
.replace(/\s+/g, " ");
const imageContainerClasses = `
w-[var(--measures-sizing-048)]
h-[var(--measures-sizing-048)]
rounded-full
overflow-hidden
shrink-0
`
.trim()
.replace(/\s+/g, " ");
const handleImageLoad = () => { const handleImageLoad = () => {
setImageLoading(false); setImageLoading(false);
setImageError(false); setImageError(false);
}; };
const handleImageError = () => { // Validate required props
setImageError(true); if (!quote || !author) {
setImageLoading(false); logger.error("QuoteBlock: Missing required props (quote or author)");
logger.warn("QuoteBlock: Failed to load author image", { if (onError) {
authorImage, onError({
author, type: "missing_props",
}); message: "QuoteBlock requires quote and author props",
}; quote: !!quote,
author,
});
}
return null; // Don't render if missing required props
}
// Determine which avatar to use
const currentAvatarSrc = imageError ? fallbackAvatarSrc : avatarSrc;
return ( return (
<QuoteBlockView <QuoteBlockView
quoteId={quoteId} className={className}
quote={quote} quote={quote}
author={author} author={author}
authorRole={authorRole} source={source}
authorImage={authorImage} quoteId={quoteId}
variant={variant} authorId={authorId}
className={className} config={config}
imageError={imageError} imageError={imageError}
imageLoading={imageLoading} imageLoading={imageLoading}
containerClasses={containerClasses} currentAvatarSrc={currentAvatarSrc}
quoteClasses={quoteClasses}
authorClasses={authorClasses}
authorRoleClasses={authorRoleClasses}
imageContainerClasses={imageContainerClasses}
onImageLoad={handleImageLoad} onImageLoad={handleImageLoad}
onImageError={handleImageError} onImageError={handleImageError}
/> />
+35 -17
View File
@@ -1,27 +1,45 @@
export interface QuoteBlockProps { export interface QuoteBlockProps {
quote: string; variant?: "compact" | "standard" | "extended";
author?: string;
authorRole?: string;
authorImage?: string;
variant?: "default" | "inverse" | "compact";
className?: string; className?: string;
quote?: string;
author?: string;
source?: string;
avatarSrc?: string;
id?: string;
fallbackAvatarSrc?: string;
onError?: (_error: {
type: string;
message: string;
author?: string;
avatarSrc?: string;
error?: unknown;
quote?: boolean;
}) => void;
}
export interface VariantConfig {
container: string;
card: string;
gap: string;
avatarGap: string;
avatar: string;
quote: string;
author: string;
source: string;
showDecor: boolean;
} }
export interface QuoteBlockViewProps { export interface QuoteBlockViewProps {
quoteId: string;
quote: string;
author?: string;
authorRole?: string;
authorImage?: string;
variant: "default" | "inverse" | "compact";
className: string; className: string;
quote: string;
author: string;
source?: string;
quoteId: string;
authorId: string;
config: VariantConfig;
imageError: boolean; imageError: boolean;
imageLoading: boolean; imageLoading: boolean;
containerClasses: string; currentAvatarSrc: string;
quoteClasses: string;
authorClasses: string;
authorRoleClasses: string;
imageContainerClasses: string;
onImageLoad: () => void; onImageLoad: () => void;
onImageError: () => void; onImageError: (error: unknown) => void;
} }
+126 -42
View File
@@ -1,64 +1,148 @@
"use client";
import { memo } from "react"; import { memo } from "react";
import Image from "next/image"; import Image from "next/image";
import QuoteDecor from "../QuoteDecor"; import QuoteDecor from "../QuoteDecor";
import type { QuoteBlockViewProps } from "./QuoteBlock.types"; import type { QuoteBlockViewProps } from "./QuoteBlock.types";
function QuoteBlockView({ function QuoteBlockView({
quoteId, className,
quote, quote,
author, author,
authorRole, source,
authorImage, quoteId,
variant, authorId,
className, config,
imageError, imageError,
imageLoading, imageLoading,
containerClasses, currentAvatarSrc,
quoteClasses,
authorClasses,
authorRoleClasses,
imageContainerClasses,
onImageLoad, onImageLoad,
onImageError, onImageError,
}: QuoteBlockViewProps) { }: QuoteBlockViewProps) {
return ( return (
<blockquote <section
id={quoteId} className={`${config.container} ${className}`}
className={`${containerClasses} ${className}`} aria-labelledby={quoteId}
aria-label={author ? `Quote by ${author}` : "Quote"} role="region"
> >
<QuoteDecor variant={variant} /> <div
<div className="flex flex-col gap-[var(--spacing-scale-016)] md:gap-[var(--spacing-scale-024)]"> className={`${config.card} bg-[var(--color-surface-default-brand-darker-accent)] relative overflow-hidden`}
<p className={quoteClasses}>{quote}</p> >
{(author || authorRole) && ( {/* Background with noise texture */}
<div className="flex items-center gap-[var(--spacing-scale-016)]"> <div
{authorImage && !imageError && ( className="absolute inset-0 bg-[var(--color-surface-default-brand-darker-accent)]"
<div className={imageContainerClasses}> style={{
{imageLoading ? ( filter:
<div className="w-full h-full bg-[var(--color-surface-default-secondary)] animate-pulse rounded-full" /> 'url(\'data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg"><defs><filter id="grain" filterUnits="objectBoundingBox" x="0" y="0" width="1" height="1" colorInterpolationFilters="sRGB"><feTurbulence type="fractalNoise" baseFrequency="0.4" numOctaves="3" seed="7" stitchTiles="stitch" result="noise"/><feColorMatrix in="noise" result="softNoise" type="matrix" values="0.8 0 0 0 0.3 0 0.6 0 0 0.2 0 0 1.0 0 0.4 0 0 0 0.25 0"/><feComposite in="softNoise" in2="SourceAlpha" operator="in" result="maskedNoise"/><feBlend in="SourceGraphic" in2="maskedNoise" mode="multiply"/></filter></defs></svg>#grain\')',
) : ( }}
<Image />
src={authorImage}
alt={author ? `${author}'s profile picture` : "Author"} {/* DECORATIONS (behind content) */}
width={48} {config.showDecor && (
height={48} <QuoteDecor
className="rounded-full object-cover" className="pointer-events-none absolute z-0
onLoad={onImageLoad} left-0 top-0
onError={onImageError} w-full h-full"
/> aria-hidden="true"
)} />
</div> )}
)}
<div className="flex flex-col"> <div className={`flex flex-col ${config.gap} relative z-10`}>
{author && <cite className={authorClasses}>{author}</cite>} <div className={`flex flex-col ${config.avatarGap}`}>
{authorRole && ( {/* Avatar with error handling */}
<span className={authorRoleClasses}>{authorRole}</span> <div className="relative">
{!imageError ? (
<Image
src={currentAvatarSrc}
alt={`Portrait of ${author}`}
width={64}
height={64}
className={`filter sepia ${
config.avatar
} transition-opacity duration-300 ${
imageLoading ? "opacity-0" : "opacity-100"
}`}
loading="lazy"
onError={onImageError}
onLoad={onImageLoad}
/>
) : null}
{/* Loading state */}
{imageLoading && !imageError && (
<div
className={`absolute inset-0 bg-gray-200 animate-pulse rounded-full ${config.avatar}`}
/>
)}
{/* Error state - show initials */}
{imageError && (
<div
className={`flex items-center justify-center bg-gray-300 rounded-full ${config.avatar} text-gray-600 font-bold`}
>
<span className="text-sm md:text-base lg:text-lg xl:text-xl">
{author
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()}
</span>
</div>
)} )}
</div> </div>
<blockquote
id={quoteId}
aria-labelledby={authorId}
className="relative"
>
<p
data-qopen="&ldquo;"
data-qclose="&rdquo;"
className={[
"font-bricolage-grotesque font-normal",
config.quote,
"text-[var(--color-content-inverse-primary)]",
// give space for the hanging open-quote so it's not clipped:
"pl-[0.6em] -indent-[0.6em]",
// inject quotes
"relative before:content-[attr(data-qopen)] after:content-[attr(data-qclose)]",
// lock quote glyphs to your display face
"before:[font-family:var(--font-bricolage-grotesque)]",
"after:[font-family:var(--font-bricolage-grotesque)]",
].join(" ")}
>
{quote}
</p>
</blockquote>
</div> </div>
)} <footer className="flex flex-col gap-[var(--spacing-scale-008)] md:gap-[var(--spacing-scale-012)] xl:gap-[var(--spacing-scale-020)]">
<cite
id={authorId}
className={`font-inter font-normal ${config.author} text-[var(--color-content-inverse-primary)] uppercase not-italic`}
>
{author}
</cite>
{source && (
<p
data-qopen="&ldquo;"
data-qclose="&rdquo;"
className={[
"font-inter font-normal",
config.source,
"text-[var(--color-content-inverse-primary)] uppercase",
"pl-[0.6em] -indent-[0.6em]",
"relative before:content-[attr(data-qopen)] after:content-[attr(data-qclose)]",
"before:[font-family:var(--font-inter)] after:[font-family:var(--font-inter)]",
].join(" ")}
>
{source}
</p>
)}
</footer>
</div>
</div> </div>
</blockquote> </section>
); );
} }
+2 -1
View File
@@ -9,11 +9,12 @@ interface QuoteDecorProps {
const QuoteDecor = memo<QuoteDecorProps>(({ className = "" }) => { const QuoteDecor = memo<QuoteDecorProps>(({ className = "" }) => {
return ( return (
<svg <svg
className={`text-[var(--color-surface-inverse-brand-primary)] opacity-100 w-full h-full md:max-w-[640px] lg:max-w-[850px] xl:max-w-[1100px] ${className}`} className={`text-[var(--color-surface-inverse-brand-primary)] opacity-100 ${className}`}
viewBox="400 0 442 163" viewBox="400 0 442 163"
aria-hidden="true" aria-hidden="true"
overflow="visible" overflow="visible"
preserveAspectRatio="xMinYMin meet" preserveAspectRatio="xMinYMin meet"
style={{ width: "100%", height: "100%" }}
> >
<g fill="currentColor"> <g fill="currentColor">
{/* Mobile ellipses */} {/* Mobile ellipses */}
@@ -1,7 +1,7 @@
"use client"; "use client";
import { memo, useEffect, useState } from "react"; import { memo, useEffect, useState } from "react";
import { logger } from "../../lib/logger"; import { logger } from "../../../lib/logger";
import WebVitalsDashboardView from "./WebVitalsDashboard.view"; import WebVitalsDashboardView from "./WebVitalsDashboard.view";
import type { Metrics, Vitals, VitalData } from "./WebVitalsDashboard.types"; import type { Metrics, Vitals, VitalData } from "./WebVitalsDashboard.types";