+ {voices.map((voice, idx) => (
+
+ {/* 왼쪽: 선택 버튼 */}
+ setSelectedVoice(idx)}
+ />
+ {/* 오른쪽: 미리듣기 버튼 */}
+ handlePlayVoice(idx)}
+ selected={playingIndex === idx}
+ />
+
+ ))}
+
+ );
+}
diff --git a/app/onboarding/voice/_locales/text.json b/app/onboarding/voice/_locales/text.json
new file mode 100644
index 0000000..9a279c1
--- /dev/null
+++ b/app/onboarding/voice/_locales/text.json
@@ -0,0 +1,33 @@
+{
+ "voice": {
+ "subText": {
+ "vt": "Chọn một ngôn ngữ!\nChúng tôi sẽ sử dụng nó để dịch.",
+ "en": "This is ’Kody’ who will study Korean with you.",
+ "jp": "言語を選択してください!\n翻訳に使用します。",
+ "chn": "选择一种语言!\n我们将用它进行翻译。"
+ },
+ "characterText":"안녕! 내 목소리를 선택해 줄래?",
+ "characterSubText": {
+ "vt": "Xin chào! Bạn có thể chọn giọng nói của tôi không?",
+ "en": "Hi! Can you choose my voice?",
+ "jp": "こんにちは!私の声を選んでくれませんか?",
+ "chn": "你好!你能选择我的声音吗?"
+ }
+ },
+ "selectVoice": {
+ "subText": {
+ "vt": "Chọn một ngôn ngữ!\nChúng tôi sẽ sử dụng nó để dịch.",
+ "en": "Please choose Kody's voice.\nYou can change your friend's voice at any time.",
+ "jp": "言語を選択してください!\n翻訳に使用します。",
+ "chn": "选择一种语言!\n我们将用它进行翻译。"
+ }
+ },
+ "complete" : {
+ "subText": {
+ "vt": "Chọn một ngôn ngữ!\nChúng tôi sẽ sử dụng nó để dịch.",
+ "en": "Let's start studying Korean with Kody!",
+ "jp": "言語を選択してください!\n翻訳に使用します。",
+ "chn": "选择一种语言!\n我们将用它进行翻译。"
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/onboarding/voice/complete/page.tsx b/app/onboarding/voice/complete/page.tsx
new file mode 100644
index 0000000..6017d90
--- /dev/null
+++ b/app/onboarding/voice/complete/page.tsx
@@ -0,0 +1,54 @@
+'use client';
+
+import TitleText from '@/components/TitleText';
+import text from '../_locales/text.json';
+import { useLanguageStore } from '@/stores/languageStore';
+import { useUserStore } from '@/stores/userStore';
+import { useRouter } from 'next/navigation';
+import { ROUTES } from '@/constants/routes';
+import Button from '@/components/buttons/_index';
+import MotionFadeIn from '@/components/_animations/MotionFadeIn';
+import CharacterText from '@/components/CharacterText';
+
+export default function VoiceCompletionPage() {
+ const router = useRouter();
+ const { subText } = text.complete;
+ const { username } = useUserStore(); // TODO: 사용자 이름 가져오기 (일단 로컬 스토리지에서 가져옴)
+ const { currentLanguage } = useLanguageStore();
+
+ const handleBtnClick = () => {
+ router.push(ROUTES.MAIN.ROOT);
+ };
+
+ return (
+ <>
+
-
-
-
-
- Get started by editing{' '}
-
- app/page.tsx
-
- .
-
-
- Save and see your changes instantly.
-
-
+export default function Splash() {
+ const router = useRouter();
-
-
-
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ // TODO: 로그인 여부에 따라 라우팅, 로컬에 로그인 상태 여부 저장
+ const isLoggedIn = false;
+ // TODO: 메인 홈 라우트 구현 및 상수화
+ router.push(isLoggedIn ? '/main' : ROUTES.ONBOARDING.LOGIN);
+ }, 2500);
+ return () => clearTimeout(timer);
+ }, [router]);
+
+ return (
+
+
+
+
);
}
diff --git a/components/CharacterText.tsx b/components/CharacterText.tsx
new file mode 100644
index 0000000..f5f89b3
--- /dev/null
+++ b/components/CharacterText.tsx
@@ -0,0 +1,52 @@
+import { FONT_CLASS } from '@/constants/languages';
+import { useLanguageStore } from '@/stores/languageStore';
+import Image from 'next/image';
+
+interface CharacterTextProps {
+ title: string;
+ subtitle: string;
+ audio?: boolean;
+ image?: string; // 이미지 URL
+}
+
+export default function CharacterText({
+ title,
+ subtitle,
+ audio = false,
+ image = '/character/default.webp', // 기본 이미지 URL
+}: CharacterTextProps) {
+ const { currentLanguage } = useLanguageStore();
+
+ return (
+
+ {/* 텍스트 영역 */}
+
+
+ {title}
+ {audio && (
+
+ )}
+
+ {/* 구분선 */}
+
+
+ {subtitle}
+
+
+ {/* 캐릭터 이미지 */}
+ {image && (
+
+
+
+ )}
+
+ );
+}
diff --git a/components/Logo.tsx b/components/Logo.tsx
new file mode 100644
index 0000000..f229b24
--- /dev/null
+++ b/components/Logo.tsx
@@ -0,0 +1,12 @@
+'use client';
+
+import { motion } from 'framer-motion';
+import Image from 'next/image';
+
+export default function Logo() {
+ return (
+
+
+
+ );
+}
diff --git a/components/ProgressBar.tsx b/components/ProgressBar.tsx
new file mode 100644
index 0000000..d8288a9
--- /dev/null
+++ b/components/ProgressBar.tsx
@@ -0,0 +1,39 @@
+interface ProgressBarProps {
+ totalSteps: number; // 바의 총 개수
+ currentStep: number; // 현재 진행 상태 (누적된 값)
+ incompleteColor?: string;
+ className?: string;
+}
+
+export default function ProgressBar({
+ totalSteps,
+ currentStep,
+ incompleteColor = 'bg-secondary',
+ className,
+}: ProgressBarProps) {
+ if (totalSteps <= 0) {
+ return null;
+ }
+
+ const segments = Array(totalSteps).fill(1);
+ const totalSegments = segments.length;
+ const gap = totalSteps === 4 ? 16 : totalSteps <= 3 ? 8 : 0;
+
+ return (
+
+ {segments.map((_, index) => {
+ const isCurrentOrCompleted = index < Math.ceil(currentStep);
+
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/components/TitleText.tsx b/components/TitleText.tsx
new file mode 100644
index 0000000..c1a6530
--- /dev/null
+++ b/components/TitleText.tsx
@@ -0,0 +1,28 @@
+import { FONT_CLASS, Language } from '@/constants/languages';
+import { ReactNode } from 'react';
+
+interface TitleTextProps {
+ title: ReactNode; // JSX도 허용되도록
+ subText: Record
;
+ lang: Language;
+ className?: string;
+}
+
+export default function TitleText({
+ title,
+ subText,
+ lang,
+ className,
+}: TitleTextProps) {
+ return (
+
+
{title}
+
+ {subText[lang]}
+
+
+ );
+}
diff --git a/components/Toast.tsx b/components/Toast.tsx
new file mode 100644
index 0000000..1e1c6b2
--- /dev/null
+++ b/components/Toast.tsx
@@ -0,0 +1,47 @@
+'use client';
+
+import { useEffect } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+
+interface ToastProps {
+ message: string;
+ isOpen: boolean;
+ onClose: () => void;
+ duration?: number; // 자동 닫힘 시간 (ms)
+}
+
+export default function Toast({
+ message,
+ isOpen,
+ onClose,
+ duration = 2000,
+}: ToastProps) {
+ // 일정 시간 후 자동 닫기
+ useEffect(() => {
+ if (isOpen) {
+ const timer = setTimeout(() => {
+ onClose();
+ }, duration);
+ return () => clearTimeout(timer);
+ }
+ }, [isOpen, duration, onClose]);
+
+ return (
+
+
+ {isOpen && (
+
+ {message}
+
+ )}
+
+
+ );
+}
diff --git a/components/TopAppBar.tsx b/components/TopAppBar.tsx
new file mode 100644
index 0000000..8a456d1
--- /dev/null
+++ b/components/TopAppBar.tsx
@@ -0,0 +1,34 @@
+'use client';
+
+import Image from 'next/image';
+import { useRouter } from 'next/navigation';
+
+interface TopAppBarProps {
+ title?: string;
+}
+
+export default function TopAppBar({ title }: TopAppBarProps) {
+ const router = useRouter();
+
+ const handleGoBack = () => {
+ router.back();
+ };
+
+ return (
+
+ {/* 뒤로가기 버튼 */}
+
+
+
+ {/* 가운데 타이틀 */}
+ {title && (
+ {title}
+ )}
+
+ );
+}
diff --git a/components/_animations/MotionFadeIn.tsx b/components/_animations/MotionFadeIn.tsx
new file mode 100644
index 0000000..e6a26bf
--- /dev/null
+++ b/components/_animations/MotionFadeIn.tsx
@@ -0,0 +1,32 @@
+import { motion } from 'framer-motion';
+import { CSSProperties, ReactNode } from 'react';
+
+interface MotionFadeInProps {
+ children: ReactNode;
+ initial?: any;
+ animate?: any;
+ transition?: any;
+ className?: string;
+ style?: CSSProperties;
+}
+
+export default function MotionFadeIn({
+ children,
+ initial = { opacity: 0, y: 10 },
+ animate = { opacity: 1, y: 0 },
+ transition = { duration: 0.6, delay: 0.2, ease: 'easeIn' },
+ className,
+ style,
+}: MotionFadeInProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/components/_animations/bottomSheetMotion.ts b/components/_animations/bottomSheetMotion.ts
new file mode 100644
index 0000000..3f4a0a1
--- /dev/null
+++ b/components/_animations/bottomSheetMotion.ts
@@ -0,0 +1,24 @@
+export const bottomSheetVariants = {
+ hidden: {
+ y: '100%',
+ opacity: 0,
+ },
+ visible: {
+ y: 0,
+ opacity: 1,
+ transition: {
+ type: 'spring' as const,
+ stiffness: 100,
+ damping: 25,
+ },
+ },
+ exit: {
+ y: '100%',
+ opacity: 0,
+ transition: {
+ type: 'spring' as const,
+ stiffness: 100,
+ damping: 25,
+ },
+ },
+};
diff --git a/components/buttons/VoiceKeyboard.tsx b/components/buttons/VoiceKeyboard.tsx
new file mode 100644
index 0000000..4a79114
--- /dev/null
+++ b/components/buttons/VoiceKeyboard.tsx
@@ -0,0 +1,222 @@
+'use client';
+
+import { useState, KeyboardEvent, useEffect } from 'react';
+import Image from 'next/image';
+import TextFieldChat from '../textfields/TextFieldChat';
+
+type VoiceKeyboardProps = {
+ placeholder?: string;
+ onClick?: (mode: 'mic' | 'keyboard', data?: Blob | string) => void;
+};
+
+export default function VoiceKeyboard({
+ placeholder = '텍스트를 입력하세요...',
+ onClick,
+}: VoiceKeyboardProps) {
+ const [mode, setMode] = useState<'mic' | 'keyboard'>('mic');
+ const [mediaRecorder, setMediaRecorder] = useState(
+ null,
+ );
+ const [isRecording, setIsRecording] = useState(false);
+ const [audioURL, setAudioURL] = useState(null);
+ const [inputValue, setInputValue] = useState('');
+
+ // 마이크 권한 먼저 띄우기
+ useEffect(() => {
+ const requestMicPermission = async () => {
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({
+ audio: true,
+ });
+ stream.getTracks().forEach(track => track.stop());
+ } catch (err) {
+ console.warn('마이크 권한 거부됨', err);
+ }
+ };
+ requestMicPermission();
+ }, []);
+
+ // 녹음 시작
+ const startRecording = async () => {
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+ const mimeType = MediaRecorder.isTypeSupported('audio/mp4')
+ ? 'audio/mp4'
+ : MediaRecorder.isTypeSupported('audio/webm')
+ ? 'audio/webm'
+ : '';
+ if (!mimeType) return alert('녹음을 지원하지 않는 브라우저입니다.');
+
+ const recorder = new MediaRecorder(stream, { mimeType });
+ setMediaRecorder(recorder);
+
+ const chunks: Blob[] = [];
+
+ recorder.ondataavailable = e => {
+ if (e.data.size > 0) chunks.push(e.data);
+ };
+
+ recorder.onstop = () => {
+ const blob = new Blob(chunks, { type: mimeType });
+ const url = URL.createObjectURL(blob);
+ setAudioURL(url);
+ onClick?.('mic', blob);
+ setIsRecording(false);
+ };
+
+ recorder.start();
+ setIsRecording(true);
+ } catch (err) {
+ console.error('마이크 권한 필요:', err);
+ alert('마이크 권한을 허용해주세요.');
+ }
+ };
+
+ const stopRecording = () => {
+ if (mediaRecorder) mediaRecorder.stop();
+ setIsRecording(false);
+ };
+
+ const handleToggle = (newMode: 'mic' | 'keyboard') => {
+ setMode(newMode);
+ if (newMode === 'keyboard') {
+ stopRecording(); // 키보드 모드일 때는 녹음 중지
+ }
+ // mic 모드일 때는 녹음 시작하지 않음! 눌렀을 때만 녹음
+ };
+
+ // 키보드 입력
+ const handleInputSubmit = (e: KeyboardEvent) => {
+ if (e.key === 'Enter' && inputValue.trim()) {
+ onClick?.('keyboard', inputValue);
+ setInputValue('');
+ }
+ };
+
+ return (
+
+ {/* 버튼 + 입력창 영역 */}
+
+ {/* 토글 버튼 */}
+
+
+
+ {/* 마이크 배경 */}
+
+
+ {/* 키보드 배경 */}
+
+
+ {/* 마이크 버튼 */}
+
handleToggle('mic')}
+ onMouseDown={() => {
+ if (mode === 'mic') {
+ startRecording();
+ setIsRecording(true);
+ }
+ }}
+ onMouseUp={() => {
+ if (mode === 'mic') stopRecording();
+ }}
+ onTouchStart={() => {
+ if (mode === 'mic') {
+ startRecording();
+ setIsRecording(true);
+ }
+ }}
+ onTouchEnd={() => {
+ if (mode === 'mic') stopRecording();
+ }}
+ className={`absolute left-0 top-1/2 ${
+ isRecording ? '-translate-y-6' : '-translate-y-1/2'
+ } w-14 h-14 flex items-center justify-center rounded-full transition-all duration-150 ${
+ mode === 'mic' ? 'bg-primary' : 'bg-transparent'
+ }`}
+ style={{
+ userSelect: 'none', // 텍스트 선택 막기
+ WebkitUserSelect: 'none', // 사파리/모바일 대응
+ touchAction: 'manipulation', // 터치 동작 최적화
+ }}
+ >
+
+
+
+ {/* 키보드 버튼 */}
+
handleToggle('keyboard')}
+ className={`absolute right-0 top-1/2 -translate-y-1/2 w-14 h-14 flex items-center justify-center rounded-full transition ${mode === 'keyboard' ? 'bg-bg-solid' : 'bg-transparent'}`}
+ >
+
+
+
+
+ {/* 키보드 입력 필드 */}
+ {mode === 'keyboard' && (
+
+ {
+ onClick?.('keyboard', val);
+ setInputValue('');
+ }}
+ />
+
+ )}
+
+
+ {/* 녹음 상태 표시 */}
+ {/* {mode === 'mic' && (
+
+ {isRecording ? (
+
+ ⏹️ 녹음 중지
+
+ ) : (
+
+ ▶️ 녹음 시작
+
+ )}
+ {audioURL && (
+
+ )}
+
+ )} */}
+
+ );
+}
diff --git a/components/buttons/_index.tsx b/components/buttons/_index.tsx
new file mode 100644
index 0000000..3c40370
--- /dev/null
+++ b/components/buttons/_index.tsx
@@ -0,0 +1,63 @@
+import Image from 'next/image';
+import { useState, TouchEventHandler, MouseEventHandler } from 'react';
+
+interface ButtonProps {
+ text: string;
+ iconPath?: string;
+ className?: string;
+ onClick?: MouseEventHandler;
+ disabled?: boolean;
+ btnColor?: string;
+}
+
+export default function Button({
+ text,
+ iconPath,
+ className,
+ onClick,
+ disabled = false,
+ btnColor = 'bg-primary',
+}: ButtonProps) {
+ const [isPressed, setIsPressed] = useState(false);
+
+ // 모바일 터치 이벤트
+ const handleTouchStart: TouchEventHandler = () =>
+ setIsPressed(true);
+ const handleTouchEnd: TouchEventHandler = () =>
+ setIsPressed(false);
+
+ return (
+
+ {/* 직사각형 그림자 */}
+
+ {/* 실제 버튼 */}
+
+ {text}
+ {iconPath && (
+
+ )}
+
+
+ );
+}
diff --git a/components/buttons/characterVoice.tsx b/components/buttons/characterVoice.tsx
new file mode 100644
index 0000000..74d6f4e
--- /dev/null
+++ b/components/buttons/characterVoice.tsx
@@ -0,0 +1,41 @@
+import Image from 'next/image';
+import { MouseEventHandler } from 'react';
+
+interface ButtonProps {
+ className?: string;
+ onClick?: MouseEventHandler;
+ selected?: boolean;
+}
+
+export default function CharacterVoiceButton({
+ className,
+ onClick,
+ selected = false,
+}: ButtonProps) {
+ return (
+
+ {/* 직사각형 그림자 */}
+
+ {/* 실제 버튼 */}
+
+
+
+
+ );
+}
diff --git a/components/buttons/select.tsx b/components/buttons/select.tsx
new file mode 100644
index 0000000..3f4dcc6
--- /dev/null
+++ b/components/buttons/select.tsx
@@ -0,0 +1,60 @@
+import Image from 'next/image';
+import { MouseEventHandler } from 'react';
+
+interface ButtonProps {
+ text: string;
+ subText?: string;
+ className?: string;
+ onClick?: MouseEventHandler;
+ selected?: boolean;
+}
+
+export default function SelectButton({
+ text,
+ subText,
+ className,
+ onClick,
+ selected = false,
+}: ButtonProps) {
+ return (
+
+ {/* 직사각형 그림자 */}
+
+ {/* 실제 버튼 */}
+
+ {/* 왼쪽 텍스트 영역 */}
+
+ {text}
+ {subText && (
+
+ {subText}
+
+ )}
+
+ {/* 오른쪽 아이콘 */}
+
+
+
+ );
+}
diff --git a/components/dropdowns/LanguageList.tsx b/components/dropdowns/LanguageList.tsx
new file mode 100644
index 0000000..83d64d9
--- /dev/null
+++ b/components/dropdowns/LanguageList.tsx
@@ -0,0 +1,111 @@
+import ISO6391 from 'iso-639-1';
+import Image from 'next/image';
+import { useState, useEffect } from 'react';
+import { languageKoreanNames } from '@/constants/languageKoreanNames';
+
+interface LanguageListProps {
+ disabled?: boolean;
+ onSelect?: (code: string) => void;
+}
+
+function LanguageListItems({
+ isOpen,
+ onSelect,
+}: {
+ isOpen: boolean;
+ onSelect: (code: string) => void;
+}) {
+ const codes = ISO6391.getAllCodes();
+ return (
+ e.stopPropagation()}
+ >
+ {codes.map(code => (
+ onSelect(code)}
+ >
+ {ISO6391.getName(code)}
+
+ {languageKoreanNames[code] || code}
+
+
+ ))}
+
+ );
+}
+
+export default function LanguageListDropdown({
+ disabled = false,
+ onSelect,
+}: LanguageListProps) {
+ const [isOpen, setIsOpen] = useState(false);
+ const [selectedCode, setSelectedCode] = useState(
+ ISO6391.getAllCodes()[0],
+ );
+
+ const handleSelect = (code: string) => {
+ setSelectedCode(code);
+ setIsOpen(false);
+ onSelect?.(code);
+ };
+
+ // 드롭다운이 열려있을 때 배경 스크롤 방지
+ useEffect(() => {
+ if (isOpen) {
+ document.body.style.overflow = 'hidden';
+ } else {
+ document.body.style.overflow = 'unset';
+ }
+
+ return () => {
+ document.body.style.overflow = 'unset';
+ };
+ }, [isOpen]);
+
+ const handleToggle = () => {
+ if (!disabled) {
+ setIsOpen(!isOpen);
+ }
+ };
+
+ return (
+
+ {/* 겹치는 회색 직사각형 배경 */}
+
+
+
+ {ISO6391.getName(selectedCode)}
+
+ {languageKoreanNames[selectedCode] || selectedCode}
+
+
+ {!disabled && (
+
+ )}
+
+ {/* 언어 리스트 (최대 8개 높이, 스크롤 가능) */}
+
+
+ );
+}
diff --git a/components/dropdowns/LanguageSelect.tsx b/components/dropdowns/LanguageSelect.tsx
new file mode 100644
index 0000000..b7b3771
--- /dev/null
+++ b/components/dropdowns/LanguageSelect.tsx
@@ -0,0 +1,77 @@
+import { useState } from 'react';
+import Image from 'next/image';
+import {
+ LanguageDropdown,
+ LanguageDropdownProps,
+} from '@/constants/dropdown/languages';
+import { useLanguageStore } from '@/stores/languageStore';
+
+interface LanguageSelectDropdownProps {
+ disabled?: boolean;
+ onChange?: (lang: LanguageDropdownProps) => void;
+}
+
+export default function LanguageSelectDropdown({
+ disabled = false,
+ onChange,
+}: LanguageSelectDropdownProps) {
+ const [isOpen, setIsOpen] = useState(false);
+ const { currentLanguage, setLanguage } = useLanguageStore();
+
+ const toggleDropdown = () => setIsOpen(!isOpen);
+
+ const handleSelect = (lang: LanguageDropdownProps) => {
+ setLanguage(lang);
+ if (onChange) onChange(lang);
+ setIsOpen(false);
+ };
+
+ return (
+
+ {/* 겹치는 회색 직사각형 배경 */}
+
+ {/* 드랍다운 버튼 */}
+
+
+ {currentLanguage.label}
+
+ {currentLanguage.subLabel}
+
+
+ {!disabled && (
+
+ )}
+
+ {/* 드롭다운 옵션 */}
+ {isOpen && (
+
+ {LanguageDropdown.map(lang => (
+ handleSelect(lang)}
+ className="px-4 py-3 cursor-pointer text-trans-cp1-regular hover:bg-gray-100"
+ >
+ {lang.label}
+
+ {lang.subLabel}
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/components/dropdowns/SchoolSelect.tsx b/components/dropdowns/SchoolSelect.tsx
new file mode 100644
index 0000000..12bb2e7
--- /dev/null
+++ b/components/dropdowns/SchoolSelect.tsx
@@ -0,0 +1,95 @@
+import { useState } from 'react';
+import Image from 'next/image';
+import { useUserStore } from '@/stores/userStore';
+import { useLanguageStore } from '@/stores/languageStore';
+import {
+ DefaultSchoolOption,
+ SchoolOptions,
+ SchoolOption,
+} from '@/constants/dropdown/schools';
+import { FONT_CLASS } from '@/constants/languages';
+
+export default function SchoolSelectDropdown({
+ options = SchoolOptions,
+ disabled = false,
+ onChange,
+}: {
+ options?: SchoolOption[];
+ disabled?: boolean;
+ onChange?: (school: SchoolOption) => void;
+}) {
+ const { currentLanguage } = useLanguageStore();
+ const { school, setSchool } = useUserStore();
+ const [isOpen, setIsOpen] = useState(false);
+ const [selectedSchool, setSelectedSchool] = useState(
+ options.find(opt => opt.value === school) || DefaultSchoolOption,
+ );
+
+ const toggleDropdown = () => setIsOpen(!isOpen);
+
+ const handleSelect = (schoolOption: SchoolOption) => {
+ setSelectedSchool(schoolOption);
+ setSchool(schoolOption.value);
+ if (onChange) onChange(schoolOption);
+ setIsOpen(false);
+ };
+
+ const getSubLabel = (school: SchoolOption) => {
+ return school.subLabel?.[currentLanguage.code] ?? '';
+ };
+
+ return (
+
+
+
+
+ {selectedSchool.label}
+ {selectedSchool.subLabel && (
+
+ {getSubLabel(selectedSchool)}
+
+ )}
+
+ {!disabled && (
+
+ )}
+
+ {isOpen && (
+
+ {options.map(school => (
+ handleSelect(school)}
+ className="px-4 py-3 cursor-pointer text-trans-cp1-regular text-black hover:bg-gray-100"
+ >
+ {school.label}
+ {school.subLabel && (
+
+ {getSubLabel(school)}
+
+ )}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/components/textfields/TextFieldChat.tsx b/components/textfields/TextFieldChat.tsx
new file mode 100644
index 0000000..40e8094
--- /dev/null
+++ b/components/textfields/TextFieldChat.tsx
@@ -0,0 +1,68 @@
+'use client';
+
+import { useState, KeyboardEvent } from 'react';
+import Image from 'next/image';
+
+type TextFieldChatProps = {
+ placeholder?: string;
+ onSubmit?: (text: string) => void;
+};
+
+export default function TextFieldChat({
+ placeholder = '여기에 문장을 써 보자.',
+ onSubmit,
+}: TextFieldChatProps) {
+ const [inputValue, setInputValue] = useState('');
+ const [isActive, setIsActive] = useState(false);
+
+ const handleSubmit = () => {
+ if (!inputValue.trim()) return;
+ onSubmit?.(inputValue.trim());
+ setInputValue('');
+ setIsActive(false);
+ };
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ handleSubmit();
+ }
+ };
+
+ const hasValue = inputValue.trim().length > 0;
+
+ return (
+
+ {/* 그림자 레이어 */}
+
+ {/* 실제 인풋 컨테이너 */}
+
+ setInputValue(e.target.value)}
+ onFocus={() => setIsActive(true)}
+ onBlur={() => setIsActive(false)}
+ onKeyDown={handleKeyDown}
+ className="flex-1 outline-none text-black [text-size-adjust:100%]"
+ />
+
+
+
+
+
+ );
+}
diff --git a/components/textfields/TextFieldLong.tsx b/components/textfields/TextFieldLong.tsx
new file mode 100644
index 0000000..2ac0108
--- /dev/null
+++ b/components/textfields/TextFieldLong.tsx
@@ -0,0 +1,51 @@
+import { FONT_CLASS, Language } from '@/constants/languages';
+import { ChangeEvent, HTMLAttributes, useState } from 'react';
+
+interface TextFieldProps {
+ placeholder?: string;
+ lang?: Language;
+ maxLength?: number;
+ value: string;
+ onChange: (value: string) => void;
+ inputMode?: HTMLAttributes['inputMode'];
+}
+
+export default function TextFieldLong({
+ placeholder,
+ lang = 'en',
+ maxLength,
+ value,
+ onChange,
+ inputMode,
+}: TextFieldProps) {
+ const [isFocused, setIsFocused] = useState(false);
+
+ const handleChange = (e: ChangeEvent) => {
+ const newValue = e.target.value;
+ if (maxLength && newValue.length > maxLength) {
+ alert(`최대 ${maxLength}글자까지 입력 가능합니다.`);
+ return;
+ }
+ onChange(newValue);
+ };
+
+ return (
+
+
+
setIsFocused(true)}
+ onBlur={() => setIsFocused(false)}
+ className={`
+ relative w-full px-4 py-2 border rounded-2xl bg-white focus:outline-none transition text-bd1-regular text-black [text-size-adjust:100%]
+ ${FONT_CLASS[lang]}
+ ${isFocused ? 'border-primary border-2' : 'border-gray-900 border'}
+ `}
+ />
+
+ );
+}
diff --git a/components/textfields/TextFieldShort.tsx b/components/textfields/TextFieldShort.tsx
new file mode 100644
index 0000000..0ecd904
--- /dev/null
+++ b/components/textfields/TextFieldShort.tsx
@@ -0,0 +1,58 @@
+import { useState, ChangeEvent, HTMLAttributes } from 'react';
+import { FONT_CLASS, Language } from '@/constants/languages';
+
+interface TextFieldShortProps {
+ placeholder?: string;
+ lang?: Language;
+ maxLength?: number;
+ value: string;
+ onChange: (value: string) => void;
+ endText?: string;
+ inputMode?: HTMLAttributes['inputMode'];
+}
+
+export default function TextFieldShort({
+ placeholder,
+ lang = 'en',
+ maxLength,
+ value,
+ onChange,
+ endText = '',
+ inputMode,
+}: TextFieldShortProps) {
+ const [isFocused, setIsFocused] = useState(false);
+
+ const handleChange = (e: ChangeEvent) => {
+ const newValue = e.target.value;
+ if (maxLength && newValue.length > maxLength) {
+ alert(`최대 ${maxLength}글자까지 입력 가능합니다.`);
+ return;
+ }
+ onChange(newValue);
+ };
+
+ return (
+
+
+
setIsFocused(true)}
+ onBlur={() => setIsFocused(false)}
+ className={`
+ relative w-full px-4 py-2 border rounded-2xl bg-white focus:outline-none transition text-bd1-regular text-black [text-size-adjust:100%]
+ ${FONT_CLASS[lang]}
+ ${isFocused ? 'border-primary border-2' : 'border-gray-900 border'}
+ `}
+ />
+ {endText && (
+
+ {endText}
+
+ )}
+
+ );
+}
diff --git a/constants/dropdown/languages.ts b/constants/dropdown/languages.ts
new file mode 100644
index 0000000..7e745f5
--- /dev/null
+++ b/constants/dropdown/languages.ts
@@ -0,0 +1,14 @@
+import { LANGUAGES } from '@/constants/languages';
+
+export interface LanguageDropdownProps {
+ code: (typeof LANGUAGES)[keyof typeof LANGUAGES];
+ label: string;
+ subLabel?: string;
+}
+
+export const LanguageDropdown: LanguageDropdownProps[] = [
+ { code: 'en', label: 'English', subLabel: '영어' },
+ { code: 'vt', label: 'tiếng Việt', subLabel: '베트남어' },
+ { code: 'chn', label: '中文', subLabel: '중국어' },
+ { code: 'jp', label: '日本語', subLabel: '일본어' },
+];
diff --git a/constants/dropdown/schools.ts b/constants/dropdown/schools.ts
new file mode 100644
index 0000000..34aa5ae
--- /dev/null
+++ b/constants/dropdown/schools.ts
@@ -0,0 +1,52 @@
+import { Language } from '../languages';
+
+export interface SchoolOption {
+ label: string;
+ subLabel: Record;
+ value: 'elementary' | 'middle' | 'high' | '';
+}
+
+export const SchoolOptions: SchoolOption[] = [
+ {
+ label: '초등학교',
+ subLabel: {
+ en: 'Elementary school',
+ vt: 'Trường tiểu học',
+ chn: '小学',
+ jp: '小学校',
+ },
+ value: 'elementary',
+ },
+ {
+ label: '중학교',
+ subLabel: {
+ en: 'Middle school',
+ vt: 'Trường trung học cơ sở',
+ chn: '中学',
+ jp: '中学校',
+ },
+ value: 'middle',
+ },
+ {
+ label: '고등학교',
+ subLabel: {
+ en: 'High school',
+ vt: 'Trường trung học phổ thông',
+ chn: '高中',
+ jp: '高等学校',
+ },
+ value: 'high',
+ },
+];
+
+// 기본 선택 안내 옵션
+export const DefaultSchoolOption: SchoolOption = {
+ label: '학교를 선택해주세요',
+ subLabel: {
+ en: 'Please select a school',
+ vt: 'Vui lòng chọn trường',
+ chn: '请选择学校',
+ jp: '学校を選択してください',
+ },
+ value: '',
+};
diff --git a/constants/languageKoreanNames.ts b/constants/languageKoreanNames.ts
new file mode 100644
index 0000000..43dcf3e
--- /dev/null
+++ b/constants/languageKoreanNames.ts
@@ -0,0 +1,186 @@
+export const languageKoreanNames: { [key: string]: string } = {
+ aa: '아파르어',
+ ab: '압하스어',
+ ae: '아베스타어',
+ af: '아프리칸스어',
+ ak: '아칸어',
+ am: '암하라어',
+ an: '아라곤어',
+ ar: '아랍어',
+ as: '아삼어',
+ av: '아바르어',
+ ay: '아이마라어',
+ az: '아제르바이잔어',
+ ba: '바시키르어',
+ be: '벨라루스어',
+ bg: '불가리아어',
+ bh: '비하르어',
+ bi: '비슬라마어',
+ bm: '밤바라어',
+ bn: '벵골어',
+ bo: '티베트어',
+ br: '브르타뉴어',
+ bs: '보스니아어',
+ ca: '카탈로니아어',
+ ce: '체첸어',
+ ch: '차모로어',
+ co: '코르시카어',
+ cr: '크리어',
+ cs: '체코어',
+ cu: '교회 슬라브어',
+ cv: '추바시어',
+ cy: '웨일스어',
+ da: '덴마크어',
+ de: '독일어',
+ dv: '디베히어',
+ dz: '종카어',
+ ee: '에웨어',
+ el: '그리스어',
+ en: '영어',
+ eo: '에스페란토어',
+ es: '스페인어',
+ et: '에스토니아어',
+ eu: '바스크어',
+ fa: '페르시아어',
+ ff: '풀라어',
+ fi: '핀란드어',
+ fj: '피지어',
+ fo: '페로어',
+ fr: '프랑스어',
+ fy: '프리지아어',
+ ga: '아일랜드어',
+ gd: '스코틀랜드 게일어',
+ gl: '갈리시아어',
+ gn: '과라니어',
+ gu: '구자라트어',
+ gv: '맨섬어',
+ ha: '하우사어',
+ he: '히브리어',
+ hi: '힌디어',
+ ho: '히리모투어',
+ hr: '크로아티아어',
+ ht: '아이티어',
+ hu: '헝가리어',
+ hy: '아르메니아어',
+ hz: '헤레로어',
+ ia: '인터링구아',
+ id: '인도네시아어',
+ ie: '인터링구에',
+ ig: '이그보어',
+ ii: '쓰촨이어',
+ ik: '이누피아크어',
+ io: '이도어',
+ is: '아이슬란드어',
+ it: '이탈리아어',
+ iu: '이누크티투트어',
+ ja: '일본어',
+ jv: '자바어',
+ ka: '조지아어',
+ kg: '콩고어',
+ ki: '키쿠유어',
+ kj: '콰냐마어',
+ kk: '카자흐어',
+ kl: '그린란드어',
+ km: '크메르어',
+ kn: '칸나다어',
+ ko: '한국어',
+ kr: '카누리어',
+ ks: '카슈미르어',
+ ku: '쿠르드어',
+ kv: '코미어',
+ kw: '콘월어',
+ ky: '키르기스어',
+ la: '라틴어',
+ lb: '룩셈부르크어',
+ lg: '간다어',
+ li: '림뷔르흐어',
+ ln: '링갈라어',
+ lo: '라오어',
+ lt: '리투아니아어',
+ lu: '루바-카탄가어',
+ lv: '라트비아어',
+ mg: '마다가스카르어',
+ mh: '마셜어',
+ mi: '마오리어',
+ mk: '마케도니아어',
+ ml: '말라얄람어',
+ mn: '몽골어',
+ mr: '마라티어',
+ ms: '말레이어',
+ mt: '몰타어',
+ my: '미얀마어',
+ na: '나우루어',
+ nb: '노르웨이어(보크말)',
+ nd: '북은데벨레어',
+ ne: '네팔어',
+ ng: '은동가어',
+ nl: '네덜란드어',
+ nn: '노르웨이어(뉘노르스크)',
+ no: '노르웨이어',
+ nr: '남은데벨레어',
+ nv: '나바호어',
+ ny: '니안자어',
+ oc: '오크어',
+ oj: '오지브웨이어',
+ om: '오로모어',
+ or: '오리야어',
+ os: '오세트어',
+ pa: '펀자브어',
+ pi: '팔리어',
+ pl: '폴란드어',
+ ps: '파슈토어',
+ pt: '포르투갈어',
+ qu: '케추아어',
+ rm: '로만시어',
+ rn: '룬디어',
+ ro: '루마니아어',
+ ru: '러시아어',
+ rw: '키냐르완다어',
+ sa: '산스크리트어',
+ sc: '사르데냐어',
+ sd: '신드어',
+ se: '북사미어',
+ sg: '상고어',
+ si: '싱할라어',
+ sk: '슬로바키아어',
+ sl: '슬로베니아어',
+ sm: '사모아어',
+ sn: '쇼나어',
+ so: '소말리어',
+ sq: '알바니아어',
+ sr: '세르비아어',
+ ss: '스와티어',
+ st: '소토어',
+ su: '순다어',
+ sv: '스웨덴어',
+ sw: '스와힐리어',
+ ta: '타밀어',
+ te: '텔루구어',
+ tg: '타지크어',
+ th: '태국어',
+ ti: '티그리냐어',
+ tk: '투르크멘어',
+ tl: '타갈로그어',
+ tn: '츠와나어',
+ to: '통가어',
+ tr: '터키어',
+ ts: '총가어',
+ tt: '타타르어',
+ tw: '트위어',
+ ty: '타히티어',
+ ug: '위구르어',
+ uk: '우크라이나어',
+ ur: '우르두어',
+ uz: '우즈베크어',
+ ve: '벤다어',
+ vi: '베트남어',
+ vo: '볼라퓌크어',
+ wa: '왈론어',
+ wo: '월로프어',
+ xh: '코사어',
+ yi: '이디시어',
+ yo: '요루바어',
+ za: '좡어',
+ zh: '중국어',
+ zu: '줄루어',
+};
diff --git a/constants/languages.ts b/constants/languages.ts
new file mode 100644
index 0000000..1f0eddd
--- /dev/null
+++ b/constants/languages.ts
@@ -0,0 +1,15 @@
+export const LANGUAGES = {
+ EN: 'en',
+ JP: 'jp',
+ VT: 'vt',
+ CHN: 'chn',
+} as const;
+
+export type Language = (typeof LANGUAGES)[keyof typeof LANGUAGES];
+
+export const FONT_CLASS = {
+ [LANGUAGES.EN]: 'font-mplus',
+ [LANGUAGES.JP]: 'font-mplus',
+ [LANGUAGES.VT]: 'font-mplus',
+ [LANGUAGES.CHN]: 'font-noto',
+} as const;
diff --git a/constants/rating.ts b/constants/rating.ts
new file mode 100644
index 0000000..a178e09
--- /dev/null
+++ b/constants/rating.ts
@@ -0,0 +1,65 @@
+export const RATING_THRESHOLDS = [
+ {
+ min: 100,
+ rating: 5,
+ feedback: '발음이 아주 정확해!',
+ feedbackEn: 'Your pronunciation is perfect!',
+ },
+ {
+ min: 70,
+ rating: 4,
+ feedback: '발음이 정확해!',
+ feedbackEn: 'Your pronunciation is accurate!',
+ },
+ {
+ min: 50,
+ rating: 3,
+ feedback: '발음이 나쁘지 않은걸?',
+ feedbackEn: "Your pronunciation isn't bad?",
+ },
+ {
+ min: 20,
+ rating: 2,
+ feedback: '발음은 노력해 보자!',
+ feedbackEn: 'Keep trying to improve your pronunciation!',
+ },
+ {
+ min: 0,
+ rating: 1,
+ feedback: '발음은 많이 노력해 보자!',
+ feedbackEn: 'You need to work on your pronunciation!',
+ },
+];
+
+export const SPELLING_RATING_THRESHOLDS = [
+ {
+ min: 100,
+ rating: 5,
+ feedback: '맞춤법이 아주 정확해!',
+ feedbackEn: 'Your spelling is perfect!',
+ },
+ {
+ min: 70,
+ rating: 4,
+ feedback: '맞춤법이 정확해!',
+ feedbackEn: 'Your spelling is accurate!',
+ },
+ {
+ min: 50,
+ rating: 3,
+ feedback: '맞춤법이 나쁘지 않은걸?',
+ feedbackEn: "Your spelling isn't bad?",
+ },
+ {
+ min: 20,
+ rating: 2,
+ feedback: '맞춤법은 노력해 보자!',
+ feedbackEn: 'You should improve your spelling!',
+ },
+ {
+ min: 0,
+ rating: 1,
+ feedback: '맞춤법은 많이 노력해 보자!',
+ feedbackEn: 'You really need to work on your spelling!',
+ },
+];
diff --git a/constants/routes.ts b/constants/routes.ts
new file mode 100644
index 0000000..d9f7d23
--- /dev/null
+++ b/constants/routes.ts
@@ -0,0 +1,32 @@
+export const ROUTES = {
+ SPLASH: '/',
+ ONBOARDING: {
+ ROOT: '/onboarding',
+ LOGIN: '/onboarding/login',
+ SIGNIN: {
+ ROOT: '/onboarding/signin',
+ getStep: (step: number) => `/onboarding/signin/step${step}`,
+ },
+ VOICE: {
+ ROOT: '/onboarding/voice',
+ SELECT: '/onboarding/voice/select',
+ COMPLETE: '/onboarding/voice/complete',
+ },
+ },
+ MAIN: {
+ ROOT: '/main',
+ MY_LEARNING: {
+ ROOT: '/main/my-learning',
+ getLevel: (level: number) => `/main/my-learning/level-${level}`,
+ getStep: (
+ level: number,
+ step: 'intro1' | 'step1' | 'step2' | 'step3' | 'intro2' | 'ending',
+ ) => `/main/my-learning/level-${level}/${step}`,
+ },
+ CONVERSATION: {
+ ROOT: '/main/conversation',
+ CALLING: '/main/conversation/calling',
+ },
+ MY_PAGE: '/main/my-page',
+ },
+};
diff --git a/constants/voiceData.ts b/constants/voiceData.ts
new file mode 100644
index 0000000..91507b9
--- /dev/null
+++ b/constants/voiceData.ts
@@ -0,0 +1,66 @@
+import { Language } from './languages';
+
+export type LangKey = Language;
+
+interface VoiceOption {
+ text: string;
+ subText: Record;
+}
+
+interface VoiceData {
+ selectVoice: {
+ characterVoice: Record;
+ };
+}
+
+export const voiceData: VoiceData = {
+ selectVoice: {
+ characterVoice: {
+ voice1: {
+ text: 'Kody의 목소리',
+ subText: {
+ vt: 'Giọng nói của Kody',
+ en: "Kody's voice",
+ jp: 'コディの声',
+ chn: 'Kody的声音',
+ },
+ },
+ voice2: {
+ text: 'Kody의 목소리22',
+ subText: {
+ vt: 'Giọng nói của Kody',
+ en: "Kody's voice",
+ jp: 'コディの声',
+ chn: 'Kody的声音',
+ },
+ },
+ voice3: {
+ text: '33Kody의 목소리',
+ subText: {
+ vt: 'Giọng nói của Kody',
+ en: "Kody's voice",
+ jp: 'コディの声',
+ chn: 'Kody的声音',
+ },
+ },
+ voice4: {
+ text: '444Kody의 목소리',
+ subText: {
+ vt: 'Giọng nói của Kody',
+ en: "Kody's voice",
+ jp: 'コディの声',
+ chn: 'Kody的声音',
+ },
+ },
+ voice5: {
+ text: '555Kody의 목소리',
+ subText: {
+ vt: 'Giọng nói của Kody',
+ en: "Kody's voice",
+ jp: 'コディの声',
+ chn: 'Kody的声音',
+ },
+ },
+ },
+ },
+};
diff --git a/hooks/useLevelParam.ts b/hooks/useLevelParam.ts
new file mode 100644
index 0000000..7d13a45
--- /dev/null
+++ b/hooks/useLevelParam.ts
@@ -0,0 +1,9 @@
+'use client';
+import { usePathname } from 'next/navigation';
+
+export function useLevelParam(defaultLevel = 1): number {
+ const pathname = usePathname(); // 예: /main/my-learning/level-2/intro
+ const match = pathname.match(/level-(\d+)/);
+ const level = match ? Number(match[1]) : defaultLevel;
+ return level;
+}
diff --git a/hooks/useVoiceSubmit.ts b/hooks/useVoiceSubmit.ts
new file mode 100644
index 0000000..df3d92a
--- /dev/null
+++ b/hooks/useVoiceSubmit.ts
@@ -0,0 +1,46 @@
+import { useState } from 'react';
+
+export function useVoiceSubmit() {
+ const [inputText, setInputText] = useState('');
+ const [isProcessing, setIsProcessing] = useState(false);
+
+ const handleVoiceSubmit = async (blob: Blob) => {
+ setIsProcessing(true);
+ try {
+ const base64 = await blobToBase64(blob);
+ const res = await fetch('/api/deepgram/transcribe', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ audioBase64: base64 }),
+ });
+ const data = await res.json();
+ if (data.transcript) {
+ setInputText(data.transcript);
+ return data.transcript;
+ } else {
+ console.error('STT 실패:', data.error);
+ return '';
+ }
+ } catch (err) {
+ console.error('STT 에러:', err);
+ return '';
+ } finally {
+ setIsProcessing(false);
+ }
+ };
+
+ return { inputText, isProcessing, handleVoiceSubmit };
+}
+
+// Blob → Base64
+function blobToBase64(blob: Blob): Promise {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onloadend = () => {
+ const result = reader.result as string;
+ resolve(result.split(',')[1]);
+ };
+ reader.onerror = reject;
+ reader.readAsDataURL(blob);
+ });
+}
diff --git a/package-lock.json b/package-lock.json
index 9b2011c..4906a97 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,14 +8,19 @@
"name": "frontend",
"version": "0.1.0",
"dependencies": {
+ "@deepgram/sdk": "^4.11.2",
"axios": "^1.10.0",
+ "dotenv": "^17.2.1",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-prettier": "^5.5.1",
+ "framer-motion": "^12.23.12",
+ "iso-639-1": "^3.1.5",
"next": "15.3.5",
"next-pwa": "^5.6.0",
"prettier": "^3.6.2",
"react": "^19.0.0",
- "react-dom": "^19.0.0"
+ "react-dom": "^19.0.0",
+ "zustand": "^5.0.7"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
@@ -1546,6 +1551,50 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@deepgram/captions": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@deepgram/captions/-/captions-1.2.0.tgz",
+ "integrity": "sha512-8B1C/oTxTxyHlSFubAhNRgCbQ2SQ5wwvtlByn8sDYZvdDtdn/VE2yEPZ4BvUnrKWmsbTQY6/ooLV+9Ka2qmDSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "dayjs": "^1.11.10"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@deepgram/sdk": {
+ "version": "4.11.2",
+ "resolved": "https://registry.npmjs.org/@deepgram/sdk/-/sdk-4.11.2.tgz",
+ "integrity": "sha512-lKGxuXxlSixC8bB0BnzmIpbVjUSgYtz17cqvrgv0ZjmazgUPkuUj9egQPj6k+fbPX8wRzWEqlhrL/DXlXqeDXA==",
+ "license": "MIT",
+ "dependencies": {
+ "@deepgram/captions": "^1.1.1",
+ "@types/node": "^18.19.39",
+ "cross-fetch": "^3.1.5",
+ "deepmerge": "^4.3.1",
+ "events": "^3.3.0",
+ "ws": "^8.17.0"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@deepgram/sdk/node_modules/@types/node": {
+ "version": "18.19.123",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.123.tgz",
+ "integrity": "sha512-K7DIaHnh0mzVxreCR9qwgNxp3MH9dltPNIEddW9MYUlcKAzm+3grKNSTe2vCJHI1FaLpvpL5JGJrz1UZDKYvDg==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~5.26.4"
+ }
+ },
+ "node_modules/@deepgram/sdk/node_modules/undici-types": {
+ "version": "5.26.5",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
+ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
+ "license": "MIT"
+ },
"node_modules/@emnapi/core": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.4.tgz",
@@ -2968,7 +3017,7 @@
"version": "19.1.8",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz",
"integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
@@ -4517,6 +4566,15 @@
"url": "https://opencollective.com/core-js"
}
},
+ "node_modules/cross-fetch": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz",
+ "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==",
+ "license": "MIT",
+ "dependencies": {
+ "node-fetch": "^2.7.0"
+ }
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -4544,7 +4602,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
- "dev": true,
+ "devOptional": true,
"license": "MIT"
},
"node_modules/damerau-levenshtein": {
@@ -4605,6 +4663,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/dayjs": {
+ "version": "1.11.13",
+ "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
+ "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==",
+ "license": "MIT"
+ },
"node_modules/debug": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
@@ -4770,6 +4834,18 @@
"node": ">=0.10.0"
}
},
+ "node_modules/dotenv": {
+ "version": "17.2.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.1.tgz",
+ "integrity": "sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -5507,7 +5583,6 @@
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=0.8.x"
}
@@ -5746,6 +5821,33 @@
"node": ">= 6"
}
},
+ "node_modules/framer-motion": {
+ "version": "12.23.12",
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.12.tgz",
+ "integrity": "sha512-6e78rdVtnBvlEVgu6eFEAgG9v3wLnYEboM8I5O5EXvfKC8gxGQB8wXJdhkMy10iVcn05jl6CNw7/HTsTCfwcWg==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-dom": "^12.23.12",
+ "motion-utils": "^12.23.6",
+ "tslib": "^2.4.0"
+ },
+ "peerDependencies": {
+ "@emotion/is-prop-valid": "*",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/is-prop-valid": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/fs-extra": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
@@ -6636,6 +6738,15 @@
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
+ "node_modules/iso-639-1": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/iso-639-1/-/iso-639-1-3.1.5.tgz",
+ "integrity": "sha512-gXkz5+KN7HrG0Q5UGqSMO2qB9AsbEeyLP54kF1YrMsIxmu+g4BdB7rflReZTSTZGpfj8wywu6pfPBCylPIzGQA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0"
+ }
+ },
"node_modules/iterator.prototype": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz",
@@ -7368,6 +7479,21 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/motion-dom": {
+ "version": "12.23.12",
+ "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.12.tgz",
+ "integrity": "sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-utils": "^12.23.6"
+ }
+ },
+ "node_modules/motion-utils": {
+ "version": "12.23.6",
+ "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
+ "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
+ "license": "MIT"
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -7520,6 +7646,48 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/node-fetch": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
+ "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-url": "^5.0.0"
+ },
+ "engines": {
+ "node": "4.x || >=6.0.0"
+ },
+ "peerDependencies": {
+ "encoding": "^0.1.0"
+ },
+ "peerDependenciesMeta": {
+ "encoding": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/node-fetch/node_modules/tr46": {
+ "version": "0.0.3",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
+ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
+ "license": "MIT"
+ },
+ "node_modules/node-fetch/node_modules/webidl-conversions": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/node-fetch/node_modules/whatwg-url": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
+ "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
+ "license": "MIT",
+ "dependencies": {
+ "tr46": "~0.0.3",
+ "webidl-conversions": "^3.0.0"
+ }
+ },
"node_modules/node-releases": {
"version": "2.0.19",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
@@ -10041,6 +10209,27 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
+ "node_modules/ws": {
+ "version": "8.18.3",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
+ "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
"node_modules/yallist": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
@@ -10062,6 +10251,35 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
+ },
+ "node_modules/zustand": {
+ "version": "5.0.7",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.7.tgz",
+ "integrity": "sha512-Ot6uqHDW/O2VdYsKLLU8GQu8sCOM1LcoE8RwvLv9uuRT9s6SOHCKs0ZEOhxg+I1Ld+A1Q5lwx+UlKXXUoCZITg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18.0.0",
+ "immer": ">=9.0.6",
+ "react": ">=18.0.0",
+ "use-sync-external-store": ">=1.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "use-sync-external-store": {
+ "optional": true
+ }
+ }
}
}
}
diff --git a/package.json b/package.json
index 757dc95..e1181ee 100644
--- a/package.json
+++ b/package.json
@@ -10,14 +10,19 @@
"type-check": "tsc --noEmit"
},
"dependencies": {
+ "@deepgram/sdk": "^4.11.2",
"axios": "^1.10.0",
+ "dotenv": "^17.2.1",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-prettier": "^5.5.1",
+ "framer-motion": "^12.23.12",
+ "iso-639-1": "^3.1.5",
"next": "15.3.5",
"next-pwa": "^5.6.0",
"prettier": "^3.6.2",
"react": "^19.0.0",
- "react-dom": "^19.0.0"
+ "react-dom": "^19.0.0",
+ "zustand": "^5.0.7"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
diff --git a/public/character/bg.webp b/public/character/bg.webp
new file mode 100644
index 0000000..867c10f
Binary files /dev/null and b/public/character/bg.webp differ
diff --git a/public/character/default.webp b/public/character/default.webp
new file mode 100644
index 0000000..94c8145
Binary files /dev/null and b/public/character/default.webp differ
diff --git a/public/icons/arrow-right.svg b/public/icons/arrow-right.svg
new file mode 100644
index 0000000..d6eae32
--- /dev/null
+++ b/public/icons/arrow-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/public/icons/audio-blue.svg b/public/icons/audio-blue.svg
new file mode 100644
index 0000000..92727aa
--- /dev/null
+++ b/public/icons/audio-blue.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/public/icons/audio-gray.svg b/public/icons/audio-gray.svg
new file mode 100644
index 0000000..513655d
--- /dev/null
+++ b/public/icons/audio-gray.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/public/icons/audio.svg b/public/icons/audio.svg
new file mode 100644
index 0000000..a3548c6
--- /dev/null
+++ b/public/icons/audio.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/public/icons/book.svg b/public/icons/book.svg
new file mode 100644
index 0000000..7ed953b
--- /dev/null
+++ b/public/icons/book.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/public/icons/bookmark-checked.svg b/public/icons/bookmark-checked.svg
new file mode 100644
index 0000000..55a5e30
--- /dev/null
+++ b/public/icons/bookmark-checked.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/public/icons/bookmark-unchecked-black.svg b/public/icons/bookmark-unchecked-black.svg
new file mode 100644
index 0000000..aebff67
--- /dev/null
+++ b/public/icons/bookmark-unchecked-black.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/public/icons/bookmark-unchecked.svg b/public/icons/bookmark-unchecked.svg
new file mode 100644
index 0000000..eabad5f
--- /dev/null
+++ b/public/icons/bookmark-unchecked.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/public/icons/call-end.svg b/public/icons/call-end.svg
new file mode 100644
index 0000000..6ee9c4e
--- /dev/null
+++ b/public/icons/call-end.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/public/icons/call.svg b/public/icons/call.svg
new file mode 100644
index 0000000..6641428
--- /dev/null
+++ b/public/icons/call.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/public/icons/check-checked-small.svg b/public/icons/check-checked-small.svg
new file mode 100644
index 0000000..f12d390
--- /dev/null
+++ b/public/icons/check-checked-small.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/public/icons/check-disabled.svg b/public/icons/check-disabled.svg
new file mode 100644
index 0000000..266024f
--- /dev/null
+++ b/public/icons/check-disabled.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/public/icons/check-enabled.svg b/public/icons/check-enabled.svg
new file mode 100644
index 0000000..1e792cb
--- /dev/null
+++ b/public/icons/check-enabled.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/public/icons/check-pressed.svg b/public/icons/check-pressed.svg
new file mode 100644
index 0000000..724c667
--- /dev/null
+++ b/public/icons/check-pressed.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/public/icons/check-unchecked-small.svg b/public/icons/check-unchecked-small.svg
new file mode 100644
index 0000000..3eb9929
--- /dev/null
+++ b/public/icons/check-unchecked-small.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/public/icons/checkbox-checked.svg b/public/icons/checkbox-checked.svg
new file mode 100644
index 0000000..7757338
--- /dev/null
+++ b/public/icons/checkbox-checked.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/public/icons/checkbox-unchecked.svg b/public/icons/checkbox-unchecked.svg
new file mode 100644
index 0000000..2c26eda
--- /dev/null
+++ b/public/icons/checkbox-unchecked.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/public/icons/chevron-down.svg b/public/icons/chevron-down.svg
new file mode 100644
index 0000000..1cc5b0c
--- /dev/null
+++ b/public/icons/chevron-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/public/icons/chevron-left.svg b/public/icons/chevron-left.svg
new file mode 100644
index 0000000..f506e72
--- /dev/null
+++ b/public/icons/chevron-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/public/icons/chevron-right.svg b/public/icons/chevron-right.svg
new file mode 100644
index 0000000..880118b
--- /dev/null
+++ b/public/icons/chevron-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/public/icons/chevron-up.svg b/public/icons/chevron-up.svg
new file mode 100644
index 0000000..ed7e74a
--- /dev/null
+++ b/public/icons/chevron-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/public/icons/flag.svg b/public/icons/flag.svg
new file mode 100644
index 0000000..05f2788
--- /dev/null
+++ b/public/icons/flag.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/public/icons/google-logo.svg b/public/icons/google-logo.svg
new file mode 100644
index 0000000..f49ab28
--- /dev/null
+++ b/public/icons/google-logo.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/public/icons/keyboard.svg b/public/icons/keyboard.svg
new file mode 100644
index 0000000..6a86a99
--- /dev/null
+++ b/public/icons/keyboard.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/public/icons/logo.svg b/public/icons/logo.svg
new file mode 100644
index 0000000..5f98b96
--- /dev/null
+++ b/public/icons/logo.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/public/icons/mic-gray.svg b/public/icons/mic-gray.svg
new file mode 100644
index 0000000..14750a6
--- /dev/null
+++ b/public/icons/mic-gray.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/public/icons/mic.svg b/public/icons/mic.svg
new file mode 100644
index 0000000..95872e8
--- /dev/null
+++ b/public/icons/mic.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/public/icons/nav-book-blue.svg b/public/icons/nav-book-blue.svg
new file mode 100644
index 0000000..2744275
--- /dev/null
+++ b/public/icons/nav-book-blue.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/public/icons/nav-book.svg b/public/icons/nav-book.svg
new file mode 100644
index 0000000..b7adbc1
--- /dev/null
+++ b/public/icons/nav-book.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/public/icons/nav-phone-blue.svg b/public/icons/nav-phone-blue.svg
new file mode 100644
index 0000000..00d717d
--- /dev/null
+++ b/public/icons/nav-phone-blue.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/public/icons/nav-phone.svg b/public/icons/nav-phone.svg
new file mode 100644
index 0000000..84cc9f9
--- /dev/null
+++ b/public/icons/nav-phone.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/public/icons/nav-user-blue.svg b/public/icons/nav-user-blue.svg
new file mode 100644
index 0000000..f7fce88
--- /dev/null
+++ b/public/icons/nav-user-blue.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/public/icons/nav-user.svg b/public/icons/nav-user.svg
new file mode 100644
index 0000000..e3c3dca
--- /dev/null
+++ b/public/icons/nav-user.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/public/icons/pwa/192x192.png b/public/icons/pwa/192x192.png
index 9a6be1f..deb3ce8 100644
Binary files a/public/icons/pwa/192x192.png and b/public/icons/pwa/192x192.png differ
diff --git a/public/icons/pwa/512x512.png b/public/icons/pwa/512x512.png
index 56886f3..bc4a3ba 100644
Binary files a/public/icons/pwa/512x512.png and b/public/icons/pwa/512x512.png differ
diff --git a/public/icons/retry.svg b/public/icons/retry.svg
new file mode 100644
index 0000000..206d8f9
--- /dev/null
+++ b/public/icons/retry.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/public/icons/setting.svg b/public/icons/setting.svg
new file mode 100644
index 0000000..d222e01
--- /dev/null
+++ b/public/icons/setting.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/public/icons/star-filled.svg b/public/icons/star-filled.svg
new file mode 100644
index 0000000..6c95a2d
--- /dev/null
+++ b/public/icons/star-filled.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/public/icons/star-unfilled.svg b/public/icons/star-unfilled.svg
new file mode 100644
index 0000000..0992f37
--- /dev/null
+++ b/public/icons/star-unfilled.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/public/icons/translate-gray.svg b/public/icons/translate-gray.svg
new file mode 100644
index 0000000..69c1257
--- /dev/null
+++ b/public/icons/translate-gray.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/public/ttsTest.m4a b/public/ttsTest.m4a
new file mode 100644
index 0000000..37f7a61
Binary files /dev/null and b/public/ttsTest.m4a differ
diff --git a/stores/callStore.ts b/stores/callStore.ts
new file mode 100644
index 0000000..7a0ab07
--- /dev/null
+++ b/stores/callStore.ts
@@ -0,0 +1,16 @@
+import { create } from 'zustand';
+
+interface CallState {
+ remainingCalls: number;
+ decrementCall: () => void;
+ resetCalls: (count: number) => void;
+}
+
+export const useCallStore = create(set => ({
+ remainingCalls: 3, // 초기값
+ decrementCall: () =>
+ set(state => ({
+ remainingCalls: state.remainingCalls > 0 ? state.remainingCalls - 1 : 0,
+ })),
+ resetCalls: (count: number) => set({ remainingCalls: count }),
+}));
diff --git a/stores/languageStore.ts b/stores/languageStore.ts
new file mode 100644
index 0000000..3040086
--- /dev/null
+++ b/stores/languageStore.ts
@@ -0,0 +1,24 @@
+import { create } from 'zustand';
+import { persist } from 'zustand/middleware';
+import {
+ LanguageDropdown,
+ LanguageDropdownProps,
+} from '@/constants/dropdown/languages';
+
+interface LanguageState {
+ currentLanguage: LanguageDropdownProps;
+ setLanguage: (lang: LanguageDropdownProps) => void;
+}
+
+export const useLanguageStore = create()(
+ persist(
+ set => ({
+ currentLanguage: LanguageDropdown[0],
+ setLanguage: (lang: LanguageDropdownProps) =>
+ set({ currentLanguage: lang }),
+ }),
+ {
+ name: 'language-store', // localStorage key
+ },
+ ),
+);
diff --git a/stores/userStore.ts b/stores/userStore.ts
new file mode 100644
index 0000000..c752a17
--- /dev/null
+++ b/stores/userStore.ts
@@ -0,0 +1,32 @@
+import { SchoolOption } from '@/constants/dropdown/schools';
+import { create } from 'zustand';
+import { persist } from 'zustand/middleware';
+
+interface UserStore {
+ username: string;
+ setUsername: (name: string) => void;
+ age: string;
+ setAge: (age: string) => void;
+ school: SchoolOption['value'];
+ setSchool: (school: SchoolOption['value']) => void;
+ grade: string;
+ setGrade: (grade: string) => void;
+}
+
+export const useUserStore = create(
+ persist(
+ set => ({
+ username: '',
+ setUsername: name => set({ username: name }),
+ age: '',
+ setAge: age => set({ age: age }),
+ school: '',
+ setSchool: school => set({ school }),
+ grade: '',
+ setGrade: grade => set({ grade }),
+ }),
+ {
+ name: 'user-store', // localStorage key
+ },
+ ),
+);
diff --git a/stores/voiceStore.ts b/stores/voiceStore.ts
new file mode 100644
index 0000000..9197918
--- /dev/null
+++ b/stores/voiceStore.ts
@@ -0,0 +1,11 @@
+import { create } from 'zustand';
+
+interface VoiceState {
+ selectedVoice: number | null; // 선택된 목소리 인덱스
+ setSelectedVoice: (idx: number | null) => void;
+}
+
+export const useVoiceStore = create(set => ({
+ selectedVoice: null,
+ setSelectedVoice: idx => set({ selectedVoice: idx }),
+}));
diff --git a/utils/getOrdinalSuffix.ts b/utils/getOrdinalSuffix.ts
new file mode 100644
index 0000000..08d9db0
--- /dev/null
+++ b/utils/getOrdinalSuffix.ts
@@ -0,0 +1,16 @@
+export const getOrdinalSuffix = (numStr: string | number) => {
+ const num = typeof numStr === 'string' ? parseInt(numStr, 10) : numStr;
+ if (isNaN(num)) return '';
+ const tens = num % 100;
+ if (tens >= 11 && tens <= 13) return 'th';
+ switch (num % 10) {
+ case 1:
+ return 'st';
+ case 2:
+ return 'nd';
+ case 3:
+ return 'rd';
+ default:
+ return 'th';
+ }
+};
diff --git a/utils/similarity.ts b/utils/similarity.ts
new file mode 100644
index 0000000..89724ab
--- /dev/null
+++ b/utils/similarity.ts
@@ -0,0 +1,33 @@
+/** 두 문자열의 Levenshtein distance 계산 */
+export function levenshtein(a: string, b: string): number {
+ const matrix = Array.from({ length: b.length + 1 }, (_, i) =>
+ Array.from({ length: a.length + 1 }, (_, j) => 0),
+ );
+
+ for (let i = 0; i <= b.length; i++) matrix[i][0] = i;
+ for (let j = 0; j <= a.length; j++) matrix[0][j] = j;
+
+ for (let i = 1; i <= b.length; i++) {
+ for (let j = 1; j <= a.length; j++) {
+ const cost = a[j - 1] === b[i - 1] ? 0 : 1;
+ matrix[i][j] = Math.min(
+ matrix[i - 1][j] + 1, // 삭제
+ matrix[i][j - 1] + 1, // 삽입
+ matrix[i - 1][j - 1] + cost, // 교체
+ );
+ }
+ }
+ return matrix[b.length][a.length];
+}
+
+/** 두 단어 유사도를 0~100 퍼센트로 반환 */
+export function similarity(a: string, b: string): number {
+ if (!a || !b) return 0;
+ const longer = a.length > b.length ? a : b;
+ const shorter = a.length > b.length ? b : a;
+ const longerLength = longer.length;
+ if (longerLength === 0) return 100;
+
+ const distance = levenshtein(longer, shorter);
+ return Math.round(((longerLength - distance) / longerLength) * 100);
+}
diff --git a/utils/textHighlighter.ts b/utils/textHighlighter.ts
new file mode 100644
index 0000000..8923977
--- /dev/null
+++ b/utils/textHighlighter.ts
@@ -0,0 +1,24 @@
+export interface HighlightedWord {
+ text: string;
+ key: number;
+ percentage?: number; // 단어별 점수
+}
+
+export function highlightWords(
+ phrase: string,
+ percentages: number[], // 단어 순서대로 점수
+ options?: { punctuation?: boolean },
+): HighlightedWord[] {
+ const punctuationRegex = options?.punctuation ? /[.,!?;:]/g : undefined;
+
+ return phrase.split(/\s+/).map((word, idx) => {
+ const cleanWord = punctuationRegex
+ ? word.replace(punctuationRegex, '')
+ : word;
+ return {
+ text: word,
+ key: idx,
+ percentage: percentages[idx] ?? 0,
+ };
+ });
+}