From 0e205f279a971e4993483016f69567b52d86b7fb Mon Sep 17 00:00:00 2001 From: dasosann Date: Thu, 16 Apr 2026 15:22:43 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=EB=A7=A4=EC=B9=AD=20=EB=94=94?= =?UTF-8?q?=EC=9E=90=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_components/ImportantOptionDrawer.tsx | 70 +++++++----- .../MatchingImportantOptionSection.tsx | 106 +++++++++++++----- .../_components/MatchingOptionCard.tsx | 50 +++++++++ .../_components/MatchingSameMajorSection.tsx | 97 ++++++++++++---- app/matching/_components/ScreenMatching.tsx | 6 + hooks/useMatching.ts | 36 ++++++ lib/types/matching.ts | 24 +++- 7 files changed, 313 insertions(+), 76 deletions(-) create mode 100644 app/matching/_components/MatchingOptionCard.tsx create mode 100644 hooks/useMatching.ts diff --git a/app/matching/_components/ImportantOptionDrawer.tsx b/app/matching/_components/ImportantOptionDrawer.tsx index 41ec780..299b944 100644 --- a/app/matching/_components/ImportantOptionDrawer.tsx +++ b/app/matching/_components/ImportantOptionDrawer.tsx @@ -1,5 +1,4 @@ -"use client"; - +import { ArrowUpToLine } from "lucide-react"; import React from "react"; import { Drawer, @@ -11,22 +10,30 @@ import { } from "@/components/ui/drawer"; import { ImportantOption } from "@/lib/types/matching"; import { cn } from "@/lib/utils"; +import MatchingOptionCard from "./MatchingOptionCard"; interface ImportantOptionDrawerProps { trigger: React.ReactNode; onSelect: (option: ImportantOption) => void; selectedOption?: ImportantOption | null; + selections?: Record; } export default function ImportantOptionDrawer({ trigger, onSelect, selectedOption, + selections = { + MBTI: "미선택", + AGE: "미선택", + HOBBY: "미선택", + CONTACT: "미선택", + }, }: ImportantOptionDrawerProps) { const options: { label: string; value: ImportantOption }[] = [ { label: "MBTI", value: "MBTI" }, - { label: "나이", value: "AGE" }, { label: "관심사", value: "HOBBY" }, + { label: "나이", value: "AGE" }, { label: "연락빈도", value: "CONTACT" }, ]; @@ -34,32 +41,43 @@ export default function ImportantOptionDrawer({ {trigger} - - - 가장 중요한 옵션 선택 - -

- AI가 어떤 조건을 최우선으로 고려하면 좋을까요? + +

+ {/* 좌측 스페이서 (닫기 버튼과 동일한 너비를 가져야 타이틀이 정중앙에 위치함) */} +
+ + + 중요한 옵션 선택 + + + + 닫기 + +
+

+ 가장 중요하게 생각하는 옵션을 하나 선택하세요. +
+ 선택한 조건을 반드시 만족하는 사람만 추천해 줄 거에요!

-
+ +
+ + + 중요한 옵션을 위에 올려놓으세요! + +
+ +
{options.map((option) => ( - - - + onSelect(option.value)} + /> ))}
diff --git a/app/matching/_components/MatchingImportantOptionSection.tsx b/app/matching/_components/MatchingImportantOptionSection.tsx index 820cb61..e2ede54 100644 --- a/app/matching/_components/MatchingImportantOptionSection.tsx +++ b/app/matching/_components/MatchingImportantOptionSection.tsx @@ -1,33 +1,54 @@ "use client"; +import { Check, Delete } from "lucide-react"; import Image from "next/image"; -import React from "react"; +import React, { useEffect, useState } from "react"; import { ImportantOption } from "@/lib/types/matching"; import ImportantOptionDrawer from "./ImportantOptionDrawer"; interface MatchingImportantOptionSectionProps { - onSelect: (option: ImportantOption) => void; + onSelect: (option: ImportantOption | null) => void; selectedOption?: ImportantOption | null; + selections?: Record; } export default function MatchingImportantOptionSection({ onSelect, selectedOption, + selections, }: MatchingImportantOptionSectionProps) { - const options: { label: string; value: ImportantOption }[] = [ - { label: "MBTI", value: "MBTI" }, - { label: "나이", value: "AGE" }, - { label: "관심사", value: "HOBBY" }, - { label: "연락빈도", value: "CONTACT" }, - ]; + const [showCheck, setShowCheck] = useState(false); + const [prevSelected, setPrevSelected] = useState(selectedOption); + + if (selectedOption !== prevSelected) { + setPrevSelected(selectedOption); + if (selectedOption) { + setShowCheck(true); + } + } + + useEffect(() => { + if (selectedOption && showCheck) { + const timer = setTimeout(() => { + setShowCheck(false); + }, 2000); + return () => clearTimeout(timer); + } + }, [selectedOption, showCheck]); + + const handleDelete = (e: React.MouseEvent) => { + e.stopPropagation(); + onSelect(null); + }; return ( void} selectedOption={selectedOption} + selections={selections} trigger={ - + {/* 가격 뱃지 / 선택 완료 */} + {selectedOption ? ( +
+ {/* Check 아이콘 */} +
+ +
+ + {/* Delete 아이콘 */} +
+ +
+
+ ) : ( +
+ bulb + + 1 + +
+ )} +
} /> ); diff --git a/app/matching/_components/MatchingOptionCard.tsx b/app/matching/_components/MatchingOptionCard.tsx new file mode 100644 index 0000000..96195b7 --- /dev/null +++ b/app/matching/_components/MatchingOptionCard.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { DrawerClose } from "@/components/ui/drawer"; +import { ImportantOption } from "@/lib/types/matching"; +import { cn } from "@/lib/utils"; +import React from "react"; + +interface MatchingOptionCardProps { + label: string; + value: ImportantOption; + isSelected: boolean; + selectionLabel: string; + onClick: () => void; +} + +export default function MatchingOptionCard({ + label, + isSelected, + selectionLabel, + onClick, +}: MatchingOptionCardProps) { + return ( + + + + ); +} diff --git a/app/matching/_components/MatchingSameMajorSection.tsx b/app/matching/_components/MatchingSameMajorSection.tsx index af15b01..13c6479 100644 --- a/app/matching/_components/MatchingSameMajorSection.tsx +++ b/app/matching/_components/MatchingSameMajorSection.tsx @@ -1,7 +1,8 @@ "use client"; +import { Check, Delete } from "lucide-react"; import Image from "next/image"; -import React from "react"; +import React, { useEffect, useState } from "react"; interface MatchingSameMajorSectionProps { onSameMajorToggle: (isSameMajorExclude: boolean) => void; @@ -12,14 +13,41 @@ export default function MatchingSameMajorSection({ onSameMajorToggle, isExcluded = false, }: MatchingSameMajorSectionProps) { - const handleToggle = (value: boolean) => { - onSameMajorToggle(value); + const [showCheck, setShowCheck] = useState(false); + const [prevExcluded, setPrevExcluded] = useState(isExcluded); + + if (isExcluded !== prevExcluded) { + setPrevExcluded(isExcluded); + if (isExcluded) { + setShowCheck(true); + } + } + + useEffect(() => { + if (isExcluded && showCheck) { + const timer = setTimeout(() => { + setShowCheck(false); + }, 2000); + return () => clearTimeout(timer); + } + }, [isExcluded, showCheck]); + + const handleMainClick = () => { + if (!isExcluded) { + setShowCheck(true); + onSameMajorToggle(true); + } + }; + + const handleDelete = (e: React.MouseEvent) => { + e.stopPropagation(); + onSameMajorToggle(false); }; return ( - + {/* 옵션 전용 가격 뱃지 / 선택 완료 */} + {isExcluded ? ( +
+ {/* Check 아이콘 */} +
+ +
+ + {/* Delete 아이콘 */} +
+ +
+
+ ) : ( +
+ bulb + + 1 + +
+ )} +
); } diff --git a/app/matching/_components/ScreenMatching.tsx b/app/matching/_components/ScreenMatching.tsx index a28a669..11dfd3b 100644 --- a/app/matching/_components/ScreenMatching.tsx +++ b/app/matching/_components/ScreenMatching.tsx @@ -163,6 +163,12 @@ const ScreenMatching = () => { setImportantOption(option)} selectedOption={importantOption} + selections={{ + MBTI: selectedMBTI || "미선택", + AGE: selectedAgeGroup || "미선택", + HOBBY: selectedHobbyCategory || "미선택", + CONTACT: selectedFrequency || "미선택", + }} /> => { + const { data } = await api.post>( + "/api/matching", + payload, + ); + return data.data; +}; + +/** + * 매칭 실행 Mutation 훅 + * 성공 시 매칭된 유저 정보를 반환합니다. + */ +export const useMatching = () => { + return useMutation({ + mutationFn: postMatching, + onSuccess: (data) => { + console.log("✅ 매칭 성공:", data); + }, + onError: (error) => { + console.error("❌ 매칭 실패:", error); + }, + }); +}; diff --git a/lib/types/matching.ts b/lib/types/matching.ts index 263909b..017fb5f 100644 --- a/lib/types/matching.ts +++ b/lib/types/matching.ts @@ -1,4 +1,4 @@ -import { ContactFrequency } from "./profile"; +import { ContactFrequency, Hobby, Gender, MBTI, SocialType } from "./profile"; export type AgeOption = "OLDER" | "YOUNGER" | "EQUAL"; @@ -22,3 +22,25 @@ export interface MatchingRequest { minAgeOffset?: number | null; maxAgeOffset?: number | null; } + +export interface MatchingResult { + memberId: number; + gender: Gender; + age: number; + mbti: MBTI; + major: string; + intro: string; + nickname: string; + profileImageUrl: string; + socialType: SocialType; + socialAccountId: string; + hobbies: Hobby[]; + tags: { tag: string }[]; +} + +export interface ApiResponse { + code: string; + status: number; + message: string; + data: T; +} From a05bdb0fb310578313fc400eaa118e5fe14503ba Mon Sep 17 00:00:00 2001 From: dasosann Date: Thu, 16 Apr 2026 17:02:05 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=EB=A7=A4=EC=B9=AD=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=99=84=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_components/ImportantOptionDrawer.tsx | 322 +++++++++++++++--- .../MatchingImportantOptionSection.tsx | 2 +- .../_components/MatchingOptionCard.tsx | 59 ++-- app/matching/_components/ScreenMatching.tsx | 8 +- 4 files changed, 318 insertions(+), 73 deletions(-) diff --git a/app/matching/_components/ImportantOptionDrawer.tsx b/app/matching/_components/ImportantOptionDrawer.tsx index 299b944..7a5ad83 100644 --- a/app/matching/_components/ImportantOptionDrawer.tsx +++ b/app/matching/_components/ImportantOptionDrawer.tsx @@ -1,5 +1,6 @@ import { ArrowUpToLine } from "lucide-react"; import React from "react"; +import { createPortal } from "react-dom"; import { Drawer, DrawerClose, @@ -14,22 +15,74 @@ import MatchingOptionCard from "./MatchingOptionCard"; interface ImportantOptionDrawerProps { trigger: React.ReactNode; - onSelect: (option: ImportantOption) => void; + onSelect: (option: ImportantOption | null) => void; selectedOption?: ImportantOption | null; selections?: Record; } +function TypingText({ + text, + speed = 30, + onComplete, +}: { + text: string; + speed?: number; + onComplete?: () => void; +}) { + const [displayedText, setDisplayedText] = React.useState(""); + + React.useEffect(() => { + setDisplayedText(""); + let i = 0; + const timer = setInterval(() => { + setDisplayedText(text.slice(0, i + 1)); + i++; + if (i >= text.length) { + clearInterval(timer); + onComplete?.(); + } + }, speed); + return () => clearInterval(timer); + }, [text, speed, onComplete]); + + return <>{displayedText}; +} + export default function ImportantOptionDrawer({ trigger, onSelect, selectedOption, selections = { - MBTI: "미선택", - AGE: "미선택", - HOBBY: "미선택", - CONTACT: "미선택", + MBTI: "", + AGE: "", + HOBBY: "", + CONTACT: "", }, }: ImportantOptionDrawerProps) { + const [dragState, setDragState] = React.useState<{ + option: ImportantOption | null; + x: number; + y: number; + width: number; + height: number; + offsetX: number; + offsetY: number; + isOverZone: boolean; + }>({ + option: null, + x: 0, + y: 0, + width: 0, + height: 0, + offsetX: 0, + offsetY: 0, + isOverZone: false, + }); + const [typingStep, setTypingStep] = React.useState(0); + const isMounted = React.useRef(false); + + const dropZoneRef = React.useRef(null); + const options: { label: string; value: ImportantOption }[] = [ { label: "MBTI", value: "MBTI" }, { label: "관심사", value: "HOBBY" }, @@ -37,48 +90,235 @@ export default function ImportantOptionDrawer({ { label: "연락빈도", value: "CONTACT" }, ]; + const selectedItem = options.find((opt) => opt.value === selectedOption); + const remainingOptions = options.filter( + (opt) => opt.value !== selectedOption, + ); + + React.useEffect(() => { + if (selectedOption) { + if (!isMounted.current) { + setTypingStep(2); // 이미 선택된 상태로 진입 시 완성된 상태로 시작 + } else { + setTypingStep(1); // 드롭 등으로 새롭게 선택된 경우 애니메이션 시작 + } + } else { + setTypingStep(0); + } + isMounted.current = true; + }, [selectedOption]); + + const handleStep1Complete = React.useCallback(() => { + setTypingStep(2); + }, []); + + const handlePointerMove = (e: React.PointerEvent) => { + if (!dragState.option) return; + + const x = e.clientX; + const y = e.clientY; + + let isOverZone = false; + if (dropZoneRef.current) { + const rect = dropZoneRef.current.getBoundingClientRect(); + isOverZone = + x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; + } + + setDragState((prev) => ({ ...prev, x, y, isOverZone })); + }; + + const handlePointerUp = (e: React.PointerEvent) => { + if (!dragState.option) return; + + if (dragState.isOverZone) { + onSelect(dragState.option); + } + + setDragState({ + option: null, + x: 0, + y: 0, + width: 0, + height: 0, + offsetX: 0, + offsetY: 0, + isOverZone: false, + }); + (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); + }; + + const handlePointerDown = (e: React.PointerEvent, value: ImportantOption) => { + const selection = selections[value]; + if (!selection || selection === "" || selection === "선택 전") { + alert( + `${options.find((o) => o.value === value)?.label} 옵션을 먼저 선택해 주세요!`, + ); + return; + } + + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + const offsetX = e.clientX - rect.left; + const offsetY = e.clientY - rect.top; + + setDragState({ + option: value, + x: e.clientX, + y: e.clientY, + width: rect.width, + height: rect.height, + offsetX, + offsetY, + isOverZone: false, + }); + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + }; + return ( {trigger} - - -
- {/* 좌측 스페이서 (닫기 버튼과 동일한 너비를 가져야 타이틀이 정중앙에 위치함) */} -
- - - 중요한 옵션 선택 - - - - 닫기 + +
+ +
+
+ + 중요한 옵션 선택 + + + 닫기 + +
+

+ 가장 중요하게 생각하는 옵션을 하나 선택하세요. +
+ 선택한 조건을 반드시 만족하는 사람만 추천해 줄 거에요! +

+ + +
+
+ {!selectedOption ? ( +
+ + + 중요한 옵션을 위에 올려놓으세요! + +
+ ) : ( +
+ {typingStep >= 1 && ( +
+ + + +
+ )} + {typingStep >= 2 && ( +
+ + + +
+ )} + {typingStep >= 2 && ( + + )} +
+ )} +
+ +
+ {remainingOptions.map((option) => ( + + ))} +
+
+ +
+ +
-

- 가장 중요하게 생각하는 옵션을 하나 선택하세요. -
- 선택한 조건을 반드시 만족하는 사람만 추천해 줄 거에요! -

- - -
- - - 중요한 옵션을 위에 올려놓으세요! - -
-
- {options.map((option) => ( - onSelect(option.value)} - /> - ))} + {/* Ghost Element (Dragging) */} + {dragState.option && + typeof document !== "undefined" && + createPortal( +
+
+
+
+ + { + options.find((o) => o.value === dragState.option) + ?.label + } + +
+
+
+
+
+
+
+
+
, + document.body, + )}
diff --git a/app/matching/_components/MatchingImportantOptionSection.tsx b/app/matching/_components/MatchingImportantOptionSection.tsx index e2ede54..c53b846 100644 --- a/app/matching/_components/MatchingImportantOptionSection.tsx +++ b/app/matching/_components/MatchingImportantOptionSection.tsx @@ -44,7 +44,7 @@ export default function MatchingImportantOptionSection({ return ( void} + onSelect={onSelect} selectedOption={selectedOption} selections={selections} trigger={ diff --git a/app/matching/_components/MatchingOptionCard.tsx b/app/matching/_components/MatchingOptionCard.tsx index 96195b7..58197ba 100644 --- a/app/matching/_components/MatchingOptionCard.tsx +++ b/app/matching/_components/MatchingOptionCard.tsx @@ -10,41 +10,46 @@ interface MatchingOptionCardProps { value: ImportantOption; isSelected: boolean; selectionLabel: string; - onClick: () => void; + onPointerDown?: (e: React.PointerEvent, value: ImportantOption) => void; + onPointerMove?: (e: React.PointerEvent) => void; + onPointerUp?: (e: React.PointerEvent) => void; } export default function MatchingOptionCard({ label, + value, isSelected, selectionLabel, - onClick, + onPointerDown, + onPointerMove, + onPointerUp, }: MatchingOptionCardProps) { return ( - - - + {/* 드래그 핸들 아이콘 (Hamburger 형태) */} +
+
+
+
+
+
+ ); } diff --git a/app/matching/_components/ScreenMatching.tsx b/app/matching/_components/ScreenMatching.tsx index 11dfd3b..e68af1b 100644 --- a/app/matching/_components/ScreenMatching.tsx +++ b/app/matching/_components/ScreenMatching.tsx @@ -164,10 +164,10 @@ const ScreenMatching = () => { onSelect={(option) => setImportantOption(option)} selectedOption={importantOption} selections={{ - MBTI: selectedMBTI || "미선택", - AGE: selectedAgeGroup || "미선택", - HOBBY: selectedHobbyCategory || "미선택", - CONTACT: selectedFrequency || "미선택", + MBTI: selectedMBTI || "", + AGE: selectedAgeGroup || "", + HOBBY: selectedHobbyCategory || "", + CONTACT: selectedFrequency || "", }} /> Date: Fri, 17 Apr 2026 10:46:54 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=EC=BD=94=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_components/ImportantOptionDrawer.tsx | 34 ++++++++++------ .../_components/MatchingAgeSection.tsx | 5 ++- .../_components/MatchingFrequencySection.tsx | 5 ++- .../_components/MatchingHobbySection.tsx | 4 +- .../MatchingImportantOptionSection.tsx | 29 ++++++++------ .../_components/MatchingMBTISection.tsx | 38 +++++++++--------- .../_components/MatchingOptionCard.tsx | 29 +++++++------- .../_components/MatchingSameMajorSection.tsx | 31 ++++++++------ app/matching/_components/ScreenMatching.tsx | 8 ++-- hooks/useMatching.ts | 24 ++--------- lib/actions/matchingAction.ts | 40 +++++++++++++++++++ 11 files changed, 146 insertions(+), 101 deletions(-) create mode 100644 lib/actions/matchingAction.ts diff --git a/app/matching/_components/ImportantOptionDrawer.tsx b/app/matching/_components/ImportantOptionDrawer.tsx index 7a5ad83..3ba7228 100644 --- a/app/matching/_components/ImportantOptionDrawer.tsx +++ b/app/matching/_components/ImportantOptionDrawer.tsx @@ -1,3 +1,4 @@ +"use client"; import { ArrowUpToLine } from "lucide-react"; import React from "react"; import { createPortal } from "react-dom"; @@ -20,6 +21,13 @@ interface ImportantOptionDrawerProps { selections?: Record; } +const OPTIONS: { label: string; value: ImportantOption }[] = [ + { label: "MBTI", value: "MBTI" }, + { label: "관심사", value: "HOBBY" }, + { label: "나이", value: "AGE" }, + { label: "연락빈도", value: "CONTACT" }, +]; + function TypingText({ text, speed = 30, @@ -82,16 +90,10 @@ export default function ImportantOptionDrawer({ const isMounted = React.useRef(false); const dropZoneRef = React.useRef(null); + const dropZoneRectRef = React.useRef(null); - const options: { label: string; value: ImportantOption }[] = [ - { label: "MBTI", value: "MBTI" }, - { label: "관심사", value: "HOBBY" }, - { label: "나이", value: "AGE" }, - { label: "연락빈도", value: "CONTACT" }, - ]; - - const selectedItem = options.find((opt) => opt.value === selectedOption); - const remainingOptions = options.filter( + const selectedItem = OPTIONS.find((opt) => opt.value === selectedOption); + const remainingOptions = OPTIONS.filter( (opt) => opt.value !== selectedOption, ); @@ -119,8 +121,8 @@ export default function ImportantOptionDrawer({ const y = e.clientY; let isOverZone = false; - if (dropZoneRef.current) { - const rect = dropZoneRef.current.getBoundingClientRect(); + const rect = dropZoneRectRef.current; + if (rect) { isOverZone = x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; } @@ -152,7 +154,7 @@ export default function ImportantOptionDrawer({ const selection = selections[value]; if (!selection || selection === "" || selection === "선택 전") { alert( - `${options.find((o) => o.value === value)?.label} 옵션을 먼저 선택해 주세요!`, + `${OPTIONS.find((o) => o.value === value)?.label} 옵션을 먼저 선택해 주세요!`, ); return; } @@ -161,6 +163,11 @@ export default function ImportantOptionDrawer({ const offsetX = e.clientX - rect.left; const offsetY = e.clientY - rect.top; + // 드롭 존 위치 미리 계산 (Reflow 방지) + if (dropZoneRef.current) { + dropZoneRectRef.current = dropZoneRef.current.getBoundingClientRect(); + } + setDragState({ option: value, x: e.clientX, @@ -270,6 +277,7 @@ export default function ImportantOptionDrawer({ onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} + onClick={onSelect} /> ))}
@@ -304,7 +312,7 @@ export default function ImportantOptionDrawer({
{ - options.find((o) => o.value === dragState.option) + OPTIONS.find((o) => o.value === dragState.option) ?.label } diff --git a/app/matching/_components/MatchingAgeSection.tsx b/app/matching/_components/MatchingAgeSection.tsx index 2b9bc23..9d3972b 100644 --- a/app/matching/_components/MatchingAgeSection.tsx +++ b/app/matching/_components/MatchingAgeSection.tsx @@ -7,12 +7,13 @@ interface MatchingAgeSectionProps { defaultValue?: string; } +const AGE_GROUPS = ["연하", "동갑", "연상"]; + export default function MatchingAgeSection({ onAgeGroupSelect, defaultValue = "", }: MatchingAgeSectionProps) { const [selected, setSelected] = React.useState(defaultValue); - const ageGroups = ["연하", "동갑", "연상"]; const handleSelect = (group: string) => { setSelected(group); @@ -30,7 +31,7 @@ export default function MatchingAgeSection({
- {ageGroups.map((group) => ( + {AGE_GROUPS.map((group) => ( { setSelected(frequency); @@ -31,7 +32,7 @@ export default function MatchingFrequencySection({
- {options.map((option) => ( + {OPTIONS.map((option) => (
diff --git a/app/matching/_components/MatchingImportantOptionSection.tsx b/app/matching/_components/MatchingImportantOptionSection.tsx index c53b846..81decc2 100644 --- a/app/matching/_components/MatchingImportantOptionSection.tsx +++ b/app/matching/_components/MatchingImportantOptionSection.tsx @@ -3,6 +3,7 @@ import { Check, Delete } from "lucide-react"; import Image from "next/image"; import React, { useEffect, useState } from "react"; +import { cn } from "@/lib/utils"; import { ImportantOption } from "@/lib/types/matching"; import ImportantOptionDrawer from "./ImportantOptionDrawer"; @@ -48,7 +49,7 @@ export default function MatchingImportantOptionSection({ selectedOption={selectedOption} selections={selections} trigger={ -
+ ) : (
)} -
+ } /> ); diff --git a/app/matching/_components/MatchingMBTISection.tsx b/app/matching/_components/MatchingMBTISection.tsx index 95cac75..0acb70f 100644 --- a/app/matching/_components/MatchingMBTISection.tsx +++ b/app/matching/_components/MatchingMBTISection.tsx @@ -8,6 +8,22 @@ interface MatchingMBTISectionProps { defaultValue?: string; } +const OPPOSITES: Record = { + E: "I", + I: "E", + S: "N", + N: "S", + T: "F", + F: "T", + J: "P", + P: "J", +}; + +const ROWS = [ + ["E", "S", "F", "P"], + ["I", "N", "T", "J"], +]; + export default function MatchingMBTISection({ onMBTISelect, defaultValue = "", @@ -17,17 +33,6 @@ export default function MatchingMBTISection({ defaultValue.split("").filter(Boolean), ); - const opposites: Record = { - E: "I", - I: "E", - S: "N", - N: "S", - T: "F", - F: "T", - J: "P", - P: "J", - }; - const handleSelect = (char: string) => { const newSelection = [...selected]; const index = newSelection.indexOf(char); @@ -38,7 +43,7 @@ export default function MatchingMBTISection({ } else { // 2개 미만인 경우에만 새로 선택 가능 if (newSelection.length < 2) { - const oppositeChar = opposites[char]; + const oppositeChar = OPPOSITES[char]; const oppositeIndex = newSelection.indexOf(oppositeChar); if (oppositeIndex > -1) { @@ -49,7 +54,7 @@ export default function MatchingMBTISection({ } } else { // 이미 2개가 선택된 상태에서 새로운(교체 대상도 아닌) MBTI를 누른 경우 - const oppositeChar = opposites[char]; + const oppositeChar = OPPOSITES[char]; const oppositeIndex = newSelection.indexOf(oppositeChar); if (oppositeIndex > -1) { @@ -65,11 +70,6 @@ export default function MatchingMBTISection({ onMBTISelect(newSelection.join("")); }; - const rows = [ - ["E", "S", "F", "P"], - ["I", "N", "T", "J"], - ]; - return (
@@ -82,7 +82,7 @@ export default function MatchingMBTISection({
- {rows.map((row, rowIndex) => ( + {ROWS.map((row, rowIndex) => (
{row.map((char) => ( void; onPointerMove?: (e: React.PointerEvent) => void; onPointerUp?: (e: React.PointerEvent) => void; + onClick?: (value: ImportantOption) => void; } export default function MatchingOptionCard({ @@ -23,32 +24,32 @@ export default function MatchingOptionCard({ onPointerDown, onPointerMove, onPointerUp, + onClick, }: MatchingOptionCardProps) { return ( ); diff --git a/app/matching/_components/MatchingSameMajorSection.tsx b/app/matching/_components/MatchingSameMajorSection.tsx index 13c6479..c7e0857 100644 --- a/app/matching/_components/MatchingSameMajorSection.tsx +++ b/app/matching/_components/MatchingSameMajorSection.tsx @@ -3,6 +3,7 @@ import { Check, Delete } from "lucide-react"; import Image from "next/image"; import React, { useEffect, useState } from "react"; +import { cn } from "@/lib/utils"; interface MatchingSameMajorSectionProps { onSameMajorToggle: (isSameMajorExclude: boolean) => void; @@ -45,8 +46,8 @@ export default function MatchingSameMajorSection({ }; return ( -
@@ -64,15 +65,18 @@ export default function MatchingSameMajorSection({
{/* 옵션 전용 가격 뱃지 / 선택 완료 */} {isExcluded ? ( -
{/* Check 아이콘 */}
-
+ ) : (
)} -
+ ); } diff --git a/app/matching/_components/ScreenMatching.tsx b/app/matching/_components/ScreenMatching.tsx index e68af1b..1c1ecf1 100644 --- a/app/matching/_components/ScreenMatching.tsx +++ b/app/matching/_components/ScreenMatching.tsx @@ -38,9 +38,11 @@ const frequencyMapping: Record = { }; import { useItems } from "@/hooks/useItems"; +import { useMatching } from "@/hooks/useMatching"; const ScreenMatching = () => { const { data: itemData } = useItems(); + const { mutate: match, isPending } = useMatching(); const [selectedMBTI, setSelectedMBTI] = useState(""); const [selectedAgeGroup, setSelectedAgeGroup] = useState(""); const [selectedFrequency, setSelectedFrequency] = useState(""); @@ -130,8 +132,7 @@ const ScreenMatching = () => { maxAgeOffset: ageInfo.max, }; - console.log("Matching Payload:", payload); - alert("매칭을 시작합니다!"); + match(payload); }; return ( @@ -178,7 +179,8 @@ const ScreenMatching = () => { diff --git a/hooks/useMatching.ts b/hooks/useMatching.ts index 3cf05c0..ead5cbc 100644 --- a/hooks/useMatching.ts +++ b/hooks/useMatching.ts @@ -1,23 +1,5 @@ -import { api } from "@/lib/axios"; import { useMutation } from "@tanstack/react-query"; -import { - ApiResponse, - MatchingRequest, - MatchingResult, -} from "@/lib/types/matching"; - -/** - * 매칭 실행 API 호출 함수 - */ -const postMatching = async ( - payload: MatchingRequest, -): Promise => { - const { data } = await api.post>( - "/api/matching", - payload, - ); - return data.data; -}; +import { postMatchingAction } from "@/lib/actions/matchingAction"; /** * 매칭 실행 Mutation 훅 @@ -25,12 +7,12 @@ const postMatching = async ( */ export const useMatching = () => { return useMutation({ - mutationFn: postMatching, + mutationFn: postMatchingAction, onSuccess: (data) => { console.log("✅ 매칭 성공:", data); }, onError: (error) => { - console.error("❌ 매칭 실패:", error); + console.error("❌ 매칭 실패:", (error as Error).message); }, }); }; diff --git a/lib/actions/matchingAction.ts b/lib/actions/matchingAction.ts new file mode 100644 index 0000000..601b2c1 --- /dev/null +++ b/lib/actions/matchingAction.ts @@ -0,0 +1,40 @@ +"use server"; + +import { serverApi } from "@/lib/server-api"; +import { + MatchingRequest, + MatchingResult, + ApiResponse, +} from "@/lib/types/matching"; +import { isAxiosError } from "@/lib/server-api"; + +/** + * 매칭 실행 Server Action + * 백엔드 API를 호출하여 매칭을 진행합니다. + */ +export async function postMatchingAction( + payload: MatchingRequest, +): Promise { + try { + const response = await serverApi.post({ + path: "/api/matching", + body: payload, + }); + + return response.data; + } catch (error) { + if (isAxiosError(error)) { + const message = + error.response?.data?.message || "매칭 시스템 오류가 발생했습니다."; + console.error("[postMatchingAction] API Error:", { + status: error.response?.status, + message, + payload, + }); + throw new Error(message); + } + + console.error("[postMatchingAction] Unexpected Error:", error); + throw new Error("알 수 없는 오류가 발생했습니다."); + } +} From 320180f29acfad7c8fc3ac5b32d6b84df81a5c40 Mon Sep 17 00:00:00 2001 From: dasosann Date: Fri, 17 Apr 2026 11:26:05 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20css=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_components/ImportantOptionDrawer.tsx | 40 ++++++++----------- .../_components/MatchingHobbySection.tsx | 8 ++-- .../MatchingImportantOptionSection.tsx | 6 +-- .../_components/MatchingOptionCard.tsx | 12 +++--- .../_components/MatchingSameMajorSection.tsx | 6 +-- .../_components/ProfileButton.tsx | 4 +- 6 files changed, 36 insertions(+), 40 deletions(-) diff --git a/app/matching/_components/ImportantOptionDrawer.tsx b/app/matching/_components/ImportantOptionDrawer.tsx index 3ba7228..0c8769d 100644 --- a/app/matching/_components/ImportantOptionDrawer.tsx +++ b/app/matching/_components/ImportantOptionDrawer.tsx @@ -192,14 +192,14 @@ export default function ImportantOptionDrawer({
- + 중요한 옵션 선택 - + 닫기
-

+

가장 중요하게 생각하는 옵션을 하나 선택하세요.
선택한 조건을 반드시 만족하는 사람만 추천해 줄 거에요! @@ -218,18 +218,18 @@ export default function ImportantOptionDrawer({ {!selectedOption ? (

- - 중요한 옵션을 위에 올려놓으세요! + + 중요한 옵션인 버튼을 끌어올려 보세요!
) : (
{typingStep >= 1 && ( -
- +
+ )} {typingStep >= 2 && ( -
- +
+ = 2 && ( @@ -285,7 +279,7 @@ export default function ImportantOptionDrawer({
- @@ -305,12 +299,12 @@ export default function ImportantOptionDrawer({ }} >
- + { OPTIONS.find((o) => o.value === dragState.option) ?.label @@ -318,9 +312,9 @@ export default function ImportantOptionDrawer({
-
-
-
+
+
+
diff --git a/app/matching/_components/MatchingHobbySection.tsx b/app/matching/_components/MatchingHobbySection.tsx index 6d5394a..0cbf005 100644 --- a/app/matching/_components/MatchingHobbySection.tsx +++ b/app/matching/_components/MatchingHobbySection.tsx @@ -65,7 +65,7 @@ export default function MatchingHobbySection({ @@ -84,7 +84,7 @@ export default function MatchingHobbySection({ className={cn( "flex h-[148px] w-[148px] flex-col items-center justify-center gap-2 rounded-full border transition-all", selectedCategory === category - ? "bg-pink-gradient border-[#F57DB2]" + ? "bg-pink-gradient border-color-pink-700" : "border-color-gray-100 bg-white", )} > @@ -96,7 +96,9 @@ export default function MatchingHobbySection({ className="object-contain" />
- {category} + + {category} + ))} diff --git a/app/matching/_components/MatchingImportantOptionSection.tsx b/app/matching/_components/MatchingImportantOptionSection.tsx index 81decc2..2fa139e 100644 --- a/app/matching/_components/MatchingImportantOptionSection.tsx +++ b/app/matching/_components/MatchingImportantOptionSection.tsx @@ -66,7 +66,7 @@ export default function MatchingImportantOptionSection({ {/* 가격 뱃지 / 선택 완료 */} {selectedOption ? (
@@ -92,7 +92,7 @@ export default function MatchingImportantOptionSection({ )} >
diff --git a/app/matching/_components/MatchingOptionCard.tsx b/app/matching/_components/MatchingOptionCard.tsx index feb716a..bcc7e0d 100644 --- a/app/matching/_components/MatchingOptionCard.tsx +++ b/app/matching/_components/MatchingOptionCard.tsx @@ -36,20 +36,20 @@ export default function MatchingOptionCard({ "flex h-[75px] w-full cursor-grab touch-none items-center justify-between gap-1 rounded-lg border px-[17px] transition-all outline-none focus-visible:ring-2 focus-visible:ring-offset-2 active:scale-[0.98] active:cursor-grabbing", isSelected ? "border-color-main-700 ring-color-main-700 bg-white ring-1" - : "border-[#EFEFEF] bg-[#F5F5F5]", + : "border-color-gray-64 bg-color-gray-50", )} >
- {label} - + {label} + {selectionLabel}
{/* 드래그 핸들 아이콘 (Hamburger 형태) */}