chore: remove unused UI components, dummy data, and theme provider
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import { createContext, useContext, useState, useEffect, type ReactNode } from "react"
|
||||
import { useNavigate } from "react-router"
|
||||
import { type User, getUser, getUserAsync, logout as logoutUser } from "@/lib/auth"
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null
|
||||
loading: boolean
|
||||
logout: () => void
|
||||
refreshUser: () => void
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null)
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
async function init() {
|
||||
let currentUser = getUser()
|
||||
if (!currentUser) {
|
||||
currentUser = await getUserAsync()
|
||||
}
|
||||
setUser(currentUser)
|
||||
setLoading(false)
|
||||
}
|
||||
init()
|
||||
}, [])
|
||||
|
||||
async function logout() {
|
||||
await logoutUser()
|
||||
setUser(null)
|
||||
navigate("/login")
|
||||
}
|
||||
|
||||
function refreshUser() {
|
||||
const currentUser = getUser()
|
||||
setUser(currentUser)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, logout, refreshUser }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext)
|
||||
if (!context) {
|
||||
throw new Error("useAuth must be used within AuthProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
+130
-8
@@ -1,23 +1,92 @@
|
||||
import { Link, useParams } from "react-router"
|
||||
import { useState, useEffect } from "react"
|
||||
import { Link, useParams, useNavigate } from "react-router"
|
||||
import {
|
||||
ArrowLeft,
|
||||
Calendar,
|
||||
FileText,
|
||||
Lock,
|
||||
MessageSquare,
|
||||
Pencil,
|
||||
Trash2,
|
||||
} from "lucide-react"
|
||||
import { dummyForms } from "@/lib/dummy-data"
|
||||
import { Navbar } from "@/components/shared/navbar"
|
||||
import { Footer } from "@/components/shared/footer"
|
||||
import { FormButton } from "@/components/shared/form-button"
|
||||
import { QuestionPreview } from "@/components/forms/question-preview"
|
||||
import { getFormById, deleteForm } from "@/lib/api"
|
||||
import { useAuth } from "@/app/context/auth-context"
|
||||
import type { FormDetail } from "@/lib/types"
|
||||
|
||||
export default function FormPreviewPage() {
|
||||
const { id } = useParams()
|
||||
const form = dummyForms.find((f) => f.id === id)
|
||||
const navigate = useNavigate()
|
||||
const { user } = useAuth()
|
||||
const [form, setForm] = useState<FormDetail | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
if (!form) {
|
||||
throw new Response("Form not found", { status: 404 })
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
|
||||
async function fetchForm() {
|
||||
try {
|
||||
const data = await getFormById(id!)
|
||||
setForm(data)
|
||||
} catch (err) {
|
||||
if (err instanceof Response && err.status === 404) {
|
||||
throw err
|
||||
}
|
||||
setError("Failed to load form. Please try again.")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchForm()
|
||||
}, [id])
|
||||
|
||||
async function handleDelete() {
|
||||
if (!id) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
await deleteForm(id)
|
||||
navigate("/forms")
|
||||
} catch {
|
||||
setError("Failed to delete form.")
|
||||
setShowDeleteConfirm(false)
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !form) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-background">
|
||||
<Navbar />
|
||||
<main className="flex flex-1 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-destructive">{error ?? "Form not found"}</p>
|
||||
<Link
|
||||
to="/forms"
|
||||
className="mt-4 inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to forms
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -59,17 +128,38 @@ export default function FormPreviewPage() {
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
{form.responseCount} responses
|
||||
{form.response_count} responses
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
Created {form.createdAt}
|
||||
Created {new Date(form.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
Updated {form.updatedAt}
|
||||
Updated {new Date(form.updated_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{user && user.id === form.user_id && (
|
||||
<div className="mt-5 flex items-center gap-3 border-t border-border pt-5">
|
||||
<Link to={`/form/${id}/edit`}>
|
||||
<FormButton type="button" variant="ghost" size="sm">
|
||||
<Pencil className="h-4 w-4" />
|
||||
Edit
|
||||
</FormButton>
|
||||
</Link>
|
||||
<FormButton
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</FormButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -103,6 +193,38 @@ export default function FormPreviewPage() {
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{showDeleteConfirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="mx-4 w-full max-w-sm rounded-xl border border-border bg-card p-6 shadow-lg">
|
||||
<h2 className="text-lg font-semibold text-foreground">Delete Form</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Are you sure you want to delete this form? This action cannot be
|
||||
undone.
|
||||
</p>
|
||||
<div className="mt-6 flex items-center justify-end gap-3">
|
||||
<FormButton
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
disabled={deleting}
|
||||
>
|
||||
Cancel
|
||||
</FormButton>
|
||||
<FormButton
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleting ? "Deleting..." : "Delete"}
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
|
||||
+63
-11
@@ -1,18 +1,56 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useState, useEffect } from "react"
|
||||
import { useNavigate, Link } from "react-router"
|
||||
import { Plus, Search } from "lucide-react"
|
||||
import { dummyForms } from "@/lib/dummy-data"
|
||||
import { Navbar } from "@/components/shared/navbar"
|
||||
import { Footer } from "@/components/shared/footer"
|
||||
import { FormInput } from "@/components/shared/form-input"
|
||||
import { FormButton } from "@/components/shared/form-button"
|
||||
import { FormCard } from "@/components/forms/form-card"
|
||||
import { useAuth } from "@/app/context/auth-context"
|
||||
import { getForms } from "@/lib/api"
|
||||
import type { FormSummary } from "@/lib/types"
|
||||
|
||||
export default function FormsPage() {
|
||||
const { user, loading } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [search, setSearch] = useState("")
|
||||
const [forms, setForms] = useState<FormSummary[]>([])
|
||||
const [loadingForms, setLoadingForms] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const filtered = dummyForms.filter((form) => {
|
||||
useEffect(() => {
|
||||
if (!loading && !user) {
|
||||
navigate("/login")
|
||||
}
|
||||
}, [user, loading, navigate])
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return
|
||||
|
||||
async function fetchForms() {
|
||||
try {
|
||||
const data = await getForms()
|
||||
setForms(data)
|
||||
} catch {
|
||||
setError("Failed to load forms. Please try again.")
|
||||
} finally {
|
||||
setLoadingForms(false)
|
||||
}
|
||||
}
|
||||
fetchForms()
|
||||
}, [user])
|
||||
|
||||
if (loading || loadingForms) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) return null
|
||||
|
||||
const filtered = forms.filter((form) => {
|
||||
return (
|
||||
form.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||
form.description.toLowerCase().includes(search.toLowerCase())
|
||||
@@ -32,10 +70,12 @@ export default function FormsPage() {
|
||||
Manage and preview all your forms in one place
|
||||
</p>
|
||||
</div>
|
||||
<FormButton size="md">
|
||||
<Plus className="h-4 w-4" />
|
||||
New Form
|
||||
</FormButton>
|
||||
<Link to="/forms/new">
|
||||
<FormButton size="md">
|
||||
<Plus className="h-4 w-4" />
|
||||
New Form
|
||||
</FormButton>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
@@ -51,10 +91,22 @@ export default function FormsPage() {
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
Showing {filtered.length} of {dummyForms.length} forms
|
||||
Showing {filtered.length} of {forms.length} forms
|
||||
</p>
|
||||
|
||||
{filtered.length > 0 ? (
|
||||
{error ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border py-16">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
<FormButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Retry
|
||||
</FormButton>
|
||||
</div>
|
||||
) : filtered.length > 0 ? (
|
||||
<div className="grid gap-5 sm:grid-cols-2">
|
||||
{filtered.map((form) => (
|
||||
<FormCard key={form.id} form={form} />
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export function meta() {
|
||||
return [{ title: "404 - Page Not Found" }];
|
||||
}
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div style={{ textAlign: "center", padding: "4rem" }}>
|
||||
<h1>404</h1>
|
||||
<p>Page not found.</p>
|
||||
<a href="/">Go home</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,12 +4,12 @@ import {
|
||||
FileText,
|
||||
MessageSquare,
|
||||
} from "lucide-react"
|
||||
import type { Form } from "@/lib/dummy-data"
|
||||
import type { FormSummary } from "@/lib/types"
|
||||
import { FormButton } from "@/components/shared/form-button"
|
||||
import { Link } from "react-router";
|
||||
|
||||
interface FormCardProps {
|
||||
form: Form
|
||||
form: FormSummary
|
||||
}
|
||||
|
||||
export function FormCard({ form }: FormCardProps) {
|
||||
@@ -29,22 +29,18 @@ export function FormCard({ form }: FormCardProps) {
|
||||
</p>
|
||||
|
||||
<div className="mt-auto flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
{form.questions.length} questions
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
{form.responseCount} responses
|
||||
{form.response_count} responses
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
{form.updatedAt}
|
||||
{new Date(form.updated_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 border-t border-border pt-4">
|
||||
<Link to={`/forms/${form.id}`}>
|
||||
<Link to={`/form/${form.id}`}>
|
||||
<FormButton variant="outline" size="sm" className="w-full">
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
Preview Form
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import { useState, useRef } from "react"
|
||||
import {
|
||||
GripVertical,
|
||||
Plus,
|
||||
Trash2,
|
||||
ChevronDown,
|
||||
} from "lucide-react"
|
||||
import { FormInput } from "@/components/shared/form-input"
|
||||
import type { QuestionType, CreateQuestion } from "@/lib/types"
|
||||
|
||||
const QUESTION_TYPES: { value: QuestionType; label: string }[] = [
|
||||
{ value: "short_text", label: "Short Text" },
|
||||
{ value: "long_text", label: "Long Text" },
|
||||
{ value: "multiple_choice", label: "Multiple Choice" },
|
||||
{ value: "checkbox", label: "Checkbox" },
|
||||
{ value: "dropdown", label: "Dropdown" },
|
||||
{ value: "date", label: "Date" },
|
||||
{ value: "rating", label: "Rating" },
|
||||
]
|
||||
|
||||
const TYPES_WITH_OPTIONS: QuestionType[] = ["multiple_choice", "checkbox", "dropdown"]
|
||||
|
||||
interface QuestionEditorProps {
|
||||
questions: CreateQuestion[]
|
||||
onChange: (questions: CreateQuestion[]) => void
|
||||
}
|
||||
|
||||
function reposition(questions: CreateQuestion[]): CreateQuestion[] {
|
||||
return questions.map((q, i) => ({ ...q, position: i + 1 }))
|
||||
}
|
||||
|
||||
export function QuestionEditor({ questions, onChange }: QuestionEditorProps) {
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null)
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
|
||||
const dragNode = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
function updateQuestion(index: number, updates: Partial<CreateQuestion>) {
|
||||
const updated = questions.map((q, i) => {
|
||||
if (i !== index) return q
|
||||
const merged = { ...q, ...updates }
|
||||
if (updates.type && !TYPES_WITH_OPTIONS.includes(merged.type)) {
|
||||
merged.options = []
|
||||
}
|
||||
if (
|
||||
updates.type &&
|
||||
TYPES_WITH_OPTIONS.includes(merged.type) &&
|
||||
merged.options.length === 0
|
||||
) {
|
||||
merged.options = [{ label: "", position: 1 }]
|
||||
}
|
||||
return merged
|
||||
})
|
||||
onChange(updated)
|
||||
}
|
||||
|
||||
function addQuestion() {
|
||||
onChange([
|
||||
...questions,
|
||||
{
|
||||
type: "short_text",
|
||||
title: "",
|
||||
required: false,
|
||||
position: questions.length + 1,
|
||||
options: [],
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
function removeQuestion(index: number) {
|
||||
if (questions.length <= 1) return
|
||||
onChange(reposition(questions.filter((_, i) => i !== index)))
|
||||
}
|
||||
|
||||
function updateOption(qIndex: number, oIndex: number, label: string) {
|
||||
const updated = questions.map((q, i) => {
|
||||
if (i !== qIndex) return q
|
||||
const options = q.options.map((o, j) =>
|
||||
j === oIndex ? { ...o, label } : o
|
||||
)
|
||||
return { ...q, options }
|
||||
})
|
||||
onChange(updated)
|
||||
}
|
||||
|
||||
function addOption(qIndex: number) {
|
||||
const updated = questions.map((q, i) => {
|
||||
if (i !== qIndex) return q
|
||||
return {
|
||||
...q,
|
||||
options: [...q.options, { label: "", position: q.options.length + 1 }],
|
||||
}
|
||||
})
|
||||
onChange(updated)
|
||||
}
|
||||
|
||||
function removeOption(qIndex: number, oIndex: number) {
|
||||
const updated = questions.map((q, i) => {
|
||||
if (i !== qIndex) return q
|
||||
const options = q.options
|
||||
.filter((_, j) => j !== oIndex)
|
||||
.map((o, j) => ({ ...o, position: j + 1 }))
|
||||
return { ...q, options }
|
||||
})
|
||||
onChange(updated)
|
||||
}
|
||||
|
||||
function handleDragStart(index: number, e: React.DragEvent<HTMLDivElement>) {
|
||||
setDragIndex(index)
|
||||
dragNode.current = e.currentTarget
|
||||
e.dataTransfer.effectAllowed = "move"
|
||||
requestAnimationFrame(() => {
|
||||
if (dragNode.current) {
|
||||
dragNode.current.style.opacity = "0.4"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleDragOver(index: number, e: React.DragEvent<HTMLDivElement>) {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = "move"
|
||||
if (dragIndex === null || dragIndex === index) return
|
||||
setDragOverIndex(index)
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
if (dragNode.current) {
|
||||
dragNode.current.style.opacity = "1"
|
||||
}
|
||||
if (dragIndex !== null && dragOverIndex !== null && dragIndex !== dragOverIndex) {
|
||||
const reordered = [...questions]
|
||||
const [moved] = reordered.splice(dragIndex, 1)
|
||||
reordered.splice(dragOverIndex, 0, moved)
|
||||
onChange(reposition(reordered))
|
||||
}
|
||||
setDragIndex(null)
|
||||
setDragOverIndex(null)
|
||||
dragNode.current = null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
{questions.map((question, qIndex) => (
|
||||
<div
|
||||
key={qIndex}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(qIndex, e)}
|
||||
onDragOver={(e) => handleDragOver(qIndex, e)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragLeave={() => setDragOverIndex(null)}
|
||||
className={`rounded-xl border bg-card p-5 shadow-sm transition-all ${
|
||||
dragOverIndex === qIndex && dragIndex !== qIndex
|
||||
? "border-primary ring-2 ring-primary/20"
|
||||
: "border-border"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-grab active:cursor-grabbing rounded p-0.5 text-muted-foreground hover:text-foreground"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-xs font-semibold text-primary">
|
||||
{qIndex + 1}
|
||||
</span>
|
||||
</div>
|
||||
{questions.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeQuestion(qIndex)}
|
||||
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<FormInput
|
||||
label="Question Title"
|
||||
placeholder="Enter question"
|
||||
value={question.title}
|
||||
onChange={(e) =>
|
||||
updateQuestion(qIndex, { title: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
Type
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={question.type}
|
||||
onChange={(e) =>
|
||||
updateQuestion(qIndex, {
|
||||
type: e.target.value as QuestionType,
|
||||
})
|
||||
}
|
||||
className="appearance-none rounded-lg border border-input bg-card py-2.5 pl-3.5 pr-9 text-sm text-foreground outline-none transition-colors focus:border-primary focus:ring-2 focus:ring-primary/20"
|
||||
>
|
||||
{QUESTION_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="pointer-events-none absolute right-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 pb-2.5 text-sm text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={question.required}
|
||||
onChange={(e) =>
|
||||
updateQuestion(qIndex, { required: e.target.checked })
|
||||
}
|
||||
className="h-4 w-4 rounded border-input accent-primary"
|
||||
/>
|
||||
Required
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{TYPES_WITH_OPTIONS.includes(question.type) && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
Options
|
||||
</label>
|
||||
{question.options.map((option, oIndex) => (
|
||||
<div key={oIndex} className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground w-5 text-center">
|
||||
{oIndex + 1}.
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={`Option ${oIndex + 1}`}
|
||||
value={option.label}
|
||||
onChange={(e) =>
|
||||
updateOption(qIndex, oIndex, e.target.value)
|
||||
}
|
||||
className="flex-1 rounded-lg border border-input bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground outline-none transition-colors focus:border-primary focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
{question.options.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeOption(qIndex, oIndex)}
|
||||
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addOption(qIndex)}
|
||||
className="mt-1 inline-flex items-center gap-1.5 self-start rounded-md px-3 py-1.5 text-sm font-medium text-primary transition-colors hover:bg-primary/10"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add option
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={addQuestion}
|
||||
className="mt-4 flex w-full items-center justify-center gap-2 rounded-xl border-2 border-dashed border-border py-4 text-sm font-medium text-muted-foreground transition-colors hover:border-primary hover:text-primary"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Question
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
ChevronDown,
|
||||
Star,
|
||||
} from "lucide-react"
|
||||
import type { Question } from "@/lib/dummy-data"
|
||||
import type { Question } from "@/lib/types"
|
||||
|
||||
interface QuestionPreviewProps {
|
||||
question: Question
|
||||
@@ -13,7 +13,6 @@ interface QuestionPreviewProps {
|
||||
export function QuestionPreview({ question, index }: QuestionPreviewProps) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card p-5 shadow-sm">
|
||||
{/* Question header */}
|
||||
<div className="mb-4 flex items-start gap-3">
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-xs font-semibold text-primary">
|
||||
{index + 1}
|
||||
@@ -31,7 +30,6 @@ export function QuestionPreview({ question, index }: QuestionPreviewProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Read-only field preview */}
|
||||
<div className="pl-10">
|
||||
{question.type === "short_text" && (
|
||||
<div className="rounded-lg border border-input bg-muted/50 px-3.5 py-2.5 text-sm text-muted-foreground">
|
||||
@@ -45,29 +43,29 @@ export function QuestionPreview({ question, index }: QuestionPreviewProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{question.type === "multiple_choice" && question.options && (
|
||||
{question.type === "multiple_choice" && question.options.length > 0 && (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{question.options.map((option) => (
|
||||
<label
|
||||
key={option}
|
||||
key={option.id}
|
||||
className="flex items-center gap-2.5 text-sm text-foreground"
|
||||
>
|
||||
<span className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-2 border-input" />
|
||||
{option}
|
||||
{option.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{question.type === "checkbox" && question.options && (
|
||||
{question.type === "checkbox" && question.options.length > 0 && (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{question.options.map((option) => (
|
||||
<label
|
||||
key={option}
|
||||
key={option.id}
|
||||
className="flex items-center gap-2.5 text-sm text-foreground"
|
||||
>
|
||||
<span className="flex h-4 w-4 shrink-0 items-center justify-center rounded border-2 border-input" />
|
||||
{option}
|
||||
{option.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
"use client"
|
||||
|
||||
import { Link } from "react-router";
|
||||
import { useLocation } from "react-router";
|
||||
import { LogOut, Menu, X } from "lucide-react"
|
||||
import { Link, useLocation } from "react-router"
|
||||
import { LogIn, LogOut, Menu, X } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { useAuth } from "@/app/context/auth-context"
|
||||
|
||||
const navLinks = [
|
||||
{ href: "/forms", label: "My Forms" },
|
||||
]
|
||||
|
||||
export function Navbar() {
|
||||
const { pathname } = useLocation();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const { pathname } = useLocation()
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const { user, logout } = useAuth()
|
||||
|
||||
const isAuthPage = pathname === "/login" || pathname === "/register";
|
||||
const isAuthPage = pathname === "/login" || pathname === "/register"
|
||||
const isLoggedIn = !!user
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-border bg-card/80 backdrop-blur-md">
|
||||
@@ -24,7 +24,7 @@ const isAuthPage = pathname === "/login" || pathname === "/register";
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{!isAuthPage && (
|
||||
{!isAuthPage && isLoggedIn && (
|
||||
<>
|
||||
<div className="hidden items-center gap-6 md:flex">
|
||||
{navLinks.map((link) => (
|
||||
@@ -44,15 +44,15 @@ const isAuthPage = pathname === "/login" || pathname === "/register";
|
||||
|
||||
<div className="hidden items-center gap-4 md:flex">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
bagas@example.com
|
||||
{user?.email}
|
||||
</span>
|
||||
<Link
|
||||
to="/login"
|
||||
<button
|
||||
onClick={logout}
|
||||
className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Logout
|
||||
</Link>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -69,6 +69,24 @@ const isAuthPage = pathname === "/login" || pathname === "/register";
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isAuthPage && !isLoggedIn && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/login"
|
||||
className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
>
|
||||
<LogIn className="h-4 w-4" />
|
||||
Log in
|
||||
</Link>
|
||||
<Link
|
||||
to="/register"
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAuthPage && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
@@ -91,7 +109,7 @@ const isAuthPage = pathname === "/login" || pathname === "/register";
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{!isAuthPage && mobileOpen && (
|
||||
{!isAuthPage && isLoggedIn && mobileOpen && (
|
||||
<div className="border-t border-border bg-card px-4 pb-4 pt-2 md:hidden">
|
||||
{navLinks.map((link) => (
|
||||
<Link
|
||||
@@ -109,16 +127,15 @@ const isAuthPage = pathname === "/login" || pathname === "/register";
|
||||
))}
|
||||
<div className="mt-2 border-t border-border pt-2">
|
||||
<span className="block px-3 py-1 text-sm text-muted-foreground">
|
||||
bagas@example.com
|
||||
{user?.email}
|
||||
</span>
|
||||
<Link
|
||||
to="/login"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="flex items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-secondary"
|
||||
<button
|
||||
onClick={() => { setMobileOpen(false); logout() }}
|
||||
className="flex items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-secondary w-full text-left"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Logout
|
||||
</Link>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import {
|
||||
ThemeProvider as NextThemesProvider,
|
||||
type ThemeProviderProps,
|
||||
} from 'next-themes'
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import { fetchWithAuth } from "@/lib/auth"
|
||||
import type { FormSummary, FormDetail, CreateFormPayload, UpdateFormPayload } from "@/lib/types"
|
||||
|
||||
const API_BASE = "http://localhost:8080"
|
||||
|
||||
export async function getForms(): Promise<FormSummary[]> {
|
||||
const res = await fetchWithAuth(`${API_BASE}/api/forms`)
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to fetch forms")
|
||||
}
|
||||
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function getFormById(id: string): Promise<FormDetail> {
|
||||
const res = await fetchWithAuth(`${API_BASE}/api/form/${id}`)
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) {
|
||||
throw new Response("Form not found", { status: 404 })
|
||||
}
|
||||
throw new Error("Failed to fetch form")
|
||||
}
|
||||
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function createForm(payload: CreateFormPayload): Promise<FormDetail> {
|
||||
const res = await fetchWithAuth(`${API_BASE}/api/form`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null)
|
||||
throw new Error(data?.message ?? "Failed to create form")
|
||||
}
|
||||
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function updateForm(id: string, payload: UpdateFormPayload): Promise<FormDetail> {
|
||||
const res = await fetchWithAuth(`${API_BASE}/api/form/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null)
|
||||
throw new Error(data?.message ?? "Failed to update form")
|
||||
}
|
||||
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function deleteForm(id: string): Promise<void> {
|
||||
const res = await fetchWithAuth(`${API_BASE}/api/form/${id}`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null)
|
||||
throw new Error(data?.message ?? "Failed to delete form")
|
||||
}
|
||||
}
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
export interface User {
|
||||
id: string
|
||||
email: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
export interface JWTPayload {
|
||||
sub: string
|
||||
email: string
|
||||
name?: string
|
||||
exp: number
|
||||
iat: number
|
||||
}
|
||||
|
||||
export function decodeJWT(token: string): JWTPayload | null {
|
||||
try {
|
||||
const base64Url = token.split(".")[1]
|
||||
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/")
|
||||
const jsonPayload = decodeURIComponent(
|
||||
atob(base64)
|
||||
.split("")
|
||||
.map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2))
|
||||
.join("")
|
||||
)
|
||||
return JSON.parse(jsonPayload)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function isTokenExpired(token: string): boolean {
|
||||
const payload = decodeJWT(token)
|
||||
if (!payload) return true
|
||||
return Date.now() >= payload.exp * 1000
|
||||
}
|
||||
|
||||
function getStorage(): Storage {
|
||||
if (typeof window === "undefined") return localStorage
|
||||
return localStorage.getItem("remember_me") === "true"
|
||||
? localStorage
|
||||
: sessionStorage
|
||||
}
|
||||
|
||||
function getToken(key: string): string | null {
|
||||
return localStorage.getItem(key) ?? sessionStorage.getItem(key)
|
||||
}
|
||||
|
||||
export function setTokens(accessToken: string, refreshToken: string, remember?: boolean) {
|
||||
if (typeof window === "undefined") return
|
||||
|
||||
if (remember !== undefined) {
|
||||
localStorage.setItem("remember_me", String(remember))
|
||||
}
|
||||
|
||||
const storage = remember !== undefined
|
||||
? (remember ? localStorage : sessionStorage)
|
||||
: getStorage()
|
||||
|
||||
localStorage.removeItem("access_token")
|
||||
localStorage.removeItem("refresh_token")
|
||||
sessionStorage.removeItem("access_token")
|
||||
sessionStorage.removeItem("refresh_token")
|
||||
|
||||
storage.setItem("access_token", accessToken)
|
||||
storage.setItem("refresh_token", refreshToken)
|
||||
}
|
||||
|
||||
function clearTokens() {
|
||||
localStorage.removeItem("access_token")
|
||||
localStorage.removeItem("refresh_token")
|
||||
localStorage.removeItem("remember_me")
|
||||
sessionStorage.removeItem("access_token")
|
||||
sessionStorage.removeItem("refresh_token")
|
||||
}
|
||||
|
||||
export async function refreshAccessToken(): Promise<string | null> {
|
||||
if (typeof window === "undefined") return null
|
||||
|
||||
const refreshToken = getToken("refresh_token")
|
||||
if (!refreshToken) {
|
||||
clearTokens()
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("http://localhost:8080/api/auth/refresh", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
clearTokens()
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
setTokens(data.access_token, data.refresh_token)
|
||||
return data.access_token
|
||||
} catch {
|
||||
clearTokens()
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function getUser(): User | null {
|
||||
if (typeof window === "undefined") return null
|
||||
|
||||
const token = getToken("access_token")
|
||||
if (!token || isTokenExpired(token)) return null
|
||||
|
||||
const payload = decodeJWT(token)
|
||||
if (!payload) return null
|
||||
|
||||
return {
|
||||
id: payload.sub,
|
||||
email: payload.email,
|
||||
name: payload.name,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserAsync(): Promise<User | null> {
|
||||
if (typeof window === "undefined") return null
|
||||
|
||||
let token = getToken("access_token")
|
||||
|
||||
if (!token || isTokenExpired(token)) {
|
||||
token = await refreshAccessToken()
|
||||
if (!token) return null
|
||||
}
|
||||
|
||||
const payload = decodeJWT(token)
|
||||
if (!payload) return null
|
||||
|
||||
return {
|
||||
id: payload.sub,
|
||||
email: payload.email,
|
||||
name: payload.name,
|
||||
}
|
||||
}
|
||||
|
||||
export function getAccessToken(): string | null {
|
||||
if (typeof window === "undefined") return null
|
||||
|
||||
const token = getToken("access_token")
|
||||
if (!token || isTokenExpired(token)) return null
|
||||
return token
|
||||
}
|
||||
|
||||
export async function getAccessTokenAsync(): Promise<string | null> {
|
||||
if (typeof window === "undefined") return null
|
||||
|
||||
let token = getToken("access_token")
|
||||
if (token && !isTokenExpired(token)) return token
|
||||
|
||||
token = await refreshAccessToken()
|
||||
return token
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
if (typeof window === "undefined") return
|
||||
|
||||
const token = getToken("access_token")
|
||||
const refreshToken = getToken("refresh_token")
|
||||
|
||||
try {
|
||||
await fetch("http://localhost:8080/api/auth/logout", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: token ? `Bearer ${token}` : "",
|
||||
},
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
})
|
||||
} catch {
|
||||
}
|
||||
|
||||
clearTokens()
|
||||
}
|
||||
|
||||
export async function fetchWithAuth(url: string, options: RequestInit = {}) {
|
||||
let token = await getAccessTokenAsync()
|
||||
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...options.headers,
|
||||
Authorization: token ? `Bearer ${token}` : "",
|
||||
},
|
||||
})
|
||||
|
||||
if (res.status === 401) {
|
||||
token = await refreshAccessToken()
|
||||
if (token) {
|
||||
return fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...options.headers,
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
export type QuestionType =
|
||||
| "short_text"
|
||||
| "long_text"
|
||||
| "multiple_choice"
|
||||
| "checkbox"
|
||||
| "dropdown"
|
||||
| "date"
|
||||
| "rating"
|
||||
|
||||
export interface Question {
|
||||
id: string
|
||||
type: QuestionType
|
||||
title: string
|
||||
required: boolean
|
||||
options?: string[]
|
||||
}
|
||||
|
||||
export interface Form {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
responseCount: number
|
||||
questions: Question[]
|
||||
}
|
||||
|
||||
export const dummyForms: Form[] = [
|
||||
{
|
||||
id: "1",
|
||||
title: "RISTEK Datathon 2025 Registration",
|
||||
description:
|
||||
"Register for RISTEK's flagship data science competition — the biggest student-led Datathon in Indonesia. Open for undergraduate students from all universities.",
|
||||
createdAt: "2026-01-10",
|
||||
updatedAt: "2026-02-05",
|
||||
responseCount: 420,
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
type: "short_text",
|
||||
title: "Full Name",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "q2",
|
||||
type: "short_text",
|
||||
title: "University / Institution",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "q3",
|
||||
type: "dropdown",
|
||||
title: "Which track are you registering for?",
|
||||
required: true,
|
||||
options: [
|
||||
"Data Analytics",
|
||||
"Machine Learning",
|
||||
"Data Engineering",
|
||||
"MLOps",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "q4",
|
||||
type: "multiple_choice",
|
||||
title: "How did you hear about RISTEK Datathon 2025?",
|
||||
required: false,
|
||||
options: [
|
||||
"Instagram / Social Media",
|
||||
"Friend / Colleague",
|
||||
"Fasilkom UI Website",
|
||||
"Email Newsletter",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "q5",
|
||||
type: "checkbox",
|
||||
title:
|
||||
"Which tools/technologies are you comfortable with? (Select all that apply)",
|
||||
required: false,
|
||||
options: [
|
||||
"Python",
|
||||
"SQL",
|
||||
"TensorFlow / PyTorch",
|
||||
"Tableau / Power BI",
|
||||
"Spark / Hadoop",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "q6",
|
||||
type: "long_text",
|
||||
title:
|
||||
"Briefly describe your motivation for joining RISTEK Datathon 2025.",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "RISTEK Sisters in Tech 2025 — Mentee Application",
|
||||
description:
|
||||
"Apply to be a mentee in SISTECH 2025, the first student-powered women-only tech mentorship program in Indonesia. Open for female Indonesian citizens aged 17–25.",
|
||||
createdAt: "2026-01-20",
|
||||
updatedAt: "2026-02-15",
|
||||
responseCount: 67,
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
type: "short_text",
|
||||
title: "Full Name",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "q2",
|
||||
type: "short_text",
|
||||
title: "Email Address",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "q3",
|
||||
type: "dropdown",
|
||||
title: "Select your preferred career path",
|
||||
required: true,
|
||||
options: [
|
||||
"Product Management",
|
||||
"UI/UX Design",
|
||||
"Software Engineering",
|
||||
"Data Analytics",
|
||||
"Digital Marketing",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "q4",
|
||||
type: "rating",
|
||||
title:
|
||||
"How would you rate your current knowledge in your chosen career path?",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "q5",
|
||||
type: "checkbox",
|
||||
title: "What do you hope to gain from SISTECH? (Select all that apply)",
|
||||
required: false,
|
||||
options: [
|
||||
"Mentorship from industry professionals",
|
||||
"Networking opportunities",
|
||||
"Portfolio & project guidance",
|
||||
"Career direction & clarity",
|
||||
"Community support",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "q6",
|
||||
type: "long_text",
|
||||
title:
|
||||
"Tell us about yourself and why you want to join RISTEK Sisters in Tech 2025.",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "q7",
|
||||
type: "multiple_choice",
|
||||
title: "What is your current education level?",
|
||||
required: true,
|
||||
options: [
|
||||
"High School / Vocational",
|
||||
"Undergraduate (Year 1–2)",
|
||||
"Undergraduate (Year 3–4)",
|
||||
"Fresh Graduate",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,68 @@
|
||||
export type QuestionType =
|
||||
| "short_text"
|
||||
| "long_text"
|
||||
| "multiple_choice"
|
||||
| "checkbox"
|
||||
| "dropdown"
|
||||
| "date"
|
||||
| "rating"
|
||||
|
||||
export interface QuestionOption {
|
||||
id: number
|
||||
label: string
|
||||
position: number
|
||||
}
|
||||
|
||||
export interface Question {
|
||||
id: string
|
||||
type: QuestionType
|
||||
title: string
|
||||
required: boolean
|
||||
position: number
|
||||
options: QuestionOption[]
|
||||
}
|
||||
|
||||
export interface FormSummary {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
response_count: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface FormDetail {
|
||||
id: string
|
||||
user_id: string
|
||||
title: string
|
||||
description: string
|
||||
response_count: number
|
||||
questions: Question[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface CreateQuestionOption {
|
||||
label: string
|
||||
position: number
|
||||
}
|
||||
|
||||
export interface CreateQuestion {
|
||||
type: QuestionType
|
||||
title: string
|
||||
required: boolean
|
||||
position: number
|
||||
options: CreateQuestionOption[]
|
||||
}
|
||||
|
||||
export interface CreateFormPayload {
|
||||
title: string
|
||||
description: string
|
||||
questions: CreateQuestion[]
|
||||
}
|
||||
|
||||
export interface UpdateFormPayload {
|
||||
title: string
|
||||
description: string
|
||||
questions: CreateQuestion[]
|
||||
}
|
||||
Reference in New Issue
Block a user