First pass on component refactor

This commit is contained in:
adilallo
2026-01-29 17:29:37 -07:00
parent 11f32d7051
commit 7b9101824a
25 changed files with 1240 additions and 559 deletions
-143
View File
@@ -1,143 +0,0 @@
"use client";
import { memo } from "react";
import ContentLockup from "./ContentLockup";
import Button from "./Button";
import { useAnalytics } from "../hooks";
interface AskOrganizerProps {
title?: string;
subtitle?: string;
description?: string;
buttonText?: string;
buttonHref?: string;
className?: string;
variant?: "centered" | "left-aligned" | "compact" | "inverse";
onContactClick?: (_data: {
event: string;
component: string;
variant: string;
buttonText: string;
buttonHref: string;
timestamp: string;
}) => void;
}
const AskOrganizer = memo<AskOrganizerProps>(
({
title,
subtitle,
description,
buttonText = "Ask an organizer",
buttonHref = "#",
className = "",
variant = "centered",
onContactClick,
}) => {
const { trackEvent, trackCustomEvent } = useAnalytics();
// Analytics tracking for contact button clicks
const handleContactClick = (
_event: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>,
) => {
// Track with standard analytics
trackEvent({
event: "contact_button_click",
category: "engagement",
label: "ask_organizer",
component: "AskOrganizer",
variant,
});
// Also call custom callback if provided
trackCustomEvent(
"contact_button_click",
{
component: "AskOrganizer",
variant,
buttonText,
buttonHref,
},
onContactClick,
);
};
// Variant-specific styling
const variantStyles: Record<
string,
{ container: string; buttonContainer: string }
> = {
centered: {
container: "text-center",
buttonContainer: "flex justify-center",
},
"left-aligned": {
container: "text-left",
buttonContainer: "flex justify-start",
},
compact: {
container: "text-center",
buttonContainer: "flex justify-center",
},
inverse: {
container: "text-center",
buttonContainer: "flex justify-center",
},
};
const styles = variantStyles[variant] || variantStyles.centered;
// Section padding based on variant
const sectionPadding =
variant === "compact"
? "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-032)] md:py-[var(--spacing-scale-096)] md:px-[var(--spacing-scale-064)]";
// Gap between content and button based on variant
const contentGap =
variant === "compact"
? "gap-[var(--spacing-scale-020)]"
: "gap-[var(--spacing-scale-040)]";
const labelledBy = title ? "ask-organizer-headline" : undefined;
return (
<section
className={`${sectionPadding} ${className}`}
aria-labelledby={labelledBy}
aria-label={labelledBy ? undefined : "Ask an organizer"}
tabIndex={-1}
>
<div className={`flex flex-col ${contentGap} ${styles.container}`}>
{/* Content Lockup */}
<ContentLockup
title={title}
subtitle={subtitle}
description={description}
variant={variant === "inverse" ? "ask-inverse" : "ask"}
alignment={variant === "left-aligned" ? "left" : "center"}
titleId={labelledBy}
/>
{/* Button */}
<div className={styles.buttonContainer}>
<Button
href={buttonHref}
size="large"
variant={variant === "inverse" ? "primary" : "default"}
className="xl:!px-[var(--spacing-scale-020)] xl:!py-[var(--spacing-scale-012)] xl:!text-[24px] xl:!leading-[28px]"
onClick={handleContactClick}
ariaLabel={`${buttonText} - Contact an organizer for help`}
>
{buttonText}
</Button>
</div>
</div>
</section>
);
},
);
AskOrganizer.displayName = "AskOrganizer";
export default AskOrganizer;
@@ -0,0 +1,114 @@
"use client";
import { memo } from "react";
import { useAnalytics } from "../../hooks";
import AskOrganizerView from "./AskOrganizer.view";
import type {
AskOrganizerProps,
AskOrganizerVariant,
} from "./AskOrganizer.types";
const VARIANT_STYLES: Record<
AskOrganizerVariant,
{ container: string; buttonContainer: string }
> = {
centered: {
container: "text-center",
buttonContainer: "flex justify-center",
},
"left-aligned": {
container: "text-left",
buttonContainer: "flex justify-start",
},
compact: {
container: "text-center",
buttonContainer: "flex justify-center",
},
inverse: {
container: "text-center",
buttonContainer: "flex justify-center",
},
};
const AskOrganizerContainer = memo<AskOrganizerProps>(
({
title,
subtitle,
description,
buttonText = "Ask an organizer",
buttonHref = "#",
className = "",
variant = "centered",
onContactClick,
}) => {
const { trackEvent, trackCustomEvent } = useAnalytics();
const resolvedVariant: AskOrganizerVariant = variant ?? "centered";
const styles = VARIANT_STYLES[resolvedVariant] ?? VARIANT_STYLES.centered;
const sectionPadding =
resolvedVariant === "compact"
? "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-032)] md:py-[var(--spacing-scale-096)] md:px-[var(--spacing-scale-064)]";
const contentGap =
resolvedVariant === "compact"
? "gap-[var(--spacing-scale-020)]"
: "gap-[var(--spacing-scale-040)]";
const labelledBy = title ? "ask-organizer-headline" : undefined;
const handleContactClick = (
event: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>,
) => {
trackEvent({
event: "contact_button_click",
category: "engagement",
label: "ask_organizer",
component: "AskOrganizer",
variant: resolvedVariant,
});
trackCustomEvent(
"contact_button_click",
{
component: "AskOrganizer",
variant: resolvedVariant,
buttonText,
buttonHref,
},
onContactClick as
| ((
_data: Record<string, unknown>,
) => void)
| undefined,
);
// Preserve existing button behavior (no preventDefault here)
// while still tracking analytics.
return event;
};
return (
<AskOrganizerView
title={title}
subtitle={subtitle}
description={description}
buttonText={buttonText}
buttonHref={buttonHref}
className={className}
sectionPadding={sectionPadding}
contentGap={`${contentGap} ${styles.container}`}
buttonContainerClass={styles.buttonContainer}
variant={resolvedVariant}
labelledBy={labelledBy}
onContactClick={handleContactClick}
/>
);
},
);
AskOrganizerContainer.displayName = "AskOrganizer";
export default AskOrganizerContainer;
@@ -0,0 +1,43 @@
import type React from "react";
export type AskOrganizerVariant =
| "centered"
| "left-aligned"
| "compact"
| "inverse";
export interface AskOrganizerProps {
title?: string;
subtitle?: string;
description?: string;
buttonText?: string;
buttonHref?: string;
className?: string;
variant?: AskOrganizerVariant;
onContactClick?: (_data: {
event: string;
component: string;
variant: string;
buttonText: string;
buttonHref: string;
timestamp: string;
}) => void;
}
export interface AskOrganizerViewProps {
title?: string;
subtitle?: string;
description?: string;
buttonText: string;
buttonHref: string;
className: string;
sectionPadding: string;
contentGap: string;
buttonContainerClass: string;
variant: AskOrganizerVariant;
labelledBy?: string;
onContactClick: (
event: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>,
) => void;
}
@@ -0,0 +1,56 @@
import ContentLockup from "../ContentLockup";
import Button from "../Button";
import type { AskOrganizerViewProps } from "./AskOrganizer.types";
function AskOrganizerView({
title,
subtitle,
description,
buttonText,
buttonHref,
className,
sectionPadding,
contentGap,
buttonContainerClass,
variant,
labelledBy,
onContactClick,
}: AskOrganizerViewProps) {
return (
<section
className={`${sectionPadding} ${className}`}
aria-labelledby={labelledBy}
aria-label={labelledBy ? undefined : "Ask an organizer"}
tabIndex={-1}
>
<div className={`flex flex-col ${contentGap}`}>
{/* Content Lockup */}
<ContentLockup
title={title}
subtitle={subtitle}
description={description}
variant={variant === "inverse" ? "ask-inverse" : "ask"}
alignment={variant === "left-aligned" ? "left" : "center"}
titleId={labelledBy}
/>
{/* Button */}
<div className={buttonContainerClass}>
<Button
href={buttonHref}
size="large"
variant={variant === "inverse" ? "primary" : "default"}
className="xl:!px-[var(--spacing-scale-020)] xl:!py-[var(--spacing-scale-012)] xl:!text-[24px] xl:!leading-[28px]"
onClick={onContactClick}
ariaLabel={`${buttonText} - Contact an organizer for help`}
>
{buttonText}
</Button>
</div>
</div>
</section>
);
}
export default AskOrganizerView;
+3
View File
@@ -0,0 +1,3 @@
export { default } from "./AskOrganizer.container";
export * from "./AskOrganizer.types";
-98
View File
@@ -1,98 +0,0 @@
"use client";
import { memo, useMemo } from "react";
import ContentLockup from "./ContentLockup";
import MiniCard from "./MiniCard";
interface FeatureGridProps {
title?: string;
subtitle?: string;
className?: string;
}
const FeatureGrid = memo<FeatureGridProps>(
({ title, subtitle, className = "" }) => {
// Memoize the feature data to prevent unnecessary re-renders
const features = useMemo(
() => [
{
backgroundColor: "bg-[var(--color-surface-default-brand-royal)]",
labelLine1: "Decision-making",
labelLine2: "support",
panelContent: "/assets/Feature_Support.png",
ariaLabel: "Decision-making support tools",
href: "#decision-making",
},
{
backgroundColor: "bg-[#D1FFE2]",
labelLine1: "Values alignment",
labelLine2: "exercises",
panelContent: "/assets/Feature_Exercises.png",
ariaLabel: "Values alignment exercises",
href: "#values-alignment",
},
{
backgroundColor: "bg-[#F4CAFF]",
labelLine1: "Membership",
labelLine2: "guidance",
panelContent: "/assets/Feature_Guidance.png",
ariaLabel: "Membership guidance resources",
href: "#membership-guidance",
},
{
backgroundColor: "bg-[#CBDDFF]",
labelLine1: "Conflict resolution",
labelLine2: "tools",
panelContent: "/assets/Feature_Tools.png",
ariaLabel: "Conflict resolution tools",
href: "#conflict-resolution",
},
],
[],
);
const labelledBy = title ? "feature-grid-headline" : undefined;
return (
<section
className={`p-0 lg:p-[var(--spacing-scale-064)] ${className}`}
aria-labelledby={labelledBy}
aria-label={labelledBy ? undefined : "Feature tools and services"}
>
<div className="py-[var(--spacing-scale-032)] px-[var(--spacing-scale-020)] md:pt-[var(--spacing-scale-076)] md:pb-[var(--spacing-scale-048)] lg:pb-[var(--spacing-scale-076)] md:px-[var(--spacing-scale-048)] bg-[#171717] rounded-[var(--radius-measures-radius-xlarge)] focus-within:ring-2 focus-within:ring-[var(--color-surface-default-brand-royal)] focus-within:ring-offset-2">
<div className="w-full mx-auto gap-[var(--spacing-scale-048)] lg:flex lg:items-start lg:gap-[var(--spacing-scale-048)] [container-type:inline-size]">
{/* Feature Content Lockup */}
<div className="lg:shrink lg:min-w-0">
<ContentLockup
title={title}
subtitle={subtitle}
variant="feature"
linkText="Learn more"
linkHref="#"
titleId={labelledBy}
/>
</div>
{/* MiniCard Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-[var(--spacing-scale-012)] mt-[var(--spacing-scale-048)] lg:mt-0 lg:flex-grow lg:shrink-0">
{features.map((feature, index) => (
<MiniCard
key={index}
backgroundColor={feature.backgroundColor}
labelLine1={feature.labelLine1}
labelLine2={feature.labelLine2}
panelContent={feature.panelContent}
ariaLabel={feature.ariaLabel}
href={feature.href}
/>
))}
</div>
</div>
</div>
</section>
);
},
);
FeatureGrid.displayName = "FeatureGrid";
export default FeatureGrid;
@@ -0,0 +1,64 @@
"use client";
import { memo, useMemo } from "react";
import FeatureGridView from "./FeatureGrid.view";
import type { FeatureGridProps, Feature } from "./FeatureGrid.types";
const FeatureGridContainer = memo<FeatureGridProps>(
({ title, subtitle, className = "" }) => {
const features: Feature[] = useMemo(
() => [
{
backgroundColor: "bg-[var(--color-surface-default-brand-royal)]",
labelLine1: "Decision-making",
labelLine2: "support",
panelContent: "/assets/Feature_Support.png",
ariaLabel: "Decision-making support tools",
href: "#decision-making",
},
{
backgroundColor: "bg-[#D1FFE2]",
labelLine1: "Values alignment",
labelLine2: "exercises",
panelContent: "/assets/Feature_Exercises.png",
ariaLabel: "Values alignment exercises",
href: "#values-alignment",
},
{
backgroundColor: "bg-[#F4CAFF]",
labelLine1: "Membership",
labelLine2: "guidance",
panelContent: "/assets/Feature_Guidance.png",
ariaLabel: "Membership guidance resources",
href: "#membership-guidance",
},
{
backgroundColor: "bg-[#CBDDFF]",
labelLine1: "Conflict resolution",
labelLine2: "tools",
panelContent: "/assets/Feature_Tools.png",
ariaLabel: "Conflict resolution tools",
href: "#conflict-resolution",
},
],
[],
);
const labelledBy = title ? "feature-grid-headline" : undefined;
return (
<FeatureGridView
title={title}
subtitle={subtitle}
className={className}
features={features}
labelledBy={labelledBy}
/>
);
},
);
FeatureGridContainer.displayName = "FeatureGrid";
export default FeatureGridContainer;
@@ -0,0 +1,20 @@
export interface FeatureGridProps {
title?: string;
subtitle?: string;
className?: string;
}
export interface Feature {
backgroundColor: string;
labelLine1: string;
labelLine2: string;
panelContent: string;
ariaLabel: string;
href: string;
}
export interface FeatureGridViewProps extends FeatureGridProps {
features: Feature[];
labelledBy?: string;
}
@@ -0,0 +1,53 @@
import ContentLockup from "../ContentLockup";
import MiniCard from "../MiniCard";
import type { FeatureGridViewProps } from "./FeatureGrid.types";
function FeatureGridView({
title,
subtitle,
className = "",
features,
labelledBy,
}: FeatureGridViewProps) {
return (
<section
className={`p-0 lg:p-[var(--spacing-scale-064)] ${className}`}
aria-labelledby={labelledBy}
aria-label={labelledBy ? undefined : "Feature tools and services"}
>
<div className="py-[var(--spacing-scale-032)] px-[var(--spacing-scale-020)] md:pt-[var(--spacing-scale-076)] md:pb-[var(--spacing-scale-048)] lg:pb-[var(--spacing-scale-076)] md:px-[var(--spacing-scale-048)] bg-[#171717] rounded-[var(--radius-measures-radius-xlarge)] focus-within:ring-2 focus-within:ring-[var(--color-surface-default-brand-royal)] focus-within:ring-offset-2">
<div className="w-full mx-auto gap-[var(--spacing-scale-048)] lg:flex lg:items-start lg:gap-[var(--spacing-scale-048)] [container-type:inline-size]">
{/* Feature Content Lockup */}
<div className="lg:shrink lg:min-w-0">
<ContentLockup
title={title}
subtitle={subtitle}
variant="feature"
linkText="Learn more"
linkHref="#"
titleId={labelledBy}
/>
</div>
{/* MiniCard Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-[var(--spacing-scale-012)] mt-[var(--spacing-scale-048)] lg:mt-0 lg:flex-grow lg:shrink-0">
{features.map((feature, index) => (
<MiniCard
key={index}
backgroundColor={feature.backgroundColor}
labelLine1={feature.labelLine1}
labelLine2={feature.labelLine2}
panelContent={feature.panelContent}
ariaLabel={feature.ariaLabel}
href={feature.href}
/>
))}
</div>
</div>
</div>
</section>
);
}
export default FeatureGridView;
+3
View File
@@ -0,0 +1,3 @@
export { default } from "./FeatureGrid.container";
export * from "./FeatureGrid.types";
@@ -0,0 +1,36 @@
"use client";
import { memo } from "react";
import { useSchemaData } from "../../hooks";
import NumberedCardsView from "./NumberedCards.view";
import type { NumberedCardsProps } from "./NumberedCards.types";
const NumberedCardsContainer = memo<NumberedCardsProps>(
({ title, subtitle, cards }) => {
const schemaData = useSchemaData({
type: "HowTo",
name: title,
description: subtitle,
steps: cards.map((card) => ({
name: card.text,
text: card.text,
})),
});
const schemaJson = JSON.stringify(schemaData);
return (
<NumberedCardsView
title={title}
subtitle={subtitle}
cards={cards}
schemaJson={schemaJson}
/>
);
},
);
NumberedCardsContainer.displayName = "NumberedCards";
export default NumberedCardsContainer;
@@ -0,0 +1,16 @@
export interface Card {
text: string;
iconShape?: string;
iconColor?: string;
}
export interface NumberedCardsProps {
title: string;
subtitle: string;
cards: Card[];
}
export interface NumberedCardsViewProps extends NumberedCardsProps {
schemaJson: string;
}
@@ -1,40 +1,19 @@
"use client";
import { memo } from "react";
import NumberedCard from "./NumberedCard";
import SectionHeader from "./SectionHeader";
import Button from "./Button";
import { useSchemaData } from "../hooks";
interface Card {
text: string;
iconShape?: string;
iconColor?: string;
}
interface NumberedCardsProps {
title: string;
subtitle: string;
cards: Card[];
}
const NumberedCards = memo<NumberedCardsProps>(({ title, subtitle, cards }) => {
// Generate schema data using hook
const schemaData = useSchemaData({
type: "HowTo",
name: title,
description: subtitle,
steps: cards.map((card) => ({
name: card.text,
text: card.text,
})),
});
import SectionHeader from "../SectionHeader";
import NumberedCard from "../NumberedCard";
import Button from "../Button";
import type { NumberedCardsViewProps } from "./NumberedCards.types";
function NumberedCardsView({
title,
subtitle,
cards,
schemaJson,
}: NumberedCardsViewProps) {
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaData) }}
dangerouslySetInnerHTML={{ __html: schemaJson }}
/>
<section className="bg-transparent py-[var(--spacing-scale-032)] px-[var(--spacing-scale-020)] sm:py-[var(--spacing-scale-048)] sm:px-[var(--spacing-scale-032)] lg:py-[var(--spacing-scale-064)] lg:px-[var(--spacing-scale-064)] xl:py-[var(--spacing-scale-076)] xl:px-[var(--spacing-scale-064)]">
<div className="max-w-[var(--spacing-measures-max-width-lg)] mx-auto">
@@ -81,8 +60,7 @@ const NumberedCards = memo<NumberedCardsProps>(({ title, subtitle, cards }) => {
</section>
</>
);
});
}
NumberedCards.displayName = "NumberedCards";
export default NumberedCardsView;
export default NumberedCards;
+3
View File
@@ -0,0 +1,3 @@
export { default } from "./NumberedCards.container";
export * from "./NumberedCards.types";
@@ -11,33 +11,13 @@ import React, {
useCallback,
memo,
useImperativeHandle,
useEffect,
} from "react";
import { useClickOutside } from "../hooks";
import SelectDropdown from "./SelectDropdown";
import SelectOption from "./SelectOption";
import { useClickOutside } from "../../hooks";
import { SelectView } from "./Select.view";
import type { SelectProps } from "./Select.types";
interface SelectOptionData {
value: string;
label: string;
}
interface SelectProps {
id?: string;
label?: string;
labelVariant?: "default" | "horizontal";
size?: "small" | "medium" | "large";
state?: "default" | "hover" | "focus";
disabled?: boolean;
error?: boolean;
placeholder?: string;
className?: string;
children?: React.ReactNode;
value?: string;
onChange?: (_data: { target: { value: string; text: string } }) => void;
options?: SelectOptionData[];
}
const Select = forwardRef<HTMLButtonElement, SelectProps>(
const SelectContainer = forwardRef<HTMLButtonElement, SelectProps>(
(
{
id,
@@ -65,6 +45,13 @@ const Select = forwardRef<HTMLButtonElement, SelectProps>(
const selectRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
// Sync internal state with external value prop
useEffect(() => {
if (value !== undefined) {
setSelectedValue(value);
}
}, [value]);
useImperativeHandle(
ref,
() => selectRef.current as HTMLButtonElement | null,
@@ -239,6 +226,12 @@ const Select = forwardRef<HTMLButtonElement, SelectProps>(
? "flex items-center gap-[12px]"
: "flex flex-col";
const chevronClasses = `${
size === "large" ? "w-5 h-5" : "w-4 h-4"
} text-[var(--color-content-default-primary)] transition-transform duration-200 ${
isOpen ? "rotate-180" : ""
}`;
// Get display text for selected value
const getDisplayText = (): string => {
if (!selectedValue) return placeholder;
@@ -273,107 +266,39 @@ const Select = forwardRef<HTMLButtonElement, SelectProps>(
};
return (
<div className={containerClasses}>
{label && (
<label
id={labelId}
htmlFor={selectId}
className={`${labelClasses} text-[var(--color-content-default-secondary)]`}
>
{label}
</label>
)}
<div className="relative">
<button
ref={selectRef}
id={selectId}
disabled={disabled}
className={selectClasses}
aria-labelledby={label ? labelId : undefined}
aria-invalid={error}
aria-expanded={isOpen}
aria-haspopup="listbox"
onClick={handleSelectClick}
onKeyDown={handleKeyDown}
{...props}
>
<span className="text-left">{getDisplayText()}</span>
</button>
<div className="absolute inset-y-0 right-0 flex items-center pr-[12px] pointer-events-none">
<svg
className={`${
size === "large" ? "w-5 h-5" : "w-4 h-4"
} text-[var(--color-content-default-primary)] transition-transform duration-200 ${
isOpen ? "rotate-180" : ""
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
{isOpen && (
<div
ref={menuRef}
className="absolute top-full left-0 right-0 z-50 mt-1"
>
<SelectDropdown>
{options && Array.isArray(options)
? options.map((option) => (
<SelectOption
key={option.value}
selected={option.value === selectedValue}
size={size}
onClick={() =>
handleOptionSelect(option.value, option.label)
}
>
{option.label}
</SelectOption>
))
: Children.map(children, (child) => {
if (
React.isValidElement(child) &&
child.type === "option"
) {
const optionProps = child.props as {
value: string;
children: ReactNode;
};
return (
<SelectOption
key={optionProps.value}
selected={optionProps.value === selectedValue}
size={size}
onClick={() =>
handleOptionSelect(
optionProps.value,
String(optionProps.children),
)
}
>
{optionProps.children}
</SelectOption>
);
}
return child;
})}
</SelectDropdown>
</div>
)}
</div>
</div>
<SelectView
label={label}
placeholder={placeholder}
size={size}
state={state}
disabled={disabled}
error={error}
labelVariant={labelVariant}
className={className}
options={options}
children={children}
selectId={selectId}
labelId={labelId}
isOpen={isOpen}
selectedValue={selectedValue}
displayText={getDisplayText()}
selectClasses={selectClasses}
labelClasses={labelClasses}
containerClasses={containerClasses}
chevronClasses={chevronClasses}
onButtonClick={handleSelectClick}
onButtonKeyDown={handleKeyDown}
onOptionClick={handleOptionSelect}
selectRef={selectRef}
menuRef={menuRef}
ariaLabelledby={label ? labelId : undefined}
ariaInvalid={error}
{...props}
/>
);
},
);
Select.displayName = "Select";
SelectContainer.displayName = "Select";
export default memo(Select);
export default memo(SelectContainer);
+22
View File
@@ -0,0 +1,22 @@
import type { ReactNode } from "react";
export interface SelectOptionData {
value: string;
label: string;
}
export interface SelectProps {
id?: string;
label?: string;
labelVariant?: "default" | "horizontal";
size?: "small" | "medium" | "large";
state?: "default" | "hover" | "focus";
disabled?: boolean;
error?: boolean;
placeholder?: string;
className?: string;
children?: ReactNode;
value?: string;
onChange?: (_data: { target: { value: string; text: string } }) => void;
options?: SelectOptionData[];
}
+161
View File
@@ -0,0 +1,161 @@
import React, { Children, type ReactElement, type ReactNode } from "react";
import SelectDropdown from "../SelectDropdown";
import SelectOption from "../SelectOption";
import type { SelectOptionData } from "./Select.types";
export interface SelectViewProps {
label?: string;
placeholder: string;
size: "small" | "medium" | "large";
state: "default" | "hover" | "focus";
disabled: boolean;
error: boolean;
labelVariant: "default" | "horizontal";
className: string;
options?: SelectOptionData[];
children?: ReactNode;
// Computed props from container
selectId: string;
labelId: string;
isOpen: boolean;
selectedValue: string;
displayText: string;
selectClasses: string;
labelClasses: string;
containerClasses: string;
chevronClasses: string;
// Callbacks
onButtonClick: () => void;
onButtonKeyDown: (e: React.KeyboardEvent<HTMLButtonElement>) => void;
onOptionClick: (value: string, text: string) => void;
// Refs
selectRef: React.RefObject<HTMLButtonElement>;
menuRef: React.RefObject<HTMLDivElement>;
// Additional props
ariaLabelledby?: string;
ariaInvalid?: boolean;
}
export function SelectView({
label,
placeholder,
size,
disabled,
error,
labelVariant,
options,
children,
selectId,
labelId,
isOpen,
selectedValue,
displayText,
selectClasses,
labelClasses,
containerClasses,
chevronClasses,
onButtonClick,
onButtonKeyDown,
onOptionClick,
selectRef,
menuRef,
ariaLabelledby,
ariaInvalid,
...props
}: SelectViewProps) {
return (
<div className={containerClasses}>
{label && (
<label
id={labelId}
htmlFor={selectId}
className={`${labelClasses} text-[var(--color-content-default-secondary)]`}
>
{label}
</label>
)}
<div className="relative">
<button
ref={selectRef}
id={selectId}
disabled={disabled}
className={selectClasses}
aria-labelledby={ariaLabelledby}
aria-invalid={ariaInvalid}
aria-expanded={isOpen}
aria-haspopup="listbox"
onClick={onButtonClick}
onKeyDown={onButtonKeyDown}
{...props}
>
<span className="text-left">{displayText}</span>
</button>
<div className="absolute inset-y-0 right-0 flex items-center pr-[12px] pointer-events-none">
<svg
className={chevronClasses}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
{isOpen && (
<div
ref={menuRef}
className="absolute top-full left-0 right-0 z-50 mt-1"
>
<SelectDropdown>
{options && Array.isArray(options)
? options.map((option) => (
<SelectOption
key={option.value}
selected={option.value === selectedValue}
size={size}
onClick={() =>
onOptionClick(option.value, option.label)
}
>
{option.label}
</SelectOption>
))
: Children.map(children, (child) => {
if (
React.isValidElement(child) &&
child.type === "option"
) {
const optionProps = child.props as {
value: string;
children: ReactNode;
};
return (
<SelectOption
key={optionProps.value}
selected={optionProps.value === selectedValue}
size={size}
onClick={() =>
onOptionClick(
optionProps.value,
String(optionProps.children),
)
}
>
{optionProps.children}
</SelectOption>
);
}
return child;
})}
</SelectDropdown>
</div>
)}
</div>
</div>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./Select.container";
export type { SelectProps, SelectOptionData } from "./Select.types";
@@ -0,0 +1,106 @@
"use client";
import { memo, useEffect, useState } from "react";
import { logger } from "../../lib/logger";
import WebVitalsDashboardView from "./WebVitalsDashboard.view";
import type { Metrics, Vitals, VitalData } from "./WebVitalsDashboard.types";
const createInitialVital = (): VitalData => ({
value: 0,
rating: "unknown",
});
const createInitialVitals = (): Vitals => ({
lcp: createInitialVital(),
fid: createInitialVital(),
cls: createInitialVital(),
fcp: createInitialVital(),
ttfb: createInitialVital(),
});
const WebVitalsDashboardContainer = memo(() => {
const [vitals, setVitals] = useState<Vitals>(createInitialVitals);
const [metrics, setMetrics] = useState<Metrics>({});
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchVitals = async () => {
try {
const response = await fetch("/api/web-vitals");
const data = (await response.json()) as { metrics?: Metrics };
setMetrics(data.metrics || {});
} catch (error) {
logger.error("Error fetching web vitals:", error);
} finally {
setLoading(false);
}
};
fetchVitals();
if (typeof window !== "undefined") {
import("web-vitals").then((webVitals) => {
const { getCLS, getFID, getFCP, getLCP, getTTFB } = webVitals as any;
getLCP((metric: { value: number; rating: VitalData["rating"] }) => {
setVitals((prev) => ({
...prev,
lcp: {
value: Math.round(metric.value),
rating: metric.rating,
},
}));
});
getFID((metric: { value: number; rating: VitalData["rating"] }) => {
setVitals((prev) => ({
...prev,
fid: {
value: Math.round(metric.value),
rating: metric.rating,
},
}));
});
getCLS((metric: { value: number; rating: VitalData["rating"] }) => {
setVitals((prev) => ({
...prev,
cls: {
value: Math.round(metric.value * 1000) / 1000,
rating: metric.rating,
},
}));
});
getFCP((metric: { value: number; rating: VitalData["rating"] }) => {
setVitals((prev) => ({
...prev,
fcp: {
value: Math.round(metric.value),
rating: metric.rating,
},
}));
});
getTTFB((metric: { value: number; rating: VitalData["rating"] }) => {
setVitals((prev) => ({
...prev,
ttfb: {
value: Math.round(metric.value),
rating: metric.rating,
},
}));
});
});
}
}, []);
return (
<WebVitalsDashboardView vitals={vitals} metrics={metrics} loading={loading} />
);
});
WebVitalsDashboardContainer.displayName = "WebVitalsDashboard";
export default WebVitalsDashboardContainer;
@@ -0,0 +1,34 @@
export interface VitalData {
value: number;
rating: "good" | "needs-improvement" | "poor" | "unknown";
}
export interface Vitals {
lcp: VitalData;
fid: VitalData;
cls: VitalData;
fcp: VitalData;
ttfb: VitalData;
}
export interface MetricData {
count: number;
average: number;
min: number;
max: number;
goodCount: number;
needsImprovementCount: number;
poorCount: number;
lastUpdated?: string;
}
export interface Metrics {
[key: string]: MetricData;
}
export interface WebVitalsDashboardViewProps {
vitals: Vitals;
metrics: Metrics;
loading: boolean;
}
@@ -1,159 +1,43 @@
"use client";
import type { WebVitalsDashboardViewProps } from "./WebVitalsDashboard.types";
import { useState, useEffect, memo } from "react";
import { logger } from "../../lib/logger";
const getRatingColor = (rating: string): string => {
switch (rating) {
case "good":
return "text-green-600 bg-green-50";
case "needs-improvement":
return "text-yellow-600 bg-yellow-50";
case "poor":
return "text-red-600 bg-red-50";
default:
return "text-gray-600 bg-gray-50";
}
};
interface VitalData {
value: number;
rating: "good" | "needs-improvement" | "poor" | "unknown";
}
const getRatingIcon = (rating: string): string => {
switch (rating) {
case "good":
return "✅";
case "needs-improvement":
return "⚠️";
case "poor":
return "❌";
default:
return "❓";
}
};
interface Vitals {
lcp: VitalData;
fid: VitalData;
cls: VitalData;
fcp: VitalData;
ttfb: VitalData;
}
interface MetricData {
count: number;
average: number;
min: number;
max: number;
goodCount: number;
needsImprovementCount: number;
poorCount: number;
lastUpdated?: string;
}
interface Metrics {
[key: string]: MetricData;
}
const WebVitalsDashboard = memo(() => {
const [vitals, setVitals] = useState<Vitals>({
lcp: { value: 0, rating: "unknown" },
fid: { value: 0, rating: "unknown" },
cls: { value: 0, rating: "unknown" },
fcp: { value: 0, rating: "unknown" },
ttfb: { value: 0, rating: "unknown" },
});
const [metrics, setMetrics] = useState<Metrics>({});
const [loading, setLoading] = useState(true);
useEffect(() => {
// Fetch Web Vitals data from API
const fetchVitals = async () => {
try {
const response = await fetch("/api/web-vitals");
const data = (await response.json()) as { metrics?: Metrics };
setMetrics(data.metrics || {});
} catch (error) {
logger.error("Error fetching web vitals:", error);
} finally {
setLoading(false);
}
};
fetchVitals();
// Set up Web Vitals tracking
if (typeof window !== "undefined") {
import("web-vitals").then((webVitals) => {
const { getCLS, getFID, getFCP, getLCP, getTTFB } = webVitals as any;
// Track Largest Contentful Paint
getLCP((metric) => {
setVitals((prev) => ({
...prev,
lcp: {
value: Math.round(metric.value),
rating: metric.rating,
},
}));
});
// Track First Input Delay
getFID((metric) => {
setVitals((prev) => ({
...prev,
fid: {
value: Math.round(metric.value),
rating: metric.rating,
},
}));
});
// Track Cumulative Layout Shift
getCLS((metric) => {
setVitals((prev) => ({
...prev,
cls: {
value: Math.round(metric.value * 1000) / 1000,
rating: metric.rating,
},
}));
});
// Track First Contentful Paint
getFCP((metric) => {
setVitals((prev) => ({
...prev,
fcp: {
value: Math.round(metric.value),
rating: metric.rating,
},
}));
});
// Track Time to First Byte
getTTFB((metric) => {
setVitals((prev) => ({
...prev,
ttfb: {
value: Math.round(metric.value),
rating: metric.rating,
},
}));
});
});
}
}, []);
const getRatingColor = (rating: string): string => {
switch (rating) {
case "good":
return "text-green-600 bg-green-50";
case "needs-improvement":
return "text-yellow-600 bg-yellow-50";
case "poor":
return "text-red-600 bg-red-50";
default:
return "text-gray-600 bg-gray-50";
}
};
const getRatingIcon = (rating: string): string => {
switch (rating) {
case "good":
return "✅";
case "needs-improvement":
return "⚠️";
case "poor":
return "❌";
default:
return "❓";
}
};
const formatValue = (metric: string, value: number): string => {
if (metric === "cls") {
return value.toFixed(3);
}
return `${value}ms`;
};
const formatValue = (metric: string, value: number): string => {
if (metric === "cls") {
return value.toFixed(3);
}
return `${value}ms`;
};
function WebVitalsDashboardView({
vitals,
metrics,
loading,
}: WebVitalsDashboardViewProps) {
if (loading) {
return (
<div className="p-6 bg-white rounded-lg shadow-lg">
@@ -227,7 +111,9 @@ const WebVitalsDashboard = memo(() => {
<span className="text-yellow-600">
Needs Improvement: {data.needsImprovementCount}
</span>
<span className="text-red-600">Poor: {data.poorCount}</span>
<span className="text-red-600">
Poor: {data.poorCount}
</span>
</div>
</div>
</div>
@@ -266,8 +152,7 @@ const WebVitalsDashboard = memo(() => {
</div>
</div>
);
});
}
WebVitalsDashboard.displayName = "WebVitalsDashboard";
export default WebVitalsDashboardView;
export default WebVitalsDashboard;
@@ -0,0 +1,3 @@
export { default } from "./WebVitalsDashboard.container";
export * from "./WebVitalsDashboard.types";
+5 -3
View File
@@ -347,14 +347,16 @@ const {
The following components have been refactored to use custom hooks:
- **Select.tsx** - Uses `useClickOutside`
- **AskOrganizer.tsx** - Uses `useAnalytics`
- **Select** - Uses `useClickOutside` (now uses Container/Presentation pattern)
- **AskOrganizer** - Uses `useAnalytics` (now uses Container/Presentation pattern)
- **Input.tsx** - Uses `useComponentId` and `useFormField`
- **TextArea.tsx** - Uses `useComponentId` and `useFormField`
- **Checkbox.tsx** - Uses `useComponentId`
- **NumberedCards.tsx** - Uses `useSchemaData`
- **NumberedCards** - Uses `useSchemaData` (now uses Container/Presentation pattern)
- **RelatedArticles.tsx** - Uses `useIsMobile`
> **Note**: Components marked with "Container/Presentation pattern" have been refactored to separate logic (container) from presentation (view). Hooks are used in the container components. See [Container/Presentation Pattern Guide](./guides/container-presentation-pattern.md) for details.
## Adding New Hooks
When creating a new hook:
+10
View File
@@ -11,6 +11,7 @@ This directory contains project documentation organized by topic.
### Guides (`guides/`)
- **[container-presentation-pattern.md](./guides/container-presentation-pattern.md)** - Container/Presentation pattern guide for component architecture
- **[content-creation.md](./guides/content-creation.md)** - Guide for creating blog content
## Quick Navigation
@@ -33,6 +34,15 @@ See **[CUSTOM_HOOKS.md](./CUSTOM_HOOKS.md)** for:
- Usage examples
- API documentation
### For Component Architecture
See **[container-presentation-pattern.md](./guides/container-presentation-pattern.md)** for:
- Container/Presentation pattern overview
- When and how to use the pattern
- Migration guide for existing components
- Best practices and examples
### For Content Creation
See **[content-creation.md](./guides/content-creation.md)** for:
@@ -0,0 +1,383 @@
# Container/Presentation Pattern
## Overview
The Container/Presentation pattern separates component logic from presentation, improving testability, reusability, and maintainability. This pattern is now the standard for complex components in this codebase.
## Motivation
### Benefits
- **Testability**: Pure presentation components can be tested independently with simple prop assertions
- **Reusability**: Presentation components can be reused with different data sources or logic
- **Maintainability**: Clear separation makes it easier to locate and modify specific concerns
- **Performance**: Easier to optimize rendering with React.memo on pure components
### When to Use
Use this pattern for components that have:
- Business logic or state management
- Data fetching or API calls
- Analytics tracking
- Complex event handlers
- Custom hooks usage
- Dynamic imports or side effects
Simple presentational components (e.g., `Button`, `Avatar`) can remain as single files.
## Folder Structure
Each component following this pattern should have this structure:
```
app/components/[ComponentName]/
├── index.tsx # Exports container as default
├── [ComponentName].container.tsx # Logic, hooks, state management
├── [ComponentName].view.tsx # Pure presentation component
└── [ComponentName].types.ts # Shared TypeScript types
```
### File Responsibilities
#### `index.tsx`
- Exports the container component as the default export
- Optionally exports types for external use
- Maintains backward compatibility with existing import paths
```typescript
export { default } from "./AskOrganizer.container";
export type { AskOrganizerProps } from "./AskOrganizer.types";
```
#### `[ComponentName].container.tsx`
**Contains all logic:**
- React hooks (`useState`, `useEffect`, custom hooks)
- Event handlers and business logic
- Data fetching and API calls
- Analytics tracking
- State management
- Computed values and derived state
- Side effects
**Should NOT contain:**
- JSX layout details (beyond composing the view)
- Inline styles or complex className logic (pass as props)
- Direct DOM manipulation
```typescript
"use client";
import { memo } from "react";
import { useAnalytics } from "../../hooks";
import { AskOrganizerView } from "./AskOrganizer.view";
import type { AskOrganizerProps } from "./AskOrganizer.types";
function AskOrganizerContainer(props: AskOrganizerProps) {
const { trackEvent } = useAnalytics();
const handleContactClick = () => {
trackEvent({
event: "contact_button_click",
category: "engagement",
component: "AskOrganizer",
});
// ... additional logic
};
// Compute derived props
const variantStyles = computeVariantStyles(props.variant);
return (
<AskOrganizerView
{...props}
onContactClick={handleContactClick}
variantStyles={variantStyles}
/>
);
}
export default memo(AskOrganizerContainer);
```
#### `[ComponentName].view.tsx`
**Pure presentation:**
- Receives all data via props
- Renders JSX based on props
- No hooks, no state, no side effects
- Only imports other presentational components
**Should NOT contain:**
- `useState`, `useEffect`, or any hooks
- Event handler implementations (receive as callbacks)
- Data fetching or API calls
- Analytics tracking
- Business logic
```typescript
import ContentLockup from "../ContentLockup";
import Button from "../Button";
import type { AskOrganizerViewProps } from "./AskOrganizer.types";
export function AskOrganizerView({
title,
subtitle,
description,
buttonText,
buttonHref,
variant,
onContactClick,
variantStyles,
...props
}: AskOrganizerViewProps) {
return (
<section className={variantStyles.container}>
<ContentLockup
title={title}
subtitle={subtitle}
description={description}
/>
<Button
href={buttonHref}
onClick={onContactClick}
>
{buttonText}
</Button>
</section>
);
}
```
#### `[ComponentName].types.ts`
- Shared TypeScript interfaces and types
- Public props interface (used by consumers)
- Internal view props (used between container and view)
- Any utility types specific to the component
```typescript
export interface AskOrganizerProps {
title?: string;
subtitle?: string;
buttonText?: string;
buttonHref?: string;
variant?: "centered" | "left-aligned" | "compact" | "inverse";
onContactClick?: (data: ContactClickData) => void;
}
export interface AskOrganizerViewProps extends AskOrganizerProps {
onContactClick: () => void;
variantStyles: {
container: string;
buttonContainer: string;
};
}
```
## Rules of Thumb
### Container Components
**DO:**
- Use React hooks (`useState`, `useEffect`, custom hooks)
- Handle all event handlers and business logic
- Fetch data and manage loading states
- Track analytics events
- Compute derived values from props/state
- Compose the view component with computed props
**DON'T:**
- Include complex JSX layout (delegate to view)
- Mix presentation logic with business logic
- Access DOM directly (use refs when necessary)
### View Components
**DO:**
- Receive all data via props
- Render JSX based on props
- Import only presentational components
- Use simple conditional rendering
- Accept callback props for user interactions
**DON'T:**
- Use any React hooks
- Manage state or side effects
- Fetch data or make API calls
- Track analytics directly
- Implement business logic
- Access browser APIs directly
## Example: AskOrganizer
### Before (Monolithic)
```typescript
"use client";
import { memo } from "react";
import { useAnalytics } from "../hooks";
import ContentLockup from "./ContentLockup";
import Button from "./Button";
const AskOrganizer = memo(({ title, variant, ...props }) => {
const { trackEvent } = useAnalytics();
const handleContactClick = () => {
trackEvent({ event: "contact_click", component: "AskOrganizer" });
};
return (
<section>
<ContentLockup title={title} />
<Button onClick={handleContactClick}>Ask an organizer</Button>
</section>
);
});
```
### After (Container/Presentation)
**AskOrganizer.container.tsx:**
```typescript
"use client";
import { memo } from "react";
import { useAnalytics } from "../../hooks";
import { AskOrganizerView } from "./AskOrganizer.view";
import type { AskOrganizerProps } from "./AskOrganizer.types";
function AskOrganizerContainer(props: AskOrganizerProps) {
const { trackEvent } = useAnalytics();
const handleContactClick = () => {
trackEvent({ event: "contact_click", component: "AskOrganizer" });
};
return <AskOrganizerView {...props} onContactClick={handleContactClick} />;
}
export default memo(AskOrganizerContainer);
```
**AskOrganizer.view.tsx:**
```typescript
import ContentLockup from "../ContentLockup";
import Button from "../Button";
import type { AskOrganizerViewProps } from "./AskOrganizer.types";
export function AskOrganizerView({
title,
onContactClick,
...props
}: AskOrganizerViewProps) {
return (
<section>
<ContentLockup title={title} />
<Button onClick={onContactClick}>Ask an organizer</Button>
</section>
);
}
```
## Migration Checklist
When converting an existing component to this pattern:
- [ ] **Identify separation points**
- [ ] List all hooks and state management
- [ ] List all event handlers and business logic
- [ ] List all data fetching and side effects
- [ ] Identify pure presentation JSX
- [ ] **Create folder structure**
- [ ] Create `[ComponentName]/` folder
- [ ] Create `[ComponentName].types.ts` with shared types
- [ ] Create `[ComponentName].view.tsx` with pure presentation
- [ ] Create `[ComponentName].container.tsx` with all logic
- [ ] Create `index.tsx` exporting container
- [ ] **Extract types**
- [ ] Move component props interface to `types.ts`
- [ ] Create view props interface extending container props
- [ ] Export types from `index.tsx` for external use
- [ ] **Move presentation to view**
- [ ] Copy JSX to view component
- [ ] Remove all hooks and state
- [ ] Replace event handlers with callback props
- [ ] Replace computed values with props
- [ ] Ensure view is a pure function
- [ ] **Move logic to container**
- [ ] Move all hooks to container
- [ ] Move event handlers to container
- [ ] Move data fetching to container
- [ ] Compute derived props in container
- [ ] Render view component with computed props
- [ ] **Update exports**
- [ ] Export container as default from `index.tsx`
- [ ] Export types from `index.tsx`
- [ ] Delete original component file
- [ ] Verify import paths still work
- [ ] **Update tests**
- [ ] Verify tests still pass (imports should resolve automatically)
- [ ] Update any tests that relied on implementation details
- [ ] Add tests for view component with mocked props if needed
- [ ] **Update Storybook**
- [ ] Verify stories still work (imports should resolve automatically)
- [ ] Optionally add view-only stories with mocked props
## Refactored Components
The following components have been refactored to use this pattern:
-**AskOrganizer** - Analytics tracking and event handlers
-**NumberedCards** - Schema generation with `useSchemaData` hook
-**FeatureGrid** - Memoized feature data structures
-**WebVitalsDashboard** - Dynamic imports, data fetching, complex state
-**Select** - Complex form state management with refs and keyboard navigation
These components serve as reference implementations for the pattern.
## Remaining Components
The following components are candidates for future conversion:
### High Priority (Complex Logic)
- `Header` / `HomeHeader` - Navigation state, conditional rendering logic
- `MenuBar` - Menu state management, keyboard navigation
- `ContextMenu` - Positioning logic, click outside handling
- `RadioGroup` - Group state management
- `ToggleGroup` - Group state management
### Medium Priority (Some Logic)
- `ContentContainer` - Data fetching or transformation
- `RelatedArticles` - Data fetching, filtering logic
- `RuleStack` - Complex rendering logic
- `LogoWall` - Animation or interaction logic
### Low Priority (Mostly Presentational)
- `Button`, `Avatar`, `Checkbox`, `Input`, `TextArea` - Simple presentational components
- `Separator`, `SectionHeader`, `SectionNumber` - Pure presentation
- `QuoteBlock`, `QuoteDecor`, `HeroDecor` - Decorative components
## Best Practices
1. **Start with complex components** - Components with the most logic benefit most from separation
2. **Keep it simple** - Don't over-engineer simple presentational components
3. **Maintain backward compatibility** - Import paths should remain unchanged
4. **Test both layers** - Test container for logic, view for presentation
5. **Document the pattern** - Add comments explaining non-obvious prop flows
6. **Use TypeScript strictly** - Leverage types to enforce the separation
## Additional Resources
- [React Container/Presenter Pattern](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0)
- [Separation of Concerns in React](https://react.dev/learn/thinking-in-react)
---
**Last Updated**: April 2025
**Maintained by**: CommunityRule Development Team