Compare commits
4 Commits
version_6_
...
version_8_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ca116da42 | ||
| 497cfbf854 | |||
|
|
ee03fea5aa | ||
| 6d1cb751d0 |
19
src/App.tsx
19
src/App.tsx
@@ -1,13 +1,20 @@
|
|||||||
import { Routes, Route } from 'react-router-dom';
|
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||||
import Layout from './components/Layout';
|
import Layout from './components/Layout';
|
||||||
import HomePage from './pages/HomePage';
|
import HomePage from './pages/HomePage';
|
||||||
|
import CartPage from './pages/CartPage';
|
||||||
|
import { CartProvider } from './context/CartContext';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<CartProvider>
|
||||||
<Route element={<Layout />}>
|
<Router>
|
||||||
<Route path="/" element={<HomePage />} />
|
<Routes>
|
||||||
</Route>
|
<Route element={<Layout />}>
|
||||||
</Routes>
|
<Route path="/" element={<HomePage />} />
|
||||||
|
<Route path="/cart" element={<CartPage />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</Router>
|
||||||
|
</CartProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ const HeroBillboard = ({
|
|||||||
videoSrc,
|
videoSrc,
|
||||||
}: HeroBillboardProps) => {
|
}: HeroBillboardProps) => {
|
||||||
return (
|
return (
|
||||||
<section aria-label="Hero section" className="relative pt-25 pb-20 md:py-30 mb-20">
|
<section aria-label="Hero section" className="relative pt-25 pb-20 md:py-30">
|
||||||
<HeroBackgroundSlot />
|
<HeroBackgroundSlot />
|
||||||
<div className="flex flex-col gap-10 w-content-width mx-auto">
|
<div className="flex flex-col gap-10 w-content-width mx-auto">
|
||||||
<div className="flex flex-col items-center gap-2 text-center">
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
|
|||||||
@@ -6,6 +6,18 @@ import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
|||||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||||
import useProducts from "@/hooks/useProducts";
|
import useProducts from "@/hooks/useProducts";
|
||||||
|
import { useCart } from "@/context/CartContext";
|
||||||
|
|
||||||
|
type Product = {
|
||||||
|
id: number;
|
||||||
|
brand: string;
|
||||||
|
name:string;
|
||||||
|
price: string;
|
||||||
|
rating: number;
|
||||||
|
reviewCount: string;
|
||||||
|
imageSrc: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
type ProductRatingCardsProps = {
|
type ProductRatingCardsProps = {
|
||||||
tag: string;
|
tag: string;
|
||||||
@@ -13,15 +25,7 @@ type ProductRatingCardsProps = {
|
|||||||
description: string;
|
description: string;
|
||||||
primaryButton?: { text: string; href: string };
|
primaryButton?: { text: string; href: string };
|
||||||
secondaryButton?: { text: string; href: string };
|
secondaryButton?: { text: string; href: string };
|
||||||
products?: {
|
products?: Product[];
|
||||||
brand: string;
|
|
||||||
name: string;
|
|
||||||
price: string;
|
|
||||||
rating: number;
|
|
||||||
reviewCount: string;
|
|
||||||
imageSrc: string;
|
|
||||||
onClick?: () => void;
|
|
||||||
}[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const ProductRatingCards = ({
|
const ProductRatingCards = ({
|
||||||
@@ -33,9 +37,12 @@ const ProductRatingCards = ({
|
|||||||
products: productsProp,
|
products: productsProp,
|
||||||
}: ProductRatingCardsProps) => {
|
}: ProductRatingCardsProps) => {
|
||||||
const { products: fetchedProducts, isLoading } = useProducts();
|
const { products: fetchedProducts, isLoading } = useProducts();
|
||||||
|
const { addToCart } = useCart();
|
||||||
|
|
||||||
const isFromApi = fetchedProducts.length > 0;
|
const isFromApi = fetchedProducts.length > 0;
|
||||||
const products = isFromApi
|
const products = isFromApi
|
||||||
? fetchedProducts.map((p) => ({
|
? fetchedProducts.map((p, index) => ({
|
||||||
|
id: p.id || index,
|
||||||
brand: p.brand || "",
|
brand: p.brand || "",
|
||||||
name: p.name,
|
name: p.name,
|
||||||
price: p.price,
|
price: p.price,
|
||||||
@@ -44,7 +51,7 @@ const ProductRatingCards = ({
|
|||||||
imageSrc: p.imageSrc,
|
imageSrc: p.imageSrc,
|
||||||
onClick: p.onProductClick,
|
onClick: p.onProductClick,
|
||||||
}))
|
}))
|
||||||
: productsProp;
|
: productsProp?.map((p, index) => ({ ...p, id: p.id || index }));
|
||||||
|
|
||||||
if (isLoading && !productsProp) {
|
if (isLoading && !productsProp) {
|
||||||
return (
|
return (
|
||||||
@@ -60,6 +67,11 @@ const ProductRatingCards = ({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleAddToCart = (product: Product) => {
|
||||||
|
const priceNumber = parseFloat(product.price.replace('$', ''));
|
||||||
|
addToCart({ ...product, price: priceNumber, quantity: 1, image: product.imageSrc });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-label="Products section" className="py-20">
|
<section aria-label="Products section" className="py-20">
|
||||||
<div className="flex flex-col gap-8">
|
<div className="flex flex-col gap-8">
|
||||||
@@ -93,12 +105,11 @@ const ProductRatingCards = ({
|
|||||||
<ScrollReveal variant="fade-blur">
|
<ScrollReveal variant="fade-blur">
|
||||||
<GridOrCarousel>
|
<GridOrCarousel>
|
||||||
{products.map((product) => (
|
{products.map((product) => (
|
||||||
<button
|
<div
|
||||||
key={product.name}
|
key={product.name}
|
||||||
onClick={product.onClick}
|
className="group h-full flex flex-col gap-3 xl:gap-4 2xl:gap-5 p-3 xl:p-4 2xl:p-5 text-left card rounded"
|
||||||
className="group h-full flex flex-col gap-3 xl:gap-4 2xl:gap-5 p-3 xl:p-4 2xl:p-5 text-left card rounded cursor-pointer"
|
|
||||||
>
|
>
|
||||||
<div className="aspect-square rounded overflow-hidden">
|
<div className="aspect-square rounded overflow-hidden" onClick={product.onClick}>
|
||||||
<ImageOrVideo imageSrc={product.imageSrc} />
|
<ImageOrVideo imageSrc={product.imageSrc} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -127,7 +138,10 @@ const ProductRatingCards = ({
|
|||||||
|
|
||||||
<p className="text-2xl font-medium">{product.price}</p>
|
<p className="text-2xl font-medium">{product.price}</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
<Button onClick={() => handleAddToCart(product)} variant="primary" className="w-full mt-auto">
|
||||||
|
Add to Cart
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</GridOrCarousel>
|
</GridOrCarousel>
|
||||||
</ScrollReveal>
|
</ScrollReveal>
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { motion, AnimatePresence } from "motion/react";
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
import { Plus, ArrowUpRight } from "lucide-react";
|
import { Plus, ArrowUpRight, ShoppingCart } from "lucide-react";
|
||||||
import { cls } from "@/lib/utils";
|
import { cls } from "@/lib/utils";
|
||||||
import Button from "@/components/ui/Button";
|
import Button from "@/components/ui/Button";
|
||||||
|
import { useCart } from "@/context/CartContext";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
interface NavbarFloatingProps {
|
interface NavbarFloatingProps {
|
||||||
logo: string;
|
logo: string;
|
||||||
@@ -21,6 +26,8 @@ const handleNavClick = (e: React.MouseEvent<HTMLAnchorElement>, href: string, on
|
|||||||
|
|
||||||
const NavbarFloating = ({ logo, navItems, ctaButton }: NavbarFloatingProps) => {
|
const NavbarFloating = ({ logo, navItems, ctaButton }: NavbarFloatingProps) => {
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const { cartItems } = useCart();
|
||||||
|
const cartItemCount = cartItems.reduce((acc, item) => acc + item.quantity, 0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
@@ -48,22 +55,32 @@ const NavbarFloating = ({ logo, navItems, ctaButton }: NavbarFloatingProps) => {
|
|||||||
<nav className="fixed z-1000 top-5 left-1/2 -translate-x-1/2 w-content-width">
|
<nav className="fixed z-1000 top-5 left-1/2 -translate-x-1/2 w-content-width">
|
||||||
<div className="mx-auto w-full md:w-1/2 overflow-hidden rounded backdrop-blur-sm card">
|
<div className="mx-auto w-full md:w-1/2 overflow-hidden rounded backdrop-blur-sm card">
|
||||||
<div className="relative z-10 flex items-center justify-between gap-3 xl:gap-4 2xl:gap-5 p-3 xl:p-4 2xl:p-5">
|
<div className="relative z-10 flex items-center justify-between gap-3 xl:gap-4 2xl:gap-5 p-3 xl:p-4 2xl:p-5">
|
||||||
<a href="/" className="text-xl font-medium text-foreground">{logo}</a>
|
<a href="/" className="text-xl font-medium text-foreground">{logo}</Link>
|
||||||
|
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
className="flex items-center justify-center shrink-0 size-9 rounded cursor-pointer primary-button"
|
<Link to="/cart" className="relative p-2">
|
||||||
onClick={() => setMenuOpen(!menuOpen)}
|
<ShoppingCart className="w-6 h-6 text-foreground" />
|
||||||
aria-label="Toggle menu"
|
{cartItemCount > 0 && (
|
||||||
aria-expanded={menuOpen}
|
<span className="absolute top-0 right-0 block h-4 w-4 rounded-full bg-red-500 text-white text-xs text-center">
|
||||||
>
|
{cartItemCount}
|
||||||
<Plus
|
</span>
|
||||||
className={cls(
|
|
||||||
"w-1/2 h-1/2 text-primary-cta-text transition-transform duration-300",
|
|
||||||
menuOpen ? "rotate-45" : "rotate-0"
|
|
||||||
)}
|
)}
|
||||||
strokeWidth={1.5}
|
</Link>
|
||||||
/>
|
<button
|
||||||
</button>
|
className="flex items-center justify-center shrink-0 size-9 rounded cursor-pointer primary-button"
|
||||||
|
onClick={() => setMenuOpen(!menuOpen)}
|
||||||
|
aria-label="Toggle menu"
|
||||||
|
aria-expanded={menuOpen}
|
||||||
|
>
|
||||||
|
<Plus
|
||||||
|
className={cls(
|
||||||
|
"w-1/2 h-1/2 text-primary-cta-text transition-transform duration-300",
|
||||||
|
menuOpen ? "rotate-45" : "rotate-0"
|
||||||
|
)}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
@@ -91,7 +108,7 @@ const NavbarFloating = ({ logo, navItems, ctaButton }: NavbarFloatingProps) => {
|
|||||||
className="h-(--text-xl) md:h-(--text-2xl) w-auto text-foreground group-hover:rotate-45 group-hover:mr-3 transition-all duration-300"
|
className="h-(--text-xl) md:h-(--text-2xl) w-auto text-foreground group-hover:rotate-45 group-hover:mr-3 transition-all duration-300"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
/>
|
/>
|
||||||
</a>
|
</Link>
|
||||||
{index < navItems.length - 1 && (
|
{index < navItems.length - 1 && (
|
||||||
<div className="h-px bg-accent/50" />
|
<div className="h-px bg-accent/50" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
70
src/context/CartContext.tsx
Normal file
70
src/context/CartContext.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
|
||||||
|
import React, { createContext, useState, useContext, ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface Product {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
image: string;
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CartContextType {
|
||||||
|
cartItems: Product[];
|
||||||
|
addToCart: (product: Product) => void;
|
||||||
|
removeFromCart: (productId: number) => void;
|
||||||
|
updateQuantity: (productId: number, quantity: number) => void;
|
||||||
|
clearCart: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CartContext = createContext<CartContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const CartProvider = ({ children }: { children: ReactNode }) => {
|
||||||
|
const [cartItems, setCartItems] = useState<Product[]>([]);
|
||||||
|
|
||||||
|
const addToCart = (product: Product) => {
|
||||||
|
setCartItems(prevItems => {
|
||||||
|
const itemInCart = prevItems.find(item => item.id === product.id);
|
||||||
|
if (itemInCart) {
|
||||||
|
return prevItems.map(item =>
|
||||||
|
item.id === product.id ? { ...item, quantity: item.quantity + 1 } : item
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return [...prevItems, { ...product, quantity: 1 }];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFromCart = (productId: number) => {
|
||||||
|
setCartItems(prevItems => prevItems.filter(item => item.id !== productId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateQuantity = (productId: number, quantity: number) => {
|
||||||
|
if (quantity <= 0) {
|
||||||
|
removeFromCart(productId);
|
||||||
|
} else {
|
||||||
|
setCartItems(prevItems =>
|
||||||
|
prevItems.map(item =>
|
||||||
|
item.id === productId ? { ...item, quantity } : item
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearCart = () => {
|
||||||
|
setCartItems([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CartContext.Provider value={{ cartItems, addToCart, removeFromCart, updateQuantity, clearCart }}>
|
||||||
|
{children}
|
||||||
|
</CartContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCart = () => {
|
||||||
|
const context = useContext(CartContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useCart must be used within a CartProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
56
src/pages/CartPage.tsx
Normal file
56
src/pages/CartPage.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useCart } from '../context/CartContext';
|
||||||
|
import Button from '../components/ui/Button';
|
||||||
|
|
||||||
|
const CartPage = () => {
|
||||||
|
const { cartItems, removeFromCart, updateQuantity, clearCart } = useCart();
|
||||||
|
|
||||||
|
const total = cartItems.reduce((acc, item) => acc + item.price * item.quantity, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<h1 className="text-3xl font-bold mb-4">Your Cart</h1>
|
||||||
|
{cartItems.length === 0 ? (
|
||||||
|
<p>Your cart is empty.</p>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{cartItems.map(item => (
|
||||||
|
<div key={item.id} className="flex items-center justify-between border-b py-4">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<img src={item.image} alt={item.name} className="w-20 h-20 object-cover mr-4" />
|
||||||
|
<div>
|
||||||
|
<h2 className="font-bold">{item.name}</h2>
|
||||||
|
<p>${item.price.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={item.quantity}
|
||||||
|
onChange={(e) => updateQuantity(item.id, parseInt(e.target.value))}
|
||||||
|
className="w-16 text-center border rounded mr-4"
|
||||||
|
min="1"
|
||||||
|
/>
|
||||||
|
<Button onClick={() => removeFromCart(item.id)} variant="destructive">
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="mt-4 text-right">
|
||||||
|
<h2 className="text-2xl font-bold">Total: ${total.toFixed(2)}</h2>
|
||||||
|
<Button onClick={() => alert('Checkout functionality not implemented yet.')} className="mt-4">
|
||||||
|
Checkout
|
||||||
|
</Button>
|
||||||
|
<Button onClick={clearCart} variant="secondary" className="mt-4 ml-2">
|
||||||
|
Clear Cart
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CartPage;
|
||||||
@@ -137,6 +137,7 @@ export default function HomePage() {
|
|||||||
description="Handcrafted with passion and precision, our wines embody the spirit of our vineyard. Explore our diverse range and find your next favorite vintage."
|
description="Handcrafted with passion and precision, our wines embody the spirit of our vineyard. Explore our diverse range and find your next favorite vintage."
|
||||||
products={[
|
products={[
|
||||||
{
|
{
|
||||||
|
id: 1,
|
||||||
brand: "Vine & Vintages",
|
brand: "Vine & Vintages",
|
||||||
name: "Estate Cabernet Sauvignon",
|
name: "Estate Cabernet Sauvignon",
|
||||||
price: "$55.00",
|
price: "$55.00",
|
||||||
@@ -145,6 +146,7 @@ export default function HomePage() {
|
|||||||
imageSrc: "http://img.b2bpic.net/free-photo/liquor-round-bottle_176474-6072.jpg",
|
imageSrc: "http://img.b2bpic.net/free-photo/liquor-round-bottle_176474-6072.jpg",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: 2,
|
||||||
brand: "Vine & Vintages",
|
brand: "Vine & Vintages",
|
||||||
name: "Reserve Chardonnay",
|
name: "Reserve Chardonnay",
|
||||||
price: "$48.00",
|
price: "$48.00",
|
||||||
@@ -153,6 +155,7 @@ export default function HomePage() {
|
|||||||
imageSrc: "http://img.b2bpic.net/free-photo/set-wine-bottle-with-glass-carafe_23-2148261706.jpg",
|
imageSrc: "http://img.b2bpic.net/free-photo/set-wine-bottle-with-glass-carafe_23-2148261706.jpg",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: 3,
|
||||||
brand: "Vine & Vintages",
|
brand: "Vine & Vintages",
|
||||||
name: "Summer Rosé",
|
name: "Summer Rosé",
|
||||||
price: "$32.00",
|
price: "$32.00",
|
||||||
@@ -161,6 +164,7 @@ export default function HomePage() {
|
|||||||
imageSrc: "http://img.b2bpic.net/free-photo/still-life-stacked-aesthetic-objects_23-2150185382.jpg",
|
imageSrc: "http://img.b2bpic.net/free-photo/still-life-stacked-aesthetic-objects_23-2150185382.jpg",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: 4,
|
||||||
brand: "Vine & Vintages",
|
brand: "Vine & Vintages",
|
||||||
name: "Sparkling Brut",
|
name: "Sparkling Brut",
|
||||||
price: "$60.00",
|
price: "$60.00",
|
||||||
@@ -169,6 +173,7 @@ export default function HomePage() {
|
|||||||
imageSrc: "http://img.b2bpic.net/free-photo/front-view-pair-champagne-glasses-wood-board-champagne-bottle_140725-99664.jpg",
|
imageSrc: "http://img.b2bpic.net/free-photo/front-view-pair-champagne-glasses-wood-board-champagne-bottle_140725-99664.jpg",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: 5,
|
||||||
brand: "Vine & Vintages",
|
brand: "Vine & Vintages",
|
||||||
name: "Late Harvest Dessert Wine",
|
name: "Late Harvest Dessert Wine",
|
||||||
price: "$75.00",
|
price: "$75.00",
|
||||||
@@ -177,6 +182,7 @@ export default function HomePage() {
|
|||||||
imageSrc: "http://img.b2bpic.net/free-photo/rollcake-with-berries-drink-platter_114579-16491.jpg",
|
imageSrc: "http://img.b2bpic.net/free-photo/rollcake-with-berries-drink-platter_114579-16491.jpg",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: 6,
|
||||||
brand: "Vine & Vintages",
|
brand: "Vine & Vintages",
|
||||||
name: "Grand Cru Pinot Noir",
|
name: "Grand Cru Pinot Noir",
|
||||||
price: "$90.00",
|
price: "$90.00",
|
||||||
|
|||||||
Reference in New Issue
Block a user