diff --git a/src/app/Router.tsx b/src/app/Router.tsx
index 23d3a7cd..cb879151 100644
--- a/src/app/Router.tsx
+++ b/src/app/Router.tsx
@@ -6,6 +6,8 @@ import RouteErrorElement from '@/app/RouteErrorElement';
import checkAuth from '@/entities/auth/lib/checkAuth';
import groupTokenUrlLoader from '@/entities/auth/lib/groupTokenUrlLoader';
import createExpensePageGuardLoader from '@/pages/CreateExpensePage/lib/createExpensePageGuardLoader';
+import joinLoader from '@/pages/join/loader';
+import expenseDetailLoader from '@/pages/expenseDetail/loader';
const LazyExpenseDetail = lazy(() =>
import('@/pages/expenseDetail/').then(({ ExpenseDetailPage }) => ({
@@ -58,6 +60,11 @@ const LazySelectGroup = lazy(() =>
default: SelectGroupPage,
}))
);
+const LazyJoinPage = lazy(() =>
+ import('@/pages/join').then(({ JoinPage }) => ({
+ default: JoinPage,
+ }))
+);
const LazyNotFound = lazy(() =>
import('@/pages/notFound').then(({ NotFoundPage }) => ({
default: NotFoundPage,
@@ -122,10 +129,15 @@ function AppRouter() {
],
},
// TODO : 로그인 기능으로 변경될 예정
+ {
+ path: ROUTE.join,
+ element: ,
+ loader: joinLoader,
+ },
{
path: ROUTE.expenseDetail,
element: ,
- loader: groupTokenUrlLoader,
+ loader: expenseDetailLoader,
},
{
path: ROUTE.characterShare,
diff --git a/src/entities/auth/api/auth.ts b/src/entities/auth/api/auth.ts
index adb2a80c..b1b3c42b 100644
--- a/src/entities/auth/api/auth.ts
+++ b/src/entities/auth/api/auth.ts
@@ -24,7 +24,7 @@ export const getAuth = async () => {
};
export const getUserInfo = async () => {
- const response = await axiosInstance.get('/user/info', {
+ const response = await axiosInstance.get('/user', {
useMock: true,
});
diff --git a/src/entities/group/api/group.ts b/src/entities/group/api/group.ts
index a48ba2c9..f088c587 100644
--- a/src/entities/group/api/group.ts
+++ b/src/entities/group/api/group.ts
@@ -40,10 +40,11 @@ export const putGroupAccount = async ({
return response.data;
};
+// NOTE : 기존에 groupToken을 전달하는 방식에서 settlementCode를 전달하는 방식으로 변경함
export const getGroupHeader = (
- groupToken: string
+ settlementCode: string
): Promise => {
return axiosInstance
- .get(`/group/header?groupToken=${groupToken}`)
+ .get(`/groups/${settlementCode}/header`)
.then((res) => res.data);
};
diff --git a/src/entities/member/api/assignMember.ts b/src/entities/member/api/assignMember.ts
new file mode 100644
index 00000000..cff5f358
--- /dev/null
+++ b/src/entities/member/api/assignMember.ts
@@ -0,0 +1,15 @@
+import axiosInstance from '@/shared/api/axios';
+
+// 참여자 선택 api (로그인한 참여자가 정산에 참여하도록 프로필 설정)
+export const assignMember = async (
+ settlementCode: string,
+ memberId: number
+): Promise => {
+ await axiosInstance.post(
+ `/groups/${settlementCode}/members/assign`,
+ {
+ memberId,
+ },
+ { useMock: true }
+ );
+};
diff --git a/src/entities/member/api/getProfiles.ts b/src/entities/member/api/getProfiles.ts
new file mode 100644
index 00000000..0d08bf9e
--- /dev/null
+++ b/src/entities/member/api/getProfiles.ts
@@ -0,0 +1,14 @@
+import axiosInstance from '@/shared/api/axios';
+import { MemberProfile, MemberProfileData } 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 }
+ );
+ return response.data.members;
+};
diff --git a/src/entities/member/model/member.type.ts b/src/entities/member/model/member.type.ts
index 1fd75101..88fe6c5a 100644
--- a/src/entities/member/model/member.type.ts
+++ b/src/entities/member/model/member.type.ts
@@ -15,3 +15,18 @@ export interface MemberData {
name: string;
role: MemberRole;
}
+
+// 정산 참여자의 프로필 선택 시 필요한 정보
+export interface MemberProfile {
+ id: number;
+ role: MemberRole;
+ name: string;
+ profile: string;
+ userId: number | null; // userId는 로그인한 사용자의 ID와 매칭되어야 함
+ isPaid: boolean;
+ paidAt: Date | null;
+}
+
+export interface MemberProfileData {
+ members: MemberProfile[];
+}
diff --git a/src/features/expense-management/ui/MemberExpenses/index.tsx b/src/features/expense-management/ui/MemberExpenses/index.tsx
index 7654601b..46b579f1 100644
--- a/src/features/expense-management/ui/MemberExpenses/index.tsx
+++ b/src/features/expense-management/ui/MemberExpenses/index.tsx
@@ -1,6 +1,6 @@
import NumberInput from '@/features/expense-management/ui/NumberInput';
import { ExpenseFormMember } from '@/entities/expense/model/expense.type';
-import MemberProfile from '@/shared/ui/MemberProfile';
+import Profile from '@/shared/ui/Profile';
import * as S from './index.styles';
interface MemberExpensesProps {
@@ -14,12 +14,12 @@ function MemberExpenses({ members, onDelete }: MemberExpensesProps) {
{members.map((member) => (
- onDelete(member.name)}
+ imageSrc={member.profile}
+ type="delete"
+ onDelete={() => onDelete(member.name)}
/>
{
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (memberId: number) => assignMember(groupToken, memberId),
+ onSuccess: () => {
+ queryClient.removeQueries({ queryKey: ['profiles', groupToken] });
+ },
+ });
+};
+
+export default useAssignMember;
diff --git a/src/features/payment-management/ui/PaymentAlert/index.tsx b/src/features/payment-management/ui/PaymentAlert/index.tsx
index 973dd086..e4a16fae 100644
--- a/src/features/payment-management/ui/PaymentAlert/index.tsx
+++ b/src/features/payment-management/ui/PaymentAlert/index.tsx
@@ -1,8 +1,8 @@
import type { PaymentRequest } from '@/entities/payment/model/payment.type';
-import MemberProfileImage from '@/shared/ui/MemberProfileImage';
import Button from '@/shared/ui/Button';
import Text from '@/shared/ui/Text';
import Flex from '@/shared/ui/Flex';
+import ProfileImage from '@/shared/ui/ProfileImage';
export interface PaymentAlertProps {
payment: PaymentRequest;
@@ -13,7 +13,7 @@ export interface PaymentAlertProps {
function PaymentAlert({ payment, onReject, onConfirm }: PaymentAlertProps) {
return (
-
+
-
+
{profile.name}
{/* TODO: 디자인 시스템 정비 후 다시 디자인 확인이 필요합니다 (Opacity를 계속 쓰는지?) */}
diff --git a/src/mocks/handlers/auth.ts b/src/mocks/handlers/auth.ts
index f4fd8ab9..c7c06db3 100644
--- a/src/mocks/handlers/auth.ts
+++ b/src/mocks/handlers/auth.ts
@@ -24,7 +24,7 @@ const authHandlers = [
});
}),
- http.get('/api/v1/user/info', ({ request }) => {
+ http.get('/api/v1/user', ({ request }) => {
const isMocked = request.headers.get('X-Mock-Request');
if (!isMocked || isMocked !== 'true') return passthrough();
diff --git a/src/mocks/handlers/group.ts b/src/mocks/handlers/group.ts
index 48005168..27295fe8 100644
--- a/src/mocks/handlers/group.ts
+++ b/src/mocks/handlers/group.ts
@@ -65,6 +65,36 @@ const dummyGroups: Group[] = [
},
];
+const dummyMemberList = [
+ {
+ id: 1,
+ role: 'MANAGER',
+ name: '김모또',
+ profile: '',
+ userId: null as number | null,
+ isPaid: false,
+ paidAt: null,
+ },
+ {
+ id: 2,
+ role: 'PARTICIPANT',
+ name: '박완숙',
+ profile: '',
+ userId: null as number | null,
+ isPaid: false,
+ paidAt: null,
+ },
+ {
+ id: 3,
+ role: 'PARTICIPANT',
+ name: '정에그',
+ profile: '',
+ userId: 3,
+ isPaid: false,
+ paidAt: null,
+ },
+];
+
const groupHandlers = [
// GET GetGroupList
http.get('/api/v1/groups', ({ request }) => {
@@ -75,7 +105,18 @@ const groupHandlers = [
});
}),
+ // GET GetGroupHeader (path 방식)
+ // 모임 상단 조회
+ http.get('/api/v1/groups/:groupToken/header', ({ request }) => {
+ if (!getIsMocked(request)) return passthrough();
+
+ return HttpResponse.json({
+ ...dummyGroups[0],
+ });
+ }),
+
// GET GetGroupOne
+ // TODO: /api/v1/groups/:groupToken/header 로 대체 예정, 삭제 필요
http.get(`/api/v1/group`, ({ request }) => {
if (!getIsMocked(request)) return passthrough();
@@ -140,6 +181,40 @@ const groupHandlers = [
});
}
),
+
+ // GET /api/v1/groups/:settlementCode/members
+ http.get('/api/v1/groups/:settlementCode/members', ({ request, params }) => {
+ if (!getIsMocked(request)) return passthrough();
+
+ const { settlementCode } = params;
+
+ if (!settlementCode) {
+ return HttpResponse.json(
+ { error: 'settlementCode is required' },
+ { status: 400 }
+ );
+ }
+
+ return HttpResponse.json({ members: dummyMemberList });
+ }),
+
+ http.post<{ settlementCode: string }, { memberId: number }>(
+ '/api/v1/groups/:settlementCode/members/assign',
+ async ({ request, params }) => {
+ if (!getIsMocked(request)) return passthrough();
+
+ const { settlementCode } = params;
+ const { memberId } = await request.json();
+
+ console.log(`settlementCode: ${settlementCode}, memberId: ${memberId}`);
+
+ // mock user id: 1 (auth.ts 참고)
+ const target = dummyMemberList.find((m) => m.id === memberId);
+ if (target) target.userId = 1;
+
+ return HttpResponse.json({ success: true }, { status: 200 });
+ }
+ ),
];
export default groupHandlers;
diff --git a/src/pages/expenseDetail/loader.ts b/src/pages/expenseDetail/loader.ts
new file mode 100644
index 00000000..cd85e034
--- /dev/null
+++ b/src/pages/expenseDetail/loader.ts
@@ -0,0 +1,63 @@
+// 정산 상세 페이지 전 거치는 로더
+// TODO : 기존 groupToken들을 사용하는 방식을 settlementCode를 사용하는 방식으로 변경했음. 동작 확인 필요함.
+
+import { getUserInfo } from '@/entities/auth/api/auth';
+import { getGroupHeader } from '@/entities/group/api/group';
+import { getProfiles } from '@/entities/member/api/getProfiles';
+import { queryClient } from '@/shared/api/queryClient';
+import { ROUTE } from '@/shared/config/route';
+import { BoundaryError } from '@/shared/types/error.type';
+import { isAxiosError } from 'axios';
+import { LoaderFunctionArgs, redirect } from 'react-router';
+
+async function expenseDetailLoader({ params }: LoaderFunctionArgs) {
+ // TODO: groupToken → settlementCode 마이그레이션 시 파라미터 이름 변경 필요
+ const { groupToken } = params;
+
+ if (!groupToken) return redirect(ROUTE.home);
+
+ try {
+ // 1. 로그인 여부 확인
+ // TODO: getUserInfo 401 발생 시 axiosInstance 인터셉터가 window.location.href로 처리해 returnUrl이 무시됨. 인터셉터를 React Router redirect 방식으로 교체 필요. (https://moddo2.atlassian.net/browse/MD-25)
+ const user = await queryClient.ensureQueryData({
+ queryKey: ['userInfo'],
+ queryFn: getUserInfo,
+ });
+ // TODO: 로그인 페이지에서 성공 후 returnUrl 처리 필요함
+ if (!user) {
+ const returnUrl = encodeURIComponent(`/expense-detail/${groupToken}`);
+ return redirect(`/login?returnUrl=${returnUrl}`);
+ }
+
+ // 2. 프로필 선택 여부 확인
+ const profiles = await queryClient.ensureQueryData({
+ queryKey: ['profiles', groupToken],
+ queryFn: () => getProfiles(groupToken),
+ });
+ const myProfile =
+ profiles.find((profile) => profile.userId === user.id) ?? null;
+ if (!myProfile) return redirect(`/join/${groupToken}`);
+
+ const groupData = await queryClient.ensureQueryData({
+ queryKey: ['groupHeader', groupToken],
+ queryFn: () => getGroupHeader(groupToken),
+ });
+
+ return { groupToken, groupData, myProfile };
+ } catch (error: unknown) {
+ if (isAxiosError(error)) {
+ // CHECK - 문서에는 401 에러로 되어있지만 실제로는 500 에러가 발생함
+ if (error.response?.status === 401) {
+ throw new BoundaryError({
+ title: '접근 권한이 없어요',
+ description: '참여한 모임의 정산만 확인할 수 있어요.',
+ });
+ }
+ }
+
+ // 그 외에는 그대로 전달
+ throw error;
+ }
+}
+
+export default expenseDetailLoader;
diff --git a/src/pages/expenseDetail/ui/ExpenseMemberItem/index.tsx b/src/pages/expenseDetail/ui/ExpenseMemberItem/index.tsx
index b6f0bb13..3bb1fdd3 100644
--- a/src/pages/expenseDetail/ui/ExpenseMemberItem/index.tsx
+++ b/src/pages/expenseDetail/ui/ExpenseMemberItem/index.tsx
@@ -8,7 +8,7 @@ import { Close, Confirm, Receipt } from '@/shared/assets/svgs/icon';
import BottomSheet from '@/shared/ui/BottomSheet';
import { MemberSettlement } from '@/entities/settlement/model/settlement.type';
import useUpdatePaymentStatus from '@/features/settlement-details/api/useUpdatePaymentStatus';
-import MemberProfileImage from '@/shared/ui/MemberProfileImage';
+import ProfileImage from '@/shared/ui/ProfileImage';
import * as S from './index.style';
import StatusChip from './ui/StatusChip';
@@ -65,7 +65,7 @@ function ExpenseMemberItem({
-
+
diff --git a/src/pages/expenseDetail/ui/ExpenseTimeHeader/index.stories.tsx b/src/pages/expenseDetail/ui/ExpenseTimeHeader/index.stories.tsx
index 911e3cfc..1b9fc529 100644
--- a/src/pages/expenseDetail/ui/ExpenseTimeHeader/index.stories.tsx
+++ b/src/pages/expenseDetail/ui/ExpenseTimeHeader/index.stories.tsx
@@ -22,7 +22,7 @@ const meta: Meta = {
chromatic: { disableSnapshot: false },
msw: {
handlers: [
- http.get('/group/header', () => {
+ http.get('/groups/:settlementCode/header', () => {
return HttpResponse.json({
groupName: '모또 정기모임',
totalAmount: 150000,
diff --git a/src/pages/join/JoinPage.styles.ts b/src/pages/join/JoinPage.styles.ts
new file mode 100644
index 00000000..483bbaf9
--- /dev/null
+++ b/src/pages/join/JoinPage.styles.ts
@@ -0,0 +1,44 @@
+import styled from 'styled-components';
+
+export const ScrollWrapper = styled.div`
+ flex: 1;
+ position: relative;
+ overflow: hidden;
+`;
+
+export const ScrollArea = styled.div`
+ height: 100%;
+ overflow-y: auto;
+`;
+
+// TODO: 공통 breakpoint를 정해두는게 좋을 것 같습니다!
+// TODO: 피그마 디자인 기준 4열이나, 390px 이상 5열 / 600px 이상 6열 반응형 처리에 대한 디자인 의견이 필요합니다.
+export const ProfileGrid = styled.div`
+ display: grid;
+ grid-template-columns: repeat(
+ 4,
+ 1fr
+ ); // 기본적으로 4열로 시작 (피그마 디자인 기준)
+ justify-items: center;
+ padding: ${({ theme }) => theme.unit[16]};
+
+ // 피그마 디자인의 Frame 너비가 390px 이상이므로, 390px 이상에서는 5열로 변경
+ @media (min-width: 390px) {
+ grid-template-columns: repeat(5, 1fr);
+ }
+
+ // body 최대 너비가 600px 이므로, 600px 이상에서는 6열로 변경
+ @media (min-width: 600px) {
+ grid-template-columns: repeat(6, 1fr);
+ }
+`;
+
+export const GradientOverlay = styled.div`
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ height: 200px;
+ background: linear-gradient(to bottom, rgba(255, 255, 255, 0), white);
+ pointer-events: none;
+`;
diff --git a/src/pages/join/JoinPage.tsx b/src/pages/join/JoinPage.tsx
new file mode 100644
index 00000000..2709fd7e
--- /dev/null
+++ b/src/pages/join/JoinPage.tsx
@@ -0,0 +1,94 @@
+import { ArrowLeft } from '@/shared/assets/svgs/icon';
+import { BottomButtonContainer } from '@/shared/styles/bottomButton.styles';
+import Button from '@/shared/ui/Button';
+import DescriptionField from '@/shared/ui/DescriptionField';
+import Header from '@/shared/ui/Header';
+import Text from '@/shared/ui/Text';
+import { useLoaderData, useNavigate, useParams } from 'react-router';
+import { useTheme } from 'styled-components';
+import { MemberProfile } from '@/entities/member/model/member.type';
+import { useState } from 'react';
+import useAssignMember from '@/features/join/api/useAssignMember';
+import Profile from '@/shared/ui/Profile';
+import * as S from './JoinPage.styles';
+
+function JoinPage() {
+ const { unit } = useTheme();
+ const navigate = useNavigate();
+ // TODO: groupToken → settlementCode 마이그레이션 시 파라미터 이름 변경 필요
+ const { groupToken } = useParams();
+ const { profiles } = useLoaderData() as { profiles: MemberProfile[] };
+ const [selectedId, setSelectedId] = useState(null);
+ const { mutate: assignMember, isPending } = useAssignMember(groupToken!);
+
+ const handleSelect = (id: number) => {
+ setSelectedId(id);
+ };
+
+ const handleConfirm = () => {
+ if (selectedId === null || !groupToken) return;
+ assignMember(selectedId, {
+ onSuccess: () => {
+ console.log('프로필 선택 성공');
+ navigate(`/expense-detail/${groupToken}`);
+ },
+ });
+ };
+
+ const getProfileType = (profile: MemberProfile) => {
+ if (profile.userId !== null) return 'disabled';
+ if (profile.id === selectedId) return 'checked';
+ return 'default';
+ };
+
+ return (
+ <>
+
+
+ 뒤로가기
+ >
+ }
+ leftButtonOnClick={() => navigate(-1)}
+ />
+
+
+
+
+ {profiles.map((profile) => (
+ handleSelect(profile.id)
+ : undefined
+ }
+ />
+ ))}
+
+
+
+
+
+
+
+ >
+ );
+}
+
+export default JoinPage;
diff --git a/src/pages/join/index.ts b/src/pages/join/index.ts
new file mode 100644
index 00000000..fc464fda
--- /dev/null
+++ b/src/pages/join/index.ts
@@ -0,0 +1 @@
+export { default as JoinPage } from './JoinPage';
diff --git a/src/pages/join/loader.ts b/src/pages/join/loader.ts
new file mode 100644
index 00000000..d71eb15c
--- /dev/null
+++ b/src/pages/join/loader.ts
@@ -0,0 +1,42 @@
+// 정산 참여 페이지 전 거치는 로더
+// TODO : 기존 groupToken들을 사용하는 방식을 settlementCode를 사용하는 방식으로 변경해야 함.
+
+import { getUserInfo } from '@/entities/auth/api/auth';
+import { getProfiles } from '@/entities/member/api/getProfiles';
+import { queryClient } from '@/shared/api/queryClient';
+import { ROUTE } from '@/shared/config/route';
+import { LoaderFunctionArgs, redirect } from 'react-router';
+
+async function joinLoader({ params }: LoaderFunctionArgs) {
+ // TODO: groupToken → settlementCode 마이그레이션 시 파라미터 이름 변경 필요
+ const { groupToken } = params;
+
+ if (!groupToken) return redirect(ROUTE.home);
+
+ // 1. 로그인 여부 확인
+ // TODO: getUserInfo 401 발생 시 axiosInstance 인터셉터가 window.location.href로 처리해 returnUrl이 무시됨. 인터셉터를 React Router redirect 방식으로 교체 필요. (https://moddo2.atlassian.net/browse/MD-25)
+ const user = await queryClient.ensureQueryData({
+ queryKey: ['userInfo'],
+ queryFn: getUserInfo,
+ });
+
+ if (!user) {
+ const returnUrl = encodeURIComponent(`/join/${groupToken}`);
+ return redirect(`/login?returnUrl=${returnUrl}`);
+ }
+
+ // 2. 표시할 프로필 목록 조회
+ const profiles = await queryClient.ensureQueryData({
+ queryKey: ['profiles', groupToken],
+ queryFn: () => getProfiles(groupToken),
+ });
+
+ // 3. 본인 프로필을 선택했는지 확인
+ const myProfile =
+ profiles.find((profile) => profile.userId === user.id) ?? null;
+ if (myProfile) return redirect(`/expense-detail/${groupToken}`);
+
+ return { profiles };
+}
+
+export default joinLoader;
diff --git a/src/pages/memberSetup/ui/AddMember/index.tsx b/src/pages/memberSetup/ui/AddMember/index.tsx
index 82405c7c..60415e27 100644
--- a/src/pages/memberSetup/ui/AddMember/index.tsx
+++ b/src/pages/memberSetup/ui/AddMember/index.tsx
@@ -3,7 +3,7 @@ import * as z from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { Member } from '@/entities/member/model/member.type';
import Text from '@/shared/ui/Text';
-import MemberProfile from '@/shared/ui/MemberProfile';
+import Profile from '@/shared/ui/Profile';
import InputGroup from '@/shared/ui/InputGroup';
import Input from '@/shared/ui/Input';
import Button from '@/shared/ui/Button';
@@ -107,13 +107,13 @@ function AddMember({ members, groupToken }: AddMemberProps) {
{members.map((member) => (
-
))}
diff --git a/src/shared/config/route.ts b/src/shared/config/route.ts
index 0a8061f8..bd8b049c 100644
--- a/src/shared/config/route.ts
+++ b/src/shared/config/route.ts
@@ -10,6 +10,7 @@ export const ROUTE = {
createExpense: '/create-expense/:groupToken',
selectGroup: '/select-group',
groupSetup: '/group-setup',
+ join: '/join/:groupToken',
expenseDetail: '/expense-detail/:groupToken',
characterShare: '/expense-detail/:groupToken/character',
} as const;
diff --git a/src/shared/ui/MemberProfile/index.stories.ts b/src/shared/ui/MemberProfile/index.stories.ts
deleted file mode 100644
index 0f126643..00000000
--- a/src/shared/ui/MemberProfile/index.stories.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import type { Meta, StoryObj } from '@storybook/react';
-import MemberProfile from './index';
-
-const meta: Meta = {
- title: 'ui/MemberProfile',
- component: MemberProfile,
- parameters: {
- chromatic: { disableSnapshot: false },
- },
-};
-export default meta;
-type Story = StoryObj;
-
-export const ManagerProfile: Story = {
- args: {
- id: 1,
- name: '모또',
- canDelete: false,
- handleDeleteButtonClick: () => {
- alert('삭제 버튼 클릭됨');
- },
- },
-};
-
-export const ParticipantProfile: Story = {
- args: {
- id: 1,
- name: '모또',
- canDelete: true,
- handleDeleteButtonClick: () => {
- alert('삭제 버튼 클릭됨');
- },
- },
-};
diff --git a/src/shared/ui/MemberProfile/index.style.ts b/src/shared/ui/MemberProfile/index.style.ts
deleted file mode 100644
index 61ba4f86..00000000
--- a/src/shared/ui/MemberProfile/index.style.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import styled from 'styled-components';
-
-export const ProfileImg = styled.img`
- width: 3rem;
- height: 3rem;
- object-fit: contain;
- border-radius: 50%;
-`;
-
-export const ProfileWrapper = styled.div`
- position: relative; // 부모 요소
- display: flex;
- align-items: flex-start;
- justify-content: flex-end;
-`;
-
-export const DeleteButton = styled.button`
- position: absolute; // 자식 요소
- width: fit-content;
- height: fit-content;
- &:hover {
- filter: brightness(0.6);
- }
-`;
diff --git a/src/shared/ui/MemberProfile/index.tsx b/src/shared/ui/MemberProfile/index.tsx
deleted file mode 100644
index 8537f9ef..00000000
--- a/src/shared/ui/MemberProfile/index.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import defaultProfileImg from '@/shared/assets/pngs/defaultProfileImg.png';
-import { SystemDanger } from '@/shared/assets/svgs/icon';
-import Text from '@/shared/ui/Text';
-import Flex from '@/shared/ui/Flex';
-import * as S from './index.style';
-
-interface MemberProfileProps {
- id: number;
- profile?: string;
- name: string;
- canDelete?: boolean;
- handleDeleteButtonClick: (id: number) => void;
-}
-
-function MemberProfile({
- id,
- profile,
- name,
- canDelete = true,
- handleDeleteButtonClick,
-}: MemberProfileProps) {
- return (
-
-
- {canDelete && (
- handleDeleteButtonClick(id)}>
-
-
- )}
-
-
- {name}
-
- );
-}
-
-export default MemberProfile;
diff --git a/src/shared/ui/MemberProfileImage/index.stories.ts b/src/shared/ui/MemberProfileImage/index.stories.ts
deleted file mode 100644
index a142e9a5..00000000
--- a/src/shared/ui/MemberProfileImage/index.stories.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import type { Meta, StoryObj } from '@storybook/react';
-import MemberProfileImage from './index';
-
-const meta: Meta = {
- title: 'components/MemberProfileImage',
- component: MemberProfileImage,
- tags: ['autodocs'],
- parameters: {
- chromatic: { disableSnapshot: false },
- },
-};
-
-export default meta;
-type Story = StoryObj;
-
-export const SmallProfileImage: Story = {
- args: {
- src: '',
- size: 'sm',
- },
-};
-
-export const MediumProfileImage: Story = {
- args: {
- src: '',
- size: 'md',
- },
-};
-
-export const LargeProfileImage: Story = {
- args: {
- src: '',
- size: 'lg',
- },
-};
diff --git a/src/shared/ui/MemberProfileImage/index.tsx b/src/shared/ui/MemberProfileImage/index.tsx
deleted file mode 100644
index 1ada6880..00000000
--- a/src/shared/ui/MemberProfileImage/index.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-import defaultProfileImg from '@/shared/assets/pngs/defaultProfileImg.png';
-import * as S from './index.styles';
-
-interface MemberProfileImageProps {
- src?: string;
- size: 'sm' | 'md' | 'lg';
-}
-
-// TODO: Profile 디자인시스템 컴포넌트 정의 필요
-function MemberProfileImage({ src, size }: MemberProfileImageProps) {
- return ;
-}
-
-export default MemberProfileImage;
diff --git a/src/shared/ui/Profile/index.stories.ts b/src/shared/ui/Profile/index.stories.ts
new file mode 100644
index 00000000..77926785
--- /dev/null
+++ b/src/shared/ui/Profile/index.stories.ts
@@ -0,0 +1,76 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import Profile from './index';
+
+const meta: Meta = {
+ title: 'ui/Profile',
+ component: Profile,
+ tags: ['autodocs'],
+ parameters: {
+ chromatic: { disableSnapshot: false },
+ },
+};
+export default meta;
+type Story = StoryObj;
+
+export const SizeS: Story = {
+ args: {
+ id: 1,
+ name: '모또',
+ size: 'S',
+ type: 'default',
+ },
+};
+
+export const SizeM: Story = {
+ args: {
+ id: 1,
+ name: '모또',
+ size: 'm',
+ type: 'default',
+ },
+};
+
+export const SizeL: Story = {
+ args: {
+ id: 1,
+ name: '모또',
+ size: 'L',
+ type: 'default',
+ },
+};
+
+export const DeleteType: Story = {
+ args: {
+ id: 1,
+ name: '모또',
+ size: 'm',
+ type: 'delete',
+ onDelete: () => {
+ alert('삭제 버튼 클릭됨');
+ },
+ onClick: () => {
+ alert('프로필 클릭됨');
+ },
+ },
+};
+
+export const CheckedType: Story = {
+ args: {
+ id: 1,
+ name: '모또',
+ size: 'm',
+ type: 'checked',
+ onClick: () => {
+ alert('프로필 클릭됨');
+ },
+ },
+};
+
+export const DisabledType: Story = {
+ args: {
+ id: 1,
+ name: '모또',
+ size: 'm',
+ type: 'disabled',
+ },
+};
diff --git a/src/shared/ui/Profile/index.style.ts b/src/shared/ui/Profile/index.style.ts
new file mode 100644
index 00000000..afbfd84c
--- /dev/null
+++ b/src/shared/ui/Profile/index.style.ts
@@ -0,0 +1,30 @@
+import styled from 'styled-components';
+
+export const ProfileWrapper = styled.div`
+ position: relative; // 부모 요소
+ display: flex;
+ align-items: flex-start;
+ justify-content: flex-end;
+`;
+
+export const DeleteButton = styled.button`
+ position: absolute; // 자식 요소
+ width: fit-content;
+ height: fit-content;
+ &:hover {
+ filter: brightness(0.6);
+ }
+`;
+
+export const CheckedIcon = styled.div<{ $size: number }>`
+ position: absolute; // 자식 요소
+ top: -0.25rem;
+ right: -0.25rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: ${({ $size }) => $size}px;
+ height: ${({ $size }) => $size}px;
+ background-color: ${({ theme }) => theme.color.semantic.icon.default};
+ border-radius: ${({ theme }) => theme.radius.circle};
+`;
diff --git a/src/shared/ui/Profile/index.tsx b/src/shared/ui/Profile/index.tsx
new file mode 100644
index 00000000..6824bb37
--- /dev/null
+++ b/src/shared/ui/Profile/index.tsx
@@ -0,0 +1,79 @@
+import { MouseEventHandler } from 'react';
+import { CheckCircle, SystemDanger } from '@/shared/assets/svgs/icon';
+import ProfileImage from '@/shared/ui/ProfileImage';
+import Text from '@/shared/ui/Text';
+import Flex from '@/shared/ui/Flex';
+import * as S from './index.style';
+
+interface ProfileProps {
+ id: number;
+ imageSrc?: string;
+ name: string;
+ size?: 'S' | 'm' | 'L';
+ type?: 'default' | 'delete' | 'checked' | 'disabled';
+ onDelete?: (id: number) => void;
+ onClick?: () => void;
+}
+
+const sizeConfig = {
+ S: { imageSize: '36' as const, iconPx: 20, textVariant: 'caption' as const },
+ m: { imageSize: '48' as const, iconPx: 24, textVariant: 'body2R' as const },
+ L: { imageSize: '68' as const, iconPx: 24, textVariant: 'body2R' as const },
+};
+
+function Profile({
+ id,
+ imageSrc,
+ name,
+ size = 'm',
+ type = 'default',
+ onDelete,
+ onClick,
+}: ProfileProps) {
+ const { imageSize, iconPx, textVariant } = sizeConfig[size];
+
+ const handleClick: MouseEventHandler = (e) => {
+ // onDelete 이벤트와 별개로 동작하기 위해 전파 방지
+ e.stopPropagation();
+ onClick?.();
+ };
+
+ const handleDeleteClick: MouseEventHandler = (e) => {
+ // onClick 이벤트와 별개로 동작하기 위해 전파 방지
+ e.stopPropagation();
+ onDelete?.(id);
+ };
+
+ return (
+
+
+ {type === 'delete' && (
+
+
+
+ )}
+ {type === 'checked' && (
+
+
+
+ )}
+
+
+ {name}
+
+ );
+}
+
+export default Profile;
diff --git a/src/shared/ui/ProfileImage/index.stories.ts b/src/shared/ui/ProfileImage/index.stories.ts
new file mode 100644
index 00000000..8643c0ad
--- /dev/null
+++ b/src/shared/ui/ProfileImage/index.stories.ts
@@ -0,0 +1,42 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import ProfileImage from './index';
+
+const meta: Meta = {
+ title: 'components/ProfileImage',
+ component: ProfileImage,
+ tags: ['autodocs'],
+ parameters: {
+ chromatic: { disableSnapshot: false },
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Size36: Story = {
+ args: {
+ src: '',
+ size: '36',
+ },
+};
+
+export const Size40: Story = {
+ args: {
+ src: '',
+ size: '40',
+ },
+};
+
+export const Size48: Story = {
+ args: {
+ src: '',
+ size: '48',
+ },
+};
+
+export const Size68: Story = {
+ args: {
+ src: '',
+ size: '68',
+ },
+};
diff --git a/src/shared/ui/MemberProfileImage/index.styles.ts b/src/shared/ui/ProfileImage/index.styles.ts
similarity index 73%
rename from src/shared/ui/MemberProfileImage/index.styles.ts
rename to src/shared/ui/ProfileImage/index.styles.ts
index dc500253..c2128f8c 100644
--- a/src/shared/ui/MemberProfileImage/index.styles.ts
+++ b/src/shared/ui/ProfileImage/index.styles.ts
@@ -1,13 +1,14 @@
import styled from 'styled-components';
interface ProfileImgProps {
- $size: 'sm' | 'md' | 'lg';
+ $size: '36' | '40' | '48' | '68';
}
const sizeMap = {
- sm: '2.25rem',
- md: '2.5rem',
- lg: '4.25rem',
+ '36': '2.25rem',
+ '40': '2.5rem',
+ '48': '3rem',
+ '68': '4.25rem',
} as const;
export const Image = styled.img`
diff --git a/src/shared/ui/ProfileImage/index.tsx b/src/shared/ui/ProfileImage/index.tsx
new file mode 100644
index 00000000..5e88ba84
--- /dev/null
+++ b/src/shared/ui/ProfileImage/index.tsx
@@ -0,0 +1,13 @@
+import defaultProfileImg from '@/shared/assets/pngs/defaultProfileImg.png';
+import * as S from './index.styles';
+
+interface ProfileImageProps {
+ src?: string;
+ size: '36' | '40' | '48' | '68';
+}
+
+function ProfileImage({ src, size }: ProfileImageProps) {
+ return ;
+}
+
+export default ProfileImage;