docs: add comprehensive frontend landing page plan and download design skills
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
This commit is contained in:
775
.opencode/skills/SKILL.md
Normal file
775
.opencode/skills/SKILL.md
Normal file
@@ -0,0 +1,775 @@
|
||||
---
|
||||
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
|
||||
Reference in New Issue
Block a user