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:
parent
dbc9ffd328
commit
bd8c6fde43
487
dashboards/admin-web/src/app/(dashboard)/surveys/[id]/page.tsx
Normal file
487
dashboards/admin-web/src/app/(dashboard)/surveys/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user