Compare commits
3 Commits
31c3deb2da
...
6f04e39bf3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f04e39bf3 | ||
|
|
a2ba52e69b | ||
|
|
09a105cd1e |
@@ -15,6 +15,9 @@ WORKDIR /app
|
|||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Generate Prisma Client
|
||||||
|
RUN npx prisma generate
|
||||||
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|||||||
191
app/admin/actions.ts
Normal file
191
app/admin/actions.ts
Normal 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
322
app/admin/admin.css
Normal 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);
|
||||||
|
}
|
||||||
73
app/admin/categories/CategoryManager.tsx
Normal file
73
app/admin/categories/CategoryManager.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
app/admin/categories/page.tsx
Normal file
18
app/admin/categories/page.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
22
app/admin/components/AdminNavItem.tsx
Normal file
22
app/admin/components/AdminNavItem.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
app/admin/components/CategoryForm.tsx
Normal file
66
app/admin/components/CategoryForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
app/admin/components/DeleteButton.tsx
Normal file
29
app/admin/components/DeleteButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
app/admin/components/ItemForm.tsx
Normal file
112
app/admin/components/ItemForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
app/admin/components/SignOutButton.tsx
Normal file
16
app/admin/components/SignOutButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
app/admin/components/UserForm.tsx
Normal file
78
app/admin/components/UserForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
app/admin/items/ItemManager.tsx
Normal file
91
app/admin/items/ItemManager.tsx
Normal 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
20
app/admin/items/page.tsx
Normal 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
62
app/admin/layout.tsx
Normal 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
44
app/admin/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
app/admin/users/UserManager.tsx
Normal file
75
app/admin/users/UserManager.tsx
Normal 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
13
app/admin/users/page.tsx
Normal 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
305
app/components/MenuView.tsx
Normal 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
39
app/lib/auth.ts
Normal 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
15
app/lib/prisma.ts
Normal 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
107
app/login/login.css
Normal 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
65
app/login/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
353
app/page.tsx
353
app/page.tsx
@@ -1,324 +1,43 @@
|
|||||||
"use client";
|
import { prisma } from "@/app/lib/prisma";
|
||||||
|
import MenuView from "./components/MenuView";
|
||||||
|
|
||||||
import { useEffect, useState, useRef, useCallback } from "react";
|
export const dynamic = 'force-dynamic';
|
||||||
import Image from "next/image";
|
|
||||||
import menuData from "../menu/menu.json";
|
|
||||||
|
|
||||||
/* ────── Helpers ────── */
|
export default async function Page() {
|
||||||
interface MenuItem {
|
const restaurant = await prisma.restaurant.findFirst({
|
||||||
name: string;
|
include: {
|
||||||
ingredients?: string;
|
categories: {
|
||||||
taste_profile?: string;
|
include: {
|
||||||
grape_variety?: string;
|
items: true,
|
||||||
price:
|
},
|
||||||
| string
|
orderBy: {
|
||||||
| { single?: string; double?: string; glass?: string; bottle?: string };
|
createdAt: 'asc',
|
||||||
}
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
interface Category {
|
if (!restaurant) {
|
||||||
id: string;
|
return <div>Veritabanı yükleniyor...</div>;
|
||||||
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][] = [];
|
// Map database structure to the structure expected by MenuView
|
||||||
if (price.single) entries.push(["Tek", price.single]);
|
const data = {
|
||||||
if (price.double) entries.push(["Dbl", price.double]);
|
restaurant_name: restaurant.name,
|
||||||
if (price.glass) entries.push(["Kadeh", price.glass]);
|
footer_note: restaurant.footerNote || "",
|
||||||
if (price.bottle) entries.push(["Şişe", price.bottle]);
|
categories: restaurant.categories.map(cat => ({
|
||||||
|
id: cat.id,
|
||||||
return (
|
title: cat.title,
|
||||||
<div className="item-price-multi">
|
externalId: cat.externalId,
|
||||||
{entries.map(([label, val]) => (
|
items: cat.items.map(item => ({
|
||||||
<div key={label} className="price-row">
|
name: item.name,
|
||||||
<span className="price-label">{label}</span>
|
ingredients: item.ingredients,
|
||||||
<span className="item-price">{val}</span>
|
tasteProfile: item.tasteProfile,
|
||||||
</div>
|
grapeVariety: item.grapeVariety,
|
||||||
))}
|
price: item.price,
|
||||||
</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] || "✦";
|
|
||||||
}
|
return <MenuView data={data} />;
|
||||||
|
|
||||||
/* ────── 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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
1901
package-lock.json
generated
1901
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@@ -8,10 +8,18 @@
|
|||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "tsx prisma/seed.ts"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "16.2.4",
|
"next": "16.2.4",
|
||||||
"react": "19.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": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
@@ -21,6 +29,11 @@
|
|||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.4",
|
"eslint-config-next": "16.2.4",
|
||||||
"tailwindcss": "^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
11
prisma.config.js
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
43
prisma/migrations/20260515145626_init/migration.sql
Normal file
43
prisma/migrations/20260515145626_init/migration.sql
Normal 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;
|
||||||
@@ -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");
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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
48
prisma/schema.prisma
Normal 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
76
prisma/seed.ts
Normal 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
27
proxy.ts
Normal 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*'],
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user