feat: add prisma support, admin panel and auth
This commit is contained in:
@@ -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
|
||||
|
||||
150
app/admin/actions.ts
Normal file
150
app/admin/actions.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
'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');
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
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} />;
|
||||
}
|
||||
58
app/admin/layout.tsx
Normal file
58
app/admin/layout.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
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";
|
||||
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',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
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>;
|
||||
if (!restaurant) {
|
||||
return <div>Veritabanı yükleniyor...</div>;
|
||||
}
|
||||
|
||||
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} />;
|
||||
}
|
||||
|
||||
27
middleware.ts
Normal file
27
middleware.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { decrypt } from '@/app/lib/auth';
|
||||
|
||||
export async function middleware(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*'],
|
||||
};
|
||||
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",
|
||||
"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
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();
|
||||
});
|
||||
Reference in New Issue
Block a user