diff --git a/.github/workflows/coderabbit.yml b/.github/workflows/coderabbit.yml
deleted file mode 100644
index f6e24ae..0000000
--- a/.github/workflows/coderabbit.yml
+++ /dev/null
@@ -1,17 +0,0 @@
-name: CodeRabbit Review
-
-on:
- pull_request:
- types: [opened, synchronize, reopened]
-
-jobs:
- coderabbit:
- runs-on: ubuntu-latest
- steps:
- - name: CodeRabbit Review
- uses: coderabbitai/coderabbit-action@v1
- with:
- # GitHub 토큰 (자동 제공)
- github-token: ${{ secrets.GITHUB_TOKEN }}
- # CodeRabbit API 키 (GitHub Secrets에 추가 필요)
- # coderabbit-api-key: ${{ secrets.CODERABBIT_API_KEY }}
diff --git a/.gitignore b/.gitignore
index 0e30a54..03707d9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,3 +26,7 @@ dist-ssr
# Environment variables
.env
.env.local
+
+# TanStack Router auto-generated
+src/routeTree.gen.ts
+.tanstack/
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index fa28a2b..7fea198 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -80,7 +80,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -3157,7 +3156,6 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.145.7.tgz",
"integrity": "sha512-0O+a4TjJSPXd2BsvDPwDPBKRQKYqNIBg5TAg9NzCteqJ0NXRxwohyqCksHqCEEtJe/uItwqmHoqkK4q5MDhEsA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@tanstack/history": "1.145.7",
"@tanstack/react-store": "^0.8.0",
@@ -3229,7 +3227,6 @@
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.145.7.tgz",
"integrity": "sha512-v6jx6JqVUBM0/FcBq1tX22xiPq8Ufc0PDEP582/4deYoq2/RYd+bZstANp3mGSsqdxE/luhoLYuuSQiwi/j1wA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@tanstack/history": "1.145.7",
"@tanstack/store": "^0.8.0",
@@ -3526,7 +3523,6 @@
"integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -3537,7 +3533,6 @@
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"devOptional": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -3611,7 +3606,6 @@
"integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.52.0",
"@typescript-eslint/types": "8.52.0",
@@ -3863,7 +3857,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4232,7 +4225,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -4501,8 +4493,7 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/data-view-buffer": {
"version": "1.0.2",
@@ -4922,7 +4913,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -6292,7 +6282,6 @@
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
@@ -6434,7 +6423,6 @@
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
"dev": true,
"license": "MPL-2.0",
- "peer": true,
"dependencies": {
"detect-libc": "^2.0.3"
},
@@ -7080,7 +7068,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -7161,7 +7148,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -7171,7 +7157,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
- "peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -7547,7 +7532,6 @@
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.4.2.tgz",
"integrity": "sha512-N3HEHRCZYn3cQbsC4B5ldj9j+tHdf4JZoYPlcI4rRYu0Xy4qN8MQf1Z08EibzB0WpgRG5BGK08FTrmM66eSzKQ==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=10"
}
@@ -7738,6 +7722,7 @@
"integrity": "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"csstype": "^3.1.0",
"seroval": "~1.3.0",
@@ -7761,6 +7746,7 @@
"integrity": "sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=10"
},
@@ -8146,7 +8132,6 @@
"integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==",
"dev": true,
"license": "BSD-2-Clause",
- "peer": true,
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.15.0",
@@ -8362,7 +8347,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -8561,7 +8545,6 @@
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
- "peer": true,
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
@@ -8572,7 +8555,6 @@
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -8971,7 +8953,6 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -9029,7 +9010,6 @@
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"rollup": "dist/bin/rollup"
},
@@ -9298,7 +9278,6 @@
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
"dev": true,
"license": "MIT",
- "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/src/assets/MainIcon.svg b/src/assets/MainIcon.svg
new file mode 100644
index 0000000..2e4a4ed
--- /dev/null
+++ b/src/assets/MainIcon.svg
@@ -0,0 +1,9 @@
+
diff --git a/src/assets/RealMatchLogo_ex.svg b/src/assets/RealMatchLogo_ex.svg
new file mode 100644
index 0000000..f175c5e
--- /dev/null
+++ b/src/assets/RealMatchLogo_ex.svg
@@ -0,0 +1,9 @@
+
diff --git a/src/assets/whitelogo.svg b/src/assets/whitelogo.svg
new file mode 100644
index 0000000..4b172c8
--- /dev/null
+++ b/src/assets/whitelogo.svg
@@ -0,0 +1,9 @@
+
diff --git a/src/components/common/RealmatchHeader.tsx b/src/components/common/RealmatchHeader.tsx
new file mode 100644
index 0000000..ec7aff5
--- /dev/null
+++ b/src/components/common/RealmatchHeader.tsx
@@ -0,0 +1,74 @@
+import { useNavigate } from "@tanstack/react-router";
+import RealMatchLogo from "../../assets/RealMatchLogo_ex.svg"
+
+type RealMatchHeaderProps = {
+ /** 뒤로가기 버튼 노출 여부 */
+ showBack?: boolean;
+ /** 뒤로가기 클릭 시 동작 커스텀 (없으면 navigate({to: ".."}) 시도 후 history.back) */
+ onBack?: () => void;
+};
+
+export default function RealMatchHeader({
+ showBack = true,
+ onBack,
+}: RealMatchHeaderProps) {
+ const navigate = useNavigate();
+
+ const handleBack = () => {
+ if (onBack) return onBack();
+
+ // 가능하면 라우터 상위로, 실패하면 브라우저 히스토리
+ try {
+ navigate({ to: "/matchingTest/step3" });
+ } catch {
+ window.history.back();
+ }
+ };
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/layout/MobileContainer.tsx b/src/components/layout/MobileContainer.tsx
index 0403352..074eed1 100644
--- a/src/components/layout/MobileContainer.tsx
+++ b/src/components/layout/MobileContainer.tsx
@@ -8,7 +8,7 @@ export default function MobileContainer({ children }: MobileContainerProps) {
return (
{/* 데스크톱: 중앙 정렬 컨테이너 */}
-
diff --git a/src/globals.css b/src/globals.css
index 0a92281..2eaf1ac 100644
--- a/src/globals.css
+++ b/src/globals.css
@@ -7,6 +7,7 @@
font-style: normal;
font-display: swap;
}
+html { scrollbar-gutter: stable; }
@theme {
/* Core 컬러 */
diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts
deleted file mode 100644
index 6c74b77..0000000
--- a/src/routeTree.gen.ts
+++ /dev/null
@@ -1,185 +0,0 @@
-/* eslint-disable */
-
-// @ts-nocheck
-
-// noinspection JSUnusedGlobalSymbols
-
-// This file was automatically generated by TanStack Router.
-// You should NOT make any changes in this file as it will be overwritten.
-// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
-
-import { Route as rootRouteImport } from './routes/__root'
-import { Route as MainRouteImport } from './routes/_main'
-import { Route as AuthRouteImport } from './routes/_auth'
-import { Route as MainIndexRouteImport } from './routes/_main/index'
-import { Route as MainMypageRouteImport } from './routes/_main/mypage'
-import { Route as MainMatchingRouteImport } from './routes/_main/matching'
-import { Route as MainChatRouteImport } from './routes/_main/chat'
-import { Route as AuthLoginRouteImport } from './routes/_auth/login'
-
-const MainRoute = MainRouteImport.update({
- id: '/_main',
- getParentRoute: () => rootRouteImport,
-} as any)
-const AuthRoute = AuthRouteImport.update({
- id: '/_auth',
- getParentRoute: () => rootRouteImport,
-} as any)
-const MainIndexRoute = MainIndexRouteImport.update({
- id: '/',
- path: '/',
- getParentRoute: () => MainRoute,
-} as any)
-const MainMypageRoute = MainMypageRouteImport.update({
- id: '/mypage',
- path: '/mypage',
- getParentRoute: () => MainRoute,
-} as any)
-const MainMatchingRoute = MainMatchingRouteImport.update({
- id: '/matching',
- path: '/matching',
- getParentRoute: () => MainRoute,
-} as any)
-const MainChatRoute = MainChatRouteImport.update({
- id: '/chat',
- path: '/chat',
- getParentRoute: () => MainRoute,
-} as any)
-const AuthLoginRoute = AuthLoginRouteImport.update({
- id: '/login',
- path: '/login',
- getParentRoute: () => AuthRoute,
-} as any)
-
-export interface FileRoutesByFullPath {
- '/login': typeof AuthLoginRoute
- '/chat': typeof MainChatRoute
- '/matching': typeof MainMatchingRoute
- '/mypage': typeof MainMypageRoute
- '/': typeof MainIndexRoute
-}
-export interface FileRoutesByTo {
- '/login': typeof AuthLoginRoute
- '/chat': typeof MainChatRoute
- '/matching': typeof MainMatchingRoute
- '/mypage': typeof MainMypageRoute
- '/': typeof MainIndexRoute
-}
-export interface FileRoutesById {
- __root__: typeof rootRouteImport
- '/_auth': typeof AuthRouteWithChildren
- '/_main': typeof MainRouteWithChildren
- '/_auth/login': typeof AuthLoginRoute
- '/_main/chat': typeof MainChatRoute
- '/_main/matching': typeof MainMatchingRoute
- '/_main/mypage': typeof MainMypageRoute
- '/_main/': typeof MainIndexRoute
-}
-export interface FileRouteTypes {
- fileRoutesByFullPath: FileRoutesByFullPath
- fullPaths: '/login' | '/chat' | '/matching' | '/mypage' | '/'
- fileRoutesByTo: FileRoutesByTo
- to: '/login' | '/chat' | '/matching' | '/mypage' | '/'
- id:
- | '__root__'
- | '/_auth'
- | '/_main'
- | '/_auth/login'
- | '/_main/chat'
- | '/_main/matching'
- | '/_main/mypage'
- | '/_main/'
- fileRoutesById: FileRoutesById
-}
-export interface RootRouteChildren {
- AuthRoute: typeof AuthRouteWithChildren
- MainRoute: typeof MainRouteWithChildren
-}
-
-declare module '@tanstack/react-router' {
- interface FileRoutesByPath {
- '/_main': {
- id: '/_main'
- path: ''
- fullPath: ''
- preLoaderRoute: typeof MainRouteImport
- parentRoute: typeof rootRouteImport
- }
- '/_auth': {
- id: '/_auth'
- path: ''
- fullPath: ''
- preLoaderRoute: typeof AuthRouteImport
- parentRoute: typeof rootRouteImport
- }
- '/_main/': {
- id: '/_main/'
- path: '/'
- fullPath: '/'
- preLoaderRoute: typeof MainIndexRouteImport
- parentRoute: typeof MainRoute
- }
- '/_main/mypage': {
- id: '/_main/mypage'
- path: '/mypage'
- fullPath: '/mypage'
- preLoaderRoute: typeof MainMypageRouteImport
- parentRoute: typeof MainRoute
- }
- '/_main/matching': {
- id: '/_main/matching'
- path: '/matching'
- fullPath: '/matching'
- preLoaderRoute: typeof MainMatchingRouteImport
- parentRoute: typeof MainRoute
- }
- '/_main/chat': {
- id: '/_main/chat'
- path: '/chat'
- fullPath: '/chat'
- preLoaderRoute: typeof MainChatRouteImport
- parentRoute: typeof MainRoute
- }
- '/_auth/login': {
- id: '/_auth/login'
- path: '/login'
- fullPath: '/login'
- preLoaderRoute: typeof AuthLoginRouteImport
- parentRoute: typeof AuthRoute
- }
- }
-}
-
-interface AuthRouteChildren {
- AuthLoginRoute: typeof AuthLoginRoute
-}
-
-const AuthRouteChildren: AuthRouteChildren = {
- AuthLoginRoute: AuthLoginRoute,
-}
-
-const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
-
-interface MainRouteChildren {
- MainChatRoute: typeof MainChatRoute
- MainMatchingRoute: typeof MainMatchingRoute
- MainMypageRoute: typeof MainMypageRoute
- MainIndexRoute: typeof MainIndexRoute
-}
-
-const MainRouteChildren: MainRouteChildren = {
- MainChatRoute: MainChatRoute,
- MainMatchingRoute: MainMatchingRoute,
- MainMypageRoute: MainMypageRoute,
- MainIndexRoute: MainIndexRoute,
-}
-
-const MainRouteWithChildren = MainRoute._addFileChildren(MainRouteChildren)
-
-const rootRouteChildren: RootRouteChildren = {
- AuthRoute: AuthRouteWithChildren,
- MainRoute: MainRouteWithChildren,
-}
-export const routeTree = rootRouteImport
- ._addFileChildren(rootRouteChildren)
- ._addFileTypes
()
diff --git a/src/routes/_main/_home/StartRMButton.tsx b/src/routes/_main/_home/StartRMButton.tsx
new file mode 100644
index 0000000..0449f04
--- /dev/null
+++ b/src/routes/_main/_home/StartRMButton.tsx
@@ -0,0 +1,22 @@
+import { Link } from "@tanstack/react-router";
+import WhiteLogo from "../../../assets/whitelogo.svg"
+
+export default function StartMatchingTestButton() {
+ return (
+
+
+
+ 매칭률 검사하기
+
+ );
+}
diff --git a/src/routes/_main/_home/index.tsx b/src/routes/_main/_home/index.tsx
new file mode 100644
index 0000000..aa62d01
--- /dev/null
+++ b/src/routes/_main/_home/index.tsx
@@ -0,0 +1,10 @@
+import { createFileRoute } from "@tanstack/react-router";
+import HomeContent from "./indexContent";
+
+export const Route = createFileRoute("/_main/_home/")({
+ component: HomePage,
+});
+
+function HomePage() {
+ return ;
+}
diff --git a/src/routes/_main/_home/indexContent.tsx b/src/routes/_main/_home/indexContent.tsx
new file mode 100644
index 0000000..88bec12
--- /dev/null
+++ b/src/routes/_main/_home/indexContent.tsx
@@ -0,0 +1,56 @@
+import StartMatchingTestButton from "./StartRMButton";
+import RMLOGO from "../../../assets/RealMatchLogo_ex.svg";
+import MainIcon from "../../../assets/MainIcon.svg";
+
+
+export default function HomeContent() {
+ return (
+
+ {/* ✅ Header: 좌/중/우 3칸으로 '진짜 중앙' 고정 */}
+
+
+ {/* Body */}
+
+
+
+ {/* 일러스트 */}
+

+
+ {/* 문구 */}
+
+
+ 매칭된 기업이 없어요
+
+
+ 매칭 검사를 먼저 진행해주세요
+
+
+
+ {/* 버튼 (기존 유지) */}
+
+
+
+
+
+ );
+}
diff --git a/src/routes/_main/index.tsx b/src/routes/_main/index.tsx
deleted file mode 100644
index 3318640..0000000
--- a/src/routes/_main/index.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import { createFileRoute } from "@tanstack/react-router";
-import Header from "../../components/layout/Header";
-
-export const Route = createFileRoute("/_main/")({
- component: HomePage,
-});
-
-function HomePage() {
- return (
-
-
-
-
-
환영합니다!
-
Real Match에서 새로운 인연을 만나보세요
-
-
-
-
-
오늘의 추천 매칭
-
- 아직 추천 매칭이 없습니다
-
-
-
-
-
-
-
- );
-}
diff --git a/src/routes/_matchingTest/components/BottomSheet.tsx b/src/routes/_matchingTest/components/BottomSheet.tsx
new file mode 100644
index 0000000..df118d5
--- /dev/null
+++ b/src/routes/_matchingTest/components/BottomSheet.tsx
@@ -0,0 +1,40 @@
+import type { ReactNode } from "react";
+
+interface BottomSheetProps {
+ title: string;
+ children: ReactNode;
+ onClose: () => void;
+}
+
+export default function BottomSheet({ title, children, onClose }: BottomSheetProps) {
+ return (
+
+ {/* dim */}
+
+
+ {/* sheet */}
+
+ {/* header: 좌 타이틀 / 우 닫기 */}
+
+
+ {/* content */}
+
{children}
+
+
+ );
+}
diff --git a/src/routes/_matchingTest/components/CheckDropdown.tsx b/src/routes/_matchingTest/components/CheckDropdown.tsx
new file mode 100644
index 0000000..fa3d9d2
--- /dev/null
+++ b/src/routes/_matchingTest/components/CheckDropdown.tsx
@@ -0,0 +1,61 @@
+interface CheckDropdownProps {
+ options: T;
+ values: string[]; // ✅ 다중 선택
+ onToggle: (v: T[number]) => void; // ✅ 토글
+ onDone: () => void;
+}
+
+export default function CheckDropdown({
+ options,
+ values,
+ onToggle,
+ onDone,
+}: CheckDropdownProps) {
+ return (
+
+ {/* 옵션 */}
+
+ {options.map((opt) => {
+ const checked = values.includes(opt);
+
+ return (
+
+ );
+ })}
+
+
+ {/* 입력 완료 */}
+
+
+
+
+ );
+}
diff --git a/src/routes/_matchingTest/components/FormField.tsx b/src/routes/_matchingTest/components/FormField.tsx
new file mode 100644
index 0000000..eada84d
--- /dev/null
+++ b/src/routes/_matchingTest/components/FormField.tsx
@@ -0,0 +1,60 @@
+interface FormFieldProps {
+ label: string;
+ value: string;
+ placeholder: string;
+ onClick: () => void;
+}
+
+export default function FormField({
+ label,
+ value,
+ placeholder,
+ onClick,
+}: FormFieldProps) {
+ const hasValue = value.trim().length > 0;
+
+
+
+
+
+
+ return (
+
+ );
+}
diff --git a/src/routes/_matchingTest/components/InputSheet.tsx b/src/routes/_matchingTest/components/InputSheet.tsx
new file mode 100644
index 0000000..9964b87
--- /dev/null
+++ b/src/routes/_matchingTest/components/InputSheet.tsx
@@ -0,0 +1,61 @@
+interface InputSheetProps {
+ value: string;
+ placeholder: string;
+ onChange: (v: string) => void;
+ doneDisabled: boolean;
+ onDone: () => void;
+ suffix?: string;
+
+ helperText?: string;
+ errorText?: string;
+}
+
+export default function InputSheet({
+ value,
+ placeholder,
+ onChange,
+ doneDisabled,
+ onDone,
+ suffix,
+ helperText,
+ errorText,
+}: InputSheetProps) {
+ const showError = Boolean(errorText) && value.trim().length > 0;
+
+ return (
+
+
+
onChange(e.target.value)}
+ placeholder={placeholder}
+ className={[
+ "w-full rounded-xl border px-3 py-3 text-sm outline-none",
+ showError ? "border-red-400 focus:border-red-500" : "border-text-gray4 focus:border-core-3",
+ ].join(" ")}
+ />
+
+ {showError ? (
+
{errorText}
+ ) : helperText ? (
+
{helperText}
+ ) : null}
+
+ {suffix ?
{suffix}
: null}
+
+
+
+
+ );
+}
diff --git a/src/routes/_matchingTest/components/MatchingTestHeader.tsx b/src/routes/_matchingTest/components/MatchingTestHeader.tsx
new file mode 100644
index 0000000..63fdc0f
--- /dev/null
+++ b/src/routes/_matchingTest/components/MatchingTestHeader.tsx
@@ -0,0 +1,53 @@
+interface MatchingTestTopBarProps {
+ /** 1부터 시작 (1,2,3...) */
+ step: number;
+ totalSteps: number;
+ onBack: () => void;
+
+ /** 레이아웃 미세조정 필요하면 사용 */
+ className?: string;
+}
+
+/*
+ * 진행바 + 뒤로가기 + "n / total" 텍스트를 한 번에 통일하는 컴포넌트
+ * 1/3, 2/3, 3/3에 따라 진행바 길이가 변함
+ */
+export default function MatchingTestHeader({
+ step,
+ totalSteps,
+ onBack,
+ className = "",
+}: MatchingTestTopBarProps) {
+ const safeTotal = Math.max(1, totalSteps);
+ const safeStep = Math.min(Math.max(1, step), safeTotal);
+ const percent = (safeStep / safeTotal) * 100;
+
+ return (
+
+ {/* progress */}
+
+
+ {/* header (step1 스타일로 통일) */}
+
+
+
+
+
+ {safeStep} / {safeTotal}
+
+
+
+
+ );
+}
diff --git a/src/routes/_matchingTest/components/SelectChip.tsx b/src/routes/_matchingTest/components/SelectChip.tsx
new file mode 100644
index 0000000..abb4bd4
--- /dev/null
+++ b/src/routes/_matchingTest/components/SelectChip.tsx
@@ -0,0 +1,35 @@
+interface SelectChipProps {
+ label: string;
+ isSelected: boolean;
+ onToggle: () => void;
+ disabled?: boolean;
+}
+
+export default function SelectChip({
+ label,
+ isSelected,
+ onToggle,
+ disabled = false,
+}: SelectChipProps) {
+ return (
+
+ );
+}
diff --git a/src/routes/_matchingTest/components/SelectContents.tsx b/src/routes/_matchingTest/components/SelectContents.tsx
new file mode 100644
index 0000000..9492a01
--- /dev/null
+++ b/src/routes/_matchingTest/components/SelectContents.tsx
@@ -0,0 +1,63 @@
+import { useState } from "react";
+
+interface ContentCategoryDropdownProps {
+ onDone?: () => void; // ✅ 추가: 입력 완료 눌렀을 때 부모에게 알림
+}
+
+export default function ContentCategoryDropdown({
+ onDone,
+}: ContentCategoryDropdownProps) {
+ const OPTIONS = ["패션", "뷰티"] as const;
+ const [selected, setSelected] = useState<(typeof OPTIONS)[number]>("패션");
+
+ return (
+
+
+ {/* ✅ “콘텐츠 분야” 첫 줄 스타일(아까와 동일) */}
+
+ 콘텐츠 분야
+
+
+
+
+
+
+ {OPTIONS.map((opt) => {
+ const checked = selected === opt;
+
+ return (
+
+ );
+ })}
+
+
+ {/* ✅ 입력 완료 누르면 드롭다운 닫기(onDone 호출) */}
+
+
+ );
+}
diff --git a/src/routes/_matchingTest/components/SelectSheet.tsx b/src/routes/_matchingTest/components/SelectSheet.tsx
new file mode 100644
index 0000000..69d648b
--- /dev/null
+++ b/src/routes/_matchingTest/components/SelectSheet.tsx
@@ -0,0 +1,33 @@
+interface SelectSheetProps {
+ options: T;
+ value: string;
+ onSelect: (v: T[number]) => void;
+}
+
+export default function SelectSheet({
+ options,
+ value,
+ onSelect,
+}: SelectSheetProps) {
+ return (
+
+ {options.map((opt) => {
+ const selected = opt === value;
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/src/routes/_matchingTest/components/Selectfield.tsx b/src/routes/_matchingTest/components/Selectfield.tsx
new file mode 100644
index 0000000..a3db472
--- /dev/null
+++ b/src/routes/_matchingTest/components/Selectfield.tsx
@@ -0,0 +1,51 @@
+interface SelectFieldProps {
+ label: string;
+ valueText?: string;
+ onOpen: () => void; // ✅ "선택하기" 클릭 시 실행
+}
+
+export default function SelectField({
+ label,
+ valueText,
+ onOpen,
+}: SelectFieldProps) {
+ const isSelected = Boolean(valueText);
+ const displayText = valueText ?? "선택하기";
+
+ return (
+
+
+ {label}
+
+
+
+
+ );
+}
diff --git a/src/routes/_matchingTest/matchingResult.tsx b/src/routes/_matchingTest/matchingResult.tsx
new file mode 100644
index 0000000..00d4bcb
--- /dev/null
+++ b/src/routes/_matchingTest/matchingResult.tsx
@@ -0,0 +1,132 @@
+import { createFileRoute, useNavigate } from "@tanstack/react-router";
+import MatchResultHeader from "../../components/common/RealmatchHeader";
+import MainIcon from "../../assets/MainIcon.svg";
+import WhiteLogo from "../../assets/whitelogo.svg"
+
+
+type Step4Search = {
+ userName?: string; // "OO" 자리
+ fitTraits?: string;
+ styleTraits?: string;
+ moodTraits?: string;
+ recommendedBrand?: string; // "00한 브랜드와" 자리
+};
+
+export const Route = createFileRoute("/_matchingTest/matchingResult")({
+ component: Step4,
+ validateSearch: (search: Record): Step4Search => ({
+ userName: typeof search.userName === "string" ? search.userName : undefined,
+ fitTraits: typeof search.fitTraits === "string" ? search.fitTraits : undefined,
+ styleTraits:
+ typeof search.styleTraits === "string" ? search.styleTraits : undefined,
+ moodTraits:
+ typeof search.moodTraits === "string" ? search.moodTraits : undefined,
+ recommendedBrand:
+ typeof search.recommendedBrand === "string"
+ ? search.recommendedBrand
+ : undefined,
+ }),
+});
+
+function Step4() {
+ const navigate = useNavigate();
+ const search = Route.useSearch();
+
+ // ✅ 백엔드/step3에서 전달될 값(없으면 더미)
+ const resultData = {
+ userName: search.userName ?? "OO",
+ beautyTraits: search.fitTraits ?? "00 핏 특성들",
+ styleTraits: search.styleTraits ?? "00 패션 특성들",
+ contentTraits: search.moodTraits ?? "00 콘텐츠 특성들",
+ recommendedBrand: search.recommendedBrand ?? "00한 브랜드와",
+ };
+
+ return (
+
+ {/* ✅ 상단 헤더(스크린샷 스타일) */}
+
+
+ {/* ✅ 중앙 컨텐츠 (폭 제한 제거) */}
+
+
+ 매칭 결과
+
+ {resultData.userName}
+한 크리에이터
+
+
+ {resultData.userName}님의 특성
+
+
+
+
+ {resultData.beautyTraits}
+
+
+ {resultData.styleTraits}
+
+
+ {resultData.contentTraits}
+
+
+
+
+
+ {resultData.userName}님과 어울리는 브랜드
+
+
+
+ {resultData.recommendedBrand}
+
+
+
+ 잘 어울릴 것으로 보여요
+
+
+
+ {/* 일러스트 */}
+
+

+
+
+ {/* sticky footer와 겹치지 않게 여유 */}
+
+
+
+ {/* ✅ 하단 버튼: fixed 대신 sticky로 (컨테이너 폭에 맞춰 “max”로 보임) */}
+
+
+
+
+ );
+}
diff --git a/src/routes/_matchingTest/matchingTest.step1.tsx b/src/routes/_matchingTest/matchingTest.step1.tsx
new file mode 100644
index 0000000..5ebc745
--- /dev/null
+++ b/src/routes/_matchingTest/matchingTest.step1.tsx
@@ -0,0 +1,55 @@
+import { createFileRoute, useNavigate } from "@tanstack/react-router";
+import { useMemo } from "react";
+import MatchingTestContent from "./matchingTest.step1Content";
+import { useMatchingTestStore, type SectionKey } from "./matchingTest.store";
+
+const SECTIONS: Array<{
+ key: SectionKey;
+ title: string;
+ items: readonly string[];
+}> = [
+ { key: "style", title: "관심 스타일", items: ["스킨케어", "메이크업", "향수", "바디", "헤어"] },
+ {
+ key: "function",
+ title: "관심 기능",
+ items: ["트러블", "수분 / 보습", "진정", "미백", "안티에이징", "각질/모공"],
+ },
+ { key: "skinType", title: "피부 타입", items: ["건성", "지성", "복합성", "민감성"] },
+ { key: "skinTone", title: "피부 밝기", items: ["17호 이하", "17-21호", "21-23호", "23호 이상"] },
+ { key: "makeupStyle", title: "메이크업 스타일", items: ["내추럴", "화려한", "글로우", "매트"] },
+] as const;
+
+export const Route = createFileRoute("/_matchingTest/matchingTest/step1")({
+ component: MatchingTestStep1Page,
+});
+
+function MatchingTestStep1Page() {
+ const navigate = useNavigate();
+ const MAX_PER_SECTION = 5;
+
+ const selected = useMatchingTestStore((s) => s.selected);
+ const toggleStore = useMatchingTestStore((s) => s.toggleStep1);
+
+ const isSelected = (section: SectionKey, label: string) =>
+ selected[section].includes(label);
+
+ const canGoNext = useMemo(
+ () => SECTIONS.every((s) => selected[s.key].length >= 1),
+ [selected]
+ );
+
+ return (
+ toggleStore(section, label, MAX_PER_SECTION)}
+ canGoNext={canGoNext}
+ onBack={() => navigate({ to: "/" })}
+ onNext={() => navigate({ to: "/matchingTest/step2" })}
+ />
+ );
+}
diff --git a/src/routes/_matchingTest/matchingTest.step1Content.tsx b/src/routes/_matchingTest/matchingTest.step1Content.tsx
new file mode 100644
index 0000000..dcadb36
--- /dev/null
+++ b/src/routes/_matchingTest/matchingTest.step1Content.tsx
@@ -0,0 +1,109 @@
+import SelectChip from "./components/SelectChip";
+import MatchingTestTopBar from "./components/matchingTestHeader";
+
+type SectionKey = "style" | "function" | "skinType" | "skinTone" | "makeupStyle";
+
+interface MatchingSection {
+ key: SectionKey;
+ title: string;
+ items: readonly string[];
+}
+
+type SelectedState = Record;
+
+interface MatchingTestContentProps {
+ // 기존 props 유지(부모에서 내려주고 있으면 안 깨지게)
+ progressText: string; // 이제 TopBar가 step/total로 직접 보여주므로 사실상 불필요하지만 유지
+ maxText: string;
+
+ sections: readonly MatchingSection[];
+ selected: SelectedState;
+
+ maxPerSection: number;
+
+ isSelected: (section: SectionKey, label: string) => boolean;
+ onToggle: (section: SectionKey, label: string) => void;
+
+ canGoNext: boolean;
+ onBack: () => void;
+ onNext: () => void;
+}
+
+export default function MatchingTestContent({
+ // progressText는 더 이상 쓰지 않지만(부모 변경 전까지) props는 유지 가능
+ maxText,
+ sections,
+ selected,
+ maxPerSection,
+ isSelected,
+ onToggle,
+ canGoNext,
+ onBack,
+ onNext,
+}: MatchingTestContentProps) {
+ return (
+
+ {/* ✅ 공용 상단 (1/3) */}
+
+
+ {/* ✅ 본문 */}
+
+ {/* 타이틀 */}
+
+ 관심 있는 뷰티 특성을
+
+ 모두 선택해주세요
+
+ {maxText}
+
+ {/* ✅ 섹션들 */}
+ {sections.map((section) => {
+ const sectionSelectedCount = selected[section.key].length;
+ const sectionLimitReached = sectionSelectedCount >= maxPerSection;
+
+ return (
+
+
+ {section.title}
+
+
+
+ {section.items.map((label) => {
+ const checked = isSelected(section.key, label);
+ const disabled = !checked && sectionLimitReached;
+
+ return (
+ onToggle(section.key, label)}
+ />
+ );
+ })}
+
+
+ );
+ })}
+
+
+ {/* ✅ 하단 고정 */}
+
+
+
+
+ );
+}
diff --git a/src/routes/_matchingTest/matchingTest.step2.tsx b/src/routes/_matchingTest/matchingTest.step2.tsx
new file mode 100644
index 0000000..312a8b1
--- /dev/null
+++ b/src/routes/_matchingTest/matchingTest.step2.tsx
@@ -0,0 +1,62 @@
+import { createFileRoute, useNavigate } from "@tanstack/react-router";
+import { useMemo } from "react";
+import MatchingTestStep2Content from "./matchingTest.step2Content";
+import { useMatchingTestStore, type Step2SectionKey } from "./matchingTest.store";
+
+export const Route = createFileRoute("/_matchingTest/matchingTest/step2")({
+ component: MatchingTestStep2Page,
+});
+
+function MatchingTestStep2Page() {
+ const navigate = useNavigate();
+ const MAX_PER_SECTION = 5;
+
+ const selected = useMatchingTestStore((s) => s.step2Selected);
+ const toggle = useMatchingTestStore((s) => s.toggleStep2);
+
+ const heightCm = useMatchingTestStore((s) => s.heightCm);
+ const bodyShape = useMatchingTestStore((s) => s.bodyShape);
+ const topSize = useMatchingTestStore((s) => s.topSize);
+ const bottomSizeIn = useMatchingTestStore((s) => s.bottomSizeIn);
+
+ const setHeightCm = useMatchingTestStore((s) => s.setHeightCm);
+ const setBodyShape = useMatchingTestStore((s) => s.setBodyShape);
+ const setTopSize = useMatchingTestStore((s) => s.setTopSize);
+ const setBottomSizeIn = useMatchingTestStore((s) => s.setBottomSizeIn);
+
+ const isSelected = (section: Step2SectionKey, label: string) =>
+ selected[section].includes(label);
+
+ const canGoNext = useMemo(() => {
+ const chipsOk =
+ selected.fashionStyle.length >= 1 &&
+ selected.interestItem.length >= 1 &&
+ selected.brandType.length >= 1;
+
+ const bodyOk = heightCm.trim().length > 0 && bodyShape.trim().length > 0;
+ const sizeOk = topSize.trim().length > 0 && bottomSizeIn.trim().length > 0;
+
+ return chipsOk && bodyOk && sizeOk;
+ }, [selected, heightCm, bodyShape, topSize, bottomSizeIn]);
+
+ return (
+ toggle(section, label, MAX_PER_SECTION)}
+ heightCm={heightCm}
+ bodyShape={bodyShape}
+ topSize={topSize}
+ bottomSizeIn={bottomSizeIn}
+ onHeightChange={setHeightCm}
+ onBodyShapeChange={setBodyShape}
+ onTopSizeChange={setTopSize}
+ onBottomSizeChange={setBottomSizeIn}
+ canGoNext={canGoNext}
+ onBack={() => navigate({ to: "/matchingTest/step1" })}
+ onNext={() => navigate({ to: "/matchingTest/step3" })}
+ />
+ );
+}
diff --git a/src/routes/_matchingTest/matchingTest.step2Content.tsx b/src/routes/_matchingTest/matchingTest.step2Content.tsx
new file mode 100644
index 0000000..cdcc9cc
--- /dev/null
+++ b/src/routes/_matchingTest/matchingTest.step2Content.tsx
@@ -0,0 +1,276 @@
+import { useMemo, useState } from "react";
+import type { Step2SectionKey, Step2SelectedState } from "./matchingTest.store";
+
+import SelectChip from "./components/SelectChip";
+import FormField from "./components/FormField";
+import BottomSheet from "./components/BottomSheet";
+import InputSheet from "./components/InputSheet";
+import SelectSheet from "./components/SelectSheet";
+import MatchingTestTopBar from "./components/matchingTestHeader";
+
+type Props = {
+ maxText: string;
+ maxPerSection: number;
+
+ selected: Step2SelectedState;
+ isSelected: (section: Step2SectionKey, label: string) => boolean;
+ onToggle: (section: Step2SectionKey, label: string) => void;
+
+ heightCm: string;
+ bodyShape: string;
+ topSize: string;
+ bottomSizeIn: string;
+
+ onHeightChange: (v: string) => void;
+ onBodyShapeChange: (v: string) => void;
+ onTopSizeChange: (v: string) => void;
+ onBottomSizeChange: (v: string) => void;
+
+ canGoNext: boolean;
+ onBack: () => void;
+ onNext: () => void;
+};
+
+const CONTAINER = "mx-auto w-full max-w-[420px]";
+type SheetType = null | "height" | "bodyShape" | "topSize" | "bottomSize";
+
+const STYLE = ["미니멀", "페미닌", "러블리", "비즈니스 캐주얼", "캐주얼", "스트리트"] as const;
+const ITEM = ["의류", "가방", "신발", "주얼리", "패션 소품"] as const;
+const BRAND = ["SPA", "빈티지", "중가 브랜드", "디자이너 브랜드", "명품 브랜드"] as const;
+
+const BODY_SHAPE_OPTIONS = ["마름", "표준", "통통", "근육형", "웨이브"] as const;
+const TOP_SIZE_OPTIONS = ["33", "44", "55", "66", "77"] as const;
+
+export default function MatchingTestStep2Content({
+ // progressText는 이제 TopBar가 step/total로 그리므로 사실상 불필요하지만,
+ // 부모 props 변경이 귀찮으면 일단 유지해도 됨.
+ maxText,
+ maxPerSection,
+ selected,
+ isSelected,
+ onToggle,
+ heightCm,
+ bodyShape,
+ topSize,
+ bottomSizeIn,
+ onHeightChange,
+ onBodyShapeChange,
+ onTopSizeChange,
+ onBottomSizeChange,
+ canGoNext,
+ onBack,
+ onNext,
+}: Props) {
+ const [sheet, setSheet] = useState(null);
+ const open = (t: SheetType) => setSheet(t);
+ const close = () => setSheet(null);
+
+ const chipDisabled = useMemo(() => {
+ return {
+ fashionStyle: selected.fashionStyle.length >= maxPerSection,
+ interestItem: selected.interestItem.length >= maxPerSection,
+ brandType: selected.brandType.length >= maxPerSection,
+ };
+ }, [selected, maxPerSection]);
+
+ return (
+
+
+ {/* ✅ 공용 상단 (2/3) */}
+
+
+
+
+ 관심 있는 패션 특성을
+ 모두 선택해주세요!
+
+
{maxText}
+
+ {/* Chips */}
+
+
+ {STYLE.map((label) => {
+ const sel = isSelected("fashionStyle", label);
+ const disabled = !sel && chipDisabled.fashionStyle;
+ return (
+ onToggle("fashionStyle", label)}
+ />
+ );
+ })}
+
+
+
+
+
+ {ITEM.map((label) => {
+ const sel = isSelected("interestItem", label);
+ const disabled = !sel && chipDisabled.interestItem;
+ return (
+ onToggle("interestItem", label)}
+ />
+ );
+ })}
+
+
+
+
+
+ {BRAND.map((label) => {
+ const sel = isSelected("brandType", label);
+ const disabled = !sel && chipDisabled.brandType;
+ return (
+ onToggle("brandType", label)}
+ />
+ );
+ })}
+
+
+
+ {/* Body Info */}
+
+
체형 정보
+
+
+
키를 입력해주세요
+
+ open("height")}
+ />
+
+
+
+
+
체형을 선택해주세요
+
+ open("bodyShape")}
+ />
+
+
+
+
+
평소 입는 옷 사이즈를 선택해주세요
+
+ open("topSize")}
+ />
+ open("bottomSize")}
+ />
+
+
+
+
+
+ {/* CTA */}
+
+
+
+
+
+ {/* Sheets */}
+ {sheet === "height" ? (
+
+ onHeightChange(v.replace(/[^\d]/g, ""))}
+ doneDisabled={heightCm.trim().length === 0}
+ onDone={close}
+ suffix="cm"
+ />
+
+ ) : null}
+
+ {sheet === "bottomSize" ? (
+
+ onBottomSizeChange(v.replace(/[^\d]/g, ""))}
+ doneDisabled={bottomSizeIn.trim().length === 0}
+ onDone={close}
+ suffix="in"
+ />
+
+ ) : null}
+
+ {sheet === "bodyShape" ? (
+
+ {
+ onBodyShapeChange(v);
+ close();
+ }}
+ />
+
+ ) : null}
+
+ {sheet === "topSize" ? (
+
+ {
+ onTopSizeChange(v);
+ close();
+ }}
+ />
+
+ ) : null}
+
+ );
+}
+
+/* ============== local layout helpers ============== */
+function Section({ title, children }: { title: string; children: React.ReactNode }) {
+ return (
+
+ );
+}
+
+function ChipRow({ children }: { children: React.ReactNode }) {
+ return {children}
;
+}
diff --git a/src/routes/_matchingTest/matchingTest.step3.tsx b/src/routes/_matchingTest/matchingTest.step3.tsx
new file mode 100644
index 0000000..f623f07
--- /dev/null
+++ b/src/routes/_matchingTest/matchingTest.step3.tsx
@@ -0,0 +1,74 @@
+import { createFileRoute, useNavigate } from "@tanstack/react-router";
+import { useMemo } from "react";
+import MatchingTestStep3Content from "./matchingTest.step3Content";
+import { useMatchingTestStore, type Step3ChipKey, type Step3SelectKey } from "./matchingTest.store.ts";
+
+export const Route = createFileRoute("/_matchingTest/matchingTest/step3")({
+ component: MatchingTestStep3Page,
+});
+
+function MatchingTestStep3Page() {
+ const navigate = useNavigate();
+ const MAX_CHIP = 5; // 필요하면 섹션별 다르게 해도 됨
+ const MAX_MULTI = 5;
+
+ const snsUrl = useMatchingTestStore((s) => s.snsUrl);
+ const setSnsUrl = useMatchingTestStore((s) => s.setSnsUrl);
+
+ const isValidInstagramUrl = useMatchingTestStore((s) => s.isValidInstagramUrl());
+
+
+ const step3Selected = useMatchingTestStore((s) => s.step3Selected);
+ const toggleSelect = useMatchingTestStore((s) => s.toggleStep3Select);
+
+ const step3Chips = useMatchingTestStore((s) => s.step3Chips);
+ const toggleChip = useMatchingTestStore((s) => s.toggleStep3Chip);
+
+ const onToggleSelect = (key: Step3SelectKey, label: string) => {
+ // ✅ 성별/나이대는 다중선택, 영상길이/조회수는 단일 선택
+ const max =
+ key === "videoLength" || key === "views" ? 1 : MAX_MULTI;
+ toggleSelect(key, label, max);
+ };
+
+ const setSingleSelect = useMatchingTestStore((s) => s.setSingleStep3Select);
+
+
+ const onToggleChip = (key: Step3ChipKey, label: string) => toggleChip(key, label, MAX_CHIP);
+
+ const canGoNext = useMemo(() => {
+ const snsOk = snsUrl.trim().length > 0;
+ const genderOk = step3Selected.gender.length > 0;
+ const ageOk = step3Selected.ageGroup.length > 0;
+ const lenOk = step3Selected.videoLength.length > 0;
+ const viewsOk = step3Selected.views.length > 0;
+
+ // 칩은 최소 1개씩 원함
+ const chipsOk =
+ step3Chips.contentFormat.length > 0 &&
+ step3Chips.contentType.length > 0 &&
+ step3Chips.contentTone.length > 0 &&
+ step3Chips.contentHardness.length > 0 &&
+ step3Chips.editingRange.length > 0;
+
+ return snsOk && genderOk && ageOk && lenOk && viewsOk && chipsOk;
+ }, [snsUrl, step3Selected, step3Chips]);
+
+ return (
+ navigate({ to: "/matchingTest/step2" })}
+onNext={() => navigate({ to: "/matchingResult" })}
+/>
+
+ );
+}
diff --git a/src/routes/_matchingTest/matchingTest.step3Content.tsx b/src/routes/_matchingTest/matchingTest.step3Content.tsx
new file mode 100644
index 0000000..89855c9
--- /dev/null
+++ b/src/routes/_matchingTest/matchingTest.step3Content.tsx
@@ -0,0 +1,300 @@
+import { useMemo, useState } from "react";
+import type { Step3ChipKey, Step3ChipsState, Step3SelectKey, Step3SelectedState } from "./matchingTest.store.ts";
+
+import MatchingTestTopBar from "./components/matchingTestHeader.tsx";
+import SelectChip from "./components/SelectChip";
+import FormField from "./components/FormField";
+import BottomSheet from "./components/BottomSheet";
+import InputSheet from "./components/InputSheet";
+import SelectSheet from "./components/SelectSheet";
+import CheckDropdown from "./components/CheckDropdown";
+
+type Props = {
+ snsUrl: string;
+ onSnsUrlChange: (v: string) => void;
+ isValidInstagramUrl: boolean;
+
+ step3Selected: Step3SelectedState;
+ onToggleSelect: (key: Step3SelectKey, label: string) => void;
+ setSingleSelect: (key: Step3SelectKey, label: string) => void;
+
+ step3Chips: Step3ChipsState;
+ onToggleChip: (key: Step3ChipKey, label: string) => void;
+
+ canGoNext: boolean;
+ onBack: () => void;
+ onNext: () => void;
+};
+
+type Sheet = null | "snsUrl" | "gender" | "ageGroup" | "videoLength" | "views";
+
+const GENDER = ["여성", "남성"] as const;
+const AGE = ["10~20대", "20~30대", "30~40대", "40~50대", "50대~"] as const;
+const VIDEO_LEN = ["~15초", "15~30초", "30~45초", "45~60초"] as const;
+const VIEWS = ["~1만회", "1~10만회", "10~50만회", "50~100만회", "100만회~"] as const;
+
+const CONTENT_FORMAT = ["인스타 스토리", "인스타 포스트", "인스타 릴스"] as const;
+const CONTENT_TYPE = ["바이럴성", "리뷰", "게리디언스", "비포&애프터", "스토리/썰", "챌린지"] as const;
+const CONTENT_TONE = ["전문적인", "감성적인", "유쾌/재밌는", "트렌디한", "일상적인", "수다떠는"] as const;
+const CONTENT_HARDNESS = ["관여 안함", "가이드라인만 제공", "대본 일부 제공", "모든 연출 관여"] as const;
+const EDITING_RANGE = ["크리에이터 1차 활용", "브랜드 2차 활용"] as const;
+
+export default function MatchingTestStep3Content({
+ snsUrl,
+ onSnsUrlChange,
+ isValidInstagramUrl,
+ step3Selected,
+ onToggleSelect,
+ setSingleSelect,
+ step3Chips,
+ onToggleChip,
+ canGoNext,
+ onBack,
+ onNext,
+}: Props) {
+ const [sheet, setSheet] = useState(null);
+ const open = (s: Sheet) => setSheet(s);
+ const close = () => setSheet(null);
+
+ const max = 5;
+ const chipDisabled = useMemo(
+ () => ({
+ contentFormat: step3Chips.contentFormat.length >= max,
+ contentType: step3Chips.contentType.length >= max,
+ contentTone: step3Chips.contentTone.length >= max,
+ contentHardness: step3Chips.contentHardness.length >= max,
+ editingRange: step3Chips.editingRange.length >= max,
+ }),
+ [step3Chips]
+ );
+const genderValue = step3Selected.gender.join("\n");
+const ageValue = step3Selected.ageGroup.join("\n");
+ const lenValue = step3Selected.videoLength[0] ?? "";
+ const viewsValue = step3Selected.views[0] ?? "";
+
+ return (
+
+ {/* ✅ step1과 동일한 상단 컴포넌트만 사용 */}
+
+
+ {/* ✅ step1 기준: px-6 */}
+
+
+ 콘텐츠 특성을 모두 선택해주세요
+
+
+
+
SNS 정보
+
SNS 주소를 입력해주세요
+
+
+ open("snsUrl")}
+ />
+
+
+
주 시청자 정보를 선택해주세요
+
+ open("gender")} />
+ open("ageGroup")} />
+
+
+
평균 영상 길이 및 조회수를 선택해주세요
+
+ open("videoLength")} />
+ open("views")} />
+
+
+
+
+
+ {CONTENT_FORMAT.map((x) => {
+ const sel = step3Chips.contentFormat.includes(x);
+ const disabled = !sel && chipDisabled.contentFormat;
+ return (
+ onToggleChip("contentFormat", x)}
+ />
+ );
+ })}
+
+
+
+
+
+ {CONTENT_TYPE.map((x) => {
+ const sel = step3Chips.contentType.includes(x);
+ const disabled = !sel && chipDisabled.contentType;
+ return (
+ onToggleChip("contentType", x)}
+ />
+ );
+ })}
+
+
+
+
+
+ {CONTENT_TONE.map((x) => {
+ const sel = step3Chips.contentTone.includes(x);
+ const disabled = !sel && chipDisabled.contentTone;
+ return (
+ onToggleChip("contentTone", x)}
+ />
+ );
+ })}
+
+
+
+
+
+ {CONTENT_HARDNESS.map((x) => {
+ const sel = step3Chips.contentHardness.includes(x);
+ const disabled = !sel && chipDisabled.contentHardness;
+ return (
+ onToggleChip("contentHardness", x)}
+ />
+ );
+ })}
+
+
+
+
+
+ {EDITING_RANGE.map((x) => {
+ const sel = step3Chips.editingRange.includes(x);
+ const disabled = !sel && chipDisabled.editingRange;
+ return (
+ onToggleChip("editingRange", x)}
+ />
+ );
+ })}
+
+
+
+
+ {/* ✅ step1 기준: px-6 */}
+
+
+
+
+ {sheet === "snsUrl" ? (
+
+
+
+ ) : null}
+
+
+
+{sheet === "gender" ? (
+
+ onToggleSelect("gender", v)}
+ onDone={close}
+ />
+
+) : null}
+
+
+{sheet === "ageGroup" ? (
+
+ onToggleSelect("ageGroup", v)}
+ onDone={close}
+ />
+
+) : null}
+
+
+ {sheet === "videoLength" ? (
+
+ {
+ if (lenValue && lenValue !== v) onToggleSelect("videoLength", lenValue);
+ if (lenValue !== v) onToggleSelect("videoLength", v);
+ close();
+ }}
+ />
+
+ ) : null}
+
+ {sheet === "views" ? (
+
+ {
+ if (viewsValue && viewsValue !== v) onToggleSelect("views", viewsValue);
+ if (viewsValue !== v) onToggleSelect("views", v);
+ close();
+ }}
+ />
+
+ ) : null}
+
+ );
+}
+
+function Section({ title, children }: { title: string; children: React.ReactNode }) {
+ return (
+
+ );
+}
+
+function ChipRow({ children }: { children: React.ReactNode }) {
+ return {children}
;
+}
diff --git a/src/routes/_matchingTest/matchingTest.store.ts b/src/routes/_matchingTest/matchingTest.store.ts
new file mode 100644
index 0000000..ad6f0ea
--- /dev/null
+++ b/src/routes/_matchingTest/matchingTest.store.ts
@@ -0,0 +1,195 @@
+import { create } from "zustand";
+
+/* ======================================================
+ * STEP 1
+ * ====================================================== */
+export type SectionKey = "style" | "function" | "skinType" | "skinTone" | "makeupStyle";
+export type SelectedState = Record;
+
+const EMPTY_STEP1: SelectedState = {
+ style: [],
+ function: [],
+ skinType: [],
+ skinTone: [],
+ makeupStyle: [],
+};
+
+/* ======================================================
+ * STEP 2
+ * ====================================================== */
+export type Step2SectionKey = "fashionStyle" | "interestItem" | "brandType";
+export type Step2SelectedState = Record;
+
+const EMPTY_STEP2: Step2SelectedState = {
+ fashionStyle: [],
+ interestItem: [],
+ brandType: [],
+};
+
+/* ======================================================
+ * STEP 3
+ * ====================================================== */
+export type Step3SelectKey = "gender" | "ageGroup" | "videoLength" | "views";
+export type Step3SelectedState = Record;
+
+const EMPTY_STEP3_SELECTED: Step3SelectedState = {
+ gender: [],
+ ageGroup: [],
+ videoLength: [],
+ views: [],
+};
+
+export type Step3ChipKey =
+ | "contentFormat"
+ | "contentType"
+ | "contentTone"
+ | "contentHardness"
+ | "editingRange";
+
+export type Step3ChipsState = Record;
+
+const EMPTY_STEP3_CHIPS: Step3ChipsState = {
+ contentFormat: [],
+ contentType: [],
+ contentTone: [],
+ contentHardness: [],
+ editingRange: [],
+};
+
+/* ======================================================
+ * STORE
+ * ====================================================== */
+type MatchingTestStore = {
+ // step1
+ selected: SelectedState;
+ toggleStep1: (section: SectionKey, label: string, maxPerSection: number) => void;
+
+ // step2
+ step2Selected: Step2SelectedState;
+ toggleStep2: (section: Step2SectionKey, label: string, maxPerSection: number) => void;
+
+ heightCm: string;
+ bodyShape: string;
+ topSize: string;
+ bottomSizeIn: string;
+
+ setHeightCm: (v: string) => void;
+ setBodyShape: (v: string) => void;
+ setTopSize: (v: string) => void;
+ setBottomSizeIn: (v: string) => void;
+
+ // step3
+ snsUrl: string;
+ setSnsUrl: (v: string) => void;
+ isValidInstagramUrl: () => boolean;
+
+ step3Selected: Step3SelectedState;
+ toggleStep3Select: (key: Step3SelectKey, label: string, max: number) => void;
+
+ // ✅ 추가: 단일 선택(성별/나이대 같은 라디오용)
+ setSingleStep3Select: (key: Step3SelectKey, label: string) => void;
+
+ step3Chips: Step3ChipsState;
+ toggleStep3Chip: (key: Step3ChipKey, label: string, max: number) => void;
+
+ resetAll: () => void;
+};
+
+export const useMatchingTestStore = create((set, get) => ({
+ /* ---------- step1 ---------- */
+ selected: EMPTY_STEP1,
+ toggleStep1: (section, label, maxPerSection) => {
+ const prev = get().selected;
+ const cur = prev[section];
+ const already = cur.includes(label);
+
+ if (already) {
+ set({ selected: { ...prev, [section]: cur.filter((x) => x !== label) } });
+ return;
+ }
+ if (cur.length >= maxPerSection) return;
+
+ set({ selected: { ...prev, [section]: [...cur, label] } });
+ },
+
+ /* ---------- step2 ---------- */
+ step2Selected: EMPTY_STEP2,
+ toggleStep2: (section, label, maxPerSection) => {
+ const prev = get().step2Selected;
+ const cur = prev[section];
+ const already = cur.includes(label);
+
+ if (already) {
+ set({ step2Selected: { ...prev, [section]: cur.filter((x) => x !== label) } });
+ return;
+ }
+ if (cur.length >= maxPerSection) return;
+
+ set({ step2Selected: { ...prev, [section]: [...cur, label] } });
+ },
+
+ heightCm: "",
+ bodyShape: "",
+ topSize: "",
+ bottomSizeIn: "",
+
+ setHeightCm: (v) => set({ heightCm: v }),
+ setBodyShape: (v) => set({ bodyShape: v }),
+ setTopSize: (v) => set({ topSize: v }),
+ setBottomSizeIn: (v) => set({ bottomSizeIn: v }),
+
+ /* ---------- step3 ---------- */
+ snsUrl: "",
+ setSnsUrl: (v) => set({ snsUrl: v }),
+ isValidInstagramUrl: () => get().snsUrl.startsWith("www.instagram/"),
+
+ step3Selected: EMPTY_STEP3_SELECTED,
+
+ toggleStep3Select: (key, label, max) => {
+ const prevAll = get().step3Selected;
+ const cur = prevAll[key];
+ const already = cur.includes(label);
+
+ if (already) {
+ set({ step3Selected: { ...prevAll, [key]: cur.filter((x) => x !== label) } });
+ return;
+ }
+ if (cur.length >= max) return;
+
+ set({ step3Selected: { ...prevAll, [key]: [...cur, label] } });
+ },
+
+ // ✅ 단일 선택(라디오): 무조건 하나만 유지
+ setSingleStep3Select: (key, label) => {
+ const prevAll = get().step3Selected;
+ set({ step3Selected: { ...prevAll, [key]: [label] } });
+ },
+
+ step3Chips: EMPTY_STEP3_CHIPS,
+ toggleStep3Chip: (key, label, max) => {
+ const prevAll = get().step3Chips;
+ const cur = prevAll[key];
+ const already = cur.includes(label);
+
+ if (already) {
+ set({ step3Chips: { ...prevAll, [key]: cur.filter((x) => x !== label) } });
+ return;
+ }
+ if (cur.length >= max) return;
+
+ set({ step3Chips: { ...prevAll, [key]: [...cur, label] } });
+ },
+
+ resetAll: () =>
+ set({
+ selected: EMPTY_STEP1,
+ step2Selected: EMPTY_STEP2,
+ heightCm: "",
+ bodyShape: "",
+ topSize: "",
+ bottomSizeIn: "",
+ snsUrl: "",
+ step3Selected: EMPTY_STEP3_SELECTED,
+ step3Chips: EMPTY_STEP3_CHIPS,
+ }),
+}));