Skip to content

Commit 19d7c6e

Browse files
authored
Merge pull request #65 from Leets-Official/64-feat/마이페이지-UI-구현
[Feat] 마이페이지 UI 구현
2 parents d435c61 + 7559af1 commit 19d7c6e

26 files changed

Lines changed: 736 additions & 6 deletions

src/app/routes.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ export default [
77
index('routes/(main)/_index.tsx'),
88
route('sell', 'routes/(main)/sell.tsx'),
99
route('sell/confirm', 'routes/(main)/sell.confirm.tsx'),
10+
route('mypage', 'routes/mypage.tsx'),
11+
route('mypage/settings', 'routes/mypage-settings.tsx'),
12+
route('mypage/profile', 'routes/mypage-profile.tsx'),
1013
]),
1114
route('playground', 'routes/playground.tsx'),
1215
] satisfies RouteConfig;

src/app/routes/mypage-profile.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { ProfileInfoPage } from '@pages/mypage';
2+
3+
export default ProfileInfoPage;

src/app/routes/mypage-settings.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { AccountSettingsPage } from '@pages/mypage';
2+
3+
export default AccountSettingsPage;

src/app/routes/mypage.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { MyPage } from '@pages/mypage';
2+
3+
export default MyPage;

src/pages/mypage/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { default as MyPage } from './ui/MyPage';
2+
export { default as AccountSettingsPage } from './ui/AccountSettingsPage';
3+
export { default as ProfileInfoPage } from './ui/ProfileInfoPage';
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { MY_PAGE_PROFILE, PERSONAL_INFO_DEFAULTS } from '@shared/mocks/data/mypage';
2+
import { type PersonalInfoValues } from '@shared/types/mypage';
3+
4+
export type StoredProfile = PersonalInfoValues & {
5+
profileImage?: string;
6+
};
7+
8+
const STORAGE_KEY = 'myPageProfile';
9+
10+
const readProfileStorage = (): Partial<StoredProfile> | null => {
11+
if (typeof window === 'undefined') {
12+
return null;
13+
}
14+
try {
15+
const raw = window.localStorage.getItem(STORAGE_KEY);
16+
if (!raw) {
17+
return null;
18+
}
19+
return JSON.parse(raw) as Partial<StoredProfile>;
20+
} catch {
21+
return null;
22+
}
23+
};
24+
25+
export const getPersonalInfoDefaults = (): PersonalInfoValues => {
26+
const stored = readProfileStorage();
27+
return {
28+
...PERSONAL_INFO_DEFAULTS,
29+
...stored,
30+
};
31+
};
32+
33+
export const getProfileSummary = () => {
34+
const stored = readProfileStorage();
35+
return {
36+
...MY_PAGE_PROFILE,
37+
...stored,
38+
};
39+
};
40+
41+
export const saveProfile = (values: Partial<StoredProfile>) => {
42+
if (typeof window === 'undefined') {
43+
return;
44+
}
45+
const stored = readProfileStorage() ?? {};
46+
const next = {
47+
...stored,
48+
...values,
49+
};
50+
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
51+
};
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { ChevronRightIcon, PictureIcon } from '@shared/assets/icons';
2+
import { MY_PAGE_PROFILE } from '@shared/mocks/data/mypage';
3+
import { Profile } from '@shared/ui/Profile';
4+
import { useRef, useState } from 'react';
5+
import { useNavigate } from 'react-router';
6+
import { PageContainer } from './PageContainer';
7+
import { getProfileSummary, saveProfile } from '../model/profileStorage';
8+
9+
export default function AccountSettingsPage() {
10+
const navigate = useNavigate();
11+
const profileSummary = getProfileSummary();
12+
const [profileImage, setProfileImage] = useState(profileSummary.profileImage ?? MY_PAGE_PROFILE.profileImage);
13+
const fileInputRef = useRef<HTMLInputElement | null>(null);
14+
15+
const handleProfileClick = () => {
16+
fileInputRef.current?.click();
17+
};
18+
19+
const handleProfileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
20+
const file = event.target.files?.[0];
21+
if (!file) {
22+
return;
23+
}
24+
const reader = new FileReader();
25+
reader.onload = () => {
26+
if (typeof reader.result !== 'string') {
27+
return;
28+
}
29+
setProfileImage(reader.result);
30+
saveProfile({ profileImage: reader.result });
31+
};
32+
reader.readAsDataURL(file);
33+
};
34+
35+
const handleLogout = () => {
36+
// NOTE: 로그아웃 API 연동 후 핸들러 구현 예정
37+
};
38+
39+
return (
40+
<main className="min-h-screen bg-white">
41+
<PageContainer>
42+
<h1 className="typo-title-2 text-gray-900">계정 설정</h1>
43+
44+
<div className="mt-10 flex flex-col items-center">
45+
<div className="relative inline-flex h-45 w-45">
46+
<Profile size="lg" image={profileImage} alt={`${profileSummary.nickname} 프로필`} />
47+
<button
48+
type="button"
49+
onClick={handleProfileClick}
50+
className="absolute right-[8px] bottom-[8px] flex h-[40px] w-[40px] items-center justify-center rounded-full border border-[#C7C7CC] bg-white p-[8px]"
51+
aria-label="프로필 사진 변경"
52+
>
53+
<PictureIcon className="h-4.5 w-4.5 text-gray-500" />
54+
</button>
55+
<input ref={fileInputRef} type="file" accept="image/*" className="sr-only" onChange={handleProfileChange} />
56+
</div>
57+
</div>
58+
59+
<section className="mt-12.25">
60+
<h2 className="typo-title-2 text-gray-900">내 정보</h2>
61+
<div className="mt-[40px] flex flex-col gap-12.25">
62+
<button
63+
type="button"
64+
onClick={() => navigate('/mypage/profile', { viewTransition: true })}
65+
className="flex h-11 w-full items-center justify-between px-[10px] py-[10px] text-left"
66+
>
67+
<span className="typo-body-1 text-gray-900">개인정보 관리</span>
68+
<ChevronRightIcon className="h-6 w-5.75 text-gray-900" />
69+
</button>
70+
<button
71+
type="button"
72+
onClick={handleLogout}
73+
className="flex h-11 w-full items-center justify-between px-[10px] py-[10px] text-left"
74+
>
75+
<span className="typo-body-1 text-gray-900">로그아웃</span>
76+
<ChevronRightIcon className="h-6 w-5.75 text-gray-900" />
77+
</button>
78+
</div>
79+
</section>
80+
</PageContainer>
81+
</main>
82+
);
83+
}

src/pages/mypage/ui/CommonTabs.tsx

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { cn } from '@shared/utils/cn';
2+
3+
export type CommonTabItem<TId extends string = string> = {
4+
id: TId;
5+
label: string;
6+
count: number;
7+
};
8+
9+
export type CommonTabsProps<TId extends string = string> = {
10+
title: string;
11+
tabs: Array<CommonTabItem<TId>>;
12+
activeId: TId;
13+
onChange: (id: TId) => void;
14+
gridClassName: string;
15+
labelClassName: string;
16+
countClassName: string;
17+
countActiveClassName: string;
18+
countInactiveClassName: string;
19+
itemClassName?: string;
20+
};
21+
22+
export const CommonTabs = <TId extends string>({
23+
title,
24+
tabs,
25+
activeId,
26+
onChange,
27+
gridClassName,
28+
labelClassName,
29+
countClassName,
30+
countActiveClassName,
31+
countInactiveClassName,
32+
itemClassName,
33+
}: CommonTabsProps<TId>) => {
34+
return (
35+
<section className="mt-10">
36+
<h2 className="typo-title-3 text-gray-900">{title}</h2>
37+
<div className="mt-6 border-b border-gray-200">
38+
<div className={cn('grid', gridClassName)}>
39+
{tabs.map((tab) => (
40+
<button
41+
key={tab.id}
42+
type="button"
43+
onClick={() => onChange(tab.id)}
44+
className={cn(
45+
'relative flex flex-col items-center gap-1 pb-4 text-center transition-colors',
46+
itemClassName,
47+
activeId === tab.id
48+
? 'text-gray-900 after:absolute after:-bottom-px after:left-1/2 after:h-0.5 after:w-29.5 after:-translate-x-1/2 after:bg-black'
49+
: 'text-gray-500'
50+
)}
51+
>
52+
<span className={labelClassName}>{tab.label}</span>
53+
<span className={cn(countClassName, activeId === tab.id ? countActiveClassName : countInactiveClassName)}>
54+
{tab.count}
55+
</span>
56+
</button>
57+
))}
58+
</div>
59+
</div>
60+
</section>
61+
);
62+
};

src/pages/mypage/ui/MyPage.tsx

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { BUY_ITEMS, FAVORITE_PRODUCT_ITEMS, FAVORITE_REPAIR_ITEMS, SELL_ITEMS } from '@shared/mocks/data/mypage';
2+
import { RepairList, type RepairListItem } from '@shared/ui/RepairList';
3+
import { useMemo, useState } from 'react';
4+
import { useNavigate } from 'react-router';
5+
import { CommonTabs, type CommonTabItem } from './CommonTabs';
6+
import { MyPageTabs, type MyPageTab } from './MyPageTabs';
7+
import { PageContainer } from './PageContainer';
8+
import { ProfileSummaryCard } from './ProfileSummaryCard';
9+
import { TradeItemList, type TradeListItem } from './TradeItemList';
10+
import { getProfileSummary } from '../model/profileStorage';
11+
12+
const MAIN_TABS: MyPageTab[] = [
13+
{ id: 'buy', label: '구매 내역' },
14+
{ id: 'sell', label: '판매 내역' },
15+
{ id: 'favorite', label: '찜한 목록' },
16+
] as const;
17+
18+
type MainTabId = MyPageTab['id'];
19+
20+
type StatusFilter = 'all' | 'buying' | 'completed';
21+
22+
const getStatusCount = (items: TradeListItem[], status: StatusFilter) => {
23+
if (status === 'all') {
24+
return items.length;
25+
}
26+
if (status === 'buying') {
27+
return items.filter((item) => item.status === 'buying' || item.status === 'reserved').length;
28+
}
29+
return items.filter((item) => item.status === status).length;
30+
};
31+
32+
export default function MyPage() {
33+
const navigate = useNavigate();
34+
const [activeTab, setActiveTab] = useState<MainTabId>('buy');
35+
const [buyStatus, setBuyStatus] = useState<StatusFilter>('all');
36+
const [sellStatus, setSellStatus] = useState<StatusFilter>('all');
37+
type FavoriteCategory = 'product' | 'repair';
38+
const [favoriteCategory, setFavoriteCategory] = useState<FavoriteCategory>('product');
39+
const profileSummary = getProfileSummary();
40+
41+
const buyStatusTabs = useMemo<Array<CommonTabItem<StatusFilter>>>(
42+
() => [
43+
{ id: 'all', label: '전체', count: getStatusCount(BUY_ITEMS, 'all') },
44+
{ id: 'buying', label: '구매중', count: getStatusCount(BUY_ITEMS, 'buying') },
45+
{ id: 'completed', label: '구매완료', count: getStatusCount(BUY_ITEMS, 'completed') },
46+
],
47+
[]
48+
);
49+
50+
const sellStatusTabs = useMemo<Array<CommonTabItem<StatusFilter>>>(
51+
() => [
52+
{ id: 'all', label: '전체', count: getStatusCount(SELL_ITEMS, 'all') },
53+
{ id: 'buying', label: '판매중', count: getStatusCount(SELL_ITEMS, 'buying') },
54+
{ id: 'completed', label: '판매완료', count: getStatusCount(SELL_ITEMS, 'completed') },
55+
],
56+
[]
57+
);
58+
59+
const favoriteTabs = useMemo<Array<CommonTabItem<FavoriteCategory>>>(
60+
() => [
61+
{ id: 'product', label: '상품', count: FAVORITE_PRODUCT_ITEMS.length },
62+
{ id: 'repair', label: '수리점', count: FAVORITE_REPAIR_ITEMS.length },
63+
],
64+
[]
65+
);
66+
67+
const filteredBuyItems = useMemo(() => {
68+
if (buyStatus === 'all') {
69+
return BUY_ITEMS;
70+
}
71+
if (buyStatus === 'buying') {
72+
return BUY_ITEMS.filter((item) => item.status === 'buying' || item.status === 'reserved');
73+
}
74+
return BUY_ITEMS.filter((item) => item.status === buyStatus);
75+
}, [buyStatus]);
76+
77+
const filteredSellItems = useMemo(() => {
78+
if (sellStatus === 'all') {
79+
return SELL_ITEMS;
80+
}
81+
if (sellStatus === 'buying') {
82+
return SELL_ITEMS.filter((item) => item.status === 'buying' || item.status === 'reserved');
83+
}
84+
return SELL_ITEMS.filter((item) => item.status === sellStatus);
85+
}, [sellStatus]);
86+
87+
const handleRepairContact = (_item: RepairListItem) => {
88+
// NOTE: 수리점 연락하기 기능 연동 후 처리 예정
89+
};
90+
91+
const handleRepairFindRoute = (_item: RepairListItem) => {
92+
// NOTE: 수리점 길찾기 기능 연동 후 처리 예정
93+
};
94+
95+
return (
96+
<main className="min-h-screen bg-white">
97+
<PageContainer>
98+
<h1 className="typo-title-2 text-gray-900">마이페이지</h1>
99+
100+
<div className="mt-6">
101+
<ProfileSummaryCard
102+
nickname={profileSummary.nickname}
103+
email={profileSummary.email}
104+
profileImage={profileSummary.profileImage}
105+
onSettingsClick={() => navigate('/mypage/settings', { viewTransition: true })}
106+
/>
107+
</div>
108+
109+
<div className="mt-8">
110+
<MyPageTabs tabs={MAIN_TABS} activeId={activeTab} onChange={setActiveTab} />
111+
</div>
112+
113+
{activeTab === 'favorite' ? (
114+
<>
115+
<CommonTabs
116+
title="찜한 목록"
117+
tabs={favoriteTabs}
118+
activeId={favoriteCategory}
119+
onChange={setFavoriteCategory}
120+
gridClassName="w-full grid-cols-2 px-[434.5px]"
121+
labelClassName="typo-body-1"
122+
countClassName="typo-title-3"
123+
countActiveClassName="text-green-700"
124+
countInactiveClassName="text-gray-900"
125+
itemClassName="first:justify-self-start last:justify-self-end"
126+
/>
127+
{favoriteCategory === 'product' ? (
128+
<TradeItemList items={FAVORITE_PRODUCT_ITEMS} emptyMessage="찜한 목록이 아직 없어요." />
129+
) : (
130+
<RepairList
131+
items={FAVORITE_REPAIR_ITEMS}
132+
emptyMessage="찜한 수리점이 아직 없어요."
133+
onContact={handleRepairContact}
134+
onFindRoute={handleRepairFindRoute}
135+
/>
136+
)}
137+
</>
138+
) : (
139+
<>
140+
<CommonTabs
141+
title={activeTab === 'buy' ? '구매 내역' : '판매 내역'}
142+
tabs={activeTab === 'buy' ? buyStatusTabs : sellStatusTabs}
143+
activeId={activeTab === 'buy' ? buyStatus : sellStatus}
144+
onChange={(id) => {
145+
if (activeTab === 'buy') {
146+
setBuyStatus(id);
147+
} else {
148+
setSellStatus(id);
149+
}
150+
}}
151+
gridClassName="w-full grid-cols-3 px-[276px]"
152+
labelClassName="typo-body-1"
153+
countClassName="typo-title-3"
154+
countActiveClassName="text-green-700"
155+
countInactiveClassName="text-gray-900"
156+
itemClassName="first:justify-self-start last:justify-self-end"
157+
/>
158+
<TradeItemList
159+
items={activeTab === 'buy' ? filteredBuyItems : filteredSellItems}
160+
emptyMessage="선택한 조건에 해당하는 상품은 없어요."
161+
/>
162+
</>
163+
)}
164+
</PageContainer>
165+
</main>
166+
);
167+
}

0 commit comments

Comments
 (0)