Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
43 changes: 43 additions & 0 deletions src/app/providers/modal-provider.tsx
Original file line number Diff line number Diff line change
@@ -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";
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

깊은 상대 경로 import는 alias로 통일해주세요.

이 파일은 Line 4에서 이미 alias import를 쓰고 있는데, ../../shared/ui/modal/modal만 상대 경로라 이동/리팩터링에 더 취약합니다. @/shared/ui/modal/modal로 맞추는 편이 일관성과 유지보수성에 좋습니다. As per coding guidelines, "@shared, @features, @widgets 등 alias 기반 절대 경로 import 사용 권장" and "깊은 상대 경로(../../..) 사용은 가독성과 유지보수 측면에서 지양".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/providers/modal-provider.tsx` at line 6, The file imports Modal using
a deep relative path ("../../shared/ui/modal/modal") which breaks consistency
with the other alias imports; replace that import with the project's alias
import (e.g., "@/shared/ui/modal/modal") in the modal-provider file where Modal
is imported so it matches the existing alias style, and ensure the import
statement referencing Modal is updated accordingly (no other changes required).


interface ModalItem {
id: string;
content: ReactNode;
autoPlay?: number;
}
Comment on lines +8 to +12
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

store의 ModalItem 타입을 재사용하는 편이 안전합니다.

여기 타입이 src/shared/model/store/modal.store.ts:3-8와 거의 동일해서, 필드가 추가되거나 바뀌면 provider 쪽만 조용히 drift할 수 있습니다. store에서 타입을 export하거나 별도 shared type으로 분리해 한 군데서 관리하는 편이 좋겠습니다. As per coding guidelines, "중복 코드 제거 및 재사용 가능한 구조 제안".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/providers/modal-provider.tsx` around lines 8 - 12, The local
ModalItem interface in modal-provider.tsx duplicates the store type; remove the
local declaration and import the canonical ModalItem type exported by the store
(the ModalItem exported from modal.store) so both the provider and store share a
single definition; update any uses in ModalProvider (e.g., state, props,
functions referencing ModalItem) to use the imported type and ensure the store
file exports ModalItem if it doesn't already.


export const ModalProvider = () => {
const { pathname } = useLocation();
const [modals, setModals] = useState<ModalItem[]>([]);

useEffect(() => {
modalStore.subscribe(setModals);
return () => modalStore.unsubscribe();
}, []);
Comment on lines +16 to +21
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

외부 store 구독은 useSyncExternalStore로 바꾸는 편이 더 견고합니다.

지금 패턴은 useState([])로 시작한 뒤 effect에서 subscribe만 등록하므로, 현재 snapshot 동기화와 concurrent rendering 안정성을 React가 직접 보장해주지 않습니다. modalStore가 외부 store라면 getSnapshot을 제공하고 useSyncExternalStore로 연결하는 쪽이 더 안전합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/providers/modal-provider.tsx` around lines 16 - 21, The current modal
subscription uses useState + useEffect (modalStore.subscribe -> setModals) which
is not safe for concurrent rendering; replace this pattern by implementing a
getSnapshot function that reads current modalStore state and subscribe function
that delegates to modalStore.subscribe, then call React's
useSyncExternalStore(subscribe, getSnapshot) instead of useState/useEffect so
the component reads synchronized snapshots from modalStore; update references to
useSyncExternalStore, modalStore, and remove the useEffect/setModals logic
accordingly.


useEffect(() => {
modalStore.reset();
}, [pathname]);

return (
<>
{modals.map((modal) => {
return (
<Modal
key={modal.id}
isOpen={true}
autoPlay={modal.autoPlay}
onClose={() => modalStore.close(modal.id)}
>
{modal.content}
</Modal>
);
})}
</>
);
};
2 changes: 2 additions & 0 deletions src/app/routes/root-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<>
<ModalProvider />
<StoreResetListener />
<Header />
<main>
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -24,11 +25,10 @@ const isDraftDirty = (draft: ExperienceUpsertBody): boolean => {
};

export const useLeaveConfirm = () => {
const LEAVE_MODAL_ID = "leave-confirm-modal";
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);

Expand All @@ -46,17 +46,36 @@ export const useLeaveConfirm = () => {
return shouldBlockNow && !isSubmitting && !isTransitioning;
});

const prevBlockerStateRef = useRef(blocker.state);
const confirmLeave = useCallback(() => {
if (blocker.state === "blocked") {
blocker.proceed();
}
}, [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(
<ModalBasic
title={`작성중인 내용이 있습니다.\n정말 나가시겠습니까?`}
subTitle="저장하지 않으면 내용이 사라져요."
closeText="이어서 작성"
confirmText="나가기"
onClose={cancelLeave}
onConfirm={confirmLeave}
/>,
0,
undefined,
LEAVE_MODAL_ID
);
}
}, [blocker.state, isOpen, openModal]);
}, [blocker.state, cancelLeave, confirmLeave]);

useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
Expand All @@ -70,23 +89,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 {};
};
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;

Expand All @@ -42,6 +39,22 @@ const ExperienceViewer = () => {

const typeLabel = current.type ? EXPERIENCE_TYPE[current.type] : "미지정";

const handleOpenDeleteModal = () => {
modalStore.open(
<ModalBasic
title="이 경험을 삭제하시겠습니까?"
subTitle="작성한 내용은 즉시 제거되며, 복구할 수 없습니다."
closeText="취소"
confirmText="삭제"
onClose={() => modalStore.reset()} // 취소 시 닫기
onConfirm={() => {
onClickDelete(); // 실제 삭제 동작
modalStore.reset(); // 모달 닫기
}}
/>
);
Comment on lines +42 to +55
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

이 확인창에서 reset()을 쓰면 다른 전역 모달까지 같이 닫힙니다.

onCloseonConfirm이 모두 modalStore.reset()을 호출해서, 이 삭제 모달과 무관한 다른 모달도 함께 사라집니다. 여기서는 고정 ID를 넘기고 modalStore.close(id)로 자기 자신만 닫도록 분리하는 편이 안전합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/experience-detail/ui/experience-viewer/experience-viewer.tsx`
around lines 42 - 55, The delete modal currently calls modalStore.reset() in
handleOpenDeleteModal which closes all global modals; change it to open the
ModalBasic with a fixed unique id (e.g. const id = 'experience-delete') passed
to modalStore.open and then replace modalStore.reset() in both onClose and
onConfirm with modalStore.close(id) so only this modal is closed (keep onConfirm
calling onClickDelete() before modalStore.close(id)). Ensure you pass the id
when opening and use the same id in onClose/onConfirm.

};

return (
<main className={s.page}>
<StickyHeader
Expand All @@ -53,7 +66,7 @@ const ExperienceViewer = () => {
<Button
variant="secondary"
size="small"
onClick={toggleDeleteModal}
onClick={handleOpenDeleteModal}
>
삭제하기
</Button>
Expand Down Expand Up @@ -130,19 +143,6 @@ const ExperienceViewer = () => {
</div>
</div>
</section>

<ModalBasic
title="이 경험을 삭제하시겠습니까?"
subTitle="작성한 내용은 즉시 제거되며, 복구할 수 없습니다."
closeText="취소"
confirmText="삭제"
isOpen={isDeleteModalOpen}
onClose={toggleDeleteModal}
onConfirm={() => {
toggleDeleteModal();
onClickDelete();
}}
/>
</main>
);
};
Expand Down
110 changes: 44 additions & 66 deletions src/features/experience-matching/ui/select-company/select-company.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { useEffect, useRef, useState } from "react";
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,
Expand All @@ -19,53 +20,61 @@ 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 | null>(
company
);
const { data: searchResults = [] } = useGetCompanyList(searchKeyword); // 기업 검색 API

// 경험 등록 여부 확인 모달 오픈
// 경험 등록 여부 확인 모달
useEffect(() => {
if (data?.totalElements === 0) {
if (!alertModal.isOpen) {
alertModal.openModal();
}
modalStore.open(
<>
<Modal.Content>
<Modal.Title>아직 등록된 경험이 없습니다</Modal.Title>
<Modal.SubTitle>지금 바로 경험을 등록하러 가볼까요?</Modal.SubTitle>
</Modal.Content>
<Modal.Buttons>
<Button variant="secondary" onClick={() => navigate(ROUTES.HOME)}>
나가기
</Button>
<Button
variant="primary"
onClick={() => navigate(ROUTES.EXPERIENCE_CREATE)}
>
이동하기
</Button>
</Modal.Buttons>
</>
);
}
}, [data, alertModal]);
}, [data, navigate]);

// useEffect(() => {
// if (company?.id) {
// const temp={
// id:company.id,
// name:company.name
// }
// setSelectedCompany(temp);
// }
// }, [company]);

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(
<>
<Modal.Content type="auto">
<Modal.Title>{selectedCompany.name}을 선택하셨습니다</Modal.Title>
<Modal.SubTitle>기업분석 내용을 불러오는 중입니다.</Modal.SubTitle>
</Modal.Content>
<Modal.Image />
</>,
3000,
() => {
setCompany(selectedCompany);
onClick();
}
}
prevIsOpen.current = isOpen;
}, [isOpen, selectedCompany, onClick, setCompany]);
);
};
Comment on lines +60 to +79
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

이 플로우는 재진입 방지가 필요합니다.

handleSearch가 클릭마다 새 3초 타이머를 등록하는데, 선택 버튼은 onSearch?.()를 동기적으로 바로 호출합니다. 그래서 빠른 더블클릭이면 setCompany(selectedCompany)onClick()이 여러 번 실행될 수 있습니다. 이 구간은 pending 플래그로 한 번만 실행되게 잠그거나, 버튼을 즉시 비활성화하는 방어가 필요합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/experience-matching/ui/select-company/select-company.tsx` around
lines 60 - 79, handleSearch can be re-entered on rapid clicks causing
setCompany(selectedCompany) and onClick() to run multiple times; add a pending
guard (e.g., a useRef or state boolean like isPending) checked at the top of
handleSearch to return early if true, set isPending = true immediately before
calling modalStore.open, and clear/reset isPending in the modalStore.open
completion callback (the function that currently calls setCompany and onClick)
or after the 3s timeout so the flow only executes once per selection;
alternatively disable the related button when isPending is true. Ensure you
reference and update the same pending flag around handleSearch, modalStore.open,
setCompany and onClick to prevent double execution.


return (
<div className={styles.layout}>
Expand All @@ -77,39 +86,8 @@ export const SelectCompany = ({ onClick }: { onClick: () => void }) => {
onDebounceChange={setSearchKeyword}
selectedItem={selectedCompany}
onSelect={setSelectedCompany}
onSearch={handleModal}
onSearch={handleSearch}
/>
{/** 경험 등록 여부 확인 모달 */}
<Modal isOpen={alertModal.isOpen} onClose={alertModal.closeModal}>
<Modal.Content>
<Modal.Title>아직 등록된 경험이 없습니다</Modal.Title>
<Modal.SubTitle>지금 바로 경험을 등록하러 가볼까요?</Modal.SubTitle>
</Modal.Content>
<Modal.Buttons>
<Button
variant="secondary"
size="large"
onClick={() => navigate(ROUTES.HOME)}
>
나가기
</Button>
<Button
variant="primary"
size="large"
onClick={() => navigate(ROUTES.EXPERIENCE_CREATE)}
>
이동하기
</Button>
</Modal.Buttons>
</Modal>
{/** 기업 선택 후, 대기 모달 */}
<Modal autoPlay={autoPlay} isOpen={isOpen} onClose={handleModal}>
<Modal.Content type="auto">
<Modal.Title>{selectedCompany?.name}을 선택하셨습니다</Modal.Title>
<Modal.SubTitle>기업분석 내용을 불러오는 중입니다.</Modal.SubTitle>
</Modal.Content>
<Modal.Image />
</Modal>
</div>
);
};
Loading
Loading