repositories
loading repo index
repositories
loading repo index
repository
loading code, commits, and activity
Fastfood QR
stars
latest
clone command
git clone gitlawb://did:key:z6Mkfh4Y...QBEi/fastfood-qrgit clone gitlawb://did:key:z6Mkfh4Y.../fastfood-qr3042a875sync from playground1d ago| #1 | import { useState, useEffect, useRef, useCallback } from 'react'; |
| #2 | import { useParams } from 'react-router-dom'; |
| #3 | import { getRestaurantBySlug, loadCategories, loadProducts, getCurrencySymbol } from '../data'; |
| #4 | import { useApp } from '../context'; |
| #5 | import { IconMapPin, IconClock, IconInstagram, IconMessageCircle, IconUtensils, IconX, IconMinus, IconPlus, IconTrash, IconWhatsapp, getCategoryIcon } from '../icons'; |
| #6 | import type { Restaurant, Category, Product } from '../types'; |
| #7 | |
| #8 | export default function MenuPage() { |
| #9 | const { slug } = useParams<{ slug: string }>(); |
| #10 | const { cart, addToCart, removeFromCart, updateCartQuantity, clearCart } = useApp(); |
| #11 | |
| #12 | const [restaurant, setRestaurant] = useState<Restaurant | null>(null); |
| #13 | const [categories, setCategories] = useState<Category[]>([]); |
| #14 | const [products, setProducts] = useState<Product[]>([]); |
| #15 | const [cartOpen, setCartOpen] = useState(false); |
| #16 | const [customerName, setCustomerName] = useState(''); |
| #17 | const [tableNumber, setTableNumber] = useState(''); |
| #18 | const [notes, setNotes] = useState(''); |
| #19 | const [activeCategory, setActiveCategory] = useState(''); |
| #20 | const categoryRefs = useRef<Record<string, HTMLDivElement | null>>({}); |
| #21 | |
| #22 | useEffect(() => { |
| #23 | if (!slug) return; |
| #24 | const r = getRestaurantBySlug(slug); |
| #25 | if (r) { |
| #26 | setRestaurant(r); |
| #27 | const cats = loadCategories(r.id); |
| #28 | const prods = loadProducts(r.id); |
| #29 | setCategories(cats); |
| #30 | setProducts(prods); |
| #31 | if (cats.length > 0) setActiveCategory(cats[0].id); |
| #32 | } |
| #33 | }, [slug]); |
| #34 | |
| #35 | // Scroll spy |
| #36 | useEffect(() => { |
| #37 | const observer = new IntersectionObserver( |
| #38 | (entries) => { |
| #39 | for (const entry of entries) { |
| #40 | if (entry.isIntersecting) { |
| #41 | setActiveCategory(entry.target.id); |
| #42 | } |
| #43 | } |
| #44 | }, |
| #45 | { rootMargin: '-100px 0px -60% 0px' } |
| #46 | ); |
| #47 | |
| #48 | Object.values(categoryRefs.current).forEach((el) => { |
| #49 | if (el) observer.observe(el); |
| #50 | }); |
| #51 | |
| #52 | return () => observer.disconnect(); |
| #53 | }, [categories]); |
| #54 | |
| #55 | const scrollToCategory = useCallback((catId: string) => { |
| #56 | setActiveCategory(catId); |
| #57 | categoryRefs.current[catId]?.scrollIntoView({ behavior: 'smooth', block: 'start' }); |
| #58 | }, []); |
| #59 | |
| #60 | const totalItems = cart.reduce((sum, item) => sum + item.quantity, 0); |
| #61 | const subtotal = cart.reduce((sum, item) => sum + item.product.price * item.quantity, 0); |
| #62 | const currency = restaurant ? getCurrencySymbol(restaurant.currency) : '\u20ac'; |
| #63 | const deliveryFee = restaurant?.deliveryFee ?? 0; |
| #64 | const total = subtotal + (totalItems > 0 ? deliveryFee : 0); |
| #65 | |
| #66 | const handleWhatsAppOrder = () => { |
| #67 | if (!restaurant || cart.length === 0) return; |
| #68 | |
| #69 | const lines = cart.map((item) => { |
| #70 | const emoji = getCategoryEmoji(item.product.categoryId, categories); |
| #71 | return `${emoji} ${item.quantity}x ${item.product.name} - ${currency}${(item.product.price * item.quantity).toFixed(2)}`; |
| #72 | }); |
| #73 | |
| #74 | const deliveryLine = deliveryFee > 0 ? ` (incl. ${currency}${deliveryFee.toFixed(2)} delivery)` : ''; |
| #75 | const message = [ |
| #76 | 'Hello! I would like to place an order:', |
| #77 | '', |
| #78 | ...lines, |
| #79 | '', |
| #80 | `Total: ${currency}${total.toFixed(2)}${deliveryLine}`, |
| #81 | '', |
| #82 | `Customer Name: ${customerName || '___'}`, |
| #83 | `Table Number: ${tableNumber || '___'}`, |
| #84 | `Notes: ${notes || 'None'}`, |
| #85 | ].join('\n'); |
| #86 | |
| #87 | const phone = restaurant.whatsappNumber.replace(/[^0-9+]/g, ''); |
| #88 | window.open(`https://wa.me/${phone.replace('+', '')}?text=${encodeURIComponent(message)}`, '_blank'); |
| #89 | }; |
| #90 | |
| #91 | if (!restaurant) { |
| #92 | return ( |
| #93 | <div className="menu-page"> |
| #94 | <div className="menu-not-found"> |
| #95 | <h1>Restaurant not found</h1> |
| #96 | <p>The menu you are looking for does not exist.</p> |
| #97 | </div> |
| #98 | </div> |
| #99 | ); |
| #100 | } |
| #101 | |
| #102 | return ( |
| #103 | <div className="menu-page"> |
| #104 | {/* Banner */} |
| #105 | <div className="menu-banner" style={{ backgroundImage: `url(${restaurant.bannerUrl})` }}> |
| #106 | <div className="menu-banner-overlay"> |
| #107 | <h1 className="menu-banner-title">{restaurant.name}</h1> |
| #108 | <p className="menu-banner-desc">{restaurant.description}</p> |
| #109 | </div> |
| #110 | </div> |
| #111 | |
| #112 | {/* Info bar */} |
| #113 | <div className="menu-info"> |
| #114 | {restaurant.address && <span className="menu-info-item"><IconMapPin size={14} /> {restaurant.address}</span>} |
| #115 | {restaurant.openingHours && <span className="menu-info-item"><IconClock size={14} /> {restaurant.openingHours}</span>} |
| #116 | {restaurant.instagram && <span className="menu-info-item"><IconInstagram size={14} /> {restaurant.instagram}</span>} |
| #117 | {restaurant.whatsappNumber && ( |
| #118 | <a |
| #119 | className="menu-info-item menu-info-wa" |
| #120 | href={`https://wa.me/${restaurant.whatsappNumber.replace(/[^0-9]/g, '')}`} |
| #121 | target="_blank" |
| #122 | rel="noopener noreferrer" |
| #123 | > |
| #124 | <IconMessageCircle size={14} /> WhatsApp |
| #125 | </a> |
| #126 | )} |
| #127 | </div> |
| #128 | |
| #129 | {/* Category nav */} |
| #130 | <div className="category-nav"> |
| #131 | <div className="category-nav-inner"> |
| #132 | {categories.map((cat) => ( |
| #133 | <button |
| #134 | key={cat.id} |
| #135 | className={`category-nav-item ${activeCategory === cat.id ? 'active' : ''}`} |
| #136 | onClick={() => scrollToCategory(cat.id)} |
| #137 | > |
| #138 | {cat.name} |
| #139 | </button> |
| #140 | ))} |
| #141 | </div> |
| #142 | </div> |
| #143 | |
| #144 | {/* Products by category */} |
| #145 | <div className="menu-content"> |
| #146 | {categories.map((cat) => { |
| #147 | const catProducts = products.filter((p) => p.categoryId === cat.id); |
| #148 | if (catProducts.length === 0) return null; |
| #149 | return ( |
| #150 | <div |
| #151 | key={cat.id} |
| #152 | id={cat.id} |
| #153 | ref={(el) => { categoryRefs.current[cat.id] = el; }} |
| #154 | className="menu-section" |
| #155 | > |
| #156 | <h2 className="menu-section-title">{cat.name}</h2> |
| #157 | <div className="product-grid"> |
| #158 | {catProducts.map((product) => ( |
| #159 | <div key={product.id} className={`product-card ${!product.available ? 'unavailable' : ''}`}> |
| #160 | <div className="product-image-wrap"> |
| #161 | {product.imageUrl ? ( |
| #162 | <img src={product.imageUrl} alt={product.name} className="product-image" loading="lazy" /> |
| #163 | ) : ( |
| #164 | <div className="product-image-placeholder"><IconUtensils size={48} /></div> |
| #165 | )} |
| #166 | {product.featured && <span className="product-badge">Popular</span>} |
| #167 | {!product.available && <span className="product-badge product-badge-unavailable">Unavailable</span>} |
| #168 | </div> |
| #169 | <div className="product-info"> |
| #170 | <h3 className="product-name">{product.name}</h3> |
| #171 | <p className="product-desc">{product.description}</p> |
| #172 | <div className="product-bottom"> |
| #173 | <span className="product-price">{currency}{product.price.toFixed(2)}</span> |
| #174 | {product.available && ( |
| #175 | <button className="add-to-cart-btn" onClick={() => addToCart(product)}> |
| #176 | <IconPlus size={16} /> Add |
| #177 | </button> |
| #178 | )} |
| #179 | </div> |
| #180 | </div> |
| #181 | </div> |
| #182 | ))} |
| #183 | </div> |
| #184 | </div> |
| #185 | ); |
| #186 | })} |
| #187 | </div> |
| #188 | |
| #189 | {/* Floating cart button */} |
| #190 | {totalItems > 0 && ( |
| #191 | <button className="cart-float" onClick={() => setCartOpen(true)}> |
| #192 | <span className="cart-float-count">{totalItems}</span> |
| #193 | <span className="cart-float-label">View Cart</span> |
| #194 | <span className="cart-float-total">{currency}{total.toFixed(2)}</span> |
| #195 | </button> |
| #196 | )} |
| #197 | |
| #198 | {/* Cart overlay */} |
| #199 | {cartOpen && <div className="cart-overlay" onClick={() => setCartOpen(false)} />} |
| #200 | |
| #201 | {/* Cart drawer */} |
| #202 | <div className={`cart-drawer ${cartOpen ? 'open' : ''}`}> |
| #203 | <div className="cart-header"> |
| #204 | <h2>Your Order</h2> |
| #205 | <button className="cart-close" onClick={() => setCartOpen(false)}><IconX size={20} /></button> |
| #206 | </div> |
| #207 | |
| #208 | {cart.length === 0 ? ( |
| #209 | <div className="cart-empty"> |
| #210 | <p>Your cart is empty</p> |
| #211 | <p>Add items from the menu to get started.</p> |
| #212 | </div> |
| #213 | ) : ( |
| #214 | <> |
| #215 | <div className="cart-items"> |
| #216 | {cart.map((item) => ( |
| #217 | <div key={item.product.id} className="cart-item"> |
| #218 | <div className="cart-item-info"> |
| #219 | <span className="cart-item-name">{item.product.name}</span> |
| #220 | <span className="cart-item-price">{currency}{(item.product.price * item.quantity).toFixed(2)}</span> |
| #221 | </div> |
| #222 | <div className="cart-item-controls"> |
| #223 | <button onClick={() => updateCartQuantity(item.product.id, item.quantity - 1)}><IconMinus size={16} /></button> |
| #224 | <span>{item.quantity}</span> |
| #225 | <button onClick={() => updateCartQuantity(item.product.id, item.quantity + 1)}><IconPlus size={16} /></button> |
| #226 | <button className="cart-item-remove" onClick={() => removeFromCart(item.product.id)}> |
| #227 | <IconTrash size={14} /> Remove |
| #228 | </button> |
| #229 | </div> |
| #230 | </div> |
| #231 | ))} |
| #232 | </div> |
| #233 | |
| #234 | <div className="cart-summary"> |
| #235 | <div className="cart-summary-row"> |
| #236 | <span>Subtotal</span> |
| #237 | <span>{currency}{subtotal.toFixed(2)}</span> |
| #238 | </div> |
| #239 | {deliveryFee > 0 && ( |
| #240 | <div className="cart-summary-row"> |
| #241 | <span>Delivery</span> |
| #242 | <span>{currency}{deliveryFee.toFixed(2)}</span> |
| #243 | </div> |
| #244 | )} |
| #245 | <div className="cart-summary-row cart-summary-total"> |
| #246 | <span>Total</span> |
| #247 | <span>{currency}{total.toFixed(2)}</span> |
| #248 | </div> |
| #249 | </div> |
| #250 | |
| #251 | <div className="cart-fields"> |
| #252 | <div className="cart-field"> |
| #253 | <label htmlFor="cart-name">Customer Name</label> |
| #254 | <input |
| #255 | id="cart-name" |
| #256 | type="text" |
| #257 | placeholder="Your name" |
| #258 | value={customerName} |
| #259 | onChange={(e) => setCustomerName(e.target.value)} |
| #260 | /> |
| #261 | </div> |
| #262 | <div className="cart-field"> |
| #263 | <label htmlFor="cart-table">Table Number</label> |
| #264 | <input |
| #265 | id="cart-table" |
| #266 | type="text" |
| #267 | placeholder="e.g. 5" |
| #268 | value={tableNumber} |
| #269 | onChange={(e) => setTableNumber(e.target.value)} |
| #270 | /> |
| #271 | </div> |
| #272 | <div className="cart-field"> |
| #273 | <label htmlFor="cart-notes">Notes</label> |
| #274 | <textarea |
| #275 | id="cart-notes" |
| #276 | placeholder="Special requests..." |
| #277 | value={notes} |
| #278 | onChange={(e) => setNotes(e.target.value)} |
| #279 | rows={2} |
| #280 | /> |
| #281 | </div> |
| #282 | </div> |
| #283 | |
| #284 | <button className="whatsapp-btn" onClick={handleWhatsAppOrder}> |
| #285 | <IconWhatsapp size={20} /> Order via WhatsApp |
| #286 | </button> |
| #287 | <button className="cart-clear-btn" onClick={clearCart}>Clear Cart</button> |
| #288 | </> |
| #289 | )} |
| #290 | </div> |
| #291 | </div> |
| #292 | ); |
| #293 | } |
| #294 | |
| #295 | function getCategoryEmoji(categoryId: string, categories: Category[]): string { |
| #296 | const cat = categories.find((c) => c.id === categoryId); |
| #297 | if (!cat) return '•'; |
| #298 | const name = cat.name.toLowerCase(); |
| #299 | if (name.includes('burger')) return '•'; |
| #300 | if (name.includes('side') || name.includes('fries')) return '•'; |
| #301 | if (name.includes('drink') || name.includes('beverage')) return '•'; |
| #302 | if (name.includes('dessert') || name.includes('sweet')) return '•'; |
| #303 | if (name.includes('pizza')) return '•'; |
| #304 | return '•'; |
| #305 | } |
| #306 |