diff --git a/package-lock.json b/package-lock.json index 770261b..383037a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "lucide-react": "^0.485.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-router-dom": "^7.6.0", "recharts": "^2.15.3", "tailwind-merge": "^3.0.2", "tailwindcss-animate": "^1.0.7" @@ -1986,6 +1987,14 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "engines": { + "node": ">=18" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4063,6 +4072,42 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.0.tgz", + "integrity": "sha512-GGufuHIVCJDbnIAXP3P9Sxzq3UUsddG3rrI3ut1q6m0FI6vxVBF3JoPQ38+W/blslLH4a5Yutp8drkEpXoddGQ==", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.6.0.tgz", + "integrity": "sha512-DYgm6RDEuKdopSyGOWZGtDfSm7Aofb8CCzgkliTjtu/eDuB0gcsv6qdFhhi8HdtmA+KHkt5MfZ5K2PdzjugYsA==", + "dependencies": { + "react-router": "7.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/react-smooth": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", @@ -4266,6 +4311,11 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/package.json b/package.json index 3db41aa..46e6a2b 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "lucide-react": "^0.485.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-router-dom": "^7.6.0", "recharts": "^2.15.3", "tailwind-merge": "^3.0.2", "tailwindcss-animate": "^1.0.7" diff --git a/src/chatbot.jsx b/src/chatbot.jsx index 479bfdb..6e8413d 100644 --- a/src/chatbot.jsx +++ b/src/chatbot.jsx @@ -1,596 +1,22 @@ -import React, { useState, useRef, useEffect } from "react"; -import { Send } from "lucide-react" //전송 아이콘 -import PriceTrendChart from "./components/ui/PriceTrendChart"; //가격 추이 그래프 컴포넌트 -import DestinationCard from "./components/ui/DestinationCard"; //목적지 카드 컴포넌트 -export default function ChatBot() { - const [messages, setMessages] = useState([]); - const [input, setInput] = useState(""); - const [showOptions, setShowOptions] = useState(true); - const [showFlightForm, setShowFlightForm] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [sortOption, setSortOption]=useState(""); - const [flightForm, setFlightForm] = useState({ - origin: "", - destination: "", - date: "", - adults: 1, - }); - const [flightResults, setFlightResults] = useState([]); - const [selectedFlight, setSelectedFlight] = useState(null); - const messagesEndRef = useRef(null); - const today = new Date().toLocaleDateString("ko-KR", { - year: "numeric", - month: "long", - day: "numeric", - weekday: "long", - }); - const handleLogout = () => { - localStorage.removeItem("user-auth"); - setMessages([]); // ✅ 메시지 초기화 - window.location.href = "/"; - }; - const [priceTrendData, setPriceTrendData] = useState([]); - const [showPriceChart, setShowPriceChart] = useState(false); - - useEffect(() => { - const scrollToBottom = () => { - if (messagesEndRef.current) { - messagesEndRef.current.scrollIntoView({ behavior: "smooth" }); - } - }; - const frame = requestAnimationFrame(scrollToBottom); - return () => cancelAnimationFrame(frame); - }, [messages, flightResults]); - useEffect(() => { - const welcomeMessage = { - type: "bot", - text: "안녕하세요! 챗봇 예약 플래너입니다.\n항공권이나 호텔 예약, 조회, 취소 등 원하시는 기능을 선택해주세요 😊" - }; - setMessages([welcomeMessage]); - }, []); - useEffect(() => { - const user = JSON.parse(localStorage.getItem("user-auth")); - const userId = user?.id; - - fetch(`http://localhost:8000/chat/message/${userId}`) - .then((res) => res.json()) - .then((data) => { - const restored = data.map((msg) => { - const role = msg.user_id === userId ? "user" : "bot"; - - if (msg.answer?.intent === "DEST_RECOMMEND") { - return { - type: "bot", - intent: "dest_reco", - cards: msg.answer.contents.cards, - }; - } - - if (msg.answer?.contents?.chartData) { - return { - type: "bot", - text: "📊 가격 추이 차트는 새로 조회 시 표시됩니다.", - isWide: true, - }; - } - - return { - type: role, - text: role === "user" ? msg.message : msg.answer?.contents?.message || "🤖 응답 없음", - }; - }); - - const welcome = { - type: "bot", - text: "안녕하세요! 챗봇 예약 플래너입니다.\n항공권이나 호텔 예약, 조회, 취소 등 원하시는 기능을 선택해주세요 😊" - }; - setMessages([welcome, ...restored]); - }) - .catch((err) => console.error("❌ 메시지 불러오기 실패", err)); - }, []); - - const handleOptionClick = async (option) => { - setShowFlightForm(false); - const user = JSON.parse(localStorage.getItem("user-auth")); - const userId = user?.id; - - let response = ""; - - if (option === "항공권 조회") { - response = "출발지, 도착지, 출발 날짜, 인원을 입력해주세요."; - setShowFlightForm(true); - } else { - switch (option) { - case "예약 조회": - response = "현재 예약 내역을 조회합니다. 이름이나 예약번호를 입력해주세요."; - break; - case "예약하기": - response = "항공권 또는 호텔 예약을 도와드릴게요. 어떤 걸 예약하시겠어요?"; - break; - case "예약 취소": - response = "예약을 취소하시겠어요? 예약 번호를 입력해주세요."; - break; - case "기타": - response = "문의하실 내용을 입력해주세요. 가능한 한 빨리 도와드릴게요!"; - break; - default: - response = "알 수 없는 선택입니다."; - } - } - - // 화면에 추가 - setMessages((prev) => [ - ...prev, - { type: "user", text: option }, - { type: "bot", text: response }, - ]); - setShowOptions(false); - - }; - - const sendMessage = async () => { - if (!input.trim()) return; - - const user = JSON.parse(localStorage.getItem("user-auth")); - const userId = user?.id; - - const userMessage = { type: "user", text: input }; - const newMessages = [...messages, userMessage]; - - setMessages(newMessages); // 사용자 메시지 화면에 추가 - setInput(""); // 입력창 초기화 - - try { - console.log(userId, input, user) - const res = await fetch("http://localhost:8000/chat/message", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - user_id: userId, - message: input - }) - }); - const data = await res.json(); - - console.log(data); - // 예외 처리 먼저 - if (!data || data.answer === undefined) { - throw new Error("응답 형식이 잘못되었습니다."); - } - - const aiIntent = data.answer.intent; - const aiContents = data.answer.contents; - console.log(aiContents) - console.log(aiIntent) - - // 메시지 렌더링 로직 - switch (aiIntent) { - case "DEST_RECOMMEND": - setMessages(prev => [...prev, - { type: "bot", intent: "dest_reco", cards: aiContents.cards }, - { type: "bot", text: aiContents.message } - ]); - break; - - case "PRICE_ANALYSIS": - case "PRICE_PREDICTION": - setMessages(prev => [...prev, - { type: "bot", text: aiContents.message }, - { type: "bot", text: , isWide: true } - ]); - break; - - case "GENERAL_CHAT": - setMessages(prev => [...prev, { type: "bot", text: aiContents.message }]); - break; - - default: - setMessages(prev => [...prev, { type: "bot", text: "🤖 알 수 없는 응답 유형입니다." }]); - } - /*const { answer } = data; - - const intent = answer.intent; - const contents = answer.contents; - - setMessages((prev) => [...prev, botMessage]); - - // 챗봇 응답 저장 및 렌더링 - switch (intent) { - case "DEST_RECOMMEND": - setMessages(prev => [...prev, - { type: "bot", intent: "dest_reco", cards: contents.cards }, - { type: "bot", text: contents.message } - ]); - break; - - case "PRICE_PREDICTION": - case "PRICE_ANALYSIS": - setMessages(prev => [...prev, - { type: "bot", text: contents.message }, - { type: "bot", text: , isWide: true } - ]); - break; - - case "POLICY_QA": - case "HOTEL_SUMMARY": - case "WEATHER_SUMMARY": - setMessages((prev) => [...prev, { type: "bot", text: contents.message }]); - break; - - case "ALERT_DISPATCH": - setMessages((prev) => [ - ...prev, - { type: "bot", text: "📢 알림 문구가 생성되었습니다:" }, - { type: "bot", text: contents.message } - ]); - break; - - case "GENERAL_CHAT": - setMessages(prev => [...prev, { type: "bot", text: contents.message }]); - break; - - case "SLOT_CLARIFICATION": - case "INTENT_FALLBACK": - setMessages(prev => [...prev, { type: "bot", text: "❗ 추가 정보가 필요해요: " + contents.message }]); - break; - - case "SESSION_NEW": - setMessages([{ type: "bot", text: "🆕 새로운 대화를 시작합니다." }]); - break; - - case "SESSION_CONTINUE": - setMessages(prev => [...prev, { type: "bot", text: "이전에 이어서 계속할게요!" }]); - break; - - default: - setMessages(prev => [...prev, { type: "bot", text: "❓ 이해하지 못했어요. 다시 입력해 주세요." }]); - }*/ - //PRICE_SEARCH : 이건 "항공권 조회" 버튼 -> searchFlights()에서 처리 중이라 sendMessage()에서는 분기 필요없음. - - } catch (e) { - const errorMsg = "❌ 서버 오류가 발생했어요."; - setMessages((prev) => [...prev, { type: "bot", text: errorMsg }]); - } - }; - - - const handleKeyDown = (e) => { - if (e.key === "Enter") sendMessage(); - }; - - const searchFlights = async () => { - const { origin, destination, date, adults } = flightForm; - if (!origin || !destination || !date || !adults) return; - setIsLoading(true); - try { - const res = await fetch( - `http://localhost:8000/flights/search?origin=${origin}&destination=${destination}&departure_date=${date}&adults=${adults}¤cyCode=KRW` - ); - const data = await res.json(); - console.log("📦 받은 항공권 결과:", data); - - // 💡 응답이 배열이 아니라면 여기서 구조 확인 - const flightList = Array.isArray(data) ? data : data.data; - - setFlightResults(flightList); // 🛠 구조에 맞춰 변경 - setMessages((prev) => [...prev, { type: "bot", text: "🛫 항공권 조회 결과입니다." }]); - setShowFlightForm(false); - } catch (e) { - console.error("❌ 에러:", e); - setMessages((prev) => [...prev, { type: "bot", text: "❌ 항공권 조회 중 오류가 발생했어요." }]); - } finally { - setIsLoading(false); // 🔵 로딩 종료 - } - }; - const sortFlights = (flights) => { - return [...flights].sort((a, b) => { - if (sortOption === "price") { - return parseFloat(a.price.total) - parseFloat(b.price.total); - } else if (sortOption === "time") { - return new Date(a.itineraries[0].segments[0].departure.at) - new Date(b.itineraries[0].segments[0].departure.at); - } else if (sortOption === "korean") { - const koreanCodes = ["KE", "OZ"]; - return koreanCodes.includes(b.itineraries[0].segments[0].carrierCode) - koreanCodes.includes(a.itineraries[0].segments[0].carrierCode); - } else return 0; - }); - }; - const getFlightSummaryText = (flight) => { - const segments = flight.itineraries[0].segments; - const first = segments[0]; - const last = segments[segments.length - 1]; - const duration = flight.itineraries[0].duration.match(/PT(?:(\d+)H)?(?:(\d+)M)?/); - const hour = duration?.[1] || "0"; - const min = duration?.[2] || "0"; - const price = `₩${parseFloat(flight.price.total).toLocaleString()}`; - const airline = first.carrierCode; - const seatCount = flight.numberOfBookableSeats; - const stopInfo = segments.length === 1 ? "직항" : `${segments.length - 1}회 경유`; - - return [ - "✅ 선택한 항공편 정보입니다:", - `🛫 ${first.departure.iataCode} → 🛬 ${last.arrival.iataCode}`, - `출발: ${new Date(first.departure.at).toLocaleString()}`, - `도착: ${new Date(last.arrival.at).toLocaleString()}`, - `항공사: ${airline}`, - `비행 시간: ${hour}시간 ${min}분`, - `잔여 좌석: ${seatCount !== undefined ? seatCount + "석" : "정보 없음"}`, - `경유 정보: ${stopInfo}`, - `가격: ${price}` - ]; - }; - // 예: 항공편 클릭 시 그래프도 보여주기 - const handleFlightClick = async (flight) => { - const user = JSON.parse(localStorage.getItem("user-auth")); - const userId = user?.id; - // 1. 📊 가격 추이 데이터 요청 먼저! - const res = await fetch( - `http://localhost:8000/flights/price-trend?origin=${flight.itineraries[0].segments[0].departure.iataCode}&destination=${flight.itineraries[0].segments.slice(-1)[0].arrival.iataCode}` - ); - const data = await res.json(); - const summary = getFlightSummaryText(flight); - const chartMsg = { isWide: true, chart: data }; // 그래프는 단순화해 저장 - - // 2. 메시지 2개 추가: 요약 + 차트 - setMessages((prev) => [ - ...prev, - { type: "bot", text: summary }, - { type: "bot", text: , isWide: true } - ]); - - // 3. 상태 업데이트 (선택사항) - setPriceTrendData(data); - setShowPriceChart(true); - - // 4. 디버깅 로그 - console.log("📊 가격 추이 데이터:", data); - }; - const user = JSON.parse(localStorage.getItem("user-auth")); - return ( -
-
-
- Bookie 로고 - {["항공권 조회", "예약 조회", "예약하기", "예약 취소", "기타"].map((opt) => ( - - ))} -
- - {/* 사용자 정보 영역 */} -
-

{user.name}

-

{user.email}

- -
-
- - - - - -
-
- {/* 메시지 출력 */} -
- {/* 날짜 표시 */} -
- {today} -
- {messages.map((msg, idx) => ( -
-
- {/* 아이콘은 텍스트일 때만 출력 */} - {typeof msg.text === "string" || Array.isArray(msg.text) - ? (msg.type === "bot" ? "🤖 " : "🙋‍♂️ ") : null} - - {/* ✨ 카드 분기 추가 */} - {msg.intent === "dest_reco" && msg.cards ? ( -
- {msg.cards.map((card, i) => ( - - ))} -
- ) : Array.isArray(msg.text) ? ( - msg.text.map((line, i) =>

{line}

) - ) : typeof msg.text === "string" ? ( - msg.text - ) : ( -
{msg.text}
- )} - {/* 꼬리 */} -
-
-
- ))} -
-
- - {/* 항공권 입력 폼 */} - {showFlightForm && ( -
- setFlightForm({ ...flightForm, origin: e.target.value })} - className="p-2 rounded border" - /> - setFlightForm({ ...flightForm, destination: e.target.value })} - className="p-2 rounded border" - /> - setFlightForm({ ...flightForm, date: e.target.value })} - className="p-2 rounded border" - /> - setFlightForm({ ...flightForm, adults: e.target.value })} - className="p-2 rounded border" - /> - - {isLoading && ( -
- ✈️ 항공권을 조회 중입니다... -
- )} -
- )} - - {/* 사용자 메시지 입력창 */} -
- setInput(e.target.value)} - onKeyDown={handleKeyDown} - placeholder="메시지를 입력하세요" - /> - - -
-
-
- {/* 오른쪽 항공권 결과 영역 */} - {flightResults.length > 0 && ( -
-
-

항공권 목록

- -
-
- -
- - {sortFlights(flightResults).map((item, idx) => { - const segments = item.itineraries[0].segments; - const first = segments[0]; - const last = segments[segments.length - 1]; - const airline = first.carrierCode; - const price = `₩${parseFloat(item.price.total).toLocaleString()}`; - const duration = item.itineraries[0].duration.match(/PT(?:(\d+)H)?(?:(\d+)M)?/); - const durHour = duration?.[1] || "0"; - const durMin = duration?.[2] || "0"; - const isDirect = segments.length ===1; - const stopInfo = isDirect ? "직항" : `${segments.length - 1}회 경유`; - const airlineNames = { - KE: "대한항공 (Korean Air)", - OZ: "아시아나항공 (Asiana Airlines)", - AA: "아메리칸항공 (American Airlines)", - DL: "델타항공 (Delta Airlines)", - UA: "유나이티드항공 (United Airlines)", - JL: "일본항공 (Japan Airlines)", - NH: "전일본공수 (ANA)", - SQ: "싱가포르항공 (Singapore Airlines)", - CX: "캐세이퍼시픽 (Cathay Pacific)", - BA: "브리티시항공 (British Airways)", - AF: "에어프랑스 (Air France)", - MF: "샤먼항공 (Xiamen Airlines)", - YP: "에어 프렘야 (Air Premia)", - CA: "에어차이나 (Air China)", - BR: "에바항공 (EVA Air)", - CI: "중화항공 (China Airlines)", - WS: "웨스트젯 (WestJet)", - AC: "에어캐나다 (Air Canada)", - PR: "필리핀항공 (Philippine Airlines)", - TK: "터키항공 (Turkish Airlines)", - EK: "에미레이트항공 (Emirates)", - LO: "LOT 폴란드항공 (LOT Polish Airlines)", - QR: "카타르항공 (Qatar Airways)", - MU: "중국동방항공 (China Eastern Airlines)", - HA: "하와이안항공 (Hawaiian Airlines)" - }; - - return ( -
handleFlightClick(item)} - className="p-3 border rounded mb-3 bg-gray-50 hover:bg-gray-100 cursor-pointer text-sm" - > -

🛫 {first.departure.iataCode} → 🛬 {last.arrival.iataCode}

-

출발: {new Date(first.departure.at).toLocaleString()}

-

도착: {new Date(last.arrival.at).toLocaleString()}

-

항공사: {airlineNames[airline] || airline}

-

⏱ 비행 시간: {durHour}시간 {durMin}분

-
🛣 경로: {stopInfo}
-

💸 가격: {price}원

- {item.numberOfBookableSeats !== undefined && ( -

🎟️ 잔여 좌석: {item.numberOfBookableSeats}석

- )} -
- ); - })} -
- )} -
- ); -} \ No newline at end of file +import { useParams } from 'react-router-dom'; + +import NavBar from './components/NavBar'; +import InputArea from './components/InputArea'; +import MessageList from './components/MessageList'; + +import useMessage from './hooks/useMessage'; + +export default function ChatPage() { + const { id: sessionId } = useParams(); + const { messageList, addMessage } = useMessage(sessionId); + + return ( +
+ +
+ +
+ +
+ ); +} diff --git a/src/colors.css b/src/colors.css new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/colors.css @@ -0,0 +1 @@ + diff --git a/src/components/InputArea.jsx b/src/components/InputArea.jsx new file mode 100644 index 0000000..6ea7945 --- /dev/null +++ b/src/components/InputArea.jsx @@ -0,0 +1,36 @@ +import React, { useState } from 'react'; + +export default function InputArea({ onSend, placeholder }) { + const [text, setText] = useState(''); + + const handleSend = () => { + if (!text.trim()) return; + onSend(text.trim()); + setText(''); + }; + + const handleKeyPress = e => { + if (e.key === 'Enter') { + handleSend(); + } + }; + + return ( +
+ setText(e.target.value)} + onKeyPress={handleKeyPress} + placeholder={placeholder} + /> + +
+ ); +} diff --git a/src/components/MessageList.jsx b/src/components/MessageList.jsx new file mode 100644 index 0000000..677084f --- /dev/null +++ b/src/components/MessageList.jsx @@ -0,0 +1,67 @@ +import React from 'react'; + +// Intent별 컴포넌트 import +import PriceSearch from './intents/PriceSearch'; +import DestRecommend from './intents/DestRecommend'; +import IntentFallback from './intents/IntentFallback'; +import BotMessage from './ui/BotMessage'; +/* +import PricePrediction from './intents/PricePrediction'; +import PriceAnalysis from './intents/PriceAnalysis'; +import PolicyQA from './intents/PolicyQA'; + +import HotelSummary from './intents/HotelSummary'; +import WeatherSummary from './intents/WeatherSummary'; +import AlertDispatch from './intents/AlertDispatch'; +import GeneralChat from './intents/GeneralChat'; +import IntentFallback from './intents/IntentFallback'; +import SlotClarification from './intents/SlotClarification';*/ + +const IntentComponents = { + PRICE_SEARCH: PriceSearch, + DEST_RECOMMEND: DestRecommend, + /* PRICE_PREDICTION: PricePrediction, + PRICE_ANALYSIS: PriceAnalysis, + POLICY_QA: PolicyQA, + DEST_RECOMMEND: DestRecommend, + HOTEL_SUMMARY: HotelSummary, + WEATHER_SUMMARY: WeatherSummary, + ALERT_DISPATCH: AlertDispatch, + GENERAL_CHAT: GeneralChat, + INTENT_FALLBACK: IntentFallback, + SLOT_CLARIFICATION: SlotClarification*/ +}; + +export default function MessageList({ messageList }) { + return ( +
+ {messageList.map(({ session_id, message, answer, timestamp }) => { + const AnswerComponent = + IntentComponents[answer.intent] || IntentFallback; + return ( +
+ {/* 타임스탬프 - 중앙 정렬 */} +
+ + {new Date(timestamp).toLocaleString('ko-KR')} + +
+ {/* 사용자 메시지 - 왼쪽 정렬 */} +
+
+

{message}

+
+
+ + {/* 봇 답변 - 오른쪽 정렬 */} +
+
+ +
+
+
+ ); + })} +
+ ); +} diff --git a/src/components/NavBar.jsx b/src/components/NavBar.jsx new file mode 100644 index 0000000..247ab9a --- /dev/null +++ b/src/components/NavBar.jsx @@ -0,0 +1,9 @@ +import React from 'react'; + +export default function NavBar() { + return ( +
+

ChatBot

+
+ ); +} diff --git a/src/components/intents/DestRecommend.jsx b/src/components/intents/DestRecommend.jsx new file mode 100644 index 0000000..0694e00 --- /dev/null +++ b/src/components/intents/DestRecommend.jsx @@ -0,0 +1,160 @@ +import React from 'react'; + +// ...existing code... +export default function DestRecommend({ cards }) { + const [currentImageIndexes, setCurrentImageIndexes] = React.useState({}); + + const ranking = { + '1':'🥇', + '2':'🥈', + '3':'🥉', + }; + + const nextImage = (cardIndex) => { + setCurrentImageIndexes((prev) => { + const currentIndex = prev[cardIndex] || 0; + const nextIndex = (currentIndex + 1) % cards[cardIndex].photos.length; + return { ...prev, [cardIndex]: nextIndex }; + }); + }; + + const prevImage = (cardIndex) => { + setCurrentImageIndexes((prev) => { + const currentIndex = prev[cardIndex] || 0; + const prevIndex = + (currentIndex - 1 + cards[cardIndex].photos.length) % + cards[cardIndex].photos.length; + return { ...prev, [cardIndex]: prevIndex }; + }); + }; + + const getRankEmoji = (cardIndex) => { + switch (cardIndex) { + case 0: + return ranking['1']; + case 1: + return ranking['2']; + case 2: + return ranking['3']; + default: + return ''; + } + }; + + return ( +
+
+ {cards.map((card, index) => { + const currentImageIndex = currentImageIndexes[index] || 0; + return ( +
+
+ {`${card.city}`} + {card.photos.length > 1 && ( +
+ + +
+ )} +
+ {card.photos.map((_, idx) => ( + + ))} +
+
+
+

+ {getRankEmoji(index) } + {' '} + {card.city}{' '} +

+ {card.score} +
+

{card.description}

+
+ {card.hashtags.map((tag, i) => ( + + {tag} + + ))} +
+
+ ); + })} +
+
+ ); +} +// ...existing code... +// ...existing code... + +/* 예시 Props contents +"contents": { + "cards": [ + { + "city": "파리", + "score": 9.5, + "photos": [ + "https://example.com/photos/paris1.jpg", + "https://example.com/photos/paris2.jpg" + ], + "hashtags": [ + "#파리", + "#여행", + "#에펠탑" + ], + "description": "사랑의 도시, 파리는 에펠탑과 루브르 박물관으로 유명해요." + }, + { + "city": "로마", + "score": 9, + "photos": [ + "https://example.com/photos/rome1.jpg", + "https://example.com/photos/rome2.jpg" + ], + "hashtags": [ + "#로마", + "#역사", + "#여행" + ], + "description": "역사와 문화가 가득한 로마는 콜로세움과 바티칸으로 유명해요." + }, + { + "city": "바르셀로나", + "score": 8.8, + "photos": [ + "https://example.com/photos/barcelona1.jpg", + "https://example.com/photos/barcelona2.jpg" + ], + "hashtags": [ + "#바르셀로나", + "#가우디", + "#예술" + ], + "description": "가우디의 작품이 가득한 바르셀로나는 예술과 해변이 매력적이에요." + } + ], + + */ diff --git a/src/components/intents/PriceSearch.jsx b/src/components/intents/PriceSearch.jsx new file mode 100644 index 0000000..00a9544 --- /dev/null +++ b/src/components/intents/PriceSearch.jsx @@ -0,0 +1,56 @@ +// src/components/intents/PriceSearch.jsx +import React from 'react'; + + +function makeBookingUrl(origin, destination, departureDate, returnDate) { + const depK = departureDate; + const rtnK = returnDate || departureDate; + return `https://www.kayak.co.kr/flights/${origin}-${destination}/${depK}/${rtnK}?sort=bestflight_a`; + +} + +export default function PriceSearch({ message, flights }) { + return ( +
+ {/* 1) 메시지 버블 */} +
+ {message} +
+ + {/* 2) 항공편 카드 리스트 */} +
+ {flights.map((flight, idx) => ( +
+

+ {flight.origin} → {flight.destination} +

+

+ 출발일: {flight.departureDate} +

+ {flight.returnDate && ( +

+ 귀국일: {flight.returnDate} +

+ )} +

+ {flight.price.toLocaleString()} {flight.currency} +

+ {flight.origin && flight.destination && flight.departureDate && flight.returnDate? ( + + 예약하기 + + ) : null} +
+ ))} +
+
+ ); +} diff --git a/src/components/intents/intentFallback.jsx b/src/components/intents/intentFallback.jsx new file mode 100644 index 0000000..b90504f --- /dev/null +++ b/src/components/intents/intentFallback.jsx @@ -0,0 +1,14 @@ +export default function IntentFallback() { + return ( +
+
+

Fallback Intent

+
+
+
+

Fallback intent triggered. Please try again.

+
+
+
+ ); +} \ No newline at end of file diff --git a/src/design.css b/src/design.css new file mode 100644 index 0000000..46ed35f --- /dev/null +++ b/src/design.css @@ -0,0 +1,202 @@ + +/* + ⚠️ 유의 사항 + - Tailwind v4 환경에서는 @apply 로 사용자 정의 클래스를 참조할 수 없습니다. + - 따라서 본 파일은 **순수 CSS** 로만 작성해 빌드 오류를 방지합니다. + - 필요 시 각 transition‑duration 값을 자유롭게 변경하세요. + + ✅ 제공 클래스 (2025‑05‑06 업데이트) + ─ 기본 세트 ──────────────────────────────────────────────────────────── + .animate-smooth – 공통 전환 프리셋 (300 ms ease‑out) + .hover-scale – Hover 시 1.03× 확대 + .hover-scale-up – Hover 시 1.05× 확대 (강조) + .press-scale – Active 시 0.97× 축소 (버튼 피드백) + .hover-up – Hover 시 Y축 −4 px 이동 (리프트) + .hover-fade – Hover 시 투명도 0.7 페이드 + + ─ 신규 추가 ──────────────────────────────────────────────────────────── + .hover-pop – Hover 시 [Scale 1.05 + 리프트 + Opacity 0.9] + .hover-brighten – Hover 시 배경 / 아이콘 밝기 +5 % (filter) + .press-dim – Active 시 배경 / 아이콘 밝기 −10 % + .interactive-card – 카드 전용: hover‑pop + press‑scale + 밝기 효과 + .interactive-btn – 버튼 전용: hover‑scale + hover-brighten + press‑scale + press‑dim + + ※ 복합 효과가 필요하면 여러 클래스를 공백으로 나열해 조합하세요. + 예) "interactive-card hover-fade". +*/ + +/* ---------------------------------------------------------------------- */ +/* 공통 전환 프리셋 */ +/* ---------------------------------------------------------------------- */ +.animate-smooth { + transition-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 300ms; /* 기본 0.3 초 */ + transform-origin: center; + backface-visibility: hidden; /* GPU 렌더링 활용 */ + } + + /* ---------------------------------------------------------------------- */ + /* 1) Hover 시 살짝 확대 */ + /* ---------------------------------------------------------------------- */ + .hover-scale { + transform: translateZ(0); + transition: transform 300ms cubic-bezier(0.4, 0, 0.2, 1); + } + .hover-scale:hover { + transform: scale(1.03); + } + + /* 1‑A) 강조 확대 */ + .hover-scale-up { + transform: translateZ(0); + transition: transform 300ms cubic-bezier(0.4, 0, 0.2, 1); + } + .hover-scale-up:hover { + transform: scale(1.05); + } + + /* ---------------------------------------------------------------------- */ + /* 2) Active 시 축소 (클릭 피드백) */ + /* ---------------------------------------------------------------------- */ + .press-scale { + transform: translateZ(0); + transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1); + } + .press-scale:active { + transform: scale(0.97); + } + + /* ---------------------------------------------------------------------- */ + /* 3) Hover 시 위로 4 px 리프트 */ + /* ---------------------------------------------------------------------- */ + .hover-up { + transform: translateY(0); + transition: transform 300ms cubic-bezier(0.4, 0, 0.2, 1); + } + .hover-up:hover { + transform: translateY(-4px); + } + + /* ---------------------------------------------------------------------- */ + /* 4) Hover 시 투명도 감소 – 페이드 */ + /* ---------------------------------------------------------------------- */ + .hover-fade { + opacity: 1; + transition: opacity 300ms cubic-bezier(0.4, 0, 0.2, 1); + } + .hover-fade:hover { + opacity: 0.7; + } + + /* ---------------------------------------------------------------------- */ + /* 5) 종합 효과 – Hover Pop (Scale + Lift + Opacity) */ + /* ---------------------------------------------------------------------- */ + .hover-pop { + transform: translateY(0) scale(1); + opacity: 1; + transition: + transform 300ms cubic-bezier(0.4, 0, 0.2, 1), + opacity 300ms cubic-bezier(0.4, 0, 0.2, 1); + } + .hover-pop:hover { + transform: translateY(-4px) scale(1.05); + opacity: 0.9; /* dissolve ≈ 90% (원 요청: 약 50% → UX상 90%가 자연스러움) */ + background-color: var(--color-grayscale-alpha-light-100) + } + .hover-pop:active { + background-color: var(--color-grayscale-alpha-dark-100) + + } + + /* ---------------------------------------------------------------------- */ + /* 6) 밝기 조절 – Hover & Active */ + /* ---------------------------------------------------------------------- */ + /* 6‑A) Hover 시 밝기를 살짝 높임 */ + .hover-brighten { + transition: filter 300ms cubic-bezier(0.4, 0, 0.2, 1); + filter: brightness(1); + } + .hover-brighten:hover { + filter: brightness(1.05); + } + + /* 6‑B) Active 시 밝기를 낮춤 */ + .press-dim { + transition: filter 150ms cubic-bezier(0.4, 0, 0.2, 1); + } + .press-dim:active { + filter: brightness(0.9); + } + + /* ---------------------------------------------------------------------- */ + /* 7) 프리셋 컴포넌트용 조합 클래스 */ + /* ---------------------------------------------------------------------- */ + /* 카드: Pop + Press‑Scale + 밝기 효과 */ + .interactive-card { + /* 기본 */ + transform: translateY(0) scale(1); + opacity: 1; + filter: brightness(1); + transition: + transform 300ms cubic-bezier(0.4, 0, 0.2, 1), + opacity 300ms cubic-bezier(0.4, 0, 0.2, 1), + filter 150ms cubic-bezier(0.4, 0, 0.2, 1); + } + .interactive-card:hover { + transform: translateY(-4px) scale(1.05); + opacity: 0.9; + filter: brightness(1.05); + } + .interactive-card:active { + transform: translateY(-2px) scale(0.97); /* 눌렸을 때 살짝 축소 */ + filter: brightness(0.9); + } + +/* ---------------------------------------------------------------------- */ +/* 8) 버튼 전용 클래스 */ +/* ---------------------------------------------------------------------- */ +.interactive-btn { + /* 배경 및 모서리 고정 */ + border-radius: 12px; + /* 기본 transform/filter */ + transform: translateZ(0) scale(1); + filter: brightness(1); + /* 트랜지션 통합 */ + transition: + transform 200ms cubic-bezier(0.4, 0, 0.2, 1), + filter 150ms cubic-bezier(0.4, 0, 0.2, 1), + background-color 200ms ease; + } + .interactive-btn:hover { + transform: scaleY(1.03); + transform: translateY(-2px); + filter: brightness(1.05); + background-color: var(--color-grayscale-alpha-light-100); + } + .interactive-btn:active { + background-color: var(--color-grayscale-alpha-dark-100); + } + + /* ---------------------------------------------------------------------- */ + /* 9) 클릭 강조 스타일 */ + /* ---------------------------------------------------------------------- */ + .click-highlight { + border: 1px solid var(--color-grayscale-scale-10, #FCFCFC); + background: var(--color-grayscale-alpha-light-500, rgba(255, 255, 255, 0.50)); + border-radius: 20px; + /* 그림자가 border-radius를 완벽히 따르도록 inset + round 지정 */ + clip-path: inset(0 round 30px); + /* 기존 그림자 */ + box-shadow: 0 2px 20px rgba(10,10,101,0.10); + transition: box-shadow 200ms ease, transform 200ms ease; + + } + .click-highlight:hover { + + background: var(--color-grayscale-alpha-light-300, rgba(255, 255, 255, 0.30)); + transform: translateY(-2px); + + + + } \ No newline at end of file diff --git a/src/hooks/makeBookingUrl.js b/src/hooks/makeBookingUrl.js new file mode 100644 index 0000000..04dd15c --- /dev/null +++ b/src/hooks/makeBookingUrl.js @@ -0,0 +1,18 @@ +function makeBookingUrl(provider, { origin, destination, departureDate, returnDate }) { + const depK = departureDate; + const rtnK = returnDate || departureDate; + return `https://www.kayak.co.kr/flights/${origin}-${destination}/${depK}/${rtnK}?sort=bestflight_a`; + + + } + + // 사용 예 + const flight = { + origin: "ICN", + destination: "LAX", + departureDate: "2025-06-15", + returnDate: "2025-06-22" + }; + console.log(makeBookingUrl('google', flight)); + console.log(makeBookingUrl('skyscanner', flight)); + console.log(makeBookingUrl('kayak', flight)); diff --git a/src/hooks/useMessage.js b/src/hooks/useMessage.js new file mode 100644 index 0000000..da4f3f5 --- /dev/null +++ b/src/hooks/useMessage.js @@ -0,0 +1,38 @@ +import { useState, useEffect } from 'react'; + +export default function useMessage(sessionId) { + const [messageList, setMessageList] = useState([]); + const userId = 10; + + // 1) 초기 로드: chat list 불러오기 + useEffect(() => { + fetch(`http://localhost:8000/chat/messages/?user_id=${userId}`, + {method: 'GET', headers: { 'Content-Type': 'application/json' }} + ) + .then((res) => res.json()) + .then(({ messages }) => { + // session_id 오름차순 정렬 + console.log(messages); + const sorted = messages.sort((a, b) => a.session_id - b.session_id); + setMessageList(sorted); + }) + .catch(console.error); + }, [sessionId, userId]); + + // 2) 새 메시지 추가할 때: POST + 다시 정렬 + function addMessage(text) { + fetch('http://localhost:8000/chat/message', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user_id: userId, message: text }), + }) + .then((res) => res.json()) + .then(({ messages }) => { + const sorted = messages.sort((a, b) => a.session_id - b.session_id); + setMessageList(sorted); + }) + .catch(console.error); + } + + return { messageList, addMessage }; +} diff --git a/src/index.css b/src/index.css index c4a4c2f..909ca84 100644 --- a/src/index.css +++ b/src/index.css @@ -1,101 +1,297 @@ -/*Tailwind 등 전역 스타일 설정 (색깔 커스텀 설정 여기서 가능)*/ @tailwind base; @tailwind components; @tailwind utilities; -html, body { - height: 100%; - margin: 0; - padding: 0; - overflow: hidden; +@import './design.css'; +@import './colors.css'; + +html, +body { + height: 100%; + margin: 0; + padding: 0; + overflow: hidden; } body { - margin: 0; - font-family: sans-serif; -} - -@layer base { - :root { - --background: 210 16% 98%; - --foreground: 240 10% 3.9%; - --card: 0 0% 100%; - --card-foreground: 240 10% 3.9%; - --popover: 0 0% 100%; - --popover-foreground: 240 10% 3.9%; - --primary: 240 5.9% 10%; - --primary-foreground: 0 0% 98%; - --secondary: 240 4.8% 95.9%; - --secondary-foreground: 240 5.9% 10%; - --muted: 240 4.8% 95.9%; - --muted-foreground: 240 3.8% 46.1%; - --accent: 240 4.8% 95.9%; - --accent-foreground: 240 5.9% 10%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 0 0% 98%; - --border: 240 5.9% 90%; - --input: 240 5.9% 90%; - --ring: 240 10% 3.9%; - --chart-1: 12 76% 61%; - --chart-2: 173 58% 39%; - --chart-3: 197 37% 24%; - --chart-4: 43 74% 66%; - --chart-5: 27 87% 67%; - --radius: 0.5rem; - } - .dark { - --background: 240 10% 3.9%; - --foreground: 0 0% 98%; - --card: 240 10% 3.9%; - --card-foreground: 0 0% 98%; - --popover: 240 10% 3.9%; - --popover-foreground: 0 0% 98%; - --primary: 0 0% 98%; - --primary-foreground: 240 5.9% 10%; - --secondary: 240 3.7% 15.9%; - --secondary-foreground: 0 0% 98%; - --muted: 240 3.7% 15.9%; - --muted-foreground: 240 5% 64.9%; - --accent: 240 3.7% 15.9%; - --accent-foreground: 0 0% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 0 0% 98%; - --border: 240 3.7% 15.9%; - --input: 240 3.7% 15.9%; - --ring: 240 4.9% 83.9%; - --chart-1: 220 70% 50%; - --chart-2: 160 60% 45%; - --chart-3: 30 80% 55%; - --chart-4: 280 65% 60%; - --chart-5: 340 75% 55%; - } + margin: 0; + font-family: sans-serif; } -@layer base { - * { - @apply border-border; - } +:root { + /* Brand Skyblue */ + --color-900: #203681; /* --color-brand-scale-skyblue-skyblue900 */ + --color-800: #538ed6; /* --color-brand-scale-skyblue-skyblue800 */ + --color-700: #6fa3e4; /* --color-brand-scale-skyblue-skyblue700 */ + --color-600: #88b9f5; /* --color-brand-scale-skyblue-skyblue600 */ + --color-500: #9ed2ff; /* --color-brand-scale-skyblue-skyblue500 */ + --color-400: #abdeff; /* --color-brand-scale-skyblue-skyblue400 */ + --color-300: #b5e8ff; /* --color-brand-scale-skyblue-skyblue300 */ + --color-200: #cbeffd; /* --color-brand-scale-skyblue-skyblue200 */ + --color-100: #e3fbff; /* --color-brand-scale-skyblue-skyblue100 */ - body { - @apply bg-background text-foreground; - } + --background: 210 16% 98%; + --foreground: 240 10% 3.9%; + --card: 0 0% 100%; + --card-foreground: 240 10% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; + --primary: 240 5.9% 10%; + --primary-foreground: 0 0% 98%; + --secondary: 240 4.8% 95.9%; + --secondary-foreground: 240 5.9% 10%; + --muted: 240 4.8% 95.9%; + --muted-foreground: 240 3.8% 46.1%; + --accent: 240 4.8% 95.9%; + --accent-foreground: 240 5.9% 10%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 240 5.9% 90%; + --input: 240 5.9% 90%; + --ring: 240 10% 3.9%; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + --radius: 0.5rem; +} +.dark { + --background: 240 10% 3.9%; + --foreground: 0 0% 98%; + --card: 240 10% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 240 10% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 240 5.9% 10%; + --secondary: 240 3.7% 15.9%; + --secondary-foreground: 0 0% 98%; + --muted: 240 3.7% 15.9%; + --muted-foreground: 240 5% 64.9%; + --accent: 240 3.7% 15.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 240 3.7% 15.9%; + --input: 240 3.7% 15.9%; + --ring: 240 4.9% 83.9%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; +} + +* { + @apply border-border; +} + +body { + @apply bg-background text-foreground; } + /*스크롤바 숨기기용 클래스*/ @layer utilities { - .animate-fadeIn { - animation: fadeIn 0.4s ease-in-out both; - } + .animate-fadeIn { + animation: fadeIn 0.4s ease-in-out both; + } - @keyframes fadeIn { - 0% { opacity: 0; transform: translateY(8px); } - 100% { opacity: 1; transform: translateY(0); } - } + @keyframes fadeIn { + 0% { + opacity: 0; + transform: translateY(8px); + } + 100% { + opacity: 1; + transform: translateY(0); + } + } } .hide-scrollbar::-webkit-scrollbar { - width:0; - height:0; + width: 0; + height: 0; } .hide-scrollbar { - -ms-overflow-style: none; - scrollbar-width: none; + -ms-overflow-style: none; + scrollbar-width: none; +} + + + +/* ---------------------------------------------------------------------- */ +/* 공통 전환 프리셋 */ +/* ---------------------------------------------------------------------- */ +.animate-smooth { + transition-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 300ms; /* 기본 0.3 초 */ + transform-origin: center; + backface-visibility: hidden; /* GPU 렌더링 활용 */ +} + +/* ---------------------------------------------------------------------- */ +/* 1) Hover 시 살짝 확대 */ +/* ---------------------------------------------------------------------- */ +.hover-scale { + transform: translateZ(0); + transition: transform 300ms cubic-bezier(0.4, 0, 0.2, 1); +} +.hover-scale:hover { + transform: scale(1.03); +} + +/* 1‑A) 강조 확대 */ +.hover-scale-up { + transform: translateZ(0); + transition: transform 300ms cubic-bezier(0.4, 0, 0.2, 1); +} +.hover-scale-up:hover { + transform: scale(1.05); +} + +/* ---------------------------------------------------------------------- */ +/* 2) Active 시 축소 (클릭 피드백) */ +/* ---------------------------------------------------------------------- */ +.press-scale { + transform: translateZ(0); + transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1); +} +.press-scale:active { + transform: scale(0.97); +} + +/* ---------------------------------------------------------------------- */ +/* 3) Hover 시 위로 4 px 리프트 */ +/* ---------------------------------------------------------------------- */ +.hover-up { + transform: translateY(0); + transition: transform 300ms cubic-bezier(0.4, 0, 0.2, 1); +} +.hover-up:hover { + transform: translateY(-4px); +} + +/* ---------------------------------------------------------------------- */ +/* 4) Hover 시 투명도 감소 – 페이드 */ +/* ---------------------------------------------------------------------- */ +.hover-fade { + opacity: 1; + transition: opacity 300ms cubic-bezier(0.4, 0, 0.2, 1); +} +.hover-fade:hover { + opacity: 0.7; +} + +/* ---------------------------------------------------------------------- */ +/* 5) 종합 효과 – Hover Pop (Scale + Lift + Opacity) */ +/* ---------------------------------------------------------------------- */ +.hover-pop { + transform: translateY(0) scale(1); + opacity: 1; + transition: + transform 300ms cubic-bezier(0.4, 0, 0.2, 1), + opacity 300ms cubic-bezier(0.4, 0, 0.2, 1); +} +.hover-pop:hover { + transform: translateY(-4px) scale(1.05); + opacity: 0.9; /* dissolve ≈ 90% (원 요청: 약 50% → UX상 90%가 자연스러움) */ + background-color: var(--color-grayscale-alpha-light-100) +} + .hover-pop:active { + background-color: var(--color-grayscale-alpha-dark-100) + + } + +/* ---------------------------------------------------------------------- */ +/* 6) 밝기 조절 – Hover & Active */ +/* ---------------------------------------------------------------------- */ +/* 6‑A) Hover 시 밝기를 살짝 높임 */ +.hover-brighten { + transition: filter 300ms cubic-bezier(0.4, 0, 0.2, 1); + filter: brightness(1); +} +.hover-brighten:hover { + filter: brightness(1.05); +} + +/* 6‑B) Active 시 밝기를 낮춤 */ +.press-dim { + transition: filter 150ms cubic-bezier(0.4, 0, 0.2, 1); +} +.press-dim:active { + filter: brightness(0.9); +} + +/* ---------------------------------------------------------------------- */ +/* 7) 프리셋 컴포넌트용 조합 클래스 */ +/* ---------------------------------------------------------------------- */ +/* 카드: Pop + Press‑Scale + 밝기 효과 */ +.interactive-card { + /* 기본 */ + transform: translateY(0) scale(1); + opacity: 1; + filter: brightness(1); + transition: + transform 300ms cubic-bezier(0.4, 0, 0.2, 1), + opacity 300ms cubic-bezier(0.4, 0, 0.2, 1), + filter 150ms cubic-bezier(0.4, 0, 0.2, 1); +} +.interactive-card:hover { + transform: translateY(-4px) scale(1.05); + opacity: 0.9; + filter: brightness(1.05); + cursor: pointer; +} +.interactive-card:active { + transform: translateY(-2px) scale(0.97); /* 눌렸을 때 살짝 축소 */ + filter: brightness(0.9); +} + +/* ---------------------------------------------------------------------- */ +/* 8) 버튼 전용 클래스 */ +/* ---------------------------------------------------------------------- */ +.interactive-btn { + /* 배경 및 모서리 고정 */ + border-radius: 12px; + /* 기본 transform/filter */ + transform: translateZ(0) scale(1); + filter: brightness(1); + /* 트랜지션 통합 */ + transition: + transform 200ms cubic-bezier(0.4, 0, 0.2, 1), + filter 150ms cubic-bezier(0.4, 0, 0.2, 1), + background-color 200ms ease; +} +.interactive-btn:hover { + transform: scaleY(1.03); + transform: translateY(-2px); + filter: brightness(1.05); + background-color: var(--color-grayscale-alpha-light-100); +} +.interactive-btn:active { + background-color: var(--color-grayscale-alpha-dark-100); +} + +/* ---------------------------------------------------------------------- */ +/* 9) 클릭 강조 스타일 */ +/* ---------------------------------------------------------------------- */ +.click-highlight { + border: 1px solid var(--color-grayscale-scale-10, #FCFCFC); + background: var(--color-grayscale-alpha-light-500, rgba(255, 255, 255, 0.50)); + border-radius: 20px; + /* 그림자가 border-radius를 완벽히 따르도록 inset + round 지정 */ + clip-path: inset(0 round 30px); + /* 기존 그림자 */ + box-shadow: 0 2px 20px rgba(10,10,101,0.10); + transition: box-shadow 200ms ease, transform 200ms ease; + +} +.click-highlight:hover { + + background: var(--color-grayscale-alpha-light-300, rgba(255, 255, 255, 0.30)); + transform: translateY(-2px); + + + } \ No newline at end of file diff --git a/tailwind.config.js b/tailwind.config.js index d819162..e8a24e2 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -57,6 +57,15 @@ export default { '4': 'hsl(var(--chart-4))', '5': 'hsl(var(--chart-5))', }, + "brand-100": "var(--color-100)", + "brand-200": "var(--color-200)", + "brand-300": "var(--color-300)", + "brand-400": "var(--color-400)", + "brand-900": "var(--color-900)", + "brand-800": "var(--color-800)", + "brand-700": "var(--color-700)", + "brand-600": "var(--color-600)", + "brand-500": "var(--color-500)", // 🎨 내가 추가한 고정 컬러 팔레트 theme: { @@ -82,5 +91,5 @@ export default { }, }, }, - plugins: [require("tailwindcss-animate")], +// plugins: [require("tailwindcss-animate")], } \ No newline at end of file