Second pass on component refactor

This commit is contained in:
adilallo
2026-01-29 17:35:51 -07:00
parent 7b9101824a
commit b5735bb2ad
26 changed files with 778 additions and 491 deletions
+156
View File
@@ -0,0 +1,156 @@
"use client";
import { memo } from "react";
import { usePathname } from "next/navigation";
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 { HeaderView } from "./Header.view";
import type { HeaderProps, NavSize } from "./Header.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: "header" as const, showText: false },
{
breakpoint: "hidden sm:block md:hidden",
size: "header" as const,
showText: true,
},
{
breakpoint: "hidden md:block lg:hidden",
size: "headerMd" as const,
showText: true,
},
{
breakpoint: "hidden lg:block xl:hidden",
size: "headerLg" as const,
showText: true,
},
{ breakpoint: "hidden xl:block", size: "headerXl" as const, showText: true },
];
const HeaderContainer = memo<HeaderProps>(() => {
const pathname = usePathname();
// Schema markup for site navigation
const schemaData = {
"@context": "https://schema.org",
"@type": "WebSite",
name: "CommunityRule",
url: "https://communityrule.com",
potentialAction: {
"@type": "SearchAction",
target: "https://communityrule.com/search?q={search_term_string}",
"query-input": "required name=search_term_string",
},
};
const renderNavigationItems = (size: NavSize) => {
return navigationItems.map((item, index) => (
<MenuBarItem
key={index}
href={item.href}
size={item.extraPadding && size === "xsmall" ? "xsmallUseCases" : size}
isActive={pathname === item.href}
ariaLabel={`Navigate to ${item.text} page`}
>
{item.text}
</MenuBarItem>
));
};
const renderAvatarGroup = (
containerSize: "small" | "medium" | "large" | "xlarge",
avatarSize: "small" | "medium" | "large" | "xlarge",
) => {
return (
<AvatarContainer size={containerSize}>
{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} 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}
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 (
<HeaderView
schemaData={schemaData}
navigationItems={navigationItems}
avatarImages={avatarImages}
logoConfig={logoConfig}
pathname={pathname}
renderNavigationItems={renderNavigationItems}
renderAvatarGroup={renderAvatarGroup}
renderLoginButton={renderLoginButton}
renderCreateRuleButton={renderCreateRuleButton}
renderLogo={renderLogo}
/>
);
});
HeaderContainer.displayName = "Header";
export default HeaderContainer;
+83
View File
@@ -0,0 +1,83 @@
export interface HeaderProps {
// No props currently, but keeping interface for future extensibility
}
export interface HeaderViewProps {
schemaData: {
"@context": string;
"@type": string;
name: string;
url: string;
potentialAction: {
"@type": string;
target: string;
"query-input": string;
};
};
navigationItems: Array<{
href: string;
text: string;
extraPadding?: boolean;
}>;
avatarImages: Array<{
src: string;
alt: string;
}>;
logoConfig: Array<{
breakpoint: string;
size:
| "default"
| "homeHeaderXsmall"
| "homeHeaderSm"
| "homeHeaderMd"
| "homeHeaderLg"
| "homeHeaderXl"
| "header"
| "headerMd"
| "headerLg"
| "headerXl"
| "footer"
| "footerLg";
showText: boolean;
}>;
pathname: string;
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;
}
export type NavSize =
| "default"
| "xsmall"
| "xsmallUseCases"
| "home"
| "homeMd"
| "homeUseCases"
| "large"
| "largeUseCases"
| "homeXlarge"
| "xlarge";
@@ -1,151 +1,23 @@
"use client"; import Logo from "../Logo";
import MenuBar from "../MenuBar";
import { memo } from "react"; import MenuBarItem from "../MenuBarItem";
import { usePathname } from "next/navigation"; import Button from "../Button";
import Logo from "./Logo"; import AvatarContainer from "../AvatarContainer";
import MenuBar from "./MenuBar"; import Avatar from "../Avatar";
import MenuBarItem from "./MenuBarItem"; import type { HeaderViewProps } from "./Header.types";
import Button from "./Button";
import AvatarContainer from "./AvatarContainer";
import Avatar from "./Avatar";
import { getAssetPath, ASSETS } from "../../lib/assetUtils";
// 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: "header" as const, showText: false },
{
breakpoint: "hidden sm:block md:hidden",
size: "header" as const,
showText: true,
},
{
breakpoint: "hidden md:block lg:hidden",
size: "headerMd" as const,
showText: true,
},
{
breakpoint: "hidden lg:block xl:hidden",
size: "headerLg" as const,
showText: true,
},
{ breakpoint: "hidden xl:block", size: "headerXl" as const, showText: true },
];
const Header = memo(() => {
const pathname = usePathname();
// Schema markup for site navigation
const schemaData = {
"@context": "https://schema.org",
"@type": "WebSite",
name: "CommunityRule",
url: "https://communityrule.com",
potentialAction: {
"@type": "SearchAction",
target: "https://communityrule.com/search?q={search_term_string}",
"query-input": "required name=search_term_string",
},
};
type NavSize =
| "default"
| "xsmall"
| "xsmallUseCases"
| "home"
| "homeMd"
| "homeUseCases"
| "large"
| "largeUseCases"
| "homeXlarge"
| "xlarge";
const renderNavigationItems = (size: NavSize) => {
return navigationItems.map((item, index) => (
<MenuBarItem
key={index}
href={item.href}
size={item.extraPadding && size === "xsmall" ? "xsmallUseCases" : size}
isActive={pathname === item.href}
ariaLabel={`Navigate to ${item.text} page`}
>
{item.text}
</MenuBarItem>
));
};
const renderAvatarGroup = (
containerSize: "small" | "medium" | "large" | "xlarge",
avatarSize: "small" | "medium" | "large" | "xlarge",
) => {
return (
<AvatarContainer size={containerSize}>
{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} 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}
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} />;
};
export function HeaderView({
schemaData,
navigationItems,
avatarImages,
logoConfig,
pathname,
renderNavigationItems,
renderAvatarGroup,
renderLoginButton,
renderCreateRuleButton,
renderLogo,
}: HeaderViewProps) {
return ( return (
<> <>
<script <script
@@ -253,8 +125,4 @@ const Header = memo(() => {
</header> </header>
</> </>
); );
}); }
Header.displayName = "Header";
export default Header;
+3
View File
@@ -0,0 +1,3 @@
export { default } from "./Header.container";
export type { HeaderProps } from "./Header.types";
export { navigationItems, avatarImages, logoConfig } from "./Header.container";
-83
View File
@@ -1,83 +0,0 @@
"use client";
import { memo, useCallback, useId } from "react";
import RadioButton from "./RadioButton";
interface RadioOption {
value: string;
label: string;
ariaLabel?: string;
}
interface RadioGroupProps {
name?: string;
value?: string;
onChange?: (_data: { value: string }) => void;
mode?: "standard" | "inverse";
state?: "default" | "hover" | "focus";
disabled?: boolean;
options?: RadioOption[];
className?: string;
"aria-label"?: string;
}
const RadioGroup = ({
name,
value,
onChange,
mode = "standard",
state = "default",
disabled = false,
options = [],
className = "",
...props
}: RadioGroupProps) => {
// Generate unique ID for accessibility if not provided
const generatedId = useId();
const groupId = name || `radio-group-${generatedId}`;
const handleChange = useCallback(
(optionValue: string) => {
if (!disabled && onChange) {
onChange({ value: optionValue });
}
},
[disabled, onChange],
);
return (
<div
className={`space-y-[8px] ${className}`}
role="radiogroup"
aria-label={props["aria-label"]}
{...props}
>
{options.map((option) => {
const isSelected = value === option.value;
return (
<RadioButton
key={option.value}
checked={isSelected}
mode={mode}
state={state}
disabled={disabled}
label={option.label}
name={groupId}
value={option.value}
ariaLabel={option.ariaLabel}
onChange={({ checked }) => {
if (checked) {
handleChange(option.value);
}
}}
/>
);
})}
</div>
);
};
RadioGroup.displayName = "RadioGroup";
export default memo(RadioGroup);
@@ -0,0 +1,48 @@
"use client";
import { memo, useCallback, useId } from "react";
import { RadioGroupView } from "./RadioGroup.view";
import type { RadioGroupProps } from "./RadioGroup.types";
const RadioGroupContainer = ({
name,
value,
onChange,
mode = "standard",
state = "default",
disabled = false,
options = [],
className = "",
...props
}: RadioGroupProps) => {
// Generate unique ID for accessibility if not provided
const generatedId = useId();
const groupId = name || `radio-group-${generatedId}`;
const handleChange = useCallback(
(optionValue: string) => {
if (!disabled && onChange) {
onChange({ value: optionValue });
}
},
[disabled, onChange],
);
return (
<RadioGroupView
groupId={groupId}
value={value}
mode={mode}
state={state}
disabled={disabled}
options={options}
className={className}
ariaLabel={props["aria-label"]}
onOptionChange={handleChange}
/>
);
};
RadioGroupContainer.displayName = "RadioGroup";
export default memo(RadioGroupContainer);
@@ -0,0 +1,29 @@
export interface RadioOption {
value: string;
label: string;
ariaLabel?: string;
}
export interface RadioGroupProps {
name?: string;
value?: string;
onChange?: (_data: { value: string }) => void;
mode?: "standard" | "inverse";
state?: "default" | "hover" | "focus";
disabled?: boolean;
options?: RadioOption[];
className?: string;
"aria-label"?: string;
}
export interface RadioGroupViewProps {
groupId: string;
value?: string;
mode: "standard" | "inverse";
state: "default" | "hover" | "focus";
disabled: boolean;
options: RadioOption[];
className: string;
ariaLabel?: string;
onOptionChange: (optionValue: string) => void;
}
@@ -0,0 +1,45 @@
import RadioButton from "../RadioButton";
import type { RadioGroupViewProps } from "./RadioGroup.types";
export function RadioGroupView({
groupId,
value,
mode,
state,
disabled,
options,
className,
ariaLabel,
onOptionChange,
}: RadioGroupViewProps) {
return (
<div
className={`space-y-[8px] ${className}`}
role="radiogroup"
aria-label={ariaLabel}
>
{options.map((option) => {
const isSelected = value === option.value;
return (
<RadioButton
key={option.value}
checked={isSelected}
mode={mode}
state={state}
disabled={disabled}
label={option.label}
name={groupId}
value={option.value}
ariaLabel={option.ariaLabel}
onChange={({ checked }) => {
if (checked) {
onOptionChange(option.value);
}
}}
/>
);
})}
</div>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./RadioGroup.container";
export type { RadioGroupProps, RadioOption } from "./RadioGroup.types";
@@ -1,17 +1,11 @@
"use client"; "use client";
import { useState, useEffect, memo, useMemo, useCallback } from "react"; import { useState, useEffect, memo, useMemo, useCallback } from "react";
import ContentThumbnailTemplate from "./ContentThumbnailTemplate"; import { useIsMobile } from "../../hooks";
import type { BlogPost } from "../../lib/content"; import { RelatedArticlesView } from "./RelatedArticles.view";
import { useIsMobile } from "../hooks"; import type { RelatedArticlesProps } from "./RelatedArticles.types";
interface RelatedArticlesProps { const RelatedArticlesContainer = memo<RelatedArticlesProps>(
relatedPosts: BlogPost[];
currentPostSlug: string;
slugOrder?: string[];
}
const RelatedArticles = memo<RelatedArticlesProps>(
({ relatedPosts, currentPostSlug, slugOrder = [] }) => { ({ relatedPosts, currentPostSlug, slugOrder = [] }) => {
// Memoize filtered posts to prevent unnecessary re-computations // Memoize filtered posts to prevent unnecessary re-computations
const filteredPosts = useMemo( const filteredPosts = useMemo(
@@ -73,8 +67,6 @@ const RelatedArticles = memo<RelatedArticlesProps>(
[currentIndex, progress], [currentIndex, progress],
); );
// Mobile detection is now handled by useIsMobile hook
// Auto-advance every 3 seconds (only on mobile) // Auto-advance every 3 seconds (only on mobile)
useEffect(() => { useEffect(() => {
if (filteredPosts.length <= 1 || !isMobile) return; if (filteredPosts.length <= 1 || !isMobile) return;
@@ -103,69 +95,19 @@ const RelatedArticles = memo<RelatedArticlesProps>(
return () => clearInterval(progressInterval); return () => clearInterval(progressInterval);
}, [currentIndex, filteredPosts.length, isMobile]); }, [currentIndex, filteredPosts.length, isMobile]);
if (filteredPosts.length === 0) {
return null;
}
return ( return (
<section <RelatedArticlesView
className="py-[var(--spacing-scale-032)] lg:py-[var(--spacing-scale-064)]" filteredPosts={filteredPosts}
data-testid="related-articles" slugOrder={slugOrder}
> isMobile={isMobile}
<div className="flex flex-col gap-[var(--spacing-scale-032)] lg:gap-[51px]"> transformStyle={transformStyle}
<h2 className="text-[32px] lg:text-[44px] leading-[110%] font-medium text-[var(--color-content-inverse-primary)] text-center"> getProgressStyle={getProgressStyle}
Related Articles onMouseDown={handleMouseDown}
</h2> />
{/* Horizontal Articles Row - Carousel on mobile, Scrollable slider on desktop */}
<div className="flex justify-center overflow-hidden">
<div
className={`flex gap-0 transition-transform duration-500 ease-in-out ${
!isMobile
? "overflow-x-auto scrollbar-hide cursor-grab active:cursor-grabbing"
: ""
}`}
style={transformStyle}
onMouseDown={!isMobile ? handleMouseDown : undefined}
>
{filteredPosts.map((relatedPost) => (
<div
key={relatedPost.slug}
className="flex flex-col items-center flex-shrink-0"
data-testid={`related-${relatedPost.slug}`}
>
<ContentThumbnailTemplate
post={relatedPost}
variant="vertical"
slugOrder={slugOrder}
/>
</div>
))}
</div>
</div>
{/* Progress bars - only show on mobile */}
{isMobile && (
<div className="flex justify-center gap-[var(--measures-spacing-008)] px-[var(--measures-spacing-064)]">
{filteredPosts.map((relatedPost, index) => (
<div
key={relatedPost.slug}
className="max-w-[var(--measures-spacing-056)] w-full h-[var(--measures-spacing-004)] bg-gray-200 rounded-full overflow-hidden"
>
<div
className="h-full bg-gray-600 rounded-full transition-all duration-75 ease-linear"
style={getProgressStyle(index)}
/>
</div>
))}
</div>
)}
</div>
</section>
); );
}, },
); );
RelatedArticles.displayName = "RelatedArticles"; RelatedArticlesContainer.displayName = "RelatedArticles";
export default RelatedArticles; export default RelatedArticlesContainer;
@@ -0,0 +1,16 @@
import type { BlogPost } from "../../../lib/content";
export interface RelatedArticlesProps {
relatedPosts: BlogPost[];
currentPostSlug: string;
slugOrder?: string[];
}
export interface RelatedArticlesViewProps {
filteredPosts: BlogPost[];
slugOrder: string[];
isMobile: boolean;
transformStyle: React.CSSProperties;
getProgressStyle: (index: number) => React.CSSProperties;
onMouseDown?: (e: React.MouseEvent<HTMLDivElement>) => void;
}
@@ -0,0 +1,72 @@
import ContentThumbnailTemplate from "../ContentThumbnailTemplate";
import type { RelatedArticlesViewProps } from "./RelatedArticles.types";
export function RelatedArticlesView({
filteredPosts,
slugOrder,
isMobile,
transformStyle,
getProgressStyle,
onMouseDown,
}: RelatedArticlesViewProps) {
if (filteredPosts.length === 0) {
return null;
}
return (
<section
className="py-[var(--spacing-scale-032)] lg:py-[var(--spacing-scale-064)]"
data-testid="related-articles"
>
<div className="flex flex-col gap-[var(--spacing-scale-032)] lg:gap-[51px]">
<h2 className="text-[32px] lg:text-[44px] leading-[110%] font-medium text-[var(--color-content-inverse-primary)] text-center">
Related Articles
</h2>
{/* Horizontal Articles Row - Carousel on mobile, Scrollable slider on desktop */}
<div className="flex justify-center overflow-hidden">
<div
className={`flex gap-0 transition-transform duration-500 ease-in-out ${
!isMobile
? "overflow-x-auto scrollbar-hide cursor-grab active:cursor-grabbing"
: ""
}`}
style={transformStyle}
onMouseDown={!isMobile ? onMouseDown : undefined}
>
{filteredPosts.map((relatedPost) => (
<div
key={relatedPost.slug}
className="flex flex-col items-center flex-shrink-0"
data-testid={`related-${relatedPost.slug}`}
>
<ContentThumbnailTemplate
post={relatedPost}
variant="vertical"
slugOrder={slugOrder}
/>
</div>
))}
</div>
</div>
{/* Progress bars - only show on mobile */}
{isMobile && (
<div className="flex justify-center gap-[var(--measures-spacing-008)] px-[var(--measures-spacing-064)]">
{filteredPosts.map((relatedPost, index) => (
<div
key={relatedPost.slug}
className="max-w-[var(--measures-spacing-056)] w-full h-[var(--measures-spacing-004)] bg-gray-200 rounded-full overflow-hidden"
>
<div
className="h-full bg-gray-600 rounded-full transition-all duration-75 ease-linear"
style={getProgressStyle(index)}
/>
</div>
))}
</div>
)}
</div>
</section>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./RelatedArticles.container";
export type { RelatedArticlesProps } from "./RelatedArticles.types";
-101
View File
@@ -1,101 +0,0 @@
"use client";
import { memo } from "react";
interface RuleCardProps {
title: string;
description?: string;
icon?: React.ReactNode;
backgroundColor?: string;
className?: string;
onClick?: () => void;
}
declare global {
interface Window {
gtag?: (
_command: string,
_eventName: string,
_params?: Record<string, unknown>,
) => void;
analytics?: {
track: (_eventName: string, _params?: Record<string, unknown>) => void;
};
}
}
const RuleCard = memo<RuleCardProps>(
({
title,
description,
icon,
backgroundColor = "bg-[var(--color-community-teal-100)]",
className = "",
onClick,
}) => {
const handleClick = () => {
// Basic analytics event tracking
if (typeof window !== "undefined" && window.gtag) {
window.gtag("event", "template_selected", {
template_name: title,
template_type: "governance_pattern",
});
}
// Custom analytics event for other tracking systems
if (typeof window !== "undefined" && window.analytics) {
window.analytics.track("Template Selected", {
templateName: title,
templateType: "governance_pattern",
});
}
if (onClick) onClick();
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
handleClick();
}
};
return (
<div
className={`${backgroundColor} rounded-[var(--radius-measures-radius-small)] pt-[var(--spacing-scale-012)] pr-[var(--spacing-scale-012)] pl-[var(--spacing-scale-012)] pb-[var(--spacing-scale-024)] md:p-[var(--spacing-scale-024)] md:h-[210px] lg:h-[277px] flex flex-col gap-[18px] shadow-lg backdrop-blur-sm transition-all duration-500 ease-in-out hover:shadow-xl hover:scale-[1.02] focus:outline-none focus:ring-2 focus:ring-[var(--color-community-teal-500)] focus:ring-offset-2 cursor-pointer min-h-[44px] min-w-[44px] ${className}`}
tabIndex={0}
role="button"
aria-label={`Learn more about ${title} governance pattern`}
onClick={handleClick}
onKeyDown={handleKeyDown}
>
{/* Header Container */}
<div className="grid grid-cols-[auto_1fr] h-[72px] md:h-[80px] lg:h-[138px] border-b border-[var(--color-surface-default-primary)]">
{/* Icon Container */}
{icon && (
<div className="p-[var(--spacing-scale-016)] md:p-[var(--spacing-scale-012)] lg:p-[var(--spacing-scale-024)] border-r border-[var(--color-surface-default-primary)] w-fit flex items-center justify-center">
{icon}
</div>
)}
{/* Title Container */}
{title && (
<div className="pl-[var(--spacing-scale-008)] md:pl-[var(--spacing-scale-012)] lg:pl-[var(--spacing-scale-024)] flex items-center gap-[var(--spacing-scale-004)]">
<h3 className="font-space-grotesk font-bold text-[20px] md:text-[28px] lg:text-[36px] leading-[28px] md:leading-[36px] lg:leading-[44px] text-[--color-content-inverse-primary]">
{title}
</h3>
</div>
)}
</div>
{description && (
<p className="font-inter font-medium text-[12px] md:text-[14px] lg:text-[18px] leading-[14px] md:leading-[16px] lg:leading-[24px] text-[var(--color-content-inverse-primary)]">
{description}
</p>
)}
</div>
);
},
);
RuleCard.displayName = "RuleCard";
export default RuleCard;
@@ -0,0 +1,72 @@
"use client";
import { memo } from "react";
import { RuleCardView } from "./RuleCard.view";
import type { RuleCardProps } from "./RuleCard.types";
declare global {
interface Window {
gtag?: (
_command: string,
_eventName: string,
_params?: Record<string, unknown>,
) => void;
analytics?: {
track: (_eventName: string, _params?: Record<string, unknown>) => void;
};
}
}
const RuleCardContainer = memo<RuleCardProps>(
({
title,
description,
icon,
backgroundColor = "bg-[var(--color-community-teal-100)]",
className = "",
onClick,
}) => {
const handleClick = () => {
// Basic analytics event tracking
if (typeof window !== "undefined" && window.gtag) {
window.gtag("event", "template_selected", {
template_name: title,
template_type: "governance_pattern",
});
}
// Custom analytics event for other tracking systems
if (typeof window !== "undefined" && window.analytics) {
window.analytics.track("Template Selected", {
templateName: title,
templateType: "governance_pattern",
});
}
if (onClick) onClick();
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
handleClick();
}
};
return (
<RuleCardView
title={title}
description={description}
icon={icon}
backgroundColor={backgroundColor}
className={className}
onClick={handleClick}
onKeyDown={handleKeyDown}
/>
);
},
);
RuleCardContainer.displayName = "RuleCard";
export default RuleCardContainer;
+18
View File
@@ -0,0 +1,18 @@
export interface RuleCardProps {
title: string;
description?: string;
icon?: React.ReactNode;
backgroundColor?: string;
className?: string;
onClick?: () => void;
}
export interface RuleCardViewProps {
title: string;
description?: string;
icon?: React.ReactNode;
backgroundColor: string;
className: string;
onClick: () => void;
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void;
}
+45
View File
@@ -0,0 +1,45 @@
import type { RuleCardViewProps } from "./RuleCard.types";
export function RuleCardView({
title,
description,
icon,
backgroundColor,
className,
onClick,
onKeyDown,
}: RuleCardViewProps) {
return (
<div
className={`${backgroundColor} rounded-[var(--radius-measures-radius-small)] pt-[var(--spacing-scale-012)] pr-[var(--spacing-scale-012)] pl-[var(--spacing-scale-012)] pb-[var(--spacing-scale-024)] md:p-[var(--spacing-scale-024)] md:h-[210px] lg:h-[277px] flex flex-col gap-[18px] shadow-lg backdrop-blur-sm transition-all duration-500 ease-in-out hover:shadow-xl hover:scale-[1.02] focus:outline-none focus:ring-2 focus:ring-[var(--color-community-teal-500)] focus:ring-offset-2 cursor-pointer min-h-[44px] min-w-[44px] ${className}`}
tabIndex={0}
role="button"
aria-label={`Learn more about ${title} governance pattern`}
onClick={onClick}
onKeyDown={onKeyDown}
>
{/* Header Container */}
<div className="grid grid-cols-[auto_1fr] h-[72px] md:h-[80px] lg:h-[138px] border-b border-[var(--color-surface-default-primary)]">
{/* Icon Container */}
{icon && (
<div className="p-[var(--spacing-scale-016)] md:p-[var(--spacing-scale-012)] lg:p-[var(--spacing-scale-024)] border-r border-[var(--color-surface-default-primary)] w-fit flex items-center justify-center">
{icon}
</div>
)}
{/* Title Container */}
{title && (
<div className="pl-[var(--spacing-scale-008)] md:pl-[var(--spacing-scale-012)] lg:pl-[var(--spacing-scale-024)] flex items-center gap-[var(--spacing-scale-004)]">
<h3 className="font-space-grotesk font-bold text-[20px] md:text-[28px] lg:text-[36px] leading-[28px] md:leading-[36px] lg:leading-[44px] text-[--color-content-inverse-primary]">
{title}
</h3>
</div>
)}
</div>
{description && (
<p className="font-inter font-medium text-[12px] md:text-[14px] lg:text-[18px] leading-[14px] md:leading-[16px] lg:leading-[24px] text-[var(--color-content-inverse-primary)]">
{description}
</p>
)}
</div>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./RuleCard.container";
export type { RuleCardProps } from "./RuleCard.types";
@@ -0,0 +1,44 @@
"use client";
import { memo } from "react";
import { logger } from "../../../lib/logger";
import { RuleStackView } from "./RuleStack.view";
import type { RuleStackProps } from "./RuleStack.types";
declare global {
interface Window {
gtag?: (
_command: string,
_eventName: string,
_params?: Record<string, unknown>,
) => void;
analytics?: {
track: (_eventName: string, _params?: Record<string, unknown>) => void;
};
}
}
const RuleStackContainer = memo<RuleStackProps>(({ className = "" }) => {
const handleTemplateClick = (templateName: string) => {
// Basic analytics tracking
if (typeof window !== "undefined") {
if (window.gtag) {
window.gtag("event", "template_click", {
template_name: templateName,
});
}
if (window.analytics) {
window.analytics.track("Template Clicked", {
templateName: templateName,
});
}
}
logger.debug(`${templateName} template clicked`);
};
return <RuleStackView className={className} onTemplateClick={handleTemplateClick} />;
});
RuleStackContainer.displayName = "RuleStack";
export default RuleStackContainer;
@@ -0,0 +1,8 @@
export interface RuleStackProps {
className?: string;
}
export interface RuleStackViewProps {
className: string;
onTemplateClick: (templateName: string) => void;
}
@@ -1,47 +1,13 @@
"use client";
import { memo } from "react";
import Image from "next/image"; import Image from "next/image";
import RuleCard from "./RuleCard"; import RuleCard from "../RuleCard";
import Button from "./Button"; import Button from "../Button";
import { getAssetPath } from "../../lib/assetUtils"; import { getAssetPath } from "../../../lib/assetUtils";
import { logger } from "../../lib/logger"; import type { RuleStackViewProps } from "./RuleStack.types";
interface RuleStackProps {
className?: string;
}
declare global {
interface Window {
gtag?: (
_command: string,
_eventName: string,
_params?: Record<string, unknown>,
) => void;
analytics?: {
track: (_eventName: string, _params?: Record<string, unknown>) => void;
};
}
}
const RuleStack = memo<RuleStackProps>(({ className = "" }) => {
const handleTemplateClick = (templateName: string) => {
// Basic analytics tracking
if (typeof window !== "undefined") {
if (window.gtag) {
window.gtag("event", "template_click", {
template_name: templateName,
});
}
if (window.analytics) {
window.analytics.track("Template Clicked", {
templateName: templateName,
});
}
}
logger.debug(`${templateName} template clicked`);
};
export function RuleStackView({
className,
onTemplateClick,
}: RuleStackViewProps) {
return ( return (
<section <section
className={`w-full bg-transparent py-[var(--spacing-scale-032)] px-[var(--spacing-scale-020)] md:py-[var(--spacing-scale-048)] md:px-[var(--spacing-scale-032)] xmd:py-[var(--spacing-scale-056)] xmd:px-[var(--spacing-scale-032)] lg:py-[var(--spacing-scale-064)] lg:px-[var(--spacing-scale-064)] xl:py-[var(--spacing-scale-064)] xl:px-[var(--spacing-scale-096)] flex flex-col gap-[var(--spacing-scale-024)] xmd:gap-[var(--spacing-scale-032)] lg:gap-[var(--spacing-scale-040)] ${className}`} className={`w-full bg-transparent py-[var(--spacing-scale-032)] px-[var(--spacing-scale-020)] md:py-[var(--spacing-scale-048)] md:px-[var(--spacing-scale-032)] xmd:py-[var(--spacing-scale-056)] xmd:px-[var(--spacing-scale-032)] lg:py-[var(--spacing-scale-064)] lg:px-[var(--spacing-scale-064)] xl:py-[var(--spacing-scale-064)] xl:px-[var(--spacing-scale-096)] flex flex-col gap-[var(--spacing-scale-024)] xmd:gap-[var(--spacing-scale-032)] lg:gap-[var(--spacing-scale-040)] ${className}`}
@@ -60,7 +26,7 @@ const RuleStack = memo<RuleStackProps>(({ className = "" }) => {
/> />
} }
backgroundColor="bg-[var(--color-surface-default-brand-lime)]" backgroundColor="bg-[var(--color-surface-default-brand-lime)]"
onClick={() => handleTemplateClick("Consensus clusters")} onClick={() => onTemplateClick("Consensus clusters")}
/> />
<RuleCard <RuleCard
title="Consensus" title="Consensus"
@@ -75,7 +41,7 @@ const RuleStack = memo<RuleStackProps>(({ className = "" }) => {
/> />
} }
backgroundColor="bg-[var(--color-surface-default-brand-rust)]" backgroundColor="bg-[var(--color-surface-default-brand-rust)]"
onClick={() => handleTemplateClick("Consensus")} onClick={() => onTemplateClick("Consensus")}
/> />
<RuleCard <RuleCard
title="Elected Board" title="Elected Board"
@@ -90,7 +56,7 @@ const RuleStack = memo<RuleStackProps>(({ className = "" }) => {
/> />
} }
backgroundColor="bg-[var(--color-surface-default-brand-red)]" backgroundColor="bg-[var(--color-surface-default-brand-red)]"
onClick={() => handleTemplateClick("Elected Board")} onClick={() => onTemplateClick("Elected Board")}
/> />
<RuleCard <RuleCard
title="Petition" title="Petition"
@@ -105,7 +71,7 @@ const RuleStack = memo<RuleStackProps>(({ className = "" }) => {
/> />
} }
backgroundColor="bg-[var(--color-surface-default-brand-teal)]" backgroundColor="bg-[var(--color-surface-default-brand-teal)]"
onClick={() => handleTemplateClick("Petition")} onClick={() => onTemplateClick("Petition")}
/> />
</div> </div>
@@ -117,8 +83,4 @@ const RuleStack = memo<RuleStackProps>(({ className = "" }) => {
</div> </div>
</section> </section>
); );
}); }
RuleStack.displayName = "RuleStack";
export default RuleStack;
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./RuleStack.container";
export type { RuleStackProps } from "./RuleStack.types";
@@ -1,25 +1,10 @@
"use client";
import { memo, useCallback, useId, forwardRef } from "react"; import { memo, useCallback, useId, forwardRef } from "react";
import { ToggleGroupView } from "./ToggleGroup.view";
import type { ToggleGroupProps } from "./ToggleGroup.types";
interface ToggleGroupProps extends Omit< const ToggleGroupContainer = memo(
React.ButtonHTMLAttributes<HTMLButtonElement>,
"onChange"
> {
children?: React.ReactNode;
className?: string;
position?: "left" | "middle" | "right";
state?: "default" | "hover" | "focus" | "selected";
showText?: boolean;
ariaLabel?: string;
onChange?: (
_e:
| React.MouseEvent<HTMLButtonElement>
| React.KeyboardEvent<HTMLButtonElement>,
) => void;
onFocus?: (_e: React.FocusEvent<HTMLButtonElement>) => void;
onBlur?: (_e: React.FocusEvent<HTMLButtonElement>) => void;
}
const ToggleGroup = memo(
forwardRef<HTMLButtonElement, ToggleGroupProps>((props, ref) => { forwardRef<HTMLButtonElement, ToggleGroupProps>((props, ref) => {
const { const {
children, children,
@@ -132,25 +117,25 @@ const ToggleGroup = memo(
.replace(/\s+/g, " "); .replace(/\s+/g, " ");
return ( return (
<button <ToggleGroupView
ref={ref} groupId={groupId}
id={groupId} children={children}
type="button" className={className}
role="button" position={position}
aria-label={ariaLabel || (showText ? undefined : "Toggle option")} state={state}
showText={showText}
ariaLabel={ariaLabel}
toggleClasses={toggleClasses}
onClick={handleClick} onClick={handleClick}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onFocus={handleFocus} onFocus={handleFocus}
onBlur={handleBlur} onBlur={handleBlur}
className={toggleClasses}
{...rest} {...rest}
> />
{showText ? children : children || "☰"}
</button>
); );
}), }),
); );
ToggleGroup.displayName = "ToggleGroup"; ToggleGroupContainer.displayName = "ToggleGroup";
export default ToggleGroup; export default ToggleGroupContainer;
@@ -0,0 +1,31 @@
export interface ToggleGroupProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
children?: React.ReactNode;
className?: string;
position?: "left" | "middle" | "right";
state?: "default" | "hover" | "focus" | "selected";
showText?: boolean;
ariaLabel?: string;
onChange?: (
_e:
| React.MouseEvent<HTMLButtonElement>
| React.KeyboardEvent<HTMLButtonElement>,
) => void;
onFocus?: (_e: React.FocusEvent<HTMLButtonElement>) => void;
onBlur?: (_e: React.FocusEvent<HTMLButtonElement>) => void;
}
export interface ToggleGroupViewProps {
groupId: string;
children?: React.ReactNode;
className: string;
position: "left" | "middle" | "right";
state: "default" | "hover" | "focus" | "selected";
showText: boolean;
ariaLabel?: string;
toggleClasses: string;
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
onKeyDown: (e: React.KeyboardEvent<HTMLButtonElement>) => void;
onFocus: (e: React.FocusEvent<HTMLButtonElement>) => void;
onBlur: (e: React.FocusEvent<HTMLButtonElement>) => void;
}
@@ -0,0 +1,34 @@
import type { ToggleGroupViewProps } from "./ToggleGroup.types";
export function ToggleGroupView({
groupId,
children,
className,
position,
state,
showText,
ariaLabel,
toggleClasses,
onClick,
onKeyDown,
onFocus,
onBlur,
...rest
}: ToggleGroupViewProps) {
return (
<button
id={groupId}
type="button"
role="button"
aria-label={ariaLabel || (showText ? undefined : "Toggle option")}
onClick={onClick}
onKeyDown={onKeyDown}
onFocus={onFocus}
onBlur={onBlur}
className={toggleClasses}
{...rest}
>
{showText ? children : children || "☰"}
</button>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./ToggleGroup.container";
export type { ToggleGroupProps } from "./ToggleGroup.types";