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() {
{/* 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 (
-