Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export default [
{
rules: {
'prettier/prettier': 'warn',
'no-console': ['error', { allow: ['warn', 'error'] }],
},
},
tsConfig,
Expand Down
12 changes: 10 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import './App.css';
import '@/App.css';

import { useMemo } from 'react';
import { RouterProvider } from 'react-router-dom';

import { DeviceTokenProvider } from '@/providers/deviceTokenProvider';
import { alarmKeys } from '@/queryKey/queryKey';
import router from '@/routes/routes';

function App() {
return <RouterProvider router={router} />;
const refetchKeys = useMemo(() => [alarmKeys.all.queryKey], []);
return (
<DeviceTokenProvider refetchKeys={refetchKeys}>
<RouterProvider router={router} />
</DeviceTokenProvider>
);
}

export default App;
22 changes: 9 additions & 13 deletions src/api/axiosInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,30 +34,26 @@ axiosInstance.interceptors.response.use(
const refreshResponse = await refresh();

if (refreshResponse.code === '200') {
console.log('refreshToken이 재발급 되었습니다');
isRedirecting = false;

return axiosInstance(error.config);
}
} catch (errors) {
if (axios.isAxiosError(errors)) {
const refreshError = error as AxiosError<IRefreshResponse>;
const refreshError = errors as AxiosError<IRefreshResponse>;
if (refreshError.response?.data.message === 'The token is null.') {
console.log('refreshToken이 없습니다. 로그인 페이지로 이동합니다.');
console.error('refreshToken이 없습니다. 로그인 페이지로 이동합니다.');
void logout();
} else if (refreshError.response?.data.message === 'The token is invalid.') {
console.log('refreshToken이 만료되었습니다. 로그인 페이지로 이동합니다.');
logout();
console.error('refreshToken이 만료되었습니다. 로그인 페이지로 이동합니다.');
void logout();
} else {
if (refreshError.response?.data.message === 'Incorrect password.') {
alert('Your email or password is incorrect.');
} else {
console.log('알 수 없는 오류가 발생했습니다', errors);
logout();
}
console.error('알 수 없는 오류가 발생했습니다', errors);
void logout();
}
} else {
console.log('알 수 없는 오류가 발생했습니다', errors);
logout();
console.error('알 수 없는 오류가 발생했습니다', errors);
void logout();
}

return Promise.reject(errors);
Expand Down
32 changes: 18 additions & 14 deletions src/components/common/PasswordEdit.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useState } from 'react';
import type { AxiosError } from 'axios';
import { z } from 'zod';

import { useAccount } from '@/hooks/auth/useAccount';
Expand Down Expand Up @@ -28,16 +29,7 @@ export default function PasswordEditSection() {
setIsEditing(false);
};

const { mutate: changePw, isPending } = useChangePassword({
onSuccess: () => {
alert('비밀번호가 변경되었습니다.');
handleCancel();
},
onError: (err) => {
const msg = (err as any)?.response?.data?.message ?? '비밀번호 변경에 실패했습니다.';
alert(msg);
},
});
const { mutate: changePw, isPending } = useChangePassword();

// 제출
const handleSubmit = () => {
Expand All @@ -58,10 +50,22 @@ export default function PasswordEditSection() {
if (Object.keys(nextErrors).length > 0) return;

// 제출
changePw({
currentPassword: currentPw,
newPassword: newPw,
});
changePw(
{
currentPassword: currentPw,
newPassword: newPw,
},
{
onSuccess: () => {
alert('비밀번호가 변경되었습니다.');
handleCancel();
},
onError: (err: AxiosError) => {
const msg = (err as any)?.response?.data?.message ?? '비밀번호 변경에 실패했습니다.';
alert(msg);
},
},
);
};

// 공통 인풋 스타일
Expand Down
1 change: 0 additions & 1 deletion src/components/dateCourse/dateCourse.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ function DateCourse({ defaultOpen = false }: { defaultOpen?: boolean }) {
} else {
setIsBookmarked(!isBookmarked);
}
// console.log('북마크 해제');
};

useEffect(() => {
Expand Down
2 changes: 1 addition & 1 deletion src/components/dateCourse/dateCourseSearchFilterOption.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export default function DateCourseSearchFilterOption({ options, type, value, onC
mode="search"
onSearchClick={handleSearch}
placeholder="ex: 서울시 강남구"
className="w-full"
className="!w-full min-w-full"
value={inputValue}
onChange={handleInputChange}
/>
Expand Down
1 change: 0 additions & 1 deletion src/components/modal/dateCourseSearchFilterModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ export default function DateCourseSearchFilterModal({ onClose }: TDateCourseSear
const [errorMessages, setErrorMessages] = useState<string[]>(Array(7).fill(''));

const handleSearch = () => {
console.log('선택된 필터:', answers);
onClose();
};

Expand Down
4 changes: 4 additions & 0 deletions src/components/modal/regionModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import { useUserRegion } from '@/hooks/home/useUserRegion';
import EditableInputBox from '@/components/common/EditableInputBox';
import Modal from '@/components/common/modal';

import { queryClient } from '@/api/queryClient';
import { homeKeys } from '@/queryKey/queryKey';

interface IRegionModalProps {
onClose: () => void;
}
Expand All @@ -23,6 +26,7 @@ function RegionModal({ onClose }: IRegionModalProps) {
},
{
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: homeKeys.userRegion().queryKey });
onClose();
},
},
Expand Down
35 changes: 20 additions & 15 deletions src/components/settingTab/AlarmSetting.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import { useGetAlarmSettings, usePatchAlarmSettings } from '@/hooks/settingAlarm

import ToggleSwitch from '@/components/common/ToggleSwitch';

import { queryClient } from '@/api/queryClient';
import { alarmKeys } from '@/queryKey/queryKey';

type TAlarmType = 'email' | 'push' | 'sms';

interface IAlarmSettingState {
Expand Down Expand Up @@ -36,22 +39,24 @@ export default function AlarmSetting() {

// 토글 핸들러
const handleToggle = (key: TAlarmType) => {
const prev = alarmSetting;
const next = { ...prev, [key]: !prev[key] };
setAlarmSetting(next);

patchAlarm(
{
emailAlarm: next.email,
pushAlarm: next.push,
smsAlarm: next.sms,
},
{
onError: () => setAlarmSetting(prev),
},
);
setAlarmSetting((prev) => {
const next = { ...prev, [key]: !prev[key] };
patchAlarm(
{
emailAlarm: next.email,
pushAlarm: next.push,
smsAlarm: next.sms,
},
{
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: alarmKeys.alarmSettings().queryKey });
},
onError: () => setAlarmSetting(prev),
},
);
return next;
});
};

const items: { label: string; key: TAlarmType }[] = [
{ label: 'Email 알람', key: 'email' },
{ label: '푸쉬 알람', key: 'push' },
Expand Down
74 changes: 40 additions & 34 deletions src/components/settingTab/InfoSetting.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';

import type { TResponseError } from '@/types/common/common';
import { TERMS_URL } from '@/constants/policies';

import { QUERY_KEYS, useAccount } from '@/hooks/auth/useAccount';
import { useAccount } from '@/hooks/auth/useAccount';

import EditableInputBox from '../common/EditableInputBox';
import PasswordEditSection from '../common/PasswordEdit';

import { queryClient } from '@/api/queryClient';
import ChevronForward from '@/assets/icons/default_arrows/chevron_forward.svg?react';
import { memberKeys } from '@/queryKey/queryKey';

const getApiErrorMessage = (err: any, fallback: string) => err?.response?.data?.message ?? (err?.response?.status === 401 ? '로그인이 필요합니다.' : fallback);
const getApiErrorMessage = (err: TResponseError, fallback: string) =>
err?.response?.data?.message ?? (err?.response?.status === 401 ? '로그인이 필요합니다.' : fallback);

export default function InfoSetting() {
const qc = useQueryClient();
const { useGetMemberInfo, useChangeNickname, useResetPreferences } = useAccount();

const { data: memberData, isLoading: infoLoading, isError: infoError } = useGetMemberInfo();
Expand All @@ -32,40 +34,35 @@ export default function InfoSetting() {
}
}, [apiNickname]);

const { mutate: changeNickname, isPending: nickPending } = useChangeNickname({
onSuccess: (res) => {
if (res?.isSuccess) {
const next = res.result.username;
setNickname(next);
setInitialNickname(next);
localStorage.setItem('nickname', next);

qc.invalidateQueries({ queryKey: QUERY_KEYS.memberInfo });
qc.invalidateQueries({ queryKey: QUERY_KEYS.memberGrade });
qc.setQueryData(['userGrade'], (old: any) => (old ? { ...old, result: { ...old.result, username: next } } : old));
} else {
alert(res?.message ?? '닉네임 변경에 실패했습니다.');
}
},
onError: (err: any) => alert(getApiErrorMessage(err, '닉네임 변경에 실패했습니다.')),
});

const { mutate: resetPref, isPending: resetPending } = useResetPreferences({
onSuccess: (res) => {
if (res?.isSuccess) {
alert('취향 데이터가 초기화되었습니다.');
} else {
alert(res?.message ?? '초기화에 실패했습니다.');
}
},
onError: (err: any) => alert(getApiErrorMessage(err, '초기화에 실패했습니다.')),
});
const { mutate: changeNickname, isPending: nickPending } = useChangeNickname();

const { mutate: resetPref, isPending: resetPending } = useResetPreferences();

const handleSubmitNickname = () => {
const trimmed = nickname.trim();
if (!trimmed) return alert('닉네임을 입력해 주세요.');
if (trimmed === initialNickname || nickPending) return;
changeNickname({ username: trimmed });
changeNickname(
{ username: trimmed },
{
onSuccess: (res: any) => {
if (res?.isSuccess) {
const next = res.result.username;
setNickname(next);
setInitialNickname(next);
localStorage.setItem('nickname', next);

queryClient.invalidateQueries({ queryKey: memberKeys.all.queryKey });
queryClient.setQueryData(memberKeys.memberGrade().queryKey, (old: any) =>
old ? { ...old, result: { ...old.result, username: next } } : old,
);
} else {
alert(res?.message ?? '닉네임 변경에 실패했습니다.');
}
},
onError: (err: any) => alert(getApiErrorMessage(err, '닉네임 변경에 실패했습니다.')),
},
);
};

const handleCancelNickname = () => {
Expand All @@ -75,7 +72,16 @@ export default function InfoSetting() {
const handleResetPreferences = () => {
if (resetPending) return;
if (!confirm('정말 초기화할까요? 되돌릴 수 없습니다.')) return;
resetPref();
resetPref(undefined, {
onSuccess: (res: any) => {
if (res?.isSuccess) {
alert('취향 데이터가 초기화되었습니다.');
} else {
alert(res?.message ?? '초기화에 실패했습니다.');
}
},
onError: (err: any) => alert(getApiErrorMessage(err, '초기화에 실패했습니다.')),
});
};

return (
Expand Down
51 changes: 40 additions & 11 deletions src/firebase/firebase.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// src/firebase/firebase.ts
import { initializeApp } from 'firebase/app';
import { getMessaging, getToken } from 'firebase/messaging';
import type { Messaging } from 'firebase/messaging';
import { deleteToken, getMessaging, getToken, isSupported } from 'firebase/messaging';

const firebaseConfig = {
apiKey: 'AIzaSyAjZqK2lhCOeX_P2Sf-_2IGEFlORchcO5w',
Expand All @@ -13,22 +14,39 @@ const firebaseConfig = {
};

const app = initializeApp(firebaseConfig);
const messaging = getMessaging(app);
export let messaging: Messaging | null = null;
async function ensureMessaging() {
if (!(await isSupported())) return null;
if (!messaging) messaging = getMessaging(app);
return messaging;
}
(async () => {
if (await isSupported()) {
messaging = getMessaging(app);
}
})();

export async function generateToken(): Promise<string | null> {
const m = await ensureMessaging();
if (!m) return null;

// 권한 요청 (이미 허용/거부된 상태면 브라우저가 적절히 동작)
if ('Notification' in window && Notification.permission !== 'granted') {
const perm = await Notification.requestPermission();
if (perm !== 'granted') return null;
}

export const generateToken = async () => {
try {
const token = await getToken(messaging, {
const token = await getToken(m, {
vapidKey: import.meta.env.VITE_FIREBASE_VAPID_KEY,
serviceWorkerRegistration: await navigator.serviceWorker.ready,
});
if (!token) {
console.warn('FCM 토큰 생성에 실패했습니다. 알림 권한을 확인해주세요.');
}
return token;
} catch (error) {
console.error('FCM 토큰 생성 중 오류 발생:', error);
return token ?? null;
} catch (e) {
console.error('FCM getToken 실패:', e);
return null;
}
};
}

export const registerServiceWorker = async () => {
try {
Expand All @@ -39,3 +57,14 @@ export const registerServiceWorker = async () => {
console.error('Service Worker registration failed:', err);
}
};

export async function deleteFcmToken(): Promise<boolean> {
const m = await ensureMessaging();
if (!m) return false;
try {
return await deleteToken(m);
} catch (e) {
console.error('FCM deleteToken 실패:', e);
return false;
}
}
Loading