@@ -33,33 +44,45 @@ const ScoreScreen = () => {
{curTab === "공인어학" &&
languageTestScoreList.map((score) => (
-
+ type="button"
+ className="text-left"
+ onClick={() => handleScoreClick(score.verifyStatus)}
+ >
+
+
))}
{curTab === "학점" &&
gpaScoreList.map((score) => (
-
+ type="button"
+ className="text-left"
+ onClick={() => handleScoreClick(score.verifyStatus)}
+ >
+
+
))}
)}
diff --git a/apps/web/src/components/layout/GlobalLayout/ui/AIInspectorFab/index.tsx b/apps/web/src/components/layout/GlobalLayout/ui/AIInspectorFab/index.tsx
index d39ec4f8..c5a57db1 100644
--- a/apps/web/src/components/layout/GlobalLayout/ui/AIInspectorFab/index.tsx
+++ b/apps/web/src/components/layout/GlobalLayout/ui/AIInspectorFab/index.tsx
@@ -7,7 +7,7 @@ import {
} from "@solid-connect/ai-inspector";
import { Bot, Target, X } from "lucide-react";
import { useState } from "react";
-import { toast } from "react-hot-toast";
+import { showIconToast } from "@/lib/toast/showIconToast";
import useAuthStore from "@/lib/zustand/useAuthStore";
import { UserRole } from "@/types/mentor";
@@ -38,27 +38,27 @@ const AIInspectorFab = () => {
const handleSwitchToMentorView = () => {
setClientRole(UserRole.MENTOR);
- toast.success("멘토 UI 보기로 전환되었습니다.");
+ showIconToast("logo", "멘토 UI 보기로 전환되었습니다.");
};
const handleSwitchToMenteeView = () => {
setClientRole(UserRole.MENTEE);
- toast.success("멘티 UI 보기로 전환되었습니다.");
+ showIconToast("logo", "멘티 UI 보기로 전환되었습니다.");
};
const handleSave = async () => {
if (!selection) {
- toast.error("먼저 수정할 요소를 선택해주세요.");
+ showIconToast("logo", "먼저 수정할 요소를 선택해주세요.");
return;
}
if (!instruction.trim()) {
- toast.error("수정 요청 문구를 입력해주세요.");
+ showIconToast("logo", "수정 요청 문구를 입력해주세요.");
return;
}
if (!accessToken) {
- toast.error("로그인 세션이 만료되었습니다. 다시 로그인해주세요.");
+ showIconToast("logo", "로그인 세션이 만료되었습니다. 다시 로그인해주세요.");
return;
}
@@ -73,13 +73,13 @@ const AIInspectorFab = () => {
},
});
- toast.success(`요청이 저장되었습니다. (${result.taskId.slice(0, 8)})`);
+ showIconToast("logo", `요청이 저장되었습니다. (${result.taskId.slice(0, 8)})`);
resetForm();
} catch (error) {
if (error instanceof AiInspectorRequestError) {
- toast.error(error.message);
+ showIconToast("logo", error.message);
} else {
- toast.error("요청 저장에 실패했습니다. 잠시 후 다시 시도해주세요.");
+ showIconToast("logo", "요청 저장에 실패했습니다. 잠시 후 다시 시도해주세요.");
}
} finally {
setIsSaving(false);
diff --git a/apps/web/src/components/login/signup/SignupPrepareScreen.tsx b/apps/web/src/components/login/signup/SignupPrepareScreen.tsx
index f1adae4b..37e355f4 100644
--- a/apps/web/src/components/login/signup/SignupPrepareScreen.tsx
+++ b/apps/web/src/components/login/signup/SignupPrepareScreen.tsx
@@ -2,8 +2,8 @@
import clsx from "clsx";
import type { Dispatch, SetStateAction } from "react";
-import { toast } from "react-hot-toast";
import BlockBtn from "@/components/button/BlockBtn";
+import { showIconToast } from "@/lib/toast/showIconToast";
import { IconPrepare1, IconPrepare2, IconPrepare3 } from "@/public/svgs/auth";
import { type PreparationStatus, PreparationStatusEnum } from "@/types/auth";
@@ -16,7 +16,7 @@ type SignupPrepareScreenProps = {
const SignupPrepareScreen = ({ preparation, setPreparation, toNextStage }: SignupPrepareScreenProps) => {
const submit = () => {
if (!preparation) {
- toast.error("준비 단계를 선택해주세요.");
+ showIconToast("logo", "준비 단계를 선택해주세요.");
return;
}
toNextStage();
diff --git a/apps/web/src/components/login/signup/SignupProfileScreen.tsx b/apps/web/src/components/login/signup/SignupProfileScreen.tsx
index ebcc6742..78ad9449 100644
--- a/apps/web/src/components/login/signup/SignupProfileScreen.tsx
+++ b/apps/web/src/components/login/signup/SignupProfileScreen.tsx
@@ -1,9 +1,9 @@
"use client";
import { type Dispatch, type SetStateAction, useRef, useState } from "react";
-import { toast } from "react-hot-toast";
import BlockBtn from "@/components/button/BlockBtn";
import { Input } from "@/components/ui/Inputa";
+import { showIconToast } from "@/lib/toast/showIconToast";
import { IconSignupProfileImage } from "@/public/svgs/auth";
type SignupProfileScreenProps = {
@@ -28,7 +28,7 @@ const SignupProfileScreen = ({
const submit = () => {
if (!nickname) {
- toast.error("닉네임을 입력해주세요.");
+ showIconToast("logo", "닉네임을 입력해주세요.");
return;
}
toNextStage();
diff --git a/apps/web/src/components/login/signup/SignupRegionScreen.tsx b/apps/web/src/components/login/signup/SignupRegionScreen.tsx
index 72f4604f..f11345e7 100644
--- a/apps/web/src/components/login/signup/SignupRegionScreen.tsx
+++ b/apps/web/src/components/login/signup/SignupRegionScreen.tsx
@@ -2,9 +2,9 @@
import clsx from "clsx";
import type { Dispatch, SetStateAction } from "react";
-import { toast } from "react-hot-toast";
import BlockBtn from "@/components/button/BlockBtn";
import { regionList } from "@/constants/regions";
+import { showIconToast } from "@/lib/toast/showIconToast";
type SignupRegionScreenProps = {
curRegion: string | null;
@@ -23,7 +23,7 @@ const SignupRegionScreen = ({
}: SignupRegionScreenProps) => {
const submit = () => {
if (!curRegion) {
- toast.error("권역을 선택해주세요.");
+ showIconToast("logo", "권역을 선택해주세요.");
return;
}
toNextStage();
diff --git a/apps/web/src/components/login/signup/SignupSurvey.tsx b/apps/web/src/components/login/signup/SignupSurvey.tsx
index e69a27d8..423ea611 100644
--- a/apps/web/src/components/login/signup/SignupSurvey.tsx
+++ b/apps/web/src/components/login/signup/SignupSurvey.tsx
@@ -2,10 +2,10 @@
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
-import { toast } from "react-hot-toast";
import { usePostSignUp } from "@/apis/Auth";
import { useUploadProfileImagePublic } from "@/apis/image-upload";
import { Progress } from "@/components/ui/Progress";
+import { showIconToast } from "@/lib/toast/showIconToast";
import useAuthStore from "@/lib/zustand/useAuthStore";
import type { PreparationStatus, SignUpRequest } from "@/types/auth";
@@ -82,7 +82,7 @@ const SignupSurvey = ({ baseNickname, baseEmail, baseProfileImageUrl }: SignupSu
signUpMutation.mutate(registerRequest, {
onSuccess: (data) => {
setAccessToken(data.accessToken);
- toast.success("회원가입이 완료되었습니다.");
+ showIconToast("logo", "회원가입이 완료되었습니다.");
setTimeout(() => {
router.push("/");
@@ -91,7 +91,7 @@ const SignupSurvey = ({ baseNickname, baseEmail, baseProfileImageUrl }: SignupSu
});
} catch (err: unknown) {
const error = err as { message?: string };
- toast.error(error.message || "회원가입에 실패했습니다.");
+ showIconToast("logo", error.message || "회원가입에 실패했습니다.");
}
};
diff --git a/apps/web/src/components/mentor/MentorCard/_ui/ArticlePreview.tsx b/apps/web/src/components/mentor/MentorCard/_ui/ArticlePreview.tsx
new file mode 100644
index 00000000..df0e18bd
--- /dev/null
+++ b/apps/web/src/components/mentor/MentorCard/_ui/ArticlePreview.tsx
@@ -0,0 +1,26 @@
+import Image from "@/components/ui/FallbackImage";
+import type { Article } from "@/types/news";
+import { normalizeImageUrlToUploadCdn } from "@/utils/cdnUrl";
+
+interface ArticlePreviewProps {
+ article: Article;
+}
+
+const ArticlePreview = ({ article }: ArticlePreviewProps) => {
+ const thumbnailUrl = normalizeImageUrlToUploadCdn(article.thumbnailUrl);
+ return (
+
+
+
+
+ {article.title}
+
+ );
+};
+
+export default ArticlePreview;
diff --git a/apps/web/src/components/mentor/MentorCard/index.tsx b/apps/web/src/components/mentor/MentorCard/index.tsx
index 7edf77ea..d898ce18 100644
--- a/apps/web/src/components/mentor/MentorCard/index.tsx
+++ b/apps/web/src/components/mentor/MentorCard/index.tsx
@@ -2,12 +2,14 @@
import clsx from "clsx";
import Link from "next/link";
-import { useState } from "react";
+import { useMemo, useState } from "react";
+import { useGetArticleList } from "@/apis/news";
import { IconDirectionDown, IconDirectionUp } from "@/public/svgs/mentor";
import type { MentorCardDetail, MentorCardPreview } from "@/types/mentor";
import ChannelBadge from "../../ui/ChannelBadge";
import ProfileWithBadge from "../../ui/ProfileWithBadge";
import StudyDate from "../StudyDate";
+import ArticlePreview from "./_ui/ArticlePreview";
import usePostApplyMentorHandler from "./hooks/usePostApplyMentorHandler";
interface MentorCardProps {
@@ -36,6 +38,13 @@ const MentorCard = ({ mentor, observeRef, isMine = false }: MentorCardProps) =>
const isDetail = mentor && "passTip" in mentor;
+ const { data: articles } = useGetArticleList(id ?? 0, { enabled: isExpanded && !!id });
+
+ const latestArticle = useMemo(() => {
+ if (!articles || articles.length === 0) return undefined;
+ return [...articles].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0];
+ }, [articles]);
+
return (
{isMine ? (
diff --git a/apps/web/src/components/modal/SurveyModal.tsx b/apps/web/src/components/modal/SurveyModal.tsx
index e6529601..4c9c0f65 100644
--- a/apps/web/src/components/modal/SurveyModal.tsx
+++ b/apps/web/src/components/modal/SurveyModal.tsx
@@ -2,7 +2,7 @@
import Image from "next/image";
import { useEffect, useState } from "react";
-import { toast } from "react-hot-toast";
+import { showIconToast } from "@/lib/toast/showIconToast";
import ModalBase from "./ModalBase";
type SurveyModalProps = {
@@ -38,11 +38,11 @@ const SurveyModal = ({ isOpen, onClose, onCloseForWeek }: SurveyModalProps) => {
if (!newWindow) {
// 팝업이 차단된 경우
- toast.error(`팝업 차단으로 설문을 열 수 없습니다. 새 탭에서 수동으로 ${surveyUrl} 를 열어주세요.`);
+ showIconToast("logo", `팝업 차단으로 설문을 열 수 없습니다. 새 탭에서 수동으로 ${surveyUrl} 를 열어주세요.`);
}
} catch (error) {
// 예외 발생 시
- toast.error(`설문 링크를 열 수 없습니다. 수동으로 ${surveyUrl} 를 열어주세요.`);
+ showIconToast("logo", `설문 링크를 열 수 없습니다. 수동으로 ${surveyUrl} 를 열어주세요.`);
}
};
diff --git a/apps/web/src/lib/react-query/queryClient.ts b/apps/web/src/lib/react-query/queryClient.ts
index ea123168..a1ee5670 100644
--- a/apps/web/src/lib/react-query/queryClient.ts
+++ b/apps/web/src/lib/react-query/queryClient.ts
@@ -1,6 +1,6 @@
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
-import { toast } from "react-hot-toast";
+import { showIconToast } from "@/lib/toast/showIconToast";
import { shouldSkipGlobalErrorToast } from "./errorToastMeta";
type ErrorResponse = {
@@ -14,9 +14,6 @@ const isUnauthorized = (status?: number) => status === 401;
const resolveErrorMessage = (error: AxiosError
) =>
error.response?.data?.message || error.message || DEFAULT_ERROR_MESSAGE;
-const buildToastId = (status: number | undefined, message: string) =>
- `rq-error:${status ?? "unknown"}:${message.trim().toLowerCase()}`;
-
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error, query) => {
@@ -27,9 +24,7 @@ const queryClient = new QueryClient({
if (isUnauthorized(status)) return;
const errorMessage = resolveErrorMessage(axiosError);
- toast.error(errorMessage, {
- id: buildToastId(status, errorMessage),
- });
+ showIconToast("logo", errorMessage);
},
}),
mutationCache: new MutationCache({
@@ -43,9 +38,7 @@ const queryClient = new QueryClient({
if (isUnauthorized(status)) return;
const errorMessage = resolveErrorMessage(axiosError);
- toast.error(errorMessage, {
- id: buildToastId(status, errorMessage),
- });
+ showIconToast("logo", errorMessage);
},
}),
defaultOptions: {
diff --git a/apps/web/src/lib/toast/options.ts b/apps/web/src/lib/toast/options.ts
deleted file mode 100644
index 55c33182..00000000
--- a/apps/web/src/lib/toast/options.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import type { ToastOptions } from "react-hot-toast";
-
-const BASE_STYLE = {
- borderRadius: "0.5rem",
- color: "#ffffff",
-};
-
-export const infoToastOptions: ToastOptions = {
- duration: 3000,
- icon: "ℹ",
- style: {
- ...BASE_STYLE,
- background: "#111827",
- },
-};
-
-export const warningToastOptions: ToastOptions = {
- duration: 3000,
- icon: "⚠",
- style: {
- ...BASE_STYLE,
- background: "#f59e0b",
- },
-};
diff --git a/apps/web/src/lib/toast/showIconToast.tsx b/apps/web/src/lib/toast/showIconToast.tsx
new file mode 100644
index 00000000..db4d7595
--- /dev/null
+++ b/apps/web/src/lib/toast/showIconToast.tsx
@@ -0,0 +1,49 @@
+import type { FC, SVGProps } from "react";
+import { toast } from "react-hot-toast";
+
+import { IconToastCap, IconToastLike, IconToastLink, IconToastLogo, IconToastUniv } from "@/public/svgs/toast";
+
+export type ToastIconKey = "like" | "link" | "univ" | "cap" | "logo";
+
+const ICONS: Record>> = {
+ like: IconToastLike,
+ link: IconToastLink,
+ univ: IconToastUniv,
+ cap: IconToastCap,
+ logo: IconToastLogo,
+};
+
+const TOAST_DURATION = 3000;
+const TOAST_COOLDOWN = 3500;
+const activeToastKeys = new Set();
+
+export const showIconToast = (icon: ToastIconKey, message: string) => {
+ const Icon = ICONS[icon];
+ const key = `${icon}:${message}`;
+
+ if (activeToastKeys.has(key)) return;
+ activeToastKeys.add(key);
+
+ toast.custom(
+ () => (
+
+
+
+ {message}
+
+
+ ),
+ { duration: TOAST_DURATION },
+ );
+
+ setTimeout(() => activeToastKeys.delete(key), TOAST_COOLDOWN);
+};
diff --git a/apps/web/src/utils/authUtils.ts b/apps/web/src/utils/authUtils.ts
index 981ac939..4dfe58ac 100644
--- a/apps/web/src/utils/authUtils.ts
+++ b/apps/web/src/utils/authUtils.ts
@@ -1,4 +1,4 @@
-import { toast } from "react-hot-toast";
+import { showIconToast } from "@/lib/toast/showIconToast";
import type { appleOAuth2CodeResponse } from "@/types/auth";
export const authProviderName = (provider: "KAKAO" | "APPLE" | "EMAIL"): string => {
@@ -19,13 +19,13 @@ export const kakaoLogin = () => {
redirectUri: `${process.env.NEXT_PUBLIC_WEB_URL}/login/kakao/callback`,
});
} else {
- toast.error("Kakao SDK를 불러오는 중입니다. 잠시 후 다시 시도해주세요.");
+ showIconToast("logo", "Kakao SDK를 불러오는 중입니다. 잠시 후 다시 시도해주세요.");
}
};
export const appleLogin = async () => {
if (!window.AppleID || !window.AppleID.auth) {
- toast.error("Apple SDK를 불러오는 중입니다. 잠시 후 다시 시도해주세요.");
+ showIconToast("logo", "Apple SDK를 불러오는 중입니다. 잠시 후 다시 시도해주세요.");
return;
}
@@ -52,7 +52,7 @@ export const appleLogin = async () => {
}
// Show user-facing error message for other failures
- toast.error("Apple 로그인에 실패했습니다. 잠시 후 다시 시도해주세요.");
+ showIconToast("logo", "Apple 로그인에 실패했습니다. 잠시 후 다시 시도해주세요.");
// Propagate error for upstream handling if needed
throw error;
diff --git a/apps/web/src/utils/axiosInstance.ts b/apps/web/src/utils/axiosInstance.ts
index d728a450..be7ddfa8 100644
--- a/apps/web/src/utils/axiosInstance.ts
+++ b/apps/web/src/utils/axiosInstance.ts
@@ -1,6 +1,6 @@
import axios, { type AxiosError, type AxiosInstance } from "axios";
-import { toast } from "react-hot-toast";
import { postReissueToken } from "@/apis/Auth/server";
+import { showIconToast } from "@/lib/toast/showIconToast";
import useAuthStore from "@/lib/zustand/useAuthStore";
import { isTokenExpired } from "@/utils/jwtUtils";
@@ -27,7 +27,7 @@ const redirectToLogin = (message: string) => {
try {
// 쿠키 유틸이 클라이언트에서만 동작하므로 window 가드 내에서 호출
} catch {}
- toast.error(message, { id: "auth-redirect" });
+ showIconToast("logo", message);
window.location.href = "/login";
}
};