From ba88b9e1b90ad54488acac54fb6032bcb7382466 Mon Sep 17 00:00:00 2001 From: harry kim <73218463+hanyugeon@users.noreply.github.com> Date: Mon, 18 Dec 2023 21:31:10 +0900 Subject: [PATCH 1/3] =?UTF-8?q?[#457]=20LikeButton=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20(#458)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: LikeButton 컴포넌트 작성 * feat: LikeButton 컴포넌트 작성 * refactor: LikeButton 컴포넌트 타입 지정부 수정 * refactor: 유저 책장 페이지 LikeButton 교체 * refactor: 책장 좋아요 Query 리팩터링 * chore: useMutateBookshelfLikeQuery 이름 변경 * fix: 빌드 에러 수정 * chore: useMutateBookshelfLikeQuery 파라미터 객체로 감싼 것 제거 * chore: 좋아요 버튼 스타일 수정 * fix: LikeButton 컴포넌트 onClick props 옵셔널로 수정 --- src/app/bookshelf/[bookshelfId]/page.tsx | 55 +++++------- .../bookshelf/useBookShelfLikeMutation.ts | 86 ------------------- .../bookshelf/useMutateBookshelfLikeQuery.ts | 52 +++++++++++ src/stories/base/LikeButton.stories.tsx | 26 ++++++ src/v1/base/LikeButton.tsx | 32 +++++++ 5 files changed, 132 insertions(+), 119 deletions(-) delete mode 100644 src/queries/bookshelf/useBookShelfLikeMutation.ts create mode 100644 src/queries/bookshelf/useMutateBookshelfLikeQuery.ts create mode 100644 src/stories/base/LikeButton.stories.tsx create mode 100644 src/v1/base/LikeButton.tsx diff --git a/src/app/bookshelf/[bookshelfId]/page.tsx b/src/app/bookshelf/[bookshelfId]/page.tsx index 78a5d1ef..aa2d808d 100644 --- a/src/app/bookshelf/[bookshelfId]/page.tsx +++ b/src/app/bookshelf/[bookshelfId]/page.tsx @@ -1,24 +1,26 @@ 'use client'; -import { IconHeart, IconArrowLeft, IconShare, IconKakao } from '@public/icons'; -import useToast from '@/v1/base/Toast/useToast'; -import useBookShelfBooksQuery from '@/queries/bookshelf/useBookShelfBookListQuery'; -import useBookShelfInfoQuery from '@/queries/bookshelf/useBookShelfInfoQuery'; -import { - useBookshelfLike, - useBookshelfUnlike, -} from '@/queries/bookshelf/useBookShelfLikeMutation'; - import { useEffect } from 'react'; import { useInView } from 'react-intersection-observer'; -import Button from '@/v1/base/Button'; -import TopNavigation from '@/v1/base/TopNavigation'; -import BookShelfRow from '@/v1/bookShelf/BookShelfRow'; import { useRouter } from 'next/navigation'; -import { isAuthed } from '@/utils/helpers'; import Link from 'next/link'; + import type { APIBookshelf, APIBookshelfInfo } from '@/types/bookshelf'; +import useBookShelfBooksQuery from '@/queries/bookshelf/useBookShelfBookListQuery'; +import useBookShelfInfoQuery from '@/queries/bookshelf/useBookShelfInfoQuery'; +import useMutateBookshelfLikeQuery from '@/queries/bookshelf/useMutateBookshelfLikeQuery'; + +import useToast from '@/v1/base/Toast/useToast'; +import { isAuthed } from '@/utils/helpers'; + +import { IconArrowLeft, IconShare, IconKakao } from '@public/icons'; + +import TopNavigation from '@/v1/base/TopNavigation'; +import BookShelfRow from '@/v1/bookShelf/BookShelfRow'; +import Button from '@/v1/base/Button'; +import LikeButton from '@/v1/base/LikeButton'; + const KAKAO_OAUTH_LOGIN_URL = `${process.env.NEXT_PUBLIC_API_URL}/oauth2/authorize/kakao?redirect_uri=${process.env.NEXT_PUBLIC_CLIENT_REDIRECT_URI}`; export default function UserBookShelfPage({ @@ -29,8 +31,8 @@ export default function UserBookShelfPage({ }; }) { const { data, isSuccess } = useBookShelfInfoQuery({ bookshelfId }); - const { mutate: likeBookshelf } = useBookshelfLike(bookshelfId); - const { mutate: unlikeBookshelf } = useBookshelfUnlike(bookshelfId); + const { mutate: mutateBookshelfLike } = + useMutateBookshelfLikeQuery(bookshelfId); const { show: showToast } = useToast(); const router = useRouter(); @@ -55,7 +57,7 @@ export default function UserBookShelfPage({ return; } - !data.isLiked ? likeBookshelf() : unlikeBookshelf(); + mutateBookshelfLike(data.isLiked); }; return ( @@ -81,24 +83,11 @@ export default function UserBookShelfPage({ {`${data.job.jobGroupKoreanName} • ${data.job.jobNameKoreanName}`} - + /> diff --git a/src/queries/bookshelf/useBookShelfLikeMutation.ts b/src/queries/bookshelf/useBookShelfLikeMutation.ts deleted file mode 100644 index 5f5dea4e..00000000 --- a/src/queries/bookshelf/useBookShelfLikeMutation.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import type { APIBookshelfInfo } from '@/types/bookshelf'; -import bookshelfAPI from '@/apis/bookshelf'; -import bookShelfKeys from './key'; - -export const useBookshelfLike = ( - bookshelfId: APIBookshelfInfo['bookshelfId'] -) => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async () => bookshelfAPI.likeBookshelf(bookshelfId), - onMutate: async () => { - await queryClient.cancelQueries(bookShelfKeys.info(bookshelfId)); - - const oldData = queryClient.getQueryData( - bookShelfKeys.info(bookshelfId) - ); - - if (oldData) { - const newData: APIBookshelfInfo = { - ...oldData, - isLiked: !oldData.isLiked, - likeCount: oldData.likeCount + 1, - }; - - queryClient.setQueryData( - bookShelfKeys.info(bookshelfId), - newData - ); - } - - return { oldData }; - }, - onError: (_error, _value, context) => { - queryClient.setQueryData( - bookShelfKeys.info(bookshelfId), - context?.oldData - ); - }, - onSettled: () => { - queryClient.invalidateQueries(bookShelfKeys.info(bookshelfId)); - }, - }); -}; - -export const useBookshelfUnlike = ( - bookshelfId: APIBookshelfInfo['bookshelfId'] -) => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async () => bookshelfAPI.unlikeBookshelf(bookshelfId), - onMutate: async () => { - await queryClient.cancelQueries(bookShelfKeys.info(bookshelfId)); - - const oldData = queryClient.getQueryData( - bookShelfKeys.info(bookshelfId) - ); - - if (oldData) { - const newData: APIBookshelfInfo = { - ...oldData, - isLiked: !oldData.isLiked, - likeCount: oldData.likeCount - 1, - }; - - queryClient.setQueryData( - bookShelfKeys.info(bookshelfId), - newData - ); - } - - return { oldData }; - }, - onError: (_error, _value, context) => { - queryClient.setQueryData( - bookShelfKeys.info(bookshelfId), - context?.oldData - ); - }, - onSettled: () => { - queryClient.invalidateQueries(bookShelfKeys.info(bookshelfId)); - }, - }); -}; diff --git a/src/queries/bookshelf/useMutateBookshelfLikeQuery.ts b/src/queries/bookshelf/useMutateBookshelfLikeQuery.ts new file mode 100644 index 00000000..97c79748 --- /dev/null +++ b/src/queries/bookshelf/useMutateBookshelfLikeQuery.ts @@ -0,0 +1,52 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import type { APIBookshelfInfo } from '@/types/bookshelf'; +import bookshelfAPI from '@/apis/bookshelf'; +import bookShelfKeys from './key'; + +const useMutateBookshelfLikeQuery = ( + bookshelfId: APIBookshelfInfo['bookshelfId'] +) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (isLiked: APIBookshelfInfo['isLiked']) => + !isLiked + ? bookshelfAPI.likeBookshelf(bookshelfId) + : bookshelfAPI.unlikeBookshelf(bookshelfId), + onMutate: async () => { + await queryClient.cancelQueries(bookShelfKeys.info(bookshelfId)); + + const prevData = queryClient.getQueryData( + bookShelfKeys.info(bookshelfId) + ); + + if (prevData) { + const newData: APIBookshelfInfo = { + ...prevData, + isLiked: !prevData.isLiked, + likeCount: prevData.isLiked + ? prevData.likeCount - 1 + : prevData.likeCount + 1, + }; + + queryClient.setQueryData( + bookShelfKeys.info(bookshelfId), + newData + ); + } + + return { prevData }; + }, + onError: (_error, _value, context) => { + queryClient.setQueryData( + bookShelfKeys.info(bookshelfId), + context?.prevData + ); + }, + onSettled: () => { + queryClient.invalidateQueries(bookShelfKeys.info(bookshelfId)); + }, + }); +}; + +export default useMutateBookshelfLikeQuery; diff --git a/src/stories/base/LikeButton.stories.tsx b/src/stories/base/LikeButton.stories.tsx new file mode 100644 index 00000000..d8622453 --- /dev/null +++ b/src/stories/base/LikeButton.stories.tsx @@ -0,0 +1,26 @@ +import LikeButton from '@/v1/base/LikeButton'; +import { Meta, StoryObj } from '@storybook/react'; + +const meta: Meta = { + title: 'Base/LikeButton', + component: LikeButton, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + isLiked: false, + likeCount: 10, + }, +}; + +export const IsLiked: Story = { + args: { + isLiked: true, + likeCount: 999, + }, +}; diff --git a/src/v1/base/LikeButton.tsx b/src/v1/base/LikeButton.tsx new file mode 100644 index 00000000..3364f07c --- /dev/null +++ b/src/v1/base/LikeButton.tsx @@ -0,0 +1,32 @@ +import { APIBookshelfInfo } from '@/types/bookshelf'; +import { IconHeart } from '@public/icons'; + +type LikeButtonProps = { + isLiked: APIBookshelfInfo['isLiked']; + likeCount: APIBookshelfInfo['likeCount']; + onClick?: () => void; +}; + +const LikeButton = ({ isLiked, likeCount, onClick }: LikeButtonProps) => { + const BG_COLOR_CLASS = isLiked ? 'bg-warning-800' : 'bg-white'; + const ICON_COLOR_CLASS = isLiked ? 'stroke-white' : 'stroke-warning-800'; + const TEXT_COLOR_CLASS = isLiked ? 'text-white' : 'text-warning-800'; + + return ( + + ); +}; + +export default LikeButton; From 6da065701df6bbfbd5c0b6a4e2fb054ec07f0876 Mon Sep 17 00:00:00 2001 From: harry kim <73218463+hanyugeon@users.noreply.github.com> Date: Mon, 18 Dec 2023 21:32:16 +0900 Subject: [PATCH 2/3] =?UTF-8?q?[#459]=20[=EC=9C=A0=EC=A0=80=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8]=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20BottomSheet?= =?UTF-8?q?=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=9E=91=EC=84=B1=20?= =?UTF-8?q?(#460)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: LoginBottomSheet 컴포넌트 작성 * feat: BottomNavigation에 BottomSheet 이벤트 등록 * fix: SVG stroke-width 파라미터명 수정 * fix: 좋아요 갯수 뱃지 아이콘 스타일링 방식을 className 인라인으로 수정 * fix: BottomNavigation 롤백 및 시맨틱 태그 적용 * chore: 주석 제거 * chore: 불필요한 Fragment 제거 --- src/v1/base/BottomNavigation.tsx | 12 ++++---- src/v1/bookShelf/BookShelf.tsx | 8 +----- src/v1/profile/LoginBottomSheet.tsx | 44 +++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 13 deletions(-) create mode 100644 src/v1/profile/LoginBottomSheet.tsx diff --git a/src/v1/base/BottomNavigation.tsx b/src/v1/base/BottomNavigation.tsx index 3d957bef..4aa401d8 100644 --- a/src/v1/base/BottomNavigation.tsx +++ b/src/v1/base/BottomNavigation.tsx @@ -41,20 +41,20 @@ const iconColor = { const BottomNavigation = ({ pathname }: BottomNavigationProps) => { return ( -
+
+ ); }; diff --git a/src/v1/bookShelf/BookShelf.tsx b/src/v1/bookShelf/BookShelf.tsx index f977fe50..d0868e6b 100644 --- a/src/v1/bookShelf/BookShelf.tsx +++ b/src/v1/bookShelf/BookShelf.tsx @@ -33,13 +33,7 @@ const Info = ({ bookshelfName, bookshelfId, likeCount }: InfoProps) => {
- +
{likeCount}
diff --git a/src/v1/profile/LoginBottomSheet.tsx b/src/v1/profile/LoginBottomSheet.tsx new file mode 100644 index 00000000..6027ee38 --- /dev/null +++ b/src/v1/profile/LoginBottomSheet.tsx @@ -0,0 +1,44 @@ +import { IconClose, IconKakao, LogoWithText } from '@public/icons'; + +import Button from '@/v1/base/Button'; +import BottomSheet from '@/v1/base/BottomSheet'; + +type LoginBottomSheetProps = { + isOpen: boolean; + onClose: () => void; +}; + +const LoginBottomSheet = ({ isOpen, onClose }: LoginBottomSheetProps) => { + const handleClickKakaoLogin = () => { + return (location.href = `${process.env.NEXT_PUBLIC_API_URL}/oauth2/authorize/kakao?redirect_uri=${process.env.NEXT_PUBLIC_CLIENT_REDIRECT_URI}`); + }; + + return ( + + +
+ +

+ 로그인이 필요한 서비스에요! +

+

+ 간편하게 카카오로 로그인을 하고, +
+ 다독다독의 다양한 기능을 + 이용해보세요. +

+ +
+
+ ); +}; + +export default LoginBottomSheet; From 1610cfdf11eb8eff167cfc8fee237ace21416095 Mon Sep 17 00:00:00 2001 From: kyuran kim <57716832+gxxrxn@users.noreply.github.com> Date: Mon, 18 Dec 2023 21:36:11 +0900 Subject: [PATCH 3/3] =?UTF-8?q?[#455]=20[=EB=AA=A8=EC=9E=84=20=EC=83=81?= =?UTF-8?q?=EC=84=B8]=20=EB=AA=A8=EC=9E=84=20=EA=B0=80=EC=9E=85=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=ED=8E=98=EC=9D=B4=EC=A7=80=20(#462)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 모임 가입 페이지 구현 * feat: 모임 가입 후 모임 상세 페이지로 돌아가는 로직 구현 * fix: 가입문제 답변 최대 10자로 수정 * fix: 모임 상세 페이지 내에서 뒤로가기 버튼 의도대로 동작하도록 수정 * refactor: useJoinBookGroup hooks 추출 * refactor: BackButtonProps union type으로 수정 * refactor: routeOption 삼항연산자로 변경 * refactor: onSuccess 명시적으로 truthy한 값인지 확인 * fix: 가입 문제 정답으로 1자 이상, 띄어쓰기 없이 입력되도록 수정 --- src/app/group/[groupId]/join/page.tsx | 104 ++++++++++++++++++ src/hooks/group/useJoinBookGroup.ts | 56 ++++++++++ src/v1/bookGroup/BookGroupNavigation.tsx | 59 ++++++++-- .../bookGroup/detail/JoinBookGroupButton.tsx | 42 ++----- 4 files changed, 216 insertions(+), 45 deletions(-) create mode 100644 src/app/group/[groupId]/join/page.tsx create mode 100644 src/hooks/group/useJoinBookGroup.ts diff --git a/src/app/group/[groupId]/join/page.tsx b/src/app/group/[groupId]/join/page.tsx new file mode 100644 index 00000000..2f7b6688 --- /dev/null +++ b/src/app/group/[groupId]/join/page.tsx @@ -0,0 +1,104 @@ +'use client'; + +import { notFound, useRouter } from 'next/navigation'; +import { SubmitHandler, useForm } from 'react-hook-form'; + +import useJoinBookGroup from '@/hooks/group/useJoinBookGroup'; + +import SSRSafeSuspense from '@/components/SSRSafeSuspense'; +import Loading from '@/v1/base/Loading'; +import Input from '@/v1/base/Input'; +import InputLength from '@/v1/base/InputLength'; +import ErrorMessage from '@/v1/base/ErrorMessage'; +import BottomActionButton from '@/v1/base/BottomActionButton'; +import BookGroupNavigation from '@/v1/bookGroup/BookGroupNavigation'; + +type JoinFormValues = { + answer: string; +}; + +const JoinBookGroupPage = ({ + params: { groupId }, +}: { + params: { groupId: number }; +}) => { + return ( + }> + + + + + + + ); +}; + +const BookGroupJoinForm = ({ groupId }: { groupId: number }) => { + const router = useRouter(); + const { isMember, hasPassword, question, joinBookGroup } = + useJoinBookGroup(groupId); + + if (isMember || !hasPassword) { + notFound(); + } + + const { + register, + watch, + handleSubmit, + formState: { errors }, + } = useForm({ mode: 'all' }); + + const submitJoinForm: SubmitHandler = ({ answer }) => { + joinBookGroup({ + answer, + onSuccess: () => router.replace(`/group/${groupId}`), + }); + }; + + return ( +
+

+ {`문제를 맞추면 + 모임에 가입할 수 있어요`} +

+
+

{question}

+
+ +
+ + {errors.answer && ( + {errors.answer.message} + )} +
+
+
+ 제출하기 +
+ ); +}; + +export default JoinBookGroupPage; diff --git a/src/hooks/group/useJoinBookGroup.ts b/src/hooks/group/useJoinBookGroup.ts new file mode 100644 index 00000000..f86175b2 --- /dev/null +++ b/src/hooks/group/useJoinBookGroup.ts @@ -0,0 +1,56 @@ +import { isAxiosErrorWithCustomCode } from '@/utils/helpers'; +import { SERVICE_ERROR_MESSAGE } from '@/constants'; +import groupAPI from '@/apis/group'; +import useToast from '@/v1/base/Toast/useToast'; +import { useBookGroupJoinInfo } from '@/queries/group/useBookGroupQuery'; + +const useJoinBookGroup = (groupId: number) => { + const { data: bookGroupJoinData, refetch } = useBookGroupJoinInfo(groupId); + const { isExpired, isMember, hasPassword, question } = bookGroupJoinData; + + const toast = useToast(); + + const joinBookGroup = async ({ + answer, + onSuccess, + }: { + answer?: string; + onSuccess?: () => void; + }) => { + try { + await groupAPI.joinGroup({ bookGroupId: groupId, password: answer }); + toast.show({ message: '🎉 모임에 가입되었어요! 🎉', type: 'success' }); + onSuccess && onSuccess(); + } catch (error) { + if (!isAxiosErrorWithCustomCode(error)) { + toast.show({ message: '잠시 후 다시 시도해주세요', type: 'error' }); + return; + } + + const { code } = error.response.data; + const message = SERVICE_ERROR_MESSAGE[code]; + const isWrongAnswerErrorCode = code === 'BG3'; + + if (isWrongAnswerErrorCode) { + toast.show({ + message: '정답이 아니에요. 다시 시도해주세요!', + type: 'error', + }); + return; + } + + toast.show({ message, type: 'error' }); + } + }; + + return { + isExpired, + isMember, + hasPassword, + question, + refetch, + joinBookGroup, + }; +}; + +export default useJoinBookGroup; diff --git a/src/v1/bookGroup/BookGroupNavigation.tsx b/src/v1/bookGroup/BookGroupNavigation.tsx index b904e995..22ed0ed1 100644 --- a/src/v1/bookGroup/BookGroupNavigation.tsx +++ b/src/v1/bookGroup/BookGroupNavigation.tsx @@ -18,14 +18,6 @@ import { const NavigationContext = createContext({} as { groupId: number }); -const getTargetChildren = (children: ReactNode, Target: () => JSX.Element) => { - const childrenArray = Children.toArray(children); - - return childrenArray.filter( - child => isValidElement(child) && child.type === ().type - ); -}; - const BookGroupNavigation = ({ groupId, children, @@ -55,11 +47,38 @@ const BookGroupNavigation = ({ ); }; -const BackButton = () => { +type BackButtonProps = + | { + routeOption: 'push'; + href: string; + } + | { + routeOption: 'replace'; + href: string; + } + | { + routeOption?: 'back'; + }; + +const BackButton = (props: BackButtonProps) => { + const { routeOption } = props; const router = useRouter(); + const handleClick = () => { + switch (routeOption) { + case 'push': + return router.push(props.href); + case 'replace': + return router.replace(props.href); + case 'back': + return router.back(); + default: + return router.back(); + } + }; + return ( - + ); @@ -97,3 +116,23 @@ export default BookGroupNavigation; const TitleSkeleton = () => (
); + +const BackButtonType = ().type; +const TitleType = ().type; +const MenuButtonType = (<MenuButton />).type; +const WriteButtonType = (<WriteButton />).type; + +const getTargetChildren = ( + children: ReactNode, + targetType: + | typeof BackButtonType + | typeof TitleType + | typeof MenuButtonType + | typeof WriteButtonType +) => { + const childrenArray = Children.toArray(children); + + return childrenArray.find( + child => isValidElement(child) && child.type === targetType + ); +}; diff --git a/src/v1/bookGroup/detail/JoinBookGroupButton.tsx b/src/v1/bookGroup/detail/JoinBookGroupButton.tsx index 13a70a4b..d1eb1dd7 100644 --- a/src/v1/bookGroup/detail/JoinBookGroupButton.tsx +++ b/src/v1/bookGroup/detail/JoinBookGroupButton.tsx @@ -1,49 +1,21 @@ import { usePathname, useRouter } from 'next/navigation'; -import { SERVICE_ERROR_MESSAGE } from '@/constants'; -import { isAxiosErrorWithCustomCode } from '@/utils/helpers'; - -import { useBookGroupJoinInfo } from '@/queries/group/useBookGroupQuery'; -import groupAPI from '@/apis/group'; -import useToast from '@/v1/base/Toast/useToast'; +import useJoinBookGroup from '@/hooks/group/useJoinBookGroup'; import BottomActionButton from '@/v1/base/BottomActionButton'; const JoinBookGroupButton = ({ groupId }: { groupId: number }) => { - const _router = useRouter(); - const _pathname = usePathname(); - const toast = useToast(); - - const { - data: { isExpired, isMember, hasPassword }, - refetch, - } = useBookGroupJoinInfo(groupId); - - const joinBookGroup = async () => { - try { - await groupAPI.joinGroup({ bookGroupId: groupId }); - toast.show({ message: '모임에 가입했어요!', type: 'success' }); - refetch(); - } catch (error) { - if (!isAxiosErrorWithCustomCode(error)) { - toast.show({ message: '잠시 후 다시 시도해주세요.', type: 'error' }); - return; - } - - const { code } = error.response.data; - const message = SERVICE_ERROR_MESSAGE[code]; - - toast.show({ message, type: 'error' }); - } - }; + const router = useRouter(); + const pathname = usePathname(); + const { isExpired, isMember, hasPassword, joinBookGroup, refetch } = + useJoinBookGroup(groupId); const handleButtonClick = async () => { if (hasPassword) { - // TODO: 모임 가입문제 페이지 생성 후 연결 - // router.push(`${pathname}/join`); + router.replace(`${pathname}/join`); return; } - joinBookGroup(); + joinBookGroup({ onSuccess: refetch }); }; if (isMember) {