diff --git a/apps/web/src/features/coupons/CouponWalletPage.tsx b/apps/web/src/features/coupons/CouponWalletPage.tsx
new file mode 100644
index 0000000..93ef1a7
--- /dev/null
+++ b/apps/web/src/features/coupons/CouponWalletPage.tsx
@@ -0,0 +1,241 @@
+import { useEffect, useState } from "react";
+import { Link } from "react-router-dom";
+import { getUserCoupons } from "../../services/couponApi";
+import { getStoredUser } from "../../stores/userStore";
+import type { UsedCoupon, UserCoupon, UserCouponWalletResponse } from "../../types/coupon";
+
+const KST_TIME_ZONE = "Asia/Seoul";
+
+function formatPrice(value: string | number, currency = "KRW") {
+ const numericValue = Number(value);
+
+ if (Number.isNaN(numericValue)) {
+ return `${value} ${currency}`;
+ }
+
+ return new Intl.NumberFormat("ko-KR", {
+ style: "currency",
+ currency,
+ maximumFractionDigits: 0,
+ }).format(numericValue);
+}
+
+function parseServerDateTime(value: string) {
+ const hasTimezone = /([zZ]|[+-]\d{2}:\d{2})$/.test(value);
+
+ if (hasTimezone) {
+ return new Date(value);
+ }
+
+ const normalizedValue = value.replace(
+ /\.(\d{3})\d+/,
+ ".$1",
+ );
+
+ return new Date(normalizedValue);
+}
+
+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 formatDate(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",
+ }).format(date);
+}
+
+function formatCouponBenefit(coupon: UserCoupon) {
+ if (coupon.coupon_type === "percentage") {
+ return `${Number(coupon.discount_value).toLocaleString("ko-KR")}% 할인`;
+ }
+
+ if (coupon.coupon_type === "fixed_amount") {
+ return `${formatPrice(coupon.discount_value)} 할인`;
+ }
+
+ return `${coupon.discount_value} 할인`;
+}
+
+function CouponCard({ coupon }: { coupon: UserCoupon }) {
+ return (
+
+
+
+ 사용 가능
+
{coupon.coupon_name}
+
+
{formatCouponBenefit(coupon)}
+
+
+
+ 최소 주문 금액 {formatPrice(coupon.minimum_order_amount)}
+
+ 유효기간 {formatDate(coupon.valid_start_at)} ~{" "}
+ {formatDate(coupon.valid_end_at)}
+
+
+
+ );
+}
+
+function UsedCouponCard({ coupon }: { coupon: UsedCoupon }) {
+ return (
+
+
+
+ 사용 완료
+
{coupon.coupon_name}
+
+
{formatCouponBenefit(coupon)}
+
+
+
+ 사용 주문번호 : {coupon.used_order_id}
+ 사용 일시 : {formatDateTime(coupon.used_at)}
+ 최소 주문 금액 : {formatPrice(coupon.minimum_order_amount)}
+
+
+ );
+}
+
+export function CouponWalletPage() {
+ const storedUser = getStoredUser();
+ const userId = storedUser?.user_id ?? null;
+
+ const [couponWallet, setCouponWallet] =
+ useState
(null);
+ const [isLoading, setIsLoading] = useState(Boolean(userId));
+ const [errorMessage, setErrorMessage] = useState(null);
+
+ useEffect(() => {
+ async function loadCoupons() {
+ if (!userId) {
+ setIsLoading(false);
+ return;
+ }
+
+ try {
+ setIsLoading(true);
+ setErrorMessage(null);
+
+ const data = await getUserCoupons(userId);
+ setCouponWallet(data);
+ } catch {
+ setErrorMessage("쿠폰함을 불러오지 못했습니다.");
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ loadCoupons();
+ }, [userId]);
+
+ if (!userId) {
+ return (
+
+
+
Coupons
+
쿠폰함
+
로그인 후 보유 쿠폰과 사용 이력을 확인할 수 있습니다.
+
+
+
+ 쿠폰함을 확인하려면 로그인이 필요합니다.
+
+
+ 로그인
+
+
+ 회원가입
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
Coupons
+
쿠폰함
+
+ 사용 가능한 쿠폰과 결제 완료 주문에서 사용한 쿠폰 이력을 확인합니다.
+
+
+
+ {isLoading ? (
+ 쿠폰함을 불러오는 중입니다.
+ ) : errorMessage ? (
+ {errorMessage}
+ ) : !couponWallet ? (
+ 쿠폰 정보를 확인할 수 없습니다.
+ ) : (
+
+
+
+
사용 가능한 쿠폰
+ {couponWallet.available_coupons.length}개
+
+
+ {couponWallet.available_coupons.length === 0 ? (
+ 현재 사용 가능한 쿠폰이 없습니다.
+ ) : (
+
+ {couponWallet.available_coupons.map((coupon) => (
+
+ ))}
+
+ )}
+
+
+
+
+
사용 완료 쿠폰
+ {couponWallet.used_coupons.length}개
+
+
+ {couponWallet.used_coupons.length === 0 ? (
+ 아직 사용한 쿠폰이 없습니다.
+ ) : (
+
+ {couponWallet.used_coupons.map((coupon) => (
+
+ ))}
+
+ )}
+
+
+ )}
+
+ );
+}
\ 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 ecba20b..81a6d00 100644
--- a/apps/web/src/features/products/ProductDetailPage.tsx
+++ b/apps/web/src/features/products/ProductDetailPage.tsx
@@ -37,6 +37,36 @@ function formatReviewerName(userName?: string | null) {
return normalizedUserName ? `${normalizedUserName}님` : "구매자님";
}
+function parseUtcNaiveDateTime(value: string) {
+ const hasTimezone = /([zZ]|[+-]\d{2}:\d{2})$/.test(value);
+
+ if (hasTimezone) {
+ return new Date(value);
+ }
+
+ const normalizedValue = value.replace(/\.(\d{3})\d+/, ".$1");
+
+ return new Date(`${normalizedValue}Z`);
+}
+
+function formatReviewDateTime(value?: string | null) {
+ if (!value) {
+ return "-";
+ }
+
+ const date = parseUtcNaiveDateTime(value);
+
+ if (Number.isNaN(date.getTime())) {
+ return value;
+ }
+
+ return new Intl.DateTimeFormat("ko-KR", {
+ timeZone: "Asia/Seoul",
+ dateStyle: "medium",
+ timeStyle: "short",
+ }).format(date);
+}
+
function getReviewRecommendationLabel(reviewContent: string) {
const firstLine = reviewContent.split("\n")[0]?.trim();
@@ -369,9 +399,7 @@ export function ProductDetailPage() {
{review.review_title}
{formatReviewerName(review.user_name)} ·{" "}
- {new Intl.DateTimeFormat("ko-KR", {
- dateStyle: "medium",
- }).format(new Date(review.created_at))}
+ {formatReviewDateTime(review.created_at)}
diff --git a/apps/web/src/services/couponApi.ts b/apps/web/src/services/couponApi.ts
new file mode 100644
index 0000000..9a70bfc
--- /dev/null
+++ b/apps/web/src/services/couponApi.ts
@@ -0,0 +1,6 @@
+import { apiClient } from "./apiClient";
+import type { UserCouponWalletResponse } from "../types/coupon";
+
+export function getUserCoupons(userId: string) {
+ return apiClient