From 096c3551219bf0c835234287dd2e9666d5693436 Mon Sep 17 00:00:00 2001
From: jjunier
Date: Sat, 16 May 2026 23:16:28 +0900
Subject: [PATCH 1/2] feat(orders): implement order history payment status flow
[D2C-39]
- implement order history page with payment status filtering and date grouping
- render order-created and payment-simulated events as separate history records
- add order history API client and frontend order history types
- add event-level order history identifiers to prevent duplicate render keys
- standardize order and payment timestamps using KST-based application time
- preserve pending checkout order state across cart navigation until cart changes
- clear pending order state when cart items are added, removed, or updated
---
apps/api/backend/schemas/order_history.py | 4 +
.../backend/services/order_history_service.py | 54 +-
apps/api/backend/services/order_service.py | 21 +-
apps/api/backend/services/payment_service.py | 42 +-
apps/web/src/features/cart/CartPage.tsx | 5 +
.../src/features/checkout/CheckoutPage.tsx | 53 +-
.../src/features/orders/OrderHistoryPage.tsx | 489 +++++++++++++++++-
.../features/products/ProductDetailPage.tsx | 3 +
.../src/features/products/ProductListPage.tsx | 6 +-
apps/web/src/services/orderApi.ts | 6 +
apps/web/src/stores/userStore.ts | 56 ++
apps/web/src/styles/global.css | 280 ++++++++++
apps/web/src/types/order.ts | 38 ++
13 files changed, 1018 insertions(+), 39 deletions(-)
create mode 100644 apps/web/src/services/orderApi.ts
create mode 100644 apps/web/src/types/order.ts
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/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/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..dada38a 100644
--- a/apps/web/src/features/orders/OrderHistoryPage.tsx
+++ b/apps/web/src/features/orders/OrderHistoryPage.tsx
@@ -1,8 +1,491 @@
+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) => (
+
+
+
{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,
+ )}
+
+
+ ))
+ )}
+
+
+ );
+ })}
+
+
+ ))}
+
+ )}
+ >
+ )}
);
}
\ 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..496db30 100644
--- a/apps/web/src/features/products/ProductDetailPage.tsx
+++ b/apps/web/src/features/products/ProductDetailPage.tsx
@@ -4,6 +4,7 @@ import { addCartItem, createCart } from "../../services/cartApi";
import { getProductDetail } from "../../services/catalogApi";
import {
clearStoredCartId,
+ clearStoredPendingOrder,
getStoredCartId,
getStoredUser,
setStoredCartId,
@@ -112,6 +113,7 @@ export function ProductDetailPage() {
quantity,
});
} catch {
+ clearStoredPendingOrder(cartId);
clearStoredCartId();
const createdCart = await createCart({
@@ -127,6 +129,7 @@ export function ProductDetailPage() {
});
}
+ clearStoredPendingOrder(cartId);
setCartMessage("상품을 장바구니에 담았습니다.");
} catch {
setCartErrorMessage("상품 수량은 최대 99개까지만 담을 수 있습니다.");
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/services/orderApi.ts b/apps/web/src/services/orderApi.ts
new file mode 100644
index 0000000..f1fb93d
--- /dev/null
+++ b/apps/web/src/services/orderApi.ts
@@ -0,0 +1,6 @@
+import { apiClient } from "./apiClient";
+import type { OrderHistoryResponse } from "../types/order";
+
+export function getOrderHistory(userId: string) {
+ return apiClient(`/orders?user_id=${userId}`);
+}
\ No newline at end of file
diff --git a/apps/web/src/stores/userStore.ts b/apps/web/src/stores/userStore.ts
index 481ff4c..c330849 100644
--- a/apps/web/src/stores/userStore.ts
+++ b/apps/web/src/stores/userStore.ts
@@ -2,6 +2,7 @@ import type { AuthUser } from "../types/auth";
const USER_STORAGE_KEY = "d2c_user";
const CART_STORAGE_KEY = "d2c_cart_id";
+const PENDING_ORDER_STORAGE_KEY = "d2c_pending_order_by_cart";
export const USER_STORAGE_EVENT = "d2c_user_changed";
function notifyUserChanged() {
@@ -31,9 +32,21 @@ export function setStoredUser(user: AuthUser) {
export function clearStoredUser() {
localStorage.removeItem(USER_STORAGE_KEY);
localStorage.removeItem(CART_STORAGE_KEY);
+ localStorage.removeItem(PENDING_ORDER_STORAGE_KEY);
notifyUserChanged();
}
+export type StoredPendingOrder = {
+ order_id: string;
+ cart_id: string;
+ order_status: string;
+ subtotal_amount?: string | number;
+ discount_amount?: string | number;
+ total_amount: string | number;
+ currency: string;
+ ordered_at?: string;
+};
+
export function getStoredCartId(): string | null {
return localStorage.getItem(CART_STORAGE_KEY);
}
@@ -44,4 +57,47 @@ export function setStoredCartId(cartId: string) {
export function clearStoredCartId() {
localStorage.removeItem(CART_STORAGE_KEY);
+}
+
+function getPendingOrderMap() {
+ const rawValue = localStorage.getItem(PENDING_ORDER_STORAGE_KEY);
+
+ if (!rawValue) {
+ return {};
+ }
+
+ try {
+ return JSON.parse(rawValue) as Record;
+ } catch {
+ localStorage.removeItem(PENDING_ORDER_STORAGE_KEY);
+ return {};
+ }
+}
+
+export function getStoredPendingOrder(cartId: string) {
+ const pendingOrderMap = getPendingOrderMap();
+
+ return pendingOrderMap[cartId] ?? null;
+}
+
+export function setStoredPendingOrder(order: StoredPendingOrder) {
+ const pendingOrderMap = getPendingOrderMap();
+
+ pendingOrderMap[order.cart_id] = order;
+
+ localStorage.setItem(
+ PENDING_ORDER_STORAGE_KEY,
+ JSON.stringify(pendingOrderMap),
+ );
+}
+
+export function clearStoredPendingOrder(cartId: string) {
+ const pendingOrderMap = getPendingOrderMap();
+
+ delete pendingOrderMap[cartId];
+
+ localStorage.setItem(
+ PENDING_ORDER_STORAGE_KEY,
+ JSON.stringify(pendingOrderMap),
+ );
}
\ No newline at end of file
diff --git a/apps/web/src/styles/global.css b/apps/web/src/styles/global.css
index bd731ac..28f8f17 100644
--- a/apps/web/src/styles/global.css
+++ b/apps/web/src/styles/global.css
@@ -1405,6 +1405,247 @@ textarea {
width: 100%;
}
+/* Order History Page */
+.order-history-page {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+}
+
+.order-history-header {
+ padding: 32px;
+ border: 1px solid #e5e7eb;
+ border-radius: 24px;
+ background-color: #ffffff;
+}
+
+.order-history-header h1 {
+ margin: 0 0 12px;
+ color: #111827;
+ font-size: clamp(30px, 4vw, 42px);
+ line-height: 1.18;
+ letter-spacing: -0.04em;
+ word-break: keep-all;
+}
+
+.order-history-header p {
+ max-width: 720px;
+ margin: 0;
+ color: #4b5563;
+ line-height: 1.7;
+ word-break: keep-all;
+}
+
+.order-history-count {
+ color: #4b5563;
+ font-size: 14px;
+ font-weight: 700;
+}
+
+.order-history-list {
+ display: flex;
+ flex-direction: column;
+ gap: 18px;
+}
+
+.order-history-card {
+ display: flex;
+ flex-direction: column;
+ gap: 18px;
+ padding: 24px;
+ border: 1px solid #e5e7eb;
+ border-radius: 20px;
+ background-color: #ffffff;
+}
+
+.order-history-card-header {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ align-items: flex-start;
+}
+
+.order-history-date {
+ margin: 0 0 8px;
+ color: #6b7280;
+ font-size: 13px;
+}
+
+.order-history-card-header h2 {
+ margin: 0;
+ color: #111827;
+ font-size: 18px;
+ line-height: 1.45;
+ letter-spacing: -0.02em;
+ word-break: break-all;
+}
+
+.order-status-group {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 8px;
+}
+
+.status-badge {
+ display: inline-flex;
+ min-height: 30px;
+ align-items: center;
+ padding: 0 10px;
+ border-radius: 999px;
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.status-badge.success {
+ color: #166534;
+ background-color: #dcfce7;
+}
+
+.status-badge.error {
+ color: #991b1b;
+ background-color: #fee2e2;
+}
+
+.status-badge.neutral {
+ color: #374151;
+ background-color: #f3f4f6;
+}
+
+.order-history-summary-grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 12px;
+}
+
+.order-history-summary-grid > div {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 14px;
+ border-radius: 14px;
+ background-color: #f9fafb;
+}
+
+.order-history-summary-grid span {
+ color: #6b7280;
+ font-size: 12px;
+}
+
+.order-history-summary-grid strong {
+ color: #111827;
+ font-size: 14px;
+ word-break: break-all;
+}
+
+.order-history-items {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.order-history-item-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 14px;
+ border-radius: 14px;
+ background-color: #f9fafb;
+}
+
+.order-history-item-row h3 {
+ margin: 0 0 6px;
+ color: #111827;
+ font-size: 15px;
+ line-height: 1.45;
+}
+
+.order-history-item-row p,
+.order-history-empty-items {
+ margin: 0;
+ color: #6b7280;
+ font-size: 13px;
+}
+
+.order-history-item-row strong {
+ display: inline-flex;
+ flex-shrink: 0;
+ align-items: center;
+ justify-content: flex-end;
+ min-width: 96px;
+ color: #111827;
+ font-size: 16px;
+ font-weight: 800;
+ text-align: right;
+}
+
+.order-history-groups {
+ display: flex;
+ flex-direction: column;
+ gap: 28px;
+}
+
+.order-history-date-group {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.order-history-date-heading {
+ margin: 0;
+ color: #111827;
+ font-size: 18px;
+ font-weight: 800;
+ letter-spacing: -0.03em;
+}
+
+.order-history-toolbar {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.order-history-count span {
+ margin-left: 6px;
+ color: #6b7280;
+ font-weight: 600;
+}
+
+.order-history-filter {
+ display: inline-flex;
+ flex-shrink: 0;
+ align-items: center;
+ gap: 10px;
+ color: #4b5563;
+ font-size: 14px;
+ font-weight: 700;
+ white-space: nowrap;
+}
+
+.order-history-filter select {
+ min-height: 38px;
+ padding: 0 34px 0 12px;
+ border: 1px solid #d1d5db;
+ border-radius: 10px;
+ color: #111827;
+ background-color: #ffffff;
+ cursor: pointer;
+}
+
+.order-history-filter select:focus {
+ outline: 2px solid #111827;
+ outline-offset: 2px;
+}
+
+.order-history-date-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ min-height: 38px;
+}
+
/* Responsive layout */
@media (max-width: 1080px) {
.product-grid {
@@ -1448,6 +1689,10 @@ textarea {
.checkout-summary-card {
position: static;
}
+
+ .order-history-summary-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
}
@media (max-width: 820px) {
@@ -1527,6 +1772,41 @@ textarea {
.checkout-item {
flex-direction: column;
}
+
+ .order-history-card-header {
+ flex-direction: column;
+ }
+
+ .order-status-group {
+ justify-content: flex-start;
+ }
+
+ .order-history-summary-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .order-history-item-row {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .order-history-item-row strong {
+ min-width: auto;
+ }
+
+ .order-history-date-row {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .order-history-filter {
+ width: 100%;
+ justify-content: space-between;
+ }
+
+ .order-history-filter select {
+ flex: 1;
+ }
}
@media (max-width: 560px) {
diff --git a/apps/web/src/types/order.ts b/apps/web/src/types/order.ts
new file mode 100644
index 0000000..df5fa79
--- /dev/null
+++ b/apps/web/src/types/order.ts
@@ -0,0 +1,38 @@
+export type OrderItem = {
+ order_item_id: string;
+ product_id: string;
+ product_name?: string;
+ quantity: number;
+ unit_price: string | number;
+ discount_amount?: string | number;
+ final_item_amount?: string | number;
+ line_total?: string | number;
+ currency: string;
+};
+
+export type OrderHistoryItem = {
+ order_history_id: string;
+ history_event_type: string;
+ history_event_at: string;
+ order_id: string;
+ user_id: string;
+ cart_id?: string;
+ coupon_name?: string | null;
+ order_status: string;
+ payment_id?: string | null;
+ payment_status?: string | null;
+ subtotal_amount?: string | number;
+ discount_amount?: string | number;
+ total_amount: string | number;
+ currency: string;
+ ordered_at?: string;
+ created_at?: string;
+ updated_at?: string;
+ items: OrderItem[];
+};
+
+export type OrderHistoryResponse = {
+ user_id: string;
+ total_orders: number;
+ orders: OrderHistoryItem[];
+};
\ No newline at end of file
From 1def733fd836973713141d048b2cf16f26300341 Mon Sep 17 00:00:00 2001
From: jjunier
Date: Mon, 18 May 2026 23:19:52 +0900
Subject: [PATCH 2/2] feat(reviews): implement order item review creation flow
[D2C-40]
- add review creation page for purchased order items
- add review CTA to paid order items in order history
- require product recommendation and star rating before review submission
- support optional review title and detailed review content with default fallback text
- add product review list API and render persisted reviews on product detail page
- improve product review card layout, rating display and reviewer name formatting
---
apps/api/backend/api/routes/reviews.py | 22 +-
apps/api/backend/schemas/review.py | 24 +-
apps/api/backend/services/review_service.py | 66 +++
apps/web/src/components/layout/MainLayout.tsx | 2 +-
.../src/features/orders/OrderHistoryPage.tsx | 56 ++-
.../features/products/ProductDetailPage.tsx | 155 +++++-
.../src/features/reviews/ReviewCreatePage.tsx | 307 +++++++++++-
apps/web/src/services/reviewApi.ts | 19 +
apps/web/src/styles/global.css | 458 ++++++++++++++++++
apps/web/src/types/review.ts | 43 ++
10 files changed, 1125 insertions(+), 27 deletions(-)
create mode 100644 apps/web/src/services/reviewApi.ts
create mode 100644 apps/web/src/types/review.ts
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/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/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/orders/OrderHistoryPage.tsx b/apps/web/src/features/orders/OrderHistoryPage.tsx
index dada38a..e95f1f4 100644
--- a/apps/web/src/features/orders/OrderHistoryPage.tsx
+++ b/apps/web/src/features/orders/OrderHistoryPage.tsx
@@ -454,26 +454,44 @@ export function OrderHistoryPage() {
주문 상품 상세가 없습니다.
) : (
- orderItems.map((item) => (
-
-
-
{item.product_name ?? item.product_id}
-
- 수량 {item.quantity}개 · 단가{" "}
- {formatPrice(item.unit_price, item.currency)}
-
+ 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 && (
+
+ 리뷰 작성
+
+ )}
+
-
-
- {formatPrice(
- item.line_total ??
- item.final_item_amount ??
- Number(item.unit_price) * Number(item.quantity),
- item.currency,
- )}
-
-
- ))
+ );
+ })
)}
diff --git a/apps/web/src/features/products/ProductDetailPage.tsx b/apps/web/src/features/products/ProductDetailPage.tsx
index 496db30..ecba20b 100644
--- a/apps/web/src/features/products/ProductDetailPage.tsx
+++ b/apps/web/src/features/products/ProductDetailPage.tsx
@@ -1,7 +1,8 @@
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,
@@ -10,6 +11,11 @@ import {
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);
@@ -25,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();
@@ -38,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) {
@@ -62,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));
};
@@ -242,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/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
+
리뷰 작성
+
구매한 상품에 대한 평점과 후기를 남겨주세요.
+
+
+
);
}
\ No newline at end of file
diff --git a/apps/web/src/services/reviewApi.ts b/apps/web/src/services/reviewApi.ts
new file mode 100644
index 0000000..2bf80ba
--- /dev/null
+++ b/apps/web/src/services/reviewApi.ts
@@ -0,0 +1,19 @@
+import { apiClient } from "./apiClient";
+import type {
+ ProductReviewListResponse,
+ ReviewCreateRequest,
+ ReviewCreateResponse,
+} from "../types/review";
+
+export function createReview(payload: ReviewCreateRequest) {
+ return apiClient("/reviews", {
+ method: "POST",
+ body: payload,
+ });
+}
+
+export function getProductReviews(productId: string) {
+ return apiClient(
+ `/reviews/products/${productId}/reviews`,
+ );
+}
\ No newline at end of file
diff --git a/apps/web/src/styles/global.css b/apps/web/src/styles/global.css
index 28f8f17..e278d3b 100644
--- a/apps/web/src/styles/global.css
+++ b/apps/web/src/styles/global.css
@@ -788,6 +788,130 @@ textarea {
margin-top: 16px;
}
+.product-review-section {
+ display: flex;
+ flex-direction: column;
+ gap: 18px;
+ width: 100%;
+ margin-top: 24px;
+ padding: 24px;
+ border: 1px solid #e5e7eb;
+ border-radius: 20px;
+ background-color: #ffffff;
+}
+
+.product-review-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 20px;
+}
+
+.product-review-header h2 {
+ margin: 0 0 8px;
+ color: #111827;
+ font-size: 26px;
+ letter-spacing: -0.03em;
+}
+
+.product-review-header p {
+ margin: 0;
+ color: #6b7280;
+ font-size: 15px;
+ line-height: 1.6;
+}
+
+.product-review-summary {
+ display: flex;
+ min-width: 120px;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 4px;
+ padding: 12px 14px;
+ border-radius: 14px;
+ background-color: #f9fafb;
+}
+
+.product-review-summary strong {
+ color: #111827;
+ font-size: 28px;
+ font-weight: 900;
+ line-height: 1;
+}
+
+.product-review-summary span {
+ color: #6b7280;
+ font-size: 13px;
+ font-weight: 700;
+}
+
+.product-review-list {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.product-review-card {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ min-height: 104px;
+ padding: 18px 128px 18px 18px;
+ border: 1px solid #f3f4f6;
+ border-radius: 16px;
+ background-color: #f9fafb;
+}
+
+.product-review-card-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.product-review-card-header strong {
+ display: block;
+ margin-bottom: 4px;
+ color: #111827;
+ font-size: 17px;
+ font-weight: 900;
+}
+
+.product-review-card-header p {
+ margin: 0;
+ color: #6b7280;
+ font-size: 13px;
+ font-weight: 600;
+}
+
+.product-review-rating {
+ position: absolute;
+ top: 50%;
+ right: 18px;
+ transform: translateY(-50%);
+ color: #f59e0b;
+ font-size: 22px;
+ line-height: 1;
+ letter-spacing: 2px;
+ white-space: nowrap;
+}
+
+.product-review-content {
+ margin: 2px 0 0;
+ color: #374151;
+ font-size: 15px;
+ line-height: 1.65;
+ white-space: pre-wrap;
+}
+
+.product-review-recommendation {
+ margin: 4px 0 0;
+ color: #111827;
+ font-size: 14px;
+ font-weight: 700;
+}
+
/* Cart Page */
.cart-page {
display: flex;
@@ -1544,6 +1668,14 @@ textarea {
gap: 10px;
}
+.order-history-item-actions {
+ display: inline-flex;
+ flex-shrink: 0;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 12px;
+}
+
.order-history-item-row {
display: flex;
align-items: center;
@@ -1646,6 +1778,269 @@ textarea {
min-height: 38px;
}
+/* Review Create Page */
+.review-create-page {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+}
+
+.review-create-header {
+ padding: 32px;
+ border: 1px solid #e5e7eb;
+ border-radius: 24px;
+ background-color: #ffffff;
+}
+
+.review-create-header h1 {
+ margin: 0 0 12px;
+ color: #111827;
+ font-size: clamp(30px, 4vw, 42px);
+ line-height: 1.18;
+ letter-spacing: -0.04em;
+ word-break: keep-all;
+}
+
+.review-create-header p {
+ max-width: 720px;
+ margin: 0;
+ color: #4b5563;
+ line-height: 1.7;
+ word-break: keep-all;
+}
+
+.review-form {
+ display: flex;
+ flex-direction: column;
+ gap: 18px;
+ padding: 24px;
+ border: 1px solid #e5e7eb;
+ border-radius: 20px;
+ background-color: #ffffff;
+}
+
+.review-target-box {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 16px;
+ border-radius: 16px;
+ background-color: #f9fafb;
+}
+
+.review-target-box span {
+ color: #6b7280;
+ font-size: 14px;
+ font-weight: 800;
+}
+
+.review-target-box strong {
+ color: #111827;
+ font-size: 22px;
+ line-height: 1.45;
+}
+
+.review-target-info {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.review-target-info span {
+ color: #6b7280;
+ font-size: 14px;
+ font-weight: 800;
+}
+
+.review-target-info strong {
+ color: #111827;
+ font-size: 22px;
+ line-height: 1.45;
+ word-break: keep-all;
+}
+
+.review-product-detail-link {
+ display: inline-flex;
+ flex-shrink: 0;
+ min-height: 40px;
+ align-items: center;
+ justify-content: center;
+ padding: 0 16px;
+ border: 1px solid #d1d5db;
+ border-radius: 12px;
+ color: #111827;
+ background-color: #ffffff;
+ font-size: 14px;
+ font-weight: 800;
+ text-decoration: none;
+}
+
+.review-product-detail-link:hover {
+ border-color: #111827;
+}
+
+.review-form .form-field {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.review-form .form-field span {
+ color: #374151;
+ font-size: 14px;
+ font-weight: 800;
+}
+
+.review-form .form-field input,
+.review-form .form-field select,
+.review-form .form-field textarea {
+ width: 100%;
+ border: 1px solid #d1d5db;
+ border-radius: 12px;
+ color: #111827;
+ background-color: #ffffff;
+ font: inherit;
+ font-size: 16px;
+}
+
+.review-form .form-field input::placeholder,
+.review-form .form-field textarea::placeholder {
+ color: #6b7280;
+ font-size: 16px;
+}
+
+.review-form .form-field input,
+.review-form .form-field select {
+ min-height: 44px;
+ padding: 0 12px;
+}
+
+.review-form .form-field textarea {
+ resize: vertical;
+ min-height: 180px;
+ padding: 12px;
+ line-height: 1.6;
+}
+
+.review-form .form-field input:focus,
+.review-form .form-field select:focus,
+.review-form .form-field textarea:focus {
+ outline: 2px solid #111827;
+ outline-offset: 2px;
+}
+
+.review-form-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 12px;
+}
+
+.secondary-link.compact {
+ min-height: 34px;
+ padding: 0 12px;
+ font-size: 13px;
+}
+
+.review-form-section {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.review-form-label {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.review-form-label span {
+ color: #374151;
+ font-size: 16px;
+ font-weight: 800;
+}
+
+.review-form-label small,
+.review-form .form-field small {
+ color: #6b7280;
+ line-height: 1.5;
+ font-size: 13px;
+ font-weight: 700;
+}
+
+.review-recommendation-group {
+ display: flex;
+ gap: 10px;
+}
+
+.review-recommendation-button {
+ min-height: 48px;
+ padding: 0 20px;
+ border: 1px solid #d1d5db;
+ border-radius: 12px;
+ color: #374151;
+ background-color: #ffffff;
+ font-size: 16px;
+ font-weight: 800;
+ cursor: pointer;
+}
+
+.review-recommendation-button.active {
+ border-color: #16a34a;
+ color: #166534;
+ background-color: #dcfce7;
+}
+
+.review-recommendation-button.danger.active {
+ border-color: #dc2626;
+ color: #991b1b;
+ background-color: #fee2e2;
+}
+
+.review-recommendation-button:disabled {
+ cursor: not-allowed;
+ opacity: 0.6;
+}
+
+.review-star-rating {
+ display: flex;
+ gap: 6px;
+}
+
+.review-modify-star-btn {
+ display: inline-flex;
+ width: 46px;
+ height: 46px;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid #d1d5db;
+ border-radius: 12px;
+ color: #9ca3af;
+ background-color: #ffffff;
+ font-size: 26px;
+ line-height: 1;
+ cursor: pointer;
+}
+
+.review-modify-star-btn.active {
+ border-color: #f59e0b;
+ color: #f59e0b;
+ background-color: #fffbeb;
+}
+
+.review-modify-star-btn:disabled {
+ cursor: not-allowed;
+ opacity: 0.6;
+}
+
+.review-form .required-label,
+.review-form-label .required-label {
+ color: #dc2626;
+ font-size: 13px;
+ font-weight: 800;
+}
+
/* Responsive layout */
@media (max-width: 1080px) {
.product-grid {
@@ -1720,6 +2115,30 @@ textarea {
flex-direction: column;
}
+ .product-review-header {
+ flex-direction: column;
+ }
+
+ .product-review-summary {
+ width: 100%;
+ align-items: flex-start;
+ }
+
+ .product-review-card {
+ min-height: auto;
+ padding: 18px;
+ }
+
+ .product-review-card-header {
+ flex-direction: column;
+ }
+
+ .product-review-rating {
+ position: static;
+ transform: none;
+ margin-top: 8px;
+ }
+
.page-size-control {
width: 100%;
justify-content: space-between;
@@ -1790,6 +2209,11 @@ textarea {
flex-direction: column;
}
+ .order-history-item-actions {
+ width: 100%;
+ justify-content: space-between;
+ }
+
.order-history-item-row strong {
min-width: auto;
}
@@ -1807,6 +2231,40 @@ textarea {
.order-history-filter select {
flex: 1;
}
+
+ .review-form-actions {
+ flex-direction: column-reverse;
+ }
+
+ .review-form-actions .primary-button,
+ .review-form-actions .secondary-link {
+ width: 100%;
+ }
+
+ .review-recommendation-group {
+ flex-direction: column;
+ }
+
+ .review-recommendation-button {
+ width: 100%;
+ }
+
+ .review-star-rating {
+ justify-content: space-between;
+ }
+
+ .review-modify-star-btn {
+ flex: 1;
+ }
+
+ .review-target-box {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .review-product-detail-link {
+ width: 100%;
+ }
}
@media (max-width: 560px) {
diff --git a/apps/web/src/types/review.ts b/apps/web/src/types/review.ts
new file mode 100644
index 0000000..4e3fbca
--- /dev/null
+++ b/apps/web/src/types/review.ts
@@ -0,0 +1,43 @@
+export type ReviewCreateRequest = {
+ user_id: string;
+ product_id: string;
+ order_item_id: string;
+ rating: number;
+ review_title: string;
+ review_content: string;
+};
+
+export type ReviewCreateResponse = {
+ review_id: string;
+ user_id: string;
+ product_id: string;
+ order_item_id: string;
+ rating: number;
+ review_title: string;
+ review_content: string;
+ review_status: string;
+ created_at: string;
+ updated_at: string;
+ message: string;
+};
+
+export type ProductReviewItem = {
+ review_id: string;
+ user_id: string;
+ product_id: string;
+ order_item_id: string;
+ rating: number;
+ review_title: string;
+ review_content: string;
+ review_status: string;
+ created_at: string;
+ updated_at?: string | null;
+ user_name?: string | null;
+};
+
+export type ProductReviewListResponse = {
+ product_id: string;
+ total_reviews: number;
+ average_rating?: string | number | null;
+ reviews: ProductReviewItem[];
+};
\ No newline at end of file