First pass on component refactor
This commit is contained in:
@@ -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;
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { default } from "./AskOrganizer.container";
|
||||||
|
export * from "./AskOrganizer.types";
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
+13
-35
@@ -1,40 +1,19 @@
|
|||||||
"use client";
|
import SectionHeader from "../SectionHeader";
|
||||||
|
import NumberedCard from "../NumberedCard";
|
||||||
import { memo } from "react";
|
import Button from "../Button";
|
||||||
import NumberedCard from "./NumberedCard";
|
import type { NumberedCardsViewProps } from "./NumberedCards.types";
|
||||||
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,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
|
|
||||||
|
function NumberedCardsView({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
cards,
|
||||||
|
schemaJson,
|
||||||
|
}: NumberedCardsViewProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<script
|
<script
|
||||||
type="application/ld+json"
|
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)]">
|
<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">
|
<div className="max-w-[var(--spacing-measures-max-width-lg)] mx-auto">
|
||||||
@@ -81,8 +60,7 @@ const NumberedCards = memo<NumberedCardsProps>(({ title, subtitle, cards }) => {
|
|||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|
||||||
NumberedCards.displayName = "NumberedCards";
|
export default NumberedCardsView;
|
||||||
|
|
||||||
export default NumberedCards;
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { default } from "./NumberedCards.container";
|
||||||
|
export * from "./NumberedCards.types";
|
||||||
|
|
||||||
@@ -11,33 +11,13 @@ import React, {
|
|||||||
useCallback,
|
useCallback,
|
||||||
memo,
|
memo,
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
|
useEffect,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useClickOutside } from "../hooks";
|
import { useClickOutside } from "../../hooks";
|
||||||
import SelectDropdown from "./SelectDropdown";
|
import { SelectView } from "./Select.view";
|
||||||
import SelectOption from "./SelectOption";
|
import type { SelectProps } from "./Select.types";
|
||||||
|
|
||||||
interface SelectOptionData {
|
const SelectContainer = forwardRef<HTMLButtonElement, SelectProps>(
|
||||||
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>(
|
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
@@ -65,6 +45,13 @@ const Select = forwardRef<HTMLButtonElement, SelectProps>(
|
|||||||
const selectRef = useRef<HTMLButtonElement>(null);
|
const selectRef = useRef<HTMLButtonElement>(null);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Sync internal state with external value prop
|
||||||
|
useEffect(() => {
|
||||||
|
if (value !== undefined) {
|
||||||
|
setSelectedValue(value);
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
useImperativeHandle(
|
useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
() => selectRef.current as HTMLButtonElement | null,
|
() => selectRef.current as HTMLButtonElement | null,
|
||||||
@@ -239,6 +226,12 @@ const Select = forwardRef<HTMLButtonElement, SelectProps>(
|
|||||||
? "flex items-center gap-[12px]"
|
? "flex items-center gap-[12px]"
|
||||||
: "flex flex-col";
|
: "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
|
// Get display text for selected value
|
||||||
const getDisplayText = (): string => {
|
const getDisplayText = (): string => {
|
||||||
if (!selectedValue) return placeholder;
|
if (!selectedValue) return placeholder;
|
||||||
@@ -273,107 +266,39 @@ const Select = forwardRef<HTMLButtonElement, SelectProps>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={containerClasses}>
|
<SelectView
|
||||||
{label && (
|
label={label}
|
||||||
<label
|
placeholder={placeholder}
|
||||||
id={labelId}
|
size={size}
|
||||||
htmlFor={selectId}
|
state={state}
|
||||||
className={`${labelClasses} text-[var(--color-content-default-secondary)]`}
|
disabled={disabled}
|
||||||
>
|
error={error}
|
||||||
{label}
|
labelVariant={labelVariant}
|
||||||
</label>
|
className={className}
|
||||||
)}
|
options={options}
|
||||||
<div className="relative">
|
children={children}
|
||||||
<button
|
selectId={selectId}
|
||||||
ref={selectRef}
|
labelId={labelId}
|
||||||
id={selectId}
|
isOpen={isOpen}
|
||||||
disabled={disabled}
|
selectedValue={selectedValue}
|
||||||
className={selectClasses}
|
displayText={getDisplayText()}
|
||||||
aria-labelledby={label ? labelId : undefined}
|
selectClasses={selectClasses}
|
||||||
aria-invalid={error}
|
labelClasses={labelClasses}
|
||||||
aria-expanded={isOpen}
|
containerClasses={containerClasses}
|
||||||
aria-haspopup="listbox"
|
chevronClasses={chevronClasses}
|
||||||
onClick={handleSelectClick}
|
onButtonClick={handleSelectClick}
|
||||||
onKeyDown={handleKeyDown}
|
onButtonKeyDown={handleKeyDown}
|
||||||
{...props}
|
onOptionClick={handleOptionSelect}
|
||||||
>
|
selectRef={selectRef}
|
||||||
<span className="text-left">{getDisplayText()}</span>
|
menuRef={menuRef}
|
||||||
</button>
|
ariaLabelledby={label ? labelId : undefined}
|
||||||
<div className="absolute inset-y-0 right-0 flex items-center pr-[12px] pointer-events-none">
|
ariaInvalid={error}
|
||||||
<svg
|
{...props}
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
Select.displayName = "Select";
|
SelectContainer.displayName = "Select";
|
||||||
|
|
||||||
export default memo(Select);
|
export default memo(SelectContainer);
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
+41
-156
@@ -1,159 +1,43 @@
|
|||||||
"use client";
|
import type { WebVitalsDashboardViewProps } from "./WebVitalsDashboard.types";
|
||||||
|
|
||||||
import { useState, useEffect, memo } from "react";
|
const getRatingColor = (rating: string): string => {
|
||||||
import { logger } from "../../lib/logger";
|
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 {
|
const getRatingIcon = (rating: string): string => {
|
||||||
value: number;
|
switch (rating) {
|
||||||
rating: "good" | "needs-improvement" | "poor" | "unknown";
|
case "good":
|
||||||
}
|
return "✅";
|
||||||
|
case "needs-improvement":
|
||||||
|
return "⚠️";
|
||||||
|
case "poor":
|
||||||
|
return "❌";
|
||||||
|
default:
|
||||||
|
return "❓";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
interface Vitals {
|
const formatValue = (metric: string, value: number): string => {
|
||||||
lcp: VitalData;
|
if (metric === "cls") {
|
||||||
fid: VitalData;
|
return value.toFixed(3);
|
||||||
cls: VitalData;
|
}
|
||||||
fcp: VitalData;
|
return `${value}ms`;
|
||||||
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`;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
function WebVitalsDashboardView({
|
||||||
|
vitals,
|
||||||
|
metrics,
|
||||||
|
loading,
|
||||||
|
}: WebVitalsDashboardViewProps) {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="p-6 bg-white rounded-lg shadow-lg">
|
<div className="p-6 bg-white rounded-lg shadow-lg">
|
||||||
@@ -227,7 +111,9 @@ const WebVitalsDashboard = memo(() => {
|
|||||||
<span className="text-yellow-600">
|
<span className="text-yellow-600">
|
||||||
Needs Improvement: {data.needsImprovementCount}
|
Needs Improvement: {data.needsImprovementCount}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-red-600">Poor: {data.poorCount}</span>
|
<span className="text-red-600">
|
||||||
|
Poor: {data.poorCount}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -266,8 +152,7 @@ const WebVitalsDashboard = memo(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|
||||||
WebVitalsDashboard.displayName = "WebVitalsDashboard";
|
export default WebVitalsDashboardView;
|
||||||
|
|
||||||
export default WebVitalsDashboard;
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { default } from "./WebVitalsDashboard.container";
|
||||||
|
export * from "./WebVitalsDashboard.types";
|
||||||
|
|
||||||
@@ -347,14 +347,16 @@ const {
|
|||||||
|
|
||||||
The following components have been refactored to use custom hooks:
|
The following components have been refactored to use custom hooks:
|
||||||
|
|
||||||
- **Select.tsx** - Uses `useClickOutside`
|
- **Select** - Uses `useClickOutside` (now uses Container/Presentation pattern)
|
||||||
- **AskOrganizer.tsx** - Uses `useAnalytics`
|
- **AskOrganizer** - Uses `useAnalytics` (now uses Container/Presentation pattern)
|
||||||
- **Input.tsx** - Uses `useComponentId` and `useFormField`
|
- **Input.tsx** - Uses `useComponentId` and `useFormField`
|
||||||
- **TextArea.tsx** - Uses `useComponentId` and `useFormField`
|
- **TextArea.tsx** - Uses `useComponentId` and `useFormField`
|
||||||
- **Checkbox.tsx** - Uses `useComponentId`
|
- **Checkbox.tsx** - Uses `useComponentId`
|
||||||
- **NumberedCards.tsx** - Uses `useSchemaData`
|
- **NumberedCards** - Uses `useSchemaData` (now uses Container/Presentation pattern)
|
||||||
- **RelatedArticles.tsx** - Uses `useIsMobile`
|
- **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
|
## Adding New Hooks
|
||||||
|
|
||||||
When creating a new hook:
|
When creating a new hook:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ This directory contains project documentation organized by topic.
|
|||||||
|
|
||||||
### Guides (`guides/`)
|
### 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
|
- **[content-creation.md](./guides/content-creation.md)** - Guide for creating blog content
|
||||||
|
|
||||||
## Quick Navigation
|
## Quick Navigation
|
||||||
@@ -33,6 +34,15 @@ See **[CUSTOM_HOOKS.md](./CUSTOM_HOOKS.md)** for:
|
|||||||
- Usage examples
|
- Usage examples
|
||||||
- API documentation
|
- 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
|
### For Content Creation
|
||||||
|
|
||||||
See **[content-creation.md](./guides/content-creation.md)** for:
|
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
|
||||||
Reference in New Issue
Block a user