Skip to content

[WRFE-92](feat) 상세 페이지 API 연동 #94

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 4 commits into
base: dev
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 53 additions & 25 deletions apps/front/wraffle-webview/app/products/[productId]/page.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -25,12 +25,52 @@ 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';

console.log('id', productId);
console.log('type', type);

const [productData, setProductData] = useState<RaffleData | EventData | null>(
null,
);
const [isLoading, setIsLoading] = useState(true);

// ✅ 유효성 검사
const numericId = Number(productId);
const isValidtype = type === 'raffle' || type === 'event';

useEffect(() => {
if (!numericId || isNaN(numericId)) {
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();
}, [numericId, type, router, isValidtype]);

const {selectedMenu, selectMenu} = useMenu('상품' as RaffleMenu | EventMenu);

const sectionsRef = useRef<{
[key: string]: React.RefObject<HTMLDivElement>;
Expand All @@ -42,23 +82,11 @@ const ProductPage = () => {
유의사항: useRef<HTMLDivElement>(null),
});

const menus = type === 'event' ? [...EVENT_MENUS] : [...RAFFLE_MENUS];

useEffect(() => {
const data: {
raffle: RaffleData;
event: EventData;
} = {
raffle: sampleRaffleData,
event: sampleEventData,
};
if (isLoading || !productData) {
return <div>Loading...</div>;
}

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) => {
Expand All @@ -77,10 +105,6 @@ const ProductPage = () => {
scrollToSection(menu);
};

if (!productData) {
return <div>Loading...</div>;
}

return (
<div className='flex min-h-screen flex-col'>
<div className='sticky top-0 z-20 bg-white'>
Expand Down Expand Up @@ -147,6 +171,10 @@ const ProductPage = () => {
isApplied={productData.isApplied}
productImage={productData.images[0]}
isCreator={productData.isCreator}
productId={productData.id}
productType={type.toUpperCase() as 'RAFFLE' | 'EVENT'}
isClipped={productData.isClipped}
clippingId={productData.clippingId}
/>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion apps/front/wraffle-webview/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const nextConfig = {
hostname: 'image.vans.co.kr',
},
],
domains: ['github.com'],
domains: ['github.com', 'unsplash.com', 'example.com'],
},
};

Expand Down
99 changes: 2 additions & 97 deletions apps/front/wraffle-webview/src/entities/product/product.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export interface BaseProductData {
status: string;
applyCount: number;
winnerCount: number;
isClipped: boolean;
clippingId?: number;
isCreator: boolean;
isApplied: boolean;
createUserId: number;
Expand All @@ -34,100 +36,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',
},
],
};
Original file line number Diff line number Diff line change
@@ -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;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
'use server';

import apiClient from '@/shared/api/apiClient';
import {getSession} from '@/shared/util/auth/server';

export const deleteClipping = async (clippingId: number): Promise<void> => {
const session = await getSession();

if (!session?.accessToken) {
throw new Error('Unauthorized');
}

await apiClient.delete<void>(`/clippings/${clippingId}`, {
withAuth: true,
});
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
'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;
initialClippingId?: number; // ✅ 클리핑 ID
}

interface ApiError {
status: number;
code?: string;
message?: string;
digest?: string;
}

export const ClippingButton = ({
targetId,
type,
clipCount,
isInitiallyClipped = false,
initialClippingId,
}: ClippingButtonProps) => {
const [isBookmarked, setIsBookmarked] = useState(isInitiallyClipped);
const [count, setCount] = useState(clipCount);
const [clippingId, setClippingId] = useState<number | null>(
initialClippingId ?? null,
);

const handleBookmark = async () => {
try {
if (isBookmarked) {
if (!clippingId) {
alert('클리핑 ID가 존재하지 않아 삭제할 수 없습니다.');
return;
}
await deleteClipping(clippingId);
setIsBookmarked(false);
setCount(prev => Math.max(prev - 1, 0));
setClippingId(null);
return;
}

const res = await createClipping({targetId, type});
setIsBookmarked(true);
setCount(prev => prev + 1);
setClippingId(res.data?.id ?? null);
} catch (err) {
const error = err as ApiError;

if (error.status === 409 && error.code === 'CL03') {
alert('이미 클리핑된 상태입니다.');
setIsBookmarked(true);
} else {
console.error('클리핑 오류:', error);
alert('클리핑 처리에 실패했습니다.');
}
}
};

return (
<button onClick={handleBookmark}>
<div className='flex flex-col items-center justify-center'>
<Icon name='bookmark' color={isBookmarked ? 'black' : ''} />
<span>{count}</span>
</div>
</button>
);
};
Loading