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 (
- {rooms.length === 0 ? (
-
- 아직 채팅이 없어요
-
- ) : (
-
- {visible.map((room) => (
-
onRoomClick(room)}
- />
- ))}
- {hasMore && (
+
+
+ {options.map((option) => {
+ const isSelected = option.value === selected;
+ 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) => (
+ -
+
+ {index + 1}
+
+ {step}
+
+ ))}
+
+
+
+
+ {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 (
+
+ );
+}
+
+export function PostAddButton({
+ className,
+ children,
+ ...props
+}: ButtonHTMLAttributes) {
+ return (
+
+ );
+}
+
+export function PostRemoveButton({
+ className,
+ children = '삭제',
+ ...props
+}: ButtonHTMLAttributes) {
+ return (
+
+ );
+}
+
+export function PostErrorMessage({ message }: { message: string }) {
+ return (
+
+ {message}
+
+ );
+}
diff --git a/src/features/post/ui/FormField.tsx b/src/features/post/ui/FormField.tsx
index c245e34..aed43ee 100644
--- a/src/features/post/ui/FormField.tsx
+++ b/src/features/post/ui/FormField.tsx
@@ -3,13 +3,26 @@ import type { ReactNode } from 'react';
interface FormFieldProps {
label: string;
required?: boolean;
+ labelSize?: 'display' | 'compact';
+ htmlFor?: string;
children: ReactNode;
}
-export function FormField({ label, required, children }: FormFieldProps) {
+export function FormField({
+ label,
+ required,
+ labelSize = 'display',
+ htmlFor,
+ children,
+}: FormFieldProps) {
+ const labelClass =
+ labelSize === 'display'
+ ? 'text-[1.375rem] leading-snug font-bold tracking-[-0.02em] text-gray-950'
+ : 'text-sm font-semibold text-gray-700';
+
return (
-
-