First pass on component refactor

This commit is contained in:
adilallo
2026-01-29 17:29:37 -07:00
parent 11f32d7051
commit 7b9101824a
25 changed files with 1240 additions and 559 deletions
-143
View File
@@ -1,143 +0,0 @@
"use client";
import { memo } from "react";
import ContentLockup from "./ContentLockup";
import Button from "./Button";
import { useAnalytics } from "../hooks";
interface AskOrganizerProps {
title?: string;
subtitle?: string;
description?: string;
buttonText?: string;
buttonHref?: string;
className?: string;
variant?: "centered" | "left-aligned" | "compact" | "inverse";
onContactClick?: (_data: {
event: string;
component: string;
variant: string;
buttonText: string;
buttonHref: string;
timestamp: string;
}) => void;
}
const AskOrganizer = memo<AskOrganizerProps>(
({
title,
subtitle,
description,
buttonText = "Ask an organizer",
buttonHref = "#",
className = "",
variant = "centered",
onContactClick,
}) => {
const { trackEvent, trackCustomEvent } = useAnalytics();
// Analytics tracking for contact button clicks
const handleContactClick = (
_event: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>,
) => {
// Track with standard analytics
trackEvent({
event: "contact_button_click",
category: "engagement",
label: "ask_organizer",
component: "AskOrganizer",
variant,
});
// Also call custom callback if provided
trackCustomEvent(
"contact_button_click",
{
component: "AskOrganizer",
variant,
buttonText,
buttonHref,
},
onContactClick,
);
};
// Variant-specific styling
const variantStyles: Record<
string,
{ container: string; buttonContainer: string }
> = {
centered: {
container: "text-center",
buttonContainer: "flex justify-center",
},
"left-aligned": {
container: "text-left",
buttonContainer: "flex justify-start",
},
compact: {
container: "text-center",
buttonContainer: "flex justify-center",
},
inverse: {
container: "text-center",
buttonContainer: "flex justify-center",
},
};
const styles = variantStyles[variant] || variantStyles.centered;
// Section padding based on variant
const sectionPadding =
variant === "compact"
? "py-[var(--spacing-scale-016)] px-[var(--spacing-scale-016)] md:py-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-032)]"
: "py-[var(--spacing-scale-032)] px-[var(--spacing-scale-032)] md:py-[var(--spacing-scale-096)] md:px-[var(--spacing-scale-064)]";
// Gap between content and button based on variant
const contentGap =
variant === "compact"
? "gap-[var(--spacing-scale-020)]"
: "gap-[var(--spacing-scale-040)]";
const labelledBy = title ? "ask-organizer-headline" : undefined;
return (
<section
className={`${sectionPadding} ${className}`}
aria-labelledby={labelledBy}
aria-label={labelledBy ? undefined : "Ask an organizer"}
tabIndex={-1}
>
<div className={`flex flex-col ${contentGap} ${styles.container}`}>
{/* Content Lockup */}
<ContentLockup
title={title}
subtitle={subtitle}
description={description}
variant={variant === "inverse" ? "ask-inverse" : "ask"}
alignment={variant === "left-aligned" ? "left" : "center"}
titleId={labelledBy}
/>
{/* Button */}
<div className={styles.buttonContainer}>
<Button
href={buttonHref}
size="large"
variant={variant === "inverse" ? "primary" : "default"}
className="xl:!px-[var(--spacing-scale-020)] xl:!py-[var(--spacing-scale-012)] xl:!text-[24px] xl:!leading-[28px]"
onClick={handleContactClick}
ariaLabel={`${buttonText} - Contact an organizer for help`}
>
{buttonText}
</Button>
</div>
</div>
</section>
);
},
);
AskOrganizer.displayName = "AskOrganizer";
export default AskOrganizer;
@@ -0,0 +1,114 @@
"use client";
import { memo } from "react";
import { useAnalytics } from "../../hooks";
import AskOrganizerView from "./AskOrganizer.view";
import type {
AskOrganizerProps,
AskOrganizerVariant,
} from "./AskOrganizer.types";
const VARIANT_STYLES: Record<
AskOrganizerVariant,
{ container: string; buttonContainer: string }
> = {
centered: {
container: "text-center",
buttonContainer: "flex justify-center",
},
"left-aligned": {
container: "text-left",
buttonContainer: "flex justify-start",
},
compact: {
container: "text-center",
buttonContainer: "flex justify-center",
},
inverse: {
container: "text-center",
buttonContainer: "flex justify-center",
},
};
const AskOrganizerContainer = memo<AskOrganizerProps>(
({
title,
subtitle,
description,
buttonText = "Ask an organizer",
buttonHref = "#",
className = "",
variant = "centered",
onContactClick,
}) => {
const { trackEvent, trackCustomEvent } = useAnalytics();
const resolvedVariant: AskOrganizerVariant = variant ?? "centered";
const styles = VARIANT_STYLES[resolvedVariant] ?? VARIANT_STYLES.centered;
const sectionPadding =
resolvedVariant === "compact"
? "py-[var(--spacing-scale-016)] px-[var(--spacing-scale-016)] md:py-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-032)]"
: "py-[var(--spacing-scale-032)] px-[var(--spacing-scale-032)] md:py-[var(--spacing-scale-096)] md:px-[var(--spacing-scale-064)]";
const contentGap =
resolvedVariant === "compact"
? "gap-[var(--spacing-scale-020)]"
: "gap-[var(--spacing-scale-040)]";
const labelledBy = title ? "ask-organizer-headline" : undefined;
const handleContactClick = (
event: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>,
) => {
trackEvent({
event: "contact_button_click",
category: "engagement",
label: "ask_organizer",
component: "AskOrganizer",
variant: resolvedVariant,
});
trackCustomEvent(
"contact_button_click",
{
component: "AskOrganizer",
variant: resolvedVariant,
buttonText,
buttonHref,
},
onContactClick as
| ((
_data: Record<string, unknown>,
) => void)
| undefined,
);
// Preserve existing button behavior (no preventDefault here)
// while still tracking analytics.
return event;
};
return (
<AskOrganizerView
title={title}
subtitle={subtitle}
description={description}
buttonText={buttonText}
buttonHref={buttonHref}
className={className}
sectionPadding={sectionPadding}
contentGap={`${contentGap} ${styles.container}`}
buttonContainerClass={styles.buttonContainer}
variant={resolvedVariant}
labelledBy={labelledBy}
onContactClick={handleContactClick}
/>
);
},
);
AskOrganizerContainer.displayName = "AskOrganizer";
export default AskOrganizerContainer;
@@ -0,0 +1,43 @@
import type React from "react";
export type AskOrganizerVariant =
| "centered"
| "left-aligned"
| "compact"
| "inverse";
export interface AskOrganizerProps {
title?: string;
subtitle?: string;
description?: string;
buttonText?: string;
buttonHref?: string;
className?: string;
variant?: AskOrganizerVariant;
onContactClick?: (_data: {
event: string;
component: string;
variant: string;
buttonText: string;
buttonHref: string;
timestamp: string;
}) => void;
}
export interface AskOrganizerViewProps {
title?: string;
subtitle?: string;
description?: string;
buttonText: string;
buttonHref: string;
className: string;
sectionPadding: string;
contentGap: string;
buttonContainerClass: string;
variant: AskOrganizerVariant;
labelledBy?: string;
onContactClick: (
event: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>,
) => void;
}
@@ -0,0 +1,56 @@
import ContentLockup from "../ContentLockup";
import Button from "../Button";
import type { AskOrganizerViewProps } from "./AskOrganizer.types";
function AskOrganizerView({
title,
subtitle,
description,
buttonText,
buttonHref,
className,
sectionPadding,
contentGap,
buttonContainerClass,
variant,
labelledBy,
onContactClick,
}: AskOrganizerViewProps) {
return (
<section
className={`${sectionPadding} ${className}`}
aria-labelledby={labelledBy}
aria-label={labelledBy ? undefined : "Ask an organizer"}
tabIndex={-1}
>
<div className={`flex flex-col ${contentGap}`}>
{/* Content Lockup */}
<ContentLockup
title={title}
subtitle={subtitle}
description={description}
variant={variant === "inverse" ? "ask-inverse" : "ask"}
alignment={variant === "left-aligned" ? "left" : "center"}
titleId={labelledBy}
/>
{/* Button */}
<div className={buttonContainerClass}>
<Button
href={buttonHref}
size="large"
variant={variant === "inverse" ? "primary" : "default"}
className="xl:!px-[var(--spacing-scale-020)] xl:!py-[var(--spacing-scale-012)] xl:!text-[24px] xl:!leading-[28px]"
onClick={onContactClick}
ariaLabel={`${buttonText} - Contact an organizer for help`}
>
{buttonText}
</Button>
</div>
</div>
</section>
);
}
export default AskOrganizerView;
+3
View File
@@ -0,0 +1,3 @@
export { default } from "./AskOrganizer.container";
export * from "./AskOrganizer.types";
-98
View File
@@ -1,98 +0,0 @@
"use client";
import { memo, useMemo } from "react";
import ContentLockup from "./ContentLockup";
import MiniCard from "./MiniCard";
interface FeatureGridProps {
title?: string;
subtitle?: string;
className?: string;
}
const FeatureGrid = memo<FeatureGridProps>(
({ title, subtitle, className = "" }) => {
// Memoize the feature data to prevent unnecessary re-renders
const features = useMemo(
() => [
{
backgroundColor: "bg-[var(--color-surface-default-brand-royal)]",
labelLine1: "Decision-making",
labelLine2: "support",
panelContent: "/assets/Feature_Support.png",
ariaLabel: "Decision-making support tools",
href: "#decision-making",
},
{
backgroundColor: "bg-[#D1FFE2]",
labelLine1: "Values alignment",
labelLine2: "exercises",
panelContent: "/assets/Feature_Exercises.png",
ariaLabel: "Values alignment exercises",
href: "#values-alignment",
},
{
backgroundColor: "bg-[#F4CAFF]",
labelLine1: "Membership",
labelLine2: "guidance",
panelContent: "/assets/Feature_Guidance.png",
ariaLabel: "Membership guidance resources",
href: "#membership-guidance",
},
{
backgroundColor: "bg-[#CBDDFF]",
labelLine1: "Conflict resolution",
labelLine2: "tools",
panelContent: "/assets/Feature_Tools.png",
ariaLabel: "Conflict resolution tools",
href: "#conflict-resolution",
},
],
[],
);
const labelledBy = title ? "feature-grid-headline" : undefined;
return (
<section
className={`p-0 lg:p-[var(--spacing-scale-064)] ${className}`}
aria-labelledby={labelledBy}
aria-label={labelledBy ? undefined : "Feature tools and services"}
>
<div className="py-[var(--spacing-scale-032)] px-[var(--spacing-scale-020)] md:pt-[var(--spacing-scale-076)] md:pb-[var(--spacing-scale-048)] lg:pb-[var(--spacing-scale-076)] md:px-[var(--spacing-scale-048)] bg-[#171717] rounded-[var(--radius-measures-radius-xlarge)] focus-within:ring-2 focus-within:ring-[var(--color-surface-default-brand-royal)] focus-within:ring-offset-2">
<div className="w-full mx-auto gap-[var(--spacing-scale-048)] lg:flex lg:items-start lg:gap-[var(--spacing-scale-048)] [container-type:inline-size]">
{/* Feature Content Lockup */}
<div className="lg:shrink lg:min-w-0">
<ContentLockup
title={title}
subtitle={subtitle}
variant="feature"
linkText="Learn more"
linkHref="#"
titleId={labelledBy}
/>
</div>
{/* MiniCard Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-[var(--spacing-scale-012)] mt-[var(--spacing-scale-048)] lg:mt-0 lg:flex-grow lg:shrink-0">
{features.map((feature, index) => (
<MiniCard
key={index}
backgroundColor={feature.backgroundColor}
labelLine1={feature.labelLine1}
labelLine2={feature.labelLine2}
panelContent={feature.panelContent}
ariaLabel={feature.ariaLabel}
href={feature.href}
/>
))}
</div>
</div>
</div>
</section>
);
},
);
FeatureGrid.displayName = "FeatureGrid";
export default FeatureGrid;
@@ -0,0 +1,64 @@
"use client";
import { memo, useMemo } from "react";
import FeatureGridView from "./FeatureGrid.view";
import type { FeatureGridProps, Feature } from "./FeatureGrid.types";
const FeatureGridContainer = memo<FeatureGridProps>(
({ title, subtitle, className = "" }) => {
const features: Feature[] = useMemo(
() => [
{
backgroundColor: "bg-[var(--color-surface-default-brand-royal)]",
labelLine1: "Decision-making",
labelLine2: "support",
panelContent: "/assets/Feature_Support.png",
ariaLabel: "Decision-making support tools",
href: "#decision-making",
},
{
backgroundColor: "bg-[#D1FFE2]",
labelLine1: "Values alignment",
labelLine2: "exercises",
panelContent: "/assets/Feature_Exercises.png",
ariaLabel: "Values alignment exercises",
href: "#values-alignment",
},
{
backgroundColor: "bg-[#F4CAFF]",
labelLine1: "Membership",
labelLine2: "guidance",
panelContent: "/assets/Feature_Guidance.png",
ariaLabel: "Membership guidance resources",
href: "#membership-guidance",
},
{
backgroundColor: "bg-[#CBDDFF]",
labelLine1: "Conflict resolution",
labelLine2: "tools",
panelContent: "/assets/Feature_Tools.png",
ariaLabel: "Conflict resolution tools",
href: "#conflict-resolution",
},
],
[],
);
const labelledBy = title ? "feature-grid-headline" : undefined;
return (
<FeatureGridView
title={title}
subtitle={subtitle}
className={className}
features={features}
labelledBy={labelledBy}
/>
);
},
);
FeatureGridContainer.displayName = "FeatureGrid";
export default FeatureGridContainer;
@@ -0,0 +1,20 @@
export interface FeatureGridProps {
title?: string;
subtitle?: string;
className?: string;
}
export interface Feature {
backgroundColor: string;
labelLine1: string;
labelLine2: string;
panelContent: string;
ariaLabel: string;
href: string;
}
export interface FeatureGridViewProps extends FeatureGridProps {
features: Feature[];
labelledBy?: string;
}
@@ -0,0 +1,53 @@
import ContentLockup from "../ContentLockup";
import MiniCard from "../MiniCard";
import type { FeatureGridViewProps } from "./FeatureGrid.types";
function FeatureGridView({
title,
subtitle,
className = "",
features,
labelledBy,
}: FeatureGridViewProps) {
return (
<section
className={`p-0 lg:p-[var(--spacing-scale-064)] ${className}`}
aria-labelledby={labelledBy}
aria-label={labelledBy ? undefined : "Feature tools and services"}
>
<div className="py-[var(--spacing-scale-032)] px-[var(--spacing-scale-020)] md:pt-[var(--spacing-scale-076)] md:pb-[var(--spacing-scale-048)] lg:pb-[var(--spacing-scale-076)] md:px-[var(--spacing-scale-048)] bg-[#171717] rounded-[var(--radius-measures-radius-xlarge)] focus-within:ring-2 focus-within:ring-[var(--color-surface-default-brand-royal)] focus-within:ring-offset-2">
<div className="w-full mx-auto gap-[var(--spacing-scale-048)] lg:flex lg:items-start lg:gap-[var(--spacing-scale-048)] [container-type:inline-size]">
{/* Feature Content Lockup */}
<div className="lg:shrink lg:min-w-0">
<ContentLockup
title={title}
subtitle={subtitle}
variant="feature"
linkText="Learn more"
linkHref="#"
titleId={labelledBy}
/>
</div>
{/* MiniCard Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-[var(--spacing-scale-012)] mt-[var(--spacing-scale-048)] lg:mt-0 lg:flex-grow lg:shrink-0">
{features.map((feature, index) => (
<MiniCard
key={index}
backgroundColor={feature.backgroundColor}
labelLine1={feature.labelLine1}
labelLine2={feature.labelLine2}
panelContent={feature.panelContent}
ariaLabel={feature.ariaLabel}
href={feature.href}
/>
))}
</div>
</div>
</div>
</section>
);
}
export default FeatureGridView;
+3
View File
@@ -0,0 +1,3 @@
export { default } from "./FeatureGrid.container";
export * from "./FeatureGrid.types";
@@ -0,0 +1,36 @@
"use client";
import { memo } from "react";
import { useSchemaData } from "../../hooks";
import NumberedCardsView from "./NumberedCards.view";
import type { NumberedCardsProps } from "./NumberedCards.types";
const NumberedCardsContainer = memo<NumberedCardsProps>(
({ title, subtitle, cards }) => {
const schemaData = useSchemaData({
type: "HowTo",
name: title,
description: subtitle,
steps: cards.map((card) => ({
name: card.text,
text: card.text,
})),
});
const schemaJson = JSON.stringify(schemaData);
return (
<NumberedCardsView
title={title}
subtitle={subtitle}
cards={cards}
schemaJson={schemaJson}
/>
);
},
);
NumberedCardsContainer.displayName = "NumberedCards";
export default NumberedCardsContainer;
@@ -0,0 +1,16 @@
export interface Card {
text: string;
iconShape?: string;
iconColor?: string;
}
export interface NumberedCardsProps {
title: string;
subtitle: string;
cards: Card[];
}
export interface NumberedCardsViewProps extends NumberedCardsProps {
schemaJson: string;
}
@@ -1,40 +1,19 @@
"use client";
import { memo } from "react";
import NumberedCard from "./NumberedCard";
import SectionHeader from "./SectionHeader";
import Button from "./Button";
import { useSchemaData } from "../hooks";
interface Card {
text: string;
iconShape?: string;
iconColor?: string;
}
interface NumberedCardsProps {
title: string;
subtitle: string;
cards: Card[];
}
const NumberedCards = memo<NumberedCardsProps>(({ title, subtitle, cards }) => {
// Generate schema data using hook
const schemaData = useSchemaData({
type: "HowTo",
name: title,
description: subtitle,
steps: cards.map((card) => ({
name: card.text,
text: card.text,
})),
});
import SectionHeader from "../SectionHeader";
import NumberedCard from "../NumberedCard";
import Button from "../Button";
import type { NumberedCardsViewProps } from "./NumberedCards.types";
function NumberedCardsView({
title,
subtitle,
cards,
schemaJson,
}: NumberedCardsViewProps) {
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaData) }}
dangerouslySetInnerHTML={{ __html: schemaJson }}
/>
<section className="bg-transparent py-[var(--spacing-scale-032)] px-[var(--spacing-scale-020)] sm:py-[var(--spacing-scale-048)] sm:px-[var(--spacing-scale-032)] lg:py-[var(--spacing-scale-064)] lg:px-[var(--spacing-scale-064)] xl:py-[var(--spacing-scale-076)] xl:px-[var(--spacing-scale-064)]">
<div className="max-w-[var(--spacing-measures-max-width-lg)] mx-auto">
@@ -81,8 +60,7 @@ const NumberedCards = memo<NumberedCardsProps>(({ title, subtitle, cards }) => {
</section>
</>
);
});
}
NumberedCards.displayName = "NumberedCards";
export default NumberedCardsView;
export default NumberedCards;
+3
View File
@@ -0,0 +1,3 @@
export { default } from "./NumberedCards.container";
export * from "./NumberedCards.types";
@@ -11,33 +11,13 @@ import React, {
useCallback,
memo,
useImperativeHandle,
useEffect,
} from "react";
import { useClickOutside } from "../hooks";
import SelectDropdown from "./SelectDropdown";
import SelectOption from "./SelectOption";
import { useClickOutside } from "../../hooks";
import { SelectView } from "./Select.view";
import type { SelectProps } from "./Select.types";
interface SelectOptionData {
value: string;
label: string;
}
interface SelectProps {
id?: string;
label?: string;
labelVariant?: "default" | "horizontal";
size?: "small" | "medium" | "large";
state?: "default" | "hover" | "focus";
disabled?: boolean;
error?: boolean;
placeholder?: string;
className?: string;
children?: React.ReactNode;
value?: string;
onChange?: (_data: { target: { value: string; text: string } }) => void;
options?: SelectOptionData[];
}
const Select = forwardRef<HTMLButtonElement, SelectProps>(
const SelectContainer = forwardRef<HTMLButtonElement, SelectProps>(
(
{
id,
@@ -65,6 +45,13 @@ const Select = forwardRef<HTMLButtonElement, SelectProps>(
const selectRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
// Sync internal state with external value prop
useEffect(() => {
if (value !== undefined) {
setSelectedValue(value);
}
}, [value]);
useImperativeHandle(
ref,
() => selectRef.current as HTMLButtonElement | null,
@@ -239,6 +226,12 @@ const Select = forwardRef<HTMLButtonElement, SelectProps>(
? "flex items-center gap-[12px]"
: "flex flex-col";
const chevronClasses = `${
size === "large" ? "w-5 h-5" : "w-4 h-4"
} text-[var(--color-content-default-primary)] transition-transform duration-200 ${
isOpen ? "rotate-180" : ""
}`;
// Get display text for selected value
const getDisplayText = (): string => {
if (!selectedValue) return placeholder;
@@ -273,107 +266,39 @@ const Select = forwardRef<HTMLButtonElement, SelectProps>(
};
return (
<div className={containerClasses}>
{label && (
<label
id={labelId}
htmlFor={selectId}
className={`${labelClasses} text-[var(--color-content-default-secondary)]`}
>
{label}
</label>
)}
<div className="relative">
<button
ref={selectRef}
id={selectId}
disabled={disabled}
className={selectClasses}
aria-labelledby={label ? labelId : undefined}
aria-invalid={error}
aria-expanded={isOpen}
aria-haspopup="listbox"
onClick={handleSelectClick}
onKeyDown={handleKeyDown}
{...props}
>
<span className="text-left">{getDisplayText()}</span>
</button>
<div className="absolute inset-y-0 right-0 flex items-center pr-[12px] pointer-events-none">
<svg
className={`${
size === "large" ? "w-5 h-5" : "w-4 h-4"
} text-[var(--color-content-default-primary)] transition-transform duration-200 ${
isOpen ? "rotate-180" : ""
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
{isOpen && (
<div
ref={menuRef}
className="absolute top-full left-0 right-0 z-50 mt-1"
>
<SelectDropdown>
{options && Array.isArray(options)
? options.map((option) => (
<SelectOption
key={option.value}
selected={option.value === selectedValue}
size={size}
onClick={() =>
handleOptionSelect(option.value, option.label)
}
>
{option.label}
</SelectOption>
))
: Children.map(children, (child) => {
if (
React.isValidElement(child) &&
child.type === "option"
) {
const optionProps = child.props as {
value: string;
children: ReactNode;
};
return (
<SelectOption
key={optionProps.value}
selected={optionProps.value === selectedValue}
size={size}
onClick={() =>
handleOptionSelect(
optionProps.value,
String(optionProps.children),
)
}
>
{optionProps.children}
</SelectOption>
);
}
return child;
})}
</SelectDropdown>
</div>
)}
</div>
</div>
<SelectView
label={label}
placeholder={placeholder}
size={size}
state={state}
disabled={disabled}
error={error}
labelVariant={labelVariant}
className={className}
options={options}
children={children}
selectId={selectId}
labelId={labelId}
isOpen={isOpen}
selectedValue={selectedValue}
displayText={getDisplayText()}
selectClasses={selectClasses}
labelClasses={labelClasses}
containerClasses={containerClasses}
chevronClasses={chevronClasses}
onButtonClick={handleSelectClick}
onButtonKeyDown={handleKeyDown}
onOptionClick={handleOptionSelect}
selectRef={selectRef}
menuRef={menuRef}
ariaLabelledby={label ? labelId : undefined}
ariaInvalid={error}
{...props}
/>
);
},
);
Select.displayName = "Select";
SelectContainer.displayName = "Select";
export default memo(Select);
export default memo(SelectContainer);
+22
View File
@@ -0,0 +1,22 @@
import type { ReactNode } from "react";
export interface SelectOptionData {
value: string;
label: string;
}
export interface SelectProps {
id?: string;
label?: string;
labelVariant?: "default" | "horizontal";
size?: "small" | "medium" | "large";
state?: "default" | "hover" | "focus";
disabled?: boolean;
error?: boolean;
placeholder?: string;
className?: string;
children?: ReactNode;
value?: string;
onChange?: (_data: { target: { value: string; text: string } }) => void;
options?: SelectOptionData[];
}
+161
View File
@@ -0,0 +1,161 @@
import React, { Children, type ReactElement, type ReactNode } from "react";
import SelectDropdown from "../SelectDropdown";
import SelectOption from "../SelectOption";
import type { SelectOptionData } from "./Select.types";
export interface SelectViewProps {
label?: string;
placeholder: string;
size: "small" | "medium" | "large";
state: "default" | "hover" | "focus";
disabled: boolean;
error: boolean;
labelVariant: "default" | "horizontal";
className: string;
options?: SelectOptionData[];
children?: ReactNode;
// Computed props from container
selectId: string;
labelId: string;
isOpen: boolean;
selectedValue: string;
displayText: string;
selectClasses: string;
labelClasses: string;
containerClasses: string;
chevronClasses: string;
// Callbacks
onButtonClick: () => void;
onButtonKeyDown: (e: React.KeyboardEvent<HTMLButtonElement>) => void;
onOptionClick: (value: string, text: string) => void;
// Refs
selectRef: React.RefObject<HTMLButtonElement>;
menuRef: React.RefObject<HTMLDivElement>;
// Additional props
ariaLabelledby?: string;
ariaInvalid?: boolean;
}
export function SelectView({
label,
placeholder,
size,
disabled,
error,
labelVariant,
options,
children,
selectId,
labelId,
isOpen,
selectedValue,
displayText,
selectClasses,
labelClasses,
containerClasses,
chevronClasses,
onButtonClick,
onButtonKeyDown,
onOptionClick,
selectRef,
menuRef,
ariaLabelledby,
ariaInvalid,
...props
}: SelectViewProps) {
return (
<div className={containerClasses}>
{label && (
<label
id={labelId}
htmlFor={selectId}
className={`${labelClasses} text-[var(--color-content-default-secondary)]`}
>
{label}
</label>
)}
<div className="relative">
<button
ref={selectRef}
id={selectId}
disabled={disabled}
className={selectClasses}
aria-labelledby={ariaLabelledby}
aria-invalid={ariaInvalid}
aria-expanded={isOpen}
aria-haspopup="listbox"
onClick={onButtonClick}
onKeyDown={onButtonKeyDown}
{...props}
>
<span className="text-left">{displayText}</span>
</button>
<div className="absolute inset-y-0 right-0 flex items-center pr-[12px] pointer-events-none">
<svg
className={chevronClasses}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
{isOpen && (
<div
ref={menuRef}
className="absolute top-full left-0 right-0 z-50 mt-1"
>
<SelectDropdown>
{options && Array.isArray(options)
? options.map((option) => (
<SelectOption
key={option.value}
selected={option.value === selectedValue}
size={size}
onClick={() =>
onOptionClick(option.value, option.label)
}
>
{option.label}
</SelectOption>
))
: Children.map(children, (child) => {
if (
React.isValidElement(child) &&
child.type === "option"
) {
const optionProps = child.props as {
value: string;
children: ReactNode;
};
return (
<SelectOption
key={optionProps.value}
selected={optionProps.value === selectedValue}
size={size}
onClick={() =>
onOptionClick(
optionProps.value,
String(optionProps.children),
)
}
>
{optionProps.children}
</SelectOption>
);
}
return child;
})}
</SelectDropdown>
</div>
)}
</div>
</div>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./Select.container";
export type { SelectProps, SelectOptionData } from "./Select.types";
@@ -0,0 +1,106 @@
"use client";
import { memo, useEffect, useState } from "react";
import { logger } from "../../lib/logger";
import WebVitalsDashboardView from "./WebVitalsDashboard.view";
import type { Metrics, Vitals, VitalData } from "./WebVitalsDashboard.types";
const createInitialVital = (): VitalData => ({
value: 0,
rating: "unknown",
});
const createInitialVitals = (): Vitals => ({
lcp: createInitialVital(),
fid: createInitialVital(),
cls: createInitialVital(),
fcp: createInitialVital(),
ttfb: createInitialVital(),
});
const WebVitalsDashboardContainer = memo(() => {
const [vitals, setVitals] = useState<Vitals>(createInitialVitals);
const [metrics, setMetrics] = useState<Metrics>({});
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchVitals = async () => {
try {
const response = await fetch("/api/web-vitals");
const data = (await response.json()) as { metrics?: Metrics };
setMetrics(data.metrics || {});
} catch (error) {
logger.error("Error fetching web vitals:", error);
} finally {
setLoading(false);
}
};
fetchVitals();
if (typeof window !== "undefined") {
import("web-vitals").then((webVitals) => {
const { getCLS, getFID, getFCP, getLCP, getTTFB } = webVitals as any;
getLCP((metric: { value: number; rating: VitalData["rating"] }) => {
setVitals((prev) => ({
...prev,
lcp: {
value: Math.round(metric.value),
rating: metric.rating,
},
}));
});
getFID((metric: { value: number; rating: VitalData["rating"] }) => {
setVitals((prev) => ({
...prev,
fid: {
value: Math.round(metric.value),
rating: metric.rating,
},
}));
});
getCLS((metric: { value: number; rating: VitalData["rating"] }) => {
setVitals((prev) => ({
...prev,
cls: {
value: Math.round(metric.value * 1000) / 1000,
rating: metric.rating,
},
}));
});
getFCP((metric: { value: number; rating: VitalData["rating"] }) => {
setVitals((prev) => ({
...prev,
fcp: {
value: Math.round(metric.value),
rating: metric.rating,
},
}));
});
getTTFB((metric: { value: number; rating: VitalData["rating"] }) => {
setVitals((prev) => ({
...prev,
ttfb: {
value: Math.round(metric.value),
rating: metric.rating,
},
}));
});
});
}
}, []);
return (
<WebVitalsDashboardView vitals={vitals} metrics={metrics} loading={loading} />
);
});
WebVitalsDashboardContainer.displayName = "WebVitalsDashboard";
export default WebVitalsDashboardContainer;
@@ -0,0 +1,34 @@
export interface VitalData {
value: number;
rating: "good" | "needs-improvement" | "poor" | "unknown";
}
export interface Vitals {
lcp: VitalData;
fid: VitalData;
cls: VitalData;
fcp: VitalData;
ttfb: VitalData;
}
export interface MetricData {
count: number;
average: number;
min: number;
max: number;
goodCount: number;
needsImprovementCount: number;
poorCount: number;
lastUpdated?: string;
}
export interface Metrics {
[key: string]: MetricData;
}
export interface WebVitalsDashboardViewProps {
vitals: Vitals;
metrics: Metrics;
loading: boolean;
}
@@ -1,159 +1,43 @@
"use client";
import type { WebVitalsDashboardViewProps } from "./WebVitalsDashboard.types";
import { useState, useEffect, memo } from "react";
import { logger } from "../../lib/logger";
const getRatingColor = (rating: string): string => {
switch (rating) {
case "good":
return "text-green-600 bg-green-50";
case "needs-improvement":
return "text-yellow-600 bg-yellow-50";
case "poor":
return "text-red-600 bg-red-50";
default:
return "text-gray-600 bg-gray-50";
}
};
interface VitalData {
value: number;
rating: "good" | "needs-improvement" | "poor" | "unknown";
}
const getRatingIcon = (rating: string): string => {
switch (rating) {
case "good":
return "✅";
case "needs-improvement":
return "⚠️";
case "poor":
return "❌";
default:
return "❓";
}
};
interface Vitals {
lcp: VitalData;
fid: VitalData;
cls: VitalData;
fcp: VitalData;
ttfb: VitalData;
}
interface MetricData {
count: number;
average: number;
min: number;
max: number;
goodCount: number;
needsImprovementCount: number;
poorCount: number;
lastUpdated?: string;
}
interface Metrics {
[key: string]: MetricData;
}
const WebVitalsDashboard = memo(() => {
const [vitals, setVitals] = useState<Vitals>({
lcp: { value: 0, rating: "unknown" },
fid: { value: 0, rating: "unknown" },
cls: { value: 0, rating: "unknown" },
fcp: { value: 0, rating: "unknown" },
ttfb: { value: 0, rating: "unknown" },
});
const [metrics, setMetrics] = useState<Metrics>({});
const [loading, setLoading] = useState(true);
useEffect(() => {
// Fetch Web Vitals data from API
const fetchVitals = async () => {
try {
const response = await fetch("/api/web-vitals");
const data = (await response.json()) as { metrics?: Metrics };
setMetrics(data.metrics || {});
} catch (error) {
logger.error("Error fetching web vitals:", error);
} finally {
setLoading(false);
}
};
fetchVitals();
// Set up Web Vitals tracking
if (typeof window !== "undefined") {
import("web-vitals").then((webVitals) => {
const { getCLS, getFID, getFCP, getLCP, getTTFB } = webVitals as any;
// Track Largest Contentful Paint
getLCP((metric) => {
setVitals((prev) => ({
...prev,
lcp: {
value: Math.round(metric.value),
rating: metric.rating,
},
}));
});
// Track First Input Delay
getFID((metric) => {
setVitals((prev) => ({
...prev,
fid: {
value: Math.round(metric.value),
rating: metric.rating,
},
}));
});
// Track Cumulative Layout Shift
getCLS((metric) => {
setVitals((prev) => ({
...prev,
cls: {
value: Math.round(metric.value * 1000) / 1000,
rating: metric.rating,
},
}));
});
// Track First Contentful Paint
getFCP((metric) => {
setVitals((prev) => ({
...prev,
fcp: {
value: Math.round(metric.value),
rating: metric.rating,
},
}));
});
// Track Time to First Byte
getTTFB((metric) => {
setVitals((prev) => ({
...prev,
ttfb: {
value: Math.round(metric.value),
rating: metric.rating,
},
}));
});
});
}
}, []);
const getRatingColor = (rating: string): string => {
switch (rating) {
case "good":
return "text-green-600 bg-green-50";
case "needs-improvement":
return "text-yellow-600 bg-yellow-50";
case "poor":
return "text-red-600 bg-red-50";
default:
return "text-gray-600 bg-gray-50";
}
};
const getRatingIcon = (rating: string): string => {
switch (rating) {
case "good":
return "✅";
case "needs-improvement":
return "⚠️";
case "poor":
return "❌";
default:
return "❓";
}
};
const formatValue = (metric: string, value: number): string => {
if (metric === "cls") {
return value.toFixed(3);
}
return `${value}ms`;
};
const formatValue = (metric: string, value: number): string => {
if (metric === "cls") {
return value.toFixed(3);
}
return `${value}ms`;
};
function WebVitalsDashboardView({
vitals,
metrics,
loading,
}: WebVitalsDashboardViewProps) {
if (loading) {
return (
<div className="p-6 bg-white rounded-lg shadow-lg">
@@ -227,7 +111,9 @@ const WebVitalsDashboard = memo(() => {
<span className="text-yellow-600">
Needs Improvement: {data.needsImprovementCount}
</span>
<span className="text-red-600">Poor: {data.poorCount}</span>
<span className="text-red-600">
Poor: {data.poorCount}
</span>
</div>
</div>
</div>
@@ -266,8 +152,7 @@ const WebVitalsDashboard = memo(() => {
</div>
</div>
);
});
}
WebVitalsDashboard.displayName = "WebVitalsDashboard";
export default WebVitalsDashboardView;
export default WebVitalsDashboard;
@@ -0,0 +1,3 @@
export { default } from "./WebVitalsDashboard.container";
export * from "./WebVitalsDashboard.types";