Compare commits

...

3 Commits

Author SHA1 Message Date
AyrisAI
6f04e39bf3 feat: add admin user management (create, password change) 2026-05-15 19:12:42 +03:00
AyrisAI
a2ba52e69b chore: migrate middleware to proxy as per Next.js 2026 convention 2026-05-15 19:11:46 +03:00
AyrisAI
09a105cd1e feat: add prisma support, admin panel and auth 2026-05-15 19:11:17 +03:00
32 changed files with 3817 additions and 441 deletions

View File

@@ -15,6 +15,9 @@ WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Generate Prisma Client
RUN npx prisma generate
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build

191
app/admin/actions.ts Normal file
View File

@@ -0,0 +1,191 @@
'use server';
import { prisma } from '@/app/lib/prisma';
import { revalidatePath } from 'next/cache';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import bcrypt from 'bcryptjs';
import { encrypt } from '@/app/lib/auth';
/* ────── Auth Actions ────── */
export async function authenticate(formData: FormData) {
const username = formData.get('username') as string;
const password = formData.get('password') as string;
const user = await prisma.user.findUnique({ where: { username } });
if (!user || !(await bcrypt.compare(password, user.password))) {
return { error: 'Geçersiz kullanıcı adı veya şifre' };
}
// Create session
const expires = new Date(Date.now() + 2 * 60 * 60 * 1000); // 2 hours
const session = await encrypt({ userId: user.id, username: user.username, expires });
(await cookies()).set('session', session, { expires, httpOnly: true });
redirect('/admin');
}
export async function signout() {
(await cookies()).set('session', '', { expires: new Date(0) });
redirect('/login');
}
/* ────── User Actions ────── */
export async function createAdmin(formData: FormData) {
const username = formData.get('username') as string;
const password = formData.get('password') as string;
const hashedPassword = await bcrypt.hash(password, 10);
await prisma.user.create({
data: {
username,
password: hashedPassword,
},
});
revalidatePath('/admin/users');
}
export async function updateAdminPassword(id: string, formData: FormData) {
const password = formData.get('password') as string;
const hashedPassword = await bcrypt.hash(password, 10);
await prisma.user.update({
where: { id },
data: {
password: hashedPassword,
},
});
revalidatePath('/admin/users');
}
export async function deleteAdmin(id: string) {
const session = await encrypt({ expires: new Date(0) }); // placeholder check
// Don't allow deleting self if we had current user id here,
// but for now simple delete
await prisma.user.delete({ where: { id } });
revalidatePath('/admin/users');
}
export async function createCategory(formData: FormData) {
const title = formData.get('title') as string;
const externalId = formData.get('externalId') as string;
await prisma.category.create({
data: {
title,
externalId: externalId || null,
restaurantId: (await prisma.restaurant.findFirst())?.id || '',
},
});
revalidatePath('/admin/categories');
revalidatePath('/');
}
export async function updateCategory(id: string, formData: FormData) {
const title = formData.get('title') as string;
const externalId = formData.get('externalId') as string;
await prisma.category.update({
where: { id },
data: {
title,
externalId: externalId || null,
},
});
revalidatePath('/admin/categories');
revalidatePath('/');
}
export async function deleteCategory(id: string) {
// Optional: check if there are items first, or let prisma handles cascade if configured
await prisma.item.deleteMany({ where: { categoryId: id } });
await prisma.category.delete({ where: { id } });
revalidatePath('/admin/categories');
revalidatePath('/');
}
/* ────── Item Actions ────── */
export async function createItem(formData: FormData) {
const name = formData.get('name') as string;
const categoryId = formData.get('categoryId') as string;
const ingredients = formData.get('ingredients') as string;
const tasteProfile = formData.get('tasteProfile') as string;
const grapeVariety = formData.get('grapeVariety') as string;
const priceInput = formData.get('price') as string;
let price;
try {
// Try to parse as JSON if it looks like an object, otherwise keep as string
price = (priceInput.startsWith('{') || priceInput.startsWith('['))
? JSON.parse(priceInput)
: priceInput;
} catch (e) {
price = priceInput;
}
await prisma.item.create({
data: {
name,
categoryId,
ingredients: ingredients || null,
tasteProfile: tasteProfile || null,
grapeVariety: grapeVariety || null,
price,
},
});
revalidatePath('/admin/items');
revalidatePath('/');
}
export async function updateItem(id: string, formData: FormData) {
const name = formData.get('name') as string;
const categoryId = formData.get('categoryId') as string;
const ingredients = formData.get('ingredients') as string;
const tasteProfile = formData.get('tasteProfile') as string;
const grapeVariety = formData.get('grapeVariety') as string;
const priceInput = formData.get('price') as string;
let price;
try {
price = (priceInput.startsWith('{') || priceInput.startsWith('['))
? JSON.parse(priceInput)
: priceInput;
} catch (e) {
price = priceInput;
}
await prisma.item.update({
where: { id },
data: {
name,
categoryId,
ingredients: ingredients || null,
tasteProfile: tasteProfile || null,
grapeVariety: grapeVariety || null,
price,
},
});
revalidatePath('/admin/items');
revalidatePath('/');
}
export async function deleteItem(id: string) {
await prisma.item.delete({ where: { id } });
revalidatePath('/admin/items');
revalidatePath('/');
}

322
app/admin/admin.css Normal file
View File

@@ -0,0 +1,322 @@
.admin-layout {
display: flex;
min-height: 100vh;
background: var(--background);
color: var(--foreground);
}
.admin-sidebar {
width: 280px;
background: var(--surface);
border-right: 1px solid var(--border-subtle);
display: flex;
flex-direction: column;
position: sticky;
top: 0;
height: 100vh;
}
.admin-sidebar-header {
padding: 2rem;
border-bottom: 1px solid var(--border-subtle);
}
.admin-logo {
font-family: 'Playfair Display', serif;
font-size: 1.5rem;
color: #fff;
text-decoration: none;
letter-spacing: 0.1em;
}
.admin-logo span {
color: var(--gold);
font-size: 0.8rem;
font-family: 'Inter', sans-serif;
letter-spacing: 0.3em;
margin-left: 0.5rem;
}
.admin-nav {
padding: 1.5rem 0;
flex: 1;
}
.admin-nav-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.85rem 2rem;
color: var(--text-secondary);
text-decoration: none;
font-size: 0.9rem;
transition: all 0.3s;
border-left: 3px solid transparent;
}
.admin-nav-item svg {
width: 18px;
height: 18px;
}
.admin-nav-item:hover {
color: #fff;
background: rgba(255, 255, 255, 0.03);
}
.admin-nav-item.active {
color: var(--gold);
background: rgba(201, 169, 110, 0.05);
border-left-color: var(--gold);
}
.admin-sidebar-footer {
padding: 1.5rem 2rem;
border-top: 1px solid var(--border-subtle);
}
.admin-view-site {
display: flex;
align-items: center;
gap: 0.75rem;
color: var(--text-muted);
text-decoration: none;
font-size: 0.8rem;
transition: color 0.3s;
}
.admin-view-site:hover {
color: var(--gold);
}
.admin-view-site svg {
width: 14px;
height: 14px;
}
.admin-main {
flex: 1;
display: flex;
flex-direction: column;
}
.admin-header {
height: 70px;
padding: 0 2rem;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border-subtle);
background: rgba(10, 10, 15, 0.5);
backdrop-filter: blur(10px);
}
.admin-header-title {
font-size: 1.1rem;
font-weight: 500;
color: #fff;
}
.admin-user {
font-size: 0.85rem;
color: var(--text-secondary);
}
.admin-content {
padding: 2rem;
overflow-y: auto;
}
/* Dashboard Cards */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
margin-bottom: 3rem;
}
.stat-card {
background: var(--surface-elevated);
padding: 1.5rem;
border-radius: 12px;
border: 1px solid var(--border-subtle);
transition: transform 0.3s, border-color 0.3s;
}
.stat-card:hover {
transform: translateY(-5px);
border-color: var(--border-glow);
}
.stat-label {
font-size: 0.75rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.1em;
margin-bottom: 0.5rem;
}
.stat-value {
font-size: 2rem;
font-weight: 600;
color: var(--gold);
}
/* Tables & Lists */
.admin-card {
background: var(--surface);
border-radius: 16px;
border: 1px solid var(--border-subtle);
overflow: hidden;
}
.card-header {
padding: 1.5rem 2rem;
border-bottom: 1px solid var(--border-subtle);
display: flex;
justify-content: space-between;
align-items: center;
}
.card-title {
font-size: 1.1rem;
color: #fff;
}
.admin-btn {
background: var(--gold);
color: #000;
border: none;
padding: 0.6rem 1.2rem;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
display: flex;
align-items: center;
gap: 0.5rem;
}
.admin-btn:hover {
background: var(--gold-light);
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(201, 169, 110, 0.2);
}
.admin-table {
width: 100%;
border-collapse: collapse;
}
.admin-table th {
text-align: left;
padding: 1rem 2rem;
font-size: 0.75rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid var(--border-subtle);
}
.admin-table td {
padding: 1.25rem 2rem;
font-size: 0.9rem;
border-bottom: 1px solid var(--border-subtle);
}
.admin-table tr:last-child td {
border-bottom: none;
}
.admin-table tr:hover {
background: rgba(255, 255, 255, 0.02);
}
.actions-cell {
display: flex;
gap: 0.75rem;
}
.action-btn {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
transition: color 0.3s;
}
.action-btn:hover {
color: var(--gold);
}
.action-btn.delete:hover {
color: var(--accent-rose);
}
.badge {
padding: 0.25rem 0.6rem;
border-radius: 4px;
font-size: 0.7rem;
font-weight: 600;
background: rgba(201, 169, 110, 0.1);
color: var(--gold);
}
/* Forms */
.admin-form {
padding: 2rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
font-size: 0.85rem;
color: var(--text-secondary);
}
.admin-input, .admin-select, .admin-textarea {
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border-subtle);
border-radius: 8px;
padding: 0.75rem 1rem;
color: #fff;
font-family: inherit;
transition: all 0.3s;
}
.admin-input:focus, .admin-select:focus, .admin-textarea:focus {
outline: none;
border-color: var(--gold);
background: rgba(255, 255, 255, 0.08);
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(5px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 2rem;
}
.modal-content {
background: var(--surface);
border: 1px solid var(--border-subtle);
border-radius: 20px;
width: 100%;
max-width: 600px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
}

View File

@@ -0,0 +1,73 @@
'use client';
import React, { useState } from 'react';
import CategoryForm from '../components/CategoryForm';
import DeleteButton from '../components/DeleteButton';
import { deleteCategory } from '../actions';
interface CategoryManagerProps {
categories: any[];
}
export default function CategoryManager({ categories }: CategoryManagerProps) {
const [showForm, setShowForm] = useState(false);
const [editingCategory, setEditingCategory] = useState<any>(null);
const handleEdit = (category: any) => {
setEditingCategory(category);
setShowForm(true);
};
const handleClose = () => {
setShowForm(false);
setEditingCategory(null);
};
return (
<div className="admin-categories">
<div className="admin-page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
<div>
<h1>Kategoriler</h1>
<p>Menü kategorilerini yönetin</p>
</div>
<button className="admin-btn" onClick={() => setShowForm(true)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{width: '16px'}}><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></svg>
Yeni Kategori
</button>
</div>
<div className="admin-card">
<table className="admin-table">
<thead>
<tr>
<th>Başlık</th>
<th>Harici ID</th>
<th>Ürün Sayısı</th>
<th>İşlemler</th>
</tr>
</thead>
<tbody>
{categories.map((cat) => (
<tr key={cat.id}>
<td style={{ fontWeight: 500, color: '#fff' }}>{cat.title}</td>
<td><code className="badge">{cat.externalId}</code></td>
<td>{cat._count?.items || 0} Ürün</td>
<td className="actions-cell">
<button className="action-btn" onClick={() => handleEdit(cat)}>Düzenle</button>
<DeleteButton onDelete={async () => { await deleteCategory(cat.id); }} />
</td>
</tr>
))}
</tbody>
</table>
</div>
{showForm && (
<CategoryForm
initialData={editingCategory}
onClose={handleClose}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,18 @@
import React from 'react';
import { prisma } from '@/app/lib/prisma';
import CategoryManager from './CategoryManager';
export const dynamic = 'force-dynamic';
export default async function AdminCategoriesPage() {
const categories = await prisma.category.findMany({
include: {
_count: {
select: { items: true }
}
},
orderBy: { createdAt: 'asc' }
});
return <CategoryManager categories={categories} />;
}

View File

@@ -0,0 +1,22 @@
'use client';
import React from 'react';
import Link from 'next/use-pathname';
import { usePathname } from 'next/navigation';
import NextLink from 'next/link';
interface NavItemProps {
href: string;
children: React.ReactNode;
}
export default function AdminNavItem({ href, children }: NavItemProps) {
const pathname = usePathname();
const isActive = pathname === href;
return (
<NextLink href={href} className={`admin-nav-item ${isActive ? 'active' : ''}`}>
{children}
</NextLink>
);
}

View File

@@ -0,0 +1,66 @@
'use client';
import React, { useState } from 'react';
import { createCategory, updateCategory } from '../actions';
interface CategoryFormProps {
initialData?: any;
onClose: () => void;
}
export default function CategoryForm({ initialData, onClose }: CategoryFormProps) {
const [loading, setLoading] = useState(false);
async function handleSubmit(formData: FormData) {
setLoading(true);
if (initialData) {
await updateCategory(initialData.id, formData);
} else {
await createCategory(formData);
}
setLoading(false);
onClose();
}
return (
<div className="modal-overlay">
<div className="modal-content">
<div className="card-header">
<h2 className="card-title">{initialData ? 'Kategoriyi Düzenle' : 'Yeni Kategori Ekle'}</h2>
<button onClick={onClose} className="action-btn"></button>
</div>
<form action={handleSubmit} className="admin-form">
<div className="form-group">
<label htmlFor="title">Kategori Başlığı</label>
<input
id="title"
name="title"
defaultValue={initialData?.title}
className="admin-input"
placeholder="Örn: Klasik Kokteyller"
required
/>
</div>
<div className="form-group">
<label htmlFor="externalId">Harici ID (İkon eşleşmesi için)</label>
<input
id="externalId"
name="externalId"
defaultValue={initialData?.externalId}
className="admin-input"
placeholder="Örn: classic_cocktails"
/>
</div>
<div style={{ display: 'flex', gap: '1rem', marginTop: '1rem' }}>
<button type="submit" className="admin-btn" disabled={loading}>
{loading ? 'Kaydediliyor...' : 'Kaydet'}
</button>
<button type="button" onClick={onClose} className="admin-btn" style={{ background: 'rgba(255,255,255,0.05)', color: '#fff' }}>
İptal
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,29 @@
'use client';
import React from 'react';
interface DeleteButtonProps {
onDelete: () => Promise<void>;
}
export default function DeleteButton({ onDelete }: DeleteButtonProps) {
const [loading, setLoading] = React.useState(false);
async function handleClick() {
if (confirm('Bu öğeyi silmek istediğinizden emin misiniz?')) {
setLoading(true);
await onDelete();
setLoading(false);
}
}
return (
<button
onClick={handleClick}
className="action-btn delete"
disabled={loading}
>
{loading ? '...' : 'Sil'}
</button>
);
}

View File

@@ -0,0 +1,112 @@
'use client';
import React, { useState } from 'react';
import { createItem, updateItem } from '../actions';
interface ItemFormProps {
categories: any[];
initialData?: any;
onClose: () => void;
}
export default function ItemForm({ categories, initialData, onClose }: ItemFormProps) {
const [loading, setLoading] = useState(false);
async function handleSubmit(formData: FormData) {
setLoading(true);
if (initialData) {
await updateItem(initialData.id, formData);
} else {
await createItem(formData);
}
setLoading(false);
onClose();
}
return (
<div className="modal-overlay">
<div className="modal-content">
<div className="card-header">
<h2 className="card-title">{initialData ? 'Ürünü Düzenle' : 'Yeni Ürün Ekle'}</h2>
<button onClick={onClose} className="action-btn"></button>
</div>
<form action={handleSubmit} className="admin-form">
<div className="form-group">
<label htmlFor="name">Ürün Adı</label>
<input
id="name"
name="name"
defaultValue={initialData?.name}
className="admin-input"
placeholder="Örn: Mojito"
required
/>
</div>
<div className="form-group">
<label htmlFor="categoryId">Kategori</label>
<select
id="categoryId"
name="categoryId"
defaultValue={initialData?.categoryId || categories[0]?.id}
className="admin-select"
required
>
{categories.map(cat => (
<option key={cat.id} value={cat.id}>{cat.title}</option>
))}
</select>
</div>
<div className="form-group">
<label htmlFor="price">Fiyat (Metin veya JSON)</label>
<textarea
id="price"
name="price"
defaultValue={typeof initialData?.price === 'object' ? JSON.stringify(initialData.price) : initialData?.price}
className="admin-textarea"
placeholder='Örn: 590 tl veya {"single": "370 tl", "double": "560 tl"}'
required
/>
</div>
<div className="form-group">
<label htmlFor="ingredients">İçerik</label>
<input
id="ingredients"
name="ingredients"
defaultValue={initialData?.ingredients}
className="admin-input"
placeholder="Örn: Rom, Nane, Lime"
/>
</div>
<div className="form-group">
<label htmlFor="tasteProfile">Tat Profili</label>
<input
id="tasteProfile"
name="tasteProfile"
defaultValue={initialData?.tasteProfile}
className="admin-input"
placeholder="Örn: Tatlı-Ekşi-Ferah"
/>
</div>
<div className="form-group">
<label htmlFor="grapeVariety">Üzüm Çeşidi (Şaraplar için)</label>
<input
id="grapeVariety"
name="grapeVariety"
defaultValue={initialData?.grapeVariety}
className="admin-input"
placeholder="Örn: Merlot"
/>
</div>
<div style={{ display: 'flex', gap: '1rem', marginTop: '1rem' }}>
<button type="submit" className="admin-btn" disabled={loading}>
{loading ? 'Kaydediliyor...' : 'Kaydet'}
</button>
<button type="button" onClick={onClose} className="admin-btn" style={{ background: 'rgba(255,255,255,0.05)', color: '#fff' }}>
İptal
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
'use client';
import React from 'react';
import { signout } from '../actions';
export default function SignOutButton() {
return (
<button
onClick={() => signout()}
className="action-btn"
style={{ fontSize: '0.8rem', color: 'var(--accent-rose)' }}
>
Çıkış Yap
</button>
);
}

View File

@@ -0,0 +1,78 @@
'use client';
import React, { useState } from 'react';
import { createAdmin, updateAdminPassword } from '../actions';
interface UserFormProps {
initialData?: any;
onClose: () => void;
}
export default function UserForm({ initialData, onClose }: UserFormProps) {
const [loading, setLoading] = useState(false);
async function handleSubmit(formData: FormData) {
setLoading(true);
if (initialData) {
await updateAdminPassword(initialData.id, formData);
} else {
await createAdmin(formData);
}
setLoading(false);
onClose();
}
return (
<div className="modal-overlay">
<div className="modal-content">
<div className="card-header">
<h2 className="card-title">{initialData ? 'Şifre Değiştir' : 'Yeni Admin Ekle'}</h2>
<button onClick={onClose} className="action-btn"></button>
</div>
<form action={handleSubmit} className="admin-form">
{!initialData && (
<div className="form-group">
<label htmlFor="username">Kullanıcı Adı</label>
<input
id="username"
name="username"
className="admin-input"
placeholder="Örn: ayrisdev"
required
/>
</div>
)}
{initialData && (
<div className="form-group">
<label>Kullanıcı Adı</label>
<input
className="admin-input"
value={initialData.username}
disabled
style={{ opacity: 0.6 }}
/>
</div>
)}
<div className="form-group">
<label htmlFor="password">{initialData ? 'Yeni Şifre' : 'Şifre'}</label>
<input
id="password"
name="password"
type="password"
className="admin-input"
required
/>
</div>
<div style={{ display: 'flex', gap: '1rem', marginTop: '1rem' }}>
<button type="submit" className="admin-btn" disabled={loading}>
{loading ? 'Kaydediliyor...' : 'Kaydet'}
</button>
<button type="button" onClick={onClose} className="admin-btn" style={{ background: 'rgba(255,255,255,0.05)', color: '#fff' }}>
İptal
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,91 @@
'use client';
import React, { useState } from 'react';
import ItemForm from '../components/ItemForm';
import DeleteButton from '../components/DeleteButton';
import { deleteItem } from '../actions';
interface ItemManagerProps {
items: any[];
categories: any[];
}
export default function ItemManager({ items, categories }: ItemManagerProps) {
const [showForm, setShowForm] = useState(false);
const [editingItem, setEditingItem] = useState<any>(null);
const handleEdit = (item: any) => {
setEditingItem(item);
setShowForm(true);
};
const handleClose = () => {
setShowForm(false);
setEditingItem(null);
};
return (
<div className="admin-items">
<div className="admin-page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
<div>
<h1>Ürünler</h1>
<p>Tüm menü öğelerini yönetin</p>
</div>
<button className="admin-btn" onClick={() => setShowForm(true)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{width: '16px'}}><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></svg>
Yeni Ürün Ekle
</button>
</div>
<div className="admin-card">
<table className="admin-table">
<thead>
<tr>
<th>Ürün Adı</th>
<th>Kategori</th>
<th>Fiyat</th>
<th>İşlemler</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id}>
<td>
<div style={{ fontWeight: 500, color: '#fff' }}>{item.name}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
{item.ingredients ? (item.ingredients.length > 50 ? item.ingredients.substring(0, 50) + '...' : item.ingredients) : 'İçerik yok'}
</div>
</td>
<td>
<span className="badge" style={{ background: 'rgba(124, 92, 191, 0.1)', color: 'var(--accent-purple)', borderColor: 'rgba(124, 92, 191, 0.2)' }}>
{item.category.title}
</span>
</td>
<td>
<div style={{ color: 'var(--gold)', fontWeight: 600 }}>
{typeof item.price === 'string'
? item.price
: Object.entries(item.price as object).map(([k, v]) => `${k}: ${v}`).join(', ')
}
</div>
</td>
<td className="actions-cell">
<button className="action-btn" onClick={() => handleEdit(item)}>Düzenle</button>
<DeleteButton onDelete={async () => { await deleteItem(item.id); }} />
</td>
</tr>
))}
</tbody>
</table>
</div>
{showForm && (
<ItemForm
categories={categories}
initialData={editingItem}
onClose={handleClose}
/>
)}
</div>
);
}

20
app/admin/items/page.tsx Normal file
View File

@@ -0,0 +1,20 @@
import React from 'react';
import { prisma } from '@/app/lib/prisma';
import ItemManager from './ItemManager';
export const dynamic = 'force-dynamic';
export default async function AdminItemsPage() {
const items = await prisma.item.findMany({
include: {
category: true
},
orderBy: { createdAt: 'desc' }
});
const categories = await prisma.category.findMany({
orderBy: { title: 'asc' }
});
return <ItemManager items={items} categories={categories} />;
}

62
app/admin/layout.tsx Normal file
View File

@@ -0,0 +1,62 @@
import React from 'react';
import Link from 'next/link';
import AdminNavItem from './components/AdminNavItem';
import SignOutButton from './components/SignOutButton';
import { getSession } from '@/app/lib/auth';
import './admin.css';
export default async function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getSession();
return (
<div className="admin-layout">
<aside className="admin-sidebar">
<div className="admin-sidebar-header">
<Link href="/admin" className="admin-logo">
LUNA <span>ADMIN</span>
</Link>
</div>
<nav className="admin-nav">
<AdminNavItem href="/admin">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" /><rect x="14" y="14" width="7" height="7" /><rect x="3" y="14" width="7" height="7" /></svg>
Dashboard
</AdminNavItem>
<AdminNavItem href="/admin/categories">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" /></svg>
Kategoriler
</AdminNavItem>
<AdminNavItem href="/admin/items">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" /></svg>
Ürünler
</AdminNavItem>
<AdminNavItem href="/admin/users">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M23 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" /></svg>
Kullanıcılar
</AdminNavItem>
</nav>
<div className="admin-sidebar-footer">
<Link href="/" target="_blank" className="admin-view-site">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /><polyline points="15 3 21 3 21 9" /><line x1="10" y1="14" x2="21" y2="3" /></svg>
Siteyi Görüntüle
</Link>
</div>
</aside>
<main className="admin-main">
<header className="admin-header">
<div className="admin-header-title">Panel</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '1.5rem' }}>
<div className="admin-user">{session?.username || 'Admin'}</div>
<SignOutButton />
</div>
</header>
<div className="admin-content">
{children}
</div>
</main>
</div>
);
}

44
app/admin/page.tsx Normal file
View File

@@ -0,0 +1,44 @@
import React from 'react';
import { prisma } from '@/app/lib/prisma';
export const dynamic = 'force-dynamic';
export default async function AdminDashboard() {
const categoryCount = await prisma.category.count();
const itemCount = await prisma.item.count();
const restaurant = await prisma.restaurant.findFirst();
return (
<div className="admin-dashboard">
<div className="admin-page-header">
<h1>Dashboard</h1>
<p>Luna Cocktail & More - Menü Özeti</p>
</div>
<div className="stats-grid">
<div className="stat-card">
<div className="stat-label">Toplam Kategori</div>
<div className="stat-value">{categoryCount}</div>
</div>
<div className="stat-card">
<div className="stat-label">Toplam Ürün</div>
<div className="stat-value">{itemCount}</div>
</div>
<div className="stat-card">
<div className="stat-label">Restoran İsmi</div>
<div className="stat-value" style={{ fontSize: '1.2rem' }}>{restaurant?.name || 'Yükleniyor...'}</div>
</div>
</div>
<div className="admin-card">
<div className="card-header">
<h2 className="card-title">Hızlı İşlemler</h2>
</div>
<div style={{ padding: '2rem', display: 'flex', gap: '1rem' }}>
<button className="admin-btn">Yeni Kategori Ekle</button>
<button className="admin-btn">Yeni Ürün Ekle</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,75 @@
'use client';
import React, { useState } from 'react';
import UserForm from '../components/UserForm';
import DeleteButton from '../components/DeleteButton';
import { deleteAdmin } from '../actions';
interface UserManagerProps {
users: any[];
}
export default function UserManager({ users }: UserManagerProps) {
const [showForm, setShowForm] = useState(false);
const [editingUser, setEditingUser] = useState<any>(null);
const handleEdit = (user: any) => {
setEditingUser(user);
setShowForm(true);
};
const handleClose = () => {
setShowForm(false);
setEditingUser(null);
};
return (
<div className="admin-users">
<div className="admin-page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
<div>
<h1>Kullanıcılar</h1>
<p>Yönetici hesaplarını yönetin</p>
</div>
<button className="admin-btn" onClick={() => setShowForm(true)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{width: '16px'}}><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></svg>
Yeni Admin Ekle
</button>
</div>
<div className="admin-card">
<table className="admin-table">
<thead>
<tr>
<th>Kullanıcı Adı</th>
<th>Oluşturulma</th>
<th>İşlemler</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id}>
<td style={{ fontWeight: 500, color: '#fff' }}>{user.username}</td>
<td style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>
{new Date(user.createdAt).toLocaleDateString('tr-TR')}
</td>
<td className="actions-cell">
<button className="action-btn" onClick={() => handleEdit(user)}>Şifre Değiştir</button>
{users.length > 1 && (
<DeleteButton onDelete={async () => { await deleteAdmin(user.id); }} />
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{showForm && (
<UserForm
initialData={editingUser}
onClose={handleClose}
/>
)}
</div>
);
}

13
app/admin/users/page.tsx Normal file
View File

@@ -0,0 +1,13 @@
import React from 'react';
import { prisma } from '@/app/lib/prisma';
import UserManager from './UserManager';
export const dynamic = 'force-dynamic';
export default async function AdminUsersPage() {
const users = await prisma.user.findMany({
orderBy: { createdAt: 'desc' }
});
return <UserManager users={users} />;
}

305
app/components/MenuView.tsx Normal file
View File

@@ -0,0 +1,305 @@
"use client";
import { useEffect, useState, useRef, useCallback } from "react";
import Image from "next/image";
/* ────── Helpers ────── */
interface MenuItem {
name: string;
ingredients?: string | null;
tasteProfile?: string | null;
grapeVariety?: string | null;
price: any;
}
interface Category {
id: string;
title: string;
externalId?: string | null;
items: MenuItem[];
}
interface MenuData {
restaurant_name: string;
footer_note: string;
categories: Category[];
}
/* ────── Stars component ────── */
interface Star {
id: number;
left: string;
top: string;
duration: string;
delay: string;
maxOpacity: number;
}
function HeroStars() {
const [stars, setStars] = useState<Star[]>([]);
useEffect(() => {
setStars(
Array.from({ length: 40 }, (_, i) => ({
id: i,
left: `${Math.random() * 100}%`,
top: `${Math.random() * 100}%`,
duration: `${3 + Math.random() * 4}s`,
delay: `${Math.random() * 5}s`,
maxOpacity: 0.3 + Math.random() * 0.5,
}))
);
}, []);
return (
<div className="hero-stars">
{stars.map((s) => (
<span
key={s.id}
className="star"
style={
{
left: s.left,
top: s.top,
"--duration": s.duration,
"--max-opacity": s.maxOpacity,
animationDelay: s.delay,
} as React.CSSProperties
}
/>
))}
</div>
);
}
/* ────── Price renderer ────── */
function PriceDisplay({ price }: { price: MenuItem["price"] }) {
if (typeof price === "string") {
return <span className="item-price">{price}</span>;
}
const entries: [string, string][] = [];
if (price.single) entries.push(["Tek", price.single]);
if (price.double) entries.push(["Dbl", price.double]);
if (price.glass) entries.push(["Kadeh", price.glass]);
if (price.bottle) entries.push(["Şişe", price.bottle]);
return (
<div className="item-price-multi">
{entries.map(([label, val]) => (
<div key={label} className="price-row">
<span className="price-label">{label}</span>
<span className="item-price">{val}</span>
</div>
))}
</div>
);
}
/* ────── Category Icon (decorative) ────── */
function getCategoryIcon(id: string): string {
const icons: Record<string, string> = {
classic_cocktails: "🍸",
lunas_cocktails: "🌙",
shots: "🥃",
gin: "🫒",
whiskey: "🥂",
rum: "🏴‍☠️",
tequila: "🌵",
vodka: "🧊",
cognac: "🍷",
red_wine: "🍷",
white_wine: "🥂",
roze_blush: "🌸",
champagne_prosecco: "🍾",
draft_beer: "🍺",
bottle_beer: "🍻",
coffee: "☕",
soft_drinks: "🧃",
snacks: "🥜",
};
return icons[id] || "✦";
}
export default function MenuView({ data }: { data: MenuData }) {
const [activeCategory, setActiveCategory] = useState<string>(
data.categories[0]?.id || ""
);
const [showBackToTop, setShowBackToTop] = useState(false);
const navRef = useRef<HTMLDivElement>(null);
const sectionRefs = useRef<Map<string, HTMLElement>>(new Map());
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
if (visible.length > 0) {
const id = visible[0].target.getAttribute("data-category-id");
if (id) setActiveCategory(id);
}
},
{ rootMargin: "-50% 0px -50% 0px", threshold: 0 }
);
sectionRefs.current.forEach((el) => observer.observe(el));
return () => observer.disconnect();
}, []);
useEffect(() => {
const onScroll = () => setShowBackToTop(window.scrollY > 600);
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
useEffect(() => {
if (!navRef.current) return;
const btn = navRef.current.querySelector(
`[data-nav-id="${activeCategory}"]`
);
if (btn) {
btn.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" });
}
}, [activeCategory]);
const scrollToCategory = useCallback((id: string) => {
const el = sectionRefs.current.get(id);
if (el) {
const offset = 56;
const y = el.getBoundingClientRect().top + window.scrollY - offset;
window.scrollTo({ top: y, behavior: "smooth" });
}
}, []);
const setSectionRef = useCallback(
(id: string) => (el: HTMLElement | null) => {
if (el) sectionRefs.current.set(id, el);
},
[]
);
return (
<>
<section className="hero" id="hero">
<HeroStars />
<div className="hero-moon" />
<div className="hero-brand">
<Image
src="/logo1.png"
alt="Luna Cocktail and More"
width={360}
height={360}
className="hero-logo"
priority
/>
<div className="hero-divider" />
<p className="hero-subtitle">MENÜ</p>
</div>
<a
href="#menu"
className="hero-scroll-cta"
onClick={(e) => {
e.preventDefault();
scrollToCategory(data.categories[0]?.id);
}}
>
MENÜYÜ KEŞFET
<span className="scroll-arrow" />
</a>
</section>
<nav className="category-nav" id="menu">
<div className="category-nav-inner" ref={navRef}>
{data.categories.map((cat) => (
<button
key={cat.id}
data-nav-id={cat.id}
className={`category-nav-btn ${activeCategory === cat.id ? "active" : ""
}`}
onClick={() => scrollToCategory(cat.id)}
>
{cat.title.toLocaleUpperCase('en')}
</button>
))}
</div>
</nav>
<main className="menu-container">
{data.categories.map((cat) => (
<section
key={cat.id}
className="category-section"
data-category-id={cat.id}
ref={setSectionRef(cat.id)}
>
<div className="category-header">
<h2 className="category-title">
<span style={{ marginRight: "0.5rem", fontSize: "0.85em" }}>
{getCategoryIcon(cat.externalId || cat.id)}
</span>
{cat.title}
</h2>
<p className="category-item-count">
{cat.items.length} ÜRÜN
</p>
</div>
<div className="menu-items">
{cat.items.map((item, idx) => (
<div className="menu-item" key={`${cat.id}-${idx}`}>
<div className="item-info">
<h3 className="item-name">{item.name}</h3>
<div className="item-meta">
{item.grapeVariety && (
<span className="item-grape">{item.grapeVariety}</span>
)}
{item.ingredients && (
<span className="item-ingredients">
{item.ingredients}
</span>
)}
{item.tasteProfile && (
<div className="item-taste-profile">
{item.tasteProfile.split("-").map((t, i) => (
<span key={i} className="taste-tag">
{t.trim()}
</span>
))}
</div>
)}
</div>
</div>
<div className="item-price-area">
<PriceDisplay price={item.price} />
</div>
</div>
))}
</div>
</section>
))}
</main>
<footer className="menu-footer">
<p className="footer-note">{data.footer_note}</p>
<p className="footer-brand">LUNA </p>
</footer>
<button
className={`back-to-top ${showBackToTop ? "visible" : ""}`}
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
aria-label="Yukarı git"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<polyline points="18 15 12 9 6 15" />
</svg>
</button>
</>
);
}

39
app/lib/auth.ts Normal file
View File

@@ -0,0 +1,39 @@
import { SignJWT, jwtVerify } from 'jose';
import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
const secretKey = process.env.JWT_SECRET || 'fallback_secret';
const key = new TextEncoder().encode(secretKey);
export async function encrypt(payload: any) {
return await new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('2h')
.sign(key);
}
export async function decrypt(input: string): Promise<any> {
const { payload } = await jwtVerify(input, key, {
algorithms: ['HS256'],
});
return payload;
}
export async function login(formData: FormData) {
// Real login logic will be in a server action, this is just for session management
}
export async function logout() {
(await cookies()).set('session', '', { expires: new Date(0) });
}
export async function getSession() {
const session = (await cookies()).get('session')?.value;
if (!session) return null;
try {
return await decrypt(session);
} catch (e) {
return null;
}
}

15
app/lib/prisma.ts Normal file
View File

@@ -0,0 +1,15 @@
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import { Pool } from 'pg';
const globalForPrisma = global as unknown as { prisma: PrismaClient };
const connectionString = process.env.DATABASE_URL;
const pool = new Pool({ connectionString });
const adapter = new PrismaPg(pool);
export const prisma =
globalForPrisma.prisma ||
new PrismaClient({ adapter });
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;

107
app/login/login.css Normal file
View File

@@ -0,0 +1,107 @@
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--background);
padding: 1.5rem;
position: relative;
overflow: hidden;
}
.login-page::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle at center, rgba(201, 169, 110, 0.05) 0%, transparent 70%);
pointer-events: none;
}
.login-card {
width: 100%;
max-width: 400px;
background: var(--surface);
border: 1px solid var(--border-subtle);
border-radius: 24px;
padding: 3rem 2.5rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
position: relative;
z-index: 1;
animation: fadeSlideUp 0.8s ease-out;
}
.login-header {
text-align: center;
margin-bottom: 2.5rem;
}
.login-header h1 {
font-family: 'Playfair Display', serif;
font-size: 1.8rem;
color: #fff;
letter-spacing: 0.1em;
margin-bottom: 0.5rem;
}
.login-header h1 span {
color: var(--gold);
font-size: 0.9rem;
font-family: 'Inter', sans-serif;
letter-spacing: 0.3em;
margin-left: 0.5rem;
}
.login-header p {
color: var(--text-secondary);
font-size: 0.85rem;
}
.login-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.login-error {
background: rgba(180, 74, 101, 0.1);
color: var(--accent-rose);
padding: 0.75rem 1rem;
border-radius: 8px;
font-size: 0.85rem;
border: 1px solid rgba(180, 74, 101, 0.2);
text-align: center;
}
.login-btn {
width: 100%;
justify-content: center;
padding: 0.85rem;
margin-top: 1rem;
}
.login-footer {
margin-top: 2rem;
text-align: center;
}
.back-to-site {
color: var(--text-muted);
text-decoration: none;
font-size: 0.8rem;
transition: color 0.3s;
}
.back-to-site:hover {
color: var(--gold);
}
@keyframes fadeSlideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

65
app/login/page.tsx Normal file
View File

@@ -0,0 +1,65 @@
'use client';
import React, { useState } from 'react';
import { authenticate } from '../admin/actions';
import './login.css';
export default function LoginPage() {
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(formData: FormData) {
setLoading(true);
setError(null);
const result = await authenticate(formData);
if (result?.error) {
setError(result.error);
setLoading(false);
}
}
return (
<div className="login-page">
<div className="login-card">
<div className="login-header">
<h1>LUNA <span>ADMIN</span></h1>
<p>Yönetim paneline giriş yapın</p>
</div>
<form action={handleSubmit} className="login-form">
{error && <div className="login-error">{error}</div>}
<div className="form-group">
<label htmlFor="username">Kullanıcı Adı</label>
<input
type="text"
id="username"
name="username"
className="admin-input"
required
/>
</div>
<div className="form-group">
<label htmlFor="password">Şifre</label>
<input
type="password"
id="password"
name="password"
className="admin-input"
required
/>
</div>
<button type="submit" className="admin-btn login-btn" disabled={loading}>
{loading ? 'Giriş yapılıyor...' : 'Giriş Yap'}
</button>
</form>
<div className="login-footer">
<a href="/" className="back-to-site"> Siteye Dön</a>
</div>
</div>
</div>
);
}

View File

@@ -1,324 +1,43 @@
"use client";
import { prisma } from "@/app/lib/prisma";
import MenuView from "./components/MenuView";
import { useEffect, useState, useRef, useCallback } from "react";
import Image from "next/image";
import menuData from "../menu/menu.json";
export const dynamic = 'force-dynamic';
/* ────── Helpers ────── */
interface MenuItem {
name: string;
ingredients?: string;
taste_profile?: string;
grape_variety?: string;
price:
| string
| { single?: string; double?: string; glass?: string; bottle?: string };
export default async function Page() {
const restaurant = await prisma.restaurant.findFirst({
include: {
categories: {
include: {
items: true,
},
orderBy: {
createdAt: 'asc',
},
},
},
});
if (!restaurant) {
return <div>Veritabanı yükleniyor...</div>;
}
interface Category {
id: string;
title: string;
items: MenuItem[];
}
interface MenuData {
restaurant_name: string;
footer_note: string;
categories: Category[];
}
const data = menuData as MenuData;
/* ────── Stars component ────── */
interface Star {
id: number;
left: string;
top: string;
duration: string;
delay: string;
maxOpacity: number;
}
function HeroStars() {
const [stars, setStars] = useState<Star[]>([]);
useEffect(() => {
setStars(
Array.from({ length: 40 }, (_, i) => ({
id: i,
left: `${Math.random() * 100}%`,
top: `${Math.random() * 100}%`,
duration: `${3 + Math.random() * 4}s`,
delay: `${Math.random() * 5}s`,
maxOpacity: 0.3 + Math.random() * 0.5,
}))
);
}, []);
return (
<div className="hero-stars">
{stars.map((s) => (
<span
key={s.id}
className="star"
style={
{
left: s.left,
top: s.top,
"--duration": s.duration,
"--max-opacity": s.maxOpacity,
animationDelay: s.delay,
} as React.CSSProperties
}
/>
))}
</div>
);
}
/* ────── Price renderer ────── */
function PriceDisplay({ price }: { price: MenuItem["price"] }) {
if (typeof price === "string") {
return <span className="item-price">{price}</span>;
}
const entries: [string, string][] = [];
if (price.single) entries.push(["Tek", price.single]);
if (price.double) entries.push(["Dbl", price.double]);
if (price.glass) entries.push(["Kadeh", price.glass]);
if (price.bottle) entries.push(["Şişe", price.bottle]);
return (
<div className="item-price-multi">
{entries.map(([label, val]) => (
<div key={label} className="price-row">
<span className="price-label">{label}</span>
<span className="item-price">{val}</span>
</div>
))}
</div>
);
}
/* ────── Category Icon (decorative) ────── */
function getCategoryIcon(id: string): string {
const icons: Record<string, string> = {
classic_cocktails: "🍸",
lunas_cocktails: "🌙",
shots: "🥃",
gin: "🫒",
whiskey: "🥂",
rum: "🏴‍☠️",
tequila: "🌵",
vodka: "🧊",
cognac: "🍷",
red_wine: "🍷",
white_wine: "🥂",
roze_blush: "🌸",
champagne_prosecco: "🍾",
draft_beer: "🍺",
bottle_beer: "🍻",
coffee: "☕",
soft_drinks: "🧃",
snacks: "🥜",
// Map database structure to the structure expected by MenuView
const data = {
restaurant_name: restaurant.name,
footer_note: restaurant.footerNote || "",
categories: restaurant.categories.map(cat => ({
id: cat.id,
title: cat.title,
externalId: cat.externalId,
items: cat.items.map(item => ({
name: item.name,
ingredients: item.ingredients,
tasteProfile: item.tasteProfile,
grapeVariety: item.grapeVariety,
price: item.price,
})),
})),
};
return icons[id] || "✦";
}
/* ────── Main Page ────── */
export default function MenuPage() {
const [activeCategory, setActiveCategory] = useState<string>(
data.categories[0]?.id || ""
);
const [showBackToTop, setShowBackToTop] = useState(false);
const navRef = useRef<HTMLDivElement>(null);
const sectionRefs = useRef<Map<string, HTMLElement>>(new Map());
/* Intersection observer to update active category */
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
if (visible.length > 0) {
const id = visible[0].target.getAttribute("data-category-id");
if (id) setActiveCategory(id);
}
},
{ rootMargin: "-50% 0px -50% 0px", threshold: 0 }
);
sectionRefs.current.forEach((el) => observer.observe(el));
return () => observer.disconnect();
}, []);
/* Back-to-top visibility */
useEffect(() => {
const onScroll = () => setShowBackToTop(window.scrollY > 600);
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
/* Scroll nav button into view */
useEffect(() => {
if (!navRef.current) return;
const btn = navRef.current.querySelector(
`[data-nav-id="${activeCategory}"]`
);
if (btn) {
btn.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" });
}
}, [activeCategory]);
const scrollToCategory = useCallback((id: string) => {
const el = sectionRefs.current.get(id);
if (el) {
const offset = 56;
const y = el.getBoundingClientRect().top + window.scrollY - offset;
window.scrollTo({ top: y, behavior: "smooth" });
}
}, []);
const setSectionRef = useCallback(
(id: string) => (el: HTMLElement | null) => {
if (el) sectionRefs.current.set(id, el);
},
[]
);
return (
<>
{/* ───── HERO ───── */}
<section className="hero" id="hero">
<HeroStars />
<div className="hero-moon" />
{/* Background illustration */}
<div className="hero-brand">
<Image
src="/logo1.png"
alt="Luna Cocktail and More"
width={360}
height={360}
className="hero-logo"
priority
/>
<div className="hero-divider" />
<p className="hero-subtitle">MENÜ</p>
</div>
<a
href="#menu"
className="hero-scroll-cta"
onClick={(e) => {
e.preventDefault();
scrollToCategory(data.categories[0]?.id);
}}
>
MENÜYÜ KEŞFET
<span className="scroll-arrow" />
</a>
</section>
{/* ───── CATEGORY NAV ───── */}
<nav className="category-nav" id="menu">
<div className="category-nav-inner" ref={navRef}>
{data.categories.map((cat) => (
<button
key={cat.id}
data-nav-id={cat.id}
className={`category-nav-btn ${activeCategory === cat.id ? "active" : ""
}`}
onClick={() => scrollToCategory(cat.id)}
>
{cat.title.toLocaleUpperCase('en')}
</button>
))}
</div>
</nav>
{/* ───── MENU CONTENT ───── */}
<main className="menu-container">
{data.categories.map((cat) => (
<section
key={cat.id}
className="category-section"
data-category-id={cat.id}
ref={setSectionRef(cat.id)}
>
<div className="category-header">
<h2 className="category-title">
<span style={{ marginRight: "0.5rem", fontSize: "0.85em" }}>
{getCategoryIcon(cat.id)}
</span>
{cat.title}
</h2>
<p className="category-item-count">
{cat.items.length} ÜRÜN
</p>
</div>
<div className="menu-items">
{cat.items.map((item, idx) => (
<div className="menu-item" key={`${cat.id}-${idx}`}>
<div className="item-info">
<h3 className="item-name">{item.name}</h3>
<div className="item-meta">
{item.grape_variety && (
<span className="item-grape">{item.grape_variety}</span>
)}
{item.ingredients && (
<span className="item-ingredients">
{item.ingredients}
</span>
)}
{item.taste_profile && (
<div className="item-taste-profile">
{item.taste_profile.split("-").map((t, i) => (
<span key={i} className="taste-tag">
{t.trim()}
</span>
))}
</div>
)}
</div>
</div>
<div className="item-price-area">
<PriceDisplay price={item.price} />
</div>
</div>
))}
</div>
</section>
))}
</main>
{/* ───── FOOTER ───── */}
<footer className="menu-footer">
<p className="footer-note">{data.footer_note}</p>
<p className="footer-brand">LUNA </p>
</footer>
{/* ───── BACK TO TOP ───── */}
<button
className={`back-to-top ${showBackToTop ? "visible" : ""}`}
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
aria-label="Yukarı git"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="18 15 12 9 6 15" />
</svg>
</button>
</>
);
return <MenuView data={data} />;
}

1901
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,10 +8,18 @@
"start": "next start",
"lint": "eslint"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"dependencies": {
"next": "16.2.4",
"react": "19.2.4",
"react-dom": "19.2.4"
"react-dom": "19.2.4",
"@prisma/client": "^7.8.0",
"@prisma/adapter-pg": "^7.8.0",
"pg": "^8.11.0",
"bcryptjs": "^2.4.3",
"jose": "^5.2.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -21,6 +29,11 @@
"eslint": "^9",
"eslint-config-next": "16.2.4",
"tailwindcss": "^4",
"typescript": "^5"
"typescript": "^5",
"prisma": "^7.8.0",
"@prisma/config": "^7.8.0",
"@types/pg": "^8.11.0",
"tsx": "^4.19.0",
"dotenv": "^16.4.5"
}
}

11
prisma.config.js Normal file
View File

@@ -0,0 +1,11 @@
require('dotenv').config();
module.exports = {
schema: "prisma/schema.prisma",
migrations: {
seed: 'tsx ./prisma/seed.ts',
},
datasource: {
url: process.env.DATABASE_URL,
},
};

View File

@@ -0,0 +1,43 @@
-- CreateTable
CREATE TABLE "Restaurant" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"footerNote" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Restaurant_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Category" (
"id" TEXT NOT NULL,
"externalId" TEXT,
"title" TEXT NOT NULL,
"restaurantId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Item" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"ingredients" TEXT,
"tasteProfile" TEXT,
"grapeVariety" TEXT,
"price" JSONB NOT NULL,
"categoryId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Item_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Category" ADD CONSTRAINT "Category_restaurantId_fkey" FOREIGN KEY ("restaurantId") REFERENCES "Restaurant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Item" ADD CONSTRAINT "Item_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,13 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"username" TEXT NOT NULL,
"password" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

48
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,48 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
model Restaurant {
id String @id @default(cuid())
name String
footerNote String?
categories Category[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Category {
id String @id @default(cuid())
externalId String?
title String
restaurantId String
restaurant Restaurant @relation(fields: [restaurantId], references: [id])
items Item[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Item {
id String @id @default(cuid())
name String
ingredients String?
tasteProfile String?
grapeVariety String?
price Json
categoryId String
category Category @relation(fields: [categoryId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model User {
id String @id @default(cuid())
username String @unique
password String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

76
prisma/seed.ts Normal file
View File

@@ -0,0 +1,76 @@
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import { Pool } from 'pg';
import fs from 'fs';
import path from 'path';
import 'dotenv/config';
import bcrypt from 'bcryptjs';
const connectionString = process.env.DATABASE_URL;
const pool = new Pool({ connectionString });
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter });
async function main() {
const menuPath = path.join(__dirname, '../menu/menu.json');
const menuData = JSON.parse(fs.readFileSync(menuPath, 'utf-8'));
// 1. Create Restaurant
const restaurant = await prisma.restaurant.create({
data: {
name: menuData.restaurant_name,
footerNote: menuData.footer_note,
},
});
console.log(`Created restaurant: ${restaurant.name}`);
// 1.5 Create Admin User
const adminUsername = 'admin';
const hashedPassword = await bcrypt.hash('admin', 10);
const existingUser = await prisma.user.findUnique({ where: { username: adminUsername } });
if (!existingUser) {
await prisma.user.create({
data: {
username: adminUsername,
password: hashedPassword,
},
});
console.log(`Created admin user: ${adminUsername}`);
}
for (const cat of menuData.categories) {
const category = await prisma.category.create({
data: {
title: cat.title,
externalId: cat.id,
restaurantId: restaurant.id,
},
});
console.log(`Created category: ${category.title}`);
for (const item of cat.items) {
await prisma.item.create({
data: {
name: item.name,
ingredients: item.ingredients || null,
tasteProfile: item.taste_profile || null,
grapeVariety: item.grape_variety || null,
price: item.price, // Prisma handles JSON fields automatically
categoryId: category.id,
},
});
}
console.log(` Added ${cat.items.length} items to ${cat.title}`);
}
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

27
proxy.ts Normal file
View File

@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from 'next/server';
import { decrypt } from '@/app/lib/auth';
export async function proxy(request: NextRequest) {
const session = request.cookies.get('session')?.value;
const { pathname } = request.nextUrl;
// Protect admin routes
if (pathname.startsWith('/admin')) {
if (!session) {
return NextResponse.redirect(new URL('/login', request.url));
}
try {
await decrypt(session);
return NextResponse.next();
} catch (e) {
return NextResponse.redirect(new URL('/login', request.url));
}
}
return NextResponse.next();
}
export const config = {
matcher: ['/admin/:path*'],
};