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