Frontend Performance Optimization #21
@@ -335,8 +335,13 @@ jobs:
|
|||||||
run: npm i -D @lhci/cli
|
run: npm i -D @lhci/cli
|
||||||
|
|
||||||
- name: Build application
|
- name: Build application
|
||||||
run:
|
run: npm run build
|
||||||
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
|
# 1) Sanity check that the build exists
|
||||||
- name: Verify Next build output
|
- name: Verify Next build output
|
||||||
@@ -456,12 +461,18 @@ jobs:
|
|||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
NODE_OPTIONS: "--max-old-space-size=8192"
|
NODE_OPTIONS: "--max-old-space-size=8192"
|
||||||
|
|
||||||
- name: Upload LHCI results
|
- name: Upload Performance Artifacts
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: lhci-results
|
name: performance-results
|
||||||
path: lhci-results
|
path: |
|
||||||
|
lhci-results
|
||||||
|
.next/analyze
|
||||||
|
.next/monitoring
|
||||||
|
.next/web-vitals
|
||||||
|
.next/test-results
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
storybook:
|
storybook:
|
||||||
runs-on: [self-hosted, macos-latest]
|
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
|
# Storybook tests
|
||||||
npm run test:sb
|
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
|
### Test Coverage
|
||||||
|
|
||||||
- ✅ **124 Unit Tests** (8 components + 1 integration)
|
- ✅ **428 Unit Tests** (94.88% coverage - exceeds 85% target)
|
||||||
- ✅ **308 E2E Tests** (4 browsers × 77 tests)
|
- ✅ **92 E2E Tests** across 4 browsers
|
||||||
- ✅ **92 Visual Regression Screenshots**
|
- ✅ **23 Visual Regression Tests** per browser
|
||||||
- ✅ **Performance Budgets**
|
- ✅ **Performance Budgets** with Lighthouse CI
|
||||||
- ✅ **Accessibility Compliance**
|
- ✅ **WCAG 2.1 AA Compliance** with automated testing
|
||||||
|
- ✅ **Bundle Analysis** with automated monitoring
|
||||||
|
- ✅ **Web Vitals Tracking** with real-time metrics
|
||||||
|
|
||||||
### CI/CD Pipeline
|
### CI/CD Pipeline
|
||||||
|
|
||||||
@@ -50,6 +58,39 @@ npm run test:sb
|
|||||||
|
|
||||||
📖 **For detailed testing documentation, see [docs/TESTING.md](docs/TESTING.md)**
|
📖 **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
|
## 📚 Storybook Development
|
||||||
|
|
||||||
This project includes Storybook for component development and documentation. The setup automatically detects the environment and applies the appropriate configuration.
|
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";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React, { memo } from "react";
|
||||||
import ContentLockup from "./ContentLockup";
|
import ContentLockup from "./ContentLockup";
|
||||||
import Button from "./Button";
|
import Button from "./Button";
|
||||||
|
|
||||||
const AskOrganizer = ({
|
const AskOrganizer = memo(
|
||||||
title,
|
({
|
||||||
subtitle,
|
title,
|
||||||
description,
|
subtitle,
|
||||||
buttonText = "Ask an organizer",
|
description,
|
||||||
buttonHref = "#",
|
buttonText = "Ask an organizer",
|
||||||
className = "",
|
buttonHref = "#",
|
||||||
variant = "centered", // centered, left-aligned, compact
|
className = "",
|
||||||
onContactClick, // Analytics callback
|
variant = "centered", // centered, left-aligned, compact
|
||||||
}) => {
|
onContactClick, // Analytics callback
|
||||||
// Analytics tracking for contact button clicks
|
}) => {
|
||||||
const handleContactClick = (event) => {
|
// Analytics tracking for contact button clicks
|
||||||
// Track contact button interaction
|
const handleContactClick = (event) => {
|
||||||
if (onContactClick) {
|
// Track contact button interaction
|
||||||
onContactClick({
|
if (onContactClick) {
|
||||||
event: "contact_button_click",
|
onContactClick({
|
||||||
component: "AskOrganizer",
|
event: "contact_button_click",
|
||||||
variant,
|
component: "AskOrganizer",
|
||||||
buttonText,
|
variant,
|
||||||
buttonHref,
|
buttonText,
|
||||||
timestamp: new Date().toISOString(),
|
buttonHref,
|
||||||
});
|
timestamp: new Date().toISOString(),
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Additional analytics tracking (can be expanded)
|
// Additional analytics tracking (can be expanded)
|
||||||
if (typeof window !== "undefined" && window.gtag) {
|
if (typeof window !== "undefined" && window.gtag) {
|
||||||
window.gtag("event", "contact_button_click", {
|
window.gtag("event", "contact_button_click", {
|
||||||
event_category: "engagement",
|
event_category: "engagement",
|
||||||
event_label: "ask_organizer",
|
event_label: "ask_organizer",
|
||||||
value: 1,
|
value: 1,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Variant-specific styling
|
// Variant-specific styling
|
||||||
const variantStyles = {
|
const variantStyles = {
|
||||||
centered: {
|
centered: {
|
||||||
container: "text-center",
|
container: "text-center",
|
||||||
buttonContainer: "flex justify-center",
|
buttonContainer: "flex justify-center",
|
||||||
},
|
},
|
||||||
"left-aligned": {
|
"left-aligned": {
|
||||||
container: "text-left",
|
container: "text-left",
|
||||||
buttonContainer: "flex justify-start",
|
buttonContainer: "flex justify-start",
|
||||||
},
|
},
|
||||||
compact: {
|
compact: {
|
||||||
container: "text-center",
|
container: "text-center",
|
||||||
buttonContainer: "flex justify-center",
|
buttonContainer: "flex justify-center",
|
||||||
},
|
},
|
||||||
inverse: {
|
inverse: {
|
||||||
container: "text-center",
|
container: "text-center",
|
||||||
buttonContainer: "flex justify-center",
|
buttonContainer: "flex justify-center",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const styles = variantStyles[variant] || variantStyles.centered;
|
const styles = variantStyles[variant] || variantStyles.centered;
|
||||||
|
|
||||||
// Section padding based on variant
|
// Section padding based on variant
|
||||||
const sectionPadding =
|
const sectionPadding =
|
||||||
variant === "compact"
|
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-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)]";
|
: "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
|
// Gap between content and button based on variant
|
||||||
const contentGap =
|
const contentGap =
|
||||||
variant === "compact"
|
variant === "compact"
|
||||||
? "gap-[var(--spacing-scale-020)]"
|
? "gap-[var(--spacing-scale-020)]"
|
||||||
: "gap-[var(--spacing-scale-040)]";
|
: "gap-[var(--spacing-scale-040)]";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={`${sectionPadding} ${className}`}
|
className={`${sectionPadding} ${className}`}
|
||||||
aria-labelledby="ask-organizer-headline"
|
aria-labelledby="ask-organizer-headline"
|
||||||
role="region"
|
role="region"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
<div className={`flex flex-col ${contentGap} ${styles.container}`}>
|
<div className={`flex flex-col ${contentGap} ${styles.container}`}>
|
||||||
{/* Content Lockup */}
|
{/* Content Lockup */}
|
||||||
<ContentLockup
|
<ContentLockup
|
||||||
title={title}
|
title={title}
|
||||||
subtitle={subtitle}
|
subtitle={subtitle}
|
||||||
description={description}
|
description={description}
|
||||||
variant={variant === "inverse" ? "ask-inverse" : "ask"}
|
variant={variant === "inverse" ? "ask-inverse" : "ask"}
|
||||||
alignment={variant === "left-aligned" ? "left" : "center"}
|
alignment={variant === "left-aligned" ? "left" : "center"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Button */}
|
{/* Button */}
|
||||||
<div className={styles.buttonContainer}>
|
<div className={styles.buttonContainer}>
|
||||||
<Button
|
<Button
|
||||||
href={buttonHref}
|
href={buttonHref}
|
||||||
size="large"
|
size="large"
|
||||||
variant={variant === "inverse" ? "primary" : "default"}
|
variant={variant === "inverse" ? "primary" : "default"}
|
||||||
className="xl:!px-[var(--spacing-scale-020)] xl:!py-[var(--spacing-scale-012)] xl:!text-[24px] xl:!leading-[28px]"
|
className="xl:!px-[var(--spacing-scale-020)] xl:!py-[var(--spacing-scale-012)] xl:!text-[24px] xl:!leading-[28px]"
|
||||||
onClick={handleContactClick}
|
onClick={handleContactClick}
|
||||||
aria-label={`${buttonText} - Contact an organizer for help`}
|
aria-label={`${buttonText} - Contact an organizer for help`}
|
||||||
>
|
>
|
||||||
{buttonText}
|
{buttonText}
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</section>
|
);
|
||||||
);
|
},
|
||||||
};
|
);
|
||||||
|
|
||||||
|
AskOrganizer.displayName = "AskOrganizer";
|
||||||
|
|
||||||
export default AskOrganizer;
|
export default AskOrganizer;
|
||||||
|
|||||||
+18
-16
@@ -1,18 +1,20 @@
|
|||||||
export default function Avatar({
|
import React, { memo } from "react";
|
||||||
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)]",
|
|
||||||
};
|
|
||||||
|
|
||||||
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({
|
import React, { memo } from "react";
|
||||||
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]",
|
|
||||||
};
|
|
||||||
|
|
||||||
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 (
|
const baseStyles = `items-center ${sizeStyles[size]} ${className}`;
|
||||||
<div className={baseStyles} {...props}>
|
|
||||||
{children}
|
return (
|
||||||
</div>
|
<div className={baseStyles} {...props}>
|
||||||
);
|
{children}
|
||||||
}
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
AvatarContainer.displayName = "AvatarContainer";
|
||||||
|
|
||||||
|
export default AvatarContainer;
|
||||||
|
|||||||
+99
-91
@@ -1,108 +1,116 @@
|
|||||||
export default function Button({
|
import React, { memo } from "react";
|
||||||
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 fontStyles = {
|
const Button = memo(
|
||||||
xsmall: "font-inter text-[10px] leading-[12px] font-medium tracking-[0%]",
|
({
|
||||||
small: "font-inter text-[12px] leading-[14px] font-medium tracking-[0%]",
|
children,
|
||||||
medium: "font-inter text-[14px] leading-[16px] font-medium tracking-[0%]",
|
variant = "default",
|
||||||
large: "font-inter text-[16px] leading-[20px] font-medium tracking-[0%]",
|
size = "xsmall",
|
||||||
xlarge: "font-inter text-[24px] leading-[28px] font-normal tracking-[0%]",
|
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 = {
|
const fontStyles = {
|
||||||
default:
|
xsmall: "font-inter text-[10px] leading-[12px] font-medium tracking-[0%]",
|
||||||
"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",
|
small: "font-inter text-[12px] leading-[14px] font-medium tracking-[0%]",
|
||||||
secondary:
|
medium: "font-inter text-[14px] leading-[16px] font-medium tracking-[0%]",
|
||||||
"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",
|
large: "font-inter text-[16px] leading-[20px] font-medium tracking-[0%]",
|
||||||
primary:
|
xlarge: "font-inter text-[24px] leading-[28px] font-normal tracking-[0%]",
|
||||||
"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 hoverOutlineStyles = {
|
const variantStyles = {
|
||||||
xsmall: "hover:outline-1",
|
default:
|
||||||
small: "hover:outline-1",
|
"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",
|
||||||
medium: "hover:outline-1",
|
secondary:
|
||||||
large: "hover:outline-2",
|
"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",
|
||||||
xlarge: "hover:outline-[2.5px]",
|
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 hoverOutlineStyles = {
|
||||||
const outlineStyles =
|
xsmall: "hover:outline-1",
|
||||||
variant === "primary" ||
|
small: "hover:outline-1",
|
||||||
variant === "outlined" ||
|
medium: "hover:outline-1",
|
||||||
variant === "dark" ||
|
large: "hover:outline-2",
|
||||||
variant === "inverse"
|
xlarge: "hover:outline-[2.5px]",
|
||||||
? ""
|
};
|
||||||
: hoverOutlineStyles[size];
|
|
||||||
|
|
||||||
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;
|
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}`;
|
||||||
if (disabled) {
|
|
||||||
finalVariant = "default";
|
|
||||||
}
|
|
||||||
|
|
||||||
const combinedStyles = `${baseStyles} ${className}`;
|
let finalVariant = variant;
|
||||||
|
if (disabled) {
|
||||||
|
finalVariant = "default";
|
||||||
|
}
|
||||||
|
|
||||||
const accessibilityProps = {
|
const combinedStyles = `${baseStyles} ${className}`;
|
||||||
...(ariaLabel && { "aria-label": ariaLabel }),
|
|
||||||
...(disabled && { "aria-disabled": "true" }),
|
const accessibilityProps = {
|
||||||
...(target && { target }),
|
...(ariaLabel && { "aria-label": ariaLabel }),
|
||||||
...(rel && { rel }),
|
...(disabled && { "aria-disabled": "true" }),
|
||||||
tabIndex: disabled ? -1 : 0,
|
...(target && { target }),
|
||||||
...props,
|
...(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 (
|
return (
|
||||||
<a
|
<button
|
||||||
href={href}
|
type={type}
|
||||||
className={combinedStyles}
|
className={combinedStyles}
|
||||||
|
disabled={disabled}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
{...accessibilityProps}
|
{...accessibilityProps}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</a>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
Button.displayName = "Button";
|
||||||
<button
|
|
||||||
type={type}
|
export default Button;
|
||||||
className={combinedStyles}
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={onClick}
|
|
||||||
{...accessibilityProps}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import React, { memo } from "react";
|
||||||
import { getAssetPath } from "../../lib/assetUtils";
|
import { getAssetPath } from "../../lib/assetUtils";
|
||||||
import ContentContainer from "./ContentContainer";
|
import ContentContainer from "./ContentContainer";
|
||||||
|
|
||||||
export default function ContentBanner({ post }) {
|
const ContentBanner = memo(({ post }) => {
|
||||||
// Get article-specific horizontal thumbnail (small) and banner (md+)
|
// Get article-specific horizontal thumbnail (small) and banner (md+)
|
||||||
const getBackgroundImage = (post) => {
|
const getBackgroundImage = (post) => {
|
||||||
if (post.frontmatter?.thumbnail?.horizontal) {
|
if (post.frontmatter?.thumbnail?.horizontal) {
|
||||||
@@ -71,4 +72,8 @@ export default function ContentBanner({ post }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
ContentBanner.displayName = "ContentBanner";
|
||||||
|
|
||||||
|
export default ContentBanner;
|
||||||
|
|||||||
+105
-101
@@ -1,127 +1,131 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React, { memo } from "react";
|
||||||
import { getAssetPath, ASSETS } from "../../lib/assetUtils";
|
import { getAssetPath, ASSETS } from "../../lib/assetUtils";
|
||||||
|
|
||||||
const ContentContainer = ({ post, width = "200px", size = "responsive" }) => {
|
const ContentContainer = memo(
|
||||||
// Get the corresponding icon based on the same logic as background images
|
({ post, width = "200px", size = "responsive" }) => {
|
||||||
const getIconImage = (slug) => {
|
// Get the corresponding icon based on the same logic as background images
|
||||||
const icons = [
|
const getIconImage = (slug) => {
|
||||||
getAssetPath(ASSETS.ICON_1),
|
const icons = [
|
||||||
getAssetPath(ASSETS.ICON_2),
|
getAssetPath(ASSETS.ICON_1),
|
||||||
getAssetPath(ASSETS.ICON_3),
|
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
|
// Use the same cycling logic as background images to ensure matching
|
||||||
const slugOrder = [
|
const slugOrder = [
|
||||||
"building-community-trust",
|
"building-community-trust",
|
||||||
"operational-security-mutual-aid",
|
"operational-security-mutual-aid",
|
||||||
"making-decisions-without-hierarchy",
|
"making-decisions-without-hierarchy",
|
||||||
"resolving-active-conflicts",
|
"resolving-active-conflicts",
|
||||||
];
|
];
|
||||||
const index = slugOrder.indexOf(slug);
|
const index = slugOrder.indexOf(slug);
|
||||||
const finalIndex = index >= 0 ? index % icons.length : 0;
|
const finalIndex = index >= 0 ? index % icons.length : 0;
|
||||||
return icons[finalIndex];
|
return icons[finalIndex];
|
||||||
};
|
};
|
||||||
|
|
||||||
const iconImage = getIconImage(post.slug);
|
const iconImage = getIconImage(post.slug);
|
||||||
|
|
||||||
// Choose styling based on size prop
|
// Choose styling based on size prop
|
||||||
const containerClasses =
|
const containerClasses =
|
||||||
size === "xs"
|
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)]"
|
||||||
: "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)]";
|
: "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 (
|
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 */}
|
|
||||||
<div
|
<div
|
||||||
className={
|
className={`${containerClasses} ${
|
||||||
size === "xs"
|
size === "responsive"
|
||||||
? "flex flex-col gap-[var(--measures-spacing-008)]"
|
? "max-w-[298px] sm:max-w-[479px] lg:max-w-[365px] xl:max-w-[623px]"
|
||||||
: "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)]"
|
: ""
|
||||||
}
|
}`}
|
||||||
|
style={size === "responsive" ? {} : { width }}
|
||||||
>
|
>
|
||||||
{/* Icon */}
|
{/* Content Container - gap between icon and text */}
|
||||||
<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 */}
|
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
size === "xs"
|
size === "xs"
|
||||||
? "flex flex-col gap-[var(--measures-spacing-004)]"
|
? "flex flex-col gap-[var(--measures-spacing-008)]"
|
||||||
: "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)] 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 */}
|
{/* Icon */}
|
||||||
<h3
|
<div className="w-[60px] h-[30px] flex items-center justify-center">
|
||||||
className={
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
size === "xs"
|
<img
|
||||||
? "font-bricolage font-medium text-[18px] leading-[120%] text-[var(--color-content-inverse-brand-royal)] group-hover:text-blue-200 transition-colors"
|
src={iconImage}
|
||||||
: "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"
|
alt={`Icon for ${post.frontmatter.title}`}
|
||||||
}
|
className="w-[60px] h-[30px] object-contain"
|
||||||
>
|
/>
|
||||||
{post.frontmatter.title}
|
</div>
|
||||||
</h3>
|
|
||||||
|
|
||||||
{/* Description */}
|
{/* Text Container */}
|
||||||
<p
|
<div
|
||||||
className={
|
className={
|
||||||
size === "xs"
|
size === "xs"
|
||||||
? "font-inter font-normal text-[12px] leading-[16px] text-[var(--color-content-inverse-brand-royal)] max-w-md"
|
? "flex flex-col gap-[var(--measures-spacing-004)]"
|
||||||
: "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)] md:gap-[var(--measures-spacing-002)] lg:gap-[var(--measures-spacing-004)]"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{post.frontmatter.description}
|
{/* Title */}
|
||||||
</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
{/* Metadata Container - horizontal with 8px gap */}
|
ContentContainer.displayName = "ContentContainer";
|
||||||
<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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ContentContainer;
|
export default ContentContainer;
|
||||||
|
|||||||
+167
-158
@@ -1,178 +1,187 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import React, { memo } from "react";
|
||||||
import Button from "./Button";
|
import Button from "./Button";
|
||||||
import { getAssetPath } from "../../lib/assetUtils";
|
import { getAssetPath } from "../../lib/assetUtils";
|
||||||
|
|
||||||
const ContentLockup = ({
|
const ContentLockup = memo(
|
||||||
title,
|
({
|
||||||
subtitle,
|
title,
|
||||||
description,
|
subtitle,
|
||||||
ctaText,
|
description,
|
||||||
ctaHref,
|
ctaText,
|
||||||
buttonClassName = "",
|
ctaHref,
|
||||||
variant = "hero",
|
buttonClassName = "",
|
||||||
linkText,
|
variant = "hero",
|
||||||
linkHref,
|
linkText,
|
||||||
alignment = "center", // center, left
|
linkHref,
|
||||||
}) => {
|
alignment = "center", // center, left
|
||||||
// Variant-specific styling
|
}) => {
|
||||||
const variantStyles = {
|
// Variant-specific styling
|
||||||
hero: {
|
const variantStyles = {
|
||||||
container:
|
hero: {
|
||||||
"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",
|
container:
|
||||||
textContainer:
|
"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",
|
||||||
"flex flex-col md:gap-[var(--spacing-scale-004)] lg:gap-[var(--spacing-scale-008)] xl:gap-[var(--spacing-scale-020)]",
|
textContainer:
|
||||||
titleGroup: "flex flex-col xl:gap-0",
|
"flex flex-col md:gap-[var(--spacing-scale-004)] lg:gap-[var(--spacing-scale-008)] xl:gap-[var(--spacing-scale-020)]",
|
||||||
titleContainer:
|
titleGroup: "flex flex-col xl:gap-0",
|
||||||
"flex gap-[var(--spacing-scale-008)] xl:gap-[var(--spacing-scale-010)] items-center",
|
titleContainer:
|
||||||
title:
|
"flex gap-[var(--spacing-scale-008)] xl:gap-[var(--spacing-scale-010)] items-center",
|
||||||
"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)]",
|
title:
|
||||||
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)]",
|
||||||
"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:
|
||||||
description:
|
"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)]",
|
||||||
"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)]",
|
description:
|
||||||
shape:
|
"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)]",
|
||||||
"w-[27.2px] h-[27.2px] md:w-[34px] md:h-[34px] lg:w-[50px] lg:h-[50px]",
|
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",
|
feature: {
|
||||||
textContainer: "flex flex-col gap-[var(--spacing-scale-012)]",
|
container: "flex flex-col gap-[var(--spacing-scale-012)] relative z-10",
|
||||||
titleGroup: "flex flex-col gap-[var(--spacing-scale-012)]",
|
textContainer: "flex flex-col gap-[var(--spacing-scale-012)]",
|
||||||
titleContainer: "flex gap-[var(--spacing-scale-008)] items-center",
|
titleGroup: "flex flex-col gap-[var(--spacing-scale-012)]",
|
||||||
title:
|
titleContainer: "flex gap-[var(--spacing-scale-008)] items-center",
|
||||||
"font-bricolage-grotesque font-medium text-[32px] leading-[130%] tracking-[0] text-[var(--color-content-default-primary)]",
|
title:
|
||||||
subtitle:
|
"font-bricolage-grotesque font-medium text-[32px] leading-[130%] tracking-[0] text-[var(--color-content-default-primary)]",
|
||||||
"font-space-grotesk font-normal text-[20px] leading-[130%] tracking-[0] text-[var(--color-content-default-primary)]",
|
subtitle:
|
||||||
description:
|
"font-space-grotesk font-normal text-[20px] leading-[130%] tracking-[0] text-[var(--color-content-default-primary)]",
|
||||||
"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)]",
|
description:
|
||||||
shape:
|
"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)]",
|
||||||
"w-[20px] h-[20px] md:w-[24px] md:h-[24px] lg:w-[28px] lg:h-[28px]",
|
shape:
|
||||||
},
|
"w-[20px] h-[20px] md:w-[24px] md:h-[24px] lg:w-[28px] lg:h-[28px]",
|
||||||
learn: {
|
},
|
||||||
container:
|
learn: {
|
||||||
"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)]",
|
container:
|
||||||
textContainer:
|
"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)]",
|
||||||
"flex flex-col gap-[var(--spacing-scale-012)] md:gap-[var(--spacing-scale-016)]",
|
textContainer:
|
||||||
titleGroup:
|
"flex flex-col gap-[var(--spacing-scale-012)] md:gap-[var(--spacing-scale-016)]",
|
||||||
"flex flex-col gap-[var(--spacing-scale-012)] md:gap-[var(--spacing-scale-016)] lg:gap-[var(--spacing-scale-008)]",
|
titleGroup:
|
||||||
titleContainer: "flex gap-[var(--spacing-scale-008)] items-center",
|
"flex flex-col gap-[var(--spacing-scale-012)] md:gap-[var(--spacing-scale-016)] lg:gap-[var(--spacing-scale-008)]",
|
||||||
title:
|
titleContainer: "flex gap-[var(--spacing-scale-008)] items-center",
|
||||||
"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)]",
|
title:
|
||||||
subtitle:
|
"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)]",
|
||||||
"font-space-grotesk font-normal text-[16px] leading-[24px] tracking-[0] lg:text-[24px] lg:leading-[28px] text-[var(--color-content-default-primary)]",
|
subtitle:
|
||||||
description:
|
"font-space-grotesk font-normal text-[16px] leading-[24px] tracking-[0] lg:text-[24px] lg:leading-[28px] text-[var(--color-content-default-primary)]",
|
||||||
"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)]",
|
description:
|
||||||
shape:
|
"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)]",
|
||||||
"w-[20px] h-[20px] md:w-[24px] md:h-[24px] lg:w-[28px] lg:h-[28px]",
|
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",
|
ask: {
|
||||||
textContainer: "flex flex-col gap-[var(--spacing-scale-008)]",
|
container: "flex flex-col gap-[var(--spacing-scale-008)] relative z-10",
|
||||||
titleGroup: "flex flex-col gap-[var(--spacing-scale-008)]",
|
textContainer: "flex flex-col gap-[var(--spacing-scale-008)]",
|
||||||
titleContainer: "flex gap-[var(--spacing-scale-008)] items-center",
|
titleGroup: "flex flex-col gap-[var(--spacing-scale-008)]",
|
||||||
title:
|
titleContainer: "flex gap-[var(--spacing-scale-008)] items-center",
|
||||||
"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)]",
|
title:
|
||||||
subtitle:
|
"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)]",
|
||||||
"font-inter font-normal text-[18px] leading-[130%] tracking-[0] md:text-[24px] md:leading-[32px] text-[var(--color-content-default-primary)]",
|
subtitle:
|
||||||
shape:
|
"font-inter font-normal text-[18px] leading-[130%] tracking-[0] md:text-[24px] md:leading-[32px] text-[var(--color-content-default-primary)]",
|
||||||
"w-[16px] h-[16px] md:w-[20px] md:h-[20px] lg:w-[24px] lg:h-[24px]",
|
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",
|
"ask-inverse": {
|
||||||
textContainer: "flex flex-col gap-[var(--spacing-scale-008)]",
|
container: "flex flex-col gap-[var(--spacing-scale-008)] relative z-10",
|
||||||
titleGroup: "flex flex-col gap-[var(--spacing-scale-008)]",
|
textContainer: "flex flex-col gap-[var(--spacing-scale-008)]",
|
||||||
titleContainer: "flex gap-[var(--spacing-scale-008)] items-center",
|
titleGroup: "flex flex-col gap-[var(--spacing-scale-008)]",
|
||||||
title:
|
titleContainer: "flex gap-[var(--spacing-scale-008)] items-center",
|
||||||
"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)]",
|
title:
|
||||||
subtitle:
|
"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)]",
|
||||||
"font-inter font-normal text-[18px] leading-[130%] tracking-[0] md:text-[24px] md:leading-[32px] text-[var(--color-content-inverse-primary)]",
|
subtitle:
|
||||||
shape:
|
"font-inter font-normal text-[18px] leading-[130%] tracking-[0] md:text-[24px] md:leading-[32px] text-[var(--color-content-inverse-primary)]",
|
||||||
"w-[16px] h-[16px] md:w-[20px] md:h-[20px] lg:w-[24px] lg:h-[24px]",
|
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 (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
{variant === "ask" || variant === "ask-inverse" ? (
|
{variant === "ask" || variant === "ask-inverse" ? (
|
||||||
/* Simplified structure for ask variant */
|
/* Simplified structure for ask variant */
|
||||||
<div
|
|
||||||
className={`${styles.titleGroup} ${
|
|
||||||
alignment === "left" ? "text-left" : "text-center"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className={`${styles.titleContainer} ${
|
className={`${styles.titleGroup} ${
|
||||||
alignment === "left" ? "justify-start" : "justify-center"
|
alignment === "left" ? "text-left" : "text-center"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<h1 className={styles.title}>{title}</h1>
|
<div
|
||||||
</div>
|
className={`${styles.titleContainer} ${
|
||||||
<h2 className={styles.subtitle}>{subtitle}</h2>
|
alignment === "left" ? "justify-start" : "justify-center"
|
||||||
</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>
|
<h1 className={styles.title}>{title}</h1>
|
||||||
{variant === "hero" && (
|
|
||||||
<img
|
|
||||||
src={getAssetPath("assets/Shapes_1.svg")}
|
|
||||||
alt=""
|
|
||||||
className={styles.shape}
|
|
||||||
role="presentation"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Subtitle */}
|
|
||||||
<h2 className={styles.subtitle}>{subtitle}</h2>
|
<h2 className={styles.subtitle}>{subtitle}</h2>
|
||||||
</div>
|
</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 */}
|
{/* Subtitle */}
|
||||||
{description && <p className={styles.description}>{description}</p>}
|
<h2 className={styles.subtitle}>{subtitle}</h2>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Link for feature variant */}
|
{/* Description */}
|
||||||
{variant === "feature" && linkText && (
|
{description && <p className={styles.description}>{description}</p>}
|
||||||
<a
|
</div>
|
||||||
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 */}
|
{/* Link for feature variant */}
|
||||||
{ctaText && (
|
{variant === "feature" && linkText && (
|
||||||
<div className="flex justify-start">
|
<a
|
||||||
{/* Small button for xsm and sm breakpoints */}
|
href={linkHref || "#"}
|
||||||
<div className="block md:hidden">
|
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"
|
||||||
<Button variant="primary" size="small">
|
>
|
||||||
{ctaText}
|
{linkText}
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
{/* Large button for md and lg breakpoints */}
|
)}
|
||||||
<div className="hidden md:block xl:hidden">
|
</div>
|
||||||
<Button variant="primary" size="large" className={buttonClassName}>
|
);
|
||||||
{ctaText}
|
},
|
||||||
</Button>
|
);
|
||||||
</div>
|
|
||||||
{/* XLarge button for xl breakpoint */}
|
ContentLockup.displayName = "ContentLockup";
|
||||||
<div className="hidden xl:block">
|
|
||||||
<Button variant="primary" size="xlarge">
|
|
||||||
{ctaText}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ContentLockup;
|
export default ContentLockup;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React, { memo } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import ContentContainer from "./ContentContainer";
|
import ContentContainer from "./ContentContainer";
|
||||||
import { getAssetPath, ASSETS } from "../../lib/assetUtils";
|
import { getAssetPath, ASSETS } from "../../lib/assetUtils";
|
||||||
@@ -9,87 +9,91 @@ import { getAssetPath, ASSETS } from "../../lib/assetUtils";
|
|||||||
* ContentThumbnailTemplate component for displaying blog post previews
|
* ContentThumbnailTemplate component for displaying blog post previews
|
||||||
* Simplified version to debug infinite loop
|
* Simplified version to debug infinite loop
|
||||||
*/
|
*/
|
||||||
const ContentThumbnailTemplate = ({
|
const ContentThumbnailTemplate = memo(
|
||||||
post,
|
({
|
||||||
className = "",
|
post,
|
||||||
variant = "vertical", // Internal prop for testing/development
|
className = "",
|
||||||
}) => {
|
variant = "vertical", // Internal prop for testing/development
|
||||||
// Get article-specific background image from frontmatter
|
}) => {
|
||||||
const getBackgroundImage = (post, variant) => {
|
// Get article-specific background image from frontmatter
|
||||||
// Check if post has thumbnail images defined in frontmatter
|
const getBackgroundImage = (post, variant) => {
|
||||||
if (post.frontmatter?.thumbnail) {
|
// Check if post has thumbnail images defined in frontmatter
|
||||||
const imageName =
|
if (post.frontmatter?.thumbnail) {
|
||||||
variant === "vertical"
|
const imageName =
|
||||||
? post.frontmatter.thumbnail.vertical
|
variant === "vertical"
|
||||||
: post.frontmatter.thumbnail.horizontal;
|
? post.frontmatter.thumbnail.vertical
|
||||||
|
: post.frontmatter.thumbnail.horizontal;
|
||||||
|
|
||||||
if (imageName) {
|
if (imageName) {
|
||||||
// Return path to image in public/content/blog directory
|
// Return path to image in public/content/blog directory
|
||||||
return `/content/blog/${imageName}`;
|
return `/content/blog/${imageName}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to default images if no thumbnail specified
|
// Fallback to default images if no thumbnail specified
|
||||||
const fallbackImages = {
|
const fallbackImages = {
|
||||||
vertical: getAssetPath(ASSETS.VERTICAL_1),
|
vertical: getAssetPath(ASSETS.VERTICAL_1),
|
||||||
horizontal: getAssetPath(ASSETS.HORIZONTAL_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 (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/blog/${post.slug}`}
|
href={`/blog/${post.slug}`}
|
||||||
className={`block transition-transform duration-200 hover:scale-[1.02] ${className}`}
|
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]">
|
<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 - fills container with maintained aspect */}
|
{/* Background SVG - sized to fit the 320x225.5 container exactly */}
|
||||||
<div className="absolute inset-0 z-0">
|
<div className="absolute inset-0 z-0">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={backgroundImage}
|
src={backgroundImage}
|
||||||
alt={`Background for ${post.frontmatter.title}`}
|
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 */}
|
{/* Gradient overlay */}
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-black/60 z-10" />
|
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-transparent to-black/70 z-10" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content Section - positioned within the padding constraints */}
|
{/* Content - positioned within the padding constraints */}
|
||||||
<ContentContainer post={post} width="200px" size="xs" />
|
<ContentContainer post={post} width="230px" size="xs" />
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Horizontal variant
|
ContentThumbnailTemplate.displayName = "ContentThumbnailTemplate";
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default 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";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React, { memo, useMemo } from "react";
|
||||||
import ContentLockup from "./ContentLockup";
|
import ContentLockup from "./ContentLockup";
|
||||||
import MiniCard from "./MiniCard";
|
import MiniCard from "./MiniCard";
|
||||||
import Image from "next/image";
|
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 (
|
return (
|
||||||
<section
|
<section
|
||||||
className={`p-0 lg:p-[var(--spacing-scale-064)] ${className}`}
|
className={`p-0 lg:p-[var(--spacing-scale-064)] ${className}`}
|
||||||
@@ -32,43 +70,24 @@ const FeatureGrid = ({ title, subtitle, className = "" }) => {
|
|||||||
role="grid"
|
role="grid"
|
||||||
aria-label="Feature tools and services"
|
aria-label="Feature tools and services"
|
||||||
>
|
>
|
||||||
<MiniCard
|
{features.map((feature, index) => (
|
||||||
backgroundColor="bg-[var(--color-surface-default-brand-royal)]"
|
<MiniCard
|
||||||
labelLine1="Decision-making"
|
key={index}
|
||||||
labelLine2="support"
|
backgroundColor={feature.backgroundColor}
|
||||||
panelContent="assets/Feature_Support.png"
|
labelLine1={feature.labelLine1}
|
||||||
ariaLabel="Decision-making support tools"
|
labelLine2={feature.labelLine2}
|
||||||
href="#decision-making"
|
panelContent={feature.panelContent}
|
||||||
/>
|
ariaLabel={feature.ariaLabel}
|
||||||
<MiniCard
|
href={feature.href}
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
|
FeatureGrid.displayName = "FeatureGrid";
|
||||||
|
|
||||||
export default FeatureGrid;
|
export default FeatureGrid;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import React, { memo } from "react";
|
||||||
import Logo from "./Logo";
|
import Logo from "./Logo";
|
||||||
import Separator from "./Separator";
|
import Separator from "./Separator";
|
||||||
import { getAssetPath, ASSETS } from "../../lib/assetUtils";
|
import { getAssetPath, ASSETS } from "../../lib/assetUtils";
|
||||||
|
|
||||||
export default function Footer() {
|
const Footer = memo(() => {
|
||||||
// Schema markup for organization information
|
// Schema markup for organization information
|
||||||
const schemaData = {
|
const schemaData = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
@@ -155,4 +156,8 @@ export default function Footer() {
|
|||||||
</footer>
|
</footer>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
Footer.displayName = "Footer";
|
||||||
|
|
||||||
|
export default Footer;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import React, { memo } from "react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import Logo from "./Logo";
|
import Logo from "./Logo";
|
||||||
import MenuBar from "./MenuBar";
|
import MenuBar from "./MenuBar";
|
||||||
@@ -38,7 +39,7 @@ export const logoConfig = [
|
|||||||
{ breakpoint: "hidden xl:block", size: "headerXl", showText: true },
|
{ breakpoint: "hidden xl:block", size: "headerXl", showText: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Header() {
|
const Header = memo(() => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
// Schema markup for site navigation
|
// Schema markup for site navigation
|
||||||
@@ -214,4 +215,8 @@ export default function Header() {
|
|||||||
</header>
|
</header>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
Header.displayName = "Header";
|
||||||
|
|
||||||
|
export default Header;
|
||||||
|
|||||||
+38
-36
@@ -1,39 +1,41 @@
|
|||||||
|
import React, { memo } from "react";
|
||||||
import { getAssetPath } from "../../lib/assetUtils";
|
import { getAssetPath } from "../../lib/assetUtils";
|
||||||
|
|
||||||
export default function HeaderTab({
|
const HeaderTab = memo(
|
||||||
children,
|
({ children, className = "", stretch = false, ...props }) => {
|
||||||
className = "",
|
const stretchClasses = stretch
|
||||||
stretch = false,
|
? "flex-1 sm:mr-[var(--spacing-scale-008)] md:mr-[185px] lg:mr-[var(--spacing-scale-024)] xl:mr-[var(--spacing-scale-032)]"
|
||||||
...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 (
|
return (
|
||||||
<div
|
<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}`}
|
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}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<img
|
<img
|
||||||
src={getAssetPath("assets/Union_xsm.svg")}
|
src={getAssetPath("assets/Union_xsm.svg")}
|
||||||
alt=""
|
alt=""
|
||||||
role="presentation"
|
role="presentation"
|
||||||
className="absolute -bottom-[3px] -right-[52px] w-[61px] h-[24px] sm:w-[61px] sm:h-[31.5px] sm:hidden -z-10"
|
className="absolute -bottom-[3px] -right-[52px] w-[61px] h-[24px] sm:w-[61px] sm:h-[31.5px] sm:hidden -z-10"
|
||||||
/>
|
/>
|
||||||
<img
|
<img
|
||||||
src={getAssetPath("assets/Union_sm_md_lg.svg")}
|
src={getAssetPath("assets/Union_sm_md_lg.svg")}
|
||||||
alt=""
|
alt=""
|
||||||
role="presentation"
|
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"
|
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
|
<img
|
||||||
src={getAssetPath("assets/Union_xlg.svg")}
|
src={getAssetPath("assets/Union_xlg.svg")}
|
||||||
alt=""
|
alt=""
|
||||||
role="presentation"
|
role="presentation"
|
||||||
className="absolute -bottom-[6px] -right-[94px] w-[105px] h-[53px] hidden xl:block -z-10"
|
className="absolute -bottom-[6px] -right-[94px] w-[105px] h-[53px] hidden xl:block -z-10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
HeaderTab.displayName = "HeaderTab";
|
||||||
|
|
||||||
|
export default HeaderTab;
|
||||||
|
|||||||
@@ -1,47 +1,54 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import React, { memo } from "react";
|
||||||
import ContentLockup from "./ContentLockup";
|
import ContentLockup from "./ContentLockup";
|
||||||
import HeroDecor from "./HeroDecor";
|
import HeroDecor from "./HeroDecor";
|
||||||
import { getAssetPath } from "../../lib/assetUtils";
|
import { getAssetPath } from "../../lib/assetUtils";
|
||||||
|
|
||||||
const HeroBanner = ({ title, subtitle, description, ctaText, ctaHref }) => {
|
const HeroBanner = memo(
|
||||||
return (
|
({ title, subtitle, description, ctaText, ctaHref }) => {
|
||||||
<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)]">
|
return (
|
||||||
<div className="flex flex-col gap-[var(--spacing-scale-010)]">
|
<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)]">
|
||||||
{/* Frame container for content */}
|
<div className="flex flex-col gap-[var(--spacing-scale-010)]">
|
||||||
<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">
|
{/* Frame container for content */}
|
||||||
{/* DECORATIONS (behind 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">
|
||||||
<HeroDecor
|
{/* DECORATIONS (behind content) */}
|
||||||
className="pointer-events-none absolute z-0
|
<HeroDecor
|
||||||
|
className="pointer-events-none absolute z-0
|
||||||
left-0 top-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]
|
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]"
|
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 */}
|
{/* Content lockup - Large variant */}
|
||||||
<div className="w-full h-full md:flex-1 rounded-[8px] overflow-hidden relative z-10 flex items-center justify-center">
|
<div className="md:flex-1">
|
||||||
<img
|
<ContentLockup
|
||||||
src={getAssetPath("assets/HeroImage.png")}
|
title={title}
|
||||||
alt="Hero illustration"
|
subtitle={subtitle}
|
||||||
className="w-full h-auto"
|
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>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</section>
|
);
|
||||||
);
|
},
|
||||||
};
|
);
|
||||||
|
|
||||||
|
HeroBanner.displayName = "HeroBanner";
|
||||||
|
|
||||||
export default HeroBanner;
|
export default HeroBanner;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
const HeroDecor = ({ className = "" }) => {
|
import React, { memo } from "react";
|
||||||
|
|
||||||
|
const HeroDecor = memo(({ className = "" }) => {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
className={`text-[var(--color-surface-default-brand-lighter-accent)] opacity-50 ${className}`}
|
className={`text-[var(--color-surface-default-brand-lighter-accent)] opacity-50 ${className}`}
|
||||||
@@ -65,6 +67,8 @@ const HeroDecor = ({ className = "" }) => {
|
|||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
|
HeroDecor.displayName = "HeroDecor";
|
||||||
|
|
||||||
export default HeroDecor;
|
export default HeroDecor;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import React, { memo } from "react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import Logo from "./Logo";
|
import Logo from "./Logo";
|
||||||
import MenuBar from "./MenuBar";
|
import MenuBar from "./MenuBar";
|
||||||
@@ -9,7 +10,7 @@ import AvatarContainer from "./AvatarContainer";
|
|||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
import HeaderTab from "./HeaderTab";
|
import HeaderTab from "./HeaderTab";
|
||||||
|
|
||||||
export default function HomeHeader() {
|
const HomeHeader = memo(() => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
// Schema markup for site navigation (home page specific)
|
// Schema markup for site navigation (home page specific)
|
||||||
@@ -33,9 +34,9 @@ export default function HomeHeader() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const avatarImages = [
|
const avatarImages = [
|
||||||
{ src: "assets/Avatar_1.png", alt: "Avatar 1" },
|
{ src: "/assets/Avatar_1.png", alt: "Avatar 1" },
|
||||||
{ src: "assets/Avatar_2.png", alt: "Avatar 2" },
|
{ src: "/assets/Avatar_2.png", alt: "Avatar 2" },
|
||||||
{ src: "assets/Avatar_3.png", alt: "Avatar 3" },
|
{ src: "/assets/Avatar_3.png", alt: "Avatar 3" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const logoConfig = [
|
const logoConfig = [
|
||||||
@@ -241,4 +242,8 @@ export default function HomeHeader() {
|
|||||||
</header>
|
</header>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
HomeHeader.displayName = "HomeHeader";
|
||||||
|
|
||||||
|
export default HomeHeader;
|
||||||
|
|||||||
@@ -1,37 +1,41 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React, { memo } from "react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simple image placeholder component for testing
|
* Simple image placeholder component for testing
|
||||||
* Generates colored backgrounds with text overlays
|
* Generates colored backgrounds with text overlays
|
||||||
*/
|
*/
|
||||||
const ImagePlaceholder = ({
|
const ImagePlaceholder = memo(
|
||||||
width = 260,
|
({
|
||||||
height = 390,
|
width = 260,
|
||||||
text = "Blog Image",
|
height = 390,
|
||||||
color = "blue",
|
text = "Blog Image",
|
||||||
className = "",
|
color = "blue",
|
||||||
}) => {
|
className = "",
|
||||||
const colors = {
|
}) => {
|
||||||
blue: "bg-blue-500",
|
const colors = {
|
||||||
green: "bg-green-500",
|
blue: "bg-blue-500",
|
||||||
purple: "bg-purple-500",
|
green: "bg-green-500",
|
||||||
red: "bg-red-500",
|
purple: "bg-purple-500",
|
||||||
orange: "bg-orange-500",
|
red: "bg-red-500",
|
||||||
teal: "bg-teal-500",
|
orange: "bg-orange-500",
|
||||||
};
|
teal: "bg-teal-500",
|
||||||
|
};
|
||||||
|
|
||||||
const bgColor = colors[color] || colors.blue;
|
const bgColor = colors[color] || colors.blue;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`${bgColor} flex items-center justify-center text-white font-bold text-lg ${className}`}
|
className={`${bgColor} flex items-center justify-center text-white font-bold text-lg ${className}`}
|
||||||
style={{ width: `${width}px`, height: `${height}px` }}
|
style={{ width: `${width}px`, height: `${height}px` }}
|
||||||
>
|
>
|
||||||
{text}
|
{text}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ImagePlaceholder.displayName = "ImagePlaceholder";
|
||||||
|
|
||||||
export default ImagePlaceholder;
|
export default ImagePlaceholder;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import React, { memo } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getAssetPath, ASSETS } from "../../lib/assetUtils";
|
import { getAssetPath, ASSETS } from "../../lib/assetUtils";
|
||||||
|
|
||||||
export default function Logo({ size = "default", showText = true }) {
|
const Logo = memo(({ size = "default", showText = true }) => {
|
||||||
// Size configurations
|
// Size configurations
|
||||||
const sizes = {
|
const sizes = {
|
||||||
default: {
|
default: {
|
||||||
@@ -165,4 +166,8 @@ export default function Logo({ size = "default", showText = true }) {
|
|||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
Logo.displayName = "Logo";
|
||||||
|
|
||||||
|
export default Logo;
|
||||||
|
|||||||
@@ -1,45 +1,45 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import React, { useState, useEffect, memo } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
|
||||||
const LogoWall = ({ logos = [] }) => {
|
const LogoWall = memo(({ logos = [] }) => {
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
// Default logos if none provided - ordered for mobile (3 rows × 2 columns)
|
// Default logos if none provided - ordered for mobile (3 rows × 2 columns)
|
||||||
const defaultLogos = [
|
const defaultLogos = [
|
||||||
{
|
{
|
||||||
src: "assets/Section/Logo_FoodNotBombs.png",
|
src: "/assets/Section/Logo_FoodNotBombs.png",
|
||||||
alt: "Food Not Bombs",
|
alt: "Food Not Bombs",
|
||||||
size: "h-11 lg:h-14 xl:h-[70px]",
|
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)
|
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",
|
alt: "Start COOP",
|
||||||
size: "h-[42px] lg:h-[53px] xl:h-[66px]",
|
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)
|
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",
|
alt: "Metagov",
|
||||||
size: "h-6 lg:h-8 xl:h-[41px]",
|
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)
|
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",
|
alt: "Open Civics",
|
||||||
size: "h-8 lg:h-10 xl:h-[50px]",
|
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
|
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",
|
alt: "Mutual Aid CO",
|
||||||
size: "h-11 lg:h-14 xl:h-[70px]",
|
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
|
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",
|
alt: "CU Boulder",
|
||||||
size: "h-10 lg:h-12 xl:h-[60px]",
|
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)
|
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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
|
LogoWall.displayName = "LogoWall";
|
||||||
|
|
||||||
export default LogoWall;
|
export default LogoWall;
|
||||||
|
|||||||
+31
-28
@@ -1,30 +1,33 @@
|
|||||||
export default function MenuBar({
|
import React, { memo } from "react";
|
||||||
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)]",
|
|
||||||
};
|
|
||||||
|
|
||||||
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 (
|
const baseStyles = `flex items-center ${sizeStyles[size]} ${className}`;
|
||||||
<nav
|
|
||||||
className={baseStyles}
|
return (
|
||||||
role="menubar"
|
<nav
|
||||||
aria-label="Main navigation menu"
|
className={baseStyles}
|
||||||
{...props}
|
role="menubar"
|
||||||
>
|
aria-label="Main navigation menu"
|
||||||
{children}
|
{...props}
|
||||||
</nav>
|
>
|
||||||
);
|
{children}
|
||||||
}
|
</nav>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
MenuBar.displayName = "MenuBar";
|
||||||
|
|
||||||
|
export default MenuBar;
|
||||||
|
|||||||
+151
-143
@@ -1,158 +1,166 @@
|
|||||||
export default function MenuBarItem({
|
import React, { memo } from "react";
|
||||||
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 activeOutlineStyles = {
|
const MenuBarItem = memo(
|
||||||
xsmall:
|
({
|
||||||
"active:outline-1 active:outline-[var(--color-content-default-primary)] focus:outline-1 focus:outline-[var(--color-content-default-primary)]",
|
href = "#",
|
||||||
xsmallUseCases:
|
children,
|
||||||
"active:outline-1 active:outline-[var(--color-content-default-primary)] focus:outline-1 focus:outline-[var(--color-content-default-primary)]",
|
variant = "default",
|
||||||
default:
|
size = "default",
|
||||||
"active:outline-1 active:outline-[var(--color-content-default-brand-primary)] focus:outline-1 focus:outline-[var(--color-content-default-brand-primary)]",
|
className = "",
|
||||||
homeMd:
|
disabled = false,
|
||||||
"active:outline-[1.5px] active:outline-[var(--color-content-default-brand-primary)] focus:outline-[1.5px] focus:outline-[var(--color-content-default-brand-primary)]",
|
isActive = false,
|
||||||
homeUseCases:
|
ariaLabel,
|
||||||
"active:outline-[1.5px] active:outline-[var(--color-content-default-brand-primary)] focus:outline-[1.5px] focus:outline-[var(--color-content-default-brand-primary)]",
|
...props
|
||||||
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)]",
|
const variantStyles = {
|
||||||
largeUseCases:
|
default:
|
||||||
"active:outline-[1.75px] active:outline-[var(--color-content-default-brand-primary)] focus:outline-[1.75px] focus:outline-[var(--color-content-default-brand-primary)]",
|
"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",
|
||||||
homeXlarge:
|
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",
|
||||||
"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 homeOutlineStyles = {
|
const activeOutlineStyles = {
|
||||||
xsmall:
|
xsmall:
|
||||||
"active:outline-1 active:outline-[var(--color-content-default-primary)] focus:outline-1 focus:outline-[var(--color-content-default-primary)]",
|
"active:outline-1 active:outline-[var(--color-content-default-primary)] focus:outline-1 focus:outline-[var(--color-content-default-primary)]",
|
||||||
xsmallUseCases:
|
xsmallUseCases:
|
||||||
"active:outline-1 active:outline-[var(--color-content-default-primary)] focus:outline-1 focus:outline-[var(--color-content-default-primary)]",
|
"active:outline-1 active:outline-[var(--color-content-default-primary)] focus:outline-1 focus:outline-[var(--color-content-default-primary)]",
|
||||||
default:
|
default:
|
||||||
"active:outline-[1.5px] active:outline-[var(--color-content-default-primary)] focus:outline-[1.5px] focus:outline-[var(--color-content-default-primary)]",
|
"active:outline-1 active:outline-[var(--color-content-default-brand-primary)] focus:outline-1 focus:outline-[var(--color-content-default-brand-primary)]",
|
||||||
homeMd:
|
homeMd:
|
||||||
"active:outline-[1.5px] active:outline-[var(--color-content-default-primary)] focus:outline-[1.5px] focus:outline-[var(--color-content-default-primary)]",
|
"active:outline-[1.5px] active:outline-[var(--color-content-default-brand-primary)] focus:outline-[1.5px] focus:outline-[var(--color-content-default-brand-primary)]",
|
||||||
homeUseCases:
|
homeUseCases:
|
||||||
"active:outline-[1.5px] active:outline-[var(--color-content-default-primary)] focus:outline-[1.5px] focus:outline-[var(--color-content-default-primary)]",
|
"active:outline-[1.5px] active:outline-[var(--color-content-default-brand-primary)] focus:outline-[1.5px] focus:outline-[var(--color-content-default-brand-primary)]",
|
||||||
largeUseCases:
|
large:
|
||||||
"active:outline-[1.75px] active:outline-[var(--color-content-default-primary)] focus:outline-[1.75px] focus:outline-[var(--color-content-default-primary)]",
|
"active:outline-[1.75px] active:outline-[var(--color-content-default-brand-primary)] focus:outline-[1.75px] focus:outline-[var(--color-content-default-brand-primary)]",
|
||||||
large:
|
largeUseCases:
|
||||||
"active:outline-[1.75px] active:outline-[var(--color-content-default-primary)] focus:outline-[1.75px] focus:outline-[var(--color-content-default-primary)]",
|
"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:
|
homeXlarge:
|
||||||
"active:outline-[2px] active:outline-[var(--color-content-default-primary)] focus:outline-[2px] focus:outline-[var(--color-content-default-primary)]",
|
"active:outline-[2px] active:outline-[var(--color-content-default-brand-primary)] focus:outline-[2px] focus:outline-[var(--color-content-default-brand-primary)]",
|
||||||
xlarge:
|
xlarge:
|
||||||
"active:outline-2 active:outline-[var(--color-content-default-primary)] focus:outline-2 focus:outline-[var(--color-content-default-primary)]",
|
"active:outline-2 active:outline-[var(--color-content-default-brand-primary)] focus:outline-2 focus:outline-[var(--color-content-default-brand-primary)]",
|
||||||
};
|
};
|
||||||
|
|
||||||
const activeStateStyles = {
|
const homeOutlineStyles = {
|
||||||
xsmall:
|
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)]",
|
"active:outline-1 active:outline-[var(--color-content-default-primary)] focus:outline-1 focus:outline-[var(--color-content-default-primary)]",
|
||||||
xsmallUseCases:
|
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)]",
|
"active:outline-1 active:outline-[var(--color-content-default-primary)] focus:outline-1 focus:outline-[var(--color-content-default-primary)]",
|
||||||
default:
|
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)]",
|
"active:outline-[1.5px] active:outline-[var(--color-content-default-primary)] focus:outline-[1.5px] focus:outline-[var(--color-content-default-primary)]",
|
||||||
homeMd:
|
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)]",
|
"active:outline-[1.5px] active:outline-[var(--color-content-default-primary)] focus:outline-[1.5px] focus:outline-[var(--color-content-default-primary)]",
|
||||||
homeUseCases:
|
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)]",
|
"active:outline-[1.5px] active:outline-[var(--color-content-default-primary)] focus:outline-[1.5px] focus:outline-[var(--color-content-default-primary)]",
|
||||||
large:
|
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)]",
|
"active:outline-[1.75px] active:outline-[var(--color-content-default-primary)] focus:outline-[1.75px] focus:outline-[var(--color-content-default-primary)]",
|
||||||
largeUseCases:
|
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)]",
|
"active:outline-[1.75px] active:outline-[var(--color-content-default-primary)] focus:outline-[1.75px] focus:outline-[var(--color-content-default-primary)]",
|
||||||
homeXlarge:
|
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)]",
|
"active:outline-[2px] active:outline-[var(--color-content-default-primary)] focus:outline-[2px] focus:outline-[var(--color-content-default-primary)]",
|
||||||
xlarge:
|
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)]",
|
"active:outline-2 active:outline-[var(--color-content-default-primary)] focus:outline-2 focus:outline-[var(--color-content-default-primary)]",
|
||||||
};
|
};
|
||||||
|
|
||||||
const sizeStyles = {
|
const activeStateStyles = {
|
||||||
default:
|
xsmall:
|
||||||
"px-[var(--spacing-measures-spacing-016)] py-[var(--spacing-measures-spacing-016)] gap-[var(--spacing-scale-004)]",
|
"!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)]",
|
||||||
xsmall:
|
xsmallUseCases:
|
||||||
"px-[var(--spacing-scale-004)] py-[var(--spacing-scale-002)] gap-[var(--spacing-scale-004)]",
|
"!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:
|
default:
|
||||||
"px-[var(--spacing-scale-002)] py-[var(--spacing-scale-002)] gap-[var(--spacing-scale-004)]",
|
"!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:
|
homeMd:
|
||||||
"px-[var(--spacing-scale-008)] py-[var(--spacing-scale-008)] gap-[var(--spacing-scale-004)]",
|
"!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:
|
homeUseCases:
|
||||||
"px-[var(--spacing-scale-002)] py-[var(--spacing-scale-008)] gap-[var(--spacing-scale-004)]",
|
"!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:
|
large:
|
||||||
"px-[var(--spacing-scale-012)] py-[var(--spacing-scale-012)] gap-[var(--spacing-scale-004)] h-[44px]",
|
"!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:
|
largeUseCases:
|
||||||
"px-[var(--spacing-scale-012)] py-[var(--spacing-scale-012)] gap-[var(--spacing-scale-004)] h-[44px]",
|
"!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:
|
homeXlarge:
|
||||||
"px-[var(--spacing-scale-016)] py-[var(--spacing-scale-016)] gap-[var(--spacing-scale-004)] h-[44px]",
|
"!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:
|
xlarge:
|
||||||
"px-[var(--spacing-scale-016)] py-[var(--spacing-scale-008)] gap-[var(--spacing-scale-004)] h-[44px]",
|
"!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 =
|
const sizeStyles = {
|
||||||
"font-inter text-[10px] leading-[12px] font-medium tracking-[0%]";
|
default:
|
||||||
const mediumTextStyle =
|
"px-[var(--spacing-measures-spacing-016)] py-[var(--spacing-measures-spacing-016)] gap-[var(--spacing-scale-004)]",
|
||||||
"font-inter text-[12px] leading-[14px] font-medium tracking-[0%]";
|
xsmall:
|
||||||
const largeTextStyle =
|
"px-[var(--spacing-scale-004)] py-[var(--spacing-scale-002)] gap-[var(--spacing-scale-004)]",
|
||||||
"font-inter text-[16px] leading-[20px] font-medium tracking-[0%]";
|
xsmallUseCases:
|
||||||
const xlargeTextStyle =
|
"px-[var(--spacing-scale-002)] py-[var(--spacing-scale-002)] gap-[var(--spacing-scale-004)]",
|
||||||
"font-inter text-[24px] leading-[28px] font-normal tracking-[0%]";
|
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 = {
|
const smallTextStyle =
|
||||||
default: smallTextStyle,
|
"font-inter text-[10px] leading-[12px] font-medium tracking-[0%]";
|
||||||
xsmall: smallTextStyle,
|
const mediumTextStyle =
|
||||||
xsmallUseCases: smallTextStyle,
|
"font-inter text-[12px] leading-[14px] font-medium tracking-[0%]";
|
||||||
home: smallTextStyle,
|
const largeTextStyle =
|
||||||
homeMd: mediumTextStyle,
|
"font-inter text-[16px] leading-[20px] font-medium tracking-[0%]";
|
||||||
homeUseCases: mediumTextStyle,
|
const xlargeTextStyle =
|
||||||
large: largeTextStyle,
|
"font-inter text-[24px] leading-[28px] font-normal tracking-[0%]";
|
||||||
largeUseCases: largeTextStyle,
|
|
||||||
homeXlarge: xlargeTextStyle,
|
|
||||||
xlarge: xlargeTextStyle,
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
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]`;
|
||||||
if (disabled) {
|
|
||||||
finalVariant = "default";
|
|
||||||
}
|
|
||||||
|
|
||||||
const combinedStyles = `${baseStyles} ${variantStyles[finalVariant]} ${
|
let finalVariant = variant;
|
||||||
finalVariant === "home"
|
if (disabled) {
|
||||||
? homeOutlineStyles[size]
|
finalVariant = "default";
|
||||||
: activeOutlineStyles[size]
|
}
|
||||||
} ${isActive ? activeStateStyles[size] : ""} ${className}`;
|
|
||||||
|
|
||||||
const accessibilityProps = {
|
const combinedStyles = `${baseStyles} ${variantStyles[finalVariant]} ${
|
||||||
...(ariaLabel && { "aria-label": ariaLabel }),
|
finalVariant === "home"
|
||||||
...(disabled && { "aria-disabled": "true" }),
|
? homeOutlineStyles[size]
|
||||||
role: "menuitem",
|
: activeOutlineStyles[size]
|
||||||
tabIndex: disabled ? -1 : 0,
|
} ${isActive ? activeStateStyles[size] : ""} ${className}`;
|
||||||
...props,
|
|
||||||
};
|
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 (
|
return (
|
||||||
<span className={combinedStyles} {...accessibilityProps}>
|
<a href={href} className={combinedStyles} {...accessibilityProps}>
|
||||||
{children}
|
{children}
|
||||||
</span>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
MenuBarItem.displayName = "MenuBarItem";
|
||||||
<a href={href} className={combinedStyles} {...accessibilityProps}>
|
|
||||||
{children}
|
export default MenuBarItem;
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
+109
-97
@@ -1,112 +1,124 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React, { memo } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
|
||||||
const MiniCard = ({
|
const MiniCard = memo(
|
||||||
children,
|
({
|
||||||
className = "",
|
children,
|
||||||
backgroundColor = "bg-[var(--color-surface-default-brand-royal)]",
|
className = "",
|
||||||
panelContent,
|
backgroundColor = "bg-[var(--color-surface-default-brand-royal)]",
|
||||||
label,
|
panelContent,
|
||||||
labelLine1,
|
label,
|
||||||
labelLine2,
|
labelLine1,
|
||||||
onClick,
|
labelLine2,
|
||||||
href,
|
onClick,
|
||||||
ariaLabel,
|
href,
|
||||||
}) => {
|
ariaLabel,
|
||||||
const cardContent = (
|
}) => {
|
||||||
<div className={`h-[186px] flex flex-col gap-[7px] ${className}`}>
|
const cardContent = (
|
||||||
{/* Top part - Inner panel */}
|
<div className={`h-[186px] flex flex-col gap-[7px] ${className}`}>
|
||||||
<div
|
{/* Top part - Inner panel */}
|
||||||
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`}
|
<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 && (
|
{/* Content for the inner panel */}
|
||||||
<div className="flex items-center justify-center w-full h-full">
|
{panelContent && (
|
||||||
<Image
|
<div className="flex items-center justify-center w-full h-full">
|
||||||
src={panelContent}
|
<Image
|
||||||
alt={
|
src={panelContent}
|
||||||
ariaLabel ||
|
alt={
|
||||||
`${labelLine1} ${labelLine2}` ||
|
ariaLabel ||
|
||||||
label ||
|
`${labelLine1} ${labelLine2}` ||
|
||||||
"Feature icon"
|
label ||
|
||||||
}
|
"Feature icon"
|
||||||
className="max-w-[58px] max-h-[58px] w-auto h-auto object-contain"
|
}
|
||||||
unoptimized
|
className="max-w-[58px] max-h-[58px] w-auto h-auto object-contain"
|
||||||
width={0}
|
width={58}
|
||||||
height={0}
|
height={58}
|
||||||
sizes="100vw"
|
sizes="(max-width: 768px) 50vw, 25vw"
|
||||||
/>
|
loading="lazy"
|
||||||
</div>
|
placeholder="blur"
|
||||||
)}
|
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k="
|
||||||
{children}
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Bottom part - Text container */}
|
{/* Bottom part - Text container */}
|
||||||
<div className="font-inter font-medium text-[12px] leading-[14px] text-center text-[var(--color-content-default-primary)]">
|
<div className="font-inter font-medium text-[12px] leading-[14px] text-center text-[var(--color-content-default-primary)]">
|
||||||
{labelLine1 && labelLine2 ? (
|
{labelLine1 && labelLine2 ? (
|
||||||
<>
|
<>
|
||||||
<div>{labelLine1}</div>
|
<div>{labelLine1}</div>
|
||||||
<div>{labelLine2}</div>
|
<div>{labelLine2}</div>
|
||||||
<div> </div>
|
<div> </div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
label
|
label
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</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 href is provided, render as a link
|
||||||
if (onClick) {
|
if (href) {
|
||||||
return (
|
return (
|
||||||
<button
|
<a
|
||||||
onClick={onClick}
|
href={href}
|
||||||
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]"
|
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={
|
aria-label={
|
||||||
ariaLabel || `${labelLine1} ${labelLine2}` || label || "Feature card"
|
ariaLabel ||
|
||||||
}
|
`${labelLine1} ${labelLine2}` ||
|
||||||
tabIndex={0}
|
label ||
|
||||||
onKeyDown={(e) => {
|
"Feature card"
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
onClick();
|
|
||||||
}
|
}
|
||||||
}}
|
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}
|
{cardContent}
|
||||||
</button>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Default render as a div
|
MiniCard.displayName = "MiniCard";
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="block"
|
|
||||||
aria-label={
|
|
||||||
ariaLabel || `${labelLine1} ${labelLine2}` || label || "Feature card"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{cardContent}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MiniCard;
|
export default MiniCard;
|
||||||
|
|||||||
@@ -1,53 +1,62 @@
|
|||||||
export default function NavigationItem({
|
import React, { memo } from "react";
|
||||||
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",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Size styles
|
const NavigationItem = memo(
|
||||||
const sizeStyles = {
|
({
|
||||||
default:
|
href = "#",
|
||||||
"px-[var(--spacing-scale-016)] py-[var(--spacing-scale-016)] gap-[var(--spacing-scale-004)]",
|
children,
|
||||||
xsmall:
|
variant = "default",
|
||||||
"px-[var(--spacing-scale-004)] py-[var(--spacing-scale-002)] gap-[var(--spacing-scale-004)]",
|
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
|
// Size styles
|
||||||
const textStyles = {
|
const sizeStyles = {
|
||||||
default: "font-inter text-[10px] leading-[12px] font-medium tracking-[0%]",
|
default:
|
||||||
xsmall: "font-inter text-[10px] leading-[12px] font-medium tracking-[0%]",
|
"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
|
const baseStyles = `inline-flex items-center ${sizeStyles[size]} rounded-[var(--radius-measures-radius-full)] ${textStyles[size]} transition-all duration-200 cursor-pointer`;
|
||||||
let finalVariant = variant;
|
|
||||||
if (disabled) {
|
|
||||||
finalVariant = "default"; // The disabled state is handled by disabled: utilities
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<span className={combinedStyles} {...props}>
|
<a href={href} className={combinedStyles} {...props}>
|
||||||
{children}
|
{children}
|
||||||
</span>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
NavigationItem.displayName = "NavigationItem";
|
||||||
<a href={href} className={combinedStyles} {...props}>
|
|
||||||
{children}
|
export default NavigationItem;
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import React, { memo } from "react";
|
||||||
import SectionNumber from "./SectionNumber";
|
import SectionNumber from "./SectionNumber";
|
||||||
|
|
||||||
const NumberedCard = ({ number, text, iconShape, iconColor }) => {
|
const NumberedCard = memo(({ number, text, iconShape, iconColor }) => {
|
||||||
return (
|
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]">
|
<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) */}
|
{/* Section Number - Top right (lg breakpoint) */}
|
||||||
@@ -18,6 +19,8 @@ const NumberedCard = ({ number, text, iconShape, iconColor }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
|
NumberedCard.displayName = "NumberedCard";
|
||||||
|
|
||||||
export default NumberedCard;
|
export default NumberedCard;
|
||||||
|
|||||||
@@ -1,23 +1,27 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import React, { memo, useMemo } from "react";
|
||||||
import NumberedCard from "./NumberedCard";
|
import NumberedCard from "./NumberedCard";
|
||||||
import SectionHeader from "./SectionHeader";
|
import SectionHeader from "./SectionHeader";
|
||||||
import Button from "./Button";
|
import Button from "./Button";
|
||||||
|
|
||||||
const NumberedCards = ({ title, subtitle, cards }) => {
|
const NumberedCards = memo(({ title, subtitle, cards }) => {
|
||||||
// Schema markup for SEO
|
// Memoize schema data to prevent unnecessary re-computations
|
||||||
const schemaData = {
|
const schemaData = useMemo(
|
||||||
"@context": "https://schema.org",
|
() => ({
|
||||||
"@type": "HowTo",
|
"@context": "https://schema.org",
|
||||||
name: title,
|
"@type": "HowTo",
|
||||||
description: subtitle,
|
name: title,
|
||||||
step: cards.map((card, index) => ({
|
description: subtitle,
|
||||||
"@type": "HowToStep",
|
step: cards.map((card, index) => ({
|
||||||
position: index + 1,
|
"@type": "HowToStep",
|
||||||
name: card.text,
|
position: index + 1,
|
||||||
text: card.text,
|
name: card.text,
|
||||||
})),
|
text: card.text,
|
||||||
};
|
})),
|
||||||
|
}),
|
||||||
|
[title, subtitle, cards],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -70,6 +74,8 @@ const NumberedCards = ({ title, subtitle, cards }) => {
|
|||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
|
NumberedCards.displayName = "NumberedCards";
|
||||||
|
|
||||||
export default NumberedCards;
|
export default NumberedCards;
|
||||||
|
|||||||
+224
-219
@@ -1,247 +1,252 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useState, memo } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import QuoteDecor from "./QuoteDecor";
|
import QuoteDecor from "./QuoteDecor";
|
||||||
|
|
||||||
const QuoteBlock = ({
|
const QuoteBlock = memo(
|
||||||
variant = "standard",
|
({
|
||||||
className = "",
|
variant = "standard",
|
||||||
quote = "The rules of decision-making must be open and available to everyone, and this can happen only if they are formalized.",
|
className = "",
|
||||||
author = "Jo Freeman",
|
quote = "The rules of decision-making must be open and available to everyone, and this can happen only if they are formalized.",
|
||||||
source = "The Tyranny of Structurelessness",
|
author = "Jo Freeman",
|
||||||
avatarSrc = "assets/Quote_Avatar.svg",
|
source = "The Tyranny of Structurelessness",
|
||||||
id,
|
avatarSrc = "/assets/Quote_Avatar.svg",
|
||||||
fallbackAvatarSrc = "assets/Quote_Avatar.svg", // Fallback avatar
|
id,
|
||||||
onError, // Error callback
|
fallbackAvatarSrc = "/assets/Quote_Avatar.svg", // Fallback avatar
|
||||||
}) => {
|
onError, // Error callback
|
||||||
const [imageError, setImageError] = useState(false);
|
}) => {
|
||||||
const [imageLoading, setImageLoading] = useState(true);
|
const [imageError, setImageError] = useState(false);
|
||||||
|
const [imageLoading, setImageLoading] = useState(true);
|
||||||
|
|
||||||
// Variant configurations
|
// Variant configurations
|
||||||
const variants = {
|
const variants = {
|
||||||
compact: {
|
compact: {
|
||||||
container: "py-[var(--spacing-scale-032)] px-[var(--spacing-scale-016)]",
|
container:
|
||||||
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)]",
|
"py-[var(--spacing-scale-032)] px-[var(--spacing-scale-016)]",
|
||||||
gap: "gap-[var(--spacing-scale-016)] md:gap-[var(--spacing-scale-024)]",
|
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)]",
|
||||||
avatarGap: "gap-[var(--spacing-scale-012)]",
|
gap: "gap-[var(--spacing-scale-016)] md:gap-[var(--spacing-scale-024)]",
|
||||||
avatar: "w-[48px] h-[48px] md:w-[64px] md:h-[64px]",
|
avatarGap: "gap-[var(--spacing-scale-012)]",
|
||||||
quote: "text-[16px] leading-[120%] md:text-[20px] md:leading-[110%]",
|
avatar: "w-[48px] h-[48px] md:w-[64px] md:h-[64px]",
|
||||||
author: "text-[10px] leading-[120%] md:text-[12px]",
|
quote: "text-[16px] leading-[120%] md:text-[20px] md:leading-[110%]",
|
||||||
source: "text-[10px] leading-[120%] md:text-[12px]",
|
author: "text-[10px] leading-[120%] md:text-[12px]",
|
||||||
showDecor: false,
|
source: "text-[10px] leading-[120%] md:text-[12px]",
|
||||||
},
|
showDecor: false,
|
||||||
standard: {
|
},
|
||||||
container:
|
standard: {
|
||||||
"md:py-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-016)] lg:p-[var(--spacing-scale-064)]",
|
container:
|
||||||
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]",
|
"md:py-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-016)] lg:p-[var(--spacing-scale-064)]",
|
||||||
gap: "gap-[var(--spacing-scale-024)] md:gap-[var(--spacing-scale-048)] lg:gap-[var(--spacing-scale-064)] xl:gap-[105px]",
|
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]",
|
||||||
avatarGap:
|
gap: "gap-[var(--spacing-scale-024)] md:gap-[var(--spacing-scale-048)] lg:gap-[var(--spacing-scale-064)] xl:gap-[105px]",
|
||||||
"gap-[var(--spacing-scale-020)] lg:gap-[var(--spacing-scale-018)] xl:gap-[var(--spacing-scale-032)]",
|
avatarGap:
|
||||||
avatar:
|
"gap-[var(--spacing-scale-020)] lg:gap-[var(--spacing-scale-018)] xl:gap-[var(--spacing-scale-032)]",
|
||||||
"md:w-[120px] md:h-[120px] lg:w-[150px] lg:h-[150px] xl:w-[200px] xl:h-[200px]",
|
avatar:
|
||||||
quote:
|
"md:w-[120px] md:h-[120px] lg:w-[150px] lg:h-[150px] xl:w-[200px] xl:h-[200px]",
|
||||||
"text-[18px] leading-[120%] md:text-[36px] md:leading-[110%] md:tracking-[0px] lg:text-[52px] xl:text-[64px]",
|
quote:
|
||||||
author:
|
"text-[18px] leading-[120%] md:text-[36px] md:leading-[110%] md:tracking-[0px] lg:text-[52px] xl:text-[64px]",
|
||||||
"text-[12px] leading-[120%] md:text-[18px] md:leading-[120%] md:tracking-[0.24px] lg:text-[24px] xl:text-[32px]",
|
author:
|
||||||
source:
|
"text-[12px] leading-[120%] md:text-[18px] md:leading-[120%] md:tracking-[0.24px] lg:text-[24px] xl:text-[32px]",
|
||||||
"text-[12px] leading-[120%] md:text-[18px] md:leading-[120%] md:tracking-[0.24px] lg:text-[24px] xl:text-[32px]",
|
source:
|
||||||
showDecor: true,
|
"text-[12px] leading-[120%] md:text-[18px] md:leading-[120%] md:tracking-[0.24px] lg:text-[24px] xl:text-[32px]",
|
||||||
},
|
showDecor: true,
|
||||||
extended: {
|
},
|
||||||
container:
|
extended: {
|
||||||
"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)]",
|
container:
|
||||||
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]",
|
"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)]",
|
||||||
gap: "gap-[var(--spacing-scale-032)] md:gap-[var(--spacing-scale-064)] lg:gap-[var(--spacing-scale-080)] xl:gap-[140px]",
|
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]",
|
||||||
avatarGap:
|
gap: "gap-[var(--spacing-scale-032)] md:gap-[var(--spacing-scale-064)] lg:gap-[var(--spacing-scale-080)] xl:gap-[140px]",
|
||||||
"gap-[var(--spacing-scale-032)] lg:gap-[var(--spacing-scale-040)] xl:gap-[var(--spacing-scale-048)]",
|
avatarGap:
|
||||||
avatar:
|
"gap-[var(--spacing-scale-032)] lg:gap-[var(--spacing-scale-040)] xl:gap-[var(--spacing-scale-048)]",
|
||||||
"w-[80px] h-[80px] md:w-[140px] md:h-[140px] lg:w-[180px] lg:h-[180px] xl:w-[240px] xl:h-[240px]",
|
avatar:
|
||||||
quote:
|
"w-[80px] h-[80px] md:w-[140px] md:h-[140px] lg:w-[180px] lg:h-[180px] xl:w-[240px] xl:h-[240px]",
|
||||||
"text-[20px] leading-[120%] md:text-[40px] md:leading-[110%] md:tracking-[0px] lg:text-[60px] xl:text-[72px]",
|
quote:
|
||||||
author:
|
"text-[20px] leading-[120%] md:text-[40px] md:leading-[110%] md:tracking-[0px] lg:text-[60px] xl:text-[72px]",
|
||||||
"text-[14px] leading-[120%] md:text-[20px] md:leading-[120%] md:tracking-[0.24px] lg:text-[28px] xl:text-[36px]",
|
author:
|
||||||
source:
|
"text-[14px] leading-[120%] md:text-[20px] md:leading-[120%] md:tracking-[0.24px] lg:text-[28px] xl:text-[36px]",
|
||||||
"text-[14px] leading-[120%] md:text-[20px] md:leading-[120%] md:tracking-[0.24px] lg:text-[28px] xl:text-[36px]",
|
source:
|
||||||
showDecor: true,
|
"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
|
// Use provided ID or generate a stable one based on content
|
||||||
const baseId = id || `quote-${author.toLowerCase().replace(/\s+/g, "-")}`;
|
const baseId = id || `quote-${author.toLowerCase().replace(/\s+/g, "-")}`;
|
||||||
const quoteId = `${baseId}-content`;
|
const quoteId = `${baseId}-content`;
|
||||||
const authorId = `${baseId}-author`;
|
const authorId = `${baseId}-author`;
|
||||||
|
|
||||||
// Error handling functions
|
// Error handling functions
|
||||||
const handleImageError = (error) => {
|
const handleImageError = (error) => {
|
||||||
console.warn(
|
console.warn(
|
||||||
`QuoteBlock: Failed to load avatar image for ${author}:`,
|
`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,
|
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 = () => {
|
// Determine which avatar to use
|
||||||
setImageLoading(false);
|
const currentAvatarSrc = imageError ? fallbackAvatarSrc : avatarSrc;
|
||||||
setImageError(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Validate required props
|
return (
|
||||||
if (!quote || !author) {
|
<section
|
||||||
console.error("QuoteBlock: Missing required props (quote or author)");
|
className={`${config.container} ${className}`}
|
||||||
if (onError) {
|
aria-labelledby={quoteId}
|
||||||
onError({
|
role="region"
|
||||||
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`}
|
|
||||||
>
|
>
|
||||||
{/* Background with noise texture */}
|
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-[var(--color-surface-default-brand-darker-accent)]"
|
className={`${config.card} bg-[var(--color-surface-default-brand-darker-accent)] relative overflow-hidden`}
|
||||||
style={{
|
>
|
||||||
filter:
|
{/* Background with noise texture */}
|
||||||
'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\')',
|
<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) */}
|
{/* DECORATIONS (behind content) */}
|
||||||
{config.showDecor && (
|
{config.showDecor && (
|
||||||
<QuoteDecor
|
<QuoteDecor
|
||||||
className="pointer-events-none absolute z-0
|
className="pointer-events-none absolute z-0
|
||||||
left-0 top-0
|
left-0 top-0
|
||||||
w-full h-full"
|
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.gap} relative z-10`}>
|
||||||
<div className={`flex flex-col ${config.avatarGap}`}>
|
<div className={`flex flex-col ${config.avatarGap}`}>
|
||||||
{/* Avatar with error handling */}
|
{/* Avatar with error handling */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{!imageError ? (
|
{!imageError ? (
|
||||||
<Image
|
<Image
|
||||||
src={avatarSrc}
|
src={avatarSrc}
|
||||||
alt={`Portrait of ${author}`}
|
alt={`Portrait of ${author}`}
|
||||||
width={64}
|
width={64}
|
||||||
height={64}
|
height={64}
|
||||||
className={`filter sepia ${
|
className={`filter sepia ${
|
||||||
config.avatar
|
config.avatar
|
||||||
} transition-opacity duration-300 ${
|
} transition-opacity duration-300 ${
|
||||||
imageLoading ? "opacity-0" : "opacity-100"
|
imageLoading ? "opacity-0" : "opacity-100"
|
||||||
}`}
|
}`}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
onError={handleImageError}
|
onError={handleImageError}
|
||||||
onLoad={handleImageLoad}
|
onLoad={handleImageLoad}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Loading state */}
|
{/* Loading state */}
|
||||||
{imageLoading && !imageError && (
|
{imageLoading && !imageError && (
|
||||||
<div
|
<div
|
||||||
className={`absolute inset-0 bg-gray-200 animate-pulse rounded-full ${config.avatar}`}
|
className={`absolute inset-0 bg-gray-200 animate-pulse rounded-full ${config.avatar}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error state - show initials */}
|
{/* Error state - show initials */}
|
||||||
{imageError && (
|
{imageError && (
|
||||||
<div
|
<div
|
||||||
className={`flex items-center justify-center bg-gray-300 rounded-full ${config.avatar} text-gray-600 font-bold`}
|
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">
|
{quote}
|
||||||
{author
|
</p>
|
||||||
.split(" ")
|
</blockquote>
|
||||||
.map((n) => n[0])
|
|
||||||
.join("")
|
|
||||||
.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<footer className="flex flex-col gap-[var(--spacing-scale-008)] md:gap-[var(--spacing-scale-012)] xl:gap-[var(--spacing-scale-020)]">
|
||||||
<blockquote
|
<cite
|
||||||
id={quoteId}
|
id={authorId}
|
||||||
aria-labelledby={authorId}
|
className={`font-inter font-normal ${config.author} text-[var(--color-content-inverse-primary)] uppercase not-italic`}
|
||||||
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(" ")}
|
|
||||||
>
|
>
|
||||||
{quote}
|
{author}
|
||||||
</p>
|
</cite>
|
||||||
</blockquote>
|
{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>
|
||||||
<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>
|
||||||
</div>
|
</section>
|
||||||
</section>
|
);
|
||||||
);
|
},
|
||||||
};
|
);
|
||||||
|
|
||||||
|
QuoteBlock.displayName = "QuoteBlock";
|
||||||
|
|
||||||
export default QuoteBlock;
|
export default QuoteBlock;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
const QuoteDecor = ({ className = "" }) => {
|
import React, { memo } from "react";
|
||||||
|
|
||||||
|
const QuoteDecor = memo(({ className = "" }) => {
|
||||||
return (
|
return (
|
||||||
<svg
|
<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}`}
|
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>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
|
QuoteDecor.displayName = "QuoteDecor";
|
||||||
|
|
||||||
export default QuoteDecor;
|
export default QuoteDecor;
|
||||||
|
|||||||
+141
-130
@@ -1,152 +1,163 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import React, { useState, useEffect, memo, useMemo, useCallback } from "react";
|
||||||
import ContentThumbnailTemplate from "./ContentThumbnailTemplate";
|
import ContentThumbnailTemplate from "./ContentThumbnailTemplate";
|
||||||
|
|
||||||
export default function RelatedArticles({
|
const RelatedArticles = memo(
|
||||||
relatedPosts,
|
({ relatedPosts, currentPostSlug, slugOrder = [] }) => {
|
||||||
currentPostSlug,
|
// Memoize filtered posts to prevent unnecessary re-computations
|
||||||
slugOrder = [],
|
const filteredPosts = useMemo(
|
||||||
}) {
|
() => relatedPosts.filter((post) => post.slug !== currentPostSlug),
|
||||||
// Filter out the current post from related posts
|
[relatedPosts, currentPostSlug],
|
||||||
const filteredPosts = relatedPosts.filter(
|
);
|
||||||
(post) => post.slug !== currentPostSlug,
|
|
||||||
);
|
|
||||||
|
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
const [isMobile, setIsMobile] = useState(true);
|
const [isMobile, setIsMobile] = useState(true);
|
||||||
|
|
||||||
// Check if we're on mobile (below lg breakpoint)
|
// Memoize the mouse down handler to prevent unnecessary re-renders
|
||||||
useEffect(() => {
|
const handleMouseDown = useCallback((e) => {
|
||||||
const checkScreenSize = () => {
|
const slider = e.currentTarget;
|
||||||
setIsMobile(window.innerWidth < 1024); // lg breakpoint is 1024px
|
const startX = e.pageX - slider.offsetLeft;
|
||||||
};
|
const scrollLeft = slider.scrollLeft;
|
||||||
|
|
||||||
checkScreenSize();
|
const handleMouseMove = (e) => {
|
||||||
window.addEventListener("resize", checkScreenSize);
|
const x = e.pageX - slider.offsetLeft;
|
||||||
return () => window.removeEventListener("resize", checkScreenSize);
|
const walk = (x - startX) * 2;
|
||||||
}, []);
|
slider.scrollLeft = scrollLeft - walk;
|
||||||
|
};
|
||||||
|
|
||||||
// Auto-advance every 3 seconds (only on mobile)
|
const handleMouseUp = () => {
|
||||||
useEffect(() => {
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
if (filteredPosts.length <= 1 || !isMobile) return;
|
document.removeEventListener("mouseup", handleMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
document.addEventListener("mousemove", handleMouseMove);
|
||||||
setProgress(0);
|
document.addEventListener("mouseup", handleMouseUp);
|
||||||
setCurrentIndex((prev) => (prev + 1) % filteredPosts.length);
|
}, []);
|
||||||
}, 3000);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
// Memoize transform style to prevent unnecessary recalculations
|
||||||
}, [filteredPosts.length, isMobile]);
|
const transformStyle = useMemo(
|
||||||
|
() => ({
|
||||||
|
transform: isMobile
|
||||||
|
? `translateX(calc(50% - 130px - ${currentIndex * 260}px))`
|
||||||
|
: "none",
|
||||||
|
scrollBehavior: !isMobile ? "smooth" : "auto",
|
||||||
|
}),
|
||||||
|
[isMobile, currentIndex],
|
||||||
|
);
|
||||||
|
|
||||||
// Progress animation (only on mobile)
|
// Memoize progress bar style calculation
|
||||||
useEffect(() => {
|
const getProgressStyle = useCallback(
|
||||||
if (filteredPosts.length <= 1 || !isMobile) return;
|
(index) => ({
|
||||||
|
width:
|
||||||
|
index === currentIndex
|
||||||
|
? `${progress}%`
|
||||||
|
: index < currentIndex
|
||||||
|
? "100%"
|
||||||
|
: "0%",
|
||||||
|
}),
|
||||||
|
[currentIndex, progress],
|
||||||
|
);
|
||||||
|
|
||||||
const progressInterval = setInterval(() => {
|
// Check if we're on mobile (below lg breakpoint)
|
||||||
setProgress((prev) => {
|
useEffect(() => {
|
||||||
if (prev >= 100) {
|
const checkScreenSize = () => {
|
||||||
return 0;
|
setIsMobile(window.innerWidth < 1024); // lg breakpoint is 1024px
|
||||||
}
|
};
|
||||||
return prev + 1;
|
|
||||||
});
|
|
||||||
}, 30); // 30ms intervals for smooth animation
|
|
||||||
|
|
||||||
return () => clearInterval(progressInterval);
|
checkScreenSize();
|
||||||
}, [currentIndex, filteredPosts.length, isMobile]);
|
window.addEventListener("resize", checkScreenSize);
|
||||||
|
return () => window.removeEventListener("resize", checkScreenSize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (filteredPosts.length === 0) {
|
// Auto-advance every 3 seconds (only on mobile)
|
||||||
return null;
|
useEffect(() => {
|
||||||
}
|
if (filteredPosts.length <= 1 || !isMobile) return;
|
||||||
|
|
||||||
return (
|
const interval = setInterval(() => {
|
||||||
<section className="py-[var(--spacing-scale-032)] lg:py-[var(--spacing-scale-064)]">
|
setProgress(0);
|
||||||
<div className="flex flex-col gap-[var(--spacing-scale-032)] lg:gap-[51px]">
|
setCurrentIndex((prev) => (prev + 1) % filteredPosts.length);
|
||||||
<h2 className="text-[32px] lg:text-[44px] leading-[110%] font-medium text-[var(--color-content-inverse-primary)] text-center">
|
}, 3000);
|
||||||
Related Articles
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{/* Horizontal Articles Row - Carousel on mobile, Scrollable slider on desktop */}
|
return () => clearInterval(interval);
|
||||||
<div className="flex justify-center overflow-hidden">
|
}, [filteredPosts.length, isMobile]);
|
||||||
<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;
|
|
||||||
|
|
||||||
const handleMouseMove = (e) => {
|
// Progress animation (only on mobile)
|
||||||
const x = e.pageX - slider.offsetLeft;
|
useEffect(() => {
|
||||||
const walk = (x - startX) * 2;
|
if (filteredPosts.length <= 1 || !isMobile) return;
|
||||||
slider.scrollLeft = scrollLeft - walk;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
const progressInterval = setInterval(() => {
|
||||||
document.removeEventListener(
|
setProgress((prev) => {
|
||||||
"mousemove",
|
if (prev >= 100) {
|
||||||
handleMouseMove,
|
return 0;
|
||||||
);
|
}
|
||||||
document.removeEventListener("mouseup", handleMouseUp);
|
return prev + 1;
|
||||||
};
|
});
|
||||||
|
}, 30); // 30ms intervals for smooth animation
|
||||||
|
|
||||||
document.addEventListener("mousemove", handleMouseMove);
|
return () => clearInterval(progressInterval);
|
||||||
document.addEventListener("mouseup", handleMouseUp);
|
}, [currentIndex, filteredPosts.length, isMobile]);
|
||||||
}
|
|
||||||
: 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>
|
|
||||||
|
|
||||||
{/* Progress bars - only show on mobile */}
|
if (filteredPosts.length === 0) {
|
||||||
{isMobile && (
|
return null;
|
||||||
<div className="flex justify-center gap-[var(--measures-spacing-008)] px-[var(--measures-spacing-064)]">
|
}
|
||||||
{filteredPosts.map((relatedPost, index) => (
|
|
||||||
<div
|
return (
|
||||||
key={relatedPost.slug}
|
<section className="py-[var(--spacing-scale-032)] lg:py-[var(--spacing-scale-064)]">
|
||||||
className="max-w-[var(--measures-spacing-056)] w-full h-[var(--measures-spacing-004)] bg-gray-200 rounded-full overflow-hidden"
|
<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
|
<div
|
||||||
className="h-full bg-gray-600 rounded-full transition-all duration-75 ease-linear"
|
key={relatedPost.slug}
|
||||||
style={{
|
className="flex flex-col items-center flex-shrink-0"
|
||||||
width:
|
>
|
||||||
index === currentIndex
|
<ContentThumbnailTemplate
|
||||||
? `${progress}%`
|
post={relatedPost}
|
||||||
: index < currentIndex
|
variant="vertical"
|
||||||
? "100%"
|
slugOrder={slugOrder}
|
||||||
: "0%",
|
/>
|
||||||
}}
|
</div>
|
||||||
/>
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
{/* Progress bars - only show on mobile */}
|
||||||
</section>
|
{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";
|
"use client";
|
||||||
|
|
||||||
const RuleCard = ({
|
import React, { memo } from "react";
|
||||||
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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom analytics event for other tracking systems
|
const RuleCard = memo(
|
||||||
if (typeof window !== "undefined" && window.analytics) {
|
({
|
||||||
window.analytics.track("Template Selected", {
|
title,
|
||||||
templateName: title,
|
description,
|
||||||
templateType: "governance_pattern",
|
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 (onClick) onClick();
|
||||||
if (event.key === "Enter" || event.key === " ") {
|
};
|
||||||
event.preventDefault();
|
|
||||||
handleClick();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
const handleKeyDown = (event) => {
|
||||||
<div
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
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}`}
|
event.preventDefault();
|
||||||
tabIndex={0}
|
handleClick();
|
||||||
role="button"
|
}
|
||||||
aria-label={`Learn more about ${title} governance pattern`}
|
};
|
||||||
onClick={handleClick}
|
|
||||||
onKeyDown={handleKeyDown}
|
return (
|
||||||
>
|
<div
|
||||||
{/* Header Container */}
|
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}`}
|
||||||
<div className="grid grid-cols-[auto_1fr] h-[72px] md:h-[80px] lg:h-[138px] border-b border-[var(--color-surface-default-primary)]">
|
tabIndex={0}
|
||||||
{/* Icon Container */}
|
role="button"
|
||||||
{icon && (
|
aria-label={`Learn more about ${title} governance pattern`}
|
||||||
<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">
|
onClick={handleClick}
|
||||||
{icon}
|
onKeyDown={handleKeyDown}
|
||||||
</div>
|
>
|
||||||
)}
|
{/* Header Container */}
|
||||||
{/* Title Container */}
|
<div className="grid grid-cols-[auto_1fr] h-[72px] md:h-[80px] lg:h-[138px] border-b border-[var(--color-surface-default-primary)]">
|
||||||
{title && (
|
{/* Icon Container */}
|
||||||
<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)]">
|
{icon && (
|
||||||
<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]">
|
<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">
|
||||||
{title}
|
{icon}
|
||||||
</h3>
|
</div>
|
||||||
</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>
|
</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>
|
|
||||||
)}
|
RuleCard.displayName = "RuleCard";
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RuleCard;
|
export default RuleCard;
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React, { memo } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import RuleCard from "./RuleCard";
|
import RuleCard from "./RuleCard";
|
||||||
import Button from "./Button";
|
import Button from "./Button";
|
||||||
import { getAssetPath } from "../../lib/assetUtils";
|
import { getAssetPath } from "../../lib/assetUtils";
|
||||||
|
|
||||||
const RuleStack = ({ className = "" }) => {
|
const RuleStack = memo(({ className = "" }) => {
|
||||||
const handleTemplateClick = (templateName) => {
|
const handleTemplateClick = (templateName) => {
|
||||||
// Basic analytics tracking
|
// Basic analytics tracking
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
@@ -99,6 +99,8 @@ const RuleStack = ({ className = "" }) => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
|
RuleStack.displayName = "RuleStack";
|
||||||
|
|
||||||
export default RuleStack;
|
export default RuleStack;
|
||||||
|
|||||||
@@ -1,54 +1,60 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
const SectionHeader = ({ title, subtitle, titleLg, variant = "default" }) => {
|
import React, { memo } from "react";
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
variant === "multi-line"
|
|
||||||
? "flex flex-col gap-[var(--spacing-scale-004)] w-full lg:flex-row lg:justify-between lg:items-start xl:gap-[var(--spacing-scale-024)]"
|
|
||||||
: "flex flex-col gap-[var(--spacing-scale-004)] w-full lg:flex-row lg:justify-between lg:items-start xl:gap-[var(--spacing-scale-024)]"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{/* Title Container - Left side (lg breakpoint) */}
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
variant === "multi-line"
|
|
||||||
? "lg:w-[50%] lg:h-[var(--spacing-scale-120)] lg:flex lg:items-center xl:w-[50%] xl:h-[156px] xl:flex xl:items-center"
|
|
||||||
: "lg:w-[369px] lg:h-[var(--spacing-scale-120)] lg:flex lg:items-center xl:w-[452px] xl:h-[156px] xl:flex xl:items-center"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<h2
|
|
||||||
className={
|
|
||||||
variant === "multi-line"
|
|
||||||
? "font-bricolage-grotesque font-bold text-[28px] leading-[36px] md:font-bold md:text-[32px] md:leading-[40px] lg:w-[410px] lg:text-left xl:text-[40px] xl:leading-[52px] text-[var(--color-content-default-primary)]"
|
|
||||||
: "font-bricolage-grotesque font-bold text-[28px] leading-[36px] sm:text-[32px] sm:leading-[40px] lg:text-[32px] lg:leading-[40px] lg:w-[369px] lg:pr-[var(--spacing-scale-096)] xl:text-[40px] xl:leading-[52px] xl:w-[452px] xl:pr-[var(--spacing-scale-096)] text-[var(--color-content-default-primary)]"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span className="block lg:hidden">{title}</span>
|
|
||||||
<span className="hidden lg:block">{titleLg || title}</span>
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Subtitle Container */}
|
const SectionHeader = memo(
|
||||||
|
({ title, subtitle, titleLg, variant = "default" }) => {
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
variant === "multi-line"
|
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"
|
? "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)]"
|
||||||
: "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)]"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<p
|
{/* Title Container - Left side (lg breakpoint) */}
|
||||||
|
<div
|
||||||
className={
|
className={
|
||||||
variant === "multi-line"
|
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)]"
|
? "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"
|
||||||
: "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-[369px] lg:h-[var(--spacing-scale-120)] lg:flex lg:items-center xl:w-[452px] xl:h-[156px] xl:flex xl:items-center"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{subtitle}
|
<h2
|
||||||
</p>
|
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>
|
||||||
</div>
|
);
|
||||||
);
|
},
|
||||||
};
|
);
|
||||||
|
|
||||||
|
SectionHeader.displayName = "SectionHeader";
|
||||||
|
|
||||||
export default SectionHeader;
|
export default SectionHeader;
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
const SectionNumber = ({ number }) => {
|
import React, { memo } from "react";
|
||||||
|
|
||||||
|
const SectionNumber = memo(({ number }) => {
|
||||||
const getImageSrc = (num) => {
|
const getImageSrc = (num) => {
|
||||||
switch (num) {
|
switch (num) {
|
||||||
case 1:
|
case 1:
|
||||||
return "assets/SectionNumber_1.png";
|
return "/assets/SectionNumber_1.png";
|
||||||
case 2:
|
case 2:
|
||||||
return "assets/SectionNumber_2.png";
|
return "/assets/SectionNumber_2.png";
|
||||||
case 3:
|
case 3:
|
||||||
return "assets/SectionNumber_3.png";
|
return "/assets/SectionNumber_3.png";
|
||||||
default:
|
default:
|
||||||
return "assets/SectionNumber_1.png";
|
return "/assets/SectionNumber_1.png";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -28,6 +30,8 @@ const SectionNumber = ({ number }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
|
SectionNumber.displayName = "SectionNumber";
|
||||||
|
|
||||||
export default SectionNumber;
|
export default SectionNumber;
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
export default function Separator() {
|
import React, { memo } from "react";
|
||||||
|
|
||||||
|
const Separator = memo(() => {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center self-stretch">
|
<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 className="flex items-start self-stretch h-px w-full bg-[var(--border-color-default-secondary)]" />
|
||||||
</div>
|
</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"],
|
weight: ["400", "500", "600", "700"],
|
||||||
variable: "--font-inter",
|
variable: "--font-inter",
|
||||||
display: "swap",
|
display: "swap",
|
||||||
|
preload: true,
|
||||||
|
fallback: ["system-ui", "arial"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const bricolageGrotesque = Bricolage_Grotesque({
|
const bricolageGrotesque = Bricolage_Grotesque({
|
||||||
@@ -17,6 +19,8 @@ const bricolageGrotesque = Bricolage_Grotesque({
|
|||||||
weight: ["400", "500", "700", "800"],
|
weight: ["400", "500", "700", "800"],
|
||||||
variable: "--font-bricolage-grotesque",
|
variable: "--font-bricolage-grotesque",
|
||||||
display: "swap",
|
display: "swap",
|
||||||
|
preload: true,
|
||||||
|
fallback: ["system-ui", "arial"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const spaceGrotesk = Space_Grotesk({
|
const spaceGrotesk = Space_Grotesk({
|
||||||
@@ -24,6 +28,8 @@ const spaceGrotesk = Space_Grotesk({
|
|||||||
weight: ["400", "500", "700"],
|
weight: ["400", "500", "700"],
|
||||||
variable: "--font-space-grotesk",
|
variable: "--font-space-grotesk",
|
||||||
display: "swap",
|
display: "swap",
|
||||||
|
preload: true,
|
||||||
|
fallback: ["system-ui", "arial"],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata = {
|
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
|
**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
|
## 🎯 How to Use These Documents
|
||||||
|
|
||||||
### For New Team Members
|
### 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
|
1. Start with **testing-framework.md** to understand the complete testing strategy
|
||||||
2. Use **testing-quick-reference.md** for daily development
|
2. Use **testing-quick-reference.md** for daily development
|
||||||
3. Reference **visual-regression-guide.md** when working with visual tests
|
3. Reference **visual-regression-guide.md** when working with visual tests
|
||||||
|
4. Review **performance-optimization-guide.md** for performance optimization
|
||||||
|
|
||||||
### For Daily Development
|
### For Daily Development
|
||||||
|
|
||||||
1. Use **testing-quick-reference.md** for commands and troubleshooting
|
1. Use **testing-quick-reference.md** for commands and troubleshooting
|
||||||
2. Reference **testing-framework.md** for detailed explanations
|
2. Reference **testing-framework.md** for detailed explanations
|
||||||
3. Use **visual-regression-guide.md** for visual test workflows
|
3. Use **visual-regression-guide.md** for visual test workflows
|
||||||
|
4. Use **performance-optimization-guide.md** for performance monitoring
|
||||||
|
|
||||||
### For Troubleshooting
|
### For Troubleshooting
|
||||||
|
|
||||||
1. Check **testing-quick-reference.md** for common solutions
|
1. Check **testing-quick-reference.md** for common solutions
|
||||||
2. Use **testing-framework.md** for detailed troubleshooting
|
2. Use **testing-framework.md** for detailed troubleshooting
|
||||||
3. Reference **visual-regression-guide.md** for visual test issues
|
3. Reference **visual-regression-guide.md** for visual test issues
|
||||||
|
4. Use **performance-optimization-guide.md** for performance issues
|
||||||
|
|
||||||
## 📊 Current Testing Status
|
## 📊 Current Testing Status
|
||||||
|
|
||||||
@@ -72,6 +88,9 @@ This directory contains comprehensive testing documentation for the CommunityRul
|
|||||||
- **Visual Regression**: 23 tests per browser
|
- **Visual Regression**: 23 tests per browser
|
||||||
- **Accessibility**: WCAG 2.1 AA compliance
|
- **Accessibility**: WCAG 2.1 AA compliance
|
||||||
- **Performance**: Lighthouse CI with budgets
|
- **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
|
## 🔄 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
|
### 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
|
- ✅ **92 E2E Tests** across 4 browsers
|
||||||
- ✅ **23 Visual Regression Tests** per browser
|
- ✅ **23 Visual Regression Tests** per browser
|
||||||
- ✅ **Performance Budgets** with Lighthouse CI
|
- ✅ **Performance Budgets** with Lighthouse CI
|
||||||
- ✅ **WCAG 2.1 AA Compliance** with automated testing
|
- ✅ **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
|
## 🏗 Testing Architecture
|
||||||
|
|
||||||
@@ -438,8 +441,10 @@ npx playwright test tests/accessibility/e2e/wcag-compliance.spec.ts
|
|||||||
### Framework
|
### Framework
|
||||||
|
|
||||||
- **Lighthouse CI**: Automated performance testing
|
- **Lighthouse CI**: Automated performance testing
|
||||||
- **Performance Budgets**: Defined thresholds
|
- **Bundle Analysis**: Real-time bundle size monitoring
|
||||||
- **Core Web Vitals**: LCP, FID, CLS monitoring
|
- **Web Vitals Tracking**: Core Web Vitals collection and reporting
|
||||||
|
- **Performance Monitoring**: Comprehensive performance metrics
|
||||||
|
- **Performance Budgets**: Defined thresholds with automated enforcement
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
@@ -472,6 +477,7 @@ npx playwright test tests/accessibility/e2e/wcag-compliance.spec.ts
|
|||||||
- **Performance Score**: >80
|
- **Performance Score**: >80
|
||||||
- **Accessibility Score**: >80
|
- **Accessibility Score**: >80
|
||||||
- **Best Practices**: >90
|
- **Best Practices**: >90
|
||||||
|
- **Bundle Size**: <250KB gzipped (currently 101KB)
|
||||||
|
|
||||||
### Performance Budgets
|
### Performance Budgets
|
||||||
|
|
||||||
@@ -479,15 +485,46 @@ npx playwright test tests/accessibility/e2e/wcag-compliance.spec.ts
|
|||||||
- **Largest Contentful Paint**: <5000ms
|
- **Largest Contentful Paint**: <5000ms
|
||||||
- **First Input Delay**: <100ms
|
- **First Input Delay**: <100ms
|
||||||
- **TTFB**: <700ms
|
- **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
|
### Available Scripts
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run lhci # Run Lighthouse CI
|
# Individual monitoring tools
|
||||||
npm run lhci:mobile # Run with mobile preset
|
npm run bundle:analyze # Analyze bundle sizes and budgets
|
||||||
npm run lhci:desktop # Run with desktop preset
|
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
|
## 🔄 CI/CD Pipeline
|
||||||
|
|
||||||
### Gitea Actions Workflow
|
### Gitea Actions Workflow
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ npm run visual:test
|
|||||||
# Performance check
|
# Performance check
|
||||||
npm run lhci
|
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
|
# Storybook tests
|
||||||
npm run test:sb
|
npm run test:sb
|
||||||
```
|
```
|
||||||
@@ -48,6 +54,9 @@ npx playwright test --headed
|
|||||||
- **Visual Regression**: 23 tests per browser ✅
|
- **Visual Regression**: 23 tests per browser ✅
|
||||||
- **Accessibility Tests**: WCAG 2.1 AA compliance ✅
|
- **Accessibility Tests**: WCAG 2.1 AA compliance ✅
|
||||||
- **Performance Tests**: Lighthouse CI with budgets ✅
|
- **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
|
## 🔧 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: {
|
eslint: {
|
||||||
ignoreDuringBuilds: true,
|
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({
|
config.module.rules.push({
|
||||||
test: /\.svg$/,
|
test: /\.svg$/,
|
||||||
issuer: /\.[jt]sx?$/,
|
issuer: /\.[jt]sx?$/,
|
||||||
use: ["@svgr/webpack"],
|
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;
|
return config;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Generated
+249
-17
@@ -12,6 +12,7 @@
|
|||||||
"@mdx-js/loader": "^3.1.1",
|
"@mdx-js/loader": "^3.1.1",
|
||||||
"@mdx-js/react": "^3.1.1",
|
"@mdx-js/react": "^3.1.1",
|
||||||
"@next/mdx": "^15.5.2",
|
"@next/mdx": "^15.5.2",
|
||||||
|
"critters": "^0.0.23",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"next": "15.2.4",
|
"next": "15.2.4",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
@@ -59,7 +60,9 @@
|
|||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.2",
|
||||||
"vitest": "^3.2.4",
|
"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": {
|
"node_modules/@adobe/css-tools": {
|
||||||
@@ -2210,6 +2213,16 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz",
|
"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"
|
"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": {
|
"node_modules/agent-base": {
|
||||||
"version": "7.1.4",
|
"version": "7.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||||
@@ -8097,7 +8123,6 @@
|
|||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-convert": "^2.0.1"
|
"color-convert": "^2.0.1"
|
||||||
@@ -8890,7 +8915,6 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||||
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
@@ -9185,7 +9209,6 @@
|
|||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-styles": "^4.1.0",
|
"ansi-styles": "^4.1.0",
|
||||||
@@ -9491,7 +9514,6 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-name": "~1.1.4"
|
"color-name": "~1.1.4"
|
||||||
@@ -9504,7 +9526,6 @@
|
|||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/color-string": {
|
"node_modules/color-string": {
|
||||||
@@ -9778,6 +9799,95 @@
|
|||||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
"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": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -9814,7 +9924,6 @@
|
|||||||
"version": "5.2.2",
|
"version": "5.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
||||||
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
|
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"boolbase": "^1.0.0",
|
"boolbase": "^1.0.0",
|
||||||
@@ -9831,7 +9940,6 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"domelementtype": "^2.3.0",
|
"domelementtype": "^2.3.0",
|
||||||
@@ -9846,7 +9954,6 @@
|
|||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -9859,7 +9966,6 @@
|
|||||||
"version": "5.0.3",
|
"version": "5.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"domelementtype": "^2.3.0"
|
"domelementtype": "^2.3.0"
|
||||||
@@ -9875,7 +9981,6 @@
|
|||||||
"version": "3.2.2",
|
"version": "3.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||||
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dom-serializer": "^2.0.0",
|
"dom-serializer": "^2.0.0",
|
||||||
@@ -9904,7 +10009,6 @@
|
|||||||
"version": "6.2.2",
|
"version": "6.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
|
||||||
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
|
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
@@ -10076,6 +10180,13 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -10576,7 +10687,6 @@
|
|||||||
"version": "4.5.0",
|
"version": "4.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.12"
|
"node": ">=0.12"
|
||||||
@@ -12765,6 +12875,22 @@
|
|||||||
"js-yaml": "bin/js-yaml.js"
|
"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": {
|
"node_modules/has-bigints": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
||||||
@@ -12782,7 +12908,6 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -18108,7 +18233,6 @@
|
|||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||||
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
|
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"boolbase": "^1.0.0"
|
"boolbase": "^1.0.0"
|
||||||
@@ -18553,6 +18677,16 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
@@ -19163,7 +19297,6 @@
|
|||||||
"version": "8.5.6",
|
"version": "8.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -19188,6 +19321,12 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"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": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
@@ -21534,7 +21673,6 @@
|
|||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"has-flag": "^4.0.0"
|
"has-flag": "^4.0.0"
|
||||||
@@ -23006,6 +23144,13 @@
|
|||||||
"makeerror": "1.0.12"
|
"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": {
|
"node_modules/webdriver-bidi-protocol": {
|
||||||
"version": "0.2.11",
|
"version": "0.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.2.11.tgz",
|
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.2.11.tgz",
|
||||||
@@ -23023,6 +23168,93 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/webpack-virtual-modules": {
|
||||||
"version": "0.6.2",
|
"version": "0.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
"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",
|
"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: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: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": {
|
"dependencies": {
|
||||||
"@mdx-js/loader": "^3.1.1",
|
"@mdx-js/loader": "^3.1.1",
|
||||||
"@mdx-js/react": "^3.1.1",
|
"@mdx-js/react": "^3.1.1",
|
||||||
"@next/mdx": "^15.5.2",
|
"@next/mdx": "^15.5.2",
|
||||||
|
"critters": "^0.0.23",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"next": "15.2.4",
|
"next": "15.2.4",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
@@ -86,6 +95,8 @@
|
|||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.2",
|
||||||
"vitest": "^3.2.4",
|
"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": {
|
"performance": {
|
||||||
"budgets": [
|
"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
|
* Performance Monitoring Script
|
||||||
*
|
* Monitors Core Web Vitals and performance metrics
|
||||||
* This script provides comprehensive performance monitoring capabilities
|
|
||||||
* for the Community Rule application.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { spawn } = require("child_process");
|
const { execSync } = require("child_process");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
|
||||||
// Performance budgets
|
const PERFORMANCE_BUDGETS = require("../performance-budgets.json");
|
||||||
const PERFORMANCE_BUDGETS = {
|
const MONITORING_DIR = path.join(__dirname, "..", ".next", "monitoring");
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Baseline metrics for regression detection
|
class PerformanceMonitor {
|
||||||
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 {
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.metrics = new Map();
|
this.metrics = {
|
||||||
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 = {
|
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
summary: {
|
coreWebVitals: {},
|
||||||
totalMetrics: this.metrics.size,
|
bundleMetrics: {},
|
||||||
regressions: this.regressions.length,
|
recommendations: [],
|
||||||
warnings: this.warnings.length,
|
|
||||||
},
|
|
||||||
regressions: this.regressions,
|
|
||||||
warnings: this.warnings,
|
|
||||||
metrics: Object.fromEntries(this.metrics),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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() {
|
async monitorPerformance() {
|
||||||
console.log("🔍 Starting Performance Monitoring...\n");
|
console.log("📊 Starting performance monitoring...");
|
||||||
|
|
||||||
try {
|
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();
|
await this.runLighthouseCI();
|
||||||
|
|
||||||
// Run Playwright performance tests
|
// Analyze bundle performance
|
||||||
await this.runPlaywrightPerformanceTests();
|
await this.analyzeBundlePerformance();
|
||||||
|
|
||||||
// Generate and display report
|
// Check performance budgets
|
||||||
const report = this.generateReport();
|
this.checkPerformanceBudgets();
|
||||||
|
|
||||||
// Exit with appropriate code
|
// Generate performance report
|
||||||
if (this.regressions.length > 0) {
|
this.generatePerformanceReport();
|
||||||
console.log("❌ Performance regressions detected!");
|
|
||||||
process.exit(1);
|
console.log("✅ Performance monitoring complete!");
|
||||||
} else if (this.warnings.length > 0) {
|
console.log(`📁 Results saved to: ${MONITORING_DIR}`);
|
||||||
console.log("⚠️ Performance warnings detected.");
|
|
||||||
process.exit(0);
|
|
||||||
} else {
|
|
||||||
console.log("✅ All performance checks passed!");
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ Performance monitoring failed:", error.message);
|
console.error("❌ Performance monitoring failed:", error.message);
|
||||||
process.exit(1);
|
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) {
|
if (require.main === module) {
|
||||||
const monitor = new PerformanceMonitorScript();
|
const monitor = new PerformanceMonitor();
|
||||||
monitor.run();
|
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");
|
const foodNotBombsLogo = screen.getByAltText("Food Not Bombs");
|
||||||
expect(foodNotBombsLogo).toHaveAttribute(
|
expect(foodNotBombsLogo).toHaveAttribute(
|
||||||
"src",
|
"src",
|
||||||
"assets/Section/Logo_FoodNotBombs.png",
|
"/assets/Section/Logo_FoodNotBombs.png",
|
||||||
);
|
);
|
||||||
expect(foodNotBombsLogo).toHaveClass("h-11", "lg:h-14", "xl:h-[70px]");
|
expect(foodNotBombsLogo).toHaveClass("h-11", "lg:h-14", "xl:h-[70px]");
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user