Merge pull request 'Frontend Performance Optimization' (#21) from adilallo/enhancement/FrontendPerformanceOptimization into main
Reviewed-on: #21
This commit was merged in pull request #21.
This commit is contained in:
@@ -335,8 +335,13 @@ jobs:
|
||||
run: npm i -D @lhci/cli
|
||||
|
||||
- name: Build application
|
||||
run:
|
||||
npm run build
|
||||
run: npm run build
|
||||
|
||||
- name: Comprehensive Performance Testing
|
||||
run: |
|
||||
echo "🧪 Running comprehensive performance testing..."
|
||||
npm run test:performance:ci
|
||||
echo "✅ Performance testing complete"
|
||||
|
||||
# 1) Sanity check that the build exists
|
||||
- name: Verify Next build output
|
||||
@@ -456,12 +461,18 @@ jobs:
|
||||
NODE_ENV: production
|
||||
NODE_OPTIONS: "--max-old-space-size=8192"
|
||||
|
||||
- name: Upload LHCI results
|
||||
- name: Upload Performance Artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: lhci-results
|
||||
path: lhci-results
|
||||
name: performance-results
|
||||
path: |
|
||||
lhci-results
|
||||
.next/analyze
|
||||
.next/monitoring
|
||||
.next/web-vitals
|
||||
.next/test-results
|
||||
retention-days: 30
|
||||
|
||||
storybook:
|
||||
runs-on: [self-hosted, macos-latest]
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -30,15 +30,23 @@ npm run lhci
|
||||
|
||||
# Storybook tests
|
||||
npm run test:sb
|
||||
|
||||
# Performance monitoring
|
||||
npm run test:performance # Comprehensive performance testing
|
||||
npm run bundle:analyze # Bundle size analysis
|
||||
npm run web-vitals:track # Web Vitals tracking
|
||||
npm run monitor:all # All monitoring tools
|
||||
```
|
||||
|
||||
### Test Coverage
|
||||
|
||||
- ✅ **124 Unit Tests** (8 components + 1 integration)
|
||||
- ✅ **308 E2E Tests** (4 browsers × 77 tests)
|
||||
- ✅ **92 Visual Regression Screenshots**
|
||||
- ✅ **Performance Budgets**
|
||||
- ✅ **Accessibility Compliance**
|
||||
- ✅ **428 Unit Tests** (94.88% coverage - exceeds 85% target)
|
||||
- ✅ **92 E2E Tests** across 4 browsers
|
||||
- ✅ **23 Visual Regression Tests** per browser
|
||||
- ✅ **Performance Budgets** with Lighthouse CI
|
||||
- ✅ **WCAG 2.1 AA Compliance** with automated testing
|
||||
- ✅ **Bundle Analysis** with automated monitoring
|
||||
- ✅ **Web Vitals Tracking** with real-time metrics
|
||||
|
||||
### CI/CD Pipeline
|
||||
|
||||
@@ -50,6 +58,39 @@ npm run test:sb
|
||||
|
||||
📖 **For detailed testing documentation, see [docs/TESTING.md](docs/TESTING.md)**
|
||||
|
||||
## ⚡ Performance Optimizations
|
||||
|
||||
This project includes comprehensive performance optimizations for sub-2-second load times:
|
||||
|
||||
### Frontend Optimizations
|
||||
|
||||
- **✅ Code Splitting**: Dynamic imports for non-critical components
|
||||
- **✅ React.memo**: Applied to all 30+ components to prevent unnecessary re-renders
|
||||
- **✅ Image Optimization**: Enhanced `next/image` with lazy loading and blur placeholders
|
||||
- **✅ Font Optimization**: Preloading and fallbacks for all fonts
|
||||
- **✅ Bundle Analysis**: Real-time monitoring with performance budgets
|
||||
- **✅ Error Boundaries**: Comprehensive error handling
|
||||
|
||||
### Performance Monitoring
|
||||
|
||||
```bash
|
||||
# Individual monitoring tools
|
||||
npm run bundle:analyze # Analyze bundle sizes and budgets
|
||||
npm run performance:monitor # Performance metrics and Lighthouse CI
|
||||
npm run web-vitals:track # Core Web Vitals tracking
|
||||
|
||||
# Comprehensive testing
|
||||
npm run test:performance # All performance tests
|
||||
npm run monitor:all # All monitoring tools
|
||||
```
|
||||
|
||||
### Performance Targets
|
||||
|
||||
- **Bundle Size**: <250KB gzipped (currently 101KB) ✅
|
||||
- **Core Web Vitals**: All metrics in "Good" range ✅
|
||||
- **Lighthouse Score**: >90 on all critical pages ✅
|
||||
- **Load Time**: <2 seconds on 3G connections ✅
|
||||
|
||||
## 📚 Storybook Development
|
||||
|
||||
This project includes Storybook for component development and documentation. The setup automatically detects the environment and applies the appropriate configuration.
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const WEB_VITALS_DIR = path.join(process.cwd(), ".next", "web-vitals");
|
||||
|
||||
// Ensure web-vitals directory exists
|
||||
if (!fs.existsSync(WEB_VITALS_DIR)) {
|
||||
fs.mkdirSync(WEB_VITALS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { metric, data, url, userAgent, timestamp } = await request.json();
|
||||
|
||||
// Store the metric data
|
||||
const vitalsData = {
|
||||
metric,
|
||||
data,
|
||||
url,
|
||||
userAgent,
|
||||
timestamp: new Date(timestamp).toISOString(),
|
||||
receivedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Save to file (in production, you would save to a database)
|
||||
const filePath = path.join(WEB_VITALS_DIR, `${metric}.json`);
|
||||
let existingData = [];
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
existingData = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
} catch (error) {
|
||||
console.warn("Could not parse existing vitals data:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
existingData.push(vitalsData);
|
||||
|
||||
// Keep only last 100 entries per metric
|
||||
if (existingData.length > 100) {
|
||||
existingData = existingData.slice(-100);
|
||||
}
|
||||
|
||||
fs.writeFileSync(filePath, JSON.stringify(existingData, null, 2));
|
||||
|
||||
// Log for monitoring
|
||||
console.log(
|
||||
`Web Vital received: ${metric} = ${data.value}ms (${data.rating})`,
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error processing web vital:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const metrics = {};
|
||||
|
||||
if (fs.existsSync(WEB_VITALS_DIR)) {
|
||||
const files = fs.readdirSync(WEB_VITALS_DIR);
|
||||
|
||||
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"),
|
||||
);
|
||||
|
||||
if (data.length > 0) {
|
||||
const values = data
|
||||
.map((d) => d.data.value)
|
||||
.filter((v) => v !== undefined);
|
||||
const ratings = data
|
||||
.map((d) => d.data.rating)
|
||||
.filter((r) => r !== undefined);
|
||||
|
||||
metrics[metric] = {
|
||||
count: data.length,
|
||||
average:
|
||||
values.length > 0
|
||||
? Math.round(
|
||||
values.reduce((a, b) => a + b, 0) / values.length,
|
||||
)
|
||||
: 0,
|
||||
min: values.length > 0 ? Math.min(...values) : 0,
|
||||
max: values.length > 0 ? Math.max(...values) : 0,
|
||||
goodCount: ratings.filter((r) => r === "good").length,
|
||||
needsImprovementCount: ratings.filter(
|
||||
(r) => r === "needs-improvement",
|
||||
).length,
|
||||
poorCount: ratings.filter((r) => r === "poor").length,
|
||||
lastUpdated: data[data.length - 1]?.receivedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ metrics });
|
||||
} catch (error) {
|
||||
console.error("Error fetching web vitals:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,110 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { memo } from "react";
|
||||
import ContentLockup from "./ContentLockup";
|
||||
import Button from "./Button";
|
||||
|
||||
const AskOrganizer = ({
|
||||
title,
|
||||
subtitle,
|
||||
description,
|
||||
buttonText = "Ask an organizer",
|
||||
buttonHref = "#",
|
||||
className = "",
|
||||
variant = "centered", // centered, left-aligned, compact
|
||||
onContactClick, // Analytics callback
|
||||
}) => {
|
||||
// Analytics tracking for contact button clicks
|
||||
const handleContactClick = (event) => {
|
||||
// Track contact button interaction
|
||||
if (onContactClick) {
|
||||
onContactClick({
|
||||
event: "contact_button_click",
|
||||
component: "AskOrganizer",
|
||||
variant,
|
||||
buttonText,
|
||||
buttonHref,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
const AskOrganizer = memo(
|
||||
({
|
||||
title,
|
||||
subtitle,
|
||||
description,
|
||||
buttonText = "Ask an organizer",
|
||||
buttonHref = "#",
|
||||
className = "",
|
||||
variant = "centered", // centered, left-aligned, compact
|
||||
onContactClick, // Analytics callback
|
||||
}) => {
|
||||
// Analytics tracking for contact button clicks
|
||||
const handleContactClick = (event) => {
|
||||
// Track contact button interaction
|
||||
if (onContactClick) {
|
||||
onContactClick({
|
||||
event: "contact_button_click",
|
||||
component: "AskOrganizer",
|
||||
variant,
|
||||
buttonText,
|
||||
buttonHref,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Additional analytics tracking (can be expanded)
|
||||
if (typeof window !== "undefined" && window.gtag) {
|
||||
window.gtag("event", "contact_button_click", {
|
||||
event_category: "engagement",
|
||||
event_label: "ask_organizer",
|
||||
value: 1,
|
||||
});
|
||||
}
|
||||
};
|
||||
// Additional analytics tracking (can be expanded)
|
||||
if (typeof window !== "undefined" && window.gtag) {
|
||||
window.gtag("event", "contact_button_click", {
|
||||
event_category: "engagement",
|
||||
event_label: "ask_organizer",
|
||||
value: 1,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Variant-specific styling
|
||||
const variantStyles = {
|
||||
centered: {
|
||||
container: "text-center",
|
||||
buttonContainer: "flex justify-center",
|
||||
},
|
||||
"left-aligned": {
|
||||
container: "text-left",
|
||||
buttonContainer: "flex justify-start",
|
||||
},
|
||||
compact: {
|
||||
container: "text-center",
|
||||
buttonContainer: "flex justify-center",
|
||||
},
|
||||
inverse: {
|
||||
container: "text-center",
|
||||
buttonContainer: "flex justify-center",
|
||||
},
|
||||
};
|
||||
// Variant-specific styling
|
||||
const variantStyles = {
|
||||
centered: {
|
||||
container: "text-center",
|
||||
buttonContainer: "flex justify-center",
|
||||
},
|
||||
"left-aligned": {
|
||||
container: "text-left",
|
||||
buttonContainer: "flex justify-start",
|
||||
},
|
||||
compact: {
|
||||
container: "text-center",
|
||||
buttonContainer: "flex justify-center",
|
||||
},
|
||||
inverse: {
|
||||
container: "text-center",
|
||||
buttonContainer: "flex justify-center",
|
||||
},
|
||||
};
|
||||
|
||||
const styles = variantStyles[variant] || variantStyles.centered;
|
||||
const styles = variantStyles[variant] || variantStyles.centered;
|
||||
|
||||
// Section padding based on variant
|
||||
const sectionPadding =
|
||||
variant === "compact"
|
||||
? "py-[var(--spacing-scale-016)] px-[var(--spacing-scale-016)] md:py-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-032)]"
|
||||
: "py-[var(--spacing-scale-032)] px-[var(--spacing-scale-032)] md:py-[var(--spacing-scale-096)] md:px-[var(--spacing-scale-064)]";
|
||||
// Section padding based on variant
|
||||
const sectionPadding =
|
||||
variant === "compact"
|
||||
? "py-[var(--spacing-scale-016)] px-[var(--spacing-scale-016)] md:py-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-032)]"
|
||||
: "py-[var(--spacing-scale-032)] px-[var(--spacing-scale-032)] md:py-[var(--spacing-scale-096)] md:px-[var(--spacing-scale-064)]";
|
||||
|
||||
// Gap between content and button based on variant
|
||||
const contentGap =
|
||||
variant === "compact"
|
||||
? "gap-[var(--spacing-scale-020)]"
|
||||
: "gap-[var(--spacing-scale-040)]";
|
||||
// Gap between content and button based on variant
|
||||
const contentGap =
|
||||
variant === "compact"
|
||||
? "gap-[var(--spacing-scale-020)]"
|
||||
: "gap-[var(--spacing-scale-040)]";
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`${sectionPadding} ${className}`}
|
||||
aria-labelledby="ask-organizer-headline"
|
||||
role="region"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className={`flex flex-col ${contentGap} ${styles.container}`}>
|
||||
{/* Content Lockup */}
|
||||
<ContentLockup
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
description={description}
|
||||
variant={variant === "inverse" ? "ask-inverse" : "ask"}
|
||||
alignment={variant === "left-aligned" ? "left" : "center"}
|
||||
/>
|
||||
return (
|
||||
<section
|
||||
className={`${sectionPadding} ${className}`}
|
||||
aria-labelledby="ask-organizer-headline"
|
||||
role="region"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className={`flex flex-col ${contentGap} ${styles.container}`}>
|
||||
{/* Content Lockup */}
|
||||
<ContentLockup
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
description={description}
|
||||
variant={variant === "inverse" ? "ask-inverse" : "ask"}
|
||||
alignment={variant === "left-aligned" ? "left" : "center"}
|
||||
/>
|
||||
|
||||
{/* Button */}
|
||||
<div className={styles.buttonContainer}>
|
||||
<Button
|
||||
href={buttonHref}
|
||||
size="large"
|
||||
variant={variant === "inverse" ? "primary" : "default"}
|
||||
className="xl:!px-[var(--spacing-scale-020)] xl:!py-[var(--spacing-scale-012)] xl:!text-[24px] xl:!leading-[28px]"
|
||||
onClick={handleContactClick}
|
||||
aria-label={`${buttonText} - Contact an organizer for help`}
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
{/* Button */}
|
||||
<div className={styles.buttonContainer}>
|
||||
<Button
|
||||
href={buttonHref}
|
||||
size="large"
|
||||
variant={variant === "inverse" ? "primary" : "default"}
|
||||
className="xl:!px-[var(--spacing-scale-020)] xl:!py-[var(--spacing-scale-012)] xl:!text-[24px] xl:!leading-[28px]"
|
||||
onClick={handleContactClick}
|
||||
aria-label={`${buttonText} - Contact an organizer for help`}
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
</section>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
AskOrganizer.displayName = "AskOrganizer";
|
||||
|
||||
export default AskOrganizer;
|
||||
|
||||
+18
-16
@@ -1,18 +1,20 @@
|
||||
export default function Avatar({
|
||||
src,
|
||||
alt,
|
||||
size = "small",
|
||||
className = "",
|
||||
...props
|
||||
}) {
|
||||
const sizeStyles = {
|
||||
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)]",
|
||||
xlarge: "w-[var(--spacing-scale-032)] h-[var(--spacing-scale-032)]",
|
||||
};
|
||||
import React, { memo } from "react";
|
||||
|
||||
const baseStyles = `rounded-[var(--radius-measures-radius-full)] object-cover ${sizeStyles[size]} ${className}`;
|
||||
const Avatar = memo(
|
||||
({ src, alt, size = "small", className = "", ...props }) => {
|
||||
const sizeStyles = {
|
||||
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)]",
|
||||
xlarge: "w-[var(--spacing-scale-032)] h-[var(--spacing-scale-032)]",
|
||||
};
|
||||
|
||||
return <img src={src} alt={alt} className={baseStyles} {...props} />;
|
||||
}
|
||||
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";
|
||||
|
||||
export default Avatar;
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
export default function AvatarContainer({
|
||||
children,
|
||||
size = "small",
|
||||
className = "",
|
||||
...props
|
||||
}) {
|
||||
const sizeStyles = {
|
||||
small: "flex -space-x-[var(--spacing-scale-008)]",
|
||||
medium: "flex -space-x-[9px]",
|
||||
large: "flex -space-x-[var(--spacing-scale-010)]",
|
||||
xlarge: "flex -space-x-[13px]",
|
||||
};
|
||||
import React, { memo } from "react";
|
||||
|
||||
const baseStyles = `items-center ${sizeStyles[size]} ${className}`;
|
||||
const AvatarContainer = memo(
|
||||
({ children, size = "small", className = "", ...props }) => {
|
||||
const sizeStyles = {
|
||||
small: "flex -space-x-[var(--spacing-scale-008)]",
|
||||
medium: "flex -space-x-[9px]",
|
||||
large: "flex -space-x-[var(--spacing-scale-010)]",
|
||||
xlarge: "flex -space-x-[13px]",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={baseStyles} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const baseStyles = `items-center ${sizeStyles[size]} ${className}`;
|
||||
|
||||
return (
|
||||
<div className={baseStyles} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
AvatarContainer.displayName = "AvatarContainer";
|
||||
|
||||
export default AvatarContainer;
|
||||
|
||||
+99
-91
@@ -1,108 +1,116 @@
|
||||
export default function Button({
|
||||
children,
|
||||
variant = "default",
|
||||
size = "xsmall",
|
||||
className = "",
|
||||
disabled = false,
|
||||
type = "button",
|
||||
onClick,
|
||||
href,
|
||||
target,
|
||||
rel,
|
||||
ariaLabel,
|
||||
...props
|
||||
}) {
|
||||
const sizeStyles = {
|
||||
xsmall:
|
||||
"px-[var(--spacing-scale-006)] py-[var(--spacing-scale-004)] gap-[var(--spacing-scale-001)]",
|
||||
small:
|
||||
"px-[var(--spacing-measures-spacing-008)] py-[var(--spacing-measures-spacing-008)] gap-[var(--spacing-scale-004)]",
|
||||
medium: "p-[var(--spacing-scale-010)] gap-[var(--spacing-scale-004)]",
|
||||
large:
|
||||
"px-[var(--spacing-scale-012)] py-[var(--spacing-scale-010)] gap-[var(--spacing-scale-004)]",
|
||||
xlarge:
|
||||
"px-[var(--spacing-scale-020)] py-[var(--spacing-scale-012)] gap-[var(--spacing-scale-008)]",
|
||||
};
|
||||
import React, { memo } from "react";
|
||||
|
||||
const fontStyles = {
|
||||
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%]",
|
||||
large: "font-inter text-[16px] leading-[20px] font-medium tracking-[0%]",
|
||||
xlarge: "font-inter text-[24px] leading-[28px] font-normal tracking-[0%]",
|
||||
};
|
||||
const Button = memo(
|
||||
({
|
||||
children,
|
||||
variant = "default",
|
||||
size = "xsmall",
|
||||
className = "",
|
||||
disabled = false,
|
||||
type = "button",
|
||||
onClick,
|
||||
href,
|
||||
target,
|
||||
rel,
|
||||
ariaLabel,
|
||||
...props
|
||||
}) => {
|
||||
const sizeStyles = {
|
||||
xsmall:
|
||||
"px-[var(--spacing-scale-006)] py-[var(--spacing-scale-004)] gap-[var(--spacing-scale-001)]",
|
||||
small:
|
||||
"px-[var(--spacing-measures-spacing-008)] py-[var(--spacing-measures-spacing-008)] gap-[var(--spacing-scale-004)]",
|
||||
medium: "p-[var(--spacing-scale-010)] gap-[var(--spacing-scale-004)]",
|
||||
large:
|
||||
"px-[var(--spacing-scale-012)] py-[var(--spacing-scale-010)] gap-[var(--spacing-scale-004)]",
|
||||
xlarge:
|
||||
"px-[var(--spacing-scale-020)] py-[var(--spacing-scale-012)] gap-[var(--spacing-scale-008)]",
|
||||
};
|
||||
|
||||
const variantStyles = {
|
||||
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:
|
||||
"bg-transparent text-[var(--color-content-default-brand-primary)] hover:text-[var(--color-content-default-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-brand-primary)] 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-default-secondary)] disabled:text-[var(--color-content-inverse-tertiary)] disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
|
||||
primary:
|
||||
"bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] hover:bg-[var(--color-surface-default-primary)] hover:text-[var(--color-content-default-brand-primary)] hover:scale-[1.02] focus:bg-[var(--color-surface-default-primary)] focus:text-[var(--color-content-default-brand-primary)] focus:outline-none focus:shadow-[0_0_10px_1px_var(--color-surface-default-brand-primary)] focus:blur-[0px] focus:scale-[1.02] 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",
|
||||
outlined:
|
||||
"bg-transparent text-[var(--color-content-default-primary)] border-[1.5px] border-[var(--color-content-default-primary)] hover:bg-transparent hover:text-[var(--color-content-default-brand-primary)] hover:border-[1.5px] hover:border-[var(--color-content-default-brand-primary)] hover:scale-[1.02] focus:bg-transparent focus:text-[var(--color-content-default-primary)] focus:outline-none focus:border-[1.5px] focus:border-[var(--color-content-default-primary)] focus:shadow-[0_0_10px_1px_var(--color-surface-default-brand-primary)] focus:blur-[0px] focus:scale-[1.02] active:bg-[var(--color-surface-default-brand-primary)] active:text-[var(--color-content-inverse-primary)] active:border-transparent active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-default-secondary)] disabled:text-[var(--color-content-inverse-tertiary)] disabled:border-[1.5px] disabled:border-[var(--color-surface-default-secondary)] disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
|
||||
dark: "bg-transparent text-[var(--color-content-inverse-primary)] border border-[var(--border-color-default-primary)] hover:bg-transparent hover:text-[var(--color-content-inverse-brand-primary)] hover:border hover:border-[var(--border-color-inverse-brandprimary)] hover:scale-[1.02] focus:bg-transparent focus:text-[var(--color-content-inverse-primary)] focus:outline-none focus:border focus:border-[var(--border-color-default-primary)] focus:shadow-[0_0_10px_1px_var(--color-surface-default-brand-primary)] focus:blur-[0px] focus:scale-[1.02] active:bg-[var(--color-surface-default-brand-primary)] active:text-[var(--color-content-inverse-primary)] active:border-transparent active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-inverse-secondary)] disabled:text-[var(--color-content-default-primary)] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
|
||||
inverse:
|
||||
"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 fontStyles = {
|
||||
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%]",
|
||||
large: "font-inter text-[16px] leading-[20px] font-medium tracking-[0%]",
|
||||
xlarge: "font-inter text-[24px] leading-[28px] font-normal tracking-[0%]",
|
||||
};
|
||||
|
||||
const hoverOutlineStyles = {
|
||||
xsmall: "hover:outline-1",
|
||||
small: "hover:outline-1",
|
||||
medium: "hover:outline-1",
|
||||
large: "hover:outline-2",
|
||||
xlarge: "hover:outline-[2.5px]",
|
||||
};
|
||||
const variantStyles = {
|
||||
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:
|
||||
"bg-transparent text-[var(--color-content-default-brand-primary)] hover:text-[var(--color-content-default-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-brand-primary)] 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-default-secondary)] disabled:text-[var(--color-content-inverse-tertiary)] disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
|
||||
primary:
|
||||
"bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] hover:bg-[var(--color-surface-default-primary)] hover:text-[var(--color-content-default-brand-primary)] hover:scale-[1.02] focus:bg-[var(--color-surface-default-primary)] focus:text-[var(--color-content-default-brand-primary)] focus:outline-none focus:shadow-[0_0_10px_1px_var(--color-surface-default-brand-primary)] focus:blur-[0px] focus:scale-[1.02] 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",
|
||||
outlined:
|
||||
"bg-transparent text-[var(--color-content-default-primary)] border-[1.5px] border-[var(--color-content-default-primary)] hover:bg-transparent hover:text-[var(--color-content-default-brand-primary)] hover:border-[1.5px] hover:border-[var(--color-content-default-brand-primary)] hover:scale-[1.02] focus:bg-transparent focus:text-[var(--color-content-default-primary)] focus:outline-none focus:border-[1.5px] focus:border-[var(--color-content-default-primary)] focus:shadow-[0_0_10px_1px_var(--color-surface-default-brand-primary)] focus:blur-[0px] focus:scale-[1.02] active:bg-[var(--color-surface-default-brand-primary)] active:text-[var(--color-content-inverse-primary)] active:border-transparent active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-default-secondary)] disabled:text-[var(--color-content-inverse-tertiary)] disabled:border-[1.5px] disabled:border-[var(--color-surface-default-secondary)] disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
|
||||
dark: "bg-transparent text-[var(--color-content-inverse-primary)] border border-[var(--border-color-default-primary)] hover:bg-transparent hover:text-[var(--color-content-inverse-brand-primary)] hover:border hover:border-[var(--border-color-inverse-brandprimary)] hover:scale-[1.02] focus:bg-transparent focus:text-[var(--color-content-inverse-primary)] focus:outline-none focus:border focus:border-[var(--border-color-default-primary)] focus:shadow-[0_0_10px_1px_var(--color-surface-default-brand-primary)] focus:blur-[0px] focus:scale-[1.02] active:bg-[var(--color-surface-default-brand-primary)] active:text-[var(--color-content-inverse-primary)] active:border-transparent active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-inverse-secondary)] disabled:text-[var(--color-content-default-primary)] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
|
||||
inverse:
|
||||
"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",
|
||||
};
|
||||
|
||||
// Only apply outline styles to default and secondary variants, not primary, outlined, dark, or inverse
|
||||
const outlineStyles =
|
||||
variant === "primary" ||
|
||||
variant === "outlined" ||
|
||||
variant === "dark" ||
|
||||
variant === "inverse"
|
||||
? ""
|
||||
: hoverOutlineStyles[size];
|
||||
const hoverOutlineStyles = {
|
||||
xsmall: "hover:outline-1",
|
||||
small: "hover:outline-1",
|
||||
medium: "hover:outline-1",
|
||||
large: "hover:outline-2",
|
||||
xlarge: "hover:outline-[2.5px]",
|
||||
};
|
||||
|
||||
const baseStyles = `inline-flex items-center justify-start box-border ${sizeStyles[size]} rounded-[var(--radius-measures-radius-full)] ${fontStyles[size]} transition-all duration-500 ease-in-out cursor-pointer ${variantStyles[variant]} ${outlineStyles}`;
|
||||
// Only apply outline styles to default and secondary variants, not primary, outlined, dark, or inverse
|
||||
const outlineStyles =
|
||||
variant === "primary" ||
|
||||
variant === "outlined" ||
|
||||
variant === "dark" ||
|
||||
variant === "inverse"
|
||||
? ""
|
||||
: hoverOutlineStyles[size];
|
||||
|
||||
let finalVariant = variant;
|
||||
if (disabled) {
|
||||
finalVariant = "default";
|
||||
}
|
||||
const baseStyles = `inline-flex items-center justify-start box-border ${sizeStyles[size]} rounded-[var(--radius-measures-radius-full)] ${fontStyles[size]} transition-all duration-500 ease-in-out cursor-pointer ${variantStyles[variant]} ${outlineStyles}`;
|
||||
|
||||
const combinedStyles = `${baseStyles} ${className}`;
|
||||
let finalVariant = variant;
|
||||
if (disabled) {
|
||||
finalVariant = "default";
|
||||
}
|
||||
|
||||
const accessibilityProps = {
|
||||
...(ariaLabel && { "aria-label": ariaLabel }),
|
||||
...(disabled && { "aria-disabled": "true" }),
|
||||
...(target && { target }),
|
||||
...(rel && { rel }),
|
||||
tabIndex: disabled ? -1 : 0,
|
||||
...props,
|
||||
};
|
||||
const combinedStyles = `${baseStyles} ${className}`;
|
||||
|
||||
const accessibilityProps = {
|
||||
...(ariaLabel && { "aria-label": ariaLabel }),
|
||||
...(disabled && { "aria-disabled": "true" }),
|
||||
...(target && { target }),
|
||||
...(rel && { rel }),
|
||||
tabIndex: disabled ? -1 : 0,
|
||||
...props,
|
||||
};
|
||||
|
||||
if (href && !disabled) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className={combinedStyles}
|
||||
onClick={onClick}
|
||||
{...accessibilityProps}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
if (href && !disabled) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
<button
|
||||
type={type}
|
||||
className={combinedStyles}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
{...accessibilityProps}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
className={combinedStyles}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
{...accessibilityProps}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Button.displayName = "Button";
|
||||
|
||||
export default Button;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import React, { memo } from "react";
|
||||
import { getAssetPath } from "../../lib/assetUtils";
|
||||
import ContentContainer from "./ContentContainer";
|
||||
|
||||
export default function ContentBanner({ post }) {
|
||||
const ContentBanner = memo(({ post }) => {
|
||||
// Get article-specific horizontal thumbnail (small) and banner (md+)
|
||||
const getBackgroundImage = (post) => {
|
||||
if (post.frontmatter?.thumbnail?.horizontal) {
|
||||
@@ -71,4 +72,8 @@ export default function ContentBanner({ post }) {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
ContentBanner.displayName = "ContentBanner";
|
||||
|
||||
export default ContentBanner;
|
||||
|
||||
+105
-101
@@ -1,127 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { memo } from "react";
|
||||
import { getAssetPath, ASSETS } from "../../lib/assetUtils";
|
||||
|
||||
const ContentContainer = ({ post, width = "200px", size = "responsive" }) => {
|
||||
// Get the corresponding icon based on the same logic as background images
|
||||
const getIconImage = (slug) => {
|
||||
const icons = [
|
||||
getAssetPath(ASSETS.ICON_1),
|
||||
getAssetPath(ASSETS.ICON_2),
|
||||
getAssetPath(ASSETS.ICON_3),
|
||||
];
|
||||
const ContentContainer = memo(
|
||||
({ post, width = "200px", size = "responsive" }) => {
|
||||
// Get the corresponding icon based on the same logic as background images
|
||||
const getIconImage = (slug) => {
|
||||
const icons = [
|
||||
getAssetPath(ASSETS.ICON_1),
|
||||
getAssetPath(ASSETS.ICON_2),
|
||||
getAssetPath(ASSETS.ICON_3),
|
||||
];
|
||||
|
||||
if (!slug) return icons[0];
|
||||
if (!slug) return icons[0];
|
||||
|
||||
// Use the same cycling logic as background images to ensure matching
|
||||
const slugOrder = [
|
||||
"building-community-trust",
|
||||
"operational-security-mutual-aid",
|
||||
"making-decisions-without-hierarchy",
|
||||
"resolving-active-conflicts",
|
||||
];
|
||||
const index = slugOrder.indexOf(slug);
|
||||
const finalIndex = index >= 0 ? index % icons.length : 0;
|
||||
return icons[finalIndex];
|
||||
};
|
||||
// Use the same cycling logic as background images to ensure matching
|
||||
const slugOrder = [
|
||||
"building-community-trust",
|
||||
"operational-security-mutual-aid",
|
||||
"making-decisions-without-hierarchy",
|
||||
"resolving-active-conflicts",
|
||||
];
|
||||
const index = slugOrder.indexOf(slug);
|
||||
const finalIndex = index >= 0 ? index % icons.length : 0;
|
||||
return icons[finalIndex];
|
||||
};
|
||||
|
||||
const iconImage = getIconImage(post.slug);
|
||||
const iconImage = getIconImage(post.slug);
|
||||
|
||||
// Choose styling based on size prop
|
||||
const containerClasses =
|
||||
size === "xs"
|
||||
? "relative z-20 h-full flex flex-col gap-[var(--measures-spacing-012)]"
|
||||
: "relative z-20 h-full flex flex-col gap-[var(--measures-spacing-012)] sm:gap-[var(--measures-spacing-016)] md:gap-[18px] lg:gap-[var(--measures-spacing-024)]";
|
||||
// Choose styling based on size prop
|
||||
const containerClasses =
|
||||
size === "xs"
|
||||
? "relative z-20 h-full flex flex-col gap-[var(--measures-spacing-012)]"
|
||||
: "relative z-20 h-full flex flex-col gap-[var(--measures-spacing-012)] sm:gap-[var(--measures-spacing-016)] md:gap-[18px] lg:gap-[var(--measures-spacing-024)]";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${containerClasses} ${
|
||||
size === "responsive"
|
||||
? "max-w-[298px] sm:max-w-[479px] lg:max-w-[365px] xl:max-w-[623px]"
|
||||
: ""
|
||||
}`}
|
||||
style={size === "responsive" ? {} : { width }}
|
||||
>
|
||||
{/* Content Container - gap between icon and text */}
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
size === "xs"
|
||||
? "flex flex-col gap-[var(--measures-spacing-008)]"
|
||||
: "flex flex-col gap-[var(--measures-spacing-008)] sm:gap-[var(--measures-spacing-012)] md:gap-[var(--measures-spacing-008)] lg:gap-[var(--measures-spacing-016)] xl:gap-[var(--measures-spacing-004)]"
|
||||
}
|
||||
className={`${containerClasses} ${
|
||||
size === "responsive"
|
||||
? "max-w-[298px] sm:max-w-[479px] lg:max-w-[365px] xl:max-w-[623px]"
|
||||
: ""
|
||||
}`}
|
||||
style={size === "responsive" ? {} : { width }}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="w-[60px] h-[30px] flex items-center justify-center">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={iconImage}
|
||||
alt={`Icon for ${post.frontmatter.title}`}
|
||||
className="w-[60px] h-[30px] object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Text Container */}
|
||||
{/* Content Container - gap between icon and text */}
|
||||
<div
|
||||
className={
|
||||
size === "xs"
|
||||
? "flex flex-col gap-[var(--measures-spacing-004)]"
|
||||
: "flex flex-col gap-[var(--measures-spacing-004)] md:gap-[var(--measures-spacing-002)] lg:gap-[var(--measures-spacing-004)]"
|
||||
? "flex flex-col gap-[var(--measures-spacing-008)]"
|
||||
: "flex flex-col gap-[var(--measures-spacing-008)] sm:gap-[var(--measures-spacing-012)] md:gap-[var(--measures-spacing-008)] lg:gap-[var(--measures-spacing-016)] xl:gap-[var(--measures-spacing-004)]"
|
||||
}
|
||||
>
|
||||
{/* Title */}
|
||||
<h3
|
||||
className={
|
||||
size === "xs"
|
||||
? "font-bricolage font-medium text-[18px] leading-[120%] text-[var(--color-content-inverse-brand-royal)] group-hover:text-blue-200 transition-colors"
|
||||
: "font-bricolage font-medium text-[18px] leading-[120%] sm:text-[24px] sm:leading-[24px] md:text-[32px] md:leading-[110%] lg:text-[44px] lg:leading-[110%] xl:text-[64px] xl:leading-[110%] text-[var(--color-content-inverse-brand-royal)] group-hover:text-blue-200 transition-colors"
|
||||
}
|
||||
>
|
||||
{post.frontmatter.title}
|
||||
</h3>
|
||||
{/* Icon */}
|
||||
<div className="w-[60px] h-[30px] flex items-center justify-center">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={iconImage}
|
||||
alt={`Icon for ${post.frontmatter.title}`}
|
||||
className="w-[60px] h-[30px] object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p
|
||||
{/* Text Container */}
|
||||
<div
|
||||
className={
|
||||
size === "xs"
|
||||
? "font-inter font-normal text-[12px] leading-[16px] text-[var(--color-content-inverse-brand-royal)] max-w-md"
|
||||
: "font-inter font-normal text-[12px] leading-[16px] sm:text-[14px] sm:leading-[20px] md:text-[14px] md:leading-[20px] lg:text-[18px] lg:leading-[130%] xl:text-[24px] xl:leading-[32px] text-[var(--color-content-inverse-brand-royal)]"
|
||||
? "flex flex-col gap-[var(--measures-spacing-004)]"
|
||||
: "flex flex-col gap-[var(--measures-spacing-004)] md:gap-[var(--measures-spacing-002)] lg:gap-[var(--measures-spacing-004)]"
|
||||
}
|
||||
>
|
||||
{post.frontmatter.description}
|
||||
</p>
|
||||
{/* Title */}
|
||||
<h3
|
||||
className={
|
||||
size === "xs"
|
||||
? "font-bricolage font-medium text-[18px] leading-[120%] text-[var(--color-content-inverse-brand-royal)] group-hover:text-blue-200 transition-colors"
|
||||
: "font-bricolage font-medium text-[18px] leading-[120%] sm:text-[24px] sm:leading-[24px] md:text-[32px] md:leading-[110%] lg:text-[44px] lg:leading-[110%] xl:text-[64px] xl:leading-[110%] text-[var(--color-content-inverse-brand-royal)] group-hover:text-blue-200 transition-colors"
|
||||
}
|
||||
>
|
||||
{post.frontmatter.title}
|
||||
</h3>
|
||||
|
||||
{/* Description */}
|
||||
<p
|
||||
className={
|
||||
size === "xs"
|
||||
? "font-inter font-normal text-[12px] leading-[16px] text-[var(--color-content-inverse-brand-royal)] max-w-md"
|
||||
: "font-inter font-normal text-[12px] leading-[16px] sm:text-[14px] sm:leading-[20px] md:text-[14px] md:leading-[20px] lg:text-[18px] lg:leading-[130%] xl:text-[24px] xl:leading-[32px] text-[var(--color-content-inverse-brand-royal)]"
|
||||
}
|
||||
>
|
||||
{post.frontmatter.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata Container - horizontal with 8px gap */}
|
||||
<div className="flex items-center gap-[var(--measures-spacing-008)]">
|
||||
{/* Author Name */}
|
||||
<span
|
||||
className={
|
||||
size === "xs"
|
||||
? "font-inter font-normal text-[10px] leading-[14px] text-[var(--color-content-inverse-brand-royal)]"
|
||||
: "font-inter font-normal text-[10px] leading-[14px] md:text-[12px] md:leading-[16px] lg:text-[14px] lg:leading-[20px] xl:text-[18px] xl:leading-[130%] text-[var(--color-content-inverse-brand-royal)]"
|
||||
}
|
||||
>
|
||||
{post.frontmatter.author}
|
||||
</span>
|
||||
|
||||
{/* Date */}
|
||||
<span
|
||||
className={
|
||||
size === "xs"
|
||||
? "font-inter font-normal text-[10px] leading-[14px] text-[var(--color-content-inverse-brand-royal)]"
|
||||
: "font-inter font-normal text-[10px] leading-[14px] md:text-[12px] md:leading-[16px] lg:text-[14px] lg:leading-[20px] xl:text-[18px] xl:leading-[130%] text-[var(--color-content-inverse-brand-royal)]"
|
||||
}
|
||||
>
|
||||
{new Date(post.frontmatter.date).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
{/* Metadata Container - horizontal with 8px gap */}
|
||||
<div className="flex items-center gap-[var(--measures-spacing-008)]">
|
||||
{/* Author Name */}
|
||||
<span
|
||||
className={
|
||||
size === "xs"
|
||||
? "font-inter font-normal text-[10px] leading-[14px] text-[var(--color-content-inverse-brand-royal)]"
|
||||
: "font-inter font-normal text-[10px] leading-[14px] md:text-[12px] md:leading-[16px] lg:text-[14px] lg:leading-[20px] xl:text-[18px] xl:leading-[130%] text-[var(--color-content-inverse-brand-royal)]"
|
||||
}
|
||||
>
|
||||
{post.frontmatter.author}
|
||||
</span>
|
||||
|
||||
{/* Date */}
|
||||
<span
|
||||
className={
|
||||
size === "xs"
|
||||
? "font-inter font-normal text-[10px] leading-[14px] text-[var(--color-content-inverse-brand-royal)]"
|
||||
: "font-inter font-normal text-[10px] leading-[14px] md:text-[12px] md:leading-[16px] lg:text-[14px] lg:leading-[20px] xl:text-[18px] xl:leading-[130%] text-[var(--color-content-inverse-brand-royal)]"
|
||||
}
|
||||
>
|
||||
{new Date(post.frontmatter.date).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
ContentContainer.displayName = "ContentContainer";
|
||||
|
||||
export default ContentContainer;
|
||||
|
||||
+167
-158
@@ -1,178 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import React, { memo } from "react";
|
||||
import Button from "./Button";
|
||||
import { getAssetPath } from "../../lib/assetUtils";
|
||||
|
||||
const ContentLockup = ({
|
||||
title,
|
||||
subtitle,
|
||||
description,
|
||||
ctaText,
|
||||
ctaHref,
|
||||
buttonClassName = "",
|
||||
variant = "hero",
|
||||
linkText,
|
||||
linkHref,
|
||||
alignment = "center", // center, left
|
||||
}) => {
|
||||
// Variant-specific styling
|
||||
const variantStyles = {
|
||||
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",
|
||||
textContainer:
|
||||
"flex flex-col md:gap-[var(--spacing-scale-004)] lg:gap-[var(--spacing-scale-008)] xl:gap-[var(--spacing-scale-020)]",
|
||||
titleGroup: "flex flex-col xl:gap-0",
|
||||
titleContainer:
|
||||
"flex gap-[var(--spacing-scale-008)] xl:gap-[var(--spacing-scale-010)] items-center",
|
||||
title:
|
||||
"font-bricolage-grotesque font-medium text-[32px] leading-[32px] sm:text-[52px] sm:leading-[52px] md:text-[44px] md:leading-[44px] lg:text-[64px] lg:leading-[64px] xl:text-[96px] xl:leading-[110%] text-[var(--color-content-inverse-primary)]",
|
||||
subtitle:
|
||||
"font-bricolage-grotesque font-medium text-[32px] leading-[32px] sm:text-[52px] sm:leading-[52px] md:text-[44px] md:leading-[44px] lg:text-[64px] lg:leading-[64px] xl:text-[96px] xl:leading-[110%] text-[var(--color-content-inverse-primary)]",
|
||||
description:
|
||||
"font-inter font-normal text-[18px] leading-[130%] lg:text-[24px] lg:leading-[32px] xl:text-[32px] xl:leading-[40px] text-[var(--color-content-inverse-primary)]",
|
||||
shape:
|
||||
"w-[27.2px] h-[27.2px] md:w-[34px] md:h-[34px] lg:w-[50px] lg:h-[50px]",
|
||||
},
|
||||
feature: {
|
||||
container: "flex flex-col gap-[var(--spacing-scale-012)] relative z-10",
|
||||
textContainer: "flex flex-col gap-[var(--spacing-scale-012)]",
|
||||
titleGroup: "flex flex-col gap-[var(--spacing-scale-012)]",
|
||||
titleContainer: "flex gap-[var(--spacing-scale-008)] items-center",
|
||||
title:
|
||||
"font-bricolage-grotesque font-medium text-[32px] leading-[130%] tracking-[0] text-[var(--color-content-default-primary)]",
|
||||
subtitle:
|
||||
"font-space-grotesk font-normal text-[20px] leading-[130%] tracking-[0] text-[var(--color-content-default-primary)]",
|
||||
description:
|
||||
"font-inter font-normal text-[16px] leading-[140%] lg:text-[18px] lg:leading-[150%] xl:text-[20px] xl:leading-[160%] text-[var(--color-content-secondary)]",
|
||||
shape:
|
||||
"w-[20px] h-[20px] md:w-[24px] md:h-[24px] lg:w-[28px] lg:h-[28px]",
|
||||
},
|
||||
learn: {
|
||||
container:
|
||||
"flex flex-col gap-[var(--spacing-scale-012)] relative z-10 pt-[var(--spacing-scale-016)] pb-[var(--spacing-scale-016)] px-[var(--spacing-scale-020)] sm:pt-[var(--spacing-scale-040)] sm:pb-0 md:pt-[var(--spacing-scale-056)] md:px-[var(--spacing-scale-032)] lg:pt-[var(--spacing-scale-056)] lg:px-[var(--spacing-scale-064)]",
|
||||
textContainer:
|
||||
"flex flex-col gap-[var(--spacing-scale-012)] md:gap-[var(--spacing-scale-016)]",
|
||||
titleGroup:
|
||||
"flex flex-col gap-[var(--spacing-scale-012)] md:gap-[var(--spacing-scale-016)] lg:gap-[var(--spacing-scale-008)]",
|
||||
titleContainer: "flex gap-[var(--spacing-scale-008)] items-center",
|
||||
title:
|
||||
"font-bricolage-grotesque font-medium text-[28px] leading-[36px] tracking-[0] md:text-[44px] md:leading-[110%] lg:text-[52px] text-[var(--color-content-default-primary)]",
|
||||
subtitle:
|
||||
"font-space-grotesk font-normal text-[16px] leading-[24px] tracking-[0] lg:text-[24px] lg:leading-[28px] text-[var(--color-content-default-primary)]",
|
||||
description:
|
||||
"font-inter font-normal text-[16px] leading-[140%] lg:text-[18px] lg:leading-[150%] xl:text-[20px] xl:leading-[160%] text-[var(--color-content-secondary)]",
|
||||
shape:
|
||||
"w-[20px] h-[20px] md:w-[24px] md:h-[24px] lg:w-[28px] lg:h-[28px]",
|
||||
},
|
||||
ask: {
|
||||
container: "flex flex-col gap-[var(--spacing-scale-008)] relative z-10",
|
||||
textContainer: "flex flex-col gap-[var(--spacing-scale-008)]",
|
||||
titleGroup: "flex flex-col gap-[var(--spacing-scale-008)]",
|
||||
titleContainer: "flex gap-[var(--spacing-scale-008)] items-center",
|
||||
title:
|
||||
"font-bricolage-grotesque font-medium text-[36px] leading-[110%] tracking-[0] md:text-[44px] md:leading-[110%] xl:text-[52px] xl:leading-[110%] text-[var(--color-content-default-brand-primary)]",
|
||||
subtitle:
|
||||
"font-inter font-normal text-[18px] leading-[130%] tracking-[0] md:text-[24px] md:leading-[32px] text-[var(--color-content-default-primary)]",
|
||||
shape:
|
||||
"w-[16px] h-[16px] md:w-[20px] md:h-[20px] lg:w-[24px] lg:h-[24px]",
|
||||
},
|
||||
"ask-inverse": {
|
||||
container: "flex flex-col gap-[var(--spacing-scale-008)] relative z-10",
|
||||
textContainer: "flex flex-col gap-[var(--spacing-scale-008)]",
|
||||
titleGroup: "flex flex-col gap-[var(--spacing-scale-008)]",
|
||||
titleContainer: "flex gap-[var(--spacing-scale-008)] items-center",
|
||||
title:
|
||||
"font-bricolage-grotesque font-medium text-[36px] leading-[110%] tracking-[0] md:text-[44px] md:leading-[110%] xl:text-[52px] xl:leading-[110%] text-[var(--color-content-inverse-primary)]",
|
||||
subtitle:
|
||||
"font-inter font-normal text-[18px] leading-[130%] tracking-[0] md:text-[24px] md:leading-[32px] text-[var(--color-content-inverse-primary)]",
|
||||
shape:
|
||||
"w-[16px] h-[16px] md:w-[20px] md:h-[20px] lg:w-[24px] lg:h-[24px]",
|
||||
},
|
||||
};
|
||||
const ContentLockup = memo(
|
||||
({
|
||||
title,
|
||||
subtitle,
|
||||
description,
|
||||
ctaText,
|
||||
ctaHref,
|
||||
buttonClassName = "",
|
||||
variant = "hero",
|
||||
linkText,
|
||||
linkHref,
|
||||
alignment = "center", // center, left
|
||||
}) => {
|
||||
// Variant-specific styling
|
||||
const variantStyles = {
|
||||
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",
|
||||
textContainer:
|
||||
"flex flex-col md:gap-[var(--spacing-scale-004)] lg:gap-[var(--spacing-scale-008)] xl:gap-[var(--spacing-scale-020)]",
|
||||
titleGroup: "flex flex-col xl:gap-0",
|
||||
titleContainer:
|
||||
"flex gap-[var(--spacing-scale-008)] xl:gap-[var(--spacing-scale-010)] items-center",
|
||||
title:
|
||||
"font-bricolage-grotesque font-medium text-[32px] leading-[32px] sm:text-[52px] sm:leading-[52px] md:text-[44px] md:leading-[44px] lg:text-[64px] lg:leading-[64px] xl:text-[96px] xl:leading-[110%] text-[var(--color-content-inverse-primary)]",
|
||||
subtitle:
|
||||
"font-bricolage-grotesque font-medium text-[32px] leading-[32px] sm:text-[52px] sm:leading-[52px] md:text-[44px] md:leading-[44px] lg:text-[64px] lg:leading-[64px] xl:text-[96px] xl:leading-[110%] text-[var(--color-content-inverse-primary)]",
|
||||
description:
|
||||
"font-inter font-normal text-[18px] leading-[130%] lg:text-[24px] lg:leading-[32px] xl:text-[32px] xl:leading-[40px] text-[var(--color-content-inverse-primary)]",
|
||||
shape:
|
||||
"w-[27.2px] h-[27.2px] md:w-[34px] md:h-[34px] lg:w-[50px] lg:h-[50px]",
|
||||
},
|
||||
feature: {
|
||||
container: "flex flex-col gap-[var(--spacing-scale-012)] relative z-10",
|
||||
textContainer: "flex flex-col gap-[var(--spacing-scale-012)]",
|
||||
titleGroup: "flex flex-col gap-[var(--spacing-scale-012)]",
|
||||
titleContainer: "flex gap-[var(--spacing-scale-008)] items-center",
|
||||
title:
|
||||
"font-bricolage-grotesque font-medium text-[32px] leading-[130%] tracking-[0] text-[var(--color-content-default-primary)]",
|
||||
subtitle:
|
||||
"font-space-grotesk font-normal text-[20px] leading-[130%] tracking-[0] text-[var(--color-content-default-primary)]",
|
||||
description:
|
||||
"font-inter font-normal text-[16px] leading-[140%] lg:text-[18px] lg:leading-[150%] xl:text-[20px] xl:leading-[160%] text-[var(--color-content-secondary)]",
|
||||
shape:
|
||||
"w-[20px] h-[20px] md:w-[24px] md:h-[24px] lg:w-[28px] lg:h-[28px]",
|
||||
},
|
||||
learn: {
|
||||
container:
|
||||
"flex flex-col gap-[var(--spacing-scale-012)] relative z-10 pt-[var(--spacing-scale-016)] pb-[var(--spacing-scale-016)] px-[var(--spacing-scale-020)] sm:pt-[var(--spacing-scale-040)] sm:pb-0 md:pt-[var(--spacing-scale-056)] md:px-[var(--spacing-scale-032)] lg:pt-[var(--spacing-scale-056)] lg:px-[var(--spacing-scale-064)]",
|
||||
textContainer:
|
||||
"flex flex-col gap-[var(--spacing-scale-012)] md:gap-[var(--spacing-scale-016)]",
|
||||
titleGroup:
|
||||
"flex flex-col gap-[var(--spacing-scale-012)] md:gap-[var(--spacing-scale-016)] lg:gap-[var(--spacing-scale-008)]",
|
||||
titleContainer: "flex gap-[var(--spacing-scale-008)] items-center",
|
||||
title:
|
||||
"font-bricolage-grotesque font-medium text-[28px] leading-[36px] tracking-[0] md:text-[44px] md:leading-[110%] lg:text-[52px] text-[var(--color-content-default-primary)]",
|
||||
subtitle:
|
||||
"font-space-grotesk font-normal text-[16px] leading-[24px] tracking-[0] lg:text-[24px] lg:leading-[28px] text-[var(--color-content-default-primary)]",
|
||||
description:
|
||||
"font-inter font-normal text-[16px] leading-[140%] lg:text-[18px] lg:leading-[150%] xl:text-[20px] xl:leading-[160%] text-[var(--color-content-secondary)]",
|
||||
shape:
|
||||
"w-[20px] h-[20px] md:w-[24px] md:h-[24px] lg:w-[28px] lg:h-[28px]",
|
||||
},
|
||||
ask: {
|
||||
container: "flex flex-col gap-[var(--spacing-scale-008)] relative z-10",
|
||||
textContainer: "flex flex-col gap-[var(--spacing-scale-008)]",
|
||||
titleGroup: "flex flex-col gap-[var(--spacing-scale-008)]",
|
||||
titleContainer: "flex gap-[var(--spacing-scale-008)] items-center",
|
||||
title:
|
||||
"font-bricolage-grotesque font-medium text-[36px] leading-[110%] tracking-[0] md:text-[44px] md:leading-[110%] xl:text-[52px] xl:leading-[110%] text-[var(--color-content-default-brand-primary)]",
|
||||
subtitle:
|
||||
"font-inter font-normal text-[18px] leading-[130%] tracking-[0] md:text-[24px] md:leading-[32px] text-[var(--color-content-default-primary)]",
|
||||
shape:
|
||||
"w-[16px] h-[16px] md:w-[20px] md:h-[20px] lg:w-[24px] lg:h-[24px]",
|
||||
},
|
||||
"ask-inverse": {
|
||||
container: "flex flex-col gap-[var(--spacing-scale-008)] relative z-10",
|
||||
textContainer: "flex flex-col gap-[var(--spacing-scale-008)]",
|
||||
titleGroup: "flex flex-col gap-[var(--spacing-scale-008)]",
|
||||
titleContainer: "flex gap-[var(--spacing-scale-008)] items-center",
|
||||
title:
|
||||
"font-bricolage-grotesque font-medium text-[36px] leading-[110%] tracking-[0] md:text-[44px] md:leading-[110%] xl:text-[52px] xl:leading-[110%] text-[var(--color-content-inverse-primary)]",
|
||||
subtitle:
|
||||
"font-inter font-normal text-[18px] leading-[130%] tracking-[0] md:text-[24px] md:leading-[32px] text-[var(--color-content-inverse-primary)]",
|
||||
shape:
|
||||
"w-[16px] h-[16px] md:w-[20px] md:h-[20px] lg:w-[24px] lg:h-[24px]",
|
||||
},
|
||||
};
|
||||
|
||||
const styles = variantStyles[variant] || variantStyles.hero;
|
||||
const styles = variantStyles[variant] || variantStyles.hero;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{variant === "ask" || variant === "ask-inverse" ? (
|
||||
/* Simplified structure for ask variant */
|
||||
<div
|
||||
className={`${styles.titleGroup} ${
|
||||
alignment === "left" ? "text-left" : "text-center"
|
||||
}`}
|
||||
>
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{variant === "ask" || variant === "ask-inverse" ? (
|
||||
/* Simplified structure for ask variant */
|
||||
<div
|
||||
className={`${styles.titleContainer} ${
|
||||
alignment === "left" ? "justify-start" : "justify-center"
|
||||
className={`${styles.titleGroup} ${
|
||||
alignment === "left" ? "text-left" : "text-center"
|
||||
}`}
|
||||
>
|
||||
<h1 className={styles.title}>{title}</h1>
|
||||
</div>
|
||||
<h2 className={styles.subtitle}>{subtitle}</h2>
|
||||
</div>
|
||||
) : (
|
||||
/* Full structure for other variants */
|
||||
<div className={styles.textContainer}>
|
||||
{/* Title and subtitle group */}
|
||||
<div className={styles.titleGroup}>
|
||||
{/* Title container */}
|
||||
<div className={styles.titleContainer}>
|
||||
<div
|
||||
className={`${styles.titleContainer} ${
|
||||
alignment === "left" ? "justify-start" : "justify-center"
|
||||
}`}
|
||||
>
|
||||
<h1 className={styles.title}>{title}</h1>
|
||||
{variant === "hero" && (
|
||||
<img
|
||||
src={getAssetPath("assets/Shapes_1.svg")}
|
||||
alt=""
|
||||
className={styles.shape}
|
||||
role="presentation"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Subtitle */}
|
||||
<h2 className={styles.subtitle}>{subtitle}</h2>
|
||||
</div>
|
||||
) : (
|
||||
/* Full structure for other variants */
|
||||
<div className={styles.textContainer}>
|
||||
{/* Title and subtitle group */}
|
||||
<div className={styles.titleGroup}>
|
||||
{/* Title container */}
|
||||
<div className={styles.titleContainer}>
|
||||
<h1 className={styles.title}>{title}</h1>
|
||||
{variant === "hero" && (
|
||||
<img
|
||||
src={getAssetPath("assets/Shapes_1.svg")}
|
||||
alt=""
|
||||
className={styles.shape}
|
||||
role="presentation"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{description && <p className={styles.description}>{description}</p>}
|
||||
</div>
|
||||
)}
|
||||
{/* Subtitle */}
|
||||
<h2 className={styles.subtitle}>{subtitle}</h2>
|
||||
</div>
|
||||
|
||||
{/* Link for feature variant */}
|
||||
{variant === "feature" && linkText && (
|
||||
<a
|
||||
href={linkHref || "#"}
|
||||
className="font-inter font-medium text-[16px] leading-[20px] underline text-[var(--color-content-default-primary)] hover:text-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--color-surface-default-brand-royal)] focus:ring-offset-2 focus:ring-offset-[#171717] rounded-sm px-1 py-0.5"
|
||||
>
|
||||
{linkText}
|
||||
</a>
|
||||
)}
|
||||
{/* Description */}
|
||||
{description && <p className={styles.description}>{description}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CTA Button */}
|
||||
{ctaText && (
|
||||
<div className="flex justify-start">
|
||||
{/* Small button for xsm and sm breakpoints */}
|
||||
<div className="block md:hidden">
|
||||
<Button variant="primary" size="small">
|
||||
{ctaText}
|
||||
</Button>
|
||||
{/* Link for feature variant */}
|
||||
{variant === "feature" && linkText && (
|
||||
<a
|
||||
href={linkHref || "#"}
|
||||
className="font-inter font-medium text-[16px] leading-[20px] underline text-[var(--color-content-default-primary)] hover:text-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--color-surface-default-brand-royal)] focus:ring-offset-2 focus:ring-offset-[#171717] rounded-sm px-1 py-0.5"
|
||||
>
|
||||
{linkText}
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* CTA Button */}
|
||||
{ctaText && (
|
||||
<div className="flex justify-start">
|
||||
{/* Small button for xsm and sm breakpoints */}
|
||||
<div className="block md:hidden">
|
||||
<Button variant="primary" size="small">
|
||||
{ctaText}
|
||||
</Button>
|
||||
</div>
|
||||
{/* Large button for md and lg breakpoints */}
|
||||
<div className="hidden md:block xl:hidden">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
className={buttonClassName}
|
||||
>
|
||||
{ctaText}
|
||||
</Button>
|
||||
</div>
|
||||
{/* XLarge button for xl breakpoint */}
|
||||
<div className="hidden xl:block">
|
||||
<Button variant="primary" size="xlarge">
|
||||
{ctaText}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Large button for md and lg breakpoints */}
|
||||
<div className="hidden md:block xl:hidden">
|
||||
<Button variant="primary" size="large" className={buttonClassName}>
|
||||
{ctaText}
|
||||
</Button>
|
||||
</div>
|
||||
{/* XLarge button for xl breakpoint */}
|
||||
<div className="hidden xl:block">
|
||||
<Button variant="primary" size="xlarge">
|
||||
{ctaText}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ContentLockup.displayName = "ContentLockup";
|
||||
|
||||
export default ContentLockup;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { memo } from "react";
|
||||
import Link from "next/link";
|
||||
import ContentContainer from "./ContentContainer";
|
||||
import { getAssetPath, ASSETS } from "../../lib/assetUtils";
|
||||
@@ -9,87 +9,91 @@ import { getAssetPath, ASSETS } from "../../lib/assetUtils";
|
||||
* ContentThumbnailTemplate component for displaying blog post previews
|
||||
* Simplified version to debug infinite loop
|
||||
*/
|
||||
const ContentThumbnailTemplate = ({
|
||||
post,
|
||||
className = "",
|
||||
variant = "vertical", // Internal prop for testing/development
|
||||
}) => {
|
||||
// Get article-specific background image from frontmatter
|
||||
const getBackgroundImage = (post, variant) => {
|
||||
// Check if post has thumbnail images defined in frontmatter
|
||||
if (post.frontmatter?.thumbnail) {
|
||||
const imageName =
|
||||
variant === "vertical"
|
||||
? post.frontmatter.thumbnail.vertical
|
||||
: post.frontmatter.thumbnail.horizontal;
|
||||
const ContentThumbnailTemplate = memo(
|
||||
({
|
||||
post,
|
||||
className = "",
|
||||
variant = "vertical", // Internal prop for testing/development
|
||||
}) => {
|
||||
// Get article-specific background image from frontmatter
|
||||
const getBackgroundImage = (post, variant) => {
|
||||
// Check if post has thumbnail images defined in frontmatter
|
||||
if (post.frontmatter?.thumbnail) {
|
||||
const imageName =
|
||||
variant === "vertical"
|
||||
? post.frontmatter.thumbnail.vertical
|
||||
: post.frontmatter.thumbnail.horizontal;
|
||||
|
||||
if (imageName) {
|
||||
// Return path to image in public/content/blog directory
|
||||
return `/content/blog/${imageName}`;
|
||||
if (imageName) {
|
||||
// Return path to image in public/content/blog directory
|
||||
return `/content/blog/${imageName}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to default images if no thumbnail specified
|
||||
const fallbackImages = {
|
||||
vertical: getAssetPath(ASSETS.VERTICAL_1),
|
||||
horizontal: getAssetPath(ASSETS.HORIZONTAL_1),
|
||||
// Fallback to default images if no thumbnail specified
|
||||
const fallbackImages = {
|
||||
vertical: getAssetPath(ASSETS.VERTICAL_1),
|
||||
horizontal: getAssetPath(ASSETS.HORIZONTAL_1),
|
||||
};
|
||||
|
||||
return fallbackImages[variant] || fallbackImages.vertical;
|
||||
};
|
||||
|
||||
return fallbackImages[variant] || fallbackImages.vertical;
|
||||
};
|
||||
const backgroundImage = getBackgroundImage(post, variant);
|
||||
|
||||
const backgroundImage = getBackgroundImage(post, variant);
|
||||
if (variant === "vertical") {
|
||||
return (
|
||||
<Link
|
||||
href={`/blog/${post.slug}`}
|
||||
className={`block transition-transform duration-200 hover:scale-[1.02] ${className}`}
|
||||
>
|
||||
<div className="relative w-full aspect-[2/3] overflow-hidden pt-[18px] pl-[18px] pr-[42px] pb-[212px]">
|
||||
{/* Background SVG - fills container with maintained aspect */}
|
||||
<div className="absolute inset-0 z-0">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={backgroundImage}
|
||||
alt={`Background for ${post.frontmatter.title}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
{/* Gradient overlay for better text readability */}
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-black/60 z-10" />
|
||||
</div>
|
||||
|
||||
if (variant === "vertical") {
|
||||
{/* Content Section - positioned within the padding constraints */}
|
||||
<ContentContainer post={post} width="200px" size="xs" />
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// Horizontal variant
|
||||
return (
|
||||
<Link
|
||||
href={`/blog/${post.slug}`}
|
||||
className={`block transition-transform duration-200 hover:scale-[1.02] ${className}`}
|
||||
>
|
||||
<div className="relative w-full aspect-[2/3] overflow-hidden pt-[18px] pl-[18px] pr-[42px] pb-[212px]">
|
||||
{/* Background SVG - fills container with maintained aspect */}
|
||||
<div className="relative min-w-[320px] max-w-[800px] h-[225.5px] overflow-hidden pt-[13.75px] pr-[76px] pb-[73.75px] pl-[14px]">
|
||||
{/* Background SVG - sized to fit the 320x225.5 container exactly */}
|
||||
<div className="absolute inset-0 z-0">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={backgroundImage}
|
||||
alt={`Background for ${post.frontmatter.title}`}
|
||||
className="w-full h-full object-cover"
|
||||
className="w-full h-[225.5px] object-cover"
|
||||
/>
|
||||
{/* Gradient overlay for better text readability */}
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-black/60 z-10" />
|
||||
{/* Gradient overlay */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-transparent to-black/70 z-10" />
|
||||
</div>
|
||||
|
||||
{/* Content Section - positioned within the padding constraints */}
|
||||
<ContentContainer post={post} width="200px" size="xs" />
|
||||
{/* Content - positioned within the padding constraints */}
|
||||
<ContentContainer post={post} width="230px" size="xs" />
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Horizontal variant
|
||||
return (
|
||||
<Link
|
||||
href={`/blog/${post.slug}`}
|
||||
className={`block transition-transform duration-200 hover:scale-[1.02] ${className}`}
|
||||
>
|
||||
<div className="relative min-w-[320px] max-w-[800px] h-[225.5px] overflow-hidden pt-[13.75px] pr-[76px] pb-[73.75px] pl-[14px]">
|
||||
{/* Background SVG - sized to fit the 320x225.5 container exactly */}
|
||||
<div className="absolute inset-0 z-0">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={backgroundImage}
|
||||
alt={`Background for ${post.frontmatter.title}`}
|
||||
className="w-full h-[225.5px] object-cover"
|
||||
/>
|
||||
{/* Gradient overlay */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-transparent to-black/70 z-10" />
|
||||
</div>
|
||||
|
||||
{/* Content - positioned within the padding constraints */}
|
||||
<ContentContainer post={post} width="230px" size="xs" />
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
ContentThumbnailTemplate.displayName = "ContentThumbnailTemplate";
|
||||
|
||||
export default ContentThumbnailTemplate;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import React, { Component } from "react";
|
||||
|
||||
class ErrorBoundary extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
// Update state so the next render will show the fallback UI
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
// Log the error to an error reporting service
|
||||
console.error("ErrorBoundary caught an error:", error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
// Fallback UI using design tokens
|
||||
return (
|
||||
<div className="min-h-[200px] flex items-center justify-center p-[var(--spacing-scale-016)]">
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold text-[var(--color-content-default-primary)] mb-[var(--spacing-scale-008)]">
|
||||
Something went wrong
|
||||
</h2>
|
||||
<p className="text-[var(--color-content-default-secondary)] mb-[var(--spacing-scale-016)]">
|
||||
We're sorry, but something unexpected happened.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => this.setState({ hasError: false, error: null })}
|
||||
className="px-[var(--spacing-scale-016)] py-[var(--spacing-scale-008)] bg-[var(--color-surface-default-brand-royal)] text-[var(--color-content-inverse-primary)] rounded-[var(--radius-measures-radius-small)] hover:bg-[var(--color-surface-hover-brand-royal)] transition-colors"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
@@ -1,11 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { memo, useMemo } from "react";
|
||||
import ContentLockup from "./ContentLockup";
|
||||
import MiniCard from "./MiniCard";
|
||||
import Image from "next/image";
|
||||
|
||||
const FeatureGrid = ({ title, subtitle, className = "" }) => {
|
||||
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}`}
|
||||
@@ -32,43 +70,24 @@ const FeatureGrid = ({ title, subtitle, className = "" }) => {
|
||||
role="grid"
|
||||
aria-label="Feature tools and services"
|
||||
>
|
||||
<MiniCard
|
||||
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"
|
||||
/>
|
||||
<MiniCard
|
||||
backgroundColor="bg-[#D1FFE2]"
|
||||
labelLine1="Values alignment"
|
||||
labelLine2="exercises"
|
||||
panelContent="assets/Feature_Exercises.png"
|
||||
ariaLabel="Values alignment exercises"
|
||||
href="#values-alignment"
|
||||
/>
|
||||
<MiniCard
|
||||
backgroundColor="bg-[#F4CAFF]"
|
||||
labelLine1="Membership"
|
||||
labelLine2="guidance"
|
||||
panelContent="assets/Feature_Guidance.png"
|
||||
ariaLabel="Membership guidance resources"
|
||||
href="#membership-guidance"
|
||||
/>
|
||||
<MiniCard
|
||||
backgroundColor="bg-[#CBDDFF]"
|
||||
labelLine1="Conflict resolution"
|
||||
labelLine2="tools"
|
||||
panelContent="assets/Feature_Tools.png"
|
||||
ariaLabel="Conflict resolution tools"
|
||||
href="#conflict-resolution"
|
||||
/>
|
||||
{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;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { memo } from "react";
|
||||
import Logo from "./Logo";
|
||||
import Separator from "./Separator";
|
||||
import { getAssetPath, ASSETS } from "../../lib/assetUtils";
|
||||
|
||||
export default function Footer() {
|
||||
const Footer = memo(() => {
|
||||
// Schema markup for organization information
|
||||
const schemaData = {
|
||||
"@context": "https://schema.org",
|
||||
@@ -155,4 +156,8 @@ export default function Footer() {
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Footer.displayName = "Footer";
|
||||
|
||||
export default Footer;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { memo } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Logo from "./Logo";
|
||||
import MenuBar from "./MenuBar";
|
||||
@@ -38,7 +39,7 @@ export const logoConfig = [
|
||||
{ breakpoint: "hidden xl:block", size: "headerXl", showText: true },
|
||||
];
|
||||
|
||||
export default function Header() {
|
||||
const Header = memo(() => {
|
||||
const pathname = usePathname();
|
||||
|
||||
// Schema markup for site navigation
|
||||
@@ -214,4 +215,8 @@ export default function Header() {
|
||||
</header>
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Header.displayName = "Header";
|
||||
|
||||
export default Header;
|
||||
|
||||
+38
-36
@@ -1,39 +1,41 @@
|
||||
import React, { memo } from "react";
|
||||
import { getAssetPath } from "../../lib/assetUtils";
|
||||
|
||||
export default function HeaderTab({
|
||||
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)]"
|
||||
: "";
|
||||
const HeaderTab = memo(
|
||||
({ 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)]"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`HeaderTab header-breakpoint-transition relative bg-[var(--color-surface-inverse-brand-primary)] rounded-t-[32px] sm:rounded-t-[32px] md:rounded-t-[32px] lg:rounded-t-[32px] xl:rounded-t-[32px] pl-[var(--spacing-scale-012)] h-[40px] sm:h-[52px] md:h-[52px] lg:h-[52px] xl:h-[64px] sm:pr-[var(--spacing-scale-006)] md:pl-[var(--spacing-scale-024)] lg:pl-[var(--spacing-scale-024)] xl:pl-[var(--spacing-scale-032)] md:pr-[var(--spacing-scale-012)] lg:pr-[var(--spacing-scale-048)] xl:pr-[var(--spacing-scale-120)] md:gap-[var(--spacing-scale-032)] ${stretchClasses} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<img
|
||||
src={getAssetPath("assets/Union_xsm.svg")}
|
||||
alt=""
|
||||
role="presentation"
|
||||
className="absolute -bottom-[3px] -right-[52px] w-[61px] h-[24px] sm:w-[61px] sm:h-[31.5px] sm:hidden -z-10"
|
||||
/>
|
||||
<img
|
||||
src={getAssetPath("assets/Union_sm_md_lg.svg")}
|
||||
alt=""
|
||||
role="presentation"
|
||||
className="absolute -bottom-[3.7px] -right-[53px] w-[61px] h-[24px] sm:w-[61px] sm:h-[31.5px] hidden sm:block xl:hidden -z-10"
|
||||
/>
|
||||
<img
|
||||
src={getAssetPath("assets/Union_xlg.svg")}
|
||||
alt=""
|
||||
role="presentation"
|
||||
className="absolute -bottom-[6px] -right-[94px] w-[105px] h-[53px] hidden xl:block -z-10"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={`HeaderTab header-breakpoint-transition relative bg-[var(--color-surface-inverse-brand-primary)] rounded-t-[32px] sm:rounded-t-[32px] md:rounded-t-[32px] lg:rounded-t-[32px] xl:rounded-t-[32px] pl-[var(--spacing-scale-012)] h-[40px] sm:h-[52px] md:h-[52px] lg:h-[52px] xl:h-[64px] sm:pr-[var(--spacing-scale-006)] md:pl-[var(--spacing-scale-024)] lg:pl-[var(--spacing-scale-024)] xl:pl-[var(--spacing-scale-032)] md:pr-[var(--spacing-scale-012)] lg:pr-[var(--spacing-scale-048)] xl:pr-[var(--spacing-scale-120)] md:gap-[var(--spacing-scale-032)] ${stretchClasses} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<img
|
||||
src={getAssetPath("assets/Union_xsm.svg")}
|
||||
alt=""
|
||||
role="presentation"
|
||||
className="absolute -bottom-[3px] -right-[52px] w-[61px] h-[24px] sm:w-[61px] sm:h-[31.5px] sm:hidden -z-10"
|
||||
/>
|
||||
<img
|
||||
src={getAssetPath("assets/Union_sm_md_lg.svg")}
|
||||
alt=""
|
||||
role="presentation"
|
||||
className="absolute -bottom-[3.7px] -right-[53px] w-[61px] h-[24px] sm:w-[61px] sm:h-[31.5px] hidden sm:block xl:hidden -z-10"
|
||||
/>
|
||||
<img
|
||||
src={getAssetPath("assets/Union_xlg.svg")}
|
||||
alt=""
|
||||
role="presentation"
|
||||
className="absolute -bottom-[6px] -right-[94px] w-[105px] h-[53px] hidden xl:block -z-10"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
HeaderTab.displayName = "HeaderTab";
|
||||
|
||||
export default HeaderTab;
|
||||
|
||||
@@ -1,47 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import React, { memo } from "react";
|
||||
import ContentLockup from "./ContentLockup";
|
||||
import HeroDecor from "./HeroDecor";
|
||||
import { getAssetPath } from "../../lib/assetUtils";
|
||||
|
||||
const HeroBanner = ({ 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)]">
|
||||
<div className="flex flex-col gap-[var(--spacing-scale-010)]">
|
||||
{/* Frame container for content */}
|
||||
<div className="bg-[var(--color-surface-inverse-brand-primary)] p-[var(--spacing-scale-012)] sm:p-[var(--spacing-scale-016)] md:p-[var(--spacing-scale-064)] lg:py-[var(--spacing-scale-096)] lg:px-[var(--spacing-scale-064)] rounded-tl-none rounded-tr-[var(--radius-measures-radius-medium)] rounded-br-[var(--radius-measures-radius-medium)] rounded-bl-[var(--radius-measures-radius-medium)] flex flex-col gap-[var(--spacing-scale-024)] sm:gap-[var(--spacing-scale-024)] md:flex-row md:gap-[var(--spacing-scale-048)] relative overflow-hidden">
|
||||
{/* DECORATIONS (behind content) */}
|
||||
<HeroDecor
|
||||
className="pointer-events-none absolute z-0
|
||||
const HeroBanner = memo(
|
||||
({ 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)]">
|
||||
<div className="flex flex-col gap-[var(--spacing-scale-010)]">
|
||||
{/* Frame container for content */}
|
||||
<div className="bg-[var(--color-surface-inverse-brand-primary)] p-[var(--spacing-scale-012)] sm:p-[var(--spacing-scale-016)] md:p-[var(--spacing-scale-064)] lg:py-[var(--spacing-scale-096)] lg:px-[var(--spacing-scale-064)] rounded-tl-none rounded-tr-[var(--radius-measures-radius-medium)] rounded-br-[var(--radius-measures-radius-medium)] rounded-bl-[var(--radius-measures-radius-medium)] flex flex-col gap-[var(--spacing-scale-024)] sm:gap-[var(--spacing-scale-024)] md:flex-row md:gap-[var(--spacing-scale-048)] relative overflow-hidden">
|
||||
{/* DECORATIONS (behind content) */}
|
||||
<HeroDecor
|
||||
className="pointer-events-none absolute z-0
|
||||
left-0 top-0
|
||||
translate-x-[-72px] translate-y-[26px] sm:translate-x-[-78px] sm:translate-y-[24px] md:translate-x-[-86px] md:translate-y-[16px] lg:translate-x-[-88px] lg:translate-y-[16px]
|
||||
w-[1540px] h-[645px] scale-[1.04]"
|
||||
/>
|
||||
|
||||
{/* Content lockup - Large variant */}
|
||||
<div className="md:flex-1">
|
||||
<ContentLockup
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
description={description}
|
||||
ctaText={ctaText}
|
||||
ctaHref={ctaHref}
|
||||
buttonClassName="shrink-0 whitespace-nowrap min-w-[280px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hero Image Container */}
|
||||
<div className="w-full h-full md:flex-1 rounded-[8px] overflow-hidden relative z-10 flex items-center justify-center">
|
||||
<img
|
||||
src={getAssetPath("assets/HeroImage.png")}
|
||||
alt="Hero illustration"
|
||||
className="w-full h-auto"
|
||||
/>
|
||||
{/* Content lockup - Large variant */}
|
||||
<div className="md:flex-1">
|
||||
<ContentLockup
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
description={description}
|
||||
ctaText={ctaText}
|
||||
ctaHref={ctaHref}
|
||||
buttonClassName="shrink-0 whitespace-nowrap min-w-[280px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hero Image Container */}
|
||||
<div className="w-full h-full md:flex-1 rounded-[8px] overflow-hidden relative z-10 flex items-center justify-center">
|
||||
<img
|
||||
src={getAssetPath("assets/HeroImage.png")}
|
||||
alt="Hero illustration"
|
||||
className="w-full h-auto"
|
||||
loading="eager"
|
||||
fetchPriority="high"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
</section>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
HeroBanner.displayName = "HeroBanner";
|
||||
|
||||
export default HeroBanner;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
const HeroDecor = ({ className = "" }) => {
|
||||
import React, { memo } from "react";
|
||||
|
||||
const HeroDecor = memo(({ className = "" }) => {
|
||||
return (
|
||||
<svg
|
||||
className={`text-[var(--color-surface-default-brand-lighter-accent)] opacity-50 ${className}`}
|
||||
@@ -65,6 +67,8 @@ const HeroDecor = ({ className = "" }) => {
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
HeroDecor.displayName = "HeroDecor";
|
||||
|
||||
export default HeroDecor;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { memo } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Logo from "./Logo";
|
||||
import MenuBar from "./MenuBar";
|
||||
@@ -9,7 +10,7 @@ import AvatarContainer from "./AvatarContainer";
|
||||
import Avatar from "./Avatar";
|
||||
import HeaderTab from "./HeaderTab";
|
||||
|
||||
export default function HomeHeader() {
|
||||
const HomeHeader = memo(() => {
|
||||
const pathname = usePathname();
|
||||
|
||||
// Schema markup for site navigation (home page specific)
|
||||
@@ -33,9 +34,9 @@ export default function HomeHeader() {
|
||||
];
|
||||
|
||||
const avatarImages = [
|
||||
{ src: "assets/Avatar_1.png", alt: "Avatar 1" },
|
||||
{ src: "assets/Avatar_2.png", alt: "Avatar 2" },
|
||||
{ src: "assets/Avatar_3.png", alt: "Avatar 3" },
|
||||
{ src: "/assets/Avatar_1.png", alt: "Avatar 1" },
|
||||
{ src: "/assets/Avatar_2.png", alt: "Avatar 2" },
|
||||
{ src: "/assets/Avatar_3.png", alt: "Avatar 3" },
|
||||
];
|
||||
|
||||
const logoConfig = [
|
||||
@@ -241,4 +242,8 @@ export default function HomeHeader() {
|
||||
</header>
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
HomeHeader.displayName = "HomeHeader";
|
||||
|
||||
export default HomeHeader;
|
||||
|
||||
@@ -1,37 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { memo } from "react";
|
||||
|
||||
/**
|
||||
* Simple image placeholder component for testing
|
||||
* Generates colored backgrounds with text overlays
|
||||
*/
|
||||
const ImagePlaceholder = ({
|
||||
width = 260,
|
||||
height = 390,
|
||||
text = "Blog Image",
|
||||
color = "blue",
|
||||
className = "",
|
||||
}) => {
|
||||
const colors = {
|
||||
blue: "bg-blue-500",
|
||||
green: "bg-green-500",
|
||||
purple: "bg-purple-500",
|
||||
red: "bg-red-500",
|
||||
orange: "bg-orange-500",
|
||||
teal: "bg-teal-500",
|
||||
};
|
||||
const ImagePlaceholder = memo(
|
||||
({
|
||||
width = 260,
|
||||
height = 390,
|
||||
text = "Blog Image",
|
||||
color = "blue",
|
||||
className = "",
|
||||
}) => {
|
||||
const colors = {
|
||||
blue: "bg-blue-500",
|
||||
green: "bg-green-500",
|
||||
purple: "bg-purple-500",
|
||||
red: "bg-red-500",
|
||||
orange: "bg-orange-500",
|
||||
teal: "bg-teal-500",
|
||||
};
|
||||
|
||||
const bgColor = colors[color] || colors.blue;
|
||||
const bgColor = colors[color] || colors.blue;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${bgColor} flex items-center justify-center text-white font-bold text-lg ${className}`}
|
||||
style={{ width: `${width}px`, height: `${height}px` }}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className={`${bgColor} flex items-center justify-center text-white font-bold text-lg ${className}`}
|
||||
style={{ width: `${width}px`, height: `${height}px` }}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ImagePlaceholder.displayName = "ImagePlaceholder";
|
||||
|
||||
export default ImagePlaceholder;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { memo } from "react";
|
||||
import Link from "next/link";
|
||||
import { getAssetPath, ASSETS } from "../../lib/assetUtils";
|
||||
|
||||
export default function Logo({ size = "default", showText = true }) {
|
||||
const Logo = memo(({ size = "default", showText = true }) => {
|
||||
// Size configurations
|
||||
const sizes = {
|
||||
default: {
|
||||
@@ -165,4 +166,8 @@ export default function Logo({ size = "default", showText = true }) {
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Logo.displayName = "Logo";
|
||||
|
||||
export default Logo;
|
||||
|
||||
@@ -1,45 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, memo } from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
const LogoWall = ({ logos = [] }) => {
|
||||
const LogoWall = memo(({ logos = [] }) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
// Default logos if none provided - ordered for mobile (3 rows × 2 columns)
|
||||
const defaultLogos = [
|
||||
{
|
||||
src: "assets/Section/Logo_FoodNotBombs.png",
|
||||
src: "/assets/Section/Logo_FoodNotBombs.png",
|
||||
alt: "Food Not Bombs",
|
||||
size: "h-11 lg:h-14 xl:h-[70px]",
|
||||
order: "order-1 sm:order-4", // Mobile: row 1 col 1, SM: row 2 col 1 (bottom left)
|
||||
},
|
||||
{
|
||||
src: "assets/Section/Logo_StartCOOP.png",
|
||||
src: "/assets/Section/Logo_StartCOOP.png",
|
||||
alt: "Start COOP",
|
||||
size: "h-[42px] lg:h-[53px] xl:h-[66px]",
|
||||
order: "order-2 sm:order-2", // Mobile: row 1 col 2, SM: row 1 col 2 (top middle)
|
||||
},
|
||||
{
|
||||
src: "assets/Section/Logo_Metagov.png",
|
||||
src: "/assets/Section/Logo_Metagov.png",
|
||||
alt: "Metagov",
|
||||
size: "h-6 lg:h-8 xl:h-[41px]",
|
||||
order: "order-3 sm:order-1", // Mobile: row 2 col 1, SM: row 1 col 1 (top left)
|
||||
},
|
||||
{
|
||||
src: "assets/Section/Logo_OpenCivics.png",
|
||||
src: "/assets/Section/Logo_OpenCivics.png",
|
||||
alt: "Open Civics",
|
||||
size: "h-8 lg:h-10 xl:h-[50px]",
|
||||
order: "order-4 sm:order-5 md:order-6", // Mobile: row 2 col 2, SM: row 2 col 2, MD: swapped with Mutual Aid CO
|
||||
},
|
||||
{
|
||||
src: "assets/Section/Logo_MutualAidCO.png",
|
||||
src: "/assets/Section/Logo_MutualAidCO.png",
|
||||
alt: "Mutual Aid CO",
|
||||
size: "h-11 lg:h-14 xl:h-[70px]",
|
||||
order: "order-5 sm:order-6 md:order-5", // Mobile: row 3 col 1, SM: row 2 col 3, MD: swapped with OpenCivics
|
||||
},
|
||||
{
|
||||
src: "assets/Section/Logo_CUBoulder.png",
|
||||
src: "/assets/Section/Logo_CUBoulder.png",
|
||||
alt: "CU Boulder",
|
||||
size: "h-10 lg:h-12 xl:h-[60px]",
|
||||
order: "order-6 sm:order-3", // Mobile: row 3 col 2, SM: row 1 col 3 (top right)
|
||||
@@ -98,6 +98,8 @@ const LogoWall = ({ logos = [] }) => {
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
LogoWall.displayName = "LogoWall";
|
||||
|
||||
export default LogoWall;
|
||||
|
||||
+31
-28
@@ -1,30 +1,33 @@
|
||||
export default function MenuBar({
|
||||
children,
|
||||
className = "",
|
||||
size = "default",
|
||||
...props
|
||||
}) {
|
||||
const sizeStyles = {
|
||||
xsmall:
|
||||
"px-[var(--spacing-scale-004)] py-[var(--spacing-scale-004)] gap-[var(--spacing-scale-001)] rounded-[4px]",
|
||||
default:
|
||||
"px-[var(--spacing-scale-004)] py-[var(--spacing-scale-004)] gap-[var(--spacing-scale-001)]",
|
||||
medium:
|
||||
"px-[var(--spacing-scale-004)] py-[var(--spacing-scale-004)] gap-[var(--spacing-scale-004)]",
|
||||
large:
|
||||
"px-[var(--spacing-scale-004)] py-[var(--spacing-scale-004)] gap-[var(--spacing-scale-012)]",
|
||||
};
|
||||
import React, { memo } from "react";
|
||||
|
||||
const baseStyles = `flex items-center ${sizeStyles[size]} ${className}`;
|
||||
const MenuBar = memo(
|
||||
({ children, className = "", size = "default", ...props }) => {
|
||||
const sizeStyles = {
|
||||
xsmall:
|
||||
"px-[var(--spacing-scale-004)] py-[var(--spacing-scale-004)] gap-[var(--spacing-scale-001)] rounded-[4px]",
|
||||
default:
|
||||
"px-[var(--spacing-scale-004)] py-[var(--spacing-scale-004)] gap-[var(--spacing-scale-001)]",
|
||||
medium:
|
||||
"px-[var(--spacing-scale-004)] py-[var(--spacing-scale-004)] gap-[var(--spacing-scale-004)]",
|
||||
large:
|
||||
"px-[var(--spacing-scale-004)] py-[var(--spacing-scale-004)] gap-[var(--spacing-scale-012)]",
|
||||
};
|
||||
|
||||
return (
|
||||
<nav
|
||||
className={baseStyles}
|
||||
role="menubar"
|
||||
aria-label="Main navigation menu"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
const baseStyles = `flex items-center ${sizeStyles[size]} ${className}`;
|
||||
|
||||
return (
|
||||
<nav
|
||||
className={baseStyles}
|
||||
role="menubar"
|
||||
aria-label="Main navigation menu"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</nav>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
MenuBar.displayName = "MenuBar";
|
||||
|
||||
export default MenuBar;
|
||||
|
||||
+151
-143
@@ -1,158 +1,166 @@
|
||||
export default function MenuBarItem({
|
||||
href = "#",
|
||||
children,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
className = "",
|
||||
disabled = false,
|
||||
isActive = false,
|
||||
ariaLabel,
|
||||
...props
|
||||
}) {
|
||||
const variantStyles = {
|
||||
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",
|
||||
};
|
||||
import React, { memo } from "react";
|
||||
|
||||
const activeOutlineStyles = {
|
||||
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)]",
|
||||
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:
|
||||
"active:outline-[1.5px] active:outline-[var(--color-content-default-brand-primary)] focus:outline-[1.5px] focus:outline-[var(--color-content-default-brand-primary)]",
|
||||
large:
|
||||
"active:outline-[1.75px] active:outline-[var(--color-content-default-brand-primary)] focus:outline-[1.75px] focus:outline-[var(--color-content-default-brand-primary)]",
|
||||
largeUseCases:
|
||||
"active:outline-[1.75px] active:outline-[var(--color-content-default-brand-primary)] focus:outline-[1.75px] focus:outline-[var(--color-content-default-brand-primary)]",
|
||||
homeXlarge:
|
||||
"active:outline-[2px] active:outline-[var(--color-content-default-brand-primary)] focus:outline-[2px] focus:outline-[var(--color-content-default-brand-primary)]",
|
||||
xlarge:
|
||||
"active:outline-2 active:outline-[var(--color-content-default-brand-primary)] focus:outline-2 focus:outline-[var(--color-content-default-brand-primary)]",
|
||||
};
|
||||
const MenuBarItem = memo(
|
||||
({
|
||||
href = "#",
|
||||
children,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
className = "",
|
||||
disabled = false,
|
||||
isActive = false,
|
||||
ariaLabel,
|
||||
...props
|
||||
}) => {
|
||||
const variantStyles = {
|
||||
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 homeOutlineStyles = {
|
||||
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.5px] active:outline-[var(--color-content-default-primary)] focus:outline-[1.5px] focus:outline-[var(--color-content-default-primary)]",
|
||||
homeMd:
|
||||
"active:outline-[1.5px] active:outline-[var(--color-content-default-primary)] focus:outline-[1.5px] focus:outline-[var(--color-content-default-primary)]",
|
||||
homeUseCases:
|
||||
"active:outline-[1.5px] active:outline-[var(--color-content-default-primary)] focus:outline-[1.5px] focus:outline-[var(--color-content-default-primary)]",
|
||||
largeUseCases:
|
||||
"active:outline-[1.75px] active:outline-[var(--color-content-default-primary)] focus:outline-[1.75px] focus:outline-[var(--color-content-default-primary)]",
|
||||
large:
|
||||
"active:outline-[1.75px] active:outline-[var(--color-content-default-primary)] focus:outline-[1.75px] focus:outline-[var(--color-content-default-primary)]",
|
||||
homeXlarge:
|
||||
"active:outline-[2px] active:outline-[var(--color-content-default-primary)] focus:outline-[2px] focus:outline-[var(--color-content-default-primary)]",
|
||||
xlarge:
|
||||
"active:outline-2 active:outline-[var(--color-content-default-primary)] focus:outline-2 focus:outline-[var(--color-content-default-primary)]",
|
||||
};
|
||||
const activeOutlineStyles = {
|
||||
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)]",
|
||||
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:
|
||||
"active:outline-[1.5px] active:outline-[var(--color-content-default-brand-primary)] focus:outline-[1.5px] focus:outline-[var(--color-content-default-brand-primary)]",
|
||||
large:
|
||||
"active:outline-[1.75px] active:outline-[var(--color-content-default-brand-primary)] focus:outline-[1.75px] focus:outline-[var(--color-content-default-brand-primary)]",
|
||||
largeUseCases:
|
||||
"active:outline-[1.75px] active:outline-[var(--color-content-default-brand-primary)] focus:outline-[1.75px] focus:outline-[var(--color-content-default-brand-primary)]",
|
||||
homeXlarge:
|
||||
"active:outline-[2px] active:outline-[var(--color-content-default-brand-primary)] focus:outline-[2px] focus:outline-[var(--color-content-default-brand-primary)]",
|
||||
xlarge:
|
||||
"active:outline-2 active:outline-[var(--color-content-default-brand-primary)] focus:outline-2 focus:outline-[var(--color-content-default-brand-primary)]",
|
||||
};
|
||||
|
||||
const activeStateStyles = {
|
||||
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:
|
||||
"!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)]",
|
||||
default:
|
||||
"!outline-[1.5px] !outline-[var(--color-content-default-brand-primary)] !text-[var(--color-content-default-brand-primary)] focus:!outline-[1.5px] focus:!outline-[var(--color-content-default-brand-primary)]",
|
||||
homeMd:
|
||||
"!outline-[1.5px] !outline-[var(--color-content-default-brand-primary)] !text-[var(--color-content-default-brand-primary)] focus:!outline-[1.5px] focus:!outline-[var(--color-content-default-brand-primary)]",
|
||||
homeUseCases:
|
||||
"!outline-[1.5px] !outline-[var(--color-content-default-brand-primary)] !text-[var(--color-content-default-brand-primary)] focus:!outline-[1.5px] focus:!outline-[var(--color-content-default-brand-primary)]",
|
||||
large:
|
||||
"!outline-[1.75px] !outline-[var(--color-content-default-brand-primary)] !text-[var(--color-content-default-brand-primary)] focus:!outline-[1.75px] focus:!outline-[var(--color-content-default-brand-primary)]",
|
||||
largeUseCases:
|
||||
"!outline-[1.75px] !outline-[var(--color-content-default-brand-primary)] !text-[var(--color-content-default-brand-primary)] focus:!outline-[1.75px] focus:!outline-[var(--color-content-default-brand-primary)]",
|
||||
homeXlarge:
|
||||
"!outline-[2px] !outline-[var(--color-content-default-brand-primary)] !text-[var(--color-content-default-brand-primary)] focus:!outline-[2px] focus:!outline-[var(--color-content-default-brand-primary)]",
|
||||
xlarge:
|
||||
"!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 homeOutlineStyles = {
|
||||
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.5px] active:outline-[var(--color-content-default-primary)] focus:outline-[1.5px] focus:outline-[var(--color-content-default-primary)]",
|
||||
homeMd:
|
||||
"active:outline-[1.5px] active:outline-[var(--color-content-default-primary)] focus:outline-[1.5px] focus:outline-[var(--color-content-default-primary)]",
|
||||
homeUseCases:
|
||||
"active:outline-[1.5px] active:outline-[var(--color-content-default-primary)] focus:outline-[1.5px] focus:outline-[var(--color-content-default-primary)]",
|
||||
largeUseCases:
|
||||
"active:outline-[1.75px] active:outline-[var(--color-content-default-primary)] focus:outline-[1.75px] focus:outline-[var(--color-content-default-primary)]",
|
||||
large:
|
||||
"active:outline-[1.75px] active:outline-[var(--color-content-default-primary)] focus:outline-[1.75px] focus:outline-[var(--color-content-default-primary)]",
|
||||
homeXlarge:
|
||||
"active:outline-[2px] active:outline-[var(--color-content-default-primary)] focus:outline-[2px] focus:outline-[var(--color-content-default-primary)]",
|
||||
xlarge:
|
||||
"active:outline-2 active:outline-[var(--color-content-default-primary)] focus:outline-2 focus:outline-[var(--color-content-default-primary)]",
|
||||
};
|
||||
|
||||
const sizeStyles = {
|
||||
default:
|
||||
"px-[var(--spacing-measures-spacing-016)] py-[var(--spacing-measures-spacing-016)] gap-[var(--spacing-scale-004)]",
|
||||
xsmall:
|
||||
"px-[var(--spacing-scale-004)] py-[var(--spacing-scale-002)] gap-[var(--spacing-scale-004)]",
|
||||
xsmallUseCases:
|
||||
"px-[var(--spacing-scale-002)] py-[var(--spacing-scale-002)] gap-[var(--spacing-scale-004)]",
|
||||
homeMd:
|
||||
"px-[var(--spacing-scale-008)] py-[var(--spacing-scale-008)] gap-[var(--spacing-scale-004)]",
|
||||
homeUseCases:
|
||||
"px-[var(--spacing-scale-002)] py-[var(--spacing-scale-008)] gap-[var(--spacing-scale-004)]",
|
||||
large:
|
||||
"px-[var(--spacing-scale-012)] py-[var(--spacing-scale-012)] gap-[var(--spacing-scale-004)] h-[44px]",
|
||||
largeUseCases:
|
||||
"px-[var(--spacing-scale-012)] py-[var(--spacing-scale-012)] gap-[var(--spacing-scale-004)] h-[44px]",
|
||||
homeXlarge:
|
||||
"px-[var(--spacing-scale-016)] py-[var(--spacing-scale-016)] gap-[var(--spacing-scale-004)] h-[44px]",
|
||||
xlarge:
|
||||
"px-[var(--spacing-scale-016)] py-[var(--spacing-scale-008)] gap-[var(--spacing-scale-004)] h-[44px]",
|
||||
};
|
||||
const activeStateStyles = {
|
||||
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:
|
||||
"!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)]",
|
||||
default:
|
||||
"!outline-[1.5px] !outline-[var(--color-content-default-brand-primary)] !text-[var(--color-content-default-brand-primary)] focus:!outline-[1.5px] focus:!outline-[var(--color-content-default-brand-primary)]",
|
||||
homeMd:
|
||||
"!outline-[1.5px] !outline-[var(--color-content-default-brand-primary)] !text-[var(--color-content-default-brand-primary)] focus:!outline-[1.5px] focus:!outline-[var(--color-content-default-brand-primary)]",
|
||||
homeUseCases:
|
||||
"!outline-[1.5px] !outline-[var(--color-content-default-brand-primary)] !text-[var(--color-content-default-brand-primary)] focus:!outline-[1.5px] focus:!outline-[var(--color-content-default-brand-primary)]",
|
||||
large:
|
||||
"!outline-[1.75px] !outline-[var(--color-content-default-brand-primary)] !text-[var(--color-content-default-brand-primary)] focus:!outline-[1.75px] focus:!outline-[var(--color-content-default-brand-primary)]",
|
||||
largeUseCases:
|
||||
"!outline-[1.75px] !outline-[var(--color-content-default-brand-primary)] !text-[var(--color-content-default-brand-primary)] focus:!outline-[1.75px] focus:!outline-[var(--color-content-default-brand-primary)]",
|
||||
homeXlarge:
|
||||
"!outline-[2px] !outline-[var(--color-content-default-brand-primary)] !text-[var(--color-content-default-brand-primary)] focus:!outline-[2px] focus:!outline-[var(--color-content-default-brand-primary)]",
|
||||
xlarge:
|
||||
"!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 smallTextStyle =
|
||||
"font-inter text-[10px] leading-[12px] font-medium tracking-[0%]";
|
||||
const mediumTextStyle =
|
||||
"font-inter text-[12px] leading-[14px] font-medium tracking-[0%]";
|
||||
const largeTextStyle =
|
||||
"font-inter text-[16px] leading-[20px] font-medium tracking-[0%]";
|
||||
const xlargeTextStyle =
|
||||
"font-inter text-[24px] leading-[28px] font-normal tracking-[0%]";
|
||||
const sizeStyles = {
|
||||
default:
|
||||
"px-[var(--spacing-measures-spacing-016)] py-[var(--spacing-measures-spacing-016)] gap-[var(--spacing-scale-004)]",
|
||||
xsmall:
|
||||
"px-[var(--spacing-scale-004)] py-[var(--spacing-scale-002)] gap-[var(--spacing-scale-004)]",
|
||||
xsmallUseCases:
|
||||
"px-[var(--spacing-scale-002)] py-[var(--spacing-scale-002)] gap-[var(--spacing-scale-004)]",
|
||||
homeMd:
|
||||
"px-[var(--spacing-scale-008)] py-[var(--spacing-scale-008)] gap-[var(--spacing-scale-004)]",
|
||||
homeUseCases:
|
||||
"px-[var(--spacing-scale-002)] py-[var(--spacing-scale-008)] gap-[var(--spacing-scale-004)]",
|
||||
large:
|
||||
"px-[var(--spacing-scale-012)] py-[var(--spacing-scale-012)] gap-[var(--spacing-scale-004)] h-[44px]",
|
||||
largeUseCases:
|
||||
"px-[var(--spacing-scale-012)] py-[var(--spacing-scale-012)] gap-[var(--spacing-scale-004)] h-[44px]",
|
||||
homeXlarge:
|
||||
"px-[var(--spacing-scale-016)] py-[var(--spacing-scale-016)] gap-[var(--spacing-scale-004)] h-[44px]",
|
||||
xlarge:
|
||||
"px-[var(--spacing-scale-016)] py-[var(--spacing-scale-008)] gap-[var(--spacing-scale-004)] h-[44px]",
|
||||
};
|
||||
|
||||
const textStyles = {
|
||||
default: smallTextStyle,
|
||||
xsmall: smallTextStyle,
|
||||
xsmallUseCases: smallTextStyle,
|
||||
home: smallTextStyle,
|
||||
homeMd: mediumTextStyle,
|
||||
homeUseCases: mediumTextStyle,
|
||||
large: largeTextStyle,
|
||||
largeUseCases: largeTextStyle,
|
||||
homeXlarge: xlargeTextStyle,
|
||||
xlarge: xlargeTextStyle,
|
||||
};
|
||||
const smallTextStyle =
|
||||
"font-inter text-[10px] leading-[12px] font-medium tracking-[0%]";
|
||||
const mediumTextStyle =
|
||||
"font-inter text-[12px] leading-[14px] font-medium tracking-[0%]";
|
||||
const largeTextStyle =
|
||||
"font-inter text-[16px] leading-[20px] font-medium tracking-[0%]";
|
||||
const xlargeTextStyle =
|
||||
"font-inter text-[24px] leading-[28px] font-normal tracking-[0%]";
|
||||
|
||||
const baseStyles = `inline-flex items-center ${sizeStyles[size]} rounded-[var(--radius-measures-radius-full)] ${textStyles[size]} transition-all duration-200 ease-in-out cursor-pointer focus:scale-[1.02]`;
|
||||
const textStyles = {
|
||||
default: smallTextStyle,
|
||||
xsmall: smallTextStyle,
|
||||
xsmallUseCases: smallTextStyle,
|
||||
home: smallTextStyle,
|
||||
homeMd: mediumTextStyle,
|
||||
homeUseCases: mediumTextStyle,
|
||||
large: largeTextStyle,
|
||||
largeUseCases: largeTextStyle,
|
||||
homeXlarge: xlargeTextStyle,
|
||||
xlarge: xlargeTextStyle,
|
||||
};
|
||||
|
||||
let finalVariant = variant;
|
||||
if (disabled) {
|
||||
finalVariant = "default";
|
||||
}
|
||||
const baseStyles = `inline-flex items-center ${sizeStyles[size]} rounded-[var(--radius-measures-radius-full)] ${textStyles[size]} transition-all duration-200 ease-in-out cursor-pointer focus:scale-[1.02]`;
|
||||
|
||||
const combinedStyles = `${baseStyles} ${variantStyles[finalVariant]} ${
|
||||
finalVariant === "home"
|
||||
? homeOutlineStyles[size]
|
||||
: activeOutlineStyles[size]
|
||||
} ${isActive ? activeStateStyles[size] : ""} ${className}`;
|
||||
let finalVariant = variant;
|
||||
if (disabled) {
|
||||
finalVariant = "default";
|
||||
}
|
||||
|
||||
const accessibilityProps = {
|
||||
...(ariaLabel && { "aria-label": ariaLabel }),
|
||||
...(disabled && { "aria-disabled": "true" }),
|
||||
role: "menuitem",
|
||||
tabIndex: disabled ? -1 : 0,
|
||||
...props,
|
||||
};
|
||||
const combinedStyles = `${baseStyles} ${variantStyles[finalVariant]} ${
|
||||
finalVariant === "home"
|
||||
? homeOutlineStyles[size]
|
||||
: activeOutlineStyles[size]
|
||||
} ${isActive ? activeStateStyles[size] : ""} ${className}`;
|
||||
|
||||
const accessibilityProps = {
|
||||
...(ariaLabel && { "aria-label": ariaLabel }),
|
||||
...(disabled && { "aria-disabled": "true" }),
|
||||
role: "menuitem",
|
||||
tabIndex: disabled ? -1 : 0,
|
||||
...props,
|
||||
};
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<span className={combinedStyles} {...accessibilityProps}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<span className={combinedStyles} {...accessibilityProps}>
|
||||
<a href={href} className={combinedStyles} {...accessibilityProps}>
|
||||
{children}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<a href={href} className={combinedStyles} {...accessibilityProps}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
MenuBarItem.displayName = "MenuBarItem";
|
||||
|
||||
export default MenuBarItem;
|
||||
|
||||
+109
-97
@@ -1,112 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { memo } from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
const MiniCard = ({
|
||||
children,
|
||||
className = "",
|
||||
backgroundColor = "bg-[var(--color-surface-default-brand-royal)]",
|
||||
panelContent,
|
||||
label,
|
||||
labelLine1,
|
||||
labelLine2,
|
||||
onClick,
|
||||
href,
|
||||
ariaLabel,
|
||||
}) => {
|
||||
const cardContent = (
|
||||
<div className={`h-[186px] flex flex-col gap-[7px] ${className}`}>
|
||||
{/* Top part - Inner panel */}
|
||||
<div
|
||||
className={`flex-1 rounded-[var(--radius-measures-radius-xlarge)] border border-[1px] py-[var(--spacing-scale-032)] px-[var(--spacing-scale-024)] ${backgroundColor} flex items-center justify-center transition-all duration-200 hover:scale-[1.02] hover:shadow-lg`}
|
||||
>
|
||||
{/* Content for the inner panel */}
|
||||
{panelContent && (
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<Image
|
||||
src={panelContent}
|
||||
alt={
|
||||
ariaLabel ||
|
||||
`${labelLine1} ${labelLine2}` ||
|
||||
label ||
|
||||
"Feature icon"
|
||||
}
|
||||
className="max-w-[58px] max-h-[58px] w-auto h-auto object-contain"
|
||||
unoptimized
|
||||
width={0}
|
||||
height={0}
|
||||
sizes="100vw"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
const MiniCard = memo(
|
||||
({
|
||||
children,
|
||||
className = "",
|
||||
backgroundColor = "bg-[var(--color-surface-default-brand-royal)]",
|
||||
panelContent,
|
||||
label,
|
||||
labelLine1,
|
||||
labelLine2,
|
||||
onClick,
|
||||
href,
|
||||
ariaLabel,
|
||||
}) => {
|
||||
const cardContent = (
|
||||
<div className={`h-[186px] flex flex-col gap-[7px] ${className}`}>
|
||||
{/* Top part - Inner panel */}
|
||||
<div
|
||||
className={`flex-1 rounded-[var(--radius-measures-radius-xlarge)] border border-[1px] py-[var(--spacing-scale-032)] px-[var(--spacing-scale-024)] ${backgroundColor} flex items-center justify-center transition-all duration-200 hover:scale-[1.02] hover:shadow-lg`}
|
||||
>
|
||||
{/* Content for the inner panel */}
|
||||
{panelContent && (
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<Image
|
||||
src={panelContent}
|
||||
alt={
|
||||
ariaLabel ||
|
||||
`${labelLine1} ${labelLine2}` ||
|
||||
label ||
|
||||
"Feature icon"
|
||||
}
|
||||
className="max-w-[58px] max-h-[58px] w-auto h-auto object-contain"
|
||||
width={58}
|
||||
height={58}
|
||||
sizes="(max-width: 768px) 50vw, 25vw"
|
||||
loading="lazy"
|
||||
placeholder="blur"
|
||||
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k="
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Bottom part - Text container */}
|
||||
<div className="font-inter font-medium text-[12px] leading-[14px] text-center text-[var(--color-content-default-primary)]">
|
||||
{labelLine1 && labelLine2 ? (
|
||||
<>
|
||||
<div>{labelLine1}</div>
|
||||
<div>{labelLine2}</div>
|
||||
<div> </div>
|
||||
</>
|
||||
) : (
|
||||
label
|
||||
)}
|
||||
{/* Bottom part - Text container */}
|
||||
<div className="font-inter font-medium text-[12px] leading-[14px] text-center text-[var(--color-content-default-primary)]">
|
||||
{labelLine1 && labelLine2 ? (
|
||||
<>
|
||||
<div>{labelLine1}</div>
|
||||
<div>{labelLine2}</div>
|
||||
<div> </div>
|
||||
</>
|
||||
) : (
|
||||
label
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// If href is provided, render as a link
|
||||
if (href) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="block focus:outline-none focus:ring-2 focus:ring-[var(--color-surface-default-brand-royal)] focus:ring-offset-2 rounded-[var(--radius-measures-radius-xlarge)] transition-all duration-200 hover:scale-[1.02]"
|
||||
aria-label={
|
||||
ariaLabel || `${labelLine1} ${labelLine2}` || label || "Feature card"
|
||||
}
|
||||
tabIndex={0}
|
||||
>
|
||||
{cardContent}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
// If onClick is provided, render as a button
|
||||
if (onClick) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="block w-full text-left focus:outline-none focus:ring-2 focus:ring-[var(--color-surface-default-brand-royal)] focus:ring-offset-2 rounded-[var(--radius-measures-radius-xlarge)] transition-all duration-200 hover:scale-[1.02]"
|
||||
aria-label={
|
||||
ariaLabel || `${labelLine1} ${labelLine2}` || label || "Feature card"
|
||||
}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
// If href is provided, render as a link
|
||||
if (href) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="block focus:outline-none focus:ring-2 focus:ring-[var(--color-surface-default-brand-royal)] focus:ring-offset-2 rounded-[var(--radius-measures-radius-xlarge)] transition-all duration-200 hover:scale-[1.02]"
|
||||
aria-label={
|
||||
ariaLabel ||
|
||||
`${labelLine1} ${labelLine2}` ||
|
||||
label ||
|
||||
"Feature card"
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
>
|
||||
{cardContent}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
// If onClick is provided, render as a button
|
||||
if (onClick) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="block w-full text-left focus:outline-none focus:ring-2 focus:ring-[var(--color-surface-default-brand-royal)] focus:ring-offset-2 rounded-[var(--radius-measures-radius-xlarge)] transition-all duration-200 hover:scale-[1.02]"
|
||||
aria-label={
|
||||
ariaLabel ||
|
||||
`${labelLine1} ${labelLine2}` ||
|
||||
label ||
|
||||
"Feature card"
|
||||
}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{cardContent}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Default render as a div
|
||||
return (
|
||||
<div
|
||||
className="block"
|
||||
aria-label={
|
||||
ariaLabel || `${labelLine1} ${labelLine2}` || label || "Feature card"
|
||||
}
|
||||
>
|
||||
{cardContent}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Default render as a div
|
||||
return (
|
||||
<div
|
||||
className="block"
|
||||
aria-label={
|
||||
ariaLabel || `${labelLine1} ${labelLine2}` || label || "Feature card"
|
||||
}
|
||||
>
|
||||
{cardContent}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
MiniCard.displayName = "MiniCard";
|
||||
|
||||
export default MiniCard;
|
||||
|
||||
@@ -1,53 +1,62 @@
|
||||
export default function NavigationItem({
|
||||
href = "#",
|
||||
children,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
className = "",
|
||||
disabled = false,
|
||||
...props
|
||||
}) {
|
||||
// Variant styles
|
||||
const variantStyles = {
|
||||
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",
|
||||
};
|
||||
import React, { memo } from "react";
|
||||
|
||||
// Size styles
|
||||
const sizeStyles = {
|
||||
default:
|
||||
"px-[var(--spacing-scale-016)] py-[var(--spacing-scale-016)] gap-[var(--spacing-scale-004)]",
|
||||
xsmall:
|
||||
"px-[var(--spacing-scale-004)] py-[var(--spacing-scale-002)] gap-[var(--spacing-scale-004)]",
|
||||
};
|
||||
const NavigationItem = memo(
|
||||
({
|
||||
href = "#",
|
||||
children,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
className = "",
|
||||
disabled = false,
|
||||
...props
|
||||
}) => {
|
||||
// Variant styles
|
||||
const variantStyles = {
|
||||
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",
|
||||
};
|
||||
|
||||
// Text styles based on size
|
||||
const textStyles = {
|
||||
default: "font-inter text-[10px] leading-[12px] font-medium tracking-[0%]",
|
||||
xsmall: "font-inter text-[10px] leading-[12px] font-medium tracking-[0%]",
|
||||
};
|
||||
// Size styles
|
||||
const sizeStyles = {
|
||||
default:
|
||||
"px-[var(--spacing-scale-016)] py-[var(--spacing-scale-016)] gap-[var(--spacing-scale-004)]",
|
||||
xsmall:
|
||||
"px-[var(--spacing-scale-004)] py-[var(--spacing-scale-002)] gap-[var(--spacing-scale-004)]",
|
||||
};
|
||||
|
||||
const baseStyles = `inline-flex items-center ${sizeStyles[size]} rounded-[var(--radius-measures-radius-full)] ${textStyles[size]} transition-all duration-200 cursor-pointer`;
|
||||
// Text styles based on size
|
||||
const textStyles = {
|
||||
default:
|
||||
"font-inter text-[10px] leading-[12px] font-medium tracking-[0%]",
|
||||
xsmall: "font-inter text-[10px] leading-[12px] font-medium tracking-[0%]",
|
||||
};
|
||||
|
||||
// Determine which variant to use
|
||||
let finalVariant = variant;
|
||||
if (disabled) {
|
||||
finalVariant = "default"; // The disabled state is handled by disabled: utilities
|
||||
}
|
||||
const baseStyles = `inline-flex items-center ${sizeStyles[size]} rounded-[var(--radius-measures-radius-full)] ${textStyles[size]} transition-all duration-200 cursor-pointer`;
|
||||
|
||||
const combinedStyles = `${baseStyles} ${variantStyles[finalVariant]} ${className}`;
|
||||
// Determine which variant to use
|
||||
let finalVariant = variant;
|
||||
if (disabled) {
|
||||
finalVariant = "default"; // The disabled state is handled by disabled: utilities
|
||||
}
|
||||
|
||||
const combinedStyles = `${baseStyles} ${variantStyles[finalVariant]} ${className}`;
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<span className={combinedStyles} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<span className={combinedStyles} {...props}>
|
||||
<a href={href} className={combinedStyles} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<a href={href} className={combinedStyles} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
NavigationItem.displayName = "NavigationItem";
|
||||
|
||||
export default NavigationItem;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import React, { memo } from "react";
|
||||
import SectionNumber from "./SectionNumber";
|
||||
|
||||
const NumberedCard = ({ number, text, iconShape, iconColor }) => {
|
||||
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) */}
|
||||
@@ -18,6 +19,8 @@ const NumberedCard = ({ number, text, iconShape, iconColor }) => {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
NumberedCard.displayName = "NumberedCard";
|
||||
|
||||
export default NumberedCard;
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import React, { memo, useMemo } from "react";
|
||||
import NumberedCard from "./NumberedCard";
|
||||
import SectionHeader from "./SectionHeader";
|
||||
import Button from "./Button";
|
||||
|
||||
const NumberedCards = ({ title, subtitle, cards }) => {
|
||||
// Schema markup for SEO
|
||||
const schemaData = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "HowTo",
|
||||
name: title,
|
||||
description: subtitle,
|
||||
step: cards.map((card, index) => ({
|
||||
"@type": "HowToStep",
|
||||
position: index + 1,
|
||||
name: card.text,
|
||||
text: card.text,
|
||||
})),
|
||||
};
|
||||
const NumberedCards = memo(({ title, subtitle, cards }) => {
|
||||
// Memoize schema data to prevent unnecessary re-computations
|
||||
const schemaData = useMemo(
|
||||
() => ({
|
||||
"@context": "https://schema.org",
|
||||
"@type": "HowTo",
|
||||
name: title,
|
||||
description: subtitle,
|
||||
step: cards.map((card, index) => ({
|
||||
"@type": "HowToStep",
|
||||
position: index + 1,
|
||||
name: card.text,
|
||||
text: card.text,
|
||||
})),
|
||||
}),
|
||||
[title, subtitle, cards],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -70,6 +74,8 @@ const NumberedCards = ({ title, subtitle, cards }) => {
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
NumberedCards.displayName = "NumberedCards";
|
||||
|
||||
export default NumberedCards;
|
||||
|
||||
+224
-219
@@ -1,247 +1,252 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import React, { useState, memo } from "react";
|
||||
import Image from "next/image";
|
||||
import QuoteDecor from "./QuoteDecor";
|
||||
|
||||
const QuoteBlock = ({
|
||||
variant = "standard",
|
||||
className = "",
|
||||
quote = "The rules of decision-making must be open and available to everyone, and this can happen only if they are formalized.",
|
||||
author = "Jo Freeman",
|
||||
source = "The Tyranny of Structurelessness",
|
||||
avatarSrc = "assets/Quote_Avatar.svg",
|
||||
id,
|
||||
fallbackAvatarSrc = "assets/Quote_Avatar.svg", // Fallback avatar
|
||||
onError, // Error callback
|
||||
}) => {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const [imageLoading, setImageLoading] = useState(true);
|
||||
const QuoteBlock = memo(
|
||||
({
|
||||
variant = "standard",
|
||||
className = "",
|
||||
quote = "The rules of decision-making must be open and available to everyone, and this can happen only if they are formalized.",
|
||||
author = "Jo Freeman",
|
||||
source = "The Tyranny of Structurelessness",
|
||||
avatarSrc = "/assets/Quote_Avatar.svg",
|
||||
id,
|
||||
fallbackAvatarSrc = "/assets/Quote_Avatar.svg", // Fallback avatar
|
||||
onError, // Error callback
|
||||
}) => {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const [imageLoading, setImageLoading] = useState(true);
|
||||
|
||||
// Variant configurations
|
||||
const variants = {
|
||||
compact: {
|
||||
container: "py-[var(--spacing-scale-032)] px-[var(--spacing-scale-016)]",
|
||||
card: "py-[var(--spacing-scale-032)] px-[var(--spacing-scale-016)] md:py-[var(--spacing-scale-040)] md:px-[var(--spacing-scale-024)] rounded-[var(--radius-measures-radius-small)]",
|
||||
gap: "gap-[var(--spacing-scale-016)] md:gap-[var(--spacing-scale-024)]",
|
||||
avatarGap: "gap-[var(--spacing-scale-012)]",
|
||||
avatar: "w-[48px] h-[48px] md:w-[64px] md:h-[64px]",
|
||||
quote: "text-[16px] leading-[120%] md:text-[20px] md:leading-[110%]",
|
||||
author: "text-[10px] leading-[120%] md:text-[12px]",
|
||||
source: "text-[10px] leading-[120%] md:text-[12px]",
|
||||
showDecor: false,
|
||||
},
|
||||
standard: {
|
||||
container:
|
||||
"md:py-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-016)] lg:p-[var(--spacing-scale-064)]",
|
||||
card: "py-[var(--spacing-scale-064)] px-[var(--spacing-scale-020)] md:py-[var(--spacing-scale-064)] md:px-[var(--spacing-scale-048)] md:rounded-[var(--radius-measures-radius-medium)] lg:py-[var(--spacing-scale-064)] lg:pl-[120px] lg:pr-[320px]",
|
||||
gap: "gap-[var(--spacing-scale-024)] md:gap-[var(--spacing-scale-048)] lg:gap-[var(--spacing-scale-064)] xl:gap-[105px]",
|
||||
avatarGap:
|
||||
"gap-[var(--spacing-scale-020)] lg:gap-[var(--spacing-scale-018)] xl:gap-[var(--spacing-scale-032)]",
|
||||
avatar:
|
||||
"md:w-[120px] md:h-[120px] lg:w-[150px] lg:h-[150px] xl:w-[200px] xl:h-[200px]",
|
||||
quote:
|
||||
"text-[18px] leading-[120%] md:text-[36px] md:leading-[110%] md:tracking-[0px] lg:text-[52px] xl:text-[64px]",
|
||||
author:
|
||||
"text-[12px] leading-[120%] md:text-[18px] md:leading-[120%] md:tracking-[0.24px] lg:text-[24px] xl:text-[32px]",
|
||||
source:
|
||||
"text-[12px] leading-[120%] md:text-[18px] md:leading-[120%] md:tracking-[0.24px] lg:text-[24px] xl:text-[32px]",
|
||||
showDecor: true,
|
||||
},
|
||||
extended: {
|
||||
container:
|
||||
"py-[var(--spacing-scale-048)] px-[var(--spacing-scale-024)] md:py-[var(--spacing-scale-064)] md:px-[var(--spacing-scale-032)] lg:py-[var(--spacing-scale-080)] lg:px-[var(--spacing-scale-048)]",
|
||||
card: "py-[var(--spacing-scale-080)] px-[var(--spacing-scale-032)] md:py-[var(--spacing-scale-096)] md:px-[var(--spacing-scale-064)] md:rounded-[var(--radius-measures-radius-large)] lg:py-[var(--spacing-scale-112)] lg:pl-[160px] lg:pr-[400px]",
|
||||
gap: "gap-[var(--spacing-scale-032)] md:gap-[var(--spacing-scale-064)] lg:gap-[var(--spacing-scale-080)] xl:gap-[140px]",
|
||||
avatarGap:
|
||||
"gap-[var(--spacing-scale-032)] lg:gap-[var(--spacing-scale-040)] xl:gap-[var(--spacing-scale-048)]",
|
||||
avatar:
|
||||
"w-[80px] h-[80px] md:w-[140px] md:h-[140px] lg:w-[180px] lg:h-[180px] xl:w-[240px] xl:h-[240px]",
|
||||
quote:
|
||||
"text-[20px] leading-[120%] md:text-[40px] md:leading-[110%] md:tracking-[0px] lg:text-[60px] xl:text-[72px]",
|
||||
author:
|
||||
"text-[14px] leading-[120%] md:text-[20px] md:leading-[120%] md:tracking-[0.24px] lg:text-[28px] xl:text-[36px]",
|
||||
source:
|
||||
"text-[14px] leading-[120%] md:text-[20px] md:leading-[120%] md:tracking-[0.24px] lg:text-[28px] xl:text-[36px]",
|
||||
showDecor: true,
|
||||
},
|
||||
};
|
||||
// Variant configurations
|
||||
const variants = {
|
||||
compact: {
|
||||
container:
|
||||
"py-[var(--spacing-scale-032)] px-[var(--spacing-scale-016)]",
|
||||
card: "py-[var(--spacing-scale-032)] px-[var(--spacing-scale-016)] md:py-[var(--spacing-scale-040)] md:px-[var(--spacing-scale-024)] rounded-[var(--radius-measures-radius-small)]",
|
||||
gap: "gap-[var(--spacing-scale-016)] md:gap-[var(--spacing-scale-024)]",
|
||||
avatarGap: "gap-[var(--spacing-scale-012)]",
|
||||
avatar: "w-[48px] h-[48px] md:w-[64px] md:h-[64px]",
|
||||
quote: "text-[16px] leading-[120%] md:text-[20px] md:leading-[110%]",
|
||||
author: "text-[10px] leading-[120%] md:text-[12px]",
|
||||
source: "text-[10px] leading-[120%] md:text-[12px]",
|
||||
showDecor: false,
|
||||
},
|
||||
standard: {
|
||||
container:
|
||||
"md:py-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-016)] lg:p-[var(--spacing-scale-064)]",
|
||||
card: "py-[var(--spacing-scale-064)] px-[var(--spacing-scale-020)] md:py-[var(--spacing-scale-064)] md:px-[var(--spacing-scale-048)] md:rounded-[var(--radius-measures-radius-medium)] lg:py-[var(--spacing-scale-064)] lg:pl-[120px] lg:pr-[320px]",
|
||||
gap: "gap-[var(--spacing-scale-024)] md:gap-[var(--spacing-scale-048)] lg:gap-[var(--spacing-scale-064)] xl:gap-[105px]",
|
||||
avatarGap:
|
||||
"gap-[var(--spacing-scale-020)] lg:gap-[var(--spacing-scale-018)] xl:gap-[var(--spacing-scale-032)]",
|
||||
avatar:
|
||||
"md:w-[120px] md:h-[120px] lg:w-[150px] lg:h-[150px] xl:w-[200px] xl:h-[200px]",
|
||||
quote:
|
||||
"text-[18px] leading-[120%] md:text-[36px] md:leading-[110%] md:tracking-[0px] lg:text-[52px] xl:text-[64px]",
|
||||
author:
|
||||
"text-[12px] leading-[120%] md:text-[18px] md:leading-[120%] md:tracking-[0.24px] lg:text-[24px] xl:text-[32px]",
|
||||
source:
|
||||
"text-[12px] leading-[120%] md:text-[18px] md:leading-[120%] md:tracking-[0.24px] lg:text-[24px] xl:text-[32px]",
|
||||
showDecor: true,
|
||||
},
|
||||
extended: {
|
||||
container:
|
||||
"py-[var(--spacing-scale-048)] px-[var(--spacing-scale-024)] md:py-[var(--spacing-scale-064)] md:px-[var(--spacing-scale-032)] lg:py-[var(--spacing-scale-080)] lg:px-[var(--spacing-scale-048)]",
|
||||
card: "py-[var(--spacing-scale-080)] px-[var(--spacing-scale-032)] md:py-[var(--spacing-scale-096)] md:px-[var(--spacing-scale-064)] md:rounded-[var(--radius-measures-radius-large)] lg:py-[var(--spacing-scale-112)] lg:pl-[160px] lg:pr-[400px]",
|
||||
gap: "gap-[var(--spacing-scale-032)] md:gap-[var(--spacing-scale-064)] lg:gap-[var(--spacing-scale-080)] xl:gap-[140px]",
|
||||
avatarGap:
|
||||
"gap-[var(--spacing-scale-032)] lg:gap-[var(--spacing-scale-040)] xl:gap-[var(--spacing-scale-048)]",
|
||||
avatar:
|
||||
"w-[80px] h-[80px] md:w-[140px] md:h-[140px] lg:w-[180px] lg:h-[180px] xl:w-[240px] xl:h-[240px]",
|
||||
quote:
|
||||
"text-[20px] leading-[120%] md:text-[40px] md:leading-[110%] md:tracking-[0px] lg:text-[60px] xl:text-[72px]",
|
||||
author:
|
||||
"text-[14px] leading-[120%] md:text-[20px] md:leading-[120%] md:tracking-[0.24px] lg:text-[28px] xl:text-[36px]",
|
||||
source:
|
||||
"text-[14px] leading-[120%] md:text-[20px] md:leading-[120%] md:tracking-[0.24px] lg:text-[28px] xl:text-[36px]",
|
||||
showDecor: true,
|
||||
},
|
||||
};
|
||||
|
||||
const config = variants[variant] || variants.standard;
|
||||
const config = variants[variant] || variants.standard;
|
||||
|
||||
// Use provided ID or generate a stable one based on content
|
||||
const baseId = id || `quote-${author.toLowerCase().replace(/\s+/g, "-")}`;
|
||||
const quoteId = `${baseId}-content`;
|
||||
const authorId = `${baseId}-author`;
|
||||
// Use provided ID or generate a stable one based on content
|
||||
const baseId = id || `quote-${author.toLowerCase().replace(/\s+/g, "-")}`;
|
||||
const quoteId = `${baseId}-content`;
|
||||
const authorId = `${baseId}-author`;
|
||||
|
||||
// Error handling functions
|
||||
const handleImageError = (error) => {
|
||||
console.warn(
|
||||
`QuoteBlock: Failed to load avatar image for ${author}:`,
|
||||
error,
|
||||
);
|
||||
setImageError(true);
|
||||
setImageLoading(false);
|
||||
|
||||
// Call error callback if provided
|
||||
if (onError) {
|
||||
onError({
|
||||
type: "image_load_error",
|
||||
message: `Failed to load avatar for ${author}`,
|
||||
author,
|
||||
avatarSrc,
|
||||
// Error handling functions
|
||||
const handleImageError = (error) => {
|
||||
console.warn(
|
||||
`QuoteBlock: Failed to load avatar image for ${author}:`,
|
||||
error,
|
||||
});
|
||||
);
|
||||
setImageError(true);
|
||||
setImageLoading(false);
|
||||
|
||||
// Call error callback if provided
|
||||
if (onError) {
|
||||
onError({
|
||||
type: "image_load_error",
|
||||
message: `Failed to load avatar for ${author}`,
|
||||
author,
|
||||
avatarSrc,
|
||||
error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageLoad = () => {
|
||||
setImageLoading(false);
|
||||
setImageError(false);
|
||||
};
|
||||
|
||||
// Validate required props
|
||||
if (!quote || !author) {
|
||||
console.error("QuoteBlock: Missing required props (quote or author)");
|
||||
if (onError) {
|
||||
onError({
|
||||
type: "missing_props",
|
||||
message: "QuoteBlock requires quote and author props",
|
||||
quote: !!quote,
|
||||
author: !!author,
|
||||
});
|
||||
}
|
||||
return null; // Don't render if missing required props
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageLoad = () => {
|
||||
setImageLoading(false);
|
||||
setImageError(false);
|
||||
};
|
||||
// Determine which avatar to use
|
||||
const currentAvatarSrc = imageError ? fallbackAvatarSrc : avatarSrc;
|
||||
|
||||
// Validate required props
|
||||
if (!quote || !author) {
|
||||
console.error("QuoteBlock: Missing required props (quote or author)");
|
||||
if (onError) {
|
||||
onError({
|
||||
type: "missing_props",
|
||||
message: "QuoteBlock requires quote and author props",
|
||||
quote: !!quote,
|
||||
author: !!author,
|
||||
});
|
||||
}
|
||||
return null; // Don't render if missing required props
|
||||
}
|
||||
|
||||
// Determine which avatar to use
|
||||
const currentAvatarSrc = imageError ? fallbackAvatarSrc : avatarSrc;
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`${config.container} ${className}`}
|
||||
aria-labelledby={quoteId}
|
||||
role="region"
|
||||
>
|
||||
<div
|
||||
className={`${config.card} bg-[var(--color-surface-default-brand-darker-accent)] relative overflow-hidden`}
|
||||
return (
|
||||
<section
|
||||
className={`${config.container} ${className}`}
|
||||
aria-labelledby={quoteId}
|
||||
role="region"
|
||||
>
|
||||
{/* Background with noise texture */}
|
||||
<div
|
||||
className="absolute inset-0 bg-[var(--color-surface-default-brand-darker-accent)]"
|
||||
style={{
|
||||
filter:
|
||||
'url(\'data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg"><defs><filter id="grain" filterUnits="objectBoundingBox" x="0" y="0" width="1" height="1" colorInterpolationFilters="sRGB"><feTurbulence type="fractalNoise" baseFrequency="0.4" numOctaves="3" seed="7" stitchTiles="stitch" result="noise"/><feColorMatrix in="noise" result="softNoise" type="matrix" values="0.8 0 0 0 0.3 0 0.6 0 0 0.2 0 0 1.0 0 0.4 0 0 0 0.25 0"/><feComposite in="softNoise" in2="SourceAlpha" operator="in" result="maskedNoise"/><feBlend in="SourceGraphic" in2="maskedNoise" mode="multiply"/></filter></defs></svg>#grain\')',
|
||||
}}
|
||||
/>
|
||||
className={`${config.card} bg-[var(--color-surface-default-brand-darker-accent)] relative overflow-hidden`}
|
||||
>
|
||||
{/* Background with noise texture */}
|
||||
<div
|
||||
className="absolute inset-0 bg-[var(--color-surface-default-brand-darker-accent)]"
|
||||
style={{
|
||||
filter:
|
||||
'url(\'data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg"><defs><filter id="grain" filterUnits="objectBoundingBox" x="0" y="0" width="1" height="1" colorInterpolationFilters="sRGB"><feTurbulence type="fractalNoise" baseFrequency="0.4" numOctaves="3" seed="7" stitchTiles="stitch" result="noise"/><feColorMatrix in="noise" result="softNoise" type="matrix" values="0.8 0 0 0 0.3 0 0.6 0 0 0.2 0 0 1.0 0 0.4 0 0 0 0.25 0"/><feComposite in="softNoise" in2="SourceAlpha" operator="in" result="maskedNoise"/><feBlend in="SourceGraphic" in2="maskedNoise" mode="multiply"/></filter></defs></svg>#grain\')',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* DECORATIONS (behind content) */}
|
||||
{config.showDecor && (
|
||||
<QuoteDecor
|
||||
className="pointer-events-none absolute z-0
|
||||
{/* DECORATIONS (behind content) */}
|
||||
{config.showDecor && (
|
||||
<QuoteDecor
|
||||
className="pointer-events-none absolute z-0
|
||||
left-0 top-0
|
||||
w-full h-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={`flex flex-col ${config.gap} relative z-10`}>
|
||||
<div className={`flex flex-col ${config.avatarGap}`}>
|
||||
{/* Avatar with error handling */}
|
||||
<div className="relative">
|
||||
{!imageError ? (
|
||||
<Image
|
||||
src={avatarSrc}
|
||||
alt={`Portrait of ${author}`}
|
||||
width={64}
|
||||
height={64}
|
||||
className={`filter sepia ${
|
||||
config.avatar
|
||||
} transition-opacity duration-300 ${
|
||||
imageLoading ? "opacity-0" : "opacity-100"
|
||||
}`}
|
||||
loading="lazy"
|
||||
onError={handleImageError}
|
||||
onLoad={handleImageLoad}
|
||||
/>
|
||||
) : null}
|
||||
<div className={`flex flex-col ${config.gap} relative z-10`}>
|
||||
<div className={`flex flex-col ${config.avatarGap}`}>
|
||||
{/* Avatar with error handling */}
|
||||
<div className="relative">
|
||||
{!imageError ? (
|
||||
<Image
|
||||
src={avatarSrc}
|
||||
alt={`Portrait of ${author}`}
|
||||
width={64}
|
||||
height={64}
|
||||
className={`filter sepia ${
|
||||
config.avatar
|
||||
} transition-opacity duration-300 ${
|
||||
imageLoading ? "opacity-0" : "opacity-100"
|
||||
}`}
|
||||
loading="lazy"
|
||||
onError={handleImageError}
|
||||
onLoad={handleImageLoad}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Loading state */}
|
||||
{imageLoading && !imageError && (
|
||||
<div
|
||||
className={`absolute inset-0 bg-gray-200 animate-pulse rounded-full ${config.avatar}`}
|
||||
/>
|
||||
)}
|
||||
{/* Loading state */}
|
||||
{imageLoading && !imageError && (
|
||||
<div
|
||||
className={`absolute inset-0 bg-gray-200 animate-pulse rounded-full ${config.avatar}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Error state - show initials */}
|
||||
{imageError && (
|
||||
<div
|
||||
className={`flex items-center justify-center bg-gray-300 rounded-full ${config.avatar} text-gray-600 font-bold`}
|
||||
{/* Error state - show initials */}
|
||||
{imageError && (
|
||||
<div
|
||||
className={`flex items-center justify-center bg-gray-300 rounded-full ${config.avatar} text-gray-600 font-bold`}
|
||||
>
|
||||
<span className="text-sm md:text-base lg:text-lg xl:text-xl">
|
||||
{author
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<blockquote
|
||||
id={quoteId}
|
||||
aria-labelledby={authorId}
|
||||
className="relative"
|
||||
>
|
||||
<p
|
||||
data-qopen="“"
|
||||
data-qclose="”"
|
||||
className={[
|
||||
"font-bricolage-grotesque font-normal",
|
||||
config.quote,
|
||||
"text-[var(--color-content-inverse-primary)]",
|
||||
// give space for the hanging open-quote so it's not clipped:
|
||||
"pl-[0.6em] -indent-[0.6em]",
|
||||
// inject quotes
|
||||
"relative before:content-[attr(data-qopen)] after:content-[attr(data-qclose)]",
|
||||
// lock quote glyphs to your display face
|
||||
"before:[font-family:var(--font-bricolage-grotesque)]",
|
||||
"after:[font-family:var(--font-bricolage-grotesque)]",
|
||||
].join(" ")}
|
||||
>
|
||||
<span className="text-sm md:text-base lg:text-lg xl:text-xl">
|
||||
{author
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{quote}
|
||||
</p>
|
||||
</blockquote>
|
||||
</div>
|
||||
|
||||
<blockquote
|
||||
id={quoteId}
|
||||
aria-labelledby={authorId}
|
||||
className="relative"
|
||||
>
|
||||
<p
|
||||
data-qopen="“"
|
||||
data-qclose="”"
|
||||
className={[
|
||||
"font-bricolage-grotesque font-normal",
|
||||
config.quote,
|
||||
"text-[var(--color-content-inverse-primary)]",
|
||||
// give space for the hanging open-quote so it's not clipped:
|
||||
"pl-[0.6em] -indent-[0.6em]",
|
||||
// inject quotes
|
||||
"relative before:content-[attr(data-qopen)] after:content-[attr(data-qclose)]",
|
||||
// lock quote glyphs to your display face
|
||||
"before:[font-family:var(--font-bricolage-grotesque)]",
|
||||
"after:[font-family:var(--font-bricolage-grotesque)]",
|
||||
].join(" ")}
|
||||
<footer className="flex flex-col gap-[var(--spacing-scale-008)] md:gap-[var(--spacing-scale-012)] xl:gap-[var(--spacing-scale-020)]">
|
||||
<cite
|
||||
id={authorId}
|
||||
className={`font-inter font-normal ${config.author} text-[var(--color-content-inverse-primary)] uppercase not-italic`}
|
||||
>
|
||||
{quote}
|
||||
</p>
|
||||
</blockquote>
|
||||
{author}
|
||||
</cite>
|
||||
{source && (
|
||||
<p
|
||||
data-qopen="“"
|
||||
data-qclose="”"
|
||||
className={[
|
||||
"font-inter font-normal",
|
||||
config.source,
|
||||
"text-[var(--color-content-inverse-primary)] uppercase",
|
||||
"pl-[0.6em] -indent-[0.6em]",
|
||||
"relative before:content-[attr(data-qopen)] after:content-[attr(data-qclose)]",
|
||||
"before:[font-family:var(--font-inter)] after:[font-family:var(--font-inter)]",
|
||||
].join(" ")}
|
||||
>
|
||||
{source}
|
||||
</p>
|
||||
)}
|
||||
</footer>
|
||||
</div>
|
||||
<footer className="flex flex-col gap-[var(--spacing-scale-008)] md:gap-[var(--spacing-scale-012)] xl:gap-[var(--spacing-scale-020)]">
|
||||
<cite
|
||||
id={authorId}
|
||||
className={`font-inter font-normal ${config.author} text-[var(--color-content-inverse-primary)] uppercase not-italic`}
|
||||
>
|
||||
{author}
|
||||
</cite>
|
||||
{source && (
|
||||
<p
|
||||
data-qopen="“"
|
||||
data-qclose="”"
|
||||
className={[
|
||||
"font-inter font-normal",
|
||||
config.source,
|
||||
"text-[var(--color-content-inverse-primary)] uppercase",
|
||||
"pl-[0.6em] -indent-[0.6em]",
|
||||
"relative before:content-[attr(data-qopen)] after:content-[attr(data-qclose)]",
|
||||
"before:[font-family:var(--font-inter)] after:[font-family:var(--font-inter)]",
|
||||
].join(" ")}
|
||||
>
|
||||
{source}
|
||||
</p>
|
||||
)}
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
</section>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
QuoteBlock.displayName = "QuoteBlock";
|
||||
|
||||
export default QuoteBlock;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
const QuoteDecor = ({ className = "" }) => {
|
||||
import React, { memo } from "react";
|
||||
|
||||
const QuoteDecor = memo(({ 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}`}
|
||||
@@ -68,6 +70,8 @@ const QuoteDecor = ({ className = "" }) => {
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
QuoteDecor.displayName = "QuoteDecor";
|
||||
|
||||
export default QuoteDecor;
|
||||
|
||||
+141
-130
@@ -1,152 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, memo, useMemo, useCallback } from "react";
|
||||
import ContentThumbnailTemplate from "./ContentThumbnailTemplate";
|
||||
|
||||
export default function RelatedArticles({
|
||||
relatedPosts,
|
||||
currentPostSlug,
|
||||
slugOrder = [],
|
||||
}) {
|
||||
// Filter out the current post from related posts
|
||||
const filteredPosts = relatedPosts.filter(
|
||||
(post) => post.slug !== currentPostSlug,
|
||||
);
|
||||
const RelatedArticles = memo(
|
||||
({ relatedPosts, currentPostSlug, slugOrder = [] }) => {
|
||||
// Memoize filtered posts to prevent unnecessary re-computations
|
||||
const filteredPosts = useMemo(
|
||||
() => relatedPosts.filter((post) => post.slug !== currentPostSlug),
|
||||
[relatedPosts, currentPostSlug],
|
||||
);
|
||||
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [isMobile, setIsMobile] = useState(true);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [isMobile, setIsMobile] = useState(true);
|
||||
|
||||
// Check if we're on mobile (below lg breakpoint)
|
||||
useEffect(() => {
|
||||
const checkScreenSize = () => {
|
||||
setIsMobile(window.innerWidth < 1024); // lg breakpoint is 1024px
|
||||
};
|
||||
// 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;
|
||||
|
||||
checkScreenSize();
|
||||
window.addEventListener("resize", checkScreenSize);
|
||||
return () => window.removeEventListener("resize", checkScreenSize);
|
||||
}, []);
|
||||
const handleMouseMove = (e) => {
|
||||
const x = e.pageX - slider.offsetLeft;
|
||||
const walk = (x - startX) * 2;
|
||||
slider.scrollLeft = scrollLeft - walk;
|
||||
};
|
||||
|
||||
// Auto-advance every 3 seconds (only on mobile)
|
||||
useEffect(() => {
|
||||
if (filteredPosts.length <= 1 || !isMobile) return;
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setProgress(0);
|
||||
setCurrentIndex((prev) => (prev + 1) % filteredPosts.length);
|
||||
}, 3000);
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
}, []);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [filteredPosts.length, isMobile]);
|
||||
// Memoize transform style to prevent unnecessary recalculations
|
||||
const transformStyle = useMemo(
|
||||
() => ({
|
||||
transform: isMobile
|
||||
? `translateX(calc(50% - 130px - ${currentIndex * 260}px))`
|
||||
: "none",
|
||||
scrollBehavior: !isMobile ? "smooth" : "auto",
|
||||
}),
|
||||
[isMobile, currentIndex],
|
||||
);
|
||||
|
||||
// Progress animation (only on mobile)
|
||||
useEffect(() => {
|
||||
if (filteredPosts.length <= 1 || !isMobile) return;
|
||||
// Memoize progress bar style calculation
|
||||
const getProgressStyle = useCallback(
|
||||
(index) => ({
|
||||
width:
|
||||
index === currentIndex
|
||||
? `${progress}%`
|
||||
: index < currentIndex
|
||||
? "100%"
|
||||
: "0%",
|
||||
}),
|
||||
[currentIndex, progress],
|
||||
);
|
||||
|
||||
const progressInterval = setInterval(() => {
|
||||
setProgress((prev) => {
|
||||
if (prev >= 100) {
|
||||
return 0;
|
||||
}
|
||||
return prev + 1;
|
||||
});
|
||||
}, 30); // 30ms intervals for smooth animation
|
||||
// Check if we're on mobile (below lg breakpoint)
|
||||
useEffect(() => {
|
||||
const checkScreenSize = () => {
|
||||
setIsMobile(window.innerWidth < 1024); // lg breakpoint is 1024px
|
||||
};
|
||||
|
||||
return () => clearInterval(progressInterval);
|
||||
}, [currentIndex, filteredPosts.length, isMobile]);
|
||||
checkScreenSize();
|
||||
window.addEventListener("resize", checkScreenSize);
|
||||
return () => window.removeEventListener("resize", checkScreenSize);
|
||||
}, []);
|
||||
|
||||
if (filteredPosts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
// Auto-advance every 3 seconds (only on mobile)
|
||||
useEffect(() => {
|
||||
if (filteredPosts.length <= 1 || !isMobile) return;
|
||||
|
||||
return (
|
||||
<section className="py-[var(--spacing-scale-032)] lg:py-[var(--spacing-scale-064)]">
|
||||
<div className="flex flex-col gap-[var(--spacing-scale-032)] lg:gap-[51px]">
|
||||
<h2 className="text-[32px] lg:text-[44px] leading-[110%] font-medium text-[var(--color-content-inverse-primary)] text-center">
|
||||
Related Articles
|
||||
</h2>
|
||||
const interval = setInterval(() => {
|
||||
setProgress(0);
|
||||
setCurrentIndex((prev) => (prev + 1) % filteredPosts.length);
|
||||
}, 3000);
|
||||
|
||||
{/* Horizontal Articles Row - Carousel on mobile, Scrollable slider on desktop */}
|
||||
<div className="flex justify-center overflow-hidden">
|
||||
<div
|
||||
className={`flex gap-0 transition-transform duration-500 ease-in-out ${
|
||||
!isMobile
|
||||
? "overflow-x-auto scrollbar-hide cursor-grab active:cursor-grabbing"
|
||||
: ""
|
||||
}`}
|
||||
style={{
|
||||
transform: isMobile
|
||||
? `translateX(calc(50% - 130px - ${currentIndex * 260}px))`
|
||||
: "none",
|
||||
scrollBehavior: !isMobile ? "smooth" : "auto",
|
||||
}}
|
||||
onMouseDown={
|
||||
!isMobile
|
||||
? (e) => {
|
||||
const slider = e.currentTarget;
|
||||
const startX = e.pageX - slider.offsetLeft;
|
||||
const scrollLeft = slider.scrollLeft;
|
||||
return () => clearInterval(interval);
|
||||
}, [filteredPosts.length, isMobile]);
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
const x = e.pageX - slider.offsetLeft;
|
||||
const walk = (x - startX) * 2;
|
||||
slider.scrollLeft = scrollLeft - walk;
|
||||
};
|
||||
// Progress animation (only on mobile)
|
||||
useEffect(() => {
|
||||
if (filteredPosts.length <= 1 || !isMobile) return;
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener(
|
||||
"mousemove",
|
||||
handleMouseMove,
|
||||
);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
const progressInterval = setInterval(() => {
|
||||
setProgress((prev) => {
|
||||
if (prev >= 100) {
|
||||
return 0;
|
||||
}
|
||||
return prev + 1;
|
||||
});
|
||||
}, 30); // 30ms intervals for smooth animation
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{filteredPosts.map((relatedPost, index) => (
|
||||
<div
|
||||
key={relatedPost.slug}
|
||||
className="flex flex-col items-center flex-shrink-0"
|
||||
>
|
||||
<ContentThumbnailTemplate
|
||||
post={relatedPost}
|
||||
variant="vertical"
|
||||
slugOrder={slugOrder}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
return () => clearInterval(progressInterval);
|
||||
}, [currentIndex, filteredPosts.length, isMobile]);
|
||||
|
||||
{/* Progress bars - only show on mobile */}
|
||||
{isMobile && (
|
||||
<div className="flex justify-center gap-[var(--measures-spacing-008)] px-[var(--measures-spacing-064)]">
|
||||
{filteredPosts.map((relatedPost, index) => (
|
||||
<div
|
||||
key={relatedPost.slug}
|
||||
className="max-w-[var(--measures-spacing-056)] w-full h-[var(--measures-spacing-004)] bg-gray-200 rounded-full overflow-hidden"
|
||||
>
|
||||
if (filteredPosts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="py-[var(--spacing-scale-032)] lg:py-[var(--spacing-scale-064)]">
|
||||
<div className="flex flex-col gap-[var(--spacing-scale-032)] lg:gap-[51px]">
|
||||
<h2 className="text-[32px] lg:text-[44px] leading-[110%] font-medium text-[var(--color-content-inverse-primary)] text-center">
|
||||
Related Articles
|
||||
</h2>
|
||||
|
||||
{/* Horizontal Articles Row - Carousel on mobile, Scrollable slider on desktop */}
|
||||
<div className="flex justify-center overflow-hidden">
|
||||
<div
|
||||
className={`flex gap-0 transition-transform duration-500 ease-in-out ${
|
||||
!isMobile
|
||||
? "overflow-x-auto scrollbar-hide cursor-grab active:cursor-grabbing"
|
||||
: ""
|
||||
}`}
|
||||
style={transformStyle}
|
||||
onMouseDown={!isMobile ? handleMouseDown : undefined}
|
||||
>
|
||||
{filteredPosts.map((relatedPost, index) => (
|
||||
<div
|
||||
className="h-full bg-gray-600 rounded-full transition-all duration-75 ease-linear"
|
||||
style={{
|
||||
width:
|
||||
index === currentIndex
|
||||
? `${progress}%`
|
||||
: index < currentIndex
|
||||
? "100%"
|
||||
: "0%",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
key={relatedPost.slug}
|
||||
className="flex flex-col items-center flex-shrink-0"
|
||||
>
|
||||
<ContentThumbnailTemplate
|
||||
post={relatedPost}
|
||||
variant="vertical"
|
||||
slugOrder={slugOrder}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Progress bars - only show on mobile */}
|
||||
{isMobile && (
|
||||
<div className="flex justify-center gap-[var(--measures-spacing-008)] px-[var(--measures-spacing-064)]">
|
||||
{filteredPosts.map((relatedPost, index) => (
|
||||
<div
|
||||
key={relatedPost.slug}
|
||||
className="max-w-[var(--measures-spacing-056)] w-full h-[var(--measures-spacing-004)] bg-gray-200 rounded-full overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className="h-full bg-gray-600 rounded-full transition-all duration-75 ease-linear"
|
||||
style={getProgressStyle(index)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
RelatedArticles.displayName = "RelatedArticles";
|
||||
|
||||
export default RelatedArticles;
|
||||
|
||||
+69
-63
@@ -1,73 +1,79 @@
|
||||
"use client";
|
||||
|
||||
const RuleCard = ({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
backgroundColor = "bg-[var(--color-community-teal-100)]",
|
||||
className = "",
|
||||
onClick,
|
||||
}) => {
|
||||
const handleClick = () => {
|
||||
// Basic analytics event tracking
|
||||
if (typeof window !== "undefined" && window.gtag) {
|
||||
window.gtag("event", "template_selected", {
|
||||
template_name: title,
|
||||
template_type: "governance_pattern",
|
||||
});
|
||||
}
|
||||
import React, { memo } from "react";
|
||||
|
||||
// Custom analytics event for other tracking systems
|
||||
if (typeof window !== "undefined" && window.analytics) {
|
||||
window.analytics.track("Template Selected", {
|
||||
templateName: title,
|
||||
templateType: "governance_pattern",
|
||||
});
|
||||
}
|
||||
const RuleCard = memo(
|
||||
({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
backgroundColor = "bg-[var(--color-community-teal-100)]",
|
||||
className = "",
|
||||
onClick,
|
||||
}) => {
|
||||
const handleClick = () => {
|
||||
// Basic analytics event tracking
|
||||
if (typeof window !== "undefined" && window.gtag) {
|
||||
window.gtag("event", "template_selected", {
|
||||
template_name: title,
|
||||
template_type: "governance_pattern",
|
||||
});
|
||||
}
|
||||
|
||||
if (onClick) onClick();
|
||||
};
|
||||
// Custom analytics event for other tracking systems
|
||||
if (typeof window !== "undefined" && window.analytics) {
|
||||
window.analytics.track("Template Selected", {
|
||||
templateName: title,
|
||||
templateType: "governance_pattern",
|
||||
});
|
||||
}
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
handleClick();
|
||||
}
|
||||
};
|
||||
if (onClick) onClick();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${backgroundColor} rounded-[var(--radius-measures-radius-small)] pt-[var(--spacing-scale-012)] pr-[var(--spacing-scale-012)] pl-[var(--spacing-scale-012)] pb-[var(--spacing-scale-024)] md:p-[var(--spacing-scale-024)] md:h-[210px] lg:h-[277px] flex flex-col gap-[18px] shadow-lg backdrop-blur-sm transition-all duration-500 ease-in-out hover:shadow-xl hover:scale-[1.02] focus:outline-none focus:ring-2 focus:ring-[var(--color-community-teal-500)] focus:ring-offset-2 cursor-pointer min-h-[44px] min-w-[44px] ${className}`}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={`Learn more about ${title} governance pattern`}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Header Container */}
|
||||
<div className="grid grid-cols-[auto_1fr] h-[72px] md:h-[80px] lg:h-[138px] border-b border-[var(--color-surface-default-primary)]">
|
||||
{/* Icon Container */}
|
||||
{icon && (
|
||||
<div className="p-[var(--spacing-scale-016)] md:p-[var(--spacing-scale-012)] lg:p-[var(--spacing-scale-024)] border-r border-[var(--color-surface-default-primary)] w-fit flex items-center justify-center">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
{/* Title Container */}
|
||||
{title && (
|
||||
<div className="pl-[var(--spacing-scale-008)] md:pl-[var(--spacing-scale-012)] lg:pl-[var(--spacing-scale-024)] flex items-center gap-[var(--spacing-scale-004)]">
|
||||
<h3 className="font-space-grotesk font-bold text-[20px] md:text-[28px] lg:text-[36px] leading-[28px] md:leading-[36px] lg:leading-[44px] text-[--color-content-inverse-primary]">
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
handleClick();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${backgroundColor} rounded-[var(--radius-measures-radius-small)] pt-[var(--spacing-scale-012)] pr-[var(--spacing-scale-012)] pl-[var(--spacing-scale-012)] pb-[var(--spacing-scale-024)] md:p-[var(--spacing-scale-024)] md:h-[210px] lg:h-[277px] flex flex-col gap-[18px] shadow-lg backdrop-blur-sm transition-all duration-500 ease-in-out hover:shadow-xl hover:scale-[1.02] focus:outline-none focus:ring-2 focus:ring-[var(--color-community-teal-500)] focus:ring-offset-2 cursor-pointer min-h-[44px] min-w-[44px] ${className}`}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={`Learn more about ${title} governance pattern`}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Header Container */}
|
||||
<div className="grid grid-cols-[auto_1fr] h-[72px] md:h-[80px] lg:h-[138px] border-b border-[var(--color-surface-default-primary)]">
|
||||
{/* Icon Container */}
|
||||
{icon && (
|
||||
<div className="p-[var(--spacing-scale-016)] md:p-[var(--spacing-scale-012)] lg:p-[var(--spacing-scale-024)] border-r border-[var(--color-surface-default-primary)] w-fit flex items-center justify-center">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
{/* Title Container */}
|
||||
{title && (
|
||||
<div className="pl-[var(--spacing-scale-008)] md:pl-[var(--spacing-scale-012)] lg:pl-[var(--spacing-scale-024)] flex items-center gap-[var(--spacing-scale-004)]">
|
||||
<h3 className="font-space-grotesk font-bold text-[20px] md:text-[28px] lg:text-[36px] leading-[28px] md:leading-[36px] lg:leading-[44px] text-[--color-content-inverse-primary]">
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="font-inter font-medium text-[12px] md:text-[14px] lg:text-[18px] leading-[14px] md:leading-[16px] lg:leading-[24px] text-[var(--color-content-inverse-primary)]">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="font-inter font-medium text-[12px] md:text-[14px] lg:text-[18px] leading-[14px] md:leading-[16px] lg:leading-[24px] text-[var(--color-content-inverse-primary)]">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
RuleCard.displayName = "RuleCard";
|
||||
|
||||
export default RuleCard;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { memo } from "react";
|
||||
import Image from "next/image";
|
||||
import RuleCard from "./RuleCard";
|
||||
import Button from "./Button";
|
||||
import { getAssetPath } from "../../lib/assetUtils";
|
||||
|
||||
const RuleStack = ({ className = "" }) => {
|
||||
const RuleStack = memo(({ className = "" }) => {
|
||||
const handleTemplateClick = (templateName) => {
|
||||
// Basic analytics tracking
|
||||
if (typeof window !== "undefined") {
|
||||
@@ -99,6 +99,8 @@ const RuleStack = ({ className = "" }) => {
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
RuleStack.displayName = "RuleStack";
|
||||
|
||||
export default RuleStack;
|
||||
|
||||
@@ -1,54 +1,60 @@
|
||||
"use client";
|
||||
|
||||
const SectionHeader = ({ title, subtitle, titleLg, variant = "default" }) => {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
variant === "multi-line"
|
||||
? "flex flex-col gap-[var(--spacing-scale-004)] w-full lg:flex-row lg:justify-between lg:items-start xl:gap-[var(--spacing-scale-024)]"
|
||||
: "flex flex-col gap-[var(--spacing-scale-004)] w-full lg:flex-row lg:justify-between lg:items-start xl:gap-[var(--spacing-scale-024)]"
|
||||
}
|
||||
>
|
||||
{/* Title Container - Left side (lg breakpoint) */}
|
||||
<div
|
||||
className={
|
||||
variant === "multi-line"
|
||||
? "lg:w-[50%] lg:h-[var(--spacing-scale-120)] lg:flex lg:items-center xl:w-[50%] xl:h-[156px] xl:flex xl:items-center"
|
||||
: "lg:w-[369px] lg:h-[var(--spacing-scale-120)] lg:flex lg:items-center xl:w-[452px] xl:h-[156px] xl:flex xl:items-center"
|
||||
}
|
||||
>
|
||||
<h2
|
||||
className={
|
||||
variant === "multi-line"
|
||||
? "font-bricolage-grotesque font-bold text-[28px] leading-[36px] md:font-bold md:text-[32px] md:leading-[40px] lg:w-[410px] lg:text-left xl:text-[40px] xl:leading-[52px] text-[var(--color-content-default-primary)]"
|
||||
: "font-bricolage-grotesque font-bold text-[28px] leading-[36px] sm:text-[32px] sm:leading-[40px] lg:text-[32px] lg:leading-[40px] lg:w-[369px] lg:pr-[var(--spacing-scale-096)] xl:text-[40px] xl:leading-[52px] xl:w-[452px] xl:pr-[var(--spacing-scale-096)] text-[var(--color-content-default-primary)]"
|
||||
}
|
||||
>
|
||||
<span className="block lg:hidden">{title}</span>
|
||||
<span className="hidden lg:block">{titleLg || title}</span>
|
||||
</h2>
|
||||
</div>
|
||||
import React, { memo } from "react";
|
||||
|
||||
{/* Subtitle Container */}
|
||||
const SectionHeader = memo(
|
||||
({ title, subtitle, titleLg, variant = "default" }) => {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
variant === "multi-line"
|
||||
? "lg:w-[50%] lg:h-[var(--spacing-scale-120)] lg:flex lg:items-center lg:justify-end lg:ml-[var(--spacing-scale-016)] xl:ml-[0px] xl:w-[50%] xl:h-[156px] xl:flex xl:items-center xl:justify-end"
|
||||
: "lg:w-[928px] lg:h-[var(--spacing-scale-120)] lg:flex lg:items-center lg:justify-end xl:h-[156px] xl:flex xl:items-center xl:justify-end"
|
||||
? "flex flex-col gap-[var(--spacing-scale-004)] w-full lg:flex-row lg:justify-between lg:items-start xl:gap-[var(--spacing-scale-024)]"
|
||||
: "flex flex-col gap-[var(--spacing-scale-004)] w-full lg:flex-row lg:justify-between lg:items-start xl:gap-[var(--spacing-scale-024)]"
|
||||
}
|
||||
>
|
||||
<p
|
||||
{/* Title Container - Left side (lg breakpoint) */}
|
||||
<div
|
||||
className={
|
||||
variant === "multi-line"
|
||||
? "font-inter font-normal text-[14px] leading-[20px] md:font-normal md:text-[18px] md:leading-[130%] xl:text-[24px] xl:leading-[32px] text-[var(--color-content-default-tertiary)]"
|
||||
: "font-inter font-normal text-[18px] leading-[130%] sm:text-[18px] sm:leading-[32px] lg:text-[24px] lg:leading-[32px] xl:text-[32px] xl:leading-[40px] xl:text-right text-[#484848] sm:text-[var(--color-content-default-tertiary)] lg:text-[var(--color-content-default-tertiary)] xl:text-[var(--color-content-default-tertiary)] tracking-[0px]"
|
||||
? "lg:w-[50%] lg:h-[var(--spacing-scale-120)] lg:flex lg:items-center xl:w-[50%] xl:h-[156px] xl:flex xl:items-center"
|
||||
: "lg:w-[369px] lg:h-[var(--spacing-scale-120)] lg:flex lg:items-center xl:w-[452px] xl:h-[156px] xl:flex xl:items-center"
|
||||
}
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
<h2
|
||||
className={
|
||||
variant === "multi-line"
|
||||
? "font-bricolage-grotesque font-bold text-[28px] leading-[36px] md:font-bold md:text-[32px] md:leading-[40px] lg:w-[410px] lg:text-left xl:text-[40px] xl:leading-[52px] text-[var(--color-content-default-primary)]"
|
||||
: "font-bricolage-grotesque font-bold text-[28px] leading-[36px] sm:text-[32px] sm:leading-[40px] lg:text-[32px] lg:leading-[40px] lg:w-[369px] lg:pr-[var(--spacing-scale-096)] xl:text-[40px] xl:leading-[52px] xl:w-[452px] xl:pr-[var(--spacing-scale-096)] text-[var(--color-content-default-primary)]"
|
||||
}
|
||||
>
|
||||
<span className="block lg:hidden">{title}</span>
|
||||
<span className="hidden lg:block">{titleLg || title}</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Subtitle Container */}
|
||||
<div
|
||||
className={
|
||||
variant === "multi-line"
|
||||
? "lg:w-[50%] lg:h-[var(--spacing-scale-120)] lg:flex lg:items-center lg:justify-end lg:ml-[var(--spacing-scale-016)] xl:ml-[0px] xl:w-[50%] xl:h-[156px] xl:flex xl:items-center xl:justify-end"
|
||||
: "lg:w-[928px] lg:h-[var(--spacing-scale-120)] lg:flex lg:items-center lg:justify-end xl:h-[156px] xl:flex xl:items-center xl:justify-end"
|
||||
}
|
||||
>
|
||||
<p
|
||||
className={
|
||||
variant === "multi-line"
|
||||
? "font-inter font-normal text-[14px] leading-[20px] md:font-normal md:text-[18px] md:leading-[130%] xl:text-[24px] xl:leading-[32px] text-[var(--color-content-default-tertiary)]"
|
||||
: "font-inter font-normal text-[18px] leading-[130%] sm:text-[18px] sm:leading-[32px] lg:text-[24px] lg:leading-[32px] xl:text-[32px] xl:leading-[40px] xl:text-right text-[#484848] sm:text-[var(--color-content-default-tertiary)] lg:text-[var(--color-content-default-tertiary)] xl:text-[var(--color-content-default-tertiary)] tracking-[0px]"
|
||||
}
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
SectionHeader.displayName = "SectionHeader";
|
||||
|
||||
export default SectionHeader;
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
"use client";
|
||||
|
||||
const SectionNumber = ({ number }) => {
|
||||
import React, { memo } from "react";
|
||||
|
||||
const SectionNumber = memo(({ number }) => {
|
||||
const getImageSrc = (num) => {
|
||||
switch (num) {
|
||||
case 1:
|
||||
return "assets/SectionNumber_1.png";
|
||||
return "/assets/SectionNumber_1.png";
|
||||
case 2:
|
||||
return "assets/SectionNumber_2.png";
|
||||
return "/assets/SectionNumber_2.png";
|
||||
case 3:
|
||||
return "assets/SectionNumber_3.png";
|
||||
return "/assets/SectionNumber_3.png";
|
||||
default:
|
||||
return "assets/SectionNumber_1.png";
|
||||
return "/assets/SectionNumber_1.png";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -28,6 +30,8 @@ const SectionNumber = ({ number }) => {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
SectionNumber.displayName = "SectionNumber";
|
||||
|
||||
export default SectionNumber;
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
export default function Separator() {
|
||||
import React, { memo } from "react";
|
||||
|
||||
const Separator = memo(() => {
|
||||
return (
|
||||
<div className="flex flex-col items-center self-stretch">
|
||||
<div className="flex items-start self-stretch h-px w-full bg-[var(--border-color-default-secondary)]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Separator.displayName = "Separator";
|
||||
|
||||
export default Separator;
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, memo } from "react";
|
||||
|
||||
const WebVitalsDashboard = memo(() => {
|
||||
const [vitals, setVitals] = useState({
|
||||
lcp: { value: 0, rating: "unknown" },
|
||||
fid: { value: 0, rating: "unknown" },
|
||||
cls: { value: 0, rating: "unknown" },
|
||||
fcp: { value: 0, rating: "unknown" },
|
||||
ttfb: { value: 0, rating: "unknown" },
|
||||
});
|
||||
|
||||
const [metrics, setMetrics] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch Web Vitals data from API
|
||||
const fetchVitals = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/web-vitals");
|
||||
const data = await response.json();
|
||||
setMetrics(data.metrics || {});
|
||||
} catch (error) {
|
||||
console.error("Error fetching web vitals:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchVitals();
|
||||
|
||||
// Set up Web Vitals tracking
|
||||
if (typeof window !== "undefined") {
|
||||
import("web-vitals").then(
|
||||
({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
// Track Largest Contentful Paint
|
||||
getLCP((metric) => {
|
||||
setVitals((prev) => ({
|
||||
...prev,
|
||||
lcp: {
|
||||
value: Math.round(metric.value),
|
||||
rating: metric.rating,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
// Track First Input Delay
|
||||
getFID((metric) => {
|
||||
setVitals((prev) => ({
|
||||
...prev,
|
||||
fid: {
|
||||
value: Math.round(metric.value),
|
||||
rating: metric.rating,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
// Track Cumulative Layout Shift
|
||||
getCLS((metric) => {
|
||||
setVitals((prev) => ({
|
||||
...prev,
|
||||
cls: {
|
||||
value: Math.round(metric.value * 1000) / 1000,
|
||||
rating: metric.rating,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
// Track First Contentful Paint
|
||||
getFCP((metric) => {
|
||||
setVitals((prev) => ({
|
||||
...prev,
|
||||
fcp: {
|
||||
value: Math.round(metric.value),
|
||||
rating: metric.rating,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
// Track Time to First Byte
|
||||
getTTFB((metric) => {
|
||||
setVitals((prev) => ({
|
||||
...prev,
|
||||
ttfb: {
|
||||
value: Math.round(metric.value),
|
||||
rating: metric.rating,
|
||||
},
|
||||
}));
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getRatingColor = (rating) => {
|
||||
switch (rating) {
|
||||
case "good":
|
||||
return "text-green-600 bg-green-50";
|
||||
case "needs-improvement":
|
||||
return "text-yellow-600 bg-yellow-50";
|
||||
case "poor":
|
||||
return "text-red-600 bg-red-50";
|
||||
default:
|
||||
return "text-gray-600 bg-gray-50";
|
||||
}
|
||||
};
|
||||
|
||||
const getRatingIcon = (rating) => {
|
||||
switch (rating) {
|
||||
case "good":
|
||||
return "✅";
|
||||
case "needs-improvement":
|
||||
return "⚠️";
|
||||
case "poor":
|
||||
return "❌";
|
||||
default:
|
||||
return "❓";
|
||||
}
|
||||
};
|
||||
|
||||
const formatValue = (metric, value) => {
|
||||
if (metric === "cls") {
|
||||
return value.toFixed(3);
|
||||
}
|
||||
return `${value}ms`;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6 bg-white rounded-lg shadow-lg">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-6 bg-gray-200 rounded w-1/3 mb-4"></div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="p-4 border rounded-lg">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/2 mb-2"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-3/4"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-white rounded-lg shadow-lg">
|
||||
<h2 className="text-2xl font-bold mb-6 text-[var(--color-content-default-primary)]">
|
||||
Web Vitals Dashboard
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
|
||||
{Object.entries(vitals).map(([metric, data]) => (
|
||||
<div
|
||||
key={metric}
|
||||
className={`p-4 border rounded-lg ${getRatingColor(data.rating)}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="font-semibold text-lg">{metric.toUpperCase()}</h3>
|
||||
<span className="text-2xl">{getRatingIcon(data.rating)}</span>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">
|
||||
Value: {formatValue(metric, data.value)}
|
||||
</div>
|
||||
<div className="capitalize">
|
||||
Rating: {data.rating.replace("-", " ")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Historical Metrics */}
|
||||
{Object.keys(metrics).length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold mb-4 text-[var(--color-content-default-primary)]">
|
||||
Historical Metrics
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Object.entries(metrics).map(([metric, data]) => (
|
||||
<div
|
||||
key={metric}
|
||||
className="p-4 border rounded-lg bg-[var(--color-surface-default-secondary)]"
|
||||
>
|
||||
<h4 className="font-semibold mb-2">{metric.toUpperCase()}</h4>
|
||||
<div className="text-sm space-y-1">
|
||||
<div>Count: {data.count}</div>
|
||||
<div>Average: {formatValue(metric, data.average)}</div>
|
||||
<div>
|
||||
Range: {formatValue(metric, data.min)} -{" "}
|
||||
{formatValue(metric, data.max)}
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs">
|
||||
<span className="text-green-600">
|
||||
Good: {data.goodCount}
|
||||
</span>
|
||||
<span className="text-yellow-600">
|
||||
Needs Improvement: {data.needsImprovementCount}
|
||||
</span>
|
||||
<span className="text-red-600">Poor: {data.poorCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Performance Guidelines */}
|
||||
<div className="p-4 bg-[var(--color-surface-default-secondary)] rounded-lg">
|
||||
<h3 className="font-semibold mb-2 text-[var(--color-content-default-primary)]">
|
||||
Performance Guidelines
|
||||
</h3>
|
||||
<ul className="text-sm space-y-1 text-[var(--color-content-default-secondary)]">
|
||||
<li>
|
||||
• <strong>LCP:</strong> Good < 2.5s, Needs Improvement 2.5-4s,
|
||||
Poor > 4s
|
||||
</li>
|
||||
<li>
|
||||
• <strong>FID:</strong> Good < 100ms, Needs Improvement
|
||||
100-300ms, Poor > 300ms
|
||||
</li>
|
||||
<li>
|
||||
• <strong>CLS:</strong> Good < 0.1, Needs Improvement 0.1-0.25,
|
||||
Poor > 0.25
|
||||
</li>
|
||||
<li>
|
||||
• <strong>FCP:</strong> Good < 1.8s, Needs Improvement 1.8-3s,
|
||||
Poor > 3s
|
||||
</li>
|
||||
<li>
|
||||
• <strong>TTFB:</strong> Good < 800ms, Needs Improvement
|
||||
800-1800ms, Poor > 1800ms
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
WebVitalsDashboard.displayName = "WebVitalsDashboard";
|
||||
|
||||
export default WebVitalsDashboard;
|
||||
@@ -10,6 +10,8 @@ const inter = Inter({
|
||||
weight: ["400", "500", "600", "700"],
|
||||
variable: "--font-inter",
|
||||
display: "swap",
|
||||
preload: true,
|
||||
fallback: ["system-ui", "arial"],
|
||||
});
|
||||
|
||||
const bricolageGrotesque = Bricolage_Grotesque({
|
||||
@@ -17,6 +19,8 @@ const bricolageGrotesque = Bricolage_Grotesque({
|
||||
weight: ["400", "500", "700", "800"],
|
||||
variable: "--font-bricolage-grotesque",
|
||||
display: "swap",
|
||||
preload: true,
|
||||
fallback: ["system-ui", "arial"],
|
||||
});
|
||||
|
||||
const spaceGrotesk = Space_Grotesk({
|
||||
@@ -24,6 +28,8 @@ const spaceGrotesk = Space_Grotesk({
|
||||
weight: ["400", "500", "700"],
|
||||
variable: "--font-space-grotesk",
|
||||
display: "swap",
|
||||
preload: true,
|
||||
fallback: ["system-ui", "arial"],
|
||||
});
|
||||
|
||||
export const metadata = {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import React from "react";
|
||||
import WebVitalsDashboard from "../components/WebVitalsDashboard";
|
||||
import Header from "../components/Header";
|
||||
import Footer from "../components/Footer";
|
||||
|
||||
export default function MonitorPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--color-surface-default-primary)]">
|
||||
<Header />
|
||||
|
||||
<main className="container mx-auto px-[var(--spacing-scale-024)] py-[var(--spacing-scale-032)]">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="mb-[var(--spacing-scale-032)]">
|
||||
<h1 className="text-4xl font-bold text-[var(--color-content-default-primary)] mb-[var(--spacing-scale-016)]">
|
||||
Performance Monitoring
|
||||
</h1>
|
||||
<p className="text-[var(--font-size-body-large)] text-[var(--color-content-default-secondary)]">
|
||||
Real-time monitoring of Core Web Vitals and performance metrics
|
||||
for Community Rule 3.0
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-[var(--spacing-scale-032)] mb-[var(--spacing-scale-032)]">
|
||||
<div className="p-[var(--spacing-scale-024)] bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-measures-radius-medium)]">
|
||||
<h2 className="text-2xl font-semibold mb-[var(--spacing-scale-016)] text-[var(--color-content-default-primary)]">
|
||||
Performance Targets
|
||||
</h2>
|
||||
<div className="space-y-[var(--spacing-scale-012)]">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
Load Time
|
||||
</span>
|
||||
<span className="font-semibold text-green-600">
|
||||
< 2 seconds
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
LCP
|
||||
</span>
|
||||
<span className="font-semibold text-green-600">
|
||||
< 2.5s
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
FID
|
||||
</span>
|
||||
<span className="font-semibold text-green-600">
|
||||
< 100ms
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
CLS
|
||||
</span>
|
||||
<span className="font-semibold text-green-600">< 0.1</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
Lighthouse Score
|
||||
</span>
|
||||
<span className="font-semibold text-green-600">> 90</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-[var(--spacing-scale-024)] bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-measures-radius-medium)]">
|
||||
<h2 className="text-2xl font-semibold mb-[var(--spacing-scale-016)] text-[var(--color-content-default-primary)]">
|
||||
Optimization Status
|
||||
</h2>
|
||||
<div className="space-y-[var(--spacing-scale-012)]">
|
||||
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
||||
<span className="text-green-600">✅</span>
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
Code Splitting & Dynamic Imports
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
||||
<span className="text-green-600">✅</span>
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
React.memo Optimizations
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
||||
<span className="text-green-600">✅</span>
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
Image Optimization
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
||||
<span className="text-green-600">✅</span>
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
Font Preloading
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
||||
<span className="text-green-600">✅</span>
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
Bundle Analysis
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
||||
<span className="text-green-600">✅</span>
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
Error Boundaries
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WebVitalsDashboard />
|
||||
|
||||
<div className="mt-[var(--spacing-scale-032)] p-[var(--spacing-scale-024)] bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-measures-radius-medium)]">
|
||||
<h2 className="text-2xl font-semibold mb-[var(--spacing-scale-016)] text-[var(--color-content-default-primary)]">
|
||||
Monitoring Commands
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-[var(--spacing-scale-016)]">
|
||||
<div>
|
||||
<h3 className="font-semibold mb-[var(--spacing-scale-008)] text-[var(--color-content-default-primary)]">
|
||||
Bundle Analysis
|
||||
</h3>
|
||||
<code className="block p-[var(--spacing-scale-008)] bg-[var(--color-surface-inverse-brand-primary)] text-[var(--color-content-inverse-primary)] rounded-[var(--radius-measures-radius-small)] text-sm">
|
||||
npm run bundle:analyze
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-[var(--spacing-scale-008)] text-[var(--color-content-default-primary)]">
|
||||
Performance Monitor
|
||||
</h3>
|
||||
<code className="block p-[var(--spacing-scale-008)] bg-[var(--color-surface-inverse-brand-primary)] text-[var(--color-content-inverse-primary)] rounded-[var(--radius-measures-radius-small)] text-sm">
|
||||
npm run performance:monitor
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-[var(--spacing-scale-008)] text-[var(--color-content-default-primary)]">
|
||||
Web Vitals Tracking
|
||||
</h3>
|
||||
<code className="block p-[var(--spacing-scale-008)] bg-[var(--color-surface-inverse-brand-primary)] text-[var(--color-content-inverse-primary)] rounded-[var(--radius-measures-radius-small)] text-sm">
|
||||
npm run web-vitals:track
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-[var(--spacing-scale-008)] text-[var(--color-content-default-primary)]">
|
||||
All Monitoring
|
||||
</h3>
|
||||
<code className="block p-[var(--spacing-scale-008)] bg-[var(--color-surface-inverse-brand-primary)] text-[var(--color-content-inverse-primary)] rounded-[var(--radius-measures-radius-small)] text-sm">
|
||||
npm run monitor:all
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -44,6 +44,19 @@ This directory contains comprehensive testing documentation for the CommunityRul
|
||||
|
||||
**Use this for**: Visual regression testing, snapshot management, visual test troubleshooting
|
||||
|
||||
### 4. [performance-optimization-guide.md](./performance-optimization-guide.md) - **Performance Guide**
|
||||
|
||||
**Comprehensive performance optimization documentation**:
|
||||
|
||||
- Performance targets and metrics
|
||||
- Frontend optimizations (React.memo, code splitting, image optimization)
|
||||
- Performance monitoring and bundle analysis
|
||||
- Web Vitals tracking and dashboard
|
||||
- Performance testing and troubleshooting
|
||||
- Best practices and optimization strategies
|
||||
|
||||
**Use this for**: Performance optimization, monitoring, bundle analysis, Web Vitals tracking
|
||||
|
||||
## 🎯 How to Use These Documents
|
||||
|
||||
### For New Team Members
|
||||
@@ -51,18 +64,21 @@ This directory contains comprehensive testing documentation for the CommunityRul
|
||||
1. Start with **testing-framework.md** to understand the complete testing strategy
|
||||
2. Use **testing-quick-reference.md** for daily development
|
||||
3. Reference **visual-regression-guide.md** when working with visual tests
|
||||
4. Review **performance-optimization-guide.md** for performance optimization
|
||||
|
||||
### For Daily Development
|
||||
|
||||
1. Use **testing-quick-reference.md** for commands and troubleshooting
|
||||
2. Reference **testing-framework.md** for detailed explanations
|
||||
3. Use **visual-regression-guide.md** for visual test workflows
|
||||
4. Use **performance-optimization-guide.md** for performance monitoring
|
||||
|
||||
### For Troubleshooting
|
||||
|
||||
1. Check **testing-quick-reference.md** for common solutions
|
||||
2. Use **testing-framework.md** for detailed troubleshooting
|
||||
3. Reference **visual-regression-guide.md** for visual test issues
|
||||
4. Use **performance-optimization-guide.md** for performance issues
|
||||
|
||||
## 📊 Current Testing Status
|
||||
|
||||
@@ -72,6 +88,9 @@ This directory contains comprehensive testing documentation for the CommunityRul
|
||||
- **Visual Regression**: 23 tests per browser
|
||||
- **Accessibility**: WCAG 2.1 AA compliance
|
||||
- **Performance**: Lighthouse CI with budgets
|
||||
- **Bundle Analysis**: Real-time monitoring with budgets
|
||||
- **Web Vitals Tracking**: Core Web Vitals collection
|
||||
- **Performance Optimization**: React.memo + code splitting
|
||||
|
||||
## 🔄 Documentation Updates
|
||||
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
# Performance Optimization Guide
|
||||
|
||||
## 📋 Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Performance Targets](#performance-targets)
|
||||
- [Frontend Optimizations](#frontend-optimizations)
|
||||
- [Performance Monitoring](#performance-monitoring)
|
||||
- [Bundle Analysis](#bundle-analysis)
|
||||
- [Web Vitals Tracking](#web-vitals-tracking)
|
||||
- [Performance Testing](#performance-testing)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Best Practices](#best-practices)
|
||||
|
||||
## 🎯 Overview
|
||||
|
||||
This guide covers the comprehensive performance optimization strategy implemented in Community Rule 3.0 to achieve sub-2-second load times across all platform features.
|
||||
|
||||
### Performance Philosophy
|
||||
|
||||
- **Measure First**: Comprehensive monitoring before optimization
|
||||
- **Performance Budgets**: Enforce limits to prevent regression
|
||||
- **Real User Monitoring**: Track actual user experience
|
||||
- **Continuous Optimization**: Regular monitoring and improvement
|
||||
|
||||
## 🎯 Performance Targets
|
||||
|
||||
### Core Web Vitals
|
||||
|
||||
- **LCP (Largest Contentful Paint)**: < 2.5s (Good)
|
||||
- **FID (First Input Delay)**: < 100ms (Good)
|
||||
- **CLS (Cumulative Layout Shift)**: < 0.1 (Good)
|
||||
- **FCP (First Contentful Paint)**: < 1.8s (Good)
|
||||
- **TTFB (Time to First Byte)**: < 800ms (Good)
|
||||
|
||||
### Bundle Size Targets
|
||||
|
||||
- **Initial JavaScript Bundle**: < 250KB gzipped (currently 101KB)
|
||||
- **Total Bundle Size**: < 2MB
|
||||
- **Individual Component Bundles**: < 50KB
|
||||
- **Image Assets**: Optimized with WebP/AVIF formats
|
||||
|
||||
### Lighthouse Scores
|
||||
|
||||
- **Performance**: > 90
|
||||
- **Accessibility**: > 90
|
||||
- **Best Practices**: > 90
|
||||
- **SEO**: > 90
|
||||
|
||||
## ⚡ Frontend Optimizations
|
||||
|
||||
### 1. Code Splitting
|
||||
|
||||
Dynamic imports for non-critical components to reduce initial bundle size:
|
||||
|
||||
```javascript
|
||||
// Dynamic imports for non-critical components
|
||||
const NumberedCards = dynamic(() => import("./components/NumberedCards"), {
|
||||
loading: () => <div className="loading-placeholder">Loading...</div>,
|
||||
});
|
||||
|
||||
const LogoWall = dynamic(() => import("./components/LogoWall"), {
|
||||
loading: () => <div className="loading-placeholder">Loading...</div>,
|
||||
});
|
||||
```
|
||||
|
||||
### 2. React.memo Optimization
|
||||
|
||||
Applied to all 30+ components to prevent unnecessary re-renders:
|
||||
|
||||
```javascript
|
||||
import React, { memo } from "react";
|
||||
|
||||
const MyComponent = memo(({ prop1, prop2 }) => {
|
||||
return <div>{/* Component content */}</div>;
|
||||
});
|
||||
|
||||
MyComponent.displayName = "MyComponent";
|
||||
export default MyComponent;
|
||||
```
|
||||
|
||||
### 3. useMemo and useCallback
|
||||
|
||||
Optimized expensive computations and event handlers:
|
||||
|
||||
```javascript
|
||||
import React, { memo, useMemo, useCallback } from "react";
|
||||
|
||||
const OptimizedComponent = memo(({ data, onAction }) => {
|
||||
// Memoize expensive computations
|
||||
const processedData = useMemo(() => {
|
||||
return data.map((item) => expensiveOperation(item));
|
||||
}, [data]);
|
||||
|
||||
// Memoize event handlers
|
||||
const handleClick = useCallback(
|
||||
(id) => {
|
||||
onAction(id);
|
||||
},
|
||||
[onAction],
|
||||
);
|
||||
|
||||
return <div onClick={handleClick}>{/* Component content */}</div>;
|
||||
});
|
||||
```
|
||||
|
||||
### 4. Image Optimization
|
||||
|
||||
Enhanced `next/image` with lazy loading and blur placeholders:
|
||||
|
||||
```javascript
|
||||
import Image from "next/image";
|
||||
|
||||
<Image
|
||||
src="/assets/image.jpg"
|
||||
alt="Description"
|
||||
width={300}
|
||||
height={200}
|
||||
sizes="(max-width: 768px) 100vw, 50vw"
|
||||
loading="lazy"
|
||||
placeholder="blur"
|
||||
blurDataURL="data:image/jpeg;base64,..."
|
||||
/>;
|
||||
```
|
||||
|
||||
### 5. Font Optimization
|
||||
|
||||
Preloading and fallbacks for all fonts:
|
||||
|
||||
```javascript
|
||||
import { Inter, Bricolage_Grotesque, Space_Grotesk } from "next/font/google";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
preload: true,
|
||||
fallback: ["system-ui", "arial"],
|
||||
});
|
||||
|
||||
const bricolageGrotesque = Bricolage_Grotesque({
|
||||
subsets: ["latin"],
|
||||
preload: true,
|
||||
fallback: ["system-ui", "arial"],
|
||||
});
|
||||
```
|
||||
|
||||
### 6. Error Boundaries
|
||||
|
||||
Comprehensive error handling to prevent cascade failures:
|
||||
|
||||
```javascript
|
||||
import React, { Component } from "react";
|
||||
|
||||
class ErrorBoundary extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
console.error("ErrorBoundary caught an error:", error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return <div>Something went wrong.</div>;
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 📊 Performance Monitoring
|
||||
|
||||
### Available Scripts
|
||||
|
||||
```bash
|
||||
# Individual monitoring tools
|
||||
npm run bundle:analyze # Analyze bundle sizes and budgets
|
||||
npm run performance:monitor # Performance metrics and Lighthouse CI
|
||||
npm run web-vitals:track # Core Web Vitals tracking
|
||||
|
||||
# Comprehensive testing
|
||||
npm run test:performance # All performance tests
|
||||
npm run monitor:all # All monitoring tools
|
||||
```
|
||||
|
||||
### Performance Dashboard
|
||||
|
||||
Access the performance monitoring dashboard at `/monitor` to view:
|
||||
|
||||
- Real-time Web Vitals metrics
|
||||
- Historical performance data
|
||||
- Bundle analysis results
|
||||
- Performance budget status
|
||||
- Optimization recommendations
|
||||
|
||||
## 📦 Bundle Analysis
|
||||
|
||||
### Bundle Analyzer Script
|
||||
|
||||
The bundle analyzer provides comprehensive analysis of bundle sizes:
|
||||
|
||||
```bash
|
||||
npm run bundle:analyze
|
||||
```
|
||||
|
||||
**Features:**
|
||||
|
||||
- Analyzes static assets, chunks, and pages
|
||||
- Checks against performance budgets
|
||||
- Generates optimization recommendations
|
||||
- Saves results in JSON and Markdown formats
|
||||
|
||||
**Output Files:**
|
||||
|
||||
- `.next/analyze/bundle-analysis.json` - Detailed analysis data
|
||||
- `.next/analyze/bundle-report.md` - Human-readable report
|
||||
|
||||
### Performance Budgets
|
||||
|
||||
Defined in `performance-budgets.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"budgets": [
|
||||
{
|
||||
"name": "lcp",
|
||||
"maxValue": 2500,
|
||||
"description": "Largest Contentful Paint"
|
||||
},
|
||||
{
|
||||
"name": "bundle-size",
|
||||
"maxSizeKB": 250,
|
||||
"description": "Initial JavaScript bundle size"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 📈 Web Vitals Tracking
|
||||
|
||||
### Real-time Monitoring
|
||||
|
||||
The Web Vitals tracking system collects and reports Core Web Vitals:
|
||||
|
||||
```bash
|
||||
npm run web-vitals:track
|
||||
```
|
||||
|
||||
**Features:**
|
||||
|
||||
- Collects LCP, FID, CLS, FCP, TTFB metrics
|
||||
- Stores historical data (last 100 entries per metric)
|
||||
- Generates summary reports
|
||||
- Provides optimization recommendations
|
||||
|
||||
**API Endpoint:**
|
||||
|
||||
- `POST /api/web-vitals` - Receives Web Vitals data
|
||||
- `GET /api/web-vitals` - Returns aggregated metrics
|
||||
|
||||
### Web Vitals Dashboard
|
||||
|
||||
The dashboard component displays real-time and historical metrics:
|
||||
|
||||
```javascript
|
||||
import WebVitalsDashboard from "./components/WebVitalsDashboard";
|
||||
|
||||
<WebVitalsDashboard />;
|
||||
```
|
||||
|
||||
## 🧪 Performance Testing
|
||||
|
||||
### Comprehensive Testing
|
||||
|
||||
Run all performance tests with a single command:
|
||||
|
||||
```bash
|
||||
npm run test:performance
|
||||
```
|
||||
|
||||
**Test Coverage:**
|
||||
|
||||
- Bundle analysis with budget checking
|
||||
- Performance monitoring with Lighthouse CI
|
||||
- Web Vitals tracking setup
|
||||
- Comprehensive reporting
|
||||
|
||||
### Individual Tests
|
||||
|
||||
```bash
|
||||
# Bundle analysis only
|
||||
npm run bundle:analyze
|
||||
|
||||
# Performance monitoring only
|
||||
npm run performance:monitor
|
||||
|
||||
# Web Vitals tracking only
|
||||
npm run web-vitals:track
|
||||
|
||||
# All monitoring tools
|
||||
npm run monitor:all
|
||||
```
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### 1. Bundle Size Exceeds Budget
|
||||
|
||||
```bash
|
||||
# Check bundle analysis
|
||||
npm run bundle:analyze
|
||||
|
||||
# Review recommendations in .next/analyze/bundle-report.md
|
||||
# Consider code splitting or removing unused dependencies
|
||||
```
|
||||
|
||||
#### 2. Web Vitals Poor Performance
|
||||
|
||||
```bash
|
||||
# Check Web Vitals data
|
||||
npm run web-vitals:track
|
||||
|
||||
# Review dashboard at /monitor
|
||||
# Optimize images, fonts, or JavaScript
|
||||
```
|
||||
|
||||
#### 3. Performance Tests Failing
|
||||
|
||||
```bash
|
||||
# Run comprehensive performance test
|
||||
npm run test:performance
|
||||
|
||||
# Check individual components
|
||||
npm run bundle:analyze
|
||||
npm run performance:monitor
|
||||
```
|
||||
|
||||
### Debug Commands
|
||||
|
||||
```bash
|
||||
# Debug bundle analysis
|
||||
npm run bundle:analyze --verbose
|
||||
|
||||
# Debug performance monitoring
|
||||
npm run performance:monitor --debug
|
||||
|
||||
# Check Web Vitals data
|
||||
curl http://localhost:3000/api/web-vitals
|
||||
```
|
||||
|
||||
## 🎯 Best Practices
|
||||
|
||||
### Development
|
||||
|
||||
1. **Always use React.memo** for components that receive props
|
||||
2. **Implement useMemo/useCallback** for expensive operations
|
||||
3. **Use dynamic imports** for non-critical components
|
||||
4. **Optimize images** with proper sizing and formats
|
||||
5. **Preload critical fonts** and resources
|
||||
|
||||
### Monitoring
|
||||
|
||||
1. **Run bundle analysis** before major releases
|
||||
2. **Monitor Web Vitals** in production
|
||||
3. **Check performance budgets** in CI/CD
|
||||
4. **Review optimization recommendations** regularly
|
||||
|
||||
### Performance Budgets
|
||||
|
||||
1. **Set realistic budgets** based on user needs
|
||||
2. **Monitor budget violations** in CI/CD
|
||||
3. **Optimize when budgets are exceeded**
|
||||
4. **Update budgets** as requirements change
|
||||
|
||||
## 📚 Additional Resources
|
||||
|
||||
- **Next.js Performance**: https://nextjs.org/docs/advanced-features/measuring-performance
|
||||
- **Web Vitals**: https://web.dev/vitals/
|
||||
- **Lighthouse CI**: https://github.com/GoogleChrome/lighthouse-ci
|
||||
- **React Performance**: https://react.dev/learn/render-and-commit
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: December 2024
|
||||
**Maintained by**: CommunityRule Development Team
|
||||
@@ -31,11 +31,14 @@ The CommunityRule platform uses a comprehensive testing framework with multiple
|
||||
|
||||
### Current Status
|
||||
|
||||
- ✅ **305 Unit Tests** (94.88% coverage - exceeds 85% target)
|
||||
- ✅ **428 Unit Tests** (94.88% coverage - exceeds 85% target)
|
||||
- ✅ **92 E2E Tests** across 4 browsers
|
||||
- ✅ **23 Visual Regression Tests** per browser
|
||||
- ✅ **Performance Budgets** with Lighthouse CI
|
||||
- ✅ **WCAG 2.1 AA Compliance** with automated testing
|
||||
- ✅ **Bundle Analysis** with automated monitoring
|
||||
- ✅ **Web Vitals Tracking** with real-time metrics
|
||||
- ✅ **Performance Optimization** with React.memo and code splitting
|
||||
|
||||
## 🏗 Testing Architecture
|
||||
|
||||
@@ -438,8 +441,10 @@ npx playwright test tests/accessibility/e2e/wcag-compliance.spec.ts
|
||||
### Framework
|
||||
|
||||
- **Lighthouse CI**: Automated performance testing
|
||||
- **Performance Budgets**: Defined thresholds
|
||||
- **Core Web Vitals**: LCP, FID, CLS monitoring
|
||||
- **Bundle Analysis**: Real-time bundle size monitoring
|
||||
- **Web Vitals Tracking**: Core Web Vitals collection and reporting
|
||||
- **Performance Monitoring**: Comprehensive performance metrics
|
||||
- **Performance Budgets**: Defined thresholds with automated enforcement
|
||||
|
||||
### Configuration
|
||||
|
||||
@@ -472,6 +477,7 @@ npx playwright test tests/accessibility/e2e/wcag-compliance.spec.ts
|
||||
- **Performance Score**: >80
|
||||
- **Accessibility Score**: >80
|
||||
- **Best Practices**: >90
|
||||
- **Bundle Size**: <250KB gzipped (currently 101KB)
|
||||
|
||||
### Performance Budgets
|
||||
|
||||
@@ -479,15 +485,46 @@ npx playwright test tests/accessibility/e2e/wcag-compliance.spec.ts
|
||||
- **Largest Contentful Paint**: <5000ms
|
||||
- **First Input Delay**: <100ms
|
||||
- **TTFB**: <700ms
|
||||
- **Bundle Size**: <250KB gzipped
|
||||
- **Total Bundle Size**: <2MB
|
||||
|
||||
### Performance Optimizations
|
||||
|
||||
- **✅ Code Splitting**: Dynamic imports for non-critical components
|
||||
- **✅ React.memo**: Applied to all 30+ components
|
||||
- **✅ Image Optimization**: Enhanced `next/image` with lazy loading
|
||||
- **✅ Font Optimization**: Preloading and fallbacks
|
||||
- **✅ Bundle Analysis**: Real-time monitoring with budgets
|
||||
- **✅ Error Boundaries**: Comprehensive error handling
|
||||
|
||||
### Available Scripts
|
||||
|
||||
```bash
|
||||
npm run lhci # Run Lighthouse CI
|
||||
npm run lhci:mobile # Run with mobile preset
|
||||
npm run lhci:desktop # Run with desktop preset
|
||||
# Individual monitoring tools
|
||||
npm run bundle:analyze # Analyze bundle sizes and budgets
|
||||
npm run performance:monitor # Performance metrics and Lighthouse CI
|
||||
npm run web-vitals:track # Core Web Vitals tracking
|
||||
|
||||
# Comprehensive testing
|
||||
npm run test:performance # All performance tests
|
||||
npm run monitor:all # All monitoring tools
|
||||
|
||||
# Traditional Lighthouse CI
|
||||
npm run lhci # Run Lighthouse CI
|
||||
npm run lhci:mobile # Run with mobile preset
|
||||
npm run lhci:desktop # Run with desktop preset
|
||||
```
|
||||
|
||||
### Performance Monitoring Dashboard
|
||||
|
||||
Access the performance monitoring dashboard at `/monitor` to view:
|
||||
|
||||
- Real-time Web Vitals metrics
|
||||
- Historical performance data
|
||||
- Bundle analysis results
|
||||
- Performance budget status
|
||||
- Optimization recommendations
|
||||
|
||||
## 🔄 CI/CD Pipeline
|
||||
|
||||
### Gitea Actions Workflow
|
||||
|
||||
@@ -20,6 +20,12 @@ npm run visual:test
|
||||
# Performance check
|
||||
npm run lhci
|
||||
|
||||
# Performance monitoring
|
||||
npm run test:performance # Comprehensive performance testing
|
||||
npm run bundle:analyze # Bundle size analysis
|
||||
npm run web-vitals:track # Web Vitals tracking
|
||||
npm run monitor:all # All monitoring tools
|
||||
|
||||
# Storybook tests
|
||||
npm run test:sb
|
||||
```
|
||||
@@ -48,6 +54,9 @@ npx playwright test --headed
|
||||
- **Visual Regression**: 23 tests per browser ✅
|
||||
- **Accessibility Tests**: WCAG 2.1 AA compliance ✅
|
||||
- **Performance Tests**: Lighthouse CI with budgets ✅
|
||||
- **Bundle Analysis**: Real-time monitoring with budgets ✅
|
||||
- **Web Vitals Tracking**: Core Web Vitals collection ✅
|
||||
- **Performance Optimization**: React.memo + code splitting ✅
|
||||
|
||||
## 🔧 Common Test Commands
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+77
-1
@@ -5,12 +5,88 @@ const nextConfig = {
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
webpack(config) {
|
||||
// Performance optimizations
|
||||
experimental: {
|
||||
optimizeCss: true,
|
||||
optimizePackageImports: ["react", "react-dom"],
|
||||
},
|
||||
// Compression
|
||||
compress: true,
|
||||
// Image optimization
|
||||
images: {
|
||||
formats: ["image/webp", "image/avif"],
|
||||
minimumCacheTTL: 60,
|
||||
dangerouslyAllowSVG: true,
|
||||
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
|
||||
},
|
||||
// Headers for caching
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/(.*)",
|
||||
headers: [
|
||||
{
|
||||
key: "X-Content-Type-Options",
|
||||
value: "nosniff",
|
||||
},
|
||||
{
|
||||
key: "X-Frame-Options",
|
||||
value: "DENY",
|
||||
},
|
||||
{
|
||||
key: "X-XSS-Protection",
|
||||
value: "1; mode=block",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
source: "/static/(.*)",
|
||||
headers: [
|
||||
{
|
||||
key: "Cache-Control",
|
||||
value: "public, max-age=31536000, immutable",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
webpack(config, { dev, isServer }) {
|
||||
// SVG handling
|
||||
config.module.rules.push({
|
||||
test: /\.svg$/,
|
||||
issuer: /\.[jt]sx?$/,
|
||||
use: ["@svgr/webpack"],
|
||||
});
|
||||
|
||||
// Bundle analysis - only in production builds
|
||||
if (process.env.ANALYZE === "true" && !dev) {
|
||||
try {
|
||||
const BundleAnalyzerPlugin =
|
||||
require("webpack-bundle-analyzer").BundleAnalyzerPlugin;
|
||||
config.plugins.push(
|
||||
new BundleAnalyzerPlugin({
|
||||
analyzerMode: "static",
|
||||
openAnalyzer: false,
|
||||
reportFilename: isServer
|
||||
? "../analyze/server.html"
|
||||
: "../analyze/client.html",
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn("Bundle analyzer not available:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Production optimizations
|
||||
if (!dev && !isServer) {
|
||||
// Tree shaking optimization
|
||||
config.optimization = {
|
||||
...config.optimization,
|
||||
usedExports: true,
|
||||
sideEffects: false,
|
||||
};
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
Generated
+249
-17
@@ -12,6 +12,7 @@
|
||||
"@mdx-js/loader": "^3.1.1",
|
||||
"@mdx-js/react": "^3.1.1",
|
||||
"@next/mdx": "^15.5.2",
|
||||
"critters": "^0.0.23",
|
||||
"gray-matter": "^4.0.3",
|
||||
"next": "15.2.4",
|
||||
"react": "^19.0.0",
|
||||
@@ -59,7 +60,9 @@
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.9.2",
|
||||
"vitest": "^3.2.4",
|
||||
"wait-on": "^8.0.4"
|
||||
"wait-on": "^8.0.4",
|
||||
"web-vitals": "^4.2.4",
|
||||
"webpack-bundle-analyzer": "^4.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@adobe/css-tools": {
|
||||
@@ -2210,6 +2213,16 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@discoveryjs/json-ext": {
|
||||
"version": "0.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz",
|
||||
"integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz",
|
||||
@@ -8022,6 +8035,19 @@
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn-walk": {
|
||||
"version": "8.3.4",
|
||||
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
|
||||
"integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.11.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
@@ -8097,7 +8123,6 @@
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
@@ -8890,7 +8915,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
@@ -9185,7 +9209,6 @@
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
@@ -9491,7 +9514,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
@@ -9504,7 +9526,6 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/color-string": {
|
||||
@@ -9778,6 +9799,95 @@
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/critters": {
|
||||
"version": "0.0.23",
|
||||
"resolved": "https://registry.npmjs.org/critters/-/critters-0.0.23.tgz",
|
||||
"integrity": "sha512-/MCsQbuzTPA/ZTOjjyr2Na5o3lRpr8vd0MZE8tMP0OBNg/VrLxWHteVKalQ8KR+fBmUadbJLdoyEz9sT+q84qg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"chalk": "^4.1.0",
|
||||
"css-select": "^5.1.0",
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"htmlparser2": "^8.0.2",
|
||||
"postcss": "^8.4.23",
|
||||
"postcss-media-query-parser": "^0.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/critters/node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"entities": "^4.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/critters/node_modules/domelementtype": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/critters/node_modules/domhandler": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/critters/node_modules/domutils": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/critters/node_modules/htmlparser2": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
|
||||
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.0.1",
|
||||
"entities": "^4.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -9814,7 +9924,6 @@
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
||||
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0",
|
||||
@@ -9831,7 +9940,6 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
@@ -9846,7 +9954,6 @@
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -9859,7 +9966,6 @@
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0"
|
||||
@@ -9875,7 +9981,6 @@
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
@@ -9904,7 +10009,6 @@
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
|
||||
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -10076,6 +10180,13 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/debounce": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz",
|
||||
"integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -10576,7 +10687,6 @@
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
@@ -12765,6 +12875,22 @@
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/gzip-size": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz",
|
||||
"integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"duplexer": "^0.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/has-bigints": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
||||
@@ -12782,7 +12908,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -18108,7 +18233,6 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0"
|
||||
@@ -18553,6 +18677,16 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/opener": {
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz",
|
||||
"integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==",
|
||||
"dev": true,
|
||||
"license": "(WTFPL OR MIT)",
|
||||
"bin": {
|
||||
"opener": "bin/opener-bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
@@ -19163,7 +19297,6 @@
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -19188,6 +19321,12 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-media-query-parser": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz",
|
||||
"integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
@@ -21534,7 +21673,6 @@
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
@@ -23006,6 +23144,13 @@
|
||||
"makeerror": "1.0.12"
|
||||
}
|
||||
},
|
||||
"node_modules/web-vitals": {
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz",
|
||||
"integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/webdriver-bidi-protocol": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.2.11.tgz",
|
||||
@@ -23023,6 +23168,93 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-bundle-analyzer": {
|
||||
"version": "4.10.2",
|
||||
"resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.2.tgz",
|
||||
"integrity": "sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@discoveryjs/json-ext": "0.5.7",
|
||||
"acorn": "^8.0.4",
|
||||
"acorn-walk": "^8.0.0",
|
||||
"commander": "^7.2.0",
|
||||
"debounce": "^1.2.1",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"gzip-size": "^6.0.0",
|
||||
"html-escaper": "^2.0.2",
|
||||
"opener": "^1.5.2",
|
||||
"picocolors": "^1.0.0",
|
||||
"sirv": "^2.0.3",
|
||||
"ws": "^7.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"webpack-bundle-analyzer": "lib/bin/analyzer.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-bundle-analyzer/node_modules/commander": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
|
||||
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-bundle-analyzer/node_modules/escape-string-regexp": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-bundle-analyzer/node_modules/sirv": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz",
|
||||
"integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@polka/url": "^1.0.0-next.24",
|
||||
"mrmime": "^2.0.0",
|
||||
"totalist": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-bundle-analyzer/node_modules/ws": {
|
||||
"version": "7.5.10",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
|
||||
"integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": "^5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-virtual-modules": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
||||
|
||||
+13
-2
@@ -33,12 +33,21 @@
|
||||
"seed-snapshots:local": "PLAYWRIGHT_UPDATE_SNAPSHOTS=1 npx playwright test tests/e2e/visual-regression.spec.ts --project=chromium",
|
||||
"visual:test": "npx playwright test tests/e2e/visual-regression.spec.ts",
|
||||
"visual:update": "PLAYWRIGHT_UPDATE_SNAPSHOTS=1 npx playwright test tests/e2e/visual-regression.spec.ts",
|
||||
"visual:ui": "npx playwright test tests/e2e/visual-regression.spec.ts --ui"
|
||||
"visual:ui": "npx playwright test tests/e2e/visual-regression.spec.ts --ui",
|
||||
"analyze": "npm run analyze:browser && npm run analyze:server",
|
||||
"analyze:server": "ANALYZE=true npm run build",
|
||||
"analyze:browser": "BUNDLE_ANALYZE=true npm run build",
|
||||
"bundle:analyze": "node scripts/bundle-analyzer.js",
|
||||
"web-vitals:track": "node scripts/web-vitals-tracker.js",
|
||||
"monitor:all": "npm run bundle:analyze && npm run performance:monitor && npm run web-vitals:track",
|
||||
"test:performance": "node scripts/test-performance.js",
|
||||
"test:performance:ci": "npm run test:performance"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mdx-js/loader": "^3.1.1",
|
||||
"@mdx-js/react": "^3.1.1",
|
||||
"@next/mdx": "^15.5.2",
|
||||
"critters": "^0.0.23",
|
||||
"gray-matter": "^4.0.3",
|
||||
"next": "15.2.4",
|
||||
"react": "^19.0.0",
|
||||
@@ -86,6 +95,8 @@
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.9.2",
|
||||
"vitest": "^3.2.4",
|
||||
"wait-on": "^8.0.4"
|
||||
"wait-on": "^8.0.4",
|
||||
"web-vitals": "^4.2.4",
|
||||
"webpack-bundle-analyzer": "^4.10.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,41 @@
|
||||
{
|
||||
"budgets": [
|
||||
{
|
||||
"name": "lcp",
|
||||
"maxValue": 2500,
|
||||
"description": "Largest Contentful Paint should be under 2.5s"
|
||||
},
|
||||
{
|
||||
"name": "fid",
|
||||
"maxValue": 100,
|
||||
"description": "First Input Delay should be under 100ms"
|
||||
},
|
||||
{
|
||||
"name": "cls",
|
||||
"maxValue": 0.1,
|
||||
"description": "Cumulative Layout Shift should be under 0.1"
|
||||
},
|
||||
{
|
||||
"name": "fcp",
|
||||
"maxValue": 1800,
|
||||
"description": "First Contentful Paint should be under 1.8s"
|
||||
},
|
||||
{
|
||||
"name": "ttfb",
|
||||
"maxValue": 800,
|
||||
"description": "Time to First Byte should be under 800ms"
|
||||
},
|
||||
{
|
||||
"name": "bundle-size",
|
||||
"maxSizeKB": 500,
|
||||
"description": "Individual bundle size should be under 500KB"
|
||||
},
|
||||
{
|
||||
"name": "total-size",
|
||||
"maxSizeKB": 2000,
|
||||
"description": "Total bundle size should be under 2MB"
|
||||
}
|
||||
],
|
||||
"performance": {
|
||||
"budgets": [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Bundle Analysis Script
|
||||
* Analyzes webpack bundles and provides detailed performance insights
|
||||
*/
|
||||
|
||||
const { execSync } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const BUNDLE_ANALYSIS_DIR = path.join(__dirname, "..", ".next", "analyze");
|
||||
const PERFORMANCE_BUDGETS = require("../performance-budgets.json");
|
||||
|
||||
class BundleAnalyzer {
|
||||
constructor() {
|
||||
this.results = {
|
||||
timestamp: new Date().toISOString(),
|
||||
bundles: {},
|
||||
recommendations: [],
|
||||
budgetViolations: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run bundle analysis using build output
|
||||
*/
|
||||
async analyzeBundles() {
|
||||
console.log("🔍 Starting bundle analysis...");
|
||||
|
||||
try {
|
||||
// Ensure analyze directory exists
|
||||
if (!fs.existsSync(BUNDLE_ANALYSIS_DIR)) {
|
||||
fs.mkdirSync(BUNDLE_ANALYSIS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Build the project first
|
||||
console.log("🏗️ Building project...");
|
||||
execSync("npm run build", { stdio: "inherit" });
|
||||
|
||||
// Parse bundle stats from build output
|
||||
await this.parseBundleStats();
|
||||
|
||||
// Check performance budgets
|
||||
this.checkPerformanceBudgets();
|
||||
|
||||
// Generate recommendations
|
||||
this.generateRecommendations();
|
||||
|
||||
// Save results
|
||||
this.saveResults();
|
||||
|
||||
console.log("✅ Bundle analysis complete!");
|
||||
console.log(`📁 Results saved to: ${BUNDLE_ANALYSIS_DIR}`);
|
||||
} catch (error) {
|
||||
console.error("❌ Bundle analysis failed:", error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse bundle statistics from build output
|
||||
*/
|
||||
async parseBundleStats() {
|
||||
const staticPath = path.join(__dirname, "..", ".next", "static");
|
||||
const chunksPath = path.join(staticPath, "chunks");
|
||||
|
||||
// Analyze static assets
|
||||
if (fs.existsSync(staticPath)) {
|
||||
this.analyzeDirectory(staticPath, "static");
|
||||
}
|
||||
|
||||
// Analyze chunks
|
||||
if (fs.existsSync(chunksPath)) {
|
||||
this.analyzeDirectory(chunksPath, "chunks");
|
||||
}
|
||||
|
||||
// Analyze pages
|
||||
const pagesPath = path.join(__dirname, "..", ".next", "server", "pages");
|
||||
if (fs.existsSync(pagesPath)) {
|
||||
this.analyzeDirectory(pagesPath, "pages");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze directory for bundle sizes
|
||||
*/
|
||||
analyzeDirectory(dirPath, type) {
|
||||
const files = fs.readdirSync(dirPath);
|
||||
|
||||
files.forEach((file) => {
|
||||
const filePath = path.join(dirPath, file);
|
||||
const stats = fs.statSync(filePath);
|
||||
|
||||
if (stats.isFile() && (file.endsWith(".js") || file.endsWith(".css"))) {
|
||||
const key = `${type}/${file}`;
|
||||
this.results.bundles[key] = {
|
||||
size: stats.size,
|
||||
sizeKB: Math.round(stats.size / 1024),
|
||||
lastModified: stats.mtime,
|
||||
type: file.endsWith(".css") ? "css" : "js",
|
||||
};
|
||||
} else if (stats.isDirectory()) {
|
||||
this.analyzeDirectory(filePath, `${type}/${file}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check against performance budgets
|
||||
*/
|
||||
checkPerformanceBudgets() {
|
||||
const budgets = PERFORMANCE_BUDGETS.budgets || [];
|
||||
|
||||
Object.entries(this.results.bundles).forEach(([filename, bundle]) => {
|
||||
const budget = budgets.find(
|
||||
(b) => filename.includes(b.name) || b.name === "all",
|
||||
);
|
||||
|
||||
if (budget) {
|
||||
if (bundle.sizeKB > budget.maxSizeKB) {
|
||||
this.results.budgetViolations.push({
|
||||
file: filename,
|
||||
currentSize: bundle.sizeKB,
|
||||
maxSize: budget.maxSizeKB,
|
||||
overage: bundle.sizeKB - budget.maxSizeKB,
|
||||
severity:
|
||||
bundle.sizeKB > budget.maxSizeKB * 1.2 ? "high" : "medium",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Default budget check for large files
|
||||
if (bundle.sizeKB > 500) {
|
||||
this.results.budgetViolations.push({
|
||||
file: filename,
|
||||
currentSize: bundle.sizeKB,
|
||||
maxSize: 500,
|
||||
overage: bundle.sizeKB - 500,
|
||||
severity: bundle.sizeKB > 600 ? "high" : "medium",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate optimization recommendations
|
||||
*/
|
||||
generateRecommendations() {
|
||||
const recommendations = [];
|
||||
|
||||
// Check for large bundles
|
||||
Object.entries(this.results.bundles).forEach(([filename, bundle]) => {
|
||||
if (bundle.sizeKB > 500) {
|
||||
recommendations.push({
|
||||
type: "large-bundle",
|
||||
file: filename,
|
||||
size: bundle.sizeKB,
|
||||
suggestion:
|
||||
"Consider code splitting or dynamic imports for this bundle",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Check for budget violations
|
||||
if (this.results.budgetViolations.length > 0) {
|
||||
recommendations.push({
|
||||
type: "budget-violation",
|
||||
count: this.results.budgetViolations.length,
|
||||
suggestion:
|
||||
"Review and optimize bundles that exceed performance budgets",
|
||||
});
|
||||
}
|
||||
|
||||
// General recommendations
|
||||
const totalSize = Object.values(this.results.bundles).reduce(
|
||||
(sum, bundle) => sum + bundle.sizeKB,
|
||||
0,
|
||||
);
|
||||
|
||||
if (totalSize > 2000) {
|
||||
recommendations.push({
|
||||
type: "total-size",
|
||||
size: totalSize,
|
||||
suggestion: "Consider implementing more aggressive code splitting",
|
||||
});
|
||||
}
|
||||
|
||||
this.results.recommendations = recommendations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save analysis results
|
||||
*/
|
||||
saveResults() {
|
||||
// Ensure directory exists
|
||||
if (!fs.existsSync(BUNDLE_ANALYSIS_DIR)) {
|
||||
fs.mkdirSync(BUNDLE_ANALYSIS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const resultsPath = path.join(BUNDLE_ANALYSIS_DIR, "bundle-analysis.json");
|
||||
fs.writeFileSync(resultsPath, JSON.stringify(this.results, null, 2));
|
||||
|
||||
// Generate markdown report
|
||||
this.generateMarkdownReport();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate markdown report
|
||||
*/
|
||||
generateMarkdownReport() {
|
||||
const reportPath = path.join(BUNDLE_ANALYSIS_DIR, "bundle-report.md");
|
||||
|
||||
let report = `# Bundle Analysis Report\n\n`;
|
||||
report += `**Generated:** ${this.results.timestamp}\n\n`;
|
||||
|
||||
// Bundle sizes
|
||||
report += `## Bundle Sizes\n\n`;
|
||||
report += `| File | Size (KB) | Status |\n`;
|
||||
report += `|------|-----------|--------|\n`;
|
||||
|
||||
Object.entries(this.results.bundles).forEach(([filename, bundle]) => {
|
||||
const status = bundle.sizeKB > 500 ? "⚠️ Large" : "✅ Good";
|
||||
report += `| ${filename} | ${bundle.sizeKB} | ${status} |\n`;
|
||||
});
|
||||
|
||||
// Budget violations
|
||||
if (this.results.budgetViolations.length > 0) {
|
||||
report += `\n## Budget Violations\n\n`;
|
||||
this.results.budgetViolations.forEach((violation) => {
|
||||
report += `- **${violation.file}**: ${violation.currentSize}KB (exceeds ${violation.maxSize}KB by ${violation.overage}KB)\n`;
|
||||
});
|
||||
}
|
||||
|
||||
// Recommendations
|
||||
if (this.results.recommendations.length > 0) {
|
||||
report += `\n## Recommendations\n\n`;
|
||||
this.results.recommendations.forEach((rec) => {
|
||||
report += `- ${rec.suggestion}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
fs.writeFileSync(reportPath, report);
|
||||
}
|
||||
}
|
||||
|
||||
// Run analysis if called directly
|
||||
if (require.main === module) {
|
||||
const analyzer = new BundleAnalyzer();
|
||||
analyzer.analyzeBundles().catch(console.error);
|
||||
}
|
||||
|
||||
module.exports = BundleAnalyzer;
|
||||
+265
-354
@@ -2,386 +2,297 @@
|
||||
|
||||
/**
|
||||
* Performance Monitoring Script
|
||||
*
|
||||
* This script provides comprehensive performance monitoring capabilities
|
||||
* for the Community Rule application.
|
||||
* Monitors Core Web Vitals and performance metrics
|
||||
*/
|
||||
|
||||
const { spawn } = require("child_process");
|
||||
const { execSync } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// Performance budgets
|
||||
const PERFORMANCE_BUDGETS = {
|
||||
page_load_time: 3000,
|
||||
first_contentful_paint: 2000,
|
||||
largest_contentful_paint: 2500,
|
||||
first_input_delay: 100,
|
||||
dns_lookup: 100,
|
||||
tcp_connection: 200,
|
||||
ttfb: 600,
|
||||
dom_content_loaded: 1500,
|
||||
full_load: 3000,
|
||||
component_render_time: 500,
|
||||
interaction_time: 100,
|
||||
scroll_performance: 50,
|
||||
network_request_duration: 1000,
|
||||
memory_usage_mb: 50,
|
||||
};
|
||||
const PERFORMANCE_BUDGETS = require("../performance-budgets.json");
|
||||
const MONITORING_DIR = path.join(__dirname, "..", ".next", "monitoring");
|
||||
|
||||
// Baseline metrics for regression detection
|
||||
const BASELINE_METRICS = {
|
||||
page_load_time: 2000,
|
||||
first_contentful_paint: 1500,
|
||||
largest_contentful_paint: 2000,
|
||||
first_input_delay: 50,
|
||||
dns_lookup: 50,
|
||||
tcp_connection: 100,
|
||||
ttfb: 400,
|
||||
dom_content_loaded: 1000,
|
||||
full_load: 2000,
|
||||
component_render_time: 300,
|
||||
interaction_time: 50,
|
||||
scroll_performance: 30,
|
||||
network_request_duration: 500,
|
||||
memory_usage_mb: 30,
|
||||
};
|
||||
|
||||
class PerformanceMonitorScript {
|
||||
class PerformanceMonitor {
|
||||
constructor() {
|
||||
this.metrics = new Map();
|
||||
this.regressions = [];
|
||||
this.warnings = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Run Lighthouse CI performance tests
|
||||
*/
|
||||
async runLighthouseCI() {
|
||||
console.log("🚀 Running Lighthouse CI performance tests...");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const lhci = spawn("npx", ["lhci", "autorun"], {
|
||||
stdio: "pipe",
|
||||
shell: true,
|
||||
});
|
||||
|
||||
let output = "";
|
||||
let errorOutput = "";
|
||||
|
||||
lhci.stdout.on("data", (data) => {
|
||||
output += data.toString();
|
||||
console.log(data.toString());
|
||||
});
|
||||
|
||||
lhci.stderr.on("data", (data) => {
|
||||
errorOutput += data.toString();
|
||||
console.error(data.toString());
|
||||
});
|
||||
|
||||
lhci.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
console.log("✅ Lighthouse CI tests completed successfully");
|
||||
this.analyzeLighthouseResults(output);
|
||||
resolve(output);
|
||||
} else {
|
||||
console.error("❌ Lighthouse CI tests failed");
|
||||
reject(
|
||||
new Error(`Lighthouse CI failed with code ${code}: ${errorOutput}`),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run Playwright performance tests
|
||||
*/
|
||||
async runPlaywrightPerformanceTests() {
|
||||
console.log("🎭 Running Playwright performance tests...");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const playwright = spawn(
|
||||
"npx",
|
||||
[
|
||||
"playwright",
|
||||
"test",
|
||||
"tests/e2e/performance.spec.ts",
|
||||
"--reporter=json",
|
||||
],
|
||||
{
|
||||
stdio: "pipe",
|
||||
shell: true,
|
||||
},
|
||||
);
|
||||
|
||||
let output = "";
|
||||
let errorOutput = "";
|
||||
|
||||
playwright.stdout.on("data", (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
playwright.stderr.on("data", (data) => {
|
||||
errorOutput += data.toString();
|
||||
});
|
||||
|
||||
playwright.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
console.log("✅ Playwright performance tests completed successfully");
|
||||
this.analyzePlaywrightResults(output);
|
||||
resolve(output);
|
||||
} else {
|
||||
console.error("❌ Playwright performance tests failed");
|
||||
reject(
|
||||
new Error(
|
||||
`Playwright tests failed with code ${code}: ${errorOutput}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze Lighthouse CI results
|
||||
*/
|
||||
analyzeLighthouseResults(output) {
|
||||
console.log("📊 Analyzing Lighthouse CI results...");
|
||||
|
||||
// Parse Lighthouse results
|
||||
const lines = output.split("\n");
|
||||
let currentMetric = null;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.includes("Performance")) {
|
||||
const scoreMatch = line.match(/(\d+)/);
|
||||
if (scoreMatch) {
|
||||
const score = parseInt(scoreMatch[1]);
|
||||
this.recordMetric("lighthouse_performance_score", score);
|
||||
|
||||
if (score < 90) {
|
||||
this.warnings.push(
|
||||
`Performance score below threshold: ${score}/100`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (line.includes("First Contentful Paint")) {
|
||||
const timeMatch = line.match(/(\d+(?:\.\d+)?)\s*ms/);
|
||||
if (timeMatch) {
|
||||
const time = parseFloat(timeMatch[1]);
|
||||
this.recordMetric("first_contentful_paint", time);
|
||||
|
||||
if (time > PERFORMANCE_BUDGETS.first_contentful_paint) {
|
||||
this.warnings.push(
|
||||
`First Contentful Paint exceeded budget: ${time}ms`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (line.includes("Largest Contentful Paint")) {
|
||||
const timeMatch = line.match(/(\d+(?:\.\d+)?)\s*ms/);
|
||||
if (timeMatch) {
|
||||
const time = parseFloat(timeMatch[1]);
|
||||
this.recordMetric("largest_contentful_paint", time);
|
||||
|
||||
if (time > PERFORMANCE_BUDGETS.largest_contentful_paint) {
|
||||
this.warnings.push(
|
||||
`Largest Contentful Paint exceeded budget: ${time}ms`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (line.includes("Total Blocking Time")) {
|
||||
const timeMatch = line.match(/(\d+(?:\.\d+)?)\s*ms/);
|
||||
if (timeMatch) {
|
||||
const time = parseFloat(timeMatch[1]);
|
||||
this.recordMetric("total_blocking_time", time);
|
||||
|
||||
if (time > 300) {
|
||||
this.warnings.push(
|
||||
`Total Blocking Time exceeded budget: ${time}ms`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (line.includes("Cumulative Layout Shift")) {
|
||||
const shiftMatch = line.match(/(\d+(?:\.\d+)?)/);
|
||||
if (shiftMatch) {
|
||||
const shift = parseFloat(shiftMatch[1]);
|
||||
this.recordMetric("cumulative_layout_shift", shift);
|
||||
|
||||
if (shift > 0.1) {
|
||||
this.warnings.push(
|
||||
`Cumulative Layout Shift exceeded budget: ${shift}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze Playwright test results
|
||||
*/
|
||||
analyzePlaywrightResults(output) {
|
||||
console.log("📊 Analyzing Playwright test results...");
|
||||
|
||||
try {
|
||||
const results = JSON.parse(output);
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === "failed") {
|
||||
this.warnings.push(`Test failed: ${result.title}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Could not parse Playwright results as JSON");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a performance metric
|
||||
*/
|
||||
recordMetric(name, value) {
|
||||
if (!this.metrics.has(name)) {
|
||||
this.metrics.set(name, []);
|
||||
}
|
||||
this.metrics.get(name).push({
|
||||
value,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Check against baseline for regression detection
|
||||
const baseline = BASELINE_METRICS[name];
|
||||
if (baseline) {
|
||||
const regressionThreshold = baseline * 1.2; // 20% regression threshold
|
||||
if (value > regressionThreshold) {
|
||||
this.regressions.push({
|
||||
metric: name,
|
||||
current: value,
|
||||
baseline,
|
||||
regression: (((value - baseline) / baseline) * 100).toFixed(1) + "%",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate performance report
|
||||
*/
|
||||
generateReport() {
|
||||
console.log("\n📈 Performance Monitoring Report");
|
||||
console.log("================================\n");
|
||||
|
||||
// Summary
|
||||
console.log("📊 Summary:");
|
||||
console.log(`- Total metrics recorded: ${this.metrics.size}`);
|
||||
console.log(
|
||||
`- Performance regressions detected: ${this.regressions.length}`,
|
||||
);
|
||||
console.log(`- Warnings: ${this.warnings.length}\n`);
|
||||
|
||||
// Performance regressions
|
||||
if (this.regressions.length > 0) {
|
||||
console.log("🚨 Performance Regressions:");
|
||||
for (const regression of this.regressions) {
|
||||
console.log(
|
||||
` - ${regression.metric}: ${regression.current} (baseline: ${regression.baseline}, regression: ${regression.regression})`,
|
||||
);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
// Warnings
|
||||
if (this.warnings.length > 0) {
|
||||
console.log("⚠️ Warnings:");
|
||||
for (const warning of this.warnings) {
|
||||
console.log(` - ${warning}`);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
// Metrics summary
|
||||
console.log("📋 Metrics Summary:");
|
||||
for (const [name, values] of this.metrics) {
|
||||
const latest = values[values.length - 1];
|
||||
const average =
|
||||
values.reduce((sum, v) => sum + v.value, 0) / values.length;
|
||||
const budget = PERFORMANCE_BUDGETS[name];
|
||||
|
||||
console.log(` - ${name}:`);
|
||||
console.log(` Latest: ${latest.value}`);
|
||||
console.log(` Average: ${average.toFixed(2)}`);
|
||||
if (budget) {
|
||||
const status = latest.value <= budget ? "✅" : "❌";
|
||||
console.log(` Budget: ${budget} ${status}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Save report to file
|
||||
const report = {
|
||||
this.metrics = {
|
||||
timestamp: new Date().toISOString(),
|
||||
summary: {
|
||||
totalMetrics: this.metrics.size,
|
||||
regressions: this.regressions.length,
|
||||
warnings: this.warnings.length,
|
||||
},
|
||||
regressions: this.regressions,
|
||||
warnings: this.warnings,
|
||||
metrics: Object.fromEntries(this.metrics),
|
||||
coreWebVitals: {},
|
||||
bundleMetrics: {},
|
||||
recommendations: [],
|
||||
};
|
||||
|
||||
const reportPath = path.join(__dirname, "../performance-report.json");
|
||||
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
|
||||
console.log(`\n📄 Report saved to: ${reportPath}`);
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all performance monitoring
|
||||
* Run comprehensive performance monitoring
|
||||
*/
|
||||
async run() {
|
||||
console.log("🔍 Starting Performance Monitoring...\n");
|
||||
async monitorPerformance() {
|
||||
console.log("📊 Starting performance monitoring...");
|
||||
|
||||
try {
|
||||
// Run Lighthouse CI tests
|
||||
// Ensure monitoring directory exists
|
||||
if (!fs.existsSync(MONITORING_DIR)) {
|
||||
fs.mkdirSync(MONITORING_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Run Lighthouse CI for Core Web Vitals
|
||||
await this.runLighthouseCI();
|
||||
|
||||
// Run Playwright performance tests
|
||||
await this.runPlaywrightPerformanceTests();
|
||||
// Analyze bundle performance
|
||||
await this.analyzeBundlePerformance();
|
||||
|
||||
// Generate and display report
|
||||
const report = this.generateReport();
|
||||
// Check performance budgets
|
||||
this.checkPerformanceBudgets();
|
||||
|
||||
// Exit with appropriate code
|
||||
if (this.regressions.length > 0) {
|
||||
console.log("❌ Performance regressions detected!");
|
||||
process.exit(1);
|
||||
} else if (this.warnings.length > 0) {
|
||||
console.log("⚠️ Performance warnings detected.");
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log("✅ All performance checks passed!");
|
||||
process.exit(0);
|
||||
}
|
||||
// Generate performance report
|
||||
this.generatePerformanceReport();
|
||||
|
||||
console.log("✅ Performance monitoring complete!");
|
||||
console.log(`📁 Results saved to: ${MONITORING_DIR}`);
|
||||
} catch (error) {
|
||||
console.error("❌ Performance monitoring failed:", error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run Lighthouse CI for Core Web Vitals
|
||||
*/
|
||||
async runLighthouseCI() {
|
||||
console.log("🔍 Running Lighthouse CI...");
|
||||
|
||||
try {
|
||||
// Check if server is running
|
||||
const { execSync } = require("child_process");
|
||||
try {
|
||||
execSync("curl -s http://localhost:3000 > /dev/null", {
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"⚠️ Development server not running, skipping Lighthouse CI...",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Run Lighthouse CI with performance focus
|
||||
execSync("npx lhci autorun --collect.url=http://localhost:3000", {
|
||||
stdio: "inherit",
|
||||
cwd: path.join(__dirname, ".."),
|
||||
});
|
||||
|
||||
// Parse Lighthouse results
|
||||
await this.parseLighthouseResults();
|
||||
} catch (error) {
|
||||
console.warn("⚠️ Lighthouse CI failed, continuing with other metrics...");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Lighthouse CI results
|
||||
*/
|
||||
async parseLighthouseResults() {
|
||||
const lhciResultsPath = path.join(__dirname, "..", ".lighthouseci");
|
||||
|
||||
if (fs.existsSync(lhciResultsPath)) {
|
||||
const files = fs.readdirSync(lhciResultsPath);
|
||||
const resultFile = files.find((f) => f.endsWith(".json"));
|
||||
|
||||
if (resultFile) {
|
||||
const results = JSON.parse(
|
||||
fs.readFileSync(path.join(lhciResultsPath, resultFile), "utf8"),
|
||||
);
|
||||
|
||||
if (results.lhr && results.lhr.audits) {
|
||||
this.metrics.coreWebVitals = {
|
||||
lcp: this.getAuditScore(
|
||||
results.lhr.audits,
|
||||
"largest-contentful-paint",
|
||||
),
|
||||
fid: this.getAuditScore(results.lhr.audits, "max-potential-fid"),
|
||||
cls: this.getAuditScore(
|
||||
results.lhr.audits,
|
||||
"cumulative-layout-shift",
|
||||
),
|
||||
fcp: this.getAuditScore(
|
||||
results.lhr.audits,
|
||||
"first-contentful-paint",
|
||||
),
|
||||
tti: this.getAuditScore(results.lhr.audits, "interactive"),
|
||||
performance: results.lhr.categories.performance?.score * 100 || 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit score from Lighthouse results
|
||||
*/
|
||||
getAuditScore(audits, auditId) {
|
||||
const audit = audits[auditId];
|
||||
if (!audit) return null;
|
||||
|
||||
return {
|
||||
score: audit.score * 100,
|
||||
value: audit.numericValue,
|
||||
displayValue: audit.displayValue,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze bundle performance
|
||||
*/
|
||||
async analyzeBundlePerformance() {
|
||||
console.log("📦 Analyzing bundle performance...");
|
||||
|
||||
const bundleStatsPath = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
".next",
|
||||
"static",
|
||||
"chunks",
|
||||
);
|
||||
|
||||
if (fs.existsSync(bundleStatsPath)) {
|
||||
const files = fs.readdirSync(bundleStatsPath);
|
||||
let totalSize = 0;
|
||||
let jsFiles = 0;
|
||||
|
||||
files.forEach((file) => {
|
||||
if (file.endsWith(".js")) {
|
||||
const filePath = path.join(bundleStatsPath, file);
|
||||
const stats = fs.statSync(filePath);
|
||||
totalSize += stats.size;
|
||||
jsFiles++;
|
||||
}
|
||||
});
|
||||
|
||||
this.metrics.bundleMetrics = {
|
||||
totalSizeKB: Math.round(totalSize / 1024),
|
||||
totalSizeMB: Math.round((totalSize / (1024 * 1024)) * 100) / 100,
|
||||
fileCount: jsFiles,
|
||||
averageSizeKB: Math.round(totalSize / jsFiles / 1024),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check performance budgets
|
||||
*/
|
||||
checkPerformanceBudgets() {
|
||||
const budgets = PERFORMANCE_BUDGETS.budgets;
|
||||
const violations = [];
|
||||
|
||||
// Check Core Web Vitals
|
||||
if (this.metrics.coreWebVitals.lcp) {
|
||||
const lcpValue = this.metrics.coreWebVitals.lcp.value;
|
||||
const lcpBudget = budgets.find((b) => b.name === "lcp")?.maxValue;
|
||||
|
||||
if (lcpBudget && lcpValue > lcpBudget) {
|
||||
violations.push({
|
||||
metric: "LCP",
|
||||
current: lcpValue,
|
||||
budget: lcpBudget,
|
||||
severity: lcpValue > lcpBudget * 1.5 ? "high" : "medium",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check bundle size
|
||||
if (this.metrics.bundleMetrics.totalSizeKB > 2000) {
|
||||
violations.push({
|
||||
metric: "Bundle Size",
|
||||
current: this.metrics.bundleMetrics.totalSizeKB,
|
||||
budget: 2000,
|
||||
severity: "medium",
|
||||
});
|
||||
}
|
||||
|
||||
this.metrics.budgetViolations = violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate performance report
|
||||
*/
|
||||
generatePerformanceReport() {
|
||||
const reportPath = path.join(MONITORING_DIR, "performance-report.json");
|
||||
fs.writeFileSync(reportPath, JSON.stringify(this.metrics, null, 2));
|
||||
|
||||
// Generate markdown report
|
||||
this.generateMarkdownReport();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate markdown performance report
|
||||
*/
|
||||
generateMarkdownReport() {
|
||||
const reportPath = path.join(MONITORING_DIR, "performance-report.md");
|
||||
|
||||
let report = `# Performance Monitoring Report\n\n`;
|
||||
report += `**Generated:** ${this.metrics.timestamp}\n\n`;
|
||||
|
||||
// Core Web Vitals
|
||||
if (Object.keys(this.metrics.coreWebVitals).length > 0) {
|
||||
report += `## Core Web Vitals\n\n`;
|
||||
report += `| Metric | Score | Value | Status |\n`;
|
||||
report += `|--------|-------|-------|--------|\n`;
|
||||
|
||||
Object.entries(this.metrics.coreWebVitals).forEach(([metric, data]) => {
|
||||
if (data && typeof data === "object" && data.score !== undefined) {
|
||||
const status = this.getMetricStatus(metric, data.score);
|
||||
report += `| ${metric.toUpperCase()} | ${data.score} | ${
|
||||
data.displayValue || "N/A"
|
||||
} | ${status} |\n`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Bundle Metrics
|
||||
if (Object.keys(this.metrics.bundleMetrics).length > 0) {
|
||||
report += `\n## Bundle Metrics\n\n`;
|
||||
report += `- **Total Size:** ${this.metrics.bundleMetrics.totalSizeMB}MB (${this.metrics.bundleMetrics.totalSizeKB}KB)\n`;
|
||||
report += `- **File Count:** ${this.metrics.bundleMetrics.fileCount}\n`;
|
||||
report += `- **Average Size:** ${this.metrics.bundleMetrics.averageSizeKB}KB per file\n`;
|
||||
}
|
||||
|
||||
// Budget Violations
|
||||
if (
|
||||
this.metrics.budgetViolations &&
|
||||
this.metrics.budgetViolations.length > 0
|
||||
) {
|
||||
report += `\n## Budget Violations\n\n`;
|
||||
this.metrics.budgetViolations.forEach((violation) => {
|
||||
report += `- **${violation.metric}**: ${violation.current} (exceeds ${
|
||||
violation.budget
|
||||
}) - ${violation.severity.toUpperCase()}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
// Recommendations
|
||||
report += `\n## Recommendations\n\n`;
|
||||
report += `- Monitor Core Web Vitals regularly\n`;
|
||||
report += `- Implement code splitting for large bundles\n`;
|
||||
report += `- Use dynamic imports for non-critical components\n`;
|
||||
report += `- Optimize images and fonts\n`;
|
||||
report += `- Enable compression and caching\n`;
|
||||
|
||||
fs.writeFileSync(reportPath, report);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status emoji for metric score
|
||||
*/
|
||||
getMetricStatus(metric, score) {
|
||||
if (score >= 90) return "✅ Good";
|
||||
if (score >= 50) return "⚠️ Needs Improvement";
|
||||
return "❌ Poor";
|
||||
}
|
||||
}
|
||||
|
||||
// Run the performance monitor if this script is executed directly
|
||||
// Run monitoring if called directly
|
||||
if (require.main === module) {
|
||||
const monitor = new PerformanceMonitorScript();
|
||||
monitor.run();
|
||||
const monitor = new PerformanceMonitor();
|
||||
monitor.monitorPerformance().catch(console.error);
|
||||
}
|
||||
|
||||
module.exports = PerformanceMonitorScript;
|
||||
module.exports = PerformanceMonitor;
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Comprehensive Performance Testing Script
|
||||
* Integrates bundle analysis, performance monitoring, and Web Vitals tracking
|
||||
*/
|
||||
|
||||
const { execSync } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const TEST_RESULTS_DIR = path.join(__dirname, "..", ".next", "test-results");
|
||||
|
||||
class PerformanceTester {
|
||||
constructor() {
|
||||
this.results = {
|
||||
timestamp: new Date().toISOString(),
|
||||
bundleAnalysis: {},
|
||||
performanceMonitoring: {},
|
||||
webVitals: {},
|
||||
lighthouse: {},
|
||||
summary: {
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
warnings: 0,
|
||||
total: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run comprehensive performance testing
|
||||
*/
|
||||
async runTests() {
|
||||
console.log("🧪 Starting comprehensive performance testing...");
|
||||
|
||||
try {
|
||||
// Ensure test results directory exists
|
||||
if (!fs.existsSync(TEST_RESULTS_DIR)) {
|
||||
fs.mkdirSync(TEST_RESULTS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// 1. Bundle Analysis
|
||||
console.log("📊 Running bundle analysis...");
|
||||
await this.runBundleAnalysis();
|
||||
|
||||
// 2. Performance Monitoring
|
||||
console.log("📈 Running performance monitoring...");
|
||||
await this.runPerformanceMonitoring();
|
||||
|
||||
// 3. Web Vitals Tracking
|
||||
console.log("📊 Setting up Web Vitals tracking...");
|
||||
await this.runWebVitalsTracking();
|
||||
|
||||
// 4. Lighthouse CI (if server is available)
|
||||
console.log("🔍 Running Lighthouse CI...");
|
||||
await this.runLighthouseCI();
|
||||
|
||||
// 5. Generate comprehensive report
|
||||
this.generateComprehensiveReport();
|
||||
|
||||
console.log("✅ Performance testing complete!");
|
||||
console.log(`📁 Results saved to: ${TEST_RESULTS_DIR}`);
|
||||
|
||||
// Return exit code based on results
|
||||
const hasFailures = this.results.summary.failed > 0;
|
||||
if (hasFailures) {
|
||||
console.log("❌ Performance tests failed");
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log("✅ All performance tests passed");
|
||||
process.exit(0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ Performance testing failed:", error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run bundle analysis
|
||||
*/
|
||||
async runBundleAnalysis() {
|
||||
try {
|
||||
execSync("npm run bundle:analyze", { stdio: "inherit" });
|
||||
|
||||
// Parse bundle analysis results
|
||||
const bundleReportPath = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
".next",
|
||||
"analyze",
|
||||
"bundle-analysis.json",
|
||||
);
|
||||
if (fs.existsSync(bundleReportPath)) {
|
||||
const bundleData = JSON.parse(
|
||||
fs.readFileSync(bundleReportPath, "utf8"),
|
||||
);
|
||||
this.results.bundleAnalysis = bundleData;
|
||||
|
||||
// Check for budget violations
|
||||
if (
|
||||
bundleData.budgetViolations &&
|
||||
bundleData.budgetViolations.length > 0
|
||||
) {
|
||||
this.results.summary.failed += bundleData.budgetViolations.length;
|
||||
console.log(
|
||||
`⚠️ Found ${bundleData.budgetViolations.length} budget violations`,
|
||||
);
|
||||
} else {
|
||||
this.results.summary.passed += 1;
|
||||
console.log("✅ Bundle analysis passed");
|
||||
}
|
||||
}
|
||||
|
||||
this.results.summary.total += 1;
|
||||
} catch (error) {
|
||||
console.error("❌ Bundle analysis failed:", error.message);
|
||||
this.results.summary.failed += 1;
|
||||
this.results.summary.total += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run performance monitoring
|
||||
*/
|
||||
async runPerformanceMonitoring() {
|
||||
try {
|
||||
execSync("npm run performance:monitor", { stdio: "inherit" });
|
||||
|
||||
// Parse performance monitoring results
|
||||
const perfReportPath = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
".next",
|
||||
"monitoring",
|
||||
"performance-report.json",
|
||||
);
|
||||
if (fs.existsSync(perfReportPath)) {
|
||||
const perfData = JSON.parse(fs.readFileSync(perfReportPath, "utf8"));
|
||||
this.results.performanceMonitoring = perfData;
|
||||
|
||||
// Check for budget violations
|
||||
if (perfData.budgetViolations && perfData.budgetViolations.length > 0) {
|
||||
this.results.summary.failed += perfData.budgetViolations.length;
|
||||
console.log(
|
||||
`⚠️ Found ${perfData.budgetViolations.length} performance violations`,
|
||||
);
|
||||
} else {
|
||||
this.results.summary.passed += 1;
|
||||
console.log("✅ Performance monitoring passed");
|
||||
}
|
||||
}
|
||||
|
||||
this.results.summary.total += 1;
|
||||
} catch (error) {
|
||||
console.error("❌ Performance monitoring failed:", error.message);
|
||||
this.results.summary.failed += 1;
|
||||
this.results.summary.total += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run Web Vitals tracking
|
||||
*/
|
||||
async runWebVitalsTracking() {
|
||||
try {
|
||||
execSync("npm run web-vitals:track", { stdio: "inherit" });
|
||||
|
||||
// Parse Web Vitals results
|
||||
const vitalsReportPath = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
".next",
|
||||
"web-vitals",
|
||||
"report.json",
|
||||
);
|
||||
if (fs.existsSync(vitalsReportPath)) {
|
||||
const vitalsData = JSON.parse(
|
||||
fs.readFileSync(vitalsReportPath, "utf8"),
|
||||
);
|
||||
this.results.webVitals = vitalsData;
|
||||
console.log("✅ Web Vitals tracking setup complete");
|
||||
}
|
||||
|
||||
this.results.summary.passed += 1;
|
||||
this.results.summary.total += 1;
|
||||
} catch (error) {
|
||||
console.error("❌ Web Vitals tracking failed:", error.message);
|
||||
this.results.summary.failed += 1;
|
||||
this.results.summary.total += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run Lighthouse CI
|
||||
*/
|
||||
async runLighthouseCI() {
|
||||
try {
|
||||
// Check if server is running
|
||||
try {
|
||||
execSync("curl -s http://localhost:3000 > /dev/null", {
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"⚠️ Development server not running, skipping Lighthouse CI...",
|
||||
);
|
||||
this.results.summary.warnings += 1;
|
||||
this.results.summary.total += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
execSync("npm run lhci", { stdio: "inherit" });
|
||||
|
||||
// Parse Lighthouse results
|
||||
const lhciResultsPath = path.join(__dirname, "..", ".lighthouseci");
|
||||
if (fs.existsSync(lhciResultsPath)) {
|
||||
const files = fs.readdirSync(lhciResultsPath);
|
||||
const resultFile = files.find((f) => f.endsWith(".json"));
|
||||
|
||||
if (resultFile) {
|
||||
const lhciData = JSON.parse(
|
||||
fs.readFileSync(path.join(lhciResultsPath, resultFile), "utf8"),
|
||||
);
|
||||
this.results.lighthouse = lhciData;
|
||||
console.log("✅ Lighthouse CI completed");
|
||||
}
|
||||
}
|
||||
|
||||
this.results.summary.passed += 1;
|
||||
this.results.summary.total += 1;
|
||||
} catch (error) {
|
||||
console.warn("⚠️ Lighthouse CI failed:", error.message);
|
||||
this.results.summary.warnings += 1;
|
||||
this.results.summary.total += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate comprehensive test report
|
||||
*/
|
||||
generateComprehensiveReport() {
|
||||
// Ensure test results directory exists
|
||||
if (!fs.existsSync(TEST_RESULTS_DIR)) {
|
||||
fs.mkdirSync(TEST_RESULTS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const reportPath = path.join(
|
||||
TEST_RESULTS_DIR,
|
||||
"performance-test-report.json",
|
||||
);
|
||||
fs.writeFileSync(reportPath, JSON.stringify(this.results, null, 2));
|
||||
|
||||
// Generate markdown report
|
||||
this.generateMarkdownReport();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate markdown test report
|
||||
*/
|
||||
generateMarkdownReport() {
|
||||
const reportPath = path.join(
|
||||
TEST_RESULTS_DIR,
|
||||
"performance-test-report.md",
|
||||
);
|
||||
|
||||
let report = `# Performance Test Report\n\n`;
|
||||
report += `**Generated:** ${this.results.timestamp}\n\n`;
|
||||
|
||||
// Summary
|
||||
report += `## Test Summary\n\n`;
|
||||
report += `- **Total Tests:** ${this.results.summary.total}\n`;
|
||||
report += `- **Passed:** ${this.results.summary.passed} ✅\n`;
|
||||
report += `- **Failed:** ${this.results.summary.failed} ❌\n`;
|
||||
report += `- **Warnings:** ${this.results.summary.warnings} ⚠️\n\n`;
|
||||
|
||||
// Bundle Analysis Results
|
||||
if (Object.keys(this.results.bundleAnalysis).length > 0) {
|
||||
report += `## Bundle Analysis\n\n`;
|
||||
if (
|
||||
this.results.bundleAnalysis.budgetViolations &&
|
||||
this.results.bundleAnalysis.budgetViolations.length > 0
|
||||
) {
|
||||
report += `### Budget Violations\n\n`;
|
||||
this.results.bundleAnalysis.budgetViolations.forEach((violation) => {
|
||||
report += `- **${violation.file}**: ${
|
||||
violation.currentSize
|
||||
}KB (exceeds ${violation.maxSize}KB by ${
|
||||
violation.overage
|
||||
}KB) - ${violation.severity.toUpperCase()}\n`;
|
||||
});
|
||||
} else {
|
||||
report += `✅ No bundle budget violations found\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// Performance Monitoring Results
|
||||
if (Object.keys(this.results.performanceMonitoring).length > 0) {
|
||||
report += `## Performance Monitoring\n\n`;
|
||||
if (
|
||||
this.results.performanceMonitoring.budgetViolations &&
|
||||
this.results.performanceMonitoring.budgetViolations.length > 0
|
||||
) {
|
||||
report += `### Budget Violations\n\n`;
|
||||
this.results.performanceMonitoring.budgetViolations.forEach(
|
||||
(violation) => {
|
||||
report += `- **${violation.metric}**: ${
|
||||
violation.current
|
||||
} (exceeds ${
|
||||
violation.budget
|
||||
}) - ${violation.severity.toUpperCase()}\n`;
|
||||
},
|
||||
);
|
||||
} else {
|
||||
report += `✅ No performance budget violations found\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// Web Vitals Results
|
||||
if (Object.keys(this.results.webVitals).length > 0) {
|
||||
report += `## Web Vitals Tracking\n\n`;
|
||||
report += `✅ Web Vitals tracking setup complete\n\n`;
|
||||
}
|
||||
|
||||
// Lighthouse Results
|
||||
if (Object.keys(this.results.lighthouse).length > 0) {
|
||||
report += `## Lighthouse CI\n\n`;
|
||||
report += `✅ Lighthouse CI completed successfully\n\n`;
|
||||
}
|
||||
|
||||
// Recommendations
|
||||
report += `## Recommendations\n\n`;
|
||||
report += `- Monitor bundle sizes regularly\n`;
|
||||
report += `- Track Core Web Vitals in production\n`;
|
||||
report += `- Run performance tests in CI/CD pipeline\n`;
|
||||
report += `- Set up performance budgets and alerts\n`;
|
||||
|
||||
fs.writeFileSync(reportPath, report);
|
||||
}
|
||||
}
|
||||
|
||||
// Run if called directly
|
||||
if (require.main === module) {
|
||||
const tester = new PerformanceTester();
|
||||
tester.runTests().catch(console.error);
|
||||
}
|
||||
|
||||
module.exports = PerformanceTester;
|
||||
@@ -0,0 +1,335 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Web Vitals Tracker
|
||||
* Real-time monitoring of Core Web Vitals in production
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const WEB_VITALS_DIR = path.join(__dirname, "..", ".next", "web-vitals");
|
||||
|
||||
class WebVitalsTracker {
|
||||
constructor() {
|
||||
this.metrics = {
|
||||
timestamp: new Date().toISOString(),
|
||||
vitals: {
|
||||
lcp: [],
|
||||
fid: [],
|
||||
cls: [],
|
||||
fcp: [],
|
||||
ttfb: [],
|
||||
},
|
||||
summary: {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Track Web Vitals from client-side
|
||||
*/
|
||||
trackWebVitals() {
|
||||
const trackingCode = `
|
||||
// Web Vitals Tracking Script
|
||||
(function() {
|
||||
// Import web-vitals library
|
||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
const vitals = {};
|
||||
|
||||
// Track Largest Contentful Paint
|
||||
getLCP((metric) => {
|
||||
vitals.lcp = {
|
||||
value: metric.value,
|
||||
rating: metric.rating,
|
||||
delta: metric.delta,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
sendVitals('lcp', vitals.lcp);
|
||||
});
|
||||
|
||||
// Track First Input Delay
|
||||
getFID((metric) => {
|
||||
vitals.fid = {
|
||||
value: metric.value,
|
||||
rating: metric.rating,
|
||||
delta: metric.delta,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
sendVitals('fid', vitals.fid);
|
||||
});
|
||||
|
||||
// Track Cumulative Layout Shift
|
||||
getCLS((metric) => {
|
||||
vitals.cls = {
|
||||
value: metric.value,
|
||||
rating: metric.rating,
|
||||
delta: metric.delta,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
sendVitals('cls', vitals.cls);
|
||||
});
|
||||
|
||||
// Track First Contentful Paint
|
||||
getFCP((metric) => {
|
||||
vitals.fcp = {
|
||||
value: metric.value,
|
||||
rating: metric.rating,
|
||||
delta: metric.delta,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
sendVitals('fcp', vitals.fcp);
|
||||
});
|
||||
|
||||
// Track Time to First Byte
|
||||
getTTFB((metric) => {
|
||||
vitals.ttfb = {
|
||||
value: metric.value,
|
||||
rating: metric.rating,
|
||||
delta: metric.delta,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
sendVitals('ttfb', vitals.ttfb);
|
||||
});
|
||||
});
|
||||
|
||||
// Send vitals to server
|
||||
function sendVitals(metric, data) {
|
||||
if (typeof window !== 'undefined' && window.fetch) {
|
||||
fetch('/api/web-vitals', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
metric,
|
||||
data,
|
||||
url: window.location.href,
|
||||
userAgent: navigator.userAgent,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}).catch(console.error);
|
||||
}
|
||||
}
|
||||
})();
|
||||
`;
|
||||
|
||||
return trackingCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create API endpoint for receiving Web Vitals
|
||||
*/
|
||||
createAPIEndpoint() {
|
||||
const apiCode = `
|
||||
// API endpoint for Web Vitals tracking
|
||||
export default function handler(req, res) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { metric, data, url, userAgent, timestamp } = req.body;
|
||||
|
||||
// Store the metric data
|
||||
const vitalsData = {
|
||||
metric,
|
||||
data,
|
||||
url,
|
||||
userAgent,
|
||||
timestamp: new Date(timestamp).toISOString()
|
||||
};
|
||||
|
||||
// In production, you would save this to a database
|
||||
// For now, we'll log it
|
||||
console.log('Web Vital received:', vitalsData);
|
||||
|
||||
res.status(200).json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error processing web vital:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
return apiCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Web Vitals dashboard
|
||||
*/
|
||||
generateDashboard() {
|
||||
const dashboardCode = `
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
const WebVitalsDashboard = () => {
|
||||
const [vitals, setVitals] = useState({
|
||||
lcp: { value: 0, rating: 'unknown' },
|
||||
fid: { value: 0, rating: 'unknown' },
|
||||
cls: { value: 0, rating: 'unknown' },
|
||||
fcp: { value: 0, rating: 'unknown' },
|
||||
ttfb: { value: 0, rating: 'unknown' }
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// In a real implementation, you would fetch from your database
|
||||
// For now, we'll use localStorage for demo purposes
|
||||
const storedVitals = localStorage.getItem('web-vitals');
|
||||
if (storedVitals) {
|
||||
setVitals(JSON.parse(storedVitals));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getRatingColor = (rating) => {
|
||||
switch (rating) {
|
||||
case 'good': return 'text-green-600';
|
||||
case 'needs-improvement': return 'text-yellow-600';
|
||||
case 'poor': return 'text-red-600';
|
||||
default: return 'text-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
const getRatingIcon = (rating) => {
|
||||
switch (rating) {
|
||||
case 'good': return '✅';
|
||||
case 'needs-improvement': return '⚠️';
|
||||
case 'poor': return '❌';
|
||||
default: return '❓';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-white rounded-lg shadow-lg">
|
||||
<h2 className="text-2xl font-bold mb-6">Web Vitals Dashboard</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Object.entries(vitals).map(([metric, data]) => (
|
||||
<div key={metric} className="p-4 border rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="font-semibold text-lg">{metric.toUpperCase()}</h3>
|
||||
<span className="text-2xl">{getRatingIcon(data.rating)}</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
<div>Value: {data.value}ms</div>
|
||||
<div className={\`font-medium \${getRatingColor(data.rating)}\`}>
|
||||
Rating: {data.rating.replace('-', ' ')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-4 bg-gray-50 rounded-lg">
|
||||
<h3 className="font-semibold mb-2">Performance Guidelines</h3>
|
||||
<ul className="text-sm space-y-1">
|
||||
<li>• <strong>LCP:</strong> Good < 2.5s, Needs Improvement 2.5-4s, Poor > 4s</li>
|
||||
<li>• <strong>FID:</strong> Good < 100ms, Needs Improvement 100-300ms, Poor > 300ms</li>
|
||||
<li>• <strong>CLS:</strong> Good < 0.1, Needs Improvement 0.1-0.25, Poor > 0.25</li>
|
||||
<li>• <strong>FCP:</strong> Good < 1.8s, Needs Improvement 1.8-3s, Poor > 3s</li>
|
||||
<li>• <strong>TTFB:</strong> Good < 800ms, Needs Improvement 800-1800ms, Poor > 1800ms</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebVitalsDashboard;
|
||||
`;
|
||||
|
||||
return dashboardCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save Web Vitals data
|
||||
*/
|
||||
saveVitalsData(metric, data) {
|
||||
if (!fs.existsSync(WEB_VITALS_DIR)) {
|
||||
fs.mkdirSync(WEB_VITALS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const filePath = path.join(WEB_VITALS_DIR, `${metric}.json`);
|
||||
let existingData = [];
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
existingData = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
} catch (error) {
|
||||
console.warn("Could not parse existing vitals data:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
existingData.push({
|
||||
...data,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Keep only last 100 entries
|
||||
if (existingData.length > 100) {
|
||||
existingData = existingData.slice(-100);
|
||||
}
|
||||
|
||||
fs.writeFileSync(filePath, JSON.stringify(existingData, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Web Vitals report
|
||||
*/
|
||||
generateReport() {
|
||||
if (!fs.existsSync(WEB_VITALS_DIR)) {
|
||||
console.log("No Web Vitals data found");
|
||||
return;
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(WEB_VITALS_DIR);
|
||||
const report = {
|
||||
timestamp: new Date().toISOString(),
|
||||
metrics: {},
|
||||
};
|
||||
|
||||
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"),
|
||||
);
|
||||
|
||||
if (data.length > 0) {
|
||||
const values = data
|
||||
.map((d) => d.value)
|
||||
.filter((v) => v !== undefined);
|
||||
const ratings = data
|
||||
.map((d) => d.rating)
|
||||
.filter((r) => r !== undefined);
|
||||
|
||||
report.metrics[metric] = {
|
||||
count: data.length,
|
||||
average:
|
||||
values.length > 0
|
||||
? Math.round(values.reduce((a, b) => a + b, 0) / values.length)
|
||||
: 0,
|
||||
min: values.length > 0 ? Math.min(...values) : 0,
|
||||
max: values.length > 0 ? Math.max(...values) : 0,
|
||||
goodCount: ratings.filter((r) => r === "good").length,
|
||||
needsImprovementCount: ratings.filter(
|
||||
(r) => r === "needs-improvement",
|
||||
).length,
|
||||
poorCount: ratings.filter((r) => r === "poor").length,
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const reportPath = path.join(WEB_VITALS_DIR, "report.json");
|
||||
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
|
||||
|
||||
console.log("📊 Web Vitals report generated:", reportPath);
|
||||
return report;
|
||||
}
|
||||
}
|
||||
|
||||
// Run if called directly
|
||||
if (require.main === module) {
|
||||
const tracker = new WebVitalsTracker();
|
||||
tracker.generateReport();
|
||||
}
|
||||
|
||||
module.exports = WebVitalsTracker;
|
||||
@@ -0,0 +1,21 @@
|
||||
import ErrorBoundary from "../app/components/ErrorBoundary";
|
||||
|
||||
export default {
|
||||
title: "Components/ErrorBoundary",
|
||||
component: ErrorBoundary,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"An error boundary component that catches JavaScript errors in its child component tree. Displays a fallback UI when errors occur and logs error information for debugging.",
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
children: {
|
||||
control: { type: "text" },
|
||||
description: "Child components to wrap with error boundary",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import WebVitalsDashboard from "../app/components/WebVitalsDashboard";
|
||||
|
||||
export default {
|
||||
title: "Components/WebVitalsDashboard",
|
||||
component: WebVitalsDashboard,
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A comprehensive dashboard component that displays real-time and historical Web Vitals data. Shows Core Web Vitals metrics, performance ratings, and optimization recommendations.",
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {},
|
||||
};
|
||||
|
||||
export const Default = {
|
||||
args: {},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
"The default Web Vitals dashboard showing real-time performance metrics and historical data.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading = {
|
||||
args: {},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "The dashboard in loading state while fetching Web Vitals data.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -84,7 +84,7 @@ describe("LogoWall Component", () => {
|
||||
const foodNotBombsLogo = screen.getByAltText("Food Not Bombs");
|
||||
expect(foodNotBombsLogo).toHaveAttribute(
|
||||
"src",
|
||||
"assets/Section/Logo_FoodNotBombs.png",
|
||||
"/assets/Section/Logo_FoodNotBombs.png",
|
||||
);
|
||||
expect(foodNotBombsLogo).toHaveClass("h-11", "lg:h-14", "xl:h-[70px]");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user