diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 9349998..d7fbadb 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -1,18 +1,34 @@
-## JIRA 이슈 키
-
+## 🔑 JIRA 이슈 키
+
+---
-## JIRA 이슈 키
+## ✒ 진행한 작업
+- [x] 진행한 작업
+- [x] 진행한 작업
+- [x] 진행한 작업
+
+---
+
+## 💡 생겼던 문제 및 해결법
1️⃣ 요약
-문제:
-해결:
+- 문제:
+- 해결:
+
+---
+## 📢 아쉬운 부분 및 개선점
+- ...
-## 스크린샷
+---
+## 📚 개발에 참고한 자료 및 포인트
+- 참고한 부분 요약 및 포인트
+ - link
-## 아쉬운 부분 및 개선점
+---
+## 📸 스크린샷
+- ...
-## 개발에 참고한 자료 및 포인트
-
\ No newline at end of file
+---
\ No newline at end of file
diff --git a/app/api/deepgram/transcribe/route.ts b/app/api/deepgram/transcribe/route.ts
new file mode 100644
index 0000000..e118e2d
--- /dev/null
+++ b/app/api/deepgram/transcribe/route.ts
@@ -0,0 +1,63 @@
+import { createClient } from '@deepgram/sdk';
+import { NextRequest, NextResponse } from 'next/server';
+
+export const POST = async (req: NextRequest) => {
+ try {
+ const deepgram = createClient(process.env.DEEPGRAM_API_KEY!);
+
+ // JSON으로 Base64 보내는 경우
+ const contentType = req.headers.get('content-type') || '';
+ let audioBuffer: Buffer | null = null;
+
+ if (contentType.includes('application/json')) {
+ const { audioBase64 } = await req.json();
+ if (!audioBase64) {
+ return NextResponse.json(
+ { error: 'No audioBase64 provided' },
+ { status: 400 },
+ );
+ }
+ audioBuffer = Buffer.from(audioBase64, 'base64');
+ }
+ // FormData로 보내는 경우
+ else if (contentType.includes('multipart/form-data')) {
+ const formData = await req.formData();
+ const audioFile = formData.get('file') as File;
+ if (!audioFile) {
+ return NextResponse.json(
+ { error: 'No audio file provided' },
+ { status: 400 },
+ );
+ }
+ const arrayBuffer = await audioFile.arrayBuffer();
+ audioBuffer = Buffer.from(arrayBuffer);
+ } else {
+ return NextResponse.json(
+ { error: 'Unsupported Content-Type' },
+ { status: 400 },
+ );
+ }
+
+ // Deepgram STT
+ const { result } = await deepgram.listen.prerecorded.transcribeFile(
+ audioBuffer,
+ {
+ model: 'nova-2',
+ language: 'ko',
+ smart_format: true,
+ },
+ );
+
+ // 안전하게 transcript 추출
+ const transcript =
+ result?.results?.channels?.[0]?.alternatives?.[0]?.transcript || '';
+
+ return NextResponse.json({ transcript });
+ } catch (err) {
+ console.error('STT Error:', err);
+ return NextResponse.json(
+ { error: err instanceof Error ? err.message : 'Unknown error' },
+ { status: 500 },
+ );
+ }
+};
diff --git a/app/favicon.ico b/app/favicon.ico
index 718d6fe..c17f1e0 100644
Binary files a/app/favicon.ico and b/app/favicon.ico differ
diff --git a/app/globals.css b/app/globals.css
index a2dc41e..51874cf 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -1,15 +1,17 @@
@import "tailwindcss";
:root {
+ --font-gothic-a1: 'Gothic A1', sans-serif;
+ --font-mplus-2: 'M PLUS 2', sans-serif;
+ --font-noto-sc: 'Noto Sans SC',sans-serif;
+ --font-sans: var(--font-gothic-a1), var(--font-mplus-2), var(--font-noto-sc), system-ui, sans-serif;
+
--background: #ffffff;
--foreground: #171717;
-}
-@theme inline {
- --color-background: var(--background);
- --color-foreground: var(--foreground);
- --font-sans: var(--font-geist-sans);
- --font-mono: var(--font-geist-mono);
+ .scrollbar-hide::-webkit-scrollbar {
+ display: none;
+}
}
@media (prefers-color-scheme: dark) {
@@ -19,8 +21,170 @@
}
}
+@theme inline {
+ /* 폰트 */
+ --font-sans: var(--font-sans);
+ --font-gothic: var(--font-gothic-a1);
+ --font-mplus: var(--font-mplus-2);
+ --font-noto: var(--font-noto-sc);
+
+ /* 컬러 */
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+
+ --color-primary: #F7DD4A;
+ --color-primary-900: #FEF9DD;
+ --color-primary-800: #FBEFAC;
+ --color-primary-dimensional: #F6B831;
+
+ --color-secondary: #BFE2F3;
+ --color-secondary-400: #248EC1;
+ --color-secondary-300: #1C6E96;
+
+ --color-bg-solid: #E6F4FA;
+
+ --color-error: #F74A4A;
+ --color-error-dimensional: #D51111;
+
+ --color-black: #1B1B1B;
+ --color-black-70: rgba(27, 27, 27, 0.7);
+
+ --color-gray-300: #4F4F4F;
+ --color-gray-500: #828282;
+ --color-gray-700: #B5B5B5;
+ --color-gray-900: #E8E8E8;
+ --color-gray-950: #F2F2F2;
+
+ --color-white: #FEFEFE;
+ --color-white-70: rgba(254, 254, 254, 0.7);
+
+ /* H1 Bold */
+ --text-h1-bold: 24px;
+ --text-h1-bold--line-height: 160%;
+ --text-h1-bold--font-weight: 700;
+ --text-h1-bold--font-family: var(--font-gothic-a1);
+
+ /* H1 Regular */
+ --text-h1-regular: 24px;
+ --text-h1-regular--line-height: 160%;
+ --text-h1-regular--font-weight: 400;
+ --text-h1-regular--font-family: var(--font-gothic-a1);
+
+ /* H2 Bold */
+ --text-h2-bold: 20px;
+ --text-h2-bold--line-height: 160%;
+ --text-h2-bold--font-weight: 700;
+ --text-h2-bold--font-family: var(--font-gothic-a1);
+
+ /* H2 Regular */
+ --text-h2-regular: 20px;
+ --text-h2-regular--line-height: 160%;
+ --text-h2-regular--font-weight: 400;
+ --text-h2-regular--font-family: var(--font-gothic-a1);
+
+ /* H3 Bold */
+ --text-h3-bold: 18px;
+ --text-h3-bold--line-height: 160%;
+ --text-h3-bold--font-weight: 700;
+ --text-h3-bold--font-family: var(--font-gothic-a1);
+
+ /* H3 Regular */
+ --text-h3-regular: 18px;
+ --text-h3-regular--line-height: 160%;
+ --text-h3-regular--font-weight: 400;
+ --text-h3-regular--font-family: var(--font-gothic-a1);
+
+ /* Body1 Bold */
+ --text-bd1-bold: 16px;
+ --text-bd1-bold--line-height: 160%;
+ --text-bd1-bold--font-weight: 700;
+ --text-bd1-bold--font-family: var(--font-gothic-a1);
+
+ /* Body1 Regular */
+ --text-bd1-regular: 16px;
+ --text-bd1-regular--line-height: 160%;
+ --text-bd1-regular--font-weight: 400;
+ --text-bd1-regular--font-family: var(--font-gothic-a1);
+
+ /* Body2 Bold */
+ --text-bd2-bold: 14px;
+ --text-bd2-bold--line-height: 160%;
+ --text-bd2-bold--font-weight: 700;
+ --text-bd2-bold--font-family: var(--font-gothic-a1);
+
+ /* Body2 Regular */
+ --text-bd2-regular: 14px;
+ --text-bd2-regular--line-height: 160%;
+ --text-bd2-regular--font-weight: 400;
+ --text-bd2-regular--font-family: var(--font-gothic-a1);
+
+ /* Caption1 Bold */
+ --text-cp1-bold: 12px;
+ --text-cp1-bold--line-height: 160%;
+ --text-cp1-bold--font-weight: 700;
+ --text-cp1-bold--font-family: var(--font-gothic-a1);
+
+ /* Caption1 Regular */
+ --text-cp1-regular: 12px;
+ --text-cp1-regular--line-height: 160%;
+ --text-cp1-regular--font-weight: 400;
+ --text-cp1-regular--font-family: var(--font-gothic-a1);
+
+ /* Caption2 Bold */
+ --text-cp2-bold: 10px;
+ --text-cp2-bold--line-height: 160%;
+ --text-cp2-bold--font-weight: 700;
+ --text-cp2-bold--font-family: var(--font-gothic-a1);
+
+ /* Caption2 Regular */
+ --text-cp2-regular: 10px;
+ --text-cp2-regular--line-height: 160%;
+ --text-cp2-regular--font-weight: 400;
+ --text-cp2-regular--font-family: var(--font-gothic-a1);
+
+ /* Translate Caption1 Regular */
+ --text-trans-cp1-regular: 14px;
+ --text-cp1-regular--line-height: 160%;
+ --text-cp1-regular--font-weight: 400;
+ --text-cp1-regular--font-family: var(--font-mplus-2);
+
+ /* Translate Caption1 Regular */
+ --text-trans-cp1-regular: 14px;
+ --text-trans-cp1-regular--line-height: 160%;
+ --text-trans-cp1-regular--font-weight: 400;
+ --text-trans-cp1-regular--font-family: var(--font-mplus-2);
+
+ /* Translate Caption2 Regular */
+ --text-trans-cp2-regular: 12px;
+ --text-trans-cp2-regular--line-height: 120%;
+ --text-trans-cp2-regular--font-weight: 400;
+ --text-trans-cp2-regular--font-family: var(--font-mplus-2);
+
+ /* Translate Caption3 Regular */
+ --text-trans-cp3-regular: 10px;
+ --text-trans-cp3-regular--line-height: 120%;
+ --text-trans-cp3-regular--font-weight: 400;
+ --text-trans-cp3-regular--font-family: var(--font-mplus-2);
+}
+
+@layer utilities {
+ /* 그라디언트 */
+ .bg-gradient {
+ background-image: linear-gradient(to bottom, #FEFEFE, #E6F4FA);
+ }
+ .bg-gradient-reverse {
+ background-image: linear-gradient(to bottom, #E6F4FA, #FEFEFE);
+ }
+ /* 스크롤바 숨기기 */
+ .scrollbar-hide {
+ &::-webkit-scrollbar {
+ display: none;
+ }
+ scrollbar-width: none;
+ }
+}
+
body {
background: var(--background);
color: var(--foreground);
- font-family: Arial, Helvetica, sans-serif;
}
diff --git a/app/layout.tsx b/app/layout.tsx
index 1e8468c..df4091c 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,16 +1,24 @@
import type { Metadata } from 'next';
-import { Geist, Geist_Mono } from 'next/font/google';
+import { Gothic_A1, M_PLUS_2, Noto_Sans_SC } from 'next/font/google';
import './globals.css';
import { ReactNode } from 'react';
-const geistSans = Geist({
- variable: '--font-geist-sans',
+const gothic = Gothic_A1({
+ weight: ['400', '700'],
subsets: ['latin'],
+ variable: '--font-gothic-a1',
});
-const geistMono = Geist_Mono({
- variable: '--font-geist-mono',
+const mplus = M_PLUS_2({
+ weight: ['400'],
+ subsets: ['latin', 'vietnamese', 'latin-ext'],
+ variable: '--font-mplus-2',
+});
+
+const noto = Noto_Sans_SC({
+ weight: ['400'],
subsets: ['latin'],
+ variable: '--font-noto-sc',
});
// TODO: 메타데이터 전체 수정
@@ -24,16 +32,13 @@ export const metadata: Metadata = {
},
};
-export default function RootLayout({
- children,
-}: Readonly<{
- children: ReactNode;
-}>) {
+export default function RootLayout({ children }: { children: ReactNode }) {
return (
-
-
+
+
{children}
diff --git a/app/main/_components/NavigationBar.tsx b/app/main/_components/NavigationBar.tsx
new file mode 100644
index 0000000..67ed688
--- /dev/null
+++ b/app/main/_components/NavigationBar.tsx
@@ -0,0 +1,62 @@
+'use client';
+
+import Image from 'next/image';
+import Link from 'next/link';
+import { usePathname } from 'next/navigation';
+import { ROUTES } from '@/constants/routes';
+
+export default function NavigationBar() {
+ const pathname = usePathname();
+
+ const navItems = [
+ {
+ label: '나의 학습',
+ href: ROUTES.MAIN.MY_LEARNING.ROOT,
+ icon: '/icons/nav-book.svg',
+ activeIcon: '/icons/nav-book-blue.svg', // active용 아이콘
+ },
+ {
+ label: '대화',
+ href: ROUTES.MAIN.CONVERSATION.ROOT,
+ icon: '/icons/nav-phone.svg',
+ activeIcon: '/icons/nav-phone-blue.svg',
+ },
+ {
+ label: '마이페이지',
+ href: ROUTES.MAIN.MY_PAGE,
+ icon: '/icons/nav-user.svg',
+ activeIcon: '/icons/nav-user-blue.svg',
+ },
+ ];
+
+ return (
+
+ {navItems.map(item => {
+ const isActive = pathname === item.href;
+ return (
+
+
+
+ {item.label}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/app/main/conversation/_components/SwipeButton.tsx b/app/main/conversation/_components/SwipeButton.tsx
new file mode 100644
index 0000000..ce2527c
--- /dev/null
+++ b/app/main/conversation/_components/SwipeButton.tsx
@@ -0,0 +1,106 @@
+'use client';
+
+import { ROUTES } from '@/constants/routes';
+import {
+ motion,
+ useAnimation,
+ useMotionValue,
+ useMotionValueEvent,
+} from 'framer-motion';
+import Image from 'next/image';
+import { useEffect, useRef, useState } from 'react';
+import { usePathname } from 'next/navigation';
+
+export default function SwipeButton() {
+ const pathname = usePathname();
+
+ const trackRef = useRef(null);
+ const knobRef = useRef(null);
+
+ const controls = useAnimation();
+ const x = useMotionValue(0);
+
+ const [maxX, setMaxX] = useState(180); // fallback
+ const [completed, setCompleted] = useState(false);
+
+ // 트랙과 노란원 실제 너비로 최대 이동거리 계산
+ useEffect(() => {
+ const measure = () => {
+ const trackW = trackRef.current?.offsetWidth ?? 244;
+ const knobW = knobRef.current?.offsetWidth ?? 56; // w-14 = 56px
+ setMaxX(trackW - knobW);
+ };
+ measure();
+ window.addEventListener('resize', measure);
+ return () => window.removeEventListener('resize', measure);
+ }, []);
+
+ // 라우트 바뀔 때/초기 마운트 때 항상 리셋
+ useEffect(() => {
+ setCompleted(false);
+ x.set(0);
+ controls.set({ x: 0 });
+ }, [pathname, controls, x]);
+
+ // bfcache로 뒤로가기 복귀 시에도 리셋
+ useEffect(() => {
+ const onPageShow = () => {
+ setCompleted(false);
+ x.set(0);
+ controls.set({ x: 0 });
+ };
+ window.addEventListener('pageshow', onPageShow);
+ return () => window.removeEventListener('pageshow', onPageShow);
+ }, [controls, x]);
+
+ // x 변화 구독: 끝에 '도착'하면 트리거
+ useMotionValueEvent(x, 'change', latest => {
+ if (!completed && latest >= maxX - 1) {
+ setCompleted(true);
+ window.location.href = ROUTES.MAIN.CONVERSATION.CALLING;
+ }
+ });
+
+ return (
+
+
+ {/* 회색 트랙 */}
+
+
+ 밀어서 시작하기 →
+
+
+
+ {/* 노란 원 아이콘 */}
+
{
+ if (!completed) {
+ // 끝에 못 닿았으면 '툭!' 하고 복귀
+ controls.start({
+ x: 0,
+ transition: { duration: 0.15, ease: 'easeOut' },
+ });
+ }
+ }}
+ animate={controls}
+ className="absolute top-1/2 -translate-y-1/2 w-14 h-14 rounded-full bg-primary flex items-center justify-center cursor-pointer"
+ >
+
+
+
+
+ );
+}
diff --git a/app/main/conversation/_locales/text.json b/app/main/conversation/_locales/text.json
new file mode 100644
index 0000000..c44133e
--- /dev/null
+++ b/app/main/conversation/_locales/text.json
@@ -0,0 +1,35 @@
+{
+ "conversation": {
+ "title": "코디에게 전화를 걸어\n대화할까요?",
+ "subText": {
+ "vt": "Chọn một ngôn ngữ!\nChúng tôi sẽ sử dụng nó để dịch.",
+ "en": "Lets call Kody and have a conversation!",
+ "jp": "言語を選択してください!\n翻訳に使用します。",
+ "chn": "选择一种语言!\n我们将用它进行翻译。"
+ },
+ "phrase1": {
+ "id": 1,
+ "kor": "너는 취미가 뭐야?",
+ "vt": "Không có ngôn ngữ mà tôi muốn.",
+ "en": "What is your hobby?",
+ "jp": "私の欲しい言語がありません。",
+ "chn": "没有我想要的语言。"
+ },
+ "phrase2": {
+ "id": 2,
+ "kor": "너는 이름이 뭐야?",
+ "vt": "Không có ngôn ngữ mà tôi muốn.",
+ "en": "What is your name?",
+ "jp": "あなたの名前は何ですか?",
+ "chn": "你叫什么名字?"
+ },
+ "phrase3": {
+ "id": 3,
+ "kor": "가장 좋아하는게 뭐야?",
+ "vt": "Không có ngôn ngữ mà tôi muốn.",
+ "en": "What is your favorite thing?",
+ "jp": "あなたの名前は何ですか?",
+ "chn": "你叫什么名字?"
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/main/conversation/calling/layout.tsx b/app/main/conversation/calling/layout.tsx
new file mode 100644
index 0000000..9d83ac7
--- /dev/null
+++ b/app/main/conversation/calling/layout.tsx
@@ -0,0 +1,12 @@
+import { ReactNode } from 'react';
+
+export default function CallingLayout({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/app/main/conversation/calling/page.tsx b/app/main/conversation/calling/page.tsx
new file mode 100644
index 0000000..fc3dc31
--- /dev/null
+++ b/app/main/conversation/calling/page.tsx
@@ -0,0 +1,123 @@
+'use client';
+
+import Image from 'next/image';
+import { useEffect, useState } from 'react';
+import { motion } from 'framer-motion';
+import { useRouter } from 'next/navigation';
+import { ROUTES } from '@/constants/routes';
+import { useCallStore } from '@/stores/callStore';
+
+export default function CallingPage() {
+ const router = useRouter();
+ const decrementCall = useCallStore(state => state.decrementCall);
+ const [showConnecting, setShowConnecting] = useState(true);
+ const [callTime, setCallTime] = useState(0);
+
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ setShowConnecting(false);
+ setCallTime(0);
+ }, 5000);
+ return () => clearTimeout(timer);
+ }, []);
+
+ useEffect(() => {
+ let interval: number | undefined;
+
+ if (!showConnecting) {
+ interval = window.setInterval(() => {
+ setCallTime(prev => prev + 1);
+ }, 1000);
+ }
+
+ return () => {
+ if (interval !== undefined) {
+ window.clearInterval(interval);
+ }
+ };
+ }, [showConnecting]);
+
+ const formatTime = (seconds: number) => {
+ const m = Math.floor(seconds / 60)
+ .toString()
+ .padStart(1, '0');
+ const s = (seconds % 60).toString().padStart(2, '0');
+ return `${m}:${s}`;
+ };
+
+ const handleEndCall = () => {
+ decrementCall();
+ router.replace(ROUTES.MAIN.CONVERSATION.ROOT);
+ };
+
+ return (
+
+ {showConnecting ? (
+ // 연결 중 모션
+
+ 코디에게 연결 중...
+ Connecting the call to Kody...
+
+ ) : (
+ <>
+ {/* 통화 시작 텍스트 모션 */}
+
+ {formatTime(callTime)}
+ 코디
+ Kody
+
+
+ {/* 캐릭터 번역 텍스트 모션 */}
+
+
+ 안녕! 오늘은 어떤 얘기할래?
+
+
+
+ What do you want to talk about today?
+
+
+ >
+ )}
+
+ {/* 이미지 영역 */}
+
+
+
+
+
+
+ );
+}
diff --git a/app/main/conversation/page.tsx b/app/main/conversation/page.tsx
new file mode 100644
index 0000000..3340f56
--- /dev/null
+++ b/app/main/conversation/page.tsx
@@ -0,0 +1,34 @@
+'use client';
+
+import TitleText from '@/components/TitleText';
+import text from './_locales/text.json';
+import CharacterText from '@/components/CharacterText';
+import SwipeButton from './_components/SwipeButton';
+import MotionFadeIn from '@/components/_animations/MotionFadeIn';
+import { useCallStore } from '@/stores/callStore';
+
+export default function ConversationPage() {
+ const { remainingCalls } = useCallStore();
+ const { title, subText } = text.conversation;
+ return (
+
+
+ 오늘 남은 통화 횟수: {remainingCalls}
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/main/layout.tsx b/app/main/layout.tsx
new file mode 100644
index 0000000..d5ed944
--- /dev/null
+++ b/app/main/layout.tsx
@@ -0,0 +1,35 @@
+'use client';
+
+import { usePathname } from 'next/navigation';
+import { ReactNode } from 'react';
+import NavigationBar from './_components/NavigationBar';
+
+interface MainLayoutProps {
+ children: ReactNode;
+}
+
+export default function MainLayout({ children }: MainLayoutProps) {
+ const pathname = usePathname();
+
+ // 네비바를 보여줄 루트 경로
+ const showNavbarRoots = [
+ '/main/my-learning',
+ '/main/conversation',
+ '/main/my-page',
+ ];
+
+ // 네비바를 숨길 하위 경로 패턴
+ const hideNavbarPaths = ['/main/my-learning/level-'];
+
+ // show 조건: 루트 경로 포함 + 숨김 패턴 미포함
+ const showNavbar =
+ showNavbarRoots.some(root => pathname === root) &&
+ !hideNavbarPaths.some(pattern => pathname.startsWith(pattern));
+
+ return (
+
+ {children}
+ {showNavbar && }
+
+ );
+}
diff --git a/app/main/my-learning/[level]/ending/layout.tsx b/app/main/my-learning/[level]/ending/layout.tsx
new file mode 100644
index 0000000..bdc6458
--- /dev/null
+++ b/app/main/my-learning/[level]/ending/layout.tsx
@@ -0,0 +1,13 @@
+import { ReactNode } from 'react';
+
+export default function LevelCompleteLayout({
+ children,
+}: {
+ children: ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/app/main/my-learning/[level]/ending/page.tsx b/app/main/my-learning/[level]/ending/page.tsx
new file mode 100644
index 0000000..b389872
--- /dev/null
+++ b/app/main/my-learning/[level]/ending/page.tsx
@@ -0,0 +1,57 @@
+'use client';
+
+import TitleText from '@/components/TitleText';
+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';
+import { useLevelParam } from '@/hooks/useLevelParam';
+
+export default function LevelCompletionPage() {
+ const router = useRouter();
+ const { currentLanguage } = useLanguageStore();
+ const levelParam = useLevelParam();
+
+ const handleBtnClick = () => {
+ router.push(ROUTES.MAIN.ROOT);
+ };
+
+ return (
+ <>
+
+
+ Lv. {levelParam}
+ 완료!
+ >
+ }
+ subText={{
+ en: `Level ${levelParam} Done!`,
+ jp: `レベル ${levelParam} 完了!`,
+ vt: `Hoàn thành Level ${levelParam}!`,
+ chn: `第 ${levelParam} 关完成!`,
+ }}
+ lang={currentLanguage.code}
+ className="mt-26 mb-8"
+ />
+
+
+ {/* 하단 버튼 */}
+
+
+
+ >
+ );
+}
diff --git a/app/main/my-learning/[level]/intro1/layout.tsx b/app/main/my-learning/[level]/intro1/layout.tsx
new file mode 100644
index 0000000..6ee3a39
--- /dev/null
+++ b/app/main/my-learning/[level]/intro1/layout.tsx
@@ -0,0 +1,15 @@
+import TopAppBar from '@/components/TopAppBar';
+import { ReactNode } from 'react';
+
+export default function LevelIntro1Layout({
+ children,
+}: {
+ children: ReactNode;
+}) {
+ return (
+
+
+ {children}
+
+ );
+}
diff --git a/app/main/my-learning/[level]/intro1/page.tsx b/app/main/my-learning/[level]/intro1/page.tsx
new file mode 100644
index 0000000..a2704c9
--- /dev/null
+++ b/app/main/my-learning/[level]/intro1/page.tsx
@@ -0,0 +1,64 @@
+'use client';
+
+import MotionFadeIn from '@/components/_animations/MotionFadeIn';
+import ProgressBar from '@/components/ProgressBar';
+import TitleText from '@/components/TitleText';
+import { useLanguageStore } from '@/stores/languageStore';
+import CharacterLevelText from '../../_components/CharacterLevelText';
+import text from '../../_locales/text.json';
+import IntroPhrasesList from '../../_components/step1/IntroPhrasesList';
+import Button from '@/components/buttons/_index';
+import { useRouter } from 'next/navigation';
+import { ROUTES } from '@/constants/routes';
+import { useLevelParam } from '@/hooks/useLevelParam';
+
+export default function LevelIntro1Page() {
+ const router = useRouter();
+ const levelParam = useLevelParam();
+ const { currentLanguage } = useLanguageStore();
+ const { title, subText, characterText, characterSubText } = text.intro;
+
+ // TODO: API 연결
+
+ const phrasesArray = Object.values(text.intro).filter(
+ // @ts-ignore
+ value => value?.id !== undefined,
+ );
+
+ const handleBtnCLick = () => {
+ router.push(ROUTES.MAIN.MY_LEARNING.getStep(levelParam, 'step1'));
+ };
+
+ return (
+
+
+ {/* 텍스트 */}
+
+
+
+
+ {/* 캐릭터 텍스트 */}
+
+ {/* 문장 리스트 */}
+ {/* @ts-ignore */}
+
+
+ {/* 하단 버튼 */}
+
+
+
+
+ );
+}
diff --git a/app/main/my-learning/[level]/intro2/layout.tsx b/app/main/my-learning/[level]/intro2/layout.tsx
new file mode 100644
index 0000000..44e3003
--- /dev/null
+++ b/app/main/my-learning/[level]/intro2/layout.tsx
@@ -0,0 +1,15 @@
+import TopAppBar from '@/components/TopAppBar';
+import { ReactNode } from 'react';
+
+export default function LevelIntro1Layout({
+ children,
+}: {
+ children: ReactNode;
+}) {
+ return (
+
+
+ {children}
+
+ );
+}
diff --git a/app/main/my-learning/[level]/intro2/page.tsx b/app/main/my-learning/[level]/intro2/page.tsx
new file mode 100644
index 0000000..90f55b6
--- /dev/null
+++ b/app/main/my-learning/[level]/intro2/page.tsx
@@ -0,0 +1,63 @@
+'use client';
+
+import MotionFadeIn from '@/components/_animations/MotionFadeIn';
+import ProgressBar from '@/components/ProgressBar';
+import TitleText from '@/components/TitleText';
+import { useLanguageStore } from '@/stores/languageStore';
+import CharacterLevelText from '../../_components/CharacterLevelText';
+import text from '../../_locales/text.json';
+import Button from '@/components/buttons/_index';
+import { useRouter } from 'next/navigation';
+import { ROUTES } from '@/constants/routes';
+import { useLevelParam } from '@/hooks/useLevelParam';
+import IntroPhrasesListStep2 from '../../_components/step2/IntroPhrasesListStep2';
+
+export default function LevelIntro2Page() {
+ const router = useRouter();
+ const levelParam = useLevelParam();
+ const { currentLanguage } = useLanguageStore();
+ const { title, subText, characterText, characterSubText } = text.intro2;
+
+ // TODO: API 연결
+ const phrasesArray = Object.values(text.intro2).filter(
+ // @ts-ignore
+ value => value?.id !== undefined,
+ );
+
+ const handleBtnCLick = () => {
+ router.push(ROUTES.MAIN.MY_LEARNING.getStep(levelParam, 'step2'));
+ };
+
+ return (
+
+
+ {/* 텍스트 */}
+
+
+
+
+ {/* 캐릭터 텍스트 */}
+
+ {/* 문장 리스트 */}
+ {/* @ts-ignore */}
+
+
+ {/* 하단 버튼 */}
+
+
+
+
+ );
+}
diff --git a/app/main/my-learning/[level]/step1/layout.tsx b/app/main/my-learning/[level]/step1/layout.tsx
new file mode 100644
index 0000000..f3dc62d
--- /dev/null
+++ b/app/main/my-learning/[level]/step1/layout.tsx
@@ -0,0 +1,15 @@
+import TopAppBar from '@/components/TopAppBar';
+import { ReactNode } from 'react';
+
+export default function LevelStep1Layout({
+ children,
+}: {
+ children: ReactNode;
+}) {
+ return (
+
+
+ {children}
+
+ );
+}
diff --git a/app/main/my-learning/[level]/step1/page.tsx b/app/main/my-learning/[level]/step1/page.tsx
new file mode 100644
index 0000000..c2a6aff
--- /dev/null
+++ b/app/main/my-learning/[level]/step1/page.tsx
@@ -0,0 +1,227 @@
+'use client';
+
+import MotionFadeIn from '@/components/_animations/MotionFadeIn';
+import ProgressBar from '@/components/ProgressBar';
+import { useLanguageStore } from '@/stores/languageStore';
+import text from '../../_locales/text.json';
+import Button from '@/components/buttons/_index';
+import { useRouter } from 'next/navigation';
+import { ROUTES } from '@/constants/routes';
+import { useLevelParam } from '@/hooks/useLevelParam';
+import PhrasePracticeText from '../../_components/step1/PhrasePracticeText';
+import { useState } from 'react';
+import { AnimatePresence, motion } from 'framer-motion';
+import VoiceKeyboard from '@/components/buttons/VoiceKeyboard';
+
+export default function LevelStep1Page() {
+ const router = useRouter();
+ const levelParam = useLevelParam();
+ const { currentLanguage } = useLanguageStore();
+ const { title, subText } = text.step1;
+
+ const [audioBlob, setAudioBlob] = useState(null);
+ const [inputType, setInputType] = useState<'mic' | 'keyboard'>('keyboard');
+
+ //TODO: API 연결
+ const phrases = [
+ {
+ phrase: '너는 취미가 뭐야?',
+ romanization: 'Neo nun / chui mi ga / muh ya',
+ translation: 'What is your hobby?',
+ },
+ {
+ phrase: '오늘 날씨 어때?',
+ romanization: 'Oneul nalssi eottae?',
+ translation: 'How is the weather today?',
+ },
+ {
+ phrase: '무슨 음식을 좋아해?',
+ romanization: 'Museun eumsigeul joahae?',
+ translation: 'What food do you like?',
+ },
+ ];
+
+ // 상태
+ const [currentIndex, setCurrentIndex] = useState(0);
+ const [inputText, setInputText] = useState('');
+ const [showInput, setShowInput] = useState(false);
+ const [showEvaluation, setShowEvaluation] = useState(false);
+ const [resetKey, setResetKey] = useState(0);
+
+ const handleComplete = () => {
+ if (inputText.trim() !== '') {
+ setShowEvaluation(true);
+ setShowInput(false);
+ }
+ };
+
+ const handleRetry = () => {
+ setInputText('');
+ setShowInput(false);
+ setShowEvaluation(false);
+ setResetKey(prev => prev + 1);
+ };
+
+ const handleNext = () => {
+ if (currentIndex < phrases.length - 1) {
+ setCurrentIndex(prev => prev + 1);
+ setInputText('');
+ setShowInput(false);
+ setShowEvaluation(false);
+ setResetKey(prev => prev + 1);
+ } else {
+ // 마지막 문장이면 step2로 이동
+ router.push(ROUTES.MAIN.MY_LEARNING.getStep(levelParam, 'intro2'));
+ }
+ };
+
+ const handleVoiceSubmit = async (blob: Blob) => {
+ setAudioBlob(blob);
+ setShowInput(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();
+
+ console.log('STT Response:', data);
+
+ if (data.transcript) {
+ setInputText(data.transcript);
+ console.log('Recognized Text:', data.transcript);
+ } else {
+ console.error('STT 실패:', data.error);
+ }
+ } catch (err) {
+ console.error('STT 에러:', err);
+ } finally {
+ setShowEvaluation(true);
+ }
+ };
+
+ // Blob → Base64 변환
+ function blobToBase64(blob: Blob): Promise {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onloadend = () => {
+ const result = reader.result as string;
+ // data:audio/mp4;base64,... 부분 제거
+ const base64 = result.split(',')[1];
+ resolve(base64);
+ };
+ reader.onerror = reject;
+ reader.readAsDataURL(blob);
+ });
+ }
+
+ return (
+
+
+
+
+
+ {title}
+
+
+ {subText[currentLanguage.code]}
+
+
+
+
+
+
+
+ {!showInput && !showEvaluation && (
+
+
+ {
+ if (mode === 'keyboard' && typeof data === 'string') {
+ setInputText(data);
+ setInputType('keyboard');
+ setShowInput(true);
+ setShowEvaluation(true);
+ setAudioBlob(null); // 키보드 입력이면 오디오 초기화
+ }
+ if (mode === 'mic' && data instanceof Blob) {
+ console.log('녹음된 오디오:', data);
+ setInputType('mic');
+ handleVoiceSubmit(data);
+ }
+ }}
+ />
+
+
+ )}
+
+
+
+ {showEvaluation && (
+
+
+
+
+ )}
+
+
+ {/* 녹음된 오디오 재생 / 다운로드 */}
+ {/* {audioBlob && (
+
+
+
{
+ const a = document.createElement('a');
+ a.href = URL.createObjectURL(audioBlob);
+ a.download = `recording.mp4`;
+ a.click();
+ }}
+ className="px-4 py-2 bg-blue-500 text-white rounded"
+ >
+ 다운로드
+
+
+ )} */}
+
+ );
+}
diff --git a/app/main/my-learning/[level]/step2/layout.tsx b/app/main/my-learning/[level]/step2/layout.tsx
new file mode 100644
index 0000000..95c56e5
--- /dev/null
+++ b/app/main/my-learning/[level]/step2/layout.tsx
@@ -0,0 +1,15 @@
+import TopAppBar from '@/components/TopAppBar';
+import { ReactNode } from 'react';
+
+export default function LevelStep2Layout({
+ children,
+}: {
+ children: ReactNode;
+}) {
+ return (
+
+
+ {children}
+
+ );
+}
diff --git a/app/main/my-learning/[level]/step2/page.tsx b/app/main/my-learning/[level]/step2/page.tsx
new file mode 100644
index 0000000..789abc1
--- /dev/null
+++ b/app/main/my-learning/[level]/step2/page.tsx
@@ -0,0 +1,232 @@
+'use client';
+
+import ProgressBar from '@/components/ProgressBar';
+import { useEffect, useRef, useState } from 'react';
+import VoiceKeyboard from '@/components/buttons/VoiceKeyboard';
+import ChatLesson from '../../_components/step2/chat/ChatLesson';
+import { Message } from '../../_components/step2/chat/ChatMessageList';
+import { useVoiceSubmit } from '@/hooks/useVoiceSubmit';
+import Button from '@/components/buttons/_index';
+import BottomSheet from '@/app/onboarding/signin/components/BottomSheet';
+import Image from 'next/image';
+import { useRouter } from 'next/navigation';
+import { ROUTES } from '@/constants/routes';
+import { useLevelParam } from '@/hooks/useLevelParam';
+
+type QuestionItem = {
+ question: string;
+ questionTrans: string;
+ hint: string;
+ translation?: string;
+ correction?: string;
+ explanation?: string;
+ answer: string;
+};
+
+const questions: QuestionItem[] = [
+ {
+ question: '안녕 너는 좋아하는 게 뭐야?',
+ questionTrans: 'What do you like?',
+ hint: '취미가 뭐야? 라고 말해 봐!',
+ translation: 'What is your hobby?',
+ correction: '안녕! 너는 취미가 뭐야?',
+ explanation: '문장 시작에 ~ 자연스러운 대화가 됩니다.',
+ answer: '그렇구나! 나도 이거 좋아해!',
+ },
+ {
+ question: '너는 주말에 뭐 해?',
+ questionTrans: 'What do you do on weekends?',
+ hint: '주말에 뭐 해? 라고 말해 봐!',
+ translation: 'What do you do on weekends?',
+ correction: '너는 주말에 주로 뭐 해?',
+ explanation: '조금 더 자연스러운 질문으로 ~',
+ answer: '재밌었겠다!',
+ },
+ {
+ question: '네가 제일 좋아하는 음식은 뭐야?',
+ questionTrans: 'What is your most favorite food?',
+ hint: '좋아하는 음식을 말해 봐!',
+ translation: 'What is your favorite food?',
+ correction: '네가 가장 좋아하는 음식은 뭐야?',
+ explanation: '"가장"이 더 자연스러운 표현입니다.',
+ answer: '맛있겠다!',
+ },
+];
+
+export default function LevelStep2Page() {
+ const router = useRouter();
+ const levelParam = useLevelParam();
+ const { handleVoiceSubmit } = useVoiceSubmit();
+ const [messages, setMessages] = useState([
+ {
+ id: 'm1',
+ role: 'ai',
+ text: questions[0].question,
+ questionTrans: questions[0].questionTrans,
+ round: 1,
+ isQuestion: true,
+ },
+ ]);
+ const [currentRound, setCurrentRound] = useState(1);
+ const [showSummaryModal, setShowSummaryModal] = useState(false);
+
+ const handleUserSubmit = (text: string) => {
+ const newRound = currentRound;
+ const q = questions[newRound - 1];
+
+ setMessages(prev => [
+ ...prev,
+ {
+ id: Date.now().toString() + '_sys',
+ role: 'system',
+ round: newRound,
+ feedback: {
+ userInput: text,
+ translation: q.translation ?? '(번역)',
+ correction: q.correction ?? '(올바른 한국어 예시)',
+ explanation: q.explanation ?? '(왜 맞고 틀린지 설명)',
+ },
+ isQuestion: false,
+ },
+ ]);
+
+ setTimeout(() => {
+ setMessages(prev => [
+ ...prev,
+ {
+ id: Date.now().toString() + '_ai',
+ role: 'ai',
+ text: q.answer ?? '그에 대한 간단한 답변',
+ round: newRound,
+ isQuestion: false,
+ },
+ ]);
+ }, 2000);
+
+ // 마지막 라운드 체크
+ if (newRound === questions.length) {
+ setTimeout(() => {
+ setShowSummaryModal(true);
+ }, 5000); // 답변 보여주고 잠깐 후 모달
+ } else {
+ const nextRound = newRound + 1;
+ setTimeout(() => {
+ setMessages(prev => [
+ ...prev,
+ {
+ id: Date.now().toString() + '_next',
+ role: 'ai',
+ text: questions[nextRound - 1].question,
+ questionTrans: questions[nextRound - 1].questionTrans,
+ round: nextRound,
+ isQuestion: true,
+ },
+ ]);
+ setCurrentRound(nextRound);
+ }, 4000);
+ }
+ };
+
+ // messages가 바뀔 때마다 스크롤
+ const chatContainerRef = useRef(null);
+
+ useEffect(() => {
+ const timeout = setTimeout(() => {
+ const container = chatContainerRef.current;
+ if (container) {
+ container.scrollTo({
+ top:
+ container.scrollTop +
+ (container.scrollHeight - container.clientHeight) * 0.6,
+ behavior: 'smooth',
+ });
+ }
+ }, 500);
+
+ return () => clearTimeout(timeout);
+ }, [messages]);
+
+ return (
+
+
+
+
+
+ q.hint)}
+ />
+
+
+
+
+ {
+ if (mode === 'mic' && data instanceof Blob) {
+ const text = await handleVoiceSubmit(data);
+ if (text) handleUserSubmit(text);
+ }
+ if (mode === 'keyboard' && typeof data === 'string') {
+ handleUserSubmit(data);
+ }
+ }}
+ />
+
+
+
setShowSummaryModal(false)}
+ title={'잘했어요! 오늘 대화를 요약했어요.'}
+ subText={"Good job! I summarized today's conversation."}
+ >
+
+ {questions.map((q, i) => (
+
+
+
+
+
+ {q.question}
+
+ {q.translation}
+
+
+
+ ))}
+
+
+
+ {
+ router.push(ROUTES.MAIN.MY_LEARNING.getStep(levelParam, 'step3'));
+ }}
+ className="flex-2"
+ />
+
+
+
+ );
+}
diff --git a/app/main/my-learning/[level]/step3/layout.tsx b/app/main/my-learning/[level]/step3/layout.tsx
new file mode 100644
index 0000000..d9988ec
--- /dev/null
+++ b/app/main/my-learning/[level]/step3/layout.tsx
@@ -0,0 +1,15 @@
+import TopAppBar from '@/components/TopAppBar';
+import { ReactNode } from 'react';
+
+export default function LevelStep3Layout({
+ children,
+}: {
+ children: ReactNode;
+}) {
+ return (
+
+
+ {children}
+
+ );
+}
diff --git a/app/main/my-learning/[level]/step3/page.tsx b/app/main/my-learning/[level]/step3/page.tsx
new file mode 100644
index 0000000..7f25c63
--- /dev/null
+++ b/app/main/my-learning/[level]/step3/page.tsx
@@ -0,0 +1,140 @@
+'use client';
+
+import MotionFadeIn from '@/components/_animations/MotionFadeIn';
+import ProgressBar from '@/components/ProgressBar';
+import TitleText from '@/components/TitleText';
+import { useLanguageStore } from '@/stores/languageStore';
+import text from '../../_locales/text.json';
+import IntroPhrasesList from '../../_components/step1/IntroPhrasesList';
+import Button from '@/components/buttons/_index';
+import { useRouter } from 'next/navigation';
+import { ROUTES } from '@/constants/routes';
+import { useLevelParam } from '@/hooks/useLevelParam';
+import Image from 'next/image';
+import BottomSheet from '../../_components/step3/BottomSheet';
+import SelectButton from '@/components/buttons/select';
+import { useState } from 'react';
+
+export default function LevelStep3Page() {
+ const router = useRouter();
+ const levelParam = useLevelParam();
+ const { currentLanguage } = useLanguageStore();
+ const { title, subText } = text.step3;
+
+ const [isBottomSheetOpen, setIsBottomSheetOpen] = useState(false);
+ const [selectedDifficulty, setSelectedDifficulty] = useState(
+ null,
+ );
+
+ // TODO: API 연결
+ const phrasesArray = Object.values(text.intro).filter(
+ // @ts-ignore
+ value => value?.id !== undefined,
+ );
+
+ const handleBtnCLick = () => {
+ setIsBottomSheetOpen(true); // BottomSheet 열기
+ };
+
+ // 난이도 옵션
+ const difficulties = [
+ {
+ text: '쉬웠음',
+ subText: 'easy',
+ },
+ {
+ text: '보통',
+ subText: 'normal',
+ },
+ {
+ text: '어려웠음',
+ subText: 'hard',
+ },
+ ];
+
+ return (
+
+
+ {/* 텍스트 */}
+
+
+
+
+ {/* 문장 리스트 */}
+ 오늘의 표현
+
+ Today's expression
+
+ {/* @ts-ignore */}
+
+
+ {/* 단어 리스트 */}
+ 오늘의 단어
+
+ Today's word
+
+
+
+ {[1, 2, 3].map((item, idx) => (
+
+
+
단어{item}
+
+
+
+ 단어 뜻 {item}
+
+
+ ))}
+
+
+ {/* 하단 버튼 */}
+
+
+
+
setIsBottomSheetOpen(false)}
+ title={'오늘 공부한 내용 난이도가 어땠나요?'}
+ subText={'What was the difficulty level of what you studied today?'}
+ >
+
+ {difficulties.map((difficulty, idx) => (
+ setSelectedDifficulty(idx)}
+ className="border-primary border"
+ />
+ ))}
+
+ {
+ router.push(ROUTES.MAIN.MY_LEARNING.getStep(levelParam, 'ending'));
+ }}
+ className="mt-8"
+ disabled={selectedDifficulty === null}
+ />
+
+
+ );
+}
diff --git a/app/main/my-learning/_components/CharacterFrontText.tsx b/app/main/my-learning/_components/CharacterFrontText.tsx
new file mode 100644
index 0000000..de071c1
--- /dev/null
+++ b/app/main/my-learning/_components/CharacterFrontText.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 CharacterFrontText({
+ title,
+ subtitle,
+ audio = false,
+ image = '/character/default.webp', // 기본 이미지 URL
+}: CharacterTextProps) {
+ const { currentLanguage } = useLanguageStore();
+
+ return (
+
+ {/* 캐릭터 이미지 */}
+ {image && (
+
+
+
+ )}
+ {/* 텍스트 영역 */}
+
+
+ {title}
+ {audio && (
+
+ )}
+
+ {/* 구분선 */}
+
+
+ {subtitle}
+
+
+
+ );
+}
diff --git a/app/main/my-learning/_components/CharacterLevelText.tsx b/app/main/my-learning/_components/CharacterLevelText.tsx
new file mode 100644
index 0000000..5033f2f
--- /dev/null
+++ b/app/main/my-learning/_components/CharacterLevelText.tsx
@@ -0,0 +1,56 @@
+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;
+ className?: string;
+}
+
+export default function CharacterLevelText({
+ title,
+ subtitle,
+ audio = false,
+ image = '/character/default.webp',
+ className,
+}: CharacterTextProps) {
+ const { currentLanguage } = useLanguageStore();
+
+ return (
+
+ {/* 캐릭터 이미지 */}
+ {image && (
+
+
+
+ )}
+ {/* 텍스트 영역 */}
+
+
+ {title}
+ {audio && (
+
+ )}
+
+ {/* 구분선 */}
+
+
+ {subtitle}
+
+
+
+ );
+}
diff --git a/app/main/my-learning/_components/roadmap/LevelButton.tsx b/app/main/my-learning/_components/roadmap/LevelButton.tsx
new file mode 100644
index 0000000..e81d2a8
--- /dev/null
+++ b/app/main/my-learning/_components/roadmap/LevelButton.tsx
@@ -0,0 +1,122 @@
+import { MouseEventHandler, TouchEventHandler, useState } from 'react';
+import LevelModal from './LevelModal';
+import Toast from '@/components/Toast';
+
+interface ButtonProps {
+ text: string;
+ subText?: string;
+ status: 'current' | 'complete' | 'locked';
+ levelNum: number;
+ className?: string;
+}
+
+export default function LevelButton({
+ text,
+ subText,
+ levelNum,
+ status = 'locked',
+ className,
+}: ButtonProps) {
+ const [isPressed, setIsPressed] = useState(false);
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const [isToastOpen, setIsToastOpen] = useState(false);
+
+ const handleTouchStart: TouchEventHandler = () =>
+ setIsPressed(true);
+ const handleTouchEnd: TouchEventHandler = () =>
+ setIsPressed(false);
+
+ const handleClick: MouseEventHandler = e => {
+ setIsPressed(true);
+ if (status === 'locked') {
+ setIsToastOpen(true);
+ setIsPressed(false);
+ return;
+ }
+ // 1초 뒤에 모달 열기
+ setTimeout(() => {
+ setIsModalOpen(true);
+ setIsPressed(false);
+ }, 300);
+ };
+
+ const statusStyles = {
+ current: {
+ bg: 'bg-primary',
+ shadowBg: 'bg-primary-dimensional',
+ levelBg: 'bg-white',
+ textColor: 'text-black',
+ subTextColor: 'text-gray-300',
+ levelTextColor: 'text-secondary-300',
+ },
+ complete: {
+ bg: 'bg-white',
+ shadowBg: 'bg-primary',
+ levelBg: 'bg-primary-800',
+ textColor: 'text-black',
+ subTextColor: 'text-gray-500',
+ levelTextColor: 'text-secondary-300',
+ },
+ locked: {
+ bg: 'bg-bg-solid',
+ shadowBg: 'bg-bg-solid',
+ levelBg: 'bg-white',
+ textColor: 'text-secondary-300',
+ subTextColor: 'text-secondary-400',
+ levelTextColor: 'text-secondary-300',
+ },
+ };
+
+ const { bg, shadowBg, levelBg, textColor, subTextColor, levelTextColor } =
+ statusStyles[status];
+
+ return (
+ <>
+
+ {/* 그림자 */}
+
+
+ {/* 실제 버튼 */}
+
setIsPressed(true)}
+ onTouchEnd={() => setIsPressed(false)}
+ className={`relative z-10 w-full flex flex-col items-center px-3 py-4 rounded-full text-center transition-transform duration-150
+ ${isPressed ? 'translate-y-0' : '-translate-y-2'}
+ ${bg} ${className ?? ''}`}
+ >
+
+ Lv. {levelNum}
+
+ {text}
+ {subText && (
+
+ {subText}
+
+ )}
+
+
+
+ {/* 모달 */}
+ {isModalOpen && (
+ setIsModalOpen(false)}
+ />
+ )}
+ {/* 토스트 */}
+ setIsToastOpen(false)}
+ />
+ >
+ );
+}
diff --git a/app/main/my-learning/_components/roadmap/LevelModal.tsx b/app/main/my-learning/_components/roadmap/LevelModal.tsx
new file mode 100644
index 0000000..954fbea
--- /dev/null
+++ b/app/main/my-learning/_components/roadmap/LevelModal.tsx
@@ -0,0 +1,92 @@
+'use client';
+
+import Button from '@/components/buttons/_index';
+import { motion, AnimatePresence } from 'framer-motion';
+import LevelModalList from './LevelModalList';
+import { useRouter } from 'next/navigation';
+import { ROUTES } from '@/constants/routes';
+
+interface LevelModalProps {
+ levelNum: number;
+ title: string;
+ onClose: () => void;
+ showButton?: boolean;
+}
+
+export default function LevelModal({
+ levelNum,
+ title,
+ onClose,
+ showButton = false,
+}: LevelModalProps) {
+ const router = useRouter();
+
+ // TODO: API 연동
+ const levelGoals = [
+ {
+ goal: '반갑게 인사하기',
+ sub: "Sharing each other's hobbies.",
+ },
+ {
+ goal: '좋아하는 음식 말하기',
+ sub: 'Talk about your favorite foods.',
+ },
+ {
+ goal: '하루 일과 소개하기',
+ sub: 'Introduce your daily routine.',
+ },
+ ];
+
+ const handleStart = () => {
+ router.push(ROUTES.MAIN.MY_LEARNING.getStep(levelNum, 'intro1'));
+ };
+
+ return (
+
+
+ e.stopPropagation()} // 내부 클릭 시 닫히지 않게
+ >
+ {/* 상단 제목 */}
+
+
+ Lv. {levelNum}
+
+
{title}
+
+ {/* 리스트 */}
+
+ {levelGoals.map((item, idx) => (
+
+ ))}
+
+ {/* 버튼 */}
+ {showButton && (
+
+ )}
+
+
+
+ );
+}
diff --git a/app/main/my-learning/_components/roadmap/LevelModalList.tsx b/app/main/my-learning/_components/roadmap/LevelModalList.tsx
new file mode 100644
index 0000000..3701dd8
--- /dev/null
+++ b/app/main/my-learning/_components/roadmap/LevelModalList.tsx
@@ -0,0 +1,53 @@
+import Image from 'next/image';
+import { MouseEventHandler } from 'react';
+
+interface ButtonProps {
+ levelGoalText: string;
+ levelGoalSubText?: string;
+ className?: string;
+ onClick?: MouseEventHandler;
+ completeGoal?: boolean;
+}
+
+export default function LevelModalList({
+ levelGoalText,
+ levelGoalSubText,
+ className,
+ onClick,
+ completeGoal = false,
+}: ButtonProps) {
+ return (
+
+
+ {/* 체크 아이콘 */}
+
+ {/* 텍스트 영역 */}
+
+ {levelGoalText}
+ {levelGoalSubText && (
+
+ {levelGoalSubText}
+
+ )}
+
+
+
+ );
+}
diff --git a/app/main/my-learning/_components/step1/IntroPhrasesList.tsx b/app/main/my-learning/_components/step1/IntroPhrasesList.tsx
new file mode 100644
index 0000000..bc12bc2
--- /dev/null
+++ b/app/main/my-learning/_components/step1/IntroPhrasesList.tsx
@@ -0,0 +1,78 @@
+import { useState } from 'react';
+import Image from 'next/image';
+import { useLanguageStore } from '@/stores/languageStore';
+import { FONT_CLASS } from '@/constants/languages';
+
+interface Phrase {
+ id: number;
+ kor: string;
+ [key: string]: any;
+}
+
+interface IntroPhrasesListProps {
+ phrases: Phrase[];
+ initialBookmarks?: Record;
+}
+
+export default function IntroPhrasesList({
+ phrases,
+ initialBookmarks = {},
+}: IntroPhrasesListProps) {
+ const { currentLanguage } = useLanguageStore();
+
+ // 북마크 상태를 phrase.id 기준으로 관리
+ const [bookmarks, setBookmarks] = useState(() => {
+ const initState: Record = {};
+ phrases.forEach(phrase => {
+ initState[phrase.id] = initialBookmarks[phrase.id] || false;
+ });
+ return initState;
+ });
+
+ const toggleBookmark = (id: number) => {
+ setBookmarks(prev => ({
+ ...prev,
+ [id]: !prev[id],
+ }));
+ };
+
+ return (
+
+
+ {phrases.map(phrase => (
+
+
+
{phrase.kor}
+ toggleBookmark(phrase.id)}
+ className="p-1"
+ aria-label="bookmark toggle"
+ >
+
+
+
+
+ {phrase?.[currentLanguage.code] || (
+ 번역 없음
+ )}
+
+
+ ))}
+
+
+ );
+}
diff --git a/app/main/my-learning/_components/step1/PhrasePracticeText.tsx b/app/main/my-learning/_components/step1/PhrasePracticeText.tsx
new file mode 100644
index 0000000..209a392
--- /dev/null
+++ b/app/main/my-learning/_components/step1/PhrasePracticeText.tsx
@@ -0,0 +1,159 @@
+import Image from 'next/image';
+import { useLanguageStore } from '@/stores/languageStore';
+import { useState } from 'react';
+import {
+ RATING_THRESHOLDS,
+ SPELLING_RATING_THRESHOLDS,
+} from '@/constants/rating';
+import { similarity } from '@/utils/similarity';
+
+interface WordPercentage {
+ text: string;
+ percentage: number;
+}
+
+interface PhrasePracticeTextProps {
+ phrase: string;
+ romanization: string;
+ translation: string;
+ className?: string;
+ inputText: string;
+ showEvaluation: boolean;
+ evaluationType?: 'mic' | 'keyboard';
+}
+
+export default function PhrasePracticeText({
+ phrase,
+ romanization,
+ translation,
+ className,
+ inputText,
+ showEvaluation,
+ evaluationType,
+}: PhrasePracticeTextProps) {
+ const { currentLanguage } = useLanguageStore();
+ const [isBookmarked, setIsBookmarked] = useState(false);
+
+ const typedWords = inputText.trim() === '' ? [] : inputText.trim().split(' ');
+ const words = phrase.split(' ');
+
+ const wordPercentages: WordPercentage[] = words.map((w, idx) => ({
+ text: w,
+ percentage: typedWords[idx] ? similarity(w, typedWords[idx]) : 0,
+ }));
+
+ const avg =
+ wordPercentages.reduce((sum, w) => sum + w.percentage, 0) /
+ wordPercentages.length;
+
+ const thresholds =
+ evaluationType === 'mic' ? RATING_THRESHOLDS : SPELLING_RATING_THRESHOLDS;
+
+ const matched = thresholds.find(t => avg >= t.min)!;
+
+ const evaluationResult = {
+ rating: typedWords.length > 0 ? matched.rating : 0,
+ feedback: typedWords.length > 0 ? matched.feedback : '',
+ feedbackEn: typedWords.length > 0 ? matched.feedbackEn : '',
+ };
+
+ const getHighlightColor = (percentage: number) => {
+ if (percentage >= 80) return 'bg-primary-dimensional';
+ if (percentage >= 20) return 'bg-primary';
+ return 'bg-primary-900';
+ };
+
+ const renderHighlightedPhrase = () =>
+ words.map((word, idx) => {
+ const percentage = wordPercentages[idx].percentage;
+ const highlightClass =
+ typedWords.length > 0 && showEvaluation
+ ? getHighlightColor(percentage)
+ : 'bg-transparent';
+
+ return (
+
+
{word}
+
+ {typedWords.length > 0 && showEvaluation && (
+
+ {percentage}%
+
+ )}
+
+ );
+ });
+
+ return (
+
+
+
+ {romanization}
+
+
+
+ {renderHighlightedPhrase()}
+
+ 0 && showEvaluation ? 'pb-5.5' : 'pb-1.5'
+ }
+ />
+ setIsBookmarked(prev => !prev)}
+ className="focus:outline-none"
+ >
+ 0 && showEvaluation ? 'pb-5.5' : 'pb-1.5'
+ }
+ />
+
+
+ {typedWords.length > 0 && showEvaluation && (
+
+
+ {evaluationResult.feedback}
+
+
+ {evaluationResult.feedbackEn}
+
+
+ {Array.from({ length: 5 }, (_, i) => (
+
+ ))}
+
+
+ )}
+
+
+
+ {translation}
+
+
+ );
+}
diff --git a/app/main/my-learning/_components/step2/IntroPhrasesListStep2.tsx b/app/main/my-learning/_components/step2/IntroPhrasesListStep2.tsx
new file mode 100644
index 0000000..edd3bfa
--- /dev/null
+++ b/app/main/my-learning/_components/step2/IntroPhrasesListStep2.tsx
@@ -0,0 +1,55 @@
+import { useState } from 'react';
+import Image from 'next/image';
+import { useLanguageStore } from '@/stores/languageStore';
+import { FONT_CLASS } from '@/constants/languages';
+
+interface Phrase {
+ id: number;
+ kor: string;
+ [key: string]: any;
+}
+
+interface IntroPhrasesListProps {
+ phrases: Phrase[];
+}
+
+export default function IntroPhrasesListStep2({
+ phrases,
+}: IntroPhrasesListProps) {
+ const { currentLanguage } = useLanguageStore();
+
+ return (
+
+
+ {phrases.map(phrase => (
+
+
+
+
+
+
{phrase.kor}
+
+ {phrase?.[currentLanguage.code] || (
+ 번역 없음
+ )}
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/app/main/my-learning/_components/step2/chat/ChatBubble.tsx b/app/main/my-learning/_components/step2/chat/ChatBubble.tsx
new file mode 100644
index 0000000..b1e3dea
--- /dev/null
+++ b/app/main/my-learning/_components/step2/chat/ChatBubble.tsx
@@ -0,0 +1,66 @@
+'use client';
+
+import Image from 'next/image';
+import { useState } from 'react';
+import { Role } from './ChatMessageList';
+
+export default function ChatBubble({
+ role,
+ text,
+ translation,
+ showAudio,
+ showTranslate,
+}: {
+ role: Role;
+ text: string;
+ translation?: string;
+ showAudio?: boolean;
+ showTranslate?: boolean;
+}) {
+ const [showTranslation, setShowTranslation] = useState(false);
+
+ return (
+
+
+
+
+ {/* 원문 대신 번역 표시 */}
+
{showTranslation && translation ? translation : text}
+
+ {(showAudio || showTranslate) && (
+
+ {showAudio && (
+
+ )}
+ {showTranslate && translation && (
+ setShowTranslation(prev => !prev)}>
+
+
+ )}
+
+ )}
+
+
+ );
+}
diff --git a/app/main/my-learning/_components/step2/chat/ChatLesson.tsx b/app/main/my-learning/_components/step2/chat/ChatLesson.tsx
new file mode 100644
index 0000000..d91a7a7
--- /dev/null
+++ b/app/main/my-learning/_components/step2/chat/ChatLesson.tsx
@@ -0,0 +1,62 @@
+import Image from 'next/image';
+import { Message } from './ChatMessageList';
+import ChatMessageList from './ChatMessageList';
+
+type ChatLessonProps = {
+ totalRounds: number;
+ currentRound: number;
+ messages: Message[];
+ questionHints: string[];
+};
+
+export default function ChatLesson({
+ totalRounds,
+ currentRound,
+ messages,
+ questionHints,
+}: ChatLessonProps) {
+ return (
+
+ {Array.from({ length: currentRound }).map((_, roundIndex) => {
+ const roundMessages = messages.filter(m => m.round === roundIndex + 1);
+
+ return (
+
+ {/* 라운드 구분선 */}
+
+
+
+ 대화 횟수: {roundIndex + 1}/{totalRounds}
+
+
+
+
+ {roundMessages.map(msg => (
+
+
+
+ {/* AI 질문 메시지 뒤에만 힌트 표시 */}
+ {msg.role === 'ai' &&
+ msg.isQuestion &&
+ questionHints[roundIndex] && (
+
+
+
+ {questionHints[roundIndex]}
+
+
+ )}
+
+ ))}
+
+ );
+ })}
+
+ );
+}
diff --git a/app/main/my-learning/_components/step2/chat/ChatMessageList.tsx b/app/main/my-learning/_components/step2/chat/ChatMessageList.tsx
new file mode 100644
index 0000000..94d0602
--- /dev/null
+++ b/app/main/my-learning/_components/step2/chat/ChatMessageList.tsx
@@ -0,0 +1,71 @@
+'use client';
+
+import { Fragment } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import ChatBubble from './ChatBubble';
+import UserFeedbackCard from './UserFeedbackCard';
+
+export type Role = 'ai' | 'user' | 'system';
+
+export type Message = {
+ isQuestion: boolean;
+ id: string;
+ role: Role;
+ text?: string; // 기존 일반 텍스트
+ questionTrans?: string;
+ feedback?: {
+ userInput: string;
+ translation: string;
+ correction: string;
+ explanation: string;
+ };
+ round?: number;
+ showAudio?: boolean;
+ showTranslate?: boolean;
+};
+
+export type OptionItem = { label: string; muted?: string };
+
+export default function ChatMessageList({
+ messages,
+ options,
+ onSelectOption,
+ onRetry,
+}: {
+ messages: Message[];
+ options?: OptionItem[];
+ onSelectOption?: (i: number) => void;
+ onRetry?: () => void;
+}) {
+ return (
+
+ {messages.map(m => (
+
+
+ {m.feedback ? (
+
+ ) : (
+
+ )}
+
+
+ ))}
+
+ );
+}
diff --git a/app/main/my-learning/_components/step2/chat/UserFeedbackCard.tsx b/app/main/my-learning/_components/step2/chat/UserFeedbackCard.tsx
new file mode 100644
index 0000000..11a2cb5
--- /dev/null
+++ b/app/main/my-learning/_components/step2/chat/UserFeedbackCard.tsx
@@ -0,0 +1,48 @@
+import Image from 'next/image';
+
+interface FeedbackCardProps {
+ userInput: string;
+ translation: string;
+ correction: string;
+ explanation: string;
+}
+
+export default function FeedbackCard({
+ userInput,
+ translation,
+ correction,
+ explanation,
+}: FeedbackCardProps) {
+ return (
+
+
+
+ {/* user text*/}
+
+ {userInput}
+
+
+ {/* 번역 */}
+
+ {translation}
+
+
+ {/* 올바른 한국어 */}
+
+ {correction}
+
+
+ {/* 설명 */}
+
+ {explanation}
+
+
+
+ );
+}
diff --git a/app/main/my-learning/_components/step3/BottomSheet.tsx b/app/main/my-learning/_components/step3/BottomSheet.tsx
new file mode 100644
index 0000000..172e0bf
--- /dev/null
+++ b/app/main/my-learning/_components/step3/BottomSheet.tsx
@@ -0,0 +1,112 @@
+'use client';
+
+import { ReactNode, useRef, useEffect, useState } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { bottomSheetVariants } from '@/components/_animations/bottomSheetMotion';
+
+interface BottomSheetProps {
+ isOpen: boolean;
+ onClose: () => void;
+ title: string;
+ subText: string;
+ children?: ReactNode;
+}
+
+export default function BottomSheet({
+ isOpen,
+ onClose,
+ title,
+ subText,
+ children,
+}: BottomSheetProps) {
+ const sheetRef = useRef(null);
+ const [sheetHeight, setSheetHeight] = useState('auto');
+
+ // 내용 높이 자동 감지
+ useEffect(() => {
+ if (sheetRef.current) {
+ const contentHeight = sheetRef.current.scrollHeight;
+ const maxHeight = window.innerHeight * 0.9; // 화면 최대 90%
+ setSheetHeight(contentHeight > maxHeight ? maxHeight : contentHeight);
+ }
+ }, [children, isOpen]);
+
+ // body 스크롤 막기
+ useEffect(() => {
+ if (isOpen) {
+ document.body.style.overflow = 'hidden';
+ } else {
+ document.body.style.overflow = 'unset';
+ }
+ return () => {
+ document.body.style.overflow = 'unset';
+ };
+ }, [isOpen]);
+
+ // 스크롤 조금만 내려도 닫기
+ useEffect(() => {
+ const el = sheetRef.current;
+ if (!el) return;
+
+ const handleScroll = () => {
+ if (el.scrollTop > 10) {
+ onClose();
+ }
+ };
+
+ el.addEventListener('scroll', handleScroll);
+ return () => {
+ el.removeEventListener('scroll', handleScroll);
+ };
+ }, [onClose, isOpen]);
+
+ return (
+
+ {isOpen && (
+
+ {/* 배경 */}
+
+ e.stopPropagation()}
+ style={{ whiteSpace: 'pre-line' }}
+ transition={{ type: 'spring', stiffness: 100 }}
+ // drag="y"
+ // dragConstraints={{ top: 0, bottom: 0 }} // 위로는 못 끌고, 아래로만
+ // dragElastic={0.1} // 살짝 탄성
+ // onDragEnd={(event, info) => {
+ // if (info.offset.y > 50) {
+ // // 50px 이상 아래로 끌면 닫힘
+ // onClose();
+ // }
+ // }}
+ >
+ {/* 회색 그랩바 */}
+
+
+ {title}
+
+ {subText}
+
+ {children}
+
+
+ )}
+
+ );
+}
diff --git a/app/main/my-learning/_locales/text.json b/app/main/my-learning/_locales/text.json
new file mode 100644
index 0000000..20ab491
--- /dev/null
+++ b/app/main/my-learning/_locales/text.json
@@ -0,0 +1,124 @@
+{
+ "intro": {
+ "title": "오늘 공부할 문장들이에요.",
+ "subText": {
+ "vt": "Chọn một ngôn ngữ!\nChúng tôi sẽ sử dụng nó để dịch.",
+ "en": "These are the phrases that you're going to study today.",
+ "jp": "言語を選択してください!\n翻訳に使用します。",
+ "chn": "选择一种语言!\n我们将用它进行翻译。"
+ },
+ "characterText": "주제는 취미와 좋아하는 것들이야.",
+ "characterSubText": {
+ "vt": "Không có ngôn ngữ mà tôi muốn.",
+ "en": "The topics are your hobbies and favorite things.",
+ "jp": "私の欲しい言語がありません。",
+ "chn": "没有我想要的语言。"
+ },
+ "phrase1": {
+ "id": 1,
+ "kor": "너는 취미가 뭐야?",
+ "vt": "Không có ngôn ngữ mà tôi muốn.",
+ "en": "What is your hobby?",
+ "jp": "私の欲しい言語がありません。",
+ "chn": "没有我想要的语言。"
+ },
+ "phrase2": {
+ "id": 2,
+ "kor": "너는 이름이 뭐야?",
+ "vt": "Không có ngôn ngữ mà tôi muốn.",
+ "en": "What is your name?",
+ "jp": "あなたの名前は何ですか?",
+ "chn": "你叫什么名字?"
+ },
+ "phrase3": {
+ "id": 3,
+ "kor": "가장 좋아하는게 뭐야?",
+ "vt": "Không có ngôn ngữ mà tôi muốn.",
+ "en": "What is your favorite thing?",
+ "jp": "あなたの名前は何ですか?",
+ "chn": "你叫什么名字?"
+ }
+ },
+"step1": {
+ "title": "아래 문장을 말해 봐!\n말할 수 없다면 글로 써 보자.",
+ "subText": {
+ "vt": "Chọn một ngôn ngữ!\nChúng tôi sẽ sử dụng nó để dịch.",
+ "en": "Say the sentence below!\nIf you can't say it, let's write it.",
+ "jp": "言語を選択してください!\n翻訳に使用します。",
+ "chn": "选择一种语言!\n我们将用它进行翻译。"
+ }
+ },
+ "intro2": {
+ "title": "코디와 대화하며 문장을 연습해요.",
+ "subText": {
+ "vt": "Chọn một ngôn ngữ!\nChúng tôi sẽ sử dụng nó để dịch.",
+ "en": "Practice your sentences by talking with Kody.",
+ "jp": "言語を選択してください!\n翻訳に使用します。",
+ "chn": "选择一种语言!\n我们将用它进行翻译。"
+ },
+ "characterText": "아래는 오늘의 대화 흐름이야.",
+ "characterSubText": {
+ "vt": "Không có ngôn ngữ mà tôi muốn.",
+ "en": "Below is the flow of today's conversation.",
+ "jp": "私の欲しい言語がありません。",
+ "chn": "没有我想要的语言。"
+ },
+ "phrase1": {
+ "id": 1,
+ "kor": "반갑게 인사하기",
+ "vt": "Không có ngôn ngữ mà tôi muốn.",
+ "en": "Greeting each other warmly",
+ "jp": "私の欲しい言語がありません。",
+ "chn": "没有我想要的语言。"
+ },
+ "phrase2": {
+ "id": 2,
+ "kor": "서로의 취미 공유하기",
+ "vt": "Không có ngôn ngữ mà tôi muốn.",
+ "en": "Sharing each other’s hobbies",
+ "jp": "あなたの名前は何ですか?",
+ "chn": "你叫什么名字?"
+ },
+ "phrase3": {
+ "id": 3,
+ "kor": "좋아하는 것 말하기",
+ "vt": "Không có ngôn ngữ mà tôi muốn.",
+ "en": "Talking about what you like",
+ "jp": "あなたの名前は何ですか?",
+ "chn": "你叫什么名字?"
+ }
+ },
+ "step3": {
+ "title": "오늘도 고생했어요!\n배운 내용을 확인해 보세요.",
+ "subText": {
+ "vt": "Chọn một ngôn ngữ!\nChúng tôi sẽ sử dụng nó để dịch.",
+ "en": "You did a great job today, too!\nCheck out what you've learned.",
+ "jp": "言語を選択してください!\n翻訳に使用します。",
+ "chn": "选择一种语言!\n我们将用它进行翻译。"
+ },
+ "phrase1": {
+ "id": 1,
+ "kor": "너는 취미가 뭐야?",
+ "vt": "Không có ngôn ngữ mà tôi muốn.",
+ "en": "What is your hobby?",
+ "jp": "私の欲しい言語がありません。",
+ "chn": "没有我想要的语言。"
+ },
+ "phrase2": {
+ "id": 2,
+ "kor": "너는 이름이 뭐야?",
+ "vt": "Không có ngôn ngữ mà tôi muốn.",
+ "en": "What is your name?",
+ "jp": "あなたの名前は何ですか?",
+ "chn": "你叫什么名字?"
+ },
+ "phrase3": {
+ "id": 3,
+ "kor": "가장 좋아하는게 뭐야?",
+ "vt": "Không có ngôn ngữ mà tôi muốn.",
+ "en": "What is your favorite thing?",
+ "jp": "あなたの名前は何ですか?",
+ "chn": "你叫什么名字?"
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/main/my-learning/page.tsx b/app/main/my-learning/page.tsx
new file mode 100644
index 0000000..bb5b7d6
--- /dev/null
+++ b/app/main/my-learning/page.tsx
@@ -0,0 +1,158 @@
+'use client';
+
+import { useEffect, useRef } from 'react';
+import MotionFadeIn from '@/components/_animations/MotionFadeIn';
+import CharacterFrontText from './_components/CharacterFrontText';
+import LevelButton from './_components/roadmap/LevelButton';
+import { useUserStore } from '@/stores/userStore';
+
+export default function MyLearningPage() {
+ const { username } = useUserStore();
+ const scrollRef = useRef(null);
+
+ const levelData = [
+ { text: '학교에 가자!', subText: "Let's go to School!" },
+ { text: '숙제를 하자!', subText: "Let's do Homework!" },
+ { text: '책 읽기', subText: 'Reading Books' },
+ { text: '운동하기', subText: 'Exercise Time' },
+ { text: '음악 듣기', subText: 'Listening Music' },
+ { text: '영화 보기', subText: 'Watch a Movie' },
+ { text: '친구 만나기', subText: 'Meeting Friends' },
+ { text: '게임 하기', subText: 'Play Games' },
+ ];
+
+ const currentLevel = 1; // 현재 레벨 예시
+
+ // 부드러운 스크롤 함수
+ const smoothScrollTo = (
+ container: HTMLElement,
+ target: number,
+ duration: number,
+ ) => {
+ const start = container.scrollTop;
+ const change = target - start;
+ let currentTime = 0;
+ const increment = 16; // 약 60fps
+
+ const animateScroll = () => {
+ currentTime += increment;
+ const val = easeInOutQuad(currentTime, start, change, duration);
+ container.scrollTop = val;
+
+ if (currentTime < duration) {
+ requestAnimationFrame(animateScroll);
+ }
+ };
+ animateScroll();
+ };
+
+ // easing 함수
+ const easeInOutQuad = (t: number, b: number, c: number, d: number) => {
+ t /= d / 2;
+ if (t < 1) return (c / 2) * t * t + b;
+ t--;
+ return (-c / 2) * (t * (t - 2) - 1) + b;
+ };
+
+ //TODO: 사용자 진도로 자동 스크롤, 커스텀 훅 혹은 유틸로 리팩토링
+ useEffect(() => {
+ const smoothScrollTo = (
+ container: HTMLElement,
+ target: number,
+ duration: number,
+ ) => {
+ const easeInOutQuad = (t: number, b: number, c: number, d: number) => {
+ t /= d / 2;
+ if (t < 1) return (c / 2) * t * t + b;
+ t--;
+ return (-c / 2) * (t * (t - 2) - 1) + b;
+ };
+
+ const start = container.scrollTop;
+ const change = target - start;
+ let currentTime = 0;
+ const increment = 16;
+
+ const animateScroll = () => {
+ currentTime += increment;
+ const val = easeInOutQuad(currentTime, start, change, duration);
+ container.scrollTop = val;
+
+ if (currentTime < duration) {
+ requestAnimationFrame(animateScroll);
+ }
+ };
+ animateScroll();
+ };
+
+ const timer = setTimeout(() => {
+ if (scrollRef.current) {
+ const container = scrollRef.current;
+ const levelElement = container.querySelector(
+ `[data-level='${currentLevel}']`,
+ ) as HTMLElement | null;
+
+ if (levelElement) {
+ const targetTop =
+ levelElement.offsetTop -
+ container.offsetTop -
+ container.clientHeight / 2 +
+ levelElement.clientHeight / 2;
+ const maxScroll = container.scrollHeight - container.clientHeight;
+ const scrollPosition = Math.min(Math.max(targetTop, 0), maxScroll);
+ smoothScrollTo(container, scrollPosition, 1000);
+ }
+ }
+ }, 400);
+
+ return () => clearTimeout(timer);
+ }, [currentLevel]);
+
+ return (
+
+
+
+
+
+
+
+ {levelData.map((level, idx) => {
+ const levelNum = idx + 1;
+ const status =
+ levelNum === currentLevel
+ ? 'current'
+ : levelNum < currentLevel
+ ? 'complete'
+ : 'locked';
+ return (
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/app/main/my-page/layout.tsx b/app/main/my-page/layout.tsx
new file mode 100644
index 0000000..69fb738
--- /dev/null
+++ b/app/main/my-page/layout.tsx
@@ -0,0 +1,7 @@
+import { ReactNode } from 'react';
+
+export default function MyPageLayout({ children }: { children: ReactNode }) {
+ return (
+ {children}
+ );
+}
diff --git a/app/main/my-page/page.tsx b/app/main/my-page/page.tsx
new file mode 100644
index 0000000..4de3feb
--- /dev/null
+++ b/app/main/my-page/page.tsx
@@ -0,0 +1,103 @@
+'use client';
+
+import Button from '@/components/buttons/_index';
+import { useUserStore } from '@/stores/userStore';
+import Image from 'next/image';
+
+export default function MyPage() {
+ const { username } = useUserStore();
+ // TODO: 구조 리팩토링
+ return (
+
+ {/* 상단 헤더 */}
+
+
{username}
+
+
+ {/* 통계 영역 */}
+
+
+
+ 총 공부 시간
+
+
+ 02:26
+
+
+
+
+ 총 대화 시간
+
+
+ 01:03
+
+
+
+ {/* 저장한 표현 버튼 */}
+
+
+
+ {/* 메뉴 리스트 */}
+
+
+
+
코디 목소리 변경
+
+ Change Kody's voice
+
+
+
+
+
+
+
+
+
+
+
+
+
회원 탈퇴
+
+ Delete Account
+
+
+
+
+
+ );
+}
diff --git a/app/main/page.tsx b/app/main/page.tsx
new file mode 100644
index 0000000..133d37f
--- /dev/null
+++ b/app/main/page.tsx
@@ -0,0 +1,6 @@
+import { ROUTES } from '@/constants/routes';
+import { redirect } from 'next/navigation';
+
+export default function MainPage() {
+ redirect(ROUTES.MAIN.MY_LEARNING.ROOT);
+}
diff --git a/app/onboarding/layout.tsx b/app/onboarding/layout.tsx
new file mode 100644
index 0000000..ab9229b
--- /dev/null
+++ b/app/onboarding/layout.tsx
@@ -0,0 +1,11 @@
+import { ReactNode } from 'react';
+
+export default function OnboardingLayout({
+ children,
+}: {
+ children: ReactNode;
+}) {
+ return (
+ {children}
+ );
+}
diff --git a/app/onboarding/login/_components/GoogleLoginBtn.tsx b/app/onboarding/login/_components/GoogleLoginBtn.tsx
new file mode 100644
index 0000000..1a3ebb5
--- /dev/null
+++ b/app/onboarding/login/_components/GoogleLoginBtn.tsx
@@ -0,0 +1,42 @@
+'use client';
+
+import Image from 'next/image';
+import { useState } from 'react';
+
+interface GoogleSignInButtonProps {
+ onClick?: () => void;
+}
+
+export default function GoogleSignInButton({
+ onClick,
+}: GoogleSignInButtonProps) {
+ const [isPressed, setIsPressed] = useState(false);
+
+ return (
+ setIsPressed(true)}
+ onTouchEnd={() => setIsPressed(false)}
+ // Desktop
+ onMouseDown={() => setIsPressed(true)}
+ onMouseUp={() => setIsPressed(false)}
+ onMouseLeave={() => setIsPressed(false)}
+ className={`
+ flex items-center justify-center gap-3 w-screen max-w-92 py-3 rounded-2xl
+ bg-white text-black text-bd2-bold
+ transition-all duration-150
+ ${isPressed ? 'translate-y-1' : 'shadow-gray-900 shadow-lg'}
+ hover:shadow-lg
+ `}
+ >
+
+ Sign in with Google
+
+ );
+}
diff --git a/app/onboarding/login/_utils/googleLogin.ts b/app/onboarding/login/_utils/googleLogin.ts
new file mode 100644
index 0000000..a55f1c6
--- /dev/null
+++ b/app/onboarding/login/_utils/googleLogin.ts
@@ -0,0 +1,6 @@
+export async function googleLogin() {
+ // TODO: 실제 Google OAuth 로직 넣기
+ return new Promise<{ success: boolean }>(resolve => {
+ setTimeout(() => resolve({ success: true }), 1000);
+ });
+}
diff --git a/app/onboarding/login/page.tsx b/app/onboarding/login/page.tsx
new file mode 100644
index 0000000..527441a
--- /dev/null
+++ b/app/onboarding/login/page.tsx
@@ -0,0 +1,62 @@
+'use client';
+
+import { motion } from 'framer-motion';
+import { useRouter } from 'next/navigation';
+import Logo from '@/components/Logo';
+import GoogleSignInButton from './_components/GoogleLoginBtn';
+import { googleLogin } from './_utils/googleLogin';
+import { ROUTES } from '@/constants/routes';
+
+export default function LoginPage() {
+ const router = useRouter();
+
+ const handleGoogleLogin = async () => {
+ try {
+ const result = await googleLogin();
+ if (result.success) {
+ router.push(ROUTES.ONBOARDING.SIGNIN.getStep(1));
+ } else {
+ alert('로그인 실패');
+ }
+ } catch (error) {
+ console.error(error);
+ alert('로그인 중 오류가 발생했습니다.');
+ }
+ };
+
+ return (
+
+ {/* Logo */}
+
+
+
+
+ {/* Text */}
+
+ 안녕하세요, 반가워요!
+
+ Hello, nice to meet you!
+
+
+
+ {/* Button */}
+
+
+
+
+ );
+}
diff --git a/app/onboarding/page.tsx b/app/onboarding/page.tsx
new file mode 100644
index 0000000..3596a8c
--- /dev/null
+++ b/app/onboarding/page.tsx
@@ -0,0 +1,8 @@
+export default function OnboardingPage() {
+ return (
+
+
온보딩 화면
+
첫 방문이면 - 온보딩, 로그인 이력 - main
+
+ );
+}
diff --git a/app/onboarding/signin/components/BottomSheet.tsx b/app/onboarding/signin/components/BottomSheet.tsx
new file mode 100644
index 0000000..e131c3c
--- /dev/null
+++ b/app/onboarding/signin/components/BottomSheet.tsx
@@ -0,0 +1,112 @@
+'use client';
+
+import { ReactNode, useRef, useEffect, useState } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { bottomSheetVariants } from '@/components/_animations/bottomSheetMotion';
+
+interface BottomSheetProps {
+ isOpen: boolean;
+ onClose: () => void;
+ title: string;
+ subText: string;
+ children?: ReactNode;
+}
+
+export default function BottomSheet({
+ isOpen,
+ onClose,
+ title,
+ subText,
+ children,
+}: BottomSheetProps) {
+ const sheetRef = useRef(null);
+ const [sheetHeight, setSheetHeight] = useState('auto');
+
+ // 내용 높이 자동 감지
+ useEffect(() => {
+ if (sheetRef.current) {
+ const contentHeight = sheetRef.current.scrollHeight;
+ const maxHeight = window.innerHeight * 0.9; // 화면 최대 90%
+ setSheetHeight(contentHeight > maxHeight ? maxHeight : contentHeight);
+ }
+ }, [children, isOpen]);
+
+ // body 스크롤 막기
+ useEffect(() => {
+ if (isOpen) {
+ document.body.style.overflow = 'hidden';
+ } else {
+ document.body.style.overflow = 'unset';
+ }
+ return () => {
+ document.body.style.overflow = 'unset';
+ };
+ }, [isOpen]);
+
+ // 스크롤 조금만 내려도 닫기
+ useEffect(() => {
+ const el = sheetRef.current;
+ if (!el) return;
+
+ const handleScroll = () => {
+ if (el.scrollTop > 10) {
+ onClose();
+ }
+ };
+
+ el.addEventListener('scroll', handleScroll);
+ return () => {
+ el.removeEventListener('scroll', handleScroll);
+ };
+ }, [onClose, isOpen]);
+
+ return (
+
+ {isOpen && (
+
+ {/* 배경 */}
+
+ e.stopPropagation()}
+ style={{ whiteSpace: 'pre-line' }}
+ transition={{ type: 'spring', stiffness: 100 }}
+ // drag="y"
+ // dragConstraints={{ top: 0, bottom: 0 }} // 위로는 못 끌고, 아래로만
+ // dragElastic={0.1} // 살짝 탄성
+ // onDragEnd={(event, info) => {
+ // if (info.offset.y > 50) {
+ // // 50px 이상 아래로 끌면 닫힘
+ // onClose();
+ // }
+ // }}
+ >
+ {/* 회색 그랩바 */}
+
+
+ {title}
+
+ {subText}
+
+ {children}
+
+
+ )}
+
+ );
+}
diff --git a/app/onboarding/signin/layout.tsx b/app/onboarding/signin/layout.tsx
new file mode 100644
index 0000000..fd94bce
--- /dev/null
+++ b/app/onboarding/signin/layout.tsx
@@ -0,0 +1,11 @@
+import TopAppBar from '@/components/TopAppBar';
+import { ReactNode } from 'react';
+
+export default function SigninLayout({ children }: { children: ReactNode }) {
+ return (
+
+
+ {children}
+
+ );
+}
diff --git a/app/onboarding/signin/locales/subtitles.json b/app/onboarding/signin/locales/subtitles.json
new file mode 100644
index 0000000..ec6bfa4
--- /dev/null
+++ b/app/onboarding/signin/locales/subtitles.json
@@ -0,0 +1,92 @@
+{
+ "step1": {
+ "title": "사용할 언어를 골라주세요!\n선택한 언어는 번역에 사용돼요.",
+ "subText": {
+ "vt": "Chọn một ngôn ngữ!\nChúng tôi sẽ sử dụng nó để dịch.",
+ "en": "Pick a language!\nWe'll use it for translations.",
+ "jp": "言語を選択してください!\n翻訳に使用します。",
+ "chn": "选择一种语言!\n我们将用它进行翻译。"
+ },
+ "checkboxText": "내가 원하는 언어가 없어요.",
+ "checkboxSubText": {
+ "vt": "Không có ngôn ngữ mà tôi muốn.",
+ "en": "There's no language I want.",
+ "jp": "私の欲しい言語がありません。",
+ "chn": "没有我想要的语言。"
+ },
+ "bottomSheetTitle": "사용할 수 있는 언어가 무엇인가요?\n나중에 그 언어로 공부할 수 있도록 준비할게요",
+ "bottomSheetSubText": {
+ "vt": "Bạn có thể sử dụng những ngôn ngữ nào?\nChúng tôi sẽ chuẩn bị ngôn ngữ bạn chọn khi nó khả dụng.",
+ "en": "Which language can you use?\nWe'll get your chosen language ready when it's available.",
+ "jp": "あなたはどの言語を使えますか?\n選んだ言語は利用可能になったら準備しておきます。",
+ "chn": "你可以使用哪些语言?\n我们会在所选语言可用时为你准备好。"
+ }
+ },
+ "step2": {
+ "title": "사용할 이름이나 닉네임을\n적어 주세요.",
+ "subText": {
+ "vt": "Tên này sẽ được sử dụng trong toàn bộ ứng dụng.",
+ "en": "This name will be used across the app.",
+ "jp": "この名前はアプリ全体で使用されます。",
+ "chn": "此名称将在整个应用程序中使用。"
+ },
+ "usernameTitle": "내 이름/닉네임은...",
+ "usernameSubText": {
+ "vt": "Tên/Nickname của tôi là...",
+ "en": "My name/nickname is...",
+ "jp": "私の名前/ニックネームは...",
+ "chn": "我的名字/昵称是..."
+ }
+ },
+ "step3": {
+ "title": "다음으로,\n나이를 알려주세요.",
+ "subText": {
+ "vt": "Tuổi này sẽ được sử dụng trong toàn bộ ứng dụng.",
+ "en": "Next,\nplease tell us your age.",
+ "jp": "次に、年齢を教えてください。",
+ "chn": "接下来,请告诉我们您的年龄。"
+ },
+ "ageTitle": "나의 나이는...",
+ "ageSubText": {
+ "vt": "Tuổi của tôi là...",
+ "en": "My age is...",
+ "jp": "私の年齢は...",
+ "chn": "我的年龄是..."
+ },
+ "ageEndText" : {
+ "vt": "Tôi tuổi.",
+ "en": "years old.",
+ "jp": "歳です。",
+ "chn": "岁。"
+ }
+ },
+ "step4": {
+ "title": "마지막으로,\n다니는 학교와 학년 알려주세요.",
+ "subText": {
+ "vt": "Tuổi này sẽ được sử dụng trong toàn bộ ứng dụng.",
+ "en": "Finally,\nplease tell us your school and grade.",
+ "jp": "次に、年齢を教えてください。",
+ "chn": "接下来,请告诉我们您的年龄。"
+ },
+ "schoolTitle": "내가 다니는 학교는...",
+ "schoolSubText": {
+ "vt": "Tuổi của tôi là...",
+ "en": "My school is...",
+ "jp": "私の年齢は...",
+ "chn": "我的年龄是..."
+ },
+ "gradeTitle": "나의 학년은...",
+ "gradeSubText": {
+ "vt": "Tuổi của tôi là...",
+ "en": "My grade is...",
+ "jp": "私の年齢は...",
+ "chn": "我的年龄是..."
+ },
+ "gradeEndText" : {
+ "vt": "Tôi tuổi.",
+ "en": "grade.",
+ "jp": "歳です。",
+ "chn": "岁。"
+ }
+ }
+}
diff --git a/app/onboarding/signin/step1/page.tsx b/app/onboarding/signin/step1/page.tsx
new file mode 100644
index 0000000..c51e090
--- /dev/null
+++ b/app/onboarding/signin/step1/page.tsx
@@ -0,0 +1,110 @@
+'use client';
+
+import { useState } from 'react';
+import { useLanguageStore } from '@/stores/languageStore';
+import LanguageSelectDropdown from '@/components/dropdowns/LanguageSelect';
+import subtitles from '../locales/subtitles.json';
+import ProgressBar from '@/components/ProgressBar';
+import TitleText from '@/components/TitleText';
+import MotionFadeIn from '@/components/_animations/MotionFadeIn';
+import Button from '@/components/buttons/_index';
+import Image from 'next/image';
+import { useRouter } from 'next/navigation';
+import BottomSheet from '../components/BottomSheet';
+import { ROUTES } from '@/constants/routes';
+import LanguageListDropdown from '@/components/dropdowns/LanguageList';
+
+export default function SignInStep1Page() {
+ const router = useRouter();
+ const { currentLanguage, setLanguage } = useLanguageStore();
+ const {
+ title,
+ subText,
+ checkboxText,
+ checkboxSubText,
+ bottomSheetTitle,
+ bottomSheetSubText,
+ } = subtitles.step1;
+
+ const [isChecked, setIsChecked] = useState(false);
+ const toggleCheckbox = () => setIsChecked(prev => !prev);
+
+ const [isSheetOpen, setIsSheetOpen] = useState(false);
+
+ const handleBtnClick = () => {
+ if (isChecked) {
+ setIsSheetOpen(true);
+ } else {
+ router.push(ROUTES.ONBOARDING.SIGNIN.getStep(2)); // 다음 페이지 라우팅
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+ {/* 체크박스 */}
+
+
+
+
+ {checkboxText}
+
+
+ {checkboxSubText[currentLanguage.code]}
+
+
+
+ {/* 하단 버튼 */}
+
+
+ {/* 바텀시트 */}
+
setIsSheetOpen(false)}
+ title={bottomSheetTitle}
+ subText={bottomSheetSubText[currentLanguage.code]}
+ >
+
+
+ 그 전까지는 한글을 영어로 번역할게요.
+
+
+ Until then, I'll translate Korean into English.
+
+ {
+ router.push(ROUTES.ONBOARDING.SIGNIN.getStep(2));
+ }}
+ />
+
+
+ );
+}
diff --git a/app/onboarding/signin/step2/page.tsx b/app/onboarding/signin/step2/page.tsx
new file mode 100644
index 0000000..3fac996
--- /dev/null
+++ b/app/onboarding/signin/step2/page.tsx
@@ -0,0 +1,63 @@
+'use client';
+
+import { useLanguageStore } from '@/stores/languageStore';
+import subtitles from '../locales/subtitles.json';
+import ProgressBar from '@/components/ProgressBar';
+import TitleText from '@/components/TitleText';
+import MotionFadeIn from '@/components/_animations/MotionFadeIn';
+import Button from '@/components/buttons/_index';
+import { useRouter } from 'next/navigation';
+import { FONT_CLASS } from '@/constants/languages';
+import TextFieldLong from '@/components/textfields/TextFieldLong';
+import { useUserStore } from '@/stores/userStore';
+import { ROUTES } from '@/constants/routes';
+
+export default function SignInStep2Page() {
+ const router = useRouter();
+ const { currentLanguage } = useLanguageStore();
+ const { title, subText, usernameTitle, usernameSubText } = subtitles.step2;
+ const { username, setUsername } = useUserStore();
+
+ const handleBtnClick = () => {
+ if (username.length !== 0) {
+ router.push(ROUTES.ONBOARDING.SIGNIN.getStep(3));
+ }
+ };
+
+ return (
+
+
+
+
+ {usernameTitle}
+
+ {usernameSubText[currentLanguage.code]}
+
+ {/* 이름 입력 */}
+
+
+
+ {/* 하단 버튼 */}
+
+
+
+ );
+}
diff --git a/app/onboarding/signin/step3/page.tsx b/app/onboarding/signin/step3/page.tsx
new file mode 100644
index 0000000..f09907a
--- /dev/null
+++ b/app/onboarding/signin/step3/page.tsx
@@ -0,0 +1,71 @@
+'use client';
+
+import { useLanguageStore } from '@/stores/languageStore';
+import subtitles from '../locales/subtitles.json';
+import ProgressBar from '@/components/ProgressBar';
+import TitleText from '@/components/TitleText';
+import MotionFadeIn from '@/components/_animations/MotionFadeIn';
+import Button from '@/components/buttons/_index';
+import { useRouter } from 'next/navigation';
+import { FONT_CLASS } from '@/constants/languages';
+import { useUserStore } from '@/stores/userStore';
+import { ROUTES } from '@/constants/routes';
+import TextFieldShort from '@/components/textfields/TextFieldShort';
+
+export default function SignInStep3Page() {
+ const router = useRouter();
+ const { currentLanguage } = useLanguageStore();
+ const { title, subText, ageTitle, ageSubText, ageEndText } = subtitles.step3;
+ const { age, setAge } = useUserStore();
+
+ const handleBtnClick = () => {
+ if (age.length !== 0) {
+ router.push(ROUTES.ONBOARDING.SIGNIN.getStep(4));
+ }
+ };
+
+ return (
+
+
+
+
+ {ageTitle}
+
+ {ageSubText[currentLanguage.code]}
+
+ {/* 나이 입력 */}
+
+
+
+
살
+
+ {ageEndText[currentLanguage.code]}
+
+
+
+
+
+ {/* 하단 버튼 */}
+
+
+
+ );
+}
diff --git a/app/onboarding/signin/step4/page.tsx b/app/onboarding/signin/step4/page.tsx
new file mode 100644
index 0000000..ae2dc42
--- /dev/null
+++ b/app/onboarding/signin/step4/page.tsx
@@ -0,0 +1,115 @@
+'use client';
+
+import { useLanguageStore } from '@/stores/languageStore';
+import subtitles from '../locales/subtitles.json';
+import ProgressBar from '@/components/ProgressBar';
+import TitleText from '@/components/TitleText';
+import MotionFadeIn from '@/components/_animations/MotionFadeIn';
+import Button from '@/components/buttons/_index';
+import { useRouter } from 'next/navigation';
+import { FONT_CLASS } from '@/constants/languages';
+import { useUserStore } from '@/stores/userStore';
+import { ROUTES } from '@/constants/routes';
+import TextFieldShort from '@/components/textfields/TextFieldShort';
+import SchoolSelectDropdown from '@/components/dropdowns/SchoolSelect';
+import { getOrdinalSuffix } from '@/utils/getOrdinalSuffix';
+import { SchoolOption } from '@/constants/dropdown/schools';
+
+export default function SignInStep3Page() {
+ const router = useRouter();
+ const { currentLanguage } = useLanguageStore();
+ const {
+ title,
+ subText,
+ schoolTitle,
+ schoolSubText,
+ gradeTitle,
+ gradeSubText,
+ gradeEndText,
+ } = subtitles.step4;
+ const { school, setSchool, grade, setGrade } = useUserStore();
+
+ const handleBtnClick = () => {
+ if (!grade || !school) return;
+ router.push(ROUTES.ONBOARDING.VOICE.ROOT);
+ // TODO: API 연동
+ // mutate(
+ // { school, grade },
+ // {
+ // onSuccess: response => {
+ // // 서버 응답 성공 시 라우트 이동
+ // router.push(ROUTES.ONBOARDING.VOICE.ROOT);
+ // },
+ // onError: error => {
+ // console.error(error);
+ // },
+ // },
+ // );
+ };
+
+ const setSchoolFromDropdown = (schoolOption: SchoolOption) => {
+ if (schoolOption.value) setSchool(schoolOption.value);
+ };
+
+ return (
+
+
+
+
+ {/* 학교 입력 */}
+ {schoolTitle}
+
+ {schoolSubText[currentLanguage.code]}
+
+
+ {/* 학년 입력 */}
+ {school && (
+
+
+ {gradeTitle}
+
+
+ {gradeSubText[currentLanguage.code]}
+
+
+
+
+
학년
+
+ {gradeEndText[currentLanguage.code]}
+
+
+
+
+ )}
+
+
+ {/* 하단 버튼 */}
+
+
+
+ );
+}
diff --git a/app/onboarding/voice/_components/CharacterVoiceSelectList.tsx b/app/onboarding/voice/_components/CharacterVoiceSelectList.tsx
new file mode 100644
index 0000000..f913608
--- /dev/null
+++ b/app/onboarding/voice/_components/CharacterVoiceSelectList.tsx
@@ -0,0 +1,52 @@
+'use client';
+
+import { voiceData } from '@/constants/voiceData';
+import SelectButton from '@/components/buttons/select';
+import CharacterVoiceButton from '@/components/buttons/characterVoice';
+import { useLanguageStore } from '@/stores/languageStore';
+import { useVoiceStore } from '@/stores/voiceStore';
+import { useState } from 'react';
+
+export default function CharacterVoiceSelectList() {
+ const { currentLanguage } = useLanguageStore();
+ const { selectedVoice, setSelectedVoice } = useVoiceStore();
+ const [playingIndex, setPlayingIndex] = useState(null);
+
+ const voices = Object.values(voiceData.selectVoice.characterVoice);
+
+ const handlePlayVoice = (idx: number) => {
+ if (playingIndex !== null) return; // 이미 재생 중이면 무시
+ setPlayingIndex(idx); // 재생 중 표시
+
+ const audio = new Audio(`/ttsTest.m4a`);
+ audio.play();
+
+ audio.addEventListener('ended', () => {
+ setPlayingIndex(null); // 재생 종료 후 표시 해제
+ });
+ };
+
+ return (
+
+ {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 (
+ <>
+
+
+ 코디 와 함께
+
+ 한국어 공부를 시작해 봐요!
+ >
+ }
+ subText={subText}
+ lang={currentLanguage.code}
+ className="mt-26 mb-8"
+ />
+
+
+ {/* 하단 버튼 */}
+
+
+
+ >
+ );
+}
diff --git a/app/onboarding/voice/page.tsx b/app/onboarding/voice/page.tsx
new file mode 100644
index 0000000..e11e547
--- /dev/null
+++ b/app/onboarding/voice/page.tsx
@@ -0,0 +1,57 @@
+'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 VoicePage() {
+ const router = useRouter();
+ const { subText, characterText, characterSubText } = text.voice;
+ const { username } = useUserStore(); // TODO: 사용자 이름 가져오기 (일단 로컬 스토리지에서 가져옴)
+ const { currentLanguage } = useLanguageStore();
+
+ const handleBtnClick = () => {
+ router.push(ROUTES.ONBOARDING.VOICE.SELECT);
+ };
+
+ return (
+ <>
+
+
+ {username}!
+
+ 앞으로 같이
+
+ 한국어를 공부할
+ ‘코디’
+ 예요.
+ >
+ }
+ subText={subText}
+ lang={currentLanguage.code}
+ className="mt-26 mb-8"
+ />
+
+
+ {/* 하단 버튼 */}
+
+
+
+ >
+ );
+}
diff --git a/app/onboarding/voice/select/layout.tsx b/app/onboarding/voice/select/layout.tsx
new file mode 100644
index 0000000..73478b9
--- /dev/null
+++ b/app/onboarding/voice/select/layout.tsx
@@ -0,0 +1,17 @@
+import ProgressBar from '@/components/ProgressBar';
+import TopAppBar from '@/components/TopAppBar';
+import { ReactNode } from 'react';
+
+export default function VoiceSelectLayout({
+ children,
+}: {
+ children: ReactNode;
+}) {
+ return (
+
+ );
+}
diff --git a/app/onboarding/voice/select/page.tsx b/app/onboarding/voice/select/page.tsx
new file mode 100644
index 0000000..165d456
--- /dev/null
+++ b/app/onboarding/voice/select/page.tsx
@@ -0,0 +1,73 @@
+'use client';
+
+import TitleText from '@/components/TitleText';
+import text from '../_locales/text.json';
+import { useLanguageStore } from '@/stores/languageStore';
+import { useRouter } from 'next/navigation';
+import { ROUTES } from '@/constants/routes';
+import Button from '@/components/buttons/_index';
+import MotionFadeIn from '@/components/_animations/MotionFadeIn';
+import Image from 'next/image';
+import CharacterVoiceSelectList from '../_components/CharacterVoiceSelectList';
+import { useVoiceStore } from '@/stores/voiceStore';
+
+export default function VoiceSelectPage() {
+ const router = useRouter();
+ const { subText } = text.selectVoice;
+ const { selectedVoice } = useVoiceStore();
+ const { currentLanguage } = useLanguageStore();
+
+ const handleBtnClick = () => {
+ router.push(ROUTES.ONBOARDING.VOICE.COMPLETE);
+ };
+
+ return (
+ <>
+
+
+ 코디
+ 의 목소리를 선택해 주세요.
+
+ 목소리는 언제든 변경할 수 있어요.
+ >
+ }
+ subText={subText}
+ lang={currentLanguage.code}
+ className="mt-10 mb-5"
+ />
+ {/* 캐릭터 프로필 */}
+
+ {/* 음성 선택 */}
+
+
+
+
+ {/* 하단 버튼 */}
+
+
+
+ >
+ );
+}
diff --git a/app/page.tsx b/app/page.tsx
index 2fc0c21..186a050 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,103 +1,34 @@
+'use client';
+
+import { useEffect } from 'react';
+import { useRouter } from 'next/navigation';
import Image from 'next/image';
+import { motion } from 'framer-motion';
+import Logo from '@/components/Logo';
+import { ROUTES } from '@/constants/routes';
-export default function Home() {
- 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 5110274..4906a97 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,24 +8,29 @@
"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",
- "@tailwindcss/postcss": "^4",
+ "@tailwindcss/postcss": "^4.1.11",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.3.5",
- "tailwindcss": "^4",
+ "tailwindcss": "^4.1.11",
"typescript": "^5"
}
},
@@ -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 8ea7064..e1181ee 100644
--- a/package.json
+++ b/package.json
@@ -10,24 +10,29 @@
"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",
- "@tailwindcss/postcss": "^4",
+ "@tailwindcss/postcss": "^4.1.11",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.3.5",
- "tailwindcss": "^4",
+ "tailwindcss": "^4.1.11",
"typescript": "^5"
}
}
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,
+ };
+ });
+}