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/api/routes/checkout.py b/apps/api/backend/api/routes/checkout.py index be1ea07..bb8d563 100644 --- a/apps/api/backend/api/routes/checkout.py +++ b/apps/api/backend/api/routes/checkout.py @@ -5,11 +5,11 @@ from backend.schemas.checkout import CheckoutResponse from backend.services.checkout_service import prepare_checkout -router = APIRouter(prefix="/carts", tags=["checkout"]) +router = APIRouter(prefix="/checkout", tags=["checkout"]) -@router.post( - "/{cart_id}/checkout", +@router.get( + "/{cart_id}", response_model=CheckoutResponse, status_code=status.HTTP_200_OK, ) diff --git a/apps/api/backend/api/routes/coupon_apply.py b/apps/api/backend/api/routes/coupon_apply.py index ffd504a..e84e0d9 100644 --- a/apps/api/backend/api/routes/coupon_apply.py +++ b/apps/api/backend/api/routes/coupon_apply.py @@ -5,11 +5,11 @@ from backend.schemas.coupon_apply import CouponApplyRequest, CouponApplyResponse from backend.services.coupon_service import apply_coupon_to_cart -router = APIRouter(prefix="/carts", tags=["checkout"]) +router = APIRouter(prefix="/carts", tags=["coupons"]) @router.post( - "/{cart_id}/coupon", + "/{cart_id}/apply-coupon", response_model=CouponApplyResponse, status_code=status.HTTP_200_OK, ) 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..06ef466 100644 --- a/apps/api/backend/schemas/cart_item.py +++ b/apps/api/backend/schemas/cart_item.py @@ -7,7 +7,7 @@ class CartItemCreateRequest(BaseModel): product_id: UUID - quantity: int = Field(gt=0, le=100) + quantity: int = Field(ge=1, le=99) class CartItemResponse(BaseModel): @@ -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=99) \ No newline at end of file diff --git a/apps/api/backend/schemas/checkout.py b/apps/api/backend/schemas/checkout.py index 7cbea01..893ef8d 100644 --- a/apps/api/backend/schemas/checkout.py +++ b/apps/api/backend/schemas/checkout.py @@ -9,6 +9,7 @@ class CheckoutCartItemResponse(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/services/cart_item_service.py b/apps/api/backend/services/cart_item_service.py index e0a1bb3..f00e583 100644 --- a/apps/api/backend/services/cart_item_service.py +++ b/apps/api/backend/services/cart_item_service.py @@ -5,8 +5,12 @@ 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, +) +MAX_CART_ITEM_QUANTITY = 99 def add_item_to_cart(cart_id: UUID, payload: CartItemCreateRequest) -> dict[str, Any]: cart_query = text(""" @@ -132,6 +136,14 @@ def add_item_to_cart(cart_id: UUID, payload: CartItemCreateRequest) -> dict[str, ).mappings().first() if existing_item is not None: + next_quantity = existing_item["quantity"] + payload.quantity + + if next_quantity > MAX_CART_ITEM_QUANTITY: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cart item quantity cannot exceed 99", + ) + updated_item = connection.execute( update_item_query, { @@ -235,4 +247,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/api/backend/services/checkout_service.py b/apps/api/backend/services/checkout_service.py index 2bf1962..dd7ddf2 100644 --- a/apps/api/backend/services/checkout_service.py +++ b/apps/api/backend/services/checkout_service.py @@ -34,6 +34,7 @@ def prepare_checkout(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, @@ -109,6 +110,7 @@ def prepare_checkout(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/api/backend/services/coupon_service.py b/apps/api/backend/services/coupon_service.py index 25329cc..6c8c25d 100644 --- a/apps/api/backend/services/coupon_service.py +++ b/apps/api/backend/services/coupon_service.py @@ -13,6 +13,7 @@ def apply_coupon_to_cart(cart_id: UUID, payload: CouponApplyRequest) -> dict[str cart_query = text(""" SELECT c.cart_id, + c.user_id, c.cart_status FROM carts c WHERE c.cart_id = :cart_id @@ -47,6 +48,16 @@ def apply_coupon_to_cart(cart_id: UUID, payload: CouponApplyRequest) -> dict[str LIMIT 1 """) + used_coupon_query = text(""" + SELECT + order_id + FROM orders + WHERE user_id = :user_id + AND coupon_id = :coupon_id + AND order_status = 'paid' + LIMIT 1 + """) + with engine.connect() as connection: cart = connection.execute( cart_query, @@ -80,6 +91,20 @@ def apply_coupon_to_cart(cart_id: UUID, payload: CouponApplyRequest) -> dict[str status_code=status.HTTP_404_NOT_FOUND, detail="Coupon not found", ) + + used_coupon = connection.execute( + used_coupon_query, + { + "user_id": cart["user_id"], + "coupon_id": coupon["coupon_id"], + }, + ).mappings().first() + + if used_coupon is not None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Coupon has already been used", + ) total_amount = Decimal(str(total_row["total_amount"])) currency = total_row["currency"] diff --git a/apps/api/backend/services/order_service.py b/apps/api/backend/services/order_service.py index aae23ec..be13a6b 100644 --- a/apps/api/backend/services/order_service.py +++ b/apps/api/backend/services/order_service.py @@ -55,6 +55,16 @@ def create_order_from_cart(payload: OrderCreateRequest) -> dict[str, Any]: LIMIT 1 """) + used_coupon_query = text(""" + SELECT + order_id + FROM orders + WHERE user_id = :user_id + AND coupon_id = :coupon_id + AND order_status = 'paid' + LIMIT 1 + """) + insert_order_query = text(""" INSERT INTO orders ( user_id, @@ -129,15 +139,6 @@ def create_order_from_cart(payload: OrderCreateRequest) -> dict[str, Any]: currency """) - update_cart_query = text(""" - UPDATE carts - SET - cart_status = 'checked_out', - checked_out_at = CURRENT_TIMESTAMP, - updated_at = CURRENT_TIMESTAMP - WHERE cart_id = :cart_id - """) - with engine.begin() as connection: cart = connection.execute( cart_query, @@ -200,6 +201,20 @@ def create_order_from_cart(payload: OrderCreateRequest) -> dict[str, Any]: detail="Coupon not found", ) + used_coupon = connection.execute( + used_coupon_query, + { + "user_id": cart["user_id"], + "coupon_id": coupon["coupon_id"], + }, + ).mappings().first() + + if used_coupon is not None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Coupon has already been used", + ) + minimum_order_amount = Decimal(str(coupon["minimum_order_amount"])) discount_value = Decimal(str(coupon["discount_value"])) @@ -283,11 +298,6 @@ def create_order_from_cart(payload: OrderCreateRequest) -> dict[str, Any]: } ) - connection.execute( - update_cart_query, - {"cart_id": payload.cart_id}, - ) - return { "order_id": created_order["order_id"], "user_id": created_order["user_id"], diff --git a/apps/api/backend/services/payment_service.py b/apps/api/backend/services/payment_service.py index d32905a..f8fef6b 100644 --- a/apps/api/backend/services/payment_service.py +++ b/apps/api/backend/services/payment_service.py @@ -13,6 +13,7 @@ def simulate_payment(payload: PaymentSimulationRequest) -> dict[str, Any]: order_query = text(""" SELECT order_id, + cart_id, order_status, total_amount, currency @@ -81,6 +82,16 @@ def simulate_payment(payload: PaymentSimulationRequest) -> dict[str, Any]: WHERE order_id = :order_id """) + update_cart_checked_out_query = text(""" + UPDATE carts + SET + cart_status = 'checked_out', + checked_out_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE cart_id = :cart_id + AND cart_status = 'active' + """) + with engine.begin() as connection: order = connection.execute( order_query, @@ -109,10 +120,16 @@ def simulate_payment(payload: PaymentSimulationRequest) -> dict[str, Any]: paid_at = datetime.now() pg_provider = "mock_pg" transaction_id = f"tx-{payload.order_id}" + connection.execute( update_order_success_query, {"order_id": payload.order_id}, ) + + connection.execute( + update_cart_checked_out_query, + {"cart_id": order["cart_id"]}, + ) else: payment_status = "failed" paid_amount = Decimal("0") diff --git a/apps/api/tests/test_checkout.py b/apps/api/tests/test_checkout.py index 5f5f04d..37a7086 100644 --- a/apps/api/tests/test_checkout.py +++ b/apps/api/tests/test_checkout.py @@ -36,7 +36,7 @@ def test_checkout_returns_200_for_valid_cart() -> None: cart_id = _create_cart(user_id) _add_item(cart_id, "33333333-3333-3333-3333-000000000001", 2) - response = client.post(f"/carts/{cart_id}/checkout") + response = client.get(f"/checkout/{cart_id}") assert response.status_code == 200 @@ -47,7 +47,7 @@ def test_checkout_returns_expected_fields() -> None: cart_id = _create_cart(user_id) _add_item(cart_id, "33333333-3333-3333-3333-000000000001", 2) - response = client.post(f"/carts/{cart_id}/checkout") + response = client.get(f"/checkout/{cart_id}") data = response.json() assert "cart_id" in data @@ -74,14 +74,14 @@ def test_checkout_returns_400_for_empty_cart() -> None: user_id = _signup_user(f"checkout_empty_{unique_suffix}@example.com") cart_id = _create_cart(user_id) - response = client.post(f"/carts/{cart_id}/checkout") + response = client.get(f"/checkout/{cart_id}") assert response.status_code == 400 assert response.json()["detail"] == "Cart is empty" def test_checkout_returns_404_for_missing_cart() -> None: - response = client.post("/carts/99999999-9999-9999-9999-999999999999/checkout") + response = client.get("/checkout/99999999-9999-9999-9999-999999999999") assert response.status_code == 404 assert response.json()["detail"] == "Active cart not found" \ No newline at end of file diff --git a/apps/api/tests/test_coupon_apply.py b/apps/api/tests/test_coupon_apply.py index e4825e7..221d092 100644 --- a/apps/api/tests/test_coupon_apply.py +++ b/apps/api/tests/test_coupon_apply.py @@ -37,7 +37,7 @@ def test_apply_coupon_returns_200_for_valid_coupon() -> None: _add_item(cart_id, "33333333-3333-3333-3333-000000000001", 2) response = client.post( - f"/carts/{cart_id}/coupon", + f"/carts/{cart_id}/apply-coupon", json={"coupon_name": "WELCOME10"}, ) @@ -51,7 +51,7 @@ def test_apply_coupon_returns_expected_fields() -> None: _add_item(cart_id, "33333333-3333-3333-3333-000000000001", 2) response = client.post( - f"/carts/{cart_id}/coupon", + f"/carts/{cart_id}/apply-coupon", json={"coupon_name": "WELCOME10"}, ) data = response.json() @@ -75,7 +75,7 @@ def test_apply_coupon_returns_404_for_missing_coupon() -> None: _add_item(cart_id, "33333333-3333-3333-3333-000000000001", 2) response = client.post( - f"/carts/{cart_id}/coupon", + f"/carts/{cart_id}/apply-coupon", json={"coupon_name": "NOT_EXIST_COUPON"}, ) @@ -89,7 +89,7 @@ def test_apply_coupon_returns_400_for_empty_cart() -> None: cart_id = _create_cart(user_id) response = client.post( - f"/carts/{cart_id}/coupon", + f"/carts/{cart_id}/apply-coupon", json={"coupon_name": "WELCOME10"}, ) diff --git a/apps/web/src/features/cart/CartPage.tsx b/apps/web/src/features/cart/CartPage.tsx index 3ade2f3..70ee303 100644 --- a/apps/web/src/features/cart/CartPage.tsx +++ b/apps/web/src/features/cart/CartPage.tsx @@ -1,8 +1,405 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Link } from "react-router-dom"; +import { + getCart, + removeCartItem, + updateCartItemQuantity, +} from "../../services/cartApi"; +import { + clearStoredCartId, + 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 { + clearStoredCartId(); + setCart(null); + setErrorMessage(null); + } 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/checkout/CheckoutPage.tsx b/apps/web/src/features/checkout/CheckoutPage.tsx index e4dd675..a0ca8ee 100644 --- a/apps/web/src/features/checkout/CheckoutPage.tsx +++ b/apps/web/src/features/checkout/CheckoutPage.tsx @@ -1,8 +1,499 @@ +import { type ChangeEvent, useEffect, useMemo, useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { + applyCoupon, + createOrder, + enterCheckout, + simulatePayment, +} from "../../services/checkoutApi"; +import { + clearStoredCartId, + getStoredCartId, + getStoredUser, +} from "../../stores/userStore"; +import type { + CheckoutSummary, + CouponApplyResponse, + OrderCreateResponse, + PaymentSimulationResponse, +} from "../../types/checkout"; +import { ApiError } from "../../services/apiClient"; + +const PAYMENT_METHOD = "card"; + +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 getCheckoutItemLineAmount(item: CheckoutSummary["items"][number]) { + const explicitLineAmount = item.line_total ?? item.line_amount; + + if (explicitLineAmount !== undefined) { + return explicitLineAmount; + } + + const calculatedAmount = Number(item.unit_price) * Number(item.quantity); + + return Number.isNaN(calculatedAmount) ? 0 : calculatedAmount; +} + +function getCheckoutItemsTotalAmount(items: CheckoutSummary["items"]) { + return items.reduce((sum, item) => { + const lineAmount = Number(getCheckoutItemLineAmount(item)); + + return Number.isNaN(lineAmount) ? sum : sum + lineAmount; + }, 0); +} + +function getAppliedCouponName( + appliedCoupon: CouponApplyResponse | null, + fallbackCouponCode: string, +) { + return ( + appliedCoupon?.coupon?.coupon_name ?? + appliedCoupon?.coupon_name ?? + appliedCoupon?.coupon_code ?? + fallbackCouponCode + ); +} + export function CheckoutPage() { + const navigate = useNavigate(); + + const storedUser = getStoredUser(); + const userId = storedUser?.user_id ?? null; + const [checkoutCartId, setCheckoutCartId] = useState(() => + getStoredCartId(), + ); + + const [checkout, setCheckout] = useState(null); + const [couponCode, setCouponCode] = useState(""); + const [appliedCoupon, setAppliedCoupon] = useState(null); + const [createdOrder, setCreatedOrder] = useState(null); + const [paymentResult, setPaymentResult] = useState( + null, + ); + + const [isLoading, setIsLoading] = useState(Boolean(userId && checkoutCartId)); + const [isApplyingCoupon, setIsApplyingCoupon] = useState(false); + const [isCreatingOrder, setIsCreatingOrder] = useState(false); + const [isPaying, setIsPaying] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + const [actionMessage, setActionMessage] = useState(null); + const [actionMessageType, setActionMessageType] = useState<"success" | "error">( + "success", + ); + + const currency = checkout?.currency ?? appliedCoupon?.currency ?? "KRW"; + + const originalAmount = useMemo(() => { + return ( + appliedCoupon?.original_amount ?? + checkout?.original_amount ?? + checkout?.total_amount ?? + (checkout ? getCheckoutItemsTotalAmount(checkout.items) : 0) + ); + }, [appliedCoupon, checkout]); + + const discountAmount = useMemo(() => { + return appliedCoupon?.discount_amount ?? checkout?.discount_amount ?? 0; + }, [appliedCoupon, checkout]); + + const finalAmount = useMemo(() => { + const couponFinalAmount = appliedCoupon?.final_amount ?? checkout?.final_amount; + + if (couponFinalAmount !== undefined) { + return couponFinalAmount; + } + + return Number(originalAmount) - Number(discountAmount); + }, [appliedCoupon, checkout, originalAmount, discountAmount]); + + useEffect(() => { + async function loadCheckout() { + if (!userId || !checkoutCartId) { + setIsLoading(false); + return; + } + + try { + setIsLoading(true); + setErrorMessage(null); + + const checkoutData = await enterCheckout(checkoutCartId); + setCheckout(checkoutData); + } catch { + clearStoredCartId(); + setCheckout(null); + setErrorMessage( + "장바구니 정보를 찾을 수 없습니다. 상품을 다시 장바구니에 담아주세요.", + ); + } finally { + setIsLoading(false); + } + } + + loadCheckout(); + }, [checkoutCartId, userId]); + + const handleCouponCodeChange = (event: ChangeEvent) => { + setCouponCode(event.target.value); + }; + + const handleApplyCoupon = async () => { + const normalizedCouponCode = couponCode.trim(); + + setActionMessage(null); + setErrorMessage(null); + + if (!checkoutCartId || !normalizedCouponCode) { + setErrorMessage("쿠폰 코드를 입력해주세요."); + return; + } + + const appliedCouponName = getAppliedCouponName(appliedCoupon, ""); + + if ( + appliedCouponName && + appliedCouponName.toLowerCase() === normalizedCouponCode.toLowerCase() + ) { + setErrorMessage("이미 사용된 쿠폰입니다."); + return; + } + + try { + setIsApplyingCoupon(true); + + const couponResult = await applyCoupon(checkoutCartId, normalizedCouponCode); + + setAppliedCoupon(couponResult); + setCouponCode(""); + setActionMessageType("success"); + setActionMessage("쿠폰이 적용되었습니다."); + } catch (error) { + setActionMessage(null); + + if ( + error instanceof ApiError && + error.detail === "Coupon has already been used" + ) { + setErrorMessage("이미 사용된 쿠폰입니다."); + } else { + setErrorMessage("쿠폰 적용에 실패했습니다. 쿠폰 코드를 확인해주세요."); + } + } finally { + setIsApplyingCoupon(false); + } + }; + + const handleCreateOrder = async () => { + if (!checkoutCartId) { + setErrorMessage("장바구니 정보를 확인할 수 없습니다."); + return; + } + + try { + setIsCreatingOrder(true); + setErrorMessage(null); + setActionMessage(null); + setPaymentResult(null); + + const order = await createOrder({ + cart_id: checkoutCartId, + coupon_name: getAppliedCouponName(appliedCoupon, "") || null, + }); + + setCreatedOrder(order); + setActionMessageType("success"); + setActionMessage("주문이 생성되었습니다. 결제 시뮬레이션을 진행할 수 있습니다."); + } catch (error) { + setActionMessage(null); + + if ( + error instanceof ApiError && + error.detail === "Coupon has already been used" + ) { + setErrorMessage("이미 사용된 쿠폰입니다."); + } else { + setErrorMessage("주문 생성에 실패했습니다. 장바구니 상태를 확인해주세요."); + } + } finally { + setIsCreatingOrder(false); + } + }; + + const handleSimulatePayment = async (simulateResult: "success" | "failed") => { + if (!createdOrder) { + setErrorMessage("먼저 주문을 생성해주세요."); + return; + } + + try { + setIsPaying(true); + setErrorMessage(null); + setActionMessage(null); + + const result = await simulatePayment({ + order_id: createdOrder.order_id, + payment_method: PAYMENT_METHOD, + simulate_result: simulateResult, + }); + + setPaymentResult(result); + + if (result.payment_status === "paid") { + clearStoredCartId(); + setActionMessageType("success"); + setActionMessage("결제 성공 시뮬레이션이 완료되었습니다."); + } else { + setActionMessageType("error") + setActionMessage("결제 실패 시뮬레이션이 완료되었습니다."); + } + } catch { + setErrorMessage("결제 시뮬레이션에 실패했습니다."); + } finally { + setIsPaying(false); + } + }; + + if (!userId) { + return ( +
+
+

Checkout

+

체크아웃

+

로그인 후 주문 단계를 진행할 수 있습니다.

+
+ +
+ 체크아웃을 진행하려면 로그인이 필요합니다. +
+ + 로그인 + + + 회원가입 + +
+
+
+ ); + } + + if (!checkoutCartId) { + return ( +
+
+

Checkout

+

체크아웃

+

주문할 장바구니를 찾을 수 없습니다.

+
+ +
+ 장바구니에 상품을 먼저 담아주세요. +
+ + 상품 둘러보기 + +
+
+
+ ); + } + return ( -
-

체크아웃

-

D2C-38에서 쿠폰 적용, 주문 생성, 결제 시뮬레이션 흐름을 구현합니다.

+
+
+

Checkout

+

주문 및 결제 시뮬레이션

+

+ 장바구니 상품을 확인하고, 쿠폰 적용 후 주문 생성과 결제 성공/실패 흐름을 + 검증합니다. +

+
+ + {isLoading ? ( +
체크아웃 정보를 불러오는 중입니다.
+ ) : errorMessage && !checkout ? ( +
{errorMessage}
+ ) : !checkout || checkout.items.length === 0 ? ( +
+ 주문할 상품이 없습니다. +
+ + 상품 둘러보기 + +
+
+ ) : ( +
+
+ {actionMessage && ( +
+ {actionMessage} +
+ )} + {errorMessage &&
{errorMessage}
} + +
+

주문 상품

+ +
+ {checkout.items.map((item) => ( +
+
+ + {item.brand_name || "브랜드 미지정"} + +

{item.product_name ?? item.product_id}

+

+ 수량 {item.quantity}개 · 단가{" "} + {formatPrice(item.unit_price, item.currency)} +

+
+ + + {formatPrice( + item.line_total ?? + item.line_amount ?? + Number(item.unit_price) * Number(item.quantity), + item.currency, + )} + +
+ ))} +
+
+ +
+

쿠폰 적용

+ +
+ + +
+ + {appliedCoupon && ( +

+ 적용된 쿠폰: {getAppliedCouponName(appliedCoupon, "확인 불가")} +

+ )} +
+ +
+

주문 생성 및 결제

+ +
+ + + + + +
+ + {createdOrder && ( +
+ 생성된 주문 ID + {createdOrder.order_id} +
+ )} + + {paymentResult && ( +
+ 결제 상태 + {paymentResult.payment_status} +

+ {paymentResult.payment_status === "paid" + ? "결제가 성공 처리되었습니다." + : "결제가 실패 처리되었습니다."} +

+
+ )} + + {paymentResult && ( + + )} +
+
+ + +
+ )}
); } \ No newline at end of file diff --git a/apps/web/src/features/products/ProductDetailPage.tsx b/apps/web/src/features/products/ProductDetailPage.tsx index e78cc6d..6d7edbd 100644 --- a/apps/web/src/features/products/ProductDetailPage.tsx +++ b/apps/web/src/features/products/ProductDetailPage.tsx @@ -3,6 +3,7 @@ import { Link, useParams } from "react-router-dom"; import { addCartItem, createCart } from "../../services/cartApi"; import { getProductDetail } from "../../services/catalogApi"; import { + clearStoredCartId, getStoredCartId, getStoredUser, setStoredCartId, @@ -105,14 +106,30 @@ export function ProductDetailPage() { setStoredCartId(cartId); } - await addCartItem(cartId, { - product_id: product.product_id, - quantity, - }); + try { + await addCartItem(cartId, { + product_id: product.product_id, + quantity, + }); + } catch { + clearStoredCartId(); + + const createdCart = await createCart({ + user_id: user.user_id, + }); + + cartId = createdCart.cart_id; + setStoredCartId(cartId); + + await addCartItem(cartId, { + product_id: product.product_id, + quantity, + }); + } setCartMessage("상품을 장바구니에 담았습니다."); } catch { - setCartErrorMessage("장바구니 담기에 실패했습니다. 잠시 후 다시 시도해주세요."); + setCartErrorMessage("상품 수량은 최대 99개까지만 담을 수 있습니다."); } finally { setIsAddingToCart(false); } diff --git a/apps/web/src/features/products/ProductListPage.tsx b/apps/web/src/features/products/ProductListPage.tsx index e922dac..72129e5 100644 --- a/apps/web/src/features/products/ProductListPage.tsx +++ b/apps/web/src/features/products/ProductListPage.tsx @@ -1,10 +1,26 @@ -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 { + clearStoredCartId, + 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 +51,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 +122,8 @@ export function ProductListPage() { const handleCategorySelect = (categoryId: string) => { setSelectedCategoryId(categoryId); + setCartFeedbackMessage(null); + setCartErrorMessage(null); }; const handlePageSizeChange = (event: ChangeEvent) => { @@ -121,6 +142,65 @@ 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); + } + + try { + await addCartItem(cartId, { + product_id: product.product_id, + quantity: QUICK_ADD_QUANTITY, + }); + } catch { + clearStoredCartId(); + + 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("상품 수량은 최대 99개까지만 담을 수 있습니다."); + } finally { + setAddingProductId(null); + } + }; + return (
@@ -128,8 +208,8 @@ export function ProductListPage() {

Product Catalog

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

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

@@ -182,6 +262,13 @@ export function ProductListPage() { + {(cartFeedbackMessage || cartErrorMessage) && ( +
+ {cartFeedbackMessage &&
{cartFeedbackMessage}
} + {cartErrorMessage &&
{cartErrorMessage}
} +
+ )} + {isLoading ? (
상품 목록을 불러오는 중입니다.
) : errorMessage ? ( @@ -191,33 +278,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/services/checkoutApi.ts b/apps/web/src/services/checkoutApi.ts new file mode 100644 index 0000000..920ea49 --- /dev/null +++ b/apps/web/src/services/checkoutApi.ts @@ -0,0 +1,37 @@ +import { apiClient } from "./apiClient"; +import type { + CheckoutSummary, + CouponApplyRequest, + CouponApplyResponse, + OrderCreateRequest, + OrderCreateResponse, + PaymentSimulationRequest, + PaymentSimulationResponse, +} from "../types/checkout"; + +export function enterCheckout(cartId: string) { + return apiClient(`/checkout/${cartId}`); +} + +export function applyCoupon(cartId: string, couponName: string) { + return apiClient(`/carts/${cartId}/apply-coupon`, { + method: "POST", + body: { + coupon_name: couponName, + }, + }); +} + +export function createOrder(payload: OrderCreateRequest) { + return apiClient("/orders", { + method: "POST", + body: payload, + }); +} + +export function simulatePayment(payload: PaymentSimulationRequest) { + return apiClient("/payments/simulate", { + method: "POST", + body: payload, + }); +} \ No newline at end of file diff --git a/apps/web/src/styles/global.css b/apps/web/src/styles/global.css index 075a1cd..bd731ac 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,29 +770,6 @@ textarea { text-align: center; } -.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; -} - .product-detail-actions { display: flex; flex-wrap: wrap; @@ -740,31 +788,21 @@ textarea { margin-top: 16px; } -.state-box.success { - color: #166534; - border-color: #bbf7d0; - background-color: #f0fdf4; -} - -/* Auth pages */ -.auth-page { +/* Cart Page */ +.cart-page { display: flex; - justify-content: center; + flex-direction: column; + gap: 24px; } -.auth-card { - width: min(520px, 100%); - padding: 34px; +.cart-header { + padding: 32px; border: 1px solid #e5e7eb; border-radius: 24px; background-color: #ffffff; } -.auth-header { - margin-bottom: 28px; -} - -.auth-header h1 { +.cart-header h1 { margin: 0 0 12px; color: #111827; font-size: clamp(30px, 4vw, 42px); @@ -772,128 +810,599 @@ textarea { letter-spacing: -0.04em; } -.auth-header p { +.cart-header p { + max-width: 680px; margin: 0; color: #4b5563; line-height: 1.7; word-break: keep-all; } -.auth-form { +.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: 16px; + gap: 14px; } -.form-field { +.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; + 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: 8px; + gap: 10px; } -.form-field span, -.checkbox-field span { - color: #374151; +.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; - font-weight: 600; } -.form-field input { - min-height: 44px; +.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: #111827; + color: #991b1b; background-color: #ffffff; + cursor: pointer; } -.form-field input:focus { - outline: 2px solid #111827; - outline-offset: 2px; +.cart-remove-button:hover:not(:disabled) { + border-color: #991b1b; + background-color: #fef2f2; } -.form-field input:-webkit-autofill { - box-shadow: 0 0 0 1000px #ffffff inset; - -webkit-text-fill-color: #111827; +.cart-remove-button:disabled { + color: #9ca3af; + background-color: #f3f4f6; + cursor: not-allowed; } -.checkbox-field { +.cart-summary-card { + position: sticky; + top: 92px; display: flex; - align-items: center; - gap: 10px; - margin-top: 2px; + flex-direction: column; + gap: 16px; + padding: 22px; + border: 1px solid #e5e7eb; + border-radius: 20px; + background-color: #ffffff; } -.checkbox-field input { - width: 16px; - height: 16px; - accent-color: #111827; +.cart-summary-card h2 { + margin: 0; + color: #111827; + font-size: 20px; + letter-spacing: -0.03em; } -.auth-submit-button { - width: 100%; - margin-top: 4px; +.cart-summary-row, +.cart-summary-total { + display: flex; + justify-content: space-between; + gap: 16px; } -.auth-footer { - display: flex; - justify-content: center; - gap: 8px; - margin-top: 22px; - color: #6b7280; +.cart-summary-row { + color: #4b5563; font-size: 14px; } -.auth-footer a { +.cart-summary-total { + padding-top: 16px; + border-top: 1px solid #e5e7eb; +} + +.cart-summary-total span { color: #111827; font-weight: 700; } -@media (max-width: 900px) { - .product-detail-layout { - grid-template-columns: 1fr; - } +.cart-summary-total strong { + color: #111827; + font-size: 20px; +} - .product-detail-image { - min-height: 360px; - } +.cart-checkout-link, +.cart-continue-link { + width: 100%; } -@media (max-width: 560px) { - .product-detail-info { - padding: 24px; - } +.cart-empty-actions { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 10px; + margin-top: 18px; +} - .quantity-control { - align-items: flex-start; - flex-direction: column; - } +.cart-item-quantity-control { + display: flex; + align-items: center; + gap: 12px; + margin-top: 2px; +} - .quantity-stepper { - width: 100%; - } +.cart-item-quantity-control > span { + color: #374151; + font-size: 14px; + font-weight: 700; +} - .quantity-stepper input { - flex: 1; - } +.cart-quantity-stepper { + display: inline-flex; + overflow: hidden; + border: 1px solid #d1d5db; + border-radius: 10px; + background-color: #ffffff; +} - .product-detail-actions { - flex-direction: column; - } +.cart-quantity-stepper button { + width: 36px; + border: 0; + color: #111827; + background-color: #ffffff; + cursor: pointer; +} - .product-detail-actions .primary-button, - .product-detail-actions .secondary-link { - width: 100%; - } +.cart-quantity-stepper button:hover:not(:disabled) { + background-color: #f3f4f6; +} - .auth-card { - padding: 26px 22px; - } +.cart-quantity-stepper button:disabled { + color: #9ca3af; + cursor: not-allowed; +} - .auth-footer { - align-items: center; - flex-direction: column; - } +.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 */ +.auth-page { + display: flex; + justify-content: center; +} + +.auth-card { + width: min(520px, 100%); + padding: 34px; + border: 1px solid #e5e7eb; + border-radius: 24px; + background-color: #ffffff; +} + +.auth-header { + margin-bottom: 28px; +} + +.auth-header h1 { + margin: 0 0 12px; + color: #111827; + font-size: clamp(30px, 4vw, 42px); + line-height: 1.18; + letter-spacing: -0.04em; +} + +.auth-header p { + margin: 0; + color: #4b5563; + line-height: 1.7; + word-break: keep-all; +} + +.auth-form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.form-field { + display: flex; + flex-direction: column; + gap: 8px; +} + +.form-field span, +.checkbox-field span { + color: #374151; + font-size: 14px; + font-weight: 600; +} + +.form-field input { + min-height: 44px; + padding: 0 14px; + border: 1px solid #d1d5db; + border-radius: 10px; + color: #111827; + background-color: #ffffff; +} + +.form-field input:focus { + outline: 2px solid #111827; + outline-offset: 2px; +} + +.form-field input:-webkit-autofill { + box-shadow: 0 0 0 1000px #ffffff inset; + -webkit-text-fill-color: #111827; +} + +.checkbox-field { + display: flex; + align-items: center; + gap: 10px; + margin-top: 2px; +} + +.checkbox-field input { + width: 16px; + height: 16px; + accent-color: #111827; +} + +.auth-submit-button { + width: 100%; + margin-top: 4px; +} + +.auth-footer { + display: flex; + justify-content: center; + gap: 8px; + margin-top: 22px; + color: #6b7280; + font-size: 14px; +} + +.auth-footer a { + color: #111827; + font-weight: 700; +} + +/* Checkout Page */ +.checkout-page { + display: flex; + flex-direction: column; + gap: 24px; +} + +.checkout-header { + padding: 32px; + border: 1px solid #e5e7eb; + border-radius: 24px; + background-color: #ffffff; +} + +.checkout-header h1 { + margin: 0 0 12px; + color: #111827; + font-size: clamp(30px, 4vw, 42px); + line-height: 1.18; + letter-spacing: -0.04em; + word-break: keep-all; +} + +.checkout-header p { + max-width: 720px; + margin: 0; + color: #4b5563; + line-height: 1.7; + word-break: keep-all; +} + +.checkout-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 340px; + gap: 20px; + align-items: flex-start; +} + +.checkout-main { + display: flex; + flex-direction: column; + gap: 16px; +} + +.checkout-section { + padding: 24px; + border: 1px solid #e5e7eb; + border-radius: 20px; + background-color: #ffffff; +} + +.checkout-section h2 { + margin: 0 0 18px; + color: #111827; + font-size: 20px; + letter-spacing: -0.03em; +} + +.checkout-items { + display: flex; + flex-direction: column; + gap: 12px; +} + +.checkout-item { + display: flex; + justify-content: space-between; + gap: 18px; + padding: 16px; + border-radius: 14px; + background-color: #f9fafb; +} + +.checkout-item h3 { + margin: 4px 0 8px; + color: #111827; + font-size: 17px; + line-height: 1.45; + word-break: keep-all; +} + +.checkout-item p { + margin: 0; + color: #6b7280; + font-size: 14px; +} + +.checkout-item strong { + flex-shrink: 0; + color: #111827; +} + +.checkout-item-brand { + color: #6b7280; + font-size: 12px; + font-weight: 700; +} + +.coupon-row { + display: flex; + gap: 10px; +} + +.coupon-row input { + flex: 1; + min-height: 42px; + padding: 0 14px; + border: 1px solid #d1d5db; + border-radius: 10px; +} + +.coupon-row input:focus { + outline: 2px solid #111827; + outline-offset: 2px; +} + +.secondary-button, +.danger-button { + display: inline-flex; + min-height: 42px; + align-items: center; + justify-content: center; + padding: 0 16px; + border-radius: 10px; + font-weight: 700; + cursor: pointer; +} + +.secondary-button { + color: #111827; + border: 1px solid #d1d5db; + background-color: #ffffff; +} + +.secondary-button:hover:not(:disabled) { + border-color: #111827; +} + +.success-button { + display: inline-flex; + min-height: 42px; + align-items: center; + justify-content: center; + padding: 0 16px; + border: 1px solid #bbf7d0; + border-radius: 10px; + color: #166534; + background-color: #f0fdf4; + font-weight: 700; + cursor: pointer; +} + +.success-button:hover:not(:disabled) { + border-color: #166534; +} + +.success-button:disabled { + color: #9ca3af; + border-color: #e5e7eb; + background-color: #f3f4f6; + cursor: not-allowed; +} + +.danger-button { + color: #991b1b; + border: 1px solid #fecaca; + background-color: #fef2f2; +} + +.danger-button:hover:not(:disabled) { + border-color: #991b1b; +} + +.secondary-button:disabled, +.danger-button:disabled { + color: #9ca3af; + border-color: #e5e7eb; + background-color: #f3f4f6; + cursor: not-allowed; +} + +.coupon-applied-text { + margin: 12px 0 0; + color: #166534; + font-size: 14px; + font-weight: 700; +} + +.checkout-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.checkout-result-box { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 16px; + padding: 16px; + border-radius: 14px; + background-color: #f9fafb; +} + +.checkout-result-box span { + color: #6b7280; + font-size: 13px; +} + +.checkout-result-box strong { + color: #111827; + word-break: break-all; +} + +.checkout-result-box p { + margin: 0; + color: #4b5563; +} + +.checkout-summary-card { + position: sticky; + top: 92px; + display: flex; + flex-direction: column; + gap: 16px; + padding: 22px; + border: 1px solid #e5e7eb; + border-radius: 20px; + background-color: #ffffff; +} + +.checkout-summary-card h2 { + margin: 0; + color: #111827; + font-size: 20px; + letter-spacing: -0.03em; +} + +.checkout-summary-row, +.checkout-summary-total { + display: flex; + justify-content: space-between; + gap: 16px; +} + +.checkout-summary-row { + color: #4b5563; + font-size: 14px; +} + +.checkout-summary-total { + padding-top: 16px; + border-top: 1px solid #e5e7eb; +} + +.checkout-summary-total span { + color: #111827; + font-weight: 700; +} + +.checkout-summary-total strong { + color: #111827; + font-size: 20px; +} + +.checkout-back-link { + width: 100%; } /* Responsive layout */ @@ -918,6 +1427,29 @@ 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; + } + + .checkout-layout { + grid-template-columns: 1fr; + } + + .checkout-summary-card { + position: static; + } +} + @media (max-width: 820px) { .product-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -933,7 +1465,8 @@ textarea { padding: 40px 24px; } - .home-grid { + .home-grid, + .product-grid { grid-template-columns: 1fr; } @@ -950,15 +1483,96 @@ 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; + } + + .coupon-row { + flex-direction: column; + } + + .checkout-actions { + flex-direction: column; + } + + .checkout-actions .primary-button, + .checkout-actions .secondary-button, + .checkout-actions .success-button, + .checkout-actions .danger-button { + width: 100%; + } + + .checkout-item { + flex-direction: column; + } +} + +@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..ddd4cde 100644 --- a/apps/web/src/types/cart.ts +++ b/apps/web/src/types/cart.ts @@ -12,7 +12,16 @@ 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; + line_total?: string | number; currency: string; +}; + +export type CartDetail = Cart & { + items?: CartItem[]; + cart_items?: CartItem[]; }; \ No newline at end of file diff --git a/apps/web/src/types/checkout.ts b/apps/web/src/types/checkout.ts new file mode 100644 index 0000000..1a93929 --- /dev/null +++ b/apps/web/src/types/checkout.ts @@ -0,0 +1,82 @@ +import type { CartItem } from "./cart"; + +export type CheckoutSummary = { + cart_id: string; + user_id: string; + items: CartItem[]; + total_items: number; + total_quantity: number; + total_amount?: string | number; + original_amount?: string | number; + discount_amount?: string | number; + final_amount?: string | number; + currency: string; + applied_coupon_id?: string | null; + applied_coupon_code?: string | null; +}; + +export type CouponApplyRequest = { + coupon_name: string; +}; + +export type CouponSummary = { + coupon_id: string; + campaign_id?: string | null; + coupon_name: string; + coupon_type?: string; + discount_value?: string | number; + minimum_order_amount?: string | number; + coupon_status?: string; + valid_start_at?: string; + valid_end_at?: string; +}; + +export type CouponApplyResponse = { + cart_id: string; + coupon?: CouponSummary; + coupon_id?: string; + coupon_name?: string; + coupon_code?: string; + total_amount?: string | number; + original_amount?: string | number; + discount_amount: string | number; + final_amount: string | number; + currency: string; + message: string; +}; + +export type OrderCreateRequest = { + cart_id: string; + coupon_name?: string | null; +}; + +export type OrderCreateResponse = { + order_id: string; + user_id: string; + cart_id: string; + order_status: string; + total_amount: string | number; + discount_amount?: string | number; + final_amount?: string | number; + currency: string; + created_at: string; +}; + +export type PaymentSimulationRequest = { + order_id: string; + payment_method: string; + simulate_result: "success" | "failed"; +}; + +export type PaymentSimulationResponse = { + payment_id: string; + order_id: string; + payment_method: string; + payment_status: string; + requested_amount: string | number; + approved_amount: string | number; + failed_reason?: string | null; + paid_at?: string | null; + created_at: string; + message: string; +}; \ No newline at end of file