Convert from JSX to TSX
CI Pipeline / test (20) (pull_request) Failing after 1m17s
CI Pipeline / test (18) (pull_request) Failing after 1m28s
CI Pipeline / e2e (chromium) (pull_request) Failing after 1m33s
CI Pipeline / e2e (firefox) (pull_request) Failing after 1m27s
CI Pipeline / e2e (webkit) (pull_request) Failing after 1m34s
CI Pipeline / visual-regression (pull_request) Failing after 2m9s
CI Pipeline / storybook (pull_request) Failing after 1m5s
CI Pipeline / performance (pull_request) Failing after 1m42s
CI Pipeline / lint (pull_request) Failing after 49s
CI Pipeline / build (pull_request) Failing after 1m29s
CI Pipeline / test (20) (pull_request) Failing after 1m17s
CI Pipeline / test (18) (pull_request) Failing after 1m28s
CI Pipeline / e2e (chromium) (pull_request) Failing after 1m33s
CI Pipeline / e2e (firefox) (pull_request) Failing after 1m27s
CI Pipeline / e2e (webkit) (pull_request) Failing after 1m34s
CI Pipeline / visual-regression (pull_request) Failing after 2m9s
CI Pipeline / storybook (pull_request) Failing after 1m5s
CI Pipeline / performance (pull_request) Failing after 1m42s
CI Pipeline / lint (pull_request) Failing after 49s
CI Pipeline / build (pull_request) Failing after 1m29s
This commit is contained in:
@@ -1,20 +1,52 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const WEB_VITALS_DIR = path.join(process.cwd(), ".next", "web-vitals");
|
||||
|
||||
interface WebVitalData {
|
||||
metric: string;
|
||||
data: {
|
||||
value: number;
|
||||
rating: string;
|
||||
};
|
||||
url: string;
|
||||
userAgent: string;
|
||||
timestamp: string;
|
||||
receivedAt: string;
|
||||
}
|
||||
|
||||
interface WebVitalMetrics {
|
||||
[metric: string]: {
|
||||
count: number;
|
||||
average: number;
|
||||
min: number;
|
||||
max: number;
|
||||
goodCount: number;
|
||||
needsImprovementCount: number;
|
||||
poorCount: number;
|
||||
lastUpdated: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Ensure web-vitals directory exists
|
||||
if (!fs.existsSync(WEB_VITALS_DIR)) {
|
||||
fs.mkdirSync(WEB_VITALS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
export async function POST(request) {
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { metric, data, url, userAgent, timestamp } = await request.json();
|
||||
const body = await request.json();
|
||||
const { metric, data, url, userAgent, timestamp } = body as {
|
||||
metric: string;
|
||||
data: { value: number; rating: string };
|
||||
url: string;
|
||||
userAgent: string;
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
// Store the metric data
|
||||
const vitalsData = {
|
||||
const vitalsData: WebVitalData = {
|
||||
metric,
|
||||
data,
|
||||
url,
|
||||
@@ -25,13 +57,15 @@ export async function POST(request) {
|
||||
|
||||
// Save to file (in production, you would save to a database)
|
||||
const filePath = path.join(WEB_VITALS_DIR, `${metric}.json`);
|
||||
let existingData = [];
|
||||
let existingData: WebVitalData[] = [];
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
existingData = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
const fileContent = fs.readFileSync(filePath, "utf8");
|
||||
existingData = JSON.parse(fileContent) as WebVitalData[];
|
||||
} catch (error) {
|
||||
console.warn("Could not parse existing vitals data:", error.message);
|
||||
const err = error as Error;
|
||||
console.warn("Could not parse existing vitals data:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +95,7 @@ export async function POST(request) {
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const metrics = {};
|
||||
const metrics: WebVitalMetrics = {};
|
||||
|
||||
if (fs.existsSync(WEB_VITALS_DIR)) {
|
||||
const files = fs.readdirSync(WEB_VITALS_DIR);
|
||||
@@ -69,9 +103,11 @@ export async function GET() {
|
||||
files.forEach((file) => {
|
||||
if (file.endsWith(".json")) {
|
||||
const metric = file.replace(".json", "");
|
||||
const data = JSON.parse(
|
||||
fs.readFileSync(path.join(WEB_VITALS_DIR, file), "utf8"),
|
||||
const fileContent = fs.readFileSync(
|
||||
path.join(WEB_VITALS_DIR, file),
|
||||
"utf8",
|
||||
);
|
||||
const data = JSON.parse(fileContent) as WebVitalData[];
|
||||
|
||||
if (data.length > 0) {
|
||||
const values = data
|
||||
@@ -96,7 +132,7 @@ export async function GET() {
|
||||
(r) => r === "needs-improvement",
|
||||
).length,
|
||||
poorCount: ratings.filter((r) => r === "poor").length,
|
||||
lastUpdated: data[data.length - 1]?.receivedAt,
|
||||
lastUpdated: data[data.length - 1]?.receivedAt || "",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import {
|
||||
getBlogPostBySlug,
|
||||
getAllBlogPosts as getAllPosts,
|
||||
type BlogPost,
|
||||
} from "../../../lib/content";
|
||||
import ContentBanner from "../../components/ContentBanner";
|
||||
import RelatedArticles from "../../components/RelatedArticles";
|
||||
@@ -17,6 +18,10 @@ const askOrganizerData = {
|
||||
buttonHref: "#contact",
|
||||
};
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate static params for all blog posts
|
||||
* This enables static generation for all blog posts at build time
|
||||
@@ -36,7 +41,9 @@ export async function generateStaticParams() {
|
||||
/**
|
||||
* Generate metadata for each blog post
|
||||
*/
|
||||
export async function generateMetadata({ params }) {
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: PageProps): Promise<Metadata> {
|
||||
try {
|
||||
const { slug } = await params;
|
||||
const post = getBlogPostBySlug(slug);
|
||||
@@ -80,7 +87,7 @@ export async function generateMetadata({ params }) {
|
||||
/**
|
||||
* Dynamic blog post page
|
||||
*/
|
||||
export default async function BlogPostPage({ params }) {
|
||||
export default async function BlogPostPage({ params }: PageProps) {
|
||||
// Get the blog post data
|
||||
const { slug } = await params;
|
||||
const post = getBlogPostBySlug(slug);
|
||||
@@ -97,7 +104,11 @@ export default async function BlogPostPage({ params }) {
|
||||
const slugOrder = allPosts.map((post) => post.slug);
|
||||
|
||||
// Simple related articles algorithm based on content similarity
|
||||
const getRelatedArticles = (currentPost, allPosts, limit = 3) => {
|
||||
const getRelatedArticles = (
|
||||
currentPost: BlogPost,
|
||||
allPosts: BlogPost[],
|
||||
limit = 3,
|
||||
): BlogPost[] => {
|
||||
const otherPosts = allPosts.filter((p) => p.slug !== currentPost.slug);
|
||||
|
||||
// Score posts based on content similarity
|
||||
@@ -202,7 +213,7 @@ export default async function BlogPostPage({ params }) {
|
||||
};
|
||||
|
||||
// Get article-specific background color from frontmatter
|
||||
const getBackgroundColor = (post) => {
|
||||
const getBackgroundColor = (post: BlogPost): string => {
|
||||
if (post.frontmatter?.background?.color) {
|
||||
return post.frontmatter.background.color;
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { getAllBlogPosts } from "../../lib/content";
|
||||
import ContentThumbnailTemplate from "../components/ContentThumbnailTemplate";
|
||||
import ContentContainer from "../components/ContentContainer";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata = {
|
||||
export const metadata: Metadata = {
|
||||
title: "Blog - CommunityRule",
|
||||
description:
|
||||
"Learn about community governance, decision-making, and building successful organizations.",
|
||||
@@ -4,7 +4,35 @@ import React, { memo } from "react";
|
||||
import ContentLockup from "./ContentLockup";
|
||||
import Button from "./Button";
|
||||
|
||||
const AskOrganizer = memo(
|
||||
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;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
gtag?: (
|
||||
command: string,
|
||||
eventName: string,
|
||||
params?: Record<string, unknown>
|
||||
) => void;
|
||||
}
|
||||
}
|
||||
|
||||
const AskOrganizer = memo<AskOrganizerProps>(
|
||||
({
|
||||
title,
|
||||
subtitle,
|
||||
@@ -12,11 +40,13 @@ const AskOrganizer = memo(
|
||||
buttonText = "Ask an organizer",
|
||||
buttonHref = "#",
|
||||
className = "",
|
||||
variant = "centered", // centered, left-aligned, compact
|
||||
onContactClick, // Analytics callback
|
||||
variant = "centered",
|
||||
onContactClick,
|
||||
}) => {
|
||||
// Analytics tracking for contact button clicks
|
||||
const handleContactClick = (event) => {
|
||||
const handleContactClick = (
|
||||
event: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>
|
||||
) => {
|
||||
// Track contact button interaction
|
||||
if (onContactClick) {
|
||||
onContactClick({
|
||||
@@ -40,7 +70,10 @@ const AskOrganizer = memo(
|
||||
};
|
||||
|
||||
// Variant-specific styling
|
||||
const variantStyles = {
|
||||
const variantStyles: Record<
|
||||
string,
|
||||
{ container: string; buttonContainer: string }
|
||||
> = {
|
||||
centered: {
|
||||
container: "text-center",
|
||||
buttonContainer: "flex justify-center",
|
||||
@@ -98,7 +131,7 @@ const AskOrganizer = memo(
|
||||
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}
|
||||
aria-label={`${buttonText} - Contact an organizer for help`}
|
||||
ariaLabel={`${buttonText} - Contact an organizer for help`}
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
@@ -106,7 +139,7 @@ const AskOrganizer = memo(
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
AskOrganizer.displayName = "AskOrganizer";
|
||||
@@ -1,8 +1,15 @@
|
||||
import React, { memo } from "react";
|
||||
|
||||
const Avatar = memo(
|
||||
interface AvatarProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
src: string;
|
||||
alt: string;
|
||||
size?: "small" | "medium" | "large" | "xlarge";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Avatar = memo<AvatarProps>(
|
||||
({ src, alt, size = "small", className = "", ...props }) => {
|
||||
const sizeStyles = {
|
||||
const sizeStyles: Record<string, string> = {
|
||||
small: "w-[var(--spacing-scale-016)] h-[var(--spacing-scale-016)]",
|
||||
medium: "w-[18px] h-[18px]",
|
||||
large: "w-[var(--spacing-scale-024)] h-[var(--spacing-scale-024)]",
|
||||
@@ -12,7 +19,7 @@ const Avatar = memo(
|
||||
const baseStyles = `rounded-[var(--radius-measures-radius-full)] object-cover ${sizeStyles[size]} ${className}`;
|
||||
|
||||
return <img src={src} alt={alt} className={baseStyles} {...props} />;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
Avatar.displayName = "Avatar";
|
||||
@@ -1,8 +1,14 @@
|
||||
import React, { memo } from "react";
|
||||
|
||||
const AvatarContainer = memo(
|
||||
interface AvatarContainerProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children?: React.ReactNode;
|
||||
size?: "small" | "medium" | "large" | "xlarge";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const AvatarContainer = memo<AvatarContainerProps>(
|
||||
({ children, size = "small", className = "", ...props }) => {
|
||||
const sizeStyles = {
|
||||
const sizeStyles: Record<string, string> = {
|
||||
small: "flex -space-x-[var(--spacing-scale-008)]",
|
||||
medium: "flex -space-x-[9px]",
|
||||
large: "flex -space-x-[var(--spacing-scale-010)]",
|
||||
@@ -16,7 +22,7 @@ const AvatarContainer = memo(
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
AvatarContainer.displayName = "AvatarContainer";
|
||||
@@ -1,6 +1,20 @@
|
||||
import React, { memo } from "react";
|
||||
|
||||
const Button = memo(
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
children: React.ReactNode;
|
||||
variant?: "default" | "secondary" | "primary" | "outlined" | "dark" | "inverse";
|
||||
size?: "xsmall" | "small" | "medium" | "large" | "xlarge";
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
type?: "button" | "submit" | "reset";
|
||||
onClick?: (e: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => void;
|
||||
href?: string;
|
||||
target?: string;
|
||||
rel?: string;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
const Button = memo<ButtonProps>(
|
||||
({
|
||||
children,
|
||||
variant = "default",
|
||||
@@ -15,7 +29,7 @@ const Button = memo(
|
||||
ariaLabel,
|
||||
...props
|
||||
}) => {
|
||||
const sizeStyles = {
|
||||
const sizeStyles: Record<string, string> = {
|
||||
xsmall:
|
||||
"px-[var(--spacing-scale-006)] py-[var(--spacing-scale-004)] gap-[var(--spacing-scale-001)]",
|
||||
small:
|
||||
@@ -27,7 +41,7 @@ const Button = memo(
|
||||
"px-[var(--spacing-scale-020)] py-[var(--spacing-scale-012)] gap-[var(--spacing-scale-008)]",
|
||||
};
|
||||
|
||||
const fontStyles = {
|
||||
const fontStyles: Record<string, string> = {
|
||||
xsmall: "font-inter text-[10px] leading-[12px] font-medium tracking-[0%]",
|
||||
small: "font-inter text-[12px] leading-[14px] font-medium tracking-[0%]",
|
||||
medium: "font-inter text-[14px] leading-[16px] font-medium tracking-[0%]",
|
||||
@@ -35,7 +49,7 @@ const Button = memo(
|
||||
xlarge: "font-inter text-[24px] leading-[28px] font-normal tracking-[0%]",
|
||||
};
|
||||
|
||||
const variantStyles = {
|
||||
const variantStyles: Record<string, string> = {
|
||||
default:
|
||||
"bg-[var(--color-surface-inverse-primary)] text-[var(--color-content-inverse-primary)] hover:bg-[var(--color-surface-inverse-primary)] hover:text-[var(--color-content-inverse-brand-primary)] hover:outline-[var(--border-color-default-brandprimary)] hover:outline-inset hover:scale-[1.02] hover:shadow-lg focus:shadow-[0_0_10px_1px_var(--color-surface-default-brand-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-content-default-brand-primary)] focus:ring-offset-1 focus:scale-[1.02] active:bg-[var(--color-surface-inverse-brand-primary)] active:text-[var(--color-content-inverse-primary)] active:outline-[var(--border-color-default-brandprimary)] active:outline-offset-1 active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-default-secondary)] disabled:text-[var(--color-content-inverse-tertiary)] disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100 disabled:hover:shadow-none disabled:hover:outline-none",
|
||||
secondary:
|
||||
@@ -49,7 +63,7 @@ const Button = memo(
|
||||
"bg-transparent text-[var(--color-content-inverse-primary)] hover:text-[var(--color-content-inverse-brand-primary)] hover:scale-[1.02] hover:bg-transparent hover:outline-none focus:outline-1 focus:outline-inset focus:outline-[var(--border-color-default-tertiary)] focus:shadow-[0_0_10px_1px_var(--color-surface-default-tertiary)] focus:blur-[0px] active:bg-[var(--color-surface-default-brand-primary)] active:text-[var(--color-content-inverse-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-inverse-secondary)] disabled:text-[var(--color-content-default-primary)] disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
|
||||
};
|
||||
|
||||
const hoverOutlineStyles = {
|
||||
const hoverOutlineStyles: Record<string, string> = {
|
||||
xsmall: "hover:outline-1",
|
||||
small: "hover:outline-1",
|
||||
medium: "hover:outline-1",
|
||||
@@ -2,12 +2,30 @@
|
||||
|
||||
import React, { memo, useId } from "react";
|
||||
|
||||
interface CheckboxProps {
|
||||
checked?: boolean;
|
||||
mode?: "standard" | "inverse";
|
||||
state?: "default" | "hover" | "focus";
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
className?: string;
|
||||
onChange?: (data: {
|
||||
checked: boolean;
|
||||
value?: string;
|
||||
event: React.MouseEvent | React.KeyboardEvent;
|
||||
}) => void;
|
||||
id?: string;
|
||||
name?: string;
|
||||
value?: string;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkbox
|
||||
* A basic controlled checkbox with visual modes and interaction states.
|
||||
* This is a minimal first pass; visuals will be refined collaboratively.
|
||||
*/
|
||||
const Checkbox = memo(
|
||||
const Checkbox = memo<CheckboxProps>(
|
||||
({
|
||||
checked = false,
|
||||
mode = "standard", // "standard" | "inverse"
|
||||
@@ -38,7 +56,7 @@ const Checkbox = memo(
|
||||
// Visual container depending on state
|
||||
const baseBox = `flex items-center justify-center shrink-0 w-[var(--measures-sizing-024)] h-[var(--measures-sizing-024)] rounded-[var(--measures-radius-medium)] transition-all duration-200 ease-in-out`;
|
||||
|
||||
const stateStyles = {
|
||||
const stateStyles: Record<string, string> = {
|
||||
default: "",
|
||||
hover: "",
|
||||
focus: "",
|
||||
@@ -73,7 +91,7 @@ const Checkbox = memo(
|
||||
const conditionalFocusClass =
|
||||
"focus:outline focus:outline-1 focus:outline-[var(--color-border-default-utility-info)] focus:shadow-[0_0_10px_1px_var(--color-surface-inverse-brand-primary)]";
|
||||
|
||||
const handleToggle = (e) => {
|
||||
const handleToggle = (e: React.MouseEvent | React.KeyboardEvent) => {
|
||||
if (disabled) return;
|
||||
onChange?.({
|
||||
checked: !checked,
|
||||
@@ -87,7 +105,7 @@ const Checkbox = memo(
|
||||
const checkboxId = id || `checkbox-${generatedId}`;
|
||||
|
||||
const accessibilityProps = {
|
||||
role: "checkbox",
|
||||
role: "checkbox" as const,
|
||||
"aria-checked": checked ? "true" : "false",
|
||||
...(disabled && { "aria-disabled": "true", tabIndex: -1 }),
|
||||
...(!disabled && { tabIndex: 0 }),
|
||||
@@ -160,7 +178,7 @@ const Checkbox = memo(
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
Checkbox.displayName = "Checkbox";
|
||||
@@ -3,10 +3,15 @@
|
||||
import React, { memo } from "react";
|
||||
import { getAssetPath } from "../../lib/assetUtils";
|
||||
import ContentContainer from "./ContentContainer";
|
||||
import type { BlogPost } from "../../lib/content";
|
||||
|
||||
const ContentBanner = memo(({ post }) => {
|
||||
interface ContentBannerProps {
|
||||
post: BlogPost;
|
||||
}
|
||||
|
||||
const ContentBanner = memo<ContentBannerProps>(({ post }) => {
|
||||
// Get article-specific horizontal thumbnail (small) and banner (md+)
|
||||
const getBackgroundImage = (post) => {
|
||||
const getBackgroundImage = (post: BlogPost): string => {
|
||||
if (post.frontmatter?.thumbnail?.horizontal) {
|
||||
return `/content/blog/${post.frontmatter.thumbnail.horizontal}`;
|
||||
}
|
||||
@@ -14,7 +19,7 @@ const ContentBanner = memo(({ post }) => {
|
||||
return getAssetPath("assets/Content_Banner.svg");
|
||||
};
|
||||
|
||||
const getBannerImageMd = (post) => {
|
||||
const getBannerImageMd = (post: BlogPost): string => {
|
||||
// Use banner.horizontal when provided; fallback to horizontal thumbnail
|
||||
if (post.frontmatter?.banner?.horizontal) {
|
||||
return `/content/blog/${post.frontmatter.banner.horizontal}`;
|
||||
@@ -2,11 +2,18 @@
|
||||
|
||||
import React, { memo } from "react";
|
||||
import { getAssetPath, ASSETS } from "../../lib/assetUtils";
|
||||
import type { BlogPost } from "../../lib/content";
|
||||
|
||||
const ContentContainer = memo(
|
||||
interface ContentContainerProps {
|
||||
post: BlogPost;
|
||||
width?: string;
|
||||
size?: "xs" | "responsive";
|
||||
}
|
||||
|
||||
const ContentContainer = memo<ContentContainerProps>(
|
||||
({ post, width = "200px", size = "responsive" }) => {
|
||||
// Get the corresponding icon based on the same logic as background images
|
||||
const getIconImage = (slug) => {
|
||||
const getIconImage = (slug: string): string => {
|
||||
const icons = [
|
||||
getAssetPath(ASSETS.ICON_1),
|
||||
getAssetPath(ASSETS.ICON_2),
|
||||
@@ -123,7 +130,7 @@ const ContentContainer = memo(
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
ContentContainer.displayName = "ContentContainer";
|
||||
@@ -4,7 +4,31 @@ import React, { memo } from "react";
|
||||
import Button from "./Button";
|
||||
import { getAssetPath } from "../../lib/assetUtils";
|
||||
|
||||
const ContentLockup = memo(
|
||||
interface ContentLockupProps {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
description?: string;
|
||||
ctaText?: string;
|
||||
ctaHref?: string;
|
||||
buttonClassName?: string;
|
||||
variant?: "hero" | "feature" | "learn" | "ask" | "ask-inverse";
|
||||
linkText?: string;
|
||||
linkHref?: string;
|
||||
alignment?: "center" | "left";
|
||||
}
|
||||
|
||||
interface VariantStyle {
|
||||
container: string;
|
||||
textContainer: string;
|
||||
titleGroup: string;
|
||||
titleContainer: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
description?: string;
|
||||
shape: string;
|
||||
}
|
||||
|
||||
const ContentLockup = memo<ContentLockupProps>(
|
||||
({
|
||||
title,
|
||||
subtitle,
|
||||
@@ -15,10 +39,10 @@ const ContentLockup = memo(
|
||||
variant = "hero",
|
||||
linkText,
|
||||
linkHref,
|
||||
alignment = "center", // center, left
|
||||
alignment = "center",
|
||||
}) => {
|
||||
// Variant-specific styling
|
||||
const variantStyles = {
|
||||
const variantStyles: Record<string, VariantStyle> = {
|
||||
hero: {
|
||||
container:
|
||||
"flex flex-col gap-[var(--spacing-scale-006)] sm:gap-[var(--spacing-scale-012)] md:gap-[var(--spacing-scale-020)] lg:gap-[var(--spacing-scale-020)] relative z-10",
|
||||
@@ -179,7 +203,7 @@ const ContentLockup = memo(
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
ContentLockup.displayName = "ContentLockup";
|
||||
+16
-9
@@ -4,19 +4,26 @@ import React, { memo } from "react";
|
||||
import Link from "next/link";
|
||||
import ContentContainer from "./ContentContainer";
|
||||
import { getAssetPath, ASSETS } from "../../lib/assetUtils";
|
||||
import type { BlogPost } from "../../lib/content";
|
||||
|
||||
/**
|
||||
* ContentThumbnailTemplate component for displaying blog post previews
|
||||
* Simplified version to debug infinite loop
|
||||
*/
|
||||
const ContentThumbnailTemplate = memo(
|
||||
({
|
||||
post,
|
||||
className = "",
|
||||
variant = "vertical", // Internal prop for testing/development
|
||||
}) => {
|
||||
interface ContentThumbnailTemplateProps {
|
||||
post: BlogPost;
|
||||
className?: string;
|
||||
variant?: "vertical" | "horizontal";
|
||||
slugOrder?: string[];
|
||||
}
|
||||
|
||||
const ContentThumbnailTemplate = memo<ContentThumbnailTemplateProps>(
|
||||
({ post, className = "", variant = "vertical", slugOrder }) => {
|
||||
// Get article-specific background image from frontmatter
|
||||
const getBackgroundImage = (post, variant) => {
|
||||
const getBackgroundImage = (
|
||||
post: BlogPost,
|
||||
variant: "vertical" | "horizontal"
|
||||
): string => {
|
||||
// Check if post has thumbnail images defined in frontmatter
|
||||
if (post.frontmatter?.thumbnail) {
|
||||
const imageName =
|
||||
@@ -31,7 +38,7 @@ const ContentThumbnailTemplate = memo(
|
||||
}
|
||||
|
||||
// Fallback to default images if no thumbnail specified
|
||||
const fallbackImages = {
|
||||
const fallbackImages: Record<string, string> = {
|
||||
vertical: getAssetPath(ASSETS.VERTICAL_1),
|
||||
horizontal: getAssetPath(ASSETS.HORIZONTAL_1),
|
||||
};
|
||||
@@ -91,7 +98,7 @@ const ContentThumbnailTemplate = memo(
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
ContentThumbnailTemplate.displayName = "ContentThumbnailTemplate";
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
import React, { forwardRef, memo } from "react";
|
||||
|
||||
const ContextMenu = forwardRef(
|
||||
interface ContextMenuProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
|
||||
({ className = "", children, ...props }, ref) => {
|
||||
const menuClasses = `
|
||||
bg-black
|
||||
@@ -28,7 +33,7 @@ const ContextMenu = forwardRef(
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
ContextMenu.displayName = "ContextMenu";
|
||||
@@ -1,21 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { forwardRef, memo } from "react";
|
||||
|
||||
const ContextMenuDivider = forwardRef(({ className = "", ...props }, ref) => {
|
||||
const dividerClasses = `
|
||||
border-t border-[var(--color-border-default-tertiary)]
|
||||
my-1
|
||||
${className}
|
||||
`
|
||||
.trim()
|
||||
.replace(/\s+/g, " ");
|
||||
|
||||
return (
|
||||
<div ref={ref} className={dividerClasses} role="separator" {...props} />
|
||||
);
|
||||
});
|
||||
|
||||
ContextMenuDivider.displayName = "ContextMenuDivider";
|
||||
|
||||
export default memo(ContextMenuDivider);
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import React, { forwardRef, memo } from "react";
|
||||
|
||||
interface ContextMenuDividerProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ContextMenuDivider = forwardRef<HTMLDivElement, ContextMenuDividerProps>(
|
||||
({ className = "", ...props }, ref) => {
|
||||
const dividerClasses = `
|
||||
border-t border-[var(--color-border-default-tertiary)]
|
||||
my-1
|
||||
${className}
|
||||
`
|
||||
.trim()
|
||||
.replace(/\s+/g, " ");
|
||||
|
||||
return (
|
||||
<div ref={ref} className={dividerClasses} role="separator" {...props} />
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ContextMenuDivider.displayName = "ContextMenuDivider";
|
||||
|
||||
export default memo(ContextMenuDivider);
|
||||
@@ -2,7 +2,19 @@
|
||||
|
||||
import React, { forwardRef, memo, useCallback } from "react";
|
||||
|
||||
const ContextMenuItem = forwardRef(
|
||||
interface ContextMenuItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children?: React.ReactNode;
|
||||
selected?: boolean;
|
||||
hasSubmenu?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
onClick?: (
|
||||
e: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>
|
||||
) => void;
|
||||
size?: "small" | "medium" | "large";
|
||||
}
|
||||
|
||||
const ContextMenuItem = forwardRef<HTMLDivElement, ContextMenuItemProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
@@ -14,9 +26,9 @@ const ContextMenuItem = forwardRef(
|
||||
size = "medium",
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
ref
|
||||
) => {
|
||||
const getTextSize = () => {
|
||||
const getTextSize = (): string => {
|
||||
switch (size) {
|
||||
case "small":
|
||||
return "text-[10px] leading-[14px]";
|
||||
@@ -52,16 +64,16 @@ const ContextMenuItem = forwardRef(
|
||||
.replace(/\s+/g, " ");
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e) => {
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!disabled && onClick) {
|
||||
onClick(e);
|
||||
}
|
||||
},
|
||||
[disabled, onClick],
|
||||
[disabled, onClick]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e) => {
|
||||
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
if (!disabled && onClick) {
|
||||
@@ -69,7 +81,7 @@ const ContextMenuItem = forwardRef(
|
||||
}
|
||||
}
|
||||
},
|
||||
[disabled, onClick],
|
||||
[disabled, onClick]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -119,7 +131,7 @@ const ContextMenuItem = forwardRef(
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
ContextMenuItem.displayName = "ContextMenuItem";
|
||||
@@ -2,7 +2,13 @@
|
||||
|
||||
import React, { forwardRef, memo } from "react";
|
||||
|
||||
const ContextMenuSection = forwardRef(
|
||||
interface ContextMenuSectionProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
title?: string;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ContextMenuSection = forwardRef<HTMLDivElement, ContextMenuSectionProps>(
|
||||
({ title, children, className = "", ...props }, ref) => {
|
||||
const sectionClasses = `
|
||||
${className}
|
||||
@@ -22,7 +28,7 @@ const ContextMenuSection = forwardRef(
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
ContextMenuSection.displayName = "ContextMenuSection";
|
||||
@@ -1,19 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import React, { Component } from "react";
|
||||
import React, { Component, type ReactNode } from "react";
|
||||
|
||||
class ErrorBoundary extends Component {
|
||||
constructor(props) {
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
// Update state so the next render will show the fallback UI
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
// Log the error to an error reporting service
|
||||
console.error("ErrorBoundary caught an error:", error, errorInfo);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { memo, useMemo } from "react";
|
||||
import ContentLockup from "./ContentLockup";
|
||||
import MiniCard from "./MiniCard";
|
||||
import Image from "next/image";
|
||||
|
||||
const FeatureGrid = memo(({ title, subtitle, className = "" }) => {
|
||||
// Memoize the feature data to prevent unnecessary re-renders
|
||||
const features = useMemo(
|
||||
() => [
|
||||
{
|
||||
backgroundColor: "bg-[var(--color-surface-default-brand-royal)]",
|
||||
labelLine1: "Decision-making",
|
||||
labelLine2: "support",
|
||||
panelContent: "/assets/Feature_Support.png",
|
||||
ariaLabel: "Decision-making support tools",
|
||||
href: "#decision-making",
|
||||
},
|
||||
{
|
||||
backgroundColor: "bg-[#D1FFE2]",
|
||||
labelLine1: "Values alignment",
|
||||
labelLine2: "exercises",
|
||||
panelContent: "/assets/Feature_Exercises.png",
|
||||
ariaLabel: "Values alignment exercises",
|
||||
href: "#values-alignment",
|
||||
},
|
||||
{
|
||||
backgroundColor: "bg-[#F4CAFF]",
|
||||
labelLine1: "Membership",
|
||||
labelLine2: "guidance",
|
||||
panelContent: "/assets/Feature_Guidance.png",
|
||||
ariaLabel: "Membership guidance resources",
|
||||
href: "#membership-guidance",
|
||||
},
|
||||
{
|
||||
backgroundColor: "bg-[#CBDDFF]",
|
||||
labelLine1: "Conflict resolution",
|
||||
labelLine2: "tools",
|
||||
panelContent: "/assets/Feature_Tools.png",
|
||||
ariaLabel: "Conflict resolution tools",
|
||||
href: "#conflict-resolution",
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<section
|
||||
className={`p-0 lg:p-[var(--spacing-scale-064)] ${className}`}
|
||||
aria-labelledby="feature-grid-headline"
|
||||
role="region"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<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="#"
|
||||
/>
|
||||
</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"
|
||||
role="grid"
|
||||
aria-label="Feature tools and services"
|
||||
>
|
||||
{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,100 @@
|
||||
"use client";
|
||||
|
||||
import React, { 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",
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
return (
|
||||
<section
|
||||
className={`p-0 lg:p-[var(--spacing-scale-064)] ${className}`}
|
||||
aria-labelledby="feature-grid-headline"
|
||||
role="region"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<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="#"
|
||||
/>
|
||||
</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"
|
||||
role="grid"
|
||||
aria-label="Feature tools and services"
|
||||
>
|
||||
{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;
|
||||
@@ -101,56 +101,29 @@ const Footer = memo(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Section */}
|
||||
<div className="flex flex-col items-start gap-[var(--spacing-measures-spacing-032,32px)] order-1 sm:order-2 sm:items-end">
|
||||
{/* Links Section */}
|
||||
<div className="flex flex-col items-start gap-[var(--spacing-measures-spacing-016,16px)] order-1 sm:order-2">
|
||||
<a
|
||||
href="#"
|
||||
className="text-[var(--color-content-default-primary)] font-inter text-base leading-5 font-medium tracking-[0%] lg:text-2xl lg:leading-7 lg:font-normal hover:opacity-80 active:opacity-60 focus:opacity-80 focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-primary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-primary)] transition-opacity p-2 -m-2 cursor-pointer"
|
||||
>
|
||||
Use cases
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
href="/learn"
|
||||
className="text-[var(--color-content-default-primary)] font-inter text-base leading-5 font-medium tracking-[0%] lg:text-2xl lg:leading-7 lg:font-normal hover:opacity-80 active:opacity-60 focus:opacity-80 focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-primary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-primary)] transition-opacity p-2 -m-2 cursor-pointer"
|
||||
>
|
||||
Learn
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
href="/blog"
|
||||
className="text-[var(--color-content-default-primary)] font-inter text-base leading-5 font-medium tracking-[0%] lg:text-2xl lg:leading-7 lg:font-normal hover:opacity-80 active:opacity-60 focus:opacity-80 focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-primary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-primary)] transition-opacity p-2 -m-2 cursor-pointer"
|
||||
>
|
||||
About
|
||||
Blog
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Bottom section */}
|
||||
<div className="flex flex-col items-start gap-[var(--spacing-measures-spacing-032,32px)] sm:flex-row sm:justify-between sm:items-center w-full">
|
||||
<div className="text-[var(--color-content-default-secondary)] font-inter text-sm leading-5 font-medium sm:text-xs sm:leading-4 lg:text-sm lg:leading-5 lg:font-normal">
|
||||
© All right reserved
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-[var(--spacing-measures-spacing-040,40px)] sm:flex-row sm:gap-[var(--spacing-measures-spacing-040,40px)]">
|
||||
<a
|
||||
href="#"
|
||||
className="text-[var(--color-content-default-secondary)] font-inter text-sm leading-5 font-medium underline sm:text-xs sm:leading-4 sm:no-underline lg:text-sm lg:leading-5 lg:font-normal hover:opacity-80 active:opacity-60 focus:opacity-80 focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-secondary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-primary)] transition-opacity p-2 -m-2 cursor-pointer"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className="text-[var(--color-content-default-secondary)] font-inter text-sm leading-5 font-medium underline sm:text-xs sm:leading-4 sm:no-underline lg:text-sm lg:leading-5 lg:font-normal hover:opacity-80 active:opacity-60 focus:opacity-80 focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-secondary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-primary)] transition-opacity p-2 -m-2 cursor-pointer"
|
||||
>
|
||||
Terms of Service
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className="text-[var(--color-content-default-secondary)] font-inter text-sm leading-5 font-medium underline sm:text-xs sm:leading-4 sm:no-underline lg:text-sm lg:leading-5 lg:font-normal hover:opacity-80 active:opacity-60 focus:opacity-80 focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-secondary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-primary)] transition-opacity p-2 -m-2 cursor-pointer"
|
||||
>
|
||||
Cookies Settings
|
||||
</a>
|
||||
</div>
|
||||
{/* Copyright */}
|
||||
<div className="text-[var(--color-content-default-secondary)] font-inter text-sm leading-5 font-normal tracking-[0%] lg:text-base lg:leading-6">
|
||||
© {new Date().getFullYear()} Media Economies Design Lab. All rights
|
||||
reserved.
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -24,19 +24,23 @@ export const avatarImages = [
|
||||
];
|
||||
|
||||
export const logoConfig = [
|
||||
{ breakpoint: "block sm:hidden", size: "header", showText: false },
|
||||
{ breakpoint: "hidden sm:block md:hidden", size: "header", showText: true },
|
||||
{ breakpoint: "block sm:hidden", size: "header" as const, showText: false },
|
||||
{
|
||||
breakpoint: "hidden sm:block md:hidden",
|
||||
size: "header" as const,
|
||||
showText: true,
|
||||
},
|
||||
{
|
||||
breakpoint: "hidden md:block lg:hidden",
|
||||
size: "headerMd",
|
||||
size: "headerMd" as const,
|
||||
showText: true,
|
||||
},
|
||||
{
|
||||
breakpoint: "hidden lg:block xl:hidden",
|
||||
size: "headerLg",
|
||||
size: "headerLg" as const,
|
||||
showText: true,
|
||||
},
|
||||
{ breakpoint: "hidden xl:block", size: "headerXl", showText: true },
|
||||
{ breakpoint: "hidden xl:block", size: "headerXl" as const, showText: true },
|
||||
];
|
||||
|
||||
const Header = memo(() => {
|
||||
@@ -55,7 +59,7 @@ const Header = memo(() => {
|
||||
},
|
||||
};
|
||||
|
||||
const renderNavigationItems = (size) => {
|
||||
const renderNavigationItems = (size: string) => {
|
||||
return navigationItems.map((item, index) => (
|
||||
<MenuBarItem
|
||||
key={index}
|
||||
@@ -69,7 +73,10 @@ const Header = memo(() => {
|
||||
));
|
||||
};
|
||||
|
||||
const renderAvatarGroup = (containerSize, avatarSize) => {
|
||||
const renderAvatarGroup = (
|
||||
containerSize: "small" | "medium" | "large" | "xlarge",
|
||||
avatarSize: "small" | "medium" | "large" | "xlarge"
|
||||
) => {
|
||||
return (
|
||||
<AvatarContainer size={containerSize}>
|
||||
{avatarImages.map((avatar, index) => (
|
||||
@@ -84,7 +91,7 @@ const Header = memo(() => {
|
||||
);
|
||||
};
|
||||
|
||||
const renderLoginButton = (size) => {
|
||||
const renderLoginButton = (size: string) => {
|
||||
return (
|
||||
<MenuBarItem href="#" size={size} ariaLabel="Log in to your account">
|
||||
Log in
|
||||
@@ -92,7 +99,11 @@ const Header = memo(() => {
|
||||
);
|
||||
};
|
||||
|
||||
const renderCreateRuleButton = (buttonSize, containerSize, avatarSize) => {
|
||||
const renderCreateRuleButton = (
|
||||
buttonSize: string,
|
||||
containerSize: "small" | "medium" | "large" | "xlarge",
|
||||
avatarSize: "small" | "medium" | "large" | "xlarge"
|
||||
) => {
|
||||
return (
|
||||
<Button
|
||||
size={buttonSize}
|
||||
@@ -104,7 +115,22 @@ const Header = memo(() => {
|
||||
);
|
||||
};
|
||||
|
||||
const renderLogo = (size, showText) => {
|
||||
const renderLogo = (
|
||||
size:
|
||||
| "default"
|
||||
| "homeHeaderXsmall"
|
||||
| "homeHeaderSm"
|
||||
| "homeHeaderMd"
|
||||
| "homeHeaderLg"
|
||||
| "homeHeaderXl"
|
||||
| "header"
|
||||
| "headerMd"
|
||||
| "headerLg"
|
||||
| "headerXl"
|
||||
| "footer"
|
||||
| "footerLg",
|
||||
showText: boolean
|
||||
) => {
|
||||
return <Logo size={size} showText={showText} />;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import React, { memo } from "react";
|
||||
import { getAssetPath } from "../../lib/assetUtils";
|
||||
|
||||
const HeaderTab = memo(
|
||||
interface HeaderTabProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
stretch?: boolean;
|
||||
}
|
||||
|
||||
const HeaderTab = memo<HeaderTabProps>(
|
||||
({ children, className = "", stretch = false, ...props }) => {
|
||||
const stretchClasses = stretch
|
||||
? "flex-1 sm:mr-[var(--spacing-scale-008)] md:mr-[185px] lg:mr-[var(--spacing-scale-024)] xl:mr-[var(--spacing-scale-032)]"
|
||||
@@ -33,7 +39,7 @@ const HeaderTab = memo(
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
HeaderTab.displayName = "HeaderTab";
|
||||
@@ -5,7 +5,15 @@ import ContentLockup from "./ContentLockup";
|
||||
import HeroDecor from "./HeroDecor";
|
||||
import { getAssetPath } from "../../lib/assetUtils";
|
||||
|
||||
const HeroBanner = memo(
|
||||
interface HeroBannerProps {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
description?: string;
|
||||
ctaText?: string;
|
||||
ctaHref?: string;
|
||||
}
|
||||
|
||||
const HeroBanner = memo<HeroBannerProps>(
|
||||
({ title, subtitle, description, ctaText, ctaHref }) => {
|
||||
return (
|
||||
<section className="bg-transparent px-[var(--spacing-scale-008)] sm:px-[var(--spacing-scale-010)] md:px-[var(--spacing-scale-016)] lg:px-[var(--spacing-scale-024)] xl:px-[var(--spacing-scale-048)]">
|
||||
@@ -46,7 +54,7 @@ const HeroBanner = memo(
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
HeroBanner.displayName = "HeroBanner";
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
import React, { memo } from "react";
|
||||
|
||||
const HeroDecor = memo(({ className = "" }) => {
|
||||
interface HeroDecorProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const HeroDecor = memo<HeroDecorProps>(({ className = "" }) => {
|
||||
return (
|
||||
<svg
|
||||
className={`text-[var(--color-surface-default-brand-lighter-accent)] opacity-50 ${className}`}
|
||||
@@ -42,28 +42,32 @@ const HomeHeader = memo(() => {
|
||||
const logoConfig = [
|
||||
{
|
||||
breakpoint: "block sm:hidden",
|
||||
size: "homeHeaderXsmall",
|
||||
size: "homeHeaderXsmall" as const,
|
||||
showText: false,
|
||||
},
|
||||
{
|
||||
breakpoint: "hidden sm:block md:hidden",
|
||||
size: "homeHeaderSm",
|
||||
size: "homeHeaderSm" as const,
|
||||
showText: true,
|
||||
},
|
||||
{
|
||||
breakpoint: "hidden md:block lg:hidden",
|
||||
size: "homeHeaderMd",
|
||||
size: "homeHeaderMd" as const,
|
||||
showText: true,
|
||||
},
|
||||
{
|
||||
breakpoint: "hidden lg:block xl:hidden",
|
||||
size: "homeHeaderLg",
|
||||
size: "homeHeaderLg" as const,
|
||||
showText: true,
|
||||
},
|
||||
{
|
||||
breakpoint: "hidden xl:block",
|
||||
size: "homeHeaderXl" as const,
|
||||
showText: true,
|
||||
},
|
||||
{ breakpoint: "hidden xl:block", size: "homeHeaderXl", showText: true },
|
||||
];
|
||||
|
||||
const renderNavigationItems = (size) => {
|
||||
const renderNavigationItems = (size: string) => {
|
||||
return navigationItems.map((item, index) => (
|
||||
<MenuBarItem
|
||||
key={index}
|
||||
@@ -79,10 +83,10 @@ const HomeHeader = memo(() => {
|
||||
? size === "home" || size === "homeMd"
|
||||
? "homeMd"
|
||||
: size === "large"
|
||||
? "large"
|
||||
: size === "homeXlarge"
|
||||
? "homeXlarge"
|
||||
: "xsmallUseCases"
|
||||
? "large"
|
||||
: size === "homeXlarge"
|
||||
? "homeXlarge"
|
||||
: "xsmallUseCases"
|
||||
: size
|
||||
}
|
||||
variant={
|
||||
@@ -103,7 +107,10 @@ const HomeHeader = memo(() => {
|
||||
));
|
||||
};
|
||||
|
||||
const renderAvatarGroup = (containerSize, avatarSize) => {
|
||||
const renderAvatarGroup = (
|
||||
containerSize: "small" | "medium" | "large" | "xlarge",
|
||||
avatarSize: "small" | "medium" | "large" | "xlarge"
|
||||
) => {
|
||||
return (
|
||||
<AvatarContainer size={containerSize}>
|
||||
{avatarImages.map((avatar, index) => (
|
||||
@@ -118,7 +125,7 @@ const HomeHeader = memo(() => {
|
||||
);
|
||||
};
|
||||
|
||||
const renderLoginButton = (size) => {
|
||||
const renderLoginButton = (size: string) => {
|
||||
return (
|
||||
<MenuBarItem
|
||||
href="#"
|
||||
@@ -131,7 +138,11 @@ const HomeHeader = memo(() => {
|
||||
);
|
||||
};
|
||||
|
||||
const renderCreateRuleButton = (buttonSize, containerSize, avatarSize) => {
|
||||
const renderCreateRuleButton = (
|
||||
buttonSize: string,
|
||||
containerSize: "small" | "medium" | "large" | "xlarge",
|
||||
avatarSize: "small" | "medium" | "large" | "xlarge"
|
||||
) => {
|
||||
return (
|
||||
<Button
|
||||
size={buttonSize}
|
||||
@@ -144,7 +155,22 @@ const HomeHeader = memo(() => {
|
||||
);
|
||||
};
|
||||
|
||||
const renderLogo = (size, showText) => {
|
||||
const renderLogo = (
|
||||
size:
|
||||
| "default"
|
||||
| "homeHeaderXsmall"
|
||||
| "homeHeaderSm"
|
||||
| "homeHeaderMd"
|
||||
| "homeHeaderLg"
|
||||
| "homeHeaderXl"
|
||||
| "header"
|
||||
| "headerMd"
|
||||
| "headerLg"
|
||||
| "headerXl"
|
||||
| "footer"
|
||||
| "footerLg",
|
||||
showText: boolean
|
||||
) => {
|
||||
return <Logo size={size} showText={showText} />;
|
||||
};
|
||||
|
||||
@@ -2,11 +2,19 @@
|
||||
|
||||
import React, { memo } from "react";
|
||||
|
||||
interface ImagePlaceholderProps {
|
||||
width?: number;
|
||||
height?: number;
|
||||
text?: string;
|
||||
color?: "blue" | "green" | "purple" | "red" | "orange" | "teal";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple image placeholder component for testing
|
||||
* Generates colored backgrounds with text overlays
|
||||
*/
|
||||
const ImagePlaceholder = memo(
|
||||
const ImagePlaceholder = memo<ImagePlaceholderProps>(
|
||||
({
|
||||
width = 260,
|
||||
height = 390,
|
||||
@@ -14,7 +22,7 @@ const ImagePlaceholder = memo(
|
||||
color = "blue",
|
||||
className = "",
|
||||
}) => {
|
||||
const colors = {
|
||||
const colors: Record<string, string> = {
|
||||
blue: "bg-blue-500",
|
||||
green: "bg-green-500",
|
||||
purple: "bg-purple-500",
|
||||
@@ -33,7 +41,7 @@ const ImagePlaceholder = memo(
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
ImagePlaceholder.displayName = "ImagePlaceholder";
|
||||
@@ -2,7 +2,26 @@
|
||||
|
||||
import React, { memo, useCallback, forwardRef, useId } from "react";
|
||||
|
||||
const Input = forwardRef(
|
||||
interface InputProps
|
||||
extends Omit<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
"size" | "onChange" | "onFocus" | "onBlur"
|
||||
> {
|
||||
size?: "small" | "medium" | "large";
|
||||
labelVariant?: "default" | "horizontal";
|
||||
state?: "default" | "active" | "hover" | "focus";
|
||||
disabled?: boolean;
|
||||
error?: boolean;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
value?: string;
|
||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onFocus?: (e: React.FocusEvent<HTMLInputElement>) => void;
|
||||
onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
(
|
||||
{
|
||||
size = "medium",
|
||||
@@ -22,14 +41,22 @@ const Input = forwardRef(
|
||||
className = "",
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
ref
|
||||
) => {
|
||||
// Generate unique ID for accessibility if not provided
|
||||
const generatedId = useId();
|
||||
const inputId = id || `input-${generatedId}`;
|
||||
|
||||
// Size variants
|
||||
const sizeStyles = {
|
||||
const sizeStyles: Record<
|
||||
string,
|
||||
{
|
||||
input: string;
|
||||
label: string;
|
||||
container: string;
|
||||
radius: string;
|
||||
}
|
||||
> = {
|
||||
small: {
|
||||
input:
|
||||
labelVariant === "horizontal"
|
||||
@@ -54,7 +81,10 @@ const Input = forwardRef(
|
||||
};
|
||||
|
||||
// State styles
|
||||
const getStateStyles = () => {
|
||||
const getStateStyles = (): {
|
||||
input: string;
|
||||
label: string;
|
||||
} => {
|
||||
if (disabled) {
|
||||
return {
|
||||
input:
|
||||
@@ -122,30 +152,30 @@ const Input = forwardRef(
|
||||
`.trim();
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e) => {
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!disabled && onChange) {
|
||||
onChange(e);
|
||||
}
|
||||
},
|
||||
[disabled, onChange],
|
||||
[disabled, onChange]
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(
|
||||
(e) => {
|
||||
(e: React.FocusEvent<HTMLInputElement>) => {
|
||||
if (!disabled && onFocus) {
|
||||
onFocus(e);
|
||||
}
|
||||
},
|
||||
[disabled, onFocus],
|
||||
[disabled, onFocus]
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(
|
||||
(e) => {
|
||||
(e: React.FocusEvent<HTMLInputElement>) => {
|
||||
if (!disabled && onBlur) {
|
||||
onBlur(e);
|
||||
}
|
||||
},
|
||||
[disabled, onBlur],
|
||||
[disabled, onBlur]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -177,7 +207,7 @@ const Input = forwardRef(
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = "Input";
|
||||
@@ -2,9 +2,34 @@ import React, { memo } from "react";
|
||||
import Link from "next/link";
|
||||
import { getAssetPath, ASSETS } from "../../lib/assetUtils";
|
||||
|
||||
const Logo = memo(({ size = "default", showText = true }) => {
|
||||
interface LogoProps {
|
||||
size?:
|
||||
| "default"
|
||||
| "homeHeaderXsmall"
|
||||
| "homeHeaderSm"
|
||||
| "homeHeaderMd"
|
||||
| "homeHeaderLg"
|
||||
| "homeHeaderXl"
|
||||
| "header"
|
||||
| "headerMd"
|
||||
| "headerLg"
|
||||
| "headerXl"
|
||||
| "footer"
|
||||
| "footerLg";
|
||||
showText?: boolean;
|
||||
}
|
||||
|
||||
interface SizeConfig {
|
||||
containerHeight: string;
|
||||
gap: string;
|
||||
textSize: string;
|
||||
lineHeight: string;
|
||||
iconSize: string;
|
||||
}
|
||||
|
||||
const Logo = memo<LogoProps>(({ size = "default", showText = true }) => {
|
||||
// Size configurations
|
||||
const sizes = {
|
||||
const sizes: Record<string, SizeConfig> = {
|
||||
default: {
|
||||
containerHeight: "h-[41px]",
|
||||
gap: "gap-[8.28px]",
|
||||
@@ -3,11 +3,22 @@
|
||||
import React, { useState, useEffect, memo } from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
const LogoWall = memo(({ logos = [] }) => {
|
||||
interface Logo {
|
||||
src: string;
|
||||
alt: string;
|
||||
size?: string;
|
||||
order?: string;
|
||||
}
|
||||
|
||||
interface LogoWallProps {
|
||||
logos?: Logo[];
|
||||
}
|
||||
|
||||
const LogoWall = memo<LogoWallProps>(({ logos = [] }) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
// Default logos if none provided - ordered for mobile (3 rows × 2 columns)
|
||||
const defaultLogos = [
|
||||
const defaultLogos: Logo[] = [
|
||||
{
|
||||
src: "/assets/Section/Logo_FoodNotBombs.png",
|
||||
alt: "Food Not Bombs",
|
||||
@@ -1,8 +1,14 @@
|
||||
import React, { memo } from "react";
|
||||
|
||||
const MenuBar = memo(
|
||||
interface MenuBarProps extends React.HTMLAttributes<HTMLElement> {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
size?: "xsmall" | "default" | "medium" | "large";
|
||||
}
|
||||
|
||||
const MenuBar = memo<MenuBarProps>(
|
||||
({ children, className = "", size = "default", ...props }) => {
|
||||
const sizeStyles = {
|
||||
const sizeStyles: Record<string, string> = {
|
||||
xsmall:
|
||||
"px-[var(--spacing-scale-004)] py-[var(--spacing-scale-004)] gap-[var(--spacing-scale-001)] rounded-[4px]",
|
||||
default:
|
||||
@@ -25,7 +31,7 @@ const MenuBar = memo(
|
||||
{children}
|
||||
</nav>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
MenuBar.displayName = "MenuBar";
|
||||
@@ -1,6 +1,28 @@
|
||||
import React, { memo } from "react";
|
||||
|
||||
const MenuBarItem = memo(
|
||||
interface MenuBarItemProps
|
||||
extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
|
||||
href?: string;
|
||||
children?: React.ReactNode;
|
||||
variant?: "default" | "home";
|
||||
size?:
|
||||
| "default"
|
||||
| "xsmall"
|
||||
| "xsmallUseCases"
|
||||
| "home"
|
||||
| "homeMd"
|
||||
| "homeUseCases"
|
||||
| "large"
|
||||
| "largeUseCases"
|
||||
| "homeXlarge"
|
||||
| "xlarge";
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
isActive?: boolean;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
const MenuBarItem = memo<MenuBarItemProps>(
|
||||
({
|
||||
href = "#",
|
||||
children,
|
||||
@@ -12,19 +34,19 @@ const MenuBarItem = memo(
|
||||
ariaLabel,
|
||||
...props
|
||||
}) => {
|
||||
const variantStyles = {
|
||||
const variantStyles: Record<string, string> = {
|
||||
default:
|
||||
"bg-transparent text-[var(--color-content-default-brand-primary)] hover:bg-[var(--color-surface-default-tertiary)] hover:text-[var(--color-content-default-brand-primary)] hover:scale-[1.02] active:bg-transparent active:text-[var(--color-content-default-brand-primary)] active:scale-[0.98] disabled:bg-[var(--color-surface-default-tertiary)] disabled:text-[var(--color-content-default-tertiary)] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 disabled:active:scale-100",
|
||||
home: "bg-transparent text-[var(--color-content-inverse-primary)] hover:bg-[var(--color-content-default-brand-accent)] hover:text-[var(--color-content-inverse-primary)] hover:scale-[1.02] active:bg-transparent active:text-[var(--color-content-inverse-primary)] active:scale-[0.98] disabled:bg-[var(--color-surface-default-tertiary)] disabled:text-[var(--color-content-default-tertiary)] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 disabled:active:scale-100",
|
||||
};
|
||||
|
||||
const activeOutlineStyles = {
|
||||
const activeOutlineStyles: Record<string, string> = {
|
||||
xsmall:
|
||||
"active:outline-1 active:outline-[var(--color-content-default-primary)] focus:outline-1 focus:outline-[var(--color-content-default-primary)]",
|
||||
xsmallUseCases:
|
||||
"active:outline-1 active:outline-[var(--color-content-default-primary)] focus:outline-1 focus:outline-[var(--color-content-default-primary)]",
|
||||
default:
|
||||
"active:outline-1 active:outline-[var(--color-content-default-brand-primary)] focus:outline-1 focus:outline-[var(--color-content-default-brand-primary)]",
|
||||
"active:outline-[1.5px] active:outline-[var(--color-content-default-brand-primary)] focus:outline-[1.5px] focus:outline-[var(--color-content-default-brand-primary)]",
|
||||
homeMd:
|
||||
"active:outline-[1.5px] active:outline-[var(--color-content-default-brand-primary)] focus:outline-[1.5px] focus:outline-[var(--color-content-default-brand-primary)]",
|
||||
homeUseCases:
|
||||
@@ -39,7 +61,7 @@ const MenuBarItem = memo(
|
||||
"active:outline-2 active:outline-[var(--color-content-default-brand-primary)] focus:outline-2 focus:outline-[var(--color-content-default-brand-primary)]",
|
||||
};
|
||||
|
||||
const homeOutlineStyles = {
|
||||
const homeOutlineStyles: Record<string, string> = {
|
||||
xsmall:
|
||||
"active:outline-1 active:outline-[var(--color-content-default-primary)] focus:outline-1 focus:outline-[var(--color-content-default-primary)]",
|
||||
xsmallUseCases:
|
||||
@@ -60,7 +82,7 @@ const MenuBarItem = memo(
|
||||
"active:outline-2 active:outline-[var(--color-content-default-primary)] focus:outline-2 focus:outline-[var(--color-content-default-primary)]",
|
||||
};
|
||||
|
||||
const activeStateStyles = {
|
||||
const activeStateStyles: Record<string, string> = {
|
||||
xsmall:
|
||||
"!outline-1 !outline-[var(--color-content-default-brand-primary)] !text-[var(--color-content-default-brand-primary)] focus:!outline-1 focus:!outline-[var(--color-content-default-brand-primary)]",
|
||||
xsmallUseCases:
|
||||
@@ -81,7 +103,7 @@ const MenuBarItem = memo(
|
||||
"!outline-2 !outline-[var(--color-content-default-brand-primary)] !text-[var(--color-content-default-brand-primary)] focus:!outline-2 focus:!outline-[var(--color-content-default-brand-primary)]",
|
||||
};
|
||||
|
||||
const sizeStyles = {
|
||||
const sizeStyles: Record<string, string> = {
|
||||
default:
|
||||
"px-[var(--spacing-measures-spacing-016)] py-[var(--spacing-measures-spacing-016)] gap-[var(--spacing-scale-004)]",
|
||||
xsmall:
|
||||
@@ -111,7 +133,7 @@ const MenuBarItem = memo(
|
||||
const xlargeTextStyle =
|
||||
"font-inter text-[24px] leading-[28px] font-normal tracking-[0%]";
|
||||
|
||||
const textStyles = {
|
||||
const textStyles: Record<string, string> = {
|
||||
default: smallTextStyle,
|
||||
xsmall: smallTextStyle,
|
||||
xsmallUseCases: smallTextStyle,
|
||||
@@ -140,7 +162,7 @@ const MenuBarItem = memo(
|
||||
const accessibilityProps = {
|
||||
...(ariaLabel && { "aria-label": ariaLabel }),
|
||||
...(disabled && { "aria-disabled": "true" }),
|
||||
role: "menuitem",
|
||||
role: "menuitem" as const,
|
||||
tabIndex: disabled ? -1 : 0,
|
||||
...props,
|
||||
};
|
||||
@@ -158,7 +180,7 @@ const MenuBarItem = memo(
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
MenuBarItem.displayName = "MenuBarItem";
|
||||
@@ -3,7 +3,20 @@
|
||||
import React, { memo } from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
const MiniCard = memo(
|
||||
interface MiniCardProps {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
backgroundColor?: string;
|
||||
panelContent?: string;
|
||||
label?: string;
|
||||
labelLine1?: string;
|
||||
labelLine2?: string;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
const MiniCard = memo<MiniCardProps>(
|
||||
({
|
||||
children,
|
||||
className = "",
|
||||
@@ -116,7 +129,7 @@ const MiniCard = memo(
|
||||
{cardContent}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
MiniCard.displayName = "MiniCard";
|
||||
@@ -1,6 +1,16 @@
|
||||
import React, { memo } from "react";
|
||||
|
||||
const NavigationItem = memo(
|
||||
interface NavigationItemProps
|
||||
extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
|
||||
href?: string;
|
||||
children?: React.ReactNode;
|
||||
variant?: "default";
|
||||
size?: "default" | "xsmall";
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const NavigationItem = memo<NavigationItemProps>(
|
||||
({
|
||||
href = "#",
|
||||
children,
|
||||
@@ -11,13 +21,13 @@ const NavigationItem = memo(
|
||||
...props
|
||||
}) => {
|
||||
// Variant styles
|
||||
const variantStyles = {
|
||||
const variantStyles: Record<string, string> = {
|
||||
default:
|
||||
"bg-transparent text-[var(--color-content-default-brand-primary)] border border-transparent hover:bg-[var(--color-surface-default-tertiary)] hover:text-[var(--color-content-default-brand-primary)] active:bg-transparent active:text-[var(--color-content-default-brand-primary)] active:border-[var(--color-content-default-brand-primary)] disabled:bg-[var(--color-surface-default-tertiary)] disabled:text-[var(--color-content-default-tertiary)] disabled:border-[var(--color-content-default-tertiary)] disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
};
|
||||
|
||||
// Size styles
|
||||
const sizeStyles = {
|
||||
const sizeStyles: Record<string, string> = {
|
||||
default:
|
||||
"px-[var(--spacing-scale-016)] py-[var(--spacing-scale-016)] gap-[var(--spacing-scale-004)]",
|
||||
xsmall:
|
||||
@@ -25,7 +35,7 @@ const NavigationItem = memo(
|
||||
};
|
||||
|
||||
// Text styles based on size
|
||||
const textStyles = {
|
||||
const textStyles: Record<string, string> = {
|
||||
default:
|
||||
"font-inter text-[10px] leading-[12px] font-medium tracking-[0%]",
|
||||
xsmall: "font-inter text-[10px] leading-[12px] font-medium tracking-[0%]",
|
||||
@@ -54,7 +64,7 @@ const NavigationItem = memo(
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
NavigationItem.displayName = "NavigationItem";
|
||||
@@ -1,26 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { memo } from "react";
|
||||
import SectionNumber from "./SectionNumber";
|
||||
|
||||
const NumberedCard = memo(({ number, text, iconShape, iconColor }) => {
|
||||
return (
|
||||
<div className="bg-[var(--color-surface-inverse-primary)] rounded-[12px] p-5 shadow-lg flex flex-col gap-4 sm:p-8 sm:gap-8 sm:flex-row sm:items-center lg:p-8 lg:gap-0 lg:flex-row lg:items-stretch lg:relative lg:h-[238px]">
|
||||
{/* Section Number - Top right (lg breakpoint) */}
|
||||
<div className="flex justify-end sm:justify-start sm:flex-shrink-0 lg:absolute lg:top-8 lg:right-8">
|
||||
<SectionNumber number={number} />
|
||||
</div>
|
||||
|
||||
{/* Card Content - Bottom left (lg breakpoint) */}
|
||||
<div className="sm:flex-1 lg:absolute lg:bottom-8 lg:left-8 lg:right-16">
|
||||
<p className="font-bricolage-grotesque font-medium text-[24px] leading-[32px] sm:font-normal sm:leading-[24px] sm:text-[24px] lg:text-[24px] lg:leading-[24px] xl:text-[32px] xl:leading-[32px] text-[#141414]">
|
||||
{text}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
NumberedCard.displayName = "NumberedCard";
|
||||
|
||||
export default NumberedCard;
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import React, { memo } from "react";
|
||||
import SectionNumber from "./SectionNumber";
|
||||
|
||||
interface NumberedCardProps {
|
||||
number: number;
|
||||
text: string;
|
||||
iconShape?: string;
|
||||
iconColor?: string;
|
||||
}
|
||||
|
||||
const NumberedCard = memo<NumberedCardProps>(
|
||||
({ number, text, iconShape, iconColor }) => {
|
||||
return (
|
||||
<div className="bg-[var(--color-surface-inverse-primary)] rounded-[12px] p-5 shadow-lg flex flex-col gap-4 sm:p-8 sm:gap-8 sm:flex-row sm:items-center lg:p-8 lg:gap-0 lg:flex-row lg:items-stretch lg:relative lg:h-[238px]">
|
||||
{/* Section Number - Top right (lg breakpoint) */}
|
||||
<div className="flex justify-end sm:justify-start sm:flex-shrink-0 lg:absolute lg:top-8 lg:right-8">
|
||||
<SectionNumber number={number} />
|
||||
</div>
|
||||
|
||||
{/* Card Content - Bottom left (lg breakpoint) */}
|
||||
<div className="sm:flex-1 lg:absolute lg:bottom-8 lg:left-8 lg:right-16">
|
||||
<p className="font-bricolage-grotesque font-medium text-[24px] leading-[32px] sm:font-normal sm:leading-[24px] sm:text-[24px] lg:text-[24px] lg:leading-[24px] xl:text-[32px] xl:leading-[32px] text-[#141414]">
|
||||
{text}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
NumberedCard.displayName = "NumberedCard";
|
||||
|
||||
export default NumberedCard;
|
||||
@@ -5,7 +5,19 @@ import NumberedCard from "./NumberedCard";
|
||||
import SectionHeader from "./SectionHeader";
|
||||
import Button from "./Button";
|
||||
|
||||
const NumberedCards = memo(({ title, subtitle, cards }) => {
|
||||
interface Card {
|
||||
text: string;
|
||||
iconShape?: string;
|
||||
iconColor?: string;
|
||||
}
|
||||
|
||||
interface NumberedCardsProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
cards: Card[];
|
||||
}
|
||||
|
||||
const NumberedCards = memo<NumberedCardsProps>(({ title, subtitle, cards }) => {
|
||||
// Memoize schema data to prevent unnecessary re-computations
|
||||
const schemaData = useMemo(
|
||||
() => ({
|
||||
@@ -20,7 +32,7 @@ const NumberedCards = memo(({ title, subtitle, cards }) => {
|
||||
text: card.text,
|
||||
})),
|
||||
}),
|
||||
[title, subtitle, cards],
|
||||
[title, subtitle, cards]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -4,7 +4,26 @@ import React, { useState, memo } from "react";
|
||||
import Image from "next/image";
|
||||
import QuoteDecor from "./QuoteDecor";
|
||||
|
||||
const QuoteBlock = memo(
|
||||
interface QuoteBlockProps {
|
||||
variant?: "compact" | "standard" | "extended";
|
||||
className?: string;
|
||||
quote?: string;
|
||||
author?: string;
|
||||
source?: string;
|
||||
avatarSrc?: string;
|
||||
id?: string;
|
||||
fallbackAvatarSrc?: string;
|
||||
onError?: (error: {
|
||||
type: string;
|
||||
message: string;
|
||||
author?: string;
|
||||
avatarSrc?: string;
|
||||
error?: unknown;
|
||||
quote?: boolean;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
const QuoteBlock = memo<QuoteBlockProps>(
|
||||
({
|
||||
variant = "standard",
|
||||
className = "",
|
||||
@@ -13,14 +32,26 @@ const QuoteBlock = memo(
|
||||
source = "The Tyranny of Structurelessness",
|
||||
avatarSrc = "/assets/Quote_Avatar.svg",
|
||||
id,
|
||||
fallbackAvatarSrc = "/assets/Quote_Avatar.svg", // Fallback avatar
|
||||
onError, // Error callback
|
||||
fallbackAvatarSrc = "/assets/Quote_Avatar.svg",
|
||||
onError,
|
||||
}) => {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const [imageLoading, setImageLoading] = useState(true);
|
||||
|
||||
// Variant configurations
|
||||
const variants = {
|
||||
interface VariantConfig {
|
||||
container: string;
|
||||
card: string;
|
||||
gap: string;
|
||||
avatarGap: string;
|
||||
avatar: string;
|
||||
quote: string;
|
||||
author: string;
|
||||
source: string;
|
||||
showDecor: boolean;
|
||||
}
|
||||
|
||||
const variants: Record<string, VariantConfig> = {
|
||||
compact: {
|
||||
container:
|
||||
"py-[var(--spacing-scale-032)] px-[var(--spacing-scale-016)]",
|
||||
@@ -77,10 +108,10 @@ const QuoteBlock = memo(
|
||||
const authorId = `${baseId}-author`;
|
||||
|
||||
// Error handling functions
|
||||
const handleImageError = (error) => {
|
||||
const handleImageError = (error: unknown) => {
|
||||
console.warn(
|
||||
`QuoteBlock: Failed to load avatar image for ${author}:`,
|
||||
error,
|
||||
error
|
||||
);
|
||||
setImageError(true);
|
||||
setImageLoading(false);
|
||||
@@ -244,7 +275,7 @@ const QuoteBlock = memo(
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
QuoteBlock.displayName = "QuoteBlock";
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
import React, { memo } from "react";
|
||||
|
||||
const QuoteDecor = memo(({ className = "" }) => {
|
||||
interface QuoteDecorProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const QuoteDecor = memo<QuoteDecorProps>(({ className = "" }) => {
|
||||
return (
|
||||
<svg
|
||||
className={`text-[var(--color-surface-inverse-brand-primary)] opacity-100 w-full h-full md:max-w-[640px] lg:max-w-[850px] xl:max-w-[1100px] ${className}`}
|
||||
@@ -2,6 +2,20 @@
|
||||
|
||||
import React, { memo, useCallback, useId } from "react";
|
||||
|
||||
interface RadioButtonProps {
|
||||
checked?: boolean;
|
||||
mode?: "standard" | "inverse";
|
||||
state?: "default" | "hover" | "focus";
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
onChange?: (data: { checked: boolean; value?: string }) => void;
|
||||
id?: string;
|
||||
name?: string;
|
||||
value?: string;
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const RadioButton = ({
|
||||
checked = false,
|
||||
mode = "standard",
|
||||
@@ -15,7 +29,7 @@ const RadioButton = ({
|
||||
ariaLabel,
|
||||
className = "",
|
||||
...props
|
||||
}) => {
|
||||
}: RadioButtonProps) => {
|
||||
const isInverse = mode === "inverse";
|
||||
|
||||
// Base tokens (using same design tokens as Checkbox)
|
||||
@@ -32,7 +46,7 @@ const RadioButton = ({
|
||||
// Visual container depending on state
|
||||
const baseBox = `flex items-center justify-center shrink-0 w-[var(--measures-sizing-024)] h-[var(--measures-sizing-024)] rounded-[var(--measures-radius-medium)] transition-all duration-200 ease-in-out`;
|
||||
|
||||
const stateStyles = {
|
||||
const stateStyles: Record<string, string> = {
|
||||
default: "",
|
||||
hover: "",
|
||||
focus: "",
|
||||
@@ -75,12 +89,12 @@ const RadioButton = ({
|
||||
const radioId = id || `radio-${generatedId}`;
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(e) => {
|
||||
(e: React.MouseEvent | React.KeyboardEvent) => {
|
||||
if (!disabled && onChange && !checked) {
|
||||
onChange({ checked: true, value });
|
||||
}
|
||||
},
|
||||
[disabled, onChange, checked, value],
|
||||
[disabled, onChange, checked, value]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -3,6 +3,24 @@
|
||||
import React, { memo, useCallback, useId } from "react";
|
||||
import RadioButton from "./RadioButton";
|
||||
|
||||
interface RadioOption {
|
||||
value: string;
|
||||
label: string;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
interface RadioGroupProps {
|
||||
name?: string;
|
||||
value?: string;
|
||||
onChange?: (data: { value: string }) => void;
|
||||
mode?: "standard" | "inverse";
|
||||
state?: "default" | "hover" | "focus";
|
||||
disabled?: boolean;
|
||||
options?: RadioOption[];
|
||||
className?: string;
|
||||
"aria-label"?: string;
|
||||
}
|
||||
|
||||
const RadioGroup = ({
|
||||
name,
|
||||
value,
|
||||
@@ -13,18 +31,18 @@ const RadioGroup = ({
|
||||
options = [],
|
||||
className = "",
|
||||
...props
|
||||
}) => {
|
||||
}: RadioGroupProps) => {
|
||||
// Generate unique ID for accessibility if not provided
|
||||
const generatedId = useId();
|
||||
const groupId = name || `radio-group-${generatedId}`;
|
||||
|
||||
const handleChange = useCallback(
|
||||
(optionValue) => {
|
||||
(optionValue: string) => {
|
||||
if (!disabled && onChange) {
|
||||
onChange({ value: optionValue });
|
||||
}
|
||||
},
|
||||
[disabled, onChange],
|
||||
[disabled, onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -34,7 +52,7 @@ const RadioGroup = ({
|
||||
aria-label={props["aria-label"]}
|
||||
{...props}
|
||||
>
|
||||
{options.map((option, index) => {
|
||||
{options.map((option) => {
|
||||
const isSelected = value === option.value;
|
||||
|
||||
return (
|
||||
@@ -2,13 +2,20 @@
|
||||
|
||||
import React, { useState, useEffect, memo, useMemo, useCallback } from "react";
|
||||
import ContentThumbnailTemplate from "./ContentThumbnailTemplate";
|
||||
import type { BlogPost } from "../../lib/content";
|
||||
|
||||
const RelatedArticles = memo(
|
||||
interface RelatedArticlesProps {
|
||||
relatedPosts: BlogPost[];
|
||||
currentPostSlug: string;
|
||||
slugOrder?: string[];
|
||||
}
|
||||
|
||||
const RelatedArticles = memo<RelatedArticlesProps>(
|
||||
({ relatedPosts, currentPostSlug, slugOrder = [] }) => {
|
||||
// Memoize filtered posts to prevent unnecessary re-computations
|
||||
const filteredPosts = useMemo(
|
||||
() => relatedPosts.filter((post) => post.slug !== currentPostSlug),
|
||||
[relatedPosts, currentPostSlug],
|
||||
[relatedPosts, currentPostSlug]
|
||||
);
|
||||
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
@@ -16,25 +23,28 @@ const RelatedArticles = memo(
|
||||
const [isMobile, setIsMobile] = useState(true);
|
||||
|
||||
// Memoize the mouse down handler to prevent unnecessary re-renders
|
||||
const handleMouseDown = useCallback((e) => {
|
||||
const slider = e.currentTarget;
|
||||
const startX = e.pageX - slider.offsetLeft;
|
||||
const scrollLeft = slider.scrollLeft;
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const slider = e.currentTarget;
|
||||
const startX = e.pageX - slider.offsetLeft;
|
||||
const scrollLeft = slider.scrollLeft;
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
const x = e.pageX - slider.offsetLeft;
|
||||
const walk = (x - startX) * 2;
|
||||
slider.scrollLeft = scrollLeft - walk;
|
||||
};
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const x = e.pageX - slider.offsetLeft;
|
||||
const walk = (x - startX) * 2;
|
||||
slider.scrollLeft = scrollLeft - walk;
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
}, []);
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Memoize transform style to prevent unnecessary recalculations
|
||||
const transformStyle = useMemo(
|
||||
@@ -44,20 +54,20 @@ const RelatedArticles = memo(
|
||||
: "none",
|
||||
scrollBehavior: !isMobile ? "smooth" : "auto",
|
||||
}),
|
||||
[isMobile, currentIndex],
|
||||
[isMobile, currentIndex]
|
||||
);
|
||||
|
||||
// Memoize progress bar style calculation
|
||||
const getProgressStyle = useCallback(
|
||||
(index) => ({
|
||||
(index: number): React.CSSProperties => ({
|
||||
width:
|
||||
index === currentIndex
|
||||
? `${progress}%`
|
||||
: index < currentIndex
|
||||
? "100%"
|
||||
: "0%",
|
||||
? "100%"
|
||||
: "0%",
|
||||
}),
|
||||
[currentIndex, progress],
|
||||
[currentIndex, progress]
|
||||
);
|
||||
|
||||
// Check if we're on mobile (below lg breakpoint)
|
||||
@@ -121,7 +131,7 @@ const RelatedArticles = memo(
|
||||
style={transformStyle}
|
||||
onMouseDown={!isMobile ? handleMouseDown : undefined}
|
||||
>
|
||||
{filteredPosts.map((relatedPost, index) => (
|
||||
{filteredPosts.map((relatedPost) => (
|
||||
<div
|
||||
key={relatedPost.slug}
|
||||
className="flex flex-col items-center flex-shrink-0"
|
||||
@@ -155,7 +165,7 @@ const RelatedArticles = memo(
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
RelatedArticles.displayName = "RelatedArticles";
|
||||
@@ -2,7 +2,29 @@
|
||||
|
||||
import React, { memo } from "react";
|
||||
|
||||
const RuleCard = memo(
|
||||
interface RuleCardProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
backgroundColor?: string;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
gtag?: (
|
||||
command: string,
|
||||
eventName: string,
|
||||
params?: Record<string, unknown>
|
||||
) => void;
|
||||
analytics?: {
|
||||
track: (eventName: string, params?: Record<string, unknown>) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const RuleCard = memo<RuleCardProps>(
|
||||
({
|
||||
title,
|
||||
description,
|
||||
@@ -31,7 +53,7 @@ const RuleCard = memo(
|
||||
if (onClick) onClick();
|
||||
};
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
handleClick();
|
||||
@@ -71,7 +93,7 @@ const RuleCard = memo(
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
RuleCard.displayName = "RuleCard";
|
||||
@@ -6,8 +6,25 @@ import RuleCard from "./RuleCard";
|
||||
import Button from "./Button";
|
||||
import { getAssetPath } from "../../lib/assetUtils";
|
||||
|
||||
const RuleStack = memo(({ className = "" }) => {
|
||||
const handleTemplateClick = (templateName) => {
|
||||
interface RuleStackProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
gtag?: (
|
||||
command: string,
|
||||
eventName: string,
|
||||
params?: Record<string, unknown>
|
||||
) => void;
|
||||
analytics?: {
|
||||
track: (eventName: string, params?: Record<string, unknown>) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const RuleStack = memo<RuleStackProps>(({ className = "" }) => {
|
||||
const handleTemplateClick = (templateName: string) => {
|
||||
// Basic analytics tracking
|
||||
if (typeof window !== "undefined") {
|
||||
if (window.gtag) {
|
||||
@@ -2,7 +2,14 @@
|
||||
|
||||
import React, { memo } from "react";
|
||||
|
||||
const SectionHeader = memo(
|
||||
interface SectionHeaderProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
titleLg?: string;
|
||||
variant?: "default" | "multi-line";
|
||||
}
|
||||
|
||||
const SectionHeader = memo<SectionHeaderProps>(
|
||||
({ title, subtitle, titleLg, variant = "default" }) => {
|
||||
return (
|
||||
<div
|
||||
@@ -52,7 +59,7 @@ const SectionHeader = memo(
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
SectionHeader.displayName = "SectionHeader";
|
||||
@@ -2,8 +2,12 @@
|
||||
|
||||
import React, { memo } from "react";
|
||||
|
||||
const SectionNumber = memo(({ number }) => {
|
||||
const getImageSrc = (num) => {
|
||||
interface SectionNumberProps {
|
||||
number: number;
|
||||
}
|
||||
|
||||
const SectionNumber = memo<SectionNumberProps>(({ number }) => {
|
||||
const getImageSrc = (num: number): string => {
|
||||
switch (num) {
|
||||
case 1:
|
||||
return "/assets/SectionNumber_1.png";
|
||||
@@ -12,7 +12,28 @@ import React, {
|
||||
import SelectDropdown from "./SelectDropdown";
|
||||
import SelectOption from "./SelectOption";
|
||||
|
||||
const Select = forwardRef(
|
||||
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>(
|
||||
(
|
||||
{
|
||||
id,
|
||||
@@ -27,26 +48,27 @@ const Select = forwardRef(
|
||||
children,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
ref
|
||||
) => {
|
||||
const generatedId = useId();
|
||||
const selectId = id || `select-${generatedId}`;
|
||||
const labelId = `${selectId}-label`;
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedValue, setSelectedValue] = useState(value || "");
|
||||
const selectRef = useRef(null);
|
||||
const menuRef = useRef(null);
|
||||
const selectRef = useRef<HTMLButtonElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Handle click outside to close menu
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
menuRef.current &&
|
||||
!menuRef.current.contains(event.target) &&
|
||||
!menuRef.current.contains(event.target as Node) &&
|
||||
selectRef.current &&
|
||||
!selectRef.current.contains(event.target)
|
||||
!selectRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
@@ -61,7 +83,7 @@ const Select = forwardRef(
|
||||
|
||||
// Handle option selection
|
||||
const handleOptionSelect = useCallback(
|
||||
(optionValue, optionText) => {
|
||||
(optionValue: string, optionText: string) => {
|
||||
setSelectedValue(optionValue);
|
||||
setIsOpen(false);
|
||||
if (onChange) {
|
||||
@@ -72,7 +94,7 @@ const Select = forwardRef(
|
||||
selectRef.current.focus();
|
||||
}
|
||||
},
|
||||
[onChange],
|
||||
[onChange]
|
||||
);
|
||||
|
||||
// Handle select button click
|
||||
@@ -84,7 +106,7 @@ const Select = forwardRef(
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = useCallback(
|
||||
(e) => {
|
||||
(e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (disabled) return;
|
||||
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
@@ -94,10 +116,10 @@ const Select = forwardRef(
|
||||
setIsOpen(false);
|
||||
}
|
||||
},
|
||||
[disabled, isOpen],
|
||||
[disabled, isOpen]
|
||||
);
|
||||
|
||||
const getSizeStyles = () => {
|
||||
const getSizeStyles = (): string => {
|
||||
const baseStyles = "w-full";
|
||||
|
||||
switch (size) {
|
||||
@@ -114,7 +136,7 @@ const Select = forwardRef(
|
||||
}
|
||||
};
|
||||
|
||||
const getLabelSizeStyles = () => {
|
||||
const getLabelSizeStyles = (): string => {
|
||||
switch (size) {
|
||||
case "small":
|
||||
return "text-[12px] leading-[14px]";
|
||||
@@ -127,7 +149,10 @@ const Select = forwardRef(
|
||||
}
|
||||
};
|
||||
|
||||
const getStateStyles = () => {
|
||||
const getStateStyles = (): {
|
||||
select: string;
|
||||
label: string;
|
||||
} => {
|
||||
if (disabled) {
|
||||
return {
|
||||
select:
|
||||
@@ -164,7 +189,7 @@ const Select = forwardRef(
|
||||
}
|
||||
};
|
||||
|
||||
const getBorderRadius = () => {
|
||||
const getBorderRadius = (): string => {
|
||||
switch (size) {
|
||||
case "small":
|
||||
return "rounded-[var(--measures-radius-small)]";
|
||||
@@ -222,22 +247,27 @@ const Select = forwardRef(
|
||||
: "flex flex-col";
|
||||
|
||||
// Get display text for selected value
|
||||
const getDisplayText = () => {
|
||||
const getDisplayText = (): string => {
|
||||
if (!selectedValue) return placeholder;
|
||||
|
||||
// Handle options prop
|
||||
if (props.options && Array.isArray(props.options)) {
|
||||
const selectedOption = props.options.find(
|
||||
(option) => option.value === selectedValue,
|
||||
if (options && Array.isArray(options)) {
|
||||
const selectedOption = options.find(
|
||||
(option) => option.value === selectedValue
|
||||
);
|
||||
return selectedOption ? selectedOption.label : placeholder;
|
||||
}
|
||||
|
||||
// Handle children (option elements)
|
||||
const selectedOption = React.Children.toArray(children).find(
|
||||
(child) => child.props.value === selectedValue,
|
||||
);
|
||||
return selectedOption ? selectedOption.props.children : placeholder;
|
||||
(child) =>
|
||||
React.isValidElement(child) && child.props.value === selectedValue
|
||||
) as
|
||||
| React.ReactElement<{ value: string; children: React.ReactNode }>
|
||||
| undefined;
|
||||
return selectedOption
|
||||
? String(selectedOption.props.children)
|
||||
: placeholder;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -293,8 +323,8 @@ const Select = forwardRef(
|
||||
className="absolute top-full left-0 right-0 z-50 mt-1"
|
||||
>
|
||||
<SelectDropdown>
|
||||
{props.options && Array.isArray(props.options)
|
||||
? props.options.map((option) => (
|
||||
{options && Array.isArray(options)
|
||||
? options.map((option) => (
|
||||
<SelectOption
|
||||
key={option.value}
|
||||
selected={option.value === selectedValue}
|
||||
@@ -307,20 +337,27 @@ const Select = forwardRef(
|
||||
</SelectOption>
|
||||
))
|
||||
: React.Children.map(children, (child) => {
|
||||
if (child.type === "option") {
|
||||
if (
|
||||
React.isValidElement(child) &&
|
||||
child.type === "option"
|
||||
) {
|
||||
const optionProps = child.props as {
|
||||
value: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
return (
|
||||
<SelectOption
|
||||
key={child.props.value}
|
||||
selected={child.props.value === selectedValue}
|
||||
key={optionProps.value}
|
||||
selected={optionProps.value === selectedValue}
|
||||
size={size}
|
||||
onClick={() =>
|
||||
handleOptionSelect(
|
||||
child.props.value,
|
||||
child.props.children,
|
||||
optionProps.value,
|
||||
String(optionProps.children)
|
||||
)
|
||||
}
|
||||
>
|
||||
{child.props.children}
|
||||
{optionProps.children}
|
||||
</SelectOption>
|
||||
);
|
||||
}
|
||||
@@ -332,7 +369,7 @@ const Select = forwardRef(
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
Select.displayName = "Select";
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
import React, { forwardRef, memo } from "react";
|
||||
|
||||
const SelectDropdown = forwardRef(
|
||||
interface SelectDropdownProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const SelectDropdown = forwardRef<HTMLDivElement, SelectDropdownProps>(
|
||||
({ className = "", children, ...props }, ref) => {
|
||||
const menuClasses = `
|
||||
bg-black
|
||||
@@ -29,7 +34,7 @@ const SelectDropdown = forwardRef(
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
SelectDropdown.displayName = "SelectDropdown";
|
||||
@@ -2,7 +2,18 @@
|
||||
|
||||
import React, { forwardRef, memo, useCallback } from "react";
|
||||
|
||||
const SelectOption = forwardRef(
|
||||
interface SelectOptionProps {
|
||||
children?: React.ReactNode;
|
||||
selected?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
onClick?: (
|
||||
e: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>
|
||||
) => void;
|
||||
size?: "small" | "medium" | "large";
|
||||
}
|
||||
|
||||
const SelectOption = forwardRef<HTMLDivElement, SelectOptionProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
@@ -13,9 +24,9 @@ const SelectOption = forwardRef(
|
||||
size = "medium",
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
ref
|
||||
) => {
|
||||
const getTextSize = () => {
|
||||
const getTextSize = (): string => {
|
||||
switch (size) {
|
||||
case "small":
|
||||
return "text-[10px] leading-[14px]";
|
||||
@@ -51,16 +62,16 @@ const SelectOption = forwardRef(
|
||||
.replace(/\s+/g, " ");
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e) => {
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!disabled && onClick) {
|
||||
onClick(e);
|
||||
}
|
||||
},
|
||||
[disabled, onClick],
|
||||
[disabled, onClick]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e) => {
|
||||
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
if (!disabled && onClick) {
|
||||
@@ -68,7 +79,7 @@ const SelectOption = forwardRef(
|
||||
}
|
||||
}
|
||||
},
|
||||
[disabled, onClick],
|
||||
[disabled, onClick]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -103,7 +114,7 @@ const SelectOption = forwardRef(
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
SelectOption.displayName = "SelectOption";
|
||||
@@ -1,7 +1,22 @@
|
||||
import React, { memo, useCallback, useId, forwardRef } from "react";
|
||||
|
||||
interface SwitchProps
|
||||
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
|
||||
checked?: boolean;
|
||||
onChange?: (
|
||||
e:
|
||||
| React.MouseEvent<HTMLButtonElement>
|
||||
| React.KeyboardEvent<HTMLButtonElement>
|
||||
) => void;
|
||||
onFocus?: (e: React.FocusEvent<HTMLButtonElement>) => void;
|
||||
onBlur?: (e: React.FocusEvent<HTMLButtonElement>) => void;
|
||||
state?: "default" | "hover" | "focus";
|
||||
label?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Switch = memo(
|
||||
forwardRef((props, ref) => {
|
||||
forwardRef<HTMLButtonElement, SwitchProps>((props, ref) => {
|
||||
const {
|
||||
checked = false,
|
||||
onChange,
|
||||
@@ -16,16 +31,16 @@ const Switch = memo(
|
||||
const switchId = useId();
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e) => {
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (onChange) {
|
||||
onChange(e);
|
||||
}
|
||||
},
|
||||
[onChange],
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e) => {
|
||||
(e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
if (onChange) {
|
||||
@@ -33,25 +48,25 @@ const Switch = memo(
|
||||
}
|
||||
}
|
||||
},
|
||||
[onChange],
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(
|
||||
(e) => {
|
||||
(e: React.FocusEvent<HTMLButtonElement>) => {
|
||||
if (onFocus) {
|
||||
onFocus(e);
|
||||
}
|
||||
},
|
||||
[onFocus],
|
||||
[onFocus]
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(
|
||||
(e) => {
|
||||
(e: React.FocusEvent<HTMLButtonElement>) => {
|
||||
if (onBlur) {
|
||||
onBlur(e);
|
||||
}
|
||||
},
|
||||
[onBlur],
|
||||
[onBlur]
|
||||
);
|
||||
|
||||
// Switch track styles based on checked state
|
||||
@@ -155,7 +170,7 @@ const Switch = memo(
|
||||
{label && <span className={labelClasses}>{label}</span>}
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
Switch.displayName = "Switch";
|
||||
@@ -2,7 +2,27 @@
|
||||
|
||||
import React, { memo, useCallback, forwardRef, useId } from "react";
|
||||
|
||||
const TextArea = forwardRef(
|
||||
interface TextAreaProps
|
||||
extends Omit<
|
||||
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
|
||||
"size" | "onChange" | "onFocus" | "onBlur"
|
||||
> {
|
||||
size?: "small" | "medium" | "large";
|
||||
labelVariant?: "default" | "horizontal";
|
||||
state?: "default" | "active" | "hover" | "focus";
|
||||
disabled?: boolean;
|
||||
error?: boolean;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
value?: string;
|
||||
onChange?: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
onFocus?: (e: React.FocusEvent<HTMLTextAreaElement>) => void;
|
||||
onBlur?: (e: React.FocusEvent<HTMLTextAreaElement>) => void;
|
||||
className?: string;
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
|
||||
(
|
||||
{
|
||||
size = "medium",
|
||||
@@ -22,14 +42,22 @@ const TextArea = forwardRef(
|
||||
rows,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
ref
|
||||
) => {
|
||||
// Generate unique ID for accessibility if not provided
|
||||
const generatedId = useId();
|
||||
const textareaId = id || `textarea-${generatedId}`;
|
||||
|
||||
// Size variants with specific heights and radius for TextArea
|
||||
const sizeStyles = {
|
||||
const sizeStyles: Record<
|
||||
string,
|
||||
{
|
||||
textarea: string;
|
||||
label: string;
|
||||
container: string;
|
||||
radius: string;
|
||||
}
|
||||
> = {
|
||||
small: {
|
||||
textarea:
|
||||
labelVariant === "horizontal"
|
||||
@@ -57,7 +85,10 @@ const TextArea = forwardRef(
|
||||
};
|
||||
|
||||
// State styles
|
||||
const getStateStyles = () => {
|
||||
const getStateStyles = (): {
|
||||
textarea: string;
|
||||
label: string;
|
||||
} => {
|
||||
if (disabled) {
|
||||
return {
|
||||
textarea:
|
||||
@@ -125,30 +156,30 @@ const TextArea = forwardRef(
|
||||
`.trim();
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e) => {
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
if (!disabled && onChange) {
|
||||
onChange(e);
|
||||
}
|
||||
},
|
||||
[disabled, onChange],
|
||||
[disabled, onChange]
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(
|
||||
(e) => {
|
||||
(e: React.FocusEvent<HTMLTextAreaElement>) => {
|
||||
if (!disabled && onFocus) {
|
||||
onFocus(e);
|
||||
}
|
||||
},
|
||||
[disabled, onFocus],
|
||||
[disabled, onFocus]
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(
|
||||
(e) => {
|
||||
(e: React.FocusEvent<HTMLTextAreaElement>) => {
|
||||
if (!disabled && onBlur) {
|
||||
onBlur(e);
|
||||
}
|
||||
},
|
||||
[disabled, onBlur],
|
||||
[disabled, onBlur]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -182,7 +213,7 @@ const TextArea = forwardRef(
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
TextArea.displayName = "TextArea";
|
||||
@@ -1,6 +1,26 @@
|
||||
import React, { memo, useCallback, useId, forwardRef } from "react";
|
||||
|
||||
const Toggle = forwardRef(
|
||||
interface ToggleProps
|
||||
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
|
||||
label?: string;
|
||||
checked?: boolean;
|
||||
onChange?: (
|
||||
e:
|
||||
| React.MouseEvent<HTMLButtonElement>
|
||||
| React.KeyboardEvent<HTMLButtonElement>
|
||||
) => void;
|
||||
onFocus?: (e: React.FocusEvent<HTMLButtonElement>) => void;
|
||||
onBlur?: (e: React.FocusEvent<HTMLButtonElement>) => void;
|
||||
disabled?: boolean;
|
||||
state?: "default" | "hover" | "focus";
|
||||
showIcon?: boolean;
|
||||
showText?: boolean;
|
||||
icon?: string;
|
||||
text?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Toggle = forwardRef<HTMLButtonElement, ToggleProps>(
|
||||
(
|
||||
{
|
||||
label,
|
||||
@@ -17,7 +37,7 @@ const Toggle = forwardRef(
|
||||
className = "",
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
ref
|
||||
) => {
|
||||
const toggleId = useId();
|
||||
const labelId = useId();
|
||||
@@ -29,7 +49,10 @@ const Toggle = forwardRef(
|
||||
};
|
||||
|
||||
// State styles
|
||||
const getStateStyles = () => {
|
||||
const getStateStyles = (): {
|
||||
toggle: string;
|
||||
label: string;
|
||||
} => {
|
||||
if (disabled) {
|
||||
return {
|
||||
toggle:
|
||||
@@ -115,34 +138,38 @@ const Toggle = forwardRef(
|
||||
.replace(/\s+/g, " ");
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e) => {
|
||||
(
|
||||
e:
|
||||
| React.MouseEvent<HTMLButtonElement>
|
||||
| React.KeyboardEvent<HTMLButtonElement>
|
||||
) => {
|
||||
if (!disabled && onChange) {
|
||||
onChange(e);
|
||||
}
|
||||
},
|
||||
[disabled, onChange],
|
||||
[disabled, onChange]
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(
|
||||
(e) => {
|
||||
(e: React.FocusEvent<HTMLButtonElement>) => {
|
||||
if (!disabled && onFocus) {
|
||||
onFocus(e);
|
||||
}
|
||||
},
|
||||
[disabled, onFocus],
|
||||
[disabled, onFocus]
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(
|
||||
(e) => {
|
||||
(e: React.FocusEvent<HTMLButtonElement>) => {
|
||||
if (!disabled && onBlur) {
|
||||
onBlur(e);
|
||||
}
|
||||
},
|
||||
[disabled, onBlur],
|
||||
[disabled, onBlur]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e) => {
|
||||
(e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (!disabled && (e.key === "Enter" || e.key === " ")) {
|
||||
e.preventDefault();
|
||||
if (onChange) {
|
||||
@@ -150,7 +177,7 @@ const Toggle = forwardRef(
|
||||
}
|
||||
}
|
||||
},
|
||||
[disabled, onChange],
|
||||
[disabled, onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -186,7 +213,7 @@ const Toggle = forwardRef(
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
Toggle.displayName = "Toggle";
|
||||
@@ -1,7 +1,24 @@
|
||||
import React, { memo, useCallback, useId, forwardRef } from "react";
|
||||
|
||||
interface ToggleGroupProps
|
||||
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
position?: "left" | "middle" | "right";
|
||||
state?: "default" | "hover" | "focus" | "selected";
|
||||
showText?: boolean;
|
||||
ariaLabel?: string;
|
||||
onChange?: (
|
||||
e:
|
||||
| React.MouseEvent<HTMLButtonElement>
|
||||
| React.KeyboardEvent<HTMLButtonElement>
|
||||
) => void;
|
||||
onFocus?: (e: React.FocusEvent<HTMLButtonElement>) => void;
|
||||
onBlur?: (e: React.FocusEvent<HTMLButtonElement>) => void;
|
||||
}
|
||||
|
||||
const ToggleGroup = memo(
|
||||
forwardRef((props, ref) => {
|
||||
forwardRef<HTMLButtonElement, ToggleGroupProps>((props, ref) => {
|
||||
const {
|
||||
children,
|
||||
className = "",
|
||||
@@ -18,7 +35,7 @@ const ToggleGroup = memo(
|
||||
const groupId = useId();
|
||||
|
||||
// Position-based styling for border radius
|
||||
const getPositionStyles = useCallback((pos) => {
|
||||
const getPositionStyles = useCallback((pos: string): string => {
|
||||
switch (pos) {
|
||||
case "left":
|
||||
return "rounded-l-[var(--measures-radius-medium)] rounded-r-none";
|
||||
@@ -32,7 +49,7 @@ const ToggleGroup = memo(
|
||||
}, []);
|
||||
|
||||
// State-based styling
|
||||
const getStateStyles = useCallback((state) => {
|
||||
const getStateStyles = useCallback((state: string): string => {
|
||||
switch (state) {
|
||||
case "hover":
|
||||
return "bg-[var(--color-magenta-magenta100)] text-[var(--color-content-default-primary)]";
|
||||
@@ -50,34 +67,34 @@ const ToggleGroup = memo(
|
||||
const stateStyles = getStateStyles(state);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e) => {
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (onChange) {
|
||||
onChange(e);
|
||||
}
|
||||
},
|
||||
[onChange],
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(
|
||||
(e) => {
|
||||
(e: React.FocusEvent<HTMLButtonElement>) => {
|
||||
if (onFocus) {
|
||||
onFocus(e);
|
||||
}
|
||||
},
|
||||
[onFocus],
|
||||
[onFocus]
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(
|
||||
(e) => {
|
||||
(e: React.FocusEvent<HTMLButtonElement>) => {
|
||||
if (onBlur) {
|
||||
onBlur(e);
|
||||
}
|
||||
},
|
||||
[onBlur],
|
||||
[onBlur]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e) => {
|
||||
(e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
if (onChange) {
|
||||
@@ -85,7 +102,7 @@ const ToggleGroup = memo(
|
||||
}
|
||||
}
|
||||
},
|
||||
[onChange],
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const toggleClasses = `
|
||||
@@ -129,7 +146,7 @@ const ToggleGroup = memo(
|
||||
{showText ? children : children || "☰"}
|
||||
</button>
|
||||
);
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
ToggleGroup.displayName = "ToggleGroup";
|
||||
@@ -2,8 +2,36 @@
|
||||
|
||||
import React, { useState, useEffect, memo } from "react";
|
||||
|
||||
interface VitalData {
|
||||
value: number;
|
||||
rating: "good" | "needs-improvement" | "poor" | "unknown";
|
||||
}
|
||||
|
||||
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({
|
||||
const [vitals, setVitals] = useState<Vitals>({
|
||||
lcp: { value: 0, rating: "unknown" },
|
||||
fid: { value: 0, rating: "unknown" },
|
||||
cls: { value: 0, rating: "unknown" },
|
||||
@@ -11,7 +39,7 @@ const WebVitalsDashboard = memo(() => {
|
||||
ttfb: { value: 0, rating: "unknown" },
|
||||
});
|
||||
|
||||
const [metrics, setMetrics] = useState({});
|
||||
const [metrics, setMetrics] = useState<Metrics>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -19,7 +47,7 @@ const WebVitalsDashboard = memo(() => {
|
||||
const fetchVitals = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/web-vitals");
|
||||
const data = await response.json();
|
||||
const data = (await response.json()) as { metrics?: Metrics };
|
||||
setMetrics(data.metrics || {});
|
||||
} catch (error) {
|
||||
console.error("Error fetching web vitals:", error);
|
||||
@@ -88,12 +116,12 @@ const WebVitalsDashboard = memo(() => {
|
||||
},
|
||||
}));
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getRatingColor = (rating) => {
|
||||
const getRatingColor = (rating: string): string => {
|
||||
switch (rating) {
|
||||
case "good":
|
||||
return "text-green-600 bg-green-50";
|
||||
@@ -106,7 +134,7 @@ const WebVitalsDashboard = memo(() => {
|
||||
}
|
||||
};
|
||||
|
||||
const getRatingIcon = (rating) => {
|
||||
const getRatingIcon = (rating: string): string => {
|
||||
switch (rating) {
|
||||
case "good":
|
||||
return "✅";
|
||||
@@ -119,7 +147,7 @@ const WebVitalsDashboard = memo(() => {
|
||||
}
|
||||
};
|
||||
|
||||
const formatValue = (metric, value) => {
|
||||
const formatValue = (metric: string, value: number): string => {
|
||||
if (metric === "cls") {
|
||||
return value.toFixed(3);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Inter, Bricolage_Grotesque, Space_Grotesk } from "next/font/google";
|
||||
import type { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import "./globals.css";
|
||||
import Header from "./components/Header";
|
||||
import HomeHeader from "./components/HomeHeader";
|
||||
@@ -32,7 +34,7 @@ const spaceGrotesk = Space_Grotesk({
|
||||
fallback: ["system-ui", "arial"],
|
||||
});
|
||||
|
||||
export const metadata = {
|
||||
export const metadata: Metadata = {
|
||||
title: "CommunityRule - Build operating manuals for successful communities",
|
||||
description:
|
||||
"Help your community make important decisions in a way that reflects its unique values.",
|
||||
@@ -77,7 +79,7 @@ export const metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }) {
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en" className="font-sans">
|
||||
<head>
|
||||
@@ -6,10 +6,10 @@
|
||||
|
||||
/**
|
||||
* Get the correct asset path based on environment
|
||||
* @param {string} assetPath - The asset path (e.g., "assets/Logo.svg")
|
||||
* @returns {string} - The correct path for the current environment
|
||||
* @param assetPath - The asset path (e.g., "assets/Logo.svg")
|
||||
* @returns The correct path for the current environment
|
||||
*/
|
||||
export function getAssetPath(assetPath) {
|
||||
export function getAssetPath(assetPath: string): string {
|
||||
// Check if we're in Storybook environment
|
||||
const isStorybook =
|
||||
typeof window !== "undefined" &&
|
||||
@@ -55,4 +55,4 @@ export const ASSETS = {
|
||||
// Content page decorative shapes
|
||||
CONTENT_SHAPE_1: "assets/Content_Shape_1.svg",
|
||||
CONTENT_SHAPE_2: "assets/Content_Shape_2.svg",
|
||||
};
|
||||
} as const;
|
||||
+67
-50
@@ -3,10 +3,10 @@
|
||||
*/
|
||||
|
||||
// In-memory cache for blog posts
|
||||
const blogPostCache = new Map();
|
||||
const blogListCache = new Map();
|
||||
const tagCache = new Map();
|
||||
const authorCache = new Map();
|
||||
const blogPostCache = new Map<string, CacheEntry<unknown>>();
|
||||
const blogListCache = new Map<string, CacheEntry<unknown[]>>();
|
||||
const tagCache = new Map<string, CacheEntry<string[]>>();
|
||||
const authorCache = new Map<string, CacheEntry<string[]>>();
|
||||
|
||||
// Cache configuration
|
||||
const isDevelopment =
|
||||
@@ -17,13 +17,16 @@ const MAX_CACHE_SIZE = 100; // Maximum number of cached items
|
||||
/**
|
||||
* Cache entry with timestamp
|
||||
*/
|
||||
class CacheEntry {
|
||||
constructor(data) {
|
||||
class CacheEntry<T> {
|
||||
data: T;
|
||||
timestamp: number;
|
||||
|
||||
constructor(data: T) {
|
||||
this.data = data;
|
||||
this.timestamp = Date.now();
|
||||
}
|
||||
|
||||
isExpired() {
|
||||
isExpired(): boolean {
|
||||
// In development, always consider cache expired (no caching)
|
||||
if (isDevelopment) return true;
|
||||
return Date.now() - this.timestamp > CACHE_TTL;
|
||||
@@ -32,11 +35,11 @@ class CacheEntry {
|
||||
|
||||
/**
|
||||
* Get cached blog post data
|
||||
* @param {string} key - Cache key
|
||||
* @returns {Object|null} Cached data or null if not found/expired
|
||||
* @param key - Cache key
|
||||
* @returns Cached data or null if not found/expired
|
||||
*/
|
||||
function getCached(key) {
|
||||
const entry = blogPostCache.get(key);
|
||||
function getCached<T>(key: string): T | null {
|
||||
const entry = blogPostCache.get(key) as CacheEntry<T> | undefined;
|
||||
if (!entry || entry.isExpired()) {
|
||||
blogPostCache.delete(key);
|
||||
return null;
|
||||
@@ -46,10 +49,10 @@ function getCached(key) {
|
||||
|
||||
/**
|
||||
* Set cached blog post data
|
||||
* @param {string} key - Cache key
|
||||
* @param {Object} data - Data to cache
|
||||
* @param key - Cache key
|
||||
* @param data - Data to cache
|
||||
*/
|
||||
function setCached(key, data) {
|
||||
function setCached<T>(key: string, data: T): void {
|
||||
// Implement LRU eviction if cache is full
|
||||
if (blogPostCache.size >= MAX_CACHE_SIZE) {
|
||||
const oldestKey = blogPostCache.keys().next().value;
|
||||
@@ -62,7 +65,7 @@ function setCached(key, data) {
|
||||
/**
|
||||
* Clear expired cache entries
|
||||
*/
|
||||
function clearExpiredCache() {
|
||||
function clearExpiredCache(): void {
|
||||
for (const [key, entry] of blogPostCache.entries()) {
|
||||
if (entry.isExpired()) {
|
||||
blogPostCache.delete(key);
|
||||
@@ -73,7 +76,7 @@ function clearExpiredCache() {
|
||||
/**
|
||||
* Clear all caches
|
||||
*/
|
||||
export function clearAllCaches() {
|
||||
export function clearAllCaches(): void {
|
||||
blogPostCache.clear();
|
||||
blogListCache.clear();
|
||||
tagCache.clear();
|
||||
@@ -82,50 +85,50 @@ export function clearAllCaches() {
|
||||
|
||||
/**
|
||||
* Get cached blog post by slug
|
||||
* @param {string} slug - Blog post slug
|
||||
* @returns {Object|null} Cached blog post or null
|
||||
* @param slug - Blog post slug
|
||||
* @returns Cached blog post or null
|
||||
*/
|
||||
export function getCachedBlogPost(slug) {
|
||||
return getCached(`post:${slug}`);
|
||||
export function getCachedBlogPost<T>(slug: string): T | null {
|
||||
return getCached<T>(`post:${slug}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache blog post data
|
||||
* @param {string} slug - Blog post slug
|
||||
* @param {Object} postData - Blog post data
|
||||
* @param slug - Blog post slug
|
||||
* @param postData - Blog post data
|
||||
*/
|
||||
export function cacheBlogPost(slug, postData) {
|
||||
export function cacheBlogPost<T>(slug: string, postData: T): void {
|
||||
setCached(`post:${slug}`, postData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached blog post list
|
||||
* @param {string} key - Cache key for list (e.g., 'all', 'recent', 'tag:governance')
|
||||
* @returns {Array|null} Cached list or null
|
||||
* @param key - Cache key for list (e.g., 'all', 'recent', 'tag:governance')
|
||||
* @returns Cached list or null
|
||||
*/
|
||||
export function getCachedBlogList(key) {
|
||||
export function getCachedBlogList<T>(key: string): T[] | null {
|
||||
const entry = blogListCache.get(key);
|
||||
if (!entry || entry.isExpired()) {
|
||||
blogListCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return entry.data;
|
||||
return entry.data as T[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache blog post list
|
||||
* @param {string} key - Cache key
|
||||
* @param {Array} listData - List data to cache
|
||||
* @param key - Cache key
|
||||
* @param listData - List data to cache
|
||||
*/
|
||||
export function cacheBlogList(key, listData) {
|
||||
export function cacheBlogList<T>(key: string, listData: T[]): void {
|
||||
blogListCache.set(key, new CacheEntry(listData));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached tags
|
||||
* @returns {Array|null} Cached tags or null
|
||||
* @returns Cached tags or null
|
||||
*/
|
||||
export function getCachedTags() {
|
||||
export function getCachedTags(): string[] | null {
|
||||
const entry = tagCache.get("all");
|
||||
if (!entry || entry.isExpired()) {
|
||||
tagCache.delete("all");
|
||||
@@ -136,17 +139,17 @@ export function getCachedTags() {
|
||||
|
||||
/**
|
||||
* Cache tags
|
||||
* @param {Array} tags - Tags to cache
|
||||
* @param tags - Tags to cache
|
||||
*/
|
||||
export function cacheTags(tags) {
|
||||
export function cacheTags(tags: string[]): void {
|
||||
tagCache.set("all", new CacheEntry(tags));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached authors
|
||||
* @returns {Array|null} Cached authors or null
|
||||
* @returns Cached authors or null
|
||||
*/
|
||||
export function getCachedAuthors() {
|
||||
export function getCachedAuthors(): string[] | null {
|
||||
const entry = authorCache.get("all");
|
||||
if (!entry || entry.isExpired()) {
|
||||
authorCache.delete("all");
|
||||
@@ -157,17 +160,17 @@ export function getCachedAuthors() {
|
||||
|
||||
/**
|
||||
* Cache authors
|
||||
* @param {Array} authors - Authors to cache
|
||||
* @param authors - Authors to cache
|
||||
*/
|
||||
export function cacheAuthors(authors) {
|
||||
export function cacheAuthors(authors: string[]): void {
|
||||
authorCache.set("all", new CacheEntry(authors));
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cache for a specific blog post
|
||||
* @param {string} slug - Blog post slug
|
||||
* @param slug - Blog post slug
|
||||
*/
|
||||
export function invalidateBlogPostCache(slug) {
|
||||
export function invalidateBlogPostCache(slug: string): void {
|
||||
blogPostCache.delete(`post:${slug}`);
|
||||
// Also invalidate list caches since they might contain this post
|
||||
blogListCache.clear();
|
||||
@@ -176,15 +179,25 @@ export function invalidateBlogPostCache(slug) {
|
||||
/**
|
||||
* Invalidate all caches
|
||||
*/
|
||||
export function invalidateAllCaches() {
|
||||
export function invalidateAllCaches(): void {
|
||||
clearAllCaches();
|
||||
}
|
||||
|
||||
export interface CacheStats {
|
||||
blogPostCacheSize: number;
|
||||
blogListCacheSize: number;
|
||||
tagCacheSize: number;
|
||||
authorCacheSize: number;
|
||||
totalCacheSize: number;
|
||||
maxCacheSize: number;
|
||||
cacheTTL: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
* @returns {Object} Cache statistics
|
||||
* @returns Cache statistics
|
||||
*/
|
||||
export function getCacheStats() {
|
||||
export function getCacheStats(): CacheStats {
|
||||
clearExpiredCache();
|
||||
|
||||
return {
|
||||
@@ -193,7 +206,7 @@ export function getCacheStats() {
|
||||
tagCacheSize: tagCache.size,
|
||||
authorCacheSize: authorCache.size,
|
||||
totalCacheSize:
|
||||
blogPostCache.size + blogListCache.size + tagCache.size + authorCacheSize,
|
||||
blogPostCache.size + blogListCache.size + tagCache.size + authorCache.size,
|
||||
maxCacheSize: MAX_CACHE_SIZE,
|
||||
cacheTTL: CACHE_TTL,
|
||||
};
|
||||
@@ -201,10 +214,13 @@ export function getCacheStats() {
|
||||
|
||||
/**
|
||||
* Warm up cache with frequently accessed data
|
||||
* @param {Function} getAllPosts - Function to get all blog posts
|
||||
* @param {Function} getAllTags - Function to get all tags
|
||||
* @param getAllPosts - Function to get all blog posts
|
||||
* @param getAllTags - Function to get all tags
|
||||
*/
|
||||
export async function warmCache(getAllPosts, getAllTags) {
|
||||
export async function warmCache<T>(
|
||||
getAllPosts: () => T[],
|
||||
getAllTags: () => string[],
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Cache all blog posts
|
||||
const allPosts = getAllPosts();
|
||||
@@ -220,7 +236,8 @@ export async function warmCache(getAllPosts, getAllTags) {
|
||||
|
||||
// Cache individual posts (first 10)
|
||||
allPosts.slice(0, 10).forEach((post) => {
|
||||
cacheBlogPost(post.slug, post);
|
||||
const postWithSlug = post as { slug: string };
|
||||
cacheBlogPost(postWithSlug.slug, post);
|
||||
});
|
||||
|
||||
console.log("Cache warmed up successfully");
|
||||
@@ -231,9 +248,9 @@ export async function warmCache(getAllPosts, getAllTags) {
|
||||
|
||||
/**
|
||||
* Check if cache is healthy
|
||||
* @returns {boolean} True if cache is healthy
|
||||
* @returns True if cache is healthy
|
||||
*/
|
||||
export function isCacheHealthy() {
|
||||
export function isCacheHealthy(): boolean {
|
||||
try {
|
||||
clearExpiredCache();
|
||||
return blogPostCache.size < MAX_CACHE_SIZE;
|
||||
@@ -1,18 +1,31 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import matter from "gray-matter";
|
||||
import { validateBlogPost, sanitizeBlogPost } from "./validation.js";
|
||||
import {
|
||||
validateBlogPost,
|
||||
sanitizeBlogPost,
|
||||
type BlogPostFrontmatter,
|
||||
} from "./validation";
|
||||
|
||||
/**
|
||||
* Content processing utilities for blog posts
|
||||
*/
|
||||
|
||||
export interface BlogPost {
|
||||
slug: string;
|
||||
frontmatter: BlogPostFrontmatter;
|
||||
content: string;
|
||||
htmlContent: string;
|
||||
filePath: string;
|
||||
lastModified: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a URL-friendly slug from a string
|
||||
* @param {string} text - Text to convert to slug
|
||||
* @returns {string} URL-friendly slug
|
||||
* @param text - Text to convert to slug
|
||||
* @returns URL-friendly slug
|
||||
*/
|
||||
function generateSlug(text) {
|
||||
function generateSlug(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, "") // Remove special characters
|
||||
@@ -23,9 +36,9 @@ function generateSlug(text) {
|
||||
|
||||
/**
|
||||
* Get all blog post files from the content directory
|
||||
* @returns {Array} Array of file paths
|
||||
* @returns Array of file paths
|
||||
*/
|
||||
export function markdownToHtml(markdown) {
|
||||
export function markdownToHtml(markdown: string): string {
|
||||
if (!markdown) return "";
|
||||
|
||||
return (
|
||||
@@ -54,13 +67,13 @@ export function markdownToHtml(markdown) {
|
||||
);
|
||||
}
|
||||
|
||||
export function getBlogPostFiles() {
|
||||
export function getBlogPostFiles(): string[] {
|
||||
const contentDirectory = path.join(process.cwd(), "content/blog");
|
||||
|
||||
try {
|
||||
const files = fs.readdirSync(contentDirectory);
|
||||
return files.filter(
|
||||
(file) => file.endsWith(".md") || file.endsWith(".mdx"),
|
||||
(file) => file.endsWith(".md") || file.endsWith(".mdx")
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error reading blog content directory:", error);
|
||||
@@ -70,10 +83,10 @@ export function getBlogPostFiles() {
|
||||
|
||||
/**
|
||||
* Parse a single blog post file
|
||||
* @param {string} filePath - Path to the markdown file
|
||||
* @returns {Object|null} Parsed blog post data or null if invalid
|
||||
* @param filePath - Path to the markdown file
|
||||
* @returns Parsed blog post data or null if invalid
|
||||
*/
|
||||
export function parseBlogPost(filePath) {
|
||||
export function parseBlogPost(filePath: string): BlogPost | null {
|
||||
const fullPath = path.join(process.cwd(), "content/blog", filePath);
|
||||
|
||||
try {
|
||||
@@ -84,7 +97,7 @@ export function parseBlogPost(filePath) {
|
||||
if (!validationResult.isValid) {
|
||||
console.error(
|
||||
`Validation errors for ${filePath}:`,
|
||||
validationResult.errors,
|
||||
validationResult.errors
|
||||
);
|
||||
return null;
|
||||
}
|
||||
@@ -108,51 +121,53 @@ export function parseBlogPost(filePath) {
|
||||
|
||||
/**
|
||||
* Get all blog posts, sorted by date
|
||||
* @returns {Array} Array of parsed blog post objects
|
||||
* @returns Array of parsed blog post objects
|
||||
*/
|
||||
export function getAllBlogPosts() {
|
||||
export function getAllBlogPosts(): BlogPost[] {
|
||||
const fileNames = getBlogPostFiles();
|
||||
const allPosts = fileNames
|
||||
.map((fileName) => parseBlogPost(fileName))
|
||||
.filter(Boolean) // Filter out nulls (invalid posts)
|
||||
.filter((post): post is BlogPost => post !== null) // Filter out nulls (invalid posts)
|
||||
.sort(
|
||||
(a, b) => new Date(b.frontmatter.date) - new Date(a.frontmatter.date),
|
||||
(a, b) =>
|
||||
new Date(b.frontmatter.date).getTime() -
|
||||
new Date(a.frontmatter.date).getTime()
|
||||
); // Sort by date descending
|
||||
return allPosts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single blog post by its slug
|
||||
* @param {string} slug - The slug of the blog post
|
||||
* @returns {Object|null} The parsed blog post data or null if not found
|
||||
* @param slug - The slug of the blog post
|
||||
* @returns The parsed blog post data or null if not found
|
||||
*/
|
||||
export function getBlogPostBySlug(slug) {
|
||||
export function getBlogPostBySlug(slug: string): BlogPost | null {
|
||||
const allPosts = getAllBlogPosts();
|
||||
return allPosts.find((post) => post.slug === slug) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get related blog posts based on provided slugs or fallback to recent posts.
|
||||
* @param {string} currentPostSlug - The slug of the current post to exclude.
|
||||
* @param {string[]} relatedSlugs - Array of slugs for explicitly related posts.
|
||||
* @param {number} limit - Maximum number of related posts to return.
|
||||
* @returns {Array} Array of related blog post objects.
|
||||
* @param currentPostSlug - The slug of the current post to exclude.
|
||||
* @param relatedSlugs - Array of slugs for explicitly related posts.
|
||||
* @param limit - Maximum number of related posts to return.
|
||||
* @returns Array of related blog post objects.
|
||||
*/
|
||||
export function getRelatedBlogPosts(
|
||||
currentPostSlug,
|
||||
relatedSlugs = [],
|
||||
limit = 3,
|
||||
) {
|
||||
currentPostSlug: string,
|
||||
relatedSlugs: string[] = [],
|
||||
limit: number = 3
|
||||
): BlogPost[] {
|
||||
const allPosts = getAllBlogPosts();
|
||||
const filteredPosts = allPosts.filter(
|
||||
(post) => post.slug !== currentPostSlug,
|
||||
(post) => post.slug !== currentPostSlug
|
||||
);
|
||||
|
||||
let related = [];
|
||||
let related: BlogPost[] = [];
|
||||
if (relatedSlugs && relatedSlugs.length > 0) {
|
||||
related = relatedSlugs
|
||||
.map((slug) => filteredPosts.find((post) => post.slug === slug))
|
||||
.filter(Boolean); // Filter out any related slugs that don't exist
|
||||
.filter((post): post is BlogPost => post !== undefined); // Filter out any related slugs that don't exist
|
||||
}
|
||||
|
||||
// If not enough related posts, or no related slugs provided, fill with recent posts
|
||||
@@ -170,11 +185,11 @@ export function getRelatedBlogPosts(
|
||||
|
||||
/**
|
||||
* Get all unique tags from all blog posts.
|
||||
* @returns {string[]} Array of unique tags.
|
||||
* @returns Array of unique tags.
|
||||
*/
|
||||
export function getAllTags() {
|
||||
export function getAllTags(): string[] {
|
||||
const allPosts = getAllBlogPosts();
|
||||
const tags = new Set();
|
||||
const tags = new Set<string>();
|
||||
allPosts.forEach((post) => {
|
||||
if (post.frontmatter.tags) {
|
||||
post.frontmatter.tags.forEach((tag) => tags.add(tag));
|
||||
@@ -185,23 +200,23 @@ export function getAllTags() {
|
||||
|
||||
/**
|
||||
* Get blog posts filtered by a specific tag.
|
||||
* @param {string} tag - The tag to filter by.
|
||||
* @returns {Object[]} Array of blog post objects matching the tag.
|
||||
* @param tag - The tag to filter by.
|
||||
* @returns Array of blog post objects matching the tag.
|
||||
*/
|
||||
export function getBlogPostsByTag(tag) {
|
||||
export function getBlogPostsByTag(tag: string): BlogPost[] {
|
||||
const allPosts = getAllBlogPosts();
|
||||
return allPosts.filter(
|
||||
(post) => post.frontmatter.tags && post.frontmatter.tags.includes(tag),
|
||||
(post) => post.frontmatter.tags && post.frontmatter.tags.includes(tag)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search blog posts by text content
|
||||
* @param {string} query - Search query
|
||||
* @param {number} limit - Maximum number of results
|
||||
* @returns {Object[]} Array of matching blog post objects
|
||||
* @param query - Search query
|
||||
* @param limit - Maximum number of results
|
||||
* @returns Array of matching blog post objects
|
||||
*/
|
||||
export function searchBlogPosts(query, limit = 10) {
|
||||
export function searchBlogPosts(query: string, limit: number = 10): BlogPost[] {
|
||||
if (!query || query.trim() === "") return [];
|
||||
|
||||
const searchTerm = query.toLowerCase().trim();
|
||||
@@ -216,7 +231,7 @@ export function searchBlogPosts(query, limit = 10) {
|
||||
.includes(searchTerm);
|
||||
const contentMatch = post.content.toLowerCase().includes(searchTerm);
|
||||
const tagMatch = post.frontmatter.tags?.some((tag) =>
|
||||
tag.toLowerCase().includes(searchTerm),
|
||||
tag.toLowerCase().includes(searchTerm)
|
||||
);
|
||||
|
||||
return titleMatch || descriptionMatch || contentMatch || tagMatch;
|
||||
@@ -227,31 +242,42 @@ export function searchBlogPosts(query, limit = 10) {
|
||||
|
||||
/**
|
||||
* Get blog posts by author
|
||||
* @param {string} author - Author name to filter by
|
||||
* @returns {Object[]} Array of blog post objects by the author
|
||||
* @param author - Author name to filter by
|
||||
* @returns Array of blog post objects by the author
|
||||
*/
|
||||
export function getBlogPostsByAuthor(author) {
|
||||
export function getBlogPostsByAuthor(author: string): BlogPost[] {
|
||||
const allPosts = getAllBlogPosts();
|
||||
return allPosts.filter(
|
||||
(post) => post.frontmatter.author.toLowerCase() === author.toLowerCase(),
|
||||
(post) => post.frontmatter.author.toLowerCase() === author.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent blog posts
|
||||
* @param {number} limit - Maximum number of posts to return
|
||||
* @returns {Object[]} Array of recent blog post objects
|
||||
* @param limit - Maximum number of posts to return
|
||||
* @returns Array of recent blog post objects
|
||||
*/
|
||||
export function getRecentBlogPosts(limit = 5) {
|
||||
export function getRecentBlogPosts(limit: number = 5): BlogPost[] {
|
||||
const allPosts = getAllBlogPosts();
|
||||
return allPosts.slice(0, limit);
|
||||
}
|
||||
|
||||
export interface BlogStats {
|
||||
totalPosts: number;
|
||||
totalTags: number;
|
||||
totalAuthors: number;
|
||||
dateRange: {
|
||||
earliest: string | null;
|
||||
latest: string | null;
|
||||
};
|
||||
averagePostsPerMonth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get blog post statistics
|
||||
* @returns {Object} Statistics about blog posts
|
||||
* @returns Statistics about blog posts
|
||||
*/
|
||||
export function getBlogStats() {
|
||||
export function getBlogStats(): BlogStats {
|
||||
const allPosts = getAllBlogPosts();
|
||||
const tags = getAllTags();
|
||||
|
||||
@@ -272,11 +298,13 @@ export function getBlogStats() {
|
||||
(allPosts.length /
|
||||
Math.max(
|
||||
1,
|
||||
(new Date(allPosts[0].frontmatter.date) -
|
||||
new Date(allPosts[allPosts.length - 1].frontmatter.date)) /
|
||||
(1000 * 60 * 60 * 24 * 30),
|
||||
(new Date(allPosts[0].frontmatter.date).getTime() -
|
||||
new Date(
|
||||
allPosts[allPosts.length - 1].frontmatter.date
|
||||
).getTime()) /
|
||||
(1000 * 60 * 60 * 24 * 30)
|
||||
)) *
|
||||
10,
|
||||
10
|
||||
) / 10
|
||||
: 0,
|
||||
};
|
||||
+79
-40
@@ -2,11 +2,47 @@
|
||||
* MDX processing utilities for enhanced markdown content
|
||||
*/
|
||||
|
||||
export interface Heading {
|
||||
level: number;
|
||||
text: string;
|
||||
id: string;
|
||||
line: number;
|
||||
}
|
||||
|
||||
export interface Link {
|
||||
text: string;
|
||||
url: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export interface Image {
|
||||
alt: string;
|
||||
src: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export interface ProcessedMarkdown {
|
||||
content: string;
|
||||
htmlContent: string;
|
||||
headings: Heading[];
|
||||
links: Link[];
|
||||
images: Image[];
|
||||
}
|
||||
|
||||
export interface ProcessedFrontmatter {
|
||||
publishedDate: Date;
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
isRecent: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date consistently across the markdown pipeline
|
||||
* Uses "Month Year" format (e.g., "April 2025")
|
||||
*/
|
||||
export function formatDate(dateString) {
|
||||
export function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
@@ -16,10 +52,10 @@ export function formatDate(dateString) {
|
||||
|
||||
/**
|
||||
* Process markdown content and extract metadata
|
||||
* @param {string} markdown - Raw markdown content
|
||||
* @returns {object} Processed content with metadata
|
||||
* @param markdown - Raw markdown content
|
||||
* @returns Processed content with metadata
|
||||
*/
|
||||
export function processMarkdown(markdown) {
|
||||
export function processMarkdown(markdown: string): ProcessedMarkdown {
|
||||
if (!markdown) {
|
||||
return {
|
||||
content: "",
|
||||
@@ -53,13 +89,13 @@ export function processMarkdown(markdown) {
|
||||
|
||||
/**
|
||||
* Extract all headings from markdown content
|
||||
* @param {string} markdown - Raw markdown content
|
||||
* @returns {Array} Array of heading objects with level, text, and id
|
||||
* @param markdown - Raw markdown content
|
||||
* @returns Array of heading objects with level, text, and id
|
||||
*/
|
||||
function extractHeadings(markdown) {
|
||||
function extractHeadings(markdown: string): Heading[] {
|
||||
const headingRegex = /^(#{1,6})\s+(.+)$/gm;
|
||||
const headings = [];
|
||||
let match;
|
||||
const headings: Heading[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = headingRegex.exec(markdown)) !== null) {
|
||||
const level = match[1].length;
|
||||
@@ -79,13 +115,13 @@ function extractHeadings(markdown) {
|
||||
|
||||
/**
|
||||
* Extract all links from markdown content
|
||||
* @param {string} markdown - Raw markdown content
|
||||
* @returns {Array} Array of link objects
|
||||
* @param markdown - Raw markdown content
|
||||
* @returns Array of link objects
|
||||
*/
|
||||
function extractLinks(markdown) {
|
||||
function extractLinks(markdown: string): Link[] {
|
||||
const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
|
||||
const links = [];
|
||||
let match;
|
||||
const links: Link[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = linkRegex.exec(markdown)) !== null) {
|
||||
links.push({
|
||||
@@ -100,13 +136,13 @@ function extractLinks(markdown) {
|
||||
|
||||
/**
|
||||
* Extract all images from markdown content
|
||||
* @param {string} markdown - Raw markdown content
|
||||
* @returns {Array} Array of image objects
|
||||
* @param markdown - Raw markdown content
|
||||
* @returns Array of image objects
|
||||
*/
|
||||
function extractImages(markdown) {
|
||||
function extractImages(markdown: string): Image[] {
|
||||
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
||||
const images = [];
|
||||
let match;
|
||||
const images: Image[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = imageRegex.exec(markdown)) !== null) {
|
||||
images.push({
|
||||
@@ -121,10 +157,10 @@ function extractImages(markdown) {
|
||||
|
||||
/**
|
||||
* Generate a unique ID for a heading
|
||||
* @param {string} text - Heading text
|
||||
* @returns {string} Unique ID
|
||||
* @param text - Heading text
|
||||
* @returns Unique ID
|
||||
*/
|
||||
function generateHeadingId(text) {
|
||||
function generateHeadingId(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, "")
|
||||
@@ -137,10 +173,10 @@ function generateHeadingId(text) {
|
||||
* Convert markdown to HTML with enhanced formatting
|
||||
* - Preserves extra blank lines between paragraphs as visible gaps
|
||||
* (each extra blank line becomes <p class="md-gap"> </p>)
|
||||
* @param {string} markdown - Raw markdown content
|
||||
* @returns {string} HTML content
|
||||
* @param markdown - Raw markdown content
|
||||
* @returns HTML content
|
||||
*/
|
||||
function markdownToHtml(markdown) {
|
||||
function markdownToHtml(markdown: string): string {
|
||||
if (!markdown) return "";
|
||||
|
||||
// Normalize line endings
|
||||
@@ -266,10 +302,10 @@ function markdownToHtml(markdown) {
|
||||
|
||||
/**
|
||||
* Generate a table of contents from headings
|
||||
* @param {Array} headings - Array of heading objects
|
||||
* @returns {string} HTML table of contents
|
||||
* @param headings - Array of heading objects
|
||||
* @returns HTML table of contents
|
||||
*/
|
||||
export function generateTableOfContents(headings) {
|
||||
export function generateTableOfContents(headings: Heading[]): string {
|
||||
if (!headings || headings.length === 0) return "";
|
||||
|
||||
let toc = '<nav class="table-of-contents"><h4>Table of Contents</h4><ul>';
|
||||
@@ -285,18 +321,21 @@ export function generateTableOfContents(headings) {
|
||||
|
||||
/**
|
||||
* Process frontmatter with enhanced validation
|
||||
* @param {Object} frontmatter - Raw frontmatter data
|
||||
* @returns {Object} Processed and validated frontmatter
|
||||
* @param frontmatter - Raw frontmatter data
|
||||
* @returns Processed and validated frontmatter
|
||||
*/
|
||||
export function processFrontmatter(frontmatter) {
|
||||
export function processFrontmatter(
|
||||
frontmatter: Record<string, unknown>,
|
||||
): ProcessedFrontmatter {
|
||||
// Add computed fields
|
||||
const processed = {
|
||||
const date = frontmatter.date as string;
|
||||
const processed: ProcessedFrontmatter = {
|
||||
...frontmatter,
|
||||
publishedDate: new Date(frontmatter.date),
|
||||
year: new Date(frontmatter.date).getFullYear(),
|
||||
month: new Date(frontmatter.date).getMonth() + 1,
|
||||
day: new Date(frontmatter.date).getDate(),
|
||||
isRecent: isRecentPost(frontmatter.date),
|
||||
publishedDate: new Date(date),
|
||||
year: new Date(date).getFullYear(),
|
||||
month: new Date(date).getMonth() + 1,
|
||||
day: new Date(date).getDate(),
|
||||
isRecent: isRecentPost(date),
|
||||
};
|
||||
|
||||
return processed;
|
||||
@@ -304,10 +343,10 @@ export function processFrontmatter(frontmatter) {
|
||||
|
||||
/**
|
||||
* Check if a post is recent (within last 30 days)
|
||||
* @param {string} date - Post date string
|
||||
* @returns {boolean} True if post is recent
|
||||
* @param date - Post date string
|
||||
* @returns True if post is recent
|
||||
*/
|
||||
function isRecentPost(date) {
|
||||
function isRecentPost(date: string): boolean {
|
||||
const postDate = new Date(date);
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Shared type definitions for the CommunityRule application
|
||||
*/
|
||||
|
||||
// Re-export types from other modules for convenience
|
||||
export type {
|
||||
BlogPost,
|
||||
BlogStats,
|
||||
} from "./content";
|
||||
|
||||
export type {
|
||||
BlogPostFrontmatter,
|
||||
ValidationResult,
|
||||
} from "./validation";
|
||||
|
||||
export type {
|
||||
Heading,
|
||||
Link,
|
||||
Image,
|
||||
ProcessedMarkdown,
|
||||
ProcessedFrontmatter,
|
||||
} from "./mdx";
|
||||
|
||||
export type {
|
||||
CacheStats,
|
||||
} from "./cache";
|
||||
|
||||
// Additional shared types
|
||||
export interface ComponentProps {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface PageProps {
|
||||
params?: Record<string, string | string[]>;
|
||||
searchParams?: Record<string, string | string[]>;
|
||||
}
|
||||
@@ -2,10 +2,54 @@
|
||||
* Content validation utilities for blog posts
|
||||
*/
|
||||
|
||||
export interface BlogPostSchemaField {
|
||||
type: "string" | "array" | "object";
|
||||
required: boolean;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
pattern?: RegExp;
|
||||
default?: unknown;
|
||||
items?: {
|
||||
type: string;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
};
|
||||
properties?: Record<string, BlogPostSchemaField>;
|
||||
}
|
||||
|
||||
export interface BlogPostSchema {
|
||||
[key: string]: BlogPostSchemaField;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
isValid: boolean;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface BlogPostFrontmatter {
|
||||
title: string;
|
||||
description: string;
|
||||
author: string;
|
||||
date: string;
|
||||
related?: string[];
|
||||
thumbnail?: {
|
||||
vertical?: string;
|
||||
horizontal?: string;
|
||||
};
|
||||
banner?: {
|
||||
horizontal?: string;
|
||||
};
|
||||
background?: {
|
||||
color?: string;
|
||||
};
|
||||
tags?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blog post frontmatter schema
|
||||
*/
|
||||
export const BLOG_POST_SCHEMA = {
|
||||
export const BLOG_POST_SCHEMA: BlogPostSchema = {
|
||||
title: {
|
||||
type: "string",
|
||||
required: true,
|
||||
@@ -80,11 +124,13 @@ export const BLOG_POST_SCHEMA = {
|
||||
|
||||
/**
|
||||
* Validate a blog post's frontmatter
|
||||
* @param {Object} frontmatter - The frontmatter object to validate
|
||||
* @returns {Object} Validation result with isValid boolean and errors array
|
||||
* @param frontmatter - The frontmatter object to validate
|
||||
* @returns Validation result with isValid boolean and errors array
|
||||
*/
|
||||
export function validateBlogPost(frontmatter) {
|
||||
const errors = [];
|
||||
export function validateBlogPost(
|
||||
frontmatter: Record<string, unknown>,
|
||||
): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Check required fields first
|
||||
for (const [field, config] of Object.entries(BLOG_POST_SCHEMA)) {
|
||||
@@ -116,12 +162,13 @@ export function validateBlogPost(frontmatter) {
|
||||
|
||||
// Length validation for strings
|
||||
if (config.type === "string" && typeof frontmatter[field] === "string") {
|
||||
if (config.minLength && frontmatter[field].length < config.minLength) {
|
||||
const fieldValue = frontmatter[field] as string;
|
||||
if (config.minLength && fieldValue.length < config.minLength) {
|
||||
errors.push(
|
||||
`Field ${field} must be at least ${config.minLength} characters`,
|
||||
);
|
||||
}
|
||||
if (config.maxLength && frontmatter[field].length > config.maxLength) {
|
||||
if (config.maxLength && fieldValue.length > config.maxLength) {
|
||||
errors.push(
|
||||
`Field ${field} must be no more than ${config.maxLength} characters`,
|
||||
);
|
||||
@@ -129,23 +176,38 @@ export function validateBlogPost(frontmatter) {
|
||||
}
|
||||
|
||||
// Pattern validation
|
||||
if (config.pattern && !config.pattern.test(frontmatter[field])) {
|
||||
if (
|
||||
config.pattern &&
|
||||
typeof frontmatter[field] === "string" &&
|
||||
!config.pattern.test(frontmatter[field] as string)
|
||||
) {
|
||||
errors.push(`Field ${field} format is invalid`);
|
||||
}
|
||||
|
||||
// Array item validation
|
||||
if (config.type === "array" && Array.isArray(frontmatter[field])) {
|
||||
for (let i = 0; i < frontmatter[field].length; i++) {
|
||||
const item = frontmatter[field][i];
|
||||
if (config.items.type === "string" && typeof item !== "string") {
|
||||
const fieldArray = frontmatter[field] as unknown[];
|
||||
for (let i = 0; i < fieldArray.length; i++) {
|
||||
const item = fieldArray[i];
|
||||
if (config.items?.type === "string" && typeof item !== "string") {
|
||||
errors.push(`Item ${i} in ${field} must be a string`);
|
||||
}
|
||||
if (config.items.minLength && item.length < config.items.minLength) {
|
||||
if (
|
||||
config.items &&
|
||||
typeof item === "string" &&
|
||||
config.items.minLength &&
|
||||
item.length < config.items.minLength
|
||||
) {
|
||||
errors.push(
|
||||
`Item ${i} in ${field} must be at least ${config.items.minLength} characters`,
|
||||
);
|
||||
}
|
||||
if (config.items.maxLength && item.length > config.items.maxLength) {
|
||||
if (
|
||||
config.items &&
|
||||
typeof item === "string" &&
|
||||
config.items.maxLength &&
|
||||
item.length > config.items.maxLength
|
||||
) {
|
||||
errors.push(
|
||||
`Item ${i} in ${field} must be no more than ${config.items.maxLength} characters`,
|
||||
);
|
||||
@@ -163,11 +225,13 @@ export function validateBlogPost(frontmatter) {
|
||||
|
||||
/**
|
||||
* Sanitize and normalize frontmatter data
|
||||
* @param {Object} frontmatter - Raw frontmatter data
|
||||
* @returns {Object} Sanitized frontmatter
|
||||
* @param frontmatter - Raw frontmatter data
|
||||
* @returns Sanitized frontmatter
|
||||
*/
|
||||
export function sanitizeBlogPost(frontmatter) {
|
||||
const sanitized = {};
|
||||
export function sanitizeBlogPost(
|
||||
frontmatter: Record<string, unknown>,
|
||||
): BlogPostFrontmatter {
|
||||
const sanitized: Record<string, unknown> = {};
|
||||
|
||||
for (const [field, config] of Object.entries(BLOG_POST_SCHEMA)) {
|
||||
if (frontmatter[field] !== undefined) {
|
||||
@@ -187,5 +251,5 @@ export function sanitizeBlogPost(frontmatter) {
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
return sanitized as BlogPostFrontmatter;
|
||||
}
|
||||
+6
-2
@@ -9,16 +9,20 @@
|
||||
"incremental": true,
|
||||
"module": "esnext",
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
]
|
||||
},
|
||||
"include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"],
|
||||
"include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user