kite-qr
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
"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": "✨",
|
||||
};
|
||||
|
||||
type SiteSettings = {
|
||||
logoUrl: string
|
||||
location: string
|
||||
restaurantName: string
|
||||
}
|
||||
|
||||
export default function MenuClient({ initialCategories, siteSettings }: { initialCategories: MenuCategory[], siteSettings: SiteSettings }) {
|
||||
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'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 | null
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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.' }
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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.' }
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,70 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ── Design Tokens ─────────────────────────────── */
|
||||
:root {
|
||||
--cream: #FAF8F3;
|
||||
--sand: #F0E8DC;
|
||||
--sand-border: rgba(140, 108, 72, 0.13);
|
||||
--amber: #C2712E;
|
||||
--amber-light: #E49248;
|
||||
--amber-pale: #F6EDDE;
|
||||
--stone-ink: #2E2820;
|
||||
--stone-mid: #6B5E54;
|
||||
--stone-muted: #9C9088;
|
||||
--hero-from: #1E0E06;
|
||||
--hero-via: #7C3312;
|
||||
--hero-to: #C96B28;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-cream: var(--cream);
|
||||
--color-sand: var(--sand);
|
||||
--color-amber: var(--amber);
|
||||
--color-amber-light: var(--amber-light);
|
||||
--color-amber-pale: var(--amber-pale);
|
||||
--color-stone-ink: var(--stone-ink);
|
||||
--color-stone-mid: var(--stone-mid);
|
||||
--color-stone-muted: var(--stone-muted);
|
||||
|
||||
--font-display: var(--font-cormorant);
|
||||
--font-sans: var(--font-jakarta);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--cream);
|
||||
color: var(--stone-ink);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.hide-scrollbar::-webkit-scrollbar { display: none; }
|
||||
.hide-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.font-display {
|
||||
font-family: var(--font-cormorant), "Garamond", serif;
|
||||
}
|
||||
|
||||
.hero-gradient {
|
||||
background: linear-gradient(
|
||||
170deg,
|
||||
var(--hero-from) 0%,
|
||||
var(--hero-via) 50%,
|
||||
var(--hero-to) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.pill-active {
|
||||
background: linear-gradient(135deg, #C2712E, #9A5320);
|
||||
color: #FFF6E8;
|
||||
box-shadow: 0 2px 10px rgba(194, 113, 46, 0.38);
|
||||
}
|
||||
|
||||
.pill-inactive {
|
||||
background: rgba(120, 90, 58, 0.09);
|
||||
color: var(--stone-mid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Cormorant_Garamond, Plus_Jakarta_Sans } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const cormorant = Cormorant_Garamond({
|
||||
variable: "--font-cormorant",
|
||||
subsets: ["latin"],
|
||||
weight: ["300", "400", "500", "600", "700"],
|
||||
style: ["normal", "italic"],
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const jakarta = Plus_Jakarta_Sans({
|
||||
variable: "--font-jakarta",
|
||||
subsets: ["latin"],
|
||||
weight: ["300", "400", "500", "600", "700"],
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Moy Beach Akyaka | Menü",
|
||||
description: "Moy Beach Akyaka dijital menü.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="tr" className={`${cormorant.variable} ${jakarta.variable} h-full`}>
|
||||
<body className="min-h-full flex flex-col antialiased">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import MenuClient from "./MenuClient";
|
||||
import { MenuCategory } from "@/data/menu";
|
||||
|
||||
async function getMenuData(): Promise<MenuCategory[]> {
|
||||
const dbCategories = await prisma.categories.findMany({
|
||||
where: { status: 1 },
|
||||
orderBy: { order_num: "asc" },
|
||||
});
|
||||
|
||||
const dbProducts = await prisma.products.findMany({
|
||||
where: { status: 1 },
|
||||
orderBy: [{ category_id: "asc" }, { id: "asc" }],
|
||||
});
|
||||
|
||||
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',
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user