diff --git a/app/extra-info/_components/StartExtraInfo.tsx b/app/extra-info/_components/StartExtraInfo.tsx index a646bf2..706420e 100644 --- a/app/extra-info/_components/StartExtraInfo.tsx +++ b/app/extra-info/_components/StartExtraInfo.tsx @@ -14,8 +14,8 @@ const StartExtraInfo = () => { useEffect(() => { const t1 = setTimeout(() => setShowFirst(true), 100); - const t2 = setTimeout(() => setShowSecond(true), 2100); - const t3 = setTimeout(() => setShowThird(true), 4100); + const t2 = setTimeout(() => setShowSecond(true), 1100); + const t3 = setTimeout(() => setShowThird(true), 2100); return () => { clearTimeout(t1); clearTimeout(t2); diff --git a/app/layout.tsx b/app/layout.tsx index eea142d..a9e9b0a 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -67,7 +67,7 @@ export default async function RootLayout({ {/* */} -
+
{children} diff --git a/app/matching/_components/ImportantBottomSheet.tsx b/app/matching/_components/ImportantBottomSheet.tsx new file mode 100644 index 0000000..0b3dcf7 --- /dev/null +++ b/app/matching/_components/ImportantBottomSheet.tsx @@ -0,0 +1,67 @@ +"use client"; + +import React from "react"; +import { + Drawer, + DrawerContent, + DrawerHeader, + DrawerTitle, +} from "@/components/ui/drawer"; +import { ImportantOption } from "@/lib/types/matching"; + +interface ImportantBottomSheetProps { + isOpen: boolean; + onClose: () => void; + onSelect: (option: ImportantOption) => void; + selectedOption?: ImportantOption | null; +} + +export default function ImportantBottomSheet({ + isOpen, + onClose, + onSelect, + selectedOption, +}: ImportantBottomSheetProps) { + const options: { label: string; value: ImportantOption }[] = [ + { label: "MBTI", value: "MBTI" }, + { label: "나이", value: "AGE" }, + { label: "관심사", value: "HOBBY" }, + { label: "연락빈도", value: "CONTACT" }, + ]; + + return ( + !open && onClose()}> + + + + 가장 중요한 옵션 선택 + +

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

+
+
+ {options.map((option) => ( + + ))} +
+
+
+ ); +} diff --git a/app/matching/_components/MatchingAgeSection.tsx b/app/matching/_components/MatchingAgeSection.tsx new file mode 100644 index 0000000..2b9bc23 --- /dev/null +++ b/app/matching/_components/MatchingAgeSection.tsx @@ -0,0 +1,45 @@ +"use client"; + +import React from "react"; +import ProfileButton from "../../profile-builder/_components/ProfileButton"; +interface MatchingAgeSectionProps { + onAgeGroupSelect: (ageGroup: string) => void; + defaultValue?: string; +} + +export default function MatchingAgeSection({ + onAgeGroupSelect, + defaultValue = "", +}: MatchingAgeSectionProps) { + const [selected, setSelected] = React.useState(defaultValue); + const ageGroups = ["연하", "동갑", "연상"]; + + const handleSelect = (group: string) => { + setSelected(group); + onAgeGroupSelect(group); + }; + + return ( +
+
+
+

나이

+

+ 상대의 나이를 골라주세요. +

+
+
+
+ {ageGroups.map((group) => ( + handleSelect(group)} + > + {group} + + ))} +
+
+ ); +} diff --git a/app/matching/_components/MatchingFrequencySection.tsx b/app/matching/_components/MatchingFrequencySection.tsx new file mode 100644 index 0000000..c883fdc --- /dev/null +++ b/app/matching/_components/MatchingFrequencySection.tsx @@ -0,0 +1,46 @@ +"use client"; + +import React from "react"; +import ProfileButton from "../../profile-builder/_components/ProfileButton"; + +interface MatchingFrequencySectionProps { + onFrequencySelect: (frequency: string) => void; + defaultValue?: string; +} + +export default function MatchingFrequencySection({ + onFrequencySelect, + defaultValue = "", +}: MatchingFrequencySectionProps) { + const [selected, setSelected] = React.useState(defaultValue); + const options = ["자주", "보통", "적음"]; + + const handleSelect = (frequency: string) => { + setSelected(frequency); + onFrequencySelect(frequency); + }; + + return ( +
+
+
+

연락빈도

+

+ 상대방의 연락빈도를 골라주세요. +

+
+
+
+ {options.map((option) => ( + handleSelect(option)} + > + {option} + + ))} +
+
+ ); +} diff --git a/app/matching/_components/MatchingHobbyBottomSheet.tsx b/app/matching/_components/MatchingHobbyBottomSheet.tsx new file mode 100644 index 0000000..62dc5ef --- /dev/null +++ b/app/matching/_components/MatchingHobbyBottomSheet.tsx @@ -0,0 +1,62 @@ +"use client"; + +import React from "react"; +import { + Drawer, + DrawerContent, + DrawerHeader, + DrawerTitle, +} from "@/components/ui/drawer"; +import { HOBBIES, HobbyCategory } from "@/lib/constants/hobbies"; + +interface MatchingHobbyBottomSheetProps { + isOpen: boolean; + onClose: () => void; + onSelect: (category: HobbyCategory) => void; + selectedCategory?: string; +} + +export default function MatchingHobbyBottomSheet({ + isOpen, + onClose, + onSelect, + selectedCategory, +}: MatchingHobbyBottomSheetProps) { + const categories = Object.keys(HOBBIES) as HobbyCategory[]; + + return ( + !open && onClose()}> + + + + 관심사 카테고리 선택 + +

+ 어떤 분야의 관심사를 가진 분을 찾으시나요? +

+
+
+ {categories.map((category) => ( + + ))} +
+
+
+ ); +} diff --git a/app/matching/_components/MatchingHobbySection.tsx b/app/matching/_components/MatchingHobbySection.tsx new file mode 100644 index 0000000..a93c2f0 --- /dev/null +++ b/app/matching/_components/MatchingHobbySection.tsx @@ -0,0 +1,47 @@ +"use client"; + +import React from "react"; +import { ChevronRight } from "lucide-react"; + +interface MatchingHobbySectionProps { + onHobbyClick?: () => void; + selectedHobbies?: string[]; +} + +export default function MatchingHobbySection({ + onHobbyClick, + selectedHobbies = [], +}: MatchingHobbySectionProps) { + return ( +
+
+ +
+ + {/* 선택된 관심사들이 있다면 여기에 표시 (추후 구현 가능) */} + {selectedHobbies.length > 0 && ( +
+ {selectedHobbies.map((hobby) => ( + + {hobby} + + ))} +
+ )} +
+ ); +} diff --git a/app/matching/_components/MatchingImportantOptionSection.tsx b/app/matching/_components/MatchingImportantOptionSection.tsx new file mode 100644 index 0000000..4fc4736 --- /dev/null +++ b/app/matching/_components/MatchingImportantOptionSection.tsx @@ -0,0 +1,46 @@ +"use client"; + +import Image from "next/image"; +import React from "react"; + +interface MatchingImportantOptionSectionProps { + onClick?: () => void; +} + +export default function MatchingImportantOptionSection({ + onClick, +}: MatchingImportantOptionSectionProps) { + return ( + + ); +} diff --git a/app/matching/_components/MatchingMBTISection.tsx b/app/matching/_components/MatchingMBTISection.tsx new file mode 100644 index 0000000..95cac75 --- /dev/null +++ b/app/matching/_components/MatchingMBTISection.tsx @@ -0,0 +1,101 @@ +"use client"; + +import React from "react"; +import ProfileButton from "../../profile-builder/_components/ProfileButton"; + +interface MatchingMBTISectionProps { + onMBTISelect: (mbti: string) => void; + defaultValue?: string; +} + +export default function MatchingMBTISection({ + onMBTISelect, + defaultValue = "", +}: MatchingMBTISectionProps) { + // 온보딩 MBTI와 느낌을 맞추기 위해 내부 상태(useState) 사용 + const [selected, setSelected] = React.useState( + 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); + + if (index > -1) { + // 이미 선택된 경우 해제 + newSelection.splice(index, 1); + } else { + // 2개 미만인 경우에만 새로 선택 가능 + if (newSelection.length < 2) { + const oppositeChar = opposites[char]; + const oppositeIndex = newSelection.indexOf(oppositeChar); + + if (oppositeIndex > -1) { + // 반대 성향(예: E인데 I가 선택된 경우)은 교체 + newSelection[oppositeIndex] = char; + } else { + newSelection.push(char); + } + } else { + // 이미 2개가 선택된 상태에서 새로운(교체 대상도 아닌) MBTI를 누른 경우 + const oppositeChar = opposites[char]; + const oppositeIndex = newSelection.indexOf(oppositeChar); + + if (oppositeIndex > -1) { + // 이미 2개여도 반대 성향 교체는 허용 + newSelection[oppositeIndex] = char; + } else { + alert("MBTI는 2개만 선택할 수 있어요!"); + } + } + } + + setSelected(newSelection); + onMBTISelect(newSelection.join("")); + }; + + const rows = [ + ["E", "S", "F", "P"], + ["I", "N", "T", "J"], + ]; + + return ( +
+
+
+

MBTI

+

+ 상대방의 MBTI를 2개 골라주세요. +

+
+
+ +
+ {rows.map((row, rowIndex) => ( +
+ {row.map((char) => ( + handleSelect(char)} + > + {char} + + ))} +
+ ))} +
+
+ ); +} diff --git a/app/matching/_components/MatchingSameMajorSection.tsx b/app/matching/_components/MatchingSameMajorSection.tsx new file mode 100644 index 0000000..af15b01 --- /dev/null +++ b/app/matching/_components/MatchingSameMajorSection.tsx @@ -0,0 +1,52 @@ +"use client"; + +import Image from "next/image"; +import React from "react"; + +interface MatchingSameMajorSectionProps { + onSameMajorToggle: (isSameMajorExclude: boolean) => void; + isExcluded?: boolean; +} + +export default function MatchingSameMajorSection({ + onSameMajorToggle, + isExcluded = false, +}: MatchingSameMajorSectionProps) { + const handleToggle = (value: boolean) => { + onSameMajorToggle(value); + }; + + return ( + + ); +} diff --git a/app/matching/_components/MatchingSliderButton.tsx b/app/matching/_components/MatchingSliderButton.tsx new file mode 100644 index 0000000..20f9666 --- /dev/null +++ b/app/matching/_components/MatchingSliderButton.tsx @@ -0,0 +1,157 @@ +"use client"; + +import React, { useState, useRef, useEffect, useLayoutEffect } from "react"; +import { ArrowRight } from "lucide-react"; + +interface MatchingSliderButtonProps { + onConfirm: () => void; + isLoading?: boolean; + isActive?: boolean; +} + +export default function MatchingSliderButton({ + onConfirm, + isLoading = false, + isActive = false, +}: MatchingSliderButtonProps) { + const [position, setPosition] = useState(0); + const [containerWidth, setContainerWidth] = useState(0); + const [isDragging, setIsDragging] = useState(false); + const containerRef = useRef(null); + const thumbRef = useRef(null); + const positionRef = useRef(0); + const timerRef = useRef(null); + + const THUMB_SIZE = 40; + + useLayoutEffect(() => { + const updateWidth = () => { + if (containerRef.current) { + setContainerWidth(containerRef.current.clientWidth); + } + }; + + updateWidth(); + window.addEventListener("resize", updateWidth); + return () => window.removeEventListener("resize", updateWidth); + }, []); + + const handleStart = () => { + if (isLoading || !isActive) return; + setIsDragging(true); + }; + + useEffect(() => { + if (!isDragging) return; + + const calcPos = (clientX: number) => { + if (!containerRef.current) return; + const rect = containerRef.current.getBoundingClientRect(); + const maxPos = rect.width - THUMB_SIZE - 8; + let newPos = clientX - rect.left - 4 - THUMB_SIZE / 2; + if (newPos < 0) newPos = 0; + if (newPos > maxPos) newPos = maxPos; + positionRef.current = newPos; + setPosition(newPos); + }; + + const onMouseMove = (e: MouseEvent) => calcPos(e.clientX); + const onTouchMove = (e: TouchEvent) => calcPos(e.touches[0].clientX); + + const onEnd = () => { + if (!containerRef.current) return; + const maxPos = containerRef.current.clientWidth - THUMB_SIZE - 8; + setIsDragging(false); + if (positionRef.current >= maxPos * 0.9) { + setPosition(maxPos); + onConfirm(); + timerRef.current = setTimeout(() => setPosition(0), 1000); + } else { + setPosition(0); + } + }; + + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("touchmove", onTouchMove, { passive: true }); + window.addEventListener("mouseup", onEnd); + window.addEventListener("touchend", onEnd); + + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("touchmove", onTouchMove); + window.removeEventListener("mouseup", onEnd); + window.removeEventListener("touchend", onEnd); + }; + }, [isDragging]); + + return ( +
+
0 + ? Math.round((position / (containerWidth - THUMB_SIZE - 8)) * 100) + : 0 + } + aria-disabled={!isActive} + style={{ + width: "min(80vw, 344px)", + height: "48px", + background: + "radial-gradient(100% 100.45% at 0% 0%, rgba(255, 255, 255, 0.9) 0%, rgba(255, 255, 255, 0.6) 100%)", + boxShadow: "inset -2px 2px 6px rgba(0, 0, 0, 0.2)", + borderRadius: "100px", + border: "1px solid rgba(255, 255, 255, 0.3)", + display: "flex", + alignItems: "center", + padding: "4px", + position: "relative", + overflow: "hidden", + }} + > + {/* Text Layer */} +
+ + {isLoading + ? "매칭 중..." + : isActive + ? "밀어서 커플되기" + : "조건을 선택해 주세요"} + +
+ + {/* Thumb */} +
+ +
+
+
+ ); +} diff --git a/app/matching/_components/ScreenMatching.tsx b/app/matching/_components/ScreenMatching.tsx index 34fbd8a..2b7dbf6 100644 --- a/app/matching/_components/ScreenMatching.tsx +++ b/app/matching/_components/ScreenMatching.tsx @@ -1,16 +1,162 @@ -import MyCoinSectionSSR from "@/components/common/MyCoinSectionSSR"; +"use client"; + +import MyCoinSection from "@/components/common/MyCoinSection"; import { BackButton } from "@/components/ui/BackButton"; -import React from "react"; +import React, { useState } from "react"; +import MatchingAgeSection from "./MatchingAgeSection"; +import MatchingHobbySection from "./MatchingHobbySection"; +import MatchingMBTISection from "./MatchingMBTISection"; +import MatchingFrequencySection from "./MatchingFrequencySection"; +import MatchingImportantOptionSection from "./MatchingImportantOptionSection"; +import MatchingSameMajorSection from "./MatchingSameMajorSection"; +import { ContactFrequency } from "@/lib/types/profile"; +import MatchingSliderButton from "./MatchingSliderButton"; + +import { + AgeOption, + HobbyOption, + ImportantOption, + MatchingRequest, +} from "@/lib/types/matching"; + +import MatchingHobbyBottomSheet from "./MatchingHobbyBottomSheet"; +import ImportantBottomSheet from "./ImportantBottomSheet"; +import { HobbyCategory } from "@/lib/constants/hobbies"; + +const hobbyMapping: Record = { + 스포츠: "SPORTS", + 문화예술: "CULTURE", + 음악: "MUSIC", + 여행: "TRAVEL", + "일상/공부": "DAILY", + 게임: "GAME", +}; + +const frequencyMapping: Record = { + 자주: "FREQUENT", + 보통: "NORMAL", + 적음: "RARE", +}; const ScreenMatching = () => { + const [selectedMBTI, setSelectedMBTI] = useState(""); + const [selectedAgeGroup, setSelectedAgeGroup] = useState(""); + const [selectedFrequency, setSelectedFrequency] = useState(""); + const [isSameMajorExclude, setIsSameMajorExclude] = useState(false); + const [selectedHobbyCategory, setSelectedHobbyCategory] = + useState(""); + const [importantOption, setImportantOption] = + useState(null); + + const [isHobbyDrawerOpen, setIsHobbyDrawerOpen] = useState(false); + const [isImportantDrawerOpen, setIsImportantDrawerOpen] = useState(false); + + const canSubmit = !!( + selectedMBTI.length === 2 && + selectedAgeGroup && + selectedFrequency && + selectedHobbyCategory + ); + + const calculateAgeOffsets = ( + group: string, + ): { + min: number | null; + max: number | null; + option: AgeOption | undefined; + } => { + switch (group) { + case "연하": + return { min: -5, max: -1, option: "YOUNGER" }; + case "동갑": + return { min: 0, max: 0, option: "EQUAL" }; + case "연상": + return { min: 1, max: 5, option: "OLDER" }; + default: + return { min: null, max: null, option: undefined }; + } + }; + + const handleMatchingSubmit = () => { + if (!canSubmit) { + alert("모든 조건을 선택해 주세요!"); + return; + } + + const ageInfo = calculateAgeOffsets(selectedAgeGroup); + + const payload: MatchingRequest = { + ageOption: ageInfo.option, + mbtiOption: selectedMBTI || undefined, + hobbyOption: selectedHobbyCategory + ? hobbyMapping[selectedHobbyCategory] + : undefined, + contactFrequency: selectedFrequency + ? frequencyMapping[selectedFrequency] + : undefined, + sameMajorOption: isSameMajorExclude, + importantOption: importantOption || undefined, + minAgeOffset: ageInfo.min, + maxAgeOffset: ageInfo.max, + }; + + console.log("Matching Payload:", payload); + alert("매칭을 시작합니다!"); + }; + return ( -
+
-
- 요즘 관심있는 것들을 3개 이상 선택해주세요. - 최대 10개까지 선택할 수 있어요. + + +
+ + + setIsHobbyDrawerOpen(true)} + selectedHobbies={selectedHobbyCategory ? [selectedHobbyCategory] : []} + /> + + + + + + setIsImportantDrawerOpen(true)} + /> +
- + + + + setIsHobbyDrawerOpen(false)} + selectedCategory={selectedHobbyCategory} + onSelect={(category) => setSelectedHobbyCategory(category)} + /> + + setIsImportantDrawerOpen(false)} + selectedOption={importantOption} + onSelect={(option) => setImportantOption(option)} + />
); }; diff --git a/app/onboarding/_components/StartOnBoarding.tsx b/app/onboarding/_components/StartOnBoarding.tsx index b00b4a0..d55f0c0 100644 --- a/app/onboarding/_components/StartOnBoarding.tsx +++ b/app/onboarding/_components/StartOnBoarding.tsx @@ -12,8 +12,8 @@ const StartOnBoarding = () => { useEffect(() => { const t1 = setTimeout(() => setShowFirst(true), 100); - const t2 = setTimeout(() => setShowSecond(true), 2100); - const t3 = setTimeout(() => setShowThird(true), 4100); + const t2 = setTimeout(() => setShowSecond(true), 1100); + const t3 = setTimeout(() => setShowThird(true), 2100); return () => { clearTimeout(t1); clearTimeout(t2); diff --git a/app/profile-builder/_components/Step3MBTI.tsx b/app/profile-builder/_components/Step3MBTI.tsx index 10b40db..b1cb47b 100644 --- a/app/profile-builder/_components/Step3MBTI.tsx +++ b/app/profile-builder/_components/Step3MBTI.tsx @@ -36,7 +36,7 @@ export default function Step3MBTI({ return (
- +
{/* 상단 행: E S F P */} diff --git a/app/profile-builder/_components/Step4ContactFrequency.tsx b/app/profile-builder/_components/Step4ContactFrequency.tsx index 6615b24..0922bd0 100644 --- a/app/profile-builder/_components/Step4ContactFrequency.tsx +++ b/app/profile-builder/_components/Step4ContactFrequency.tsx @@ -22,7 +22,7 @@ export default function Step4ContactFrequency({ return (
- +
{ +interface MyCoinSectionProps { + className?: string; +} + +const MyCoinSection = ({ className }: MyCoinSectionProps) => { const { data, isLoading, isError } = useItems(); if (isLoading) { return ( -
+
@@ -28,7 +38,12 @@ const MyCoinSection = () => { const { matchingTicketCount, optionTicketCount } = data.data; return ( -
+
보유현황