diff --git a/apps/front/wraffle-webview/app/products/[productId]/page.tsx b/apps/front/wraffle-webview/app/products/[productId]/page.tsx index e3118100..0c97f909 100644 --- a/apps/front/wraffle-webview/app/products/[productId]/page.tsx +++ b/apps/front/wraffle-webview/app/products/[productId]/page.tsx @@ -1,10 +1,10 @@ 'use client'; -import {useRouter, useSearchParams} from 'next/navigation'; +import {useRouter, useParams, useSearchParams} from 'next/navigation'; import {useEffect, useState, useRef} from 'react'; -import {sampleRaffleData, sampleEventData} from '@/entities/product/product'; -import type {RaffleData, EventData} from '@/entities/product/product'; +import type {EventData, RaffleData} from '@/entities/product/product'; import ParticipateButton from '@/features/participate/ui/ParticipateButton'; +import {getProductDetailServer} from '@/features/product-detail/api/getProductDetailServer'; import ShareDialog from '@/features/share-product-link/ShareDialog'; import {Header, Divider} from '@/shared/ui'; import {formatDate} from '@/shared/util/formatDate'; @@ -25,12 +25,51 @@ const HEADER_OFFSET = 115; const ProductPage = () => { const router = useRouter(); + const {productId} = useParams(); const searchParams = useSearchParams(); - const type = searchParams.get('type'); - const {selectedMenu, selectMenu} = useMenu('상품' as RaffleMenu | EventMenu); + const type = searchParams.get('type') as 'raffle' | 'event'; + const [productData, setProductData] = useState( null, ); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const numericId = Number(productId); + + if (!numericId || isNaN(numericId)) { + router.push('/404'); + return; + } + + if (type !== 'raffle' && type !== 'event') { + router.push('/404'); + return; + } + + const fetchData = async () => { + try { + const data = await getProductDetailServer({id: numericId, type}); + setProductData(data); + } catch (error) { + console.error('상품 조회 실패:', error); + + if ((error as Error).message.includes('Unauthorized')) { + router.push('/login'); + } else if ((error as Error).message.includes('Not Found')) { + router.push('/404'); + } else { + alert('알 수 없는 오류가 발생했습니다.'); + } + } finally { + setIsLoading(false); + } + }; + + fetchData(); + }, [productId, type, router]); + + const {selectedMenu, selectMenu} = useMenu('상품' as RaffleMenu | EventMenu); const sectionsRef = useRef<{ [key: string]: React.RefObject; @@ -42,25 +81,12 @@ const ProductPage = () => { 유의사항: useRef(null), }); - const menus = type === 'event' ? [...EVENT_MENUS] : [...RAFFLE_MENUS]; - - useEffect(() => { - const data: { - raffle: RaffleData; - event: EventData; - } = { - raffle: sampleRaffleData, - event: sampleEventData, - }; + if (isLoading || !productData) { + return
Loading...
; + } - if (type === 'raffle' || type === 'event') { - setProductData(data[type]); - } else { - router.push('/404'); - } - }, [router, type]); + const menus = type === 'event' ? [...EVENT_MENUS] : [...RAFFLE_MENUS]; - // 메뉴 선택 시 스크롤 이동 함수 const scrollToSection = (menu: RaffleMenu | EventMenu) => { const section = sectionsRef.current[menu]; if (section && section.current) { @@ -71,16 +97,11 @@ const ProductPage = () => { } }; - // 메뉴 클릭 시 메뉴를 선택하고 해당 섹션으로 스크롤 이동 const handleMenuSelect = (menu: RaffleMenu | EventMenu) => { selectMenu(menu); scrollToSection(menu); }; - if (!productData) { - return
Loading...
; - } - return (
@@ -147,6 +168,9 @@ const ProductPage = () => { isApplied={productData.isApplied} productImage={productData.images[0]} isCreator={productData.isCreator} + productId={productData.id} + productType={type.toUpperCase() as 'RAFFLE' | 'EVENT'} + isClipped={productData.isClipped} />
diff --git a/apps/front/wraffle-webview/next.config.mjs b/apps/front/wraffle-webview/next.config.mjs index 30b28ec8..e934c299 100644 --- a/apps/front/wraffle-webview/next.config.mjs +++ b/apps/front/wraffle-webview/next.config.mjs @@ -30,7 +30,7 @@ const nextConfig = { hostname: 'image.vans.co.kr', }, ], - domains: ['github.com'], + domains: ['github.com', 'unsplash.com', 'example.com'], }, }; diff --git a/apps/front/wraffle-webview/src/entities/product/product.ts b/apps/front/wraffle-webview/src/entities/product/product.ts index 1a7a1472..dd70169f 100644 --- a/apps/front/wraffle-webview/src/entities/product/product.ts +++ b/apps/front/wraffle-webview/src/entities/product/product.ts @@ -22,6 +22,7 @@ export interface BaseProductData { status: string; applyCount: number; winnerCount: number; + isClipped: boolean; isCreator: boolean; isApplied: boolean; createUserId: number; @@ -34,100 +35,3 @@ export interface RaffleData extends BaseProductData {} export interface EventData extends BaseProductData { products: Product[]; } - -export const sampleRaffleData: RaffleData = { - id: 1, - title: '[Vans] 올드스쿨', - price: 78000, - startDate: '2024-07-31T00:00:00.000Z', - endDate: '2024-08-02T00:00:00.000Z', - announceAt: '2024-08-03T00:00:00.000Z', - description: - '제작 박스로 준비해드립니다. 오후 3시 이전 결제 완료 시 택배 출고 드립니다. 당일 상품 출고 마감 시간 3시입니다.', - etc: '유의사항', - clipCount: 53, - status: 'after', - applyCount: 10, - winnerCount: 2, - isCreator: true, - isApplied: false, - createUserId: 1, - tags: [ - {id: '1', name: 'Vans'}, - {id: '2', name: '래플'}, - ], - images: [ - 'https://github.com/user-attachments/assets/73684618-8305-4a78-bcd6-e36342b46c22', - 'https://github.com/user-attachments/assets/4a104905-0106-4b8a-8dcd-06926162e2e6', - ], -}; - -export const sampleEventData: EventData = { - id: 1, - title: '이벤트 제목', - price: 10000, - startDate: '2021-08-05T00:00:00.000Z', - endDate: '2021-08-05T00:00:00.000Z', - announceAt: '2021-08-05T00:00:00.000Z', - description: '어쩌고 설명 저쩌고 설명', - etc: '이벤트 유의사항', - clipCount: 10, - applyCount: 10, - status: 'waiting', - winnerCount: 2, - isCreator: false, - isApplied: false, - createUserId: 1, - tags: [ - {id: '1', name: 'Vans'}, - {id: '2', name: '래플'}, - ], - images: [ - 'https://github.com/user-attachments/assets/73684618-8305-4a78-bcd6-e36342b46c22', - 'https://github.com/user-attachments/assets/4a104905-0106-4b8a-8dcd-06926162e2e6', - ], - products: [ - { - id: 1, - name: '1번 상품', - imageUrl: - 'https://github.com/user-attachments/assets/4a104905-0106-4b8a-8dcd-06926162e2e6', - }, - { - id: 2, - name: '2번 상품', - imageUrl: - 'https://github.com/user-attachments/assets/4a104905-0106-4b8a-8dcd-06926162e2e6', - }, - { - id: 3, - name: '3번 상품', - imageUrl: - 'https://github.com/user-attachments/assets/4a104905-0106-4b8a-8dcd-06926162e2e6', - }, - { - id: 4, - name: '4번 상품', - imageUrl: - 'https://github.com/user-attachments/assets/4a104905-0106-4b8a-8dcd-06926162e2e6', - }, - { - id: 5, - name: '5번 상품', - imageUrl: - 'https://github.com/user-attachments/assets/4a104905-0106-4b8a-8dcd-06926162e2e6', - }, - { - id: 6, - name: '6번 상품', - imageUrl: - 'https://github.com/user-attachments/assets/4a104905-0106-4b8a-8dcd-06926162e2e6', - }, - { - id: 7, - name: '7번 상품', - imageUrl: - 'https://github.com/user-attachments/assets/4a104905-0106-4b8a-8dcd-06926162e2e6', - }, - ], -}; diff --git a/apps/front/wraffle-webview/src/features/clipping/api/createClipping.ts b/apps/front/wraffle-webview/src/features/clipping/api/createClipping.ts new file mode 100644 index 00000000..de6e97c9 --- /dev/null +++ b/apps/front/wraffle-webview/src/features/clipping/api/createClipping.ts @@ -0,0 +1,33 @@ +'use server'; + +import apiClient from '@/shared/api/apiClient'; +import {getSession} from '@/shared/util/auth/server'; + +interface CreateClippingRequest { + targetId: number; + type: 'RAFFLE' | 'EVENT'; +} + +interface CreateClippingResponse { + id: number; +} + +export const createClipping = async ( + data: CreateClippingRequest, +): Promise<{data: CreateClippingResponse}> => { + const session = await getSession(); + + if (!session?.accessToken) { + throw new Error('Unauthorized'); + } + + const response = await apiClient.post< + CreateClippingResponse, + CreateClippingRequest + >('/clippings', { + withAuth: true, + body: data, + }); + + return response; +}; diff --git a/apps/front/wraffle-webview/src/features/clipping/api/deleteClipping.ts b/apps/front/wraffle-webview/src/features/clipping/api/deleteClipping.ts new file mode 100644 index 00000000..c02b0e27 --- /dev/null +++ b/apps/front/wraffle-webview/src/features/clipping/api/deleteClipping.ts @@ -0,0 +1,24 @@ +'use server'; + +import apiClient from '@/shared/api/apiClient'; +import {getSession} from '@/shared/util/auth/server'; + +interface DeleteClippingRequest { + id: number; + type: 'RAFFLE' | 'EVENT'; +} + +export const deleteClipping = async ( + data: DeleteClippingRequest, +): Promise => { + const session = await getSession(); + + if (!session?.accessToken) { + throw new Error('Unauthorized'); + } + + await apiClient.delete(`/clippings`, { + withAuth: true, + body: data, + }); +}; diff --git a/apps/front/wraffle-webview/src/features/clipping/ui/ClippingButton.tsx b/apps/front/wraffle-webview/src/features/clipping/ui/ClippingButton.tsx new file mode 100644 index 00000000..e9faa9d1 --- /dev/null +++ b/apps/front/wraffle-webview/src/features/clipping/ui/ClippingButton.tsx @@ -0,0 +1,69 @@ +'use client'; + +import {createClipping} from '../api/createClipping'; +import {deleteClipping} from '../api/deleteClipping'; +import {useState} from 'react'; +import {Icon} from '@wraffle/ui'; + +interface ClippingButtonProps { + targetId: number; + type: 'RAFFLE' | 'EVENT'; + clipCount: number; + isInitiallyClipped?: boolean; +} + +interface ApiError { + status: number; + code?: string; + message?: string; + digest?: string; +} + +export const ClippingButton = ({ + targetId, + type, + clipCount, + isInitiallyClipped = false, +}: ClippingButtonProps) => { + const [isBookmarked, setIsBookmarked] = useState(isInitiallyClipped); + const [count, setCount] = useState(clipCount); + + const handleBookmark = async () => { + try { + if (isBookmarked) { + await deleteClipping({ + id: targetId, + type, + }); + + setIsBookmarked(false); + setCount(prev => Math.max(prev - 1, 0)); + return; + } + + await createClipping({targetId, type}); + + setIsBookmarked(true); + setCount(prev => prev + 1); + } catch (err) { + const error = err as ApiError; + + if (error.status === 409 && error.code === 'CL03') { + alert('이미 클리핑된 상태입니다.'); + setIsBookmarked(true); + } else { + console.error('클리핑 오류:', error); + alert('클리핑 처리에 실패했습니다.'); + } + } + }; + + return ( + + ); +}; diff --git a/apps/front/wraffle-webview/src/features/participate/api/applyProduct.ts b/apps/front/wraffle-webview/src/features/participate/api/applyProduct.ts new file mode 100644 index 00000000..3ccf09dc --- /dev/null +++ b/apps/front/wraffle-webview/src/features/participate/api/applyProduct.ts @@ -0,0 +1,35 @@ +'use server'; + +import apiClient from '@/shared/api/apiClient'; +import {getSession} from '@/shared/util/auth/server'; + +interface ApplyToProductRequest { + targetId: number; + type: 'RAFFLE' | 'EVENT'; +} + +interface ApplyProductResponse { + id: number; + applyUid: string; + applyStatus: 'WAITING' | 'COMPLETE' | 'APPROVED' | 'REJECTED'; +} + +export const applyProduct = async ( + data: ApplyToProductRequest, +): Promise => { + const session = await getSession(); + + if (!session?.accessToken) { + throw new Error('Unauthorized'); + } + + const response = await apiClient.post< + ApplyProductResponse, + ApplyToProductRequest + >('/applies', { + withAuth: true, + body: data, + }); + + return response.data; +}; diff --git a/apps/front/wraffle-webview/src/features/participate/ui/ParticipateButton.tsx b/apps/front/wraffle-webview/src/features/participate/ui/ParticipateButton.tsx index 4e1e00a4..a45ea627 100644 --- a/apps/front/wraffle-webview/src/features/participate/ui/ParticipateButton.tsx +++ b/apps/front/wraffle-webview/src/features/participate/ui/ParticipateButton.tsx @@ -1,8 +1,10 @@ 'use client'; +import {applyProduct} from '../api/applyProduct'; import ParticipateDialog from './ParticipateDialog'; import React, {useState} from 'react'; -import {Button, Icon} from '@wraffle/ui'; +import {ClippingButton} from '@/features/clipping/ui/ClippingButton'; +import {Button} from '@wraffle/ui'; interface ParticipateButtonProps { status: string; @@ -10,6 +12,9 @@ interface ParticipateButtonProps { isApplied: boolean; productImage: string; isCreator: boolean; + productId: number; + productType: 'RAFFLE' | 'EVENT'; + isClipped: boolean; } const ParticipateButton = ({ @@ -18,16 +23,32 @@ const ParticipateButton = ({ isApplied: initialApplyStatus, productImage, isCreator, + productId, + productType, + isClipped, }: ParticipateButtonProps) => { - const [isBookmarked, setIsBookmarked] = useState(false); - const [isApplied, setIsApplied] = useState(initialApplyStatus); + const [isApplied, setIsApplied] = useState(() => initialApplyStatus); + const [dialogOpen, setDialogOpen] = useState(false); - const handleBookmark = () => { - setIsBookmarked(prev => !prev); - }; + const handleApply = async () => { + try { + const response = await applyProduct({ + targetId: productId, + type: productType, + }); - const handleApply = () => { - setIsApplied(true); + console.log('API 응답 수신:', response); + if (response.applyStatus === 'WAITING') { + console.log('🎉 응모 성공, 상태 업데이트'); + setIsApplied(true); + setDialogOpen(true); + } else { + console.log('응모 상태가 WAITING 아님:', response.applyStatus); + } + } catch (error) { + console.error('❗ 응모 중 에러 발생:', error); + alert('예상치 못한 에러가 발생했습니다.'); + } }; if (status === 'after') { @@ -40,23 +61,26 @@ const ParticipateButton = ({ return (
- {isCreator ? ( - - ) : ( + {!isCreator && ( <> - - + + )} +
); }; diff --git a/apps/front/wraffle-webview/src/features/participate/ui/ParticipateDialog.tsx b/apps/front/wraffle-webview/src/features/participate/ui/ParticipateDialog.tsx index 53b9bb6c..67279520 100644 --- a/apps/front/wraffle-webview/src/features/participate/ui/ParticipateDialog.tsx +++ b/apps/front/wraffle-webview/src/features/participate/ui/ParticipateDialog.tsx @@ -11,33 +11,29 @@ import { DialogFooter, DialogHeader, DialogTitle, - DialogTrigger, } from '@wraffle/ui'; interface DialogComponentProps { isApplied: boolean; - handleApply: () => void; + open: boolean; productImage: string; + onOpenChange: (open: boolean) => void; } const ParticipateDialog = ({ - isApplied, - handleApply, + open, + onOpenChange, productImage, }: DialogComponentProps) => { return ( - - - - + 참여가 완료되었습니다 응모 후 당첨 시에만 결제를 진행해요! +
{ + const session = await getSession(); + + if (!session?.accessToken) { + throw new Error('Unauthorized: no access token'); + } + + if (type === 'raffle') { + const res = await apiClient.get(`/raffles/${id}`, { + withAuth: true, + }); + return res.data; + } + + if (type === 'event') { + const res = await apiClient.get(`/events/${id}`, { + withAuth: true, + }); + return res.data; + } + + throw new Error('Invalid product type'); +}; diff --git a/apps/front/wraffle-webview/src/features/share-product-link/ShareDialog.tsx b/apps/front/wraffle-webview/src/features/share-product-link/ShareDialog.tsx index 9ffa383f..c899fb5e 100644 --- a/apps/front/wraffle-webview/src/features/share-product-link/ShareDialog.tsx +++ b/apps/front/wraffle-webview/src/features/share-product-link/ShareDialog.tsx @@ -25,7 +25,7 @@ const ShareDialog = () => { className='flex items-center justify-center' > Instagram { className='flex items-center justify-center' > Kakao { className='flex items-center justify-center' > Copy Link
- {productData.tags.map(tag => ( - {tag.name} - ))} + {productData.tags?.map(tag => {tag.name})}