Langkah – langkah Instal Nextjs dan Supabase Siap Pakai Untuk Pemula
Dalam kesempatan ini saya akan menjelaskan tentang bagaimana membangun web modern menggunakan NextJS dan Supabase. Dalam studi kasus kali ini mungkin saya akan menginstall beberapa library/package yang mungkin akan dibutuhkan didalam project dari mulai plugins/module atau component shadcn tertentu.
Baca juga : Membuat Function Handle New USer Supabase
Berikut ini adalah teknologi utama yang digunakan dalam proyek ini:
- Framework: Next.js 16 dengan App Router.
Ini adalah framework React yang modern untuk membangun aplikasi web yang cepat dan SEO-friendly. - Bahasa: TypeScript.
Menambahkan keamanan tipe statis pada JavaScript untuk mengurangi bug dan meningkatkan pengalaman pengembangan. - Styling: Tailwind CSS 4.
Sebuah framework CSS utility-first untuk membangun desain kustom dengan cepat tanpa meninggalkan HTML Kamu. - Komponen UI: Shadcn/UI.
Ini bukan library komponen biasa, melainkan kumpulan komponen yang dapat digunakan kembali yang dibangun menggunakan Radix UI dan Tailwind CSS. Kamu dapat menyalin dan menempelkannya ke dalam proyek Kamu dan memodifikasinya sesuai kebutuhan. - Backend & Database: Supabase.
proyek ini menggunakan Supabase sebagai backend, yang menyediakan database Postgres, otentikasi, penyimpanan, dan banyak lagi. - Manajemen Form: React Hook Form & Zod.
Kombinasi ini digunakan untuk mengelola state form dan melakukan validasi skema data dengan cara yang efisien dan aman. - Internasionalisasi (i18n): next-intl.
Library ini digunakan untuk mengelola terjemahan teks (dalam kasus ini, ke Bahasa Indonesia) di dalam aplikasi Next.js. - Notifikasi: Sonner.
Untuk menampilkan notifikasi toast yang simpel dan elegan. - Ikon: Lucide React.
Sebuah library ikon yang ringan dan mudah dikustomisasi. - Utilitas Tanggal & Waktu: date-fns.
Library modern untuk manipulasi tanggal dan waktu. - Eksport Data: ExcelJS.
Digunakan untuk membuat file Excel, seperti pada fitur ekspor data pesanan.
Langkah – langkah Instal NextJS dan Supabase Siap Pakai Untuk Pemula
1. Instalasi NextJS
npx create-next-app@latest nama-proyek-kamu --typescript --tailwind --eslintDalam projek nextjs ini saya menggunakan struktur folder src.
Selanjutnya masuk ke direktori proyek kamu:
cd nama-proyek-kamu2. Instalasi Dependensi Utama
Instal semua library utama yang dibutuhkan oleh proyek.
npm install @hookform/resolvers @supabase/ssr @supabase/supabase-js class-variance-authority clsx date-fns exceljs jsonwebtoken lucide-react next-intl next-themes react-day-picker react-hook-form react-markdown remark-gfm sonner tailwind-merge zod
3. Instalasi Dependensi Pengembangan (Dev Dependencies)
Instal dependensi yang hanya dibutuhkan saat pengembangan.
npm install -D @types/jsonwebtoken @types/node @types/react @types/react-dom typescript4. Konfigurasi Shadcn/UI
Shadcn/UI memiliki CLI sendiri untuk proses inisialisasi dan penambahan komponen.
Inisialisasi Shadcn/UI: Jalankan perintah berikut di terminal Anda:
npx shadcn@latest init
5. Tambahkan Komponen Shadcn/UI yang Digunakan
Berdasarkan analisis file kode, berikut adalah komponen-komponen dari shadcn/ui yang perlu Anda tambahkan:
Bash
npx shadcn@latest add alert
npx shadcn@latest add avatar
npx shadcn@latest add badge
npx shadcn@latest add breadcrumb
npx shadcn@latest add button
npx shadcn@latest add calendar
npx shadcn@latest add card
npx shadcn@latest add checkbox
npx shadcn@latest add collapsible
npx shadcn@latest add dialog
npx shadcn@latest add dropdown-menu
npx shadcn@latest add form
npx shadcn@latest add input
npx shadcn@latest add label
npx shadcn@latest add popover
npx shadcn@latest add select
npx shadcn@latest add switch
npx shadcn@latest add table
Catatan: Perintah npx shadcn@latest add form akan secara otomatis menginstal react-hook-form dan @hookform/resolvers jika belum ada.
6. Konfigurasi lain – lain
Ada beberapa tambahan atau penyesuain khususnya untuk translation, eslint dan prettier
npm install next-intlSupaya eslint dan prettier tidak bentrok, kita perlu lakukan konfigurasi tambahan
npm install --save-dev prettier prettier-plugin-tailwindcssnpm install --save-dev eslint prettier eslint-config-prettier eslint-plugin-prettierTambahkan konfigurasi tambahan untuk prettier
# .prettierignore
# Folder dependensi
node_modules
# Folder build Next.js
.next
# File environment (jangan diformat)
.env.local
# File cache dan log lainnya
*.log
Kemudian tambahkan konfigurasi untuk next.config.ts
import createNextIntlPlugin from "next-intl/plugin";
import type { NextConfig } from "next";
const withNextIntl = createNextIntlPlugin();
const nextConfig: NextConfig = {
experimental: {
serverActions: {
allowedOrigins: [
"localhost:3000",
"*.devtunnels.ms",
"*.asse.devtunnels.ms",
],
},
},
images: {
remotePatterns: [
{
protocol: "https",
hostname: "yoursupabasepublichostname.supabase.co",
port: "",
pathname: "/storage/v1/object/public/**",
},
],
},
async rewrites() {
return [
// {
// source: "/ingest/static/:path*",
// destination: "https://us-assets.i.posthog.com/static/:path*",
// },
// {
// source: "/ingest/:path*",
// destination: "https://us.i.posthog.com/:path*",
// },
];
},
// This is required to support PostHog trailing slash API requests
skipTrailingSlashRedirect: true,
};
export default withNextIntl(nextConfig);
7. Integrasi dengan Editor Kode (Contoh: VS Code)
Langkah yang paling penting adalah mengintegrasikannya dengan editor Anda agar pemformatan berjalan otomatis.
Jika Anda menggunakan Visual Studio Code:
- Instal ekstensi Prettier - Code formatter.
- Buka Pengaturan VS Code (File > Preferences > Settings, atau Ctrl + ,).
- Cari "Format On Save" dan pastikan opsinya dicentang. Ini akan membuat VS Code otomatis memformat file setiap kali Anda menyimpannya.
- (Opsional) Anda bisa mengatur Prettier sebagai pemformat default untuk file JavaScript/TypeScript agar tidak konflik dengan pemformat lain.
8. Konfigurasi .env.local
Selanjutnya masukan api key untuk koneksi supabase masing – masing
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY =
9. Konfigurasi untuk kebutuhan Api
Untuk kebutuhan api dan berkomunikasi dengan supabase, kamu perlu menambahkan konfigurasi untuk kebutuhan koneksi yang digunakan dalam operasi client atau server.
Dalam folder src terdapat direktori lib, nah didalam direktori lib, kamu buat direktori supabase, lalu didalamnya buat empat file yakni, admin.ts, client.ts, server.ts dan middleware.ts
//admin.ts untuk operasi yang membutuhkan service role key untuk bypass, contoh untuk kasus penambahan user ke supabase
import { createClient } from "@supabase/supabase-js";
export const createAdminClient = () => {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
//process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
};//client.ts
import { createBrowserClient } from "@supabase/ssr";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
export const createClient = () => createBrowserClient(supabaseUrl!, supabaseKey!);
//middleware.ts
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({
request,
});
const supabase = createServerClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, {
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value));
supabaseResponse = NextResponse.next({
request,
});
cookiesToSet.forEach(({ name, value, options }) => supabaseResponse.cookies.set(name, value, options));
},
},
});
const {
data: { user },
} = await supabase.auth.getUser();
// Definisikan rute dan peran yang diizinkan
const adminOnlyRoutes = ["/users"]; // Contoh rute khusus admin
const protectedRoutes = ["/dashboard", "/profile", ...adminOnlyRoutes];
const authRoutes = ["/sign-in", "/forgot-password"];
const isProtectedRoute = protectedRoutes.some((route) => request.nextUrl.pathname.startsWith(route));
const isAuthRoute = authRoutes.some((route) => request.nextUrl.pathname.startsWith(route));
const isAdminOnlyRoute = adminOnlyRoutes.some((route) => request.nextUrl.pathname.startsWith(route));
// 1. Jika pengguna tidak login dan mencoba mengakses rute yang dilindungi
if (isProtectedRoute && !user) {
const redirectUrl = new URL("/sign-in", request.url);
redirectUrl.searchParams.set("redirectTo", request.nextUrl.pathname);
return NextResponse.redirect(redirectUrl);
}
// 2. Jika pengguna sudah login, periksa perannya untuk rute khusus admin
if (user && isAdminOnlyRoute) {
const userRole = user.app_metadata?.role;
if (userRole !== 'admin') {
// Alihkan ke halaman dashboard utama jika bukan admin
return NextResponse.redirect(new URL("/dashboard", request.url));
}
}
// 3. Jika pengguna sudah login dan mencoba mengakses halaman login/register
if (isAuthRoute && user) {
const redirectTo = request.nextUrl.searchParams.get("redirectTo");
if (redirectTo) {
return NextResponse.redirect(new URL(redirectTo, request.url));
}
return NextResponse.redirect(new URL("/", request.url));
}
return supabaseResponse;
}
//server.ts
//untuk operasi api dari sisi admin
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(supabaseUrl, supabaseKey, {
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) => {
cookieStore.set(name, value, options);
});
} catch {
/* ignore in server components */
}
},
},
});
}
Selanjutnya didalam folder lib buat juga folder api kemudian didalamnya masukan file dengan nama response.ts untuk respon api ketika sukses atau gagal
import { NextResponse } from "next/server";
export interface ApiSuccessResponse<T = any> {
success: true;
data: T;
message: string;
meta?: {
page?: number;
limit?: number;
total?: number;
totalPages?: number;
};
}
export interface ApiErrorResponse {
success: false;
error: {
message: string;
details?: any;
};
}
export type ApiResponse<T = any> = ApiSuccessResponse<T> | ApiErrorResponse;
export function successResponse<T = any>(data: T, message: string = "OK", status: number = 200, meta?: ApiSuccessResponse["meta"]) {
const response: ApiSuccessResponse<T> = {
success: true,
data,
message,
...(meta && { meta }),
};
return NextResponse.json(response, { status });
}
export function errorResponse(message: string, status: number = 500, details?: any, error?: any) {
if (error) {
console.error(`[API Error - ${status}]`, message, error);
}
const response: ApiErrorResponse = {
success: false,
error: {
message,
...(details && { details }),
},
};
return NextResponse.json(response, { status });
}
export function badRequestResponse(message: string = "The request could not be understood or was missing required parameters", details?: any) {
return errorResponse(message, 400, details);
}
export function unauthorizedResponse(message: string = "Authentication is required to access this resource", details?: any) {
return errorResponse(message, 401, details);
}
export function forbiddenResponse(message: string = "You do not have permission to access this resource", details?: any) {
return errorResponse(message, 403, details);
}
export function notFoundResponse(message: string = "The requested resource was not found", details?: any) {
return errorResponse(message, 404, details);
}
export function conflictResponse(message: string = "The request could not be completed due to a conflict with the current state", details?: any) {
return errorResponse(message, 409, details);
}
export function validationErrorResponse(message: string = "The provided data failed validation", details?: any) {
return errorResponse(message, 422, details);
}
export function serverErrorResponse(message: string = "An unexpected error occurred while processing your request", error?: any) {
return errorResponse(message, 500, undefined, error);
}
export function createdResponse<T = any>(data: T, message?: string) {
return successResponse(data, message || "Resource created successfully", 201);
}
export function noContentResponse() {
return new NextResponse(null, { status: 204 });
}
export function paginatedResponse<T = any>(data: T[], page: number, limit: number, total: number, message?: string) {
return successResponse(data, message, 200, {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
});
}10. Konfigurasi Translation
Sebenarnya ini optional, tapi ideal jika ada, untuk konfigurasinya, pertama buat folder i18n didalam folder src lalu didalam folder tersebut buat file en.json dan id.json
{
"HomePage": {
"title": "Hello world!"
}
}Kemudian buat file request.ts
import { getRequestConfig } from "next-intl/server";
import { cookies, headers } from "next/headers";
const SUPPORTED = ["id", "en"] as const;
type Locale = (typeof SUPPORTED)[number];
const DEFAULT_LOCALE: Locale = "id";
function pickFromAcceptLanguage(accept: string | null): Locale {
if (!accept) return DEFAULT_LOCALE;
const langs = accept.split(",").map((s) => s.split(";")[0].trim().toLowerCase());
for (const l of langs) {
const base = l.split("-")[0];
if (SUPPORTED.includes(base as Locale)) return base as Locale;
}
return DEFAULT_LOCALE;
}
export default getRequestConfig(async () => {
const cookieStore = await cookies();
const headerStore = await headers();
const cookieLocale = cookieStore.get("NEXT_LOCALE")?.value as Locale | undefined;
const headerLocale = pickFromAcceptLanguage(headerStore.get("accept-language"));
const locale: Locale = cookieLocale && SUPPORTED.includes(cookieLocale) ? cookieLocale : headerLocale;
return {
locale,
messages: (await import(`./${locale}.json`)).default,
};
});
{
11. Contexts
Selanjutnya kita membutuhkan sistem autentifikasi untuk melakukan identifikasi user atau untuk membatasi user ke halaman tertentu.
Pada folder src , buat folder baru bernama contexts. Kemudian buat dua buah file didalamnya yakni auth-context.tsx dan auth-provider.tsx
//auth context
"use client";
import { createContext } from "react";
import type { User } from "@supabase/supabase-js";
export type AuthContextType = {
user: User | null;
loading: boolean;
role: string | null;
signIn: (email: string, password: string) => Promise<{ error: string | null }>;
signOut: () => Promise<void>;
};
export const AuthContext = createContext<AuthContextType | undefined>(undefined);
//auth-provider.tsx
"use client";
import { useEffect, useState, ReactNode } from "react";
import { AuthContext } from "./auth-context";
import { createClient } from "@/lib/supabase/client";
import type { User } from "@supabase/supabase-js";
export function AuthProvider({ children }: { children: ReactNode }) {
const supabase = createClient();
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [role, setRole] = useState<string | null>(null);
useEffect(() => {
// Listener untuk perubahan status autentikasi (login, logout)
const {
data: { subscription },
} = supabase.auth.onAuthStateChange(async (_event, session) => {
const currentUser = session?.user ?? null;
setUser(currentUser);
// Ambil peran langsung dari JWT (app_metadata)
// Ini cepat karena sudah ada di dalam sesi
if (currentUser && currentUser.app_metadata) {
setRole(currentUser.app_metadata.role || null);
} else {
setRole(null); // Reset peran saat logout
}
// Jika ada pengguna, kita juga perbarui data user_metadata
// Ini penting agar data seperti nama depan/belakang selalu terbaru
if (currentUser) {
// Memanggil getUser() akan memaksa refresh data dari server
await supabase.auth.getUser();
}
setLoading(false);
});
// Cek sesi awal saat komponen dimuat
const checkInitialSession = async () => {
const {
data: { session },
} = await supabase.auth.getSession();
// Jika tidak ada sesi, kita bisa berhenti loading
if (!session) {
setLoading(false);
}
// Jika ada sesi, onAuthStateChange akan otomatis terpanggil dan menangani sisanya.
};
checkInitialSession();
return () => {
subscription.unsubscribe();
};
}, [supabase]);
const signOut = async () => {
await supabase.auth.signOut();
// onAuthStateChange akan menangani reset state user dan profile
// Redirect bisa dilakukan di komponen jika perlu, atau di sini
window.location.href = "/sign-in";
};
const signIn = async (email: string, password: string) => {
const { error } = await supabase.auth.signInWithPassword({ email, password });
// onAuthStateChange akan otomatis menangani pengambilan profil setelah login berhasil
return { error: error?.message || null };
};
const value = {
user,
loading,
role, // Sediakan role langsung
signIn,
signOut,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
Selanjutnya pada folder src buat kembali sebuah folder bernama hooks. Kemudian didalamnya buat sebuah file bernama use-auth.ts
"use client";
import { useContext } from "react";
import { AuthContext } from "@/contexts/auth-context";
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}
12. Layout
Pada folder app, buat file layout.tsx, providers.tsx dan not-found.tsx. Pada folder app, di root direktori tidak perlu page.tsx, nanti page.tsx akan kita arahkan ke folder (public)
//layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { getLocale, getMessages } from "next-intl/server";
import { Providers } from "./providers";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Sakola - LMS",
description: "LMS nomor 1 Indonesia",
};
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const locale = await getLocale();
const messages = await getMessages();
return (
<html lang={locale}>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
<Providers locale={locale} messages={messages}>
{children}
</Providers>
</body>
</html>
);
}
//providers.tsx
"use client";
import { NextIntlClientProvider } from "next-intl";
import { AuthProvider } from "@/contexts/auth-provider";
import { Toaster } from "sonner"; // atau react-hot-toast
export function Providers({
children,
locale,
messages,
}: {
children: React.ReactNode;
locale: string;
messages: any;
}) {
return (
<NextIntlClientProvider locale={locale} messages={messages}>
<AuthProvider>
{children}
<Toaster />
</AuthProvider>
</NextIntlClientProvider>
);
}
//not-found.tsx
import Image from "next/image";
import { Button } from "@/components/ui/button";
import Link from "next/link";
export default function NotFound() {
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-gray-50 px-6 py-12 text-center">
<div className="max-w-md">
{/* Logo */}
{/* 404 Title */}
<h1 className="text-7xl font-bold text-gray-900">404</h1>
{/* Page Not Found */}
<h2 className="mt-4 text-2xl font-semibold text-gray-700">
Page Not Found
</h2>
{/* Description */}
<p className="mt-2 text-gray-500">
The page you're looking for either doesn’t exist or has been moved.
</p>
{/* Button */}
<Button asChild className="mt-6">
<Link href="/">Go Back Home</Link>
</Button>
</div>
</div>
);
}
13. Implementasi Halaman Nextjs dengan autentifikasi supabase
Pertama Selanjutnya buat file/folder (protected)/dashboard/page.tsx. dan layout.tsx
//layout.tsx
"use client";
import { useAuth } from "@/hooks/use-auth";
import { redirect } from "next/navigation";
export default function ProtectedLayout({
children,
}: {
children: React.ReactNode;
}) {
const { user, loading } = useAuth();
if (loading) return <p>Loading...</p>;
if (!user) {
redirect("/sign-in");
}
return (
<div>
{/* Navbar / Sidebar */}
{children}
</div>
);
}//(protected)/dashboard/page.tsx
"use client";
import { useAuth } from "@/hooks/use-auth";
export default function DashboardPage() {
const { user, role, loading, signOut } = useAuth();
if (loading) return <p>Loading...</p>;
if (!user) return <p>Not logged in</p>;
return (
<div>
<p>Email: {user.email}</p>
<p>Role: {role}</p>
<button onClick={signOut}>Logout</button>
</div>
);
}
Kedua Buat file layout.tsx dan page.tsx pada folder (public)
//layout.tsx
export default function PublicLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<main style={{ minHeight: "100vh" }}>
{children}
</main>
);
}
//page.tsx
export default function HomePage() {
return (
<main style={{ padding: '2rem' }}>
<h1>🚀 Next.js Starter</h1>
<p>App Router + TypeScript siap digunakan.</p>
<ul>
<li>📁 App Router</li>
<li>⚡ Server Components</li>
<li>🔌 API Routes</li>
</ul>
</main>
)
}
Ketiga buat file/folder sign-in/page.tsx di (public)
"use client";
import { useState, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Loader2, Eye, EyeOff, KeyRound, AtSign } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useAuth } from "@/hooks/use-auth";
import Link from "next/link";
export default function SignInPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { signIn } = useAuth();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [redirectTo, setRedirectTo] = useState("/dashboard");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
useEffect(() => {
const redirect = searchParams.get("redirectTo");
if (redirect && redirect.startsWith("/")) {
setRedirectTo(redirect);
}
}, [searchParams]);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setLoading(true);
setError(null);
try {
const result = await signIn(email, password);
if (result.error) {
throw new Error(result.error);
}
router.push(redirectTo);
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "An error occurred");
} finally {
setLoading(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-orange-50 to-orange-200 p-4">
<div className="w-full max-w-md">
<div className="mb-10 text-center">
<img src="/images/logo.png" alt="Logo" className="mx-auto h-12 w-auto" />
</div>
<Card className="bg-white/80 shadow-xl backdrop-blur-lg">
<CardHeader>
<CardTitle className="text-2xl font-bold">Selamat Datang Kembali</CardTitle>
<CardDescription>Masukkan kredensial untuk mengakses dashboard Anda.</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
<div className="space-y-3">
<Label htmlFor="email">Email</Label>
<div className="relative">
<AtSign className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="user@example.com" required className="pl-10" />
</div>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label htmlFor="password">Kata Sandi</Label>
<Link href="#" className="text-sm text-orange-600 hover:underline">
Lupa kata sandi?
</Link>
</div>
<div className="relative">
<KeyRound className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
id="password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
minLength={6}
className="pl-10 pr-10"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeOff className="text-muted-foreground h-4 w-4" /> : <Eye className="text-muted-foreground h-4 w-4" />}
</Button>
</div>
</div>
</CardContent>
<CardFooter className="flex flex-col gap-4 mt-4">
{error && <p className="text-destructive text-sm font-medium">{error}</p>}
<Button
type="submit"
size="lg"
className="w-full bg-orange-600 text-white shadow-md transition-transform hover:bg-orange-700 hover:scale-[1.02]"
disabled={loading}
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Signing in...
</>
) : (
"Sign In"
)}
</Button>
</CardFooter>
</form>
</Card>
</div>
</div>
);
}
Daftar Refrensi :