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_item.py b/apps/api/backend/schemas/cart_item.py index 65809be..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): @@ -28,4 +28,4 @@ class CartItemDeleteResponse(BaseModel): class CartItemQuantityUpdateRequest(BaseModel): - quantity: int = Field(ge=1, le=999) \ No newline at end of file + 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 74d9978..f00e583 100644 --- a/apps/api/backend/services/cart_item_service.py +++ b/apps/api/backend/services/cart_item_service.py @@ -10,6 +10,7 @@ CartItemQuantityUpdateRequest, ) +MAX_CART_ITEM_QUANTITY = 99 def add_item_to_cart(cart_id: UUID, payload: CartItemCreateRequest) -> dict[str, Any]: cart_query = text(""" @@ -135,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, { 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 e93568e..70ee303 100644 --- a/apps/web/src/features/cart/CartPage.tsx +++ b/apps/web/src/features/cart/CartPage.tsx @@ -5,7 +5,11 @@ import { removeCartItem, updateCartItemQuantity, } from "../../services/cartApi"; -import { getStoredCartId, getStoredUser } from "../../stores/userStore"; +import { + clearStoredCartId, + getStoredCartId, + getStoredUser, +} from "../../stores/userStore"; import type { CartDetail, CartItem } from "../../types/cart"; type CartItemWithTotals = CartItem & { @@ -105,7 +109,9 @@ export function CartPage() { const cartData = await getCart(storedCartId); setCart(cartData); } catch { - setErrorMessage("장바구니 정보를 불러오지 못했습니다."); + clearStoredCartId(); + setCart(null); + setErrorMessage(null); } finally { setIsLoading(false); } @@ -326,7 +332,7 @@ export function CartPage() { key={`${item.cart_item_id}-${item.quantity}`} type="number" min="1" - max="999" + max="99" defaultValue={item.quantity} disabled={updatingItemId === item.cart_item_id} onBlur={(event) => { 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 b0bb373..72129e5 100644 --- a/apps/web/src/features/products/ProductListPage.tsx +++ b/apps/web/src/features/products/ProductListPage.tsx @@ -11,6 +11,7 @@ import { Link } from "react-router-dom"; import { addCartItem, createCart } from "../../services/cartApi"; import { getCategories, getProducts } from "../../services/catalogApi"; import { + clearStoredCartId, getStoredCartId, getStoredUser, setStoredCartId, @@ -172,13 +173,29 @@ export function ProductListPage() { setStoredCartId(cartId); } - await addCartItem(cartId, { - product_id: product.product_id, - quantity: QUICK_ADD_QUANTITY, - }); + 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("장바구니 담기에 실패했습니다. 잠시 후 다시 시도해주세요."); + setCartErrorMessage("상품 수량은 최대 99개까지만 담을 수 있습니다."); } finally { setAddingProductId(null); } 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 977ef8b..bd731ac 100644 --- a/apps/web/src/styles/global.css +++ b/apps/web/src/styles/global.css @@ -1136,6 +1136,275 @@ textarea { 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 */ @media (max-width: 1080px) { .product-grid { @@ -1171,6 +1440,14 @@ textarea { .cart-summary-card { position: static; } + + .checkout-layout { + grid-template-columns: 1fr; + } + + .checkout-summary-card { + position: static; + } } @media (max-width: 820px) { @@ -1231,6 +1508,25 @@ textarea { .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) { diff --git a/apps/web/src/types/cart.ts b/apps/web/src/types/cart.ts index efcc388..ddd4cde 100644 --- a/apps/web/src/types/cart.ts +++ b/apps/web/src/types/cart.ts @@ -17,6 +17,7 @@ export type CartItem = { quantity: number; unit_price: string | number; line_amount?: string | number; + line_total?: string | number; currency: string; }; 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