diff --git a/package.json b/package.json index 7f75cbfa..b6db6ce4 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@vanilla-extract/recipes": "^0.5.7", "@vanilla-extract/vite-plugin": "^5.1.4", "axios": "^1.13.2", + "es-hangul": "^2.3.8", "eslint-import-resolver-typescript": "^4.4.4", "framer-motion": "^12.27.1", "react": "^19.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3261d294..e807a61a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: axios: specifier: ^1.13.2 version: 1.13.2 + es-hangul: + specifier: ^2.3.8 + version: 2.3.8 eslint-import-resolver-typescript: specifier: ^4.4.4 version: 4.4.4(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) @@ -1929,6 +1932,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-hangul@2.3.8: + resolution: {integrity: sha512-VrJuqYBC7W04aKYjCnswomuJNXQRc0q33SG1IltVrRofi2YEE6FwVDPlsEJIdKbHwsOpbBL/mk9sUaBxVpbd+w==} + es-iterator-helpers@1.2.2: resolution: {integrity: sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==} engines: {node: '>= 0.4'} @@ -5769,6 +5775,8 @@ snapshots: es-errors@1.3.0: {} + es-hangul@2.3.8: {} + es-iterator-helpers@1.2.2: dependencies: call-bind: 1.0.8 diff --git a/src/app/providers/modal-provider.tsx b/src/app/providers/modal-provider.tsx new file mode 100644 index 00000000..dc85c5eb --- /dev/null +++ b/src/app/providers/modal-provider.tsx @@ -0,0 +1,43 @@ +import { useEffect, useState, type ReactNode } from "react"; +import { useLocation } from "react-router-dom"; + +import { modalStore } from "@/shared/model/store"; + +import { Modal } from "../../shared/ui/modal/modal"; + +interface ModalItem { + id: string; + content: ReactNode; + autoPlay?: number; +} + +export const ModalProvider = () => { + const { pathname } = useLocation(); + const [modals, setModals] = useState([]); + + useEffect(() => { + const unsubscribe = modalStore.subscribe(setModals); + return unsubscribe; + }, []); + + useEffect(() => { + modalStore.reset(); + }, [pathname]); + + return ( + <> + {modals.map((modal) => { + return ( + modalStore.close(modal.id)} + > + {modal.content} + + ); + })} + + ); +}; diff --git a/src/app/routes/root-layout.tsx b/src/app/routes/root-layout.tsx index 626af69b..b2358e41 100644 --- a/src/app/routes/root-layout.tsx +++ b/src/app/routes/root-layout.tsx @@ -2,11 +2,13 @@ import { Outlet } from "react-router-dom"; import { Header } from "@widgets/index"; +import { ModalProvider } from "../providers/modal-provider"; import { StoreResetListener } from "../providers/store-reset-listener"; export const RootLayout = () => { return ( <> +
diff --git a/src/features/experience-detail/model/use-leave-confirm.ts b/src/features/experience-detail/model/use-leave-confirm.tsx similarity index 71% rename from src/features/experience-detail/model/use-leave-confirm.ts rename to src/features/experience-detail/model/use-leave-confirm.tsx index b4fc8cf6..a5cfee1a 100644 --- a/src/features/experience-detail/model/use-leave-confirm.ts +++ b/src/features/experience-detail/model/use-leave-confirm.tsx @@ -1,7 +1,8 @@ -import { useEffect, useCallback, useRef } from "react"; +import { useEffect, useCallback } from "react"; import { useBlocker } from "react-router-dom"; -import { useModal } from "@/shared/ui/modal/use-modal"; +import { modalStore } from "@/shared/model/store"; +import { ModalBasic } from "@/shared/ui"; import { initialDraft, @@ -23,12 +24,12 @@ const isDraftDirty = (draft: ExperienceUpsertBody): boolean => { ); }; +const LEAVE_MODAL_ID = "leave-confirm-modal"; + export const useLeaveConfirm = () => { const mode = useExperienceDetailStore((s) => s.mode); const draft = useExperienceDetailStore((s) => s.draft); - const { isOpen, openModal, closeModal } = useModal(); - const shouldBlock = (mode === "create" || mode === "edit") && isDraftDirty(draft); @@ -46,17 +47,37 @@ export const useLeaveConfirm = () => { return shouldBlockNow && !isSubmitting && !isTransitioning; }); - const prevBlockerStateRef = useRef(blocker.state); + const confirmLeave = useCallback(() => { + if (blocker.state === "blocked") { + blocker.proceed(); + } + modalStore.close(LEAVE_MODAL_ID); + }, [blocker]); - useEffect(() => { - const wasBlocked = prevBlockerStateRef.current === "blocked"; - const nowBlocked = blocker.state === "blocked"; - prevBlockerStateRef.current = blocker.state; + const cancelLeave = useCallback(() => { + if (blocker.state === "blocked") { + blocker.reset(); + } + modalStore.close(LEAVE_MODAL_ID); + }, [blocker]); - if (nowBlocked && !wasBlocked && !isOpen) { - openModal(); + useEffect(() => { + if (blocker.state === "blocked") { + modalStore.open( + , + 0, + undefined, + LEAVE_MODAL_ID + ); } - }, [blocker.state, isOpen, openModal]); + }, [blocker.state, cancelLeave, confirmLeave]); useEffect(() => { const handleBeforeUnload = (e: BeforeUnloadEvent) => { @@ -70,23 +91,5 @@ export const useLeaveConfirm = () => { return () => window.removeEventListener("beforeunload", handleBeforeUnload); }, [shouldBlock]); - const confirmLeave = useCallback(() => { - if (blocker.state === "blocked") { - blocker.proceed(); - } - closeModal(); - }, [blocker, closeModal]); - - const cancelLeave = useCallback(() => { - if (blocker.state === "blocked") { - blocker.reset(); - } - closeModal(); - }, [blocker, closeModal]); - - return { - isOpen, - confirmLeave, - cancelLeave, - }; + return {}; }; diff --git a/src/features/experience-detail/ui/experience-viewer/experience-viewer.tsx b/src/features/experience-detail/ui/experience-viewer/experience-viewer.tsx index acd34eac..42ca1d30 100644 --- a/src/features/experience-detail/ui/experience-viewer/experience-viewer.tsx +++ b/src/features/experience-detail/ui/experience-viewer/experience-viewer.tsx @@ -1,8 +1,8 @@ import { EXPERIENCE_TYPE } from "@/shared/config/experience"; import { parseYMD } from "@/shared/lib/format-date"; +import { modalStore } from "@/shared/model/store"; import { ModalBasic, Tooltip } from "@/shared/ui"; import { Button } from "@/shared/ui/button/button"; -import { useModal } from "@/shared/ui/modal/use-modal"; import { Tag } from "@/shared/ui/tag/tag"; import { Textfield } from "@/shared/ui/textfield/textfield"; import { HELP_TOOLTIP_CONTENT } from "@/shared/ui/tooltip/tooltip.content"; @@ -24,9 +24,6 @@ const ExperienceViewer = () => { const { showEditDelete, onClickEdit, onClickDelete, onToggleDefault } = useExperienceHeaderActions(); - const { isOpen: isDeleteModalOpen, handleModal: toggleDeleteModal } = - useModal(); - const startDate = current?.startAt ? parseYMD(current.startAt) : null; const endDate = current?.endAt ? parseYMD(current.endAt) : null; @@ -42,6 +39,22 @@ const ExperienceViewer = () => { const typeLabel = current.type ? EXPERIENCE_TYPE[current.type] : "미지정"; + const handleOpenDeleteModal = () => { + modalStore.open( + modalStore.reset()} // 취소 시 닫기 + onConfirm={() => { + onClickDelete(); // 실제 삭제 동작 + modalStore.reset(); // 모달 닫기 + }} + /> + ); + }; + return (
{ @@ -130,19 +143,6 @@ const ExperienceViewer = () => { - - { - toggleDeleteModal(); - onClickDelete(); - }} - />
); }; diff --git a/src/features/experience-matching/ui/select-company/select-company.tsx b/src/features/experience-matching/ui/select-company/select-company.tsx index 2a7cc75a..6f7c23b4 100644 --- a/src/features/experience-matching/ui/select-company/select-company.tsx +++ b/src/features/experience-matching/ui/select-company/select-company.tsx @@ -1,8 +1,10 @@ -import { useEffect, useRef, useState } from "react"; +import { josa } from "es-hangul"; +import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { ROUTES } from "@/app/routes/paths"; -import { Button, Modal, useModal } from "@/shared/ui"; +import { modalStore } from "@/shared/model/store"; +import { Button, Modal } from "@/shared/ui"; import { useGetExperience, useGetCompanyList, @@ -14,58 +16,67 @@ import { MatchingAutoComplete } from "../matching-auto-complete/matching-auto-co import * as styles from "./select-company.css"; import type { Company } from "../../type"; - export const SelectCompany = ({ onClick }: { onClick: () => void }) => { const navigate = useNavigate(); const { data } = useGetExperience(); // 경험 조회 API - /** Report 전역 상태 */ + // AI-Report 입력 단계 저장을 위한 전역 상태 const setCompany = useReportStore((state) => state.setCompany); const company = useReportStore((state) => state.company); - /** 모달 상태 관리 */ - const { autoPlay, isOpen, handleModal } = useModal(3000); // 몇 초 뒤 닫히게 할 건지 정의 - const alertModal = useModal(); // 경험 등록 여부 확인 모달 - - /** 입력 데이터 상태 관리 */ + // 기업 입력 데이터 상태 관리 const [inputValue, setInputValue] = useState(""); // 실시간 입력 상태 const [searchKeyword, setSearchKeyword] = useState(""); // 디바운스된 키워드 상태 const [selectedCompany, setSelectedCompany] = useState( company ); + const { data: searchResults = [] } = useGetCompanyList(searchKeyword); // 기업 검색 API - // 경험 등록 여부 확인 모달 오픈 + // 경험 등록 여부 확인 모달 useEffect(() => { if (data?.totalElements === 0) { - if (!alertModal.isOpen) { - alertModal.openModal(); - } + modalStore.open( + <> + + 아직 등록된 경험이 없습니다 + 지금 바로 경험을 등록하러 가볼까요? + + + + + + + ); } - }, [data, alertModal]); - - // useEffect(() => { - // if (company?.id) { - // const temp={ - // id:company.id, - // name:company.name - // } - // setSelectedCompany(temp); - // } - // }, [company]); + }, [data, navigate]); - const { data: searchResults = [] } = useGetCompanyList(searchKeyword); // 기업 검색 API - - // 모달 닫힘 여부 확인 후 페이지 이동 - const prevIsOpen = useRef(isOpen); - useEffect(() => { - if (prevIsOpen.current === true && isOpen === false) { - if (selectedCompany) { - setCompany(selectedCompany); // 선택된 기업 데이터 저장 + const handleSearch = () => { + if (!selectedCompany) return; + // 기업 선택 후, 대기하는 모달 + modalStore.open( + <> + + + {josa(selectedCompany.name, "을/를")} 선택하셨습니다 + + 기업분석 내용을 불러오는 중입니다. + + + , + 3000, + () => { + setCompany(selectedCompany); onClick(); } - } - prevIsOpen.current = isOpen; - }, [isOpen, selectedCompany, onClick, setCompany]); + ); + }; return (
@@ -77,39 +88,8 @@ export const SelectCompany = ({ onClick }: { onClick: () => void }) => { onDebounceChange={setSearchKeyword} selectedItem={selectedCompany} onSelect={setSelectedCompany} - onSearch={handleModal} + onSearch={handleSearch} /> - {/** 경험 등록 여부 확인 모달 */} - - - 아직 등록된 경험이 없습니다 - 지금 바로 경험을 등록하러 가볼까요? - - - - - - - {/** 기업 선택 후, 대기 모달 */} - - - {selectedCompany?.name}을 선택하셨습니다 - 기업분석 내용을 불러오는 중입니다. - - -
); }; diff --git a/src/pages/experience-detail/experience-detail-page.tsx b/src/pages/experience-detail/experience-detail-page.tsx index a0f2769f..a808f458 100644 --- a/src/pages/experience-detail/experience-detail-page.tsx +++ b/src/pages/experience-detail/experience-detail-page.tsx @@ -11,7 +11,6 @@ import { useGetExperienceDetail, hydrateExperienceFromApi, } from "@/features/experience-detail"; -import { ModalBasic } from "@/shared/ui/modal/modal-basic"; import type { ExperienceMode } from "@/features/experience-detail"; @@ -22,7 +21,7 @@ interface ExperiencePageProps { const ExperienceDetailPage = ({ mode }: ExperiencePageProps) => { const { id: experienceId } = useParams<{ id: string }>(); const currentMode = useExperienceMode(); - const { isOpen, confirmLeave, cancelLeave } = useLeaveConfirm(); + useLeaveConfirm(); const initializedExperienceIdRef = useRef(null); const parsedExperienceId = experienceId ? Number(experienceId) : NaN; @@ -69,15 +68,6 @@ const ExperienceDetailPage = ({ mode }: ExperiencePageProps) => { <> {content} - ); }; diff --git a/src/shared/model/store/index.ts b/src/shared/model/store/index.ts index 6030aba4..c6267ef8 100644 --- a/src/shared/model/store/index.ts +++ b/src/shared/model/store/index.ts @@ -1 +1,2 @@ export { useAuthStore } from "./auth.store"; +export { modalStore } from "./modal.store"; diff --git a/src/shared/model/store/modal.store.ts b/src/shared/model/store/modal.store.ts new file mode 100644 index 00000000..104dd5e2 --- /dev/null +++ b/src/shared/model/store/modal.store.ts @@ -0,0 +1,73 @@ +import type { ReactNode } from "react"; + +interface ModalItem { + id: string; + content: ReactNode; + onClose?: () => void; + autoPlay?: number; +} + +class ModalStore { + private _modalList: ModalItem[] = []; // 모달 리스트 관리 + private _listeners = new Set<(list: ModalItem[]) => void>(); + private _timers = new Map(); // 타이머 관리 + + private notify() { + this._listeners.forEach((listener) => { + listener(this._modalList); + }); + } + + subscribe(callback: (list: ModalItem[]) => void) { + this._listeners.add(callback); // 모달 리스트 상태 업데이트 함수 등록 + + return () => { + this._listeners.delete(callback); + }; + } + + open( + content: ReactNode, + autoPlay?: number, + onClose?: () => void, + id: string = new Date().toString() + ) { + const new_modal = { id: id, content: content, autoPlay, onClose }; + this._modalList = [...this._modalList, new_modal]; + this.notify(); + + if (autoPlay && autoPlay > 0) { + const timer = setTimeout(() => { + this.close(id); + }, autoPlay); + + this._timers.set(id, timer); + } + } + + close(id: string) { + // 수동으로 닫았을 때(ex. pathname 이동) 예약된 setTimeout이 실행되지 않도록 제거 + if (this._timers.has(id)) { + clearTimeout(this._timers.get(id)); + this._timers.delete(id); + } + + const target = this._modalList.find((m) => m.id === id); + + this._modalList = this._modalList.filter((modal) => modal.id !== id); + this.notify(); + + if (target?.onClose) target.onClose(); + } + + reset() { + // 예약된 타이머 제거 + this._timers.forEach(clearTimeout); + this._timers.clear(); // 메모리 참조 제거 + + this._modalList = []; + this.notify(); + } +} + +export const modalStore = new ModalStore(); diff --git a/src/shared/ui/modal/modal-basic.tsx b/src/shared/ui/modal/modal-basic.tsx index 9706af4d..cefdd5b3 100644 --- a/src/shared/ui/modal/modal-basic.tsx +++ b/src/shared/ui/modal/modal-basic.tsx @@ -3,7 +3,6 @@ import { Button } from "../button/button"; import { Modal } from "./modal"; interface ModalBasicProps { - isOpen: boolean; // 모달 오픈 여부 onClose: () => void; // 모달 닫기 액션 onConfirm: () => void; // 모달 작업 확인 액션 title: string; // 모달 제목 @@ -17,7 +16,6 @@ interface ModalBasicProps { * - 많이 사용되는 모달 스타일을 정의한 모달 래퍼 함수입니다. */ export const ModalBasic = ({ - isOpen, onClose, onConfirm, title, @@ -26,7 +24,7 @@ export const ModalBasic = ({ confirmText = "이어서 작성하기", }: ModalBasicProps) => { return ( - + <> {title} @@ -40,6 +38,6 @@ export const ModalBasic = ({ {confirmText} - + ); }; diff --git a/src/shared/ui/textfield/textfield.tsx b/src/shared/ui/textfield/textfield.tsx index 639a3ce3..a7e01e7e 100644 --- a/src/shared/ui/textfield/textfield.tsx +++ b/src/shared/ui/textfield/textfield.tsx @@ -14,7 +14,7 @@ export type TextfieldType = | "action"; const TEXTFIELD_MAX_LENGTH: Record = { - jobDescription: 300, + jobDescription: 500, situation: 200, task: 200, result: 300,