Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>D2C Commerce Prototype</title>
</head>
<body>
Expand Down
6 changes: 6 additions & 0 deletions apps/web/public/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
224 changes: 219 additions & 5 deletions apps/web/src/features/products/ProductDetailPage.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,227 @@
import { useParams } from "react-router-dom";
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { addCartItem, createCart } from "../../services/cartApi";
import { getProductDetail } from "../../services/catalogApi";
import {
getStoredCartId,
getStoredUser,
setStoredCartId,
} from "../../stores/userStore";
import type { ProductDetail } from "../../types/catalog";

function formatPrice(value: string | number, currency: string) {
const numericValue = Number(value);

if (Number.isNaN(numericValue)) {
return `${value} ${currency}`;
}

return new Intl.NumberFormat("ko-KR", {
style: "currency",
currency,
maximumFractionDigits: 0,
}).format(numericValue);
}

export function ProductDetailPage() {
const { productId } = useParams();

const [product, setProduct] = useState<ProductDetail | null>(null);
const [quantity, setQuantity] = useState<number>(1);
const [isLoading, setIsLoading] = useState(true);
const [isAddingToCart, setIsAddingToCart] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [cartMessage, setCartMessage] = useState<string | null>(null);
const [cartErrorMessage, setCartErrorMessage] = useState<string | null>(null);

const user = getStoredUser();

useEffect(() => {
async function loadProductDetail() {
if (!productId) {
setErrorMessage("상품 식별자를 확인할 수 없습니다.");
setIsLoading(false);
return;
}

try {
setIsLoading(true);
setErrorMessage(null);

const productData = await getProductDetail(productId);
setProduct(productData);
} catch {
setErrorMessage("상품 상세 정보를 불러오지 못했습니다.");
} finally {
setIsLoading(false);
}
}

loadProductDetail();
}, [productId]);

const handleDecreaseQuantity = () => {
setQuantity((currentQuantity) => Math.max(1, currentQuantity - 1));
};

const handleIncreaseQuantity = () => {
setQuantity((currentQuantity) => Math.min(99, currentQuantity + 1));
};

const handleQuantityInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const nextQuantity = Number(event.target.value);

if (Number.isNaN(nextQuantity)) {
return;
}

setQuantity(Math.min(99, Math.max(1, nextQuantity)));
};

const handleAddToCart = async () => {
if (!productId || !product) {
return;
}

if (!user) {
setCartMessage(null);
setCartErrorMessage("로그인 후 장바구니에 상품을 담을 수 있습니다.");
return;
}

try {
setIsAddingToCart(true);
setCartMessage(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,
});

setCartMessage("상품을 장바구니에 담았습니다.");
} catch {
setCartErrorMessage("장바구니 담기에 실패했습니다. 잠시 후 다시 시도해주세요.");
} finally {
setIsAddingToCart(false);
}
};

if (isLoading) {
return <div className="state-box">상품 상세 정보를 불러오는 중입니다.</div>;
}

if (errorMessage || !product) {
return (
<section className="product-detail-page">
<div className="state-box error">
{errorMessage ?? "상품 정보를 확인할 수 없습니다."}
</div>
<Link to="/products" className="secondary-link">
상품 목록으로 돌아가기
</Link>
</section>
);
}

const hasDiscount = Number(product.list_price) !== Number(product.sale_price);

return (
<section className="page-section">
<h1>상품 상세</h1>
<p>D2C-34에서 상품 상세 조회와 장바구니 담기 흐름을 구현합니다.</p>
<p>productId: {productId}</p>
<section className="product-detail-page">
<Link to="/products" className="product-detail-back-link">
← 상품 목록으로 돌아가기
</Link>

<div className="product-detail-layout">
<div className="product-detail-image">
<span>{product.brand_name ?? "D2C"}</span>
</div>

<div className="product-detail-info">
<div className="product-detail-meta">
<span>{product.brand_name ?? "브랜드 미지정"}</span>
<span>{product.product_status}</span>
</div>

<h1>{product.product_name}</h1>

<div className="product-detail-price">
<strong>{formatPrice(product.sale_price, product.currency)}</strong>
{hasDiscount && (
<span>{formatPrice(product.list_price, product.currency)}</span>
)}
</div>

<div className="product-detail-description">
<p>
이 상품은 D2C Commerce Prototype의 상품 탐색 및 장바구니 흐름 검증을
위한 샘플 상품입니다.
</p>
</div>

<div className="quantity-control">
<span>수량</span>
<div className="quantity-stepper">
<button type="button" onClick={handleDecreaseQuantity}>
-
</button>
<input
type="number"
min="1"
max="99"
value={quantity}
onChange={handleQuantityInputChange}
/>
<button type="button" onClick={handleIncreaseQuantity}>
+
</button>
</div>
</div>

{!user && (
<div className="state-box product-detail-notice">
로그인 후 장바구니에 상품을 담을 수 있습니다.
<div className="product-detail-notice-actions">
<Link to="/login" className="primary-link">
로그인
</Link>
<Link to="/signup" className="secondary-link">
회원가입
</Link>
</div>
</div>
)}

{cartMessage && <div className="state-box success">{cartMessage}</div>}
{cartErrorMessage && <div className="state-box error">{cartErrorMessage}</div>}

<div className="product-detail-actions">
<button
type="button"
className="primary-button"
onClick={handleAddToCart}
disabled={isAddingToCart || !product.is_active}
>
{isAddingToCart ? "장바구니 담는 중..." : "장바구니 담기"}
</button>

<Link to="/cart" className="secondary-link">
장바구니 보기
</Link>
</div>
</div>
</div>
</section>
);
}
25 changes: 25 additions & 0 deletions apps/web/src/services/cartApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { apiClient } from "./apiClient";
import type { Cart, CartItem } from "../types/cart";

type CreateCartRequest = {
user_id: string;
};

type AddCartItemRequest = {
product_id: string;
quantity: number;
};

export function createCart(payload: CreateCartRequest) {
return apiClient<Cart>("/carts", {
method: "POST",
body: payload,
});
}

export function addCartItem(cartId: string, payload: AddCartItemRequest) {
return apiClient<CartItem>(`/carts/${cartId}/items`, {
method: "POST",
body: payload,
});
}
6 changes: 5 additions & 1 deletion apps/web/src/services/catalogApi.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { apiClient } from "./apiClient";
import type { Category, Product } from "../types/catalog";
import type { Category, Product, ProductDetail } from "../types/catalog";

export function getCategories() {
return apiClient<Category[]>("/categories");
Expand All @@ -8,4 +8,8 @@ export function getCategories() {
export function getProducts(categoryId?: string) {
const query = categoryId ? `?category_id=${categoryId}` : "";
return apiClient<Product[]>(`/products${query}`);
}

export function getProductDetail(productId: string) {
return apiClient<ProductDetail>(`/products/${productId}`);
}
Loading
Loading