feat: admin panel CRUD, Cloudinary upload, logo, user management

This commit is contained in:
mstfyldz
2026-06-05 17:38:01 +03:00
parent 53eeeee474
commit 5e4e2f0db9
43 changed files with 3917 additions and 281 deletions
+266
View File
@@ -0,0 +1,266 @@
"use client";
import { useEffect, useState, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { MenuCategory, MenuItem as MenuItemType } from "@/data/menu";
import { CategoryNav } from "@/components/CategoryNav";
import { MenuItem } from "@/components/MenuItem";
export const CATEGORY_ICONS: Record<string, string> = {
"kahvaltiliklar": "🌅",
"kaseler": "🥗",
"burger-ve-sandvic": "🍔",
"pizzalar": "🍕",
"makarnalar": "🍝",
"baslangiclar": "🧆",
"salatalar": "🥬",
"ana-yemekler": "🍽️",
"tatlilar": "🍮",
"i̇mza-kokteyller": "🍹",
"klasik-kokteyller": "🍸",
"biralar": "🍺",
"shotlar": "🥃",
"sise-ve-kadeh-alkollu": "🥂",
"saraplar": "🍷",
"cerezler": "🥜",
"alkolsuz-i̇cecekler": "🧃",
"sicak-kahveler": "☕",
"soguk-kahveler": "🧊",
"ekstralar": "✨",
};
export default function MenuClient({ initialCategories }: { initialCategories: MenuCategory[] }) {
const [activeCategoryId, setActiveCategoryId] = useState<string>(
initialCategories[0]?.id || ""
);
const [selectedItem, setSelectedItem] = useState<MenuItemType | null>(null);
const sectionRefs = useRef<(HTMLElement | null)[]>([]);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
const hit = entries.find((e) => e.isIntersecting);
if (hit) setActiveCategoryId(hit.target.id);
},
{ root: null, rootMargin: "-100px 0px -62% 0px", threshold: 0 }
);
sectionRefs.current.forEach((ref) => ref && observer.observe(ref));
return () => observer.disconnect();
}, []);
useEffect(() => {
if (selectedItem) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "unset";
}
return () => {
document.body.style.overflow = "unset";
};
}, [selectedItem]);
return (
<div className="min-h-screen" style={{ background: "var(--cream)" }}>
{/* ── Hero ─────────────────────────────────────── */}
<header className="relative overflow-hidden bg-stone-900">
{/* Background Image */}
<div
className="absolute inset-0 z-0 opacity-80"
style={{
backgroundImage: "url('/header-bg.jpg')",
backgroundSize: "cover",
backgroundPosition: "center"
}}
/>
{/* Dark overlay to make text readable */}
<div className="absolute inset-0 z-0 bg-gradient-to-b from-black/60 via-black/40 to-black/80" />
{/* hero content */}
<div className="relative z-10 flex flex-col items-center text-center px-6 pt-16 pb-20">
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.9, ease: [0.16, 1, 0.3, 1] }}
className="flex flex-col items-center"
>
<p
className="text-[10px] tracking-[0.38em] uppercase mb-8 font-sans font-medium"
style={{ color: "rgba(255,220,160,1)" }}
>
Akyaka · Muğla
</p>
{/* Logo */}
<img
src="/logo.png"
alt="Moy Beach"
className="w-64 md:w-80 object-contain drop-shadow-lg"
style={{ filter: 'brightness(0) invert(1)' }}
/>
<div
className="flex items-center gap-3 my-5"
style={{ color: "rgba(255,190,100,0.35)" }}
>
<div className="h-px w-14" style={{ background: "currentColor" }} />
<span className="text-base"></span>
<div className="h-px w-14" style={{ background: "currentColor" }} />
</div>
<p
className="text-xs font-sans tracking-wide"
style={{ color: "rgba(255,218,168,0.5)" }}
>
Akyaka&apos;nın en keyifli menüsü
</p>
</motion.div>
</div>
{/* wave transition */}
<div className="absolute bottom-0 left-0 right-0 translate-y-px">
<svg
viewBox="0 0 1440 52"
preserveAspectRatio="none"
className="w-full h-[52px]"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0 52L80 44C160 36 320 20 480 18C640 16 800 28 960 32C1120 36 1280 32 1360 30L1440 28V52H1360C1280 52 1120 52 960 52C800 52 640 52 480 52C320 52 160 52 80 52H0Z"
fill="#FAF8F3"
/>
</svg>
</div>
</header>
{/* ── Category Nav ─────────────────────────────── */}
<CategoryNav
categories={initialCategories}
activeCategoryId={activeCategoryId}
icons={CATEGORY_ICONS}
/>
{/* ── Menu Sections ────────────────────────────── */}
<main className="pb-28">
{initialCategories.map((category, index) => (
<motion.section
key={category.id}
id={category.id}
ref={(el) => { sectionRefs.current[index] = el; }}
className="pt-10 px-4 max-w-lg mx-auto scroll-mt-[62px]"
initial={{ opacity: 0, y: 14 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
>
{/* section header */}
<div
className="flex items-center gap-2.5 mb-4 pb-3 border-b"
style={{ borderColor: "var(--sand-border)" }}
>
<span className="text-xl leading-none select-none">
{CATEGORY_ICONS[category.id] ?? "🍽️"}
</span>
<h2
className="font-display text-[22px] leading-tight font-semibold italic"
style={{ color: "var(--stone-ink)" }}
>
{category.title}
</h2>
</div>
<div>
{category.items.map((item, idx) => (
<MenuItem key={idx} item={item} onClick={() => setSelectedItem(item)} />
))}
</div>
</motion.section>
))}
</main>
{/* ── Footer ───────────────────────────────────── */}
<footer
className="py-12 text-center"
style={{ background: "var(--hero-from)" }}
>
<div className="flex flex-col items-center gap-1">
{/* Footer Logo */}
<img
src="/logo.png"
alt="Moy Beach"
className="w-36 object-contain opacity-70 mb-2"
style={{ filter: 'brightness(0) invert(1)' }}
/>
<p
className="text-[10px] font-sans tracking-[0.3em] uppercase mt-1"
style={{ color: "rgba(255,200,130,0.35)" }}
>
Akyaka, Muğla
</p>
<div
className="flex items-center gap-3 my-4"
style={{ color: "rgba(160,100,50,0.4)" }}
>
<div className="h-px w-10" style={{ background: "currentColor" }} />
<span className="text-xs"></span>
<div className="h-px w-10" style={{ background: "currentColor" }} />
</div>
<p
className="text-[11px] font-sans"
style={{ color: "rgba(120,90,60,0.5)" }}
>
© 2026 Moy Beach. Tüm hakları saklıdır.
</p>
</div>
</footer>
{/* ── Modal ────────────────────────────────────── */}
<AnimatePresence>
{selectedItem && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm"
onClick={() => setSelectedItem(null)}
>
<motion.div
initial={{ y: 50, opacity: 0, scale: 0.95 }}
animate={{ y: 0, opacity: 1, scale: 1 }}
exit={{ y: 20, opacity: 0, scale: 0.95 }}
className="bg-white rounded-2xl overflow-hidden w-full max-w-sm shadow-xl"
onClick={(e) => e.stopPropagation()}
>
{selectedItem.image && (
<div className="w-full h-56 relative">
<img src={selectedItem.image} alt={selectedItem.name} className="w-full h-full object-cover" />
<button
className="absolute top-3 right-3 bg-black/50 text-white w-8 h-8 rounded-full flex items-center justify-center backdrop-blur-md transition-colors hover:bg-black/70"
onClick={() => setSelectedItem(null)}
>
</button>
</div>
)}
<div className="p-5">
<div className="flex justify-between items-start gap-4 mb-2">
<h3 className="text-xl font-semibold font-sans" style={{ color: "var(--stone-ink)" }}>
{selectedItem.name}
</h3>
<span className="shrink-0 text-lg font-semibold font-sans tabular-nums" style={{ color: "var(--amber)" }}>
{selectedItem.price}
</span>
</div>
<p className="text-[14px] leading-relaxed font-sans" style={{ color: "var(--stone-muted)" }}>
{selectedItem.description}
</p>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
@@ -0,0 +1,86 @@
'use client'
import { useActionState } from 'react'
import { createCategory, updateCategory } from './actions'
import Link from 'next/link'
type Category = {
id: number
name_tr: string
order_num: number
status: number | null
}
export default function CategoryForm({ category }: { category?: Category }) {
const isEditing = !!category
// Use appropriate action based on editing state
const actionToUse = isEditing
? updateCategory.bind(null, category.id)
: createCategory
const [state, formAction, isPending] = useActionState(actionToUse, undefined)
return (
<form action={formAction} className="space-y-6">
{state?.error && (
<div className="bg-red-50 text-red-700 p-3 rounded-lg text-sm">
{state.error}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Kategori Adı (TR) *
</label>
<input
type="text"
name="name_tr"
defaultValue={category?.name_tr || ''}
required
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Sıra Numarası
</label>
<input
type="number"
name="order_num"
defaultValue={category?.order_num || 0}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
/>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
name="status"
id="status"
defaultChecked={category ? category.status === 1 : true}
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
<label htmlFor="status" className="text-sm font-medium text-gray-700">
Aktif
</label>
</div>
<div className="flex justify-end gap-3 pt-4 border-t">
<Link href="/admin/categories" className="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50 transition-colors">
İptal
</Link>
<button
type="submit"
disabled={isPending}
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-6 rounded-lg transition-colors disabled:opacity-70"
>
{isPending ? 'Kaydediliyor...' : 'Kaydet'}
</button>
</div>
</form>
)
}
@@ -0,0 +1,23 @@
'use client'
import { Trash2 } from 'lucide-react'
import { deleteCategory } from './actions'
export default function DeleteButton({ id }: { id: number }) {
return (
<button
onClick={async () => {
if (confirm('Bu kategoriyi silmek istediğinize emin misiniz?')) {
const res = await deleteCategory(id)
if (res?.error) {
alert(res.error)
}
}
}}
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title="Sil"
>
<Trash2 size={18} />
</button>
)
}
@@ -0,0 +1,34 @@
import prisma from '@/lib/prisma'
import CategoryForm from '../../CategoryForm'
import { notFound } from 'next/navigation'
export const metadata = {
title: 'Kategori Düzenle - Admin',
}
export default async function EditCategoryPage({ params }: { params: Promise<{ id: string }> }) {
const resolvedParams = await params
const id = parseInt(resolvedParams.id, 10)
if (isNaN(id)) {
notFound()
}
const category = await prisma.categories.findUnique({
where: { id }
})
if (!category) {
notFound()
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-gray-900">Kategori Düzenle</h1>
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 max-w-2xl">
<CategoryForm category={category} />
</div>
</div>
)
}
@@ -0,0 +1,90 @@
'use server'
import prisma from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
export async function createCategory(_prevState: unknown, formData: FormData) {
const name_tr = formData.get('name_tr') as string
const name = formData.get('name') as string || name_tr
const order_num = parseInt(formData.get('order_num') as string, 10) || 0
const status = formData.get('status') === 'on' ? 1 : 0
if (!name_tr) {
return { error: 'Lütfen kategori adını doldurun.' }
}
try {
await prisma.categories.create({
data: {
name,
name_tr,
slug: name_tr.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
order_num,
status,
}
})
} catch (error) {
console.error('Create category error:', error)
return { error: 'Kategori eklenirken hata oluştu.' }
}
revalidatePath('/admin/categories')
revalidatePath('/')
redirect('/admin/categories')
}
export async function updateCategory(id: number, _prevState: unknown, formData: FormData) {
const name_tr = formData.get('name_tr') as string
const name = formData.get('name') as string || name_tr
const order_num = parseInt(formData.get('order_num') as string, 10) || 0
const status = formData.get('status') === 'on' ? 1 : 0
if (!name_tr) {
return { error: 'Lütfen kategori adını doldurun.' }
}
try {
await prisma.categories.update({
where: { id },
data: {
name,
name_tr,
slug: name_tr.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
order_num,
status,
}
})
} catch (error) {
console.error('Update category error:', error)
return { error: 'Kategori güncellenirken hata oluştu.' }
}
revalidatePath('/admin/categories')
revalidatePath('/')
redirect('/admin/categories')
}
export async function deleteCategory(id: number) {
try {
// Check if category has products
const productsCount = await prisma.products.count({
where: { category_id: id }
})
if (productsCount > 0) {
return { error: 'Bu kategoriye ait ürünler olduğu için silinemez. Önce ürünleri silin veya başka kategoriye taşıyın.' }
}
await prisma.categories.delete({
where: { id }
})
revalidatePath('/admin/categories')
revalidatePath('/')
return { success: true }
} catch (error) {
console.error('Delete category error:', error)
return { error: 'Silme işlemi başarısız oldu.' }
}
}
@@ -0,0 +1,17 @@
import CategoryForm from '../CategoryForm'
export const metadata = {
title: 'Yeni Kategori Ekle - Admin',
}
export default function NewCategoryPage() {
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-gray-900">Yeni Kategori Ekle</h1>
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 max-w-2xl">
<CategoryForm />
</div>
</div>
)
}
+81
View File
@@ -0,0 +1,81 @@
import prisma from '@/lib/prisma'
import { Pencil, Plus } from 'lucide-react'
import Link from 'next/link'
import DeleteButton from './DeleteButton'
export const metadata = {
title: 'Kategoriler - Admin',
}
export default async function CategoriesPage() {
const categories = await prisma.categories.findMany({
orderBy: { order_num: 'asc' },
include: {
_count: {
select: { products: true }
}
}
})
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900">Kategoriler</h1>
<Link href="/admin/categories/new" className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-medium flex items-center gap-2 transition-colors">
<Plus size={18} /> Yeni Kategori Ekle
</Link>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-gray-50 border-b border-gray-100 text-sm font-semibold text-gray-600">
<th className="py-4 px-6">Kategori Adı</th>
<th className="py-4 px-6 text-center">Sıra</th>
<th className="py-4 px-6 text-center">Ürün Sayısı</th>
<th className="py-4 px-6 text-center">Durum</th>
<th className="py-4 px-6 text-right">İşlemler</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{categories.map((category) => (
<tr key={category.id} className="hover:bg-gray-50 transition-colors">
<td className="py-4 px-6 font-medium text-gray-900">{category.name_tr}</td>
<td className="py-4 px-6 text-center text-gray-600">{category.order_num}</td>
<td className="py-4 px-6 text-center">
<span className="bg-blue-50 text-blue-700 py-1 px-3 rounded-full text-xs font-medium">
{category._count.products} Ürün
</span>
</td>
<td className="py-4 px-6 text-center">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
category.status === 1 ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
}`}>
{category.status === 1 ? 'Aktif' : 'Pasif'}
</span>
</td>
<td className="py-4 px-6 text-right">
<div className="flex justify-end gap-2">
<Link href={`/admin/categories/${category.id}/edit`} className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors" title="Düzenle">
<Pencil size={18} />
</Link>
<DeleteButton id={category.id} />
</div>
</td>
</tr>
))}
{categories.length === 0 && (
<tr>
<td colSpan={5} className="py-8 text-center text-gray-500">
Henüz kategori bulunmuyor.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
)
}
+62
View File
@@ -0,0 +1,62 @@
import Link from 'next/link'
import { LayoutDashboard, LogOut, Package, Settings, Tags, Users2 } from 'lucide-react'
import { logout } from '@/lib/auth'
import { redirect } from 'next/navigation'
export default function AdminLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="flex h-screen bg-gray-100 overflow-hidden">
{/* Sidebar */}
<aside className="w-64 bg-white shadow-md flex flex-col hidden md:flex">
<div className="p-4 border-b">
<h2 className="text-xl font-bold text-gray-800">Admin Panel</h2>
</div>
<nav className="flex-1 p-4 space-y-2">
<Link href="/admin" className="flex items-center gap-3 px-3 py-2 text-gray-700 rounded-lg hover:bg-gray-100 transition-colors">
<LayoutDashboard size={20} />
<span>Dashboard</span>
</Link>
<Link href="/admin/categories" className="flex items-center gap-3 px-3 py-2 text-gray-700 rounded-lg hover:bg-gray-100 transition-colors">
<Tags size={20} />
<span>Kategoriler</span>
</Link>
<Link href="/admin/products" className="flex items-center gap-3 px-3 py-2 text-gray-700 rounded-lg hover:bg-gray-100 transition-colors">
<Package size={20} />
<span>Ürünler</span>
</Link>
<Link href="/admin/settings" className="flex items-center gap-3 px-3 py-2 text-gray-700 rounded-lg hover:bg-gray-100 transition-colors">
<Settings size={20} />
<span>Ayarlar</span>
</Link>
<Link href="/admin/users" className="flex items-center gap-3 px-3 py-2 text-gray-700 rounded-lg hover:bg-gray-100 transition-colors">
<Users2 size={20} />
<span>Kullanıcılar</span>
</Link>
</nav>
<div className="p-4 border-t">
<form action={async () => {
'use server'
await logout()
redirect('/admin/login')
}}>
<button className="flex items-center gap-3 w-full px-3 py-2 text-red-600 rounded-lg hover:bg-red-50 transition-colors">
<LogOut size={20} />
<span>Çıkış Yap</span>
</button>
</form>
</div>
</aside>
{/* Main Content */}
<main className="flex-1 overflow-y-auto bg-gray-50 p-6 md:p-8">
{children}
</main>
{/* Mobile Bottom Nav (Optional, simpler for now just a top bar) */}
</div>
)
}
+72
View File
@@ -0,0 +1,72 @@
import prisma from '@/lib/prisma'
import { Package, Tags, Eye, TrendingUp } from 'lucide-react'
import Link from 'next/link'
export const metadata = {
title: 'Admin Dashboard - Moy Beach',
}
export default async function AdminDashboard() {
const [totalCategories, totalProducts, activeProducts] = await Promise.all([
prisma.categories.count(),
prisma.products.count(),
prisma.products.count({ where: { status: 1 } }),
])
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 flex items-center gap-4">
<div className="p-3 bg-blue-100 text-blue-600 rounded-lg">
<Tags size={24} />
</div>
<div>
<p className="text-sm text-gray-500 font-medium">Kategori Sayısı</p>
<p className="text-2xl font-bold text-gray-900">{totalCategories}</p>
</div>
</div>
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 flex items-center gap-4">
<div className="p-3 bg-indigo-100 text-indigo-600 rounded-lg">
<Package size={24} />
</div>
<div>
<p className="text-sm text-gray-500 font-medium">Toplam Ürün</p>
<p className="text-2xl font-bold text-gray-900">{totalProducts}</p>
</div>
</div>
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 flex items-center gap-4">
<div className="p-3 bg-green-100 text-green-600 rounded-lg">
<TrendingUp size={24} />
</div>
<div>
<p className="text-sm text-gray-500 font-medium">Aktif Ürünler</p>
<p className="text-2xl font-bold text-gray-900">{activeProducts}</p>
</div>
</div>
</div>
{/* Quick Actions */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
<h2 className="text-lg font-semibold mb-4">Hızlı Kısayollar</h2>
<div className="flex flex-col gap-3">
<Link href="/admin/products" className="text-blue-600 hover:text-blue-800 font-medium flex items-center gap-2">
<Package size={18} /> Ürünleri Yönet
</Link>
<Link href="/admin/categories" className="text-blue-600 hover:text-blue-800 font-medium flex items-center gap-2">
<Tags size={18} /> Kategorileri Yönet
</Link>
<Link href="/" target="_blank" className="text-gray-600 hover:text-gray-800 font-medium flex items-center gap-2">
<Eye size={18} /> Canlı Menüyü Görüntüle
</Link>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,20 @@
'use client'
import { Trash2 } from 'lucide-react'
import { deleteProduct } from './actions'
export default function DeleteButton({ id }: { id: number }) {
return (
<button
onClick={async () => {
if (confirm('Bu ürünü silmek istediğinize emin misiniz?')) {
await deleteProduct(id)
}
}}
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title="Sil"
>
<Trash2 size={18} />
</button>
)
}
@@ -0,0 +1,210 @@
'use client'
import { useActionState, useState, useRef } from 'react'
import { createProduct, updateProduct } from './actions'
import Link from 'next/link'
import Image from 'next/image'
import { Upload, X, Loader2 } from 'lucide-react'
const DEFAULT_IMAGE = '/default-product.png'
type Category = {
id: number
name_tr: string
}
type Product = {
id: number
name_tr: string
price: number | string
category_id: number | null
description_tr: string | null
image_url: string | null
status: number | null
}
export default function ProductForm({ categories, product }: { categories: Category[], product?: Product }) {
const isEditing = !!product
const actionToUse = isEditing
? updateProduct.bind(null, product.id)
: createProduct
const [state, formAction, isPending] = useActionState(actionToUse, undefined)
const [imageUrl, setImageUrl] = useState<string>(product?.image_url || '')
const [uploading, setUploading] = useState(false)
const [uploadError, setUploadError] = useState('')
const fileInputRef = useRef<HTMLInputElement>(null)
const previewSrc = imageUrl || DEFAULT_IMAGE
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
if (file.size > 5 * 1024 * 1024) {
setUploadError("Dosya boyutu 5MB'ı geçemez.")
return
}
setUploadError('')
setUploading(true)
try {
const fd = new FormData()
fd.append('file', file)
const res = await fetch('/api/upload', { method: 'POST', body: fd })
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Yükleme başarısız')
setImageUrl(data.url)
} catch (err: any) {
setUploadError(err.message || 'Resim yüklenirken hata oluştu.')
} finally {
setUploading(false)
}
}
return (
<form action={formAction} className="space-y-6">
{state?.error && (
<div className="bg-red-50 text-red-700 p-3 rounded-lg text-sm">
{state.error}
</div>
)}
{/* Image Upload */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Ürün Resmi</label>
<div className="flex items-start gap-4">
{/* Preview */}
<div className="relative w-28 h-28 rounded-xl overflow-hidden border-2 border-gray-200 bg-gray-50 flex-shrink-0">
<Image
src={previewSrc}
alt="Ürün resmi önizleme"
fill
className="object-cover"
/>
{imageUrl && (
<button
type="button"
onClick={() => {
setImageUrl('')
if (fileInputRef.current) fileInputRef.current.value = ''
}}
className="absolute top-1 right-1 bg-red-500 text-white rounded-full p-0.5 hover:bg-red-600 transition-colors"
>
<X size={12} />
</button>
)}
</div>
{/* Upload button */}
<div className="flex flex-col gap-2 justify-center h-28">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="flex items-center gap-2 px-4 py-2 border-2 border-dashed border-gray-300 rounded-lg text-sm text-gray-600 hover:border-blue-400 hover:text-blue-600 transition-colors disabled:opacity-50"
>
{uploading
? <><Loader2 size={16} className="animate-spin" /> Yükleniyor...</>
: <><Upload size={16} /> Resim Yükle</>
}
</button>
<p className="text-xs text-gray-500">PNG, JPG, WEBP Maks. 5MB</p>
{uploadError && <p className="text-xs text-red-600">{uploadError}</p>}
{!imageUrl && (
<p className="text-xs text-gray-400 italic">Resim yüklenmezse varsayılan görsel kullanılır.</p>
)}
</div>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileChange}
/>
{/* URL'yi form verisiyle gönder */}
<input type="hidden" name="image_url" value={imageUrl} />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Ürün Adı (TR) *</label>
<input
type="text"
name="name_tr"
defaultValue={product?.name_tr || ''}
required
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Kategori *</label>
<select
name="category_id"
defaultValue={product?.category_id?.toString() || ''}
required
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
>
<option value="" disabled>Seçiniz</option>
{categories.map(c => (
<option key={c.id} value={c.id}>{c.name_tr}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Fiyat () *</label>
<input
type="number"
step="0.01"
name="price"
defaultValue={product ? product.price.toString() : ''}
required
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Açıklama (TR)</label>
<textarea
name="description_tr"
defaultValue={product?.description_tr || ''}
rows={3}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
name="status"
id="status"
defaultChecked={product ? product.status === 1 : true}
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
<label htmlFor="status" className="text-sm font-medium text-gray-700">Aktif</label>
</div>
<div className="flex justify-end gap-3 pt-4 border-t">
<Link
href="/admin/products"
className="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50 transition-colors"
>
İptal
</Link>
<button
type="submit"
disabled={isPending || uploading}
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-6 rounded-lg transition-colors disabled:opacity-70"
>
{isPending ? 'Kaydediliyor...' : 'Kaydet'}
</button>
</div>
</form>
)
}
@@ -0,0 +1,46 @@
import prisma from '@/lib/prisma'
import ProductForm from '../../ProductForm'
import { notFound } from 'next/navigation'
export const metadata = {
title: 'Ürün Düzenle - Admin',
}
export default async function EditProductPage({ params }: { params: Promise<{ id: string }> }) {
const resolvedParams = await params
const id = parseInt(resolvedParams.id, 10)
if (isNaN(id)) {
notFound()
}
const [categories, product] = await Promise.all([
prisma.categories.findMany({
orderBy: { order_num: 'asc' },
select: { id: true, name_tr: true }
}),
prisma.products.findUnique({
where: { id }
})
])
if (!product) {
notFound()
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-gray-900">Ürün Düzenle</h1>
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 max-w-3xl">
<ProductForm
categories={categories}
product={{
...product,
price: product.price.toString() // Convert Decimal to string
}}
/>
</div>
</div>
)
}
+92
View File
@@ -0,0 +1,92 @@
'use server'
import prisma from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
export async function createProduct(_prevState: unknown, formData: FormData) {
const name_tr = formData.get('name_tr') as string
const name = formData.get('name') as string || name_tr
const price = parseFloat(formData.get('price') as string)
const category_id = parseInt(formData.get('category_id') as string, 10)
const description_tr = formData.get('description_tr') as string || ''
const image_url = formData.get('image_url') as string || null
const status = formData.get('status') === 'on' ? 1 : 0
if (!name_tr || isNaN(price) || isNaN(category_id)) {
return { error: 'Lütfen zorunlu alanları doldurun.' }
}
try {
await prisma.products.create({
data: {
name,
name_tr,
slug: name_tr.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
price,
category_id,
description_tr,
image_url,
status,
}
})
} catch (error) {
console.error('Create product error:', error)
return { error: 'Ürün eklenirken hata oluştu.' }
}
revalidatePath('/admin/products')
revalidatePath('/')
redirect('/admin/products')
}
export async function updateProduct(id: number, _prevState: unknown, formData: FormData) {
const name_tr = formData.get('name_tr') as string
const name = formData.get('name') as string || name_tr
const price = parseFloat(formData.get('price') as string)
const category_id = parseInt(formData.get('category_id') as string, 10)
const description_tr = formData.get('description_tr') as string || ''
const image_url = formData.get('image_url') as string || null
const status = formData.get('status') === 'on' ? 1 : 0
if (!name_tr || isNaN(price) || isNaN(category_id)) {
return { error: 'Lütfen zorunlu alanları doldurun.' }
}
try {
await prisma.products.update({
where: { id },
data: {
name,
name_tr,
slug: name_tr.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
price,
category_id,
description_tr,
image_url,
status,
}
})
} catch (error) {
console.error('Update product error:', error)
return { error: 'Ürün güncellenirken hata oluştu.' }
}
revalidatePath('/admin/products')
revalidatePath('/')
redirect('/admin/products')
}
export async function deleteProduct(id: number) {
try {
await prisma.products.delete({
where: { id }
})
revalidatePath('/admin/products')
revalidatePath('/')
return { success: true }
} catch (error) {
console.error('Delete product error:', error)
return { error: 'Silme işlemi başarısız oldu.' }
}
}
@@ -0,0 +1,23 @@
import prisma from '@/lib/prisma'
import ProductForm from '../ProductForm'
export const metadata = {
title: 'Yeni Ürün Ekle - Admin',
}
export default async function NewProductPage() {
const categories = await prisma.categories.findMany({
orderBy: { order_num: 'asc' },
select: { id: true, name_tr: true }
})
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-gray-900">Yeni Ürün Ekle</h1>
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 max-w-3xl">
<ProductForm categories={categories} />
</div>
</div>
)
}
+100
View File
@@ -0,0 +1,100 @@
import prisma from '@/lib/prisma'
import { Pencil, Plus, Trash2, Image as ImageIcon } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import DeleteButton from './DeleteButton'
export const metadata = {
title: 'Ürün Yönetimi - Admin',
}
export default async function ProductsPage() {
const categories = await prisma.categories.findMany({
orderBy: { order_num: 'asc' },
include: {
products: {
orderBy: { id: 'asc' },
},
},
})
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900">Ürün Yönetimi</h1>
<Link href="/admin/products/new" className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 transition-colors">
<Plus size={20} />
<span>Yeni Ürün Ekle</span>
</Link>
</div>
<div className="space-y-8">
{categories.map((category) => (
<div key={category.id} className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div className="bg-gray-50 px-6 py-4 border-b border-gray-100 flex justify-between items-center">
<h2 className="text-lg font-bold text-gray-800">{category.name_tr}</h2>
<span className="text-sm text-gray-500 font-medium">{category.products.length} ürün</span>
</div>
{category.products.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-gray-100">
<th className="py-3 px-6 text-sm font-semibold text-gray-600 w-16">Resim</th>
<th className="py-3 px-6 text-sm font-semibold text-gray-600">İsim</th>
<th className="py-3 px-6 text-sm font-semibold text-gray-600">Fiyat</th>
<th className="py-3 px-6 text-sm font-semibold text-gray-600">Durum</th>
<th className="py-3 px-6 text-sm font-semibold text-gray-600 text-right">İşlemler</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{category.products.map((product) => (
<tr key={product.id} className="hover:bg-gray-50 transition-colors">
<td className="py-3 px-6">
{product.image_url ? (
<div className="relative w-12 h-12 rounded-lg overflow-hidden border border-gray-200">
<Image
src={product.image_url}
alt={product.name_tr}
fill
className="object-cover"
/>
</div>
) : (
<div className="w-12 h-12 rounded-lg bg-gray-100 flex items-center justify-center text-gray-400 border border-gray-200">
<ImageIcon size={20} />
</div>
)}
</td>
<td className="py-3 px-6 font-medium text-gray-900">{product.name_tr}</td>
<td className="py-3 px-6 text-gray-600">{product.price.toString()}</td>
<td className="py-3 px-6">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${product.status === 1 ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
{product.status === 1 ? 'Aktif' : 'Pasif'}
</span>
</td>
<td className="py-3 px-6 text-right">
<div className="flex justify-end gap-2">
<Link href={`/admin/products/${product.id}/edit`} className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors" title="Düzenle">
<Pencil size={18} />
</Link>
<DeleteButton id={product.id} />
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="py-6 px-6 text-gray-500 text-sm">
Bu kategoride henüz ürün yok.
</div>
)}
</div>
))}
</div>
</div>
)
}
@@ -0,0 +1,158 @@
'use client'
import { useActionState, useState, useRef } from 'react'
import { saveSettings } from './actions'
import Image from 'next/image'
import { Upload, X, Loader2 } from 'lucide-react'
export default function SettingsForm({
defaultRestaurantName,
defaultLocation,
defaultLogoUrl,
}: {
defaultRestaurantName: string
defaultLocation: string
defaultLogoUrl: string
}) {
const [state, action, isPending] = useActionState(saveSettings, undefined)
const [logoUrl, setLogoUrl] = useState(defaultLogoUrl)
const [uploading, setUploading] = useState(false)
const [uploadError, setUploadError] = useState('')
const fileInputRef = useRef<HTMLInputElement>(null)
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
if (file.size > 5 * 1024 * 1024) {
setUploadError("Dosya boyutu 5MB'ı geçemez.")
return
}
setUploadError('')
setUploading(true)
try {
const fd = new FormData()
fd.append('file', file)
const res = await fetch('/api/upload', { method: 'POST', body: fd })
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Yükleme başarısız')
setLogoUrl(data.url)
} catch (err: any) {
setUploadError(err.message || 'Logo yüklenirken hata oluştu.')
} finally {
setUploading(false)
}
}
return (
<form action={action} className="space-y-6">
{state?.success && (
<div className="bg-green-50 text-green-700 p-3 rounded-lg text-sm">
{state.message}
</div>
)}
{state?.error && (
<div className="bg-red-50 text-red-700 p-3 rounded-lg text-sm">
{state.error}
</div>
)}
{/* Logo Upload */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Site Logosu
</label>
<div className="flex items-start gap-4">
{/* Preview */}
<div className="relative w-40 h-20 rounded-xl overflow-hidden border-2 border-gray-200 bg-stone-900 flex items-center justify-center flex-shrink-0">
{logoUrl ? (
<>
<Image
src={logoUrl}
alt="Logo önizleme"
fill
className="object-contain p-2"
/>
<button
type="button"
onClick={() => { setLogoUrl(''); if (fileInputRef.current) fileInputRef.current.value = '' }}
className="absolute top-1 right-1 bg-red-500 text-white rounded-full p-0.5 hover:bg-red-600 transition-colors"
>
<X size={12} />
</button>
</>
) : (
<span className="text-xs text-gray-400 text-center px-2">Logo yok<br />(varsayılan kullanılır)</span>
)}
</div>
{/* Upload button */}
<div className="flex flex-col gap-2 justify-center h-20">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="flex items-center gap-2 px-4 py-2 border-2 border-dashed border-gray-300 rounded-lg text-sm text-gray-600 hover:border-blue-400 hover:text-blue-600 transition-colors disabled:opacity-50"
>
{uploading
? <><Loader2 size={16} className="animate-spin" /> Yükleniyor...</>
: <><Upload size={16} /> Logo Yükle</>
}
</button>
<p className="text-xs text-gray-500">PNG, SVG, WEBP Maks. 5MB</p>
{uploadError && <p className="text-xs text-red-600">{uploadError}</p>}
</div>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileChange}
/>
<input type="hidden" name="logo_url" value={logoUrl} />
</div>
<hr className="border-gray-100" />
{/* Restaurant Name */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Restoran / Mekan Adı
</label>
<input
type="text"
name="restaurant_name"
defaultValue={defaultRestaurantName}
required
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
/>
</div>
{/* Location */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Konum Bilgisi
</label>
<input
type="text"
name="location"
defaultValue={defaultLocation}
required
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
/>
</div>
<button
type="submit"
disabled={isPending || uploading}
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-6 rounded-lg transition-colors disabled:opacity-70"
>
{isPending ? 'Kaydediliyor...' : 'Kaydet'}
</button>
</form>
)
}
+38
View File
@@ -0,0 +1,38 @@
'use server'
import prisma from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
export async function saveSettings(_prevState: unknown, formData: FormData) {
const restaurant_name = formData.get('restaurant_name') as string
const location = formData.get('location') as string
const logo_url = formData.get('logo_url') as string
try {
await prisma.$transaction([
prisma.settings.upsert({
where: { key: 'restaurant_name' },
update: { value: restaurant_name },
create: { key: 'restaurant_name', value: restaurant_name },
}),
prisma.settings.upsert({
where: { key: 'location' },
update: { value: location },
create: { key: 'location', value: location },
}),
prisma.settings.upsert({
where: { key: 'logo_url' },
update: { value: logo_url || '' },
create: { key: 'logo_url', value: logo_url || '' },
}),
])
revalidatePath('/')
revalidatePath('/admin/settings')
return { success: true, message: 'Ayarlar başarıyla kaydedildi.' }
} catch (error) {
console.error('Settings save error:', error)
return { error: 'Ayarlar kaydedilirken bir hata oluştu.' }
}
}
+32
View File
@@ -0,0 +1,32 @@
import prisma from '@/lib/prisma'
import SettingsForm from './SettingsForm'
export const metadata = {
title: 'Genel Ayarlar - Admin',
}
export default async function SettingsPage() {
const settingsData = await prisma.settings.findMany()
const settings = settingsData.reduce((acc, curr) => {
acc[curr.key] = curr.value
return acc
}, {} as Record<string, string>)
const restaurantName = settings['restaurant_name'] || 'Moy Beach'
const location = settings['location'] || 'Akyaka · Muğla'
const logoUrl = settings['logo_url'] || ''
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-gray-900">Genel Ayarlar</h1>
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 max-w-2xl">
<SettingsForm
defaultRestaurantName={restaurantName}
defaultLocation={location}
defaultLogoUrl={logoUrl}
/>
</div>
</div>
)
}
@@ -0,0 +1,74 @@
'use client'
import { useActionState } from 'react'
import { createUser } from './actions'
import { UserPlus } from 'lucide-react'
export default function CreateUserForm() {
const [state, formAction, isPending] = useActionState(createUser, undefined)
return (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
<div className="flex items-center gap-2 mb-4">
<UserPlus size={20} className="text-blue-600" />
<h2 className="text-lg font-semibold text-gray-800">Yeni Kullanıcı Ekle</h2>
</div>
{state?.success && (
<div className="bg-green-50 text-green-700 p-3 rounded-lg text-sm mb-4">
Kullanıcı başarıyla oluşturuldu.
</div>
)}
{state?.error && (
<div className="bg-red-50 text-red-700 p-3 rounded-lg text-sm mb-4">
{state.error}
</div>
)}
<form action={formAction} className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Kullanıcı Adı *</label>
<input
type="text"
name="username"
required
autoComplete="off"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Şifre * <span className="text-gray-400 font-normal">(min. 6 karakter)</span></label>
<input
type="password"
name="password"
required
autoComplete="new-password"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Rol</label>
<select
name="role"
defaultValue="admin"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm"
>
<option value="admin">Admin</option>
<option value="editor">Editor</option>
</select>
</div>
<div className="sm:col-span-3 flex justify-end">
<button
type="submit"
disabled={isPending}
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-5 rounded-lg text-sm transition-colors disabled:opacity-70 flex items-center gap-2"
>
<UserPlus size={16} />
{isPending ? 'Oluşturuluyor...' : 'Kullanıcı Oluştur'}
</button>
</div>
</form>
</div>
)
}
+135
View File
@@ -0,0 +1,135 @@
'use client'
import { useActionState, useState } from 'react'
import { changePassword, deleteUser } from './actions'
import { KeyRound, Trash2, ChevronDown, ChevronUp, Shield } from 'lucide-react'
type User = {
id: number
username: string
role: string
created_at: Date
}
function ChangePasswordForm({ userId }: { userId: number }) {
const action = changePassword.bind(null, userId)
const [state, formAction, isPending] = useActionState(action, undefined)
return (
<form action={formAction} className="mt-3 p-4 bg-gray-50 rounded-lg border border-gray-200 space-y-3">
{state?.success && (
<p className="text-xs text-green-700 bg-green-50 px-3 py-2 rounded-lg">{state.message}</p>
)}
{state?.error && (
<p className="text-xs text-red-700 bg-red-50 px-3 py-2 rounded-lg">{state.error}</p>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Yeni Şifre</label>
<input
type="password"
name="new_password"
required
autoComplete="new-password"
placeholder="Min. 6 karakter"
className="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Şifre Tekrar</label>
<input
type="password"
name="confirm_password"
required
autoComplete="new-password"
placeholder="Tekrar giriniz"
className="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none"
/>
</div>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={isPending}
className="bg-amber-500 hover:bg-amber-600 text-white text-sm font-medium py-1.5 px-4 rounded-lg transition-colors disabled:opacity-70 flex items-center gap-1.5"
>
<KeyRound size={14} />
{isPending ? 'Güncelleniyor...' : 'Şifreyi Güncelle'}
</button>
</div>
</form>
)
}
function UserRow({ user }: { user: User }) {
const [expanded, setExpanded] = useState(false)
async function handleDelete() {
if (!confirm(`"${user.username}" kullanıcısını silmek istediğinize emin misiniz?`)) return
const res = await deleteUser(user.id)
if (res?.error) alert(res.error)
}
return (
<div className="border border-gray-100 rounded-xl overflow-hidden">
<div className="flex items-center justify-between px-5 py-4 hover:bg-gray-50 transition-colors">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-blue-100 flex items-center justify-center text-blue-700 font-semibold text-sm uppercase">
{user.username[0]}
</div>
<div>
<p className="font-medium text-gray-900 text-sm">{user.username}</p>
<p className="text-xs text-gray-400">
{new Date(user.created_at).toLocaleDateString('tr-TR', { day: 'numeric', month: 'long', year: 'numeric' })}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium ${
user.role === 'admin' ? 'bg-purple-100 text-purple-700' : 'bg-gray-100 text-gray-600'
}`}>
<Shield size={10} />
{user.role}
</span>
<button
onClick={() => setExpanded(v => !v)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-amber-600 bg-amber-50 hover:bg-amber-100 rounded-lg transition-colors font-medium"
>
<KeyRound size={13} />
Şifre
{expanded ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
</button>
<button
onClick={handleDelete}
className="p-2 text-red-500 hover:bg-red-50 rounded-lg transition-colors"
title="Kullanıcıyı Sil"
>
<Trash2 size={16} />
</button>
</div>
</div>
{expanded && <ChangePasswordForm userId={user.id} />}
</div>
)
}
export default function UserList({ users }: { users: User[] }) {
return (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4">Mevcut Kullanıcılar ({users.length})</h2>
{users.length === 0 ? (
<p className="text-sm text-gray-500 text-center py-6">Henüz kullanıcı yok.</p>
) : (
<div className="space-y-3">
{users.map(user => (
<UserRow key={user.id} user={user} />
))}
</div>
)}
</div>
)
}
+71
View File
@@ -0,0 +1,71 @@
'use server'
import prisma from '@/lib/prisma'
import bcrypt from 'bcryptjs'
import { revalidatePath } from 'next/cache'
export async function createUser(_prevState: unknown, formData: FormData) {
const username = (formData.get('username') as string)?.trim()
const password = formData.get('password') as string
const role = (formData.get('role') as string) || 'admin'
if (!username || !password) {
return { error: 'Kullanıcı adı ve şifre zorunludur.' }
}
if (password.length < 6) {
return { error: 'Şifre en az 6 karakter olmalıdır.' }
}
try {
const existing = await prisma.users.findUnique({ where: { username } })
if (existing) {
return { error: 'Bu kullanıcı adı zaten kullanılıyor.' }
}
const password_hash = await bcrypt.hash(password, 12)
await prisma.users.create({ data: { username, password_hash, role } })
revalidatePath('/admin/users')
return { success: true }
} catch (error) {
console.error('Create user error:', error)
return { error: 'Kullanıcı oluşturulurken hata oluştu.' }
}
}
export async function changePassword(userId: number, _prevState: unknown, formData: FormData) {
const newPassword = formData.get('new_password') as string
const confirmPassword = formData.get('confirm_password') as string
if (!newPassword || newPassword.length < 6) {
return { error: 'Şifre en az 6 karakter olmalıdır.' }
}
if (newPassword !== confirmPassword) {
return { error: 'Şifreler eşleşmiyor.' }
}
try {
const password_hash = await bcrypt.hash(newPassword, 12)
await prisma.users.update({ where: { id: userId }, data: { password_hash } })
revalidatePath('/admin/users')
return { success: true, message: 'Şifre güncellendi.' }
} catch (error) {
console.error('Change password error:', error)
return { error: 'Şifre güncellenirken hata oluştu.' }
}
}
export async function deleteUser(userId: number) {
try {
// Count total users — don't allow deleting the last admin
const count = await prisma.users.count()
if (count <= 1) {
return { error: 'Son yönetici kullanıcı silinemez.' }
}
await prisma.users.delete({ where: { id: userId } })
revalidatePath('/admin/users')
return { success: true }
} catch (error) {
console.error('Delete user error:', error)
return { error: 'Kullanıcı silinirken hata oluştu.' }
}
}
+27
View File
@@ -0,0 +1,27 @@
import prisma from '@/lib/prisma'
import CreateUserForm from './CreateUserForm'
import UserList from './UserList'
export const metadata = {
title: 'Kullanıcı Yönetimi - Admin',
}
export default async function UsersPage() {
const users = await prisma.users.findMany({
orderBy: { created_at: 'asc' },
select: {
id: true,
username: true,
role: true,
created_at: true,
}
})
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-gray-900">Kullanıcı Yönetimi</h1>
<CreateUserForm />
<UserList users={users} />
</div>
)
}
+52
View File
@@ -0,0 +1,52 @@
'use client'
import { useActionState } from 'react'
import { authenticate } from './actions'
export default function LoginForm() {
const [state, action, isPending] = useActionState(authenticate, undefined)
return (
<form action={action} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Kullanıcı Adı
</label>
<input
name="username"
type="text"
required
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
placeholder="admin"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Şifre
</label>
<input
name="password"
type="password"
required
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
placeholder="••••••••"
/>
</div>
{state?.error && (
<div className="text-red-500 text-sm bg-red-50 p-3 rounded-lg">
{state.error}
</div>
)}
<button
type="submit"
disabled={isPending}
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors disabled:opacity-70"
>
{isPending ? 'Giriş Yapılıyor...' : 'Giriş Yap'}
</button>
</form>
)
}
+40
View File
@@ -0,0 +1,40 @@
'use server'
import { login } from '@/lib/auth'
import prisma from '@/lib/prisma'
import bcrypt from 'bcryptjs'
import { redirect } from 'next/navigation'
export async function authenticate(prevState: any, formData: FormData) {
const username = formData.get('username') as string
const password = formData.get('password') as string
if (!username || !password) {
return { error: 'Lütfen tüm alanları doldurun.' }
}
try {
// Check user in database
const user = await prisma.users.findUnique({
where: { username }
})
if (!user) {
return { error: 'Kullanıcı adı veya şifre hatalı.' }
}
const passwordsMatch = await bcrypt.compare(password, user.password_hash)
if (!passwordsMatch) {
return { error: 'Kullanıcı adı veya şifre hatalı.' }
}
await login(username)
} catch (error) {
console.error('Login error:', error)
return { error: 'Bir hata oluştu, lütfen tekrar deneyin.' }
}
// Redirect to admin dashboard after successful login
redirect('/admin')
}
+26
View File
@@ -0,0 +1,26 @@
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import LoginForm from './LoginForm'
export const metadata = {
title: 'Admin Login - Moy Beach',
}
export default async function LoginPage() {
const session = await getSession()
if (session) {
redirect('/admin')
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full p-8 bg-white rounded-xl shadow-lg">
<div className="text-center mb-8">
<h1 className="text-2xl font-bold text-gray-900">Admin Girişi</h1>
<p className="text-gray-500 mt-2">Yönetim paneline erişmek için giriş yapın</p>
</div>
<LoginForm />
</div>
</div>
)
}
+32
View File
@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server'
import cloudinary from '@/lib/cloudinary'
export async function POST(request: NextRequest) {
try {
const formData = await request.formData()
const file = formData.get('file') as File
if (!file) {
return NextResponse.json({ error: 'Dosya bulunamadı.' }, { status: 400 })
}
// Convert file to buffer
const arrayBuffer = await file.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
const base64 = `data:${file.type};base64,${buffer.toString('base64')}`
// Upload to Cloudinary
const result = await cloudinary.uploader.upload(base64, {
folder: 'moy-qr/products',
transformation: [
{ width: 800, height: 800, crop: 'limit' },
{ quality: 'auto', fetch_format: 'auto' }
]
})
return NextResponse.json({ url: result.secure_url })
} catch (error) {
console.error('Upload error:', error)
return NextResponse.json({ error: 'Yükleme başarısız oldu.' }, { status: 500 })
}
}
+48 -266
View File
@@ -1,271 +1,53 @@
"use client";
import prisma from "@/lib/prisma";
import MenuClient from "./MenuClient";
import { MenuCategory } from "@/data/menu";
import { useEffect, useState, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { menuData, MenuItem as MenuItemType } from "@/data/menu";
import { CategoryNav } from "@/components/CategoryNav";
import { MenuItem } from "@/components/MenuItem";
async function getMenuData(): Promise<MenuCategory[]> {
const dbCategories = await prisma.categories.findMany({
where: { status: 1 },
orderBy: { order_num: "asc" },
});
export const CATEGORY_ICONS: Record<string, string> = {
"kahvaltiliklar": "🌅",
"kaseler": "🥗",
"burger-ve-sandvic": "🍔",
"pizzalar": "🍕",
"makarnalar": "🍝",
"baslangiclar": "🧆",
"salatalar": "🥬",
"ana-yemekler": "🍽️",
"tatlilar": "🍮",
"i̇mza-kokteyller": "🍹",
"klasik-kokteyller": "🍸",
"biralar": "🍺",
"shotlar": "🥃",
"sise-ve-kadeh-alkollu": "🥂",
"saraplar": "🍷",
"cerezler": "🥜",
"alkolsuz-i̇cecekler": "🧃",
"sicak-kahveler": "☕",
"soguk-kahveler": "🧊",
"ekstralar": "✨",
};
const dbProducts = await prisma.products.findMany({
where: { status: 1 },
orderBy: [{ category_id: "asc" }, { id: "asc" }],
});
export default function MenuPage() {
const [activeCategoryId, setActiveCategoryId] = useState<string>(
menuData[0]?.id || ""
);
const [selectedItem, setSelectedItem] = useState<MenuItemType | null>(null);
const sectionRefs = useRef<(HTMLElement | null)[]>([]);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
const hit = entries.find((e) => e.isIntersecting);
if (hit) setActiveCategoryId(hit.target.id);
},
{ root: null, rootMargin: "-100px 0px -62% 0px", threshold: 0 }
);
sectionRefs.current.forEach((ref) => ref && observer.observe(ref));
return () => observer.disconnect();
}, []);
useEffect(() => {
if (selectedItem) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "unset";
}
return () => {
document.body.style.overflow = "unset";
return dbCategories.map((cat) => {
const categoryProducts = dbProducts.filter((p) => p.category_id === cat.id);
return {
id: cat.slug,
title: cat.name_tr,
items: categoryProducts.map((p) => ({
id: p.id.toString(),
name: p.name_tr,
description: p.description_tr,
price: p.price.toString() + " ₺",
image: p.image_url || '/default-product.png',
})),
};
}, [selectedItem]);
return (
<div className="min-h-screen" style={{ background: "var(--cream)" }}>
{/* ── Hero ─────────────────────────────────────── */}
<header className="relative overflow-hidden bg-stone-900">
{/* Background Image */}
<div
className="absolute inset-0 z-0 opacity-80"
style={{
backgroundImage: "url('/header-bg.jpg')",
backgroundSize: "cover",
backgroundPosition: "center"
}}
/>
{/* Dark overlay to make text readable */}
<div className="absolute inset-0 z-0 bg-gradient-to-b from-black/60 via-black/40 to-black/80" />
{/* hero content */}
<div className="relative z-10 flex flex-col items-center text-center px-6 pt-16 pb-20">
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.9, ease: [0.16, 1, 0.3, 1] }}
className="flex flex-col items-center"
>
<p
className="text-[10px] tracking-[0.38em] uppercase mb-8 font-sans font-medium"
style={{ color: "rgba(255,220,160,1)" }}
>
Akyaka · Muğla
</p>
<h1
className="font-display text-[82px] font-light leading-none tracking-tight"
style={{ color: "#FFFAF2" }}
>
Moy
</h1>
<h2
className="font-display text-[28px] font-light tracking-[0.22em] uppercase"
style={{ color: "rgba(255,200,120,0.85)", marginTop: "-2px" }}
>
Beach
</h2>
<div
className="flex items-center gap-3 my-5"
style={{ color: "rgba(255,190,100,0.35)" }}
>
<div className="h-px w-14" style={{ background: "currentColor" }} />
<span className="text-base"></span>
<div className="h-px w-14" style={{ background: "currentColor" }} />
</div>
<p
className="text-xs font-sans tracking-wide"
style={{ color: "rgba(255,218,168,0.5)" }}
>
Akyaka&apos;nın en keyifli menüsü
</p>
</motion.div>
</div>
{/* wave transition */}
<div className="absolute bottom-0 left-0 right-0 translate-y-px">
<svg
viewBox="0 0 1440 52"
preserveAspectRatio="none"
className="w-full h-[52px]"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0 52L80 44C160 36 320 20 480 18C640 16 800 28 960 32C1120 36 1280 32 1360 30L1440 28V52H1360C1280 52 1120 52 960 52C800 52 640 52 480 52C320 52 160 52 80 52H0Z"
fill="#FAF8F3"
/>
</svg>
</div>
</header>
{/* ── Category Nav ─────────────────────────────── */}
<CategoryNav
categories={menuData}
activeCategoryId={activeCategoryId}
icons={CATEGORY_ICONS}
/>
{/* ── Menu Sections ────────────────────────────── */}
<main className="pb-28">
{menuData.map((category, index) => (
<motion.section
key={category.id}
id={category.id}
ref={(el) => { sectionRefs.current[index] = el; }}
className="pt-10 px-4 max-w-lg mx-auto scroll-mt-[62px]"
initial={{ opacity: 0, y: 14 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
>
{/* section header */}
<div
className="flex items-center gap-2.5 mb-4 pb-3 border-b"
style={{ borderColor: "var(--sand-border)" }}
>
<span className="text-xl leading-none select-none">
{CATEGORY_ICONS[category.id] ?? "🍽️"}
</span>
<h2
className="font-display text-[22px] leading-tight font-semibold italic"
style={{ color: "var(--stone-ink)" }}
>
{category.title}
</h2>
</div>
<div>
{category.items.map((item, idx) => (
<MenuItem key={idx} item={item} onClick={() => setSelectedItem(item)} />
))}
</div>
</motion.section>
))}
</main>
{/* ── Footer ───────────────────────────────────── */}
<footer
className="py-12 text-center"
style={{ background: "var(--hero-from)" }}
>
<div className="flex flex-col items-center gap-1">
<span
className="font-display text-3xl font-light italic"
style={{ color: "rgba(255,220,160,0.85)" }}
>
Moy Beach
</span>
<p
className="text-[10px] font-sans tracking-[0.3em] uppercase mt-1"
style={{ color: "rgba(255,200,130,0.35)" }}
>
Akyaka, Muğla
</p>
<div
className="flex items-center gap-3 my-4"
style={{ color: "rgba(160,100,50,0.4)" }}
>
<div className="h-px w-10" style={{ background: "currentColor" }} />
<span className="text-xs"></span>
<div className="h-px w-10" style={{ background: "currentColor" }} />
</div>
<p
className="text-[11px] font-sans"
style={{ color: "rgba(120,90,60,0.5)" }}
>
© 2026 Moy Beach. Tüm hakları saklıdır.
</p>
</div>
</footer>
{/* ── Modal ────────────────────────────────────── */}
<AnimatePresence>
{selectedItem && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm"
onClick={() => setSelectedItem(null)}
>
<motion.div
initial={{ y: 50, opacity: 0, scale: 0.95 }}
animate={{ y: 0, opacity: 1, scale: 1 }}
exit={{ y: 20, opacity: 0, scale: 0.95 }}
className="bg-white rounded-2xl overflow-hidden w-full max-w-sm shadow-xl"
onClick={(e) => e.stopPropagation()}
>
{selectedItem.image && (
<div className="w-full h-56 relative">
<img src={selectedItem.image} alt={selectedItem.name} className="w-full h-full object-cover" />
<button
className="absolute top-3 right-3 bg-black/50 text-white w-8 h-8 rounded-full flex items-center justify-center backdrop-blur-md transition-colors hover:bg-black/70"
onClick={() => setSelectedItem(null)}
>
</button>
</div>
)}
<div className="p-5">
<div className="flex justify-between items-start gap-4 mb-2">
<h3 className="text-xl font-semibold font-sans" style={{ color: "var(--stone-ink)" }}>
{selectedItem.name}
</h3>
<span className="shrink-0 text-lg font-semibold font-sans tabular-nums" style={{ color: "var(--amber)" }}>
{selectedItem.price}
</span>
</div>
<p className="text-[14px] leading-relaxed font-sans" style={{ color: "var(--stone-muted)" }}>
{selectedItem.description}
</p>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
);
});
}
async function getSiteSettings() {
const settingsData = await prisma.settings.findMany()
const settings = settingsData.reduce((acc, curr) => {
acc[curr.key] = curr.value
return acc
}, {} as Record<string, string>)
return {
logoUrl: settings['logo_url'] || '/logo.png',
location: settings['location'] || 'Akyaka · Muğla',
restaurantName: settings['restaurant_name'] || 'Moy Beach',
}
}
export default async function Page() {
const [menuData, siteSettings] = await Promise.all([
getMenuData(),
getSiteSettings(),
]);
return <MenuClient initialCategories={menuData} siteSettings={siteSettings} />;
}