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
7 changes: 5 additions & 2 deletions index.html
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
<!doctype html>
<html lang="en">
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="naver-site-verification" content="82b5a25d57eea7a66d7f10181122c7b709df07d0" />
<meta
name="naver-site-verification"
content="82b5a25d57eea7a66d7f10181122c7b709df07d0"
/>
<title>MODDO | 즐거운 모임, 정산까지 즐겁게! 모임 또 모여, 모또</title>
<meta
name="description"
Expand Down
9 changes: 9 additions & 0 deletions src/entities/character/api/character.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import axiosInstance from '@/shared/api/axios';
import { CharacterItemsResponse } from '../model/character.type';

export const getCharacterCollection = () =>
axiosInstance
.get<CharacterItemsResponse>('/character/collection', {
useMock: true,
})
.then((res) => res.data);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
10 changes: 10 additions & 0 deletions src/entities/character/model/character.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,13 @@ export interface CharacterData {
imageUrl: string;
imageBigUrl: string;
}

export interface CharacterItemData extends CharacterData {
id: number;
isUnlocked: boolean;
unlockedAt: string | null;
}

export interface CharacterItemsResponse {
characters: CharacterItemData[];
}
1 change: 1 addition & 0 deletions src/features/character-management/api/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as useGetCharacterCollection } from './useGetCharacterCollection';
16 changes: 16 additions & 0 deletions src/features/character-management/api/useGetCharacterCollection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { getCharacterCollection } from '@/entities/character/api/character';
import { useSuspenseQuery } from '@tanstack/react-query';

const useGetCharacterCollection = () =>
useSuspenseQuery({
queryKey: ['characters'],
queryFn: getCharacterCollection,
// 획득한 캐릭터가 가장 앞에 오도록 정렬
select: (data) =>
[...data.characters].sort((a, b) => {
if (a.isUnlocked === b.isUnlocked) return 0;
return a.isUnlocked ? -1 : 1;
}),
});

export default useGetCharacterCollection;
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import styled from 'styled-components';

export const CharacterList = styled.ul`
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: ${({ theme }) => theme.unit[8]};
padding: ${({ theme }) => theme.unit[20]};
`;
17 changes: 17 additions & 0 deletions src/features/character-management/ui/CharacterGrid/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { useGetCharacterCollection } from '@/features/character-management/api/index';
import CharacterItem from '../CharacterItem';
import * as S from './index.styles';

function CharacterList() {
const { data: characterCollection } = useGetCharacterCollection();
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return (
<S.CharacterList>
{characterCollection.map((character) => (
<CharacterItem key={character.id} character={character} />
))}
</S.CharacterList>
);
}

export default CharacterList;
39 changes: 39 additions & 0 deletions src/features/character-management/ui/CharacterItem/index.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import styled from 'styled-components';

export const CardContainerBase = styled.li`
box-sizing: border-box;
aspect-ratio: 171 / 196;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

카드 비율이 해당 비율로 고정이라 aspect-ratio를 쓰신 걸까요?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵 맞습니다! 피그마에 카드 크기가 width: 171px; height: 196px; 로 되어 있어서 비율 맞춰 두었습니다.
이미지 사이즈가 햄스터 종류마다 달라서 🥹 카드 사이즈 맞추기가 애매해서 aspect-ratio로 비율을 지정하는 방법을 썼어요 😩

min-height: 12.25rem;
display: flex;
flex-direction: column;
align-items: center;
gap: ${({ theme }) => theme.unit[4]};
border-radius: ${({ theme }) => theme.radius.default};
background-color: ${({ theme }) => theme.color.semantic.orange.subtle};
`;

export const CardContainer = styled(CardContainerBase)`
justify-content: flex-end;
padding-top: ${({ theme }) => theme.unit[32]};
padding-bottom: ${({ theme }) => theme.unit[16]};
padding-left: ${({ theme }) => theme.unit[16]};
padding-right: ${({ theme }) => theme.unit[16]};
border: 1px solid ${({ theme }) => theme.color.semantic.orange.subtle};
`;

export const LockedCharacterCard = styled(CardContainerBase)`
justify-content: center;
padding-top: ${({ theme }) => theme.unit[28]};
padding-bottom: ${({ theme }) => theme.unit[36]};
padding-left: ${({ theme }) => theme.unit[16]};
padding-right: ${({ theme }) => theme.unit[16]};
border: 1px dashed ${({ theme }) => theme.color.semantic.border.default};
`;

export const CharacterImage = styled.img`
width: 100%;
flex-shrink: 1;
min-height: 0;
object-fit: contain;
margin-bottom: ${({ theme }) => theme.unit[16]};
`;
42 changes: 42 additions & 0 deletions src/features/character-management/ui/CharacterItem/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { format } from 'date-fns';
import { CharacterItemData } from '@/entities/character/model/character.type';
import { Certified } from '@/shared/assets/svgs/icon';
import Text from '@/shared/ui/Text';
import * as S from './index.styles';

function LockedCharacterCard() {
return (
<S.LockedCharacterCard aria-label="잠긴 캐릭터">
<Certified width={62} />
</S.LockedCharacterCard>
);
}

interface CharacterCardProps {
character: CharacterItemData;
}

function CharacterCard({ character }: CharacterCardProps) {
const { imageUrl, name, unlockedAt } = 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}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

자주 쓰이는 포맷이라면 날짜 포맷팅 유틸로 따로 분리해도 좋을 것 같습니다!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저희 전체 코드에서 날짜 포맷팅을 사용하는 패턴이 아래 4가지더라고요!

format(new Date(unlockedAt), 'yyyy.MM.dd')
format(new Date(), 'yyyy-MM-dd')
format(new Date(expense.date), 'yyyy년 M월 d일')
format(date, 'yyyy년 M월')

각각 포맷이 달라서,,, 이 부분은 유틸로 분리하지 않고 그냥 두도록 하겠습니다 😂

</Text>
Comment thread
yoouyeon marked this conversation as resolved.
</S.CardContainer>
);
}

interface CharacterItemProps {
character: CharacterItemData;
}

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

export default CharacterItem;
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import styled from 'styled-components';

export const Container = styled.div`
display: flex;
flex-direction: column;
padding: ${({ theme }) => `${theme.unit[28]} 0`};
`;

export const TitleWrapper = styled.div`
padding: ${({ theme }) => `${theme.unit[8]} ${theme.unit[20]}`};
`;

export const CharacterGrid = styled.div`
padding: ${({ theme }) => theme.unit[20]};
`;
21 changes: 21 additions & 0 deletions src/features/character-management/ui/CharacterSection/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Suspense } from 'react';
import Text from '@/shared/ui/Text';
import CharacterGrid from '../CharacterGrid';
import * as S from './index.styles';

function CharacterSection() {
return (
<S.Container>
<S.TitleWrapper>
<Text color="semantic.text.strong" variant="title">
캐릭터 도감
</Text>
</S.TitleWrapper>
<Suspense fallback={<S.CharacterGrid>로딩 중...</S.CharacterGrid>}>
<CharacterGrid />
</Suspense>
</S.Container>
);
}

export default CharacterSection;
5 changes: 5 additions & 0 deletions src/features/character-management/ui/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export { default as CharacterBottomSheet } from './CharacterBottomSheet';
export { default as CharacterGrid } from './CharacterGrid';
export { default as CharacterItem } from './CharacterItem';
export { default as CharacterSection } from './CharacterSection';
export { default as StarChip } from './StarChip';
2 changes: 2 additions & 0 deletions src/mocks/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import authHandlers from './handlers/auth';
import expenseHandlers from './handlers/expense';
import groupHandlers from './handlers/group';
import groupMemberHandlers from './handlers/groupMember';
import characterHandlers from './handlers/character';

export const handlers = [
...expenseHandlers,
...authHandlers,
...groupHandlers,
...groupMemberHandlers,
...characterHandlers,
];
51 changes: 51 additions & 0 deletions src/mocks/handlers/character.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { http, HttpResponse, passthrough } from 'msw';
import { CharacterItemsResponse } from '@/entities/character/model/character.type';
import getIsMocked from '@/mocks/lib/getIsMocked';

const characterHandlers = [
http.get('/api/v1/character/collection', async ({ request }) => {
if (!getIsMocked(request)) return passthrough();

const dummyCharacterCollectionResponse: CharacterItemsResponse = {
characters: [
{
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',
},
{
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',
},
{
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,
},
],
};

return HttpResponse.json(dummyCharacterCollectionResponse);
}),
];

export default characterHandlers;
2 changes: 2 additions & 0 deletions src/pages/my/MyPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useTheme } from 'styled-components';
import { Menu } from '@/shared/assets/svgs/icon';
import Header from '@/shared/ui/Header';
import { ROUTE } from '@/shared/config/route';
import { CharacterSection } from '@/features/character-management/ui';
import { MyProfile } from '@/features/user-profile/ui';
import * as S from './MyPage.styles';

Expand All @@ -24,6 +25,7 @@ function MyPage() {
<Suspense fallback={<S.ProfileContainer>로딩 중...</S.ProfileContainer>}>
<MyProfile />
</Suspense>
<CharacterSection />
</>
);
}
Expand Down
4 changes: 2 additions & 2 deletions src/shared/api/axios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ axiosInstance.interceptors.request.use(
if (accessToken) {
newConfig.headers.Authorization = accessToken;
}
/** useMock 설정이 true인 경우에는 X-Mock-Request 헤더를 추가해서 모킹한 API를 사용할 수 있게 하는 interceptor */
if (newConfig.useMock) {
/** 개발 환경에서 useMock 설정이 true인 경우에는 X-Mock-Request 헤더를 추가해서 모킹한 API를 사용할 수 있게 하는 interceptor */
if (import.meta.env.MODE === 'development' && newConfig.useMock) {
newConfig.baseURL = '/api/v1';
newConfig.headers = AxiosHeaders.from({
...newConfig.headers,
Expand Down
27 changes: 27 additions & 0 deletions src/shared/assets/svgs/icon/Certified.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { SVGProps } from 'react';

const SvgCertified = (props: SVGProps<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 61 61"
{...props}
>
<path fill="#D2D4D5" d="M10.415 23.805h40.171v26.78H10.415z" />
<path
fill="#D2D4D5"
d="M35.073 35.074a4.58 4.58 0 0 1-2.538 4.097v4.545a2.033 2.033 0 0 1-4.066 0v-4.54a4.575 4.575 0 1 1 6.604-4.102"
/>
<path
fill="#D2D4D5"
fillRule="evenodd"
d="M45.202 20.332c-.116-2.765-.56-6.049-2.124-8.867-1.013-1.822-2.494-3.452-4.617-4.61-2.109-1.15-4.737-1.773-7.963-1.773s-5.854.623-7.963 1.773c-2.123 1.158-3.604 2.788-4.616 4.61-1.566 2.818-2.008 6.102-2.124 8.867H12.2a4.575 4.575 0 0 0-4.575 4.575v23.892a4.575 4.575 0 0 0 4.575 4.575h36.598a4.575 4.575 0 0 0 4.575-4.575V24.907a4.575 4.575 0 0 0-4.575-4.575zm-25.335 0c.116-2.433.5-4.901 1.606-6.892.682-1.227 1.637-2.266 3.01-3.015 1.385-.755 3.31-1.276 6.015-1.276s4.63.52 6.016 1.276c1.372.749 2.327 1.788 3.009 3.015 1.105 1.99 1.49 4.459 1.606 6.892zm-2.075 4.067H12.2a.51.51 0 0 0-.508.508v23.892c0 .28.227.508.508.508h36.598c.28 0 .508-.228.508-.508V24.907a.51.51 0 0 0-.508-.508H17.792"
clipRule="evenodd"
/>
<path
fill="#F1F3F5"
d="M35.073 35.075a4.58 4.58 0 0 1-2.538 4.097v4.545a2.033 2.033 0 0 1-4.066 0v-4.54a4.575 4.575 0 1 1 6.604-4.102"
/>
</svg>
);
export default SvgCertified;
1 change: 1 addition & 0 deletions src/shared/assets/svgs/icon/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export { default as Bell } from './Bell';
export { default as BtnShortcut } from './BtnShortcut';
export { default as Calendar } from './Calendar';
export { default as CarbonEdit } from './CarbonEdit';
export { default as Certified } from './Certified';
export { default as CheckCircle } from './CheckCircle';
export { default as Close } from './Close';
export { default as Confirm } from './Confirm';
Expand Down