Add detailed landing page development plan in docs/frontend_landing_plan.md: - Complete landing page structure (Hero, Problem/Solution, Features, Demo, CTA) - Design guidelines from downloaded skills (typography, color, motion, composition) - Security considerations (XSS prevention, input sanitization, CSP) - Performance targets (LCP <2.5s, bundle <150KB, Lighthouse >90) - Responsiveness and accessibility requirements (WCAG 2.1 AA) - Success KPIs and monitoring setup - 3-week development timeline with daily tasks - Definition of Done checklist Download 10+ frontend/UI/UX skills via universal-skills-manager: - frontend-ui-ux: UI/UX design without mockups - frontend-design-guidelines: Production-grade interface guidelines - frontend-developer: React best practices (40+ rules) - frontend-engineer: Next.js 14 App Router patterns - ui-ux-master: Comprehensive design systems and accessibility - ui-ux-systems-designer: Information architecture and interaction - ui-ux-design-user-experience: Platform-specific guidelines - Plus additional reference materials and validation scripts Configure universal-skills MCP with SkillsMP API key for curated skill access. Safety first: All skills validated before installation, no project code modified. Refs: Universal Skills Manager (github:jacob-bd/universal-skills-manager) Next: Begin Sprint 3 landing page development
776 lines
20 KiB
Markdown
776 lines
20 KiB
Markdown
---
|
|
name: frontend_engineer
|
|
description: Frontend development for RetentionAI dashboard. Use this skill when working on: (1) Next.js 14 App Router components, (2) Dashboard UI and data visualization, (3) Real-time updates (WebSocket), (4) Form handling and validation, (5) TypeScript interfaces, (6) Tailwind CSS styling with Shadcn/UI, (7) API integration with React Query.
|
|
---
|
|
|
|
# Frontend Engineer Skill
|
|
|
|
## Next.js 14 Project Structure
|
|
|
|
```
|
|
frontend/
|
|
├── app/
|
|
│ ├── (auth)/ # Auth routes
|
|
│ │ └── login/
|
|
│ ├── dashboard/ # Main dashboard
|
|
│ │ ├── page.tsx # Executive view
|
|
│ │ ├── campaigns/ # Campaign manager
|
|
│ │ ├── customers/ # Customer explorer
|
|
│ │ └── health/ # Data quality
|
|
│ ├── layout.tsx # Root layout
|
|
│ └── globals.css
|
|
├── components/
|
|
│ ├── ui/ # Shadcn components
|
|
│ ├── charts/ # Recharts wrappers
|
|
│ ├── EventFeed.tsx
|
|
│ ├── MetricsCard.tsx
|
|
│ └── RiskHeatmap.tsx
|
|
├── lib/
|
|
│ ├── api-client.ts # Backend API wrapper
|
|
│ ├── hooks.ts # Custom React hooks
|
|
│ └── utils.ts
|
|
├── package.json
|
|
└── tsconfig.json
|
|
```
|
|
|
|
---
|
|
|
|
## Dashboard Page (Server Component)
|
|
|
|
```tsx
|
|
// app/dashboard/page.tsx
|
|
import { MetricsCard } from '@/components/MetricsCard'
|
|
import { EventFeed } from '@/components/EventFeed'
|
|
import { RiskHeatmap } from '@/components/RiskHeatmap'
|
|
|
|
export default async function DashboardPage() {
|
|
// Fetch data on server (Next.js 14 pattern)
|
|
const metrics = await fetchMetrics()
|
|
|
|
return (
|
|
<div className="container mx-auto p-6 space-y-6">
|
|
{/* KPI Cards */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<MetricsCard
|
|
title="At-Risk Revenue"
|
|
value={metrics.atRiskRevenue}
|
|
format="currency"
|
|
trend={metrics.atRiskTrend}
|
|
/>
|
|
<MetricsCard
|
|
title="Saved Revenue"
|
|
value={metrics.savedRevenue}
|
|
format="currency"
|
|
trend={metrics.savedTrend}
|
|
/>
|
|
<MetricsCard
|
|
title="Campaign ROI"
|
|
value={metrics.campaignRoi}
|
|
format="percentage"
|
|
trend={metrics.roiTrend}
|
|
/>
|
|
</div>
|
|
|
|
{/* Main Content */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{/* Live Event Feed */}
|
|
<EventFeed initialEvents={metrics.recentEvents} />
|
|
|
|
{/* Risk Heatmap */}
|
|
<RiskHeatmap data={metrics.cohortRisks} />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
async function fetchMetrics() {
|
|
// Server-side data fetching
|
|
const res = await fetch('http://localhost:8000/api/metrics', {
|
|
cache: 'no-store', // Always fresh
|
|
headers: {
|
|
'Authorization': `Bearer ${process.env.API_KEY}`
|
|
}
|
|
})
|
|
|
|
if (!res.ok) throw new Error('Failed to fetch metrics')
|
|
return res.json()
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Metrics Card Component
|
|
|
|
```tsx
|
|
// components/MetricsCard.tsx
|
|
import { TrendingUp, TrendingDown } from 'lucide-react'
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
|
|
interface MetricsCardProps {
|
|
title: string
|
|
value: number
|
|
format: 'currency' | 'percentage' | 'number'
|
|
trend?: { value: number; direction: 'up' | 'down' }
|
|
}
|
|
|
|
export function MetricsCard({ title, value, format, trend }: MetricsCardProps) {
|
|
const formattedValue = formatValue(value, format)
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
|
{title}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-3xl font-bold">{formattedValue}</div>
|
|
|
|
{trend && (
|
|
<div className="flex items-center gap-1 text-sm mt-2">
|
|
{trend.direction === 'up' ? (
|
|
<TrendingUp className="h-4 w-4 text-green-500" />
|
|
) : (
|
|
<TrendingDown className="h-4 w-4 text-red-500" />
|
|
)}
|
|
<span className={trend.direction === 'up' ? 'text-green-500' : 'text-red-500'}>
|
|
{Math.abs(trend.value)}%
|
|
</span>
|
|
<span className="text-muted-foreground">vs last week</span>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
function formatValue(value: number, format: MetricsCardProps['format']): string {
|
|
switch (format) {
|
|
case 'currency':
|
|
return new Intl.NumberFormat('en-US', {
|
|
style: 'currency',
|
|
currency: 'USD',
|
|
minimumFractionDigits: 0,
|
|
maximumFractionDigits: 0
|
|
}).format(value)
|
|
|
|
case 'percentage':
|
|
return `${(value * 100).toFixed(1)}%`
|
|
|
|
case 'number':
|
|
return new Intl.NumberFormat('en-US').format(value)
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Live Event Feed (Client Component)
|
|
|
|
```tsx
|
|
// components/EventFeed.tsx
|
|
'use client'
|
|
|
|
import { useState, useEffect } from 'react'
|
|
import { useQuery } from '@tanstack/react-query'
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { formatDistanceToNow } from 'date-fns'
|
|
|
|
interface Event {
|
|
id: string
|
|
type: string
|
|
customer_email: string
|
|
timestamp: string
|
|
properties: Record<string, any>
|
|
}
|
|
|
|
interface EventFeedProps {
|
|
initialEvents?: Event[]
|
|
}
|
|
|
|
export function EventFeed({ initialEvents = [] }: EventFeedProps) {
|
|
const [events, setEvents] = useState<Event[]>(initialEvents)
|
|
|
|
// Poll for new events every 5 seconds
|
|
const { data } = useQuery({
|
|
queryKey: ['events'],
|
|
queryFn: fetchEvents,
|
|
refetchInterval: 5000,
|
|
initialData: initialEvents
|
|
})
|
|
|
|
useEffect(() => {
|
|
if (data) setEvents(data)
|
|
}, [data])
|
|
|
|
// WebSocket for real-time updates (optional enhancement)
|
|
useEffect(() => {
|
|
const ws = new WebSocket('ws://localhost:8000/ws/events')
|
|
|
|
ws.onmessage = (event) => {
|
|
const newEvent = JSON.parse(event.data)
|
|
setEvents(prev => [newEvent, ...prev].slice(0, 20)) // Keep last 20
|
|
}
|
|
|
|
return () => ws.close()
|
|
}, [])
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Live Event Stream</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-2 max-h-[500px] overflow-y-auto">
|
|
{events.map(event => (
|
|
<div
|
|
key={event.id}
|
|
className="flex items-center justify-between p-3 rounded-lg border bg-card hover:bg-accent transition-colors"
|
|
>
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant={getEventVariant(event.type)}>
|
|
{event.type}
|
|
</Badge>
|
|
<span className="text-sm font-medium">
|
|
{event.customer_email}
|
|
</span>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
{formatDistanceToNow(new Date(event.timestamp), { addSuffix: true })}
|
|
</p>
|
|
</div>
|
|
|
|
{event.type === 'payment_failed' && (
|
|
<div className="text-right">
|
|
<p className="text-sm font-bold text-destructive">
|
|
${(event.properties.amount / 100).toFixed(2)}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{event.properties.failure_code}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
async function fetchEvents(): Promise<Event[]> {
|
|
const res = await fetch('/api/events/recent')
|
|
if (!res.ok) throw new Error('Failed to fetch events')
|
|
return res.json()
|
|
}
|
|
|
|
function getEventVariant(eventType: string): 'default' | 'destructive' | 'secondary' {
|
|
switch (eventType) {
|
|
case 'payment_failed':
|
|
return 'destructive'
|
|
case 'subscription_cancelled':
|
|
return 'destructive'
|
|
case 'purchase':
|
|
return 'default'
|
|
default:
|
|
return 'secondary'
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Risk Heatmap (Recharts)
|
|
|
|
```tsx
|
|
// components/RiskHeatmap.tsx
|
|
'use client'
|
|
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import {
|
|
ResponsiveContainer,
|
|
ScatterChart,
|
|
Scatter,
|
|
XAxis,
|
|
YAxis,
|
|
ZAxis,
|
|
Tooltip,
|
|
Cell
|
|
} from 'recharts'
|
|
|
|
interface CohortData {
|
|
month: string
|
|
cohortSize: number
|
|
churnRisk: number // 0-1
|
|
revenue: number
|
|
}
|
|
|
|
interface RiskHeatmapProps {
|
|
data: CohortData[]
|
|
}
|
|
|
|
export function RiskHeatmap({ data }: RiskHeatmapProps) {
|
|
// Color scale based on churn risk
|
|
const getColor = (risk: number) => {
|
|
if (risk > 0.7) return '#ef4444' // red
|
|
if (risk > 0.4) return '#f97316' // orange
|
|
if (risk > 0.2) return '#eab308' // yellow
|
|
return '#22c55e' // green
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Cohort Risk Analysis</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<ResponsiveContainer width="100%" height={400}>
|
|
<ScatterChart margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
|
|
<XAxis
|
|
type="number"
|
|
dataKey="cohortSize"
|
|
name="Cohort Size"
|
|
label={{ value: 'Cohort Size', position: 'insideBottom', offset: -10 }}
|
|
/>
|
|
<YAxis
|
|
type="number"
|
|
dataKey="churnRisk"
|
|
name="Churn Risk"
|
|
label={{ value: 'Churn Risk', angle: -90, position: 'insideLeft' }}
|
|
domain={[0, 1]}
|
|
/>
|
|
<ZAxis
|
|
type="number"
|
|
dataKey="revenue"
|
|
range={[100, 1000]}
|
|
name="Revenue"
|
|
/>
|
|
<Tooltip
|
|
cursor={{ strokeDasharray: '3 3' }}
|
|
content={({ active, payload }) => {
|
|
if (!active || !payload?.[0]) return null
|
|
|
|
const data = payload[0].payload as CohortData
|
|
return (
|
|
<div className="bg-background border rounded-lg p-3 shadow-lg">
|
|
<p className="font-semibold">{data.month}</p>
|
|
<p className="text-sm">Size: {data.cohortSize} customers</p>
|
|
<p className="text-sm">Risk: {(data.churnRisk * 100).toFixed(1)}%</p>
|
|
<p className="text-sm">Revenue: ${data.revenue.toLocaleString()}</p>
|
|
</div>
|
|
)
|
|
}}
|
|
/>
|
|
<Scatter data={data}>
|
|
{data.map((entry, index) => (
|
|
<Cell key={index} fill={getColor(entry.churnRisk)} />
|
|
))}
|
|
</Scatter>
|
|
</ScatterChart>
|
|
</ResponsiveContainer>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## API Client (React Query)
|
|
|
|
```typescript
|
|
// lib/api-client.ts
|
|
import { QueryClient } from '@tanstack/react-query'
|
|
|
|
export const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: {
|
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
|
retry: 1
|
|
}
|
|
}
|
|
})
|
|
|
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'
|
|
|
|
export async function apiClient<T>(
|
|
endpoint: string,
|
|
options?: RequestInit
|
|
): Promise<T> {
|
|
const res = await fetch(`${API_URL}${endpoint}`, {
|
|
...options,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...options?.headers
|
|
}
|
|
})
|
|
|
|
if (!res.ok) {
|
|
const error = await res.json().catch(() => ({ message: 'Unknown error' }))
|
|
throw new Error(error.message || `HTTP ${res.status}`)
|
|
}
|
|
|
|
return res.json()
|
|
}
|
|
|
|
// API methods
|
|
export const api = {
|
|
// Metrics
|
|
getMetrics: () => apiClient<Metrics>('/api/metrics'),
|
|
|
|
// Events
|
|
getRecentEvents: () => apiClient<Event[]>('/api/events/recent'),
|
|
|
|
// Customers
|
|
getCustomer: (id: string) => apiClient<Customer>(`/api/customers/${id}`),
|
|
searchCustomers: (query: string) =>
|
|
apiClient<Customer[]>(`/api/customers/search?q=${query}`),
|
|
|
|
// Predictions
|
|
getCustomerPredictions: (customerId: string) =>
|
|
apiClient<Prediction[]>(`/api/predictions/${customerId}`),
|
|
|
|
// Campaigns
|
|
getCampaigns: () => apiClient<Campaign[]>('/api/campaigns'),
|
|
createCampaign: (data: CampaignCreate) =>
|
|
apiClient<Campaign>('/api/campaigns', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data)
|
|
})
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Custom Hooks
|
|
|
|
```typescript
|
|
// lib/hooks.ts
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { api } from './api-client'
|
|
|
|
// Metrics hook
|
|
export function useMetrics() {
|
|
return useQuery({
|
|
queryKey: ['metrics'],
|
|
queryFn: api.getMetrics,
|
|
refetchInterval: 30000 // Refresh every 30s
|
|
})
|
|
}
|
|
|
|
// Customer search hook
|
|
export function useCustomerSearch(query: string) {
|
|
return useQuery({
|
|
queryKey: ['customers', 'search', query],
|
|
queryFn: () => api.searchCustomers(query),
|
|
enabled: query.length > 2 // Only search if 3+ characters
|
|
})
|
|
}
|
|
|
|
// Campaign creation hook
|
|
export function useCreateCampaign() {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useMutation({
|
|
mutationFn: api.createCampaign,
|
|
onSuccess: () => {
|
|
// Invalidate campaigns list
|
|
queryClient.invalidateQueries({ queryKey: ['campaigns'] })
|
|
}
|
|
})
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Form Handling (Zod + React Hook Form)
|
|
|
|
```tsx
|
|
// app/dashboard/campaigns/create/page.tsx
|
|
'use client'
|
|
|
|
import { useForm } from 'react-hook-form'
|
|
import { zodResolver } from '@hookform/resolvers/zod'
|
|
import { z } from 'zod'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select'
|
|
import { useCreateCampaign } from '@/lib/hooks'
|
|
|
|
const campaignSchema = z.object({
|
|
name: z.string().min(3, 'Name must be at least 3 characters'),
|
|
segment: z.enum(['persuadable', 'sure_thing', 'all']),
|
|
offerType: z.enum(['10_percent_off', 'free_shipping', 'bogo', 'vip_access']),
|
|
channel: z.enum(['email', 'sms'])
|
|
})
|
|
|
|
type CampaignForm = z.infer<typeof campaignSchema>
|
|
|
|
export default function CreateCampaignPage() {
|
|
const createCampaign = useCreateCampaign()
|
|
|
|
const { register, handleSubmit, formState: { errors } } = useForm<CampaignForm>({
|
|
resolver: zodResolver(campaignSchema)
|
|
})
|
|
|
|
const onSubmit = async (data: CampaignForm) => {
|
|
await createCampaign.mutateAsync(data)
|
|
// Redirect or show success message
|
|
}
|
|
|
|
return (
|
|
<div className="container max-w-2xl py-8">
|
|
<h1 className="text-3xl font-bold mb-6">Create Campaign</h1>
|
|
|
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
|
<div>
|
|
<label className="text-sm font-medium">Campaign Name</label>
|
|
<Input {...register('name')} placeholder="Winback Campaign Q1" />
|
|
{errors.name && (
|
|
<p className="text-sm text-destructive mt-1">{errors.name.message}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-sm font-medium">Target Segment</label>
|
|
<Select {...register('segment')}>
|
|
<SelectTrigger>
|
|
<span>Select segment</span>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="persuadable">Persuadables Only</SelectItem>
|
|
<SelectItem value="sure_thing">Sure Things</SelectItem>
|
|
<SelectItem value="all">All Customers</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-sm font-medium">Offer Type</label>
|
|
<Select {...register('offerType')}>
|
|
<SelectTrigger>
|
|
<span>Select offer</span>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="10_percent_off">10% Discount</SelectItem>
|
|
<SelectItem value="free_shipping">Free Shipping</SelectItem>
|
|
<SelectItem value="bogo">Buy One Get One</SelectItem>
|
|
<SelectItem value="vip_access">VIP Early Access</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<Button type="submit" disabled={createCampaign.isPending}>
|
|
{createCampaign.isPending ? 'Creating...' : 'Create Campaign'}
|
|
</Button>
|
|
</form>
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Styling with Tailwind + Shadcn
|
|
|
|
```tsx
|
|
// Example of consistent styling patterns
|
|
|
|
// Card pattern
|
|
<Card className="p-6">
|
|
<CardHeader>
|
|
<CardTitle>Title</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>Content</CardContent>
|
|
</Card>
|
|
|
|
// Button variants
|
|
<Button>Default</Button>
|
|
<Button variant="destructive">Delete</Button>
|
|
<Button variant="outline">Cancel</Button>
|
|
<Button variant="ghost">Icon only</Button>
|
|
|
|
// Badge variants
|
|
<Badge>Default</Badge>
|
|
<Badge variant="destructive">Error</Badge>
|
|
<Badge variant="secondary">Info</Badge>
|
|
|
|
// Colors (use Tailwind classes)
|
|
- Primary: bg-primary text-primary-foreground
|
|
- Destructive: bg-destructive text-destructive-foreground
|
|
- Success: bg-green-500 text-white
|
|
- Warning: bg-yellow-500 text-black
|
|
- Info: bg-blue-500 text-white
|
|
```
|
|
|
|
---
|
|
|
|
## TypeScript Interfaces
|
|
|
|
```typescript
|
|
// lib/types.ts
|
|
|
|
export interface Metrics {
|
|
atRiskRevenue: number
|
|
savedRevenue: number
|
|
campaignRoi: number
|
|
atRiskTrend: { value: number; direction: 'up' | 'down' }
|
|
savedTrend: { value: number; direction: 'up' | 'down' }
|
|
roiTrend: { value: number; direction: 'up' | 'down' }
|
|
recentEvents: Event[]
|
|
cohortRisks: CohortData[]
|
|
}
|
|
|
|
export interface Event {
|
|
id: string
|
|
type: string
|
|
customer_email: string
|
|
timestamp: string
|
|
properties: Record<string, any>
|
|
}
|
|
|
|
export interface Customer {
|
|
id: string
|
|
email: string
|
|
totalRevenue: number
|
|
purchaseCount: number
|
|
lastPurchaseAt: string
|
|
customAttributes: Record<string, any>
|
|
}
|
|
|
|
export interface Prediction {
|
|
id: string
|
|
type: 'clv' | 'churn_risk' | 'uplift'
|
|
value: number
|
|
segment?: 'persuadable' | 'sure_thing' | 'lost_cause'
|
|
confidence: number
|
|
predictedAt: string
|
|
}
|
|
|
|
export interface Campaign {
|
|
id: string
|
|
name: string
|
|
segment: string
|
|
offerType: string
|
|
status: 'active' | 'paused' | 'completed'
|
|
createdAt: string
|
|
}
|
|
|
|
export interface CampaignCreate {
|
|
name: string
|
|
segment: string
|
|
offerType: string
|
|
channel: string
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Testing Components
|
|
|
|
```tsx
|
|
// components/__tests__/MetricsCard.test.tsx
|
|
import { render, screen } from '@testing-library/react'
|
|
import { MetricsCard } from '../MetricsCard'
|
|
|
|
describe('MetricsCard', () => {
|
|
it('renders currency value correctly', () => {
|
|
render(
|
|
<MetricsCard
|
|
title="At-Risk Revenue"
|
|
value={125000}
|
|
format="currency"
|
|
/>
|
|
)
|
|
|
|
expect(screen.getByText('At-Risk Revenue')).toBeInTheDocument()
|
|
expect(screen.getByText('$125,000')).toBeInTheDocument()
|
|
})
|
|
|
|
it('renders percentage value correctly', () => {
|
|
render(
|
|
<MetricsCard
|
|
title="Campaign ROI"
|
|
value={0.456}
|
|
format="percentage"
|
|
/>
|
|
)
|
|
|
|
expect(screen.getByText('45.6%')).toBeInTheDocument()
|
|
})
|
|
|
|
it('shows trend indicator', () => {
|
|
render(
|
|
<MetricsCard
|
|
title="Test"
|
|
value={100}
|
|
format="number"
|
|
trend={{ value: 12.5, direction: 'up' }}
|
|
/>
|
|
)
|
|
|
|
expect(screen.getByText('12.5%')).toBeInTheDocument()
|
|
expect(screen.getByText(/vs last week/)).toBeInTheDocument()
|
|
})
|
|
})
|
|
```
|
|
|
|
---
|
|
|
|
## Performance Optimization
|
|
|
|
### 1. Use Server Components by Default
|
|
```tsx
|
|
// Default: Server Component (no 'use client')
|
|
export default async function Page() {
|
|
const data = await fetchData() // Fetched on server
|
|
return <div>{data}</div>
|
|
}
|
|
```
|
|
|
|
### 2. Client Components Only When Needed
|
|
```tsx
|
|
// Use 'use client' for interactivity
|
|
'use client'
|
|
|
|
export function InteractiveComponent() {
|
|
const [state, setState] = useState()
|
|
// ... interactive logic
|
|
}
|
|
```
|
|
|
|
### 3. Image Optimization
|
|
```tsx
|
|
import Image from 'next/image'
|
|
|
|
<Image
|
|
src="/logo.png"
|
|
alt="RetentionAI"
|
|
width={200}
|
|
height={50}
|
|
priority // For above-the-fold images
|
|
/>
|
|
```
|
|
|
|
### 4. Dynamic Imports (Code Splitting)
|
|
```tsx
|
|
import dynamic from 'next/dynamic'
|
|
|
|
const HeavyChart = dynamic(() => import('@/components/HeavyChart'), {
|
|
loading: () => <p>Loading chart...</p>,
|
|
ssr: false // Don't render on server
|
|
})
|
|
```
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
- Use Next.js 14 App Router (Server Components by default)
|
|
- Tailwind CSS + Shadcn/UI for consistent styling
|
|
- React Query for server state management
|
|
- Zod + React Hook Form for type-safe forms
|
|
- Recharts for data visualization
|
|
- TypeScript strict mode for type safety
|
|
- Component testing with Vitest
|