HamzaAri's picture
🚀 Deploy ScanMenu - Production-ready SaaS web app for digital restaurant menus & QR ordering
e1ef9fc verified
'use client';
import { DashboardLayout } from '@/components/layout/dashboard-layout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
Plus,
Search,
MoreHorizontal,
GripVertical,
Pencil,
Trash2,
Eye,
EyeOff,
Star,
Clock,
Flame,
ChevronDown,
ChevronRight,
X,
Image as ImageIcon,
DollarSign,
Tag,
AlertCircle,
} from 'lucide-react';
import { useState } from 'react';
import { demoCategories, demoProducts } from '@/lib/demo-data';
import { formatCurrency, cn } from '@/lib/utils';
import { Product, Category } from '@/types/database';
function ProductCard({ product, onEdit }: { product: Product; onEdit: () => void }) {
return (
<div className="group flex items-start gap-3 rounded-xl border border-zinc-100 bg-white p-3 transition-all hover:border-zinc-200 hover:shadow-sm dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-zinc-700">
<button className="mt-1 cursor-grab text-zinc-300 opacity-0 transition-opacity group-hover:opacity-100 active:cursor-grabbing">
<GripVertical className="h-4 w-4" />
</button>
{/* Image placeholder */}
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-zinc-100 to-zinc-50 dark:from-zinc-800 dark:to-zinc-700">
<ImageIcon className="h-6 w-6 text-zinc-300 dark:text-zinc-500" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h4 className="truncate text-sm font-semibold text-zinc-900 dark:text-zinc-100">{product.name}</h4>
{product.is_featured && <Star className="h-3.5 w-3.5 shrink-0 fill-amber-400 text-amber-400" />}
</div>
<p className="mt-0.5 line-clamp-1 text-xs text-zinc-500">{product.description}</p>
</div>
<div className="flex items-center gap-1 shrink-0">
<span className="text-sm font-bold text-zinc-900 dark:text-zinc-100">{formatCurrency(product.price)}</span>
</div>
</div>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{product.is_available ? (
<Badge variant="success" className="text-[10px]"><Eye className="h-3 w-3 mr-0.5" />Available</Badge>
) : (
<Badge variant="destructive" className="text-[10px]"><EyeOff className="h-3 w-3 mr-0.5" />Hidden</Badge>
)}
{product.preparation_time && (
<Badge variant="secondary" className="text-[10px]"><Clock className="h-3 w-3 mr-0.5" />{product.preparation_time}m</Badge>
)}
{product.calories && (
<Badge variant="secondary" className="text-[10px]"><Flame className="h-3 w-3 mr-0.5" />{product.calories} cal</Badge>
)}
{product.tags?.map((tag) => (
<Badge key={tag} variant="outline" className="text-[10px]">{tag}</Badge>
))}
</div>
</div>
<div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button onClick={onEdit} className="rounded-lg p-1.5 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-800">
<Pencil className="h-3.5 w-3.5" />
</button>
<button className="rounded-lg p-1.5 text-zinc-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-950/30">
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
);
}
function ProductModal({ product, onClose }: { product?: Product; onClose: () => void }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
<div className="w-full max-w-lg max-h-[90vh] overflow-y-auto rounded-2xl border border-zinc-200 bg-white shadow-xl dark:border-zinc-700 dark:bg-zinc-900">
<div className="flex items-center justify-between border-b border-zinc-200 p-5 dark:border-zinc-800">
<h3 className="text-lg font-semibold">{product ? 'Edit Product' : 'Add Product'}</h3>
<button onClick={onClose} className="rounded-lg p-1.5 hover:bg-zinc-100 dark:hover:bg-zinc-800">
<X className="h-4 w-4" />
</button>
</div>
<div className="space-y-4 p-5">
{/* Image upload */}
<div className="flex items-center justify-center rounded-xl border-2 border-dashed border-zinc-200 bg-zinc-50 p-8 dark:border-zinc-700 dark:bg-zinc-800/50">
<div className="text-center">
<ImageIcon className="mx-auto h-8 w-8 text-zinc-300" />
<p className="mt-2 text-sm font-medium text-zinc-600">Upload product image</p>
<p className="text-xs text-zinc-400">PNG, JPG up to 5MB</p>
<Button variant="outline" size="sm" className="mt-3">Choose File</Button>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Product Name</label>
<Input placeholder="e.g., Truffle Burrata" defaultValue={product?.name} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Description</label>
<Textarea placeholder="Describe your product..." defaultValue={product?.description} />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Price</label>
<div className="relative">
<DollarSign className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400" />
<Input type="number" step="0.01" placeholder="0.00" defaultValue={product?.price} className="pl-10" />
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Prep Time (min)</label>
<div className="relative">
<Clock className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400" />
<Input type="number" placeholder="15" defaultValue={product?.preparation_time ?? undefined} className="pl-10" />
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Calories</label>
<div className="relative">
<Flame className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400" />
<Input type="number" placeholder="350" defaultValue={product?.calories ?? undefined} className="pl-10" />
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Tags</label>
<div className="relative">
<Tag className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400" />
<Input placeholder="vegetarian, popular" defaultValue={product?.tags?.join(', ')} className="pl-10" />
</div>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Allergens</label>
<div className="relative">
<AlertCircle className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400" />
<Input placeholder="dairy, gluten, nuts" defaultValue={product?.allergens?.join(', ')} className="pl-10" />
</div>
</div>
<div className="flex items-center gap-6 rounded-xl bg-zinc-50 p-4 dark:bg-zinc-800/50">
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" defaultChecked={product?.is_available ?? true} className="h-4 w-4 rounded border-zinc-300 text-emerald-600 focus:ring-emerald-500" />
<span className="font-medium text-zinc-700 dark:text-zinc-300">Available</span>
</label>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" defaultChecked={product?.is_featured ?? false} className="h-4 w-4 rounded border-zinc-300 text-emerald-600 focus:ring-emerald-500" />
<span className="font-medium text-zinc-700 dark:text-zinc-300">Featured</span>
</label>
</div>
</div>
<div className="flex justify-end gap-2 border-t border-zinc-200 p-5 dark:border-zinc-800">
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button variant="primary" onClick={onClose}>{product ? 'Save Changes' : 'Add Product'}</Button>
</div>
</div>
</div>
);
}
export default function MenuBuilderPage() {
const [search, setSearch] = useState('');
const [expandedCategories, setExpandedCategories] = useState<string[]>(demoCategories.map((c) => c.id));
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
const [showAddProduct, setShowAddProduct] = useState(false);
const toggleCategory = (id: string) => {
setExpandedCategories((prev) =>
prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id]
);
};
const filteredProducts = search
? demoProducts.filter((p) => p.name.toLowerCase().includes(search.toLowerCase()) || p.description?.toLowerCase().includes(search.toLowerCase()))
: demoProducts;
return (
<DashboardLayout>
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-white sm:text-3xl">Menu Builder</h1>
<p className="mt-1 text-sm text-zinc-500">Manage your menus, categories, and products.</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm">
<Plus className="h-4 w-4" /> Add Category
</Button>
<Button variant="primary" size="sm" onClick={() => setShowAddProduct(true)}>
<Plus className="h-4 w-4" /> Add Product
</Button>
</div>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400" />
<Input
placeholder="Search products..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10 max-w-sm"
/>
</div>
{/* Categories & Products */}
<div className="space-y-4">
{demoCategories.map((category) => {
const categoryProducts = filteredProducts.filter((p) => p.category_id === category.id);
const isExpanded = expandedCategories.includes(category.id);
return (
<Card key={category.id}>
<div
className="flex cursor-pointer items-center justify-between p-4"
onClick={() => toggleCategory(category.id)}
>
<div className="flex items-center gap-3">
<button className="cursor-grab text-zinc-300 active:cursor-grabbing">
<GripVertical className="h-4 w-4" />
</button>
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-zinc-400" />
) : (
<ChevronRight className="h-4 w-4 text-zinc-400" />
)}
<div>
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">{category.name}</h3>
<p className="text-xs text-zinc-500">{categoryProducts.length} items{category.description && ` • ${category.description}`}</p>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant={category.is_active ? 'success' : 'secondary'} className="text-[10px]">
{category.is_active ? 'Active' : 'Hidden'}
</Badge>
<button className="rounded-lg p-1.5 text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800" onClick={(e) => e.stopPropagation()}>
<MoreHorizontal className="h-4 w-4" />
</button>
</div>
</div>
{isExpanded && (
<CardContent className="border-t border-zinc-100 pt-3 dark:border-zinc-800">
{categoryProducts.length > 0 ? (
<div className="space-y-2">
{categoryProducts.map((product) => (
<ProductCard
key={product.id}
product={product}
onEdit={() => setEditingProduct(product)}
/>
))}
</div>
) : (
<div className="py-8 text-center text-sm text-zinc-400">
No products in this category
</div>
)}
<button
onClick={() => setShowAddProduct(true)}
className="mt-3 flex w-full items-center justify-center gap-2 rounded-xl border-2 border-dashed border-zinc-200 py-3 text-sm font-medium text-zinc-400 transition-all hover:border-emerald-300 hover:text-emerald-600 dark:border-zinc-700 dark:hover:border-emerald-700"
>
<Plus className="h-4 w-4" /> Add product
</button>
</CardContent>
)}
</Card>
);
})}
</div>
</div>
{/* Modals */}
{(editingProduct || showAddProduct) && (
<ProductModal
product={editingProduct ?? undefined}
onClose={() => {
setEditingProduct(null);
setShowAddProduct(false);
}}
/>
)}
</DashboardLayout>
);
}