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 @@ + + + + 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/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