From 197ff638d996f1da8864e9bb2d21f65206729f23 Mon Sep 17 00:00:00 2001 From: Jaewoong Choi Date: Sun, 31 May 2026 18:57:17 +0900 Subject: [PATCH 1/3] chore: migrate club API endpoints from v1 to v2 Co-Authored-By: Claude Opus 4.7 --- src/repositories/club.ts | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/src/repositories/club.ts b/src/repositories/club.ts index e9e9279..10ad5a3 100644 --- a/src/repositories/club.ts +++ b/src/repositories/club.ts @@ -133,29 +133,33 @@ export const getClubRepository = (): ClubRepository => ({ searchParams.append('min_activity_period', period) }) - const response = await apiConnector.get('/v2/clubs/search', searchParams, signal) + const response = await apiConnector.get( + '/v2/clubs/search', + searchParams, + signal, + ) return response }, listPopularClubs: async () => { - const response = await apiConnector.get('/v1/clubs/popular') + const response = await apiConnector.get('/v2/clubs/popular') return response }, listLatestClubs: async () => { - const response = await apiConnector.get('/v1/clubs/latest') + const response = await apiConnector.get('/v2/clubs/latest') return response }, listClubs: async req => { - const response = await apiConnector.get('/v1/clubs', { + const response = await apiConnector.get('/v2/clubs', { ...(req.category && { category: req.category }), }) return response }, getClub: async req => { - const club = await apiConnector.get(`/v1/clubs/${req.uuid}`) + const club = await apiConnector.get(`/v2/clubs/${req.uuid}`) if (!club) { throw new Error('Club not found') @@ -163,33 +167,33 @@ export const getClubRepository = (): ClubRepository => ({ return club }, listManageClubs: async () => { - const response = await apiConnector.get('/v1/managers/me/clubs') + const response = await apiConnector.get('/v2/managers/me/clubs') return response }, listClubRankings: async req => { const response = await apiConnector.get( - `/v1/clubs/rankings?topk=${req.topK ?? 5}`, + `/v2/clubs/rankings?topk=${req.topK ?? 5}`, ) return response }, requestClubManager: async req => { - await apiConnector.post('/v1/managers/me/clubs', req) + await apiConnector.post('/v2/managers/me/clubs', req) }, listSavedClubs: async () => { - const response = await apiConnector.get('/v1/users/me/clubs/saved') + const response = await apiConnector.get('/v2/users/me/clubs/saved') return response }, createSavedClub: async req => { - await apiConnector.post(`/v1/clubs/${req.clubId}/saved`) + await apiConnector.post(`/v2/clubs/${req.clubId}/saved`) }, removeSavedClub: async req => { - await apiConnector.delete(`/v1/clubs/${req.clubId}/saved`) + await apiConnector.delete(`/v2/clubs/${req.clubId}/saved`) }, listMyClubs: async () => { - const response = await apiConnector.get('/v1/users/me/clubs') + const response = await apiConnector.get('/v2/users/me/clubs') return response }, From 823bcd8993956a6adb6ff05cf2a42382124ebb18 Mon Sep 17 00:00:00 2001 From: Jaewoong Choi Date: Sun, 31 May 2026 18:57:23 +0900 Subject: [PATCH 2/3] feat: add club save toggle with optimistic updates Add a heart button to each club card that saves/unsaves a club with debounced, optimistic React Query cache updates and rollback on failure. Co-Authored-By: Claude Opus 4.7 --- src/assets/icons/heart-fill.png | Bin 0 -> 900 bytes src/assets/icons/heart.png | Bin 0 -> 1323 bytes src/entities/club.ts | 1 + .../club/components/ClubList/ClubCard.tsx | 53 ++++++- .../club/components/ClubList/ClubList.tsx | 15 +- .../screens/SavedClubListScreen/index.tsx | 3 +- src/shared/hooks/useSaveClub.ts | 142 ++++++++++++++++++ 7 files changed, 196 insertions(+), 18 deletions(-) create mode 100644 src/assets/icons/heart-fill.png create mode 100644 src/assets/icons/heart.png create mode 100644 src/shared/hooks/useSaveClub.ts diff --git a/src/assets/icons/heart-fill.png b/src/assets/icons/heart-fill.png new file mode 100644 index 0000000000000000000000000000000000000000..e23c448631ad62fdaaad46ddbf3bd2c3856321e1 GIT binary patch literal 900 zcmV-~1AF|5P)JKj zBPWzK;l8K3P?+$ri(<@pbvOCPXfXlqh%k&^N`dtrdA(KE#G|6*;co&=_)QH_1qEmH zM6L|)KdDIxF-cNiR-9B(-+QX_S7DCO9csX5Asc>J2le2;BHVuUXq|TRK$Hjy6qXg= z=2=h{`r|o0Ca?b|$|OO12p^OCY%hufg3bQFM13xI7Ifq#T*vpAwZFNGa5fA>dzHeE z_%&^*Fl;YjGBG#QmSQX2!E|<>rlD{z{X!cEttFP~qAdhFEf%}p4cY?XgM5gkY_xGPF{^3W1k z%tC)kS`qo8$%uyET%eJ$j(%*m2N(`(@S+Nq7PSSYFdsJ>Xi-`rm!DuNgjwpzm#sB4 zcyGD!U-qKJT3ji0VH)bN26#{+<3r<;Cw5^5{%Aq(k{8o*zkxAs$S2*j%Lk&wlt#Fr zI$nA{T1#g)oZ?aoG71k|pFdjTKiT#8)>CtC+w^cjNXuxvpzBj}PM)Wpcl&FN%faWa z$!*r8JpHLlDM$NZ3Cz!$m?=uQ0AaZW3AscGFDR^#AR%{+LNCgTpLvfJ79{tikCK!wk?|XJ&_w1TnL-6YLTO7yguN|k zdfBLe{_X@}F!rdjYgZN})DRg<1%zYNA}XR(zQ}pp)`FAu4Vfa;DoS8sedOpLhYjd2 zY@Hcurgl++c1J!;v$)S`_xWGKibM9GE_XXv9}SYUS{Mvm4hD@@uNoC4gjS2rKGUYG a6XzdAV<)AM+%S0n0000)#a5dPY{D&E`W-N2lHa01K)$m9e}PLO0n zk!;3nAjO8s2?!_1Bo|Bu>y!R|`-qbKiN(TQo$Ankv z0DYj49$nH)`S|*&nyf&^g?F)v@RE8nQrkZi&SBJH0Ax($ng`p)Fv#u^cAwAa%J;HD zN(ki&AL>?o^xh-y7wQOUXFxc; z1*RbXvK{@YtwN+&=c3rs3d?1c{?j66CrK#XeC(-a?iZ; zAk1!ny|(g<%+!!8%UUeXqoR#1NuBJdcf3PmPlrS9aE2A&orq#1zoZl^phlDCM;G+O zK9CtaF}qYApN;W&MlQ^x~dx&i^Owb$`zq8&@NL53jyb@t-9LN)~GnothSAszI5aT%QnRpRwFq9Kb2#0Sb zXwcqIE^AH)Nsjc(O{@{DdSg^oZ)Wdjnv~pT-HyCl`$lg!3g$99@w{5btD2N%$myqI z3w=5(yu*fpd{=wISuwO*9=^O=2UIk09^9OF2IA3SCoZRS=C>atutGl?u$Ih#aa}y8T_P*F}&!dL%{Y7%~kATO1 z3tyIt^Af)H6;iU5x&P*yoIF);@teV#BPutpyinoPGt~&2!k>^?=Em0zO^C-sbq=RI zhoA$it$Lh{)H$bqM;@VTCl(?kC8OgDdQ{i9_C9s9u&-)Ln2<^Z18YKXhZZ^a-KPF+ z>QljVG#`Z17nd|x>dTB5BxDknS(A)LQf8nqF^=+Ys9Rp#2jjNI5sf{sTN5&KYKPha zLdQXUve|ae1NE!UvRmgUv``lAwJ{;egeA|7j?jI$?@)vUof0o1{6>fod^z@568Bvk z2z%H4Jh1!X#hJ{Q$d)QyjUk3K@Cz_&X@wWZfiSu}V?L1uoE;QJol)5Rd9EQ0Z3 z9Xi&N+h9;q0-Y48tET@NxK};7LH6)yQJtT&%idd8$!IO4EZkkw|42o;mORB8u~t$7 hvupM|n*NT0-cPyfHeAG}U_k%?002ovPDHLkV1mg2X6^t0 literal 0 HcmV?d00001 diff --git a/src/entities/club.ts b/src/entities/club.ts index 4be4dcc..54be2be 100644 --- a/src/entities/club.ts +++ b/src/entities/club.ts @@ -24,6 +24,7 @@ export type Club = { reviewKeywords: ReviewKeyword[] // v240218 totalReviews: number + isSaved: boolean } type ReviewKeyword = { diff --git a/src/features/club/components/ClubList/ClubCard.tsx b/src/features/club/components/ClubList/ClubCard.tsx index f4cba39..6620460 100644 --- a/src/features/club/components/ClubList/ClubCard.tsx +++ b/src/features/club/components/ClubList/ClubCard.tsx @@ -1,4 +1,5 @@ import { Image, StyleSheet, Text, View } from 'react-native' +import { Pressable } from 'react-native-gesture-handler' import { Colors } from '@/shared/constants/colors' import { typography } from '@/shared/constants/typography' @@ -6,23 +7,26 @@ import { Category } from '@/entities/category' import { CategoryMap } from '@/shared/constants/category' import { Club } from '@/entities/club' import { ms, s, vs } from '@/shared/utils/scale' +import useSaveClub from '@/shared/hooks/useSaveClub' type Props = { club: Club category?: Category['name'] + onPress?: () => void } -const ClubCard = ({ club, category }: Props) => { +const ClubCard = ({ club, category, onPress }: Props) => { const categoryDetail = category ? CategoryMap[category] : undefined const borderColor = categoryDetail ? categoryDetail.themeColor : Colors.BUTTON_SELECTED const backgroundColor = categoryDetail ? categoryDetail.backgroundColor : Colors.POINTCOLOR_10 - return ( - - + const { isSaved, handleToggle } = useSaveClub(club) + + const cardInner = ( + <> + - {club.name} @@ -59,6 +63,32 @@ const ClubCard = ({ club, category }: Props) => { )} + + ) + + return ( + + {onPress ? ( + [styles.cardContent, { opacity: pressed ? 0.5 : 1 }]} + onPress={onPress}> + {cardInner} + + ) : ( + {cardInner} + )} + [styles.heartButton, { opacity: pressed ? 0.5 : 1 }]}> + + ) } @@ -66,10 +96,13 @@ const ClubCard = ({ club, category }: Props) => { const styles = StyleSheet.create({ container: { flexDirection: 'row', - justifyContent: 'space-between', width: '100%', height: s(90), }, + cardContent: { + flex: 1, + flexDirection: 'row', + }, imageWrapper: { justifyContent: 'center', alignItems: 'center', @@ -123,6 +156,14 @@ const styles = StyleSheet.create({ color: Colors.BODYTEXT_SUB, flexShrink: 1, }, + heartButton: { + alignSelf: 'flex-start', + marginLeft: s(4), + }, + heartIcon: { + width: ms(20), + height: ms(20), + }, }) export default ClubCard diff --git a/src/features/club/components/ClubList/ClubList.tsx b/src/features/club/components/ClubList/ClubList.tsx index 0ee1909..787448c 100644 --- a/src/features/club/components/ClubList/ClubList.tsx +++ b/src/features/club/components/ClubList/ClubList.tsx @@ -1,7 +1,7 @@ import { Category } from '@/entities/category' import { Club } from '@/entities/club' import { Image, StyleSheet, View, Text, useWindowDimensions } from 'react-native' -import { FlatList, Pressable } from 'react-native-gesture-handler' +import { FlatList } from 'react-native-gesture-handler' import ClubCard from './ClubCard' import ClubListSkeleton from './ClubListSkeleton' import { Colors } from '@/shared/constants/colors' @@ -38,17 +38,10 @@ const ClubList = ({ clubs, category, openDetailPage, emptyPlaceholder, isLoading style={styles.list} contentContainerStyle={styles.listContent} renderItem={({ item }) => ( - ({ - width, - paddingHorizontal: s(20), - opacity: pressed ? 0.5 : 1, - })} - onPress={() => openDetailPage(item)}> - - + + openDetailPage(item)} /> + )} - removeClippedSubviews={true} initialNumToRender={6} maxToRenderPerBatch={1} updateCellsBatchingPeriod={100} diff --git a/src/features/club/screens/SavedClubListScreen/index.tsx b/src/features/club/screens/SavedClubListScreen/index.tsx index 069a8f4..b394751 100644 --- a/src/features/club/screens/SavedClubListScreen/index.tsx +++ b/src/features/club/screens/SavedClubListScreen/index.tsx @@ -51,6 +51,7 @@ const useSavedClubs = () => { const { clubService } = useContext(serviceContext) return useQuery(['savedClubs'], () => clubService.listSavedClubs(), { - select: data => data.clubs, + // 저장 목록의 동아리는 정의상 모두 저장 상태이므로 하트가 항상 채워지도록 보정 + select: data => data.clubs.map(club => ({ ...club, isSaved: true })), }) } diff --git a/src/shared/hooks/useSaveClub.ts b/src/shared/hooks/useSaveClub.ts new file mode 100644 index 0000000..9420471 --- /dev/null +++ b/src/shared/hooks/useSaveClub.ts @@ -0,0 +1,142 @@ +import { useCallback, useContext, useEffect, useRef, useState } from 'react' +import { useQueryClient } from '@tanstack/react-query' +import Toast from 'react-native-toast-message' +import { Club } from '@/entities/club' +import { useProfile } from '@/shared/contexts/profileContext' +import { useLoginBottomSheet } from '@/shared/contexts/loginBottomSheetContext' +import { serviceContext } from '@/shared/contexts/serviceContext' + +const useSaveClub = (club: Club | undefined) => { + const { user } = useProfile() + const { openBottomSheet } = useLoginBottomSheet() + const { clubService } = useContext(serviceContext) + const queryClient = useQueryClient() + + const initialSaved = user ? (club?.isSaved ?? false) : false + const [localIsSaved, setLocalIsSaved] = useState(initialSaved) + const localSavedRef = useRef(initialSaved) + const debounceRef = useRef | null>(null) + const hasPendingRef = useRef(false) + const serverIsSavedRef = useRef(initialSaved) + const pendingShouldSaveRef = useRef(null) + const callApiRef = useRef<(shouldSave: boolean) => Promise>() + + // state와 ref를 항상 함께 갱신해 같은 렌더 틱 내 연속 토글에서도 최신 값을 보장 + const applyLocal = useCallback((value: boolean) => { + localSavedRef.current = value + setLocalIsSaved(value) + }, []) + + useEffect(() => { + if (!hasPendingRef.current) { + const serverValue = user ? (club?.isSaved ?? false) : false + applyLocal(serverValue) + serverIsSavedRef.current = serverValue + } + }, [club?.isSaved, user, applyLocal]) + + // unmount 시 pending debounce가 있으면 취소 대신 즉시 API 호출 + useEffect( + () => () => { + if (debounceRef.current !== null && pendingShouldSaveRef.current !== null) { + clearTimeout(debounceRef.current) + callApiRef.current?.(pendingShouldSaveRef.current) + } + }, + [], + ) + + const updateClubsCache = useCallback( + (shouldSave: boolean) => { + if (!club) return + const updater = (old: { clubs: Club[]; totalSize: number } | undefined) => { + if (!old || !old.clubs) return old + return { + ...old, + clubs: old.clubs.map(c => (c.uuid === club.uuid ? { ...c, isSaved: shouldSave } : c)), + } + } + + queryClient.setQueriesData(['clubs'], updater) + queryClient.setQueriesData(['searchClubs'], updater) + queryClient.setQueriesData( + ['savedClubs'], + (old: { clubs: Club[]; totalSize: number } | undefined) => { + if (!old) return old + const alreadyIn = old.clubs.some(c => c.uuid === club.uuid) + if (shouldSave) { + const clubs = alreadyIn + ? old.clubs.map(c => (c.uuid === club.uuid ? { ...c, isSaved: true } : c)) + : [...old.clubs, { ...club, isSaved: true }] + return { ...old, clubs, totalSize: alreadyIn ? old.totalSize : old.totalSize + 1 } + } + return { + ...old, + clubs: old.clubs.filter(c => c.uuid !== club.uuid), + totalSize: alreadyIn ? old.totalSize - 1 : old.totalSize, + } + }, + ) + }, + [club, queryClient], + ) + + const callApi = useCallback( + async (shouldSave: boolean) => { + if (!club) return + // 서버 상태와 동일하면(연속 토글로 원상복귀 등) 불필요한 요청 skip + if (shouldSave === serverIsSavedRef.current) { + hasPendingRef.current = false + pendingShouldSaveRef.current = null + return + } + try { + if (shouldSave) { + await clubService.createSavedClub({ clubId: club.uuid }) + } else { + await clubService.removeSavedClub({ clubId: club.uuid }) + } + serverIsSavedRef.current = shouldSave + // 로그인 직후 invalidate 리페치가 옵티미스틱 값을 덮어쓰는 레이스 방지를 위해 확정값 재기록 + updateClubsCache(shouldSave) + } catch { + const serverValue = serverIsSavedRef.current + applyLocal(serverValue) + updateClubsCache(serverValue) + Toast.show({ type: 'info', text1: '저장에 실패했어요.' }) + } finally { + hasPendingRef.current = false + pendingShouldSaveRef.current = null + } + }, + [club, clubService, updateClubsCache, applyLocal], + ) + + // callApiRef를 항상 최신 callApi로 유지 (unmount cleanup에서 stale closure 방지) + useEffect(() => { + callApiRef.current = callApi + }, [callApi]) + + const handleToggle = useCallback(() => { + if (!user) { + openBottomSheet() + return + } + + const next = !localSavedRef.current + applyLocal(next) + updateClubsCache(next) + hasPendingRef.current = true + pendingShouldSaveRef.current = next + + if (debounceRef.current) clearTimeout(debounceRef.current) + debounceRef.current = setTimeout(() => { + debounceRef.current = null + callApi(next) + }, 300) + }, [user, openBottomSheet, callApi, updateClubsCache, applyLocal]) + + return { isSaved: localIsSaved, handleToggle } +} + +export default useSaveClub From 688167b6b65acd15dfc690d8a9f758a4babd40d8 Mon Sep 17 00:00:00 2001 From: Jaewoong Choi Date: Sun, 31 May 2026 18:57:29 +0900 Subject: [PATCH 3/3] fix: refresh club queries on login and clear stale login callback Invalidate clubs, searchClubs, and savedClubs queries on login success so saved state reflects the logged-in user, and reset the onSuccess ref when the login sheet is dismissed to avoid firing a stale callback later. Co-Authored-By: Claude Opus 4.7 --- src/shared/components/LoginView.tsx | 3 +++ src/shared/contexts/loginBottomSheetContext.tsx | 2 ++ 2 files changed, 5 insertions(+) diff --git a/src/shared/components/LoginView.tsx b/src/shared/components/LoginView.tsx index 7ef38d4..e861086 100644 --- a/src/shared/components/LoginView.tsx +++ b/src/shared/components/LoginView.tsx @@ -36,6 +36,9 @@ const LoginView = ({ closeBottomSheet, onSuccess }: Props) => { onSuccess?.() queryClient.invalidateQueries(['manageClubs']) queryClient.invalidateQueries(['recentSearches']) + queryClient.invalidateQueries(['clubs'], { refetchType: 'all' }) + queryClient.invalidateQueries(['searchClubs'], { refetchType: 'all' }) + queryClient.invalidateQueries(['savedClubs'], { refetchType: 'all' }) Toast.show({ type: 'info', text1: '로그인 되었어요!' }) } diff --git a/src/shared/contexts/loginBottomSheetContext.tsx b/src/shared/contexts/loginBottomSheetContext.tsx index 53159c1..6e2250c 100644 --- a/src/shared/contexts/loginBottomSheetContext.tsx +++ b/src/shared/contexts/loginBottomSheetContext.tsx @@ -83,6 +83,8 @@ export const LoginBottomSheetProvider = ({ children }: Props) => { snapPoints={[Platform.OS === 'ios' ? vs(310) : vs(260)]} onDismiss={() => { isBottomSheetOpenRef.current = false + // 로그인 없이 닫힌 경우 stale 콜백이 다음 로그인에 잘못 실행되지 않도록 정리 + onSuccessRef.current = undefined }} backdropComponent={renderBackdrop}>