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 { 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;
|
||||
@@ -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);
|
||||
@@ -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";
|
||||
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";
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user