diff --git a/src/app/providers/modal-provider.tsx b/src/app/providers/modal-provider.tsx index 9c1be8aa..559c985d 100644 --- a/src/app/providers/modal-provider.tsx +++ b/src/app/providers/modal-provider.tsx @@ -9,6 +9,7 @@ interface ModalItem { id: string; content: ReactNode; autoPlay?: number; + size?: "default" | "auto"; } export const ModalProvider = () => { @@ -33,6 +34,7 @@ export const ModalProvider = () => { isOpen={true} autoPlay={modal.autoPlay} onClose={() => modalStore.close(modal.id)} + size={modal.size} > {modal.content} diff --git a/src/app/routes/paths.ts b/src/app/routes/paths.ts index 9007da92..ca1af694 100644 --- a/src/app/routes/paths.ts +++ b/src/app/routes/paths.ts @@ -15,5 +15,8 @@ export const ROUTES = { EXPERIENCE_DETAIL: (id = ":id") => `/experience/${id}`, // 경험 상세 EXPERIENCE_EDIT: (id = ":id") => `/experience/${id}/edit`, // 경험 수정 + POLICY_USE: "/policy/terms", // 이용약관 + POLICY_PRIVACY: "/policy/privacy", // 개인정보처리방침 + MYPAGE: "/mypage", }; diff --git a/src/app/routes/public-routes.tsx b/src/app/routes/public-routes.tsx index 47a882e6..aaec6742 100644 --- a/src/app/routes/public-routes.tsx +++ b/src/app/routes/public-routes.tsx @@ -32,6 +32,12 @@ const CompanyDetailPage = lazy(() => })) ); +const PolicyPage = lazy(() => + import("@/pages/policy/policy-page").then((module) => ({ + default: module.PolicyPage, + })) +); + export const guestRoutes = [{ path: ROUTES.LOGIN, element: }]; export const publicRoutes = [ @@ -39,4 +45,6 @@ export const publicRoutes = [ { path: ROUTES.LANDING, element: }, { path: ROUTES.HOME, element: }, { path: ROUTES.COMPANY(), element: }, + { path: ROUTES.POLICY_USE, element: }, + { path: ROUTES.POLICY_PRIVACY, element: }, ]; diff --git a/src/features/experience-detail/model/use-leave-confirm.tsx b/src/features/experience-detail/model/use-leave-confirm.tsx index a5cfee1a..266b1c1d 100644 --- a/src/features/experience-detail/model/use-leave-confirm.tsx +++ b/src/features/experience-detail/model/use-leave-confirm.tsx @@ -1,6 +1,7 @@ import { useEffect, useCallback } from "react"; import { useBlocker } from "react-router-dom"; +import { IconWarn } from "@/shared/assets/icons"; import { modalStore } from "@/shared/model/store"; import { ModalBasic } from "@/shared/ui"; @@ -65,12 +66,13 @@ export const useLeaveConfirm = () => { if (blocker.state === "blocked") { modalStore.open( } + title={`작성 중인 내용이 있어요`} + subTitle="저장하지 않으면 내용이 모두 사라져요." + closeText="나가기" + confirmText="계속 작성하기" + onClose={confirmLeave} + onConfirm={cancelLeave} />, 0, undefined, 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 717e31cb..743890d8 100644 --- a/src/features/experience-detail/ui/experience-viewer/experience-viewer.tsx +++ b/src/features/experience-detail/ui/experience-viewer/experience-viewer.tsx @@ -1,3 +1,4 @@ +import { IconTrash } from "@/shared/assets/icons"; import { EXPERIENCE_TYPE, type ExperienceTypeCode, @@ -48,15 +49,18 @@ const ExperienceViewer = () => { const handleOpenDeleteModal = () => { modalStore.open( modalStore.reset()} // 취소 시 닫기 - onConfirm={() => { + icon={} + title="이 경험을 삭제할까요?" + subTitle="삭제하면 다시 복구할 수 없어요" + closeText="삭제하기" + confirmText="취소하기" + onClose={() => { onClickDelete(); // 실제 삭제 동작 modalStore.reset(); // 모달 닫기 }} + onConfirm={() => { + modalStore.reset(); // 취소 시 닫기 + }} /> ); }; diff --git a/src/features/experience-matching/ui/analyzing/analyzing.tsx b/src/features/experience-matching/ui/analyzing/analyzing.tsx index 6c32a332..598586e4 100644 --- a/src/features/experience-matching/ui/analyzing/analyzing.tsx +++ b/src/features/experience-matching/ui/analyzing/analyzing.tsx @@ -10,38 +10,44 @@ import * as styles from "./analyzing.css"; import type { CustomErrorResponse } from "@/shared/api/generate/http-client"; +let isRequesting = false; + export const Analyzing = ({ nextStep }: { nextStep: () => void }) => { const { company, experience, jobDescription, setReportId } = useReportStore(); - const { mutate } = useCreateReport(); + const { mutateAsync } = useCreateReport(); // 에러 핸들링 (임시) const [open, setOpen] = useState(false); const [errorMsg, setErrorMsg] = useState(""); useEffect(() => { - mutate( - { - companyId: company?.id ?? 0, - experienceId: experience?.id ?? 0, - jobDescription: jobDescription, - }, - { - onSuccess: (response) => { - setReportId(response?.id ?? 0); - nextStep(); - }, - onError: (error: CustomErrorResponse) => { - const serverMessage = - error.message || "리포트 생성 중 에러가 발생했습니다"; - setErrorMsg(serverMessage); - setOpen(true); + if (isRequesting) return; + isRequesting = true; + + const handleRequest = async () => { + try { + const response = await mutateAsync({ + companyId: company?.id ?? 0, + experienceId: experience?.id ?? 0, + jobDescription: jobDescription, + }); - setTimeout(() => setOpen(false), 3000); - }, + setReportId(response?.id ?? 0); + nextStep(); + } catch (err) { + const error = err as CustomErrorResponse; + const serverMessage = + error.message || "리포트 생성 중 에러가 발생했습니다"; + setErrorMsg(serverMessage); + setOpen(true); + setTimeout(() => setOpen(false), 3000); + } finally { + isRequesting = false; } - ); - }, [nextStep, setReportId, mutate]); + }; + handleRequest(); + }, []); return ( <>
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 e1e3cf65..83da168d 100644 --- a/src/features/experience-matching/ui/select-company/select-company.tsx +++ b/src/features/experience-matching/ui/select-company/select-company.tsx @@ -3,8 +3,9 @@ import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { ROUTES } from "@/app/routes/paths"; +import { IconPen } from "@/shared/assets/icons"; import { modalStore } from "@/shared/model/store"; -import { Button, Modal } from "@/shared/ui"; +import { Modal, ModalBasic } from "@/shared/ui"; import { useGetExperience, useGetCompanyList, @@ -36,23 +37,21 @@ export const SelectCompany = ({ onClick }: { onClick: () => void }) => { useEffect(() => { if (data?.totalElements === 0) { modalStore.open( - <> - - 아직 등록된 경험이 없습니다 - 지금 바로 경험을 등록하러 가볼까요? - - - - - - , + { + modalStore.close("NO-EXPERIENCE"); + navigate(ROUTES.HOME); + }} + onConfirm={() => { + modalStore.close("NO-EXPERIENCE"); + navigate(ROUTES.EXPERIENCE_CREATE); + }} + icon={} + title="아직 등록된 경험이 없어요" + subTitle="경험을 등록하고 AI매칭을 시작해보세요" + closeText="나중에 할게요" + confirmText="경험 등록하기" + />, undefined, undefined, "NO-EXPERIENCE" @@ -65,13 +64,15 @@ export const SelectCompany = ({ onClick }: { onClick: () => void }) => { // 기업 선택 후, 대기하는 모달 modalStore.open( <> - - - {josa(selectedCompany.name, "을/를")} 선택하셨습니다 - - 기업분석 내용을 불러오는 중입니다. + + + + + {josa(selectedCompany.name, "을/를")} 선택했어요 + + 기업분석 내용을 불러오고 있어요 + - , 3000, () => { diff --git a/src/features/onboarding/index.ts b/src/features/onboarding/index.ts index 0ab6a244..9efb4999 100644 --- a/src/features/onboarding/index.ts +++ b/src/features/onboarding/index.ts @@ -7,3 +7,5 @@ export * from "./store/interest-select/selectors"; export { useGetUniversity } from "./api/use-get-university.query"; export { usePostOnboarding } from "./api/use-post-onboarding.mutation"; + +export { PolicyModal } from "./ui/policy-modal/policy-modal"; diff --git a/src/features/onboarding/lib/onboarding-form.validator.ts b/src/features/onboarding/lib/onboarding-form.validator.ts index eeb57a63..1d3c2a0b 100644 --- a/src/features/onboarding/lib/onboarding-form.validator.ts +++ b/src/features/onboarding/lib/onboarding-form.validator.ts @@ -6,8 +6,10 @@ export const isOnboardingFormComplete = (params: { selectedUniversity: SearchItem | null; industry: Record; job: Record; + isAgreed: boolean; }) => { - const { selectedEducation, selectedUniversity, industry, job } = params; + const { selectedEducation, selectedUniversity, industry, job, isAgreed } = + params; const hasEducation = Boolean(selectedEducation); const hasUniversity = Boolean(selectedUniversity); @@ -15,5 +17,5 @@ export const isOnboardingFormComplete = (params: { const hasIndustry1 = Boolean(industry[1]); const hasJob1 = Boolean(job[1]); - return hasEducation && hasUniversity && hasIndustry1 && hasJob1; + return hasEducation && hasUniversity && hasIndustry1 && hasJob1 && isAgreed; }; diff --git a/src/features/onboarding/ui/policy-modal/policy-modal.css.ts b/src/features/onboarding/ui/policy-modal/policy-modal.css.ts new file mode 100644 index 00000000..24cd53a2 --- /dev/null +++ b/src/features/onboarding/ui/policy-modal/policy-modal.css.ts @@ -0,0 +1,154 @@ +import { style } from "@vanilla-extract/css"; +import { recipe } from "@vanilla-extract/recipes"; + +import { themeVars } from "@/app/styles"; + +export const wrapper = style({ + width: "40rem", + paddingBottom: "1.6rem", +}); + +export const modalHeader = style({ + width: "100%", + position: "relative", + display: "flex", + justifyContent: "center", + alignItems: "center", + padding: "1.6rem", + borderBottom: `1px solid ${themeVars.color.normal}`, + ...themeVars.fontStyles.hline_b_18, +}); + +export const buttonWrapper = style({ + position: "absolute", + right: "1.6rem", + width: "2.4rem", + height: "2.4rem", +}); + +export const modalContent = style({ + display: "flex", + flexDirection: "column", + gap: "2.4rem", + textAlign: "left", + height: "28rem", + padding: "2rem 3rem", + marginBottom: "2rem", + overflowY: "auto", + + selectors: { + "&::-webkit-scrollbar": { + width: "1.2rem", + }, + "&::-webkit-scrollbar-thumb": { + backgroundColor: themeVars.color.gray300, + height: "5rem", + borderRadius: "100px", + backgroundClip: "padding-box", + border: `4px solid transparent`, + }, + "&::-webkit-scrollbar-track": { + backgroundColor: "transparent", + margin: "0.8rem 1.2rem", + }, + }, +}); + +export const title = style({ + color: themeVars.color.gray800, + ...themeVars.fontStyles.body_b_14, + fontWeight: 600, + marginBottom: "0.8rem", +}); + +export const subTitle = style({ + color: themeVars.color.gray800, + ...themeVars.fontStyles.cap_m_12, + fontWeight: 500, +}); + +export const content = style({ + display: "flex", + flexDirection: "column", + color: themeVars.color.gray500, + ...themeVars.fontStyles.cap_m_12, + fontWeight: 500, + whiteSpace: "pre-wrap", +}); + +export const textStyle = recipe({ + base: { + color: themeVars.color.gray800, + }, + variants: { + type: { + title1: { + ...themeVars.fontStyles.body_b_16, + fontWeight: 700, + }, + title2: { + ...themeVars.fontStyles.body_b_14, + }, + title3: { + ...themeVars.fontStyles.body_r_14, + fontWeight: 400, + }, + }, + }, + defaultVariants: { + type: "title1", + }, +}); + +export const flexColumn = recipe({ + base: { + display: "flex", + flexDirection: "column", + }, + variants: { + gap: { + 8: { gap: "0.8rem" }, + 16: { gap: "1.6rem" }, + 24: { gap: "2.4rem" }, + }, + }, +}); + +export const tableWrapper = style({ + width: "100%", + overflowX: "auto", + selectors: { + "&::-webkit-scrollbar": { + display: "none", + }, + }, +}); + +export const table = style({ + width: "max-content", + minWidth: "100%", + borderCollapse: "collapse", +}); + +export const tCell = style({ + minWidth: "10rem", + maxWidth: "25rem", + padding: "0.8rem", + border: `1px solid ${themeVars.color.gray200}`, + fontWeight: 400, + verticalAlign: "top", + wordBreak: "keep-all", +}); + +export const thead = style({ + backgroundColor: themeVars.color.gray100, +}); + +export const th = style({ + whiteSpace: "nowrap", + padding: "1rem 0.8rem", +}); + +export const tableText = style({ + fontWeight: 400, +}); diff --git a/src/features/onboarding/ui/policy-modal/policy-modal.tsx b/src/features/onboarding/ui/policy-modal/policy-modal.tsx new file mode 100644 index 00000000..414d1732 --- /dev/null +++ b/src/features/onboarding/ui/policy-modal/policy-modal.tsx @@ -0,0 +1,46 @@ +import { Button, Modal } from "@/shared/ui"; + +import * as styles from "./policy-modal.css"; +import { PrivacyPolicyContent } from "./privacy-policy-content"; +import { UsePolicyContent } from "./use-policy-content"; + +interface PolicyModalProps { + type: "USE" | "PRIVACY"; + onClose: () => void; +} + +const POLICY_MODAL_CONTENT = { + USE: { + title: "이용약관", + Content: UsePolicyContent, + }, + PRIVACY: { + title: "개인정보처리방침", + Content: PrivacyPolicyContent, + }, +}; + +export const PolicyModal = ({ type, onClose }: PolicyModalProps) => { + const { title, Content } = POLICY_MODAL_CONTENT[type]; // 타입에 따른 약관모달 선택 + + return ( +
+
+

{title}

+
+ +
+
+ +
+ +
+
+ + + +
+ ); +}; diff --git a/src/features/onboarding/ui/policy-modal/privacy-policy-content.tsx b/src/features/onboarding/ui/policy-modal/privacy-policy-content.tsx new file mode 100644 index 00000000..dfbe7d1e --- /dev/null +++ b/src/features/onboarding/ui/policy-modal/privacy-policy-content.tsx @@ -0,0 +1,94 @@ +import { + TERMS_OF_PRIVACY_INFO, + type Article, + type Section, +} from "@/shared/config"; + +import * as styles from "./policy-modal.css"; + +export const PrivacyPolicyContent = () => { + return ( +
+ {/** 개인정보처리방침 타이틀 */} +
+
+

{TERMS_OF_PRIVACY_INFO.title}

+

{TERMS_OF_PRIVACY_INFO.date}

+
+

{TERMS_OF_PRIVACY_INFO.description}

+
+ {/** 조항 리스트 (ex. 1. 개인정보의 수집 및 이용) */} + {TERMS_OF_PRIVACY_INFO.sections.map((section: Section) => ( +
+ {/* 조항 타이틀 및 설명 */} +
+

+ {section.title} +

+

{section.description}

+
+ {/** 조항 상세설명 */} +
+ {section.articles?.map((article: Article) => ( +
+ {/** 조항 상세설명의 타이틀 (ex. 가. 회원가입 및 계정 관리) */} + {article.title && ( +

+ {article.title} +

+ )} +
+ {article.content && ( +
+ {/* 줄글 형태의 컨텐츠 */} + {"text" in article.content && ( +
{article.content.text}
+ )} + {/* 테이블 형태의 컨텐츠 */} + {"table" in article.content && ( +
+ + + + {article.content.table.header.map( + (th, thIdx) => ( + + ) + )} + + + + {article.content.table.rows.map((row, rowIdx) => ( + + {row.map((td, tdIdx) => ( + + ))} + + ))} + +
+ {th} +
+ {td} +
+
+ )} +
+ )} +
+
+ ))} +
+ {/** 조항 추가사항 */} + {section.alert &&
{section.alert}
} +
+ ))} +
+ ); +}; diff --git a/src/features/onboarding/ui/policy-modal/use-policy-content.tsx b/src/features/onboarding/ui/policy-modal/use-policy-content.tsx new file mode 100644 index 00000000..8a60cead --- /dev/null +++ b/src/features/onboarding/ui/policy-modal/use-policy-content.tsx @@ -0,0 +1,26 @@ +import { TERMS_OF_USE, type Chapters, type Chapter } from "@/shared/config"; + +import * as styles from "./policy-modal.css"; + +export const UsePolicyContent = () => { + return TERMS_OF_USE.map((policy: Chapters) => { + return ( +
+

{policy.chapterTitle}

+ + {policy.chapter.map((chapter: Chapter, idx) => ( +
+ {chapter.title && ( +

{chapter.title}

+ )} +
+ {chapter.contents.map((content, idx) => ( +

{content}

+ ))} +
+
+ ))} +
+ ); + }); +}; diff --git a/src/pages/login/kakao-login-page.tsx b/src/pages/login/kakao-login-page.tsx index 6d2aabb2..21d44775 100644 --- a/src/pages/login/kakao-login-page.tsx +++ b/src/pages/login/kakao-login-page.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { useAuthStore } from "@/app/store"; @@ -9,7 +9,9 @@ const KakaoLoginPage = () => { const navigate = useNavigate(); const { actions } = useAuthStore(); - const code = new URL(window.location.href).searchParams.get("code"); + const [code] = useState(() => + new URL(window.location.href).searchParams.get("code") + ); const { data } = useLogin(code ?? ""); diff --git a/src/pages/onboarding/onboarding-page.css.ts b/src/pages/onboarding/onboarding-page.css.ts index 46937712..006c1c3b 100644 --- a/src/pages/onboarding/onboarding-page.css.ts +++ b/src/pages/onboarding/onboarding-page.css.ts @@ -1,4 +1,5 @@ import { style } from "@vanilla-extract/css"; +import { recipe } from "@vanilla-extract/recipes"; import { themeVars } from "@/app/styles"; @@ -106,6 +107,59 @@ export const sectionGroup = style({ gap: "4rem", }); +export const agreeGroup = style({ + display: "flex", + alignItems: "center", + gap: "1.6rem", +}); + +export const agreeContent = style({ + display: "flex", + alignItems: "center", + color: themeVars.color.gray800, + ...themeVars.fontStyles.body_m_16, + fontWeight: 500, +}); + +export const checkbox = recipe({ + base: { + display: "flex", + justifyContent: "center", + alignItems: "center", + width: "1.8rem", + height: "1.8rem", + aspectRatio: 1 / 1, + borderRadius: "2px", + cursor: "pointer", + }, + variants: { + isAgreed: { + true: { + border: `1.6px solid ${themeVars.color.blue400}`, + background: themeVars.color.blue500, + }, + false: { + border: `1px solid ${themeVars.color.gray400}`, + background: themeVars.color.white, + }, + }, + }, + defaultVariants: { + isAgreed: false, + }, +}); + +export const underlineText = style({ + textDecoration: "underline", + textUnderlinePosition: "under", + color: themeVars.color.blue600, + selectors: { + "&:hover": { + cursor: "pointer", + }, + }, +}); + export const buttonWrap = style({ width: "34rem", diff --git a/src/pages/onboarding/onboarding-page.tsx b/src/pages/onboarding/onboarding-page.tsx index 68a1f5e6..c4b196cb 100644 --- a/src/pages/onboarding/onboarding-page.tsx +++ b/src/pages/onboarding/onboarding-page.tsx @@ -13,6 +13,7 @@ import { labelToCodeIndustry } from "@/shared/config"; import { Button, Alert } from "@/shared/ui"; import * as s from "./onboarding-page.css"; +import { AgreeSection } from "./ui/agree-section"; import { SelectSection } from "./ui/select-section"; import type { EducationTypeCode } from "@/features/onboarding"; @@ -27,6 +28,7 @@ const OnboardingPage = () => { useState(null); const [selectedUniversity, setSelectedUniversity] = useState(null); + const [isAgreed, setIsAgreed] = useState(false); // 이용약관 및 개인정보처리방침 동의 여부 const industry = useInterestSelectStore((s) => s.industry); const job = useInterestSelectStore((s) => s.job); @@ -41,8 +43,9 @@ const OnboardingPage = () => { selectedUniversity, industry, job, + isAgreed, }), - [selectedEducation, selectedUniversity, industry, job] + [selectedEducation, selectedUniversity, industry, job, isAgreed] ); const handleSelectionSubmit = () => { @@ -98,6 +101,8 @@ const OnboardingPage = () => { setSelectedUniversity={setSelectedUniversity} /> + +