AgentSkillsCN

frontend_engineer

为RetentionAI仪表板进行前端开发。适用于以下工作场景:(1) Next.js 14 App Router组件;(2) 仪表板UI与数据可视化;(3) 实时更新(WebSocket);(4) 表单处理与验证;(5) TypeScript接口;(6) 使用Shadcn/UI进行Tailwind CSS样式设计;(7) 与React Query进行API集成。

SKILL.md
--- frontmatter
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

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