-
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/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx
index 50f5038..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 '../contexts/AuthContext'
-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
new file mode 100644
index 0000000..f882379
--- /dev/null
+++ b/src/components/cart/CartLine.tsx
@@ -0,0 +1,76 @@
+import { currency } from '#src/utils/constants'
+import type { CartLineItem } from '#src/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/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
new file mode 100644
index 0000000..3e54e31
--- /dev/null
+++ b/src/components/layout/Layout.tsx
@@ -0,0 +1,24 @@
+import { Header } from './header/Header'
+import { CartSidebar } from './cart-sidebar/CartSidebar'
+import { BackLink } from '#src/components/BackLink'
+
+type LayoutProps = {
+ children: React.ReactNode
+ backLink?: {
+ to: string
+ label: string
+ }
+}
+
+export function Layout({ children, backLink }: LayoutProps) {
+ return (
+
+
+
+
+ {backLink && }
+ {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..b34b0fe
--- /dev/null
+++ b/src/components/layout/cart-sidebar/CartItemsList.tsx
@@ -0,0 +1,24 @@
+import { useCart } from '#src/hooks/useCart'
+import { CartLine } from '#src/components/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..a8bd260
--- /dev/null
+++ b/src/components/layout/cart-sidebar/CartSidebar.tsx
@@ -0,0 +1,31 @@
+import { useCart } from '#src/hooks/useCart'
+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
+
+
+ Hide
+
+
+ )
+}
+
diff --git a/src/components/layout/cart-sidebar/CartSummary.tsx b/src/components/layout/cart-sidebar/CartSummary.tsx
new file mode 100644
index 0000000..98fd2c8
--- /dev/null
+++ b/src/components/layout/cart-sidebar/CartSummary.tsx
@@ -0,0 +1,24 @@
+import { currency } from '#src/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..518bf16
--- /dev/null
+++ b/src/components/layout/header/AuthSection.tsx
@@ -0,0 +1,39 @@
+import { Link } from 'react-router-dom'
+import { useAuth } from '#src/hooks/useAuth'
+import { useCart } from '#src/hooks/useCart'
+
+export function AuthSection() {
+ const { isAuthenticated, user, logout } = useAuth()
+ const { isCartOpen, toggleCart } = useCart()
+
+ if (isAuthenticated) {
+ return (
+ <>
+
+ {user?.email}
+
+
+ Logout
+
+ >
+ )
+ }
+
+ 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..8ca137a
--- /dev/null
+++ b/src/components/layout/header/CartInfo.tsx
@@ -0,0 +1,14 @@
+import { useCart } from '#src/hooks/useCart'
+
+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..7bdd31d
--- /dev/null
+++ b/src/components/layout/header/CartToggleButton.tsx
@@ -0,0 +1,23 @@
+import { useCart } from '#src/hooks/useCart'
+
+export function CartToggleButton() {
+ const { cartCount, toggleCart } = useCart()
+
+ return (
+
+ Bag
+
+ {cartCount}
+
+
+ )
+}
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/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 eeb9604..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 '../../contexts/AuthContext'
+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/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/cart/CartContext.tsx b/src/contexts/cart/CartContext.tsx
new file mode 100644
index 0000000..e8dee29
--- /dev/null
+++ b/src/contexts/cart/CartContext.tsx
@@ -0,0 +1,135 @@
+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'
+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<{
+ 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 = useCallback(() => {
+ setCart({})
+ }, [])
+
+ const isHighlighted = (productId: string) => highlightedProduct?.id === productId
+
+ useEffect(() => {
+ return () => {
+ if (highlightTimeoutRef.current) {
+ clearTimeout(highlightTimeoutRef.current)
+ }
+ }
+ }, [])
+
+ const value: CartContextType = {
+ cartItems,
+ cartCount,
+ subtotal,
+ shipping,
+ total,
+ freeShippingMessage,
+ isCartOpen,
+ toggleCart,
+ updateQuantity,
+ addToCart,
+ clearCart,
+ isHighlighted,
+ }
+
+ return {children}
+}
diff --git a/src/contexts/cart/CartContext.types.ts b/src/contexts/cart/CartContext.types.ts
new file mode 100644
index 0000000..6a71eea
--- /dev/null
+++ b/src/contexts/cart/CartContext.types.ts
@@ -0,0 +1,16 @@
+import type { CartLineItem } from '#src/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..4685e69
--- /dev/null
+++ b/src/hooks/useAuth.ts
@@ -0,0 +1,10 @@
+import { useContext } from 'react'
+import { AuthContext } from '#src/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..cf079fe
--- /dev/null
+++ b/src/hooks/useCart.ts
@@ -0,0 +1,10 @@
+import { useContext } from 'react'
+import { CartContext } from '#src/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
+}
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
new file mode 100644
index 0000000..c5b2ea4
--- /dev/null
+++ b/src/pages/HomePage.tsx
@@ -0,0 +1,136 @@
+import { Link } from 'react-router-dom'
+import { useQuery } from '@apollo/client/react'
+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)
+ 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
+
+
+ document.querySelector('#editorial')?.scrollIntoView({ behavior: 'smooth' })
+ }
+ >
+ Studio story
+
+
+
+ {currency.format(heroProduct.price)}
+ {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
+
+ ))}
+
+
+
+
+
+
+
+
+
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
+
+
+ Book a design consult
+
+
+
+ >
+ )
+}
diff --git a/src/routes.tsx b/src/routes.tsx
new file mode 100644
index 0000000..e3bb05d
--- /dev/null
+++ b/src/routes.tsx
@@ -0,0 +1,52 @@
+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 = [
+ {
+ 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' },
+ },
+]
diff --git a/src/types/index.ts b/src/types/index.ts
new file mode 100644
index 0000000..d64c38b
--- /dev/null
+++ b/src/types/index.ts
@@ -0,0 +1,10 @@
+import type { Product } from '#src/data/products'
+
+export type ProductsQueryResult = {
+ products: Product[]
+}
+
+export type CartLineItem = {
+ product: Product
+ quantity: number
+}
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
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.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/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 1baabdc..501e863 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,12 +1,23 @@
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': path.resolve(__dirname, './test'),
+ },
+ },
test: {
environment: 'jsdom',
globals: true,
setupFiles: './vitest.setup.ts',
+ include: ['test/**/*.{test,spec}.{ts,tsx}'],
+ typecheck: {
+ tsconfig: './tsconfig.test.json',
+ },
},
})