diff --git a/apps/web/app/_apis/mutations/useAddLike.ts b/apps/web/app/_apis/mutations/useAddLike.ts index 6061277..2a9da92 100644 --- a/apps/web/app/_apis/mutations/useAddLike.ts +++ b/apps/web/app/_apis/mutations/useAddLike.ts @@ -7,8 +7,19 @@ export const useAddLike = () => { return useMutation({ mutationFn: async (id: string) => await addLike(id), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [...PlaceQueryKeys.byLike()] }) + onSuccess: async (response) => { + const { placeId } = response + + if (!placeId) return + + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: [...PlaceQueryKeys.byLike()], + }), + queryClient.invalidateQueries({ + queryKey: [...PlaceQueryKeys.detail(String(placeId))], + }), + ]) }, // 공통 에러 처리 필요 onError: (error) => console.error(error), diff --git a/apps/web/app/_apis/mutations/useRemoveLike.ts b/apps/web/app/_apis/mutations/useRemoveLike.ts index e8cf24e..2f868c8 100644 --- a/apps/web/app/_apis/mutations/useRemoveLike.ts +++ b/apps/web/app/_apis/mutations/useRemoveLike.ts @@ -7,8 +7,19 @@ export const useRemoveLike = () => { return useMutation({ mutationFn: async (id: string) => await removeLike(id), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [...PlaceQueryKeys.byLike()] }) + onSuccess: async (response) => { + const { placeId } = response + + if (!placeId) return + + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: [...PlaceQueryKeys.byLike()], + }), + queryClient.invalidateQueries({ + queryKey: [...PlaceQueryKeys.detail(String(placeId))], + }), + ]) }, // 공통 에러 처리 필요 onError: (error) => console.error(error), diff --git a/apps/web/app/_apis/queries/user.ts b/apps/web/app/_apis/queries/user.ts new file mode 100644 index 0000000..29d94d2 --- /dev/null +++ b/apps/web/app/_apis/queries/user.ts @@ -0,0 +1,15 @@ +import { queryOptions } from '@tanstack/react-query' +import { getUserData } from '@/_apis/services/user' + +export const UserQueryKeys = { + all: () => ['user'] as const, + detail: () => [...UserQueryKeys.all(), 'detail'] as const, +} + +export const useUserQueries = { + detail: () => + queryOptions({ + queryKey: UserQueryKeys.detail(), + queryFn: getUserData, + }), +} diff --git a/apps/web/app/_apis/schemas/user.ts b/apps/web/app/_apis/schemas/user.ts new file mode 100644 index 0000000..6fa2f87 --- /dev/null +++ b/apps/web/app/_apis/schemas/user.ts @@ -0,0 +1,9 @@ +import { z } from 'zod' + +export const UserSchema = z.object({ + nickname: z.string(), + profileImageUrl: z.url(), + profileBackgroundHexCode: z.string(), +}) + +export type User = z.infer diff --git a/apps/web/app/_apis/services/category.ts b/apps/web/app/_apis/services/category.ts index f009182..0e84cce 100644 --- a/apps/web/app/_apis/services/category.ts +++ b/apps/web/app/_apis/services/category.ts @@ -3,7 +3,6 @@ import { API_PATH } from '@/_constants/path' import { CategorySchema, Category } from '../schemas/category' export const getCategories = async (): Promise => { - const { data: response } = await axiosInstance.get(API_PATH.CATEGORY) - const { data } = response + const { data } = await axiosInstance.get(API_PATH.CATEGORY) return CategorySchema.array().parse(data) } diff --git a/apps/web/app/_apis/services/event.ts b/apps/web/app/_apis/services/event.ts index 4d930a7..924fb43 100644 --- a/apps/web/app/_apis/services/event.ts +++ b/apps/web/app/_apis/services/event.ts @@ -10,14 +10,12 @@ import { } from '@/_apis/schemas/event' export const getPublicEventInfo = async (): Promise => { - const { data: response } = await axiosInstance.get(API_PATH.EVENT.INFO) - const { data } = response + const { data } = await axiosInstance.get(API_PATH.EVENT.INFO) return PublicEventSchema.parse(data) } export const getPrivateEventInfo = async (): Promise => { - const { data: response } = await axiosInstance.get(API_PATH.EVENT.INFO) - const { data } = response + const { data } = await axiosInstance.get(API_PATH.EVENT.INFO) return PrivateEventSchema.parse(data) } @@ -25,16 +23,11 @@ export const participationEvent = async (body: { eventId: string ticketsCount: number }) => { - const { data: response } = await axiosInstance.post( - API_PATH.EVENT.PARTICIPATIONS, - body, - ) - const { data } = response + const { data } = await axiosInstance.post(API_PATH.EVENT.PARTICIPATIONS, body) return data } export const getEventResult = async (): Promise => { - const { data: response } = await axiosInstance.get(API_PATH.EVENT.RESULT) - const { data } = response + const { data } = await axiosInstance.get(API_PATH.EVENT.RESULT) return EventResultSchema.parse(data) } diff --git a/apps/web/app/_apis/services/like.ts b/apps/web/app/_apis/services/like.ts index 15cb28b..fc04311 100644 --- a/apps/web/app/_apis/services/like.ts +++ b/apps/web/app/_apis/services/like.ts @@ -2,22 +2,18 @@ import axiosInstance from '@/_lib/axiosInstance' import { API_PATH } from '@/_constants/path' type Response = { - placeId: string + placeId: number message: string } export const addLike = async (placeId: string): Promise => { - const { data: response } = await axiosInstance.post( - API_PATH.PLACES.LIKE.POST(placeId), - ) - const { data } = response + const { data } = await axiosInstance.post(API_PATH.PLACES.LIKE.POST(placeId)) return data } export const removeLike = async (placeId: string): Promise => { - const { data: response } = await axiosInstance.delete( + const { data } = await axiosInstance.delete( API_PATH.PLACES.LIKE.DELETE(placeId), ) - const { data } = response return data } diff --git a/apps/web/app/_apis/services/place.ts b/apps/web/app/_apis/services/place.ts index 752118e..bde879c 100644 --- a/apps/web/app/_apis/services/place.ts +++ b/apps/web/app/_apis/services/place.ts @@ -25,10 +25,9 @@ export const getPlacesByRanking = async ( sort: RankingPlaceSort, campus: CampusType, ): Promise => { - const { data: response } = await axiosInstance.get( + const { data } = await axiosInstance.get( API_PATH.PLACES.BY_RANKING(sort, campus), ) - const { data } = response return BasePlaceSchema.array().parse(data) } @@ -36,10 +35,9 @@ export const getPlacesByCategory = async ( id: string, campus: CampusType, ): Promise => { - const { data: response } = await axiosInstance.get( + const { data } = await axiosInstance.get( API_PATH.PLACES.BY_CATEGORY(id, campus), ) - const { data } = response return BasePlaceSchema.array().parse(data) } @@ -49,7 +47,7 @@ export const getPlacesByMap = async ({ maxLatitude, maxLongitude, }: MapBounds): Promise => { - const { data: response } = await axiosInstance.get( + const { data } = await axiosInstance.get( API_PATH.PLACES.BY_MAP({ minLatitude, minLongitude, @@ -57,13 +55,11 @@ export const getPlacesByMap = async ({ maxLongitude, }), ) - const { data } = response return PlaceByMapSchema.array().parse(data) } export const getPlaceDetail = async (id: string): Promise => { - const { data: response } = await axiosInstance.get(API_PATH.PLACES.DETAIL(id)) - const { data } = response + const { data } = await axiosInstance.get(API_PATH.PLACES.DETAIL(id)) return PlaceDetailSchema.parse(data) } @@ -89,16 +85,14 @@ export const getSearchPlaceByKakao = async ({ export const getPlaceByPreview = async ( kakaoPlaceId: string, ): Promise => { - const { data: response } = await axiosInstance.get( + const { data } = await axiosInstance.get( API_PATH.PLACES.NEW.PREVIEW(kakaoPlaceId), ) - const { data } = response return PlaceByPreviewSchema.parse(data) } export const getPlacesByLike = async (): Promise => { - const { data: response } = await axiosInstance.get(API_PATH.PLACES.LIKE.GET) - const { data } = response + const { data } = await axiosInstance.get(API_PATH.PLACES.LIKE.GET) return BasePlaceSchema.array().parse(data) } diff --git a/apps/web/app/_apis/services/request.ts b/apps/web/app/_apis/services/request.ts index 4f03213..a2398f3 100644 --- a/apps/web/app/_apis/services/request.ts +++ b/apps/web/app/_apis/services/request.ts @@ -8,15 +8,11 @@ import { } from '@/_apis/schemas/request' export const getRequests = async (): Promise => { - const { data: response } = await axiosInstance.get(API_PATH.REQUEST.LIST) - const { data } = response + const { data } = await axiosInstance.get(API_PATH.REQUEST.LIST) return RequestSchema.array().parse(data) } export const getRequestDetail = async (id: string): Promise => { - const { data: response } = await axiosInstance.get( - API_PATH.REQUEST.DETAIL(id), - ) - const { data } = response + const { data } = await axiosInstance.get(API_PATH.REQUEST.DETAIL(id)) return RequestDetailSchema.parse(data) } diff --git a/apps/web/app/_apis/services/user.ts b/apps/web/app/_apis/services/user.ts new file mode 100644 index 0000000..0925dd0 --- /dev/null +++ b/apps/web/app/_apis/services/user.ts @@ -0,0 +1,8 @@ +import axiosInstance from '@/_lib/axiosInstance' +import { API_PATH } from '@/_constants/path' +import { User, UserSchema } from '@/_apis/schemas/user' + +export const getUserData = async (): Promise => { + const { data } = await axiosInstance.get(API_PATH.USER) + return UserSchema.parse(data) +} diff --git a/apps/web/app/_constants/path.ts b/apps/web/app/_constants/path.ts index f40e78b..fa65cd6 100644 --- a/apps/web/app/_constants/path.ts +++ b/apps/web/app/_constants/path.ts @@ -45,6 +45,7 @@ export const API_PATH = { `/auth/oauth2?code=${code}&redirectUri=${redirectUri}`, TOKEN: '/auth/token', }, + USER: '/users/me', } export const CLIENT_PATH = { diff --git a/apps/web/app/places/[id]/_components/LikeButton/LikeButton.tsx b/apps/web/app/places/[id]/_components/LikeButton/LikeButton.tsx index 4ad0873..2df55c6 100644 --- a/apps/web/app/places/[id]/_components/LikeButton/LikeButton.tsx +++ b/apps/web/app/places/[id]/_components/LikeButton/LikeButton.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { motion } from 'motion/react' import { Icon } from '@repo/ui/components/Icon' import { useAddLike } from '@/_apis/mutations/useAddLike' @@ -14,20 +14,37 @@ type Props = { export const LikeButton = ({ placeId, initIsLiked }: Props) => { const [isLiked, setIsLiked] = useState(initIsLiked) const [isAnimating, setIsAnimating] = useState(false) - const { mutate: addLike } = useAddLike() - const { mutate: removeLike } = useRemoveLike() - const toggleLikeMutate = isLiked ? removeLike : addLike + const { mutate: addLike, isPending: isAdding } = useAddLike() + const { mutate: removeLike, isPending: isRemoving } = useRemoveLike() + + const isPending = isAdding || isRemoving const onClick = () => { + if (isPending) return + + // 현재 상태 저장 (에러 시 롤백용) + const prevIsLiked = isLiked + // 낙관적 업데이트 (UI 먼저 변경) + const nextIsLiked = !prevIsLiked + setIsLiked(nextIsLiked) + + const toggleLikeMutate = nextIsLiked ? addLike : removeLike + + // 애니메이션 트리거 + if (nextIsLiked) { + setIsAnimating(true) + setTimeout(() => setIsAnimating(false), 200) + } + toggleLikeMutate(placeId, { - onSuccess: () => { - setIsLiked((prev) => !prev) - setIsAnimating(true) - setTimeout(() => setIsAnimating(false), 200) // 0.2초 동안 팝 애니메이션 - }, + onError: () => setIsLiked(prevIsLiked), // 실패 시 롤백 }) } + useEffect(() => { + setIsLiked(initIsLiked) + }, [initIsLiked]) + return ( { - return ( - - - - - - - - - ) -} - -const Profile = () => ( - - - {'profileImage'} - - - 배고픈 강아지 - - -) - -const Menu = ({ - href, - title, - icon, -}: { - href: string - title: string - icon: IconType -}) => ( - - - - {title} - - - -) diff --git a/apps/web/app/profile/_components/ProfileMenuItem/ProfileMenuItem.tsx b/apps/web/app/profile/_components/ProfileMenuItem/ProfileMenuItem.tsx new file mode 100644 index 0000000..ae2f1c6 --- /dev/null +++ b/apps/web/app/profile/_components/ProfileMenuItem/ProfileMenuItem.tsx @@ -0,0 +1,19 @@ +import { Icon, IconType } from '@repo/ui/components/Icon' +import { Flex, JustifyBetween } from '@repo/ui/components/Layout' +import { Text } from '@repo/ui/components/Text' + +type Props = { + href: string + title: string + icon: IconType +} + +export const ProfileMenuItem = ({ href, title, icon }: Props) => ( + + + + {title} + + + +) diff --git a/apps/web/app/profile/_components/ProfileMenuItem/index.ts b/apps/web/app/profile/_components/ProfileMenuItem/index.ts new file mode 100644 index 0000000..2c9b103 --- /dev/null +++ b/apps/web/app/profile/_components/ProfileMenuItem/index.ts @@ -0,0 +1 @@ +export { ProfileMenuItem } from './ProfileMenuItem' diff --git a/apps/web/app/profile/_components/UserProfile/UserProfile.tsx b/apps/web/app/profile/_components/UserProfile/UserProfile.tsx new file mode 100644 index 0000000..7f61b19 --- /dev/null +++ b/apps/web/app/profile/_components/UserProfile/UserProfile.tsx @@ -0,0 +1,32 @@ +'use client' + +import { useSuspenseQuery } from '@tanstack/react-query' +import { useUserQueries } from '@/_apis/queries/user' +import { Flex } from '@repo/ui/components/Layout' +import Image from 'next/image' +import { Text } from '@repo/ui/components/Text' + +export const UserProfile = () => { + const { data } = useSuspenseQuery(useUserQueries.detail()) + const { nickname, profileImageUrl, profileBackgroundHexCode } = data + + return ( + + + {'profileImage'} + + + {nickname} + + + ) +} diff --git a/apps/web/app/profile/_components/UserProfile/index.ts b/apps/web/app/profile/_components/UserProfile/index.ts new file mode 100644 index 0000000..331ea78 --- /dev/null +++ b/apps/web/app/profile/_components/UserProfile/index.ts @@ -0,0 +1 @@ +export { UserProfile } from './UserProfile' diff --git a/apps/web/app/profile/page.tsx b/apps/web/app/profile/page.tsx index 2158655..b9b0b42 100644 --- a/apps/web/app/profile/page.tsx +++ b/apps/web/app/profile/page.tsx @@ -1,12 +1,44 @@ +import { CLIENT_PATH } from '@/_constants/path' +import { useUserQueries } from '@/_apis/queries/user' +import { HydrationBoundaryPage } from '@/HydrationBoundaryPage' import { BottomNavigation } from '@/_components/BottomNavigation' import { OnlyLeftHeader } from '@repo/ui/components/Header' -import { ProfilePage } from './ProfilePage' +import { Column } from '@repo/ui/components/Layout' +import { UserProfile } from '@/profile/_components/UserProfile' +import { ProfileMenuItem } from '@/profile/_components/ProfileMenuItem' const Page = () => { return ( <> - + + { + await queryClient.prefetchQuery(useUserQueries.detail()) + }} + > + + + + + + + + ) diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 0098c37..246b03f 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -35,6 +35,11 @@ const nextConfig: NextConfig = { hostname: 'example.com', pathname: '/**', }, + { + protocol: 'https', + hostname: process.env.NEXT_PUBLIC_API_URL_HOST || '', //테스트용 주소 + pathname: '/**', + }, ], }, async rewrites() {