From 779513f6f82d93c844fe371d9a4abf14d697b684 Mon Sep 17 00:00:00 2001 From: seoJing Date: Sat, 6 Jun 2026 17:24:41 +0900 Subject: [PATCH 1/2] fix: address second pass QA issues --- src/app/(detail)/feed/[id]/page.tsx | 6 +- src/features/chat/api/chat-api.ts | 44 +++++++-- src/features/chat/model/mock.ts | 19 +++- src/features/chat/model/types.ts | 1 + src/features/chat/ui/ChatCreationPanel.tsx | 26 +++++- src/features/chat/ui/ChatDetail.tsx | 42 +++++++-- src/features/feed/model/use-feed.ts | 1 + src/features/feed/ui/FeedCard.tsx | 27 ++++-- src/features/feed/ui/MapFeedCardPager.tsx | 17 +++- .../detail/FeedParticipationActions.test.tsx | 2 +- .../ui/detail/FeedParticipationActions.tsx | 71 ++++++++++---- .../feed/ui/detail/FeedRecentViewRecorder.tsx | 26 ++++++ src/features/map/client/MapClient.tsx | 10 +- src/features/map/ui/MapFeedInfoCard.tsx | 16 +++- src/features/map/ui/MapFeedStackCard.tsx | 68 ++++++++------ .../my/model/recent-feed-views-store.ts | 93 +++++++++++++++++++ src/features/my/model/use-my.ts | 35 ++++++- src/features/my/ui/my-page/MyFormControls.tsx | 4 +- src/features/post/client/OfferFormClient.tsx | 34 ++++++- .../post/client/RequestFormClient.tsx | 36 ++++++- src/features/post/model/use-post-base-form.ts | 4 + .../post/model/use-post-form-prefill.ts | 72 ++++++++++++-- 22 files changed, 556 insertions(+), 98 deletions(-) create mode 100644 src/features/feed/ui/detail/FeedRecentViewRecorder.tsx create mode 100644 src/features/my/model/recent-feed-views-store.ts diff --git a/src/app/(detail)/feed/[id]/page.tsx b/src/app/(detail)/feed/[id]/page.tsx index 017c835..edbad4a 100644 --- a/src/app/(detail)/feed/[id]/page.tsx +++ b/src/app/(detail)/feed/[id]/page.tsx @@ -14,6 +14,7 @@ import { getServerFeedDetail, } from '@/features/feed/api/feed-server-api'; import { FeedParticipationActions } from '@/features/feed/ui/detail/FeedParticipationActions'; +import { FeedRecentViewRecorder } from '@/features/feed/ui/detail/FeedRecentViewRecorder'; import { EditPlanPreparationCard } from '@/features/feed/ui/detail/EditPlanPreparationCard'; import { PlanSection } from '@/features/feed/ui/detail/PlanSection'; import { PriceSection } from '@/features/feed/ui/detail/PriceSection'; @@ -229,7 +230,7 @@ function OfferDetailContent({ item }: { item: FeedItem }) { )} - + {!item.isAi && } {/* 본문 */} @@ -317,7 +318,7 @@ function RequestDetailContent({ 서포터와 매칭된 후 채팅을 통해 금액과 일정을 함께 조율합니다.

- + {!item.isAi && } {/* 서포터 지원 현황 */} @@ -523,6 +524,7 @@ export default async function FeedDetailPage({ params }: Props) { return ( <> + - 역제안은 참여 중인 서포터만 등록할 수 있어요. + 역제안은 피드 상태에서 참여 중인 서포터만 등록할 수 있어요. ); } diff --git a/src/features/chat/ui/ChatDetail.tsx b/src/features/chat/ui/ChatDetail.tsx index 2909122..03484bd 100644 --- a/src/features/chat/ui/ChatDetail.tsx +++ b/src/features/chat/ui/ChatDetail.tsx @@ -597,12 +597,40 @@ export function ChatDetail({ roomId }: ChatDetailProps) { const messageCount = messages.filter( (message) => message.kind !== 'system', ).length; + const roomLifecycleSource = + currentRoom.category === 'spot' + ? (currentRoom.sourceKind ?? + (currentRoom.sourceFeedId && currentRoom.spot.status === 'OPEN' + ? 'feed' + : 'spot')) + : null; + const userFeedRole = + currentRoom.category === 'spot' + ? (currentRoom.participationRole ?? + (currentRoom.spot.isOwner || isOwnedSpotRoom(currentRoom) + ? 'OWNER' + : isSupporterForSpot(currentRoom) + ? 'SUPPORTER' + : 'PARTNER')) + : null; const canManageOwnerActions = - currentRoom.category === 'spot' && isOwnedSpotRoom(currentRoom); + currentRoom.category === 'spot' && userFeedRole === 'OWNER'; const canCreateReverseOffer = currentRoom.category === 'spot' && - !canManageOwnerActions && - isSupporterForSpot(currentRoom); + roomLifecycleSource === 'feed' && + userFeedRole === 'SUPPORTER'; + const conversationTitle = + currentRoom.category === 'spot' && roomLifecycleSource === 'feed' + ? '피드 대화' + : currentRoom.category === 'spot' + ? '스팟 대화' + : '개인 대화'; + const roleInfoLabel = + userFeedRole === 'OWNER' + ? '오너 정보' + : userFeedRole === 'SUPPORTER' + ? '서포터 정보' + : '파트너 정보'; const creationItems = [ ...(canManageOwnerActions ? [ @@ -634,7 +662,7 @@ export function ChatDetail({ roomId }: ChatDetailProps) { { step: 'reverse-offer' as const, label: '역제안', - description: '파트너에게 역제안', + description: '오너에게 역제안', icon: , tone: 'bg-emerald-50 text-emerald-700', }, @@ -785,7 +813,7 @@ export function ChatDetail({ roomId }: ChatDetailProps) { variant="ghost" size="sm" onClick={() => openRoomInfo(currentRoom.id)} - label="참여자 정보" + label={roleInfoLabel} className="h-9 w-9 text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900" icon={} /> @@ -820,9 +848,7 @@ export function ChatDetail({ roomId }: ChatDetailProps) {

- {currentRoom.category === 'spot' - ? '스팟 대화' - : '개인 대화'} + {conversationTitle}

{messageCount}개 메시지 diff --git a/src/features/feed/model/use-feed.ts b/src/features/feed/model/use-feed.ts index 5701d28..bbee7eb 100644 --- a/src/features/feed/model/use-feed.ts +++ b/src/features/feed/model/use-feed.ts @@ -172,6 +172,7 @@ export function useToggleFeedBookmark() { queryClient.invalidateQueries({ queryKey: feedKeys.detail(feedId), }); + queryClient.invalidateQueries({ queryKey: ['my', 'favorites'] }); }, }); } diff --git a/src/features/feed/ui/FeedCard.tsx b/src/features/feed/ui/FeedCard.tsx index bbf1694..9e0883b 100644 --- a/src/features/feed/ui/FeedCard.tsx +++ b/src/features/feed/ui/FeedCard.tsx @@ -59,8 +59,9 @@ function FeedRow({ function createAuthorProfile(item: FeedItem): FeedParticipantProfile { return { - id: `author-${item.id}`, - nickname: item.authorNickname, + id: item.authorProfile?.id ?? `author-${item.id}`, + nickname: item.authorProfile?.nickname ?? item.authorNickname, + avatarUrl: item.authorProfile?.avatarUrl, }; } @@ -195,8 +196,8 @@ function ExploreSlotToken({ slot }: { slot: ExploreSlot }) { avatarUrl={slot.profile.avatarUrl} size="sm" /> - - {slot.label} + + {slot.profile.nickname}
); @@ -223,10 +224,13 @@ function AutoScrollList({ children }: { children: React.ReactNode }) { const rafRef = useRef(0); const pausedRef = useRef(false); const posRef = useRef(0); + const childArray = React.Children.toArray(children); + const shouldLoop = childArray.length > 3; useEffect(() => { const el = ref.current; if (!el) return; + if (!shouldLoop) return; const SPEED = 0.4; @@ -268,14 +272,19 @@ function AutoScrollList({ children }: { children: React.ReactNode }) { el.removeEventListener('touchstart', pause); el.removeEventListener('touchend', resume); }; - }, []); - - const childArray = React.Children.toArray(children); + }, [shouldLoop]); return ( -
- {childArray} +
{childArray} + {shouldLoop ? childArray : null}
); } diff --git a/src/features/feed/ui/MapFeedCardPager.tsx b/src/features/feed/ui/MapFeedCardPager.tsx index 088d64d..5eda348 100644 --- a/src/features/feed/ui/MapFeedCardPager.tsx +++ b/src/features/feed/ui/MapFeedCardPager.tsx @@ -455,12 +455,25 @@ export function MapFeedCardPager({ onPointerDown={(e) => e.stopPropagation() } - aria-label="찜에 추가" - className="absolute right-3 top-3 z-10 flex h-9 w-9 items-center justify-center rounded-full border border-border-soft/70 bg-card/90 text-foreground shadow-sm backdrop-blur-md transition-colors hover:bg-destructive hover:text-destructive-foreground" + aria-label={ + item.isBookmarked + ? '찜 해제' + : '찜에 추가' + } + className={ + item.isBookmarked + ? 'absolute right-3 top-3 z-10 flex h-9 w-9 items-center justify-center rounded-full border border-red-200 bg-red-50 text-red-500 shadow-sm backdrop-blur-md transition-colors hover:bg-red-100' + : 'absolute right-3 top-3 z-10 flex h-9 w-9 items-center justify-center rounded-full border border-border-soft/70 bg-card/90 text-foreground shadow-sm backdrop-blur-md transition-colors hover:bg-destructive hover:text-destructive-foreground' + } > diff --git a/src/features/feed/ui/detail/FeedParticipationActions.test.tsx b/src/features/feed/ui/detail/FeedParticipationActions.test.tsx index ccbe729..224994f 100644 --- a/src/features/feed/ui/detail/FeedParticipationActions.test.tsx +++ b/src/features/feed/ui/detail/FeedParticipationActions.test.tsx @@ -362,7 +362,7 @@ describe('FeedParticipationActions', () => { ); }); expect(mockShowMessage).toHaveBeenCalledWith( - '신청자를 수락했어요.', + '파트너 신청을 수락했어요.', '', ); expect(mockRefresh).toHaveBeenCalled(); diff --git a/src/features/feed/ui/detail/FeedParticipationActions.tsx b/src/features/feed/ui/detail/FeedParticipationActions.tsx index 77a27da..898ee1b 100644 --- a/src/features/feed/ui/detail/FeedParticipationActions.tsx +++ b/src/features/feed/ui/detail/FeedParticipationActions.tsx @@ -35,6 +35,8 @@ import { useRejectFeedApplication, } from '../../model/use-feed'; import { useAuthStore } from '@/shared/model/auth-store'; +import { getErrorMessage } from '@/features/my/ui/my-page/my-page-client-utils'; +import { savePostFormPrefillContext } from '@/features/post/model/use-post-form-prefill'; function formatCurrency(value: number, maximumFractionDigits = 0) { return `${value.toLocaleString('ko-KR', { @@ -366,16 +368,17 @@ export function FeedParticipationActions({ ); const handleApplicationDecision = async ( - applicationId: string, + application: FeedApplication, decision: 'accept' | 'reject', ) => { - setProcessingApplicationId(applicationId); + setProcessingApplicationId(application.id); + const roleLabel = getRoleLabel(application.appliedRole); try { if (decision === 'accept') { const response = await acceptFeedApplication.mutateAsync({ feedId: item.id, - applicationId, + applicationId: application.id, }); const convertedSpotId = response.data.spotId ?? item.spotId; @@ -398,16 +401,27 @@ export function FeedParticipationActions({ item.remainingAmount > 0 ? ` 앞으로 ${item.remainingAmount.toLocaleString('ko-KR')}P 더 필요해요.` : ''; - showBottomNavMessage(`신청자를 수락했어요.${remainCopy}`, ''); + showBottomNavMessage( + `${roleLabel} 신청을 수락했어요.${remainCopy}`, + '', + ); } else { await rejectFeedApplication.mutateAsync({ feedId: item.id, - applicationId, + applicationId: application.id, }); - showBottomNavMessage('신청자를 거절했어요.', ''); + showBottomNavMessage(`${roleLabel} 신청을 거절했어요.`, ''); + toast.success(`${roleLabel} 신청을 거절했어요.`); } router.refresh(); + } catch (error) { + toast.error( + getErrorMessage( + error, + `${roleLabel} 신청을 ${decision === 'accept' ? '수락' : '거절'}하지 못했어요.`, + ), + ); } finally { setProcessingApplicationId(null); } @@ -422,10 +436,6 @@ export function FeedParticipationActions({

내 피드 신청 관리

-

- authorProfile.id 기준으로 내 피드라서 참여 버튼 - 대신 승인 플로우를 보여줘요. -

{pendingApplications.length}명 대기 @@ -480,7 +490,7 @@ export function FeedParticipationActions({ className="rounded-full px-3" onClick={() => void handleApplicationDecision( - application.id, + application, 'reject', ) } @@ -494,7 +504,7 @@ export function FeedParticipationActions({ className="rounded-full bg-accent px-3" onClick={() => void handleApplicationDecision( - application.id, + application, 'accept', ) } @@ -520,9 +530,34 @@ export function FeedParticipationActions({ // AI 피드(시뮬레이터가 합성한 데이터) 는 실제 호스트가 없으므로 참여가 의미 없음. // 같은 자리에 "이런 알려줘 직접 열기" 액션만 보여주고 /post/request 로 prefill 라우팅. if (item.isAi) { - const openRequestFromAiFeed = () => { + const isAiOffer = item.type === 'OFFER' || item.type === 'RENT'; + const targetPostType = isAiOffer ? 'offer' : 'request'; + const actionLabel = isAiOffer ? '해볼래' : '알려줘'; + const openPostFromAiFeed = () => { + const prefillKey = `ai-feed-${item.id}`; + savePostFormPrefillContext(prefillKey, { + spotName: item.title, + title: item.title, + location: item.location, + content: item.description ?? item.title, + detailDescription: item.description ?? item.title, + deadline: item.deadline, + maxPartnerCount: item.maxParticipants, + desiredPrice: isAiOffer + ? (item.fundingGoal ?? + item.priceBreakdown?.base_fee ?? + item.price) + : undefined, + priceCapPerPerson: !isAiOffer + ? (item.priceBreakdown?.base_fee ?? item.price) + : undefined, + plan: item.plan, + preparation: item.preparation, + priceBreakdown: item.priceBreakdown, + }); const params = new URLSearchParams({ fromSpot: item.id, + prefillKey, title: item.title, category: item.category ?? '', location: item.location, @@ -532,22 +567,22 @@ export function FeedParticipationActions({ 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()}`); + router.push(`/post/${targetPostType}?${params.toString()}`); }; return (

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

); diff --git a/src/features/feed/ui/detail/FeedRecentViewRecorder.tsx b/src/features/feed/ui/detail/FeedRecentViewRecorder.tsx new file mode 100644 index 0000000..99170ee --- /dev/null +++ b/src/features/feed/ui/detail/FeedRecentViewRecorder.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { useEffect } from 'react'; +import type { FeedItem } from '@/features/feed/model/types'; +import { recordRecentFeedView } from '@/features/my/model/recent-feed-views-store'; + +type FeedRecentViewRecorderProps = { + item: Pick< + FeedItem, + | 'id' + | 'title' + | 'description' + | 'type' + | 'price' + | 'authorNickname' + | 'status' + >; +}; + +export function FeedRecentViewRecorder({ item }: FeedRecentViewRecorderProps) { + useEffect(() => { + recordRecentFeedView(item); + }, [item]); + + return null; +} diff --git a/src/features/map/client/MapClient.tsx b/src/features/map/client/MapClient.tsx index 379aeff..191d983 100644 --- a/src/features/map/client/MapClient.tsx +++ b/src/features/map/client/MapClient.tsx @@ -565,6 +565,14 @@ export function MapClient() { return visibleClusterCount + visibleFeedMarkerCount; }, [clusters, feedMarkerGroups, inViewport]); + const viewportFeedItems = useMemo( + () => + feedMarkerGroups + .filter((group) => inViewport(group.coord)) + .flatMap((group) => group.items), + [feedMarkerGroups, inViewport], + ); + const getMarkerVisualMode = useCallback( (selected: boolean) => decideMarkerVisualMode({ @@ -889,7 +897,7 @@ export function MapClient() { onSnapChange={setPagerSnap} promotedCount={pagerPromotedCount} onPromotedCountChange={setPagerPromotedCount} - items={visibleFeedItems} + items={viewportFeedItems} isInitialLoading={isInitialFeedLoading} onTutorialCardPromote={showCardTutorialStep} onTutorialCardDismiss={completeDeckTutorial} diff --git a/src/features/map/ui/MapFeedInfoCard.tsx b/src/features/map/ui/MapFeedInfoCard.tsx index 6e59760..445102d 100644 --- a/src/features/map/ui/MapFeedInfoCard.tsx +++ b/src/features/map/ui/MapFeedInfoCard.tsx @@ -18,6 +18,8 @@ export function MapFeedInfoCard({ onDetailAction, onBookmarkAction, }: MapFeedInfoCardProps) { + const bookmarked = Boolean(item.isBookmarked); + return ( { event.stopPropagation(); onBookmarkAction(item); }} onPointerDown={(event) => event.stopPropagation()} - className="absolute right-3 top-3 z-10 flex h-9 w-9 items-center justify-center rounded-full border border-border-soft/70 bg-card/90 text-foreground shadow-sm backdrop-blur-md transition-colors hover:bg-destructive hover:text-destructive-foreground" + className={ + bookmarked + ? 'absolute right-3 top-3 z-10 flex h-9 w-9 items-center justify-center rounded-full border border-red-200 bg-red-50 text-red-500 shadow-sm backdrop-blur-md transition-colors hover:bg-red-100' + : 'absolute right-3 top-3 z-10 flex h-9 w-9 items-center justify-center rounded-full border border-border-soft/70 bg-card/90 text-foreground shadow-sm backdrop-blur-md transition-colors hover:bg-destructive hover:text-destructive-foreground' + } > - + ) : undefined } diff --git a/src/features/map/ui/MapFeedStackCard.tsx b/src/features/map/ui/MapFeedStackCard.tsx index 15e6b68..9581b67 100644 --- a/src/features/map/ui/MapFeedStackCard.tsx +++ b/src/features/map/ui/MapFeedStackCard.tsx @@ -27,32 +27,48 @@ export function MapFeedStackCard({ ({ - id: item.id, - content: ( - <> - - {onBookmarkAction ? ( - - ) : null} - - ), - onDetailAction: () => router.push(`/feed/${item.id}`), - }))} + items={items.map((item) => { + const bookmarked = Boolean(item.isBookmarked); + + return { + id: item.id, + content: ( + <> + + {onBookmarkAction ? ( + + ) : null} + + ), + onDetailAction: () => router.push(`/feed/${item.id}`), + }; + })} onCloseAction={onCloseAction} dismissBehavior="next" disableContentClick diff --git a/src/features/my/model/recent-feed-views-store.ts b/src/features/my/model/recent-feed-views-store.ts new file mode 100644 index 0000000..0b73442 --- /dev/null +++ b/src/features/my/model/recent-feed-views-store.ts @@ -0,0 +1,93 @@ +import type { MyRecentViewItem } from '@/entities/user/types'; +import type { FeedItem } from '@/features/feed/model/types'; + +const RECENT_FEED_VIEWS_KEY = 'spot.recentFeedViews.v1'; +const MAX_RECENT_FEED_VIEWS = 20; + +type RecordableFeed = Pick< + FeedItem, + | 'id' + | 'title' + | 'description' + | 'type' + | 'price' + | 'authorNickname' + | 'status' +>; + +function readRecentFeedViews(): MyRecentViewItem[] { + if (typeof window === 'undefined') return []; + + try { + const raw = window.localStorage.getItem(RECENT_FEED_VIEWS_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw) as MyRecentViewItem[]; + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function writeRecentFeedViews(items: MyRecentViewItem[]) { + if (typeof window === 'undefined') return; + + try { + window.localStorage.setItem( + RECENT_FEED_VIEWS_KEY, + JSON.stringify(items.slice(0, MAX_RECENT_FEED_VIEWS)), + ); + } catch { + // localStorage 실패는 최근 본 캐시만 포기한다. + } +} + +export function getRecentFeedViews(params?: { page?: number; size?: number }): { + data: MyRecentViewItem[]; + meta: { page: number; size: number; total: number; hasNext: boolean }; +} { + const items = readRecentFeedViews(); + const page = params?.page ?? 0; + const size = params?.size ?? items.length; + const zeroBasedPage = page > 0 ? page - 1 : page; + const start = zeroBasedPage * size; + const data = items.slice(start, start + size); + + return { + data, + meta: { + page, + size, + total: items.length, + hasNext: start + size < items.length, + }, + }; +} + +export function recordRecentFeedView(feed: RecordableFeed): void { + const viewedAt = new Date().toISOString(); + const item: MyRecentViewItem = { + id: `feed-${feed.id}`, + targetId: feed.id, + title: feed.title, + description: feed.description, + type: feed.type === 'RENT' ? 'OFFER' : feed.type, + viewedAt, + pointCost: feed.price, + authorNickname: feed.authorNickname, + status: feed.status, + }; + const rest = readRecentFeedViews().filter( + (view) => view.targetId !== feed.id, + ); + writeRecentFeedViews([item, ...rest]); +} + +export function removeRecentFeedView(recentViewId: string): void { + writeRecentFeedViews( + readRecentFeedViews().filter((view) => view.id !== recentViewId), + ); +} + +export function clearRecentFeedViews(): void { + writeRecentFeedViews([]); +} diff --git a/src/features/my/model/use-my.ts b/src/features/my/model/use-my.ts index f56ae4f..bb8de21 100644 --- a/src/features/my/model/use-my.ts +++ b/src/features/my/model/use-my.ts @@ -5,6 +5,11 @@ import type { SupporterRegistration, } from '@/entities/user/types'; import { myApi } from '../api/my-api'; +import { + clearRecentFeedViews, + getRecentFeedViews, + removeRecentFeedView, +} from './recent-feed-views-store'; export const myKeys = { profile: ['my', 'profile'] as const, @@ -86,7 +91,16 @@ export function useMyFavorites(params?: { page?: number; size?: number }) { export function useMyRecentViews(params?: { page?: number; size?: number }) { return useQuery({ queryKey: myKeys.recentViews(params), - queryFn: () => myApi.recentViews(params), + queryFn: async () => { + try { + const remote = await myApi.recentViews(params); + return remote.data.length > 0 + ? remote + : getRecentFeedViews(params); + } catch { + return getRecentFeedViews(params); + } + }, }); } @@ -178,8 +192,14 @@ export function useRemoveFavorite() { export function useRemoveRecentView() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (recentViewId: string) => - myApi.removeRecentView(recentViewId), + mutationFn: async (recentViewId: string) => { + removeRecentFeedView(recentViewId); + try { + await myApi.removeRecentView(recentViewId); + } catch { + // 로컬 캐시 항목은 백엔드에 없을 수 있다. + } + }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['my', 'recent-views'] }); }, @@ -189,7 +209,14 @@ export function useRemoveRecentView() { export function useClearRecentViews() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: () => myApi.clearRecentViews(), + mutationFn: async () => { + clearRecentFeedViews(); + try { + await myApi.clearRecentViews(); + } catch { + // 로컬 캐시만 있는 환경에서도 전체 삭제 UX는 유지한다. + } + }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['my', 'recent-views'] }); }, diff --git a/src/features/my/ui/my-page/MyFormControls.tsx b/src/features/my/ui/my-page/MyFormControls.tsx index 6082f0d..3a860a2 100644 --- a/src/features/my/ui/my-page/MyFormControls.tsx +++ b/src/features/my/ui/my-page/MyFormControls.tsx @@ -173,13 +173,13 @@ export function MyToggleRow({ className={cn( 'relative h-7 w-12 rounded-full border transition-colors disabled:cursor-not-allowed disabled:opacity-60', checked - ? 'border-foreground bg-foreground' + ? 'border-accent bg-accent' : 'border-border-strong bg-muted', )} > diff --git a/src/features/post/client/OfferFormClient.tsx b/src/features/post/client/OfferFormClient.tsx index 8981323..9f2f68f 100644 --- a/src/features/post/client/OfferFormClient.tsx +++ b/src/features/post/client/OfferFormClient.tsx @@ -51,6 +51,7 @@ export function OfferFormClient() { ); }, [searchParams]); + const postPrefill = usePostFormPrefill(); const { spotName, title, @@ -69,7 +70,7 @@ export function OfferFormClient() { handleAddPhoto, handleRemovePhoto, clearDraft, - } = usePostBaseForm(usePostFormPrefill()); + } = usePostBaseForm(postPrefill); const [detailDescription, setDetailDescription] = useState(''); const [supporterPhotoPreview, setSupporterPhotoPreview] = useState< @@ -87,6 +88,37 @@ export function OfferFormClient() { PriceBreakdown | undefined >(undefined); + const postPrefillKey = JSON.stringify(postPrefill ?? null); + useEffect(() => { + if (!postPrefill) return; + setDetailDescription( + (value) => + value || + postPrefill.detailDescription || + postPrefill.content || + '', + ); + setQualifications((value) => value || postPrefill.qualifications || ''); + setDesiredPrice( + (value) => + value || + (postPrefill.desiredPrice != null + ? String(postPrefill.desiredPrice) + : ''), + ); + setMaxPartnerCount( + (value) => + value || + (postPrefill.maxPartnerCount != null + ? String(postPrefill.maxPartnerCount) + : ''), + ); + setPlan((value) => value ?? postPrefill.plan); + setPreparation((value) => value ?? postPrefill.preparation); + setPriceBreakdown((value) => value ?? postPrefill.priceBreakdown); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [postPrefillKey]); + const isStep0Valid = spotName.trim() !== '' && title.trim() !== '' && diff --git a/src/features/post/client/RequestFormClient.tsx b/src/features/post/client/RequestFormClient.tsx index 9c85b09..633b80a 100644 --- a/src/features/post/client/RequestFormClient.tsx +++ b/src/features/post/client/RequestFormClient.tsx @@ -51,6 +51,7 @@ export function RequestFormClient() { ); }, [searchParams]); + const postPrefill = usePostFormPrefill(); const { spotName, title, @@ -69,7 +70,7 @@ export function RequestFormClient() { handleAddPhoto, handleRemovePhoto, clearDraft, - } = usePostBaseForm(usePostFormPrefill()); + } = usePostBaseForm(postPrefill); const [detailDescription, setDetailDescription] = useState(''); const [stylePhotoPreview, setStylePhotoPreview] = useState( @@ -87,6 +88,39 @@ export function RequestFormClient() { PriceBreakdown | undefined >(undefined); + const postPrefillKey = JSON.stringify(postPrefill ?? null); + useEffect(() => { + if (!postPrefill) return; + setDetailDescription( + (value) => + value || + postPrefill.detailDescription || + postPrefill.content || + '', + ); + setPreferredSchedule( + (value) => value || postPrefill.preferredSchedule || '', + ); + setMaxPartnerCount( + (value) => + value || + (postPrefill.maxPartnerCount != null + ? String(postPrefill.maxPartnerCount) + : ''), + ); + setPriceCapPerPerson( + (value) => + value || + (postPrefill.priceCapPerPerson != null + ? String(postPrefill.priceCapPerPerson) + : ''), + ); + setPlan((value) => value ?? postPrefill.plan); + setPreparation((value) => value ?? postPrefill.preparation); + setPriceBreakdown((value) => value ?? postPrefill.priceBreakdown); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [postPrefillKey]); + const isStep0Valid = spotName.trim() !== '' && title.trim() !== '' && diff --git a/src/features/post/model/use-post-base-form.ts b/src/features/post/model/use-post-base-form.ts index 5093e38..698338c 100644 --- a/src/features/post/model/use-post-base-form.ts +++ b/src/features/post/model/use-post-base-form.ts @@ -35,6 +35,8 @@ export type PostBaseFormPrefill = Partial< | 'locationLat' | 'locationLng' | 'content' + | 'spotName' + | 'deadline' > >; @@ -46,11 +48,13 @@ function applyPrefill( return { ...base, title: prefill.title ?? base.title, + spotName: prefill.spotName ?? base.spotName, 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, + deadline: prefill.deadline ?? base.deadline, }; } diff --git a/src/features/post/model/use-post-form-prefill.ts b/src/features/post/model/use-post-form-prefill.ts index 709f729..eb43dcb 100644 --- a/src/features/post/model/use-post-form-prefill.ts +++ b/src/features/post/model/use-post-form-prefill.ts @@ -1,5 +1,5 @@ -// 시뮬레이션 Spot 카드에서 "이런 모임 열기" CTA 로 넘어올 때 -// URL query → usePostBaseForm 의 prefill 형태로 변환하는 훅. +// 시뮬레이션/AI 피드 카드에서 "이런 모임 열기" CTA 로 넘어올 때 +// URL query + sessionStorage → post form prefill 형태로 변환하는 훅. 'use client'; @@ -8,6 +8,25 @@ import { useMemo } from 'react'; import type { SpotCategory } from '@/entities/spot/categories'; import type { PostBaseFormPrefill } from './use-post-base-form'; import type { PostSpotCategory } from './types'; +import type { + PlanV3, + Preparation, + PriceBreakdown, +} from '@/entities/spot/simulation-types'; + +const POST_FORM_PREFILL_STORAGE_KEY = 'spot.postFormPrefill.v1'; + +export type PostFormPrefill = PostBaseFormPrefill & { + detailDescription?: string; + preferredSchedule?: string; + qualifications?: string; + maxPartnerCount?: number; + desiredPrice?: number; + priceCapPerPerson?: number; + plan?: PlanV3; + preparation?: Preparation; + priceBreakdown?: PriceBreakdown; +}; /** 시뮬 SpotCategory → Post 폼의 PostSpotCategory 매핑. */ function mapSpotCategoryToPost( @@ -33,14 +52,46 @@ function mapSpotCategoryToPost( } } -export function usePostFormPrefill(): PostBaseFormPrefill | undefined { +export function savePostFormPrefillContext( + key: string, + prefill: PostFormPrefill, +): void { + try { + sessionStorage.setItem( + `${POST_FORM_PREFILL_STORAGE_KEY}:${key}`, + JSON.stringify(prefill), + ); + } catch { + // sessionStorage 접근 실패는 URL 기반 기본 prefill 로만 진행한다. + } +} + +function readPostFormPrefillContext( + key: string | null, +): PostFormPrefill | null { + if (!key) return null; + + try { + const raw = sessionStorage.getItem( + `${POST_FORM_PREFILL_STORAGE_KEY}:${key}`, + ); + if (!raw) return null; + return JSON.parse(raw) as PostFormPrefill; + } catch { + return null; + } +} + +export function usePostFormPrefill(): PostFormPrefill | undefined { const searchParams = useSearchParams(); - return useMemo(() => { + return useMemo(() => { const title = searchParams.get('title'); const category = searchParams.get('category'); const location = searchParams.get('location'); const content = searchParams.get('content'); + const prefillKey = searchParams.get('prefillKey'); + const storedPrefill = readPostFormPrefillContext(prefillKey); const latRaw = searchParams.get('lat'); const lngRaw = searchParams.get('lng'); const parsedLat = latRaw == null ? undefined : Number(latRaw); @@ -55,6 +106,7 @@ export function usePostFormPrefill(): PostBaseFormPrefill | undefined { : undefined; if ( + !storedPrefill && !title && !category && !location && @@ -65,13 +117,19 @@ export function usePostFormPrefill(): PostBaseFormPrefill | undefined { return undefined; } - const prefill: PostBaseFormPrefill = {}; - if (title) prefill.title = title; + const prefill: PostFormPrefill = { ...(storedPrefill ?? {}) }; + if (title) { + prefill.title = title; + prefill.spotName = prefill.spotName ?? title; + } if (category) prefill.categories = [mapSpotCategoryToPost(category)]; if (location) prefill.location = location; if (lat != null) prefill.locationLat = lat; if (lng != null) prefill.locationLng = lng; - if (content) prefill.content = content; + if (content) { + prefill.content = content; + prefill.detailDescription = prefill.detailDescription ?? content; + } return prefill; }, [searchParams]); } From c9890968f1f16582024a4f7c1834341af2ec018e Mon Sep 17 00:00:00 2001 From: seoJing Date: Sat, 6 Jun 2026 18:04:04 +0900 Subject: [PATCH 2/2] fix: address CodeRabbit review comments --- src/features/chat/model/mock.ts | 21 ++------ src/features/chat/model/room-role.ts | 51 +++++++++++++++++++ src/features/chat/ui/ChatCreationPanel.tsx | 23 ++------- src/features/chat/ui/ChatDetail.tsx | 28 +++------- src/features/feed/ui/FeedCard.tsx | 11 +++- .../feed/ui/detail/FeedRecentViewRecorder.tsx | 4 +- .../my/model/recent-feed-views-store.ts | 4 +- 7 files changed, 83 insertions(+), 59 deletions(-) create mode 100644 src/features/chat/model/room-role.ts diff --git a/src/features/chat/model/mock.ts b/src/features/chat/model/mock.ts index 4626bb2..66d9a82 100644 --- a/src/features/chat/model/mock.ts +++ b/src/features/chat/model/mock.ts @@ -504,6 +504,10 @@ function buildSpotRoom(spotId: string): SpotChatRoom | null { new Date(right.createdAt).getTime(), ) : baseMessages; + const isCurrentUserSupporter = + spot.type === 'OFFER' + ? spot.authorId === CHAT_CURRENT_USER_ID + : spot.authorId !== CHAT_CURRENT_USER_ID; return { id: `spot-room-${spot.id}`, @@ -519,22 +523,7 @@ function buildSpotRoom(spotId: string): SpotChatRoom | null { reverseOffer: reverseOfferScenario?.reverseOffer, sourceFeedId: spot.status === 'OPEN' ? `feed-${spot.id}` : undefined, sourceKind: spot.status === 'OPEN' ? 'feed' : 'spot', - participationRole: isSupporterForSpot({ - id: `spot-room-${spot.id}`, - category: 'spot', - currentUserId: CHAT_CURRENT_USER_ID, - currentUserName: CHAT_CURRENT_USER_NAME, - title: spot.title, - subtitle: '', - description: spot.description, - metaLabel: '', - updatedAt: spot.updatedAt, - unreadCount: 0, - spot, - messages: [], - }) - ? 'SUPPORTER' - : 'PARTNER', + participationRole: isCurrentUserSupporter ? 'SUPPORTER' : 'PARTNER', messages, }; } diff --git a/src/features/chat/model/room-role.ts b/src/features/chat/model/room-role.ts new file mode 100644 index 0000000..acd4c77 --- /dev/null +++ b/src/features/chat/model/room-role.ts @@ -0,0 +1,51 @@ +import type { SpotChatRoom } from './types'; + +export type ChatRoomLifecycleSource = 'feed' | 'spot'; +export type ChatRoomUserRole = 'OWNER' | 'SUPPORTER' | 'PARTNER'; + +export type ChatRoomRoleContext = { + roomLifecycleSource: ChatRoomLifecycleSource; + userFeedRole: ChatRoomUserRole; + canManageOwnerActions: boolean; + canCreateReverseOffer: boolean; +}; + +export function computeRoomLifecycleSource( + room: SpotChatRoom, +): ChatRoomLifecycleSource { + return ( + room.sourceKind ?? + (room.sourceFeedId && room.spot.status === 'OPEN' ? 'feed' : 'spot') + ); +} + +export function computeUserFeedRole(room: SpotChatRoom): ChatRoomUserRole { + if (room.participationRole) return room.participationRole; + if (room.spot.isOwner || room.spot.authorId === room.currentUserId) { + return 'OWNER'; + } + + const isSupporter = + room.spot.type === 'OFFER' + ? room.spot.authorId === room.currentUserId + : room.spot.authorId !== room.currentUserId; + + return isSupporter ? 'SUPPORTER' : 'PARTNER'; +} + +export function computeChatRoomRoleContext( + room: SpotChatRoom, +): ChatRoomRoleContext { + const roomLifecycleSource = computeRoomLifecycleSource(room); + const userFeedRole = computeUserFeedRole(room); + const canManageOwnerActions = userFeedRole === 'OWNER'; + const canCreateReverseOffer = + roomLifecycleSource === 'feed' && userFeedRole === 'SUPPORTER'; + + return { + roomLifecycleSource, + userFeedRole, + canManageOwnerActions, + canCreateReverseOffer, + }; +} diff --git a/src/features/chat/ui/ChatCreationPanel.tsx b/src/features/chat/ui/ChatCreationPanel.tsx index aab8420..59a8ebc 100644 --- a/src/features/chat/ui/ChatCreationPanel.tsx +++ b/src/features/chat/ui/ChatCreationPanel.tsx @@ -30,8 +30,8 @@ import { formatReverseOfferApprovalProgress } from '../model/types'; import { CHAT_CURRENT_USER_ID, getChatDirectoryCandidates, - isSupporterForSpot, } from '../model/mock'; +import { computeChatRoomRoleContext } from '../model/room-role'; import type { ChatReverseOfferFinancialSnapshot, ChatReverseOfferStatus, @@ -875,22 +875,9 @@ function TeamCreationPanel({ ); } - const userFeedRole = - selectedSpotRoom.participationRole ?? - (selectedSpotRoom.spot.isOwner || - selectedSpotRoom.spot.authorId === selectedSpotRoom.currentUserId - ? 'OWNER' - : isSupporterForSpot(selectedSpotRoom) - ? 'SUPPORTER' - : 'PARTNER'); - const roomLifecycleSource = - selectedSpotRoom.sourceKind ?? - (selectedSpotRoom.sourceFeedId && - selectedSpotRoom.spot.status === 'OPEN' - ? 'feed' - : 'spot'); - const isOwner = userFeedRole === 'OWNER'; - const isSupporter = userFeedRole === 'SUPPORTER'; + const roleContext = computeChatRoomRoleContext(selectedSpotRoom); + const isOwner = roleContext.userFeedRole === 'OWNER'; + const isSupporter = roleContext.userFeedRole === 'SUPPORTER'; if (['vote', 'schedule', 'file'].includes(step) && !isOwner) { return ( @@ -902,7 +889,7 @@ function TeamCreationPanel({ if ( step === 'reverse-offer' && - (roomLifecycleSource !== 'feed' || isOwner || !isSupporter) + (!roleContext.canCreateReverseOffer || isOwner || !isSupporter) ) { return (
diff --git a/src/features/chat/ui/ChatDetail.tsx b/src/features/chat/ui/ChatDetail.tsx index 03484bd..d0ca0af 100644 --- a/src/features/chat/ui/ChatDetail.tsx +++ b/src/features/chat/ui/ChatDetail.tsx @@ -30,7 +30,7 @@ import { useMainChatStore, } from '../model/use-main-chat-store'; import { chatApi } from '../api/chat-api'; -import { isOwnedSpotRoom, isSupporterForSpot } from '../model/mock'; +import { computeChatRoomRoleContext } from '../model/room-role'; import { getShareableSpotActionItems, getSpotScheduleActionId, @@ -597,28 +597,14 @@ export function ChatDetail({ roomId }: ChatDetailProps) { const messageCount = messages.filter( (message) => message.kind !== 'system', ).length; - const roomLifecycleSource = + const roleContext = currentRoom.category === 'spot' - ? (currentRoom.sourceKind ?? - (currentRoom.sourceFeedId && currentRoom.spot.status === 'OPEN' - ? 'feed' - : 'spot')) + ? computeChatRoomRoleContext(currentRoom) : null; - const userFeedRole = - currentRoom.category === 'spot' - ? (currentRoom.participationRole ?? - (currentRoom.spot.isOwner || isOwnedSpotRoom(currentRoom) - ? 'OWNER' - : isSupporterForSpot(currentRoom) - ? 'SUPPORTER' - : 'PARTNER')) - : null; - const canManageOwnerActions = - currentRoom.category === 'spot' && userFeedRole === 'OWNER'; - const canCreateReverseOffer = - currentRoom.category === 'spot' && - roomLifecycleSource === 'feed' && - userFeedRole === 'SUPPORTER'; + const roomLifecycleSource = roleContext?.roomLifecycleSource ?? null; + const userFeedRole = roleContext?.userFeedRole ?? null; + const canManageOwnerActions = Boolean(roleContext?.canManageOwnerActions); + const canCreateReverseOffer = Boolean(roleContext?.canCreateReverseOffer); const conversationTitle = currentRoom.category === 'spot' && roomLifecycleSource === 'feed' ? '피드 대화' diff --git a/src/features/feed/ui/FeedCard.tsx b/src/features/feed/ui/FeedCard.tsx index 9e0883b..d0345f1 100644 --- a/src/features/feed/ui/FeedCard.tsx +++ b/src/features/feed/ui/FeedCard.tsx @@ -226,6 +226,15 @@ function AutoScrollList({ children }: { children: React.ReactNode }) { const posRef = useRef(0); const childArray = React.Children.toArray(children); const shouldLoop = childArray.length > 3; + const loopChildren = shouldLoop + ? childArray.map((child, index) => + React.isValidElement(child) + ? React.cloneElement(child, { + key: `loop-${child.key ?? index}`, + }) + : child, + ) + : null; useEffect(() => { const el = ref.current; @@ -284,7 +293,7 @@ function AutoScrollList({ children }: { children: React.ReactNode }) { } > {childArray} - {shouldLoop ? childArray : null} + {loopChildren}
); } diff --git a/src/features/feed/ui/detail/FeedRecentViewRecorder.tsx b/src/features/feed/ui/detail/FeedRecentViewRecorder.tsx index 99170ee..a200349 100644 --- a/src/features/feed/ui/detail/FeedRecentViewRecorder.tsx +++ b/src/features/feed/ui/detail/FeedRecentViewRecorder.tsx @@ -20,7 +20,9 @@ type FeedRecentViewRecorderProps = { export function FeedRecentViewRecorder({ item }: FeedRecentViewRecorderProps) { useEffect(() => { recordRecentFeedView(item); - }, [item]); + // CodeRabbit QA: record only once per feed navigation, not on parent object re-renders. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [item.id]); return null; } diff --git a/src/features/my/model/recent-feed-views-store.ts b/src/features/my/model/recent-feed-views-store.ts index 0b73442..37874f2 100644 --- a/src/features/my/model/recent-feed-views-store.ts +++ b/src/features/my/model/recent-feed-views-store.ts @@ -46,9 +46,9 @@ export function getRecentFeedViews(params?: { page?: number; size?: number }): { meta: { page: number; size: number; total: number; hasNext: boolean }; } { const items = readRecentFeedViews(); - const page = params?.page ?? 0; + const page = params?.page ?? 1; const size = params?.size ?? items.length; - const zeroBasedPage = page > 0 ? page - 1 : page; + const zeroBasedPage = Math.max(0, page - 1); const start = zeroBasedPage * size; const data = items.slice(start, start + size);