diff --git a/apps/api/backend/api/routes/cart_items.py b/apps/api/backend/api/routes/cart_items.py index 392c561..0e159cd 100644 --- a/apps/api/backend/api/routes/cart_items.py +++ b/apps/api/backend/api/routes/cart_items.py @@ -5,9 +5,14 @@ from backend.schemas.cart_item import ( CartItemCreateRequest, CartItemDeleteResponse, + CartItemQuantityUpdateRequest, CartItemResponse, ) -from backend.services.cart_item_service import add_item_to_cart, remove_item_from_cart +from backend.services.cart_item_service import ( + add_item_to_cart, + remove_item_from_cart, + update_cart_item_quantity, +) router = APIRouter(prefix="/carts", tags=["carts"]) @@ -29,4 +34,18 @@ def add_cart_item(cart_id: UUID, payload: CartItemCreateRequest) -> CartItemResp ) def delete_cart_item(cart_id: UUID, cart_item_id: UUID) -> CartItemDeleteResponse: result = remove_item_from_cart(cart_id, cart_item_id) - return CartItemDeleteResponse(**result) \ No newline at end of file + return CartItemDeleteResponse(**result) + + +@router.patch( + "/{cart_id}/items/{cart_item_id}", + response_model=CartItemResponse, + status_code=status.HTTP_200_OK, +) +def patch_cart_item_quantity( + cart_id: UUID, + cart_item_id: UUID, + payload: CartItemQuantityUpdateRequest, +) -> CartItemResponse: + item = update_cart_item_quantity(cart_id, cart_item_id, payload) + return CartItemResponse(**item) \ No newline at end of file diff --git a/apps/api/backend/schemas/cart.py b/apps/api/backend/schemas/cart.py index 2472c56..79692d3 100644 --- a/apps/api/backend/schemas/cart.py +++ b/apps/api/backend/schemas/cart.py @@ -22,6 +22,7 @@ class CartItemSummaryResponse(BaseModel): cart_item_id: UUID product_id: UUID product_name: str + brand_name: str | None = None quantity: int unit_price: Decimal currency: str diff --git a/apps/api/backend/schemas/cart_item.py b/apps/api/backend/schemas/cart_item.py index 251ede9..65809be 100644 --- a/apps/api/backend/schemas/cart_item.py +++ b/apps/api/backend/schemas/cart_item.py @@ -24,4 +24,8 @@ class CartItemResponse(BaseModel): class CartItemDeleteResponse(BaseModel): cart_item_id: UUID cart_id: UUID - message: str \ No newline at end of file + message: str + + +class CartItemQuantityUpdateRequest(BaseModel): + quantity: int = Field(ge=1, le=999) \ No newline at end of file diff --git a/apps/api/backend/services/cart_item_service.py b/apps/api/backend/services/cart_item_service.py index e0a1bb3..74d9978 100644 --- a/apps/api/backend/services/cart_item_service.py +++ b/apps/api/backend/services/cart_item_service.py @@ -5,7 +5,10 @@ from sqlalchemy import text from backend.db.connection import engine -from backend.schemas.cart_item import CartItemCreateRequest +from backend.schemas.cart_item import ( + CartItemCreateRequest, + CartItemQuantityUpdateRequest, +) def add_item_to_cart(cart_id: UUID, payload: CartItemCreateRequest) -> dict[str, Any]: @@ -235,4 +238,103 @@ def remove_item_from_cart(cart_id: UUID, cart_item_id: UUID) -> dict[str, Any]: "cart_item_id": deleted_item["cart_item_id"], "cart_id": deleted_item["cart_id"], "message": "Cart item removed successfully", - } \ No newline at end of file + } + +def update_cart_item_quantity( + cart_id: UUID, + cart_item_id: UUID, + payload: CartItemQuantityUpdateRequest, +) -> dict[str, Any]: + cart_query = text(""" + SELECT + cart_id, + cart_status + FROM carts + WHERE cart_id = :cart_id + AND cart_status = 'active' + LIMIT 1 + """) + + item_query = text(""" + SELECT + cart_item_id, + cart_id, + product_id, + unit_price, + currency + FROM cart_items + WHERE cart_id = :cart_id + AND cart_item_id = :cart_item_id + LIMIT 1 + """) + + update_query = text(""" + UPDATE cart_items + SET + quantity = :quantity, + updated_at = CURRENT_TIMESTAMP + WHERE cart_id = :cart_id + AND cart_item_id = :cart_item_id + RETURNING + cart_item_id, + cart_id, + product_id, + quantity, + unit_price, + currency, + added_at, + updated_at + """) + + with engine.begin() as connection: + cart = connection.execute( + cart_query, + {"cart_id": cart_id}, + ).mappings().first() + + if cart is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Active cart not found", + ) + + item = connection.execute( + item_query, + { + "cart_id": cart_id, + "cart_item_id": cart_item_id, + }, + ).mappings().first() + + if item is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Cart item not found", + ) + + updated_item = connection.execute( + update_query, + { + "cart_id": cart_id, + "cart_item_id": cart_item_id, + "quantity": payload.quantity, + }, + ).mappings().first() + + if updated_item is None: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to update cart item quantity", + ) + + return { + "cart_item_id": updated_item["cart_item_id"], + "cart_id": updated_item["cart_id"], + "product_id": updated_item["product_id"], + "quantity": updated_item["quantity"], + "unit_price": updated_item["unit_price"], + "currency": updated_item["currency"], + "added_at": updated_item["added_at"], + "updated_at": updated_item["updated_at"], + "message": "Cart item quantity updated successfully", + } \ No newline at end of file diff --git a/apps/api/backend/services/cart_service.py b/apps/api/backend/services/cart_service.py index d0cc038..f7e8efd 100644 --- a/apps/api/backend/services/cart_service.py +++ b/apps/api/backend/services/cart_service.py @@ -106,6 +106,7 @@ def get_cart_detail(cart_id: UUID) -> dict[str, Any]: ci.cart_item_id, ci.product_id, p.product_name, + p.brand_name, ci.quantity, ci.unit_price, ci.currency, @@ -154,6 +155,7 @@ def get_cart_detail(cart_id: UUID) -> dict[str, Any]: "cart_item_id": row["cart_item_id"], "product_id": row["product_id"], "product_name": row["product_name"], + "brand_name": row["brand_name"], "quantity": row["quantity"], "unit_price": row["unit_price"], "currency": row["currency"], diff --git a/apps/web/src/features/cart/CartPage.tsx b/apps/web/src/features/cart/CartPage.tsx index 3ade2f3..e93568e 100644 --- a/apps/web/src/features/cart/CartPage.tsx +++ b/apps/web/src/features/cart/CartPage.tsx @@ -1,8 +1,399 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Link } from "react-router-dom"; +import { + getCart, + removeCartItem, + updateCartItemQuantity, +} from "../../services/cartApi"; +import { getStoredCartId, getStoredUser } from "../../stores/userStore"; +import type { CartDetail, CartItem } from "../../types/cart"; + +type CartItemWithTotals = CartItem & { + line_amount?: string | number; + line_total?: string | number; +}; + +const MIN_CART_QUANTITY = 0; +const MAX_CART_QUANTITY = 99; + +function formatPrice(value: string | number | undefined, currency = "KRW") { + if (value === undefined || value === null) { + return "-"; + } + + const numericValue = Number(value); + + if (Number.isNaN(numericValue)) { + return `${value} ${currency}`; + } + + return new Intl.NumberFormat("ko-KR", { + style: "currency", + currency, + maximumFractionDigits: 0, + }).format(numericValue); +} + +function getCartItems(cart: CartDetail | null): CartItemWithTotals[] { + if (!cart) { + return []; + } + + return (cart.cart_items ?? cart.items ?? []) as CartItemWithTotals[]; +} + +function getCartItemLineAmount(item: CartItemWithTotals) { + const explicitLineAmount = item.line_total ?? item.line_amount; + + if (explicitLineAmount !== undefined) { + return explicitLineAmount; + } + + const calculatedAmount = Number(item.unit_price) * item.quantity; + + return Number.isNaN(calculatedAmount) ? undefined : calculatedAmount; +} + +function getTotalQuantity(items: CartItemWithTotals[]) { + return items.reduce((sum, item) => sum + item.quantity, 0); +} + +function getFallbackTotalAmount(items: CartItemWithTotals[]) { + return items.reduce((sum, item) => { + const lineAmount = Number(getCartItemLineAmount(item)); + + return Number.isNaN(lineAmount) ? sum : sum + lineAmount; + }, 0); +} + +function normalizeQuantity(value: number) { + return Math.max(MIN_CART_QUANTITY, Math.min(MAX_CART_QUANTITY, value)); +} + export function CartPage() { + const storedUser = getStoredUser(); + const userId = storedUser?.user_id ?? null; + const storedCartId = getStoredCartId(); + + const [cart, setCart] = useState(null); + const [isLoading, setIsLoading] = useState(Boolean(userId && storedCartId)); + const [errorMessage, setErrorMessage] = useState(null); + const [actionErrorMessage, setActionErrorMessage] = useState(null); + const [actionMessage, setActionMessage] = useState(null); + const [removingItemId, setRemovingItemId] = useState(null); + const [updatingItemId, setUpdatingItemId] = useState(null); + const [quantityDrafts, setQuantityDrafts] = useState>({}); + + const cartItems = useMemo(() => getCartItems(cart), [cart]); + const totalQuantity = useMemo(() => getTotalQuantity(cartItems), [cartItems]); + const totalAmount = useMemo( + () => cart?.total_amount ?? getFallbackTotalAmount(cartItems), + [cart, cartItems], + ); + const currency = cart?.currency ?? cartItems[0]?.currency ?? "KRW"; + + const loadCart = useCallback(async () => { + if (!userId || !storedCartId) { + setIsLoading(false); + return; + } + + try { + setIsLoading(true); + setErrorMessage(null); + + const cartData = await getCart(storedCartId); + setCart(cartData); + } catch { + setErrorMessage("장바구니 정보를 불러오지 못했습니다."); + } finally { + setIsLoading(false); + } + }, [storedCartId, userId]); + + useEffect(() => { + loadCart(); + }, [loadCart]); + + useEffect(() => { + const nextDrafts = cartItems.reduce>((drafts, item) => { + drafts[item.cart_item_id] = String(item.quantity); + return drafts; + }, {}); + + setQuantityDrafts(nextDrafts); + }, [cartItems]); + + const handleRemoveItem = async (cartItemId: string) => { + if (!storedCartId || removingItemId || updatingItemId) { + return; + } + + try { + setRemovingItemId(cartItemId); + setActionMessage(null); + setErrorMessage(null); + + await removeCartItem(storedCartId, cartItemId); + + const refreshedCart = await getCart(storedCartId); + setCart(refreshedCart); + } catch { + setErrorMessage("상품 제거에 실패했습니다. 잠시 후 다시 시도해주세요."); + } finally { + setRemovingItemId(null); + } + }; + + const handleChangeQuantity = async (item: CartItem, nextQuantity: number) => { + if (!storedCartId || updatingItemId) { + return; + } + + const normalizedQuantity = Math.max(0, Math.min(99, nextQuantity)); + + try { + setUpdatingItemId(item.cart_item_id); + setActionMessage(null); + setErrorMessage(null); + + if (normalizedQuantity === 0) { + await removeCartItem(storedCartId, item.cart_item_id); + } else { + await updateCartItemQuantity( + storedCartId, + item.cart_item_id, + normalizedQuantity, + ); + } + + const refreshedCart = await getCart(storedCartId); + setCart(refreshedCart); + } catch { + setActionErrorMessage("상품 수량 변경에 실패했습니다. 잠시 후 다시 시도해주세요."); + } finally { + setUpdatingItemId(null); + } + }; + + const handleQuantityInputChange = (cartItemId: string, value: string) => { + if (!/^\d*$/.test(value)) { + return; + } + + setQuantityDrafts((currentDrafts) => ({ + ...currentDrafts, + [cartItemId]: value, + })); + }; + + const handleQuantityInputBlur = (item: CartItemWithTotals) => { + const draftValue = quantityDrafts[item.cart_item_id]; + + if (draftValue === undefined || draftValue.trim() === "") { + setQuantityDrafts((currentDrafts) => ({ + ...currentDrafts, + [item.cart_item_id]: String(item.quantity), + })); + return; + } + + const nextQuantity = Number(draftValue); + + if (Number.isNaN(nextQuantity)) { + setQuantityDrafts((currentDrafts) => ({ + ...currentDrafts, + [item.cart_item_id]: String(item.quantity), + })); + return; + } + + handleChangeQuantity(item, nextQuantity); + }; + + if (!userId) { + return ( +
+
+

Shopping Cart

+

장바구니

+

로그인 후 장바구니에 담은 상품을 확인할 수 있습니다.

+
+ +
+ 장바구니를 확인하려면 로그인이 필요합니다. +
+ + 로그인 + + + 회원가입 + +
+
+
+ ); + } + + if (!storedCartId) { + return ( +
+
+

Shopping Cart

+

장바구니

+

아직 장바구니가 생성되지 않았습니다. 상품을 먼저 담아보세요.

+
+ +
+ 장바구니에 담긴 상품이 없습니다. +
+ + 상품 둘러보기 + +
+
+
+ ); + } + return ( -
-

장바구니

-

D2C-37에서 장바구니 조회, 상품 제거, 체크아웃 진입 흐름을 구현합니다.

+
+
+
+

Shopping Cart

+

장바구니

+

장바구니에 담은 상품을 확인하고, 주문 단계로 이동할 수 있습니다.

+
+
+ + {isLoading ? ( +
장바구니 정보를 불러오는 중입니다.
+ ) : errorMessage ? ( +
{errorMessage}
+ ) : cartItems.length === 0 ? ( +
+ {actionMessage ?? "장바구니에 담긴 상품이 없습니다."} +
+ + 상품 둘러보기 + +
+
+ ) : ( +
+
+ {actionMessage &&
{actionMessage}
} + {actionErrorMessage &&
{actionErrorMessage}
} + + {cartItems.map((item) => { + const lineAmount = getCartItemLineAmount(item); + const isItemBusy = + removingItemId === item.cart_item_id || + updatingItemId === item.cart_item_id; + + return ( +
+
+ {item.brand_name ?? "D2C"} +
+ +
+
+ {item.brand_name || "브랜드 미지정"} +
+ +

{item.product_name ?? item.product_id}

+ +
+ 단가 {formatPrice(item.unit_price, item.currency)} + {formatPrice(lineAmount, item.currency)} +
+ +
+ 수량 + +
+ + + { + const nextQuantity = Number(event.target.value); + + if (Number.isNaN(nextQuantity)) { + event.target.value = String(item.quantity); + return; + } + + handleChangeQuantity(item, nextQuantity); + }} + /> + + +
+
+
+ + +
+ ); + })} +
+ + +
+ )}
); -} \ No newline at end of file +} diff --git a/apps/web/src/features/products/ProductListPage.tsx b/apps/web/src/features/products/ProductListPage.tsx index e922dac..b0bb373 100644 --- a/apps/web/src/features/products/ProductListPage.tsx +++ b/apps/web/src/features/products/ProductListPage.tsx @@ -1,10 +1,25 @@ -import { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + type ChangeEvent, + type MouseEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { Link } from "react-router-dom"; +import { addCartItem, createCart } from "../../services/cartApi"; import { getCategories, getProducts } from "../../services/catalogApi"; +import { + getStoredCartId, + getStoredUser, + setStoredCartId, +} from "../../stores/userStore"; import type { Category, Product } from "../../types/catalog"; const PAGE_SIZE_OPTIONS = [12, 24, 48] as const; const DEFAULT_PAGE_SIZE = 24; +const QUICK_ADD_QUANTITY = 1; function formatPrice(value: string | number, currency: string) { const numericValue = Number(value); @@ -35,6 +50,9 @@ export function ProductListPage() { const [currentPage, setCurrentPage] = useState(1); const [isLoading, setIsLoading] = useState(true); const [errorMessage, setErrorMessage] = useState(null); + const [cartFeedbackMessage, setCartFeedbackMessage] = useState(null); + const [cartErrorMessage, setCartErrorMessage] = useState(null); + const [addingProductId, setAddingProductId] = useState(null); const productListTopRef = useRef(null); @@ -103,6 +121,8 @@ export function ProductListPage() { const handleCategorySelect = (categoryId: string) => { setSelectedCategoryId(categoryId); + setCartFeedbackMessage(null); + setCartErrorMessage(null); }; const handlePageSizeChange = (event: ChangeEvent) => { @@ -121,6 +141,49 @@ export function ProductListPage() { scrollToProductListTop(); }; + const handleQuickAddToCart = async ( + event: MouseEvent, + product: Product, + ) => { + event.preventDefault(); + event.stopPropagation(); + + const user = getStoredUser(); + + if (!user) { + setCartFeedbackMessage(null); + setCartErrorMessage("로그인 후 장바구니에 상품을 담을 수 있습니다."); + return; + } + + try { + setAddingProductId(product.product_id); + setCartFeedbackMessage(null); + setCartErrorMessage(null); + + let cartId = getStoredCartId(); + + if (!cartId) { + const createdCart = await createCart({ + user_id: user.user_id, + }); + + cartId = createdCart.cart_id; + setStoredCartId(cartId); + } + + await addCartItem(cartId, { + product_id: product.product_id, + quantity: QUICK_ADD_QUANTITY, + }); + + } catch { + setCartErrorMessage("장바구니 담기에 실패했습니다. 잠시 후 다시 시도해주세요."); + } finally { + setAddingProductId(null); + } + }; + return (
@@ -128,8 +191,8 @@ export function ProductListPage() {

Product Catalog

카테고리별 상품을 탐색해보세요.

- 상품 목록에서 관심 상품을 선택하면 상세 화면으로 이동하여 장바구니 담기 - 흐름을 이어갈 수 있습니다. + 상품 목록에서 관심 상품을 선택하면 상세 화면으로 이동하거나 바로 장바구니에 + 담아 구매 흐름을 이어갈 수 있습니다.

@@ -182,6 +245,13 @@ export function ProductListPage() { + {(cartFeedbackMessage || cartErrorMessage) && ( +
+ {cartFeedbackMessage &&
{cartFeedbackMessage}
} + {cartErrorMessage &&
{cartErrorMessage}
} +
+ )} + {isLoading ? (
상품 목록을 불러오는 중입니다.
) : errorMessage ? ( @@ -191,33 +261,50 @@ export function ProductListPage() { ) : ( <>
- {paginatedProducts.map((product) => ( - -
- {product.brand_name ?? "D2C"} -
- -
-
- {product.brand_name ?? "브랜드 미지정"} - {product.product_status} + {paginatedProducts.map((product) => { + const isAddingCurrentProduct = addingProductId === product.product_id; + const isDiscounted = Number(product.list_price) !== Number(product.sale_price); + + return ( + +
+ {product.brand_name ?? "D2C"}
-

{product.product_name}

- -
- {formatPrice(product.sale_price, product.currency)} - {Number(product.list_price) !== Number(product.sale_price) && ( - {formatPrice(product.list_price, product.currency)} - )} +
+
+ {product.brand_name || "브랜드 미지정"} + {product.product_status} +
+ +

{product.product_name}

+ +
+
+ {formatPrice(product.sale_price, product.currency)} + {isDiscounted && ( + {formatPrice(product.list_price, product.currency)} + )} +
+ + +
-
- - ))} + + ); + })}
diff --git a/apps/web/src/services/cartApi.ts b/apps/web/src/services/cartApi.ts index 3896370..12ac5ee 100644 --- a/apps/web/src/services/cartApi.ts +++ b/apps/web/src/services/cartApi.ts @@ -1,5 +1,5 @@ import { apiClient } from "./apiClient"; -import type { Cart, CartItem } from "../types/cart"; +import type { Cart, CartDetail, CartItem } from "../types/cart"; type CreateCartRequest = { user_id: string; @@ -17,9 +17,32 @@ export function createCart(payload: CreateCartRequest) { }); } +export function getCart(cartId: string) { + return apiClient(`/carts/${cartId}`); +} + export function addCartItem(cartId: string, payload: AddCartItemRequest) { return apiClient(`/carts/${cartId}/items`, { method: "POST", body: payload, }); +} + +export function updateCartItemQuantity( + cartId: string, + cartItemId: string, + quantity: number, +) { + return apiClient(`/carts/${cartId}/items/${cartItemId}`, { + method: "PATCH", + body: { + quantity, + }, + }); +} + +export function removeCartItem(cartId: string, cartItemId: string) { + return apiClient<{ message: string }>(`/carts/${cartId}/items/${cartItemId}`, { + method: "DELETE", + }); } \ No newline at end of file diff --git a/apps/web/src/styles/global.css b/apps/web/src/styles/global.css index 075a1cd..977ef8b 100644 --- a/apps/web/src/styles/global.css +++ b/apps/web/src/styles/global.css @@ -136,6 +136,29 @@ textarea { background-color: #374151; } +.primary-button { + display: inline-flex; + min-height: 44px; + align-items: center; + justify-content: center; + padding: 0 18px; + border: 0; + border-radius: 10px; + color: #ffffff; + background-color: #111827; + font-weight: 700; + cursor: pointer; +} + +.primary-button:hover:not(:disabled) { + background-color: #374151; +} + +.primary-button:disabled { + background-color: #9ca3af; + cursor: not-allowed; +} + /* Generic placeholder page */ .page-section { padding: 32px; @@ -171,6 +194,27 @@ textarea { text-transform: uppercase; } +.state-box { + padding: 32px; + border: 1px solid #e5e7eb; + border-radius: 18px; + color: #4b5563; + background-color: #ffffff; + text-align: center; +} + +.state-box.error { + color: #991b1b; + border-color: #fecaca; + background-color: #fef2f2; +} + +.state-box.success { + color: #166534; + border-color: #bbf7d0; + background-color: #f0fdf4; +} + /* Home page */ .home-page { display: flex; @@ -400,6 +444,12 @@ textarea { font-size: 14px; } +.product-list-feedback { + display: flex; + flex-direction: column; + gap: 10px; +} + .page-size-control { display: inline-flex; align-items: center; @@ -508,6 +558,42 @@ textarea { text-decoration: line-through; } +.product-card-footer { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 12px; + margin-top: auto; +} + +.product-card-footer .product-price-row { + margin-top: 0; +} + +.product-card-cart-button { + flex-shrink: 0; + min-height: 34px; + padding: 0 12px; + border: 1px solid #111827; + border-radius: 999px; + color: #ffffff; + background-color: #111827; + font-size: 13px; + font-weight: 700; + cursor: pointer; +} + +.product-card-cart-button:hover:not(:disabled) { + background-color: #374151; +} + +.product-card-cart-button:disabled { + border-color: #d1d5db; + color: #9ca3af; + background-color: #f3f4f6; + cursor: not-allowed; +} + .pagination { display: flex; align-items: center; @@ -543,21 +629,6 @@ textarea { text-align: center; } -.state-box { - padding: 32px; - border: 1px solid #e5e7eb; - border-radius: 18px; - color: #4b5563; - background-color: #ffffff; - text-align: center; -} - -.state-box.error { - color: #991b1b; - border-color: #fecaca; - background-color: #fef2f2; -} - /* Product Detail Page */ .product-detail-page { display: flex; @@ -699,51 +770,267 @@ textarea { text-align: center; } -.primary-button { - display: inline-flex; - min-height: 44px; +.product-detail-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: auto; +} + +.product-detail-notice { + text-align: left; +} + +.product-detail-notice-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 16px; +} + +/* Cart Page */ +.cart-page { + display: flex; + flex-direction: column; + gap: 24px; +} + +.cart-header { + padding: 32px; + border: 1px solid #e5e7eb; + border-radius: 24px; + background-color: #ffffff; +} + +.cart-header h1 { + margin: 0 0 12px; + color: #111827; + font-size: clamp(30px, 4vw, 42px); + line-height: 1.18; + letter-spacing: -0.04em; +} + +.cart-header p { + max-width: 680px; + margin: 0; + color: #4b5563; + line-height: 1.7; + word-break: keep-all; +} + +.cart-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 340px; + gap: 20px; + align-items: flex-start; +} + +.cart-items { + display: flex; + flex-direction: column; + gap: 14px; +} + +.cart-item-card { + display: grid; + grid-template-columns: 120px minmax(0, 1fr) auto; + gap: 18px; + align-items: center; + padding: 18px; + border: 1px solid #e5e7eb; + border-radius: 18px; + background-color: #ffffff; +} + +.cart-item-image { + display: flex; + min-height: 96px; align-items: center; justify-content: center; - padding: 0 18px; - border: 0; + border-radius: 14px; + background: + radial-gradient(circle at top right, rgba(17, 24, 39, 0.1), transparent 32%), + #f3f4f6; +} + +.cart-item-image span { + color: #6b7280; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.cart-item-info { + display: flex; + min-width: 0; + flex-direction: column; + gap: 10px; +} + +.cart-item-meta { + display: flex; + flex-wrap: wrap; + gap: 10px; + color: #6b7280; + font-size: 12px; +} + +.cart-item-info h2 { + margin: 0; + color: #111827; + font-size: 18px; + line-height: 1.45; + letter-spacing: -0.02em; + word-break: keep-all; +} + +.cart-item-price { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: baseline; +} + +.cart-item-price span { + color: #6b7280; + font-size: 14px; +} + +.cart-item-price strong { + color: #111827; + font-size: 17px; +} + +.cart-remove-button { + min-height: 38px; + padding: 0 14px; + border: 1px solid #d1d5db; border-radius: 10px; - color: #ffffff; - background-color: #111827; - font-weight: 700; + color: #991b1b; + background-color: #ffffff; cursor: pointer; } -.primary-button:hover:not(:disabled) { - background-color: #374151; +.cart-remove-button:hover:not(:disabled) { + border-color: #991b1b; + background-color: #fef2f2; } -.primary-button:disabled { - background-color: #9ca3af; +.cart-remove-button:disabled { + color: #9ca3af; + background-color: #f3f4f6; cursor: not-allowed; } -.product-detail-actions { +.cart-summary-card { + position: sticky; + top: 92px; display: flex; - flex-wrap: wrap; - gap: 10px; - margin-top: auto; + flex-direction: column; + gap: 16px; + padding: 22px; + border: 1px solid #e5e7eb; + border-radius: 20px; + background-color: #ffffff; } -.product-detail-notice { - text-align: left; +.cart-summary-card h2 { + margin: 0; + color: #111827; + font-size: 20px; + letter-spacing: -0.03em; } -.product-detail-notice-actions { +.cart-summary-row, +.cart-summary-total { + display: flex; + justify-content: space-between; + gap: 16px; +} + +.cart-summary-row { + color: #4b5563; + font-size: 14px; +} + +.cart-summary-total { + padding-top: 16px; + border-top: 1px solid #e5e7eb; +} + +.cart-summary-total span { + color: #111827; + font-weight: 700; +} + +.cart-summary-total strong { + color: #111827; + font-size: 20px; +} + +.cart-checkout-link, +.cart-continue-link { + width: 100%; +} + +.cart-empty-actions { display: flex; flex-wrap: wrap; + justify-content: center; gap: 10px; - margin-top: 16px; + margin-top: 18px; } -.state-box.success { - color: #166534; - border-color: #bbf7d0; - background-color: #f0fdf4; +.cart-item-quantity-control { + display: flex; + align-items: center; + gap: 12px; + margin-top: 2px; +} + +.cart-item-quantity-control > span { + color: #374151; + font-size: 14px; + font-weight: 700; +} + +.cart-quantity-stepper { + display: inline-flex; + overflow: hidden; + border: 1px solid #d1d5db; + border-radius: 10px; + background-color: #ffffff; +} + +.cart-quantity-stepper button { + width: 36px; + border: 0; + color: #111827; + background-color: #ffffff; + cursor: pointer; +} + +.cart-quantity-stepper button:hover:not(:disabled) { + background-color: #f3f4f6; +} + +.cart-quantity-stepper button:disabled { + color: #9ca3af; + cursor: not-allowed; +} + +.cart-quantity-stepper input { + width: 56px; + border: 0; + border-right: 1px solid #e5e7eb; + border-left: 1px solid #e5e7eb; + text-align: center; +} + +.cart-quantity-stepper input:disabled { + color: #9ca3af; + background-color: #f9fafb; } /* Auth pages */ @@ -849,53 +1136,6 @@ textarea { font-weight: 700; } -@media (max-width: 900px) { - .product-detail-layout { - grid-template-columns: 1fr; - } - - .product-detail-image { - min-height: 360px; - } -} - -@media (max-width: 560px) { - .product-detail-info { - padding: 24px; - } - - .quantity-control { - align-items: flex-start; - flex-direction: column; - } - - .quantity-stepper { - width: 100%; - } - - .quantity-stepper input { - flex: 1; - } - - .product-detail-actions { - flex-direction: column; - } - - .product-detail-actions .primary-button, - .product-detail-actions .secondary-link { - width: 100%; - } - - .auth-card { - padding: 26px 22px; - } - - .auth-footer { - align-items: center; - flex-direction: column; - } -} - /* Responsive layout */ @media (max-width: 1080px) { .product-grid { @@ -918,6 +1158,21 @@ textarea { } } +@media (max-width: 900px) { + .product-detail-layout, + .cart-layout { + grid-template-columns: 1fr; + } + + .product-detail-image { + min-height: 360px; + } + + .cart-summary-card { + position: static; + } +} + @media (max-width: 820px) { .product-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -933,7 +1188,8 @@ textarea { padding: 40px 24px; } - .home-grid { + .home-grid, + .product-grid { grid-template-columns: 1fr; } @@ -950,15 +1206,77 @@ textarea { .page-size-control select { flex: 1; } -} -@media (max-width: 560px) { - .product-grid { + .cart-item-card { grid-template-columns: 1fr; } + .cart-item-image { + min-height: 140px; + } + + .cart-remove-button { + width: 100%; + } + + .cart-item-quantity-control { + align-items: flex-start; + flex-direction: column; + } + + .cart-quantity-stepper { + width: 100%; + } + + .cart-quantity-stepper input { + flex: 1; + } +} + +@media (max-width: 560px) { .product-list-summary { align-items: flex-start; flex-direction: column; } + + .product-card-footer { + align-items: stretch; + flex-direction: column; + } + + .product-card-cart-button { + width: 100%; + } + + .product-detail-info, + .auth-card { + padding: 24px; + } + + .quantity-control { + align-items: flex-start; + flex-direction: column; + } + + .quantity-stepper { + width: 100%; + } + + .quantity-stepper input { + flex: 1; + } + + .product-detail-actions { + flex-direction: column; + } + + .product-detail-actions .primary-button, + .product-detail-actions .secondary-link { + width: 100%; + } + + .auth-footer { + align-items: center; + flex-direction: column; + } } diff --git a/apps/web/src/types/cart.ts b/apps/web/src/types/cart.ts index cd98410..efcc388 100644 --- a/apps/web/src/types/cart.ts +++ b/apps/web/src/types/cart.ts @@ -12,7 +12,15 @@ export type CartItem = { cart_item_id: string; cart_id: string; product_id: string; + product_name?: string; + brand_name?: string | null; quantity: number; unit_price: string | number; + line_amount?: string | number; currency: string; +}; + +export type CartDetail = Cart & { + items?: CartItem[]; + cart_items?: CartItem[]; }; \ No newline at end of file