From 15ea14552d7032df9ceb7c44e594af6ac504b228 Mon Sep 17 00:00:00 2001 From: "ALMAZROUEI Shamma (2021) WKIS203" <shamma.almazrouei.2021@live.rhul.ac.uk> Date: Wed, 12 Mar 2025 18:07:52 +0530 Subject: [PATCH] Build checkout page --- golden-crust-bakery/package-lock.json | 33 ++ golden-crust-bakery/package.json | 1 + golden-crust-bakery/src/components/Header.jsx | 48 ++- .../src/components/ui/radio-group.jsx | 29 ++ golden-crust-bakery/src/main.jsx | 5 + golden-crust-bakery/src/pages/About.jsx | 8 +- golden-crust-bakery/src/pages/Checkout.jsx | 401 ++++++++++++++++++ 7 files changed, 502 insertions(+), 23 deletions(-) create mode 100644 golden-crust-bakery/src/components/ui/radio-group.jsx create mode 100644 golden-crust-bakery/src/pages/Checkout.jsx diff --git a/golden-crust-bakery/package-lock.json b/golden-crust-bakery/package-lock.json index 87b1ca4..9f0b7ca 100644 --- a/golden-crust-bakery/package-lock.json +++ b/golden-crust-bakery/package-lock.json @@ -14,6 +14,7 @@ "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-radio-group": "^1.2.3", "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-slot": "^1.1.2", "class-variance-authority": "^0.7.1", @@ -1684,6 +1685,38 @@ } } }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.2.3.tgz", + "integrity": "sha512-xtCsqt8Rp09FK50ItqEqTJ7Sxanz8EM8dnkVIhJrc/wkMMomSmXHvYbhv3E7Zx4oXh98aaLt9W679SUYXg4IDA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-roving-focus": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-roving-focus": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.2.tgz", diff --git a/golden-crust-bakery/package.json b/golden-crust-bakery/package.json index 45050f1..f043ed5 100644 --- a/golden-crust-bakery/package.json +++ b/golden-crust-bakery/package.json @@ -16,6 +16,7 @@ "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-radio-group": "^1.2.3", "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-slot": "^1.1.2", "class-variance-authority": "^0.7.1", diff --git a/golden-crust-bakery/src/components/Header.jsx b/golden-crust-bakery/src/components/Header.jsx index e188ebe..3dbad6f 100644 --- a/golden-crust-bakery/src/components/Header.jsx +++ b/golden-crust-bakery/src/components/Header.jsx @@ -91,16 +91,18 @@ export default function Header() { <Button variant='ghost' size='icon' - onClick={() => navigate('/cart')} className='relative' aria-label='Shopping cart' + asChild > - <ShoppingBag className='h-6 w-6 text-gray-700' /> - {totalItems > 0 && ( - <span className='absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-pink-500 text-xs font-medium text-white'> - {totalItems} - </span> - )} + <Link to='/cart'> + <ShoppingBag className='h-6 w-6 text-gray-700' /> + {totalItems > 0 && ( + <span className='absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-pink-500 text-xs font-medium text-white'> + {totalItems} + </span> + )} + </Link> </Button> <DropdownMenu> @@ -124,11 +126,15 @@ export default function Header() { </div> </DropdownMenuLabel> <DropdownMenuSeparator /> - <DropdownMenuItem onClick={() => navigate('/profile')}> - <User className='mr-2 h-4 w-4' /> Profile + <DropdownMenuItem asChild> + <Link to='/profile'> + <User className='mr-2 h-4 w-4' /> Profile + </Link> </DropdownMenuItem> - <DropdownMenuItem onClick={() => navigate('/orders')}> - <ShoppingBag className='mr-2 h-4 w-4' /> My Orders + <DropdownMenuItem asChild> + <Link to='/orders'> + <ShoppingBag className='mr-2 h-4 w-4' /> My Orders + </Link> </DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem onClick={handleLogout}> @@ -139,10 +145,10 @@ export default function Header() { </> ) : ( <Button - onClick={() => navigate('/login')} + asChild className='bg-pink-500 hover:bg-pink-600 text-white rounded-full' > - Sign In + <Link to='/login'>Sign In</Link> </Button> )} @@ -204,14 +210,16 @@ export default function Header() { {isAuthenticated ? ( <> <Button + asChild onClick={() => { - navigate('/cart'); setIsMobileMenuOpen(false); }} className='w-full bg-pink-500 hover:bg-pink-600 text-white rounded-full mb-4' > - <ShoppingBag className='mr-2 h-4 w-4' /> - View Cart {totalItems > 0 && `(${totalItems})`} + <Link to='/cart'> + <ShoppingBag className='mr-2 h-4 w-4' /> + View Cart {totalItems > 0 && `(${totalItems})`} + </Link> </Button> <Button variant='outline' @@ -225,22 +233,22 @@ export default function Header() { <div className='space-y-4'> <Button onClick={() => { - navigate('/login'); setIsMobileMenuOpen(false); }} + asChild className='w-full bg-pink-500 hover:bg-pink-600 text-white rounded-full' > - Sign In + <Link to='/login'>Sign In</Link> </Button> <Button variant='outline' onClick={() => { - navigate('/register'); setIsMobileMenuOpen(false); }} + asChild className='w-full border-pink-300 text-pink-600 hover:bg-pink-50 rounded-full' > - Create Account + <Link to='/register'>Create Account</Link> </Button> </div> )} diff --git a/golden-crust-bakery/src/components/ui/radio-group.jsx b/golden-crust-bakery/src/components/ui/radio-group.jsx new file mode 100644 index 0000000..4d8a29e --- /dev/null +++ b/golden-crust-bakery/src/components/ui/radio-group.jsx @@ -0,0 +1,29 @@ +import * as React from "react" +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" +import { Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const RadioGroup = React.forwardRef(({ className, ...props }, ref) => { + return (<RadioGroupPrimitive.Root className={cn("grid gap-2", className)} {...props} ref={ref} />); +}) +RadioGroup.displayName = RadioGroupPrimitive.Root.displayName + +const RadioGroupItem = React.forwardRef(({ className, ...props }, ref) => { + return ( + <RadioGroupPrimitive.Item + ref={ref} + className={cn( + "aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50", + className + )} + {...props}> + <RadioGroupPrimitive.Indicator className="flex items-center justify-center"> + <Circle className="h-3.5 w-3.5 fill-primary" /> + </RadioGroupPrimitive.Indicator> + </RadioGroupPrimitive.Item> + ); +}) +RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName + +export { RadioGroup, RadioGroupItem } diff --git a/golden-crust-bakery/src/main.jsx b/golden-crust-bakery/src/main.jsx index aa26fb2..d681e03 100644 --- a/golden-crust-bakery/src/main.jsx +++ b/golden-crust-bakery/src/main.jsx @@ -18,6 +18,7 @@ import MenuItem from './pages/MenuItem'; import ErrorPage from './pages/Error'; import Cart from './pages/Cart'; import Orders from './pages/Orders'; +import Checkout from './pages/Checkout'; const router = createBrowserRouter([ { @@ -53,6 +54,10 @@ const router = createBrowserRouter([ path: '/orders', element: <Orders />, }, + { + path: '/checkout', + element: <Checkout />, + }, { path: '/menu', element: <Menu />, diff --git a/golden-crust-bakery/src/pages/About.jsx b/golden-crust-bakery/src/pages/About.jsx index b9e7c50..8e69a83 100644 --- a/golden-crust-bakery/src/pages/About.jsx +++ b/golden-crust-bakery/src/pages/About.jsx @@ -1,4 +1,4 @@ -import { useNavigate } from 'react-router-dom'; +import { Link, useNavigate } from 'react-router-dom'; import { ArrowRight, Award, Clock, Heart, Users } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -60,10 +60,12 @@ export default function About() { of your special moments and memories. </p> <Button - onClick={() => navigate('/contact')} + asChild className='bg-pink-500 hover:bg-pink-600 text-white rounded-full mt-2' > - Get in Touch <ArrowRight className='ml-2 h-4 w-4' /> + <Link to='/contact'> + Get in Touch <ArrowRight className='ml-2 h-4 w-4' /> + </Link> </Button> </div> <div className='relative h-[300px] md:h-[400px] rounded-2xl overflow-hidden shadow-md order-1 md:order-2'> diff --git a/golden-crust-bakery/src/pages/Checkout.jsx b/golden-crust-bakery/src/pages/Checkout.jsx new file mode 100644 index 0000000..db53d4d --- /dev/null +++ b/golden-crust-bakery/src/pages/Checkout.jsx @@ -0,0 +1,401 @@ +import { useEffect, useState } from 'react'; +import { toast } from 'sonner'; +import { useNavigate } from 'react-router-dom'; +import { ArrowLeft, CreditCard, HandCoins, Truck } from 'lucide-react'; + +import { useCartStore } from '@/lib/store'; +import { useOrderStore } from '@/lib/orderStore'; +import { useAuthStore } from '@/lib/authStore'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; + +export default function Checkout() { + const navigate = useNavigate(); + const { cart, clearCart, initializeCart } = useCartStore(); + const { addOrder } = useOrderStore(); + const { user, isAuthenticated } = useAuthStore(); + + const [formData, setFormData] = useState({ + name: '', + email: '', + phone: '', + address: '', + city: '', + zipCode: '', + paymentMethod: 'credit-card', + specialInstructions: '', + }); + + const [errors, setErrors] = useState({}); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + initializeCart(); + + // Redirect to login if not authenticated + if (!isAuthenticated) { + toast.error('Authentication required!', { + description: 'Please sign in to checkout.', + }); + navigate('/login'); + return; + } + + // Pre-fill user data if available + if (user) { + setFormData((prev) => ({ + ...prev, + name: user.name || '', + email: user.email || '', + })); + } + + if (cart.length === 0) { + toast.error('Cart is empty!', { + description: 'Please add items to your cart before checking out.', + }); + navigate('/menu'); + } + }, [cart.length, initializeCart, isAuthenticated, user, navigate]); + + // If not authenticated, don't render the checkout content + if (!isAuthenticated) { + return null; + } + + const subtotal = cart.reduce( + (total, item) => total + item.price * item.quantity, + 0 + ); + const tax = subtotal * 0.12; // 12% tax + const deliveryFee = 5.99; + const total = subtotal + tax + deliveryFee; + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData((prev) => ({ + ...prev, + [name]: value, + })); + + // Clear error when field is being edited + if (errors[name]) { + setErrors((prev) => ({ + ...prev, + [name]: '', + })); + } + }; + + const validateForm = () => { + const newErrors = {}; + + if (!formData.name.trim()) newErrors.name = 'Name is required'; + if (!formData.email.trim()) { + newErrors.email = 'Email is required'; + } else if (!/\S+@\S+\.\S+/.test(formData.email)) { + newErrors.email = 'Email is invalid'; + } + if (!formData.phone.trim()) newErrors.phone = 'Phone number is required'; + if (!formData.address.trim()) newErrors.address = 'Address is required'; + if (!formData.city.trim()) newErrors.city = 'City is required'; + if (!formData.zipCode.trim()) newErrors.zipCode = 'ZIP code is required'; + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = (e) => { + e.preventDefault(); + + if (!validateForm()) return; + + setIsSubmitting(true); + + // Simulate processing + setTimeout(() => { + const orderId = `ORD-${Date.now()}`; + const orderDate = new Date().toISOString(); + + const order = { + id: orderId, + date: orderDate, + items: [...cart], + customer: { + name: formData.name, + email: formData.email, + phone: formData.phone, + address: formData.address, + city: formData.city, + zipCode: formData.zipCode, + }, + paymentMethod: formData.paymentMethod, + specialInstructions: formData.specialInstructions, + status: 'processing', + subtotal, + tax, + deliveryFee, + total, + }; + + addOrder(order); + clearCart(); + + setIsSubmitting(false); + navigate(`/confirmation?orderId=${orderId}`); + }, 1500); + }; + + return ( + <div className='container mx-auto px-4 py-8 md:py-12'> + <div className='text-center mb-8'> + <h1 className='text-4xl font-bold text-gray-800 mb-4'>Checkout</h1> + <p className='text-gray-600'>Complete your order details below</p> + </div> + + <div className='grid grid-cols-1 lg:grid-cols-3 gap-8'> + <div className='lg:col-span-2'> + <form + onSubmit={handleSubmit} + className='bg-white rounded-xl shadow-sm border overflow-hidden' + > + <div className='p-6'> + <h2 className='text-xl font-semibold text-gray-800 mb-6'> + Delivery Information + </h2> + + <div className='grid grid-cols-1 md:grid-cols-2 gap-6 mb-6'> + <div className='space-y-2'> + <Label htmlFor='name'>Full Name</Label> + <Input + id='name' + name='name' + value={formData.name} + onChange={handleChange} + className={`rounded-lg ${ + errors.name ? 'border-red-500' : 'border-gray-200' + }`} + /> + {errors.name && ( + <p className='text-red-500 text-sm'>{errors.name}</p> + )} + </div> + + <div className='space-y-2'> + <Label htmlFor='email'>Email Address</Label> + <Input + id='email' + name='email' + type='email' + value={formData.email} + onChange={handleChange} + className={`rounded-lg ${ + errors.email ? 'border-red-500' : 'border-gray-200' + }`} + /> + {errors.email && ( + <p className='text-red-500 text-sm'>{errors.email}</p> + )} + </div> + + <div className='space-y-2'> + <Label htmlFor='phone'>Phone Number</Label> + <Input + id='phone' + name='phone' + value={formData.phone} + onChange={handleChange} + className={`rounded-lg ${ + errors.phone ? 'border-red-500' : 'border-gray-200' + }`} + /> + {errors.phone && ( + <p className='text-red-500 text-sm'>{errors.phone}</p> + )} + </div> + + <div className='space-y-2'> + <Label htmlFor='address'>Delivery Address</Label> + <Input + id='address' + name='address' + value={formData.address} + onChange={handleChange} + className={`rounded-lg ${ + errors.address ? 'border-red-500' : 'border-gray-200' + }`} + /> + {errors.address && ( + <p className='text-red-500 text-sm'>{errors.address}</p> + )} + </div> + + <div className='space-y-2'> + <Label htmlFor='city'>City</Label> + <Input + id='city' + name='city' + value={formData.city} + onChange={handleChange} + className={`rounded-lg ${ + errors.city ? 'border-red-500' : 'border-gray-200' + }`} + /> + {errors.city && ( + <p className='text-red-500 text-sm'>{errors.city}</p> + )} + </div> + + <div className='space-y-2'> + <Label htmlFor='zipCode'>ZIP Code</Label> + <Input + id='zipCode' + name='zipCode' + value={formData.zipCode} + onChange={handleChange} + className={`rounded-lg ${ + errors.zipCode ? 'border-red-500' : 'border-gray-200' + }`} + /> + {errors.zipCode && ( + <p className='text-red-500 text-sm'>{errors.zipCode}</p> + )} + </div> + </div> + + <div className='space-y-4 mb-6'> + <h2 className='text-xl font-semibold text-gray-800 mb-2'> + Payment Method + </h2> + <RadioGroup + value={formData.paymentMethod} + onValueChange={(value) => + setFormData((prev) => ({ ...prev, paymentMethod: value })) + } + className='flex flex-col space-y-2' + > + <div className='flex items-center space-x-2'> + <RadioGroupItem value='credit-card' id='credit-card' /> + <Label htmlFor='credit-card' className='flex items-center'> + <CreditCard className='mr-2 h-4 w-4' /> Credit Card (Pay + at Delivery) + </Label> + </div> + <div className='flex items-center space-x-2'> + <RadioGroupItem value='cash' id='cash' /> + <Label htmlFor='cash' className='flex items-center'> + <HandCoins className='mr-2 h-4 w-4' /> Credit Card (Pay at + Delivery) + </Label> + </div> + </RadioGroup> + </div> + + <div className='space-y-2 mb-6'> + <Label htmlFor='specialInstructions'> + Special Instructions (Optional) + </Label> + <Textarea + id='specialInstructions' + name='specialInstructions' + value={formData.specialInstructions} + onChange={handleChange} + placeholder='Any special requests for your order or delivery' + className='rounded-lg border-gray-200 min-h-[100px]' + /> + </div> + + <div className='flex flex-col sm:flex-row gap-4'> + <Button + type='button' + variant='outline' + onClick={() => navigate('/cart')} + className='border-pink-300 text-pink-600 hover:bg-pink-50 rounded-full' + > + <ArrowLeft className='mr-2 h-4 w-4' /> Back to Cart + </Button> + <Button + type='submit' + disabled={isSubmitting} + className='bg-pink-500 hover:bg-pink-600 text-white rounded-full' + > + {isSubmitting ? 'Processing...' : 'Complete Order'} + </Button> + </div> + </div> + </form> + </div> + + <div className='lg:col-span-1'> + <div className='bg-white rounded-xl shadow-sm border overflow-hidden'> + <div className='p-6'> + <h2 className='text-lg font-semibold text-gray-900 mb-4'> + Order Summary + </h2> + <div className='flow-root'> + <ul className='divide-y divide-gray-200'> + {cart.map((item) => ( + <li key={item.id} className='py-3 flex justify-between'> + <div> + <p className='text-sm font-medium text-gray-900'> + {item.name} + </p> + <p className='text-sm text-gray-500'> + Qty: {item.quantity} + </p> + </div> + <p className='text-sm font-medium text-gray-900'> + ${(item.price * item.quantity).toFixed(2)} + </p> + </li> + ))} + </ul> + </div> + <div className='border-t border-gray-200 mt-4 pt-4'> + <div className='flex justify-between py-2'> + <dt className='text-sm text-gray-600'>Subtotal</dt> + <dd className='text-sm font-medium text-gray-900'> + ${subtotal.toFixed(2)} + </dd> + </div> + <div className='flex justify-between py-2'> + <dt className='text-sm text-gray-600'>Tax (12%)</dt> + <dd className='text-sm font-medium text-gray-900'> + ${tax.toFixed(2)} + </dd> + </div> + <div className='flex justify-between py-2'> + <dt className='text-sm text-gray-600'>Delivery Fee</dt> + <dd className='text-sm font-medium text-gray-900'> + ${deliveryFee.toFixed(2)} + </dd> + </div> + <div className='flex justify-between py-2 border-t border-gray-200'> + <dt className='text-base font-medium text-gray-900'>Total</dt> + <dd className='text-base font-medium text-pink-600'> + ${total.toFixed(2)} + </dd> + </div> + </div> + <div className='mt-6 bg-blue-50 p-4 rounded-lg flex items-start'> + <Truck className='h-5 w-5 text-blue-800 mr-2 mt-0.5' /> + <div> + <p className='text-sm font-medium text-blue-800'> + Delivery Information + </p> + <p className='text-sm text-blue-600'> + Your order will be delivered within 2-3 business days. + </p> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + ); +} -- GitLab