From eb1f3fd99b036a427e9dcf73a9f8829251a94c33 Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Fri, 3 Apr 2026 15:26:28 +0900 Subject: [PATCH 1/8] =?UTF-8?q?refactor:=20MemberProfile,=20MemberProfileI?= =?UTF-8?q?mage=EB=A5=BC=20Profile,=20ProfileImage=EB=A1=9C=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 피그마 디자인 시스템 명칭에 맞게 컴포넌트를 리네이밍 - Profile 컴포넌트에 피그마 기준 size variant(S/m/L)를 추가 - Profile이 ProfileImage를 내부적으로 재사용하도록 통합 --- .../ui/MemberExpenses/index.tsx | 10 +-- .../user-profile/ui/MyProfile/index.tsx | 4 +- .../ui/ExpenseMemberItem/index.tsx | 4 +- src/pages/memberSetup/ui/AddMember/index.tsx | 10 +-- src/shared/ui/MemberProfile/index.stories.ts | 34 -------- src/shared/ui/MemberProfile/index.style.ts | 24 ------ src/shared/ui/MemberProfile/index.tsx | 44 ----------- .../ui/MemberProfileImage/index.stories.ts | 35 -------- src/shared/ui/MemberProfileImage/index.tsx | 13 --- src/shared/ui/Profile/index.stories.ts | 76 ++++++++++++++++++ src/shared/ui/Profile/index.style.ts | 30 +++++++ src/shared/ui/Profile/index.tsx | 79 +++++++++++++++++++ src/shared/ui/ProfileImage/index.stories.ts | 42 ++++++++++ .../index.styles.ts | 9 ++- src/shared/ui/ProfileImage/index.tsx | 13 +++ 15 files changed, 259 insertions(+), 168 deletions(-) delete mode 100644 src/shared/ui/MemberProfile/index.stories.ts delete mode 100644 src/shared/ui/MemberProfile/index.style.ts delete mode 100644 src/shared/ui/MemberProfile/index.tsx delete mode 100644 src/shared/ui/MemberProfileImage/index.stories.ts delete mode 100644 src/shared/ui/MemberProfileImage/index.tsx create mode 100644 src/shared/ui/Profile/index.stories.ts create mode 100644 src/shared/ui/Profile/index.style.ts create mode 100644 src/shared/ui/Profile/index.tsx create mode 100644 src/shared/ui/ProfileImage/index.stories.ts rename src/shared/ui/{MemberProfileImage => ProfileImage}/index.styles.ts (73%) create mode 100644 src/shared/ui/ProfileImage/index.tsx 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)} /> - + {profile.name} {/* TODO: 디자인 시스템 정비 후 다시 디자인 확인이 필요합니다 (Opacity를 계속 쓰는지?) */} 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/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/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 113e0274..00000000 --- a/src/shared/ui/MemberProfileImage/index.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import defaultProfileImg from '@/shared/assets/pngs/defaultProfileImg.png'; -import * as S from './index.styles'; - -interface MemberProfileImageProps { - src?: string; - size: 'sm' | 'md' | 'lg'; -} - -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; From 239d1eac1963726a154655242684bb452bac84b4 Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Fri, 3 Apr 2026 15:27:35 +0900 Subject: [PATCH 2/8] =?UTF-8?q?fix:=20=EC=8B=A4=EC=A0=9C=20=EB=B0=B1?= =?UTF-8?q?=EC=97=94=EB=93=9C=20=EA=B5=AC=ED=98=84=EC=97=90=20=EB=A7=9E?= =?UTF-8?q?=EA=B2=8C=20getUserInfo,=20getGroupHeader=20API=20=EC=97=94?= =?UTF-8?q?=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entities/auth/api/auth.ts | 2 +- src/entities/group/api/group.ts | 5 +++-- src/mocks/handlers/auth.ts | 2 +- .../expenseDetail/ui/ExpenseTimeHeader/index.stories.tsx | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) 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/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/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, From 238902adb9ffa6db75a6d3a481a7ef1a06dae466 Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Fri, 3 Apr 2026 15:28:15 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20MemberProfile=20=ED=83=80=EC=9E=85?= =?UTF-8?q?=20=EC=A0=95=EC=9D=98=EC=99=80=20member=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entities/member/api/assignMember.ts | 15 +++++++++++++++ src/entities/member/api/getProfiles.ts | 14 ++++++++++++++ src/entities/member/model/member.type.ts | 15 +++++++++++++++ 3 files changed, 44 insertions(+) create mode 100644 src/entities/member/api/assignMember.ts create mode 100644 src/entities/member/api/getProfiles.ts 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[]; +} From fcd5b20c8dc7009a6911ed8289bf8a8e13358694 Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Fri, 3 Apr 2026 15:30:53 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20=EC=A0=95=EC=82=B0=20=EC=B0=B8?= =?UTF-8?q?=EC=97=AC(join)=20=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 공유 링크로 진입한 참여자 프로필 선택 → 정산 상세 페이지로 이동하는 플로우 구현 - joinLoader : 인증 확인 → 프로필 선택 여부 확인 → 프로필 목록 반환 - expenseDetailLoader : 정산 상세 페이지 진입 전 프로필 미선택 시 Join 페이지로 리다이렉트 --- src/app/Router.tsx | 14 +++- src/features/join/api/useAssignMember.ts | 14 ++++ src/pages/expenseDetail/loader.ts | 58 +++++++++++++++ src/pages/join/JoinPage.styles.ts | 44 +++++++++++ src/pages/join/JoinPage.tsx | 94 ++++++++++++++++++++++++ src/pages/join/index.ts | 1 + src/pages/join/loader.ts | 37 ++++++++++ src/shared/config/route.ts | 1 + 8 files changed, 262 insertions(+), 1 deletion(-) create mode 100644 src/features/join/api/useAssignMember.ts create mode 100644 src/pages/expenseDetail/loader.ts create mode 100644 src/pages/join/JoinPage.styles.ts create mode 100644 src/pages/join/JoinPage.tsx create mode 100644 src/pages/join/index.ts create mode 100644 src/pages/join/loader.ts diff --git a/src/app/Router.tsx b/src/app/Router.tsx index d7e38168..2cf13e1a 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 }) => ({ @@ -53,6 +55,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, @@ -113,10 +120,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/features/join/api/useAssignMember.ts b/src/features/join/api/useAssignMember.ts new file mode 100644 index 00000000..a9805627 --- /dev/null +++ b/src/features/join/api/useAssignMember.ts @@ -0,0 +1,14 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { assignMember } from '@/entities/member/api/assignMember'; + +const useAssignMember = (groupToken: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (memberId: number) => assignMember(groupToken, memberId), + onSuccess: () => { + queryClient.removeQueries({ queryKey: ['profiles', groupToken] }); + }, + }); +}; + +export default useAssignMember; diff --git a/src/pages/expenseDetail/loader.ts b/src/pages/expenseDetail/loader.ts new file mode 100644 index 00000000..f63e3e90 --- /dev/null +++ b/src/pages/expenseDetail/loader.ts @@ -0,0 +1,58 @@ +// 정산 상세 페이지 전 거치는 로더 +// 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. 로그인 여부 확인 + const user = await queryClient.ensureQueryData({ + queryKey: ['userInfo'], + queryFn: getUserInfo, + }); + if (!user) + return redirect(`/login?returnUrl=/expense-detail/${groupToken}`); + + // 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/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..a4ecd01e --- /dev/null +++ b/src/pages/join/loader.ts @@ -0,0 +1,37 @@ +// 정산 참여 페이지 전 거치는 로더 +// 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. 로그인 여부 확인 + const user = await queryClient.ensureQueryData({ + queryKey: ['userInfo'], + queryFn: getUserInfo, + }); + + if (!user) return redirect(`/login?returnUrl=/join/${groupToken}`); + + // 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/shared/config/route.ts b/src/shared/config/route.ts index 67f54574..1c5a4e21 100644 --- a/src/shared/config/route.ts +++ b/src/shared/config/route.ts @@ -9,6 +9,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; From 6e7e60a08e32e4d4c80c5d2304be7751b5f30416 Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Fri, 3 Apr 2026 15:31:42 +0900 Subject: [PATCH 5/8] =?UTF-8?q?chore:=20=EC=A0=95=EC=82=B0=20=EC=B0=B8?= =?UTF-8?q?=EC=97=AC=20=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?MSW=20=ED=95=B8=EB=93=A4=EB=9F=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/mocks/handlers/group.ts | 75 +++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) 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; From 1477a0a19334494ecef4c5cd8700e3a20288923b Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Fri, 3 Apr 2026 16:11:20 +0900 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=EB=9E=98=EB=B9=97?= =?UTF-8?q?=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit url 인코딩 추가 - https://github.com/moddo-kr/moddo-frontend/pull/28#discussion_r3031737655 - https://github.com/moddo-kr/moddo-frontend/pull/28#discussion_r3031695442 prettier 포맷 수정 https://github.com/moddo-kr/moddo-frontend/pull/28#discussion_r3031695450 --- src/pages/expenseDetail/loader.ts | 10 +++++++--- src/pages/join/loader.ts | 8 ++++++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/pages/expenseDetail/loader.ts b/src/pages/expenseDetail/loader.ts index f63e3e90..9d409cdc 100644 --- a/src/pages/expenseDetail/loader.ts +++ b/src/pages/expenseDetail/loader.ts @@ -22,15 +22,19 @@ async function expenseDetailLoader({ params }: LoaderFunctionArgs) { queryKey: ['userInfo'], queryFn: getUserInfo, }); - if (!user) - return redirect(`/login?returnUrl=/expense-detail/${groupToken}`); + // 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; + const myProfile = + profiles.find((profile) => profile.userId === user.id) ?? null; if (!myProfile) return redirect(`/join/${groupToken}`); const groupData = await queryClient.ensureQueryData({ diff --git a/src/pages/join/loader.ts b/src/pages/join/loader.ts index a4ecd01e..c6f6f99a 100644 --- a/src/pages/join/loader.ts +++ b/src/pages/join/loader.ts @@ -19,7 +19,10 @@ async function joinLoader({ params }: LoaderFunctionArgs) { queryFn: getUserInfo, }); - if (!user) return redirect(`/login?returnUrl=/join/${groupToken}`); + if (!user) { + const returnUrl = encodeURIComponent(`/join/${groupToken}`); + return redirect(`/login?returnUrl=${returnUrl}`); + } // 2. 표시할 프로필 목록 조회 const profiles = await queryClient.ensureQueryData({ @@ -28,7 +31,8 @@ async function joinLoader({ params }: LoaderFunctionArgs) { }); // 3. 본인 프로필을 선택했는지 확인 - const myProfile = profiles.find((profile) => profile.userId === user.id) ?? null; + const myProfile = + profiles.find((profile) => profile.userId === user.id) ?? null; if (myProfile) return redirect(`/expense-detail/${groupToken}`); return { profiles }; From 691a8c326282153a30dfab04b3b4146cf0dbb627 Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Fri, 3 Apr 2026 16:17:43 +0900 Subject: [PATCH 7/8] =?UTF-8?q?docs:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EB=A6=AC=EB=8B=A4=EC=9D=B4=EB=A0=89=ED=8A=B8=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=ED=9B=84=EC=86=8D=20=EC=9D=B4=EC=8A=88=EB=A5=BC=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=EC=9C=BC=EB=A1=9C=20=EB=82=A8=EA=B9=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/moddo-kr/moddo-frontend/pull/28#discussion_r3031737654 --- src/pages/expenseDetail/loader.ts | 1 + src/pages/join/loader.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/pages/expenseDetail/loader.ts b/src/pages/expenseDetail/loader.ts index 9d409cdc..cd85e034 100644 --- a/src/pages/expenseDetail/loader.ts +++ b/src/pages/expenseDetail/loader.ts @@ -18,6 +18,7 @@ async function expenseDetailLoader({ params }: LoaderFunctionArgs) { 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, diff --git a/src/pages/join/loader.ts b/src/pages/join/loader.ts index c6f6f99a..d71eb15c 100644 --- a/src/pages/join/loader.ts +++ b/src/pages/join/loader.ts @@ -14,6 +14,7 @@ async function joinLoader({ params }: LoaderFunctionArgs) { 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, From 7c131c9cf47dc0a7bd575480c839d1e7920cefd8 Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Wed, 15 Apr 2026 01:23:46 +0900 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20MemberProfileImage=EB=A5=BC=20Profil?= =?UTF-8?q?eImage=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref: eb1f3fd99b036a427e9dcf73a9f8829251a94c33 --- src/features/payment-management/ui/PaymentAlert/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 ( - +