diff --git a/app/main/_components/FooterButtonList.tsx b/app/main/_components/FooterButtonList.tsx index 888c392..bc15e4d 100644 --- a/app/main/_components/FooterButtonList.tsx +++ b/app/main/_components/FooterButtonList.tsx @@ -4,8 +4,8 @@ import Link from "next/link"; const MatchingButton = () => { return ( - + ); }; diff --git a/app/matching/_components/ImportantOptionDrawer.tsx b/app/matching/_components/ImportantOptionDrawer.tsx index 41ec780..0c8769d 100644 --- a/app/matching/_components/ImportantOptionDrawer.tsx +++ b/app/matching/_components/ImportantOptionDrawer.tsx @@ -1,6 +1,7 @@ "use client"; - +import { ArrowUpToLine } from "lucide-react"; import React from "react"; +import { createPortal } from "react-dom"; import { Drawer, DrawerClose, @@ -11,56 +12,315 @@ 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; + onSelect: (option: ImportantOption | null) => void; selectedOption?: ImportantOption | null; + 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, + 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: "", + }, }: ImportantOptionDrawerProps) { - const options: { label: string; value: ImportantOption }[] = [ - { label: "MBTI", value: "MBTI" }, - { label: "나이", value: "AGE" }, - { label: "관심사", value: "HOBBY" }, - { label: "연락빈도", value: "CONTACT" }, - ]; + 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 dropZoneRectRef = React.useRef(null); + + 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; + const rect = dropZoneRectRef.current; + if (rect) { + 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; + + // 드롭 존 위치 미리 계산 (Reflow 방지) + if (dropZoneRef.current) { + dropZoneRectRef.current = dropZoneRef.current.getBoundingClientRect(); + } + + 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} - - - - 가장 중요한 옵션 선택 - -

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

-
-
- {options.map((option) => ( - - + )} +
+ )} + + +
+ {remainingOptions.map((option) => ( + + ))} +
+ + +
+ + - ))} +
+ + {/* 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/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) => (
@@ -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 820cb61..2fa139e 100644 --- a/app/matching/_components/MatchingImportantOptionSection.tsx +++ b/app/matching/_components/MatchingImportantOptionSection.tsx @@ -1,33 +1,55 @@ "use client"; +import { Check, Delete } from "lucide-react"; import Image from "next/image"; -import React from "react"; +import React, { useEffect, useState } from "react"; +import { cn } from "@/lib/utils"; 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 ( + + ) : ( +
+ bulb + + 1 + +
+ )} } /> 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({ + label, + value, + isSelected, + selectionLabel, + onPointerDown, + onPointerMove, + onPointerUp, + onClick, +}: MatchingOptionCardProps) { + return ( + + ); +} diff --git a/app/matching/_components/MatchingSameMajorSection.tsx b/app/matching/_components/MatchingSameMajorSection.tsx index af15b01..73acdf1 100644 --- a/app/matching/_components/MatchingSameMajorSection.tsx +++ b/app/matching/_components/MatchingSameMajorSection.tsx @@ -1,7 +1,9 @@ "use client"; +import { Check, Delete } from "lucide-react"; import Image from "next/image"; -import React from "react"; +import React, { useEffect, useState } from "react"; +import { cn } from "@/lib/utils"; interface MatchingSameMajorSectionProps { onSameMajorToggle: (isSameMajorExclude: boolean) => void; @@ -12,14 +14,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 ( + ) : ( +
+ bulb + + 1 + +
+ )} ); } diff --git a/app/matching/_components/ScreenMatching.tsx b/app/matching/_components/ScreenMatching.tsx index a28a669..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 ( @@ -163,6 +164,12 @@ const ScreenMatching = () => { setImportantOption(option)} selectedOption={importantOption} + selections={{ + MBTI: selectedMBTI || "", + AGE: selectedAgeGroup || "", + HOBBY: selectedHobbyCategory || "", + CONTACT: selectedFrequency || "", + }} /> { diff --git a/app/profile-builder/_components/ProfileButton.tsx b/app/profile-builder/_components/ProfileButton.tsx index 9960729..2c6b8a6 100644 --- a/app/profile-builder/_components/ProfileButton.tsx +++ b/app/profile-builder/_components/ProfileButton.tsx @@ -20,8 +20,8 @@ export default function ProfileButton({ className={cn( "typo-20-700 flex h-12 flex-1 items-center justify-center rounded-full transition-colors", selected - ? "bg-pink-gradient border border-pink-700 text-pink-700" - : "bg-[#FFFFFF4D] text-gray-300", + ? "bg-pink-gradient border-color-pink-700 text-color-pink-700 border" + : "bg-color-gray-0-a30 text-color-gray-300", )} onClick={onClick} > diff --git a/hooks/useMatching.ts b/hooks/useMatching.ts new file mode 100644 index 0000000..ead5cbc --- /dev/null +++ b/hooks/useMatching.ts @@ -0,0 +1,18 @@ +import { useMutation } from "@tanstack/react-query"; +import { postMatchingAction } from "@/lib/actions/matchingAction"; + +/** + * 매칭 실행 Mutation 훅 + * 성공 시 매칭된 유저 정보를 반환합니다. + */ +export const useMatching = () => { + return useMutation({ + mutationFn: postMatchingAction, + onSuccess: (data) => { + console.log("✅ 매칭 성공:", data); + }, + onError: (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("알 수 없는 오류가 발생했습니다."); + } +} 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; +}