Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
1d83987
feat: add form field validation to surveys using Zod schemas
antoniocapelo Jul 5, 2025
51391bb
feat: improve field accessibility by adding "Optional" indicator and …
antoniocapelo Jul 5, 2025
d32422d
chore: update notes
antoniocapelo Jul 5, 2025
ac54694
chore: update notes
antoniocapelo Jul 5, 2025
7c95ef5
chore: remove logs
antoniocapelo Jul 5, 2025
87ac276
chore: remove logs
antoniocapelo Jul 5, 2025
ede9490
chore: create reusable hook for zod validation
antoniocapelo Jul 5, 2025
8ae6cae
chore: adjust doc
antoniocapelo Jul 5, 2025
45b742f
feat: add validation to survey creation
antoniocapelo Jul 5, 2025
6fe3070
chore: update notes
antoniocapelo Jul 5, 2025
4098426
feat: add error summary in survey view
antoniocapelo Jul 5, 2025
74c9dde
feat: add loading component and use it across app
antoniocapelo Jul 5, 2025
e59d30f
feat: add progress
antoniocapelo Jul 5, 2025
618f07d
fix: fix logic in error summary
antoniocapelo Jul 5, 2025
526979e
fix: remove global error message
antoniocapelo Jul 5, 2025
839984f
feat: add progress bar checkbox on creation page
antoniocapelo Jul 5, 2025
deaa0ce
feat: refactor survey viewing and added "preview" mode
antoniocapelo Jul 5, 2025
6ea008f
feat: improved creation UX
antoniocapelo Jul 5, 2025
f859729
feat: table view and modified at
antoniocapelo Jul 5, 2025
e52c60a
chore: small refactor
antoniocapelo Jul 5, 2025
a883720
feat: layout persist and cleanusp
antoniocapelo Jul 5, 2025
20a9b4c
feat: search feature
antoniocapelo Jul 5, 2025
fda9ccb
chore: join create and edit into single form component
antoniocapelo Jul 5, 2025
2f61ca2
chore: UI refactor
antoniocapelo Jul 5, 2025
0f64612
chore: improved loading and added no results UI
antoniocapelo Jul 5, 2025
ef4d8a3
chore: update notes
antoniocapelo Jul 5, 2025
f8cd52f
chore: UI tweaks
antoniocapelo Jul 6, 2025
ea8791c
chore: table tweak
antoniocapelo Jul 6, 2025
0b4acad
feat: use actions popover
antoniocapelo Jul 6, 2025
f84a79f
chore: final refactor
antoniocapelo Jul 6, 2025
460f10a
chore: small tweaks
antoniocapelo Jul 7, 2025
9d4bbe3
chore: refactor
antoniocapelo Jul 7, 2025
3fca641
chore: remove repeated component
antoniocapelo Jul 7, 2025
2bc4143
feat: reorder questions
antoniocapelo Jul 7, 2025
4e95138
chore: fix deps
antoniocapelo Jul 7, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 38 additions & 72 deletions app/create/page.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
"use client";

import { QuestionBuilder } from "@/components/survey/question-builder";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { saveSurvey } from "@/lib/survey";
import { Question, Survey } from "@/types/survey";
import { SurveyForm } from "@/components/survey/survey-form";
import { useFormValidation } from "@/hooks/use-form-validation";
import { loadDraft, saveSurvey } from "@/lib/survey";
import { Survey, surveySchema } from "@/types/survey";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useEffect, useState } from "react";
import { toast } from "sonner";

export default function CreateSurvey() {
const router = useRouter();
Expand All @@ -18,80 +18,46 @@ export default function CreateSurvey() {
questions: [],
createdAt: new Date().toISOString(),
});
const { errors, validate, resetError } = useFormValidation(surveySchema);

useEffect(() => {
const savedDraft = loadDraft();
if (savedDraft) {
setSurvey(savedDraft);
}
}, []);

const addQuestion = () => {
const newQuestion: Question = {
id: crypto.randomUUID(),
type: "text",
text: "",
options: [],
required: false,
};
setSurvey({
...survey,
questions: [...survey.questions, newQuestion],
});
};

const updateQuestion = (updatedQuestion: Question) => {
setSurvey({
...survey,
questions: survey.questions.map((q) =>
q.id === updatedQuestion.id ? updatedQuestion : q
),
});
};

const deleteQuestion = (questionId: string) => {
setSurvey({
...survey,
questions: survey.questions.filter((q) => q.id !== questionId),
});
};

const handleSave = () => {
const isValid = validate(survey);
if (!isValid) {
return;
}
survey.modifiedAt = new Date().toISOString();
saveSurvey(survey);
toast.success(
<span>
Test saved successfully! You can view it{" "}
<Link className="text-primary underline" href={`/survey/${survey.id}`}>
here
</Link>
</span>
);
router.push("/");
};

return (
<div className="container mx-auto py-8">
<div className="max-w-3xl mx-auto">
<h1 className="text-3xl font-bold mb-8">Create New Test</h1>

<div className="space-y-4 mb-8">
<Input
placeholder="Test Title"
value={survey.title}
onChange={(e) => setSurvey({ ...survey, title: e.target.value })}
/>
<Textarea
placeholder="Test Description"
value={survey.description}
onChange={(e) =>
setSurvey({ ...survey, description: e.target.value })
}
/>
</div>

<div className="space-y-4 mb-8">
{survey.questions.map((question) => (
<QuestionBuilder
key={question.id}
question={question}
onUpdate={updateQuestion}
onDelete={deleteQuestion}
/>
))}
</div>

<div className="flex gap-4">
<Button onClick={addQuestion} variant="outline">
Add Question
</Button>
<Button onClick={handleSave}>Save Test</Button>
</div>
</div>
</div>
<SurveyForm
survey={survey}
setSurvey={setSurvey}
errors={errors}
resetError={resetError}
isLoading={false}
validate={validate}
onSave={handleSave}
mode="create"
/>
);
}
115 changes: 34 additions & 81 deletions app/edit/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,27 @@
"use client";

export const dynamic = 'force-dynamic';
export const dynamic = "force-dynamic";

import { QuestionBuilder } from "@/components/survey/question-builder";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { getSurveyById, saveSurvey } from "@/lib/survey";
import { Question, Survey } from "@/types/survey";
import { useFormValidation } from "@/hooks/use-form-validation";
import { getSurveyById, saveDraft, saveSurvey } from "@/lib/survey";
import { Question, Survey, surveySchema } from "@/types/survey";
import { SurveyForm } from "@/components/survey/survey-form";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { Dispatch, useEffect, useState } from "react";
import Loading from "@/components/ui/loading";
import { toast } from "sonner";

export default function EditSurvey() {
const router = useRouter();
const params = useParams();
const [survey, setSurvey] = useState<Survey | undefined>();
const [survey, setSurvey] = useState<Survey>();
const [isLoading, setIsLoading] = useState(true);
const { errors, validate, resetError } = useFormValidation(surveySchema);

useEffect(() => {
if (params.id) {
const loadedSurvey = getSurveyById(params.id as string);
setIsLoading(false);
if (loadedSurvey) {
setSurvey(loadedSurvey);
} else {
Expand All @@ -27,87 +30,37 @@ export default function EditSurvey() {
}
}, [params.id, router]);

const addQuestion = () => {
if (!survey) return;
const newQuestion: Question = {
id: crypto.randomUUID(),
type: "text",
text: "",
options: [],
required: false,
};
setSurvey({
...survey,
questions: [...survey.questions, newQuestion],
});
};

const updateQuestion = (updatedQuestion: Question) => {
if (!survey) return;
setSurvey({
...survey,
questions: survey.questions.map((q) =>
q.id === updatedQuestion.id ? updatedQuestion : q
),
});
};

const deleteQuestion = (questionId: string) => {
if (!survey) return;
setSurvey({
...survey,
questions: survey.questions.filter((q) => q.id !== questionId),
});
};

const handleSave = () => {
if (!survey) return;
const isValid = validate(survey);
if (!isValid) {
return;
}
survey.modifiedAt = new Date().toISOString();
saveSurvey(survey);
toast.success(' Survey saved successfully!');
router.push("/");
};

if (isLoading) {
return <Loading fullHeight text="Loading survey..." />;
}

if (!survey) {
return <div>Loading...</div>;
return null
}

return (
<div className="container mx-auto py-8">
<div className="max-w-3xl mx-auto">
<h1 className="text-3xl font-bold mb-8">Edit Test</h1>

<div className="space-y-4 mb-8">
<Input
placeholder="Test Title"
value={survey.title}
onChange={(e) => setSurvey({ ...survey, title: e.target.value })}
/>
<Textarea
placeholder="Test Description"
value={survey.description}
onChange={(e) =>
setSurvey({ ...survey, description: e.target.value })
}
/>
</div>

<div className="space-y-4 mb-8">
{survey.questions.map((question) => (
<QuestionBuilder
key={question.id}
question={question}
onUpdate={updateQuestion}
onDelete={deleteQuestion}
/>
))}
</div>

<div className="flex gap-4">
<Button onClick={addQuestion} variant="outline">
Add Question
</Button>
<Button onClick={handleSave}>Save Changes</Button>
</div>
</div>
</div>
<SurveyForm
survey={survey}
// Casting because at this point we know survey is defined
setSurvey={setSurvey as Dispatch<React.SetStateAction<Survey>>}
errors={errors}
validate={validate}
resetError={resetError}
isLoading={false}
onSave={handleSave}
mode="edit"
/>
);
}
6 changes: 4 additions & 2 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary: 226 71% 40%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
Expand All @@ -44,6 +44,7 @@
--chart-5: 27 87% 67%;
--radius: 0.5rem;
}

.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
Expand Down Expand Up @@ -76,7 +77,8 @@
* {
@apply border-border;
}

body {
@apply bg-background text-foreground;
}
}
}
39 changes: 38 additions & 1 deletion app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import './globals.css';
import type { Metadata } from 'next';
import Link from 'next/link';
import { Inter } from 'next/font/google';
import { Toaster, toast } from 'sonner';

const inter = Inter({ subsets: ['latin'] });

Expand All @@ -16,7 +18,42 @@ export default function RootLayout({
}) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
<body className={inter.className + " bg-[#f6f8fa] min-h-screen flex flex-col"}>
<Toaster />
{/* Header */}
<header className="bg-white shadow-sm rounded-b-xl px-8 py-3 flex items-center justify-between sticky top-0 z-30">
<div className="flex items-center gap-8">
{/* Logo and App Name */}
<div className="flex items-center gap-2">
<Link href="/" className="font-medium hover:text-black flex gap-2">
<img src="/logo.svg" alt="Survey Builder Logo" className="h-6 w-6" />
<span className="font-bold text-lg tracking-tight">Survey Builder</span>
</Link>
</div>
{/* Navigation Links */}
<nav className="hidden md:flex gap-6 text-sm text-gray-700">
<Link href="/" className="font-medium hover:text-black">My Surveys</Link>
</nav>
</div>
{/* <div className="flex-1 flex justify-center">
<div className="relative w-full max-w-xs">
<input
type="text"
placeholder="Search"
className="w-full pl-10 pr-4 py-2 rounded-md border border-gray-200 bg-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-200"
/>
<svg className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</div>
</div> */}
</header>
{/* Main Content */}
<main className="px-4 md:px-12 py-8 max-w-5xl mx-auto flex-1 flex flex-col w-full">
{children}
</main>
</body>
</html>
);
}
Loading