diff --git a/package.json b/package.json index 53d2312..fd025d8 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "next-auth": "5.0.0-beta.30", "radix-ui": "^1.4.3", "react": "19.2.3", + "react-daum-postcode": "^3.2.0", "react-dom": "19.2.3", "react-hook-form": "^7.71.1", "tailwind-merge": "^3.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c6a265c..3b0adf9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: react: specifier: 19.2.3 version: 19.2.3 + react-daum-postcode: + specifier: ^3.2.0 + version: 3.2.0(react@19.2.3) react-dom: specifier: 19.2.3 version: 19.2.3(react@19.2.3) @@ -2660,6 +2663,11 @@ packages: '@types/react-dom': optional: true + react-daum-postcode@3.2.0: + resolution: {integrity: sha512-NHY8TUicZXMqykbKYT8kUo2PEU7xu1DFsdRmyWJrLEUY93Xhd3rEdoJ7vFqrvs+Grl9wIm9Byxh3bI+eZxepMQ==} + peerDependencies: + react: '>=16.8.0' + react-dom@19.2.3: resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} peerDependencies: @@ -5723,6 +5731,10 @@ snapshots: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) + react-daum-postcode@3.2.0(react@19.2.3): + dependencies: + react: 19.2.3 + react-dom@19.2.3(react@19.2.3): dependencies: react: 19.2.3 diff --git a/public/icons/bag-check.svg b/public/icons/bag-check.svg new file mode 100644 index 0000000..7c7bbed --- /dev/null +++ b/public/icons/bag-check.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/app/actions/order.ts b/src/app/actions/order.ts index e70fe59..7be33e1 100644 --- a/src/app/actions/order.ts +++ b/src/app/actions/order.ts @@ -2,12 +2,19 @@ import { api } from '@/lib/api-client'; import { revalidatePath } from 'next/cache'; +import { redirect, notFound } from 'next/navigation'; import { OrderFromCartRequest, OrderFromProductRequest, OrderDetail, + OrderListResponse, + OrderListParams, } from '@/types/domain/order'; +import type { PaymentDisplayItem } from '@/app/payment/_components/order-items'; +import { getCartItems } from '@/app/actions/cart'; +import { getProductDetail } from '@/app/actions/product'; +/** 장바구니 주문 생성 */ export async function createOrderFromCart( data: OrderFromCartRequest, ): Promise { @@ -21,11 +28,14 @@ export async function createOrderFromCart( } catch (error) { console.error('장바구니 상품 주문 실패:', error); throw new Error( - error instanceof Error ? error.message : '장바구니 상품 주문에 실패했습니다.', + error instanceof Error + ? error.message + : '장바구니 상품 주문에 실패했습니다.', ); } } +/** 상품 직접 주문 생성 */ export async function createOrderFromProduct( data: OrderFromProductRequest, ): Promise { @@ -43,6 +53,92 @@ export async function createOrderFromProduct( } } +/** 장바구니 주문 아이템 조회 */ +export async function fetchCartOrderItems( + params: Record, +): Promise { + const rawIds = params.items; + const cartItemIds = ( + typeof rawIds === 'string' ? rawIds.split(',') : (rawIds ?? []) + ) + .map(Number) + .filter((id) => !isNaN(id)); + + if (cartItemIds.length === 0) redirect('/cart'); + + const cartItems = await getCartItems(); + const selected = cartItems.filter((c) => cartItemIds.includes(c.cartId)); + + if (selected.length === 0) redirect('/cart'); + + return selected.map((c) => ({ + productId: c.productId, + productName: c.productName, + brandName: c.brandName, + thumbnailImageUrl: c.thumbnailImageUrl, + selectedSize: c.selectedSize, + selectedColor: c.selectedColor, + quantity: c.quantity, + originalPrice: c.price, + price: c.price, + totalPrice: c.totalPrice, + cartItemId: c.cartId, + })); +} + +/** 바로 구매 아이템 조회 */ +export async function fetchDirectOrderItems( + params: Record, +): Promise { + const productId = Number(params.productId); + if (!productId || isNaN(productId)) notFound(); + + let selections: { color: string; size: string; quantity: number }[]; + try { + const raw = Array.isArray(params.selections) + ? params.selections[0] + : params.selections; + selections = JSON.parse(raw || '[]'); + } catch { + notFound(); + } + + if (selections.length === 0) notFound(); + + const product = await getProductDetail(productId); + + return selections.map((sel) => ({ + productId: product.id, + productName: product.name, + brandName: product.brandName, + thumbnailImageUrl: product.thumbnailImageUrl, + selectedSize: sel.size, + selectedColor: sel.color, + quantity: sel.quantity, + originalPrice: product.price, + price: product.finalPrice, + totalPrice: product.finalPrice * sel.quantity, + })); +} + +/** 주문 내역 조회 */ +export async function getOrders( + params?: OrderListParams, +): Promise { + try { + const response = await api.get('/orders', { + params: params as Record, + }); + return response; + } catch (error) { + console.error('주문 내역 조회 실패:', error); + throw new Error( + error instanceof Error ? error.message : '주문 내역 조회에 실패했습니다.', + ); + } +} + +/** 주문 상세 조회 */ export async function getOrderDetail(orderId: number): Promise { try { const orderDetail = await api.get(`/orders/${orderId}`); diff --git a/src/app/actions/product.ts b/src/app/actions/product.ts new file mode 100644 index 0000000..8f76508 --- /dev/null +++ b/src/app/actions/product.ts @@ -0,0 +1,19 @@ +'use server'; + +import { api } from '@/lib/api-client'; +import type { ProductDetail } from '@/types/domain/product'; + +/** 상품 상세 조회 */ +export async function getProductDetail( + productId: number, +): Promise { + try { + const product = await api.get(`/products/${productId}`); + return product; + } catch (error) { + console.error('상품 상세 조회 실패:', error); + throw new Error( + error instanceof Error ? error.message : '상품 상세 조회에 실패했습니다.', + ); + } +} diff --git a/src/app/actions/user.ts b/src/app/actions/user.ts new file mode 100644 index 0000000..740c734 --- /dev/null +++ b/src/app/actions/user.ts @@ -0,0 +1,20 @@ +'use server'; + +import { api } from '@/lib/api-client'; +import type { UserInfoResDto } from '@/types/domain/user'; + +/** 내 정보 조회 */ +export async function getUserInfo(): Promise { + try { + const user = await api.get('/users/me'); + if (typeof user.points === 'string') { + user.points = Number((user.points as string).replace(/,/g, '')) || 0; + } + return user; + } catch (error) { + console.error('유저 정보 조회 실패:', error); + throw new Error( + error instanceof Error ? error.message : '유저 정보 조회에 실패했습니다.', + ); + } +} diff --git a/src/app/payment/_components/constants.ts b/src/app/payment/_components/constants.ts new file mode 100644 index 0000000..1a542d0 --- /dev/null +++ b/src/app/payment/_components/constants.ts @@ -0,0 +1,7 @@ +export const SECTIONS = { + ITEMS: 'section-items', + SHIPPING: 'section-shipping', + PAYMENT: 'section-payment', +} as const; + +export const SECTION_IDS = Object.values(SECTIONS); diff --git a/src/app/payment/_components/order-items.tsx b/src/app/payment/_components/order-items.tsx new file mode 100644 index 0000000..502a138 --- /dev/null +++ b/src/app/payment/_components/order-items.tsx @@ -0,0 +1,96 @@ +import Image from 'next/image'; + +export interface PaymentDisplayItem { + productId: number; + productName: string; + brandName: string; + thumbnailImageUrl: string; + selectedSize: string; + selectedColor: string; + quantity: number; + originalPrice: number; // 정가 + price: number; // 최종가(판매가) + totalPrice: number; + cartItemId?: number; +} + +interface Props { + items: PaymentDisplayItem[]; +} + +export default function OrderItemsSection({ items }: Props) { + const originalTotal = items.reduce( + (acc, item) => acc + item.originalPrice * item.quantity, + 0, + ); + const discountTotal = items.reduce( + (acc, item) => acc + (item.originalPrice - item.price) * item.quantity, + 0, + ); + const finalTotal = items.reduce((acc, item) => acc + item.totalPrice, 0); + const hasDiscount = discountTotal > 0; + + return ( +
+

+ 주문 정보 확인해주세요 +

+
    + {items.map((item, index) => ( +
  • +
    + {item.thumbnailImageUrl ? ( + {item.productName} + ) : ( +
    + No Image +
    + )} +
    + {item.brandName} +

    {item.productName}

    + + {item.selectedColor} / {item.selectedSize} + + {item.quantity}개 +
    +
    +
  • + ))} +
+ {hasDiscount && ( +

+ 최대 할인이 적용 됐어요 +

+ )} + +
+
+ 상품 금액 + + {originalTotal.toLocaleString()}원 + +
+ {hasDiscount && ( +
+ 할인 금액 + + {discountTotal.toLocaleString()}원 + +
+ )} +
+ 총 금액 + + {finalTotal.toLocaleString()}원 + +
+
+
+ ); +} diff --git a/src/app/payment/_components/payment-context.tsx b/src/app/payment/_components/payment-context.tsx new file mode 100644 index 0000000..77fe355 --- /dev/null +++ b/src/app/payment/_components/payment-context.tsx @@ -0,0 +1,206 @@ +'use client'; + +import { createContext, useContext, useState, type ReactNode } from 'react'; +import { useRouter } from 'next/navigation'; +import type { UserInfoResDto } from '@/types/domain/user'; +import { + createOrderFromCart, + createOrderFromProduct, +} from '@/app/actions/order'; +import type { PaymentDisplayItem } from './order-items'; +import StepNavigator from './step-navigator'; +import ShippingInfoSection, { type ShippingFormState } from './shipping-info'; +import PaymentInfoSection from './payment-info'; +import usePaymentScrollSpy from './use-payment-scroll-spy'; +import { SECTIONS } from './constants'; + +const SECTION_IDS = Object.values(SECTIONS); + +interface PaymentContextValue { + activeId: string; + scrollToId: (id: string) => void; + shippingInfo: ShippingFormState; + setShippingInfo: (v: ShippingFormState) => void; + usedPoints: number; + setUsedPoints: (v: number) => void; + userPoints: number; + totalItemPrice: number; + finalPrice: number; + isSubmitting: boolean; + handlePayment: () => void; +} + +const PaymentContext = createContext(null); + +/** + * 결제 Context를 사용하기 위한 훅 + */ +function usePayment() { + const ctx = useContext(PaymentContext); + if (!ctx) throw new Error('usePayment must be used within PaymentProvider'); + return ctx; +} + +interface ProviderProps { + user: UserInfoResDto; + items: PaymentDisplayItem[]; + orderType: 'cart' | 'direct'; + children: ReactNode; +} + +/** + * 결제 페이지의 전역 상태 관리 Provider + * - 배송지 정보, 포인트, 결제 처리 + */ +export function PaymentProvider({ + user, + items, + orderType, + children, +}: ProviderProps) { + const router = useRouter(); + const { activeId, scrollToId } = usePaymentScrollSpy(SECTION_IDS); + const [isSubmitting, setIsSubmitting] = useState(false); + + const [shippingInfo, setShippingInfo] = useState({ + recipient: user.name, + recipientPhone: user.phone || '', + deliveryAddress: '', + detailAddress: '', + postalCode: '', + deliveryMessage: '', + }); + + const [usedPoints, setUsedPoints] = useState(0); + + const totalItemPrice = items.reduce((acc, item) => acc + item.totalPrice, 0); + const finalPrice = Math.max(0, totalItemPrice - usedPoints); + + /** + * 결제 처리 함수 + * - 배송지 정보 유효성 검증 후 주문 생성 + */ + const handlePayment = async () => { + if (isSubmitting) return; + if ( + !shippingInfo.recipient.trim() || + !shippingInfo.recipientPhone.trim() || + !shippingInfo.deliveryAddress || + !shippingInfo.postalCode + ) { + alert('배송지 정보를 모두 입력해주세요.'); + scrollToId(SECTIONS.SHIPPING); + return; + } + + try { + setIsSubmitting(true); + let orderId: number; + + if (orderType === 'cart') { + orderId = await createOrderFromCart({ + cartItemIds: items + .map((item) => item.cartItemId) + .filter((id): id is number => id != null), + usedPoints, + ...shippingInfo, + }); + } else { + orderId = await createOrderFromProduct({ + items: items.map((item) => ({ + productId: item.productId, + selectedSize: item.selectedSize, + selectedColor: item.selectedColor, + quantity: item.quantity, + })), + usedPoints, + ...shippingInfo, + }); + } + + router.replace(`/payment/complete?orderId=${orderId}`); + } catch (error) { + console.error(error); + const errorMessage = + error instanceof Error ? error.message : '주문에 실패했습니다.'; + alert(errorMessage); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + {children} + + ); +} + +/** + * Context와 연결된 단계별 네비게이터 + */ +export function ConnectedStepNavigator() { + const { activeId, scrollToId } = usePayment(); + return ; +} + +/** + * Context와 연결된 배송지 정보 섹션 + */ +export function ConnectedShippingSection() { + const { shippingInfo, setShippingInfo } = usePayment(); + return ( + + ); +} + +/** + * Context와 연결된 결제 정보 섹션 + */ +export function ConnectedPaymentSection() { + const { userPoints, totalItemPrice, usedPoints, setUsedPoints } = + usePayment(); + return ( + + ); +} + +/** + * 결제하기 버튼 (하단 고정) + */ +export function PaymentButton() { + const { finalPrice, isSubmitting, handlePayment } = usePayment(); + return ( +
+
+ +
+
+ ); +} diff --git a/src/app/payment/_components/payment-info.tsx b/src/app/payment/_components/payment-info.tsx new file mode 100644 index 0000000..86c00d0 --- /dev/null +++ b/src/app/payment/_components/payment-info.tsx @@ -0,0 +1,60 @@ +import { Input } from '@/components/ui/input'; + +interface Props { + userPoints: number; + totalPrice: number; + usedPoints: number; + onPointsChange: (points: number) => void; +} + +export default function PaymentInfoSection({ + userPoints, + totalPrice, + usedPoints, + onPointsChange, +}: Props) { + const handlePointChange = (e: React.ChangeEvent) => { + let val = Number(e.target.value.replace(/[^0-9]/g, '')); + if (val > userPoints) val = userPoints; + if (val > totalPrice) val = totalPrice; + onPointsChange(val); + }; + + return ( +
+
+ 적립금/캐시는 + 얼마나 사용하시겠습니까? +
+ + {/* 적립금 섹션 */} +
+
+ + +
+
+ + {/* 최종 금액 계산 */} +
+ 현재 보유한 적립금: + {userPoints.toLocaleString()}원 +
+
+ ); +} diff --git a/src/app/payment/_components/shipping-info.tsx b/src/app/payment/_components/shipping-info.tsx new file mode 100644 index 0000000..6f5c3cf --- /dev/null +++ b/src/app/payment/_components/shipping-info.tsx @@ -0,0 +1,132 @@ +import { useDaumPostcodePopup } from 'react-daum-postcode'; +import { Input } from '@/components/ui/input'; +import Label from '@/components/ui/label'; + +// 배송지 수정 페이지 구현 되면 다시 컴포넌트 UI 잡기 + +export interface ShippingFormState { + recipient: string; + recipientPhone: string; + deliveryAddress: string; + detailAddress: string; + postalCode: string; + deliveryMessage: string; +} + +interface Props { + value: ShippingFormState; + onChange: (value: ShippingFormState) => void; +} + +export default function ShippingInfoSection({ value, onChange }: Props) { + const openPostcode = useDaumPostcodePopup(); + + const handleChange = (field: keyof ShippingFormState, val: string) => { + onChange({ ...value, [field]: val }); + }; + + const handlePostcodeComplete = (data: { + address: string; + addressType: string; + bname: string; + buildingName: string; + zonecode: string; + }) => { + let fullAddress = data.address; + let extraAddress = ''; + + if (data.addressType === 'R') { + if (data.bname !== '') { + extraAddress += data.bname; + } + if (data.buildingName !== '') { + extraAddress += + extraAddress !== '' ? `, ${data.buildingName}` : data.buildingName; + } + fullAddress += extraAddress !== '' ? ` (${extraAddress})` : ''; + } + + onChange({ + ...value, + postalCode: data.zonecode, + deliveryAddress: fullAddress, + }); + }; + + const handleSearchPostcode = () => { + openPostcode({ onComplete: handlePostcodeComplete }); + }; + + return ( +
+

배송지를 확인 해주세요

+
+
+
+ + handleChange('recipient', e.target.value)} + placeholder="이름" + /> +
+
+ + handleChange('recipientPhone', e.target.value)} + placeholder="010-0000-0000" + /> +
+
+ +
+
+ + +
+ + handleChange('detailAddress', e.target.value)} + placeholder="상세 주소 입력" + /> +
+ +
+ + handleChange('deliveryMessage', e.target.value)} + placeholder="예: 문 앞에 놓아주세요." + /> +
+
+
+ ); +} diff --git a/src/app/payment/_components/step-navigator.tsx b/src/app/payment/_components/step-navigator.tsx new file mode 100644 index 0000000..d8a70ab --- /dev/null +++ b/src/app/payment/_components/step-navigator.tsx @@ -0,0 +1,92 @@ +'use client'; + +import { CloseButton } from '@/components/ui/close-button'; +import { cn } from '@/lib/utils'; +import { SECTIONS } from './constants'; + +interface Props { + activeStep: string; + onStepChange: (id: string) => void; +} + +export default function StepNavigator({ activeStep, onStepChange }: Props) { + const steps = [ + { id: SECTIONS.ITEMS, label: '주문정보\n확인' }, + { id: SECTIONS.SHIPPING, label: '배송지\n확인' }, + { id: SECTIONS.PAYMENT, label: '적립금/캐시\n 확인' }, + ]; + + // 현재 활성 단계 계산 (activeStep이 비어있으면 첫 번째 단계로 간주) + const currentId = activeStep || steps[0].id; + + const getStepStatus = (stepId: string) => { + const currentIndex = steps.findIndex((s) => s.id === currentId); + const stepIndex = steps.findIndex((s) => s.id === stepId); + + if (stepIndex === currentIndex) return 'active'; + return 'inactive'; + }; + + return ( + <> +
+ {/* 1. 타이틀 바 */} +
+ +

+ 주문/결제 +

+
+ + {/* 2. 스텝 내비게이션 바 */} + +
+ + {/* 헤더 높이만큼 공간 확보 */} +