Add GrubFlip admin dashboard — meals, orders, trades pages
Built out the full restaurant owner admin dashboard: - Dashboard overview with stats, recent trades, and top meals - Meals management with card grid, add/edit/delete modals, search & filter - Orders page with status tabs, accept/ready/complete workflow - Trades activity page with search and stats overview - Shared admin-app.js module (sidebar, API client, toast, auth helpers) - All pages use existing design tokens, mobile-first responsive layout - Demo data fallback when API endpoints aren't ready yet Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
05dd55e0b6
commit
a3331881d4
6 changed files with 1797 additions and 0 deletions
|
|
@ -972,3 +972,219 @@ img { max-width: 100%; height: auto; }
|
||||||
max-width: 80rem;
|
max-width: 80rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==========================================
|
||||||
|
DASHBOARD-SPECIFIC STYLES
|
||||||
|
========================================== */
|
||||||
|
|
||||||
|
.dashboard-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--gf-space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.dashboard-grid {
|
||||||
|
grid-template-columns: 1.2fr 0.8fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Top Meals List */
|
||||||
|
.top-meals-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-meal-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--gf-space-3);
|
||||||
|
padding: var(--gf-space-3) 0;
|
||||||
|
border-bottom: 1px solid var(--gf-neutral-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-meal-item:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.top-meal-rank {
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
border-radius: var(--gf-radius-full);
|
||||||
|
background: var(--gf-neutral-100);
|
||||||
|
color: var(--gf-neutral-600);
|
||||||
|
font-size: var(--gf-text-sm);
|
||||||
|
font-weight: var(--gf-weight-semibold);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-meal-item:first-child .top-meal-rank {
|
||||||
|
background: var(--gf-primary-bg);
|
||||||
|
color: var(--gf-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-meal-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-meal-info strong {
|
||||||
|
font-size: var(--gf-text-sm);
|
||||||
|
font-weight: var(--gf-weight-semibold);
|
||||||
|
color: var(--gf-neutral-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================
|
||||||
|
MEALS PAGE STYLES
|
||||||
|
========================================== */
|
||||||
|
|
||||||
|
.meals-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--gf-space-3);
|
||||||
|
margin-bottom: var(--gf-space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.meals-toolbar {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meals-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--gf-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.meals-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.meals-grid {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-card {
|
||||||
|
background: var(--gf-bg);
|
||||||
|
border-radius: var(--gf-radius-lg);
|
||||||
|
box-shadow: var(--gf-shadow-card);
|
||||||
|
border: 1px solid var(--gf-neutral-200);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: box-shadow var(--gf-duration-normal) var(--gf-ease), transform var(--gf-duration-normal) var(--gf-ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-card:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.12);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-card-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 10rem;
|
||||||
|
object-fit: cover;
|
||||||
|
background: var(--gf-neutral-100);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-card-img-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 10rem;
|
||||||
|
background: var(--gf-neutral-100);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 2.5rem;
|
||||||
|
color: var(--gf-neutral-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-card-body {
|
||||||
|
padding: var(--gf-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-card-title {
|
||||||
|
font-family: var(--gf-font-heading);
|
||||||
|
font-size: var(--gf-text-base);
|
||||||
|
font-weight: var(--gf-weight-semibold);
|
||||||
|
color: var(--gf-neutral-900);
|
||||||
|
margin-bottom: var(--gf-space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-card-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--gf-space-3);
|
||||||
|
font-size: var(--gf-text-xs);
|
||||||
|
color: var(--gf-neutral-500);
|
||||||
|
margin-bottom: var(--gf-space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-card-desc {
|
||||||
|
font-size: var(--gf-text-sm);
|
||||||
|
color: var(--gf-neutral-600);
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: var(--gf-space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-card-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-top: var(--gf-space-3);
|
||||||
|
border-top: 1px solid var(--gf-neutral-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-card-footer .actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--gf-space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal form image preview */
|
||||||
|
.img-preview-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
height: 8rem;
|
||||||
|
border: 2px dashed var(--gf-neutral-300);
|
||||||
|
border-radius: var(--gf-radius-md);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color var(--gf-duration-fast);
|
||||||
|
background: var(--gf-neutral-50);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-preview-wrapper:hover {
|
||||||
|
border-color: var(--gf-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-preview-wrapper img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-preview-wrapper .upload-label {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--gf-neutral-400);
|
||||||
|
font-size: var(--gf-text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-preview-wrapper .upload-label span {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: var(--gf-space-1);
|
||||||
|
}
|
||||||
|
|
|
||||||
315
admin/dashboard.html
Normal file
315
admin/dashboard.html
Normal file
|
|
@ -0,0 +1,315 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Grubflip Admin — Dashboard</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@600;700;800&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="css/tokens.css">
|
||||||
|
<link rel="stylesheet" href="css/admin.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="admin-layout">
|
||||||
|
<!-- Sidebar Overlay (mobile) -->
|
||||||
|
<div class="sidebar-overlay" id="sidebarOverlay"></div>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="sidebar" id="sidebar" role="navigation" aria-label="Admin navigation">
|
||||||
|
<div class="sidebar-brand">
|
||||||
|
<div class="logo-icon">GF</div>
|
||||||
|
<span>Grubflip</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
<div class="sidebar-section-title">Main</div>
|
||||||
|
<a href="dashboard.html" class="sidebar-link active">
|
||||||
|
<span class="icon">📊</span> Dashboard
|
||||||
|
</a>
|
||||||
|
<a href="meals.html" class="sidebar-link">
|
||||||
|
<span class="icon">🍔</span> Meals
|
||||||
|
</a>
|
||||||
|
<a href="orders.html" class="sidebar-link">
|
||||||
|
<span class="icon">📦</span> Orders
|
||||||
|
</a>
|
||||||
|
<a href="trades.html" class="sidebar-link">
|
||||||
|
<span class="icon">🔄</span> Trades
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="sidebar-section-title">Settings</div>
|
||||||
|
<a href="profile.html" class="sidebar-link">
|
||||||
|
<span class="icon">🏪</span> Restaurant Profile
|
||||||
|
</a>
|
||||||
|
<a href="settings.html" class="sidebar-link">
|
||||||
|
<span class="icon">⚙️</span> Settings
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div class="sidebar-user">
|
||||||
|
<div class="avatar" id="userAvatar">DR</div>
|
||||||
|
<div class="user-info">
|
||||||
|
<div class="user-name" id="userName">Demo Restaurant</div>
|
||||||
|
<div class="user-role" id="userRole">Owner</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="main-content">
|
||||||
|
<!-- Top Bar -->
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="topbar-left">
|
||||||
|
<button class="menu-toggle" id="menuToggle" aria-label="Toggle menu">
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
|
<h1 class="topbar-title">Dashboard</h1>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-right">
|
||||||
|
<button class="topbar-btn" aria-label="Notifications" id="notifBtn">
|
||||||
|
🔔
|
||||||
|
<span class="badge-dot"></span>
|
||||||
|
</button>
|
||||||
|
<button class="topbar-btn" aria-label="Sign out" id="logoutBtn">
|
||||||
|
⏻
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Page Content -->
|
||||||
|
<div class="page-content">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Welcome back, <span id="greetingName">Demo Restaurant</span></h1>
|
||||||
|
<p class="subtitle">Here's what's happening with your meals today.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Grid -->
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon orange">🍔</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3>Active Meals</h3>
|
||||||
|
<div class="stat-value" id="statMeals">0</div>
|
||||||
|
<div class="stat-change up" id="statMealsChange">--</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon green">🔄</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3>Trades Today</h3>
|
||||||
|
<div class="stat-value" id="statTrades">0</div>
|
||||||
|
<div class="stat-change up" id="statTradesChange">--</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon yellow">⭐</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3>Avg Rating</h3>
|
||||||
|
<div class="stat-value" id="statRating">--</div>
|
||||||
|
<div class="stat-change up" id="statRatingChange">--</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon blue">👥</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3>Profile Views</h3>
|
||||||
|
<div class="stat-value" id="statViews">0</div>
|
||||||
|
<div class="stat-change up" id="statViewsChange">--</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content Grid -->
|
||||||
|
<div class="dashboard-grid">
|
||||||
|
<!-- Recent Trades -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2>Recent Trades</h2>
|
||||||
|
<a href="trades.html" class="btn btn-ghost">View all →</a>
|
||||||
|
</div>
|
||||||
|
<div id="recentTradesList">
|
||||||
|
<div class="loading-overlay"><div class="spinner"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Popular Meals -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2>Top Meals</h2>
|
||||||
|
<a href="meals.html" class="btn btn-ghost">Manage →</a>
|
||||||
|
</div>
|
||||||
|
<div id="topMealsList">
|
||||||
|
<div class="loading-overlay"><div class="spinner"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast Container -->
|
||||||
|
<div class="toast-container" id="toastContainer"></div>
|
||||||
|
|
||||||
|
<script src="js/admin-app.js"></script>
|
||||||
|
<script>
|
||||||
|
// --- Auth Guard ---
|
||||||
|
const token = localStorage.getItem('gf_token');
|
||||||
|
const user = JSON.parse(localStorage.getItem('gf_user') || '{}');
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
window.location.href = 'index.html';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Populate user info ---
|
||||||
|
if (user.name) {
|
||||||
|
document.getElementById('greetingName').textContent = user.name;
|
||||||
|
document.getElementById('userName').textContent = user.name;
|
||||||
|
document.getElementById('userAvatar').textContent = user.name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
if (user.role) {
|
||||||
|
document.getElementById('userRole').textContent = user.role.charAt(0).toUpperCase() + user.role.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Sidebar Toggle ---
|
||||||
|
AdminApp.initSidebar();
|
||||||
|
|
||||||
|
// --- Logout ---
|
||||||
|
document.getElementById('logoutBtn').addEventListener('click', () => {
|
||||||
|
localStorage.removeItem('gf_token');
|
||||||
|
localStorage.removeItem('gf_user');
|
||||||
|
window.location.href = 'index.html';
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Load Dashboard Data ---
|
||||||
|
async function loadDashboard() {
|
||||||
|
try {
|
||||||
|
const data = await AdminApp.api('/admin/dashboard');
|
||||||
|
if (data) {
|
||||||
|
document.getElementById('statMeals').textContent = data.active_meals || 0;
|
||||||
|
document.getElementById('statTrades').textContent = data.trades_today || 0;
|
||||||
|
document.getElementById('statRating').textContent = data.avg_rating ? data.avg_rating.toFixed(1) : '--';
|
||||||
|
document.getElementById('statViews').textContent = data.profile_views || 0;
|
||||||
|
|
||||||
|
if (data.meals_change) {
|
||||||
|
const el = document.getElementById('statMealsChange');
|
||||||
|
el.textContent = `${data.meals_change > 0 ? '+' : ''}${data.meals_change} this week`;
|
||||||
|
el.className = `stat-change ${data.meals_change >= 0 ? 'up' : 'down'}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// API not ready yet — load demo data
|
||||||
|
loadDemoData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadDemoData() {
|
||||||
|
document.getElementById('statMeals').textContent = '12';
|
||||||
|
document.getElementById('statTrades').textContent = '8';
|
||||||
|
document.getElementById('statRating').textContent = '4.6';
|
||||||
|
document.getElementById('statViews').textContent = '342';
|
||||||
|
document.getElementById('statMealsChange').textContent = '+3 this week';
|
||||||
|
document.getElementById('statTradesChange').textContent = '+12% vs last week';
|
||||||
|
document.getElementById('statRatingChange').textContent = '+0.2 from last month';
|
||||||
|
document.getElementById('statViewsChange').textContent = '+18% this week';
|
||||||
|
|
||||||
|
// Recent trades
|
||||||
|
const tradesHtml = `
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Meal</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Time</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Jamie L.</td>
|
||||||
|
<td>Spicy Ramen Bowl</td>
|
||||||
|
<td><span class="badge badge-success">Completed</span></td>
|
||||||
|
<td class="text-muted">2 min ago</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Alex K.</td>
|
||||||
|
<td>Chicken Burrito</td>
|
||||||
|
<td><span class="badge badge-warning">Pending</span></td>
|
||||||
|
<td class="text-muted">15 min ago</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Sam R.</td>
|
||||||
|
<td>Veggie Power Bowl</td>
|
||||||
|
<td><span class="badge badge-success">Completed</span></td>
|
||||||
|
<td class="text-muted">28 min ago</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Morgan T.</td>
|
||||||
|
<td>BBQ Pulled Pork</td>
|
||||||
|
<td><span class="badge badge-info">In Progress</span></td>
|
||||||
|
<td class="text-muted">45 min ago</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Riley W.</td>
|
||||||
|
<td>Greek Salad Wrap</td>
|
||||||
|
<td><span class="badge badge-success">Completed</span></td>
|
||||||
|
<td class="text-muted">1 hr ago</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.getElementById('recentTradesList').innerHTML = tradesHtml;
|
||||||
|
|
||||||
|
// Top meals
|
||||||
|
const mealsHtml = `
|
||||||
|
<div class="top-meals-list">
|
||||||
|
<div class="top-meal-item">
|
||||||
|
<span class="top-meal-rank">1</span>
|
||||||
|
<div class="top-meal-info">
|
||||||
|
<strong>Spicy Ramen Bowl</strong>
|
||||||
|
<span class="text-muted text-sm">47 trades this week</span>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-primary">Hot</span>
|
||||||
|
</div>
|
||||||
|
<div class="top-meal-item">
|
||||||
|
<span class="top-meal-rank">2</span>
|
||||||
|
<div class="top-meal-info">
|
||||||
|
<strong>Chicken Burrito</strong>
|
||||||
|
<span class="text-muted text-sm">38 trades this week</span>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-accent">Trending</span>
|
||||||
|
</div>
|
||||||
|
<div class="top-meal-item">
|
||||||
|
<span class="top-meal-rank">3</span>
|
||||||
|
<div class="top-meal-info">
|
||||||
|
<strong>Veggie Power Bowl</strong>
|
||||||
|
<span class="text-muted text-sm">31 trades this week</span>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-success">Popular</span>
|
||||||
|
</div>
|
||||||
|
<div class="top-meal-item">
|
||||||
|
<span class="top-meal-rank">4</span>
|
||||||
|
<div class="top-meal-info">
|
||||||
|
<strong>BBQ Pulled Pork</strong>
|
||||||
|
<span class="text-muted text-sm">24 trades this week</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="top-meal-item">
|
||||||
|
<span class="top-meal-rank">5</span>
|
||||||
|
<div class="top-meal-info">
|
||||||
|
<strong>Greek Salad Wrap</strong>
|
||||||
|
<span class="text-muted text-sm">19 trades this week</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.getElementById('topMealsList').innerHTML = mealsHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadDashboard();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
180
admin/js/admin-app.js
Normal file
180
admin/js/admin-app.js
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
/* ============================================
|
||||||
|
Grubflip Admin — Core Application Module
|
||||||
|
Sidebar, API client, toast, auth helpers
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
const AdminApp = (() => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const API_BASE = '/api';
|
||||||
|
|
||||||
|
// --- Auth ---
|
||||||
|
function getToken() {
|
||||||
|
return localStorage.getItem('gf_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUser() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem('gf_user') || '{}');
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireAuth() {
|
||||||
|
if (!getToken()) {
|
||||||
|
window.location.href = 'index.html';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
localStorage.removeItem('gf_token');
|
||||||
|
localStorage.removeItem('gf_user');
|
||||||
|
window.location.href = 'index.html';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- API Client ---
|
||||||
|
async function api(endpoint, options = {}) {
|
||||||
|
const url = `${API_BASE}${endpoint}`;
|
||||||
|
const headers = {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
...options.headers,
|
||||||
|
};
|
||||||
|
|
||||||
|
const token = getToken();
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.body && !(options.body instanceof FormData)) {
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
|
if (typeof options.body === 'object') {
|
||||||
|
options.body = JSON.stringify(options.body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = await fetch(url, { ...options, headers });
|
||||||
|
|
||||||
|
if (resp.status === 401) {
|
||||||
|
logout();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(data.message || `Request failed (${resp.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resp.status === 204) return null;
|
||||||
|
return resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Sidebar ---
|
||||||
|
function initSidebar() {
|
||||||
|
const sidebar = document.getElementById('sidebar');
|
||||||
|
const overlay = document.getElementById('sidebarOverlay');
|
||||||
|
const toggle = document.getElementById('menuToggle');
|
||||||
|
|
||||||
|
if (!sidebar || !toggle) return;
|
||||||
|
|
||||||
|
toggle.addEventListener('click', () => {
|
||||||
|
sidebar.classList.toggle('open');
|
||||||
|
if (overlay) overlay.classList.toggle('visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (overlay) {
|
||||||
|
overlay.addEventListener('click', () => {
|
||||||
|
sidebar.classList.remove('open');
|
||||||
|
overlay.classList.remove('visible');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close on Escape
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && sidebar.classList.contains('open')) {
|
||||||
|
sidebar.classList.remove('open');
|
||||||
|
if (overlay) overlay.classList.remove('visible');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Toast System ---
|
||||||
|
function toast(message, type = 'info', duration = 4000) {
|
||||||
|
const container = document.getElementById('toastContainer');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
success: '✅',
|
||||||
|
error: '❌',
|
||||||
|
warning: '⚠️',
|
||||||
|
info: 'ℹ️'
|
||||||
|
};
|
||||||
|
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = `toast toast-${type}`;
|
||||||
|
el.innerHTML = `<span>${icons[type] || ''}</span> <span>${escapeHtml(message)}</span>`;
|
||||||
|
container.appendChild(el);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
el.classList.add('removing');
|
||||||
|
el.addEventListener('animationend', () => el.remove());
|
||||||
|
}, duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Utilities ---
|
||||||
|
function escapeHtml(str) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = str;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
if (!dateStr) return '--';
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(dateStr) {
|
||||||
|
if (!dateStr) return '--';
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(dateStr) {
|
||||||
|
if (!dateStr) return '--';
|
||||||
|
const now = Date.now();
|
||||||
|
const then = new Date(dateStr).getTime();
|
||||||
|
const diff = Math.floor((now - then) / 1000);
|
||||||
|
|
||||||
|
if (diff < 60) return 'just now';
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)} min ago`;
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)} hr ago`;
|
||||||
|
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
|
||||||
|
return formatDate(dateStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Confirm Dialog ---
|
||||||
|
function confirm(message) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
// Use native confirm for now — can replace with modal later
|
||||||
|
resolve(window.confirm(message));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
api,
|
||||||
|
getToken,
|
||||||
|
getUser,
|
||||||
|
requireAuth,
|
||||||
|
logout,
|
||||||
|
initSidebar,
|
||||||
|
toast,
|
||||||
|
escapeHtml,
|
||||||
|
formatDate,
|
||||||
|
formatTime,
|
||||||
|
timeAgo,
|
||||||
|
confirm,
|
||||||
|
};
|
||||||
|
})();
|
||||||
542
admin/meals.html
Normal file
542
admin/meals.html
Normal file
|
|
@ -0,0 +1,542 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Grubflip Admin — Meals</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@600;700;800&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="css/tokens.css">
|
||||||
|
<link rel="stylesheet" href="css/admin.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="admin-layout">
|
||||||
|
<!-- Sidebar Overlay (mobile) -->
|
||||||
|
<div class="sidebar-overlay" id="sidebarOverlay"></div>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="sidebar" id="sidebar" role="navigation" aria-label="Admin navigation">
|
||||||
|
<div class="sidebar-brand">
|
||||||
|
<div class="logo-icon">GF</div>
|
||||||
|
<span>Grubflip</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
<div class="sidebar-section-title">Main</div>
|
||||||
|
<a href="dashboard.html" class="sidebar-link">
|
||||||
|
<span class="icon">📊</span> Dashboard
|
||||||
|
</a>
|
||||||
|
<a href="meals.html" class="sidebar-link active">
|
||||||
|
<span class="icon">🍔</span> Meals
|
||||||
|
</a>
|
||||||
|
<a href="orders.html" class="sidebar-link">
|
||||||
|
<span class="icon">📦</span> Orders
|
||||||
|
</a>
|
||||||
|
<a href="trades.html" class="sidebar-link">
|
||||||
|
<span class="icon">🔄</span> Trades
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="sidebar-section-title">Settings</div>
|
||||||
|
<a href="profile.html" class="sidebar-link">
|
||||||
|
<span class="icon">🏪</span> Restaurant Profile
|
||||||
|
</a>
|
||||||
|
<a href="settings.html" class="sidebar-link">
|
||||||
|
<span class="icon">⚙️</span> Settings
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div class="sidebar-user">
|
||||||
|
<div class="avatar" id="userAvatar">DR</div>
|
||||||
|
<div class="user-info">
|
||||||
|
<div class="user-name" id="userName">Demo Restaurant</div>
|
||||||
|
<div class="user-role" id="userRole">Owner</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="main-content">
|
||||||
|
<!-- Top Bar -->
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="topbar-left">
|
||||||
|
<button class="menu-toggle" id="menuToggle" aria-label="Toggle menu">
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
|
<h1 class="topbar-title">Meals</h1>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-right">
|
||||||
|
<button class="topbar-btn" aria-label="Sign out" id="logoutBtn">
|
||||||
|
⏻
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Page Content -->
|
||||||
|
<div class="page-content">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Manage Meals</h1>
|
||||||
|
<p class="subtitle">Add, edit, and manage your restaurant's meal listings.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="meals-toolbar">
|
||||||
|
<div class="search-box">
|
||||||
|
<span class="search-icon">🔍</span>
|
||||||
|
<input type="text" class="form-input" id="searchInput" placeholder="Search meals...">
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<select class="form-select" id="filterStatus" style="width: auto; min-width: 8rem;">
|
||||||
|
<option value="">All Status</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="draft">Draft</option>
|
||||||
|
<option value="paused">Paused</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-primary" id="addMealBtn">
|
||||||
|
+ Add Meal
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Meals Grid -->
|
||||||
|
<div id="mealsContainer">
|
||||||
|
<div class="loading-overlay"><div class="spinner"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add/Edit Meal Modal -->
|
||||||
|
<div class="modal-overlay" id="mealModal">
|
||||||
|
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="modalTitle">Add Meal</h2>
|
||||||
|
<button class="modal-close" id="modalClose" aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="mealForm" novalidate>
|
||||||
|
<input type="hidden" id="mealId" value="">
|
||||||
|
|
||||||
|
<!-- Image Upload -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Photo</label>
|
||||||
|
<div class="img-preview-wrapper" id="imgPreview">
|
||||||
|
<div class="upload-label">
|
||||||
|
<span>📷</span>
|
||||||
|
Tap to upload a photo
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input type="file" id="mealImage" accept="image/*" class="hidden">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="mealTitle" class="form-label">Meal Title *</label>
|
||||||
|
<input type="text" id="mealTitle" class="form-input" placeholder="e.g. Spicy Ramen Bowl" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="mealDesc" class="form-label">Description</label>
|
||||||
|
<textarea id="mealDesc" class="form-textarea" placeholder="Describe the meal, ingredients, what makes it special..."></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Category & Tags -->
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="mealCategory" class="form-label">Category</label>
|
||||||
|
<select id="mealCategory" class="form-select">
|
||||||
|
<option value="">Select category</option>
|
||||||
|
<option value="bowls">Bowls</option>
|
||||||
|
<option value="burgers">Burgers</option>
|
||||||
|
<option value="wraps">Wraps & Sandwiches</option>
|
||||||
|
<option value="salads">Salads</option>
|
||||||
|
<option value="pasta">Pasta</option>
|
||||||
|
<option value="tacos">Tacos & Burritos</option>
|
||||||
|
<option value="pizza">Pizza</option>
|
||||||
|
<option value="dessert">Desserts</option>
|
||||||
|
<option value="drinks">Drinks</option>
|
||||||
|
<option value="other">Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="mealCalories" class="form-label">Calories</label>
|
||||||
|
<input type="number" id="mealCalories" class="form-input" placeholder="e.g. 520">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Price & Availability -->
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="mealPrice" class="form-label">Price ($)</label>
|
||||||
|
<input type="number" id="mealPrice" class="form-input" placeholder="0.00" step="0.01" min="0">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="mealStatus" class="form-label">Status</label>
|
||||||
|
<select id="mealStatus" class="form-select">
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="draft">Draft</option>
|
||||||
|
<option value="paused">Paused</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="mealTags" class="form-label">Tags</label>
|
||||||
|
<input type="text" id="mealTags" class="form-input" placeholder="spicy, vegan, gluten-free (comma separated)">
|
||||||
|
<span class="form-hint">Comma-separated. Helps users find your meal.</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-ghost" id="modalCancel">Cancel</button>
|
||||||
|
<button class="btn btn-primary" id="modalSave">Save Meal</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Modal -->
|
||||||
|
<div class="modal-overlay" id="deleteModal">
|
||||||
|
<div class="modal" style="max-width: 24rem;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Delete Meal</h2>
|
||||||
|
<button class="modal-close" id="deleteModalClose" aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>Are you sure you want to delete <strong id="deleteMealName"></strong>? This action cannot be undone.</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-ghost" id="deleteCancelBtn">Cancel</button>
|
||||||
|
<button class="btn btn-danger" id="deleteConfirmBtn">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast Container -->
|
||||||
|
<div class="toast-container" id="toastContainer"></div>
|
||||||
|
|
||||||
|
<script src="js/admin-app.js"></script>
|
||||||
|
<script>
|
||||||
|
// --- Auth Guard ---
|
||||||
|
if (!AdminApp.requireAuth()) throw new Error('Not authenticated');
|
||||||
|
const user = AdminApp.getUser();
|
||||||
|
|
||||||
|
// Populate user sidebar
|
||||||
|
if (user.name) {
|
||||||
|
document.getElementById('userName').textContent = user.name;
|
||||||
|
document.getElementById('userAvatar').textContent = user.name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
if (user.role) {
|
||||||
|
document.getElementById('userRole').textContent = user.role.charAt(0).toUpperCase() + user.role.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Init ---
|
||||||
|
AdminApp.initSidebar();
|
||||||
|
|
||||||
|
document.getElementById('logoutBtn').addEventListener('click', AdminApp.logout);
|
||||||
|
|
||||||
|
// --- State ---
|
||||||
|
let meals = [];
|
||||||
|
let editingMealId = null;
|
||||||
|
let deletingMealId = null;
|
||||||
|
|
||||||
|
// --- Demo Data ---
|
||||||
|
const DEMO_MEALS = [
|
||||||
|
{
|
||||||
|
id: 1, title: 'Spicy Ramen Bowl', description: 'Rich miso broth with hand-pulled noodles, chashu pork, soft egg, nori, and chili oil. Our most popular dish.', category: 'bowls', calories: 680, price: 14.99, status: 'active', tags: ['spicy', 'pork', 'noodles'], trades: 47, image: null, created_at: '2026-03-20T10:00:00Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2, title: 'Chicken Burrito', description: 'Grilled chicken, cilantro lime rice, black beans, pico de gallo, sour cream, and guacamole in a flour tortilla.', category: 'tacos', calories: 720, price: 12.99, status: 'active', tags: ['chicken', 'mexican'], trades: 38, image: null, created_at: '2026-03-19T14:30:00Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3, title: 'Veggie Power Bowl', description: 'Quinoa, roasted sweet potato, avocado, chickpeas, kale, tahini dressing. 100% plant-based goodness.', category: 'bowls', calories: 520, price: 13.49, status: 'active', tags: ['vegan', 'healthy', 'gluten-free'], trades: 31, image: null, created_at: '2026-03-18T09:15:00Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4, title: 'BBQ Pulled Pork', description: 'Slow-smoked pulled pork with house-made BBQ sauce, coleslaw, and pickles on a brioche bun.', category: 'burgers', calories: 650, price: 11.99, status: 'active', tags: ['bbq', 'pork'], trades: 24, image: null, created_at: '2026-03-17T16:00:00Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5, title: 'Greek Salad Wrap', description: 'Fresh romaine, feta, olives, cucumber, tomato, red onion, and Greek dressing in a spinach wrap.', category: 'wraps', calories: 380, price: 10.49, status: 'active', tags: ['healthy', 'vegetarian'], trades: 19, image: null, created_at: '2026-03-16T11:00:00Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6, title: 'Truffle Mac & Cheese', description: 'Creamy four-cheese blend with truffle oil, topped with panko breadcrumbs. Ultimate comfort food.', category: 'pasta', calories: 780, price: 15.99, status: 'draft', tags: ['comfort', 'cheese', 'truffle'], trades: 0, image: null, created_at: '2026-03-26T08:00:00Z'
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- Load Meals ---
|
||||||
|
async function loadMeals() {
|
||||||
|
try {
|
||||||
|
const data = await AdminApp.api('/admin/meals');
|
||||||
|
meals = data.meals || data;
|
||||||
|
} catch {
|
||||||
|
// API not ready — use demo data
|
||||||
|
meals = [...DEMO_MEALS];
|
||||||
|
}
|
||||||
|
renderMeals();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Render ---
|
||||||
|
function renderMeals() {
|
||||||
|
const container = document.getElementById('mealsContainer');
|
||||||
|
const search = document.getElementById('searchInput').value.toLowerCase();
|
||||||
|
const statusFilter = document.getElementById('filterStatus').value;
|
||||||
|
|
||||||
|
let filtered = meals.filter(m => {
|
||||||
|
if (search && !m.title.toLowerCase().includes(search) && !(m.description || '').toLowerCase().includes(search)) return false;
|
||||||
|
if (statusFilter && m.status !== statusFilter) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="icon">🍔</div>
|
||||||
|
<h3>${search || statusFilter ? 'No meals match your filters' : 'No meals yet'}</h3>
|
||||||
|
<p>${search || statusFilter ? 'Try adjusting your search or filters.' : 'Add your first meal to get started on Grubflip.'}</p>
|
||||||
|
${!search && !statusFilter ? '<button class="btn btn-primary" onclick="openAddModal()">+ Add Your First Meal</button>' : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusBadge = (status) => {
|
||||||
|
const map = { active: 'badge-success', draft: 'badge-default', paused: 'badge-warning' };
|
||||||
|
return `<span class="badge ${map[status] || 'badge-default'}">${status}</span>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const categoryLabel = (cat) => {
|
||||||
|
const map = { bowls: 'Bowls', burgers: 'Burgers', wraps: 'Wraps', salads: 'Salads', pasta: 'Pasta', tacos: 'Tacos', pizza: 'Pizza', dessert: 'Desserts', drinks: 'Drinks', other: 'Other' };
|
||||||
|
return map[cat] || cat || 'Uncategorized';
|
||||||
|
};
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="meals-grid">
|
||||||
|
${filtered.map(m => `
|
||||||
|
<div class="meal-card" data-id="${m.id}">
|
||||||
|
${m.image
|
||||||
|
? `<img class="meal-card-img" src="${AdminApp.escapeHtml(m.image)}" alt="${AdminApp.escapeHtml(m.title)}">`
|
||||||
|
: `<div class="meal-card-img-placeholder">🍽</div>`
|
||||||
|
}
|
||||||
|
<div class="meal-card-body">
|
||||||
|
<div class="meal-card-title">${AdminApp.escapeHtml(m.title)}</div>
|
||||||
|
<div class="meal-card-meta">
|
||||||
|
<span>${categoryLabel(m.category)}</span>
|
||||||
|
${m.calories ? `<span>${m.calories} cal</span>` : ''}
|
||||||
|
${m.price ? `<span>$${Number(m.price).toFixed(2)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="meal-card-desc">${AdminApp.escapeHtml(m.description || '')}</div>
|
||||||
|
<div class="meal-card-footer">
|
||||||
|
${statusBadge(m.status)}
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="openEditModal(${m.id})" aria-label="Edit ${AdminApp.escapeHtml(m.title)}">✏ Edit</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="openDeleteModal(${m.id})" aria-label="Delete ${AdminApp.escapeHtml(m.title)}" style="color:var(--gf-error)">🗑</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Search & Filter ---
|
||||||
|
document.getElementById('searchInput').addEventListener('input', renderMeals);
|
||||||
|
document.getElementById('filterStatus').addEventListener('change', renderMeals);
|
||||||
|
|
||||||
|
// --- Modal Helpers ---
|
||||||
|
const mealModal = document.getElementById('mealModal');
|
||||||
|
const deleteModal = document.getElementById('deleteModal');
|
||||||
|
|
||||||
|
function openModal(modal) {
|
||||||
|
modal.classList.add('visible');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal(modal) {
|
||||||
|
modal.classList.remove('visible');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetMealForm() {
|
||||||
|
document.getElementById('mealForm').reset();
|
||||||
|
document.getElementById('mealId').value = '';
|
||||||
|
document.getElementById('imgPreview').innerHTML = '<div class="upload-label"><span>📷</span>Tap to upload a photo</div>';
|
||||||
|
editingMealId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Add Meal ---
|
||||||
|
function openAddModal() {
|
||||||
|
resetMealForm();
|
||||||
|
document.getElementById('modalTitle').textContent = 'Add Meal';
|
||||||
|
document.getElementById('modalSave').textContent = 'Save Meal';
|
||||||
|
openModal(mealModal);
|
||||||
|
}
|
||||||
|
document.getElementById('addMealBtn').addEventListener('click', openAddModal);
|
||||||
|
|
||||||
|
// --- Edit Meal ---
|
||||||
|
function openEditModal(id) {
|
||||||
|
const meal = meals.find(m => m.id === id);
|
||||||
|
if (!meal) return;
|
||||||
|
|
||||||
|
resetMealForm();
|
||||||
|
editingMealId = id;
|
||||||
|
document.getElementById('modalTitle').textContent = 'Edit Meal';
|
||||||
|
document.getElementById('modalSave').textContent = 'Update Meal';
|
||||||
|
document.getElementById('mealId').value = id;
|
||||||
|
document.getElementById('mealTitle').value = meal.title || '';
|
||||||
|
document.getElementById('mealDesc').value = meal.description || '';
|
||||||
|
document.getElementById('mealCategory').value = meal.category || '';
|
||||||
|
document.getElementById('mealCalories').value = meal.calories || '';
|
||||||
|
document.getElementById('mealPrice').value = meal.price || '';
|
||||||
|
document.getElementById('mealStatus').value = meal.status || 'active';
|
||||||
|
document.getElementById('mealTags').value = (meal.tags || []).join(', ');
|
||||||
|
|
||||||
|
if (meal.image) {
|
||||||
|
document.getElementById('imgPreview').innerHTML = `<img src="${AdminApp.escapeHtml(meal.image)}" alt="Preview">`;
|
||||||
|
}
|
||||||
|
|
||||||
|
openModal(mealModal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Delete Meal ---
|
||||||
|
function openDeleteModal(id) {
|
||||||
|
const meal = meals.find(m => m.id === id);
|
||||||
|
if (!meal) return;
|
||||||
|
|
||||||
|
deletingMealId = id;
|
||||||
|
document.getElementById('deleteMealName').textContent = meal.title;
|
||||||
|
openModal(deleteModal);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('deleteConfirmBtn').addEventListener('click', async () => {
|
||||||
|
if (!deletingMealId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await AdminApp.api(`/admin/meals/${deletingMealId}`, { method: 'DELETE' });
|
||||||
|
} catch {
|
||||||
|
// Demo mode — just remove locally
|
||||||
|
}
|
||||||
|
|
||||||
|
meals = meals.filter(m => m.id !== deletingMealId);
|
||||||
|
deletingMealId = null;
|
||||||
|
closeModal(deleteModal);
|
||||||
|
renderMeals();
|
||||||
|
AdminApp.toast('Meal deleted', 'success');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Save Meal ---
|
||||||
|
document.getElementById('modalSave').addEventListener('click', async () => {
|
||||||
|
const title = document.getElementById('mealTitle').value.trim();
|
||||||
|
if (!title) {
|
||||||
|
document.getElementById('mealTitle').classList.add('error');
|
||||||
|
document.getElementById('mealTitle').focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById('mealTitle').classList.remove('error');
|
||||||
|
|
||||||
|
const mealData = {
|
||||||
|
title,
|
||||||
|
description: document.getElementById('mealDesc').value.trim(),
|
||||||
|
category: document.getElementById('mealCategory').value,
|
||||||
|
calories: parseInt(document.getElementById('mealCalories').value) || null,
|
||||||
|
price: parseFloat(document.getElementById('mealPrice').value) || null,
|
||||||
|
status: document.getElementById('mealStatus').value,
|
||||||
|
tags: document.getElementById('mealTags').value.split(',').map(t => t.trim()).filter(Boolean),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (editingMealId) {
|
||||||
|
const updated = await AdminApp.api(`/admin/meals/${editingMealId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: mealData,
|
||||||
|
});
|
||||||
|
if (updated) {
|
||||||
|
const idx = meals.findIndex(m => m.id === editingMealId);
|
||||||
|
if (idx !== -1) meals[idx] = { ...meals[idx], ...updated };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const created = await AdminApp.api('/admin/meals', {
|
||||||
|
method: 'POST',
|
||||||
|
body: mealData,
|
||||||
|
});
|
||||||
|
if (created) meals.unshift(created);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Demo mode — update locally
|
||||||
|
if (editingMealId) {
|
||||||
|
const idx = meals.findIndex(m => m.id === editingMealId);
|
||||||
|
if (idx !== -1) meals[idx] = { ...meals[idx], ...mealData };
|
||||||
|
} else {
|
||||||
|
const newMeal = {
|
||||||
|
id: Date.now(),
|
||||||
|
...mealData,
|
||||||
|
trades: 0,
|
||||||
|
image: null,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
meals.unshift(newMeal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closeModal(mealModal);
|
||||||
|
renderMeals();
|
||||||
|
AdminApp.toast(editingMealId ? 'Meal updated' : 'Meal added', 'success');
|
||||||
|
editingMealId = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Image Upload ---
|
||||||
|
const imgPreview = document.getElementById('imgPreview');
|
||||||
|
const imageInput = document.getElementById('mealImage');
|
||||||
|
|
||||||
|
imgPreview.addEventListener('click', () => imageInput.click());
|
||||||
|
|
||||||
|
imageInput.addEventListener('change', (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
AdminApp.toast('Please select an image file', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
AdminApp.toast('Image must be under 5MB', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (ev) => {
|
||||||
|
imgPreview.innerHTML = `<img src="${ev.target.result}" alt="Preview">`;
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Modal Close Handlers ---
|
||||||
|
document.getElementById('modalClose').addEventListener('click', () => closeModal(mealModal));
|
||||||
|
document.getElementById('modalCancel').addEventListener('click', () => closeModal(mealModal));
|
||||||
|
document.getElementById('deleteModalClose').addEventListener('click', () => closeModal(deleteModal));
|
||||||
|
document.getElementById('deleteCancelBtn').addEventListener('click', () => closeModal(deleteModal));
|
||||||
|
|
||||||
|
// Close on overlay click
|
||||||
|
mealModal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === mealModal) closeModal(mealModal);
|
||||||
|
});
|
||||||
|
deleteModal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === deleteModal) closeModal(deleteModal);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close on Escape
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeModal(mealModal);
|
||||||
|
closeModal(deleteModal);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Boot ---
|
||||||
|
loadMeals();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
311
admin/orders.html
Normal file
311
admin/orders.html
Normal file
|
|
@ -0,0 +1,311 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Grubflip Admin — Orders</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@600;700;800&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="css/tokens.css">
|
||||||
|
<link rel="stylesheet" href="css/admin.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="admin-layout">
|
||||||
|
<div class="sidebar-overlay" id="sidebarOverlay"></div>
|
||||||
|
|
||||||
|
<aside class="sidebar" id="sidebar" role="navigation" aria-label="Admin navigation">
|
||||||
|
<div class="sidebar-brand">
|
||||||
|
<div class="logo-icon">GF</div>
|
||||||
|
<span>Grubflip</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
<div class="sidebar-section-title">Main</div>
|
||||||
|
<a href="dashboard.html" class="sidebar-link">
|
||||||
|
<span class="icon">📊</span> Dashboard
|
||||||
|
</a>
|
||||||
|
<a href="meals.html" class="sidebar-link">
|
||||||
|
<span class="icon">🍔</span> Meals
|
||||||
|
</a>
|
||||||
|
<a href="orders.html" class="sidebar-link active">
|
||||||
|
<span class="icon">📦</span> Orders
|
||||||
|
</a>
|
||||||
|
<a href="trades.html" class="sidebar-link">
|
||||||
|
<span class="icon">🔄</span> Trades
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="sidebar-section-title">Settings</div>
|
||||||
|
<a href="profile.html" class="sidebar-link">
|
||||||
|
<span class="icon">🏪</span> Restaurant Profile
|
||||||
|
</a>
|
||||||
|
<a href="settings.html" class="sidebar-link">
|
||||||
|
<span class="icon">⚙️</span> Settings
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div class="sidebar-user">
|
||||||
|
<div class="avatar" id="userAvatar">DR</div>
|
||||||
|
<div class="user-info">
|
||||||
|
<div class="user-name" id="userName">Demo Restaurant</div>
|
||||||
|
<div class="user-role" id="userRole">Owner</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="main-content">
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="topbar-left">
|
||||||
|
<button class="menu-toggle" id="menuToggle" aria-label="Toggle menu">☰</button>
|
||||||
|
<h1 class="topbar-title">Orders</h1>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-right">
|
||||||
|
<button class="topbar-btn" aria-label="Sign out" id="logoutBtn">⏻</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="page-content">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Orders</h1>
|
||||||
|
<p class="subtitle">Track and manage incoming meal orders from trades.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="tabs">
|
||||||
|
<button class="tab active" data-tab="all">All Orders</button>
|
||||||
|
<button class="tab" data-tab="pending">Pending</button>
|
||||||
|
<button class="tab" data-tab="preparing">Preparing</button>
|
||||||
|
<button class="tab" data-tab="ready">Ready</button>
|
||||||
|
<button class="tab" data-tab="completed">Completed</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Orders Table -->
|
||||||
|
<div class="card">
|
||||||
|
<div id="ordersContainer">
|
||||||
|
<div class="loading-overlay"><div class="spinner"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Order Detail Modal -->
|
||||||
|
<div class="modal-overlay" id="orderModal">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Order Details</h2>
|
||||||
|
<button class="modal-close" id="orderModalClose" aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="orderModalBody">
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" id="orderModalFooter">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast-container" id="toastContainer"></div>
|
||||||
|
|
||||||
|
<script src="js/admin-app.js"></script>
|
||||||
|
<script>
|
||||||
|
if (!AdminApp.requireAuth()) throw new Error('Not authenticated');
|
||||||
|
const user = AdminApp.getUser();
|
||||||
|
|
||||||
|
if (user.name) {
|
||||||
|
document.getElementById('userName').textContent = user.name;
|
||||||
|
document.getElementById('userAvatar').textContent = user.name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
if (user.role) {
|
||||||
|
document.getElementById('userRole').textContent = user.role.charAt(0).toUpperCase() + user.role.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
AdminApp.initSidebar();
|
||||||
|
document.getElementById('logoutBtn').addEventListener('click', AdminApp.logout);
|
||||||
|
|
||||||
|
// --- State ---
|
||||||
|
let orders = [];
|
||||||
|
let currentTab = 'all';
|
||||||
|
|
||||||
|
const DEMO_ORDERS = [
|
||||||
|
{ id: 1001, user: 'Jamie L.', meal: 'Spicy Ramen Bowl', status: 'completed', time: '2026-03-27T15:10:00Z', notes: '' },
|
||||||
|
{ id: 1002, user: 'Alex K.', meal: 'Chicken Burrito', status: 'pending', time: '2026-03-27T15:15:00Z', notes: 'No onions please' },
|
||||||
|
{ id: 1003, user: 'Sam R.', meal: 'Veggie Power Bowl', status: 'completed', time: '2026-03-27T14:50:00Z', notes: '' },
|
||||||
|
{ id: 1004, user: 'Morgan T.', meal: 'BBQ Pulled Pork', status: 'preparing', time: '2026-03-27T14:35:00Z', notes: 'Extra sauce' },
|
||||||
|
{ id: 1005, user: 'Riley W.', meal: 'Greek Salad Wrap', status: 'ready', time: '2026-03-27T14:20:00Z', notes: '' },
|
||||||
|
{ id: 1006, user: 'Casey P.', meal: 'Spicy Ramen Bowl', status: 'pending', time: '2026-03-27T15:25:00Z', notes: 'Mild spice level' },
|
||||||
|
{ id: 1007, user: 'Jordan B.', meal: 'Chicken Burrito', status: 'preparing', time: '2026-03-27T14:55:00Z', notes: '' },
|
||||||
|
{ id: 1008, user: 'Taylor M.', meal: 'Veggie Power Bowl', status: 'completed', time: '2026-03-27T13:30:00Z', notes: 'Extra avocado' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- Tabs ---
|
||||||
|
document.querySelectorAll('.tab').forEach(tab => {
|
||||||
|
tab.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||||
|
tab.classList.add('active');
|
||||||
|
currentTab = tab.dataset.tab;
|
||||||
|
renderOrders();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Load ---
|
||||||
|
async function loadOrders() {
|
||||||
|
try {
|
||||||
|
const data = await AdminApp.api('/admin/orders');
|
||||||
|
orders = data.orders || data;
|
||||||
|
} catch {
|
||||||
|
orders = [...DEMO_ORDERS];
|
||||||
|
}
|
||||||
|
renderOrders();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Render ---
|
||||||
|
function renderOrders() {
|
||||||
|
const container = document.getElementById('ordersContainer');
|
||||||
|
let filtered = orders;
|
||||||
|
if (currentTab !== 'all') {
|
||||||
|
filtered = orders.filter(o => o.status === currentTab);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="icon">📦</div>
|
||||||
|
<h3>No ${currentTab === 'all' ? '' : currentTab + ' '}orders</h3>
|
||||||
|
<p>Orders will appear here when users trade for your meals.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusBadge = (s) => {
|
||||||
|
const map = {
|
||||||
|
pending: 'badge-warning',
|
||||||
|
preparing: 'badge-info',
|
||||||
|
ready: 'badge-primary',
|
||||||
|
completed: 'badge-success',
|
||||||
|
cancelled: 'badge-error',
|
||||||
|
};
|
||||||
|
return `<span class="badge ${map[s] || 'badge-default'}">${s}</span>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Order #</th>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Meal</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${filtered.map(o => `
|
||||||
|
<tr>
|
||||||
|
<td class="font-mono">#${o.id}</td>
|
||||||
|
<td>${AdminApp.escapeHtml(o.user)}</td>
|
||||||
|
<td>${AdminApp.escapeHtml(o.meal)}</td>
|
||||||
|
<td>${statusBadge(o.status)}</td>
|
||||||
|
<td class="text-muted">${AdminApp.timeAgo(o.time)}</td>
|
||||||
|
<td class="actions">
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="viewOrder(${o.id})">View</button>
|
||||||
|
${o.status === 'pending' ? `<button class="btn btn-sm btn-primary" onclick="updateStatus(${o.id}, 'preparing')">Accept</button>` : ''}
|
||||||
|
${o.status === 'preparing' ? `<button class="btn btn-sm btn-success" onclick="updateStatus(${o.id}, 'ready')">Ready</button>` : ''}
|
||||||
|
${o.status === 'ready' ? `<button class="btn btn-sm btn-success" onclick="updateStatus(${o.id}, 'completed')">Complete</button>` : ''}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Update Status ---
|
||||||
|
async function updateStatus(id, newStatus) {
|
||||||
|
try {
|
||||||
|
await AdminApp.api(`/admin/orders/${id}/status`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: { status: newStatus },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Demo mode
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = orders.find(o => o.id === id);
|
||||||
|
if (order) order.status = newStatus;
|
||||||
|
renderOrders();
|
||||||
|
|
||||||
|
const labels = { preparing: 'Order accepted', ready: 'Marked as ready', completed: 'Order completed' };
|
||||||
|
AdminApp.toast(labels[newStatus] || 'Status updated', 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- View Order ---
|
||||||
|
function viewOrder(id) {
|
||||||
|
const order = orders.find(o => o.id === id);
|
||||||
|
if (!order) return;
|
||||||
|
|
||||||
|
const modal = document.getElementById('orderModal');
|
||||||
|
document.getElementById('orderModalBody').innerHTML = `
|
||||||
|
<div style="display:flex;flex-direction:column;gap:var(--gf-space-4)">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="font-mono text-muted">Order #${order.id}</span>
|
||||||
|
${(() => {
|
||||||
|
const map = { pending: 'badge-warning', preparing: 'badge-info', ready: 'badge-primary', completed: 'badge-success' };
|
||||||
|
return `<span class="badge ${map[order.status] || 'badge-default'}">${order.status}</span>`;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-muted" style="text-transform:uppercase;letter-spacing:0.05em;margin-bottom:0.25rem">Customer</div>
|
||||||
|
<strong>${AdminApp.escapeHtml(order.user)}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-muted" style="text-transform:uppercase;letter-spacing:0.05em;margin-bottom:0.25rem">Meal</div>
|
||||||
|
<strong>${AdminApp.escapeHtml(order.meal)}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-muted" style="text-transform:uppercase;letter-spacing:0.05em;margin-bottom:0.25rem">Time</div>
|
||||||
|
<span>${AdminApp.formatTime(order.time)} · ${AdminApp.formatDate(order.time)}</span>
|
||||||
|
</div>
|
||||||
|
${order.notes ? `
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-muted" style="text-transform:uppercase;letter-spacing:0.05em;margin-bottom:0.25rem">Notes</div>
|
||||||
|
<p>${AdminApp.escapeHtml(order.notes)}</p>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Footer buttons based on status
|
||||||
|
const footer = document.getElementById('orderModalFooter');
|
||||||
|
let btns = '<button class="btn btn-ghost" onclick="closeOrderModal()">Close</button>';
|
||||||
|
if (order.status === 'pending') btns += `<button class="btn btn-primary" onclick="updateStatus(${order.id}, 'preparing'); closeOrderModal()">Accept Order</button>`;
|
||||||
|
if (order.status === 'preparing') btns += `<button class="btn btn-success" onclick="updateStatus(${order.id}, 'ready'); closeOrderModal()">Mark Ready</button>`;
|
||||||
|
if (order.status === 'ready') btns += `<button class="btn btn-success" onclick="updateStatus(${order.id}, 'completed'); closeOrderModal()">Complete</button>`;
|
||||||
|
footer.innerHTML = btns;
|
||||||
|
|
||||||
|
modal.classList.add('visible');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeOrderModal() {
|
||||||
|
const modal = document.getElementById('orderModal');
|
||||||
|
modal.classList.remove('visible');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('orderModalClose').addEventListener('click', closeOrderModal);
|
||||||
|
document.getElementById('orderModal').addEventListener('click', (e) => {
|
||||||
|
if (e.target.id === 'orderModal') closeOrderModal();
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') closeOrderModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
loadOrders();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
233
admin/trades.html
Normal file
233
admin/trades.html
Normal file
|
|
@ -0,0 +1,233 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Grubflip Admin — Trades</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@600;700;800&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="css/tokens.css">
|
||||||
|
<link rel="stylesheet" href="css/admin.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="admin-layout">
|
||||||
|
<div class="sidebar-overlay" id="sidebarOverlay"></div>
|
||||||
|
|
||||||
|
<aside class="sidebar" id="sidebar" role="navigation" aria-label="Admin navigation">
|
||||||
|
<div class="sidebar-brand">
|
||||||
|
<div class="logo-icon">GF</div>
|
||||||
|
<span>Grubflip</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
<div class="sidebar-section-title">Main</div>
|
||||||
|
<a href="dashboard.html" class="sidebar-link">
|
||||||
|
<span class="icon">📊</span> Dashboard
|
||||||
|
</a>
|
||||||
|
<a href="meals.html" class="sidebar-link">
|
||||||
|
<span class="icon">🍔</span> Meals
|
||||||
|
</a>
|
||||||
|
<a href="orders.html" class="sidebar-link">
|
||||||
|
<span class="icon">📦</span> Orders
|
||||||
|
</a>
|
||||||
|
<a href="trades.html" class="sidebar-link active">
|
||||||
|
<span class="icon">🔄</span> Trades
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="sidebar-section-title">Settings</div>
|
||||||
|
<a href="profile.html" class="sidebar-link">
|
||||||
|
<span class="icon">🏪</span> Restaurant Profile
|
||||||
|
</a>
|
||||||
|
<a href="settings.html" class="sidebar-link">
|
||||||
|
<span class="icon">⚙️</span> Settings
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div class="sidebar-user">
|
||||||
|
<div class="avatar" id="userAvatar">DR</div>
|
||||||
|
<div class="user-info">
|
||||||
|
<div class="user-name" id="userName">Demo Restaurant</div>
|
||||||
|
<div class="user-role" id="userRole">Owner</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="main-content">
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="topbar-left">
|
||||||
|
<button class="menu-toggle" id="menuToggle" aria-label="Toggle menu">☰</button>
|
||||||
|
<h1 class="topbar-title">Trades</h1>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-right">
|
||||||
|
<button class="topbar-btn" aria-label="Sign out" id="logoutBtn">⏻</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="page-content">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Trade Activity</h1>
|
||||||
|
<p class="subtitle">See how your meals are being traded on Grubflip.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats row -->
|
||||||
|
<div class="stats-grid" style="margin-bottom: var(--gf-space-6);">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon green">🔄</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3>Total Trades</h3>
|
||||||
|
<div class="stat-value" id="totalTrades">159</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon orange">🔥</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3>This Week</h3>
|
||||||
|
<div class="stat-value" id="weekTrades">47</div>
|
||||||
|
<div class="stat-change up">+23% vs last week</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon yellow">⭐</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3>Most Traded</h3>
|
||||||
|
<div class="stat-value" id="topMeal" style="font-size:var(--gf-text-lg)">Spicy Ramen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon blue">📈</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3>Trade Rate</h3>
|
||||||
|
<div class="stat-value" id="tradeRate">87%</div>
|
||||||
|
<div class="stat-change up">Acceptance rate</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Trades Table -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2>Recent Trades</h2>
|
||||||
|
<div class="search-box" style="max-width: 14rem;">
|
||||||
|
<span class="search-icon">🔍</span>
|
||||||
|
<input type="text" class="form-input" id="searchTrades" placeholder="Search trades...">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="tradesContainer">
|
||||||
|
<div class="loading-overlay"><div class="spinner"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast-container" id="toastContainer"></div>
|
||||||
|
|
||||||
|
<script src="js/admin-app.js"></script>
|
||||||
|
<script>
|
||||||
|
if (!AdminApp.requireAuth()) throw new Error('Not authenticated');
|
||||||
|
const user = AdminApp.getUser();
|
||||||
|
if (user.name) {
|
||||||
|
document.getElementById('userName').textContent = user.name;
|
||||||
|
document.getElementById('userAvatar').textContent = user.name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
if (user.role) {
|
||||||
|
document.getElementById('userRole').textContent = user.role.charAt(0).toUpperCase() + user.role.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
AdminApp.initSidebar();
|
||||||
|
document.getElementById('logoutBtn').addEventListener('click', AdminApp.logout);
|
||||||
|
|
||||||
|
const DEMO_TRADES = [
|
||||||
|
{ id: 501, from_user: 'Jamie L.', to_user: 'Pat D.', meal: 'Spicy Ramen Bowl', offered: 'Pad Thai', status: 'completed', time: '2026-03-27T15:10:00Z' },
|
||||||
|
{ id: 502, from_user: 'Alex K.', to_user: 'Sam R.', meal: 'Chicken Burrito', offered: 'Falafel Wrap', status: 'pending', time: '2026-03-27T15:15:00Z' },
|
||||||
|
{ id: 503, from_user: 'Morgan T.', to_user: 'Riley W.', meal: 'BBQ Pulled Pork', offered: 'Fish Tacos', status: 'completed', time: '2026-03-27T14:35:00Z' },
|
||||||
|
{ id: 504, from_user: 'Casey P.', to_user: 'Jordan B.', meal: 'Veggie Power Bowl', offered: 'Caesar Salad', status: 'completed', time: '2026-03-27T13:20:00Z' },
|
||||||
|
{ id: 505, from_user: 'Taylor M.', to_user: 'Jamie L.', meal: 'Greek Salad Wrap', offered: 'Spicy Ramen Bowl', status: 'declined', time: '2026-03-27T12:45:00Z' },
|
||||||
|
{ id: 506, from_user: 'Pat D.', to_user: 'Alex K.', meal: 'Spicy Ramen Bowl', offered: 'Poke Bowl', status: 'pending', time: '2026-03-27T15:30:00Z' },
|
||||||
|
{ id: 507, from_user: 'Riley W.', to_user: 'Casey P.', meal: 'Chicken Burrito', offered: 'BBQ Pulled Pork', status: 'completed', time: '2026-03-27T11:00:00Z' },
|
||||||
|
{ id: 508, from_user: 'Jordan B.', to_user: 'Taylor M.', meal: 'Veggie Power Bowl', offered: 'Grilled Cheese', status: 'completed', time: '2026-03-26T16:30:00Z' },
|
||||||
|
];
|
||||||
|
|
||||||
|
let trades = [];
|
||||||
|
|
||||||
|
async function loadTrades() {
|
||||||
|
try {
|
||||||
|
const data = await AdminApp.api('/admin/trades');
|
||||||
|
trades = data.trades || data;
|
||||||
|
} catch {
|
||||||
|
trades = [...DEMO_TRADES];
|
||||||
|
}
|
||||||
|
renderTrades();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTrades() {
|
||||||
|
const container = document.getElementById('tradesContainer');
|
||||||
|
const search = document.getElementById('searchTrades').value.toLowerCase();
|
||||||
|
|
||||||
|
let filtered = trades;
|
||||||
|
if (search) {
|
||||||
|
filtered = trades.filter(t =>
|
||||||
|
t.from_user.toLowerCase().includes(search) ||
|
||||||
|
t.to_user.toLowerCase().includes(search) ||
|
||||||
|
t.meal.toLowerCase().includes(search) ||
|
||||||
|
t.offered.toLowerCase().includes(search)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="icon">🔄</div>
|
||||||
|
<h3>No trades found</h3>
|
||||||
|
<p>Trades involving your meals will appear here.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusBadge = (s) => {
|
||||||
|
const map = { pending: 'badge-warning', completed: 'badge-success', declined: 'badge-error', cancelled: 'badge-default' };
|
||||||
|
return `<span class="badge ${map[s] || 'badge-default'}">${s}</span>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Trade #</th>
|
||||||
|
<th>From</th>
|
||||||
|
<th>To</th>
|
||||||
|
<th>Meal Wanted</th>
|
||||||
|
<th>Offered</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Time</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${filtered.map(t => `
|
||||||
|
<tr>
|
||||||
|
<td class="font-mono">#${t.id}</td>
|
||||||
|
<td>${AdminApp.escapeHtml(t.from_user)}</td>
|
||||||
|
<td>${AdminApp.escapeHtml(t.to_user)}</td>
|
||||||
|
<td><strong>${AdminApp.escapeHtml(t.meal)}</strong></td>
|
||||||
|
<td>${AdminApp.escapeHtml(t.offered)}</td>
|
||||||
|
<td>${statusBadge(t.status)}</td>
|
||||||
|
<td class="text-muted">${AdminApp.timeAgo(t.time)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('searchTrades').addEventListener('input', renderTrades);
|
||||||
|
|
||||||
|
loadTrades();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Add table
Reference in a new issue