From 7785621c75c070811c8ba5f7c65a1e54093a51c9 Mon Sep 17 00:00:00 2001 From: seoJing Date: Sat, 6 Jun 2026 15:16:23 +0900 Subject: [PATCH 1/3] fix: keep ai feed markers visible --- src/features/feed/model/feed-filter.ts | 13 +++++++++--- src/features/feed/model/feed-map.test.ts | 27 ++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/features/feed/model/feed-filter.ts b/src/features/feed/model/feed-filter.ts index 0a21850..7b89d2a 100644 --- a/src/features/feed/model/feed-filter.ts +++ b/src/features/feed/model/feed-filter.ts @@ -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 ( @@ -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, diff --git a/src/features/feed/model/feed-map.test.ts b/src/features/feed/model/feed-map.test.ts index ec48a18..b2ccbc8 100644 --- a/src/features/feed/model/feed-map.test.ts +++ b/src/features/feed/model/feed-map.test.ts @@ -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(); From 479da7ed3211583d7ca405bb49e1933b9b006dda Mon Sep 17 00:00:00 2001 From: seoJing Date: Sat, 6 Jun 2026 15:34:48 +0900 Subject: [PATCH 2/3] feat: add my feed applications page --- src/app/(detail)/my/applications/page.tsx | 8 + .../ui/detail/FeedParticipationActions.tsx | 4 +- src/features/my/api/my-api.ts | 52 ++++++ src/features/my/client/MyPageClient.tsx | 5 + .../client/page/MyApplicationsPageClient.tsx | 176 ++++++++++++++++++ src/features/my/index.ts | 3 + src/features/my/model/use-my.ts | 23 +++ 7 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 src/app/(detail)/my/applications/page.tsx create mode 100644 src/features/my/client/page/MyApplicationsPageClient.tsx diff --git a/src/app/(detail)/my/applications/page.tsx b/src/app/(detail)/my/applications/page.tsx new file mode 100644 index 0000000..470e36c --- /dev/null +++ b/src/app/(detail)/my/applications/page.tsx @@ -0,0 +1,8 @@ +import type { Metadata } from 'next'; +import { MyApplicationsPageClient } from '@/features/my'; + +export const metadata: Metadata = { title: '신청한 피드' }; + +export default function MyApplicationsPage() { + return ; +} diff --git a/src/features/feed/ui/detail/FeedParticipationActions.tsx b/src/features/feed/ui/detail/FeedParticipationActions.tsx index 2f098bf..77a27da 100644 --- a/src/features/feed/ui/detail/FeedParticipationActions.tsx +++ b/src/features/feed/ui/detail/FeedParticipationActions.tsx @@ -285,7 +285,9 @@ export function FeedParticipationActions({ '참여 신청이 완료되었어요. 승인되면 팀 채팅에 참여할 수 있어요.', '', ); - toast.success('참여 신청이 완료되었어요.'); + toast.success( + '신청한 피드는 맵에서 숨겨져요. 마이페이지 > 신청한 피드에서 확인하거나 취소할 수 있어요.', + ); setSelectedRole(null); router.refresh(); } finally { diff --git a/src/features/my/api/my-api.ts b/src/features/my/api/my-api.ts index 8c318ca..e444731 100644 --- a/src/features/my/api/my-api.ts +++ b/src/features/my/api/my-api.ts @@ -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 { @@ -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 & { id: string | number; spotId?: string | number; @@ -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(endpoints.me.profile).then((data) => ({ @@ -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 => + clientApiFetch(endpoints.feeds.myApplication(feedItemId), { + method: 'DELETE', + }), + deleteUser: async (payload?: { password?: string }): Promise => clientApiFetch(endpoints.me.profile, { method: 'DELETE', diff --git a/src/features/my/client/MyPageClient.tsx b/src/features/my/client/MyPageClient.tsx index bfc4cc4..63cdaad 100644 --- a/src/features/my/client/MyPageClient.tsx +++ b/src/features/my/client/MyPageClient.tsx @@ -69,6 +69,11 @@ const MY_GROUPS: MyGroup[] = [ description: '스팟 활동, 서포터 관련 기록, 리뷰', unavailable: true, }, + { + href: '/my/applications', + label: '신청한 피드', + description: '신청 상태 확인, 상세 보기, 신청 취소', + }, { href: '/my/favorite', label: '찜한 게시글', diff --git a/src/features/my/client/page/MyApplicationsPageClient.tsx b/src/features/my/client/page/MyApplicationsPageClient.tsx new file mode 100644 index 0000000..4fb20fb --- /dev/null +++ b/src/features/my/client/page/MyApplicationsPageClient.tsx @@ -0,0 +1,176 @@ +'use client'; + +import Link from 'next/link'; +import { toast } from '@frontend/design-system'; +import { + useCancelMyFeedApplication, + useMyFeedApplications, +} from '../../model/use-my'; +import { EmptyState } from '@/shared/ui'; +import type { + MyFeedApplication, + MyFeedApplicationStatus, +} from '../../api/my-api'; +import { formatDate } from '../../model/my-page-helpers'; +import { MyActionButton } from '../../ui/my-page/MyFormControls'; +import { MyPageLayout, MyPageSection } from '../../ui/my-page/MyPageLayout'; +import { getErrorMessage } from '../../ui/my-page/my-page-client-utils'; + +const STATUS_COPY: Record< + MyFeedApplicationStatus, + { label: string; className: string; description: string } +> = { + APPLIED: { + label: '신청 대기', + className: 'bg-neutral-100 text-warning', + description: '호스트가 아직 신청을 확인 중이에요.', + }, + PENDING: { + label: '신청 대기', + className: 'bg-neutral-100 text-warning', + description: '호스트가 아직 신청을 확인 중이에요.', + }, + ACCEPTED: { + label: '승인됨', + className: 'bg-success/10 text-success', + description: + '승인된 신청이에요. 이후 채팅이나 스팟 흐름을 확인해 주세요.', + }, + REJECTED: { + label: '거절됨', + className: 'bg-muted text-muted-foreground', + description: '호스트가 이번 신청을 거절했어요.', + }, + CANCELLED: { + label: '취소됨', + className: 'bg-muted text-muted-foreground', + description: '내가 취소한 신청이에요.', + }, +}; + +function formatCurrency(value: number) { + return `${value.toLocaleString('ko-KR')}원`; +} + +function getRoleLabel(role: MyFeedApplication['appliedRole']) { + return role === 'SUPPORTER' ? '서포터' : '파트너'; +} + +function canCancel(status: MyFeedApplicationStatus) { + return status === 'APPLIED' || status === 'PENDING'; +} + +export function MyApplicationsPageClient() { + const applicationsQuery = useMyFeedApplications(); + const cancelApplicationMutation = useCancelMyFeedApplication(); + const applications = applicationsQuery.data?.data ?? []; + + const handleCancel = async (item: MyFeedApplication) => { + try { + await cancelApplicationMutation.mutateAsync(item.feedItemId); + toast.success('신청을 취소했어요.'); + } catch (error) { + toast.error(getErrorMessage(error, '신청을 취소하지 못했어요.')); + } + }; + + return ( + + + {applicationsQuery.isPending && applications.length === 0 ? ( +
+
+
+
+ ) : applicationsQuery.isError && applications.length === 0 ? ( + applicationsQuery.refetch(), + }} + /> + ) : applications.length === 0 ? ( + + ) : ( +
    + {applications.map((item) => { + const status = STATUS_COPY[item.status]; + const isCancelling = + cancelApplicationMutation.isPending && + cancelApplicationMutation.variables === + item.feedItemId; + + return ( +
  • +
    +
    +
    + + {item.feedTitle} + + + {status.label} + +
    +

    + {getRoleLabel(item.appliedRole)}{' '} + · 보증금{' '} + {formatCurrency(item.deposit)} · + 신청일{' '} + {formatDate(item.createdAt)} +

    +

    + {status.description} +

    +
    +
    + + 상세 보기 + + {canCancel(item.status) ? ( + + handleCancel(item) + } + disabled={isCancelling} + className="text-xs text-destructive" + > + {isCancelling + ? '취소 중' + : '신청 취소'} + + ) : null} +
    +
    +
  • + ); + })} +
+ )} + + + ); +} diff --git a/src/features/my/index.ts b/src/features/my/index.ts index 21d3260..1cc78d3 100644 --- a/src/features/my/index.ts +++ b/src/features/my/index.ts @@ -1,5 +1,6 @@ export { myApi } from './api/my-api'; export { MyPageClient } from './client/MyPageClient'; +export { MyApplicationsPageClient } from './client/page/MyApplicationsPageClient'; export { MyFavoritePageClient } from './client/page/MyFavoritePageClient'; export { MyHistoryPageClient } from './client/page/MyHistoryPageClient'; export { MyNotificationSettingsPageClient } from './client/page/MyNotificationSettingsPageClient'; @@ -12,6 +13,8 @@ export { myKeys, useMyProfile, useMyParticipations, + useMyFeedApplications, + useCancelMyFeedApplication, useNotificationSettings, useSupporterRegistration, useSupporterProfile, diff --git a/src/features/my/model/use-my.ts b/src/features/my/model/use-my.ts index 2c3fba6..f56ae4f 100644 --- a/src/features/my/model/use-my.ts +++ b/src/features/my/model/use-my.ts @@ -13,6 +13,7 @@ export const myKeys = { supporterProfile: ['my', 'supporter-profile'] as const, participations: (params?: object) => ['my', 'participations', params] as const, + feedApplications: ['my', 'feed-applications'] as const, favorites: (params?: object) => ['my', 'favorites', params] as const, recentViews: (params?: object) => ['my', 'recent-views', params] as const, supportActivitySummary: ['my', 'support-activity-summary'] as const, @@ -32,6 +33,28 @@ export function useMyParticipations(params?: { page?: number; size?: number }) { }); } +export function useMyFeedApplications() { + return useQuery({ + queryKey: myKeys.feedApplications, + queryFn: myApi.feedApplications, + }); +} + +export function useCancelMyFeedApplication() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (feedItemId: string) => + myApi.cancelFeedApplication(feedItemId), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: myKeys.feedApplications, + }); + queryClient.invalidateQueries({ queryKey: ['feed'] }); + queryClient.invalidateQueries({ queryKey: ['pay'] }); + }, + }); +} + export function useNotificationSettings() { return useQuery({ queryKey: myKeys.notificationSettings, From 1f1afb9918e3b506e7ee06c1229603ce5cb6700c Mon Sep 17 00:00:00 2001 From: seoJing Date: Sat, 6 Jun 2026 15:37:24 +0900 Subject: [PATCH 3/3] fix: enable marker card detail swipe --- .../map/model/feed-stack-gesture.test.ts | 31 ++++++++++++------- src/features/map/model/feed-stack-gesture.ts | 25 ++++++++++++++- src/features/map/ui/MapCardDeckOverlay.tsx | 13 ++++++++ 3 files changed, 56 insertions(+), 13 deletions(-) diff --git a/src/features/map/model/feed-stack-gesture.test.ts b/src/features/map/model/feed-stack-gesture.test.ts index 3ff94e4..6625ac2 100644 --- a/src/features/map/model/feed-stack-gesture.test.ts +++ b/src/features/map/model/feed-stack-gesture.test.ts @@ -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, diff --git a/src/features/map/model/feed-stack-gesture.ts b/src/features/map/model/feed-stack-gesture.ts index 70b9b41..4ebaf42 100644 --- a/src/features/map/model/feed-stack-gesture.ts +++ b/src/features/map/model/feed-stack-gesture.ts @@ -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; @@ -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; diff --git a/src/features/map/ui/MapCardDeckOverlay.tsx b/src/features/map/ui/MapCardDeckOverlay.tsx index ad5d670..5a05bf0 100644 --- a/src/features/map/ui/MapCardDeckOverlay.tsx +++ b/src/features/map/ui/MapCardDeckOverlay.tsx @@ -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'); }