Created
May 7, 2025 10:42
-
-
Save ekaone/cf412ca74a8cd01f8cf50f3256638020 to your computer and use it in GitHub Desktop.
POS
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import React, { useState, useEffect, useCallback } from 'react'; | |
| import { ChevronDown, ChevronUp, ShoppingCart, Trash2, CreditCard, DollarSign, X, Coffee, Pizza, IceCream, GlassWater, PlusCircle, MinusCircle, CheckCircle } from 'lucide-react'; | |
| // Mock Product Data | |
| const initialProducts = [ | |
| { id: 'F001', name: 'Margherita Pizza', price: 75000, category: 'Main Course', imageUrl: 'https://placehold.co/400x300/f87171/ffffff?text=Margherita' }, | |
| { id: 'F002', name: 'Pepperoni Pizza', price: 85000, category: 'Main Course', imageUrl: 'https://placehold.co/400x300/fb923c/ffffff?text=Pepperoni' }, | |
| { id: 'F003', name: 'Spaghetti Carbonara', price: 65000, category: 'Main Course', imageUrl: 'https://placehold.co/400x300/fdba74/1e293b?text=Carbonara' }, | |
| { id: 'F004', name: 'Caesar Salad', price: 45000, category: 'Appetizer', imageUrl: 'https://placehold.co/400x300/a3e635/1e293b?text=Caesar+Salad' }, | |
| { id: 'F005', name: 'Garlic Bread', price: 25000, category: 'Appetizer', imageUrl: 'https://placehold.co/400x300/bef264/1e293b?text=Garlic+Bread' }, | |
| { id: 'D001', name: 'Chocolate Lava Cake', price: 35000, category: 'Dessert', imageUrl: 'https://placehold.co/400x300/7e22ce/ffffff?text=Lava+Cake' }, | |
| { id: 'D002', name: 'Vanilla Ice Cream', price: 20000, category: 'Dessert', imageUrl: 'https://placehold.co/400x300/a855f7/ffffff?text=Ice+Cream' }, | |
| { id: 'B001', name: 'Espresso', price: 22000, category: 'Beverage', imageUrl: 'https://placehold.co/400x300/2dd4bf/1e293b?text=Espresso' }, | |
| { id: 'B002', name: 'Iced Latte', price: 28000, category: 'Beverage', imageUrl: 'https://placehold.co/400x300/5eead4/1e293b?text=Iced+Latte' }, | |
| { id: 'B003', name: 'Mineral Water', price: 10000, category: 'Beverage', imageUrl: 'https://placehold.co/400x300/99f6e4/1e293b?text=Water' }, | |
| { id: 'F006', name: 'Beef Burger', price: 55000, category: 'Main Course', imageUrl: 'https://placehold.co/400x300/fca5a5/1e293b?text=Beef+Burger' }, | |
| { id: 'F007', name: 'Chicken Wings', price: 40000, category: 'Appetizer', imageUrl: 'https://placehold.co/400x300/86efac/1e293b?text=Chicken+Wings' }, | |
| { id: 'D003', name: 'Cheesecake Slice', price: 30000, category: 'Dessert', imageUrl: 'https://placehold.co/400x300/c084fc/ffffff?text=Cheesecake' }, | |
| { id: 'B004', name: 'Fresh Orange Juice', price: 25000, category: 'Beverage', imageUrl: 'https://placehold.co/400x300/67e8f9/1e293b?text=Orange+Juice' }, | |
| ]; | |
| // Helper to format currency (Indonesian Rupiah) | |
| const formatCurrency = (amount) => { | |
| return new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', minimumFractionDigits: 0 }).format(amount); | |
| }; | |
| // Category Icons Mapping | |
| const categoryIcons = { | |
| 'All': <ShoppingCart size={20} className="mr-2" />, | |
| 'Main Course': <Pizza size={20} className="mr-2" />, | |
| 'Appetizer': <Coffee size={20} className="mr-2" />, // Using Coffee as a stand-in for general appetizer | |
| 'Dessert': <IceCream size={20} className="mr-2" />, | |
| 'Beverage': <GlassWater size={20} className="mr-2" />, | |
| }; | |
| // Main App Component | |
| function App() { | |
| const [products] = useState(initialProducts); | |
| const [categories, setCategories] = useState(['All']); | |
| const [selectedCategory, setSelectedCategory] = useState('All'); | |
| const [cart, setCart] = useState([]); | |
| const [searchTerm, setSearchTerm] = useState(''); | |
| const [showPaymentModal, setShowPaymentModal] = useState(false); | |
| const [paymentSuccess, setPaymentSuccess] = useState(false); | |
| const [orderNumber, setOrderNumber] = useState(1001); // Mock order number | |
| // Extract categories from products on mount | |
| useEffect(() => { | |
| const uniqueCategories = ['All', ...new Set(products.map(p => p.category))]; | |
| setCategories(uniqueCategories); | |
| }, [products]); | |
| // Filter products based on selected category and search term | |
| const filteredProducts = products.filter(product => { | |
| const categoryMatch = selectedCategory === 'All' || product.category === selectedCategory; | |
| const searchMatch = product.name.toLowerCase().includes(searchTerm.toLowerCase()); | |
| return categoryMatch && searchMatch; | |
| }); | |
| // Add item to cart or increment quantity | |
| const addToCart = (product) => { | |
| setCart(prevCart => { | |
| const existingItem = prevCart.find(item => item.id === product.id); | |
| if (existingItem) { | |
| return prevCart.map(item => | |
| item.id === product.id ? { ...item, quantity: item.quantity + 1 } : item | |
| ); | |
| } | |
| return [...prevCart, { ...product, quantity: 1 }]; | |
| }); | |
| }; | |
| // Decrement item quantity or remove from cart | |
| const decrementItem = (productId) => { | |
| setCart(prevCart => { | |
| const existingItem = prevCart.find(item => item.id === productId); | |
| if (existingItem && existingItem.quantity > 1) { | |
| return prevCart.map(item => | |
| item.id === productId ? { ...item, quantity: item.quantity - 1 } : item | |
| ); | |
| } | |
| return prevCart.filter(item => item.id !== productId); | |
| }); | |
| }; | |
| // Remove item from cart | |
| const removeFromCart = (productId) => { | |
| setCart(prevCart => prevCart.filter(item => item.id !== productId)); | |
| }; | |
| // Calculate total items in cart | |
| const totalItemsInCart = cart.reduce((total, item) => total + item.quantity, 0); | |
| // Calculate total price of items in cart | |
| const totalPrice = cart.reduce((total, item) => total + item.price * item.quantity, 0); | |
| // Handle payment process | |
| const handlePayment = (paymentMethod) => { | |
| console.log(`Processing payment with ${paymentMethod}... Total: ${formatCurrency(totalPrice)}`); | |
| // Simulate payment success | |
| setPaymentSuccess(true); | |
| setShowPaymentModal(true); | |
| // In a real app, you'd integrate with a payment gateway here. | |
| }; | |
| // Close payment modal and reset cart | |
| const closePaymentModal = useCallback(() => { | |
| setShowPaymentModal(false); | |
| if (paymentSuccess) { | |
| setCart([]); | |
| setOrderNumber(prev => prev + 1); // Increment order number for next order | |
| } | |
| setPaymentSuccess(false); // Reset payment success status | |
| }, [paymentSuccess]); | |
| // Product Card Component | |
| const ProductCard = ({ product }) => ( | |
| <div className="bg-white rounded-xl shadow-lg overflow-hidden transform hover:scale-105 transition-transform duration-300 ease-in-out"> | |
| <img | |
| src={product.imageUrl} | |
| alt={product.name} | |
| className="w-full h-40 object-cover" | |
| onError={(e) => { e.target.onerror = null; e.target.src="https://placehold.co/400x300/cccccc/ffffff?text=Image+Not+Found"; }} | |
| /> | |
| <div className="p-4"> | |
| <h3 className="text-lg font-semibold text-gray-800 mb-1 truncate" title={product.name}>{product.name}</h3> | |
| <p className="text-sm text-gray-500 mb-2">{product.category}</p> | |
| <p className="text-xl font-bold text-indigo-600 mb-3">{formatCurrency(product.price)}</p> | |
| <button | |
| onClick={() => addToCart(product)} | |
| className="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-2 px-4 rounded-lg shadow-md hover:shadow-lg transition-all duration-200 flex items-center justify-center" | |
| > | |
| <PlusCircle size={18} className="mr-2" /> Add to Cart | |
| </button> | |
| </div> | |
| </div> | |
| ); | |
| // Cart Item Component | |
| const CartItemCard = ({ item }) => ( | |
| <div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg mb-2 shadow-sm"> | |
| <div className="flex items-center"> | |
| <img | |
| src={item.imageUrl} | |
| alt={item.name} | |
| className="w-12 h-12 object-cover rounded-md mr-3" | |
| onError={(e) => { e.target.onerror = null; e.target.src="https://placehold.co/50x50/cccccc/ffffff?text=N/A"; }} | |
| /> | |
| <div> | |
| <h4 className="font-semibold text-sm text-gray-700">{item.name}</h4> | |
| <p className="text-xs text-gray-500">{formatCurrency(item.price)}</p> | |
| </div> | |
| </div> | |
| <div className="flex items-center"> | |
| <button onClick={() => decrementItem(item.id)} className="p-1 text-indigo-600 hover:text-indigo-800 rounded-full hover:bg-indigo-100 transition-colors"> | |
| <MinusCircle size={20} /> | |
| </button> | |
| <span className="mx-2 font-medium text-gray-700 w-5 text-center">{item.quantity}</span> | |
| <button onClick={() => addToCart(item)} className="p-1 text-indigo-600 hover:text-indigo-800 rounded-full hover:bg-indigo-100 transition-colors"> | |
| <PlusCircle size={20} /> | |
| </button> | |
| <button onClick={() => removeFromCart(item.id)} className="ml-3 p-1 text-red-500 hover:text-red-700 rounded-full hover:bg-red-100 transition-colors"> | |
| <Trash2 size={18} /> | |
| </button> | |
| </div> | |
| </div> | |
| ); | |
| // Payment Modal Component | |
| const PaymentModal = () => ( | |
| <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50 transition-opacity duration-300"> | |
| <div className="bg-white p-8 rounded-xl shadow-2xl w-full max-w-md text-center transform scale-100 transition-transform duration-300"> | |
| {paymentSuccess ? ( | |
| <> | |
| <CheckCircle size={64} className="text-green-500 mx-auto mb-6" /> | |
| <h2 className="text-2xl font-bold text-gray-800 mb-3">Payment Successful!</h2> | |
| <p className="text-gray-600 mb-2">Order Number: <span className="font-semibold">#{orderNumber}</span></p> | |
| <p className="text-gray-600 mb-6">Thank you for your purchase. Your order is being prepared.</p> | |
| <button | |
| onClick={closePaymentModal} | |
| className="w-full bg-green-500 hover:bg-green-600 text-white font-semibold py-3 px-4 rounded-lg shadow-md hover:shadow-lg transition-all duration-200" | |
| > | |
| Close & New Order | |
| </button> | |
| </> | |
| ) : ( | |
| <> | |
| <X size={64} className="text-red-500 mx-auto mb-6" /> | |
| <h2 className="text-2xl font-bold text-gray-800 mb-3">Payment Failed!</h2> | |
| <p className="text-gray-600 mb-6">Something went wrong with the payment. Please try again.</p> | |
| <button | |
| onClick={closePaymentModal} | |
| className="w-full bg-red-500 hover:bg-red-600 text-white font-semibold py-3 px-4 rounded-lg shadow-md hover:shadow-lg transition-all duration-200" | |
| > | |
| Try Again | |
| </button> | |
| </> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| return ( | |
| <div className="min-h-screen bg-gray-100 flex flex-col font-sans"> | |
| {/* Header */} | |
| <header className="bg-indigo-700 text-white shadow-lg sticky top-0 z-40"> | |
| <div className="container mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between"> | |
| <h1 className="text-2xl font-bold tracking-tight">F&B Point of Sale</h1> | |
| <div className="relative"> | |
| <ShoppingCart size={28} /> | |
| {totalItemsInCart > 0 && ( | |
| <span className="absolute -top-2 -right-2 bg-pink-500 text-white text-xs font-bold rounded-full h-5 w-5 flex items-center justify-center"> | |
| {totalItemsInCart} | |
| </span> | |
| )} | |
| </div> | |
| </div> | |
| </header> | |
| {/* Main Content Area */} | |
| <main className="flex-grow container mx-auto px-4 sm:px-6 lg:px-8 py-6"> | |
| <div className="flex flex-col lg:flex-row gap-6"> | |
| {/* Products Section (Left/Top on mobile) */} | |
| <div className="lg:w-2/3"> | |
| {/* Search and Category Filters */} | |
| <div className="mb-6 p-4 bg-white rounded-xl shadow-md"> | |
| <input | |
| type="text" | |
| placeholder="Search products..." | |
| value={searchTerm} | |
| onChange={(e) => setSearchTerm(e.target.value)} | |
| className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-shadow" | |
| /> | |
| <div className="mt-4 flex flex-wrap gap-2"> | |
| {categories.map(category => ( | |
| <button | |
| key={category} | |
| onClick={() => setSelectedCategory(category)} | |
| className={`px-4 py-2 text-sm font-medium rounded-lg transition-all duration-200 ease-in-out flex items-center | |
| ${selectedCategory === category | |
| ? 'bg-indigo-600 text-white shadow-md ring-2 ring-indigo-300' | |
| : 'bg-gray-200 text-gray-700 hover:bg-gray-300 hover:shadow-sm' | |
| }`} | |
| > | |
| {categoryIcons[category] || <ShoppingCart size={16} className="mr-2"/>} {category} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| {/* Product Grid */} | |
| {filteredProducts.length > 0 ? ( | |
| <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 gap-6"> | |
| {filteredProducts.map(product => ( | |
| <ProductCard key={product.id} product={product} /> | |
| ))} | |
| </div> | |
| ) : ( | |
| <div className="text-center py-10"> | |
| <img src="https://placehold.co/128x128/e0e7ff/4338ca?text=:(" alt="No products found" className="mx-auto mb-4 rounded-lg" /> | |
| <p className="text-xl text-gray-500 font-semibold">No products found matching your criteria.</p> | |
| <p className="text-gray-400">Try adjusting your search or category filters.</p> | |
| </div> | |
| )} | |
| </div> | |
| {/* Order Summary Section (Right/Bottom on mobile) */} | |
| <div className="lg:w-1/3"> | |
| <div className="bg-white rounded-xl shadow-lg p-6 sticky top-24"> {/* Sticky for desktop */} | |
| <h2 className="text-2xl font-semibold text-gray-800 mb-6 border-b pb-3">Your Order</h2> | |
| {cart.length === 0 ? ( | |
| <div className="text-center py-8"> | |
| <ShoppingCart size={48} className="text-gray-300 mx-auto mb-4" /> | |
| <p className="text-gray-500">Your cart is empty.</p> | |
| <p className="text-sm text-gray-400">Add items from the menu to get started.</p> | |
| </div> | |
| ) : ( | |
| <> | |
| <div className="max-h-80 overflow-y-auto pr-2 mb-4 custom-scrollbar"> {/* Scrollable cart items */} | |
| {cart.map(item => ( | |
| <CartItemCard key={item.id} item={item} /> | |
| ))} | |
| </div> | |
| <div className="border-t pt-4"> | |
| <div className="flex justify-between items-center mb-2 text-gray-700"> | |
| <span>Subtotal</span> | |
| <span className="font-semibold">{formatCurrency(totalPrice)}</span> | |
| </div> | |
| <div className="flex justify-between items-center mb-2 text-gray-700"> | |
| <span>Tax (10%)</span> | |
| <span className="font-semibold">{formatCurrency(totalPrice * 0.10)}</span> | |
| </div> | |
| <div className="flex justify-between items-center text-xl font-bold text-gray-800 mt-3 pt-3 border-t"> | |
| <span>Total</span> | |
| <span>{formatCurrency(totalPrice * 1.10)}</span> | |
| </div> | |
| </div> | |
| <div className="mt-6 space-y-3"> | |
| <button | |
| onClick={() => handlePayment('Cash')} | |
| className="w-full bg-green-500 hover:bg-green-600 text-white font-semibold py-3 px-4 rounded-lg shadow-md hover:shadow-lg transition-all duration-200 flex items-center justify-center text-lg" | |
| > | |
| <DollarSign size={20} className="mr-2" /> Pay with Cash | |
| </button> | |
| <button | |
| onClick={() => handlePayment('Card')} | |
| className="w-full bg-blue-500 hover:bg-blue-600 text-white font-semibold py-3 px-4 rounded-lg shadow-md hover:shadow-lg transition-all duration-200 flex items-center justify-center text-lg" | |
| > | |
| <CreditCard size={20} className="mr-2" /> Pay with Card | |
| </button> | |
| </div> | |
| </> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| </main> | |
| {/* Footer */} | |
| <footer className="bg-gray-800 text-gray-300 text-center p-4 mt-auto"> | |
| <p>© {new Date().getFullYear()} Cool F&B POS. All rights reserved.</p> | |
| </footer> | |
| {/* Payment Modal */} | |
| {showPaymentModal && <PaymentModal />} | |
| {/* Custom Scrollbar CSS (Optional, for aesthetics) */} | |
| <style jsx global>{` | |
| .custom-scrollbar::-webkit-scrollbar { | |
| width: 8px; | |
| } | |
| .custom-scrollbar::-webkit-scrollbar-track { | |
| background: #f1f1f1; | |
| border-radius: 10px; | |
| } | |
| .custom-scrollbar::-webkit-scrollbar-thumb { | |
| background: #cbd5e1; // Tailwind gray-300 | |
| border-radius: 10px; | |
| } | |
| .custom-scrollbar::-webkit-scrollbar-thumb:hover { | |
| background: #94a3b8; // Tailwind gray-400 | |
| } | |
| `}</style> | |
| </div> | |
| ); | |
| } | |
| export default App; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment