--- 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 (

Create Campaign

{errors.name && (

{errors.name.message}

)}
) } ``` --- ## Styling with Tailwind + Shadcn ```tsx // Example of consistent styling patterns // Card pattern Title Content // Button variants // 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' RetentionAI ``` ### 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