diff --git a/apps/api/backend/api/routes/reviews.py b/apps/api/backend/api/routes/reviews.py index 6f81c8d..8e21c64 100644 --- a/apps/api/backend/api/routes/reviews.py +++ b/apps/api/backend/api/routes/reviews.py @@ -1,6 +1,9 @@ +from uuid import UUID + from fastapi import APIRouter, status from backend.schemas.review import ( + ProductReviewListResponse, ReviewCreateRequest, ReviewCreateResponse, ReviewDeleteRequest, @@ -8,7 +11,12 @@ ReviewUpdateRequest, ReviewUpdateResponse, ) -from backend.services.review_service import create_review, delete_review, update_review +from backend.services.review_service import ( + create_review, + delete_review, + list_product_reviews, + update_review, +) router = APIRouter(prefix="/reviews", tags=["reviews"]) @@ -42,4 +50,14 @@ def delete_product_review( payload: ReviewDeleteRequest, ) -> ReviewDeleteResponse: result = delete_review(review_id, payload) - return ReviewDeleteResponse(**result) \ No newline at end of file + return ReviewDeleteResponse(**result) + + +@router.get( + "/products/{product_id}/reviews", + response_model=ProductReviewListResponse, + status_code=status.HTTP_200_OK, +) +def get_product_reviews(product_id: UUID) -> ProductReviewListResponse: + result = list_product_reviews(product_id) + return ProductReviewListResponse(**result) \ No newline at end of file diff --git a/apps/api/backend/schemas/order_history.py b/apps/api/backend/schemas/order_history.py index 8a68271..295f4f9 100644 --- a/apps/api/backend/schemas/order_history.py +++ b/apps/api/backend/schemas/order_history.py @@ -16,9 +16,13 @@ class OrderHistoryItemResponse(BaseModel): class OrderHistorySummaryResponse(BaseModel): + order_history_id: str + history_event_type: str + history_event_at: datetime order_id: UUID cart_id: UUID | None = None order_status: str + payment_id: UUID | None = None payment_status: str | None = None subtotal_amount: Decimal discount_amount: Decimal diff --git a/apps/api/backend/schemas/review.py b/apps/api/backend/schemas/review.py index 5897671..702ea3c 100644 --- a/apps/api/backend/schemas/review.py +++ b/apps/api/backend/schemas/review.py @@ -1,4 +1,5 @@ from datetime import datetime +from decimal import Decimal from uuid import UUID from pydantic import BaseModel, Field @@ -50,4 +51,25 @@ class ReviewDeleteResponse(BaseModel): user_id: UUID review_status: str updated_at: datetime - message: str \ No newline at end of file + message: str + + +class ProductReviewItemResponse(BaseModel): + review_id: UUID + user_id: UUID + product_id: UUID + order_item_id: UUID + rating: int + review_title: str + review_content: str + review_status: str + created_at: datetime + updated_at: datetime | None = None + user_name: str | None = None + + +class ProductReviewListResponse(BaseModel): + product_id: UUID + total_reviews: int + average_rating: Decimal | None = None + reviews: list[ProductReviewItemResponse] \ No newline at end of file diff --git a/apps/api/backend/services/order_history_service.py b/apps/api/backend/services/order_history_service.py index 9cbd7fb..8bcfb14 100644 --- a/apps/api/backend/services/order_history_service.py +++ b/apps/api/backend/services/order_history_service.py @@ -19,28 +19,56 @@ def get_order_history(user_id: UUID) -> dict[str, Any]: orders_query = text(""" SELECT + CONCAT(o.order_id::text, CHR(58), 'created') AS order_history_id, + 'order_created' AS history_event_type, + o.ordered_at AS history_event_at, o.order_id, o.user_id, o.cart_id, - o.order_status, + 'created' AS order_status, + NULL AS payment_id, + NULL AS payment_status, o.subtotal_amount, o.discount_amount, o.total_amount, o.currency, c.coupon_name, - o.ordered_at, - ( - SELECT p.payment_status - FROM payments p - WHERE p.order_id = o.order_id - ORDER BY p.created_at DESC - LIMIT 1 - ) AS payment_status + o.ordered_at FROM orders o LEFT JOIN coupons c - ON c.coupon_id = o.coupon_id + ON c.coupon_id = o.coupon_id WHERE o.user_id = :user_id - ORDER BY o.ordered_at DESC, o.created_at DESC + + UNION ALL + + SELECT + CONCAT(o.order_id::text, CHR(58), 'payment', CHR(58), p.payment_id::text) AS order_history_id, + 'payment_simulated' AS history_event_type, + COALESCE(p.paid_at, p.created_at) AS history_event_at, + o.order_id, + o.user_id, + o.cart_id, + CASE + WHEN p.payment_status = 'paid' THEN 'paid' + WHEN p.payment_status = 'failed' THEN 'payment_failed' + ELSE o.order_status + END AS order_status, + p.payment_id, + p.payment_status, + o.subtotal_amount, + o.discount_amount, + o.total_amount, + o.currency, + c.coupon_name, + o.ordered_at + FROM orders o + JOIN payments p + ON p.order_id = o.order_id + LEFT JOIN coupons c + ON c.coupon_id = o.coupon_id + WHERE o.user_id = :user_id + + ORDER BY history_event_at DESC """) order_items_query = text(""" @@ -100,9 +128,13 @@ def get_order_history(user_id: UUID) -> dict[str, Any]: orders.append( { + "order_history_id": order_row["order_history_id"], + "history_event_type": order_row["history_event_type"], + "history_event_at": order_row["history_event_at"], "order_id": order_row["order_id"], "cart_id": order_row["cart_id"], "order_status": order_row["order_status"], + "payment_id": order_row["payment_id"], "payment_status": order_row["payment_status"], "subtotal_amount": Decimal(str(order_row["subtotal_amount"])), "discount_amount": Decimal(str(order_row["discount_amount"])), diff --git a/apps/api/backend/services/order_service.py b/apps/api/backend/services/order_service.py index be13a6b..8c7c388 100644 --- a/apps/api/backend/services/order_service.py +++ b/apps/api/backend/services/order_service.py @@ -1,3 +1,4 @@ +from datetime import datetime, timedelta, timezone from decimal import Decimal, ROUND_HALF_UP from typing import Any from uuid import UUID @@ -9,6 +10,12 @@ from backend.schemas.order import OrderCreateRequest +KST = timezone(timedelta(hours=9)) + + +def now_kst_naive() -> datetime: + return datetime.now(KST).replace(tzinfo=None) + def create_order_from_cart(payload: OrderCreateRequest) -> dict[str, Any]: cart_query = text(""" SELECT @@ -88,9 +95,9 @@ def create_order_from_cart(payload: OrderCreateRequest) -> dict[str, Any]: :discount_amount, :total_amount, :currency, - CURRENT_TIMESTAMP, - CURRENT_TIMESTAMP, - CURRENT_TIMESTAMP + :now, + :now, + :now ) RETURNING order_id, @@ -126,8 +133,8 @@ def create_order_from_cart(payload: OrderCreateRequest) -> dict[str, Any]: :final_item_amount, :currency, :line_total, - CURRENT_TIMESTAMP, - CURRENT_TIMESTAMP + :now, + :now ) RETURNING order_item_id, @@ -244,6 +251,8 @@ def create_order_from_cart(payload: OrderCreateRequest) -> dict[str, Any]: total_amount = subtotal_amount - discount_amount + now = now_kst_naive() + created_order = connection.execute( insert_order_query, { @@ -254,6 +263,7 @@ def create_order_from_cart(payload: OrderCreateRequest) -> dict[str, Any]: "discount_amount": discount_amount, "total_amount": total_amount, "currency": currency, + "now": now }, ).mappings().first() @@ -277,6 +287,7 @@ def create_order_from_cart(payload: OrderCreateRequest) -> dict[str, Any]: "final_item_amount": item["final_item_amount"], "line_total": item["line_total"], "currency": item["currency"], + "now": now }, ).mappings().first() diff --git a/apps/api/backend/services/payment_service.py b/apps/api/backend/services/payment_service.py index f8fef6b..ca7ae9c 100644 --- a/apps/api/backend/services/payment_service.py +++ b/apps/api/backend/services/payment_service.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timedelta, timezone from decimal import Decimal from typing import Any @@ -9,6 +9,12 @@ from backend.schemas.payment import PaymentSimulationRequest +KST = timezone(timedelta(hours=9)) + + +def now_kst_naive() -> datetime: + return datetime.now(KST).replace(tzinfo=None) + def simulate_payment(payload: PaymentSimulationRequest) -> dict[str, Any]: order_query = text(""" SELECT @@ -46,10 +52,10 @@ def simulate_payment(payload: PaymentSimulationRequest) -> dict[str, Any]: :pg_provider, :transaction_id, :failure_code, - CURRENT_TIMESTAMP, + :now, :paid_at, - CURRENT_TIMESTAMP, - CURRENT_TIMESTAMP + :now, + :now ) RETURNING payment_id, @@ -70,7 +76,7 @@ def simulate_payment(payload: PaymentSimulationRequest) -> dict[str, Any]: UPDATE orders SET order_status = 'paid', - updated_at = CURRENT_TIMESTAMP + updated_at = :now WHERE order_id = :order_id """) @@ -78,7 +84,7 @@ def simulate_payment(payload: PaymentSimulationRequest) -> dict[str, Any]: UPDATE orders SET order_status = 'payment_failed', - updated_at = CURRENT_TIMESTAMP + updated_at = :now WHERE order_id = :order_id """) @@ -86,8 +92,8 @@ def simulate_payment(payload: PaymentSimulationRequest) -> dict[str, Any]: UPDATE carts SET cart_status = 'checked_out', - checked_out_at = CURRENT_TIMESTAMP, - updated_at = CURRENT_TIMESTAMP + checked_out_at = :now, + updated_at = :now WHERE cart_id = :cart_id AND cart_status = 'active' """) @@ -112,23 +118,30 @@ def simulate_payment(payload: PaymentSimulationRequest) -> dict[str, Any]: requested_amount = Decimal(str(order["total_amount"])) currency = order["currency"] + now = now_kst_naive() if payload.simulate_result == "success": payment_status = "paid" paid_amount = requested_amount failure_code = None - paid_at = datetime.now() + paid_at = now pg_provider = "mock_pg" transaction_id = f"tx-{payload.order_id}" connection.execute( update_order_success_query, - {"order_id": payload.order_id}, + { + "order_id": payload.order_id, + "now": now, + }, ) connection.execute( update_cart_checked_out_query, - {"cart_id": order["cart_id"]}, + { + "cart_id": order["cart_id"], + "now": now, + }, ) else: payment_status = "failed" @@ -137,9 +150,13 @@ def simulate_payment(payload: PaymentSimulationRequest) -> dict[str, Any]: paid_at = None pg_provider = "mock_pg" transaction_id = None + connection.execute( update_order_failed_query, - {"order_id": payload.order_id}, + { + "order_id": payload.order_id, + "now": now, + }, ) created_payment = connection.execute( @@ -154,6 +171,7 @@ def simulate_payment(payload: PaymentSimulationRequest) -> dict[str, Any]: "transaction_id": transaction_id, "failure_code": failure_code, "paid_at": paid_at, + "now": now, }, ).mappings().first() diff --git a/apps/api/backend/services/review_service.py b/apps/api/backend/services/review_service.py index 6465252..ace3540 100644 --- a/apps/api/backend/services/review_service.py +++ b/apps/api/backend/services/review_service.py @@ -1,4 +1,6 @@ from typing import Any +from decimal import Decimal +from uuid import UUID from fastapi import HTTPException, status from sqlalchemy import text @@ -338,4 +340,68 @@ def delete_review(review_id: str, payload: ReviewDeleteRequest) -> dict[str, Any "review_status": deleted_review["review_status"], "updated_at": deleted_review["updated_at"], "message": "Review deleted successfully", + } + +def list_product_reviews(product_id: UUID) -> dict[str, Any]: + product_query = text(""" + SELECT product_id + FROM products + WHERE product_id = :product_id + AND is_active = TRUE + LIMIT 1 + """) + + reviews_query = text(""" + SELECT + r.review_id, + r.user_id, + r.product_id, + r.order_item_id, + r.rating, + r.review_title, + r.review_content, + r.review_status, + r.created_at, + r.updated_at, + u.user_name + FROM reviews r + JOIN users u + ON u.user_id = r.user_id + WHERE r.product_id = :product_id + AND r.review_status = 'visible' + ORDER BY r.created_at DESC + """) + + with engine.begin() as connection: + product = connection.execute( + product_query, + {"product_id": product_id}, + ).mappings().first() + + if product is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Product not found", + ) + + review_rows = connection.execute( + reviews_query, + {"product_id": product_id}, + ).mappings().all() + + reviews = [dict(row) for row in review_rows] + total_reviews = len(reviews) + + average_rating = None + if total_reviews > 0: + average_rating = ( + sum(Decimal(str(review["rating"])) for review in reviews) + / Decimal(str(total_reviews)) + ).quantize(Decimal("0.1")) + + return { + "product_id": product_id, + "total_reviews": total_reviews, + "average_rating": average_rating, + "reviews": reviews, } \ No newline at end of file diff --git a/apps/web/src/components/layout/MainLayout.tsx b/apps/web/src/components/layout/MainLayout.tsx index 6c506bb..a02e2d3 100644 --- a/apps/web/src/components/layout/MainLayout.tsx +++ b/apps/web/src/components/layout/MainLayout.tsx @@ -43,7 +43,7 @@ export function MainLayout() { {user ? ( <> 주문 내역 - {user.user_name} + {user.user_name}님 diff --git a/apps/web/src/features/cart/CartPage.tsx b/apps/web/src/features/cart/CartPage.tsx index 70ee303..fc56a67 100644 --- a/apps/web/src/features/cart/CartPage.tsx +++ b/apps/web/src/features/cart/CartPage.tsx @@ -7,6 +7,7 @@ import { } from "../../services/cartApi"; import { clearStoredCartId, + clearStoredPendingOrder, getStoredCartId, getStoredUser, } from "../../stores/userStore"; @@ -109,6 +110,7 @@ export function CartPage() { const cartData = await getCart(storedCartId); setCart(cartData); } catch { + clearStoredPendingOrder(storedCartId); clearStoredCartId(); setCart(null); setErrorMessage(null); @@ -141,6 +143,7 @@ export function CartPage() { setErrorMessage(null); await removeCartItem(storedCartId, cartItemId); + clearStoredPendingOrder(storedCartId); const refreshedCart = await getCart(storedCartId); setCart(refreshedCart); @@ -173,6 +176,8 @@ export function CartPage() { ); } + clearStoredPendingOrder(storedCartId); + const refreshedCart = await getCart(storedCartId); setCart(refreshedCart); } catch { diff --git a/apps/web/src/features/checkout/CheckoutPage.tsx b/apps/web/src/features/checkout/CheckoutPage.tsx index a0ca8ee..c08a509 100644 --- a/apps/web/src/features/checkout/CheckoutPage.tsx +++ b/apps/web/src/features/checkout/CheckoutPage.tsx @@ -6,10 +6,13 @@ import { enterCheckout, simulatePayment, } from "../../services/checkoutApi"; -import { +import { clearStoredCartId, - getStoredCartId, - getStoredUser, + clearStoredPendingOrder, + getStoredCartId, + getStoredPendingOrder, + getStoredUser, + setStoredPendingOrder, } from "../../stores/userStore"; import type { CheckoutSummary, @@ -76,6 +79,7 @@ export function CheckoutPage() { const storedUser = getStoredUser(); const userId = storedUser?.user_id ?? null; + const [checkoutCartId, setCheckoutCartId] = useState(() => getStoredCartId(), ); @@ -83,7 +87,17 @@ export function CheckoutPage() { const [checkout, setCheckout] = useState(null); const [couponCode, setCouponCode] = useState(""); const [appliedCoupon, setAppliedCoupon] = useState(null); - const [createdOrder, setCreatedOrder] = useState(null); + const [createdOrder, setCreatedOrder] = useState( + () => { + const storedCartId = getStoredCartId(); + + if (!storedCartId) { + return null; + } + + return getStoredPendingOrder(storedCartId) as OrderCreateResponse | null; + }, + ); const [paymentResult, setPaymentResult] = useState( null, ); @@ -137,7 +151,12 @@ export function CheckoutPage() { const checkoutData = await enterCheckout(checkoutCartId); setCheckout(checkoutData); } catch { + if (checkoutCartId) { + clearStoredPendingOrder(checkoutCartId); + } + clearStoredCartId(); + setCheckoutCartId(null); setCheckout(null); setErrorMessage( "장바구니 정보를 찾을 수 없습니다. 상품을 다시 장바구니에 담아주세요.", @@ -214,10 +233,22 @@ export function CheckoutPage() { const order = await createOrder({ cart_id: checkoutCartId, - coupon_name: getAppliedCouponName(appliedCoupon, "") || null, + coupon_name: + appliedCoupon?.coupon?.coupon_name ?? + appliedCoupon?.coupon_name ?? + appliedCoupon?.coupon_code ?? + null, }); setCreatedOrder(order); + setStoredPendingOrder({ + order_id: order.order_id, + cart_id: order.cart_id, + order_status: order.order_status, + discount_amount: order.discount_amount, + total_amount: order.total_amount, + currency: order.currency, + }); setActionMessageType("success"); setActionMessage("주문이 생성되었습니다. 결제 시뮬레이션을 진행할 수 있습니다."); } catch (error) { @@ -256,11 +287,15 @@ export function CheckoutPage() { setPaymentResult(result); if (result.payment_status === "paid") { + if (checkoutCartId) { + clearStoredPendingOrder(checkoutCartId); + } + clearStoredCartId(); setActionMessageType("success"); setActionMessage("결제 성공 시뮬레이션이 완료되었습니다."); } else { - setActionMessageType("error") + setActionMessageType("error"); setActionMessage("결제 실패 시뮬레이션이 완료되었습니다."); } } catch { @@ -402,7 +437,11 @@ export function CheckoutPage() { {appliedCoupon && (

- 적용된 쿠폰: {getAppliedCouponName(appliedCoupon, "확인 불가")} + 적용된 쿠폰:{" "} + {appliedCoupon.coupon?.coupon_name ?? + appliedCoupon.coupon?.coupon_name ?? + appliedCoupon.coupon?.coupon_name ?? + "확인 불가"}

)} diff --git a/apps/web/src/features/orders/OrderHistoryPage.tsx b/apps/web/src/features/orders/OrderHistoryPage.tsx index 9f0ad83..e95f1f4 100644 --- a/apps/web/src/features/orders/OrderHistoryPage.tsx +++ b/apps/web/src/features/orders/OrderHistoryPage.tsx @@ -1,8 +1,509 @@ +import { useEffect, useMemo, useState } from "react"; +import { Link } from "react-router-dom"; +import { getOrderHistory } from "../../services/orderApi"; +import { getStoredUser } from "../../stores/userStore"; +import type { OrderHistoryItem, OrderItem } from "../../types/order"; + +type PaymentStatusFilter = "all" | "paid" | "failed" | "unpaid"; + +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); +} + +const KST_TIME_ZONE = "Asia/Seoul"; + +function parseServerDateTime(value: string) { + const normalizedValue = value.includes("T") + ? value + : value.replace(" ", "T"); + + const hasTimezone = /([zZ]|[+-]\d{2}:\d{2})$/.test(normalizedValue); + + if (hasTimezone) { + return new Date(normalizedValue); + } + + return new Date(`${normalizedValue}+09:00`); +} + +function formatDateTime(value?: string | null) { + if (!value) { + return "-"; + } + + const date = parseServerDateTime(value); + + if (Number.isNaN(date.getTime())) { + return value; + } + + return new Intl.DateTimeFormat("ko-KR", { + timeZone: KST_TIME_ZONE, + dateStyle: "medium", + timeStyle: "short", + }).format(date); +} + +function getOrderDateValue(order: OrderHistoryItem) { + return order.history_event_at ?? order.ordered_at ?? order.created_at ?? ""; +} + +function getKstDateParts(value: string) { + const date = parseServerDateTime(value); + + if (Number.isNaN(date.getTime())) { + return null; + } + + const parts = new Intl.DateTimeFormat("ko-KR", { + timeZone: KST_TIME_ZONE, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(date); + + const year = parts.find((part) => part.type === "year")?.value; + const month = parts.find((part) => part.type === "month")?.value; + const day = parts.find((part) => part.type === "day")?.value; + + if (!year || !month || !day) { + return null; + } + + return { year, month, day }; +} + +function getOrderDateKey(order: OrderHistoryItem) { + const value = getOrderDateValue(order); + + if (!value) { + return "unknown"; + } + + const dateParts = getKstDateParts(value); + + if (!dateParts) { + return value.slice(0, 10) || "unknown"; + } + + return `${dateParts.year}-${dateParts.month}-${dateParts.day}`; +} + +function getOrderDateLabel(order: OrderHistoryItem) { + const value = getOrderDateValue(order); + + if (!value) { + return "날짜 미지정"; + } + + const date = parseServerDateTime(value); + + if (Number.isNaN(date.getTime())) { + return value.slice(0, 10) || "날짜 미지정"; + } + + return new Intl.DateTimeFormat("ko-KR", { + timeZone: KST_TIME_ZONE, + dateStyle: "long", + }).format(date); +} + +function getOrderStatusLabel(status: string) { + const statusMap: Record = { + created: "주문 생성", + paid: "주문 완료", + payment_failed: "주문 실패", + cancelled: "주문 취소", + }; + + return statusMap[status] ?? status; +} + +function getPaymentStatusLabel(status?: string | null) { + if (!status) { + return "결제 전"; + } + + const statusMap: Record = { + paid: "결제 성공", + failed: "결제 실패", + pending: "결제 대기", + }; + + return statusMap[status] ?? status; +} + +function matchesPaymentStatusFilter( + order: OrderHistoryItem, + filter: PaymentStatusFilter, +) { + if (filter === "all") { + return true; + } + + if (filter === "paid") { + return order.payment_status === "paid"; + } + + if (filter === "failed") { + return order.payment_status === "failed"; + } + + return !order.payment_status; +} + +function getStatusTone(status?: string | null) { + if (status === "paid") { + return "success"; + } + + if (status === "failed" || status === "payment_failed") { + return "error"; + } + + return "neutral"; +} + +function getOrderItems(order: OrderHistoryItem): OrderItem[] { + return order.items ?? []; +} + export function OrderHistoryPage() { + const storedUser = getStoredUser(); + const userId = storedUser?.user_id ?? null; + + const [orders, setOrders] = useState([]); + const [totalOrders, setTotalOrders] = useState(0); + const [paymentStatusFilter, setPaymentStatusFilter] = + useState("all"); + const [isLoading, setIsLoading] = useState(Boolean(userId)); + const [errorMessage, setErrorMessage] = useState(null); + + const filteredOrders = useMemo(() => { + return orders.filter((order) => + matchesPaymentStatusFilter(order, paymentStatusFilter), + ); + }, [orders, paymentStatusFilter]); + + const sortedOrders = useMemo(() => { + return [...filteredOrders].sort((a, b) => { + const aValue = a.history_event_at ?? a.ordered_at ?? a.created_at ?? ""; + const bValue = b.history_event_at ?? b.ordered_at ?? b.created_at ?? ""; + + const aTime = aValue ? parseServerDateTime(aValue).getTime() : 0; + const bTime = bValue ? parseServerDateTime(bValue).getTime() : 0; + + const timeDiff = + (Number.isNaN(bTime) ? 0 : bTime) - (Number.isNaN(aTime) ? 0 : aTime); + + if (timeDiff !== 0) { + return timeDiff; + } + + return String(b.order_history_id ?? b.order_id).localeCompare( + String(a.order_history_id ?? a.order_id), + ); + }); + }, [filteredOrders]); + + const paymentStatusCounts = useMemo(() => { + return orders.reduce( + (acc, order) => { + if (order.payment_status === "paid") { + acc.paid += 1; + } else if (order.payment_status === "failed") { + acc.failed += 1; + } else { + acc.unpaid += 1; + } + + return acc; + }, + { + paid: 0, + failed: 0, + unpaid: 0, + }, + ); + }, [orders]); + + const groupedOrders = useMemo(() => { + const groups = new Map< + string, + { + dateLabel: string; + orders: OrderHistoryItem[]; + } + >(); + + sortedOrders.forEach((order) => { + const dateKey = getOrderDateKey(order); + const existingGroup = groups.get(dateKey); + + if (existingGroup) { + existingGroup.orders.push(order); + return; + } + + groups.set(dateKey, { + dateLabel: getOrderDateLabel(order), + orders: [order], + }); + }); + + return Array.from(groups.entries()).map(([dateKey, group]) => ({ + dateKey, + ...group, + })); + }, [sortedOrders]); + + useEffect(() => { + async function loadOrders() { + if (!userId) { + setIsLoading(false); + return; + } + + try { + setIsLoading(true); + setErrorMessage(null); + + const orderHistory = await getOrderHistory(userId); + + setOrders(orderHistory.orders); + setTotalOrders(orderHistory.total_orders); + } catch { + setErrorMessage("주문 내역을 불러오지 못했습니다."); + } finally { + setIsLoading(false); + } + } + + loadOrders(); + }, [userId]); + + if (!userId) { + return ( +
+
+

Orders

+

주문 내역

+

로그인 후 주문 및 결제 상태를 확인할 수 있습니다.

+
+ +
+ 주문 내역을 확인하려면 로그인이 필요합니다. +
+ + 로그인 + + + 회원가입 + +
+
+
+ ); + } + return ( -
-

주문 내역

-

D2C-39에서 주문 내역 조회와 결제 상태 확인 흐름을 구현합니다.

+
+
+

Orders

+

주문 내역

+

생성된 주문과 결제 성공/실패 시뮬레이션 결과를 확인합니다.

+
+ + {isLoading ? ( +
주문 내역을 불러오는 중입니다.
+ ) : errorMessage ? ( +
{errorMessage}
+ ) : orders.length === 0 ? ( +
+ 아직 주문 내역이 없습니다. +
+ + 상품 둘러보기 + +
+
+ ) : ( + <> +
+
+ 총 {totalOrders}건의 주문이 있습니다. + {paymentStatusFilter === "all" ? ( + + {" "} + 결제 성공 {paymentStatusCounts.paid}건 · 결제 실패{" "} + {paymentStatusCounts.failed}건 · 결제 전 {paymentStatusCounts.unpaid}건입니다. + + ) : ( + 현재 조건에 맞는 주문은 {filteredOrders.length}건입니다. + )} +
+
+ + {sortedOrders.length === 0 ? ( +
+ 선택한 결제 상태에 해당하는 주문이 없습니다. +
+ ) : ( +
+ {groupedOrders.map((group) => ( +
+
+

{group.dateLabel}

+ + {group.dateKey === groupedOrders[0]?.dateKey && ( + + )} +
+ +
+ {group.orders.map((order) => { + const orderItems = getOrderItems(order); + const orderStatusTone = getStatusTone(order.order_status); + const paymentStatusTone = getStatusTone(order.payment_status); + + return ( +
+
+
+

+ {formatDateTime(order.history_event_at ?? order.ordered_at ?? order.created_at)} +

+

주문번호 {order.order_id}

+
+ +
+ + 주문: {getOrderStatusLabel(order.order_status)} + + + 결제: {getPaymentStatusLabel(order.payment_status)} + +
+
+ +
+
+ 상품 금액 + + {formatPrice( + order.subtotal_amount ?? order.total_amount, + order.currency, + )} + +
+ +
+ 할인 금액 + -{formatPrice(order.discount_amount ?? 0, order.currency)} +
+ +
+ 쿠폰 + {order.coupon_name ?? "미적용"} +
+ +
+ 최종 결제 금액 + {formatPrice(order.total_amount, order.currency)} +
+
+ +
+ {orderItems.length === 0 ? ( +

+ 주문 상품 상세가 없습니다. +

+ ) : ( + orderItems.map((item) => { + const canCreateReview = + order.order_status === "paid" && order.payment_status === "paid"; + + return ( +
+
+

{item.product_name ?? item.product_id}

+

+ 수량 {item.quantity}개 · 단가{" "} + {formatPrice(item.unit_price, item.currency)} +

+
+ +
+ + {formatPrice( + item.line_total ?? + item.final_item_amount ?? + Number(item.unit_price) * Number(item.quantity), + item.currency, + )} + + + {canCreateReview && ( + + 리뷰 작성 + + )} +
+
+ ); + }) + )} +
+
+ ); + })} +
+
+ ))} +
+ )} + + )}
); } \ 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 6d7edbd..ecba20b 100644 --- a/apps/web/src/features/products/ProductDetailPage.tsx +++ b/apps/web/src/features/products/ProductDetailPage.tsx @@ -1,14 +1,21 @@ import { useEffect, useState } from "react"; -import { Link, useParams } from "react-router-dom"; +import { Link, useLocation, useParams } from "react-router-dom"; import { addCartItem, createCart } from "../../services/cartApi"; import { getProductDetail } from "../../services/catalogApi"; +import { getProductReviews } from "../../services/reviewApi"; import { clearStoredCartId, + clearStoredPendingOrder, getStoredCartId, getStoredUser, setStoredCartId, } from "../../stores/userStore"; import type { ProductDetail } from "../../types/catalog"; +import type { ProductReviewItem, ReviewCreateResponse } from "../../types/review"; + +type ProductDetailLocationState = { + createdReview?: ReviewCreateResponse; +}; function formatPrice(value: string | number, currency: string) { const numericValue = Number(value); @@ -24,6 +31,33 @@ function formatPrice(value: string | number, currency: string) { }).format(numericValue); } +function formatReviewerName(userName?: string | null) { + const normalizedUserName = userName?.trim(); + + return normalizedUserName ? `${normalizedUserName}님` : "구매자님"; +} + +function getReviewRecommendationLabel(reviewContent: string) { + const firstLine = reviewContent.split("\n")[0]?.trim(); + + if (firstLine.startsWith("상품 추천 여부:")) { + return firstLine; + } + + return "상품 추천 여부: 미기록"; +} + +function getReviewDetailContent(reviewContent: string) { + const lines = reviewContent.split("\n"); + const firstLine = lines[0]?.trim(); + + if (firstLine.startsWith("상품 추천 여부:")) { + return lines.slice(1).join("\n").trim(); + } + + return reviewContent; +} + export function ProductDetailPage() { const { productId } = useParams(); @@ -37,6 +71,19 @@ export function ProductDetailPage() { const user = getStoredUser(); + const location = useLocation(); + const locationState = location.state as ProductDetailLocationState | null; + + const [reviews, setReviews] = useState([]); + const [reviewSummary, setReviewSummary] = useState<{ + totalReviews: number; + averageRating: string | number | null; + }>({ + totalReviews: 0, + averageRating: null, + }); + const [reviewErrorMessage, setReviewErrorMessage] = useState(null); + useEffect(() => { async function loadProductDetail() { if (!productId) { @@ -61,6 +108,55 @@ export function ProductDetailPage() { loadProductDetail(); }, [productId]); + useEffect(() => { + async function loadProductReviews() { + if (!productId) { + return; + } + + try { + setReviewErrorMessage(null); + + const reviewData = await getProductReviews(productId); + + setReviews(reviewData.reviews); + setReviewSummary({ + totalReviews: reviewData.total_reviews, + averageRating: reviewData.average_rating ?? null, + }); + } catch { + setReviewErrorMessage("상품 리뷰를 불러오지 못했습니다."); + } + } + + loadProductReviews(); + }, [productId]); + + useEffect(() => { + const createdReview = locationState?.createdReview; + + if (!createdReview) { + return; + } + + setReviews((currentReviews) => { + const alreadyExists = currentReviews.some( + (review) => review.review_id === createdReview.review_id, + ); + + if (alreadyExists) { + return currentReviews; + } + + return [createdReview, ...currentReviews]; + }); + + setReviewSummary((currentSummary) => ({ + totalReviews: currentSummary.totalReviews + 1, + averageRating: currentSummary.averageRating, + })); + }, [locationState?.createdReview]); + const handleDecreaseQuantity = () => { setQuantity((currentQuantity) => Math.max(1, currentQuantity - 1)); }; @@ -112,6 +208,7 @@ export function ProductDetailPage() { quantity, }); } catch { + clearStoredPendingOrder(cartId); clearStoredCartId(); const createdCart = await createCart({ @@ -127,6 +224,7 @@ export function ProductDetailPage() { }); } + clearStoredPendingOrder(cartId); setCartMessage("상품을 장바구니에 담았습니다."); } catch { setCartErrorMessage("상품 수량은 최대 99개까지만 담을 수 있습니다."); @@ -239,6 +337,64 @@ export function ProductDetailPage() { + +
+
+
+

Reviews

+

상품 리뷰

+

구매자가 남긴 평점과 상세 후기를 확인해보세요.

+
+ +
+ + {reviewSummary.averageRating + ? Number(reviewSummary.averageRating).toFixed(1) + : "-"} + + 리뷰 {reviewSummary.totalReviews}개 +
+
+ + {reviewErrorMessage ? ( +
{reviewErrorMessage}
+ ) : reviews.length === 0 ? ( +
아직 작성된 리뷰가 없습니다.
+ ) : ( +
+ {reviews.map((review) => ( +
+
+
+ {review.review_title} +

+ {formatReviewerName(review.user_name)} ·{" "} + {new Intl.DateTimeFormat("ko-KR", { + dateStyle: "medium", + }).format(new Date(review.created_at))} +

+
+ +
+ {"★".repeat(review.rating)} + {"☆".repeat(5 - review.rating)} +
+
+ +

+ {getReviewRecommendationLabel(review.review_content)} +

+ + {getReviewDetailContent(review.review_content) && ( +

+ {getReviewDetailContent(review.review_content)} +

+ )} +
+ ))} +
+ )} +
); } \ 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 72129e5..9bc4644 100644 --- a/apps/web/src/features/products/ProductListPage.tsx +++ b/apps/web/src/features/products/ProductListPage.tsx @@ -12,6 +12,7 @@ import { addCartItem, createCart } from "../../services/cartApi"; import { getCategories, getProducts } from "../../services/catalogApi"; import { clearStoredCartId, + clearStoredPendingOrder, getStoredCartId, getStoredUser, setStoredCartId, @@ -179,6 +180,7 @@ export function ProductListPage() { quantity: QUICK_ADD_QUANTITY, }); } catch { + clearStoredPendingOrder(cartId); clearStoredCartId(); const createdCart = await createCart({ @@ -193,7 +195,9 @@ export function ProductListPage() { quantity: QUICK_ADD_QUANTITY, }); } - + + clearStoredPendingOrder(cartId); + setCartFeedbackMessage("상품을 장바구니에 담았습니다."); } catch { setCartErrorMessage("상품 수량은 최대 99개까지만 담을 수 있습니다."); } finally { diff --git a/apps/web/src/features/reviews/ReviewCreatePage.tsx b/apps/web/src/features/reviews/ReviewCreatePage.tsx index 6deb8c1..67bbc37 100644 --- a/apps/web/src/features/reviews/ReviewCreatePage.tsx +++ b/apps/web/src/features/reviews/ReviewCreatePage.tsx @@ -1,8 +1,309 @@ +import { FormEvent, useState } from "react"; +import { Link, useNavigate, useSearchParams } from "react-router-dom"; +import { ApiError } from "../../services/apiClient"; +import { createReview } from "../../services/reviewApi"; +import { getStoredUser } from "../../stores/userStore"; + +const MIN_RATING = 1; +const MAX_RATING = 5; + +type ProductRecommendation = "recommend" | "not_recommend"; + +function getValidId(value: string | null) { + if (!value || value === "undefined" || value === "null") { + return null; + } + + return value; +} + export function ReviewCreatePage() { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + + const storedUser = getStoredUser(); + const userId = storedUser?.user_id ?? null; + + const productId = searchParams.get("product_id"); + const orderItemId = searchParams.get("order_item_id"); + const productName = searchParams.get("product_name") ?? "주문 상품"; + + const validProductId = getValidId(productId); + const validOrderItemId = getValidId(orderItemId); + + const [recommendation, setRecommendation] = + useState(null); + const [rating, setRating] = useState(0); + const [reviewTitle, setReviewTitle] = useState(""); + const [reviewContent, setReviewContent] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + + const isInvalidReviewTarget = !validProductId || !validOrderItemId; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + + setErrorMessage(null); + setSuccessMessage(null); + + if (!userId) { + setErrorMessage("로그인 후 리뷰를 작성할 수 있습니다."); + return; + } + + if (!validProductId || !validOrderItemId) { + setErrorMessage("리뷰를 작성할 주문 상품 정보를 확인할 수 없습니다."); + return; + } + + if (!recommendation) { + setErrorMessage("상품 추천 여부를 선택해주세요."); + return; + } + + if (rating < MIN_RATING || rating > MAX_RATING) { + setErrorMessage("평점을 선택해주세요."); + return; + } + + const normalizedTitle = reviewTitle.trim(); + const normalizedContent = reviewContent.trim(); + const recommendationLabel = + recommendation === "recommend" ? "추천" : "비추천"; + + const defaultReviewTitle = + recommendation === "recommend" + ? "만족스러운 구매 경험이었습니다." + : "아쉬움이 남는 구매 경험이었습니다."; + + const defaultReviewContent = `상품 추천 여부: ${recommendationLabel}`; + + const finalReviewContent = normalizedContent + ? `${defaultReviewContent}\n\n${normalizedContent}` + : defaultReviewContent; + + try { + setIsSubmitting(true); + + const reviewPayload = { + user_id: userId, + product_id: validProductId, + order_item_id: validOrderItemId, + rating, + review_title: normalizedTitle || defaultReviewTitle, + review_content: finalReviewContent, + }; + + const createdReview = await createReview(reviewPayload); + + setSuccessMessage("리뷰가 등록되었습니다."); + + navigate(`/products/${validProductId}`, { + state: { + createdReview, + }, + }); + } catch (error) { + if ( + error instanceof ApiError && + error.detail === "Review already exists for this order item" + ) { + setErrorMessage("이미 해당 주문 상품에 대한 리뷰를 작성했습니다."); + } else if ( + error instanceof ApiError && + error.detail === "Review can only be created for purchased products" + ) { + setErrorMessage("구매 완료된 상품에 대해서만 리뷰를 작성할 수 있습니다."); + } else if (error instanceof ApiError) { + console.error("review create error detail", error.detail); + + setErrorMessage( + typeof error.detail === "string" + ? error.detail + : JSON.stringify(error.detail, null, 2), + ); + } else { + setErrorMessage("리뷰 등록에 실패했습니다. 잠시 후 다시 시도해주세요."); + } + } finally { + setIsSubmitting(false); + } + }; + + if (!userId) { + return ( +
+
+

Review

+

리뷰 작성

+

로그인 후 구매한 상품에 대한 리뷰를 작성할 수 있습니다.

+
+ +
+ 리뷰를 작성하려면 로그인이 필요합니다. +
+ + 로그인 + + + 주문 내역으로 돌아가기 + +
+
+
+ ); + } + + if (isInvalidReviewTarget) { + return ( +
+
+

Review

+

리뷰 작성

+

리뷰를 작성할 주문 상품 정보를 확인할 수 없습니다.

+
+ +
+ 잘못된 접근입니다. 주문 내역에서 리뷰 작성 버튼을 통해 다시 시도해주세요. +
+ + 주문 내역으로 돌아가기 + +
+
+
+ ); + } + return ( -
-

리뷰 작성

-

D2C-40에서 주문 상품 기반 리뷰 작성 흐름을 구현합니다.

+
+
+

Review

+

리뷰 작성

+

구매한 상품에 대한 평점과 후기를 남겨주세요.

+
+ +
+
+
+ 리뷰 작성 상품 + {productName} +
+ + {validProductId && ( + + 상품 상세 보기 + + )} +
+ + {errorMessage &&
{errorMessage}
} + {successMessage &&
{successMessage}
} + +
+
+ 상품 추천 (필수 사항) + 이 상품을 전반적으로 추천하시나요? +
+ +
+ + + +
+
+ +
+
+ 평점 (필수 사항) + 별점을 선택해주세요. +
+ +
+ {[1, 2, 3, 4, 5].map((score) => ( + + ))} +
+
+ + + +