From bc9c759d7a8992a92df40db27863f453896240db Mon Sep 17 00:00:00 2001 From: yoonyoungyang Date: Mon, 2 Feb 2026 17:48:27 +0900 Subject: [PATCH 1/5] =?UTF-8?q?=EB=A7=A4=EC=B9=AD=20=EA=B2=80=EC=82=AC=20A?= =?UTF-8?q?PI=20=EC=97=B0=EA=B2=B0=20=EC=A4=91=20&=20=EB=A7=A4=EC=B9=AD=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=20UI=20=EC=88=98=EC=A0=95=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../matching/test/_shared/api/matches.api.ts | 16 + .../_shared/builders/build-match-payload.ts | 70 +++++ .../matching/test/_shared/tags/tags.api.ts | 33 +++ .../matching/test/_shared/tags/tags.query.ts | 30 ++ .../matching/test/_shared/tags/tags.types.ts | 30 ++ .../test/_shared/types/matches.types.ts | 44 +++ .../test/result/matching-result-content.tsx | 200 +++++++------ app/routes/matching/test/step1/route.tsx | 77 +++-- .../matching/test/step1/step1-content.tsx | 88 +++--- app/routes/matching/test/step2/route.tsx | 54 +++- .../matching/test/step2/step2-content.tsx | 277 +++++++++--------- app/routes/matching/test/step3/route.tsx | 93 +++++- .../matching/test/step3/step3-content.tsx | 269 +++++++++++------ app/stores/matching-test.ts | 110 +++---- 14 files changed, 929 insertions(+), 462 deletions(-) create mode 100644 app/routes/matching/test/_shared/api/matches.api.ts create mode 100644 app/routes/matching/test/_shared/builders/build-match-payload.ts create mode 100644 app/routes/matching/test/_shared/tags/tags.api.ts create mode 100644 app/routes/matching/test/_shared/tags/tags.query.ts create mode 100644 app/routes/matching/test/_shared/tags/tags.types.ts create mode 100644 app/routes/matching/test/_shared/types/matches.types.ts diff --git a/app/routes/matching/test/_shared/api/matches.api.ts b/app/routes/matching/test/_shared/api/matches.api.ts new file mode 100644 index 0000000..2dacb0c --- /dev/null +++ b/app/routes/matching/test/_shared/api/matches.api.ts @@ -0,0 +1,16 @@ +import { axiosInstance } from "../../../../../api/axios"; +import type { + ApiResponse, + MatchesRequest, + MatchesResponseResult, +} from "../types/matches.types"; + +export async function postMatches( + payload: MatchesRequest, +): Promise> { + const res = await axiosInstance.post>( + "/api/v1/matches", + payload, + ); + return res.data; +} diff --git a/app/routes/matching/test/_shared/builders/build-match-payload.ts b/app/routes/matching/test/_shared/builders/build-match-payload.ts new file mode 100644 index 0000000..c9f7561 --- /dev/null +++ b/app/routes/matching/test/_shared/builders/build-match-payload.ts @@ -0,0 +1,70 @@ +import { useMatchingTestStore } from "../../../../../stores/matching-test"; +import type { MatchesRequest } from "../types/matches.types"; + +function mustOne(arr: number[], label: string): number { + if (arr.length !== 1) throw new Error(`${label}은(는) 1개만 선택해야 해요.`); + return arr[0]; +} +function mustMany(arr: number[], label: string): number[] { + if (arr.length < 1) throw new Error(`${label}을(를) 1개 이상 선택해야 해요.`); + return arr; +} +function mustTag(v: number | null, label: string): number { + if (v == null) throw new Error(`${label}을(를) 선택해야 해요.`); + return v; +} + +export function buildMatchPayload(): MatchesRequest { + const s = useMatchingTestStore.getState(); + + return { + beauty: { + interestStyleTags: mustMany(s.selected.style, "뷰티 관심 스타일"), + prefferedFunctionTags: mustMany(s.selected.function, "뷰티 관심 기능"), + skinTypeTags: mustOne(s.selected.skinType, "피부 타입"), + skinToneTags: mustOne(s.selected.skinTone, "피부 톤"), + makeupStyleTags: mustOne(s.selected.makeupStyle, "메이크업 스타일"), + }, + fashion: { + interestStyleTags: mustMany( + s.step2Selected.fashionStyle, + "패션 관심 스타일", + ), + preferredItemTags: mustMany(s.step2Selected.interestItem, "관심 아이템"), + preferredBrandTags: mustMany( + s.step2Selected.brandType, + "관심 브랜드 종류", + ), + heightTags: mustTag(s.fashionBody.heightTagId, "키"), + weightTypeTags: mustTag(s.fashionBody.weightTypeTagId, "체형"), + topSizeTags: mustTag(s.fashionBody.topSizeTagId, "상의 사이즈"), + bottomSizeTags: mustTag(s.fashionBody.bottomSizeTagId, "하의 사이즈"), + }, + content: { + sns: { + url: s.snsUrl, + mainAudience: { + genderTags: mustMany(s.step3Selected.gender, "주 시청자 성별"), + ageTags: mustMany(s.step3Selected.ageGroup, "주 시청자 나이대"), + }, + averageAudience: { + videoLengthTags: mustMany( + s.step3Selected.videoLength, + "평균 영상 길이", + ), + videoViewsTags: mustMany(s.step3Selected.views, "평균 조회수"), + }, + }, + typeTags: mustMany(s.step3Chips.contentType, "콘텐츠 종류"), + toneTags: mustMany(s.step3Chips.contentTone, "콘텐츠 톤"), + prefferedInvolvementTags: mustMany( + s.step3Chips.contentHardness, + "콘텐츠 관여도", + ), + prefferedCoverageTags: mustMany( + s.step3Chips.editingRange, + "콘텐츠 활용 범위", + ), + }, + }; +} diff --git a/app/routes/matching/test/_shared/tags/tags.api.ts b/app/routes/matching/test/_shared/tags/tags.api.ts new file mode 100644 index 0000000..cf642f8 --- /dev/null +++ b/app/routes/matching/test/_shared/tags/tags.api.ts @@ -0,0 +1,33 @@ +import { axiosInstance } from "../../../../../api/axios"; +import type { + ApiResponse, + BeautyFashionTagsResult, + ContentTagsResult, +} from "./tags.types"; + +export async function fetchBeautyTags(): Promise { + const res = await axiosInstance.get>( + "/api/v1/tags/beauty", + ); + if (!res.data.isSuccess) + throw new Error(res.data.message || "beauty 태그 조회 실패"); + return res.data.result; +} + +export async function fetchFashionTags(): Promise { + const res = await axiosInstance.get>( + "/api/v1/tags/fashion", + ); + if (!res.data.isSuccess) + throw new Error(res.data.message || "fashion 태그 조회 실패"); + return res.data.result; +} + +export async function fetchContentTags(): Promise { + const res = await axiosInstance.get>( + "/api/v1/tags/content", + ); + if (!res.data.isSuccess) + throw new Error(res.data.message || "content 태그 조회 실패"); + return res.data.result; +} diff --git a/app/routes/matching/test/_shared/tags/tags.query.ts b/app/routes/matching/test/_shared/tags/tags.query.ts new file mode 100644 index 0000000..d1a669d --- /dev/null +++ b/app/routes/matching/test/_shared/tags/tags.query.ts @@ -0,0 +1,30 @@ +import { useQuery } from "@tanstack/react-query"; +import { + fetchBeautyTags, + fetchFashionTags, + fetchContentTags, +} from "./tags.api"; + +export function useBeautyTags() { + return useQuery({ + queryKey: ["tags", "beauty"], + queryFn: fetchBeautyTags, + staleTime: 1000 * 60 * 10, + }); +} + +export function useFashionTags() { + return useQuery({ + queryKey: ["tags", "fashion"], + queryFn: fetchFashionTags, + staleTime: 1000 * 60 * 10, + }); +} + +export function useContentTags() { + return useQuery({ + queryKey: ["tags", "content"], + queryFn: fetchContentTags, + staleTime: 1000 * 60 * 10, + }); +} diff --git a/app/routes/matching/test/_shared/tags/tags.types.ts b/app/routes/matching/test/_shared/tags/tags.types.ts new file mode 100644 index 0000000..5f8bdc1 --- /dev/null +++ b/app/routes/matching/test/_shared/tags/tags.types.ts @@ -0,0 +1,30 @@ +export type ApiResponse = { + isSuccess: boolean; + code: string; + message: string; + result: T; +}; + +export type TagId = number | string; + +export type TagItem = { + id: TagId; + name: string; +}; + +// /api/v1/tags/beauty, /api/v1/tags/fashion +export type CategoryTagMap = Record; + +export type BeautyFashionTagsResult = { + tagType: string; + categories: CategoryTagMap; +}; + +// /api/v1/tags/content +export type ContentTagsResult = { + formats: TagItem[]; + categories: TagItem[]; + tones: TagItem[]; + involvements: TagItem[]; + usageRanges: TagItem[]; +}; diff --git a/app/routes/matching/test/_shared/types/matches.types.ts b/app/routes/matching/test/_shared/types/matches.types.ts new file mode 100644 index 0000000..98ceaa8 --- /dev/null +++ b/app/routes/matching/test/_shared/types/matches.types.ts @@ -0,0 +1,44 @@ +export type ApiResponse = { + isSuccess: boolean; + code: string; + message: string; + result: T; +}; + +export type MatchesRequest = { + beauty: { + interestStyleTags: number[]; + prefferedFunctionTags: number[]; + skinTypeTags: number; + skinToneTags: number; + makeupStyleTags: number; + }; + fashion: { + interestStyleTags: number[]; + preferredItemTags: number[]; + preferredBrandTags: number[]; + heightTags: number; + weightTypeTags: number; + topSizeTags: number; + bottomSizeTags: number; + }; + content: { + sns: { + url: string; + mainAudience: { + genderTags: number[]; + ageTags: number[]; + }; + averageAudience: { + videoLengthTags: number[]; + videoViewsTags: number[]; + }; + }; + typeTags: number[]; + toneTags: number[]; + prefferedInvolvementTags: number[]; + prefferedCoverageTags: number[]; + }; +}; + +export type MatchesResponseResult = unknown; diff --git a/app/routes/matching/test/result/matching-result-content.tsx b/app/routes/matching/test/result/matching-result-content.tsx index e4672cf..3e17025 100644 --- a/app/routes/matching/test/result/matching-result-content.tsx +++ b/app/routes/matching/test/result/matching-result-content.tsx @@ -1,24 +1,37 @@ import { useMemo } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; -import { useMatchResultStore } from "../../../../stores/matching-result"; // ✅ 실제 상대경로로 조정 -import MatchResultHeader from "../../../../components/common/RealmatchHeader"; +import { useMatchResultStore } from "../../../../stores/matching-result"; import MainIcon from "../../../../assets/MainIcon.svg"; import Button from "../../../../components/common/Button"; +type Brand = { + brandId: number; + brandName: string; + matchingRatio: number; + logoUrl?: string; +}; + export default function MatchingResultContent() { const navigate = useNavigate(); const [searchParams] = useSearchParams(); const setResult = useMatchResultStore((s) => s.setResult); - const resultData = useMemo(() => { - const userName = searchParams.get("userName") ?? "OO"; - const beauty = searchParams.get("fitTraits") ?? "00 핏 특성들"; - const style = searchParams.get("styleTraits") ?? "00 패션 특성들"; - const content = searchParams.get("moodTraits") ?? "00 콘텐츠 특성들"; - const recommendedBrand = - searchParams.get("recommendedBrand") ?? "00한 브랜드와"; + const data = useMemo(() => { + const userName = searchParams.get("userName") ?? "비비"; + const userType = searchParams.get("userType") ?? "섬세한 설계자"; + + const tags = searchParams + .get("typeTag") + ?.split(",") + .map((v) => v.trim()) ?? ["기획중심", "구조탄탄", "디테일중심"]; + + const brands: Brand[] = [ + { brandId: 1, brandName: "beplain", matchingRatio: 98 }, + { brandId: 2, brandName: "isntree", matchingRatio: 96 }, + { brandId: 3, brandName: "ma:nyo", matchingRatio: 91 }, + ]; - return { userName, beauty, style, content, recommendedBrand }; + return { userName, userType, tags: tags.slice(0, 3), brands }; }, [searchParams]); const onStart = () => { @@ -26,13 +39,13 @@ export default function MatchingResultContent() { completed: true, updatedAt: Date.now(), summary: { - userName: resultData.userName, + userName: data.userName, traits: { - beauty: resultData.beauty, - style: resultData.style, - content: resultData.content, + beauty: data.tags[0], + style: data.tags[1], + content: data.tags[2], }, - recommendedBrand: resultData.recommendedBrand, + recommendedBrand: data.brands[0].brandName, }, }); @@ -40,79 +53,100 @@ export default function MatchingResultContent() { }; return ( -
- - -
-
-

매칭 결과

- -

- {resultData.userName}한 크리에이터{" "} -

- -

- {resultData.userName}님의 특성 -

- -
-

- {resultData.beauty} -

-

- {resultData.style} -

-

- {resultData.content} -

-
- -
-

- {resultData.userName}님과 어울리는 브랜드 -

- -

- {resultData.recommendedBrand} +

+
+
+
+ +

+ + {data.userName} + {" "} + 님의 매칭 결과

-

- 잘 어울릴 것으로 보여요 +

+ {data.userType} +

+ +
+ {data.tags.map((tag) => ( + + #{tag} + + ))} +
+ +
+ 매칭 결과 아이콘 +
+ +

+ 나와 어울리는 TOP3 브랜드

-
-
- 메인 아이콘 +
+ {data.brands.map((b) => ( +
+
+ {b.logoUrl ? ( + {b.brandName} + ) : ( + + {b.brandName} + + )} +
+ +
+ + {b.brandName} + + + {b.matchingRatio}% + +
+
+ ))} +
+ + {/* 버튼 밀어내기 */} + +
+ +
- -
-
- -
- {/* ✅ to="/" 대신 onClick */} -
); diff --git a/app/routes/matching/test/step1/route.tsx b/app/routes/matching/test/step1/route.tsx index a684ee4..b2d445a 100644 --- a/app/routes/matching/test/step1/route.tsx +++ b/app/routes/matching/test/step1/route.tsx @@ -1,48 +1,73 @@ import { useNavigate } from "react-router"; import { useMemo } from "react"; import MatchingTestContent from "./step1-content"; -import { useMatchingTestStore, type SectionKey } from "../../../../stores/matching-test"; - -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; + +import { + useMatchingTestStore, + type SectionKey, + type TagId, +} from "../../../../stores/matching-test"; +import { useBeautyTags } from "../_shared/tags/tags.query"; +import type { TagItem } from "../_shared/tags/tags.types"; + +// ⚠️ 중요: 아래 key는 swagger에 additionalProp로 나와서 실제 키를 백엔드에게 확인해야 함. +// 실제 예: "interests", "functions", "skinTypes", "skinTones", "makeupStyles" 등 +const BEAUTY_CATEGORY_KEY: Record = { + style: "style", + function: "function", + skinType: "skinType", + skinTone: "skinTone", + makeupStyle: "makeupStyle", +}; + +const SECTIONS: Array<{ key: SectionKey; title: string; max: number }> = [ + { key: "style", title: "관심 스타일", max: 5 }, + { key: "function", title: "관심 기능", max: 5 }, + { key: "skinType", title: "피부 타입", max: 1 }, + { key: "skinTone", title: "피부 밝기", max: 1 }, + { key: "makeupStyle", title: "메이크업 스타일", max: 1 }, +]; export default function MatchingTestStep1Page() { const navigate = useNavigate(); - const MAX_PER_SECTION = 5; + const { data, isLoading, error } = useBeautyTags(); const selected = useMatchingTestStore((s) => s.selected); - const toggleStore = useMatchingTestStore((s) => s.toggleStep1); + const toggleStep1 = useMatchingTestStore((s) => s.toggleStep1); + const setSingleStep1 = useMatchingTestStore((s) => s.setSingleStep1); + + const sectionItems = useMemo(() => { + const categories = data?.categories ?? {}; + const out: Record = { + style: categories[BEAUTY_CATEGORY_KEY.style] ?? [], + function: categories[BEAUTY_CATEGORY_KEY.function] ?? [], + skinType: categories[BEAUTY_CATEGORY_KEY.skinType] ?? [], + skinTone: categories[BEAUTY_CATEGORY_KEY.skinTone] ?? [], + makeupStyle: categories[BEAUTY_CATEGORY_KEY.makeupStyle] ?? [], + }; + return out; + }, [data]); - const isSelected = (section: SectionKey, label: string) => - selected[section].includes(label); + const isSelected = (section: SectionKey, id: TagId) => + selected[section].includes(id); const canGoNext = useMemo( () => SECTIONS.every((s) => selected[s.key].length >= 1), - [selected] + [selected], ); return ( toggleStore(section, label, MAX_PER_SECTION)} + onToggle={(section, id, max) => { + if (max === 1) setSingleStep1(section, id); + else toggleStep1(section, id, max); + }} canGoNext={canGoNext} onBack={() => navigate("/")} onNext={() => navigate("/matching/test/step2")} diff --git a/app/routes/matching/test/step1/step1-content.tsx b/app/routes/matching/test/step1/step1-content.tsx index 8f9c8c5..2f4118e 100644 --- a/app/routes/matching/test/step1/step1-content.tsx +++ b/app/routes/matching/test/step1/step1-content.tsx @@ -1,66 +1,75 @@ -import SelectChip from "../components/SelectChip"; import MatchingTestTopBar from "../components/MatchingTestHeader"; +import SelectChip from "../components/SelectChip"; import Button from "../../../../components/common/Button"; +import type { + SectionKey, + SelectedState, + TagId, +} from "../../../../stores/matching-test"; +import type { TagItem } from "../_shared/tags/tags.types"; -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; +type Props = { + isLoading: boolean; + errorText: string | null; - sections: readonly MatchingSection[]; + sections: Array<{ key: SectionKey; title: string; max: number }>; + itemsBySection: Record; selected: SelectedState; - maxPerSection: number; - - isSelected: (section: SectionKey, label: string) => boolean; - onToggle: (section: SectionKey, label: string) => void; + isSelected: (section: SectionKey, id: TagId) => boolean; + onToggle: (section: SectionKey, id: TagId, max: number) => void; canGoNext: boolean; onBack: () => void; onNext: () => void; -} +}; export default function MatchingTestContent({ - // progressText는 더 이상 쓰지 않지만(부모 변경 전까지) props는 유지 가능 - maxText, + isLoading, + errorText, sections, + itemsBySection, selected, - maxPerSection, isSelected, onToggle, canGoNext, onBack, onNext, -}: MatchingTestContentProps) { +}: Props) { + if (isLoading) { + return ( +
+ +
+ 태그를 불러오는 중... +
+
+ ); + } + + if (errorText) { + return ( +
+ +
{errorText}
+
+ ); + } + return (
- {/* 공용 상단 */} - {/* 본문 */}
- {/* 타이틀 */}

- 관심 있는 뷰티 특성을 -
+ 관심 있는 뷰티 특성
모두 선택해주세요

-

{maxText}

- {/* 섹션들 */} {sections.map((section) => { + const items = itemsBySection[section.key] ?? []; const sectionSelectedCount = selected[section.key].length; - const sectionLimitReached = sectionSelectedCount >= maxPerSection; + const sectionLimitReached = sectionSelectedCount >= section.max; return (
@@ -69,17 +78,19 @@ export default function MatchingTestContent({
- {section.items.map((label) => { - const checked = isSelected(section.key, label); + {items.map((tag) => { + const checked = isSelected(section.key, tag.id); const disabled = !checked && sectionLimitReached; return ( onToggle(section.key, label)} + onToggle={() => + onToggle(section.key, tag.id, section.max) + } /> ); })} @@ -89,7 +100,6 @@ export default function MatchingTestContent({ })}
- {/* 하단 고정 */}
-
+
+
{/* Sheets */} @@ -258,8 +264,13 @@ export default function MatchingTestStep2Content({ ); } -/* ============== local layout helpers ============== */ -function Section({ title, children }: { title: string; children: React.ReactNode }) { +function Section({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { return (
{title}
diff --git a/app/routes/matching/test/step3/route.tsx b/app/routes/matching/test/step3/route.tsx index e471cc3..ca3fe14 100644 --- a/app/routes/matching/test/step3/route.tsx +++ b/app/routes/matching/test/step3/route.tsx @@ -1,17 +1,35 @@ +// app/routes/home/matching/test/step3/route.tsx + +import { useMemo, useState } from "react"; import { useNavigate } from "react-router"; -import { useMemo } from "react"; import MatchingTestStep3Content from "./step3-content"; -import { useMatchingTestStore, type Step3ChipKey, type Step3SelectKey } from "../../../../stores/matching-test"; + +import { + useMatchingTestStore, + type Step3ChipKey, + type Step3SelectKey, +} from "../../../../stores/matching-test"; + +import { postMatches } from "../_shared/api/matches.api"; +import { buildMatchPayload } from "../_shared/builders/build-match-payload"; + +import { useContentTags } from "../_shared/tags/tags.query"; export default function MatchingTestStep3Page() { const navigate = useNavigate(); - const MAX_CHIP = 5; - const MAX_MULTI = 5; + + const { + data: contentTagsRes, + isLoading: tagsLoading, + isError: tagsIsError, + error: tagsErrorObj, + } = useContentTags(); const snsUrl = useMatchingTestStore((s) => s.snsUrl); const setSnsUrl = useMatchingTestStore((s) => s.setSnsUrl); - - const isValidInstagramUrl = useMatchingTestStore((s) => s.isValidInstagramUrl()); + const isValidInstagramUrl = useMatchingTestStore((s) => + s.isValidInstagramUrl(), + ); const step3Selected = useMatchingTestStore((s) => s.step3Selected); const toggleSelect = useMatchingTestStore((s) => s.toggleStep3Select); @@ -19,13 +37,10 @@ export default function MatchingTestStep3Page() { 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 onToggleChip = (key: Step3ChipKey, label: string) => toggleChip(key, label, MAX_CHIP); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + // "다음" 가능 조건 const canGoNext = useMemo(() => { const snsOk = snsUrl.trim().length > 0; const genderOk = step3Selected.gender.length > 0; @@ -34,7 +49,6 @@ export default function MatchingTestStep3Page() { const viewsOk = step3Selected.views.length > 0; const chipsOk = - step3Chips.contentFormat.length > 0 && step3Chips.contentType.length > 0 && step3Chips.contentTone.length > 0 && step3Chips.contentHardness.length > 0 && @@ -43,8 +57,56 @@ export default function MatchingTestStep3Page() { return snsOk && genderOk && ageOk && lenOk && viewsOk && chipsOk; }, [snsUrl, step3Selected, step3Chips]); + // id 기반 toggle (max=5) + const onToggleSelect = (key: Step3SelectKey, id: number) => { + toggleSelect(key, id, 5); + }; + + const onToggleChip = (key: Step3ChipKey, id: number) => { + toggleChip(key, id, 5); + }; + + // 태그 결과만 step3-content에 내려줌 + const contentTags = contentTagsRes ?? null; + + const tagsError = tagsIsError + ? tagsErrorObj instanceof Error + ? tagsErrorObj.message + : "콘텐츠 태그를 불러오지 못했어요." + : null; + + // submit handler + const handleSubmit = async () => { + if (submitting) return; + setSubmitting(true); + setSubmitError(null); + + try { + const payload = buildMatchPayload(); + const res = await postMatches(payload); + + if (!res.isSuccess) { + setSubmitError(res.message); + return; + } + + navigate("/matching/test/result"); + } catch (e) { + setSubmitError( + e instanceof Error ? e.message : "제출 중 오류가 발생했어요.", + ); + } finally { + setSubmitting(false); + } + }; + return ( navigate("/matching/test/step2")} - onNext={() => navigate("/matching/test/result")} + onNext={handleSubmit} /> ); } diff --git a/app/routes/matching/test/step3/step3-content.tsx b/app/routes/matching/test/step3/step3-content.tsx index eac4e04..4211047 100644 --- a/app/routes/matching/test/step3/step3-content.tsx +++ b/app/routes/matching/test/step3/step3-content.tsx @@ -1,5 +1,10 @@ import { useMemo, useState } from "react"; -import type { Step3ChipKey, Step3ChipsState, Step3SelectKey, Step3SelectedState } from "../../../../../stores/matching-test"; +import type { + Step3ChipKey, + Step3ChipsState, + Step3SelectKey, + Step3SelectedState, +} from "../../../../stores/matching-test"; import MatchingTestTopBar from "../components/MatchingTestHeader"; import SelectChip from "../components/SelectChip"; @@ -10,36 +15,57 @@ import SelectSheet from "../components/SelectSheet"; import CheckDropdown from "../components/CheckDropdown"; import Button from "../../../../components/common/Button"; +/** + * ✅ 너 tags.types.ts 구조에 맞춰 contentTags 타입을 맞춰줘야 함 + * 여기서는 "step3에서 필요한 최소 형태"만 정의 + */ +type TagItem = { id: number; name: string }; + +type ContentTags = { + // 콘텐츠 성격(칩) + categories: TagItem[]; // -> typeTags + tones: TagItem[]; // -> toneTags + involvements: TagItem[]; // -> prefferedInvolvementTags + usageRanges: TagItem[]; // -> prefferedCoverageTags + + // 시청자/평균지표(드롭다운) + genderTags: TagItem[]; + ageTags: TagItem[]; + videoLengthTags: TagItem[]; + videoViewsTags: TagItem[]; +}; + type Props = { + // ✅ route에서 내려줌 + tagsLoading: boolean; + tagsError: string | null; + contentTags: ContentTags | null; + snsUrl: string; onSnsUrlChange: (v: string) => void; isValidInstagramUrl: boolean; step3Selected: Step3SelectedState; - onToggleSelect: (key: Step3SelectKey, label: string) => void; + onToggleSelect: (key: Step3SelectKey, id: number) => void; step3Chips: Step3ChipsState; - onToggleChip: (key: Step3ChipKey, label: string) => void; + onToggleChip: (key: Step3ChipKey, id: number) => void; canGoNext: boolean; + submitting?: boolean; + submitError?: string | null; + 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({ + tagsLoading, + tagsError, + contentTags, + snsUrl, onSnsUrlChange, isValidInstagramUrl, @@ -48,6 +74,8 @@ export default function MatchingTestStep3Content({ step3Chips, onToggleChip, canGoNext, + submitting = false, + submitError = null, onBack, onNext, }: Props) { @@ -55,36 +83,69 @@ export default function MatchingTestStep3Content({ const open = (s: Sheet) => setSheet(s); const close = () => setSheet(null); + // 태그가 아직 없으면 빈 배열 + const genderOptions = contentTags?.genderTags ?? []; + const ageOptions = contentTags?.ageTags ?? []; + const videoLenOptions = contentTags?.videoLengthTags ?? []; + const viewsOptions = contentTags?.videoViewsTags ?? []; + + const typeOptions = contentTags?.categories ?? []; + const toneOptions = contentTags?.tones ?? []; + const involvementOptions = contentTags?.involvements ?? []; + const coverageOptions = contentTags?.usageRanges ?? []; + + // id[] -> 표시용 name들 + const namesByIds = (ids: number[], options: TagItem[]) => + options.filter((o) => ids.includes(o.id)).map((o) => o.name); + + const joinNames = (ids: number[], options: TagItem[]) => + namesByIds(ids, options).join("\n"); + + // name -> id + const idByName = (name: string, options: TagItem[]) => + options.find((o) => o.name === name)?.id; + + // 단일 선택 sheet 값(SelectSheet는 보통 string 하나) + const lenValue = + namesByIds(step3Selected.videoLength, videoLenOptions)[0] ?? ""; + const viewsValue = namesByIds(step3Selected.views, viewsOptions)[0] ?? ""; + + // 칩 max 5개 제한 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] + [step3Chips], ); - const genderValue = step3Selected.gender.join("\n"); - const ageValue = step3Selected.ageGroup.join("\n"); - const lenValue = step3Selected.videoLength[0] ?? ""; - const viewsValue = step3Selected.views[0] ?? ""; return (
- {/* 공용 상단 */} - {/* 본문 */} + {/* 태그 로딩/에러 */} + {tagsLoading ? ( +
+ 태그를 불러오는 중... +
+ ) : tagsError ? ( +
{tagsError}
+ ) : null} +

콘텐츠 특성을 모두 선택해주세요

+ {/* SNS */}
SNS 정보
-
SNS 주소를 입력해주세요
+
+ SNS 주소를 입력해주세요 +
-
주 시청자 정보를 선택해주세요
+
+ 주 시청자 정보를 선택해주세요 +
- open("gender")} /> - open("ageGroup")} /> + open("gender")} + /> + open("ageGroup")} + />
-
평균 영상 길이 및 조회수를 선택해주세요
+
+ 평균 영상 길이 및 조회수를 선택해주세요 +
- open("videoLength")} /> - open("views")} /> + open("videoLength")} + /> + open("views")} + />
-
- - {CONTENT_FORMAT.map((x) => { - const sel = step3Chips.contentFormat.includes(x); - const disabled = !sel && chipDisabled.contentFormat; - return ( - onToggleChip("contentFormat", x)} - /> - ); - })} - -
- + {/* 칩: 콘텐츠 종류(typeTags) */}
- {CONTENT_TYPE.map((x) => { - const sel = step3Chips.contentType.includes(x); + {typeOptions.map((t) => { + const sel = step3Chips.contentType.includes(t.id); const disabled = !sel && chipDisabled.contentType; return ( onToggleChip("contentType", x)} + onToggle={() => onToggleChip("contentType", t.id)} /> ); })}
+ {/* 칩: 콘텐츠 톤(toneTags) */}
- {CONTENT_TONE.map((x) => { - const sel = step3Chips.contentTone.includes(x); + {toneOptions.map((t) => { + const sel = step3Chips.contentTone.includes(t.id); const disabled = !sel && chipDisabled.contentTone; return ( onToggleChip("contentTone", x)} + onToggle={() => onToggleChip("contentTone", t.id)} /> ); })}
+ {/* 칩: 관여도(involvementTags) */}
- {CONTENT_HARDNESS.map((x) => { - const sel = step3Chips.contentHardness.includes(x); + {involvementOptions.map((t) => { + const sel = step3Chips.contentHardness.includes(t.id); const disabled = !sel && chipDisabled.contentHardness; return ( onToggleChip("contentHardness", x)} + onToggle={() => onToggleChip("contentHardness", t.id)} /> ); })}
+ {/* 칩: 활용 범위(coverageTags) */}
- {EDITING_RANGE.map((x) => { - const sel = step3Chips.editingRange.includes(x); + {coverageOptions.map((t) => { + const sel = step3Chips.editingRange.includes(t.id); const disabled = !sel && chipDisabled.editingRange; return ( onToggleChip("editingRange", x)} + onToggle={() => onToggleChip("editingRange", t.id)} /> ); })} @@ -199,19 +270,24 @@ export default function MatchingTestStep3Content({
- {/* 하단 고정 */} + {/* CTA */}
+ + {submitError ? ( +
{submitError}
+ ) : null}
+ {/* Sheets */} {sheet === "snsUrl" ? ( ) : null} - - {sheet === "gender" ? ( onToggleSelect("gender", v)} + options={genderOptions.map((x) => x.name)} + values={namesByIds(step3Selected.gender, genderOptions)} + onToggle={(name) => { + const id = idByName(name, genderOptions); + if (id != null) onToggleSelect("gender", id); + }} onDone={close} /> ) : null} - {sheet === "ageGroup" ? ( onToggleSelect("ageGroup", v)} + options={ageOptions.map((x) => x.name)} + values={namesByIds(step3Selected.ageGroup, ageOptions)} + onToggle={(name) => { + const id = idByName(name, ageOptions); + if (id != null) onToggleSelect("ageGroup", id); + }} onDone={close} /> ) : null} - {sheet === "videoLength" ? ( x.name)} value={lenValue} - onSelect={(v) => { - if (lenValue && lenValue !== v) onToggleSelect("videoLength", lenValue); - if (lenValue !== v) onToggleSelect("videoLength", v); + onSelect={(name) => { + const id = idByName(name, videoLenOptions); + if (id != null) onToggleSelect("videoLength", id); close(); }} /> @@ -269,11 +351,11 @@ export default function MatchingTestStep3Content({ {sheet === "views" ? ( x.name)} value={viewsValue} - onSelect={(v) => { - if (viewsValue && viewsValue !== v) onToggleSelect("views", viewsValue); - if (viewsValue !== v) onToggleSelect("views", v); + onSelect={(name) => { + const id = idByName(name, viewsOptions); + if (id != null) onToggleSelect("views", id); close(); }} /> @@ -283,7 +365,13 @@ export default function MatchingTestStep3Content({ ); } -function Section({ title, children }: { title: string; children: React.ReactNode }) { +function Section({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { return (
{title}
@@ -291,7 +379,6 @@ function Section({ title, children }: { title: string; children: React.ReactNode
); } - function ChipRow({ children }: { children: React.ReactNode }) { return
{children}
; } diff --git a/app/stores/matching-test.ts b/app/stores/matching-test.ts index 47563c4..52b99c9 100644 --- a/app/stores/matching-test.ts +++ b/app/stores/matching-test.ts @@ -1,8 +1,15 @@ import { create } from "zustand"; +export type TagId = number | string; + // step1 -export type SectionKey = "style" | "function" | "skinType" | "skinTone" | "makeupStyle"; -export type SelectedState = Record; +export type SectionKey = + | "style" + | "function" + | "skinType" + | "skinTone" + | "makeupStyle"; +export type SelectedState = Record; const EMPTY_STEP1: SelectedState = { style: [], @@ -14,7 +21,7 @@ const EMPTY_STEP1: SelectedState = { // step2 export type Step2SectionKey = "fashionStyle" | "interestItem" | "brandType"; -export type Step2SelectedState = Record; +export type Step2SelectedState = Record; const EMPTY_STEP2: Step2SelectedState = { fashionStyle: [], @@ -22,9 +29,9 @@ const EMPTY_STEP2: Step2SelectedState = { brandType: [], }; -// step3 +// step3(아직 태그 API에 없는 항목은 문자열 유지) export type Step3SelectKey = "gender" | "ageGroup" | "videoLength" | "views"; -export type Step3SelectedState = Record; +export type Step3SelectedState = Record; const EMPTY_STEP3_SELECTED: Step3SelectedState = { gender: [], @@ -33,14 +40,14 @@ const EMPTY_STEP3_SELECTED: Step3SelectedState = { views: [], }; +// step3 export type Step3ChipKey = | "contentFormat" | "contentType" | "contentTone" | "contentHardness" | "editingRange"; - -export type Step3ChipsState = Record; +export type Step3ChipsState = Record; const EMPTY_STEP3_CHIPS: Step3ChipsState = { contentFormat: [], @@ -50,18 +57,19 @@ const EMPTY_STEP3_CHIPS: Step3ChipsState = { editingRange: [], }; -// 결과 변환용 (추천에서 공통으로 쓸 카테고리) -export type MatchCategory = "beauty" | "fashion"; - -// store type MatchingTestStore = { // step1 selected: SelectedState; - toggleStep1: (section: SectionKey, label: string, maxPerSection: number) => void; + toggleStep1: (section: SectionKey, id: TagId, maxPerSection: number) => void; + setSingleStep1: (section: SectionKey, id: TagId) => void; // step2 step2Selected: Step2SelectedState; - toggleStep2: (section: Step2SectionKey, label: string, maxPerSection: number) => void; + toggleStep2: ( + section: Step2SectionKey, + id: TagId, + maxPerSection: number, + ) => void; heightCm: string; bodyShape: string; @@ -79,16 +87,10 @@ type MatchingTestStore = { isValidInstagramUrl: () => boolean; step3Selected: Step3SelectedState; - toggleStep3Select: (key: Step3SelectKey, label: string, max: number) => void; - - // 추가: 단일 선택(성별/나이대 같은 라디오용) - setSingleStep3Select: (key: Step3SelectKey, label: string) => void; + toggleStep3Select: (key: Step3SelectKey, id: number, max: number) => void; step3Chips: Step3ChipsState; - toggleStep3Chip: (key: Step3ChipKey, label: string, max: number) => void; - - // 최종 결과 payload 생성(추천 API에 그대로 넘길 수 있게) - buildResult: (category: MatchCategory) => { category: MatchCategory; tags: string[] }; + toggleStep3Chip: (key: Step3ChipKey, id: TagId, max: number) => void; resetAll: () => void; }; @@ -96,34 +98,43 @@ type MatchingTestStore = { export const useMatchingTestStore = create((set, get) => ({ // step1 selected: EMPTY_STEP1, - toggleStep1: (section, label, maxPerSection) => { + + toggleStep1: (section, id, maxPerSection) => { const prev = get().selected; const cur = prev[section]; - const already = cur.includes(label); + const already = cur.includes(id); if (already) { - set({ selected: { ...prev, [section]: cur.filter((x) => x !== label) } }); + set({ selected: { ...prev, [section]: cur.filter((x) => x !== id) } }); return; } if (cur.length >= maxPerSection) return; - set({ selected: { ...prev, [section]: [...cur, label] } }); + set({ selected: { ...prev, [section]: [...cur, id] } }); + }, + + // step1 단일 선택(피부타입/톤/메이크업스타일 같은 것) + setSingleStep1: (section, id) => { + const prev = get().selected; + set({ selected: { ...prev, [section]: [id] } }); }, // step2 step2Selected: EMPTY_STEP2, - toggleStep2: (section, label, maxPerSection) => { + toggleStep2: (section, id, maxPerSection) => { const prev = get().step2Selected; const cur = prev[section]; - const already = cur.includes(label); + const already = cur.includes(id); if (already) { - set({ step2Selected: { ...prev, [section]: cur.filter((x) => x !== label) } }); + set({ + step2Selected: { ...prev, [section]: cur.filter((x) => x !== id) }, + }); return; } if (cur.length >= maxPerSection) return; - set({ step2Selected: { ...prev, [section]: [...cur, label] } }); + set({ step2Selected: { ...prev, [section]: [...cur, id] } }); }, heightCm: "", @@ -139,19 +150,18 @@ export const useMatchingTestStore = create((set, get) => ({ // step3 snsUrl: "", setSnsUrl: (v) => set({ snsUrl: v }), - - // ⚠️ 기존 코드 유지: 필요하면 "https://www.instagram.com/" 형태로 고쳐야 정확함 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) } }); + set({ + step3Selected: { ...prevAll, [key]: cur.filter((x) => x !== label) }, + }); return; } if (cur.length >= max) return; @@ -159,45 +169,19 @@ export const useMatchingTestStore = create((set, get) => ({ 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) => { + toggleStep3Chip: (key, id, max) => { const prevAll = get().step3Chips; const cur = prevAll[key]; - const already = cur.includes(label); + const already = cur.includes(id); if (already) { - set({ step3Chips: { ...prevAll, [key]: cur.filter((x) => x !== label) } }); + set({ step3Chips: { ...prevAll, [key]: cur.filter((x) => x !== id) } }); return; } if (cur.length >= max) return; - set({ step3Chips: { ...prevAll, [key]: [...cur, label] } }); - }, - - // 임시 입력값 -> 결과 요약(tags) - buildResult: (category) => { - const s1 = get().selected; - const s2 = get().step2Selected; - const s3sel = get().step3Selected; - const s3chip = get().step3Chips; - - const flatten = (obj: Record) => Object.values(obj).flat(); - - const tags = - category === "beauty" - ? [...flatten(s1), ...flatten(s3sel), ...flatten(s3chip)] - : [...flatten(s2), ...flatten(s3sel), ...flatten(s3chip)]; - - // 중복 제거 - const uniq = Array.from(new Set(tags)); - - return { category, tags: uniq }; + set({ step3Chips: { ...prevAll, [key]: [...cur, id] } }); }, resetAll: () => From bca056bc33feaaab022aad9063086b60313debb8 Mon Sep 17 00:00:00 2001 From: yoonyoungyang Date: Mon, 2 Feb 2026 21:32:43 +0900 Subject: [PATCH 2/5] =?UTF-8?q?step1=20api=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_shared/builders/build-match-payload.ts | 73 ++++----- .../matching/test/_shared/tags/tags.api.ts | 25 +-- .../matching/test/_shared/tags/tags.query.ts | 22 +-- .../matching/test/_shared/tags/tags.types.ts | 28 +--- .../test/_shared/types/matches.types.ts | 32 +++- app/routes/matching/test/step1/route.tsx | 98 +++++++----- .../matching/test/step1/step1-content.tsx | 7 +- app/routes/matching/test/step2/route.tsx | 110 ++++++++----- .../matching/test/step2/step2-content.tsx | 146 +++++++++++------- app/routes/matching/test/step3/route.tsx | 87 +++-------- app/stores/matching-test.ts | 107 ++++++------- vite.config.ts | 20 ++- 12 files changed, 368 insertions(+), 387 deletions(-) diff --git a/app/routes/matching/test/_shared/builders/build-match-payload.ts b/app/routes/matching/test/_shared/builders/build-match-payload.ts index c9f7561..bb886d3 100644 --- a/app/routes/matching/test/_shared/builders/build-match-payload.ts +++ b/app/routes/matching/test/_shared/builders/build-match-payload.ts @@ -1,16 +1,13 @@ import { useMatchingTestStore } from "../../../../../stores/matching-test"; import type { MatchesRequest } from "../types/matches.types"; -function mustOne(arr: number[], label: string): number { - if (arr.length !== 1) throw new Error(`${label}은(는) 1개만 선택해야 해요.`); - return arr[0]; -} -function mustMany(arr: number[], label: string): number[] { - if (arr.length < 1) throw new Error(`${label}을(를) 1개 이상 선택해야 해요.`); - return arr; +function mustMany(v: number[] | null | undefined): number[] { + if (!v || v.length < 1) throw new Error(); + return v; } -function mustTag(v: number | null, label: string): number { - if (v == null) throw new Error(`${label}을(를) 선택해야 해요.`); + +function mustOne(v: number | null | undefined): number { + if (v == null) throw new Error(); return v; } @@ -19,52 +16,40 @@ export function buildMatchPayload(): MatchesRequest { return { beauty: { - interestStyleTags: mustMany(s.selected.style, "뷰티 관심 스타일"), - prefferedFunctionTags: mustMany(s.selected.function, "뷰티 관심 기능"), - skinTypeTags: mustOne(s.selected.skinType, "피부 타입"), - skinToneTags: mustOne(s.selected.skinTone, "피부 톤"), - makeupStyleTags: mustOne(s.selected.makeupStyle, "메이크업 스타일"), + interestStyleTags: mustMany(s.selected.style), + prefferedFunctionTags: mustMany(s.selected.function), + skinTypeTags: mustMany(s.selected.skinType), + skinToneTags: mustMany(s.selected.skinTone), + makeupStyleTags: mustMany(s.selected.makeupStyle), }, + fashion: { - interestStyleTags: mustMany( - s.step2Selected.fashionStyle, - "패션 관심 스타일", - ), - preferredItemTags: mustMany(s.step2Selected.interestItem, "관심 아이템"), - preferredBrandTags: mustMany( - s.step2Selected.brandType, - "관심 브랜드 종류", - ), - heightTags: mustTag(s.fashionBody.heightTagId, "키"), - weightTypeTags: mustTag(s.fashionBody.weightTypeTagId, "체형"), - topSizeTags: mustTag(s.fashionBody.topSizeTagId, "상의 사이즈"), - bottomSizeTags: mustTag(s.fashionBody.bottomSizeTagId, "하의 사이즈"), + interestStyleTags: mustMany(s.step2Selected.fashionStyle), + preferredItemTags: mustMany(s.step2Selected.interestItem), + preferredBrandTags: mustMany(s.step2Selected.brandType), + heightTag: mustOne(s.fashionBody.heightTag), + weightTypeTag: mustOne(s.fashionBody.weightTypeTag), + topSizeTag: mustOne(s.fashionBody.topSizeTag), + bottomSizeTag: mustOne(s.fashionBody.bottomSizeTag), }, + content: { sns: { url: s.snsUrl, mainAudience: { - genderTags: mustMany(s.step3Selected.gender, "주 시청자 성별"), - ageTags: mustMany(s.step3Selected.ageGroup, "주 시청자 나이대"), + genderTags: mustMany(s.step3Selected.gender), + ageTags: mustMany(s.step3Selected.ageGroup), }, averageAudience: { - videoLengthTags: mustMany( - s.step3Selected.videoLength, - "평균 영상 길이", - ), - videoViewsTags: mustMany(s.step3Selected.views, "평균 조회수"), + videoLengthTags: mustMany(s.step3Selected.videoLength), + videoViewsTags: mustMany(s.step3Selected.views), }, }, - typeTags: mustMany(s.step3Chips.contentType, "콘텐츠 종류"), - toneTags: mustMany(s.step3Chips.contentTone, "콘텐츠 톤"), - prefferedInvolvementTags: mustMany( - s.step3Chips.contentHardness, - "콘텐츠 관여도", - ), - prefferedCoverageTags: mustMany( - s.step3Chips.editingRange, - "콘텐츠 활용 범위", - ), + + typeTags: mustMany(s.step3Chips.contentType), + toneTags: mustMany(s.step3Chips.contentTone), + prefferedInvolvementTags: mustMany(s.step3Chips.contentHardness), + prefferedCoverageTags: mustMany(s.step3Chips.editingRange), }, }; } diff --git a/app/routes/matching/test/_shared/tags/tags.api.ts b/app/routes/matching/test/_shared/tags/tags.api.ts index cf642f8..d0924ac 100644 --- a/app/routes/matching/test/_shared/tags/tags.api.ts +++ b/app/routes/matching/test/_shared/tags/tags.api.ts @@ -1,33 +1,20 @@ import { axiosInstance } from "../../../../../api/axios"; -import type { - ApiResponse, - BeautyFashionTagsResult, - ContentTagsResult, -} from "./tags.types"; +import type { ApiResponse } from "../types/matches.types"; +import type { BeautyTags } from "./tags.types"; -export async function fetchBeautyTags(): Promise { - const res = await axiosInstance.get>( +export async function fetchBeautyTags(): Promise { + const res = await axiosInstance.get>( "/api/v1/tags/beauty", ); if (!res.data.isSuccess) throw new Error(res.data.message || "beauty 태그 조회 실패"); return res.data.result; } - -export async function fetchFashionTags(): Promise { - const res = await axiosInstance.get>( +export async function fetchFashionTags(): Promise { + const res = await axiosInstance.get>( "/api/v1/tags/fashion", ); if (!res.data.isSuccess) throw new Error(res.data.message || "fashion 태그 조회 실패"); return res.data.result; } - -export async function fetchContentTags(): Promise { - const res = await axiosInstance.get>( - "/api/v1/tags/content", - ); - if (!res.data.isSuccess) - throw new Error(res.data.message || "content 태그 조회 실패"); - return res.data.result; -} diff --git a/app/routes/matching/test/_shared/tags/tags.query.ts b/app/routes/matching/test/_shared/tags/tags.query.ts index d1a669d..e85c84d 100644 --- a/app/routes/matching/test/_shared/tags/tags.query.ts +++ b/app/routes/matching/test/_shared/tags/tags.query.ts @@ -1,30 +1,18 @@ import { useQuery } from "@tanstack/react-query"; -import { - fetchBeautyTags, - fetchFashionTags, - fetchContentTags, -} from "./tags.api"; +import { fetchBeautyTags } from "./tags.api"; +import type { BeautyTags } from "./tags.types"; export function useBeautyTags() { - return useQuery({ + return useQuery({ queryKey: ["tags", "beauty"], queryFn: fetchBeautyTags, staleTime: 1000 * 60 * 10, }); } - export function useFashionTags() { - return useQuery({ + return useQuery({ queryKey: ["tags", "fashion"], queryFn: fetchFashionTags, - staleTime: 1000 * 60 * 10, - }); -} - -export function useContentTags() { - return useQuery({ - queryKey: ["tags", "content"], - queryFn: fetchContentTags, - staleTime: 1000 * 60 * 10, + staleTime: STALE, }); } diff --git a/app/routes/matching/test/_shared/tags/tags.types.ts b/app/routes/matching/test/_shared/tags/tags.types.ts index 5f8bdc1..807fdca 100644 --- a/app/routes/matching/test/_shared/tags/tags.types.ts +++ b/app/routes/matching/test/_shared/tags/tags.types.ts @@ -1,30 +1,14 @@ -export type ApiResponse = { - isSuccess: boolean; - code: string; - message: string; - result: T; -}; - -export type TagId = number | string; - export type TagItem = { - id: TagId; + id: number; name: string; }; -// /api/v1/tags/beauty, /api/v1/tags/fashion -export type CategoryTagMap = Record; - -export type BeautyFashionTagsResult = { +export type BeautyTags = { tagType: string; - categories: CategoryTagMap; + categories: Record; }; -// /api/v1/tags/content -export type ContentTagsResult = { - formats: TagItem[]; - categories: TagItem[]; - tones: TagItem[]; - involvements: TagItem[]; - usageRanges: TagItem[]; +export type FashionTags = { + tagType: string; + categories: Record; }; diff --git a/app/routes/matching/test/_shared/types/matches.types.ts b/app/routes/matching/test/_shared/types/matches.types.ts index 98ceaa8..4ca831f 100644 --- a/app/routes/matching/test/_shared/types/matches.types.ts +++ b/app/routes/matching/test/_shared/types/matches.types.ts @@ -9,18 +9,18 @@ export type MatchesRequest = { beauty: { interestStyleTags: number[]; prefferedFunctionTags: number[]; - skinTypeTags: number; - skinToneTags: number; - makeupStyleTags: number; + skinTypeTags: number[]; + skinToneTags: number[]; + makeupStyleTags: number[]; }; fashion: { interestStyleTags: number[]; preferredItemTags: number[]; preferredBrandTags: number[]; - heightTags: number; - weightTypeTags: number; - topSizeTags: number; - bottomSizeTags: number; + heightTag: number; + weightTypeTag: number; + topSizeTag: number; + bottomSizeTag: number; }; content: { sns: { @@ -41,4 +41,20 @@ export type MatchesRequest = { }; }; -export type MatchesResponseResult = unknown; +export type MatchedBrand = { + brandId: number; + brandName: string; + matchingRatio: number; + logoUrl?: string; +}; + +export type HighMatchingBrandList = { + count: number; + brands: MatchedBrand[]; +}; + +export type MatchesResponseResult = { + userType: string; + typeTag: string[]; + highMatchingBrandList: HighMatchingBrandList; +}; diff --git a/app/routes/matching/test/step1/route.tsx b/app/routes/matching/test/step1/route.tsx index b2d445a..5825cd2 100644 --- a/app/routes/matching/test/step1/route.tsx +++ b/app/routes/matching/test/step1/route.tsx @@ -1,54 +1,83 @@ -import { useNavigate } from "react-router"; import { useMemo } from "react"; +import { useNavigate } from "react-router"; import MatchingTestContent from "./step1-content"; - +import { useBeautyTags } from "../_shared/tags/tags.query"; import { useMatchingTestStore, type SectionKey, - type TagId, } from "../../../../stores/matching-test"; -import { useBeautyTags } from "../_shared/tags/tags.query"; import type { TagItem } from "../_shared/tags/tags.types"; -// ⚠️ 중요: 아래 key는 swagger에 additionalProp로 나와서 실제 키를 백엔드에게 확인해야 함. -// 실제 예: "interests", "functions", "skinTypes", "skinTones", "makeupStyles" 등 -const BEAUTY_CATEGORY_KEY: Record = { - style: "style", - function: "function", - skinType: "skinType", - skinTone: "skinTone", - makeupStyle: "makeupStyle", -}; - const SECTIONS: Array<{ key: SectionKey; title: string; max: number }> = [ - { key: "style", title: "관심 스타일", max: 5 }, - { key: "function", title: "관심 기능", max: 5 }, - { key: "skinType", title: "피부 타입", max: 1 }, - { key: "skinTone", title: "피부 밝기", max: 1 }, - { key: "makeupStyle", title: "메이크업 스타일", max: 1 }, + { key: "style", title: "관심 스타일", max: Number.POSITIVE_INFINITY }, + { key: "function", title: "관심 기능", max: Number.POSITIVE_INFINITY }, + { key: "skinType", title: "피부 타입", max: Number.POSITIVE_INFINITY }, + { key: "skinTone", title: "피부 밝기", max: Number.POSITIVE_INFINITY }, + { + key: "makeupStyle", + title: "메이크업 스타일", + max: Number.POSITIVE_INFINITY, + }, ]; +type ItemsBySection = Record; + +const pickCategory = ( + categories: Record, + candidates: readonly string[], +): TagItem[] => { + for (const key of candidates) { + const v = categories[key]; + if (Array.isArray(v)) return v; + } + + const normalize = (s: string) => s.replace(/\s+/g, "").trim(); + const keys = Object.keys(categories); + + for (const cand of candidates) { + const hit = keys.find((k) => normalize(k) === normalize(cand)); + if (hit) { + const v = categories[hit]; + if (Array.isArray(v)) return v; + } + } + + return []; +}; + export default function MatchingTestStep1Page() { const navigate = useNavigate(); + const { data, isLoading, error } = useBeautyTags(); const selected = useMatchingTestStore((s) => s.selected); const toggleStep1 = useMatchingTestStore((s) => s.toggleStep1); - const setSingleStep1 = useMatchingTestStore((s) => s.setSingleStep1); - const sectionItems = useMemo(() => { + const itemsBySection = useMemo((): ItemsBySection => { const categories = data?.categories ?? {}; - const out: Record = { - style: categories[BEAUTY_CATEGORY_KEY.style] ?? [], - function: categories[BEAUTY_CATEGORY_KEY.function] ?? [], - skinType: categories[BEAUTY_CATEGORY_KEY.skinType] ?? [], - skinTone: categories[BEAUTY_CATEGORY_KEY.skinTone] ?? [], - makeupStyle: categories[BEAUTY_CATEGORY_KEY.makeupStyle] ?? [], + + return { + style: pickCategory(categories, ["관심 스타일"]), + function: pickCategory(categories, ["관심 기능"]), + skinType: pickCategory(categories, ["피부 타입"]), + skinTone: pickCategory(categories, [ + "피부 밝기", + "피부 밝기(톤)", + "피부 밝기 (톤)", + "피부 톤", + "피부톤", + ]), + makeupStyle: pickCategory(categories, [ + "메이크업 스타일", + "메이크업 스타일(연출)", + "메이크업 스타일 (연출)", + "메이크업", + "메이크업스타일", + ]), }; - return out; }, [data]); - const isSelected = (section: SectionKey, id: TagId) => + const isSelected = (section: SectionKey, id: number) => selected[section].includes(id); const canGoNext = useMemo( @@ -56,18 +85,17 @@ export default function MatchingTestStep1Page() { [selected], ); + const errorText = error ? error.message : null; + return ( { - if (max === 1) setSingleStep1(section, id); - else toggleStep1(section, id, max); - }} + onToggle={(section, id, max) => toggleStep1(section, id, max)} canGoNext={canGoNext} onBack={() => navigate("/")} onNext={() => navigate("/matching/test/step2")} diff --git a/app/routes/matching/test/step1/step1-content.tsx b/app/routes/matching/test/step1/step1-content.tsx index 2f4118e..ae748fb 100644 --- a/app/routes/matching/test/step1/step1-content.tsx +++ b/app/routes/matching/test/step1/step1-content.tsx @@ -4,7 +4,6 @@ import Button from "../../../../components/common/Button"; import type { SectionKey, SelectedState, - TagId, } from "../../../../stores/matching-test"; import type { TagItem } from "../_shared/tags/tags.types"; @@ -16,8 +15,8 @@ type Props = { itemsBySection: Record; selected: SelectedState; - isSelected: (section: SectionKey, id: TagId) => boolean; - onToggle: (section: SectionKey, id: TagId, max: number) => void; + isSelected: (section: SectionKey, id: number) => boolean; + onToggle: (section: SectionKey, id: number, max: number) => void; canGoNext: boolean; onBack: () => void; @@ -84,7 +83,7 @@ export default function MatchingTestContent({ return ( = { - fashionStyle: "fashionStyle", - interestItem: "interestItem", - brandType: "brandType", -}; - const SECTIONS: Array<{ key: Step2SectionKey; title: string; max: number }> = [ { key: "fashionStyle", title: "관심 스타일", max: 5 }, { key: "interestItem", title: "관심 아이템/분야", max: 5 }, { key: "brandType", title: "관심 브랜드 종류", max: 5 }, ]; +type ItemsBySection = Record; + +const pickCategory = ( + categories: Record, + candidates: readonly string[], +): TagItem[] => { + for (const key of candidates) { + const v = categories[key]; + if (Array.isArray(v)) return v; + } + + const normalize = (s: string) => s.replace(/\s+/g, "").trim(); + const keys = Object.keys(categories); + + for (const cand of candidates) { + const hit = keys.find((k) => normalize(k) === normalize(cand)); + if (hit) { + const v = categories[hit]; + if (Array.isArray(v)) return v; + } + } + + return []; +}; + export default function MatchingTestStep2Page() { const navigate = useNavigate(); const { data, isLoading, error } = useFashionTags(); @@ -31,53 +46,64 @@ export default function MatchingTestStep2Page() { 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 fashionBody = useMatchingTestStore((s) => s.fashionBody); + const setFashionBody = useMatchingTestStore((s) => s.setFashionBody); - const itemsBySection = useMemo(() => { + const itemsBySection = useMemo((): ItemsBySection => { const categories = data?.categories ?? {}; - const out: Record = { - fashionStyle: categories[FASHION_CATEGORY_KEY.fashionStyle] ?? [], - interestItem: categories[FASHION_CATEGORY_KEY.interestItem] ?? [], - brandType: categories[FASHION_CATEGORY_KEY.brandType] ?? [], + + return { + fashionStyle: pickCategory(categories, [ + "관심 스타일", + "패션 스타일", + "스타일", + ]), + interestItem: pickCategory(categories, [ + "관심 아이템/분야", + "관심 아이템", + "아이템/분야", + "아이템", + ]), + brandType: pickCategory(categories, [ + "관심 브랜드 종류", + "브랜드 종류", + "브랜드 타입", + "브랜드", + ]), }; - return out; }, [data]); - const isSelected = (section: Step2SectionKey, id: TagId) => + const computedSections = useMemo(() => { + return SECTIONS.filter((s) => (itemsBySection[s.key]?.length ?? 0) > 0); + }, [itemsBySection]); + + const isSelected = (section: Step2SectionKey, id: number) => selected[section].includes(id); const canGoNext = useMemo(() => { - const chipsOk = SECTIONS.every((s) => selected[s.key].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]); + const chipsOk = computedSections.every((s) => selected[s.key].length >= 1); + const bodyOk = + fashionBody.heightTag !== null && + fashionBody.weightTypeTag !== null && + fashionBody.topSizeTag !== null && + fashionBody.bottomSizeTag !== null; + + return chipsOk && bodyOk; + }, [computedSections, selected, fashionBody]); + + const errorText = error ? error.message : null; return ( toggle(section, id, max)} - heightCm={heightCm} - bodyShape={bodyShape} - topSize={topSize} - bottomSizeIn={bottomSizeIn} - onHeightChange={setHeightCm} - onBodyShapeChange={setBodyShape} - onTopSizeChange={setTopSize} - onBottomSizeChange={setBottomSizeIn} + fashionBody={fashionBody} + onSetFashionBody={setFashionBody} canGoNext={canGoNext} onBack={() => navigate("/matching/test/step1")} onNext={() => navigate("/matching/test/step3")} diff --git a/app/routes/matching/test/step2/step2-content.tsx b/app/routes/matching/test/step2/step2-content.tsx index 80e9975..c935712 100644 --- a/app/routes/matching/test/step2/step2-content.tsx +++ b/app/routes/matching/test/step2/step2-content.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from "react"; import type { Step2SectionKey, Step2SelectedState, - TagId, + FashionBodyTags, } from "../../../../stores/matching-test"; import type { TagItem } from "../_shared/tags/tags.types"; @@ -22,18 +22,11 @@ type Props = { itemsBySection: Record; selected: Step2SelectedState; - isSelected: (section: Step2SectionKey, id: TagId) => boolean; - onToggle: (section: Step2SectionKey, id: TagId, max: number) => void; + isSelected: (section: Step2SectionKey, id: number) => boolean; + onToggle: (section: Step2SectionKey, id: number, max: number) => 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; + fashionBody: FashionBodyTags; + onSetFashionBody: (key: keyof FashionBodyTags, id: number | null) => void; canGoNext: boolean; onBack: () => void; @@ -43,13 +36,20 @@ type Props = { type SheetType = null | "height" | "bodyShape" | "topSize" | "bottomSize"; const BODY_SHAPE_OPTIONS = [ - "마름", - "표준", - "통통", - "근육형", - "웨이브", + { id: 1, label: "마름" }, + { id: 2, label: "표준" }, + { id: 3, label: "통통" }, + { id: 4, label: "근육형" }, + { id: 5, label: "웨이브" }, +] as const; + +const TOP_SIZE_OPTIONS = [ + { id: 33, label: "33" }, + { id: 44, label: "44" }, + { id: 55, label: "55" }, + { id: 66, label: "66" }, + { id: 77, label: "77" }, ] as const; -const TOP_SIZE_OPTIONS = ["33", "44", "55", "66", "77"] as const; export default function MatchingTestStep2Content({ isLoading, @@ -59,14 +59,8 @@ export default function MatchingTestStep2Content({ selected, isSelected, onToggle, - heightCm, - bodyShape, - topSize, - bottomSizeIn, - onHeightChange, - onBodyShapeChange, - onTopSizeChange, - onBottomSizeChange, + fashionBody, + onSetFashionBody, canGoNext, onBack, onNext, @@ -76,20 +70,33 @@ export default function MatchingTestStep2Content({ const close = () => setSheet(null); const chipDisabled = useMemo(() => { - const out: Record = { - fashionStyle: - selected.fashionStyle.length >= - (sections.find((s) => s.key === "fashionStyle")?.max ?? 5), - interestItem: - selected.interestItem.length >= - (sections.find((s) => s.key === "interestItem")?.max ?? 5), - brandType: - selected.brandType.length >= - (sections.find((s) => s.key === "brandType")?.max ?? 5), - }; - return out; + const getMax = (k: Step2SectionKey) => + sections.find((s) => s.key === k)?.max ?? 5; + + return { + fashionStyle: selected.fashionStyle.length >= getMax("fashionStyle"), + interestItem: selected.interestItem.length >= getMax("interestItem"), + brandType: selected.brandType.length >= getMax("brandType"), + } satisfies Record; }, [selected, sections]); + const heightText = + fashionBody.heightTag === null ? "" : `${fashionBody.heightTag} cm`; + const bottomText = + fashionBody.bottomSizeTag === null ? "" : `${fashionBody.bottomSizeTag} in`; + + const bodyShapeText = + fashionBody.weightTypeTag === null + ? "" + : (BODY_SHAPE_OPTIONS.find((o) => o.id === fashionBody.weightTypeTag) + ?.label ?? ""); + + const topSizeText = + fashionBody.topSizeTag === null + ? "" + : (TOP_SIZE_OPTIONS.find((o) => o.id === fashionBody.topSizeTag)?.label ?? + String(fashionBody.topSizeTag)); + if (isLoading) { return (
@@ -120,7 +127,6 @@ export default function MatchingTestStep2Content({ 모두 선택해주세요! - {/* Chips: 서버 태그 기반 */} {sections.map((sec) => { const items = itemsBySection[sec.key] ?? []; return ( @@ -131,7 +137,7 @@ export default function MatchingTestStep2Content({ const disabled = !sel && (chipDisabled[sec.key] ?? false); return (
체형 정보
@@ -153,7 +158,7 @@ export default function MatchingTestStep2Content({
open("height")} /> @@ -167,7 +172,7 @@ export default function MatchingTestStep2Content({
open("bodyShape")} /> @@ -181,13 +186,13 @@ export default function MatchingTestStep2Content({
open("topSize")} /> open("bottomSize")} /> @@ -208,14 +213,23 @@ export default function MatchingTestStep2Content({
- {/* Sheets */} {sheet === "height" ? ( onHeightChange(v.replace(/[^\d]/g, ""))} - doneDisabled={heightCm.trim().length === 0} + onChange={(v) => { + const n = Number(v.replace(/[^\d]/g, "")); + onSetFashionBody( + "heightTag", + Number.isFinite(n) && n > 0 ? n : null, + ); + }} + doneDisabled={fashionBody.heightTag === null} onDone={close} suffix="cm" /> @@ -225,10 +239,20 @@ export default function MatchingTestStep2Content({ {sheet === "bottomSize" ? ( onBottomSizeChange(v.replace(/[^\d]/g, ""))} - doneDisabled={bottomSizeIn.trim().length === 0} + onChange={(v) => { + const n = Number(v.replace(/[^\d]/g, "")); + onSetFashionBody( + "bottomSizeTag", + Number.isFinite(n) && n > 0 ? n : null, + ); + }} + doneDisabled={fashionBody.bottomSizeTag === null} onDone={close} suffix="in" /> @@ -238,10 +262,11 @@ export default function MatchingTestStep2Content({ {sheet === "bodyShape" ? ( { - onBodyShapeChange(v); + options={BODY_SHAPE_OPTIONS.map((o) => o.label)} + value={bodyShapeText} + onSelect={(label) => { + const hit = BODY_SHAPE_OPTIONS.find((o) => o.label === label); + onSetFashionBody("weightTypeTag", hit ? hit.id : null); close(); }} /> @@ -251,10 +276,11 @@ export default function MatchingTestStep2Content({ {sheet === "topSize" ? ( { - onTopSizeChange(v); + options={TOP_SIZE_OPTIONS.map((o) => o.label)} + value={topSizeText} + onSelect={(label) => { + const hit = TOP_SIZE_OPTIONS.find((o) => o.label === label); + onSetFashionBody("topSizeTag", hit ? hit.id : null); close(); }} /> diff --git a/app/routes/matching/test/step3/route.tsx b/app/routes/matching/test/step3/route.tsx index ca3fe14..3d1e8e4 100644 --- a/app/routes/matching/test/step3/route.tsx +++ b/app/routes/matching/test/step3/route.tsx @@ -1,83 +1,42 @@ -// app/routes/home/matching/test/step3/route.tsx +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; -import { useMemo, useState } from "react"; -import { useNavigate } from "react-router"; import MatchingTestStep3Content from "./step3-content"; -import { - useMatchingTestStore, - type Step3ChipKey, - type Step3SelectKey, -} from "../../../../stores/matching-test"; - -import { postMatches } from "../_shared/api/matches.api"; -import { buildMatchPayload } from "../_shared/builders/build-match-payload"; - +// ✅ 스토어/유틸/타입/훅 경로는 프로젝트에 맞게 조정 +import { useMatchingTestStore } from "../../../../stores/matching-test"; import { useContentTags } from "../_shared/tags/tags.query"; +import { buildMatchPayload } from "../_shared/builders/build-match-payload"; +import { postMatches } from "../_shared/api/matches.api"; export default function MatchingTestStep3Page() { const navigate = useNavigate(); - const { - data: contentTagsRes, - isLoading: tagsLoading, - isError: tagsIsError, - error: tagsErrorObj, - } = useContentTags(); - + // store 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 onToggleSelect = useMatchingTestStore((s) => s.onToggleSelect); const step3Chips = useMatchingTestStore((s) => s.step3Chips); - const toggleChip = useMatchingTestStore((s) => s.toggleStep3Chip); - - const [submitting, setSubmitting] = useState(false); - const [submitError, setSubmitError] = useState(null); - - // "다음" 가능 조건 - 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; + const onToggleChip = useMatchingTestStore((s) => s.onToggleChip); - const chipsOk = - 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]); - - // id 기반 toggle (max=5) - const onToggleSelect = (key: Step3SelectKey, id: number) => { - toggleSelect(key, id, 5); - }; + const canGoNext = useMatchingTestStore((s) => s.canGoNextStep3); - const onToggleChip = (key: Step3ChipKey, id: number) => { - toggleChip(key, id, 5); - }; + // tags query + const { + data: contentTags, + isLoading: tagsLoading, + error: tagsError, + } = useContentTags(); - // 태그 결과만 step3-content에 내려줌 - const contentTags = contentTagsRes ?? null; + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); - const tagsError = tagsIsError - ? tagsErrorObj instanceof Error - ? tagsErrorObj.message - : "콘텐츠 태그를 불러오지 못했어요." - : null; + const isValidInstagramUrl = /^https?:\/\/(www\.)?instagram\.com\/.+/i.test(snsUrl.trim()); - // submit handler const handleSubmit = async () => { - if (submitting) return; setSubmitting(true); setSubmitError(null); @@ -92,9 +51,7 @@ export default function MatchingTestStep3Page() { navigate("/matching/test/result"); } catch (e) { - setSubmitError( - e instanceof Error ? e.message : "제출 중 오류가 발생했어요.", - ); + setSubmitError(e instanceof Error ? e.message : "제출 중 오류가 발생했어요."); } finally { setSubmitting(false); } @@ -103,10 +60,9 @@ export default function MatchingTestStep3Page() { return ( ; + +export type Step2SectionKey = "fashionStyle" | "interestItem" | "brandType"; + +export type Step3SelectKey = "gender" | "ageGroup" | "videoLength" | "views"; + +export type Step3ChipKey = + | "contentFormat" + | "contentType" + | "contentTone" + | "contentHardness" + | "editingRange"; + +export type SelectedState = Record; +export type Step2SelectedState = Record; +export type Step3SelectedState = Record; +export type Step3ChipsState = Record; const EMPTY_STEP1: SelectedState = { style: [], @@ -19,20 +31,12 @@ const EMPTY_STEP1: SelectedState = { makeupStyle: [], }; -// step2 -export type Step2SectionKey = "fashionStyle" | "interestItem" | "brandType"; -export type Step2SelectedState = Record; - const EMPTY_STEP2: Step2SelectedState = { fashionStyle: [], interestItem: [], brandType: [], }; -// step3(아직 태그 API에 없는 항목은 문자열 유지) -export type Step3SelectKey = "gender" | "ageGroup" | "videoLength" | "views"; -export type Step3SelectedState = Record; - const EMPTY_STEP3_SELECTED: Step3SelectedState = { gender: [], ageGroup: [], @@ -40,15 +44,6 @@ const EMPTY_STEP3_SELECTED: Step3SelectedState = { views: [], }; -// step3 -export type Step3ChipKey = - | "contentFormat" - | "contentType" - | "contentTone" - | "contentHardness" - | "editingRange"; -export type Step3ChipsState = Record; - const EMPTY_STEP3_CHIPS: Step3ChipsState = { contentFormat: [], contentType: [], @@ -57,31 +52,35 @@ const EMPTY_STEP3_CHIPS: Step3ChipsState = { editingRange: [], }; +export type FashionBodyTags = { + heightTag: number | null; + weightTypeTag: number | null; + topSizeTag: number | null; + bottomSizeTag: number | null; +}; + +const EMPTY_FASHION_BODY: FashionBodyTags = { + heightTag: null, + weightTypeTag: null, + topSizeTag: null, + bottomSizeTag: null, +}; + type MatchingTestStore = { - // step1 selected: SelectedState; - toggleStep1: (section: SectionKey, id: TagId, maxPerSection: number) => void; - setSingleStep1: (section: SectionKey, id: TagId) => void; + toggleStep1: (section: SectionKey, id: number, maxPerSection: number) => void; + setSingleStep1: (section: SectionKey, id: number) => void; - // step2 step2Selected: Step2SelectedState; toggleStep2: ( section: Step2SectionKey, - id: TagId, + id: number, maxPerSection: number, ) => void; - heightCm: string; - bodyShape: string; - topSize: string; - bottomSizeIn: string; + fashionBody: FashionBodyTags; + setFashionBody: (key: keyof FashionBodyTags, id: number | null) => void; - 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; @@ -90,13 +89,12 @@ type MatchingTestStore = { toggleStep3Select: (key: Step3SelectKey, id: number, max: number) => void; step3Chips: Step3ChipsState; - toggleStep3Chip: (key: Step3ChipKey, id: TagId, max: number) => void; + toggleStep3Chip: (key: Step3ChipKey, id: number, max: number) => void; resetAll: () => void; }; export const useMatchingTestStore = create((set, get) => ({ - // step1 selected: EMPTY_STEP1, toggleStep1: (section, id, maxPerSection) => { @@ -113,13 +111,11 @@ export const useMatchingTestStore = create((set, get) => ({ set({ selected: { ...prev, [section]: [...cur, id] } }); }, - // step1 단일 선택(피부타입/톤/메이크업스타일 같은 것) setSingleStep1: (section, id) => { const prev = get().selected; set({ selected: { ...prev, [section]: [id] } }); }, - // step2 step2Selected: EMPTY_STEP2, toggleStep2: (section, id, maxPerSection) => { const prev = get().step2Selected; @@ -137,36 +133,32 @@ export const useMatchingTestStore = create((set, get) => ({ set({ step2Selected: { ...prev, [section]: [...cur, id] } }); }, - heightCm: "", - bodyShape: "", - topSize: "", - bottomSizeIn: "", - - setHeightCm: (v) => set({ heightCm: v }), - setBodyShape: (v) => set({ bodyShape: v }), - setTopSize: (v) => set({ topSize: v }), - setBottomSizeIn: (v) => set({ bottomSizeIn: v }), + fashionBody: EMPTY_FASHION_BODY, + setFashionBody: (key, id) => { + const prev = get().fashionBody; + set({ fashionBody: { ...prev, [key]: id } }); + }, - // step3 snsUrl: "", setSnsUrl: (v) => set({ snsUrl: v }), - isValidInstagramUrl: () => get().snsUrl.startsWith("www.instagram/"), + isValidInstagramUrl: () => + /^https?:\/\/(www\.)?instagram\.com\/.+/i.test(get().snsUrl.trim()), step3Selected: EMPTY_STEP3_SELECTED, - toggleStep3Select: (key, label, max) => { + toggleStep3Select: (key, id, max) => { const prevAll = get().step3Selected; const cur = prevAll[key]; - const already = cur.includes(label); + const already = cur.includes(id); if (already) { set({ - step3Selected: { ...prevAll, [key]: cur.filter((x) => x !== label) }, + step3Selected: { ...prevAll, [key]: cur.filter((x) => x !== id) }, }); return; } if (cur.length >= max) return; - set({ step3Selected: { ...prevAll, [key]: [...cur, label] } }); + set({ step3Selected: { ...prevAll, [key]: [...cur, id] } }); }, step3Chips: EMPTY_STEP3_CHIPS, @@ -188,10 +180,7 @@ export const useMatchingTestStore = create((set, get) => ({ set({ selected: EMPTY_STEP1, step2Selected: EMPTY_STEP2, - heightCm: "", - bodyShape: "", - topSize: "", - bottomSizeIn: "", + fashionBody: EMPTY_FASHION_BODY, snsUrl: "", step3Selected: EMPTY_STEP3_SELECTED, step3Chips: EMPTY_STEP3_CHIPS, diff --git a/vite.config.ts b/vite.config.ts index 2ac76f4..e2c20bc 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,13 +3,19 @@ import tailwindcss from "@tailwindcss/vite"; import { defineConfig } from "vite"; import { VitePWA } from "vite-plugin-pwa"; -// https://vite.dev/config/ export default defineConfig({ server: { port: 5173, hmr: { overlay: false, }, + proxy: { + "/api": { + target: "https://api.realmatch.co.kr", + changeOrigin: true, + secure: true, + }, + }, }, logLevel: "info", build: { @@ -35,16 +41,8 @@ export default defineConfig({ start_url: "/", orientation: "portrait", icons: [ - { - src: "pwa-64x64.png", - sizes: "64x64", - type: "image/png", - }, - { - src: "pwa-192x192.png", - sizes: "192x192", - type: "image/png", - }, + { src: "pwa-64x64.png", sizes: "64x64", type: "image/png" }, + { src: "pwa-192x192.png", sizes: "192x192", type: "image/png" }, { src: "pwa-512x512.png", sizes: "512x512", From 02893431d1e5f1fa4ba5e3a9ec5e26252e6f0bc2 Mon Sep 17 00:00:00 2001 From: yoonyoungyang Date: Tue, 3 Feb 2026 03:19:56 +0900 Subject: [PATCH 3/5] =?UTF-8?q?step3=20api=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../matching/test/_shared/api/matches.api.ts | 8 +- .../_shared/builders/build-matching-result.ts | 24 ++ .../matching/test/_shared/tags/tags.api.ts | 10 +- .../matching/test/_shared/tags/tags.query.ts | 20 +- .../matching/test/_shared/tags/tags.types.ts | 13 + app/routes/matching/test/step1/route.tsx | 22 +- .../matching/test/step1/step1-content.tsx | 22 +- app/routes/matching/test/step2/route.tsx | 16 +- .../matching/test/step2/step2-content.tsx | 22 +- app/routes/matching/test/step3/route.tsx | 84 +++++-- .../matching/test/step3/step3-content.tsx | 227 +++++++----------- app/stores/matching-test.ts | 126 +++++----- vite.config.ts | 5 + 13 files changed, 318 insertions(+), 281 deletions(-) create mode 100644 app/routes/matching/test/_shared/builders/build-matching-result.ts diff --git a/app/routes/matching/test/_shared/api/matches.api.ts b/app/routes/matching/test/_shared/api/matches.api.ts index 2dacb0c..3d7364b 100644 --- a/app/routes/matching/test/_shared/api/matches.api.ts +++ b/app/routes/matching/test/_shared/api/matches.api.ts @@ -1,6 +1,6 @@ import { axiosInstance } from "../../../../../api/axios"; +import type { ApiResponse } from "../types/matches.types"; import type { - ApiResponse, MatchesRequest, MatchesResponseResult, } from "../types/matches.types"; @@ -12,5 +12,11 @@ export async function postMatches( "/api/v1/matches", payload, ); + + // 통신 자체는 성공했는데 서버가 실패 플래그 준 케이스 + if (!res.data.isSuccess) { + throw new Error(res.data.message || "매칭 요청 실패"); + } + return res.data; } diff --git a/app/routes/matching/test/_shared/builders/build-matching-result.ts b/app/routes/matching/test/_shared/builders/build-matching-result.ts new file mode 100644 index 0000000..bea2a34 --- /dev/null +++ b/app/routes/matching/test/_shared/builders/build-matching-result.ts @@ -0,0 +1,24 @@ +import type { MatchesResponseResult } from "../types/matches.types"; + +export type MatchingApiViewModel = { + userType: string; + typeTag: string[]; + brands: Array<{ + brandId: number; + brandName: string; + matchingRatio: number; + logoUrl?: string; + }>; + count: number; +}; + +export function buildMatchingResult( + api: MatchesResponseResult, +): MatchingApiViewModel { + return { + userType: api.userType, + typeTag: api.typeTag, + brands: api.highMatchingBrandList.brands, + count: api.highMatchingBrandList.count, + }; +} diff --git a/app/routes/matching/test/_shared/tags/tags.api.ts b/app/routes/matching/test/_shared/tags/tags.api.ts index d0924ac..0dd777a 100644 --- a/app/routes/matching/test/_shared/tags/tags.api.ts +++ b/app/routes/matching/test/_shared/tags/tags.api.ts @@ -1,6 +1,6 @@ import { axiosInstance } from "../../../../../api/axios"; import type { ApiResponse } from "../types/matches.types"; -import type { BeautyTags } from "./tags.types"; +import type { BeautyTags, FashionTags, ContentTags } from "./tags.types"; export async function fetchBeautyTags(): Promise { const res = await axiosInstance.get>( @@ -18,3 +18,11 @@ export async function fetchFashionTags(): Promise { throw new Error(res.data.message || "fashion 태그 조회 실패"); return res.data.result; } +export async function fetchContentTags(): Promise { + const res = await axiosInstance.get>( + "/api/v1/tags/content", + ); + if (!res.data.isSuccess) + throw new Error(res.data.message || "content 태그 조회 실패"); + return res.data.result; +} diff --git a/app/routes/matching/test/_shared/tags/tags.query.ts b/app/routes/matching/test/_shared/tags/tags.query.ts index e85c84d..c84940e 100644 --- a/app/routes/matching/test/_shared/tags/tags.query.ts +++ b/app/routes/matching/test/_shared/tags/tags.query.ts @@ -1,14 +1,21 @@ import { useQuery } from "@tanstack/react-query"; -import { fetchBeautyTags } from "./tags.api"; -import type { BeautyTags } from "./tags.types"; +import { + fetchBeautyTags, + fetchFashionTags, + fetchContentTags, +} from "./tags.api"; +import type { BeautyTags, FashionTags, ContentTags } from "./tags.types"; + +const STALE = 1000 * 60 * 10; export function useBeautyTags() { return useQuery({ queryKey: ["tags", "beauty"], queryFn: fetchBeautyTags, - staleTime: 1000 * 60 * 10, + staleTime: STALE, }); } + export function useFashionTags() { return useQuery({ queryKey: ["tags", "fashion"], @@ -16,3 +23,10 @@ export function useFashionTags() { staleTime: STALE, }); } +export function useContentTags() { + return useQuery({ + queryKey: ["tags", "content"], + queryFn: fetchContentTags, + staleTime: STALE, + }); +} diff --git a/app/routes/matching/test/_shared/tags/tags.types.ts b/app/routes/matching/test/_shared/tags/tags.types.ts index 807fdca..6ab0583 100644 --- a/app/routes/matching/test/_shared/tags/tags.types.ts +++ b/app/routes/matching/test/_shared/tags/tags.types.ts @@ -12,3 +12,16 @@ export type FashionTags = { tagType: string; categories: Record; }; + +export type ContentTags = { + viewerGenders: TagItem[]; + viewerAges: TagItem[]; + avgVideoLengths: TagItem[]; + avgVideoViews: TagItem[]; + + formats: TagItem[]; + categories: TagItem[]; + tones: TagItem[]; + involvements: TagItem[]; + usageRanges: TagItem[]; +}; diff --git a/app/routes/matching/test/step1/route.tsx b/app/routes/matching/test/step1/route.tsx index 5825cd2..8e1cf2d 100644 --- a/app/routes/matching/test/step1/route.tsx +++ b/app/routes/matching/test/step1/route.tsx @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { useNavigate } from "react-router"; +import { useNavigate } from "react-router-dom"; import MatchingTestContent from "./step1-content"; import { useBeautyTags } from "../_shared/tags/tags.query"; import { @@ -8,16 +8,12 @@ import { } from "../../../../stores/matching-test"; import type { TagItem } from "../_shared/tags/tags.types"; -const SECTIONS: Array<{ key: SectionKey; title: string; max: number }> = [ - { key: "style", title: "관심 스타일", max: Number.POSITIVE_INFINITY }, - { key: "function", title: "관심 기능", max: Number.POSITIVE_INFINITY }, - { key: "skinType", title: "피부 타입", max: Number.POSITIVE_INFINITY }, - { key: "skinTone", title: "피부 밝기", max: Number.POSITIVE_INFINITY }, - { - key: "makeupStyle", - title: "메이크업 스타일", - max: Number.POSITIVE_INFINITY, - }, +const SECTIONS: Array<{ key: SectionKey; title: string }> = [ + { key: "style", title: "관심 스타일" }, + { key: "function", title: "관심 기능" }, + { key: "skinType", title: "피부 타입" }, + { key: "skinTone", title: "피부 밝기" }, + { key: "makeupStyle", title: "메이크업 스타일" }, ]; type ItemsBySection = Record; @@ -47,7 +43,6 @@ const pickCategory = ( export default function MatchingTestStep1Page() { const navigate = useNavigate(); - const { data, isLoading, error } = useBeautyTags(); const selected = useMatchingTestStore((s) => s.selected); @@ -93,9 +88,8 @@ export default function MatchingTestStep1Page() { errorText={errorText} sections={SECTIONS} itemsBySection={itemsBySection} - selected={selected} isSelected={isSelected} - onToggle={(section, id, max) => toggleStep1(section, id, max)} + onToggle={(section, id) => toggleStep1(section, id)} canGoNext={canGoNext} onBack={() => navigate("/")} onNext={() => navigate("/matching/test/step2")} diff --git a/app/routes/matching/test/step1/step1-content.tsx b/app/routes/matching/test/step1/step1-content.tsx index ae748fb..d3aafac 100644 --- a/app/routes/matching/test/step1/step1-content.tsx +++ b/app/routes/matching/test/step1/step1-content.tsx @@ -1,22 +1,18 @@ import MatchingTestTopBar from "../components/MatchingTestHeader"; import SelectChip from "../components/SelectChip"; import Button from "../../../../components/common/Button"; -import type { - SectionKey, - SelectedState, -} from "../../../../stores/matching-test"; +import type { SectionKey } from "../../../../stores/matching-test"; import type { TagItem } from "../_shared/tags/tags.types"; type Props = { isLoading: boolean; errorText: string | null; - sections: Array<{ key: SectionKey; title: string; max: number }>; + sections: Array<{ key: SectionKey; title: string }>; itemsBySection: Record; - selected: SelectedState; isSelected: (section: SectionKey, id: number) => boolean; - onToggle: (section: SectionKey, id: number, max: number) => void; + onToggle: (section: SectionKey, id: number) => void; canGoNext: boolean; onBack: () => void; @@ -28,7 +24,6 @@ export default function MatchingTestContent({ errorText, sections, itemsBySection, - selected, isSelected, onToggle, canGoNext, @@ -61,14 +56,13 @@ export default function MatchingTestContent({

- 관심 있는 뷰티 특성
+ 관심 있는 뷰티 특성을 +
모두 선택해주세요

{sections.map((section) => { const items = itemsBySection[section.key] ?? []; - const sectionSelectedCount = selected[section.key].length; - const sectionLimitReached = sectionSelectedCount >= section.max; return (
@@ -79,17 +73,13 @@ export default function MatchingTestContent({
{items.map((tag) => { const checked = isSelected(section.key, tag.id); - const disabled = !checked && sectionLimitReached; return ( - onToggle(section.key, tag.id, section.max) - } + onToggle={() => onToggle(section.key, tag.id)} /> ); })} diff --git a/app/routes/matching/test/step2/route.tsx b/app/routes/matching/test/step2/route.tsx index c9a28b3..0828c98 100644 --- a/app/routes/matching/test/step2/route.tsx +++ b/app/routes/matching/test/step2/route.tsx @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { useNavigate } from "react-router"; +import { useNavigate } from "react-router-dom"; import MatchingTestStep2Content from "./step2-content"; import { useMatchingTestStore, @@ -8,10 +8,10 @@ import { import { useFashionTags } from "../_shared/tags/tags.query"; import type { TagItem } from "../_shared/tags/tags.types"; -const SECTIONS: Array<{ key: Step2SectionKey; title: string; max: number }> = [ - { key: "fashionStyle", title: "관심 스타일", max: 5 }, - { key: "interestItem", title: "관심 아이템/분야", max: 5 }, - { key: "brandType", title: "관심 브랜드 종류", max: 5 }, +const SECTIONS: Array<{ key: Step2SectionKey; title: string }> = [ + { key: "fashionStyle", title: "관심 스타일" }, + { key: "interestItem", title: "관심 아이템/분야" }, + { key: "brandType", title: "관심 브랜드 종류" }, // ✅ 표시 타이틀 ]; type ItemsBySection = Record; @@ -63,9 +63,12 @@ export default function MatchingTestStep2Page() { "관심 아이템", "아이템/분야", "아이템", + "관심 분야", + "분야", ]), brandType: pickCategory(categories, [ "관심 브랜드 종류", + "선호 브랜드 종류", // ✅ API 응답 키 대응 "브랜드 종류", "브랜드 타입", "브랜드", @@ -82,6 +85,7 @@ export default function MatchingTestStep2Page() { const canGoNext = useMemo(() => { const chipsOk = computedSections.every((s) => selected[s.key].length >= 1); + const bodyOk = fashionBody.heightTag !== null && fashionBody.weightTypeTag !== null && @@ -101,7 +105,7 @@ export default function MatchingTestStep2Page() { itemsBySection={itemsBySection} selected={selected} isSelected={isSelected} - onToggle={(section, id, max) => toggle(section, id, max)} + onToggle={(section, id) => toggle(section, id)} fashionBody={fashionBody} onSetFashionBody={setFashionBody} canGoNext={canGoNext} diff --git a/app/routes/matching/test/step2/step2-content.tsx b/app/routes/matching/test/step2/step2-content.tsx index c935712..b94dfec 100644 --- a/app/routes/matching/test/step2/step2-content.tsx +++ b/app/routes/matching/test/step2/step2-content.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useState } from "react"; import type { Step2SectionKey, Step2SelectedState, @@ -18,12 +18,12 @@ type Props = { isLoading: boolean; errorText: string | null; - sections: Array<{ key: Step2SectionKey; title: string; max: number }>; + sections: Array<{ key: Step2SectionKey; title: string }>; itemsBySection: Record; selected: Step2SelectedState; isSelected: (section: Step2SectionKey, id: number) => boolean; - onToggle: (section: Step2SectionKey, id: number, max: number) => void; + onToggle: (section: Step2SectionKey, id: number) => void; // ✅ max 제거 fashionBody: FashionBodyTags; onSetFashionBody: (key: keyof FashionBodyTags, id: number | null) => void; @@ -56,7 +56,6 @@ export default function MatchingTestStep2Content({ errorText, sections, itemsBySection, - selected, isSelected, onToggle, fashionBody, @@ -69,17 +68,6 @@ export default function MatchingTestStep2Content({ const open = (t: SheetType) => setSheet(t); const close = () => setSheet(null); - const chipDisabled = useMemo(() => { - const getMax = (k: Step2SectionKey) => - sections.find((s) => s.key === k)?.max ?? 5; - - return { - fashionStyle: selected.fashionStyle.length >= getMax("fashionStyle"), - interestItem: selected.interestItem.length >= getMax("interestItem"), - brandType: selected.brandType.length >= getMax("brandType"), - } satisfies Record; - }, [selected, sections]); - const heightText = fashionBody.heightTag === null ? "" : `${fashionBody.heightTag} cm`; const bottomText = @@ -134,14 +122,12 @@ export default function MatchingTestStep2Content({ {items.map((tag) => { const sel = isSelected(sec.key, tag.id); - const disabled = !sel && (chipDisabled[sec.key] ?? false); return ( onToggle(sec.key, tag.id, sec.max)} + onToggle={() => onToggle(sec.key, tag.id)} // ✅ 무제한 /> ); })} diff --git a/app/routes/matching/test/step3/route.tsx b/app/routes/matching/test/step3/route.tsx index 3d1e8e4..a27043f 100644 --- a/app/routes/matching/test/step3/route.tsx +++ b/app/routes/matching/test/step3/route.tsx @@ -1,40 +1,69 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import MatchingTestStep3Content from "./step3-content"; -// ✅ 스토어/유틸/타입/훅 경로는 프로젝트에 맞게 조정 -import { useMatchingTestStore } from "../../../../stores/matching-test"; +import { + useMatchingTestStore, + type Step3SelectKey, + type Step3ChipKey, +} from "../../../../stores/matching-test"; + import { useContentTags } from "../_shared/tags/tags.query"; -import { buildMatchPayload } from "../_shared/builders/build-match-payload"; import { postMatches } from "../_shared/api/matches.api"; +import { buildMatchPayload } from "../_shared/builders/build-match-payload"; export default function MatchingTestStep3Page() { const navigate = useNavigate(); - // store const snsUrl = useMatchingTestStore((s) => s.snsUrl); const setSnsUrl = useMatchingTestStore((s) => s.setSnsUrl); const step3Selected = useMatchingTestStore((s) => s.step3Selected); - const onToggleSelect = useMatchingTestStore((s) => s.onToggleSelect); + const toggleStep3Select = useMatchingTestStore((s) => s.toggleStep3Select); + const setSingleStep3Select = useMatchingTestStore( + (s) => s.setSingleStep3Select, + ); const step3Chips = useMatchingTestStore((s) => s.step3Chips); - const onToggleChip = useMatchingTestStore((s) => s.onToggleChip); - - const canGoNext = useMatchingTestStore((s) => s.canGoNextStep3); + const toggleStep3Chip = useMatchingTestStore((s) => s.toggleStep3Chip); - // tags query - const { - data: contentTags, - isLoading: tagsLoading, - error: tagsError, - } = useContentTags(); + const { data, isLoading, error } = useContentTags(); + const tagsError = error ? error.message : null; const [submitting, setSubmitting] = useState(false); const [submitError, setSubmitError] = useState(null); - const isValidInstagramUrl = /^https?:\/\/(www\.)?instagram\.com\/.+/i.test(snsUrl.trim()); + const isValidInstagramUrl = useMemo( + () => /^https?:\/\/(www\.)?instagram\.com\/.+/i.test(snsUrl.trim()), + [snsUrl], + ); + + const canGoNext = useMemo(() => { + const snsOk = isValidInstagramUrl; + + const genderOk = step3Selected.gender.length >= 1; + const ageOk = step3Selected.ageGroup.length >= 1; + const lenOk = step3Selected.videoLength.length >= 1; + const viewsOk = step3Selected.views.length >= 1; + + const typeOk = step3Chips.contentType.length >= 1; + const toneOk = step3Chips.contentTone.length >= 1; + const invOk = step3Chips.contentHardness.length >= 1; + const rangeOk = step3Chips.editingRange.length >= 1; + + return ( + snsOk && + genderOk && + ageOk && + lenOk && + viewsOk && + typeOk && + toneOk && + invOk && + rangeOk + ); + }, [isValidInstagramUrl, step3Selected, step3Chips]); const handleSubmit = async () => { setSubmitting(true); @@ -45,13 +74,16 @@ export default function MatchingTestStep3Page() { const res = await postMatches(payload); if (!res.isSuccess) { - setSubmitError(res.message); + setSubmitError(res.message || "제출 실패"); return; } - navigate("/matching/test/result"); + // ✅ 성공하면 무조건 결과 화면으로 이동 + navigate("/matching/test/result", { replace: true }); } catch (e) { - setSubmitError(e instanceof Error ? e.message : "제출 중 오류가 발생했어요."); + setSubmitError( + e instanceof Error ? e.message : "제출 중 오류가 발생했어요.", + ); } finally { setSubmitting(false); } @@ -59,17 +91,21 @@ export default function MatchingTestStep3Page() { return ( + toggleStep3Select(key, id) + } + onSelectSingle={(key: Step3SelectKey, id: number) => + setSingleStep3Select(key, id) + } step3Chips={step3Chips} - onToggleChip={onToggleChip} + onToggleChip={(key: Step3ChipKey, id: number) => toggleStep3Chip(key, id)} canGoNext={canGoNext} submitting={submitting} submitError={submitError} diff --git a/app/routes/matching/test/step3/step3-content.tsx b/app/routes/matching/test/step3/step3-content.tsx index 4211047..8d8d0b3 100644 --- a/app/routes/matching/test/step3/step3-content.tsx +++ b/app/routes/matching/test/step3/step3-content.tsx @@ -15,28 +15,9 @@ import SelectSheet from "../components/SelectSheet"; import CheckDropdown from "../components/CheckDropdown"; import Button from "../../../../components/common/Button"; -/** - * ✅ 너 tags.types.ts 구조에 맞춰 contentTags 타입을 맞춰줘야 함 - * 여기서는 "step3에서 필요한 최소 형태"만 정의 - */ -type TagItem = { id: number; name: string }; - -type ContentTags = { - // 콘텐츠 성격(칩) - categories: TagItem[]; // -> typeTags - tones: TagItem[]; // -> toneTags - involvements: TagItem[]; // -> prefferedInvolvementTags - usageRanges: TagItem[]; // -> prefferedCoverageTags - - // 시청자/평균지표(드롭다운) - genderTags: TagItem[]; - ageTags: TagItem[]; - videoLengthTags: TagItem[]; - videoViewsTags: TagItem[]; -}; +import type { ContentTags, TagItem } from "../_shared/tags/tags.types"; type Props = { - // ✅ route에서 내려줌 tagsLoading: boolean; tagsError: string | null; contentTags: ContentTags | null; @@ -47,6 +28,7 @@ type Props = { step3Selected: Step3SelectedState; onToggleSelect: (key: Step3SelectKey, id: number) => void; + onSelectSingle: (key: Step3SelectKey, id: number) => void; step3Chips: Step3ChipsState; onToggleChip: (key: Step3ChipKey, id: number) => void; @@ -61,6 +43,18 @@ type Props = { type Sheet = null | "snsUrl" | "gender" | "ageGroup" | "videoLength" | "views"; +const namesByIds = (ids: number[], options: TagItem[]) => + options.filter((o) => ids.includes(o.id)).map((o) => o.name); + +const joinNames = (ids: number[], options: TagItem[]) => + namesByIds(ids, options).join("\n"); + +const idByName = (name: string, options: TagItem[]) => + options.find((o) => o.name === name)?.id; + +const nameById = (id: number | undefined, options: TagItem[]) => + id == null ? "" : (options.find((o) => o.id === id)?.name ?? ""); + export default function MatchingTestStep3Content({ tagsLoading, tagsError, @@ -69,13 +63,18 @@ export default function MatchingTestStep3Content({ snsUrl, onSnsUrlChange, isValidInstagramUrl, + step3Selected, onToggleSelect, + onSelectSingle, + step3Chips, onToggleChip, + canGoNext, submitting = false, submitError = null, + onBack, onNext, }: Props) { @@ -83,69 +82,54 @@ export default function MatchingTestStep3Content({ const open = (s: Sheet) => setSheet(s); const close = () => setSheet(null); - // 태그가 아직 없으면 빈 배열 - const genderOptions = contentTags?.genderTags ?? []; - const ageOptions = contentTags?.ageTags ?? []; - const videoLenOptions = contentTags?.videoLengthTags ?? []; - const viewsOptions = contentTags?.videoViewsTags ?? []; + const genderOptions = contentTags?.viewerGenders ?? []; + const ageOptions = contentTags?.viewerAges ?? []; + const videoLenOptions = contentTags?.avgVideoLengths ?? []; + const viewsOptions = contentTags?.avgVideoViews ?? []; const typeOptions = contentTags?.categories ?? []; const toneOptions = contentTags?.tones ?? []; const involvementOptions = contentTags?.involvements ?? []; const coverageOptions = contentTags?.usageRanges ?? []; - // id[] -> 표시용 name들 - const namesByIds = (ids: number[], options: TagItem[]) => - options.filter((o) => ids.includes(o.id)).map((o) => o.name); - - const joinNames = (ids: number[], options: TagItem[]) => - namesByIds(ids, options).join("\n"); - - // name -> id - const idByName = (name: string, options: TagItem[]) => - options.find((o) => o.name === name)?.id; - - // 단일 선택 sheet 값(SelectSheet는 보통 string 하나) - const lenValue = - namesByIds(step3Selected.videoLength, videoLenOptions)[0] ?? ""; - const viewsValue = namesByIds(step3Selected.views, viewsOptions)[0] ?? ""; - - // 칩 max 5개 제한 - const max = 5; - const chipDisabled = useMemo( - () => ({ - contentType: step3Chips.contentType.length >= max, - contentTone: step3Chips.contentTone.length >= max, - contentHardness: step3Chips.contentHardness.length >= max, - editingRange: step3Chips.editingRange.length >= max, - }), - [step3Chips], + const genderValue = useMemo( + () => joinNames(step3Selected.gender, genderOptions), + [step3Selected.gender, genderOptions], + ); + const ageValue = useMemo( + () => joinNames(step3Selected.ageGroup, ageOptions), + [step3Selected.ageGroup, ageOptions], + ); + + const lenValue = useMemo( + () => nameById(step3Selected.videoLength[0], videoLenOptions), + [step3Selected.videoLength, videoLenOptions], + ); + const viewsValue = useMemo( + () => nameById(step3Selected.views[0], viewsOptions), + [step3Selected.views, viewsOptions], ); return (
- {/* 태그 로딩/에러 */} {tagsLoading ? ( -
+
태그를 불러오는 중...
) : tagsError ? ( -
{tagsError}
+
{tagsError}
) : null}
-

- 콘텐츠 특성을 모두 선택해주세요 +

+ 콘텐츠 특성을 선택해주세요

- {/* SNS */}
-
SNS 정보
-
- SNS 주소를 입력해주세요 -
+
SNS 정보
+
SNS 주소를 입력해주세요
-
+
주 시청자 정보를 선택해주세요
open("gender")} /> open("ageGroup")} />
-
+
평균 영상 길이 및 조회수를 선택해주세요
@@ -193,84 +177,59 @@ export default function MatchingTestStep3Content({
- {/* 칩: 콘텐츠 종류(typeTags) */}
- {typeOptions.map((t) => { - const sel = step3Chips.contentType.includes(t.id); - const disabled = !sel && chipDisabled.contentType; - return ( - onToggleChip("contentType", t.id)} - /> - ); - })} + {typeOptions.map((t) => ( + onToggleChip("contentType", t.id)} + /> + ))}
- {/* 칩: 콘텐츠 톤(toneTags) */}
- {toneOptions.map((t) => { - const sel = step3Chips.contentTone.includes(t.id); - const disabled = !sel && chipDisabled.contentTone; - return ( - onToggleChip("contentTone", t.id)} - /> - ); - })} + {toneOptions.map((t) => ( + onToggleChip("contentTone", t.id)} + /> + ))}
- {/* 칩: 관여도(involvementTags) */}
- {involvementOptions.map((t) => { - const sel = step3Chips.contentHardness.includes(t.id); - const disabled = !sel && chipDisabled.contentHardness; - return ( - onToggleChip("contentHardness", t.id)} - /> - ); - })} + {involvementOptions.map((t) => ( + onToggleChip("contentHardness", t.id)} + /> + ))}
- {/* 칩: 활용 범위(coverageTags) */}
- {coverageOptions.map((t) => { - const sel = step3Chips.editingRange.includes(t.id); - const disabled = !sel && chipDisabled.editingRange; - return ( - onToggleChip("editingRange", t.id)} - /> - ); - })} + {coverageOptions.map((t) => ( + onToggleChip("editingRange", t.id)} + /> + ))}
- {/* CTA */}
- {/* 버튼 밀어내기 */} -
); })} -
-
체형 정보
- -
-
키를 입력해주세요
-
- open("height")} - /> -
+ {/* 체형 정보(UI 유지) */} +
+

+ 체형 정보 +

+ +
키를 입력해주세요
+
+ { + setHeightInput(heightValue.replace("cm", "")); + open("height"); + }} + />
-
-
- 체형을 선택해주세요 -
-
- open("bodyShape")} - /> -
+
+ 체형을 선택해주세요 +
+
+ open("weightType")} + />
-
-
- 평소 입는 옷 사이즈를 선택해주세요 -
-
- open("topSize")} - /> - open("bottomSize")} - /> -
+
+ 평소 입는 옷 사이즈를 선택해주세요
-
-
+
+ open("topSize")} + /> + { + setBottomInput(bottomValue); + open("bottomSize"); + }} + /> +
+
+
-
+
+ {/* ✅ 키 입력: 숫자 -> "180cm" -> id 저장 */} {sheet === "height" ? ( { - const n = Number(v.replace(/[^\d]/g, "")); - onSetFashionBody( - "heightTag", - Number.isFinite(n) && n > 0 ? n : null, - ); + value={heightInput} + placeholder="예: 180" + onChange={setHeightInput} + doneDisabled={!/^\d+$/.test(heightInput.trim())} + onDone={() => { + const id = idByNumericInput(heightInput, heightOptions, "cm"); + if (id != null) onSetFashionBody("heightTag", id); + close(); }} - doneDisabled={fashionBody.heightTag === null} - onDone={close} - suffix="cm" + helperText="예: 180" + errorText={ + heightInput.trim().length > 0 && !/^\d+$/.test(heightInput.trim()) + ? "숫자만 입력해주세요." + : undefined + } /> ) : null} + {/* ✅ 하의 입력: 숫자 -> "27" -> id 저장 */} {sheet === "bottomSize" ? ( { - const n = Number(v.replace(/[^\d]/g, "")); - onSetFashionBody( - "bottomSizeTag", - Number.isFinite(n) && n > 0 ? n : null, - ); + value={bottomInput} + placeholder="예: 27" + onChange={setBottomInput} + doneDisabled={!/^\d+$/.test(bottomInput.trim())} + onDone={() => { + const id = idByNumericInput(bottomInput, bottomSizeOptions, ""); + if (id != null) onSetFashionBody("bottomSizeTag", id); + close(); }} - doneDisabled={fashionBody.bottomSizeTag === null} - onDone={close} - suffix="in" + helperText="예: 27" + errorText={ + bottomInput.trim().length > 0 && !/^\d+$/.test(bottomInput.trim()) + ? "숫자만 입력해주세요." + : undefined + } /> ) : null} - {sheet === "bodyShape" ? ( + {/* ✅ 체형 선택: 선택 name -> id 저장 */} + {sheet === "weightType" ? ( o.label)} - value={bodyShapeText} - onSelect={(label) => { - const hit = BODY_SHAPE_OPTIONS.find((o) => o.label === label); - onSetFashionBody("weightTypeTag", hit ? hit.id : null); + options={weightTypeOptions.map((x) => x.name)} + value={weightValue} + onSelect={(name) => { + const id = idByName(name, weightTypeOptions); + if (id != null) onSetFashionBody("weightTypeTag", id); close(); }} /> ) : null} + {/* ✅ 상의 선택: 선택 name -> id 저장 */} {sheet === "topSize" ? ( o.label)} - value={topSizeText} - onSelect={(label) => { - const hit = TOP_SIZE_OPTIONS.find((o) => o.label === label); - onSetFashionBody("topSizeTag", hit ? hit.id : null); + options={topSizeOptions.map((x) => x.name)} + value={topSizeValue} + onSelect={(name) => { + const id = idByName(name, topSizeOptions); + if (id != null) onSetFashionBody("topSizeTag", id); close(); }} /> @@ -275,22 +338,3 @@ export default function MatchingTestStep2Content({
); } - -function Section({ - title, - children, -}: { - title: string; - children: React.ReactNode; -}) { - return ( -
-
{title}
-
{children}
-
- ); -} - -function ChipRow({ children }: { children: React.ReactNode }) { - return
{children}
; -} diff --git a/app/routes/matching/test/step3/route.tsx b/app/routes/matching/test/step3/route.tsx index a27043f..5112621 100644 --- a/app/routes/matching/test/step3/route.tsx +++ b/app/routes/matching/test/step3/route.tsx @@ -1,12 +1,13 @@ import { useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; +import axios from "axios"; import MatchingTestStep3Content from "./step3-content"; import { useMatchingTestStore, - type Step3SelectKey, type Step3ChipKey, + type Step3SelectKey, } from "../../../../stores/matching-test"; import { useContentTags } from "../_shared/tags/tags.query"; @@ -71,21 +72,18 @@ export default function MatchingTestStep3Page() { try { const payload = buildMatchPayload(); - const res = await postMatches(payload); + console.log("[matches payload]", payload); - if (!res.isSuccess) { - setSubmitError(res.message || "제출 실패"); - return; - } + const res = await postMatches(payload); + console.log("[matches response]", res); - // ✅ 성공하면 무조건 결과 화면으로 이동 navigate("/matching/test/result", { replace: true }); } catch (e) { - setSubmitError( - e instanceof Error ? e.message : "제출 중 오류가 발생했어요.", - ); - } finally { - setSubmitting(false); + if (axios.isAxiosError(e)) { + console.log("[matches error status]", e.response?.status); + console.log("[matches error body]", e.response?.data); + } + throw e; } }; diff --git a/app/stores/matching-test.ts b/app/stores/matching-test.ts index 0c0205b..a10a472 100644 --- a/app/stores/matching-test.ts +++ b/app/stores/matching-test.ts @@ -8,7 +8,6 @@ export type SectionKey = | "makeupStyle"; export type Step2SectionKey = "fashionStyle" | "interestItem" | "brandType"; - export type Step3SelectKey = "gender" | "ageGroup" | "videoLength" | "views"; export type Step3ChipKey = @@ -23,6 +22,7 @@ export type Step2SelectedState = Record; export type Step3SelectedState = Record; export type Step3ChipsState = Record; +// ✅ 전부 "태그 id"를 저장 export type FashionBodyTags = { heightTag: number | null; weightTypeTag: number | null; @@ -75,7 +75,7 @@ type MatchingTestStore = { toggleStep2: (section: Step2SectionKey, id: number) => void; fashionBody: FashionBodyTags; - setFashionBody: (key: keyof FashionBodyTags, id: number | null) => void; + setFashionBody: (key: keyof FashionBodyTags, tagId: number | null) => void; step3Selected: Step3SelectedState; toggleStep3Select: (key: Step3SelectKey, id: number) => void; @@ -86,7 +86,6 @@ type MatchingTestStore = { snsUrl: string; setSnsUrl: (v: string) => void; - isValidInstagramUrl: () => boolean; resetAll: () => void; }; @@ -129,9 +128,9 @@ export const useMatchingTestStore = create((set, get) => ({ fashionBody: EMPTY_FASHION_BODY, - setFashionBody: (key, id) => { + setFashionBody: (key, tagId) => { const prev = get().fashionBody; - set({ fashionBody: { ...prev, [key]: id } }); + set({ fashionBody: { ...prev, [key]: tagId } }); }, step3Selected: EMPTY_STEP3_SELECTED, @@ -171,8 +170,6 @@ export const useMatchingTestStore = create((set, get) => ({ snsUrl: "", setSnsUrl: (v) => set({ snsUrl: v }), - isValidInstagramUrl: () => - /^https?:\/\/(www\.)?instagram\.com\/.+/i.test(get().snsUrl.trim()), resetAll: () => set({ From dac8526c991e5cad22ea62b36a665e2148e92ac6 Mon Sep 17 00:00:00 2001 From: yoonyoungyang Date: Tue, 3 Feb 2026 05:11:12 +0900 Subject: [PATCH 5/5] =?UTF-8?q?=EB=A7=A4=EC=B9=AD=20=EA=B2=80=EC=82=AC=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=EA=B9=8C=EC=A7=80=20API=20=EC=97=B0=EA=B2=B0?= =?UTF-8?q?=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_shared/builders/build-match-payload.ts | 3 - .../test/_shared/types/matching-result.ts | 22 +++++++ .../test/result/matching-result-content.tsx | 66 ++++++++++++------- app/routes/matching/test/step2/route.tsx | 1 - .../matching/test/step2/step2-content.tsx | 12 ---- app/routes/matching/test/step3/route.tsx | 24 ++++++- app/stores/matching-test.ts | 1 - 7 files changed, 87 insertions(+), 42 deletions(-) create mode 100644 app/routes/matching/test/_shared/types/matching-result.ts diff --git a/app/routes/matching/test/_shared/builders/build-match-payload.ts b/app/routes/matching/test/_shared/builders/build-match-payload.ts index e3d7806..4897477 100644 --- a/app/routes/matching/test/_shared/builders/build-match-payload.ts +++ b/app/routes/matching/test/_shared/builders/build-match-payload.ts @@ -33,7 +33,6 @@ export function buildMatchPayload(): MatchesRequest { const step3Sel = s.step3Selected; const step3Chips = s.step3Chips; - // ✅ beauty: 일부는 단일 number로 보내야 함 const interestStyleTags = requireNonEmpty( step1.style, "beauty.interestStyleTags", @@ -50,7 +49,6 @@ export function buildMatchPayload(): MatchesRequest { "beauty.makeupStyleTags", ); - // ✅ fashion: 전부 number (null 금지) const heightTag = requireNumber(body.heightTag, "fashion.heightTag"); const weightTypeTag = requireNumber( body.weightTypeTag, @@ -62,7 +60,6 @@ export function buildMatchPayload(): MatchesRequest { "fashion.bottomSizeTag", ); - // ✅ content const url = s.snsUrl.trim(); if (!url) throw new Error("content.sns.url 값이 비어있습니다."); diff --git a/app/routes/matching/test/_shared/types/matching-result.ts b/app/routes/matching/test/_shared/types/matching-result.ts new file mode 100644 index 0000000..484570c --- /dev/null +++ b/app/routes/matching/test/_shared/types/matching-result.ts @@ -0,0 +1,22 @@ +export type ApiResponse = { + isSuccess: boolean; + code: string; + message: string; + result: T; +}; + +export type Brand = { + brandId: number; + brandName: string; + logoUrl?: string; + matchingRatio: number; +}; + +export type MatchingResultApi = { + userType: string; + typeTag: string[]; + highMatchingBrandList: { + count: number; + brands: Brand[]; + }; +}; diff --git a/app/routes/matching/test/result/matching-result-content.tsx b/app/routes/matching/test/result/matching-result-content.tsx index 13c047c..8785236 100644 --- a/app/routes/matching/test/result/matching-result-content.tsx +++ b/app/routes/matching/test/result/matching-result-content.tsx @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { useNavigate, useSearchParams } from "react-router-dom"; +import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; import { useMatchResultStore } from "../../../../stores/matching-result"; import MainIcon from "../../../../assets/MainIcon.svg"; import Button from "../../../../components/common/Button"; @@ -11,19 +11,49 @@ type Brand = { logoUrl?: string; }; +type ApiResult = { + userType: string; + typeTag: string[]; + highMatchingBrandList: { + count: number; + brands: Brand[]; + }; +}; + +type LocationState = { + apiResult?: ApiResult; +}; + export default function MatchingResultContent() { const navigate = useNavigate(); + const location = useLocation() as { state: LocationState | null }; const [searchParams] = useSearchParams(); const setResult = useMatchResultStore((s) => s.setResult); const data = useMemo(() => { + const apiResult = location.state?.apiResult; + + // userName은 아직 API에 없으므로 기존대로 query/default 사용 const userName = searchParams.get("userName") ?? "비비"; - const userType = searchParams.get("userType") ?? "섬세한 설계자"; + if (apiResult) { + const userType = apiResult.userType; + const tags = apiResult.typeTag.slice(0, 3); + const brands = [...apiResult.highMatchingBrandList.brands] + .sort((a, b) => b.matchingRatio - a.matchingRatio) + .slice(0, 3); + + return { userName, userType, tags, brands }; + } + + // fallback (직접 진입/새로고침으로 state 사라진 경우) + const userType = searchParams.get("userType") ?? "섬세한 설계자"; const tags = searchParams .get("typeTag") ?.split(",") - .map((v) => v.trim()) ?? ["기획중심", "구조탄탄", "디테일중심"]; + .map((v) => v.trim()) + .filter(Boolean) + .slice(0, 3) ?? ["기획중심", "구조탄탄", "디테일중심"]; const brands: Brand[] = [ { brandId: 1, brandName: "beplain", matchingRatio: 98 }, @@ -31,8 +61,8 @@ export default function MatchingResultContent() { { brandId: 3, brandName: "ma:nyo", matchingRatio: 91 }, ]; - return { userName, userType, tags: tags.slice(0, 3), brands }; - }, [searchParams]); + return { userName, userType, tags, brands }; + }, [location.state, searchParams]); const onStart = () => { setResult({ @@ -41,11 +71,11 @@ export default function MatchingResultContent() { summary: { userName: data.userName, traits: { - beauty: data.tags[0], - style: data.tags[1], - content: data.tags[2], + beauty: data.tags[0] ?? "", + style: data.tags[1] ?? "", + content: data.tags[2] ?? "", }, - recommendedBrand: data.brands[0].brandName, + recommendedBrand: data.brands[0]?.brandName ?? "", }, }); @@ -72,12 +102,8 @@ export default function MatchingResultContent() {
{data.tags.map((tag) => ( #{tag} @@ -100,13 +126,7 @@ export default function MatchingResultContent() {
{data.brands.map((b) => (
-
+
{b.logoUrl ? (
- + {b.brandName} diff --git a/app/routes/matching/test/step2/route.tsx b/app/routes/matching/test/step2/route.tsx index 14f6ee5..126fa31 100644 --- a/app/routes/matching/test/step2/route.tsx +++ b/app/routes/matching/test/step2/route.tsx @@ -86,7 +86,6 @@ export default function MatchingTestStep2Page() { const canGoNext = useMemo(() => { const chipsOk = computedSections.every((s) => selected[s.key].length >= 1); - // ✅ 너가 정한 정책대로: 키/하의는 필수, 체형/상의는 "선택" const bodyOk = fashionBody.heightTag !== null && fashionBody.bottomSizeTag !== null; diff --git a/app/routes/matching/test/step2/step2-content.tsx b/app/routes/matching/test/step2/step2-content.tsx index 16fcdec..789ce13 100644 --- a/app/routes/matching/test/step2/step2-content.tsx +++ b/app/routes/matching/test/step2/step2-content.tsx @@ -29,7 +29,6 @@ type Props = { fashionBody: FashionBodyTags; onSetFashionBody: (key: keyof FashionBodyTags, id: number | null) => void; - // ✅ 패션 태그 전체 categories (키/체형/상의/하의 찾기) fashionCategories: Record; canGoNext: boolean; @@ -66,9 +65,6 @@ const idByName = (name: string, options: TagItem[]) => const nameById = (id: number | null, options: TagItem[]) => id == null ? "" : (options.find((o) => o.id === id)?.name ?? ""); -// ✅ 숫자 입력을 태그 name으로 매핑해서 id 찾기 -// height: "180" + "cm" => "180cm" -// bottom: "27" + "" => "27" const idByNumericInput = (raw: string, options: TagItem[], suffix: string) => { const v = raw.trim(); if (!/^\d+$/.test(v)) return null; @@ -96,7 +92,6 @@ export default function MatchingTestStep2Content({ const open = (s: Sheet) => setSheet(s); const close = () => setSheet(null); - // 입력용 state (키/하의만) const [heightInput, setHeightInput] = useState(""); const [bottomInput, setBottomInput] = useState(""); @@ -117,7 +112,6 @@ export default function MatchingTestStep2Content({ [fashionCategories], ); - // 표시값 (store는 id 저장, 화면은 name 표시) const heightValue = useMemo( () => nameById(fashionBody.heightTag, heightOptions), [fashionBody.heightTag, heightOptions], @@ -166,7 +160,6 @@ export default function MatchingTestStep2Content({ 모두 선택해주세요 - {/* 칩 섹션들(기존 유지) */} {sections.map((section) => { const items = itemsBySection[section.key] ?? []; @@ -194,7 +187,6 @@ export default function MatchingTestStep2Content({ ); })} - {/* 체형 정보(UI 유지) */}

체형 정보 @@ -260,7 +252,6 @@ export default function MatchingTestStep2Content({

- {/* ✅ 키 입력: 숫자 -> "180cm" -> id 저장 */} {sheet === "height" ? ( ) : null} - {/* ✅ 하의 입력: 숫자 -> "27" -> id 저장 */} {sheet === "bottomSize" ? ( ) : null} - {/* ✅ 체형 선택: 선택 name -> id 저장 */} {sheet === "weightType" ? ( ) : null} - {/* ✅ 상의 선택: 선택 name -> id 저장 */} {sheet === "topSize" ? ( ).message; + return typeof msg === "string" ? msg : null; +} + export default function MatchingTestStep3Page() { const navigate = useNavigate(); @@ -77,13 +84,26 @@ export default function MatchingTestStep3Page() { const res = await postMatches(payload); console.log("[matches response]", res); - navigate("/matching/test/result", { replace: true }); + navigate("/matching/test/result", { + replace: true, + state: { + apiResult: res.result, + }, + }); } catch (e) { if (axios.isAxiosError(e)) { console.log("[matches error status]", e.response?.status); console.log("[matches error body]", e.response?.data); + + const apiMsg = getApiFailMessage(e.response?.data); + setSubmitError(apiMsg ?? "매칭 결과 요청에 실패했어요."); + } else { + setSubmitError( + e instanceof Error ? e.message : "알 수 없는 오류가 발생했어요.", + ); } - throw e; + } finally { + setSubmitting(false); } }; diff --git a/app/stores/matching-test.ts b/app/stores/matching-test.ts index a10a472..a47e51c 100644 --- a/app/stores/matching-test.ts +++ b/app/stores/matching-test.ts @@ -22,7 +22,6 @@ export type Step2SelectedState = Record; export type Step3SelectedState = Record; export type Step3ChipsState = Record; -// ✅ 전부 "태그 id"를 저장 export type FashionBodyTags = { heightTag: number | null; weightTypeTag: number | null;