From 7b9101824a742743496fe99447a0a8e39cb416d8 Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Thu, 29 Jan 2026 17:29:37 -0700 Subject: [PATCH] First pass on component refactor --- app/components/AskOrganizer.tsx | 143 ------- .../AskOrganizer/AskOrganizer.container.tsx | 114 ++++++ .../AskOrganizer/AskOrganizer.types.ts | 43 ++ .../AskOrganizer/AskOrganizer.view.tsx | 56 +++ app/components/AskOrganizer/index.tsx | 3 + app/components/FeatureGrid.tsx | 98 ----- .../FeatureGrid/FeatureGrid.container.tsx | 64 +++ .../FeatureGrid/FeatureGrid.types.ts | 20 + .../FeatureGrid/FeatureGrid.view.tsx | 53 +++ app/components/FeatureGrid/index.tsx | 3 + .../NumberedCards/NumberedCards.container.tsx | 36 ++ .../NumberedCards/NumberedCards.types.ts | 16 + .../NumberedCards.view.tsx} | 48 +-- app/components/NumberedCards/index.tsx | 3 + .../Select.container.tsx} | 173 +++----- app/components/Select/Select.types.ts | 22 + app/components/Select/Select.view.tsx | 161 ++++++++ app/components/Select/index.tsx | 2 + .../WebVitalsDashboard.container.tsx | 106 +++++ .../WebVitalsDashboard.types.ts | 34 ++ .../WebVitalsDashboard.view.tsx} | 197 ++------- app/components/WebVitalsDashboard/index.tsx | 3 + docs/CUSTOM_HOOKS.md | 8 +- docs/README.md | 10 + docs/guides/container-presentation-pattern.md | 383 ++++++++++++++++++ 25 files changed, 1240 insertions(+), 559 deletions(-) delete mode 100644 app/components/AskOrganizer.tsx create mode 100644 app/components/AskOrganizer/AskOrganizer.container.tsx create mode 100644 app/components/AskOrganizer/AskOrganizer.types.ts create mode 100644 app/components/AskOrganizer/AskOrganizer.view.tsx create mode 100644 app/components/AskOrganizer/index.tsx delete mode 100644 app/components/FeatureGrid.tsx create mode 100644 app/components/FeatureGrid/FeatureGrid.container.tsx create mode 100644 app/components/FeatureGrid/FeatureGrid.types.ts create mode 100644 app/components/FeatureGrid/FeatureGrid.view.tsx create mode 100644 app/components/FeatureGrid/index.tsx create mode 100644 app/components/NumberedCards/NumberedCards.container.tsx create mode 100644 app/components/NumberedCards/NumberedCards.types.ts rename app/components/{NumberedCards.tsx => NumberedCards/NumberedCards.view.tsx} (70%) create mode 100644 app/components/NumberedCards/index.tsx rename app/components/{Select.tsx => Select/Select.container.tsx} (62%) create mode 100644 app/components/Select/Select.types.ts create mode 100644 app/components/Select/Select.view.tsx create mode 100644 app/components/Select/index.tsx create mode 100644 app/components/WebVitalsDashboard/WebVitalsDashboard.container.tsx create mode 100644 app/components/WebVitalsDashboard/WebVitalsDashboard.types.ts rename app/components/{WebVitalsDashboard.tsx => WebVitalsDashboard/WebVitalsDashboard.view.tsx} (51%) create mode 100644 app/components/WebVitalsDashboard/index.tsx create mode 100644 docs/guides/container-presentation-pattern.md diff --git a/app/components/AskOrganizer.tsx b/app/components/AskOrganizer.tsx deleted file mode 100644 index 4b61b5a..0000000 --- a/app/components/AskOrganizer.tsx +++ /dev/null @@ -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( - ({ - 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, - ) => { - // 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 ( -
-
- {/* Content Lockup */} - - - {/* Button */} -
- -
-
-
- ); - }, -); - -AskOrganizer.displayName = "AskOrganizer"; - -export default AskOrganizer; diff --git a/app/components/AskOrganizer/AskOrganizer.container.tsx b/app/components/AskOrganizer/AskOrganizer.container.tsx new file mode 100644 index 0000000..1e6376d --- /dev/null +++ b/app/components/AskOrganizer/AskOrganizer.container.tsx @@ -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( + ({ + 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, + ) => { + 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, + ) => void) + | undefined, + ); + + // Preserve existing button behavior (no preventDefault here) + // while still tracking analytics. + return event; + }; + + return ( + + ); + }, +); + +AskOrganizerContainer.displayName = "AskOrganizer"; + +export default AskOrganizerContainer; + diff --git a/app/components/AskOrganizer/AskOrganizer.types.ts b/app/components/AskOrganizer/AskOrganizer.types.ts new file mode 100644 index 0000000..c6514bc --- /dev/null +++ b/app/components/AskOrganizer/AskOrganizer.types.ts @@ -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, + ) => void; +} + diff --git a/app/components/AskOrganizer/AskOrganizer.view.tsx b/app/components/AskOrganizer/AskOrganizer.view.tsx new file mode 100644 index 0000000..18d5cca --- /dev/null +++ b/app/components/AskOrganizer/AskOrganizer.view.tsx @@ -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 ( +
+
+ {/* Content Lockup */} + + + {/* Button */} +
+ +
+
+
+ ); +} + +export default AskOrganizerView; + diff --git a/app/components/AskOrganizer/index.tsx b/app/components/AskOrganizer/index.tsx new file mode 100644 index 0000000..ed9b85a --- /dev/null +++ b/app/components/AskOrganizer/index.tsx @@ -0,0 +1,3 @@ +export { default } from "./AskOrganizer.container"; +export * from "./AskOrganizer.types"; + diff --git a/app/components/FeatureGrid.tsx b/app/components/FeatureGrid.tsx deleted file mode 100644 index e72fe83..0000000 --- a/app/components/FeatureGrid.tsx +++ /dev/null @@ -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( - ({ 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 ( -
-
-
- {/* Feature Content Lockup */} -
- -
- - {/* MiniCard Grid */} -
- {features.map((feature, index) => ( - - ))} -
-
-
-
- ); - }, -); - -FeatureGrid.displayName = "FeatureGrid"; - -export default FeatureGrid; diff --git a/app/components/FeatureGrid/FeatureGrid.container.tsx b/app/components/FeatureGrid/FeatureGrid.container.tsx new file mode 100644 index 0000000..b440557 --- /dev/null +++ b/app/components/FeatureGrid/FeatureGrid.container.tsx @@ -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( + ({ 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 ( + + ); + }, +); + +FeatureGridContainer.displayName = "FeatureGrid"; + +export default FeatureGridContainer; + diff --git a/app/components/FeatureGrid/FeatureGrid.types.ts b/app/components/FeatureGrid/FeatureGrid.types.ts new file mode 100644 index 0000000..09e1151 --- /dev/null +++ b/app/components/FeatureGrid/FeatureGrid.types.ts @@ -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; +} + diff --git a/app/components/FeatureGrid/FeatureGrid.view.tsx b/app/components/FeatureGrid/FeatureGrid.view.tsx new file mode 100644 index 0000000..c5bbe92 --- /dev/null +++ b/app/components/FeatureGrid/FeatureGrid.view.tsx @@ -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 ( +
+
+
+ {/* Feature Content Lockup */} +
+ +
+ + {/* MiniCard Grid */} +
+ {features.map((feature, index) => ( + + ))} +
+
+
+
+ ); +} + +export default FeatureGridView; + diff --git a/app/components/FeatureGrid/index.tsx b/app/components/FeatureGrid/index.tsx new file mode 100644 index 0000000..f077179 --- /dev/null +++ b/app/components/FeatureGrid/index.tsx @@ -0,0 +1,3 @@ +export { default } from "./FeatureGrid.container"; +export * from "./FeatureGrid.types"; + diff --git a/app/components/NumberedCards/NumberedCards.container.tsx b/app/components/NumberedCards/NumberedCards.container.tsx new file mode 100644 index 0000000..48c69f1 --- /dev/null +++ b/app/components/NumberedCards/NumberedCards.container.tsx @@ -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( + ({ 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 ( + + ); + }, +); + +NumberedCardsContainer.displayName = "NumberedCards"; + +export default NumberedCardsContainer; + diff --git a/app/components/NumberedCards/NumberedCards.types.ts b/app/components/NumberedCards/NumberedCards.types.ts new file mode 100644 index 0000000..f6ffc86 --- /dev/null +++ b/app/components/NumberedCards/NumberedCards.types.ts @@ -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; +} + diff --git a/app/components/NumberedCards.tsx b/app/components/NumberedCards/NumberedCards.view.tsx similarity index 70% rename from app/components/NumberedCards.tsx rename to app/components/NumberedCards/NumberedCards.view.tsx index fb68657..a606d94 100644 --- a/app/components/NumberedCards.tsx +++ b/app/components/NumberedCards/NumberedCards.view.tsx @@ -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(({ 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 ( <>