User Interface Module Plan¶
Overview¶
The User Interface Module provides a modern, responsive Next.js application for users to request content generation, monitor job progress, manage strategies, and view generated content. Built with React, TypeScript, and Tailwind CSS, it integrates with Supabase for authentication and real-time updates.
Key Features¶
- Content Request Dashboard: Intuitive interface for creating generation requests
- Job Monitoring: Real-time status updates with progress indicators
- Content Preview/Export: View and download generated content in multiple formats
- Strategy Management: UI for updating client strategies and templates
- Multi-Client Support: Switch between different clients/brands
- Analytics Dashboard: View usage metrics and costs
Technology Stack¶
- Framework: Next.js 14+ with App Router
- UI Library: React 18+
- Styling: Tailwind CSS + shadcn/ui components
- State Management: Zustand + React Query
- Authentication: Supabase Auth
- Real-time: Supabase Realtime subscriptions
- Forms: React Hook Form + Zod validation
- Charts: Recharts for analytics
Application Structure¶
apps/frontend/
├── src/
│ ├── app/ # Next.js App Router
│ │ ├── (auth)/ # Auth routes
│ │ │ ├── login/
│ │ │ └── register/
│ │ ├── (dashboard)/ # Protected routes
│ │ │ ├── layout.tsx
│ │ │ ├── page.tsx # Main dashboard
│ │ │ ├── content/ # Content management
│ │ │ │ ├── new/
│ │ │ │ ├── [jobId]/
│ │ │ │ └── history/
│ │ │ ├── strategies/ # Strategy management
│ │ │ ├── analytics/ # Usage analytics
│ │ │ └── settings/ # User settings
│ │ ├── api/ # API routes
│ │ └── layout.tsx # Root layout
│ ├── components/
│ │ ├── ui/ # shadcn/ui components
│ │ ├── content/ # Content-specific components
│ │ ├── dashboard/ # Dashboard components
│ │ └── shared/ # Shared components
│ ├── hooks/ # Custom React hooks
│ ├── lib/ # Utilities and configs
│ ├── stores/ # Zustand stores
│ └── types/ # TypeScript types
├── public/ # Static assets
└── package.json
Core Components¶
1. Main Dashboard¶
// app/(dashboard)/page.tsx
import { Suspense } from 'react'
import { ContentRequestForm } from '@/components/content/ContentRequestForm'
import { RecentJobs } from '@/components/dashboard/RecentJobs'
import { QuickStats } from '@/components/dashboard/QuickStats'
import { ClientSelector } from '@/components/shared/ClientSelector'
export default function DashboardPage() {
return (
<div className="container mx-auto py-6 space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold">Content Dashboard</h1>
<ClientSelector />
</div>
<div className="grid gap-6 md:grid-cols-3">
<Suspense fallback={<div>Loading stats...</div>}>
<QuickStats />
</Suspense>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<div>
<h2 className="text-xl font-semibold mb-4">Create New Content</h2>
<ContentRequestForm />
</div>
<div>
<h2 className="text-xl font-semibold mb-4">Recent Jobs</h2>
<Suspense fallback={<div>Loading jobs...</div>}>
<RecentJobs />
</Suspense>
</div>
</div>
</div>
)
}
2. Content Request Form¶
// components/content/ContentRequestForm.tsx
import { useState } from 'react'
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 { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { useContentStore } from '@/stores/content'
import { useRouter } from 'next/navigation'
import { toast } from 'sonner'
const contentSchema = z.object({
topic: z.string().min(5, 'Topic must be at least 5 characters'),
content_type: z.enum(['blog', 'social', 'local']),
keywords: z.array(z.string()).optional(),
length: z.enum(['short', 'medium', 'long']).optional(),
priority: z.enum(['cost', 'quality', 'speed', 'balanced']).default('balanced')
})
type ContentFormData = z.infer<typeof contentSchema>
export function ContentRequestForm() {
const router = useRouter()
const { currentClient, createContentRequest } = useContentStore()
const [keywords, setKeywords] = useState<string[]>([])
const [keywordInput, setKeywordInput] = useState('')
const form = useForm<ContentFormData>({
resolver: zodResolver(contentSchema),
defaultValues: {
content_type: 'blog',
priority: 'balanced',
keywords: []
}
})
const onSubmit = async (data: ContentFormData) => {
try {
const jobId = await createContentRequest({
...data,
keywords,
client_id: currentClient?.id
})
toast.success('Content request created!')
router.push(`/content/${jobId}`)
} catch (error) {
toast.error('Failed to create request')
}
}
const addKeyword = () => {
if (keywordInput && !keywords.includes(keywordInput)) {
setKeywords([...keywords, keywordInput])
setKeywordInput('')
}
}
return (
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div>
<Label htmlFor="topic">Topic</Label>
<Textarea
id="topic"
placeholder="What should the content be about?"
{...form.register('topic')}
/>
{form.formState.errors.topic && (
<p className="text-sm text-red-500">{form.formState.errors.topic.message}</p>
)}
</div>
<div>
<Label htmlFor="content_type">Content Type</Label>
<Select
value={form.watch('content_type')}
onValueChange={(value) => form.setValue('content_type', value as any)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="blog">Blog Post</SelectItem>
<SelectItem value="social">Social Media</SelectItem>
<SelectItem value="local">Local Content</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Keywords</Label>
<div className="flex gap-2">
<Input
value={keywordInput}
onChange={(e) => setKeywordInput(e.target.value)}
placeholder="Add keywords..."
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addKeyword())}
/>
<Button type="button" onClick={addKeyword} variant="secondary">
Add
</Button>
</div>
<div className="flex flex-wrap gap-2 mt-2">
{keywords.map((keyword) => (
<Badge key={keyword} variant="secondary">
{keyword}
<button
type="button"
onClick={() => setKeywords(keywords.filter(k => k !== keyword))}
className="ml-1 text-xs"
>
×
</button>
</Badge>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="length">Length</Label>
<Select
value={form.watch('length')}
onValueChange={(value) => form.setValue('length', value as any)}
>
<SelectTrigger>
<SelectValue placeholder="Select length" />
</SelectTrigger>
<SelectContent>
<SelectItem value="short">Short</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="long">Long</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="priority">Priority</Label>
<Select
value={form.watch('priority')}
onValueChange={(value) => form.setValue('priority', value as any)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="cost">Lowest Cost</SelectItem>
<SelectItem value="quality">Highest Quality</SelectItem>
<SelectItem value="speed">Fastest</SelectItem>
<SelectItem value="balanced">Balanced</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Creating...' : 'Generate Content'}
</Button>
</form>
)
}
3. Job Status Monitor¶
// components/content/JobStatusMonitor.tsx
import { useEffect, useState } from 'react'
import { Card } from '@/components/ui/card'
import { Progress } from '@/components/ui/progress'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { CheckCircle, XCircle, Clock, Download } from 'lucide-react'
import { useJob } from '@/hooks/useJob'
import { ContentPreview } from './ContentPreview'
interface JobStatusMonitorProps {
jobId: string
}
export function JobStatusMonitor({ jobId }: JobStatusMonitorProps) {
const { job, loading, error } = useJob(jobId)
const [showPreview, setShowPreview] = useState(false)
if (loading) return <div>Loading job details...</div>
if (error) return <div>Error loading job: {error.message}</div>
if (!job) return <div>Job not found</div>
const getStatusIcon = () => {
switch (job.status) {
case 'completed':
return <CheckCircle className="text-green-500" />
case 'failed':
return <XCircle className="text-red-500" />
default:
return <Clock className="text-yellow-500 animate-pulse" />
}
}
const getProgress = () => {
switch (job.status) {
case 'pending':
return 25
case 'in_progress':
return 50
case 'completed':
case 'failed':
return 100
default:
return 0
}
}
const handleExport = (format: 'txt' | 'md' | 'html') => {
if (!job.result) return
let content = job.result.content
let filename = `${job.result.title || 'content'}.${format}`
let mimeType = 'text/plain'
if (format === 'html') {
content = `<!DOCTYPE html>
<html>
<head><title>${job.result.title}</title></head>
<body>${content.replace(/\n/g, '<br>')}</body>
</html>`
mimeType = 'text/html'
} else if (format === 'md') {
mimeType = 'text/markdown'
}
const blob = new Blob([content], { type: mimeType })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
return (
<div className="space-y-6">
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold flex items-center gap-2">
Job Status
{getStatusIcon()}
</h2>
<Badge>{job.status}</Badge>
</div>
<Progress value={getProgress()} className="mb-4" />
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-gray-500">Job ID</p>
<p className="font-mono">{job.id}</p>
</div>
<div>
<p className="text-gray-500">Created</p>
<p>{new Date(job.created_at).toLocaleString()}</p>
</div>
<div>
<p className="text-gray-500">Client</p>
<p>{job.params.client_id}</p>
</div>
<div>
<p className="text-gray-500">Content Type</p>
<p className="capitalize">{job.params.content_type}</p>
</div>
</div>
{job.error && (
<div className="mt-4 p-3 bg-red-50 text-red-700 rounded">
Error: {job.error}
</div>
)}
</Card>
{job.status === 'completed' && job.result && (
<>
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">Generated Content</h3>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => setShowPreview(!showPreview)}
>
{showPreview ? 'Hide' : 'Show'} Preview
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleExport('txt')}
>
<Download className="w-4 h-4 mr-1" />
TXT
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleExport('md')}
>
<Download className="w-4 h-4 mr-1" />
MD
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleExport('html')}
>
<Download className="w-4 h-4 mr-1" />
HTML
</Button>
</div>
</div>
<div className="grid grid-cols-3 gap-4 text-sm mb-4">
<div>
<p className="text-gray-500">Word Count</p>
<p className="font-semibold">{job.result.metadata.word_count}</p>
</div>
<div>
<p className="text-gray-500">Model Used</p>
<p className="font-semibold">{job.result.metadata.model}</p>
</div>
<div>
<p className="text-gray-500">Generation Cost</p>
<p className="font-semibold">${job.result.metadata.generation_cost.toFixed(4)}</p>
</div>
</div>
{showPreview && (
<ContentPreview content={job.result.content} />
)}
</Card>
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">SEO Analysis</h3>
<div className="space-y-2">
{job.result.metadata.keywords_used.map((keyword: string) => (
<div key={keyword} className="flex items-center justify-between">
<span>{keyword}</span>
<Badge variant="outline" className="text-xs">
Used
</Badge>
</div>
))}
</div>
{job.result.metadata.seo_notes && (
<p className="text-sm text-gray-600 mt-4">
{job.result.metadata.seo_notes}
</p>
)}
</Card>
</>
)}
</div>
)
}
4. Analytics Dashboard¶
// components/dashboard/AnalyticsDashboard.tsx
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { LineChart, Line, BarChart, Bar, PieChart, Pie, Cell, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import { useAnalytics } from '@/hooks/useAnalytics'
export function AnalyticsDashboard() {
const { data, loading } = useAnalytics()
if (loading) return <div>Loading analytics...</div>
const costByProvider = data?.costByProvider || []
const dailyCosts = data?.dailyCosts || []
const contentTypeDistribution = data?.contentTypeDistribution || []
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042']
return (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Spent</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">${data?.totalSpent.toFixed(2)}</div>
<p className="text-xs text-muted-foreground">This month</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Content Generated</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{data?.totalContent}</div>
<p className="text-xs text-muted-foreground">Total pieces</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Avg Cost</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">${data?.avgCostPerContent.toFixed(3)}</div>
<p className="text-xs text-muted-foreground">Per content</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Success Rate</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{data?.successRate.toFixed(1)}%</div>
<p className="text-xs text-muted-foreground">Completed jobs</p>
</CardContent>
</Card>
</div>
<Tabs defaultValue="costs" className="space-y-4">
<TabsList>
<TabsTrigger value="costs">Costs</TabsTrigger>
<TabsTrigger value="usage">Usage</TabsTrigger>
<TabsTrigger value="performance">Performance</TabsTrigger>
</TabsList>
<TabsContent value="costs" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Daily Costs</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={dailyCosts}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip formatter={(value) => `$${value.toFixed(2)}`} />
<Line type="monotone" dataKey="cost" stroke="#8884d8" />
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Cost by Provider</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={costByProvider}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="provider" />
<YAxis />
<Tooltip formatter={(value) => `$${value.toFixed(2)}`} />
<Bar dataKey="cost" fill="#8884d8" />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="usage" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Content Type Distribution</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={contentTypeDistribution}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
outerRadius={80}
fill="#8884d8"
dataKey="value"
>
{contentTypeDistribution.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
State Management¶
Content Store (Zustand)¶
// stores/content.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { createClient } from '@supabase/supabase-js'
interface Client {
id: string
name: string
type: string
}
interface ContentStore {
currentClient: Client | null
setCurrentClient: (client: Client) => void
createContentRequest: (params: any) => Promise<string>
getJob: (jobId: string) => Promise<any>
getRecentJobs: (limit?: number) => Promise<any[]>
}
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
export const useContentStore = create<ContentStore>()(
persist(
(set, get) => ({
currentClient: null,
setCurrentClient: (client) => set({ currentClient: client }),
createContentRequest: async (params) => {
const { currentClient } = get()
if (!currentClient) throw new Error('No client selected')
const response = await fetch('/api/content/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...params,
client_id: currentClient.id
})
})
if (!response.ok) throw new Error('Failed to create request')
const { job_id } = await response.json()
return job_id
},
getJob: async (jobId) => {
const { data } = await supabase
.from('jobs')
.select('*')
.eq('id', jobId)
.single()
return data
},
getRecentJobs: async (limit = 10) => {
const { currentClient } = get()
if (!currentClient) return []
const { data } = await supabase
.from('jobs')
.select('*')
.eq('client_id', currentClient.id)
.order('created_at', { ascending: false })
.limit(limit)
return data || []
}
}),
{
name: 'content-storage',
partialize: (state) => ({ currentClient: state.currentClient })
}
)
)
Hooks¶
useJob Hook with Real-time Updates¶
// hooks/useJob.ts
import { useEffect, useState } from 'react'
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
export function useJob(jobId: string) {
const [job, setJob] = useState<any>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
// Initial load
loadJob()
// Subscribe to updates
const subscription = supabase
.channel(`job:${jobId}`)
.on('postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'jobs',
filter: `id=eq.${jobId}`
},
(payload) => {
setJob(payload.new)
}
)
.subscribe()
return () => {
subscription.unsubscribe()
}
}, [jobId])
const loadJob = async () => {
try {
const { data, error } = await supabase
.from('jobs')
.select('*')
.eq('id', jobId)
.single()
if (error) throw error
setJob(data)
} catch (err) {
setError(err as Error)
} finally {
setLoading(false)
}
}
return { job, loading, error, refetch: loadJob }
}
API Routes¶
Content Generation API¶
// app/api/content/generate/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_KEY!
)
export async function POST(request: NextRequest) {
try {
const body = await request.json()
// Call CPM service
const response = await fetch(`${process.env.CPM_SERVICE_URL}/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
if (!response.ok) {
throw new Error('CPM service error')
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
return NextResponse.json(
{ error: 'Failed to generate content' },
{ status: 500 }
)
}
}
Authentication Flow¶
// app/(auth)/login/page.tsx
import { Auth } from '@supabase/auth-ui-react'
import { ThemeSupa } from '@supabase/auth-ui-shared'
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
export default function LoginPage() {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="w-full max-w-md">
<h1 className="text-2xl font-bold text-center mb-6">
Content Generation System
</h1>
<Auth
supabaseClient={supabase}
appearance={{ theme: ThemeSupa }}
providers={['google', 'github']}
redirectTo={`${window.location.origin}/auth/callback`}
/>
</div>
</div>
)
}
Environment Variables¶
# .env.local
NEXT_PUBLIC_SUPABASE_URL=your_supabase_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
SUPABASE_SERVICE_KEY=your_supabase_service_key
# Service URLs
CPM_SERVICE_URL=http://localhost:8000
IM_SERVICE_URL=http://localhost:8001
# Production URLs (set in Vercel)
# CPM_SERVICE_URL=https://cpm.railway.app
# IM_SERVICE_URL=https://im.railway.app
Deployment Configuration¶
Vercel Configuration¶
// vercel.json
{
"buildCommand": "cd ../.. && pnpm build --filter=frontend",
"installCommand": "pnpm install",
"framework": "nextjs",
"outputDirectory": ".next",
"rewrites": [
{
"source": "/api/cpm/:path*",
"destination": "https://cpm.railway.app/:path*"
},
{
"source": "/api/im/:path*",
"destination": "https://im.railway.app/:path*"
}
]
}
V2 Enhancements¶
1. Advanced Content Editor¶
// components/content/AdvancedEditor.tsx
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Highlight from '@tiptap/extension-highlight'
import TaskList from '@tiptap/extension-task-list'
import TaskItem from '@tiptap/extension-task-item'
export function AdvancedEditor({ content, onChange }) {
const editor = useEditor({
extensions: [
StarterKit,
Highlight,
TaskList,
TaskItem,
],
content,
onUpdate: ({ editor }) => {
onChange(editor.getHTML())
},
})
return (
<div className="prose prose-sm max-w-none">
<EditorContent editor={editor} />
</div>
)
}
2. Collaborative Features¶
// Real-time collaboration using Supabase Presence
const presence = supabase.channel('content-collaboration')
.on('presence', { event: 'sync' }, () => {
const state = presence.presenceState()
// Update UI with active users
})
.on('broadcast', { event: 'cursor' }, ({ payload }) => {
// Update cursor positions
})
.subscribe()
3. Mobile App (React Native)¶
// Basic structure for mobile companion app
import { NavigationContainer } from '@react-navigation/native'
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'
const Tab = createBottomTabNavigator()
export default function App() {
return (
<NavigationContainer>
<Tab.Navigator>
<Tab.Screen name="Dashboard" component={DashboardScreen} />
<Tab.Screen name="Create" component={CreateScreen} />
<Tab.Screen name="History" component={HistoryScreen} />
</Tab.Navigator>
</NavigationContainer>
)
}
Performance Optimization¶
1. Code Splitting¶
// Dynamic imports for heavy components
const AnalyticsDashboard = dynamic(
() => import('@/components/dashboard/AnalyticsDashboard'),
{ ssr: false }
)
2. Data Fetching Optimization¶
// Use React Query for caching and prefetching
import { useQuery } from '@tanstack/react-query'
export function useRecentJobs() {
return useQuery({
queryKey: ['jobs', 'recent'],
queryFn: async () => {
const { data } = await supabase
.from('jobs')
.select('*')
.order('created_at', { ascending: false })
.limit(10)
return data
},
staleTime: 30000, // 30 seconds
refetchInterval: 60000, // 1 minute
})
}
Success Metrics¶
MVP Goals¶
- Page load time: <2s
- Time to interactive: <3s
- Lighthouse score: >90
- User satisfaction: >80%
V2 Goals¶
- Real-time updates: <100ms latency
- Mobile app adoption: 30% of users
- Collaborative features usage: 50% of teams
- Advanced editor adoption: 60% of content creators