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