Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Binary file added public/favicon.ico
Binary file not shown.
3 changes: 2 additions & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -24,7 +25,7 @@ export default function RootLayout({
}>) {
return (
<html lang="ko-KR">
<body className="relative flex flex-col min-h-screen">
<body className="relative flex flex-col min-h-full-screen">
<Provider>
<Header />
<ClientInitHook />
Expand Down
19 changes: 6 additions & 13 deletions src/domains/recommend/components/ChatList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div
ref={chatListRef}
ref={combinedRef}
onScroll={handleCheckBottom}
className="absolute top-8 left-0 bottom-18 sm:bottom-21 w-full gap-5 px-3 pt-7 pb-4 flex flex-col items-center overflow-y-auto pr-2"
>
Expand All @@ -35,8 +32,6 @@ function ChatList({ messages, userCurrentStep, onSelectedOption }: ChatListProps

const isTyping = msg.type === 'TYPING';

const recommendations = getRecommendations(msg.type, msg.stepData);

return (
<BotMessage
key={keyId}
Expand All @@ -45,12 +40,10 @@ function ChatList({ messages, userCurrentStep, onSelectedOption }: ChatListProps
id: msg.id,
message: msg.message,
type: msg.type ?? 'TEXT',
options: msg.type === 'RADIO_OPTIONS' ? (msg.stepData?.options ?? []) : [],
recommendations,
stepData: msg.stepData,
},
]}
showProfile={showProfile}
stepData={msg.stepData}
currentStep={userCurrentStep}
onSelectedOption={onSelectedOption}
isTyping={isTyping}
Expand Down
12 changes: 10 additions & 2 deletions src/domains/recommend/components/ChatPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Image from 'next/image';
import Send from '@/shared/assets/icons/send_36.svg';
import Link from 'next/link';
import { setPreLoginPath } from '@/domains/shared/auth/utils/setPreLoginPath';
import Crop from '@/shared/assets/icons/crop_32.svg';

function ChatPreview() {
return (
Expand All @@ -27,7 +28,7 @@ function ChatPreview() {
<div>
<div className="flex flex-col w-fit max-w-[80%] min-w-[120px] p-3 rounded-2xl rounded-tl-none bg-white text-black opacity-0 animate-fadeIn">
<div>
<p className="whitespace-pre-line">취향에 맞는 칵테일, 저와 함께 찾아볼까요?</p>
<p className="whitespace-pre-line">취향에 맞는 칵테일🤩 저와 함께 찾아볼까요?</p>
<Link
href="/login"
onNavigate={async () => {
Expand All @@ -45,7 +46,14 @@ function ChatPreview() {

<div className="fixed left-0 bottom-0 w-full px-3 py-4 flex-center bg-primary">
<form onSubmit={(e) => e.preventDefault()} className="w-full max-w-[64rem]">
<div className="flex items-end gap-2">
<div className="flex items-center gap-2">
<button
aria-label="채팅 이미지 저장"
title="채팅내용 이미지 저장"
className="flex-center rounded-full sm:bg-secondary/20 sm:w-10 sm:h-10 hover:bg-white/10 active:bg-white/10"
>
<Crop />
</button>
<label htmlFor="chatInput" className="sr-only">
질문 입력창
</label>
Expand Down
29 changes: 26 additions & 3 deletions src/domains/recommend/components/ChatSection.tsx
Original file line number Diff line number Diff line change
@@ -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<ChatMessage[]>([]);
const [userCurrentStep, setUserCurrentStep] = useState(0);
const { selectedOptions, setOption, setStepOption } = useSelectedOptions();
const chatRef = useRef<HTMLDivElement>(null);
const { capture } = useChatCapture(chatRef);

const isInputDisabled =
selectedOptions.current.selectedSearchType !== 'QA' && userCurrentStep < 3;
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -131,11 +153,12 @@ function ChatSection() {
⚠️ 페이지를 벗어나면 채팅내용이 사라집니다.
</div>
<ChatList
chatRef={chatRef}
messages={messages}
userCurrentStep={userCurrentStep}
onSelectedOption={handleSelectedOption}
/>
<MessageInput onSubmit={handleSubmitText} disabled={isInputDisabled} />
<MessageInput onSubmit={handleSubmitText} onCapture={capture} disabled={isInputDisabled} />
</section>
);
}
Expand Down
1 change: 1 addition & 0 deletions src/domains/recommend/components/bot/BotCocktailCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ function BotCocktailCard({ cocktailId, cocktailNameKo, cocktailImgUrl }: Recomme
className="object-cover"
alt={cocktailNameKo}
sizes="200px"
crossOrigin="anonymous"
priority
/>
</div>
Expand Down
73 changes: 44 additions & 29 deletions src/domains/recommend/components/bot/BotMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -28,12 +26,16 @@ interface BotMessages {
function BotMessage({
messages,
showProfile,
stepData,
currentStep,
onSelectedOption,
isTyping,
}: BotMessages) {
const [selected, setSelected] = useState('');
const [selectedOptions, setSelectedOptions] = useState<Record<number, string>>({});

const handleOptionChange = (step: number, value: string) => {
setSelectedOptions((prev) => ({ ...prev, [step]: value }));
onSelectedOption?.(value);
};

return (
<article aria-label="취향추천 챗봇 메시지" className="">
Expand Down Expand Up @@ -66,34 +68,47 @@ function BotMessage({
</div>

{/* radio */}
{msg.type === 'RADIO_OPTIONS' && msg.options?.length && (
{msg.type === 'RADIO_OPTIONS' && msg.stepData?.options?.length ? (
<BotOptions
options={msg.options}
step={stepData?.currentStep ?? 0}
currentStep={currentStep ?? 0}
value={selected}
onChange={(val) => {
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}
</div>
{msg.type === 'CARD_LIST' && msg.recommendations?.length ? (
<ul className="inline-grid grid-cols-1 mt-5 sm:grid-cols-3 gap-2 justify-start">
{msg.recommendations.map((rec) => (
<li key={rec.cocktailId}>
<BotCocktailCard
cocktailId={rec.cocktailId}
cocktailName={rec.cocktailName}
cocktailNameKo={rec.cocktailNameKo}
cocktailImgUrl={rec.cocktailImgUrl}
alcoholStrength={rec.alcoholStrength}
{msg.type === 'CARD_LIST' && msg.stepData?.recommendations?.length ? (
<>
{/* 카드 목록 */}
<ul className="inline-grid grid-cols-1 mt-5 sm:grid-cols-3 gap-2 justify-start">
{msg.stepData.recommendations.map((rec) => (
<li key={rec.cocktailId}>
<BotCocktailCard
cocktailId={rec.cocktailId}
cocktailName={rec.cocktailName}
cocktailNameKo={rec.cocktailNameKo}
cocktailImgUrl={rec.cocktailImgUrl}
alcoholStrength={rec.alcoholStrength}
/>
</li>
))}
</ul>

{/* 카드 목록 마지막 restart */}
{msg.stepData?.options && msg.stepData.options?.length > 0 && (
<div className="flex flex-col w-fit max-w-[80%] min-w-[120px] p-3 rounded-2xl rounded-tl-none bg-white text-black opacity-0 animate-fadeIn mt-3">
<p>다시 추천받기를 원하시나요?</p>
<BotOptions
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 ? currentStep > (msg.stepData.currentStep ?? 0) : false}
/>
</li>
))}
</ul>
</div>
)}
</>
) : (
''
)}
Expand Down
11 changes: 6 additions & 5 deletions src/domains/recommend/components/bot/BotOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div role="radiogroup" className="flex flex-col gap-3 mt-5">
{options.map((opt) => (
Expand All @@ -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"
/>
<span
Expand All @@ -37,9 +38,9 @@ function BotOptions({ options, value, onChange, step, currentStep }: BotOptionsP
? 'bg-secondary shadow-[inset_0_0_6px_rgba(255,196,1,1)]'
: 'bg-gray-light'
}
${currentStep > step && value !== opt.value ? 'cursor-not-allowed bg-gray-light' : 'hover:bg-secondary'}`}
${disabled ? 'cursor-not-allowed bg-gray-light' : 'hover:bg-secondary'}`}
>
<span>{opt.label}</span>
{opt.label}
</span>
</label>
))}
Expand Down
Loading