diff --git a/apps/web/index.html b/apps/web/index.html index 97ce28c..4d8d36e 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -3,6 +3,7 @@ + D2C Commerce Prototype diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg new file mode 100644 index 0000000..514bb52 --- /dev/null +++ b/apps/web/public/favicon.svg @@ -0,0 +1,6 @@ + + + + D2C + + \ 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 da52b45..e78cc6d 100644 --- a/apps/web/src/features/products/ProductDetailPage.tsx +++ b/apps/web/src/features/products/ProductDetailPage.tsx @@ -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(null); + const [quantity, setQuantity] = useState(1); + const [isLoading, setIsLoading] = useState(true); + const [isAddingToCart, setIsAddingToCart] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + const [cartMessage, setCartMessage] = useState(null); + const [cartErrorMessage, setCartErrorMessage] = useState(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) => { + 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
상품 상세 정보를 불러오는 중입니다.
; + } + + if (errorMessage || !product) { + return ( +
+
+ {errorMessage ?? "상품 정보를 확인할 수 없습니다."} +
+ + 상품 목록으로 돌아가기 + +
+ ); + } + + const hasDiscount = Number(product.list_price) !== Number(product.sale_price); + return ( -
-

상품 상세

-

D2C-34에서 상품 상세 조회와 장바구니 담기 흐름을 구현합니다.

-

productId: {productId}

+
+ + ← 상품 목록으로 돌아가기 + + +
+
+ {product.brand_name ?? "D2C"} +
+ +
+
+ {product.brand_name ?? "브랜드 미지정"} + {product.product_status} +
+ +

{product.product_name}

+ +
+ {formatPrice(product.sale_price, product.currency)} + {hasDiscount && ( + {formatPrice(product.list_price, product.currency)} + )} +
+ +
+

+ 이 상품은 D2C Commerce Prototype의 상품 탐색 및 장바구니 흐름 검증을 + 위한 샘플 상품입니다. +

+
+ +
+ 수량 +
+ + + +
+
+ + {!user && ( +
+ 로그인 후 장바구니에 상품을 담을 수 있습니다. +
+ + 로그인 + + + 회원가입 + +
+
+ )} + + {cartMessage &&
{cartMessage}
} + {cartErrorMessage &&
{cartErrorMessage}
} + +
+ + + + 장바구니 보기 + +
+
+
); } \ No newline at end of file diff --git a/apps/web/src/services/cartApi.ts b/apps/web/src/services/cartApi.ts new file mode 100644 index 0000000..3896370 --- /dev/null +++ b/apps/web/src/services/cartApi.ts @@ -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("/carts", { + method: "POST", + body: payload, + }); +} + +export function addCartItem(cartId: string, payload: AddCartItemRequest) { + return apiClient(`/carts/${cartId}/items`, { + method: "POST", + body: payload, + }); +} \ No newline at end of file diff --git a/apps/web/src/services/catalogApi.ts b/apps/web/src/services/catalogApi.ts index 2b54d0f..290dd4e 100644 --- a/apps/web/src/services/catalogApi.ts +++ b/apps/web/src/services/catalogApi.ts @@ -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("/categories"); @@ -8,4 +8,8 @@ export function getCategories() { export function getProducts(categoryId?: string) { const query = categoryId ? `?category_id=${categoryId}` : ""; return apiClient(`/products${query}`); +} + +export function getProductDetail(productId: string) { + return apiClient(`/products/${productId}`); } \ No newline at end of file diff --git a/apps/web/src/styles/global.css b/apps/web/src/styles/global.css index 39db273..01cf004 100644 --- a/apps/web/src/styles/global.css +++ b/apps/web/src/styles/global.css @@ -558,6 +558,232 @@ textarea { background-color: #fef2f2; } +/* Product Detail Page */ +.product-detail-page { + display: flex; + flex-direction: column; + gap: 20px; +} + +.product-detail-back-link { + width: fit-content; + color: #4b5563; + font-size: 14px; +} + +.product-detail-back-link:hover { + color: #111827; +} + +.product-detail-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(360px, 0.85fr); + gap: 28px; + align-items: stretch; +} + +.product-detail-image { + display: flex; + min-height: 520px; + align-items: center; + justify-content: center; + border: 1px solid #e5e7eb; + border-radius: 24px; + background: + radial-gradient(circle at top right, rgba(17, 24, 39, 0.1), transparent 32%), + #f3f4f6; +} + +.product-detail-image span { + color: #6b7280; + font-size: 16px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.product-detail-info { + display: flex; + flex-direction: column; + gap: 22px; + padding: 32px; + border: 1px solid #e5e7eb; + border-radius: 24px; + background-color: #ffffff; +} + +.product-detail-meta { + display: flex; + justify-content: space-between; + gap: 12px; + color: #6b7280; + font-size: 13px; +} + +.product-detail-info h1 { + margin: 0; + color: #111827; + font-size: clamp(30px, 4vw, 44px); + line-height: 1.18; + letter-spacing: -0.04em; + word-break: keep-all; +} + +.product-detail-price { + display: flex; + align-items: baseline; + gap: 10px; +} + +.product-detail-price strong { + color: #111827; + font-size: 28px; +} + +.product-detail-price span { + color: #9ca3af; + font-size: 15px; + text-decoration: line-through; +} + +.product-detail-description { + padding: 18px; + border-radius: 16px; + background-color: #f9fafb; +} + +.product-detail-description p { + margin: 0; + color: #4b5563; + line-height: 1.7; + word-break: keep-all; +} + +.quantity-control { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.quantity-control > span { + color: #111827; + font-weight: 700; +} + +.quantity-stepper { + display: inline-flex; + overflow: hidden; + border: 1px solid #d1d5db; + border-radius: 12px; + background-color: #ffffff; +} + +.quantity-stepper button { + width: 40px; + border: 0; + color: #111827; + background-color: #ffffff; + cursor: pointer; +} + +.quantity-stepper button:hover { + background-color: #f3f4f6; +} + +.quantity-stepper input { + width: 64px; + border: 0; + border-right: 1px solid #e5e7eb; + border-left: 1px solid #e5e7eb; + 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; + gap: 10px; + margin-top: auto; +} + +.product-detail-notice { + text-align: left; +} + +.product-detail-notice-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 16px; +} + +.state-box.success { + color: #166534; + border-color: #bbf7d0; + background-color: #f0fdf4; +} + +@media (max-width: 900px) { + .product-detail-layout { + grid-template-columns: 1fr; + } + + .product-detail-image { + min-height: 360px; + } +} + +@media (max-width: 560px) { + .product-detail-info { + padding: 24px; + } + + .quantity-control { + align-items: flex-start; + flex-direction: column; + } + + .quantity-stepper { + width: 100%; + } + + .quantity-stepper input { + flex: 1; + } + + .product-detail-actions { + flex-direction: column; + } + + .product-detail-actions .primary-button, + .product-detail-actions .secondary-link { + width: 100%; + } +} + /* Responsive layout */ @media (max-width: 1080px) { .product-grid { diff --git a/apps/web/src/types/cart.ts b/apps/web/src/types/cart.ts new file mode 100644 index 0000000..cd98410 --- /dev/null +++ b/apps/web/src/types/cart.ts @@ -0,0 +1,18 @@ +export type Cart = { + cart_id: string; + user_id: string; + cart_status: string; + total_items?: number; + total_quantity?: number; + total_amount?: string | number; + currency?: string; +}; + +export type CartItem = { + cart_item_id: string; + cart_id: string; + product_id: string; + quantity: number; + unit_price: string | number; + currency: string; +}; \ No newline at end of file diff --git a/apps/web/src/types/catalog.ts b/apps/web/src/types/catalog.ts index dbb408a..0273083 100644 --- a/apps/web/src/types/catalog.ts +++ b/apps/web/src/types/catalog.ts @@ -16,4 +16,6 @@ export type Product = { currency: string; brand_name: string | null; is_active: boolean; -}; \ No newline at end of file +}; + +export type ProductDetail = Product; \ No newline at end of file