diff --git a/public/personas/family.png b/public/personas/family.png new file mode 100644 index 0000000..a3c39ca Binary files /dev/null and b/public/personas/family.png differ diff --git a/src/app/globals.css b/src/app/globals.css index 0aac778..147d6ba 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -121,11 +121,27 @@ body { } @media (prefers-reduced-motion: reduce) { .animate-step-next, - .animate-step-prev { + .animate-step-prev, + .animate-persona-hero-in { animation: none; } } +@keyframes persona-hero-in { + from { + opacity: 0; + transform: scale(0.96); + } + to { + opacity: 1; + transform: scale(1); + } +} + +.animate-persona-hero-in { + animation: persona-hero-in 300ms ease-in-out both; +} + /* hide the native scrollbar (used by the custom ScrollArea component) */ .scrollbar-none { scrollbar-width: none; diff --git a/src/components/WorkspaceLayout.tsx b/src/components/WorkspaceLayout.tsx index de2cdea..3a192b3 100644 --- a/src/components/WorkspaceLayout.tsx +++ b/src/components/WorkspaceLayout.tsx @@ -125,7 +125,7 @@ export function WorkspaceLayout({ // lives in localStorage, so redirecting before it loads would bounce a // logged-in user straight back to /login on every refresh. useEffect(() => { - if (hydrated && !authed) router.replace("/login"); + if (hydrated && !authed) router.replace("/prologue"); }, [hydrated, authed, router]); if (hydrated && !authed) return null; diff --git a/src/components/prologue/PersonaCenterPanel.tsx b/src/components/prologue/PersonaCenterPanel.tsx index 6a68075..0ae22ab 100644 --- a/src/components/prologue/PersonaCenterPanel.tsx +++ b/src/components/prologue/PersonaCenterPanel.tsx @@ -13,40 +13,33 @@ const accentVar: Record, string> = { green: "var(--color-event-green)", }; -/** - * 페르소나를 고르면 화면 위쪽에 뜨는 소개. - * - * 카드(테두리+배경)를 쓰지 않는다 — 목업(`.mockup/시나리오선택.png`)처럼 - * **어두운 장면 위에 글자만 떠 있고** 이름의 발광과 좌우 점 패턴이 깊이를 만든다. - * 상자를 두면 뒤의 도로·캐릭터가 잘려 장면이 두 겹으로 갈라진다. - */ +/** 선택된 페르소나 — 상단 Hero (목업: 글자만 떠 있고 이름 뒤 발광) */ export function PersonaCenterPanel({ persona }: { persona: ProloguePersona }) { - const accent = persona.accent ?? "orange"; + const accent = persona.accent ?? "amber"; return (
e.stopPropagation()} - onKeyDown={(e) => e.stopPropagation()} > - {/* 이름 좌우로 흩뿌린 점. 장식이라 읽히지 않아야 한다 */} - - +
+ + - -

{persona.name}

-

{persona.title}

-

{persona.description}

+ +

{persona.name}

+

{persona.title}

+

{persona.description}

- + -
    - {persona.details.map((line) => ( -
  • {line}
  • - ))} -
+
    + {persona.details.map((line) => ( +
  • {line}
  • + ))} +
+
); } diff --git a/src/components/prologue/PersonaRoadCharacter.tsx b/src/components/prologue/PersonaRoadCharacter.tsx index 99260bc..6fb24da 100644 --- a/src/components/prologue/PersonaRoadCharacter.tsx +++ b/src/components/prologue/PersonaRoadCharacter.tsx @@ -26,7 +26,7 @@ export function PersonaRoadCharacter({ interactive: boolean; onSelect: () => void; }) { - const accent = persona.accent ?? "orange"; + const accent = persona.accent ?? "amber"; return (
diff --git a/src/components/prologue/PersonaSelectStep.tsx b/src/components/prologue/PersonaSelectStep.tsx index b4874ac..21174e0 100644 --- a/src/components/prologue/PersonaSelectStep.tsx +++ b/src/components/prologue/PersonaSelectStep.tsx @@ -26,7 +26,6 @@ export function PersonaSelectStep({ selectedId: string | null; onSelect: (id: string | null) => void; onConfirm: () => void; - /** 데모 진입은 로그인을 한 번 다녀온다 — 그동안 버튼을 잠근다. */ busy?: boolean; error?: string; }) { @@ -54,45 +53,38 @@ export function PersonaSelectStep({ data-phase={phase} data-selected={selectedId ? "1" : "0"} > -
-
- -
-
-
- {phase === "truck" && ( -
-
- + <> +
+
+
+
-
+
+
+ +
+
+
+
+
+

+ 잠시만요, +
+ 쓩— +

+

+ 트럭이 도로를 지나갑니다… +

+
+
+
+ )} -
- {showCharacters && - personas.map((p) => ( - - onSelect(selectedId === p.id ? null : p.id) - } - /> - ))} -
- {phase === "select" && selectedId && (
-
+ )}
); } diff --git a/src/components/prologue/PrologueFlow.tsx b/src/components/prologue/PrologueFlow.tsx index fb37bb3..5d43d03 100644 --- a/src/components/prologue/PrologueFlow.tsx +++ b/src/components/prologue/PrologueFlow.tsx @@ -60,7 +60,10 @@ export function PrologueFlow() { onAnswer={(qid, oid) => setAnswers((prev) => ({ ...prev, [qid]: oid })) } - onComplete={() => setPhase("persona")} + onComplete={() => { + setPersonaId(null); + setPhase("persona"); + }} /> )} diff --git a/src/components/prologue/prologue.module.css b/src/components/prologue/prologue.module.css index bddc50b..12375ce 100644 --- a/src/components/prologue/prologue.module.css +++ b/src/components/prologue/prologue.module.css @@ -298,16 +298,15 @@ .personaField { position: absolute; left: 50%; - right: auto; - bottom: calc(18% + var(--road-char-base, 48px)); - z-index: 6; + bottom: calc(18% + var(--road-char-base, 58px)); + z-index: 22; display: flex; width: min(100%, 760px); + transform: translateX(-50%); justify-content: center; align-items: flex-end; gap: clamp(28px, 9vw, 72px); padding: 0 clamp(16px, 4vw, 32px); - transform: translateX(-50%); pointer-events: none; opacity: 0; transition: opacity 0.28s ease; @@ -368,18 +367,7 @@ } .personaScene[data-selected="1"]::before { - content: ""; - position: absolute; - inset: 0; - z-index: 14; - pointer-events: none; - background: linear-gradient( - 180deg, - rgba(0, 0, 0, 0.55) 0%, - rgba(0, 0, 0, 0.28) 42%, - transparent 58%, - rgba(0, 0, 0, 0.38) 100% - ); + content: none; } .personaDeselectBackdrop { @@ -512,8 +500,22 @@ width: min(92vw, 560px); transform: translateX(-50%); text-align: center; - pointer-events: auto; - animation: personaCenterIn 0.28s cubic-bezier(0.33, 0, 0.2, 1) forwards; + pointer-events: none; +} + +.personaCenterInner { + animation: personaHeroContentIn 300ms ease-in-out both; +} + +@keyframes personaHeroContentIn { + from { + opacity: 0; + transform: scale(0.96); + } + to { + opacity: 1; + transform: scale(1); + } } /* 이름 뒤에서 번지는 빛. 상자를 없앤 자리를 이게 대신 잡아준다 — @@ -627,14 +629,9 @@ margin-top: clamp(0.35rem, 0.9vw, 0.6rem); } -@keyframes personaCenterIn { - from { - opacity: 0; - transform: translate(-50%, -10px); - } - to { - opacity: 1; - transform: translate(-50%, 0); +@media (prefers-reduced-motion: reduce) { + .personaCenterInner { + animation: none; } } diff --git a/src/lib/prologueData.ts b/src/lib/prologueData.ts index aee5720..373bdf2 100644 --- a/src/lib/prologueData.ts +++ b/src/lib/prologueData.ts @@ -34,11 +34,11 @@ export interface ProloguePersona { name: string; /** 한 줄 역할/상황 */ title: string; - /** 짧은 설명 — 선택 시 이름 아래 */ + /** 짧은 설명 — Hero 영역 */ description: string; - /** 도로 위 이름 밑에 붙는 한 마디. 이름만으로는 셋이 구분되지 않아서 둔다 */ + /** 도로 위 이름 밑에 붙는 한 마디 */ tagline: string; - /** 클릭 시 상세 패널에 표시 */ + /** 상세 목록 */ details: string[]; /** 픽셀아트 경로 (public/ 기준). 비어 있으면 placeholder */ imageSrc?: string; @@ -46,16 +46,12 @@ export interface ProloguePersona { position: ProloguePersonaPosition; } -/** - * 페르소나 슬롯 — 확정 전 placeholder. - * imageSrc · position · details 만 교체하면 장면에 반영됩니다. - */ export const prologuePersonas: ProloguePersona[] = [ { id: "persona-slot-1", name: "대학생", title: "첫 독립을 준비 중", - description: "자취방·원룸을 알아보는 단계", + description: "자취방 · 원룸을 알아보는 단계", tagline: "처음 자취 시작", details: [ "보증금·월세 예산을 처음 직접 짜고 있어요.", @@ -68,16 +64,16 @@ export const prologuePersonas: ProloguePersona[] = [ }, { id: "persona-slot-2", - name: "사회초년생", - title: "직장 근처로 이사", - description: "출퇴근과 생활권을 맞추는 중", - tagline: "독립을 준비 중", + name: "다자녀가구", + title: "더 넓은 집을 찾는 중", + description: "방 · 학군 · 예산을 함께 고민", + tagline: "함께 살 공간 찾기", details: [ - "전세·월세 조건을 비교하며 이사 시기를 정해요.", - "등기부등본·계약서 확인을 습관화하고 있어요.", - "혼자 살지만 가족과 상의하며 결정한다.", + "아이들 방과 학군을 최우선으로 봐요.", + "전세·월세와 생활비를 가족 단위로 계산해요.", + "층간소음·주차·안전까지 꼼꼼히 확인한다.", ], - imageSrc: "/personas/worker.png", + imageSrc: "/personas/family.png", accent: "blue", position: { x: 0, y: 0 }, }, @@ -85,11 +81,11 @@ export const prologuePersonas: ProloguePersona[] = [ id: "persona-slot-3", name: "신혼부부", title: "둘이 살 집을 찾는 중", - description: "공간·예산·지역을 함께 고민", + description: "공간 · 예산 · 지역을 함께 고민", tagline: "함께 꾸릴 우리 집", details: [ "보증금·대출·혼수 예산을 함께 계산해요.", - "학군·교통·생활 인프라를 우선순위로 둔다.", + "학군·교통·생활 인프라를 우선으로 둡니다.", "계약 전 서류와 특약을 꼼꼼히 확인한다.", ], imageSrc: "/personas/couple.png", @@ -100,60 +96,132 @@ export const prologuePersonas: ProloguePersona[] = [ export const prologueQuestions: PrologueQuestion[] = [ { - id: "move-in", - topic: "전입신고", - prompt: "새 집으로 이사한 뒤 전입신고는 언제 하는 것이 바람직할까요?", + id: "move-in-vs-fixed-date", + topic: "전입·확정일자", + prompt: + "전세 계약 후 임차인이 챙겨야 할 전입신고와 확정일자, 역할 연결이 옳은 것은?", + options: [ + { + id: "a", + label: + "전입신고 → 집에 실제 거주(대항력), 확정일자 → 보증금 우선변제 순위", + }, + { + id: "b", + label: + "확정일자 → 주민등록·전입, 전입신고 → 보증금 반환보증 가입", + }, + { + id: "c", + label: + "둘 중 하나만 해도 대항력과 우선변제가 동시에 생긴다", + }, + ], + correctOptionId: "a", + explanation: + "전입신고와 확정일자는 역할이 다릅니다. 둘 다 하는 것이 보증금 보호에 유리합니다.", + wrongHint: + "확정일자는 주민등록을 대신하지 않고, 하나만으로 모든 보호가 되지는 않습니다.", + }, + { + id: "registry-risk", + topic: "등기부등본", + prompt: + "전세 계약 전 등기부등본을 볼 때, 보증금 회수가 어려워질 수 있다고 의심해볼 만한 상황은?", options: [ - { id: "a", label: "이사 당일 또는 그 직후" }, - { id: "b", label: "1년 뒤에 여유 있을 때" }, - { id: "c", label: "전입신고는 선택 사항이다" }, + { + id: "a", + label: + "선순위 근저당·가압류 등이 있고, 채권액이 높은 경우", + }, + { + id: "b", + label: + "임대인과 소유자 이름이 등기부상 일치하는 경우", + }, + { + id: "c", + label: + "건물 주용도가 '공동주택'으로 기재된 경우", + }, ], correctOptionId: "a", explanation: - "전입신고는 이사 후 빠르게 하는 것이 좋습니다. 주민등록·각종 행정·혜택과 연결됩니다.", - wrongHint: "전입신고는 의무에 가깝고, 늦출수록 불이익이 생길 수 있어요.", + "선순위 채권이 많으면 경매·공매 때 보증금을 돌려받기 어려울 수 있습니다. '깡통전세' 확인의 기본입니다.", + wrongHint: + "소유자 일치·공동주택 표기는 오히려 흔한 정상적인 경우입니다.", }, { - id: "fixed-date", - topic: "확정일자", - prompt: "전세 계약 후 보증금을 지키기 위해 우선 받아야 할 것은?", + id: "deposit-insurance", + topic: "반환보증", + prompt: + "전세보증금 반환보증(보증보험)에 대한 설명으로 옳은 것은?", options: [ - { id: "a", label: "확정일자가 찍힌 임대차계약서" }, - { id: "b", label: "집주인과의 카톡 약속" }, - { id: "c", label: "부동산 중개사 명함" }, + { + id: "a", + label: + "조건을 충족하면 임대인이 보증금을 돌려주지 않을 때 보증기관이 대신 보장해줄 수 있다", + }, + { + id: "b", + label: + "전세 계약만 하면 국가가 보증금 전액을 자동으로 보장한다", + }, + { + id: "c", + label: + "확정일자를 받으면 별도 가입 없이 같은 효과가 생긴다", + }, ], correctOptionId: "a", explanation: - "확정일자는 전세 보증금 우선변제권 등을 위해 꼭 필요합니다. 주민센터·온라인으로 받을 수 있어요.", - wrongHint: "구두·메신저 약속만으로는 법적 보호가 충분하지 않습니다.", + "반환보증은 별도로 가입·심사를 거치는 제도입니다. 확정일자와는 다른 역할입니다.", + wrongHint: + "확정일자는 우선변제 순위와 관련 있지만, 보증보험을 자동으로 대신하지 않습니다.", }, { - id: "lease-fraud", - topic: "전세사기", - prompt: "전세사기를 예방하기 위한 설명으로 옳은 것은?", + id: "move-in-deadline", + topic: "전입신고", + prompt: + "새 집으로 이사한 뒤 주민등록 전입신고를 해야 하는 시기로 옳은 것은?", options: [ - { id: "a", label: "등기부등본·건축물대장 등 권리관계를 확인한다" }, - { id: "b", label: "보증금이 저렴하면 서류 확인은 생략한다" }, - { id: "c", label: "계약 전 집만 보면 충분하다" }, + { id: "a", label: "이사한 날부터 14일 이내" }, + { id: "b", label: "임대차 계약 기간이 끝난 뒤" }, + { id: "c", label: "전입신고는 선택 사항이라 기한이 없다" }, ], correctOptionId: "a", explanation: - "등기·근저당·가압류 등 권리관계 확인은 전세사기 예방의 기본입니다.", - wrongHint: "저렴한 매물일수록 권리관계·서류 확인을 더 꼼꼼히 해야 합니다.", + "주민등록법상 전입신고는 이사 후 14일 이내가 원칙입니다. 늦출수록 행정·혜택에서 불이익이 생길 수 있어요.", + wrongHint: + "전입신고는 선택이 아니라 의무에 가깝고, 계약 만료를 기다릴 필요가 없습니다.", }, { - id: "deposit-protect", - topic: "보증금 보호", - prompt: "전세 보증금 반환을 위해 일반적으로 함께 고려하는 제도는?", + id: "renewal-period", + topic: "갱신청구권", + prompt: + "주택 전세·월세 계약에서 임차인의 갱신청구권(1회)에 대한 설명으로 옳은 것은?", options: [ - { id: "a", label: "전세보증금 반환보증(보증보험)" }, - { id: "b", label: "자동차 종합보험" }, - { id: "c", label: "건강보험 자격 유지" }, + { + id: "a", + label: + "정당한 사유 없이 거절하기 어렵고, 갱신 시 임대 기간은 4년이다", + }, + { + id: "b", + label: + "임대인·임차인이 다시 합의해야만 갱신되며, 기간은 2년이다", + }, + { + id: "c", + label: + "갱신청구권은 상가 임대에만 적용되고 주택에는 없다", + }, ], correctOptionId: "a", explanation: - "전세보증금 반환보증 등은 임차인의 보증금 회수를 돕는 제도입니다. 조건·한도를 확인하세요.", - wrongHint: "주거 보증금과 무관한 보험·제도는 보증금 보호에 직접적이지 않습니다.", + "주택 임차인은 1회 갱신을 요구할 수 있고, 갱신 기간은 4년입니다. 다만 임대인의 정당한 거절 사유는 있습니다.", + wrongHint: + "2년·합의 필수·상가만 해당은 흔히 헷갈리는 보기입니다. 주택은 4년 갱신이 원칙입니다.", }, ];