diff --git a/index.html b/index.html index 3a7cf21..891f6b8 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,8 @@ MUSINSSAK + +
diff --git a/src/App.tsx b/src/App.tsx index 1d04db1..e84e7e2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,11 +14,14 @@ import { Home, Login, Mypage, + Order, Payment, + PaymentSuccess, ProductDetail, ProductInquiry, SignUp, } from "./pages"; + import type { Message } from "./types/types"; function GlobalAppSetup() { @@ -71,6 +74,8 @@ export default function App() { /> } /> } /> + } /> + } /> } /> diff --git a/src/api/cart.ts b/src/api/cart.ts new file mode 100644 index 0000000..f4418b3 --- /dev/null +++ b/src/api/cart.ts @@ -0,0 +1,116 @@ +import { api } from "../lib/axios"; + +// Cart API 응답 타입들 +type CartItem = { + cartItemId: number; + productId: number; + productName: string; + brandName: string; + productImageUrl: string; + size: string; + quantity: number; + originalPrice: number; + salePrice: number; + discountRate: number; + selected: boolean; + stock: number; +}; + +type CartGetResponse = { + userId: number; + cartItems: CartItem[]; + selectedCount: number; + totalProductAmount: number; + totalCount: number; + totalPrice: number; + discountAmount: number; + deliveryFee: number; + finalAmount: number; +}; + +type CartChangeQuantityResponse = { + cartItemId: number; + oldQuantity: number; + newQuantity: number; + itemTotalPrice: number; + availableStock: number; + canIncrease: boolean; + canDecrease: boolean; +}; + +type CartDeleteResponse = { + deletedCount: number; + remainingItems: number; +}; + +type CartSelectResponse = { + selectedCount: number; + selectedTotalPrice: number; +}; + +type ApiSuccessMessage = { + message: string; +}; + +// 장바구니 조회 +export async function getCart(): Promise { + const res = await api.get("/cart"); + return res.data.data as CartGetResponse; +} + +// 장바구니에 상품 추가 +export async function addToCart( + productOptionId: number, + quantity: number, +): Promise { + const res = await api.post("/cart", { productOptionId, quantity }); + return { message: res.data.message }; +} + +// 장바구니 수량 변경 +export async function changeCartQuantity( + cartItemId: number, + quantity: number, +): Promise { + const res = await api.put(`/cart/${cartItemId}/quantity`, { quantity }); + return res.data.data as CartChangeQuantityResponse; +} + +// 단일 장바구니 항목 삭제 +export async function deleteCartItem( + cartItemId: number, +): Promise { + const res = await api.delete(`/cart/items/${cartItemId}`); + return res.data.data as CartDeleteResponse; +} + +// 여러 장바구니 항목 삭제 +export async function deleteCartSelected( + cartItemIds: number[], +): Promise { + const res = await api.delete("/cart/items", { data: { cartItemIds } }); + return res.data.data as CartDeleteResponse; +} + +// 장바구니 선택 상태 변경 (특정 항목들) +export async function selectCartItems( + cartItemIds: number[], + isSelected: boolean, +): Promise { + const res = await api.put("/cart/select", { cartItemIds, isSelected }); + return res.data.data as CartSelectResponse; +} + +// 장바구니 전체 선택/해제 +export async function selectCartAll( + isSelected: boolean, +): Promise { + const res = await api.put("/cart/select", { selectAll: true, isSelected }); + return res.data.data as CartSelectResponse; +} + +// 장바구니에서 주문 생성 +export async function createOrderFromCart(cartItemIds: number[]) { + const res = await api.post("/orders/create", { cartItemIds }); + return res.data.data; +} diff --git a/src/api/orders.ts b/src/api/orders.ts new file mode 100644 index 0000000..f0a4f7a --- /dev/null +++ b/src/api/orders.ts @@ -0,0 +1,100 @@ +import { api } from "../lib/axios"; + +export type OrderItemsApiData = { + orderPk: number; + orderId: number; + orderNumber: string; + status: string; + reservationExpiresAt: string; + totalProductAmount: number; + discountAmount: number; + deliveryFee: number; + finalAmount: number; + items: Array<{ + id: number; + isDefault: boolean; + // 추가 필드들이 백엔드에서 제공되면 여기에 추가 + cartItemId?: number; + productId?: number; + productName?: string; + brandName?: string; + size?: string; + quantity?: number; + salePrice?: number; + originalPrice?: number; + imageUrl?: string | null; + thumbnailImageUrl?: string | null; + }>; + summary?: { + originalTotal: number; + cartDiscount: number; + couponDiscount: number; + pointsUsed: number; + shippingFee: number; + finalAmount: number; + }; + remainingTimeSeconds?: number; +}; + +export async function getOrderItems(orderIdentifier: string | number) { + let url: string; + + if (typeof orderIdentifier === "number") { + // 숫자 orderPk인 경우 기존 API 사용 + url = `/orders/${orderIdentifier}/items`; + } else { + // 문자열 orderNumber인 경우 새로운 API 사용 + const trimmed = orderIdentifier.trim(); + if (!trimmed) { + throw new Error("orderIdentifier is required"); + } + url = `/orders/number/${trimmed}/items`; + } + + const response = await api.get<{ data: OrderItemsApiData }>(url); + return response.data.data; +} + +type UpdateOrderInfoRequest = { + deliveryInfo: { + recipient: string; + phone: string; + address: string; + detailAddress: string; + postalCode?: string; + deliveryRequest: string; + }; + ordererInfo: { + name: string; + email: string; + phone: string; + }; +}; + +export async function updateOrderInfo( + orderIdentifier: string | number, + data: UpdateOrderInfoRequest, +) { + let resolved: string; + + if (typeof orderIdentifier === "number") { + resolved = orderIdentifier.toString(); + } else { + const trimmed = orderIdentifier.trim(); + + // 백엔드가 Long 타입만 받으므로 문자열에서 숫자 부분 추출 + const match = trimmed.match(/\d+/); + if (match) { + resolved = match[0]; + } else { + resolved = trimmed; + } + } + + if (!resolved) { + throw new Error("orderIdentifier is required"); + } + + const response = await api.put(`/orders/${resolved}`, data); + return response.data; +} diff --git a/src/api/payments.ts b/src/api/payments.ts new file mode 100644 index 0000000..16e6431 --- /dev/null +++ b/src/api/payments.ts @@ -0,0 +1,133 @@ +import { api } from "../lib/axios"; + +// 결제 정보 조회 API 응답 타입 +export type PaymentInfoResponse = { + orderPk: number; + orderId: string; + orderItems: Array<{ + id: number; + productName: string; + brandName: string; + size: string; + quantity: number; + price: number; + imageUrl?: string; + }>; + paymentSummary: { + finalAmount: number; + }; + deliveryInfo: { + recipient: string; + phone: string; + address: string; + detailAddress: string; + deliveryRequest: string; + }; + remainingTime: string; +}; + +// 결제 요청 API 응답 타입 +type PaymentRequestResponse = { + paymentId: string; + merchantId: string; + channelKey: string; // V2 API용 추가 + orderName: string; + totalAmount: number; + currency: string; + customerName: string; + customerEmail: string; + returnUrl: string; + notificationUrl: string; +}; + +// 결제 정보 조회 +export async function getPaymentInfo( + orderId: string, +): Promise { + const response = await api.get<{ data: PaymentInfoResponse }>( + `/payments/${orderId}/info`, + ); + return response.data.data; +} + +// 결제 요청 +export async function requestPayment( + orderId: string, +): Promise { + try { + console.log("결제 요청 API 호출:", { + orderId, + url: `/payments/${orderId}/request`, + }); + const response = await api.post<{ data: PaymentRequestResponse }>( + `/payments/${orderId}/request`, + {}, + ); + console.log("결제 요청 API 성공:", response.data); + return response.data.data; + } catch (error: unknown) { + const apiError = error as { + response?: { status?: number; statusText?: string; data?: unknown }; + message?: string; + }; + console.error("결제 요청 API 오류:", { + orderId, + status: apiError.response?.status, + statusText: apiError.response?.statusText, + data: apiError.response?.data, + message: apiError.message, + }); + throw error; + } +} + +// 결제 완료 API 타입 +type PaymentCompleteRequest = { + transactionId: string; + status: string; +}; + +type PaymentCompleteResponse = { + orderNumber: string; + paymentId: string; + transactionId: string; + paymentStatus: string; + completedAt: string; + finalAmount: number; +}; + +// 결제 완료 알림 +export async function completePayment( + paymentId: string, + data: PaymentCompleteRequest, +): Promise { + try { + console.log("=== 백엔드 결제 완료 API 호출 시작 ==="); + console.log("결제 완료 API 호출:", { + paymentId, + url: `/payments/${paymentId}/complete`, + data, + }); + + const response = await api.post<{ data: PaymentCompleteResponse }>( + `/payments/${paymentId}/complete`, + data, + ); + + console.log("✅ 백엔드 결제 완료 처리 성공:", response.data); + return response.data.data; + } catch (error: unknown) { + const apiError = error as { + response?: { status?: number; statusText?: string; data?: unknown }; + message?: string; + }; + console.error("❌ 백엔드 결제 완료 처리 실패:", { + paymentId, + status: apiError.response?.status, + statusText: apiError.response?.statusText, + data: apiError.response?.data, + message: apiError.message, + }); + throw error; + } +} diff --git a/src/components/atoms/NumberStepper/NumberStepper.tsx b/src/components/atoms/NumberStepper/NumberStepper.tsx index c8851a4..0e70291 100644 --- a/src/components/atoms/NumberStepper/NumberStepper.tsx +++ b/src/components/atoms/NumberStepper/NumberStepper.tsx @@ -1,7 +1,7 @@ import { Minus, Plus } from "lucide-react"; import styles from "./NumberStepper.module.css"; -export type NumberStepperProps = { +type NumberStepperProps = { value: number; min: number; max: number; diff --git a/src/components/molecules/AddressSearchModal/AddressSearchModal.tsx b/src/components/molecules/AddressSearchModal/AddressSearchModal.tsx new file mode 100644 index 0000000..1562692 --- /dev/null +++ b/src/components/molecules/AddressSearchModal/AddressSearchModal.tsx @@ -0,0 +1,89 @@ +import styles from "../../organisms/Order/OrderPage.module.css"; + +type AddressSearchModalProps = { + isOpen: boolean; + keyword: string; + results: Array<{ id: number; address: string; detail: string }>; + isSearching: boolean; + hasSearched: boolean; + onKeywordChange: (value: string) => void; + onSearch: () => void; + onSelect: (address: string) => void; + onClose: () => void; + searchInputId: string; +}; + +export default function AddressSearchModal({ + isOpen, + keyword, + results, + isSearching, + hasSearched, + onKeywordChange, + onSearch, + onSelect, + onClose, + searchInputId, +}: AddressSearchModalProps) { + if (!isOpen) return null; + + return ( +
+
+
+

주소 검색

+ +
+ +
+ + onKeywordChange(event.target.value)} + placeholder="도로명이나 동을 입력하세요" + /> + +
+ +
+ {isSearching ? ( +
+
+
+ ) : hasSearched && results.length === 0 ? ( +
검색 결과가 없습니다
+ ) : ( + results.map((result) => ( + + )) + )} +
+
+
+ ); +} diff --git a/src/components/molecules/CartBrandGroup/CartBrandGroup.module.css b/src/components/molecules/CartBrandGroup/CartBrandGroup.module.css new file mode 100644 index 0000000..3d83771 --- /dev/null +++ b/src/components/molecules/CartBrandGroup/CartBrandGroup.module.css @@ -0,0 +1,25 @@ +.container { + background-color: var(--color-white); + border: 1px solid var(--color-gray-200); + border-radius: 0.5rem; + overflow: hidden; +} + +.header { + display: flex; + align-items: center; + padding: 1rem; + border-bottom: 1px solid var(--color-gray-200); +} + +.checkbox { + margin-right: 0.75rem; +} + +.brand { + font-weight: 600; +} + +.list { + display: block; +} diff --git a/src/components/molecules/CartBrandGroup/CartBrandGroup.tsx b/src/components/molecules/CartBrandGroup/CartBrandGroup.tsx new file mode 100644 index 0000000..96aeaac --- /dev/null +++ b/src/components/molecules/CartBrandGroup/CartBrandGroup.tsx @@ -0,0 +1,53 @@ +import type { OrderItemData } from "../../../types/order"; +import { Checkbox } from "../../atoms"; +import { OrderItem } from "../../molecules"; +import styles from "./CartBrandGroup.module.css"; + +type CartBrandGroupProps = { + brand: string; + items: OrderItemData[]; + checked: boolean; + onToggleBrand: (brand: string, checked: boolean) => void; + onToggleItem: (id: number) => void; + onChangeQty: (id: number, quantity: number) => void; + onDelete: (id: number) => void; +}; + +export default function CartBrandGroup({ + brand, + items, + checked, + onToggleBrand, + onToggleItem, + onChangeQty, + onDelete, +}: CartBrandGroupProps) { + const handleToggleBrand = () => onToggleBrand(brand, !checked); + + return ( +
+
+ + {brand} +
+ +
+ {items.map((item, index) => ( + + ))} +
+
+ ); +} diff --git a/src/components/molecules/CartSelectionBar/CartSelectionBar.module.css b/src/components/molecules/CartSelectionBar/CartSelectionBar.module.css new file mode 100644 index 0000000..239d2da --- /dev/null +++ b/src/components/molecules/CartSelectionBar/CartSelectionBar.module.css @@ -0,0 +1,27 @@ +.container { + display: block; + background-color: var(--color-white); + border: 1px solid var(--color-gray-200); + border-radius: 0.5rem; +} + +.header { + display: flex; + align-items: center; + padding: 1rem; +} + +.checkbox { + margin-right: 0.75rem; +} + +.label { + font-weight: 600; + margin-right: 0.5rem; +} + +.count { + margin-left: auto; + font-size: 0.875rem; + color: var(--color-gray-500); +} diff --git a/src/components/molecules/CartSelectionBar/CartSelectionBar.tsx b/src/components/molecules/CartSelectionBar/CartSelectionBar.tsx new file mode 100644 index 0000000..4963e94 --- /dev/null +++ b/src/components/molecules/CartSelectionBar/CartSelectionBar.tsx @@ -0,0 +1,33 @@ +import { Checkbox } from "../../atoms"; +import styles from "./CartSelectionBar.module.css"; + +type CartSelectionBarProps = { + checked: boolean; + totalCount: number; + selectedCount: number; + onToggle: () => void; +}; + +export default function CartSelectionBar({ + checked, + totalCount, + selectedCount, + onToggle, +}: CartSelectionBarProps) { + return ( +
+
+ + 전체 선택 + + {selectedCount} / {totalCount} + +
+
+ ); +} diff --git a/src/components/molecules/CouponModal/CouponModal.tsx b/src/components/molecules/CouponModal/CouponModal.tsx new file mode 100644 index 0000000..22d73fe --- /dev/null +++ b/src/components/molecules/CouponModal/CouponModal.tsx @@ -0,0 +1,82 @@ +import type { Coupon } from "../../../types/order"; +import styles from "../../organisms/Order/OrderPage.module.css"; + +type CouponModalProps = { + isOpen: boolean; + coupons: Coupon[]; + selectedCouponId: string; + onSelect: (couponId: string) => void; + onClose: () => void; + formatCurrency: (value: number) => string; +}; + +export default function CouponModal({ + isOpen, + coupons, + selectedCouponId, + onSelect, + onClose, + formatCurrency, +}: CouponModalProps) { + if (!isOpen) return null; + + return ( +
+
+
+

보유 쿠폰 목록

+ +
+ +
+ {coupons.map((coupon) => { + const isSelected = selectedCouponId === coupon.id; + return ( + + ); + })} +
+ +
+ + +
+
+
+ ); +} diff --git a/src/components/molecules/Dialog/Dialog.tsx b/src/components/molecules/Dialog/Dialog.tsx index 82ddb5d..81d6e32 100644 --- a/src/components/molecules/Dialog/Dialog.tsx +++ b/src/components/molecules/Dialog/Dialog.tsx @@ -2,7 +2,7 @@ import { X } from "lucide-react"; import { Modal } from "../../atoms"; import styles from "./Dialog.module.css"; -export type DialogProps = { +type DialogProps = { open: boolean; onClose?: () => void; closeOnBackdrop?: boolean; diff --git a/src/components/molecules/index.ts b/src/components/molecules/index.ts index 6af68dd..5b719d9 100644 --- a/src/components/molecules/index.ts +++ b/src/components/molecules/index.ts @@ -1,10 +1,14 @@ export { default as AccountSecurity } from "./AccountSecurity/AccountSecurity"; export { default as AddressSearchItem } from "./AddressSearchItem/AddressSearchItem"; +export { default as AddressSearchModal } from "./AddressSearchModal/AddressSearchModal"; export { default as AddressSection } from "./AddressSection/AddressSection"; export { default as AuthNav } from "./AuthNav/AuthNav"; export { default as Banner } from "./Banner/Banner"; export { default as Breadcrumb } from "./Breadcrumb/Breadcrumb"; +export { default as CartBrandGroup } from "./CartBrandGroup/CartBrandGroup"; +export { default as CartSelectionBar } from "./CartSelectionBar/CartSelectionBar"; export { default as CouponItem } from "./CouponItem/CouponItem"; +export { default as CouponModal } from "./CouponModal/CouponModal"; export { default as Dialog } from "./Dialog/Dialog"; export { default as EmptyState } from "./EmptyState/EmptyState"; export { default as FilterSection } from "./FilterSection/FilterSection"; diff --git a/src/components/organisms/CartFooter/CartFooter.tsx b/src/components/organisms/CartFooter/CartFooter.tsx index 31969af..b825ce8 100644 --- a/src/components/organisms/CartFooter/CartFooter.tsx +++ b/src/components/organisms/CartFooter/CartFooter.tsx @@ -1,3 +1,4 @@ +// src/components/organisms/CartFooter/CartFooter.tsx import { useNavigate } from "react-router-dom"; import { Button } from "../../atoms"; import styles from "./CartFooter.module.css"; @@ -6,14 +7,28 @@ type CartFooterProps = { items: number; selectedCount: number; finalAmount: number; + onOrder?: () => void; // 외부에서 주문 클릭 동작 주입 + ordering?: boolean; // ✅ 주문 진행 중 }; export default function CartFooter({ items, selectedCount, finalAmount, + onOrder, + ordering = false, }: CartFooterProps) { const navigate = useNavigate(); + + // onOrder 있으면 실행, 없으면 /order로 이동 + const handleOrderClick = () => { + if (ordering) return; // ✅ 진행 중엔 무시 + if (onOrder) onOrder(); + else navigate("/order"); + }; + + const disabled = ordering || selectedCount === 0; + return (
{items !== 0 && ( @@ -27,11 +42,13 @@ export default function CartFooter({
)} diff --git a/src/components/organisms/CartList/CartList.module.css b/src/components/organisms/CartList/CartList.module.css index 44868f6..16cd54c 100644 --- a/src/components/organisms/CartList/CartList.module.css +++ b/src/components/organisms/CartList/CartList.module.css @@ -1,27 +1,4 @@ -.container { - background-color: var(--color-white); - border-radius: 0.5rem; -} - -.header { - display: flex; - align-items: center; - padding: 1rem; - border-bottom: 1px solid var(--color-gray-300); -} - -.checkbox { - margin-right: 0.75rem; -} - -.brand { - font-weight: 500; -} - -.list { - display: block; -} - -.list > * + * { - border-top: 1px solid var(--color-gray-200); +.listWrapper { + display: grid; + gap: 2rem; } diff --git a/src/components/organisms/CartList/CartList.tsx b/src/components/organisms/CartList/CartList.tsx index d351254..7532d0b 100644 --- a/src/components/organisms/CartList/CartList.tsx +++ b/src/components/organisms/CartList/CartList.tsx @@ -1,6 +1,6 @@ +import { useMemo } from "react"; import type { OrderItemData } from "../../../types/order"; -import { Checkbox } from "../../atoms"; -import { OrderItem } from "../../molecules"; +import { CartBrandGroup, CartSelectionBar } from "../../molecules"; import styles from "./CartList.module.css"; type CartListProps = { @@ -9,6 +9,7 @@ type CartListProps = { onToggleItem: (id: number) => void; onChangeQty: (id: number, qty: number) => void; onDelete: (id: number) => void; + onToggleAll?: (checked: boolean) => void; }; export default function CartList({ @@ -17,47 +18,56 @@ export default function CartList({ onToggleItem, onChangeQty, onDelete, + onToggleAll, }: CartListProps) { - const grouped = items.reduce>((acc, cur) => { - if (!acc[cur.brand]) { - acc[cur.brand] = []; + const totalCount = items.length; + const selectedCount = useMemo( + () => items.filter((item) => item.selected).length, + [items], + ); + const allChecked = totalCount > 0 && selectedCount === totalCount; + + const grouped = useMemo(() => { + return items.reduce>((acc, cur) => { + if (!acc[cur.brand]) acc[cur.brand] = []; + acc[cur.brand].push(cur); + return acc; + }, {}); + }, [items]); + + const handleToggleAll = () => { + const next = !allChecked; + if (onToggleAll) { + onToggleAll(next); + return; } - acc[cur.brand].push(cur); - return acc; - }, {}); - return ( -
- {Object.entries(grouped).map(([brand, group]) => { - const allChecked = group.every((i) => i.selected); - const handleBrandToggle = () => onToggleBrand(brand, !allChecked); + items.forEach((item) => { + if (item.selected !== next) onToggleItem(item.id); + }); + }; - return ( -
-
- - {brand} -
+ return ( +
+ -
- {group.map((item) => ( - - ))} -
-
- ); - })} + {Object.entries(grouped).map(([brand, group]) => ( + item.selected)} + onToggleBrand={onToggleBrand} + onToggleItem={onToggleItem} + onChangeQty={onChangeQty} + onDelete={onDelete} + /> + ))}
); } diff --git a/src/components/organisms/Order/DeliveryInfoSection/DeliveryInfoSection.tsx b/src/components/organisms/Order/DeliveryInfoSection/DeliveryInfoSection.tsx new file mode 100644 index 0000000..6f7253d --- /dev/null +++ b/src/components/organisms/Order/DeliveryInfoSection/DeliveryInfoSection.tsx @@ -0,0 +1,166 @@ +import { AddressSearchModal } from "../../../molecules"; +import styles from "../OrderPage.module.css"; + +type DeliveryInfo = { + name: string; + recipient: string; + phone: string; + address: string; + detailAddress: string; + deliveryRequest: string; +}; + +type AddressModalProps = { + isOpen: boolean; + keyword: string; + results: Array<{ id: number; address: string; detail: string }>; + isSearching: boolean; + hasSearched: boolean; + onKeywordChange: (value: string) => void; + onSearch: () => void; + onSelect: (address: string) => void; + onClose: () => void; + onOpen: () => void; + searchInputId: string; +}; + +type DeliveryInfoSectionProps = { + info: DeliveryInfo; + onChange: (field: keyof DeliveryInfo, value: string) => void; + requestOptions: string[]; + ids: { + name: string; + recipient: string; + phone: string; + address: string; + detail: string; + request: string; + }; + addressModal: AddressModalProps; +}; + +export default function DeliveryInfoSection({ + info, + onChange, + requestOptions, + ids, + addressModal, +}: DeliveryInfoSectionProps) { + return ( +
+

배송지 정보

+ +
+
+ + onChange("name", event.target.value)} + placeholder="예) 우리 집" + /> +
+ +
+ + onChange("recipient", event.target.value)} + placeholder="받으실 분의 이름" + /> +
+ +
+ + onChange("phone", event.target.value)} + placeholder="010-0000-0000" + /> +
+ +
+ +
+ onChange("address", event.target.value)} + placeholder="주소를 검색해주세요" + /> + +
+
+ +
+ + onChange("detailAddress", event.target.value)} + placeholder="상세주소" + /> +
+ +
+ +
+ + +
+
+
+ + +
+ ); +} diff --git a/src/components/organisms/Order/DiscountSection/DiscountSection.tsx b/src/components/organisms/Order/DiscountSection/DiscountSection.tsx new file mode 100644 index 0000000..b59257a --- /dev/null +++ b/src/components/organisms/Order/DiscountSection/DiscountSection.tsx @@ -0,0 +1,119 @@ +import type { Coupon } from "../../../../types/order"; +import { CouponModal } from "../../../molecules"; +import styles from "../OrderPage.module.css"; + +type DiscountSectionProps = { + ids: { + couponSelect: string; + pointInput: string; + }; + coupons: Coupon[]; + selectedCouponId: string; + onSelectCoupon: (couponId: string) => void; + onApplyCoupon: (couponId: string) => void; + onOpenCouponModal: () => void; + couponModal: { + isOpen: boolean; + onClose: () => void; + }; + pointsUsed: number; + onChangePoints: (value: number) => void; + onApplyAllPoints: () => void; + availablePoints: number; + minPointUse: number; + formatCurrency: (value: number) => string; +}; + +export default function DiscountSection({ + ids, + coupons, + selectedCouponId, + onSelectCoupon, + onApplyCoupon, + onOpenCouponModal, + couponModal, + pointsUsed, + onChangePoints, + onApplyAllPoints, + availablePoints, + minPointUse, + formatCurrency, +}: DiscountSectionProps) { + return ( +
+

할인 적용

+ +
+ +
+
+ + +
+ +
+
+ +
+ +
+ + onChangePoints(Number(event.target.value || 0)) + } + placeholder="사용할 포인트 입력" + /> + +
+

+ 보유 포인트 {formatCurrency(availablePoints)} + {minPointUse > 0 + ? ` / 최소 ${formatCurrency(minPointUse)} 이상 사용` + : ""} +

+
+ + +
+ ); +} diff --git a/src/components/organisms/Order/OrderFixedBar/OrderFixedBar.tsx b/src/components/organisms/Order/OrderFixedBar/OrderFixedBar.tsx new file mode 100644 index 0000000..971a80f --- /dev/null +++ b/src/components/organisms/Order/OrderFixedBar/OrderFixedBar.tsx @@ -0,0 +1,41 @@ +import styles from "../OrderPage.module.css"; + +type OrderFixedBarProps = { + itemCount: number; + finalAmount: number; + disabled: boolean; + onSubmit: () => void; + formatCurrency: (value: number) => string; + buttonLabel?: string; + isProcessing?: boolean; +}; + +export default function OrderFixedBar({ + itemCount, + finalAmount, + disabled, + onSubmit, + formatCurrency, + buttonLabel, + isProcessing = false, +}: OrderFixedBarProps) { + const label = buttonLabel ?? `${formatCurrency(finalAmount)} 결제하기`; + return ( +
+
+
+

총 {itemCount}개 상품

+

{formatCurrency(finalAmount)}

+
+ +
+
+ ); +} diff --git a/src/components/organisms/Order/OrderHeader/OrderHeader.tsx b/src/components/organisms/Order/OrderHeader/OrderHeader.tsx new file mode 100644 index 0000000..5347128 --- /dev/null +++ b/src/components/organisms/Order/OrderHeader/OrderHeader.tsx @@ -0,0 +1,24 @@ +import styles from "../OrderPage.module.css"; + +type OrderHeaderProps = { + title: string; + onBack: () => void; +}; + +export default function OrderHeader({ title, onBack }: OrderHeaderProps) { + return ( +
+
+ +

{title}

+
+
+ ); +} diff --git a/src/components/organisms/Order/OrderItemsSection/OrderItemsSection.tsx b/src/components/organisms/Order/OrderItemsSection/OrderItemsSection.tsx new file mode 100644 index 0000000..48d24b3 --- /dev/null +++ b/src/components/organisms/Order/OrderItemsSection/OrderItemsSection.tsx @@ -0,0 +1,79 @@ +import { useState } from "react"; +import type { OrderItem } from "../../../../types/order"; +import styles from "../OrderPage.module.css"; + +type OrderItemsSectionProps = { + items: OrderItem[]; + formatCurrency: (value: number) => string; + defaultOpen?: boolean; + loading?: boolean; + errorMessage?: string | null; +}; + +export default function OrderItemsSection({ + items, + formatCurrency, + defaultOpen = false, + loading = false, + errorMessage = null, +}: OrderItemsSectionProps) { + const [open, setOpen] = useState(defaultOpen); + const hasItems = items.length > 0; + + return ( +
+ + + {loading && ( +
주문 상품을 불러오는 중입니다.
+ )} + + {!loading && errorMessage && ( +
{errorMessage}
+ )} + + {open && !loading && hasItems && ( +
+ {items.map((item, index) => ( +
+
+ {item.name} +
+
+

{item.brand}

+

{item.name}

+

+ 옵션 {item.size} | 수량: {item.quantity}개 +

+

+ {formatCurrency(item.price * item.quantity)} +

+
+ {index < items.length - 1 && ( +
+ )} +
+ ))} +
+ )} + + {!loading && !errorMessage && !hasItems && open && ( +
주문 상품 정보가 없습니다.
+ )} +
+ ); +} diff --git a/src/components/organisms/Order/OrderPage.module.css b/src/components/organisms/Order/OrderPage.module.css new file mode 100644 index 0000000..4ae1545 --- /dev/null +++ b/src/components/organisms/Order/OrderPage.module.css @@ -0,0 +1,581 @@ +:root { + --black: #111; + --line: #eaeaea; + --muted: #777; + --minus: #e60023; + --green: #11a55a; + --card: #fff; + --bg: #fff; +} +* { + box-sizing: border-box; +} +button { + border: 0; + background: none; + padding: 0; + cursor: pointer; +} +img { + display: block; +} + +/* Page */ +.pageWrap { + min-height: 100vh; + background: var(--bg); + color: #111; + font-family: + "Pretendard", + system-ui, + -apple-system, + Segoe UI, + Roboto, + sans-serif; +} + +/* Header */ +.header { + position: sticky; + top: 0; + z-index: 40; + background: #fff; + border-bottom: 1px solid var(--line); +} +.headerInner { + max-width: 960px; + margin: 0 auto; + padding: 0 16px; + height: 56px; + display: flex; + align-items: center; + gap: 12px; +} +.backBtn { + width: 36px; + height: 36px; + border-radius: 8px; + display: grid; + place-items: center; +} +.backBtn:hover { + background: #f6f6f6; +} +.iconArrow { + width: 8px; + height: 8px; + border-right: 2px solid #111; + border-bottom: 2px solid #111; + transform: rotate(135deg); +} +.headerTitle { + font-size: 18px; + font-weight: 600; +} + +.main { + max-width: 960px; + margin: 0 auto; + padding: 24px 16px 120px; +} + +/* Card */ +.card { + background: var(--card); + border: 1px solid var(--line); + border-radius: 12px; + padding: 16px; + margin-bottom: 16px; +} +.cardTitle { + font-size: 17px; + font-weight: 700; +} +.cardTitleRow { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; +} +.rowDivider { + height: 1px; + background: #eee; + margin: 8px 0 0; +} + +.itemsStack { + display: grid; + gap: 12px; + margin-top: 12px; +} +.itemRow { + padding-bottom: 8px; +} +.thumbBox { + width: 64px; + height: 64px; + overflow: hidden; + border-radius: 10px; + background: #f2f2f2; + float: left; + margin-right: 12px; +} +.thumb { + width: 100%; + height: 100%; + object-fit: cover; +} +.itemMeta { + overflow: hidden; +} +.itemBrand { + font-size: 12px; + color: #666; +} +.itemName { + font-weight: 600; +} +.itemOpt { + font-size: 13px; + color: #777; + margin: 2px 0 4px; +} +.itemPrice { + font-weight: 800; +} + +/* Chevrons */ +.chev { + width: 10px; + height: 10px; + border-right: 2px solid #999; + border-bottom: 2px solid #999; +} +.chevDown { + transform: rotate(45deg); +} +.chevUp { + transform: rotate(-135deg); +} + +/* Forms */ +.formStack { + display: grid; + gap: 12px; + margin-top: 12px; +} +.formItem { + display: grid; + gap: 8px; +} +.label { + font-size: 14px; + font-weight: 600; + color: #222; +} +.req { + color: var(--minus); +} + +.row { + display: grid; + grid-template-columns: 1fr 120px; + gap: 8px; +} +@media (max-width: 420px) { + .row { + grid-template-columns: 1fr; + } +} + +.input, +.select { + width: 100%; + border: 1px solid #d9d9d9; + border-radius: 10px; + padding: 12px 14px; + font-size: 14px; + outline: none; + background: #fff; +} +.input:focus, +.select:focus { + border-color: #111; + box-shadow: 0 0 0 3px rgba(17, 17, 17, 0.08); +} + +.selectWrap { + position: relative; +} +.select { + appearance: none; +} +.chevDownSmall { + position: absolute; + right: 14px; + top: 50%; + width: 8px; + height: 8px; + border-right: 2px solid #999; + border-bottom: 2px solid #999; + transform: translateY(-50%) rotate(45deg); +} + +/* Buttons */ +.btnGhost { + border: 1px solid #d9d9d9; + border-radius: 10px; + padding: 12px; + font-weight: 600; + background: #fff; +} +.btnGhost:hover { + background: #f7f7f7; +} +.btnPrimary { + background: #111; + color: #fff; + border-radius: 10px; + padding: 12px 16px; + font-weight: 700; +} +.btnPrimary:hover { + filter: brightness(0.96); +} + +/* Same as delivery */ +.sameRow { + display: flex; + align-items: center; + gap: 8px; + margin-top: 4px; + margin-bottom: 8px; +} +.checkSquare { + width: 18px; + height: 18px; + border: 2px solid #cfcfcf; + border-radius: 4px; + position: relative; +} +.checkOn { + border-color: #111; + background: #111; +} +.checkOn::after { + content: ""; + position: absolute; + left: 4px; + top: 0px; + width: 6px; + height: 12px; + border-right: 2px solid #fff; + border-bottom: 2px solid #fff; + transform: rotate(45deg); +} +.sameLabel { + font-size: 14px; +} + +/* Modal */ +.modalBackdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: grid; + place-items: center; + z-index: 60; +} +.modalBox { + width: 100%; + max-width: 560px; + background: #fff; + border-radius: 14px; + padding: 16px; +} +.modalHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} +.modalTitle { + font-size: 17px; + font-weight: 700; +} +.iconBtn { + width: 36px; + height: 36px; + border-radius: 8px; + display: grid; + place-items: center; +} +.iconBtn:hover { + background: #f6f6f6; +} +.iconClose { + width: 14px; + height: 14px; + position: relative; +} +.iconClose::before, +.iconClose::after { + content: ""; + position: absolute; + left: 6px; + top: 0; + width: 2px; + height: 14px; + background: #555; + border-radius: 1px; +} +.iconClose::before { + transform: rotate(45deg); +} +.iconClose::after { + transform: rotate(-45deg); +} + +.searchRow { + display: grid; + grid-template-columns: 1fr 100px; + gap: 8px; + margin-bottom: 12px; +} +.resultList { + max-height: 380px; + overflow: auto; +} +.addrItem { + width: 100%; + text-align: left; + border: 1px solid #e5e5e5; + border-radius: 10px; + padding: 12px; + margin-bottom: 8px; +} +.addrItem:hover { + border-color: #bdbdbd; +} +.addrLine { + font-weight: 600; + margin-bottom: 2px; +} +.addrDetail { + font-size: 12px; + color: #666; +} +.centerBox { + display: grid; + place-items: center; + padding: 24px 0; +} +.centerMuted { + text-align: center; + padding: 24px 0; + color: #777; +} + +/* Spinner */ +.spinner { + width: 28px; + height: 28px; + border-radius: 50%; + border: 3px solid #111; + border-top-color: transparent; + animation: spin 0.8s linear infinite; +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Coupon list */ +.couponList { + display: grid; + gap: 10px; + margin-top: 8px; + max-height: 60vh; + overflow: auto; +} +.couponItem { + text-align: left; + border: 2px solid #e5e5e5; + border-radius: 12px; + padding: 12px; + background: #fff; +} +.couponItem:hover { + border-color: #d8d8d8; +} +.couponOn { + border-color: #111; + background: #fafafa; +} +.couponHead { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 6px; +} +.couponName { + font-weight: 700; +} +.couponBadge { + font-size: 13px; + font-weight: 700; + color: var(--minus); +} +.couponDesc { + font-size: 13px; + color: #666; + margin-bottom: 6px; +} +.couponMeta { + font-size: 12px; + color: #8b8b8b; + display: grid; + gap: 2px; +} +.modalActions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin-top: 12px; +} + +/* Summary */ +.summaryCard { + background: #f8f8f8; + border: 1px solid #eee; + border-radius: 12px; + padding: 16px; +} +.summaryRows { + display: grid; + gap: 8px; + margin-top: 8px; +} +.summaryRow { + display: flex; + align-items: center; + justify-content: space-between; +} +.summaryTotal { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 8px; + font-size: 18px; + font-weight: 800; +} +.muted { + color: #666; +} +.bold { + font-weight: 700; +} +.minus { + color: var(--minus); + font-weight: 700; +} +.free { + color: var(--green); + font-weight: 700; +} + +/* Fixed bottom bar */ +.fixedBar { + position: fixed; + left: 0; + right: 0; + bottom: 0; + background: #fff; + border-top: 1px solid var(--line); + z-index: 45; +} +.fixedInner { + max-width: 960px; + margin: 0 auto; + padding: 12px 16px; + display: grid; + grid-template-columns: 1fr; + gap: 10px; +} +@media (min-width: 560px) { + .fixedInner { + grid-template-columns: 1fr 260px; + align-items: center; + } +} +.fixedRight { + text-align: right; +} +.fixedMeta { + font-size: 12px; + color: #666; +} +.fixedPrice { + font-weight: 800; + font-size: 18px; +} +.payButton { + width: 100%; + padding: 14px; + font-size: 16px; + font-weight: 800; + color: #fff; + background: #111; + border-radius: 12px; +} +.payButton:active { + transform: translateY(1px); +} +.payDisabled { + background: #cfcfcf; + color: #666; + cursor: not-allowed; +} +.errorBox { + margin-top: 12px; + padding: 12px; + border-radius: 10px; + background: #fff4f4; + border: 1px solid #ffcdcd; + color: #c81e1e; + font-size: 14px; +} + +.errorBox { + margin-top: 12px; + padding: 12px; + border-radius: 10px; + background: #fff4f4; + border: 1px solid #ffcdcd; + color: #c81e1e; + font-size: 14px; +} + +.centerMuted { + text-align: center; + color: #777; + font-size: 14px; + padding: 16px 0; +} + +.errorBox { + margin-top: 12px; + padding: 12px; + border-radius: 10px; + background: #fff4f4; + border: 1px solid #ffcdcd; + color: #c81e1e; + font-size: 14px; +} +.centerMuted { + text-align: center; + color: #777; + font-size: 14px; + padding: 16px 0; +} diff --git a/src/components/organisms/Order/OrderSummarySection/OrderSummarySection.tsx b/src/components/organisms/Order/OrderSummarySection/OrderSummarySection.tsx new file mode 100644 index 0000000..fd3f7ee --- /dev/null +++ b/src/components/organisms/Order/OrderSummarySection/OrderSummarySection.tsx @@ -0,0 +1,61 @@ +import styles from "../OrderPage.module.css"; + +type OrderSummarySectionProps = { + originalTotal: number; + cartDiscount: number; + couponDiscount: number; + pointsUsed: number; + shippingFee: number; + finalAmount: number; + formatCurrency: (value: number) => string; +}; + +export default function OrderSummarySection({ + originalTotal, + cartDiscount, + couponDiscount, + pointsUsed, + shippingFee, + finalAmount, + formatCurrency, +}: OrderSummarySectionProps) { + return ( +
+

결제 정보

+
+
+ 상품 금액 + {formatCurrency(originalTotal)} +
+
+ 장바구니 할인 + - {formatCurrency(cartDiscount)} +
+
+ 쿠폰 할인 + + - {formatCurrency(couponDiscount)} + +
+
+ 포인트 사용 + - {formatCurrency(pointsUsed)} +
+
+ 배송비 + + {shippingFee === 0 ? ( + 무료 + ) : ( + formatCurrency(shippingFee) + )} + +
+
+ 총 결제 금액 + {formatCurrency(finalAmount)} +
+
+
+ ); +} diff --git a/src/components/organisms/Order/OrdererInfoSection/OrdererInfoSection.tsx b/src/components/organisms/Order/OrdererInfoSection/OrdererInfoSection.tsx new file mode 100644 index 0000000..98b2edf --- /dev/null +++ b/src/components/organisms/Order/OrdererInfoSection/OrdererInfoSection.tsx @@ -0,0 +1,85 @@ +import styles from "../OrderPage.module.css"; + +type OrdererInfo = { + name: string; + email: string; + phone: string; + sameAsDelivery: boolean; +}; + +type OrdererInfoSectionProps = { + info: OrdererInfo; + onChange: (field: keyof OrdererInfo, value: string | boolean) => void; + onToggleSameAsDelivery: () => void; + ids: { + name: string; + email: string; + phone: string; + }; +}; + +export default function OrdererInfoSection({ + info, + onChange, + onToggleSameAsDelivery, + ids, +}: OrdererInfoSectionProps) { + return ( +
+

주문자 정보

+ + + +
+
+ + onChange("name", event.target.value)} + placeholder="주문자 이름" + /> +
+ +
+ + onChange("email", event.target.value)} + placeholder="example@email.com" + /> +
+ +
+ + onChange("phone", event.target.value)} + placeholder="010-0000-0000" + /> +
+
+
+ ); +} diff --git a/src/components/organisms/OrderHistorySection/OrderHistorySection.tsx b/src/components/organisms/OrderHistorySection/OrderHistorySection.tsx index 7c3fcff..75fceee 100644 --- a/src/components/organisms/OrderHistorySection/OrderHistorySection.tsx +++ b/src/components/organisms/OrderHistorySection/OrderHistorySection.tsx @@ -1,11 +1,24 @@ -import { ShoppingBag } from "lucide-react"; +import { ShoppingBag } from "lucide-react"; import { useState } from "react"; -import type { Order } from "../../../types/order"; import { Select, Tag } from "../../atoms"; import { EmptyState, Table } from "../../molecules"; import styles from "./OrderHistorySection.module.css"; -const dummyOrders: Order[] = [ +type OrderHistory = { + date: string; + orderNumber: string; + products: Array<{ + name: string; + image: string; + option: string; + count: number; + }>; + amount: string; + status: string; + statusType: "success" | "processing" | "canceled"; +}; + +const dummyOrders: OrderHistory[] = [ { date: "2023-10-01", orderNumber: "ORD123456", @@ -27,7 +40,7 @@ const dummyOrders: Order[] = [ orderNumber: "ORD123457", products: [ { - name: "오버사이즈 블레이저", + name: "오버핏 블레이저", image: "https://readdy.ai/api/search-image?query=elegant%20black%20blazer%20jacket%20on%20white%20background%20minimalist%20fashion%20photography%20studio%20lighting%20professional%20commercial%20style&width=400&height=400&seq=product2&orientation=squarish", option: "M", @@ -57,15 +70,14 @@ const dummyOrders: Order[] = [ count: 1, }, { - name: "레트로 매트 립스틱", + name: "매트 립스틱", image: "https://readdy.ai/api/search-image?query=elegant%20red%20lipstick%20on%20black%20glossy%20surface%20minimalist%20beauty%20product%20photography%20studio%20lighting%20professional%20commercial%20style&width=400&height=400&seq=product11&orientation=squarish", - - option: "312호", + option: "312", count: 1, }, { - name: "수분 크림", + name: "보습 크림", image: "https://readdy.ai/api/search-image?query=luxury%20moisturizing%20cream%20in%20elegant%20glass%20jar%20on%20clean%20white%20background%20minimalist%20beauty%20product%20photography%20studio%20lighting%20professional%20commercial%20style&width=400&height=400&seq=product8&orientation=squarish", option: "50ml", @@ -91,7 +103,7 @@ export default function OrderHistorySection() { const onPeriodChange = (period: string) => { setSelected(period); - // 여기에 기간 변경에 따른 추가 로직을 작성할 수 있습니다. + // TODO: 기간 변경에 따른 필터링 로직을 추가하세요. }; return ( @@ -101,9 +113,9 @@ export default function OrderHistorySection() {

주문 내역

+ setCardInfo((prev) => ({ + ...prev, + company: event.target.value, + })) + } + className={styles.select} + > + + {CARD_COMPANIES.map((company) => ( + + ))} + + +
+
+ +
+ + + setCardInfo((prev) => ({ + ...prev, + number: event.target.value + .replace(/[^0-9]/g, "") + .slice(0, 16) + .replace(/(.{4})/g, "$1 ") + .trim(), + })) + } + placeholder="0000 0000 0000 0000" + /> +
+ +
+ + + setCardInfo((prev) => ({ + ...prev, + expiry: event.target.value + .replace(/[^0-9]/g, "") + .slice(0, 4), + })) + } + placeholder="MMYY" + /> +
+ +
+ + + setCardInfo((prev) => ({ + ...prev, + cvc: event.target.value + .replace(/[^0-9]/g, "") + .slice(0, 3), + })) + } + placeholder="3자리" + /> +
+ +
+ +
+ + +
+
+ + + )} + + {method === "simple" && ( +
+

간편결제 선택

+
+ {( + [ + { value: "kakao", label: "카카오페이" }, + { value: "naver", label: "네이버페이" }, + ] as Array<{ value: SimpleService; label: string }> + ).map((service) => ( + + ))} +
+
+ )} + + {method === "transfer" && ( +
+

계좌이체 정보

+
+
+ +
+ + +
+
+ +
+ + + setBankInfo((prev) => ({ + ...prev, + depositorName: event.target.value, + })) + } + placeholder="입금자 이름을 입력하세요" + /> +
+
+
+ )} + +
+

결제 동의

+ + + {AGREEMENTS.map((agreement) => ( + + ))} +
+ + +
+
+
+

+ 총 {paymentState.orderItems.length}개 상품 +

+

{formatCurrency(finalAmount)}

+
+ +
+
+ + {showSuccessModal && ( +
+
+
+ +
+

결제가 완료되었습니다.

+

주문 내역 페이지로 이동합니다.

+
+
+
+ )} +
+ ); +} diff --git a/src/pages/PaymentPage/PaymentPage.tsx b/src/pages/PaymentPage/PaymentPage.tsx new file mode 100644 index 0000000..df03d41 --- /dev/null +++ b/src/pages/PaymentPage/PaymentPage.tsx @@ -0,0 +1,329 @@ +import { useEffect, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { + getPaymentInfo, + type PaymentInfoResponse, + requestPayment, +} from "../../api/payments"; + +// 포트원 SDK 타입 선언 +declare global { + interface Window { + IMP: { + init: (merchantId: string) => void; + request_pay: ( + params: ImpPaymentParams, + callback: (response: ImpPaymentResponse) => void, + ) => void; + }; + } +} + +type ImpPaymentParams = { + pg: string; + merchant_uid: string; + name: string; + amount: number; + buyer_name: string; + buyer_email: string; +}; + +type ImpPaymentResponse = { + success: boolean; + error_msg?: string; + merchant_uid?: string; + imp_uid?: string; +}; + +const PaymentPage = () => { + const { orderId } = useParams<{ orderId: string }>(); + const navigate = useNavigate(); + + const [paymentInfo, setPaymentInfo] = useState( + null, + ); + const [loading, setLoading] = useState(true); + const [paying, setPaying] = useState(false); + const [error, setError] = useState(null); + + // 결제 정보 로드 + useEffect(() => { + if (!orderId) { + setError("주문 ID가 없습니다."); + setLoading(false); + return; + } + + const fetchPaymentInfo = async () => { + try { + const data = await getPaymentInfo(orderId); + setPaymentInfo(data); + } catch (err) { + console.error("결제 정보 조회 실패:", err); + setError("결제 정보를 불러올 수 없습니다."); + } finally { + setLoading(false); + } + }; + + fetchPaymentInfo(); + }, [orderId]); + + // 결제 실행 + const handlePayment = async () => { + if (!orderId || !paymentInfo) return; + + setPaying(true); + setError(null); + + try { + // 1. 백엔드에 결제 요청 + const paymentData = await requestPayment(orderId); + + // 2. 포트원 결제창 호출 + if (!window.IMP) { + throw new Error("포트원 SDK가 로드되지 않았습니다."); + } + + window.IMP.init(paymentData.merchantId); + + window.IMP.request_pay( + { + pg: "portone", + merchant_uid: paymentData.paymentId, + name: paymentData.orderName, + amount: paymentData.totalAmount, + buyer_name: paymentData.customerName, + buyer_email: paymentData.customerEmail, + }, + (response: ImpPaymentResponse) => { + if (response.success) { + // 결제 성공 + console.log("결제 성공:", response); + navigate("/payment/success", { + state: { + paymentId: paymentData.paymentId, + impUid: response.imp_uid, + orderId: orderId, + }, + }); + } else { + // 결제 실패 + console.error("결제 실패:", response.error_msg); + setError(`결제 실패: ${response.error_msg}`); + setPaying(false); + } + }, + ); + } catch (err) { + console.error("결제 요청 실패:", err); + setError("결제 요청에 실패했습니다. 다시 시도해주세요."); + setPaying(false); + } + }; + + const formatCurrency = (amount: number) => + `${amount.toLocaleString("ko-KR")}원`; + + if (loading) { + return ( +
+

결제 정보를 불러오는 중...

+
+ ); + } + + if (error) { + return ( +
+

오류 발생

+

{error}

+ +
+ ); + } + + if (!paymentInfo) { + return ( +
+

결제 정보를 찾을 수 없습니다.

+ +
+ ); + } + + return ( +
+

결제하기

+ + {/* 주문 정보 */} +
+

주문 정보

+

+ 주문번호: {paymentInfo.orderId} +

+

+ 남은 시간: {paymentInfo.remainingTime} +

+
+ + {/* 배송 정보 */} +
+

배송 정보

+

+ 받는분: {paymentInfo.deliveryInfo.recipient} +

+

+ 연락처: {paymentInfo.deliveryInfo.phone} +

+

+ 주소: {paymentInfo.deliveryInfo.address} +

+ {paymentInfo.deliveryInfo.detailAddress && ( +

+ 상세주소: {paymentInfo.deliveryInfo.detailAddress} +

+ )} +

+ 배송요청: {paymentInfo.deliveryInfo.deliveryRequest} +

+
+ + {/* 주문 상품 */} +
+

주문 상품

+ {paymentInfo.orderItems.map((item) => ( +
+ {item.imageUrl && ( + {item.productName} + )} +
+

+ {item.brandName} +

+

{item.productName}

+

+ 사이즈: {item.size} | 수량: {item.quantity}개 +

+

+ {formatCurrency(item.price)} +

+
+
+ ))} +
+ + {/* 결제 금액 */} +
+

결제 금액

+
+ 최종 결제 금액 + + {formatCurrency(paymentInfo.paymentSummary.finalAmount)} + +
+
+ + {/* 결제 버튼 */} + + + {error && ( +
+ {error} +
+ )} +
+ ); +}; + +export default PaymentPage; diff --git a/src/pages/PaymentSuccess/PaymentSuccess.tsx b/src/pages/PaymentSuccess/PaymentSuccess.tsx new file mode 100644 index 0000000..24fc244 --- /dev/null +++ b/src/pages/PaymentSuccess/PaymentSuccess.tsx @@ -0,0 +1,244 @@ +import { useEffect, useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; + +type PaymentSuccessState = { + paymentId: string; + txId: string; // V2 API에서는 txId 사용 + orderId: string; + orderNumber?: string; // 백엔드에서 받은 주문번호 + finalAmount?: number; // 최종 결제금액 + error?: string; // 에러 메시지 (백엔드 처리 실패시) +}; + +const PaymentSuccess = () => { + const navigate = useNavigate(); + const location = useLocation(); + const [paymentData, setPaymentData] = useState( + null, + ); + + useEffect(() => { + const state = location.state as PaymentSuccessState; + console.log("전달받은 state:", state); // 디버깅용 + + if (state?.paymentId && state?.txId && state?.orderId) { + setPaymentData(state); + console.log("결제 완료 정보:", state); + } else { + console.error("결제 완료 정보가 없습니다. state:", state); + } + }, [location.state]); + + const handleGoHome = () => { + navigate("/"); + }; + + const handleGoToOrders = () => { + navigate("/mypage/order"); // 주문 내역 페이지로 이동 + }; + + if (!paymentData) { + return ( +
+

결제 정보를 확인할 수 없습니다

+

결제 과정에서 오류가 발생했습니다.

+ +
+ ); + } + + return ( +
+ {/* 성공 아이콘 */} +
+
+ ✓ +
+

+ 결제가 완료되었습니다! +

+

+ 주문이 정상적으로 처리되었습니다. +

+
+ + {/* 결제 정보 */} +
+

결제 정보

+
+ + 주문번호: + + {paymentData.orderId} +
+
+ + 결제번호: + + {paymentData.paymentId} +
+
+ + 포트원 거래번호: + + {paymentData.txId} +
+ {paymentData.finalAmount && ( +
+ + 결제금액: + + {paymentData.finalAmount.toLocaleString()}원 +
+ )} + {paymentData.error && ( +
+ + ⚠️ 주의:{" "} + + + 결제는 완료되었으나 주문 처리 중 일부 오류가 발생했습니다. + 고객센터에 문의해주세요. + +
+ )} +
+ + {/* 안내 메시지 */} +
+

배송 안내

+
    +
  • + 주문하신 상품은 결제 완료 후 1-2일 내에 배송 준비가 완료됩니다. +
  • +
  • 배송 준비가 완료되면 SMS로 배송 시작 안내를 보내드립니다.
  • +
  • + 배송 조회는 마이페이지 > 주문내역에서 확인하실 수 있습니다. +
  • +
  • 배송 관련 문의사항은 고객센터(1588-0000)로 연락주세요.
  • +
+
+ + {/* 버튼 그룹 */} +
+ + +
+
+ ); +}; + +export default PaymentSuccess; diff --git a/src/pages/index.ts b/src/pages/index.ts index 0e40bab..5690b47 100644 --- a/src/pages/index.ts +++ b/src/pages/index.ts @@ -4,7 +4,9 @@ export { default as FindPassword } from "./FindPassword/FindPassword"; export { default as Home } from "./Home/Home"; export { default as Login } from "./Login/Login"; export { default as Mypage } from "./Mypage/Mypage"; +export { default as Order } from "./Order/Order"; export { default as Payment } from "./Payment/Payment"; +export { default as PaymentSuccess } from "./PaymentSuccess/PaymentSuccess"; export { default as ProductDetail } from "./ProductDetail/ProductDetail"; export { default as ProductInquiry } from "./ProductDetail/ProductInquiry/ProductInquiry"; export { default as SignUp } from "./SignUp/SignUp"; diff --git a/src/types/order.ts b/src/types/order.ts index 1b066d8..e66a301 100644 --- a/src/types/order.ts +++ b/src/types/order.ts @@ -1,21 +1,47 @@ -export type Order = { - date: string; - orderNumber: string; - products: { name: string; image: string; option: string; count: number }[]; - amount: string; - status: string; - statusType: "success" | "processing" | "canceled" | "done"; -}; - export type OrderItemData = { id: number; brand: string; name: string; - option: string; - price: number; - originalPrice: number; + option: string; // 사이즈 + price: number; // 판매가 + originalPrice: number; // 원가 quantity: number; image: string; selected: boolean; stock: number; }; + +export type OrderItem = { + id: number; + brand: string; + name: string; + size: string; // Cart의 option + price: number; // 판매가 + quantity: number; + image: string; +}; + +export type Coupon = { + id: string; + name: string; + discountRate: number; // 0이면 정액 + discountAmount: number; // 0이면 정율 + minOrderAmount: number; + maxDiscountAmount: number; + validUntil: string; // YYYY-MM-DD + description: string; +}; + +export type CartToOrderState = { + orderItems: OrderItem[]; +}; + +export type OrderToPaymentState = { + orderItems: OrderItem[]; + appliedCouponDiscount: number; + appliedPointsUsed: number; + shippingFee: number; + totalPay: number; +}; + +export type PaymentState = OrderToPaymentState;