diff --git a/package-lock.json b/package-lock.json index ff6b65a9..f43daaad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@tanstack/react-virtual": "^3.13.12", "class-variance-authority": "^0.7.1", "gsap": "^3.13.0", + "html-to-image": "^1.11.13", "lottie-react": "^2.4.1", "next": "15.5.3", "react": "19.1.0", @@ -6329,6 +6330,12 @@ "node": ">= 0.4" } }, + "node_modules/html-to-image": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", + "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", + "license": "MIT" + }, "node_modules/husky": { "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", diff --git a/package.json b/package.json index 4eaef365..3c01ca92 100644 --- a/package.json +++ b/package.json @@ -22,14 +22,15 @@ "@tanstack/react-virtual": "^3.13.12", "class-variance-authority": "^0.7.1", "gsap": "^3.13.0", + "html-to-image": "^1.11.13", "lottie-react": "^2.4.1", "next": "15.5.3", "react": "19.1.0", "react-dom": "19.1.0", "react-hot-toast": "^2.6.0", + "react-intersection-observer": "^9.16.0", "react-use": "^17.6.0", - "swiper": "^12.0.2", - "react-intersection-observer": "^9.16.0" + "swiper": "^12.0.2" }, "devDependencies": { "@eslint/eslintrc": "^3", diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 00000000..b12f6ffd Binary files /dev/null and b/public/favicon.ico differ diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f35edec6..b72838c1 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -15,6 +15,7 @@ export const metadata: Metadata = { title: { default: 'SSOUL', template: 'SSOUL | %s' }, metadataBase: new URL('http://www.ssoul.life'), description: '칵테일을 좋아하는 사람들을 위한 서비스', + icons: '/favicon.ico', }; export default function RootLayout({ @@ -24,7 +25,7 @@ export default function RootLayout({ }>) { return ( - +
diff --git a/src/domains/recommend/components/ChatList.tsx b/src/domains/recommend/components/ChatList.tsx index 4687b795..658f6470 100644 --- a/src/domains/recommend/components/ChatList.tsx +++ b/src/domains/recommend/components/ChatList.tsx @@ -4,21 +4,18 @@ import BotMessage from './bot/BotMessage'; import NewMessageAlert from './bot/NewMessageAlert'; import UserMessage from './user/UserMessage'; -function ChatList({ messages, userCurrentStep, onSelectedOption }: ChatListProps) { +function ChatList({ messages, userCurrentStep, onSelectedOption, chatRef }: ChatListProps) { const { chatListRef, chatEndRef, showNewMessageAlert, handleCheckBottom, handleScrollToBottom } = useChatScroll(messages[messages.length - 1]?.id); - const getRecommendations = ( - type: string | undefined, - stepData?: StepRecommendation | null - ): RecommendationItem[] => { - if (type !== 'CARD_LIST' || !stepData?.recommendations) return []; - return stepData.recommendations; + const combinedRef = (el: HTMLDivElement) => { + chatListRef.current = el; + if (chatRef) chatRef.current = el; }; return (
@@ -35,8 +32,6 @@ function ChatList({ messages, userCurrentStep, onSelectedOption }: ChatListProps const isTyping = msg.type === 'TYPING'; - const recommendations = getRecommendations(msg.type, msg.stepData); - return (
-

취향에 맞는 칵테일, 저와 함께 찾아볼까요?

+

취향에 맞는 칵테일🤩 저와 함께 찾아볼까요?

{ @@ -45,7 +46,14 @@ function ChatPreview() {
e.preventDefault()} className="w-full max-w-[64rem]"> -
+
+ diff --git a/src/domains/recommend/components/ChatSection.tsx b/src/domains/recommend/components/ChatSection.tsx index 1158db08..c1faf253 100644 --- a/src/domains/recommend/components/ChatSection.tsx +++ b/src/domains/recommend/components/ChatSection.tsx @@ -1,19 +1,22 @@ 'use client'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; import MessageInput from './user/MessageInput'; -import { fetchSendStepMessage, fetchSendTextMessage } from '../api/chat'; +import { fetchGreeting, fetchSendStepMessage, fetchSendTextMessage } from '../api/chat'; import { ChatMessage, stepPayload } from '../types/recommend'; import ChatList from './ChatList'; import { useSelectedOptions } from '../hook/useSelectedOptions'; import { useAuthStore } from '@/domains/shared/store/auth'; import { useChatInit } from '../hook/useChatInit'; import { useChatWarning } from '../hook/useChatWarning'; +import { useChatCapture } from '../hook/useChatCapture'; function ChatSection() { const [messages, setMessages] = useState([]); const [userCurrentStep, setUserCurrentStep] = useState(0); const { selectedOptions, setOption, setStepOption } = useSelectedOptions(); + const chatRef = useRef(null); + const { capture } = useChatCapture(chatRef); const isInputDisabled = selectedOptions.current.selectedSearchType !== 'QA' && userCurrentStep < 3; @@ -77,6 +80,25 @@ function ChatSection() { const userId = useAuthStore.getState().user?.id; if (!userId) return; + // RESTART 처리 + if (value === 'RESTART') { + setUserCurrentStep(0); + setMessages([]); + + // 초기 인사 불러오기 + try { + const greeting = await fetchGreeting(''); + if (greeting) setMessages([greeting]); + } catch (err) { + console.error('인사 메시지 불러오기 실패:', err); + } + + // 선택된 옵션 초기화 + setOption('selectedSearchType', ''); + setStepOption(0, ''); + return; + } + const tempId = Date.now().toString(); const tempCreatedAt = new Date().toISOString(); @@ -131,11 +153,12 @@ function ChatSection() { ⚠️ 페이지를 벗어나면 채팅내용이 사라집니다.
- + ); } diff --git a/src/domains/recommend/components/bot/BotCocktailCard.tsx b/src/domains/recommend/components/bot/BotCocktailCard.tsx index 78d5c659..62ab45e8 100644 --- a/src/domains/recommend/components/bot/BotCocktailCard.tsx +++ b/src/domains/recommend/components/bot/BotCocktailCard.tsx @@ -19,6 +19,7 @@ function BotCocktailCard({ cocktailId, cocktailNameKo, cocktailImgUrl }: Recomme className="object-cover" alt={cocktailNameKo} sizes="200px" + crossOrigin="anonymous" priority />
diff --git a/src/domains/recommend/components/bot/BotMessage.tsx b/src/domains/recommend/components/bot/BotMessage.tsx index 21552590..c3c183ae 100644 --- a/src/domains/recommend/components/bot/BotMessage.tsx +++ b/src/domains/recommend/components/bot/BotMessage.tsx @@ -12,14 +12,12 @@ interface BotMessage { id: string; message: string; type: string; - options?: StepOption[]; - recommendations?: RecommendationItem[]; + stepData?: StepRecommendation | null; } interface BotMessages { messages: BotMessage[]; showProfile: boolean; - stepData?: StepRecommendation | null; currentStep?: number; onSelectedOption?: (value: string) => void; isTyping?: boolean; @@ -28,12 +26,16 @@ interface BotMessages { function BotMessage({ messages, showProfile, - stepData, currentStep, onSelectedOption, isTyping, }: BotMessages) { - const [selected, setSelected] = useState(''); + const [selectedOptions, setSelectedOptions] = useState>({}); + + const handleOptionChange = (step: number, value: string) => { + setSelectedOptions((prev) => ({ ...prev, [step]: value })); + onSelectedOption?.(value); + }; return (
@@ -66,34 +68,47 @@ function BotMessage({
{/* radio */} - {msg.type === 'RADIO_OPTIONS' && msg.options?.length && ( + {msg.type === 'RADIO_OPTIONS' && msg.stepData?.options?.length ? ( { - setSelected(val); - onSelectedOption?.(val); - }} + options={msg.stepData.options} + step={msg.stepData.currentStep ?? 0} + value={selectedOptions[msg.stepData.currentStep ?? 0] ?? ''} + onChange={(val) => handleOptionChange(msg.stepData?.currentStep ?? 0, val)} + disabled={currentStep !== undefined && currentStep > msg.stepData.currentStep!} /> - )} - {/* {children} */} + ) : null}
- {msg.type === 'CARD_LIST' && msg.recommendations?.length ? ( -
    - {msg.recommendations.map((rec) => ( -
  • - + {/* 카드 목록 */} +
      + {msg.stepData.recommendations.map((rec) => ( +
    • + +
    • + ))} +
    + + {/* 카드 목록 마지막 restart */} + {msg.stepData?.options && msg.stepData.options?.length > 0 && ( +
    +

    다시 추천받기를 원하시나요?

    + handleOptionChange(msg.stepData?.currentStep ?? 0, val)} + disabled={currentStep ? currentStep > (msg.stepData.currentStep ?? 0) : false} /> -
  • - ))} -
+
+ )} + ) : ( '' )} diff --git a/src/domains/recommend/components/bot/BotOptions.tsx b/src/domains/recommend/components/bot/BotOptions.tsx index 7e6040ce..49dbb357 100644 --- a/src/domains/recommend/components/bot/BotOptions.tsx +++ b/src/domains/recommend/components/bot/BotOptions.tsx @@ -8,10 +8,11 @@ interface BotOptionsProps { value: string; onChange: (value: string) => void; step: number; - currentStep: number; + currentStep?: number; + disabled?: boolean; } -function BotOptions({ options, value, onChange, step, currentStep }: BotOptionsProps) { +function BotOptions({ options, value, onChange, step, disabled = false }: BotOptionsProps) { return (
{options.map((opt) => ( @@ -27,7 +28,7 @@ function BotOptions({ options, value, onChange, step, currentStep }: BotOptionsP value={opt.value} checked={value === opt.value} onChange={() => onChange(opt.value)} - disabled={currentStep > step && value !== opt.value} + disabled={disabled} className="sr-only" /> step && value !== opt.value ? 'cursor-not-allowed bg-gray-light' : 'hover:bg-secondary'}`} + ${disabled ? 'cursor-not-allowed bg-gray-light' : 'hover:bg-secondary'}`} > - {opt.label} + {opt.label} ))} diff --git a/src/domains/recommend/components/user/MessageInput.tsx b/src/domains/recommend/components/user/MessageInput.tsx index b8fe686b..0200a770 100644 --- a/src/domains/recommend/components/user/MessageInput.tsx +++ b/src/domains/recommend/components/user/MessageInput.tsx @@ -1,16 +1,18 @@ 'use client'; import Send from '@/shared/assets/icons/send_36.svg'; +import Crop from '@/shared/assets/icons/crop_32.svg'; import { handleTextareaSubmit } from '@/shared/utills/handleTextareaSubmit'; import { resizeTextarea } from '@/shared/utills/textareaResize'; import { useRef, useState } from 'react'; interface Props { onSubmit: (message: string) => void; + onCapture: () => void; disabled: boolean; } -function MessageInput({ onSubmit, disabled }: Props) { +function MessageInput({ onSubmit, disabled, onCapture }: Props) { const [value, setValue] = useState(''); const textareaRef = useRef(null); @@ -22,9 +24,20 @@ function MessageInput({ onSubmit, disabled }: Props) { }; return ( -
- e.preventDefault()} className="w-full max-w-[64rem]"> -
+
+ e.preventDefault()} + className="flex items-center w-full max-w-[64rem] gap-2 justify-between" + > + +
@@ -39,7 +52,7 @@ function MessageInput({ onSubmit, disabled }: Props) { placeholder={disabled ? '옵션 선택' : '칵테일 추천 질문 입력'} disabled={disabled} className={` - w-[calc(100%-3rem)] md:w-[calc(100%-3.75rem)] px-4 py-2 md:py-3.5 + flex-1 px-4 py-2 md:py-3.5 rounded-lg h-[40px] md:h-[52px] max-h-[160px] md:max-h-[280px] bg-white text-primary placeholder:text-gray-dark resize-none outline-none disabled:bg-gray disabled:text-gray-dark disabled:cursor-not-allowed @@ -49,7 +62,7 @@ function MessageInput({ onSubmit, disabled }: Props) { type="button" onClick={() => handleTextareaSubmit(null, textareaRef.current, handleSubmit)} aria-label="보내기" - className="flex-center w-10 md:w-13 h-10 md:h-13 rounded-xl border-1 border-white bg-secondary/20" + className="flex-center w-10 md:w-13 h-10 md:h-13 rounded-xl border-1 border-white bg-secondary/20 hover:bg-white/10 active:bg-white/10" > diff --git a/src/domains/recommend/hook/useChatCapture.ts b/src/domains/recommend/hook/useChatCapture.ts new file mode 100644 index 00000000..22c2cb08 --- /dev/null +++ b/src/domains/recommend/hook/useChatCapture.ts @@ -0,0 +1,32 @@ +import { RefObject } from 'react'; +import * as htmlToImage from 'html-to-image'; + +export function useChatCapture(chatRef: RefObject) { + const capture = async () => { + if (!chatRef.current) return; + + const chatEl = chatRef.current; + const originalHeight = chatEl.style.height; + + chatEl.style.height = chatEl.scrollHeight + 'px'; + + try { + const dataUrl = await htmlToImage.toPng(chatEl, { + backgroundColor: '#1A1A1A', + cacheBust: true, + includeQueryParams: true, + }); + + const link = document.createElement('a'); + link.href = dataUrl; + link.download = 'chat.png'; + link.click(); + } catch (err) { + console.error('채팅 캡처 실패:', err); + } finally { + chatEl.style.height = originalHeight; + } + }; + + return { capture }; +} diff --git a/src/domains/recommend/hook/useChatInit.ts b/src/domains/recommend/hook/useChatInit.ts index 341ae672..2539a9a4 100644 --- a/src/domains/recommend/hook/useChatInit.ts +++ b/src/domains/recommend/hook/useChatInit.ts @@ -11,7 +11,7 @@ export function useChatInit(setMessages: React.Dispatch { if (step === 2) selectedOptions.current.selectedAlcoholStrength = value; - if (step === 3) selectedOptions.current.selectedAlcoholBaseType = value; - // if (step === 4) selectedOptions.current.selectedCocktailType = value; + if (step === 3) { + if (selectedOptions.current.selectedAlcoholStrength === 'NON_ALCOHOLIC') { + selectedOptions.current.selectedCocktailType = value; + } else { + selectedOptions.current.selectedAlcoholBaseType = value; + } + } }; const reset = () => { diff --git a/src/domains/recommend/types/recommend.ts b/src/domains/recommend/types/recommend.ts index 837e0b14..b03588ef 100644 --- a/src/domains/recommend/types/recommend.ts +++ b/src/domains/recommend/types/recommend.ts @@ -1,3 +1,5 @@ +import { RefObject } from 'react'; + export interface StepOption { value: string; label: string; @@ -57,4 +59,5 @@ export interface ChatListProps { userCurrentStep: number; onSelectedOption: (value: string) => void; isBotTyping?: boolean; + chatRef?: RefObject; } diff --git a/src/shared/assets/icons/crop_32.svg b/src/shared/assets/icons/crop_32.svg new file mode 100644 index 00000000..0cb3570b --- /dev/null +++ b/src/shared/assets/icons/crop_32.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/shared/styles/_utilities.css b/src/shared/styles/_utilities.css index 02fbc28b..362cb7c3 100644 --- a/src/shared/styles/_utilities.css +++ b/src/shared/styles/_utilities.css @@ -127,4 +127,14 @@ .swiper-pagination-bullet-active { background: var(--color-secondary) !important; } + + .min-h-full-screen { + min-height: 100vh; + min-height: 100svh; + } + + .h-full-screen { + height: 100vh; + height: 100svh; + } } diff --git a/src/shared/utills/navigation.ts b/src/shared/utills/navigation.ts index d3338c8a..5c31933d 100644 --- a/src/shared/utills/navigation.ts +++ b/src/shared/utills/navigation.ts @@ -1,5 +1,5 @@ export const navItem = [ - { href: '/recipe', label: '칵테일 찾기' }, + { href: '/recipe', label: '칵테일 레시피' }, { href: '/recommend', label: '취향추천받기' }, { href: '/community', label: '커뮤니티' }, {