diff --git a/Dockerfile b/Dockerfile index 3b6eb30..2c77791 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,6 +15,9 @@ WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . +# Generate Prisma Client +RUN npx prisma generate + ENV NEXT_TELEMETRY_DISABLED=1 RUN npm run build diff --git a/app/admin/actions.ts b/app/admin/actions.ts new file mode 100644 index 0000000..72688a8 --- /dev/null +++ b/app/admin/actions.ts @@ -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('/'); +} diff --git a/app/admin/admin.css b/app/admin/admin.css new file mode 100644 index 0000000..b6545c1 --- /dev/null +++ b/app/admin/admin.css @@ -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); +} diff --git a/app/admin/categories/CategoryManager.tsx b/app/admin/categories/CategoryManager.tsx new file mode 100644 index 0000000..c6b9b2a --- /dev/null +++ b/app/admin/categories/CategoryManager.tsx @@ -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(null); + + const handleEdit = (category: any) => { + setEditingCategory(category); + setShowForm(true); + }; + + const handleClose = () => { + setShowForm(false); + setEditingCategory(null); + }; + + return ( +
+
+
+

Kategoriler

+

Menü kategorilerini yönetin

+
+ +
+ +
+ + + + + + + + + + + {categories.map((cat) => ( + + + + + + + ))} + +
BaşlıkHarici IDÜrün Sayısıİşlemler
{cat.title}{cat.externalId}{cat._count?.items || 0} Ürün + + { await deleteCategory(cat.id); }} /> +
+
+ + {showForm && ( + + )} +
+ ); +} diff --git a/app/admin/categories/page.tsx b/app/admin/categories/page.tsx new file mode 100644 index 0000000..7a909b1 --- /dev/null +++ b/app/admin/categories/page.tsx @@ -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 ; +} diff --git a/app/admin/components/AdminNavItem.tsx b/app/admin/components/AdminNavItem.tsx new file mode 100644 index 0000000..26c98d8 --- /dev/null +++ b/app/admin/components/AdminNavItem.tsx @@ -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 ( + + {children} + + ); +} diff --git a/app/admin/components/CategoryForm.tsx b/app/admin/components/CategoryForm.tsx new file mode 100644 index 0000000..56ef85c --- /dev/null +++ b/app/admin/components/CategoryForm.tsx @@ -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 ( +
+
+
+

{initialData ? 'Kategoriyi Düzenle' : 'Yeni Kategori Ekle'}

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ ); +} diff --git a/app/admin/components/DeleteButton.tsx b/app/admin/components/DeleteButton.tsx new file mode 100644 index 0000000..813fca0 --- /dev/null +++ b/app/admin/components/DeleteButton.tsx @@ -0,0 +1,29 @@ +'use client'; + +import React from 'react'; + +interface DeleteButtonProps { + onDelete: () => Promise; +} + +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 ( + + ); +} diff --git a/app/admin/components/ItemForm.tsx b/app/admin/components/ItemForm.tsx new file mode 100644 index 0000000..fc31589 --- /dev/null +++ b/app/admin/components/ItemForm.tsx @@ -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 ( +
+
+
+

{initialData ? 'Ürünü Düzenle' : 'Yeni Ürün Ekle'}

+ +
+
+
+ + +
+
+ + +
+
+ +