From 495fe54d5953a7440a8a47eb32aea482894d3be0 Mon Sep 17 00:00:00 2001 From: seojing Date: Mon, 25 May 2026 02:28:22 +0900 Subject: [PATCH 1/7] fix: persist onboarding persona locally --- src/shared/model/auth-store.test.ts | 62 +++++++++++++++++++++++++ src/shared/model/auth-store.ts | 72 +++++++++++++++++++++++++++-- 2 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 src/shared/model/auth-store.test.ts diff --git a/src/shared/model/auth-store.test.ts b/src/shared/model/auth-store.test.ts new file mode 100644 index 0000000..fb099d7 --- /dev/null +++ b/src/shared/model/auth-store.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import type { UserPersona } from '@/entities/persona/types'; +import { useAuthStore } from './auth-store'; + +const ONBOARDING_PERSONA_STORAGE_KEY = 'spot-onboarding-personas'; + +const persona: UserPersona = { + userId: 'user-1', + role: 'PARTNER', + archetype: 'explorer', + interests: ['운동'], + createdAt: '2026-05-25T00:00:00.000Z', +}; + +function resetAuthStore() { + useAuthStore.setState({ + token: null, + userId: null, + isAuthenticated: false, + hasCompletedOnboarding: false, + userPersona: null, + }); +} + +describe('useAuthStore onboarding persistence', () => { + beforeEach(() => { + window.localStorage.clear(); + resetAuthStore(); + }); + + it('persists completed onboarding persona outside the auth session cache', () => { + useAuthStore.getState().setPersona(persona); + + const stored = JSON.parse( + window.localStorage.getItem(ONBOARDING_PERSONA_STORAGE_KEY) ?? '{}', + ); + + expect(stored[persona.userId]).toEqual(persona); + }); + + it('restores onboarding completion for the same user after auth state is cleared', () => { + useAuthStore.getState().setPersona(persona); + useAuthStore.getState().clearAuth(); + + expect(useAuthStore.getState().hasCompletedOnboarding).toBe(false); + + useAuthStore.getState().setSession(persona.userId); + + expect(useAuthStore.getState().hasCompletedOnboarding).toBe(true); + expect(useAuthStore.getState().userPersona).toEqual(persona); + }); + + it('does not reuse another user onboarding persona', () => { + useAuthStore.getState().setPersona(persona); + useAuthStore.getState().clearAuth(); + + useAuthStore.getState().setSession('user-2'); + + expect(useAuthStore.getState().hasCompletedOnboarding).toBe(false); + expect(useAuthStore.getState().userPersona).toBeNull(); + }); +}); diff --git a/src/shared/model/auth-store.ts b/src/shared/model/auth-store.ts index 7d37a58..c0c3d0a 100644 --- a/src/shared/model/auth-store.ts +++ b/src/shared/model/auth-store.ts @@ -2,6 +2,48 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import type { UserPersona } from '@/entities/persona/types'; +const ONBOARDING_PERSONA_STORAGE_KEY = 'spot-onboarding-personas'; + +type StoredOnboardingPersonas = Record; + +function canUseLocalStorage() { + return typeof window !== 'undefined' && Boolean(window.localStorage); +} + +function readStoredOnboardingPersonas(): StoredOnboardingPersonas { + if (!canUseLocalStorage()) return {}; + + try { + const raw = window.localStorage.getItem(ONBOARDING_PERSONA_STORAGE_KEY); + if (!raw) return {}; + + const parsed = JSON.parse(raw) as unknown; + return parsed && typeof parsed === 'object' + ? (parsed as StoredOnboardingPersonas) + : {}; + } catch { + return {}; + } +} + +function getStoredOnboardingPersona(userId: string): UserPersona | null { + return readStoredOnboardingPersonas()[userId] ?? null; +} + +function persistOnboardingPersona(persona: UserPersona) { + if (!canUseLocalStorage()) return; + + try { + const personas = readStoredOnboardingPersonas(); + window.localStorage.setItem( + ONBOARDING_PERSONA_STORAGE_KEY, + JSON.stringify({ ...personas, [persona.userId]: persona }), + ); + } catch { + // localStorage 접근 실패(private mode 등)는 온보딩 흐름을 막지 않는다. + } +} + type AuthState = { token: string | null; userId: string | null; @@ -28,18 +70,30 @@ export const useAuthStore = create()( setSession: (userId) => { set((state) => { + const storedPersona = getStoredOnboardingPersona(userId); const userChanged = state.userId !== null && state.userId !== userId; + const shouldRestoreOnboarding = + Boolean(storedPersona) || + (state.userId === userId && + state.hasCompletedOnboarding); + return { token: null, userId, isAuthenticated: true, - ...(userChanged + ...(shouldRestoreOnboarding ? { - userPersona: null, - hasCompletedOnboarding: false, + userPersona: + storedPersona ?? state.userPersona, + hasCompletedOnboarding: true, } - : {}), + : userChanged + ? { + userPersona: null, + hasCompletedOnboarding: false, + } + : {}), }; }); }, @@ -55,6 +109,7 @@ export const useAuthStore = create()( }, setPersona: (persona) => { + persistOnboardingPersona(persona); set({ userPersona: persona, hasCompletedOnboarding: true }); }, @@ -73,6 +128,15 @@ export const useAuthStore = create()( if (state) { state.token = null; state.isAuthenticated = false; + + if (state.userPersona) { + persistOnboardingPersona(state.userPersona); + } + + const storedPersona = state.userId + ? getStoredOnboardingPersona(state.userId) + : null; + state.userPersona = storedPersona ?? state.userPersona; state.hasCompletedOnboarding = Boolean( state.hasCompletedOnboarding && state.userPersona, ); From 7212369e12ecf7a8253d55e2737e525128a839dc Mon Sep 17 00:00:00 2001 From: seojing Date: Mon, 25 May 2026 02:28:25 +0900 Subject: [PATCH 2/7] feat: add chat drawer filters --- src/features/chat/api/chat-api.ts | 1 + src/features/chat/model/types.ts | 1 + src/features/chat/ui/ChatDrawer.test.tsx | 105 +++++++++- src/features/chat/ui/ChatDrawer.tsx | 251 ++++++++++++++++------- 4 files changed, 276 insertions(+), 82 deletions(-) diff --git a/src/features/chat/api/chat-api.ts b/src/features/chat/api/chat-api.ts index 2e764ed..5eeafd3 100644 --- a/src/features/chat/api/chat-api.ts +++ b/src/features/chat/api/chat-api.ts @@ -122,6 +122,7 @@ function toChatRoom(room: BackendRoom): ChatRoom { description: room.lastMessagePreview ?? '백엔드 채팅방입니다.', metaLabel: '팀 채팅', updatedAt, + unreadCount: room.unreadCount ?? 0, messages: [], spot: { id: room.spotId ?? id, diff --git a/src/features/chat/model/types.ts b/src/features/chat/model/types.ts index 7b9c00c..a49e793 100644 --- a/src/features/chat/model/types.ts +++ b/src/features/chat/model/types.ts @@ -151,6 +151,7 @@ interface ChatRoomBase { description: string; metaLabel: string; updatedAt: string; + unreadCount?: number; messages: ChatMessage[]; } diff --git a/src/features/chat/ui/ChatDrawer.test.tsx b/src/features/chat/ui/ChatDrawer.test.tsx index 2b721e7..7292724 100644 --- a/src/features/chat/ui/ChatDrawer.test.tsx +++ b/src/features/chat/ui/ChatDrawer.test.tsx @@ -1,7 +1,13 @@ -import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ChatDrawer } from './ChatDrawer'; -import type { ChatRoom } from '../model/types'; +import type { ChatRoom, PersonalChatRoom, SpotChatRoom } from '../model/types'; const mockPush = vi.fn(); const mockLoadRooms = vi.fn(); @@ -28,6 +34,47 @@ vi.mock('../model/use-main-chat-store', () => ({ selector({ rooms: mockRooms, loadRooms: mockLoadRooms }), })); +function createPersonalRoom( + overrides: Partial, +): PersonalChatRoom { + return { + id: 'personal-room', + category: 'personal', + currentUserId: 'user-me', + currentUserName: '나', + partnerId: 'user-partner', + partnerName: '민수', + presenceLabel: '온라인', + unreadCount: 2, + counterpartRole: 'PARTNER', + title: '민수', + subtitle: '개인 채팅', + description: '개인 마지막 메시지', + metaLabel: '개인 채팅', + updatedAt: '2026-05-24T12:00:00.000Z', + messages: [], + ...overrides, + }; +} + +function createSpotRoom(overrides: Partial): SpotChatRoom { + return { + id: 'spot-room', + category: 'spot', + currentUserId: 'user-me', + currentUserName: '나', + title: '한강 러닝 스팟', + subtitle: '스팟 채팅', + description: '스팟 마지막 메시지', + metaLabel: '팀 채팅', + updatedAt: '2026-05-24T11:00:00.000Z', + unreadCount: 1, + messages: [], + spot: { id: 'spot-1' } as SpotChatRoom['spot'], + ...overrides, + }; +} + describe('ChatDrawer', () => { beforeEach(() => { mockPush.mockReset(); @@ -41,15 +88,61 @@ describe('ChatDrawer', () => { cleanup(); }); - it('requests chat rooms when the drawer opens so personal/feed/spot sections are populated from API state', async () => { + it('requests chat rooms when the drawer opens and renders the messenger-style filter badges', async () => { render(); await waitFor(() => { expect(mockLoadRooms).toHaveBeenCalledTimes(1); }); - expect(screen.getByText('개인 채팅')).not.toBeNull(); - expect(screen.getByText('피드 채팅')).not.toBeNull(); - expect(screen.getByText('스팟 채팅')).not.toBeNull(); + expect( + screen.getByRole('button', { name: '전체 채팅 필터' }), + ).not.toBeNull(); + expect( + screen.getByRole('button', { name: '개인 채팅 필터' }), + ).not.toBeNull(); + expect( + screen.getByRole('button', { name: '피드 채팅 필터' }), + ).not.toBeNull(); + expect( + screen.getByRole('button', { name: '스팟 채팅 필터' }), + ).not.toBeNull(); + expect(screen.queryByText('개인 채팅')).toBeNull(); + expect(screen.queryByText('피드 채팅')).toBeNull(); + expect(screen.queryByText('스팟 채팅')).toBeNull(); + }); + + it('filters one shared chat list into personal, feed, and spot buckets', () => { + mockRooms = [ + createPersonalRoom({ id: 'personal-room', title: '민수' }), + createSpotRoom({ + id: 'feed-room', + title: '저녁 산책 피드', + sourceFeedId: 'feed-1', + spot: { id: 'feed-1' } as SpotChatRoom['spot'], + }), + createSpotRoom({ + id: 'spot-room', + title: '한강 러닝 스팟', + sourceFeedId: undefined, + spot: { id: 'spot-1' } as SpotChatRoom['spot'], + }), + ]; + + render(); + + expect(screen.getByText('민수')).not.toBeNull(); + expect(screen.getByText('저녁 산책 피드')).not.toBeNull(); + expect(screen.getByText('한강 러닝 스팟')).not.toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '피드 채팅 필터' })); + expect(screen.queryByText('민수')).toBeNull(); + expect(screen.getByText('저녁 산책 피드')).not.toBeNull(); + expect(screen.queryByText('한강 러닝 스팟')).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '스팟 채팅 필터' })); + expect(screen.queryByText('민수')).toBeNull(); + expect(screen.queryByText('저녁 산책 피드')).toBeNull(); + expect(screen.getByText('한강 러닝 스팟')).not.toBeNull(); }); it('does not request chat rooms while the drawer is closed', () => { diff --git a/src/features/chat/ui/ChatDrawer.tsx b/src/features/chat/ui/ChatDrawer.tsx index f7c3650..7f7298e 100644 --- a/src/features/chat/ui/ChatDrawer.tsx +++ b/src/features/chat/ui/ChatDrawer.tsx @@ -3,7 +3,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; import { AnimatePresence, motion } from 'framer-motion'; -import { IconChevronDown, IconChevronRight, IconX } from '@tabler/icons-react'; +import { IconX } from '@tabler/icons-react'; +import { cn } from '@/shared/lib/cn'; import { useMainChatStore } from '../model/use-main-chat-store'; import type { ChatRoom, PersonalChatRoom, SpotChatRoom } from '../model/types'; @@ -13,7 +14,37 @@ type ChatDrawerProps = { }; const spring = { type: 'spring', stiffness: 380, damping: 34 } as const; -const PREVIEW_COUNT = 2; + +type ChatFilter = 'all' | 'personal' | 'feed' | 'spot'; + +type ChatFilterOption = { + value: ChatFilter; + label: string; + count: number; +}; + +const EMPTY_STATE: Record = + { + all: { + title: '아직 열린 채팅이 없어요', + description: + '피드에 신청하거나 스팟에 참여하면 대화가 여기에 모여요.', + }, + personal: { + title: '아직 개인 채팅이 없어요', + description: '상대 프로필에서 1:1 대화를 시작하면 여기에 표시돼요.', + }, + feed: { + title: '아직 피드 채팅이 없어요', + description: + '피드에서 시작된 대화는 스팟으로 전환되기 전까지 여기에 보여요.', + }, + spot: { + title: '아직 스팟 채팅이 없어요', + description: + '피드가 스팟으로 확정되면 같은 채팅이 스팟 채팅으로 이동해요.', + }, + }; function RoomAvatar({ room }: { room: ChatRoom }) { const initial = @@ -58,13 +89,31 @@ function formatTime(iso: string): string { return `${diffDays}일 전`; } +function getRoomFilter(room: ChatRoom): Exclude { + if (room.category === 'personal') return 'personal'; + return room.sourceFeedId ? 'feed' : 'spot'; +} + +function getRoomLabel(room: ChatRoom): string { + const filter = getRoomFilter(room); + if (filter === 'personal') return '개인'; + if (filter === 'feed') return '피드'; + return '스팟'; +} + +function getUnreadCount(room: ChatRoom): number { + return 'unreadCount' in room ? (room.unreadCount ?? 0) : 0; +} + function RoomRow({ room, onClick }: { room: ChatRoom; onClick: () => void }) { - const unread = room.category === 'personal' ? room.unreadCount : 0; + const unread = getUnreadCount(room); + const lastText = getLastText(room) || room.description; + return ( - )} - - )} - + ); + })} + + + ); +} + +type ChatListProps = { + filter: ChatFilter; + rooms: ChatRoom[]; + onRoomClick: (room: ChatRoom) => void; +}; + +function ChatList({ filter, rooms, onRoomClick }: ChatListProps) { + if (rooms.length === 0) { + const empty = EMPTY_STATE[filter]; + return ( +
+

+ {empty.title} +

+

+ {empty.description} +

+
+ ); + } + + return ( +
+ {rooms.map((room) => ( + onRoomClick(room)} + /> + ))} +
); } @@ -163,7 +236,9 @@ export function ChatDrawer({ open, onClose }: ChatDrawerProps) { void loadRooms(); }, [loadRooms, open]); - const { personalRooms, feedRooms, spotRooms } = useMemo(() => { + const [selectedFilter, setSelectedFilter] = useState('all'); + + const { personalRooms, feedRooms, spotRooms, allRooms } = useMemo(() => { const personal: PersonalChatRoom[] = []; const feed: SpotChatRoom[] = []; const spot: SpotChatRoom[] = []; @@ -172,9 +247,40 @@ export function ChatDrawer({ open, onClose }: ChatDrawerProps) { else if (room.sourceFeedId) feed.push(room); else spot.push(room); } - return { personalRooms: personal, feedRooms: feed, spotRooms: spot }; + return { + personalRooms: personal, + feedRooms: feed, + spotRooms: spot, + allRooms: [...personal, ...feed, ...spot].sort( + (left, right) => + new Date(right.updatedAt).getTime() - + new Date(left.updatedAt).getTime(), + ), + }; }, [rooms]); + const filterOptions = useMemo( + () => [ + { value: 'all', label: '전체', count: allRooms.length }, + { value: 'personal', label: '개인', count: personalRooms.length }, + { value: 'feed', label: '피드', count: feedRooms.length }, + { value: 'spot', label: '스팟', count: spotRooms.length }, + ], + [ + allRooms.length, + feedRooms.length, + personalRooms.length, + spotRooms.length, + ], + ); + + const visibleRooms = useMemo(() => { + if (selectedFilter === 'personal') return personalRooms; + if (selectedFilter === 'feed') return feedRooms; + if (selectedFilter === 'spot') return spotRooms; + return allRooms; + }, [allRooms, feedRooms, personalRooms, selectedFilter, spotRooms]); + useEffect(() => { if (!open) return; const handleKey = (e: KeyboardEvent) => { @@ -219,24 +325,17 @@ export function ChatDrawer({ open, onClose }: ChatDrawerProps) { + +
-
- - - +
From fa3699ce8a1be7942886fc7112b2bce934e132f7 Mon Sep 17 00:00:00 2001 From: seojing Date: Mon, 25 May 2026 02:31:43 +0900 Subject: [PATCH 3/7] feat: show layer-aware feed markers --- src/features/feed/api/feed-api.ts | 21 +- src/features/feed/model/feed-filter.ts | 43 ++++ src/features/feed/model/feed-layer-filter.ts | 9 + src/features/feed/model/feed-location.ts | 53 +++++ src/features/feed/model/feed-map.test.ts | 91 ++++++++ src/features/feed/model/types.ts | 6 +- src/features/feed/model/use-feed.ts | 14 ++ src/features/feed/ui/FeedBottomSheet.tsx | 9 +- src/features/feed/ui/MapFeedCardPager.tsx | 46 ++--- src/features/map/client/MapClient.tsx | 164 +++++++++------ src/features/map/model/types.ts | 15 +- src/features/map/ui/ClusterBlob.tsx | 205 +++++++++++-------- src/features/map/ui/MapFeedInfoCard.tsx | 97 +++++++++ src/features/map/ui/SpotInfoCard.tsx | 43 ++-- 14 files changed, 604 insertions(+), 212 deletions(-) create mode 100644 src/features/feed/model/feed-filter.ts create mode 100644 src/features/feed/model/feed-layer-filter.ts create mode 100644 src/features/feed/model/feed-location.ts create mode 100644 src/features/feed/model/feed-map.test.ts create mode 100644 src/features/map/ui/MapFeedInfoCard.tsx diff --git a/src/features/feed/api/feed-api.ts b/src/features/feed/api/feed-api.ts index 886172e..f2de08c 100644 --- a/src/features/feed/api/feed-api.ts +++ b/src/features/feed/api/feed-api.ts @@ -9,6 +9,7 @@ import type { } from '../model/types'; import type { PagedResponse } from '@/entities/spot/types'; import type { PlanV3, Preparation } from '@/entities/spot/simulation-types'; +import { resolveFeedCoordinate } from '../model/feed-location'; export type FeedApplyPayload = { proposal: string; @@ -24,6 +25,7 @@ export type FeedListParams = { status?: FeedItemStatus; category?: string; sort?: string; + isAi?: boolean; page?: number; size?: number; }; @@ -35,12 +37,24 @@ type BackendFeedList = { export type BackendFeedItem = Omit< FeedItem, - 'id' | 'spotId' | 'confirmedPartnerProfiles' | 'isAi' + | 'id' + | 'spotId' + | 'coord' + | 'lat' + | 'lng' + | 'confirmedPartnerProfiles' + | 'isAi' > & { id: string | number; spotId?: string | number; ai?: boolean; isAi?: boolean; + coordinate?: { + lat?: number | string | null; + lng?: number | string | null; + } | null; + lat?: number | string | null; + lng?: number | string | null; confirmedPartnerProfiles?: Array<{ id: string; nickname: string; @@ -94,10 +108,15 @@ export function toFeedApplication( } export function toFeedItem(item: BackendFeedItem): FeedItem { + const coord = resolveFeedCoordinate(item); + return { ...item, id: String(item.id), spotId: item.spotId == null ? undefined : String(item.spotId), + coord: coord ?? undefined, + lat: coord?.lat, + lng: coord?.lng, confirmedPartnerProfiles: item.confirmedPartnerProfiles?.map( (profile) => ({ id: profile.id, diff --git a/src/features/feed/model/feed-filter.ts b/src/features/feed/model/feed-filter.ts new file mode 100644 index 0000000..d2280db --- /dev/null +++ b/src/features/feed/model/feed-filter.ts @@ -0,0 +1,43 @@ +import type { SpotCategory } from '@/entities/spot/categories'; +import type { FeedItem } from './types'; +import { isSearchExcludedFeedItem } from './types'; + +export type FeedMarkerFilter = { + feedType: 'all' | 'offer' | 'request'; + categories: readonly SpotCategory[]; + searchQuery: string; +}; + +export function filterVisibleFeedItems( + feedItems: readonly FeedItem[], + { feedType, categories, searchQuery }: FeedMarkerFilter, +): FeedItem[] { + const q = searchQuery.trim().toLowerCase(); + + return feedItems.filter((item) => { + if (feedType === 'offer' && item.type !== 'OFFER') return false; + if (feedType === 'request' && item.type !== 'REQUEST') return false; + if ( + categories.length > 0 && + (!item.category || + !categories.includes(item.category as SpotCategory)) + ) { + return false; + } + if (q.length > 0) { + if (isSearchExcludedFeedItem(item)) return false; + + const haystack = [ + item.title, + item.description ?? '', + item.category ?? '', + item.location, + item.authorNickname, + ] + .join(' ') + .toLowerCase(); + if (!haystack.includes(q)) return false; + } + return true; + }); +} diff --git a/src/features/feed/model/feed-layer-filter.ts b/src/features/feed/model/feed-layer-filter.ts new file mode 100644 index 0000000..ec10613 --- /dev/null +++ b/src/features/feed/model/feed-layer-filter.ts @@ -0,0 +1,9 @@ +import type { LayerType } from '@/features/layer/model/use-layer-store'; + +export function getFeedListIsAiParamByLayer( + activeLayer: LayerType, +): boolean | undefined { + if (activeLayer === 'real') return false; + if (activeLayer === 'virtual') return true; + return undefined; +} diff --git a/src/features/feed/model/feed-location.ts b/src/features/feed/model/feed-location.ts new file mode 100644 index 0000000..368bbc1 --- /dev/null +++ b/src/features/feed/model/feed-location.ts @@ -0,0 +1,53 @@ +import type { GeoCoord } from '@/entities/spot/types'; +import type { FeedItem } from './types'; + +type CoordinateLike = { + lat?: unknown; + lng?: unknown; +}; + +type FeedCoordinateSource = Pick & + CoordinateLike & { + coordinate?: CoordinateLike | null; + }; + +function toFiniteCoord(lat: unknown, lng: unknown): GeoCoord | null { + const numericLat = typeof lat === 'number' ? lat : Number(lat); + const numericLng = typeof lng === 'number' ? lng : Number(lng); + + if ( + !Number.isFinite(numericLat) || + !Number.isFinite(numericLng) || + numericLat < -90 || + numericLat > 90 || + numericLng < -180 || + numericLng > 180 + ) { + return null; + } + + return { lat: numericLat, lng: numericLng }; +} + +export function resolveFeedCoordinate( + item: FeedCoordinateSource, +): GeoCoord | null { + const coord = item.coord ?? item.coordinate ?? null; + if (coord) { + const resolved = toFiniteCoord(coord.lat, coord.lng); + if (resolved) return resolved; + } + + const topLevel = toFiniteCoord(item.lat, item.lng); + if (topLevel) return topLevel; + + if (item.primaryPin) { + const primaryPin = toFiniteCoord( + item.primaryPin.lat, + item.primaryPin.lng, + ); + if (primaryPin) return primaryPin; + } + + return null; +} diff --git a/src/features/feed/model/feed-map.test.ts b/src/features/feed/model/feed-map.test.ts new file mode 100644 index 0000000..f148f8d --- /dev/null +++ b/src/features/feed/model/feed-map.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import type { FeedItem } from './types'; +import { resolveFeedCoordinate } from './feed-location'; +import { filterVisibleFeedItems } from './feed-filter'; +import { getFeedListIsAiParamByLayer } from './feed-layer-filter'; + +function makeFeed(overrides: Partial = {}): FeedItem { + return { + id: overrides.id ?? 'feed-1', + title: overrides.title ?? '함께 기타 연습해요', + location: overrides.location ?? '경기대', + authorNickname: overrides.authorNickname ?? '진규', + price: overrides.price ?? 0, + type: overrides.type ?? 'OFFER', + status: overrides.status ?? 'OPEN', + views: overrides.views ?? 0, + likes: overrides.likes ?? 0, + ...overrides, + }; +} + +describe('feed map helpers', () => { + it('resolves feed coordinates from normalized coord first', () => { + const item = makeFeed({ + coord: { lat: 37.2636, lng: 127.0286 }, + lat: 1, + lng: 2, + }); + + expect(resolveFeedCoordinate(item)).toEqual({ + lat: 37.2636, + lng: 127.0286, + }); + }); + + it('falls back to top-level lat/lng for backend feed list items', () => { + const item = makeFeed({ lat: 37.5123, lng: 127.0456 }); + + expect(resolveFeedCoordinate(item)).toEqual({ + lat: 37.5123, + lng: 127.0456, + }); + }); + + it('falls back to primaryPin coordinates used by contextBuilder feed fixtures', () => { + const item = makeFeed({ + primaryPin: { + place_id: 27440700, + name: '아롬', + primary_category: 'cafe', + role: 'main', + lat: 37.2636, + lng: 127.0286, + address: '경기 수원시 장안구 창훈로40번길 9', + confidence: 1, + }, + }); + + expect(resolveFeedCoordinate(item)).toEqual({ + lat: 37.2636, + lng: 127.0286, + }); + }); + + it('keeps map marker filters aligned with the visible feed list', () => { + const feeds = [ + makeFeed({ id: 'offer', type: 'OFFER', category: '음악' }), + makeFeed({ id: 'request', type: 'REQUEST', category: '요리' }), + makeFeed({ + id: 'joined', + type: 'OFFER', + category: '음악', + myApplicationStatus: 'ACCEPTED', + }), + ]; + + expect( + filterVisibleFeedItems(feeds, { + feedType: 'offer', + categories: ['음악'], + searchQuery: '기타', + }).map((item) => item.id), + ).toEqual(['offer']); + }); + + it('maps map layer state to backend isAi feed list filtering', () => { + expect(getFeedListIsAiParamByLayer('real')).toBe(false); + expect(getFeedListIsAiParamByLayer('mixed')).toBeUndefined(); + expect(getFeedListIsAiParamByLayer('virtual')).toBe(true); + }); +}); diff --git a/src/features/feed/model/types.ts b/src/features/feed/model/types.ts index f68e6cd..5b31d49 100644 --- a/src/features/feed/model/types.ts +++ b/src/features/feed/model/types.ts @@ -1,5 +1,5 @@ // FeedItem - 피드 목록에 표시되는 아이템 타입 - +import type { GeoCoord } from '@/entities/spot/types'; import type { PlanV3, Preparation, @@ -106,6 +106,10 @@ export interface FeedItem { myApplicationRole?: FeedApplicationRole; myApplicationDeposit?: number; spotId?: string; + coord?: GeoCoord; + /** Backend feed list exposes the selected map point as top-level lat/lng. */ + lat?: number; + lng?: number; owner?: boolean; /** * 2026-04-30 — contextBuilder 시뮬레이션이 합성한 AI 피드 마커. diff --git a/src/features/feed/model/use-feed.ts b/src/features/feed/model/use-feed.ts index 21d2f0d..23ee609 100644 --- a/src/features/feed/model/use-feed.ts +++ b/src/features/feed/model/use-feed.ts @@ -1,3 +1,4 @@ +import { useMemo } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { payKeys } from '@/features/pay'; import { @@ -5,6 +6,8 @@ import { type FeedApplyPayload, type FeedListParams, } from '../api/feed-api'; +import { useLayerStore } from '@/features/layer/model/use-layer-store'; +import { getFeedListIsAiParamByLayer } from './feed-layer-filter'; export const feedKeys = { all: ['feed'] as const, @@ -24,6 +27,17 @@ export function useFeedList(params?: FeedListParams) { }); } +export function useLayerAwareFeedList(params?: FeedListParams) { + const activeLayer = useLayerStore((state) => state.activeLayer); + const isAi = getFeedListIsAiParamByLayer(activeLayer); + const layerParams = useMemo(() => { + if (isAi === undefined) return params; + return { ...params, isAi }; + }, [params, isAi]); + + return useFeedList(layerParams); +} + export function useFeedApplications(feedId: string, enabled: boolean) { return useQuery({ queryKey: feedKeys.applications(feedId), diff --git a/src/features/feed/ui/FeedBottomSheet.tsx b/src/features/feed/ui/FeedBottomSheet.tsx index 2fdbea2..ce73c41 100644 --- a/src/features/feed/ui/FeedBottomSheet.tsx +++ b/src/features/feed/ui/FeedBottomSheet.tsx @@ -1,15 +1,13 @@ 'use client'; import { useMemo } from 'react'; -import { useQuery } from '@tanstack/react-query'; import { type BottomSheetSnapPoint, PersistentDrawer, } from '@frontend/design-system'; import { useAuthStore } from '@/shared/model/auth-store'; import { buildSpotCardLookup } from '@/features/simulation/model/spot-card-adapter'; -import { feedApi } from '../api/feed-api'; -import { feedKeys } from '../model/use-feed'; +import { useLayerAwareFeedList } from '../model/use-feed'; import { useFilterStore } from '@/features/map/model/use-filter-store'; import type { SpotCategory } from '@/entities/spot/categories'; import { FeedCard } from './FeedCard'; @@ -55,10 +53,7 @@ export function FeedBottomSheet({ const role = userPersona?.role ?? null; const searchQuery = useFilterStore((s) => s.searchQuery); const normalizedQuery = searchQuery.trim().toLowerCase(); - const { data: feedData } = useQuery({ - queryKey: feedKeys.list(), - queryFn: () => feedApi.list(), - }); + const { data: feedData } = useLayerAwareFeedList(); const feedItems = feedData?.data ?? []; const spotCardLookup = useMemo(() => buildSpotCardLookup([]), []); diff --git a/src/features/feed/ui/MapFeedCardPager.tsx b/src/features/feed/ui/MapFeedCardPager.tsx index dbe91a0..ad4464c 100644 --- a/src/features/feed/ui/MapFeedCardPager.tsx +++ b/src/features/feed/ui/MapFeedCardPager.tsx @@ -10,10 +10,9 @@ import { import { IconHeart } from '@tabler/icons-react'; import { useRouter } from 'next/navigation'; import { useFilterStore } from '@/features/map/model/use-filter-store'; -import type { SpotCategory } from '@/entities/spot/categories'; -import { useFeedList } from '@/features/feed/model/use-feed'; +import { useLayerAwareFeedList } from '@/features/feed/model/use-feed'; +import { filterVisibleFeedItems } from '@/features/feed/model/feed-filter'; import type { FeedItem } from '@/features/feed/model/types'; -import { isSearchExcludedFeedItem } from '@/features/feed/model/types'; import { FeedCard } from '@/features/feed/ui/FeedCard'; export type FeedCardPagerSnap = 'peek' | 'expanded'; @@ -58,38 +57,17 @@ export function MapFeedCardPager({ dir: ExitDirection; } | null>(null); - const { data: feedData } = useFeedList(); - const feedItems = feedData?.data ?? []; + const { data: feedData } = useLayerAwareFeedList(); - const filtered = useMemo(() => { - const q = searchQuery.trim().toLowerCase(); - return feedItems.filter((item) => { - if (feedType === 'offer' && item.type !== 'OFFER') return false; - if (feedType === 'request' && item.type !== 'REQUEST') return false; - if ( - categoriesSelected.length > 0 && - (!item.category || - !categoriesSelected.includes(item.category as SpotCategory)) - ) { - return false; - } - if (q.length > 0) { - if (isSearchExcludedFeedItem(item)) return false; - - const haystack = [ - item.title, - item.description ?? '', - item.category ?? '', - item.location, - item.authorNickname, - ] - .join(' ') - .toLowerCase(); - if (!haystack.includes(q)) return false; - } - return true; - }); - }, [feedItems, feedType, categoriesSelected, searchQuery]); + const filtered = useMemo( + () => + filterVisibleFeedItems(feedData?.data ?? [], { + feedType, + categories: categoriesSelected, + searchQuery, + }), + [feedData?.data, feedType, categoriesSelected, searchQuery], + ); const total = filtered.length; const safePromoted = Math.min(promotedCount, total); diff --git a/src/features/map/client/MapClient.tsx b/src/features/map/client/MapClient.tsx index e23a411..372cf3d 100644 --- a/src/features/map/client/MapClient.tsx +++ b/src/features/map/client/MapClient.tsx @@ -20,10 +20,6 @@ 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'; import { useMySpotsStore } from '@/features/spot/model/my-spots-store'; -import { - saveSimulationConversionContext, - getSuggestedPriceKrw, -} from '@/features/simulation/model/simulation-conversion-context'; import { useFilterStore } from '@/features/map/model/use-filter-store'; import { useMapUrlState } from '@/features/map/model/use-map-url-state'; import { ClusterBlob } from '@/features/map/ui/ClusterBlob'; @@ -31,7 +27,11 @@ import { PersonaDotMarkerBlob } from '@/features/map/ui/PersonaDotMarkerBlob'; import { MapBottomStack } from '@/features/map/ui/MapBottomStack'; import { SpotInfoCard } from '@/features/map/ui/SpotInfoCard'; import { MySpotInfoCard } from '@/features/map/ui/MySpotInfoCard'; +import { MapFeedInfoCard } from '@/features/map/ui/MapFeedInfoCard'; import { LiveTicker } from '@/features/map/ui/LiveTicker'; +import { useLayerAwareFeedList } from '@/features/feed/model/use-feed'; +import { filterVisibleFeedItems } from '@/features/feed/model/feed-filter'; +import { resolveFeedCoordinate } from '@/features/feed/model/feed-location'; import type { TickerEvent } from '@/features/map/model/ticker-adapter'; import { createSwarmTickerAdapter } from '@/features/map/model/swarm-ticker-adapter'; import { useTheme } from '@/shared/model/use-theme'; @@ -140,6 +140,16 @@ export function MapClient() { const feedType = useFilterStore((s) => s.feedType); const categories = useFilterStore((s) => s.categories); const searchQuery = useFilterStore((s) => s.searchQuery); + const { data: feedData } = useLayerAwareFeedList(); + const visibleFeedItems = useMemo( + () => + filterVisibleFeedItems(feedData?.data ?? [], { + feedType, + categories, + searchQuery, + }), + [feedData?.data, feedType, categories, searchQuery], + ); const activeLayer = useLayerStore((s) => s.activeLayer); @@ -242,6 +252,13 @@ export function MapClient() { [updateUrl], ); + const handleFeedMarkerSelect = useCallback( + (feedId: string) => { + updateUrl({ cluster: `feed-${feedId}` }); + }, + [updateUrl], + ); + // v3 는 spot marker 가 아닌 cluster 중심이라 SpotPreviewSheet 은 사용하지 않음. void selectedSpotId; @@ -275,7 +292,10 @@ export function MapClient() { clickable: true, render: () => ( @@ -333,9 +353,62 @@ export function MapClient() { inViewport, ]); + const feedMarkerOverlays: MapOverlayItem[] = useMemo(() => { + return visibleFeedItems.flatMap((item) => { + const coord = resolveFeedCoordinate(item); + if (!coord || !inViewport(coord)) return []; + + const markerCount = Math.max( + 1, + item.confirmedPartnerProfiles?.length ?? + item.partnerCount ?? + item.applicantCount ?? + 1, + ); + const personas = Array.from( + { length: markerCount }, + (_, index) => ({ + id: `${item.id}-${index}`, + name: item.authorNickname, + emoji: item.type === 'REQUEST' ? '🙋' : '📍', + }), + ); + const cluster: ActivityCluster = { + id: `feed-${item.id}`, + centerCoord: coord, + category: item.category ?? '피드', + intent: item.type === 'REQUEST' ? 'request' : 'offer', + personas, + variant: item.isAi ? 'ai-feed' : 'user-feed', + }; + + return [ + { + key: `feed-marker-${item.id}`, + position: coord, + clickable: true, + render: () => ( + + handleFeedMarkerSelect(item.id) + } + /> + ), + } satisfies MapOverlayItem, + ]; + }); + }, [ + visibleFeedItems, + selectedClusterId, + handleFeedMarkerSelect, + inViewport, + ]); + const overlays: MapOverlayItem[] = useMemo( - () => [...clusterOverlays, ...personaOverlays], - [clusterOverlays, personaOverlays], + () => [...clusterOverlays, ...feedMarkerOverlays, ...personaOverlays], + [clusterOverlays, feedMarkerOverlays, personaOverlays], ); const [tickerEvent, setTickerEvent] = useState(null); @@ -457,6 +530,27 @@ export function MapClient() { ); } + // 실제 사용자/AI 추천 피드 마커 — 바로 상세 이동하지 않고 요약 카드 먼저 노출. + if (selectedClusterId.startsWith('feed-')) { + const feedId = selectedClusterId.slice('feed-'.length); + const selectedFeed = visibleFeedItems.find( + (item) => item.id === feedId, + ); + if (!selectedFeed) return null; + return ( + + updateUrl({ cluster: null }) + } + onDetailAction={() => + router.push(`/feed/${selectedFeed.id}`) + } + /> + ); + } + // 시뮬 lifecycle 기반 클러스터. const selectedLifecycle = lifecycleResult.lifecycles.find( (lc) => lc.spotId === selectedClusterId, @@ -467,62 +561,8 @@ export function MapClient() { key={`spot-${selectedLifecycle.spotId}`} lifecycle={selectedLifecycle} personaLookup={basePersonaLookup} + variant="discovery" onCloseAction={() => updateUrl({ cluster: null })} - onCreateSimilarAction={() => { - // 시뮬 → post 전환: prefill + insight 컨텍스트 둘 다 전달. - // 단순 prefill(URL 쿼리) 로는 담기 힘든 분석 지표(평균 참여자, - // 가격 벤치마크 등) 는 sessionStorage 에 JSON 으로 저장해 - // post 폼 쪽 SimulationInsightCard 가 읽어 '적용' 제안으로 노출. - const sameCategory = - lifecycleResult.lifecycles.filter( - (l) => - l.category === - selectedLifecycle.category, - ); - const nowMs = performance.now(); - const similarActive = sameCategory.filter( - (l) => nowMs < l.closedAtMs, - ).length; - const avgParticipants = - sameCategory.length > 0 - ? Math.max( - 2, - Math.round( - sameCategory.reduce( - (s, l) => - s + - l.participants.length, - 0, - ) / sameCategory.length, - ), - ) - : selectedLifecycle.participants.length; - saveSimulationConversionContext({ - sourceSpotId: selectedLifecycle.spotId, - category: selectedLifecycle.category, - intent: selectedLifecycle.intent, - title: selectedLifecycle.title, - similarActiveCount: similarActive, - avgParticipants, - suggestedPriceKrw: getSuggestedPriceKrw( - selectedLifecycle.category, - ), - typicalLifespanMs: - selectedLifecycle.closedAtMs - - selectedLifecycle.createdAtMs, - spotLocation: selectedLifecycle.location, - }); - - const qs = new URLSearchParams(); - qs.set('title', selectedLifecycle.title); - qs.set('category', selectedLifecycle.category); - qs.set('fromSpot', selectedLifecycle.spotId); - const path = - selectedLifecycle.intent === 'offer' - ? '/post/offer' - : '/post/request'; - router.push(`${path}?${qs.toString()}`); - }} /> ); })()} diff --git a/src/features/map/model/types.ts b/src/features/map/model/types.ts index a9a188c..6e21956 100644 --- a/src/features/map/model/types.ts +++ b/src/features/map/model/types.ts @@ -1,4 +1,3 @@ -import type { SpotCategory } from '@/entities/spot/categories'; import type { GeoCoord } from '@/entities/spot/types'; export type PersonaRef = { @@ -10,7 +9,7 @@ export type PersonaRef = { export type ActivityCluster = { id: string; centerCoord: GeoCoord; - category: SpotCategory; + category: string; intent: 'offer' | 'request'; personas: PersonaRef[]; /** 새로 생성된 클러스터 — birth pulse 1회. */ @@ -19,8 +18,14 @@ export type ActivityCluster = { isDying?: boolean; /** 물리적으로 spot 에 도착한 참여자 수. 증가 시 ClusterBlob 가 join burst 재생. */ arrivedCount?: number; - /** 클러스터 시각 변형. 'mine' = 유저 본인이 만든 모임 (primary 톤 + 뱃지). */ - variant?: 'mine'; + /** + * 클러스터 시각 변형. + * - discovery: 시뮬레이션상 생기는 동네 발견 신호. 상세/리퀘스트 전환보다 배경 발견 역할. + * - ai-feed: LLM 검증 추천 피드. 상세 진입 가능. + * - user-feed: 실제 사용자 피드. 가장 높은 우선도. + * - mine: 유저 본인이 만든 모임. + */ + variant?: 'discovery' | 'ai-feed' | 'user-feed' | 'mine'; /** 변형에 따른 추가 라벨(예: "내 모임"). */ variantLabel?: string; }; @@ -28,7 +33,7 @@ export type ActivityCluster = { export type ClusterInput = { id: string; coord: GeoCoord; - category: SpotCategory; + category: string; intent: 'offer' | 'request'; emoji: string; name: string; diff --git a/src/features/map/ui/ClusterBlob.tsx b/src/features/map/ui/ClusterBlob.tsx index 3586ad2..c0ecd30 100644 --- a/src/features/map/ui/ClusterBlob.tsx +++ b/src/features/map/ui/ClusterBlob.tsx @@ -38,6 +38,38 @@ const CORE_SELECTED = 26; const CORE_IDLE = 22; const SAT_COUNT = 5; +function getVariantTone( + variant: ActivityCluster['variant'], + selected: boolean, +) { + if (variant === 'ai-feed') { + return { + fill: '#8B5CF6', + opacity: 0.86, + countClassName: 'bg-violet-500 text-white', + }; + } + if (variant === 'discovery') { + return { + fill: 'var(--color-persona)', + opacity: 0.42, + countClassName: 'bg-background/90 text-muted-foreground', + }; + } + if (variant === 'mine' || variant === 'user-feed' || selected) { + return { + fill: 'var(--color-primary)', + opacity: variant === 'user-feed' ? 0.94 : 0.88, + countClassName: 'bg-primary text-primary-foreground', + }; + } + return { + fill: 'var(--color-persona)', + opacity: 0.72, + countClassName: 'bg-foreground text-background', + }; +} + function ClusterBlobImpl({ cluster, selected, @@ -49,6 +81,8 @@ function ClusterBlobImpl({ const count = cluster.personas.length; const core = selected ? CORE_SELECTED : CORE_IDLE; const dying = !!cluster.isDying; + const tone = getVariantTone(cluster.variant, selected); + const isDiscovery = cluster.variant === 'discovery'; // 물리적 도착자 수 증가 감지 → join burst 트리거. // (assigned 수 아님 — 이동 완료 후 "딱 도착한 순간" 이 사용자에게 의미 있는 이벤트) @@ -210,17 +244,8 @@ function ClusterBlobImpl({ - {Array.from({ length: SAT_COUNT }).map((_, i) => { - const angle = (i / SAT_COUNT) * Math.PI * 2 + i * 0.35; - const baseR = core * 1.05; - const sx = CX + Math.cos(angle) * baseR; - const sy = CY + Math.sin(angle) * baseR; - const drift = 5; - return ( - - ); - })} + {!isDiscovery && + Array.from({ length: SAT_COUNT }).map((_, i) => { + const angle = + (i / SAT_COUNT) * Math.PI * 2 + i * 0.35; + const baseR = core * 1.05; + const sx = CX + Math.cos(angle) * baseR; + const sy = CY + Math.sin(angle) * baseR; + const drift = 5; + return ( + + ); + })} {absorbing.map((dot) => { const startX = CX + dot.fromX * 55; const startY = CY + dot.fromY * 55; @@ -284,49 +317,47 @@ function ClusterBlobImpl({ -
- {count} -
- - {cluster.variant === 'mine' && ( -
- {cluster.variantLabel ?? '내 모임'} -
+ {isDiscovery && !reduceMotion && ( + )} -
- {cluster.category} -
- - {selected && !dying && ( -
- {cluster.category} -
- {count}명 모여있음 + {!isDiscovery && ( + <> +
+ {count}
-
+ className="absolute left-1/2 -translate-x-1/2 whitespace-nowrap text-[11px] font-semibold tracking-tight text-foreground/75 drop-shadow-sm dark:text-foreground/80" + style={{ top: CY + core + 10 }} + > + {cluster.category} +
+ )} ); diff --git a/src/features/map/ui/MapFeedInfoCard.tsx b/src/features/map/ui/MapFeedInfoCard.tsx new file mode 100644 index 0000000..3ff22c5 --- /dev/null +++ b/src/features/map/ui/MapFeedInfoCard.tsx @@ -0,0 +1,97 @@ +'use client'; + +import { cn } from '@frontend/design-system'; +import type { FeedItem } from '@/features/feed/model/types'; + +const TYPE_LABEL: Record = { + OFFER: '해볼래', + REQUEST: '알려줘', + RENT: '빌려줘', +}; + +type MapFeedInfoCardProps = { + item: FeedItem; + onCloseAction: () => void; + onDetailAction: () => void; +}; + +function formatPrice(price: number) { + if (price <= 0) return '무료'; + return `${price.toLocaleString('ko-KR')}원`; +} + +export function MapFeedInfoCard({ + item, + onCloseAction, + onDetailAction, +}: MapFeedInfoCardProps) { + const isAi = item.isAi === true; + + return ( +
event.stopPropagation()} + className="rounded-2xl border border-border-soft bg-card p-3 shadow-md" + > +
+
+ {isAi ? '✦' : '●'} +
+
+
+ + {item.title} + + + {isAi ? '추천' : '사용자'} + +
+
+ {TYPE_LABEL[item.type]} · {item.category ?? '피드'} ·{' '} + {item.location} +
+
+ +
+ + {item.description && ( +

+ {item.description} +

+ )} + +
+ {item.authorNickname} + {formatPrice(item.price)} +
+ + +
+ ); +} diff --git a/src/features/map/ui/SpotInfoCard.tsx b/src/features/map/ui/SpotInfoCard.tsx index 2503cd0..4ee914b 100644 --- a/src/features/map/ui/SpotInfoCard.tsx +++ b/src/features/map/ui/SpotInfoCard.tsx @@ -18,6 +18,7 @@ type SpotInfoCardProps = { lifecycle: SpotLifecycle; personaLookup: Map; onCloseAction?: () => void; + variant?: 'discovery' | 'actionable'; /** "이런 모임 열기" 클릭 시 호출. 시뮬→포스트 생성 플로우로 연결. */ onCreateSimilarAction?: () => void; }; @@ -63,6 +64,7 @@ export function SpotInfoCard({ lifecycle, personaLookup, onCloseAction, + variant = 'actionable', onCreateSimilarAction, }: SpotInfoCardProps) { const router = useRouter(); @@ -175,26 +177,37 @@ export function SpotInfoCard({
)} - - - {onCreateSimilarAction && status !== 'CLOSED' && ( + {variant === 'discovery' ? ( +
+ 동네에서 감지된 발견 신호예요. 실제 피드는 아니어서 자세히 + 보기나 리퀘스트 전환 없이 주변 활동 분위기만 보여줘요. +
+ ) : ( )} + + {variant !== 'discovery' && + onCreateSimilarAction && + status !== 'CLOSED' && ( + + )}
); } From 12461ea36dd81e26c44b5be1ff51e7b0445741bf Mon Sep 17 00:00:00 2001 From: seojing Date: Mon, 25 May 2026 02:31:47 +0900 Subject: [PATCH 4/7] style: simplify post creation form --- src/features/post/client/OfferFormClient.tsx | 7 +- .../post/client/RequestFormClient.tsx | 7 +- src/features/post/ui/FormCard.tsx | 11 +- src/features/post/ui/FormControls.tsx | 104 ++++++++++++++ src/features/post/ui/FormField.tsx | 17 ++- src/features/post/ui/ImageUploadGrid.tsx | 9 +- src/features/post/ui/ImageUploadSlot.tsx | 9 +- src/features/post/ui/ReceiptCard.tsx | 127 +++++++++--------- .../post/ui/post-form/OfferDetailsSection.tsx | 15 +-- .../post/ui/post-form/PlanInputSection.tsx | 46 ++++--- .../post/ui/post-form/PostBaseInfoSection.tsx | 28 ++-- .../ui/post-form/PreparationInputSection.tsx | 34 ++--- .../post/ui/post-form/PriceInputSection.tsx | 115 ++++++++-------- .../ui/post-form/RequestDetailsSection.tsx | 15 +-- 14 files changed, 331 insertions(+), 213 deletions(-) create mode 100644 src/features/post/ui/FormControls.tsx diff --git a/src/features/post/client/OfferFormClient.tsx b/src/features/post/client/OfferFormClient.tsx index e8a6f71..0510421 100644 --- a/src/features/post/client/OfferFormClient.tsx +++ b/src/features/post/client/OfferFormClient.tsx @@ -14,6 +14,7 @@ import { PostBaseInfoSection } from '../ui/post-form/PostBaseInfoSection'; import { PostSubmitBar } from '../ui/post-form/PostSubmitBar'; import { PreparationInputSection } from '../ui/post-form/PreparationInputSection'; import { PriceInputSection } from '../ui/post-form/PriceInputSection'; +import { PostErrorMessage } from '../ui/FormControls'; import { PostStepIndicator } from '../ui/PostStepIndicator'; import { ReceiptCard } from '../ui/ReceiptCard'; import { DetailPageShell } from '@/shared/ui'; @@ -208,11 +209,7 @@ export function OfferFormClient() {

함께할 파트너들이 한눈에 이해할 수 있게 작성해주세요.

- {submitError && ( -

- {submitError} -

- )} + {submitError && } {step === 0 && ( 함께할 파트너들이 한눈에 이해할 수 있게 작성해주세요.

- {submitError && ( -

- {submitError} -

- )} + {submitError && } {step === 0 && ( -

{title}

+
+

{title}

{children}
); diff --git a/src/features/post/ui/FormControls.tsx b/src/features/post/ui/FormControls.tsx new file mode 100644 index 0000000..1535318 --- /dev/null +++ b/src/features/post/ui/FormControls.tsx @@ -0,0 +1,104 @@ +import type { + ButtonHTMLAttributes, + InputHTMLAttributes, + TextareaHTMLAttributes, +} from 'react'; +import { cn } from '@frontend/design-system'; + +export function PostTextInput({ + className, + variant = 'underline', + align = 'left', + ...props +}: InputHTMLAttributes & { + variant?: 'underline' | 'box' | 'compact'; + align?: 'left' | 'right'; +}) { + return ( + + ); +} + +export function PostTextarea({ + className, + variant = 'box', + ...props +}: TextareaHTMLAttributes & { + variant?: 'box' | 'compact'; +}) { + return ( +