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..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,12 +597,26 @@ export function ChatDetail({ roomId }: ChatDetailProps) { const messageCount = messages.filter( (message) => message.kind !== 'system', ).length; - const canManageOwnerActions = - currentRoom.category === 'spot' && isOwnedSpotRoom(currentRoom); - const canCreateReverseOffer = - currentRoom.category === 'spot' && - !canManageOwnerActions && - isSupporterForSpot(currentRoom); + const roleContext = + currentRoom.category === 'spot' + ? computeChatRoomRoleContext(currentRoom) + : null; + 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' + ? '피드 대화' + : currentRoom.category === 'spot' + ? '스팟 대화' + : '개인 대화'; + const roleInfoLabel = + userFeedRole === 'OWNER' + ? '오너 정보' + : userFeedRole === 'SUPPORTER' + ? '서포터 정보' + : '파트너 정보'; const creationItems = [ ...(canManageOwnerActions ? [ @@ -634,7 +648,7 @@ export function ChatDetail({ roomId }: ChatDetailProps) { { step: 'reverse-offer' as const, label: '역제안', - description: '파트너에게 역제안', + description: '오너에게 역제안', icon: , tone: 'bg-emerald-50 text-emerald-700', }, @@ -785,7 +799,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 +834,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..d0345f1 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,22 @@ 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; + 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; if (!el) return; + if (!shouldLoop) return; const SPEED = 0.4; @@ -268,14 +281,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} + {loopChildren}
); } 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..a200349 --- /dev/null +++ b/src/features/feed/ui/detail/FeedRecentViewRecorder.tsx @@ -0,0 +1,28 @@ +'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); + // 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/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..37874f2 --- /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 ?? 1; + const size = params?.size ?? items.length; + const zeroBasedPage = Math.max(0, page - 1); + 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]); }