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 src/assets/icons/heart-fill.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 src/assets/icons/heart.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/entities/club.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export type Club = {
reviewKeywords: ReviewKeyword[]
// v240218
totalReviews: number
isSaved: boolean
}

type ReviewKeyword = {
Expand Down
53 changes: 47 additions & 6 deletions src/features/club/components/ClubList/ClubCard.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,32 @@
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'
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 (
<View style={styles.container}>
<View style={[styles.imageWrapper, { borderColor: borderColor }]}>
const { isSaved, handleToggle } = useSaveClub(club)

const cardInner = (
<>
<View style={[styles.imageWrapper, { borderColor }]}>
<Image style={styles.image} resizeMode="contain" source={{ uri: club.imageUri }} />
</View>

<View style={styles.contentWrapper}>
<View style={styles.textGroup}>
<Text style={styles.title}>{club.name}</Text>
Expand Down Expand Up @@ -59,17 +63,46 @@ const ClubCard = ({ club, category }: Props) => {
)}
</View>
</View>
</>
)

return (
<View style={styles.container}>
{onPress ? (
<Pressable
style={({ pressed }) => [styles.cardContent, { opacity: pressed ? 0.5 : 1 }]}
onPress={onPress}>
{cardInner}
</Pressable>
) : (
<View style={styles.cardContent}>{cardInner}</View>
)}
<Pressable
onPress={handleToggle}
hitSlop={ms(8)}
style={({ pressed }) => [styles.heartButton, { opacity: pressed ? 0.5 : 1 }]}>
<Image
source={
isSaved ? require('@/assets/icons/heart-fill.png') : require('@/assets/icons/heart.png')
}
style={styles.heartIcon}
resizeMode="contain"
/>
</Pressable>
</View>
)
}

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',
Expand Down Expand Up @@ -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
15 changes: 4 additions & 11 deletions src/features/club/components/ClubList/ClubList.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -38,17 +38,10 @@ const ClubList = ({ clubs, category, openDetailPage, emptyPlaceholder, isLoading
style={styles.list}
contentContainerStyle={styles.listContent}
renderItem={({ item }) => (
<Pressable
style={({ pressed }) => ({
width,
paddingHorizontal: s(20),
opacity: pressed ? 0.5 : 1,
})}
onPress={() => openDetailPage(item)}>
<ClubCard club={item} category={category} />
</Pressable>
<View style={{ width, paddingHorizontal: s(20) }}>
<ClubCard club={item} category={category} onPress={() => openDetailPage(item)} />
</View>
)}
removeClippedSubviews={true}
initialNumToRender={6}
maxToRenderPerBatch={1}
updateCellsBatchingPeriod={100}
Expand Down
3 changes: 2 additions & 1 deletion src/features/club/screens/SavedClubListScreen/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 })),
})
}
28 changes: 16 additions & 12 deletions src/repositories/club.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,63 +133,67 @@ export const getClubRepository = (): ClubRepository => ({
searchParams.append('min_activity_period', period)
})

const response = await apiConnector.get<SearchClubsResponse>('/v2/clubs/search', searchParams, signal)
const response = await apiConnector.get<SearchClubsResponse>(
'/v2/clubs/search',
searchParams,
signal,
)

return response
},
listPopularClubs: async () => {
const response = await apiConnector.get<ListPopularClubsResponse>('/v1/clubs/popular')
const response = await apiConnector.get<ListPopularClubsResponse>('/v2/clubs/popular')

return response
},
listLatestClubs: async () => {
const response = await apiConnector.get<ListLatestClubsResponse>('/v1/clubs/latest')
const response = await apiConnector.get<ListLatestClubsResponse>('/v2/clubs/latest')

return response
},
listClubs: async req => {
const response = await apiConnector.get<ListClubsResponse>('/v1/clubs', {
const response = await apiConnector.get<ListClubsResponse>('/v2/clubs', {
...(req.category && { category: req.category }),
})

return response
},
getClub: async req => {
const club = await apiConnector.get<Club>(`/v1/clubs/${req.uuid}`)
const club = await apiConnector.get<Club>(`/v2/clubs/${req.uuid}`)

if (!club) {
throw new Error('Club not found')
}
return club
},
listManageClubs: async () => {
const response = await apiConnector.get<ListManageClubsResponse>('/v1/managers/me/clubs')
const response = await apiConnector.get<ListManageClubsResponse>('/v2/managers/me/clubs')

return response
},
listClubRankings: async req => {
const response = await apiConnector.get<ListClubRankingsResponse>(
`/v1/clubs/rankings?topk=${req.topK ?? 5}`,
`/v2/clubs/rankings?topk=${req.topK ?? 5}`,
)

return response
},
requestClubManager: async req => {
await apiConnector.post<void>('/v1/managers/me/clubs', req)
await apiConnector.post<void>('/v2/managers/me/clubs', req)
},
listSavedClubs: async () => {
const response = await apiConnector.get<ListSavedClubsResponse>('/v1/users/me/clubs/saved')
const response = await apiConnector.get<ListSavedClubsResponse>('/v2/users/me/clubs/saved')

return response
},
createSavedClub: async req => {
await apiConnector.post<void>(`/v1/clubs/${req.clubId}/saved`)
await apiConnector.post<void>(`/v2/clubs/${req.clubId}/saved`)
},
removeSavedClub: async req => {
await apiConnector.delete<void>(`/v1/clubs/${req.clubId}/saved`)
await apiConnector.delete<void>(`/v2/clubs/${req.clubId}/saved`)
},
listMyClubs: async () => {
const response = await apiConnector.get<ListMyClubsResponse>('/v1/users/me/clubs')
const response = await apiConnector.get<ListMyClubsResponse>('/v2/users/me/clubs')

return response
},
Expand Down
3 changes: 3 additions & 0 deletions src/shared/components/LoginView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: '로그인 되었어요!' })
}

Expand Down
2 changes: 2 additions & 0 deletions src/shared/contexts/loginBottomSheetContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}>
<LoginView closeBottomSheet={closeBottomSheet} onSuccess={callOnSuccess} />
Expand Down
142 changes: 142 additions & 0 deletions src/shared/hooks/useSaveClub.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof setTimeout> | null>(null)
const hasPendingRef = useRef(false)
const serverIsSavedRef = useRef(initialSaved)
const pendingShouldSaveRef = useRef<boolean | null>(null)
const callApiRef = useRef<(shouldSave: boolean) => Promise<void>>()

// 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