Compare commits
9 Commits
version_3_
...
version_8_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ca116da42 | ||
| 497cfbf854 | |||
|
|
ee03fea5aa | ||
| 6d1cb751d0 | |||
|
|
1384b7d561 | ||
| 368345f4c4 | |||
|
|
50b0860872 | ||
| a5deb35a54 | |||
| 2dca9e5f5b |
@@ -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 HomePage from './pages/HomePage';
|
||||
import CartPage from './pages/CartPage';
|
||||
import { CartProvider } from './context/CartContext';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<CartProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/cart" element={<CartPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Router>
|
||||
</CartProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,24 +34,11 @@ const HeroBillboard = ({
|
||||
videoSrc,
|
||||
}: HeroBillboardProps) => {
|
||||
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 />
|
||||
<div className="flex flex-col gap-10 w-content-width mx-auto">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
{testimonials && testimonials.length > 0 ? (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<AvatarGroup avatars={testimonials.map(t => ({ src: t.imageSrc }))} label={avatarsLabel} />
|
||||
<div className="flex flex-wrap justify-center gap-8 mt-4">
|
||||
{testimonials.map((testimonial, index) => (
|
||||
<div key={index} className="max-w-xs text-center">
|
||||
<p className="italic">"{testimonial.testimonial}"</p>
|
||||
<p className="mt-2 font-semibold">{testimonial.name}</p>
|
||||
<p className="text-sm text-gray-600">{testimonial.role}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : tag ? (
|
||||
{tag ? (
|
||||
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
|
||||
) : null}
|
||||
|
||||
@@ -60,7 +47,7 @@ const HeroBillboard = ({
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h1"
|
||||
className="text-6xl font-bold text-balance"
|
||||
className="text-6xl font-extrabold text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
@@ -77,8 +64,19 @@ const HeroBillboard = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="fade" delay={0.2} className="w-full p-3 xl:p-4 2xl:p-5 card rounded overflow-hidden">
|
||||
<ScrollReveal variant="fade" delay={0.2} className="relative w-full p-3 xl:p-4 2xl:p-5 card rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-4/5 md:aspect-video" />
|
||||
{testimonials && testimonials.length > 0 && (
|
||||
<div className="absolute inset-0 grid grid-cols-1 md:grid-cols-3 gap-4 p-4 md:p-8 pointer-events-none">
|
||||
{testimonials.map((testimonial, index) => (
|
||||
<div key={index} className="card p-4 bg-white/80 backdrop-blur-sm rounded-lg shadow-lg pointer-events-auto self-start">
|
||||
<p className="italic">"{testimonial.testimonial}"</p>
|
||||
<p className="mt-2 font-semibold">{testimonial.name}</p>
|
||||
<p className="text-sm text-gray-600">{testimonial.role}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -6,6 +6,18 @@ import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
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 = {
|
||||
tag: string;
|
||||
@@ -13,15 +25,7 @@ type ProductRatingCardsProps = {
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
products?: {
|
||||
brand: string;
|
||||
name: string;
|
||||
price: string;
|
||||
rating: number;
|
||||
reviewCount: string;
|
||||
imageSrc: string;
|
||||
onClick?: () => void;
|
||||
}[];
|
||||
products?: Product[];
|
||||
};
|
||||
|
||||
const ProductRatingCards = ({
|
||||
@@ -33,9 +37,12 @@ const ProductRatingCards = ({
|
||||
products: productsProp,
|
||||
}: ProductRatingCardsProps) => {
|
||||
const { products: fetchedProducts, isLoading } = useProducts();
|
||||
const { addToCart } = useCart();
|
||||
|
||||
const isFromApi = fetchedProducts.length > 0;
|
||||
const products = isFromApi
|
||||
? fetchedProducts.map((p) => ({
|
||||
? fetchedProducts.map((p, index) => ({
|
||||
id: p.id || index,
|
||||
brand: p.brand || "",
|
||||
name: p.name,
|
||||
price: p.price,
|
||||
@@ -44,7 +51,7 @@ const ProductRatingCards = ({
|
||||
imageSrc: p.imageSrc,
|
||||
onClick: p.onProductClick,
|
||||
}))
|
||||
: productsProp;
|
||||
: productsProp?.map((p, index) => ({ ...p, id: p.id || index }));
|
||||
|
||||
if (isLoading && !productsProp) {
|
||||
return (
|
||||
@@ -60,6 +67,11 @@ const ProductRatingCards = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleAddToCart = (product: Product) => {
|
||||
const priceNumber = parseFloat(product.price.replace('$', ''));
|
||||
addToCart({ ...product, price: priceNumber, quantity: 1, image: product.imageSrc });
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-label="Products section" className="py-20">
|
||||
<div className="flex flex-col gap-8">
|
||||
@@ -93,12 +105,11 @@ const ProductRatingCards = ({
|
||||
<ScrollReveal variant="fade-blur">
|
||||
<GridOrCarousel>
|
||||
{products.map((product) => (
|
||||
<button
|
||||
<div
|
||||
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 cursor-pointer"
|
||||
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"
|
||||
>
|
||||
<div className="aspect-square rounded overflow-hidden">
|
||||
<div className="aspect-square rounded overflow-hidden" onClick={product.onClick}>
|
||||
<ImageOrVideo imageSrc={product.imageSrc} />
|
||||
</div>
|
||||
|
||||
@@ -127,7 +138,10 @@ const ProductRatingCards = ({
|
||||
|
||||
<p className="text-2xl font-medium">{product.price}</p>
|
||||
</div>
|
||||
</button>
|
||||
<Button onClick={() => handleAddToCart(product)} variant="primary" className="w-full mt-auto">
|
||||
Add to Cart
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { useState, useEffect } from "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 Button from "@/components/ui/Button";
|
||||
import { useCart } from "@/context/CartContext";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
|
||||
|
||||
|
||||
interface NavbarFloatingProps {
|
||||
logo: string;
|
||||
@@ -21,6 +26,8 @@ const handleNavClick = (e: React.MouseEvent<HTMLAnchorElement>, href: string, on
|
||||
|
||||
const NavbarFloating = ({ logo, navItems, ctaButton }: NavbarFloatingProps) => {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const { cartItems } = useCart();
|
||||
const cartItemCount = cartItems.reduce((acc, item) => acc + item.quantity, 0);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -48,8 +55,17 @@ const NavbarFloating = ({ logo, navItems, ctaButton }: NavbarFloatingProps) => {
|
||||
<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="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>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Link to="/cart" className="relative p-2">
|
||||
<ShoppingCart className="w-6 h-6 text-foreground" />
|
||||
{cartItemCount > 0 && (
|
||||
<span className="absolute top-0 right-0 block h-4 w-4 rounded-full bg-red-500 text-white text-xs text-center">
|
||||
{cartItemCount}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
<button
|
||||
className="flex items-center justify-center shrink-0 size-9 rounded cursor-pointer primary-button"
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
@@ -65,6 +81,7 @@ const NavbarFloating = ({ logo, navItems, ctaButton }: NavbarFloatingProps) => {
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{menuOpen && (
|
||||
@@ -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"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</a>
|
||||
</Link>
|
||||
{index < navItems.length - 1 && (
|
||||
<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;
|
||||
@@ -1,4 +1,4 @@
|
||||
import AboutText from '@/components/sections/about/AboutText';
|
||||
import AboutFeaturesSplit from '@/components/sections/about/AboutFeaturesSplit';
|
||||
import ContactSplitForm from '@/components/sections/contact/ContactSplitForm';
|
||||
import FeaturesArrowCards from '@/components/sections/features/FeaturesArrowCards';
|
||||
import HeroBillboard from '@/components/sections/hero/HeroBillboard';
|
||||
@@ -55,16 +55,36 @@ export default function HomePage() {
|
||||
|
||||
<div id="about" data-section="about">
|
||||
<SectionErrorBoundary name="about">
|
||||
<AboutText
|
||||
title="Our Heritage of Winemaking Excellence"
|
||||
<AboutFeaturesSplit
|
||||
tag="Our Story"
|
||||
title="From Our Family to Yours"
|
||||
description="Founded in 1924, Vine & Vintages is a family-owned winery dedicated to crafting exceptional wines. Our story is one of passion, perseverance, and a deep connection to the land. We believe that every bottle tells a story, and we invite you to become a part of ours."
|
||||
primaryButton={{
|
||||
text: "Read Our Story",
|
||||
href: "#about",
|
||||
text: "Meet the Family",
|
||||
href: "#team",
|
||||
}}
|
||||
secondaryButton={{
|
||||
text: "View Vineyards",
|
||||
text: "Explore the Vineyard",
|
||||
href: "#features",
|
||||
}}
|
||||
imageSrc="http://img.b2bpic.net/free-photo/group-people-enjoying-wine-tasting-event-vineyard-generated-by-ai_188544-45590.jpg"
|
||||
items={[
|
||||
{
|
||||
icon: "Grape",
|
||||
title: "Generations of Expertise",
|
||||
description: "With over a century of winemaking experience, our family has perfected the art of cultivating grapes and producing world-class wines.",
|
||||
},
|
||||
{
|
||||
icon: "Leaf",
|
||||
title: "Sustainable Practices",
|
||||
description: "We are committed to sustainable farming and winemaking practices to ensure the health of our land for generations to come.",
|
||||
},
|
||||
{
|
||||
icon: "Award",
|
||||
title: "Award-Winning Wines",
|
||||
description: "Our dedication to quality has been recognized with numerous awards, a testament to our unwavering commitment to excellence.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionErrorBoundary>
|
||||
</div>
|
||||
@@ -117,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."
|
||||
products={[
|
||||
{
|
||||
id: 1,
|
||||
brand: "Vine & Vintages",
|
||||
name: "Estate Cabernet Sauvignon",
|
||||
price: "$55.00",
|
||||
@@ -125,6 +146,7 @@ export default function HomePage() {
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/liquor-round-bottle_176474-6072.jpg",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
brand: "Vine & Vintages",
|
||||
name: "Reserve Chardonnay",
|
||||
price: "$48.00",
|
||||
@@ -133,6 +155,7 @@ export default function HomePage() {
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/set-wine-bottle-with-glass-carafe_23-2148261706.jpg",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
brand: "Vine & Vintages",
|
||||
name: "Summer Rosé",
|
||||
price: "$32.00",
|
||||
@@ -141,6 +164,7 @@ export default function HomePage() {
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/still-life-stacked-aesthetic-objects_23-2150185382.jpg",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
brand: "Vine & Vintages",
|
||||
name: "Sparkling Brut",
|
||||
price: "$60.00",
|
||||
@@ -149,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",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
brand: "Vine & Vintages",
|
||||
name: "Late Harvest Dessert Wine",
|
||||
price: "$75.00",
|
||||
@@ -157,6 +182,7 @@ export default function HomePage() {
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/rollcake-with-berries-drink-platter_114579-16491.jpg",
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
brand: "Vine & Vintages",
|
||||
name: "Grand Cru Pinot Noir",
|
||||
price: "$90.00",
|
||||
|
||||
Reference in New Issue
Block a user