feat(admin): Phase 2.3 - Survey builder UI

- surveys/[id]/page.tsx: Full survey builder with 9 question types
- Questions tab: Add/edit/remove questions with drag handles
- 9 question types: single/multiple choice, rating, NPS, text, dropdown, scale, ranking
- Dynamic options management for choice-based questions
- Settings tab: Display trigger selection, incentive configuration
- Targeting tab: Platform and segment targeting
This commit is contained in:
saravanakumardb1 2026-03-03 07:30:10 -08:00
parent dbc9ffd328
commit bd8c6fde43

View File

@ -0,0 +1,487 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import {
apiCreateSurvey,
apiGetSurvey,
apiUpdateSurvey,
type ApiSurvey,
} from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useToast } from '@/components/ui/toast';
import { ArrowLeft, Plus, Trash2, GripVertical, Loader2 } from 'lucide-react';
import Link from 'next/link';
const QUESTION_TYPES = [
{ id: 'single_choice', label: 'Single Choice', icon: '○' },
{ id: 'multiple_choice', label: 'Multiple Choice', icon: '☐' },
{ id: 'rating', label: 'Rating (1-10)', icon: '★' },
{ id: 'nps', label: 'NPS (0-10)', icon: '📊' },
{ id: 'text_short', label: 'Short Text', icon: '📝' },
{ id: 'text_long', label: 'Long Text', icon: '📄' },
{ id: 'dropdown', label: 'Dropdown', icon: '▼' },
{ id: 'scale', label: 'Scale', icon: '↔' },
{ id: 'ranking', label: 'Ranking', icon: '⇅' },
] as const;
const PLATFORMS = ['ios', 'android', 'web', 'macos', 'windows'] as const;
const SEGMENTS = ['free', 'pro', 'enterprise', 'churned', 'active'] as const;
const TRIGGERS = ['immediate', 'delay_seconds', 'event', 'page_view'] as const;
interface Question {
id: string;
type: typeof QUESTION_TYPES[number]['id'];
text: string;
description: string;
required: boolean;
options: { id: string; text: string }[];
}
export default function SurveyEditorPage() {
const params = useParams();
const router = useRouter();
const { toast } = useToast();
const isEdit = params.id && params.id !== 'new';
const surveyId = isEdit ? (params.id as string) : null;
const [loading, setLoading] = useState(isEdit);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({
title: '',
description: '',
questions: [] as Question[],
target: {
platforms: [] as string[],
userSegments: [] as string[],
},
displayTrigger: { type: 'immediate' as typeof TRIGGERS[number] },
incentive: null as { type: 'pro_days' | 'credits'; amount: number } | null,
});
useEffect(() => {
if (surveyId) {
loadSurvey();
}
}, [surveyId]);
async function loadSurvey() {
try {
const { data, error } = await apiGetSurvey(surveyId!);
if (error) throw new Error(error);
if (data) {
setForm({
title: data.title,
description: data.description || '',
questions: data.questions as Question[],
target: {
platforms: data.target.platforms || [],
userSegments: data.target.userSegments || [],
},
displayTrigger: data.displayTrigger,
incentive: data.incentive || null,
});
}
} catch (err) {
toast({
title: 'Error',
description: err instanceof Error ? err.message : 'Failed to load survey',
variant: 'error',
});
} finally {
setLoading(false);
}
}
function addQuestion(type: Question['type']) {
const newQuestion: Question = {
id: `q_${Date.now()}`,
type,
text: '',
description: '',
required: true,
options: ['single_choice', 'multiple_choice', 'dropdown', 'ranking'].includes(type)
? [
{ id: `opt_${Date.now()}_1`, text: 'Option 1' },
{ id: `opt_${Date.now()}_2`, text: 'Option 2' },
]
: [],
};
setForm({ ...form, questions: [...form.questions, newQuestion] });
}
function updateQuestion(index: number, updates: Partial<Question>) {
const updated = [...form.questions];
updated[index] = { ...updated[index], ...updates };
setForm({ ...form, questions: updated });
}
function removeQuestion(index: number) {
const updated = [...form.questions];
updated.splice(index, 1);
setForm({ ...form, questions: updated });
}
function addOption(questionIndex: number) {
const question = form.questions[questionIndex];
const newOption = {
id: `opt_${Date.now()}_${question.options.length + 1}`,
text: `Option ${question.options.length + 1}`,
};
updateQuestion(questionIndex, {
options: [...question.options, newOption],
});
}
function updateOption(questionIndex: number, optionIndex: number, text: string) {
const question = form.questions[questionIndex];
const updatedOptions = [...question.options];
updatedOptions[optionIndex] = { ...updatedOptions[optionIndex], text };
updateQuestion(questionIndex, { options: updatedOptions });
}
function removeOption(questionIndex: number, optionIndex: number) {
const question = form.questions[questionIndex];
const updatedOptions = [...question.options];
updatedOptions.splice(optionIndex, 1);
updateQuestion(questionIndex, { options: updatedOptions });
}
async function handleSave(asDraft = false) {
setSaving(true);
try {
const payload = {
...form,
status: asDraft ? 'draft' : 'active',
};
if (isEdit) {
const { error } = await apiUpdateSurvey(surveyId!, payload as unknown as Partial<ApiSurvey>);
if (error) throw new Error(error);
toast({ title: 'Success', description: 'Survey updated', variant: 'success' });
} else {
const { error } = await apiCreateSurvey(payload as unknown as Omit<ApiSurvey, 'id' | 'metrics' | 'createdAt' | 'updatedAt' | 'createdBy'>);
if (error) throw new Error(error);
toast({ title: 'Success', description: 'Survey created', variant: 'success' });
}
router.push('/surveys');
} catch (err) {
toast({
title: 'Error',
description: err instanceof Error ? err.message : 'Failed to save',
variant: 'error',
});
} finally {
setSaving(false);
}
}
if (loading) {
return (
<div className="container mx-auto py-8">
<div className="animate-pulse space-y-4">
<div className="h-8 w-48 bg-muted rounded" />
<div className="h-64 bg-muted rounded" />
</div>
</div>
);
}
return (
<div className="container mx-auto py-8 space-y-6">
<div className="flex items-center gap-4">
<Link href="/surveys">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-3xl font-bold">{isEdit ? 'Edit Survey' : 'New Survey'}</h1>
<p className="text-muted-foreground">
{isEdit ? 'Update your survey questions' : 'Create an in-app survey with conditional logic'}
</p>
</div>
</div>
<Tabs defaultValue="questions" className="space-y-6">
<TabsList>
<TabsTrigger value="questions">Questions</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
<TabsTrigger value="targeting">Targeting</TabsTrigger>
</TabsList>
<TabsContent value="questions" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Survey Details</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Title *</Label>
<Input
id="title"
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
placeholder="Enter survey title"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
placeholder="Enter survey description"
rows={2}
/>
</div>
</CardContent>
</Card>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium">Questions ({form.questions.length})</h3>
<div className="flex gap-2">
{QUESTION_TYPES.map((type) => (
<Button
key={type.id}
variant="outline"
size="sm"
onClick={() => addQuestion(type.id)}
>
<Plus className="mr-1 h-3 w-3" />
{type.label}
</Button>
))}
</div>
</div>
{form.questions.map((question, index) => (
<Card key={question.id}>
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
<GripVertical className="h-4 w-4 text-muted-foreground cursor-move" />
<Badge variant="outline">{QUESTION_TYPES.find((t) => t.id === question.type)?.label}</Badge>
<span className="text-sm text-muted-foreground">Q{index + 1}</span>
<Button
variant="ghost"
size="icon"
className="ml-auto h-8 w-8 text-destructive"
onClick={() => removeQuestion(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Question Text *</Label>
<Input
value={question.text}
onChange={(e) => updateQuestion(index, { text: e.target.value })}
placeholder="Enter your question"
/>
</div>
<div className="space-y-2">
<Label>Description (optional)</Label>
<Input
value={question.description}
onChange={(e) => updateQuestion(index, { description: e.target.value })}
placeholder="Additional context for this question"
/>
</div>
{question.options.length > 0 && (
<div className="space-y-2">
<Label>Options</Label>
<div className="space-y-2">
{question.options.map((option, optIndex) => (
<div key={option.id} className="flex items-center gap-2">
<Input
value={option.text}
onChange={(e) => updateOption(index, optIndex, e.target.value)}
placeholder={`Option ${optIndex + 1}`}
/>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => removeOption(index, optIndex)}
disabled={question.options.length <= 2}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<Button variant="outline" size="sm" onClick={() => addOption(index)}>
<Plus className="mr-1 h-3 w-3" />
Add Option
</Button>
</div>
</div>
)}
</CardContent>
</Card>
))}
{form.questions.length === 0 && (
<Card className="border-dashed">
<CardContent className="py-8 text-center text-muted-foreground">
<p>No questions yet. Click a question type above to add one.</p>
</CardContent>
</Card>
)}
</div>
</TabsContent>
<TabsContent value="settings" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Display Settings</CardTitle>
<CardDescription>When and how to show this survey</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Display Trigger</Label>
<div className="flex flex-wrap gap-2">
{TRIGGERS.map((trigger) => (
<Badge
key={trigger}
variant={form.displayTrigger.type === trigger ? 'default' : 'outline'}
className="cursor-pointer"
onClick={() =>
setForm({
...form,
displayTrigger: { type: trigger } as ApiSurvey['displayTrigger'],
})
}
>
{trigger.replace('_', ' ')}
</Badge>
))}
</div>
</div>
<div className="space-y-2">
<Label>Incentive (optional)</Label>
<div className="flex gap-4">
<div className="flex items-center gap-2">
<Input
type="number"
min={0}
value={form.incentive?.amount || ''}
onChange={(e) =>
setForm({
...form,
incentive: {
type: 'pro_days',
amount: parseInt(e.target.value) || 0,
},
})
}
placeholder="Amount"
className="w-24"
/>
<span className="text-sm text-muted-foreground">Pro Days</span>
</div>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="targeting" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Audience Targeting</CardTitle>
<CardDescription>Who should see this survey</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Platforms</Label>
<div className="flex flex-wrap gap-2">
{PLATFORMS.map((platform) => (
<Badge
key={platform}
variant={form.target.platforms.includes(platform) ? 'default' : 'outline'}
className="cursor-pointer"
onClick={() =>
setForm({
...form,
target: {
...form.target,
platforms: form.target.platforms.includes(platform)
? form.target.platforms.filter((p) => p !== platform)
: [...form.target.platforms, platform],
},
})
}
>
{platform}
</Badge>
))}
</div>
</div>
<div className="space-y-2">
<Label>User Segments</Label>
<div className="flex flex-wrap gap-2">
{SEGMENTS.map((segment) => (
<Badge
key={segment}
variant={form.target.userSegments.includes(segment) ? 'default' : 'outline'}
className="cursor-pointer"
onClick={() =>
setForm({
...form,
target: {
...form.target,
userSegments: form.target.userSegments.includes(segment)
? form.target.userSegments.filter((s) => s !== segment)
: [...form.target.userSegments, segment],
},
})
}
>
{segment}
</Badge>
))}
</div>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
<div className="flex items-center justify-end gap-4">
<Link href="/surveys">
<Button variant="outline" disabled={saving}>
Cancel
</Button>
</Link>
<Button
variant="secondary"
onClick={() => handleSave(true)}
disabled={saving || !form.title || form.questions.length === 0}
>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Save as Draft
</Button>
<Button
onClick={() => handleSave(false)}
disabled={saving || !form.title || form.questions.length === 0}
>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{isEdit ? 'Update Survey' : 'Create Survey'}
</Button>
</div>
</div>
);
}