Skip to content
Merged

Qa #19

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
8 changes: 8 additions & 0 deletions src/app/(detail)/my/applications/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { Metadata } from 'next';
import { MyApplicationsPageClient } from '@/features/my';

export const metadata: Metadata = { title: '신청한 피드' };

export default function MyApplicationsPage() {
return <MyApplicationsPageClient />;
}
13 changes: 10 additions & 3 deletions src/features/feed/model/feed-filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,17 @@ export function filterVisibleFeedItems(
const q = searchQuery.trim().toLowerCase();

return feedItems.filter((item) => {
if (onlyOpenFeeds && (item.status !== 'OPEN' || item.spotId)) {
const isRealFeed = item.isAi !== true;

if (
isRealFeed &&
onlyOpenFeeds &&
(item.status !== 'OPEN' || item.spotId)
) {
return false;
}
if (excludeApplied && isSearchExcludedFeedItem(item)) return false;
if (isRealFeed && excludeApplied && isSearchExcludedFeedItem(item))
return false;
if (feedType === 'offer' && item.type !== 'OFFER') return false;
if (feedType === 'request' && item.type !== 'REQUEST') return false;
if (
Expand All @@ -37,7 +44,7 @@ export function filterVisibleFeedItems(
return false;
}
if (q.length > 0) {
if (isSearchExcludedFeedItem(item)) return false;
if (isRealFeed && isSearchExcludedFeedItem(item)) return false;

const haystack = [
item.title,
Expand Down
27 changes: 27 additions & 0 deletions src/features/feed/model/feed-map.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,33 @@ describe('feed map helpers', () => {
).toEqual(['offer']);
});

it('keeps AI feeds visible on the map even when real-feed cleanup filters are enabled', () => {
const feeds = [
makeFeed({
id: 'converted-real',
status: 'MATCHED',
spotId: 'spot-1',
}),
makeFeed({
id: 'ai-pd',
isAi: true,
status: 'MATCHED',
spotId: 'synthetic-spot',
myApplicationStatus: 'ACCEPTED',
}),
];

expect(
filterVisibleFeedItems(feeds, {
feedType: 'all',
categories: [],
searchQuery: '',
excludeApplied: true,
onlyOpenFeeds: true,
}).map((item) => item.id),
).toEqual(['ai-pd']);
});

it('maps map layer state to backend isAi feed list filtering', () => {
expect(getFeedListIsAiParamByLayer('real')).toBe(false);
expect(getFeedListIsAiParamByLayer('mixed')).toBeUndefined();
Expand Down
4 changes: 3 additions & 1 deletion src/features/feed/ui/detail/FeedParticipationActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,9 @@ export function FeedParticipationActions({
'참여 신청이 완료되었어요. 승인되면 팀 채팅에 참여할 수 있어요.',
'',
);
toast.success('참여 신청이 완료되었어요.');
toast.success(
'신청한 피드는 맵에서 숨겨져요. 마이페이지 > 신청한 피드에서 확인하거나 취소할 수 있어요.',
);
setSelectedRole(null);
router.refresh();
} finally {
Expand Down
31 changes: 19 additions & 12 deletions src/features/map/model/feed-stack-gesture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,47 +5,54 @@ describe('resolveFeedStackGesture', () => {
it('snaps back to center when horizontal drag is below the side threshold', () => {
expect(
resolveFeedStackGesture({
dx: 96,
dx: 32,
dy: 10,
}),
).toBe('center');
});

it('keeps both side swipes centered instead of opening detail', () => {
it('uses both side swipes to open feed detail', () => {
expect(
resolveFeedStackGesture({
dx: -150,
dx: -96,
dy: 12,
}),
).toBe('center');
).toBe('detail-left');

expect(
resolveFeedStackGesture({
dx: 150,
dx: 96,
dy: 12,
}),
).toBe('center');
).toBe('detail-right');
});

it('keeps fast side flicks centered instead of opening detail', () => {
it('lets fast side flicks open detail even with shorter travel', () => {
expect(
resolveFeedStackGesture({
dx: -64,
dx: -42,
dy: 8,
velocityX: -820,
}),
).toBe('center');
).toBe('detail-left');

expect(
resolveFeedStackGesture({
dx: 64,
dx: 42,
dy: 8,
velocityX: 820,
}),
).toBe('center');
).toBe('detail-right');
});

it('requires vertical dominance before next actions', () => {
it('requires axis dominance before side or next actions', () => {
expect(
resolveFeedStackGesture({
dx: 96,
dy: 92,
}),
).toBe('center');

expect(
resolveFeedStackGesture({
dx: 150,
Expand Down
25 changes: 24 additions & 1 deletion src/features/map/model/feed-stack-gesture.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
export type FeedStackGestureAction = 'center' | 'next';
export type FeedStackGestureAction =
| 'center'
| 'next'
| 'detail-left'
| 'detail-right';

const SWIPE_SIDE_THRESHOLD = 50;
const SWIPE_SIDE_FLICK_THRESHOLD = 36;
const SWIPE_SIDE_VELOCITY_THRESHOLD = 650;
const SWIPE_DOWN_THRESHOLD = 72;
const SWIPE_AXIS_DOMINANCE_RATIO = 1.45;
const SWIPE_SIDE_AXIS_DOMINANCE_RATIO = 1.2;

type ResolveFeedStackGestureInput = {
dx: number;
Expand All @@ -12,8 +20,23 @@ type ResolveFeedStackGestureInput = {
export function resolveFeedStackGesture({
dx,
dy,
velocityX = 0,
}: ResolveFeedStackGestureInput): FeedStackGestureAction {
const absX = Math.abs(dx);
const absY = Math.abs(dy);
const absVelocityX = Math.abs(velocityX);

const isSideSwipe =
absX >= SWIPE_SIDE_THRESHOLD &&
absX >= absY * SWIPE_SIDE_AXIS_DOMINANCE_RATIO;
const isFastSideFlick =
absX >= SWIPE_SIDE_FLICK_THRESHOLD &&
absVelocityX >= SWIPE_SIDE_VELOCITY_THRESHOLD &&
absX >= absY * SWIPE_SIDE_AXIS_DOMINANCE_RATIO;

if (isSideSwipe || isFastSideFlick) {
return dx < 0 ? 'detail-left' : 'detail-right';
}

const isDownSwipe =
dy >= SWIPE_DOWN_THRESHOLD && dy >= absX * SWIPE_AXIS_DOMINANCE_RATIO;
Expand Down
13 changes: 13 additions & 0 deletions src/features/map/ui/MapCardDeckOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,21 @@ export function MapCardDeckOverlay({
const gesture = resolveFeedStackGesture({
dx: info.offset.x,
dy: info.offset.y,
velocityX: info.velocity.x,
});

if (gesture === 'detail-left' || gesture === 'detail-right') {
if (!topItem.onDetailAction) return;
setExitOverride({
id: topItem.id,
dir: gesture === 'detail-left' ? 'left' : 'right',
});
requestAnimationFrame(() => {
topItem.onDetailAction?.();
});
return;
}

if (gesture === 'next') dismissTop('down');
}

Expand Down
52 changes: 52 additions & 0 deletions src/features/my/api/my-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
} from '@/entities/user/types';
import type { PagedResponse } from '@/entities/spot/types';
import type { FeedItem } from '@/features/feed/model/types';
import type { FeedApplicationRole } from '@/features/feed/model/types';
import { clientApiFetch } from '@/lib/client-api';
import { endpoints } from '@/lib/endpoint';
import {
Expand All @@ -37,6 +38,32 @@ type BackendParticipation = {
joinedAt: string;
};

export type MyFeedApplicationStatus =
| 'APPLIED'
| 'PENDING'
| 'ACCEPTED'
| 'REJECTED'
| 'CANCELLED';

export type MyFeedApplication = {
applicationId: string;
feedItemId: string;
feedTitle: string;
status: MyFeedApplicationStatus;
appliedRole: FeedApplicationRole;
deposit: number;
createdAt: string;
};

type BackendMyFeedApplication = Omit<
MyFeedApplication,
'applicationId' | 'feedItemId' | 'deposit'
> & {
applicationId: string | number;
feedItemId: string | number;
deposit?: number | null;
};

type BackendInvolvedFeedItem = Omit<FeedItem, 'id' | 'spotId' | 'isAi'> & {
id: string | number;
spotId?: string | number;
Expand Down Expand Up @@ -64,6 +91,17 @@ function toParticipation(item: BackendParticipation): Participation {
};
}

function toMyFeedApplication(
item: BackendMyFeedApplication,
): MyFeedApplication {
return {
...item,
applicationId: String(item.applicationId),
feedItemId: String(item.feedItemId),
deposit: item.deposit ?? 0,
};
}

export const myApi = {
profile: async (): Promise<{ data: UserProfile }> =>
clientApiFetch<UserProfile>(endpoints.me.profile).then((data) => ({
Expand Down Expand Up @@ -112,6 +150,20 @@ export const myApi = {
endpoints.me.involvedFeeds,
).then((items) => ({ data: (items ?? []).map(toInvolvedFeedItem) })),

feedApplications: async (): Promise<{ data: MyFeedApplication[] }> =>
clientApiFetch<
BackendMyFeedApplication[] | { data?: BackendMyFeedApplication[] }
>(endpoints.me.feedApplications).then((payload) => ({
data: (Array.isArray(payload) ? payload : (payload.data ?? [])).map(
toMyFeedApplication,
),
})),

cancelFeedApplication: async (feedItemId: string): Promise<void> =>
clientApiFetch<void>(endpoints.feeds.myApplication(feedItemId), {
method: 'DELETE',
}),

deleteUser: async (payload?: { password?: string }): Promise<void> =>
clientApiFetch<void>(endpoints.me.profile, {
method: 'DELETE',
Expand Down
5 changes: 5 additions & 0 deletions src/features/my/client/MyPageClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ const MY_GROUPS: MyGroup[] = [
description: '스팟 활동, 서포터 관련 기록, 리뷰',
unavailable: true,
},
{
href: '/my/applications',
label: '신청한 피드',
description: '신청 상태 확인, 상세 보기, 신청 취소',
},
{
href: '/my/favorite',
label: '찜한 게시글',
Expand Down
Loading
Loading