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;