From f935915587387aef2d30b380542678d0c20880d9 Mon Sep 17 00:00:00 2001 From: seoJing Date: Sat, 6 Jun 2026 14:40:59 +0900 Subject: [PATCH 1/2] fix: address spot qa feedback --- package.json | 1 + .../design-system/src/components/Toaster.tsx | 30 +++++++++ packages/design-system/src/index.ts | 3 + pnpm-lock.yaml | 14 ++++ src/app/layout.tsx | 2 + .../auth/ui/login/SocialLoginButtons.tsx | 11 +++ src/features/chat/api/chat-api.ts | 6 +- src/features/feed/model/feed-filter.ts | 14 +++- src/features/feed/model/types.ts | 2 +- .../ui/detail/FeedParticipationActions.tsx | 40 ++++++++--- src/features/map/client/MapClient.tsx | 50 ++++++++------ .../map/model/feed-stack-gesture.test.ts | 14 ++-- src/features/map/model/feed-stack-gesture.ts | 25 +------ src/features/map/model/types.ts | 2 +- src/features/map/ui/MapCardDeckOverlay.tsx | 17 ----- src/features/map/ui/MapFeedInfoCard.tsx | 1 + src/features/map/ui/SpotInfoCard.tsx | 2 +- src/features/my/client/MyPageClient.tsx | 67 ++++++++++++++----- src/features/my/ui/my-page/MyFormControls.tsx | 4 +- src/features/post/client/OfferFormClient.tsx | 2 + .../post/client/RequestFormClient.tsx | 2 + src/features/post/model/use-post-base-form.ts | 12 +++- .../post/model/use-post-form-prefill.ts | 4 ++ .../client/detail/SettlementSubmitSheet.tsx | 31 ++++++--- .../spot/client/detail/SpotDetailClient.tsx | 39 +++++++++-- .../client/detail/SpotSettlementActions.tsx | 13 ++-- src/features/spot/ui/detail/ActionBar.tsx | 17 ++++- 27 files changed, 296 insertions(+), 129 deletions(-) create mode 100644 packages/design-system/src/components/Toaster.tsx diff --git a/package.json b/package.json index 34e5bdf..6d56f55 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "react-dom": "19.2.4", "react-icons": "^5.6.0", "serwist": "^9.5.7", + "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "vaul": "^1.1.2", "zustand": "^5.0.12" diff --git a/packages/design-system/src/components/Toaster.tsx b/packages/design-system/src/components/Toaster.tsx new file mode 100644 index 0000000..f227f04 --- /dev/null +++ b/packages/design-system/src/components/Toaster.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { Toaster as SonnerToaster, toast } from 'sonner'; +import type { ToasterProps } from 'sonner'; + +export type AppToasterProps = ToasterProps; + +export function AppToaster(props: AppToasterProps) { + return ( + + ); +} + +export { toast }; diff --git a/packages/design-system/src/index.ts b/packages/design-system/src/index.ts index a4c826e..2a261c7 100644 --- a/packages/design-system/src/index.ts +++ b/packages/design-system/src/index.ts @@ -55,3 +55,6 @@ export type { BottomSheetProps } from './components/BottomSheet'; export { Modal } from './components/Modal'; export type { ModalProps } from './components/Modal'; + +export { AppToaster, toast } from './components/Toaster'; +export type { AppToasterProps } from './components/Toaster'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb62c6b..cd44ff0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: serwist: specifier: ^9.5.7 version: 9.5.7(browserslist@4.28.1)(typescript@5.9.3) + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) tailwind-merge: specifier: ^3.5.0 version: 3.5.0 @@ -4114,6 +4117,12 @@ packages: resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} engines: {node: '>=18'} + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -8889,6 +8898,11 @@ snapshots: mrmime: 2.0.1 totalist: 3.0.1 + sonner@2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + source-map-js@1.2.1: {} source-map-support@0.5.21: diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 226a924..79feac0 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next'; +import { AppToaster } from '@frontend/design-system'; import { NotificationSSEProvider } from '@/app/providers/notification-sse-provider'; import { QueryProvider } from '@/app/providers/query-provider'; import { ThemeProvider } from '@/app/providers/theme-provider'; @@ -52,6 +53,7 @@ export default function RootLayout({ {children} + diff --git a/src/features/auth/ui/login/SocialLoginButtons.tsx b/src/features/auth/ui/login/SocialLoginButtons.tsx index 2b4d7ab..6414e19 100644 --- a/src/features/auth/ui/login/SocialLoginButtons.tsx +++ b/src/features/auth/ui/login/SocialLoginButtons.tsx @@ -1,3 +1,7 @@ +'use client'; + +import type { MouseEvent } from 'react'; +import { toast } from '@frontend/design-system'; import { SiGoogle, SiNaver } from 'react-icons/si'; type SocialLoginButtonsProps = { @@ -5,6 +9,11 @@ type SocialLoginButtonsProps = { googleHref: string; }; +function showBlockedOAuth(event: MouseEvent) { + event.preventDefault(); + toast.info('시연 중에는 소셜 로그인을 잠시 막아뒀어요.'); +} + export function SocialLoginButtons({ naverHref, googleHref, @@ -20,6 +29,7 @@ export function SocialLoginButtons({
@@ -27,6 +37,7 @@ export function SocialLoginButtons({ diff --git a/src/features/chat/api/chat-api.ts b/src/features/chat/api/chat-api.ts index 30267ab..df6d21a 100644 --- a/src/features/chat/api/chat-api.ts +++ b/src/features/chat/api/chat-api.ts @@ -123,7 +123,7 @@ function toChatRoom(room: BackendRoom): ChatRoom { room.title ?? (room.spotId ? `스팟 ${room.spotId}` : `팀 채팅 ${id}`), subtitle: room.subtitle ?? '팀 채팅', - description: room.lastMessagePreview ?? '백엔드 채팅방입니다.', + description: room.lastMessagePreview ?? '아직 메시지가 없어요.', metaLabel: '팀 채팅', updatedAt, unreadCount: room.unreadCount ?? 0, @@ -134,7 +134,7 @@ function toChatRoom(room: BackendRoom): ChatRoom { type: 'REQUEST', status: 'OPEN', title: room.spotId ? `스팟 ${room.spotId}` : `팀 채팅 ${id}`, - description: '백엔드 채팅방입니다.', + description: '아직 메시지가 없어요.', pointCost: 0, authorId: '', authorNickname: '', @@ -162,7 +162,7 @@ function toChatRoom(room: BackendRoom): ChatRoom { counterpartRole: 'PARTNER', title: room.title ?? `개인 채팅 ${id}`, subtitle: room.subtitle ?? '개인 채팅', - description: room.lastMessagePreview ?? '백엔드 채팅방입니다.', + description: room.lastMessagePreview ?? '아직 메시지가 없어요.', metaLabel: '개인 채팅', updatedAt, messages: [], diff --git a/src/features/feed/model/feed-filter.ts b/src/features/feed/model/feed-filter.ts index d2280db..0a21850 100644 --- a/src/features/feed/model/feed-filter.ts +++ b/src/features/feed/model/feed-filter.ts @@ -6,15 +6,27 @@ export type FeedMarkerFilter = { feedType: 'all' | 'offer' | 'request'; categories: readonly SpotCategory[]; searchQuery: string; + excludeApplied?: boolean; + onlyOpenFeeds?: boolean; }; export function filterVisibleFeedItems( feedItems: readonly FeedItem[], - { feedType, categories, searchQuery }: FeedMarkerFilter, + { + feedType, + categories, + searchQuery, + excludeApplied = false, + onlyOpenFeeds = false, + }: FeedMarkerFilter, ): FeedItem[] { const q = searchQuery.trim().toLowerCase(); return feedItems.filter((item) => { + if (onlyOpenFeeds && (item.status !== 'OPEN' || item.spotId)) { + return false; + } + if (excludeApplied && isSearchExcludedFeedItem(item)) return false; if (feedType === 'offer' && item.type !== 'OFFER') return false; if (feedType === 'request' && item.type !== 'REQUEST') return false; if ( diff --git a/src/features/feed/model/types.ts b/src/features/feed/model/types.ts index 068991a..300b155 100644 --- a/src/features/feed/model/types.ts +++ b/src/features/feed/model/types.ts @@ -119,7 +119,7 @@ export interface FeedItem { owner?: boolean; /** * 2026-04-30 — contextBuilder 시뮬레이션이 합성한 AI 피드 마커. - * 실제 호스트가 만든 글이 아니므로 참여 액션 대신 "리퀘스트 열기" 안내로 대체. + * 실제 호스트가 만든 글이 아니므로 참여 액션 대신 "알려줘 열기" 안내로 대체. */ isAi?: boolean; // 2026-04-30 contextBuilder PlanV3/PriceBreakdown/Preparation/ResolvedPlace 통합. diff --git a/src/features/feed/ui/detail/FeedParticipationActions.tsx b/src/features/feed/ui/detail/FeedParticipationActions.tsx index c1c5722..2f098bf 100644 --- a/src/features/feed/ui/detail/FeedParticipationActions.tsx +++ b/src/features/feed/ui/detail/FeedParticipationActions.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; -import { Button, Input, Modal } from '@frontend/design-system'; +import { Button, Input, Modal, toast } from '@frontend/design-system'; import { usePointBalance } from '@/features/pay'; import { BottomSheet } from '@/shared/ui'; import { useBottomNavMessageStore } from '@/shared/model/bottom-nav-message-store'; @@ -285,6 +285,7 @@ export function FeedParticipationActions({ '참여 신청이 완료되었어요. 승인되면 팀 채팅에 참여할 수 있어요.', '', ); + toast.success('참여 신청이 완료되었어요.'); setSelectedRole(null); router.refresh(); } finally { @@ -295,6 +296,8 @@ export function FeedParticipationActions({ const hasPendingApplication = item.myApplicationStatus === 'APPLIED' || item.myApplicationStatus === 'PENDING'; + const hasActiveApplication = + hasPendingApplication || item.myApplicationStatus === 'ACCEPTED'; const cancelOutcome: CancellationOutcome | null = useMemo(() => { if ( @@ -327,6 +330,7 @@ export function FeedParticipationActions({ await cancelFeedApplication.mutateAsync(item.id); showBottomNavMessage('신청을 취소했어요.', ''); + toast.success('신청을 취소했어요.'); setCancelOpen(false); router.refresh(); } finally { @@ -354,7 +358,7 @@ export function FeedParticipationActions({ return '신청 후 승인되면 팀 채팅에 참여할 수 있어요.'; })(); - const isApplied = hasPendingApplication; + const isApplied = hasActiveApplication; const pendingApplications = (applicationsQuery.data?.data ?? []).filter( isPendingApplication, ); @@ -512,25 +516,36 @@ export function FeedParticipationActions({ } // AI 피드(시뮬레이터가 합성한 데이터) 는 실제 호스트가 없으므로 참여가 의미 없음. - // 같은 자리에 "이런 리퀘스트 직접 열기" 액션만 보여주고 /post/request 로 prefill 라우팅. + // 같은 자리에 "이런 알려줘 직접 열기" 액션만 보여주고 /post/request 로 prefill 라우팅. if (item.isAi) { + const openRequestFromAiFeed = () => { + const params = new URLSearchParams({ + fromSpot: item.id, + title: item.title, + category: item.category ?? '', + location: item.location, + content: item.description ?? item.title, + }); + const lat = item.lat ?? item.coord?.lat; + const lng = item.lng ?? item.coord?.lng; + if (typeof lat === 'number') params.set('lat', String(lat)); + if (typeof lng === 'number') params.set('lng', String(lng)); + router.push(`/post/request?${params.toString()}`); + }; + return (

- AI 가 추천한 모임이에요. 마음에 들면 비슷한 리퀘스트를 직접 + AI가 추천한 모임이에요. 마음에 들면 비슷한 알려줘를 직접 열어보세요.

); @@ -545,9 +560,12 @@ export function FeedParticipationActions({ size="lg" variant="secondary" className="rounded-full" + disabled={!hasPendingApplication} onClick={() => setCancelOpen(true)} > - 신청 취소 + {hasPendingApplication + ? '신청 취소' + : '이미 참여 중인 피드예요'} ) : (
diff --git a/src/features/map/client/MapClient.tsx b/src/features/map/client/MapClient.tsx index bef40ec..379aeff 100644 --- a/src/features/map/client/MapClient.tsx +++ b/src/features/map/client/MapClient.tsx @@ -16,6 +16,7 @@ import { } from '@/features/feed/ui/MapFeedCardPager'; import { FeedBottomSheet } from '@/features/feed/ui/FeedBottomSheet'; import type { BottomSheetSnapPoint } from '@frontend/design-system'; +import { toast } from '@frontend/design-system'; import { useLayerStore } from '@/features/layer/model/use-layer-store'; import { useSimRun } from '@/features/simulation/model/use-sim-run'; import { useSimDomain } from '@/features/simulation/model/sim-domain-adapter'; @@ -35,7 +36,10 @@ import { type MapTutorialMarkerInfo, } from '@/features/map/ui/MapTutorialOverlay'; import { LiveTicker } from '@/features/map/ui/LiveTicker'; -import { useLayerAwareFeedList } from '@/features/feed/model/use-feed'; +import { + useLayerAwareFeedList, + useToggleFeedBookmark, +} from '@/features/feed/model/use-feed'; import { filterVisibleFeedItems } from '@/features/feed/model/feed-filter'; import { groupFeedMarkersByProximity } from '@/features/feed/model/feed-marker-group'; import { filterHotspotsOverlappingFeedMarkers } from '@/features/map/model/hotspot-feed-overlap'; @@ -382,6 +386,8 @@ export function MapClient() { feedType, categories, searchQuery, + excludeApplied: true, + onlyOpenFeeds: true, }), [feedData?.data, feedType, categories, searchQuery], ); @@ -390,6 +396,25 @@ export function MapClient() { () => groupFeedMarkersByProximity(visibleFeedItems), [visibleFeedItems], ); + const toggleFeedBookmark = useToggleFeedBookmark(); + const handleFeedBookmark = useCallback( + (item: (typeof visibleFeedItems)[number]) => { + toggleFeedBookmark.mutate( + { feedId: item.id, bookmarked: Boolean(item.isBookmarked) }, + { + onSuccess: () => { + toast.success( + item.isBookmarked + ? '찜을 해제했어요.' + : '찜에 추가했어요.', + ); + }, + onError: () => toast.error('찜 상태를 바꾸지 못했어요.'), + }, + ); + }, + [toggleFeedBookmark], + ); const activeLayer = useLayerStore((s) => s.activeLayer); const showSpots = activeLayer !== 'virtual'; @@ -871,10 +896,7 @@ export function MapClient() { onTutorialCardDetail={ tutorialOpen ? completeDeckTutorial : undefined } - onBookmark={(item) => { - // TODO: 다음 PR에서 useAddFavorite mutation 연결 - console.info('[bookmark]', item.id, item.title); - }} + onBookmark={handleFeedBookmark} /> updateUrl({ cluster: null }) } - onBookmarkAction={(item) => { - // TODO: 다음 PR에서 useAddFavorite mutation 연결 - console.info( - '[bookmark]', - item.id, - item.title, - ); - }} + onBookmarkAction={handleFeedBookmark} /> ); } @@ -979,14 +994,7 @@ export function MapClient() { onDetailAction={() => router.push(`/feed/${selectedFeed.id}`) } - onBookmarkAction={(item) => { - // TODO: 다음 PR에서 useAddFavorite mutation 연결 - console.info( - '[bookmark]', - item.id, - item.title, - ); - }} + onBookmarkAction={handleFeedBookmark} /> ); } diff --git a/src/features/map/model/feed-stack-gesture.test.ts b/src/features/map/model/feed-stack-gesture.test.ts index 81a5dd9..3ff94e4 100644 --- a/src/features/map/model/feed-stack-gesture.test.ts +++ b/src/features/map/model/feed-stack-gesture.test.ts @@ -11,30 +11,30 @@ describe('resolveFeedStackGesture', () => { ).toBe('center'); }); - it('treats both side swipes as detail triggers', () => { + it('keeps both side swipes centered instead of opening detail', () => { expect( resolveFeedStackGesture({ dx: -150, dy: 12, }), - ).toBe('detail-left'); + ).toBe('center'); expect( resolveFeedStackGesture({ dx: 150, dy: 12, }), - ).toBe('detail-right'); + ).toBe('center'); }); - it('uses fast side flicks as detail triggers even with short drag distance', () => { + it('keeps fast side flicks centered instead of opening detail', () => { expect( resolveFeedStackGesture({ dx: -64, dy: 8, velocityX: -820, }), - ).toBe('detail-left'); + ).toBe('center'); expect( resolveFeedStackGesture({ @@ -42,10 +42,10 @@ describe('resolveFeedStackGesture', () => { dy: 8, velocityX: 820, }), - ).toBe('detail-right'); + ).toBe('center'); }); - it('requires horizontal dominance before side actions', () => { + it('requires vertical dominance before next actions', () => { expect( resolveFeedStackGesture({ dx: 150, diff --git a/src/features/map/model/feed-stack-gesture.ts b/src/features/map/model/feed-stack-gesture.ts index 769a9d2..70b9b41 100644 --- a/src/features/map/model/feed-stack-gesture.ts +++ b/src/features/map/model/feed-stack-gesture.ts @@ -1,13 +1,6 @@ -export type FeedStackGestureAction = - | 'center' - | 'next' - | 'detail-left' - | 'detail-right'; +export type FeedStackGestureAction = 'center' | 'next'; const SWIPE_DOWN_THRESHOLD = 72; -const SWIPE_SIDE_TRIGGER_THRESHOLD = 136; -const SWIPE_SIDE_VELOCITY_THRESHOLD = 760; -const SWIPE_SIDE_RESTORING_DISTANCE = 48; const SWIPE_AXIS_DOMINANCE_RATIO = 1.45; type ResolveFeedStackGestureInput = { @@ -19,24 +12,8 @@ type ResolveFeedStackGestureInput = { export function resolveFeedStackGesture({ dx, dy, - velocityX = 0, }: ResolveFeedStackGestureInput): FeedStackGestureAction { const absX = Math.abs(dx); - const absY = Math.abs(dy); - const absVx = Math.abs(velocityX); - - const isMostlyHorizontal = - absX >= SWIPE_SIDE_RESTORING_DISTANCE && - absX >= absY * SWIPE_AXIS_DOMINANCE_RATIO; - const isSideTrigger = - isMostlyHorizontal && - (absX >= SWIPE_SIDE_TRIGGER_THRESHOLD || - absVx >= SWIPE_SIDE_VELOCITY_THRESHOLD); - - if (isSideTrigger) { - if (dx < 0) return 'detail-left'; - return 'detail-right'; - } const isDownSwipe = dy >= SWIPE_DOWN_THRESHOLD && dy >= absX * SWIPE_AXIS_DOMINANCE_RATIO; diff --git a/src/features/map/model/types.ts b/src/features/map/model/types.ts index bfac61d..466c4f1 100644 --- a/src/features/map/model/types.ts +++ b/src/features/map/model/types.ts @@ -20,7 +20,7 @@ export type ActivityCluster = { arrivedCount?: number; /** * 클러스터 시각 변형. - * - discovery: 시뮬레이션상 생기는 동네 발견 신호. 상세/리퀘스트 전환보다 배경 발견 역할. + * - discovery: 시뮬레이션상 생기는 동네 발견 신호. 상세/알려줘 전환보다 배경 발견 역할. * - ai-feed: LLM 검증 추천 피드. 상세 진입 가능. * - user-feed: 실제 사용자 피드. 가장 높은 우선도. * - feed-group: 정확히 같거나 가까운 좌표에 쌓인 여러 피드 묶음. diff --git a/src/features/map/ui/MapCardDeckOverlay.tsx b/src/features/map/ui/MapCardDeckOverlay.tsx index 4b9c05f..ad5d670 100644 --- a/src/features/map/ui/MapCardDeckOverlay.tsx +++ b/src/features/map/ui/MapCardDeckOverlay.tsx @@ -95,31 +95,14 @@ export function MapCardDeckOverlay({ if (prefersReducedMotion) onCloseAction(); } - function openDetail(dir: Extract) { - if (!topItem?.onDetailAction) return; - setExitOverride({ id: topItem.id, dir }); - requestAnimationFrame(topItem.onDetailAction); - } - function handleTopDragEnd(_event: unknown, info: PanInfo) { if (!topItem) return; const gesture = resolveFeedStackGesture({ dx: info.offset.x, dy: info.offset.y, - velocityX: info.velocity.x, }); - if (gesture === 'detail-left') { - openDetail('left'); - return; - } - - if (gesture === 'detail-right') { - openDetail('right'); - return; - } - if (gesture === 'next') dismissTop('down'); } diff --git a/src/features/map/ui/MapFeedInfoCard.tsx b/src/features/map/ui/MapFeedInfoCard.tsx index 7c20172..6e59760 100644 --- a/src/features/map/ui/MapFeedInfoCard.tsx +++ b/src/features/map/ui/MapFeedInfoCard.tsx @@ -31,6 +31,7 @@ export function MapFeedInfoCard({ ]} onCloseAction={onCloseAction} dismissBehavior="close" + disableContentClick chrome={ onBookmarkAction ? (
) : ( + ) : ( + + {content} + + ); + })}
))} diff --git a/src/features/my/ui/my-page/MyFormControls.tsx b/src/features/my/ui/my-page/MyFormControls.tsx index 600b8fc..6082f0d 100644 --- a/src/features/my/ui/my-page/MyFormControls.tsx +++ b/src/features/my/ui/my-page/MyFormControls.tsx @@ -174,12 +174,12 @@ export function MyToggleRow({ 'relative h-7 w-12 rounded-full border transition-colors disabled:cursor-not-allowed disabled:opacity-60', checked ? 'border-foreground bg-foreground' - : 'border-border-strong bg-border-soft', + : 'border-border-strong bg-muted', )} > diff --git a/src/features/post/client/OfferFormClient.tsx b/src/features/post/client/OfferFormClient.tsx index 0510421..8981323 100644 --- a/src/features/post/client/OfferFormClient.tsx +++ b/src/features/post/client/OfferFormClient.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; +import { toast } from '@frontend/design-system'; import { usePostBaseForm } from '../model/use-post-base-form'; import { usePostFormPrefill } from '../model/use-post-form-prefill'; import { readSimulationConversionContext } from '@/features/simulation/model/simulation-conversion-context'; @@ -158,6 +159,7 @@ export function OfferFormClient() { }); clearDraft(); + toast.success('해볼래 피드를 만들었어요.'); router.push( `/chat?tab=team&spotId=${encodeURIComponent(created.spotId ?? created.id)}`, ); diff --git a/src/features/post/client/RequestFormClient.tsx b/src/features/post/client/RequestFormClient.tsx index f6d2beb..9c85b09 100644 --- a/src/features/post/client/RequestFormClient.tsx +++ b/src/features/post/client/RequestFormClient.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; +import { toast } from '@frontend/design-system'; import { usePostBaseForm } from '../model/use-post-base-form'; import { usePostFormPrefill } from '../model/use-post-form-prefill'; import { readSimulationConversionContext } from '@/features/simulation/model/simulation-conversion-context'; @@ -147,6 +148,7 @@ export function RequestFormClient() { }); clearDraft(); + toast.success('알려줘 피드를 만들었어요.'); router.push( `/chat?tab=team&spotId=${encodeURIComponent(created.spotId ?? created.id)}`, ); diff --git a/src/features/post/model/use-post-base-form.ts b/src/features/post/model/use-post-base-form.ts index f94bf55..5093e38 100644 --- a/src/features/post/model/use-post-base-form.ts +++ b/src/features/post/model/use-post-base-form.ts @@ -27,7 +27,15 @@ interface BaseFormDraft { } export type PostBaseFormPrefill = Partial< - Pick + Pick< + BaseFormDraft, + | 'title' + | 'categories' + | 'location' + | 'locationLat' + | 'locationLng' + | 'content' + > >; function applyPrefill( @@ -40,6 +48,8 @@ function applyPrefill( title: prefill.title ?? base.title, categories: prefill.categories ?? base.categories, location: prefill.location ?? base.location, + locationLat: prefill.locationLat ?? base.locationLat, + locationLng: prefill.locationLng ?? base.locationLng, content: prefill.content ?? base.content, }; } diff --git a/src/features/post/model/use-post-form-prefill.ts b/src/features/post/model/use-post-form-prefill.ts index 4ed81a8..7cde7fb 100644 --- a/src/features/post/model/use-post-form-prefill.ts +++ b/src/features/post/model/use-post-form-prefill.ts @@ -41,6 +41,8 @@ export function usePostFormPrefill(): PostBaseFormPrefill | undefined { const category = searchParams.get('category'); const location = searchParams.get('location'); const content = searchParams.get('content'); + const lat = Number(searchParams.get('lat')); + const lng = Number(searchParams.get('lng')); if (!title && !category && !location && !content) return undefined; @@ -48,6 +50,8 @@ export function usePostFormPrefill(): PostBaseFormPrefill | undefined { if (title) prefill.title = title; if (category) prefill.categories = [mapSpotCategoryToPost(category)]; if (location) prefill.location = location; + if (Number.isFinite(lat)) prefill.locationLat = lat; + if (Number.isFinite(lng)) prefill.locationLng = lng; if (content) prefill.content = content; return prefill; }, [searchParams]); diff --git a/src/features/spot/client/detail/SettlementSubmitSheet.tsx b/src/features/spot/client/detail/SettlementSubmitSheet.tsx index 3f0012f..471bd36 100644 --- a/src/features/spot/client/detail/SettlementSubmitSheet.tsx +++ b/src/features/spot/client/detail/SettlementSubmitSheet.tsx @@ -1,7 +1,13 @@ 'use client'; import { useMemo, useState } from 'react'; -import { BottomSheet, Button, Input, Textarea } from '@frontend/design-system'; +import { + BottomSheet, + Button, + Input, + Textarea, + toast, +} from '@frontend/design-system'; import { IconX } from '@tabler/icons-react'; import type { SpotForfeitPool } from '@/entities/spot/types'; import { useSubmitSpotSettlement } from '../../model/use-spot'; @@ -97,15 +103,20 @@ export function SettlementSubmitSheet({ })) .filter((item) => item.label.length > 0 && item.amount > 0); - await submit.mutateAsync({ - id: spotId, - payload: { lineItems, summary: summary.trim() }, - }); - - setItems([emptyLineItem(), emptyLineItem()]); - setSummary(''); - onClose(); - onSubmitted?.(); + try { + await submit.mutateAsync({ + id: spotId, + payload: { lineItems, summary: summary.trim() }, + }); + + toast.success('정산을 제출했어요.'); + setItems([emptyLineItem(), emptyLineItem()]); + setSummary(''); + onClose(); + onSubmitted?.(); + } catch { + toast.error('정산 제출에 실패했어요.'); + } }; return ( diff --git a/src/features/spot/client/detail/SpotDetailClient.tsx b/src/features/spot/client/detail/SpotDetailClient.tsx index 6b15289..150e321 100644 --- a/src/features/spot/client/detail/SpotDetailClient.tsx +++ b/src/features/spot/client/detail/SpotDetailClient.tsx @@ -1,5 +1,6 @@ 'use client'; +import { toast } from '@frontend/design-system'; import { AnimatePresence } from 'framer-motion'; import { ScheduleModal } from '../../ui/detail/schedule/ScheduleModal'; import { VoteModal } from '../../ui/detail/vote/VoteModal'; @@ -35,9 +36,15 @@ export function SpotDetailClient({ }: SpotDetailClientProps) { const { activeModal, selectedVoteId, closeModal } = useSpotDetailStore(); - const { mutate: matchSpot } = useMatchSpot(); - const { mutate: cancelSpot } = useCancelSpot(); - const { mutate: completeSpot } = useCompleteSpot(); + const { mutate: matchSpot, isPending: isMatching } = useMatchSpot(); + const { mutate: cancelSpot, isPending: isCancelling } = useCancelSpot(); + const { mutate: completeSpot, isPending: isCompleting } = useCompleteSpot(); + + const participantRole = participants.find( + (participant) => participant.userId === currentUserId, + )?.role; + const canManageSpot = + spot.authorId === currentUserId || participantRole === 'SUPPORTER'; const partnerNickname = participants.find((p) => p.userId !== currentUserId)?.nickname ?? ''; @@ -47,9 +54,29 @@ export function SpotDetailClient({ matchSpot(spot.id)} - onCancel={() => cancelSpot(spot.id)} - onComplete={() => completeSpot(spot.id)} + canManageSpot={canManageSpot} + isBusy={isMatching || isCancelling || isCompleting} + onMatch={() => + matchSpot(spot.id, { + onSuccess: () => + toast.success('스팟 매칭을 시작했어요.'), + onError: () => toast.error('스팟 매칭에 실패했어요.'), + }) + } + onCancel={() => + cancelSpot(spot.id, { + onSuccess: () => toast.success('스팟을 취소했어요.'), + onError: () => toast.error('스팟 취소에 실패했어요.'), + }) + } + onComplete={() => + completeSpot(spot.id, { + onSuccess: () => + toast.success('스팟 완료를 요청했어요.'), + onError: () => + toast.error('스팟 완료 처리에 실패했어요.'), + }) + } /> diff --git a/src/features/spot/client/detail/SpotSettlementActions.tsx b/src/features/spot/client/detail/SpotSettlementActions.tsx index a55aad5..fffaf56 100644 --- a/src/features/spot/client/detail/SpotSettlementActions.tsx +++ b/src/features/spot/client/detail/SpotSettlementActions.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState } from 'react'; -import { Button, Modal } from '@frontend/design-system'; +import { Button, Modal, toast } from '@frontend/design-system'; import type { SpotForfeitPool, SpotStatus, @@ -47,9 +47,14 @@ export function SpotSettlementActions({ } const handleApprove = async () => { - await approve.mutateAsync(spotId); - setApproveOpen(false); - onSettled?.(); + try { + await approve.mutateAsync(spotId); + toast.success('정산을 승인했어요.'); + setApproveOpen(false); + onSettled?.(); + } catch { + toast.error('정산 승인에 실패했어요.'); + } }; return ( diff --git a/src/features/spot/ui/detail/ActionBar.tsx b/src/features/spot/ui/detail/ActionBar.tsx index fff2619..39690f4 100644 --- a/src/features/spot/ui/detail/ActionBar.tsx +++ b/src/features/spot/ui/detail/ActionBar.tsx @@ -6,6 +6,8 @@ import type { Spot } from '@/entities/spot/types'; interface ActionBarProps { spot: Spot; currentUserId: string; + canManageSpot?: boolean; + isBusy?: boolean; onMatch: () => void; onCancel: () => void; onComplete: () => void; @@ -14,6 +16,8 @@ interface ActionBarProps { export function ActionBar({ spot, currentUserId, + canManageSpot = false, + isBusy = false, onMatch, onCancel, onComplete, @@ -30,21 +34,27 @@ export function ActionBar({ - )} - {spot.status === 'MATCHED' && isAuthor && ( + {spot.status === 'MATCHED' && canManageSpot && ( <> ); diff --git a/src/features/post/model/use-post-form-prefill.ts b/src/features/post/model/use-post-form-prefill.ts index 7cde7fb..709f729 100644 --- a/src/features/post/model/use-post-form-prefill.ts +++ b/src/features/post/model/use-post-form-prefill.ts @@ -41,17 +41,36 @@ export function usePostFormPrefill(): PostBaseFormPrefill | undefined { const category = searchParams.get('category'); const location = searchParams.get('location'); const content = searchParams.get('content'); - const lat = Number(searchParams.get('lat')); - const lng = Number(searchParams.get('lng')); + const latRaw = searchParams.get('lat'); + const lngRaw = searchParams.get('lng'); + const parsedLat = latRaw == null ? undefined : Number(latRaw); + const parsedLng = lngRaw == null ? undefined : Number(lngRaw); + const lat = + parsedLat != null && Number.isFinite(parsedLat) + ? parsedLat + : undefined; + const lng = + parsedLng != null && Number.isFinite(parsedLng) + ? parsedLng + : undefined; - if (!title && !category && !location && !content) return undefined; + if ( + !title && + !category && + !location && + !content && + lat == null && + lng == null + ) { + return undefined; + } const prefill: PostBaseFormPrefill = {}; if (title) prefill.title = title; if (category) prefill.categories = [mapSpotCategoryToPost(category)]; if (location) prefill.location = location; - if (Number.isFinite(lat)) prefill.locationLat = lat; - if (Number.isFinite(lng)) prefill.locationLng = lng; + if (lat != null) prefill.locationLat = lat; + if (lng != null) prefill.locationLng = lng; if (content) prefill.content = content; return prefill; }, [searchParams]);