Component cleanup
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { useSchemaData } from "../../../hooks";
|
||||
import CardStepsView from "./CardSteps.view";
|
||||
import type { CardStepsProps } from "./CardSteps.types";
|
||||
|
||||
/**
|
||||
* Figma: "Community Rule System" → Sections → SectionCardSteps ([17434:19695](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=17434-19695)).
|
||||
* Composes **`cards/Step`** (Figma Card / Step), not **`progress/Stepper`**.
|
||||
*/
|
||||
const CardStepsContainer = memo<CardStepsProps>(
|
||||
({ title, subtitle, steps, headingDesktopLines }) => {
|
||||
const schemaData = useSchemaData({
|
||||
type: "HowTo",
|
||||
name: title,
|
||||
description: subtitle,
|
||||
steps: steps.map((item) => ({
|
||||
name: item.text,
|
||||
text: item.text,
|
||||
})),
|
||||
});
|
||||
|
||||
const schemaJson = JSON.stringify(schemaData);
|
||||
|
||||
return (
|
||||
<CardStepsView
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
steps={steps}
|
||||
headingDesktopLines={headingDesktopLines}
|
||||
schemaJson={schemaJson}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
CardStepsContainer.displayName = "CardSteps";
|
||||
|
||||
export default CardStepsContainer;
|
||||
@@ -0,0 +1,18 @@
|
||||
/** One row in the section grid; rendered with `cards/Step`. */
|
||||
export interface CardStepsItem {
|
||||
text: string;
|
||||
iconShape?: string;
|
||||
iconColor?: string;
|
||||
}
|
||||
|
||||
export interface CardStepsProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
steps: CardStepsItem[];
|
||||
/** Large-screen heading split: line 1–3 (e.g. How / CommunityRule / helps). */
|
||||
headingDesktopLines?: readonly [string, string, string];
|
||||
}
|
||||
|
||||
export interface CardStepsViewProps extends CardStepsProps {
|
||||
schemaJson: string;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import SectionHeader from "../../type/SectionHeader";
|
||||
import Step from "../../cards/Step";
|
||||
import Button from "../../buttons/Button";
|
||||
import type { CardStepsViewProps } from "./CardSteps.types";
|
||||
|
||||
function CardStepsView({
|
||||
title,
|
||||
subtitle,
|
||||
steps,
|
||||
headingDesktopLines,
|
||||
schemaJson,
|
||||
}: CardStepsViewProps) {
|
||||
const t = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
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">
|
||||
<div className="grid grid-cols-1 gap-y-[var(--spacing-scale-032)] sm:gap-y-[var(--spacing-scale-048)] lg:gap-y-[var(--spacing-scale-056)]">
|
||||
<div>
|
||||
<SectionHeader
|
||||
variant="multi-line"
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
titleLg={t("cardSteps.titleLg")}
|
||||
stackedDesktopLines={headingDesktopLines}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-y-[var(--spacing-scale-024)] lg:grid-cols-3 lg:gap-[var(--spacing-scale-024)]">
|
||||
{steps.map((item, index) => (
|
||||
<Step
|
||||
key={index}
|
||||
number={index + 1}
|
||||
text={item.text}
|
||||
iconShape={item.iconShape}
|
||||
iconColor={item.iconColor}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<Button buttonType="outline" palette="default" size="large">
|
||||
{t("cardSteps.buttons.seeHowItWorks")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default CardStepsView;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from "./CardSteps.container";
|
||||
export * from "./CardSteps.types";
|
||||
@@ -1,16 +0,0 @@
|
||||
export interface CommunityRuleDocumentEntry {
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface CommunityRuleDocumentSection {
|
||||
categoryName: string;
|
||||
entries: CommunityRuleDocumentEntry[];
|
||||
}
|
||||
|
||||
export interface CommunityRuleDocumentProps {
|
||||
sections: CommunityRuleDocumentSection[];
|
||||
className?: string;
|
||||
/** When true, wrap in white background with left teal bar (small breakpoint). */
|
||||
useCardStyle?: boolean;
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import type { CommunityRuleDocumentProps } from "./CommunityRuleDocument.types";
|
||||
|
||||
const SECTION_GAP = "var(--measures-spacing-1200, 64px)";
|
||||
const TEAL_BG = "var(--color-teal-teal50, #c9fef9)";
|
||||
const SECTION_LINE_COLOR = "var(--color-border-default-tertiary, #464646)";
|
||||
|
||||
function CommunityRuleDocumentView({
|
||||
sections,
|
||||
className = "",
|
||||
useCardStyle = false,
|
||||
}: CommunityRuleDocumentProps) {
|
||||
const rootClass = useCardStyle
|
||||
? `rounded-[12px] bg-white pl-3 border-l-4 ${className}`
|
||||
: className;
|
||||
const rootStyle = useCardStyle ? { borderLeftColor: TEAL_BG } : undefined;
|
||||
|
||||
const sectionLineStyle = useCardStyle
|
||||
? undefined
|
||||
: { borderLeftColor: SECTION_LINE_COLOR };
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col min-w-0 ${rootClass}`}
|
||||
style={{ gap: SECTION_GAP, ...rootStyle }}
|
||||
>
|
||||
{sections.map((section, sectionIndex) => (
|
||||
<div
|
||||
key={sectionIndex}
|
||||
className={`flex flex-col min-w-0 ${!useCardStyle ? "border-l pl-3" : ""}`}
|
||||
style={sectionLineStyle}
|
||||
>
|
||||
{/* Section content: line runs full height of this block via border-left */}
|
||||
<div className="flex flex-1 flex-col gap-4 min-w-0">
|
||||
<p className="font-inter font-medium text-[16px] leading-[20px] text-[var(--color-content-invert-secondary,#1f1f1f)] shrink-0">
|
||||
{section.categoryName}
|
||||
</p>
|
||||
<div className="flex flex-col min-w-0" style={{ gap: "24px" }}>
|
||||
{section.entries.map((entry, entryIndex) => (
|
||||
<div
|
||||
key={entryIndex}
|
||||
className="flex flex-col min-w-0"
|
||||
style={{ gap: "6px" }}
|
||||
>
|
||||
<p className="font-inter font-bold text-[20px] leading-[28px] text-[var(--color-content-invert-primary)] shrink-0">
|
||||
{entry.title}
|
||||
</p>
|
||||
<p className="font-inter font-normal text-[14px] leading-[20px] text-[var(--color-content-invert-primary)] shrink-0">
|
||||
{entry.body}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CommunityRuleDocumentView.displayName = "CommunityRuleDocumentView";
|
||||
|
||||
export default memo(CommunityRuleDocumentView);
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default } from "./CommunityRuleDocument.view";
|
||||
export type { CommunityRuleDocumentProps } from "./CommunityRuleDocument.types";
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import ContentLockup from "../../type/ContentLockup";
|
||||
import MiniCard from "../../cards/MiniCard";
|
||||
import Mini from "../../cards/Mini";
|
||||
import type { FeatureGridViewProps } from "./FeatureGrid.types";
|
||||
|
||||
function FeatureGridView({
|
||||
@@ -37,10 +37,10 @@ function FeatureGridView({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* MiniCard Grid */}
|
||||
{/* Mini 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
|
||||
<Mini
|
||||
key={index}
|
||||
backgroundColor={feature.backgroundColor}
|
||||
labelLine1={feature.labelLine1}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import Image from "next/image";
|
||||
import { useMediaQuery } from "../../../hooks/useMediaQuery";
|
||||
import RuleCard from "../../cards/RuleCard";
|
||||
import Rule from "../../cards/Rule";
|
||||
import { getAssetPath } from "../../../../lib/assetUtils";
|
||||
import type { GovernanceTemplateCatalogEntry } from "../../../../lib/templates/governanceTemplateCatalog";
|
||||
|
||||
@@ -53,7 +53,7 @@ export function GovernanceTemplateGrid({
|
||||
`}
|
||||
>
|
||||
{entries.map((card) => (
|
||||
<RuleCard
|
||||
<Rule
|
||||
key={card.slug}
|
||||
title={card.title}
|
||||
description={card.description}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
"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;
|
||||
@@ -1,15 +0,0 @@
|
||||
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,70 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import SectionHeader from "../SectionHeader";
|
||||
import NumberCard from "../../cards/NumberCard";
|
||||
import Button from "../../buttons/Button";
|
||||
import type { NumberedCardsViewProps } from "./NumberedCards.types";
|
||||
|
||||
function NumberedCardsView({
|
||||
title,
|
||||
subtitle,
|
||||
cards,
|
||||
schemaJson,
|
||||
}: NumberedCardsViewProps) {
|
||||
const t = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
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">
|
||||
<div className="grid grid-cols-1 gap-y-[var(--spacing-scale-032)] lg:gap-y-[var(--spacing-scale-056)]">
|
||||
{/* Section Header */}
|
||||
<div>
|
||||
<SectionHeader
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
titleLg={t("numberedCards.titleLg")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Cards Container */}
|
||||
<div className="grid grid-cols-1 gap-y-[var(--spacing-scale-024)] lg:grid-cols-3 lg:gap-[var(--spacing-scale-024)]">
|
||||
{cards.map((card, index) => (
|
||||
<NumberCard
|
||||
key={index}
|
||||
number={index + 1}
|
||||
text={card.text}
|
||||
iconShape={card.iconShape}
|
||||
iconColor={card.iconColor}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Call to Action Button */}
|
||||
<div className="text-center sm:text-left lg:text-center">
|
||||
{/* Filled button for xsm and sm breakpoints */}
|
||||
<div className="block lg:hidden">
|
||||
<Button buttonType="filled" palette="default" size="large">
|
||||
{t("numberedCards.buttons.createCommunityRule")}
|
||||
</Button>
|
||||
</div>
|
||||
{/* Outline button for lg and xlg breakpoints */}
|
||||
<div className="hidden lg:block">
|
||||
<Button buttonType="outline" palette="default" size="large">
|
||||
{t("numberedCards.buttons.seeHowItWorks")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default NumberedCardsView;
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default } from "./NumberedCards.container";
|
||||
export * from "./NumberedCards.types";
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import SectionHeader from "../SectionHeader";
|
||||
import SectionHeader from "../../type/SectionHeader";
|
||||
import Button from "../../buttons/Button";
|
||||
import { GovernanceTemplateGrid } from "../GovernanceTemplateGrid";
|
||||
import { GovernanceTemplateGridSkeleton } from "../GovernanceTemplateGrid/GovernanceTemplateGridSkeleton";
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
|
||||
export type SectionHeaderVariantValue = "default" | "multi-line";
|
||||
|
||||
interface SectionHeaderProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
titleLg?: string;
|
||||
variant?: SectionHeaderVariantValue;
|
||||
}
|
||||
|
||||
const SectionHeader = memo<SectionHeaderProps>(
|
||||
({ title, subtitle, titleLg, variant: variantProp = "default" }) => {
|
||||
const variant = variantProp;
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
variant === "multi-line"
|
||||
? "flex flex-col gap-[var(--spacing-scale-004)] w-full lg:flex-row lg:justify-between lg:items-start xl:gap-[var(--spacing-scale-024)]"
|
||||
: "flex flex-col gap-[var(--spacing-scale-004)] w-full lg:flex-row lg:justify-between lg:items-start xl:gap-[var(--spacing-scale-024)]"
|
||||
}
|
||||
>
|
||||
{/* Title Container - Left side (lg breakpoint) */}
|
||||
<div
|
||||
className={
|
||||
variant === "multi-line"
|
||||
? "lg:w-[50%] lg:h-[var(--spacing-scale-120)] lg:flex lg:items-center xl:w-[50%] xl:h-[156px] xl:flex xl:items-center"
|
||||
: "lg:w-[369px] lg:h-[var(--spacing-scale-120)] lg:flex lg:items-center xl:w-[452px] xl:h-[156px] xl:flex xl:items-center"
|
||||
}
|
||||
>
|
||||
<h2
|
||||
className={
|
||||
variant === "multi-line"
|
||||
? "font-bricolage-grotesque font-bold text-[28px] leading-[36px] md:font-bold md:text-[32px] md:leading-[40px] lg:w-[410px] lg:text-left xl:text-[40px] xl:leading-[52px] text-[var(--color-content-default-primary)]"
|
||||
: "font-bricolage-grotesque font-bold text-[28px] leading-[36px] sm:text-[32px] sm:leading-[40px] lg:text-[32px] lg:leading-[40px] lg:w-[369px] lg:pr-[var(--spacing-scale-096)] xl:text-[40px] xl:leading-[52px] xl:w-[452px] xl:pr-[var(--spacing-scale-096)] text-[var(--color-content-default-primary)]"
|
||||
}
|
||||
>
|
||||
<span className="block lg:hidden">{title}</span>
|
||||
<span className="hidden lg:block">{titleLg || title}</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Subtitle Container */}
|
||||
<div
|
||||
className={
|
||||
variant === "multi-line"
|
||||
? "lg:w-[50%] lg:h-[var(--spacing-scale-120)] lg:flex lg:items-center lg:justify-end lg:ml-[var(--spacing-scale-016)] xl:ml-[0px] xl:w-[50%] xl:h-[156px] xl:flex xl:items-center xl:justify-end"
|
||||
: "lg:w-[928px] lg:h-[var(--spacing-scale-120)] lg:flex lg:items-center lg:justify-end xl:h-[156px] xl:flex xl:items-center xl:justify-end"
|
||||
}
|
||||
>
|
||||
<p
|
||||
className={
|
||||
variant === "multi-line"
|
||||
? "font-inter font-normal text-[14px] leading-[20px] md:font-normal md:text-[18px] md:leading-[130%] xl:text-[24px] xl:leading-[32px] text-[var(--color-content-default-tertiary)]"
|
||||
: "font-inter font-normal text-[18px] leading-[130%] sm:text-[18px] sm:leading-[32px] lg:text-[24px] lg:leading-[32px] xl:text-[32px] xl:leading-[40px] xl:text-right text-[#484848] sm:text-[var(--color-content-default-tertiary)] lg:text-[var(--color-content-default-tertiary)] xl:text-[var(--color-content-default-tertiary)] tracking-[0px]"
|
||||
}
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
SectionHeader.displayName = "SectionHeader";
|
||||
|
||||
export default SectionHeader;
|
||||
@@ -1,163 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useEffect, useState } from "react";
|
||||
import { useMessages } from "../../../contexts/MessagesContext";
|
||||
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(),
|
||||
});
|
||||
|
||||
function reportWebVitalToApi(
|
||||
metric: keyof Vitals,
|
||||
value: number,
|
||||
rating: VitalData["rating"],
|
||||
): void {
|
||||
if (typeof window === "undefined") return;
|
||||
if (rating === "unknown") return;
|
||||
|
||||
const body = {
|
||||
metric,
|
||||
data: { value, rating },
|
||||
url: window.location.href,
|
||||
userAgent: navigator.userAgent,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
void fetch("/api/web-vitals", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}).catch((err: unknown) => {
|
||||
logger.error("Web vitals ingest failed:", err);
|
||||
});
|
||||
}
|
||||
|
||||
const WebVitalsDashboardContainer = memo(() => {
|
||||
const m = useMessages();
|
||||
const copy = m.webVitalsDashboard;
|
||||
const [vitals, setVitals] = useState<Vitals>(createInitialVitals);
|
||||
const [metrics, setMetrics] = useState<Metrics>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [storage, setStorage] = useState<"external" | "local">("local");
|
||||
|
||||
const rumDashboardUrl =
|
||||
typeof process.env.NEXT_PUBLIC_RUM_DASHBOARD_URL === "string" &&
|
||||
process.env.NEXT_PUBLIC_RUM_DASHBOARD_URL.trim() !== ""
|
||||
? process.env.NEXT_PUBLIC_RUM_DASHBOARD_URL.trim()
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchVitals = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/web-vitals");
|
||||
const data = (await response.json()) as {
|
||||
metrics?: Metrics;
|
||||
storage?: "external" | "local";
|
||||
};
|
||||
setMetrics(data.metrics || {});
|
||||
setStorage(data.storage === "external" ? "external" : "local");
|
||||
} catch (error) {
|
||||
logger.error("Error fetching web vitals:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchVitals();
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
// web-vitals v4+ exposes onLCP / onCLS / … — legacy getLCP was removed.
|
||||
import("web-vitals").then(
|
||||
({ onCLS, onFID, onFCP, onLCP, onTTFB }) => {
|
||||
onLCP((metric) => {
|
||||
const rating = metric.rating as VitalData["rating"];
|
||||
setVitals((prev) => ({
|
||||
...prev,
|
||||
lcp: {
|
||||
value: Math.round(metric.value),
|
||||
rating,
|
||||
},
|
||||
}));
|
||||
reportWebVitalToApi("lcp", Math.round(metric.value), rating);
|
||||
});
|
||||
|
||||
onFID((metric) => {
|
||||
const rating = metric.rating as VitalData["rating"];
|
||||
setVitals((prev) => ({
|
||||
...prev,
|
||||
fid: {
|
||||
value: Math.round(metric.value),
|
||||
rating,
|
||||
},
|
||||
}));
|
||||
reportWebVitalToApi("fid", Math.round(metric.value), rating);
|
||||
});
|
||||
|
||||
onCLS((metric) => {
|
||||
const rounded = Math.round(metric.value * 1000) / 1000;
|
||||
const rating = metric.rating as VitalData["rating"];
|
||||
setVitals((prev) => ({
|
||||
...prev,
|
||||
cls: {
|
||||
value: rounded,
|
||||
rating,
|
||||
},
|
||||
}));
|
||||
reportWebVitalToApi("cls", rounded, rating);
|
||||
});
|
||||
|
||||
onFCP((metric) => {
|
||||
const rating = metric.rating as VitalData["rating"];
|
||||
setVitals((prev) => ({
|
||||
...prev,
|
||||
fcp: {
|
||||
value: Math.round(metric.value),
|
||||
rating,
|
||||
},
|
||||
}));
|
||||
reportWebVitalToApi("fcp", Math.round(metric.value), rating);
|
||||
});
|
||||
|
||||
onTTFB((metric) => {
|
||||
const rating = metric.rating as VitalData["rating"];
|
||||
setVitals((prev) => ({
|
||||
...prev,
|
||||
ttfb: {
|
||||
value: Math.round(metric.value),
|
||||
rating,
|
||||
},
|
||||
}));
|
||||
reportWebVitalToApi("ttfb", Math.round(metric.value), rating);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<WebVitalsDashboardView
|
||||
vitals={vitals}
|
||||
metrics={metrics}
|
||||
loading={loading}
|
||||
storage={storage}
|
||||
copy={copy}
|
||||
rumDashboardUrl={rumDashboardUrl}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
WebVitalsDashboardContainer.displayName = "WebVitalsDashboard";
|
||||
|
||||
export default WebVitalsDashboardContainer;
|
||||
@@ -1,40 +0,0 @@
|
||||
import type messages from "../../../../messages/en/index";
|
||||
|
||||
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 type WebVitalsDashboardCopy = typeof messages.webVitalsDashboard;
|
||||
|
||||
export interface WebVitalsDashboardViewProps {
|
||||
vitals: Vitals;
|
||||
metrics: Metrics;
|
||||
loading: boolean;
|
||||
storage: "external" | "local";
|
||||
copy: WebVitalsDashboardCopy;
|
||||
rumDashboardUrl: string | null;
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
import type { WebVitalsDashboardViewProps } from "./WebVitalsDashboard.types";
|
||||
|
||||
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 "❓";
|
||||
}
|
||||
};
|
||||
|
||||
function formatValue(metric: string, value: number): string {
|
||||
if (metric === "cls") {
|
||||
return value.toFixed(3);
|
||||
}
|
||||
return `${value}ms`;
|
||||
}
|
||||
|
||||
function WebVitalsDashboardView({
|
||||
vitals,
|
||||
metrics,
|
||||
loading,
|
||||
storage,
|
||||
copy,
|
||||
rumDashboardUrl,
|
||||
}: WebVitalsDashboardViewProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6 bg-white rounded-lg shadow-lg">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-6 bg-gray-200 rounded w-1/3 mb-4"></div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="p-4 border rounded-lg">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/2 mb-2"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-3/4"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-white rounded-lg shadow-lg">
|
||||
<h2 className="text-2xl font-bold mb-6 text-[var(--color-content-default-primary)]">
|
||||
{copy.title}
|
||||
</h2>
|
||||
|
||||
{storage === "external" && (
|
||||
<div
|
||||
className="mb-6 p-4 rounded-lg border border-[var(--color-border-default-primary)] bg-[var(--color-surface-default-secondary)] text-[var(--font-size-body-medium)] text-[var(--color-content-default-secondary)]"
|
||||
role="status"
|
||||
>
|
||||
<p className="font-semibold text-[var(--color-content-default-primary)] mb-2">
|
||||
{copy.externalNoticeTitle}
|
||||
</p>
|
||||
<p className="mb-3">{copy.externalNoticeBody}</p>
|
||||
{rumDashboardUrl ? (
|
||||
<a
|
||||
href={rumDashboardUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[var(--color-content-default-primary)] underline font-medium"
|
||||
>
|
||||
{copy.externalDashboardLinkLabel}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
|
||||
{Object.entries(vitals).map(([metric, data]) => (
|
||||
<div
|
||||
key={metric}
|
||||
className={`p-4 border rounded-lg ${getRatingColor(data.rating)}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="font-semibold text-lg">{metric.toUpperCase()}</h3>
|
||||
<span className="text-2xl">{getRatingIcon(data.rating)}</span>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">
|
||||
{copy.valueLabel}: {formatValue(metric, data.value)}
|
||||
</div>
|
||||
<div className="capitalize">
|
||||
{copy.ratingLabel}: {data.rating.replace("-", " ")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{Object.keys(metrics).length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold mb-4 text-[var(--color-content-default-primary)]">
|
||||
{copy.historicalMetricsTitle}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Object.entries(metrics).map(([metric, data]) => (
|
||||
<div
|
||||
key={metric}
|
||||
className="p-4 border rounded-lg bg-[var(--color-surface-default-secondary)]"
|
||||
>
|
||||
<h4 className="font-semibold mb-2">{metric.toUpperCase()}</h4>
|
||||
<div className="text-sm space-y-1">
|
||||
<div>
|
||||
{copy.countLabel}: {data.count}
|
||||
</div>
|
||||
<div>
|
||||
{copy.averageLabel}: {formatValue(metric, data.average)}
|
||||
</div>
|
||||
<div>
|
||||
{copy.rangeLabel}: {formatValue(metric, data.min)} -{" "}
|
||||
{formatValue(metric, data.max)}
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs">
|
||||
<span className="text-green-600">
|
||||
{copy.goodLabel}: {data.goodCount}
|
||||
</span>
|
||||
<span className="text-yellow-600">
|
||||
{copy.needsImprovementLabel}: {data.needsImprovementCount}
|
||||
</span>
|
||||
<span className="text-red-600">
|
||||
{copy.poorLabel}: {data.poorCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4 bg-[var(--color-surface-default-secondary)] rounded-lg">
|
||||
<h3 className="font-semibold mb-2 text-[var(--color-content-default-primary)]">
|
||||
{copy.performanceGuidelinesTitle}
|
||||
</h3>
|
||||
<ul className="text-sm space-y-1 text-[var(--color-content-default-secondary)]">
|
||||
<li>• {copy.guidelines.lcp}</li>
|
||||
<li>• {copy.guidelines.fid}</li>
|
||||
<li>• {copy.guidelines.cls}</li>
|
||||
<li>• {copy.guidelines.fcp}</li>
|
||||
<li>• {copy.guidelines.ttfb}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WebVitalsDashboardView;
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default } from "./WebVitalsDashboard.container";
|
||||
export * from "./WebVitalsDashboard.types";
|
||||
Reference in New Issue
Block a user