first commit

This commit is contained in:
mstfyldz
2026-06-03 04:11:30 +03:00
parent d369c9e258
commit 7b4b3a85af
20 changed files with 3446 additions and 442 deletions
+2
View File
@@ -42,3 +42,5 @@ next-env.d.ts
# brainstorming sessions
.superpowers/
/app/generated/prisma
+25 -27
View File
@@ -1,36 +1,34 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# Kozmos QR Menu
## Getting Started
Modern, minimalist ve çok dilli (TR/EN) QR Menü Uygulaması.
First, run the development server:
## Özellikler
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
- **Çift Dil Desteği (i18n):** Türkçe ve İngilizce menü seçenekleri
- **Admin Paneli:** Ürün, kategori ekleme/silme ve sıralama (Sürükle-bırak benzeri butonlar ile)
- **Minimalist UI Tasarımı:** Kullanıcı dostu, modern arayüz
- **PostgreSQL & Prisma:** Güvenli ve performanslı veri yönetimi
- **Resim Yükleme:** Admin paneli üzerinden ürünlere görsel ekleyebilme
- **Responsive:** Tüm mobil cihazlar ile tam uyumlu görünüm
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
## Kurulum
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
1. Depoyu klonlayın ve bağımlılıkları yükleyin:
```bash
npm install
```
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
2. `.env` ve `.env.local` dosyalarınızı ayarlayın (Veritabanı bağlantısı vb.).
## Learn More
3. Veritabanını güncelleyin ve istemciyi oluşturun:
```bash
npx prisma db push
npx prisma generate
```
To learn more about Next.js, take a look at the following resources:
4. Geliştirme sunucusunu başlatın:
```bash
npm run dev
```
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
Uygulamaya `http://localhost:3000` adresinden ulaşabilirsiniz.
+2 -1
View File
@@ -1,6 +1,6 @@
import { notFound } from 'next/navigation'
import { getDictionary, hasLocale } from './dictionaries'
import { categories } from '@/data/menu'
import { getCategories } from '@/data/menu'
import Header from '@/components/Header'
import CategorySection from '@/components/CategorySection'
import BottomTabBar from '@/components/BottomTabBar'
@@ -15,6 +15,7 @@ export default async function MenuPage({
if (!hasLocale(lang)) notFound()
const dict = await getDictionary(lang)
const categories = await getCategories()
return (
<>
+33
View File
@@ -0,0 +1,33 @@
'use server'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
export async function login(prevState: any, formData: FormData) {
const username = formData.get('username')
const password = formData.get('password')
if (
username === process.env.ADMIN_USERNAME &&
password === process.env.ADMIN_PASSWORD
) {
const cookieStore = await cookies()
cookieStore.set('admin_session', process.env.ADMIN_SECRET || 'secret', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 7 // 1 week
})
redirect('/admin')
} else {
return { error: 'Geçersiz kullanıcı adı veya şifre' }
}
}
export async function logout() {
const cookieStore = await cookies()
cookieStore.delete('admin_session')
redirect('/admin/login')
}
+22
View File
@@ -0,0 +1,22 @@
'use server'
import { saveCategories, type Category } from '@/data/menu'
import { revalidatePath } from 'next/cache'
import { cookies } from 'next/headers'
async function checkAuth() {
const cookieStore = await cookies()
const session = cookieStore.get('admin_session')?.value
if (!session || session !== (process.env.ADMIN_SECRET || 'secret')) {
throw new Error('Unauthorized')
}
}
export async function saveEntireMenu(categories: Category[]) {
await checkAuth()
await saveCategories(categories)
// Revalidate both language paths
revalidatePath('/tr')
revalidatePath('/en')
return { success: true }
}
+34
View File
@@ -0,0 +1,34 @@
'use server'
import { promises as fs } from 'fs'
import path from 'path'
import { cookies } from 'next/headers'
export async function uploadImage(formData: FormData) {
const cookieStore = await cookies()
const session = cookieStore.get('admin_session')?.value
const secret = process.env.ADMIN_SECRET || 'secret'
if (!session || session !== secret) {
throw new Error('Unauthorized')
}
const file = formData.get('file') as File
if (!file) {
throw new Error('No file uploaded')
}
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
const extension = path.extname(file.name) || '.jpg'
const filename = `${Date.now()}-${Math.random().toString(36).substring(7)}${extension}`
const uploadDir = path.join(process.cwd(), 'public', 'uploads')
await fs.mkdir(uploadDir, { recursive: true })
const filePath = path.join(uploadDir, filename)
await fs.writeFile(filePath, buffer)
return { url: `/uploads/${filename}` }
}
+25
View File
@@ -0,0 +1,25 @@
import { ReactNode } from 'react'
import { logout } from '@/app/actions/auth'
export default function AdminLayout({ children }: { children: ReactNode }) {
return (
<div className="min-h-screen bg-cream flex flex-col" style={{ backgroundColor: '#F5F0E8' }}>
<header className="bg-coffee-dark px-6 py-4 flex justify-between items-center" style={{ backgroundColor: '#3D2B1F' }}>
<h1 className="text-xl font-display font-bold text-cream" style={{ color: '#F5F0E8' }}>Kozmos Yönetim</h1>
<form action={logout}>
<button
type="submit"
className="px-4 py-1.5 rounded bg-cream/10 text-cream text-sm font-medium hover:bg-cream/20 transition-colors"
>
Çıkış Yap
</button>
</form>
</header>
<main className="flex-1 p-4 md:p-6 lg:p-8 max-w-5xl mx-auto w-full">
{children}
</main>
</div>
)
}
+61
View File
@@ -0,0 +1,61 @@
'use client'
import { useActionState } from 'react'
import { login } from '@/app/actions/auth'
export default function LoginPage() {
const [state, formAction, isPending] = useActionState(login, null)
return (
<div className="min-h-screen flex items-center justify-center bg-cream px-4" style={{ backgroundColor: '#F5F0E8' }}>
<div className="w-full max-w-sm bg-white rounded-2xl shadow-xl overflow-hidden">
<div className="bg-coffee-dark px-6 py-8 text-center" style={{ backgroundColor: '#3D2B1F' }}>
<h1 className="text-2xl font-display font-bold text-cream" style={{ color: '#F5F0E8' }}>Kozmos Admin</h1>
<p className="text-cream/70 text-sm mt-2">Yönetim paneline giriş yapın</p>
</div>
<form action={formAction} className="px-6 py-8 space-y-5">
{state?.error && (
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm font-medium border border-red-100">
{state.error}
</div>
)}
<div>
<label className="block text-sm font-medium text-coffee mb-1.5">Kullanıcı Adı</label>
<input
type="text"
name="username"
required
className="w-full px-4 py-2.5 rounded-lg border border-beige/60 bg-cream/30 focus:outline-none focus:ring-2 focus:ring-teal/50"
placeholder="admin"
/>
</div>
<div>
<label className="block text-sm font-medium text-coffee mb-1.5">Şifre</label>
<input
type="password"
name="password"
required
className="w-full px-4 py-2.5 rounded-lg border border-beige/60 bg-cream/30 focus:outline-none focus:ring-2 focus:ring-teal/50"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={isPending}
className="w-full py-3 rounded-lg text-white font-medium transition-all"
style={{
backgroundColor: '#4AAFA8',
opacity: isPending ? 0.7 : 1
}}
>
{isPending ? 'Giriş yapılıyor...' : 'Giriş Yap'}
</button>
</form>
</div>
</div>
)
}
+10
View File
@@ -0,0 +1,10 @@
import { getCategories } from '@/data/menu'
import AdminDashboard from '@/components/admin/AdminDashboard'
export const dynamic = 'force-dynamic'
export default async function AdminPage() {
const categories = await getCategories()
return <AdminDashboard initialCategories={categories} />
}
+32 -72
View File
@@ -11,25 +11,17 @@ export default function CategorySection({ category, lang, currency }: Props) {
const l = lang as 'tr' | 'en'
return (
<section id={category.id} className="category-section category-reveal px-4 pt-9 pb-1">
<section id={category.id} className="category-section category-reveal px-4 pt-12 pb-2">
{/* Category header */}
<div className="mb-4">
<div className="flex items-center gap-3 mb-2">
{/* Emoji badge with gradient ring */}
<div className="mb-6">
<div className="flex items-center gap-3 mb-3">
{/* Subtle minimal Emoji badge */}
<div className="relative flex-shrink-0">
{/* Outer glow ring */}
<div
className="absolute inset-0 rounded-2xl blur-sm opacity-60"
className="w-12 h-12 rounded-full flex items-center justify-center text-2xl shadow-sm"
style={{
background: 'linear-gradient(135deg, #4AAFA8, #5C8A5C)',
transform: 'scale(1.15)',
}}
/>
<div
className="relative w-11 h-11 rounded-2xl flex items-center justify-center text-xl"
style={{
background: 'linear-gradient(135deg, rgba(74,175,168,0.18), rgba(92,138,92,0.12))',
border: '1px solid rgba(74,175,168,0.30)',
background: '#FDFCF8',
border: '1px solid rgba(212,200,176,0.40)',
}}
>
{category.emoji}
@@ -37,75 +29,43 @@ export default function CategorySection({ category, lang, currency }: Props) {
</div>
{/* Title */}
<div className="flex-1 min-w-0">
<div className="flex-1 min-w-0 flex items-center gap-2">
<h2
className="font-display font-semibold italic text-coffee leading-tight"
style={{ fontSize: '1.2rem' }}
className="font-display font-semibold text-coffee uppercase tracking-wide leading-tight"
style={{ fontSize: '1.25rem' }}
>
{category.label[l]}
</h2>
{/* Item count badge */}
<span
className="font-body text-[0.65rem] font-semibold tracking-widest text-muted mt-0.5"
>
({category.items.length})
</span>
</div>
{/* Item count badge */}
<span
className="flex-shrink-0 font-body text-[0.6rem] font-semibold tracking-widest uppercase rounded-full px-2.5 py-0.5"
style={{
color: '#8A7265',
background: 'rgba(61,43,31,0.07)',
border: '1px solid rgba(61,43,31,0.10)',
}}
>
{category.items.length}
</span>
</div>
{/* Decorative divider */}
<div className="flex items-center gap-2 ml-1">
<div className="w-1.5 h-1.5 rounded-full bg-teal opacity-70" />
<div
className="flex-1 h-px"
style={{
background:
'linear-gradient(90deg, rgba(74,175,168,0.40) 0%, rgba(212,200,176,0.40) 60%, transparent 100%)',
}}
/>
<div className="w-1 h-1 rounded-full bg-gold opacity-50" />
</div>
</div>
{/* Items card */}
<div
className="rounded-2xl overflow-hidden"
style={{
background: 'rgba(253,250,245,0.72)',
border: '1px solid rgba(212,200,176,0.60)',
backdropFilter: 'blur(8px)',
WebkitBackdropFilter: 'blur(8px)',
boxShadow:
'0 2px 16px rgba(61,43,31,0.06), 0 1px 3px rgba(61,43,31,0.04)',
}}
>
{/* Thin top gradient line */}
{/* Minimalist divider */}
<div
className="h-px w-full"
className="w-full h-px"
style={{
background:
'linear-gradient(90deg, transparent, rgba(74,175,168,0.35), transparent)',
background: 'linear-gradient(90deg, rgba(61,43,31,0.15) 0%, transparent 100%)',
}}
/>
</div>
<div className="px-4 pb-1 pt-0.5">
{category.items.map((item, i) => (
<MenuItemRow
key={item.id}
item={item}
lang={lang}
currency={currency}
index={i}
isLast={i === category.items.length - 1}
/>
))}
</div>
{/* Items list (no background card) */}
<div className="flex flex-col gap-1">
{category.items.map((item, i) => (
<MenuItemRow
key={item.id}
item={item}
lang={lang}
currency={currency}
index={i}
isLast={i === category.items.length - 1}
/>
))}
</div>
</section>
)
+34 -26
View File
@@ -13,48 +13,56 @@ export default function MenuItemRow({ item, lang, currency, index, isLast }: Pro
return (
<div
className={`menu-item-row flex items-center gap-3.5 py-3.5 rounded-xl px-2 -mx-2 ${
!isLast ? 'border-b border-dashed border-beige' : ''
className={`menu-item-row flex items-start gap-4 py-4 rounded-xl px-2 -mx-2 transition-colors ${
!isLast ? 'border-b border-[rgba(61,43,31,0.06)]' : ''
}`}
style={{ '--item-index': index } as React.CSSProperties}
>
{/* Emoji circle with gradient */}
{/* Image or Emoji circle */}
<div
className="w-11 h-11 rounded-xl flex items-center justify-center flex-shrink-0 text-[1.2rem]"
className="w-16 h-16 rounded-full flex items-center justify-center flex-shrink-0 text-3xl overflow-hidden shadow-sm mt-0.5"
style={{
background:
'linear-gradient(135deg, #F0E8D8 0%, #E8DCC8 100%)',
border: '1px solid rgba(212,200,176,0.80)',
boxShadow: 'inset 0 1px 2px rgba(255,255,255,0.8)',
background: '#FDFCF8',
border: '1px solid rgba(212,200,176,0.30)',
}}
>
{item.emoji || '✨'}
{item.image ? (
<img src={item.image} alt={item.name[l]} className="w-full h-full object-cover" />
) : (
item.emoji || '✨'
)}
</div>
{/* Text */}
<div className="flex-1 min-w-0">
<p
className="font-display text-coffee font-semibold leading-snug"
style={{ fontSize: '0.9rem' }}
>
{item.name[l]}
</p>
{/* Text Content */}
<div className="flex-1 min-w-0 pt-0.5">
<div className="flex justify-between items-start gap-2">
<h3
className="font-display text-coffee font-semibold leading-tight mb-1"
style={{ fontSize: '1.05rem' }}
>
{item.name[l]}
</h3>
{/* Price */}
{item.price !== undefined && (
<span
className="flex-shrink-0 font-medium text-coffee mt-0.5"
style={{ fontSize: '1rem' }}
>
{item.price} {currency}
</span>
)}
</div>
{item.description && (
<p
className="font-body text-muted leading-relaxed mt-0.5 line-clamp-2"
style={{ fontSize: '0.72rem' }}
className="font-body text-muted leading-relaxed line-clamp-2"
style={{ fontSize: '0.85rem' }}
>
{item.description[l]}
</p>
)}
</div>
{/* Price pill */}
{item.price !== undefined && (
<span className="price-pill flex-shrink-0">
{currency}{item.price}
</span>
)}
</div>
)
}
+356
View File
@@ -0,0 +1,356 @@
'use client'
import { useState, useTransition, useEffect } from 'react'
import type { Category, MenuItem } from '@/data/menu'
import { saveEntireMenu } from '@/app/actions/menu'
import { uploadImage } from '@/app/actions/upload'
export default function AdminDashboard({ initialCategories }: { initialCategories: Category[] }) {
const [categories, setCategories] = useState<Category[]>(initialCategories)
const [isPending, startTransition] = useTransition()
const [isSaved, setIsSaved] = useState(false)
const [selectedCatId, setSelectedCatId] = useState<string | null>(initialCategories[0]?.id || null)
// Auto-select first category if selected one is deleted
useEffect(() => {
if (selectedCatId && !categories.find(c => c.id === selectedCatId) && categories.length > 0) {
setSelectedCatId(categories[0].id)
}
}, [categories, selectedCatId])
const handleSave = () => {
startTransition(async () => {
await saveEntireMenu(categories)
setIsSaved(true)
setTimeout(() => setIsSaved(false), 3000)
})
}
const handleImageUpload = async (catId: string, itemId: string, e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const formData = new FormData()
formData.append('file', file)
try {
const res = await uploadImage(formData)
if (res?.url) {
updateItem(catId, itemId, 'image', res.url)
}
} catch (err) {
console.error(err)
alert("Resim yüklenemedi.")
}
}
// --- Item CRUD ---
const updateItem = (catId: string, itemId: string, field: string, value: any, lang?: 'tr' | 'en') => {
setCategories(cats => cats.map(cat => {
if (cat.id !== catId) return cat
return {
...cat,
items: cat.items.map(item => {
if (item.id !== itemId) return item
if (lang) {
return { ...item, [field]: { ...(item as any)[field], [lang]: value } }
}
return { ...item, [field]: value }
})
}
}))
}
const addItem = (catId: string) => {
const newItem: MenuItem = {
id: `item-${Date.now()}`,
name: { tr: 'Yeni Ürün', en: 'New Item' },
description: { tr: '', en: '' },
price: 0,
highlight: false,
}
setCategories(cats => cats.map(cat => {
if (cat.id !== catId) return cat
return { ...cat, items: [...cat.items, newItem] }
}))
}
const deleteItem = (catId: string, itemId: string) => {
if (!confirm('Bu ürünü silmek istediğinize emin misiniz?')) return
setCategories(cats => cats.map(cat => {
if (cat.id !== catId) return cat
return { ...cat, items: cat.items.filter(item => item.id !== itemId) }
}))
}
const moveItem = (catId: string, itemIndex: number, direction: 'up' | 'down') => {
setCategories(cats => cats.map(cat => {
if (cat.id !== catId) return cat
const newItems = [...cat.items]
if (direction === 'up' && itemIndex > 0) {
[newItems[itemIndex - 1], newItems[itemIndex]] = [newItems[itemIndex], newItems[itemIndex - 1]]
} else if (direction === 'down' && itemIndex < newItems.length - 1) {
[newItems[itemIndex + 1], newItems[itemIndex]] = [newItems[itemIndex], newItems[itemIndex + 1]]
}
return { ...cat, items: newItems }
}))
}
// --- Category CRUD ---
const updateCategory = (catId: string, field: string, value: any, lang?: 'tr' | 'en') => {
setCategories(cats => cats.map(cat => {
if (cat.id !== catId) return cat
if (lang && field === 'label') {
return { ...cat, label: { ...cat.label, [lang]: value } }
}
return { ...cat, [field]: value }
}))
}
const addCategory = () => {
const newCategory: Category = {
id: `cat-${Date.now()}`,
emoji: '🍽️',
label: { tr: 'Yeni Kategori', en: 'New Category' },
items: [],
}
setCategories(cats => [...cats, newCategory])
setSelectedCatId(newCategory.id)
}
const deleteCategory = (catId: string) => {
if (!confirm('Bu kategoriyi ve içindeki tüm ürünleri silmek istediğinize emin misiniz?')) return
setCategories(cats => cats.filter(cat => cat.id !== catId))
}
const moveCategory = (catIndex: number, direction: 'up' | 'down') => {
setCategories(cats => {
const newCats = [...cats]
if (direction === 'up' && catIndex > 0) {
[newCats[catIndex - 1], newCats[catIndex]] = [newCats[catIndex], newCats[catIndex - 1]]
} else if (direction === 'down' && catIndex < newCats.length - 1) {
[newCats[catIndex + 1], newCats[catIndex]] = [newCats[catIndex], newCats[catIndex + 1]]
}
return newCats
})
}
const selectedCategory = categories.find(c => c.id === selectedCatId)
return (
<div className="flex flex-col h-screen max-h-screen bg-gray-50 pb-8">
{/* Top Bar */}
<div className="flex justify-between items-center bg-white p-4 shadow-sm z-10 border-b shrink-0">
<h2 className="text-xl font-bold text-coffee" style={{ color: '#3D2B1F' }}>Menü Yönetimi</h2>
<div className="flex items-center gap-3">
<button
onClick={handleSave}
disabled={isPending}
className="px-6 py-2 rounded-lg text-white font-medium shadow-sm transition-all"
style={{ backgroundColor: '#4AAFA8', opacity: isPending ? 0.7 : 1 }}
>
{isPending ? 'Kaydediliyor...' : isSaved ? '✓ Kaydedildi' : 'Değişiklikleri Kaydet'}
</button>
</div>
</div>
<div className="flex flex-1 overflow-hidden">
{/* Left Sidebar (Categories) */}
<div className="w-1/3 md:w-1/4 bg-white border-r border-gray-200 flex flex-col">
<div className="p-4 border-b">
<button
onClick={addCategory}
className="w-full py-2 rounded-lg text-emerald-700 bg-emerald-50 border border-emerald-200 font-medium hover:bg-emerald-100 transition-colors text-sm"
>
+ Yeni Kategori Ekle
</button>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1">
{categories.map((cat, catIndex) => (
<div
key={cat.id}
className={`flex items-center justify-between p-2 rounded-lg cursor-pointer transition-colors group ${
selectedCatId === cat.id ? 'bg-emerald-50 border-emerald-200 border' : 'hover:bg-gray-100 border border-transparent'
}`}
onClick={() => setSelectedCatId(cat.id)}
>
<div className="flex items-center gap-2 truncate">
<span className="text-xl">{cat.emoji}</span>
<span className={`text-sm font-medium truncate ${selectedCatId === cat.id ? 'text-emerald-800' : 'text-gray-700'}`}>
{cat.label.tr}
</span>
</div>
{/* Category Actions (visible on hover or when selected) */}
<div className={`flex items-center opacity-0 group-hover:opacity-100 ${selectedCatId === cat.id ? 'opacity-100' : ''}`}>
<button onClick={(e) => { e.stopPropagation(); moveCategory(catIndex, 'up') }} disabled={catIndex === 0} className="p-1 hover:bg-white rounded text-gray-500 disabled:opacity-30"></button>
<button onClick={(e) => { e.stopPropagation(); moveCategory(catIndex, 'down') }} disabled={catIndex === categories.length - 1} className="p-1 hover:bg-white rounded text-gray-500 disabled:opacity-30"></button>
<button onClick={(e) => { e.stopPropagation(); deleteCategory(cat.id) }} className="p-1 hover:bg-red-100 text-red-500 rounded ml-1">Sil</button>
</div>
</div>
))}
{categories.length === 0 && (
<div className="text-center p-4 text-gray-400 text-sm">Kategori bulunamadı.</div>
)}
</div>
</div>
{/* Right Content (Items) */}
<div className="flex-1 overflow-y-auto bg-gray-50 p-6">
{selectedCategory ? (
<div className="max-w-4xl mx-auto space-y-6">
{/* Category Edit Header */}
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-200">
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-4 border-b pb-2">Kategori Ayarları</h3>
<div className="flex flex-col md:flex-row items-center gap-4">
<div className="flex flex-col items-center gap-1">
<input
type="text"
value={selectedCategory.emoji}
onChange={(e) => updateCategory(selectedCategory.id, 'emoji', e.target.value)}
className="w-14 h-14 text-center text-3xl border border-gray-300 rounded-xl bg-gray-50"
title="Kategori Emojisi"
/>
<span className="text-[10px] text-gray-400">Emoji</span>
</div>
<div className="flex-1 flex flex-col md:flex-row gap-4 w-full">
<div className="flex-1">
<label className="block text-xs font-medium text-gray-500 mb-1">Türkçe İsim</label>
<input
type="text"
value={selectedCategory.label.tr}
onChange={(e) => updateCategory(selectedCategory.id, 'label', e.target.value, 'tr')}
className="w-full px-3 py-2 border rounded-md font-bold text-coffee bg-gray-50"
/>
</div>
<div className="flex-1">
<label className="block text-xs font-medium text-gray-500 mb-1">İngilizce İsim</label>
<input
type="text"
value={selectedCategory.label.en}
onChange={(e) => updateCategory(selectedCategory.id, 'label', e.target.value, 'en')}
className="w-full px-3 py-2 border rounded-md text-gray-600 bg-gray-50"
/>
</div>
</div>
</div>
</div>
{/* Items List */}
<div className="space-y-4">
<div className="flex justify-between items-end">
<h3 className="text-lg font-bold text-coffee">Ürünler ({selectedCategory.items.length})</h3>
</div>
{selectedCategory.items.map((item, itemIndex) => (
<div key={item.id} className="border border-gray-200 p-5 rounded-xl bg-white relative hover:border-gray-300 transition-colors shadow-sm">
{/* Item Actions (Top Right) */}
<div className="absolute top-3 right-3 flex items-center gap-1">
<button onClick={() => moveItem(selectedCategory.id, itemIndex, 'up')} disabled={itemIndex === 0} className="p-1.5 bg-gray-100 rounded disabled:opacity-30 hover:bg-gray-200 text-xs"></button>
<button onClick={() => moveItem(selectedCategory.id, itemIndex, 'down')} disabled={itemIndex === selectedCategory.items.length - 1} className="p-1.5 bg-gray-100 rounded disabled:opacity-30 hover:bg-gray-200 text-xs"></button>
<button onClick={() => deleteItem(selectedCategory.id, item.id)} className="p-1.5 bg-red-50 text-red-500 rounded hover:bg-red-100 text-xs ml-1 font-medium">Sil</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4">
{/* TR Fields */}
<div className="space-y-3">
<div className="font-semibold text-sm text-gray-700 mb-2 border-b pb-1">Türkçe (TR)</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">İsim</label>
<input
type="text"
value={item.name.tr}
onChange={(e) => updateItem(selectedCategory.id, item.id, 'name', e.target.value, 'tr')}
className="w-full text-sm px-3 py-2 border rounded-md bg-gray-50 focus:bg-white"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Açıklama</label>
<textarea
value={item.description?.tr || ''}
onChange={(e) => updateItem(selectedCategory.id, item.id, 'description', e.target.value, 'tr')}
className="w-full text-sm px-3 py-2 border rounded-md bg-gray-50 focus:bg-white"
rows={2}
/>
</div>
</div>
{/* EN Fields */}
<div className="space-y-3">
<div className="font-semibold text-sm text-gray-700 mb-2 border-b pb-1">İngilizce (EN)</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Name</label>
<input
type="text"
value={item.name.en}
onChange={(e) => updateItem(selectedCategory.id, item.id, 'name', e.target.value, 'en')}
className="w-full text-sm px-3 py-2 border rounded-md bg-gray-50 focus:bg-white"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Description</label>
<textarea
value={item.description?.en || ''}
onChange={(e) => updateItem(selectedCategory.id, item.id, 'description', e.target.value, 'en')}
className="w-full text-sm px-3 py-2 border rounded-md bg-gray-50 focus:bg-white"
rows={2}
/>
</div>
</div>
</div>
{/* Common Fields */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-5 pt-4 border-t border-gray-100">
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Fiyat ()</label>
<input
type="number"
value={item.price || 0}
onChange={(e) => updateItem(selectedCategory.id, item.id, 'price', Number(e.target.value))}
className="w-full text-sm px-3 py-2 border rounded-md bg-gray-50 focus:bg-white font-mono"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Emoji (Opsiyonel)</label>
<input
type="text"
value={item.emoji || ''}
onChange={(e) => updateItem(selectedCategory.id, item.id, 'emoji', e.target.value)}
className="w-full text-sm px-3 py-2 border rounded-md bg-gray-50 focus:bg-white text-center text-xl"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Resim Ekle</label>
<input
type="file"
accept="image/*"
onChange={(e) => handleImageUpload(selectedCategory.id, item.id, e)}
className="w-full text-xs px-2 py-1.5 border rounded-md file:mr-2 file:py-1 file:px-2 file:rounded file:border-0 file:bg-emerald-50 file:text-emerald-700 file:text-xs"
/>
{item.image && (
<div className="mt-2 flex items-center gap-2 bg-gray-50 p-1.5 rounded-md border">
<img src={item.image} alt="" className="w-8 h-8 rounded object-cover" />
<button onClick={() => updateItem(selectedCategory.id, item.id, 'image', undefined)} className="text-xs text-red-500 hover:underline px-2">Kaldır</button>
</div>
)}
</div>
</div>
</div>
))}
<button
onClick={() => addItem(selectedCategory.id)}
className="w-full py-4 rounded-xl border-2 border-dashed border-gray-300 text-gray-500 font-medium hover:border-emerald-300 hover:text-emerald-600 transition-colors bg-white hover:bg-emerald-50 text-sm mt-4 shadow-sm"
>
+ Kategoriye Yeni Ürün Ekle
</button>
</div>
</div>
) : (
<div className="flex items-center justify-center h-full text-gray-400">
Düzenlemek için sol taraftan bir kategori seçin.
</div>
)}
</div>
</div>
</div>
)
}
+1628
View File
File diff suppressed because it is too large Load Diff
+82 -316
View File
@@ -1,8 +1,11 @@
import prisma from '@/lib/prisma'
export type LocalizedString = { tr: string; en: string }
export type MenuItem = {
id: string
emoji?: string
image?: string
name: LocalizedString
description?: LocalizedString
price?: number
@@ -18,319 +21,82 @@ export type Category = {
export const WHATSAPP_NUMBER = '905XXXXXXXXXX'
export const categories: Category[] = [
{
id: 'kahvaltiliklar',
emoji: '🍳',
label: { tr: 'Kahvaltılıklar', en: 'Breakfasts' },
items: [
{ id: 'k1', name: { tr: 'Sucuklu Yumurta', en: 'Eggs with Sucuk' }, description: { tr: 'Kaşar peynirli, çıtır domates', en: 'Kaşar cheese, crispy tomato' } },
{ id: 'k2', name: { tr: 'Menemen', en: 'Menemen' }, description: { tr: 'Domates, yeşil biber, 2 yumurta', en: 'Tomato, green pepper, 2 eggs' } },
{ id: 'k3', name: { tr: 'Kaşarlı Menemen', en: 'Menemen with Kaşar Cheese' }, description: { tr: 'Rondanın 5 kaşar peyniri, domates, yeşil biber, çeşitli 2 yumurta', en: '5 portions of kaşar cheese, tomato, green pepper, 2 eggs' } },
{ id: 'k4', name: { tr: 'Sade Omlet', en: 'Plain Omelette' }, description: { tr: 'Çeşitme 2 yumurta, mezelemeniz kaşar peyniri', en: '2 eggs, kaşar cheese' } },
{ id: 'k5', name: { tr: 'Mantarlı Omlet', en: 'Mushroom Omelette' }, description: { tr: 'Mantarlarla zenginleştirilmiş kaşar peyniri, çıtır biber, çimento, yeşil biber, satır, mantarı', en: 'Mushrooms, kaşar cheese, crispy peppers' } },
{ id: 'k6', name: { tr: 'Sebzeli Omlet', en: 'Vegetable Omelette' }, description: { tr: 'Mantarlar, zenginleştirilmiş kaşar peyniri, kapıya biber, çimento, yeşil biber ve saman mantarı', en: 'Mushrooms, kaşar cheese, capia pepper, green pepper' } },
{ id: 'k7', name: { tr: 'Vegan Kahvaltı', en: 'Vegan Breakfast' }, description: { tr: 'Avokado, çeşit domatesi, zeytin, tost, roka, havuç', en: 'Avocado, cherry tomato, olives, toast, arugula, carrot' } },
{ id: 'k8', name: { tr: 'Serpme Kahvaltı 2 Kişilik', en: 'Mixed Breakfast - For 2' }, description: { tr: 'Kaşar peyniri, bal & kaymak, siyah zeytin, yeşil zeytin, sürüm, hinse türreyi, çeşit domatesi, erikçimler, tatlıpırçık, havuç, maydanoz ve sonsuz çay', en: 'Kaşar cheese, honey & clotted cream, black & green olives, cherry tomato, jams, carrot, parsley and unlimited tea' } },
],
},
{
id: 'burgerler',
emoji: '🍔',
label: { tr: 'Burgerler', en: 'Burgers' },
items: [
{ id: 'b1', name: { tr: 'Kids Smash Burger', en: 'Kids Smash Burger' }, description: { tr: 'Kids Smash burger, cheddar peyniri, çıtır patates, kızartılmış turşu, domates, marul, burger sosu, patates cipsi', en: 'Kids Smash burger, cheddar cheese, crispy fries, fried pickles, tomato, lettuce, burger sauce, potato chips' } },
{ id: 'b2', name: { tr: 'Dana Smash Burger', en: 'Beef Smash Burger' }, description: { tr: 'Dana Smash burger, cheddar peyniri, karamelize soğan, turşu, domates, marul, burger sosu, patates cipsi', en: 'Beef Smash burger, cheddar cheese, caramelized onions, pickles, tomato, lettuce, burger sauce, potato chips' } },
{ id: 'b3', name: { tr: 'Dana Smash Big Burger', en: 'Beef Smash Big Burger' }, description: { tr: 'Dana Smash burger smash kafası, cheddar peyniri, karamelize soğan, çıtır patates, kızartılmış turşu, domates, marul, burger sosu, patates cipsi', en: 'Beef Smash big burger, cheddar cheese, caramelized onions, crispy fries, fried pickles, tomato, lettuce, burger sauce, potato chips' } },
{ id: 'b4', name: { tr: 'Çıtır Tavuk Burger', en: 'Crispy Chicken Burger' }, description: { tr: 'Çıtır Tavuk Burger harika cheddar peyniri, burger sos, patates cipsi', en: 'Crispy Chicken Burger, cheddar cheese, burger sauce, potato chips' } },
],
},
{
id: 'tatlilar',
emoji: '🍮',
label: { tr: 'Tatlılar', en: 'Desserts' },
items: [
{ id: 't1', name: { tr: 'Meşhur Akyaka Tatlısı', en: 'Famous Akyaka Dessert' }, description: { tr: 'Deniz tuzlu, bürüme, bitter çikolata, Gökova sütlemi', en: 'Sea salt, bitter chocolate, Gökova milk' } },
{ id: 't2', name: { tr: 'Fırın Helva', en: 'Baked Halva' } },
],
},
{
id: 'tostlar',
emoji: '🥪',
label: { tr: 'Tostlar', en: 'Toasts' },
items: [
{ id: 'ts1', name: { tr: 'Peynirli Tost', en: 'Cheese Toast' }, description: { tr: 'Kaşar peyniri, çeşit domatesi, zeytin, patates cipsi', en: 'Kaşar cheese, cherry tomato, olives, potato chips' } },
{ id: 'ts2', name: { tr: 'Sucuklu Tost', en: 'Sucuk Toast' }, description: { tr: 'Kaşar sucuklu, çeşit domatesi, zeytin, patates cipsi', en: 'Sucuk, kaşar cheese, cherry tomato, olives, potato chips' } },
{ id: 'ts3', name: { tr: 'Akdeniz Tost', en: 'Mediterranean Toast' }, description: { tr: 'Kaşar pul, Ezine peyniri, tulum peyniri, kaşar domates, çeşit domatesi, zeytin, patates cipsi', en: 'Ezine cheese, tulum cheese, kaşar cheese, cherry tomato, olives, potato chips' } },
{ id: 'ts4', name: { tr: 'Karışık Tost', en: 'Mixed Toast' }, description: { tr: 'Kaşar sucuklu, kaşar peyniri, peri domates, zeytin, salatalık, dilimlen', en: 'Sucuk, kaşar cheese, cherry tomato, olives, cucumber slices' } },
{ id: 'ts5', name: { tr: 'Ayvalık Tostu', en: 'Ayvalik Toast' }, description: { tr: 'Ekşi, karamize turşu, kırmızı bilet kaşar peyniri, çeşit domatesi, salatalık, dilimlen', en: 'Sourdough bread, caramelized pickles, red pepper kaşar cheese, cherry tomato, cucumber slices' } },
],
},
{
id: 'salatalar',
emoji: '🥗',
label: { tr: 'Salatalar', en: 'Salads' },
items: [
{ id: 'sl1', name: { tr: 'Çoban Salata', en: "Shepherd's Salad" } },
{ id: 'sl2', name: { tr: 'Roka Salatası', en: 'Arugula Salad' }, description: { tr: 'Domates, zenginleştirilmiş üzüme bol peyniri', en: 'Tomato, grapes, cheese' } },
{ id: 'sl3', name: { tr: 'Yaz Salatası', en: 'Summer Salad' }, description: { tr: 'Kaşar ve her, taze domates, kırmızı soğan, beyaz peynir, fasulye, çeşitli yeşil besinleri, dalya üzümleri, çeşit domatesi', en: 'Fresh tomato, red onion, white cheese, beans, mixed greens, grapes, cherry tomato' } },
{ id: 'sl4', name: { tr: 'Avokado Salatası', en: 'Avocado Salad' }, description: { tr: 'Avokado, çeşit domatesi, salatalık, yeşil zeytin', en: 'Avocado, cherry tomato, cucumber, green olives' } },
{ id: 'sl5', name: { tr: 'Ton Balıklı Kinoalı Salata', en: 'Tuna & Quinoa Salad' }, description: { tr: 'Siyah kinoa, ton balığı, maydanoz, çeşit domatesi, salatalık, dilimlen', en: 'Black quinoa, tuna fish, parsley, cherry tomato, cucumber slices' } },
],
},
{
id: 'pizzalar',
emoji: '🍕',
label: { tr: 'Pizzalar', en: 'Pizzas' },
items: [
{ id: 'pz1', name: { tr: 'Margherita Pizza', en: 'Margherita Pizza' }, description: { tr: 'Pizza sosu, mozzarella peyniri, fesleğen, zeytin makul', en: 'Pizza sauce, mozzarella cheese, basil, olives' } },
{ id: 'pz2', name: { tr: 'Akdeniz Pizza', en: 'Mediterranean Pizza' }, description: { tr: 'Kaşar peyniri, yeşil biber, mantar, fesleğen, mozzarella peyniri, piliç sosu', en: 'Kaşar cheese, green pepper, mushroom, basil, mozzarella cheese' } },
{ id: 'pz3', name: { tr: 'Sucuk Pizza', en: 'Sucuk Pizza' }, description: { tr: 'Yeşil biber, domates biber, mantar, kaşar', en: 'Green pepper, tomato, pepper, mushroom, kaşar' } },
{ id: 'pz4', name: { tr: 'Enginar lı Pizza', en: 'Artichoke Pizza' }, description: { tr: 'Belirli peşik, fesleğen, mozzarella peyniri, domates sosu', en: 'Artichoke, basil, mozzarella cheese, tomato sauce' } },
],
},
{
id: 'wraps',
emoji: '🌯',
label: { tr: 'Wraps', en: 'Wraps' },
items: [
{ id: 'w1', name: { tr: 'Sebzeli Wrap', en: 'Veggie Wrap' }, description: { tr: 'Mantar, toflu peynirli, turşu, soğan, samon biber, yeşil biber, kaşar biber', en: 'Mushroom, tofu cheese, pickles, onions, yellow pepper, green pepper, red pepper' } },
{ id: 'w2', name: { tr: 'Tavuklu Wrap', en: 'Chicken Wrap' }, description: { tr: 'Salatalık doğası, turşu soğan, kısma biber, marul, kon türfü turşu', en: 'Cucumber, pickled onions, red pepper, lettuce, pickles' } },
],
},
{
id: 'mantilar',
emoji: '🥟',
label: { tr: 'Mantılar', en: 'Manti & Ravioli' },
items: [
{ id: 'mt1', name: { tr: 'Ev Yapımı Mantı', en: 'Homemade Manti' }, description: { tr: 'Yoğurt ve sas eşliğince', en: 'Served with yogurt and sauce' } },
{ id: 'mt2', name: { tr: 'Cevizli Mantı', en: 'Walnut Manti' }, description: { tr: 'Yoğurt ve sas eşliğince', en: 'Served with yogurt and sauce' } },
{ id: 'mt3', name: { tr: 'Akdeniz Mantısı', en: 'Mediterranean Manti' }, description: { tr: 'Fesleğen, maydanoz, cziz, yoğurtlu sos', en: 'Basil, parsley, walnuts, yogurt sauce' } },
],
},
{
id: 'makarnalar',
emoji: '🍝',
label: { tr: 'Makarnalar', en: 'Pastas' },
items: [
{ id: 'mk1', name: { tr: 'Penne Arrabbiata', en: 'Penne Arrabbiata' } },
{ id: 'mk2', name: { tr: 'Spaghetti al Pesto', en: 'Spaghetti al Pesto' } },
{ id: 'mk3', name: { tr: 'Fettuccine Alfredo', en: 'Fettuccine Alfredo' } },
{ id: 'mk4', name: { tr: 'Spagetti Bolonez', en: 'Spaghetti Bolognese' } },
],
},
{
id: 'ana-yemekler',
emoji: '🍗',
label: { tr: 'Ana Yemekler', en: 'Main Courses' },
items: [
{ id: 'ay1', name: { tr: 'Tavuk Izgara', en: 'Grilled Chicken' }, description: { tr: 'Tavuk göğsü, karamelize yeşil biber, soğan, yeşillik, domatesli cipsi', en: 'Chicken breast, caramelized green pepper, onions, greens, potato chips' } },
{ id: 'ay2', name: { tr: 'Tavuk Pirzola', en: 'Chicken Chops' }, description: { tr: 'Kalemime yeşil biber, soğan, yeşil fık, patates cipsi', en: 'Green pepper, onions, greens, potato chips' } },
{ id: 'ay3', name: { tr: 'Köfte', en: 'Meatballs' }, description: { tr: 'Maydanoz, yeşil biber, soğan, yeşillik, patlıcan cipsi', en: 'Parsley, green pepper, onions, greens, eggplant chips' } },
{ id: 'ay4', name: { tr: 'Karışık Izgara', en: 'Mixed Grill' }, description: { tr: 'Köfte, tavuk göğsü, kısık pırzola. Kalemima yeşil biber, soğan, yeşillik, patlıcan cipsi', en: 'Meatballs, chicken breast, chicken chops. Green pepper, onions, greens, eggplant chips' } },
{ id: 'ay5', name: { tr: 'Sac Kavurma 2 Kişilik', en: 'Sac Kavurma - For 2' }, description: { tr: 'Mevsim salatası ve patates cipsi eşliğinde', en: 'Served with seasonal salad and potato chips' } },
{ id: 'ay6', name: { tr: 'Günlük Balık Çeşitleri', en: 'Daily Fish Catch' } },
],
},
{
id: 'atistirmaliklar',
emoji: '🍟',
label: { tr: 'Atıştırmalıklar', en: 'Snacks' },
items: [
{ id: 'at1', name: { tr: 'Patates Cipsi', en: 'Potato Chips' } },
{ id: 'at2', name: { tr: 'Çıtır Tavuk', en: 'Crispy Chicken' }, description: { tr: 'Çıtır tavuk ve patates cipsi', en: 'Crispy chicken and potato chips' } },
{ id: 'at3', name: { tr: 'Peynir Tabağı', en: 'Cheese Platter' }, description: { tr: 'Ezine peyniri, tulum peyniri, ham, haşar, çeşit kaşar, kuru domates', en: 'Ezine cheese, tulum cheese, kaşar cheese, dried tomatoes' } },
{ id: 'at4', name: { tr: 'Bira Tabağı', en: 'Beer Platter' }, description: { tr: 'Roka & patates cipsi, çıtır tavuk, çeşit kaşar', en: 'Arugula & potato chips, crispy chicken, cheese varieties' } },
{ id: 'at5', name: { tr: 'Deniz Tabağı', en: 'Seafood Platter' }, description: { tr: 'Halk kalamar, çıtır karides, roka', en: 'Calamari rings, crispy shrimp, arugula' } },
],
},
{
id: 'sicak-icecekler',
emoji: '☕',
label: { tr: 'Sıcak İçecekler & Kahveler', en: 'Hot Drinks & Coffees' },
items: [
{ id: 'si1', name: { tr: 'Çay', en: 'Tea' } },
{ id: 'si2', name: { tr: 'Türk Kahvesi', en: 'Turkish Coffee' } },
{ id: 'si3', name: { tr: 'Duble Türk Kahvesi', en: 'Double Turkish Coffee' } },
{ id: 'si4', name: { tr: 'Filtre Kahve', en: 'Filter Coffee' } },
{ id: 'si5', name: { tr: 'Espresso', en: 'Espresso' } },
{ id: 'si6', name: { tr: 'Americano', en: 'Americano' } },
{ id: 'si7', name: { tr: 'Latte', en: 'Latte' } },
{ id: 'si8', name: { tr: 'Cappuccino', en: 'Cappuccino' } },
],
},
{
id: 'soguk-icecekler',
emoji: '🥤',
label: { tr: 'Soğuk İçecekler', en: 'Cold Drinks' },
items: [
{ id: 'so1', name: { tr: 'Su', en: 'Water' } },
{ id: 'so2', name: { tr: 'Soda', en: 'Sparkling Water' } },
{ id: 'so3', name: { tr: 'Meyveli Soda', en: 'Fruit Sparkling Water' } },
{ id: 'so4', name: { tr: 'Ayran', en: 'Ayran' } },
{ id: 'so5', name: { tr: 'Limonata', en: 'Lemonade' } },
{ id: 'so6', name: { tr: 'Churchill', en: 'Churchill' } },
{ id: 'so7', name: { tr: 'Coca-Cola / Fanta / Sprite', en: 'Coca-Cola / Fanta / Sprite' } },
{ id: 'so8', name: { tr: 'Ice Tea', en: 'Ice Tea' } },
{ id: 'so9', name: { tr: 'Red Bull', en: 'Red Bull' } },
{ id: 'so10', name: { tr: 'Vişne Suyu', en: 'Cherry Juice' } },
{ id: 'so11', name: { tr: 'Portakal Suyu', en: 'Orange Juice' } },
{ id: 'so12', name: { tr: 'Nar Suyu', en: 'Pomegranate Juice' } },
],
},
{
id: 'soguk-kahveler',
emoji: '🧋',
label: { tr: 'Soğuk Kahveler', en: 'Cold Coffees' },
items: [
{ id: 'sk1', name: { tr: 'Iced Americano', en: 'Iced Americano' } },
{ id: 'sk2', name: { tr: 'Iced Latte', en: 'Iced Latte' } },
{ id: 'sk3', name: { tr: 'Iced Mocha', en: 'Iced Mocha' } },
],
},
{
id: 'frozen',
emoji: '🧊',
label: { tr: 'Frozen', en: 'Frozen' },
items: [
{ id: 'fz1', name: { tr: 'Çilek', en: 'Strawberry' } },
{ id: 'fz2', name: { tr: 'Mango', en: 'Mango' } },
{ id: 'fz3', name: { tr: 'Orman Meyvesi', en: 'Forest Fruits' } },
],
},
{
id: 'milkshakes',
emoji: '🥛',
label: { tr: 'Milkshakes', en: 'Milkshakes' },
items: [
{ id: 'ms1', name: { tr: 'Muz', en: 'Banana' } },
{ id: 'ms2', name: { tr: 'Vanilya', en: 'Vanilla' } },
{ id: 'ms3', name: { tr: 'Çilek', en: 'Strawberry' } },
{ id: 'ms4', name: { tr: 'Çikolata', en: 'Chocolate' } },
{ id: 'ms5', name: { tr: 'Mango', en: 'Mango' } },
{ id: 'ms6', name: { tr: 'Orman Meyvesi', en: 'Forest Fruits' } },
{ id: 'ms7', name: { tr: 'Espresso', en: 'Espresso' } },
],
},
{
id: 'refreshers',
emoji: '🌿',
label: { tr: 'Refreshers', en: 'Refreshers' },
items: [
{ id: 'rf1', name: { tr: 'Limonata', en: 'Lemonade' } },
{ id: 'rf2', name: { tr: 'Nar Suyu', en: 'Pomegranate Juice' } },
{ id: 'rf3', name: { tr: 'Portakal Suyu', en: 'Orange Juice' } },
{ id: 'rf4', name: { tr: 'Cool Lime', en: 'Cool Lime' } },
{ id: 'rf5', name: { tr: 'Rooibos Peach', en: 'Rooibos Peach' } },
],
},
{
id: 'biralar',
emoji: '🍺',
label: { tr: 'Biralar', en: 'Beers' },
items: [
{ id: 'br1', name: { tr: 'Tuborg Gold', en: 'Tuborg Gold' }, description: { tr: '50 cl', en: '50 cl' } },
{ id: 'br2', name: { tr: 'Tuborg Filtresiz', en: 'Tuborg Unfiltered' }, description: { tr: '50 cl', en: '50 cl' } },
{ id: 'br3', name: { tr: 'Tuborg Amber', en: 'Tuborg Amber' }, description: { tr: '50 cl', en: '50 cl' } },
{ id: 'br4', name: { tr: 'Carlsberg', en: 'Carlsberg' }, description: { tr: '50 cl', en: '50 cl' } },
{ id: 'br5', name: { tr: 'Efes Malt', en: 'Efes Malt' }, description: { tr: '50 cl', en: '50 cl' } },
{ id: 'br6', name: { tr: 'Efes Green', en: 'Efes Green' }, description: { tr: '50 cl', en: '50 cl' } },
{ id: 'br7', name: { tr: 'Stella Artois', en: 'Stella Artois' }, description: { tr: '44 cl', en: '44 cl' } },
{ id: 'br8', name: { tr: "Beck's", en: "Beck's" }, description: { tr: '33 cl', en: '33 cl' } },
{ id: 'br9', name: { tr: 'Bud', en: 'Bud' }, description: { tr: '33 cl', en: '33 cl' } },
],
},
{
id: 'saraplar',
emoji: '🍷',
label: { tr: 'Şaraplar', en: 'Wines' },
items: [
{ id: 'sr1', name: { tr: 'Beyaz Kadeh', en: 'White Glass' } },
{ id: 'sr2', name: { tr: 'Rosé Kadeh', en: 'Rosé Glass' } },
{ id: 'sr3', name: { tr: 'Kırmızı Kadeh', en: 'Red Glass' } },
{ id: 'sr4', name: { tr: 'Sevilen Colombard Semillon Beyaz Şişe', en: 'Sevilen Colombard Semillon White Bottle' } },
{ id: 'sr5', name: { tr: 'Sevilen Letter R Blush Şişe', en: 'Sevilen Letter R Blush Bottle' } },
{ id: 'sr6', name: { tr: 'Sevilen Parsel II Kırmızı Şişe', en: 'Sevilen Parsel II Red Bottle' } },
],
},
{
id: 'alkolsuz-kokteyller',
emoji: '🧃',
label: { tr: 'Alkolsüz Kokteyller', en: 'Mocktails' },
items: [
{ id: 'ak1', name: { tr: 'Golden Jüpiter', en: 'Golden Jupiter' }, description: { tr: 'Red Bull Peach Edition, fesleğen, portakal suyu, limon', en: 'Red Bull Peach Edition, basil, orange juice, lemon' } },
{ id: 'ak2', name: { tr: 'Venus Wave', en: 'Venus Wave' }, description: { tr: 'Red Bull White Edition, ananas suyu, lime', en: 'Red Bull White Edition, pineapple juice, lime' } },
{ id: 'ak3', name: { tr: 'Kozmos Twist', en: 'Kozmos Twist' }, description: { tr: 'Red Bull White Edition, portakal, lime, soda', en: 'Red Bull White Edition, orange, lime, sparkling water' } },
],
},
{
id: 'house-kokteyller',
emoji: '🍹',
label: { tr: 'House Kokteyller', en: 'House Cocktails' },
items: [
{ id: 'hk1', name: { tr: 'Grass', en: 'Grass' }, description: { tr: 'Gin, yeşil erik, buzlu turşu, limon suyu, greyfurt, portakal', en: 'Gin, green plum, ice pickle, lemon juice, grapefruit, orange' } },
{ id: 'hk2', name: { tr: 'Strawberry Hibiscus', en: 'Strawberry Hibiscus' }, description: { tr: 'Hibiscus çayı, greyfurt, Cointreau, Casemigos teklila', en: 'Hibiscus tea, grapefruit, Cointreau, Casamigos tequila' } },
{ id: 'hk3', name: { tr: 'Satsuma', en: 'Satsuma' }, description: { tr: 'Satsuma, greyfurt, portakal, votka', en: 'Satsuma, grapefruit, orange, vodka' } },
{ id: 'hk4', name: { tr: 'Chili Mango', en: 'Chili Mango' }, description: { tr: 'Mango, Antrout taberi, turunç, kamişeri', en: 'Mango, chili pepper, citrus' } },
{ id: 'hk5', name: { tr: 'Black Mulberry', en: 'Black Mulberry' }, description: { tr: 'Karadut, lime, sweet & sour, votka', en: 'Black mulberry, lime, sweet & sour, vodka' } },
{ id: 'hk6', name: { tr: 'Lunar Bloom', en: 'Lunar Bloom' }, description: { tr: 'Beyaz çay, şeftali, beyaz şarap', en: 'White tea, peach, white wine' } },
{ id: 'hk7', name: { tr: 'White Peach', en: 'White Peach' }, description: { tr: 'Beyaz çay, şeftali, rom', en: 'White tea, peach, rum' } },
{ id: 'hk8', name: { tr: 'Kozmos', en: 'Kozmos' }, description: { tr: 'Cinzano, sweet & sour, ananas suyu, limon suyu, Kozmos mavi', en: 'Cinzano, sweet & sour, pineapple juice, lemon juice, Kozmos blue' } },
],
},
{
id: 'raki',
emoji: '🥃',
label: { tr: 'Rakı', en: 'Raki' },
items: [
{ id: 'rk1', name: { tr: 'Lokal Rakı Tek', en: 'Local Raki Single' } },
{ id: 'rk2', name: { tr: 'Lokal Rakı Duble', en: 'Local Raki Double' } },
{ id: 'rk3', name: { tr: 'Yeni Rakı Özel Seri', en: 'Yeni Raki Special Series' }, description: { tr: '35 cl', en: '35 cl' } },
{ id: 'rk4', name: { tr: 'Yeni Rakı Özel Seri', en: 'Yeni Raki Special Series' }, description: { tr: '50 cl', en: '50 cl' } },
{ id: 'rk5', name: { tr: 'Yeni Rakı Özel Seri', en: 'Yeni Raki Special Series' }, description: { tr: '70 cl', en: '70 cl' } },
{ id: 'rk6', name: { tr: 'Beylerbeyi Göbek', en: 'Beylerbeyi Gobek' }, description: { tr: '35 cl', en: '35 cl' } },
{ id: 'rk7', name: { tr: 'Beylerbeyi Göbek', en: 'Beylerbeyi Gobek' }, description: { tr: '50 cl', en: '50 cl' } },
{ id: 'rk8', name: { tr: 'Beylerbeyi Göbek', en: 'Beylerbeyi Gobek' }, description: { tr: '70 cl', en: '70 cl' } },
],
},
{
id: 'klasik-kokteyller',
emoji: '🍸',
label: { tr: 'Klasik Kokteyller', en: 'Classic Cocktails' },
items: [
{ id: 'kk1', name: { tr: 'Espresso Martini', en: 'Espresso Martini' } },
{ id: 'kk2', name: { tr: 'Mojito', en: 'Mojito' } },
{ id: 'kk3', name: { tr: 'Margarita', en: 'Margarita' } },
{ id: 'kk4', name: { tr: 'Aperol Spritz', en: 'Aperol Spritz' } },
{ id: 'kk5', name: { tr: 'Lynchburg Lemonade', en: 'Lynchburg Lemonade' } },
{ id: 'kk6', name: { tr: 'Whiskey Sour', en: 'Whiskey Sour' } },
{ id: 'kk7', name: { tr: 'Long Island Iced Tea', en: 'Long Island Iced Tea' } },
{ id: 'kk8', name: { tr: 'Negroni', en: 'Negroni' } },
],
},
{
id: 'shots',
emoji: '🥃',
label: { tr: 'Shots', en: 'Shots' },
items: [
{ id: 'sh1', name: { tr: 'Olmeca Gold', en: 'Olmeca Gold' } },
{ id: 'sh2', name: { tr: 'Olmeca Silver', en: 'Olmeca Silver' } },
{ id: 'sh3', name: { tr: 'Olmeca Dark Chocolate', en: 'Olmeca Dark Chocolate' } },
{ id: 'sh4', name: { tr: 'Aperol Spritz', en: 'Aperol Spritz' } },
{ id: 'sh5', name: { tr: 'Lynchburg Lemonade', en: 'Lynchburg Lemonade' } },
{ id: 'sh6', name: { tr: 'Whiskey Sour', en: 'Whiskey Sour' } },
],
},
{
id: 'spirits',
emoji: '🫙',
label: { tr: 'Spirits', en: 'Spirits' },
items: [
{ id: 'sp1', name: { tr: 'Absolut Vodka', en: 'Absolut Vodka' } },
{ id: 'sp2', name: { tr: 'Absolut Vanilla', en: 'Absolut Vanilla' } },
{ id: 'sp3', name: { tr: 'Beefeater Gin', en: 'Beefeater Gin' } },
{ id: 'sp4', name: { tr: 'Beefeater Pink', en: 'Beefeater Pink' } },
{ id: 'sp5', name: { tr: 'Beefeater Blood Orange', en: 'Beefeater Blood Orange' } },
],
},
]
export async function getCategories(): Promise<Category[]> {
try {
const categories = await prisma.category.findMany({
orderBy: { order: 'asc' },
include: {
items: {
orderBy: { order: 'asc' },
},
},
})
return categories.map((c) => ({
id: c.id,
emoji: c.emoji,
label: { tr: c.labelTr, en: c.labelEn },
items: c.items.map((i) => ({
id: i.id,
emoji: i.emoji || undefined,
image: i.image || undefined,
name: { tr: i.nameTr, en: i.nameEn },
description:
i.descriptionTr && i.descriptionEn
? { tr: i.descriptionTr, en: i.descriptionEn }
: undefined,
price: i.price !== null ? i.price : undefined,
highlight: i.highlight,
})),
}))
} catch (error) {
console.error('Error reading from database:', error)
return []
}
}
export async function saveCategories(categories: Category[]): Promise<void> {
try {
await prisma.$transaction(async (tx) => {
// 1. Delete all existing records (mimics overwriting the JSON file)
await tx.menuItem.deleteMany()
await tx.category.deleteMany()
// 2. Insert new records
for (let cIndex = 0; cIndex < categories.length; cIndex++) {
const c = categories[cIndex]
await tx.category.create({
data: {
id: c.id,
emoji: c.emoji,
labelTr: c.label.tr,
labelEn: c.label.en || c.label.tr,
order: cIndex,
},
})
for (let iIndex = 0; iIndex < c.items.length; iIndex++) {
const i = c.items[iIndex]
await tx.menuItem.create({
data: {
id: i.id,
categoryId: c.id,
emoji: i.emoji || null,
image: i.image || null,
nameTr: i.name.tr,
nameEn: i.name.en || i.name.tr,
descriptionTr: i.description?.tr || null,
descriptionEn: i.description?.en || null,
price: i.price ?? null,
highlight: i.highlight ?? false,
order: iIndex,
},
})
}
}
})
} catch (error) {
console.error('Error saving to database:', error)
throw new Error('Failed to save menu data to database')
}
}
+15
View File
@@ -0,0 +1,15 @@
import { PrismaClient } from '@prisma/client'
const prismaClientSingleton = () => {
return new PrismaClient()
}
declare global {
var prismaGlobal: undefined | ReturnType<typeof prismaClientSingleton>
}
const prisma = globalThis.prismaGlobal ?? prismaClientSingleton()
export default prisma
if (process.env.NODE_ENV !== 'production') globalThis.prismaGlobal = prisma
+949
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -14,13 +14,17 @@
"react-dom": "19.2.4"
},
"devDependencies": {
"@prisma/client": "^6.19.3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"dotenv": "^17.4.2",
"eslint": "^9",
"eslint-config-next": "16.2.7",
"prisma": "^6.19.3",
"tailwindcss": "^4",
"tsx": "^4.22.4",
"typescript": "^5"
}
}
+32
View File
@@ -0,0 +1,32 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Category {
id String @id
emoji String
labelTr String
labelEn String
order Int @default(0)
items MenuItem[]
}
model MenuItem {
id String @id
emoji String?
image String?
nameTr String
nameEn String
descriptionTr String?
descriptionEn String?
price Float?
highlight Boolean @default(false)
order Int @default(0)
categoryId String
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
}
+22
View File
@@ -7,6 +7,26 @@ const defaultLocale = 'tr'
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
// Admin Auth Logic
if (pathname.startsWith('/admin')) {
const session = request.cookies.get('admin_session')?.value
const secret = process.env.ADMIN_SECRET || 'secret'
const isLoginPage = pathname === '/admin/login'
if (!session || session !== secret) {
if (!isLoginPage) {
return NextResponse.redirect(new URL('/admin/login', request.url))
}
} else {
if (isLoginPage) {
return NextResponse.redirect(new URL('/admin', request.url))
}
}
return NextResponse.next()
}
// Locale Logic
const hasLocale = locales.some(
(locale) => pathname === `/${locale}` || pathname.startsWith(`/${locale}/`)
)
@@ -15,6 +35,8 @@ export function proxy(request: NextRequest) {
request.nextUrl.pathname = `/${defaultLocale}${pathname}`
return NextResponse.redirect(request.nextUrl)
}
return NextResponse.next()
}
export const config = {
+78
View File
@@ -0,0 +1,78 @@
import { PrismaClient } from '@prisma/client'
import fs from 'fs'
import path from 'path'
const prisma = new PrismaClient()
type LocalizedString = { tr: string; en: string }
type JsonMenuItem = {
id: string
emoji?: string
image?: string
name: LocalizedString
description?: LocalizedString
price?: number
highlight?: boolean
}
type JsonCategory = {
id: string
emoji: string
label: LocalizedString
items: JsonMenuItem[]
}
async function migrate() {
console.log('Starting migration...')
const filePath = path.join(process.cwd(), 'data', 'menu.json')
const fileContent = fs.readFileSync(filePath, 'utf-8')
const categories: JsonCategory[] = JSON.parse(fileContent)
// Clear existing data (if any)
await prisma.menuItem.deleteMany({})
await prisma.category.deleteMany({})
console.log('Cleared existing data')
for (let cIndex = 0; cIndex < categories.length; cIndex++) {
const cat = categories[cIndex]
await prisma.category.create({
data: {
id: cat.id,
emoji: cat.emoji,
labelTr: cat.label.tr,
labelEn: cat.label.en,
order: cIndex,
items: {
create: cat.items.map((item, iIndex) => ({
id: item.id,
emoji: item.emoji || null,
image: item.image || null,
nameTr: item.name.tr,
nameEn: item.name.en,
descriptionTr: item.description?.tr || null,
descriptionEn: item.description?.en || null,
price: item.price !== undefined ? item.price : null,
highlight: item.highlight || false,
order: iIndex,
}))
}
}
})
console.log(`Migrated category: ${cat.label.tr}`)
}
console.log('Migration complete!')
}
migrate()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})