Commit iniziale

This commit is contained in:
2026-05-18 15:25:38 +02:00
commit a8d4c158b8
79 changed files with 8730 additions and 0 deletions
+125
View File
@@ -0,0 +1,125 @@
'use client'
import { Suspense, useEffect, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import Link from 'next/link'
import { Navbar } from '@/components/storefront/Navbar'
import { Badge } from '@/components/ui/Badge'
import { Alert } from '@/components/ui/Alert'
interface Order {
id: string
status: string
grandTotal: number
currency: string
createdAt: string
items: Array<{ title: string; quantity: number; unitPrice: number }>
}
const statusVariant: Record<string, 'default' | 'success' | 'warning' | 'danger' | 'info'> = {
PENDING: 'warning',
PAID: 'success',
FULFILLED: 'success',
CANCELLED: 'danger',
REFUNDED: 'info',
}
export default function OrdersPage() {
return (
<Suspense>
<OrdersContent />
</Suspense>
)
}
function OrdersContent() {
const [orders, setOrders] = useState<Order[]>([])
const [loading, setLoading] = useState(true)
const router = useRouter()
const searchParams = useSearchParams()
const success = searchParams.get('success')
useEffect(() => {
const stored = localStorage.getItem('user')
if (!stored) {
router.push('/login?redirect=/account/orders')
return
}
fetch('/api/orders')
.then((r) => r.json())
.then((data) => {
setOrders(data.orders || [])
setLoading(false)
})
.catch(() => setLoading(false))
}, [router])
return (
<div>
<Navbar />
<main className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">My Orders</h1>
<Link href="/account" className="text-sm text-blue-600 hover:underline">
Back to Account
</Link>
</div>
{success && (
<Alert variant="success" className="mb-6">
Payment successful! Your order has been confirmed.
</Alert>
)}
{loading ? (
<div className="animate-pulse space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="h-24 bg-gray-200 rounded"></div>
))}
</div>
) : orders.length === 0 ? (
<div className="text-center py-16 text-gray-500">
<p>No orders yet.</p>
<Link href="/products" className="mt-3 inline-block text-blue-600 hover:underline">
Start shopping
</Link>
</div>
) : (
<div className="space-y-4">
{orders.map((order) => (
<div key={order.id} className="bg-white rounded-lg border border-gray-200 p-6">
<div className="flex items-center justify-between mb-3">
<div>
<p className="text-sm text-gray-500">Order #{order.id.slice(-8).toUpperCase()}</p>
<p className="text-xs text-gray-400">
{new Date(order.createdAt).toLocaleDateString()}
</p>
</div>
<div className="text-right">
<Badge variant={statusVariant[order.status] || 'default'}>
{order.status}
</Badge>
<p className="text-sm font-bold mt-1">
{(order.grandTotal / 100).toFixed(2)} {order.currency}
</p>
</div>
</div>
<div className="border-t pt-3">
<ul className="space-y-1">
{order.items.map((item, i) => (
<li key={i} className="text-sm text-gray-600">
{item.title} × {item.quantity} {' '}
{((item.unitPrice * item.quantity) / 100).toFixed(2)} EUR
</li>
))}
</ul>
</div>
</div>
))}
</div>
)}
</main>
</div>
)
}
+80
View File
@@ -0,0 +1,80 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { Navbar } from '@/components/storefront/Navbar'
interface User {
id: string
email: string
name?: string
role: string
}
export default function AccountPage() {
const [user, setUser] = useState<User | null>(null)
const router = useRouter()
useEffect(() => {
const stored = localStorage.getItem('user')
if (!stored) {
router.push('/login?redirect=/account')
return
}
try {
setUser(JSON.parse(stored))
} catch {
router.push('/login')
}
}, [router])
if (!user) return null
return (
<div>
<Navbar />
<main className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<h1 className="text-2xl font-bold mb-6">My Account</h1>
<div className="bg-white rounded-lg border border-gray-200 p-6 mb-6">
<h2 className="font-semibold mb-4">Profile</h2>
<dl className="space-y-2">
<div className="flex">
<dt className="w-24 text-sm text-gray-500">Name:</dt>
<dd className="text-sm font-medium">{user.name || '—'}</dd>
</div>
<div className="flex">
<dt className="w-24 text-sm text-gray-500">Email:</dt>
<dd className="text-sm font-medium">{user.email}</dd>
</div>
<div className="flex">
<dt className="w-24 text-sm text-gray-500">Role:</dt>
<dd className="text-sm font-medium capitalize">{user.role.toLowerCase()}</dd>
</div>
</dl>
</div>
<div className="grid grid-cols-2 gap-4">
<Link
href="/account/orders"
className="bg-white rounded-lg border border-gray-200 p-6 hover:shadow-md transition-shadow text-center"
>
<div className="text-3xl mb-2">📦</div>
<h3 className="font-semibold">My Orders</h3>
<p className="text-sm text-gray-500 mt-1">View your order history</p>
</Link>
<Link
href="/products"
className="bg-white rounded-lg border border-gray-200 p-6 hover:shadow-md transition-shadow text-center"
>
<div className="text-3xl mb-2">🛍</div>
<h3 className="font-semibold">Shop</h3>
<p className="text-sm text-gray-500 mt-1">Browse our products</p>
</Link>
</div>
</main>
</div>
)
}
+192
View File
@@ -0,0 +1,192 @@
'use client'
import { useEffect, useState } from 'react'
import { Badge } from '@/components/ui/Badge'
import { Input, Select } from '@/components/ui/Input'
import { Button } from '@/components/ui/Button'
import { Alert } from '@/components/ui/Alert'
interface AdminUser {
id: string
email: string
name: string | null
role: string
createdAt: string
mustChangePassword: boolean
}
export default function AdminUsersPage() {
const [users, setUsers] = useState<AdminUser[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [showForm, setShowForm] = useState(false)
const [email, setEmail] = useState('')
const [name, setName] = useState('')
const [role, setRole] = useState('ADMIN')
const [password, setPassword] = useState('')
const [creating, setCreating] = useState(false)
async function loadUsers() {
const res = await fetch('/api/admin/users')
if (res.ok) {
const data = await res.json()
setUsers(data.users || [])
} else {
setError('You do not have permission to manage admin users (Owner role required)')
}
setLoading(false)
}
useEffect(() => {
loadUsers()
}, [])
async function handleCreate(e: React.FormEvent) {
e.preventDefault()
setError('')
setCreating(true)
const res = await fetch('/api/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, name, role, password }),
})
const data = await res.json()
if (res.ok) {
setSuccess('Admin user created!')
setShowForm(false)
setEmail('')
setName('')
setPassword('')
loadUsers()
} else {
setError(data.error || 'Failed to create user')
}
setCreating(false)
}
async function deleteUser(id: string) {
if (!confirm('Delete this admin user?')) return
const res = await fetch(`/api/admin/users?id=${id}`, { method: 'DELETE' })
if (res.ok) {
loadUsers()
}
}
return (
<div className="p-8">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Admin Users</h1>
<Button onClick={() => setShowForm(!showForm)}>
{showForm ? 'Cancel' : '+ New Admin'}
</Button>
</div>
{error && <Alert variant="error" className="mb-4">{error}</Alert>}
{success && <Alert variant="success" className="mb-4">{success}</Alert>}
{showForm && (
<div className="bg-white rounded-lg border border-gray-200 p-6 mb-6">
<h2 className="font-semibold mb-4">New Admin User</h2>
<form onSubmit={handleCreate} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<Input
label="Name"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
<Input
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Select
label="Role"
value={role}
onChange={(e) => setRole(e.target.value)}
>
<option value="ADMIN">Admin</option>
<option value="OWNER">Owner</option>
</Select>
<Input
label="Password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<p className="text-xs text-gray-500">
User will be required to change their password on first login.
</p>
<div className="flex gap-3">
<Button type="submit" loading={creating}>Create</Button>
<Button type="button" variant="secondary" onClick={() => setShowForm(false)}>Cancel</Button>
</div>
</form>
</div>
)}
<div className="bg-white rounded-lg border border-gray-200">
{loading ? (
<div className="p-8 text-center text-gray-500">Loading...</div>
) : users.length === 0 ? (
<div className="p-8 text-center text-gray-500">No admin users found.</div>
) : (
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="text-left px-4 py-3 font-medium text-gray-600">Name</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Email</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Role</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Status</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Created</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{users.map((u) => (
<tr key={u.id} className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium">{u.name || '—'}</td>
<td className="px-4 py-3 text-gray-600">{u.email}</td>
<td className="px-4 py-3">
<Badge variant={u.role === 'OWNER' ? 'info' : 'default'}>
{u.role}
</Badge>
</td>
<td className="px-4 py-3">
{u.mustChangePassword ? (
<span className="text-xs text-yellow-600">Must change password</span>
) : (
<span className="text-xs text-green-600">Active</span>
)}
</td>
<td className="px-4 py-3 text-gray-500 text-xs">
{new Date(u.createdAt).toLocaleDateString()}
</td>
<td className="px-4 py-3">
<button
onClick={() => deleteUser(u.id)}
className="text-red-600 hover:underline text-xs"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
)
}
+188
View File
@@ -0,0 +1,188 @@
'use client'
import { useEffect, useState } from 'react'
import { Input, Select } from '@/components/ui/Input'
import { Button } from '@/components/ui/Button'
import { Alert } from '@/components/ui/Alert'
interface Category {
id: string
name: string
slug: string
parentId: string | null
_count: { products: number }
}
export default function AdminCategoriesPage() {
const [categories, setCategories] = useState<Category[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [showForm, setShowForm] = useState(false)
const [editId, setEditId] = useState<string | null>(null)
const [name, setName] = useState('')
const [slug, setSlug] = useState('')
const [parentId, setParentId] = useState('')
async function loadCategories() {
const res = await fetch('/api/admin/categories')
const data = await res.json()
setCategories(data.categories || [])
setLoading(false)
}
useEffect(() => {
loadCategories()
}, [])
function generateSlug(n: string) {
return n.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
const url = '/api/admin/categories'
const method = editId ? 'PUT' : 'POST'
const body = editId
? { id: editId, name, slug, parentId: parentId || null }
: { name, slug, parentId: parentId || null }
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (res.ok) {
setSuccess(editId ? 'Updated!' : 'Created!')
setShowForm(false)
setEditId(null)
setName('')
setSlug('')
setParentId('')
loadCategories()
} else {
const data = await res.json()
setError(data.error || 'Failed')
}
}
function startEdit(c: Category) {
setEditId(c.id)
setName(c.name)
setSlug(c.slug)
setParentId(c.parentId || '')
setShowForm(true)
}
async function handleDelete(id: string) {
if (!confirm('Delete this category?')) return
const res = await fetch(`/api/admin/categories?id=${id}`, { method: 'DELETE' })
if (res.ok) {
loadCategories()
}
}
return (
<div className="p-8">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Categories</h1>
<Button onClick={() => { setShowForm(!showForm); setEditId(null); setName(''); setSlug(''); setParentId('') }}>
{showForm ? 'Cancel' : '+ New Category'}
</Button>
</div>
{error && <Alert variant="error" className="mb-4">{error}</Alert>}
{success && <Alert variant="success" className="mb-4">{success}</Alert>}
{showForm && (
<div className="bg-white rounded-lg border border-gray-200 p-6 mb-6">
<h2 className="font-semibold mb-4">{editId ? 'Edit Category' : 'New Category'}</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<Input
label="Name"
value={name}
onChange={(e) => {
setName(e.target.value)
if (!editId) setSlug(generateSlug(e.target.value))
}}
required
/>
<Input
label="Slug"
value={slug}
onChange={(e) => setSlug(e.target.value)}
required
/>
</div>
<Select
label="Parent Category (optional)"
value={parentId}
onChange={(e) => setParentId(e.target.value)}
>
<option value="">None (top-level)</option>
{categories
.filter((c) => c.id !== editId)
.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</Select>
<div className="flex gap-3">
<Button type="submit">{editId ? 'Update' : 'Create'}</Button>
<Button type="button" variant="secondary" onClick={() => setShowForm(false)}>Cancel</Button>
</div>
</form>
</div>
)}
<div className="bg-white rounded-lg border border-gray-200">
{loading ? (
<div className="p-8 text-center text-gray-500">Loading...</div>
) : categories.length === 0 ? (
<div className="p-8 text-center text-gray-500">No categories yet.</div>
) : (
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="text-left px-4 py-3 font-medium text-gray-600">Name</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Slug</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Parent</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Products</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{categories.map((c) => (
<tr key={c.id} className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium">{c.name}</td>
<td className="px-4 py-3 text-gray-600">{c.slug}</td>
<td className="px-4 py-3 text-gray-600">
{c.parentId ? categories.find((p) => p.id === c.parentId)?.name || '—' : '—'}
</td>
<td className="px-4 py-3 text-gray-600">{c._count.products}</td>
<td className="px-4 py-3">
<div className="flex gap-2">
<button onClick={() => startEdit(c)} className="text-blue-600 hover:underline text-xs">
Edit
</button>
{c._count.products === 0 && (
<button onClick={() => handleDelete(c.id)} className="text-red-600 hover:underline text-xs">
Delete
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
)
}
+107
View File
@@ -0,0 +1,107 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Input } from '@/components/ui/Input'
import { Button } from '@/components/ui/Button'
import { Alert } from '@/components/ui/Alert'
export default function ChangePasswordPage() {
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [error, setError] = useState('')
const [success, setSuccess] = useState(false)
const [loading, setLoading] = useState(false)
const router = useRouter()
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
if (newPassword !== confirm) {
setError('New passwords do not match')
return
}
setLoading(true)
try {
const response = await fetch('/api/auth/change-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword, newPassword }),
})
const data = await response.json()
if (!response.ok) {
setError(data.error || 'Failed to change password')
return
}
setSuccess(true)
// Update localStorage to remove mustChangePassword flag
const userStr = localStorage.getItem('user')
if (userStr) {
const user = JSON.parse(userStr)
user.mustChangePassword = false
localStorage.setItem('user', JSON.stringify(user))
}
setTimeout(() => router.push('/admin'), 1500)
} catch {
setError('Something went wrong')
} finally {
setLoading(false)
}
}
return (
<div className="p-8 max-w-md">
<h1 className="text-2xl font-bold mb-2">Change Password</h1>
<p className="text-gray-600 mb-6">
You must change your password before continuing.
</p>
{error && <Alert variant="error" className="mb-4">{error}</Alert>}
{success && (
<Alert variant="success" className="mb-4">
Password changed successfully! Redirecting...
</Alert>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Current Password"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
/>
<div>
<Input
label="New Password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
/>
<p className="text-xs text-gray-500 mt-1">
Min 12 chars, uppercase, lowercase, number, symbol.
</p>
</div>
<Input
label="Confirm New Password"
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
required
/>
<Button type="submit" loading={loading} className="w-full">
Change Password
</Button>
</form>
</div>
)
}
+68
View File
@@ -0,0 +1,68 @@
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
import { redirect } from 'next/navigation'
async function getCustomers() {
return prisma.user.findMany({
where: { role: 'CUSTOMER' },
select: {
id: true,
email: true,
name: true,
createdAt: true,
emailVerifiedAt: true,
_count: { select: { orders: true } },
},
orderBy: { createdAt: 'desc' },
})
}
export default async function AdminCustomersPage() {
const user = await getCurrentUser()
if (!user || (user.role !== 'ADMIN' && user.role !== 'OWNER')) redirect('/login')
const customers = await getCustomers()
return (
<div className="p-8">
<h1 className="text-2xl font-bold mb-6">Customers</h1>
<div className="bg-white rounded-lg border border-gray-200">
{customers.length === 0 ? (
<div className="p-8 text-center text-gray-500">No customers yet.</div>
) : (
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="text-left px-4 py-3 font-medium text-gray-600">Customer</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Email</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Orders</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Verified</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Joined</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{customers.map((c) => (
<tr key={c.id} className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium">{c.name || '—'}</td>
<td className="px-4 py-3 text-gray-600">{c.email}</td>
<td className="px-4 py-3 text-gray-600">{c._count.orders}</td>
<td className="px-4 py-3">
{c.emailVerifiedAt ? (
<span className="text-green-600 text-xs">Verified</span>
) : (
<span className="text-gray-400 text-xs">Not verified</span>
)}
</td>
<td className="px-4 py-3 text-gray-500 text-xs">
{new Date(c.createdAt).toLocaleDateString()}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
)
}
+44
View File
@@ -0,0 +1,44 @@
import { redirect } from 'next/navigation'
import { headers } from 'next/headers'
import { getCurrentUser } from '@/lib/auth'
import { AdminNav } from '@/components/admin/AdminNav'
export default async function AdminLayout({
children,
}: {
children: React.ReactNode
}) {
const user = await getCurrentUser()
if (!user) {
redirect('/login')
}
if (user.role === 'CUSTOMER') {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<h1 className="text-2xl font-bold text-gray-900 mb-2">Access Denied</h1>
<p className="text-gray-600">You do not have permission to access this area.</p>
</div>
</div>
)
}
if (user.mustChangePassword) {
const headersList = await headers()
const pathname = headersList.get('x-pathname') ?? ''
if (!pathname.startsWith('/admin/change-password')) {
redirect('/admin/change-password')
}
}
return (
<div className="flex min-h-screen">
<AdminNav />
<main className="flex-1 bg-gray-50 overflow-auto">
{children}
</main>
</div>
)
}
+223
View File
@@ -0,0 +1,223 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { Badge } from '@/components/ui/Badge'
import { Select } from '@/components/ui/Input'
import { Button } from '@/components/ui/Button'
import { Alert } from '@/components/ui/Alert'
interface Order {
id: string
status: string
grandTotal: number
currency: string
subtotal: number
taxTotal: number
shippingTotal: number
createdAt: string
user: { email: string; name: string | null }
items: Array<{
id: string
title: string
quantity: number
unitPrice: number
totalPrice: number
product: { title: string; slug: string }
}>
payment: {
provider: string
status: string
amount: number
providerPaymentId: string | null
} | null
}
const statusVariant: Record<string, 'default' | 'success' | 'warning' | 'danger' | 'info'> = {
PENDING: 'warning',
PAID: 'success',
FULFILLED: 'success',
CANCELLED: 'danger',
REFUNDED: 'info',
}
export default function AdminOrderDetailPage() {
const params = useParams()
const router = useRouter()
const [order, setOrder] = useState<Order | null>(null)
const [loading, setLoading] = useState(true)
const [newStatus, setNewStatus] = useState('')
const [updating, setUpdating] = useState(false)
const [message, setMessage] = useState('')
const [error, setError] = useState('')
useEffect(() => {
fetch(`/api/admin/orders/${params.id}`)
.then((r) => r.json())
.then((data) => {
setOrder(data.order)
setNewStatus(data.order?.status || '')
setLoading(false)
})
}, [params.id])
async function updateStatus() {
setUpdating(true)
setError('')
setMessage('')
const res = await fetch(`/api/admin/orders/${params.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus }),
})
if (res.ok) {
const data = await res.json()
setOrder((o) => o ? { ...o, status: data.order.status } : o)
setMessage('Status updated!')
} else {
const data = await res.json()
setError(data.error || 'Failed')
}
setUpdating(false)
}
if (loading) return <div className="p-8 text-gray-500">Loading...</div>
if (!order) return <div className="p-8 text-gray-500">Order not found</div>
return (
<div className="p-8 max-w-3xl">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">
Order #{order.id.slice(-8).toUpperCase()}
</h1>
<button
onClick={() => router.push('/admin/orders')}
className="text-sm text-gray-600 hover:text-gray-900"
>
Back to Orders
</button>
</div>
{error && <Alert variant="error" className="mb-4">{error}</Alert>}
{message && <Alert variant="success" className="mb-4">{message}</Alert>}
<div className="grid grid-cols-2 gap-6 mb-6">
<div className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="font-semibold mb-3">Customer</h2>
<p className="text-sm font-medium">{order.user.name || '—'}</p>
<p className="text-sm text-gray-600">{order.user.email}</p>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="font-semibold mb-3">Status</h2>
<div className="flex items-center gap-3 mb-3">
<Badge variant={statusVariant[order.status] || 'default'}>
{order.status}
</Badge>
<span className="text-xs text-gray-500">
{new Date(order.createdAt).toLocaleString()}
</span>
</div>
<div className="flex gap-2">
<Select
value={newStatus}
onChange={(e) => setNewStatus(e.target.value)}
className="text-sm"
>
<option value="PENDING">PENDING</option>
<option value="PAID">PAID</option>
<option value="FULFILLED">FULFILLED</option>
<option value="CANCELLED">CANCELLED</option>
<option value="REFUNDED">REFUNDED</option>
</Select>
<Button size="sm" loading={updating} onClick={updateStatus}>
Update
</Button>
</div>
</div>
</div>
<div className="bg-white rounded-lg border border-gray-200 mb-6">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="font-semibold">Items</h2>
</div>
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="text-left px-4 py-2 text-gray-600">Product</th>
<th className="text-right px-4 py-2 text-gray-600">Qty</th>
<th className="text-right px-4 py-2 text-gray-600">Unit Price</th>
<th className="text-right px-4 py-2 text-gray-600">Total</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{order.items.map((item) => (
<tr key={item.id}>
<td className="px-4 py-3">{item.title}</td>
<td className="px-4 py-3 text-right">{item.quantity}</td>
<td className="px-4 py-3 text-right">
{(item.unitPrice / 100).toFixed(2)} {order.currency}
</td>
<td className="px-4 py-3 text-right font-medium">
{(item.totalPrice / 100).toFixed(2)} {order.currency}
</td>
</tr>
))}
</tbody>
<tfoot className="bg-gray-50">
<tr>
<td colSpan={3} className="px-4 py-2 text-right font-medium">Subtotal</td>
<td className="px-4 py-2 text-right">{(order.subtotal / 100).toFixed(2)} {order.currency}</td>
</tr>
{order.taxTotal > 0 && (
<tr>
<td colSpan={3} className="px-4 py-2 text-right font-medium">Tax</td>
<td className="px-4 py-2 text-right">{(order.taxTotal / 100).toFixed(2)} {order.currency}</td>
</tr>
)}
{order.shippingTotal > 0 && (
<tr>
<td colSpan={3} className="px-4 py-2 text-right font-medium">Shipping</td>
<td className="px-4 py-2 text-right">{(order.shippingTotal / 100).toFixed(2)} {order.currency}</td>
</tr>
)}
<tr className="border-t">
<td colSpan={3} className="px-4 py-2 text-right font-bold">Total</td>
<td className="px-4 py-2 text-right font-bold">
{(order.grandTotal / 100).toFixed(2)} {order.currency}
</td>
</tr>
</tfoot>
</table>
</div>
{order.payment && (
<div className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="font-semibold mb-3">Payment</h2>
<dl className="space-y-2 text-sm">
<div className="flex">
<dt className="w-36 text-gray-500">Provider:</dt>
<dd className="font-medium capitalize">{order.payment.provider}</dd>
</div>
<div className="flex">
<dt className="w-36 text-gray-500">Status:</dt>
<dd className="font-medium">{order.payment.status}</dd>
</div>
<div className="flex">
<dt className="w-36 text-gray-500">Amount:</dt>
<dd className="font-medium">
{(order.payment.amount / 100).toFixed(2)} {order.currency}
</dd>
</div>
{order.payment.providerPaymentId && (
<div className="flex">
<dt className="w-36 text-gray-500">Payment ID:</dt>
<dd className="font-mono text-xs">{order.payment.providerPaymentId}</dd>
</div>
)}
</dl>
</div>
)}
</div>
)
}
+126
View File
@@ -0,0 +1,126 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { useSearchParams } from 'next/navigation'
import { Badge } from '@/components/ui/Badge'
interface Order {
id: string
status: string
grandTotal: number
currency: string
createdAt: string
user: { email: string; name: string | null }
items: Array<{ title: string; quantity: number }>
}
const statusVariant: Record<string, 'default' | 'success' | 'warning' | 'danger' | 'info'> = {
PENDING: 'warning',
PAID: 'success',
FULFILLED: 'success',
CANCELLED: 'danger',
REFUNDED: 'info',
}
export default function AdminOrdersPage() {
const [orders, setOrders] = useState<Order[]>([])
const [loading, setLoading] = useState(true)
const [statusFilter, setStatusFilter] = useState('')
const searchParams = useSearchParams()
useEffect(() => {
const s = searchParams.get('status') || ''
setStatusFilter(s)
}, [searchParams])
useEffect(() => {
const params = new URLSearchParams()
if (statusFilter) params.set('status', statusFilter)
fetch(`/api/admin/orders?${params}`)
.then((r) => r.json())
.then((data) => {
setOrders(data.orders || [])
setLoading(false)
})
}, [statusFilter])
return (
<div className="p-8">
<h1 className="text-2xl font-bold mb-6">Orders</h1>
<div className="flex gap-3 mb-6">
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">All Status</option>
<option value="PENDING">Pending</option>
<option value="PAID">Paid</option>
<option value="FULFILLED">Fulfilled</option>
<option value="CANCELLED">Cancelled</option>
<option value="REFUNDED">Refunded</option>
</select>
</div>
<div className="bg-white rounded-lg border border-gray-200">
{loading ? (
<div className="p-8 text-center text-gray-500">Loading...</div>
) : orders.length === 0 ? (
<div className="p-8 text-center text-gray-500">No orders found.</div>
) : (
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="text-left px-4 py-3 font-medium text-gray-600">Order</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Customer</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Items</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Total</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Status</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Date</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{orders.map((order) => (
<tr key={order.id} className="hover:bg-gray-50">
<td className="px-4 py-3 font-mono text-xs">
#{order.id.slice(-8).toUpperCase()}
</td>
<td className="px-4 py-3">
<p className="font-medium">{order.user.name || '—'}</p>
<p className="text-xs text-gray-500">{order.user.email}</p>
</td>
<td className="px-4 py-3 text-gray-600">
{order.items.map((i) => `${i.title} ×${i.quantity}`).join(', ')}
</td>
<td className="px-4 py-3 font-medium">
{(order.grandTotal / 100).toFixed(2)} {order.currency}
</td>
<td className="px-4 py-3">
<Badge variant={statusVariant[order.status] || 'default'}>
{order.status}
</Badge>
</td>
<td className="px-4 py-3 text-gray-500 text-xs">
{new Date(order.createdAt).toLocaleDateString()}
</td>
<td className="px-4 py-3">
<Link
href={`/admin/orders/${order.id}`}
className="text-blue-600 hover:underline text-xs"
>
View
</Link>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
)
}
+158
View File
@@ -0,0 +1,158 @@
import { redirect } from 'next/navigation'
import { getCurrentUser } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import Link from 'next/link'
import { Badge } from '@/components/ui/Badge'
async function getDashboardData() {
const [totalOrders, pendingOrders, totalRevenue, totalProducts, totalCustomers, recentOrders, pendingReviews] =
await Promise.all([
prisma.order.count(),
prisma.order.count({ where: { status: 'PENDING' } }),
prisma.order.aggregate({
where: { status: { in: ['PAID', 'FULFILLED'] } },
_sum: { grandTotal: true },
}),
prisma.product.count({ where: { status: { not: 'ARCHIVED' } } }),
prisma.user.count({ where: { role: 'CUSTOMER' } }),
prisma.order.findMany({
take: 5,
orderBy: { createdAt: 'desc' },
include: { user: { select: { email: true, name: true } } },
}),
prisma.review.count({ where: { status: 'PENDING' } }),
])
return {
stats: {
totalOrders,
pendingOrders,
totalRevenue: totalRevenue._sum.grandTotal ?? 0,
totalProducts,
totalCustomers,
pendingReviews,
},
recentOrders,
}
}
const statusVariant: Record<string, 'default' | 'success' | 'warning' | 'danger' | 'info'> = {
PENDING: 'warning',
PAID: 'success',
FULFILLED: 'success',
CANCELLED: 'danger',
REFUNDED: 'info',
}
export default async function AdminDashboardPage() {
const user = await getCurrentUser()
if (!user) redirect('/login')
if (user.mustChangePassword) redirect('/admin/change-password')
const { stats, recentOrders } = await getDashboardData()
return (
<div className="p-8">
<h1 className="text-2xl font-bold mb-8">Dashboard</h1>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-8">
<div className="bg-white rounded-lg border border-gray-200 p-4">
<p className="text-sm text-gray-500">Total Orders</p>
<p className="text-2xl font-bold mt-1">{stats.totalOrders}</p>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-4">
<p className="text-sm text-gray-500">Pending Orders</p>
<p className="text-2xl font-bold mt-1 text-yellow-600">{stats.pendingOrders}</p>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-4">
<p className="text-sm text-gray-500">Revenue</p>
<p className="text-2xl font-bold mt-1 text-green-600">
{(stats.totalRevenue / 100).toFixed(0)} EUR
</p>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-4">
<p className="text-sm text-gray-500">Products</p>
<p className="text-2xl font-bold mt-1">{stats.totalProducts}</p>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-4">
<p className="text-sm text-gray-500">Customers</p>
<p className="text-2xl font-bold mt-1">{stats.totalCustomers}</p>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-4">
<p className="text-sm text-gray-500">Pending Reviews</p>
<p className="text-2xl font-bold mt-1 text-blue-600">{stats.pendingReviews}</p>
</div>
</div>
{/* Quick links */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<Link
href="/admin/products/new"
className="bg-blue-600 text-white rounded-lg p-4 text-center hover:bg-blue-700 transition-colors"
>
<p className="font-semibold">+ New Product</p>
</Link>
<Link
href="/admin/orders?status=PENDING"
className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-center hover:bg-yellow-100 transition-colors"
>
<p className="font-semibold text-yellow-800">View Pending Orders</p>
</Link>
<Link
href="/admin/reviews?status=PENDING"
className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-center hover:bg-blue-100 transition-colors"
>
<p className="font-semibold text-blue-800">Review Pending Reviews</p>
</Link>
<Link
href="/admin/settings"
className="bg-gray-50 border border-gray-200 rounded-lg p-4 text-center hover:bg-gray-100 transition-colors"
>
<p className="font-semibold text-gray-800">Settings</p>
</Link>
</div>
{/* Recent orders */}
<div className="bg-white rounded-lg border border-gray-200">
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h2 className="font-semibold">Recent Orders</h2>
<Link href="/admin/orders" className="text-sm text-blue-600 hover:underline">
View all
</Link>
</div>
{recentOrders.length === 0 ? (
<div className="px-6 py-8 text-center text-gray-500 text-sm">No orders yet</div>
) : (
<div className="divide-y">
{recentOrders.map((order) => (
<Link
key={order.id}
href={`/admin/orders/${order.id}`}
className="flex items-center justify-between px-6 py-3 hover:bg-gray-50"
>
<div>
<p className="text-sm font-medium">#{order.id.slice(-8).toUpperCase()}</p>
<p className="text-xs text-gray-500">
{order.user.name || order.user.email}
</p>
</div>
<div className="flex items-center gap-3">
<Badge variant={statusVariant[order.status] || 'default'}>
{order.status}
</Badge>
<span className="text-sm font-medium">
{(order.grandTotal / 100).toFixed(2)} EUR
</span>
<span className="text-xs text-gray-400">
{new Date(order.createdAt).toLocaleDateString()}
</span>
</div>
</Link>
))}
</div>
)}
</div>
</div>
)
}
+193
View File
@@ -0,0 +1,193 @@
'use client'
import { useEffect, useState } from 'react'
import { Input } from '@/components/ui/Input'
import { Button } from '@/components/ui/Button'
import { Alert } from '@/components/ui/Alert'
interface ProductType {
id: string
name: string
slug: string
schema: object
_count: { products: number }
}
export default function AdminProductTypesPage() {
const [types, setTypes] = useState<ProductType[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [showForm, setShowForm] = useState(false)
const [editId, setEditId] = useState<string | null>(null)
const [name, setName] = useState('')
const [slug, setSlug] = useState('')
const [schemaStr, setSchemaStr] = useState('{}')
async function loadTypes() {
const res = await fetch('/api/admin/product-types')
const data = await res.json()
setTypes(data.types || [])
setLoading(false)
}
useEffect(() => {
loadTypes()
}, [])
function generateSlug(n: string) {
return n
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
let schema: object
try {
schema = JSON.parse(schemaStr)
} catch {
setError('Schema must be valid JSON')
return
}
const url = '/api/admin/product-types'
const method = editId ? 'PUT' : 'POST'
const body = editId
? { id: editId, name, slug, schema }
: { name, slug, schema }
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (res.ok) {
setSuccess(editId ? 'Updated!' : 'Created!')
setShowForm(false)
setEditId(null)
setName('')
setSlug('')
setSchemaStr('{}')
loadTypes()
} else {
const data = await res.json()
setError(data.error || 'Failed')
}
}
function startEdit(t: ProductType) {
setEditId(t.id)
setName(t.name)
setSlug(t.slug)
setSchemaStr(JSON.stringify(t.schema, null, 2))
setShowForm(true)
}
async function handleDelete(id: string) {
if (!confirm('Delete this product type?')) return
const res = await fetch(`/api/admin/product-types?id=${id}`, { method: 'DELETE' })
if (res.ok) {
loadTypes()
}
}
return (
<div className="p-8">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Product Types</h1>
<Button onClick={() => { setShowForm(!showForm); setEditId(null); setName(''); setSlug(''); setSchemaStr('{}') }}>
{showForm ? 'Cancel' : '+ New Type'}
</Button>
</div>
{error && <Alert variant="error" className="mb-4">{error}</Alert>}
{success && <Alert variant="success" className="mb-4">{success}</Alert>}
{showForm && (
<div className="bg-white rounded-lg border border-gray-200 p-6 mb-6">
<h2 className="font-semibold mb-4">{editId ? 'Edit Type' : 'New Product Type'}</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<Input
label="Name"
value={name}
onChange={(e) => {
setName(e.target.value)
if (!editId) setSlug(generateSlug(e.target.value))
}}
required
/>
<Input
label="Slug"
value={slug}
onChange={(e) => setSlug(e.target.value)}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Schema (JSON define attribute fields)
</label>
<textarea
value={schemaStr}
onChange={(e) => setSchemaStr(e.target.value)}
rows={8}
className="block w-full rounded border border-gray-300 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={'{\n "fields": [\n {"name": "color", "type": "string"}\n ]\n}'}
/>
</div>
<div className="flex gap-3">
<Button type="submit">{editId ? 'Update' : 'Create'}</Button>
<Button type="button" variant="secondary" onClick={() => setShowForm(false)}>Cancel</Button>
</div>
</form>
</div>
)}
<div className="bg-white rounded-lg border border-gray-200">
{loading ? (
<div className="p-8 text-center text-gray-500">Loading...</div>
) : types.length === 0 ? (
<div className="p-8 text-center text-gray-500">No product types yet.</div>
) : (
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="text-left px-4 py-3 font-medium text-gray-600">Name</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Slug</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Products</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{types.map((t) => (
<tr key={t.id} className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium">{t.name}</td>
<td className="px-4 py-3 text-gray-600">{t.slug}</td>
<td className="px-4 py-3 text-gray-600">{t._count.products}</td>
<td className="px-4 py-3">
<div className="flex gap-2">
<button onClick={() => startEdit(t)} className="text-blue-600 hover:underline text-xs">
Edit
</button>
{t._count.products === 0 && (
<button onClick={() => handleDelete(t.id)} className="text-red-600 hover:underline text-xs">
Delete
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
)
}
+306
View File
@@ -0,0 +1,306 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { Input, Textarea, Select } from '@/components/ui/Input'
import { Button } from '@/components/ui/Button'
import { Alert } from '@/components/ui/Alert'
interface ProductType {
id: string
name: string
}
interface Category {
id: string
name: string
}
interface ProductForm {
typeId: string
title: string
slug: string
description: string
basePrice: string
currency: string
status: string
attributes: string
stock: string
categoryIds: string[]
}
export default function AdminProductEditPage() {
const params = useParams()
const router = useRouter()
const isNew = params.id === 'new'
const [form, setForm] = useState<ProductForm>({
typeId: '',
title: '',
slug: '',
description: '',
basePrice: '0',
currency: 'EUR',
status: 'DRAFT',
attributes: '{}',
stock: '',
categoryIds: [],
})
const [productTypes, setProductTypes] = useState<ProductType[]>([])
const [categories, setCategories] = useState<Category[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
useEffect(() => {
// Load types and categories
Promise.all([
fetch('/api/admin/product-types').then((r) => r.json()),
fetch('/api/admin/categories').then((r) => r.json()),
]).then(([typesData, catsData]) => {
setProductTypes(typesData.types || [])
setCategories(catsData.categories || [])
})
if (!isNew) {
fetch(`/api/admin/products/${params.id}`)
.then((r) => r.json())
.then((data) => {
if (data.product) {
const p = data.product
setForm({
typeId: p.typeId,
title: p.title,
slug: p.slug,
description: p.description,
basePrice: String(p.basePrice),
currency: p.currency,
status: p.status,
attributes: JSON.stringify(p.attributes, null, 2),
stock: p.stock != null ? String(p.stock) : '',
categoryIds: p.categories.map((c: { categoryId: string }) => c.categoryId),
})
}
})
}
}, [isNew, params.id])
function generateSlug(title: string) {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}
function handleTitleChange(e: React.ChangeEvent<HTMLInputElement>) {
const title = e.target.value
setForm((f) => ({
...f,
title,
slug: isNew ? generateSlug(title) : f.slug,
}))
}
function toggleCategory(id: string) {
setForm((f) => ({
...f,
categoryIds: f.categoryIds.includes(id)
? f.categoryIds.filter((c) => c !== id)
: [...f.categoryIds, id],
}))
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
setSuccess('')
setLoading(true)
let attributes: object
try {
attributes = JSON.parse(form.attributes)
} catch {
setError('Attributes must be valid JSON')
setLoading(false)
return
}
const payload = {
typeId: form.typeId,
title: form.title,
slug: form.slug,
description: form.description,
basePrice: parseInt(form.basePrice),
currency: form.currency,
status: form.status,
attributes,
stock: form.stock ? parseInt(form.stock) : null,
categoryIds: form.categoryIds,
}
const url = isNew ? '/api/admin/products' : `/api/admin/products/${params.id}`
const method = isNew ? 'POST' : 'PUT'
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const data = await res.json()
if (!res.ok) {
setError(data.error || 'Save failed')
} else {
setSuccess('Saved successfully!')
if (isNew) {
router.push(`/admin/products/${data.product.id}`)
}
}
setLoading(false)
}
return (
<div className="p-8 max-w-3xl">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">{isNew ? 'New Product' : 'Edit Product'}</h1>
<button
onClick={() => router.push('/admin/products')}
className="text-sm text-gray-600 hover:text-gray-900"
>
Back to Products
</button>
</div>
{error && <Alert variant="error" className="mb-4">{error}</Alert>}
{success && <Alert variant="success" className="mb-4">{success}</Alert>}
<form onSubmit={handleSubmit} className="space-y-6">
<div className="bg-white rounded-lg border border-gray-200 p-6 space-y-4">
<h2 className="font-semibold">Basic Information</h2>
<Select
label="Product Type"
value={form.typeId}
onChange={(e) => setForm((f) => ({ ...f, typeId: e.target.value }))}
required
>
<option value="">Select a type...</option>
{productTypes.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</Select>
<Input
label="Title"
value={form.title}
onChange={handleTitleChange}
required
/>
<Input
label="Slug"
value={form.slug}
onChange={(e) => setForm((f) => ({ ...f, slug: e.target.value }))}
required
/>
<Textarea
label="Description"
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
rows={4}
required
/>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-6 space-y-4">
<h2 className="font-semibold">Pricing & Inventory</h2>
<div className="grid grid-cols-2 gap-4">
<Input
label="Base Price (cents)"
type="number"
value={form.basePrice}
onChange={(e) => setForm((f) => ({ ...f, basePrice: e.target.value }))}
min="0"
required
/>
<Select
label="Currency"
value={form.currency}
onChange={(e) => setForm((f) => ({ ...f, currency: e.target.value }))}
>
<option value="EUR">EUR</option>
<option value="USD">USD</option>
<option value="GBP">GBP</option>
</Select>
</div>
<Input
label="Stock (leave empty for unlimited)"
type="number"
value={form.stock}
onChange={(e) => setForm((f) => ({ ...f, stock: e.target.value }))}
min="0"
/>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-6 space-y-4">
<h2 className="font-semibold">Status & Organization</h2>
<Select
label="Status"
value={form.status}
onChange={(e) => setForm((f) => ({ ...f, status: e.target.value }))}
>
<option value="DRAFT">Draft</option>
<option value="PUBLISHED">Published</option>
<option value="ARCHIVED">Archived</option>
</Select>
{categories.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Categories</label>
<div className="flex flex-wrap gap-2">
{categories.map((cat) => (
<label key={cat.id} className="flex items-center gap-1 cursor-pointer">
<input
type="checkbox"
checked={form.categoryIds.includes(cat.id)}
onChange={() => toggleCategory(cat.id)}
/>
<span className="text-sm">{cat.name}</span>
</label>
))}
</div>
</div>
)}
</div>
<div className="bg-white rounded-lg border border-gray-200 p-6 space-y-4">
<h2 className="font-semibold">Custom Attributes (JSON)</h2>
<Textarea
label="Attributes"
value={form.attributes}
onChange={(e) => setForm((f) => ({ ...f, attributes: e.target.value }))}
rows={6}
className="font-mono text-xs"
/>
</div>
<div className="flex gap-3">
<Button type="submit" loading={loading}>
{isNew ? 'Create Product' : 'Save Changes'}
</Button>
<Button type="button" variant="secondary" onClick={() => router.push('/admin/products')}>
Cancel
</Button>
</div>
</form>
</div>
)
}
+161
View File
@@ -0,0 +1,161 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { Alert } from '@/components/ui/Alert'
interface Product {
id: string
title: string
slug: string
status: string
basePrice: number
currency: string
createdAt: string
type: { name: string }
_count: { variants: number; orderItems: number }
}
const statusVariant: Record<string, 'default' | 'success' | 'warning' | 'danger' | 'info'> = {
DRAFT: 'warning',
PUBLISHED: 'success',
ARCHIVED: 'danger',
}
export default function AdminProductsPage() {
const [products, setProducts] = useState<Product[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState('')
async function loadProducts() {
setLoading(true)
const params = new URLSearchParams()
if (search) params.set('search', search)
if (statusFilter) params.set('status', statusFilter)
const res = await fetch(`/api/admin/products?${params}`)
const data = await res.json()
if (res.ok) {
setProducts(data.products)
} else {
setError(data.error)
}
setLoading(false)
}
useEffect(() => {
loadProducts()
}, [statusFilter])
async function archiveProduct(id: string) {
if (!confirm('Archive this product?')) return
const res = await fetch(`/api/admin/products/${id}`, { method: 'DELETE' })
if (res.ok) {
loadProducts()
}
}
return (
<div className="p-8">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Products</h1>
<Link href="/admin/products/new">
<Button>+ New Product</Button>
</Link>
</div>
{error && <Alert variant="error" className="mb-4">{error}</Alert>}
<div className="flex gap-3 mb-6">
<input
type="text"
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && loadProducts()}
className="border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">All Status</option>
<option value="DRAFT">Draft</option>
<option value="PUBLISHED">Published</option>
<option value="ARCHIVED">Archived</option>
</select>
<Button variant="secondary" onClick={loadProducts}>
Search
</Button>
</div>
<div className="bg-white rounded-lg border border-gray-200">
{loading ? (
<div className="p-8 text-center text-gray-500">Loading...</div>
) : products.length === 0 ? (
<div className="p-8 text-center text-gray-500">No products found.</div>
) : (
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="text-left px-4 py-3 font-medium text-gray-600">Product</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Type</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Price</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Status</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Orders</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Created</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{products.map((product) => (
<tr key={product.id} className="hover:bg-gray-50">
<td className="px-4 py-3">
<div>
<p className="font-medium">{product.title}</p>
<p className="text-xs text-gray-500">{product.slug}</p>
</div>
</td>
<td className="px-4 py-3 text-gray-600">{product.type.name}</td>
<td className="px-4 py-3">
{(product.basePrice / 100).toFixed(2)} {product.currency}
</td>
<td className="px-4 py-3">
<Badge variant={statusVariant[product.status] || 'default'}>
{product.status}
</Badge>
</td>
<td className="px-4 py-3 text-gray-600">{product._count.orderItems}</td>
<td className="px-4 py-3 text-gray-500">
{new Date(product.createdAt).toLocaleDateString()}
</td>
<td className="px-4 py-3">
<div className="flex gap-2">
<Link
href={`/admin/products/${product.id}`}
className="text-blue-600 hover:underline text-xs"
>
Edit
</Link>
<button
onClick={() => archiveProduct(product.id)}
className="text-red-600 hover:underline text-xs"
>
Archive
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
)
}
+137
View File
@@ -0,0 +1,137 @@
'use client'
import { useEffect, useState } from 'react'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { Alert } from '@/components/ui/Alert'
interface Review {
id: string
rating: number
title: string | null
comment: string | null
status: string
verified: boolean
createdAt: string
user: { email: string; name: string | null }
product: { title: string; slug: string }
}
const statusVariant: Record<string, 'default' | 'success' | 'warning' | 'danger' | 'info'> = {
PENDING: 'warning',
APPROVED: 'success',
HIDDEN: 'danger',
}
export default function AdminReviewsPage() {
const [reviews, setReviews] = useState<Review[]>([])
const [loading, setLoading] = useState(true)
const [statusFilter, setStatusFilter] = useState('')
const [message, setMessage] = useState('')
async function loadReviews() {
const params = new URLSearchParams()
if (statusFilter) params.set('status', statusFilter)
const res = await fetch(`/api/admin/reviews?${params}`)
const data = await res.json()
setReviews(data.reviews || [])
setLoading(false)
}
useEffect(() => {
loadReviews()
}, [statusFilter])
async function updateStatus(id: string, status: string) {
const res = await fetch('/api/admin/reviews', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, status }),
})
if (res.ok) {
setMessage(`Review ${status.toLowerCase()}!`)
loadReviews()
}
}
return (
<div className="p-8">
<h1 className="text-2xl font-bold mb-6">Reviews</h1>
{message && <Alert variant="success" className="mb-4">{message}</Alert>}
<div className="flex gap-3 mb-6">
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">All Status</option>
<option value="PENDING">Pending</option>
<option value="APPROVED">Approved</option>
<option value="HIDDEN">Hidden</option>
</select>
</div>
<div className="space-y-4">
{loading ? (
<div className="p-8 text-center text-gray-500">Loading...</div>
) : reviews.length === 0 ? (
<div className="p-8 text-center text-gray-500">No reviews found.</div>
) : (
reviews.map((review) => (
<div key={review.id} className="bg-white rounded-lg border border-gray-200 p-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<div className="flex">
{[1, 2, 3, 4, 5].map((star) => (
<span key={star} className={star <= review.rating ? 'text-yellow-400' : 'text-gray-300'}>
</span>
))}
</div>
<Badge variant={statusVariant[review.status] || 'default'}>
{review.status}
</Badge>
{review.verified && (
<span className="text-xs text-green-600">Verified purchase</span>
)}
</div>
<p className="text-sm font-medium">{review.product.title}</p>
<p className="text-xs text-gray-500">
by {review.user.name || review.user.email} {' '}
{new Date(review.createdAt).toLocaleDateString()}
</p>
{review.title && (
<p className="mt-2 font-medium">{review.title}</p>
)}
{review.comment && (
<p className="mt-1 text-sm text-gray-600">{review.comment}</p>
)}
</div>
<div className="flex gap-2 ml-4">
{review.status !== 'APPROVED' && (
<Button size="sm" variant="secondary" onClick={() => updateStatus(review.id, 'APPROVED')}>
Approve
</Button>
)}
{review.status !== 'HIDDEN' && (
<Button size="sm" variant="danger" onClick={() => updateStatus(review.id, 'HIDDEN')}>
Hide
</Button>
)}
{review.status !== 'PENDING' && (
<Button size="sm" variant="ghost" onClick={() => updateStatus(review.id, 'PENDING')}>
Pending
</Button>
)}
</div>
</div>
</div>
))
)}
</div>
</div>
)
}
+101
View File
@@ -0,0 +1,101 @@
'use client'
import { useEffect, useState } from 'react'
import { Input } from '@/components/ui/Input'
import { Button } from '@/components/ui/Button'
import { Alert } from '@/components/ui/Alert'
const DEFAULT_SETTINGS = [
{ key: 'site_name', label: 'Site Name', type: 'text', defaultValue: 'ShopX' },
{ key: 'site_description', label: 'Site Description', type: 'text', defaultValue: 'Your online store' },
{ key: 'support_email', label: 'Support Email', type: 'email', defaultValue: 'support@example.com' },
{ key: 'currency', label: 'Default Currency', type: 'text', defaultValue: 'EUR' },
{ key: 'tax_rate', label: 'Tax Rate (%)', type: 'number', defaultValue: '0' },
]
export default function AdminSettingsPage() {
const [values, setValues] = useState<Record<string, string>>({})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState('')
const [error, setError] = useState('')
useEffect(() => {
fetch('/api/admin/settings')
.then((r) => r.json())
.then((data) => {
const settings = data.settings || {}
const initial: Record<string, string> = {}
DEFAULT_SETTINGS.forEach((s) => {
initial[s.key] = String((settings[s.key] as string | undefined) ?? s.defaultValue)
})
setValues(initial)
setLoading(false)
})
}, [])
async function saveSetting(key: string, value: string) {
setSaving(true)
setError('')
const res = await fetch('/api/admin/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, value }),
})
setSaving(false)
if (res.ok) {
setMessage('Settings saved!')
setTimeout(() => setMessage(''), 3000)
} else {
const data = await res.json()
setError(data.error || 'Failed to save')
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setSaving(true)
setError('')
for (const s of DEFAULT_SETTINGS) {
await fetch('/api/admin/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: s.key, value: values[s.key] }),
})
}
setSaving(false)
setMessage('All settings saved!')
setTimeout(() => setMessage(''), 3000)
}
if (loading) return <div className="p-8 text-gray-500">Loading...</div>
return (
<div className="p-8 max-w-2xl">
<h1 className="text-2xl font-bold mb-6">Settings</h1>
{error && <Alert variant="error" className="mb-4">{error}</Alert>}
{message && <Alert variant="success" className="mb-4">{message}</Alert>}
<form onSubmit={handleSubmit} className="bg-white rounded-lg border border-gray-200 p-6 space-y-4">
{DEFAULT_SETTINGS.map((setting) => (
<Input
key={setting.key}
label={setting.label}
type={setting.type}
value={values[setting.key] || ''}
onChange={(e) => setValues((v) => ({ ...v, [setting.key]: e.target.value }))}
/>
))}
<div className="pt-2">
<Button type="submit" loading={saving}>
Save All Settings
</Button>
</div>
</form>
</div>
)
}
+78
View File
@@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
import { categorySchema } from '@/lib/validate'
async function requireAdmin() {
const user = await getCurrentUser()
if (!user || (user.role !== 'ADMIN' && user.role !== 'OWNER')) return null
return user
}
export async function GET() {
const user = await requireAdmin()
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const categories = await prisma.category.findMany({
include: { _count: { select: { products: true } } },
orderBy: { name: 'asc' },
})
return NextResponse.json({ categories })
}
export async function POST(request: NextRequest) {
const user = await requireAdmin()
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
}
const parsed = categorySchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.errors[0]?.message || 'Invalid input' },
{ status: 400 }
)
}
const category = await prisma.category.create({ data: parsed.data })
return NextResponse.json({ category }, { status: 201 })
}
export async function PUT(request: NextRequest) {
const user = await requireAdmin()
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
}
const { id, ...data } = body as { id: string; name?: string; slug?: string; parentId?: string }
if (!id) return NextResponse.json({ error: 'ID required' }, { status: 400 })
const category = await prisma.category.update({ where: { id }, data })
return NextResponse.json({ category })
}
export async function DELETE(request: NextRequest) {
const user = await requireAdmin()
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const { searchParams } = new URL(request.url)
const id = searchParams.get('id')
if (!id) return NextResponse.json({ error: 'ID required' }, { status: 400 })
await prisma.category.delete({ where: { id } })
return NextResponse.json({ success: true })
}
+47
View File
@@ -0,0 +1,47 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
export async function GET() {
const user = await getCurrentUser()
if (!user || (user.role !== 'ADMIN' && user.role !== 'OWNER')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const [
totalOrders,
pendingOrders,
totalRevenue,
totalProducts,
totalCustomers,
recentOrders,
pendingReviews,
] = await Promise.all([
prisma.order.count(),
prisma.order.count({ where: { status: 'PENDING' } }),
prisma.order.aggregate({
where: { status: { in: ['PAID', 'FULFILLED'] } },
_sum: { grandTotal: true },
}),
prisma.product.count(),
prisma.user.count({ where: { role: 'CUSTOMER' } }),
prisma.order.findMany({
take: 5,
orderBy: { createdAt: 'desc' },
include: { user: { select: { email: true, name: true } } },
}),
prisma.review.count({ where: { status: 'PENDING' } }),
])
return NextResponse.json({
stats: {
totalOrders,
pendingOrders,
totalRevenue: totalRevenue._sum.grandTotal ?? 0,
totalProducts,
totalCustomers,
pendingReviews,
},
recentOrders,
})
}
@@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
async function requireAdmin() {
const user = await getCurrentUser()
if (!user || (user.role !== 'ADMIN' && user.role !== 'OWNER')) return null
return user
}
export async function GET(
_request: NextRequest,
{ params }: { params: { id: string } }
) {
const user = await requireAdmin()
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const order = await prisma.order.findUnique({
where: { id: params.id },
include: {
user: { select: { email: true, name: true } },
items: { include: { product: { select: { title: true, slug: true } } } },
payment: true,
},
})
if (!order) return NextResponse.json({ error: 'Not found' }, { status: 404 })
return NextResponse.json({ order })
}
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const user = await requireAdmin()
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
}
const { status } = body as { status: string }
const validStatuses = ['PENDING', 'PAID', 'CANCELLED', 'REFUNDED', 'FULFILLED']
if (!validStatuses.includes(status)) {
return NextResponse.json({ error: 'Invalid status' }, { status: 400 })
}
const order = await prisma.order.update({
where: { id: params.id },
data: { status: status as 'PENDING' | 'PAID' | 'CANCELLED' | 'REFUNDED' | 'FULFILLED' },
})
await prisma.auditLog.create({
data: {
userId: user.id,
action: 'UPDATE_STATUS',
entity: 'Order',
entityId: order.id,
metadata: { status },
},
})
return NextResponse.json({ order })
}
+40
View File
@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
async function requireAdmin() {
const user = await getCurrentUser()
if (!user || (user.role !== 'ADMIN' && user.role !== 'OWNER')) return null
return user
}
export async function GET(request: NextRequest) {
const user = await requireAdmin()
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const { searchParams } = new URL(request.url)
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '20')
const status = searchParams.get('status')
const skip = (page - 1) * limit
const where: Record<string, unknown> = {}
if (status) where.status = status
const [orders, total] = await Promise.all([
prisma.order.findMany({
where,
skip,
take: limit,
include: {
user: { select: { email: true, name: true } },
items: true,
payment: true,
},
orderBy: { createdAt: 'desc' },
}),
prisma.order.count({ where }),
])
return NextResponse.json({ orders, pagination: { page, limit, total, pages: Math.ceil(total / limit) } })
}
@@ -0,0 +1,79 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
import { productTypeSchema } from '@/lib/validate'
async function requireAdmin() {
const user = await getCurrentUser()
if (!user || (user.role !== 'ADMIN' && user.role !== 'OWNER')) return null
return user
}
export async function GET() {
const user = await requireAdmin()
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const types = await prisma.productType.findMany({
include: { _count: { select: { products: true } } },
orderBy: { createdAt: 'desc' },
})
return NextResponse.json({ types })
}
export async function POST(request: NextRequest) {
const user = await requireAdmin()
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
}
const parsed = productTypeSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.errors[0]?.message || 'Invalid input' },
{ status: 400 }
)
}
const type = await prisma.productType.create({ data: parsed.data })
return NextResponse.json({ type }, { status: 201 })
}
export async function PUT(request: NextRequest) {
const user = await requireAdmin()
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
}
const { id, ...data } = body as { id: string; name?: string; slug?: string; schema?: object }
if (!id) return NextResponse.json({ error: 'ID required' }, { status: 400 })
const type = await prisma.productType.update({ where: { id }, data })
return NextResponse.json({ type })
}
export async function DELETE(request: NextRequest) {
const user = await requireAdmin()
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const { searchParams } = new URL(request.url)
const id = searchParams.get('id')
if (!id) return NextResponse.json({ error: 'ID required' }, { status: 400 })
await prisma.productType.delete({ where: { id } })
return NextResponse.json({ success: true })
}
@@ -0,0 +1,107 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
import { productSchema } from '@/lib/validate'
async function requireAdmin() {
const user = await getCurrentUser()
if (!user || (user.role !== 'ADMIN' && user.role !== 'OWNER')) return null
return user
}
export async function GET(
_request: NextRequest,
{ params }: { params: { id: string } }
) {
const user = await requireAdmin()
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const product = await prisma.product.findUnique({
where: { id: params.id },
include: {
type: true,
categories: { include: { category: true } },
images: true,
variants: true,
},
})
if (!product) return NextResponse.json({ error: 'Not found' }, { status: 404 })
return NextResponse.json({ product })
}
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const user = await requireAdmin()
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
}
const parsed = productSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.errors[0]?.message || 'Invalid input' },
{ status: 400 }
)
}
const { categoryIds, ...data } = parsed.data
const product = await prisma.$transaction(async (tx) => {
await tx.productCategory.deleteMany({ where: { productId: params.id } })
return tx.product.update({
where: { id: params.id },
data: {
...data,
categories: categoryIds?.length
? { create: categoryIds.map((id) => ({ categoryId: id })) }
: undefined,
},
include: { type: true, categories: { include: { category: true } } },
})
})
await prisma.auditLog.create({
data: {
userId: user.id,
action: 'UPDATE',
entity: 'Product',
entityId: product.id,
},
})
return NextResponse.json({ product })
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: { id: string } }
) {
const user = await requireAdmin()
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
await prisma.product.update({
where: { id: params.id },
data: { status: 'ARCHIVED' },
})
await prisma.auditLog.create({
data: {
userId: user.id,
action: 'ARCHIVE',
entity: 'Product',
entityId: params.id,
},
})
return NextResponse.json({ success: true })
}
+97
View File
@@ -0,0 +1,97 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
import { productSchema } from '@/lib/validate'
async function requireAdmin() {
const user = await getCurrentUser()
if (!user || (user.role !== 'ADMIN' && user.role !== 'OWNER')) {
return null
}
return user
}
export async function GET(request: NextRequest) {
const user = await requireAdmin()
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const { searchParams } = new URL(request.url)
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '20')
const search = searchParams.get('search')
const status = searchParams.get('status')
const skip = (page - 1) * limit
const where: Record<string, unknown> = {}
if (status) where.status = status
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ slug: { contains: search, mode: 'insensitive' } },
]
}
const [products, total] = await Promise.all([
prisma.product.findMany({
where,
skip,
take: limit,
include: {
type: true,
categories: { include: { category: true } },
images: { take: 1 },
_count: { select: { variants: true, orderItems: true } },
},
orderBy: { createdAt: 'desc' },
}),
prisma.product.count({ where }),
])
return NextResponse.json({ products, pagination: { page, limit, total, pages: Math.ceil(total / limit) } })
}
export async function POST(request: NextRequest) {
const user = await requireAdmin()
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
}
const parsed = productSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.errors[0]?.message || 'Invalid input', details: parsed.error.errors },
{ status: 400 }
)
}
const { categoryIds, ...data } = parsed.data
const product = await prisma.product.create({
data: {
...data,
categories: categoryIds?.length
? {
create: categoryIds.map((id) => ({ categoryId: id })),
}
: undefined,
},
include: { type: true, categories: { include: { category: true } } },
})
await prisma.auditLog.create({
data: {
userId: user.id,
action: 'CREATE',
entity: 'Product',
entityId: product.id,
},
})
return NextResponse.json({ product }, { status: 201 })
}
+65
View File
@@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
async function requireAdmin() {
const user = await getCurrentUser()
if (!user || (user.role !== 'ADMIN' && user.role !== 'OWNER')) return null
return user
}
export async function GET(request: NextRequest) {
const user = await requireAdmin()
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const { searchParams } = new URL(request.url)
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '20')
const status = searchParams.get('status')
const skip = (page - 1) * limit
const where: Record<string, unknown> = {}
if (status) where.status = status
const [reviews, total] = await Promise.all([
prisma.review.findMany({
where,
skip,
take: limit,
include: {
user: { select: { email: true, name: true } },
product: { select: { title: true, slug: true } },
},
orderBy: { createdAt: 'desc' },
}),
prisma.review.count({ where }),
])
return NextResponse.json({ reviews, pagination: { page, limit, total, pages: Math.ceil(total / limit) } })
}
export async function PUT(request: NextRequest) {
const user = await requireAdmin()
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
}
const { id, status } = body as { id: string; status: string }
const validStatuses = ['PENDING', 'APPROVED', 'HIDDEN']
if (!id || !validStatuses.includes(status)) {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 })
}
const review = await prisma.review.update({
where: { id },
data: { status: status as 'PENDING' | 'APPROVED' | 'HIDDEN' },
})
return NextResponse.json({ review })
}
+50
View File
@@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
async function requireAdmin() {
const user = await getCurrentUser()
if (!user || (user.role !== 'ADMIN' && user.role !== 'OWNER')) return null
return user
}
export async function GET() {
const user = await requireAdmin()
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const settings = await prisma.siteSettings.findMany()
const settingsMap = settings.reduce(
(acc, s) => {
acc[s.key] = s.value
return acc
},
{} as Record<string, unknown>
)
return NextResponse.json({ settings: settingsMap })
}
export async function POST(request: NextRequest) {
const user = await requireAdmin()
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
}
const { key, value } = body as { key: string; value: unknown }
if (!key) return NextResponse.json({ error: 'Key is required' }, { status: 400 })
const setting = await prisma.siteSettings.upsert({
where: { key },
update: { value: value as object },
create: { key, value: value as object },
})
return NextResponse.json({ setting })
}
+90
View File
@@ -0,0 +1,90 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
import { adminUserSchema } from '@/lib/validate'
import { hashPassword } from '@/lib/auth'
async function requireOwner() {
const user = await getCurrentUser()
if (!user || user.role !== 'OWNER') return null
return user
}
export async function GET() {
const user = await requireOwner()
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const users = await prisma.user.findMany({
where: { role: { in: ['ADMIN', 'OWNER'] } },
select: {
id: true,
email: true,
name: true,
role: true,
createdAt: true,
mustChangePassword: true,
},
orderBy: { createdAt: 'desc' },
})
return NextResponse.json({ users })
}
export async function POST(request: NextRequest) {
const user = await requireOwner()
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
}
const parsed = adminUserSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.errors[0]?.message || 'Invalid input' },
{ status: 400 }
)
}
const { email, name, role, password } = parsed.data
const existing = await prisma.user.findUnique({ where: { email } })
if (existing) {
return NextResponse.json({ error: 'Email already in use' }, { status: 409 })
}
const passwordHash = await hashPassword(password)
const newUser = await prisma.user.create({
data: {
email,
name,
role: role as 'ADMIN' | 'OWNER',
passwordHash,
mustChangePassword: true,
},
select: { id: true, email: true, name: true, role: true, createdAt: true },
})
return NextResponse.json({ user: newUser }, { status: 201 })
}
export async function DELETE(request: NextRequest) {
const user = await requireOwner()
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const { searchParams } = new URL(request.url)
const id = searchParams.get('id')
if (!id) return NextResponse.json({ error: 'ID required' }, { status: 400 })
if (id === user.id) {
return NextResponse.json({ error: 'Cannot delete your own account' }, { status: 400 })
}
await prisma.user.delete({ where: { id } })
return NextResponse.json({ success: true })
}
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser, verifyPassword, hashPassword } from '@/lib/auth'
import { changePasswordSchema } from '@/lib/validate'
export async function POST(request: NextRequest) {
const user = await getCurrentUser()
if (!user) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
}
const parsed = changePasswordSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.errors[0]?.message || 'Invalid input' },
{ status: 400 }
)
}
const { currentPassword, newPassword } = parsed.data
const valid = await verifyPassword(currentPassword, user.passwordHash)
if (!valid) {
return NextResponse.json({ error: 'Current password is incorrect' }, { status: 400 })
}
const passwordHash = await hashPassword(newPassword)
await prisma.user.update({
where: { id: user.id },
data: { passwordHash, mustChangePassword: false },
})
return NextResponse.json({ success: true })
}
+81
View File
@@ -0,0 +1,81 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import {
verifyPassword,
createSession,
setSessionCookie,
} from '@/lib/auth'
import { loginSchema } from '@/lib/validate'
// Simple in-memory rate limiter
const loginAttempts = new Map<string, { count: number; resetAt: number }>()
function checkRateLimit(ip: string): boolean {
const now = Date.now()
const windowMs = 15 * 60 * 1000 // 15 minutes
const maxAttempts = 10
const record = loginAttempts.get(ip)
if (!record || record.resetAt < now) {
loginAttempts.set(ip, { count: 1, resetAt: now + windowMs })
return true
}
if (record.count >= maxAttempts) {
return false
}
record.count++
return true
}
export async function POST(request: NextRequest) {
const ip = request.headers.get('x-forwarded-for') || 'unknown'
if (!checkRateLimit(ip)) {
return NextResponse.json(
{ error: 'Too many login attempts. Please try again later.' },
{ status: 429 }
)
}
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
}
const parsed = loginSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.errors[0]?.message || 'Invalid input' },
{ status: 400 }
)
}
const { email, password } = parsed.data
const user = await prisma.user.findUnique({ where: { email } })
if (!user) {
return NextResponse.json({ error: 'Invalid email or password' }, { status: 401 })
}
const valid = await verifyPassword(password, user.passwordHash)
if (!valid) {
return NextResponse.json({ error: 'Invalid email or password' }, { status: 401 })
}
const token = await createSession(user.id)
await setSessionCookie(token)
return NextResponse.json({
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
mustChangePassword: user.mustChangePassword,
},
})
}
+13
View File
@@ -0,0 +1,13 @@
import { NextResponse } from 'next/server'
import { getSessionToken, deleteSession, clearSessionCookie } from '@/lib/auth'
export async function POST() {
const token = await getSessionToken()
if (token) {
await deleteSession(token)
await clearSessionCookie()
}
return NextResponse.json({ success: true })
}
+54
View File
@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { hashPassword, createSession, setSessionCookie } from '@/lib/auth'
import { registerSchema } from '@/lib/validate'
export async function POST(request: NextRequest) {
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
}
const parsed = registerSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.errors[0]?.message || 'Invalid input' },
{ status: 400 }
)
}
const { email, password, name } = parsed.data
const existing = await prisma.user.findUnique({ where: { email } })
if (existing) {
return NextResponse.json({ error: 'Email already in use' }, { status: 409 })
}
const passwordHash = await hashPassword(password)
const user = await prisma.user.create({
data: {
email,
passwordHash,
name,
role: 'CUSTOMER',
},
})
const token = await createSession(user.id)
await setSessionCookie(token)
return NextResponse.json(
{
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
},
},
{ status: 201 }
)
}
+69
View File
@@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
export async function POST(request: NextRequest) {
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
}
const { items } = body as { items: Array<{ productId: string; quantity: number; variantId?: string }> }
if (!Array.isArray(items) || items.length === 0) {
return NextResponse.json({ error: 'No items provided' }, { status: 400 })
}
const productIds = items.map((i) => i.productId)
const products = await prisma.product.findMany({
where: {
id: { in: productIds },
status: 'PUBLISHED',
},
include: {
variants: { where: { active: true } },
images: { take: 1 },
},
})
const cartItems = items
.map((item) => {
const product = products.find((p) => p.id === item.productId)
if (!product) return null
let price = product.basePrice
let variantInfo = null
if (item.variantId) {
const variant = product.variants.find((v) => v.id === item.variantId)
if (variant) {
price = variant.price
variantInfo = { id: variant.id, sku: variant.sku, attributes: variant.attributes }
}
}
const availableStock =
item.variantId
? product.variants.find((v) => v.id === item.variantId)?.stock ?? 0
: product.stock ?? Infinity
const quantity = Math.min(item.quantity, availableStock)
return {
productId: product.id,
title: product.title,
slug: product.slug,
price,
quantity,
totalPrice: price * quantity,
image: product.images[0]?.url || null,
variant: variantInfo,
}
})
.filter(Boolean)
const subtotal = cartItems.reduce((sum, item) => sum + (item?.totalPrice ?? 0), 0)
return NextResponse.json({ items: cartItems, subtotal })
}
+125
View File
@@ -0,0 +1,125 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
import { createCheckoutSession } from '@/lib/stripe'
import { checkoutSchema } from '@/lib/validate'
export async function POST(request: NextRequest) {
const user = await getCurrentUser()
if (!user) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
}
const parsed = checkoutSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.errors[0]?.message || 'Invalid input' },
{ status: 400 }
)
}
const { items } = parsed.data
const productIds = items.map((i) => i.productId)
const products = await prisma.product.findMany({
where: {
id: { in: productIds },
status: 'PUBLISHED',
},
include: { variants: { where: { active: true } } },
})
type OrderItemInput = {
product: { connect: { id: string } }
title: string
quantity: number
unitPrice: number
totalPrice: number
metadata?: { variantId: string; sku: string }
}
const orderItems: OrderItemInput[] = []
for (const item of items) {
const product = products.find((p: typeof products[number]) => p.id === item.productId)
if (!product) {
return NextResponse.json(
{ error: `Product ${item.productId} not found` },
{ status: 400 }
)
}
let price = product.basePrice
let metadata: { variantId: string; sku: string } | undefined
if (item.variantId) {
const variant = product.variants.find((v: typeof product.variants[number]) => v.id === item.variantId)
if (variant) {
price = variant.price
metadata = { variantId: variant.id, sku: variant.sku }
}
}
orderItems.push({
product: { connect: { id: product.id } },
title: product.title,
quantity: item.quantity,
unitPrice: price,
totalPrice: price * item.quantity,
metadata,
})
}
const subtotal = orderItems.reduce((sum, i) => sum + i.totalPrice, 0)
const grandTotal = subtotal
const order = await prisma.order.create({
data: {
userId: user.id,
status: 'PENDING',
currency: 'EUR',
subtotal,
grandTotal,
items: {
create: orderItems,
},
},
})
const stripeLineItems = orderItems.map((item) => ({
price_data: {
currency: 'eur',
product_data: { name: item.title },
unit_amount: item.unitPrice,
},
quantity: item.quantity,
}))
const appUrl = process.env.APP_URL || 'http://localhost'
const checkoutSession = await createCheckoutSession({
orderId: order.id,
lineItems: stripeLineItems,
customerEmail: user.email,
successUrl: `${appUrl}/account/orders?success=1&orderId=${order.id}`,
cancelUrl: `${appUrl}/cart`,
})
await prisma.payment.create({
data: {
orderId: order.id,
provider: 'stripe',
providerPaymentId: checkoutSession.id,
status: 'pending',
amount: grandTotal,
currency: 'EUR',
},
})
return NextResponse.json({ url: checkoutSession.url, orderId: order.id })
}
+21
View File
@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
export async function GET(_request: NextRequest) {
const user = await getCurrentUser()
if (!user) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const orders = await prisma.order.findMany({
where: { userId: user.id },
include: {
items: true,
payment: true,
},
orderBy: { createdAt: 'desc' },
})
return NextResponse.json({ orders })
}
+31
View File
@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
export async function GET(
_request: NextRequest,
{ params }: { params: { slug: string } }
) {
const product = await prisma.product.findFirst({
where: {
slug: params.slug,
status: 'PUBLISHED',
},
include: {
images: true,
categories: { include: { category: true } },
type: true,
variants: { where: { active: true } },
reviews: {
where: { status: 'APPROVED' },
include: { user: { select: { name: true } } },
orderBy: { createdAt: 'desc' },
},
},
})
if (!product) {
return NextResponse.json({ error: 'Product not found' }, { status: 404 })
}
return NextResponse.json({ product })
}
+56
View File
@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '20')
const category = searchParams.get('category')
const search = searchParams.get('search')
const skip = (page - 1) * limit
const where: Record<string, unknown> = {
status: 'PUBLISHED',
}
if (category) {
where.categories = {
some: {
category: { slug: category },
},
}
}
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
]
}
const [products, total] = await Promise.all([
prisma.product.findMany({
where,
skip,
take: limit,
include: {
images: { take: 1 },
categories: { include: { category: true } },
type: true,
},
orderBy: { createdAt: 'desc' },
}),
prisma.product.count({ where }),
])
return NextResponse.json({
products,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit),
},
})
}
+61
View File
@@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
import { reviewSchema } from '@/lib/validate'
export async function POST(request: NextRequest) {
const user = await getCurrentUser()
if (!user) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
}
const parsed = reviewSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.errors[0]?.message || 'Invalid input' },
{ status: 400 }
)
}
const { productId, rating, title, comment } = parsed.data
const product = await prisma.product.findUnique({ where: { id: productId } })
if (!product) {
return NextResponse.json({ error: 'Product not found' }, { status: 404 })
}
const existing = await prisma.review.findUnique({
where: { userId_productId: { userId: user.id, productId } },
})
if (existing) {
return NextResponse.json({ error: 'You have already reviewed this product' }, { status: 409 })
}
// Check if the user has purchased this product
const hasPurchased = await prisma.orderItem.findFirst({
where: {
productId,
order: { userId: user.id, status: { in: ['PAID', 'FULFILLED'] } },
},
})
const review = await prisma.review.create({
data: {
userId: user.id,
productId,
rating,
title,
comment,
verified: !!hasPurchased,
},
})
return NextResponse.json({ review }, { status: 201 })
}
+124
View File
@@ -0,0 +1,124 @@
import { NextRequest, NextResponse } from 'next/server'
import { constructWebhookEvent } from '@/lib/stripe'
import { prisma } from '@/lib/prisma'
import { sendOrderConfirmationEmail } from '@/lib/email'
import Stripe from 'stripe'
export async function POST(request: NextRequest) {
const body = await request.text()
const signature = request.headers.get('stripe-signature')
if (!signature) {
return NextResponse.json({ error: 'Missing stripe-signature header' }, { status: 400 })
}
let event: Stripe.Event
try {
event = constructWebhookEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!)
} catch (err) {
console.error('Webhook signature verification failed:', err)
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
}
try {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session
const orderId = session.metadata?.orderId
if (!orderId) break
await prisma.order.update({
where: { id: orderId },
data: { status: 'PAID' },
})
await prisma.payment.updateMany({
where: { orderId },
data: {
status: 'paid',
providerPaymentId: session.payment_intent as string,
rawPayload: event.data.object as object,
},
})
const order = await prisma.order.findUnique({
where: { id: orderId },
include: { user: true },
})
if (order?.user?.email) {
try {
await sendOrderConfirmationEmail(order.user.email, {
orderId: order.id,
grandTotal: order.grandTotal,
currency: order.currency,
})
} catch (emailErr) {
console.error('Failed to send confirmation email:', emailErr)
}
}
break
}
case 'payment_intent.succeeded': {
const paymentIntent = event.data.object as Stripe.PaymentIntent
const payment = await prisma.payment.findFirst({
where: { providerPaymentId: paymentIntent.id },
})
if (payment) {
await prisma.payment.update({
where: { id: payment.id },
data: {
status: 'paid',
rawPayload: event.data.object as object,
},
})
await prisma.order.update({
where: { id: payment.orderId },
data: { status: 'PAID' },
})
}
break
}
case 'payment_intent.payment_failed': {
const paymentIntent = event.data.object as Stripe.PaymentIntent
const payment = await prisma.payment.findFirst({
where: { providerPaymentId: paymentIntent.id },
})
if (payment) {
await prisma.payment.update({
where: { id: payment.id },
data: {
status: 'failed',
rawPayload: event.data.object as object,
},
})
await prisma.order.update({
where: { id: payment.orderId },
data: { status: 'CANCELLED' },
})
}
break
}
default:
console.log(`Unhandled event type: ${event.type}`)
}
} catch (err) {
console.error('Error processing webhook:', err)
return NextResponse.json({ error: 'Webhook processing failed' }, { status: 500 })
}
return NextResponse.json({ received: true })
}
+174
View File
@@ -0,0 +1,174 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { Navbar } from '@/components/storefront/Navbar'
import { Button } from '@/components/ui/Button'
interface CartItem {
productId: string
title: string
price: number
quantity: number
variantId?: string
image?: string | null
}
export default function CartPage() {
const [cart, setCart] = useState<CartItem[]>([])
const router = useRouter()
useEffect(() => {
const stored = JSON.parse(localStorage.getItem('cart') || '[]')
setCart(stored)
}, [])
function updateQuantity(index: number, qty: number) {
const newCart = [...cart]
if (qty <= 0) {
newCart.splice(index, 1)
} else {
newCart[index] = { ...newCart[index], quantity: qty }
}
setCart(newCart)
localStorage.setItem('cart', JSON.stringify(newCart))
window.dispatchEvent(new Event('storage'))
}
function removeItem(index: number) {
const newCart = cart.filter((_, i) => i !== index)
setCart(newCart)
localStorage.setItem('cart', JSON.stringify(newCart))
window.dispatchEvent(new Event('storage'))
}
function clearCart() {
setCart([])
localStorage.removeItem('cart')
window.dispatchEvent(new Event('storage'))
}
const subtotal = cart.reduce((sum, item) => sum + item.price * item.quantity, 0)
async function handleCheckout() {
const user = localStorage.getItem('user')
if (!user) {
router.push('/login?redirect=/cart')
return
}
router.push('/checkout')
}
return (
<div>
<Navbar />
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<h1 className="text-2xl font-bold mb-6">Shopping Cart</h1>
{cart.length === 0 ? (
<div className="text-center py-16">
<p className="text-gray-500 mb-4">Your cart is empty</p>
<Link
href="/products"
className="inline-block bg-blue-600 text-white px-6 py-3 rounded hover:bg-blue-700"
>
Continue Shopping
</Link>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2">
<div className="bg-white rounded-lg border border-gray-200 divide-y">
{cart.map((item, index) => (
<div key={index} className="flex items-center gap-4 p-4">
<div className="w-16 h-16 bg-gray-100 rounded flex-shrink-0">
{item.image ? (
<img
src={item.image}
alt={item.title}
className="w-full h-full object-cover rounded"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400 text-xs">
No img
</div>
)}
</div>
<div className="flex-1">
<h3 className="font-medium">{item.title}</h3>
<p className="text-sm text-gray-500">
{(item.price / 100).toFixed(2)} EUR each
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => updateQuantity(index, item.quantity - 1)}
className="w-7 h-7 rounded border flex items-center justify-center hover:bg-gray-50"
>
-
</button>
<span className="w-8 text-center text-sm">{item.quantity}</span>
<button
onClick={() => updateQuantity(index, item.quantity + 1)}
className="w-7 h-7 rounded border flex items-center justify-center hover:bg-gray-50"
>
+
</button>
</div>
<p className="w-20 text-right font-medium">
{(item.price * item.quantity / 100).toFixed(2)} EUR
</p>
<button
onClick={() => removeItem(index)}
className="text-gray-400 hover:text-red-500 ml-2"
>
</button>
</div>
))}
</div>
<button
onClick={clearCart}
className="mt-3 text-sm text-gray-500 hover:text-red-500"
>
Clear cart
</button>
</div>
<div className="lg:col-span-1">
<div className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="font-semibold text-lg mb-4">Order Summary</h2>
<div className="space-y-2 mb-4">
<div className="flex justify-between text-sm">
<span>Subtotal</span>
<span>{(subtotal / 100).toFixed(2)} EUR</span>
</div>
<div className="flex justify-between text-sm">
<span>Shipping</span>
<span className="text-gray-500">Calculated at checkout</span>
</div>
</div>
<div className="border-t pt-4 mb-6">
<div className="flex justify-between font-bold">
<span>Total</span>
<span>{(subtotal / 100).toFixed(2)} EUR</span>
</div>
</div>
<Button className="w-full" size="lg" onClick={handleCheckout}>
Proceed to Checkout
</Button>
<Link
href="/products"
className="block text-center mt-3 text-sm text-blue-600 hover:underline"
>
Continue Shopping
</Link>
</div>
</div>
</div>
)}
</main>
</div>
)
}
+135
View File
@@ -0,0 +1,135 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Navbar } from '@/components/storefront/Navbar'
import { Button } from '@/components/ui/Button'
import { Alert } from '@/components/ui/Alert'
interface CartItem {
productId: string
title: string
price: number
quantity: number
variantId?: string
}
export default function CheckoutPage() {
const [cart, setCart] = useState<CartItem[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const router = useRouter()
useEffect(() => {
const stored = JSON.parse(localStorage.getItem('cart') || '[]')
if (stored.length === 0) {
router.push('/cart')
return
}
setCart(stored)
const user = localStorage.getItem('user')
if (!user) {
router.push('/login?redirect=/checkout')
}
}, [router])
const subtotal = cart.reduce((sum, item) => sum + item.price * item.quantity, 0)
async function handleCheckout() {
setLoading(true)
setError('')
try {
const response = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
items: cart.map((item) => ({
productId: item.productId,
quantity: item.quantity,
variantId: item.variantId,
})),
}),
})
const data = await response.json()
if (!response.ok) {
setError(data.error || 'Checkout failed')
return
}
// Clear cart after successful order creation
localStorage.removeItem('cart')
window.dispatchEvent(new Event('storage'))
// Redirect to Stripe checkout
if (data.url) {
window.location.href = data.url
} else {
router.push(`/account/orders?orderId=${data.orderId}`)
}
} catch {
setError('Something went wrong. Please try again.')
} finally {
setLoading(false)
}
}
return (
<div>
<Navbar />
<main className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<h1 className="text-2xl font-bold mb-6">Checkout</h1>
{error && <Alert variant="error" className="mb-4">{error}</Alert>}
<div className="bg-white rounded-lg border border-gray-200 mb-6">
<div className="p-6">
<h2 className="font-semibold mb-4">Order Summary</h2>
<div className="space-y-3">
{cart.map((item, i) => (
<div key={i} className="flex justify-between text-sm">
<span>
{item.title} × {item.quantity}
</span>
<span className="font-medium">
{((item.price * item.quantity) / 100).toFixed(2)} EUR
</span>
</div>
))}
</div>
<div className="border-t mt-4 pt-4">
<div className="flex justify-between font-bold">
<span>Total</span>
<span>{(subtotal / 100).toFixed(2)} EUR</span>
</div>
</div>
</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6 text-sm text-blue-800">
<p className="font-medium mb-1">Secure Payment via Stripe</p>
<p>You will be redirected to Stripe to complete your payment securely.</p>
</div>
<Button
onClick={handleCheckout}
loading={loading}
size="lg"
className="w-full"
>
{loading ? 'Processing...' : `Pay ${(subtotal / 100).toFixed(2)} EUR`}
</Button>
<button
onClick={() => router.push('/cart')}
className="block w-full text-center mt-3 text-sm text-gray-600 hover:text-gray-900"
>
Back to Cart
</button>
</main>
</div>
)
}
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+21
View File
@@ -0,0 +1,21 @@
import type { Metadata } from 'next'
import './globals.css'
export const metadata: Metadata = {
title: 'ShopX - E-Commerce Platform',
description: 'Your online store',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className="bg-gray-50 text-gray-900 min-h-screen">
{children}
</body>
</html>
)
}
+108
View File
@@ -0,0 +1,108 @@
'use client'
import { Suspense, useState } from 'react'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { Input } from '@/components/ui/Input'
import { Button } from '@/components/ui/Button'
import { Alert } from '@/components/ui/Alert'
export default function LoginPage() {
return (
<Suspense>
<LoginForm />
</Suspense>
)
}
function LoginForm() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const router = useRouter()
const searchParams = useSearchParams()
const redirect = searchParams.get('redirect') || '/'
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
setLoading(true)
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
const data = await response.json()
if (!response.ok) {
setError(data.error || 'Login failed')
return
}
localStorage.setItem('user', JSON.stringify(data.user))
if (data.user.mustChangePassword) {
router.push('/admin/change-password')
} else if (data.user.role === 'ADMIN' || data.user.role === 'OWNER') {
router.push('/admin')
} else {
router.push(redirect)
}
} catch {
setError('Something went wrong. Please try again.')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="w-full max-w-md">
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-8">
<div className="text-center mb-8">
<Link href="/" className="text-2xl font-bold text-gray-900">ShopX</Link>
<h1 className="text-xl font-semibold mt-4">Welcome back</h1>
<p className="text-gray-600 text-sm mt-1">Sign in to your account</p>
</div>
{error && <Alert variant="error" className="mb-4">{error}</Alert>}
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
required
autoComplete="email"
/>
<Input
label="Password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••••••"
required
autoComplete="current-password"
/>
<Button type="submit" loading={loading} className="w-full" size="lg">
Sign In
</Button>
</form>
<p className="text-center text-sm text-gray-600 mt-6">
Don&apos;t have an account?{' '}
<Link href="/register" className="text-blue-600 hover:underline">
Register
</Link>
</p>
</div>
</div>
</div>
)
}
+98
View File
@@ -0,0 +1,98 @@
export const dynamic = 'force-dynamic'
import Link from 'next/link'
import { prisma } from '@/lib/prisma'
import { Navbar } from '@/components/storefront/Navbar'
import { ProductCard } from '@/components/storefront/ProductCard'
async function getFeaturedProducts() {
return prisma.product.findMany({
where: { status: 'PUBLISHED' },
take: 8,
include: { images: { take: 1 } },
orderBy: { createdAt: 'desc' },
})
}
export default async function HomePage() {
const products = await getFeaturedProducts()
return (
<div>
<Navbar />
<main>
{/* Hero */}
<section className="bg-blue-600 text-white py-20">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h1 className="text-4xl font-bold mb-4">Welcome to ShopX</h1>
<p className="text-xl text-blue-100 mb-8">
Discover our curated collection of products
</p>
<Link
href="/products"
className="inline-block bg-white text-blue-600 font-semibold px-8 py-3 rounded-lg hover:bg-blue-50 transition-colors"
>
Shop Now
</Link>
</div>
</section>
{/* Featured Products */}
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
<h2 className="text-2xl font-bold mb-8">Featured Products</h2>
{products.length === 0 ? (
<div className="text-center py-16 text-gray-500">
<p>No products available yet.</p>
<p className="mt-2 text-sm">Check back soon or add products in the admin panel.</p>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{products.map((product: typeof products[number]) => (
<ProductCard key={product.id} product={product} />
))}
</div>
)}
{products.length > 0 && (
<div className="text-center mt-10">
<Link
href="/products"
className="inline-block bg-blue-600 text-white px-8 py-3 rounded-lg hover:bg-blue-700 transition-colors"
>
View All Products
</Link>
</div>
)}
</section>
{/* Features */}
<section className="bg-white py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 text-center">
<div className="p-6">
<div className="text-3xl mb-3">🚚</div>
<h3 className="font-semibold mb-2">Fast Shipping</h3>
<p className="text-gray-600 text-sm">Get your orders delivered quickly</p>
</div>
<div className="p-6">
<div className="text-3xl mb-3">🔒</div>
<h3 className="font-semibold mb-2">Secure Payments</h3>
<p className="text-gray-600 text-sm">Powered by Stripe for safe transactions</p>
</div>
<div className="p-6">
<div className="text-3xl mb-3"></div>
<h3 className="font-semibold mb-2">Easy Returns</h3>
<p className="text-gray-600 text-sm">30-day hassle-free return policy</p>
</div>
</div>
</div>
</section>
</main>
<footer className="bg-gray-800 text-gray-300 py-8">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center text-sm">
<p>&copy; {new Date().getFullYear()} ShopX. All rights reserved.</p>
</div>
</footer>
</div>
)
}
+306
View File
@@ -0,0 +1,306 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { Navbar } from '@/components/storefront/Navbar'
import { Button } from '@/components/ui/Button'
import { Alert } from '@/components/ui/Alert'
interface Product {
id: string
title: string
slug: string
description: string
basePrice: number
currency: string
stock: number | null
images: Array<{ url: string; altText?: string | null }>
variants: Array<{ id: string; sku: string; price: number; stock: number; attributes: Record<string, unknown> }>
categories: Array<{ category: { name: string; slug: string } }>
reviews: Array<{ id: string; rating: number; title?: string | null; comment?: string | null; user: { name: string | null }; createdAt: string }>
}
export default function ProductDetailPage() {
const params = useParams()
const router = useRouter()
const [product, setProduct] = useState<Product | null>(null)
const [loading, setLoading] = useState(true)
const [selectedVariant, setSelectedVariant] = useState<string | null>(null)
const [quantity, setQuantity] = useState(1)
const [addedToCart, setAddedToCart] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
fetch(`/api/products/${params.slug}`)
.then((r) => r.json())
.then((data) => {
if (data.product) {
setProduct(data.product)
if (data.product.variants.length > 0) {
setSelectedVariant(data.product.variants[0].id)
}
}
setLoading(false)
})
.catch(() => {
setError('Failed to load product')
setLoading(false)
})
}, [params.slug])
function addToCart() {
if (!product) return
const cart = JSON.parse(localStorage.getItem('cart') || '[]')
const existingIndex = cart.findIndex(
(item: { productId: string; variantId?: string }) =>
item.productId === product.id && item.variantId === selectedVariant
)
if (existingIndex >= 0) {
cart[existingIndex].quantity += quantity
} else {
cart.push({
productId: product.id,
title: product.title,
price: selectedVariant
? product.variants.find((v) => v.id === selectedVariant)?.price || product.basePrice
: product.basePrice,
quantity,
variantId: selectedVariant || undefined,
image: product.images[0]?.url || null,
})
}
localStorage.setItem('cart', JSON.stringify(cart))
setAddedToCart(true)
setTimeout(() => setAddedToCart(false), 3000)
// Trigger storage event for Navbar cart count update
window.dispatchEvent(new Event('storage'))
}
if (loading) {
return (
<div>
<Navbar />
<div className="max-w-7xl mx-auto px-4 py-8">
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-1/3 mb-4"></div>
<div className="h-64 bg-gray-200 rounded mb-4"></div>
</div>
</div>
</div>
)
}
if (!product || error) {
return (
<div>
<Navbar />
<div className="max-w-7xl mx-auto px-4 py-8 text-center">
<h1 className="text-2xl font-bold mb-4">Product Not Found</h1>
<Button onClick={() => router.push('/products')}>Back to Products</Button>
</div>
</div>
)
}
const displayPrice = selectedVariant
? product.variants.find((v) => v.id === selectedVariant)?.price || product.basePrice
: product.basePrice
const avgRating =
product.reviews.length > 0
? product.reviews.reduce((sum, r) => sum + r.rating, 0) / product.reviews.length
: 0
return (
<div>
<Navbar />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-10">
{/* Images */}
<div>
<div className="aspect-square bg-gray-100 rounded-lg overflow-hidden mb-4">
{product.images[0] ? (
<img
src={product.images[0].url}
alt={product.images[0].altText || product.title}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400">
No image available
</div>
)}
</div>
{product.images.length > 1 && (
<div className="grid grid-cols-4 gap-2">
{product.images.slice(1).map((img, i) => (
<img
key={i}
src={img.url}
alt={img.altText || `${product.title} ${i + 2}`}
className="aspect-square object-cover rounded border border-gray-200"
/>
))}
</div>
)}
</div>
{/* Details */}
<div>
<div className="flex gap-2 mb-3">
{product.categories.map((c) => (
<span
key={c.category.slug}
className="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded"
>
{c.category.name}
</span>
))}
</div>
<h1 className="text-3xl font-bold mb-4">{product.title}</h1>
{product.reviews.length > 0 && (
<div className="flex items-center gap-2 mb-4">
<div className="flex">
{[1, 2, 3, 4, 5].map((star) => (
<span
key={star}
className={star <= avgRating ? 'text-yellow-400' : 'text-gray-300'}
>
</span>
))}
</div>
<span className="text-sm text-gray-600">
{avgRating.toFixed(1)} ({product.reviews.length} reviews)
</span>
</div>
)}
<p className="text-3xl font-bold text-gray-900 mb-6">
{(displayPrice / 100).toFixed(2)} {product.currency}
</p>
<p className="text-gray-600 mb-6">{product.description}</p>
{/* Variants */}
{product.variants.length > 0 && (
<div className="mb-6">
<label className="block text-sm font-medium mb-2">Options</label>
<div className="flex flex-wrap gap-2">
{product.variants.map((variant) => (
<button
key={variant.id}
onClick={() => setSelectedVariant(variant.id)}
disabled={variant.stock === 0}
className={`px-3 py-1.5 rounded border text-sm ${
selectedVariant === variant.id
? 'border-blue-600 bg-blue-50 text-blue-700'
: 'border-gray-300 hover:border-gray-400'
} ${variant.stock === 0 ? 'opacity-50 cursor-not-allowed line-through' : ''}`}
>
{variant.sku}
{variant.stock === 0 && ' (Out of stock)'}
</button>
))}
</div>
</div>
)}
{/* Quantity */}
<div className="mb-6">
<label className="block text-sm font-medium mb-2">Quantity</label>
<div className="flex items-center gap-2">
<button
onClick={() => setQuantity(Math.max(1, quantity - 1))}
className="w-8 h-8 rounded border border-gray-300 flex items-center justify-center hover:bg-gray-50"
>
-
</button>
<span className="w-12 text-center font-medium">{quantity}</span>
<button
onClick={() => setQuantity(quantity + 1)}
className="w-8 h-8 rounded border border-gray-300 flex items-center justify-center hover:bg-gray-50"
>
+
</button>
</div>
</div>
{addedToCart && (
<Alert variant="success" className="mb-4">
Added to cart!{' '}
<a href="/cart" className="underline">
View cart
</a>
</Alert>
)}
<div className="flex gap-3">
<Button size="lg" onClick={addToCart} className="flex-1">
Add to Cart
</Button>
<Button
size="lg"
variant="secondary"
onClick={() => {
addToCart()
router.push('/cart')
}}
>
Buy Now
</Button>
</div>
{product.stock !== null && product.stock <= 5 && product.stock > 0 && (
<p className="mt-3 text-sm text-orange-600">
Only {product.stock} left in stock!
</p>
)}
{product.stock === 0 && (
<p className="mt-3 text-sm text-red-600">Out of stock</p>
)}
</div>
</div>
{/* Reviews */}
{product.reviews.length > 0 && (
<section className="mt-16">
<h2 className="text-2xl font-bold mb-6">Customer Reviews</h2>
<div className="space-y-4">
{product.reviews.map((review) => (
<div key={review.id} className="bg-white rounded-lg border border-gray-200 p-6">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div className="flex">
{[1, 2, 3, 4, 5].map((star) => (
<span
key={star}
className={star <= review.rating ? 'text-yellow-400' : 'text-gray-300'}
>
</span>
))}
</div>
<span className="text-sm font-medium">{review.user.name || 'Customer'}</span>
</div>
<span className="text-sm text-gray-500">
{new Date(review.createdAt).toLocaleDateString()}
</span>
</div>
{review.title && <p className="font-medium mb-1">{review.title}</p>}
{review.comment && <p className="text-gray-600 text-sm">{review.comment}</p>}
</div>
))}
</div>
</section>
)}
</main>
</div>
)
}
+154
View File
@@ -0,0 +1,154 @@
export const dynamic = 'force-dynamic'
import { prisma } from '@/lib/prisma'
import { Navbar } from '@/components/storefront/Navbar'
import { ProductCard } from '@/components/storefront/ProductCard'
import Link from 'next/link'
async function getProducts(searchParams: { category?: string; search?: string; page?: string }) {
const page = parseInt(searchParams.page || '1')
const limit = 20
const skip = (page - 1) * limit
const where: Record<string, unknown> = { status: 'PUBLISHED' }
if (searchParams.category) {
where.categories = {
some: { category: { slug: searchParams.category } },
}
}
if (searchParams.search) {
where.OR = [
{ title: { contains: searchParams.search, mode: 'insensitive' } },
{ description: { contains: searchParams.search, mode: 'insensitive' } },
]
}
const [products, total, categories] = await Promise.all([
prisma.product.findMany({
where,
skip,
take: limit,
include: { images: { take: 1 } },
orderBy: { createdAt: 'desc' },
}),
prisma.product.count({ where }),
prisma.category.findMany({ orderBy: { name: 'asc' } }),
])
return { products, total, categories, page, pages: Math.ceil(total / limit) }
}
export default async function ProductsPage({
searchParams,
}: {
searchParams: { category?: string; search?: string; page?: string }
}) {
const { products, total, categories, page, pages } = await getProducts(searchParams)
return (
<div>
<Navbar />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex gap-8">
{/* Sidebar */}
<aside className="w-48 shrink-0">
<h2 className="font-semibold mb-3">Categories</h2>
<ul className="space-y-1">
<li>
<Link
href="/products"
className={`block text-sm py-1 px-2 rounded hover:bg-gray-100 ${
!searchParams.category ? 'font-medium text-blue-600' : 'text-gray-700'
}`}
>
All Products
</Link>
</li>
{categories.map((cat: typeof categories[number]) => (
<li key={cat.id}>
<Link
href={`/products?category=${cat.slug}`}
className={`block text-sm py-1 px-2 rounded hover:bg-gray-100 ${
searchParams.category === cat.slug
? 'font-medium text-blue-600'
: 'text-gray-700'
}`}
>
{cat.name}
</Link>
</li>
))}
</ul>
</aside>
{/* Main content */}
<div className="flex-1">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">
{searchParams.category
? categories.find((c: typeof categories[number]) => c.slug === searchParams.category)?.name || 'Products'
: 'All Products'}
</h1>
<span className="text-sm text-gray-500">{total} products</span>
</div>
{/* Search */}
<form className="mb-6">
<div className="flex gap-2">
<input
type="text"
name="search"
defaultValue={searchParams.search}
placeholder="Search products..."
className="flex-1 border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{searchParams.category && (
<input type="hidden" name="category" value={searchParams.category} />
)}
<button
type="submit"
className="bg-blue-600 text-white px-4 py-2 rounded text-sm hover:bg-blue-700"
>
Search
</button>
</div>
</form>
{products.length === 0 ? (
<div className="text-center py-16 text-gray-500">
<p>No products found.</p>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{products.map((product: typeof products[number]) => (
<ProductCard key={product.id} product={product} />
))}
</div>
)}
{/* Pagination */}
{pages > 1 && (
<div className="mt-8 flex justify-center gap-2">
{Array.from({ length: pages }, (_, i) => i + 1).map((p) => (
<Link
key={p}
href={`/products?page=${p}${searchParams.category ? `&category=${searchParams.category}` : ''}${searchParams.search ? `&search=${searchParams.search}` : ''}`}
className={`px-3 py-2 rounded text-sm ${
p === page
? 'bg-blue-600 text-white'
: 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50'
}`}
>
{p}
</Link>
))}
</div>
)}
</div>
</div>
</main>
</div>
)
}
+105
View File
@@ -0,0 +1,105 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { Input } from '@/components/ui/Input'
import { Button } from '@/components/ui/Button'
import { Alert } from '@/components/ui/Alert'
export default function RegisterPage() {
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const router = useRouter()
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
setLoading(true)
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, password }),
})
const data = await response.json()
if (!response.ok) {
setError(data.error || 'Registration failed')
return
}
localStorage.setItem('user', JSON.stringify(data.user))
router.push('/')
} catch {
setError('Something went wrong. Please try again.')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="w-full max-w-md">
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-8">
<div className="text-center mb-8">
<Link href="/" className="text-2xl font-bold text-gray-900">ShopX</Link>
<h1 className="text-xl font-semibold mt-4">Create Account</h1>
<p className="text-gray-600 text-sm mt-1">Join ShopX today</p>
</div>
{error && <Alert variant="error" className="mb-4">{error}</Alert>}
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Full Name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="John Doe"
required
/>
<Input
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
required
autoComplete="email"
/>
<div>
<Input
label="Password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••••••"
required
autoComplete="new-password"
/>
<p className="text-xs text-gray-500 mt-1">
Min 12 characters, uppercase, lowercase, number, and symbol.
</p>
</div>
<Button type="submit" loading={loading} className="w-full" size="lg">
Create Account
</Button>
</form>
<p className="text-center text-sm text-gray-600 mt-6">
Already have an account?{' '}
<Link href="/login" className="text-blue-600 hover:underline">
Sign in
</Link>
</p>
</div>
</div>
</div>
)
}
+69
View File
@@ -0,0 +1,69 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useRouter } from 'next/navigation'
const navItems = [
{ href: '/admin', label: 'Dashboard', exact: true },
{ href: '/admin/products', label: 'Products' },
{ href: '/admin/product-types', label: 'Product Types' },
{ href: '/admin/categories', label: 'Categories' },
{ href: '/admin/orders', label: 'Orders' },
{ href: '/admin/customers', label: 'Customers' },
{ href: '/admin/reviews', label: 'Reviews' },
{ href: '/admin/settings', label: 'Settings' },
{ href: '/admin/admin-users', label: 'Admin Users' },
]
export function AdminNav() {
const pathname = usePathname()
const router = useRouter()
async function handleLogout() {
await fetch('/api/auth/logout', { method: 'POST' })
router.push('/login')
}
return (
<nav className="bg-gray-900 text-white w-56 min-h-screen flex flex-col">
<div className="px-4 py-5 border-b border-gray-700">
<h1 className="text-lg font-bold">Admin Panel</h1>
</div>
<ul className="flex-1 py-4">
{navItems.map((item) => {
const active = item.exact ? pathname === item.href : pathname.startsWith(item.href)
return (
<li key={item.href}>
<Link
href={item.href}
className={`block px-4 py-2 text-sm hover:bg-gray-700 transition-colors ${
active ? 'bg-gray-700 text-white' : 'text-gray-300'
}`}
>
{item.label}
</Link>
</li>
)
})}
</ul>
<div className="px-4 py-4 border-t border-gray-700 space-y-2">
<Link
href="/admin/change-password"
className="block text-sm text-gray-300 hover:text-white"
>
Change Password
</Link>
<Link href="/" className="block text-sm text-gray-300 hover:text-white">
View Store
</Link>
<button
onClick={handleLogout}
className="block text-sm text-gray-300 hover:text-white w-full text-left"
>
Logout
</button>
</div>
</nav>
)
}
+90
View File
@@ -0,0 +1,90 @@
'use client'
import Link from 'next/link'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
export function Navbar() {
const [cartCount, setCartCount] = useState(0)
const [user, setUser] = useState<{ name?: string; email: string; role: string } | null>(null)
const router = useRouter()
useEffect(() => {
const cart = JSON.parse(localStorage.getItem('cart') || '[]')
const count = cart.reduce((sum: number, item: { quantity: number }) => sum + item.quantity, 0)
setCartCount(count)
const userData = localStorage.getItem('user')
if (userData) {
try {
setUser(JSON.parse(userData))
} catch {
// ignore
}
}
}, [])
async function handleLogout() {
await fetch('/api/auth/logout', { method: 'POST' })
localStorage.removeItem('user')
setUser(null)
router.push('/')
router.refresh()
}
return (
<header className="bg-white border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
<Link href="/" className="text-xl font-bold text-gray-900">
ShopX
</Link>
<nav className="flex items-center gap-6 text-sm">
<Link href="/products" className="text-gray-600 hover:text-gray-900">
Products
</Link>
<Link href="/cart" className="text-gray-600 hover:text-gray-900 relative">
Cart
{cartCount > 0 && (
<span className="ml-1 bg-blue-600 text-white text-xs px-1.5 py-0.5 rounded-full">
{cartCount}
</span>
)}
</Link>
{user ? (
<>
<Link href="/account" className="text-gray-600 hover:text-gray-900">
Account
</Link>
{(user.role === 'ADMIN' || user.role === 'OWNER') && (
<Link href="/admin" className="text-gray-600 hover:text-gray-900">
Admin
</Link>
)}
<button
onClick={handleLogout}
className="text-gray-600 hover:text-gray-900"
>
Logout
</button>
</>
) : (
<>
<Link href="/login" className="text-gray-600 hover:text-gray-900">
Login
</Link>
<Link
href="/register"
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition-colors"
>
Register
</Link>
</>
)}
</nav>
</div>
</div>
</header>
)
}
@@ -0,0 +1,45 @@
import Link from 'next/link'
interface Product {
id: string
title: string
slug: string
basePrice: number
currency: string
images: Array<{ url: string; altText?: string | null }>
}
interface ProductCardProps {
product: Product
}
export function ProductCard({ product }: ProductCardProps) {
const price = (product.basePrice / 100).toFixed(2)
const image = product.images[0]
return (
<Link href={`/products/${product.slug}`} className="group">
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden hover:shadow-md transition-shadow">
<div className="aspect-square bg-gray-100 flex items-center justify-center">
{image ? (
<img
src={image.url}
alt={image.altText || product.title}
className="w-full h-full object-cover"
/>
) : (
<div className="text-gray-400 text-sm">No image</div>
)}
</div>
<div className="p-4">
<h3 className="font-medium text-gray-900 group-hover:text-blue-600 transition-colors line-clamp-2">
{product.title}
</h3>
<p className="mt-1 text-lg font-bold text-gray-900">
{price} {product.currency}
</p>
</div>
</div>
</Link>
)
}
+20
View File
@@ -0,0 +1,20 @@
interface AlertProps {
children: React.ReactNode
variant?: 'info' | 'success' | 'warning' | 'error'
className?: string
}
export function Alert({ children, variant = 'info', className = '' }: AlertProps) {
const variants = {
info: 'bg-blue-50 border-blue-200 text-blue-800',
success: 'bg-green-50 border-green-200 text-green-800',
warning: 'bg-yellow-50 border-yellow-200 text-yellow-800',
error: 'bg-red-50 border-red-200 text-red-800',
}
return (
<div className={`rounded border px-4 py-3 text-sm ${variants[variant]} ${className}`}>
{children}
</div>
)
}
+22
View File
@@ -0,0 +1,22 @@
import React from 'react'
interface BadgeProps {
children: React.ReactNode
variant?: 'default' | 'success' | 'warning' | 'danger' | 'info'
}
export function Badge({ children, variant = 'default' }: BadgeProps) {
const variants = {
default: 'bg-gray-100 text-gray-700',
success: 'bg-green-100 text-green-700',
warning: 'bg-yellow-100 text-yellow-700',
danger: 'bg-red-100 text-red-700',
info: 'bg-blue-100 text-blue-700',
}
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${variants[variant]}`}>
{children}
</span>
)
}
+50
View File
@@ -0,0 +1,50 @@
'use client'
import React from 'react'
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
size?: 'sm' | 'md' | 'lg'
loading?: boolean
}
export function Button({
variant = 'primary',
size = 'md',
loading = false,
disabled,
children,
className = '',
...props
}: ButtonProps) {
const base = 'inline-flex items-center justify-center font-medium rounded focus:outline-none focus:ring-2 focus:ring-offset-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed'
const variants = {
primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500',
secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300 focus:ring-gray-400',
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
ghost: 'bg-transparent text-gray-700 hover:bg-gray-100 focus:ring-gray-400',
}
const sizes = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-3 text-base',
}
return (
<button
className={`${base} ${variants[variant]} ${sizes[size]} ${className}`}
disabled={disabled || loading}
{...props}
>
{loading && (
<svg className="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
)}
{children}
</button>
)
}
+28
View File
@@ -0,0 +1,28 @@
interface CardProps {
children: React.ReactNode
className?: string
}
export function Card({ children, className = '' }: CardProps) {
return (
<div className={`bg-white rounded-lg border border-gray-200 shadow-sm ${className}`}>
{children}
</div>
)
}
export function CardHeader({ children, className = '' }: CardProps) {
return (
<div className={`px-6 py-4 border-b border-gray-200 ${className}`}>
{children}
</div>
)
}
export function CardBody({ children, className = '' }: CardProps) {
return (
<div className={`px-6 py-4 ${className}`}>
{children}
</div>
)
}
+84
View File
@@ -0,0 +1,84 @@
import React from 'react'
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string
error?: string
}
export function Input({ label, error, className = '', id, ...props }: InputProps) {
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-')
return (
<div className="space-y-1">
{label && (
<label htmlFor={inputId} className="block text-sm font-medium text-gray-700">
{label}
</label>
)}
<input
id={inputId}
className={`block w-full rounded border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 ${
error ? 'border-red-500' : 'border-gray-300'
} ${className}`}
{...props}
/>
{error && <p className="text-sm text-red-600">{error}</p>}
</div>
)
}
interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string
error?: string
}
export function Textarea({ label, error, className = '', id, ...props }: TextareaProps) {
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-')
return (
<div className="space-y-1">
{label && (
<label htmlFor={inputId} className="block text-sm font-medium text-gray-700">
{label}
</label>
)}
<textarea
id={inputId}
className={`block w-full rounded border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 ${
error ? 'border-red-500' : 'border-gray-300'
} ${className}`}
{...props}
/>
{error && <p className="text-sm text-red-600">{error}</p>}
</div>
)
}
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
label?: string
error?: string
}
export function Select({ label, error, className = '', id, children, ...props }: SelectProps) {
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-')
return (
<div className="space-y-1">
{label && (
<label htmlFor={inputId} className="block text-sm font-medium text-gray-700">
{label}
</label>
)}
<select
id={inputId}
className={`block w-full rounded border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 ${
error ? 'border-red-500' : 'border-gray-300'
} ${className}`}
{...props}
>
{children}
</select>
{error && <p className="text-sm text-red-600">{error}</p>}
</div>
)
}
+103
View File
@@ -0,0 +1,103 @@
import { cookies } from 'next/headers'
import { createHash, randomBytes } from 'crypto'
import bcrypt from 'bcryptjs'
import { prisma } from './prisma'
import type { User, Session } from '@prisma/client'
const COOKIE_NAME = 'session_token'
const SESSION_EXPIRY_DAYS = 30
const BCRYPT_COST = 12
export function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex')
}
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, BCRYPT_COST)
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash)
}
export function validatePasswordStrength(password: string): string | null {
if (password.length < 12) return 'Password must be at least 12 characters'
if (!/[A-Z]/.test(password)) return 'Password must contain at least one uppercase letter'
if (!/[a-z]/.test(password)) return 'Password must contain at least one lowercase letter'
if (!/[0-9]/.test(password)) return 'Password must contain at least one number'
if (!/[^A-Za-z0-9]/.test(password)) return 'Password must contain at least one symbol'
return null
}
export async function createSession(userId: string): Promise<string> {
const token = randomBytes(32).toString('hex')
const tokenHash = hashToken(token)
const expiresAt = new Date(Date.now() + SESSION_EXPIRY_DAYS * 24 * 60 * 60 * 1000)
await prisma.session.create({
data: {
userId,
tokenHash,
expiresAt,
},
})
return token
}
export async function setSessionCookie(token: string): Promise<void> {
const cookieStore = cookies()
const isProd = process.env.NODE_ENV === 'production'
cookieStore.set(COOKIE_NAME, token, {
httpOnly: true,
secure: isProd,
sameSite: 'lax',
expires: new Date(Date.now() + SESSION_EXPIRY_DAYS * 24 * 60 * 60 * 1000),
path: '/',
})
}
export async function clearSessionCookie(): Promise<void> {
const cookieStore = cookies()
cookieStore.delete(COOKIE_NAME)
}
export async function getSessionToken(): Promise<string | null> {
const cookieStore = cookies()
const cookie = cookieStore.get(COOKIE_NAME)
return cookie?.value ?? null
}
export async function getSession(): Promise<(Session & { user: User }) | null> {
const token = await getSessionToken()
if (!token) return null
const tokenHash = hashToken(token)
const session = await prisma.session.findUnique({
where: { tokenHash },
include: { user: true },
})
if (!session) return null
if (session.expiresAt < new Date()) {
await prisma.session.delete({ where: { id: session.id } })
return null
}
return session
}
export async function getCurrentUser(): Promise<User | null> {
const session = await getSession()
return session?.user ?? null
}
export async function deleteSession(token: string): Promise<void> {
const tokenHash = hashToken(token)
await prisma.session.deleteMany({ where: { tokenHash } })
}
export async function deleteAllUserSessions(userId: string): Promise<void> {
await prisma.session.deleteMany({ where: { userId } })
}
+70
View File
@@ -0,0 +1,70 @@
import nodemailer from 'nodemailer'
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST || 'localhost',
port: parseInt(process.env.SMTP_PORT || '1025'),
secure: false,
auth:
process.env.SMTP_USER
? {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASSWORD,
}
: undefined,
})
export async function sendEmail({
to,
subject,
html,
text,
}: {
to: string
subject: string
html: string
text?: string
}): Promise<void> {
await transporter.sendMail({
from: process.env.SMTP_FROM || 'noreply@localhost',
to,
subject,
html,
text,
})
}
export async function sendOrderConfirmationEmail(
to: string,
orderDetails: { orderId: string; grandTotal: number; currency: string }
): Promise<void> {
const amount = (orderDetails.grandTotal / 100).toFixed(2)
await sendEmail({
to,
subject: `Order Confirmation #${orderDetails.orderId}`,
html: `
<h1>Thank you for your order!</h1>
<p>Your order <strong>#${orderDetails.orderId}</strong> has been confirmed.</p>
<p>Total: <strong>${amount} ${orderDetails.currency}</strong></p>
<p>You can track your order in your <a href="${process.env.APP_URL}/account/orders">account</a>.</p>
`,
text: `Order #${orderDetails.orderId} confirmed. Total: ${amount} ${orderDetails.currency}`,
})
}
export async function sendPasswordResetEmail(
to: string,
resetToken: string
): Promise<void> {
const resetUrl = `${process.env.APP_URL}/reset-password?token=${resetToken}`
await sendEmail({
to,
subject: 'Password Reset Request',
html: `
<h1>Password Reset</h1>
<p>Click the link below to reset your password. This link expires in 1 hour.</p>
<a href="${resetUrl}">${resetUrl}</a>
<p>If you did not request a password reset, please ignore this email.</p>
`,
text: `Reset your password: ${resetUrl}`,
})
}
+13
View File
@@ -0,0 +1,13 @@
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
})
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
+39
View File
@@ -0,0 +1,39 @@
import Stripe from 'stripe'
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-06-20',
})
export async function createCheckoutSession({
orderId,
lineItems,
customerEmail,
successUrl,
cancelUrl,
}: {
orderId: string
lineItems: Stripe.Checkout.SessionCreateParams.LineItem[]
customerEmail: string
successUrl: string
cancelUrl: string
}): Promise<Stripe.Checkout.Session> {
return stripe.checkout.sessions.create({
payment_method_types: ['card'],
mode: 'payment',
customer_email: customerEmail,
line_items: lineItems,
success_url: successUrl,
cancel_url: cancelUrl,
metadata: {
orderId,
},
})
}
export function constructWebhookEvent(
payload: string | Buffer,
signature: string,
secret: string
): Stripe.Event {
return stripe.webhooks.constructEvent(payload, signature, secret)
}
+115
View File
@@ -0,0 +1,115 @@
import { z } from 'zod'
export const loginSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(1, 'Password is required'),
})
export const registerSchema = z.object({
email: z.string().email('Invalid email address'),
password: z
.string()
.min(12, 'Password must be at least 12 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
.regex(/[0-9]/, 'Password must contain at least one number')
.regex(/[^A-Za-z0-9]/, 'Password must contain at least one symbol'),
name: z.string().min(1, 'Name is required').max(100),
})
export const changePasswordSchema = z.object({
currentPassword: z.string().min(1, 'Current password is required'),
newPassword: z
.string()
.min(12, 'Password must be at least 12 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
.regex(/[0-9]/, 'Password must contain at least one number')
.regex(/[^A-Za-z0-9]/, 'Password must contain at least one symbol'),
})
export const productTypeSchema = z.object({
name: z.string().min(1, 'Name is required').max(100),
slug: z
.string()
.min(1, 'Slug is required')
.max(100)
.regex(/^[a-z0-9-]+$/, 'Slug must contain only lowercase letters, numbers, and hyphens'),
schema: z.record(z.any()),
})
export const productSchema = z.object({
typeId: z.string().min(1, 'Product type is required'),
title: z.string().min(1, 'Title is required').max(200),
slug: z
.string()
.min(1, 'Slug is required')
.max(200)
.regex(/^[a-z0-9-]+$/, 'Slug must contain only lowercase letters, numbers, and hyphens'),
description: z.string().min(1, 'Description is required'),
basePrice: z.number().int().min(0, 'Price must be non-negative'),
currency: z.string().length(3, 'Currency must be 3 characters'),
status: z.enum(['DRAFT', 'PUBLISHED', 'ARCHIVED']),
attributes: z.record(z.any()),
stock: z.number().int().min(0).nullable().optional(),
categoryIds: z.array(z.string()).optional(),
})
export const categorySchema = z.object({
name: z.string().min(1, 'Name is required').max(100),
slug: z
.string()
.min(1, 'Slug is required')
.max(100)
.regex(/^[a-z0-9-]+$/, 'Slug must contain only lowercase letters, numbers, and hyphens'),
parentId: z.string().nullable().optional(),
})
export const reviewSchema = z.object({
productId: z.string().min(1, 'Product ID is required'),
rating: z.number().int().min(1).max(5),
title: z.string().max(200).optional(),
comment: z.string().max(2000).optional(),
})
export const cartItemSchema = z.object({
productId: z.string().min(1),
quantity: z.number().int().min(1).max(100),
variantId: z.string().optional(),
})
export const checkoutSchema = z.object({
items: z.array(
z.object({
productId: z.string(),
quantity: z.number().int().min(1),
variantId: z.string().optional(),
})
).min(1, 'Cart is empty'),
})
export const settingSchema = z.object({
key: z.string().min(1),
value: z.any(),
})
export const adminUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
role: z.enum(['ADMIN', 'OWNER']),
password: z
.string()
.min(12, 'Password must be at least 12 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
.regex(/[0-9]/, 'Password must contain at least one number')
.regex(/[^A-Za-z0-9]/, 'Password must contain at least one symbol'),
})
export type LoginInput = z.infer<typeof loginSchema>
export type RegisterInput = z.infer<typeof registerSchema>
export type ProductTypeInput = z.infer<typeof productTypeSchema>
export type ProductInput = z.infer<typeof productSchema>
export type CategoryInput = z.infer<typeof categorySchema>
export type ReviewInput = z.infer<typeof reviewSchema>
export type CheckoutInput = z.infer<typeof checkoutSchema>
+12
View File
@@ -0,0 +1,12 @@
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
const response = NextResponse.next()
response.headers.set('x-pathname', request.nextUrl.pathname)
return response
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}