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 ( +
+
+ {/* Left: Back */} +
+ {showBack ? ( + + ) : null} +
+ + {/* Center: Logo + Text (정중앙 고정) */} +
+ Real Match +
+
+ + {/* 하단 얇은 divider (스크린샷의 라인 느낌) */} +
+
+ ); +} \ 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 (
{/* 데스크톱: 중앙 정렬 컨테이너 */} -
+
{children}
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칸으로 '진짜 중앙' 고정 */} +
+
+ {/* Left: back */} +
+
+ + {/* Center: logo + text (정중앙) */} +
+ Real Match +
+
+
+ + {/* 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 */} + +
+ + {/* 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 ( +
+
{title}
+
{children}
+
+ ); +} + +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 ( +
+
{title}
+
{children}
+
+ ); +} + +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, + }), +}));