From 14d15848db75b9109fb64263a118a0ec7ba49143 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robin=20Nederg=C3=A5rd?= Date: Mon, 1 Dec 2025 08:44:32 +0100 Subject: [PATCH 1/8] fix: refactoring app tsx --- src/App.tsx | 507 ++----------------------------- src/components/cart/CartLine.tsx | 77 +++++ src/components/layout/Layout.tsx | 139 +++++++++ src/hooks/useCart.ts | 131 ++++++++ src/pages/HomePage.tsx | 137 +++++++++ src/types/index.ts | 24 ++ src/utils/constants.ts | 13 + 7 files changed, 542 insertions(+), 486 deletions(-) create mode 100644 src/components/cart/CartLine.tsx create mode 100644 src/components/layout/Layout.tsx create mode 100644 src/hooks/useCart.ts create mode 100644 src/pages/HomePage.tsx create mode 100644 src/types/index.ts create mode 100644 src/utils/constants.ts diff --git a/src/App.tsx b/src/App.tsx index cad0098..5722f2f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,6 @@ -import { useEffect, useMemo, useRef, useState } from 'react' -import { Link, Route, Routes } from 'react-router-dom' +import { useMemo } from 'react' +import { Route, Routes } from 'react-router-dom' import { useQuery } from '@apollo/client/react' -import { type Product } from './data/products' import ProductPage from './ProductPage' import ProductsPage from './ProductsPage' import CheckoutPage from './CheckoutPage' @@ -10,495 +9,31 @@ import LoginPage from './LoginPage' import RegisterPage from './RegisterPage' import UserOrdersPage from './UserOrdersPage' import { ProtectedRoute } from './components/ProtectedRoute' -import { AuthProvider, useAuth } from './contexts/AuthContext' +import { Layout } from './components/layout/Layout' +import { HomePage } from './pages/HomePage' +import { AuthProvider } from './contexts/AuthContext' import { GET_PRODUCTS } from './graphql/queries' - -const currency = new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', -}) - -const perks = [ - { title: 'Express shipping', detail: 'Complimentary on orders over $150' }, - { title: '30-day trial', detail: 'Live with every piece before you decide' }, - { title: 'Design support', detail: 'Chat with stylists for pairing advice' }, -] - -const freeShippingThreshold = 150 -const flatShippingRate = 15 - -type ProductsQueryResult = { - products: Product[] -} - -type CartLineItem = { - product: Product - quantity: number -} - -function CartLine({ - item, - onIncrement, - onDecrement, - onRemove, -}: { - item: CartLineItem - onIncrement: () => void - onDecrement: () => void - onRemove: () => void -}) { - return ( -
  • -
    -

    {item.product.name}

    - {currency.format(item.product.price)} -
    -
    -
    -
    - - - {item.quantity} - - -
    - -
    - {currency.format(item.product.price * item.quantity)} -
    -
  • - ) -} - -type LayoutProps = { - children: React.ReactNode - cartItems: CartLineItem[] - cartCount: number - subtotal: number - shipping: number - total: number - freeShippingMessage: string - isCartOpen: boolean - toggleCart: () => void - updateQuantity: (productId: string, updater: (current: number) => number) => void -} - -function Layout({ - children, - cartItems, - cartCount, - subtotal, - shipping, - total, - freeShippingMessage, - isCartOpen, - toggleCart, - updateQuantity, -}: LayoutProps) { - const { isAuthenticated, user, logout } = useAuth() - - return ( -
    -
    -
    -

    Your bag

    -

    - {cartCount ? `${cartCount} item${cartCount > 1 ? 's' : ''}` : 'No items yet'} -

    -
    -
    - {isAuthenticated ? ( - <> - - {user?.email} - - - - ) : ( - { - if (isCartOpen) { - toggleCart() - } - }} - > - Login - - )} - -
    -
    - - {isCartOpen && ( -
    -
    -
    -

    Shopping bag

    -

    Ready to ship

    -
    - -
    -

    {freeShippingMessage}

    - {cartItems.length === 0 ? ( -

    - Your basket is empty – add your favorite finds. -

    - ) : ( -
      - {cartItems.map((item) => ( - updateQuantity(item.product.id, (qty) => qty + 1)} - onDecrement={() => updateQuantity(item.product.id, (qty) => qty - 1)} - onRemove={() => updateQuantity(item.product.id, () => 0)} - /> - ))} -
    - )} -
    -
    - Subtotal - {currency.format(subtotal)} -
    -
    - Shipping - {shipping === 0 ? 'Complimentary' : currency.format(shipping)} -
    -
    - Total - {currency.format(total)} -
    - - Proceed to checkout - -
    -
    - )} - - {children} -
    - ) -} - -function HomePage() { - const { loading, data } = useQuery(GET_PRODUCTS) - const products = data?.products || [] - - const categorySummaries = Object.entries( - products.reduce>((acc: Record, product: Product) => { - acc[product.category] = (acc[product.category] ?? 0) + 1 - return acc - }, {}), - ) - .map(([category, count]) => ({ category, count: count as number })) - .sort((a, b) => b.count - a.count) - - const heroProduct = products[0] - const editorialHighlight = products.find((product) => product.badge === 'Limited') ?? heroProduct - - if (loading || !heroProduct) { - return
    Loading...
    - } - - return ( - <> -
    -
    -

    New season edit

    -

    Meet the modern home shop

    -

    - Curated furniture, lighting, and objects crafted in small batches and ready to ship. -

    -
    - - Shop the collection - - -
    -
    - {currency.format(heroProduct.price)} - {heroProduct.name} -
    -
    -
    - {heroProduct.name} -
    -
    - -
    - {perks.map((perk) => ( -
    -

    {perk.title}

    -

    {perk.detail}

    -
    - ))} -
    - -
    -
    -

    Shop by room

    -

    Spaces with intention

    -

    - Refresh a single corner or rethink your whole home with designer-backed palettes. -

    -
    -
    - {categorySummaries.map((category) => ( -
    -

    {category.category}

    -

    {category.count} curated pieces

    -
    - ))} -
    -
    - -
    -
    - {editorialHighlight.name} -
    -
    -

    From the studio

    -

    Layered neutrals, elevated silhouettes

    -

    - We partner with small-batch workshops to produce timeless staples. Every stitch, weave, - and finishing touch is considered so you can style once and enjoy for years. -

    -
      -
    • Responsibly sourced materials and certified woods
    • -
    • Color stories developed with interior stylists
    • -
    • Transparent pricing and limited runs per season
    • -
    - -
    -
    - - ) -} +import { useCart } from './hooks/useCart' +import type { ProductsQueryResult } from './types' function App() { - const [cart, setCart] = useState>({}) - const [isCartOpen, setIsCartOpen] = useState(false) - const [highlightedProduct, setHighlightedProduct] = useState<{ - id: string - token: number - } | null>(null) - const highlightTimeoutRef = useRef(null) - const highlightSequenceRef = useRef(0) - const { data: productsData } = useQuery(GET_PRODUCTS) const products = useMemo(() => productsData?.products || [], [productsData?.products]) - const productDictionary = useMemo( - () => - products.reduce>((acc: Record, product: Product) => { - acc[product.id] = product - return acc - }, {}), - [products], - ) - - const cartItems = useMemo(() => { - return Object.entries(cart) - .map(([productId, quantity]) => { - const product = productDictionary[productId] - if (!product) return null - return { product, quantity } - }) - .filter(Boolean) as CartLineItem[] - }, [cart, productDictionary]) - - const cartCount = useMemo( - () => cartItems.reduce((total, item) => total + item.quantity, 0), - [cartItems], - ) - const subtotal = useMemo( - () => cartItems.reduce((total, item) => total + item.product.price * item.quantity, 0), - [cartItems], - ) - const shipping = subtotal === 0 || subtotal >= freeShippingThreshold ? 0 : flatShippingRate - const total = subtotal + shipping - const freeShippingMessage = - subtotal === 0 - ? 'Start building your bag to unlock complimentary delivery.' - : shipping === 0 - ? 'Shipping is on us today.' - : `Add ${currency.format(freeShippingThreshold - subtotal)} more for free express delivery.` - - const updateQuantity = (productId: string, updater: (current: number) => number) => { - setCart((current) => { - const nextQuantity = updater(current[productId] ?? 0) - if (nextQuantity <= 0) { - const rest = { ...current } - delete rest[productId] - return rest - } - return { ...current, [productId]: nextQuantity } - }) - } - - const addToCart = (productId: string) => { - updateQuantity(productId, (current) => current + 1) - - // Clear any existing timeout - if (highlightTimeoutRef.current) { - clearTimeout(highlightTimeoutRef.current) - highlightTimeoutRef.current = null - } - - // If this product is already highlighted, briefly remove the highlight to restart the animation - if (highlightedProduct?.id === productId) { - setHighlightedProduct(null) - // Use setTimeout with 0ms to ensure the state update is processed before re-adding - setTimeout(() => { - const token = ++highlightSequenceRef.current - setHighlightedProduct({ id: productId, token }) - highlightTimeoutRef.current = window.setTimeout(() => { - setHighlightedProduct((currentHighlight) => - currentHighlight?.token === token ? null : currentHighlight, - ) - }, 1200) - }, 0) - } else { - // First time highlighting this product - const token = ++highlightSequenceRef.current - setHighlightedProduct({ id: productId, token }) - highlightTimeoutRef.current = window.setTimeout(() => { - setHighlightedProduct((currentHighlight) => - currentHighlight?.token === token ? null : currentHighlight, - ) - }, 1200) - } - } - - useEffect(() => { - return () => { - if (highlightTimeoutRef.current) { - clearTimeout(highlightTimeoutRef.current) - } - } - }, []) - - const toggleCart = () => setIsCartOpen((open) => !open) - - const clearCart = () => { - setCart({}) - } - - const isHighlighted = (productId: string) => highlightedProduct?.id === productId + const { + cartItems, + cartCount, + subtotal, + shipping, + total, + freeShippingMessage, + isCartOpen, + toggleCart, + updateQuantity, + addToCart, + clearCart, + isHighlighted, + } = useCart(products) return ( diff --git a/src/components/cart/CartLine.tsx b/src/components/cart/CartLine.tsx new file mode 100644 index 0000000..73a5a06 --- /dev/null +++ b/src/components/cart/CartLine.tsx @@ -0,0 +1,77 @@ +import { currency } from '../../utils/constants' +import type { CartLineItem } from '../../types' + +type CartLineProps = { + item: CartLineItem + onIncrement: () => void + onDecrement: () => void + onRemove: () => void +} + +export function CartLine({ item, onIncrement, onDecrement, onRemove }: CartLineProps) { + return ( +
  • +
    +

    {item.product.name}

    + {currency.format(item.product.price)} +
    +
    +
    +
    + + + {item.quantity} + + +
    + +
    + {currency.format(item.product.price * item.quantity)} +
    +
  • + ) +} + diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx new file mode 100644 index 0000000..fe85f7c --- /dev/null +++ b/src/components/layout/Layout.tsx @@ -0,0 +1,139 @@ +import { Link } from 'react-router-dom' +import { useAuth } from '../../contexts/AuthContext' +import { CartLine } from '../cart/CartLine' +import { currency } from '../../utils/constants' +import type { LayoutProps } from '../../types' + +export function Layout({ + children, + cartItems, + cartCount, + subtotal, + shipping, + total, + freeShippingMessage, + isCartOpen, + toggleCart, + updateQuantity, +}: LayoutProps) { + const { isAuthenticated, user, logout } = useAuth() + + return ( +
    +
    +
    +

    Your bag

    +

    + {cartCount ? `${cartCount} item${cartCount > 1 ? 's' : ''}` : 'No items yet'} +

    +
    +
    + {isAuthenticated ? ( + <> + + {user?.email} + + + + ) : ( + { + if (isCartOpen) { + toggleCart() + } + }} + > + Login + + )} + +
    +
    + + {isCartOpen && ( +
    +
    +
    +

    Shopping bag

    +

    Ready to ship

    +
    + +
    +

    {freeShippingMessage}

    + {cartItems.length === 0 ? ( +

    + Your basket is empty – add your favorite finds. +

    + ) : ( +
      + {cartItems.map((item) => ( + updateQuantity(item.product.id, (qty) => qty + 1)} + onDecrement={() => updateQuantity(item.product.id, (qty) => qty - 1)} + onRemove={() => updateQuantity(item.product.id, () => 0)} + /> + ))} +
    + )} +
    +
    + Subtotal + {currency.format(subtotal)} +
    +
    + Shipping + {shipping === 0 ? 'Complimentary' : currency.format(shipping)} +
    +
    + Total + {currency.format(total)} +
    + + Proceed to checkout + +
    +
    + )} + + {children} +
    + ) +} + diff --git a/src/hooks/useCart.ts b/src/hooks/useCart.ts new file mode 100644 index 0000000..8dab74b --- /dev/null +++ b/src/hooks/useCart.ts @@ -0,0 +1,131 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import type { Product } from '../data/products' +import type { CartLineItem } from '../types' +import { currency, freeShippingThreshold, flatShippingRate } from '../utils/constants' + +export function useCart(products: Product[]) { + const [cart, setCart] = useState>({}) + const [isCartOpen, setIsCartOpen] = useState(false) + const [highlightedProduct, setHighlightedProduct] = useState<{ + id: string + token: number + } | null>(null) + const highlightTimeoutRef = useRef(null) + const highlightSequenceRef = useRef(0) + + const productDictionary = useMemo( + () => + products.reduce>((acc, product) => { + acc[product.id] = product + return acc + }, {}), + [products], + ) + + const cartItems = useMemo(() => { + return Object.entries(cart) + .map(([productId, quantity]) => { + const product = productDictionary[productId] + if (!product) return null + return { product, quantity } + }) + .filter(Boolean) as CartLineItem[] + }, [cart, productDictionary]) + + const cartCount = useMemo( + () => cartItems.reduce((total, item) => total + item.quantity, 0), + [cartItems], + ) + + const subtotal = useMemo( + () => cartItems.reduce((total, item) => total + item.product.price * item.quantity, 0), + [cartItems], + ) + + const shipping = subtotal === 0 || subtotal >= freeShippingThreshold ? 0 : flatShippingRate + const total = subtotal + shipping + + const freeShippingMessage = + subtotal === 0 + ? 'Start building your bag to unlock complimentary delivery.' + : shipping === 0 + ? 'Shipping is on us today.' + : `Add ${currency.format(freeShippingThreshold - subtotal)} more for free express delivery.` + + const updateQuantity = (productId: string, updater: (current: number) => number) => { + setCart((current) => { + const nextQuantity = updater(current[productId] ?? 0) + if (nextQuantity <= 0) { + const rest = { ...current } + delete rest[productId] + return rest + } + return { ...current, [productId]: nextQuantity } + }) + } + + const addToCart = (productId: string) => { + updateQuantity(productId, (current) => current + 1) + + // Clear any existing timeout + if (highlightTimeoutRef.current) { + clearTimeout(highlightTimeoutRef.current) + highlightTimeoutRef.current = null + } + + // If this product is already highlighted, briefly remove the highlight to restart the animation + if (highlightedProduct?.id === productId) { + setHighlightedProduct(null) + // Use setTimeout with 0ms to ensure the state update is processed before re-adding + setTimeout(() => { + const token = ++highlightSequenceRef.current + setHighlightedProduct({ id: productId, token }) + highlightTimeoutRef.current = window.setTimeout(() => { + setHighlightedProduct((currentHighlight) => + currentHighlight?.token === token ? null : currentHighlight, + ) + }, 1200) + }, 0) + } else { + // First time highlighting this product + const token = ++highlightSequenceRef.current + setHighlightedProduct({ id: productId, token }) + highlightTimeoutRef.current = window.setTimeout(() => { + setHighlightedProduct((currentHighlight) => + currentHighlight?.token === token ? null : currentHighlight, + ) + }, 1200) + } + } + + const toggleCart = () => setIsCartOpen((open) => !open) + + const clearCart = () => { + setCart({}) + } + + const isHighlighted = (productId: string) => highlightedProduct?.id === productId + + useEffect(() => { + return () => { + if (highlightTimeoutRef.current) { + clearTimeout(highlightTimeoutRef.current) + } + } + }, []) + + return { + cartItems, + cartCount, + subtotal, + shipping, + total, + freeShippingMessage, + isCartOpen, + toggleCart, + updateQuantity, + addToCart, + clearCart, + isHighlighted, + } +} diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx new file mode 100644 index 0000000..4c97396 --- /dev/null +++ b/src/pages/HomePage.tsx @@ -0,0 +1,137 @@ +import { Link } from 'react-router-dom' +import { useQuery } from '@apollo/client/react' +import { GET_PRODUCTS } from '../graphql/queries' +import { currency, perks } from '../utils/constants' +import type { Product } from '../data/products' +import type { ProductsQueryResult } from '../types' + +export function HomePage() { + const { loading, data } = useQuery(GET_PRODUCTS) + const products = data?.products || [] + + const categorySummaries = Object.entries( + products.reduce>((acc, product: Product) => { + acc[product.category] = (acc[product.category] ?? 0) + 1 + return acc + }, {}), + ) + .map(([category, count]) => ({ category, count: count as number })) + .sort((a, b) => b.count - a.count) + + const heroProduct = products[0] + const editorialHighlight = products.find((product) => product.badge === 'Limited') ?? heroProduct + + if (loading || !heroProduct) { + return
    Loading...
    + } + + return ( + <> +
    +
    +

    New season edit

    +

    Meet the modern home shop

    +

    + Curated furniture, lighting, and objects crafted in small batches and ready to ship. +

    +
    + + Shop the collection + + +
    +
    + {currency.format(heroProduct.price)} + {heroProduct.name} +
    +
    +
    + {heroProduct.name} +
    +
    + +
    + {perks.map((perk) => ( +
    +

    {perk.title}

    +

    {perk.detail}

    +
    + ))} +
    + +
    +
    +

    Shop by room

    +

    Spaces with intention

    +

    + Refresh a single corner or rethink your whole home with designer-backed palettes. +

    +
    +
    + {categorySummaries.map((category) => ( +
    +

    {category.category}

    +

    {category.count} curated pieces

    +
    + ))} +
    +
    + +
    +
    + {editorialHighlight.name} +
    +
    +

    From the studio

    +

    Layered neutrals, elevated silhouettes

    +

    + We partner with small-batch workshops to produce timeless staples. Every stitch, weave, + and finishing touch is considered so you can style once and enjoy for years. +

    +
      +
    • Responsibly sourced materials and certified woods
    • +
    • Color stories developed with interior stylists
    • +
    • Transparent pricing and limited runs per season
    • +
    + +
    +
    + + ) +} + diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..cbc6545 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,24 @@ +import type { Product } from '../data/products' + +export type ProductsQueryResult = { + products: Product[] +} + +export type CartLineItem = { + product: Product + quantity: number +} + +export type LayoutProps = { + children: React.ReactNode + cartItems: CartLineItem[] + cartCount: number + subtotal: number + shipping: number + total: number + freeShippingMessage: string + isCartOpen: boolean + toggleCart: () => void + updateQuantity: (productId: string, updater: (current: number) => number) => void +} + diff --git a/src/utils/constants.ts b/src/utils/constants.ts new file mode 100644 index 0000000..b9c98cb --- /dev/null +++ b/src/utils/constants.ts @@ -0,0 +1,13 @@ +export const currency = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', +}) + +export const perks = [ + { title: 'Express shipping', detail: 'Complimentary on orders over $150' }, + { title: '30-day trial', detail: 'Live with every piece before you decide' }, + { title: 'Design support', detail: 'Chat with stylists for pairing advice' }, +] + +export const freeShippingThreshold = 150 +export const flatShippingRate = 15 From 7baf902974d9271f842b29e2fbd5947f2c0bf25b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robin=20Nederg=C3=A5rd?= Date: Mon, 1 Dec 2025 09:00:27 +0100 Subject: [PATCH 2/8] fix: card context --- src/App.tsx | 142 +++--------------- src/CheckoutPage.tsx | 17 +-- src/CheckoutSuccessPage.tsx | 20 +-- src/ProductPage.tsx | 11 +- src/ProductsPage.tsx | 9 +- src/components/layout/Layout.tsx | 30 ++-- .../useCart.ts => contexts/CartContext.tsx} | 40 ++++- src/types/index.ts | 12 -- 8 files changed, 95 insertions(+), 186 deletions(-) rename src/{hooks/useCart.ts => contexts/CartContext.tsx} (79%) diff --git a/src/App.tsx b/src/App.tsx index 5722f2f..c45298c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,45 +12,17 @@ import { ProtectedRoute } from './components/ProtectedRoute' import { Layout } from './components/layout/Layout' import { HomePage } from './pages/HomePage' import { AuthProvider } from './contexts/AuthContext' +import { CartProvider } from './contexts/CartContext' import { GET_PRODUCTS } from './graphql/queries' -import { useCart } from './hooks/useCart' import type { ProductsQueryResult } from './types' -function App() { - const { data: productsData } = useQuery(GET_PRODUCTS) - const products = useMemo(() => productsData?.products || [], [productsData?.products]) - - const { - cartItems, - cartCount, - subtotal, - shipping, - total, - freeShippingMessage, - isCartOpen, - toggleCart, - updateQuantity, - addToCart, - clearCart, - isHighlighted, - } = useCart(products) - +function AppRoutes() { return ( + } @@ -58,94 +30,39 @@ function App() { - + + } /> - + + } /> - + + } /> - + + } /> + } @@ -153,17 +70,7 @@ function App() { + } @@ -171,17 +78,7 @@ function App() { + @@ -192,10 +89,19 @@ function App() { ) } +function App() { + return +} + function AppWithAuth() { + const { data: productsData } = useQuery(GET_PRODUCTS) + const products = useMemo(() => productsData?.products || [], [productsData?.products]) + return ( - + + + ) } diff --git a/src/CheckoutPage.tsx b/src/CheckoutPage.tsx index 0f630e1..a5c522f 100644 --- a/src/CheckoutPage.tsx +++ b/src/CheckoutPage.tsx @@ -2,25 +2,13 @@ import { useState } from 'react' import type { FormEvent } from 'react' import { useMutation } from '@apollo/client/react' import { CREATE_CHECKOUT } from './graphql/queries' -import type { Product } from './data/products' import { ErrorMessage } from './components/ErrorMessage' import { PageContainer } from './components/PageContainer' import { EmptyCartState } from './components/checkout/EmptyCartState' import { ShippingAddressForm } from './components/checkout/ShippingAddressForm' import { PaymentMethodSelect } from './components/checkout/PaymentMethodSelect' import { OrderSummary } from './components/checkout/OrderSummary' - -type CartLineItem = { - product: Product - quantity: number -} - -type CheckoutPageProps = { - cartItems: CartLineItem[] - subtotal: number - shipping: number - total: number -} +import { useCart } from './contexts/CartContext' type CreateCheckoutInput = { items: Array<{ @@ -53,7 +41,8 @@ type CreateCheckoutMutationResult = { } } -export default function CheckoutPage({ cartItems, subtotal, shipping, total }: CheckoutPageProps) { +export default function CheckoutPage() { + const { cartItems, subtotal, shipping, total } = useCart() const [createCheckout, { loading, error }] = useMutation(CREATE_CHECKOUT) const [formData, setFormData] = useState({ diff --git a/src/CheckoutSuccessPage.tsx b/src/CheckoutSuccessPage.tsx index aa63dc1..2403647 100644 --- a/src/CheckoutSuccessPage.tsx +++ b/src/CheckoutSuccessPage.tsx @@ -5,15 +5,8 @@ import { GET_CHECKOUT } from './graphql/queries' import { LoadingState } from './components/LoadingState' import { EmptyState } from './components/EmptyState' import { PageContainer } from './components/PageContainer' - -const currency = new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', -}) - -type CheckoutSuccessPageProps = { - onClearCart?: () => void -} +import { useCart } from './contexts/CartContext' +import { currency } from './utils/constants' type CheckoutQueryResult = { checkout: { @@ -39,7 +32,8 @@ type CheckoutQueryResult = { } | null } -export default function CheckoutSuccessPage({ onClearCart }: CheckoutSuccessPageProps) { +export default function CheckoutSuccessPage() { + const { clearCart } = useCart() const { id } = useParams<{ id: string }>() const { loading, data } = useQuery(GET_CHECKOUT, { variables: { id: id || '' }, @@ -48,10 +42,10 @@ export default function CheckoutSuccessPage({ onClearCart }: CheckoutSuccessPage useEffect(() => { // Clear cart when checkout is successful - if (data?.checkout && onClearCart) { - onClearCart() + if (data?.checkout) { + clearCart() } - }, [data?.checkout, onClearCart]) + }, [data?.checkout, clearCart]) // Scroll to top on mount useEffect(() => { diff --git a/src/ProductPage.tsx b/src/ProductPage.tsx index 5b5e2c6..2b25134 100644 --- a/src/ProductPage.tsx +++ b/src/ProductPage.tsx @@ -10,17 +10,14 @@ import { EmptyState } from './components/EmptyState' import { PageContainer } from './components/PageContainer' import { ProductImage } from './components/product/ProductImage' import { ProductDetails } from './components/product/ProductDetails' - -type ProductPageProps = { - onAddToCart: (productId: string) => void - isHighlighted: (productId: string) => boolean -} +import { useCart } from './contexts/CartContext' type ProductQueryResult = { product: Product | null } -export default function ProductPage({ onAddToCart, isHighlighted }: ProductPageProps) { +export default function ProductPage() { + const { addToCart, isHighlighted } = useCart() const { id } = useParams<{ id: string }>() const [selectedColor, setSelectedColor] = useState(null) @@ -93,7 +90,7 @@ export default function ProductPage({ onAddToCart, isHighlighted }: ProductPageP } const handleAddToCart = () => { - onAddToCart(product.id) + addToCart(product.id) } return ( diff --git a/src/ProductsPage.tsx b/src/ProductsPage.tsx index 798b1b2..7c11982 100644 --- a/src/ProductsPage.tsx +++ b/src/ProductsPage.tsx @@ -7,17 +7,14 @@ import type { Product } from './data/products' import { LoadingState } from './components/LoadingState' import { ErrorMessage } from './components/ErrorMessage' import { PageContainer } from './components/PageContainer' - -type ProductsPageProps = { - addToCart: (productId: string) => void - isHighlighted: (productId: string) => boolean -} +import { useCart } from './contexts/CartContext' type ProductsQueryResult = { products: Product[] } -export default function ProductsPage({ addToCart, isHighlighted }: ProductsPageProps) { +export default function ProductsPage() { + const { addToCart, isHighlighted } = useCart() const location = useLocation() const { loading, error, data } = useQuery(GET_PRODUCTS) diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index fe85f7c..a8dc735 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -1,22 +1,26 @@ import { Link } from 'react-router-dom' import { useAuth } from '../../contexts/AuthContext' +import { useCart } from '../../contexts/CartContext' import { CartLine } from '../cart/CartLine' import { currency } from '../../utils/constants' -import type { LayoutProps } from '../../types' -export function Layout({ - children, - cartItems, - cartCount, - subtotal, - shipping, - total, - freeShippingMessage, - isCartOpen, - toggleCart, - updateQuantity, -}: LayoutProps) { +type LayoutProps = { + children: React.ReactNode +} + +export function Layout({ children }: LayoutProps) { const { isAuthenticated, user, logout } = useAuth() + const { + cartItems, + cartCount, + subtotal, + shipping, + total, + freeShippingMessage, + isCartOpen, + toggleCart, + updateQuantity, + } = useCart() return (
    diff --git a/src/hooks/useCart.ts b/src/contexts/CartContext.tsx similarity index 79% rename from src/hooks/useCart.ts rename to src/contexts/CartContext.tsx index 8dab74b..c934849 100644 --- a/src/hooks/useCart.ts +++ b/src/contexts/CartContext.tsx @@ -1,9 +1,32 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import type { Product } from '../data/products' import type { CartLineItem } from '../types' import { currency, freeShippingThreshold, flatShippingRate } from '../utils/constants' -export function useCart(products: Product[]) { +interface CartContextType { + cartItems: CartLineItem[] + cartCount: number + subtotal: number + shipping: number + total: number + freeShippingMessage: string + isCartOpen: boolean + toggleCart: () => void + updateQuantity: (productId: string, updater: (current: number) => number) => void + addToCart: (productId: string) => void + clearCart: () => void + isHighlighted: (productId: string) => boolean +} + +const CartContext = createContext(undefined) + +export function CartProvider({ + children, + products, +}: { + children: ReactNode + products: Product[] +}) { const [cart, setCart] = useState>({}) const [isCartOpen, setIsCartOpen] = useState(false) const [highlightedProduct, setHighlightedProduct] = useState<{ @@ -114,7 +137,7 @@ export function useCart(products: Product[]) { } }, []) - return { + const value: CartContextType = { cartItems, cartCount, subtotal, @@ -128,4 +151,15 @@ export function useCart(products: Product[]) { clearCart, isHighlighted, } + + return {children} +} + +export function useCart() { + const context = useContext(CartContext) + if (context === undefined) { + throw new Error('useCart must be used within a CartProvider') + } + return context } + diff --git a/src/types/index.ts b/src/types/index.ts index cbc6545..cd58b68 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -9,16 +9,4 @@ export type CartLineItem = { quantity: number } -export type LayoutProps = { - children: React.ReactNode - cartItems: CartLineItem[] - cartCount: number - subtotal: number - shipping: number - total: number - freeShippingMessage: string - isCartOpen: boolean - toggleCart: () => void - updateQuantity: (productId: string, updater: (current: number) => number) => void -} From 36015da685e889f3535d1fe365f6b2a359fbd75f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robin=20Nederg=C3=A5rd?= Date: Mon, 1 Dec 2025 09:41:47 +0100 Subject: [PATCH 3/8] fix: refactoring base layout and back navigation --- src/App.tsx | 85 ++-------- src/CheckoutPage.tsx | 47 +++--- src/CheckoutSuccessPage.tsx | 95 ++++++------ src/LoginPage.tsx | 81 +++++----- src/ProductPage.tsx | 5 +- src/ProductsPage.tsx | 41 +++-- src/RegisterPage.tsx | 121 +++++++-------- src/UserOrdersPage.tsx | 17 +- src/components/PageContainer.tsx | 27 ---- src/components/layout/Layout.tsx | 145 ++---------------- .../layout/cart-sidebar/CartItemsList.tsx | 25 +++ .../layout/cart-sidebar/CartSidebar.tsx | 27 ++++ .../layout/cart-sidebar/CartSidebarHeader.tsx | 22 +++ .../layout/cart-sidebar/CartSummary.tsx | 25 +++ .../layout/cart-sidebar/CheckoutButton.tsx | 18 +++ .../layout/cart-sidebar/EmptyCartMessage.tsx | 8 + .../layout/cart-sidebar/SummaryRow.tsx | 18 +++ src/components/layout/header/AuthSection.tsx | 40 +++++ src/components/layout/header/CartInfo.tsx | 15 ++ .../layout/header/CartToggleButton.tsx | 24 +++ src/components/layout/header/Header.tsx | 16 ++ src/routes.tsx | 52 +++++++ 22 files changed, 506 insertions(+), 448 deletions(-) delete mode 100644 src/components/PageContainer.tsx create mode 100644 src/components/layout/cart-sidebar/CartItemsList.tsx create mode 100644 src/components/layout/cart-sidebar/CartSidebar.tsx create mode 100644 src/components/layout/cart-sidebar/CartSidebarHeader.tsx create mode 100644 src/components/layout/cart-sidebar/CartSummary.tsx create mode 100644 src/components/layout/cart-sidebar/CheckoutButton.tsx create mode 100644 src/components/layout/cart-sidebar/EmptyCartMessage.tsx create mode 100644 src/components/layout/cart-sidebar/SummaryRow.tsx create mode 100644 src/components/layout/header/AuthSection.tsx create mode 100644 src/components/layout/header/CartInfo.tsx create mode 100644 src/components/layout/header/CartToggleButton.tsx create mode 100644 src/components/layout/header/Header.tsx create mode 100644 src/routes.tsx diff --git a/src/App.tsx b/src/App.tsx index c45298c..a9f4c46 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,90 +1,23 @@ import { useMemo } from 'react' import { Route, Routes } from 'react-router-dom' import { useQuery } from '@apollo/client/react' -import ProductPage from './ProductPage' -import ProductsPage from './ProductsPage' -import CheckoutPage from './CheckoutPage' -import CheckoutSuccessPage from './CheckoutSuccessPage' -import LoginPage from './LoginPage' -import RegisterPage from './RegisterPage' -import UserOrdersPage from './UserOrdersPage' -import { ProtectedRoute } from './components/ProtectedRoute' -import { Layout } from './components/layout/Layout' -import { HomePage } from './pages/HomePage' import { AuthProvider } from './contexts/AuthContext' import { CartProvider } from './contexts/CartContext' +import { Layout } from './components/layout/Layout' import { GET_PRODUCTS } from './graphql/queries' +import { routes } from './routes' import type { ProductsQueryResult } from './types' function AppRoutes() { return ( - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - - - } - /> + {routes.map((route) => ( + {route.element}} + /> + ))} ) } diff --git a/src/CheckoutPage.tsx b/src/CheckoutPage.tsx index a5c522f..1c3fff1 100644 --- a/src/CheckoutPage.tsx +++ b/src/CheckoutPage.tsx @@ -3,7 +3,6 @@ import type { FormEvent } from 'react' import { useMutation } from '@apollo/client/react' import { CREATE_CHECKOUT } from './graphql/queries' import { ErrorMessage } from './components/ErrorMessage' -import { PageContainer } from './components/PageContainer' import { EmptyCartState } from './components/checkout/EmptyCartState' import { ShippingAddressForm } from './components/checkout/ShippingAddressForm' import { PaymentMethodSelect } from './components/checkout/PaymentMethodSelect' @@ -120,29 +119,27 @@ export default function CheckoutPage() { } return ( - -
    -
    -

    Checkout

    - -
    - - - - {error && } - - - -
    - - -
    -
    +
    +
    +

    Checkout

    + +
    + + + + {error && } + + + +
    + + +
    ) } diff --git a/src/CheckoutSuccessPage.tsx b/src/CheckoutSuccessPage.tsx index 2403647..a8ab5b6 100644 --- a/src/CheckoutSuccessPage.tsx +++ b/src/CheckoutSuccessPage.tsx @@ -4,7 +4,6 @@ import { useQuery } from '@apollo/client/react' import { GET_CHECKOUT } from './graphql/queries' import { LoadingState } from './components/LoadingState' import { EmptyState } from './components/EmptyState' -import { PageContainer } from './components/PageContainer' import { useCart } from './contexts/CartContext' import { currency } from './utils/constants' @@ -72,57 +71,55 @@ export default function CheckoutSuccessPage() { } return ( - -
    -
    - - - - +
    +
    + + + + +
    +

    Order Confirmed!

    +

    + Thank you for your order. We've received your payment and will begin processing your + shipment shortly. +

    +
    +
    + Order ID: + {checkout.id}
    -

    Order Confirmed!

    -

    - Thank you for your order. We've received your payment and will begin processing your - shipment shortly. -

    -
    -
    - Order ID: - {checkout.id} -
    -
    - Total: - {currency.format(checkout.total)} -
    -
    - Status: - {checkout.status} -
    +
    + Total: + {currency.format(checkout.total)}
    -
    - +
    + Status: + {checkout.status}
    - +
    + +
    +
    ) } diff --git a/src/LoginPage.tsx b/src/LoginPage.tsx index 275f647..511a24b 100644 --- a/src/LoginPage.tsx +++ b/src/LoginPage.tsx @@ -3,7 +3,6 @@ import { useNavigate, Link, useLocation } from 'react-router-dom' import { useAuth } from './contexts/AuthContext' import { FormField } from './components/FormField' import { ErrorMessage } from './components/ErrorMessage' -import { PageContainer } from './components/PageContainer' export default function LoginPage() { const navigate = useNavigate() @@ -32,51 +31,49 @@ export default function LoginPage() { } return ( - -
    -

    Login

    +
    +

    Login

    -
    - {error && } + + {error && } - setEmail(e.target.value)} - required - autoComplete="email" - /> + setEmail(e.target.value)} + required + autoComplete="email" + /> - setPassword(e.target.value)} - required - autoComplete="current-password" - /> + setPassword(e.target.value)} + required + autoComplete="current-password" + /> - + -

    - Don't have an account?{' '} - - Register here - -

    - -
    - +

    + Don't have an account?{' '} + + Register here + +

    + +
    ) } diff --git a/src/ProductPage.tsx b/src/ProductPage.tsx index 2b25134..91032c8 100644 --- a/src/ProductPage.tsx +++ b/src/ProductPage.tsx @@ -7,7 +7,6 @@ import { GET_PRODUCT, GET_REVIEWS } from './graphql/queries' import type { Product } from './data/products' import { LoadingState } from './components/LoadingState' import { EmptyState } from './components/EmptyState' -import { PageContainer } from './components/PageContainer' import { ProductImage } from './components/product/ProductImage' import { ProductDetails } from './components/product/ProductDetails' import { useCart } from './contexts/CartContext' @@ -94,7 +93,7 @@ export default function ProductPage() { } return ( - + <>
    - + ) } diff --git a/src/ProductsPage.tsx b/src/ProductsPage.tsx index 7c11982..002ed65 100644 --- a/src/ProductsPage.tsx +++ b/src/ProductsPage.tsx @@ -6,7 +6,6 @@ import { GET_PRODUCTS } from './graphql/queries' import type { Product } from './data/products' import { LoadingState } from './components/LoadingState' import { ErrorMessage } from './components/ErrorMessage' -import { PageContainer } from './components/PageContainer' import { useCart } from './contexts/CartContext' type ProductsQueryResult = { @@ -29,26 +28,24 @@ export default function ProductsPage() { const products = data?.products || [] return ( - -
    -
    -

    Featured pieces

    -

    Crafted to layer beautifully

    -

    - Mix tactile fabrics, natural woods, and sculptural silhouettes for your signature look. -

    -
    -
    - {products.map((product: Product) => ( - addToCart(product.id)} - isHighlighted={isHighlighted(product.id)} - /> - ))} -
    -
    -
    +
    +
    +

    Featured pieces

    +

    Crafted to layer beautifully

    +

    + Mix tactile fabrics, natural woods, and sculptural silhouettes for your signature look. +

    +
    +
    + {products.map((product: Product) => ( + addToCart(product.id)} + isHighlighted={isHighlighted(product.id)} + /> + ))} +
    +
    ) } diff --git a/src/RegisterPage.tsx b/src/RegisterPage.tsx index c69dd6f..0bb9b96 100644 --- a/src/RegisterPage.tsx +++ b/src/RegisterPage.tsx @@ -3,7 +3,6 @@ import { useNavigate, Link } from 'react-router-dom' import { useAuth } from './contexts/AuthContext' import { FormField } from './components/FormField' import { ErrorMessage } from './components/ErrorMessage' -import { PageContainer } from './components/PageContainer' export default function RegisterPage() { const navigate = useNavigate() @@ -37,77 +36,75 @@ export default function RegisterPage() { } return ( - -
    -

    Register

    +
    +

    Register

    -
    - {error && } - -
    - setFirstName(e.target.value)} - required - autoComplete="given-name" - /> - - setLastName(e.target.value)} - required - autoComplete="family-name" - /> -
    + + {error && } +
    setEmail(e.target.value)} + id="firstName" + name="firstName" + label="First Name" + type="text" + value={firstName} + onChange={(e) => setFirstName(e.target.value)} required - autoComplete="email" + autoComplete="given-name" /> setPassword(e.target.value)} + id="lastName" + name="lastName" + label="Last Name" + type="text" + value={lastName} + onChange={(e) => setLastName(e.target.value)} required - autoComplete="new-password" - minLength={6} - small="Must be at least 6 characters" + autoComplete="family-name" /> +
    + + setEmail(e.target.value)} + required + autoComplete="email" + /> + + setPassword(e.target.value)} + required + autoComplete="new-password" + minLength={6} + small="Must be at least 6 characters" + /> - + -

    - Already have an account?{' '} - - Login here - -

    - -
    - +

    + Already have an account?{' '} + + Login here + +

    + +
    ) } diff --git a/src/UserOrdersPage.tsx b/src/UserOrdersPage.tsx index a257fbe..18190b5 100644 --- a/src/UserOrdersPage.tsx +++ b/src/UserOrdersPage.tsx @@ -4,7 +4,6 @@ import { useAuth } from './contexts/AuthContext' import { MY_ORDERS } from './graphql/queries' import { LoadingState } from './components/LoadingState' import { EmptyState } from './components/EmptyState' -import { PageContainer } from './components/PageContainer' import { OrderCard } from './components/orders/OrderCard' type OrderItem = { @@ -87,16 +86,14 @@ export default function UserOrdersPage() { } return ( - -
    -

    My Orders

    +
    +

    My Orders

    -
    - {orders.map((order) => ( - - ))} -
    +
    + {orders.map((order) => ( + + ))}
    - +
    ) } diff --git a/src/components/PageContainer.tsx b/src/components/PageContainer.tsx deleted file mode 100644 index 77e678e..0000000 --- a/src/components/PageContainer.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { BackLink } from './BackLink' - -type PageContainerProps = { - children: React.ReactNode - backLink?: { - to: string - label: string - } - className?: string - innerClassName?: string -} - -export function PageContainer({ - children, - backLink, - className = '', - innerClassName = '', -}: PageContainerProps) { - return ( -
    -
    - {backLink && } - {children} -
    -
    - ) -} diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index a8dc735..8f71231 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -1,143 +1,24 @@ -import { Link } from 'react-router-dom' -import { useAuth } from '../../contexts/AuthContext' -import { useCart } from '../../contexts/CartContext' -import { CartLine } from '../cart/CartLine' -import { currency } from '../../utils/constants' +import { Header } from './header/Header' +import { CartSidebar } from './cart-sidebar/CartSidebar' +import { BackLink } from '../BackLink' type LayoutProps = { children: React.ReactNode + backLink?: { + to: string + label: string + } } -export function Layout({ children }: LayoutProps) { - const { isAuthenticated, user, logout } = useAuth() - const { - cartItems, - cartCount, - subtotal, - shipping, - total, - freeShippingMessage, - isCartOpen, - toggleCart, - updateQuantity, - } = useCart() - +export function Layout({ children, backLink }: LayoutProps) { return (
    -
    -
    -

    Your bag

    -

    - {cartCount ? `${cartCount} item${cartCount > 1 ? 's' : ''}` : 'No items yet'} -

    -
    -
    - {isAuthenticated ? ( - <> - - {user?.email} - - - - ) : ( - { - if (isCartOpen) { - toggleCart() - } - }} - > - Login - - )} - -
    +
    + +
    + {backLink && } + {children}
    - - {isCartOpen && ( -
    -
    -
    -

    Shopping bag

    -

    Ready to ship

    -
    - -
    -

    {freeShippingMessage}

    - {cartItems.length === 0 ? ( -

    - Your basket is empty – add your favorite finds. -

    - ) : ( -
      - {cartItems.map((item) => ( - updateQuantity(item.product.id, (qty) => qty + 1)} - onDecrement={() => updateQuantity(item.product.id, (qty) => qty - 1)} - onRemove={() => updateQuantity(item.product.id, () => 0)} - /> - ))} -
    - )} -
    -
    - Subtotal - {currency.format(subtotal)} -
    -
    - Shipping - {shipping === 0 ? 'Complimentary' : currency.format(shipping)} -
    -
    - Total - {currency.format(total)} -
    - - Proceed to checkout - -
    -
    - )} - - {children}
    ) } - diff --git a/src/components/layout/cart-sidebar/CartItemsList.tsx b/src/components/layout/cart-sidebar/CartItemsList.tsx new file mode 100644 index 0000000..b8a3714 --- /dev/null +++ b/src/components/layout/cart-sidebar/CartItemsList.tsx @@ -0,0 +1,25 @@ +import { useCart } from '../../../contexts/CartContext' +import { CartLine } from '../../cart/CartLine' + +export function CartItemsList() { + const { cartItems, updateQuantity } = useCart() + + if (cartItems.length === 0) { + return null + } + + return ( +
      + {cartItems.map((item) => ( + updateQuantity(item.product.id, (qty) => qty + 1)} + onDecrement={() => updateQuantity(item.product.id, (qty) => qty - 1)} + onRemove={() => updateQuantity(item.product.id, () => 0)} + /> + ))} +
    + ) +} + diff --git a/src/components/layout/cart-sidebar/CartSidebar.tsx b/src/components/layout/cart-sidebar/CartSidebar.tsx new file mode 100644 index 0000000..af463c1 --- /dev/null +++ b/src/components/layout/cart-sidebar/CartSidebar.tsx @@ -0,0 +1,27 @@ +import { useCart } from '../../../contexts/CartContext' +import { CartSummary } from './CartSummary' +import { CartSidebarHeader } from './CartSidebarHeader' +import { EmptyCartMessage } from './EmptyCartMessage' +import { CartItemsList } from './CartItemsList' + +export function CartSidebar() { + const { cartItems, freeShippingMessage, isCartOpen, toggleCart, subtotal, shipping, total } = + useCart() + + if (!isCartOpen) return null + + return ( +
    + +

    {freeShippingMessage}

    + {cartItems.length === 0 ? : } + {cartItems.length > 0 && ( + + )} +
    + ) +} + diff --git a/src/components/layout/cart-sidebar/CartSidebarHeader.tsx b/src/components/layout/cart-sidebar/CartSidebarHeader.tsx new file mode 100644 index 0000000..d04ee57 --- /dev/null +++ b/src/components/layout/cart-sidebar/CartSidebarHeader.tsx @@ -0,0 +1,22 @@ +type CartSidebarHeaderProps = { + onClose: () => void +} + +export function CartSidebarHeader({ onClose }: CartSidebarHeaderProps) { + return ( +
    +
    +

    Shopping bag

    +

    Ready to ship

    +
    + +
    + ) +} + diff --git a/src/components/layout/cart-sidebar/CartSummary.tsx b/src/components/layout/cart-sidebar/CartSummary.tsx new file mode 100644 index 0000000..3c1e292 --- /dev/null +++ b/src/components/layout/cart-sidebar/CartSummary.tsx @@ -0,0 +1,25 @@ +import { currency } from '../../../utils/constants' +import { SummaryRow } from './SummaryRow' +import { CheckoutButton } from './CheckoutButton' + +type CartSummaryProps = { + subtotal: number + shipping: number + total: number + onCheckout: () => void +} + +export function CartSummary({ subtotal, shipping, total, onCheckout }: CartSummaryProps) { + return ( +
    + + + + +
    + ) +} + diff --git a/src/components/layout/cart-sidebar/CheckoutButton.tsx b/src/components/layout/cart-sidebar/CheckoutButton.tsx new file mode 100644 index 0000000..062861c --- /dev/null +++ b/src/components/layout/cart-sidebar/CheckoutButton.tsx @@ -0,0 +1,18 @@ +import { Link } from 'react-router-dom' + +type CheckoutButtonProps = { + onClick: () => void +} + +export function CheckoutButton({ onClick }: CheckoutButtonProps) { + return ( + + Proceed to checkout + + ) +} + diff --git a/src/components/layout/cart-sidebar/EmptyCartMessage.tsx b/src/components/layout/cart-sidebar/EmptyCartMessage.tsx new file mode 100644 index 0000000..3685061 --- /dev/null +++ b/src/components/layout/cart-sidebar/EmptyCartMessage.tsx @@ -0,0 +1,8 @@ +export function EmptyCartMessage() { + return ( +

    + Your basket is empty – add your favorite finds. +

    + ) +} + diff --git a/src/components/layout/cart-sidebar/SummaryRow.tsx b/src/components/layout/cart-sidebar/SummaryRow.tsx new file mode 100644 index 0000000..2db9132 --- /dev/null +++ b/src/components/layout/cart-sidebar/SummaryRow.tsx @@ -0,0 +1,18 @@ +type SummaryRowProps = { + label: string + value: string | number + isTotal?: boolean +} + +export function SummaryRow({ label, value, isTotal = false }: SummaryRowProps) { + const className = isTotal + ? 'flex justify-between text-xl text-slate-900 font-semibold pt-3 border-t border-slate-200' + : 'flex justify-between text-slate-600' + + return ( +
    + {label} + {value} +
    + ) +} diff --git a/src/components/layout/header/AuthSection.tsx b/src/components/layout/header/AuthSection.tsx new file mode 100644 index 0000000..813d25a --- /dev/null +++ b/src/components/layout/header/AuthSection.tsx @@ -0,0 +1,40 @@ +import { Link } from 'react-router-dom' +import { useAuth } from '../../../contexts/AuthContext' +import { useCart } from '../../../contexts/CartContext' + +export function AuthSection() { + const { isAuthenticated, user, logout } = useAuth() + const { isCartOpen, toggleCart } = useCart() + + if (isAuthenticated) { + return ( + <> + + {user?.email} + + + + ) + } + + return ( + { + if (isCartOpen) { + toggleCart() + } + }} + > + Login + + ) +} + diff --git a/src/components/layout/header/CartInfo.tsx b/src/components/layout/header/CartInfo.tsx new file mode 100644 index 0000000..c085f9d --- /dev/null +++ b/src/components/layout/header/CartInfo.tsx @@ -0,0 +1,15 @@ +import { useCart } from '../../../contexts/CartContext' + +export function CartInfo() { + const { cartCount } = useCart() + + return ( +
    +

    Your bag

    +

    + {cartCount ? `${cartCount} item${cartCount > 1 ? 's' : ''}` : 'No items yet'} +

    +
    + ) +} + diff --git a/src/components/layout/header/CartToggleButton.tsx b/src/components/layout/header/CartToggleButton.tsx new file mode 100644 index 0000000..4362829 --- /dev/null +++ b/src/components/layout/header/CartToggleButton.tsx @@ -0,0 +1,24 @@ +import { useCart } from '../../../contexts/CartContext' + +export function CartToggleButton() { + const { cartCount, toggleCart } = useCart() + + return ( + + ) +} + diff --git a/src/components/layout/header/Header.tsx b/src/components/layout/header/Header.tsx new file mode 100644 index 0000000..36649fa --- /dev/null +++ b/src/components/layout/header/Header.tsx @@ -0,0 +1,16 @@ +import { CartInfo } from './CartInfo' +import { AuthSection } from './AuthSection' +import { CartToggleButton } from './CartToggleButton' + +export function Header() { + return ( +
    + +
    + + +
    +
    + ) +} + diff --git a/src/routes.tsx b/src/routes.tsx new file mode 100644 index 0000000..3c009c4 --- /dev/null +++ b/src/routes.tsx @@ -0,0 +1,52 @@ +import { ProtectedRoute } from './components/ProtectedRoute' +import { HomePage } from './pages/HomePage' +import ProductsPage from './ProductsPage' +import ProductPage from './ProductPage' +import CheckoutPage from './CheckoutPage' +import CheckoutSuccessPage from './CheckoutSuccessPage' +import LoginPage from './LoginPage' +import RegisterPage from './RegisterPage' +import UserOrdersPage from './UserOrdersPage' + +export const routes = [ + { + path: '/', + element: , + }, + { + path: '/products', + element: , + backLink: { to: '/', label: '← Back to home' }, + }, + { + path: '/product/:id', + element: , + backLink: { to: '/products', label: '← Back to products' }, + }, + { + path: '/checkout', + element: , + backLink: { to: '/products', label: '← Back to products' }, + }, + { + path: '/checkout/:id/success', + element: , + }, + { + path: '/login', + element: , + }, + { + path: '/register', + element: , + }, + { + path: '/account/orders', + element: ( + + + + ), + backLink: { to: '/products', label: '← Back to products' }, + }, +] From 14c5feb303ba7e2768ec6efc0574a449a3009aa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robin=20Nederg=C3=A5rd?= Date: Mon, 1 Dec 2025 10:12:31 +0100 Subject: [PATCH 4/8] fix: lint and separation for fast refresh --- src/App.tsx | 4 +- src/CheckoutPage.tsx | 2 +- src/CheckoutSuccessPage.tsx | 2 +- src/LoginPage.tsx | 2 +- src/ProductPage.tsx | 2 +- src/ProductsPage.tsx | 2 +- src/RegisterPage.tsx | 2 +- src/UserOrdersPage.tsx | 2 +- src/components/ProtectedRoute.tsx | 2 +- .../layout/cart-sidebar/CartItemsList.tsx | 2 +- .../layout/cart-sidebar/CartSidebar.tsx | 2 +- src/components/layout/header/AuthSection.tsx | 4 +- src/components/layout/header/CartInfo.tsx | 2 +- .../layout/header/CartToggleButton.tsx | 3 +- src/components/reviews/ReviewForm.tsx | 2 +- src/contexts/auth/AuthContext.context.ts | 4 ++ src/contexts/{ => auth}/AuthContext.tsx | 32 ++----------- src/contexts/auth/AuthContext.types.ts | 16 +++++++ src/contexts/cart/CartContext.context.ts | 5 +++ src/contexts/{ => cart}/CartContext.tsx | 45 ++++--------------- src/contexts/cart/CartContext.types.ts | 17 +++++++ src/hooks/useAuth.ts | 10 +++++ src/hooks/useCart.ts | 11 +++++ 23 files changed, 91 insertions(+), 84 deletions(-) create mode 100644 src/contexts/auth/AuthContext.context.ts rename src/contexts/{ => auth}/AuthContext.tsx (78%) create mode 100644 src/contexts/auth/AuthContext.types.ts create mode 100644 src/contexts/cart/CartContext.context.ts rename src/contexts/{ => cart}/CartContext.tsx (79%) create mode 100644 src/contexts/cart/CartContext.types.ts create mode 100644 src/hooks/useAuth.ts create mode 100644 src/hooks/useCart.ts diff --git a/src/App.tsx b/src/App.tsx index a9f4c46..1b43e9b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,8 +1,8 @@ import { useMemo } from 'react' import { Route, Routes } from 'react-router-dom' import { useQuery } from '@apollo/client/react' -import { AuthProvider } from './contexts/AuthContext' -import { CartProvider } from './contexts/CartContext' +import { AuthProvider } from './contexts/auth/AuthContext' +import { CartProvider } from './contexts/cart/CartContext' import { Layout } from './components/layout/Layout' import { GET_PRODUCTS } from './graphql/queries' import { routes } from './routes' diff --git a/src/CheckoutPage.tsx b/src/CheckoutPage.tsx index 1c3fff1..9ec46f2 100644 --- a/src/CheckoutPage.tsx +++ b/src/CheckoutPage.tsx @@ -7,7 +7,7 @@ import { EmptyCartState } from './components/checkout/EmptyCartState' import { ShippingAddressForm } from './components/checkout/ShippingAddressForm' import { PaymentMethodSelect } from './components/checkout/PaymentMethodSelect' import { OrderSummary } from './components/checkout/OrderSummary' -import { useCart } from './contexts/CartContext' +import { useCart } from './hooks/useCart' type CreateCheckoutInput = { items: Array<{ diff --git a/src/CheckoutSuccessPage.tsx b/src/CheckoutSuccessPage.tsx index a8ab5b6..139ed94 100644 --- a/src/CheckoutSuccessPage.tsx +++ b/src/CheckoutSuccessPage.tsx @@ -4,7 +4,7 @@ import { useQuery } from '@apollo/client/react' import { GET_CHECKOUT } from './graphql/queries' import { LoadingState } from './components/LoadingState' import { EmptyState } from './components/EmptyState' -import { useCart } from './contexts/CartContext' +import { useCart } from './hooks/useCart' import { currency } from './utils/constants' type CheckoutQueryResult = { diff --git a/src/LoginPage.tsx b/src/LoginPage.tsx index 511a24b..653d247 100644 --- a/src/LoginPage.tsx +++ b/src/LoginPage.tsx @@ -1,6 +1,6 @@ import { useState, type FormEvent } from 'react' import { useNavigate, Link, useLocation } from 'react-router-dom' -import { useAuth } from './contexts/AuthContext' +import { useAuth } from './hooks/useAuth' import { FormField } from './components/FormField' import { ErrorMessage } from './components/ErrorMessage' diff --git a/src/ProductPage.tsx b/src/ProductPage.tsx index 91032c8..eafab8d 100644 --- a/src/ProductPage.tsx +++ b/src/ProductPage.tsx @@ -9,7 +9,7 @@ import { LoadingState } from './components/LoadingState' import { EmptyState } from './components/EmptyState' import { ProductImage } from './components/product/ProductImage' import { ProductDetails } from './components/product/ProductDetails' -import { useCart } from './contexts/CartContext' +import { useCart } from './hooks/useCart' type ProductQueryResult = { product: Product | null diff --git a/src/ProductsPage.tsx b/src/ProductsPage.tsx index 002ed65..df60525 100644 --- a/src/ProductsPage.tsx +++ b/src/ProductsPage.tsx @@ -6,7 +6,7 @@ import { GET_PRODUCTS } from './graphql/queries' import type { Product } from './data/products' import { LoadingState } from './components/LoadingState' import { ErrorMessage } from './components/ErrorMessage' -import { useCart } from './contexts/CartContext' +import { useCart } from './hooks/useCart' type ProductsQueryResult = { products: Product[] diff --git a/src/RegisterPage.tsx b/src/RegisterPage.tsx index 0bb9b96..7395d78 100644 --- a/src/RegisterPage.tsx +++ b/src/RegisterPage.tsx @@ -1,6 +1,6 @@ import { useState, type FormEvent } from 'react' import { useNavigate, Link } from 'react-router-dom' -import { useAuth } from './contexts/AuthContext' +import { useAuth } from './hooks/useAuth' import { FormField } from './components/FormField' import { ErrorMessage } from './components/ErrorMessage' diff --git a/src/UserOrdersPage.tsx b/src/UserOrdersPage.tsx index 18190b5..8ae8c56 100644 --- a/src/UserOrdersPage.tsx +++ b/src/UserOrdersPage.tsx @@ -1,6 +1,6 @@ import { useEffect } from 'react' import { useQuery } from '@apollo/client/react' -import { useAuth } from './contexts/AuthContext' +import { useAuth } from './hooks/useAuth' import { MY_ORDERS } from './graphql/queries' import { LoadingState } from './components/LoadingState' import { EmptyState } from './components/EmptyState' diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx index 50f5038..5c5b794 100644 --- a/src/components/ProtectedRoute.tsx +++ b/src/components/ProtectedRoute.tsx @@ -1,5 +1,5 @@ import { Navigate, useLocation } from 'react-router-dom' -import { useAuth } from '../contexts/AuthContext' +import { useAuth } from '../hooks/useAuth' import { LoadingState } from './LoadingState' interface ProtectedRouteProps { diff --git a/src/components/layout/cart-sidebar/CartItemsList.tsx b/src/components/layout/cart-sidebar/CartItemsList.tsx index b8a3714..9a65afc 100644 --- a/src/components/layout/cart-sidebar/CartItemsList.tsx +++ b/src/components/layout/cart-sidebar/CartItemsList.tsx @@ -1,4 +1,4 @@ -import { useCart } from '../../../contexts/CartContext' +import { useCart } from '../../../hooks/useCart' import { CartLine } from '../../cart/CartLine' export function CartItemsList() { diff --git a/src/components/layout/cart-sidebar/CartSidebar.tsx b/src/components/layout/cart-sidebar/CartSidebar.tsx index af463c1..0db0ce9 100644 --- a/src/components/layout/cart-sidebar/CartSidebar.tsx +++ b/src/components/layout/cart-sidebar/CartSidebar.tsx @@ -1,4 +1,4 @@ -import { useCart } from '../../../contexts/CartContext' +import { useCart } from '../../../hooks/useCart' import { CartSummary } from './CartSummary' import { CartSidebarHeader } from './CartSidebarHeader' import { EmptyCartMessage } from './EmptyCartMessage' diff --git a/src/components/layout/header/AuthSection.tsx b/src/components/layout/header/AuthSection.tsx index 813d25a..d07b6fd 100644 --- a/src/components/layout/header/AuthSection.tsx +++ b/src/components/layout/header/AuthSection.tsx @@ -1,6 +1,6 @@ import { Link } from 'react-router-dom' -import { useAuth } from '../../../contexts/AuthContext' -import { useCart } from '../../../contexts/CartContext' +import { useAuth } from '../../../hooks/useAuth' +import { useCart } from '../../../hooks/useCart' export function AuthSection() { const { isAuthenticated, user, logout } = useAuth() diff --git a/src/components/layout/header/CartInfo.tsx b/src/components/layout/header/CartInfo.tsx index c085f9d..2deed4a 100644 --- a/src/components/layout/header/CartInfo.tsx +++ b/src/components/layout/header/CartInfo.tsx @@ -1,4 +1,4 @@ -import { useCart } from '../../../contexts/CartContext' +import { useCart } from '../../../hooks/useCart' export function CartInfo() { const { cartCount } = useCart() diff --git a/src/components/layout/header/CartToggleButton.tsx b/src/components/layout/header/CartToggleButton.tsx index 4362829..9ae4b31 100644 --- a/src/components/layout/header/CartToggleButton.tsx +++ b/src/components/layout/header/CartToggleButton.tsx @@ -1,4 +1,4 @@ -import { useCart } from '../../../contexts/CartContext' +import { useCart } from '../../../hooks/useCart' export function CartToggleButton() { const { cartCount, toggleCart } = useCart() @@ -21,4 +21,3 @@ export function CartToggleButton() { ) } - diff --git a/src/components/reviews/ReviewForm.tsx b/src/components/reviews/ReviewForm.tsx index eeb9604..9cca0a0 100644 --- a/src/components/reviews/ReviewForm.tsx +++ b/src/components/reviews/ReviewForm.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { StarRating } from './StarRating' import { ErrorMessage } from '../ErrorMessage' -import { useAuth } from '../../contexts/AuthContext' +import { useAuth } from '../../hooks/useAuth' function generateCaptcha() { const num1 = Math.floor(Math.random() * 10) + 1 diff --git a/src/contexts/auth/AuthContext.context.ts b/src/contexts/auth/AuthContext.context.ts new file mode 100644 index 0000000..fdd27b7 --- /dev/null +++ b/src/contexts/auth/AuthContext.context.ts @@ -0,0 +1,4 @@ +import { createContext } from 'react' +import type { AuthContextType } from './AuthContext.types' + +export const AuthContext = createContext(undefined) diff --git a/src/contexts/AuthContext.tsx b/src/contexts/auth/AuthContext.tsx similarity index 78% rename from src/contexts/AuthContext.tsx rename to src/contexts/auth/AuthContext.tsx index 4ab3749..7c0b83e 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/auth/AuthContext.tsx @@ -1,23 +1,6 @@ -import { createContext, useContext, useState, type ReactNode } from 'react' - -interface User { - id: string - email: string - firstName: string - lastName: string -} - -interface AuthContextType { - user: User | null - token: string | null - login: (email: string, password: string) => Promise - register: (email: string, password: string, firstName: string, lastName: string) => Promise - logout: () => void - isAuthenticated: boolean - loading: boolean -} - -const AuthContext = createContext(undefined) +import { useState, type ReactNode } from 'react' +import { AuthContext } from './AuthContext.context' +import type { User } from './AuthContext.types' const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3000' @@ -122,12 +105,3 @@ export function AuthProvider({ children }: { children: ReactNode }) { ) } - -// eslint-disable-next-line react-refresh/only-export-components -export function useAuth() { - const context = useContext(AuthContext) - if (context === undefined) { - throw new Error('useAuth must be used within an AuthProvider') - } - return context -} diff --git a/src/contexts/auth/AuthContext.types.ts b/src/contexts/auth/AuthContext.types.ts new file mode 100644 index 0000000..df65556 --- /dev/null +++ b/src/contexts/auth/AuthContext.types.ts @@ -0,0 +1,16 @@ +export interface User { + id: string + email: string + firstName: string + lastName: string +} + +export interface AuthContextType { + user: User | null + token: string | null + login: (email: string, password: string) => Promise + register: (email: string, password: string, firstName: string, lastName: string) => Promise + logout: () => void + isAuthenticated: boolean + loading: boolean +} diff --git a/src/contexts/cart/CartContext.context.ts b/src/contexts/cart/CartContext.context.ts new file mode 100644 index 0000000..cc01c30 --- /dev/null +++ b/src/contexts/cart/CartContext.context.ts @@ -0,0 +1,5 @@ +import { createContext } from 'react' +import type { CartContextType } from './CartContext.types' + +export const CartContext = createContext(undefined) + diff --git a/src/contexts/CartContext.tsx b/src/contexts/cart/CartContext.tsx similarity index 79% rename from src/contexts/CartContext.tsx rename to src/contexts/cart/CartContext.tsx index c934849..4dbbbd4 100644 --- a/src/contexts/CartContext.tsx +++ b/src/contexts/cart/CartContext.tsx @@ -1,32 +1,11 @@ -import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' -import type { Product } from '../data/products' -import type { CartLineItem } from '../types' -import { currency, freeShippingThreshold, flatShippingRate } from '../utils/constants' - -interface CartContextType { - cartItems: CartLineItem[] - cartCount: number - subtotal: number - shipping: number - total: number - freeShippingMessage: string - isCartOpen: boolean - toggleCart: () => void - updateQuantity: (productId: string, updater: (current: number) => number) => void - addToCart: (productId: string) => void - clearCart: () => void - isHighlighted: (productId: string) => boolean -} - -const CartContext = createContext(undefined) - -export function CartProvider({ - children, - products, -}: { - children: ReactNode - products: Product[] -}) { +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' +import type { Product } from '../../data/products' +import type { CartLineItem } from '../../types' +import { currency, freeShippingThreshold, flatShippingRate } from '../../utils/constants' +import { CartContext } from './CartContext.context' +import type { CartContextType } from './CartContext.types' + +export function CartProvider({ children, products }: { children: ReactNode; products: Product[] }) { const [cart, setCart] = useState>({}) const [isCartOpen, setIsCartOpen] = useState(false) const [highlightedProduct, setHighlightedProduct] = useState<{ @@ -155,11 +134,3 @@ export function CartProvider({ return {children} } -export function useCart() { - const context = useContext(CartContext) - if (context === undefined) { - throw new Error('useCart must be used within a CartProvider') - } - return context -} - diff --git a/src/contexts/cart/CartContext.types.ts b/src/contexts/cart/CartContext.types.ts new file mode 100644 index 0000000..3272d17 --- /dev/null +++ b/src/contexts/cart/CartContext.types.ts @@ -0,0 +1,17 @@ +import type { CartLineItem } from '../../types' + +export interface CartContextType { + cartItems: CartLineItem[] + cartCount: number + subtotal: number + shipping: number + total: number + freeShippingMessage: string + isCartOpen: boolean + toggleCart: () => void + updateQuantity: (productId: string, updater: (current: number) => number) => void + addToCart: (productId: string) => void + clearCart: () => void + isHighlighted: (productId: string) => boolean +} + diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts new file mode 100644 index 0000000..b11664c --- /dev/null +++ b/src/hooks/useAuth.ts @@ -0,0 +1,10 @@ +import { useContext } from 'react' +import { AuthContext } from '../contexts/auth/AuthContext.context' + +export function useAuth() { + const context = useContext(AuthContext) + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider') + } + return context +} diff --git a/src/hooks/useCart.ts b/src/hooks/useCart.ts new file mode 100644 index 0000000..f6ea93e --- /dev/null +++ b/src/hooks/useCart.ts @@ -0,0 +1,11 @@ +import { useContext } from 'react' +import { CartContext } from '../contexts/cart/CartContext.context' + +export function useCart() { + const context = useContext(CartContext) + if (context === undefined) { + throw new Error('useCart must be used within a CartProvider') + } + return context +} + From 4b5f91d7b764d8e5bfa7f16a34f7c63298384600 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robin=20Nederg=C3=A5rd?= Date: Mon, 1 Dec 2025 10:14:39 +0100 Subject: [PATCH 5/8] fix: restore main page gap --- src/components/layout/Layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index 8f71231..d5efd49 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -15,7 +15,7 @@ export function Layout({ children, backLink }: LayoutProps) {
    -
    +
    {backLink && } {children}
    From 42cd9afea2bf64ac8ef8b41b67a247468e3234cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robin=20Nederg=C3=A5rd?= Date: Mon, 1 Dec 2025 10:21:50 +0100 Subject: [PATCH 6/8] fix: improved imports with alias for src --- src/App.tsx | 12 ++++++------ src/ProductPage.tsx | 18 +++++++++--------- src/ProductsPage.tsx | 12 ++++++------ src/components/ProtectedRoute.tsx | 4 ++-- src/components/cart/CartLine.tsx | 5 ++--- src/components/checkout/EmptyCartState.tsx | 2 +- src/components/checkout/OrderSummary.tsx | 2 +- .../checkout/ShippingAddressForm.tsx | 2 +- src/components/layout/Layout.tsx | 2 +- .../layout/cart-sidebar/CartItemsList.tsx | 5 ++--- .../layout/cart-sidebar/CartSidebar.tsx | 10 +++++++--- .../layout/cart-sidebar/CartSummary.tsx | 3 +-- src/components/layout/header/AuthSection.tsx | 5 ++--- src/components/layout/header/CartInfo.tsx | 3 +-- .../layout/header/CartToggleButton.tsx | 2 +- src/components/product/ProductDetails.tsx | 2 +- src/components/product/ProductImage.tsx | 2 +- src/components/product/ProductMeta.tsx | 2 +- src/components/reviews/ReviewForm.tsx | 12 ++++++------ src/contexts/cart/CartContext.tsx | 7 +++---- src/contexts/cart/CartContext.types.ts | 3 +-- src/hooks/useAuth.ts | 2 +- src/hooks/useCart.ts | 3 +-- src/main.tsx | 4 ++-- src/pages/HomePage.tsx | 9 ++++----- src/routes.tsx | 18 +++++++++--------- src/types/index.ts | 4 +--- tsconfig.app.json | 8 +++++++- vite.config.ts | 6 ++++++ 29 files changed, 87 insertions(+), 82 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 1b43e9b..aa50352 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,12 +1,12 @@ import { useMemo } from 'react' import { Route, Routes } from 'react-router-dom' import { useQuery } from '@apollo/client/react' -import { AuthProvider } from './contexts/auth/AuthContext' -import { CartProvider } from './contexts/cart/CartContext' -import { Layout } from './components/layout/Layout' -import { GET_PRODUCTS } from './graphql/queries' -import { routes } from './routes' -import type { ProductsQueryResult } from './types' +import { AuthProvider } from '#src/contexts/auth/AuthContext' +import { CartProvider } from '#src/contexts/cart/CartContext' +import { Layout } from '#src/components/layout/Layout' +import { GET_PRODUCTS } from '#src/graphql/queries' +import { routes } from '#src/routes' +import type { ProductsQueryResult } from '#src/types' function AppRoutes() { return ( diff --git a/src/ProductPage.tsx b/src/ProductPage.tsx index eafab8d..f6b7d82 100644 --- a/src/ProductPage.tsx +++ b/src/ProductPage.tsx @@ -1,15 +1,15 @@ import { useState, useEffect, useMemo } from 'react' import { useParams } from 'react-router-dom' import { useQuery } from '@apollo/client/react' -import Reviews from './Reviews' -import { getAverageRating } from './utils/reviews' -import { GET_PRODUCT, GET_REVIEWS } from './graphql/queries' -import type { Product } from './data/products' -import { LoadingState } from './components/LoadingState' -import { EmptyState } from './components/EmptyState' -import { ProductImage } from './components/product/ProductImage' -import { ProductDetails } from './components/product/ProductDetails' -import { useCart } from './hooks/useCart' +import Reviews from '#src/Reviews' +import { getAverageRating } from '#src/utils/reviews' +import { GET_PRODUCT, GET_REVIEWS } from '#src/graphql/queries' +import type { Product } from '#src/data/products' +import { LoadingState } from '#src/components/LoadingState' +import { EmptyState } from '#src/components/EmptyState' +import { ProductImage } from '#src/components/product/ProductImage' +import { ProductDetails } from '#src/components/product/ProductDetails' +import { useCart } from '#src/hooks/useCart' type ProductQueryResult = { product: Product | null diff --git a/src/ProductsPage.tsx b/src/ProductsPage.tsx index df60525..397a2eb 100644 --- a/src/ProductsPage.tsx +++ b/src/ProductsPage.tsx @@ -1,12 +1,12 @@ import { useEffect } from 'react' import { useLocation } from 'react-router-dom' import { useQuery } from '@apollo/client/react' -import ProductCard from './ProductCard' -import { GET_PRODUCTS } from './graphql/queries' -import type { Product } from './data/products' -import { LoadingState } from './components/LoadingState' -import { ErrorMessage } from './components/ErrorMessage' -import { useCart } from './hooks/useCart' +import ProductCard from '#src/ProductCard' +import { GET_PRODUCTS } from '#src/graphql/queries' +import type { Product } from '#src/data/products' +import { LoadingState } from '#src/components/LoadingState' +import { ErrorMessage } from '#src/components/ErrorMessage' +import { useCart } from '#src/hooks/useCart' type ProductsQueryResult = { products: Product[] diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx index 5c5b794..46c82d4 100644 --- a/src/components/ProtectedRoute.tsx +++ b/src/components/ProtectedRoute.tsx @@ -1,6 +1,6 @@ import { Navigate, useLocation } from 'react-router-dom' -import { useAuth } from '../hooks/useAuth' -import { LoadingState } from './LoadingState' +import { useAuth } from '#src/hooks/useAuth' +import { LoadingState } from '#src/components/LoadingState' interface ProtectedRouteProps { children: React.ReactNode diff --git a/src/components/cart/CartLine.tsx b/src/components/cart/CartLine.tsx index 73a5a06..f882379 100644 --- a/src/components/cart/CartLine.tsx +++ b/src/components/cart/CartLine.tsx @@ -1,5 +1,5 @@ -import { currency } from '../../utils/constants' -import type { CartLineItem } from '../../types' +import { currency } from '#src/utils/constants' +import type { CartLineItem } from '#src/types' type CartLineProps = { item: CartLineItem @@ -74,4 +74,3 @@ export function CartLine({ item, onIncrement, onDecrement, onRemove }: CartLineP ) } - diff --git a/src/components/checkout/EmptyCartState.tsx b/src/components/checkout/EmptyCartState.tsx index fb39ba8..8dd0fca 100644 --- a/src/components/checkout/EmptyCartState.tsx +++ b/src/components/checkout/EmptyCartState.tsx @@ -1,5 +1,5 @@ import { useNavigate } from 'react-router-dom' -import { EmptyState } from '../EmptyState' +import { EmptyState } from '#src/components/EmptyState' export function EmptyCartState() { const navigate = useNavigate() diff --git a/src/components/checkout/OrderSummary.tsx b/src/components/checkout/OrderSummary.tsx index 5fe5708..d65a49b 100644 --- a/src/components/checkout/OrderSummary.tsx +++ b/src/components/checkout/OrderSummary.tsx @@ -1,4 +1,4 @@ -import type { Product } from '../../data/products' +import type { Product } from '#src/data/products' const currency = new Intl.NumberFormat('en-US', { style: 'currency', diff --git a/src/components/checkout/ShippingAddressForm.tsx b/src/components/checkout/ShippingAddressForm.tsx index 89f19f2..0da030c 100644 --- a/src/components/checkout/ShippingAddressForm.tsx +++ b/src/components/checkout/ShippingAddressForm.tsx @@ -1,5 +1,5 @@ import type { ChangeEvent } from 'react' -import { FormField } from '../FormField' +import { FormField } from '#src/components/FormField' type ShippingAddressFormProps = { formData: { diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index d5efd49..3e54e31 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -1,6 +1,6 @@ import { Header } from './header/Header' import { CartSidebar } from './cart-sidebar/CartSidebar' -import { BackLink } from '../BackLink' +import { BackLink } from '#src/components/BackLink' type LayoutProps = { children: React.ReactNode diff --git a/src/components/layout/cart-sidebar/CartItemsList.tsx b/src/components/layout/cart-sidebar/CartItemsList.tsx index 9a65afc..b34b0fe 100644 --- a/src/components/layout/cart-sidebar/CartItemsList.tsx +++ b/src/components/layout/cart-sidebar/CartItemsList.tsx @@ -1,5 +1,5 @@ -import { useCart } from '../../../hooks/useCart' -import { CartLine } from '../../cart/CartLine' +import { useCart } from '#src/hooks/useCart' +import { CartLine } from '#src/components/cart/CartLine' export function CartItemsList() { const { cartItems, updateQuantity } = useCart() @@ -22,4 +22,3 @@ export function CartItemsList() { ) } - diff --git a/src/components/layout/cart-sidebar/CartSidebar.tsx b/src/components/layout/cart-sidebar/CartSidebar.tsx index 0db0ce9..a8bd260 100644 --- a/src/components/layout/cart-sidebar/CartSidebar.tsx +++ b/src/components/layout/cart-sidebar/CartSidebar.tsx @@ -1,4 +1,4 @@ -import { useCart } from '../../../hooks/useCart' +import { useCart } from '#src/hooks/useCart' import { CartSummary } from './CartSummary' import { CartSidebarHeader } from './CartSidebarHeader' import { EmptyCartMessage } from './EmptyCartMessage' @@ -19,9 +19,13 @@ export function CartSidebar() {

    {freeShippingMessage}

    {cartItems.length === 0 ? : } {cartItems.length > 0 && ( - + )} ) } - diff --git a/src/components/layout/cart-sidebar/CartSummary.tsx b/src/components/layout/cart-sidebar/CartSummary.tsx index 3c1e292..98fd2c8 100644 --- a/src/components/layout/cart-sidebar/CartSummary.tsx +++ b/src/components/layout/cart-sidebar/CartSummary.tsx @@ -1,4 +1,4 @@ -import { currency } from '../../../utils/constants' +import { currency } from '#src/utils/constants' import { SummaryRow } from './SummaryRow' import { CheckoutButton } from './CheckoutButton' @@ -22,4 +22,3 @@ export function CartSummary({ subtotal, shipping, total, onCheckout }: CartSumma
    ) } - diff --git a/src/components/layout/header/AuthSection.tsx b/src/components/layout/header/AuthSection.tsx index d07b6fd..518bf16 100644 --- a/src/components/layout/header/AuthSection.tsx +++ b/src/components/layout/header/AuthSection.tsx @@ -1,6 +1,6 @@ import { Link } from 'react-router-dom' -import { useAuth } from '../../../hooks/useAuth' -import { useCart } from '../../../hooks/useCart' +import { useAuth } from '#src/hooks/useAuth' +import { useCart } from '#src/hooks/useCart' export function AuthSection() { const { isAuthenticated, user, logout } = useAuth() @@ -37,4 +37,3 @@ export function AuthSection() { ) } - diff --git a/src/components/layout/header/CartInfo.tsx b/src/components/layout/header/CartInfo.tsx index 2deed4a..8ca137a 100644 --- a/src/components/layout/header/CartInfo.tsx +++ b/src/components/layout/header/CartInfo.tsx @@ -1,4 +1,4 @@ -import { useCart } from '../../../hooks/useCart' +import { useCart } from '#src/hooks/useCart' export function CartInfo() { const { cartCount } = useCart() @@ -12,4 +12,3 @@ export function CartInfo() {
    ) } - diff --git a/src/components/layout/header/CartToggleButton.tsx b/src/components/layout/header/CartToggleButton.tsx index 9ae4b31..7bdd31d 100644 --- a/src/components/layout/header/CartToggleButton.tsx +++ b/src/components/layout/header/CartToggleButton.tsx @@ -1,4 +1,4 @@ -import { useCart } from '../../../hooks/useCart' +import { useCart } from '#src/hooks/useCart' export function CartToggleButton() { const { cartCount, toggleCart } = useCart() diff --git a/src/components/product/ProductDetails.tsx b/src/components/product/ProductDetails.tsx index 67426af..02ef1fe 100644 --- a/src/components/product/ProductDetails.tsx +++ b/src/components/product/ProductDetails.tsx @@ -1,4 +1,4 @@ -import type { Product } from '../../data/products' +import type { Product } from '#src/data/products' import { ProductMeta } from './ProductMeta' import { ColorSelector } from './ColorSelector' import { AddToCartButton } from './AddToCartButton' diff --git a/src/components/product/ProductImage.tsx b/src/components/product/ProductImage.tsx index 744aaf3..0f39522 100644 --- a/src/components/product/ProductImage.tsx +++ b/src/components/product/ProductImage.tsx @@ -1,4 +1,4 @@ -import type { Product } from '../../data/products' +import type { Product } from '#src/data/products' type ProductImageProps = { product: Product diff --git a/src/components/product/ProductMeta.tsx b/src/components/product/ProductMeta.tsx index c0768ef..58e3dca 100644 --- a/src/components/product/ProductMeta.tsx +++ b/src/components/product/ProductMeta.tsx @@ -1,4 +1,4 @@ -import type { Product } from '../../data/products' +import type { Product } from '#src/data/products' const currency = new Intl.NumberFormat('en-US', { style: 'currency', diff --git a/src/components/reviews/ReviewForm.tsx b/src/components/reviews/ReviewForm.tsx index 9cca0a0..433f940 100644 --- a/src/components/reviews/ReviewForm.tsx +++ b/src/components/reviews/ReviewForm.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { StarRating } from './StarRating' -import { ErrorMessage } from '../ErrorMessage' -import { useAuth } from '../../hooks/useAuth' +import { ErrorMessage } from '#src/components/ErrorMessage' +import { useAuth } from '#src/hooks/useAuth' function generateCaptcha() { const num1 = Math.floor(Math.random() * 10) + 1 @@ -56,10 +56,10 @@ export function ReviewForm({ onSubmit, isSubmitting, error, onError }: ReviewFor } try { - await onSubmit({ - name: isAuthenticated ? undefined : name.trim(), - text: text.trim(), - rating + await onSubmit({ + name: isAuthenticated ? undefined : name.trim(), + text: text.trim(), + rating, }) // Reset form on success setName('') diff --git a/src/contexts/cart/CartContext.tsx b/src/contexts/cart/CartContext.tsx index 4dbbbd4..eae8b6e 100644 --- a/src/contexts/cart/CartContext.tsx +++ b/src/contexts/cart/CartContext.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' -import type { Product } from '../../data/products' -import type { CartLineItem } from '../../types' -import { currency, freeShippingThreshold, flatShippingRate } from '../../utils/constants' +import type { Product } from '#src/data/products' +import type { CartLineItem } from '#src/types' +import { currency, freeShippingThreshold, flatShippingRate } from '#src/utils/constants' import { CartContext } from './CartContext.context' import type { CartContextType } from './CartContext.types' @@ -133,4 +133,3 @@ export function CartProvider({ children, products }: { children: ReactNode; prod return {children} } - diff --git a/src/contexts/cart/CartContext.types.ts b/src/contexts/cart/CartContext.types.ts index 3272d17..6a71eea 100644 --- a/src/contexts/cart/CartContext.types.ts +++ b/src/contexts/cart/CartContext.types.ts @@ -1,4 +1,4 @@ -import type { CartLineItem } from '../../types' +import type { CartLineItem } from '#src/types' export interface CartContextType { cartItems: CartLineItem[] @@ -14,4 +14,3 @@ export interface CartContextType { clearCart: () => void isHighlighted: (productId: string) => boolean } - diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts index b11664c..4685e69 100644 --- a/src/hooks/useAuth.ts +++ b/src/hooks/useAuth.ts @@ -1,5 +1,5 @@ import { useContext } from 'react' -import { AuthContext } from '../contexts/auth/AuthContext.context' +import { AuthContext } from '#src/contexts/auth/AuthContext.context' export function useAuth() { const context = useContext(AuthContext) diff --git a/src/hooks/useCart.ts b/src/hooks/useCart.ts index f6ea93e..cf079fe 100644 --- a/src/hooks/useCart.ts +++ b/src/hooks/useCart.ts @@ -1,5 +1,5 @@ import { useContext } from 'react' -import { CartContext } from '../contexts/cart/CartContext.context' +import { CartContext } from '#src/contexts/cart/CartContext.context' export function useCart() { const context = useContext(CartContext) @@ -8,4 +8,3 @@ export function useCart() { } return context } - diff --git a/src/main.tsx b/src/main.tsx index 650d22a..9b5efaf 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,8 +3,8 @@ import { createRoot } from 'react-dom/client' import { BrowserRouter } from 'react-router-dom' import { ApolloProvider } from '@apollo/client/react' import './index.css' -import App from './App.tsx' -import { client } from './graphql/client' +import App from '#src/App.tsx' +import { client } from '#src/graphql/client' createRoot(document.getElementById('root')!).render( diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 4c97396..c5b2ea4 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -1,9 +1,9 @@ import { Link } from 'react-router-dom' import { useQuery } from '@apollo/client/react' -import { GET_PRODUCTS } from '../graphql/queries' -import { currency, perks } from '../utils/constants' -import type { Product } from '../data/products' -import type { ProductsQueryResult } from '../types' +import { GET_PRODUCTS } from '#src/graphql/queries' +import { currency, perks } from '#src/utils/constants' +import type { Product } from '#src/data/products' +import type { ProductsQueryResult } from '#src/types' export function HomePage() { const { loading, data } = useQuery(GET_PRODUCTS) @@ -134,4 +134,3 @@ export function HomePage() { ) } - diff --git a/src/routes.tsx b/src/routes.tsx index 3c009c4..e3bb05d 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -1,12 +1,12 @@ -import { ProtectedRoute } from './components/ProtectedRoute' -import { HomePage } from './pages/HomePage' -import ProductsPage from './ProductsPage' -import ProductPage from './ProductPage' -import CheckoutPage from './CheckoutPage' -import CheckoutSuccessPage from './CheckoutSuccessPage' -import LoginPage from './LoginPage' -import RegisterPage from './RegisterPage' -import UserOrdersPage from './UserOrdersPage' +import { ProtectedRoute } from '#src/components/ProtectedRoute' +import { HomePage } from '#src/pages/HomePage' +import ProductsPage from '#src/ProductsPage' +import ProductPage from '#src/ProductPage' +import CheckoutPage from '#src/CheckoutPage' +import CheckoutSuccessPage from '#src/CheckoutSuccessPage' +import LoginPage from '#src/LoginPage' +import RegisterPage from '#src/RegisterPage' +import UserOrdersPage from '#src/UserOrdersPage' export const routes = [ { diff --git a/src/types/index.ts b/src/types/index.ts index cd58b68..d64c38b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,4 @@ -import type { Product } from '../data/products' +import type { Product } from '#src/data/products' export type ProductsQueryResult = { products: Product[] @@ -8,5 +8,3 @@ export type CartLineItem = { product: Product quantity: number } - - diff --git a/tsconfig.app.json b/tsconfig.app.json index a9b5a59..9045c49 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -22,7 +22,13 @@ "noUnusedParameters": true, "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true + "noUncheckedSideEffectImports": true, + + /* Path Aliases */ + "baseUrl": ".", + "paths": { + "#src/*": ["./src/*"] + } }, "include": ["src"] } diff --git a/vite.config.ts b/vite.config.ts index 1baabdc..ce1c29c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,9 +1,15 @@ import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' +import path from 'path' // https://vite.dev/config/ export default defineConfig({ plugins: [react()], + resolve: { + alias: { + '#src': path.resolve(__dirname, './src'), + }, + }, test: { environment: 'jsdom', globals: true, From 983b7e8c624f37d1119cead9d570e536d9996dad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robin=20Nederg=C3=A5rd?= Date: Mon, 1 Dec 2025 10:48:22 +0100 Subject: [PATCH 7/8] fix: added test folder, moved tests and added more --- {src => test}/App.test.tsx | 4 +- test/components/EmptyState.test.tsx | 47 +++++++++ test/components/ErrorMessage.test.tsx | 24 +++++ test/components/LoadingState.test.tsx | 23 ++++ test/components/ProtectedRoute.test.tsx | 109 +++++++++++++++++++ test/components/StarRating.test.tsx | 47 +++++++++ test/hooks/useCart.test.tsx | 134 ++++++++++++++++++++++++ tsconfig.json | 3 +- tsconfig.test.json | 11 ++ vite.config.ts | 5 + 10 files changed, 404 insertions(+), 3 deletions(-) rename {src => test}/App.test.tsx (97%) create mode 100644 test/components/EmptyState.test.tsx create mode 100644 test/components/ErrorMessage.test.tsx create mode 100644 test/components/LoadingState.test.tsx create mode 100644 test/components/ProtectedRoute.test.tsx create mode 100644 test/components/StarRating.test.tsx create mode 100644 test/hooks/useCart.test.tsx create mode 100644 tsconfig.test.json diff --git a/src/App.test.tsx b/test/App.test.tsx similarity index 97% rename from src/App.test.tsx rename to test/App.test.tsx index 2c6efc9..e15dc20 100644 --- a/src/App.test.tsx +++ b/test/App.test.tsx @@ -4,8 +4,8 @@ import { render, screen, waitFor } from '@testing-library/react' import { MemoryRouter } from 'react-router-dom' import { MockedProvider } from '@apollo/client/testing/react' import userEvent from '@testing-library/user-event' -import App from './App' -import { GET_PRODUCTS } from './graphql/queries' +import App from '#src/App' +import { GET_PRODUCTS } from '#src/graphql/queries' // Mock data for products query const mockProducts = [ diff --git a/test/components/EmptyState.test.tsx b/test/components/EmptyState.test.tsx new file mode 100644 index 0000000..3ea6467 --- /dev/null +++ b/test/components/EmptyState.test.tsx @@ -0,0 +1,47 @@ +import { describe, it, expect, vi } from 'vitest' +import '@testing-library/jest-dom/vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router-dom' +import { EmptyState } from '#src/components/EmptyState' + +describe('EmptyState', () => { + it('renders title', () => { + render() + expect(screen.getByText('No items found')).toBeInTheDocument() + }) + + it('renders message when provided', () => { + render() + expect(screen.getByText('No items to display')).toBeInTheDocument() + }) + + it('renders link when actionTo is provided', () => { + render( + + + , + ) + const link = screen.getByRole('link', { name: 'Go Home' }) + expect(link).toBeInTheDocument() + expect(link).toHaveAttribute('href', '/') + }) + + it('renders button when onAction is provided', async () => { + const handleAction = vi.fn() + const user = userEvent.setup() + render() + + const button = screen.getByRole('button', { name: 'Click me' }) + expect(button).toBeInTheDocument() + + await user.click(button) + expect(handleAction).toHaveBeenCalledTimes(1) + }) + + it('does not render action when neither actionTo nor onAction is provided', () => { + render() + expect(screen.queryByRole('link')).not.toBeInTheDocument() + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) +}) diff --git a/test/components/ErrorMessage.test.tsx b/test/components/ErrorMessage.test.tsx new file mode 100644 index 0000000..b39ec41 --- /dev/null +++ b/test/components/ErrorMessage.test.tsx @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest' +import '@testing-library/jest-dom/vitest' +import { render, screen } from '@testing-library/react' +import { ErrorMessage } from '#src/components/ErrorMessage' + +describe('ErrorMessage', () => { + it('renders error message', () => { + render() + expect(screen.getByText('Something went wrong')).toBeInTheDocument() + }) + + it('applies custom className', () => { + const { container } = render() + const element = container.firstChild as HTMLElement + expect(element).toHaveClass('custom-class') + }) + + it('has correct styling classes', () => { + const { container } = render() + const element = container.firstChild as HTMLElement + expect(element).toHaveClass('bg-orange-50', 'border-orange-200', 'text-orange-600') + }) +}) + diff --git a/test/components/LoadingState.test.tsx b/test/components/LoadingState.test.tsx new file mode 100644 index 0000000..71609bc --- /dev/null +++ b/test/components/LoadingState.test.tsx @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest' +import '@testing-library/jest-dom/vitest' +import { render, screen } from '@testing-library/react' +import { LoadingState } from '#src/components/LoadingState' + +describe('LoadingState', () => { + it('renders default loading message', () => { + render() + expect(screen.getByText('Loading...')).toBeInTheDocument() + }) + + it('renders custom loading message', () => { + render() + expect(screen.getByText('Fetching data...')).toBeInTheDocument() + }) + + it('applies custom className', () => { + const { container } = render() + const element = container.firstChild as HTMLElement + expect(element).toHaveClass('custom-class') + }) +}) + diff --git a/test/components/ProtectedRoute.test.tsx b/test/components/ProtectedRoute.test.tsx new file mode 100644 index 0000000..ade547b --- /dev/null +++ b/test/components/ProtectedRoute.test.tsx @@ -0,0 +1,109 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import '@testing-library/jest-dom/vitest' +import { render, screen } from '@testing-library/react' + +// Mock useAuth and Navigate before imports +const mockUseAuth = vi.fn() +const mockNavigate = vi.fn(({ to }) =>
    ) + +vi.mock('#src/hooks/useAuth', () => ({ + useAuth: () => mockUseAuth(), +})) + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom') + return { + ...actual, + Navigate: ({ to, ...props }: { to: string }) => mockNavigate({ to, ...props }), + MemoryRouter: ({ children }: { children: React.ReactNode }) =>
    {children}
    , + useLocation: () => ({ + pathname: '/protected', + search: '', + hash: '', + state: null, + key: 'default', + }), + } +}) + +// Import after mocks +import { ProtectedRoute } from '#src/components/ProtectedRoute' + +describe('ProtectedRoute', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('redirects to login when user is not authenticated', () => { + mockUseAuth.mockReturnValue({ + user: null, + token: null, + login: vi.fn(), + register: vi.fn(), + logout: vi.fn(), + isAuthenticated: false, + loading: false, + }) + + render( + +
    Protected Content
    +
    , + ) + + // Should show Navigate component redirecting to /login + const navigate = screen.getByTestId('navigate') + expect(navigate).toBeInTheDocument() + expect(navigate).toHaveAttribute('data-to', '/login') + expect(screen.queryByText('Protected Content')).not.toBeInTheDocument() + }) + + it('shows loading state when loading', () => { + mockUseAuth.mockReturnValue({ + user: null, + token: null, + login: vi.fn(), + register: vi.fn(), + logout: vi.fn(), + isAuthenticated: false, + loading: true, + }) + + render( + +
    Protected Content
    +
    , + ) + + // Should show loading state + expect(screen.getByText('Loading...')).toBeInTheDocument() + expect(screen.queryByText('Protected Content')).not.toBeInTheDocument() + }) + + it('renders children when user is authenticated', () => { + mockUseAuth.mockReturnValue({ + user: { + id: '1', + email: 'test@test.com', + firstName: 'Test', + lastName: 'User', + }, + token: 'mock-token', + login: vi.fn(), + register: vi.fn(), + logout: vi.fn(), + isAuthenticated: true, + loading: false, + }) + + render( + +
    Protected Content
    +
    , + ) + + // Content should be visible when authenticated + expect(screen.getByText('Protected Content')).toBeInTheDocument() + expect(screen.queryByTestId('navigate')).not.toBeInTheDocument() + }) +}) diff --git a/test/components/StarRating.test.tsx b/test/components/StarRating.test.tsx new file mode 100644 index 0000000..c347641 --- /dev/null +++ b/test/components/StarRating.test.tsx @@ -0,0 +1,47 @@ +import { describe, it, expect, vi } from 'vitest' +import '@testing-library/jest-dom/vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { StarRating } from '#src/components/reviews/StarRating' + +describe('StarRating', () => { + it('renders correct number of stars', () => { + render() + const stars = screen.getAllByText('★') + expect(stars).toHaveLength(5) // Always shows 5 stars + }) + + it('highlights correct number of stars based on rating', () => { + const { container } = render() + const filledStars = container.querySelectorAll('.text-amber-400') + const emptyStars = container.querySelectorAll('.text-slate-200') + + expect(filledStars.length).toBe(4) + expect(emptyStars.length).toBe(1) + }) + + it('calls onRatingChange when interactive and star is clicked', async () => { + const handleRatingChange = vi.fn() + const user = userEvent.setup() + + render() + + const stars = screen.getAllByText('★') + await user.click(stars[2]) // Click 3rd star (rating 3) + + expect(handleRatingChange).toHaveBeenCalledWith(3) + }) + + it('does not call onRatingChange when not interactive', async () => { + const handleRatingChange = vi.fn() + const user = userEvent.setup() + + render() + + const stars = screen.getAllByText('★') + await user.click(stars[0]) + + expect(handleRatingChange).not.toHaveBeenCalled() + }) +}) + diff --git a/test/hooks/useCart.test.tsx b/test/hooks/useCart.test.tsx new file mode 100644 index 0000000..af7ddf8 --- /dev/null +++ b/test/hooks/useCart.test.tsx @@ -0,0 +1,134 @@ +import { describe, it, expect, vi } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { CartProvider } from '#src/contexts/cart/CartContext' +import { useCart } from '#src/hooks/useCart' +import type { Product } from '#src/data/products' + +const mockProducts: Product[] = [ + { + id: '1', + name: 'Test Product', + category: 'Test', + price: 100, + image: 'test.jpg', + description: 'Test description', + badge: undefined, + featured: false, + colors: ['Red'], + rating: 4.5, + }, +] + +describe('useCart', () => { + it('throws error when used outside CartProvider', () => { + // Suppress console.error for this test + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + expect(() => { + renderHook(() => useCart()) + }).toThrow('useCart must be used within a CartProvider') + + consoleSpy.mockRestore() + }) + + it('initializes with empty cart', () => { + const { result } = renderHook(() => useCart(), { + wrapper: ({ children }) => {children}, + }) + + expect(result.current.cartItems).toEqual([]) + expect(result.current.cartCount).toBe(0) + expect(result.current.subtotal).toBe(0) + expect(result.current.isCartOpen).toBe(false) + }) + + it('adds item to cart', () => { + const { result } = renderHook(() => useCart(), { + wrapper: ({ children }) => {children}, + }) + + act(() => { + result.current.addToCart('1') + }) + + expect(result.current.cartCount).toBe(1) + expect(result.current.cartItems).toHaveLength(1) + expect(result.current.cartItems[0].product.id).toBe('1') + expect(result.current.cartItems[0].quantity).toBe(1) + }) + + it('updates quantity when adding same item multiple times', () => { + const { result } = renderHook(() => useCart(), { + wrapper: ({ children }) => {children}, + }) + + act(() => { + result.current.addToCart('1') + result.current.addToCart('1') + }) + + expect(result.current.cartCount).toBe(2) + expect(result.current.cartItems[0].quantity).toBe(2) + }) + + it('removes item when quantity reaches zero', () => { + const { result } = renderHook(() => useCart(), { + wrapper: ({ children }) => {children}, + }) + + act(() => { + result.current.addToCart('1') + result.current.updateQuantity('1', () => 0) + }) + + expect(result.current.cartCount).toBe(0) + expect(result.current.cartItems).toHaveLength(0) + }) + + it('toggles cart open state', () => { + const { result } = renderHook(() => useCart(), { + wrapper: ({ children }) => {children}, + }) + + expect(result.current.isCartOpen).toBe(false) + + act(() => { + result.current.toggleCart() + }) + + expect(result.current.isCartOpen).toBe(true) + + act(() => { + result.current.toggleCart() + }) + + expect(result.current.isCartOpen).toBe(false) + }) + + it('clears cart', () => { + const { result } = renderHook(() => useCart(), { + wrapper: ({ children }) => {children}, + }) + + act(() => { + result.current.addToCart('1') + result.current.clearCart() + }) + + expect(result.current.cartCount).toBe(0) + expect(result.current.cartItems).toHaveLength(0) + }) + + it('calculates subtotal correctly', () => { + const { result } = renderHook(() => useCart(), { + wrapper: ({ children }) => {children}, + }) + + act(() => { + result.current.addToCart('1') + result.current.addToCart('1') + }) + + expect(result.current.subtotal).toBe(200) // 2 * 100 + }) +}) diff --git a/tsconfig.json b/tsconfig.json index 1ffef60..01490aa 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,7 @@ "files": [], "references": [ { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.test.json" } ] } diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..4f1daa5 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.app.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "#src/*": ["./src/*"], + "#test/*": ["./test/*"] + } + }, + "include": ["test", "src"] +} diff --git a/vite.config.ts b/vite.config.ts index ce1c29c..501e863 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -8,11 +8,16 @@ export default defineConfig({ resolve: { alias: { '#src': path.resolve(__dirname, './src'), + '#test': path.resolve(__dirname, './test'), }, }, test: { environment: 'jsdom', globals: true, setupFiles: './vitest.setup.ts', + include: ['test/**/*.{test,spec}.{ts,tsx}'], + typecheck: { + tsconfig: './tsconfig.test.json', + }, }, }) From 533d505d4d9278da39af0313ab7197e26d531e05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robin=20Nederg=C3=A5rd?= Date: Mon, 1 Dec 2025 10:51:22 +0100 Subject: [PATCH 8/8] fix: requested change by cursor --- src/contexts/cart/CartContext.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/contexts/cart/CartContext.tsx b/src/contexts/cart/CartContext.tsx index eae8b6e..e8dee29 100644 --- a/src/contexts/cart/CartContext.tsx +++ b/src/contexts/cart/CartContext.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import type { Product } from '#src/data/products' import type { CartLineItem } from '#src/types' import { currency, freeShippingThreshold, flatShippingRate } from '#src/utils/constants' @@ -102,9 +102,9 @@ export function CartProvider({ children, products }: { children: ReactNode; prod const toggleCart = () => setIsCartOpen((open) => !open) - const clearCart = () => { + const clearCart = useCallback(() => { setCart({}) - } + }, []) const isHighlighted = (productId: string) => highlightedProduct?.id === productId