diff --git a/app/context/auth-context.tsx b/app/context/auth-context.tsx
new file mode 100644
index 0000000..2e55407
--- /dev/null
+++ b/app/context/auth-context.tsx
@@ -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(null)
+
+export function AuthProvider({ children }: { children: ReactNode }) {
+ const [user, setUser] = useState(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 (
+
+ {children}
+
+ )
+}
+
+export function useAuth() {
+ const context = useContext(AuthContext)
+ if (!context) {
+ throw new Error("useAuth must be used within AuthProvider")
+ }
+ return context
+}
diff --git a/app/routes/form.tsx b/app/routes/form.tsx
index a95abb8..2cecbb0 100644
--- a/app/routes/form.tsx
+++ b/app/routes/form.tsx
@@ -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(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(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 (
+
+ )
+ }
+
+ if (error || !form) {
+ return (
+
+
+
+
+
{error ?? "Form not found"}
+
+
+ Back to forms
+
+
+
+
+
+ )
}
return (
@@ -59,17 +128,38 @@ export default function FormPreviewPage() {
- {form.responseCount} responses
+ {form.response_count} responses
- Created {form.createdAt}
+ Created {new Date(form.created_at).toLocaleDateString()}
- Updated {form.updatedAt}
+ Updated {new Date(form.updated_at).toLocaleDateString()}
+
+ {user && user.id === form.user_id && (
+
+
+
+
+ Edit
+
+
+
setShowDeleteConfirm(true)}
+ className="text-destructive hover:bg-destructive/10 hover:text-destructive"
+ >
+
+ Delete
+
+
+ )}
@@ -103,6 +193,38 @@ export default function FormPreviewPage() {
+ {showDeleteConfirm && (
+
+
+
Delete Form
+
+ Are you sure you want to delete this form? This action cannot be
+ undone.
+
+
+ setShowDeleteConfirm(false)}
+ disabled={deleting}
+ >
+ Cancel
+
+
+ {deleting ? "Deleting..." : "Delete"}
+
+
+
+
+ )}
+
)
diff --git a/app/routes/forms.tsx b/app/routes/forms.tsx
index 6308c40..d5aef18 100644
--- a/app/routes/forms.tsx
+++ b/app/routes/forms.tsx
@@ -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([])
+ const [loadingForms, setLoadingForms] = useState(true)
+ const [error, setError] = useState(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 (
+
+ )
+ }
+
+ 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
-
-
- New Form
-
+
+
+
+ New Form
+
+
@@ -51,10 +91,22 @@ export default function FormsPage() {
- Showing {filtered.length} of {dummyForms.length} forms
+ Showing {filtered.length} of {forms.length} forms
- {filtered.length > 0 ? (
+ {error ? (
+
+
{error}
+
window.location.reload()}
+ >
+ Retry
+
+
+ ) : filtered.length > 0 ? (
{filtered.map((form) => (
diff --git a/app/routes/not-found.tsx b/app/routes/not-found.tsx
new file mode 100644
index 0000000..244eab7
--- /dev/null
+++ b/app/routes/not-found.tsx
@@ -0,0 +1,13 @@
+export function meta() {
+ return [{ title: "404 - Page Not Found" }];
+}
+
+export default function NotFound() {
+ return (
+
+
404
+
Page not found.
+
Go home
+
+ );
+}
\ No newline at end of file
diff --git a/components/forms/form-card.tsx b/components/forms/form-card.tsx
index fb325f0..313ba79 100644
--- a/components/forms/form-card.tsx
+++ b/components/forms/form-card.tsx
@@ -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) {
-
-
- {form.questions.length} questions
-
- {form.responseCount} responses
+ {form.response_count} responses
- {form.updatedAt}
+ {new Date(form.updated_at).toLocaleDateString()}
-
+
Preview Form
diff --git a/components/forms/question-editor.tsx b/components/forms/question-editor.tsx
new file mode 100644
index 0000000..b581f46
--- /dev/null
+++ b/components/forms/question-editor.tsx
@@ -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(null)
+ const [dragOverIndex, setDragOverIndex] = useState(null)
+ const dragNode = useRef(null)
+
+ function updateQuestion(index: number, updates: Partial) {
+ 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) {
+ 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) {
+ 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 (
+ <>
+
+ {questions.map((question, qIndex) => (
+
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"
+ }`}
+ >
+
+
+ e.stopPropagation()}
+ >
+
+
+
+ {qIndex + 1}
+
+
+ {questions.length > 1 && (
+
removeQuestion(qIndex)}
+ className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
+ >
+
+
+ )}
+
+
+
+
+ updateQuestion(qIndex, { title: e.target.value })
+ }
+ required
+ />
+
+
+
+
+ Type
+
+
+
+ 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) => (
+
+ {t.label}
+
+ ))}
+
+
+
+
+
+
+
+ updateQuestion(qIndex, { required: e.target.checked })
+ }
+ className="h-4 w-4 rounded border-input accent-primary"
+ />
+ Required
+
+
+
+ {TYPES_WITH_OPTIONS.includes(question.type) && (
+
+
+ Options
+
+ {question.options.map((option, oIndex) => (
+
+
+ {oIndex + 1}.
+
+
+ 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 && (
+ removeOption(qIndex, oIndex)}
+ className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
+ >
+
+
+ )}
+
+ ))}
+
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"
+ >
+
+ Add option
+
+
+ )}
+
+
+ ))}
+
+
+
+
+ Add Question
+
+ >
+ )
+}
diff --git a/components/forms/question-preview.tsx b/components/forms/question-preview.tsx
index fa6aef1..947d795 100644
--- a/components/forms/question-preview.tsx
+++ b/components/forms/question-preview.tsx
@@ -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 (
- {/* Question header */}
{index + 1}
@@ -31,7 +30,6 @@ export function QuestionPreview({ question, index }: QuestionPreviewProps) {
- {/* Read-only field preview */}
{question.type === "short_text" && (
@@ -45,29 +43,29 @@ export function QuestionPreview({ question, index }: QuestionPreviewProps) {
)}
- {question.type === "multiple_choice" && question.options && (
+ {question.type === "multiple_choice" && question.options.length > 0 && (
{question.options.map((option) => (
- {option}
+ {option.label}
))}
)}
- {question.type === "checkbox" && question.options && (
+ {question.type === "checkbox" && question.options.length > 0 && (
{question.options.map((option) => (
- {option}
+ {option.label}
))}
diff --git a/components/shared/navbar.tsx b/components/shared/navbar.tsx
index 0b93dd7..b07448d 100644
--- a/components/shared/navbar.tsx
+++ b/components/shared/navbar.tsx
@@ -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 (
@@ -24,7 +24,7 @@ const isAuthPage = pathname === "/login" || pathname === "/register";
- {!isAuthPage && (
+ {!isAuthPage && isLoggedIn && (
<>
{navLinks.map((link) => (
@@ -44,15 +44,15 @@ const isAuthPage = pathname === "/login" || pathname === "/register";
- bagas@example.com
+ {user?.email}
-
Logout
-
+
)}
+ {!isAuthPage && !isLoggedIn && (
+
+
+
+ Log in
+
+
+ Sign up
+
+
+ )}
+
{isAuthPage && (
- {!isAuthPage && mobileOpen && (
+ {!isAuthPage && isLoggedIn && mobileOpen && (
{navLinks.map((link) => (
- bagas@example.com
+ {user?.email}
- 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"
+ { 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
-
+
)}
diff --git a/components/theme-provider.tsx b/components/theme-provider.tsx
new file mode 100644
index 0000000..55c2f6e
--- /dev/null
+++ b/components/theme-provider.tsx
@@ -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
{children}
+}
diff --git a/lib/api.ts b/lib/api.ts
new file mode 100644
index 0000000..0868592
--- /dev/null
+++ b/lib/api.ts
@@ -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
{
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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")
+ }
+}
\ No newline at end of file
diff --git a/lib/auth.ts b/lib/auth.ts
new file mode 100644
index 0000000..5a3fb5a
--- /dev/null
+++ b/lib/auth.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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
+}
diff --git a/lib/dummy-data.ts b/lib/dummy-data.ts
deleted file mode 100644
index 854d262..0000000
--- a/lib/dummy-data.ts
+++ /dev/null
@@ -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",
- ],
- },
- ],
- },
-]
\ No newline at end of file
diff --git a/lib/types.ts b/lib/types.ts
new file mode 100644
index 0000000..db6b9eb
--- /dev/null
+++ b/lib/types.ts
@@ -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[]
+}