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
19 changes: 12 additions & 7 deletions src/entities/character/api/character.ts
Original file line number Diff line number Diff line change
@@ -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<CharacterItemsResponse>('/character/collection', {
useMock: true,
})
.then((res) => res.data);
export const getCharacterCollection = (): Promise<CharacterItemData[]> =>
axiosInstance.get<CharacterItemsRawResponse>('/collections').then((res) =>
res.data.collections.map((item) => ({
...item,
acquiredAt: parseDate(item.acquiredAt),
}))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);
81 changes: 31 additions & 50 deletions src/entities/character/config/character.ts
Original file line number Diff line number Diff line change
@@ -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<CharacterType, string> = {
'천사 모또': '정산의 수호천사 등장!',
'러키 모또': '정산 성공! 좋은 일만 가득하길~',
'딸기 또또': '정산 완료! 달콤한 하루 보내~',
'잠꾸러기 또또': '정산 끝났어? 이제 푹 잘 수 있겠네~',
'마법사 또또': '정산? 아브라카다브라! 해결 완료~',
};
} satisfies Record<
string,
{
imageSize: {
big: { width?: string; height?: string };
small: { width?: string; height?: string };
};
description: string;
}
>;
23 changes: 13 additions & 10 deletions src/entities/character/model/character.type.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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[];
}
15 changes: 9 additions & 6 deletions src/entities/member/api/assignMember.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
await axiosInstance.post(
): Promise<MemberProfile> => {
const response = await axiosInstance.post<MemberProfileRaw>(
`/groups/${settlementCode}/members/assign`,
{
memberId,
},
{ useMock: true }
{ memberId }
);
return {
...response.data,
paidAt: parseDate(response.data.paidAt),
};
};
13 changes: 8 additions & 5 deletions src/entities/member/api/getProfiles.ts
Original file line number Diff line number Diff line change
@@ -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<MemberProfile[]> => {
const response = await axiosInstance.get<MemberProfileData>(
`/groups/${settlementCode}/members`,
{ useMock: true }
const response = await axiosInstance.get<MemberProfileRawData>(
`/groups/${settlementCode}/members`
);
return response.data.members;
return response.data.members.map((member) => ({
...member,
paidAt: parseDate(member.paidAt),
}));
};
9 changes: 7 additions & 2 deletions src/entities/member/model/member.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ export interface MemberProfile {
paidAt: Date | null;
}

export interface MemberProfileData {
members: MemberProfile[];
// API 원시 응답 타입 (JSON 파싱 직후, 변환 전)
export interface MemberProfileRaw extends Omit<MemberProfile, 'paidAt'> {
paidAt: string | null;
}

export interface MemberProfileRawData {
members: MemberProfileRaw[];
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}),
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
}}
/>
</S.CharacterImageContainer>
Expand Down
6 changes: 3 additions & 3 deletions src/features/character-management/ui/CharacterItem/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@ interface CharacterCardProps {
}

function CharacterCard({ character }: CharacterCardProps) {
const { imageUrl, name, unlockedAt } = character;
const { imageUrl, name, acquiredAt } = character;

return (
<S.CardContainer>
<S.CharacterImage src={imageUrl} alt={name} />
<Text variant="body2Sb">{name}</Text>
<Text variant="caption">
{unlockedAt ? format(new Date(unlockedAt), 'yyyy.MM.dd') : null}
{acquiredAt ? format(acquiredAt, 'yyyy.MM.dd') : null}
</Text>
</S.CardContainer>
);
Expand All @@ -35,7 +35,7 @@ interface CharacterItemProps {
}

function CharacterItem({ character }: CharacterItemProps) {
if (!character.isUnlocked) return <LockedCharacterCard />;
if (!character.acquiredAt) return <LockedCharacterCard />;
return <CharacterCard character={character} />;
}

Expand Down
25 changes: 21 additions & 4 deletions src/features/join/api/useAssignMember.ts
Original file line number Diff line number Diff line change
@@ -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<MemberProfile, Error, number>({
mutationFn: (memberId: number) => assignMember(groupToken, memberId),
onSuccess: () => {
queryClient.removeQueries({ queryKey: ['profiles', groupToken] });
onSuccess: (data) => {
// 응답으로 받은 업데이트된 멤버 정보로 캐시를 직접 갱신 (리패치 없이 즉시 반영)
queryClient.setQueryData<MemberProfile[]>(
['profiles', groupToken],
(prev) => prev?.map((member) => (member.id === data.id ? data : member))
);
},
// TODO: 에러 코드 확인 후 케이스별 메시지로 세분화 필요
errorHandlers: {
default: () =>
showToast({
type: 'error',
content: '참여자 선택에 실패했어요. 다시 시도해 주세요.',
}),
},
ignoreBoundaryErrors: [400, 409],
});
};

Expand Down
17 changes: 7 additions & 10 deletions src/mocks/handlers/character.ts
Original file line number Diff line number Diff line change
@@ -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,
},
],
};
Expand Down
9 changes: 3 additions & 6 deletions src/pages/characterShare/CharacterSharePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -101,15 +98,15 @@ function CharacterSharePage() {
src={data.imageBigUrl}
alt={data.name}
style={{
...CHARACTER_IMAGE_SIZE[data.name].big,
...CHARACTER_DATA[data.name].imageSize.big,
}}
/>
</S.CharacterImageContainer>
<Text variant="heading2" color="semantic.text.strong">
{data.name}
</Text>
<Text variant="body1R" color="semantic.text.subtle">
{CHARACTER_DESCRIPTION[data.name]}
{CHARACTER_DATA[data.name].description}
</Text>
</S.CharacterCard>
</S.CharacterCardContainer>
Expand Down
Loading