diff --git a/src/entities/character/api/character.ts b/src/entities/character/api/character.ts index ef33808b..b8cf2dcc 100644 --- a/src/entities/character/api/character.ts +++ b/src/entities/character/api/character.ts @@ -1,9 +1,14 @@ import axiosInstance from '@/shared/api/axios'; -import { CharacterItemsResponse } from '../model/character.type'; +import { parseDate } from '@/shared/lib/parseDate'; +import { + CharacterItemData, + CharacterItemsRawResponse, +} from '../model/character.type'; -export const getCharacterCollection = () => - axiosInstance - .get('/character/collection', { - useMock: true, - }) - .then((res) => res.data); +export const getCharacterCollection = (): Promise => + axiosInstance.get('/collections').then((res) => + res.data.collections.map((item) => ({ + ...item, + acquiredAt: parseDate(item.acquiredAt), + })) + ); diff --git a/src/entities/character/config/character.ts b/src/entities/character/config/character.ts index d3e27ac6..932953aa 100644 --- a/src/entities/character/config/character.ts +++ b/src/entities/character/config/character.ts @@ -1,65 +1,46 @@ -import { CharacterType } from '@/entities/character/model/character.type'; - -export const CHARACTER_IMAGE_SIZE: Record< - CharacterType, - { - big: { - width?: string; - height?: string; - }; - small: { - width?: string; - height?: string; - }; - } -> = { +export const CHARACTER_DATA = { '천사 모또': { - big: { - width: '15rem', - }, - small: { - width: '12rem', + imageSize: { + big: { width: '15rem' }, + small: { width: '12rem' }, }, + description: '정산의 수호천사 등장!', }, '러키 모또': { - big: { - width: '12.5rem', - }, - small: { - width: '10rem', + imageSize: { + big: { width: '12.5rem' }, + small: { width: '10rem' }, }, + description: '정산 성공! 좋은 일만 가득하길~', }, '딸기 또또': { - big: { - width: '12.5rem', - }, - small: { - width: '10rem', - height: '10rem', + imageSize: { + big: { width: '12.5rem' }, + small: { width: '10rem', height: '10rem' }, }, + description: '정산 완료! 달콤한 하루 보내~', }, '잠꾸러기 또또': { - big: { - width: '12.5rem', - }, - small: { - width: '10rem', + imageSize: { + big: { width: '12.5rem' }, + small: { width: '10rem' }, }, + description: '정산 끝났어? 이제 푹 잘 수 있겠네~', }, '마법사 또또': { - big: { - height: '13.72844rem', - }, - small: { - height: '10.98275rem', + imageSize: { + big: { height: '13.72844rem' }, + small: { height: '10.98275rem' }, }, + description: '정산? 아브라카다브라! 해결 완료~', }, -}; - -export const CHARACTER_DESCRIPTION: Record = { - '천사 모또': '정산의 수호천사 등장!', - '러키 모또': '정산 성공! 좋은 일만 가득하길~', - '딸기 또또': '정산 완료! 달콤한 하루 보내~', - '잠꾸러기 또또': '정산 끝났어? 이제 푹 잘 수 있겠네~', - '마법사 또또': '정산? 아브라카다브라! 해결 완료~', -}; +} satisfies Record< + string, + { + imageSize: { + big: { width?: string; height?: string }; + small: { width?: string; height?: string }; + }; + description: string; + } +>; diff --git a/src/entities/character/model/character.type.ts b/src/entities/character/model/character.type.ts index 7de3abf0..739dea3e 100644 --- a/src/entities/character/model/character.type.ts +++ b/src/entities/character/model/character.type.ts @@ -1,9 +1,7 @@ -export type CharacterType = - | '천사 모또' - | '러키 모또' - | '딸기 또또' - | '잠꾸러기 또또' - | '마법사 또또'; +import { CHARACTER_DATA } from '@/entities/character/config/character'; + +// 새 캐릭터 추가 시 CHARACTER_DATA에만 추가하면 타입이 자동으로 확장됨 +export type CharacterType = keyof typeof CHARACTER_DATA; export interface CharacterData { name: CharacterType; @@ -14,10 +12,15 @@ export interface CharacterData { export interface CharacterItemData extends CharacterData { id: number; - isUnlocked: boolean; - unlockedAt: string | null; + acquiredAt: Date | null; +} + +// API 원시 응답 타입 (JSON 파싱 직후, 변환 전) +interface CharacterItemRaw extends CharacterData { + id: number; + acquiredAt: string | null; } -export interface CharacterItemsResponse { - characters: CharacterItemData[]; +export interface CharacterItemsRawResponse { + collections: CharacterItemRaw[]; } diff --git a/src/entities/member/api/assignMember.ts b/src/entities/member/api/assignMember.ts index cff5f358..445b86a3 100644 --- a/src/entities/member/api/assignMember.ts +++ b/src/entities/member/api/assignMember.ts @@ -1,15 +1,18 @@ import axiosInstance from '@/shared/api/axios'; +import { parseDate } from '@/shared/lib/parseDate'; +import { MemberProfile, MemberProfileRaw } from '../model/member.type'; // 참여자 선택 api (로그인한 참여자가 정산에 참여하도록 프로필 설정) export const assignMember = async ( settlementCode: string, memberId: number -): Promise => { - await axiosInstance.post( +): Promise => { + const response = await axiosInstance.post( `/groups/${settlementCode}/members/assign`, - { - memberId, - }, - { useMock: true } + { memberId } ); + return { + ...response.data, + paidAt: parseDate(response.data.paidAt), + }; }; diff --git a/src/entities/member/api/getProfiles.ts b/src/entities/member/api/getProfiles.ts index 0d08bf9e..4adf22ff 100644 --- a/src/entities/member/api/getProfiles.ts +++ b/src/entities/member/api/getProfiles.ts @@ -1,14 +1,17 @@ import axiosInstance from '@/shared/api/axios'; -import { MemberProfile, MemberProfileData } from '../model/member.type'; +import { parseDate } from '@/shared/lib/parseDate'; +import { MemberProfile, MemberProfileRawData } from '../model/member.type'; // TODO : 기존 groupToken들을 사용하는 방식을 settlementCode를 사용하는 방식으로 변경해야 함. // 모임원 조회 API - 정산 참여자 프로필 조회 export const getProfiles = async ( settlementCode: string ): Promise => { - const response = await axiosInstance.get( - `/groups/${settlementCode}/members`, - { useMock: true } + const response = await axiosInstance.get( + `/groups/${settlementCode}/members` ); - return response.data.members; + return response.data.members.map((member) => ({ + ...member, + paidAt: parseDate(member.paidAt), + })); }; diff --git a/src/entities/member/model/member.type.ts b/src/entities/member/model/member.type.ts index 88fe6c5a..ae6726c6 100644 --- a/src/entities/member/model/member.type.ts +++ b/src/entities/member/model/member.type.ts @@ -27,6 +27,11 @@ export interface MemberProfile { paidAt: Date | null; } -export interface MemberProfileData { - members: MemberProfile[]; +// API 원시 응답 타입 (JSON 파싱 직후, 변환 전) +export interface MemberProfileRaw extends Omit { + paidAt: string | null; +} + +export interface MemberProfileRawData { + members: MemberProfileRaw[]; } diff --git a/src/features/character-management/api/useGetCharacterCollection.ts b/src/features/character-management/api/useGetCharacterCollection.ts index d422533b..7469319b 100644 --- a/src/features/character-management/api/useGetCharacterCollection.ts +++ b/src/features/character-management/api/useGetCharacterCollection.ts @@ -7,9 +7,9 @@ const useGetCharacterCollection = () => queryFn: getCharacterCollection, // 획득한 캐릭터가 가장 앞에 오도록 정렬 select: (data) => - [...data.characters].sort((a, b) => { - if (a.isUnlocked === b.isUnlocked) return 0; - return a.isUnlocked ? -1 : 1; + [...data].sort((a, b) => { + if ((a.acquiredAt !== null) === (b.acquiredAt !== null)) return 0; + return a.acquiredAt !== null ? -1 : 1; }), }); diff --git a/src/features/character-management/ui/CharacterBottomSheet/index.tsx b/src/features/character-management/ui/CharacterBottomSheet/index.tsx index 7d451fcc..6bacadb5 100644 --- a/src/features/character-management/ui/CharacterBottomSheet/index.tsx +++ b/src/features/character-management/ui/CharacterBottomSheet/index.tsx @@ -3,7 +3,7 @@ import Text from '@/shared/ui/Text'; import BottomSheet from '@/shared/ui/BottomSheet'; import ButtonGroup from '@/shared/ui/ButtonGroup'; import Button from '@/shared/ui/Button'; -import { CHARACTER_IMAGE_SIZE } from '@/entities/character/config/character'; +import { CHARACTER_DATA } from '@/entities/character/config/character'; import { ROUTE } from '@/shared/config/route'; import useGetCharacter from '@/features/character-management/api/useGetCharacter'; import * as S from './index.styles'; @@ -33,7 +33,7 @@ function CharacterBottomSheet({ open, setOpen }: CharacterBottomSheetProps) { src={data.imageUrl} alt={data.name} style={{ - ...CHARACTER_IMAGE_SIZE[data.name].small, + ...CHARACTER_DATA[data.name].imageSize.small, }} /> diff --git a/src/features/character-management/ui/CharacterItem/index.tsx b/src/features/character-management/ui/CharacterItem/index.tsx index c014ff11..c284a549 100644 --- a/src/features/character-management/ui/CharacterItem/index.tsx +++ b/src/features/character-management/ui/CharacterItem/index.tsx @@ -17,14 +17,14 @@ interface CharacterCardProps { } function CharacterCard({ character }: CharacterCardProps) { - const { imageUrl, name, unlockedAt } = character; + const { imageUrl, name, acquiredAt } = character; return ( {name} - {unlockedAt ? format(new Date(unlockedAt), 'yyyy.MM.dd') : null} + {acquiredAt ? format(acquiredAt, 'yyyy.MM.dd') : null} ); @@ -35,7 +35,7 @@ interface CharacterItemProps { } function CharacterItem({ character }: CharacterItemProps) { - if (!character.isUnlocked) return ; + if (!character.acquiredAt) return ; return ; } diff --git a/src/features/join/api/useAssignMember.ts b/src/features/join/api/useAssignMember.ts index a9805627..5cd34e4b 100644 --- a/src/features/join/api/useAssignMember.ts +++ b/src/features/join/api/useAssignMember.ts @@ -1,13 +1,30 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useQueryClient } from '@tanstack/react-query'; import { assignMember } from '@/entities/member/api/assignMember'; +import { MemberProfile } from '@/entities/member/model/member.type'; +import useMutationWithHandlers from '@/shared/hooks/useMutationWithHanders'; +import { showToast } from '@/shared/ui/Toast'; const useAssignMember = (groupToken: string) => { const queryClient = useQueryClient(); - return useMutation({ + + return useMutationWithHandlers({ mutationFn: (memberId: number) => assignMember(groupToken, memberId), - onSuccess: () => { - queryClient.removeQueries({ queryKey: ['profiles', groupToken] }); + onSuccess: (data) => { + // 응답으로 받은 업데이트된 멤버 정보로 캐시를 직접 갱신 (리패치 없이 즉시 반영) + queryClient.setQueryData( + ['profiles', groupToken], + (prev) => prev?.map((member) => (member.id === data.id ? data : member)) + ); + }, + // TODO: 에러 코드 확인 후 케이스별 메시지로 세분화 필요 + errorHandlers: { + default: () => + showToast({ + type: 'error', + content: '참여자 선택에 실패했어요. 다시 시도해 주세요.', + }), }, + ignoreBoundaryErrors: [400, 409], }); }; diff --git a/src/mocks/handlers/character.ts b/src/mocks/handlers/character.ts index eb382e4f..23dcc1d3 100644 --- a/src/mocks/handlers/character.ts +++ b/src/mocks/handlers/character.ts @@ -1,45 +1,42 @@ import { http, HttpResponse, passthrough } from 'msw'; -import { CharacterItemsResponse } from '@/entities/character/model/character.type'; +import { CharacterItemsRawResponse } from '@/entities/character/model/character.type'; import getIsMocked from '@/mocks/lib/getIsMocked'; const characterHandlers = [ - http.get('/api/v1/character/collection', async ({ request }) => { + http.get('/api/v1/collections', async ({ request }) => { if (!getIsMocked(request)) return passthrough(); - const dummyCharacterCollectionResponse: CharacterItemsResponse = { - characters: [ + const dummyCharacterCollectionResponse: CharacterItemsRawResponse = { + collections: [ { id: 1, name: '마법사 또또', - isUnlocked: true, imageUrl: 'https://ylsrfiwjufyciwoidcaz.supabase.co/storage/v1/object/public/moddo/wizard_ddoddo.png', imageBigUrl: 'https://ylsrfiwjufyciwoidcaz.supabase.co/storage/v1/object/public/moddo/wizard_ddoddo.png', rarity: 3, - unlockedAt: '2025-02-03T00:00:00Z', + acquiredAt: '2025-02-03T00:00:00Z', }, { id: 2, name: '천사 모또', - isUnlocked: true, imageUrl: 'https://ylsrfiwjufyciwoidcaz.supabase.co/storage/v1/object/public/moddo/angel_moddo.png', imageBigUrl: 'https://ylsrfiwjufyciwoidcaz.supabase.co/storage/v1/object/public/moddo/angel_moddo.png', rarity: 2, - unlockedAt: '2025-02-03T00:00:00Z', + acquiredAt: '2025-02-03T00:00:00Z', }, { id: 3, name: '딸기 또또', - isUnlocked: false, imageUrl: 'https://ylsrfiwjufyciwoidcaz.supabase.co/storage/v1/object/public/moddo/strawberry_ddoddo.png', imageBigUrl: 'https://ylsrfiwjufyciwoidcaz.supabase.co/storage/v1/object/public/moddo/strawberry_ddoddo.png', rarity: 1, - unlockedAt: null, + acquiredAt: null, }, ], }; diff --git a/src/pages/characterShare/CharacterSharePage.tsx b/src/pages/characterShare/CharacterSharePage.tsx index 4b4f8e5f..db32a31a 100644 --- a/src/pages/characterShare/CharacterSharePage.tsx +++ b/src/pages/characterShare/CharacterSharePage.tsx @@ -9,10 +9,7 @@ import { ArrowLeft, Download } from '@/shared/assets/svgs/icon'; import Header from '@/shared/ui/Header'; import Text from '@/shared/ui/Text'; import { BottomButtonContainer } from '@/shared/styles/bottomButton.styles'; -import { - CHARACTER_DESCRIPTION, - CHARACTER_IMAGE_SIZE, -} from '@/entities/character/config/character'; +import { CHARACTER_DATA } from '@/entities/character/config/character'; import StarChip from '@/features/character-management/ui/StarChip'; import useGetCharacter from '@/features/character-management/api/useGetCharacter'; import * as S from './CharacterSharePage.styles'; @@ -101,7 +98,7 @@ function CharacterSharePage() { src={data.imageBigUrl} alt={data.name} style={{ - ...CHARACTER_IMAGE_SIZE[data.name].big, + ...CHARACTER_DATA[data.name].imageSize.big, }} /> @@ -109,7 +106,7 @@ function CharacterSharePage() { {data.name} - {CHARACTER_DESCRIPTION[data.name]} + {CHARACTER_DATA[data.name].description} diff --git a/src/shared/lib/parseDate.ts b/src/shared/lib/parseDate.ts new file mode 100644 index 00000000..295c769b --- /dev/null +++ b/src/shared/lib/parseDate.ts @@ -0,0 +1,13 @@ +import { isValid, parseISO } from 'date-fns'; + +/** + * API 응답의 ISO 날짜 문자열을 Date 객체로 변환합니다. + * + * new Date("invalid")는 truthy한 Invalid Date를 반환해 이후 format() 등에서 RangeError를 반환하기 때문에 parseISO + isValid로 유효성을 검사한 후 변환합니다. + * 유효하지 않으면 null을 반환합니다. + */ +export const parseDate = (value: string | null): Date | null => { + if (!value) return null; + const parsed = parseISO(value); + return isValid(parsed) ? parsed : null; +};