Skip to content

Commit dd1cf2a

Browse files
Zeff01claude
andcommitted
feat: Complete in-app survey system with response form and analytics
Survey Response Form (/home/surveys/[surveyId]): - Create user-facing form to fill out in-app surveys - Support all 8 question types with appropriate inputs: * Text, Textarea, Email, Number, Date * Multiple Choice (radio buttons) * Checkboxes (multiple selection) * Rating Scale (interactive buttons) - Form validation for required questions - Check if user already responded (prevent duplicates) - Prevent access to external surveys - Submit responses to survey_responses table with JSONB answers Survey Results Dashboard (/settings/surveys/[surveyId]/results): - View all survey responses and statistics - Display statistics cards: * Total responses * Unique respondents * Completion rate - Show per-question analytics: * Bar charts for multiple choice/checkbox * Average ratings for rating questions * List of text responses - Export results to CSV with all responses and answers - Admin-only access with RLS policies Builder Enhancements: - Add "View Results" button in survey builder - Navigate between builder and results pages This completes the full in-app survey workflow: 1. Admin creates survey and adds questions 2. Users fill out survey with dynamic form 3. Responses stored in JSONB format 4. Admin views analytics and exports data 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 7dfa8b5 commit dd1cf2a

4 files changed

Lines changed: 716 additions & 11 deletions

File tree

‎apps/codebility/app/home/settings/surveys/[surveyId]/page.tsx‎

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { useState, useEffect } from "react";
44
import { useParams, useRouter } from "next/navigation";
5-
import { Plus, Pencil, Trash2, GripVertical, ArrowLeft } from "lucide-react";
5+
import { Plus, Pencil, Trash2, GripVertical, ArrowLeft, BarChart3 } from "lucide-react";
66
import { Button } from "@codevs/ui/button";
77
import { H1 } from "@/components/shared/dashboard";
88
import PageContainer from "../../../_components/PageContainer";
@@ -197,16 +197,26 @@ export default function SurveyBuilderPage() {
197197
{survey?.description || "Build your survey questions"}
198198
</p>
199199
</div>
200-
<Button
201-
onClick={() => {
202-
setEditingQuestion(null);
203-
setShowForm(true);
204-
}}
205-
className="flex items-center gap-2 bg-gradient-to-r from-green-500 to-teal-500 hover:from-green-600 hover:to-teal-600"
206-
>
207-
<Plus className="h-4 w-4" />
208-
Add Question
209-
</Button>
200+
<div className="flex gap-3">
201+
<Button
202+
onClick={() => router.push(`/home/settings/surveys/${surveyId}/results`)}
203+
variant="outline"
204+
className="flex items-center gap-2"
205+
>
206+
<BarChart3 className="h-4 w-4" />
207+
View Results
208+
</Button>
209+
<Button
210+
onClick={() => {
211+
setEditingQuestion(null);
212+
setShowForm(true);
213+
}}
214+
className="flex items-center gap-2 bg-gradient-to-r from-green-500 to-teal-500 hover:from-green-600 hover:to-teal-600"
215+
>
216+
<Plus className="h-4 w-4" />
217+
Add Question
218+
</Button>
219+
</div>
210220
</div>
211221

212222
{/* Question Form */}
Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
1+
"use client";
2+
3+
import { useState, useEffect } from "react";
4+
import { useParams, useRouter } from "next/navigation";
5+
import { ArrowLeft, Download, Users, CheckCircle } from "lucide-react";
6+
import { Button } from "@codevs/ui/button";
7+
import { H1 } from "@/components/shared/dashboard";
8+
import PageContainer from "../../../../_components/PageContainer";
9+
import { createClientClientComponent } from "@/utils/supabase/client";
10+
import { toast } from "sonner";
11+
import { getSurveyQuestions } from "../../../questions/actions";
12+
import { getSurveyResponses, getSurveyStatistics } from "../../../responses/actions";
13+
14+
interface Question {
15+
id: string;
16+
question_text: string;
17+
question_type: string;
18+
options: string[];
19+
}
20+
21+
interface Response {
22+
id: string;
23+
answers: Record<string, any>;
24+
submitted_at: string;
25+
respondent: {
26+
first_name: string;
27+
last_name: string;
28+
email_address: string;
29+
} | null;
30+
respondent_email: string | null;
31+
}
32+
33+
interface Statistics {
34+
total_responses: number;
35+
completed_responses: number;
36+
unique_respondents: number;
37+
last_response_at: string;
38+
}
39+
40+
export default function SurveyResultsPage() {
41+
const params = useParams();
42+
const router = useRouter();
43+
const surveyId = params.surveyId as string;
44+
45+
const [survey, setSurvey] = useState<any>(null);
46+
const [questions, setQuestions] = useState<Question[]>([]);
47+
const [responses, setResponses] = useState<Response[]>([]);
48+
const [statistics, setStatistics] = useState<Statistics | null>(null);
49+
const [loading, setLoading] = useState(true);
50+
51+
useEffect(() => {
52+
fetchData();
53+
}, [surveyId]);
54+
55+
const fetchData = async () => {
56+
setLoading(true);
57+
58+
// Fetch survey
59+
const supabase = createClientClientComponent();
60+
const { data: surveyData } = await supabase
61+
.from("surveys")
62+
.select("id, title, description")
63+
.eq("id", surveyId)
64+
.single();
65+
66+
setSurvey(surveyData);
67+
68+
// Fetch questions
69+
const questionsResult = await getSurveyQuestions(surveyId);
70+
if (questionsResult.data) {
71+
setQuestions(questionsResult.data);
72+
}
73+
74+
// Fetch responses
75+
const responsesResult = await getSurveyResponses(surveyId);
76+
if (responsesResult.data) {
77+
setResponses(responsesResult.data);
78+
}
79+
80+
// Fetch statistics
81+
const statsResult = await getSurveyStatistics(surveyId);
82+
if (statsResult.data) {
83+
setStatistics(statsResult.data);
84+
}
85+
86+
setLoading(false);
87+
};
88+
89+
const getAnswerSummary = (question: Question) => {
90+
const questionResponses = responses.map((r) => r.answers[question.id]).filter(Boolean);
91+
92+
if (question.question_type === "multiple_choice") {
93+
const counts: Record<string, number> = {};
94+
questionResponses.forEach((answer) => {
95+
counts[answer] = (counts[answer] || 0) + 1;
96+
});
97+
return counts;
98+
}
99+
100+
if (question.question_type === "checkbox") {
101+
const counts: Record<string, number> = {};
102+
questionResponses.forEach((answer) => {
103+
if (Array.isArray(answer)) {
104+
answer.forEach((item) => {
105+
counts[item] = (counts[item] || 0) + 1;
106+
});
107+
}
108+
});
109+
return counts;
110+
}
111+
112+
if (question.question_type === "rating") {
113+
const ratings = questionResponses as number[];
114+
const average = ratings.length
115+
? (ratings.reduce((sum, r) => sum + r, 0) / ratings.length).toFixed(1)
116+
: "N/A";
117+
return { average, count: ratings.length };
118+
}
119+
120+
return questionResponses;
121+
};
122+
123+
const exportToCSV = () => {
124+
if (responses.length === 0) {
125+
toast.error("No responses to export");
126+
return;
127+
}
128+
129+
const headers = [
130+
"Submitted At",
131+
"Respondent",
132+
...questions.map((q) => q.question_text),
133+
];
134+
135+
const rows = responses.map((response) => {
136+
const respondentName = response.respondent
137+
? `${response.respondent.first_name} ${response.respondent.last_name}`
138+
: response.respondent_email || "Anonymous";
139+
140+
return [
141+
new Date(response.submitted_at).toLocaleString(),
142+
respondentName,
143+
...questions.map((q) => {
144+
const answer = response.answers[q.id];
145+
if (Array.isArray(answer)) return answer.join(", ");
146+
return answer || "";
147+
}),
148+
];
149+
});
150+
151+
const csv = [headers, ...rows].map((row) => row.join(",")).join("\n");
152+
const blob = new Blob([csv], { type: "text/csv" });
153+
const url = URL.createObjectURL(blob);
154+
const a = document.createElement("a");
155+
a.href = url;
156+
a.download = `survey-${surveyId}-results.csv`;
157+
a.click();
158+
URL.revokeObjectURL(url);
159+
toast.success("Results exported successfully");
160+
};
161+
162+
if (loading) {
163+
return (
164+
<PageContainer>
165+
<div className="flex items-center justify-center h-64">
166+
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-green-600"></div>
167+
</div>
168+
</PageContainer>
169+
);
170+
}
171+
172+
return (
173+
<PageContainer maxWidth="6xl">
174+
{/* Header */}
175+
<div className="flex items-center gap-4 mb-6">
176+
<Button
177+
variant="ghost"
178+
onClick={() => router.push(`/home/settings/surveys/${surveyId}`)}
179+
className="text-gray-600 dark:text-gray-400"
180+
>
181+
<ArrowLeft className="h-4 w-4 mr-2" />
182+
Back to Builder
183+
</Button>
184+
</div>
185+
186+
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-6">
187+
<div>
188+
<H1 className="bg-gradient-to-r from-green-600 to-teal-600 bg-clip-text text-transparent">
189+
{survey?.title} - Results
190+
</H1>
191+
<p className="text-gray-600 dark:text-gray-400 mt-1">
192+
Survey responses and analytics
193+
</p>
194+
</div>
195+
<Button
196+
onClick={exportToCSV}
197+
disabled={responses.length === 0}
198+
className="flex items-center gap-2 bg-gradient-to-r from-green-500 to-teal-500 hover:from-green-600 hover:to-teal-600"
199+
>
200+
<Download className="h-4 w-4" />
201+
Export CSV
202+
</Button>
203+
</div>
204+
205+
{/* Statistics Cards */}
206+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
207+
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
208+
<div className="flex items-center gap-3">
209+
<div className="h-12 w-12 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
210+
<CheckCircle className="h-6 w-6 text-blue-600 dark:text-blue-400" />
211+
</div>
212+
<div>
213+
<p className="text-sm text-gray-600 dark:text-gray-400">Total Responses</p>
214+
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
215+
{statistics?.total_responses || 0}
216+
</p>
217+
</div>
218+
</div>
219+
</div>
220+
221+
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
222+
<div className="flex items-center gap-3">
223+
<div className="h-12 w-12 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
224+
<Users className="h-6 w-6 text-green-600 dark:text-green-400" />
225+
</div>
226+
<div>
227+
<p className="text-sm text-gray-600 dark:text-gray-400">Unique Respondents</p>
228+
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
229+
{statistics?.unique_respondents || 0}
230+
</p>
231+
</div>
232+
</div>
233+
</div>
234+
235+
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
236+
<div className="flex items-center gap-3">
237+
<div className="h-12 w-12 rounded-full bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
238+
<span className="text-2xl">📊</span>
239+
</div>
240+
<div>
241+
<p className="text-sm text-gray-600 dark:text-gray-400">Completion Rate</p>
242+
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
243+
{statistics?.total_responses
244+
? Math.round(
245+
((statistics.completed_responses || 0) / statistics.total_responses) * 100
246+
)
247+
: 0}
248+
%
249+
</p>
250+
</div>
251+
</div>
252+
</div>
253+
</div>
254+
255+
{/* Question Results */}
256+
<div className="space-y-6">
257+
{questions.map((question, index) => {
258+
const summary = getAnswerSummary(question);
259+
260+
return (
261+
<div
262+
key={question.id}
263+
className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6"
264+
>
265+
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
266+
{index + 1}. {question.question_text}
267+
</h3>
268+
269+
{/* Multiple Choice / Checkbox Results */}
270+
{(question.question_type === "multiple_choice" ||
271+
question.question_type === "checkbox") && (
272+
<div className="space-y-3">
273+
{Object.entries(summary as Record<string, number>)
274+
.sort(([, a], [, b]) => b - a)
275+
.map(([option, count]) => {
276+
const percentage =
277+
((count / responses.length) * 100).toFixed(1);
278+
return (
279+
<div key={option}>
280+
<div className="flex justify-between mb-1">
281+
<span className="text-sm text-gray-700 dark:text-gray-300">
282+
{option}
283+
</span>
284+
<span className="text-sm text-gray-600 dark:text-gray-400">
285+
{count} ({percentage}%)
286+
</span>
287+
</div>
288+
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
289+
<div
290+
className="bg-gradient-to-r from-green-500 to-teal-500 h-2 rounded-full"
291+
style={{ width: `${percentage}%` }}
292+
/>
293+
</div>
294+
</div>
295+
);
296+
})}
297+
</div>
298+
)}
299+
300+
{/* Rating Results */}
301+
{question.question_type === "rating" && (
302+
<div className="text-center p-4 bg-gray-50 dark:bg-gray-900/50 rounded-lg">
303+
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-1">
304+
{(summary as any).average}
305+
</p>
306+
<p className="text-sm text-gray-600 dark:text-gray-400">
307+
Average rating from {(summary as any).count} responses
308+
</p>
309+
</div>
310+
)}
311+
312+
{/* Text Responses */}
313+
{(question.question_type === "text" ||
314+
question.question_type === "textarea" ||
315+
question.question_type === "email") && (
316+
<div className="space-y-2 max-h-64 overflow-y-auto">
317+
{(summary as string[]).map((answer, i) => (
318+
<div
319+
key={i}
320+
className="p-3 bg-gray-50 dark:bg-gray-900/50 rounded text-sm text-gray-700 dark:text-gray-300"
321+
>
322+
{answer}
323+
</div>
324+
))}
325+
</div>
326+
)}
327+
</div>
328+
);
329+
})}
330+
</div>
331+
</PageContainer>
332+
);
333+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import PageContainer from "../../_components/PageContainer";
2+
3+
export default function Loading() {
4+
return (
5+
<PageContainer>
6+
<div className="flex items-center justify-center h-64">
7+
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-green-600"></div>
8+
</div>
9+
</PageContainer>
10+
);
11+
}

0 commit comments

Comments
 (0)