-
-
ํ์๊ฐ์
- {/*
SNS ๊ณ์ ์ผ๋ก ๊ฐํธํ๊ฒ ํ์๊ฐ์
+const SignUpPage = () => {
+ const router = useRouter();
-
-
-
+ return (
+
+
+ router.back()}
+ />
+ ํ์๊ฐ์
+
-
-
*/}
-
-
+
);
-}
+};
+
+export default SignUpPage;
diff --git a/apps/web/app/(plane)/splash/loading/page.tsx b/apps/web/app/(plane)/splash/loading/page.tsx
new file mode 100644
index 00000000..3d50fe13
--- /dev/null
+++ b/apps/web/app/(plane)/splash/loading/page.tsx
@@ -0,0 +1,25 @@
+'use client';
+
+import { useEffect } from 'react';
+import { useRouter } from 'next/navigation';
+import Logo from '@/shared/ui/icon/Logo';
+
+export default function SplashLoadingPage() {
+ const router = useRouter();
+
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ router.replace('/splash/welcome');
+ }, 2000);
+
+ return () => clearTimeout(timer);
+ }, [router]);
+
+ return (
+
+ );
+}
diff --git a/apps/web/app/(plane)/splash/page.tsx b/apps/web/app/(plane)/splash/page.tsx
index 4c518c9e..de8c8830 100644
--- a/apps/web/app/(plane)/splash/page.tsx
+++ b/apps/web/app/(plane)/splash/page.tsx
@@ -1,102 +1,14 @@
'use client';
-import { useState, useEffect } from 'react';
+import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
-import Logo from '@/shared/ui/icon/Logo';
-import { Button } from '@repo/ui/components/Button/Button';
-import Link from 'next/link';
export default function SplashPage() {
- const [isLoading, setIsLoading] = useState(true);
- const [isMounted, setIsMounted] = useState(false);
const router = useRouter();
useEffect(() => {
- setIsMounted(true);
+ router.replace('/splash/loading');
+ }, [router]);
- const timer = setTimeout(() => {
- setIsLoading(false);
- }, 5000);
-
- return () => clearTimeout(timer);
- }, []);
-
- const checkIsAuthenticated = (): boolean => {
- if (typeof window === 'undefined' || !isMounted) return false;
-
- const tokenData = localStorage.getItem('sb-nrxemenkpeejarhejbbk-auth-token');
-
- if (!tokenData) return false;
-
- const accessToken = JSON.parse(tokenData).access_token;
-
- return !!accessToken;
- };
-
- const handleStartClick = () => {
- if (!isMounted) return;
-
- const isAuthenticated = checkIsAuthenticated();
-
- if (isAuthenticated) {
- const authStorage = localStorage.getItem('auth-storage');
-
- if (!authStorage) return false;
- const address = JSON.parse(authStorage).state.user.address;
-
- if (address && address !== 'null') {
- router.push('/');
- } else {
- router.push('/setLocation');
- }
- } else {
- router.push('/signup');
- }
- };
-
- if (!isMounted) {
- return (
-
- );
- }
-
- if (isLoading) {
- return (
-
- );
- }
-
- return (
-
-
-
-
-
-
-
-
-
- ์ด๋ฏธ ๊ณ์ ์ด ์๋์?{' '}
-
- ๋ก๊ทธ์ธ
-
-
-
-
-
- );
+ return null;
}
diff --git a/apps/web/app/(plane)/splash/welcome/page.tsx b/apps/web/app/(plane)/splash/welcome/page.tsx
new file mode 100644
index 00000000..9352b0ce
--- /dev/null
+++ b/apps/web/app/(plane)/splash/welcome/page.tsx
@@ -0,0 +1,40 @@
+'use client';
+
+import Logo from '@/shared/ui/icon/Logo';
+import Link from 'next/link';
+import { Button } from '@repo/ui/components/Button/Button';
+import { useRouter } from 'next/navigation';
+
+export default function SplashWelcomePage() {
+ const router = useRouter();
+
+ const handleStartClick = () => {
+ router.push('/signup');
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+ ์ด๋ฏธ ๊ณ์ ์ด ์๋์?{' '}
+
+ ๋ก๊ทธ์ธ
+
+
+
+
+
+ );
+}
diff --git a/apps/web/app/actions.ts b/apps/web/app/actions.ts
new file mode 100644
index 00000000..cb473879
--- /dev/null
+++ b/apps/web/app/actions.ts
@@ -0,0 +1,160 @@
+'use server';
+
+import {
+ getPushAlarmMessage,
+ PushAlarmData,
+ PushAlarmType,
+} from '@/features/alarm/setting/lib/getPushAlarmMessage';
+import { createServerClient } from '@supabase/ssr';
+import { supabase } from '@/shared/lib/supabaseClient';
+import { cookies } from 'next/headers';
+import webpush from 'web-push';
+
+interface PushSubscriptionSerialized {
+ endpoint: string;
+ keys: {
+ p256dh: string;
+ auth: string;
+ };
+}
+
+let subscription: PushSubscription | null = null;
+const parsed = JSON.parse(JSON.stringify(subscription));
+
+export async function subscribeUser(subscription: PushSubscriptionSerialized) {
+ // In a production environment, you would want to store the subscription in a database
+ // For example: await db.subscriptions.create({ data: sub })
+ if (!subscription) return;
+
+ const cookieStore = await cookies();
+
+ const supabase = createServerClient(
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
+ process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
+ {
+ cookies: {
+ getAll() {
+ return cookieStore.getAll();
+ },
+ setAll(cookiesToSet) {
+ cookiesToSet.forEach(({ name, value, options }) => {
+ cookieStore.set(name, value, options);
+ });
+ },
+ },
+ }
+ );
+
+ const {
+ data: { user },
+ error: userError,
+ } = await supabase.auth.getUser();
+
+ if (!user || userError) {
+ console.error('์ ์ ์ ๋ณด ์กฐํ ์คํจ : ', userError);
+ return;
+ }
+
+ const { error } = await supabase.from('user_push_token').upsert(
+ {
+ user_id: user.id,
+ endpoint: subscription.endpoint,
+ p256dh: subscription.keys.p256dh,
+ auth: subscription.keys.auth,
+ },
+ { onConflict: 'user_id' }
+ );
+
+ if (error) {
+ console.error('ํธ์ํ ํฐ ์ ์ฅ ์คํจ : ', error);
+ return;
+ }
+
+ console.log('ํธ์ํ ํฐ ์์ฑ์๋ฃ');
+
+ return { success: true, message: 'ํธ์ ํ ํฐ์ด ์์ฑ๋์์ต๋๋ค.' };
+}
+
+export async function unsubscribeUser() {
+ subscription = null;
+ // In a production environment, you would want to remove the subscription from the database
+ // For example: await db.subscriptions.delete({ where: { ... } })
+ return { success: true };
+}
+
+export async function sendNotification(
+ user_id: string,
+ type: PushAlarmType,
+ subType: string,
+ data: PushAlarmData,
+ options?: { allowWithoutToken?: boolean }
+) {
+ webpush.setVapidDetails(
+ 'mailto:haruyam15@gmail.com',
+ process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
+ process.env.VAPID_PRIVATE_KEY!
+ );
+
+ const { data: pushToken, error } = await supabase
+ .from('user_push_token')
+ .select('*')
+ .eq('user_id', user_id);
+
+ const hasValidToken = !error && pushToken && pushToken.length > 0;
+
+ const customMessage = getPushAlarmMessage(type, subType, data);
+
+ if (hasValidToken) {
+ for (const token of pushToken) {
+ const subscription = {
+ endpoint: token.endpoint,
+ keys: {
+ p256dh: token.p256dh,
+ auth: token.auth,
+ },
+ };
+
+ const pushMessage = {
+ title: customMessage.title,
+ body: customMessage.body,
+ image: customMessage.image ?? '',
+ time: new Date().toISOString().slice(0, 19).replace('T', ' '),
+ url: customMessage.link,
+ };
+
+ const payloadData = JSON.stringify(pushMessage);
+
+ try {
+ await webpush.sendNotification(subscription, payloadData);
+ } catch (err: any) {
+ console.error('์๋ฆผ ์ ์ก ์คํจ:', err);
+ if (err.statusCode === 410 || err.statusCode === 404) {
+ await supabase.from('user_push_token').delete().eq('endpoint', subscription.endpoint);
+ }
+ }
+ }
+ }
+
+ const pushMessage = {
+ title: customMessage.title,
+ body: customMessage.body,
+ image: customMessage.image ?? '',
+ time: new Date().toISOString().slice(0, 19).replace('T', ' '),
+ url: customMessage.link,
+ };
+
+ const { error: insertError } = await supabase.from('alarm').insert({
+ user_id: user_id,
+ type: type,
+ title: pushMessage.title,
+ body: pushMessage.body,
+ link: pushMessage.url,
+ image_url: pushMessage.image,
+ });
+
+ if (insertError) {
+ return { success: false, error: 'Alarm DB ์ถ๊ฐ ์คํจ' };
+ }
+
+ return { success: true };
+}
diff --git a/apps/web/app/api/alarm/auction/bid/route.ts b/apps/web/app/api/alarm/auction/bid/route.ts
new file mode 100644
index 00000000..8b99993d
--- /dev/null
+++ b/apps/web/app/api/alarm/auction/bid/route.ts
@@ -0,0 +1,104 @@
+import { sendNotification } from '@/app/actions';
+import { createClient } from '@/shared/lib/supabase/server';
+import { NextRequest, NextResponse } from 'next/server';
+
+type MyCustomAuctionWithJoinType = {
+ auction_id: string;
+ product: {
+ title: string;
+ product_id: string;
+ exhibit_user_id: string;
+ product_image: { image_url: string }[];
+ };
+ bid_history: {
+ bid_user_id: string;
+ bid_price: number;
+ profiles: {
+ nickname: string;
+ };
+ }[];
+};
+
+export async function POST(req: NextRequest) {
+ const supabase = await createClient();
+ const winnigBidValue = await req.json();
+
+ try {
+ const { data: PushAlarmData, error } = await supabase
+ .from('auction')
+ .select(
+ `
+ product: (
+ title,
+ product_id,
+ exhibit_user_id,
+ product_image (
+ image_url
+ )
+ ),
+
+ bid_history!BidHistory_auction_id_fkey (
+ bid_user_id,
+ bid_price,
+ profiles (
+ nickname
+ )
+ )
+ `
+ )
+ .eq('auction_id', winnigBidValue.auction_id)
+ .returns
();
+
+ if (error || !PushAlarmData) {
+ throw new Error(`pushAlarm ์กฐํ ์คํจ: ${error?.message ?? 'Unknown error'}`);
+ }
+
+ const bid_user_id = PushAlarmData[0]?.bid_history[0]?.bid_user_id;
+ const exhibit_user_id = PushAlarmData[0]?.product?.exhibit_user_id;
+
+ // ์
์ฐฐ์ ์๋ฆผ ์ ์ก
+ const { error: AlarmError } = await sendNotification(
+ `${bid_user_id}`,
+ 'auction',
+ 'bidUpdated',
+ {
+ productName: PushAlarmData[0]?.product?.title,
+ auctionId: winnigBidValue.auction_id,
+ image: PushAlarmData[0]?.product?.product_image?.[0]?.image_url,
+ }
+ );
+
+ if (AlarmError) {
+ throw new Error(`์
์ฐฐ์ ๊ฐฑ์ ์๋ฆผ ์ ์ก ์คํจ: ${AlarmError}`);
+ }
+
+ // ์ถํ์ ์๋ฆผ ์ ์ก
+ const { error: exhibitAlarmError } = await sendNotification(
+ `${exhibit_user_id}`,
+ 'auction',
+ 'bidNotification',
+ {
+ nickname: PushAlarmData[0]?.bid_history[0]?.profiles?.nickname,
+ productName: PushAlarmData[0]?.product?.title,
+ price: PushAlarmData[0]?.bid_history[0]?.bid_price,
+ auctionId: winnigBidValue.auction_id,
+ image: PushAlarmData[0]?.product?.product_image?.[0]?.image_url,
+ }
+ );
+
+ if (exhibitAlarmError) {
+ throw new Error(`์ถํ์ ๊ฐฑ์ ์๋ฆผ ์ ์ก ์คํจ: ${exhibitAlarmError}`);
+ }
+
+ return NextResponse.json(
+ {
+ success: true,
+ message: '์๋ฆผ ์ ์ก์ด ์๋ฃ๋์์ต๋๋ค',
+ },
+ { status: 200 }
+ );
+ } catch (err) {
+ console.error('์๋ฆผ ์ ์ก ์ค๋ฅ:', err);
+ return NextResponse.json({ error: '์๋ฆผ ์ ์ก ์คํจ' }, { status: 500 });
+ }
+}
diff --git a/apps/web/app/api/alarm/auction/nobid/route.ts b/apps/web/app/api/alarm/auction/nobid/route.ts
new file mode 100644
index 00000000..b468912e
--- /dev/null
+++ b/apps/web/app/api/alarm/auction/nobid/route.ts
@@ -0,0 +1,60 @@
+import { sendNotification } from '@/app/actions';
+import { createClient } from '@/shared/lib/supabase/server';
+import { NextRequest, NextResponse } from 'next/server';
+
+type AuctionEndedLostPayload = {
+ product: {
+ title: string;
+ exhibit_user_id: string;
+ product_image: {
+ image_url: string;
+ }[];
+ };
+};
+
+export async function POST(req: NextRequest) {
+ const supabase = await createClient();
+ const winnigBIdValue = await req.json();
+
+ try {
+ const { data: PushAlarmData, error } = await supabase
+ .from('auction')
+ .select(
+ `
+ product (
+ title,
+ exhibit_user_id,
+ product_image (
+ image_url
+ )
+ )
+ `
+ )
+ .eq('auction_id', winnigBIdValue.auction_id)
+ .returns();
+
+ if (error || !PushAlarmData) {
+ throw new Error(`pushAlarm ์กฐํ ์คํจ: ${error.message}`);
+ }
+
+ const user_id = PushAlarmData[0]?.product?.exhibit_user_id;
+
+ //์ ์ฐฐ ์๋ฆผ ์ ์ก
+ const { success: result, error: pushAlarmError } = await sendNotification(
+ `${user_id}`,
+ 'auction',
+ 'auctionEndedLost',
+ {
+ productName: `${PushAlarmData?.[0]?.product?.title}`,
+ image: `${PushAlarmData[0]?.product?.product_image?.[0]?.image_url}`,
+ }
+ );
+
+ if (pushAlarmError) {
+ throw new Error(` ์ ์ฐฐ ์๋ฆผ ์ ์ก ์คํจ: ${pushAlarmError}`);
+ }
+ } catch (err) {
+ console.error('์๋ฆผ ์ ์ก ์ค๋ฅ:', err);
+ return NextResponse.json({ error: '์๋ฆผ ์ ์ก ์คํจ' }, { status: 500 });
+ }
+}
diff --git a/apps/web/app/api/alarm/auction/startBid/route.ts b/apps/web/app/api/alarm/auction/startBid/route.ts
new file mode 100644
index 00000000..fe4a2efc
--- /dev/null
+++ b/apps/web/app/api/alarm/auction/startBid/route.ts
@@ -0,0 +1,39 @@
+import { sendNotification } from '@/app/actions';
+import { createClient } from '@/shared/lib/supabase/server';
+import { NextRequest, NextResponse } from 'next/server';
+
+export async function POST(req: NextRequest) {
+ const supabase = await createClient();
+ const startBIdValue = await req.json();
+
+ try {
+ const { data: PushAlarmData, error } = await supabase
+ .from('product')
+ .select(`exhibit_user_id, title, product_image (image_url)`)
+ .eq('product_id', startBIdValue.product_id);
+
+ if (error || !PushAlarmData) {
+ throw new Error(`pushAlarm ์กฐํ ์คํจ: ${error.message}`);
+ }
+
+ const exhibit_user_id = PushAlarmData[0]?.exhibit_user_id;
+
+ const { success: result, error: pushAlarmError } = await sendNotification(
+ `${exhibit_user_id}`,
+ 'auction',
+ 'auctionStarted',
+ {
+ productName: `${PushAlarmData?.[0]?.title}`,
+ auctionId: `${startBIdValue.auction_id}`,
+ image: `${PushAlarmData?.[0]?.product_image[0]?.image_url}`,
+ }
+ );
+
+ if (pushAlarmError) {
+ throw new Error(`pushAlarm ์ ์ก ์คํจ: ${pushAlarmError}`);
+ }
+ } catch (err) {
+ console.error('์๋ฆผ ์ ์ก ์ค๋ฅ:', err);
+ return NextResponse.json({ error: '์๋ฆผ ์ ์ก ์คํจ' }, { status: 500 });
+ }
+}
diff --git a/apps/web/app/api/alarm/auction/winningBid/route.ts b/apps/web/app/api/alarm/auction/winningBid/route.ts
new file mode 100644
index 00000000..72cb0646
--- /dev/null
+++ b/apps/web/app/api/alarm/auction/winningBid/route.ts
@@ -0,0 +1,111 @@
+import { sendNotification } from '@/app/actions';
+import { createClient } from '@/shared/lib/supabase/server';
+import { NextRequest, NextResponse } from 'next/server';
+
+type AuctionWonPayload = {
+ auction_id: string;
+ product_id: string;
+ winning_bid_user_id: string | null;
+
+ product: {
+ title: string;
+ exhibit_user_id: string;
+ product_image: { image_url: string }[];
+ };
+
+ profiles: {
+ nickname: string;
+ } | null; // winning_bid_user_id๊ฐ ์์ผ๋ฉด null์ผ ์ ์์
+};
+
+type ChatRoomPayload = {
+ chatroom_id: string;
+};
+
+export async function POST(req: NextRequest) {
+ const supabase = await createClient();
+ const winnigBIdValue = await req.json();
+
+ try {
+ const { data: PushAlarmData, error } = await supabase
+ .from('auction')
+ .select(
+ `
+ auction_id,
+ product_id,
+ winning_bid_user_id,
+
+ product (
+ title,
+ exhibit_user_id,
+ product_image (
+ image_url
+ )
+ ),
+
+ profiles:winning_bid_user_id (
+ nickname
+ )
+ `
+ )
+ .eq('auction_id', winnigBIdValue.auction_id)
+ .returns();
+
+ if (error || !PushAlarmData || PushAlarmData.length === 0) {
+ throw new Error(`pushAlarm ์กฐํ ์คํจ: ${error?.message || 'No data found'}`);
+ }
+
+ const exhibit_user_Id = PushAlarmData[0]?.product?.exhibit_user_id;
+ const winning_bid_user_id = PushAlarmData[0]?.winning_bid_user_id;
+
+ const { data: chat, error: chatError } = await supabase
+ .from('chat_room')
+ .select('chatroom_id')
+ .eq('bid_user_id', winning_bid_user_id)
+ .eq('exhibit_user_id', exhibit_user_Id)
+ .returns();
+
+ if (chatError) {
+ console.error('Chat room ์กฐํ ์คํจ:', chatError);
+ }
+
+ // ์ถํ์ ์๋ฆผ ์ ์ก
+ const { success: test, error: exhibitBidError } = await sendNotification(
+ `${exhibit_user_Id}`,
+ 'auction',
+ 'auctionEndedWon',
+ {
+ productName: `${PushAlarmData?.[0]?.product?.title}`,
+ nickname: `${PushAlarmData?.[0]?.profiles?.nickname}`,
+ chatroomId: `${chat?.[0]?.chatroom_id}`,
+ image: `${PushAlarmData?.[0]?.product?.product_image?.[0]?.image_url}`,
+ }
+ );
+
+ if (exhibitBidError) {
+ throw new Error(`๋์ฐฐ ์๋ฆผ ์ ์ก ์คํจ: ${exhibitBidError}`);
+ }
+
+ // ๋์ฐฐ์ ์๋ฆผ ์ ์ก
+ const { error: winningBIdError } = await sendNotification(
+ `${winning_bid_user_id}`,
+ 'auction',
+ 'auctionWon',
+ {
+ productName: `${PushAlarmData?.[0]?.product?.title}`,
+ nickname: `${PushAlarmData?.[0]?.profiles?.nickname}`,
+ chatroomId: `${chat?.[0]?.chatroom_id}`,
+ image: `${PushAlarmData?.[0]?.product?.product_image?.[0]?.image_url}`,
+ }
+ );
+
+ if (winningBIdError) {
+ throw new Error(`๋์ฐฐ ์๋ฆผ ์ ์ก ์คํจ: ${winningBIdError}`);
+ }
+
+ return NextResponse.json({ success: true, message: '์๋ฆผ ์ ์ก ์๋ฃ' });
+ } catch (err) {
+ console.error('์๋ฆผ ์ ์ก ์ค๋ฅ:', err);
+ return NextResponse.json({ error: '์๋ฆผ ์ ์ก ์คํจ' }, { status: 500 });
+ }
+}
diff --git a/apps/web/app/api/alarm/chat/route.ts b/apps/web/app/api/alarm/chat/route.ts
new file mode 100644
index 00000000..3d1c7176
--- /dev/null
+++ b/apps/web/app/api/alarm/chat/route.ts
@@ -0,0 +1,98 @@
+import { sendNotification } from '@/app/actions';
+import { createClient } from '@/shared/lib/supabase/server';
+import { NextRequest, NextResponse } from 'next/server';
+
+type ChatRoomPayload = {
+ bid_user_id: string;
+ exhibit_user_id: string;
+ auction?: {
+ product?: {
+ title?: string;
+ product_image?: {
+ image_url: string;
+ order_index: number;
+ }[];
+ };
+ };
+};
+
+type ProfilePayload = {
+ nickname: string;
+};
+
+export async function POST(req: NextRequest) {
+ const supabase = await createClient();
+
+ try {
+ const chatValue = await req.json();
+
+ const { data: chatDataList, error: chatError } = await supabase
+ .from('chat_room')
+ .select(
+ `
+ bid_user_id,
+ exhibit_user_id,
+ auction (
+ product (
+ title,
+ product_image (
+ image_url,
+ order_index
+ )
+ )
+ )
+ `
+ )
+ .eq('chatroom_id', chatValue.chatroom_id)
+ .returns();
+
+ if (chatError || !chatDataList || chatDataList.length === 0) {
+ throw new Error(`chat_room ์กฐํ ์คํจ: ${chatError?.message}`);
+ }
+
+ const chatData = chatDataList[0];
+ if (!chatData) {
+ throw new Error('chat_room ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค.');
+ }
+
+ const receiver_id =
+ chatValue.sender_id === chatData.bid_user_id
+ ? chatData.exhibit_user_id
+ : chatData.bid_user_id;
+
+ const { data: senderProfile, error: senderError } = await supabase
+ .from('profiles')
+ .select('nickname')
+ .eq('user_id', chatValue.sender_id)
+ .single();
+
+ if (senderError || !senderProfile) {
+ console.error('sender ํ๋กํ ์กฐํ ์คํจ:', senderError);
+ throw new Error(`sender ํ๋กํ ์กฐํ ์คํจ: ${senderError?.message}`);
+ }
+
+ const image = chatData.auction?.product?.product_image?.[0]?.image_url ?? '';
+
+ const { error: alarmError } = await sendNotification(`${receiver_id}`, 'chat', 'newMessage', {
+ nickname: senderProfile.nickname,
+ chatroomId: chatValue.chatroom_id,
+ image,
+ });
+
+ if (alarmError) {
+ console.error('์๋ฆผ ์ ์ก ์คํจ:', alarmError);
+ throw new Error(`์๋ฆผ ์ ์ก ์คํจ: ${String(alarmError)}`);
+ }
+
+ return NextResponse.json(
+ {
+ success: true,
+ message: '์๋ฆผ ์ ์ก์ด ์๋ฃ๋์์ต๋๋ค',
+ },
+ { status: 200 }
+ );
+ } catch (err) {
+ console.error('์๋ฆผ ์ ์ก ์ค๋ฅ:', err instanceof Error ? err.message : err);
+ return NextResponse.json({ error: '์๋ฆผ ์ ์ก ์คํจ' }, { status: 500 });
+ }
+}
diff --git a/apps/web/app/api/alarm/nav/useAlarmCount.ts b/apps/web/app/api/alarm/nav/useAlarmCount.ts
new file mode 100644
index 00000000..88c6060a
--- /dev/null
+++ b/apps/web/app/api/alarm/nav/useAlarmCount.ts
@@ -0,0 +1,74 @@
+import { supabase } from '@/shared/lib/supabaseClient';
+import { useAuthStore } from '@/shared/model/authStore';
+import { useEffect, useState } from 'react';
+
+export const useAlarmCount = () => {
+ const [unreadAlarmCount, setUnreadAlarmCount] = useState(0);
+ const userId = useAuthStore((state) => state.user?.id) as string;
+
+ useEffect(() => {
+ if (!userId) return;
+
+ // 1. ์ด๊ธฐ ์ฝ์ง ์์ ๋ฉ์ธ์ง ์ ๊ฐ์ ธ์ค๊ธฐ
+ const fetchInitialUnreadAlarms = async () => {
+ const { count, error } = await supabase
+ .from('alarm')
+ .select('alarm_id', { count: 'exact' })
+ .eq('is_read', false)
+ .eq('user_id', userId);
+
+ if (error) {
+ throw new Error('์ฝ์ง ์์ ์๋ฆผ ์ ์ด๊ธฐ ์กฐํ ์คํจ', error);
+ }
+ setUnreadAlarmCount(count || 0);
+ };
+
+ fetchInitialUnreadAlarms();
+
+ // 2. realtime ๊ตฌ๋
์ค์
+ const channel = supabase.channel('unread_alarms');
+
+ channel.on(
+ 'postgres_changes',
+ {
+ event: 'INSERT',
+ schema: 'public',
+ table: 'alarm',
+ },
+ (payload) => {
+ const newAlarm = payload.new;
+ if (newAlarm.is_read === false) {
+ setUnreadAlarmCount((prevCount) => prevCount + 1);
+ }
+ }
+ );
+
+ // UPDATE ๊ฐ์ง โ is_read: false โ true๋ก ๋ฐ๋ ๊ฒฝ์ฐ์๋ง ๊ฐ์
+ channel.on(
+ 'postgres_changes',
+ {
+ event: 'UPDATE',
+ schema: 'public',
+ table: 'alarm',
+ },
+ (payload) => {
+ const oldMessage = payload.old;
+ const newMessage = payload.new;
+
+ const wasUnread = oldMessage.is_read === false;
+ const nowRead = newMessage.is_read === true;
+
+ // ๋ด๊ฐ ๋ฐ์ ๋ฉ์์ง๋ฉด์ ์ฝ์ ์ฒ๋ฆฌ๋ ๊ฒฝ์ฐ๋ง ์ฒ๋ฆฌ
+ if (wasUnread && nowRead) {
+ setUnreadAlarmCount((prev) => Math.max(prev - 1, 0));
+ }
+ }
+ );
+ channel.subscribe();
+
+ return () => {
+ supabase.removeChannel(channel);
+ };
+ }, [userId]);
+ return unreadAlarmCount;
+};
diff --git a/apps/web/app/api/alarm/point/route.ts b/apps/web/app/api/alarm/point/route.ts
new file mode 100644
index 00000000..51755177
--- /dev/null
+++ b/apps/web/app/api/alarm/point/route.ts
@@ -0,0 +1,54 @@
+import { sendNotification } from '@/app/actions';
+import { getPointValue } from '@/features/point/lib/utils';
+import { supabase } from '@/shared/lib/supabaseClient';
+import { NextRequest, NextResponse } from 'next/server';
+
+export async function POST(req: NextRequest) {
+ const pointValue = await req.json();
+
+ try {
+ let point;
+ let user_id;
+
+ if (pointValue.type === 'accepted') {
+ point = getPointValue(pointValue.reason);
+ user_id = pointValue.user_id;
+ } else if (pointValue.type === 'pending') {
+ point = getPointValue(pointValue.reason, { bidAmount: pointValue.price });
+ user_id = pointValue.user_id;
+ } else {
+ point = getPointValue(pointValue.reason, { bidAmount: pointValue.price });
+ }
+
+ if (pointValue.type === 'signup') {
+ const { data, error } = await supabase
+ .from('profiles')
+ .select('user_id')
+ .eq('email', pointValue.user_id)
+ .single();
+
+ if (error) {
+ console.error('์ฌ์ฉ์ ์กฐํ ์ค๋ฅ:', error);
+ return NextResponse.json({ error: '์ฌ์ฉ์๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค' }, { status: 404 });
+ }
+
+ user_id = data.user_id;
+ }
+
+ //ํฌ์ธํธ ์๋ฆผ ์ ์ก
+ const { error: exhibitAlarmError } = await sendNotification(
+ user_id,
+ 'point',
+ 'pointAdded',
+ { amount: point },
+ pointValue.type === 'signup' ? { allowWithoutToken: true } : undefined
+ );
+
+ if (exhibitAlarmError) {
+ throw new Error(` ์ถํ์ ํฌ์ธํธ ์๋ฆผ ์ ์ก ์คํจ: ${exhibitAlarmError}`);
+ }
+ } catch (err) {
+ console.error('์๋ฆผ ์ ์ก ์ค๋ฅ:', err);
+ return NextResponse.json({ error: '์๋ฆผ ์ ์ก ์คํจ' }, { status: 500 });
+ }
+}
diff --git a/apps/web/app/api/alarm/proposal/proposal-accepted/route.ts b/apps/web/app/api/alarm/proposal/proposal-accepted/route.ts
new file mode 100644
index 00000000..28927f8d
--- /dev/null
+++ b/apps/web/app/api/alarm/proposal/proposal-accepted/route.ts
@@ -0,0 +1,120 @@
+import { sendNotification } from '@/app/actions';
+import { createClient } from '@/shared/lib/supabase/server';
+import { NextRequest, NextResponse } from 'next/server';
+
+type ProposalWithProduct = {
+ proposer_id: string;
+ proposed_price: number;
+ auction_id: string;
+ auction: {
+ product: {
+ title: string;
+ exhibit_user_id: string;
+ product_image: {
+ image_url: string;
+ order_index: number;
+ }[];
+ };
+ };
+};
+
+export async function POST(req: NextRequest) {
+ const supabase = await createClient();
+ const proposalValue = await req.json();
+
+ try {
+ const { data: proposalData, error: proposalError } = await supabase
+ .from('proposal')
+ .select(
+ `s
+ proposer_id,
+ proposed_price,
+ auction_id,
+ auction (
+ product (
+ title,
+ exhibit_user_id,
+ product_image (
+ image_url,
+ order_index
+ )
+ )
+ )
+ `
+ )
+ .eq('proposal_id', proposalValue.proposalId)
+ .single();
+
+ if (proposalError || !proposalData) {
+ console.error('์ ์ ๋ฐ์ดํฐ ์กฐํ ์คํจ:', proposalError);
+ return NextResponse.json(
+ {
+ error: '์ ์ ์ ๋ณด๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค.',
+ },
+ { status: 404 }
+ );
+ }
+
+ const proposerId = proposalData.proposer_id;
+ const productInfo = proposalData.auction.product;
+
+ const { data: sellerProfile, error: sellerError } = await supabase
+ .from('profiles')
+ .select('nickname')
+ .eq('user_id', proposalValue.user_id)
+ .single();
+
+ if (sellerError || !sellerProfile) {
+ console.error('ํ๋งค์ ํ๋กํ ์กฐํ ์คํจ:', sellerError);
+ return NextResponse.json(
+ {
+ error: '์ฌ์ฉ์ ์ ๋ณด๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค.',
+ },
+ { status: 404 }
+ );
+ }
+
+ const sellerNickname = sellerProfile.nickname;
+
+ const sortedImages = productInfo?.product_image?.sort((a, b) => a.order_index - b.order_index);
+ const firstImageUrl = sortedImages?.[0]?.image_url ?? '';
+
+ const { data: chat, error: chatError } = await supabase
+ .from('chat_room')
+ .select('chatroom_id')
+ .eq('bid_user_id', proposalData.proposer_id)
+ .eq('exhibit_user_id', proposalData.auction.product.exhibit_user_id);
+
+ if (chatError) {
+ console.error('chat_room ์กฐํ ์คํจ:', chatError);
+ }
+
+ const payload = {
+ nickname: sellerNickname,
+ productName: productInfo?.title,
+ image: firstImageUrl,
+ price: proposalData.proposed_price,
+ chatroomId: chat?.[0]?.chatroom_id ?? null,
+ };
+
+ const { error: notificationError } = await sendNotification(
+ proposerId,
+ 'auction',
+ 'proposalAccepted',
+ payload
+ );
+ if (notificationError) {
+ throw new Error(`์๋ฆผ ์ ์ก ์คํจ: ${notificationError}`);
+ }
+
+ return NextResponse.json({ success: true });
+ } catch (err) {
+ console.error('์ ์ ์๋ฝ ์๋ฆผ ์ ์ก ์ค๋ฅ:', err);
+ return NextResponse.json(
+ {
+ error: err instanceof Error ? err.message : '์๋ฆผ ์ ์ก ์คํจ',
+ },
+ { status: 500 }
+ );
+ }
+}
diff --git a/apps/web/app/api/alarm/proposal/proposal-pending/route.ts b/apps/web/app/api/alarm/proposal/proposal-pending/route.ts
new file mode 100644
index 00000000..9dacb8a7
--- /dev/null
+++ b/apps/web/app/api/alarm/proposal/proposal-pending/route.ts
@@ -0,0 +1,91 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { sendNotification } from '@/app/actions';
+import { createClient } from '@/shared/lib/supabase/server';
+
+type PendingWithProduct = {
+ proposer_id: string;
+ proposed_price: number;
+ auction_id: string;
+ auction: {
+ product: {
+ title: string;
+ exhibit_user_id: string;
+ product_image: {
+ image_url: string;
+ order_index: number;
+ }[];
+ };
+ };
+};
+
+type ProductImage = {
+ image_url: string;
+};
+
+type Product = {
+ title: string;
+ exhibit_user_id: string;
+ product_image: ProductImage[];
+};
+
+type AuctionWithProduct = {
+ product: Product;
+};
+
+type ProfileNicknameOnly = {
+ nickname: string;
+};
+
+export async function POST(req: NextRequest) {
+ const supabase = await createClient();
+ const proposalValue = await req.json();
+
+ try {
+ // ๋๋ค์ ์กฐํ(์ ์์)
+ const { data: profileData, error: profileError } = await supabase
+ .from('profiles')
+ .select('nickname')
+ .eq('user_id', proposalValue.user_id)
+ .returns();
+
+ const nickname = profileData?.[0]?.nickname;
+
+ // ์ํ ์ ๋ณด ์กฐํ
+ const { data: auctionData, error: auctionError } = await supabase
+ .from('auction')
+ .select(
+ `
+ product (
+ title,
+ exhibit_user_id,
+ product_image (
+ image_url
+ )
+ )
+ `
+ )
+ .eq('auction_id', proposalValue.auctionId)
+ .single();
+
+ const productInfo = auctionData?.product;
+
+ const payload = {
+ nickname: nickname,
+ productName: productInfo?.title,
+ image: productInfo?.product_image?.[0]?.image_url,
+ price: proposalValue.price,
+ };
+
+ await sendNotification(
+ `${productInfo?.exhibit_user_id}`,
+ 'auction',
+ 'proposalRequest',
+ payload
+ );
+
+ return NextResponse.json({ success: true });
+ } catch (err) {
+ console.error('์๋ฆผ ์ ์ก ์ค๋ฅ:', err);
+ return NextResponse.json({ error: '์๋ฆผ ์ ์ก ์คํจ' }, { status: 500 });
+ }
+}
diff --git a/apps/web/app/api/auction-map/route.ts b/apps/web/app/api/auction-map/route.ts
new file mode 100644
index 00000000..f8602665
--- /dev/null
+++ b/apps/web/app/api/auction-map/route.ts
@@ -0,0 +1,57 @@
+import { supabase } from '@/shared/lib/supabaseClient';
+import { getDistanceKm } from '@/features/product/lib/utils';
+import { NextResponse } from 'next/server';
+import { MapAuction } from '@/entities/auction/model/types';
+import getUserId from '@/shared/lib/getUserId';
+
+export async function GET() {
+ const userId = await getUserId();
+
+ const { data: userData, error: userError } = await supabase
+ .from('profiles')
+ .select('latitude, longitude')
+ .eq('user_id', userId)
+ .single();
+
+ if (userError || !userData?.latitude || !userData?.longitude) {
+ return NextResponse.json(
+ { error: '์ ์ ์์น ์ ๋ณด๊ฐ ์์ต๋๋ค.', code: 'NO_USER_LOCATION' },
+ { status: 400 }
+ );
+ }
+
+ const lat = userData.latitude;
+ const lng = userData.longitude;
+
+ const { data, error } = await supabase.from('auction').select(`
+ auction_id,
+ product:product_id (
+ latitude,
+ longitude,
+ product_image (
+ image_url,
+ order_index
+ )
+ )
+ `);
+
+ if (error) {
+ return NextResponse.json({ error: error.message }, { status: 500 });
+ }
+
+ const markers = (data as unknown as MapAuction[])
+ .filter((item) => {
+ const { product } = item;
+ const distance = getDistanceKm(lat, lng, product.latitude, product.longitude);
+ return distance <= 5;
+ })
+ .map((item) => ({
+ id: item.auction_id,
+ location: { lat: item.product.latitude, lng: item.product.longitude },
+ thumbnail:
+ item.product.product_image?.find((img) => img.order_index === 0)?.image_url ??
+ '/default.png',
+ }));
+
+ return NextResponse.json(markers);
+}
diff --git a/apps/web/app/api/auction/[shortId]/proposal/route.ts b/apps/web/app/api/auction/[shortId]/proposal/route.ts
new file mode 100644
index 00000000..f92f7363
--- /dev/null
+++ b/apps/web/app/api/auction/[shortId]/proposal/route.ts
@@ -0,0 +1,41 @@
+import { v4 as uuidv4 } from 'uuid';
+import { NextRequest, NextResponse } from 'next/server';
+import { supabase } from '@/shared/lib/supabaseClient';
+import { createPointByReason } from '@/features/point/api/createPointByReason';
+
+export async function POST(request: NextRequest) {
+ try {
+ const formData = await request.formData();
+ const userId = formData.get('userId') as string;
+ const auctionId = formData.get('auctionId') as string;
+ const proposedPrice = parseInt(formData.get('proposedPrice') as string, 10);
+
+ if (!userId || !auctionId || isNaN(proposedPrice)) {
+ return NextResponse.json({ error: '์
๋ ฅ๊ฐ์ด ์ ํจํ์ง ์์ต๋๋ค.' }, { status: 400 });
+ }
+
+ // ์ ์ ๋ฑ๋ก
+ const { error: proposalError } = await supabase.from('proposal').insert({
+ proposal_id: uuidv4(),
+ auction_id: auctionId,
+ proposer_id: userId,
+ proposed_price: proposedPrice,
+ proposal_status: 'pending',
+ });
+
+ if (proposalError) {
+ throw new Error(`์ ์ ๋ณด๋ด๊ธฐ ์คํจ: ${proposalError.message}`);
+ }
+
+ try {
+ await createPointByReason('bid_propose', userId);
+ } catch (error) {
+ console.error('์ ์ ํฌ์ธํธ ์ฌ์ฉ ์คํจ:', error);
+ }
+
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ console.error('์ ์ ๋ณด๋ด๊ธฐ ์๋ฌ:', error);
+ return NextResponse.json({ error: '์๋ฒ ์ค๋ฅ' }, { status: 500 });
+ }
+}
diff --git a/apps/web/app/api/auction/[shortId]/proposal/target-product/route.ts b/apps/web/app/api/auction/[shortId]/proposal/target-product/route.ts
new file mode 100644
index 00000000..22de2337
--- /dev/null
+++ b/apps/web/app/api/auction/[shortId]/proposal/target-product/route.ts
@@ -0,0 +1,49 @@
+import getUserId from '@/shared/lib/getUserId';
+import { decodeShortId } from '@/shared/lib/shortUuid';
+import { supabase } from '@/shared/lib/supabaseClient';
+import { NextRequest, NextResponse } from 'next/server';
+import shortUUID from 'short-uuid';
+
+const translator = shortUUID();
+
+export async function GET(
+ request: NextRequest,
+ { params }: { params: Promise<{ shortId: string }> }
+) {
+ try {
+ const userId = await getUserId();
+
+ if (!userId) {
+ throw new Error('์ ์ ์ ๋ณด๊ฐ ๋ถ์กฑํฉ๋๋ค.');
+ }
+
+ const { shortId } = await params;
+ const auctionId = decodeShortId(shortId);
+
+ const { data, error } = await supabase
+ .from('auction')
+ .select(
+ `
+ auction_id,
+ product_id,
+ min_price,
+ product:product_id(
+ *,
+ product_image:product_image!product_image_product_id_fkey(*)
+ ),
+ bid_history!auction_id(bid_price)
+ `
+ )
+ .eq('auction_id', auctionId)
+ .single();
+
+ if (error || !data) {
+ throw new Error(`์ํ ๋ถ๋ฌ์ค๊ธฐ ์คํจ : ${error.message}`);
+ }
+
+ return NextResponse.json({ success: true, data: data });
+ } catch (err) {
+ console.error(`target-product ์ฒ๋ฆฌ ์คํจ`, err);
+ return NextResponse.json({ success: false, message: (err as Error).message }, { status: 500 });
+ }
+}
diff --git a/apps/web/app/api/auction/[shortId]/route.ts b/apps/web/app/api/auction/[shortId]/route.ts
index 171132a6..c38bad71 100644
--- a/apps/web/app/api/auction/[shortId]/route.ts
+++ b/apps/web/app/api/auction/[shortId]/route.ts
@@ -1,76 +1,78 @@
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, AuctionForBid } from '@/entities/auction/model/types';
export async function GET(_req: Request, { params }: { params: Promise<{ shortId: string }> }) {
const resolvedParams = await params;
const id = decodeShortId(resolvedParams.shortId);
try {
- // 1. ๊ฒฝ๋งค ์ ๋ณด ์กฐํ
- const { data: auctionData, error: auctionError } = await supabase
- .from('auction')
- .select(
- `
- *,
- product (
+ // ๋ณ๋ ฌ๋ก ๋ฐ์ดํฐ ์กฐํ
+ const [auctionResult, bidHistoryResult, currentHighestBidResult] = await Promise.all([
+ supabase
+ .from('auction')
+ .select(
+ `
*,
- exhibit_user:exhibit_user_id (
- *
- ),
- product_image (
- *
+ product (
+ *,
+ exhibit_user:exhibit_user_id (*),
+ product_image (*)
)
+ `
)
- `
- )
- .eq('auction_id', id)
- .single();
+ .eq('auction_id', id)
+ .single(),
+
+ supabase
+ .from('bid_history')
+ .select(
+ `
+ *,
+ bid_user_nickname:bid_user_id (nickname)
+ `
+ )
+ .eq('auction_id', id)
+ .order('bid_price', { ascending: false })
+ .limit(5),
+
+ supabase.rpc('get_current_highest_bid', { auction_uuid: id }),
+ ]);
+
+ const { data: auctionData, error: auctionError } = auctionResult;
+ const { data: bidHistory, error: bidError } = bidHistoryResult;
+ const { data: currentHighestBid, error: bidPriceError } = currentHighestBidResult;
if (auctionError || !auctionData) {
return NextResponse.json({ error: '๊ฒฝ๋งค ์กฐํ ์คํจ' }, { status: 500 });
}
- // 2. ์
์ฐฐ ๋ด์ญ ์กฐํ
- const { data: bidHistory, error: bidError } = await supabase
- .from('bid_history')
- .select('bid_id, bid_price, bid_user_id, bid_at')
- .eq('auction_id', id)
- .order('bid_price', { ascending: false });
-
- if (bidError) {
- return NextResponse.json({ error: '์
์ฐฐ ๋ด์ญ ์กฐํ ์คํจ' }, { status: 500 });
+ if (bidError || bidPriceError) {
+ return NextResponse.json({ error: '์
์ฐฐ ์ ๋ณด ์กฐํ ์คํจ' }, { status: 500 });
}
- // 3. ์
์ฐฐ ํ์คํ ๋ฆฌ๊ฐ ์์ด๋ ๊ด์ฐฎ์ (๋น ๋ฐฐ์ด๋ก ์ฒ๋ฆฌ)
- const sortedBidHistory = bidHistory || [];
+ const productData = auctionData.product;
+ const userData = productData?.exhibit_user;
- // 4. ํ์ฌ ์ต๊ณ ์
์ฐฐ๊ฐ ๊ณ์ฐ
- const currentHighestBid =
- sortedBidHistory.length > 0 ? sortedBidHistory[0]?.bid_price : auctionData.min_price;
+ const fallbackBidHistory = bidHistory || [];
+ const safeBidHistory = auctionData.is_secret ? [] : fallbackBidHistory;
+ const safeCurrentHighestBid = auctionData.is_secret
+ ? null
+ : currentHighestBid || auctionData.min_price;
- // 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,
- };
+ bid_cnt: fallbackBidHistory.length,
+ bid_history: safeBidHistory,
+ current_highest_bid: safeCurrentHighestBid,
+ } as AuctionDetail;
return NextResponse.json(response);
} catch (error) {
@@ -79,10 +81,11 @@ export async function GET(_req: Request, { params }: { params: Promise<{ shortId
}
}
-export async function POST(req: NextRequest, { params }: { params: Promise<{ shortId: string }> }) {
+export async function POST(req: NextRequest) {
try {
- const resolvedParams = await params;
- const auctionId = decodeShortId(resolvedParams.shortId);
+ const url = new URL(req.url);
+ const shortId = url.pathname.split('/').pop();
+ const auctionId = decodeShortId(shortId!);
// ์์ฒญ ๋ณธ๋ฌธ์์ ์
์ฐฐ ๋ฐ์ดํฐ ์ถ์ถ
const { bidPrice, userId } = await req.json();
@@ -102,10 +105,10 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ sho
return NextResponse.json({ error: '์ฌ๋ฐ๋ฅธ ์
์ฐฐ๊ฐ๋ฅผ ์
๋ ฅํด์ฃผ์ธ์.' }, { status: 400 });
}
- // 2. ๊ฒฝ๋งค ์ ๋ณด ์กฐํ (๋ง๊ฐ ์๊ฐ ํ์ธ์ฉ)
+ // 2. ๊ฒฝ๋งค ์ ๋ณด ์กฐํ
const { data: auctionData, error: auctionError } = await supabase
.from('auction')
- .select('auction_end_at, auction_status, min_price')
+ .select('auction_end_at, auction_status, min_price, product:product_id(title), is_secret')
.eq('auction_id', auctionId)
.single();
@@ -139,14 +142,17 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ sho
? currentHighestBid.bid_price
: auctionData.min_price;
- if (bidPrice <= minRequiredBid) {
- return NextResponse.json(
- {
- error: `์ต์ ์
์ฐฐ๊ฐ๋ ${minRequiredBid.toLocaleString()}์ ์ด๊ณผ์
๋๋ค.`,
- minRequiredBid,
- },
- { status: 400 }
- );
+ if (!auctionData.is_secret) {
+ //์ํฌ๋ฆฟ๊ฒฝ๋งค๊ฐ ์๋๋๋ง ์ฒดํฌ
+ if (bidPrice <= minRequiredBid) {
+ return NextResponse.json(
+ {
+ error: `์ต์ ์
์ฐฐ๊ฐ๋ ${minRequiredBid.toLocaleString()}์ ์ด๊ณผ์
๋๋ค.`,
+ minRequiredBid,
+ },
+ { status: 400 }
+ );
+ }
}
// 6. ์
์ฐฐ ๋ฐ์ดํฐ ์ฝ์
@@ -167,6 +173,24 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ sho
return NextResponse.json({ error: '์
์ฐฐ ์ฒ๋ฆฌ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค.' }, { status: 500 });
}
+ const { origin } = new URL(req.url);
+
+ if (!auctionData.is_secret) {
+ // ํธ์ ์๋ ์ ์ก(ํ๋งค์, ์
์ฐฐ์)
+ await fetch(`${origin}/api/alarm/auction/bid`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ auction_id: auctionId,
+ }),
+ });
+ }
+
+ const auctionTyped = auctionData as unknown as AuctionForBid;
+ const productTitle = auctionTyped.product.title;
+
// 8. ์ฑ๊ณต ์๋ต
return NextResponse.json({
success: true,
@@ -175,6 +199,8 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ sho
bid_id: bidData.bid_id,
bid_price: bidData.bid_price,
bid_at: bidData.bid_at,
+ product_title: productTitle,
+ bid_end_at: auctionData.auction_end_at,
},
});
} catch (error) {
diff --git a/apps/web/app/api/auction/bids/route.ts b/apps/web/app/api/auction/bids/route.ts
index 919fc11b..fb9bbf36 100644
--- a/apps/web/app/api/auction/bids/route.ts
+++ b/apps/web/app/api/auction/bids/route.ts
@@ -1,37 +1,107 @@
+import { SECRET_PRICE } from '@/features/auction/list/constants';
+import { SecretBidPrice } from '@/features/auction/list/types';
+import { AUCTION_STATUS } from '@/shared/consts/auctionStatus';
import { supabase } from '@/shared/lib/supabaseClient';
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const userId = searchParams.get('userId');
+ const filter = searchParams.get('filter') ?? 'all';
if (!userId) {
- return NextResponse.json({ success: false, message: 'userId is required' }, { status: 400 });
+ return NextResponse.json(
+ { success: false, message: '๋ก๊ทธ์ธ ํ ์๋น์ค ์ด์ฉ์ด ๊ฐ๋ฅํฉ๋๋ค' },
+ { status: 400 }
+ );
}
const { data, error } = await supabase
.from('bid_history')
.select(
`
- *,
- auction:auction_id(
- *,
- product:product_id(
*,
- product_image:product_image!product_image_product_id_fkey(*)
- )
- )
- `
+ auction:auction_id(
+ *,
+ product:product_id(
+ *,
+ product_image:product_image!product_image_product_id_fkey(*)
+ )
+ )
+ `
)
- .eq('bid_user_id', userId);
+ .eq('bid_user_id', userId)
+ .order('bid_at', { ascending: false });
if (error || !data) {
- console.error('์ถํ ๋ฐ์ดํฐ ๋ก๋ฉ ์คํจ:', error);
return NextResponse.json(
{ success: false, message: '๋ฐ์ดํฐ ๋ก๋ฉ ์คํจ', error },
{ status: 500 }
);
}
- return NextResponse.json({ success: true, data });
+ const uniqueByAuction = new Map();
+ for (const bid of data) {
+ if (!uniqueByAuction.has(bid.auction_id)) {
+ uniqueByAuction.set(bid.auction_id, bid);
+ }
+ }
+
+ const deduplicated = Array.from(uniqueByAuction.values());
+ const auctionIds = deduplicated.map((item) => item.auction_id);
+
+ const { data: stats, error: statsError } = await supabase
+ .from('bid_history')
+ .select('auction_id, bid_price')
+ .in('auction_id', auctionIds);
+
+ if (statsError) {
+ return NextResponse.json(
+ { success: false, message: '์
์ฐฐ ๋ฐ์ดํฐ ๋ก๋ฉ ์คํจ', error: statsError },
+ { status: 500 }
+ );
+ }
+
+ const bidCountMap: Record = {};
+ const maxPriceMap: Record = {};
+
+ for (const item of stats ?? []) {
+ const auctionId = item.auction_id;
+ const price = Number(item.bid_price);
+ bidCountMap[auctionId] = (bidCountMap[auctionId] ?? 0) + 1;
+ maxPriceMap[auctionId] = Math.max(maxPriceMap[auctionId] ?? 0, price);
+ }
+
+ const enriched = deduplicated.map((item) => {
+ const is_secret = item.auction?.is_secret;
+ const maxPrice = is_secret
+ ? SECRET_PRICE
+ : (maxPriceMap[item.auction_id] ?? item.auction?.min_price ?? 0);
+ return {
+ ...item,
+ bidCount: bidCountMap[item.auction_id] ?? 0,
+ is_secret,
+ maxPrice,
+ };
+ });
+ // ํํฐ๋ง ์กฐ๊ฑด ์ ์ฉ
+ const filtered = enriched.filter((item) => {
+ const { auction, is_awarded } = item;
+ const { product, auction_status } = auction;
+
+ if (!product?.latitude || !product?.longitude) return false;
+
+ switch (filter) {
+ case 'progress':
+ return auction_status === AUCTION_STATUS.IN_PROGRESS;
+ case 'win':
+ return auction_status === AUCTION_STATUS.ENDED && is_awarded === true;
+ case 'fail':
+ return auction_status === AUCTION_STATUS.ENDED && is_awarded === false;
+ default:
+ return true;
+ }
+ });
+
+ return NextResponse.json({ success: true, data: filtered });
}
diff --git a/apps/web/app/api/auction/list/route.ts b/apps/web/app/api/auction/list/route.ts
new file mode 100644
index 00000000..18525fa7
--- /dev/null
+++ b/apps/web/app/api/auction/list/route.ts
@@ -0,0 +1,142 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { supabase } from '@/shared/lib/supabaseClient';
+import { searcher } from '@/features/search/lib/utils';
+import { getDistanceKm } from '@/features/product/lib/utils';
+import { AuctionList } from '@/entities/auction/model/types';
+import { AuctionListResponse } from '@/features/auction/list/types';
+import getUserId from '@/shared/lib/getUserId';
+import { SECRET_PRICE } from '@/features/auction/list/constants';
+
+interface ErrorResponse {
+ error: string;
+ code?: string;
+}
+
+export async function GET(
+ req: NextRequest
+): Promise> {
+ const { searchParams } = req.nextUrl;
+ const search = searchParams.get('search')!;
+ const cate = searchParams.get('cate');
+ const sort = searchParams.get('sort');
+ const filters = searchParams.getAll('filter');
+ const hasDeadlineToday = filters.includes('deadline-today');
+ const hasExcludeEnded = filters.includes('exclude-ended');
+ const userId = await getUserId();
+ if (!userId || userId === 'undefined') {
+ return NextResponse.json(
+ { error: '๋ก๊ทธ์ธ์ด ํ์ํฉ๋๋ค.', code: 'NO_USER_ID' },
+ { status: 401 }
+ );
+ }
+
+ const { data: userData, error: userError } = await supabase
+ .from('profiles')
+ .select('latitude, longitude')
+ .eq('user_id', userId)
+ .single();
+
+ if (!userData?.latitude || !userData?.longitude) {
+ return NextResponse.json(
+ { error: '์ ์ ์์น ์ ๋ณด๊ฐ ์์ต๋๋ค.', code: 'NO_USER_LOCATION' },
+ { status: 400 }
+ );
+ }
+
+ if (userError) {
+ return NextResponse.json(
+ { error: '์ ์ ์ ๋ณด ์กฐํ ์คํจ', code: 'USER_FETCH_FAIL' },
+ { status: 500 }
+ );
+ }
+
+ const lat = userData.latitude;
+ const lng = userData.longitude;
+
+ const { data: auctionData, error } = await supabase.from('auction').select(`
+ auction_id,
+ product_id,
+ auction_status,
+ min_price,
+ auction_end_at,
+ product:product_id (
+ title,
+ category,
+ latitude,
+ longitude,
+ exhibit_user_id,
+ product_image (
+ image_url,
+ order_index
+ ),
+ address
+ ),
+ bid_history!auction_id (
+ bid_price
+ ),
+ created_at,
+ is_secret
+`);
+
+ if (error) {
+ return NextResponse.json({ error: error.message }, { status: 500 });
+ }
+
+ const filtered = (auctionData as unknown as AuctionList[])
+ .filter((item) => {
+ const { product, auction_status, auction_end_at } = item;
+ const distance = getDistanceKm(lat, lng, product.latitude, product.longitude);
+ const within5km = distance <= 5;
+ const matchSearch = !search || searcher(product.title, search);
+ const matchCate = cate === 'all' || product.category === cate;
+
+ const now = new Date();
+ const isEnded = auction_status === '๊ฒฝ๋งค ์ข
๋ฃ';
+ const isDeadlineToday = new Date(auction_end_at).toDateString() === now.toDateString();
+ const isWaiting = auction_status === '๊ฒฝ๋งค ๋๊ธฐ';
+
+ const filterDeadline = !hasDeadlineToday || (hasDeadlineToday && isDeadlineToday);
+ const filterExcludeEnded = !hasExcludeEnded || (hasExcludeEnded && !isEnded);
+
+ return (
+ !isWaiting && within5km && matchSearch && matchCate && filterDeadline && filterExcludeEnded
+ );
+ })
+ .map((item) => {
+ const bidPrices = item.bid_history?.map((b) => b.bid_price) ?? [];
+ const highestBid = bidPrices.length > 0 ? Math.max(...bidPrices) : null;
+ const safeBidPrice = item.is_secret ? SECRET_PRICE : (highestBid ?? item.min_price);
+ return {
+ id: item.auction_id,
+ thumbnail:
+ item.product.product_image?.find((img) => img.order_index === 0)?.image_url ??
+ '/default.png',
+ title: item.product.title,
+ address: item.product.address,
+ bidCount: item.bid_history?.length ?? 0,
+ bidPrice: safeBidPrice,
+ auctionEndAt: item.auction_end_at,
+ auctionStatus: item.auction_status,
+ createdAt: item.created_at,
+ isSecret: item.is_secret,
+ };
+ });
+
+ const limit = Number(searchParams.get('limit')) || 10;
+ const offset = Number(searchParams.get('offset')) || 0;
+
+ const sorted = filtered.sort((a, b) => {
+ if (sort === 'popular') {
+ return b.bidCount - a.bidCount;
+ } else {
+ return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
+ }
+ });
+
+ const sliced = sorted.slice(offset * limit, (offset + 1) * limit);
+
+ return NextResponse.json({
+ data: sliced,
+ nextOffset: sliced.length < limit ? null : offset + 1,
+ });
+}
diff --git a/apps/web/app/api/auction/listings/route.ts b/apps/web/app/api/auction/listings/route.ts
index a859156b..ba4baed6 100644
--- a/apps/web/app/api/auction/listings/route.ts
+++ b/apps/web/app/api/auction/listings/route.ts
@@ -1,45 +1,93 @@
+import { BidHistory } from '@/entities/bidHistory/model/types';
+import { SECRET_PRICE } from '@/features/auction/list/constants';
+import { AUCTION_STATUS } from '@/shared/consts/auctionStatus';
+import getUserId from '@/shared/lib/getUserId';
import { supabase } from '@/shared/lib/supabaseClient';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const { productId } = await request.json();
-
const { error } = await supabase.from('product').delete().eq('product_id', productId);
-
if (error) {
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
}
-
return NextResponse.json({ success: true });
}
export async function GET(request: NextRequest) {
- const { searchParams } = new URL(request.url);
- const userId = searchParams.get('userId');
-
+ const userId = await getUserId();
if (!userId) {
- return NextResponse.json({ success: false, message: 'userId is required' }, { status: 400 });
+ return NextResponse.json({ success: false, message: '๋ก๊ทธ์ธ ํ์' }, { status: 401 });
}
- const { data, error } = await supabase.from('product').select(`
- *,
- product_image:product_image!product_image_product_id_fkey(*),
- pending_auction:pending_auction!pending_auction_product_id_fkey(*),
- auction:auction!auction_product_id_fkey(
+ const { searchParams } = new URL(request.url);
+ const filter = searchParams.get('filter') ?? 'all';
+
+ const { data, error } = await supabase
+ .from('product')
+ .select(
+ `
*,
- bid_history:BidHistory_auction_id_fkey(*)
+ product_image:product_image!product_image_product_id_fkey(*),
+ auction:auction!auction_product_id_fkey(
+ *,
+ bid_history:BidHistory_auction_id_fkey(*)
+ )
+ `
)
- `);
+ .eq('exhibit_user_id', userId)
+ .order('created_at', { ascending: false });
if (error || !data) {
- console.error('์ถํ ๋ฐ์ดํฐ ๋ก๋ฉ ์คํจ:', error);
return NextResponse.json(
- { success: false, message: '๋ฐ์ดํฐ ๋ก๋ฉ ์คํจ', error },
+ { success: false, message: '์ถํ ๋ฐ์ดํฐ ๋ก๋ฉ ์คํจ', error },
{ status: 500 }
);
}
- const filtered = data.filter((product) => product.exhibit_user_id === userId);
+ const filtered = data
+ .map((product) => ({
+ ...product,
+ auction: product.auction[0],
+ }))
+ .map((product) => {
+ const auction = product.auction;
+
+ if (auction.is_secret) {
+ return {
+ ...product,
+ auction: {
+ ...auction,
+ min_price: SECRET_PRICE,
+ bid_history: auction.bid_history.map((bid: BidHistory) => ({
+ ...bid,
+ bid_price: bid.is_awarded ? bid.bid_price : SECRET_PRICE,
+ })),
+ },
+ };
+ }
+
+ return product;
+ });
+
+ const tabFiltered = filtered.filter((product) => {
+ const auction = Array.isArray(product.auction) ? product.auction[0] : product.auction;
+ if (!auction || product.latitude == null || product.longitude == null) return false;
+
+ switch (filter) {
+ case 'pending':
+ return auction.auction_status === AUCTION_STATUS.PENDING;
+ case 'progress':
+ return auction.auction_status === AUCTION_STATUS.IN_PROGRESS;
+ case 'win':
+ return auction.auction_status === AUCTION_STATUS.ENDED && !!auction.winning_bid_user_id;
+ case 'fail':
+ return auction.auction_status === AUCTION_STATUS.ENDED && !auction.winning_bid_user_id;
+ case 'all':
+ default:
+ return true;
+ }
+ });
- return NextResponse.json({ success: true, data: filtered });
+ return NextResponse.json({ success: true, data: tabFiltered });
}
diff --git a/apps/web/app/api/auth/login/route.ts b/apps/web/app/api/auth/login/route.ts
new file mode 100644
index 00000000..9c16dc6f
--- /dev/null
+++ b/apps/web/app/api/auth/login/route.ts
@@ -0,0 +1,49 @@
+import { NextResponse } from 'next/server';
+import { cookies } from 'next/headers';
+import { createClient } from '@/shared/lib/supabase/server';
+
+export async function POST(req: Request) {
+ const { fullEmail, password } = await req.json();
+ const cookieStore = await cookies();
+ const supabase = await createClient();
+
+ const { data, error } = await supabase.auth.signInWithPassword({
+ email: fullEmail,
+ password,
+ });
+
+ if (error || !data.user) {
+ return NextResponse.json({ error: error?.message ?? '๋ก๊ทธ์ธ ์คํจ' }, { status: 401 });
+ }
+
+ const { data: profile, error: profileError } = await supabase
+ .from('profiles')
+ .select('nickname, address')
+ .eq('user_id', data.user.id)
+ .single();
+
+ if (profileError) console.error('ํ๋กํ ์กฐํ ์คํจ', profileError);
+
+ const hasAddress = !!profile?.address;
+
+ cookieStore.set('user-has-address', String(hasAddress), {
+ path: '/',
+ expires: new Date('2099-12-31'),
+ sameSite: 'lax',
+ httpOnly: true,
+ secure: process.env.NODE_ENV === 'production',
+ });
+
+ const userInfo = {
+ id: data.user.id,
+ email: data.user.email!,
+ nickName: profile?.nickname || '',
+ address: profile?.address || '',
+ };
+
+ return NextResponse.json({
+ success: true,
+ user: userInfo,
+ session: data.session,
+ });
+}
diff --git a/apps/web/app/api/auth/logout/route.ts b/apps/web/app/api/auth/logout/route.ts
new file mode 100644
index 00000000..5b1fdf7f
--- /dev/null
+++ b/apps/web/app/api/auth/logout/route.ts
@@ -0,0 +1,13 @@
+import { createServerClient } from '@supabase/ssr';
+import { cookies } from 'next/headers';
+import { NextResponse } from 'next/server';
+import { createClient } from '@/shared/lib/supabase/server';
+
+export async function POST(req: Request) {
+ const supabase = await createClient();
+ await supabase.auth.signOut();
+
+ const res = NextResponse.json({ success: true });
+ res.cookies.delete('user-has-address');
+ return res;
+}
diff --git a/apps/web/app/api/chat/checkIsChatEnd/[shortId]/route.ts b/apps/web/app/api/chat/checkIsChatEnd/[shortId]/route.ts
new file mode 100644
index 00000000..c37e4d37
--- /dev/null
+++ b/apps/web/app/api/chat/checkIsChatEnd/[shortId]/route.ts
@@ -0,0 +1,26 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { supabase } from '@/shared/lib/supabaseClient';
+import { decodeShortId } from '@/shared/lib/shortUuid';
+
+export async function GET(_req: Request, { params }: { params: Promise<{ shortId: string }> }) {
+ const resolvedParams = await params;
+ const chatRoomId = decodeShortId(resolvedParams.shortId);
+
+ if (!chatRoomId) {
+ return NextResponse.json({ error: 'chatRoomId is required' }, { status: 400 });
+ }
+
+ const { data, error } = await supabase
+ .from('chat_room')
+ .select('exhibit_user_active, bid_user_active')
+ .eq('chatroom_id', chatRoomId)
+ .limit(1)
+ .maybeSingle();
+ if (error) {
+ return NextResponse.json({ error: error.message }, { status: 500 });
+ }
+
+ const isChatEnd = !(data?.bid_user_active && data?.exhibit_user_active);
+
+ return NextResponse.json({ isChatEnd });
+}
diff --git a/apps/web/app/api/chat/create/route.ts b/apps/web/app/api/chat/create/route.ts
new file mode 100644
index 00000000..8c086ec5
--- /dev/null
+++ b/apps/web/app/api/chat/create/route.ts
@@ -0,0 +1,47 @@
+import getUserId from '@/shared/lib/getUserId';
+import { decodeShortId, encodeUUID } from '@/shared/lib/shortUuid';
+import { supabase } from '@/shared/lib/supabaseClient';
+import { NextRequest, NextResponse } from 'next/server';
+
+export async function POST(req: NextRequest) {
+ const body = await req.json();
+ const { auction, exhibitUser, bidUser } = body;
+
+ const auctionId = decodeShortId(auction);
+ const exhibitUserId = decodeShortId(exhibitUser);
+ let bidUserId = '';
+ if (bidUser === 'loginUser') {
+ const userId = await getUserId();
+
+ if (!userId) {
+ return NextResponse.json({ error: '๋ก๊ทธ์ธ์ด ํ์ํฉ๋๋ค.' }, { status: 401 });
+ }
+
+ bidUserId = userId;
+ } else {
+ bidUserId = decodeShortId(bidUser);
+ }
+
+ const { data, error } = await supabase
+ .from('chat_room')
+ .insert([
+ {
+ auction_id: auctionId,
+ bid_user_id: bidUserId,
+ exhibit_user_id: exhibitUserId,
+ },
+ ])
+ .select('chatroom_id')
+ .single();
+
+ if (error) {
+ console.error(error.message);
+ return NextResponse.json(
+ { success: false, message: '์ฑํ
๋ฐฉ ์์ฑ ์คํจ', error },
+ { status: 500 }
+ );
+ }
+
+ const encodedChatRoomId = data ? encodeUUID(data.chatroom_id) : null;
+ return NextResponse.json({ encodedChatRoomId });
+}
diff --git a/apps/web/app/api/chat/getChatRoomLink/route.ts b/apps/web/app/api/chat/getChatRoomLink/route.ts
new file mode 100644
index 00000000..aa33aaf0
--- /dev/null
+++ b/apps/web/app/api/chat/getChatRoomLink/route.ts
@@ -0,0 +1,44 @@
+import getUserId from '@/shared/lib/getUserId';
+import { decodeShortId, encodeUUID } from '@/shared/lib/shortUuid';
+import { supabase } from '@/shared/lib/supabaseClient';
+import { NextResponse } from 'next/server';
+
+export async function POST(request: Request) {
+ try {
+ const { auctionId, exhibitUserId, bidUserId } = await request.json();
+ const auctionFullId = decodeShortId(auctionId);
+ const exhibitUserFullId = decodeShortId(exhibitUserId);
+ let bidUserFullId = '';
+ if (bidUserId === 'loginUser') {
+ const userId = await getUserId();
+
+ if (!userId) {
+ return NextResponse.json({ error: '๋ก๊ทธ์ธ์ด ํ์ํฉ๋๋ค.' }, { status: 401 });
+ }
+
+ bidUserFullId = userId;
+ } else {
+ bidUserFullId = decodeShortId(bidUserId);
+ }
+
+ const { data, error } = await supabase
+ .from('chat_room')
+ .select('chatroom_id')
+ .eq('auction_id', auctionFullId)
+ .eq('exhibit_user_id', exhibitUserFullId)
+ .eq('bid_user_id', bidUserFullId)
+ .eq('exhibit_user_active', true)
+ .eq('bid_user_active', true)
+ .maybeSingle();
+
+ if (error) {
+ throw new Error(`chat_room ์กด์ฌ ์ฌ๋ถ ์กฐํ ์คํจ: ${error.message}`);
+ }
+
+ const encodedChatRoomId = data ? encodeUUID(data.chatroom_id) : null;
+
+ return NextResponse.json({ encodedChatRoomId });
+ } catch (error) {
+ return NextResponse.json({ error: (error as Error).message }, { status: 500 });
+ }
+}
diff --git a/apps/web/app/api/chat/route.ts b/apps/web/app/api/chat/route.ts
new file mode 100644
index 00000000..cf71ad6a
--- /dev/null
+++ b/apps/web/app/api/chat/route.ts
@@ -0,0 +1,84 @@
+import { ChatRoomForList } from '@/entities/chatRoom/model/types';
+import { Message } from '@/entities/message/model/types';
+import { ProductImage } from '@/entities/productImage/model/types';
+import { Profiles } from '@/entities/profiles/model/types';
+import getUserId from '@/shared/lib/getUserId';
+import { decodeShortId } from '@/shared/lib/shortUuid';
+import { supabase } from '@/shared/lib/supabaseClient';
+import { NextRequest, NextResponse } from 'next/server';
+
+type RawRow = Omit<
+ ChatRoomForList,
+ 'last_message' | 'your_profile' | 'product_image' | 'unread_count' | 'is_win'
+> & {
+ last_message: Message | null;
+ your_profile: Profiles | null;
+ product_image: ProductImage | null;
+ unread_count: number | null;
+ is_win: boolean;
+};
+
+export async function GET(_req: Request) {
+ const userId = await getUserId();
+
+ const { data, error } = await supabase.rpc('get_chatrooms_with_profile_and_last_message', {
+ user_uuid: userId,
+ });
+
+ if (error) {
+ console.error(error.message);
+ return NextResponse.json(
+ { success: false, message: '์ฑํ
๋ฆฌ์คํธ ์กฐํ ์คํจ', error },
+ { status: 500 }
+ );
+ }
+
+ const chatRooms: ChatRoomForList[] = (data as RawRow[]).map((row) => ({
+ ...row,
+ last_message: row.last_message as Message | null,
+ your_profile: row.your_profile as Profiles,
+ product_image: row.product_image as ProductImage,
+ unread_count: (row.unread_count as number) ?? 0,
+ is_win: row.is_win as boolean,
+ }));
+
+ return NextResponse.json(chatRooms);
+}
+
+export async function POST(req: NextRequest) {
+ const body = await req.json();
+ const { chatRoom, exhibitUser } = body;
+ const userId = await getUserId();
+
+ const chatRoomId = decodeShortId(chatRoom);
+ const exhibitUserId = decodeShortId(exhibitUser);
+
+ if (exhibitUserId === userId) {
+ const { error } = await supabase
+ .from('chat_room')
+ .update({ exhibit_user_active: false })
+ .eq('chatroom_id', chatRoomId);
+
+ if (error) {
+ console.error(error.message);
+ return NextResponse.json(
+ { success: false, message: '์ฑํ
inactive ์คํจ', error },
+ { status: 500 }
+ );
+ }
+ } else {
+ const { error } = await supabase
+ .from('chat_room')
+ .update({ bid_user_active: false })
+ .eq('chatroom_id', chatRoomId);
+
+ if (error) {
+ console.error(error.message);
+ return NextResponse.json(
+ { success: false, message: '์ฑํ
inactive ์คํจ', error },
+ { status: 500 }
+ );
+ }
+ }
+ return NextResponse.json({ success: true, message: '์ฑํ
inactive ์ฒ๋ฆฌ ์๋ฃ' });
+}
diff --git a/apps/web/app/api/cron/create-auction/route.ts b/apps/web/app/api/cron/create-auction/route.ts
index 15803c91..308fa76a 100644
--- a/apps/web/app/api/cron/create-auction/route.ts
+++ b/apps/web/app/api/cron/create-auction/route.ts
@@ -1,63 +1,95 @@
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/shared/lib/supabaseClient';
+import { sendNotification } from '@/app/actions';
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('auction_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;
+ if (!auctions || auctions.length === 0) {
+ return NextResponse.json({
+ success: true,
+ processed: 0,
+ successCount: 0,
+ failCount: 0,
+ timestamp: now,
+ message: '์ฒ๋ฆฌํ ๊ฒฝ๋งค๊ฐ ์์ต๋๋ค.',
+ });
+ }
- // ๊ฐ pending auction์ ๋ํด ์ค์ auction ์์ฑ
- for (const pending of pendingAuctions || []) {
- 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: '๊ฒฝ๋งค ์ค',
- });
+ // ๊ฐ auction์ auction_status๋ฅผ '๊ฒฝ๋งค ์ค'์ผ๋ก ๋ณ๊ฒฝ
+ const updateResults = await Promise.allSettled(
+ auctions.map(async (auction) => {
+ try {
+ const { error: updateError } = await supabase
+ .from('auction')
+ .update({
+ auction_status: '๊ฒฝ๋งค ์ค',
+ updated_at: now,
+ })
+ .eq('auction_id', auction.auction_id);
- if (createError) {
- console.error(`Product ${pending.product_id} ๊ฒฝ๋งค ์์ฑ ์คํจ:`, createError);
- failCount++;
- continue;
- }
+ if (updateError) {
+ throw new Error(`Auction ${auction.auction_id} ์
๋ฐ์ดํธ ์คํจ: ${updateError.message}`);
+ }
- // pending_auction ๋ฐ์ดํฐ ์ญ์
- const { error: deleteError } = await supabase
- .from('pending_auction')
- .delete()
- .eq('pending_auction_id', pending.pending_auction_id);
+ //ํธ์ ์๋ฆผ(๊ฒฝ๋งค ์์)
+ try {
+ const { origin } = new URL(request.url);
+ const test = await fetch(`${origin}/api/alarm/auction/startBid`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ product_id: auction.product_id,
+ auction_id: auction.auction_id,
+ }),
+ });
+ } catch (e) {
+ console.error('๊ฒฝ๋งค ์์ ์ ์ก ์คํจ:', e);
+ }
- if (deleteError) {
- console.error(`Pending auction ${pending.pending_auction_id} ์ญ์ ์คํจ:`, deleteError);
+ return {
+ success: true,
+ auction_id: auction.auction_id,
+ product_id: auction.product_id,
+ };
+ } catch (error) {
+ console.error(`Product ${auction.product_id} ์ฒ๋ฆฌ ์ค ์ค๋ฅ:`, error);
+ return {
+ success: false,
+ auction_id: auction.auction_id,
+ product_id: auction.product_id,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ };
}
+ })
+ );
- successCount++;
- } catch (error) {
- console.error(`Product ${pending.product_id} ์ฒ๋ฆฌ ์ค ์ค๋ฅ:`, error);
- failCount++;
- }
- }
+ // ๊ฒฐ๊ณผ ์์ฝ
+ const successCount = updateResults.filter(
+ (result) => result.status === 'fulfilled' && result.value.success
+ ).length;
+
+ const failCount = updateResults.length - successCount;
const result = {
success: true,
- processed: pendingAuctions?.length || 0,
+ processed: auctions?.length || 0,
successCount,
failCount,
timestamp: now,
diff --git a/apps/web/app/api/cron/resolve-auction/route.ts b/apps/web/app/api/cron/resolve-auction/route.ts
index 7793a84a..34d77edb 100644
--- a/apps/web/app/api/cron/resolve-auction/route.ts
+++ b/apps/web/app/api/cron/resolve-auction/route.ts
@@ -1,5 +1,10 @@
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/shared/lib/supabaseClient';
+import { createPointByReason } from '@/features/point/api/createPointByReason';
+import { createSystemMessage } from '@/features/chat/room/api/createSystemMessage';
+import { getChatRoomLink } from '@/features/chat/room/model/getChatRoomLink';
+import { getProductInfo } from '@/features/chat/room/api/getProductInfoForSystemMessage';
+import { decodeShortId, encodeUUID } from '@/shared/lib/shortUuid';
export async function GET(request: NextRequest) {
try {
@@ -8,85 +13,212 @@ export async function GET(request: NextRequest) {
const { data: auctions, error: fetchError } = await supabase
.from('auction')
- .select('*')
- .lte('auction_end_at', now); // ๊ฒฝ๋งค ์๊ฐ์ด ์ง๋ ๊ฒ๋ค
+ .select(
+ `
+ *,
+ product (
+ exhibit_user_id
+ )
+ `
+ )
+ .lte('auction_end_at', now) // ๊ฒฝ๋งค ์๊ฐ์ด ์ง๋ ๊ฒ๋ค
+ .eq('auction_status', '๊ฒฝ๋งค ์ค');
if (fetchError) {
console.error('auction ์กฐํ ์คํจ:', fetchError);
return NextResponse.json({ success: false, error: fetchError.message }, { status: 500 });
}
- let successCount = 0;
- let failCount = 0;
-
- // ๊ฐ auction์ ๋ํด ์ํ update
- for (const auction of auctions || []) {
- try {
- const { data: bidHistory, error: bidHistoryError } = await supabase
- .from('bid_history')
- .select('*')
- .eq('auction_id', auction.auction_id)
- .order('bid_price', { ascending: false });
-
- if (bidHistoryError) {
- return NextResponse.json(
- {
- success: false,
- error: bidHistoryError instanceof Error ? bidHistoryError.message : 'Unknown error',
- timestamp: new Date().toISOString(),
- },
- { status: 500 }
- );
- } else if (!bidHistory || bidHistory.length === 0) {
- const { error } = await supabase
- .from('auction')
- .update({
- auction_status: '๊ฒฝ๋งค ์ข
๋ฃ',
- updated_at: new Date().toISOString(),
- })
- .eq('auction_id', auction.auction_id);
- if (error) {
- console.error('๊ฒฝ๋งค ์ํ ์
๋ฐ์ดํธ ์คํจ (์ ์ฐฐ ์ฒ๋ฆฌ):', error);
- } else {
- console.log('์ ์ฐฐ ์ฒ๋ฆฌ ์๋ฃ');
+ if (!auctions || auctions.length === 0) {
+ return NextResponse.json({
+ success: true,
+ processed: 0,
+ successCount: 0,
+ failCount: 0,
+ timestamp: now,
+ message: '์ฒ๋ฆฌํ ๊ฒฝ๋งค๊ฐ ์์ต๋๋ค.',
+ });
+ }
+
+ // ๊ฐ auction์ ๋ํด ์ํ update (๋ณ๋ ฌ ์ฒ๋ฆฌ)
+ const updateResults = await Promise.allSettled(
+ (auctions || []).map(async (auction) => {
+ try {
+ const { data: bidHistory, error: bidHistoryError } = await supabase
+ .from('bid_history')
+ .select('*')
+ .eq('auction_id', auction.auction_id)
+ .order('bid_price', { ascending: false });
+
+ if (bidHistoryError) {
+ throw new Error(`bid_history ์กฐํ ์คํจ: ${bidHistoryError.message}`);
}
- } else {
- const { error: auctionUpdateError } = await supabase
- .from('auction')
+
+ const { error: ProposalUpdateError } = await supabase
+ .from('proposal')
.update({
- auction_status: '๊ฒฝ๋งค ์ข
๋ฃ',
- winning_bid_user_id: bidHistory[0].bid_user_id,
- winning_bid_id: bidHistory[0].bid_id,
- updated_at: new Date().toISOString(),
+ proposal_status: 'rejected',
+ responded_at: new Date().toISOString(),
})
- .eq('auction_id', auction.auction_id);
+ .eq('auction_id', auction.auction_id)
+ .eq('proposal_status', 'pending');
- if (auctionUpdateError) {
- console.error('๋์ฐฐ์ ์ ๋ณด ์
๋ฐ์ดํธ ์คํจ:', auctionUpdateError);
- failCount++;
- return;
+ if (ProposalUpdateError) {
+ throw new Error(`์ ์ํ๊ธฐ ์ํ ์
๋ฐ์ดํธ ์คํจ: ${ProposalUpdateError.message}`);
}
- const { error: bidHistoryError } = await supabase
- .from('bid_history')
- .update({
- is_awarded: true,
- })
- .eq('bid_id', bidHistory[0].bid_id);
+ const { origin } = new URL(request.url);
- if (bidHistoryError) {
- console.error('๋์ฐฐ ์ํ ์
๋ฐ์ดํธ ์คํจ (bid_history):', bidHistoryError);
- failCount++;
+ if (!bidHistory || bidHistory.length === 0) {
+ // ์ ์ฐฐ ์ฒ๋ฆฌ
+ const { error } = await supabase
+ .from('auction')
+ .update({
+ auction_status: '๊ฒฝ๋งค ์ข
๋ฃ',
+ updated_at: new Date().toISOString(),
+ })
+ .eq('auction_id', auction.auction_id);
+
+ // ํธ์ ์๋ฆผ ์ ์ก
+ await fetch(`${origin}/api/alarm/auction/nobid`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ auction_id: auction.auction_id,
+ }),
+ });
+
+ if (error) {
+ throw new Error(`์ ์ฐฐ ์ฒ๋ฆฌ ์คํจ: ${error.message}`);
+ }
} else {
- console.log('๋์ฐฐ ์ฒ๋ฆฌ ์๋ฃ');
+ // ๋์ฐฐ ์ฒ๋ฆฌ
+ const winning_bid = bidHistory[0];
+ const winning_user_id = winning_bid.bid_user_id;
+
+ const { error: auctionUpdateError } = await supabase
+ .from('auction')
+ .update({
+ auction_status: '๊ฒฝ๋งค ์ข
๋ฃ',
+ winning_bid_user_id: winning_user_id,
+ winning_bid_id: winning_bid.bid_id,
+ updated_at: new Date().toISOString(),
+ })
+ .eq('auction_id', auction.auction_id);
+
+ if (auctionUpdateError) {
+ throw new Error(`๋์ฐฐ์ ์ ๋ณด ์
๋ฐ์ดํธ ์คํจ: ${auctionUpdateError.message}`);
+ }
+
+ const { error: bidHistoryError } = await supabase
+ .from('bid_history')
+ .update({
+ is_awarded: true,
+ })
+ .eq('bid_id', winning_bid.bid_id);
+
+ if (bidHistoryError) {
+ throw new Error(`๋์ฐฐ ์ํ ์
๋ฐ์ดํธ ์คํจ: ${bidHistoryError.message}`);
+ }
+
+ // ํธ์ ์๋ ์ ์ก(๋์ฐฐ์, ์ถํ์)
+ await fetch(`${origin}/api/alarm/auction/winningBid`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ auction_id: auction.auction_id,
+ }),
+ });
+
+ try {
+ // ์ํ ์ ๋ณด ๊ฐ์ ธ์ค๊ธฐ
+ const { title, imageUrl } = await getProductInfo(auction.product_id);
+ const chatroomId = await getChatRoomLink(
+ encodeUUID(auction.auction_id),
+ encodeUUID(auction.product.exhibit_user_id),
+ encodeUUID(winning_user_id)
+ );
+
+ await createSystemMessage({
+ chatroomId: decodeShortId(chatroomId),
+ exhibitUserId: auction.product.exhibit_user_id,
+ bidUserId: winning_user_id,
+ imgUrl: imageUrl,
+ price: winning_bid.bid_price,
+ title: title,
+ });
+ } catch (error) {
+ console.error('๋์ฐฐ ์์คํ
๋ฉ์ธ์ง ์์ฑ ์คํจ: ', error);
+ }
+
+ try {
+ await createPointByReason('deal_complete_seller', auction.product.exhibit_user_id);
+ try {
+ // ํธ์ ์๋ ์ ์ก(์ถํ์ ํฌ์ธํธ ์ ๋ฆฝ)
+ await fetch(`${origin}/api/alarm/point`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ user_id: auction.product.exhibit_user_id,
+ reason: 'deal_complete_seller',
+ test: 'testestest',
+ }),
+ });
+ } catch (e) {
+ console.error('์ถํ์ ์๋ฆผ ์ ์ก ์คํจ:', e);
+ }
+ } catch (error) {
+ console.error('์ถํ์ ํฌ์ธํธ ์ง๊ธ ์คํจ:', error);
+ }
+
+ try {
+ await createPointByReason(
+ 'deal_complete_buyer',
+ winning_user_id,
+ winning_bid.bid_price
+ );
+
+ try {
+ // ํธ์ ์๋ ์ ์ก(๋์ฐฐ์ ํฌ์ธํธ ์ ๋ฆฝ)
+ await fetch(`${origin}/api/alarm/point`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ type: 'winning_user',
+ user_id: winning_user_id,
+ price: winning_bid.bid_price,
+ reason: 'deal_complete_seller',
+ }),
+ });
+ } catch (e) {
+ console.error('๋์ฐฐ์ ์๋ฆผ ์ ์ก ์คํจ:', e);
+ }
+ } catch (error) {
+ console.error('๋์ฐฐ์ ํฌ์ธํธ ์ง๊ธ ์คํจ:', error);
+ }
}
+
+ return { success: true };
+ } catch (error) {
+ console.error(`Auction ${auction.auction_id} ์ฒ๋ฆฌ ์ค ์ค๋ฅ:`, error);
+ return { success: false };
}
- successCount++;
- } catch (error) {
- console.error(`Product ${auction.auction_id} ์ฒ๋ฆฌ ์ค ์ค๋ฅ:`, error);
- failCount++;
- }
- }
+ })
+ );
+
+ // ๊ฒฐ๊ณผ ์์ฝ
+ const successCount = updateResults.filter(
+ (result) => result.status === 'fulfilled' && result.value.success
+ ).length;
+ const failCount = updateResults.length - successCount;
const result = {
success: true,
diff --git a/apps/web/app/api/location/route.ts b/apps/web/app/api/location/route.ts
index 35b304e0..5e5907e5 100644
--- a/apps/web/app/api/location/route.ts
+++ b/apps/web/app/api/location/route.ts
@@ -1,9 +1,19 @@
+import getUserId from '@/shared/lib/getUserId';
import { supabase } from '@/shared/lib/supabaseClient';
+import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest) {
const body = await req.json();
- const { userId, lat, lng, address } = body;
+ const { lat, lng, address } = body;
+ const cookieStore = await cookies();
+ const userId = await getUserId();
+ cookieStore.set('user-has-address', 'true', {
+ path: '/',
+ expires: new Date('2099-12-31'),
+ sameSite: 'lax',
+ httpOnly: true,
+ });
if (!userId || !lat || !lng || !address) {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
diff --git a/apps/web/app/api/mypage/point/route.ts b/apps/web/app/api/mypage/point/route.ts
new file mode 100644
index 00000000..a4c587fe
--- /dev/null
+++ b/apps/web/app/api/mypage/point/route.ts
@@ -0,0 +1,66 @@
+import { getPointValue, validateReason } from '@/features/point/lib/utils';
+import { PointReason } from '@/features/point/types';
+import getUserId from '@/shared/lib/getUserId';
+import { supabase } from '@/shared/lib/supabaseClient';
+import { NextRequest, NextResponse } from 'next/server';
+
+export async function POST(req: NextRequest) {
+ const body = await req.json();
+ const reason = body.reason;
+ const bidAmount = body.bidAmount;
+ let targetUser = body.targetUser;
+
+ if (targetUser === 'loginUser') {
+ const userId = await getUserId();
+ targetUser = userId;
+ }
+
+ if (!validateReason(reason)) {
+ return NextResponse.json(
+ { success: false, message: `์ ํจํ์ง ์์ reason ๊ฐ์
๋๋ค: ${reason}` },
+ { status: 400 }
+ );
+ }
+
+ const point =
+ bidAmount !== undefined
+ ? getPointValue(reason as PointReason, { bidAmount: bidAmount })
+ : getPointValue(reason as PointReason);
+
+ const { error: profileError } = await supabase.rpc('update_user_point', {
+ p_user_id: targetUser,
+ amount: point,
+ });
+
+ if (profileError) {
+ return NextResponse.json({ success: false, error: profileError.message }, { status: 400 });
+ }
+
+ const { error: pointError } = await supabase.from('point').insert({
+ user_id: targetUser,
+ point: point,
+ reason: reason,
+ });
+
+ if (pointError) {
+ return NextResponse.json({ success: false, message: pointError.message });
+ }
+
+ return NextResponse.json({ success: true });
+}
+
+export async function GET(req: NextRequest) {
+ const userId = await getUserId();
+
+ const { data, error } = await supabase
+ .from('point')
+ .select('*')
+ .eq('user_id', userId)
+ .order('created_at', { ascending: false });
+
+ if (error) {
+ return NextResponse.json({ success: false, message: error.message }, { status: 500 });
+ }
+
+ return NextResponse.json(data);
+}
diff --git a/apps/web/app/api/mypage/route.ts b/apps/web/app/api/mypage/route.ts
index a3d0d814..20ce3e25 100644
--- a/apps/web/app/api/mypage/route.ts
+++ b/apps/web/app/api/mypage/route.ts
@@ -1,5 +1,8 @@
+import { BidWithAuction, ProductWithAuction } from '@/features/mypage/types';
+import { AUCTION_STATUS } from '@/shared/consts/auctionStatus';
import { supabase } from '@/shared/lib/supabaseClient';
import { NextRequest, NextResponse } from 'next/server';
+import { v4 as uuidv4 } from 'uuid';
export async function POST(req: NextRequest) {
const formData = await req.formData();
@@ -7,27 +10,62 @@ export async function POST(req: NextRequest) {
const userId = formData.get('userId') as string;
const nickname = formData.get('nickname') as string;
const isDeleted = formData.get('isDeleted') === 'true';
- const profileImg = formData.get('profileImg') as string | null;
+ const profileImgFile = formData.get('profileImg') as File | null;
if (!userId || !nickname) {
return NextResponse.json({ error: '์ ์ ์ ๋ณด๊ฐ ๋ถ์กฑํฉ๋๋ค.' }, { status: 400 });
}
- const profileImgToSave = isDeleted ? null : profileImg;
+ let profileImgToSave: string | null = null;
- const { error: updateError } = await supabase
- .from('profiles')
- .update({
+ try {
+ if (!isDeleted && profileImgFile && profileImgFile instanceof File) {
+ const ext = profileImgFile.name.split('.').pop();
+ const fileName = `${uuidv4()}.${ext}`;
+ const filePath = `${fileName}`;
+
+ const { error: uploadError } = await supabase.storage
+ .from('user-profile-image')
+ .upload(filePath, profileImgFile, { contentType: profileImgFile.type });
+
+ if (uploadError) {
+ return NextResponse.json(
+ { error: `์ด๋ฏธ์ง ์
๋ก๋ ์คํจ: ${uploadError.message}` },
+ { status: 500 }
+ );
+ }
+
+ const { data: urlData } = supabase.storage.from('user-profile-image').getPublicUrl(filePath);
+ profileImgToSave = urlData.publicUrl;
+ }
+
+ const updateData: { nickname: string; profile_img?: string | null } = {
nickname,
- profile_img: profileImgToSave,
- })
- .eq('user_id', userId);
+ };
- if (updateError) {
- return NextResponse.json({ error: updateError.message }, { status: 500 });
- }
+ if (isDeleted) {
+ updateData.profile_img = null;
+ } else if (profileImgToSave) {
+ updateData.profile_img = profileImgToSave;
+ }
+
+ const { error: updateError } = await supabase
+ .from('profiles')
+ .update(updateData)
+ .eq('user_id', userId);
- return NextResponse.json({ success: true });
+ if (updateError) {
+ return NextResponse.json({ error: updateError.message }, { status: 500 });
+ }
+
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ console.error('ํ๋กํ ์์ ์คํจ:', error);
+ return NextResponse.json(
+ { error: error instanceof Error ? error.message : '์๋ฒ ์ค๋ฅ ๋ฐ์' },
+ { status: 500 }
+ );
+ }
}
export async function GET(request: NextRequest) {
@@ -35,18 +73,75 @@ export async function GET(request: NextRequest) {
const userId = searchParams.get('userId');
if (!userId) {
- return NextResponse.json({ error: 'userId is required' }, { status: 400 });
+ return NextResponse.json({ error: 'ํ๋กํ ์ ๋ณด๋ฅผ ๋ถ๋ฌ์ค์ง ๋ชปํ์ต๋๋ค.' }, { status: 400 });
}
- const { data, error } = await supabase
+ const { data: profile, error: profileError } = await supabase
.from('profiles')
- .select('user_id, nickname, profile_img')
+ .select('nickname, email, profile_img, address,point')
.eq('user_id', userId)
.single();
- if (error || !data) {
+ if (profileError || !profile) {
return NextResponse.json({ error: 'ํ๋กํ ์ ๋ณด๋ฅผ ๋ถ๋ฌ์ค์ง ๋ชปํ์ต๋๋ค.' }, { status: 500 });
}
- return NextResponse.json({ success: true, data });
+ const [bidRes, productRes] = await Promise.all([
+ supabase
+ .from('bid_history')
+ .select('auction_id, auction:auction_id(auction_status)')
+ .eq('bid_user_id', userId),
+
+ supabase
+ .from('product')
+ .select('product_id, auction:auction!auction_product_id_fkey(auction_status)')
+ .eq('exhibit_user_id', userId),
+ ]);
+
+ if (bidRes.error || productRes.error) {
+ return NextResponse.json({ error: '๋ด ๊ฒฝ๋งค ์ ๋ณด๋ฅผ ๋ถ๋ฌ์ค์ง ๋ชปํ์ต๋๋ค.' }, { status: 500 });
+ }
+
+ const seenAuctionIds = new Set();
+ const uniqueBidData: BidWithAuction[] = [];
+
+ (bidRes.data as { auction_id: string; auction: any }[]).forEach((item) => {
+ const auctionId = item.auction_id;
+ if (!seenAuctionIds.has(auctionId)) {
+ seenAuctionIds.add(auctionId);
+ uniqueBidData.push({
+ auction: Array.isArray(item.auction) ? (item.auction[0] ?? null) : (item.auction ?? null),
+ });
+ }
+ });
+
+ const productData: ProductWithAuction[] = (
+ productRes.data as { product_id: string; auction: any }[]
+ ).map((item) => ({
+ product_id: item.product_id,
+ auction: Array.isArray(item.auction) ? (item.auction[0] ?? null) : (item.auction ?? null),
+ }));
+
+ const bidCount = uniqueBidData.length;
+ const bidProgressCount = uniqueBidData.filter(
+ (b) => b.auction?.auction_status === AUCTION_STATUS.IN_PROGRESS
+ ).length;
+
+ const listingCount = productData.length;
+ const listingProgressCount = productData.filter(
+ (p) => p.auction?.auction_status === AUCTION_STATUS.IN_PROGRESS
+ ).length;
+
+ return NextResponse.json({
+ success: true,
+ data: {
+ profile,
+ auction: {
+ bidCount,
+ bidProgressCount,
+ listingCount,
+ listingProgressCount,
+ },
+ },
+ });
}
diff --git a/apps/web/app/api/product/edit/[shortId]/route.ts b/apps/web/app/api/product/edit/[shortId]/route.ts
index 23c4b1cb..b186494b 100644
--- a/apps/web/app/api/product/edit/[shortId]/route.ts
+++ b/apps/web/app/api/product/edit/[shortId]/route.ts
@@ -2,51 +2,92 @@ import { v4 as uuidv4 } from 'uuid';
import { decodeShortId } from '@/shared/lib/shortUuid';
import { supabase } from '@/shared/lib/supabaseClient';
import { NextResponse } from 'next/server';
+import { ProductForEdit } from '@/entities/product/model/types';
+import sharp from 'sharp';
-export async function GET(_req: Request, { params }: { params: Promise<{ shortId: string }> }) {
+export async function GET(
+ _req: Request,
+ { params }: { params: Promise<{ shortId: string }> }
+): Promise<
+ NextResponse<
+ ProductForEdit | { error: string; details?: string; shortId?: string; decodedId?: any }
+ >
+> {
const resolvedParams = await params;
const id = decodeShortId(resolvedParams.shortId);
try {
// 1. ๋จผ์ ๊ฒฝ๋งค ์ ๋ณด๋ง ๊ฐ์ ธ์ค๊ธฐ
- const { data: imageData, error: imageError } = await supabase
+ const { data, error } = await supabase
.from('product')
.select(
`
*,
product_image (*),
- pending_auction(min_price, auction_end_at)
+ auction!inner (
+ min_price,
+ auction_end_at,
+ deal_longitude,
+ deal_latitude,
+ deal_address,
+ is_secret
+ )
`
)
.eq('product_id', id)
.single();
- if (imageError || !imageData) {
+ if (error || !data) {
return NextResponse.json(
{
- error: 'Image not found',
- details: imageError?.message,
+ error: 'Product not found',
+ details: error?.message,
shortId: resolvedParams.shortId,
decodedId: id,
},
{ status: 404 }
);
}
- return NextResponse.json(imageData);
+ // auction ๋ฐ์ดํฐ ์ถ์ถ (๋ฐฐ์ด์ ์ฒซ ๋ฒ์งธ ์์)
+ const auctionData = data.auction[0];
+
+ // ProductForEdit ํ์
์ ๋ง๊ฒ ๋ฐ์ดํฐ ๋ณํ
+ const productForEdit: ProductForEdit = {
+ ...data,
+ min_price: auctionData.min_price,
+ auction_end_at: auctionData.auction_end_at,
+ deal_address: auctionData.deal_address,
+ deal_longitude: auctionData.deal_longitude,
+ deal_latitude: auctionData.deal_latitude,
+ auction: undefined, // auction ๋ฐฐ์ด ์ ๊ฑฐ
+ is_secret: auctionData.is_secret,
+ };
+
+ return NextResponse.json(productForEdit);
} catch (err) {
return NextResponse.json({ error: '์๋ฒ ๋ด๋ถ ์ค๋ฅ ๋ฐ์' }, { status: 500 });
}
}
-export async function POST(request: Request, { params }: { params: { shortId: string } }) {
+export async function POST(request: Request) {
+ const url = new URL(request.url);
+ const shortId = url.pathname.split('/').pop();
const formData = await request.formData();
- const productId = decodeShortId(params.shortId);
+ const productId = decodeShortId(shortId!);
const title = formData.get('title') as string;
const category = formData.get('category') as string;
const description = formData.get('description') as string;
const minPrice = formData.get('min_price') as string;
const endAt = formData.get('end_at') as string;
+ const dealLatitudeRaw = formData.get('deal_latitude');
+ const dealLongitudeRaw = formData.get('deal_longitude');
+ const dealAddressRaw = formData.get('deal_address');
+ const dealLatitude = dealLatitudeRaw !== null ? Number(dealLatitudeRaw) : null;
+ const dealLongitude = dealLongitudeRaw !== null ? Number(dealLongitudeRaw) : null;
+ const isSecret = formData.get('is_secret') as string;
+ const dealAddress =
+ dealAddressRaw !== null && dealAddressRaw !== '' ? String(dealAddressRaw) : null;
// ์๋ก ์
๋ก๋ํ ํ์ผ๋ค
const newImageFiles = formData.getAll('images') as File[];
@@ -63,29 +104,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 ํ
์ด๋ธ ์
๋ฐ์ดํธ
@@ -98,23 +129,26 @@ export async function POST(request: Request, { params }: { params: { shortId: st
updated_at: new Date().toISOString(),
})
.eq('product_id', productId);
-
if (productUpdateError) {
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,
updated_at: new Date().toISOString(),
+ deal_latitude: dealLatitude,
+ deal_longitude: dealLongitude,
+ deal_address: dealAddress,
+ is_secret: isSecret,
})
.eq('product_id', productId);
- if (pendingUpdateError) {
- throw new Error(`๊ฒฝ๋งค ์ ๋ณด ์
๋ฐ์ดํธ ์คํจ: ${pendingUpdateError.message}`);
+ if (auctionUpdateError) {
+ throw new Error(`๊ฒฝ๋งค ์ ๋ณด ์
๋ฐ์ดํธ ์คํจ: ${auctionUpdateError.message}`);
}
// STEP 3: ์ด๋ฏธ์ง ์ฒ๋ฆฌ ๋ก์ง (๋ณ๊ฒฝ์ด ์๋ ๊ฒฝ์ฐ์๋ง)
@@ -130,16 +164,34 @@ export async function POST(request: Request, { params }: { params: { shortId: st
throw new Error(`๊ธฐ์กด ์ด๋ฏธ์ง ์กฐํ ์คํจ: ${fetchError.message}`);
}
- // 3-2: ์ ์ด๋ฏธ์ง ์
๋ก๋
+ // 3-2: ์ ์ด๋ฏธ์ง ์
๋ก๋ (์กฐ๊ฑด๋ถ webP ๋ณํ)
const uploadedImageUrls: string[] = [];
for (const file of newImageFiles) {
- const ext = file.name.split('.').pop();
- const fileName = `${uuidv4()}.${ext}`;
+ const fileName = `${uuidv4()}.webp`;
const filePath = `products/${fileName}`;
+ let finalBuffer: Buffer;
+ let contentType = 'image/webp';
+
+ // WebP ํ์ผ์ธ์ง ํ์ธ
+ if (file.type === 'image/webp') {
+ // ์ด๋ฏธ WebP์ธ ๊ฒฝ์ฐ ๊ทธ๋๋ก ์ฌ์ฉ
+ const arrayBuffer = await file.arrayBuffer();
+ finalBuffer = Buffer.from(arrayBuffer);
+ } else {
+ // WebP๊ฐ ์๋ ๊ฒฝ์ฐ ๋ณํ
+ const arrayBuffer = await file.arrayBuffer();
+ const buffer = Buffer.from(arrayBuffer);
+
+ // sharp๋ฅผ ์ฌ์ฉํ์ฌ WebP๋ก ๋ณํ
+ finalBuffer = await sharp(buffer).toFormat('webp', { quality: 90 }).toBuffer();
+ }
+
const { error: uploadError } = await supabase.storage
.from('product-image')
- .upload(filePath, file, { contentType: file.type });
+ .upload(filePath, finalBuffer, {
+ contentType,
+ });
if (uploadError) {
throw new Error(`์ด๋ฏธ์ง ์
๋ก๋ ์คํจ: ${uploadError.message}`);
@@ -160,7 +212,7 @@ export async function POST(request: Request, { params }: { params: { shortId: st
(img) => !orderExistingImageIds.includes(img.image_id.toString())
);
- // 3-4: ์ญ์ ํ ์ด๋ฏธ์ง๋ค ์ ๊ฑฐ
+ // 3-4: ์ญ์ ํ ์ด๋ฏธ์ง๋ค ์ ๊ฑฐ (์คํ ๋ฆฌ์ง์์๋ ์ญ์ )
if (imagesToDelete.length > 0) {
const { error: deleteError } = await supabase
.from('product_image')
@@ -173,6 +225,20 @@ export async function POST(request: Request, { params }: { params: { shortId: st
if (deleteError) {
throw new Error(`์ด๋ฏธ์ง ์ญ์ ์คํจ: ${deleteError.message}`);
}
+
+ // ์คํ ๋ฆฌ์ง์์๋ ํ์ผ ์ญ์
+ for (const img of imagesToDelete) {
+ try {
+ // URL์์ ํ์ผ ๊ฒฝ๋ก ์ถ์ถ
+ const url = new URL(img.image_url);
+ const filePath = url.pathname.split('/').slice(-2).join('/'); // "products/filename.webp" ํํ
+
+ await supabase.storage.from('product-image').remove([filePath]);
+ } catch (error) {
+ console.warn(`์คํ ๋ฆฌ์ง ํ์ผ ์ญ์ ์คํจ: ${img.image_url}`, error);
+ // ์คํ ๋ฆฌ์ง ์ญ์ ์คํจ๋ ์น๋ช
์ ์ด์ง ์์ผ๋ฏ๋ก ๊ณ์ ์งํ
+ }
+ }
}
// 3-5: ์ด๋ฏธ์ง ์์ ์ฌ์ ๋ ฌ ๋ฐ ์ ์ด๋ฏธ์ง ์ถ๊ฐ
diff --git a/apps/web/app/api/product/route.ts b/apps/web/app/api/product/route.ts
index 3979cd4e..92e73fb9 100644
--- a/apps/web/app/api/product/route.ts
+++ b/apps/web/app/api/product/route.ts
@@ -1,8 +1,7 @@
import { v4 as uuidv4 } from 'uuid';
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/shared/lib/supabaseClient';
-import { searcher } from '@/features/search/lib/utils';
-import { getDistanceKm } from '@/features/product/lib/utils';
+import sharp from 'sharp';
export async function POST(req: NextRequest) {
try {
@@ -14,6 +13,12 @@ export async function POST(req: NextRequest) {
const minPrice = parseInt(formData.get('min_price') as string, 10);
const endAt = formData.get('end_at') as string;
const exhibitUserId = formData.get('user_id') as string;
+ const dealLatitudeRaw = formData.get('deal_latitude');
+ const dealLongitudeRaw = formData.get('deal_longitude');
+ const dealLatitude = dealLatitudeRaw !== null ? Number(dealLatitudeRaw) : null;
+ const dealLongitude = dealLongitudeRaw !== null ? Number(dealLongitudeRaw) : null;
+ const dealAddress = formData.get('deal_address') as string;
+ const isSecret = formData.get('is_secret') as string;
// STEP 0: ๋ก๊ทธ์ธํ ํ์ ์ ๋ณด ์กฐํ
const { data: userData, error: userError } = await supabase
@@ -34,13 +39,31 @@ export async function POST(req: NextRequest) {
// STEP 1: ์ด๋ฏธ์ง ์
๋ก๋ โ ์คํจ ์ abort
for (const [index, file] of files.entries()) {
- const ext = file.name.split('.').pop();
- const fileName = `${uuidv4()}.${ext}`;
+ const fileName = `${uuidv4()}.webp`;
const filePath = `products/${fileName}`;
+ let finalBuffer: Buffer;
+ let contentType = 'image/webp';
+
+ // WebP ํ์ผ์ธ์ง ํ์ธ
+ if (file.type === 'image/webp') {
+ // ์ด๋ฏธ WebP์ธ ๊ฒฝ์ฐ ๊ทธ๋๋ก ์ฌ์ฉ
+ const arrayBuffer = await file.arrayBuffer();
+ finalBuffer = Buffer.from(arrayBuffer);
+ } else {
+ // WebP๊ฐ ์๋ ๊ฒฝ์ฐ ๋ณํ
+ const arrayBuffer = await file.arrayBuffer();
+ const buffer = Buffer.from(arrayBuffer);
+
+ // sharp๋ฅผ ์ฌ์ฉํ์ฌ WebP๋ก ๋ณํ
+ finalBuffer = await sharp(buffer).toFormat('webp', { quality: 90 }).toBuffer();
+ }
+
const { error: uploadError } = await supabase.storage
.from('product-image')
- .upload(filePath, file, { contentType: file.type });
+ .upload(filePath, finalBuffer, {
+ contentType,
+ });
if (uploadError) {
return NextResponse.json(
@@ -89,19 +112,20 @@ 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: '๊ฒฝ๋งค ๋๊ธฐ',
+ deal_longitude: dealLongitude,
+ deal_latitude: dealLatitude,
+ deal_address: dealAddress,
+ is_secret: isSecret,
});
- if (pendingError) {
- console.error('์์ฝ ๊ฒฝ๋งค ์ ์ฅ ์คํจ:', pendingError);
+ if (auctionError) {
+ console.error('๊ฒฝ๋งค ์ ์ฅ ์คํจ:', auctionError);
}
return NextResponse.json({ success: true, product_id: productId });
@@ -110,153 +134,3 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: '์๋ฒ ๋ด๋ถ ์ค๋ฅ ๋ฐ์' }, { status: 500 });
}
}
-
-export interface Auction {
- auction_id: string;
- product: {
- title: string;
- description: string;
- category: string | null;
- exhibit_user: {
- user_id: string;
- address: string;
- profile_img: string | null;
- nickname: string;
- };
- product_image: ProductImage[];
- };
- auction_status: string;
- min_price: number;
- auction_end_at: string;
- bid_history: {
- bid_id: string;
- bid_price: number;
- bid_user_id: string;
- bid_at: string;
- }[];
- current_highest_bid?: number; // ํ์ฌ ์ต๊ณ ์
์ฐฐ๊ฐ (์ต์
)
-}
-
-export interface ProductImage {
- image_id: string;
- image_url: string;
- order_index: number;
- product_id: string;
-}
-
-interface ProductFromDB {
- auction_id: string;
- product_id: string;
- product: {
- title: string;
- category: string | null; // ์นดํ
๊ณ ๋ฆฌ ์ถํ ์์
- exhibit_user_id: string;
- product_image: {
- image_url: string;
- order_index: number;
- }[];
- latitude: number;
- longitude: number;
- address: string;
- };
- auction_status: string;
- min_price: number;
- auction_end_at: string;
- bid_history: {
- bid_price: number;
- }[];
-}
-
-interface ProductResponse {
- id: string;
- thumbnail: string;
- title: string;
- address: string;
- bidCount: number;
- minPrice: number;
- auctionEndAt: string;
- auctionStatus: string;
-}
-
-export async function GET(req: NextRequest) {
- const { searchParams } = req.nextUrl;
- const userId = searchParams.get('userId');
- const search = searchParams.get('search')?.toLowerCase() || '';
- const cate = searchParams.get('cate') || '';
-
- if (!userId) {
- return NextResponse.json({ error: 'userId๊ฐ ์์ต๋๋ค' }, { status: 400 });
- }
-
- const { data: userData, error: userError } = await supabase
- .from('profiles')
- .select('latitude, longitude')
- .eq('user_id', userId)
- .single();
-
- if (!userData?.latitude || !userData?.longitude) {
- return NextResponse.json({ error: '์ ์ ์์น ์ ๋ณด๊ฐ ์์ต๋๋ค.' }, { status: 400 });
- }
-
- if (userError) {
- return NextResponse.json({ error: '์ ์ ์ ๋ณด ์กฐํ ์คํจ' }, { status: 500 });
- }
-
- const lat = userData.latitude;
- const lng = userData.longitude;
-
- const { data: auctionData, error } = await supabase.from('auction').select(`
- auction_id,
- product_id,
- auction_status,
- min_price,
- auction_end_at,
- product:product_id (
- title,
- category,
- latitude,
- longitude,
- exhibit_user_id,
- product_image (
- image_url,
- order_index
- ),
- address
- ),
- bid_history!auction_id (
- bid_price
- )
-`);
-
- if (error) {
- console.error('๋ฆฌ์คํธ ๋ฐ์ดํฐ ์กฐํ ์คํจ:', error.message);
- return NextResponse.json({ error: error.message }, { status: 500 });
- }
- const filtered: ProductResponse[] = (auctionData as unknown as ProductFromDB[])
- .filter((item) => {
- const { product } = item;
- const distance = getDistanceKm(lat, lng, product.latitude, product.longitude);
- const within5km = distance <= 5;
- const matchSearch = !search || searcher(product.title, search);
- const matchCate = cate === '' || cate === 'all' || product.category === cate;
- return within5km && matchSearch && matchCate;
- })
- .map((item) => {
- const bidPrices = item.bid_history?.map((b) => b.bid_price) ?? [];
- const highestBid = bidPrices.length > 0 ? Math.max(...bidPrices) : null;
- return {
- id: item.auction_id,
- thumbnail:
- item.product.product_image?.find((img) => img.order_index === 0)?.image_url ??
- '/default.png',
- title: item.product.title,
- address: item.product.address,
- bidCount: item.bid_history?.length ?? 0,
- minPrice: highestBid ?? item.min_price,
- auctionEndAt: item.auction_end_at,
- auctionStatus: item.auction_status,
- };
- });
-
- return NextResponse.json(filtered);
-}
diff --git a/apps/web/app/api/profile/route.ts b/apps/web/app/api/profile/route.ts
new file mode 100644
index 00000000..73dcd163
--- /dev/null
+++ b/apps/web/app/api/profile/route.ts
@@ -0,0 +1,20 @@
+import getUserId from '@/shared/lib/getUserId';
+import { supabase } from '@/shared/lib/supabaseClient';
+import { NextRequest, NextResponse } from 'next/server';
+
+export async function GET(req: NextRequest) {
+ const userId = await getUserId();
+
+ const { data, error } = await supabase
+ .from('profiles')
+ .select('point')
+ .eq('user_id', userId)
+ .single();
+
+ if (error) {
+ console.error('ํ๋กํ ํฌ์ธํธ ์กฐํ ์คํจ:', error);
+ return NextResponse.json({ error: '์๋ฒ ์ค๋ฅ' }, { status: 500 });
+ }
+
+ return NextResponse.json(data);
+}
diff --git a/apps/web/app/api/proposal/received-proposal/proposal-detail/route.ts b/apps/web/app/api/proposal/received-proposal/proposal-detail/route.ts
new file mode 100644
index 00000000..52386713
--- /dev/null
+++ b/apps/web/app/api/proposal/received-proposal/proposal-detail/route.ts
@@ -0,0 +1,163 @@
+import { supabase } from '@/shared/lib/supabaseClient';
+import { NextRequest, NextResponse } from 'next/server';
+
+export async function GET(request: NextRequest) {
+ try {
+ const { searchParams } = new URL(request.url);
+ const proposalId = searchParams.get('proposalId');
+ const userId = searchParams.get('userId');
+
+ if (!userId || !proposalId) {
+ return NextResponse.json(
+ { success: false, message: '์์ฒญ ์ ๋ณด๊ฐ ๋ถ์กฑํฉ๋๋ค.' },
+ { status: 400 }
+ );
+ }
+
+ const { data, error } = await supabase
+ .from('proposal')
+ .select(
+ `
+ *,
+ auction:proposal_auction_id_fkey(
+ auction_id,
+ min_price,
+ bid_history!auction_id(bid_price),
+ product:product_id(
+ product_id,
+ title,
+ product_image:product_image!product_image_product_id_fkey(image_url),
+ exhibit_user_id
+ )
+ ),
+ proposer_id:proposal_proposer_id_fkey(nickname, profile_img, user_id)
+ `
+ )
+ .eq('proposal_id', proposalId)
+ .single();
+
+ if (error || !data) {
+ throw new Error(`๋ฐ์ ์ ์ ๋ฐ์ดํฐ ์์ธ ์กฐํ ์คํจ: ${error}`);
+ }
+
+ return NextResponse.json({ success: true, data: data });
+ } catch (error) {
+ return NextResponse.json(
+ { success: false, message: '์ํ ๋ถ๋ฌ์ค๊ธฐ ์คํจ', error },
+ { status: 500 }
+ );
+ }
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ const body = await request.json();
+ const { proposalId, proposalStatus, userId } = body;
+
+ if (!proposalId || !proposalStatus || !userId) {
+ return NextResponse.json(
+ { success: false, message: 'ํ์ ์ ๋ณด๊ฐ ๋๋ฝ๋์์ต๋๋ค.' },
+ { status: 400 }
+ );
+ }
+
+ const { data: proposal, error: proposalError } = await supabase
+ .from('proposal')
+ .select(
+ `
+ *,
+ auction:proposal_auction_id_fkey(
+ auction_id,
+ auction_status,
+ bid_history!auction_id(bid_id),
+ product:product_id(exhibit_user_id)
+ )
+ `
+ )
+ .eq('proposal_id', proposalId)
+ .single();
+
+ if (proposalError || !proposal) {
+ throw new Error(`์ ์ ์ ๋ณด ์กฐํ ์คํจ : ${proposalError?.message}`);
+ }
+
+ // ๊ถํ ํ์ธ: ๋ณธ์ธ์ด ์ถํํ ์ํ์ธ์ง
+ if (proposal.auction.product.exhibit_user_id !== userId) {
+ return NextResponse.json({ success: false, message: '๊ถํ์ด ์์ต๋๋ค.' }, { status: 403 });
+ }
+
+ // ์ ์ ์ํ ์
๋ฐ์ดํธ
+ const updatedStatus = proposalStatus === 'accept' ? 'accepted' : 'rejected';
+
+ const { error: updateError } = await supabase
+ .from('proposal')
+ .update({
+ proposal_status: updatedStatus,
+ responded_at: new Date().toISOString(),
+ })
+ .eq('proposal_id', proposalId);
+
+ if (updateError) {
+ throw new Error(`์ ์ ์ํ ์
๋ฐ์ดํธ ์คํจ : ${updateError.message}`);
+ }
+
+ // ์ ์ ์๋ฝ ์
+ if (proposalStatus === 'accept') {
+ // ์
์ฐฐ ๋ฐ์ดํฐ ์ฝ์
+ const { data: bidData, error: bidError } = await supabase
+ .from('bid_history')
+ .insert([
+ {
+ auction_id: proposal.auction.auction_id,
+ bid_user_id: proposal.proposer_id,
+ bid_price: proposal.proposed_price,
+ is_awarded: true,
+ },
+ ])
+ .select()
+ .single();
+
+ if (bidError) {
+ throw new Error(`์
์ฐฐ ๋ฐ์ดํฐ ์ฝ์
์ค๋ฅ : ${bidError.message}`);
+ }
+
+ // ๋ค๋ฅธ ์ ์ ์ํ ๋ชจ๋ rejected ์ฒ๋ฆฌ
+ const { error: updateOtherProposalsError } = await supabase
+ .from('proposal')
+ .update({
+ proposal_status: 'rejected',
+ responded_at: new Date().toISOString(),
+ })
+ .eq('auction_id', proposal.auction.auction_id)
+ .neq('proposal_id', proposalId);
+
+ if (updateOtherProposalsError) {
+ throw new Error(`๋ค๋ฅธ ์ ์ ์ํ ๋ณ๊ฒฝ ์คํจ : ${updateOtherProposalsError.message}`);
+ }
+
+ // ๊ฒฝ๋งค ์ํ ์ข
๋ฃ ์ฒ๋ฆฌ
+ const { error: endAuctionError } = await supabase
+ .from('auction')
+ .update({
+ auction_status: '๊ฒฝ๋งค ์ข
๋ฃ',
+ winning_bid_user_id: proposal.proposer_id,
+ winning_bid_id: bidData.bid_id,
+ auction_end_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ })
+ .eq('auction_id', proposal.auction.auction_id);
+
+ if (endAuctionError) {
+ throw new Error(`๊ฒฝ๋งค ์ข
๋ฃ ์คํจ : ${endAuctionError.message}`);
+ }
+ }
+
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ console.error('POST /proposal/status ์๋ฌ:', error);
+ return NextResponse.json(
+ { success: false, message: (error as Error).message },
+ { status: 500 }
+ );
+ }
+}
diff --git a/apps/web/app/api/proposal/received-proposal/route.ts b/apps/web/app/api/proposal/received-proposal/route.ts
new file mode 100644
index 00000000..6436f3ee
--- /dev/null
+++ b/apps/web/app/api/proposal/received-proposal/route.ts
@@ -0,0 +1,46 @@
+import { supabase } from '@/shared/lib/supabaseClient';
+import { NextResponse } from 'next/server';
+import getUserId from '@/shared/lib/getUserId';
+
+export async function GET() {
+ const userId = await getUserId();
+
+ if (!userId) {
+ return NextResponse.json(
+ { success: false, message: '๋ก๊ทธ์ธ ํ ์๋น์ค ์ด์ฉ์ด ๊ฐ๋ฅํฉ๋๋ค' },
+ { status: 400 }
+ );
+ }
+
+ const { data, error } = await supabase
+ .from('proposal')
+ .select(
+ `
+ *,
+ auction:proposal_auction_id_fkey(
+ auction_id,
+ auction_status,
+ auction_end_at,
+ product:product_id(
+ product_id,
+ title,
+ product_image:product_image!product_image_product_id_fkey(image_url),
+ exhibit_user_id
+ )
+ ),
+ proposer_id:proposal_proposer_id_fkey(nickname, profile_img, user_id)
+ `
+ )
+ .order('created_at', { ascending: false });
+
+ if (error || !data) {
+ return NextResponse.json(
+ { success: false, message: '๋ฐ์ดํฐ ๋ก๋ฉ ์คํจ', error },
+ { status: 500 }
+ );
+ }
+
+ const filtered = data.filter((proposal) => proposal.auction?.product?.exhibit_user_id === userId);
+
+ return NextResponse.json({ success: true, data: filtered });
+}
diff --git a/apps/web/app/api/proposal/sent-proposal/route.ts b/apps/web/app/api/proposal/sent-proposal/route.ts
new file mode 100644
index 00000000..690a004a
--- /dev/null
+++ b/apps/web/app/api/proposal/sent-proposal/route.ts
@@ -0,0 +1,48 @@
+import { supabase } from '@/shared/lib/supabaseClient';
+import { NextRequest, NextResponse } from 'next/server';
+import getUserId from '@/shared/lib/getUserId';
+
+export async function GET(request: NextRequest) {
+ try {
+ const userId = await getUserId();
+
+ if (!userId) {
+ return NextResponse.json(
+ { success: false, message: '๋ก๊ทธ์ธ ํ ์๋น์ค ์ด์ฉ์ด ๊ฐ๋ฅํฉ๋๋ค' },
+ { status: 400 }
+ );
+ }
+
+ const { data, error } = await supabase
+ .from('proposal')
+ .select(
+ `
+ *,
+ auction:proposal_auction_id_fkey(
+ auction_id,
+ product:product_id(
+ product_id,
+ title,
+ product_image:product_image!product_image_product_id_fkey(image_url),
+ exhibit_user_id
+ )
+ ),
+ proposer_id:proposal_proposer_id_fkey(nickname, profile_img, user_id)
+ `
+ )
+ .eq('proposer_id', userId)
+ .order('created_at', { ascending: false });
+
+ if (error || !data) {
+ throw new Error(`๋ณด๋ธ ์ ์ ์กฐํ ์คํจ : ${error.message}`);
+ }
+
+ return NextResponse.json({ success: true, data });
+ } catch (error) {
+ console.error('์กฐ๋ธ ์ ์ ์กฐํ ์๋ฌ:', error);
+ return NextResponse.json(
+ { success: false, message: '๋ฐ์ดํฐ ๋ก๋ฉ ์คํจ', error },
+ { status: 500 }
+ );
+ }
+}
diff --git a/apps/web/app/api/system-message/route.ts b/apps/web/app/api/system-message/route.ts
new file mode 100644
index 00000000..95f0f46b
--- /dev/null
+++ b/apps/web/app/api/system-message/route.ts
@@ -0,0 +1,33 @@
+import { supabase } from '@/shared/lib/supabaseClient';
+import { NextRequest, NextResponse } from 'next/server';
+
+export async function POST(req: NextRequest) {
+ const body = await req.json();
+ const { chatroomId, exhibitUserId, bidUserId, imgUrl, price, title } = body;
+
+ const { data, error } = await supabase
+ .from('system_message')
+ .insert([
+ {
+ chatroom_id: chatroomId,
+ bid_user_id: bidUserId,
+ exhibit_user_id: exhibitUserId,
+ product_image_url: imgUrl,
+ bid_price: price,
+ product_title: title,
+ },
+ ])
+ .select('system_message_id')
+ .single();
+
+ if (error) {
+ console.error(error.message);
+ return NextResponse.json(
+ { success: false, message: '์์คํ
๋ฉ์ธ์ง ์์ฑ ์คํจ', error },
+ { status: 500 }
+ );
+ }
+
+ const systemMessageId = data ? data.system_message_id : null;
+ return NextResponse.json({ systemMessageId });
+}
diff --git a/apps/web/app/api/vworld/route.ts b/apps/web/app/api/vworld/route.ts
index 2c37c347..989b18c4 100644
--- a/apps/web/app/api/vworld/route.ts
+++ b/apps/web/app/api/vworld/route.ts
@@ -1,13 +1,31 @@
import { NextRequest, NextResponse } from 'next/server';
+const KEY = process.env.VWORLD_KEY;
+
export async function GET(req: NextRequest) {
const searchParams = req.nextUrl.searchParams;
const point = searchParams.get('point');
- const url = `https://api.vworld.kr/req/address?service=address&request=getAddress&type=both&crs=epsg:4326&zipcode=false&simple=false&format=json&key=2DBB9BE5-D790-349B-A105-250C70BCAFD3&point=${point}`;
+ const url = `https://api.vworld.kr/req/address?service=address&request=getAddress&type=both&crs=epsg:4326&zipcode=false&simple=false&format=json&key=${KEY}&point=${point}`;
+
+ try {
+ const res = await fetch(url);
+ const text = await res.text();
- const res = await fetch(url);
- const data = await res.json();
+ if (!res.ok) {
+ console.error('[ERROR] VWorld API fetch failed');
+ console.error('[ERROR] Response Status:', res.status);
+ console.error('[ERROR] Response Body:', text);
+ return NextResponse.json(
+ { error: 'VWorld API fetch failed', status: res.status, detail: text },
+ { status: 500 }
+ );
+ }
- return NextResponse.json(data);
+ const data = JSON.parse(text);
+ return NextResponse.json(data);
+ } catch (err) {
+ console.error('[EXCEPTION] Error during fetch:', err);
+ return NextResponse.json({ error: 'Fetch exception', message: String(err) }, { status: 500 });
+ }
}
diff --git a/apps/web/app/auth/callback/page.tsx b/apps/web/app/auth/callback/page.tsx
deleted file mode 100644
index 5e00fadd..00000000
--- a/apps/web/app/auth/callback/page.tsx
+++ /dev/null
@@ -1,178 +0,0 @@
-'use client';
-
-import { useEffect, useState } from 'react';
-import { useRouter, useSearchParams } from 'next/navigation';
-import { supabase } from '../../../shared/lib/supabaseClient';
-import { toast } from '@repo/ui/components/Toast/Sonner';
-
-export default function AuthCallback() {
- const router = useRouter();
- const searchParams = useSearchParams();
- const [isProcessing, setIsProcessing] = useState(true);
-
- useEffect(() => {
- const handleAuthCallback = async () => {
- try {
- // URL์์ ์ธ์ฆ ๊ด๋ จ ํ๋ผ๋ฏธํฐ ํ์ธ
- const accessToken = searchParams.get('access_token');
- const refreshToken = searchParams.get('refresh_token');
- const error = searchParams.get('error');
- const errorDescription = searchParams.get('error_description');
-
- // ์๋ฌ๊ฐ ์๋ ๊ฒฝ์ฐ
- if (error) {
- console.error('Auth error:', error, errorDescription);
- toast({ content: '์ด๋ฉ์ผ ์ธ์ฆ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค: ' + (errorDescription || error) });
-
- // ๋ถ๋ชจ ์ฐฝ์ ์๋ฌ ๋ฉ์์ง ์ ์ก
- if (window.opener) {
- window.opener.postMessage(
- {
- type: 'AUTH_ERROR',
- error: errorDescription || error,
- },
- window.location.origin
- );
- window.close();
- } else {
- router.replace('/signup');
- }
- return;
- }
-
- // ํ ํฐ์ด ์๋ ๊ฒฝ์ฐ ์ธ์
์ค์
- if (accessToken && refreshToken) {
- const { data, error: sessionError } = await supabase.auth.setSession({
- access_token: accessToken,
- refresh_token: refreshToken,
- });
-
- if (sessionError) {
- console.error('Session error:', sessionError);
- toast({ content: '์ธ์
์ค์ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค.' });
-
- if (window.opener) {
- window.opener.postMessage(
- {
- type: 'AUTH_ERROR',
- error: '์ธ์
์ค์ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค.',
- },
- window.location.origin
- );
- window.close();
- } else {
- router.replace('/signup');
- }
- return;
- }
-
- // ์ฌ์ฉ์ ์ ๋ณด ํ์ธ
- const { data: userData, error: userError } = await supabase.auth.getUser();
-
- if (userError || !userData.user) {
- console.error('User error:', userError);
- toast({ content: '์ฌ์ฉ์ ์ ๋ณด๋ฅผ ๊ฐ์ ธ์ฌ ์ ์์ต๋๋ค.' });
-
- if (window.opener) {
- window.opener.postMessage(
- {
- type: 'AUTH_ERROR',
- error: '์ฌ์ฉ์ ์ ๋ณด๋ฅผ ๊ฐ์ ธ์ฌ ์ ์์ต๋๋ค.',
- },
- window.location.origin
- );
- window.close();
- } else {
- router.replace('/signup');
- }
- return;
- }
-
- // ์ด๋ฉ์ผ ์ธ์ฆ ์๋ฃ ํ์ธ
- if (userData.user.email_confirmed_at) {
- if (window.opener) {
- window.opener.postMessage(
- {
- type: 'AUTH_SUCCESS',
- user: userData.user,
- },
- window.location.origin
- );
- // ์ฑ๊ณต ๋ฉ์์ง ํ์ ํ ์ฐฝ ๋ซ๊ธฐ
- toast({ content: '์ด๋ฉ์ผ ์ธ์ฆ์ด ์๋ฃ๋์์ต๋๋ค! ํ์๊ฐ์
์ ๊ณ์ ์งํํด์ฃผ์ธ์.' });
-
- window.close();
- } else {
- router.replace('/signup?verified=true');
- }
- } else {
- toast({ content: '์ด๋ฉ์ผ ์ธ์ฆ์ด ์๋ฃ๋์ง ์์์ต๋๋ค.' });
- if (window.opener) {
- window.opener.postMessage(
- {
- type: 'AUTH_ERROR',
- error: '์ด๋ฉ์ผ ์ธ์ฆ์ด ์๋ฃ๋์ง ์์์ต๋๋ค.',
- },
- window.location.origin
- );
- window.close();
- } else {
- router.replace('/signup');
- }
- }
- } else {
- // ํ ํฐ์ด ์๋ ๊ฒฝ์ฐ ๊ธฐ๋ณธ ์ธ์
ํ์ธ
- const { data, error: sessionError } = await supabase.auth.getSession();
-
- if (sessionError) {
- console.error('Session check error:', sessionError);
- router.replace('/signup');
- return;
- }
-
- if (data.session?.user?.email_confirmed_at) {
- router.replace('/signup?verified=true');
- } else {
- router.replace('/signup');
- }
- }
- } catch (error) {
- console.error('Unexpected error in callback:', error);
- toast({ content: '์์์น ๋ชปํ ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค.' });
-
- if (window.opener) {
- window.opener.postMessage(
- {
- type: 'AUTH_ERROR',
- error: '์์์น ๋ชปํ ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค.',
- },
- window.location.origin
- );
- window.close();
- } else {
- router.replace('/signup');
- }
- } finally {
- setIsProcessing(false);
- }
- };
-
- handleAuthCallback();
- }, [router, searchParams]);
-
- if (!isProcessing) {
- return null; // ์ฒ๋ฆฌ ์๋ฃ ํ ๋ฆฌ๋ค์ด๋ ํธ ๋๋ฏ๋ก ์๋ฌด๊ฒ๋ ๋ณด์ฌ์ฃผ์ง ์์
- }
-
- return (
-
-
-
-
์ด๋ฉ์ผ ์ธ์ฆ์ ์ฒ๋ฆฌํ๊ณ ์์ต๋๋ค...
-
์ ์๋ง ๊ธฐ๋ค๋ ค์ฃผ์ธ์.
-
-
- );
-}
diff --git a/apps/web/app/global-error.tsx b/apps/web/app/global-error.tsx
new file mode 100644
index 00000000..20c17501
--- /dev/null
+++ b/apps/web/app/global-error.tsx
@@ -0,0 +1,23 @@
+'use client';
+
+import * as Sentry from '@sentry/nextjs';
+import NextError from 'next/error';
+import { useEffect } from 'react';
+
+export default function GlobalError({ error }: { error: Error & { digest?: string } }) {
+ useEffect(() => {
+ Sentry.captureException(error);
+ }, [error]);
+
+ return (
+
+
+ {/* `NextError` is the default Next.js error page component. Its type
+ definition requires a `statusCode` prop. However, since the App Router
+ does not expose status codes for errors, we simply pass 0 to render a
+ generic error message. */}
+
+
+
+ );
+}
diff --git a/apps/web/app/install/page.tsx b/apps/web/app/install/page.tsx
new file mode 100644
index 00000000..ae565f77
--- /dev/null
+++ b/apps/web/app/install/page.tsx
@@ -0,0 +1,46 @@
+'use client';
+
+import { Button } from '@repo/ui/components/Button/Button';
+import { useState, useEffect } from 'react';
+
+function InstallPrompt() {
+ const [isIOS, setIsIOS] = useState(false);
+ const [isStandalone, setIsStandalone] = useState(false);
+
+ useEffect(() => {
+ setIsIOS(/iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream);
+
+ setIsStandalone(window.matchMedia('(display-mode: standalone)').matches);
+ }, []);
+
+ if (isStandalone) {
+ return null;
+ }
+
+ return (
+
+
Bider ์ค์นํ๊ธฐ
+
+ {isIOS && (
+ <>
+
iOS์์ ์ค์นํ๋ ค๋ฉด:
+
+ >
+ )}
+
+ );
+}
+
+export default function Page() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
index 2cd6f058..f4e84916 100644
--- a/apps/web/app/layout.tsx
+++ b/apps/web/app/layout.tsx
@@ -1,23 +1,85 @@
import type { Metadata } from 'next';
-import localFont from 'next/font/local';
import type { Viewport } from 'next';
import '@repo/ui/styles.css';
import '../styles/global.css';
-import ReactQueryProvider from '@/shared/providers/ReactQueryProvider';
import { Toaster } from '@repo/ui/components/Toast/Sonner';
-
-const geistSans = localFont({
- src: '../public/fonts/GeistVF.woff',
- variable: '--font-geist-sans',
-});
-const geistMono = localFont({
- src: '../public/fonts/GeistMonoVF.woff',
- variable: '--font-geist-mono',
-});
+import Script from 'next/script';
export const metadata: Metadata = {
title: '๊ฐ์ฅ ๊ฐ๊น์ด ๊ฒฝ๋งค์ฅ | Bider',
description: '๋ด ๊ทผ์ฒ ๊ฐ์ฅ ๊ฐ๊น์ด ๊ฒฝ๋งค์ฅ, Bider',
+ manifest: '/manifest.webmanifest',
+ icons: {
+ shortcut: '/ios/apple-touch-icon.png',
+ apple: '/ios/apple-touch-icon.png',
+ other: {
+ rel: 'apple-touch-icon-precomposed',
+ url: '/ios/apple-touch-icon.png',
+ },
+ },
+ appleWebApp: {
+ capable: true,
+ title: 'Bider',
+ statusBarStyle: 'default',
+ startupImage: [
+ '/ios/apple-splash-768x1004.png',
+ {
+ url: '/ios/apple-splash-640x1136.png',
+ media:
+ '(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)',
+ },
+ {
+ url: '/ios/apple-splash-750x1334.png',
+ media:
+ '(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)',
+ },
+ {
+ url: '/ios/apple-splash-1242x2208.png',
+ media:
+ '(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)',
+ },
+ {
+ url: '/ios/apple-splash-828x1792.png',
+ media:
+ '(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2)',
+ },
+ {
+ url: '/ios/apple-splash-1125x2436.png',
+ media:
+ '(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)',
+ },
+ {
+ url: '/ios/apple-splash-1170x2532.png',
+ media:
+ '(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3)',
+ },
+ {
+ url: '/ios/apple-splash-1179x2556.png',
+ media:
+ '(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3)',
+ },
+ {
+ url: '/ios/apple-splash-1290x2796.png',
+ media:
+ '(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3)',
+ },
+ {
+ url: '/ios/apple-splash-1080x2340.png',
+ media:
+ '(device-width: 360px) and (device-height: 780px) and (-webkit-device-pixel-ratio: 3)',
+ },
+ {
+ url: '/ios/apple-splash-1242x2688.png',
+ media:
+ '(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3)',
+ },
+ {
+ url: '/ios/apple-splash-1536x2008.png',
+ media:
+ '(min-device-width: 768px) and (max-device-width: 1024px) and (-webkit-min-device-pixel-ratio: 2) and (orientation: portrait)',
+ },
+ ],
+ },
};
export const viewport: Viewport = {
@@ -34,16 +96,24 @@ const RootLayout = ({
}>) => {
return (
-
-
+
+
- {/* 'pb-ํค๋๋์ด'๋ก ์์ */}
-
{children}
-
-
-
+
+
+ {process.env.NODE_ENV === 'production' && (
+
+ )}
);
diff --git a/apps/web/app/manifest.ts b/apps/web/app/manifest.ts
new file mode 100644
index 00000000..0471df10
--- /dev/null
+++ b/apps/web/app/manifest.ts
@@ -0,0 +1,45 @@
+import type { MetadataRoute } from 'next';
+
+export default function manifest(): MetadataRoute.Manifest {
+ return {
+ name: 'Bider',
+ short_name: 'Bider',
+ description: '๊ฐ์ฅ ๊ฐ๊น์ด ๊ฒฝ๋งค์ฅ Bider',
+ start_url: '/',
+ display: 'standalone',
+ background_color: '#64b5f7',
+ theme_color: '#ffffff',
+ icons: [
+ {
+ src: '/android/android-launchericon-48-48.png',
+ sizes: '48x48',
+ type: 'image/png',
+ },
+ {
+ src: '/android/android-launchericon-72-72.png',
+ sizes: '72x72',
+ type: 'image/png',
+ },
+ {
+ src: '/android/android-launchericon-96-96.png',
+ sizes: '96x96',
+ type: 'image/png',
+ },
+ {
+ src: '/android/android-launchericon-144-144.png',
+ sizes: '144x144',
+ type: 'image/png',
+ },
+ {
+ src: '/android/android-launchericon-192-192.png',
+ sizes: '192x192',
+ type: 'image/png',
+ },
+ {
+ src: '/android/android-launchericon-512-512-transparent.png',
+ sizes: '512x512',
+ type: 'image/png',
+ },
+ ],
+ };
+}
diff --git a/apps/web/entities/auction/model/types.ts b/apps/web/entities/auction/model/types.ts
new file mode 100644
index 00000000..967b8b08
--- /dev/null
+++ b/apps/web/entities/auction/model/types.ts
@@ -0,0 +1,72 @@
+import { BidHistory, BidHistoryWithUserNickname } from '@/entities/bidHistory/model/types';
+import {
+ ProductForList,
+ ProductForMapList,
+ ProductWithUserNImages,
+} 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;
+ deal_longitude?: number;
+ deal_latitude?: number;
+ deal_address?: string;
+ is_secret: boolean;
+}
+
+export interface AuctionDetail extends Auction {
+ product: ProductWithUserNImages;
+ bid_history: BidHistoryWithUserNickname[];
+ current_highest_bid: number;
+ bid_cnt: number;
+}
+
+export interface AuctionForBid {
+ auction_end_at: string;
+ auction_status: string;
+ min_price: number;
+ product: {
+ title: string;
+ };
+}
+
+export type AuctionList = Pick<
+ Auction,
+ | 'auction_id'
+ | 'product_id'
+ | 'auction_status'
+ | 'min_price'
+ | 'auction_end_at'
+ | 'created_at'
+ | 'is_secret'
+> & {
+ product: ProductForList;
+ bid_history: Pick[];
+};
+
+export type MapAuction = Pick<
+ Auction,
+ 'auction_id' | 'product_id' | 'auction_end_at' | 'min_price' | 'is_secret'
+> & {
+ bid_history: Pick[];
+ product: ProductForMapList;
+};
+
+export type SecretViewHistory =
+ | {
+ hasPaid: false;
+ isValid: false;
+ viewedAt?: never;
+ }
+ | {
+ hasPaid: true;
+ isValid: boolean;
+ viewedAt: string;
+ };
diff --git a/apps/web/entities/bidHistory/model/types.ts b/apps/web/entities/bidHistory/model/types.ts
new file mode 100644
index 00000000..c2580b75
--- /dev/null
+++ b/apps/web/entities/bidHistory/model/types.ts
@@ -0,0 +1,16 @@
+import { Profiles } from '@/entities/profiles/model/types';
+
+export interface BidHistory {
+ bid_id: string;
+ bid_at: string;
+ auction_id: string;
+ bid_user_id: string;
+ bid_price: number;
+ is_awarded: boolean;
+}
+
+export interface BidHistoryWithUserNickname extends BidHistory {
+ bid_user_nickname: {
+ nickname: string;
+ };
+}
diff --git a/apps/web/entities/chatRoom/model/types.ts b/apps/web/entities/chatRoom/model/types.ts
new file mode 100644
index 00000000..529c2719
--- /dev/null
+++ b/apps/web/entities/chatRoom/model/types.ts
@@ -0,0 +1,21 @@
+import { Message } from '@/entities/message/model/types';
+import { ProductImage } from '@/entities/productImage/model/types';
+import { Profiles } from '@/entities/profiles/model/types';
+
+export interface ChatRoom {
+ chatroom_id: string;
+ auction_id: string;
+ bid_user_id: string;
+ exhibit_user_id: string;
+ created_at: string;
+ bid_user_active: boolean;
+ exhibit_user_active: boolean;
+}
+
+export interface ChatRoomForList extends ChatRoom {
+ product_image: ProductImage;
+ your_profile: Profiles;
+ last_message: Message | null;
+ unread_count: number;
+ is_win: boolean;
+}
diff --git a/apps/web/entities/message/model/types.ts b/apps/web/entities/message/model/types.ts
new file mode 100644
index 00000000..b5761355
--- /dev/null
+++ b/apps/web/entities/message/model/types.ts
@@ -0,0 +1,22 @@
+import { MessageImage } from '@/entities/messageImage/model/types';
+import { Profiles } from '@/entities/profiles/model/types';
+
+type MessageType = 'text' | 'image';
+
+export interface Message {
+ message_id: string;
+ chatroom_id: string;
+ sender_id: string;
+ content?: string | null;
+ is_read: boolean;
+ created_at: string;
+ message_type: MessageType;
+}
+
+export interface MessageWithProfile extends Message {
+ profile?: Profiles;
+}
+
+export interface MessageWithImage extends MessageWithProfile {
+ images?: MessageImage[];
+}
diff --git a/apps/web/entities/messageImage/model/types.ts b/apps/web/entities/messageImage/model/types.ts
new file mode 100644
index 00000000..29221fb0
--- /dev/null
+++ b/apps/web/entities/messageImage/model/types.ts
@@ -0,0 +1,7 @@
+export interface MessageImage {
+ image_id: string;
+ message_id: string;
+ image_url: string;
+ order_index: number;
+ created_at: string;
+}
diff --git a/apps/web/entities/point/model/types.ts b/apps/web/entities/point/model/types.ts
new file mode 100644
index 00000000..887ed1b7
--- /dev/null
+++ b/apps/web/entities/point/model/types.ts
@@ -0,0 +1,7 @@
+export interface Point {
+ point_id: string;
+ user_id: string;
+ point: number;
+ reason: string;
+ created_at: string;
+}
diff --git a/apps/web/entities/product/model/types.ts b/apps/web/entities/product/model/types.ts
new file mode 100644
index 00000000..e7cad8da
--- /dev/null
+++ b/apps/web/entities/product/model/types.ts
@@ -0,0 +1,41 @@
+import { ProductImage } from '@/entities/productImage/model/types';
+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 ProductWithUserNImages extends Product {
+ exhibit_user: Profiles;
+ product_image: ProductImage[];
+}
+
+export interface ProductForEdit extends Product {
+ product_image: ProductImage[];
+ min_price: number;
+ auction_end_at: string;
+ deal_address: string;
+ deal_longitude: number;
+ deal_latitude: number;
+ is_secret: boolean;
+}
+
+export type ProductForList = Pick<
+ Product,
+ 'title' | 'category' | 'exhibit_user_id' | 'latitude' | 'longitude' | 'address'
+> & {
+ product_image: ProductImage[];
+};
+
+export type ProductForMapList = Pick & {
+ product_image: ProductImage[];
+};
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..c9c12ae6
--- /dev/null
+++ b/apps/web/entities/profiles/model/types.ts
@@ -0,0 +1,16 @@
+export interface Profiles {
+ user_id: string;
+ email: string;
+ nickname: string;
+ latitude?: number;
+ longitude?: number;
+ address?: string;
+ profile_img?: string;
+ point: number;
+ created_at: string;
+}
+export interface ProfileLocationData {
+ latitude: number;
+ longitude: number;
+ address: string;
+}
diff --git a/apps/web/entities/systemMessage/model/types.ts b/apps/web/entities/systemMessage/model/types.ts
new file mode 100644
index 00000000..c00d2c48
--- /dev/null
+++ b/apps/web/entities/systemMessage/model/types.ts
@@ -0,0 +1,15 @@
+export interface SystemMessage {
+ system_message_id: string;
+ chatroom_id: string;
+ product_image_url: string;
+ bid_user_id: string;
+ exhibit_user_id: string;
+ created_at: string;
+ product_title: string;
+ bid_price: number;
+}
+
+export interface SystemMessageWithNickname extends SystemMessage {
+ bid_user_nickname: string;
+ exhibit_user_nickname: string;
+}
diff --git a/apps/web/features/alarm/setting/lib/getPushAlarmMessage.ts b/apps/web/features/alarm/setting/lib/getPushAlarmMessage.ts
new file mode 100644
index 00000000..21e71711
--- /dev/null
+++ b/apps/web/features/alarm/setting/lib/getPushAlarmMessage.ts
@@ -0,0 +1,130 @@
+import { encodeUUID } from '@/shared/lib/shortUuid';
+
+export type PushAlarmType = 'auction' | 'point' | 'review' | 'chat';
+
+export type AuctionPushAlarmType =
+ | 'bid'
+ | 'bidUpdated'
+ | 'auctionWon'
+ | 'proposalSent'
+ | 'proposalAccepted'
+ | 'proposalRejected'
+ | 'auctionStarted'
+ | 'bidNotification'
+ | 'auctionEndedWon'
+ | 'auctionEndedLost'
+ | 'proposalRequest';
+
+export interface PushAlarmData {
+ productName?: string;
+ productId?: string;
+ price?: number;
+ nickname?: string;
+ level?: string;
+ amount?: number;
+ auctionId?: string;
+ image?: string;
+ chatroomId?: string;
+}
+
+type PushAlarmKey = `${PushAlarmType}:${string}`;
+
+const pushAlarmMessage: Record<
+ PushAlarmKey,
+ (data: PushAlarmData) => { title: string; body: string; link: string; image?: string }
+> = {
+ 'auction:bid': (data) => ({
+ title: '์
์ฐฐ ์๋ฃ',
+ body: `${data.productName}์ ${data.price}์์ผ๋ก ์
์ฐฐ๋์์ต๋๋ค.`,
+ link: `/product/${encodeUUID(data.auctionId!)}`,
+ image: `${data.image}`,
+ }),
+ 'auction:bidUpdated': (data) => ({
+ title: '์
์ฐฐ ๊ธ์ก ๊ฐฑ์ ',
+ body: `${data.productName}์ ์
์ฐฐ ๊ธ์ก์ด ๊ฐฑ์ ๋์์ต๋๋ค. ๋ค์ ์
์ฐฐํด ๋ณด์ธ์`,
+ link: `/product/${encodeUUID(data.auctionId!)}`,
+ image: `${data.image}`,
+ }),
+ 'auction:auctionWon': (data) => ({
+ title: '๊ฒฝ๋งค ๋์ฐฐ ์ฑ๊ณต',
+ body: `์ถํํฉ๋๋ค! ${data.productName}์ ๋์ฐฐ๋ฐ์์ต๋๋ค. ์ง๊ธ ${data.nickname}๋๊ณผ ๋ํ๋ฅผ ์์ํด ๋ณด์ธ์.`,
+ link: data.chatroomId ? `/chat/${encodeUUID(data.chatroomId)}` : '/chat',
+ image: `${data.image}`,
+ }),
+ 'auction:proposalAccepted': (data) => ({
+ title: '์ ์ ์๋ฝ',
+ body: `${data.productName}์ ๋ํ ์ ์์ด ์๋ฝ๋์์ต๋๋ค. ${data.nickname}๋๊ณผ ๋ํ๋ฅผ ์์ํด๋ณด์ธ์.`,
+ link: data.chatroomId ? `/chat/${encodeUUID(data.chatroomId)}` : '/chat',
+ image: `${data.image}`,
+ }),
+ 'auction:auctionStarted': (data) => ({
+ title: '๊ฒฝ๋งค ์์',
+ body: `${data.productName}์ ๊ฒฝ๋งค๊ฐ ์์๋์์ต๋๋ค!`,
+ link: `/auction/${encodeUUID(data.auctionId!)}`,
+ image: `${data.image}`,
+ }),
+ 'auction:bidNotification': (data) => ({
+ title: '์
์ฐฐ ๋ฐ์ ์๋ฆผ',
+ body: `${data.nickname}๋์ด ${data.productName}์ ${data.price}์์ผ๋ก ์
์ฐฐํ์ด์!`,
+ link: `/auction/${encodeUUID(data.auctionId!)}`,
+ image: `${data.image}`,
+ }),
+ 'auction:auctionEndedWon': (data) => ({
+ title: '๊ฒฝ๋งค ์ข
๋ฃ - ๋์ฐฐ',
+ body: `${data.productName}์ ๊ฒฝ๋งค๊ฐ ์ข
๋ฃ ๋์์ต๋๋ค. ${data.nickname}๋๊ณผ ๋ํ๋ฅผ ์์ํด๋ณด์ธ์.`,
+ link: data.chatroomId ? `/chat/${encodeUUID(data.chatroomId)}` : '/chat',
+ image: `${data.image}`,
+ }),
+ 'auction:auctionEndedLost': (data) => ({
+ title: '๊ฒฝ๋งค ์ข
๋ฃ - ์ ์ฐฐ',
+ body: `${data.productName}์ ๊ฒฝ๋งค๊ฐ ์ข
๋ฃ ๋์์ต๋๋ค.`,
+ link: '/auction/listings',
+ image: `${data.image}`,
+ }),
+ 'auction:proposalRequest': (data) => ({
+ title: '์ ์ ์์ฒญ ํ์ธ',
+ body: `${data.nickname}๋์ด ${data.productName}์ ${data.price}์์ผ๋ก ์ ์ํ์ด์! ์ ์์ ํ์ธํด ๋ณด์ธ์.`,
+ link: `/mypage/proposal/received`,
+ image: `${data.image}`,
+ }),
+
+ // ํฌ์ธํธ
+ 'point:pointAdded': (data) => ({
+ title: 'ํฌ์ธํธ ์ ๋ฆฝ',
+ body: `${data.amount} ํฌ์ธํธ๊ฐ ์ ๋ฆฝ๋์์ต๋๋ค.`,
+ link: '/mypage/point',
+ image: `${data.image}`,
+ }),
+
+ // ์ฑํ
+ 'chat:newMessage': (data) => ({
+ title: '์ ๋ฉ์์ง ๋์ฐฉ',
+ body: `${data.nickname}๋์ด ๋ฉ์์ง๋ฅผ ๋ณด๋์ต๋๋ค. ํ์ธํด๋ณด์ธ์.`,
+ link: data.chatroomId ? `/chat/${encodeUUID(data.chatroomId)}` : '/chat',
+ image: `${data.image}`,
+ }),
+};
+
+export function getPushAlarmMessage(type: PushAlarmType, subType: string, data: PushAlarmData) {
+ const key = `${type}:${subType}` as PushAlarmKey;
+ const generator = pushAlarmMessage[key];
+
+ if (data.image) {
+ }
+
+ if (!generator) {
+ return {
+ title: '์๋ฆผ',
+ body: '์๋ก์ด ์๋ฆผ์ด ๋์ฐฉํ์ต๋๋ค.',
+ link: '/',
+ image: '',
+ };
+ }
+ const result = generator(data);
+ return {
+ title: result.title,
+ body: result.body,
+ link: result.link,
+ ...(result.image ? { image: result.image } : {}),
+ };
+}
diff --git a/apps/web/features/alarm/setting/model/useCreatePushToken.ts b/apps/web/features/alarm/setting/model/useCreatePushToken.ts
new file mode 100644
index 00000000..823e800c
--- /dev/null
+++ b/apps/web/features/alarm/setting/model/useCreatePushToken.ts
@@ -0,0 +1,62 @@
+import { subscribeUser } from '@/app/actions';
+import { useEffect, useState } from 'react';
+
+function urlBase64ToUint8Array(base64String: string) {
+ const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
+ const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
+
+ const rawData = window.atob(base64);
+ const outputArray = new Uint8Array(rawData.length);
+
+ for (let i = 0; i < rawData.length; ++i) {
+ outputArray[i] = rawData.charCodeAt(i);
+ }
+ return outputArray;
+}
+
+export const useCreatePushToken = (isChecked: boolean) => {
+ const [isSupported, setIsSupported] = useState(false);
+ const [subscription, setSubscription] = useState(null);
+ const [message, setMessage] = useState('');
+
+ useEffect(() => {
+ if (!isChecked) return;
+
+ if ('serviceWorker' in navigator && 'PushManager' in window) {
+ setIsSupported(true);
+ registerServiceWorker();
+ }
+
+ async function registerServiceWorker() {
+ try {
+ const registration = await navigator.serviceWorker.register('/sw.js', {
+ scope: '/',
+ updateViaCache: 'none',
+ });
+
+ const sub = await registration.pushManager.getSubscription();
+
+ if (sub === null) {
+ const subscription = await registration.pushManager.subscribe({
+ userVisibleOnly: true,
+ applicationServerKey: urlBase64ToUint8Array(process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!),
+ });
+
+ setSubscription(subscription);
+
+ const serializedSub = JSON.parse(JSON.stringify(subscription));
+ await subscribeUser(serializedSub);
+ } else {
+ setSubscription(sub);
+ const serializedSub = JSON.parse(JSON.stringify(sub));
+ await subscribeUser(serializedSub);
+ }
+ } catch (err) {
+ console.error('์๋น์ค ๋ฑ๋ก ์คํจ:', err);
+ setMessage('ํธ์ ์๋ฆผ ๋ฑ๋ก ์คํจ');
+ }
+ }
+ }, [isChecked]);
+
+ return { isSupported, subscription, message };
+};
diff --git a/apps/web/features/alarm/setting/useAlarmSetting.ts b/apps/web/features/alarm/setting/useAlarmSetting.ts
new file mode 100644
index 00000000..bc0d55c7
--- /dev/null
+++ b/apps/web/features/alarm/setting/useAlarmSetting.ts
@@ -0,0 +1,23 @@
+import { useState } from 'react';
+
+export const useAlarmSetting = () => {
+ const [isAuctionChecked, setIsAuctionChecked] = useState(true);
+ const [isChatChecked, setIsChatChecked] = useState(true);
+ const [isPointChecked, setIsPointChecked] = useState(true);
+ const [isGradeChecked, setIsGradeChecked] = useState(true);
+ const [isStarChecked, setIsStarChecked] = useState(true);
+
+ return {
+ isAuctionChecked,
+ isChatChecked,
+ isPointChecked,
+ isGradeChecked,
+ isStarChecked,
+
+ setIsAuctionChecked,
+ setIsChatChecked,
+ setIsPointChecked,
+ setIsGradeChecked,
+ setIsStarChecked,
+ };
+};
diff --git a/apps/web/features/alarm/type/type.ts b/apps/web/features/alarm/type/type.ts
new file mode 100644
index 00000000..4bd736d9
--- /dev/null
+++ b/apps/web/features/alarm/type/type.ts
@@ -0,0 +1,9 @@
+export type AlarmItem = {
+ id: number;
+ user_id: string;
+ contents: string;
+ time: string;
+ image: string;
+ isRead: boolean;
+ link: string;
+};
diff --git a/apps/web/features/alarm/useAlarmList.ts b/apps/web/features/alarm/useAlarmList.ts
new file mode 100644
index 00000000..e46465be
--- /dev/null
+++ b/apps/web/features/alarm/useAlarmList.ts
@@ -0,0 +1,132 @@
+'use client';
+import { createClient } from '@/shared/lib/supabase/client';
+import { useEffect, useState } from 'react';
+import { AlarmItem } from './type/type';
+import { useRouter } from 'next/navigation';
+
+export const useAlarmList = () => {
+ const [alarms, setAlarms] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const router = useRouter();
+
+ useEffect(() => {
+ async function fetchAlarms() {
+ setIsLoading(true);
+
+ const supabase = createClient();
+
+ const {
+ data: { user },
+ error: userError,
+ } = await supabase.auth.getUser();
+
+ if (userError || !user) {
+ console.error('์ ์ ์ ๋ณด๋ฅผ ๊ฐ์ ธ์ค๋ ๋ฐ ์คํจํ์ต๋๋ค.', userError);
+ setIsLoading(false);
+ return;
+ }
+ const userId = user.id;
+
+ const { data: alarmData, error } = await supabase
+ .from('alarm')
+ .select('*')
+ .eq(`user_id`, userId)
+ .order('create_at', { ascending: false });
+
+ if (error) {
+ console.error('์๋ฆผ ๊ฐ์ ธ์ค๊ธฐ ์คํจ:', error);
+ return;
+ } else {
+ const formatted = (alarmData ?? []).map((item) => ({
+ id: item.alarm_id,
+ user_id: item.user_id,
+ contents: item.body,
+ time: getTimeDiff(item.create_at),
+ // ์ด๋ฏธ์ง URL ์ฒ๋ฆฌ ๊ฐ์
+ image: getImageUrl(item.image_url) ?? '',
+ isRead: item.is_read ?? false,
+ link: item.link,
+ }));
+
+ setAlarms(formatted);
+ }
+
+ setIsLoading(false);
+ }
+
+ fetchAlarms();
+ }, []);
+
+ //์๋ฆผ ์ฝ์ ์ฒ๋ฆฌ
+ const handleAlarmClick = async (alarmId: number, link: string) => {
+ // UI ์ฆ์ ์
๋ฐ์ดํธ
+ setAlarms((prev) =>
+ prev.map((alarm) => (alarm.id === alarmId ? { ...alarm, isRead: true } : alarm))
+ );
+
+ markAlarmAsRead(alarmId);
+
+ if (link && link.trim()) {
+ router.push(link);
+ }
+ };
+
+ // ๊ฐ๋ณ ์๋ฆผ ์ญ์
+ const handleAlarmDelete = async (alarmId: number) => {
+ try {
+ setAlarms((prev) => prev.filter((alarm) => alarm.id !== alarmId));
+
+ const supabase = createClient();
+ const { error } = await supabase.from('alarm').delete().eq('alarm_id', alarmId);
+
+ if (error) {
+ console.error('์๋ฆผ ์ญ์ ์คํจ:', error);
+ }
+ } catch (error) {
+ console.error('์๋ฆผ ์ญ์ ์ค ์ค๋ฅ:', error);
+ }
+ };
+
+ return { alarms, isLoading, handleAlarmClick, handleAlarmDelete };
+};
+
+function getImageUrl(imageUrl: string | null | undefined): string | null {
+ if (!imageUrl || !imageUrl.trim()) {
+ return null;
+ }
+
+ const trimmedUrl = imageUrl.trim();
+
+ if (trimmedUrl.startsWith('http://') || trimmedUrl.startsWith('https://')) {
+ return trimmedUrl;
+ }
+
+ return null;
+}
+
+function getTimeDiff(createdAt: string): string {
+ const now = new Date();
+ const created = new Date(createdAt);
+ const diff = Math.floor((now.getTime() - created.getTime()) / 1000);
+
+ if (diff < 60) return `${diff}์ด ์ `;
+ if (diff < 3600) return `${Math.floor(diff / 60)}๋ถ ์ `;
+ if (diff < 86400) return `${Math.floor(diff / 3600)}์๊ฐ ์ `;
+ return `${Math.floor(diff / 86400)}์ผ ์ `;
+}
+
+async function markAlarmAsRead(alarmId: number) {
+ try {
+ const supabase = createClient();
+ const { error } = await supabase
+ .from('alarm')
+ .update({ is_read: true })
+ .eq('alarm_id', alarmId);
+
+ if (error) {
+ console.error('์๋ฆผ ์ฝ์ ์ฒ๋ฆฌ ์คํจ:', error);
+ }
+ } catch (error) {
+ console.error('์๋ฆผ ์ฝ์ ์ฒ๋ฆฌ ์ค ์ค๋ฅ:', error);
+ }
+}
diff --git a/apps/web/features/auction/bids/api/doBid.ts b/apps/web/features/auction/bids/api/doBid.ts
new file mode 100644
index 00000000..d5a01a48
--- /dev/null
+++ b/apps/web/features/auction/bids/api/doBid.ts
@@ -0,0 +1,19 @@
+import { BidRequest, BidResponse } from '@/features/auction/bids/types';
+
+export const submitBid = async (shortId: string, bidData: BidRequest): Promise => {
+ const response = await fetch(`/api/auction/${shortId}`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(bidData),
+ });
+
+ const result = await response.json();
+
+ if (!response.ok) {
+ throw new Error(result.error || '์
์ฐฐ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค.');
+ }
+
+ return result;
+};
diff --git a/apps/web/features/auction/bids/index.tsx b/apps/web/features/auction/bids/index.tsx
deleted file mode 100644
index afdcc458..00000000
--- a/apps/web/features/auction/bids/index.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import { Tabs } from '@repo/ui/components/Tabs/Tabs';
-import AuctionTopTabs from '@/features/auction/shared/ui/AuctionTaopTabs';
-import BidList from '@/features/auction/bids/ui/BidList';
-
-//์
์ฐฐ ๋ด์ญ
-
-const AuctionBids = () => {
- const items = [
- { value: 'all', label: '์ ์ฒด', content: },
- { value: 'progress', label: '๊ฒฝ๋งค ์งํ ์ค', content: },
- { value: 'win', label: '๋์ฐฐ', content: },
- { value: 'fail', label: 'ํจ์ฐฐ', content: },
- ];
-
- return (
-
- );
-};
-export default AuctionBids;
diff --git a/apps/web/features/auction/bids/lib/utils.ts b/apps/web/features/auction/bids/lib/utils.ts
new file mode 100644
index 00000000..30c274cb
--- /dev/null
+++ b/apps/web/features/auction/bids/lib/utils.ts
@@ -0,0 +1,31 @@
+import { formatNumberWithComma } from '@/shared/lib/formatNumberWithComma';
+
+export const formatBidPrice = (price: string): string => {
+ const numericOnly = price.replace(/\D/g, '');
+ return formatNumberWithComma(numericOnly);
+};
+
+export const parseBidPrice = (price: string): number => {
+ return Number(price.replace(/,/g, ''));
+};
+
+export const validateBidPrice = (price: string): boolean => {
+ const numericPrice = parseBidPrice(price);
+ return numericPrice > 0;
+};
+
+export const getInitialBidPrice = (lastPrice: number | null): string => {
+ return formatNumberWithComma(Number(lastPrice) + 1000);
+};
+
+export const formatBidDate = (dateString: string): string => {
+ const date = new Date(dateString);
+
+ const year = String(date.getFullYear()).slice(2);
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ const hours = String(date.getHours()).padStart(2, '0');
+ const minutes = String(date.getMinutes()).padStart(2, '0');
+
+ return `${year}/${month}/${day} ${hours}:${minutes}`;
+};
diff --git a/apps/web/features/auction/bids/model/bidStore.ts b/apps/web/features/auction/bids/model/bidStore.ts
new file mode 100644
index 00000000..72302e44
--- /dev/null
+++ b/apps/web/features/auction/bids/model/bidStore.ts
@@ -0,0 +1,18 @@
+import { create } from 'zustand';
+
+type BidInfo = {
+ bidId: string;
+ title: string;
+ bid_end_at: string;
+ bid_price: number;
+};
+
+type BidStore = {
+ bidInfo: BidInfo | null;
+ setBidInfo: (info: BidInfo | null) => void;
+};
+
+export const useBidStore = create((set) => ({
+ bidInfo: null,
+ setBidInfo: (info) => set({ bidInfo: info }),
+}));
diff --git a/apps/web/features/auction/bids/model/getBidList.ts b/apps/web/features/auction/bids/model/getBidList.ts
index d35ad58d..a3b9c95d 100644
--- a/apps/web/features/auction/bids/model/getBidList.ts
+++ b/apps/web/features/auction/bids/model/getBidList.ts
@@ -1,99 +1,33 @@
-import { supabase } from '@/shared/lib/supabaseClient';
-import { ProductForList } from '@/features/product/types';
+import { ProductList } from '@/features/product/types';
+import { BidDataWithStats, BidListParams } from '@/features/auction/bids/types';
-interface BidListParams {
- filter: 'all' | 'progress' | 'win' | 'fail';
- userId: string;
-}
-
-interface BidData {
- bid_id: string;
- bid_price: number;
- is_awarded: boolean;
- bid_user_id: string;
- bid_at: string;
-
- auction: {
- auction_id: string;
- auction_status: string;
- auction_end_at: string;
- min_price: number;
- winning_bid_user_id: string | null;
-
- product: {
- product_id: string;
- title: string;
- category: string | null;
- exhibit_user_id: string;
- description: string;
- latitude: number;
- longitude: number;
- address: string | null;
-
- product_image: {
- image_url: string;
- order_index: number;
- }[];
- };
- };
-}
-
-const getBidList = async (params: BidListParams): Promise => {
+const getBidList = async (params: BidListParams): Promise => {
const { filter, userId } = params;
- const res = await fetch(`/api/auction/bids?userId=${userId}`);
+ const res = await fetch(`/api/auction/bids?userId=${userId}&filter=${filter}`);
const result = await res.json();
if (!res.ok || !result.success) {
- throw new Error(result.error || 'Failed to fetch product list');
- }
-
- const typedData: BidData[] = result.data;
-
- const filtered = typedData.filter((item) => {
- const { auction } = item;
- const { product } = auction;
-
- if (product.latitude == null || product.longitude == null) return false;
-
- const isProgress = filter === 'progress' && auction.auction_status === '๊ฒฝ๋งค ์ค';
- const isWin =
- filter === 'win' && auction.auction_status === '๊ฒฝ๋งค ์ข
๋ฃ' && item.is_awarded === true;
- const isFail =
- filter === 'fail' && auction.auction_status === '๊ฒฝ๋งค ์ข
๋ฃ' && item.is_awarded === false;
-
- return filter === 'all' || isProgress || isWin || isFail;
- });
-
- const auctionIds = filtered.map((item) => item.auction.auction_id);
-
- const { data: bidCountRaw } = await supabase
- .from('bid_history')
- .select('auction_id, bid_id')
- .in('auction_id', auctionIds);
-
- const bidCountMap: Record = {};
- for (const item of bidCountRaw ?? []) {
- const auctionId = item.auction_id;
- bidCountMap[auctionId] = (bidCountMap[auctionId] ?? 0) + 1;
+ throw new Error(result.error || '๋ฐ์ดํฐ ๋ก๋ฉ ์คํจ');
}
- return filtered.map((item) => ({
+ return result.data.map((item: BidDataWithStats) => ({
id: item.auction.auction_id,
thumbnail:
item.auction.product.product_image.find((img) => img.order_index === 0)?.image_url ??
'/default.png',
title: item.auction.product.title,
address: item.auction.product.address ?? '์์น ์ ๋ณด ์์',
- bidCount: bidCountMap[item.auction.auction_id] ?? 0,
- price: item.bid_price,
- minPrice: item.auction.min_price ?? 0,
+ bidCount: item.bidCount,
+ minPrice: item.maxPrice,
+ myBidPrice: item.bid_price,
auctionEndAt: item.auction.auction_end_at,
auctionStatus: item.auction.auction_status,
winnerId: item.auction.winning_bid_user_id ?? null,
sellerId: item.auction.product.exhibit_user_id ?? '',
isAwarded: item.is_awarded,
productId: item.auction.product.product_id,
+ isSecret: item.auction.is_secret,
}));
};
diff --git a/apps/web/features/auction/bids/model/useBidSubmit.ts b/apps/web/features/auction/bids/model/useBidSubmit.ts
new file mode 100644
index 00000000..1b979791
--- /dev/null
+++ b/apps/web/features/auction/bids/model/useBidSubmit.ts
@@ -0,0 +1,69 @@
+import { useAuthStore } from '@/shared/model/authStore';
+import { toast } from '@repo/ui/components/Toast/Sonner';
+import { parseBidPrice, validateBidPrice } from '@/features/auction/bids/lib/utils';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { useBidStore } from '@/features/auction/bids/model/bidStore';
+import { useRouter } from 'next/navigation';
+import { BidResponse, SubmitBidContext } from '@/features/auction/bids/types';
+import { submitBid } from '@/features/auction/bids/api/doBid';
+
+export const useBidSubmit = (shortId: string) => {
+ const queryClient = useQueryClient();
+ const user = useAuthStore();
+ const router = useRouter();
+ const { setBidInfo } = useBidStore();
+ const mutation = useMutation({
+ mutationFn: async (biddingPrice: string) => {
+ const bidPriceNumber = parseBidPrice(biddingPrice);
+
+ if (!validateBidPrice(biddingPrice)) {
+ throw new Error('์ฌ๋ฐ๋ฅธ ์
์ฐฐ๊ฐ๋ฅผ ์
๋ ฅํด์ฃผ์ธ์.');
+ }
+
+ if (bidPriceNumber > 2147483647) {
+ throw new Error('2,147,483,647์ ์ดํ๋ก๋ง ์
์ฐฐํ ์ ์์ต๋๋ค.');
+ }
+
+ if (!user.user?.id) {
+ throw new Error('๋ก๊ทธ์ธ์ด ํ์ํฉ๋๋ค.');
+ }
+
+ return submitBid(shortId, {
+ bidPrice: bidPriceNumber,
+ userId: user.user.id,
+ });
+ },
+ onSuccess: (result, _, context) => {
+ queryClient.invalidateQueries({
+ queryKey: ['auctionDetail', shortId],
+ });
+
+ const bidData = result.bidData;
+ if (bidData) {
+ setBidInfo({
+ bidId: bidData.bid_id,
+ title: bidData.product_title,
+ bid_end_at: bidData.bid_end_at,
+ bid_price: bidData.bid_price,
+ });
+ }
+ toast({ content: result.message || '์
์ฐฐ์ด ์๋ฃ๋์์ต๋๋ค!' });
+ context?.onSuccess?.();
+ router.push('/bid/complete');
+ },
+ onError: (error) => {
+ const errorMessage =
+ error instanceof Error ? error.message : '๋คํธ์ํฌ ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค. ๋ค์ ์๋ํด์ฃผ์ธ์.';
+ toast({ content: errorMessage });
+ },
+ });
+
+ const onSubmitBid = (biddingPrice: string) => {
+ mutation.mutate(biddingPrice);
+ };
+
+ return {
+ onSubmitBid,
+ isSubmitting: mutation.isPending,
+ };
+};
diff --git a/apps/web/features/auction/bids/model/useGetBidList.ts b/apps/web/features/auction/bids/model/useGetBidList.ts
index dee07013..e506766a 100644
--- a/apps/web/features/auction/bids/model/useGetBidList.ts
+++ b/apps/web/features/auction/bids/model/useGetBidList.ts
@@ -1,15 +1,11 @@
-import { ProductForList } from '@/features/product/types';
+import { ProductList } from '@/features/product/types';
import { useQuery } from '@tanstack/react-query';
import getBidList from '@/features/auction/bids/model/getBidList';
+import { BidListParams } from '@/features/auction/bids/types';
-interface useGetBidListParams {
- userId: string;
- filter: 'all' | 'progress' | 'win' | 'fail';
-}
-
-export const useGetBidList = (params: useGetBidListParams) => {
- return useQuery({
- queryKey: ['BidList', params.userId, params.filter],
+export const useGetBidList = (params: BidListParams) => {
+ return useQuery({
+ queryKey: ['bidList', params.userId, params.filter],
queryFn: () => getBidList(params),
enabled: !!params.userId,
staleTime: 1000 * 60 * 1,
diff --git a/apps/web/features/auction/bids/types/index.ts b/apps/web/features/auction/bids/types/index.ts
new file mode 100644
index 00000000..539aee40
--- /dev/null
+++ b/apps/web/features/auction/bids/types/index.ts
@@ -0,0 +1,72 @@
+import { SecretBidPrice } from '@/features/auction/list/types';
+
+export interface BidDialogProps {
+ shortId: string;
+ auctionEndAt: string | Date;
+ title: string;
+ lastPrice: number | null;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ isSecret: boolean;
+ minPrice: number;
+}
+
+export interface BidRequest {
+ bidPrice: number;
+ userId: string;
+}
+
+export interface BidResponse {
+ message?: string;
+ error?: string;
+ bidData: {
+ bid_id: string;
+ bid_price: number;
+ bid_at: string;
+ product_title: string;
+ bid_end_at: string;
+ };
+}
+
+export type SubmitBidContext = {
+ onSuccess?: () => void;
+};
+
+export interface BidListParams {
+ userId: string;
+ filter: 'all' | 'progress' | 'win' | 'fail';
+}
+export interface BidData {
+ bid_id: string;
+ is_awarded: boolean;
+ bid_price: number;
+
+ auction: {
+ auction_id: string;
+ auction_status: string;
+ auction_end_at: string;
+ winning_bid_user_id: string | null;
+ is_secret: boolean;
+ product: {
+ product_id: string;
+ title: string;
+ exhibit_user_id: string;
+ latitude: number;
+ longitude: number;
+ address: string | null;
+
+ product_image: {
+ image_url: string;
+ order_index: number;
+ }[];
+ };
+ };
+}
+export interface BidDataWithStats extends BidData {
+ bidCount: number;
+ maxPrice: number | SecretBidPrice;
+}
+
+export interface AuctionBidTabsProps {
+ userId: string;
+}
diff --git a/apps/web/features/auction/bids/ui/AuctionBids.tsx b/apps/web/features/auction/bids/ui/AuctionBids.tsx
new file mode 100644
index 00000000..3290945c
--- /dev/null
+++ b/apps/web/features/auction/bids/ui/AuctionBids.tsx
@@ -0,0 +1,24 @@
+import AuctionTopTabs from '@/features/auction/shared/ui/AuctionTopTabs';
+import { createClient } from '@/shared/lib/supabase/server';
+import ReactQueryProvider from '@/shared/providers/ReactQueryProvider';
+import AuctionBidsTabs from '@/features/auction/bids/ui/AuctionBidsTabs';
+
+const AuctionBids = async () => {
+ const supabase = await createClient();
+ const {
+ data: { session },
+ } = await supabase.auth.getSession();
+ const userId = session?.user.id;
+
+ if (!userId) return;
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+};
+export default AuctionBids;
diff --git a/apps/web/features/auction/bids/ui/AuctionBidsTabs.tsx b/apps/web/features/auction/bids/ui/AuctionBidsTabs.tsx
new file mode 100644
index 00000000..eb6ace9d
--- /dev/null
+++ b/apps/web/features/auction/bids/ui/AuctionBidsTabs.tsx
@@ -0,0 +1,49 @@
+'use client';
+
+import { Tabs } from '@repo/ui/components/Tabs/Tabs';
+import Loading from '@/shared/ui/Loading/Loading';
+import { Suspense, useState } from 'react';
+import { useRouter, useSearchParams } from 'next/navigation';
+import { AuctionBidTabsProps } from '@/features/auction/bids/types';
+import BidList from '@/features/auction/bids/ui/BidList';
+
+const AuctionBidsTabs = ({ userId }: AuctionBidTabsProps) => {
+ const searchParams = useSearchParams();
+ const router = useRouter();
+
+ const tabParam = searchParams.get('tab');
+ const [currentTab, setCurrentTab] = useState(tabParam ?? 'all');
+
+ const handleTabChange = (value: string) => {
+ setCurrentTab(value);
+ const url = new URL(window.location.href);
+ url.searchParams.set('tab', value);
+ router.push(url.toString(), { scroll: false });
+ };
+
+ const items = [
+ { value: 'all', label: '์ ์ฒด', content: },
+ {
+ value: 'progress',
+ label: '๊ฒฝ๋งค ์ค',
+ content: ,
+ },
+ { value: 'win', label: '๋์ฐฐ', content: },
+ { value: 'fail', label: 'ํจ์ฐฐ', content: },
+ ];
+
+ return (
+ <>
+ }>
+
+
+ >
+ );
+};
+
+export default AuctionBidsTabs;
diff --git a/apps/web/features/auction/bids/ui/BidCompleteView.tsx b/apps/web/features/auction/bids/ui/BidCompleteView.tsx
new file mode 100644
index 00000000..5daf967b
--- /dev/null
+++ b/apps/web/features/auction/bids/ui/BidCompleteView.tsx
@@ -0,0 +1,52 @@
+'use client';
+
+import { useBidStore } from '@/features/auction/bids/model/bidStore';
+import { Button } from '@repo/ui/components/Button/Button';
+import { Check } from 'lucide-react';
+import React from 'react';
+import { formatNumberWithComma } from '@/shared/lib/formatNumberWithComma';
+import { useRouter } from 'next/navigation';
+import { formatBidDate } from '@/features/auction/bids/lib/utils';
+
+const BidCompleteView = () => {
+ const { bidInfo } = useBidStore();
+ const router = useRouter();
+
+ if (!bidInfo) {
+ return ์
์ฐฐ ์ ๋ณด๊ฐ ์์ต๋๋ค.
;
+ }
+
+ const { title, bid_price, bid_end_at } = bidInfo;
+
+ return (
+
+
+
+
์
์ฐฐ์ด ์๋ฃ๋์์ต๋๋ค.
+
+
+
+
+
์
์ฐฐ ๋ง๊ฐ ์ผ์
+
+ {formatBidDate(bid_end_at)}
+
+
+
+
๋ด ์
์ฐฐ๊ฐ
+
+ {formatNumberWithComma(bid_price)}์
+
+
+
+
+
+ );
+};
+
+export default BidCompleteView;
diff --git a/apps/web/features/auction/bids/ui/BidDialog.tsx b/apps/web/features/auction/bids/ui/BidDialog.tsx
new file mode 100644
index 00000000..cc4d3d2e
--- /dev/null
+++ b/apps/web/features/auction/bids/ui/BidDialog.tsx
@@ -0,0 +1,97 @@
+'use client';
+
+import React, { useState, useEffect } from 'react';
+import { Dialog, DialogHeader, DialogTitle } from '@repo/ui/components/Dialog/Dialog';
+import { Input } from '@repo/ui/components/Input/Input';
+import { Button } from '@repo/ui/components/Button/Button';
+import { getCountdown } from '@/shared/lib/getCountdown';
+import { formatNumberWithComma } from '@/shared/lib/formatNumberWithComma';
+import { BidDialogProps } from '@/features/auction/bids/types';
+import { formatBidPrice, getInitialBidPrice } from '@/features/auction/bids/lib/utils';
+import { useBidSubmit } from '@/features/auction/bids/model/useBidSubmit';
+
+export const BidDialog = ({
+ shortId,
+ auctionEndAt,
+ title,
+ lastPrice,
+ open,
+ onOpenChange,
+ isSecret,
+ minPrice,
+}: BidDialogProps) => {
+ const initialPrice = isSecret ? minPrice : lastPrice;
+ const [biddingPrice, setBiddingPrice] = useState(getInitialBidPrice(initialPrice));
+ const [countdown, setCountdown] = useState('');
+
+ const { onSubmitBid, isSubmitting } = useBidSubmit(shortId);
+
+ useEffect(() => {
+ const update = () => setCountdown(getCountdown(auctionEndAt));
+ update();
+ const timer = setInterval(update, 1000);
+ return () => clearInterval(timer);
+ }, [auctionEndAt]);
+
+ const handleBiddingPriceChange = (e: React.ChangeEvent) => {
+ const raw = e.target.value;
+ const formatted = formatBidPrice(raw);
+ setBiddingPrice(formatted);
+ };
+
+ const handleBidSubmit = async () => {
+ await onSubmitBid(biddingPrice);
+ };
+
+ return (
+
+ );
+};
diff --git a/apps/web/features/auction/bids/ui/BidList.tsx b/apps/web/features/auction/bids/ui/BidList.tsx
index 6d51f748..2f742730 100644
--- a/apps/web/features/auction/bids/ui/BidList.tsx
+++ b/apps/web/features/auction/bids/ui/BidList.tsx
@@ -2,25 +2,29 @@
import ProductList from '@/features/product/ui/ProductList';
import { useGetBidList } from '@/features/auction/bids/model/useGetBidList';
-import { useAuthStore } from '@/shared/model/authStore';
-import Loading from '@/shared/ui/Loading/Loading';
+import Skeleton from '@/features/product/ui/Skeleton';
+import { BidListParams } from '@/features/auction/bids/types';
-interface BidListProps {
- filter: 'all' | 'progress' | 'win' | 'fail';
-}
+const BidList = ({ userId, filter }: BidListParams) => {
+ const { data, isLoading, error } = useGetBidList({ userId, filter });
-const BidList = ({ filter }: BidListProps) => {
- const userId = useAuthStore((state) => state.user?.id) as string;
-
- const { data, isLoading, error } = useGetBidList({
- userId,
- filter,
- });
-
- if (isLoading || error || !data) {
- return ;
+ if (isLoading) {
+ return (
+
+
+
+
+
+ );
}
+ if (error)
+ return (
+ ๋ด์ญ์ ์ฐพ์ ์ ์์ต๋๋ค.
+ );
+ if (!data || data.length === 0)
+ return ๋ด์ญ์ด ์์ต๋๋ค.
;
+
return (
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..a1d0d10f
--- /dev/null
+++ b/apps/web/features/auction/detail/api/getAuctionDetail.ts
@@ -0,0 +1,15 @@
+import { AuctionDetail } from '@/entities/auction/model/types';
+
+export const getAuctionDetail = async (shortId: string): Promise
=> {
+ const baseURL = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
+
+ const res = await fetch(`${baseURL}/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/api/useBidHistoryRealtime.ts b/apps/web/features/auction/detail/api/useBidHistoryRealtime.ts
new file mode 100644
index 00000000..3d6bd6fb
--- /dev/null
+++ b/apps/web/features/auction/detail/api/useBidHistoryRealtime.ts
@@ -0,0 +1,51 @@
+import { useEffect } from 'react';
+import { anonSupabase } from '@/shared/lib/supabaseClient';
+import { BidHistory, BidHistoryWithUserNickname } from '@/entities/bidHistory/model/types';
+
+export function useBidHistoryRealtime({
+ auctionId,
+ onNewBid,
+}: {
+ auctionId: string;
+ onNewBid: (newBid: BidHistoryWithUserNickname) => void;
+}) {
+ useEffect(() => {
+ const channel = anonSupabase
+ .channel('bid_history_changes')
+ .on(
+ 'postgres_changes',
+ {
+ event: 'INSERT',
+ schema: 'public',
+ table: 'bid_history',
+ filter: `auction_id=eq.${auctionId}`,
+ },
+ async (payload) => {
+ const newBid = payload.new;
+
+ const { data: profiles, error } = await anonSupabase
+ .from('profiles')
+ .select('nickname')
+ .eq('user_id', newBid.bid_user_id)
+ .limit(1)
+ .single();
+
+ if (error || !profiles) return;
+
+ const newBidWithNickname: BidHistoryWithUserNickname = {
+ ...(newBid as BidHistory),
+ bid_user_nickname: {
+ nickname: profiles.nickname,
+ },
+ };
+
+ onNewBid(newBidWithNickname);
+ }
+ )
+ .subscribe();
+
+ return () => {
+ channel.unsubscribe();
+ };
+ }, [auctionId, onNewBid]);
+}
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..71c68fcf
--- /dev/null
+++ b/apps/web/features/auction/detail/model/useAuctionDetail.ts
@@ -0,0 +1,13 @@
+import { useQuery } from '@tanstack/react-query';
+import { AuctionDetail } from '@/entities/auction/model/types';
+import { getAuctionDetail } from '@/features/auction/detail/api/getAuctionDetail';
+
+export const useAuctionDetail = (shortId: string) => {
+ return useQuery({
+ queryKey: ['auctionDetail', shortId],
+ queryFn: () => getAuctionDetail(shortId),
+ enabled: !!shortId,
+ refetchOnMount: 'always',
+ refetchOnWindowFocus: true,
+ });
+};
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..6d1264bc
--- /dev/null
+++ b/apps/web/features/auction/detail/types/index.ts
@@ -0,0 +1,44 @@
+import { ProductImage } from '@/entities/productImage/model/types';
+import { Profiles } from '@/entities/profiles/model/types';
+import { BidHistoryWithUserNickname } from '@/entities/bidHistory/model/types';
+import { Location } from '@/features/location/types';
+
+export interface AuctionDetailContent {
+ auctionId: string;
+ productTitle: string;
+ productCategory: string;
+ productDescription: string;
+ images: ProductImage[];
+ minPrice: number;
+ auctionEndAt: string;
+ auctionStatus: string;
+ exhibitUser: Profiles;
+ currentHighestBid: number | null;
+ bidHistory: BidHistoryWithUserNickname[];
+ dealLocation?: Location;
+ dealAddress?: string;
+ isSecret: boolean;
+ bidCnt: number;
+}
+
+export type AuctionDetailContentProps = {
+ data: AuctionDetailContent;
+ isProductMine: boolean;
+};
+
+export interface BottomBarProps {
+ shortId: string;
+ auctionEndAt: string | Date;
+ title: string;
+ exhibitUser: Profiles;
+ lastPrice: number | null;
+ isSecret: boolean;
+ minPrice: number;
+}
+
+export interface BiddingStatusBoardProps {
+ data: BidHistoryWithUserNickname[];
+ auctionId: string;
+ onNewHighestBid?: (price: number) => void;
+ isSecret?: boolean;
+}
diff --git a/apps/web/features/auction/detail/ui/AuctionDetail.tsx b/apps/web/features/auction/detail/ui/AuctionDetail.tsx
new file mode 100644
index 00000000..64dd994c
--- /dev/null
+++ b/apps/web/features/auction/detail/ui/AuctionDetail.tsx
@@ -0,0 +1,132 @@
+import { formatNumberWithComma } from '@/shared/lib/formatNumberWithComma';
+import { Avatar } from '@repo/ui/components/Avatar/Avatar';
+import { AlarmClock, Info, PencilLine } from 'lucide-react';
+import React, { useState } from 'react';
+import { formatTimestamptz } from '@/shared/lib/formatTimestamp';
+
+import { getCategoryLabel } from '@/features/category/lib/utils';
+import { CategoryValue } from '@/features/category/types';
+import GoogleMapView from '@/features/location/ui/GoogleMapView';
+
+import { AuctionDetailContentProps } from '@/features/auction/detail/types';
+import SecretBidStatusBoard from '@/features/auction/secret/ui/SecretBiddingStatusBoard';
+import Link from 'next/link';
+import ProposalActionButton from '@/features/auction/detail/ui/ProposalActionButton';
+import BiddingStatusBoard from '@/features/auction/detail/ui/BiddingStatusBoard';
+import { SECRET_PRICE } from '@/features/auction/list/constants';
+
+const AuctionDetail = ({ data, isProductMine }: AuctionDetailContentProps) => {
+ const [currentHighestBid, setCurrentHighestBid] = useState(data.currentHighestBid);
+
+ return (
+ <>
+ {/* ๊ฒฝ๋งค ์ํ ๋ด์ฉ */}
+
+
+
{data.productTitle}
+
+
+ {getCategoryLabel(data.productCategory as CategoryValue)}
+
+
+
+
+
+
+
์ต๊ณ ์
์ฐฐ๊ฐ
+
+ {data.isSecret ? (
+ {SECRET_PRICE}
+ ) : (
+ formatNumberWithComma(currentHighestBid as number)
+ )}
+ ์
+
+
+ {/* ์ ์ํ๊ธฐ */}
+ {!isProductMine && data.auctionStatus !== '๊ฒฝ๋งค ์ข
๋ฃ' && !data.isSecret && (
+
+ )}
+
+
+
+
+
+
{formatNumberWithComma(data.minPrice)}์
+
+
+
+
+
+
์
์ฐฐ ๋ง๊ฐ ์ผ์
+
+
{formatTimestamptz(data.auctionEndAt)}
+
+
+
+
{data.productDescription}
+
+ {/* ๊ฑฐ๋ ํฌ๋ง ์ฅ์ */}
+ {data.dealAddress && data.dealLocation && (
+
+
+
๊ฑฐ๋ ํฌ๋ง ์ฅ์
+
{data.dealAddress}
+
+
+
+
+
+ )}
+
+
+
+
+ {data.isSecret ? (
+ setCurrentHighestBid(newPrice)}
+ />
+ ) : (
+
+
+
์
์ฐฐ ํํฉํ
+
+ ์์ 5๋ฑ๊น์ง๋ง
+ ์กฐํ ๊ฐ๋ฅํฉ๋๋ค.
+
+
+
setCurrentHighestBid(newPrice)}
+ />
+
+ )}
+
+
+
+ {/* ํ๋งค์ ์ ๋ณด */}
+
+
+
+
{data.exhibitUser?.nickname}
+
{data.exhibitUser?.address}
+
+
+ >
+ );
+};
+
+export default AuctionDetail;
diff --git a/apps/web/features/auction/detail/ui/AuctionDetailClient.tsx b/apps/web/features/auction/detail/ui/AuctionDetailClient.tsx
new file mode 100644
index 00000000..5f51e873
--- /dev/null
+++ b/apps/web/features/auction/detail/ui/AuctionDetailClient.tsx
@@ -0,0 +1,62 @@
+'use client';
+
+import Loading from '@/shared/ui/Loading/Loading';
+
+import { useAuthStore } from '@/shared/model/authStore';
+import { AuctionDetailContent } from '@/features/auction/detail/types';
+import { useAuctionDetail } from '@/features/auction/detail/model/useAuctionDetail';
+import ProductImageSlider from '@/features/auction/detail/ui/ProductImageSlider';
+import AuctionDetail from '@/features/auction/detail/ui/AuctionDetail';
+import BottomBar from '@/features/auction/detail/ui/BottomBar';
+
+const AuctionDetailClient = ({ shortId }: { shortId: string }) => {
+ const { data, isLoading, error } = useAuctionDetail(shortId);
+ const user = useAuthStore();
+
+ if (isLoading) return ;
+ if (error) return ์ค๋ฅ: {(error as Error).message}
;
+ if (!data) return ๊ฒฝ๋งค ์ ๋ณด๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค.
;
+
+ const mapped: AuctionDetailContent = {
+ auctionId: data.auction_id,
+ productTitle: data.product?.title,
+ productCategory: data.product?.category,
+ productDescription: data.product?.description,
+ images: data.product?.product_image ?? [],
+ minPrice: data.min_price,
+ auctionEndAt: data.auction_end_at,
+ bidHistory: data.bid_history,
+ exhibitUser: data.product?.exhibit_user,
+ currentHighestBid: data.is_secret ? null : data.current_highest_bid || data.min_price,
+ dealLocation:
+ data.deal_latitude != null && data.deal_longitude != null
+ ? { lat: data.deal_latitude, lng: data.deal_longitude }
+ : undefined,
+ dealAddress: data.deal_address ?? undefined,
+ auctionStatus: data.auction_status,
+ isSecret: data.is_secret,
+ bidCnt: data.bid_cnt,
+ };
+
+ const isProductMine = user.user?.id === mapped.exhibitUser.user_id;
+
+ return (
+
+
+
+ {!isProductMine && (
+
+ )}
+
+ );
+};
+
+export default AuctionDetailClient;
diff --git a/apps/web/features/auction/detail/ui/AuctionDetailPageContent.tsx b/apps/web/features/auction/detail/ui/AuctionDetailPageContent.tsx
new file mode 100644
index 00000000..35bdb52a
--- /dev/null
+++ b/apps/web/features/auction/detail/ui/AuctionDetailPageContent.tsx
@@ -0,0 +1,25 @@
+import React from 'react';
+import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
+import ReactQueryProvider from '@/shared/providers/ReactQueryProvider';
+import { getAuctionDetail } from '@/features/auction/detail/api/getAuctionDetail';
+import AuctionDetailClient from '@/features/auction/detail/ui/AuctionDetailClient';
+
+const AuctionDetailPageContent = async ({ shortId }: { shortId: string }) => {
+ const queryClient = new QueryClient();
+ await queryClient.prefetchQuery({
+ queryKey: ['auctionDetail', shortId],
+ queryFn: () => getAuctionDetail(shortId),
+ });
+
+ const dehydratedState = dehydrate(queryClient);
+
+ return (
+
+
+
+
+
+ );
+};
+
+export default AuctionDetailPageContent;
diff --git a/apps/web/features/auction/detail/ui/BiddingStatusBoard.tsx b/apps/web/features/auction/detail/ui/BiddingStatusBoard.tsx
new file mode 100644
index 00000000..d9124def
--- /dev/null
+++ b/apps/web/features/auction/detail/ui/BiddingStatusBoard.tsx
@@ -0,0 +1,57 @@
+import { Crown } from 'lucide-react';
+import React, { useEffect, useState } from 'react';
+import { formatNumberWithComma } from '@/shared/lib/formatNumberWithComma';
+import { BidHistoryWithUserNickname } from '@/entities/bidHistory/model/types';
+import { BiddingStatusBoardProps } from '@/features/auction/detail/types';
+import { useBidHistoryRealtime } from '@/features/auction/detail/api/useBidHistoryRealtime';
+
+const BiddingStatusBoard = ({
+ data,
+ auctionId,
+ onNewHighestBid,
+ isSecret = false,
+}: BiddingStatusBoardProps) => {
+ const [bidData, setBidData] = useState(data);
+ const [latestBid, setLatestBid] = useState(null);
+ const pointColor = isSecret ? 'text-event' : 'text-main';
+
+ useBidHistoryRealtime({
+ auctionId: auctionId,
+ onNewBid: (newBid) => {
+ setBidData((prev) => [newBid, ...prev]);
+ setLatestBid(newBid);
+ },
+ });
+
+ useEffect(() => {
+ if (!latestBid) return;
+
+ onNewHighestBid?.(latestBid.bid_price);
+ }, [latestBid]);
+
+ if (bidData.length === 0) {
+ return ์์ง ์
์ฐฐ์๊ฐ ์์ต๋๋ค. ์ฒซ ์
์ฐฐ์๊ฐ ๋์ด๋ณด์ธ์!
;
+ }
+ const bidDataLength = isSecret ? 1 : 5;
+
+ return (
+
+ {bidData.slice(0, bidDataLength).map((bid, index) => (
+
+
+ {index === 0 &&
}
+
{bid.bid_user_nickname.nickname}
+
+
{formatNumberWithComma(bid.bid_price)}์
+
+ ))}
+
+ );
+};
+
+export default BiddingStatusBoard;
diff --git a/apps/web/features/auction/detail/ui/BottomBar.tsx b/apps/web/features/auction/detail/ui/BottomBar.tsx
new file mode 100644
index 00000000..6d5c7468
--- /dev/null
+++ b/apps/web/features/auction/detail/ui/BottomBar.tsx
@@ -0,0 +1,97 @@
+'use client';
+
+import { getCountdown } from '@/shared/lib/getCountdown';
+import { Button } from '@repo/ui/components/Button/Button';
+import { MessageSquareMore } from 'lucide-react';
+import React, { useEffect, useState } from 'react';
+import clsx from 'clsx';
+import { BottomBarProps } from '@/features/auction/detail/types';
+import { BidDialog } from '@/features/auction/bids/ui/BidDialog';
+import { useRouter } from 'next/navigation';
+import { getChatRoomLink } from '@/features/chat/room/model/getChatRoomLink';
+import { encodeUUID } from '@/shared/lib/shortUuid';
+
+const BottomBar = ({
+ shortId,
+ auctionEndAt,
+ title,
+ lastPrice,
+ isSecret,
+ minPrice,
+ exhibitUser,
+}: BottomBarProps) => {
+ const router = useRouter();
+ const [countdown, setCountdown] = useState('');
+ const [hasMounted, setHasMounted] = useState(false);
+ const [openBiddingSheet, setOpenBiddingSheet] = useState(false);
+
+ useEffect(() => {
+ setHasMounted(true);
+
+ const update = () => setCountdown(getCountdown(auctionEndAt));
+ update(); // ์ด๊ธฐ ๋ ๋
+ const timer = setInterval(update, 1000);
+
+ return () => clearInterval(timer);
+ }, [auctionEndAt]);
+
+ const linkChatRoom = async () => {
+ const chatRoomShortId = await getChatRoomLink(
+ shortId,
+ encodeUUID(exhibitUser.user_id),
+ 'loginUser'
+ );
+ router.push(`/chat/${chatRoomShortId}`);
+ };
+
+ const buttonText = isSecret ? '์ํฌ๋ฆฟ ์
์ฐฐํ๊ธฐ' : '์
์ฐฐํ๊ธฐ';
+ const bgColorClass = isSecret ? 'bg-event' : 'bg-main';
+ const borderColorClass = isSecret ? 'border-event' : 'border-main';
+ const iconColorClass = isSecret ? 'text-event' : 'text-main';
+
+ return (
+
+
+
+
์
์ฐฐ ๋ง๊ฐ ์๊ฐ
+ {!hasMounted ? (
+
-
+ ) : (
+
{countdown}
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default BottomBar;
diff --git a/apps/web/features/auction/detail/ui/ProductImageSlider.tsx b/apps/web/features/auction/detail/ui/ProductImageSlider.tsx
new file mode 100644
index 00000000..c8b68d15
--- /dev/null
+++ b/apps/web/features/auction/detail/ui/ProductImageSlider.tsx
@@ -0,0 +1,97 @@
+'use client';
+
+import React, { useRef, useState } from 'react';
+import { Swiper, SwiperSlide } from 'swiper/react';
+import { Pagination } from 'swiper/modules';
+import 'swiper/css';
+import 'swiper/css/pagination';
+import type { Swiper as SwiperType } from 'swiper';
+import { ProductImage } from '@/entities/productImage/model/types';
+import Image from 'next/image';
+import ImageViewer from '@/shared/lib/ImageViewer';
+
+interface Props {
+ images: ProductImage[];
+}
+
+export default function ProductImageSlider({ images }: Props) {
+ const [activeIndex, setActiveIndex] = useState(0);
+ const swiperRef = useRef(null);
+ const [isViewerOpen, setIsViewerOpen] = useState(false);
+
+ const sortedImages = [...images].sort((a, b) => a.order_index - b.order_index);
+ const imageUrls = sortedImages.map((img) => img.image_url);
+
+ return (
+
+ {/* Main Swiper */}
+
+ (swiperRef.current = swiper)}
+ onSlideChange={(swiper) => setActiveIndex(swiper.activeIndex)}
+ className="h-full p-0"
+ >
+ {sortedImages.map((img) => (
+
+ setIsViewerOpen(true)}
+ />
+
+ ))}
+
+
+
+ {/* Thumbnail Gallery */}
+
+ {sortedImages.map((img, idx) => (
+
+ ))}
+ {[...Array(5 - images.length)].map((_, idx) => (
+
+ ))}
+
+
+ {isViewerOpen && (
+
setIsViewerOpen(false)}
+ />
+ )}
+
+ );
+}
diff --git a/apps/web/features/auction/detail/ui/ProposalActionButton.tsx b/apps/web/features/auction/detail/ui/ProposalActionButton.tsx
new file mode 100644
index 00000000..283d22f0
--- /dev/null
+++ b/apps/web/features/auction/detail/ui/ProposalActionButton.tsx
@@ -0,0 +1,75 @@
+'use client';
+
+import { encodeUUID } from '@/shared/lib/shortUuid';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from '@repo/ui/components/Dialog/Dialog';
+import { useEffect, useState } from 'react';
+import { Button } from '@repo/ui/components/Button/Button';
+import { useRouter } from 'next/navigation';
+
+const ProposalActionButton = ({ auctionId }: { auctionId: string }) => {
+ const [open, setOpen] = useState(false);
+ const [userPoint, setUserPoint] = useState(null);
+ const router = useRouter();
+
+ useEffect(() => {
+ const fetchUserPoint = async () => {
+ const res = await fetch('/api/profile');
+ const result = await res.json();
+
+ if (res.ok) {
+ setUserPoint(result.point);
+ } else {
+ setUserPoint(0);
+ }
+ };
+
+ fetchUserPoint();
+ }, []);
+
+ const handleClick = () => {
+ if ((userPoint ?? 0) >= 100) {
+ router.push(`/auction/${encodeUUID(auctionId)}/proposal`);
+ } else {
+ setOpen(true);
+ }
+ };
+
+ return (
+ <>
+
+
+
+ >
+ );
+};
+export default ProposalActionButton;
diff --git a/apps/web/features/auction/list/actions/getAuctionListAction.ts b/apps/web/features/auction/list/actions/getAuctionListAction.ts
new file mode 100644
index 00000000..50f27d84
--- /dev/null
+++ b/apps/web/features/auction/list/actions/getAuctionListAction.ts
@@ -0,0 +1,123 @@
+'use server';
+
+import { AuctionList } from '@/entities/auction/model/types';
+import { DEFAULT_AUCTION_LIST_PARAMS, SECRET_PRICE } from '@/features/auction/list/constants';
+import { AuctionListParams, AuctionListResponse } from '@/features/auction/list/types';
+import { getDistanceKm } from '@/features/product/lib/utils';
+import { searcher } from '@/features/search/lib/utils';
+import getUserId from '@/shared/lib/getUserId';
+import { supabase } from '@/shared/lib/supabaseClient';
+
+interface GetAuctionListActionProps {
+ limit?: number;
+ offset?: number;
+ params: AuctionListParams;
+}
+
+export async function getAuctionListAction(
+ props: GetAuctionListActionProps
+): Promise {
+ const { limit = 5, offset = 0, params = DEFAULT_AUCTION_LIST_PARAMS } = props;
+ const { search, cate, sort, filter } = params;
+ const userId = await getUserId();
+
+ const { data: userData, error: userError } = await supabase
+ .from('profiles')
+ .select('latitude, longitude')
+ .eq('user_id', userId)
+ .single();
+
+ if (!userData?.latitude || !userData?.longitude || userError) {
+ return null;
+ }
+
+ const lat = userData.latitude;
+ const lng = userData.longitude;
+
+ const { data: auctionData, error } = await supabase.from('auction').select(`
+ auction_id,
+ product_id,
+ auction_status,
+ min_price,
+ auction_end_at,
+ product:product_id (
+ title,
+ category,
+ latitude,
+ longitude,
+ exhibit_user_id,
+ product_image (
+ image_url,
+ order_index
+ ),
+ address
+ ),
+ bid_history!auction_id (
+ bid_price
+ ),
+ created_at,
+ is_secret
+ `);
+
+ if (error) {
+ return null;
+ }
+
+ const hasDeadlineToday = filter.includes('deadline-today');
+ const hasExcludeEnded = filter.includes('exclude-ended');
+
+ const filtered = (auctionData as unknown as AuctionList[])
+ .filter((item) => {
+ const { product, auction_status, auction_end_at } = item;
+ const distance = getDistanceKm(lat, lng, product.latitude, product.longitude);
+ const within5km = distance <= 5;
+ const matchSearch = !search || searcher(product.title, search.toLowerCase());
+ const matchCate = cate === 'all' || product.category === cate;
+
+ const now = new Date();
+ const isEnded = auction_status === '๊ฒฝ๋งค ์ข
๋ฃ';
+ const isWaiting = auction_status === '๊ฒฝ๋งค ๋๊ธฐ';
+ const isDeadlineToday = new Date(auction_end_at).toDateString() === now.toDateString();
+
+ const filterDeadline = !hasDeadlineToday || (hasDeadlineToday && isDeadlineToday);
+ const filterExcludeEnded = !hasExcludeEnded || (hasExcludeEnded && !isEnded);
+
+ return (
+ !isWaiting && within5km && matchSearch && matchCate && filterDeadline && filterExcludeEnded
+ );
+ })
+ .map((item) => {
+ const bidPrices = item.bid_history?.map((b) => b.bid_price) ?? [];
+ const highestBid = bidPrices.length > 0 ? Math.max(...bidPrices) : null;
+ const safeBidPrice = item.is_secret ? SECRET_PRICE : (highestBid ?? item.min_price);
+ return {
+ id: item.auction_id,
+ thumbnail:
+ item.product.product_image?.find((img) => img.order_index === 0)?.image_url ??
+ '/default.png',
+ title: item.product.title,
+ address: item.product.address,
+ bidCount: item.bid_history?.length ?? 0,
+ bidPrice: safeBidPrice,
+ auctionEndAt: item.auction_end_at,
+ auctionStatus: item.auction_status,
+ createdAt: item.created_at,
+ isSecret: item.is_secret,
+ };
+ });
+
+ const sorted = filtered.sort((a, b) => {
+ if (sort === 'popular') {
+ return b.bidCount - a.bidCount;
+ } else {
+ return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
+ }
+ });
+
+ const sliced = sorted.slice(offset * limit, (offset + 1) * limit);
+
+ return {
+ data: sliced,
+ nextOffset: sliced.length < limit ? null : offset + 1,
+ };
+}
diff --git a/apps/web/features/auction/list/actions/getAuctionMakersAction.ts b/apps/web/features/auction/list/actions/getAuctionMakersAction.ts
new file mode 100644
index 00000000..6cea467a
--- /dev/null
+++ b/apps/web/features/auction/list/actions/getAuctionMakersAction.ts
@@ -0,0 +1,82 @@
+'use server';
+
+import { getDistanceKm } from '@/features/product/lib/utils';
+import getUserId from '@/shared/lib/getUserId';
+import { MapAuction } from '@/entities/auction/model/types';
+import { AuctionMarkerResponse } from '@/features/auction/list/types';
+import { supabase } from '@/shared/lib/supabaseClient';
+import { SECRET_PRICE } from '@/features/auction/list/constants';
+
+export async function getAuctionMarkersAction(): Promise {
+ const userId = await getUserId();
+
+ const { data: userData, error: userError } = await supabase
+ .from('profiles')
+ .select('latitude, longitude')
+ .eq('user_id', userId)
+ .single();
+
+ if (!userData?.latitude || !userData?.longitude || userError) {
+ return null;
+ }
+
+ const lat = userData.latitude;
+ const lng = userData.longitude;
+
+ const { data, error } = await supabase
+ .from('auction')
+ .select(
+ `
+ auction_id,
+ auction_status,
+ auction_end_at,
+ min_price,
+ bid_history!auction_id (
+ bid_price
+ ),
+ is_secret,
+ product:product_id (
+ latitude,
+ longitude,
+ title,
+ product_image (
+ image_url,
+ order_index
+ )
+ )
+ `
+ )
+ .eq('auction_status', '๊ฒฝ๋งค ์ค');
+
+ if (error) {
+ return null;
+ }
+ const filtered = (data as unknown as MapAuction[]).filter((item) => {
+ const { product } = item;
+ const distance = getDistanceKm(lat, lng, product.latitude, product.longitude);
+ const within5km = distance <= 5;
+ return within5km;
+ });
+
+ const markers = filtered.map((item) => {
+ const bidPrices = item.bid_history?.map((b) => b.bid_price) ?? [];
+ const highestBid = bidPrices.length > 0 ? Math.max(...bidPrices) : null;
+ const safeBidPrice = item.is_secret ? SECRET_PRICE : (highestBid ?? item.min_price);
+ return {
+ id: item.auction_id,
+ auctionEndAt: item.auction_end_at,
+ bidPrice: safeBidPrice,
+ title: item.product.title,
+ isSecret: item.is_secret,
+ location: {
+ lat: item.product.latitude,
+ lng: item.product.longitude,
+ },
+ thumbnail:
+ item.product.product_image?.find((img) => img.order_index === 0)?.image_url ??
+ '/default.png',
+ };
+ });
+
+ return markers;
+}
diff --git a/apps/web/features/auction/list/api/getAuctionListApi.ts b/apps/web/features/auction/list/api/getAuctionListApi.ts
new file mode 100644
index 00000000..68aeecdb
--- /dev/null
+++ b/apps/web/features/auction/list/api/getAuctionListApi.ts
@@ -0,0 +1,41 @@
+import { DEFAULT_AUCTION_LIST_PARAMS } from '@/features/auction/list/constants';
+import { AuctionList, AuctionListParams } from '@/features/auction/list/types';
+
+interface getAuctionListApiProps {
+ limit?: number;
+ offset?: number;
+ params: AuctionListParams;
+}
+
+export const getAuctionListApi = async (
+ props: getAuctionListApiProps
+): Promise<{ data: AuctionList[]; nextOffset: number }> => {
+ const { limit = 5, offset = 0, params = DEFAULT_AUCTION_LIST_PARAMS } = props;
+ const { search, cate, sort, filter } = params;
+
+ const query = new URLSearchParams();
+
+ query.set('limit', limit.toString());
+ query.set('offset', offset.toString());
+ query.set('search', search);
+ query.set('cate', cate);
+ query.set('sort', sort);
+ filter.forEach((f) => query.append('filter', f));
+
+ const res = await fetch(`/api/auction/list?${query.toString()}`);
+ if (!res.ok) {
+ const errorBody = await res.json();
+
+ throw {
+ message: errorBody.error || '์ํ ๋ฆฌ์คํธ ์กฐํ ์คํจ',
+ code: errorBody.code || 'UNKNOWN_ERROR',
+ status: res.status,
+ };
+ }
+
+ const result = await res.json();
+ return {
+ data: result.data,
+ nextOffset: result.nextOffset,
+ };
+};
diff --git a/apps/web/features/auction/list/api/getAuctionMakers.ts b/apps/web/features/auction/list/api/getAuctionMakers.ts
new file mode 100644
index 00000000..0c0277a3
--- /dev/null
+++ b/apps/web/features/auction/list/api/getAuctionMakers.ts
@@ -0,0 +1,13 @@
+import { AuctionMarkerResponse } from '@/features/auction/list/types';
+
+export const getAuctionMakers = async (): Promise => {
+ const res = await fetch(`/api/auction-map`);
+
+ if (!res.ok) {
+ const { error } = await res.json();
+ throw new Error(error || '์ํ ์์น ์กฐํ ์คํจ');
+ }
+
+ const data = await res.json();
+ return data;
+};
diff --git a/apps/web/features/auction/list/constants/index.ts b/apps/web/features/auction/list/constants/index.ts
new file mode 100644
index 00000000..4475b994
--- /dev/null
+++ b/apps/web/features/auction/list/constants/index.ts
@@ -0,0 +1,10 @@
+import { AuctionListParams } from '@/features/auction/list/types';
+
+export const DEFAULT_AUCTION_LIST_PARAMS: AuctionListParams = {
+ cate: 'all',
+ sort: 'latest',
+ filter: ['exclude-ended'],
+ search: '',
+};
+
+export const SECRET_PRICE = '*******' as const;
diff --git a/apps/web/features/auction/list/lib/utils.ts b/apps/web/features/auction/list/lib/utils.ts
new file mode 100644
index 00000000..e2875589
--- /dev/null
+++ b/apps/web/features/auction/list/lib/utils.ts
@@ -0,0 +1,26 @@
+import { AuctionListParams, Page } from '@/features/auction/list/types';
+
+export const createAuctionListQueryKey = ({ cate, sort, filter, search }: AuctionListParams) => {
+ const key = ['auctionList', cate, sort];
+
+ if (filter?.length) {
+ key.push([...filter].sort().join(','));
+ }
+
+ if (search?.trim()) {
+ key.push(search.trim());
+ }
+
+ return key;
+};
+
+export const getListHeight = (page: Page, showList: boolean = false) => {
+ switch (page) {
+ case 'home':
+ return showList ? '100%' : '0';
+ case 'list':
+ return 'calc(100vh - 326px)';
+ case 'search':
+ return 'calc(100vh - 172px)';
+ }
+};
diff --git a/apps/web/features/auction/list/model/useAuctionList.ts b/apps/web/features/auction/list/model/useAuctionList.ts
new file mode 100644
index 00000000..405b422c
--- /dev/null
+++ b/apps/web/features/auction/list/model/useAuctionList.ts
@@ -0,0 +1,22 @@
+import { getAuctionListApi } from '@/features/auction/list/api/getAuctionListApi';
+import { createAuctionListQueryKey } from '@/features/auction/list/lib/utils';
+import {
+ AuctionListError,
+ AuctionListParams,
+ AuctionListResponse,
+} from '@/features/auction/list/types';
+import { useInfiniteQuery } from '@tanstack/react-query';
+
+interface UseAuctionListProps {
+ params: AuctionListParams;
+}
+
+export const useAuctionList = ({ params }: UseAuctionListProps) => {
+ return useInfiniteQuery({
+ queryKey: createAuctionListQueryKey(params),
+ queryFn: ({ pageParam = 0 }) => getAuctionListApi({ offset: pageParam as number, params }),
+ getNextPageParam: (lastPage) => lastPage.nextOffset ?? undefined,
+ initialPageParam: 0,
+ retry: 2,
+ });
+};
diff --git a/apps/web/features/auction/list/model/useAuctionListErrorHandler.ts b/apps/web/features/auction/list/model/useAuctionListErrorHandler.ts
new file mode 100644
index 00000000..89240cb8
--- /dev/null
+++ b/apps/web/features/auction/list/model/useAuctionListErrorHandler.ts
@@ -0,0 +1,27 @@
+import { toast } from '@repo/ui/components/Toast/Sonner';
+import { useEffect } from 'react';
+import { useRouter } from 'next/navigation';
+import { AuctionListError } from '@/features/auction/list/types';
+
+const useAuctionListErrorHandler = (isError: boolean, error: AuctionListError | null) => {
+ const router = useRouter();
+
+ useEffect(() => {
+ if (!isError) return;
+
+ const { code, message } = error!;
+
+ if (code === 'NO_USER_ID') {
+ toast({ content: message });
+ router.replace('/login');
+ } else if (code === 'NO_USER_LOCATION') {
+ toast({ content: message });
+ router.replace('/set-location');
+ } else {
+ toast({ content: message || '์ ์ ์๋ ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค.' });
+ router.replace('/login');
+ }
+ }, [isError]);
+};
+
+export default useAuctionListErrorHandler;
diff --git a/apps/web/features/auction/list/model/useAuctionMakers.ts b/apps/web/features/auction/list/model/useAuctionMakers.ts
new file mode 100644
index 00000000..7d9c6cf6
--- /dev/null
+++ b/apps/web/features/auction/list/model/useAuctionMakers.ts
@@ -0,0 +1,10 @@
+import { getAuctionMakers } from '@/features/auction/list/api/getAuctionMakers';
+import { AuctionMarkerResponse } from '@/features/auction/list/types';
+import { useQuery } from '@tanstack/react-query';
+
+export const useProductMarkers = () => {
+ return useQuery({
+ queryKey: ['productMarkers'],
+ queryFn: () => getAuctionMakers(),
+ });
+};
diff --git a/apps/web/features/auction/list/model/useVirtualInfiniteScroll.ts b/apps/web/features/auction/list/model/useVirtualInfiniteScroll.ts
new file mode 100644
index 00000000..0a99c313
--- /dev/null
+++ b/apps/web/features/auction/list/model/useVirtualInfiniteScroll.ts
@@ -0,0 +1,47 @@
+import { useEffect, useRef } from 'react';
+import { useVirtualizer } from '@tanstack/react-virtual';
+
+interface UseVirtualInfiniteScrollProps {
+ data: T[];
+ hasNextPage: boolean;
+ isFetchingNextPage: boolean;
+ fetchNextPage: () => void;
+ estimateSize?: () => number;
+}
+
+const useVirtualInfiniteScroll = ({
+ data,
+ hasNextPage,
+ isFetchingNextPage,
+ fetchNextPage,
+ estimateSize = () => 190,
+}: UseVirtualInfiniteScrollProps) => {
+ const parentRef = useRef(null);
+
+ const rowVirtualizer = useVirtualizer({
+ count: hasNextPage ? data.length + 1 : data.length,
+ getScrollElement: () => parentRef.current,
+ estimateSize,
+ });
+
+ useEffect(() => {
+ const [lastItem] = [...rowVirtualizer.getVirtualItems()].reverse();
+ if (lastItem && lastItem.index >= data.length - 1 && hasNextPage && !isFetchingNextPage) {
+ fetchNextPage();
+ }
+ }, [
+ data.length,
+ hasNextPage,
+ isFetchingNextPage,
+ fetchNextPage,
+ rowVirtualizer.getVirtualItems(),
+ ]);
+
+ return {
+ parentRef,
+ virtualRows: rowVirtualizer.getVirtualItems(),
+ totalSize: rowVirtualizer.getTotalSize(),
+ };
+};
+
+export default useVirtualInfiniteScroll;
diff --git a/apps/web/features/auction/list/types/index.ts b/apps/web/features/auction/list/types/index.ts
new file mode 100644
index 00000000..d3932db5
--- /dev/null
+++ b/apps/web/features/auction/list/types/index.ts
@@ -0,0 +1,51 @@
+import { SECRET_PRICE } from '@/features/auction/list/constants';
+import { CategoryValue } from '@/features/category/types';
+import { Location } from '@/features/location/types';
+
+export type SecretBidPrice = typeof SECRET_PRICE;
+
+export interface AuctionList {
+ id: string;
+ thumbnail: string;
+ title: string;
+ address: string;
+ bidCount: number;
+ bidPrice: number | SecretBidPrice;
+ auctionEndAt: string;
+ auctionStatus: string;
+ createdAt: string;
+ isSecret: boolean;
+}
+
+export interface AuctionListResponse {
+ data: AuctionList[];
+ nextOffset: number | null;
+}
+
+export interface AuctionListError {
+ message: string;
+ code?: string;
+ status: number;
+}
+
+export type AuctionSort = 'latest' | 'popular';
+export type AuctionFilter = 'deadline-today' | 'exclude-ended';
+
+export interface AuctionListParams {
+ search: string;
+ cate: CategoryValue;
+ sort: AuctionSort;
+ filter: AuctionFilter[];
+}
+
+export interface AuctionMarkerResponse {
+ id: string;
+ location: Location;
+ thumbnail: string;
+ auctionEndAt: string;
+ bidPrice: number | SecretBidPrice;
+ title: string;
+ isSecret: boolean;
+}
+
+export type Page = 'home' | 'list' | 'search';
diff --git a/apps/web/features/auction/list/ui/AuctionFilter.tsx b/apps/web/features/auction/list/ui/AuctionFilter.tsx
new file mode 100644
index 00000000..ab41c757
--- /dev/null
+++ b/apps/web/features/auction/list/ui/AuctionFilter.tsx
@@ -0,0 +1,55 @@
+import { AuctionFilter as AuctionFilterType } from '@/features/auction/list/types';
+import AuctionTooltip from '@/features/auction/list/ui/AuctionTooltip';
+import Checkbox from '@repo/ui/components/Checkbox/Checkbox';
+
+import { useEffect, useState } from 'react';
+
+interface AuctionFilterProps {
+ setFilter: (filters: AuctionFilterType[]) => void;
+}
+
+const AuctionFilter = ({ setFilter }: AuctionFilterProps) => {
+ const [isDeadlineToday, setIsDeadlineToday] = useState(false);
+ const [isExcludeEnded, setIsExcludeEnded] = useState(true);
+
+ const handleCheckboxChange = (checked: boolean, key: string) => {
+ if (key === 'deadline-today') {
+ setIsDeadlineToday(checked);
+ } else if (key === 'exclude-ended') {
+ setIsExcludeEnded(checked);
+ }
+ };
+
+ useEffect(() => {
+ const newFilters: AuctionFilterType[] = [];
+ if (isDeadlineToday) newFilters.push('deadline-today');
+ if (isExcludeEnded) newFilters.push('exclude-ended');
+ setFilter(newFilters);
+ }, [isDeadlineToday, isExcludeEnded]);
+
+ return (
+
+
+
+
handleCheckboxChange(checked === true, 'deadline-today')}
+ className="z-10"
+ />
+
+ handleCheckboxChange(checked === true, 'exclude-ended')}
+ />
+
+ );
+};
+
+export default AuctionFilter;
diff --git a/apps/web/features/auction/list/ui/AuctionItem.tsx b/apps/web/features/auction/list/ui/AuctionItem.tsx
new file mode 100644
index 00000000..04029ec7
--- /dev/null
+++ b/apps/web/features/auction/list/ui/AuctionItem.tsx
@@ -0,0 +1,62 @@
+import { getCountdownWithColor } from '@/features/product/lib/utils';
+import Image from 'next/image';
+import { AUCTION_STATUS } from '@/shared/consts/auctionStatus';
+import { AuctionList } from '@/features/auction/list/types';
+import StatusBadge, { StatusType } from '@/shared/ui/badge/StatusBadge';
+import SecretBadge from '@/shared/ui/badge/SecretBadge';
+
+const AuctionItem = ({
+ thumbnail,
+ title,
+ address,
+ bidCount,
+ bidPrice,
+ auctionEndAt,
+ auctionStatus,
+ isSecret,
+}: AuctionList) => {
+ const { text, color } =
+ auctionStatus === AUCTION_STATUS.ENDED
+ ? { text: '๊ฒฝ๋งค ์ข
๋ฃ', color: 'gray' }
+ : getCountdownWithColor(auctionEndAt);
+
+ const timeBadgeType = {
+ gray: 'time-gray',
+ orange: 'time-orange',
+ blue: 'time-blue',
+ }[color] as StatusType;
+
+ return (
+
+ );
+};
+
+export default AuctionItem;
diff --git a/apps/web/features/auction/list/ui/AuctionList.tsx b/apps/web/features/auction/list/ui/AuctionList.tsx
new file mode 100644
index 00000000..b2bd12e1
--- /dev/null
+++ b/apps/web/features/auction/list/ui/AuctionList.tsx
@@ -0,0 +1,82 @@
+import { DEFAULT_AUCTION_LIST_PARAMS } from '@/features/auction/list/constants';
+import { getListHeight } from '@/features/auction/list/lib/utils';
+import { useAuctionList } from '@/features/auction/list/model/useAuctionList';
+import useAuctionListErrorHandler from '@/features/auction/list/model/useAuctionListErrorHandler';
+import useVirtualInfiniteScroll from '@/features/auction/list/model/useVirtualInfiniteScroll';
+import { AuctionFilter, AuctionSort, Page } from '@/features/auction/list/types';
+import AuctionItem from '@/features/auction/list/ui/AuctionItem';
+import { CategoryValue } from '@/features/category/types';
+import Skeleton from '@/features/product/ui/Skeleton';
+import { encodeUUID } from '@/shared/lib/shortUuid';
+import Loading from '@/shared/ui/Loading/Loading';
+import Link from 'next/link';
+import { useEffect, useState } from 'react';
+
+interface AuctionListProps {
+ sort?: AuctionSort;
+ filter?: AuctionFilter[];
+ cate?: CategoryValue;
+ search?: string;
+ height: string;
+}
+
+const AuctionList = ({
+ sort = DEFAULT_AUCTION_LIST_PARAMS.sort,
+ filter = DEFAULT_AUCTION_LIST_PARAMS.filter,
+ cate = DEFAULT_AUCTION_LIST_PARAMS.cate,
+ search = DEFAULT_AUCTION_LIST_PARAMS.search,
+ height,
+}: AuctionListProps) => {
+ const { data, isLoading, isError, error, fetchNextPage, hasNextPage, isFetchingNextPage } =
+ useAuctionList({ params: { sort, filter, cate, search } });
+ useAuctionListErrorHandler(isError, error);
+ const auctionList = data?.pages.flatMap((page) => page.data) ?? [];
+
+ const { parentRef, virtualRows, totalSize } = useVirtualInfiniteScroll({
+ data: auctionList,
+ hasNextPage,
+ isFetchingNextPage,
+ fetchNextPage,
+ });
+ if (isLoading) {
+ return ;
+ }
+ if (auctionList.length === 0) {
+ return ์ํ์ด ์กด์ฌํ์ง ์์ต๋๋ค.
;
+ }
+ return (
+
+
+ {virtualRows.map((virtualRow) => {
+ const index = virtualRow.index;
+ const item = auctionList[index];
+ const isLoaderRow = index === auctionList.length;
+
+ return (
+ -
+ {isLoaderRow && isFetchingNextPage && }
+
+ {item && (
+
+
+
+ )}
+
+ );
+ })}
+
+
+ );
+};
+
+export default AuctionList;
diff --git a/apps/web/features/auction/list/ui/AuctionListClientPage.tsx b/apps/web/features/auction/list/ui/AuctionListClientPage.tsx
new file mode 100644
index 00000000..3bd2763e
--- /dev/null
+++ b/apps/web/features/auction/list/ui/AuctionListClientPage.tsx
@@ -0,0 +1,40 @@
+'use client';
+
+import { DEFAULT_AUCTION_LIST_PARAMS } from '@/features/auction/list/constants';
+import { getListHeight } from '@/features/auction/list/lib/utils';
+import { AuctionFilter as AuctionFilterType, AuctionSort } from '@/features/auction/list/types';
+import AuctionFilter from '@/features/auction/list/ui/AuctionFilter';
+import AuctionList from '@/features/auction/list/ui/AuctionList';
+import AuctionSortDropdown from '@/features/auction/list/ui/AuctionSortDropdown';
+import { useCategoryStore } from '@/features/category/model/useCategoryStore';
+import Category from '@/features/category/ui/Category';
+import { LocationWithAddress } from '@/features/location/types';
+
+import LocationPin from '@/features/location/ui/LocationPin';
+import { useState } from 'react';
+
+interface AuctionListClientPageProps {
+ userLocation: LocationWithAddress;
+}
+
+const AuctionListClientPage = ({ userLocation }: AuctionListClientPageProps) => {
+ const [sort, setSort] = useState(DEFAULT_AUCTION_LIST_PARAMS.sort);
+ const [filter, setFilter] = useState(DEFAULT_AUCTION_LIST_PARAMS.filter);
+ const cate = useCategoryStore((state) => state.selected ?? DEFAULT_AUCTION_LIST_PARAMS.cate);
+ const listHeight = getListHeight('list');
+
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+};
+
+export default AuctionListClientPage;
diff --git a/apps/web/features/auction/list/ui/AuctionListWrapper.tsx b/apps/web/features/auction/list/ui/AuctionListWrapper.tsx
new file mode 100644
index 00000000..6f5d2eb6
--- /dev/null
+++ b/apps/web/features/auction/list/ui/AuctionListWrapper.tsx
@@ -0,0 +1,29 @@
+import { getAuctionListAction } from '@/features/auction/list/actions/getAuctionListAction';
+import { createAuctionListQueryKey } from '@/features/auction/list/lib/utils';
+import { DEFAULT_AUCTION_LIST_PARAMS } from '@/features/auction/list/constants';
+import { HydrationBoundary, QueryClient, dehydrate } from '@tanstack/react-query';
+import { AuctionListParams } from '@/features/auction/list/types';
+
+interface AuctionListWrapperProps {
+ children: React.ReactNode;
+ params?: AuctionListParams;
+}
+
+const AuctionListWrapper = async ({
+ children,
+ params = DEFAULT_AUCTION_LIST_PARAMS,
+}: AuctionListWrapperProps) => {
+ const queryClient = new QueryClient();
+
+ await queryClient.prefetchInfiniteQuery({
+ queryKey: createAuctionListQueryKey(params),
+ queryFn: ({ pageParam = 0 }) => getAuctionListAction({ offset: pageParam, params }),
+ initialPageParam: 0,
+ });
+
+ const dehydratedState = dehydrate(queryClient);
+
+ return {children};
+};
+
+export default AuctionListWrapper;
diff --git a/apps/web/features/auction/list/ui/AuctionProvider.tsx b/apps/web/features/auction/list/ui/AuctionProvider.tsx
new file mode 100644
index 00000000..217561b9
--- /dev/null
+++ b/apps/web/features/auction/list/ui/AuctionProvider.tsx
@@ -0,0 +1,21 @@
+'use client';
+
+import { HydrationBoundary, QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { ReactNode, useState } from 'react';
+
+interface AuctionProviderProps {
+ dehydratedState: unknown;
+ children: ReactNode;
+}
+
+const AuctionProvider = ({ dehydratedState, children }: AuctionProviderProps) => {
+ const [queryClient] = useState(() => new QueryClient());
+
+ return (
+
+ {children}
+
+ );
+};
+
+export default AuctionProvider;
diff --git a/apps/web/features/auction/list/ui/AuctionSortDropdown.tsx b/apps/web/features/auction/list/ui/AuctionSortDropdown.tsx
new file mode 100644
index 00000000..60fef87f
--- /dev/null
+++ b/apps/web/features/auction/list/ui/AuctionSortDropdown.tsx
@@ -0,0 +1,34 @@
+import { AuctionSort } from '@/features/auction/list/types';
+
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@repo/ui/components/Select/Select';
+
+interface AuctionSortDropdownProps {
+ sort: AuctionSort;
+ setSort: (sort: AuctionSort) => void;
+}
+
+const AuctionSortDropdown = ({ setSort, sort }: AuctionSortDropdownProps) => {
+ return (
+
+ );
+};
+
+export default AuctionSortDropdown;
diff --git a/apps/web/features/auction/list/ui/AuctionTooltip.tsx b/apps/web/features/auction/list/ui/AuctionTooltip.tsx
new file mode 100644
index 00000000..f88fed32
--- /dev/null
+++ b/apps/web/features/auction/list/ui/AuctionTooltip.tsx
@@ -0,0 +1,23 @@
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from '@repo/ui/components/Tooltip/Tooltip';
+
+const AuctionTooltip = () => {
+ return (
+
+
+
+ ํดํ
+
+
+ ์ค๋์ด ์
์ฐฐ ๋ง์ง๋ง ๊ธฐํ!
+
+
+
+ );
+};
+
+export default AuctionTooltip;
diff --git a/apps/web/features/auction/listings/index.tsx b/apps/web/features/auction/listings/index.tsx
deleted file mode 100644
index 69ca262c..00000000
--- a/apps/web/features/auction/listings/index.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import { Tabs } from '@repo/ui/components/Tabs/Tabs';
-import AuctionTopTabs from '@/features/auction/shared/ui/AuctionTaopTabs';
-import ListingList from '@/features/auction/listings/ui/ListingList';
-
-// ์ถํ๋ด์ญ
-
-const AuctionListings = () => {
- const items = [
- { value: 'all', label: '์ ์ฒด', content: },
- { value: 'pending', label: '๋๊ธฐ', content: },
- { value: 'progress', label: '๊ฒฝ๋งค ์งํ ์ค', content: },
- { value: 'win', label: '๋์ฐฐ', content: },
- { value: 'fail', label: '์ ์ฐฐ', content: },
- ];
-
- return (
-
- );
-};
-
-export default AuctionListings;
diff --git a/apps/web/features/auction/listings/model/getListingList.ts b/apps/web/features/auction/listings/model/getListingList.ts
index d443cefb..1fa7de93 100644
--- a/apps/web/features/auction/listings/model/getListingList.ts
+++ b/apps/web/features/auction/listings/model/getListingList.ts
@@ -1,106 +1,58 @@
-import { supabase } from '@/shared/lib/supabaseClient';
-import { ProductForList } from '@/features/product/types';
+import { ProductList } from '@/features/product/types';
+import { ListingData, ListingListParams } from '@/features/auction/listings/types';
+import { AUCTION_STATUS } from '@/shared/consts/auctionStatus';
-interface ListingListParams {
- filter: 'all' | 'pending' | 'progress' | 'win' | 'fail';
- userId: string;
-}
-
-interface ListingData {
- product_id: string;
- created_at: string;
- exhibit_user_id: string;
- title: string;
- description: string;
- updated_at: string | null;
- latitude: number;
- longitude: number;
- category: string;
- address: string;
- product_image: {
- [key: string]: string;
- }[];
- pending_auction: {
- [key: string]: any;
- }[];
- auction: {
- [key: string]: any;
- }[];
-}
-const getListingList = async (params: ListingListParams): Promise => {
+const getListingList = async (params: ListingListParams): Promise => {
const { filter, userId } = params;
- const res = await fetch(`/api/auction/listings?userId=${userId}`);
+ const res = await fetch(`/api/auction/listings?userId=${userId}&filter=${filter}`, {
+ next: { revalidate: 60 },
+ });
const result = await res.json();
if (!res.ok || !result.success) {
- throw new Error(result.error || 'Failed to fetch product list');
+ throw new Error(result.error || '๋ฐ์ดํฐ ๋ก๋ฉ ์คํจ');
}
const listingData: ListingData[] = result.data;
- const filtered = listingData
- .map((product) => {
- const auction = Array.isArray(product.auction) ? product.auction[0] : product.auction;
- const pending = Array.isArray(product.pending_auction)
- ? product.pending_auction[0]
- : product.pending_auction;
- const myBid = auction?.bid_history?.find((b: any) => b.bid_user_id === userId);
-
- const hasLocation = product.latitude && product.longitude;
-
- const isPending = filter === 'pending' && pending?.pending_auction_id;
- const isProgress = filter === 'progress' && auction && auction.auction_status === '๊ฒฝ๋งค ์ค';
- const isWin =
- filter === 'win' &&
- auction &&
- auction.auction_status === '๊ฒฝ๋งค ์ข
๋ฃ' &&
- auction.winning_bid_user_id;
- const isFail =
- filter === 'fail' &&
- auction &&
- auction.auction_status === '๊ฒฝ๋งค ์ข
๋ฃ' &&
- !auction.winning_bid_user_id;
- const pass = filter === 'all' || isPending || isProgress || isWin || isFail;
-
- return pass && hasLocation ? { product, auction, pending, myBid } : null;
- })
- .filter(Boolean) as {
- product: any;
- auction?: any;
- pending?: any;
- myBid?: any;
- }[];
-
- const auctionIds = filtered.map(({ auction }) => auction?.auction_id).filter(Boolean);
-
- const { data: bidCountRaw } = await supabase
- .from('bid_history')
- .select('auction_id, bid_id')
- .in('auction_id', auctionIds);
-
- const bidCountMap: Record = {};
- for (const item of bidCountRaw ?? []) {
- const auctionId = item.auction_id;
- bidCountMap[auctionId] = (bidCountMap[auctionId] ?? 0) + 1;
- }
-
- return filtered.map(({ product, auction, pending, myBid }) => ({
- id: pending ? product.product_id : auction?.auction_id,
- thumbnail:
- product.product_image?.find((img: any) => img.order_index === 0)?.image_url ?? '/default.png',
- title: product.title,
- address: product.address ?? '์์น ์ ๋ณด ์์',
- bidCount: auction?.auction_id ? (bidCountMap[auction.auction_id] ?? 0) : 0,
- price: myBid?.bid_price ?? 0,
- minPrice: auction?.min_price ?? pending?.min_price ?? 0,
- auctionEndAt: auction?.auction_end_at ?? pending?.auction_end_at ?? '',
- auctionStatus: auction?.auction_status ?? pending?.auction_status ?? '๊ฒฝ๋งค ๋๊ธฐ',
- winnerId: auction?.winning_bid_user_id ?? null,
- sellerId: product.exhibit_user_id,
- isAwarded: myBid?.is_awarded ?? false,
- isPending: !!pending?.pending_auction_id,
- }));
+ return listingData.map((product) => {
+ const auction = Array.isArray(product.auction) ? product.auction[0] : product.auction;
+ const myBid = auction?.bid_history?.find((b: any) => b.bid_user_id === product.exhibit_user_id); // seller ๊ธฐ์ค์ด๋ผ๋ฉด ์ ์ธ ๊ฐ๋ฅ
+ const isEnd = !!auction?.winning_bid_user_id;
+ const hasBids = auction?.bid_history?.length > 0;
+ const highestBid = hasBids
+ ? Math.max(...auction?.bid_history.map((bid: any) => Number(bid.bid_price) || 0))
+ : undefined;
+
+ const minPrice = auction?.is_secret
+ ? isEnd
+ ? (highestBid ?? auction.min_price) // ๋น๋ฐ๊ฒฝ๋งค + ๋์ฐฐ โ ์ต๊ณ ๊ฐ
+ : auction.min_price // ๋น๋ฐ๊ฒฝ๋งค + ๋ฏธ๋์ฐฐ โ ์ต์๊ฐ
+ : (highestBid ?? auction?.min_price); // ๐น ์ผ๋ฐ ๊ฒฝ๋งค
+
+ return {
+ id:
+ auction?.auction_status === AUCTION_STATUS.PENDING
+ ? product.product_id
+ : auction?.auction_id,
+ thumbnail:
+ product.product_image?.find((img: any) => img.order_index === 0)?.image_url ??
+ '/default.png',
+ title: product.title,
+ address: product.address ?? '์์น ์ ๋ณด ์์',
+ price: myBid?.bid_price ?? 0,
+ bidCount: auction?.bid_history.length ?? 0,
+ minPrice: minPrice,
+ auctionEndAt: auction?.auction_end_at ?? '',
+ auctionStatus: auction?.auction_status,
+ winnerId: auction?.winning_bid_user_id ?? null,
+ sellerId: product.exhibit_user_id,
+ isAwarded: myBid?.is_awarded ?? false,
+ isPending: auction?.auction_status === AUCTION_STATUS.PENDING,
+ isSecret: auction?.is_secret,
+ };
+ });
};
export default getListingList;
diff --git a/apps/web/features/auction/listings/model/useGetListingList.ts b/apps/web/features/auction/listings/model/useGetListingList.ts
index 7bcc2453..b2940c5a 100644
--- a/apps/web/features/auction/listings/model/useGetListingList.ts
+++ b/apps/web/features/auction/listings/model/useGetListingList.ts
@@ -1,17 +1,13 @@
-import { ProductForList } from '@/features/product/types';
+import { ProductList } from '@/features/product/types';
import { useQuery } from '@tanstack/react-query';
import getListingList from '@/features/auction/listings/model/getListingList';
+import { ListingListParams } from '@/features/auction/listings/types';
-interface useGetListingListParams {
- userId: string;
- filter: 'all' | 'pending' | 'progress' | 'win' | 'fail';
-}
-
-export const useGetListingList = (params: useGetListingListParams) => {
- return useQuery({
- queryKey: ['ListingList', params.userId, params.filter],
+export const useGetListingList = (params: ListingListParams) => {
+ return useQuery({
+ queryKey: ['listingList', params.userId, params.filter],
queryFn: () => getListingList(params),
enabled: !!params.userId,
- staleTime: 1000 * 60 * 1,
+ staleTime: 0,
});
};
diff --git a/apps/web/features/auction/listings/types/index.ts b/apps/web/features/auction/listings/types/index.ts
new file mode 100644
index 00000000..d4e4d4fb
--- /dev/null
+++ b/apps/web/features/auction/listings/types/index.ts
@@ -0,0 +1,29 @@
+export interface ListingListParams {
+ userId: string;
+ filter: 'all' | 'pending' | 'progress' | 'win' | 'fail';
+}
+export interface AuctionListingsTabsProps {
+ userId: string;
+}
+
+export interface ListingData {
+ product_id: string;
+ created_at: string;
+ exhibit_user_id: string;
+ title: string;
+ description: string;
+ updated_at: string | null;
+ latitude: number;
+ longitude: number;
+ category: string;
+ address: string;
+ product_image: {
+ [key: string]: string;
+ }[];
+ pending_auction: {
+ [key: string]: any;
+ }[];
+ auction: {
+ [key: string]: any;
+ }[];
+}
diff --git a/apps/web/features/auction/listings/ui/AuctionListings.tsx b/apps/web/features/auction/listings/ui/AuctionListings.tsx
new file mode 100644
index 00000000..e363e999
--- /dev/null
+++ b/apps/web/features/auction/listings/ui/AuctionListings.tsx
@@ -0,0 +1,25 @@
+import AuctionTopTabs from '@/features/auction/shared/ui/AuctionTopTabs';
+import { createClient } from '@/shared/lib/supabase/server';
+import ReactQueryProvider from '@/shared/providers/ReactQueryProvider';
+import AuctionListingsTabs from '@/features/auction/listings/ui/AuctionListingsTabs';
+
+const AuctionListings = async () => {
+ const supabase = await createClient();
+
+ const {
+ data: { session },
+ } = await supabase.auth.getSession();
+ const userId = session?.user.id;
+
+ if (!userId) return;
+ return (
+ <>
+
+
+
+
+ >
+ );
+};
+
+export default AuctionListings;
diff --git a/apps/web/features/auction/listings/ui/AuctionListingsTabs.tsx b/apps/web/features/auction/listings/ui/AuctionListingsTabs.tsx
new file mode 100644
index 00000000..1aa179b3
--- /dev/null
+++ b/apps/web/features/auction/listings/ui/AuctionListingsTabs.tsx
@@ -0,0 +1,54 @@
+'use client';
+
+import { Tabs } from '@repo/ui/components/Tabs/Tabs';
+import Loading from '@/shared/ui/Loading/Loading';
+import { Suspense, useState } from 'react';
+import { useRouter, useSearchParams } from 'next/navigation';
+import { AuctionListingsTabsProps } from '@/features/auction/listings/types';
+import ListingList from '@/features/auction/listings/ui/ListingList';
+
+const AuctionListingsTabs = ({ userId }: AuctionListingsTabsProps) => {
+ const searchParams = useSearchParams();
+ const router = useRouter();
+
+ const tabParam = searchParams.get('tab');
+ const [currentTab, setCurrentTab] = useState(tabParam ?? 'all');
+
+ const handleTabChange = (value: string) => {
+ setCurrentTab(value);
+ const url = new URL(window.location.href);
+ url.searchParams.set('tab', value);
+ router.push(url.toString(), { scroll: false });
+ };
+
+ const items = [
+ { value: 'all', label: '์ ์ฒด', content: },
+ {
+ value: 'pending',
+ label: '๋๊ธฐ',
+ content: ,
+ },
+ {
+ value: 'progress',
+ label: '๊ฒฝ๋งค ์ค',
+ content: ,
+ },
+ { value: 'win', label: '๋์ฐฐ', content: },
+ { value: 'fail', label: '์ ์ฐฐ', content: },
+ ];
+
+ return (
+ <>
+ }>
+
+
+ >
+ );
+};
+
+export default AuctionListingsTabs;
diff --git a/apps/web/features/auction/listings/ui/ListingList.tsx b/apps/web/features/auction/listings/ui/ListingList.tsx
index b6bbf534..9ff8b18c 100644
--- a/apps/web/features/auction/listings/ui/ListingList.tsx
+++ b/apps/web/features/auction/listings/ui/ListingList.tsx
@@ -2,25 +2,29 @@
import ProductList from '@/features/product/ui/ProductList';
import { useGetListingList } from '@/features/auction/listings/model/useGetListingList';
-import { useAuthStore } from '@/shared/model/authStore';
-import Loading from '@/shared/ui/Loading/Loading';
+import Skeleton from '@/features/product/ui/Skeleton';
+import { ListingListParams } from '@/features/auction/listings/types';
-interface ListingListProps {
- filter: 'all' | 'pending' | 'progress' | 'win' | 'fail';
-}
+const ListingList = ({ filter, userId }: ListingListParams) => {
+ const { data, isLoading, error } = useGetListingList({ userId, filter });
-const ListingList = ({ filter }: ListingListProps) => {
- const userId = useAuthStore((state) => state.user?.id) as string;
-
- const { data, isLoading, error } = useGetListingList({
- userId,
- filter,
- });
-
- if (isLoading || error || !data) {
- return ;
+ if (isLoading) {
+ return (
+
+
+
+
+
+ );
}
+ if (error)
+ return (
+ ๋ด์ญ์ ์ฐพ์ ์ ์์ต๋๋ค.
+ );
+ if (!data || data.length === 0)
+ return ๋ด์ญ์ด ์์ต๋๋ค.
;
+
return (
diff --git a/apps/web/features/auction/secret/actions/checkSecretViewHistory.ts b/apps/web/features/auction/secret/actions/checkSecretViewHistory.ts
new file mode 100644
index 00000000..5790e58e
--- /dev/null
+++ b/apps/web/features/auction/secret/actions/checkSecretViewHistory.ts
@@ -0,0 +1,38 @@
+'use server';
+
+import { SecretViewHistory } from '@/entities/auction/model/types';
+import getUserId from '@/shared/lib/getUserId';
+import { supabase } from '@/shared/lib/supabaseClient';
+
+export default async function checkSecretViewHistory(
+ auctionId: string
+): Promise
{
+ const userId = await getUserId();
+
+ if (!userId) {
+ return { hasPaid: false, isValid: false };
+ }
+ const { data, error } = await supabase
+ .from('secret_bid_view_history')
+ .select('viewed_at')
+ .eq('auction_id', auctionId)
+ .eq('user_id', userId)
+ .order('viewed_at', { ascending: false })
+ .limit(1)
+ .single();
+
+ if (error || !data) {
+ return { hasPaid: false, isValid: false };
+ }
+
+ const viewedAt = new Date(data.viewed_at);
+ const now = new Date();
+ const diffMs = now.getTime() - viewedAt.getTime();
+ const isValid = diffMs < 10 * 60 * 1000; // 10๋ถ ์ด๋ด
+
+ return {
+ hasPaid: true,
+ isValid,
+ viewedAt: viewedAt.toISOString(),
+ };
+}
diff --git a/apps/web/features/auction/secret/actions/getBidHistory.ts b/apps/web/features/auction/secret/actions/getBidHistory.ts
new file mode 100644
index 00000000..7be2ead9
--- /dev/null
+++ b/apps/web/features/auction/secret/actions/getBidHistory.ts
@@ -0,0 +1,30 @@
+'use server';
+
+import { BidHistoryWithUserNickname } from '@/entities/bidHistory/model/types';
+import { createClient } from '@/shared/lib/supabase/server';
+
+export default async function getBidHistory(
+ auctionId: string
+): Promise {
+ const supabase = await createClient();
+
+ const { data, error } = await supabase
+ .from('bid_history')
+ .select(
+ `
+ *,
+ bid_user_nickname:bid_user_id (
+ nickname
+ )
+ `
+ )
+ .eq('auction_id', auctionId)
+ .order('bid_price', { ascending: false });
+
+ if (error || !data) {
+ console.error('์ต๊ณ ์
์ฐฐ๊ฐ ์กฐํ ์คํจ:');
+ throw new Error(error.message);
+ }
+
+ return data;
+}
diff --git a/apps/web/features/auction/secret/actions/saveSecretViewHistory.ts b/apps/web/features/auction/secret/actions/saveSecretViewHistory.ts
new file mode 100644
index 00000000..ed462e53
--- /dev/null
+++ b/apps/web/features/auction/secret/actions/saveSecretViewHistory.ts
@@ -0,0 +1,17 @@
+'use server';
+
+import getUserId from '@/shared/lib/getUserId';
+import { supabase } from '@/shared/lib/supabaseClient';
+
+export async function saveSecretViewHistory(auctionId: string) {
+ const user_id = await getUserId();
+ if (!user_id) return { success: false };
+
+ const { error } = await supabase.from('secret_bid_view_history').insert({
+ auction_id: auctionId,
+ user_id,
+ viewed_at: new Date().toISOString(),
+ });
+
+ return { success: !error };
+}
diff --git a/apps/web/features/auction/secret/lib/utils.ts b/apps/web/features/auction/secret/lib/utils.ts
new file mode 100644
index 00000000..4f20496a
--- /dev/null
+++ b/apps/web/features/auction/secret/lib/utils.ts
@@ -0,0 +1,7 @@
+export const isMoreThanOneHour = (endTime: string | Date): boolean => {
+ const now = new Date();
+ const end = new Date(endTime);
+ const diff = end.getTime() - now.getTime();
+
+ return diff > 60 * 60 * 1000;
+};
diff --git a/apps/web/features/auction/secret/model/checkSecretBoard.ts b/apps/web/features/auction/secret/model/checkSecretBoard.ts
new file mode 100644
index 00000000..86250222
--- /dev/null
+++ b/apps/web/features/auction/secret/model/checkSecretBoard.ts
@@ -0,0 +1,81 @@
+'use client';
+
+import { toast } from '@repo/ui/components/Toast/Sonner';
+import { createPointByReason } from '@/features/point/api/createPointByReason';
+import { BidHistoryWithUserNickname } from '@/entities/bidHistory/model/types';
+import checkSecretViewHistory from '@/features/auction/secret/actions/checkSecretViewHistory';
+import { saveSecretViewHistory } from '@/features/auction/secret/actions/saveSecretViewHistory';
+import { isMoreThanOneHour } from '@/features/auction/secret/lib/utils';
+import getBidHistory from '@/features/auction/secret/actions/getBidHistory';
+
+export type UIAdapter = {
+ alertTimeLimit: () => Promise;
+ alertNotEnoughPoint: () => Promise;
+ confirmSpendPoints: () => Promise;
+};
+
+interface CheckSecretBoardProps {
+ auctionId: string;
+ auctionEndAt: string;
+ bidCnt: number;
+ ui: UIAdapter;
+}
+
+export async function checkSecretBoard({
+ auctionId,
+ auctionEndAt,
+ bidCnt,
+ ui,
+}: CheckSecretBoardProps): Promise {
+ // ๋ง๊ฐ ํ ์๊ฐ ์ดํ๋ฉด ํ์ธ๋ถ๊ฐ ๋ชจ๋ฌ
+ if (!isMoreThanOneHour(auctionEndAt)) {
+ await ui.alertTimeLimit();
+ return null;
+ }
+ //์์ง ์๋ฌด๋ ์
์ฐฐ์ ์ํ์ ๊ฒฝ์ฐ
+ if (bidCnt <= 0) {
+ toast({ content: '์ฒซ ๋ฒ์งธ ์
์ฐฐ์๊ฐ ๋์ด๋ณด์ธ์!' });
+ return null;
+ }
+ // 1. ๊ณผ๊ฑฐ์ ํ์ธํ๋์ง ๊ฒ์ฌ
+ //1-1. 10๋ถ ์์ง๋ฌ์
+ const { hasPaid, isValid } = await checkSecretViewHistory(auctionId);
+ if (hasPaid && isValid) {
+ return await getBidHistory(auctionId);
+ }
+
+ // 2. ์ ์ ํฌ์ธํธ ์กฐํ (๊ฒฐ์ ๋ด์ญ์์ ํน์ 10๋ถ์ง๋จ)
+ try {
+ const res = await fetch('/api/profile');
+ const { point } = await res.json();
+ if (Number(point) < 500) {
+ await ui.alertNotEnoughPoint();
+ return null;
+ }
+ } catch {
+ toast({ content: 'ํฌ์ธํธ ์ ๋ณด๋ฅผ ๋ถ๋ฌ์ค์ง ๋ชปํ์ด์.' });
+ return null;
+ }
+
+ //ํฌ์ธํธ ์ฌ์ฉ ํ์ธ ๋ชจ๋ฌ
+ const ok = await ui.confirmSpendPoints();
+ if (!ok) return null;
+
+ // 3. ํ์คํ ๋ฆฌ ์ ์ฅ
+ const historyResult = await saveSecretViewHistory(auctionId);
+ if (!historyResult.success) {
+ toast({ content: '์
์ฐฐ ํ์คํ ๋ฆฌ ์ ์ฅ์ ์คํจํ์ด์. ์ ์ ํ ๋ค์ ์๋ํด์ฃผ์ธ์.' });
+ return null;
+ }
+
+ // 4. ํฌ์ธํธ ์ฐจ๊ฐ ์์ฒญ
+ try {
+ const pointRes = await createPointByReason('secret_bid_view', 'loginUser');
+ if (!pointRes?.success) throw new Error('ํฌ์ธํธ ์ฐจ๊ฐ ์คํจ');
+ } catch {
+ toast({ content: 'ํฌ์ธํธ ์ฐจ๊ฐ์ ์คํจํ์ด์. ์ ์ ํ ๋ค์ ์๋ํด์ฃผ์ธ์.' });
+ return null;
+ }
+
+ return await getBidHistory(auctionId);
+}
diff --git a/apps/web/features/auction/secret/model/useSecretDialog.tsx b/apps/web/features/auction/secret/model/useSecretDialog.tsx
new file mode 100644
index 00000000..bec8168c
--- /dev/null
+++ b/apps/web/features/auction/secret/model/useSecretDialog.tsx
@@ -0,0 +1,215 @@
+'use client';
+
+import React, { useRef, useState, useCallback } from 'react';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from '@repo/ui/components/Dialog/Dialog';
+import { Button } from '@repo/ui/components/Button/Button';
+import Image from 'next/image';
+
+type DialogKind = 'timeLimit' | 'pointShort' | 'confirm' | 'about' | null;
+
+export function useSecretDialog() {
+ const [open, setOpen] = useState(false);
+ const [kind, setKind] = useState(null);
+
+ // ๊ฐ ๋ชจ๋ฌ๋ณ resolver
+ const resolveVoidRef = useRef<(() => void) | null>(null); // alert์ฉ
+ const resolveBoolRef = useRef<((v: boolean) => void) | null>(null); // confirm์ฉ
+
+ const alertTimeLimit = useCallback(() => {
+ return new Promise((resolve) => {
+ resolveVoidRef.current = resolve;
+ setKind('timeLimit');
+ setOpen(true);
+ });
+ }, []);
+
+ const alertNotEnoughPoint = useCallback(() => {
+ return new Promise((resolve) => {
+ resolveVoidRef.current = resolve;
+ setKind('pointShort');
+ setOpen(true);
+ });
+ }, []);
+
+ const confirmSpendPoints = useCallback(() => {
+ return new Promise((resolve) => {
+ resolveBoolRef.current = resolve;
+ setKind('confirm');
+ setOpen(true);
+ });
+ }, []);
+
+ const openSecretGuide = useCallback(() => {
+ return new Promise((resolve) => {
+ resolveVoidRef.current = resolve;
+ setKind('about');
+ setOpen(true);
+ });
+ }, []);
+
+ // --- ๋จ์ผ Dialog ํธ์คํธ
+ const DialogHost = useCallback(
+ () => (
+
+ ),
+ [open, kind]
+ );
+
+ return {
+ DialogHost,
+ alertTimeLimit,
+ alertNotEnoughPoint,
+ confirmSpendPoints,
+ openSecretGuide,
+ };
+}
diff --git a/apps/web/features/auction/secret/types/index.ts b/apps/web/features/auction/secret/types/index.ts
new file mode 100644
index 00000000..06a7dc4c
--- /dev/null
+++ b/apps/web/features/auction/secret/types/index.ts
@@ -0,0 +1,11 @@
+import { BidHistoryWithUserNickname } from '@/entities/bidHistory/model/types';
+
+export interface CheckHighestBidOptions {
+ auctionId: string;
+ auctionEndAt: string;
+ bidCnt: number;
+ onSuccess: (history: BidHistoryWithUserNickname[]) => void;
+ alertTimeLimit: () => Promise;
+ alertNotEnoughPoint: () => Promise;
+ confirmSpendPoints: () => Promise;
+}
diff --git a/apps/web/features/auction/secret/ui/SecretBiddingStatusBoard.tsx b/apps/web/features/auction/secret/ui/SecretBiddingStatusBoard.tsx
new file mode 100644
index 00000000..ba30d3d5
--- /dev/null
+++ b/apps/web/features/auction/secret/ui/SecretBiddingStatusBoard.tsx
@@ -0,0 +1,100 @@
+import { BidHistoryWithUserNickname } from '@/entities/bidHistory/model/types';
+import BiddingStatusBoard from '@/features/auction/detail/ui/BiddingStatusBoard';
+import { checkSecretBoard } from '@/features/auction/secret/model/checkSecretBoard';
+import { useSecretDialog } from '@/features/auction/secret/model/useSecretDialog';
+import { Button } from '@repo/ui/components/Button/Button';
+import { throttle } from 'lodash';
+import { Info } from 'lucide-react';
+import { useEffect, useMemo, useState } from 'react';
+
+interface SecretBidStatusBoardProps {
+ auctionId: string;
+ auctionEndAt: string;
+ bidCnt: number;
+ isSecret: boolean;
+ onNewHighestBid: (newPrice: number) => void;
+}
+
+const SecretBidStatusBoard = ({
+ auctionId,
+ auctionEndAt,
+ bidCnt,
+ isSecret,
+ onNewHighestBid,
+}: SecretBidStatusBoardProps) => {
+ const [showHighestBid, setShowHighestBid] = useState(false);
+ const [bidHistory, setBidHistory] = useState(null);
+
+ const { DialogHost, alertTimeLimit, alertNotEnoughPoint, confirmSpendPoints, openSecretGuide } =
+ useSecretDialog();
+
+ const handleCheck = async () => {
+ const history = await checkSecretBoard({
+ auctionId,
+ auctionEndAt,
+ bidCnt,
+ ui: { alertTimeLimit, alertNotEnoughPoint, confirmSpendPoints },
+ });
+ if (history) {
+ setBidHistory(history);
+ setShowHighestBid(true);
+ }
+ };
+
+ // throttle: ์์กด๊ฐ ๋ฐ๋๋ฉด ์ ํธ๋ค๋ฌ ์์ฑ
+ const handleCheckThrottled = useMemo(
+ () => throttle(handleCheck, 2000, { trailing: false }),
+ [auctionId, auctionEndAt, bidCnt]
+ );
+
+ // ์ธ๋ง์ดํธ ์ throttle ์ทจ์(๋ฉ๋ชจ๋ฆฌ ๋์/์๋์น ํธ์ถ ๋ฐฉ์ง)
+ useEffect(() => {
+ return () => handleCheckThrottled.cancel();
+ }, [handleCheckThrottled]);
+
+ return (
+
+
+
์
์ฐฐ ํํฉํ
+ {showHighestBid && (
+
10๋ถ๊ฐ ํ์ธ ๊ฐ๋ฅํฉ๋๋ค
+ )}
+
+ {showHighestBid ? (
+ bidHistory && (
+
+
+
+ )
+ ) : (
+ <>
+
+ 500ํฌ์ธํธ๋ก ์ต๊ณ ์
์ฐฐ๊ฐ๋ฅผ ํ์ธํ ์ ์์ด์
+
+
+
+
+ {bidCnt}๋ช
์ด ์ด ์ํ์ ์
์ฐฐ
+ ์ค์ด์์!
+
+ >
+ )}
+
+
+
+ );
+};
+
+export default SecretBidStatusBoard;
diff --git a/apps/web/features/auction/shared/types/index.ts b/apps/web/features/auction/shared/types/index.ts
new file mode 100644
index 00000000..2b02db78
--- /dev/null
+++ b/apps/web/features/auction/shared/types/index.ts
@@ -0,0 +1,11 @@
+interface TabItem {
+ value: string;
+ label: string;
+ content: React.ReactNode;
+}
+
+export interface UrlSyncTabsProps {
+ defaultValue: string;
+ items: TabItem[];
+ className?: string;
+}
diff --git a/apps/web/features/auction/shared/ui/AuctionTaopTabs.tsx b/apps/web/features/auction/shared/ui/AuctionTopTabs.tsx
similarity index 100%
rename from apps/web/features/auction/shared/ui/AuctionTaopTabs.tsx
rename to apps/web/features/auction/shared/ui/AuctionTopTabs.tsx
diff --git a/apps/web/features/auction/shared/ui/UrlSyncTabs.tsx b/apps/web/features/auction/shared/ui/UrlSyncTabs.tsx
new file mode 100644
index 00000000..377d516e
--- /dev/null
+++ b/apps/web/features/auction/shared/ui/UrlSyncTabs.tsx
@@ -0,0 +1,28 @@
+'use client';
+
+import { Tabs } from '@repo/ui/components/Tabs/Tabs';
+import { useSearchParams, useRouter, usePathname } from 'next/navigation';
+import { useEffect, useState } from 'react';
+import { UrlSyncTabsProps } from '@/features/auction/shared/types';
+
+const UrlSyncTabs = ({ defaultValue, items, className }: UrlSyncTabsProps) => {
+ const searchParams = useSearchParams();
+ const router = useRouter();
+ const pathname = usePathname();
+
+ const tabFromUrl = searchParams.get('tab') ?? defaultValue;
+ const [tab, setTab] = useState(tabFromUrl);
+
+ useEffect(() => {
+ setTab(tabFromUrl);
+ }, [tabFromUrl]);
+
+ const handleTabChange = (newTab: string) => {
+ setTab(newTab);
+ router.replace(`${pathname}?tab=${newTab}`);
+ };
+
+ return ;
+};
+
+export default UrlSyncTabs;
diff --git a/apps/web/features/category/lib/utils.ts b/apps/web/features/category/lib/utils.ts
index edd79489..e7ac2d0a 100644
--- a/apps/web/features/category/lib/utils.ts
+++ b/apps/web/features/category/lib/utils.ts
@@ -6,3 +6,8 @@ export function sortCategories(selected: CategoryValue) {
const others = rest.filter((item) => item.value !== selected);
return [all, ...(selectedItem ? [selectedItem] : []), ...others];
}
+
+export const getCategoryLabel = (value: CategoryValue): string => {
+ const category = categories.find((c) => c.value === value);
+ return category?.label ?? '';
+};
diff --git a/apps/web/features/category/types/index.ts b/apps/web/features/category/types/index.ts
index 1d6efd01..757e8c3f 100644
--- a/apps/web/features/category/types/index.ts
+++ b/apps/web/features/category/types/index.ts
@@ -1,19 +1,21 @@
export const categories = [
- { label: '์ ์ฒด', value: 'all', src: '/cate-all.png' },
- { label: '์๋ฅ', value: 'clothing', src: '/cate-clothing.png' },
- { label: '์กํ', value: 'accessories', src: '/cate-accessories.png' },
- { label: '๋์งํธ๊ธฐ๊ธฐ', value: 'digital', src: '/cate-digital.png' },
- { label: '์ธํ
๋ฆฌ์ด', value: 'interior', src: '/cate-interior.png' },
- { label: '์ํ๊ฐ์ ', value: 'appliances', src: '/cate-appliances.png' },
- { label: '์ฃผ๋ฐฉ', value: 'kitchen', src: '/cate-kitchen.png' },
- { label: '์คํฌ์ธ ', value: 'sports', src: '/cate-sports.png' },
- { label: '์ทจ๋ฏธ', value: 'hobby', src: '/cate-hobby.png' },
- { label: '๋ทฐํฐ/๋ฏธ์ฉ', value: 'beauty', src: '/cate-beauty.png' },
- { label: '์๋ฌผ', value: 'plants', src: '/cate-plants.png' },
- { label: '๋ฐ๋ ค๋๋ฌผ', value: 'pet', src: '/cate-pet.png' },
- { label: 'ํฐ์ผ/๊ตํ๊ถ', value: 'ticket', src: '/cate-ticket.png' },
- { label: '๋์', value: 'book', src: '/cate-book.png' },
- { label: '๊ฐ๊ณต์ํ', value: 'processed-food', src: '/cate-processed-food.png' },
+ { label: '์ ์ฒด', value: 'all', src: '/category/cate-all.png' },
+ { label: '์๋ฅ', value: 'clothing', src: '/category/cate-clothing.png' },
+ { label: '์กํ', value: 'accessories', src: '/category/cate-accessories.png' },
+ { label: '๋์งํธ๊ธฐ๊ธฐ', value: 'digital', src: '/category/cate-digital.png' },
+ { label: '์ธํ
๋ฆฌ์ด', value: 'interior', src: '/category/cate-interior.png' },
+ { label: '์ํ๊ฐ์ ', value: 'appliances', src: '/category/cate-appliances.png' },
+ { label: '์ฃผ๋ฐฉ', value: 'kitchen', src: '/category/cate-kitchen.png' },
+ { label: '์คํฌ์ธ ', value: 'sports', src: '/category/cate-sports.png' },
+ { label: '์ทจ๋ฏธ', value: 'hobby', src: '/category/cate-hobby.png' },
+ { label: '๋ทฐํฐ/๋ฏธ์ฉ', value: 'beauty', src: '/category/cate-beauty.png' },
+ { label: '์๋ฌผ', value: 'plants', src: '/category/cate-plants.png' },
+ { label: '๋ฐ๋ ค๋๋ฌผ', value: 'pet', src: '/category/cate-pet.png' },
+ { label: 'ํฐ์ผ/๊ตํ๊ถ', value: 'ticket', src: '/category/cate-ticket.png' },
+ { label: '๋์', value: 'book', src: '/category/cate-book.png' },
+ { label: '๊ฐ๊ณต์ํ', value: 'processed-food', src: '/category/cate-processed-food.png' },
] as const;
export type CategoryValue = (typeof categories)[number]['value'];
+
+export type CategoryUi = 'inline' | 'grid';
diff --git a/apps/web/features/category/ui/Category.tsx b/apps/web/features/category/ui/Category.tsx
index ed884ed6..423ae1ba 100644
--- a/apps/web/features/category/ui/Category.tsx
+++ b/apps/web/features/category/ui/Category.tsx
@@ -1,26 +1,26 @@
'use client';
-import { categories, CategoryValue } from '@/features/category/types';
-import clsx from 'clsx';
-import Image from 'next/image';
+import { useCategoryStore } from '@/features/category/model/useCategoryStore';
+import { CategoryUi, CategoryValue } from '@/features/category/types';
+import GridCategory from '@/features/category/ui/GridCategory';
+import InlineCategory from '@/features/category/ui/InlineCategory';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect } from 'react';
-import { useCategoryStore } from '@/features/category/model/useCategoryStore';
interface CategoryProps {
- type?: 'inline' | 'grid';
+ type?: CategoryUi;
}
const Category = ({ type = 'grid' }: CategoryProps) => {
const router = useRouter();
const searchParams = useSearchParams();
-
- const selected = useCategoryStore((state) => state.selected);
const setSelected = useCategoryStore((state) => state.setSelected);
useEffect(() => {
- const cate = (searchParams.get('cate') || 'all') as CategoryValue;
- setSelected(cate);
+ const cate = searchParams.get('cate') as CategoryValue;
+ if (cate) {
+ setSelected(cate);
+ }
}, [searchParams, setSelected]);
const handleClick = (cate: CategoryValue) => {
@@ -28,57 +28,12 @@ const Category = ({ type = 'grid' }: CategoryProps) => {
const params = new URLSearchParams(searchParams);
params.set('cate', cate);
- const url = `/product?${params.toString()}`;
-
- if (type === 'grid') {
- router.push(url);
- } else {
- router.replace(url);
- }
+ const url = `/auction?${params.toString()}`;
+ type === 'grid' ? router.push(url) : router.replace(url);
};
- const renderCategoryItem = (item: (typeof categories)[number]) => {
- const isSelected = item.value === selected;
-
- return (
-
-
-
-
-
- {item.label}
-
-
- );
- };
-
- if (type === 'grid') {
- return (
-
-
- {categories.map(renderCategoryItem)}
-
-
- );
- }
-
- if (type === 'inline') {
- return (
-
- {categories.map(renderCategoryItem)}
-
- );
- }
+ if (type === 'grid') return ;
+ if (type === 'inline') return ;
return null;
};
diff --git a/apps/web/features/category/ui/CategoryFilter.tsx b/apps/web/features/category/ui/CategoryFilter.tsx
new file mode 100644
index 00000000..7699d092
--- /dev/null
+++ b/apps/web/features/category/ui/CategoryFilter.tsx
@@ -0,0 +1,31 @@
+import { categories, CategoryValue } from '@/features/category/types';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@repo/ui/components/Select/Select';
+
+interface CategoryFilterProps {
+ setCate: (cate: CategoryValue) => void;
+}
+
+const CategoryFilter = ({ setCate }: CategoryFilterProps) => {
+ return (
+
+ );
+};
+
+export default CategoryFilter;
diff --git a/apps/web/features/category/ui/GridCategory.tsx b/apps/web/features/category/ui/GridCategory.tsx
new file mode 100644
index 00000000..a831b794
--- /dev/null
+++ b/apps/web/features/category/ui/GridCategory.tsx
@@ -0,0 +1,37 @@
+import { categories, CategoryValue } from '@/features/category/types';
+import Image from 'next/image';
+
+interface GridCategoryProps {
+ onSelect: (cate: CategoryValue) => void;
+}
+
+const GridCategory = ({ onSelect }: GridCategoryProps) => {
+ return (
+
+
+ {categories.map((item) => {
+ return (
+
+
+
+
+
+ {item.label}
+
+
+ );
+ })}
+
+
+ );
+};
+
+export default GridCategory;
diff --git a/apps/web/features/category/ui/InlineCategory.tsx b/apps/web/features/category/ui/InlineCategory.tsx
new file mode 100644
index 00000000..893da5f7
--- /dev/null
+++ b/apps/web/features/category/ui/InlineCategory.tsx
@@ -0,0 +1,45 @@
+import { useCategoryStore } from '@/features/category/model/useCategoryStore';
+import { categories, CategoryValue } from '@/features/category/types';
+import clsx from 'clsx';
+import Image from 'next/image';
+
+interface InlineCategoryProps {
+ onSelect: (cate: CategoryValue) => void;
+}
+
+const InlineCategory = ({ onSelect }: InlineCategoryProps) => {
+ const selected = useCategoryStore((state) => state.selected);
+
+ return (
+
+ {categories.map((item) => {
+ const isSelected = item.value === selected;
+ return (
+
+
+
+
+
+ {item.label}
+
+
+ );
+ })}
+
+ );
+};
+
+export default InlineCategory;
diff --git a/apps/web/features/chat/list/api/fetchIsChatEnd.ts b/apps/web/features/chat/list/api/fetchIsChatEnd.ts
new file mode 100644
index 00000000..2dcc7e9f
--- /dev/null
+++ b/apps/web/features/chat/list/api/fetchIsChatEnd.ts
@@ -0,0 +1,11 @@
+export const fetchIsChatEnd = async (chatRoomId: string): Promise => {
+ const baseURL = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
+ const res = await fetch(`${baseURL}/api/chat/checkIsChatEnd/${chatRoomId}`);
+ const result = await res.json();
+
+ if (!res.ok) {
+ throw new Error(result.error || '์ฑํ
์ข
๋ฃ ์ฌ๋ถ ํ์ธ ์คํจ');
+ }
+
+ return result.isChatEnd as boolean;
+};
diff --git a/apps/web/features/chat/list/api/getChatList.ts b/apps/web/features/chat/list/api/getChatList.ts
new file mode 100644
index 00000000..0ea44cd5
--- /dev/null
+++ b/apps/web/features/chat/list/api/getChatList.ts
@@ -0,0 +1,15 @@
+import { ChatRoomForList } from '@/entities/chatRoom/model/types';
+
+export const getChatList = async (): Promise => {
+ const baseURL = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
+
+ const res = await fetch(`${baseURL}/api/chat`);
+
+ if (!res.ok) {
+ console.error('์ฑํ
๋ฆฌ์คํธ ์กฐํ API ์คํจ:', res.status);
+ return [];
+ }
+
+ const data = await res.json();
+ return data as ChatRoomForList[];
+};
diff --git a/apps/web/features/chat/list/api/inactiveChat.ts b/apps/web/features/chat/list/api/inactiveChat.ts
new file mode 100644
index 00000000..41b0eb60
--- /dev/null
+++ b/apps/web/features/chat/list/api/inactiveChat.ts
@@ -0,0 +1,20 @@
+import { ApiError } from 'next/dist/server/api-utils';
+
+export const inactiveChat = async (chatRoom: string, exhibitUser: string) => {
+ const baseURL = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
+ const res = await fetch(`${baseURL}/api/chat`, {
+ method: 'POST',
+ body: JSON.stringify({
+ chatRoom,
+ exhibitUser,
+ }),
+ });
+
+ if (!res.ok) {
+ const errorData: ApiError = await res.json();
+ console.error(errorData.message);
+ throw new Error(errorData.message || '์ฑํ
๋๊ฐ๊ธฐ ์คํจ');
+ }
+
+ return res.json();
+};
diff --git a/apps/web/features/chat/list/api/useMessageRealtimeForList.ts b/apps/web/features/chat/list/api/useMessageRealtimeForList.ts
new file mode 100644
index 00000000..9c23c7ac
--- /dev/null
+++ b/apps/web/features/chat/list/api/useMessageRealtimeForList.ts
@@ -0,0 +1,61 @@
+import { useEffect } from 'react';
+import { useQueryClient } from '@tanstack/react-query';
+import { useAuthStore } from '@/shared/model/authStore';
+import { createClient } from '@/shared/lib/supabase/client';
+
+export const useMessageRealtimeForList = () => {
+ const queryClient = useQueryClient();
+ const userId = useAuthStore((state) => state.user?.id);
+ const supabase = createClient();
+
+ useEffect(() => {
+ if (!userId) return;
+
+ const channel = supabase.channel('chat_list_updates');
+ // 1. message ํ
์ด๋ธ ๋ณ๊ฒฝ ๊ฐ์ง (์ ๋ฉ์์ง, ์ฝ์ ์ํ ๋ณ๊ฒฝ ๋ฑ)
+ channel.on(
+ 'postgres_changes',
+ {
+ event: '*',
+ schema: 'public',
+ table: 'message',
+ },
+ () => {
+ queryClient.invalidateQueries({ queryKey: ['chatList'] });
+ }
+ );
+
+ // 2. chat_room ํ
์ด๋ธ ๋ณ๊ฒฝ ๊ฐ์ง (์ ์ฑํ
๋ฐฉ ์์ฑ)
+ channel.on(
+ 'postgres_changes',
+ {
+ event: 'INSERT',
+ schema: 'public',
+ table: 'chat_room',
+ filter: `exhibit_user_id=eq.${userId}`,
+ },
+ () => {
+ queryClient.invalidateQueries({ queryKey: ['chatList'] });
+ }
+ );
+
+ channel.on(
+ 'postgres_changes',
+ {
+ event: 'INSERT',
+ schema: 'public',
+ table: 'chat_room',
+ filter: `bid_user_id=eq.${userId}`,
+ },
+ () => {
+ queryClient.invalidateQueries({ queryKey: ['chatList'] });
+ }
+ );
+
+ channel.subscribe();
+
+ return () => {
+ channel.unsubscribe();
+ };
+ }, [userId]);
+};
diff --git a/apps/web/features/chat/list/lib/getTimeAgo.ts b/apps/web/features/chat/list/lib/getTimeAgo.ts
new file mode 100644
index 00000000..34f0d50e
--- /dev/null
+++ b/apps/web/features/chat/list/lib/getTimeAgo.ts
@@ -0,0 +1,51 @@
+export function getTimeAgo(timestamp: string | Date): string {
+ const now = new Date();
+ const date = typeof timestamp === 'string' ? new Date(timestamp) : timestamp;
+
+ const diffMs = now.getTime() - date.getTime();
+ const seconds = Math.floor(diffMs / 1000);
+ const minutes = Math.floor(diffMs / 1000 / 60);
+ const hours = Math.floor(diffMs / 1000 / 60 / 60);
+ const days = Math.floor(diffMs / 1000 / 60 / 60 / 24);
+
+ const nowY = now.getFullYear();
+ const nowM = now.getMonth();
+ const nowD = now.getDate();
+
+ const dateY = date.getFullYear();
+ const dateM = date.getMonth();
+ const dateD = date.getDate();
+
+ // 1๋ถ ์ด๋ด
+ if (seconds < 60) return '๋ฐฉ๊ธ ์ ';
+
+ // 60๋ถ ์ด๋ด
+ if (minutes < 60) return `${minutes}๋ถ ์ `;
+
+ // 24์๊ฐ ์ด๋ด
+ if (hours < 24) return `${hours}์๊ฐ ์ `;
+
+ // ์ด์
+ const yesterday = new Date(now);
+ yesterday.setDate(now.getDate() - 1);
+ if (
+ dateY === yesterday.getFullYear() &&
+ dateM === yesterday.getMonth() &&
+ dateD === yesterday.getDate()
+ ) {
+ return '์ด์ ';
+ }
+
+ // 7์ผ ๋ฏธ๋ง
+ if (days < 7) return `${days}์ผ ์ `;
+
+ // ๊ฐ์ ํด
+ if (nowY === dateY) {
+ return `${(dateM + 1).toString().padStart(2, '0')}์ ${dateD.toString().padStart(2, '0')}์ผ`;
+ }
+
+ // ๋ค๋ฅธ ํด
+ return `${dateY}๋
${(dateM + 1).toString().padStart(2, '0')}์ ${dateD
+ .toString()
+ .padStart(2, '0')}์ผ`;
+}
diff --git a/apps/web/features/chat/list/model/useChatList.ts b/apps/web/features/chat/list/model/useChatList.ts
new file mode 100644
index 00000000..aaa19b6f
--- /dev/null
+++ b/apps/web/features/chat/list/model/useChatList.ts
@@ -0,0 +1,11 @@
+import { ChatRoomForList } from '@/entities/chatRoom/model/types';
+import { useQuery } from '@tanstack/react-query';
+import { getChatList } from '../api/getChatList';
+
+export const useChatList = () => {
+ return useQuery({
+ queryKey: ['chatList'],
+ queryFn: () => getChatList(),
+ staleTime: 1000 * 60 * 1,
+ });
+};
diff --git a/apps/web/features/chat/list/model/useIsChatEnd.ts b/apps/web/features/chat/list/model/useIsChatEnd.ts
new file mode 100644
index 00000000..97fce144
--- /dev/null
+++ b/apps/web/features/chat/list/model/useIsChatEnd.ts
@@ -0,0 +1,11 @@
+import { useQuery } from '@tanstack/react-query';
+import { fetchIsChatEnd } from '../api/fetchIsChatEnd';
+
+export const useIsChatEnd = (shortId: string) => {
+ return useQuery({
+ queryKey: ['chatRoom_active', shortId],
+ queryFn: () => fetchIsChatEnd(shortId),
+ enabled: !!shortId,
+ staleTime: Number.POSITIVE_INFINITY,
+ });
+};
diff --git a/apps/web/features/chat/list/types/index.ts b/apps/web/features/chat/list/types/index.ts
new file mode 100644
index 00000000..074dbb37
--- /dev/null
+++ b/apps/web/features/chat/list/types/index.ts
@@ -0,0 +1,11 @@
+import { ChatRoomForList } from '@/entities/chatRoom/model/types';
+
+export interface ChatListProps {
+ filter: 'all' | 'buy' | 'sell' | 'unread';
+ data: ChatRoomForList[];
+}
+
+export interface ChatItemProps {
+ data: ChatRoomForList;
+ isLastMsgMine: boolean;
+}
diff --git a/apps/web/features/chat/list/ui/ChatItem.tsx b/apps/web/features/chat/list/ui/ChatItem.tsx
new file mode 100644
index 00000000..edf82bcf
--- /dev/null
+++ b/apps/web/features/chat/list/ui/ChatItem.tsx
@@ -0,0 +1,70 @@
+import { Avatar } from '@repo/ui/components/Avatar/Avatar';
+import { ChevronRight } from 'lucide-react';
+import Image from 'next/image';
+import React from 'react';
+import { ChatItemProps } from '../types';
+import StatusBadge from '@/shared/ui/badge/StatusBadge';
+import { getTimeAgo } from '../lib/getTimeAgo';
+
+const ChatItem = ({ data, isLastMsgMine }: ChatItemProps) => {
+ return (
+
+
+
+
+
+
+
+
+
{data.your_profile.nickname}
+ {data.is_win &&
}
+
+
+ {data.last_message ? (
+ data.last_message.message_type === 'image' ? (
+
+ ์ฌ์ง์ ๋ณด๋์ต๋๋ค.{' '}
+
+ ) : (
+
+
+ {data.last_message?.content}
+
+
+ ยท {getTimeAgo(data.last_message.created_at)}
+
+
+ )
+ ) : (
+
์์ง ๋ํ๊ฐ ์์ต๋๋ค.
+ )}
+
+
+
+
+ {data.unread_count !== 0 && (
+
+ {data.unread_count}
+
+ )}
+
+
+
+ );
+};
+
+export default ChatItem;
diff --git a/apps/web/features/chat/list/ui/ChatList.tsx b/apps/web/features/chat/list/ui/ChatList.tsx
new file mode 100644
index 00000000..e76fcd9b
--- /dev/null
+++ b/apps/web/features/chat/list/ui/ChatList.tsx
@@ -0,0 +1,151 @@
+import React, { useState } from 'react';
+import { ChatListProps } from '../types';
+import ChatItem from './ChatItem';
+import Link from 'next/link';
+import { useAuthStore } from '@/shared/model/authStore';
+import { encodeUUID } from '@/shared/lib/shortUuid';
+import SwipeableItem from '@/shared/ui/listItem/SwipeableItem';
+import { inactiveChat } from '../api/inactiveChat';
+import { useQueryClient } from '@tanstack/react-query';
+import { toast } from '@repo/ui/components/Toast/Sonner';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from '@repo/ui/components/Dialog/Dialog';
+import { Button } from '@repo/ui/components/Button/Button';
+import { useMessageRealtimeForList } from '../api/useMessageRealtimeForList';
+
+const ChatList = ({ filter, data }: ChatListProps) => {
+ const queryClient = useQueryClient();
+ useMessageRealtimeForList();
+ const userId = useAuthStore((state) => state.user?.id) as string;
+ const [isDialogOpen, setIsDialogOpen] = useState(false);
+ const [pendingDelete, setPendingDelete] = useState<{
+ chatRoom: string;
+ exhibitUser: string;
+ } | null>(null);
+
+ let filteredData;
+ switch (filter) {
+ case 'buy':
+ filteredData = data.filter((item) => item.bid_user_id === userId);
+ break;
+ case 'sell':
+ filteredData = data.filter((item) => item.exhibit_user_id === userId);
+ break;
+ case 'unread':
+ filteredData = data.filter(
+ (item) => item.last_message?.is_read === false && item.last_message.sender_id !== userId
+ );
+ break;
+ default:
+ filteredData = data;
+ break;
+ }
+
+ const [openItemId, setOpenItemId] = useState(null);
+ const [isDragging, setIsDragging] = useState(false);
+
+ const handleOpen = (id: string) => {
+ // ์ด๋ฏธ ๊ฐ์ ์์ดํ
์ด ์ด๋ ค์์ผ๋ฉด ๋ฌด์
+ if (openItemId === id) return;
+
+ // ๋ค๋ฅธ ์์ดํ
์ด ์ด๋ ค์์ผ๋ฉด ์ฆ์ ๋ซ๊ณ ์ ์์ดํ
์ด๊ธฐ
+ setOpenItemId(id);
+ };
+
+ const handleClose = () => {
+ setOpenItemId(null);
+ };
+
+ const handleLeaveChat = async (chatRoom: string, exhibitUser: string) => {
+ try {
+ await inactiveChat(chatRoom, exhibitUser);
+ await queryClient.invalidateQueries({ queryKey: ['chatList'] });
+ toast({ content: '์ญ์ ๋์์ต๋๋ค.' });
+ } catch (e) {
+ toast({ content: '์ฑํ
๋ฐฉ ๋๊ฐ๊ธฐ์ ์คํจํ์ต๋๋ค.' });
+ }
+ };
+
+ return (
+
+ {filteredData?.map((chat, index) => (
+
+ {index !== 0 &&
}
+
handleOpen(chat.chatroom_id)}
+ onClose={handleClose}
+ onDelete={() => {
+ setPendingDelete({
+ chatRoom: encodeUUID(chat.chatroom_id),
+ exhibitUser: encodeUUID(chat.exhibit_user_id),
+ });
+ setIsDialogOpen(true);
+ }}
+ btnText={'๋๊ฐ๊ธฐ'}
+ onDragChange={(dragging) => setIsDragging(dragging)}
+ >
+ {
+ if (isDragging) {
+ e.preventDefault();
+ e.stopPropagation();
+ return;
+ }
+
+ if (openItemId === chat.chatroom_id) {
+ e.preventDefault();
+ setOpenItemId(null);
+ }
+ }}
+ >
+
+
+
+
+ ))}
+
+
+ );
+};
+
+export default ChatList;
diff --git a/apps/web/features/chat/list/ui/ChatListPageContent.tsx b/apps/web/features/chat/list/ui/ChatListPageContent.tsx
new file mode 100644
index 00000000..779c4e46
--- /dev/null
+++ b/apps/web/features/chat/list/ui/ChatListPageContent.tsx
@@ -0,0 +1,34 @@
+'use client';
+
+import { Tabs } from '@repo/ui/components/Tabs/Tabs';
+import React from 'react';
+import ChatList from './ChatList';
+import { useChatList } from '../model/useChatList';
+import Loading from '@/shared/ui/Loading/Loading';
+
+const ChatListPageContent = () => {
+ const { data, isLoading, error } = useChatList();
+
+ if (isLoading) return ;
+ if (error) return ์ค๋ฅ: {(error as Error).message}
;
+ if (!data || data.length === 0)
+ return ์ฑํ
๋ด์ญ์ด ์์ต๋๋ค.
;
+
+ const items = [
+ { value: 'all', label: '์ ์ฒด ์ฑํ
', content: },
+ { value: 'buy', label: '๊ตฌ๋งค ์ฑํ
', content: },
+ { value: 'sell', label: 'ํ๋งค ์ฑํ
', content: },
+ {
+ value: 'unread',
+ label: '์ ์ฝ์ ์ฑํ
',
+ content: ,
+ },
+ ];
+ return (
+
+
+
+ );
+};
+
+export default ChatListPageContent;
diff --git a/apps/web/features/chat/nav/api/useUnreadMessagesCount.ts b/apps/web/features/chat/nav/api/useUnreadMessagesCount.ts
new file mode 100644
index 00000000..83f97368
--- /dev/null
+++ b/apps/web/features/chat/nav/api/useUnreadMessagesCount.ts
@@ -0,0 +1,107 @@
+import { createClient } from '@/shared/lib/supabase/client';
+import { useAuthStore } from '@/shared/model/authStore';
+import { useEffect } from 'react';
+import { useUnreadStore } from '../model/unreadStore';
+
+export const useUnreadMessagesCount = () => {
+ const userId = useAuthStore((state) => state.user?.id) as string;
+ const supabase = createClient();
+ const { setCount, increase, decrease } = useUnreadStore();
+
+ useEffect(() => {
+ if (!userId) return;
+
+ // 1. ์ด๊ธฐ ์ฝ์ง ์์ ๋ฉ์ธ์ง ์ ๊ฐ์ ธ์ค๊ธฐ
+ const fetchInitialUnreadMessages = async () => {
+ const { data, error } = await supabase.rpc('get_unread_message_count', {
+ user_id: userId,
+ });
+
+ if (error) {
+ throw new Error('์ฝ์ง ์์ ๋ฉ์ธ์ง ์ ์ด๊ธฐ ์กฐํ ์คํจ', error);
+ }
+ setCount(data || 0);
+ };
+
+ fetchInitialUnreadMessages();
+
+ // 2. realtime ๊ตฌ๋
์ค์
+ const channel = supabase.channel('unread_messages');
+
+ channel.on(
+ 'postgres_changes',
+ {
+ event: 'INSERT',
+ schema: 'public',
+ table: 'message',
+ },
+ (payload) => {
+ const newMessage = payload.new;
+ if (newMessage.sender_id !== userId && newMessage.is_read === false) {
+ increase(1);
+ }
+ }
+ );
+
+ // UPDATE ๊ฐ์ง
+ // 1. is_read: false โ true๋ก ๋ฐ๋ ๊ฒฝ์ฐ ๊ฐ์
+ channel.on(
+ 'postgres_changes',
+ {
+ event: 'UPDATE',
+ schema: 'public',
+ table: 'message',
+ },
+ (payload) => {
+ const oldMessage = payload.old;
+ const newMessage = payload.new;
+
+ const wasUnread = oldMessage.is_read === false;
+ const nowRead = newMessage.is_read === true;
+
+ // ๋ด๊ฐ ๋ฐ์ ๋ฉ์์ง๋ฉด์ ์ฝ์ ์ฒ๋ฆฌ๋ ๊ฒฝ์ฐ๋ง ์ฒ๋ฆฌ
+ if (newMessage.sender_id !== userId && wasUnread && nowRead) {
+ decrease(1);
+ }
+ }
+ );
+
+ // 2. ์ฑํ
๋ฐฉ ๋๊ฐ๊ธฐ(ํ์ฑ ์ํ ๋ณ๊ฒฝ) ๊ฐ์ง
+ channel.on(
+ 'postgres_changes',
+ {
+ event: 'UPDATE',
+ schema: 'public',
+ table: 'chat_room',
+ },
+ async (payload) => {
+ const oldRoom = payload.old;
+ const newRoom = payload.new;
+
+ // ๋ด๊ฐ ๋๊ฐ ๊ฒฝ์ฐ๋ง ๊ฐ์ง (bid_user์ธ์ง exhibit_user์ธ์ง์ ๋ฐ๋ผ)
+ const isSelfLeaved =
+ (newRoom.bid_user_id === userId && newRoom.bid_user_active === false) ||
+ (newRoom.exhibit_user_id === userId && newRoom.exhibit_user_active === false);
+
+ if (!isSelfLeaved) return;
+
+ // 3. ๋ด๊ฐ ๋๊ฐ ๋ฐฉ์ ๋ฏธ์ฝ์ ๋ฉ์์ง ์ ๊ณ์ฐ
+ const { count, error } = await supabase
+ .from('message')
+ .select('message_id', { count: 'exact' })
+ .eq('chatroom_id', newRoom.chatroom_id)
+ .eq('is_read', false)
+ .neq('sender_id', userId);
+
+ if (!error && count && count > 0) {
+ decrease(count);
+ }
+ }
+ );
+ channel.subscribe();
+
+ return () => {
+ supabase.removeChannel(channel);
+ };
+ }, [userId]);
+};
diff --git a/apps/web/features/chat/nav/model/unreadStore.ts b/apps/web/features/chat/nav/model/unreadStore.ts
new file mode 100644
index 00000000..b52f77af
--- /dev/null
+++ b/apps/web/features/chat/nav/model/unreadStore.ts
@@ -0,0 +1,17 @@
+import { create } from 'zustand';
+
+type UnreadState = {
+ count: number;
+ setCount: (value: number) => void;
+ increase: (n?: number) => void;
+ decrease: (n?: number) => void;
+ reset: () => void;
+};
+
+export const useUnreadStore = create((set) => ({
+ count: 0,
+ setCount: (value) => set({ count: value }),
+ increase: (n = 1) => set((state) => ({ count: state.count + n })),
+ decrease: (n = 1) => set((state) => ({ count: Math.max(state.count - n, 0) })),
+ reset: () => set({ count: 0 }),
+}));
diff --git a/apps/web/features/chat/room/api/createChatRoom.ts b/apps/web/features/chat/room/api/createChatRoom.ts
new file mode 100644
index 00000000..ea87b65c
--- /dev/null
+++ b/apps/web/features/chat/room/api/createChatRoom.ts
@@ -0,0 +1,18 @@
+export const createChatRoom = async (auction: string, exhibitUser: string, bidUser: string) => {
+ const baseURL = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
+ const res = await fetch(`${baseURL}/api/chat/create`, {
+ method: 'POST',
+ body: JSON.stringify({
+ auction,
+ exhibitUser,
+ bidUser,
+ }),
+ });
+
+ if (!res.ok) {
+ const { error } = await res.json();
+ throw new Error(error || '์ฑํ
๋ฐฉ ์์ฑ ์คํจ');
+ }
+
+ return res.json();
+};
diff --git a/apps/web/features/chat/room/api/createSystemMessage.ts b/apps/web/features/chat/room/api/createSystemMessage.ts
new file mode 100644
index 00000000..90de28f8
--- /dev/null
+++ b/apps/web/features/chat/room/api/createSystemMessage.ts
@@ -0,0 +1,37 @@
+'use server';
+
+import { CreateSystemMessagePayload } from '../types';
+
+export const createSystemMessage = async ({
+ chatroomId,
+ exhibitUserId,
+ bidUserId,
+ imgUrl,
+ price,
+ title,
+}: CreateSystemMessagePayload): Promise => {
+ const baseURL = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
+
+ const response = await fetch(`${baseURL}/api/system-message`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ chatroomId,
+ exhibitUserId,
+ bidUserId,
+ imgUrl,
+ price,
+ title,
+ }),
+ });
+
+ if (!response.ok) {
+ console.error('์์คํ
๋ฉ์์ง ์์ฑ ์คํจ');
+ return null;
+ }
+
+ const result = await response.json();
+ return result.systemMessageId ?? null;
+};
diff --git a/apps/web/features/chat/room/api/getAuctionInfo.ts b/apps/web/features/chat/room/api/getAuctionInfo.ts
new file mode 100644
index 00000000..477da3a3
--- /dev/null
+++ b/apps/web/features/chat/room/api/getAuctionInfo.ts
@@ -0,0 +1,31 @@
+'use server';
+
+import { decodeShortId } from '@/shared/lib/shortUuid';
+import { supabase } from '@/shared/lib/supabaseClient';
+import { AuctionInfoData } from '../types';
+import { getYourNickName } from './getYourNickName';
+
+export const getAuctionInfo = async (shortId: string) => {
+ const fullChatRoomId = decodeShortId(shortId);
+
+ const { data, error } = await supabase.rpc('get_auction_info', {
+ chatroom_id_input: fullChatRoomId,
+ });
+
+ if (error) {
+ throw new Error(`AuctionInfo ์กฐํ ์คํจ: ${error.message}`);
+ }
+
+ const yourNickName = await getYourNickName(fullChatRoomId);
+
+ return {
+ auctionId: data.auction_id,
+ image: data.image,
+ title: data.title,
+ price: data.price,
+ status: data.status,
+ yourNickName,
+ exhibitUserId: data.exhibit_user_id,
+ bidUserId: data.bid_user_id,
+ } as AuctionInfoData;
+};
diff --git a/apps/web/features/chat/room/api/getChatRoomIdIfExist.ts b/apps/web/features/chat/room/api/getChatRoomIdIfExist.ts
new file mode 100644
index 00000000..3019d30d
--- /dev/null
+++ b/apps/web/features/chat/room/api/getChatRoomIdIfExist.ts
@@ -0,0 +1,20 @@
+export const getChatRoomIdIfExist = async (
+ auctionId: string,
+ exhibitUserId: string,
+ bidUserId: string
+) => {
+ const baseURL = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
+ const res = await fetch(`${baseURL}/api/chat/getChatRoomLink`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ auctionId, exhibitUserId, bidUserId }),
+ });
+
+ if (!res.ok) {
+ console.error('์ฑํ
๋ฆฌ์คํธ ์กฐํ API ์คํจ:', res.status);
+ return null;
+ }
+
+ const data = await res.json();
+ return data;
+};
diff --git a/apps/web/features/chat/room/api/getMessages.ts b/apps/web/features/chat/room/api/getMessages.ts
new file mode 100644
index 00000000..d5d60030
--- /dev/null
+++ b/apps/web/features/chat/room/api/getMessages.ts
@@ -0,0 +1,48 @@
+'use server';
+
+import { MessageWithImage } from '@/entities/message/model/types';
+import { MessageImage } from '@/entities/messageImage/model/types';
+import { decodeShortId } from '@/shared/lib/shortUuid';
+import { supabase } from '@/shared/lib/supabaseClient';
+
+export const getMessages = async (chatRoomId: string) => {
+ const fullChatRoomId = decodeShortId(chatRoomId);
+
+ // 1) ๋ฉ์์ง ์กฐํ
+ const { data: messages, error: msgError } = await supabase
+ .from('message')
+ .select('*, profile:sender_id (*)')
+ .eq('chatroom_id', fullChatRoomId)
+ .order('created_at', { ascending: true });
+
+ if (msgError) {
+ throw new Error(`Message ์กฐํ ์คํจ: ${msgError.message}`);
+ }
+
+ // 2) ์ด๋ฏธ์ง ๋ฉ์์ง ์ฒ๋ฆฌ
+ const messagesWithImages: MessageWithImage[] = await Promise.all(
+ messages.map(async (msg: any) => {
+ let images: MessageImage[] = [];
+ if (msg.message_type === 'image') {
+ const { data: imgs, error: imgError } = await supabase
+ .from('message_image')
+ .select('*')
+ .eq('message_id', msg.message_id)
+ .order('order_index', { ascending: true });
+
+ if (imgError) {
+ console.error(`์ด๋ฏธ์ง ์กฐํ ์คํจ: ${imgError.message}`);
+ } else {
+ images = imgs as MessageImage[];
+ }
+ }
+
+ return {
+ ...msg,
+ images,
+ };
+ })
+ );
+
+ return messagesWithImages;
+};
diff --git a/apps/web/features/chat/room/api/getProductInfoForSystemMessage.ts b/apps/web/features/chat/room/api/getProductInfoForSystemMessage.ts
new file mode 100644
index 00000000..34d905e4
--- /dev/null
+++ b/apps/web/features/chat/room/api/getProductInfoForSystemMessage.ts
@@ -0,0 +1,28 @@
+'use server';
+
+import { supabase } from '@/shared/lib/supabaseClient';
+
+export const getProductInfo = async (productId: string) => {
+ const { data, error } = await supabase
+ .from('product')
+ .select(
+ `
+ title,
+ product_image:product_image (
+ image_url
+ )
+ `
+ )
+ .eq('product_id', productId)
+ .eq('product_image.order_index', 0)
+ .maybeSingle();
+
+ if (error || !data) {
+ throw new Error('์ํ ์ ๋ณด ์กฐํ ์คํจ');
+ }
+
+ return {
+ title: data.title,
+ imageUrl: data.product_image?.[0]?.image_url ?? '',
+ };
+};
diff --git a/apps/web/features/chat/room/api/getSystemMessage.ts b/apps/web/features/chat/room/api/getSystemMessage.ts
new file mode 100644
index 00000000..6f3df590
--- /dev/null
+++ b/apps/web/features/chat/room/api/getSystemMessage.ts
@@ -0,0 +1,39 @@
+'use server';
+
+import { SystemMessageWithNickname } from '@/entities/systemMessage/model/types';
+import { decodeShortId } from '@/shared/lib/shortUuid';
+import { supabase } from '@/shared/lib/supabaseClient';
+
+export const getSystemMessage = async (
+ chatRoomId: string
+): Promise => {
+ const fullChatRoomId = decodeShortId(chatRoomId);
+
+ const { data, error } = await supabase
+ .from('system_message')
+ .select(
+ `
+ *,
+ bid_user:profiles!system_message_bid_user_id_fkey (
+ nickname
+ ),
+ exhibit_user:profiles!system_message_exhibit_user_id_fkey (
+ nickname
+ )
+ `
+ )
+ .eq('chatroom_id', fullChatRoomId)
+ .maybeSingle();
+
+ if (error) {
+ throw new Error(`System Message ์กฐํ ์คํจ: ${error.message}`);
+ }
+
+ if (!data) return null;
+
+ return {
+ ...data,
+ bid_user_nickname: data.bid_user?.nickname ?? '๋์ฐฐ์',
+ exhibit_user_nickname: data.exhibit_user?.nickname ?? '์ถํ์',
+ };
+};
diff --git a/apps/web/features/chat/room/api/getYourNickName.ts b/apps/web/features/chat/room/api/getYourNickName.ts
new file mode 100644
index 00000000..e5b51da7
--- /dev/null
+++ b/apps/web/features/chat/room/api/getYourNickName.ts
@@ -0,0 +1,18 @@
+'use server';
+
+import getUserId from '@/shared/lib/getUserId';
+import { supabase } from '@/shared/lib/supabaseClient';
+
+export const getYourNickName = async (chatRoomId: string) => {
+ const userId = await getUserId();
+ const { data, error } = await supabase.rpc('get_other_nickname_by_chatroom', {
+ current_user_id: userId,
+ chatroom_id_input: chatRoomId,
+ });
+
+ if (error) {
+ throw new Error(`์๋๋ฐฉ ๋๋ค์ ์กฐํ ์คํจ: ${error.message}`);
+ }
+
+ return data as string | null;
+};
diff --git a/apps/web/features/chat/room/api/sendMessage.ts b/apps/web/features/chat/room/api/sendMessage.ts
new file mode 100644
index 00000000..663dcd8c
--- /dev/null
+++ b/apps/web/features/chat/room/api/sendMessage.ts
@@ -0,0 +1,99 @@
+'use server';
+
+import getUserId from '@/shared/lib/getUserId';
+import { decodeShortId } from '@/shared/lib/shortUuid';
+import { supabase } from '@/shared/lib/supabaseClient';
+import { MessageRequest } from '../types';
+import { v4 as uuidv4 } from 'uuid';
+import sharp from 'sharp';
+
+export const sendMessage = async ({ chatRoomId, message, location, images }: MessageRequest) => {
+ try {
+ const userId = await getUserId();
+ const fullChatRoomId = decodeShortId(chatRoomId);
+ const isImageMessage = images && images.length > 0;
+
+ // 1. message row ์์ฑ
+ const { data, error } = await supabase
+ .from('message')
+ .insert({
+ chatroom_id: fullChatRoomId,
+ sender_id: userId,
+ message_type: isImageMessage ? 'image' : 'text',
+ content: isImageMessage ? null : message,
+ })
+ .select()
+ .single();
+
+ if (error) {
+ console.error('๋ฉ์์ง ์ ์ก ์๋ฌ:', error);
+ throw new Error(`๋ฉ์์ง ์ ์ก ์คํจ: ${error.message}`);
+ }
+
+ // 2. image message์ผ ๊ฒฝ์ฐ storage ์
๋ก๋ & message_image insert
+ if (isImageMessage) {
+ const uploadedImageUrls: string[] = [];
+
+ await Promise.all(
+ images.map(async (img, idx) => {
+ if (!img.file) return null;
+ const file = img.file;
+
+ const fileName = `${uuidv4()}.webp`;
+ const filePath = `${data.message_id}/${fileName}`;
+
+ let finalBuffer: Buffer;
+ let contentType = 'image/webp';
+
+ if (file.type === 'image/webp') {
+ const arrayBuffer = await file.arrayBuffer();
+ finalBuffer = Buffer.from(arrayBuffer);
+ } else {
+ const arrayBuffer = await file.arrayBuffer();
+ const buffer = Buffer.from(arrayBuffer);
+ finalBuffer = await sharp(buffer).toFormat('webp', { quality: 90 }).toBuffer();
+ }
+
+ const { error: uploadError } = await supabase.storage
+ .from('message-image')
+ .upload(filePath, finalBuffer, { contentType });
+
+ if (uploadError) throw uploadError;
+
+ const { data: urlData } = supabase.storage.from('message-image').getPublicUrl(filePath);
+
+ uploadedImageUrls.push(urlData.publicUrl);
+ })
+ );
+
+ for (const [index, url] of uploadedImageUrls.entries()) {
+ const { error: imgError } = await supabase.from('message_image').insert({
+ message_id: data.message_id,
+ image_url: url,
+ order_index: index,
+ });
+ if (imgError) throw imgError;
+ }
+ }
+
+ if (!location) {
+ throw new Error('sendMessage ์คํจ: location (origin) ๊ฐ์ด ์ ๋ฌ๋์ง ์์์ต๋๋ค.');
+ }
+
+ await fetch(`${location}/api/alarm/chat`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ chatroom_id: fullChatRoomId,
+ sender_id: userId,
+ }),
+ });
+
+ return data;
+ } catch (error) {
+ console.error('sendMessage ํจ์ ์๋ฌ:', error);
+ throw error;
+ }
+};
diff --git a/apps/web/features/chat/room/api/setMessageRead.ts b/apps/web/features/chat/room/api/setMessageRead.ts
new file mode 100644
index 00000000..c8e9d09d
--- /dev/null
+++ b/apps/web/features/chat/room/api/setMessageRead.ts
@@ -0,0 +1,21 @@
+'use server';
+
+import getUserId from '@/shared/lib/getUserId';
+import { decodeShortId } from '@/shared/lib/shortUuid';
+import { supabase } from '@/shared/lib/supabaseClient';
+
+export const setMessagesRead = async (chatRoomId: string) => {
+ const fullChatRoomId = decodeShortId(chatRoomId);
+ const userId = await getUserId();
+
+ const { error } = await supabase
+ .from('message')
+ .update({ is_read: true })
+ .eq('chatroom_id', fullChatRoomId)
+ .neq('sender_id', userId)
+ .eq('is_read', false);
+
+ if (error) {
+ throw new Error(`๋ฉ์ธ์ง ์ฝ์ ์ฒ๋ฆฌ ์คํจ : ${error.message}`);
+ }
+};
diff --git a/apps/web/features/chat/room/api/useMessageRealtime.ts b/apps/web/features/chat/room/api/useMessageRealtime.ts
new file mode 100644
index 00000000..880868bd
--- /dev/null
+++ b/apps/web/features/chat/room/api/useMessageRealtime.ts
@@ -0,0 +1,186 @@
+import { useCallback, useEffect } from 'react';
+import { useQueryClient } from '@tanstack/react-query';
+import { decodeShortId } from '@/shared/lib/shortUuid';
+import { useAuthStore } from '@/shared/model/authStore';
+import { MessageWithImage } from '@/entities/message/model/types';
+import { RealtimeMessagePayload } from '../types';
+import { Profiles } from '@/entities/profiles/model/types';
+import { createClient } from '@/shared/lib/supabase/client';
+import { anonSupabase } from '@/shared/lib/supabaseClient';
+import { MessageImage } from '@/entities/messageImage/model/types';
+
+export const useMessageRealtime = (chatRoomId: string) => {
+ const queryClient = useQueryClient();
+ const fullChatRoomId = decodeShortId(chatRoomId);
+ const userId = useAuthStore((state) => state.user?.id) as string;
+ const supabase = createClient();
+
+ // ์ด๋ฏธ์ง ๋ฐ์ดํฐ๋ฅผ ์์ ํ๊ฒ ๊ฐ์ ธ์ค๋ ํจ์ (ํ์ด๋ฐ ์ด์ ๋ฐฉ์ด)
+ const fetchImagesWithRetry = async (messageId: string, retry = 5, delay = 500) => {
+ for (let i = 0; i < retry; i++) {
+ const { data, error } = await supabase
+ .from('message_image')
+ .select('*')
+ .eq('message_id', messageId)
+ .order('order_index');
+
+ if (error) {
+ console.error('์ด๋ฏธ์ง ๋ก๋ ์คํจ:', error);
+ break;
+ }
+
+ if (data && data.length > 0) {
+ return data as MessageImage[];
+ }
+
+ // ์์ง DB์ ์ด๋ฏธ์ง๊ฐ ๋ฐ์๋์ง ์์๋ค๋ฉด delay ํ ์ฌ์๋
+ await new Promise((resolve) => setTimeout(resolve, delay));
+ }
+
+ // ์ต์ข
์คํจ ์ ๋น ๋ฐฐ์ด ๋ฐํ
+ return [];
+ };
+
+ // ์บ์ ์
๋ฐ์ดํธ ํจ์
+ const updateMessageCache = useCallback(
+ async (payload: RealtimeMessagePayload) => {
+ const queryKey = ['messages', chatRoomId];
+
+ if (payload.eventType === 'INSERT') {
+ const rawMessage = payload.new as MessageWithImage;
+ if (!rawMessage) {
+ console.warn('INSERT ํ์ด๋ก๋์ ์ ๋ฉ์์ง ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค.');
+ return;
+ }
+
+ let newMessage: typeof rawMessage & Partial<{ profile: Profiles; images: MessageImage[] }> =
+ {
+ ...rawMessage,
+ };
+
+ // ํ๋กํ ๋ฐ ์ด๋ฏธ์ง ๋ฐ์ดํฐ๋ฅผ ๋ณ๋ ฌ๋ก ๊ฐ์ ธ์ค๊ธฐ
+ const profilePromise =
+ rawMessage.sender_id !== userId
+ ? anonSupabase
+ .from('profiles')
+ .select('profile_img, nickname')
+ .eq('user_id', rawMessage.sender_id)
+ .single()
+ : Promise.resolve({ data: null, error: null });
+
+ const imagePromise =
+ rawMessage.message_type === 'image'
+ ? new Promise(async (resolve) => {
+ await new Promise((r) => setTimeout(r, 1000));
+ const images = await fetchImagesWithRetry(rawMessage.message_id);
+ resolve(images);
+ })
+ : Promise.resolve([]);
+
+ try {
+ const [profileResult, imageData] = await Promise.all([profilePromise, imagePromise]);
+
+ if (profileResult.data) {
+ newMessage.profile = profileResult.data as Profiles;
+ }
+
+ if (imageData) {
+ newMessage.images = imageData;
+ }
+ } catch (e) {
+ console.error('๋ฉ์์ง ์ถ๊ฐ ๋ฐ์ดํฐ ๊ฐ์ ธ์ค๊ธฐ ์๋ฌ:', e);
+ }
+
+ // ๋ชจ๋ ๋ฐ์ดํฐ๊ฐ ์ค๋น๋ ํ์๋ง ์บ์์ ์ถ๊ฐ
+ queryClient.setQueryData(queryKey, (oldData: MessageWithImage[] | undefined) => {
+ if (!oldData) {
+ queryClient.invalidateQueries({ queryKey });
+ return oldData;
+ }
+
+ const exists = oldData.some((msg) => msg.message_id === newMessage.message_id);
+ if (exists) return oldData;
+
+ return [...oldData, newMessage];
+ });
+ } else if (payload.eventType === 'UPDATE') {
+ // ๋ฉ์์ง ์
๋ฐ์ดํธ ์ฒ๋ฆฌ (์ฝ์ ์ํ ๋ฑ)
+ queryClient.setQueryData(queryKey, (oldData: MessageWithImage[] | undefined) => {
+ if (!oldData) {
+ queryClient.invalidateQueries({ queryKey });
+ return oldData;
+ }
+ const updatedMessage = payload.new as MessageWithImage;
+ return oldData.map((msg) =>
+ msg.message_id === updatedMessage.message_id ? { ...msg, ...updatedMessage } : msg
+ );
+ });
+ }
+ },
+ [queryClient, chatRoomId]
+ );
+
+ useEffect(() => {
+ if (!userId || !fullChatRoomId) return;
+
+ const channel = supabase.channel(`message-${fullChatRoomId}`);
+
+ // 1. message ํ
์ด๋ธ ๋ณ๊ฒฝ ๊ฐ์ง
+ channel.on(
+ 'postgres_changes' as any,
+ {
+ event: '*',
+ schema: 'public',
+ table: 'message',
+ filter: `chatroom_id=eq.${fullChatRoomId}`,
+ },
+ async (payload: RealtimeMessagePayload) => {
+ const isInsert = payload.eventType === 'INSERT';
+ const isUpdate = payload.eventType === 'UPDATE';
+
+ if (isInsert) {
+ await updateMessageCache(payload);
+ } else if (isUpdate) {
+ // UPDATE์ ๊ฒฝ์ฐ ๋ด ๋ฉ์์ง๋ง ์ฒ๋ฆฌ (์ฝ์ ์ํ ๋ฑ)
+ const isMyMessage = payload.new?.sender_id === userId;
+ if (isMyMessage) {
+ await updateMessageCache(payload);
+ }
+ }
+ }
+ );
+
+ // 2. chat_room ์ํ ๋ณ๊ฒฝ ๊ฐ์ง
+ channel.on(
+ 'postgres_changes',
+ {
+ event: 'UPDATE',
+ schema: 'public',
+ table: 'chat_room',
+ filter: `chatroom_id=eq.${fullChatRoomId}`,
+ },
+ () => {
+ queryClient.invalidateQueries({ queryKey: ['chatRoom_active', chatRoomId] });
+ }
+ );
+
+ // 3. system_message ํ
์ด๋ธ INSERT ๊ฐ์ง
+ channel.on(
+ 'postgres_changes' as any,
+ {
+ event: 'INSERT',
+ schema: 'public',
+ table: 'system_message',
+ filter: `chatroom_id=eq.${fullChatRoomId}`,
+ },
+ () => {
+ queryClient.invalidateQueries({ queryKey: ['systemMessage', chatRoomId] });
+ }
+ );
+
+ channel.subscribe();
+ return () => {
+ supabase.removeChannel(channel);
+ };
+ }, [chatRoomId, fullChatRoomId, queryClient, userId]);
+};
diff --git a/apps/web/features/chat/room/lib/utils.ts b/apps/web/features/chat/room/lib/utils.ts
new file mode 100644
index 00000000..e496cf82
--- /dev/null
+++ b/apps/web/features/chat/room/lib/utils.ts
@@ -0,0 +1,20 @@
+import { MessageWithProfile } from '@/entities/message/model/types';
+import { CombinedMessage } from '../types';
+
+export const formatKoreanTime = (isoString: string): string => {
+ const date = new Date(isoString);
+ const hours = date.getHours();
+ const minutes = date.getMinutes();
+
+ const isAM = hours < 12;
+ const period = isAM ? '์ค์ ' : '์คํ';
+ const hour12 = hours % 12 === 0 ? 12 : hours % 12;
+
+ return minutes === 0 ? `${period} ${hour12}์` : `${period} ${hour12}์ ${minutes}๋ถ`;
+};
+
+export const isUserMessage = (
+ msg: CombinedMessage | undefined
+): msg is MessageWithProfile & { messageType: 'user' } => {
+ return msg?.messageType === 'user';
+};
diff --git a/apps/web/features/chat/room/model/getChatRoomLink.ts b/apps/web/features/chat/room/model/getChatRoomLink.ts
new file mode 100644
index 00000000..22c8a0f9
--- /dev/null
+++ b/apps/web/features/chat/room/model/getChatRoomLink.ts
@@ -0,0 +1,22 @@
+import { getChatRoomIdIfExist } from '../api/getChatRoomIdIfExist';
+import { createChatRoom } from '../api/createChatRoom';
+
+/*
+ ์ฌ์ฉ ๊ฐ๋ฅํ ์ฑํ
๋ฐฉ์ด ์กด์ฌํ ๊ฒฝ์ฐ, ํด๋น ์ฑํ
๋ฐฉ์ shortId ์ ๋ฌ
+ ์ฌ์ฉ ๊ฐ๋ฅํ ์ฑํ
๋ฐฉ์ด๋ ์ฑํ
๋ฐฉ์ ์ฐธ์ฌ์์ธ ๋ ์ฌ๋ ๋ชจ๋ active ์ํ์ธ ์ฑํ
๋ฐฉ์ด ์กด์ฌํ ๊ฒฝ์ฐ๋ฅผ ๋งํจ
+ ์ฌ์ฉ ๊ฐ๋ฅํ ์ฑํ
๋ฐฉ์ด ์กด์ฌํ์ง ์์ ๊ฒฝ์ฐ, ์๋ก์ด ์ฑํ
๋ฐฉ์ ๋ง๋ค์ด ํด๋น ์ฑํ
๋ฐฉ์ shortId๋ฅผ ์ ๋ฌ
+*/
+export const getChatRoomLink = async (
+ auctionId: string,
+ exhibitUserId: string,
+ bidUserId: string
+) => {
+ const existingChatRoom = await getChatRoomIdIfExist(auctionId, exhibitUserId, bidUserId);
+
+ if (existingChatRoom.encodedChatRoomId) {
+ return existingChatRoom.encodedChatRoomId;
+ } else {
+ const newChatRoom = await createChatRoom(auctionId, exhibitUserId, bidUserId);
+ return newChatRoom.encodedChatRoomId;
+ }
+};
diff --git a/apps/web/features/chat/room/model/useAuctionInfo.ts b/apps/web/features/chat/room/model/useAuctionInfo.ts
new file mode 100644
index 00000000..33d2c75a
--- /dev/null
+++ b/apps/web/features/chat/room/model/useAuctionInfo.ts
@@ -0,0 +1,10 @@
+import { useQuery } from '@tanstack/react-query';
+import { getAuctionInfo } from '../api/getAuctionInfo';
+
+export function useAuctionInfo(shortId: string) {
+ return useQuery({
+ queryKey: ['auctionInfo', shortId],
+ queryFn: () => getAuctionInfo(shortId),
+ staleTime: 1000 * 60 * 5,
+ });
+}
diff --git a/apps/web/features/chat/room/model/useCombiedMessages.ts b/apps/web/features/chat/room/model/useCombiedMessages.ts
new file mode 100644
index 00000000..921891d6
--- /dev/null
+++ b/apps/web/features/chat/room/model/useCombiedMessages.ts
@@ -0,0 +1,46 @@
+import { useQueries } from '@tanstack/react-query';
+import { getMessages } from '../api/getMessages';
+import { getSystemMessage } from '../api/getSystemMessage';
+import { CombinedMessage } from '../types';
+import { useMemo } from 'react';
+
+export function useCombinedMessages(shortId: string) {
+ const results = useQueries({
+ queries: [
+ {
+ queryKey: ['messages', shortId],
+ queryFn: () => getMessages(shortId),
+ staleTime: 1000 * 60 * 5,
+ },
+ {
+ queryKey: ['systemMessage', shortId],
+ queryFn: () => getSystemMessage(shortId),
+ staleTime: Infinity,
+ },
+ ],
+ });
+ const combinedMessages = useMemo(() => {
+ const messages = results[0].data ?? [];
+ const systemMsg = results[1].data;
+
+ const userMessages: CombinedMessage[] = messages.map((msg) => ({
+ ...msg,
+ messageType: 'user' as const,
+ }));
+
+ const systemMessages: CombinedMessage[] = systemMsg
+ ? [{ ...systemMsg, messageType: 'system' as const }]
+ : [];
+
+ const combined = [...userMessages, ...systemMessages];
+ return combined.sort(
+ (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
+ );
+ }, [results]);
+
+ return {
+ combinedMessages,
+ isLoading: results.some((r) => r.isLoading),
+ error: results.find((r) => r.error)?.error ?? null,
+ };
+}
diff --git a/apps/web/features/chat/room/types/index.ts b/apps/web/features/chat/room/types/index.ts
new file mode 100644
index 00000000..fe61c6b4
--- /dev/null
+++ b/apps/web/features/chat/room/types/index.ts
@@ -0,0 +1,64 @@
+import { MessageWithImage, MessageWithProfile } from '@/entities/message/model/types';
+import { MessageImage } from '@/entities/messageImage/model/types';
+import { SystemMessageWithNickname } from '@/entities/systemMessage/model/types';
+
+// Supabase ์ค์๊ฐ ํ์ด๋ก๋ ํ์
์ ์
+export interface RealtimeMessagePayload {
+ eventType: 'INSERT' | 'UPDATE' | 'DELETE' | 'TRUNCATE';
+ schema: string;
+ table: string;
+ commitTimestamp: string;
+ old: MessageWithProfile | null; // DELETE, UPDATE ์ ์ด์ ๋ฐ์ดํฐ
+ new: MessageWithProfile | null; // INSERT, UPDATE ์ ์ ๋ฐ์ดํฐ
+ errors: string[];
+}
+export interface MessageProps {
+ text: string | null | undefined;
+ showTime: boolean;
+ isRead?: boolean;
+ showAvatar?: boolean;
+ isLast?: boolean;
+ className?: string;
+ time: string;
+ avatar?: string;
+ isImage?: boolean;
+ images?: MessageImage[];
+}
+
+export interface MessageRequest {
+ chatRoomId: string;
+ message?: string;
+ location?: string;
+ images?: ChatImage[];
+}
+
+export interface AuctionInfoData {
+ auctionId: string;
+ image: string;
+ title: string;
+ price: number;
+ status: string;
+ yourNickName: string;
+ exhibitUserId: string;
+ bidUserId: string;
+}
+
+export type CombinedMessage =
+ | (MessageWithImage & { messageType: 'user' })
+ | (SystemMessageWithNickname & { messageType: 'system' });
+
+export interface CreateSystemMessagePayload {
+ chatroomId: string;
+ exhibitUserId: string;
+ bidUserId: string;
+ imgUrl: string;
+ price: number;
+ title: string;
+}
+
+export interface ChatImage {
+ id: string;
+ file: File | null;
+ preview: string;
+ isConverted?: boolean;
+}
diff --git a/apps/web/features/chat/room/ui/AuctionInfo.tsx b/apps/web/features/chat/room/ui/AuctionInfo.tsx
new file mode 100644
index 00000000..e71084eb
--- /dev/null
+++ b/apps/web/features/chat/room/ui/AuctionInfo.tsx
@@ -0,0 +1,53 @@
+'use client';
+
+import StatusBadge from '@/shared/ui/badge/StatusBadge';
+import Image from 'next/image';
+import React from 'react';
+import BackBtn from '@/shared/ui/button/BackBtn';
+import { formatNumberWithComma } from '@/shared/lib/formatNumberWithComma';
+import { AuctionInfoData } from '../types';
+import { encodeUUID } from '@/shared/lib/shortUuid';
+import Link from 'next/link';
+
+interface AuctionInfoProps {
+ data: AuctionInfoData;
+}
+
+const AuctionInfo = ({ data }: AuctionInfoProps) => {
+ const nickname = data.yourNickName;
+
+ return (
+
+
+
+ {nickname}
+
+
+
+
+
+
+
+
+
+
{data.title}
+
{`${formatNumberWithComma(data.price)}์`}
+
+
+
+
+
+
+ );
+};
+
+export default AuctionInfo;
diff --git a/apps/web/features/chat/room/ui/BidWinMessage.tsx b/apps/web/features/chat/room/ui/BidWinMessage.tsx
new file mode 100644
index 00000000..0932e07f
--- /dev/null
+++ b/apps/web/features/chat/room/ui/BidWinMessage.tsx
@@ -0,0 +1,36 @@
+import { SystemMessageWithNickname } from '@/entities/systemMessage/model/types';
+import { formatNumberWithComma } from '@/shared/lib/formatNumberWithComma';
+import { useAuthStore } from '@/shared/model/authStore';
+import Image from 'next/image';
+import React from 'react';
+
+type BidWinMessageProps = {
+ data: SystemMessageWithNickname;
+};
+
+const BidWinMessage = ({ data }: BidWinMessageProps) => {
+ const userId = useAuthStore((state) => state.user?.id) as string;
+ return (
+
+
+
+
+
์ถํํฉ๋๋ค! ๋์ฐฐ๋์์ต๋๋ค.
+
+ {userId === data.bid_user_id ? data.exhibit_user_nickname : data.bid_user_nickname}๋๊ณผ{' '}
+ {data.product_title}์ ๋ํ
+
+ ๊ฑฐ๋ ์ด์ผ๊ธฐ๋ฅผ ์์ํด๋ณด์ธ์.
+
+
๋์ฐฐ ๊ฐ๊ฒฉ : {formatNumberWithComma(data.bid_price)}์
+
+ );
+};
+
+export default BidWinMessage;
diff --git a/apps/web/features/chat/room/ui/ChatInputBar.tsx b/apps/web/features/chat/room/ui/ChatInputBar.tsx
new file mode 100644
index 00000000..b21a778a
--- /dev/null
+++ b/apps/web/features/chat/room/ui/ChatInputBar.tsx
@@ -0,0 +1,127 @@
+'use client';
+
+import { cn } from '@repo/ui/lib/utils';
+import { Camera, SendHorizontal } from 'lucide-react';
+import React, { useEffect, useState, useTransition } from 'react';
+import { sendMessage } from '../api/sendMessage';
+import { Textarea } from '@repo/ui/components/Textarea/Textarea';
+import { ChatImage } from '../types';
+import ImageUploadForChat from './ImageUploadForChat';
+
+const ChatInputBar = ({ shortId, isChatEnd }: { shortId: string; isChatEnd: boolean }) => {
+ const [message, setMessage] = useState('');
+ const [isPending, startTransition] = useTransition();
+ const [isFocused, setIsFocused] = useState(false);
+ const [isMobile, setIsMobile] = useState(false);
+ const [bottomOffset, setBottomOffset] = useState(0);
+ const [isSendImage, setIsSendImage] = useState(false);
+ const [images, setImages] = useState([]);
+
+ useEffect(() => {
+ const handleResize = () => {
+ setIsMobile(window.innerWidth <= 600); // ๋ชจ๋ฐ์ผ ๊ธฐ์ค ๋๋น
+
+ if (window.visualViewport) {
+ const offset = window.innerHeight - window.visualViewport.height;
+ setBottomOffset(offset > 0 ? offset : 0);
+ }
+ };
+
+ handleResize(); // ์ด๊ธฐ ํ๋จ
+ window.addEventListener('resize', handleResize);
+ return () => window.removeEventListener('resize', handleResize);
+ }, []);
+
+ let isMessageSendable = Boolean(message.trim().length > 0);
+
+ const onSubmit = async () => {
+ const messageToSend = message.trim();
+ if (!messageToSend) return;
+
+ // ๋จผ์ ์
๋ ฅ์ฐฝ ๋น์ฐ๊ธฐ
+ setMessage('');
+
+ startTransition(async () => {
+ try {
+ await sendMessage({
+ chatRoomId: shortId,
+ message: messageToSend,
+ location: window.location.origin,
+ });
+ } catch (error) {
+ console.error('๋ฉ์์ง ์ ์ก ์คํจ:', error);
+ // ์๋ฌ ๋ฐ์ ์ ๋ฉ์์ง ๋ณต์
+ setMessage(messageToSend);
+ }
+ });
+ };
+
+ useEffect(() => {
+ if (images.length > 0) {
+ startTransition(async () => {
+ try {
+ await sendMessage({
+ chatRoomId: shortId,
+ images,
+ location: window.location.origin,
+ });
+ setImages([]); // ์ ์ก ํ ์ด๊ธฐํ
+ } catch (error) {
+ console.error('์ด๋ฏธ์ง ๋ฉ์์ง ์ ์ก ์คํจ:', error);
+ }
+ });
+ }
+ }, [images, shortId]);
+
+ return (
+
+
+
+
+
setIsSendImage(false)}
+ />
+
+ );
+};
+
+export default ChatInputBar;
diff --git a/apps/web/features/chat/room/ui/ChatPageContent.tsx b/apps/web/features/chat/room/ui/ChatPageContent.tsx
new file mode 100644
index 00000000..b88ed9f4
--- /dev/null
+++ b/apps/web/features/chat/room/ui/ChatPageContent.tsx
@@ -0,0 +1,42 @@
+'use client';
+
+import React from 'react';
+import ChatInputBar from '@/features/chat/room/ui/ChatInputBar';
+import AuctionInfo from '@/features/chat/room/ui//AuctionInfo';
+import MessageList from '@/features/chat/room/ui//MessageList';
+import Loading from '@/shared/ui/Loading/Loading';
+import { useAuctionInfo } from '../model/useAuctionInfo';
+import { useIsChatEnd } from '../../list/model/useIsChatEnd';
+
+const ChatPageContent = ({ shortId }: { shortId: string }) => {
+ const { data, isLoading, error } = useAuctionInfo(shortId);
+ const {
+ data: isChatEndData,
+ isLoading: isChatEndLoading,
+ error: isChatEndError,
+ } = useIsChatEnd(shortId);
+
+ const isChatEnd = isChatEndData ?? false;
+
+ if (isLoading || isChatEndLoading) return ;
+ if (error) return ์ค๋ฅ: {(error as Error).message}
;
+ if (isChatEndError) return ์ฑํ
์ข
๋ฃ ์ฌ๋ถ ํ์ธ ์ค๋ฅ: {(isChatEndError as Error).message}
;
+ if (!data) {
+ return ๊ฒฝ๋งค ์ ๋ณด๋ฅผ ์กฐํํ ์ ์์ต๋๋ค.
;
+ }
+
+ return (
+
+ {/* ๊ฒฝ๋งค ์ํ ์ค๋ช
*/}
+
+
+ {/* ์ฑํ
๋ด์ญ */}
+
+
+ {/* ์ฑํ
์
๋ ฅ์นธ */}
+
+
+ );
+};
+
+export default ChatPageContent;
diff --git a/apps/web/features/chat/room/ui/DateDivider.tsx b/apps/web/features/chat/room/ui/DateDivider.tsx
new file mode 100644
index 00000000..34e90b19
--- /dev/null
+++ b/apps/web/features/chat/room/ui/DateDivider.tsx
@@ -0,0 +1,19 @@
+interface DateDividerProps {
+ isoDate: string;
+}
+
+export const DateDivider = ({ isoDate }: DateDividerProps) => {
+ const date = new Date(isoDate);
+ const yyyy = date.getFullYear();
+ const mm = String(date.getMonth() + 1).padStart(2, '0');
+ const dd = String(date.getDate()).padStart(2, '0');
+ const formatted = `${yyyy}๋
${mm}์ ${dd}์ผ`;
+
+ return (
+
+ );
+};
diff --git a/apps/web/features/chat/room/ui/ImageGrid.tsx b/apps/web/features/chat/room/ui/ImageGrid.tsx
new file mode 100644
index 00000000..14e236bc
--- /dev/null
+++ b/apps/web/features/chat/room/ui/ImageGrid.tsx
@@ -0,0 +1,55 @@
+import React from 'react';
+import { MessageImage } from '@/entities/messageImage/model/types';
+
+interface ImageGridProps {
+ images: MessageImage[];
+ onClick: (index: number) => void;
+}
+
+export default function ImageGrid({ images, onClick }: ImageGridProps) {
+ const rows: number[][] = [];
+
+ // ์ด๋ฏธ์ง ๊ฐ์๋ณ ์ค ๊ตฌ์ฑ
+ const n = images.length;
+ if (n === 1) rows.push([0]);
+ else if (n === 2) rows.push([0, 1]);
+ else if (n === 3) rows.push([0, 1, 2]);
+ else if (n === 4) rows.push([0, 1], [2, 3]);
+ else if (n === 5) rows.push([0, 1, 2], [3, 4]);
+ else if (n === 6) rows.push([0, 1, 2], [3, 4, 5]);
+ else if (n === 7) rows.push([0, 1, 2], [3, 4], [5, 6]);
+ else if (n === 8) rows.push([0, 1, 2], [3, 4, 5], [6, 7]);
+ else if (n === 9) rows.push([0, 1, 2], [3, 4, 5], [6, 7, 8]);
+
+ let imgIndex = 0;
+
+ return (
+
+ {rows.map((row, rowIndex) => (
+
+ {row.map(() => {
+ const currentIndex = imgIndex;
+ const img = images[imgIndex++];
+ return (
+
onClick(currentIndex)}
+ >
+

+
+ );
+ })}
+
+ ))}
+
+ );
+}
diff --git a/apps/web/features/chat/room/ui/ImageUploadForChat.tsx b/apps/web/features/chat/room/ui/ImageUploadForChat.tsx
new file mode 100644
index 00000000..d667fa8e
--- /dev/null
+++ b/apps/web/features/chat/room/ui/ImageUploadForChat.tsx
@@ -0,0 +1,156 @@
+import { Button } from '@repo/ui/components/Button/Button';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from '@repo/ui/components/Dialog/Dialog';
+import { Camera, Plus } from 'lucide-react';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { ChatImage } from '../types';
+import { convertHeicToWebP } from '@/shared/lib/convertHeicToWebP';
+import { toast } from '@repo/ui/components/Toast/Sonner';
+
+interface ImageUploadForChatProps {
+ onImagesChange: (images: ChatImage[]) => void;
+ open?: boolean;
+ onClose?: () => void;
+}
+
+const ImageUploadForChat = ({ onImagesChange, open = false, onClose }: ImageUploadForChatProps) => {
+ const [images, setImages] = useState([]);
+ const [isConverting, setIsConverting] = useState(false);
+ const fileInputRef = useRef(null);
+ const cameraInputRef = useRef(null);
+ const MAX_IMAGES = 9;
+
+ const notifyParent = useCallback(
+ (updatedImages: ChatImage[]) => {
+ onImagesChange(updatedImages);
+ },
+ [onImagesChange]
+ );
+
+ useEffect(() => {
+ if (images.length > 0) {
+ notifyParent(images);
+ }
+ }, [images, notifyParent]);
+
+ const handleFileSelect = async (files: FileList | null) => {
+ if (!files) return;
+ onClose?.();
+
+ const availableSlots = MAX_IMAGES - images.length;
+ let newFiles = Array.from(files);
+
+ if (newFiles.length > availableSlots) {
+ newFiles = newFiles.slice(0, availableSlots);
+ toast({ content: `ํ ๋ฒ์ ์ ์กํ ์ ์๋ ์ด๋ฏธ์ง๋ ์ต๋ ${MAX_IMAGES}์ฅ์
๋๋ค.` });
+ }
+
+ const processedImages: ChatImage[] = await Promise.all(
+ newFiles.map(async (f, i) => {
+ let file: File = f;
+ let isConverted = false;
+
+ try {
+ if (file.name.toLowerCase().endsWith('.heic') || file.type === 'image/heic') {
+ file = await convertHeicToWebP(file);
+ isConverted = true;
+ }
+ } catch (error) {
+ console.warn(`HEIC ๋ณํ ์คํจ, ์๋ณธ์ผ๋ก ๋์ฒด: ${file.name}`);
+ }
+
+ return {
+ id: `image-${Date.now()}-${i}`,
+ file,
+ preview: URL.createObjectURL(file),
+ isConverted,
+ };
+ })
+ );
+
+ // ๊ธฐ์กด ์ด๋ฏธ์ง ์ ์ง
+ setImages((prev) => [...prev, ...processedImages]);
+
+ if (fileInputRef.current) fileInputRef.current.value = '';
+ if (cameraInputRef.current) cameraInputRef.current.value = '';
+ };
+
+ // revoke๋ unmount ์์ ์๋ง
+ useEffect(() => {
+ return () => {
+ images.forEach((img) => URL.revokeObjectURL(img.preview));
+ };
+ }, []);
+
+ const handleGallerySelect = () => {
+ if (fileInputRef.current && !isConverting) {
+ fileInputRef.current.click();
+ }
+ };
+
+ const handleCameraCapture = () => {
+ if (cameraInputRef.current && !isConverting) {
+ cameraInputRef.current.click();
+ }
+ };
+
+ useEffect(() => {
+ onImagesChange(images);
+ }, [images, onImagesChange]);
+
+ useEffect(() => {
+ return () => {
+ images.forEach((img) => URL.revokeObjectURL(img.preview));
+ };
+ }, [images]);
+
+ return (
+ <>
+
+ handleFileSelect(e.target.files)}
+ />
+
+ handleFileSelect(e.target.files)}
+ />
+ >
+ );
+};
+
+export default ImageUploadForChat;
diff --git a/apps/web/features/chat/room/ui/MessageList.tsx b/apps/web/features/chat/room/ui/MessageList.tsx
new file mode 100644
index 00000000..dc14f8b5
--- /dev/null
+++ b/apps/web/features/chat/room/ui/MessageList.tsx
@@ -0,0 +1,208 @@
+'use client';
+
+import React, { useEffect, useRef } from 'react';
+import { DateDivider } from '@/features/chat/room/ui/DateDivider';
+import MyMessage from '@/features/chat/room/ui/MyMessage';
+import YourMessage from '@/features/chat/room/ui/YourMessage';
+import { useAuthStore } from '@/shared/model/authStore';
+import Loading from '@/shared/ui/Loading/Loading';
+import { useMessageRealtime } from '../api/useMessageRealtime';
+import { setMessagesRead } from '../api/setMessageRead';
+import { cn } from '@repo/ui/lib/utils';
+import { getChatRoomLink } from '../model/getChatRoomLink';
+import { AuctionInfoData, CombinedMessage } from '../types';
+import { useRouter } from 'next/navigation';
+import { encodeUUID } from '@/shared/lib/shortUuid';
+import BidWinMessage from './BidWinMessage';
+import { useCombinedMessages } from '../model/useCombiedMessages';
+import { isUserMessage } from '../lib/utils';
+
+const MessageList = ({
+ shortId,
+ isChatEnd,
+ auctionInfo,
+}: {
+ shortId: string;
+ isChatEnd: boolean;
+ auctionInfo: AuctionInfoData;
+}) => {
+ const { combinedMessages: data, isLoading, error } = useCombinedMessages(shortId);
+ const bottomRef = useRef(null);
+ const prevMessageCountRef = useRef(0);
+ const userId = useAuthStore((state) => state.user?.id) as string;
+ const router = useRouter();
+ const hasInitialScrolled = useRef(false); // ์ด๊ธฐ ์คํฌ๋กค ์๋ฃ ์ฌ๋ถ
+
+ useMessageRealtime(shortId);
+
+ // ์ด๊ธฐ ๋ก๋ ์ ์คํฌ๋กค
+ useEffect(() => {
+ if (data && data.length > 0 && !isLoading && !hasInitialScrolled.current) {
+ setTimeout(() => {
+ bottomRef.current?.scrollIntoView({ behavior: 'auto' });
+ hasInitialScrolled.current = true;
+ }, 100);
+ prevMessageCountRef.current = data.length;
+ setMessagesRead(shortId);
+ }
+ }, [data, isLoading, shortId]);
+
+ // ์ ๋ฉ์์ง๊ฐ ์ถ๊ฐ๋ ๋ ์คํฌ๋กค
+ useEffect(() => {
+ if (data && data.length > prevMessageCountRef.current && hasInitialScrolled.current) {
+ setTimeout(() => {
+ bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
+ }, 50);
+ prevMessageCountRef.current = data.length;
+ setMessagesRead(shortId);
+ }
+ }, [data?.length, shortId]);
+
+ if (isLoading) return ;
+ if (error) return ์ค๋ฅ: {(error as Error).message}
;
+ if (!data || (data.length === 0 && !isChatEnd))
+ return (
+
+
์์ง ๋ํ๊ฐ ์์ต๋๋ค.
+
+ );
+
+ const linkChatRoom = async () => {
+ const { auctionId, exhibitUserId, bidUserId } = auctionInfo;
+ const encodedChatRoomId = await getChatRoomLink(
+ encodeUUID(auctionId),
+ encodeUUID(exhibitUserId),
+ encodeUUID(bidUserId)
+ );
+ router.push(`/chat/${encodedChatRoomId}`);
+ };
+
+ const messages = data;
+
+ return (
+
+ {data?.map((message: CombinedMessage, index) => {
+ const isLastMessage = index === messages.length - 1;
+ if (message.messageType === 'system') {
+ return (
+
0 ? 'mt-[30px]' : ''}
+ >
+ {index === 0 && }
+
+
+ );
+ } else {
+ // ์ต์ด ๋ฉ์ธ์ง์ด๊ฑฐ๋ ์ด์ ๋ฉ์ธ์ง์ ๋ ์ง๊ฐ ๋ฌ๋ผ์ง ๊ฒฝ์ฐ DateDivider
+ const currentDate = new Date(message.created_at);
+
+ // ์ด์ ๋ฉ์์ง๊ฐ ์กด์ฌํ๋์ง ์ฒดํฌ
+ const prevMessage = data && index > 0 ? messages[index - 1] : undefined;
+ const nextMessage = data && index < messages.length - 1 ? messages[index + 1] : undefined;
+ const prevDate = isUserMessage(prevMessage)
+ ? new Date(prevMessage.created_at)
+ : undefined;
+ const nextDate = isUserMessage(nextMessage)
+ ? new Date(nextMessage.created_at)
+ : undefined;
+
+ const isFirstMessage = index === 0;
+ const isDifferentDay = prevDate
+ ? currentDate.getFullYear() !== prevDate.getFullYear() ||
+ currentDate.getMonth() !== prevDate.getMonth() ||
+ currentDate.getDate() !== prevDate.getDate()
+ : false;
+
+ const isNextSameTime = nextDate
+ ? currentDate.getHours() === nextDate.getHours() &&
+ currentDate.getMinutes() === nextDate.getMinutes()
+ : false;
+ const isNextSameDay = nextDate
+ ? currentDate.getFullYear() === nextDate.getFullYear() &&
+ currentDate.getMonth() === nextDate.getMonth() &&
+ currentDate.getDate() === nextDate.getDate()
+ : false;
+
+ const isSameUserTalking = isUserMessage(prevMessage)
+ ? prevMessage.sender_id === message.sender_id
+ : false;
+ const willSameUserTalk = isUserMessage(nextMessage)
+ ? nextMessage.sender_id === message.sender_id
+ : false;
+
+ const isBetweenSystemTop = prevMessage?.messageType === 'system';
+ const isBetweenSystemBottom = nextMessage?.messageType === 'system';
+
+ const showTime =
+ !willSameUserTalk || !isNextSameTime || !isNextSameDay || isBetweenSystemBottom;
+ const returnMessage = (
+
+ {(isFirstMessage || isDifferentDay) && }
+ {userId === message.sender_id ? (
+
+ ) : (
+
+ )}
+
+ );
+
+ return returnMessage;
+ }
+ })}
+ {isChatEnd && (
+
+
+ ์๋๋ฐฉ์ด ์ฑํ
์ ์ข
๋ฃํ์ต๋๋ค.
+ ๋ํ๋ฅผ ์ด์ด๊ฐ์๋ ค๋ฉด ์๋ก์ด ์ฑํ
๋ฐฉ์ ์์ฑํด์ฃผ์ธ์.
+
+
+
+ )}
+
+
+ );
+};
+
+export default MessageList;
diff --git a/apps/web/features/chat/room/ui/MyMessage.tsx b/apps/web/features/chat/room/ui/MyMessage.tsx
new file mode 100644
index 00000000..3e92e78c
--- /dev/null
+++ b/apps/web/features/chat/room/ui/MyMessage.tsx
@@ -0,0 +1,56 @@
+import React, { useState } from 'react';
+import { MessageProps } from '../types';
+import clsx from 'clsx';
+import { formatKoreanTime } from '../lib/utils';
+import ImageGrid from './ImageGrid';
+import ImageViewer from '@/shared/lib/ImageViewer';
+
+const MyMessage = ({
+ text,
+ showTime,
+ isRead,
+ isLast,
+ className,
+ time,
+ isImage,
+ images = [],
+}: MessageProps) => {
+ const [viewerOpen, setViewerOpen] = useState(false);
+ const [viewerIndex, setViewerIndex] = useState(0);
+
+ const handleImageClick = (index: number) => {
+ setViewerIndex(index);
+ setViewerOpen(true);
+ };
+
+ return (
+
+
+ {isLast && isRead ? (
+
์ฝ์
+ ) : (
+ <>>
+ )}
+ {showTime &&
{formatKoreanTime(time)}
}
+
+
+ {isImage && images.length > 0 ? (
+ <>
+
+ {viewerOpen && (
+
img.image_url)}
+ initialIndex={viewerIndex}
+ onClose={() => setViewerOpen(false)}
+ />
+ )}
+ >
+ ) : (
+ {text}
+ )}
+
+
+ );
+};
+
+export default MyMessage;
diff --git a/apps/web/features/chat/room/ui/YourMessage.tsx b/apps/web/features/chat/room/ui/YourMessage.tsx
new file mode 100644
index 00000000..af66bedb
--- /dev/null
+++ b/apps/web/features/chat/room/ui/YourMessage.tsx
@@ -0,0 +1,59 @@
+import React, { useState } from 'react';
+import { MessageProps } from '../types';
+import clsx from 'clsx';
+import { Avatar } from '@repo/ui/components/Avatar/Avatar';
+import { formatKoreanTime } from '../lib/utils';
+import ImageGrid from './ImageGrid';
+import ImageViewer from '@/shared/lib/ImageViewer';
+
+const YourMessage = ({
+ text,
+ showTime,
+ showAvatar,
+ className,
+ time,
+ avatar,
+ isImage,
+ images = [],
+}: MessageProps) => {
+ const [viewerOpen, setViewerOpen] = useState(false);
+ const [viewerIndex, setViewerIndex] = useState(0);
+
+ const handleImageClick = (index: number) => {
+ setViewerIndex(index);
+ setViewerOpen(true);
+ };
+
+ return (
+
+ {showAvatar ? (
+
+ ) : (
+
+ )}
+
+ {isImage && images.length > 0 ? (
+ <>
+
+ {viewerOpen && (
+
img.image_url)}
+ initialIndex={viewerIndex}
+ onClose={() => setViewerOpen(false)}
+ />
+ )}
+ >
+ ) : (
+ {text}
+ )}
+
+ {showTime && (
+
+ {formatKoreanTime(time)}
+
+ )}
+
+ );
+};
+
+export default YourMessage;
diff --git a/apps/web/features/find-id/lib/findAccountConfig.tsx b/apps/web/features/find-id/lib/findAccountConfig.tsx
new file mode 100644
index 00000000..ca7ff236
--- /dev/null
+++ b/apps/web/features/find-id/lib/findAccountConfig.tsx
@@ -0,0 +1,25 @@
+import { Mail, UserRound } from 'lucide-react';
+
+export const FindAccountConfig = (accountType: string) => {
+ if (accountType === 'email') {
+ return {
+ title: '์์ด๋(์ด๋ฉ์ผ) ์ฐพ๊ธฐ',
+ placeholder: '๋๋ค์',
+ icon: () => ,
+ inputType: 'text' as const,
+ buttonText: '์์ด๋(์ด๋ฉ์ผ) ์ฐพ๊ธฐ',
+ resultPrefix: '์ด๋ฉ์ผ: ',
+ description: '๋ณด์์ ์ํด ์ด๋ฉ์ผ์ ์ผ๋ถ๊ฐ ๋ง์คํน๋์์ต๋๋ค.',
+ };
+ } else {
+ return {
+ title: '๋น๋ฐ๋ฒํธ ์ฐพ๊ธฐ',
+ placeholder: '์ด๋ฉ์ผ ์ฃผ์',
+ icon: () => ,
+ inputType: 'email' as const,
+ buttonText: '์ฌ์ค์ ์ด๋ฉ์ผ ๋ฐ์ก',
+ resultPrefix: '',
+ description: '์๋์ ์ธ์ฆ์ฝ๋๋ฅผ ์
๋ ฅํด์ฃผ์ธ์.',
+ };
+ }
+};
diff --git a/apps/web/features/find-id/model/useFindId.ts b/apps/web/features/find-id/model/useFindId.ts
new file mode 100644
index 00000000..123683f8
--- /dev/null
+++ b/apps/web/features/find-id/model/useFindId.ts
@@ -0,0 +1,202 @@
+'use client';
+import { supabase } from '@/shared/lib/supabaseClient';
+import { validateFullEmail } from '@/shared/lib/validation/email';
+import { toast } from '@repo/ui/components/Toast/Sonner';
+import { useRouter, useSearchParams } from 'next/navigation';
+import { useEffect, useState } from 'react';
+
+export const useFindId = () => {
+ const [inputValue, setInputValue] = useState('');
+ const [result, setResult] = useState('');
+ const [isSearching, setIsSearching] = useState(false);
+ const [isFound, setIsFound] = useState(false);
+ const [accountType, setAccountType] = useState<'email' | 'password'>('email');
+
+ const [verifiedCode, setVerifiedCode] = useState('');
+ const [verifiedCodeError, setVerifiedCodeError] = useState('');
+ const [verifiedEmail, setVerifiedEmail] = useState('');
+ const [isEmailVerified, setIsEmailVerified] = useState(false);
+
+ const router = useRouter();
+ const searchParams = useSearchParams();
+
+ useEffect(() => {
+ const type = searchParams.get('type');
+
+ if (type === 'email' || type === 'password') {
+ setAccountType(type);
+
+ setInputValue('');
+ setResult('');
+ setIsFound(false);
+ } else if (type === null) {
+ setAccountType('email');
+ } else {
+ router.replace('/find-id?type=email');
+ }
+ }, [searchParams, router]);
+
+ // ์ด๋ฉ์ผ ๋ง์คํน ํจ์
+ const maskEmail = (email: string): string => {
+ const [localPart, domain] = email.split('@');
+
+ if (!localPart || !domain) {
+ throw new Error('Invalid email format');
+ }
+ if (localPart.length <= 3) {
+ return `${localPart[0]}***@${domain}`;
+ }
+ return `${localPart.slice(0, 3)}***@${domain}`;
+ };
+
+ // ์ด๋ฉ์ผ ์ฐพ๊ธฐ(๋๋ค์ ์
๋ ฅ)
+ const handleNicnameSearch = async () => {
+ try {
+ const { data, error } = await supabase
+ .from('profiles')
+ .select('email')
+ .eq('nickname', inputValue.trim())
+ .single();
+
+ if (error || !data?.email) {
+ toast({ content: '๋ฑ๋ก๋์ง ์์ ์ฌ์ฉ์๋ช
์
๋๋ค.' });
+ return;
+ }
+
+ const maskedEmail = maskEmail(data.email);
+ setResult(maskedEmail);
+ setIsFound(true);
+ } catch (err) {
+ console.error('์ด๋ฉ์ผ ๊ฒ์ ์ค๋ฅ:', err);
+ toast({ content: '๊ฒ์ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค.' });
+ }
+ };
+
+ // ๋น๋ฐ๋ฒํธ ์ฐพ๊ธฐ(์ด๋ฉ์ผ ์
๋ ฅ)
+ const handlePasswordReset = async () => {
+ const result = validateFullEmail({ fullEmail: inputValue });
+
+ if (!result.fullEmail) {
+ console.error('์ด๋ฉ์ผ ์
๋ ฅ ์ค๋ฅ');
+ return;
+ }
+
+ if (result.success) {
+ try {
+ const { data: userData, error: userError } = await supabase
+ .from('profiles')
+ .select('email')
+ .eq('email', inputValue.trim())
+ .single();
+
+ if (userError || !userData) {
+ toast({ content: '๋ฑ๋ก๋์ง ์์ ์ด๋ฉ์ผ์
๋๋ค.' });
+ return;
+ }
+
+ const { error } = await supabase.auth.signInWithOtp({
+ email: result.fullEmail,
+ options: {
+ shouldCreateUser: false,
+ },
+ });
+
+ if (error) {
+ console.error('OTP ๋ฐ์ก ์๋ฌ:', error);
+ toast({ content: `์ธ์ฆ ์ฝ๋ ์ ์ก ์คํจ: ${error.message}` });
+ return;
+ }
+
+ toast({ content: `${result.fullEmail}๋ก ์ธ์ฆ ์ฝ๋๊ฐ ์ ์ก๋์์ต๋๋ค.` });
+ setResult('๋น๋ฐ๋ฒํธ ์ฌ์ค์ ์ด๋ฉ์ผ์ด ๋ฐ์ก๋์์ต๋๋ค.');
+ setVerifiedEmail(result.fullEmail);
+ setIsFound(true);
+ } catch (err) {
+ console.error('๋น๋ฐ๋ฒํธ ์ฌ์ค์ ์ค๋ฅ:', err);
+ toast({ content: '์ฌ์ค์ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค.' });
+ }
+ } else {
+ toast({ content: `${result.error ?? '์ฌ๋ฐ๋ฅธ ์ด๋ฉ์ผ ํ์์ด ์๋๋๋ค'}` });
+ return;
+ }
+ };
+
+ // ๋ฒํผ ํด๋ฆญ ์
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (!inputValue.trim()) {
+ toast({ content: `${accountType === 'email' ? '์ฌ์ฉ์๋ช
์' : '์ด๋ฉ์ผ์'} ์
๋ ฅํด์ฃผ์ธ์.` });
+ return;
+ }
+
+ setIsSearching(true);
+ setResult('');
+ setIsFound(false);
+
+ try {
+ if (accountType === 'email') {
+ await handleNicnameSearch();
+ } else {
+ await handlePasswordReset();
+ }
+ } finally {
+ setIsSearching(false);
+ }
+ };
+
+ const onClickVerifyCode = async () => {
+ if (!verifiedCode || !verifiedEmail) {
+ setVerifiedCodeError('์ธ์ฆ ์ฝ๋๋ฅผ ์
๋ ฅํด์ฃผ์ธ์.');
+ return;
+ }
+
+ setIsSearching(true);
+ setVerifiedCodeError('');
+
+ try {
+ const { data, error } = await supabase.auth.verifyOtp({
+ email: verifiedEmail,
+ token: verifiedCode,
+ type: 'email',
+ });
+
+ if (error) {
+ console.error('OTP ์ธ์ฆ ์๋ฌ:', error);
+ setVerifiedCodeError('์ธ์ฆ ์ฝ๋๊ฐ ์ฌ๋ฐ๋ฅด์ง ์์ต๋๋ค.');
+ return;
+ }
+
+ if (data.user) {
+ setIsEmailVerified(true);
+ toast({ content: '์ด๋ฉ์ผ ์ธ์ฆ์ด ์๋ฃ๋์์ต๋๋ค!' });
+
+ router.replace('/reset-pw');
+ } else {
+ setIsEmailVerified(false);
+ }
+ } catch (error) {
+ console.error('์ธ์ฆ ์ฝ๋ ํ์ธ ์ค๋ฅ:', error);
+ setVerifiedCodeError('์ธ์ฆ ์ฝ๋ ํ์ธ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค.');
+ } finally {
+ setIsSearching(false);
+ }
+ };
+
+ return {
+ inputValue,
+ isFound,
+ isSearching,
+ accountType,
+ result,
+ setInputValue,
+ handleSubmit,
+
+ verifiedCode,
+ verifiedCodeError,
+ isEmailVerified,
+ verifiedEmail,
+ setVerifiedCode,
+ onClickVerifyCode,
+ };
+};
diff --git a/apps/web/features/home/types/index.ts b/apps/web/features/home/types/index.ts
new file mode 100644
index 00000000..a2166999
--- /dev/null
+++ b/apps/web/features/home/types/index.ts
@@ -0,0 +1 @@
+export type SheetMode = 'collapsed' | 'half' | 'full';
diff --git a/apps/web/features/home/ui/HomeClientPage.tsx b/apps/web/features/home/ui/HomeClientPage.tsx
new file mode 100644
index 00000000..7e49193b
--- /dev/null
+++ b/apps/web/features/home/ui/HomeClientPage.tsx
@@ -0,0 +1,182 @@
+'use client';
+
+import LocationPin from '@/features/location/ui/LocationPin';
+import Loading from '@/shared/ui/Loading/Loading';
+import { useEffect, useRef, useState } from 'react';
+import { Button } from '@repo/ui/components/Button/Button';
+import { Map } from 'lucide-react';
+import { AuctionMarkerResponse, AuctionSort } from '@/features/auction/list/types';
+import { DEFAULT_AUCTION_LIST_PARAMS } from '@/features/auction/list/constants';
+import { LocationWithAddress } from '@/features/location/types';
+import GoogleMapSkeleton from '@/features/location/ui/GoogleMapSkeleton';
+import dynamic from 'next/dynamic';
+import { getListHeight } from '@/features/auction/list/lib/utils';
+import { SheetMode } from '@/features/home/types';
+
+const GoogleMapView = dynamic(() => import('@/features/location/ui/GoogleMapView'), {
+ ssr: false,
+ loading: () => ,
+});
+
+const AuctionSortDropdown = dynamic(
+ () => import('@/features/auction/list/ui/AuctionSortDropdown'),
+ { ssr: false }
+);
+
+const AuctionList = dynamic(() => import('@/features/auction/list/ui/AuctionList'), {
+ ssr: false,
+ loading: () => ,
+});
+
+interface HomeClientPageProps {
+ userLocation: LocationWithAddress;
+ auctionMarkers: AuctionMarkerResponse[];
+}
+
+const HomeClientPage = ({ userLocation, auctionMarkers }: HomeClientPageProps) => {
+ const [sheetMode, setSheetMode] = useState('half');
+ const [showList, setShowList] = useState(true);
+ const [sort, setSort] = useState(DEFAULT_AUCTION_LIST_PARAMS.sort);
+ const [listHeight, setListHeight] = useState(getListHeight('home', showList));
+ const [mapHeight, setMapHeight] = useState('h-[300px]');
+
+ const getTranslateY = () => {
+ switch (sheetMode) {
+ case 'collapsed':
+ return '92%'; // ์ง๋๋ง ๋ณด์
+ case 'half':
+ return '280px'; // ์ง๋ + ๋ฆฌ์คํธ ๋ฐ๋ฐ
+ case 'full':
+ return '0%'; // ๋ฆฌ์คํธ๋ง ๋ณด์
+ }
+ };
+
+ useEffect(() => {
+ if (sheetMode === 'half') {
+ setMapHeight('h-[300px]');
+ setShowList(true);
+ } else if (sheetMode === 'full') {
+ setMapHeight('h-0');
+ setShowList(true);
+ } else {
+ setMapHeight('h-full');
+ setShowList(false);
+ }
+ }, [sheetMode]);
+
+ useEffect(() => {
+ setListHeight(getListHeight('home', showList));
+ }, [showList]);
+
+ const sheetRef = useRef(null);
+ const handleRef = useRef(null);
+
+ let startY = 0;
+ let currentY = 0;
+ let isDragging = false;
+
+ const onPointerDown = (e: React.PointerEvent) => {
+ isDragging = true;
+ startY = e.clientY;
+ currentY = startY;
+
+ document.addEventListener('pointermove', onPointerMove);
+ document.addEventListener('pointerup', onPointerUp);
+ };
+
+ const onPointerMove = (e: PointerEvent) => {
+ if (!isDragging) return;
+ currentY = e.clientY;
+ };
+
+ const onPointerUp = () => {
+ if (!isDragging) return;
+ isDragging = false;
+
+ const delta = currentY - startY;
+ if (delta > 50) {
+ setSheetMode((prev) =>
+ prev === 'full' ? 'half' : prev === 'half' ? 'collapsed' : 'collapsed'
+ );
+ } else if (delta < -50) {
+ setSheetMode((prev) => (prev === 'collapsed' ? 'half' : prev === 'half' ? 'full' : 'full'));
+ }
+
+ document.removeEventListener('pointermove', onPointerMove);
+ document.removeEventListener('pointerup', onPointerUp);
+ };
+
+ return (
+ <>
+
+ {/* ๋ฐฐ๊ฒฝ ์ ์ฒด ์ง๋ */}
+
+
+ {/* ํ๋จ ๋ฆฌ์คํธ ์ํธ */}
+
+
+
+ {sheetMode !== 'full' && (
+
+ )}
+
+ {sheetMode !== 'collapsed' && (
+
+ )}
+
+
+ {/* ๋ฆฌ์คํธ */}
+
+
+
+ {sheetMode === 'full' && (
+
+ )}
+
+ >
+ );
+};
+
+export default HomeClientPage;
diff --git a/apps/web/features/location/actions/getUserLocationAction.ts b/apps/web/features/location/actions/getUserLocationAction.ts
new file mode 100644
index 00000000..75d1612d
--- /dev/null
+++ b/apps/web/features/location/actions/getUserLocationAction.ts
@@ -0,0 +1,29 @@
+'use server';
+import { supabase } from '@/shared/lib/supabaseClient';
+import type { LocationWithAddress } from '@/features/location/types';
+import getUserId from '@/shared/lib/getUserId';
+import { ProfileLocationData } from '@/entities/profiles/model/types';
+
+export async function getUserLocationAction(): Promise {
+ const userId = await getUserId();
+
+ const { data, error } = await supabase
+ .from('profiles')
+ .select('latitude, longitude, address')
+ .eq('user_id', userId)
+ .single();
+
+ if (error) {
+ return null;
+ }
+
+ const { latitude, longitude, address } = data;
+
+ return {
+ location: {
+ lat: latitude,
+ lng: longitude,
+ },
+ address,
+ };
+}
diff --git a/apps/web/features/location/api/getAddressFromLatLng.ts b/apps/web/features/location/api/getAddressFromLatLng.ts
deleted file mode 100644
index e575e0a1..00000000
--- a/apps/web/features/location/api/getAddressFromLatLng.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-export const getAddressFromLatLng = async (coords: {
- lat: number;
- lng: number;
-}): Promise => {
- const { lat, lng } = coords;
- const res = await fetch(`/api/vworld?point=${lng},${lat}`);
- const data = await res.json();
- const results = data.response.result;
-
- if (!results || results.length === 0) return null;
-
- const structure = results[0].structure;
-
- const eupmyeondong = structure?.level4L;
-
- return eupmyeondong || null;
-};
diff --git a/apps/web/features/location/api/getKoreanAddress.ts b/apps/web/features/location/api/getKoreanAddress.ts
new file mode 100644
index 00000000..8a63b040
--- /dev/null
+++ b/apps/web/features/location/api/getKoreanAddress.ts
@@ -0,0 +1,20 @@
+import { Location } from '@/features/location/types';
+
+export const getKoreanAddress = async (coords: Location): Promise => {
+ const { lat, lng } = coords;
+ const res = await fetch(`/api/vworld?point=${lng},${lat}`);
+
+ const data = await res.json();
+
+ if (data?.response.status !== 'OK') {
+ console.error('vworld ์ฃผ์ ์กฐํ ์ค๋ฅ');
+ return null;
+ }
+
+ const results = data.response.result;
+
+ const structure = results[0].structure;
+ const eupmyeondong = structure?.level4L; //์๋ฉด๋
+
+ return eupmyeondong;
+};
diff --git a/apps/web/features/location/api/setLocation.ts b/apps/web/features/location/api/updateLocation.ts
similarity index 50%
rename from apps/web/features/location/api/setLocation.ts
rename to apps/web/features/location/api/updateLocation.ts
index 7058d3c7..1f734201 100644
--- a/apps/web/features/location/api/setLocation.ts
+++ b/apps/web/features/location/api/updateLocation.ts
@@ -1,15 +1,11 @@
-interface SetLocationProps {
- userId: string;
- lat: number;
- lng: number;
- address: string;
-}
+import { UpdateLocationProps } from '@/features/location/types';
-export const SetLocation = async ({ userId, lat, lng, address }: SetLocationProps) => {
+export const updateLocation = async ({ location, address }: UpdateLocationProps) => {
+ const { lat, lng } = location;
const res = await fetch('/api/location', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ userId, lat, lng, address }),
+ body: JSON.stringify({ lat, lng, address }),
});
if (!res.ok) {
diff --git a/apps/web/features/location/lib/utils.ts b/apps/web/features/location/lib/utils.ts
index e69de29b..089a5768 100644
--- a/apps/web/features/location/lib/utils.ts
+++ b/apps/web/features/location/lib/utils.ts
@@ -0,0 +1,13 @@
+export const getGeoPermissionState = async (): Promise<
+ 'granted' | 'prompt' | 'denied' | 'unknown'
+> => {
+ if (!('permissions' in navigator)) return 'unknown';
+ try {
+ // ์ผ๋ถ ๋ธ๋ผ์ฐ์ ๋ ํ์
์บ์คํ
ํ์
+ // @ts-ignore
+ const status = await navigator.permissions.query({ name: 'geolocation' as PermissionName });
+ return status.state as 'granted' | 'prompt' | 'denied';
+ } catch {
+ return 'unknown';
+ }
+};
diff --git a/apps/web/features/location/model/useLocationStore.ts b/apps/web/features/location/model/useLocationStore.ts
new file mode 100644
index 00000000..f7ce7d1d
--- /dev/null
+++ b/apps/web/features/location/model/useLocationStore.ts
@@ -0,0 +1,17 @@
+import { Step } from '@/features/location/types';
+import { create } from 'zustand';
+
+type LocationState = {
+ step: Step;
+ setStep: (step: Step) => void;
+ goNext: () => void;
+};
+
+export const useLocationStore = create((set) => ({
+ step: 'intro',
+ goNext: () =>
+ set((state) => ({
+ step: state.step === 'intro' ? 'confirm' : state.step === 'confirm' ? 'success' : state.step,
+ })),
+ setStep: (step) => set({ step }),
+}));
diff --git a/apps/web/features/location/model/useUpdateLocation.ts b/apps/web/features/location/model/useUpdateLocation.ts
new file mode 100644
index 00000000..103bd2ab
--- /dev/null
+++ b/apps/web/features/location/model/useUpdateLocation.ts
@@ -0,0 +1,22 @@
+import { updateLocation } from '@/features/location/api/updateLocation';
+import { useLocationStore } from '@/features/location/model/useLocationStore';
+import { UpdateLocationProps } from '@/features/location/types';
+import { toast } from '@repo/ui/components/Toast/Sonner';
+import { useMutation } from '@tanstack/react-query';
+
+const useUpdateLocation = () => {
+ const goNext = useLocationStore((state) => state.goNext);
+
+ return useMutation({
+ mutationFn: (props: UpdateLocationProps) => updateLocation(props),
+ onSuccess: () => {
+ goNext();
+ },
+ onError: (error) => {
+ console.error('์์น ์ ์ฅ ์คํจ:', error);
+ toast({ content: '์์น ์ ์ฅ์ ์คํจํ์ต๋๋ค.' });
+ },
+ });
+};
+
+export default useUpdateLocation;
diff --git a/apps/web/features/location/types/index.ts b/apps/web/features/location/types/index.ts
new file mode 100644
index 00000000..b3b17021
--- /dev/null
+++ b/apps/web/features/location/types/index.ts
@@ -0,0 +1,13 @@
+export type Step = 'intro' | 'confirm' | 'success';
+export type Location = {
+ lat: number;
+ lng: number;
+};
+export interface UpdateLocationProps {
+ location: Location;
+ address: string;
+}
+export type LocationWithAddress = {
+ location: Location;
+ address: string;
+};
diff --git a/apps/web/features/location/ui/DotStepper.tsx b/apps/web/features/location/ui/DotStepper.tsx
index b24ebbf9..899f0d81 100644
--- a/apps/web/features/location/ui/DotStepper.tsx
+++ b/apps/web/features/location/ui/DotStepper.tsx
@@ -1,19 +1,21 @@
import { Dot } from 'lucide-react';
import clsx from 'clsx';
+import { useLocationStore } from '@/features/location/model/useLocationStore';
+import { Step } from '@/features/location/types';
-interface DotStepperProps {
- activeIndex: number;
-}
+const steps: Step[] = ['intro', 'confirm', 'success'];
+
+const DotStepper = () => {
+ const step = useLocationStore((state) => state.step);
-const DotStepper = ({ activeIndex }: DotStepperProps) => {
return (
- {[0, 1, 2].map((i) => (
+ {steps.map((s) => (
))}
diff --git a/apps/web/features/location/ui/GooggleMap.tsx b/apps/web/features/location/ui/GooggleMap.tsx
new file mode 100644
index 00000000..31769b6a
--- /dev/null
+++ b/apps/web/features/location/ui/GooggleMap.tsx
@@ -0,0 +1,190 @@
+'use client';
+
+import { AdvancedMarker, APIProvider, Map, Pin } from '@vis.gl/react-google-maps';
+import Loading from '@/shared/ui/Loading/Loading';
+import { useEffect, useState } from 'react';
+import { getKoreanAddress } from '@/features/location/api/getKoreanAddress';
+import { toast } from '@repo/ui/components/Toast/Sonner';
+import { Button } from '@repo/ui/components/Button/Button';
+import { Location } from '@/features/location/types';
+import { getGeoPermissionState } from '@/features/location/lib/utils';
+
+const MAPAPIKEY = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY as string;
+
+export interface GoogleMapProps {
+ setLocation: (location: Location) => void;
+ setAddress?: (address: string) => void;
+ height?: string;
+ mapId: string;
+ draggable?: boolean;
+ initialLocation?: Location;
+}
+
+const GoogleMap = ({
+ setLocation,
+ setAddress,
+ height = 'h-[200px]',
+ mapId,
+ draggable = false,
+ initialLocation,
+}: GoogleMapProps) => {
+ const [error, setError] = useState(false);
+ const [loading, setLoading] = useState(true);
+ const [currentLocation, setCurrentLocation] = useState(null);
+ const [currentAddress, setCurrentAddress] = useState(null);
+ const [showGuide, setShowGuide] = useState(false);
+ const openGuide = () => {
+ setShowGuide(true);
+ toast({ content: '์์น ์ ๊ทผ์ด ์ฐจ๋จ๋์ด ์์ด์. ๋ธ๋ผ์ฐ์ ์ค์ ์์ ํ์ฉ์ผ๋ก ๋ณ๊ฒฝํด์ฃผ์ธ์.' });
+ };
+ const fetchLocation = async () => {
+ setLoading(true);
+ setError(false);
+
+ const state = await getGeoPermissionState();
+
+ if (state === 'denied') {
+ openGuide();
+ setError(true);
+ setLoading(false);
+ return;
+ }
+
+ navigator.geolocation.getCurrentPosition(
+ async (pos) => {
+ const coords = {
+ lat: pos.coords.latitude,
+ lng: pos.coords.longitude,
+ };
+ setCurrentLocation(coords);
+ setLocation(coords);
+ const koreanAddress = await getKoreanAddress(coords);
+
+ if (koreanAddress) {
+ setCurrentAddress(koreanAddress);
+ setAddress?.(koreanAddress);
+ } else {
+ setError(true);
+ }
+ setLoading(false);
+ },
+ (err) => {
+ if (err.code === err.PERMISSION_DENIED) {
+ openGuide();
+ } else {
+ toast({ content: '์ง๋๋ฅผ ๋ถ๋ฌ์ค๋ ๋ฐ ๋ฌธ์ ๊ฐ ๋ฐ์ํ์ต๋๋ค.' });
+ }
+ setError(true);
+ setLoading(false);
+ }
+ );
+ };
+
+ useEffect(() => {
+ if (initialLocation) {
+ // ๐ฅ props๋ก ๋ฐ์ ์ขํ๊ฐ ์์ ๊ฒฝ์ฐ
+ setCurrentLocation(initialLocation);
+ setLocation(initialLocation);
+ getKoreanAddress(initialLocation).then((koreanAddress) => {
+ if (koreanAddress) {
+ setCurrentAddress(koreanAddress);
+ if (setAddress) setAddress(koreanAddress);
+ } else {
+ setError(true);
+ }
+ setLoading(false);
+ });
+ } else {
+ fetchLocation();
+ }
+ }, []);
+
+ const handleDragEnd = async (e: google.maps.MapMouseEvent) => {
+ if (!draggable || !e.latLng) return;
+
+ const newCoords = {
+ lat: e.latLng.lat(),
+ lng: e.latLng.lng(),
+ };
+
+ setCurrentLocation(newCoords);
+ setLocation(newCoords);
+
+ const koreanAddress = await getKoreanAddress(newCoords);
+ if (koreanAddress) {
+ setCurrentAddress(koreanAddress);
+ setAddress?.(koreanAddress);
+ }
+ };
+
+ if (showGuide) {
+ return (
+
+
์์น ์ ๊ทผ์ด ์ฐจ๋จ๋์์ต๋๋ค
+
+ - ์ฌ์ดํธ๊ฐ HTTPS์ธ์ง ํ์ธ
+ -
+ ๋ธ๋ผ์ฐ์ ์ฃผ์์ฐฝ ์๋ฌผ์ ์์ด์ฝ โ ์ฌ์ดํธ ์ค์ โ ์์น: ํ์ฉ
+
+ -
+ iOS Safari: ์ค์ ์ฑ โ ๊ฐ์ธ์ ๋ณด ๋ณดํธ/๋ณด์ โ ์์น ์๋น์ค โ Safari ์น์ฌ์ดํธ โ ํ์ฉ
+
+ - Android: ์ค์ โ ์์น ์ผ๊ธฐ, ๋ธ๋ผ์ฐ์ ์ฑ ๊ถํ ํ์ฉ
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+ {loading ? (
+
+ ) : error || !currentLocation ? (
+
+
์์น ์ ๋ณด๋ฅผ ๊ฐ์ ธ์ฌ ์ ์์ต๋๋ค.
+
์ฌ์ฉ๊ธฐ๊ธฐ์ โ์์น์ ๋ณดโ ์ฌ์ฉ ์ค์ ์ ํ์ธํด ์ฃผ์๊ธฐ ๋ฐ๋๋๋ค.
+
+
+ ) : (
+ <>
+
+ {currentAddress}
+ >
+ )}
+
+
+ );
+};
+
+export default GoogleMap;
diff --git a/apps/web/features/location/ui/GoogleMapPinBottomCard.tsx b/apps/web/features/location/ui/GoogleMapPinBottomCard.tsx
new file mode 100644
index 00000000..41d063d6
--- /dev/null
+++ b/apps/web/features/location/ui/GoogleMapPinBottomCard.tsx
@@ -0,0 +1,49 @@
+'use client';
+
+import Image from 'next/image';
+import Link from 'next/link';
+import { encodeUUID } from '@/shared/lib/shortUuid';
+import { AuctionMarkerResponse } from '@/features/auction/list/types';
+import { X } from 'lucide-react';
+import StatusBadge from '@/shared/ui/badge/StatusBadge';
+import { getCountdownWithColor } from '@/features/product/lib/utils';
+
+interface GoogleMapPinBottomCardProps {
+ product: AuctionMarkerResponse;
+ onClose: () => void;
+}
+
+const GoogleMapPinBottomCard = ({ product, onClose }: GoogleMapPinBottomCardProps) => {
+ return (
+
+
+
+
+
+
+
+
+ {product.title}
+
+
+ {product.bidPrice.toLocaleString()}
+ ์
+
+
+
+
+
+
+
+ );
+};
+
+export default GoogleMapPinBottomCard;
diff --git a/apps/web/features/location/ui/GoogleMapSkeleton.tsx b/apps/web/features/location/ui/GoogleMapSkeleton.tsx
new file mode 100644
index 00000000..2f587c7d
--- /dev/null
+++ b/apps/web/features/location/ui/GoogleMapSkeleton.tsx
@@ -0,0 +1,20 @@
+import Image from 'next/image';
+const MAPAPIKEY = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY as string;
+
+const GoogleMapSkeleton = () => {
+ const staticMapUrl = `https://maps.googleapis.com/maps/api/staticmap?center=37.5642135,127.0016985&zoom=10&size=1200x300&key=${MAPAPIKEY}`;
+
+ return (
+
+
+
+ );
+};
+
+export default GoogleMapSkeleton;
diff --git a/apps/web/features/location/ui/GoogleMapView.tsx b/apps/web/features/location/ui/GoogleMapView.tsx
new file mode 100644
index 00000000..90a79a17
--- /dev/null
+++ b/apps/web/features/location/ui/GoogleMapView.tsx
@@ -0,0 +1,83 @@
+'use client';
+
+import { AdvancedMarker, APIProvider, Map, Pin } from '@vis.gl/react-google-maps';
+import { useEffect, useState } from 'react';
+import { Location } from '@/features/location/types';
+import { MapMarkers } from '@/features/location/ui/MapMarkers';
+import { AuctionMarkerResponse } from '@/features/auction/list/types';
+import GoogleMapPinBottomCard from '@/features/location/ui/GoogleMapPinBottomCard';
+import { SheetMode } from '@/features/home/types';
+
+const MAPAPIKEY = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY as string;
+
+export interface GoogleMapViewProps {
+ height?: string;
+ mapId: string;
+ location: Location;
+ markers?: AuctionMarkerResponse[];
+ showMyLocation?: boolean;
+ showMarkers?: boolean;
+ setSheetMode?: React.Dispatch>;
+}
+
+const GoogleMapView = ({
+ height = 'h-[200px]',
+ mapId,
+ location,
+ markers = [],
+ showMyLocation = true,
+ showMarkers = false,
+ setSheetMode,
+}: GoogleMapViewProps) => {
+ const [currentLocation, setCurrentLocation] = useState(null);
+ const [selectedMarker, setSelectedMarker] = useState(null);
+
+ useEffect(() => {
+ if (location) {
+ setCurrentLocation(location);
+ }
+ }, [location]);
+
+ if (!currentLocation) return null;
+
+ return (
+
+
+
+
+
+ {selectedMarker && (
+
setSelectedMarker(null)} />
+ )}
+
+ );
+};
+
+export default GoogleMapView;
diff --git a/apps/web/features/location/ui/LocationConfirm.tsx b/apps/web/features/location/ui/LocationConfirm.tsx
index 507c501a..09aa4d80 100644
--- a/apps/web/features/location/ui/LocationConfirm.tsx
+++ b/apps/web/features/location/ui/LocationConfirm.tsx
@@ -1,89 +1,31 @@
'use client';
-import { useEffect, useState } from 'react';
-import { AdvancedMarker, APIProvider, Map, Pin } from '@vis.gl/react-google-maps';
import { Button } from '@repo/ui/components/Button/Button';
-import DotStepper from '@/features/location/ui/DotStepper';
-import { getAddressFromLatLng } from '@/features/location/api/getAddressFromLatLng';
-import { SetLocation } from '@/features/location/api/setLocation';
-import { useAuthStore } from '@/shared/model/authStore';
-import { useRouter } from 'next/navigation';
-import { toast } from '@repo/ui/components/Toast/Sonner';
+import GoogleMap from '@/features/location/ui/GooggleMap';
+import useUpdateLocation from '@/features/location/model/useUpdateLocation';
+import clsx from 'clsx';
+import { Location } from '@/features/location/types';
+import { useState } from 'react';
-const MAPAPIKEY = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY as string;
-
-type LocationConfirmProps = {
- onNext: () => void;
-};
-
-const LocationConfirm = ({ onNext }: LocationConfirmProps) => {
- const router = useRouter();
- const [position, setPosition] = useState(null);
- const [address, setAddress] = useState('');
- const [error, setError] = useState(false);
- const [loading, setLoading] = useState(true);
- const userId = useAuthStore((state) => state.user?.id);
- const updateAddress = useAuthStore((state) => state.updateAddress);
+const LocationConfirm = () => {
+ const [location, setLocation] = useState(null);
+ const [address, setAddress] = useState(null);
+ const { mutate: locationMutate, isPending: isLocationUpdatePending } = useUpdateLocation();
const handleNext = async () => {
- if (!userId) {
- toast({ content: '๋ก๊ทธ์ธ์ด ํ์ํฉ๋๋ค.' });
- router.replace('/login');
+ if (!location) {
return;
}
- if (!position) {
- toast({ content: '์์น ์ ๋ณด๊ฐ ์์ต๋๋ค.' });
- return;
- }
-
if (!address) {
- toast({ content: '์ฃผ์ ์ ๋ณด๊ฐ ์์ต๋๋ค.' });
return;
}
- updateAddress(address);
-
- try {
- await SetLocation({
- userId,
- lat: position.lat,
- lng: position.lng,
- address,
- });
- onNext();
- } catch (err) {
- console.error('์์น ์ ์ฅ ์คํจ:', err);
- toast({ content: '์์น ์ ์ฅ์ ์คํจํ์ต๋๋ค.' });
- }
- };
-
- const fetchLocation = () => {
- setLoading(true);
- setError(false);
- navigator.geolocation.getCurrentPosition(
- async (pos) => {
- const coords = {
- lat: pos.coords.latitude,
- lng: pos.coords.longitude,
- };
- setPosition(coords);
- const fullAddress = await getAddressFromLatLng(coords);
- if (fullAddress) setAddress(fullAddress);
- setLoading(false);
- },
- (err) => {
- toast({ content: '์ง๋๋ฅผ ๋ถ๋ฌ์ค๋ ๋ฐ ๋ฌธ์ ๊ฐ ๋ฐ์ํ์ต๋๋ค.' });
- console.error('์ง๋๋ฅผ ๋ถ๋ฌ์ค๋ ๋ฐ ๋ฌธ์ ๊ฐ ๋ฐ์ํ์ต๋๋ค.', err);
- setError(true);
- setLoading(false);
- }
- );
+ locationMutate({
+ location,
+ address,
+ });
};
- useEffect(() => {
- fetchLocation();
- }, []);
-
return (
<>
@@ -95,43 +37,20 @@ const LocationConfirm = ({ onNext }: LocationConfirmProps) => {
์ฌ์ฉ์ ์์น๋ฅผ ์ค์ ํด์ผ ํฉ๋๋ค.
-
-
-
-
- {loading ? (
- ์์น ์ ๋ณด๋ฅผ ๋ถ๋ฌ์ค๋ ์ค...
- ) : error ? (
-
- ์์น ์ ๋ณด๋ฅผ ๊ฐ์ ธ์ฌ ์ ์์ต๋๋ค.
-
-
- ) : (
- <>
-
- {address}
- >
- )}
-
-
+
๋ฐ๊ฒฝ 3km ์ด๋ด์ ์ค์ฐจ๊ฐ ์์ ์ ์์ต๋๋ค.
-
-