---
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 (
{/* KPI Cards */}
{/* Main Content */}
{/* Live Event Feed */}
{/* Risk Heatmap */}
)
}
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 (
{title}
{formattedValue}
{trend && (
{trend.direction === 'up' ? (
) : (
)}
{Math.abs(trend.value)}%
vs last week
)}
)
}
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
}
interface EventFeedProps {
initialEvents?: Event[]
}
export function EventFeed({ initialEvents = [] }: EventFeedProps) {
const [events, setEvents] = useState(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 (
Live Event Stream
{events.map(event => (
{event.type}
{event.customer_email}
{formatDistanceToNow(new Date(event.timestamp), { addSuffix: true })}
{event.type === 'payment_failed' && (
${(event.properties.amount / 100).toFixed(2)}
{event.properties.failure_code}
)}
))}
)
}
async function fetchEvents(): Promise {
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 (
Cohort Risk Analysis
{
if (!active || !payload?.[0]) return null
const data = payload[0].payload as CohortData
return (
{data.month}
Size: {data.cohortSize} customers
Risk: {(data.churnRisk * 100).toFixed(1)}%
Revenue: ${data.revenue.toLocaleString()}
)
}}
/>
{data.map((entry, index) => (
|
))}
)
}
```
---
## 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(
endpoint: string,
options?: RequestInit
): Promise {
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('/api/metrics'),
// Events
getRecentEvents: () => apiClient('/api/events/recent'),
// Customers
getCustomer: (id: string) => apiClient(`/api/customers/${id}`),
searchCustomers: (query: string) =>
apiClient(`/api/customers/search?q=${query}`),
// Predictions
getCustomerPredictions: (customerId: string) =>
apiClient(`/api/predictions/${customerId}`),
// Campaigns
getCampaigns: () => apiClient('/api/campaigns'),
createCampaign: (data: CampaignCreate) =>
apiClient('/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
export default function CreateCampaignPage() {
const createCampaign = useCreateCampaign()
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(campaignSchema)
})
const onSubmit = async (data: CampaignForm) => {
await createCampaign.mutateAsync(data)
// Redirect or show success message
}
return (
)
}
```
---
## Styling with Tailwind + Shadcn
```tsx
// Example of consistent styling patterns
// Card pattern
Title
Content
// Button variants
Default
Delete
Cancel
Icon only
// Badge variants
Default
Error
Info
// 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
}
export interface Customer {
id: string
email: string
totalRevenue: number
purchaseCount: number
lastPurchaseAt: string
customAttributes: Record
}
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(
)
expect(screen.getByText('At-Risk Revenue')).toBeInTheDocument()
expect(screen.getByText('$125,000')).toBeInTheDocument()
})
it('renders percentage value correctly', () => {
render(
)
expect(screen.getByText('45.6%')).toBeInTheDocument()
})
it('shows trend indicator', () => {
render(
)
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 {data}
}
```
### 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'
```
### 4. Dynamic Imports (Code Splitting)
```tsx
import dynamic from 'next/dynamic'
const HeavyChart = dynamic(() => import('@/components/HeavyChart'), {
loading: () => Loading chart...
,
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