diff --git a/src/assets/icons/heart-fill.png b/src/assets/icons/heart-fill.png new file mode 100644 index 00000000..e23c4486 Binary files /dev/null and b/src/assets/icons/heart-fill.png differ diff --git a/src/assets/icons/heart.png b/src/assets/icons/heart.png new file mode 100644 index 00000000..abbe1d22 Binary files /dev/null and b/src/assets/icons/heart.png differ diff --git a/src/entities/club.ts b/src/entities/club.ts index 4be4dcc9..54be2be2 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 f4cba395..6620460b 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 0ee19091..787448cf 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 069a8f4a..b3947513 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/repositories/club.ts b/src/repositories/club.ts index e9e92790..10ad5a32 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 }, diff --git a/src/shared/components/LoginView.tsx b/src/shared/components/LoginView.tsx index 7ef38d4c..e8610867 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 53159c1e..6e2250ca 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}> diff --git a/src/shared/hooks/useSaveClub.ts b/src/shared/hooks/useSaveClub.ts new file mode 100644 index 00000000..94204714 --- /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