From a3dd73f6eb55faea584e37a62bc2c7e34200087e Mon Sep 17 00:00:00 2001 From: choiyoungae Date: Mon, 7 Jul 2025 21:15:34 +0900 Subject: [PATCH 001/453] =?UTF-8?q?refactor:=20db=20=EC=8A=A4=ED=82=A4?= =?UTF-8?q?=EB=A7=88=EC=97=90=20=EB=A7=9E=EA=B2=8C=20entities=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/entities/auction/model/types.ts | 28 +++++++++++++++++++ apps/web/entities/bidHistory/model/types.ts | 8 ++++++ apps/web/entities/product/model/types.ts | 18 ++++++++++++ apps/web/entities/productImage/model/types.ts | 6 ++++ apps/web/entities/profiles/model/types.ts | 10 +++++++ 5 files changed, 70 insertions(+) create mode 100644 apps/web/entities/auction/model/types.ts create mode 100644 apps/web/entities/bidHistory/model/types.ts create mode 100644 apps/web/entities/product/model/types.ts create mode 100644 apps/web/entities/productImage/model/types.ts create mode 100644 apps/web/entities/profiles/model/types.ts diff --git a/apps/web/entities/auction/model/types.ts b/apps/web/entities/auction/model/types.ts new file mode 100644 index 00000000..7380eaee --- /dev/null +++ b/apps/web/entities/auction/model/types.ts @@ -0,0 +1,28 @@ +import { BidHistory } from '@/entities/bidHistory/model/types'; +import { Product } from '@/entities/product/model/types'; + +export interface Auction { + auction_id: string; + created_at: string; + product_id: string; + min_price: number; + winning_bid_id?: string; + winning_bid_user_id?: string; + auction_status: string; + auction_end_at: string; + updated_at?: string; +} + +export interface AuctionWithProduct extends Auction { + product: Product; +} + +export interface AuctionWithBids extends Auction { + bid_history: BidHistory[]; +} + +export interface AuctionDetail extends Auction { + product: Product; + bid_history: BidHistory[]; + current_highest_bid: number; // 계산된 필드 +} diff --git a/apps/web/entities/bidHistory/model/types.ts b/apps/web/entities/bidHistory/model/types.ts new file mode 100644 index 00000000..9ed78475 --- /dev/null +++ b/apps/web/entities/bidHistory/model/types.ts @@ -0,0 +1,8 @@ +export interface BidHistory { + bid_id: string; + bid_at: string; + auction_id: string; + bid_user_id: string; + bid_price: number; + is_awarded: boolean; +} diff --git a/apps/web/entities/product/model/types.ts b/apps/web/entities/product/model/types.ts new file mode 100644 index 00000000..807d6cf4 --- /dev/null +++ b/apps/web/entities/product/model/types.ts @@ -0,0 +1,18 @@ +import { Profiles } from '@/entities/profiles/model/types'; + +export interface Product { + product_id: string; + exhibit_user_id: string; + title: string; + description: string; + latitude: number; + longitude: number; + category: string; + address: string; + created_at: string; + updated_at?: string; +} + +export interface ProductWithUser extends Product { + exhibit_user: Profiles[]; +} diff --git a/apps/web/entities/productImage/model/types.ts b/apps/web/entities/productImage/model/types.ts new file mode 100644 index 00000000..a223ccdc --- /dev/null +++ b/apps/web/entities/productImage/model/types.ts @@ -0,0 +1,6 @@ +export interface ProductImage { + image_id: string; + product_id: string; + image_url: string; + order_index: number; +} diff --git a/apps/web/entities/profiles/model/types.ts b/apps/web/entities/profiles/model/types.ts new file mode 100644 index 00000000..1ef9c6a2 --- /dev/null +++ b/apps/web/entities/profiles/model/types.ts @@ -0,0 +1,10 @@ +export interface Profiles { + user_id: string; + email: string; + nickname: string; + latitude?: number; + longitude?: number; + address?: string; + profile_img?: string; + created_at: string; +} From 4a8589f828081dfa42f4ef9e04ea93f8d5d440c7 Mon Sep 17 00:00:00 2001 From: choiyoungae Date: Tue, 8 Jul 2025 21:19:01 +0900 Subject: [PATCH 002/453] =?UTF-8?q?refact:=20=EA=B2=BD=EB=A7=A4=20?= =?UTF-8?q?=EC=83=81=ED=92=88=20=EC=83=81=EC=84=B8=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=ED=8C=8C=EC=9D=BC=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F?= =?UTF-8?q?=20tanstack=20query=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/(header)/auction/[shortId]/page.tsx | 127 +++--------------- apps/web/app/api/auction/[shortId]/route.ts | 31 ++--- apps/web/entities/auction/model/types.ts | 14 +- apps/web/entities/product/model/types.ts | 6 +- .../auction/detail/api/getAuctionDetail.ts | 13 ++ .../auction/detail/model/useAuctionDetail.ts | 12 ++ .../features/auction/detail/types/index.ts | 19 +++ .../auction/detail/ui/AuctionDetailPage.tsx | 69 ++++++++++ .../auction/detail/ui}/BottomBar.tsx | 0 .../auction/detail/ui}/ProductImageSlider.tsx | 0 10 files changed, 154 insertions(+), 137 deletions(-) create mode 100644 apps/web/features/auction/detail/api/getAuctionDetail.ts create mode 100644 apps/web/features/auction/detail/model/useAuctionDetail.ts create mode 100644 apps/web/features/auction/detail/types/index.ts create mode 100644 apps/web/features/auction/detail/ui/AuctionDetailPage.tsx rename apps/web/{app/(header)/auction/[shortId] => features/auction/detail/ui}/BottomBar.tsx (100%) rename apps/web/{app/(header)/auction/[shortId] => features/auction/detail/ui}/ProductImageSlider.tsx (100%) diff --git a/apps/web/app/(header)/auction/[shortId]/page.tsx b/apps/web/app/(header)/auction/[shortId]/page.tsx index 4ef882f2..513e6676 100644 --- a/apps/web/app/(header)/auction/[shortId]/page.tsx +++ b/apps/web/app/(header)/auction/[shortId]/page.tsx @@ -1,120 +1,37 @@ 'use client'; -import ProductImageSlider from './ProductImageSlider'; -import { AlarmClock, PencilLine } from 'lucide-react'; -import { formatNumberWithComma } from '@/shared/lib/formatNumberWithComma'; -import { formatTimestamptz } from '@/shared/lib/formatTimestamp'; -import { Avatar } from '@repo/ui/components/Avatar/Avatar'; -import BottomBar from './BottomBar'; -import { useEffect, useState, use } from 'react'; -import { Auction } from '@/app/api/product/route'; +import { use } from 'react'; import Loading from '@/shared/ui/Loading/Loading'; +import { useAuctionDetail } from '@/features/auction/detail/model/useAuctionDetail'; +import { AuctionDetailContent } from '@/features/auction/detail/types'; +import AuctionDetail from '@/features/auction/detail/ui/AuctionDetailPage'; +import ProductImageSlider from '@/features/auction/detail/ui/ProductImageSlider'; +import BottomBar from '@/features/auction/detail/ui/BottomBar'; -const ProductDetailPage = ({ params }: { params: Promise<{ shortId: string }> }) => { +const AuctionDetailPage = ({ params }: { params: Promise<{ shortId: string }> }) => { const resolvedParams = use(params); - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const { data, isLoading, error } = useAuctionDetail(resolvedParams.shortId); - useEffect(() => { - const fetchData = async () => { - try { - setLoading(true); - const response = await fetch(`/api/auction/${resolvedParams.shortId}`); - - if (!response.ok) { - throw new Error('경매 정보를 가져올 수 없습니다.'); - } - - const result = await response.json(); - setData(result); - } catch (err) { - setError(err instanceof Error ? err.message : '알 수 없는 오류가 발생했습니다.'); - } finally { - setLoading(false); - } - }; - - fetchData(); - }, [resolvedParams.shortId]); - - if (loading) return ; - if (error) return

오류: {error}

; + if (isLoading) return ; + if (error) return

오류: {(error as Error).message}

; if (!data) return

경매 정보를 찾을 수 없습니다.

; - const auction = data; - const mapped = { - auctionId: auction.auction_id, - productTitle: auction.product?.title, - productDescription: auction.product?.description, - images: auction.product?.product_image ?? [], - minPrice: auction.min_price, - auctionEndAt: auction.auction_end_at, - exhibitUser: auction.product?.exhibit_user, - currentHighestBid: auction.current_highest_bid || auction.min_price, - bidHistory: auction.bid_history, + const mapped: AuctionDetailContent = { + auctionId: data.auction_id, + productTitle: data.product?.title, + productDescription: data.product?.description, + images: data.product?.product_image ?? [], + minPrice: data.min_price, + auctionEndAt: data.auction_end_at, + exhibitUser: data.product?.exhibit_user, + currentHighestBid: data.current_highest_bid || data.min_price, + bidHistory: data.bid_history, }; return (
- {/* 이미지 슬라이더 */} - - {/* 경매 상품 내용 */} -
-
{mapped.productTitle}
- -
-
최고 입찰가
-
- {formatNumberWithComma(mapped.currentHighestBid)}원 -
-
- -
-
-
- -
입찰 시작가
-
-
{formatNumberWithComma(mapped.minPrice)}원
-
-
-
-
- -
입찰 마감 일자
-
-
{formatTimestamptz(mapped.auctionEndAt)}
-
-
- -
{mapped.productDescription}
-
-
-
- {/* 입찰 히스토리 (선택사항) */} - {/* {mapped.bidHistory.length > 0 && ( -
-
입찰 현황
-
- 총 {mapped.bidHistory.length}건의 입찰 -
-
- )} - -
*/} - - {/* 판매자 정보 */} -
- -
-
{mapped.exhibitUser?.nickname}
-
{mapped.exhibitUser?.address}
-
-
-
- + }) ); }; -export default ProductDetailPage; +export default AuctionDetailPage; diff --git a/apps/web/app/api/auction/[shortId]/route.ts b/apps/web/app/api/auction/[shortId]/route.ts index 171132a6..3b0dd55f 100644 --- a/apps/web/app/api/auction/[shortId]/route.ts +++ b/apps/web/app/api/auction/[shortId]/route.ts @@ -1,7 +1,7 @@ import { decodeShortId } from '@/shared/lib/shortUuid'; import { supabase } from '@/shared/lib/supabaseClient'; import { NextRequest, NextResponse } from 'next/server'; -import { Auction } from '../../product/route'; +import { AuctionDetail } from '@/entities/auction/model/types'; export async function GET(_req: Request, { params }: { params: Promise<{ shortId: string }> }) { const resolvedParams = await params; @@ -18,7 +18,7 @@ export async function GET(_req: Request, { params }: { params: Promise<{ shortId *, exhibit_user:exhibit_user_id ( * - ), + ), product_image ( * ) @@ -35,7 +35,7 @@ export async function GET(_req: Request, { params }: { params: Promise<{ shortId // 2. 입찰 내역 조회 const { data: bidHistory, error: bidError } = await supabase .from('bid_history') - .select('bid_id, bid_price, bid_user_id, bid_at') + .select('*') .eq('auction_id', id) .order('bid_price', { ascending: false }); @@ -50,27 +50,20 @@ export async function GET(_req: Request, { params }: { params: Promise<{ shortId const currentHighestBid = sortedBidHistory.length > 0 ? sortedBidHistory[0]?.bid_price : auctionData.min_price; + const productData = auctionData.product; + const userData = productData?.exhibit_user; + // 5. 응답 데이터 구성 - const response: Auction = { - auction_id: auctionData.auction_id, + const response: AuctionDetail = { + ...auctionData, product: { - title: auctionData.product?.title, - description: auctionData.product?.description, - category: auctionData.product?.category, - exhibit_user: { - user_id: auctionData.product?.exhibit_user?.user_id, - address: auctionData.product?.exhibit_user?.address, - profile_img: auctionData.product?.exhibit_user?.profile_img, - nickname: auctionData.product?.exhibit_user?.nickname, - }, - product_image: auctionData.product?.product_image || [], + ...productData, + exhibit_user: userData, + product_image: productData?.product_image || [], }, - auction_status: auctionData.auction_status, - min_price: auctionData.min_price, - auction_end_at: auctionData.auction_end_at, bid_history: sortedBidHistory, current_highest_bid: currentHighestBid, - }; + } as AuctionDetail; return NextResponse.json(response); } catch (error) { diff --git a/apps/web/entities/auction/model/types.ts b/apps/web/entities/auction/model/types.ts index 7380eaee..5317d284 100644 --- a/apps/web/entities/auction/model/types.ts +++ b/apps/web/entities/auction/model/types.ts @@ -1,5 +1,5 @@ import { BidHistory } from '@/entities/bidHistory/model/types'; -import { Product } from '@/entities/product/model/types'; +import { ProductWithUserNImages } from '@/entities/product/model/types'; export interface Auction { auction_id: string; @@ -13,16 +13,8 @@ export interface Auction { updated_at?: string; } -export interface AuctionWithProduct extends Auction { - product: Product; -} - -export interface AuctionWithBids extends Auction { - bid_history: BidHistory[]; -} - export interface AuctionDetail extends Auction { - product: Product; + product: ProductWithUserNImages; bid_history: BidHistory[]; - current_highest_bid: number; // 계산된 필드 + current_highest_bid: number; } diff --git a/apps/web/entities/product/model/types.ts b/apps/web/entities/product/model/types.ts index 807d6cf4..2479d53d 100644 --- a/apps/web/entities/product/model/types.ts +++ b/apps/web/entities/product/model/types.ts @@ -1,3 +1,4 @@ +import { ProductImage } from '@/entities/productImage/model/types'; import { Profiles } from '@/entities/profiles/model/types'; export interface Product { @@ -13,6 +14,7 @@ export interface Product { updated_at?: string; } -export interface ProductWithUser extends Product { - exhibit_user: Profiles[]; +export interface ProductWithUserNImages extends Product { + exhibit_user: Profiles; + product_image: ProductImage[]; } diff --git a/apps/web/features/auction/detail/api/getAuctionDetail.ts b/apps/web/features/auction/detail/api/getAuctionDetail.ts new file mode 100644 index 00000000..1b82031b --- /dev/null +++ b/apps/web/features/auction/detail/api/getAuctionDetail.ts @@ -0,0 +1,13 @@ +import { AuctionDetail } from '@/entities/auction/model/types'; + +export const getAuctionDetail = async (shortId: string): Promise => { + const res = await fetch(`/api/auction/${shortId}`); + + if (!res.ok) { + console.error('경매 상세 API 실패:', res.status); + return null; + } + + const data = await res.json(); + return data as AuctionDetail; +}; diff --git a/apps/web/features/auction/detail/model/useAuctionDetail.ts b/apps/web/features/auction/detail/model/useAuctionDetail.ts new file mode 100644 index 00000000..5642d848 --- /dev/null +++ b/apps/web/features/auction/detail/model/useAuctionDetail.ts @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query'; +import { getAuctionDetail } from '../api/getAuctionDetail'; +import { AuctionDetail } from '@/entities/auction/model/types'; + +export const useAuctionDetail = (shortId: string) => { + return useQuery({ + queryKey: ['auctionDetail', shortId], + queryFn: () => getAuctionDetail(shortId), + enabled: !!shortId, + staleTime: 1000 * 60 * 1, + }); +}; diff --git a/apps/web/features/auction/detail/types/index.ts b/apps/web/features/auction/detail/types/index.ts new file mode 100644 index 00000000..7cbbcdc9 --- /dev/null +++ b/apps/web/features/auction/detail/types/index.ts @@ -0,0 +1,19 @@ +import { ProductImage } from '@/entities/productImage/model/types'; +import { Profiles } from '@/entities/profiles/model/types'; +import { BidHistory } from '@/entities/bidHistory/model/types'; + +export interface AuctionDetailContent { + auctionId: string; + productTitle: string; + productDescription: string; + images: ProductImage[]; + minPrice: number; + auctionEndAt: string; + exhibitUser: Profiles; + currentHighestBid: number; + bidHistory: BidHistory[]; +} + +export type AuctionDetailContentProps = { + data: AuctionDetailContent; +}; diff --git a/apps/web/features/auction/detail/ui/AuctionDetailPage.tsx b/apps/web/features/auction/detail/ui/AuctionDetailPage.tsx new file mode 100644 index 00000000..f515c07e --- /dev/null +++ b/apps/web/features/auction/detail/ui/AuctionDetailPage.tsx @@ -0,0 +1,69 @@ +import { formatNumberWithComma } from '@/shared/lib/formatNumberWithComma'; +import { Avatar } from '@repo/ui/components/Avatar/Avatar'; +import { AlarmClock, PencilLine } from 'lucide-react'; +import React from 'react'; +import { AuctionDetailContentProps } from '../types'; +import { formatTimestamptz } from '@/shared/lib/formatTimestamp'; + +const AuctionDetail = ({ data }: AuctionDetailContentProps) => { + return ( + <> + {/* 경매 상품 내용 */} +
+
{data.productTitle}
+ +
+
최고 입찰가
+
+ {formatNumberWithComma(data.currentHighestBid)}원 +
+
+ +
+
+
+ +
입찰 시작가
+
+
{formatNumberWithComma(data.minPrice)}원
+
+
+
+
+ +
입찰 마감 일자
+
+
{formatTimestamptz(data.auctionEndAt)}
+
+
+ +
{data.productDescription}
+
+
+
+ {/* 입찰 히스토리 (선택사항) */} + {/* {data.bidHistory.length > 0 && ( +
+
입찰 현황
+
+ 총 {data.bidHistory.length}건의 입찰 +
+
+ )} + +
*/} + + {/* 판매자 정보 */} +
+ +
+
{data.exhibitUser?.nickname}
+
{data.exhibitUser?.address}
+
+
+
+ + ); +}; + +export default AuctionDetail; diff --git a/apps/web/app/(header)/auction/[shortId]/BottomBar.tsx b/apps/web/features/auction/detail/ui/BottomBar.tsx similarity index 100% rename from apps/web/app/(header)/auction/[shortId]/BottomBar.tsx rename to apps/web/features/auction/detail/ui/BottomBar.tsx diff --git a/apps/web/app/(header)/auction/[shortId]/ProductImageSlider.tsx b/apps/web/features/auction/detail/ui/ProductImageSlider.tsx similarity index 100% rename from apps/web/app/(header)/auction/[shortId]/ProductImageSlider.tsx rename to apps/web/features/auction/detail/ui/ProductImageSlider.tsx From 853ace7cbc98d07711efcfee0950def0ffd81122 Mon Sep 17 00:00:00 2001 From: choiyoungae Date: Wed, 9 Jul 2025 17:53:56 +0900 Subject: [PATCH 003/453] =?UTF-8?q?refact:=20pending=5Fauction=EC=9D=84=20?= =?UTF-8?q?auction=EC=9C=BC=EB=A1=9C=20=EB=B3=91=ED=95=A9=ED=95=A8?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D,=20=EC=88=98=EC=A0=95=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/app/api/cron/create-auction/route.ts | 49 ++++++++----------- .../app/api/product/edit/[shortId]/route.ts | 36 +++++--------- apps/web/app/api/product/route.ts | 11 ++--- 3 files changed, 37 insertions(+), 59 deletions(-) diff --git a/apps/web/app/api/cron/create-auction/route.ts b/apps/web/app/api/cron/create-auction/route.ts index 15803c91..87e24ddd 100644 --- a/apps/web/app/api/cron/create-auction/route.ts +++ b/apps/web/app/api/cron/create-auction/route.ts @@ -3,61 +3,52 @@ import { supabase } from '@/shared/lib/supabaseClient'; export async function GET(request: NextRequest) { try { - // 현재 시간을 기준으로 생성해야 할 pending_auction 조회 + // 현재 시간 - 1시간을 기준으로 '경매 중'으로 변경해야 할 auction 조회 const now = new Date().toISOString(); + const referenceTime = new Date(Date.now() - 60 * 60 * 1000).toISOString(); - const { data: pendingAuctions, error: fetchError } = await supabase - .from('pending_auction') - .select('*') + const { data: auctions, error: fetchError } = await supabase + .from('auction') + .select('id, product_id') .eq('auction_status', '경매 대기') - .lte('scheduled_create_at', now); // 예정 시간이 지난 것들 + .lte('created_at', referenceTime); if (fetchError) { - console.error('Pending auctions 조회 실패:', fetchError); + console.error('auctions 조회 실패:', fetchError); return NextResponse.json({ success: false, error: fetchError.message }, { status: 500 }); } let successCount = 0; let failCount = 0; - // 각 pending auction에 대해 실제 auction 생성 - for (const pending of pendingAuctions || []) { + // 각 auction의 auction_status를 '경매 중'으로 변경 + for (const auction of auctions || []) { try { // auction 테이블에 데이터 생성 - const { error: createError } = await supabase.from('auction').insert({ - product_id: pending.product_id, - min_price: pending.min_price, - auction_end_at: pending.auction_end_at, - created_at: new Date().toISOString(), - auction_status: '경매 중', - }); + const { error: updateError } = await supabase + .from('auction') + .update({ + auction_status: '경매 중', + updated_at: now, + }) + .eq('id', auction.id); - if (createError) { - console.error(`Product ${pending.product_id} 경매 생성 실패:`, createError); + if (updateError) { + console.error(`Product ${auction.product_id} 경매 생성 실패:`, updateError); failCount++; continue; } - // pending_auction 데이터 삭제 - const { error: deleteError } = await supabase - .from('pending_auction') - .delete() - .eq('pending_auction_id', pending.pending_auction_id); - - if (deleteError) { - console.error(`Pending auction ${pending.pending_auction_id} 삭제 실패:`, deleteError); - } - successCount++; } catch (error) { - console.error(`Product ${pending.product_id} 처리 중 오류:`, error); + console.error(`Product ${auction.product_id} 처리 중 오류:`, error); failCount++; } } const result = { success: true, - processed: pendingAuctions?.length || 0, + processed: auctions?.length || 0, successCount, failCount, timestamp: now, diff --git a/apps/web/app/api/product/edit/[shortId]/route.ts b/apps/web/app/api/product/edit/[shortId]/route.ts index 23c4b1cb..2230b92e 100644 --- a/apps/web/app/api/product/edit/[shortId]/route.ts +++ b/apps/web/app/api/product/edit/[shortId]/route.ts @@ -63,29 +63,19 @@ export async function POST(request: Request, { params }: { params: { shortId: st const hasImageChanges = newImageFiles.length > 0 || imageOrders.length > 0; try { - // STEP 0: 수정 제한 시간 체크 - const { data: pending, error: pendingError } = await supabase - .from('pending_auction') - .select('scheduled_create_at') + // STEP 0: 경매 상태 체크 + const { data: auction, error: auctionError } = await supabase + .from('auction') + .select('auction_status') .eq('product_id', productId) .maybeSingle(); - if (pendingError) { - throw new Error(`경매 제한 시간 조회 실패: ${pendingError.message}`); + if (auctionError) { + throw new Error(`경매 상태 조회 실패: ${auctionError.message}`); } - if (!pending || !pending.scheduled_create_at) { - return NextResponse.json( - { error: '경매 제한 시간이 설정되어 있지 않습니다.' }, - { status: 400 } - ); - } - - const scheduledTime = new Date(pending.scheduled_create_at); - const now = new Date(); - - if (now > scheduledTime) { - return NextResponse.json({ error: '상품 수정 가능 시간이 만료되었습니다.' }, { status: 403 }); + if (!auction || auction.auction_status !== '경매 대기') { + return NextResponse.json({ error: '상품 수정 가능 시간이 만료되었습니다.' }, { status: 400 }); } // STEP 1: product 테이블 업데이트 @@ -103,9 +93,9 @@ export async function POST(request: Request, { params }: { params: { shortId: st throw new Error(`상품 정보 업데이트 실패: ${productUpdateError.message}`); } - // STEP 2: pending_auction 테이블 업데이트 - const { error: pendingUpdateError } = await supabase - .from('pending_auction') + // STEP 2: auction 테이블 업데이트 + const { error: auctionUpdateError } = await supabase + .from('auction') .update({ min_price: parseInt(minPrice), auction_end_at: endAt, @@ -113,8 +103,8 @@ export async function POST(request: Request, { params }: { params: { shortId: st }) .eq('product_id', productId); - if (pendingUpdateError) { - throw new Error(`경매 정보 업데이트 실패: ${pendingUpdateError.message}`); + if (auctionUpdateError) { + throw new Error(`경매 정보 업데이트 실패: ${auctionUpdateError.message}`); } // STEP 3: 이미지 처리 로직 (변경이 있는 경우에만) diff --git a/apps/web/app/api/product/route.ts b/apps/web/app/api/product/route.ts index 3979cd4e..ee83725f 100644 --- a/apps/web/app/api/product/route.ts +++ b/apps/web/app/api/product/route.ts @@ -89,19 +89,16 @@ export async function POST(req: NextRequest) { } } - // STEP 4: 1시간 뒤 auction 자동 생성 예약 호출 - const auctionCreateTime = new Date(Date.now() + 60 * 60 * 1000); // 1시간 후 - - const { error: pendingError } = await supabase.from('pending_auction').insert({ + // STEP 4: auction 테이블에 insert + const { error: auctionError } = await supabase.from('auction').insert({ product_id: productId, min_price: minPrice, auction_end_at: endAt, - scheduled_create_at: auctionCreateTime.toISOString(), auction_status: '경매 대기', }); - if (pendingError) { - console.error('예약 경매 저장 실패:', pendingError); + if (auctionError) { + console.error('경매 저장 실패:', auctionError); } return NextResponse.json({ success: true, product_id: productId }); From 7ff2c1bb89b6761a107d92fb5fde56026d7fa072 Mon Sep 17 00:00:00 2001 From: choiyoungae Date: Wed, 9 Jul 2025 19:19:20 +0900 Subject: [PATCH 004/453] =?UTF-8?q?fix:=20=EC=83=81=ED=92=88=EC=9D=84=20?= =?UTF-8?q?=EA=B2=BD=EB=A7=A4=20=EB=8C=80=EA=B8=B0=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EA=B2=BD=EB=A7=A4=20=EC=A4=91=EC=9C=BC=EB=A1=9C=20=EB=B0=94?= =?UTF-8?q?=EA=BE=B8=EB=8A=94=20=EB=A1=9C=EC=A7=81=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=EA=B0=92=EC=9D=84=20=EC=9E=98=EB=AA=BB=20?= =?UTF-8?q?=EB=B6=88=EB=9F=AC=EC=98=A4=EB=8D=98=20=EB=B6=80=EB=B6=84=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/app/api/cron/create-auction/route.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/app/api/cron/create-auction/route.ts b/apps/web/app/api/cron/create-auction/route.ts index 87e24ddd..922344f1 100644 --- a/apps/web/app/api/cron/create-auction/route.ts +++ b/apps/web/app/api/cron/create-auction/route.ts @@ -9,7 +9,7 @@ export async function GET(request: NextRequest) { const { data: auctions, error: fetchError } = await supabase .from('auction') - .select('id, product_id') + .select('auction_id, product_id') .eq('auction_status', '경매 대기') .lte('created_at', referenceTime); @@ -31,7 +31,7 @@ export async function GET(request: NextRequest) { auction_status: '경매 중', updated_at: now, }) - .eq('id', auction.id); + .eq('auction_id', auction.auction_id); if (updateError) { console.error(`Product ${auction.product_id} 경매 생성 실패:`, updateError); From dbcd545f35947db301e26ad30b6fba8618bb7f15 Mon Sep 17 00:00:00 2001 From: choiyoungae Date: Wed, 9 Jul 2025 20:26:47 +0900 Subject: [PATCH 005/453] =?UTF-8?q?refact:=20=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=EB=A1=9C=EC=A7=81=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/features/product/api/createProduct.ts | 33 +++ apps/web/features/product/lib/utils.ts | 15 ++ .../product/model/useCreateProduct.ts | 73 +++++++ .../features/product/model/useProductForm.ts | 98 +++++++++ apps/web/features/product/types/index.ts | 71 +++++++ .../product/ui/ProductRegistrationForm.tsx | 190 ++++++++++++++++++ 6 files changed, 480 insertions(+) create mode 100644 apps/web/features/product/api/createProduct.ts create mode 100644 apps/web/features/product/model/useCreateProduct.ts create mode 100644 apps/web/features/product/model/useProductForm.ts create mode 100644 apps/web/features/product/ui/ProductRegistrationForm.tsx diff --git a/apps/web/features/product/api/createProduct.ts b/apps/web/features/product/api/createProduct.ts new file mode 100644 index 00000000..14b1f894 --- /dev/null +++ b/apps/web/features/product/api/createProduct.ts @@ -0,0 +1,33 @@ +import { combineDateTime, parseFormattedPrice } from '../lib/utils'; +import { ApiError, CreateProductRequest, CreateProductResponse } from '../types'; + +export const createProduct = async (data: CreateProductRequest): Promise => { + const endAt = combineDateTime(data.endDate, data.endTime); + const numericPrice = parseFormattedPrice(data.minPrice); + + const formData = new FormData(); + formData.append('title', data.title); + formData.append('description', data.description); + formData.append('min_price', numericPrice.toString()); + formData.append('end_at', endAt.toISOString()); + formData.append('category', data.category); + formData.append('user_id', data.userId); + + data.images.forEach((img) => { + if (img.file) { + formData.append('images', img.file); + } + }); + + const res = await fetch('/api/product', { + method: 'POST', + body: formData, + }); + + if (!res.ok) { + const errorData: ApiError = await res.json(); + throw new Error(errorData.error || '출품 실패'); + } + + return res.json(); +}; diff --git a/apps/web/features/product/lib/utils.ts b/apps/web/features/product/lib/utils.ts index 1dd1c15c..b6918289 100644 --- a/apps/web/features/product/lib/utils.ts +++ b/apps/web/features/product/lib/utils.ts @@ -1,3 +1,5 @@ +import { formatNumberWithComma } from '@/shared/lib/formatNumberWithComma'; + export const getCountdownWithColor = ( endTime: string | Date ): { text: string; color: 'gray' | 'orange' | 'blue' } => { @@ -46,3 +48,16 @@ export function getDistanceKm(lat1: number, lng1: number, lat2: number, lng2: nu const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c; } + +export const parseFormattedPrice = (formattedPrice: string): number => { + return parseInt(formattedPrice.replace(/,/g, ''), 10); +}; + +export const formatPriceInput = (value: string): string => { + const numericOnly = value.replace(/\D/g, ''); + return formatNumberWithComma(numericOnly); +}; + +export const combineDateTime = (date: string, time: string): Date => { + return new Date(`${date}T${time}`); +}; diff --git a/apps/web/features/product/model/useCreateProduct.ts b/apps/web/features/product/model/useCreateProduct.ts new file mode 100644 index 00000000..e58f3f68 --- /dev/null +++ b/apps/web/features/product/model/useCreateProduct.ts @@ -0,0 +1,73 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useRouter } from 'next/navigation'; +import { toast } from '@repo/ui/components/Toast/Sonner'; +import { CreateProductRequest, CreateProductResponse, productKeys } from '../types'; +import { createProduct } from '../api/createProduct'; + +interface UseCreateProductOptions { + onSuccess?: (data: CreateProductResponse) => void; + onError?: (error: Error) => void; +} + +export const useCreateProduct = (options?: UseCreateProductOptions) => { + const queryClient = useQueryClient(); + const router = useRouter(); + + return useMutation({ + mutationFn: (data: CreateProductRequest) => createProduct(data), + onSuccess: (data) => { + // 성공 시 관련 쿼리 무효화 + queryClient.invalidateQueries({ + queryKey: productKeys.lists(), + }); + + toast({ content: '출품이 완료되었습니다!' }); + + // 성공 후 페이지 이동 + setTimeout(() => { + router.push('/auction/listings'); + }, 0); + + options?.onSuccess?.(data); + }, + onError: (error: Error) => { + console.error('출품 에러:', error); + toast({ content: error.message || '출품 실패' }); + + options?.onError?.(error); + }, + }); +}; + +// 폼 검증과 함께 사용하는 hook +export const useCreateProductWithValidation = (options?: UseCreateProductOptions) => { + const createProduct = useCreateProduct(options); + + const createProductWithValidation = (data: CreateProductRequest) => { + // 기본 검증 + if ( + !data.title || + !data.category || + !data.description || + !data.minPrice || + !data.endDate || + !data.endTime || + data.images.length === 0 + ) { + toast({ content: '모든 필수 항목을 입력해 주세요' }); + return; + } + + if (!data.userId) { + toast({ content: '로그인이 필요합니다.' }); + return; + } + + createProduct.mutate(data); + }; + + return { + ...createProduct, + mutate: createProductWithValidation, + }; +}; diff --git a/apps/web/features/product/model/useProductForm.ts b/apps/web/features/product/model/useProductForm.ts new file mode 100644 index 00000000..5c28b033 --- /dev/null +++ b/apps/web/features/product/model/useProductForm.ts @@ -0,0 +1,98 @@ +import { useState } from 'react'; +import { UploadedImage } from '@/shared/lib/ImageUploadPreview'; +import { CategoryValue } from '@/features/category/types'; +import { ProductFormActions, ProductFormState } from '../types'; + +const initialState: Omit = { + title: '', + category: '', + description: '', + minPrice: '', + endDate: '', + endTime: '', + images: [], +}; + +export const useProductForm = (options?: { withSubmitting?: boolean }) => { + const [title, setTitle] = useState(''); + const [category, setCategory] = useState(''); + const [description, setDescription] = useState(''); + const [minPrice, setMinPrice] = useState(''); + const [endDate, setEndDate] = useState(''); + const [endTime, setEndTime] = useState(''); + const [images, setImages] = useState([]); + const [isSubmitting, setIsSubmitting] = useState(false); + + const reset = () => { + setTitle(''); + setCategory(''); + setDescription(''); + setMinPrice(''); + setEndDate(''); + setEndTime(''); + setImages([]); + setIsSubmitting(false); + }; + + const base = { + title, + setTitle, + category, + setCategory, + description, + setDescription, + minPrice, + setMinPrice, + endDate, + setEndDate, + endTime, + setEndTime, + images, + setImages, + reset, + }; + + return options?.withSubmitting ? { ...base, isSubmitting, setIsSubmitting } : base; +}; + +// TanStack Query 사용 시 isSubmitting은 mutation 상태에서 가져오므로 제거 +export const useProductFormWithoutSubmitting = (): Omit & + Omit => { + const [title, setTitle] = useState(initialState.title); + const [category, setCategory] = useState(initialState.category); + const [description, setDescription] = useState(initialState.description); + const [minPrice, setMinPrice] = useState(initialState.minPrice); + const [endDate, setEndDate] = useState(initialState.endDate); + const [endTime, setEndTime] = useState(initialState.endTime); + const [images, setImages] = useState(initialState.images); + + const reset = () => { + setTitle(initialState.title); + setCategory(initialState.category); + setDescription(initialState.description); + setMinPrice(initialState.minPrice); + setEndDate(initialState.endDate); + setEndTime(initialState.endTime); + setImages(initialState.images); + }; + + return { + // State + title, + category, + description, + minPrice, + endDate, + endTime, + images, + // Actions + setTitle, + setCategory, + setDescription, + setMinPrice, + setEndDate, + setEndTime, + setImages, + reset, + }; +}; diff --git a/apps/web/features/product/types/index.ts b/apps/web/features/product/types/index.ts index eac72578..a3a16285 100644 --- a/apps/web/features/product/types/index.ts +++ b/apps/web/features/product/types/index.ts @@ -1,3 +1,6 @@ +import { CategoryValue } from '@/features/category/types'; +import { UploadedImage } from '@/shared/lib/ImageUploadPreview'; + export interface ProductForList { id: string; thumbnail: string; @@ -12,3 +15,71 @@ export interface ProductForList { isAwarded: boolean; isPending?: boolean; } + +export interface ProductFormData { + title: string; + category: CategoryValue | string; + description: string; + minPrice: string; + endDate: string; + endTime: string; + images: UploadedImage[]; +} + +export interface ProductCreateRequest { + title: string; + description: string; + min_price: number; + end_at: string; + category: string; + user_id: string; + images: File[]; +} + +export interface ProductFormState extends ProductFormData { + isSubmitting: boolean; +} + +export interface ProductFormActions { + setTitle: (title: string) => void; + setCategory: (category: CategoryValue | string) => void; + setDescription: (description: string) => void; + setMinPrice: (price: string) => void; + setEndDate: (date: string) => void; + setEndTime: (time: string) => void; + setImages: (images: UploadedImage[]) => void; + setIsSubmitting: (isSubmitting: boolean) => void; + reset: () => void; +} + +export interface CreateProductRequest { + title: string; + category: string; + description: string; + minPrice: string; + endDate: string; + endTime: string; + images: UploadedImage[]; + userId: string; +} + +export interface CreateProductResponse { + id: string; + title: string; + message: string; +} + +export interface ApiError { + error: string; + message?: string; + statusCode?: number; +} + +export const productKeys = { + all: ['products'] as const, + lists: () => [...productKeys.all, 'list'] as const, + list: (filters: Record) => [...productKeys.lists(), filters] as const, + details: () => [...productKeys.all, 'detail'] as const, + detail: (id: string) => [...productKeys.details(), id] as const, + create: () => [...productKeys.all, 'create'] as const, +} as const; diff --git a/apps/web/features/product/ui/ProductRegistrationForm.tsx b/apps/web/features/product/ui/ProductRegistrationForm.tsx new file mode 100644 index 00000000..32c5a73a --- /dev/null +++ b/apps/web/features/product/ui/ProductRegistrationForm.tsx @@ -0,0 +1,190 @@ +'use client'; + +import React from 'react'; +import { Input } from '@repo/ui/components/Input/Input'; +import { Textarea } from '@repo/ui/components/Textarea/Textarea'; +import { Button } from '@repo/ui/components/Button/Button'; +import { useRouter } from 'next/navigation'; +import ImageUploadPreview from '@/shared/lib/ImageUploadPreview'; +import { categories, CategoryValue } from '@/features/category/types'; +import { useAuthStore } from '@/shared/model/authStore'; +import { + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectItem, +} from '@repo/ui/components/Select/Select'; +import { formatPriceInput } from '../lib/utils'; +import { useCreateProductWithValidation } from '../model/useCreateProduct'; +import { useProductFormWithoutSubmitting } from '../model/useProductForm'; + +export const ProductRegistrationForm = () => { + const router = useRouter(); + const user = useAuthStore(); + + const { + // State + title, + category, + description, + minPrice, + endDate, + endTime, + images, + // Actions + setTitle, + setCategory, + setDescription, + setMinPrice, + setEndDate, + setEndTime, + setImages, + reset, + } = useProductFormWithoutSubmitting(); + + const createProduct = useCreateProductWithValidation({ + onSuccess: () => { + // 성공 시 폼 리셋 + reset(); + router.push('/auction/listings'); + }, + }); + + const handleMinPriceChange = (e: React.ChangeEvent) => { + const formatted = formatPriceInput(e.target.value); + setMinPrice(formatted); + }; + + const handleSubmit = () => { + if (!user.user?.id) { + return; + } + + createProduct.mutate({ + title, + category, + description, + minPrice, + endDate, + endTime, + images, + userId: user.user.id, + }); + }; + + const isSubmitting = createProduct.isPending; + + return ( +
+
+ {/* 사진 업로드 */} + + + {/* 상품 제목 */} +
+
+ 상품 제목* +
+ setTitle(e.target.value)} + required + /> +
+ + {/* 카테고리 */} +
+
+ 카테고리* +
+ +
+ + {/* 상품 설명 */} +
+
+ 자세한 설명* +
+