diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000..23f9cd8 Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/brand/spot-logo.png b/public/brand/spot-logo.png new file mode 100644 index 0000000..6d0f67d Binary files /dev/null and b/public/brand/spot-logo.png differ diff --git a/public/icons/icon-192x192.png b/public/icons/icon-192x192.png new file mode 100644 index 0000000..5a0fe99 Binary files /dev/null and b/public/icons/icon-192x192.png differ diff --git a/public/icons/icon-512x512.png b/public/icons/icon-512x512.png new file mode 100644 index 0000000..7fc3a91 Binary files /dev/null and b/public/icons/icon-512x512.png differ diff --git a/public/icons/maskable-icon-512x512.png b/public/icons/maskable-icon-512x512.png new file mode 100644 index 0000000..dfffd8c Binary files /dev/null and b/public/icons/maskable-icon-512x512.png differ diff --git a/public/manifest.json b/public/manifest.json index f29d6a1..a2a50b1 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -2,20 +2,54 @@ "name": "Spot — Share Your Seat", "short_name": "Spot", "description": "Find and share spots in your city", - "start_url": "/", + "start_url": "/map?source=pwa", + "scope": "/", "display": "standalone", + "orientation": "portrait", "background_color": "#ffffff", - "theme_color": "#ffffff", + "theme_color": "#111827", + "categories": ["social", "lifestyle", "productivity"], "icons": [ { "src": "/icons/icon-192x192.png", "sizes": "192x192", - "type": "image/png" + "type": "image/png", + "purpose": "any" }, { "src": "/icons/icon-512x512.png", "sizes": "512x512", - "type": "image/png" + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/maskable-icon-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "shortcuts": [ + { + "name": "지도 바로 열기", + "short_name": "지도", + "description": "주변 스팟과 피드 카드덱을 바로 확인해요.", + "url": "/map?source=pwa-shortcut", + "icons": [{ "src": "/icons/icon-192x192.png", "sizes": "192x192" }] + }, + { + "name": "나눔 올리기", + "short_name": "나눔", + "description": "남는 자리나 자원을 빠르게 공유해요.", + "url": "/post/offer?source=pwa-shortcut", + "icons": [{ "src": "/icons/icon-192x192.png", "sizes": "192x192" }] + }, + { + "name": "채팅 확인", + "short_name": "채팅", + "description": "매칭된 팀/개인 채팅으로 곧장 이동해요.", + "url": "/chat?source=pwa-shortcut", + "icons": [{ "src": "/icons/icon-192x192.png", "sizes": "192x192" }] } ] } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 99ace31..83af816 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -16,6 +16,27 @@ export const metadata: Metadata = { statusBarStyle: 'default', title: 'Spot', }, + icons: { + icon: [ + { + url: '/icons/icon-192x192.png', + sizes: '192x192', + type: 'image/png', + }, + { + url: '/icons/icon-512x512.png', + sizes: '512x512', + type: 'image/png', + }, + ], + apple: [ + { + url: '/apple-touch-icon.png', + sizes: '180x180', + type: 'image/png', + }, + ], + }, }; export default function RootLayout({ diff --git a/src/features/chat/api/chat-api.test.ts b/src/features/chat/api/chat-api.test.ts new file mode 100644 index 0000000..dcb28ee --- /dev/null +++ b/src/features/chat/api/chat-api.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { chatApi } from './chat-api'; +import type { SpotChatRoom } from '../model/types'; + +describe('chatApi room lifecycle mapping', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('preserves feedId and spotId nullable pair so shared group rooms can be classified as feed or spot chats', async () => { + const fetchMock = vi.fn().mockResolvedValue( + Response.json({ + data: [ + { + id: 10, + type: 'GROUP', + feedId: 123, + spotId: null, + title: '피드 모집 채팅', + lastMessagePreview: '아직 피드 상태예요', + createdAt: '2026-05-24T10:00:00.000Z', + }, + { + id: 20, + type: 'GROUP', + feedId: null, + spotId: 456, + title: '확정 스팟 채팅', + lastMessagePreview: '스팟으로 전환됐어요', + createdAt: '2026-05-24T11:00:00.000Z', + }, + ], + }), + ); + vi.stubGlobal('fetch', fetchMock); + + const { data } = await chatApi.listRooms(); + + const feedRoom = data[0] as SpotChatRoom; + const spotRoom = data[1] as SpotChatRoom; + expect(feedRoom.category).toBe('spot'); + expect(feedRoom.sourceFeedId).toBe('123'); + expect(feedRoom.spot.id).toBe('10'); + expect(spotRoom.category).toBe('spot'); + expect(spotRoom.sourceFeedId).toBeUndefined(); + expect(spotRoom.spot.id).toBe('456'); + }); +}); diff --git a/src/features/chat/api/chat-api.ts b/src/features/chat/api/chat-api.ts index 2e764ed..30267ab 100644 --- a/src/features/chat/api/chat-api.ts +++ b/src/features/chat/api/chat-api.ts @@ -39,7 +39,8 @@ type BackendChatBlock = Omit & { type BackendRoom = { id: number | string; - spotId?: string | null; + feedId?: number | string | null; + spotId?: number | string | null; type?: 'GROUP' | 'PERSONAL'; title?: string; subtitle?: string; @@ -105,7 +106,10 @@ function toChatBlock(block: BackendChatBlock): ChatBlock { function toChatRoom(room: BackendRoom): ChatRoom { const id = String(room.id); - const isSpotRoom = room.type === 'GROUP' || Boolean(room.spotId); + const feedId = room.feedId == null ? undefined : String(room.feedId); + const spotId = room.spotId == null ? undefined : String(room.spotId); + const isSpotRoom = + room.type === 'GROUP' || Boolean(feedId) || Boolean(spotId); const updatedAt = room.lastMessageAt ?? room.createdAt ?? new Date().toISOString(); @@ -122,9 +126,11 @@ function toChatRoom(room: BackendRoom): ChatRoom { description: room.lastMessagePreview ?? '백엔드 채팅방입니다.', metaLabel: '팀 채팅', updatedAt, + unreadCount: room.unreadCount ?? 0, messages: [], + sourceFeedId: feedId, spot: { - id: room.spotId ?? id, + id: spotId ?? id, type: 'REQUEST', status: 'OPEN', title: room.spotId ? `스팟 ${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..ef4f379 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,114 @@ 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('sorts each chat filter by the latest updated room first', () => { + mockRooms = [ + createPersonalRoom({ + id: 'personal-old', + title: '오래된 개인', + updatedAt: '2026-05-24T10:00:00.000Z', + }), + createPersonalRoom({ + id: 'personal-new', + title: '최신 개인', + updatedAt: '2026-05-24T13:00:00.000Z', + }), + createSpotRoom({ + id: 'spot-old', + title: '오래된 스팟', + updatedAt: '2026-05-24T09:00:00.000Z', + sourceFeedId: undefined, + spot: { id: 'spot-old' } as SpotChatRoom['spot'], + }), + createSpotRoom({ + id: 'spot-new', + title: '최신 스팟', + updatedAt: '2026-05-24T14:00:00.000Z', + sourceFeedId: undefined, + spot: { id: 'spot-new' } as SpotChatRoom['spot'], + }), + ]; + + render(); + + fireEvent.click(screen.getByRole('button', { name: '개인 채팅 필터' })); + const personalRows = screen.getAllByRole('button'); + expect( + personalRows.findIndex((row) => + row.textContent?.includes('최신 개인'), + ), + ).toBeLessThan( + personalRows.findIndex((row) => + row.textContent?.includes('오래된 개인'), + ), + ); + + fireEvent.click(screen.getByRole('button', { name: '스팟 채팅 필터' })); + const spotRows = screen.getAllByRole('button'); + expect( + spotRows.findIndex((row) => row.textContent?.includes('최신 스팟')), + ).toBeLessThan( + spotRows.findIndex((row) => + row.textContent?.includes('오래된 스팟'), + ), + ); }); 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..edd5a9a 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,12 @@ 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 byUpdatedAtDesc = (left: ChatRoom, right: ChatRoom) => + new Date(right.updatedAt).getTime() - + new Date(left.updatedAt).getTime(); const personal: PersonalChatRoom[] = []; const feed: SpotChatRoom[] = []; const spot: SpotChatRoom[] = []; @@ -172,9 +250,36 @@ 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].sort(byUpdatedAtDesc), + feedRooms: [...feed].sort(byUpdatedAtDesc), + spotRooms: [...spot].sort(byUpdatedAtDesc), + allRooms: [...personal, ...feed, ...spot].sort(byUpdatedAtDesc), + }; }, [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 +324,17 @@ export function ChatDrawer({ open, onClose }: ChatDrawerProps) { + +
-
- - - +
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..ec48a18 --- /dev/null +++ b/src/features/feed/model/feed-map.test.ts @@ -0,0 +1,102 @@ +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('normalizes string coordinates from backend coordinate payloads', () => { + const item = makeFeed({ + coordinate: { lat: '37.2636', lng: '127.0286' }, + } as Partial); + + expect(resolveFeedCoordinate(item)).toEqual({ + lat: 37.2636, + lng: 127.0286, + }); + }); + + 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..c379d68 100644 --- a/src/features/feed/ui/FeedBottomSheet.tsx +++ b/src/features/feed/ui/FeedBottomSheet.tsx @@ -1,20 +1,19 @@ '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'; import { AttractivenessMiniGauge } from './preference/AttractivenessMiniGauge'; -import { isSearchExcludedFeedItem, type FeedItem } from '../model/types'; +import { filterVisibleFeedItems } from '../model/feed-filter'; +import type { FeedItem } from '../model/types'; type FeedBottomSheetProps = { open: boolean; @@ -54,38 +53,14 @@ export function FeedBottomSheet({ const userPersona = useAuthStore((state) => state.userPersona); 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([]), []); - const filtered = 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 (normalizedQuery.length > 0) { - if (isSearchExcludedFeedItem(item)) return false; - - const haystack = [ - item.title, - item.description ?? '', - item.category ?? '', - item.location, - item.authorNickname, - ] - .join(' ') - .toLowerCase(); - if (!haystack.includes(normalizedQuery)) return false; - } - return true; + const filtered = filterVisibleFeedItems(feedItems, { + feedType, + categories, + searchQuery, }); return ( 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' && ( + + )}
); } diff --git a/src/features/onboarding/client/OnboardingPageClient.tsx b/src/features/onboarding/client/OnboardingPageClient.tsx index 8c73873..0402ceb 100644 --- a/src/features/onboarding/client/OnboardingPageClient.tsx +++ b/src/features/onboarding/client/OnboardingPageClient.tsx @@ -1,6 +1,6 @@ 'use client'; -// 3-step onboarding wizard (INTRO → SELECT → PREVIEW). Saves finalized persona to auth-store and routes to /map. +// Onboarding wizard (INTRO → SELECT → INSTALL → PREVIEW). Saves finalized persona to auth-store and routes to /map. import { useMemo } from 'react'; import { useRouter } from 'next/navigation'; @@ -11,6 +11,7 @@ import { useAuthStore } from '@/shared/model/auth-store'; import { ArchetypeSelector } from '../ui/ArchetypeSelector'; import { InterestTagPicker } from '../ui/InterestTagPicker'; import { PersonaPreview } from '../ui/PersonaPreview'; +import { PwaInstallGuide } from '../ui/PwaInstallGuide'; import { RoleSelector } from '../ui/RoleSelector'; import { WorldIntroSlide } from '../ui/WorldIntroSlide'; import { ONBOARDING_STEPS } from '../model/types'; @@ -154,6 +155,16 @@ export function OnboardingPageClient() { )} + {step === 'INSTALL' && ( + + + + )} + {step === 'PREVIEW' && ( { + it('detects iOS Safari style installs separately from Android and desktop', () => { + expect( + detectPwaInstallPlatform( + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Version/17.0 Mobile/15E148 Safari/604.1', + ), + ).toBe('ios-safari'); + expect( + detectPwaInstallPlatform( + 'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/120 Mobile Safari/537.36', + ), + ).toBe('android-chrome'); + expect( + detectPwaInstallPlatform( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/537.36 Chrome/120 Safari/537.36', + ), + ).toBe('desktop'); + expect( + detectPwaInstallPlatform( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 Version/17.0 Mobile/15E148 Safari/604.1', + ), + ).toBe('ios-safari'); + }); + + it('keeps manual install guide copy available when browser prompt is unavailable', () => { + const guide = getPwaInstallGuide('ios-safari'); + + expect(guide.title).toContain('iPhone Safari'); + expect(guide.steps).toEqual( + expect.arrayContaining(['홈 화면에 추가를 선택해요.']), + ); + }); + + it('exposes map, offer creation, and chat shortcuts for manifest and onboarding copy', () => { + expect(PWA_SHORTCUTS.map((shortcut) => shortcut.url)).toEqual([ + '/map', + '/post/offer', + '/chat', + ]); + }); + + it('treats display-mode standalone and iOS navigator standalone as installed', () => { + expect(isStandaloneDisplay('standalone')).toBe(true); + expect(isStandaloneDisplay('browser', true)).toBe(true); + expect(isStandaloneDisplay('browser', false)).toBe(false); + }); +}); diff --git a/src/features/onboarding/model/pwa-install.ts b/src/features/onboarding/model/pwa-install.ts new file mode 100644 index 0000000..5405f92 --- /dev/null +++ b/src/features/onboarding/model/pwa-install.ts @@ -0,0 +1,99 @@ +export type PwaInstallPlatform = + | 'ios-safari' + | 'android-chrome' + | 'desktop' + | 'generic'; + +export type PwaInstallGuide = { + platform: PwaInstallPlatform; + title: string; + steps: string[]; +}; + +export const PWA_SHORTCUTS = [ + { + label: '지도 바로 열기', + description: '현재 주변 스팟과 피드 카드덱을 바로 확인해요.', + url: '/map', + }, + { + label: '나눔 올리기', + description: '남는 자리나 자원을 빠르게 공유해요.', + url: '/post/offer', + }, + { + label: '채팅 확인', + description: '매칭된 팀/개인 채팅으로 곧장 이동해요.', + url: '/chat', + }, +] as const; + +export function detectPwaInstallPlatform( + userAgent: string, +): PwaInstallPlatform { + const normalized = userAgent.toLowerCase(); + const isIOS = + /iphone|ipad|ipod/.test(normalized) || + (normalized.includes('macintosh') && normalized.includes('mobile')); + const isAndroid = normalized.includes('android'); + const isChromium = /chrome|crios|edg|samsungbrowser/.test(normalized); + + if (isIOS) return 'ios-safari'; + if (isAndroid && isChromium) return 'android-chrome'; + if (/macintosh|windows|linux|cros/.test(normalized)) return 'desktop'; + return 'generic'; +} + +export function getPwaInstallGuide( + platform: PwaInstallPlatform, +): PwaInstallGuide { + switch (platform) { + case 'ios-safari': + return { + platform, + title: 'iPhone Safari에서 설치하기', + steps: [ + '하단 공유 버튼을 눌러요.', + '홈 화면에 추가를 선택해요.', + '추가를 누르면 Spot이 앱처럼 열려요.', + ], + }; + case 'android-chrome': + return { + platform, + title: 'Android Chrome에서 설치하기', + steps: [ + '주소창 또는 메뉴의 설치 버튼을 찾아요.', + '앱 설치를 누르고 확인해요.', + '홈 화면의 Spot 아이콘으로 다시 들어와요.', + ], + }; + case 'desktop': + return { + platform, + title: 'PC 브라우저에서 설치하기', + steps: [ + '주소창 오른쪽의 설치 아이콘을 눌러요.', + '설치를 확인하면 독립 창으로 열려요.', + '작업 표시줄이나 Dock에 고정해두면 빨라요.', + ], + }; + default: + return { + platform, + title: '브라우저에서 설치하기', + steps: [ + '브라우저 메뉴를 열어요.', + '앱 설치 또는 홈 화면에 추가를 선택해요.', + '설치 후 Spot 아이콘으로 바로 시작해요.', + ], + }; + } +} + +export function isStandaloneDisplay( + displayMode: string, + navigatorStandalone?: boolean, +) { + return displayMode === 'standalone' || Boolean(navigatorStandalone); +} diff --git a/src/features/onboarding/model/types.ts b/src/features/onboarding/model/types.ts index c551bfa..0cceab4 100644 --- a/src/features/onboarding/model/types.ts +++ b/src/features/onboarding/model/types.ts @@ -5,11 +5,12 @@ import type { UserPersonaRole, } from '@/entities/persona/types'; -export type OnboardingStep = 'INTRO' | 'SELECT' | 'PREVIEW'; +export type OnboardingStep = 'INTRO' | 'SELECT' | 'INSTALL' | 'PREVIEW'; export const ONBOARDING_STEPS: readonly OnboardingStep[] = [ 'INTRO', 'SELECT', + 'INSTALL', 'PREVIEW', ] as const; diff --git a/src/features/onboarding/ui/PwaInstallGuide.tsx b/src/features/onboarding/ui/PwaInstallGuide.tsx new file mode 100644 index 0000000..92afcfd --- /dev/null +++ b/src/features/onboarding/ui/PwaInstallGuide.tsx @@ -0,0 +1,118 @@ +'use client'; + +import { Button } from '@frontend/design-system'; +import { PWA_SHORTCUTS } from '../model/pwa-install'; +import { usePwaInstallPrompt } from './use-pwa-install-prompt'; + +const STATUS_MESSAGE: Record = { + accepted: + '설치가 시작됐어요. 완료되면 홈 화면의 Spot 아이콘으로 들어오면 돼요.', + dismissed: '괜찮아요. 나중에 브라우저 메뉴에서 다시 설치할 수 있어요.', + unavailable: + '지금 브라우저에서는 자동 설치 버튼이 보이지 않아요. 아래 수동 방법으로 진행해 주세요.', +}; + +export function PwaInstallGuide() { + const { + canPromptInstall, + guide, + installState, + isInstalled, + promptInstall, + } = usePwaInstallPrompt(); + + return ( +
+
+ + 홈 화면에 Spot 고정 + +

+ 앱처럼 열어두면 바로 지도부터 시작해요 +

+

+ 설치는 선택이에요. 그래도 한 번 추가해두면 주소 입력 없이 + 주변 피드, 작성, 채팅까지 더 빠르게 들어갈 수 있어요. +

+
+ +
+
+ + + {isInstalled ? ( +
+ 이미 설치된 상태예요. 이제 Spot을 앱처럼 사용할 수 + 있어요. +
+ ) : ( + + )} + + {installState !== 'idle' && installState !== 'prompting' && ( +

+ {STATUS_MESSAGE[installState]} +

+ )} +
+ +
+

+ {guide.title} +

+
    + {guide.steps.map((step, index) => ( +
  1. + + {index + 1} + + {step} +
  2. + ))} +
+
+ +
+ {PWA_SHORTCUTS.map((shortcut) => ( +
+

+ {shortcut.label} +

+

+ {shortcut.description} +

+
+ ))} +
+
+ ); +} diff --git a/src/features/onboarding/ui/use-pwa-install-prompt.ts b/src/features/onboarding/ui/use-pwa-install-prompt.ts new file mode 100644 index 0000000..f6b4fae --- /dev/null +++ b/src/features/onboarding/ui/use-pwa-install-prompt.ts @@ -0,0 +1,110 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + detectPwaInstallPlatform, + getPwaInstallGuide, + isStandaloneDisplay, +} from '../model/pwa-install'; + +type BeforeInstallPromptEvent = Event & { + prompt: () => Promise; + userChoice: Promise<{ + outcome: 'accepted' | 'dismissed'; + platform: string; + }>; +}; + +function getBrowserInstallState() { + if (typeof window === 'undefined') { + return { isInstalled: false, userAgent: '' }; + } + + const standalone = window.matchMedia('(display-mode: standalone)').matches; + const navigatorStandalone = + 'standalone' in window.navigator && + Boolean( + (window.navigator as Navigator & { standalone?: boolean }) + .standalone, + ); + + return { + isInstalled: isStandaloneDisplay( + standalone ? 'standalone' : 'browser', + navigatorStandalone, + ), + userAgent: window.navigator.userAgent, + }; +} + +export function usePwaInstallPrompt() { + const [installPrompt, setInstallPrompt] = + useState(null); + const [{ isInstalled, userAgent }, setBrowserInstallState] = useState( + getBrowserInstallState, + ); + const [installState, setInstallState] = useState< + 'idle' | 'prompting' | 'accepted' | 'dismissed' | 'unavailable' + >('idle'); + + useEffect(() => { + const onBeforeInstallPrompt = (event: Event) => { + event.preventDefault(); + setInstallPrompt(event as BeforeInstallPromptEvent); + setInstallState('idle'); + }; + + const onAppInstalled = () => { + setBrowserInstallState((state) => ({ + ...state, + isInstalled: true, + })); + setInstallPrompt(null); + setInstallState('accepted'); + }; + + window.addEventListener('beforeinstallprompt', onBeforeInstallPrompt); + window.addEventListener('appinstalled', onAppInstalled); + + return () => { + window.removeEventListener( + 'beforeinstallprompt', + onBeforeInstallPrompt, + ); + window.removeEventListener('appinstalled', onAppInstalled); + }; + }, []); + + const guide = useMemo( + () => getPwaInstallGuide(detectPwaInstallPlatform(userAgent)), + [userAgent], + ); + + const canPromptInstall = Boolean(installPrompt) && !isInstalled; + + const promptInstall = useCallback(async () => { + if (!installPrompt || isInstalled) { + setInstallState('unavailable'); + return; + } + + setInstallState('prompting'); + try { + await installPrompt.prompt(); + const choice = await installPrompt.userChoice; + setInstallState(choice.outcome); + } catch { + setInstallState('idle'); + } finally { + setInstallPrompt(null); + } + }, [installPrompt, isInstalled]); + + return { + canPromptInstall, + guide, + installState, + isInstalled, + promptInstall, + }; +} 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 ( +