feat: implement app directory routing, authentication flow, and dynamic reporting module
This commit is contained in:
@@ -1,157 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { checkUser, registerUser } from '@/app/actions'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Loader2, Sparkles, User, Image as ImageIcon } from 'lucide-react'
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [isVerified, setIsVerified] = useState(false)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [userId, setUserId] = useState<string | null>(null)
|
||||
const [name, setName] = useState('')
|
||||
const [photo, setPhoto] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
async function initAuth() {
|
||||
let id = localStorage.getItem('user_id')
|
||||
if (!id) {
|
||||
id = uuidv4()
|
||||
localStorage.setItem('user_id', id)
|
||||
}
|
||||
setUserId(id)
|
||||
|
||||
const user = await checkUser(id)
|
||||
if (user) {
|
||||
setIsVerified(true)
|
||||
} else {
|
||||
setShowModal(true)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
initAuth()
|
||||
}, [])
|
||||
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!name.trim()) {
|
||||
setError('Nama wajib diisi')
|
||||
return
|
||||
}
|
||||
|
||||
setError('')
|
||||
setSaving(true)
|
||||
const res = await registerUser(userId!, name.trim(), photo.trim() || undefined)
|
||||
|
||||
if (res.success) {
|
||||
setShowModal(false)
|
||||
setIsVerified(true)
|
||||
} else {
|
||||
setError(res.error || 'Terjadi kesalahan')
|
||||
}
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (!open && !isVerified) {
|
||||
setShowModal(true)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-[#F4F6FB] dark:bg-[#0B0F19] gap-4">
|
||||
<div className="w-12 h-12 rounded-2xl bg-[#1B2CC1] flex items-center justify-center text-white shadow-xl shadow-[#1B2CC1]/30 animate-pulse">
|
||||
<Sparkles className="w-6 h-6" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-slate-500">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-[#1B2CC1]" />
|
||||
<span>Memuat sesi Anda...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isVerified && children}
|
||||
<Dialog open={showModal} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-[440px] p-0 overflow-hidden rounded-3xl border-slate-200/90 dark:border-slate-800 shadow-2xl [&>button]:hidden">
|
||||
<div className="bg-gradient-to-br from-[#1B2CC1] to-[#121E85] p-6 text-white text-center relative overflow-hidden">
|
||||
<div className="absolute -right-8 -bottom-8 w-32 h-32 bg-white/10 rounded-full blur-2xl pointer-events-none" />
|
||||
<div className="w-14 h-14 rounded-2xl bg-white/15 backdrop-blur-md flex items-center justify-center mx-auto mb-3 text-white border border-white/20 shadow-inner">
|
||||
<Sparkles className="w-7 h-7" />
|
||||
</div>
|
||||
<DialogTitle className="text-2xl font-black tracking-tight text-white">
|
||||
Selamat Datang di TitipIn
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-blue-100/90 text-xs mt-1.5 max-w-xs mx-auto">
|
||||
Daftarkan nama Anda untuk mulai membuka atau menitip pesanan bersama teman.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleRegister} className="p-6 space-y-4 bg-white dark:bg-slate-900">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="reg-name" className="text-xs font-bold text-slate-700 dark:text-slate-300">
|
||||
Nama Tampilan <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="reg-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Contoh: Budi Santoso"
|
||||
className="rounded-xl h-11 border-slate-200 dark:border-slate-800 focus-visible:ring-4 focus-visible:ring-[#1B2CC1]/15 focus-visible:border-[#1B2CC1]"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400">Nama harus unik agar pembuat PO mudah mengenali Anda.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="reg-photo" className="text-xs font-bold text-slate-700 dark:text-slate-300">
|
||||
URL Foto Profil (Opsional)
|
||||
</Label>
|
||||
<Input
|
||||
id="reg-photo"
|
||||
value={photo}
|
||||
onChange={(e) => setPhoto(e.target.value)}
|
||||
placeholder="https://images.unsplash.com/..."
|
||||
className="rounded-xl h-11 border-slate-200 dark:border-slate-800 focus-visible:ring-4 focus-visible:ring-[#1B2CC1]/15 focus-visible:border-[#1B2CC1]"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-950/40 border border-red-200 dark:border-red-900/60 rounded-xl text-xs text-red-600 dark:text-red-400 font-semibold">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="w-full h-11 rounded-xl bg-[#1B2CC1] hover:bg-[#15229E] text-white font-bold shadow-md shadow-[#1B2CC1]/25 transition-all mt-2"
|
||||
>
|
||||
{saving ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>Menyimpan Profil...</span>
|
||||
</div>
|
||||
) : (
|
||||
'Mulai Gunakan TitipIn'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -54,6 +54,13 @@ export function Header({ onMenuClick }: { onMenuClick: () => void }) {
|
||||
badge: 'Laporan',
|
||||
Icon: BarChart2
|
||||
}
|
||||
case '/users':
|
||||
return {
|
||||
title: 'Manajemen Pengguna',
|
||||
subtitle: 'Kelola data pengguna, role, dan status aktif.',
|
||||
badge: 'Superadmin',
|
||||
Icon: User
|
||||
}
|
||||
default:
|
||||
if (pathname.startsWith('/my-orders/')) {
|
||||
return {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { submitOrder, getUserSubmission } from '@/app/actions'
|
||||
import { submitOrder, getUserSubmission, getSessionUser } from '@/app/actions'
|
||||
import { PlusCircle, MinusCircle, User, CheckCircle2, ShoppingBag, Sparkles, Users } from 'lucide-react'
|
||||
|
||||
import { format } from 'date-fns'
|
||||
@@ -113,11 +113,14 @@ function OrderFormModal({ order, onSuccess }: { order: any, onSuccess: () => voi
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const id = localStorage.getItem('user_id')
|
||||
if (id) {
|
||||
setUserId(id)
|
||||
loadExistingSubmission(id)
|
||||
const init = async () => {
|
||||
const user = await getSessionUser()
|
||||
if (user?.id) {
|
||||
setUserId(user.id)
|
||||
loadExistingSubmission(user.id)
|
||||
}
|
||||
}
|
||||
init()
|
||||
}, [order.id])
|
||||
|
||||
const loadExistingSubmission = async (uid: string) => {
|
||||
|
||||
+76
-10
@@ -4,6 +4,12 @@ import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Store,
|
||||
ClipboardList,
|
||||
@@ -15,7 +21,6 @@ import {
|
||||
Info,
|
||||
BarChart2
|
||||
} from 'lucide-react'
|
||||
import { checkUser } from '@/app/actions'
|
||||
|
||||
const menuItems = [
|
||||
{ name: 'Open Order', href: '/', icon: Store, desc: 'Daftar PO live hari ini' },
|
||||
@@ -27,22 +32,38 @@ const menuItems = [
|
||||
export function Sidebar({ isOpen, onClose }: { isOpen?: boolean; onClose?: () => void }) {
|
||||
const pathname = usePathname()
|
||||
const [userName, setUserName] = useState<string>('Pengguna')
|
||||
const [userNameAccount, setUserNameAccount] = useState<string>('Pengguna')
|
||||
const [userPhoto, setUserPhoto] = useState<string | null>(null)
|
||||
const [userRole, setUserRole] = useState<string | null>(null)
|
||||
const [userId, setUserId] = useState<string | null>(null)
|
||||
const [isLogoutOpen, setIsLogoutOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const id = localStorage.getItem('user_id')
|
||||
if (id) {
|
||||
setUserId(id)
|
||||
checkUser(id).then((u) => {
|
||||
import('@/app/actions').then(({ getSessionUser }) => {
|
||||
getSessionUser().then((u) => {
|
||||
if (u) {
|
||||
setUserId(u.id)
|
||||
setUserName(u.name)
|
||||
setUserNameAccount(u.username)
|
||||
setUserRole(u.role)
|
||||
if (u.photo) setUserPhoto(u.photo)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}, [pathname])
|
||||
|
||||
const handleLogout = async () => {
|
||||
const { logoutUser } = await import('@/app/actions')
|
||||
await logoutUser()
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
// Dynamic Menu Items based on role
|
||||
const finalMenuItems = [...menuItems]
|
||||
if (userRole === 'superadmin') {
|
||||
finalMenuItems.push({ name: 'User Management', href: '/users', icon: UserCircle, desc: 'Kelola pengguna' })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile Backdrop */}
|
||||
@@ -106,9 +127,16 @@ export function Sidebar({ isOpen, onClose }: { isOpen?: boolean; onClose?: () =>
|
||||
</span>
|
||||
<ArrowUpRight className="w-3.5 h-3.5 text-slate-400 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
<span className="text-[10px] font-mono text-slate-400 truncate">
|
||||
{userId ? `ID: ${userId.slice(0, 10)}...` : 'Aktif'}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
{userRole === 'superadmin' && (
|
||||
<span className="px-1.5 py-[2px] rounded-md bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 text-[8px] font-black uppercase tracking-wider">
|
||||
Admin
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] font-medium text-slate-500 dark:text-slate-400 truncate">
|
||||
{userNameAccount ? `@${userNameAccount}` : 'Aktif'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -118,7 +146,7 @@ export function Sidebar({ isOpen, onClose }: { isOpen?: boolean; onClose?: () =>
|
||||
Navigasi Utama
|
||||
</div>
|
||||
<nav className="space-y-1">
|
||||
{menuItems.map((item) => {
|
||||
{finalMenuItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isActive = pathname === item.href
|
||||
return (
|
||||
@@ -153,6 +181,44 @@ export function Sidebar({ isOpen, onClose }: { isOpen?: boolean; onClose?: () =>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Logout Button */}
|
||||
{userId && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setIsLogoutOpen(true)}
|
||||
className="w-full flex items-center gap-3 px-3.5 py-3 rounded-2xl text-sm font-semibold transition-all duration-200 group relative text-rose-600 dark:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-950/30 cursor-pointer"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-xl flex items-center justify-center transition-colors bg-rose-100 dark:bg-rose-900/40 text-rose-500 group-hover:bg-rose-200 dark:group-hover:bg-rose-800/60">
|
||||
<X className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0 text-left">
|
||||
<span className="leading-tight">Logout</span>
|
||||
<span className="text-[10px] font-normal truncate mt-0.5 text-rose-400/80">Keluar dari akun</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<Dialog open={isLogoutOpen} onOpenChange={setIsLogoutOpen}>
|
||||
<DialogContent className="sm:max-w-xs rounded-3xl p-0 overflow-hidden border-0">
|
||||
<div className="px-6 pt-6 pb-2 text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-rose-100 text-rose-600 flex items-center justify-center mx-auto mb-3">
|
||||
<X className="w-6 h-6" />
|
||||
</div>
|
||||
<DialogTitle className="text-xl font-black text-slate-800">Konfirmasi Logout</DialogTitle>
|
||||
<p className="text-xs text-slate-500 mt-2">
|
||||
Apakah Anda yakin ingin keluar dari akun ini?
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 flex gap-3 bg-slate-50">
|
||||
<Button type="button" variant="outline" onClick={() => setIsLogoutOpen(false)} className="flex-1 h-11 rounded-xl">Batal</Button>
|
||||
<Button type="button" onClick={handleLogout} className="flex-1 h-11 rounded-xl bg-rose-600 hover:bg-rose-700 text-white font-bold cursor-pointer">
|
||||
Ya, Logout
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"cursor-pointer group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
Reference in New Issue
Block a user