Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added public/apple-touch-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/brand/spot-logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icons/icon-192x192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icons/icon-512x512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icons/maskable-icon-512x512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
42 changes: 38 additions & 4 deletions public/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" }]
}
]
}
21 changes: 21 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
48 changes: 48 additions & 0 deletions src/features/chat/api/chat-api.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof fetch>().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');
});
});
12 changes: 9 additions & 3 deletions src/features/chat/api/chat-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ type BackendChatBlock = Omit<ChatBlock, 'id'> & {

type BackendRoom = {
id: number | string;
spotId?: string | null;
feedId?: number | string | null;
spotId?: number | string | null;
type?: 'GROUP' | 'PERSONAL';
title?: string;
subtitle?: string;
Expand Down Expand Up @@ -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();

Expand All @@ -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}`,
Expand Down
1 change: 1 addition & 0 deletions src/features/chat/model/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ interface ChatRoomBase {
description: string;
metaLabel: string;
updatedAt: string;
unreadCount?: number;
messages: ChatMessage[];
}

Expand Down
158 changes: 152 additions & 6 deletions src/features/chat/ui/ChatDrawer.test.tsx
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -28,6 +34,47 @@ vi.mock('../model/use-main-chat-store', () => ({
selector({ rooms: mockRooms, loadRooms: mockLoadRooms }),
}));

function createPersonalRoom(
overrides: Partial<PersonalChatRoom>,
): 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>): 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();
Expand All @@ -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(<ChatDrawer open onClose={mockOnClose} />);

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(<ChatDrawer open onClose={mockOnClose} />);

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(<ChatDrawer open onClose={mockOnClose} />);

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', () => {
Expand Down
Loading
Loading