feat: add prisma support, admin panel and auth
This commit is contained in:
150
app/admin/actions.ts
Normal file
150
app/admin/actions.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
'use server';
|
||||
|
||||
import { prisma } from '@/app/lib/prisma';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { cookies } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { encrypt } from '@/app/lib/auth';
|
||||
|
||||
/* ────── Auth Actions ────── */
|
||||
|
||||
export async function authenticate(formData: FormData) {
|
||||
const username = formData.get('username') as string;
|
||||
const password = formData.get('password') as string;
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { username } });
|
||||
|
||||
if (!user || !(await bcrypt.compare(password, user.password))) {
|
||||
return { error: 'Geçersiz kullanıcı adı veya şifre' };
|
||||
}
|
||||
|
||||
// Create session
|
||||
const expires = new Date(Date.now() + 2 * 60 * 60 * 1000); // 2 hours
|
||||
const session = await encrypt({ userId: user.id, username: user.username, expires });
|
||||
|
||||
(await cookies()).set('session', session, { expires, httpOnly: true });
|
||||
|
||||
redirect('/admin');
|
||||
}
|
||||
|
||||
export async function signout() {
|
||||
(await cookies()).set('session', '', { expires: new Date(0) });
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
export async function createCategory(formData: FormData) {
|
||||
const title = formData.get('title') as string;
|
||||
const externalId = formData.get('externalId') as string;
|
||||
|
||||
await prisma.category.create({
|
||||
data: {
|
||||
title,
|
||||
externalId: externalId || null,
|
||||
restaurantId: (await prisma.restaurant.findFirst())?.id || '',
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath('/admin/categories');
|
||||
revalidatePath('/');
|
||||
}
|
||||
|
||||
export async function updateCategory(id: string, formData: FormData) {
|
||||
const title = formData.get('title') as string;
|
||||
const externalId = formData.get('externalId') as string;
|
||||
|
||||
await prisma.category.update({
|
||||
where: { id },
|
||||
data: {
|
||||
title,
|
||||
externalId: externalId || null,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath('/admin/categories');
|
||||
revalidatePath('/');
|
||||
}
|
||||
|
||||
export async function deleteCategory(id: string) {
|
||||
// Optional: check if there are items first, or let prisma handles cascade if configured
|
||||
await prisma.item.deleteMany({ where: { categoryId: id } });
|
||||
await prisma.category.delete({ where: { id } });
|
||||
|
||||
revalidatePath('/admin/categories');
|
||||
revalidatePath('/');
|
||||
}
|
||||
|
||||
/* ────── Item Actions ────── */
|
||||
|
||||
export async function createItem(formData: FormData) {
|
||||
const name = formData.get('name') as string;
|
||||
const categoryId = formData.get('categoryId') as string;
|
||||
const ingredients = formData.get('ingredients') as string;
|
||||
const tasteProfile = formData.get('tasteProfile') as string;
|
||||
const grapeVariety = formData.get('grapeVariety') as string;
|
||||
const priceInput = formData.get('price') as string;
|
||||
|
||||
let price;
|
||||
try {
|
||||
// Try to parse as JSON if it looks like an object, otherwise keep as string
|
||||
price = (priceInput.startsWith('{') || priceInput.startsWith('['))
|
||||
? JSON.parse(priceInput)
|
||||
: priceInput;
|
||||
} catch (e) {
|
||||
price = priceInput;
|
||||
}
|
||||
|
||||
await prisma.item.create({
|
||||
data: {
|
||||
name,
|
||||
categoryId,
|
||||
ingredients: ingredients || null,
|
||||
tasteProfile: tasteProfile || null,
|
||||
grapeVariety: grapeVariety || null,
|
||||
price,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath('/admin/items');
|
||||
revalidatePath('/');
|
||||
}
|
||||
|
||||
export async function updateItem(id: string, formData: FormData) {
|
||||
const name = formData.get('name') as string;
|
||||
const categoryId = formData.get('categoryId') as string;
|
||||
const ingredients = formData.get('ingredients') as string;
|
||||
const tasteProfile = formData.get('tasteProfile') as string;
|
||||
const grapeVariety = formData.get('grapeVariety') as string;
|
||||
const priceInput = formData.get('price') as string;
|
||||
|
||||
let price;
|
||||
try {
|
||||
price = (priceInput.startsWith('{') || priceInput.startsWith('['))
|
||||
? JSON.parse(priceInput)
|
||||
: priceInput;
|
||||
} catch (e) {
|
||||
price = priceInput;
|
||||
}
|
||||
|
||||
await prisma.item.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name,
|
||||
categoryId,
|
||||
ingredients: ingredients || null,
|
||||
tasteProfile: tasteProfile || null,
|
||||
grapeVariety: grapeVariety || null,
|
||||
price,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath('/admin/items');
|
||||
revalidatePath('/');
|
||||
}
|
||||
|
||||
export async function deleteItem(id: string) {
|
||||
await prisma.item.delete({ where: { id } });
|
||||
|
||||
revalidatePath('/admin/items');
|
||||
revalidatePath('/');
|
||||
}
|
||||
322
app/admin/admin.css
Normal file
322
app/admin/admin.css
Normal file
@@ -0,0 +1,322 @@
|
||||
.admin-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
width: 280px;
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.admin-sidebar-header {
|
||||
padding: 2rem;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.admin-logo {
|
||||
font-family: 'Playfair Display', serif;
|
||||
font-size: 1.5rem;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.admin-logo span {
|
||||
color: var(--gold);
|
||||
font-size: 0.8rem;
|
||||
font-family: 'Inter', sans-serif;
|
||||
letter-spacing: 0.3em;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.admin-nav {
|
||||
padding: 1.5rem 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.admin-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.85rem 2rem;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.3s;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.admin-nav-item svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.admin-nav-item:hover {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.admin-nav-item.active {
|
||||
color: var(--gold);
|
||||
background: rgba(201, 169, 110, 0.05);
|
||||
border-left-color: var(--gold);
|
||||
}
|
||||
|
||||
.admin-sidebar-footer {
|
||||
padding: 1.5rem 2rem;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.admin-view-site {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 0.8rem;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.admin-view-site:hover {
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
.admin-view-site svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
height: 70px;
|
||||
padding: 0 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: rgba(10, 10, 15, 0.5);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.admin-header-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.admin-user {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
padding: 2rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Dashboard Cards */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--surface-elevated);
|
||||
padding: 1.5rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
transition: transform 0.3s, border-color 0.3s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-5px);
|
||||
border-color: var(--border-glow);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
/* Tables & Lists */
|
||||
.admin-card {
|
||||
background: var(--surface);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 1.5rem 2rem;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.1rem;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.admin-btn {
|
||||
background: var(--gold);
|
||||
color: #000;
|
||||
border: none;
|
||||
padding: 0.6rem 1.2rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.admin-btn:hover {
|
||||
background: var(--gold-light);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 15px rgba(201, 169, 110, 0.2);
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.admin-table th {
|
||||
text-align: left;
|
||||
padding: 1rem 2rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.admin-table td {
|
||||
padding: 1.25rem 2rem;
|
||||
font-size: 0.9rem;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.admin-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.admin-table tr:hover {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
.action-btn.delete:hover {
|
||||
color: var(--accent-rose);
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
background: rgba(201, 169, 110, 0.1);
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.admin-form {
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.admin-input, .admin-select, .admin-textarea {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
color: #fff;
|
||||
font-family: inherit;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.admin-input:focus, .admin-select:focus, .admin-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--gold);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
backdrop-filter: blur(5px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 20px;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
73
app/admin/categories/CategoryManager.tsx
Normal file
73
app/admin/categories/CategoryManager.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import CategoryForm from '../components/CategoryForm';
|
||||
import DeleteButton from '../components/DeleteButton';
|
||||
import { deleteCategory } from '../actions';
|
||||
|
||||
interface CategoryManagerProps {
|
||||
categories: any[];
|
||||
}
|
||||
|
||||
export default function CategoryManager({ categories }: CategoryManagerProps) {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingCategory, setEditingCategory] = useState<any>(null);
|
||||
|
||||
const handleEdit = (category: any) => {
|
||||
setEditingCategory(category);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setShowForm(false);
|
||||
setEditingCategory(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-categories">
|
||||
<div className="admin-page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
|
||||
<div>
|
||||
<h1>Kategoriler</h1>
|
||||
<p>Menü kategorilerini yönetin</p>
|
||||
</div>
|
||||
<button className="admin-btn" onClick={() => setShowForm(true)}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{width: '16px'}}><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></svg>
|
||||
Yeni Kategori
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Başlık</th>
|
||||
<th>Harici ID</th>
|
||||
<th>Ürün Sayısı</th>
|
||||
<th>İşlemler</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{categories.map((cat) => (
|
||||
<tr key={cat.id}>
|
||||
<td style={{ fontWeight: 500, color: '#fff' }}>{cat.title}</td>
|
||||
<td><code className="badge">{cat.externalId}</code></td>
|
||||
<td>{cat._count?.items || 0} Ürün</td>
|
||||
<td className="actions-cell">
|
||||
<button className="action-btn" onClick={() => handleEdit(cat)}>Düzenle</button>
|
||||
<DeleteButton onDelete={async () => { await deleteCategory(cat.id); }} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<CategoryForm
|
||||
initialData={editingCategory}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
app/admin/categories/page.tsx
Normal file
18
app/admin/categories/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import { prisma } from '@/app/lib/prisma';
|
||||
import CategoryManager from './CategoryManager';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function AdminCategoriesPage() {
|
||||
const categories = await prisma.category.findMany({
|
||||
include: {
|
||||
_count: {
|
||||
select: { items: true }
|
||||
}
|
||||
},
|
||||
orderBy: { createdAt: 'asc' }
|
||||
});
|
||||
|
||||
return <CategoryManager categories={categories} />;
|
||||
}
|
||||
22
app/admin/components/AdminNavItem.tsx
Normal file
22
app/admin/components/AdminNavItem.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/use-pathname';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import NextLink from 'next/link';
|
||||
|
||||
interface NavItemProps {
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function AdminNavItem({ href, children }: NavItemProps) {
|
||||
const pathname = usePathname();
|
||||
const isActive = pathname === href;
|
||||
|
||||
return (
|
||||
<NextLink href={href} className={`admin-nav-item ${isActive ? 'active' : ''}`}>
|
||||
{children}
|
||||
</NextLink>
|
||||
);
|
||||
}
|
||||
66
app/admin/components/CategoryForm.tsx
Normal file
66
app/admin/components/CategoryForm.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { createCategory, updateCategory } from '../actions';
|
||||
|
||||
interface CategoryFormProps {
|
||||
initialData?: any;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function CategoryForm({ initialData, onClose }: CategoryFormProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(formData: FormData) {
|
||||
setLoading(true);
|
||||
if (initialData) {
|
||||
await updateCategory(initialData.id, formData);
|
||||
} else {
|
||||
await createCategory(formData);
|
||||
}
|
||||
setLoading(false);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-content">
|
||||
<div className="card-header">
|
||||
<h2 className="card-title">{initialData ? 'Kategoriyi Düzenle' : 'Yeni Kategori Ekle'}</h2>
|
||||
<button onClick={onClose} className="action-btn">✕</button>
|
||||
</div>
|
||||
<form action={handleSubmit} className="admin-form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="title">Kategori Başlığı</label>
|
||||
<input
|
||||
id="title"
|
||||
name="title"
|
||||
defaultValue={initialData?.title}
|
||||
className="admin-input"
|
||||
placeholder="Örn: Klasik Kokteyller"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="externalId">Harici ID (İkon eşleşmesi için)</label>
|
||||
<input
|
||||
id="externalId"
|
||||
name="externalId"
|
||||
defaultValue={initialData?.externalId}
|
||||
className="admin-input"
|
||||
placeholder="Örn: classic_cocktails"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '1rem', marginTop: '1rem' }}>
|
||||
<button type="submit" className="admin-btn" disabled={loading}>
|
||||
{loading ? 'Kaydediliyor...' : 'Kaydet'}
|
||||
</button>
|
||||
<button type="button" onClick={onClose} className="admin-btn" style={{ background: 'rgba(255,255,255,0.05)', color: '#fff' }}>
|
||||
İptal
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
app/admin/components/DeleteButton.tsx
Normal file
29
app/admin/components/DeleteButton.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface DeleteButtonProps {
|
||||
onDelete: () => Promise<void>;
|
||||
}
|
||||
|
||||
export default function DeleteButton({ onDelete }: DeleteButtonProps) {
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
|
||||
async function handleClick() {
|
||||
if (confirm('Bu öğeyi silmek istediğinizden emin misiniz?')) {
|
||||
setLoading(true);
|
||||
await onDelete();
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="action-btn delete"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '...' : 'Sil'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
112
app/admin/components/ItemForm.tsx
Normal file
112
app/admin/components/ItemForm.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { createItem, updateItem } from '../actions';
|
||||
|
||||
interface ItemFormProps {
|
||||
categories: any[];
|
||||
initialData?: any;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ItemForm({ categories, initialData, onClose }: ItemFormProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(formData: FormData) {
|
||||
setLoading(true);
|
||||
if (initialData) {
|
||||
await updateItem(initialData.id, formData);
|
||||
} else {
|
||||
await createItem(formData);
|
||||
}
|
||||
setLoading(false);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-content">
|
||||
<div className="card-header">
|
||||
<h2 className="card-title">{initialData ? 'Ürünü Düzenle' : 'Yeni Ürün Ekle'}</h2>
|
||||
<button onClick={onClose} className="action-btn">✕</button>
|
||||
</div>
|
||||
<form action={handleSubmit} className="admin-form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="name">Ürün Adı</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
defaultValue={initialData?.name}
|
||||
className="admin-input"
|
||||
placeholder="Örn: Mojito"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="categoryId">Kategori</label>
|
||||
<select
|
||||
id="categoryId"
|
||||
name="categoryId"
|
||||
defaultValue={initialData?.categoryId || categories[0]?.id}
|
||||
className="admin-select"
|
||||
required
|
||||
>
|
||||
{categories.map(cat => (
|
||||
<option key={cat.id} value={cat.id}>{cat.title}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="price">Fiyat (Metin veya JSON)</label>
|
||||
<textarea
|
||||
id="price"
|
||||
name="price"
|
||||
defaultValue={typeof initialData?.price === 'object' ? JSON.stringify(initialData.price) : initialData?.price}
|
||||
className="admin-textarea"
|
||||
placeholder='Örn: 590 tl veya {"single": "370 tl", "double": "560 tl"}'
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="ingredients">İçerik</label>
|
||||
<input
|
||||
id="ingredients"
|
||||
name="ingredients"
|
||||
defaultValue={initialData?.ingredients}
|
||||
className="admin-input"
|
||||
placeholder="Örn: Rom, Nane, Lime"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="tasteProfile">Tat Profili</label>
|
||||
<input
|
||||
id="tasteProfile"
|
||||
name="tasteProfile"
|
||||
defaultValue={initialData?.tasteProfile}
|
||||
className="admin-input"
|
||||
placeholder="Örn: Tatlı-Ekşi-Ferah"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="grapeVariety">Üzüm Çeşidi (Şaraplar için)</label>
|
||||
<input
|
||||
id="grapeVariety"
|
||||
name="grapeVariety"
|
||||
defaultValue={initialData?.grapeVariety}
|
||||
className="admin-input"
|
||||
placeholder="Örn: Merlot"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '1rem', marginTop: '1rem' }}>
|
||||
<button type="submit" className="admin-btn" disabled={loading}>
|
||||
{loading ? 'Kaydediliyor...' : 'Kaydet'}
|
||||
</button>
|
||||
<button type="button" onClick={onClose} className="admin-btn" style={{ background: 'rgba(255,255,255,0.05)', color: '#fff' }}>
|
||||
İptal
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
app/admin/components/SignOutButton.tsx
Normal file
16
app/admin/components/SignOutButton.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { signout } from '../actions';
|
||||
|
||||
export default function SignOutButton() {
|
||||
return (
|
||||
<button
|
||||
onClick={() => signout()}
|
||||
className="action-btn"
|
||||
style={{ fontSize: '0.8rem', color: 'var(--accent-rose)' }}
|
||||
>
|
||||
Çıkış Yap
|
||||
</button>
|
||||
);
|
||||
}
|
||||
91
app/admin/items/ItemManager.tsx
Normal file
91
app/admin/items/ItemManager.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import ItemForm from '../components/ItemForm';
|
||||
import DeleteButton from '../components/DeleteButton';
|
||||
import { deleteItem } from '../actions';
|
||||
|
||||
interface ItemManagerProps {
|
||||
items: any[];
|
||||
categories: any[];
|
||||
}
|
||||
|
||||
export default function ItemManager({ items, categories }: ItemManagerProps) {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<any>(null);
|
||||
|
||||
const handleEdit = (item: any) => {
|
||||
setEditingItem(item);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setShowForm(false);
|
||||
setEditingItem(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-items">
|
||||
<div className="admin-page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
|
||||
<div>
|
||||
<h1>Ürünler</h1>
|
||||
<p>Tüm menü öğelerini yönetin</p>
|
||||
</div>
|
||||
<button className="admin-btn" onClick={() => setShowForm(true)}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{width: '16px'}}><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></svg>
|
||||
Yeni Ürün Ekle
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ürün Adı</th>
|
||||
<th>Kategori</th>
|
||||
<th>Fiyat</th>
|
||||
<th>İşlemler</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td>
|
||||
<div style={{ fontWeight: 500, color: '#fff' }}>{item.name}</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
|
||||
{item.ingredients ? (item.ingredients.length > 50 ? item.ingredients.substring(0, 50) + '...' : item.ingredients) : 'İçerik yok'}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className="badge" style={{ background: 'rgba(124, 92, 191, 0.1)', color: 'var(--accent-purple)', borderColor: 'rgba(124, 92, 191, 0.2)' }}>
|
||||
{item.category.title}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ color: 'var(--gold)', fontWeight: 600 }}>
|
||||
{typeof item.price === 'string'
|
||||
? item.price
|
||||
: Object.entries(item.price as object).map(([k, v]) => `${k}: ${v}`).join(', ')
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td className="actions-cell">
|
||||
<button className="action-btn" onClick={() => handleEdit(item)}>Düzenle</button>
|
||||
<DeleteButton onDelete={async () => { await deleteItem(item.id); }} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<ItemForm
|
||||
categories={categories}
|
||||
initialData={editingItem}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
app/admin/items/page.tsx
Normal file
20
app/admin/items/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import { prisma } from '@/app/lib/prisma';
|
||||
import ItemManager from './ItemManager';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function AdminItemsPage() {
|
||||
const items = await prisma.item.findMany({
|
||||
include: {
|
||||
category: true
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
|
||||
const categories = await prisma.category.findMany({
|
||||
orderBy: { title: 'asc' }
|
||||
});
|
||||
|
||||
return <ItemManager items={items} categories={categories} />;
|
||||
}
|
||||
58
app/admin/layout.tsx
Normal file
58
app/admin/layout.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import AdminNavItem from './components/AdminNavItem';
|
||||
import SignOutButton from './components/SignOutButton';
|
||||
import { getSession } from '@/app/lib/auth';
|
||||
import './admin.css';
|
||||
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const session = await getSession();
|
||||
|
||||
return (
|
||||
<div className="admin-layout">
|
||||
<aside className="admin-sidebar">
|
||||
<div className="admin-sidebar-header">
|
||||
<Link href="/admin" className="admin-logo">
|
||||
LUNA <span>ADMIN</span>
|
||||
</Link>
|
||||
</div>
|
||||
<nav className="admin-nav">
|
||||
<AdminNavItem href="/admin">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" /><rect x="14" y="14" width="7" height="7" /><rect x="3" y="14" width="7" height="7" /></svg>
|
||||
Dashboard
|
||||
</AdminNavItem>
|
||||
<AdminNavItem href="/admin/categories">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" /></svg>
|
||||
Kategoriler
|
||||
</AdminNavItem>
|
||||
<AdminNavItem href="/admin/items">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" /></svg>
|
||||
Ürünler
|
||||
</AdminNavItem>
|
||||
</nav>
|
||||
<div className="admin-sidebar-footer">
|
||||
<Link href="/" target="_blank" className="admin-view-site">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /><polyline points="15 3 21 3 21 9" /><line x1="10" y1="14" x2="21" y2="3" /></svg>
|
||||
Siteyi Görüntüle
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="admin-main">
|
||||
<header className="admin-header">
|
||||
<div className="admin-header-title">Panel</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1.5rem' }}>
|
||||
<div className="admin-user">{session?.username || 'Admin'}</div>
|
||||
<SignOutButton />
|
||||
</div>
|
||||
</header>
|
||||
<div className="admin-content">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
app/admin/page.tsx
Normal file
44
app/admin/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { prisma } from '@/app/lib/prisma';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function AdminDashboard() {
|
||||
const categoryCount = await prisma.category.count();
|
||||
const itemCount = await prisma.item.count();
|
||||
const restaurant = await prisma.restaurant.findFirst();
|
||||
|
||||
return (
|
||||
<div className="admin-dashboard">
|
||||
<div className="admin-page-header">
|
||||
<h1>Dashboard</h1>
|
||||
<p>Luna Cocktail & More - Menü Özeti</p>
|
||||
</div>
|
||||
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Toplam Kategori</div>
|
||||
<div className="stat-value">{categoryCount}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Toplam Ürün</div>
|
||||
<div className="stat-value">{itemCount}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Restoran İsmi</div>
|
||||
<div className="stat-value" style={{ fontSize: '1.2rem' }}>{restaurant?.name || 'Yükleniyor...'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
<div className="card-header">
|
||||
<h2 className="card-title">Hızlı İşlemler</h2>
|
||||
</div>
|
||||
<div style={{ padding: '2rem', display: 'flex', gap: '1rem' }}>
|
||||
<button className="admin-btn">Yeni Kategori Ekle</button>
|
||||
<button className="admin-btn">Yeni Ürün Ekle</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user