Frontend Engineer Skill
Next.js 14 Project Structure
code
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