Skip to content

Commit b9a57e4

Browse files
authored
Merge pull request #15 from 100-hours-a-week/feature/setting
feat: 설정 페이지 및 홈 페이지 유저 정보 연동
2 parents 4465f68 + a9e92c0 commit b9a57e4

File tree

9 files changed

+604
-203
lines changed

9 files changed

+604
-203
lines changed

src/app/components/ui/select.jsx

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import * as SelectPrimitive from '@radix-ui/react-select';
2+
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
3+
4+
import { cn } from './utils';
5+
6+
function Select({ ...props }) {
7+
return <SelectPrimitive.Root data-slot="select" {...props} />;
8+
}
9+
10+
function SelectGroup({ ...props }) {
11+
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
12+
}
13+
14+
function SelectValue({ ...props }) {
15+
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
16+
}
17+
18+
function SelectTrigger({ className, size = 'default', children, ...props }) {
19+
return (
20+
<SelectPrimitive.Trigger
21+
data-slot="select-trigger"
22+
data-size={size}
23+
className={cn(
24+
'flex w-full items-center justify-between gap-2 min-h-[44px] px-4 py-3 bg-white border border-gray-200 rounded-xl',
25+
'text-sm whitespace-nowrap',
26+
'data-[placeholder]:text-gray-400',
27+
'focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent',
28+
'disabled:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50',
29+
'transition-[color,box-shadow]',
30+
"[&_svg:not([class*='text-'])]:text-gray-400",
31+
'*:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2',
32+
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
33+
className
34+
)}
35+
{...props}
36+
>
37+
{children}
38+
<SelectPrimitive.Icon asChild>
39+
<ChevronDownIcon className="size-4 opacity-50" />
40+
</SelectPrimitive.Icon>
41+
</SelectPrimitive.Trigger>
42+
);
43+
}
44+
45+
function SelectContent({ className, children, position = 'popper', ...props }) {
46+
return (
47+
<SelectPrimitive.Portal>
48+
<SelectPrimitive.Content
49+
data-slot="select-content"
50+
className={cn(
51+
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
52+
position === 'popper' &&
53+
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
54+
className
55+
)}
56+
position={position}
57+
{...props}
58+
>
59+
<SelectScrollUpButton />
60+
<SelectPrimitive.Viewport
61+
className={cn(
62+
'p-1',
63+
position === 'popper' &&
64+
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1'
65+
)}
66+
>
67+
{children}
68+
</SelectPrimitive.Viewport>
69+
<SelectScrollDownButton />
70+
</SelectPrimitive.Content>
71+
</SelectPrimitive.Portal>
72+
);
73+
}
74+
75+
function SelectLabel({ className, ...props }) {
76+
return (
77+
<SelectPrimitive.Label
78+
data-slot="select-label"
79+
className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
80+
{...props}
81+
/>
82+
);
83+
}
84+
85+
function SelectItem({ className, children, ...props }) {
86+
return (
87+
<SelectPrimitive.Item
88+
data-slot="select-item"
89+
className={cn(
90+
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
91+
className
92+
)}
93+
{...props}
94+
>
95+
<span className="absolute right-2 flex size-3.5 items-center justify-center">
96+
<SelectPrimitive.ItemIndicator>
97+
<CheckIcon className="size-4" />
98+
</SelectPrimitive.ItemIndicator>
99+
</span>
100+
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
101+
</SelectPrimitive.Item>
102+
);
103+
}
104+
105+
function SelectSeparator({ className, ...props }) {
106+
return (
107+
<SelectPrimitive.Separator
108+
data-slot="select-separator"
109+
className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
110+
{...props}
111+
/>
112+
);
113+
}
114+
115+
function SelectScrollUpButton({ className, ...props }) {
116+
return (
117+
<SelectPrimitive.ScrollUpButton
118+
data-slot="select-scroll-up-button"
119+
className={cn(
120+
'flex cursor-default items-center justify-center py-1',
121+
className
122+
)}
123+
{...props}
124+
>
125+
<ChevronUpIcon className="size-4" />
126+
</SelectPrimitive.ScrollUpButton>
127+
);
128+
}
129+
130+
function SelectScrollDownButton({ className, ...props }) {
131+
return (
132+
<SelectPrimitive.ScrollDownButton
133+
data-slot="select-scroll-down-button"
134+
className={cn(
135+
'flex cursor-default items-center justify-center py-1',
136+
className
137+
)}
138+
{...props}
139+
>
140+
<ChevronDownIcon className="size-4" />
141+
</SelectPrimitive.ScrollDownButton>
142+
);
143+
}
144+
145+
export {
146+
Select,
147+
SelectContent,
148+
SelectGroup,
149+
SelectItem,
150+
SelectLabel,
151+
SelectScrollDownButton,
152+
SelectScrollUpButton,
153+
SelectSeparator,
154+
SelectTrigger,
155+
SelectValue,
156+
};
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { useMutation, useQueryClient } from '@tanstack/react-query';
2+
import { toast } from 'sonner';
3+
import { updateUser, updateUserSettings } from '@/app/api/endpoints/user';
4+
5+
/**
6+
* Update user profile mutation
7+
* @returns {import('@tanstack/react-query').UseMutationResult}
8+
*/
9+
export function useUpdateUser() {
10+
const queryClient = useQueryClient();
11+
12+
return useMutation({
13+
mutationFn: updateUser,
14+
onSuccess: (updatedUser) => {
15+
queryClient.setQueryData(['user', 'profile'], updatedUser);
16+
queryClient.invalidateQueries({ queryKey: ['user', 'profile'] });
17+
18+
toast.success('프로필이 저장되었습니다.');
19+
},
20+
onError: (error) => {
21+
const errorCode = error.response?.data?.code;
22+
const status = error.response?.status;
23+
24+
if (status === 401) {
25+
toast.error('로그인이 필요합니다.');
26+
window.location.href = '/';
27+
return;
28+
}
29+
30+
switch (errorCode) {
31+
case 'NAME_INVALID_INPUT':
32+
toast.error('이름은 공백과 이모티콘을 제외한 2~10자로 입력해주세요.');
33+
break;
34+
case 'POSITION_SELECTION_REQUIRED':
35+
toast.error('희망 포지션을 선택해주세요.');
36+
break;
37+
case 'USER_PRIVACY_REQUIRED':
38+
toast.error('개인정보 처리방침에 동의해주세요.');
39+
break;
40+
case 'USER_PHONE_PRIVACY_REQUIRED':
41+
toast.error('전화번호 수집·이용에 동의해주세요.');
42+
break;
43+
case 'POSITION_NOT_FOUND':
44+
toast.error('선택한 포지션을 찾을 수 없습니다.');
45+
break;
46+
default:
47+
toast.error('프로필 업데이트에 실패했습니다.');
48+
}
49+
},
50+
});
51+
}
52+
53+
/**
54+
* Update user settings mutation
55+
* Invalidates settings query after success
56+
* @returns {import('@tanstack/react-query').UseMutationResult}
57+
*/
58+
export function useUpdateUserSettings() {
59+
const queryClient = useQueryClient();
60+
61+
return useMutation({
62+
mutationFn: updateUserSettings,
63+
onSuccess: () => {
64+
queryClient.invalidateQueries({ queryKey: ['user', 'settings'] });
65+
},
66+
onError: (error) => {
67+
const status = error.response?.status;
68+
69+
if (status === 401) {
70+
toast.error('로그인이 필요합니다.');
71+
window.location.href = '/';
72+
return;
73+
}
74+
75+
toast.error('설정 업데이트에 실패했습니다.');
76+
},
77+
});
78+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { useQuery } from '@tanstack/react-query';
2+
import { fetchUser, fetchUserSettings } from '@/app/api/endpoints/user';
3+
4+
/**
5+
* Fetch user profile
6+
* @returns {import('@tanstack/react-query').UseQueryResult}
7+
*/
8+
export function useUserProfile() {
9+
return useQuery({
10+
queryKey: ['user', 'profile'],
11+
queryFn: fetchUser,
12+
staleTime: 1000 * 60 * 5, // 5분
13+
retry: 1, // 1회 재시도
14+
});
15+
}
16+
17+
/**
18+
* Fetch user settings
19+
* @returns {import('@tanstack/react-query').UseQueryResult}
20+
*/
21+
export function useUserSettings() {
22+
return useQuery({
23+
queryKey: ['user', 'settings'],
24+
queryFn: fetchUserSettings,
25+
staleTime: 1000 * 60 * 5, // 5 minutes
26+
retry: 1,
27+
});
28+
}

src/app/pages/HomePage.jsx

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ import { BottomNav } from '../components/layout/BottomNav';
77
import { DropdownMenu } from '../components/common/DropdownMenu';
88
import { ConfirmDialog } from '../components/modals/ConfirmDialog';
99
import { EditTextDialog } from '../components/modals/EditTextDialog';
10-
import { ChatRoomListModal } from '../components/features/ChatRoomListModal';
11-
import { useUser } from '../hooks/useUser';
10+
import { ChatRoomListSheet } from '../components/features/ChatRoomListSheet';
11+
import { useUserProfile } from '@/app/hooks/queries/useUserQuery';
12+
import { usePositions } from '@/app/hooks/queries/usePositionsQuery';
1213

1314
/**
1415
* @typedef {import('@/app/types').Resume} Resume
@@ -17,7 +18,8 @@ import { useUser } from '../hooks/useUser';
1718

1819
export function HomePage() {
1920
const navigate = useNavigate();
20-
const { user } = useUser();
21+
const { data: profileData } = useUserProfile();
22+
const { data: positions = [] } = usePositions();
2123

2224
/** @type {[Resume[], React.Dispatch<React.SetStateAction<Resume[]>>]} */
2325
const [resumes, setResumes] = useState([
@@ -37,13 +39,6 @@ export function HomePage() {
3739
},
3840
]);
3941

40-
/** @type {[{id: string, name: string} | null, React.Dispatch<React.SetStateAction<{id: string, name: string} | null>>]} */
41-
const [editTarget, setEditTarget] = useState(null);
42-
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
43-
/** @type {[string | null, React.Dispatch<React.SetStateAction<string | null>>]} */
44-
const [deleteTarget, setDeleteTarget] = useState(null);
45-
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
46-
4742
const handleEditResumeName = useCallback((id, newName) => {
4843
setResumes((prev) =>
4944
prev.map((resume) =>
@@ -58,21 +53,27 @@ export function HomePage() {
5853
toast.success('이력서가 삭제되었습니다');
5954
}, []);
6055

56+
const displayName = profileData?.name ?? '사용자';
57+
const displayPosition = profileData
58+
? positions.find((position) => position.id === profileData.positionId)
59+
?.name || ''
60+
: '';
61+
6162
return (
6263
<div className="min-h-screen bg-gray-50 pb-20">
6364
{/* Header */}
6465
<header className="bg-white border-b border-gray-200 px-5 py-6">
6566
<div className="max-w-[390px] mx-auto">
6667
<div className="flex items-start justify-between">
6768
<div className="flex-1">
68-
<h2 className="mb-2">{user.name}님 어서오세요!</h2>
69+
<h2 className="mb-2">{displayName}님 어서오세요!</h2>
6970
<p className="text-sm text-gray-600">
70-
희망 포지션: {user.position}
71+
희망 포지션: {displayPosition}
7172
</p>
7273
</div>
7374
<div className="flex items-center gap-2">
74-
<ChatRoomListModal />
75-
{user.profileImage && (
75+
<ChatRoomListSheet />
76+
{profileData?.profileImageUrl && (
7677
<div className="w-16 h-16 rounded-full bg-gray-200" />
7778
)}
7879
</div>

0 commit comments

Comments
 (0)