Removed redux, swapped to Zustand instead
This commit is contained in:
parent
0c45e9067d
commit
406890c2aa
Binary file not shown.
@ -14,6 +14,9 @@
|
||||
"@react-email/components": "^0.0.22",
|
||||
"@react-email/tailwind": "^0.0.19",
|
||||
"@reduxjs/toolkit": "^2.3.0",
|
||||
"@stripe/react-stripe-js": "^3.0.0",
|
||||
"@stripe/stripe-js": "^5.2.0",
|
||||
"@types/stripe": "^8.0.417",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^16.4.5",
|
||||
@ -34,7 +37,8 @@
|
||||
"sharp": "^0.33.5",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"usehooks-ts": "^3.1.0"
|
||||
"usehooks-ts": "^3.1.0",
|
||||
"zustand": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.16.1",
|
||||
|
||||
@ -1,18 +0,0 @@
|
||||
'use client'
|
||||
import { useRef } from 'react'
|
||||
import { Provider } from 'react-redux'
|
||||
import { makeStore, AppStore } from '../lib/Store'
|
||||
|
||||
export default function StoreProvider({
|
||||
children
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const storeRef = useRef<AppStore>()
|
||||
if (!storeRef.current) {
|
||||
// Create the store instance the first time this renders
|
||||
storeRef.current = makeStore()
|
||||
}
|
||||
|
||||
return <Provider store={storeRef.current}>{children}</Provider>
|
||||
}
|
||||
@ -3,8 +3,7 @@ import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import Navbar from "@/components/Navbar/Navbar";
|
||||
import Footer from "@/components/Footer/Footer";
|
||||
import StoreProvider from "./StoreProvider";
|
||||
|
||||
import { CartProvider } from '@/components/CartTab/CartProvider';
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
@ -19,14 +18,16 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<StoreProvider>
|
||||
<html lang="en">
|
||||
<body className={`${inter.className} bg-slate-700 w-full max-h-screen text-white`}>
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${inter.className} bg-slate-700 w-full max-h-screen text-white`}
|
||||
>
|
||||
<CartProvider>
|
||||
<Navbar />
|
||||
{children}
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
</StoreProvider>
|
||||
</CartProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,42 +0,0 @@
|
||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||
|
||||
export interface Product {
|
||||
id: number;
|
||||
title: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
desc: string;
|
||||
image: string;
|
||||
}
|
||||
|
||||
|
||||
const initialState = {
|
||||
products: [],
|
||||
}
|
||||
|
||||
export const cartSlice = createSlice({
|
||||
name: "cart",
|
||||
initialState,
|
||||
reducers: {
|
||||
addToCart: (state: any, action: PayloadAction<Product>) => {
|
||||
const item = state.products.find((p:Product) => p.id === action.payload.id);
|
||||
if(item) {
|
||||
item.quantity += action.payload.quantity;
|
||||
} else {
|
||||
state.products.push(action.payload);
|
||||
}
|
||||
},
|
||||
removeFromCart: (state, action) => {
|
||||
state.products = state.products.filter((p:Product) => p.id !== action.payload);
|
||||
},
|
||||
resetCart: (state) => {
|
||||
state.products = [];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { addToCart, removeFromCart, resetCart } = cartSlice.actions;
|
||||
|
||||
export default cartSlice.reducer;
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@ -1,37 +0,0 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||
import type { RootState } from '@/lib/Store'
|
||||
|
||||
// Define a type for the slice state
|
||||
export interface CounterState {
|
||||
value: number
|
||||
}
|
||||
|
||||
// Define the initial state using that type
|
||||
const initialState: CounterState = {
|
||||
value: 0
|
||||
}
|
||||
|
||||
export const counterSlice = createSlice({
|
||||
name: 'counter',
|
||||
// `createSlice` will infer the state type from the `initialState` argument
|
||||
initialState,
|
||||
reducers: {
|
||||
increment: state => {
|
||||
state.value += 1
|
||||
},
|
||||
decrement: state => {
|
||||
state.value -= 1
|
||||
},
|
||||
// Use the PayloadAction type to declare the contents of `action.payload`
|
||||
incrementByAmount: (state, action: PayloadAction<number>) => {
|
||||
state.value += action.payload
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const { increment, decrement, incrementByAmount } = counterSlice.actions
|
||||
|
||||
// Other code such as selectors can use the imported `RootState` type
|
||||
export const selectCount = (state: RootState) => state.counter.value
|
||||
|
||||
export default counterSlice.reducer
|
||||
21
frontend/src/app/routes/cart/page.tsx
Normal file
21
frontend/src/app/routes/cart/page.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
// src/app/(routes)/cart/page.tsx
|
||||
'use client';
|
||||
|
||||
import { useCartStore } from '@/lib/store/cart-store';
|
||||
import StripeCheckout from '@/lib/stripe/checkout';
|
||||
|
||||
export default function CartPage() {
|
||||
const { items, getTotalPrice } = useCartStore();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Your Cart</h1>
|
||||
{items.map(item => (
|
||||
<div key={item.id}>
|
||||
{item.title} - ${item.price} x {item.quantity}
|
||||
</div>
|
||||
))}
|
||||
<StripeCheckout />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
0
frontend/src/app/routes/checkout/page.tsx
Normal file
0
frontend/src/app/routes/checkout/page.tsx
Normal file
26
frontend/src/components/CartTab/AddToCartButton.tsx
Normal file
26
frontend/src/components/CartTab/AddToCartButton.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useCartStore } from '@/lib/store/cart-store'
|
||||
import { Product } from '../../lib/types/product'
|
||||
|
||||
interface AddToCartButtonProps {
|
||||
product: Product
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function AddToCartButton({ product, className }: AddToCartButtonProps) {
|
||||
const addToCart = useCartStore((state) => state.addToCart)
|
||||
|
||||
const handleAddToCart = () => {
|
||||
addToCart(product)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleAddToCart}
|
||||
className={className}
|
||||
variant="default"
|
||||
>
|
||||
Add to Cart
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
16
frontend/src/components/CartTab/CartProvider.tsx
Normal file
16
frontend/src/components/CartTab/CartProvider.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
// src/components/cart/CartProvider.tsx
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useCartStore } from '@/lib/store/cart-store';
|
||||
|
||||
export function CartProvider({ children }: { children: React.ReactNode }) {
|
||||
const { items } = useCartStore();
|
||||
|
||||
// Optional: Hydration fix for persistent stores
|
||||
useEffect(() => {
|
||||
useCartStore.persist.rehydrate();
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
154
frontend/src/components/CartTab/CartSidebar.tsx
Normal file
154
frontend/src/components/CartTab/CartSidebar.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { CartItem } from '@/lib/store/cart-store';
|
||||
import Image from 'next/image';
|
||||
|
||||
interface CartSidebarProps {
|
||||
items: CartItem[];
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
updateQuantity: (id: string, quantity: number) => void;
|
||||
removeItem: (id: string) => void;
|
||||
}
|
||||
|
||||
const CartSidebar: React.FC<CartSidebarProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
items,
|
||||
updateQuantity,
|
||||
removeItem,
|
||||
}) => {
|
||||
// Count occurrences of each item ID
|
||||
const itemCounts = items.reduce((acc, item) => {
|
||||
acc[item.id] = (acc[item.id] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
// Create unique items array with correct total quantities
|
||||
const uniqueItems = Array.from(new Set(items.map(item => item.id)))
|
||||
.map(id => {
|
||||
const item = items.find(item => item.id === id)!;
|
||||
return {
|
||||
...item,
|
||||
quantity: item.quantity * itemCounts[item.id]
|
||||
};
|
||||
});
|
||||
|
||||
// Update total calculation to use uniqueItems
|
||||
const total = uniqueItems.reduce((sum, item) => sum + (item.price * item.quantity), 0);
|
||||
|
||||
// Add console.log to debug
|
||||
const handleRemoveItem = (itemId: string) => {
|
||||
console.log('Removing item:', itemId);
|
||||
removeItem(itemId);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Overlay */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 z-40"
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<motion.div
|
||||
className="fixed right-0 top-0 h-full w-80 bg-[#E8E4D9] shadow-lg z-50 text-black"
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: isOpen ? 0 : '100%' }}
|
||||
transition={{ type: 'tween', duration: 0.3 }}
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold">Your Cart</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-gray-100 rounded-full"
|
||||
>
|
||||
<span className="sr-only">Close cart</span>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Cart Items Container */}
|
||||
<div className="flex-1 overflow-y-auto max-h-[60vh]">
|
||||
{uniqueItems.length === 0 ? (
|
||||
<p className="text-gray-500 text-center">Your cart is empty</p>
|
||||
) : (
|
||||
uniqueItems.map((item) => (
|
||||
<div key={item.id} className="flex items-center py-4 border-b">
|
||||
<div className="w-20 h-20 flex-shrink-0 mr-4 relative bg-black/20">
|
||||
<Image
|
||||
src={`${process.env.NEXT_PUBLIC_UPLOAD_URL + item.image}`}
|
||||
alt={item.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="80px"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium">{item.title}</h3>
|
||||
<p className="text-gray-600">${item.price.toFixed(2)}</p>
|
||||
<div className="flex items-center mt-2">
|
||||
<button
|
||||
className="px-2 py-1 border rounded"
|
||||
onClick={() => {
|
||||
const newQuantity = Math.max(0, item.quantity - 1);
|
||||
updateQuantity(item.id, newQuantity);
|
||||
}}
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span className="mx-2">{item.quantity}</span>
|
||||
<button
|
||||
className="px-2 py-1 border rounded"
|
||||
onClick={() => {
|
||||
updateQuantity(item.id, item.quantity + 1);
|
||||
}}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
className="ml-4 text-red-500"
|
||||
onClick={() => handleRemoveItem(item.id)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cart Footer */}
|
||||
<div className="border-t mt-4 pt-4">
|
||||
<div className="flex justify-between mb-4">
|
||||
<span className="font-semibold">Total:</span>
|
||||
<span className="font-semibold">${total.toFixed(2)}</span>
|
||||
</div>
|
||||
<button
|
||||
className="w-full bg-[#F45B1E] text-white py-2 px-4 rounded-md hover:bg-gray-800"
|
||||
>
|
||||
Checkout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CartSidebar;
|
||||
@ -1,83 +1,83 @@
|
||||
"use client";
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
import { MdDeleteOutline } from "react-icons/md";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import "./CartTab.css";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import type { RootState } from "@/lib/Store";
|
||||
import { removeFromCart } from "@/app/redux/CartReducer";
|
||||
// "use client";
|
||||
// import React from "react";
|
||||
// import Image from "next/image";
|
||||
// import { MdDeleteOutline } from "react-icons/md";
|
||||
// import { Button } from "@/components/ui/button";
|
||||
// import "./CartTab.css";
|
||||
// import { useSelector, useDispatch } from "react-redux";
|
||||
// import type { RootState } from "@/lib/Store";
|
||||
// import { removeFromCart } from "@/app/redux/CartReducer";
|
||||
|
||||
import type { Product } from "@/app/redux/CartReducer";
|
||||
// import type { Product } from "@/app/redux/CartReducer";
|
||||
|
||||
const CartTab = () => {
|
||||
const cartItems = true;
|
||||
const dispatch = useDispatch();
|
||||
// const CartTab = () => {
|
||||
// const cartItems = true;
|
||||
// const dispatch = useDispatch();
|
||||
|
||||
// get the products from the cart
|
||||
const products = useSelector((state: RootState) => state.cart.products);
|
||||
// // get the products from the cart
|
||||
// const products = useSelector((state: RootState) => state.cart.products);
|
||||
|
||||
const totalPrice = () => {
|
||||
let total = 0;
|
||||
products?.forEach((item: Product) => {
|
||||
total += item.price * item.quantity;
|
||||
});
|
||||
return total.toFixed(2);
|
||||
};
|
||||
// const totalPrice = () => {
|
||||
// let total = 0;
|
||||
// products?.forEach((item: Product) => {
|
||||
// total += item.price * item.quantity;
|
||||
// });
|
||||
// return total.toFixed(2);
|
||||
// };
|
||||
|
||||
// const data = [
|
||||
// {
|
||||
// id: 1,
|
||||
// title: "Product 1",
|
||||
// oldPrice: 19,
|
||||
// price: 12,
|
||||
// isNew: true,
|
||||
// desc: "Short sleeve t-shirt",
|
||||
// image:
|
||||
// "https://ih1.redbubble.net/image.2997405810.0746/ssrco,slim_fit_t_shirt,womens,101010:01c5ca27c6,front,square_product,600x600.jpg",
|
||||
// },
|
||||
// {
|
||||
// id: 2,
|
||||
// title: "Product 2",
|
||||
// oldPrice: 19,
|
||||
// price: 12,
|
||||
// isNew: false,
|
||||
// desc: "Short sleeve t-shirt",
|
||||
// image:
|
||||
// "https://ih1.redbubble.net/image.2997405810.0746/ssrco,slim_fit_t_shirt,flatlay,101010:01c5ca27c6,front,wide_portrait,750x1000-bg,f8f8f8.jpg",
|
||||
// },
|
||||
// ];
|
||||
return (
|
||||
<div className="cart absolute p-5 text-slate-600 top-28 right-0 bg-slate-200">
|
||||
{products?.map((item: Product) => (
|
||||
<div>
|
||||
<div className="item flex gap-5 items-center mb-[30px] " key={item.id}>
|
||||
<div className="relative w-[80px] h-[100px]">
|
||||
<Image
|
||||
src={`${process.env.NEXT_PUBLIC_UPLOAD_URL + item.image}`}
|
||||
fill
|
||||
style={{ objectFit: "cover" }}
|
||||
className=""
|
||||
alt="item picture"
|
||||
/>
|
||||
</div>
|
||||
<div className="details">
|
||||
<h1>{item.title}</h1>
|
||||
{/* <p className="text-sm">{item.desc?.substring(0, 100)}</p> */}
|
||||
<div className="price">{item.quantity} x ${item.price}</div>
|
||||
</div>
|
||||
<MdDeleteOutline className="delete-icon mt-16" onClick={() => dispatch(removeFromCart(item.id))} />
|
||||
</div>
|
||||
<div className="total">
|
||||
<span>SUBTOTAL</span>
|
||||
<span>${totalPrice()}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button className="bg-[#F45B1E] text-white checkout rounded-none w-full">PROCEED TO CHECKOUT</Button>
|
||||
{/* <span className="reset">Reset Cart</span> */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
// // const data = [
|
||||
// // {
|
||||
// // id: 1,
|
||||
// // title: "Product 1",
|
||||
// // oldPrice: 19,
|
||||
// // price: 12,
|
||||
// // isNew: true,
|
||||
// // desc: "Short sleeve t-shirt",
|
||||
// // image:
|
||||
// // "https://ih1.redbubble.net/image.2997405810.0746/ssrco,slim_fit_t_shirt,womens,101010:01c5ca27c6,front,square_product,600x600.jpg",
|
||||
// // },
|
||||
// // {
|
||||
// // id: 2,
|
||||
// // title: "Product 2",
|
||||
// // oldPrice: 19,
|
||||
// // price: 12,
|
||||
// // isNew: false,
|
||||
// // desc: "Short sleeve t-shirt",
|
||||
// // image:
|
||||
// // "https://ih1.redbubble.net/image.2997405810.0746/ssrco,slim_fit_t_shirt,flatlay,101010:01c5ca27c6,front,wide_portrait,750x1000-bg,f8f8f8.jpg",
|
||||
// // },
|
||||
// // ];
|
||||
// return (
|
||||
// <div className="cart absolute p-5 text-slate-600 top-28 right-0 bg-slate-200">
|
||||
// {products?.map((item: Product) => (
|
||||
// <div key={item.id}>
|
||||
// <div className="item flex gap-5 items-center mb-[30px]">
|
||||
// <div className="relative w-[80px] h-[100px]">
|
||||
// <Image
|
||||
// src={`${process.env.NEXT_PUBLIC_UPLOAD_URL + item.image}`}
|
||||
// fill
|
||||
// style={{ objectFit: "cover" }}
|
||||
// className=""
|
||||
// alt="item picture"
|
||||
// />
|
||||
// </div>
|
||||
// <div className="details">
|
||||
// <h1>{item.title}</h1>
|
||||
// {/* <p className="text-sm">{item.desc?.substring(0, 100)}</p> */}
|
||||
// <div className="price">{item.quantity} x ${item.price}</div>
|
||||
// </div>
|
||||
// <MdDeleteOutline className="delete-icon mt-16" onClick={() => dispatch(removeFromCart(item.id))} />
|
||||
// </div>
|
||||
// <div className="total">
|
||||
// <span>SUBTOTAL</span>
|
||||
// <span>${totalPrice()}</span>
|
||||
// </div>
|
||||
// </div>
|
||||
// ))}
|
||||
// <Button className="bg-[#F45B1E] text-white checkout rounded-none w-full">PROCEED TO CHECKOUT</Button>
|
||||
// {/* <span className="reset">Reset Cart</span> */}
|
||||
// </div>
|
||||
// );
|
||||
// };
|
||||
|
||||
export default CartTab;
|
||||
// export default CartTab;
|
||||
|
||||
@ -4,11 +4,9 @@ import { useState, useEffect } from "react";
|
||||
import { useMediaQuery } from "@/util/useMediaQuery";
|
||||
import { FiShoppingCart } from "react-icons/fi";
|
||||
import Image from "next/image";
|
||||
import CartTab from "@/components/CartTab/CartTab";
|
||||
import { useSelector } from "react-redux";
|
||||
import type { RootState } from "@/lib/Store";
|
||||
import { useCartStore } from '@/lib/store/cart-store';
|
||||
import Products from "@/app/products/page";
|
||||
import type { Product } from "@/app/redux/CartReducer";
|
||||
import CartSidebar from '@/components/CartTab/CartSidebar';
|
||||
|
||||
const navMotion = {
|
||||
visible: {
|
||||
@ -35,7 +33,11 @@ export default function Nav() {
|
||||
// console.log(matches);
|
||||
|
||||
// get the products from the cart
|
||||
const products = useSelector((state: RootState) => state.cart.products);
|
||||
// const products = useSelector((state: RootState) => state.cart.products);
|
||||
|
||||
const totalItems = useCartStore((state) => state.items.length);
|
||||
const [isCartOpen, setIsCartOpen] = useState(false);
|
||||
const { items } = useCartStore();
|
||||
|
||||
return (
|
||||
<nav className="relative mx-8 mb-10 flex justify-between items-center pt-12 font-medium md:mx-16 lg:mx-32 text-white">
|
||||
@ -83,22 +85,25 @@ export default function Nav() {
|
||||
<a href="/">Home</a>
|
||||
<a href="/products">Shirts</a>
|
||||
<a href="/about">About</a>
|
||||
<div className="flex relative items-center justify-center" onClick={() => setOpen(!open)}>
|
||||
<div
|
||||
className="flex relative items-center justify-center"
|
||||
onClick={() => setIsCartOpen(!isCartOpen)}
|
||||
>
|
||||
<FiShoppingCart className="text-lg lg:text-xl" />
|
||||
<span className="absolute text-white text-xs w-5 h-5 rounded-[50%] bg-orange-600 -top-2.5 -right-2.5 flex items-center justify-center ">
|
||||
{products?.map((item: Product) => {
|
||||
if (item.quantity > 0 && item.quantity !== undefined) {
|
||||
return <div key={item.id}>{item.quantity}</div>;
|
||||
} else {
|
||||
return <div>0</div>;
|
||||
}
|
||||
})}
|
||||
<span className="absolute text-white text-xs w-5 h-5 rounded-[50%] bg-orange-600 -top-2.5 -right-2.5 flex items-center justify-center">
|
||||
{totalItems}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{open && <CartTab />}
|
||||
<CartSidebar
|
||||
items={items}
|
||||
isOpen={isCartOpen}
|
||||
onClose={() => setIsCartOpen(false)}
|
||||
updateQuantity={useCartStore.getState().updateQuantity}
|
||||
removeItem={useCartStore.getState().removeFromCart}
|
||||
/>
|
||||
|
||||
{!matches && (
|
||||
<div
|
||||
|
||||
@ -9,9 +9,8 @@ import { MdFavoriteBorder } from "react-icons/md";
|
||||
import { FaBalanceScale } from "react-icons/fa";
|
||||
import { FaRegStar } from "react-icons/fa";
|
||||
import { FaStar } from "react-icons/fa";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import { RootState } from "@/lib/Store";
|
||||
import { addToCart } from "@/app/redux/CartReducer";
|
||||
import { useCartStore } from '@/lib/store/cart-store';
|
||||
import { AddToCartButton } from "@/components/CartTab/AddToCartButton";
|
||||
|
||||
|
||||
|
||||
@ -33,8 +32,8 @@ const ProductPage = ({ productId }: any) => {
|
||||
const [salePrice, setSalePrice] = useState(0);
|
||||
const [category, setCategory] = useState('');
|
||||
|
||||
// Redux
|
||||
const dispatch = useDispatch()
|
||||
// Replace Redux dispatch with Zustand store
|
||||
const addToCart = useCartStore((state) => state.addToCart);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
@ -126,20 +125,16 @@ const ProductPage = ({ productId }: any) => {
|
||||
+
|
||||
</Button>
|
||||
</div>
|
||||
<Button className="add bg-[#F45B1E] font-bold"
|
||||
onClick={() => {
|
||||
dispatch(addToCart({
|
||||
id: productId,
|
||||
title: name,
|
||||
price: price,
|
||||
desc: desc,
|
||||
image: product,
|
||||
quantity: quantity
|
||||
}))
|
||||
<AddToCartButton
|
||||
product={{
|
||||
id: productId,
|
||||
title: name,
|
||||
price: price,
|
||||
desc: desc,
|
||||
image: product,
|
||||
quantity: quantity
|
||||
}}
|
||||
>
|
||||
<MdOutlineAddShoppingCart /> ADD TO CART
|
||||
</Button>
|
||||
/>
|
||||
<div className="link">
|
||||
{/* <div className="item text-emerald-400"><MdFavoriteBorder /> ADD TO WISHLIST</div> */}
|
||||
{/* if in wishlist, then display <FaStar /> else <FaRegStar /> */}
|
||||
|
||||
0
frontend/src/hooks/use-cart.tsx
Normal file
0
frontend/src/hooks/use-cart.tsx
Normal file
@ -1,28 +0,0 @@
|
||||
import { configureStore } from '@reduxjs/toolkit'
|
||||
import counterReducer from '@/app/redux/slice'
|
||||
import cartReducer from '@/app/redux/CartReducer'
|
||||
import { persistReducer, persistStore } from 'redux-persist'
|
||||
import storage from 'redux-persist/lib/storage'
|
||||
|
||||
const persistConfig = {
|
||||
key: 'cart',
|
||||
storage,
|
||||
}
|
||||
|
||||
const persistedReducer = persistReducer(persistConfig, cartReducer)
|
||||
|
||||
export const makeStore = () => {
|
||||
return configureStore({
|
||||
reducer: {
|
||||
cart: cartReducer,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const persistor = persistStore(makeStore());
|
||||
|
||||
// Infer the type of makeStore
|
||||
export type AppStore = ReturnType<typeof makeStore>
|
||||
// Infer the `RootState` and `AppDispatch` types from the store itself
|
||||
export type RootState = ReturnType<AppStore['getState']>
|
||||
export type AppDispatch = AppStore['dispatch']
|
||||
75
frontend/src/lib/store/cart-store.ts
Normal file
75
frontend/src/lib/store/cart-store.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export interface CartItem {
|
||||
id: string;
|
||||
title: string;
|
||||
price: number;
|
||||
desc: string;
|
||||
image: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
interface CartStore {
|
||||
items: CartItem[];
|
||||
updateQuantity: (id: string, quantity: number) => void;
|
||||
getTotalPrice: ({ items }: { items: CartItem[] }) => number;
|
||||
addToCart: (item: CartItem) => void;
|
||||
clearCart: () => void;
|
||||
removeFromCart: (itemId: string) => void;
|
||||
// Add other cart operations as needed
|
||||
}
|
||||
|
||||
export const useCartStore = create(
|
||||
persist<CartStore>(
|
||||
(set) => ({
|
||||
items: [],
|
||||
addToCart: (item) => set((state) => ({
|
||||
items: [...state.items, item]
|
||||
})),
|
||||
getTotalPrice: ({ items }) => {
|
||||
return items.reduce((total, item) => total + (item.price * item.quantity), 0);
|
||||
},
|
||||
clearCart: () => set({ items: [] }),
|
||||
removeFromCart: (itemId) => set((state) => ({
|
||||
items: state.items.filter(item => item.id !== itemId)
|
||||
})),
|
||||
updateQuantity: (id, quantity) =>
|
||||
set((state) => {
|
||||
if (quantity === 0) {
|
||||
// Remove all items with this id if quantity is 0
|
||||
return {
|
||||
items: state.items.filter(item => item.id !== id)
|
||||
};
|
||||
}
|
||||
|
||||
// Count how many of this item exist
|
||||
const itemCount = state.items.filter(item => item.id === id).length;
|
||||
|
||||
if (quantity > itemCount) {
|
||||
// Add new items
|
||||
const itemTemplate = state.items.find(item => item.id === id)!;
|
||||
const newItems = Array(quantity - itemCount)
|
||||
.fill(null)
|
||||
.map(() => ({ ...itemTemplate, quantity: 1 }));
|
||||
|
||||
return {
|
||||
items: [...state.items, ...newItems]
|
||||
};
|
||||
} else {
|
||||
// Remove excess items
|
||||
const itemsToKeep = state.items.filter(item => item.id !== id)
|
||||
.concat(state.items.filter(item => item.id === id)
|
||||
.slice(0, quantity));
|
||||
|
||||
return {
|
||||
items: itemsToKeep
|
||||
};
|
||||
}
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: 'cart-storage',
|
||||
}
|
||||
)
|
||||
);
|
||||
73
frontend/src/lib/stripe/checkout-form.tsx
Normal file
73
frontend/src/lib/stripe/checkout-form.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, FormEvent } from 'react';
|
||||
import {
|
||||
PaymentElement,
|
||||
useStripe,
|
||||
useElements
|
||||
} from '@stripe/react-stripe-js';
|
||||
import { StripePaymentElementOptions } from '@stripe/stripe-js';
|
||||
import { useCartStore } from '@/lib/store/cart-store';
|
||||
|
||||
export default function CheckoutForm() {
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
|
||||
const [isProcessing, setIsProcessing] = useState<boolean>(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const { clearCart } = useCartStore();
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!stripe || !elements) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
|
||||
try {
|
||||
const { error } = await stripe.confirmPayment({
|
||||
elements,
|
||||
redirect: 'if_required',
|
||||
confirmParams: {
|
||||
return_url: `${window.location.origin}/payment-confirmation`,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setMessage(error.message || 'An unexpected error occurred.');
|
||||
setIsProcessing(false);
|
||||
} else {
|
||||
clearCart();
|
||||
setIsProcessing(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Payment confirmation error:', err);
|
||||
setMessage('An unexpected error occurred.');
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const paymentElementOptions: StripePaymentElementOptions = {
|
||||
layout: 'tabs',
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<PaymentElement options={paymentElementOptions} />
|
||||
<button
|
||||
className="w-full py-2 bg-blue-500 text-white rounded"
|
||||
disabled={isProcessing || !stripe || !elements}
|
||||
type="submit"
|
||||
>
|
||||
{isProcessing ? 'Processing...' : 'Pay Now'}
|
||||
</button>
|
||||
{message && (
|
||||
<div className="text-red-500 mt-2">
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
38
frontend/src/lib/stripe/checkout.tsx
Normal file
38
frontend/src/lib/stripe/checkout.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
import { Elements } from '@stripe/react-stripe-js';
|
||||
import CheckoutForm from './checkout-form';
|
||||
import { useCartStore } from '@/lib/store/cart-store';
|
||||
|
||||
// Non-null assertion as we'll validate the key
|
||||
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);
|
||||
|
||||
export default function StripeCheckout() {
|
||||
const { items, getTotalPrice } = useCartStore();
|
||||
|
||||
// Validate Stripe key at runtime
|
||||
if (!process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) {
|
||||
return <div>Stripe configuration error</div>;
|
||||
}
|
||||
|
||||
const checkoutOptions = {
|
||||
mode: 'payment' as const,
|
||||
amount: Math.round(getTotalPrice({ items }) * 100), // Convert to cents
|
||||
currency: 'usd',
|
||||
};
|
||||
|
||||
return (
|
||||
<Elements
|
||||
stripe={stripePromise}
|
||||
options={{
|
||||
mode: 'payment',
|
||||
amount: checkoutOptions.amount,
|
||||
currency: checkoutOptions.currency
|
||||
}}
|
||||
>
|
||||
<CheckoutForm />
|
||||
</Elements>
|
||||
);
|
||||
}
|
||||
0
frontend/src/lib/stripe/types.ts
Normal file
0
frontend/src/lib/stripe/types.ts
Normal file
8
frontend/src/lib/types/product.ts
Normal file
8
frontend/src/lib/types/product.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export interface Product {
|
||||
id: string;
|
||||
title: string;
|
||||
price: number;
|
||||
desc: string;
|
||||
image: string;
|
||||
quantity: number;
|
||||
}
|
||||
32
frontend/src/store/cartStore.ts
Normal file
32
frontend/src/store/cartStore.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
interface Product {
|
||||
id: number;
|
||||
name: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
// add other product properties as needed
|
||||
}
|
||||
|
||||
interface CartStore {
|
||||
products: Product[];
|
||||
addToCart: (product: Product) => void;
|
||||
removeFromCart: (productId: number) => void;
|
||||
getTotalItems: () => number;
|
||||
}
|
||||
|
||||
export const useCartStore = create<CartStore>((set, get) => ({
|
||||
products: [],
|
||||
addToCart: (product) =>
|
||||
set((state) => ({
|
||||
products: [...state.products, product],
|
||||
})),
|
||||
removeFromCart: (productId) =>
|
||||
set((state) => ({
|
||||
products: state.products.filter((p) => p.id !== productId),
|
||||
})),
|
||||
getTotalItems: () => {
|
||||
const { products } = get();
|
||||
return products.reduce((total, item) => total + item.quantity, 0);
|
||||
},
|
||||
}));
|
||||
18
frontend/src/types/product.ts
Normal file
18
frontend/src/types/product.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { Stripe } from '@stripe/stripe-js';
|
||||
|
||||
export interface StripeCheckoutOptions {
|
||||
mode: 'payment' | 'subscription';
|
||||
amount: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface PaymentIntentResponse {
|
||||
clientSecret: string;
|
||||
status: 'requires_payment_method' | 'requires_confirmation' | 'requires_action' | 'succeeded' | 'canceled';
|
||||
}
|
||||
|
||||
export interface CreatePaymentIntentParams {
|
||||
amount: number;
|
||||
currency: string;
|
||||
payment_method_types?: string[];
|
||||
}
|
||||
@ -18,7 +18,10 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": ["./src/*"],
|
||||
"@/components/*": ["src/components/*"],
|
||||
"@/types/*": ["src/types/*"],
|
||||
"@/hooks/*": ["src/hooks/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "src/app/StoreProvider.ts", "src/app/redux/store.ts"],
|
||||
|
||||
7
lib/store/cart-store.ts
Normal file
7
lib/store/cart-store.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export type CartItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
imageUrl: string;
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user