diff --git a/apps/api/backend/main.py b/apps/api/backend/main.py
index 6b345df..00e6cf5 100644
--- a/apps/api/backend/main.py
+++ b/apps/api/backend/main.py
@@ -1,4 +1,5 @@
from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
from backend.api.routes.auth import router as auth_router
from backend.api.routes.cart_items import router as cart_items_router
@@ -17,6 +18,17 @@
app = FastAPI(title="D2C Commerce Prototype API")
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=[
+ "http://localhost:5173",
+ "http://127.0.0.1:5173",
+ ],
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
app.include_router(health_router)
app.include_router(auth_router)
app.include_router(categories_router)
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 @@
+
\ 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/features/products/ProductListPage.tsx b/apps/web/src/features/products/ProductListPage.tsx
index 11aacdb..e922dac 100644
--- a/apps/web/src/features/products/ProductListPage.tsx
+++ b/apps/web/src/features/products/ProductListPage.tsx
@@ -1,8 +1,248 @@
+import { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { Link } from "react-router-dom";
+import { getCategories, getProducts } from "../../services/catalogApi";
+import type { Category, Product } from "../../types/catalog";
+
+const PAGE_SIZE_OPTIONS = [12, 24, 48] as const;
+const DEFAULT_PAGE_SIZE = 24;
+
+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);
+}
+
+function getVisibleProducts(products: Product[], currentPage: number, pageSize: number) {
+ const startIndex = (currentPage - 1) * pageSize;
+ const endIndex = startIndex + pageSize;
+
+ return products.slice(startIndex, endIndex);
+}
+
export function ProductListPage() {
+ const [categories, setCategories] = useState([]);
+ const [products, setProducts] = useState([]);
+ const [selectedCategoryId, setSelectedCategoryId] = useState("");
+ const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
+ const [currentPage, setCurrentPage] = useState(1);
+ const [isLoading, setIsLoading] = useState(true);
+ const [errorMessage, setErrorMessage] = useState(null);
+
+ const productListTopRef = useRef(null);
+
+ const scrollToProductListTop = useCallback(() => {
+ requestAnimationFrame(() => {
+ productListTopRef.current?.scrollIntoView({
+ behavior: "smooth",
+ block: "start",
+ });
+ });
+ }, []);
+
+ const selectedCategoryName = useMemo(() => {
+ if (!selectedCategoryId) {
+ return "전체 상품";
+ }
+
+ return (
+ categories.find((category) => category.category_id === selectedCategoryId)
+ ?.category_name ?? "선택한 카테고리"
+ );
+ }, [categories, selectedCategoryId]);
+
+ const totalPages = Math.max(1, Math.ceil(products.length / pageSize));
+
+ const paginatedProducts = useMemo(
+ () => getVisibleProducts(products, currentPage, pageSize),
+ [products, currentPage, pageSize],
+ );
+
+ const pageStartItem = products.length === 0 ? 0 : (currentPage - 1) * pageSize + 1;
+ const pageEndItem = Math.min(currentPage * pageSize, products.length);
+
+ useEffect(() => {
+ async function loadCategories() {
+ try {
+ const categoryData = await getCategories();
+ setCategories(categoryData);
+ } catch {
+ setErrorMessage("카테고리 목록을 불러오지 못했습니다. 잠시 후 다시 시도해주세요.");
+ }
+ }
+
+ loadCategories();
+ }, []);
+
+ useEffect(() => {
+ async function loadProducts() {
+ try {
+ setIsLoading(true);
+ setErrorMessage(null);
+
+ const productData = await getProducts(selectedCategoryId || undefined);
+
+ setProducts(productData);
+ setCurrentPage(1);
+ } catch {
+ setErrorMessage("상품 목록을 불러오지 못했습니다. 잠시 후 다시 시도해주세요.");
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ loadProducts();
+ }, [selectedCategoryId]);
+
+ const handleCategorySelect = (categoryId: string) => {
+ setSelectedCategoryId(categoryId);
+ };
+
+ const handlePageSizeChange = (event: ChangeEvent) => {
+ setPageSize(Number(event.target.value));
+ setCurrentPage(1);
+ scrollToProductListTop();
+ };
+
+ const handlePreviousPage = () => {
+ setCurrentPage((page) => Math.max(1, page - 1));
+ scrollToProductListTop();
+ };
+
+ const handleNextPage = () => {
+ setCurrentPage((page) => Math.min(totalPages, page + 1));
+ scrollToProductListTop();
+ };
+
return (
-
- 카테고리/상품 목록
- D2C-33에서 카테고리 및 상품 목록 API를 연동합니다.
+
+
+
+
Product Catalog
+
카테고리별 상품을 탐색해보세요.
+
+ 상품 목록에서 관심 상품을 선택하면 상세 화면으로 이동하여 장바구니 담기
+ 흐름을 이어갈 수 있습니다.
+
+
+
+
+
+
+
+ {categories.map((category) => (
+
+ ))}
+
+
+
+
+
+
{selectedCategoryName}
+
+ {products.length}개 상품
+ {products.length > 0 && ` · ${pageStartItem}-${pageEndItem}개 표시 중`}
+
+
+
+
+
+
+
+ {isLoading ? (
+ 상품 목록을 불러오는 중입니다.
+ ) : errorMessage ? (
+ {errorMessage}
+ ) : products.length === 0 ? (
+ 표시할 상품이 없습니다.
+ ) : (
+ <>
+
+ {paginatedProducts.map((product) => (
+
+
+ {product.brand_name ?? "D2C"}
+
+
+
+
+ {product.brand_name ?? "브랜드 미지정"}
+ {product.product_status}
+
+
+
{product.product_name}
+
+
+ {formatPrice(product.sale_price, product.currency)}
+ {Number(product.list_price) !== Number(product.sale_price) && (
+ {formatPrice(product.list_price, product.currency)}
+ )}
+
+
+
+ ))}
+
+
+
+
+
+
+ {currentPage} / {totalPages}
+
+
+
+
+ >
+ )}
);
-}
\ No newline at end of file
+}
diff --git a/apps/web/src/services/apiClient.ts b/apps/web/src/services/apiClient.ts
index 3e392b1..933e5d3 100644
--- a/apps/web/src/services/apiClient.ts
+++ b/apps/web/src/services/apiClient.ts
@@ -24,13 +24,18 @@ export async function apiClient(
): Promise {
const { method = "GET", body, headers = {} } = options;
+ const requestHeaders: Record = {
+ ...headers,
+ };
+
+ if (body !== undefined) {
+ requestHeaders["Content-Type"] = "application/json";
+ }
+
const response = await fetch(`${API_BASE_URL}${path}`, {
method,
- headers: {
- "Content-Type": "application/json",
- ...headers
- },
- body: body ? JSON.stringify(body) : undefined
+ headers: requestHeaders,
+ body: body !== undefined ? JSON.stringify(body) : undefined,
});
const contentType = response.headers.get("content-type");
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
new file mode 100644
index 0000000..290dd4e
--- /dev/null
+++ b/apps/web/src/services/catalogApi.ts
@@ -0,0 +1,15 @@
+import { apiClient } from "./apiClient";
+import type { Category, Product, ProductDetail } from "../types/catalog";
+
+export function getCategories() {
+ return apiClient("/categories");
+}
+
+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 1499d19..01cf004 100644
--- a/apps/web/src/styles/global.css
+++ b/apps/web/src/styles/global.css
@@ -36,6 +36,7 @@ textarea {
font: inherit;
}
+/* App layout */
.app-shell {
min-height: 100vh;
background-color: #f8fafc;
@@ -79,6 +80,13 @@ textarea {
font-weight: 500;
}
+.app-main {
+ width: min(1180px, calc(100% - 48px));
+ margin: 0 auto;
+ padding: 48px 0;
+}
+
+/* Shared actions */
.link-button {
border: 0;
padding: 0;
@@ -91,37 +99,6 @@ textarea {
color: #111827;
}
-.app-main {
- width: min(1180px, calc(100% - 48px));
- margin: 0 auto;
- padding: 48px 0;
-}
-
-.page-section {
- padding: 32px;
- border: 1px solid #e5e7eb;
- border-radius: 16px;
- background-color: #ffffff;
-}
-
-.page-section h1 {
- margin: 0 0 12px;
- font-size: 28px;
-}
-
-.page-section p {
- margin: 0 0 16px;
- color: #4b5563;
- line-height: 1.6;
-}
-
-.action-row {
- display: flex;
- flex-wrap: wrap;
- gap: 12px;
- margin-top: 24px;
-}
-
.primary-link,
.secondary-link {
display: inline-flex;
@@ -159,6 +136,42 @@ textarea {
background-color: #374151;
}
+/* Generic placeholder page */
+.page-section {
+ padding: 32px;
+ border: 1px solid #e5e7eb;
+ border-radius: 16px;
+ background-color: #ffffff;
+}
+
+.page-section h1 {
+ margin: 0 0 12px;
+ font-size: 28px;
+}
+
+.page-section p {
+ margin: 0 0 16px;
+ color: #4b5563;
+ line-height: 1.6;
+}
+
+.action-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ margin-top: 24px;
+}
+
+.section-eyebrow {
+ margin: 0 0 10px;
+ color: #6b7280;
+ font-size: 13px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+/* Home page */
.home-page {
display: flex;
flex-direction: column;
@@ -199,21 +212,6 @@ textarea {
word-break: keep-all;
}
-.home-hero-actions {
- display: flex;
- flex-wrap: wrap;
- gap: 10px;
- margin-top: 30px;
-}
-
-.home-panel-actions {
- display: flex;
- flex-shrink: 0;
- flex-wrap: wrap;
- justify-content: flex-end;
- gap: 10px;
-}
-
.home-description {
max-width: 620px;
margin: 0;
@@ -223,6 +221,13 @@ textarea {
word-break: keep-all;
}
+.home-hero-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ margin-top: 30px;
+}
+
.home-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -299,6 +304,493 @@ textarea {
word-break: keep-all;
}
+.home-panel-actions {
+ display: flex;
+ flex-shrink: 0;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 10px;
+}
+
+/* Product list page */
+.product-list-page {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+}
+
+.product-list-header {
+ display: flex;
+ justify-content: space-between;
+ gap: 24px;
+ padding: 32px;
+ border: 1px solid #e5e7eb;
+ border-radius: 24px;
+ background-color: #ffffff;
+}
+
+.product-list-header h1 {
+ margin: 0 0 12px;
+ color: #111827;
+ font-size: clamp(28px, 4vw, 42px);
+ line-height: 1.18;
+ letter-spacing: -0.04em;
+ word-break: keep-all;
+}
+
+.product-list-header p {
+ max-width: 680px;
+ margin: 0;
+ color: #4b5563;
+ line-height: 1.7;
+ word-break: keep-all;
+}
+
+.category-filter {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+}
+
+.category-chip {
+ min-height: 38px;
+ padding: 0 14px;
+ border: 1px solid #d1d5db;
+ border-radius: 999px;
+ color: #374151;
+ background-color: #ffffff;
+ cursor: pointer;
+}
+
+.category-chip:hover {
+ border-color: #9ca3af;
+ color: #111827;
+}
+
+.category-chip.active {
+ border-color: #111827;
+ color: #ffffff;
+ background-color: #111827;
+}
+
+.product-list-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ scroll-margin-top: 150px;
+}
+
+.product-list-summary {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.product-list-summary h2 {
+ margin: 0 0 6px;
+ color: #111827;
+ font-size: 22px;
+ letter-spacing: -0.03em;
+}
+
+.product-list-summary span {
+ color: #6b7280;
+ font-size: 14px;
+}
+
+.page-size-control {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ color: #4b5563;
+ font-size: 14px;
+ white-space: nowrap;
+}
+
+.page-size-control select {
+ min-height: 38px;
+ padding: 0 34px 0 12px;
+ border: 1px solid #d1d5db;
+ border-radius: 10px;
+ color: #111827;
+ background-color: #ffffff;
+ cursor: pointer;
+}
+
+.page-size-control select:focus {
+ outline: 2px solid #111827;
+ outline-offset: 2px;
+}
+
+.product-grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 16px;
+}
+
+.product-card {
+ overflow: hidden;
+ border: 1px solid #e5e7eb;
+ border-radius: 18px;
+ background-color: #ffffff;
+ transition:
+ transform 0.15s ease,
+ border-color 0.15s ease,
+ box-shadow 0.15s ease;
+}
+
+.product-card:hover {
+ transform: translateY(-2px);
+ border-color: #d1d5db;
+ box-shadow: 0 14px 28px rgba(15, 23, 42, 0.07);
+}
+
+.product-image-placeholder {
+ display: flex;
+ min-height: 160px;
+ align-items: center;
+ justify-content: center;
+ background:
+ radial-gradient(circle at top right, rgba(17, 24, 39, 0.1), transparent 32%),
+ #f3f4f6;
+}
+
+.product-image-placeholder span {
+ color: #6b7280;
+ font-size: 13px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+.product-card-body {
+ display: flex;
+ min-height: 156px;
+ flex-direction: column;
+ gap: 12px;
+ padding: 18px;
+}
+
+.product-meta {
+ display: flex;
+ justify-content: space-between;
+ gap: 10px;
+ color: #6b7280;
+ font-size: 12px;
+}
+
+.product-card h3 {
+ margin: 0;
+ color: #111827;
+ font-size: 17px;
+ line-height: 1.45;
+ letter-spacing: -0.02em;
+ word-break: keep-all;
+}
+
+.product-price-row {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ margin-top: auto;
+}
+
+.product-price-row strong {
+ color: #111827;
+ font-size: 18px;
+}
+
+.product-price-row span {
+ color: #9ca3af;
+ font-size: 13px;
+ text-decoration: line-through;
+}
+
+.pagination {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 14px;
+ margin-top: 8px;
+}
+
+.pagination button {
+ min-height: 38px;
+ padding: 0 14px;
+ border: 1px solid #d1d5db;
+ border-radius: 10px;
+ color: #111827;
+ background-color: #ffffff;
+ cursor: pointer;
+}
+
+.pagination button:hover:not(:disabled) {
+ border-color: #111827;
+}
+
+.pagination button:disabled {
+ color: #9ca3af;
+ background-color: #f3f4f6;
+ cursor: not-allowed;
+}
+
+.pagination span {
+ min-width: 72px;
+ color: #4b5563;
+ font-size: 14px;
+ 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;
+ 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 {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+}
+
@media (max-width: 960px) {
.home-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -314,6 +806,16 @@ textarea {
}
}
+@media (max-width: 820px) {
+ .product-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .product-list-header {
+ padding: 28px 24px;
+ }
+}
+
@media (max-width: 640px) {
.home-hero-content {
padding: 40px 24px;
@@ -322,4 +824,29 @@ textarea {
.home-grid {
grid-template-columns: 1fr;
}
-}
\ No newline at end of file
+
+ .product-list-toolbar {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .page-size-control {
+ width: 100%;
+ justify-content: space-between;
+ }
+
+ .page-size-control select {
+ flex: 1;
+ }
+}
+
+@media (max-width: 560px) {
+ .product-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .product-list-summary {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+}
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
new file mode 100644
index 0000000..0273083
--- /dev/null
+++ b/apps/web/src/types/catalog.ts
@@ -0,0 +1,21 @@
+export type Category = {
+ category_id: string;
+ parent_category_id: string | null;
+ category_name: string;
+ category_depth: number;
+ category_status: string;
+};
+
+export type Product = {
+ product_id: string;
+ category_id: string;
+ product_name: string;
+ product_status: string;
+ list_price: string | number;
+ sale_price: string | number;
+ currency: string;
+ brand_name: string | null;
+ is_active: boolean;
+};
+
+export type ProductDetail = Product;
\ No newline at end of file