kaigiii commited on
Commit
5c920e9
·
1 Parent(s): 71cbdd1

Deploy Learn8 Demo Space

Browse files
Files changed (46) hide show
  1. .dockerignore +6 -0
  2. .github/workflows/nextjs.yml +87 -0
  3. .gitignore +4 -0
  4. Dockerfile +17 -0
  5. README.md +27 -5
  6. UIUX_REDESIGN.md +424 -0
  7. app/(arena)/play/[nodeId]/ArenaClient.tsx +1001 -0
  8. app/(arena)/play/[nodeId]/page.tsx +14 -0
  9. app/(arena)/play/[nodeId]/result/ResultClient.tsx +514 -0
  10. app/(arena)/play/[nodeId]/result/page.tsx +14 -0
  11. app/(dashboard)/home/page.tsx +620 -0
  12. app/(dashboard)/layout.tsx +13 -0
  13. app/(dashboard)/map/[courseId]/MapClient.tsx +1031 -0
  14. app/(dashboard)/map/[courseId]/page.tsx +20 -0
  15. app/(dashboard)/store/page.tsx +530 -0
  16. app/(onboarding)/login/page.tsx +539 -0
  17. app/(onboarding)/welcome/page.tsx +637 -0
  18. app/forge/page.tsx +355 -0
  19. app/globals.css +125 -0
  20. app/icon.svg +30 -0
  21. app/layout.tsx +21 -0
  22. app/page.tsx +14 -0
  23. components/arena/LogicChain.tsx +352 -0
  24. components/arena/SpatialAnatomy.tsx +327 -0
  25. components/arena/TaxonomyMatrix.tsx +463 -0
  26. components/shared/ProfileModal.tsx +214 -0
  27. components/shared/TopStatsBar.tsx +145 -0
  28. components/ui/DeepGlassCard.tsx +22 -0
  29. components/ui/GameButton.tsx +36 -0
  30. components/ui/MascotHint.tsx +43 -0
  31. components/ui/TopProgressBar.tsx +22 -0
  32. data/mock_calculus.ts +383 -0
  33. data/mock_medicine.ts +416 -0
  34. global.d.ts +1 -0
  35. next-env.d.ts +5 -0
  36. next.config.mjs +11 -0
  37. package-lock.json +1711 -0
  38. package.json +28 -0
  39. postcss.config.mjs +9 -0
  40. public/.nojekyll +0 -0
  41. public/favicon.svg +30 -0
  42. stores/useArenaStore.ts +171 -0
  43. stores/useCourseStore.ts +259 -0
  44. stores/useUserStore.ts +184 -0
  45. tailwind.config.ts +39 -0
  46. tsconfig.json +22 -0
.dockerignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ .git
2
+ .github
3
+ .next
4
+ node_modules
5
+ npm-debug.log
6
+ out
.github/workflows/nextjs.yml ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Sample workflow for building and deploying a Next.js site to GitHub Pages
2
+ #
3
+ # To get started with Next.js see: https://nextjs.org/docs/getting-started
4
+ #
5
+ name: Deploy Next.js site to Pages
6
+
7
+ on:
8
+ # Runs on pushes targeting the default branch
9
+ push:
10
+ branches: ["main"]
11
+
12
+ # Allows you to run this workflow manually from the Actions tab
13
+ workflow_dispatch:
14
+
15
+ # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
16
+ permissions:
17
+ contents: read
18
+ pages: write
19
+ id-token: write
20
+
21
+ # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
22
+ # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
23
+ concurrency:
24
+ group: "deploy-pages"
25
+ cancel-in-progress: true
26
+
27
+ jobs:
28
+ # Build job
29
+ build:
30
+ runs-on: ubuntu-latest
31
+ steps:
32
+ - name: Checkout
33
+ uses: actions/checkout@v4
34
+ - name: Detect package manager
35
+ id: detect-package-manager
36
+ run: |
37
+ if [ -f "${{ github.workspace }}/yarn.lock" ]; then
38
+ echo "manager=yarn" >> $GITHUB_OUTPUT
39
+ echo "command=install" >> $GITHUB_OUTPUT
40
+ echo "runner=yarn" >> $GITHUB_OUTPUT
41
+ exit 0
42
+ elif [ -f "${{ github.workspace }}/package.json" ]; then
43
+ echo "manager=npm" >> $GITHUB_OUTPUT
44
+ echo "command=ci" >> $GITHUB_OUTPUT
45
+ echo "runner=npx --no-install" >> $GITHUB_OUTPUT
46
+ exit 0
47
+ else
48
+ echo "Unable to determine package manager"
49
+ exit 1
50
+ fi
51
+ - name: Setup Node
52
+ uses: actions/setup-node@v4
53
+ with:
54
+ node-version: "20"
55
+ cache: ${{ steps.detect-package-manager.outputs.manager }}
56
+ - name: Setup Pages
57
+ uses: actions/configure-pages@v5
58
+ - name: Restore cache
59
+ uses: actions/cache@v4
60
+ with:
61
+ path: |
62
+ .next/cache
63
+ # Generate a new cache whenever packages or source files change.
64
+ key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }}
65
+ # If source files changed but packages didn't, rebuild from a prior cache.
66
+ restore-keys: |
67
+ ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}-
68
+ - name: Install dependencies
69
+ run: ${{ steps.detect-package-manager.outputs.manager }} ${{ steps.detect-package-manager.outputs.command }}
70
+ - name: Build with Next.js
71
+ run: ${{ steps.detect-package-manager.outputs.runner }} next build
72
+ - name: Upload artifact
73
+ uses: actions/upload-pages-artifact@v3
74
+ with:
75
+ path: ./out
76
+
77
+ # Deployment job
78
+ deploy:
79
+ environment:
80
+ name: github-pages
81
+ url: ${{ steps.deployment.outputs.page_url }}
82
+ runs-on: ubuntu-latest
83
+ needs: build
84
+ steps:
85
+ - name: Deploy to GitHub Pages
86
+ id: deployment
87
+ uses: actions/deploy-pages@v4
.gitignore ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ node_modules
2
+ .next
3
+ out
4
+
Dockerfile ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:20-alpine
2
+
3
+ WORKDIR /app
4
+
5
+ COPY package.json package-lock.json ./
6
+ RUN npm ci
7
+
8
+ COPY . .
9
+
10
+ ENV HUGGINGFACE_SPACE=1
11
+ RUN npm run build
12
+
13
+ RUN npm install -g serve
14
+
15
+ EXPOSE 7860
16
+
17
+ CMD ["serve", "-s", "out", "-l", "7860"]
README.md CHANGED
@@ -1,10 +1,32 @@
1
  ---
2
- title: README
3
- emoji: 🏢
4
- colorFrom: pink
5
- colorTo: green
6
  sdk: docker
 
7
  pinned: false
8
  ---
9
 
10
- Edit this `README.md` markdown file to author your organization card.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Learn8 Demo
3
+ emoji: "📘"
4
+ colorFrom: blue
5
+ colorTo: indigo
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
  ---
10
 
11
+ # Learn8 Demo
12
+
13
+ Next.js UI prototype prepared for deployment on Hugging Face Spaces.
14
+
15
+ ## Local development
16
+
17
+ ```bash
18
+ npm install
19
+ npm run dev
20
+ ```
21
+
22
+ Local routes with the default `/UI` base path:
23
+
24
+ - `http://localhost:3000/UI/login`
25
+ - `http://localhost:3000/UI/welcome`
26
+ - `http://localhost:3000/UI/home`
27
+ - `http://localhost:3000/UI/store`
28
+ - `http://localhost:3000/UI/forge`
29
+
30
+ ## Hugging Face Spaces
31
+
32
+ This repo includes a Docker configuration for Spaces. In the container build, the app disables the local `/UI` base path and serves the static export at the Space root on port `7860`.
UIUX_REDESIGN.md ADDED
@@ -0,0 +1,424 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Learn8: Gamified Learning Platform - Frontend Blueprint
2
+ > **[Agent Directive]**
3
+ > You are an expert Frontend AI Architect. Your task is to execute a complete Consumer-Facing UI/UX build for a brand new application named "Learn8" based *strictly* on this manual.
4
+ >
5
+ > You do NOT need any prior context about any existing backend. You are building a **Pure Frontend Prototype** that uses local state (Zustand + LocalStorage) and embedded Mock Data to simulate a flawless, gamified learning experience (similar to Duolingo or Brilliant).
6
+ >
7
+ > Create the directory structure implicitly based on modern Next.js 14+ conventions.
8
+
9
+ ---
10
+
11
+ ## 🌟 1. Project Introduction: What is Learn8?
12
+
13
+ **Learn8** is a next-generation adaptive learning platform that transforms static, boring knowledge (like PDFs, textbooks, or notes) into an immersive, gamified "Infinite Game."
14
+
15
+ Unlike traditional LMS (Learning Management Systems) or static flashcard apps, Learn8 dynamically breaks down knowledge into a visual "Skill Tree" (Syllabus Map). Each concept or exercise is delivered through highly interactive, bite-sized mini-games:
16
+ - Dragging and dropping anatomy labels (`SpatialAnatomy`)
17
+ - Arranging logical sequences of events (`LogicChain`)
18
+ - Categorizing abstract concepts into buckets (`TaxonomyMatrix`)
19
+
20
+ **The Goal**: Make learning feel less like reading a textbook and more like exploring a universe and leveling up a character.
21
+
22
+ ---
23
+
24
+ ## 🖼️ 2. Screen-by-Screen Layout Specifications (The Blueprint)
25
+
26
+ This section defines the precise visual layout and components present on every major screen to ensure a seamless flow.
27
+
28
+ ### Screen A: Welcome & Authentication (`/app/(onboarding)/login/page.tsx`)
29
+ **Vibe**: Inviting, frictionless, premium.
30
+ - **Background**: Slow-moving ambient glow (e.g., deep blue with a soft purple aura).
31
+ - **Layout**: Centered `DeepGlassCard`.
32
+ - **Content**:
33
+ - *Header*: The Learn8 Logo (glowing) and a short tagline ("Unlock the Infinite Game").
34
+ - *Main*: Large, prominent "Continue with Google/Apple" buttons (using sleek icons).
35
+ - *Divider*: "Or continue with email" (subtle text).
36
+ - *Inputs*: Elegant email/password fields with floating labels. Border glows on focus.
37
+ - *Action*: "Start exploring" button.
38
+ - **Transitions**: Upon success, fluidly scales up the card and fades into the Onboarding Survey.
39
+
40
+ ### Screen B: Onboarding Survey (`/app/(onboarding)/welcome/page.tsx`)
41
+ **Vibe**: Immersive, mysterious, welcoming. Like character creation in an RPG.
42
+ - **Background**: Continues seamlessly from the login screen.
43
+ - **Center Focus**: A `DeepGlassCard` (max-width: 2xl) acting as a conversational carousel.
44
+ - *Top*: A friendly greeting/header ("Welcome Explorer").
45
+ - *Middle*: An animated selection grid for topics (e.g., Tech, History, Med) or a single elegant text input for their name/goal.
46
+ - *Bottom*: A "Next" or "Start Journey" `GameButton` that pulses.
47
+ - **Transitions**: Slide in/out using `framer-motion` `AnimatePresence` as the user answers 2-3 questions. Routes to Dashboard upon completion.
48
+
49
+ ### Screen C: The Learning Hub / Dashboard (`/app/(dashboard)/home/page.tsx`)
50
+ **Vibe**: The player's home base. Clean, structured, motivating.
51
+ - **Top Navigation Bar (Sticky)**: `TopStatsBar`
52
+ - *Left*: User Avatar / Mascot icon. Clicking opens the Profile/Settings modal.
53
+ - *Right*:
54
+ - 🔥 Streak Count (e.g., "12 Days")
55
+ - 💎 Gem Balance (e.g., "1,500"). Clicking this routes to the Top-Up Store.
56
+ - 🛡️ Current Rank/Level Badge.
57
+ - **Hero Section (Top Center)**: "Continue Journey" Card.
58
+ - A wide `DeepGlassCard`. Displays a thumbnail of the current active course, a progress bar (e.g., "75% complete"), and a massive, glowing "Resume" button.
59
+ - **Secondary Section (Middle)**: "Forge a New Universe" (Upload Zone).
60
+ - A beautifully styled dashed border dropzone using `framer-motion` for a hover scaling effect.
61
+ - *Center icon*: A glowing magical portal or book.
62
+ - *Text*: "Drop a PDF or enter a topic to forge a new learning path." (Simulates routing to The Forge on click).
63
+ - **Bottom Section**: "Your Library" - A horizontal scrollable list or grid of generic course thumbnails.
64
+
65
+ ### Screen D: The Treasury / Top-Up Store (`/app/(dashboard)/store/page.tsx`)
66
+ **Vibe**: Premium, enticing, rewarding.
67
+ - **Top Navigation Bar (Sticky)**: Same `TopStatsBar` as Dashboard, but the "Back" arrow replaces the Avatar.
68
+ - **Header**: "The Treasury: Fuel Your Journey" - Get Gems (💎) to forge new courses or ask for hints.
69
+ - **Layout**: A row of 3 `DeepGlassCard` pricing tiers centered on screen:
70
+ - **Tier 1 (Starter)**: "Handful of Gems (500 💎)" - Minimalist design.
71
+ - **Tier 2 (Popular)**: "Chest of Gems (2000 💎)" - Emits a slight glow, features a "Best Value" ribbon/badge in the corner.
72
+ - **Tier 3 (Pro Learner)**: "Infinite Energy Subscription" - Distinct color styling (e.g., gold or neon purple background with animated gradients).
73
+ - **Interactions**: Clicking a tier opens a sleek, native-feeling fake payment modal (e.g., an Apple Pay style slide-up modal from the bottom).
74
+
75
+ ### Screen E: User Profile & Settings Modal
76
+ **Vibe**: Personalized, highly polished dashboard.
77
+ - **Interaction**: This is NOT a separate page. It is an overlay (`AnimatePresence` modal) triggered by clicking the Avatar in the Top Navigation.
78
+ - **Layout**: Centered, tall `DeepGlassCard`.
79
+ - **Content**:
80
+ - *Header*: Huge Avatar with an "Edit" icon overlay. Username and Title (e.g., "Level 5 Quantum Scholar").
81
+ - *Stats Grid*: Total XP earned, Longest Streak, Courses Completed.
82
+ - *Preferences*: Toggles for "Sound Effects", "Dark/Light Theme" (though default is Dark Glass), and "Difficulty scaling".
83
+ - *Danger Zone*: A subtle "Log Out" button at the very bottom.
84
+
85
+ ### Screen F: The Forge (Loading/Generation) (`/app/forge/page.tsx`)
86
+ **Vibe**: High-tech, anticipation.
87
+ - **Layout**: Full screen, hiding all navigation.
88
+ - **Center**: A pulsing, complex SVG animation (like an atom, a neural network, or a magical anvil forging rings).
89
+ - **Text (Below Animation)**: Large, dynamic typewriter text updating via `setTimeout` every 2 seconds:
90
+ - "Scanning document..." -> "Extracting key concepts..." -> "Forging interactive stages..."
91
+ - **Transition**: Ends with a bright flash (white screen) leading directly to the Syllabus Map.
92
+
93
+ ### Screen G: Syllabus Map / Skill Tree (`/app/(dashboard)/map/[courseId]/page.tsx`)
94
+ **Vibe**: Exploration, non-linear progression.
95
+ - **Top Bar**: Standard `TopStatsBar` with a "Back to Hub" arrow on the left.
96
+ - **Main Area**: A massive scrolling (or simple vertical) canvas.
97
+ - *Path*: A winding SVG dashed line connecting nodes from bottom to top or left to right.
98
+ - *Nodes*: Circular icons representing "Units" or "Concepts".
99
+ - *Locked*: Grayed out, padlock icon. Not clickable.
100
+ - *Available*: Full color, slowly breathing (scaling up and down slightly), surrounded by a glowing aura.
101
+ - *Completed*: Gold/Green color, checkmark or star icon.
102
+ - **Interaction**: Clicking an *Available* node triggers an immediate route change to the Arena.
103
+
104
+ ### Screen H: The Arena (Stage Player) (`/app/(arena)/play/[nodeId]/page.tsx`)
105
+ **Vibe**: Absolute focus. Zero distractions.
106
+ - **Top Navigation (Minimalist)**:
107
+ - *Left*: A discreet "X" to exit (triggers a "Lose progress?" warning modal).
108
+ - *Center*: `TopProgressBar`. A sleek, continuous line that fills up smoothly as stages are passed.
109
+ - *Right*: Empty (keep distraction-free).
110
+ - **Center Stage (The Game Area)**:
111
+ - Occupies 70% of the screen.
112
+ - Renders the specific interactive component (`LogicChain`, `TaxonomyMatrix`, etc.). Elements must be large, chunky, and touch/drag-friendly.
113
+ - **Bottom Action Area**:
114
+ - *Floating Mascot (Bottom Right Corner)*: Thinks/reacts. Clickable to ask for a "Hint" (deducts 10 💎 gems).
115
+ - *Action Bar (Fixed at bottom)*: A full-width `GameButton` saying "CHECK".
116
+ - **Feedback Overlay (Bottom Sheet)**:
117
+ - Upon clicking "CHECK", a colored panel slides up from the bottom edge covering the Action Bar.
118
+ - *If Correct*: Panel is bright green. Plays a success chime. Confetti explodes on screen. Button changes to "CONTINUE".
119
+ - *If Incorrect*: Panel is red/orange. Plays an error buzz and shakes the screen physically. Displays the `feedback.error` message and a hint. Button changes to "GOT IT".
120
+
121
+ ### Screen I: Victory Screen (`/app/(arena)/play/[nodeId]/result/page.tsx`)
122
+ **Vibe**: Celebration, dopamine hit.
123
+ - **Background**: Darkens with a spotlight effect.
124
+ - **Animations**: Massive confetti burst. A giant 3D-looking chest or star drops into the center with a bounce physics effect (`y: -100` to `0`).
125
+ - **Stats Presentation**:
126
+ - "LESSON CLEARED!" large header.
127
+ - A card slides up showing tally counters rolling quickly from 0:
128
+ - Accuracy: 100%
129
+ - Time: 2m 14s
130
+ - XP Gained: +50 XP
131
+ - An XP bar dynamically fills up, triggering a "Level Up!" flash if it crosses a threshold defined in `useUserStore`.
132
+ - **Bottom**: A massive, glowing "Back to Map" button that routes the user back, initiating the unlock animation of the next node.
133
+
134
+ ### Universal State: Graceful Errors & Empty States
135
+ **Vibe**: Forgiving, helpful.
136
+ - **Empty Library**: Instead of a blank space, show a ghosted illustration of an empty bookshelf and a pulsing arrow pointing to the "Forge" dropzone.
137
+ - **404/Error**: A friendly Mascot looking confused with a "Let's get you back home" button. Never show a raw stack trace.
138
+
139
+ ---
140
+
141
+ ## 🏗️ 3. Technical Stack & Foundation
142
+
143
+ * **Framework**: Next.js 14+ (App Router).
144
+ * **Styling**: Tailwind CSS (with specific arbitrary values for glassmorphism).
145
+ * **Icons**: `lucide-react`.
146
+ * **Animation**: `framer-motion` (Mandatory for all page transitions, modal popups, and button interactions).
147
+ * **Confetti/Effects**: `react-canvas-confetti` (For victory screens).
148
+ * **State Management**: `zustand` (Used extensively to replace backend APIs).
149
+
150
+ ### The UX Philosophy
151
+ * **Zero Developer UI**: Remove all JSON viewers, "Debug" sidebars, "System Admin" panels, and raw data dumps.
152
+ * **The Ultimate Glassmorphism (iOS 26 Style)**: Cards and overlays must use deep blurring combined with extremely subtle SVG noise textures to prevent color banding and add premium physical realism.
153
+
154
+ ### Frontend Architecture Patterns
155
+ * **Feature-Sliced Design (FSD) Lite**: Group components, stores, and hooks by domain/feature (e.g., `features/arena`, `features/dashboard`) rather than technical type, keeping related logic co-located.
156
+ * **Container / Presentational Pattern**: Keep complex React components separated. The "Container" (e.g., `/app/(arena)/play/[nodeId]/page.tsx`) handles Zustand store reading and routing. "Presentational" components (e.g., `SpatialAnatomy.tsx`) only receive props and emit events, remaining completely pure and testable.
157
+ * **Store Segregation (Zustand)**: Do not use one monolithic store. Segment by domain:
158
+ - `useUserStore`: Global persisted state (XP, streaks, preferences).
159
+ - `useCourseStore`: Domain data (curriculum structure, node unlock states).
160
+ - `useArenaStore`: Ephemeral local state (current question index, animation triggers during a game session).
161
+ * **Animation Driven Flow**: Do not rely strictly on instantaneous state changes. Use `framer-motion`'s `onAnimationComplete` callbacks to trigger state updates (e.g., unlocking a node only *after* the victory confetti finishes).
162
+
163
+ ---
164
+
165
+ ## 💎 4. The Core UI Component: `DeepGlassCard`
166
+
167
+ Whenever you need a container (dashboard tiles, store tiers, map nodes, feedback modals), use this specific implementation to achieve the required "iOS 26 Premium Glass" aesthetic.
168
+
169
+ ```tsx
170
+ "use client";
171
+ import React from 'react';
172
+ import { motion, HTMLMotionProps } from 'framer-motion';
173
+ // Assume `cn` is a standard class merging utility (like clsx + twMerge)
174
+ import { cn } from '@/lib/utils';
175
+
176
+ interface DeepGlassCardProps extends HTMLMotionProps<"div"> {
177
+ children: React.ReactNode;
178
+ className?: string;
179
+ }
180
+
181
+ export function DeepGlassCard({ children, className, ...props }: DeepGlassCardProps) {
182
+ return (
183
+ <motion.div
184
+ className={cn(
185
+ "relative group rounded-3xl p-8 overflow-hidden",
186
+ "bg-slate-900/40", // Dark mode glass base
187
+ "backdrop-blur-[40px] saturate-150", // Heavy blur and color boost
188
+ "border border-white/10", // Edge highlight
189
+ /* Thickness (top inner glow) and soft environment drop shadow */
190
+ "shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),_0_8px_32px_rgba(0,0,0,0.3)]",
191
+ "transition-all duration-500 hover:shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),_0_16px_48px_rgba(0,0,0,0.4)]",
192
+ className
193
+ )}
194
+ {...props}
195
+ >
196
+ {/* The Noise Texture layer: crucial for physical realism (prevents color banding) */}
197
+ <div className="absolute inset-0 opacity-[0.03] pointer-events-none mix-blend-overlay"
198
+ style={{
199
+ backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E")`
200
+ }}
201
+ />
202
+
203
+ {/* Content Layer */}
204
+ <div className="relative z-10 text-white">
205
+ {children}
206
+ </div>
207
+
208
+ {/* Dynamic light reflection on hover */}
209
+ <div className="absolute inset-0 bg-gradient-to-br from-white/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-700 pointer-events-none" />
210
+ </motion.div>
211
+ );
212
+ }
213
+ ```
214
+
215
+ ---
216
+
217
+ ## 🗂️ 5. Next.js App Router Structure & Implementation
218
+
219
+ Structure the application exactly as follows.
220
+
221
+ ```text
222
+ src/
223
+ ├── app/
224
+ │ ├── layout.tsx # Global App layout (Include modern sans-serif fonts)
225
+ │ ├── globals.css # Tailwind directives + custom @layer utilities
226
+ │ ├── (onboarding)/ # Route Group 1: First-time entry
227
+ │ │ ├── login/page.tsx # The premium Welcome/Auth screen (Screen A)
228
+ │ │ └── welcome/page.tsx # Interactive "Sorting Hat" questionnaire (Screen B)
229
+ │ ├── (dashboard)/ # Route Group 2: The Hub
230
+ │ │ ├── layout.tsx # Contains the Top Navigation Bar (Avatar, Streak, Gems)
231
+ │ │ ├── home/page.tsx # "Continue Journey" & "Create New Universe" dropzone (Screen C)
232
+ │ │ ├── store/page.tsx # The Treasury Top-up Center (Screen D)
233
+ │ │ └── map/[courseId]/page.tsx # The non-linear Skill Tree / Island Map view (Screen G)
234
+ │ ├── (arena)/ # Route Group 3: The Active Learning Zone
235
+ │ │ └── play/[nodeId]/ # The Stage Player (No navigation bars allowed, pure focus) (Screen H)
236
+ │ │ ├── page.tsx
237
+ │ │ └── result/page.tsx # The Victory Celebration (Screen I)
238
+ │ └── forge/page.tsx # The "Fake Loading" screen mocking AI generation (Screen F)
239
+
240
+ ├── components/
241
+ │ ├── ui/ # Reusable base components
242
+ │ │ ├── DeepGlassCard.tsx # ⭐ The core container
243
+ │ │ ├── GameButton.tsx # framer-motion wrapped button with scale-down tap
244
+ │ │ ├── TopProgressBar.tsx # Fills up smoothly as stages are completed
245
+ │ │ └── MascotHint.tsx # Floating right-bottom character for AI hints
246
+ │ └── shared/
247
+ │ ├── TopStatsBar.tsx # Renders Rank, Fire 🔥, and Diamond 💎 icons
248
+ │ └── ProfileModal.tsx # The slide-up Settings/Profile overlay (Screen E)
249
+
250
+ ├── stores/ # Pure Frontend Backend (Zustand + Persist)
251
+ │ ├── useUserStore.ts # App State: xp, level, streak, gems, lastActiveNodeId
252
+ │ ├── useCourseStore.ts # App State: Loads mock data, tracks unlocked nodes
253
+ │ └── useArenaStore.ts # Local State: currentStageIndex, isCorrect, showFeedback
254
+
255
+ └── data/ # Mock Data Sources (See Section 6)
256
+ ├── mock_medicine.ts
257
+ └── mock_calculus.ts
258
+ ```
259
+
260
+ ---
261
+
262
+ ## 🧪 6. Pure Frontend Mock Data Context
263
+
264
+ To allow immediate UI construction without backend schema definitions, you must use these EXACT two datasets as your single source of truth for the curriculum structure. The UI must dynamically render the `stage.component` string into an actual interactive view.
265
+
266
+ ### Mock 1: Medicine (Cardiovascular)
267
+ ```typescript
268
+ // Path: src/data/mock_medicine.ts
269
+ export const mockMedicineUnit = {
270
+ unitId: "med-u1",
271
+ unitTitle: "The Heart & Blood Flow",
272
+ nodes: [
273
+ {
274
+ id: "med-n1",
275
+ title: "Heart Chambers (心臟結構)",
276
+ description: "Identify the spatial anatomy of the heart.",
277
+ type: "concept",
278
+ status: "available", // Only the first node is unlocked
279
+ stages: [
280
+ {
281
+ stageId: "med-s1",
282
+ topic: "Identify the Left Ventricle",
283
+ module: "Instruction",
284
+ component: "SpatialAnatomy",
285
+ skin: "Scientific",
286
+ config: {
287
+ data: {
288
+ model: "heart-cross-section",
289
+ labels: [
290
+ { id: "ra", label: "Right Atrium" },
291
+ { id: "rv", label: "Right Ventricle" },
292
+ { id: "la", label: "Left Atrium" },
293
+ { id: "lv", label: "Left Ventricle" }
294
+ ]
295
+ },
296
+ initialState: {}
297
+ },
298
+ validation: { type: "exact", condition: { target: "lv" } },
299
+ feedback: {
300
+ success: "Correct! The Left Ventricle pumps blood to the entire body.",
301
+ error: "Incorrect. Hint: Look at the thicker muscular wall at the bottom right of the diagram.",
302
+ hint: "Where is the thickest muscle needed?"
303
+ }
304
+ }
305
+ ]
306
+ },
307
+ {
308
+ id: "med-n2",
309
+ title: "Circulation Path (血液循環)",
310
+ description: "Arrange the flow of deoxygenated blood.",
311
+ type: "exercise",
312
+ status: "locked",
313
+ stages: [
314
+ {
315
+ stageId: "med-s2",
316
+ topic: "Pulmonary Circulation",
317
+ module: "Practice",
318
+ component: "LogicChain",
319
+ skin: "Classic",
320
+ config: {
321
+ data: {
322
+ nodes: [
323
+ "Superior Vena Cava",
324
+ "Right Atrium",
325
+ "Right Ventricle",
326
+ "Pulmonary Artery",
327
+ "Lungs"
328
+ ]
329
+ },
330
+ initialState: {}
331
+ },
332
+ validation: { type: "exact", condition: {} },
333
+ feedback: {
334
+ success: "Perfect Sequence! You understand the path to the lungs.",
335
+ error: "Not quite. Remember blood enters the atrium before the ventricle.",
336
+ hint: "Veins -> Atrium -> Ventricle -> Artery"
337
+ }
338
+ }
339
+ ]
340
+ }
341
+ ]
342
+ };
343
+ ```
344
+
345
+ ### Mock 2: Calculus (Limits & Derivatives)
346
+ ```typescript
347
+ // Path: src/data/mock_calculus.ts
348
+ export const mockCalculusUnit = {
349
+ unitId: "calc-u1",
350
+ unitTitle: "Limits & Derivatives",
351
+ nodes: [
352
+ {
353
+ id: "calc-n1",
354
+ title: "Concept of Limits",
355
+ description: "Classify scenarios where limits exist or fail.",
356
+ type: "concept",
357
+ status: "available",
358
+ stages: [
359
+ {
360
+ stageId: "calc-s1",
361
+ topic: "Limit Existence",
362
+ module: "Instruction",
363
+ component: "TaxonomyMatrix",
364
+ skin: "Code",
365
+ config: {
366
+ data: {
367
+ buckets: ["Limit Exists", "Limit Does Not Exist"],
368
+ items: [
369
+ { id: "limit1", content: "Left limit = 5, Right = 5" },
370
+ { id: "limit2", content: "Left limit = 3, Right = -3 (Jump)" },
371
+ { id: "limit3", content: "Approaches infinity (Asymptote)" },
372
+ { id: "limit4", content: "Continuous polynomial" }
373
+ ]
374
+ },
375
+ initialState: { assignments: {} }
376
+ },
377
+ validation: { type: "exact", condition: {} },
378
+ feedback: {
379
+ success: "Excellent classification! Limits require agreement from both sides.",
380
+ error: "Check the definitions of jumps and asymptotes.",
381
+ hint: "If left and right don't match, it doesn't exist."
382
+ }
383
+ }
384
+ ]
385
+ },
386
+ {
387
+ id: "calc-n2",
388
+ title: "Derivative Rules",
389
+ description: "Match base functions to their derivatives.",
390
+ type: "exercise",
391
+ status: "locked",
392
+ stages: [
393
+ {
394
+ stageId: "calc-s2",
395
+ topic: "Basic Differentiation",
396
+ module: "Practice",
397
+ component: "PatternMatcher",
398
+ skin: "Scientific",
399
+ config: {
400
+ data: {
401
+ pairs: [
402
+ { id: "diff1", left: "f(x) = x³", right: "f'(x) = 3x²" },
403
+ { id: "diff2", left: "f(x) = sin(x)", right: "f'(x) = cos(x)" },
404
+ { id: "diff3", left: "f(x) = eˣ", right: "f'(x) = eˣ" },
405
+ { id: "diff4", left: "f(x) = ln(x)", right: "f'(x) = 1/x" }
406
+ ]
407
+ },
408
+ initialState: {}
409
+ },
410
+ validation: { type: "exact", condition: {} },
411
+ feedback: {
412
+ success: "All matched! You master the basic power and transcendental rules.",
413
+ error: "Review your trig and exponential derivatives.",
414
+ hint: "The derivative of eˣ is special!"
415
+ }
416
+ }
417
+ ]
418
+ }
419
+ ]
420
+ };
421
+ ```
422
+
423
+ ---
424
+ > **[End of Directives]** Ensure every file you create adheres strictly to the specifications laid out above. Build the entire ecosystem mock-first without any backend connections.
app/(arena)/play/[nodeId]/ArenaClient.tsx ADDED
@@ -0,0 +1,1001 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import React, { useState, useCallback, useMemo, useRef, useEffect } from "react";
4
+ import { motion, AnimatePresence } from "framer-motion";
5
+ import { useRouter, useParams } from "next/navigation";
6
+ import TopProgressBar from "@/components/ui/TopProgressBar";
7
+ import GameButton from "@/components/ui/GameButton";
8
+ import useArenaStore from "@/stores/useArenaStore";
9
+ import { REMEDY_THRESHOLD } from "@/stores/useArenaStore";
10
+ import useUserStore from "@/stores/useUserStore";
11
+ import useCourseStore from "@/stores/useCourseStore";
12
+ import TaxonomyMatrix from "@/components/arena/TaxonomyMatrix";
13
+ import SpatialAnatomy from "@/components/arena/SpatialAnatomy";
14
+ import LogicChain from "@/components/arena/LogicChain";
15
+
16
+ /* ═══════════════════ Taxonomy answer keys ═══════════════════ */
17
+
18
+ const TAXONOMY_ANSWERS: Record<string, Record<string, string>> = {
19
+ "calc-s1": {
20
+ limit1: "Limit Exists",
21
+ limit2: "Limit Does Not Exist",
22
+ limit3: "Limit Does Not Exist",
23
+ limit4: "Limit Exists",
24
+ },
25
+ };
26
+
27
+ /* ═══════════════════ Data ═══════════════════ */
28
+
29
+ interface MatchPair { left: string; right: string }
30
+
31
+ const STAGES: { question: string; pairs: MatchPair[] }[] = [
32
+ {
33
+ question: "Match the English word to its French translation",
34
+ pairs: [
35
+ { left: "Apple", right: "Pomme" },
36
+ { left: "Book", right: "Livre" },
37
+ { left: "House", right: "Maison" },
38
+ { left: "Cat", right: "Chat" },
39
+ ],
40
+ },
41
+ {
42
+ question: "Match the animals",
43
+ pairs: [
44
+ { left: "Dog", right: "Chien" },
45
+ { left: "Bird", right: "Oiseau" },
46
+ { left: "Fish", right: "Poisson" },
47
+ { left: "Horse", right: "Cheval" },
48
+ ],
49
+ },
50
+ {
51
+ question: "Match the colors",
52
+ pairs: [
53
+ { left: "Red", right: "Rouge" },
54
+ { left: "Blue", right: "Bleu" },
55
+ { left: "Green", right: "Vert" },
56
+ { left: "White", right: "Blanc" },
57
+ ],
58
+ },
59
+ ];
60
+
61
+ /* ═══════════════════ Helpers ═══════════════════ */
62
+
63
+ function shuffle<T>(arr: T[]): T[] {
64
+ const a = [...arr];
65
+ for (let i = a.length - 1; i > 0; i--) {
66
+ const j = Math.floor(Math.random() * (i + 1));
67
+ [a[i], a[j]] = [a[j], a[i]];
68
+ }
69
+ return a;
70
+ }
71
+
72
+ /* ═══════════════════ Page ═══════════════════ */
73
+
74
+ export default function ArenaClient() {
75
+ const router = useRouter();
76
+ const params = useParams();
77
+ const nodeId = params.nodeId as string;
78
+
79
+ // Arena store
80
+ const arenaStore = useArenaStore();
81
+ const { startSession, markCorrect: arenaMarkCorrect, markIncorrect: arenaMarkIncorrect, useHint: arenaUseHint, triggerConfetti: arenaTriggerConfetti, triggerShake: arenaTriggerShake } = arenaStore;
82
+
83
+ // User store
84
+ const spendGems = useUserStore((s) => s.spendGems);
85
+ const setLastActiveNode = useUserStore((s) => s.setLastActiveNode);
86
+
87
+ // Course store – load node data for dynamic stages
88
+ const loadMockCourses = useCourseStore((s) => s.loadMockCourses);
89
+ const getNode = useCourseStore((s) => s.getNode);
90
+ const addRemedyNode = useCourseStore((s) => s.addRemedyNode);
91
+ const remedyNodes = useCourseStore((s) => s.remedyNodes);
92
+
93
+ useEffect(() => {
94
+ loadMockCourses();
95
+ }, [loadMockCourses]);
96
+
97
+ const nodeData = getNode(nodeId);
98
+ const dynamicStages = nodeData?.stages ?? [];
99
+ const hasDynamic = dynamicStages.length > 0;
100
+
101
+ // Determine course id from node data
102
+ const courseId = useMemo(() => {
103
+ const courses = useCourseStore.getState().courses;
104
+ for (const c of Object.values(courses)) {
105
+ if (c.nodes.some((n: { id: string }) => n.id === nodeId)) return c.unitId;
106
+ }
107
+ return "med-u1";
108
+ }, [nodeId]);
109
+
110
+ const [stageIdx, setStageIdx] = useState(0);
111
+
112
+ // Current dynamic stage (if any)
113
+ const currentDynStage = hasDynamic ? dynamicStages[stageIdx] : null;
114
+ const isDynamicTaxonomy = currentDynStage?.component === "TaxonomyMatrix";
115
+ const isDynamicMatcher = currentDynStage?.component === "PatternMatcher";
116
+ const isDynamicAnatomy = currentDynStage?.component === "SpatialAnatomy";
117
+ const isDynamicChain = currentDynStage?.component === "LogicChain";
118
+ const isDynamicCustom = isDynamicTaxonomy || isDynamicAnatomy || isDynamicChain;
119
+
120
+ // For PatternMatcher: convert stage data to match-pair format
121
+ const dynamicMatchStage = useMemo(() => {
122
+ if (!isDynamicMatcher || !currentDynStage) return null;
123
+ const pairs = (currentDynStage.config.data as Record<string, unknown[]>).pairs as { id: string; left: string; right: string }[];
124
+ return {
125
+ question: `${currentDynStage.topic}: ${nodeData?.description ?? "Match each pair"}`,
126
+ pairs: pairs.map((p) => ({ left: p.left, right: p.right })),
127
+ };
128
+ }, [isDynamicMatcher, currentDynStage, nodeData]);
129
+ const [selectedLeft, setSelectedLeft] = useState<string | null>(null);
130
+ const [selectedRight, setSelectedRight] = useState<string | null>(null);
131
+ const [matched, setMatched] = useState<string[]>([]);
132
+ const [wrongPair, setWrongPair] = useState<[string, string] | null>(null);
133
+ const [feedback, setFeedback] = useState<"correct" | "incorrect" | null>(null);
134
+ const [showConfetti, setShowConfetti] = useState(false);
135
+ const [hintUsed, setHintUsed] = useState(false);
136
+ const [hintPair, setHintPair] = useState<string | null>(null);
137
+ const [showRemedyPopup, setShowRemedyPopup] = useState(false);
138
+ const remedyShownRef = useRef(false);
139
+
140
+ const stage = dynamicMatchStage || STAGES[stageIdx % STAGES.length];
141
+ const totalStages = hasDynamic ? dynamicStages.length : STAGES.length;
142
+ const progress = (stageIdx / totalStages) * 100;
143
+
144
+ // Start arena session on mount (after totalStages settled)
145
+ useEffect(() => {
146
+ startSession({ nodeId, courseId, totalStages });
147
+ setLastActiveNode(nodeId);
148
+ // eslint-disable-next-line react-hooks/exhaustive-deps
149
+ }, [nodeId]);
150
+
151
+ /* ── Remedy trigger: when consecutive errors reach threshold (once per node) ── */
152
+ useEffect(() => {
153
+ if (
154
+ arenaStore.remedyTriggered &&
155
+ !remedyShownRef.current &&
156
+ nodeData
157
+ ) {
158
+ // Only show if this node doesn't already have a remedy node
159
+ const alreadyHasRemedy = remedyNodes.some((r) => r.sourceNodeId === nodeId);
160
+ remedyShownRef.current = true;
161
+ if (!alreadyHasRemedy) {
162
+ addRemedyNode(courseId, nodeId, nodeData.title);
163
+ setShowRemedyPopup(true);
164
+ }
165
+ }
166
+ }, [arenaStore.remedyTriggered, courseId, nodeId, nodeData, addRemedyNode, remedyNodes]);
167
+
168
+ // Shuffle only on client to avoid hydration mismatch
169
+ const [shuffledRight, setShuffledRight] = useState<string[]>(
170
+ () => stage.pairs.map((p) => p.right) // initial: original order (matches SSR)
171
+ );
172
+ const [mounted, setMounted] = useState(false);
173
+
174
+ useEffect(() => {
175
+ setMounted(true);
176
+ if (!isDynamicCustom) {
177
+ setShuffledRight(shuffle(stage.pairs.map((p) => p.right)));
178
+ }
179
+ // eslint-disable-next-line react-hooks/exhaustive-deps
180
+ }, [stageIdx]);
181
+
182
+ const allMatched = matched.length === stage.pairs.length;
183
+
184
+ /* Select a left word */
185
+ const pickLeft = useCallback(
186
+ (word: string) => {
187
+ if (matched.includes(word) || feedback) return;
188
+ setSelectedLeft(word);
189
+ setWrongPair(null);
190
+ },
191
+ [matched, feedback]
192
+ );
193
+
194
+ /* Select a right word */
195
+ const pickRight = useCallback(
196
+ (word: string) => {
197
+ if (feedback) return;
198
+ if (!selectedLeft) return;
199
+ // check if this right word is already matched
200
+ const alreadyMatched = stage.pairs.find(
201
+ (p) => p.right === word && matched.includes(p.left)
202
+ );
203
+ if (alreadyMatched) return;
204
+ setSelectedRight(word);
205
+ },
206
+ [selectedLeft, matched, feedback, stage.pairs]
207
+ );
208
+
209
+ /* Check answer (called when CHECK button tapped) */
210
+ const handleCheck = useCallback(() => {
211
+ if (!selectedLeft || !selectedRight) return;
212
+
213
+ const pair = stage.pairs.find((p) => p.left === selectedLeft);
214
+ if (pair && pair.right === selectedRight) {
215
+ // Correct match
216
+ setMatched((prev) => [...prev, selectedLeft]);
217
+ setSelectedLeft(null);
218
+ setSelectedRight(null);
219
+ setWrongPair(null);
220
+ } else {
221
+ // Wrong
222
+ setWrongPair([selectedLeft, selectedRight]);
223
+ arenaMarkIncorrect();
224
+ arenaTriggerShake();
225
+ setTimeout(() => {
226
+ setWrongPair(null);
227
+ setSelectedLeft(null);
228
+ setSelectedRight(null);
229
+ }, 800);
230
+ }
231
+ }, [selectedLeft, selectedRight, stage.pairs, arenaMarkIncorrect, arenaTriggerShake]);
232
+
233
+ /* Stage complete → feedback (only for match stages) */
234
+ React.useEffect(() => {
235
+ if (!isDynamicCustom && allMatched && !feedback) {
236
+ const timer = setTimeout(() => {
237
+ setFeedback("correct");
238
+ setShowConfetti(true);
239
+ arenaMarkCorrect();
240
+ arenaTriggerConfetti();
241
+ }, 400);
242
+ return () => clearTimeout(timer);
243
+ }
244
+ }, [allMatched, feedback, arenaMarkCorrect, arenaTriggerConfetti, isDynamicCustom]);
245
+
246
+ /* Continue to next stage or result */
247
+ const handleContinue = useCallback(() => {
248
+ if (stageIdx < totalStages - 1) {
249
+ setStageIdx((i) => i + 1);
250
+ setMatched([]);
251
+ setSelectedLeft(null);
252
+ setSelectedRight(null);
253
+ setWrongPair(null);
254
+ setFeedback(null);
255
+ setShowConfetti(false);
256
+ setHintUsed(false);
257
+ setHintPair(null);
258
+ } else {
259
+ router.push(`/play/${nodeId}/result`);
260
+ }
261
+ }, [stageIdx, totalStages, router]);
262
+
263
+ /* Hint */
264
+ const handleHint = useCallback(() => {
265
+ if (hintUsed || allMatched) return;
266
+ // Spend gems (10 per hint)
267
+ const canAfford = spendGems(10);
268
+ if (!canAfford) return;
269
+ arenaUseHint();
270
+ const unmatched = stage.pairs.filter((p) => !matched.includes(p.left));
271
+ if (unmatched.length > 0) {
272
+ const pair = unmatched[0];
273
+ setHintPair(pair.left);
274
+ setSelectedLeft(pair.left);
275
+ setSelectedRight(pair.right);
276
+ setHintUsed(true);
277
+ // auto-match after a short delay
278
+ setTimeout(() => {
279
+ setMatched((prev) => [...prev, pair.left]);
280
+ setSelectedLeft(null);
281
+ setSelectedRight(null);
282
+ setHintPair(null);
283
+ }, 1200);
284
+ }
285
+ }, [hintUsed, allMatched, stage.pairs, matched, spendGems, arenaUseHint]);
286
+
287
+ /* ── AI Chat Assistant state ── */
288
+ const [chatInput, setChatInput] = useState("");
289
+ const [chatMessages, setChatMessages] = useState<{ id: number; role: "user" | "assistant"; text: string }[]>([]);
290
+ const chatEndRef = useRef<HTMLDivElement>(null);
291
+
292
+ useEffect(() => {
293
+ chatEndRef.current?.scrollIntoView({ behavior: "smooth" });
294
+ }, [chatMessages]);
295
+
296
+ const handleChatSend = useCallback(() => {
297
+ const trimmed = chatInput.trim();
298
+ if (!trimmed) return;
299
+ const userMsg = { id: Date.now(), role: "user" as const, text: trimmed };
300
+ const botMsg = { id: Date.now() + 1, role: "assistant" as const, text: trimmed };
301
+ setChatMessages((prev) => [...prev, userMsg, botMsg]);
302
+ setChatInput("");
303
+ }, [chatInput]);
304
+
305
+ const handleChatKeyDown = useCallback((e: React.KeyboardEvent) => {
306
+ if (e.key === "Enter" && !e.shiftKey) {
307
+ e.preventDefault();
308
+ handleChatSend();
309
+ }
310
+ }, [handleChatSend]);
311
+
312
+ return (
313
+ <div className="relative min-h-screen bg-gradient-to-br from-[#edf7fb] via-[#c9e6f2] to-[#a3d5e8] flex flex-col">
314
+ {/* ─── Top Nav ─── */}
315
+ <div className="flex items-center gap-3 px-8 pt-4 pb-1">
316
+ <button
317
+ onClick={() => router.push(`/map/${courseId}`)}
318
+ className="h-9 w-9 rounded-full bg-white/60 backdrop-blur flex items-center justify-center hover:bg-white/80 transition"
319
+ >
320
+ <svg viewBox="0 0 24 24" className="h-5 w-5 text-brand-gray-600" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
321
+ <path d="M18 6L6 18M6 6l12 12" />
322
+ </svg>
323
+ </button>
324
+ <div className="flex-1">
325
+ <TopProgressBar progress={progress} className="h-3" />
326
+ </div>
327
+ </div>
328
+
329
+ {/* ─── Main content: two columns ─── */}
330
+ <div className="flex-1 flex gap-8 px-8 pb-4 w-full">
331
+ {/* ── Left: Question + Match Grid ── */}
332
+ <div className="flex-1 flex flex-col min-w-0">
333
+ {isDynamicAnatomy && currentDynStage ? (
334
+ <SpatialAnatomy
335
+ key={`anat-${stageIdx}`}
336
+ stageIndex={stageIdx}
337
+ totalStages={totalStages}
338
+ topic={currentDynStage.topic}
339
+ description={nodeData?.description ?? ""}
340
+ model={(currentDynStage.config.data as Record<string, unknown>).model as string}
341
+ labels={(currentDynStage.config.data as Record<string, unknown>).labels as { id: string; label: string }[]}
342
+ targetId={(currentDynStage.validation.condition as Record<string, string>).target}
343
+ feedbackMsg={currentDynStage.feedback}
344
+ onComplete={() => {
345
+ setFeedback("correct");
346
+ setShowConfetti(true);
347
+ arenaMarkCorrect();
348
+ arenaTriggerConfetti();
349
+ }}
350
+ onError={() => {
351
+ arenaMarkIncorrect();
352
+ arenaTriggerShake();
353
+ }}
354
+ onHintUse={() => {
355
+ const canAfford = spendGems(10);
356
+ if (canAfford) arenaUseHint();
357
+ return canAfford;
358
+ }}
359
+ />
360
+ ) : isDynamicChain && currentDynStage ? (
361
+ <LogicChain
362
+ key={`chain-${stageIdx}`}
363
+ stageIndex={stageIdx}
364
+ totalStages={totalStages}
365
+ topic={currentDynStage.topic}
366
+ description={nodeData?.description ?? ""}
367
+ nodes={(currentDynStage.config.data as Record<string, unknown>).nodes as string[]}
368
+ feedbackMsg={currentDynStage.feedback}
369
+ onComplete={() => {
370
+ setFeedback("correct");
371
+ setShowConfetti(true);
372
+ arenaMarkCorrect();
373
+ arenaTriggerConfetti();
374
+ }}
375
+ onError={() => {
376
+ arenaMarkIncorrect();
377
+ arenaTriggerShake();
378
+ }}
379
+ onHintUse={() => {
380
+ const canAfford = spendGems(10);
381
+ if (canAfford) arenaUseHint();
382
+ return canAfford;
383
+ }}
384
+ />
385
+ ) : isDynamicTaxonomy && currentDynStage ? (
386
+ <TaxonomyMatrix
387
+ key={`tax-${stageIdx}`}
388
+ stageIndex={stageIdx}
389
+ totalStages={totalStages}
390
+ topic={currentDynStage.topic}
391
+ description={nodeData?.description ?? ""}
392
+ buckets={(currentDynStage.config.data as Record<string, unknown>).buckets as string[]}
393
+ items={(currentDynStage.config.data as Record<string, unknown>).items as { id: string; content: string }[]}
394
+ correctAssignments={TAXONOMY_ANSWERS[currentDynStage.stageId] || {}}
395
+ feedbackMsg={currentDynStage.feedback}
396
+ onComplete={() => {
397
+ setFeedback("correct");
398
+ setShowConfetti(true);
399
+ arenaMarkCorrect();
400
+ arenaTriggerConfetti();
401
+ }}
402
+ onError={() => {
403
+ arenaMarkIncorrect();
404
+ arenaTriggerShake();
405
+ }}
406
+ onHintUse={() => {
407
+ const canAfford = spendGems(10);
408
+ if (canAfford) arenaUseHint();
409
+ return canAfford;
410
+ }}
411
+ />
412
+ ) : (
413
+ <>
414
+ {/* Question card */}
415
+ <motion.div
416
+ initial={{ opacity: 0, y: -10 }}
417
+ animate={{ opacity: 1, y: 0 }}
418
+ className="mt-4 mb-6 flex items-center gap-4"
419
+ >
420
+ {/* Stage indicator */}
421
+ <div className="flex-shrink-0 w-11 h-11 rounded-2xl bg-gradient-to-br from-brand-teal to-[#5fb3af] flex items-center justify-center shadow-md shadow-teal-300/30">
422
+ <span className="font-heading font-extrabold text-white text-base">
423
+ {stageIdx + 1}
424
+ </span>
425
+ </div>
426
+ <div>
427
+ <p className="text-[11px] font-bold text-brand-teal uppercase tracking-wider mb-0.5">
428
+ Stage {stageIdx + 1} of {totalStages}
429
+ </p>
430
+ <h2 className="font-heading font-bold text-xl text-brand-gray-700 leading-snug">
431
+ {stage.question}
432
+ </h2>
433
+ </div>
434
+ </motion.div>
435
+
436
+ {/* Match Grid */}
437
+ <div className="flex">
438
+ <MatchGrid
439
+ pairs={stage.pairs}
440
+ shuffledRight={shuffledRight}
441
+ matched={matched}
442
+ selectedLeft={selectedLeft}
443
+ selectedRight={selectedRight}
444
+ wrongPair={wrongPair}
445
+ hintPair={hintPair}
446
+ onPickLeft={pickLeft}
447
+ onPickRight={pickRight}
448
+ />
449
+ </div>
450
+
451
+ {/* spacer to push bottom bar down */}
452
+ <div className="min-h-[60px]" />
453
+
454
+ {/* ─── Bottom Bar ─── */}
455
+ <div className="relative pt-4 pb-6 flex items-end justify-between">
456
+ {/* Mascot + hint */}
457
+ <div className="flex items-end gap-2">
458
+ <OwlMascot />
459
+ <button
460
+ onClick={handleHint}
461
+ disabled={hintUsed || allMatched}
462
+ className={`mb-1 flex items-center gap-1 text-xs font-bold px-3 py-1.5 rounded-full border transition ${
463
+ hintUsed
464
+ ? "bg-brand-gray-100 text-brand-gray-400 border-brand-gray-200 cursor-not-allowed"
465
+ : "bg-amber-50 text-amber-600 border-amber-200 hover:bg-amber-100"
466
+ }`}
467
+ >
468
+ 💡 Hint
469
+ <span className="text-[10px] opacity-60">(10 💎)</span>
470
+ </button>
471
+ </div>
472
+
473
+ {/* CHECK button */}
474
+ <GameButton
475
+ variant="primary"
476
+ onClick={handleCheck}
477
+ disabled={!selectedLeft || !selectedRight || allMatched}
478
+ className="min-w-[140px]"
479
+ >
480
+ CHECK
481
+ </GameButton>
482
+ </div>
483
+ </>
484
+ )}
485
+ </div>
486
+
487
+ {/* ── Right: AI Chat Assistant ── */}
488
+ <div className="w-[360px] flex-shrink-0 pt-2">
489
+ <motion.div
490
+ initial={{ opacity: 0, x: 40 }}
491
+ animate={{ opacity: 1, x: 0 }}
492
+ transition={{ delay: 0.3, type: "spring", damping: 18 }}
493
+ className="rounded-3xl bg-white/60 backdrop-blur-xl border border-white/50 shadow-lg shadow-teal-200/20 overflow-hidden flex flex-col w-full"
494
+ style={{ height: "calc(100vh - 80px)" }}
495
+ >
496
+ {/* Header */}
497
+ <div className="px-6 pt-6 pb-4 flex items-center gap-3">
498
+ <div className="relative w-14 h-14 flex-shrink-0">
499
+ <div className="w-14 h-14 rounded-full bg-gradient-to-br from-amber-300 to-amber-500 flex items-center justify-center shadow-md">
500
+ <svg viewBox="0 0 40 40" className="w-9 h-9" fill="none">
501
+ <ellipse cx="20" cy="24" rx="12" ry="10" fill="#C47F17" />
502
+ <circle cx="15" cy="20" r="5" fill="white" />
503
+ <circle cx="25" cy="20" r="5" fill="white" />
504
+ <circle cx="15" cy="20" r="2.5" fill="#2D2D2D" />
505
+ <circle cx="25" cy="20" r="2.5" fill="#2D2D2D" />
506
+ <circle cx="16" cy="19" r="1" fill="white" />
507
+ <circle cx="26" cy="19" r="1" fill="white" />
508
+ <polygon points="20,22 18,25 22,25" fill="#FF9500" />
509
+ <polygon points="10,16 8,8 15,14" fill="#C47F17" />
510
+ <polygon points="30,16 32,8 25,14" fill="#C47F17" />
511
+ </svg>
512
+ </div>
513
+ </div>
514
+ <div>
515
+ <h3 className="font-heading font-bold text-[15px] text-brand-gray-700">AI Chat Assistant</h3>
516
+ <p className="text-xs text-brand-gray-400 mt-0.5">Ask me anything about this stage!</p>
517
+ </div>
518
+ </div>
519
+
520
+ {/* Chat messages area */}
521
+ <div className="flex-1 overflow-y-auto px-5 pb-3 space-y-3">
522
+ {chatMessages.length === 0 && (
523
+ <div className="flex flex-col items-center justify-center py-8 gap-3 text-center">
524
+ <div className="w-12 h-12 rounded-full bg-brand-teal/10 flex items-center justify-center">
525
+ <svg viewBox="0 0 24 24" className="w-6 h-6 text-brand-teal" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
526
+ <path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
527
+ </svg>
528
+ </div>
529
+ <p className="text-xs text-brand-gray-400 leading-relaxed max-w-[200px]">
530
+ Type a message to get help from your AI study companion!
531
+ </p>
532
+ </div>
533
+ )}
534
+
535
+ {chatMessages.map((msg) => (
536
+ <motion.div
537
+ key={msg.id}
538
+ initial={{ opacity: 0, y: 8 }}
539
+ animate={{ opacity: 1, y: 0 }}
540
+ transition={{ duration: 0.2 }}
541
+ className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
542
+ >
543
+ <div
544
+ className={`max-w-[85%] rounded-2xl px-4 py-2.5 text-[13px] leading-relaxed ${
545
+ msg.role === "user"
546
+ ? "bg-gradient-to-r from-brand-teal to-[#5fb3af] text-white rounded-br-md"
547
+ : "bg-white/70 border border-white/60 text-brand-gray-600 rounded-bl-md shadow-sm"
548
+ }`}
549
+ >
550
+ {msg.text}
551
+ </div>
552
+ </motion.div>
553
+ ))}
554
+ <div ref={chatEndRef} />
555
+ </div>
556
+
557
+ {/* Divider */}
558
+ <div className="mx-5 h-px bg-gradient-to-r from-transparent via-brand-teal/20 to-transparent" />
559
+
560
+ {/* Input area */}
561
+ <div className="px-5 py-4 flex items-end gap-2">
562
+ <textarea
563
+ value={chatInput}
564
+ onChange={(e) => setChatInput(e.target.value)}
565
+ onKeyDown={handleChatKeyDown}
566
+ placeholder="Type your message..."
567
+ rows={1}
568
+ className="flex-1 resize-none rounded-xl bg-white/70 border border-white/60 px-4 py-3 text-[13px] text-brand-gray-700 placeholder-brand-gray-300 focus:outline-none focus:ring-2 focus:ring-brand-teal/30 focus:border-brand-teal/40 transition-all"
569
+ style={{ maxHeight: 80 }}
570
+ />
571
+ <motion.button
572
+ onClick={handleChatSend}
573
+ whileTap={{ scale: 0.9 }}
574
+ className="flex-shrink-0 w-11 h-11 rounded-xl bg-gradient-to-r from-brand-teal to-[#5fb3af] text-white flex items-center justify-center shadow-md shadow-teal-300/30 hover:shadow-lg hover:shadow-teal-300/40 transition-all"
575
+ >
576
+ <svg viewBox="0 0 24 24" className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
577
+ <line x1="22" y1="2" x2="11" y2="13" />
578
+ <polygon points="22 2 15 22 11 13 2 9 22 2" />
579
+ </svg>
580
+ </motion.button>
581
+ </div>
582
+ </motion.div>
583
+ </div>
584
+ </div>
585
+
586
+ {/* ─── Feedback overlay ─── */}
587
+ <AnimatePresence>
588
+ {feedback && (
589
+ <FeedbackOverlay
590
+ type={feedback}
591
+ onContinue={handleContinue}
592
+ showConfetti={showConfetti}
593
+ />
594
+ )}
595
+ </AnimatePresence>
596
+
597
+ {/* ─── Remedy Popup ─── */}
598
+ <AnimatePresence>
599
+ {showRemedyPopup && (
600
+ <RemedyPopup
601
+ threshold={REMEDY_THRESHOLD}
602
+ onDismiss={() => setShowRemedyPopup(false)}
603
+ />
604
+ )}
605
+ </AnimatePresence>
606
+ </div>
607
+ );
608
+ }
609
+
610
+ /* ═══════════════════ Match Grid (with ref-based lines) ═══════════════════ */
611
+
612
+ function MatchGrid({
613
+ pairs,
614
+ shuffledRight,
615
+ matched,
616
+ selectedLeft,
617
+ selectedRight,
618
+ wrongPair,
619
+ hintPair,
620
+ onPickLeft,
621
+ onPickRight,
622
+ }: {
623
+ pairs: MatchPair[];
624
+ shuffledRight: string[];
625
+ matched: string[];
626
+ selectedLeft: string | null;
627
+ selectedRight: string | null;
628
+ wrongPair: [string, string] | null;
629
+ hintPair: string | null;
630
+ onPickLeft: (w: string) => void;
631
+ onPickRight: (w: string) => void;
632
+ }) {
633
+ const containerRef = useRef<HTMLDivElement>(null);
634
+ const leftRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
635
+ const rightRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
636
+ const [lines, setLines] = useState<
637
+ { x1: number; y1: number; x2: number; y2: number; color: string; dash?: boolean }[]
638
+ >([]);
639
+
640
+ /* Recalculate lines whenever selection / matches change */
641
+ useEffect(() => {
642
+ const container = containerRef.current;
643
+ if (!container) return;
644
+ const box = container.getBoundingClientRect();
645
+ const newLines: typeof lines = [];
646
+
647
+ // Matched lines (green solid)
648
+ for (const pair of pairs) {
649
+ if (!matched.includes(pair.left)) continue;
650
+ const lEl = leftRefs.current.get(pair.left);
651
+ const rEl = rightRefs.current.get(pair.right);
652
+ if (!lEl || !rEl) continue;
653
+ const lb = lEl.getBoundingClientRect();
654
+ const rb = rEl.getBoundingClientRect();
655
+ newLines.push({
656
+ x1: lb.right - box.left,
657
+ y1: lb.top + lb.height / 2 - box.top,
658
+ x2: rb.left - box.left,
659
+ y2: rb.top + rb.height / 2 - box.top,
660
+ color: "#58CC02",
661
+ });
662
+ }
663
+
664
+ // Active selection line (teal dashed)
665
+ if (selectedLeft && selectedRight) {
666
+ const lEl = leftRefs.current.get(selectedLeft);
667
+ const rEl = rightRefs.current.get(selectedRight);
668
+ if (lEl && rEl) {
669
+ const lb = lEl.getBoundingClientRect();
670
+ const rb = rEl.getBoundingClientRect();
671
+ newLines.push({
672
+ x1: lb.right - box.left,
673
+ y1: lb.top + lb.height / 2 - box.top,
674
+ x2: rb.left - box.left,
675
+ y2: rb.top + rb.height / 2 - box.top,
676
+ color: "#7AC7C4",
677
+ dash: true,
678
+ });
679
+ }
680
+ }
681
+
682
+ setLines(newLines);
683
+ }, [matched, selectedLeft, selectedRight, pairs]);
684
+
685
+ return (
686
+ <div ref={containerRef} className="relative flex gap-12 w-full mx-auto">
687
+ {/* SVG overlay for lines */}
688
+ <svg className="absolute inset-0 w-full h-full pointer-events-none z-20">
689
+ {lines.map((l, i) => (
690
+ <line
691
+ key={i}
692
+ x1={l.x1}
693
+ y1={l.y1}
694
+ x2={l.x2}
695
+ y2={l.y2}
696
+ stroke={l.color}
697
+ strokeWidth="2.5"
698
+ strokeLinecap="round"
699
+ strokeDasharray={l.dash ? "6 4" : "none"}
700
+ />
701
+ ))}
702
+ </svg>
703
+
704
+ {/* Left column */}
705
+ <div className="flex-1 flex flex-col gap-5">
706
+ {pairs.map((p) => {
707
+ const isMatched = matched.includes(p.left);
708
+ const isSelected = selectedLeft === p.left;
709
+ const isWrong = wrongPair?.[0] === p.left;
710
+ const isHint = hintPair === p.left;
711
+ return (
712
+ <motion.button
713
+ key={p.left}
714
+ ref={(el) => { if (el) leftRefs.current.set(p.left, el); }}
715
+ onClick={() => onPickLeft(p.left)}
716
+ className={`relative rounded-2xl px-8 py-6 text-center font-heading font-bold text-xl
717
+ border-2 border-b-4 transition-all ${
718
+ isMatched
719
+ ? "bg-brand-green/10 border-brand-green text-brand-green"
720
+ : isWrong
721
+ ? "bg-red-50 border-red-400 text-red-500 animate-shake"
722
+ : isHint
723
+ ? "bg-amber-50 border-amber-400 text-amber-600"
724
+ : isSelected
725
+ ? "bg-brand-teal/10 border-brand-teal text-brand-teal shadow-md"
726
+ : "bg-white border-brand-gray-200 text-brand-gray-700 hover:border-brand-teal/40"
727
+ }`}
728
+ whileTap={isMatched ? {} : { scale: 0.95 }}
729
+ >
730
+ {p.left}
731
+ {isMatched && (
732
+ <motion.span
733
+ initial={{ scale: 0 }}
734
+ animate={{ scale: 1 }}
735
+ className="absolute -top-1.5 -right-1.5 h-5 w-5 bg-brand-green rounded-full flex items-center justify-center"
736
+ >
737
+ <svg viewBox="0 0 24 24" className="h-3 w-3 text-white" fill="none" stroke="currentColor" strokeWidth="3">
738
+ <path d="M20 6L9 17l-5-5" />
739
+ </svg>
740
+ </motion.span>
741
+ )}
742
+ </motion.button>
743
+ );
744
+ })}
745
+ </div>
746
+
747
+ {/* Right column */}
748
+ <div className="flex-1 flex flex-col gap-5">
749
+ {shuffledRight.map((word) => {
750
+ const pairForWord = pairs.find((p) => p.right === word);
751
+ const isMatched = pairForWord ? matched.includes(pairForWord.left) : false;
752
+ const isSelected = selectedRight === word;
753
+ const isWrong = wrongPair?.[1] === word;
754
+ return (
755
+ <motion.button
756
+ key={word}
757
+ ref={(el) => { if (el) rightRefs.current.set(word, el); }}
758
+ onClick={() => onPickRight(word)}
759
+ className={`relative rounded-2xl px-8 py-6 text-center font-heading font-bold text-xl
760
+ border-2 border-b-4 transition-all ${
761
+ isMatched
762
+ ? "bg-brand-green/10 border-brand-green text-brand-green"
763
+ : isWrong
764
+ ? "bg-red-50 border-red-400 text-red-500 animate-shake"
765
+ : isSelected
766
+ ? "bg-brand-teal/10 border-brand-teal text-brand-teal shadow-md"
767
+ : "bg-white border-brand-gray-200 text-brand-gray-700 hover:border-brand-teal/40"
768
+ }`}
769
+ whileTap={isMatched ? {} : { scale: 0.95 }}
770
+ >
771
+ {word}
772
+ {isMatched && (
773
+ <motion.span
774
+ initial={{ scale: 0 }}
775
+ animate={{ scale: 1 }}
776
+ className="absolute -top-1.5 -right-1.5 h-5 w-5 bg-brand-green rounded-full flex items-center justify-center"
777
+ >
778
+ <svg viewBox="0 0 24 24" className="h-3 w-3 text-white" fill="none" stroke="currentColor" strokeWidth="3">
779
+ <path d="M20 6L9 17l-5-5" />
780
+ </svg>
781
+ </motion.span>
782
+ )}
783
+ </motion.button>
784
+ );
785
+ })}
786
+ </div>
787
+ </div>
788
+ );
789
+ }
790
+
791
+ /* ═══════════════════ Feedback Overlay ═══════════════════ */
792
+
793
+ function FeedbackOverlay({
794
+ type,
795
+ onContinue,
796
+ showConfetti,
797
+ }: {
798
+ type: "correct" | "incorrect";
799
+ onContinue: () => void;
800
+ showConfetti: boolean;
801
+ }) {
802
+ const isCorrect = type === "correct";
803
+
804
+ return (
805
+ <motion.div
806
+ initial={{ y: "100%" }}
807
+ animate={{ y: 0 }}
808
+ exit={{ y: "100%" }}
809
+ transition={{ type: "spring", damping: 26, stiffness: 300 }}
810
+ className="fixed inset-x-0 bottom-0 z-50"
811
+ >
812
+ {/* Confetti layer */}
813
+ {showConfetti && <ConfettiParticles />}
814
+
815
+ {/* Panel */}
816
+ <div
817
+ className={`relative rounded-t-3xl px-6 pt-6 pb-8 shadow-2xl ${
818
+ isCorrect
819
+ ? "bg-gradient-to-br from-[#e8fce8] to-[#c9f5c9]"
820
+ : "bg-gradient-to-br from-[#fde8e8] to-[#f5c9c9]"
821
+ }`}
822
+ >
823
+ <div className="flex items-center gap-4 mb-5">
824
+ {/* Icon */}
825
+ <div
826
+ className={`h-14 w-14 rounded-full flex items-center justify-center ${
827
+ isCorrect ? "bg-brand-green" : "bg-brand-coral"
828
+ }`}
829
+ >
830
+ {isCorrect ? (
831
+ <svg viewBox="0 0 24 24" className="h-7 w-7 text-white" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round">
832
+ <path d="M20 6L9 17l-5-5" />
833
+ </svg>
834
+ ) : (
835
+ <svg viewBox="0 0 24 24" className="h-7 w-7 text-white" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round">
836
+ <path d="M18 6L6 18M6 6l12 12" />
837
+ </svg>
838
+ )}
839
+ </div>
840
+
841
+ <div>
842
+ <h3
843
+ className={`font-heading font-extrabold text-2xl ${
844
+ isCorrect ? "text-green-700" : "text-red-600"
845
+ }`}
846
+ >
847
+ {isCorrect ? "Excellent!" : "Not quite right"}
848
+ </h3>
849
+ <p className={`text-sm ${isCorrect ? "text-green-600" : "text-red-500"}`}>
850
+ {isCorrect
851
+ ? "You matched all pairs correctly!"
852
+ : "Review the pairs and try again."}
853
+ </p>
854
+ </div>
855
+ </div>
856
+
857
+ <GameButton
858
+ variant={isCorrect ? "primary" : "secondary"}
859
+ onClick={onContinue}
860
+ className="w-full"
861
+ >
862
+ {isCorrect ? "CONTINUE" : "GOT IT"}
863
+ </GameButton>
864
+ </div>
865
+ </motion.div>
866
+ );
867
+ }
868
+
869
+ /* ═══════════════════ Confetti ═══════════════════ */
870
+
871
+ function ConfettiParticles() {
872
+ const particles = useMemo(
873
+ () =>
874
+ Array.from({ length: 48 }, (_, i) => ({
875
+ id: i,
876
+ x: Math.random() * 100,
877
+ delay: Math.random() * 0.5,
878
+ duration: 1.5 + Math.random() * 1.5,
879
+ color: ["#58CC02", "#FFD700", "#E8734A", "#7AC7C4", "#FF6BA8", "#4FC3F7"][
880
+ i % 6
881
+ ],
882
+ size: 4 + Math.random() * 6,
883
+ rotation: Math.random() * 360,
884
+ })),
885
+ []
886
+ );
887
+
888
+ return (
889
+ <div className="absolute inset-0 pointer-events-none overflow-hidden">
890
+ {particles.map((p) => (
891
+ <motion.div
892
+ key={p.id}
893
+ initial={{ y: -20, x: `${p.x}vw`, opacity: 1, rotate: 0 }}
894
+ animate={{
895
+ y: "100vh",
896
+ rotate: p.rotation + 720,
897
+ opacity: [1, 1, 0],
898
+ }}
899
+ transition={{
900
+ duration: p.duration,
901
+ delay: p.delay,
902
+ ease: "easeIn",
903
+ }}
904
+ className="absolute top-0"
905
+ style={{
906
+ width: p.size,
907
+ height: p.size,
908
+ borderRadius: p.size > 7 ? "2px" : "50%",
909
+ backgroundColor: p.color,
910
+ }}
911
+ />
912
+ ))}
913
+ </div>
914
+ );
915
+ }
916
+
917
+ /* ═══════════════════ Owl Mascot ═══════════════════ */
918
+
919
+ function OwlMascot() {
920
+ return (
921
+ <svg viewBox="0 0 80 80" className="h-14 w-14 flex-shrink-0" fill="none">
922
+ {/* Body */}
923
+ <ellipse cx="40" cy="52" rx="22" ry="20" fill="#E8A855" />
924
+ <ellipse cx="40" cy="54" rx="16" ry="14" fill="#F5DEB3" />
925
+ {/* Eyes */}
926
+ <circle cx="32" cy="40" r="9" fill="white" />
927
+ <circle cx="48" cy="40" r="9" fill="white" />
928
+ <circle cx="33" cy="40" r="5" fill="#2D2D2D" />
929
+ <circle cx="47" cy="40" r="5" fill="#2D2D2D" />
930
+ <circle cx="34.5" cy="38.5" r="1.8" fill="white" />
931
+ <circle cx="48.5" cy="38.5" r="1.8" fill="white" />
932
+ {/* Beak */}
933
+ <polygon points="40,44 37,48 43,48" fill="#E8734A" />
934
+ {/* Ear tufts */}
935
+ <polygon points="22,30 26,38 18,36" fill="#D4943D" />
936
+ <polygon points="58,30 54,38 62,36" fill="#D4943D" />
937
+ {/* Feet */}
938
+ <ellipse cx="33" cy="72" rx="5" ry="3" fill="#E8734A" />
939
+ <ellipse cx="47" cy="72" rx="5" ry="3" fill="#E8734A" />
940
+ </svg>
941
+ );
942
+ }
943
+
944
+ /* ═══════════════════ Remedy Popup ═══════════════════ */
945
+
946
+ function RemedyPopup({ threshold, onDismiss }: { threshold: number; onDismiss: () => void }) {
947
+ return (
948
+ <motion.div
949
+ initial={{ opacity: 0 }}
950
+ animate={{ opacity: 1 }}
951
+ exit={{ opacity: 0 }}
952
+ className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 backdrop-blur-sm"
953
+ onClick={onDismiss}
954
+ >
955
+ <motion.div
956
+ initial={{ scale: 0.8, opacity: 0 }}
957
+ animate={{ scale: 1, opacity: 1 }}
958
+ exit={{ scale: 0.8, opacity: 0 }}
959
+ transition={{ type: "spring", damping: 20 }}
960
+ className="bg-white rounded-3xl p-8 max-w-sm mx-4 shadow-2xl text-center"
961
+ onClick={(e) => e.stopPropagation()}
962
+ >
963
+ {/* Remedy icon */}
964
+ <div className="mx-auto w-20 h-20 rounded-full bg-gradient-to-br from-orange-100 to-amber-100 flex items-center justify-center mb-5">
965
+ <svg viewBox="0 0 48 48" className="w-12 h-12" fill="none">
966
+ {/* Book / study icon */}
967
+ <rect x="8" y="10" width="32" height="28" rx="3" fill="#F59E0B" />
968
+ <rect x="10" y="12" width="28" height="24" rx="2" fill="#FEF3C7" />
969
+ <line x1="24" y1="12" x2="24" y2="36" stroke="#F59E0B" strokeWidth="1.5" />
970
+ <line x1="15" y1="18" x2="22" y2="18" stroke="#D97706" strokeWidth="1.5" strokeLinecap="round" />
971
+ <line x1="15" y1="22" x2="21" y2="22" stroke="#D97706" strokeWidth="1.5" strokeLinecap="round" />
972
+ <line x1="15" y1="26" x2="20" y2="26" stroke="#D97706" strokeWidth="1.5" strokeLinecap="round" />
973
+ <line x1="26" y1="18" x2="33" y2="18" stroke="#D97706" strokeWidth="1.5" strokeLinecap="round" />
974
+ <line x1="26" y1="22" x2="32" y2="22" stroke="#D97706" strokeWidth="1.5" strokeLinecap="round" />
975
+ <line x1="26" y1="26" x2="31" y2="26" stroke="#D97706" strokeWidth="1.5" strokeLinecap="round" />
976
+ {/* Star badge */}
977
+ <circle cx="38" cy="10" r="8" fill="#EF4444" />
978
+ <text x="38" y="14" textAnchor="middle" fill="white" fontSize="10" fontWeight="bold">!</text>
979
+ </svg>
980
+ </div>
981
+
982
+ <h3 className="font-heading font-extrabold text-xl text-brand-gray-700 mb-2">
983
+ 需要額外練習!
984
+ </h3>
985
+ <p className="text-sm text-brand-gray-500 mb-1">
986
+ 你已連續答錯 {threshold} 題
987
+ </p>
988
+ <p className="text-sm text-brand-gray-400 mb-6">
989
+ 在地圖上已為你新增一個<span className="text-amber-600 font-bold">補償關卡</span>,完成它來鞏固基礎知識!
990
+ </p>
991
+
992
+ <button
993
+ onClick={onDismiss}
994
+ className="w-full py-3 rounded-2xl bg-gradient-to-r from-amber-400 to-amber-500 text-white font-heading font-bold text-base shadow-md shadow-amber-300/30 hover:shadow-lg transition-all active:scale-95"
995
+ >
996
+ 我知道了
997
+ </button>
998
+ </motion.div>
999
+ </motion.div>
1000
+ );
1001
+ }
app/(arena)/play/[nodeId]/page.tsx ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import ArenaClient from "./ArenaClient";
2
+
3
+ export function generateStaticParams() {
4
+ const nodeIds = [
5
+ ...Array.from({ length: 15 }, (_, i) => `med-n${i + 1}`),
6
+ ...Array.from({ length: 15 }, (_, i) => `calc-n${i + 1}`),
7
+ ...Array.from({ length: 15 }, (_, i) => `demo-n${i + 1}`),
8
+ ];
9
+ return nodeIds.map((nodeId) => ({ nodeId }));
10
+ }
11
+
12
+ export default function PlayPage() {
13
+ return <ArenaClient />;
14
+ }
app/(arena)/play/[nodeId]/result/ResultClient.tsx ADDED
@@ -0,0 +1,514 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import React, { useMemo, useState, useEffect, useRef } from "react";
4
+ import { motion, AnimatePresence } from "framer-motion";
5
+ import { useRouter, useParams } from "next/navigation";
6
+ import GameButton from "@/components/ui/GameButton";
7
+ import useArenaStore, { getAccuracy, getElapsedTime, getXpGained } from "@/stores/useArenaStore";
8
+ import useUserStore from "@/stores/useUserStore";
9
+ import useCourseStore from "@/stores/useCourseStore";
10
+
11
+ /* ═══════════════════ Rolling Counter Hook ═══════════════════ */
12
+
13
+ function useRollingNumber(target: number, duration = 1200, delay = 800) {
14
+ const [value, setValue] = useState(0);
15
+ useEffect(() => {
16
+ const timeout = setTimeout(() => {
17
+ const start = performance.now();
18
+ const tick = (now: number) => {
19
+ const elapsed = now - start;
20
+ const progress = Math.min(elapsed / duration, 1);
21
+ // ease-out cubic
22
+ const eased = 1 - Math.pow(1 - progress, 3);
23
+ setValue(Math.round(target * eased));
24
+ if (progress < 1) requestAnimationFrame(tick);
25
+ };
26
+ requestAnimationFrame(tick);
27
+ }, delay);
28
+ return () => clearTimeout(timeout);
29
+ }, [target, duration, delay]);
30
+ return value;
31
+ }
32
+
33
+ /* ═══════════════════ Page ═══════════════════ */
34
+
35
+ export default function ResultClient() {
36
+ const router = useRouter();
37
+ const params = useParams();
38
+ const nodeId = params.nodeId as string;
39
+
40
+ // Stores
41
+ const arenaState = useArenaStore();
42
+ const addXp = useUserStore((s) => s.addXp);
43
+ const completeNode = useCourseStore((s) => s.completeNode);
44
+ const endSession = useArenaStore((s) => s.endSession);
45
+
46
+ // Derived values from arena
47
+ const actualAccuracy = getAccuracy(arenaState);
48
+ const actualTime = getElapsedTime(arenaState);
49
+ const actualXp = getXpGained(arenaState);
50
+
51
+ // Ref to capture user state RIGHT BEFORE addXp (set inside effect, not at render time)
52
+ const prevUserRef = useRef<{ xp: number; level: number; xpToNext: number } | null>(null);
53
+
54
+ // End session & reward on mount (once)
55
+ const [rewarded, setRewarded] = useState(false);
56
+ useEffect(() => {
57
+ if (!rewarded) {
58
+ // Snapshot BEFORE reward so animation knows the starting point
59
+ const snap = useUserStore.getState();
60
+ prevUserRef.current = { xp: snap.xp, level: snap.level, xpToNext: snap.xpToNextLevel };
61
+
62
+ endSession();
63
+ addXp(actualXp);
64
+ if (nodeId) completeNode(nodeId);
65
+ setRewarded(true);
66
+ }
67
+ }, [rewarded, endSession, addXp, actualXp, nodeId, completeNode]);
68
+
69
+ // Read user state AFTER reward (reactive)
70
+ const currentXp = useUserStore((s) => s.xp);
71
+ const currentLevel = useUserStore((s) => s.level);
72
+ const currentXpToNext = useUserStore((s) => s.xpToNextLevel);
73
+
74
+ const courseId = arenaState.courseId ?? "med-u1";
75
+
76
+ // Bar state — starts at 0; will be set to correct start position when animation begins
77
+ const [showLevelUp, setShowLevelUp] = useState(false);
78
+ const [xpBarWidth, setXpBarWidth] = useState(0);
79
+ const [barDuration, setBarDuration] = useState(0); // 0 = instant (no visible animation for initial placement)
80
+ const [displayLevel, setDisplayLevel] = useState(1);
81
+
82
+ const accuracy = useRollingNumber(actualAccuracy, 1000, 1200);
83
+ const xpGained = useRollingNumber(actualXp, 1000, 1200);
84
+
85
+ // Multi-phase XP bar animation — fires AFTER reward so store is up-to-date.
86
+ const animStarted = useRef(false);
87
+ useEffect(() => {
88
+ if (!rewarded || animStarted.current || !prevUserRef.current) return;
89
+ animStarted.current = true;
90
+
91
+ const prev = prevUserRef.current;
92
+ const post = useUserStore.getState();
93
+ const leveled = post.level > prev.level;
94
+ const startPct = prev.xpToNext > 0 ? (prev.xp / prev.xpToNext) * 100 : 0;
95
+ const endPct = post.xpToNextLevel > 0 ? (post.xp / post.xpToNextLevel) * 100 : 0;
96
+
97
+ // Phase 0 (instant): jump bar to pre-reward position & set level label
98
+ setDisplayLevel(prev.level);
99
+ setBarDuration(0);
100
+ setXpBarWidth(startPct);
101
+
102
+ if (leveled) {
103
+ // Phase 1: fill current bar to 100%
104
+ const t1 = setTimeout(() => {
105
+ setBarDuration(1.0);
106
+ setXpBarWidth(100);
107
+ }, 1800);
108
+ // Phase 2: show "Level Up!" text
109
+ const t2 = setTimeout(() => setShowLevelUp(true), 3000);
110
+ // Phase 3: instant reset to 0% & update level label
111
+ const t3 = setTimeout(() => {
112
+ setBarDuration(0);
113
+ setXpBarWidth(0);
114
+ setDisplayLevel(post.level);
115
+ }, 3600);
116
+ // Phase 4: animate to new XP position
117
+ const t4 = setTimeout(() => {
118
+ setBarDuration(0.8);
119
+ setXpBarWidth(endPct);
120
+ }, 3750);
121
+ return () => { clearTimeout(t1); clearTimeout(t2); clearTimeout(t3); clearTimeout(t4); };
122
+ } else {
123
+ // No level up: smoothly fill from prev to current
124
+ const t = setTimeout(() => {
125
+ setBarDuration(1.2);
126
+ setXpBarWidth(endPct);
127
+ }, 1800);
128
+ return () => clearTimeout(t);
129
+ }
130
+ }, [rewarded]);
131
+
132
+ return (
133
+ <div className="relative min-h-screen flex flex-col items-center justify-center overflow-hidden">
134
+ {/* ─── Dark background with spotlight ─── */}
135
+ <div className="absolute inset-0 bg-gradient-to-b from-[#1a1a2e] via-[#16213e] to-[#0f0f23]" />
136
+ <div
137
+ className="absolute inset-0"
138
+ style={{
139
+ background:
140
+ "radial-gradient(ellipse 60% 50% at 50% 40%, rgba(255,215,0,0.15) 0%, rgba(255,180,50,0.06) 40%, transparent 70%)",
141
+ }}
142
+ />
143
+
144
+ {/* ─── Confetti ─── */}
145
+ <VictoryConfetti />
146
+
147
+ {/* ─── Decorative corner stars ─── */}
148
+ <DecorativeStars />
149
+
150
+ {/* ─── Content ─── */}
151
+ <div className="relative z-10 flex flex-col items-center w-full max-w-lg px-6">
152
+ {/* LESSON CLEARED! */}
153
+ <motion.h1
154
+ initial={{ opacity: 0, scale: 0.5, y: -30 }}
155
+ animate={{ opacity: 1, scale: 1, y: 0 }}
156
+ transition={{ type: "spring", damping: 10, stiffness: 120, delay: 0.1 }}
157
+ className="font-heading font-extrabold text-4xl sm:text-5xl text-transparent bg-clip-text bg-gradient-to-b from-yellow-200 via-yellow-400 to-amber-500 mb-4 text-center tracking-tight"
158
+ style={{
159
+ textShadow: "0 0 40px rgba(255,215,0,0.4), 0 2px 8px rgba(0,0,0,0.6)",
160
+ WebkitTextStroke: "0.5px rgba(255,215,0,0.3)",
161
+ }}
162
+ >
163
+ LESSON CLEARED!
164
+ </motion.h1>
165
+
166
+ {/* ─── Owl + Chest assembly ─── */}
167
+ <motion.div
168
+ initial={{ y: -160, opacity: 0 }}
169
+ animate={{ y: 0, opacity: 1 }}
170
+ transition={{ type: "spring", damping: 11, stiffness: 140, delay: 0.3 }}
171
+ className="relative mb-6"
172
+ >
173
+ {/* Glow behind chest */}
174
+ <motion.div
175
+ className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-52 h-52 rounded-full"
176
+ style={{
177
+ background:
178
+ "radial-gradient(circle, rgba(255,215,0,0.25) 0%, rgba(255,180,50,0.08) 50%, transparent 70%)",
179
+ }}
180
+ animate={{ scale: [1, 1.15, 1], opacity: [0.7, 1, 0.7] }}
181
+ transition={{ repeat: Infinity, duration: 3, ease: "easeInOut" }}
182
+ />
183
+
184
+ {/* Owl with graduation cap */}
185
+ <div className="relative z-10 flex flex-col items-center">
186
+ <GraduationOwl />
187
+ <div className="-mt-4">
188
+ <TreasureChest />
189
+ </div>
190
+ </div>
191
+ </motion.div>
192
+
193
+ {/* ─── Stats Card ─── */}
194
+ <motion.div
195
+ initial={{ opacity: 0, y: 60 }}
196
+ animate={{ opacity: 1, y: 0 }}
197
+ transition={{ type: "spring", damping: 20, stiffness: 180, delay: 0.7 }}
198
+ className="w-full rounded-2xl bg-white/10 backdrop-blur-xl border border-white/15 p-5 mb-5 shadow-lg shadow-black/20"
199
+ >
200
+ {/* Stats row */}
201
+ <div className="flex justify-between items-start mb-4">
202
+ {/* Accuracy */}
203
+ <div className="flex-1 text-center">
204
+ <div className="text-xs text-white/50 font-medium mb-1">Accuracy:</div>
205
+ <div className="font-heading font-extrabold text-2xl sm:text-3xl text-white tabular-nums">
206
+ {accuracy}%
207
+ </div>
208
+ </div>
209
+ {/* Divider */}
210
+ <div className="w-px h-12 bg-white/10 mx-2 self-center" />
211
+ {/* Time */}
212
+ <div className="flex-1 text-center">
213
+ <div className="text-xs text-white/50 font-medium mb-1">Time:</div>
214
+ <div className="font-heading font-extrabold text-2xl sm:text-3xl text-white tabular-nums">
215
+ {actualTime}
216
+ </div>
217
+ </div>
218
+ {/* Divider */}
219
+ <div className="w-px h-12 bg-white/10 mx-2 self-center" />
220
+ {/* XP */}
221
+ <div className="flex-1 text-center">
222
+ <div className="text-xs text-white/50 font-medium mb-1">XP Gained:</div>
223
+ <div className="font-heading font-extrabold text-2xl sm:text-3xl text-amber-300 tabular-nums">
224
+ +{xpGained} XP
225
+ </div>
226
+ </div>
227
+ </div>
228
+
229
+ {/* Level Up! flash */}
230
+ <AnimatePresence>
231
+ {showLevelUp && (
232
+ <motion.div
233
+ initial={{ opacity: 0, scale: 0.5 }}
234
+ animate={{ opacity: 1, scale: 1 }}
235
+ exit={{ opacity: 0 }}
236
+ className="text-right mb-2"
237
+ >
238
+ <span
239
+ className="font-heading font-extrabold text-lg italic bg-clip-text text-transparent bg-gradient-to-r from-amber-300 via-yellow-400 to-orange-400"
240
+ style={{ textShadow: "0 0 20px rgba(255,215,0,0.5)" }}
241
+ >
242
+ Level Up!
243
+ </span>
244
+ </motion.div>
245
+ )}
246
+ </AnimatePresence>
247
+
248
+ {/* XP progress bar */}
249
+ <div className="flex items-center justify-between text-[10px] text-white/40 font-bold mb-1 px-0.5">
250
+ <span>Lv.{displayLevel}</span>
251
+ <span className="tabular-nums">
252
+ {Math.round(currentXp)} / {currentXpToNext} XP
253
+ </span>
254
+ <span>Lv.{displayLevel + 1}</span>
255
+ </div>
256
+ <div className="w-full h-5 rounded-full bg-black/30 border border-white/10 overflow-hidden relative">
257
+ <motion.div
258
+ className="h-full rounded-full bg-gradient-to-r from-brand-green via-emerald-400 to-brand-green relative"
259
+ animate={{ width: `${Math.max(xpBarWidth, 0)}%` }}
260
+ transition={{ duration: barDuration, ease: "easeOut" }}
261
+ >
262
+ {/* Shimmer effect on bar */}
263
+ <div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/25 to-transparent animate-shimmer-bar" />
264
+ </motion.div>
265
+ {/* Glow */}
266
+ <motion.div
267
+ className="absolute inset-0 rounded-full"
268
+ animate={{
269
+ boxShadow: [
270
+ "inset 0 0 6px rgba(88,204,2,0.3)",
271
+ "inset 0 0 12px rgba(88,204,2,0.5)",
272
+ "inset 0 0 6px rgba(88,204,2,0.3)",
273
+ ],
274
+ }}
275
+ transition={{ repeat: Infinity, duration: 2 }}
276
+ />
277
+ </div>
278
+ </motion.div>
279
+
280
+ {/* ─── Back to Map button ─── */}
281
+ <motion.div
282
+ initial={{ opacity: 0, y: 30 }}
283
+ animate={{ opacity: 1, y: 0 }}
284
+ transition={{ delay: 1.4 }}
285
+ className="w-full"
286
+ >
287
+ <button
288
+ onClick={() => router.push(`/map/${courseId}`)}
289
+ className="relative w-full py-4 rounded-2xl font-heading font-extrabold text-lg text-white
290
+ bg-gradient-to-r from-brand-green to-emerald-500
291
+ border-b-4 border-green-700
292
+ shadow-[0_0_30px_rgba(88,204,2,0.35)]
293
+ hover:shadow-[0_0_40px_rgba(88,204,2,0.5)]
294
+ active:scale-[0.97] active:border-b-2
295
+ transition-all duration-200"
296
+ >
297
+ {/* Button glow pulse */}
298
+ <motion.div
299
+ className="absolute inset-0 rounded-2xl bg-gradient-to-r from-brand-green/0 via-white/10 to-brand-green/0"
300
+ animate={{ opacity: [0, 0.5, 0] }}
301
+ transition={{ repeat: Infinity, duration: 2, ease: "easeInOut" }}
302
+ />
303
+ <span className="relative z-10">Back to Map</span>
304
+ </button>
305
+ </motion.div>
306
+ </div>
307
+ </div>
308
+ );
309
+ }
310
+
311
+ /* ═══════════════════ Graduation Owl ═══════════════════ */
312
+
313
+ function GraduationOwl() {
314
+ return (
315
+ <motion.svg
316
+ viewBox="0 0 100 90"
317
+ className="h-24 w-24"
318
+ fill="none"
319
+ animate={{ y: [0, -3, 0] }}
320
+ transition={{ repeat: Infinity, duration: 2.5, ease: "easeInOut" }}
321
+ >
322
+ {/* Wings spread */}
323
+ <ellipse cx="14" cy="52" rx="11" ry="16" fill="#D4943D" transform="rotate(-20 14 52)" />
324
+ <ellipse cx="86" cy="52" rx="11" ry="16" fill="#D4943D" transform="rotate(20 86 52)" />
325
+ {/* Body */}
326
+ <ellipse cx="50" cy="58" rx="22" ry="20" fill="#E8E1D5" />
327
+ <ellipse cx="50" cy="60" rx="16" ry="15" fill="#F5F0E8" />
328
+ {/* Eyes with glasses */}
329
+ <circle cx="38" cy="48" r="10" fill="white" stroke="#888" strokeWidth="1.5" />
330
+ <circle cx="62" cy="48" r="10" fill="white" stroke="#888" strokeWidth="1.5" />
331
+ <line x1="48" y1="48" x2="52" y2="48" stroke="#888" strokeWidth="1.5" />
332
+ <circle cx="39" cy="48" r="5" fill="#2D2D2D" />
333
+ <circle cx="61" cy="48" r="5" fill="#2D2D2D" />
334
+ <circle cx="40.5" cy="46.5" r="1.8" fill="white" />
335
+ <circle cx="62.5" cy="46.5" r="1.8" fill="white" />
336
+ {/* Beak */}
337
+ <polygon points="50,52 47,57 53,57" fill="#E8734A" />
338
+ {/* Ear tufts */}
339
+ <polygon points="28,32 33,44 22,38" fill="#C9B89E" />
340
+ <polygon points="72,32 67,44 78,38" fill="#C9B89E" />
341
+ {/* Graduation cap */}
342
+ <polygon points="50,18 30,28 50,34 70,28" fill="#2D2D2D" />
343
+ <rect x="48" y="14" width="4" height="6" fill="#2D2D2D" />
344
+ <line x1="70" y1="28" x2="72" y2="38" stroke="#FFD700" strokeWidth="1.5" />
345
+ <circle cx="72" cy="39" r="2.5" fill="#FFD700" />
346
+ </motion.svg>
347
+ );
348
+ }
349
+
350
+ /* ═══════════════════ Treasure Chest ═══════════════════ */
351
+
352
+ function TreasureChest() {
353
+ return (
354
+ <svg viewBox="0 0 160 110" className="h-28 w-40" fill="none">
355
+ {/* Shadow */}
356
+ <ellipse cx="80" cy="105" rx="60" ry="6" fill="rgba(0,0,0,0.25)" />
357
+ {/* Chest body */}
358
+ <rect x="20" y="50" width="120" height="50" rx="6" fill="url(#chestBody)" stroke="#8B6914" strokeWidth="2" />
359
+ {/* Chest lid */}
360
+ <path d="M18 52 Q80 10 142 52" fill="url(#chestLid)" stroke="#8B6914" strokeWidth="2" />
361
+ {/* Gold trim bands */}
362
+ <rect x="18" y="48" width="124" height="6" rx="2" fill="#DAA520" stroke="#8B6914" strokeWidth="1" />
363
+ <rect x="20" y="72" width="120" height="4" rx="1" fill="#DAA520" opacity="0.6" />
364
+ {/* Lock clasp */}
365
+ <rect x="72" y="46" width="16" height="14" rx="3" fill="#DAA520" stroke="#8B6914" strokeWidth="1.5" />
366
+ <circle cx="80" cy="56" r="3" fill="#8B6914" />
367
+ {/* Stars on chest */}
368
+ <StarShape cx={42} cy={68} r={5} fill="#58CC02" />
369
+ <StarShape cx={80} cy={82} r={6} fill="#58CC02" />
370
+ <StarShape cx={118} cy={68} r={5} fill="#58CC02" />
371
+ <StarShape cx={55} cy={86} r={4} fill="#58CC02" opacity={0.7} />
372
+ <StarShape cx={105} cy={86} r={4} fill="#58CC02" opacity={0.7} />
373
+ {/* Gradient defs */}
374
+ <defs>
375
+ <linearGradient id="chestBody" x1="0" y1="0" x2="0" y2="1">
376
+ <stop offset="0%" stopColor="#4A9B3F" />
377
+ <stop offset="50%" stopColor="#3D8535" />
378
+ <stop offset="100%" stopColor="#2D6B28" />
379
+ </linearGradient>
380
+ <linearGradient id="chestLid" x1="0" y1="0" x2="0" y2="1">
381
+ <stop offset="0%" stopColor="#5BBF4E" />
382
+ <stop offset="100%" stopColor="#3D8535" />
383
+ </linearGradient>
384
+ </defs>
385
+ </svg>
386
+ );
387
+ }
388
+
389
+ function StarShape({ cx, cy, r, fill, opacity = 1 }: { cx: number; cy: number; r: number; fill: string; opacity?: number }) {
390
+ const points = Array.from({ length: 5 }, (_, i) => {
391
+ const outerAngle = (i * 72 - 90) * (Math.PI / 180);
392
+ const innerAngle = ((i * 72 + 36) - 90) * (Math.PI / 180);
393
+ const ox = cx + r * Math.cos(outerAngle);
394
+ const oy = cy + r * Math.sin(outerAngle);
395
+ const ix = cx + r * 0.4 * Math.cos(innerAngle);
396
+ const iy = cy + r * 0.4 * Math.sin(innerAngle);
397
+ return `${ox},${oy} ${ix},${iy}`;
398
+ }).join(" ");
399
+ return <polygon points={points} fill={fill} opacity={opacity} />;
400
+ }
401
+
402
+ /* ═══════════════════ Decorative Stars ═══════════════════ */
403
+
404
+ function DecorativeStars() {
405
+ const stars = useMemo(
406
+ () => [
407
+ { x: "8%", y: "15%", size: 18, delay: 0.5, opacity: 0.5 },
408
+ { x: "90%", y: "20%", size: 14, delay: 0.8, opacity: 0.4 },
409
+ { x: "5%", y: "75%", size: 12, delay: 1.2, opacity: 0.3 },
410
+ { x: "92%", y: "80%", size: 22, delay: 0.3, opacity: 0.5 },
411
+ { x: "15%", y: "45%", size: 10, delay: 1.5, opacity: 0.25 },
412
+ { x: "85%", y: "50%", size: 16, delay: 0.6, opacity: 0.35 },
413
+ { x: "50%", y: "90%", size: 12, delay: 1.0, opacity: 0.3 },
414
+ ],
415
+ []
416
+ );
417
+
418
+ return (
419
+ <div className="pointer-events-none absolute inset-0 z-[5]">
420
+ {stars.map((s, i) => (
421
+ <motion.div
422
+ key={i}
423
+ className="absolute"
424
+ style={{ left: s.x, top: s.y }}
425
+ initial={{ opacity: 0, scale: 0, rotate: 0 }}
426
+ animate={{
427
+ opacity: s.opacity,
428
+ scale: [0, 1, 0.8, 1],
429
+ rotate: [0, 15, -10, 0],
430
+ }}
431
+ transition={{
432
+ delay: s.delay,
433
+ duration: 1.5,
434
+ ease: "easeOut",
435
+ }}
436
+ >
437
+ <motion.svg
438
+ viewBox="0 0 24 24"
439
+ style={{ width: s.size, height: s.size }}
440
+ fill="#FFD700"
441
+ animate={{ rotate: [0, 360] }}
442
+ transition={{ repeat: Infinity, duration: 8 + i * 2, ease: "linear" }}
443
+ >
444
+ <polygon points="12,2 15,9 22,9 16,14 18,22 12,17 6,22 8,14 2,9 9,9" />
445
+ </motion.svg>
446
+ </motion.div>
447
+ ))}
448
+ </div>
449
+ );
450
+ }
451
+
452
+ /* ═══════════════════ Victory Confetti ═══════════════════ */
453
+
454
+ function VictoryConfetti() {
455
+ const particles = useMemo(
456
+ () =>
457
+ Array.from({ length: 80 }, (_, i) => {
458
+ const isStreak = i < 20; // first 20 are "firework streak" particles
459
+ return {
460
+ id: i,
461
+ x: isStreak ? 40 + Math.random() * 20 : Math.random() * 100,
462
+ startY: isStreak ? 45 : -5,
463
+ endY: isStreak ? -10 + Math.random() * 30 : 110,
464
+ delay: isStreak ? Math.random() * 0.3 : 0.2 + Math.random() * 1.5,
465
+ duration: isStreak ? 0.6 + Math.random() * 0.5 : 2 + Math.random() * 2.5,
466
+ color: [
467
+ "#FFD700", "#FFA500", "#FF6B6B", "#58CC02", "#7AC7C4",
468
+ "#FF6BA8", "#4FC3F7", "#A855F7", "#F59E0B",
469
+ ][i % 9],
470
+ size: isStreak ? 3 + Math.random() * 3 : 4 + Math.random() * 8,
471
+ rotation: Math.random() * 360,
472
+ spreadX: isStreak
473
+ ? (Math.random() - 0.5) * 60
474
+ : 0,
475
+ };
476
+ }),
477
+ []
478
+ );
479
+
480
+ return (
481
+ <div className="pointer-events-none absolute inset-0 overflow-hidden z-[2]">
482
+ {particles.map((p) => (
483
+ <motion.div
484
+ key={p.id}
485
+ initial={{
486
+ top: `${p.startY}%`,
487
+ left: `${p.x}%`,
488
+ opacity: 1,
489
+ rotate: 0,
490
+ x: 0,
491
+ }}
492
+ animate={{
493
+ top: `${p.endY}%`,
494
+ rotate: p.rotation + 540,
495
+ opacity: [1, 1, 0.6, 0],
496
+ x: p.spreadX,
497
+ }}
498
+ transition={{
499
+ duration: p.duration,
500
+ delay: p.delay,
501
+ ease: p.startY > 0 ? "easeOut" : "easeIn",
502
+ }}
503
+ className="absolute"
504
+ style={{
505
+ width: p.size,
506
+ height: p.size,
507
+ borderRadius: p.size > 7 ? "2px" : "50%",
508
+ backgroundColor: p.color,
509
+ }}
510
+ />
511
+ ))}
512
+ </div>
513
+ );
514
+ }
app/(arena)/play/[nodeId]/result/page.tsx ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import ResultClient from "./ResultClient";
2
+
3
+ export function generateStaticParams() {
4
+ const nodeIds = [
5
+ ...Array.from({ length: 15 }, (_, i) => `med-n${i + 1}`),
6
+ ...Array.from({ length: 15 }, (_, i) => `calc-n${i + 1}`),
7
+ ...Array.from({ length: 15 }, (_, i) => `demo-n${i + 1}`),
8
+ ];
9
+ return nodeIds.map((nodeId) => ({ nodeId }));
10
+ }
11
+
12
+ export default function ResultPage() {
13
+ return <ResultClient />;
14
+ }
app/(dashboard)/home/page.tsx ADDED
@@ -0,0 +1,620 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import React, { useRef, useEffect, useLayoutEffect, useState, useCallback } from "react";
4
+ import { motion, AnimatePresence } from "framer-motion";
5
+ import { useRouter } from "next/navigation";
6
+ import Link from "next/link";
7
+ import DeepGlassCard from "@/components/ui/DeepGlassCard";
8
+ import GameButton from "@/components/ui/GameButton";
9
+ import TopProgressBar from "@/components/ui/TopProgressBar";
10
+ import TopStatsBar from "@/components/shared/TopStatsBar";
11
+ import useUserStore from "@/stores/useUserStore";
12
+ import useCourseStore from "@/stores/useCourseStore";
13
+
14
+ /* ═══════════════════ Mock data ═══════════════════ */
15
+
16
+ const CURRENT_COURSE = {
17
+ title: "Mastering Conversational French",
18
+ subtitle: "Incompliars",
19
+ progress: 75,
20
+ thumbnail: <FrenchThumbnail />,
21
+ };
22
+
23
+ const STORE_COURSE_STYLE: Record<string, { bg: string; icon: React.ReactNode }> = {
24
+ "med-u1": { bg: "from-rose-200 to-rose-100", icon: <HeartIcon /> },
25
+ "calc-u1": { bg: "from-sky-200 to-indigo-100", icon: <CalculusIcon /> },
26
+ };
27
+
28
+ const LIBRARY_COURSES = [
29
+ { id: "ja", title: "Japanese", bg: "from-pink-200 to-pink-100", icon: <JapanIcon /> },
30
+ { id: "es", title: "Spanish", bg: "from-red-300 to-yellow-200", icon: <SpainIcon /> },
31
+ { id: "py", title: "Python", bg: "from-blue-100 to-blue-50", icon: <PythonIcon /> },
32
+ { id: "py2", title: "Python Advanced", bg: "from-emerald-400 to-emerald-300", icon: <PythonIcon2 />, highlighted: true },
33
+ { id: "gen", title: "Study Skills", bg: "from-amber-100 to-orange-50", icon: <GenericIcon /> },
34
+ { id: "ml", title: "ML Basics", bg: "from-violet-200 to-violet-100", icon: <MLIcon /> },
35
+ { id: "ds", title: "Data Science", bg: "from-cyan-200 to-cyan-100", icon: <DSIcon /> },
36
+ ];
37
+
38
+ /* ═══════════════════ Page ═══════════════════ */
39
+
40
+ export default function HomePage() {
41
+ const scrollRef = useRef<HTMLDivElement>(null);
42
+ const name = useUserStore((s) => s.name);
43
+ const lastActiveCourseId = useUserStore((s) => s.lastActiveCourseId);
44
+ const loadMockCourses = useCourseStore((s) => s.loadMockCourses);
45
+ const getCourse = useCourseStore((s) => s.getCourse);
46
+ const getCourseProgress = useCourseStore((s) => s.getCourseProgress);
47
+ const courses = useCourseStore((s) => s.courses);
48
+
49
+ useEffect(() => {
50
+ loadMockCourses();
51
+ }, [loadMockCourses]);
52
+
53
+ // Pick the active course (fallback to first available)
54
+ const activeCourseId = lastActiveCourseId ?? Object.keys(courses)[0] ?? null;
55
+ const activeCourse = activeCourseId ? getCourse(activeCourseId) : null;
56
+ const activeProgress = activeCourseId ? getCourseProgress(activeCourseId) : 0;
57
+
58
+ // Fallback for library courses not in store
59
+ const libCourse = LIBRARY_COURSES.find((c) => c.id === activeCourseId);
60
+ const resumeTitle = activeCourse?.unitTitle ?? libCourse?.title ?? "Course";
61
+ const resumeNodeCount = activeCourse?.nodes.length ?? 15;
62
+ const hasResumeCourse = !!(activeCourse || libCourse);
63
+
64
+ /* ── Forge drop zone ── */
65
+ const router = useRouter();
66
+ const fileInputRef = useRef<HTMLInputElement>(null);
67
+ const [isDragging, setIsDragging] = useState(false);
68
+ const [selectedFile, setSelectedFile] = useState<File | null>(null);
69
+
70
+ // Prevent browser from opening files dropped anywhere on the page
71
+ useEffect(() => {
72
+ const prevent = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); };
73
+ window.addEventListener("dragover", prevent);
74
+ window.addEventListener("drop", prevent);
75
+ return () => {
76
+ window.removeEventListener("dragover", prevent);
77
+ window.removeEventListener("drop", prevent);
78
+ };
79
+ }, []);
80
+
81
+ const handleFileAccepted = useCallback(
82
+ (file: File) => {
83
+ setSelectedFile(file);
84
+ // Brief flash then navigate to forge
85
+ setTimeout(() => router.push("/forge"), 600);
86
+ },
87
+ [router]
88
+ );
89
+
90
+ const onDrop = useCallback(
91
+ (e: React.DragEvent) => {
92
+ e.preventDefault();
93
+ e.stopPropagation();
94
+ setIsDragging(false);
95
+ const file = e.dataTransfer.files[0];
96
+ if (file && file.type === "application/pdf") handleFileAccepted(file);
97
+ },
98
+ [handleFileAccepted]
99
+ );
100
+
101
+ const onFileChange = useCallback(
102
+ (e: React.ChangeEvent<HTMLInputElement>) => {
103
+ const file = e.target.files?.[0];
104
+ if (file) handleFileAccepted(file);
105
+ },
106
+ [handleFileAccepted]
107
+ );
108
+
109
+ // ── Ensure library scroll starts from the left ──
110
+ // 1) Pre-paint reset (covers re-renders & soft-navigation)
111
+ useLayoutEffect(() => {
112
+ if (scrollRef.current) scrollRef.current.scrollLeft = 0;
113
+ });
114
+ // 2) Post-paint fallback (covers persist-rehydration & late renders)
115
+ const courseCount = Object.keys(courses).length;
116
+ useEffect(() => {
117
+ if (scrollRef.current) scrollRef.current.scrollLeft = 0;
118
+ }, [courseCount]);
119
+
120
+ const scrollLibrary = (dir: "left" | "right") => {
121
+ scrollRef.current?.scrollBy({
122
+ left: dir === "right" ? 260 : -260,
123
+ behavior: "smooth",
124
+ });
125
+ };
126
+
127
+ return (
128
+ <div className="relative overflow-hidden" style={{ zoom: 1.05 }}>
129
+ <TopStatsBar />
130
+
131
+ {/* ── Background decorations ── */}
132
+ <DashboardBg />
133
+
134
+ {/* ══════════════ Content ══════════════ */}
135
+ <div className="relative z-10 max-w-7xl mx-auto px-4 md:px-8 py-8 space-y-8">
136
+ {/* ── Top row: Continue Journey + Forge ── */}
137
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
138
+ {/* Continue Journey */}
139
+ <DeepGlassCard className="px-6 py-6 md:px-8 md:py-8">
140
+ <h2 className="font-heading text-2xl md:text-3xl font-extrabold text-brand-gray-700 mb-1">
141
+ Welcome back, {name}!
142
+ </h2>
143
+ <p className="text-sm text-brand-gray-400 mb-5">Continue your journey</p>
144
+
145
+ {hasResumeCourse ? (
146
+ <div className="flex gap-5 items-start">
147
+ {/* Thumbnail */}
148
+ <div className={`shrink-0 w-28 h-32 rounded-xl overflow-hidden shadow-md flex items-center justify-center bg-gradient-to-br ${(STORE_COURSE_STYLE[activeCourseId!] ?? (libCourse ? { bg: libCourse.bg } : { bg: "from-teal-100 to-teal-50" })).bg}`}>
149
+ {(STORE_COURSE_STYLE[activeCourseId!] ?? (libCourse ? { icon: libCourse.icon } : { icon: <GenericIcon /> })).icon}
150
+ </div>
151
+
152
+ {/* Info */}
153
+ <div className="flex-1 min-w-0">
154
+ <h3 className="font-heading font-bold text-brand-gray-700 text-lg leading-tight mb-1">
155
+ {resumeTitle}
156
+ </h3>
157
+ <p className="text-sm text-brand-gray-400 mb-3">
158
+ {resumeNodeCount} nodes
159
+ </p>
160
+
161
+ <TopProgressBar progress={activeProgress} className="mb-2" />
162
+ <p className="text-xs text-brand-gray-500 font-semibold mb-4">
163
+ {activeProgress}% Complete
164
+ </p>
165
+
166
+ <Link href={`/map/${activeCourseId}`}>
167
+ <GameButton className="w-full text-base">Resume</GameButton>
168
+ </Link>
169
+ </div>
170
+ </div>
171
+ ) : (
172
+ <p className="text-brand-gray-400 text-sm">No active courses yet. Forge a new one!</p>
173
+ )}
174
+ </DeepGlassCard>
175
+
176
+ {/* Forge a New Universe */}
177
+ <div className="flex flex-col">
178
+ <h2 className="font-heading text-2xl md:text-3xl font-extrabold text-brand-gray-700 mb-4">
179
+ Forge a New Universe
180
+ </h2>
181
+
182
+ {/* Hidden file input */}
183
+ <input
184
+ ref={fileInputRef}
185
+ type="file"
186
+ accept=".pdf,application/pdf"
187
+ className="hidden"
188
+ onChange={onFileChange}
189
+ />
190
+
191
+ <motion.div
192
+ onClick={() => fileInputRef.current?.click()}
193
+ onDragEnter={(e) => { e.preventDefault(); e.stopPropagation(); setIsDragging(true); }}
194
+ onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); }}
195
+ onDragLeave={(e) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); }}
196
+ onDrop={onDrop}
197
+ whileHover={{ scale: 1.02 }}
198
+ whileTap={{ scale: 0.98 }}
199
+ className={`relative flex-1 min-h-[200px] rounded-2xl border-2 border-dashed backdrop-blur-sm
200
+ flex flex-col items-center justify-center gap-4 cursor-pointer transition-all duration-200
201
+ ${
202
+ selectedFile
203
+ ? "border-brand-teal bg-brand-teal/10"
204
+ : isDragging
205
+ ? "border-brand-teal/80 bg-white/60 shadow-lg shadow-brand-teal/10"
206
+ : "border-brand-teal/40 bg-white/40 hover:border-brand-teal/70 hover:bg-white/50"
207
+ }`}
208
+ >
209
+ <AnimatePresence mode="wait">
210
+ {selectedFile ? (
211
+ <motion.div
212
+ key="accepted"
213
+ initial={{ scale: 0.8, opacity: 0 }}
214
+ animate={{ scale: 1, opacity: 1 }}
215
+ className="flex flex-col items-center gap-3"
216
+ >
217
+ <motion.div
218
+ animate={{ rotate: 360 }}
219
+ transition={{ repeat: Infinity, duration: 1.5, ease: "linear" }}
220
+ >
221
+ <svg width="40" height="40" viewBox="0 0 40 40" fill="none">
222
+ <circle cx="20" cy="20" r="18" stroke="#7AC7C4" strokeWidth="3" strokeDasharray="80 30" />
223
+ </svg>
224
+ </motion.div>
225
+ <p className="text-sm font-semibold text-brand-teal">{selectedFile.name}</p>
226
+ <p className="text-xs text-brand-gray-400">Preparing to forge…</p>
227
+ </motion.div>
228
+ ) : (
229
+ <motion.div
230
+ key="idle"
231
+ initial={{ opacity: 0 }}
232
+ animate={{ opacity: 1 }}
233
+ exit={{ opacity: 0 }}
234
+ className="flex flex-col items-center gap-4"
235
+ >
236
+ <motion.div animate={isDragging ? { scale: 1.15, y: -4 } : { scale: 1, y: 0 }}>
237
+ <PortalIcon />
238
+ </motion.div>
239
+ <p className="text-sm md:text-base text-brand-gray-500 text-center max-w-xs px-4">
240
+ {isDragging
241
+ ? "Release to upload your PDF"
242
+ : "Drop a PDF or click to select a file and forge a new learning path."}
243
+ </p>
244
+ </motion.div>
245
+ )}
246
+ </AnimatePresence>
247
+ </motion.div>
248
+ </div>
249
+ </div>
250
+
251
+ {/* ── Your Library ── */}
252
+ <section>
253
+ <div className="flex items-center justify-between mb-4">
254
+ <h2 className="font-heading text-2xl md:text-3xl font-extrabold text-brand-gray-700">
255
+ Your Library
256
+ </h2>
257
+ <div className="flex gap-2">
258
+ <button
259
+ onClick={() => scrollLibrary("left")}
260
+ className="h-9 w-9 rounded-full border border-brand-gray-200 bg-white flex items-center justify-center text-brand-gray-400 hover:text-brand-gray-600 hover:border-brand-gray-300 transition shadow-sm"
261
+ >
262
+
263
+ </button>
264
+ <button
265
+ onClick={() => scrollLibrary("right")}
266
+ className="h-9 w-9 rounded-full border border-brand-gray-200 bg-white flex items-center justify-center text-brand-gray-400 hover:text-brand-gray-600 hover:border-brand-gray-300 transition shadow-sm"
267
+ >
268
+
269
+ </button>
270
+ </div>
271
+ </div>
272
+
273
+ <div
274
+ ref={scrollRef}
275
+ dir="ltr"
276
+ className="flex gap-4 overflow-x-auto pt-4 pb-4 scrollbar-hide snap-x"
277
+ style={{ scrollbarWidth: "none" }}
278
+ >
279
+ {/* Real courses from store */}
280
+ {Object.values(courses).map((c) => {
281
+ const style = STORE_COURSE_STYLE[c.unitId] ?? { bg: "from-teal-100 to-teal-50", icon: <GenericIcon /> };
282
+ return (
283
+ <motion.div
284
+ key={c.unitId}
285
+ whileHover={{ y: -4 }}
286
+ className="shrink-0 w-40 md:w-48 snap-start"
287
+ >
288
+ <Link href={`/map/${c.unitId}`}>
289
+ <div className={`h-36 md:h-44 rounded-2xl bg-gradient-to-br ${style.bg} flex items-center justify-center shadow-md hover:shadow-lg transition-all`}>
290
+ {style.icon}
291
+ </div>
292
+ <p className="mt-2 text-sm font-semibold text-brand-gray-600 text-center truncate">
293
+ {c.unitTitle}
294
+ </p>
295
+ </Link>
296
+ </motion.div>
297
+ );
298
+ })}
299
+ {/* Static demo courses */}
300
+ {LIBRARY_COURSES.map((c) => (
301
+ <motion.div
302
+ key={c.id}
303
+ whileHover={{ y: -4 }}
304
+ className={`shrink-0 w-40 md:w-48 snap-start`}
305
+ >
306
+ <Link href={`/map/${c.id}`}>
307
+ <div
308
+ className={`h-36 md:h-44 rounded-2xl bg-gradient-to-br ${c.bg} flex items-center justify-center shadow-md
309
+ ${c.highlighted ? "shadow-lg" : ""}
310
+ hover:shadow-lg transition-all`}
311
+ >
312
+ {c.icon}
313
+ </div>
314
+ <p className="mt-2 text-sm font-semibold text-brand-gray-600 text-center truncate">
315
+ {c.title}
316
+ </p>
317
+ </Link>
318
+ </motion.div>
319
+ ))}
320
+ </div>
321
+ </section>
322
+ </div>
323
+
324
+ {/* ── Footer ── */}
325
+ <footer className="relative z-10 py-4 text-center text-sm text-brand-gray-400">
326
+ <a href="#" className="hover:text-brand-gray-600 transition">About</a>
327
+ <span className="mx-2 text-brand-gray-300">|</span>
328
+ <a href="#" className="hover:text-brand-gray-600 transition">Contact</a>
329
+ <span className="mx-2 text-brand-gray-300">|</span>
330
+ <a href="#" className="hover:text-brand-gray-600 transition">Privacy</a>
331
+ <span className="mx-2 text-brand-gray-300">|</span>
332
+ <a href="#" className="hover:text-brand-gray-600 transition">Terms</a>
333
+ </footer>
334
+ </div>
335
+ );
336
+ }
337
+
338
+ /* ═══════════════════ Background ═══════════════════ */
339
+
340
+ function DashboardBg() {
341
+ const runeText =
342
+ "ᚠᚢᚦᚨᚱᚲᚷᚹᚺᚾᛁᛃᛇᛈᛉᛊᛏᛒᛖᛗᛚᛜᛝᛞᛟᚠᚢᚦᚨᚱᚲᚷᚹᚺᚾᛁᛃᛇᛈᛉᛊᛏᛒᛖᛗᛚᛜᛝᛞᛟ";
343
+ return (
344
+ <div className="pointer-events-none absolute inset-0 z-0 overflow-hidden" aria-hidden="true">
345
+ {/* Rune circle – bottom right */}
346
+ <svg
347
+ viewBox="0 0 700 700"
348
+ className="absolute -right-[180px] -bottom-[180px] w-[600px] h-[600px] opacity-20"
349
+ fill="none"
350
+ >
351
+ <defs>
352
+ <radialGradient id="dashPeach" cx="50%" cy="45%" r="50%">
353
+ <stop offset="0%" stopColor="#FFF5EC" />
354
+ <stop offset="60%" stopColor="#F5DECA" />
355
+ <stop offset="100%" stopColor="#EDD0B5" />
356
+ </radialGradient>
357
+ <path id="dashRune" d="M 350,350 m -260,0 a 260,260 0 1,1 520,0 a 260,260 0 1,1 -520,0" />
358
+ </defs>
359
+ <circle cx="350" cy="350" r="300" fill="url(#dashPeach)" opacity="0.5" />
360
+ <circle cx="350" cy="350" r="290" fill="none" stroke="#D4BC8B" strokeWidth="1" opacity="0.4" />
361
+ <circle cx="350" cy="350" r="248" fill="none" stroke="#D4BC8B" strokeWidth="1" opacity="0.4" />
362
+ <text fill="#C4A87A" fontSize="18" fontWeight="500" letterSpacing="4" opacity="0.35">
363
+ <textPath href="#dashRune">{runeText}</textPath>
364
+ </text>
365
+ </svg>
366
+
367
+ {/* Sparkle */}
368
+ <svg viewBox="0 0 40 40" className="absolute bottom-8 right-8 w-7 h-7" fill="none">
369
+ <path d="M20 0 L22 16 L40 20 L22 22 L20 40 L18 22 L0 20 L18 16 Z" fill="#D4A96A" opacity="0.5" />
370
+ </svg>
371
+ </div>
372
+ );
373
+ }
374
+
375
+ /* ═══════════════════ Portal icon for Forge ═══════════════════ */
376
+
377
+ function PortalIcon() {
378
+ return (
379
+ <svg viewBox="0 0 120 120" className="h-24 w-24" fill="none">
380
+ {/* Outer glow */}
381
+ <circle cx="60" cy="60" r="50" fill="#7AC7C4" opacity="0.08" />
382
+ <circle cx="60" cy="60" r="42" fill="#7AC7C4" opacity="0.12" />
383
+ {/* Portal ring */}
384
+ <circle cx="60" cy="60" r="35" fill="none" stroke="#7AC7C4" strokeWidth="3" opacity="0.4" />
385
+ <circle cx="60" cy="60" r="30" fill="none" stroke="#D4BC8B" strokeWidth="2" opacity="0.5" />
386
+ {/* Inner portal */}
387
+ <circle cx="60" cy="60" r="24" fill="#E8F5F4" />
388
+ <circle cx="60" cy="60" r="18" fill="#7AC7C4" opacity="0.15" />
389
+ {/* Magic runes around */}
390
+ <text x="60" y="30" textAnchor="middle" fontSize="8" fill="#C4A87A" opacity="0.6">ᚠᚢᚦ</text>
391
+ <text x="60" y="95" textAnchor="middle" fontSize="8" fill="#C4A87A" opacity="0.6">ᛉᛊᛏ</text>
392
+ {/* Center emblem */}
393
+ <circle cx="60" cy="60" r="10" fill="#7AC7C4" opacity="0.3" />
394
+ <path d="M55 55h10v10H55z" fill="none" stroke="#5BB5B0" strokeWidth="1.5" rx="2" />
395
+ <circle cx="60" cy="60" r="3" fill="#5BB5B0" opacity="0.6" />
396
+ {/* Sparkles */}
397
+ <circle cx="40" cy="45" r="2" fill="#D4A96A" opacity="0.4" />
398
+ <circle cx="80" cy="75" r="1.5" fill="#D4A96A" opacity="0.5" />
399
+ <circle cx="78" cy="42" r="1" fill="#7AC7C4" opacity="0.5" />
400
+ </svg>
401
+ );
402
+ }
403
+
404
+ /* ═══════════════════ Course thumbnail icons ═══════════════════ */
405
+
406
+ function FrenchThumbnail() {
407
+ return (
408
+ <div className="w-full h-full bg-gradient-to-br from-blue-100 via-white to-red-100 flex items-center justify-center relative">
409
+ {/* French flag stripes */}
410
+ <div className="absolute inset-0 flex">
411
+ <div className="w-1/3 bg-blue-400/30" />
412
+ <div className="w-1/3 bg-white/30" />
413
+ <div className="w-1/3 bg-red-400/30" />
414
+ </div>
415
+ <svg viewBox="0 0 60 60" className="h-16 w-16 relative z-10" fill="none">
416
+ <circle cx="20" cy="28" r="8" fill="#EDB9A0" />
417
+ <rect x="14" y="36" width="12" height="16" rx="3" fill="#E8734A" opacity="0.6" />
418
+ <circle cx="40" cy="28" r="8" fill="#EDB9A0" />
419
+ <rect x="34" y="36" width="12" height="16" rx="3" fill="#5BB5B0" opacity="0.6" />
420
+ <circle cx="20" cy="22" r="4" fill="#333" opacity="0.1" />
421
+ <circle cx="40" cy="22" r="4" fill="#333" opacity="0.1" />
422
+ </svg>
423
+ </div>
424
+ );
425
+ }
426
+
427
+ function JapanIcon() {
428
+ return (
429
+ <svg viewBox="0 0 80 80" className="h-16 w-16" fill="none">
430
+ {/* Torii gate */}
431
+ <rect x="18" y="28" width="4" height="38" rx="1.5" fill="#D94F5C" />
432
+ <rect x="58" y="28" width="4" height="38" rx="1.5" fill="#D94F5C" />
433
+ <rect x="14" y="26" width="52" height="5" rx="2" fill="#E35D6A" />
434
+ <rect x="20" y="35" width="40" height="3.5" rx="1.5" fill="#E35D6A" />
435
+ {/* Mt Fuji */}
436
+ <path d="M42 50 L58 66 H26 Z" fill="#7B9EC9" opacity="0.5" />
437
+ <path d="M42 50 L47 56 H37 Z" fill="white" opacity="0.7" />
438
+ {/* Cherry blossom */}
439
+ <circle cx="64" cy="22" r="3.5" fill="#FFB7C5" />
440
+ <circle cx="60" cy="18" r="3" fill="#FFA3B5" />
441
+ <circle cx="67" cy="17" r="2.5" fill="#FFB7C5" />
442
+ <circle cx="63" cy="15" r="2" fill="#FFCDD8" />
443
+ <circle cx="58" cy="22" r="2.5" fill="#FFCDD8" />
444
+ </svg>
445
+ );
446
+ }
447
+
448
+ function SpainIcon() {
449
+ return (
450
+ <svg viewBox="0 0 80 80" className="h-16 w-16" fill="none">
451
+ {/* Speech bubble */}
452
+ <rect x="10" y="12" width="60" height="44" rx="12" fill="white" opacity="0.9" />
453
+ <path d="M30 56 L26 68 L38 56" fill="white" opacity="0.9" />
454
+ {/* Spain flag colors as accent */}
455
+ <rect x="18" y="20" width="44" height="6" rx="3" fill="#AA151B" />
456
+ <rect x="18" y="30" width="44" height="8" rx="3" fill="#F1BF00" />
457
+ <rect x="18" y="42" width="44" height="6" rx="3" fill="#AA151B" />
458
+ {/* ¡Hola! text */}
459
+ <text x="40" y="38" textAnchor="middle" fontFamily="sans-serif" fontWeight="800" fontSize="11" fill="#AA151B" opacity="0.9">¡Hola!</text>
460
+ </svg>
461
+ );
462
+ }
463
+
464
+ function PythonIcon() {
465
+ return (
466
+ <svg viewBox="0 0 80 80" className="h-16 w-16" fill="none">
467
+ {/* Terminal window */}
468
+ <rect x="10" y="14" width="60" height="52" rx="8" fill="#1E293B" />
469
+ <rect x="10" y="14" width="60" height="12" rx="8" fill="#334155" />
470
+ <rect x="10" y="22" width="60" height="4" fill="#334155" />
471
+ {/* Window dots */}
472
+ <circle cx="20" cy="20" r="2.5" fill="#EF4444" />
473
+ <circle cx="28" cy="20" r="2.5" fill="#EAB308" />
474
+ <circle cx="36" cy="20" r="2.5" fill="#22C55E" />
475
+ {/* Code lines */}
476
+ <text x="18" y="39" fontFamily="monospace" fontWeight="700" fontSize="8" fill="#22C55E">&gt;&gt;&gt;</text>
477
+ <rect x="38" y="33" width="24" height="4" rx="2" fill="#60A5FA" opacity="0.7" />
478
+ <rect x="18" y="44" width="30" height="3.5" rx="1.5" fill="#A78BFA" opacity="0.5" />
479
+ <rect x="18" y="52" width="20" height="3.5" rx="1.5" fill="#34D399" opacity="0.5" />
480
+ <rect x="42" y="52" width="16" height="3.5" rx="1.5" fill="#FBBF24" opacity="0.5" />
481
+ </svg>
482
+ );
483
+ }
484
+
485
+ function PythonIcon2() {
486
+ return (
487
+ <svg viewBox="0 0 80 80" className="h-16 w-16" fill="none">
488
+ {/* Rocket body */}
489
+ <path d="M40 10 C40 10 28 28 28 48 C28 58 33 64 40 68 C47 64 52 58 52 48 C52 28 40 10 40 10Z" fill="white" opacity="0.95" />
490
+ {/* Window */}
491
+ <circle cx="40" cy="38" r="7" fill="#3B82F6" opacity="0.8" />
492
+ <circle cx="40" cy="38" r="4" fill="#60A5FA" opacity="0.6" />
493
+ <circle cx="38" cy="36" r="1.5" fill="white" opacity="0.7" />
494
+ {/* Fins */}
495
+ <path d="M28 48 Q22 52 20 60 L28 56Z" fill="#EF4444" opacity="0.8" />
496
+ <path d="M52 48 Q58 52 60 60 L52 56Z" fill="#EF4444" opacity="0.8" />
497
+ {/* Flame */}
498
+ <path d="M36 68 Q38 76 40 78 Q42 76 44 68 Q42 72 40 73 Q38 72 36 68Z" fill="#F59E0B" />
499
+ <path d="M38 68 Q39 74 40 75 Q41 74 42 68 Q41 71 40 71 Q39 71 38 68Z" fill="#EF4444" />
500
+ {/* Nose cone */}
501
+ <path d="M40 10 C38 16 36 22 35 26 L45 26 C44 22 42 16 40 10Z" fill="#EF4444" opacity="0.7" />
502
+ </svg>
503
+ );
504
+ }
505
+
506
+ function GenericIcon() {
507
+ return (
508
+ <svg viewBox="0 0 80 80" className="h-16 w-16" fill="none">
509
+ {/* Book */}
510
+ <path d="M16 18 C16 14 20 12 24 12 L56 12 C60 12 64 14 64 18 V62 C64 66 60 68 56 68 L24 68 C20 68 16 66 16 62Z" fill="#F59E0B" opacity="0.85" />
511
+ <path d="M20 16 L20 64 C20 64 24 62 30 62 L60 62 L60 14 L30 14 C24 14 20 16 20 16Z" fill="#FEF3C7" />
512
+ {/* Spine */}
513
+ <path d="M20 16 L20 64" stroke="#D97706" strokeWidth="2" />
514
+ {/* Lines on page */}
515
+ <rect x="28" y="22" width="24" height="3" rx="1.5" fill="#D97706" opacity="0.3" />
516
+ <rect x="28" y="30" width="20" height="2.5" rx="1" fill="#92400E" opacity="0.15" />
517
+ <rect x="28" y="36" width="22" height="2.5" rx="1" fill="#92400E" opacity="0.15" />
518
+ <rect x="28" y="42" width="18" height="2.5" rx="1" fill="#92400E" opacity="0.15" />
519
+ {/* Bookmark */}
520
+ <path d="M50 12 V28 L53 24 L56 28 V12Z" fill="#EF4444" opacity="0.7" />
521
+ {/* Star */}
522
+ <circle cx="55" cy="55" r="6" fill="#F59E0B" opacity="0.5" />
523
+ <polygon points="55,49 56.5,53 61,53 57.5,56 58.8,60 55,57.5 51.2,60 52.5,56 49,53 53.5,53" fill="#FBBF24" />
524
+ </svg>
525
+ );
526
+ }
527
+
528
+ function MLIcon() {
529
+ return (
530
+ <svg viewBox="0 0 80 80" className="h-16 w-16" fill="none">
531
+ {/* Brain outline */}
532
+ <path d="M40 14 C30 14 22 20 22 30 C22 34 24 37 24 37 C20 39 18 43 18 48 C18 55 24 60 30 60 L32 60 C32 64 36 68 40 68 C44 68 48 64 48 60 L50 60 C56 60 62 55 62 48 C62 43 60 39 56 37 C56 37 58 34 58 30 C58 20 50 14 40 14Z" fill="#A78BFA" opacity="0.2" stroke="#8B5CF6" strokeWidth="2" />
533
+ {/* Neural network nodes */}
534
+ <circle cx="28" cy="32" r="4" fill="#8B5CF6" opacity="0.7" />
535
+ <circle cx="40" cy="24" r="4" fill="#A78BFA" opacity="0.8" />
536
+ <circle cx="52" cy="32" r="4" fill="#8B5CF6" opacity="0.7" />
537
+ <circle cx="32" cy="46" r="4" fill="#7C3AED" opacity="0.6" />
538
+ <circle cx="48" cy="46" r="4" fill="#7C3AED" opacity="0.6" />
539
+ <circle cx="40" cy="58" r="4" fill="#6D28D9" opacity="0.7" />
540
+ {/* Connections */}
541
+ <line x1="28" y1="32" x2="40" y2="24" stroke="#8B5CF6" strokeWidth="1.5" opacity="0.4" />
542
+ <line x1="52" y1="32" x2="40" y2="24" stroke="#8B5CF6" strokeWidth="1.5" opacity="0.4" />
543
+ <line x1="28" y1="32" x2="32" y2="46" stroke="#8B5CF6" strokeWidth="1.5" opacity="0.4" />
544
+ <line x1="52" y1="32" x2="48" y2="46" stroke="#8B5CF6" strokeWidth="1.5" opacity="0.4" />
545
+ <line x1="32" y1="46" x2="40" y2="58" stroke="#8B5CF6" strokeWidth="1.5" opacity="0.4" />
546
+ <line x1="48" y1="46" x2="40" y2="58" stroke="#8B5CF6" strokeWidth="1.5" opacity="0.4" />
547
+ <line x1="32" y1="46" x2="48" y2="46" stroke="#8B5CF6" strokeWidth="1.5" opacity="0.3" />
548
+ {/* Sparkle */}
549
+ <path d="M60 16 L61 20 L65 21 L61 22 L60 26 L59 22 L55 21 L59 20Z" fill="#FBBF24" opacity="0.8" />
550
+ </svg>
551
+ );
552
+ }
553
+
554
+ function DSIcon() {
555
+ return (
556
+ <svg viewBox="0 0 80 80" className="h-16 w-16" fill="none">
557
+ {/* Chart background */}
558
+ <rect x="12" y="12" width="56" height="56" rx="10" fill="white" opacity="0.15" />
559
+ {/* Grid lines */}
560
+ <line x1="18" y1="58" x2="62" y2="58" stroke="#0891B2" strokeWidth="1" opacity="0.2" />
561
+ <line x1="18" y1="46" x2="62" y2="46" stroke="#0891B2" strokeWidth="1" opacity="0.1" />
562
+ <line x1="18" y1="34" x2="62" y2="34" stroke="#0891B2" strokeWidth="1" opacity="0.1" />
563
+ {/* Bars */}
564
+ <rect x="20" y="42" width="8" height="16" rx="3" fill="#06B6D4" opacity="0.7" />
565
+ <rect x="31" y="30" width="8" height="28" rx="3" fill="#0891B2" opacity="0.8" />
566
+ <rect x="42" y="36" width="8" height="22" rx="3" fill="#22D3EE" opacity="0.7" />
567
+ <rect x="53" y="22" width="8" height="36" rx="3" fill="#06B6D4" opacity="0.9" />
568
+ {/* Trend line */}
569
+ <path d="M24 40 L35 28 L46 34 L57 20" stroke="#F59E0B" strokeWidth="2.5" strokeLinecap="round" fill="none" />
570
+ {/* Dots on trend */}
571
+ <circle cx="24" cy="40" r="3" fill="#F59E0B" />
572
+ <circle cx="35" cy="28" r="3" fill="#F59E0B" />
573
+ <circle cx="46" cy="34" r="3" fill="#F59E0B" />
574
+ <circle cx="57" cy="20" r="3" fill="#FBBF24" />
575
+ {/* Magnifier */}
576
+ <circle cx="61" cy="16" r="5" fill="none" stroke="white" strokeWidth="1.5" opacity="0.6" />
577
+ <line x1="65" y1="20" x2="68" y2="23" stroke="white" strokeWidth="1.5" opacity="0.6" strokeLinecap="round" />
578
+ </svg>
579
+ );
580
+ }
581
+
582
+ function HeartIcon() {
583
+ return (
584
+ <svg viewBox="0 0 80 80" className="h-16 w-16" fill="none">
585
+ {/* Anatomical heart shape */}
586
+ <path d="M40 68 C20 52 8 40 8 28 C8 18 16 10 26 10 C32 10 37 13 40 18 C43 13 48 10 54 10 C64 10 72 18 72 28 C72 40 60 52 40 68Z" fill="#E11D48" opacity="0.75" />
587
+ <path d="M40 62 C24 48 14 38 14 28 C14 20 20 14 28 14 C33 14 37 16 40 20 C43 16 47 14 52 14 C60 14 66 20 66 28 C66 38 56 48 40 62Z" fill="#FB7185" opacity="0.6" />
588
+ {/* Heartbeat line */}
589
+ <path d="M12 40 L28 40 L32 30 L36 50 L40 25 L44 48 L48 32 L50 40 L68 40" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" opacity="0.9" />
590
+ {/* Blood drops */}
591
+ <path d="M22 58 Q24 52 26 58 Q24 62 22 58Z" fill="#E11D48" opacity="0.4" />
592
+ <path d="M56 56 Q58 50 60 56 Q58 60 56 56Z" fill="#E11D48" opacity="0.3" />
593
+ </svg>
594
+ );
595
+ }
596
+
597
+ function CalculusIcon() {
598
+ return (
599
+ <svg viewBox="0 0 80 80" className="h-16 w-16" fill="none">
600
+ {/* Background circle */}
601
+ <circle cx="40" cy="40" r="30" fill="#3B82F6" opacity="0.15" />
602
+ {/* Coordinate axes */}
603
+ <line x1="14" y1="60" x2="66" y2="60" stroke="#3B82F6" strokeWidth="2" strokeLinecap="round" opacity="0.5" />
604
+ <line x1="20" y1="14" x2="20" y2="66" stroke="#3B82F6" strokeWidth="2" strokeLinecap="round" opacity="0.5" />
605
+ {/* Curve (limit / derivative) */}
606
+ <path d="M22 55 C30 52 35 40 38 30 C41 20 46 16 55 14" stroke="#6366F1" strokeWidth="2.5" strokeLinecap="round" fill="none" />
607
+ {/* Tangent line */}
608
+ <line x1="28" y1="55" x2="58" y2="22" stroke="#F59E0B" strokeWidth="2" strokeDasharray="4 3" opacity="0.7" />
609
+ {/* Point on curve */}
610
+ <circle cx="38" cy="30" r="4" fill="#6366F1" opacity="0.8" />
611
+ <circle cx="38" cy="30" r="2" fill="white" opacity="0.9" />
612
+ {/* dx notation */}
613
+ <text x="54" y="70" fontFamily="serif" fontStyle="italic" fontSize="12" fill="#6366F1" opacity="0.7">dx</text>
614
+ {/* Integral symbol */}
615
+ <text x="8" y="30" fontFamily="serif" fontSize="18" fill="#3B82F6" opacity="0.5">∫</text>
616
+ {/* Infinity */}
617
+ <text x="58" y="14" fontFamily="serif" fontSize="10" fill="#6366F1" opacity="0.4">∞</text>
618
+ </svg>
619
+ );
620
+ }
app/(dashboard)/layout.tsx ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+
3
+ export default function DashboardLayout({
4
+ children,
5
+ }: {
6
+ children: React.ReactNode;
7
+ }) {
8
+ return (
9
+ <div className="relative min-h-screen bg-gradient-to-br from-[#edf7fb] via-[#c9e6f2] to-[#a3d5e8]">
10
+ {children}
11
+ </div>
12
+ );
13
+ }
app/(dashboard)/map/[courseId]/MapClient.tsx ADDED
@@ -0,0 +1,1031 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import React, { useMemo, useState, useEffect, useRef, useCallback } from "react";
4
+ import { motion, AnimatePresence } from "framer-motion";
5
+ import Link from "next/link";
6
+ import { useParams } from "next/navigation";
7
+ import TopStatsBar from "@/components/shared/TopStatsBar";
8
+ import useCourseStore from "@/stores/useCourseStore";
9
+ import type { RemedyNode } from "@/stores/useCourseStore";
10
+ import useUserStore from "@/stores/useUserStore";
11
+
12
+ /* ═══════════════════ Fallback mock nodes (for demo courseIds with no mock data) ═══════════════════ */
13
+
14
+ interface MapNode {
15
+ id: string;
16
+ title: string;
17
+ status: "completed" | "available" | "locked";
18
+ x: number;
19
+ y: number;
20
+ }
21
+
22
+ /* ── Per-library-course metadata: each course has its own independent title + nodes ── */
23
+ const LIBRARY_COURSE_META: Record<string, { title: string; nodes: { id: string; title: string; status: "completed" | "available" | "locked" }[] }> = {
24
+ ja: {
25
+ title: "Japanese",
26
+ nodes: [
27
+ { id: "ja-n1", title: "Hiragana Basics", status: "completed" },
28
+ { id: "ja-n2", title: "Katakana Basics", status: "available" },
29
+ { id: "ja-n3", title: "Greetings & Self-Introduction", status: "locked" },
30
+ { id: "ja-n4", title: "Numbers & Counting", status: "locked" },
31
+ { id: "ja-n5", title: "Basic Particles (は・が・を)", status: "locked" },
32
+ { id: "ja-n6", title: "Daily Conversation", status: "locked" },
33
+ { id: "ja-n7", title: "Verb Conjugation", status: "locked" },
34
+ { id: "ja-n8", title: "Adjectives & Descriptions", status: "locked" },
35
+ { id: "ja-n9", title: "Kanji Level 1", status: "locked" },
36
+ { id: "ja-n10", title: "Reading Comprehension", status: "locked" },
37
+ { id: "ja-n11", title: "Listening Practice", status: "locked" },
38
+ { id: "ja-n12", title: "Travel Japanese", status: "locked" },
39
+ { id: "ja-n13", title: "Polite vs Casual Speech", status: "locked" },
40
+ { id: "ja-n14", title: "Culture & Etiquette", status: "locked" },
41
+ { id: "ja-n15", title: "Final Challenge", status: "locked" },
42
+ ],
43
+ },
44
+ es: {
45
+ title: "Spanish",
46
+ nodes: [
47
+ { id: "es-n1", title: "Alphabet & Pronunciation", status: "completed" },
48
+ { id: "es-n2", title: "Greetings & Introductions", status: "available" },
49
+ { id: "es-n3", title: "Articles & Gender", status: "locked" },
50
+ { id: "es-n4", title: "Basic Verbs (Ser/Estar)", status: "locked" },
51
+ { id: "es-n5", title: "Numbers & Time", status: "locked" },
52
+ { id: "es-n6", title: "Food & Ordering", status: "locked" },
53
+ { id: "es-n7", title: "Present Tense Conjugation", status: "locked" },
54
+ { id: "es-n8", title: "Prepositions & Directions", status: "locked" },
55
+ { id: "es-n9", title: "Past Tense (Pretérito)", status: "locked" },
56
+ { id: "es-n10", title: "Shopping & Money", status: "locked" },
57
+ { id: "es-n11", title: "Subjunctive Mood", status: "locked" },
58
+ { id: "es-n12", title: "Travel Conversation", status: "locked" },
59
+ { id: "es-n13", title: "Reading Practice", status: "locked" },
60
+ { id: "es-n14", title: "Culture & Idioms", status: "locked" },
61
+ { id: "es-n15", title: "Final Challenge", status: "locked" },
62
+ ],
63
+ },
64
+ py: {
65
+ title: "Python",
66
+ nodes: [
67
+ { id: "py-n1", title: "Variables & Data Types", status: "completed" },
68
+ { id: "py-n2", title: "Control Flow (if/else)", status: "available" },
69
+ { id: "py-n3", title: "Loops (for/while)", status: "locked" },
70
+ { id: "py-n4", title: "Functions & Scope", status: "locked" },
71
+ { id: "py-n5", title: "Lists & Tuples", status: "locked" },
72
+ { id: "py-n6", title: "Dictionaries & Sets", status: "locked" },
73
+ { id: "py-n7", title: "String Manipulation", status: "locked" },
74
+ { id: "py-n8", title: "File I/O", status: "locked" },
75
+ { id: "py-n9", title: "Error Handling", status: "locked" },
76
+ { id: "py-n10", title: "Modules & Packages", status: "locked" },
77
+ { id: "py-n11", title: "List Comprehensions", status: "locked" },
78
+ { id: "py-n12", title: "OOP Basics", status: "locked" },
79
+ { id: "py-n13", title: "Decorators & Generators", status: "locked" },
80
+ { id: "py-n14", title: "Testing with pytest", status: "locked" },
81
+ { id: "py-n15", title: "Final Project", status: "locked" },
82
+ ],
83
+ },
84
+ py2: {
85
+ title: "Python Advanced",
86
+ nodes: [
87
+ { id: "py2-n1", title: "Async & Concurrency", status: "completed" },
88
+ { id: "py2-n2", title: "Metaprogramming", status: "available" },
89
+ { id: "py2-n3", title: "Design Patterns", status: "locked" },
90
+ { id: "py2-n4", title: "Type Hints & Mypy", status: "locked" },
91
+ { id: "py2-n5", title: "Performance Optimization", status: "locked" },
92
+ { id: "py2-n6", title: "Context Managers", status: "locked" },
93
+ { id: "py2-n7", title: "Networking & APIs", status: "locked" },
94
+ { id: "py2-n8", title: "Data Processing (Pandas)", status: "locked" },
95
+ { id: "py2-n9", title: "Database Integration", status: "locked" },
96
+ { id: "py2-n10", title: "Web Scraping", status: "locked" },
97
+ { id: "py2-n11", title: "CLI Tools (Click/Typer)", status: "locked" },
98
+ { id: "py2-n12", title: "Package Publishing", status: "locked" },
99
+ { id: "py2-n13", title: "CI/CD Pipelines", status: "locked" },
100
+ { id: "py2-n14", title: "Security Best Practices", status: "locked" },
101
+ { id: "py2-n15", title: "Capstone Project", status: "locked" },
102
+ ],
103
+ },
104
+ gen: {
105
+ title: "Study Skills",
106
+ nodes: [
107
+ { id: "gen-n1", title: "Active Recall", status: "completed" },
108
+ { id: "gen-n2", title: "Spaced Repetition", status: "available" },
109
+ { id: "gen-n3", title: "Note-Taking Methods", status: "locked" },
110
+ { id: "gen-n4", title: "Mind Mapping", status: "locked" },
111
+ { id: "gen-n5", title: "Pomodoro Technique", status: "locked" },
112
+ { id: "gen-n6", title: "Goal Setting (SMART)", status: "locked" },
113
+ { id: "gen-n7", title: "Critical Thinking", status: "locked" },
114
+ { id: "gen-n8", title: "Speed Reading", status: "locked" },
115
+ { id: "gen-n9", title: "Memory Palace", status: "locked" },
116
+ { id: "gen-n10", title: "Group Study Strategies", status: "locked" },
117
+ { id: "gen-n11", title: "Test Preparation", status: "locked" },
118
+ { id: "gen-n12", title: "Time Management", status: "locked" },
119
+ { id: "gen-n13", title: "Self-Assessment", status: "locked" },
120
+ { id: "gen-n14", title: "Growth Mindset", status: "locked" },
121
+ { id: "gen-n15", title: "Final Review", status: "locked" },
122
+ ],
123
+ },
124
+ ml: {
125
+ title: "ML Basics",
126
+ nodes: [
127
+ { id: "ml-n1", title: "What is Machine Learning?", status: "completed" },
128
+ { id: "ml-n2", title: "Supervised vs Unsupervised", status: "available" },
129
+ { id: "ml-n3", title: "Linear Regression", status: "locked" },
130
+ { id: "ml-n4", title: "Logistic Regression", status: "locked" },
131
+ { id: "ml-n5", title: "Decision Trees", status: "locked" },
132
+ { id: "ml-n6", title: "K-Nearest Neighbors", status: "locked" },
133
+ { id: "ml-n7", title: "Support Vector Machines", status: "locked" },
134
+ { id: "ml-n8", title: "Neural Network Intro", status: "locked" },
135
+ { id: "ml-n9", title: "Training & Validation", status: "locked" },
136
+ { id: "ml-n10", title: "Overfitting & Regularization", status: "locked" },
137
+ { id: "ml-n11", title: "Feature Engineering", status: "locked" },
138
+ { id: "ml-n12", title: "Model Evaluation Metrics", status: "locked" },
139
+ { id: "ml-n13", title: "Ensemble Methods", status: "locked" },
140
+ { id: "ml-n14", title: "Deploying Models", status: "locked" },
141
+ { id: "ml-n15", title: "Final Challenge", status: "locked" },
142
+ ],
143
+ },
144
+ ds: {
145
+ title: "Data Science",
146
+ nodes: [
147
+ { id: "ds-n1", title: "Data Science Overview", status: "completed" },
148
+ { id: "ds-n2", title: "Data Collection & Cleaning", status: "available" },
149
+ { id: "ds-n3", title: "Exploratory Data Analysis", status: "locked" },
150
+ { id: "ds-n4", title: "Statistical Foundations", status: "locked" },
151
+ { id: "ds-n5", title: "Data Visualization", status: "locked" },
152
+ { id: "ds-n6", title: "Hypothesis Testing", status: "locked" },
153
+ { id: "ds-n7", title: "Regression Analysis", status: "locked" },
154
+ { id: "ds-n8", title: "Clustering Techniques", status: "locked" },
155
+ { id: "ds-n9", title: "Time Series Analysis", status: "locked" },
156
+ { id: "ds-n10", title: "Natural Language Processing", status: "locked" },
157
+ { id: "ds-n11", title: "Big Data Tools", status: "locked" },
158
+ { id: "ds-n12", title: "Dashboard & Reporting", status: "locked" },
159
+ { id: "ds-n13", title: "A/B Testing", status: "locked" },
160
+ { id: "ds-n14", title: "Ethics in Data Science", status: "locked" },
161
+ { id: "ds-n15", title: "Capstone Project", status: "locked" },
162
+ ],
163
+ },
164
+ };
165
+
166
+ const FALLBACK_NODES: { id: string; title: string; status: "completed" | "available" | "locked" }[] = [
167
+ { id: "demo-n1", title: "Introduction", status: "completed" },
168
+ { id: "demo-n2", title: "Core Concepts", status: "available" },
169
+ { id: "demo-n3", title: "Deep Dive", status: "locked" },
170
+ { id: "demo-n4", title: "Practice Lab", status: "locked" },
171
+ { id: "demo-n5", title: "Advanced Topics", status: "locked" },
172
+ { id: "demo-n6", title: "Connections", status: "locked" },
173
+ { id: "demo-n7", title: "Analysis", status: "locked" },
174
+ { id: "demo-n8", title: "Synthesis", status: "locked" },
175
+ { id: "demo-n9", title: "Application", status: "locked" },
176
+ { id: "demo-n10", title: "Case Study", status: "locked" },
177
+ { id: "demo-n11", title: "Research", status: "locked" },
178
+ { id: "demo-n12", title: "Review", status: "locked" },
179
+ { id: "demo-n13", title: "Mastery", status: "locked" },
180
+ { id: "demo-n14", title: "Integration", status: "locked" },
181
+ { id: "demo-n15", title: "Final Challenge", status: "locked" },
182
+ ];
183
+
184
+ /* ═══════════════════ Page ═══════════════════ */
185
+
186
+ export default function MapClient() {
187
+ const params = useParams();
188
+ const courseId = params.courseId as string;
189
+
190
+ const loadMockCourses = useCourseStore((s) => s.loadMockCourses);
191
+ const getCourse = useCourseStore((s) => s.getCourse);
192
+ const getNodesForCourse = useCourseStore((s) => s.getNodesForCourse);
193
+ const getRemedyNodesForCourse = useCourseStore((s) => s.getRemedyNodesForCourse);
194
+ const setLastActiveCourse = useUserStore((s) => s.setLastActiveCourse);
195
+
196
+ const [loaded, setLoaded] = useState(false);
197
+
198
+ useEffect(() => {
199
+ loadMockCourses();
200
+ setLoaded(true);
201
+ }, [loadMockCourses]);
202
+
203
+ /* Lock body scroll while map page is mounted (zoom 1.05 causes overflow) */
204
+ useEffect(() => {
205
+ const prev = document.documentElement.style.overflow;
206
+ document.documentElement.style.overflow = "hidden";
207
+ document.body.style.overflow = "hidden";
208
+ return () => {
209
+ document.documentElement.style.overflow = prev;
210
+ document.body.style.overflow = "";
211
+ };
212
+ }, []);
213
+
214
+ useEffect(() => {
215
+ if (courseId) setLastActiveCourse(courseId);
216
+ }, [courseId, setLastActiveCourse]);
217
+
218
+ const course = getCourse(courseId);
219
+ const storeNodes = getNodesForCourse(courseId);
220
+
221
+ // Build MapNode array – use store data if available, otherwise fallback
222
+ // Dynamic positions: zigzag pattern, each node spaced vertically
223
+ const NODES: MapNode[] = useMemo(() => {
224
+ const X_PATTERN = [50, 28, 68, 32, 58, 40, 65, 30, 55, 42, 62, 35, 58, 45, 50];
225
+ const buildPositions = (nodes: { id: string; title: string; status: "completed" | "available" | "locked" }[]) => {
226
+ const count = nodes.length;
227
+ const spacing = 120; // px per node
228
+ return nodes.map((n, i) => ({
229
+ id: n.id,
230
+ title: n.title,
231
+ status: n.status,
232
+ x: X_PATTERN[i % X_PATTERN.length],
233
+ y: (count - 1 - i) * spacing + 60, // bottom-to-top, first node at bottom
234
+ }));
235
+ };
236
+
237
+ if (storeNodes.length > 0) {
238
+ return buildPositions(storeNodes);
239
+ }
240
+ if (!loaded) return [];
241
+ // Use per-course fallback nodes if available
242
+ const libMeta = LIBRARY_COURSE_META[courseId];
243
+ if (libMeta) return buildPositions(libMeta.nodes);
244
+ return buildPositions(FALLBACK_NODES);
245
+ }, [storeNodes, loaded, courseId]);
246
+
247
+ // Total height for scrollable map
248
+ const mapHeight = Math.max(560, NODES.length * 120 + 120);
249
+
250
+ /* ── Scroll to bottom on load ── */
251
+ const mapContainerRef = useRef<HTMLDivElement>(null);
252
+ useEffect(() => {
253
+ if (NODES.length > 0 && mapContainerRef.current) {
254
+ mapContainerRef.current.scrollTop = mapContainerRef.current.scrollHeight;
255
+ }
256
+ }, [NODES.length]);
257
+
258
+ /* ── Mouse drag to pan ── */
259
+ const isDragging = useRef(false);
260
+ const dragStart = useRef({ x: 0, y: 0, scrollLeft: 0, scrollTop: 0 });
261
+
262
+ const handleMouseDown = useCallback((e: React.MouseEvent) => {
263
+ const container = mapContainerRef.current;
264
+ if (!container) return;
265
+ isDragging.current = true;
266
+ dragStart.current = {
267
+ x: e.clientX,
268
+ y: e.clientY,
269
+ scrollLeft: container.scrollLeft,
270
+ scrollTop: container.scrollTop,
271
+ };
272
+ container.style.cursor = "grabbing";
273
+ container.style.userSelect = "none";
274
+ }, []);
275
+
276
+ const handleMouseMove = useCallback((e: React.MouseEvent) => {
277
+ if (!isDragging.current) return;
278
+ const container = mapContainerRef.current;
279
+ if (!container) return;
280
+ const dx = e.clientX - dragStart.current.x;
281
+ const dy = e.clientY - dragStart.current.y;
282
+ container.scrollTop = dragStart.current.scrollTop - dy;
283
+ container.scrollLeft = dragStart.current.scrollLeft - dx;
284
+ }, []);
285
+
286
+ const handleMouseUp = useCallback(() => {
287
+ isDragging.current = false;
288
+ const container = mapContainerRef.current;
289
+ if (container) {
290
+ container.style.cursor = "grab";
291
+ container.style.userSelect = "";
292
+ }
293
+ }, []);
294
+
295
+ const pageTitle = course?.unitTitle ?? LIBRARY_COURSE_META[courseId]?.title ?? "Course Map";
296
+ const remedyNodes = getRemedyNodesForCourse(courseId);
297
+ return (
298
+ <div className="relative overflow-hidden bg-gradient-to-br from-[#edf7fb] via-[#c9e6f2] to-[#a3d5e8]" style={{ zoom: 1.05, height: "calc(100vh / 1.05)" }}>
299
+ <TopStatsBar backHref="/home" pageTitle={pageTitle} />
300
+
301
+ {/* Background effects */}
302
+ <MapBg />
303
+ <FloatingParticles />
304
+
305
+ <div className="relative z-10 flex gap-6 px-6 max-w-7xl mx-auto" style={{ height: "calc(100vh / 1.05 - 56px)" }}>
306
+ {/* ── Center: Map canvas (drag to pan, hidden scrollbar) ── */}
307
+ <div
308
+ ref={mapContainerRef}
309
+ className="flex-1 min-w-0 overflow-y-auto rounded-2xl scrollbar-hide"
310
+ style={{ cursor: "grab" }}
311
+ onMouseDown={handleMouseDown}
312
+ onMouseMove={handleMouseMove}
313
+ onMouseUp={handleMouseUp}
314
+ onMouseLeave={handleMouseUp}
315
+ >
316
+ <div className="relative" style={{ height: mapHeight, minHeight: 560 }}>
317
+ {/* SVG path connecting nodes */}
318
+ <svg
319
+ className="absolute inset-0 w-full h-full pointer-events-none z-0"
320
+ viewBox={`0 0 100 ${mapHeight}`}
321
+ preserveAspectRatio="none"
322
+ fill="none"
323
+ >
324
+ <defs>
325
+ <linearGradient id="pathGrad" x1="0" y1="1" x2="0" y2="0">
326
+ <stop offset="0%" stopColor="#4a9e9b" stopOpacity="0.8" />
327
+ <stop offset="100%" stopColor="#8a7a60" stopOpacity="0.5" />
328
+ </linearGradient>
329
+ </defs>
330
+ {NODES.slice(0, -1).map((node, i) => {
331
+ const next = NODES[i + 1];
332
+ const nx = node.x;
333
+ const ny = node.y;
334
+ const nnx = next.x;
335
+ const nny = next.y;
336
+ const mx = (nx + nnx) / 2;
337
+ const my = (ny + nny) / 2;
338
+ const isActive =
339
+ node.status !== "locked" || next.status !== "locked";
340
+ return (
341
+ <path
342
+ key={i}
343
+ d={`M ${nx} ${ny} Q ${mx + (i % 2 === 0 ? 12 : -12)} ${my} ${nnx} ${nny}`}
344
+ stroke={isActive ? "url(#pathGrad)" : "#999"}
345
+ strokeWidth="1.8"
346
+ strokeDasharray="4 3"
347
+ opacity={isActive ? 0.85 : 0.45}
348
+ vectorEffect="non-scaling-stroke"
349
+ />
350
+ );
351
+ })}
352
+ </svg>
353
+
354
+ {/* Nodes */}
355
+ {NODES.map((node, i) => {
356
+ const remedy = remedyNodes.find((r) => r.sourceNodeId === node.id);
357
+ return (
358
+ <MapNodeCircle key={node.id} node={node} index={i} remedyNode={remedy} />
359
+ );
360
+ })}
361
+ </div>
362
+ </div>
363
+
364
+ {/* ── Right: AI Chat Assistant ── */}
365
+ <div className="w-[340px] flex-shrink-0 pt-8">
366
+ <AIChatAssistant />
367
+ </div>
368
+ </div>
369
+ </div>
370
+ );
371
+ }
372
+
373
+ /* ═══════════════════ AI Chat Assistant ═══════════════════ */
374
+
375
+ interface ChatMessage {
376
+ id: number;
377
+ role: "user" | "assistant";
378
+ text: string;
379
+ }
380
+
381
+ function AIChatAssistant() {
382
+ const [input, setInput] = useState("");
383
+ const [messages, setMessages] = useState<ChatMessage[]>([]);
384
+ const chatEndRef = React.useRef<HTMLDivElement>(null);
385
+
386
+ useEffect(() => {
387
+ chatEndRef.current?.scrollIntoView({ behavior: "smooth" });
388
+ }, [messages]);
389
+
390
+ const handleSend = () => {
391
+ const trimmed = input.trim();
392
+ if (!trimmed) return;
393
+
394
+ const userMsg: ChatMessage = { id: Date.now(), role: "user", text: trimmed };
395
+ const botMsg: ChatMessage = { id: Date.now() + 1, role: "assistant", text: trimmed };
396
+
397
+ setMessages((prev) => [...prev, userMsg, botMsg]);
398
+ setInput("");
399
+ };
400
+
401
+ const handleKeyDown = (e: React.KeyboardEvent) => {
402
+ if (e.key === "Enter" && !e.shiftKey) {
403
+ e.preventDefault();
404
+ handleSend();
405
+ }
406
+ };
407
+
408
+ return (
409
+ <motion.div
410
+ initial={{ opacity: 0, x: 40 }}
411
+ animate={{ opacity: 1, x: 0 }}
412
+ transition={{ delay: 0.3, type: "spring", damping: 18 }}
413
+ className="rounded-3xl bg-white/60 backdrop-blur-xl border border-white/50 shadow-lg shadow-teal-200/20 overflow-hidden flex flex-col"
414
+ style={{ height: "78vh", minHeight: 560 }}
415
+ >
416
+ {/* Header */}
417
+ <div className="px-6 pt-6 pb-4 flex items-center gap-3">
418
+ {/* Owl avatar */}
419
+ <div className="relative w-14 h-14 flex-shrink-0">
420
+ <div className="w-14 h-14 rounded-full bg-gradient-to-br from-amber-300 to-amber-500 flex items-center justify-center shadow-md">
421
+ <svg viewBox="0 0 40 40" className="w-9 h-9" fill="none">
422
+ <ellipse cx="20" cy="24" rx="12" ry="10" fill="#C47F17" />
423
+ <circle cx="15" cy="20" r="5" fill="white" />
424
+ <circle cx="25" cy="20" r="5" fill="white" />
425
+ <circle cx="15" cy="20" r="2.5" fill="#2D2D2D" />
426
+ <circle cx="25" cy="20" r="2.5" fill="#2D2D2D" />
427
+ <circle cx="16" cy="19" r="1" fill="white" />
428
+ <circle cx="26" cy="19" r="1" fill="white" />
429
+ <polygon points="20,22 18,25 22,25" fill="#FF9500" />
430
+ <polygon points="10,16 8,8 15,14" fill="#C47F17" />
431
+ <polygon points="30,16 32,8 25,14" fill="#C47F17" />
432
+ </svg>
433
+ </div>
434
+ </div>
435
+ <div>
436
+ <h3 className="font-heading font-bold text-[15px] text-brand-gray-700">
437
+ AI Chat Assistant
438
+ </h3>
439
+ <p className="text-xs text-brand-gray-400 mt-0.5">
440
+ Ask me anything about this course!
441
+ </p>
442
+ </div>
443
+ </div>
444
+
445
+ {/* Chat messages area */}
446
+ <div className="flex-1 overflow-y-auto px-5 pb-3 space-y-3">
447
+ {messages.length === 0 && (
448
+ <div className="flex flex-col items-center justify-center py-8 gap-3 text-center">
449
+ <div className="w-12 h-12 rounded-full bg-brand-teal/10 flex items-center justify-center">
450
+ <svg viewBox="0 0 24 24" className="w-6 h-6 text-brand-teal" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
451
+ <path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
452
+ </svg>
453
+ </div>
454
+ <p className="text-xs text-brand-gray-400 leading-relaxed max-w-[200px]">
455
+ Type a message to start chatting with your AI study companion!
456
+ </p>
457
+ </div>
458
+ )}
459
+
460
+ {messages.map((msg) => (
461
+ <motion.div
462
+ key={msg.id}
463
+ initial={{ opacity: 0, y: 8 }}
464
+ animate={{ opacity: 1, y: 0 }}
465
+ transition={{ duration: 0.2 }}
466
+ className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
467
+ >
468
+ <div
469
+ className={`max-w-[85%] rounded-2xl px-4 py-2.5 text-[13px] leading-relaxed ${
470
+ msg.role === "user"
471
+ ? "bg-gradient-to-r from-brand-teal to-[#5fb3af] text-white rounded-br-md"
472
+ : "bg-white/70 border border-white/60 text-brand-gray-600 rounded-bl-md shadow-sm"
473
+ }`}
474
+ >
475
+ {msg.text}
476
+ </div>
477
+ </motion.div>
478
+ ))}
479
+ <div ref={chatEndRef} />
480
+ </div>
481
+
482
+ {/* Divider */}
483
+ <div className="mx-5 h-px bg-gradient-to-r from-transparent via-brand-teal/20 to-transparent" />
484
+
485
+ {/* Input area */}
486
+ <div className="px-5 py-4 flex items-end gap-2">
487
+ <textarea
488
+ value={input}
489
+ onChange={(e) => setInput(e.target.value)}
490
+ onKeyDown={handleKeyDown}
491
+ placeholder="Type your message..."
492
+ rows={1}
493
+ className="flex-1 resize-none rounded-xl bg-white/70 border border-white/60 px-4 py-3 text-[13px] text-brand-gray-700 placeholder-brand-gray-300 focus:outline-none focus:ring-2 focus:ring-brand-teal/30 focus:border-brand-teal/40 transition-all"
494
+ style={{ maxHeight: 80 }}
495
+ />
496
+ <motion.button
497
+ onClick={handleSend}
498
+ whileTap={{ scale: 0.9 }}
499
+ className="flex-shrink-0 w-11 h-11 rounded-xl bg-gradient-to-r from-brand-teal to-[#5fb3af] text-white flex items-center justify-center shadow-md shadow-teal-300/30 hover:shadow-lg hover:shadow-teal-300/40 transition-all"
500
+ >
501
+ <svg viewBox="0 0 24 24" className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
502
+ <line x1="22" y1="2" x2="11" y2="13" />
503
+ <polygon points="22 2 15 22 11 13 2 9 22 2" />
504
+ </svg>
505
+ </motion.button>
506
+ </div>
507
+ </motion.div>
508
+ );
509
+ }
510
+
511
+ /* ═══════════════════ Node Circle ═══════════════════ */
512
+
513
+ function MapNodeCircle({
514
+ node,
515
+ index,
516
+ remedyNode,
517
+ }: {
518
+ node: MapNode;
519
+ index: number;
520
+ remedyNode?: RemedyNode;
521
+ }) {
522
+ const isClickable =
523
+ node.status === "available" || node.status === "completed";
524
+
525
+ const content = (
526
+ <motion.div
527
+ initial={{ opacity: 0, y: 20, scale: 0.8 }}
528
+ animate={{ opacity: 1, y: 0, scale: 1 }}
529
+ transition={{ delay: 0.15 * index, type: "spring", damping: 14 }}
530
+ className="flex flex-col items-center gap-2 cursor-pointer"
531
+ whileHover={isClickable ? { scale: 1.1 } : {}}
532
+ whileTap={isClickable ? { scale: 0.92 } : {}}
533
+ >
534
+ {/* Outer glow ring for available */}
535
+ {node.status === "available" && (
536
+ <>
537
+ <motion.div
538
+ className="absolute w-[88px] h-[88px] rounded-full"
539
+ style={{
540
+ background:
541
+ "radial-gradient(circle, rgba(122,199,196,0.25) 0%, transparent 70%)",
542
+ }}
543
+ animate={{ scale: [1, 1.4, 1], opacity: [0.4, 0.7, 0.4] }}
544
+ transition={{ repeat: Infinity, duration: 2.5, ease: "easeInOut" }}
545
+ />
546
+ {/* Petal / scallop border */}
547
+ <motion.div
548
+ className="absolute w-[76px] h-[76px]"
549
+ animate={{ rotate: [0, 360] }}
550
+ transition={{ repeat: Infinity, duration: 20, ease: "linear" }}
551
+ >
552
+ <svg viewBox="0 0 76 76" className="w-full h-full" fill="none">
553
+ {Array.from({ length: 12 }).map((_, i) => {
554
+ const angle = (i * 30 * Math.PI) / 180;
555
+ const cx = 38 + 32 * Math.cos(angle);
556
+ const cy = 38 + 32 * Math.sin(angle);
557
+ return (
558
+ <circle
559
+ key={i}
560
+ cx={cx}
561
+ cy={cy}
562
+ r="8"
563
+ fill="none"
564
+ stroke="#7AC7C4"
565
+ strokeWidth="1"
566
+ opacity="0.3"
567
+ />
568
+ );
569
+ })}
570
+ </svg>
571
+ </motion.div>
572
+ </>
573
+ )}
574
+
575
+ {/* Completed outer ring decoration */}
576
+ {node.status === "completed" && (
577
+ <motion.div
578
+ className="absolute w-[78px] h-[78px]"
579
+ animate={{ rotate: [0, -360] }}
580
+ transition={{ repeat: Infinity, duration: 25, ease: "linear" }}
581
+ >
582
+ <svg viewBox="0 0 78 78" className="w-full h-full" fill="none">
583
+ {Array.from({ length: 10 }).map((_, i) => {
584
+ const angle = (i * 36 * Math.PI) / 180;
585
+ const cx = 39 + 33 * Math.cos(angle);
586
+ const cy = 39 + 33 * Math.sin(angle);
587
+ return (
588
+ <circle
589
+ key={i}
590
+ cx={cx}
591
+ cy={cy}
592
+ r="7"
593
+ fill="none"
594
+ stroke="#F5C842"
595
+ strokeWidth="1"
596
+ opacity="0.35"
597
+ />
598
+ );
599
+ })}
600
+ </svg>
601
+ </motion.div>
602
+ )}
603
+
604
+ {/* Main circle */}
605
+ <div
606
+ className={`relative z-10 h-[64px] w-[64px] rounded-full flex items-center justify-center transition-all ${
607
+ node.status === "completed"
608
+ ? "shadow-lg shadow-amber-300/30"
609
+ : node.status === "available"
610
+ ? "shadow-lg shadow-teal-400/30"
611
+ : "shadow-md"
612
+ }`}
613
+ >
614
+ {/* Background with double border effect */}
615
+ <div
616
+ className={`absolute inset-0 rounded-full ${
617
+ node.status === "completed"
618
+ ? "bg-gradient-to-br from-yellow-300 via-amber-400 to-yellow-500"
619
+ : node.status === "available"
620
+ ? "bg-gradient-to-br from-[#7AC7C4] via-[#5fb3af] to-[#4da8a4]"
621
+ : "bg-gradient-to-br from-[#e0ddd8] via-[#d4d0ca] to-[#c8c4be]"
622
+ }`}
623
+ />
624
+ {/* Inner ring */}
625
+ <div
626
+ className={`absolute inset-[3px] rounded-full border-2 ${
627
+ node.status === "completed"
628
+ ? "border-yellow-200/50"
629
+ : node.status === "available"
630
+ ? "border-white/30"
631
+ : "border-white/20"
632
+ }`}
633
+ />
634
+ {/* Specular highlight */}
635
+ <div className="absolute inset-0 rounded-full overflow-hidden">
636
+ <div
637
+ className="absolute -top-1 left-1/2 -translate-x-1/2 w-[70%] h-[40%] rounded-[50%]"
638
+ style={{
639
+ background:
640
+ node.status === "locked"
641
+ ? "linear-gradient(180deg, rgba(255,255,255,0.25) 0%, transparent 100%)"
642
+ : "linear-gradient(180deg, rgba(255,255,255,0.35) 0%, transparent 100%)",
643
+ }}
644
+ />
645
+ </div>
646
+
647
+ {/* Icon */}
648
+ <div className="relative z-10">
649
+ {node.status === "completed" ? (
650
+ <svg
651
+ viewBox="0 0 24 24"
652
+ className="h-7 w-7 text-white drop-shadow-sm"
653
+ fill="none"
654
+ stroke="currentColor"
655
+ strokeWidth="3"
656
+ strokeLinecap="round"
657
+ strokeLinejoin="round"
658
+ >
659
+ <path d="M20 6L9 17l-5-5" />
660
+ </svg>
661
+ ) : node.status === "available" ? (
662
+ <motion.div
663
+ animate={{ scale: [1, 1.15, 1] }}
664
+ transition={{
665
+ repeat: Infinity,
666
+ duration: 2,
667
+ ease: "easeInOut",
668
+ }}
669
+ >
670
+ <svg
671
+ viewBox="0 0 24 24"
672
+ className="h-7 w-7 text-white drop-shadow-sm"
673
+ fill="currentColor"
674
+ >
675
+ <polygon points="12,2 15,9 22,9 16,14 18,22 12,17 6,22 8,14 2,9 9,9" />
676
+ </svg>
677
+ </motion.div>
678
+ ) : (
679
+ <svg
680
+ viewBox="0 0 24 24"
681
+ className="h-6 w-6 text-[#a09a92]"
682
+ fill="none"
683
+ stroke="currentColor"
684
+ strokeWidth="2"
685
+ strokeLinecap="round"
686
+ strokeLinejoin="round"
687
+ >
688
+ <rect x="3" y="11" width="18" height="11" rx="2" />
689
+ <path d="M7 11V7a5 5 0 0110 0v4" />
690
+ </svg>
691
+ )}
692
+ </div>
693
+ </div>
694
+
695
+ {/* Label */}
696
+ <span
697
+ className={`text-[11px] font-heading font-bold text-center max-w-[100px] leading-tight drop-shadow-sm ${
698
+ node.status === "completed"
699
+ ? "text-amber-700"
700
+ : node.status === "available"
701
+ ? "text-teal-700"
702
+ : "text-brand-gray-400"
703
+ }`}
704
+ >
705
+ {node.title}
706
+ </span>
707
+ </motion.div>
708
+ );
709
+
710
+ return (
711
+ <div
712
+ className="absolute z-10"
713
+ style={{
714
+ left: `${node.x}%`,
715
+ top: `${node.y}px`,
716
+ transform: "translate(-50%, -50%)",
717
+ }}
718
+ >
719
+ {/* ── Remedy circle node on the LEFT ── */}
720
+ <AnimatePresence>
721
+ {remedyNode && (
722
+ <motion.div
723
+ initial={{ opacity: 0, scale: 0.5, x: 30 }}
724
+ animate={{ opacity: 1, scale: 1, x: 0 }}
725
+ exit={{ opacity: 0, scale: 0.5, x: 30 }}
726
+ transition={{ type: "spring", damping: 16, stiffness: 180 }}
727
+ className="absolute z-20"
728
+ style={{ right: "calc(100% + 60px)", top: "50%", transform: "translateY(-50%)" }}
729
+ >
730
+ {/* Dashed curve connecting to main node */}
731
+ <svg
732
+ className="absolute pointer-events-none"
733
+ style={{ left: "calc(100% + 4px)", top: "50%", transform: "translateY(-50%)" }}
734
+ width="56" height="20" viewBox="0 0 56 20" fill="none"
735
+ >
736
+ <path
737
+ d="M0 10 Q28 0 56 10"
738
+ stroke="#f59e0b"
739
+ strokeWidth="2"
740
+ strokeDasharray="4 3"
741
+ opacity="0.7"
742
+ fill="none"
743
+ />
744
+ </svg>
745
+
746
+ {remedyNode.status === "available" ? (
747
+ <Link href={`/play/${remedyNode.sourceNodeId}`}>
748
+ <motion.div
749
+ className="flex flex-col items-center gap-2 cursor-pointer"
750
+ whileHover={{ scale: 1.12 }}
751
+ whileTap={{ scale: 0.92 }}
752
+ >
753
+ {/* Outer glow */}
754
+ <motion.div
755
+ className="absolute w-[80px] h-[80px] rounded-full"
756
+ style={{ background: "radial-gradient(circle, rgba(245,158,11,0.25) 0%, transparent 70%)" }}
757
+ animate={{ scale: [1, 1.35, 1], opacity: [0.4, 0.7, 0.4] }}
758
+ transition={{ repeat: Infinity, duration: 2.5, ease: "easeInOut" }}
759
+ />
760
+ {/* Petal ring */}
761
+ <motion.div
762
+ className="absolute w-[70px] h-[70px]"
763
+ animate={{ rotate: [0, 360] }}
764
+ transition={{ repeat: Infinity, duration: 18, ease: "linear" }}
765
+ >
766
+ <svg viewBox="0 0 70 70" className="w-full h-full" fill="none">
767
+ {Array.from({ length: 10 }).map((_, i) => {
768
+ const angle = (i * 36 * Math.PI) / 180;
769
+ const cx = 35 + 29 * Math.cos(angle);
770
+ const cy = 35 + 29 * Math.sin(angle);
771
+ return <circle key={i} cx={cx} cy={cy} r="7" fill="none" stroke="#f59e0b" strokeWidth="1" opacity="0.3" />;
772
+ })}
773
+ </svg>
774
+ </motion.div>
775
+ {/* Main circle */}
776
+ <div className="relative z-10 h-[58px] w-[58px] rounded-full flex items-center justify-center shadow-lg shadow-amber-400/30">
777
+ <div className="absolute inset-0 rounded-full bg-gradient-to-br from-amber-300 via-orange-400 to-amber-500" />
778
+ <div className="absolute inset-[3px] rounded-full border-2 border-amber-200/50" />
779
+ <div className="absolute inset-0 rounded-full overflow-hidden">
780
+ <div className="absolute -top-1 left-1/2 -translate-x-1/2 w-[70%] h-[40%] rounded-[50%]" style={{ background: "linear-gradient(180deg, rgba(255,255,255,0.35) 0%, transparent 100%)" }} />
781
+ </div>
782
+ <div className="relative z-10">
783
+ <motion.div animate={{ scale: [1, 1.12, 1] }} transition={{ repeat: Infinity, duration: 2, ease: "easeInOut" }}>
784
+ <svg viewBox="0 0 24 24" className="h-6 w-6 text-white drop-shadow-sm" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
785
+ <path d="M2 3h6a4 4 0 014 4v14a3 3 0 00-3-3H2z" />
786
+ <path d="M22 3h-6a4 4 0 00-4 4v14a3 3 0 013-3h7z" />
787
+ </svg>
788
+ </motion.div>
789
+ </div>
790
+ </div>
791
+ <span className="text-[10px] font-heading font-bold text-amber-700 text-center max-w-[90px] leading-tight drop-shadow-sm whitespace-nowrap">補強練習</span>
792
+ </motion.div>
793
+ </Link>
794
+ ) : (
795
+ <div className="flex flex-col items-center gap-2">
796
+ {/* Completed ring – amber style */}
797
+ <motion.div
798
+ className="absolute w-[70px] h-[70px]"
799
+ animate={{ rotate: [0, -360] }}
800
+ transition={{ repeat: Infinity, duration: 25, ease: "linear" }}
801
+ >
802
+ <svg viewBox="0 0 70 70" className="w-full h-full" fill="none">
803
+ {Array.from({ length: 10 }).map((_, i) => {
804
+ const angle = (i * 36 * Math.PI) / 180;
805
+ const cx = 35 + 29 * Math.cos(angle);
806
+ const cy = 35 + 29 * Math.sin(angle);
807
+ return <circle key={i} cx={cx} cy={cy} r="7" fill="none" stroke="#f59e0b" strokeWidth="1" opacity="0.35" />;
808
+ })}
809
+ </svg>
810
+ </motion.div>
811
+ {/* Main circle – completed amber */}
812
+ <div className="relative z-10 h-[58px] w-[58px] rounded-full flex items-center justify-center shadow-lg shadow-amber-400/30">
813
+ <div className="absolute inset-0 rounded-full bg-gradient-to-br from-amber-300 via-orange-400 to-amber-500" />
814
+ <div className="absolute inset-[3px] rounded-full border-2 border-amber-200/50" />
815
+ <div className="absolute inset-0 rounded-full overflow-hidden">
816
+ <div className="absolute -top-1 left-1/2 -translate-x-1/2 w-[70%] h-[40%] rounded-[50%]" style={{ background: "linear-gradient(180deg, rgba(255,255,255,0.35) 0%, transparent 100%)" }} />
817
+ </div>
818
+ <div className="relative z-10">
819
+ <svg viewBox="0 0 24 24" className="h-7 w-7 text-white drop-shadow-sm" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
820
+ <path d="M20 6L9 17l-5-5" />
821
+ </svg>
822
+ </div>
823
+ </div>
824
+ <span className="text-[10px] font-heading font-bold text-amber-700 text-center max-w-[90px] leading-tight drop-shadow-sm whitespace-nowrap">補強完成</span>
825
+ </div>
826
+ )}
827
+ </motion.div>
828
+ )}
829
+ </AnimatePresence>
830
+
831
+ {isClickable ? (
832
+ <Link href={`/play/${node.id}`}>{content}</Link>
833
+ ) : (
834
+ content
835
+ )}
836
+ </div>
837
+ );
838
+ }
839
+
840
+ /* ═══════════════════ Floating Particles ═══════════════════ */
841
+
842
+ function FloatingParticles() {
843
+ const particles = useMemo(
844
+ () =>
845
+ Array.from({ length: 24 }, (_, i) => ({
846
+ id: i,
847
+ x: 5 + Math.random() * 90,
848
+ size: 3 + Math.random() * 5,
849
+ delay: Math.random() * 8,
850
+ duration: 10 + Math.random() * 12,
851
+ opacity: 0.08 + Math.random() * 0.15,
852
+ type: i % 4, // 0=dot, 1=ring, 2=diamond, 3=star
853
+ })),
854
+ []
855
+ );
856
+
857
+ return (
858
+ <div
859
+ className="pointer-events-none absolute inset-0 z-[1] overflow-hidden"
860
+ aria-hidden="true"
861
+ >
862
+ {particles.map((p) => (
863
+ <motion.div
864
+ key={p.id}
865
+ className="absolute"
866
+ style={{ left: `${p.x}%`, bottom: -20 }}
867
+ animate={{
868
+ y: [0, -(typeof window !== "undefined" ? window.innerHeight + 40 : 900)],
869
+ x: [0, (p.id % 2 === 0 ? 1 : -1) * (15 + Math.random() * 25)],
870
+ rotate: [0, 180 + Math.random() * 180],
871
+ }}
872
+ transition={{
873
+ duration: p.duration,
874
+ delay: p.delay,
875
+ repeat: Infinity,
876
+ ease: "linear",
877
+ }}
878
+ >
879
+ {p.type === 0 && (
880
+ <div
881
+ className="rounded-full bg-brand-teal"
882
+ style={{
883
+ width: p.size,
884
+ height: p.size,
885
+ opacity: p.opacity,
886
+ }}
887
+ />
888
+ )}
889
+ {p.type === 1 && (
890
+ <div
891
+ className="rounded-full border border-brand-teal"
892
+ style={{
893
+ width: p.size * 1.5,
894
+ height: p.size * 1.5,
895
+ opacity: p.opacity,
896
+ }}
897
+ />
898
+ )}
899
+ {p.type === 2 && (
900
+ <div
901
+ className="bg-amber-400"
902
+ style={{
903
+ width: p.size,
904
+ height: p.size,
905
+ opacity: p.opacity,
906
+ transform: "rotate(45deg)",
907
+ borderRadius: 1,
908
+ }}
909
+ />
910
+ )}
911
+ {p.type === 3 && (
912
+ <svg
913
+ viewBox="0 0 24 24"
914
+ fill="#7AC7C4"
915
+ style={{
916
+ width: p.size * 1.8,
917
+ height: p.size * 1.8,
918
+ opacity: p.opacity,
919
+ }}
920
+ >
921
+ <polygon points="12,2 15,9 22,9 16,14 18,22 12,17 6,22 8,14 2,9 9,9" />
922
+ </svg>
923
+ )}
924
+ </motion.div>
925
+ ))}
926
+
927
+ {/* Sparkle shimmer dots */}
928
+ {Array.from({ length: 14 }, (_, i) => (
929
+ <motion.div
930
+ key={`sparkle-${i}`}
931
+ className="absolute rounded-full bg-white"
932
+ style={{
933
+ width: 2 + Math.random() * 3,
934
+ height: 2 + Math.random() * 3,
935
+ left: `${8 + Math.random() * 84}%`,
936
+ top: `${10 + Math.random() * 80}%`,
937
+ }}
938
+ animate={{
939
+ opacity: [0, 0.4, 0],
940
+ scale: [0.5, 1.2, 0.5],
941
+ }}
942
+ transition={{
943
+ duration: 2 + Math.random() * 3,
944
+ delay: Math.random() * 5,
945
+ repeat: Infinity,
946
+ ease: "easeInOut",
947
+ }}
948
+ />
949
+ ))}
950
+ </div>
951
+ );
952
+ }
953
+
954
+ /* ═══════════════════ Background ═══════════════════ */
955
+
956
+ function MapBg() {
957
+ return (
958
+ <div
959
+ className="pointer-events-none absolute inset-0 z-0 overflow-hidden"
960
+ aria-hidden="true"
961
+ >
962
+ {/* Runic circle — bottom-right */}
963
+ <svg
964
+ viewBox="0 0 700 700"
965
+ className="absolute -right-[160px] -bottom-[160px] w-[520px] h-[520px] opacity-[0.08]"
966
+ fill="none"
967
+ >
968
+ <defs>
969
+ <radialGradient id="mapPeach" cx="50%" cy="45%" r="50%">
970
+ <stop offset="0%" stopColor="#D4EEE8" />
971
+ <stop offset="60%" stopColor="#B5DDD4" />
972
+ <stop offset="100%" stopColor="#9ACEC3" />
973
+ </radialGradient>
974
+ <path
975
+ id="mapRune"
976
+ d="M 350,350 m -260,0 a 260,260 0 1,1 520,0 a 260,260 0 1,1 -520,0"
977
+ />
978
+ </defs>
979
+ <circle cx="350" cy="350" r="300" fill="url(#mapPeach)" opacity="0.5" />
980
+ <circle
981
+ cx="350"
982
+ cy="350"
983
+ r="290"
984
+ fill="none"
985
+ stroke="#7AC7C4"
986
+ strokeWidth="1"
987
+ opacity="0.3"
988
+ />
989
+ <circle
990
+ cx="350"
991
+ cy="350"
992
+ r="240"
993
+ fill="none"
994
+ stroke="#7AC7C4"
995
+ strokeWidth="0.5"
996
+ opacity="0.2"
997
+ strokeDasharray="8 6"
998
+ />
999
+ <text
1000
+ fill="#7AC7C4"
1001
+ fontSize="18"
1002
+ fontWeight="500"
1003
+ letterSpacing="4"
1004
+ opacity="0.3"
1005
+ >
1006
+ <textPath href="#mapRune">
1007
+ ᚠᚢᚦᚨᚱᚲᚷᚹᚺᚾᛁᛃᛇᛈᛉᛊᛏᛒᛖᛗᛚᛜᛝᛞᛟᚠᚢᚦᚨᚱᚲᚷᚹᚺᚾᛁᛃᛇᛈᛉᛊᛏᛒᛖᛗᛚᛜᛝᛞᛟ
1008
+ </textPath>
1009
+ </text>
1010
+ </svg>
1011
+
1012
+ {/* Subtle top-left gradient orb */}
1013
+ <div
1014
+ className="absolute -left-[100px] -top-[100px] w-[400px] h-[400px] rounded-full opacity-[0.06]"
1015
+ style={{
1016
+ background:
1017
+ "radial-gradient(circle, rgba(122,199,196,0.6) 0%, transparent 70%)",
1018
+ }}
1019
+ />
1020
+
1021
+ {/* Mid-right soft glow */}
1022
+ <div
1023
+ className="absolute right-[5%] top-[35%] w-[200px] h-[200px] rounded-full opacity-[0.05]"
1024
+ style={{
1025
+ background:
1026
+ "radial-gradient(circle, rgba(245,200,66,0.5) 0%, transparent 70%)",
1027
+ }}
1028
+ />
1029
+ </div>
1030
+ );
1031
+ }
app/(dashboard)/map/[courseId]/page.tsx ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import MapClient from "./MapClient";
2
+
3
+ export function generateStaticParams() {
4
+ return [
5
+ { courseId: "med-u1" },
6
+ { courseId: "calc-u1" },
7
+ // Library courses from home page
8
+ { courseId: "ja" },
9
+ { courseId: "es" },
10
+ { courseId: "py" },
11
+ { courseId: "py2" },
12
+ { courseId: "gen" },
13
+ { courseId: "ml" },
14
+ { courseId: "ds" },
15
+ ];
16
+ }
17
+
18
+ export default function MapPage() {
19
+ return <MapClient />;
20
+ }
app/(dashboard)/store/page.tsx ADDED
@@ -0,0 +1,530 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import React, { useState } from "react";
4
+ import { motion, AnimatePresence } from "framer-motion";
5
+ import TopStatsBar from "@/components/shared/TopStatsBar";
6
+ import DeepGlassCard from "@/components/ui/DeepGlassCard";
7
+ import useUserStore from "@/stores/useUserStore";
8
+
9
+ /* ═══════════════════ Types & data ═══════════════════ */
10
+
11
+ interface Tier {
12
+ id: string;
13
+ label: string;
14
+ gems: number;
15
+ price: string;
16
+ badge?: string;
17
+ variant: "starter" | "popular" | "pro";
18
+ }
19
+
20
+ const TIERS: Tier[] = [
21
+ {
22
+ id: "starter",
23
+ label: "Handful of Gems",
24
+ gems: 500,
25
+ price: "$4.99",
26
+ variant: "starter",
27
+ },
28
+ {
29
+ id: "popular",
30
+ label: "Chest of Gems",
31
+ gems: 2000,
32
+ price: "$19.99",
33
+ badge: "Best Value",
34
+ variant: "popular",
35
+ },
36
+ {
37
+ id: "pro",
38
+ label: "Infinite Energy Subscription",
39
+ gems: -1, // unlimited
40
+ price: "$14.99/mo",
41
+ variant: "pro",
42
+ },
43
+ ];
44
+
45
+ /* ═══════════════════ Page ═══════════════════ */
46
+
47
+ export default function StorePage() {
48
+ const [selectedTier, setSelectedTier] = useState<Tier | null>(null);
49
+
50
+ return (
51
+ <div className="relative min-h-screen overflow-hidden">
52
+ {/* ── TopStatsBar with back arrow ── */}
53
+ <TopStatsBar backHref="/home" pageTitle="Treasury / Top-Up Store" />
54
+
55
+ {/* ── Background decoration ── */}
56
+ <StoreBg />
57
+
58
+ {/* ══════════════ Content ══════════════ */}
59
+ <div className="relative z-10 max-w-6xl mx-auto px-4 md:px-8 py-10 md:py-16">
60
+ {/* Header */}
61
+ <div className="text-center mb-10 md:mb-14">
62
+ <h1 className="font-heading text-3xl md:text-4xl font-extrabold text-brand-gray-700 mb-3">
63
+ The Treasury: Fuel Your Journey
64
+ </h1>
65
+ <p className="text-brand-gray-500 text-base md:text-lg">
66
+ Get Gems (💎) to forge new courses or ask for hints.
67
+ </p>
68
+ </div>
69
+
70
+ {/* Pricing Tiers */}
71
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-6 md:gap-8 items-stretch justify-center max-w-4xl mx-auto">
72
+ {TIERS.map((tier) => (
73
+ <TierCard
74
+ key={tier.id}
75
+ tier={tier}
76
+ onSelect={() => setSelectedTier(tier)}
77
+ />
78
+ ))}
79
+ </div>
80
+ </div>
81
+
82
+ {/* ── Payment Modal ── */}
83
+ <AnimatePresence>
84
+ {selectedTier && (
85
+ <PaymentModal
86
+ tier={selectedTier}
87
+ onClose={() => setSelectedTier(null)}
88
+ />
89
+ )}
90
+ </AnimatePresence>
91
+ </div>
92
+ );
93
+ }
94
+
95
+ /* ═══════════════════ Tier Card ═══════════════════ */
96
+
97
+ function TierCard({
98
+ tier,
99
+ onSelect,
100
+ }: {
101
+ tier: Tier;
102
+ onSelect: () => void;
103
+ }) {
104
+ if (tier.variant === "pro") {
105
+ return <ProTierCard tier={tier} onSelect={onSelect} />;
106
+ }
107
+
108
+ return (
109
+ <motion.div whileHover={{ y: -6 }} className="relative h-full">
110
+ {/* Best Value ribbon */}
111
+ {tier.badge && (
112
+ <div className="absolute -top-0 -right-0 z-20">
113
+ <div className="relative">
114
+ <div className="bg-gradient-to-r from-brand-green to-emerald-500 text-white text-xs font-bold px-3 py-1 rounded-bl-xl rounded-tr-2xl shadow-lg transform rotate-0 origin-top-right">
115
+ {tier.badge}
116
+ </div>
117
+ </div>
118
+ </div>
119
+ )}
120
+
121
+ <DeepGlassCard
122
+ className={`h-full px-6 py-8 flex flex-col items-center text-center cursor-pointer transition-all hover:shadow-2xl ${
123
+ tier.variant === "popular"
124
+ ? "ring-2 ring-brand-teal/50 shadow-[0_0_30px_rgba(122,199,196,0.15)]"
125
+ : ""
126
+ }`}
127
+ onClick={onSelect}
128
+ >
129
+ {/* Title */}
130
+ <h3 className="font-heading font-bold text-brand-gray-700 text-lg mb-1">
131
+ {tier.label} ({tier.gems} 💎)
132
+ </h3>
133
+
134
+ {/* Illustration */}
135
+ <div className="my-6 h-28 flex items-center justify-center">
136
+ {tier.variant === "starter" ? <GemsIllustration /> : <ChestIllustration />}
137
+ </div>
138
+
139
+ {/* Price */}
140
+ <p className="font-heading text-2xl font-extrabold text-brand-gray-700 mb-5">
141
+ {tier.price}
142
+ </p>
143
+
144
+ {/* Spacer to push button to bottom */}
145
+ <div className="flex-1" />
146
+
147
+ {/* Buy button */}
148
+ <button className="w-full rounded-xl py-3 font-heading font-bold text-white bg-gradient-to-b from-brand-teal to-[#5fb3af] border-b-4 border-[#4a9e9a] shadow-md hover:shadow-lg active:translate-y-0.5 active:shadow-sm transition-all">
149
+ Purchase
150
+ </button>
151
+ </DeepGlassCard>
152
+ </motion.div>
153
+ );
154
+ }
155
+
156
+ /* ── Pro / Subscription Tier ── */
157
+
158
+ function ProTierCard({
159
+ tier,
160
+ onSelect,
161
+ }: {
162
+ tier: Tier;
163
+ onSelect: () => void;
164
+ }) {
165
+ return (
166
+ <motion.div whileHover={{ y: -6 }} className="relative h-full">
167
+ <motion.div
168
+ className="relative rounded-3xl overflow-hidden cursor-pointer shadow-2xl h-full"
169
+ onClick={onSelect}
170
+ >
171
+ {/* Animated gradient background */}
172
+ <div className="absolute inset-0 bg-gradient-to-br from-[#1a0533] via-[#2d1b69] to-[#0f0520] animate-gradient-shift" />
173
+
174
+ {/* Lightning / energy effects */}
175
+ <div className="absolute inset-0 overflow-hidden">
176
+ <ProEffects />
177
+ </div>
178
+
179
+ {/* Content */}
180
+ <div className="relative z-10 px-6 py-8 flex flex-col items-center text-center h-full">
181
+ <h3 className="font-heading font-bold text-white text-lg mb-1">
182
+ {tier.label}
183
+ </h3>
184
+
185
+ {/* Energy icon */}
186
+ <div className="my-6 h-28 flex items-center justify-center">
187
+ <InfiniteEnergyIcon />
188
+ </div>
189
+
190
+ <p className="font-heading text-2xl font-extrabold text-white mb-5">
191
+ {tier.price}
192
+ </p>
193
+
194
+ <div className="flex-1" />
195
+
196
+ <button className="w-full rounded-xl py-3 font-heading font-bold text-[#1a0533] bg-gradient-to-b from-amber-300 to-yellow-400 border-b-4 border-yellow-600 shadow-md hover:shadow-lg active:translate-y-0.5 active:shadow-sm transition-all">
197
+ Subscribe
198
+ </button>
199
+ </div>
200
+ </motion.div>
201
+ </motion.div>
202
+ );
203
+ }
204
+
205
+ /* ═══════════════════ Payment Modal ═══════════════════ */
206
+
207
+ function PaymentModal({
208
+ tier,
209
+ onClose,
210
+ }: {
211
+ tier: Tier;
212
+ onClose: () => void;
213
+ }) {
214
+ const [processing, setProcessing] = useState(false);
215
+ const [done, setDone] = useState(false);
216
+ const addGems = useUserStore((s) => s.addGems);
217
+
218
+ const handlePay = () => {
219
+ setProcessing(true);
220
+ setTimeout(() => {
221
+ setProcessing(false);
222
+ setDone(true);
223
+ if (tier.gems > 0) {
224
+ addGems(tier.gems);
225
+ }
226
+ }, 1800);
227
+ };
228
+
229
+ return (
230
+ <motion.div
231
+ className="fixed inset-0 z-[100] flex items-end justify-center"
232
+ initial={{ opacity: 0 }}
233
+ animate={{ opacity: 1 }}
234
+ exit={{ opacity: 0 }}
235
+ >
236
+ {/* Backdrop */}
237
+ <motion.div
238
+ className="absolute inset-0 bg-black/30"
239
+ onClick={onClose}
240
+ initial={{ opacity: 0, backdropFilter: "blur(0px)" }}
241
+ animate={{ opacity: 1, backdropFilter: "blur(6px)" }}
242
+ exit={{ opacity: 0, backdropFilter: "blur(0px)" }}
243
+ transition={{ duration: 0.6, ease: "easeOut" }}
244
+ />
245
+
246
+ {/* Modal sheet */}
247
+ <motion.div
248
+ className="relative z-10 w-full max-w-md mx-4 mb-0 md:mb-8"
249
+ initial={{ y: "100%" }}
250
+ animate={{ y: 0 }}
251
+ exit={{ y: "100%" }}
252
+ transition={{ type: "spring", damping: 28, stiffness: 300 }}
253
+ >
254
+ <div className="bg-white rounded-t-3xl md:rounded-3xl shadow-2xl overflow-hidden">
255
+ {/* Header */}
256
+ <div className="relative px-6 pt-6 pb-4">
257
+ {/* Close button */}
258
+ <button
259
+ onClick={onClose}
260
+ className="absolute right-4 top-4 h-8 w-8 rounded-full bg-brand-gray-100 flex items-center justify-center text-brand-gray-500 hover:bg-brand-gray-200 transition"
261
+ >
262
+ <svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
263
+ <path d="M18 6L6 18M6 6l12 12" />
264
+ </svg>
265
+ </button>
266
+
267
+ <h2 className="font-heading text-xl font-extrabold text-brand-gray-700 text-center">
268
+ Confirm Purchase
269
+ </h2>
270
+ <p className="text-sm text-brand-gray-500 text-center mt-1">
271
+ {tier.label}{" "}
272
+ {tier.gems > 0 ? `(${tier.gems.toLocaleString()} 💎)` : ""} –{" "}
273
+ {tier.price}
274
+ </p>
275
+ </div>
276
+
277
+ {/* Divider */}
278
+ <div className="h-px bg-brand-gray-100 mx-6" />
279
+
280
+ {done ? (
281
+ /* ── Success state ── */
282
+ <div className="px-6 py-10 flex flex-col items-center gap-3">
283
+ <motion.div
284
+ initial={{ scale: 0 }}
285
+ animate={{ scale: 1 }}
286
+ transition={{ type: "spring", damping: 12 }}
287
+ className="h-16 w-16 rounded-full bg-brand-green/10 flex items-center justify-center"
288
+ >
289
+ <svg viewBox="0 0 24 24" className="h-8 w-8 text-brand-green" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
290
+ <path d="M20 6L9 17l-5-5" />
291
+ </svg>
292
+ </motion.div>
293
+ <p className="font-heading font-bold text-brand-gray-700 text-lg">
294
+ Payment Successful!
295
+ </p>
296
+ <p className="text-sm text-brand-gray-500">
297
+ {tier.gems > 0
298
+ ? `${tier.gems.toLocaleString()} gems have been added to your account.`
299
+ : "Your subscription is now active."}
300
+ </p>
301
+ <button
302
+ onClick={onClose}
303
+ className="mt-4 w-full rounded-xl py-3.5 font-heading font-bold text-white bg-brand-green shadow-md hover:shadow-lg transition active:translate-y-0.5"
304
+ >
305
+ Done
306
+ </button>
307
+ </div>
308
+ ) : (
309
+ /* ── Payment form ── */
310
+ <div className="px-6 py-5 space-y-4">
311
+ {/* Credit card row */}
312
+ <button className="w-full flex items-center justify-between px-4 py-3.5 rounded-xl border border-brand-gray-200 hover:bg-brand-gray-50 transition">
313
+ <div className="flex flex-col items-start">
314
+ <span className="text-xs text-brand-gray-400 font-medium">
315
+ Credit card info
316
+ </span>
317
+ <span className="text-sm text-brand-gray-700 font-semibold tracking-wider mt-0.5">
318
+ •••• •••• •••• 0223
319
+ </span>
320
+ </div>
321
+ <svg viewBox="0 0 24 24" className="h-5 w-5 text-brand-gray-400" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
322
+ <path d="M9 6l6 6-6 6" />
323
+ </svg>
324
+ </button>
325
+
326
+ {/* Apple Pay button */}
327
+ <button
328
+ onClick={handlePay}
329
+ disabled={processing}
330
+ className="w-full flex items-center justify-center gap-2 py-3.5 rounded-xl bg-black text-white font-heading font-bold text-base shadow-lg hover:bg-gray-900 active:bg-gray-800 transition disabled:opacity-70"
331
+ >
332
+ {processing ? (
333
+ <motion.div
334
+ className="h-5 w-5 border-2 border-white/30 border-t-white rounded-full"
335
+ animate={{ rotate: 360 }}
336
+ transition={{ repeat: Infinity, duration: 0.8, ease: "linear" }}
337
+ />
338
+ ) : (
339
+ <>
340
+ Pay with{" "}
341
+ <svg viewBox="0 0 24 24" className="h-5 w-5 fill-white" xmlns="http://www.w3.org/2000/svg">
342
+ <path d="M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z" />
343
+ </svg>
344
+ Pay
345
+ </>
346
+ )}
347
+ </button>
348
+
349
+ {/* Divider */}
350
+ <div className="flex items-center gap-3">
351
+ <div className="flex-1 h-px bg-brand-gray-100" />
352
+ <span className="text-xs text-brand-gray-400">or pay with card</span>
353
+ <div className="flex-1 h-px bg-brand-gray-100" />
354
+ </div>
355
+
356
+ {/* Confirm Purchase button */}
357
+ <button
358
+ onClick={handlePay}
359
+ disabled={processing}
360
+ className="w-full rounded-xl py-3.5 font-heading font-bold text-white bg-gradient-to-b from-brand-teal to-[#5fb3af] border-b-4 border-[#4a9e9a] shadow-md hover:shadow-lg active:translate-y-0.5 active:shadow-sm transition-all disabled:opacity-70"
361
+ >
362
+ {processing ? "Processing…" : `Pay ${tier.price}`}
363
+ </button>
364
+ </div>
365
+ )}
366
+
367
+ {/* Bottom safe-area spacer (mobile) */}
368
+ <div className="h-4 md:h-0" />
369
+ </div>
370
+ </motion.div>
371
+ </motion.div>
372
+ );
373
+ }
374
+
375
+ /* ═══════════════════ Illustrations ═══════════════════ */
376
+
377
+ function GemsIllustration() {
378
+ return (
379
+ <svg viewBox="0 0 120 100" className="h-full w-auto" fill="none">
380
+ {/* Main gem */}
381
+ <polygon points="60,10 80,35 70,80 50,80 40,35" fill="#E8B4C8" opacity="0.4" />
382
+ <polygon points="60,10 80,35 60,40 40,35" fill="#F0C8D8" opacity="0.6" />
383
+ <polygon points="60,40 80,35 70,80" fill="#D8A0B8" opacity="0.5" />
384
+ <polygon points="60,40 40,35 50,80" fill="#E0B0C0" opacity="0.5" />
385
+ {/* Small gems */}
386
+ <polygon points="25,55 35,45 40,60 30,68 20,62" fill="#F0C8D8" opacity="0.5" />
387
+ <polygon points="85,50 95,42 98,55 90,62 82,58" fill="#F0C8D8" opacity="0.5" />
388
+ {/* Sparkles */}
389
+ <circle cx="50" cy="20" r="2" fill="#FFD700" opacity="0.6" />
390
+ <circle cx="75" cy="45" r="1.5" fill="#FFD700" opacity="0.5" />
391
+ <circle cx="30" cy="40" r="1.5" fill="#FFD700" opacity="0.5" />
392
+ </svg>
393
+ );
394
+ }
395
+
396
+ function ChestIllustration() {
397
+ return (
398
+ <svg viewBox="0 0 140 110" className="h-full w-auto" fill="none">
399
+ {/* Chest body */}
400
+ <rect x="25" y="50" width="90" height="45" rx="6" fill="#C4956A" />
401
+ <rect x="25" y="50" width="90" height="45" rx="6" fill="url(#chestGrad)" />
402
+ {/* Chest lid */}
403
+ <path d="M25 50 Q70 20 115 50" fill="#D4A87A" />
404
+ <path d="M25 50 Q70 25 115 50" fill="none" stroke="#B8875A" strokeWidth="1.5" />
405
+ {/* Metal band */}
406
+ <rect x="60" y="44" width="20" height="12" rx="2" fill="#DAA520" />
407
+ <circle cx="70" cy="56" r="4" fill="#B8860B" />
408
+ <circle cx="70" cy="56" r="2" fill="#DAA520" />
409
+ {/* Gems spilling out */}
410
+ <polygon points="50,45 55,35 60,45" fill="#E8B4C8" opacity="0.8" />
411
+ <polygon points="70,38 76,26 82,38" fill="#A0D8E8" opacity="0.8" />
412
+ <polygon points="88,42 92,32 96,42" fill="#C8E8A0" opacity="0.8" />
413
+ <polygon points="55,40 58,32 62,42" fill="#FFD700" opacity="0.6" />
414
+ {/* Sparkles */}
415
+ <circle cx="55" cy="30" r="2" fill="#FFD700" opacity="0.7" />
416
+ <circle cx="80" cy="22" r="2" fill="#FFD700" opacity="0.6" />
417
+ <circle cx="95" cy="28" r="1.5" fill="#FFD700" opacity="0.5" />
418
+ <circle cx="42" cy="38" r="1.5" fill="#FFD700" opacity="0.5" />
419
+ <defs>
420
+ <linearGradient id="chestGrad" x1="25" y1="50" x2="25" y2="95" gradientUnits="userSpaceOnUse">
421
+ <stop offset="0%" stopColor="#D4A87A" />
422
+ <stop offset="100%" stopColor="#A67A4A" />
423
+ </linearGradient>
424
+ </defs>
425
+ </svg>
426
+ );
427
+ }
428
+
429
+ function InfiniteEnergyIcon() {
430
+ return (
431
+ <svg viewBox="0 0 160 120" className="h-full w-auto" fill="none">
432
+ {/* Glow backdrop */}
433
+ <ellipse cx="80" cy="60" rx="60" ry="45" fill="#8B5CF6" opacity="0.1" />
434
+ {/* Infinity symbol */}
435
+ <path
436
+ d="M50 60 C50 40 20 30 20 55 C20 80 50 80 55 60 C60 40 70 35 80 35 C90 35 100 40 105 60 C110 80 140 80 140 55 C140 30 110 40 110 60"
437
+ fill="none"
438
+ stroke="#C084FC"
439
+ strokeWidth="5"
440
+ strokeLinecap="round"
441
+ opacity="0.7"
442
+ />
443
+ <path
444
+ d="M50 60 C50 40 20 30 20 55 C20 80 50 80 55 60 C60 40 70 35 80 35 C90 35 100 40 105 60 C110 80 140 80 140 55 C140 30 110 40 110 60"
445
+ fill="none"
446
+ stroke="white"
447
+ strokeWidth="2"
448
+ strokeLinecap="round"
449
+ opacity="0.5"
450
+ />
451
+ {/* Lightning bolt - left */}
452
+ <path d="M62 35 L55 55 L65 52 L58 75" stroke="#FBBF24" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" fill="none" />
453
+ {/* Lightning bolt - right */}
454
+ <path d="M102 35 L95 55 L105 52 L98 75" stroke="#FBBF24" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" fill="none" />
455
+ {/* Sparkles */}
456
+ <circle cx="40" cy="45" r="2" fill="#FBBF24" opacity="0.7" />
457
+ <circle cx="120" cy="45" r="2" fill="#FBBF24" opacity="0.7" />
458
+ <circle cx="80" cy="25" r="2.5" fill="#C084FC" opacity="0.6" />
459
+ <circle cx="80" cy="95" r="2" fill="#C084FC" opacity="0.5" />
460
+ </svg>
461
+ );
462
+ }
463
+
464
+ /* ── Pro card effects ── */
465
+
466
+ function ProEffects() {
467
+ return (
468
+ <>
469
+ {/* Diagonal light streaks */}
470
+ <div className="absolute top-0 left-1/4 w-px h-full bg-gradient-to-b from-transparent via-purple-400/20 to-transparent rotate-12" />
471
+ <div className="absolute top-0 right-1/3 w-px h-full bg-gradient-to-b from-transparent via-blue-400/15 to-transparent -rotate-12" />
472
+ {/* Corner glow */}
473
+ <div className="absolute -top-10 -right-10 w-40 h-40 rounded-full bg-purple-500/20 blur-3xl" />
474
+ <div className="absolute -bottom-10 -left-10 w-40 h-40 rounded-full bg-blue-600/15 blur-3xl" />
475
+ {/* Floating particles */}
476
+ {[...Array(6)].map((_, i) => (
477
+ <div
478
+ key={i}
479
+ className="absolute rounded-full bg-purple-300 particle"
480
+ style={{
481
+ width: `${2 + Math.random() * 3}px`,
482
+ height: `${2 + Math.random() * 3}px`,
483
+ left: `${10 + Math.random() * 80}%`,
484
+ bottom: `${Math.random() * 20}%`,
485
+ opacity: 0.4 + Math.random() * 0.3,
486
+ animationDelay: `${Math.random() * 5}s`,
487
+ animationDuration: `${4 + Math.random() * 4}s`,
488
+ }}
489
+ />
490
+ ))}
491
+ </>
492
+ );
493
+ }
494
+
495
+ /* ═══════════════════ Background ═══════════════════ */
496
+
497
+ function StoreBg() {
498
+ const runeText =
499
+ "ᚠᚢᚦᚨᚱᚲᚷᚹᚺᚾᛁᛃᛇᛈᛉᛊᛏᛒᛖᛗᛚᛜᛝᛞᛟᚠᚢᚦᚨᚱᚲᚷᚹᚺᚾᛁᛃᛇᛈᛉᛊᛏᛒᛖᛗᛚᛜᛝᛞᛟ";
500
+ return (
501
+ <div className="pointer-events-none absolute inset-0 z-0 overflow-hidden" aria-hidden="true">
502
+ {/* Rune circle – bottom right */}
503
+ <svg
504
+ viewBox="0 0 700 700"
505
+ className="absolute -right-[140px] -bottom-[140px] w-[550px] h-[550px] opacity-20"
506
+ fill="none"
507
+ >
508
+ <defs>
509
+ <radialGradient id="storePeach" cx="50%" cy="45%" r="50%">
510
+ <stop offset="0%" stopColor="#FFF5EC" />
511
+ <stop offset="60%" stopColor="#F5DECA" />
512
+ <stop offset="100%" stopColor="#EDD0B5" />
513
+ </radialGradient>
514
+ <path id="storeRune" d="M 350,350 m -260,0 a 260,260 0 1,1 520,0 a 260,260 0 1,1 -520,0" />
515
+ </defs>
516
+ <circle cx="350" cy="350" r="300" fill="url(#storePeach)" opacity="0.5" />
517
+ <circle cx="350" cy="350" r="290" fill="none" stroke="#D4BC8B" strokeWidth="1" opacity="0.4" />
518
+ <circle cx="350" cy="350" r="248" fill="none" stroke="#D4BC8B" strokeWidth="1" opacity="0.4" />
519
+ <text fill="#C4A87A" fontSize="18" fontWeight="500" letterSpacing="4" opacity="0.35">
520
+ <textPath href="#storeRune">{runeText}</textPath>
521
+ </text>
522
+ </svg>
523
+
524
+ {/* Sparkle */}
525
+ <svg viewBox="0 0 40 40" className="absolute bottom-10 right-10 w-7 h-7" fill="none">
526
+ <path d="M20 0 L22 16 L40 20 L22 22 L20 40 L18 22 L0 20 L18 16 Z" fill="#D4A96A" opacity="0.5" />
527
+ </svg>
528
+ </div>
529
+ );
530
+ }
app/(onboarding)/login/page.tsx ADDED
@@ -0,0 +1,539 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import React, { useState } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { FcGoogle } from "react-icons/fc";
6
+ import { FaFacebook, FaApple } from "react-icons/fa";
7
+
8
+ type AuthTab = "signup" | "login";
9
+
10
+ export default function LoginPage() {
11
+ const router = useRouter();
12
+ const [activeTab, setActiveTab] = useState<AuthTab>("signup");
13
+ const [email, setEmail] = useState("");
14
+ const [password, setPassword] = useState("");
15
+
16
+ return (
17
+ <div className="relative flex min-h-screen flex-col bg-gradient-to-br from-[#edf7fb] via-[#c9e6f2] to-[#a3d5e8] overflow-hidden">
18
+ {/* ── Decorative background: particles ── */}
19
+ <ParticleField />
20
+
21
+ {/* ══════════════ Main content ══════════════ */}
22
+ <main className="relative z-10 flex flex-1 items-center justify-center px-4 py-10 gap-8 flex-wrap lg:flex-nowrap max-w-7xl mx-auto w-full">
23
+ {/* ── LEFT: Hero ── */}
24
+ <section className="flex flex-col items-start max-w-md shrink-0">
25
+ {/* Logo */}
26
+ <div className="flex items-center gap-2 mb-6">
27
+ <OwlIcon className="h-10 w-10 text-brand-teal" />
28
+ <span className="font-heading text-2xl font-extrabold text-brand-gray-700">
29
+ Learn8
30
+ </span>
31
+ </div>
32
+
33
+ <h1 className="font-heading text-4xl md:text-[2.6rem] leading-tight font-extrabold text-brand-gray-700 mb-3">
34
+ Stop Reading,{" "}
35
+ <span className="block">Start Playing.</span>
36
+ </h1>
37
+ <p className="text-brand-gray-500 text-lg mb-8">
38
+ Convert your notes into a dynamic Skill Tree and master any subject through immersive mini-games.
39
+ </p>
40
+
41
+ {/* Mascot */}
42
+ <div className="mb-4 flex justify-center w-full">
43
+ <MascotOwl />
44
+ </div>
45
+ <p className="text-brand-gray-600 font-semibold text-sm">
46
+ Start your learning journey today!
47
+ </p>
48
+ </section>
49
+
50
+ {/* ── CENTER: CTA card ── */}
51
+ <section className="flex flex-col items-center bg-white/70 backdrop-blur-md rounded-2xl shadow-lg px-10 py-10 w-80 shrink-0">
52
+ {/* Tabs */}
53
+ <div className="flex gap-3 mb-6 text-lg font-heading font-bold">
54
+ <button
55
+ onClick={() => setActiveTab("signup")}
56
+ className={`pb-1 transition ${
57
+ activeTab === "signup"
58
+ ? "text-brand-teal border-b-2 border-brand-teal"
59
+ : "text-brand-gray-400"
60
+ }`}
61
+ >
62
+ Sign Up
63
+ </button>
64
+ <span className="text-brand-gray-300">/</span>
65
+ <button
66
+ onClick={() => setActiveTab("login")}
67
+ className={`pb-1 transition ${
68
+ activeTab === "login"
69
+ ? "text-brand-teal border-b-2 border-brand-teal"
70
+ : "text-brand-gray-400"
71
+ }`}
72
+ >
73
+ Log In
74
+ </button>
75
+ </div>
76
+
77
+ {activeTab === "signup" ? (
78
+ <>
79
+ <p className="text-brand-gray-600 text-center mb-6">
80
+ New here?
81
+ <br />
82
+ Create your account.
83
+ </p>
84
+ <button
85
+ className="w-full rounded-xl bg-brand-green px-6 py-3 font-heading font-bold text-white uppercase tracking-wide shadow-md
86
+ hover:bg-brand-green-dark active:translate-y-0.5 transition mb-4"
87
+ >
88
+ Start Your Journey
89
+ </button>
90
+ <button
91
+ onClick={() => setActiveTab("login")}
92
+ className="text-brand-teal font-semibold hover:underline text-sm"
93
+ >
94
+ LOG IN
95
+ </button>
96
+ </>
97
+ ) : (
98
+ <>
99
+ <p className="text-brand-gray-600 text-center mb-6">
100
+ Ready to continue?
101
+ </p>
102
+ <button
103
+ className="w-full rounded-xl bg-brand-green px-6 py-3 font-heading font-bold text-white uppercase tracking-wide shadow-md
104
+ hover:bg-brand-green-dark active:translate-y-0.5 transition mb-4"
105
+ >
106
+ Log In
107
+ </button>
108
+ <button
109
+ onClick={() => setActiveTab("signup")}
110
+ className="text-brand-teal font-semibold hover:underline text-sm"
111
+ >
112
+ SIGN UP
113
+ </button>
114
+ </>
115
+ )}
116
+ </section>
117
+
118
+ {/* ── RIGHT: Auth form card ── */}
119
+ <section className="bg-white rounded-2xl shadow-xl px-8 py-10 w-96 shrink-0">
120
+ <h2 className="font-heading text-2xl font-extrabold text-brand-gray-700 text-center mb-6">
121
+ Begin Your Mastery
122
+ </h2>
123
+
124
+ {/* Form Tabs */}
125
+ <div className="flex border-b border-brand-gray-200 mb-6">
126
+ <button
127
+ onClick={() => setActiveTab("signup")}
128
+ className={`flex-1 pb-2 text-center font-semibold transition ${
129
+ activeTab === "signup"
130
+ ? "text-brand-gray-700 border-b-2 border-brand-gray-700"
131
+ : "text-brand-gray-400"
132
+ }`}
133
+ >
134
+ Sign Up
135
+ </button>
136
+ <button
137
+ onClick={() => setActiveTab("login")}
138
+ className={`flex-1 pb-2 text-center font-semibold transition ${
139
+ activeTab === "login"
140
+ ? "text-brand-gray-700 border-b-2 border-brand-gray-700"
141
+ : "text-brand-gray-400"
142
+ }`}
143
+ >
144
+ Log In
145
+ </button>
146
+ </div>
147
+
148
+ {/* Form Fields */}
149
+ <form
150
+ onSubmit={(e) => { e.preventDefault(); router.push("/welcome"); }}
151
+ className="flex flex-col gap-4"
152
+ >
153
+ <div>
154
+ <label className="block text-xs font-bold uppercase tracking-wide text-brand-gray-500 mb-1">
155
+ {activeTab === "signup" ? "Email or Username" : "Email"}
156
+ </label>
157
+ <input
158
+ type="text"
159
+ placeholder="jane.doe@email.com"
160
+ value={email}
161
+ onChange={(e) => setEmail(e.target.value)}
162
+ className="w-full rounded-lg border border-brand-gray-200 bg-brand-gray-50 px-4 py-2.5 text-sm text-brand-gray-700
163
+ placeholder:text-brand-gray-400 focus:border-brand-teal focus:ring-2 focus:ring-brand-teal/30 outline-none transition"
164
+ />
165
+ </div>
166
+
167
+ <div>
168
+ <label className="block text-xs font-bold uppercase tracking-wide text-brand-gray-500 mb-1">
169
+ Password
170
+ </label>
171
+ <input
172
+ type="password"
173
+ value={password}
174
+ onChange={(e) => setPassword(e.target.value)}
175
+ className="w-full rounded-lg border border-brand-gray-200 bg-brand-gray-50 px-4 py-2.5 text-sm text-brand-gray-700
176
+ placeholder:text-brand-gray-400 focus:border-brand-teal focus:ring-2 focus:ring-brand-teal/30 outline-none transition"
177
+ />
178
+ </div>
179
+
180
+ <button
181
+ type="submit"
182
+ className="w-full rounded-xl bg-brand-coral px-6 py-3 font-heading font-bold text-white uppercase tracking-wide shadow-md
183
+ hover:bg-brand-coral-dark active:translate-y-0.5 transition"
184
+ >
185
+ {activeTab === "signup" ? "Sign Up with Email" : "Log In"}
186
+ </button>
187
+ </form>
188
+
189
+ {/* Divider */}
190
+ <div className="my-5 flex items-center gap-3">
191
+ <div className="h-px flex-1 bg-brand-gray-200" />
192
+ <span className="text-xs text-brand-gray-400">
193
+ Or {activeTab === "signup" ? "sign up" : "log in"} with:
194
+ </span>
195
+ <div className="h-px flex-1 bg-brand-gray-200" />
196
+ </div>
197
+
198
+ {/* Social buttons */}
199
+ <div className="flex justify-center gap-4">
200
+ <SocialButton aria-label="Google">
201
+ <FcGoogle className="h-6 w-6" />
202
+ </SocialButton>
203
+ <SocialButton aria-label="Facebook">
204
+ <FaFacebook className="h-6 w-6 text-[#1877F2]" />
205
+ </SocialButton>
206
+ <SocialButton aria-label="Apple">
207
+ <FaApple className="h-6 w-6 text-black" />
208
+ </SocialButton>
209
+ </div>
210
+
211
+ {/* Footer link */}
212
+ <p className="mt-6 text-center text-sm text-brand-gray-500">
213
+ {activeTab === "signup" ? (
214
+ <>
215
+ Already have an account?{" "}
216
+ <button
217
+ onClick={() => setActiveTab("login")}
218
+ className="font-semibold text-brand-teal hover:underline"
219
+ >
220
+ Log In
221
+ </button>
222
+ </>
223
+ ) : (
224
+ <>
225
+ Don&apos;t have an account?{" "}
226
+ <button
227
+ onClick={() => setActiveTab("signup")}
228
+ className="font-semibold text-brand-teal hover:underline"
229
+ >
230
+ Sign Up
231
+ </button>
232
+ </>
233
+ )}
234
+ </p>
235
+ </section>
236
+ </main>
237
+
238
+ {/* ══════════════ Footer ══════════════ */}
239
+ <footer className="py-4 text-center text-sm text-brand-gray-400">
240
+ <a href="#" className="hover:text-brand-gray-600 transition">
241
+ About
242
+ </a>
243
+ <Separator />
244
+ <a href="#" className="hover:text-brand-gray-600 transition">
245
+ Contact
246
+ </a>
247
+ <Separator />
248
+ <a href="#" className="hover:text-brand-gray-600 transition">
249
+ Privacy
250
+ </a>
251
+ <Separator />
252
+ <a href="#" className="hover:text-brand-gray-600 transition">
253
+ Terms
254
+ </a>
255
+ </footer>
256
+ </div>
257
+ );
258
+ }
259
+
260
+ /* ────────── Small reusable components ────────── */
261
+
262
+ function Separator() {
263
+ return <span className="mx-2 text-brand-gray-300">|</span>;
264
+ }
265
+
266
+ function SocialButton({
267
+ children,
268
+ ...props
269
+ }: React.ButtonHTMLAttributes<HTMLButtonElement>) {
270
+ return (
271
+ <button
272
+ {...props}
273
+ className="flex h-12 w-12 items-center justify-center rounded-full border border-brand-gray-200 bg-white shadow-sm
274
+ hover:shadow-md hover:border-brand-gray-300 active:translate-y-0.5 transition"
275
+ >
276
+ {children}
277
+ </button>
278
+ );
279
+ }
280
+
281
+ /* ── Inline SVG icons ── */
282
+
283
+ function OwlIcon({ className }: { className?: string }) {
284
+ return (
285
+ <svg
286
+ viewBox="0 0 64 64"
287
+ fill="none"
288
+ xmlns="http://www.w3.org/2000/svg"
289
+ className={className}
290
+ >
291
+ <circle cx="32" cy="32" r="30" fill="#7AC7C4" opacity="0.2" />
292
+ <ellipse cx="32" cy="36" rx="18" ry="20" fill="#C4A882" />
293
+ <ellipse cx="32" cy="34" rx="14" ry="16" fill="#E8D5B7" />
294
+ <circle cx="25" cy="28" r="6" fill="white" />
295
+ <circle cx="39" cy="28" r="6" fill="white" />
296
+ <circle cx="26" cy="28" r="3" fill="#333" />
297
+ <circle cx="38" cy="28" r="3" fill="#333" />
298
+ <polygon points="32,32 29,36 35,36" fill="#E8734A" />
299
+ <ellipse cx="22" cy="42" rx="4" ry="6" fill="#C4A882" opacity="0.6" />
300
+ <ellipse cx="42" cy="42" rx="4" ry="6" fill="#C4A882" opacity="0.6" />
301
+ </svg>
302
+ );
303
+ }
304
+
305
+ function MascotOwl() {
306
+ return (
307
+ <svg
308
+ viewBox="0 0 200 220"
309
+ fill="none"
310
+ xmlns="http://www.w3.org/2000/svg"
311
+ className="h-44 w-44"
312
+ >
313
+ {/* Body */}
314
+ <ellipse cx="100" cy="140" rx="55" ry="65" fill="#C4A882" />
315
+ <ellipse cx="100" cy="135" rx="44" ry="52" fill="#E8D5B7" />
316
+
317
+ {/* Head */}
318
+ <ellipse cx="100" cy="80" rx="48" ry="44" fill="#C4A882" />
319
+ <ellipse cx="100" cy="78" rx="40" ry="36" fill="#E8D5B7" />
320
+
321
+ {/* Ear tufts */}
322
+ <polygon points="62,50 72,30 78,55" fill="#C4A882" />
323
+ <polygon points="138,50 128,30 122,55" fill="#C4A882" />
324
+
325
+ {/* Eyes – white */}
326
+ <circle cx="80" cy="75" r="16" fill="white" stroke="#C4A882" strokeWidth="2" />
327
+ <circle cx="120" cy="75" r="16" fill="white" stroke="#C4A882" strokeWidth="2" />
328
+
329
+ {/* Glasses frames */}
330
+ <circle cx="80" cy="75" r="18" fill="none" stroke="#6B6B6B" strokeWidth="2.5" />
331
+ <circle cx="120" cy="75" r="18" fill="none" stroke="#6B6B6B" strokeWidth="2.5" />
332
+ <line x1="98" y1="75" x2="102" y2="75" stroke="#6B6B6B" strokeWidth="2.5" />
333
+ <line x1="62" y1="73" x2="55" y2="68" stroke="#6B6B6B" strokeWidth="2.5" />
334
+ <line x1="138" y1="73" x2="145" y2="68" stroke="#6B6B6B" strokeWidth="2.5" />
335
+
336
+ {/* Pupils */}
337
+ <circle cx="83" cy="76" r="7" fill="#333" />
338
+ <circle cx="117" cy="76" r="7" fill="#333" />
339
+ <circle cx="85" cy="73" r="2.5" fill="white" />
340
+ <circle cx="119" cy="73" r="2.5" fill="white" />
341
+
342
+ {/* Beak */}
343
+ <polygon points="100,90 93,100 107,100" fill="#E8734A" />
344
+
345
+ {/* Belly pattern */}
346
+ <ellipse cx="100" cy="155" rx="28" ry="32" fill="#F5ECD7" />
347
+
348
+ {/* Feet */}
349
+ <ellipse cx="82" cy="202" rx="14" ry="6" fill="#E8734A" />
350
+ <ellipse cx="118" cy="202" rx="14" ry="6" fill="#E8734A" />
351
+
352
+ {/* Right wing waving */}
353
+ <path d="M148,120 Q175,100 168,80 Q165,90 155,105 Q150,112 148,120Z" fill="#C4A882" />
354
+ <path d="M168,80 Q172,70 165,62" stroke="#C4A882" strokeWidth="3" strokeLinecap="round" fill="none" />
355
+
356
+ {/* Left wing */}
357
+ <ellipse cx="52" cy="130" rx="12" ry="28" fill="#C4A882" transform="rotate(10 52 130)" />
358
+ </svg>
359
+ );
360
+ }
361
+
362
+ /* ── Floating particles background effect ── */
363
+
364
+ interface Particle {
365
+ id: number;
366
+ left: string;
367
+ top: string;
368
+ size: number;
369
+ duration: string;
370
+ delay: string;
371
+ type: "dot" | "sparkle" | "rune" | "ring";
372
+ drift?: string;
373
+ floatX?: string;
374
+ floatY?: string;
375
+ opacity: number;
376
+ color: string;
377
+ }
378
+
379
+ const RUNE_CHARS = ["ᚠ", "ᚢ", "ᚦ", "ᚨ", "ᚱ", "ᚲ", "ᚷ", "ᚹ", "ᛁ", "ᛃ", "ᛈ", "ᛉ", "ᛊ", "ᛏ", "ᛒ", "ᛖ", "ᛗ", "ᛚ"];
380
+
381
+ function generateParticles(): Particle[] {
382
+ const particles: Particle[] = [];
383
+ let id = 0;
384
+
385
+ // Floating dots that rise upward
386
+ for (let i = 0; i < 18; i++) {
387
+ particles.push({
388
+ id: id++,
389
+ left: `${Math.random() * 100}%`,
390
+ top: `${70 + Math.random() * 30}%`,
391
+ size: 3 + Math.random() * 4,
392
+ duration: `${10 + Math.random() * 14}s`,
393
+ delay: `${Math.random() * 12}s`,
394
+ type: "dot",
395
+ drift: `${-30 + Math.random() * 60}px`,
396
+ opacity: 0.15 + Math.random() * 0.25,
397
+ color: ["#7AC7C4", "#D4BC8B", "#E8734A", "#C4A882"][Math.floor(Math.random() * 4)],
398
+ });
399
+ }
400
+
401
+ // Twinkling sparkle stars
402
+ for (let i = 0; i < 12; i++) {
403
+ particles.push({
404
+ id: id++,
405
+ left: `${Math.random() * 100}%`,
406
+ top: `${Math.random() * 100}%`,
407
+ size: 6 + Math.random() * 10,
408
+ duration: `${2 + Math.random() * 4}s`,
409
+ delay: `${Math.random() * 5}s`,
410
+ type: "sparkle",
411
+ opacity: 0.2 + Math.random() * 0.3,
412
+ color: ["#D4A96A", "#7AC7C4", "#E8D5B7"][Math.floor(Math.random() * 3)],
413
+ });
414
+ }
415
+
416
+ // Faint floating rune characters
417
+ for (let i = 0; i < 8; i++) {
418
+ particles.push({
419
+ id: id++,
420
+ left: `${5 + Math.random() * 90}%`,
421
+ top: `${10 + Math.random() * 80}%`,
422
+ size: 14 + Math.random() * 10,
423
+ duration: `${8 + Math.random() * 10}s`,
424
+ delay: `${Math.random() * 8}s`,
425
+ type: "rune",
426
+ floatX: `${-40 + Math.random() * 80}px`,
427
+ floatY: `${-60 + Math.random() * -40}px`,
428
+ opacity: 0.08 + Math.random() * 0.12,
429
+ color: "#C4A87A",
430
+ });
431
+ }
432
+
433
+ // Small hollow rings
434
+ for (let i = 0; i < 6; i++) {
435
+ particles.push({
436
+ id: id++,
437
+ left: `${Math.random() * 100}%`,
438
+ top: `${60 + Math.random() * 40}%`,
439
+ size: 8 + Math.random() * 14,
440
+ duration: `${12 + Math.random() * 10}s`,
441
+ delay: `${Math.random() * 10}s`,
442
+ type: "ring",
443
+ drift: `${-20 + Math.random() * 40}px`,
444
+ opacity: 0.12 + Math.random() * 0.18,
445
+ color: ["#7AC7C4", "#D4BC8B"][Math.floor(Math.random() * 2)],
446
+ });
447
+ }
448
+
449
+ return particles;
450
+ }
451
+
452
+ const PARTICLES = generateParticles();
453
+
454
+ function ParticleField() {
455
+ return (
456
+ <div className="pointer-events-none absolute inset-0 z-[1]" aria-hidden="true">
457
+ {PARTICLES.map((p) => {
458
+ const style: React.CSSProperties & Record<string, string> = {
459
+ position: "absolute",
460
+ left: p.left,
461
+ top: p.top,
462
+ "--duration": p.duration,
463
+ "--delay": p.delay,
464
+ };
465
+
466
+ if (p.drift) style["--drift"] = p.drift;
467
+ if (p.floatX) style["--float-x"] = p.floatX;
468
+ if (p.floatY) style["--float-y"] = p.floatY;
469
+
470
+ if (p.type === "dot") {
471
+ return (
472
+ <span
473
+ key={p.id}
474
+ className="particle absolute rounded-full"
475
+ style={{
476
+ ...style,
477
+ width: p.size,
478
+ height: p.size,
479
+ backgroundColor: p.color,
480
+ opacity: p.opacity,
481
+ }}
482
+ />
483
+ );
484
+ }
485
+
486
+ if (p.type === "sparkle") {
487
+ return (
488
+ <svg
489
+ key={p.id}
490
+ className="sparkle absolute"
491
+ style={{ ...style, opacity: p.opacity }}
492
+ width={p.size}
493
+ height={p.size}
494
+ viewBox="0 0 40 40"
495
+ >
496
+ <path
497
+ d="M20 2 L22 16 L36 20 L22 24 L20 38 L18 24 L4 20 L18 16 Z"
498
+ fill={p.color}
499
+ />
500
+ </svg>
501
+ );
502
+ }
503
+
504
+ if (p.type === "rune") {
505
+ const char = RUNE_CHARS[p.id % RUNE_CHARS.length];
506
+ return (
507
+ <span
508
+ key={p.id}
509
+ className="floater absolute font-heading select-none"
510
+ style={{
511
+ ...style,
512
+ fontSize: p.size,
513
+ color: p.color,
514
+ opacity: p.opacity,
515
+ }}
516
+ >
517
+ {char}
518
+ </span>
519
+ );
520
+ }
521
+
522
+ // ring
523
+ return (
524
+ <span
525
+ key={p.id}
526
+ className="particle absolute rounded-full"
527
+ style={{
528
+ ...style,
529
+ width: p.size,
530
+ height: p.size,
531
+ border: `1.5px solid ${p.color}`,
532
+ opacity: p.opacity,
533
+ }}
534
+ />
535
+ );
536
+ })}
537
+ </div>
538
+ );
539
+ }
app/(onboarding)/welcome/page.tsx ADDED
@@ -0,0 +1,637 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import React, { useState, useCallback } from "react";
4
+ import { motion, AnimatePresence } from "framer-motion";
5
+ import { useRouter } from "next/navigation";
6
+ import DeepGlassCard from "@/components/ui/DeepGlassCard";
7
+ import GameButton from "@/components/ui/GameButton";
8
+ import MascotHint from "@/components/ui/MascotHint";
9
+ import useUserStore from "@/stores/useUserStore";
10
+
11
+ /* ═══════════════════ Types ═══════════════════ */
12
+
13
+ interface TopicOption {
14
+ id: string;
15
+ label: string;
16
+ icon: React.ReactNode;
17
+ ring: string; // ring colour class
18
+ }
19
+
20
+ interface Step {
21
+ key: string;
22
+ title: string;
23
+ mascotMsg: string;
24
+ }
25
+
26
+ /* ═══════════════════ Data ═══════════════════ */
27
+
28
+ const TOPICS: TopicOption[] = [
29
+ {
30
+ id: "tech",
31
+ label: "Tech",
32
+ icon: <TechIcon />,
33
+ ring: "ring-[#5BB5B0]",
34
+ },
35
+ {
36
+ id: "history",
37
+ label: "History",
38
+ icon: <HistoryIcon />,
39
+ ring: "ring-[#C4A06A]",
40
+ },
41
+ {
42
+ id: "medicine",
43
+ label: "Medicine",
44
+ icon: <MedicineIcon />,
45
+ ring: "ring-[#5BB5B0]",
46
+ },
47
+ {
48
+ id: "arts",
49
+ label: "Arts",
50
+ icon: <ArtsIcon />,
51
+ ring: "ring-[#D4765A]",
52
+ },
53
+ {
54
+ id: "literature",
55
+ label: "Literature",
56
+ icon: <LiteratureIcon />,
57
+ ring: "ring-[#9B6DBF]",
58
+ },
59
+ {
60
+ id: "science",
61
+ label: "Science",
62
+ icon: <ScienceIcon />,
63
+ ring: "ring-[#9B6DBF]",
64
+ },
65
+ ];
66
+
67
+ const STEPS: Step[] = [
68
+ {
69
+ key: "topics",
70
+ title: "Welcome, Explorer,",
71
+ mascotMsg: "Which fields spark your interest?",
72
+ },
73
+ {
74
+ key: "name",
75
+ title: "What should we call you?",
76
+ mascotMsg: "Give yourself an explorer name!",
77
+ },
78
+ {
79
+ key: "goal",
80
+ title: "Set your daily goal",
81
+ mascotMsg: "How much time can you spare each day?",
82
+ },
83
+ ];
84
+
85
+ const GOALS = [
86
+ { id: "casual", label: "5 min / day", desc: "Casual" },
87
+ { id: "regular", label: "10 min / day", desc: "Regular" },
88
+ { id: "serious", label: "20 min / day", desc: "Serious" },
89
+ { id: "intense", label: "30 min / day", desc: "Intense" },
90
+ ];
91
+
92
+ /* ═══════════════════ Slide variants ═══════════════════ */
93
+
94
+ const slideVariants = {
95
+ enter: (dir: number) => ({
96
+ x: dir > 0 ? 300 : -300,
97
+ opacity: 0,
98
+ scale: 0.95,
99
+ }),
100
+ center: { x: 0, opacity: 1, scale: 1 },
101
+ exit: (dir: number) => ({
102
+ x: dir > 0 ? -300 : 300,
103
+ opacity: 0,
104
+ scale: 0.95,
105
+ }),
106
+ };
107
+
108
+ /* ═══════════════════ Page ═══════════════════ */
109
+
110
+ export default function WelcomePage() {
111
+ const router = useRouter();
112
+ const [step, setStep] = useState(0);
113
+ const [direction, setDirection] = useState(1);
114
+ const [selectedTopics, setSelectedTopics] = useState<string[]>([]);
115
+ const [name, setName] = useState("");
116
+ const [selectedGoal, setSelectedGoal] = useState("");
117
+
118
+ const currentStep = STEPS[step];
119
+
120
+ const canProceed = useCallback(() => {
121
+ if (step === 0) return selectedTopics.length > 0;
122
+ if (step === 1) return name.trim().length > 0;
123
+ if (step === 2) return selectedGoal !== "";
124
+ return false;
125
+ }, [step, selectedTopics, name, selectedGoal]);
126
+
127
+ const completeOnboarding = useUserStore((s) => s.completeOnboarding);
128
+
129
+ const handleNext = () => {
130
+ if (step < STEPS.length - 1) {
131
+ setDirection(1);
132
+ setStep((s) => s + 1);
133
+ } else {
134
+ // Final step – persist onboarding data & navigate
135
+ completeOnboarding({ name: name.trim(), topics: selectedTopics, goal: selectedGoal });
136
+ router.push("/home");
137
+ }
138
+ };
139
+
140
+ const handleBack = () => {
141
+ if (step > 0) {
142
+ setDirection(-1);
143
+ setStep((s) => s - 1);
144
+ }
145
+ };
146
+
147
+ const toggleTopic = (id: string) => {
148
+ setSelectedTopics((prev) =>
149
+ prev.includes(id) ? prev.filter((t) => t !== id) : [...prev, id]
150
+ );
151
+ };
152
+
153
+ return (
154
+ <div className="relative flex min-h-screen flex-col bg-gradient-to-br from-[#edf7fb] via-[#c9e6f2] to-[#a3d5e8] overflow-hidden">
155
+ {/* ── Shared background effects ── */}
156
+ <BgEffects />
157
+
158
+ {/* ── Blurred side hints (conditional) ── */}
159
+ {step > 0 && (
160
+ <SideHint side="left" step={STEPS[step - 1]} />
161
+ )}
162
+ {step < STEPS.length - 1 && (
163
+ <SideHint side="right" step={STEPS[step + 1]} />
164
+ )}
165
+
166
+ {/* ══════════════ Main ══════════════ */}
167
+ <main
168
+ className="relative z-10 flex flex-1 flex-col items-center justify-center px-4 py-10"
169
+ >
170
+ <motion.div
171
+ className="w-full max-w-2xl cursor-grab active:cursor-grabbing"
172
+ drag="x"
173
+ dragConstraints={{ left: 0, right: 0 }}
174
+ dragElastic={0.15}
175
+ onDragEnd={(_e, info) => {
176
+ if (info.offset.x < -80 && step < STEPS.length - 1 && canProceed()) {
177
+ handleNext();
178
+ } else if (info.offset.x > 80 && step > 0) {
179
+ handleBack();
180
+ }
181
+ }}
182
+ >
183
+ <DeepGlassCard className="w-full px-8 py-10 md:px-12 md:py-12">
184
+ {/* Header */}
185
+ <h1 className="text-center font-heading text-3xl md:text-4xl font-extrabold text-brand-gray-700 mb-4">
186
+ {currentStep.title}
187
+ </h1>
188
+
189
+ {/* Mascot hint */}
190
+ <div className="mb-8 flex justify-center">
191
+ <MascotHint message={currentStep.mascotMsg} />
192
+ </div>
193
+
194
+ {/* Step content – animated */}
195
+ <div className="relative overflow-hidden" style={{ minHeight: 260 }}>
196
+ <AnimatePresence mode="wait" custom={direction}>
197
+ <motion.div
198
+ key={currentStep.key}
199
+ custom={direction}
200
+ variants={slideVariants}
201
+ initial="enter"
202
+ animate="center"
203
+ exit="exit"
204
+ transition={{ type: "spring", stiffness: 300, damping: 30 }}
205
+ className="w-full"
206
+ >
207
+ {step === 0 && (
208
+ <TopicGrid
209
+ topics={TOPICS}
210
+ selected={selectedTopics}
211
+ onToggle={toggleTopic}
212
+ />
213
+ )}
214
+ {step === 1 && <NameInput value={name} onChange={setName} />}
215
+ {step === 2 && (
216
+ <GoalPicker
217
+ selected={selectedGoal}
218
+ onSelect={setSelectedGoal}
219
+ />
220
+ )}
221
+ </motion.div>
222
+ </AnimatePresence>
223
+ </div>
224
+
225
+ {/* Bottom bar */}
226
+ <div className="mt-8 flex items-center justify-between">
227
+ {step > 0 ? (
228
+ <button
229
+ onClick={handleBack}
230
+ className="text-brand-gray-400 hover:text-brand-gray-600 font-semibold transition text-sm"
231
+ >
232
+ ← Back
233
+ </button>
234
+ ) : (
235
+ <div />
236
+ )}
237
+
238
+ <div className="flex items-center gap-4">
239
+ <GameButton
240
+ pulse={canProceed()}
241
+ disabled={!canProceed()}
242
+ onClick={handleNext}
243
+ >
244
+ {step < STEPS.length - 1 ? "NEXT →" : "START JOURNEY →"}
245
+ </GameButton>
246
+ {step === STEPS.length - 1 && (
247
+ <span className="text-xs text-brand-gray-400 max-w-[120px] leading-tight">
248
+ Start your learning journey today!
249
+ </span>
250
+ )}
251
+ </div>
252
+ </div>
253
+ </DeepGlassCard>
254
+ </motion.div>
255
+
256
+ {/* Progress dots */}
257
+ <div className="mt-6 flex gap-2">
258
+ {STEPS.map((_, i) => (
259
+ <div
260
+ key={i}
261
+ className={`h-2 rounded-full transition-all duration-300 ${
262
+ i === step
263
+ ? "w-8 bg-brand-teal"
264
+ : i < step
265
+ ? "w-4 bg-brand-teal/50"
266
+ : "w-4 bg-brand-gray-300"
267
+ }`}
268
+ />
269
+ ))}
270
+ </div>
271
+ </main>
272
+
273
+ {/* ── Footer ── */}
274
+ <footer className="relative z-10 py-4 text-center text-sm text-brand-gray-400">
275
+ <a href="#" className="hover:text-brand-gray-600 transition">About</a>
276
+ <Sep />
277
+ <a href="#" className="hover:text-brand-gray-600 transition">Contact</a>
278
+ <Sep />
279
+ <a href="#" className="hover:text-brand-gray-600 transition">Privacy</a>
280
+ <Sep />
281
+ <a href="#" className="hover:text-brand-gray-600 transition">Terms</a>
282
+ </footer>
283
+ </div>
284
+ );
285
+ }
286
+
287
+ /* ═══════════════════ Sub-components ═══════════════════ */
288
+
289
+ function Sep() {
290
+ return <span className="mx-2 text-brand-gray-300">|</span>;
291
+ }
292
+
293
+ /* ── Topic selection grid ── */
294
+
295
+ function TopicGrid({
296
+ topics,
297
+ selected,
298
+ onToggle,
299
+ }: {
300
+ topics: TopicOption[];
301
+ selected: string[];
302
+ onToggle: (id: string) => void;
303
+ }) {
304
+ return (
305
+ <div className="grid grid-cols-3 gap-5 justify-items-center pt-4">
306
+ {topics.map((t) => {
307
+ const active = selected.includes(t.id);
308
+ return (
309
+ <motion.button
310
+ key={t.id}
311
+ whileTap={{ scale: 0.92 }}
312
+ onClick={() => onToggle(t.id)}
313
+ className={`flex flex-col items-center gap-2 transition-all duration-200`}
314
+ >
315
+ <div
316
+ className={`h-16 w-16 md:h-20 md:w-20 rounded-full flex items-center justify-center ring-4 transition-all duration-200
317
+ ${active ? `${t.ring} ring-opacity-100 bg-white shadow-lg scale-110` : "ring-transparent bg-white/60 shadow-sm hover:shadow-md hover:scale-105"}`}
318
+ >
319
+ {t.icon}
320
+ </div>
321
+ <span
322
+ className={`text-xs md:text-sm font-semibold transition ${
323
+ active ? "text-brand-gray-700" : "text-brand-gray-500"
324
+ }`}
325
+ >
326
+ {t.label}
327
+ </span>
328
+ </motion.button>
329
+ );
330
+ })}
331
+ </div>
332
+ );
333
+ }
334
+
335
+ /* ── Name input ── */
336
+
337
+ function NameInput({
338
+ value,
339
+ onChange,
340
+ }: {
341
+ value: string;
342
+ onChange: (v: string) => void;
343
+ }) {
344
+ return (
345
+ <div className="flex flex-col items-center gap-6 py-6">
346
+ <div className="w-full max-w-sm">
347
+ <label className="block text-xs font-bold uppercase tracking-wide text-brand-gray-500 mb-2">
348
+ Your Explorer Name
349
+ </label>
350
+ <input
351
+ type="text"
352
+ placeholder="e.g. StarSeeker42"
353
+ value={value}
354
+ onChange={(e) => onChange(e.target.value)}
355
+ className="w-full rounded-xl border border-brand-gray-200 bg-white/80 px-5 py-3.5 text-lg text-brand-gray-700
356
+ placeholder:text-brand-gray-300 focus:border-brand-teal focus:ring-2 focus:ring-brand-teal/30 outline-none transition"
357
+ autoFocus
358
+ />
359
+ </div>
360
+ <p className="text-sm text-brand-gray-400 text-center max-w-xs">
361
+ This is how other explorers will see you. You can change it later.
362
+ </p>
363
+ </div>
364
+ );
365
+ }
366
+
367
+ /* ── Goal picker ── */
368
+
369
+ function GoalPicker({
370
+ selected,
371
+ onSelect,
372
+ }: {
373
+ selected: string;
374
+ onSelect: (id: string) => void;
375
+ }) {
376
+ return (
377
+ <div className="flex flex-col gap-3 max-w-sm mx-auto py-4">
378
+ {GOALS.map((g) => (
379
+ <motion.button
380
+ key={g.id}
381
+ whileTap={{ scale: 0.97 }}
382
+ onClick={() => onSelect(g.id)}
383
+ className={`flex items-center justify-between rounded-xl border-2 px-5 py-4 transition-all duration-200
384
+ ${
385
+ selected === g.id
386
+ ? "border-brand-teal bg-brand-teal/10 shadow-md"
387
+ : "border-brand-gray-200 bg-white/60 hover:border-brand-gray-300 hover:shadow-sm"
388
+ }`}
389
+ >
390
+ <div className="text-left">
391
+ <p className="font-heading font-bold text-brand-gray-700">
392
+ {g.label}
393
+ </p>
394
+ <p className="text-xs text-brand-gray-400">{g.desc}</p>
395
+ </div>
396
+ <div
397
+ className={`h-5 w-5 rounded-full border-2 transition-all flex items-center justify-center
398
+ ${selected === g.id ? "border-brand-teal bg-brand-teal" : "border-brand-gray-300"}`}
399
+ >
400
+ {selected === g.id && (
401
+ <svg viewBox="0 0 12 12" className="h-3 w-3" fill="white">
402
+ <path d="M10 3L4.5 8.5 2 6" stroke="white" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" />
403
+ </svg>
404
+ )}
405
+ </div>
406
+ </motion.button>
407
+ ))}
408
+ </div>
409
+ );
410
+ }
411
+
412
+ /* ── Blurred side hints (peek of prev/next step from edges) ── */
413
+
414
+ function SideHint({ side, step }: { side: "left" | "right"; step: Step }) {
415
+ const isLeft = side === "left";
416
+ return (
417
+ <AnimatePresence>
418
+ <motion.div
419
+ key={`${side}-${step.key}`}
420
+ initial={{ opacity: 0, x: isLeft ? -40 : 40 }}
421
+ animate={{ opacity: 1, x: 0 }}
422
+ exit={{ opacity: 0, x: isLeft ? -40 : 40 }}
423
+ transition={{ duration: 0.3 }}
424
+ className={`pointer-events-none absolute top-1/2 -translate-y-1/2 z-[2] ${
425
+ isLeft ? "left-0 -translate-x-[60%]" : "right-0 translate-x-[60%]"
426
+ }`}
427
+ >
428
+ <div className="blur-[3px] opacity-25">
429
+ <div className="bg-white/50 backdrop-blur-md rounded-2xl px-8 py-12 w-80 shadow-lg">
430
+ <h3 className="font-heading text-xl font-bold text-brand-gray-600 mb-3">
431
+ {step.title.length > 14
432
+ ? isLeft
433
+ ? step.title.slice(0, 12) + "..."
434
+ : "..." + step.title.slice(-12)
435
+ : step.title}
436
+ </h3>
437
+ <p className="text-sm text-brand-gray-400">
438
+ {step.mascotMsg.length > 24
439
+ ? isLeft
440
+ ? step.mascotMsg.slice(0, 22) + "..."
441
+ : "..." + step.mascotMsg.slice(-22)
442
+ : step.mascotMsg}
443
+ </p>
444
+ </div>
445
+ </div>
446
+ </motion.div>
447
+ </AnimatePresence>
448
+ );
449
+ }
450
+
451
+ /* ── Background effects (same as login page) ── */
452
+
453
+ function BgEffects() {
454
+ const runeText =
455
+ "ᚠᚢᚦᚨᚱᚲᚷᚹᚺᚾᛁᛃᛇᛈᛉᛊᛏᛒᛖᛗᛚᛜᛝᛞᛟᚠᚢᚦᚨᚱᚲᚷᚹᚺᚾᛁᛃᛇᛈᛉᛊᛏᛒᛖᛗᛚᛜᛝᛞᛟ•ᚠᚢᚦᚨᚱᚲᚷᚹᚺᚾᛁᛃᛇ";
456
+
457
+ return (
458
+ <>
459
+ {/* Runic circle */}
460
+ <div className="pointer-events-none absolute inset-0 z-0" aria-hidden="true">
461
+ <svg
462
+ viewBox="0 0 700 700"
463
+ className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[800px] opacity-25"
464
+ fill="none"
465
+ >
466
+ <defs>
467
+ <radialGradient id="peachG" cx="50%" cy="45%" r="50%">
468
+ <stop offset="0%" stopColor="#FFF5EC" />
469
+ <stop offset="60%" stopColor="#F5DECA" />
470
+ <stop offset="100%" stopColor="#EDD0B5" />
471
+ </radialGradient>
472
+ <path
473
+ id="rRing"
474
+ d="M 350,350 m -260,0 a 260,260 0 1,1 520,0 a 260,260 0 1,1 -520,0"
475
+ />
476
+ </defs>
477
+ <circle cx="350" cy="350" r="300" fill="url(#peachG)" opacity="0.4" />
478
+ <circle cx="350" cy="350" r="290" fill="none" stroke="#D4BC8B" strokeWidth="1" opacity="0.35" />
479
+ <circle cx="350" cy="350" r="248" fill="none" stroke="#D4BC8B" strokeWidth="1" opacity="0.35" />
480
+ <text fill="#C4A87A" fontSize="18" fontWeight="500" letterSpacing="4" opacity="0.3">
481
+ <textPath href="#rRing">{runeText}</textPath>
482
+ </text>
483
+ </svg>
484
+
485
+ {/* Sparkle */}
486
+ <svg viewBox="0 0 40 40" className="absolute bottom-6 right-6 w-6 h-6 md:w-8 md:h-8" fill="none">
487
+ <path d="M20 0 L22 16 L40 20 L22 22 L20 40 L18 22 L0 20 L18 16 Z" fill="#D4A96A" opacity="0.5" />
488
+ </svg>
489
+ </div>
490
+
491
+ {/* Floating particles */}
492
+ <ParticlesBg />
493
+ </>
494
+ );
495
+ }
496
+
497
+ /* ── Lightweight particles for welcome page ── */
498
+
499
+ function ParticlesBg() {
500
+ const dots = Array.from({ length: 14 }, (_, i) => ({
501
+ id: i,
502
+ left: `${Math.random() * 100}%`,
503
+ top: `${70 + Math.random() * 30}%`,
504
+ size: 3 + Math.random() * 4,
505
+ dur: `${10 + Math.random() * 14}s`,
506
+ del: `${Math.random() * 12}s`,
507
+ drift: `${-30 + Math.random() * 60}px`,
508
+ opacity: 0.12 + Math.random() * 0.2,
509
+ color: ["#7AC7C4", "#D4BC8B", "#E8734A", "#C4A882"][i % 4],
510
+ }));
511
+
512
+ const sparkles = Array.from({ length: 8 }, (_, i) => ({
513
+ id: 100 + i,
514
+ left: `${Math.random() * 100}%`,
515
+ top: `${Math.random() * 100}%`,
516
+ size: 6 + Math.random() * 10,
517
+ dur: `${2 + Math.random() * 4}s`,
518
+ del: `${Math.random() * 5}s`,
519
+ opacity: 0.15 + Math.random() * 0.25,
520
+ color: ["#D4A96A", "#7AC7C4", "#E8D5B7"][i % 3],
521
+ }));
522
+
523
+ return (
524
+ <div className="pointer-events-none absolute inset-0 z-[1]" aria-hidden="true">
525
+ {dots.map((d) => (
526
+ <span
527
+ key={d.id}
528
+ className="particle absolute rounded-full"
529
+ style={{
530
+ left: d.left,
531
+ top: d.top,
532
+ width: d.size,
533
+ height: d.size,
534
+ backgroundColor: d.color,
535
+ opacity: d.opacity,
536
+ "--duration": d.dur,
537
+ "--delay": d.del,
538
+ "--drift": d.drift,
539
+ } as React.CSSProperties}
540
+ />
541
+ ))}
542
+ {sparkles.map((s) => (
543
+ <svg
544
+ key={s.id}
545
+ className="sparkle absolute"
546
+ style={{
547
+ left: s.left,
548
+ top: s.top,
549
+ opacity: s.opacity,
550
+ "--duration": s.dur,
551
+ "--delay": s.del,
552
+ } as React.CSSProperties}
553
+ width={s.size}
554
+ height={s.size}
555
+ viewBox="0 0 40 40"
556
+ >
557
+ <path d="M20 2 L22 16 L36 20 L22 24 L20 38 L18 24 L4 20 L18 16 Z" fill={s.color} />
558
+ </svg>
559
+ ))}
560
+ </div>
561
+ );
562
+ }
563
+
564
+ /* ═══════════════════ Topic Icons (inline SVG) ═══════════════════ */
565
+
566
+ function TechIcon() {
567
+ return (
568
+ <svg viewBox="0 0 48 48" className="h-8 w-8 md:h-10 md:w-10" fill="none">
569
+ <rect x="8" y="10" width="32" height="22" rx="3" stroke="#5BB5B0" strokeWidth="2.5" fill="#E8F5F4" />
570
+ <rect x="12" y="14" width="24" height="14" rx="1" fill="#5BB5B0" opacity="0.2" />
571
+ <line x1="18" y1="36" x2="30" y2="36" stroke="#5BB5B0" strokeWidth="2.5" strokeLinecap="round" />
572
+ <line x1="24" y1="32" x2="24" y2="36" stroke="#5BB5B0" strokeWidth="2.5" />
573
+ <rect x="16" y="18" width="16" height="3" rx="1" fill="#5BB5B0" opacity="0.5" />
574
+ <rect x="16" y="23" width="10" height="2" rx="1" fill="#5BB5B0" opacity="0.3" />
575
+ </svg>
576
+ );
577
+ }
578
+
579
+ function HistoryIcon() {
580
+ return (
581
+ <svg viewBox="0 0 48 48" className="h-8 w-8 md:h-10 md:w-10" fill="none">
582
+ <rect x="12" y="8" width="24" height="32" rx="2" fill="#F5ECD7" stroke="#C4A06A" strokeWidth="2" />
583
+ <line x1="16" y1="16" x2="32" y2="16" stroke="#C4A06A" strokeWidth="1.5" />
584
+ <line x1="16" y1="21" x2="32" y2="21" stroke="#C4A06A" strokeWidth="1.5" />
585
+ <line x1="16" y1="26" x2="28" y2="26" stroke="#C4A06A" strokeWidth="1.5" />
586
+ <path d="M20 8V6a4 4 0 0 1 8 0v2" stroke="#C4A06A" strokeWidth="2" />
587
+ <circle cx="24" cy="34" r="3" fill="#C4A06A" opacity="0.3" />
588
+ </svg>
589
+ );
590
+ }
591
+
592
+ function MedicineIcon() {
593
+ return (
594
+ <svg viewBox="0 0 48 48" className="h-8 w-8 md:h-10 md:w-10" fill="none">
595
+ <circle cx="24" cy="24" r="16" fill="#E8F5F4" stroke="#5BB5B0" strokeWidth="2" />
596
+ <path d="M24 14v20M14 24h20" stroke="#5BB5B0" strokeWidth="3" strokeLinecap="round" />
597
+ <circle cx="24" cy="12" r="2" fill="#5BB5B0" opacity="0.4" />
598
+ <path d="M18 16l-2-2M30 16l2-2M18 32l-2 2M30 32l2 2" stroke="#5BB5B0" strokeWidth="1.5" strokeLinecap="round" />
599
+ </svg>
600
+ );
601
+ }
602
+
603
+ function ArtsIcon() {
604
+ return (
605
+ <svg viewBox="0 0 48 48" className="h-8 w-8 md:h-10 md:w-10" fill="none">
606
+ <circle cx="24" cy="24" r="16" fill="#FFF0E8" stroke="#D4765A" strokeWidth="2" />
607
+ <path d="M20 18l-6 14h4l2-5h8l2 5h4l-6-14h-8z" fill="#D4765A" opacity="0.3" />
608
+ <line x1="18" y1="30" x2="30" y2="30" stroke="#D4765A" strokeWidth="2" strokeLinecap="round" />
609
+ <path d="M22 18v-4M26 18v-4" stroke="#D4765A" strokeWidth="1.5" strokeLinecap="round" />
610
+ </svg>
611
+ );
612
+ }
613
+
614
+ function LiteratureIcon() {
615
+ return (
616
+ <svg viewBox="0 0 48 48" className="h-8 w-8 md:h-10 md:w-10" fill="none">
617
+ <path d="M10 12c4-2 8-2 14 1v24c-6-3-10-3-14-1V12z" fill="#F3E8F5" stroke="#9B6DBF" strokeWidth="2" />
618
+ <path d="M24 13c6-3 10-3 14-1v24c-4-2-8-2-14 1V13z" fill="#F3E8F5" stroke="#9B6DBF" strokeWidth="2" />
619
+ <line x1="14" y1="18" x2="20" y2="20" stroke="#9B6DBF" strokeWidth="1" opacity="0.5" />
620
+ <line x1="14" y1="22" x2="20" y2="24" stroke="#9B6DBF" strokeWidth="1" opacity="0.5" />
621
+ <line x1="28" y1="20" x2="34" y2="18" stroke="#9B6DBF" strokeWidth="1" opacity="0.5" />
622
+ <line x1="28" y1="24" x2="34" y2="22" stroke="#9B6DBF" strokeWidth="1" opacity="0.5" />
623
+ </svg>
624
+ );
625
+ }
626
+
627
+ function ScienceIcon() {
628
+ return (
629
+ <svg viewBox="0 0 48 48" className="h-8 w-8 md:h-10 md:w-10" fill="none">
630
+ <circle cx="24" cy="24" r="16" fill="#F3E8F5" stroke="#9B6DBF" strokeWidth="2" />
631
+ <circle cx="24" cy="24" r="4" fill="#9B6DBF" opacity="0.3" stroke="#9B6DBF" strokeWidth="1.5" />
632
+ <ellipse cx="24" cy="24" rx="14" ry="6" fill="none" stroke="#9B6DBF" strokeWidth="1.5" opacity="0.5" />
633
+ <ellipse cx="24" cy="24" rx="14" ry="6" fill="none" stroke="#9B6DBF" strokeWidth="1.5" opacity="0.5" transform="rotate(60 24 24)" />
634
+ <ellipse cx="24" cy="24" rx="14" ry="6" fill="none" stroke="#9B6DBF" strokeWidth="1.5" opacity="0.5" transform="rotate(120 24 24)" />
635
+ </svg>
636
+ );
637
+ }
app/forge/page.tsx ADDED
@@ -0,0 +1,355 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import React, { useState, useEffect } from "react";
4
+ import { motion, AnimatePresence } from "framer-motion";
5
+ import { useRouter } from "next/navigation";
6
+
7
+ /* ═══════════════════ Constants ═══════════════════ */
8
+
9
+ const STEPS = [
10
+ "Scanning document...",
11
+ "Extracting key concepts...",
12
+ "Building knowledge graph...",
13
+ "Forging interactive stages...",
14
+ "Polishing your universe...",
15
+ ];
16
+
17
+ const STEP_DURATION = 2000; // ms per step
18
+
19
+ /* ═══════════════════ Page ═══════════════════ */
20
+
21
+ export default function ForgePage() {
22
+ const router = useRouter();
23
+ const [stepIdx, setStepIdx] = useState(0);
24
+ const [charIdx, setCharIdx] = useState(0);
25
+ const [flash, setFlash] = useState(false);
26
+
27
+ /* Typewriter per step */
28
+ useEffect(() => {
29
+ if (charIdx < STEPS[stepIdx].length) {
30
+ const t = setTimeout(() => setCharIdx((c) => c + 1), 38);
31
+ return () => clearTimeout(t);
32
+ }
33
+ }, [charIdx, stepIdx]);
34
+
35
+ /* Advance steps */
36
+ useEffect(() => {
37
+ if (stepIdx >= STEPS.length) return;
38
+ const t = setTimeout(() => {
39
+ if (stepIdx < STEPS.length - 1) {
40
+ setStepIdx((s) => s + 1);
41
+ setCharIdx(0);
42
+ } else {
43
+ // Last step done → flash + navigate
44
+ setFlash(true);
45
+ setTimeout(() => router.push("/map/med-u1"), 800);
46
+ }
47
+ }, STEP_DURATION);
48
+ return () => clearTimeout(t);
49
+ }, [stepIdx, router]);
50
+
51
+ const progress = ((stepIdx + 1) / STEPS.length) * 100;
52
+
53
+ return (
54
+ <div className="relative min-h-screen flex flex-col items-center justify-center overflow-hidden bg-gradient-to-br from-[#edf7fb] via-[#c9e6f2] to-[#a3d5e8]">
55
+ {/* ── Background particles ── */}
56
+ <ForgeBg />
57
+
58
+ {/* ── Flash overlay ── */}
59
+ <AnimatePresence>
60
+ {flash && (
61
+ <motion.div
62
+ className="fixed inset-0 z-[200] bg-white"
63
+ initial={{ opacity: 0 }}
64
+ animate={{ opacity: 1 }}
65
+ transition={{ duration: 0.5 }}
66
+ />
67
+ )}
68
+ </AnimatePresence>
69
+
70
+ {/* ── Main content ── */}
71
+ <div className="relative z-10 flex flex-col items-center gap-8 px-4">
72
+ {/* Anvil animation */}
73
+ <motion.div
74
+ animate={{
75
+ scale: [1, 1.04, 1],
76
+ }}
77
+ transition={{
78
+ repeat: Infinity,
79
+ duration: 2.5,
80
+ ease: "easeInOut",
81
+ }}
82
+ >
83
+ <ForgeAnvil progress={progress} />
84
+ </motion.div>
85
+
86
+ {/* Typewriter text */}
87
+ <div className="h-10 flex items-center">
88
+ <motion.p
89
+ key={stepIdx}
90
+ initial={{ opacity: 0, y: 10 }}
91
+ animate={{ opacity: 1, y: 0 }}
92
+ className="font-heading text-xl md:text-2xl font-bold text-brand-gray-700 tracking-wide"
93
+ >
94
+ {STEPS[stepIdx].slice(0, charIdx)}
95
+ <motion.span
96
+ animate={{ opacity: [1, 0] }}
97
+ transition={{ repeat: Infinity, duration: 0.6 }}
98
+ className="inline-block w-0.5 h-5 bg-brand-teal ml-0.5 align-middle"
99
+ />
100
+ </motion.p>
101
+ </div>
102
+
103
+ {/* Progress dots */}
104
+ <div className="flex gap-2.5">
105
+ {STEPS.map((_, i) => (
106
+ <motion.div
107
+ key={i}
108
+ className={`h-2.5 rounded-full transition-all duration-500 ${
109
+ i <= stepIdx
110
+ ? "bg-brand-teal w-8"
111
+ : "bg-brand-gray-200 w-2.5"
112
+ }`}
113
+ layout
114
+ />
115
+ ))}
116
+ </div>
117
+
118
+ {/* Subtle sub-text */}
119
+ <p className="text-sm text-brand-gray-400 mt-2">
120
+ Forging your personalised learning universe…
121
+ </p>
122
+ </div>
123
+ </div>
124
+ );
125
+ }
126
+
127
+ /* ═══════════════════ Anvil SVG ═══════════════════ */
128
+
129
+ function ForgeAnvil({ progress }: { progress: number }) {
130
+ return (
131
+ <div className="relative w-72 h-72 md:w-80 md:h-80">
132
+ {/* Glow behind anvil */}
133
+ <div
134
+ className="absolute inset-0 rounded-full blur-3xl"
135
+ style={{
136
+ background: `radial-gradient(circle, rgba(122,199,196,${0.12 + progress * 0.002}) 0%, rgba(212,169,106,${0.08 + progress * 0.002}) 50%, transparent 70%)`,
137
+ }}
138
+ />
139
+
140
+ <svg viewBox="0 0 400 400" className="w-full h-full" fill="none">
141
+ <defs>
142
+ {/* Runic circle path */}
143
+ <path
144
+ id="forgeRune"
145
+ d="M 200,200 m -155,0 a 155,155 0 1,1 310,0 a 155,155 0 1,1 -310,0"
146
+ />
147
+ <radialGradient id="anvilGlow" cx="50%" cy="40%" r="50%">
148
+ <stop offset="0%" stopColor="#FFD5B0" stopOpacity="0.5" />
149
+ <stop offset="60%" stopColor="#F5C8A0" stopOpacity="0.2" />
150
+ <stop offset="100%" stopColor="transparent" />
151
+ </radialGradient>
152
+ <linearGradient id="anvilBody" x1="0" y1="0" x2="0" y2="1">
153
+ <stop offset="0%" stopColor="#E8D5B7" />
154
+ <stop offset="100%" stopColor="#C4A882" />
155
+ </linearGradient>
156
+ <linearGradient id="anvilTop" x1="0" y1="0" x2="0" y2="1">
157
+ <stop offset="0%" stopColor="#F0E0CC" />
158
+ <stop offset="100%" stopColor="#D4B896" />
159
+ </linearGradient>
160
+ <linearGradient id="ringGrad" x1="0" y1="0" x2="1" y2="1">
161
+ <stop offset="0%" stopColor="#7AC7C4" />
162
+ <stop offset="100%" stopColor="#D4A96A" />
163
+ </linearGradient>
164
+ </defs>
165
+
166
+ {/* Outer runic ring */}
167
+ <circle cx="200" cy="200" r="170" fill="none" stroke="#D4BC8B" strokeWidth="1" opacity="0.25" />
168
+ <circle cx="200" cy="200" r="160" fill="none" stroke="#D4BC8B" strokeWidth="0.5" opacity="0.2" />
169
+
170
+ {/* Runic text rotating */}
171
+ <g opacity="0.3">
172
+ <animateTransform
173
+ attributeName="transform"
174
+ type="rotate"
175
+ from="0 200 200"
176
+ to="360 200 200"
177
+ dur="40s"
178
+ repeatCount="indefinite"
179
+ />
180
+ <text fill="#C4A87A" fontSize="13" fontWeight="500" letterSpacing="5">
181
+ <textPath href="#forgeRune">
182
+ ᚠᚢᚦᚨᚱᚲᚷᚹᚺᚾᛁᛃᛇᛈᛉᛊᛏᛒᛖᛗᛚᛜᛝᛞᛟᚠᚢᚦᚨᚱᚲᚷᚹᚺᚾᛁᛃᛇᛈᛉᛊᛏᛒᛖᛗᛚᛜᛝᛞᛟᚠᚢᚦᚨᚱ
183
+ </textPath>
184
+ </text>
185
+ </g>
186
+
187
+ {/* Inner glow disc */}
188
+ <circle cx="200" cy="200" r="120" fill="url(#anvilGlow)" />
189
+
190
+ {/* ── The Anvil ── */}
191
+ {/* Base */}
192
+ <rect x="155" y="280" width="90" height="35" rx="4" fill="url(#anvilBody)" />
193
+ <rect x="170" y="260" width="60" height="24" rx="3" fill="#D4B896" />
194
+
195
+ {/* Anvil body – the working surface */}
196
+ <path
197
+ d="M120 260 Q125 230 140 225 L155 220 L155 240 Q165 250 200 250 Q235 250 245 240 L245 220 L260 225 Q275 230 280 260 Z"
198
+ fill="url(#anvilTop)"
199
+ />
200
+ {/* Horn (left beak) */}
201
+ <path
202
+ d="M120 260 Q105 255 90 248 Q85 246 88 244 Q95 240 120 245 Z"
203
+ fill="#D4B896"
204
+ />
205
+ {/* Top face highlight */}
206
+ <path
207
+ d="M140 225 L155 220 L155 240 Q165 250 200 250 Q235 250 245 240 L245 220 L260 225 Q255 228 245 230 Q220 238 200 238 Q180 238 155 230 Q145 228 140 225 Z"
208
+ fill="white"
209
+ opacity="0.15"
210
+ />
211
+
212
+ {/* ── Wireframe / neural-net overlay on anvil ── */}
213
+ {/* Nodes */}
214
+ {[
215
+ [170, 185], [200, 170], [230, 185],
216
+ [155, 210], [200, 200], [245, 210],
217
+ [175, 230], [225, 230],
218
+ ].map(([cx, cy], i) => (
219
+ <g key={i}>
220
+ <circle cx={cx} cy={cy} r="4" fill="#7AC7C4" opacity="0.5">
221
+ <animate
222
+ attributeName="opacity"
223
+ values="0.3;0.7;0.3"
224
+ dur={`${1.5 + i * 0.3}s`}
225
+ repeatCount="indefinite"
226
+ />
227
+ </circle>
228
+ <circle cx={cx} cy={cy} r="2" fill="white" opacity="0.6" />
229
+ </g>
230
+ ))}
231
+
232
+ {/* Edges */}
233
+ {[
234
+ [170, 185, 200, 170], [200, 170, 230, 185],
235
+ [155, 210, 200, 200], [200, 200, 245, 210],
236
+ [170, 185, 155, 210], [230, 185, 245, 210],
237
+ [170, 185, 200, 200], [200, 200, 230, 185],
238
+ [155, 210, 175, 230], [245, 210, 225, 230],
239
+ [175, 230, 200, 200], [225, 230, 200, 200],
240
+ ].map(([x1, y1, x2, y2], i) => (
241
+ <line
242
+ key={i}
243
+ x1={x1} y1={y1} x2={x2} y2={y2}
244
+ stroke="#7AC7C4"
245
+ strokeWidth="1"
246
+ opacity="0.25"
247
+ />
248
+ ))}
249
+
250
+ {/* ── Forging rings (orbit) ── */}
251
+ <g>
252
+ <animateTransform
253
+ attributeName="transform"
254
+ type="rotate"
255
+ from="0 200 210"
256
+ to="360 200 210"
257
+ dur="6s"
258
+ repeatCount="indefinite"
259
+ />
260
+ <ellipse cx="200" cy="210" rx="90" ry="30" fill="none" stroke="url(#ringGrad)" strokeWidth="1.5" opacity="0.3" />
261
+ <circle cx="290" cy="210" r="3" fill="#7AC7C4" opacity="0.7">
262
+ <animate attributeName="opacity" values="0.4;1;0.4" dur="2s" repeatCount="indefinite" />
263
+ </circle>
264
+ </g>
265
+ <g>
266
+ <animateTransform
267
+ attributeName="transform"
268
+ type="rotate"
269
+ from="0 200 210"
270
+ to="-360 200 210"
271
+ dur="8s"
272
+ repeatCount="indefinite"
273
+ />
274
+ <ellipse cx="200" cy="210" rx="105" ry="22" fill="none" stroke="#D4A96A" strokeWidth="1" opacity="0.2" />
275
+ <circle cx="305" cy="210" r="2.5" fill="#D4A96A" opacity="0.6">
276
+ <animate attributeName="opacity" values="0.3;0.8;0.3" dur="2.5s" repeatCount="indefinite" />
277
+ </circle>
278
+ </g>
279
+
280
+ {/* ── Center spark ── */}
281
+ <circle cx="200" cy="210" r="8" fill="#FFD5B0" opacity="0.3">
282
+ <animate attributeName="r" values="6;10;6" dur="2s" repeatCount="indefinite" />
283
+ <animate attributeName="opacity" values="0.2;0.5;0.2" dur="2s" repeatCount="indefinite" />
284
+ </circle>
285
+
286
+ {/* Sparkles */}
287
+ <g opacity="0.5">
288
+ <circle cx="145" cy="175" r="2" fill="#D4A96A">
289
+ <animate attributeName="opacity" values="0;0.7;0" dur="3s" repeatCount="indefinite" />
290
+ </circle>
291
+ <circle cx="260" cy="185" r="1.5" fill="#7AC7C4">
292
+ <animate attributeName="opacity" values="0;0.8;0" dur="2.5s" begin="0.5s" repeatCount="indefinite" />
293
+ </circle>
294
+ <circle cx="200" cy="155" r="2" fill="#D4A96A">
295
+ <animate attributeName="opacity" values="0;0.6;0" dur="3.5s" begin="1s" repeatCount="indefinite" />
296
+ </circle>
297
+ <circle cx="165" cy="245" r="1.5" fill="#7AC7C4">
298
+ <animate attributeName="opacity" values="0;0.7;0" dur="2.8s" begin="0.3s" repeatCount="indefinite" />
299
+ </circle>
300
+ <circle cx="240" cy="250" r="2" fill="#D4A96A">
301
+ <animate attributeName="opacity" values="0;0.6;0" dur="3.2s" begin="0.8s" repeatCount="indefinite" />
302
+ </circle>
303
+ </g>
304
+ </svg>
305
+ </div>
306
+ );
307
+ }
308
+
309
+ /* ═══════════════════ Background ═══════════════════ */
310
+
311
+ function ForgeBg() {
312
+ return (
313
+ <div className="pointer-events-none absolute inset-0 z-0 overflow-hidden" aria-hidden="true">
314
+ {/* Floating particles */}
315
+ {[...Array(14)].map((_, i) => (
316
+ <div
317
+ key={`p-${i}`}
318
+ className="absolute rounded-full particle"
319
+ style={{
320
+ width: `${2 + Math.random() * 4}px`,
321
+ height: `${2 + Math.random() * 4}px`,
322
+ left: `${5 + Math.random() * 90}%`,
323
+ bottom: `${Math.random() * 15}%`,
324
+ opacity: 0.3 + Math.random() * 0.3,
325
+ background: i % 2 === 0 ? "#7AC7C4" : "#D4A96A",
326
+ animationDelay: `${Math.random() * 6}s`,
327
+ animationDuration: `${5 + Math.random() * 5}s`,
328
+ }}
329
+ />
330
+ ))}
331
+
332
+ {/* Sparkles */}
333
+ {[...Array(8)].map((_, i) => (
334
+ <div
335
+ key={`s-${i}`}
336
+ className="absolute sparkle"
337
+ style={{
338
+ width: `${3 + Math.random() * 4}px`,
339
+ height: `${3 + Math.random() * 4}px`,
340
+ left: `${10 + Math.random() * 80}%`,
341
+ top: `${10 + Math.random() * 80}%`,
342
+ opacity: 0.2 + Math.random() * 0.3,
343
+ background: i % 3 === 0 ? "#D4A96A" : "#7AC7C4",
344
+ borderRadius: "50%",
345
+ animationDelay: `${Math.random() * 4}s`,
346
+ }}
347
+ />
348
+ ))}
349
+
350
+ {/* Soft radial glows */}
351
+ <div className="absolute top-1/4 left-1/4 w-96 h-96 rounded-full bg-brand-teal/5 blur-3xl" />
352
+ <div className="absolute bottom-1/4 right-1/4 w-80 h-80 rounded-full bg-[#D4A96A]/5 blur-3xl" />
353
+ </div>
354
+ );
355
+ }
app/globals.css ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ /* ── Hide scrollbar but keep scroll functionality ── */
6
+ .scrollbar-hide {
7
+ -ms-overflow-style: none; /* IE/Edge */
8
+ scrollbar-width: none; /* Firefox */
9
+ }
10
+ .scrollbar-hide::-webkit-scrollbar {
11
+ display: none; /* Chrome/Safari/Opera */
12
+ }
13
+
14
+ @import url("https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800&family=Open+Sans:wght@400;500;600;700&display=swap");
15
+
16
+ html,
17
+ body {
18
+ margin: 0;
19
+ padding: 0;
20
+ font-family: "Open Sans", sans-serif;
21
+ }
22
+
23
+ /* Custom utility classes */
24
+ @layer utilities {
25
+ .text-shadow-sm {
26
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
27
+ }
28
+ }
29
+
30
+ /* ── Particle animations ── */
31
+ @keyframes float-up {
32
+ 0% {
33
+ transform: translateY(0) translateX(0) scale(1);
34
+ opacity: 0;
35
+ }
36
+ 10% {
37
+ opacity: 1;
38
+ }
39
+ 90% {
40
+ opacity: 1;
41
+ }
42
+ 100% {
43
+ transform: translateY(-100vh) translateX(var(--drift, 20px)) scale(0.4);
44
+ opacity: 0;
45
+ }
46
+ }
47
+
48
+ @keyframes shimmer {
49
+ 0%, 100% {
50
+ opacity: 0.3;
51
+ transform: scale(0.8);
52
+ }
53
+ 50% {
54
+ opacity: 1;
55
+ transform: scale(1.2);
56
+ }
57
+ }
58
+
59
+ @keyframes drift {
60
+ 0% {
61
+ transform: translateY(0) translateX(0);
62
+ opacity: 0;
63
+ }
64
+ 15% {
65
+ opacity: 0.7;
66
+ }
67
+ 85% {
68
+ opacity: 0.7;
69
+ }
70
+ 100% {
71
+ transform: translateY(var(--float-y, -80px)) translateX(var(--float-x, 30px));
72
+ opacity: 0;
73
+ }
74
+ }
75
+
76
+ .particle {
77
+ animation: float-up var(--duration, 12s) var(--delay, 0s) infinite ease-in-out;
78
+ }
79
+
80
+ .sparkle {
81
+ animation: shimmer var(--duration, 3s) var(--delay, 0s) infinite ease-in-out;
82
+ }
83
+
84
+ .floater {
85
+ animation: drift var(--duration, 8s) var(--delay, 0s) infinite ease-in-out;
86
+ }
87
+
88
+ /* ── GameButton pulse ── */
89
+ @keyframes pulse-subtle {
90
+ 0%, 100% {
91
+ transform: scale(1);
92
+ box-shadow: 0 4px 14px rgba(88, 204, 2, 0.3);
93
+ }
94
+ 50% {
95
+ transform: scale(1.03);
96
+ box-shadow: 0 6px 20px rgba(88, 204, 2, 0.5);
97
+ }
98
+ }
99
+
100
+ .animate-pulse-subtle {
101
+ animation: pulse-subtle 2s ease-in-out infinite;
102
+ }
103
+
104
+ /* ── Shake animation (wrong answer) ── */
105
+ @keyframes shake {
106
+ 0%, 100% { transform: translateX(0); }
107
+ 20% { transform: translateX(-4px); }
108
+ 40% { transform: translateX(4px); }
109
+ 60% { transform: translateX(-3px); }
110
+ 80% { transform: translateX(3px); }
111
+ }
112
+
113
+ .animate-shake {
114
+ animation: shake 0.4s ease-in-out;
115
+ }
116
+
117
+ /* ── XP bar shimmer ── */
118
+ @keyframes shimmer-bar {
119
+ 0% { transform: translateX(-100%); }
120
+ 100% { transform: translateX(200%); }
121
+ }
122
+
123
+ .animate-shimmer-bar {
124
+ animation: shimmer-bar 1.5s ease-in-out infinite;
125
+ }
app/icon.svg ADDED
app/layout.tsx ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metadata } from "next";
2
+ import "./globals.css";
3
+
4
+ export const metadata: Metadata = {
5
+ title: "Learn8",
6
+ description: "Convert your notes into a dynamic Skill Tree and master any subject through immersive mini-games.",
7
+ };
8
+
9
+ export default function RootLayout({
10
+ children,
11
+ }: {
12
+ children: React.ReactNode;
13
+ }) {
14
+ return (
15
+ <html lang="en">
16
+ <body className="min-h-screen bg-brand-teal-bg antialiased">
17
+ {children}
18
+ </body>
19
+ </html>
20
+ );
21
+ }
app/page.tsx ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useEffect } from "react";
4
+ import { useRouter } from "next/navigation";
5
+
6
+ export default function RootPage() {
7
+ const router = useRouter();
8
+
9
+ useEffect(() => {
10
+ router.replace("/login");
11
+ }, [router]);
12
+
13
+ return null;
14
+ }
components/arena/LogicChain.tsx ADDED
@@ -0,0 +1,352 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import React, { useState, useCallback, useRef } from "react";
4
+ import { motion, AnimatePresence, Reorder } from "framer-motion";
5
+ import GameButton from "@/components/ui/GameButton";
6
+
7
+ /* ═══════════════════ Types ═══════════════════ */
8
+
9
+ export interface LogicChainProps {
10
+ stageIndex: number;
11
+ totalStages: number;
12
+ topic: string;
13
+ description: string;
14
+ nodes: string[]; // correct order
15
+ feedbackMsg: { success: string; error: string; hint: string };
16
+ onComplete: () => void;
17
+ onError?: () => void;
18
+ onHintUse: () => boolean;
19
+ }
20
+
21
+ /* ═══════════════════ Helpers ═══════════════════ */
22
+
23
+ function shuffle<T>(arr: T[]): T[] {
24
+ const a = [...arr];
25
+ for (let i = a.length - 1; i > 0; i--) {
26
+ const j = Math.floor(Math.random() * (i + 1));
27
+ [a[i], a[j]] = [a[j], a[i]];
28
+ }
29
+ return a;
30
+ }
31
+
32
+ /* Owl mascot (inline SVG) */
33
+ function OwlMascotSmall() {
34
+ return (
35
+ <svg viewBox="0 0 40 44" className="w-12 h-14 flex-shrink-0">
36
+ <ellipse cx="20" cy="32" rx="14" ry="12" fill="#C47F17" />
37
+ <ellipse cx="20" cy="30" rx="14" ry="12" fill="#E8A817" />
38
+ <circle cx="14" cy="26" r="6" fill="white" />
39
+ <circle cx="26" cy="26" r="6" fill="white" />
40
+ <circle cx="14" cy="26" r="3" fill="#2D2D2D" />
41
+ <circle cx="26" cy="26" r="3" fill="#2D2D2D" />
42
+ <circle cx="15" cy="25" r="1.2" fill="white" />
43
+ <circle cx="27" cy="25" r="1.2" fill="white" />
44
+ <polygon points="20,28 18,31 22,31" fill="#FF9500" />
45
+ <path d="M6,20 Q4,8 14,16" fill="#C47F17" />
46
+ <path d="M34,20 Q36,8 26,16" fill="#C47F17" />
47
+ </svg>
48
+ );
49
+ }
50
+
51
+ /* ═══════════════════ Accent colours per step ═══════════════════ */
52
+
53
+ const STEP_COLORS = [
54
+ "from-sky-400 to-sky-500",
55
+ "from-brand-teal to-[#5fb3af]",
56
+ "from-amber-400 to-amber-500",
57
+ "from-brand-coral to-red-400",
58
+ "from-violet-400 to-purple-500",
59
+ "from-brand-green to-emerald-500",
60
+ ];
61
+
62
+ const STEP_BORDER = [
63
+ "border-sky-300",
64
+ "border-brand-teal",
65
+ "border-amber-300",
66
+ "border-brand-coral",
67
+ "border-violet-300",
68
+ "border-brand-green",
69
+ ];
70
+
71
+ /* ═══════════════════ Component ═══════════════════ */
72
+
73
+ export default function LogicChain({
74
+ stageIndex,
75
+ totalStages,
76
+ topic,
77
+ description,
78
+ nodes,
79
+ feedbackMsg,
80
+ onComplete,
81
+ onError,
82
+ onHintUse,
83
+ }: LogicChainProps) {
84
+ // Shuffle on first render (useRef so it only shuffles once)
85
+ const initialOrder = useRef(shuffle(nodes));
86
+ const [order, setOrder] = useState<string[]>(initialOrder.current);
87
+ const [result, setResult] = useState<"correct" | "wrong" | null>(null);
88
+ const [wrongIndices, setWrongIndices] = useState<number[]>([]);
89
+ const [hintUsed, setHintUsed] = useState(false);
90
+ const [completed, setCompleted] = useState(false);
91
+ const [lockedCount, setLockedCount] = useState(0); // how many from the top are locked (via hints)
92
+
93
+ /* CHECK */
94
+ const handleCheck = useCallback(() => {
95
+ if (completed) return;
96
+
97
+ // Compare with correct order
98
+ const isCorrect = order.every((n, i) => n === nodes[i]);
99
+
100
+ if (isCorrect) {
101
+ setResult("correct");
102
+ setCompleted(true);
103
+ setWrongIndices([]);
104
+ setTimeout(() => onComplete(), 600);
105
+ } else {
106
+ // Find wrong positions
107
+ const wrong: number[] = [];
108
+ order.forEach((n, i) => {
109
+ if (n !== nodes[i]) wrong.push(i);
110
+ });
111
+ setWrongIndices(wrong);
112
+ setResult("wrong");
113
+ onError?.();
114
+ setTimeout(() => {
115
+ setResult(null);
116
+ setWrongIndices([]);
117
+ }, 1200);
118
+ }
119
+ }, [order, nodes, completed, onComplete]);
120
+
121
+ /* Hint: lock the next correct item in place */
122
+ const handleHint = useCallback(() => {
123
+ if (hintUsed || completed) return;
124
+ const canAfford = onHintUse();
125
+ if (!canAfford) return;
126
+ setHintUsed(true);
127
+
128
+ // Place the next correct node at the right position
129
+ const nextCorrect = nodes[lockedCount];
130
+ const newOrder = [...order];
131
+ const currentIdx = newOrder.indexOf(nextCorrect);
132
+ if (currentIdx !== lockedCount) {
133
+ // Swap
134
+ [newOrder[lockedCount], newOrder[currentIdx]] = [newOrder[currentIdx], newOrder[lockedCount]];
135
+ setOrder(newOrder);
136
+ }
137
+ setLockedCount((c) => c + 1);
138
+ }, [hintUsed, completed, onHintUse, nodes, lockedCount, order]);
139
+
140
+ /* Arrow connector between items */
141
+ const Arrow = ({ idx }: { idx: number }) => {
142
+ const isWrong = wrongIndices.includes(idx) || wrongIndices.includes(idx + 1);
143
+ return (
144
+ <div className="flex justify-center py-1">
145
+ <motion.div
146
+ animate={isWrong && result === "wrong" ? { opacity: [1, 0.3, 1] } : {}}
147
+ transition={{ duration: 0.4, repeat: 2 }}
148
+ >
149
+ <svg viewBox="0 0 24 28" className="w-5 h-7" fill="none">
150
+ <path
151
+ d="M12 4 L12 20 M6 16 L12 22 L18 16"
152
+ stroke={isWrong && result === "wrong" ? "#EF4444" : "#7AC7C4"}
153
+ strokeWidth="2.5"
154
+ strokeLinecap="round"
155
+ strokeLinejoin="round"
156
+ />
157
+ </svg>
158
+ </motion.div>
159
+ </div>
160
+ );
161
+ };
162
+
163
+ return (
164
+ <div className="flex flex-col flex-1">
165
+ {/* ── Stage header ── */}
166
+ <motion.div
167
+ initial={{ opacity: 0, y: -10 }}
168
+ animate={{ opacity: 1, y: 0 }}
169
+ className="mt-4 mb-5 flex items-center gap-4"
170
+ >
171
+ <div className="flex-shrink-0 w-11 h-11 rounded-2xl bg-gradient-to-br from-sky-400 to-blue-500 flex items-center justify-center shadow-md shadow-sky-300/30">
172
+ <span className="font-heading font-extrabold text-white text-base">
173
+ {stageIndex + 1}
174
+ </span>
175
+ </div>
176
+ <div>
177
+ <p className="text-[11px] font-bold text-sky-500 uppercase tracking-wider mb-0.5">
178
+ Stage {stageIndex + 1} of {totalStages}
179
+ </p>
180
+ <h2 className="font-heading font-bold text-xl text-brand-gray-700 leading-snug">
181
+ {topic}
182
+ </h2>
183
+ <p className="text-xs text-brand-gray-400 mt-0.5">{description}</p>
184
+ </div>
185
+ </motion.div>
186
+
187
+ {/* ── Instruction ── */}
188
+ <div className="mb-4 flex items-center gap-2 px-1">
189
+ <span className="text-lg">🔗</span>
190
+ <p className="text-sm text-brand-gray-500 font-medium">
191
+ Drag to reorder the steps into the correct sequence
192
+ </p>
193
+ </div>
194
+
195
+ {/* ── Chain list (Reorder) ── */}
196
+ <div className="mx-auto w-full max-w-[500px]">
197
+ <Reorder.Group
198
+ axis="y"
199
+ values={order}
200
+ onReorder={(newOrder) => {
201
+ if (completed) return;
202
+ // Don't allow reordering locked items
203
+ const locked = order.slice(0, lockedCount);
204
+ const reorderUnlocked = newOrder.filter((n) => !locked.includes(n));
205
+ setOrder([...locked, ...reorderUnlocked]);
206
+ }}
207
+ className="flex flex-col"
208
+ >
209
+ {order.map((node, idx) => {
210
+ const isLocked = idx < lockedCount;
211
+ const isWrong = wrongIndices.includes(idx) && result === "wrong";
212
+ const isCorrectResult = result === "correct";
213
+ const colorIdx = idx % STEP_COLORS.length;
214
+
215
+ return (
216
+ <React.Fragment key={node}>
217
+ <Reorder.Item
218
+ value={node}
219
+ dragListener={!isLocked && !completed}
220
+ className="select-none"
221
+ >
222
+ <motion.div
223
+ layout
224
+ animate={
225
+ isWrong
226
+ ? { x: [0, -8, 8, -5, 5, 0] }
227
+ : isCorrectResult
228
+ ? { scale: [1, 1.02, 1] }
229
+ : {}
230
+ }
231
+ transition={isWrong ? { duration: 0.5 } : { duration: 0.3, delay: idx * 0.05 }}
232
+ className={`relative flex items-center gap-3 rounded-2xl border-2 px-5 py-4 transition-all duration-200
233
+ ${
234
+ isCorrectResult
235
+ ? "border-brand-green/60 bg-green-50/60 shadow-md shadow-green-200/30"
236
+ : isWrong
237
+ ? "border-red-400 bg-red-50/50 shadow-md"
238
+ : isLocked
239
+ ? `${STEP_BORDER[colorIdx]} bg-white/80 shadow-sm opacity-80`
240
+ : "border-white/60 bg-white/70 backdrop-blur-sm shadow-sm hover:shadow-md hover:border-brand-teal/40 cursor-grab active:cursor-grabbing"
241
+ }`}
242
+ >
243
+ {/* Step number badge */}
244
+ <div
245
+ className={`flex-shrink-0 w-8 h-8 rounded-xl bg-gradient-to-br ${STEP_COLORS[colorIdx]} flex items-center justify-center shadow-sm`}
246
+ >
247
+ <span className="font-heading font-extrabold text-white text-sm">
248
+ {idx + 1}
249
+ </span>
250
+ </div>
251
+
252
+ {/* Label */}
253
+ <span
254
+ className={`font-heading font-bold text-[15px] flex-1 ${
255
+ isCorrectResult
256
+ ? "text-brand-green"
257
+ : isWrong
258
+ ? "text-red-500"
259
+ : "text-brand-gray-700"
260
+ }`}
261
+ >
262
+ {node}
263
+ </span>
264
+
265
+ {/* Drag handle / lock icon */}
266
+ {isLocked ? (
267
+ <svg viewBox="0 0 20 20" className="w-4 h-4 text-brand-gray-300 flex-shrink-0" fill="currentColor">
268
+ <path fillRule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clipRule="evenodd" />
269
+ </svg>
270
+ ) : !completed ? (
271
+ <svg viewBox="0 0 20 20" className="w-5 h-5 text-brand-gray-300 flex-shrink-0" fill="currentColor">
272
+ <path d="M3 5h14M3 10h14M3 15h14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" fill="none" />
273
+ </svg>
274
+ ) : (
275
+ <motion.div
276
+ initial={{ scale: 0 }}
277
+ animate={{ scale: 1 }}
278
+ className="w-5 h-5 rounded-full bg-brand-green flex items-center justify-center"
279
+ >
280
+ <span className="text-white text-[10px] font-bold">✓</span>
281
+ </motion.div>
282
+ )}
283
+ </motion.div>
284
+ </Reorder.Item>
285
+
286
+ {/* Arrow between items */}
287
+ {idx < order.length - 1 && <Arrow idx={idx} />}
288
+ </React.Fragment>
289
+ );
290
+ })}
291
+ </Reorder.Group>
292
+ </div>
293
+
294
+ {/* ── Feedback text ── */}
295
+ <AnimatePresence mode="wait">
296
+ {result === "correct" && (
297
+ <motion.div
298
+ key="chain-ok"
299
+ initial={{ opacity: 0, y: 6 }}
300
+ animate={{ opacity: 1, y: 0 }}
301
+ exit={{ opacity: 0 }}
302
+ className="mt-5 mx-auto max-w-md text-center"
303
+ >
304
+ <p className="text-sm font-bold text-brand-green">{feedbackMsg.success}</p>
305
+ </motion.div>
306
+ )}
307
+ {result === "wrong" && (
308
+ <motion.div
309
+ key="chain-err"
310
+ initial={{ opacity: 0, y: 6 }}
311
+ animate={{ opacity: 1, y: 0 }}
312
+ exit={{ opacity: 0 }}
313
+ className="mt-5 mx-auto max-w-md text-center"
314
+ >
315
+ <p className="text-sm font-bold text-red-500">{feedbackMsg.error}</p>
316
+ </motion.div>
317
+ )}
318
+ </AnimatePresence>
319
+
320
+ {/* spacer */}
321
+ <div className="flex-1 min-h-[40px]" />
322
+
323
+ {/* ── Bottom bar ── */}
324
+ <div className="relative pt-4 pb-6 flex items-end justify-between">
325
+ <div className="flex items-end gap-2">
326
+ <OwlMascotSmall />
327
+ <button
328
+ onClick={handleHint}
329
+ disabled={hintUsed || completed}
330
+ className={`mb-1 flex items-center gap-1 text-xs font-bold px-3 py-1.5 rounded-full border transition ${
331
+ hintUsed
332
+ ? "bg-brand-gray-100 text-brand-gray-400 border-brand-gray-200 cursor-not-allowed"
333
+ : "bg-amber-50 text-amber-600 border-amber-200 hover:bg-amber-100"
334
+ }`}
335
+ >
336
+ 💡 Hint
337
+ <span className="text-[10px] opacity-60">(10 💎)</span>
338
+ </button>
339
+ </div>
340
+
341
+ <GameButton
342
+ variant="primary"
343
+ onClick={handleCheck}
344
+ disabled={completed}
345
+ className="min-w-[140px]"
346
+ >
347
+ {completed ? "CORRECT ✓" : "CHECK"}
348
+ </GameButton>
349
+ </div>
350
+ </div>
351
+ );
352
+ }
components/arena/SpatialAnatomy.tsx ADDED
@@ -0,0 +1,327 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import React, { useState, useCallback } from "react";
4
+ import { motion, AnimatePresence } from "framer-motion";
5
+ import GameButton from "@/components/ui/GameButton";
6
+
7
+ /* ═══════════════════ Types ═══════════════════ */
8
+
9
+ export interface AnatomyLabel {
10
+ id: string;
11
+ label: string;
12
+ }
13
+
14
+ export interface SpatialAnatomyProps {
15
+ stageIndex: number;
16
+ totalStages: number;
17
+ topic: string;
18
+ description: string;
19
+ model: string; // e.g. "heart-cross-section"
20
+ labels: AnatomyLabel[];
21
+ targetId: string; // correct answer id
22
+ feedbackMsg: { success: string; error: string; hint: string };
23
+ onComplete: () => void;
24
+ onError?: () => void;
25
+ onHintUse: () => boolean;
26
+ }
27
+
28
+ /* ═══════════════════ Heart region positions (relative %) ═══════════════════ */
29
+
30
+ const HEART_REGIONS: Record<string, { x: number; y: number; w: number; h: number; color: string }> = {
31
+ ra: { x: 14, y: 18, w: 30, h: 28, color: "#60B5FF" }, // Right Atrium – top-left
32
+ la: { x: 56, y: 18, w: 30, h: 28, color: "#A78BFA" }, // Left Atrium – top-right
33
+ rv: { x: 14, y: 52, w: 30, h: 30, color: "#F59E0B" }, // Right Ventricle – bottom-left
34
+ lv: { x: 56, y: 52, w: 30, h: 30, color: "#58CC02" }, // Left Ventricle – bottom-right
35
+ };
36
+
37
+ /* ═══════════════════ Owl mascot (inline SVG) ═══════════════════ */
38
+
39
+ function OwlMascotSmall() {
40
+ return (
41
+ <svg viewBox="0 0 40 44" className="w-12 h-14 flex-shrink-0">
42
+ <ellipse cx="20" cy="32" rx="14" ry="12" fill="#C47F17" />
43
+ <ellipse cx="20" cy="30" rx="14" ry="12" fill="#E8A817" />
44
+ <circle cx="14" cy="26" r="6" fill="white" />
45
+ <circle cx="26" cy="26" r="6" fill="white" />
46
+ <circle cx="14" cy="26" r="3" fill="#2D2D2D" />
47
+ <circle cx="26" cy="26" r="3" fill="#2D2D2D" />
48
+ <circle cx="15" cy="25" r="1.2" fill="white" />
49
+ <circle cx="27" cy="25" r="1.2" fill="white" />
50
+ <polygon points="20,28 18,31 22,31" fill="#FF9500" />
51
+ <path d="M6,20 Q4,8 14,16" fill="#C47F17" />
52
+ <path d="M34,20 Q36,8 26,16" fill="#C47F17" />
53
+ </svg>
54
+ );
55
+ }
56
+
57
+ /* ═══════════════════ Component ═══════════════════ */
58
+
59
+ export default function SpatialAnatomy({
60
+ stageIndex,
61
+ totalStages,
62
+ topic,
63
+ description,
64
+ model,
65
+ labels,
66
+ targetId,
67
+ feedbackMsg,
68
+ onComplete,
69
+ onError,
70
+ onHintUse,
71
+ }: SpatialAnatomyProps) {
72
+ const [selected, setSelected] = useState<string | null>(null);
73
+ const [result, setResult] = useState<"correct" | "wrong" | null>(null);
74
+ const [hintUsed, setHintUsed] = useState(false);
75
+ const [hintTarget, setHintTarget] = useState<string | null>(null);
76
+ const [completed, setCompleted] = useState(false);
77
+
78
+ /* Pick a region */
79
+ const handleSelect = useCallback(
80
+ (id: string) => {
81
+ if (completed) return;
82
+ setSelected(id);
83
+ setResult(null);
84
+ },
85
+ [completed],
86
+ );
87
+
88
+ /* CHECK */
89
+ const handleCheck = useCallback(() => {
90
+ if (!selected || completed) return;
91
+ if (selected === targetId) {
92
+ setResult("correct");
93
+ setCompleted(true);
94
+ setTimeout(() => onComplete(), 600);
95
+ } else {
96
+ setResult("wrong");
97
+ onError?.();
98
+ // shake then reset
99
+ setTimeout(() => {
100
+ setResult(null);
101
+ setSelected(null);
102
+ }, 1000);
103
+ }
104
+ }, [selected, targetId, completed, onComplete]);
105
+
106
+ /* Hint */
107
+ const handleHint = useCallback(() => {
108
+ if (hintUsed || completed) return;
109
+ const canAfford = onHintUse();
110
+ if (!canAfford) return;
111
+ setHintUsed(true);
112
+ setHintTarget(targetId);
113
+ // highlight the answer briefly
114
+ setTimeout(() => {
115
+ setSelected(targetId);
116
+ }, 800);
117
+ }, [hintUsed, completed, onHintUse, targetId]);
118
+
119
+ /* Label for selected */
120
+ const selectedLabel = labels.find((l) => l.id === selected)?.label ?? "";
121
+
122
+ return (
123
+ <div className="flex flex-col flex-1">
124
+ {/* ── Stage header ── */}
125
+ <motion.div
126
+ initial={{ opacity: 0, y: -10 }}
127
+ animate={{ opacity: 1, y: 0 }}
128
+ className="mt-4 mb-5 flex items-center gap-4"
129
+ >
130
+ <div className="flex-shrink-0 w-11 h-11 rounded-2xl bg-gradient-to-br from-rose-400 to-red-500 flex items-center justify-center shadow-md shadow-rose-300/30">
131
+ <span className="font-heading font-extrabold text-white text-base">
132
+ {stageIndex + 1}
133
+ </span>
134
+ </div>
135
+ <div>
136
+ <p className="text-[11px] font-bold text-rose-500 uppercase tracking-wider mb-0.5">
137
+ Stage {stageIndex + 1} of {totalStages}
138
+ </p>
139
+ <h2 className="font-heading font-bold text-xl text-brand-gray-700 leading-snug">
140
+ {topic}
141
+ </h2>
142
+ <p className="text-xs text-brand-gray-400 mt-0.5">{description}</p>
143
+ </div>
144
+ </motion.div>
145
+
146
+ {/* ── Interactive heart diagram ── */}
147
+ <motion.div
148
+ initial={{ opacity: 0, scale: 0.95 }}
149
+ animate={{ opacity: 1, scale: 1 }}
150
+ transition={{ delay: 0.15 }}
151
+ className="relative mx-auto w-full max-w-[480px] aspect-square rounded-3xl bg-white/70 backdrop-blur-md border border-white/50 shadow-lg shadow-rose-100/30 overflow-hidden"
152
+ >
153
+ {/* Background heart shape SVG */}
154
+ <svg viewBox="0 0 200 200" className="absolute inset-0 w-full h-full opacity-[0.08]">
155
+ <path
156
+ d="M100,180 C60,140 10,110 10,70 A45,45,0,0,1,100,50 A45,45,0,0,1,190,70 C190,110 140,140 100,180Z"
157
+ fill="#E8734A"
158
+ />
159
+ </svg>
160
+
161
+ {/* Divider lines */}
162
+ <div className="absolute left-1/2 top-[15%] bottom-[15%] w-px bg-brand-gray-200/50" />
163
+ <div className="absolute top-1/2 left-[12%] right-[12%] h-px bg-brand-gray-200/50" />
164
+
165
+ {/* Clickable regions */}
166
+ {labels.map((label) => {
167
+ const pos = HEART_REGIONS[label.id];
168
+ if (!pos) return null;
169
+ const isSelected = selected === label.id;
170
+ const isCorrectResult = result === "correct" && isSelected;
171
+ const isWrongResult = result === "wrong" && isSelected;
172
+ const isHinted = hintTarget === label.id;
173
+
174
+ return (
175
+ <motion.button
176
+ key={label.id}
177
+ onClick={() => handleSelect(label.id)}
178
+ animate={
179
+ isWrongResult
180
+ ? { x: [0, -6, 6, -4, 4, 0] }
181
+ : isCorrectResult
182
+ ? { scale: [1, 1.05, 1] }
183
+ : {}
184
+ }
185
+ transition={isWrongResult ? { duration: 0.5 } : { duration: 0.4 }}
186
+ className={`absolute rounded-2xl border-2 transition-all duration-200 flex flex-col items-center justify-center gap-1 cursor-pointer
187
+ ${
188
+ isCorrectResult
189
+ ? "border-brand-green bg-brand-green/15 shadow-lg shadow-green-300/30 ring-2 ring-brand-green/40"
190
+ : isWrongResult
191
+ ? "border-red-400 bg-red-50/60 shadow-md"
192
+ : isSelected
193
+ ? "border-brand-teal bg-brand-teal/10 shadow-md shadow-teal-200/30 ring-2 ring-brand-teal/30"
194
+ : isHinted
195
+ ? "border-amber-400 bg-amber-50/50 shadow-md animate-pulse"
196
+ : "border-white/60 bg-white/40 hover:bg-white/60 hover:border-brand-teal/40 hover:shadow-sm"
197
+ }`}
198
+ style={{
199
+ left: `${pos.x}%`,
200
+ top: `${pos.y}%`,
201
+ width: `${pos.w}%`,
202
+ height: `${pos.h}%`,
203
+ }}
204
+ >
205
+ {/* Colour dot */}
206
+ <div
207
+ className="w-3 h-3 rounded-full opacity-70"
208
+ style={{ backgroundColor: pos.color }}
209
+ />
210
+ <span
211
+ className={`font-heading font-bold text-sm leading-tight text-center px-1 ${
212
+ isCorrectResult
213
+ ? "text-brand-green"
214
+ : isWrongResult
215
+ ? "text-red-500"
216
+ : isSelected
217
+ ? "text-teal-700"
218
+ : "text-brand-gray-600"
219
+ }`}
220
+ >
221
+ {label.label}
222
+ </span>
223
+
224
+ {/* Selected indicator */}
225
+ {isSelected && !result && (
226
+ <motion.div
227
+ layoutId="anatomy-sel"
228
+ className="absolute -top-2 -right-2 w-5 h-5 rounded-full bg-brand-teal flex items-center justify-center shadow"
229
+ >
230
+ <span className="text-white text-[10px] font-bold">✓</span>
231
+ </motion.div>
232
+ )}
233
+
234
+ {/* Correct icon */}
235
+ {isCorrectResult && (
236
+ <motion.div
237
+ initial={{ scale: 0 }}
238
+ animate={{ scale: 1 }}
239
+ className="absolute -top-2 -right-2 w-6 h-6 rounded-full bg-brand-green flex items-center justify-center shadow-md"
240
+ >
241
+ <span className="text-white text-xs font-bold">✓</span>
242
+ </motion.div>
243
+ )}
244
+ </motion.button>
245
+ );
246
+ })}
247
+
248
+ {/* Model label */}
249
+ <div className="absolute bottom-3 left-1/2 -translate-x-1/2 bg-white/70 backdrop-blur rounded-full px-4 py-1">
250
+ <span className="text-[10px] font-bold text-brand-gray-400 uppercase tracking-wider">
251
+ {model.replace(/-/g, " ")}
252
+ </span>
253
+ </div>
254
+ </motion.div>
255
+
256
+ {/* ── Selection feedback text ── */}
257
+ <AnimatePresence mode="wait">
258
+ {result === "correct" && (
259
+ <motion.div
260
+ key="fb-ok"
261
+ initial={{ opacity: 0, y: 6 }}
262
+ animate={{ opacity: 1, y: 0 }}
263
+ exit={{ opacity: 0 }}
264
+ className="mt-4 mx-auto max-w-md text-center"
265
+ >
266
+ <p className="text-sm font-bold text-brand-green">{feedbackMsg.success}</p>
267
+ </motion.div>
268
+ )}
269
+ {result === "wrong" && (
270
+ <motion.div
271
+ key="fb-err"
272
+ initial={{ opacity: 0, y: 6 }}
273
+ animate={{ opacity: 1, y: 0 }}
274
+ exit={{ opacity: 0 }}
275
+ className="mt-4 mx-auto max-w-md text-center"
276
+ >
277
+ <p className="text-sm font-bold text-red-500">{feedbackMsg.error}</p>
278
+ </motion.div>
279
+ )}
280
+ {!result && selected && (
281
+ <motion.div
282
+ key="fb-sel"
283
+ initial={{ opacity: 0, y: 6 }}
284
+ animate={{ opacity: 1, y: 0 }}
285
+ exit={{ opacity: 0 }}
286
+ className="mt-4 mx-auto text-center"
287
+ >
288
+ <p className="text-sm text-brand-gray-500">
289
+ Selected: <span className="font-bold text-teal-700">{selectedLabel}</span>
290
+ </p>
291
+ </motion.div>
292
+ )}
293
+ </AnimatePresence>
294
+
295
+ {/* spacer */}
296
+ <div className="flex-1 min-h-[40px]" />
297
+
298
+ {/* ── Bottom bar ── */}
299
+ <div className="relative pt-4 pb-6 flex items-end justify-between">
300
+ <div className="flex items-end gap-2">
301
+ <OwlMascotSmall />
302
+ <button
303
+ onClick={handleHint}
304
+ disabled={hintUsed || completed}
305
+ className={`mb-1 flex items-center gap-1 text-xs font-bold px-3 py-1.5 rounded-full border transition ${
306
+ hintUsed
307
+ ? "bg-brand-gray-100 text-brand-gray-400 border-brand-gray-200 cursor-not-allowed"
308
+ : "bg-amber-50 text-amber-600 border-amber-200 hover:bg-amber-100"
309
+ }`}
310
+ >
311
+ 💡 Hint
312
+ <span className="text-[10px] opacity-60">(10 💎)</span>
313
+ </button>
314
+ </div>
315
+
316
+ <GameButton
317
+ variant="primary"
318
+ onClick={handleCheck}
319
+ disabled={!selected || completed}
320
+ className="min-w-[140px]"
321
+ >
322
+ {completed ? "CORRECT ✓" : "CHECK"}
323
+ </GameButton>
324
+ </div>
325
+ </div>
326
+ );
327
+ }
components/arena/TaxonomyMatrix.tsx ADDED
@@ -0,0 +1,463 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import React, { useState, useCallback } from "react";
4
+ import { motion, AnimatePresence } from "framer-motion";
5
+ import GameButton from "@/components/ui/GameButton";
6
+
7
+ /* ═══════════════════ Types ═══════════════════ */
8
+
9
+ export interface TaxonomyItem {
10
+ id: string;
11
+ content: string;
12
+ }
13
+
14
+ export interface TaxonomyMatrixProps {
15
+ stageIndex: number;
16
+ totalStages: number;
17
+ topic: string;
18
+ description: string;
19
+ buckets: string[];
20
+ items: TaxonomyItem[];
21
+ correctAssignments: Record<string, string>; // itemId → bucket name
22
+ feedbackMsg: { success: string; error: string; hint: string };
23
+ onComplete: () => void;
24
+ onError?: () => void;
25
+ onHintUse: () => boolean; // returns false if can't afford
26
+ }
27
+
28
+ /* ═══════════════════ Bucket accent colours ═══════════════════ */
29
+
30
+ const BUCKET_ACCENTS = [
31
+ {
32
+ border: "border-brand-teal",
33
+ borderDash: "border-brand-teal/40",
34
+ bg: "bg-brand-teal/5",
35
+ bgHover: "hover:bg-brand-teal/10",
36
+ text: "text-teal-700",
37
+ tagBg: "bg-brand-teal/10",
38
+ tagBorder: "border-brand-teal/40",
39
+ icon: "✓",
40
+ },
41
+ {
42
+ border: "border-brand-coral",
43
+ borderDash: "border-brand-coral/40",
44
+ bg: "bg-brand-coral/5",
45
+ bgHover: "hover:bg-brand-coral/10",
46
+ text: "text-red-700",
47
+ tagBg: "bg-brand-coral/10",
48
+ tagBorder: "border-brand-coral/40",
49
+ icon: "✗",
50
+ },
51
+ ];
52
+
53
+ /* ═══════════════════ Component ═══════════════════ */
54
+
55
+ export default function TaxonomyMatrix({
56
+ stageIndex,
57
+ totalStages,
58
+ topic,
59
+ description,
60
+ buckets,
61
+ items,
62
+ correctAssignments,
63
+ feedbackMsg,
64
+ onComplete,
65
+ onError,
66
+ onHintUse,
67
+ }: TaxonomyMatrixProps) {
68
+ /* ── State ── */
69
+ const [assignments, setAssignments] = useState<Record<string, string[]>>(
70
+ () => {
71
+ const init: Record<string, string[]> = {};
72
+ buckets.forEach((b) => (init[b] = []));
73
+ return init;
74
+ }
75
+ );
76
+ const [unclassified, setUnclassified] = useState<string[]>(
77
+ items.map((i) => i.id)
78
+ );
79
+ const [selectedItem, setSelectedItem] = useState<string | null>(null);
80
+ const [wrongItems, setWrongItems] = useState<string[]>([]);
81
+ const [completed, setCompleted] = useState(false);
82
+ const [hintUsed, setHintUsed] = useState(false);
83
+ const [hintItem, setHintItem] = useState<string | null>(null);
84
+ const [errorMsg, setErrorMsg] = useState<string | null>(null);
85
+
86
+ const allPlaced = unclassified.length === 0;
87
+
88
+ const getItem = useCallback(
89
+ (id: string) => items.find((i) => i.id === id)!,
90
+ [items]
91
+ );
92
+
93
+ /* ── Select item from pool ── */
94
+ const selectItem = useCallback(
95
+ (id: string) => {
96
+ if (completed) return;
97
+ setSelectedItem((prev) => (prev === id ? null : id));
98
+ setWrongItems([]);
99
+ setErrorMsg(null);
100
+ },
101
+ [completed]
102
+ );
103
+
104
+ /* ── Place selected item into a bucket ── */
105
+ const placeInBucket = useCallback(
106
+ (bucket: string) => {
107
+ if (!selectedItem || completed) return;
108
+
109
+ setUnclassified((prev) => prev.filter((id) => id !== selectedItem));
110
+ setAssignments((prev) => {
111
+ const next: Record<string, string[]> = {};
112
+ for (const b of Object.keys(prev)) {
113
+ next[b] = prev[b].filter((id) => id !== selectedItem);
114
+ }
115
+ next[bucket] = [...(next[bucket] || []), selectedItem];
116
+ return next;
117
+ });
118
+ setSelectedItem(null);
119
+ },
120
+ [selectedItem, completed]
121
+ );
122
+
123
+ /* ── Remove item from bucket back to pool ── */
124
+ const removeFromBucket = useCallback(
125
+ (itemId: string, e: React.MouseEvent) => {
126
+ e.stopPropagation();
127
+ if (completed) return;
128
+ setAssignments((prev) => {
129
+ const next: Record<string, string[]> = {};
130
+ for (const b of Object.keys(prev)) {
131
+ next[b] = prev[b].filter((id) => id !== itemId);
132
+ }
133
+ return next;
134
+ });
135
+ setUnclassified((prev) => [...prev, itemId]);
136
+ },
137
+ [completed]
138
+ );
139
+
140
+ /* ── Check all assignments ── */
141
+ const handleCheck = useCallback(() => {
142
+ if (!allPlaced || completed) return;
143
+
144
+ const wrong: string[] = [];
145
+ for (const [bucket, itemIds] of Object.entries(assignments)) {
146
+ for (const id of itemIds) {
147
+ if (correctAssignments[id] !== bucket) wrong.push(id);
148
+ }
149
+ }
150
+
151
+ if (wrong.length === 0) {
152
+ setCompleted(true);
153
+ onComplete();
154
+ } else {
155
+ setWrongItems(wrong);
156
+ setErrorMsg(feedbackMsg.error);
157
+ onError?.();
158
+ setTimeout(() => {
159
+ setAssignments((prev) => {
160
+ const next: Record<string, string[]> = {};
161
+ for (const b of Object.keys(prev)) {
162
+ next[b] = prev[b].filter((id) => !wrong.includes(id));
163
+ }
164
+ return next;
165
+ });
166
+ setUnclassified((prev) => [...prev, ...wrong]);
167
+ setWrongItems([]);
168
+ }, 900);
169
+ }
170
+ }, [allPlaced, completed, assignments, correctAssignments, onComplete, feedbackMsg]);
171
+
172
+ /* ── Hint ── */
173
+ const handleHint = useCallback(() => {
174
+ if (hintUsed || completed || unclassified.length === 0) return;
175
+ if (!onHintUse()) return;
176
+ setHintUsed(true);
177
+
178
+ const itemId = unclassified[0];
179
+ const correctBucket = correctAssignments[itemId];
180
+ setHintItem(itemId);
181
+ setSelectedItem(itemId);
182
+
183
+ setTimeout(() => {
184
+ setUnclassified((prev) => prev.filter((id) => id !== itemId));
185
+ setAssignments((prev) => {
186
+ const next = { ...prev };
187
+ next[correctBucket] = [...next[correctBucket], itemId];
188
+ return next;
189
+ });
190
+ setSelectedItem(null);
191
+ setHintItem(null);
192
+ }, 800);
193
+ }, [hintUsed, completed, onHintUse, unclassified, correctAssignments]);
194
+
195
+ /* ═══════════════════ Render ═══════════════════ */
196
+ return (
197
+ <div className="flex-1 flex flex-col min-w-0">
198
+ {/* ─── Question card ─── */}
199
+ <motion.div
200
+ initial={{ opacity: 0, y: -10 }}
201
+ animate={{ opacity: 1, y: 0 }}
202
+ className="mt-4 mb-6 flex items-center gap-4"
203
+ >
204
+ <div className="flex-shrink-0 w-11 h-11 rounded-2xl bg-gradient-to-br from-indigo-500 to-purple-500 flex items-center justify-center shadow-md shadow-purple-300/30">
205
+ <span className="font-heading font-extrabold text-white text-base">
206
+ {stageIndex + 1}
207
+ </span>
208
+ </div>
209
+ <div>
210
+ <p className="text-[11px] font-bold text-indigo-500 uppercase tracking-wider mb-0.5">
211
+ Stage {stageIndex + 1} of {totalStages} · {topic}
212
+ </p>
213
+ <h2 className="font-heading font-bold text-xl text-brand-gray-700 leading-snug">
214
+ {description}
215
+ </h2>
216
+ </div>
217
+ </motion.div>
218
+
219
+ {/* ─── Instruction chip ─── */}
220
+ <AnimatePresence>
221
+ {!allPlaced && !completed && (
222
+ <motion.div
223
+ initial={{ opacity: 0, y: -6 }}
224
+ animate={{ opacity: 1, y: 0 }}
225
+ exit={{ opacity: 0, height: 0 }}
226
+ className="mb-4 flex items-center gap-2"
227
+ >
228
+ <span className="inline-flex items-center gap-1.5 text-xs font-bold text-brand-gray-500 bg-white/60 backdrop-blur border border-brand-gray-200 rounded-full px-3 py-1.5">
229
+ <span className="text-brand-teal">①</span> Tap an item below
230
+ <span className="text-brand-teal mx-0.5">→</span>
231
+ <span className="text-brand-teal">②</span> Tap a bucket to classify
232
+ </span>
233
+ </motion.div>
234
+ )}
235
+ </AnimatePresence>
236
+
237
+ {/* ─── Bucket zones ─── */}
238
+ <div className="grid grid-cols-2 gap-4 mb-5">
239
+ {buckets.map((bucket, bi) => {
240
+ const accent = BUCKET_ACCENTS[bi % BUCKET_ACCENTS.length];
241
+ const bucketItems = assignments[bucket] || [];
242
+ const isTarget = !!selectedItem;
243
+
244
+ return (
245
+ <motion.div
246
+ key={bucket}
247
+ onClick={() => placeInBucket(bucket)}
248
+ whileHover={isTarget ? { scale: 1.02 } : {}}
249
+ whileTap={isTarget ? { scale: 0.98 } : {}}
250
+ className={`relative rounded-2xl border-2 border-dashed p-4 min-h-[160px] transition-all cursor-pointer ${
251
+ isTarget
252
+ ? `${accent.borderDash} ${accent.bg} ${accent.bgHover} shadow-md`
253
+ : "border-brand-gray-200 bg-white/40 backdrop-blur-sm"
254
+ } ${completed ? "opacity-80" : ""}`}
255
+ >
256
+ {/* Bucket label */}
257
+ <div className="flex items-center gap-2 mb-3">
258
+ <div
259
+ className={`h-6 w-6 rounded-lg flex items-center justify-center text-xs font-bold text-white ${
260
+ bi === 0
261
+ ? "bg-gradient-to-br from-brand-teal to-[#5fb3af]"
262
+ : "bg-gradient-to-br from-brand-coral to-[#d4654a]"
263
+ }`}
264
+ >
265
+ {bi === 0 ? "✓" : "✗"}
266
+ </div>
267
+ <h4 className="font-heading font-bold text-brand-gray-700 text-sm">
268
+ {bucket}
269
+ </h4>
270
+ </div>
271
+
272
+ {/* Items in this bucket */}
273
+ <div className="flex flex-wrap gap-2">
274
+ <AnimatePresence mode="popLayout">
275
+ {bucketItems.map((itemId) => {
276
+ const item = getItem(itemId);
277
+ const isWrong = wrongItems.includes(itemId);
278
+ const isHint = hintItem === itemId;
279
+ return (
280
+ <motion.button
281
+ key={itemId}
282
+ layout
283
+ initial={{ scale: 0.8, opacity: 0 }}
284
+ animate={{
285
+ scale: 1,
286
+ opacity: 1,
287
+ x: isWrong ? [0, -6, 6, -6, 6, 0] : 0,
288
+ }}
289
+ exit={{ scale: 0.8, opacity: 0 }}
290
+ transition={
291
+ isWrong
292
+ ? { x: { duration: 0.5 } }
293
+ : { type: "spring", damping: 18 }
294
+ }
295
+ onClick={(e) => removeFromBucket(itemId, e)}
296
+ className={`px-3 py-2 rounded-xl text-xs font-semibold border transition group ${
297
+ completed
298
+ ? "bg-brand-green/10 text-brand-green border-brand-green/40"
299
+ : isWrong
300
+ ? "bg-red-50 text-red-500 border-red-400"
301
+ : isHint
302
+ ? "bg-amber-50 text-amber-600 border-amber-300"
303
+ : `${accent.tagBg} ${accent.text} ${accent.tagBorder}`
304
+ }`}
305
+ >
306
+ {item.content}
307
+ {!completed && (
308
+ <span className="ml-1.5 text-[10px] opacity-0 group-hover:opacity-60 transition">
309
+ ×
310
+ </span>
311
+ )}
312
+ {completed && (
313
+ <span className="ml-1.5">✓</span>
314
+ )}
315
+ </motion.button>
316
+ );
317
+ })}
318
+ </AnimatePresence>
319
+ </div>
320
+
321
+ {/* Empty state */}
322
+ {bucketItems.length === 0 && (
323
+ <div className="flex items-center justify-center h-16 text-xs text-brand-gray-300 italic">
324
+ {isTarget ? "← Tap to place here" : "No items yet"}
325
+ </div>
326
+ )}
327
+ </motion.div>
328
+ );
329
+ })}
330
+ </div>
331
+
332
+ {/* ─── Error message ─── */}
333
+ <AnimatePresence>
334
+ {errorMsg && (
335
+ <motion.div
336
+ initial={{ opacity: 0, y: -8 }}
337
+ animate={{ opacity: 1, y: 0 }}
338
+ exit={{ opacity: 0, y: -8 }}
339
+ className="mb-3 px-4 py-2.5 rounded-xl bg-red-50 border border-red-200 text-xs text-red-600 font-medium"
340
+ >
341
+ 💡 {errorMsg}
342
+ </motion.div>
343
+ )}
344
+ </AnimatePresence>
345
+
346
+ {/* ─── Unclassified items pool ─── */}
347
+ <AnimatePresence mode="popLayout">
348
+ {unclassified.length > 0 && (
349
+ <motion.div layout>
350
+ <p className="text-[10px] font-bold uppercase tracking-widest text-brand-gray-400 mb-2">
351
+ Classify these items ↑
352
+ </p>
353
+ <div className="flex flex-col gap-2.5">
354
+ {unclassified.map((itemId) => {
355
+ const item = getItem(itemId);
356
+ const isSelected = selectedItem === itemId;
357
+ const isHint = hintItem === itemId;
358
+ return (
359
+ <motion.button
360
+ key={itemId}
361
+ layout
362
+ initial={{ opacity: 0, x: -20 }}
363
+ animate={{ opacity: 1, x: 0 }}
364
+ exit={{ opacity: 0, x: 20, height: 0 }}
365
+ transition={{ type: "spring", damping: 20 }}
366
+ onClick={() => selectItem(itemId)}
367
+ whileTap={{ scale: 0.97 }}
368
+ className={`w-full text-left rounded-2xl px-5 py-4 border-2 border-b-4 font-heading font-bold text-base transition-all ${
369
+ isHint
370
+ ? "border-amber-400 bg-amber-50 text-amber-700 shadow-md shadow-amber-200/40"
371
+ : isSelected
372
+ ? "border-brand-teal bg-brand-teal/10 text-brand-teal shadow-md shadow-teal-200/40"
373
+ : "border-brand-gray-200 bg-white text-brand-gray-700 hover:border-brand-teal/40 hover:shadow-sm"
374
+ }`}
375
+ >
376
+ <div className="flex items-center gap-3">
377
+ <span
378
+ className={`flex-shrink-0 h-7 w-7 rounded-lg flex items-center justify-center text-xs font-bold transition ${
379
+ isSelected
380
+ ? "bg-brand-teal text-white"
381
+ : "bg-brand-gray-100 text-brand-gray-400"
382
+ }`}
383
+ >
384
+ {isSelected ? "↑" : "?"}
385
+ </span>
386
+ <span className="text-sm">{item.content}</span>
387
+ </div>
388
+ </motion.button>
389
+ );
390
+ })}
391
+ </div>
392
+ </motion.div>
393
+ )}
394
+ </AnimatePresence>
395
+
396
+ {/* ─── All placed message ─── */}
397
+ {allPlaced && !completed && (
398
+ <motion.div
399
+ initial={{ opacity: 0, y: 10 }}
400
+ animate={{ opacity: 1, y: 0 }}
401
+ className="mt-3 text-center text-sm text-brand-teal font-semibold"
402
+ >
403
+ All items classified! Tap CHECK to verify.
404
+ </motion.div>
405
+ )}
406
+
407
+ {/* Spacer */}
408
+ <div className="flex-1 min-h-[40px]" />
409
+
410
+ {/* ─── Bottom Bar ─── */}
411
+ <div className="relative pt-4 pb-6 flex items-end justify-between">
412
+ {/* Mascot + hint */}
413
+ <div className="flex items-end gap-2">
414
+ <OwlMascotSmall />
415
+ <button
416
+ onClick={handleHint}
417
+ disabled={hintUsed || completed || unclassified.length === 0}
418
+ className={`mb-1 flex items-center gap-1 text-xs font-bold px-3 py-1.5 rounded-full border transition ${
419
+ hintUsed
420
+ ? "bg-brand-gray-100 text-brand-gray-400 border-brand-gray-200 cursor-not-allowed"
421
+ : "bg-amber-50 text-amber-600 border-amber-200 hover:bg-amber-100"
422
+ }`}
423
+ >
424
+ 💡 Hint
425
+ <span className="text-[10px] opacity-60">(10 💎)</span>
426
+ </button>
427
+ </div>
428
+
429
+ {/* CHECK button */}
430
+ <GameButton
431
+ variant="primary"
432
+ onClick={handleCheck}
433
+ disabled={!allPlaced || completed}
434
+ className="min-w-[140px]"
435
+ >
436
+ CHECK
437
+ </GameButton>
438
+ </div>
439
+ </div>
440
+ );
441
+ }
442
+
443
+ /* ═══════════════════ Owl Mascot (compact) ═══════════════════ */
444
+
445
+ function OwlMascotSmall() {
446
+ return (
447
+ <svg viewBox="0 0 80 80" className="h-14 w-14 flex-shrink-0" fill="none">
448
+ <ellipse cx="40" cy="52" rx="22" ry="20" fill="#E8A855" />
449
+ <ellipse cx="40" cy="54" rx="16" ry="14" fill="#F5DEB3" />
450
+ <circle cx="32" cy="40" r="9" fill="white" />
451
+ <circle cx="48" cy="40" r="9" fill="white" />
452
+ <circle cx="33" cy="40" r="5" fill="#2D2D2D" />
453
+ <circle cx="47" cy="40" r="5" fill="#2D2D2D" />
454
+ <circle cx="34.5" cy="38.5" r="1.8" fill="white" />
455
+ <circle cx="48.5" cy="38.5" r="1.8" fill="white" />
456
+ <polygon points="40,44 37,48 43,48" fill="#E8734A" />
457
+ <polygon points="22,30 26,38 18,36" fill="#D4943D" />
458
+ <polygon points="58,30 54,38 62,36" fill="#D4943D" />
459
+ <ellipse cx="33" cy="72" rx="5" ry="3" fill="#E8734A" />
460
+ <ellipse cx="47" cy="72" rx="5" ry="3" fill="#E8734A" />
461
+ </svg>
462
+ );
463
+ }
components/shared/ProfileModal.tsx ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { motion, AnimatePresence } from "framer-motion";
6
+ import useUserStore from "@/stores/useUserStore";
7
+
8
+ interface ProfileModalProps {
9
+ onClose: () => void;
10
+ }
11
+
12
+ export default function ProfileModal({ onClose }: ProfileModalProps) {
13
+ const router = useRouter();
14
+ const {
15
+ name,
16
+ title,
17
+ level,
18
+ xp,
19
+ longestStreak,
20
+ coursesCompleted,
21
+ preferences,
22
+ setPreferences,
23
+ logout,
24
+ } = useUserStore();
25
+
26
+ const { soundOn, darkGlass, difficulty } = preferences;
27
+
28
+ return (
29
+ <AnimatePresence>
30
+ <motion.div
31
+ className="fixed inset-0 z-[100] flex items-center justify-center"
32
+ initial={{ opacity: 0 }}
33
+ animate={{ opacity: 1 }}
34
+ exit={{ opacity: 0 }}
35
+ >
36
+ {/* Backdrop */}
37
+ <motion.div
38
+ className="absolute inset-0 bg-black/30"
39
+ onClick={onClose}
40
+ initial={{ opacity: 0, backdropFilter: "blur(0px)" }}
41
+ animate={{ opacity: 1, backdropFilter: "blur(6px)" }}
42
+ exit={{ opacity: 0, backdropFilter: "blur(0px)" }}
43
+ transition={{ duration: 0.6, ease: "easeOut" }}
44
+ />
45
+
46
+ {/* Modal card */}
47
+ <motion.div
48
+ className="relative z-10 w-full max-w-sm mx-4"
49
+ initial={{ scale: 0.9, opacity: 0, y: 30 }}
50
+ animate={{ scale: 1, opacity: 1, y: 0 }}
51
+ exit={{ scale: 0.9, opacity: 0, y: 30 }}
52
+ transition={{ type: "spring", damping: 25, stiffness: 300 }}
53
+ >
54
+ {/* Floating avatar – overlaps top edge */}
55
+ <div className="flex justify-center -mb-12 relative z-20">
56
+ <div className="relative">
57
+ <div className="h-24 w-24 rounded-full bg-[#e8ddd0] border-4 border-white shadow-lg flex items-center justify-center overflow-hidden">
58
+ <OwlAvatar />
59
+ </div>
60
+ {/* Edit badge */}
61
+ <button className="absolute bottom-0 right-0 h-7 w-7 rounded-full bg-white shadow-md border border-brand-gray-100 flex items-center justify-center hover:bg-brand-gray-50 transition">
62
+ <svg viewBox="0 0 16 16" className="h-3.5 w-3.5 text-brand-gray-500" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
63
+ <path d="M11.5 1.5l3 3L5 14H2v-3L11.5 1.5z" />
64
+ </svg>
65
+ </button>
66
+ </div>
67
+ </div>
68
+
69
+ {/* Glass card body */}
70
+ <div className="rounded-2xl border border-white/40 bg-white/80 backdrop-blur-xl shadow-2xl ring-1 ring-white/20 overflow-hidden pt-14 pb-5 px-6">
71
+ {/* Name & title */}
72
+ <div className="text-center mb-5">
73
+ <h2 className="font-heading text-xl font-extrabold text-brand-gray-700">
74
+ Level {level} {title}
75
+ </h2>
76
+ <p className="text-sm text-brand-gray-400 mt-0.5">
77
+ {name}
78
+ </p>
79
+ </div>
80
+
81
+ {/* ── Stats Grid ── */}
82
+ <div className="mb-5">
83
+ <h3 className="font-heading font-bold text-brand-gray-600 text-sm mb-2.5">
84
+ Stats Grid
85
+ </h3>
86
+ <div className="grid grid-cols-3 gap-2.5">
87
+ <StatBox label="Total XP Earned" value={xp.toLocaleString()} />
88
+ <StatBox label="Longest Streak" value={`${longestStreak} Days`} />
89
+ <StatBox label="Courses Completed" value={String(coursesCompleted)} />
90
+ </div>
91
+ </div>
92
+
93
+ {/* ── Preferences ── */}
94
+ <div className="mb-4">
95
+ <h3 className="font-heading font-bold text-brand-gray-600 text-sm mb-3">
96
+ Preferences
97
+ </h3>
98
+ <div className="space-y-3.5">
99
+ {/* Sound Effects */}
100
+ <div className="flex items-center justify-between">
101
+ <span className="text-sm text-brand-gray-600">Sound Effects</span>
102
+ <Toggle on={soundOn} onChange={() => setPreferences({ soundOn: !soundOn })} />
103
+ </div>
104
+
105
+ {/* Dark/Light Theme */}
106
+ <div className="flex items-center justify-between">
107
+ <span className="text-sm text-brand-gray-600">Dark/Light Theme</span>
108
+ <div className="flex items-center gap-2">
109
+ <span className="text-xs text-brand-gray-400 font-medium">Dark/Glass</span>
110
+ <Toggle on={darkGlass} onChange={() => setPreferences({ darkGlass: !darkGlass })} />
111
+ </div>
112
+ </div>
113
+
114
+ {/* Difficulty Scaling */}
115
+ <div className="flex items-center justify-between gap-4">
116
+ <span className="text-sm text-brand-gray-600 shrink-0">Difficulty Scaling</span>
117
+ <input
118
+ type="range"
119
+ min="0"
120
+ max="100"
121
+ value={difficulty}
122
+ onChange={(e) => setPreferences({ difficulty: Number(e.target.value) })}
123
+ className="w-full h-1.5 rounded-full appearance-none cursor-pointer accent-brand-teal bg-brand-gray-200"
124
+ />
125
+ </div>
126
+ </div>
127
+ </div>
128
+
129
+ {/* ── Log Out ── */}
130
+ <div className="pt-2 border-t border-brand-gray-100">
131
+ <button
132
+ onClick={() => { logout(); onClose(); router.push("/login"); }}
133
+ className="w-full text-center text-sm text-brand-teal font-semibold hover:text-brand-teal/70 transition py-2"
134
+ >
135
+ Log Out
136
+ </button>
137
+ </div>
138
+ </div>
139
+ </motion.div>
140
+ </motion.div>
141
+ </AnimatePresence>
142
+ );
143
+ }
144
+
145
+ /* ── Stat box ── */
146
+
147
+ function StatBox({ label, value }: { label: string; value: string }) {
148
+ return (
149
+ <div className="bg-gradient-to-b from-white/60 to-brand-gray-50 border border-brand-gray-100 rounded-xl px-2 py-3 text-center">
150
+ <p className="text-[10px] text-brand-gray-400 leading-tight mb-1">{label}</p>
151
+ <p className="font-heading font-extrabold text-brand-gray-700 text-sm">
152
+ {value}
153
+ </p>
154
+ </div>
155
+ );
156
+ }
157
+
158
+ /* ── Toggle switch ── */
159
+
160
+ function Toggle({ on, onChange }: { on: boolean; onChange: () => void }) {
161
+ return (
162
+ <button
163
+ onClick={onChange}
164
+ className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors duration-200 ${
165
+ on ? "bg-brand-teal" : "bg-brand-gray-200"
166
+ }`}
167
+ >
168
+ <span
169
+ className={`inline-block h-4.5 w-4.5 rounded-full bg-white shadow-sm transform transition-transform duration-200 ${
170
+ on ? "translate-x-5.5" : "translate-x-1"
171
+ }`}
172
+ style={{
173
+ width: "18px",
174
+ height: "18px",
175
+ transform: on ? "translateX(22px)" : "translateX(3px)",
176
+ }}
177
+ />
178
+ </button>
179
+ );
180
+ }
181
+
182
+ /* ── Owl avatar (large) ── */
183
+
184
+ function OwlAvatar() {
185
+ return (
186
+ <svg viewBox="0 0 96 96" className="h-20 w-20" fill="none">
187
+ {/* Body */}
188
+ <ellipse cx="48" cy="56" rx="22" ry="26" fill="#C4A882" />
189
+ <ellipse cx="48" cy="53" rx="17" ry="20" fill="#E8D5B7" />
190
+ {/* Eyes bg */}
191
+ <circle cx="39" cy="44" r="8" fill="white" />
192
+ <circle cx="57" cy="44" r="8" fill="white" />
193
+ {/* Eye rims (glasses) */}
194
+ <circle cx="39" cy="44" r="8.5" fill="none" stroke="#8B7355" strokeWidth="1.8" />
195
+ <circle cx="57" cy="44" r="8.5" fill="none" stroke="#8B7355" strokeWidth="1.8" />
196
+ <line x1="47.5" y1="44" x2="48.5" y2="44" stroke="#8B7355" strokeWidth="1.8" />
197
+ {/* Pupils */}
198
+ <circle cx="40" cy="44" r="4" fill="#333" />
199
+ <circle cx="56" cy="44" r="4" fill="#333" />
200
+ {/* Highlights */}
201
+ <circle cx="41.5" cy="42.5" r="1.5" fill="white" />
202
+ <circle cx="57.5" cy="42.5" r="1.5" fill="white" />
203
+ {/* Beak */}
204
+ <polygon points="48,50 45,54 51,54" fill="#E8734A" />
205
+ {/* Ear tufts */}
206
+ <polygon points="35,32 38,24 42,34" fill="#C4A882" />
207
+ <polygon points="61,32 58,24 54,34" fill="#C4A882" />
208
+ {/* Feet */}
209
+ <ellipse cx="42" cy="80" rx="6" ry="2.5" fill="#E8734A" opacity="0.7" />
210
+ <ellipse cx="54" cy="80" rx="6" ry="2.5" fill="#E8734A" opacity="0.7" />
211
+ </svg>
212
+ );
213
+ }
214
+
components/shared/TopStatsBar.tsx ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import React, { useState } from "react";
4
+ import Link from "next/link";
5
+ import ProfileModal from "./ProfileModal";
6
+ import useUserStore from "@/stores/useUserStore";
7
+
8
+ interface TopStatsBarProps {
9
+ backHref?: string;
10
+ pageTitle?: string;
11
+ }
12
+
13
+ export default function TopStatsBar({ backHref, pageTitle }: TopStatsBarProps = {}) {
14
+ const [profileOpen, setProfileOpen] = useState(false);
15
+ const { streak, gems, level } = useUserStore();
16
+
17
+ /* Compute rank label from level */
18
+ const rankLabel =
19
+ level >= 15 ? "Diamond Rank" :
20
+ level >= 10 ? "Platinum Rank" :
21
+ level >= 7 ? "Gold Rank" :
22
+ level >= 4 ? "Silver Rank" :
23
+ "Bronze Rank";
24
+
25
+ return (
26
+ <>
27
+ <nav className="sticky top-0 z-50 flex items-center justify-between px-4 md:px-8 py-3 bg-white/70 backdrop-blur-lg border-b border-white/40 shadow-sm">
28
+ {/* Left: back arrow or avatar + logo */}
29
+ <div className="flex items-center gap-3 relative">
30
+ {backHref ? (
31
+ /* Back arrow mode (e.g. store, map) */
32
+ <>
33
+ <Link
34
+ href={backHref}
35
+ className="h-10 w-10 rounded-full flex items-center justify-center hover:bg-brand-gray-50 transition text-brand-gray-600"
36
+ >
37
+ <svg viewBox="0 0 24 24" className="h-6 w-6" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
38
+ <path d="M15 18l-6-6 6-6" />
39
+ </svg>
40
+ </Link>
41
+ <OwlLogoSmall />
42
+ <span className="font-heading text-xl font-extrabold text-brand-gray-700">
43
+ {pageTitle}
44
+ </span>
45
+ </>
46
+ ) : (
47
+ /* Default avatar + logo mode */
48
+ <>
49
+ <button
50
+ onClick={() => setProfileOpen(true)}
51
+ className="relative h-11 w-11 rounded-full overflow-hidden border-2 border-brand-teal shadow-md hover:shadow-lg transition"
52
+ >
53
+ <MascotAvatar />
54
+ </button>
55
+
56
+ <Link href="/home" className="flex items-center gap-2">
57
+ <span className="font-heading text-xl font-extrabold text-brand-teal hidden sm:inline">
58
+ Learn8
59
+ </span>
60
+ </Link>
61
+ </>
62
+ )}
63
+ </div>
64
+
65
+ {/* Right: stats */}
66
+ <div className="flex items-center gap-4 md:gap-6">
67
+ {/* Streak */}
68
+ <div className="flex items-center gap-1.5">
69
+ <span className="text-lg">🔥</span>
70
+ <span className="font-heading font-bold text-brand-gray-700 text-sm md:text-base">
71
+ {streak} Days
72
+ </span>
73
+ </div>
74
+
75
+ {/* Gems */}
76
+ <Link
77
+ href="/store"
78
+ className="flex items-center gap-1.5 hover:opacity-80 transition"
79
+ >
80
+ <span className="text-lg">💎</span>
81
+ <span className="font-heading font-bold text-brand-gray-700 text-sm md:text-base">
82
+ {gems.toLocaleString()}
83
+ </span>
84
+ </Link>
85
+
86
+ {/* Rank badge */}
87
+ <div className="flex items-center gap-1.5">
88
+ <div className="h-8 w-8 rounded-full bg-gradient-to-br from-yellow-300 to-yellow-500 flex items-center justify-center shadow-md">
89
+ <span className="font-heading font-extrabold text-white text-xs">
90
+ {level}
91
+ </span>
92
+ </div>
93
+ <span className="font-heading font-bold text-brand-gray-700 text-sm md:text-base hidden md:inline">
94
+ {rankLabel}
95
+ </span>
96
+ </div>
97
+ </div>
98
+ </nav>
99
+
100
+ {/* Profile modal */}
101
+ {profileOpen && (
102
+ <ProfileModal onClose={() => setProfileOpen(false)} />
103
+ )}
104
+ </>
105
+ );
106
+ }
107
+
108
+ /* ── Small mascot avatar ── */
109
+ function MascotAvatar() {
110
+ return (
111
+ <svg viewBox="0 0 64 64" className="h-full w-full" fill="none">
112
+ <rect width="64" height="64" rx="32" fill="#E8F5F4" />
113
+ <ellipse cx="32" cy="36" rx="16" ry="18" fill="#C4A882" />
114
+ <ellipse cx="32" cy="34" rx="12" ry="14" fill="#E8D5B7" />
115
+ <circle cx="26" cy="29" r="5" fill="white" />
116
+ <circle cx="38" cy="29" r="5" fill="white" />
117
+ <circle cx="26" cy="29" r="5.5" fill="none" stroke="#6B6B6B" strokeWidth="1.2" />
118
+ <circle cx="38" cy="29" r="5.5" fill="none" stroke="#6B6B6B" strokeWidth="1.2" />
119
+ <line x1="31" y1="29" x2="33" y2="29" stroke="#6B6B6B" strokeWidth="1.2" />
120
+ <circle cx="27" cy="29" r="2.5" fill="#333" />
121
+ <circle cx="37" cy="29" r="2.5" fill="#333" />
122
+ <circle cx="28" cy="28" r="0.8" fill="white" />
123
+ <circle cx="38" cy="28" r="0.8" fill="white" />
124
+ <polygon points="32,33 30,36 34,36" fill="#E8734A" />
125
+ <polygon points="25,22 28,17 30,24" fill="#C4A882" />
126
+ <polygon points="39,22 36,17 34,24" fill="#C4A882" />
127
+ </svg>
128
+ );
129
+ }
130
+
131
+ /* ── Small owl logo icon ── */
132
+ function OwlLogoSmall() {
133
+ return (
134
+ <svg viewBox="0 0 32 32" className="h-8 w-8" fill="none">
135
+ <circle cx="16" cy="16" r="15" fill="#7AC7C4" opacity="0.2" />
136
+ <ellipse cx="16" cy="18" rx="9" ry="10" fill="#C4A882" />
137
+ <ellipse cx="16" cy="17" rx="7" ry="8" fill="#E8D5B7" />
138
+ <circle cx="13" cy="14" r="3" fill="white" />
139
+ <circle cx="19" cy="14" r="3" fill="white" />
140
+ <circle cx="13.5" cy="14" r="1.5" fill="#333" />
141
+ <circle cx="18.5" cy="14" r="1.5" fill="#333" />
142
+ <polygon points="16,16 14.5,18 17.5,18" fill="#E8734A" />
143
+ </svg>
144
+ );
145
+ }
components/ui/DeepGlassCard.tsx ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+
3
+ interface DeepGlassCardProps extends React.HTMLAttributes<HTMLDivElement> {
4
+ children: React.ReactNode;
5
+ }
6
+
7
+ export default function DeepGlassCard({
8
+ children,
9
+ className = "",
10
+ ...props
11
+ }: DeepGlassCardProps) {
12
+ return (
13
+ <div
14
+ className={`relative rounded-3xl border border-white/40 bg-white/60 backdrop-blur-xl shadow-2xl ring-1 ring-white/20 ${className}`}
15
+ {...props}
16
+ >
17
+ {/* Inner highlight edge */}
18
+ <div className="pointer-events-none absolute inset-0 rounded-3xl bg-gradient-to-br from-white/30 via-transparent to-transparent" />
19
+ <div className="relative z-10">{children}</div>
20
+ </div>
21
+ );
22
+ }
components/ui/GameButton.tsx ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import React from "react";
4
+
5
+ interface GameButtonProps
6
+ extends React.ButtonHTMLAttributes<HTMLButtonElement> {
7
+ variant?: "primary" | "secondary";
8
+ pulse?: boolean;
9
+ }
10
+
11
+ export default function GameButton({
12
+ children,
13
+ variant = "primary",
14
+ pulse = false,
15
+ className = "",
16
+ ...props
17
+ }: GameButtonProps) {
18
+ const base =
19
+ "relative font-heading font-bold uppercase tracking-wider rounded-xl px-8 py-3.5 text-white shadow-lg transition-all duration-200 active:translate-y-0.5 active:shadow-md disabled:opacity-50 disabled:pointer-events-none";
20
+
21
+ const variants = {
22
+ primary:
23
+ "bg-gradient-to-b from-[#58CC02] to-[#46a302] hover:from-[#62d406] hover:to-[#4fb803] border-b-4 border-[#3a8a02]",
24
+ secondary:
25
+ "bg-gradient-to-b from-[#7AC7C4] to-[#5fb3af] hover:from-[#88d1ce] hover:to-[#6bbdb9] border-b-4 border-[#4a9e9a]",
26
+ };
27
+
28
+ return (
29
+ <button
30
+ className={`${base} ${variants[variant]} ${pulse ? "animate-pulse-subtle" : ""} ${className}`}
31
+ {...props}
32
+ >
33
+ {children}
34
+ </button>
35
+ );
36
+ }
components/ui/MascotHint.tsx ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+
3
+ interface MascotHintProps {
4
+ message: string;
5
+ }
6
+
7
+ export default function MascotHint({ message }: MascotHintProps) {
8
+ return (
9
+ <div className="flex items-start gap-3">
10
+ {/* Small owl avatar */}
11
+ <div className="shrink-0">
12
+ <svg
13
+ viewBox="0 0 64 64"
14
+ className="h-12 w-12"
15
+ fill="none"
16
+ xmlns="http://www.w3.org/2000/svg"
17
+ >
18
+ <ellipse cx="32" cy="36" rx="18" ry="20" fill="#C4A882" />
19
+ <ellipse cx="32" cy="34" rx="14" ry="16" fill="#E8D5B7" />
20
+ <circle cx="25" cy="28" r="6" fill="white" />
21
+ <circle cx="39" cy="28" r="6" fill="white" />
22
+ <circle cx="25" cy="28" r="6.5" fill="none" stroke="#6B6B6B" strokeWidth="1.5" />
23
+ <circle cx="39" cy="28" r="6.5" fill="none" stroke="#6B6B6B" strokeWidth="1.5" />
24
+ <line x1="31.5" y1="28" x2="32.5" y2="28" stroke="#6B6B6B" strokeWidth="1.5" />
25
+ <circle cx="26" cy="28" r="3" fill="#333" />
26
+ <circle cx="38" cy="28" r="3" fill="#333" />
27
+ <circle cx="27" cy="27" r="1" fill="white" />
28
+ <circle cx="39" cy="27" r="1" fill="white" />
29
+ <polygon points="32,32 29,36 35,36" fill="#E8734A" />
30
+ <polygon points="24,20 28,14 30,22" fill="#C4A882" />
31
+ <polygon points="40,20 36,14 34,22" fill="#C4A882" />
32
+ </svg>
33
+ </div>
34
+ {/* Speech bubble */}
35
+ <div className="relative rounded-2xl bg-white/90 backdrop-blur-sm px-5 py-3 shadow-md border border-white/50">
36
+ <div className="absolute -left-2 top-4 h-3 w-3 rotate-45 bg-white/90 border-l border-b border-white/50" />
37
+ <p className="text-sm text-brand-gray-600 font-medium relative z-10">
38
+ {message}
39
+ </p>
40
+ </div>
41
+ </div>
42
+ );
43
+ }
components/ui/TopProgressBar.tsx ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+
3
+ interface TopProgressBarProps {
4
+ progress: number; // 0-100
5
+ className?: string;
6
+ }
7
+
8
+ export default function TopProgressBar({
9
+ progress,
10
+ className = "",
11
+ }: TopProgressBarProps) {
12
+ return (
13
+ <div
14
+ className={`w-full h-3 rounded-full bg-brand-gray-200 overflow-hidden ${className}`}
15
+ >
16
+ <div
17
+ className="h-full rounded-full bg-gradient-to-r from-brand-teal to-brand-green transition-all duration-500 ease-out"
18
+ style={{ width: `${Math.min(100, Math.max(0, progress))}%` }}
19
+ />
20
+ </div>
21
+ );
22
+ }
data/mock_calculus.ts ADDED
@@ -0,0 +1,383 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const mockCalculusUnit = {
2
+ unitId: "calc-u1",
3
+ unitTitle: "Limits & Derivatives",
4
+ nodes: [
5
+ {
6
+ id: "calc-n1",
7
+ title: "Concept of Limits",
8
+ description: "Classify scenarios where limits exist or fail.",
9
+ type: "concept",
10
+ status: "available",
11
+ stages: [
12
+ {
13
+ stageId: "calc-s1",
14
+ topic: "Limit Existence",
15
+ module: "Instruction",
16
+ component: "TaxonomyMatrix",
17
+ skin: "Code",
18
+ config: {
19
+ data: {
20
+ buckets: ["Limit Exists", "Limit Does Not Exist"],
21
+ items: [
22
+ { id: "limit1", content: "Left limit = 5, Right = 5" },
23
+ { id: "limit2", content: "Left limit = 3, Right = -3 (Jump)" },
24
+ { id: "limit3", content: "Approaches infinity (Asymptote)" },
25
+ { id: "limit4", content: "Continuous polynomial" }
26
+ ]
27
+ },
28
+ initialState: { assignments: {} }
29
+ },
30
+ validation: { type: "exact", condition: {} },
31
+ feedback: {
32
+ success: "Excellent classification! Limits require agreement from both sides.",
33
+ error: "Check the definitions of jumps and asymptotes.",
34
+ hint: "If left and right don't match, it doesn't exist."
35
+ }
36
+ }
37
+ ]
38
+ },
39
+ {
40
+ id: "calc-n2",
41
+ title: "Derivative Rules",
42
+ description: "Match base functions to their derivatives.",
43
+ type: "exercise",
44
+ status: "locked",
45
+ stages: [
46
+ {
47
+ stageId: "calc-s2",
48
+ topic: "Basic Differentiation",
49
+ module: "Practice",
50
+ component: "PatternMatcher",
51
+ skin: "Scientific",
52
+ config: {
53
+ data: {
54
+ pairs: [
55
+ { id: "diff1", left: "f(x) = x³", right: "f'(x) = 3x²" },
56
+ { id: "diff2", left: "f(x) = sin(x)", right: "f'(x) = cos(x)" },
57
+ { id: "diff3", left: "f(x) = eˣ", right: "f'(x) = eˣ" },
58
+ { id: "diff4", left: "f(x) = ln(x)", right: "f'(x) = 1/x" }
59
+ ]
60
+ },
61
+ initialState: {}
62
+ },
63
+ validation: { type: "exact", condition: {} },
64
+ feedback: {
65
+ success: "All matched! You master the basic power and transcendental rules.",
66
+ error: "Review your trig and exponential derivatives.",
67
+ hint: "The derivative of eˣ is special!"
68
+ }
69
+ }
70
+ ]
71
+ },
72
+ {
73
+ id: "calc-n3",
74
+ title: "Squeeze Theorem",
75
+ description: "Apply the squeeze theorem to find limits.",
76
+ type: "concept",
77
+ status: "locked",
78
+ stages: [
79
+ {
80
+ stageId: "calc-s3",
81
+ topic: "Squeeze Theorem Application",
82
+ module: "Instruction",
83
+ component: "LogicChain",
84
+ skin: "Code",
85
+ config: {
86
+ data: { nodes: ["Find lower bound g(x)", "Find upper bound h(x)", "Show g(x) ≤ f(x) ≤ h(x)", "Evaluate lim g(x) = lim h(x)", "Conclude lim f(x)"] },
87
+ initialState: {}
88
+ },
89
+ validation: { type: "exact", condition: {} },
90
+ feedback: { success: "Perfect squeeze theorem steps!", error: "Review the theorem conditions.", hint: "Both bounds must converge to the same value." }
91
+ }
92
+ ]
93
+ },
94
+ {
95
+ id: "calc-n4",
96
+ title: "Continuity",
97
+ description: "Determine where functions are continuous.",
98
+ type: "exercise",
99
+ status: "locked",
100
+ stages: [
101
+ {
102
+ stageId: "calc-s4",
103
+ topic: "Continuity Conditions",
104
+ module: "Practice",
105
+ component: "TaxonomyMatrix",
106
+ skin: "Code",
107
+ config: {
108
+ data: {
109
+ buckets: ["Continuous", "Discontinuous"],
110
+ items: [
111
+ { id: "cont1", content: "f(a) is defined, lim = f(a)" },
112
+ { id: "cont2", content: "Jump at x = 2" },
113
+ { id: "cont3", content: "Removable hole at x = 0" },
114
+ { id: "cont4", content: "Polynomial on all reals" }
115
+ ]
116
+ },
117
+ initialState: { assignments: {} }
118
+ },
119
+ validation: { type: "exact", condition: {} },
120
+ feedback: { success: "Great continuity analysis!", error: "Check the three conditions.", hint: "All three conditions must hold." }
121
+ }
122
+ ]
123
+ },
124
+ {
125
+ id: "calc-n5",
126
+ title: "Power Rule",
127
+ description: "Master the power rule for differentiation.",
128
+ type: "concept",
129
+ status: "locked",
130
+ stages: [
131
+ {
132
+ stageId: "calc-s5",
133
+ topic: "Power Rule Practice",
134
+ module: "Instruction",
135
+ component: "LogicChain",
136
+ skin: "Code",
137
+ config: {
138
+ data: { nodes: ["f(x) = xⁿ", "Bring down exponent", "Multiply by coefficient", "Reduce exponent by 1", "f'(x) = nxⁿ⁻¹"] },
139
+ initialState: {}
140
+ },
141
+ validation: { type: "exact", condition: {} },
142
+ feedback: { success: "Power rule mastered!", error: "Review the power rule steps.", hint: "d/dx[xⁿ] = nxⁿ⁻¹" }
143
+ }
144
+ ]
145
+ },
146
+ {
147
+ id: "calc-n6",
148
+ title: "Product Rule",
149
+ description: "Apply the product rule to differentiate.",
150
+ type: "exercise",
151
+ status: "locked",
152
+ stages: [
153
+ {
154
+ stageId: "calc-s6",
155
+ topic: "Product Rule",
156
+ module: "Practice",
157
+ component: "LogicChain",
158
+ skin: "Scientific",
159
+ config: {
160
+ data: { nodes: ["Identify u and v", "Find u'", "Find v'", "Apply u'v + uv'", "Simplify"] },
161
+ initialState: {}
162
+ },
163
+ validation: { type: "exact", condition: {} },
164
+ feedback: { success: "Product rule applied correctly!", error: "Remember: (uv)' = u'v + uv'", hint: "Don't forget both terms." }
165
+ }
166
+ ]
167
+ },
168
+ {
169
+ id: "calc-n7",
170
+ title: "Quotient Rule",
171
+ description: "Differentiate rational functions.",
172
+ type: "concept",
173
+ status: "locked",
174
+ stages: [
175
+ {
176
+ stageId: "calc-s7",
177
+ topic: "Quotient Rule",
178
+ module: "Instruction",
179
+ component: "LogicChain",
180
+ skin: "Code",
181
+ config: {
182
+ data: { nodes: ["Identify numerator u", "Identify denominator v", "Compute u'v - uv'", "Divide by v²", "Simplify"] },
183
+ initialState: {}
184
+ },
185
+ validation: { type: "exact", condition: {} },
186
+ feedback: { success: "Quotient rule nailed!", error: "Review: (u/v)' = (u'v - uv')/v²", hint: "Lo dHi minus Hi dLo over Lo Lo." }
187
+ }
188
+ ]
189
+ },
190
+ {
191
+ id: "calc-n8",
192
+ title: "Chain Rule",
193
+ description: "Differentiate composite functions.",
194
+ type: "exercise",
195
+ status: "locked",
196
+ stages: [
197
+ {
198
+ stageId: "calc-s8",
199
+ topic: "Chain Rule",
200
+ module: "Practice",
201
+ component: "LogicChain",
202
+ skin: "Scientific",
203
+ config: {
204
+ data: { nodes: ["Identify outer function f", "Identify inner function g", "Differentiate f'(g(x))", "Multiply by g'(x)", "Final answer"] },
205
+ initialState: {}
206
+ },
207
+ validation: { type: "exact", condition: {} },
208
+ feedback: { success: "Chain rule mastered!", error: "Don't forget the inner derivative!", hint: "d/dx[f(g(x))] = f'(g(x))·g'(x)" }
209
+ }
210
+ ]
211
+ },
212
+ {
213
+ id: "calc-n9",
214
+ title: "Implicit Differentiation",
215
+ description: "Differentiate implicitly defined functions.",
216
+ type: "concept",
217
+ status: "locked",
218
+ stages: [
219
+ {
220
+ stageId: "calc-s9",
221
+ topic: "Implicit Differentiation Steps",
222
+ module: "Instruction",
223
+ component: "LogicChain",
224
+ skin: "Code",
225
+ config: {
226
+ data: { nodes: ["Differentiate both sides", "Apply chain rule to y terms", "Collect dy/dx terms", "Factor out dy/dx", "Solve for dy/dx"] },
227
+ initialState: {}
228
+ },
229
+ validation: { type: "exact", condition: {} },
230
+ feedback: { success: "Implicit differentiation complete!", error: "Remember to treat y as a function of x.", hint: "Every y term needs dy/dx." }
231
+ }
232
+ ]
233
+ },
234
+ {
235
+ id: "calc-n10",
236
+ title: "Related Rates",
237
+ description: "Solve related rates problems.",
238
+ type: "exercise",
239
+ status: "locked",
240
+ stages: [
241
+ {
242
+ stageId: "calc-s10",
243
+ topic: "Related Rates Strategy",
244
+ module: "Practice",
245
+ component: "LogicChain",
246
+ skin: "Scientific",
247
+ config: {
248
+ data: { nodes: ["Draw diagram", "Write equation relating variables", "Differentiate with respect to t", "Substitute known values", "Solve for unknown rate"] },
249
+ initialState: {}
250
+ },
251
+ validation: { type: "exact", condition: {} },
252
+ feedback: { success: "Related rates solved!", error: "Make sure to differentiate with respect to time.", hint: "Everything changes with time." }
253
+ }
254
+ ]
255
+ },
256
+ {
257
+ id: "calc-n11",
258
+ title: "Trig Derivatives",
259
+ description: "Differentiate trigonometric functions.",
260
+ type: "concept",
261
+ status: "locked",
262
+ stages: [
263
+ {
264
+ stageId: "calc-s11",
265
+ topic: "Trig Function Derivatives",
266
+ module: "Instruction",
267
+ component: "TaxonomyMatrix",
268
+ skin: "Code",
269
+ config: {
270
+ data: {
271
+ buckets: ["Positive Cosine Family", "Negative Sine Family"],
272
+ items: [
273
+ { id: "trig1", content: "d/dx[sin x] = cos x" },
274
+ { id: "trig2", content: "d/dx[cos x] = -sin x" },
275
+ { id: "trig3", content: "d/dx[tan x] = sec²x" },
276
+ { id: "trig4", content: "d/dx[cot x] = -csc²x" }
277
+ ]
278
+ },
279
+ initialState: { assignments: {} }
280
+ },
281
+ validation: { type: "exact", condition: {} },
282
+ feedback: { success: "Trig derivatives classified!", error: "Review co-function patterns.", hint: "Co-functions have negative derivatives." }
283
+ }
284
+ ]
285
+ },
286
+ {
287
+ id: "calc-n12",
288
+ title: "Higher-Order Derivatives",
289
+ description: "Compute second and third derivatives.",
290
+ type: "exercise",
291
+ status: "locked",
292
+ stages: [
293
+ {
294
+ stageId: "calc-s12",
295
+ topic: "Higher-Order Derivatives",
296
+ module: "Practice",
297
+ component: "LogicChain",
298
+ skin: "Code",
299
+ config: {
300
+ data: { nodes: ["f(x)", "f'(x) — First derivative", "f''(x) — Second derivative", "f'''(x) — Third derivative"] },
301
+ initialState: {}
302
+ },
303
+ validation: { type: "exact", condition: {} },
304
+ feedback: { success: "Higher-order derivatives done!", error: "Just keep differentiating!", hint: "Differentiate the previous result." }
305
+ }
306
+ ]
307
+ },
308
+ {
309
+ id: "calc-n13",
310
+ title: "L'Hôpital's Rule",
311
+ description: "Evaluate indeterminate limits.",
312
+ type: "concept",
313
+ status: "locked",
314
+ stages: [
315
+ {
316
+ stageId: "calc-s13",
317
+ topic: "L'Hôpital's Rule",
318
+ module: "Instruction",
319
+ component: "LogicChain",
320
+ skin: "Scientific",
321
+ config: {
322
+ data: { nodes: ["Check 0/0 or ∞/∞ form", "Differentiate numerator", "Differentiate denominator", "Re-evaluate limit", "Repeat if needed"] },
323
+ initialState: {}
324
+ },
325
+ validation: { type: "exact", condition: {} },
326
+ feedback: { success: "L'Hôpital's applied correctly!", error: "Only works for indeterminate forms.", hint: "Must be 0/0 or ∞/∞ first." }
327
+ }
328
+ ]
329
+ },
330
+ {
331
+ id: "calc-n14",
332
+ title: "Mean Value Theorem",
333
+ description: "Understand and apply MVT.",
334
+ type: "exercise",
335
+ status: "locked",
336
+ stages: [
337
+ {
338
+ stageId: "calc-s14",
339
+ topic: "MVT Application",
340
+ module: "Practice",
341
+ component: "LogicChain",
342
+ skin: "Code",
343
+ config: {
344
+ data: { nodes: ["Verify continuity on [a,b]", "Verify differentiability on (a,b)", "Compute [f(b)-f(a)]/(b-a)", "Set f'(c) equal to slope", "Solve for c"] },
345
+ initialState: {}
346
+ },
347
+ validation: { type: "exact", condition: {} },
348
+ feedback: { success: "MVT applied perfectly!", error: "Check the theorem conditions.", hint: "There exists a c where the tangent equals the secant." }
349
+ }
350
+ ]
351
+ },
352
+ {
353
+ id: "calc-n15",
354
+ title: "Final Challenge",
355
+ description: "Comprehensive limits & derivatives assessment.",
356
+ type: "challenge",
357
+ status: "locked",
358
+ stages: [
359
+ {
360
+ stageId: "calc-s15",
361
+ topic: "Comprehensive Review",
362
+ module: "Assessment",
363
+ component: "TaxonomyMatrix",
364
+ skin: "Code",
365
+ config: {
366
+ data: {
367
+ buckets: ["Differentiation Technique", "Limit Technique"],
368
+ items: [
369
+ { id: "final1", content: "Chain Rule" },
370
+ { id: "final2", content: "L'Hôpital's Rule" },
371
+ { id: "final3", content: "Product Rule" },
372
+ { id: "final4", content: "Squeeze Theorem" }
373
+ ]
374
+ },
375
+ initialState: { assignments: {} }
376
+ },
377
+ validation: { type: "exact", condition: {} },
378
+ feedback: { success: "Congratulations! Calculus mastered!", error: "Review the course material.", hint: "Classify each technique by its primary use." }
379
+ }
380
+ ]
381
+ }
382
+ ]
383
+ };
data/mock_medicine.ts ADDED
@@ -0,0 +1,416 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const mockMedicineUnit = {
2
+ unitId: "med-u1",
3
+ unitTitle: "The Heart & Blood Flow",
4
+ nodes: [
5
+ {
6
+ id: "med-n1",
7
+ title: "Heart Chambers (心臟結構)",
8
+ description: "Identify the spatial anatomy of the heart.",
9
+ type: "concept",
10
+ status: "available",
11
+ stages: [
12
+ {
13
+ stageId: "med-s1",
14
+ topic: "Identify the Left Ventricle",
15
+ module: "Instruction",
16
+ component: "SpatialAnatomy",
17
+ skin: "Scientific",
18
+ config: {
19
+ data: {
20
+ model: "heart-cross-section",
21
+ labels: [
22
+ { id: "ra", label: "Right Atrium" },
23
+ { id: "rv", label: "Right Ventricle" },
24
+ { id: "la", label: "Left Atrium" },
25
+ { id: "lv", label: "Left Ventricle" }
26
+ ]
27
+ },
28
+ initialState: {}
29
+ },
30
+ validation: { type: "exact", condition: { target: "lv" } },
31
+ feedback: {
32
+ success: "Correct! The Left Ventricle pumps blood to the entire body.",
33
+ error: "Incorrect. Hint: Look at the thicker muscular wall at the bottom right of the diagram.",
34
+ hint: "Where is the thickest muscle needed?"
35
+ }
36
+ }
37
+ ]
38
+ },
39
+ {
40
+ id: "med-n2",
41
+ title: "Circulation Path (血液循環)",
42
+ description: "Arrange the flow of deoxygenated blood.",
43
+ type: "exercise",
44
+ status: "locked",
45
+ stages: [
46
+ {
47
+ stageId: "med-s2",
48
+ topic: "Pulmonary Circulation",
49
+ module: "Practice",
50
+ component: "LogicChain",
51
+ skin: "Classic",
52
+ config: {
53
+ data: {
54
+ nodes: [
55
+ "Superior Vena Cava",
56
+ "Right Atrium",
57
+ "Right Ventricle",
58
+ "Pulmonary Artery",
59
+ "Lungs"
60
+ ]
61
+ },
62
+ initialState: {}
63
+ },
64
+ validation: { type: "exact", condition: {} },
65
+ feedback: {
66
+ success: "Perfect Sequence! You understand the path to the lungs.",
67
+ error: "Not quite. Remember blood enters the atrium before the ventricle.",
68
+ hint: "Veins -> Atrium -> Ventricle -> Artery"
69
+ }
70
+ }
71
+ ]
72
+ },
73
+ {
74
+ id: "med-n3",
75
+ title: "Blood Pressure (血壓)",
76
+ description: "Understand systolic and diastolic pressure.",
77
+ type: "concept",
78
+ status: "locked",
79
+ stages: [
80
+ {
81
+ stageId: "med-s3",
82
+ topic: "Blood Pressure Basics",
83
+ module: "Instruction",
84
+ component: "TaxonomyMatrix",
85
+ skin: "Scientific",
86
+ config: {
87
+ data: {
88
+ buckets: ["Systolic", "Diastolic"],
89
+ items: [
90
+ { id: "bp1", content: "Heart contracts" },
91
+ { id: "bp2", content: "Heart relaxes" },
92
+ { id: "bp3", content: "Higher number" },
93
+ { id: "bp4", content: "Lower number" }
94
+ ]
95
+ },
96
+ initialState: { assignments: {} }
97
+ },
98
+ validation: { type: "exact", condition: {} },
99
+ feedback: { success: "Great job!", error: "Review the cardiac cycle phases.", hint: "Systolic = contraction" }
100
+ }
101
+ ]
102
+ },
103
+ {
104
+ id: "med-n4",
105
+ title: "Heart Valves (心臟瓣膜)",
106
+ description: "Learn the four valves of the heart.",
107
+ type: "concept",
108
+ status: "locked",
109
+ stages: [
110
+ {
111
+ stageId: "med-s4",
112
+ topic: "Heart Valve Identification",
113
+ module: "Instruction",
114
+ component: "SpatialAnatomy",
115
+ skin: "Scientific",
116
+ config: {
117
+ data: {
118
+ model: "heart-valves",
119
+ labels: [
120
+ { id: "tv", label: "Tricuspid Valve" },
121
+ { id: "mv", label: "Mitral Valve" },
122
+ { id: "pv", label: "Pulmonary Valve" },
123
+ { id: "av", label: "Aortic Valve" }
124
+ ]
125
+ },
126
+ initialState: {}
127
+ },
128
+ validation: { type: "exact", condition: { target: "mv" } },
129
+ feedback: { success: "Correct! The Mitral Valve is between the left atrium and ventricle.", error: "Try again.", hint: "Also called the bicuspid valve." }
130
+ }
131
+ ]
132
+ },
133
+ {
134
+ id: "med-n5",
135
+ title: "Cardiac Output (心輸出量)",
136
+ description: "Calculate and understand cardiac output.",
137
+ type: "exercise",
138
+ status: "locked",
139
+ stages: [
140
+ {
141
+ stageId: "med-s5",
142
+ topic: "Cardiac Output Formula",
143
+ module: "Practice",
144
+ component: "LogicChain",
145
+ skin: "Scientific",
146
+ config: {
147
+ data: { nodes: ["Heart Rate", "×", "Stroke Volume", "=", "Cardiac Output"] },
148
+ initialState: {}
149
+ },
150
+ validation: { type: "exact", condition: {} },
151
+ feedback: { success: "CO = HR × SV!", error: "Review the formula.", hint: "CO = HR × SV" }
152
+ }
153
+ ]
154
+ },
155
+ {
156
+ id: "med-n6",
157
+ title: "ECG Basics (心電圖基礎)",
158
+ description: "Identify basic ECG waveforms.",
159
+ type: "concept",
160
+ status: "locked",
161
+ stages: [
162
+ {
163
+ stageId: "med-s6",
164
+ topic: "ECG Waveforms",
165
+ module: "Instruction",
166
+ component: "LogicChain",
167
+ skin: "Scientific",
168
+ config: {
169
+ data: { nodes: ["P Wave", "QRS Complex", "T Wave", "U Wave"] },
170
+ initialState: {}
171
+ },
172
+ validation: { type: "exact", condition: {} },
173
+ feedback: { success: "Correct ECG sequence!", error: "Review ECG basics.", hint: "P comes before QRS" }
174
+ }
175
+ ]
176
+ },
177
+ {
178
+ id: "med-n7",
179
+ title: "Coronary Arteries (冠狀動脈)",
180
+ description: "Map the coronary artery system.",
181
+ type: "concept",
182
+ status: "locked",
183
+ stages: [
184
+ {
185
+ stageId: "med-s7",
186
+ topic: "Coronary Anatomy",
187
+ module: "Instruction",
188
+ component: "SpatialAnatomy",
189
+ skin: "Scientific",
190
+ config: {
191
+ data: {
192
+ model: "coronary-arteries",
193
+ labels: [
194
+ { id: "lad", label: "Left Anterior Descending" },
195
+ { id: "lcx", label: "Left Circumflex" },
196
+ { id: "rca", label: "Right Coronary Artery" },
197
+ { id: "lm", label: "Left Main" }
198
+ ]
199
+ },
200
+ initialState: {}
201
+ },
202
+ validation: { type: "exact", condition: { target: "lad" } },
203
+ feedback: { success: "The LAD supplies the anterior wall!", error: "Try again.", hint: "The 'widow maker' artery." }
204
+ }
205
+ ]
206
+ },
207
+ {
208
+ id: "med-n8",
209
+ title: "Blood Types (血型)",
210
+ description: "Classify ABO blood groups and compatibility.",
211
+ type: "exercise",
212
+ status: "locked",
213
+ stages: [
214
+ {
215
+ stageId: "med-s8",
216
+ topic: "Blood Type Compatibility",
217
+ module: "Practice",
218
+ component: "TaxonomyMatrix",
219
+ skin: "Classic",
220
+ config: {
221
+ data: {
222
+ buckets: ["Universal Donor", "Universal Recipient"],
223
+ items: [
224
+ { id: "bt1", content: "Type O-" },
225
+ { id: "bt2", content: "Type AB+" },
226
+ { id: "bt3", content: "Type A+" },
227
+ { id: "bt4", content: "Type B-" }
228
+ ]
229
+ },
230
+ initialState: { assignments: {} }
231
+ },
232
+ validation: { type: "exact", condition: {} },
233
+ feedback: { success: "Correct classification!", error: "Review blood type antigens.", hint: "O- has no antigens." }
234
+ }
235
+ ]
236
+ },
237
+ {
238
+ id: "med-n9",
239
+ title: "Hemoglobin (血紅素)",
240
+ description: "Understand oxygen transport by hemoglobin.",
241
+ type: "concept",
242
+ status: "locked",
243
+ stages: [
244
+ {
245
+ stageId: "med-s9",
246
+ topic: "Hemoglobin Function",
247
+ module: "Instruction",
248
+ component: "LogicChain",
249
+ skin: "Scientific",
250
+ config: {
251
+ data: { nodes: ["Lungs", "O₂ binds Hemoglobin", "Arteries", "Tissue Delivery", "CO₂ Return"] },
252
+ initialState: {}
253
+ },
254
+ validation: { type: "exact", condition: {} },
255
+ feedback: { success: "You understand O₂ transport!", error: "Review the oxygen-hemoglobin curve.", hint: "Hemoglobin picks up O₂ in the lungs." }
256
+ }
257
+ ]
258
+ },
259
+ {
260
+ id: "med-n10",
261
+ title: "Heart Failure (心衰竭)",
262
+ description: "Differentiate left vs right heart failure.",
263
+ type: "exercise",
264
+ status: "locked",
265
+ stages: [
266
+ {
267
+ stageId: "med-s10",
268
+ topic: "Heart Failure Types",
269
+ module: "Practice",
270
+ component: "TaxonomyMatrix",
271
+ skin: "Scientific",
272
+ config: {
273
+ data: {
274
+ buckets: ["Left Heart Failure", "Right Heart Failure"],
275
+ items: [
276
+ { id: "hf1", content: "Pulmonary edema" },
277
+ { id: "hf2", content: "Peripheral edema" },
278
+ { id: "hf3", content: "Orthopnea" },
279
+ { id: "hf4", content: "Jugular venous distension" }
280
+ ]
281
+ },
282
+ initialState: { assignments: {} }
283
+ },
284
+ validation: { type: "exact", condition: {} },
285
+ feedback: { success: "Great classification!", error: "Think about which side backs up where.", hint: "Left = lungs, Right = body" }
286
+ }
287
+ ]
288
+ },
289
+ {
290
+ id: "med-n11",
291
+ title: "Arrhythmias (心律不整)",
292
+ description: "Identify common cardiac arrhythmias.",
293
+ type: "concept",
294
+ status: "locked",
295
+ stages: [
296
+ {
297
+ stageId: "med-s11",
298
+ topic: "Common Arrhythmias",
299
+ module: "Instruction",
300
+ component: "LogicChain",
301
+ skin: "Scientific",
302
+ config: {
303
+ data: { nodes: ["Sinus Tachycardia", "Atrial Fibrillation", "Ventricular Tachycardia", "Ventricular Fibrillation"] },
304
+ initialState: {}
305
+ },
306
+ validation: { type: "exact", condition: {} },
307
+ feedback: { success: "Correct severity ordering!", error: "Review arrhythmia severity.", hint: "VF is the most dangerous." }
308
+ }
309
+ ]
310
+ },
311
+ {
312
+ id: "med-n12",
313
+ title: "Vascular System (血管系統)",
314
+ description: "Compare arteries, veins, and capillaries.",
315
+ type: "exercise",
316
+ status: "locked",
317
+ stages: [
318
+ {
319
+ stageId: "med-s12",
320
+ topic: "Vessel Comparison",
321
+ module: "Practice",
322
+ component: "TaxonomyMatrix",
323
+ skin: "Classic",
324
+ config: {
325
+ data: {
326
+ buckets: ["Arteries", "Veins"],
327
+ items: [
328
+ { id: "vs1", content: "Thick muscular walls" },
329
+ { id: "vs2", content: "Contain valves" },
330
+ { id: "vs3", content: "Carry oxygenated blood (mostly)" },
331
+ { id: "vs4", content: "Low pressure system" }
332
+ ]
333
+ },
334
+ initialState: { assignments: {} }
335
+ },
336
+ validation: { type: "exact", condition: {} },
337
+ feedback: { success: "Well done!", error: "Review vessel anatomy.", hint: "Arteries have thicker walls." }
338
+ }
339
+ ]
340
+ },
341
+ {
342
+ id: "med-n13",
343
+ title: "Cardiac Drugs (心臟藥物)",
344
+ description: "Classify common cardiac medications.",
345
+ type: "concept",
346
+ status: "locked",
347
+ stages: [
348
+ {
349
+ stageId: "med-s13",
350
+ topic: "Cardiac Medications",
351
+ module: "Instruction",
352
+ component: "TaxonomyMatrix",
353
+ skin: "Scientific",
354
+ config: {
355
+ data: {
356
+ buckets: ["Beta Blockers", "ACE Inhibitors"],
357
+ items: [
358
+ { id: "cd1", content: "Metoprolol" },
359
+ { id: "cd2", content: "Lisinopril" },
360
+ { id: "cd3", content: "Atenolol" },
361
+ { id: "cd4", content: "Enalapril" }
362
+ ]
363
+ },
364
+ initialState: { assignments: {} }
365
+ },
366
+ validation: { type: "exact", condition: {} },
367
+ feedback: { success: "Correct drug classification!", error: "Review cardiac pharmacology.", hint: "Beta blockers end in -olol." }
368
+ }
369
+ ]
370
+ },
371
+ {
372
+ id: "med-n14",
373
+ title: "Cardiac Cycle (心動週期)",
374
+ description: "Sequence the phases of the cardiac cycle.",
375
+ type: "exercise",
376
+ status: "locked",
377
+ stages: [
378
+ {
379
+ stageId: "med-s14",
380
+ topic: "Cardiac Cycle Phases",
381
+ module: "Practice",
382
+ component: "LogicChain",
383
+ skin: "Scientific",
384
+ config: {
385
+ data: { nodes: ["Atrial Systole", "Isovolumetric Contraction", "Ventricular Ejection", "Isovolumetric Relaxation", "Ventricular Filling"] },
386
+ initialState: {}
387
+ },
388
+ validation: { type: "exact", condition: {} },
389
+ feedback: { success: "Perfect cardiac cycle sequence!", error: "Review the Wiggers diagram.", hint: "Atrial systole comes first." }
390
+ }
391
+ ]
392
+ },
393
+ {
394
+ id: "med-n15",
395
+ title: "Final Challenge (總複習)",
396
+ description: "Comprehensive review of heart & blood flow.",
397
+ type: "challenge",
398
+ status: "locked",
399
+ stages: [
400
+ {
401
+ stageId: "med-s15",
402
+ topic: "Comprehensive Heart Review",
403
+ module: "Assessment",
404
+ component: "LogicChain",
405
+ skin: "Scientific",
406
+ config: {
407
+ data: { nodes: ["Vena Cava", "Right Atrium", "Right Ventricle", "Pulmonary Artery", "Lungs", "Pulmonary Vein", "Left Atrium", "Left Ventricle", "Aorta"] },
408
+ initialState: {}
409
+ },
410
+ validation: { type: "exact", condition: {} },
411
+ feedback: { success: "You've mastered the circulatory system!", error: "Review the complete circulation path.", hint: "Follow the blood from body back to body." }
412
+ }
413
+ ]
414
+ }
415
+ ]
416
+ };
global.d.ts ADDED
@@ -0,0 +1 @@
 
 
1
+ declare module "*.css" {}
next-env.d.ts ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ /// <reference types="next" />
2
+ /// <reference types="next/image-types/global" />
3
+
4
+ // NOTE: This file should not be edited
5
+ // see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
next.config.mjs ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('next').NextConfig} */
2
+ const isHuggingFaceSpace = process.env.HUGGINGFACE_SPACE === "1";
3
+
4
+ const nextConfig = {
5
+ output: "export",
6
+ trailingSlash: true,
7
+ ...(isHuggingFaceSpace ? {} : { basePath: "/UI" }),
8
+ images: { unoptimized: true },
9
+ };
10
+
11
+ export default nextConfig;
package-lock.json ADDED
@@ -0,0 +1,1711 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "lingualearn-ui",
3
+ "version": "0.1.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "lingualearn-ui",
9
+ "version": "0.1.0",
10
+ "dependencies": {
11
+ "framer-motion": "^12.34.3",
12
+ "next": "^14.2.0",
13
+ "react": "^18.2.0",
14
+ "react-dom": "^18.2.0",
15
+ "react-icons": "^5.3.0",
16
+ "zustand": "^4.5.0"
17
+ },
18
+ "devDependencies": {
19
+ "@types/node": "^20.0.0",
20
+ "@types/react": "^18.2.0",
21
+ "@types/react-dom": "^18.2.0",
22
+ "autoprefixer": "^10.4.0",
23
+ "postcss": "^8.4.0",
24
+ "tailwindcss": "^3.4.0",
25
+ "typescript": "^5.0.0"
26
+ }
27
+ },
28
+ "node_modules/@alloc/quick-lru": {
29
+ "version": "5.2.0",
30
+ "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
31
+ "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
32
+ "dev": true,
33
+ "license": "MIT",
34
+ "engines": {
35
+ "node": ">=10"
36
+ },
37
+ "funding": {
38
+ "url": "https://github.com/sponsors/sindresorhus"
39
+ }
40
+ },
41
+ "node_modules/@jridgewell/gen-mapping": {
42
+ "version": "0.3.13",
43
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
44
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
45
+ "dev": true,
46
+ "license": "MIT",
47
+ "dependencies": {
48
+ "@jridgewell/sourcemap-codec": "^1.5.0",
49
+ "@jridgewell/trace-mapping": "^0.3.24"
50
+ }
51
+ },
52
+ "node_modules/@jridgewell/resolve-uri": {
53
+ "version": "3.1.2",
54
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
55
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
56
+ "dev": true,
57
+ "license": "MIT",
58
+ "engines": {
59
+ "node": ">=6.0.0"
60
+ }
61
+ },
62
+ "node_modules/@jridgewell/sourcemap-codec": {
63
+ "version": "1.5.5",
64
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
65
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
66
+ "dev": true,
67
+ "license": "MIT"
68
+ },
69
+ "node_modules/@jridgewell/trace-mapping": {
70
+ "version": "0.3.31",
71
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
72
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
73
+ "dev": true,
74
+ "license": "MIT",
75
+ "dependencies": {
76
+ "@jridgewell/resolve-uri": "^3.1.0",
77
+ "@jridgewell/sourcemap-codec": "^1.4.14"
78
+ }
79
+ },
80
+ "node_modules/@next/env": {
81
+ "version": "14.2.35",
82
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.35.tgz",
83
+ "integrity": "sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==",
84
+ "license": "MIT"
85
+ },
86
+ "node_modules/@next/swc-darwin-arm64": {
87
+ "version": "14.2.33",
88
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.33.tgz",
89
+ "integrity": "sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA==",
90
+ "cpu": [
91
+ "arm64"
92
+ ],
93
+ "license": "MIT",
94
+ "optional": true,
95
+ "os": [
96
+ "darwin"
97
+ ],
98
+ "engines": {
99
+ "node": ">= 10"
100
+ }
101
+ },
102
+ "node_modules/@next/swc-darwin-x64": {
103
+ "version": "14.2.33",
104
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.33.tgz",
105
+ "integrity": "sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA==",
106
+ "cpu": [
107
+ "x64"
108
+ ],
109
+ "license": "MIT",
110
+ "optional": true,
111
+ "os": [
112
+ "darwin"
113
+ ],
114
+ "engines": {
115
+ "node": ">= 10"
116
+ }
117
+ },
118
+ "node_modules/@next/swc-linux-arm64-gnu": {
119
+ "version": "14.2.33",
120
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.33.tgz",
121
+ "integrity": "sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw==",
122
+ "cpu": [
123
+ "arm64"
124
+ ],
125
+ "license": "MIT",
126
+ "optional": true,
127
+ "os": [
128
+ "linux"
129
+ ],
130
+ "engines": {
131
+ "node": ">= 10"
132
+ }
133
+ },
134
+ "node_modules/@next/swc-linux-arm64-musl": {
135
+ "version": "14.2.33",
136
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.33.tgz",
137
+ "integrity": "sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==",
138
+ "cpu": [
139
+ "arm64"
140
+ ],
141
+ "license": "MIT",
142
+ "optional": true,
143
+ "os": [
144
+ "linux"
145
+ ],
146
+ "engines": {
147
+ "node": ">= 10"
148
+ }
149
+ },
150
+ "node_modules/@next/swc-linux-x64-gnu": {
151
+ "version": "14.2.33",
152
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.33.tgz",
153
+ "integrity": "sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==",
154
+ "cpu": [
155
+ "x64"
156
+ ],
157
+ "license": "MIT",
158
+ "optional": true,
159
+ "os": [
160
+ "linux"
161
+ ],
162
+ "engines": {
163
+ "node": ">= 10"
164
+ }
165
+ },
166
+ "node_modules/@next/swc-linux-x64-musl": {
167
+ "version": "14.2.33",
168
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.33.tgz",
169
+ "integrity": "sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==",
170
+ "cpu": [
171
+ "x64"
172
+ ],
173
+ "license": "MIT",
174
+ "optional": true,
175
+ "os": [
176
+ "linux"
177
+ ],
178
+ "engines": {
179
+ "node": ">= 10"
180
+ }
181
+ },
182
+ "node_modules/@next/swc-win32-arm64-msvc": {
183
+ "version": "14.2.33",
184
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.33.tgz",
185
+ "integrity": "sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==",
186
+ "cpu": [
187
+ "arm64"
188
+ ],
189
+ "license": "MIT",
190
+ "optional": true,
191
+ "os": [
192
+ "win32"
193
+ ],
194
+ "engines": {
195
+ "node": ">= 10"
196
+ }
197
+ },
198
+ "node_modules/@next/swc-win32-ia32-msvc": {
199
+ "version": "14.2.33",
200
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.33.tgz",
201
+ "integrity": "sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==",
202
+ "cpu": [
203
+ "ia32"
204
+ ],
205
+ "license": "MIT",
206
+ "optional": true,
207
+ "os": [
208
+ "win32"
209
+ ],
210
+ "engines": {
211
+ "node": ">= 10"
212
+ }
213
+ },
214
+ "node_modules/@next/swc-win32-x64-msvc": {
215
+ "version": "14.2.33",
216
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.33.tgz",
217
+ "integrity": "sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg==",
218
+ "cpu": [
219
+ "x64"
220
+ ],
221
+ "license": "MIT",
222
+ "optional": true,
223
+ "os": [
224
+ "win32"
225
+ ],
226
+ "engines": {
227
+ "node": ">= 10"
228
+ }
229
+ },
230
+ "node_modules/@nodelib/fs.scandir": {
231
+ "version": "2.1.5",
232
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
233
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
234
+ "dev": true,
235
+ "license": "MIT",
236
+ "dependencies": {
237
+ "@nodelib/fs.stat": "2.0.5",
238
+ "run-parallel": "^1.1.9"
239
+ },
240
+ "engines": {
241
+ "node": ">= 8"
242
+ }
243
+ },
244
+ "node_modules/@nodelib/fs.stat": {
245
+ "version": "2.0.5",
246
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
247
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
248
+ "dev": true,
249
+ "license": "MIT",
250
+ "engines": {
251
+ "node": ">= 8"
252
+ }
253
+ },
254
+ "node_modules/@nodelib/fs.walk": {
255
+ "version": "1.2.8",
256
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
257
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
258
+ "dev": true,
259
+ "license": "MIT",
260
+ "dependencies": {
261
+ "@nodelib/fs.scandir": "2.1.5",
262
+ "fastq": "^1.6.0"
263
+ },
264
+ "engines": {
265
+ "node": ">= 8"
266
+ }
267
+ },
268
+ "node_modules/@swc/counter": {
269
+ "version": "0.1.3",
270
+ "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
271
+ "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
272
+ "license": "Apache-2.0"
273
+ },
274
+ "node_modules/@swc/helpers": {
275
+ "version": "0.5.5",
276
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz",
277
+ "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==",
278
+ "license": "Apache-2.0",
279
+ "dependencies": {
280
+ "@swc/counter": "^0.1.3",
281
+ "tslib": "^2.4.0"
282
+ }
283
+ },
284
+ "node_modules/@types/node": {
285
+ "version": "20.19.34",
286
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.34.tgz",
287
+ "integrity": "sha512-by3/Z0Qp+L9cAySEsSNNwZ6WWw8ywgGLPQGgbQDhNRSitqYgkgp4pErd23ZSCavbtUA2CN4jQtoB3T8nk4j3Rg==",
288
+ "dev": true,
289
+ "license": "MIT",
290
+ "dependencies": {
291
+ "undici-types": "~6.21.0"
292
+ }
293
+ },
294
+ "node_modules/@types/prop-types": {
295
+ "version": "15.7.15",
296
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
297
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
298
+ "devOptional": true,
299
+ "license": "MIT"
300
+ },
301
+ "node_modules/@types/react": {
302
+ "version": "18.3.28",
303
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
304
+ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
305
+ "devOptional": true,
306
+ "license": "MIT",
307
+ "dependencies": {
308
+ "@types/prop-types": "*",
309
+ "csstype": "^3.2.2"
310
+ }
311
+ },
312
+ "node_modules/@types/react-dom": {
313
+ "version": "18.3.7",
314
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
315
+ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
316
+ "dev": true,
317
+ "license": "MIT",
318
+ "peerDependencies": {
319
+ "@types/react": "^18.0.0"
320
+ }
321
+ },
322
+ "node_modules/any-promise": {
323
+ "version": "1.3.0",
324
+ "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
325
+ "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
326
+ "dev": true,
327
+ "license": "MIT"
328
+ },
329
+ "node_modules/anymatch": {
330
+ "version": "3.1.3",
331
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
332
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
333
+ "dev": true,
334
+ "license": "ISC",
335
+ "dependencies": {
336
+ "normalize-path": "^3.0.0",
337
+ "picomatch": "^2.0.4"
338
+ },
339
+ "engines": {
340
+ "node": ">= 8"
341
+ }
342
+ },
343
+ "node_modules/arg": {
344
+ "version": "5.0.2",
345
+ "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
346
+ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
347
+ "dev": true,
348
+ "license": "MIT"
349
+ },
350
+ "node_modules/autoprefixer": {
351
+ "version": "10.4.27",
352
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
353
+ "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==",
354
+ "dev": true,
355
+ "funding": [
356
+ {
357
+ "type": "opencollective",
358
+ "url": "https://opencollective.com/postcss/"
359
+ },
360
+ {
361
+ "type": "tidelift",
362
+ "url": "https://tidelift.com/funding/github/npm/autoprefixer"
363
+ },
364
+ {
365
+ "type": "github",
366
+ "url": "https://github.com/sponsors/ai"
367
+ }
368
+ ],
369
+ "license": "MIT",
370
+ "dependencies": {
371
+ "browserslist": "^4.28.1",
372
+ "caniuse-lite": "^1.0.30001774",
373
+ "fraction.js": "^5.3.4",
374
+ "picocolors": "^1.1.1",
375
+ "postcss-value-parser": "^4.2.0"
376
+ },
377
+ "bin": {
378
+ "autoprefixer": "bin/autoprefixer"
379
+ },
380
+ "engines": {
381
+ "node": "^10 || ^12 || >=14"
382
+ },
383
+ "peerDependencies": {
384
+ "postcss": "^8.1.0"
385
+ }
386
+ },
387
+ "node_modules/baseline-browser-mapping": {
388
+ "version": "2.10.0",
389
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
390
+ "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
391
+ "dev": true,
392
+ "license": "Apache-2.0",
393
+ "bin": {
394
+ "baseline-browser-mapping": "dist/cli.cjs"
395
+ },
396
+ "engines": {
397
+ "node": ">=6.0.0"
398
+ }
399
+ },
400
+ "node_modules/binary-extensions": {
401
+ "version": "2.3.0",
402
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
403
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
404
+ "dev": true,
405
+ "license": "MIT",
406
+ "engines": {
407
+ "node": ">=8"
408
+ },
409
+ "funding": {
410
+ "url": "https://github.com/sponsors/sindresorhus"
411
+ }
412
+ },
413
+ "node_modules/braces": {
414
+ "version": "3.0.3",
415
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
416
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
417
+ "dev": true,
418
+ "license": "MIT",
419
+ "dependencies": {
420
+ "fill-range": "^7.1.1"
421
+ },
422
+ "engines": {
423
+ "node": ">=8"
424
+ }
425
+ },
426
+ "node_modules/browserslist": {
427
+ "version": "4.28.1",
428
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
429
+ "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
430
+ "dev": true,
431
+ "funding": [
432
+ {
433
+ "type": "opencollective",
434
+ "url": "https://opencollective.com/browserslist"
435
+ },
436
+ {
437
+ "type": "tidelift",
438
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
439
+ },
440
+ {
441
+ "type": "github",
442
+ "url": "https://github.com/sponsors/ai"
443
+ }
444
+ ],
445
+ "license": "MIT",
446
+ "dependencies": {
447
+ "baseline-browser-mapping": "^2.9.0",
448
+ "caniuse-lite": "^1.0.30001759",
449
+ "electron-to-chromium": "^1.5.263",
450
+ "node-releases": "^2.0.27",
451
+ "update-browserslist-db": "^1.2.0"
452
+ },
453
+ "bin": {
454
+ "browserslist": "cli.js"
455
+ },
456
+ "engines": {
457
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
458
+ }
459
+ },
460
+ "node_modules/busboy": {
461
+ "version": "1.6.0",
462
+ "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
463
+ "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
464
+ "dependencies": {
465
+ "streamsearch": "^1.1.0"
466
+ },
467
+ "engines": {
468
+ "node": ">=10.16.0"
469
+ }
470
+ },
471
+ "node_modules/camelcase-css": {
472
+ "version": "2.0.1",
473
+ "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
474
+ "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
475
+ "dev": true,
476
+ "license": "MIT",
477
+ "engines": {
478
+ "node": ">= 6"
479
+ }
480
+ },
481
+ "node_modules/caniuse-lite": {
482
+ "version": "1.0.30001774",
483
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz",
484
+ "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==",
485
+ "funding": [
486
+ {
487
+ "type": "opencollective",
488
+ "url": "https://opencollective.com/browserslist"
489
+ },
490
+ {
491
+ "type": "tidelift",
492
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
493
+ },
494
+ {
495
+ "type": "github",
496
+ "url": "https://github.com/sponsors/ai"
497
+ }
498
+ ],
499
+ "license": "CC-BY-4.0"
500
+ },
501
+ "node_modules/chokidar": {
502
+ "version": "3.6.0",
503
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
504
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
505
+ "dev": true,
506
+ "license": "MIT",
507
+ "dependencies": {
508
+ "anymatch": "~3.1.2",
509
+ "braces": "~3.0.2",
510
+ "glob-parent": "~5.1.2",
511
+ "is-binary-path": "~2.1.0",
512
+ "is-glob": "~4.0.1",
513
+ "normalize-path": "~3.0.0",
514
+ "readdirp": "~3.6.0"
515
+ },
516
+ "engines": {
517
+ "node": ">= 8.10.0"
518
+ },
519
+ "funding": {
520
+ "url": "https://paulmillr.com/funding/"
521
+ },
522
+ "optionalDependencies": {
523
+ "fsevents": "~2.3.2"
524
+ }
525
+ },
526
+ "node_modules/chokidar/node_modules/glob-parent": {
527
+ "version": "5.1.2",
528
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
529
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
530
+ "dev": true,
531
+ "license": "ISC",
532
+ "dependencies": {
533
+ "is-glob": "^4.0.1"
534
+ },
535
+ "engines": {
536
+ "node": ">= 6"
537
+ }
538
+ },
539
+ "node_modules/client-only": {
540
+ "version": "0.0.1",
541
+ "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
542
+ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
543
+ "license": "MIT"
544
+ },
545
+ "node_modules/commander": {
546
+ "version": "4.1.1",
547
+ "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
548
+ "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
549
+ "dev": true,
550
+ "license": "MIT",
551
+ "engines": {
552
+ "node": ">= 6"
553
+ }
554
+ },
555
+ "node_modules/cssesc": {
556
+ "version": "3.0.0",
557
+ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
558
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
559
+ "dev": true,
560
+ "license": "MIT",
561
+ "bin": {
562
+ "cssesc": "bin/cssesc"
563
+ },
564
+ "engines": {
565
+ "node": ">=4"
566
+ }
567
+ },
568
+ "node_modules/csstype": {
569
+ "version": "3.2.3",
570
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
571
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
572
+ "devOptional": true,
573
+ "license": "MIT"
574
+ },
575
+ "node_modules/didyoumean": {
576
+ "version": "1.2.2",
577
+ "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
578
+ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
579
+ "dev": true,
580
+ "license": "Apache-2.0"
581
+ },
582
+ "node_modules/dlv": {
583
+ "version": "1.1.3",
584
+ "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
585
+ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
586
+ "dev": true,
587
+ "license": "MIT"
588
+ },
589
+ "node_modules/electron-to-chromium": {
590
+ "version": "1.5.302",
591
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz",
592
+ "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==",
593
+ "dev": true,
594
+ "license": "ISC"
595
+ },
596
+ "node_modules/escalade": {
597
+ "version": "3.2.0",
598
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
599
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
600
+ "dev": true,
601
+ "license": "MIT",
602
+ "engines": {
603
+ "node": ">=6"
604
+ }
605
+ },
606
+ "node_modules/fast-glob": {
607
+ "version": "3.3.3",
608
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
609
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
610
+ "dev": true,
611
+ "license": "MIT",
612
+ "dependencies": {
613
+ "@nodelib/fs.stat": "^2.0.2",
614
+ "@nodelib/fs.walk": "^1.2.3",
615
+ "glob-parent": "^5.1.2",
616
+ "merge2": "^1.3.0",
617
+ "micromatch": "^4.0.8"
618
+ },
619
+ "engines": {
620
+ "node": ">=8.6.0"
621
+ }
622
+ },
623
+ "node_modules/fast-glob/node_modules/glob-parent": {
624
+ "version": "5.1.2",
625
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
626
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
627
+ "dev": true,
628
+ "license": "ISC",
629
+ "dependencies": {
630
+ "is-glob": "^4.0.1"
631
+ },
632
+ "engines": {
633
+ "node": ">= 6"
634
+ }
635
+ },
636
+ "node_modules/fastq": {
637
+ "version": "1.20.1",
638
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
639
+ "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
640
+ "dev": true,
641
+ "license": "ISC",
642
+ "dependencies": {
643
+ "reusify": "^1.0.4"
644
+ }
645
+ },
646
+ "node_modules/fill-range": {
647
+ "version": "7.1.1",
648
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
649
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
650
+ "dev": true,
651
+ "license": "MIT",
652
+ "dependencies": {
653
+ "to-regex-range": "^5.0.1"
654
+ },
655
+ "engines": {
656
+ "node": ">=8"
657
+ }
658
+ },
659
+ "node_modules/fraction.js": {
660
+ "version": "5.3.4",
661
+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
662
+ "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
663
+ "dev": true,
664
+ "license": "MIT",
665
+ "engines": {
666
+ "node": "*"
667
+ },
668
+ "funding": {
669
+ "type": "github",
670
+ "url": "https://github.com/sponsors/rawify"
671
+ }
672
+ },
673
+ "node_modules/framer-motion": {
674
+ "version": "12.34.3",
675
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.34.3.tgz",
676
+ "integrity": "sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q==",
677
+ "license": "MIT",
678
+ "dependencies": {
679
+ "motion-dom": "^12.34.3",
680
+ "motion-utils": "^12.29.2",
681
+ "tslib": "^2.4.0"
682
+ },
683
+ "peerDependencies": {
684
+ "@emotion/is-prop-valid": "*",
685
+ "react": "^18.0.0 || ^19.0.0",
686
+ "react-dom": "^18.0.0 || ^19.0.0"
687
+ },
688
+ "peerDependenciesMeta": {
689
+ "@emotion/is-prop-valid": {
690
+ "optional": true
691
+ },
692
+ "react": {
693
+ "optional": true
694
+ },
695
+ "react-dom": {
696
+ "optional": true
697
+ }
698
+ }
699
+ },
700
+ "node_modules/fsevents": {
701
+ "version": "2.3.3",
702
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
703
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
704
+ "dev": true,
705
+ "hasInstallScript": true,
706
+ "license": "MIT",
707
+ "optional": true,
708
+ "os": [
709
+ "darwin"
710
+ ],
711
+ "engines": {
712
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
713
+ }
714
+ },
715
+ "node_modules/function-bind": {
716
+ "version": "1.1.2",
717
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
718
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
719
+ "dev": true,
720
+ "license": "MIT",
721
+ "funding": {
722
+ "url": "https://github.com/sponsors/ljharb"
723
+ }
724
+ },
725
+ "node_modules/glob-parent": {
726
+ "version": "6.0.2",
727
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
728
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
729
+ "dev": true,
730
+ "license": "ISC",
731
+ "dependencies": {
732
+ "is-glob": "^4.0.3"
733
+ },
734
+ "engines": {
735
+ "node": ">=10.13.0"
736
+ }
737
+ },
738
+ "node_modules/graceful-fs": {
739
+ "version": "4.2.11",
740
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
741
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
742
+ "license": "ISC"
743
+ },
744
+ "node_modules/hasown": {
745
+ "version": "2.0.2",
746
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
747
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
748
+ "dev": true,
749
+ "license": "MIT",
750
+ "dependencies": {
751
+ "function-bind": "^1.1.2"
752
+ },
753
+ "engines": {
754
+ "node": ">= 0.4"
755
+ }
756
+ },
757
+ "node_modules/is-binary-path": {
758
+ "version": "2.1.0",
759
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
760
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
761
+ "dev": true,
762
+ "license": "MIT",
763
+ "dependencies": {
764
+ "binary-extensions": "^2.0.0"
765
+ },
766
+ "engines": {
767
+ "node": ">=8"
768
+ }
769
+ },
770
+ "node_modules/is-core-module": {
771
+ "version": "2.16.1",
772
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
773
+ "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
774
+ "dev": true,
775
+ "license": "MIT",
776
+ "dependencies": {
777
+ "hasown": "^2.0.2"
778
+ },
779
+ "engines": {
780
+ "node": ">= 0.4"
781
+ },
782
+ "funding": {
783
+ "url": "https://github.com/sponsors/ljharb"
784
+ }
785
+ },
786
+ "node_modules/is-extglob": {
787
+ "version": "2.1.1",
788
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
789
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
790
+ "dev": true,
791
+ "license": "MIT",
792
+ "engines": {
793
+ "node": ">=0.10.0"
794
+ }
795
+ },
796
+ "node_modules/is-glob": {
797
+ "version": "4.0.3",
798
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
799
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
800
+ "dev": true,
801
+ "license": "MIT",
802
+ "dependencies": {
803
+ "is-extglob": "^2.1.1"
804
+ },
805
+ "engines": {
806
+ "node": ">=0.10.0"
807
+ }
808
+ },
809
+ "node_modules/is-number": {
810
+ "version": "7.0.0",
811
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
812
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
813
+ "dev": true,
814
+ "license": "MIT",
815
+ "engines": {
816
+ "node": ">=0.12.0"
817
+ }
818
+ },
819
+ "node_modules/jiti": {
820
+ "version": "1.21.7",
821
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
822
+ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
823
+ "dev": true,
824
+ "license": "MIT",
825
+ "bin": {
826
+ "jiti": "bin/jiti.js"
827
+ }
828
+ },
829
+ "node_modules/js-tokens": {
830
+ "version": "4.0.0",
831
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
832
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
833
+ "license": "MIT"
834
+ },
835
+ "node_modules/lilconfig": {
836
+ "version": "3.1.3",
837
+ "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
838
+ "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
839
+ "dev": true,
840
+ "license": "MIT",
841
+ "engines": {
842
+ "node": ">=14"
843
+ },
844
+ "funding": {
845
+ "url": "https://github.com/sponsors/antonk52"
846
+ }
847
+ },
848
+ "node_modules/lines-and-columns": {
849
+ "version": "1.2.4",
850
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
851
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
852
+ "dev": true,
853
+ "license": "MIT"
854
+ },
855
+ "node_modules/loose-envify": {
856
+ "version": "1.4.0",
857
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
858
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
859
+ "license": "MIT",
860
+ "dependencies": {
861
+ "js-tokens": "^3.0.0 || ^4.0.0"
862
+ },
863
+ "bin": {
864
+ "loose-envify": "cli.js"
865
+ }
866
+ },
867
+ "node_modules/merge2": {
868
+ "version": "1.4.1",
869
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
870
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
871
+ "dev": true,
872
+ "license": "MIT",
873
+ "engines": {
874
+ "node": ">= 8"
875
+ }
876
+ },
877
+ "node_modules/micromatch": {
878
+ "version": "4.0.8",
879
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
880
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
881
+ "dev": true,
882
+ "license": "MIT",
883
+ "dependencies": {
884
+ "braces": "^3.0.3",
885
+ "picomatch": "^2.3.1"
886
+ },
887
+ "engines": {
888
+ "node": ">=8.6"
889
+ }
890
+ },
891
+ "node_modules/motion-dom": {
892
+ "version": "12.34.3",
893
+ "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.3.tgz",
894
+ "integrity": "sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ==",
895
+ "license": "MIT",
896
+ "dependencies": {
897
+ "motion-utils": "^12.29.2"
898
+ }
899
+ },
900
+ "node_modules/motion-utils": {
901
+ "version": "12.29.2",
902
+ "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.29.2.tgz",
903
+ "integrity": "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==",
904
+ "license": "MIT"
905
+ },
906
+ "node_modules/mz": {
907
+ "version": "2.7.0",
908
+ "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
909
+ "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
910
+ "dev": true,
911
+ "license": "MIT",
912
+ "dependencies": {
913
+ "any-promise": "^1.0.0",
914
+ "object-assign": "^4.0.1",
915
+ "thenify-all": "^1.0.0"
916
+ }
917
+ },
918
+ "node_modules/nanoid": {
919
+ "version": "3.3.11",
920
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
921
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
922
+ "funding": [
923
+ {
924
+ "type": "github",
925
+ "url": "https://github.com/sponsors/ai"
926
+ }
927
+ ],
928
+ "license": "MIT",
929
+ "bin": {
930
+ "nanoid": "bin/nanoid.cjs"
931
+ },
932
+ "engines": {
933
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
934
+ }
935
+ },
936
+ "node_modules/next": {
937
+ "version": "14.2.35",
938
+ "resolved": "https://registry.npmjs.org/next/-/next-14.2.35.tgz",
939
+ "integrity": "sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==",
940
+ "license": "MIT",
941
+ "dependencies": {
942
+ "@next/env": "14.2.35",
943
+ "@swc/helpers": "0.5.5",
944
+ "busboy": "1.6.0",
945
+ "caniuse-lite": "^1.0.30001579",
946
+ "graceful-fs": "^4.2.11",
947
+ "postcss": "8.4.31",
948
+ "styled-jsx": "5.1.1"
949
+ },
950
+ "bin": {
951
+ "next": "dist/bin/next"
952
+ },
953
+ "engines": {
954
+ "node": ">=18.17.0"
955
+ },
956
+ "optionalDependencies": {
957
+ "@next/swc-darwin-arm64": "14.2.33",
958
+ "@next/swc-darwin-x64": "14.2.33",
959
+ "@next/swc-linux-arm64-gnu": "14.2.33",
960
+ "@next/swc-linux-arm64-musl": "14.2.33",
961
+ "@next/swc-linux-x64-gnu": "14.2.33",
962
+ "@next/swc-linux-x64-musl": "14.2.33",
963
+ "@next/swc-win32-arm64-msvc": "14.2.33",
964
+ "@next/swc-win32-ia32-msvc": "14.2.33",
965
+ "@next/swc-win32-x64-msvc": "14.2.33"
966
+ },
967
+ "peerDependencies": {
968
+ "@opentelemetry/api": "^1.1.0",
969
+ "@playwright/test": "^1.41.2",
970
+ "react": "^18.2.0",
971
+ "react-dom": "^18.2.0",
972
+ "sass": "^1.3.0"
973
+ },
974
+ "peerDependenciesMeta": {
975
+ "@opentelemetry/api": {
976
+ "optional": true
977
+ },
978
+ "@playwright/test": {
979
+ "optional": true
980
+ },
981
+ "sass": {
982
+ "optional": true
983
+ }
984
+ }
985
+ },
986
+ "node_modules/next/node_modules/postcss": {
987
+ "version": "8.4.31",
988
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
989
+ "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
990
+ "funding": [
991
+ {
992
+ "type": "opencollective",
993
+ "url": "https://opencollective.com/postcss/"
994
+ },
995
+ {
996
+ "type": "tidelift",
997
+ "url": "https://tidelift.com/funding/github/npm/postcss"
998
+ },
999
+ {
1000
+ "type": "github",
1001
+ "url": "https://github.com/sponsors/ai"
1002
+ }
1003
+ ],
1004
+ "license": "MIT",
1005
+ "dependencies": {
1006
+ "nanoid": "^3.3.6",
1007
+ "picocolors": "^1.0.0",
1008
+ "source-map-js": "^1.0.2"
1009
+ },
1010
+ "engines": {
1011
+ "node": "^10 || ^12 || >=14"
1012
+ }
1013
+ },
1014
+ "node_modules/node-releases": {
1015
+ "version": "2.0.27",
1016
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
1017
+ "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
1018
+ "dev": true,
1019
+ "license": "MIT"
1020
+ },
1021
+ "node_modules/normalize-path": {
1022
+ "version": "3.0.0",
1023
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
1024
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
1025
+ "dev": true,
1026
+ "license": "MIT",
1027
+ "engines": {
1028
+ "node": ">=0.10.0"
1029
+ }
1030
+ },
1031
+ "node_modules/object-assign": {
1032
+ "version": "4.1.1",
1033
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
1034
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
1035
+ "dev": true,
1036
+ "license": "MIT",
1037
+ "engines": {
1038
+ "node": ">=0.10.0"
1039
+ }
1040
+ },
1041
+ "node_modules/object-hash": {
1042
+ "version": "3.0.0",
1043
+ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
1044
+ "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
1045
+ "dev": true,
1046
+ "license": "MIT",
1047
+ "engines": {
1048
+ "node": ">= 6"
1049
+ }
1050
+ },
1051
+ "node_modules/path-parse": {
1052
+ "version": "1.0.7",
1053
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
1054
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
1055
+ "dev": true,
1056
+ "license": "MIT"
1057
+ },
1058
+ "node_modules/picocolors": {
1059
+ "version": "1.1.1",
1060
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
1061
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
1062
+ "license": "ISC"
1063
+ },
1064
+ "node_modules/picomatch": {
1065
+ "version": "2.3.1",
1066
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
1067
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
1068
+ "dev": true,
1069
+ "license": "MIT",
1070
+ "engines": {
1071
+ "node": ">=8.6"
1072
+ },
1073
+ "funding": {
1074
+ "url": "https://github.com/sponsors/jonschlinkert"
1075
+ }
1076
+ },
1077
+ "node_modules/pify": {
1078
+ "version": "2.3.0",
1079
+ "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
1080
+ "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
1081
+ "dev": true,
1082
+ "license": "MIT",
1083
+ "engines": {
1084
+ "node": ">=0.10.0"
1085
+ }
1086
+ },
1087
+ "node_modules/pirates": {
1088
+ "version": "4.0.7",
1089
+ "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
1090
+ "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
1091
+ "dev": true,
1092
+ "license": "MIT",
1093
+ "engines": {
1094
+ "node": ">= 6"
1095
+ }
1096
+ },
1097
+ "node_modules/postcss": {
1098
+ "version": "8.5.6",
1099
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
1100
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
1101
+ "dev": true,
1102
+ "funding": [
1103
+ {
1104
+ "type": "opencollective",
1105
+ "url": "https://opencollective.com/postcss/"
1106
+ },
1107
+ {
1108
+ "type": "tidelift",
1109
+ "url": "https://tidelift.com/funding/github/npm/postcss"
1110
+ },
1111
+ {
1112
+ "type": "github",
1113
+ "url": "https://github.com/sponsors/ai"
1114
+ }
1115
+ ],
1116
+ "license": "MIT",
1117
+ "dependencies": {
1118
+ "nanoid": "^3.3.11",
1119
+ "picocolors": "^1.1.1",
1120
+ "source-map-js": "^1.2.1"
1121
+ },
1122
+ "engines": {
1123
+ "node": "^10 || ^12 || >=14"
1124
+ }
1125
+ },
1126
+ "node_modules/postcss-import": {
1127
+ "version": "15.1.0",
1128
+ "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
1129
+ "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
1130
+ "dev": true,
1131
+ "license": "MIT",
1132
+ "dependencies": {
1133
+ "postcss-value-parser": "^4.0.0",
1134
+ "read-cache": "^1.0.0",
1135
+ "resolve": "^1.1.7"
1136
+ },
1137
+ "engines": {
1138
+ "node": ">=14.0.0"
1139
+ },
1140
+ "peerDependencies": {
1141
+ "postcss": "^8.0.0"
1142
+ }
1143
+ },
1144
+ "node_modules/postcss-js": {
1145
+ "version": "4.1.0",
1146
+ "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
1147
+ "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
1148
+ "dev": true,
1149
+ "funding": [
1150
+ {
1151
+ "type": "opencollective",
1152
+ "url": "https://opencollective.com/postcss/"
1153
+ },
1154
+ {
1155
+ "type": "github",
1156
+ "url": "https://github.com/sponsors/ai"
1157
+ }
1158
+ ],
1159
+ "license": "MIT",
1160
+ "dependencies": {
1161
+ "camelcase-css": "^2.0.1"
1162
+ },
1163
+ "engines": {
1164
+ "node": "^12 || ^14 || >= 16"
1165
+ },
1166
+ "peerDependencies": {
1167
+ "postcss": "^8.4.21"
1168
+ }
1169
+ },
1170
+ "node_modules/postcss-load-config": {
1171
+ "version": "6.0.1",
1172
+ "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
1173
+ "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
1174
+ "dev": true,
1175
+ "funding": [
1176
+ {
1177
+ "type": "opencollective",
1178
+ "url": "https://opencollective.com/postcss/"
1179
+ },
1180
+ {
1181
+ "type": "github",
1182
+ "url": "https://github.com/sponsors/ai"
1183
+ }
1184
+ ],
1185
+ "license": "MIT",
1186
+ "dependencies": {
1187
+ "lilconfig": "^3.1.1"
1188
+ },
1189
+ "engines": {
1190
+ "node": ">= 18"
1191
+ },
1192
+ "peerDependencies": {
1193
+ "jiti": ">=1.21.0",
1194
+ "postcss": ">=8.0.9",
1195
+ "tsx": "^4.8.1",
1196
+ "yaml": "^2.4.2"
1197
+ },
1198
+ "peerDependenciesMeta": {
1199
+ "jiti": {
1200
+ "optional": true
1201
+ },
1202
+ "postcss": {
1203
+ "optional": true
1204
+ },
1205
+ "tsx": {
1206
+ "optional": true
1207
+ },
1208
+ "yaml": {
1209
+ "optional": true
1210
+ }
1211
+ }
1212
+ },
1213
+ "node_modules/postcss-nested": {
1214
+ "version": "6.2.0",
1215
+ "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
1216
+ "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
1217
+ "dev": true,
1218
+ "funding": [
1219
+ {
1220
+ "type": "opencollective",
1221
+ "url": "https://opencollective.com/postcss/"
1222
+ },
1223
+ {
1224
+ "type": "github",
1225
+ "url": "https://github.com/sponsors/ai"
1226
+ }
1227
+ ],
1228
+ "license": "MIT",
1229
+ "dependencies": {
1230
+ "postcss-selector-parser": "^6.1.1"
1231
+ },
1232
+ "engines": {
1233
+ "node": ">=12.0"
1234
+ },
1235
+ "peerDependencies": {
1236
+ "postcss": "^8.2.14"
1237
+ }
1238
+ },
1239
+ "node_modules/postcss-selector-parser": {
1240
+ "version": "6.1.2",
1241
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
1242
+ "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
1243
+ "dev": true,
1244
+ "license": "MIT",
1245
+ "dependencies": {
1246
+ "cssesc": "^3.0.0",
1247
+ "util-deprecate": "^1.0.2"
1248
+ },
1249
+ "engines": {
1250
+ "node": ">=4"
1251
+ }
1252
+ },
1253
+ "node_modules/postcss-value-parser": {
1254
+ "version": "4.2.0",
1255
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
1256
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
1257
+ "dev": true,
1258
+ "license": "MIT"
1259
+ },
1260
+ "node_modules/queue-microtask": {
1261
+ "version": "1.2.3",
1262
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
1263
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
1264
+ "dev": true,
1265
+ "funding": [
1266
+ {
1267
+ "type": "github",
1268
+ "url": "https://github.com/sponsors/feross"
1269
+ },
1270
+ {
1271
+ "type": "patreon",
1272
+ "url": "https://www.patreon.com/feross"
1273
+ },
1274
+ {
1275
+ "type": "consulting",
1276
+ "url": "https://feross.org/support"
1277
+ }
1278
+ ],
1279
+ "license": "MIT"
1280
+ },
1281
+ "node_modules/react": {
1282
+ "version": "18.3.1",
1283
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
1284
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
1285
+ "license": "MIT",
1286
+ "dependencies": {
1287
+ "loose-envify": "^1.1.0"
1288
+ },
1289
+ "engines": {
1290
+ "node": ">=0.10.0"
1291
+ }
1292
+ },
1293
+ "node_modules/react-dom": {
1294
+ "version": "18.3.1",
1295
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
1296
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
1297
+ "license": "MIT",
1298
+ "dependencies": {
1299
+ "loose-envify": "^1.1.0",
1300
+ "scheduler": "^0.23.2"
1301
+ },
1302
+ "peerDependencies": {
1303
+ "react": "^18.3.1"
1304
+ }
1305
+ },
1306
+ "node_modules/react-icons": {
1307
+ "version": "5.5.0",
1308
+ "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
1309
+ "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
1310
+ "license": "MIT",
1311
+ "peerDependencies": {
1312
+ "react": "*"
1313
+ }
1314
+ },
1315
+ "node_modules/read-cache": {
1316
+ "version": "1.0.0",
1317
+ "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
1318
+ "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
1319
+ "dev": true,
1320
+ "license": "MIT",
1321
+ "dependencies": {
1322
+ "pify": "^2.3.0"
1323
+ }
1324
+ },
1325
+ "node_modules/readdirp": {
1326
+ "version": "3.6.0",
1327
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
1328
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
1329
+ "dev": true,
1330
+ "license": "MIT",
1331
+ "dependencies": {
1332
+ "picomatch": "^2.2.1"
1333
+ },
1334
+ "engines": {
1335
+ "node": ">=8.10.0"
1336
+ }
1337
+ },
1338
+ "node_modules/resolve": {
1339
+ "version": "1.22.11",
1340
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
1341
+ "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
1342
+ "dev": true,
1343
+ "license": "MIT",
1344
+ "dependencies": {
1345
+ "is-core-module": "^2.16.1",
1346
+ "path-parse": "^1.0.7",
1347
+ "supports-preserve-symlinks-flag": "^1.0.0"
1348
+ },
1349
+ "bin": {
1350
+ "resolve": "bin/resolve"
1351
+ },
1352
+ "engines": {
1353
+ "node": ">= 0.4"
1354
+ },
1355
+ "funding": {
1356
+ "url": "https://github.com/sponsors/ljharb"
1357
+ }
1358
+ },
1359
+ "node_modules/reusify": {
1360
+ "version": "1.1.0",
1361
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
1362
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
1363
+ "dev": true,
1364
+ "license": "MIT",
1365
+ "engines": {
1366
+ "iojs": ">=1.0.0",
1367
+ "node": ">=0.10.0"
1368
+ }
1369
+ },
1370
+ "node_modules/run-parallel": {
1371
+ "version": "1.2.0",
1372
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
1373
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
1374
+ "dev": true,
1375
+ "funding": [
1376
+ {
1377
+ "type": "github",
1378
+ "url": "https://github.com/sponsors/feross"
1379
+ },
1380
+ {
1381
+ "type": "patreon",
1382
+ "url": "https://www.patreon.com/feross"
1383
+ },
1384
+ {
1385
+ "type": "consulting",
1386
+ "url": "https://feross.org/support"
1387
+ }
1388
+ ],
1389
+ "license": "MIT",
1390
+ "dependencies": {
1391
+ "queue-microtask": "^1.2.2"
1392
+ }
1393
+ },
1394
+ "node_modules/scheduler": {
1395
+ "version": "0.23.2",
1396
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
1397
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
1398
+ "license": "MIT",
1399
+ "dependencies": {
1400
+ "loose-envify": "^1.1.0"
1401
+ }
1402
+ },
1403
+ "node_modules/source-map-js": {
1404
+ "version": "1.2.1",
1405
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
1406
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
1407
+ "license": "BSD-3-Clause",
1408
+ "engines": {
1409
+ "node": ">=0.10.0"
1410
+ }
1411
+ },
1412
+ "node_modules/streamsearch": {
1413
+ "version": "1.1.0",
1414
+ "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
1415
+ "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
1416
+ "engines": {
1417
+ "node": ">=10.0.0"
1418
+ }
1419
+ },
1420
+ "node_modules/styled-jsx": {
1421
+ "version": "5.1.1",
1422
+ "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz",
1423
+ "integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==",
1424
+ "license": "MIT",
1425
+ "dependencies": {
1426
+ "client-only": "0.0.1"
1427
+ },
1428
+ "engines": {
1429
+ "node": ">= 12.0.0"
1430
+ },
1431
+ "peerDependencies": {
1432
+ "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0"
1433
+ },
1434
+ "peerDependenciesMeta": {
1435
+ "@babel/core": {
1436
+ "optional": true
1437
+ },
1438
+ "babel-plugin-macros": {
1439
+ "optional": true
1440
+ }
1441
+ }
1442
+ },
1443
+ "node_modules/sucrase": {
1444
+ "version": "3.35.1",
1445
+ "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
1446
+ "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
1447
+ "dev": true,
1448
+ "license": "MIT",
1449
+ "dependencies": {
1450
+ "@jridgewell/gen-mapping": "^0.3.2",
1451
+ "commander": "^4.0.0",
1452
+ "lines-and-columns": "^1.1.6",
1453
+ "mz": "^2.7.0",
1454
+ "pirates": "^4.0.1",
1455
+ "tinyglobby": "^0.2.11",
1456
+ "ts-interface-checker": "^0.1.9"
1457
+ },
1458
+ "bin": {
1459
+ "sucrase": "bin/sucrase",
1460
+ "sucrase-node": "bin/sucrase-node"
1461
+ },
1462
+ "engines": {
1463
+ "node": ">=16 || 14 >=14.17"
1464
+ }
1465
+ },
1466
+ "node_modules/supports-preserve-symlinks-flag": {
1467
+ "version": "1.0.0",
1468
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
1469
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
1470
+ "dev": true,
1471
+ "license": "MIT",
1472
+ "engines": {
1473
+ "node": ">= 0.4"
1474
+ },
1475
+ "funding": {
1476
+ "url": "https://github.com/sponsors/ljharb"
1477
+ }
1478
+ },
1479
+ "node_modules/tailwindcss": {
1480
+ "version": "3.4.19",
1481
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
1482
+ "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
1483
+ "dev": true,
1484
+ "license": "MIT",
1485
+ "dependencies": {
1486
+ "@alloc/quick-lru": "^5.2.0",
1487
+ "arg": "^5.0.2",
1488
+ "chokidar": "^3.6.0",
1489
+ "didyoumean": "^1.2.2",
1490
+ "dlv": "^1.1.3",
1491
+ "fast-glob": "^3.3.2",
1492
+ "glob-parent": "^6.0.2",
1493
+ "is-glob": "^4.0.3",
1494
+ "jiti": "^1.21.7",
1495
+ "lilconfig": "^3.1.3",
1496
+ "micromatch": "^4.0.8",
1497
+ "normalize-path": "^3.0.0",
1498
+ "object-hash": "^3.0.0",
1499
+ "picocolors": "^1.1.1",
1500
+ "postcss": "^8.4.47",
1501
+ "postcss-import": "^15.1.0",
1502
+ "postcss-js": "^4.0.1",
1503
+ "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
1504
+ "postcss-nested": "^6.2.0",
1505
+ "postcss-selector-parser": "^6.1.2",
1506
+ "resolve": "^1.22.8",
1507
+ "sucrase": "^3.35.0"
1508
+ },
1509
+ "bin": {
1510
+ "tailwind": "lib/cli.js",
1511
+ "tailwindcss": "lib/cli.js"
1512
+ },
1513
+ "engines": {
1514
+ "node": ">=14.0.0"
1515
+ }
1516
+ },
1517
+ "node_modules/thenify": {
1518
+ "version": "3.3.1",
1519
+ "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
1520
+ "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
1521
+ "dev": true,
1522
+ "license": "MIT",
1523
+ "dependencies": {
1524
+ "any-promise": "^1.0.0"
1525
+ }
1526
+ },
1527
+ "node_modules/thenify-all": {
1528
+ "version": "1.6.0",
1529
+ "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
1530
+ "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
1531
+ "dev": true,
1532
+ "license": "MIT",
1533
+ "dependencies": {
1534
+ "thenify": ">= 3.1.0 < 4"
1535
+ },
1536
+ "engines": {
1537
+ "node": ">=0.8"
1538
+ }
1539
+ },
1540
+ "node_modules/tinyglobby": {
1541
+ "version": "0.2.15",
1542
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
1543
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
1544
+ "dev": true,
1545
+ "license": "MIT",
1546
+ "dependencies": {
1547
+ "fdir": "^6.5.0",
1548
+ "picomatch": "^4.0.3"
1549
+ },
1550
+ "engines": {
1551
+ "node": ">=12.0.0"
1552
+ },
1553
+ "funding": {
1554
+ "url": "https://github.com/sponsors/SuperchupuDev"
1555
+ }
1556
+ },
1557
+ "node_modules/tinyglobby/node_modules/fdir": {
1558
+ "version": "6.5.0",
1559
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
1560
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
1561
+ "dev": true,
1562
+ "license": "MIT",
1563
+ "engines": {
1564
+ "node": ">=12.0.0"
1565
+ },
1566
+ "peerDependencies": {
1567
+ "picomatch": "^3 || ^4"
1568
+ },
1569
+ "peerDependenciesMeta": {
1570
+ "picomatch": {
1571
+ "optional": true
1572
+ }
1573
+ }
1574
+ },
1575
+ "node_modules/tinyglobby/node_modules/picomatch": {
1576
+ "version": "4.0.3",
1577
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
1578
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
1579
+ "dev": true,
1580
+ "license": "MIT",
1581
+ "engines": {
1582
+ "node": ">=12"
1583
+ },
1584
+ "funding": {
1585
+ "url": "https://github.com/sponsors/jonschlinkert"
1586
+ }
1587
+ },
1588
+ "node_modules/to-regex-range": {
1589
+ "version": "5.0.1",
1590
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
1591
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
1592
+ "dev": true,
1593
+ "license": "MIT",
1594
+ "dependencies": {
1595
+ "is-number": "^7.0.0"
1596
+ },
1597
+ "engines": {
1598
+ "node": ">=8.0"
1599
+ }
1600
+ },
1601
+ "node_modules/ts-interface-checker": {
1602
+ "version": "0.1.13",
1603
+ "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
1604
+ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
1605
+ "dev": true,
1606
+ "license": "Apache-2.0"
1607
+ },
1608
+ "node_modules/tslib": {
1609
+ "version": "2.8.1",
1610
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
1611
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
1612
+ "license": "0BSD"
1613
+ },
1614
+ "node_modules/typescript": {
1615
+ "version": "5.9.3",
1616
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
1617
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
1618
+ "dev": true,
1619
+ "license": "Apache-2.0",
1620
+ "bin": {
1621
+ "tsc": "bin/tsc",
1622
+ "tsserver": "bin/tsserver"
1623
+ },
1624
+ "engines": {
1625
+ "node": ">=14.17"
1626
+ }
1627
+ },
1628
+ "node_modules/undici-types": {
1629
+ "version": "6.21.0",
1630
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
1631
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
1632
+ "dev": true,
1633
+ "license": "MIT"
1634
+ },
1635
+ "node_modules/update-browserslist-db": {
1636
+ "version": "1.2.3",
1637
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
1638
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
1639
+ "dev": true,
1640
+ "funding": [
1641
+ {
1642
+ "type": "opencollective",
1643
+ "url": "https://opencollective.com/browserslist"
1644
+ },
1645
+ {
1646
+ "type": "tidelift",
1647
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
1648
+ },
1649
+ {
1650
+ "type": "github",
1651
+ "url": "https://github.com/sponsors/ai"
1652
+ }
1653
+ ],
1654
+ "license": "MIT",
1655
+ "dependencies": {
1656
+ "escalade": "^3.2.0",
1657
+ "picocolors": "^1.1.1"
1658
+ },
1659
+ "bin": {
1660
+ "update-browserslist-db": "cli.js"
1661
+ },
1662
+ "peerDependencies": {
1663
+ "browserslist": ">= 4.21.0"
1664
+ }
1665
+ },
1666
+ "node_modules/use-sync-external-store": {
1667
+ "version": "1.6.0",
1668
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
1669
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
1670
+ "license": "MIT",
1671
+ "peerDependencies": {
1672
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
1673
+ }
1674
+ },
1675
+ "node_modules/util-deprecate": {
1676
+ "version": "1.0.2",
1677
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
1678
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
1679
+ "dev": true,
1680
+ "license": "MIT"
1681
+ },
1682
+ "node_modules/zustand": {
1683
+ "version": "4.5.7",
1684
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
1685
+ "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
1686
+ "license": "MIT",
1687
+ "dependencies": {
1688
+ "use-sync-external-store": "^1.2.2"
1689
+ },
1690
+ "engines": {
1691
+ "node": ">=12.7.0"
1692
+ },
1693
+ "peerDependencies": {
1694
+ "@types/react": ">=16.8",
1695
+ "immer": ">=9.0.6",
1696
+ "react": ">=16.8"
1697
+ },
1698
+ "peerDependenciesMeta": {
1699
+ "@types/react": {
1700
+ "optional": true
1701
+ },
1702
+ "immer": {
1703
+ "optional": true
1704
+ },
1705
+ "react": {
1706
+ "optional": true
1707
+ }
1708
+ }
1709
+ }
1710
+ }
1711
+ }
package.json ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "lingualearn-ui",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev",
7
+ "build": "next build",
8
+ "start": "next start",
9
+ "lint": "next lint"
10
+ },
11
+ "dependencies": {
12
+ "framer-motion": "^12.34.3",
13
+ "next": "^14.2.0",
14
+ "react": "^18.2.0",
15
+ "react-dom": "^18.2.0",
16
+ "react-icons": "^5.3.0",
17
+ "zustand": "^4.5.0"
18
+ },
19
+ "devDependencies": {
20
+ "@types/node": "^20.0.0",
21
+ "@types/react": "^18.2.0",
22
+ "@types/react-dom": "^18.2.0",
23
+ "autoprefixer": "^10.4.0",
24
+ "postcss": "^8.4.0",
25
+ "tailwindcss": "^3.4.0",
26
+ "typescript": "^5.0.0"
27
+ }
28
+ }
postcss.config.mjs ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('postcss-load-config').Config} */
2
+ const config = {
3
+ plugins: {
4
+ tailwindcss: {},
5
+ autoprefixer: {},
6
+ },
7
+ };
8
+
9
+ export default config;
public/.nojekyll ADDED
File without changes
public/favicon.svg ADDED
stores/useArenaStore.ts ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { create } from "zustand";
2
+
3
+ /* ═══════════════════ Types ═══════════════════ */
4
+
5
+ export interface ArenaState {
6
+ /* ── Session ── */
7
+ nodeId: string | null;
8
+ courseId: string | null;
9
+ totalStages: number;
10
+ currentStageIndex: number;
11
+ /* ── Stage state ── */
12
+ isCorrect: boolean | null;
13
+ showFeedback: boolean;
14
+ /* ── Score tracking ── */
15
+ correctCount: number;
16
+ incorrectCount: number;
17
+ consecutiveErrors: number;
18
+ remedyTriggered: boolean;
19
+ hintsUsed: number;
20
+ /* ── Timing ── */
21
+ startTime: number | null;
22
+ endTime: number | null;
23
+ /* ── Animation triggers ── */
24
+ showConfetti: boolean;
25
+ shakeScreen: boolean;
26
+ }
27
+
28
+ export interface ArenaActions {
29
+ /* ── Session lifecycle ── */
30
+ startSession: (opts: {
31
+ nodeId: string;
32
+ courseId: string;
33
+ totalStages: number;
34
+ }) => void;
35
+ endSession: () => void;
36
+ /* ── Stage progression ── */
37
+ nextStage: () => void;
38
+ /* ── Answer evaluation ── */
39
+ markCorrect: () => void;
40
+ markIncorrect: () => void;
41
+ /* ── Feedback ── */
42
+ showFeedbackPanel: () => void;
43
+ hideFeedbackPanel: () => void;
44
+ /* ── Hints ── */
45
+ useHint: () => void;
46
+ /* ── Animation triggers ── */
47
+ triggerConfetti: () => void;
48
+ triggerShake: () => void;
49
+ clearAnimations: () => void;
50
+ /* ── Reset ── */
51
+ resetArena: () => void;
52
+ }
53
+
54
+ /* ═══════════════════ Initial State ═══════════════════ */
55
+
56
+ /** How many consecutive wrong answers before remedy is triggered */
57
+ export const REMEDY_THRESHOLD = 3;
58
+
59
+ const INITIAL_STATE: ArenaState = {
60
+ nodeId: null,
61
+ courseId: null,
62
+ totalStages: 0,
63
+ currentStageIndex: 0,
64
+ isCorrect: null,
65
+ showFeedback: false,
66
+ correctCount: 0,
67
+ incorrectCount: 0,
68
+ consecutiveErrors: 0,
69
+ remedyTriggered: false,
70
+ hintsUsed: 0,
71
+ startTime: null,
72
+ endTime: null,
73
+ showConfetti: false,
74
+ shakeScreen: false,
75
+ };
76
+
77
+ /* ═══════════════════ Store ═══════════════════ */
78
+
79
+ const useArenaStore = create<ArenaState & ArenaActions>()((set, get) => ({
80
+ ...INITIAL_STATE,
81
+
82
+ /* ── Session lifecycle ── */
83
+ startSession: ({ nodeId, courseId, totalStages }) =>
84
+ set({
85
+ ...INITIAL_STATE,
86
+ nodeId,
87
+ courseId,
88
+ totalStages,
89
+ startTime: Date.now(),
90
+ }),
91
+
92
+ endSession: () =>
93
+ set({
94
+ endTime: Date.now(),
95
+ }),
96
+
97
+ /* ── Stage progression ── */
98
+ nextStage: () =>
99
+ set((s) => ({
100
+ currentStageIndex: Math.min(s.currentStageIndex + 1, s.totalStages - 1),
101
+ isCorrect: null,
102
+ showFeedback: false,
103
+ showConfetti: false,
104
+ shakeScreen: false,
105
+ })),
106
+
107
+ /* ── Answer evaluation ── */
108
+ markCorrect: () =>
109
+ set((s) => ({
110
+ isCorrect: true,
111
+ correctCount: s.correctCount + 1,
112
+ consecutiveErrors: 0,
113
+ })),
114
+
115
+ markIncorrect: () =>
116
+ set((s) => {
117
+ const next = s.consecutiveErrors + 1;
118
+ return {
119
+ isCorrect: false,
120
+ incorrectCount: s.incorrectCount + 1,
121
+ consecutiveErrors: next,
122
+ remedyTriggered: next >= REMEDY_THRESHOLD ? true : s.remedyTriggered,
123
+ };
124
+ }),
125
+
126
+ /* ── Feedback ── */
127
+ showFeedbackPanel: () => set({ showFeedback: true }),
128
+ hideFeedbackPanel: () => set({ showFeedback: false }),
129
+
130
+ /* ── Hints ── */
131
+ useHint: () => set((s) => ({ hintsUsed: s.hintsUsed + 1 })),
132
+
133
+ /* ── Animation triggers ── */
134
+ triggerConfetti: () => set({ showConfetti: true }),
135
+ triggerShake: () => {
136
+ set({ shakeScreen: true });
137
+ setTimeout(() => set({ shakeScreen: false }), 500);
138
+ },
139
+ clearAnimations: () => set({ showConfetti: false, shakeScreen: false }),
140
+
141
+ /* ── Reset ── */
142
+ resetArena: () => set(INITIAL_STATE),
143
+ }));
144
+
145
+ /** Derived: compute accuracy percentage */
146
+ export function getAccuracy(state: ArenaState): number {
147
+ const total = state.correctCount + state.incorrectCount;
148
+ if (total === 0) return 100;
149
+ return Math.round((state.correctCount / total) * 100);
150
+ }
151
+
152
+ /** Derived: compute elapsed time string (e.g. "2m 14s") */
153
+ export function getElapsedTime(state: ArenaState): string {
154
+ const start = state.startTime ?? Date.now();
155
+ const end = state.endTime ?? Date.now();
156
+ const secs = Math.floor((end - start) / 1000);
157
+ const m = Math.floor(secs / 60);
158
+ const s = secs % 60;
159
+ return `${m}m ${s.toString().padStart(2, "0")}s`;
160
+ }
161
+
162
+ /** Derived: compute XP gained */
163
+ export function getXpGained(state: ArenaState): number {
164
+ const accuracy = getAccuracy(state);
165
+ const baseXp = 30;
166
+ const bonus = Math.floor((accuracy / 100) * 20);
167
+ const hintPenalty = state.hintsUsed * 5;
168
+ return Math.max(10, baseXp + bonus - hintPenalty);
169
+ }
170
+
171
+ export default useArenaStore;
stores/useCourseStore.ts ADDED
@@ -0,0 +1,259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { create } from "zustand";
2
+ import { persist } from "zustand/middleware";
3
+ import { mockMedicineUnit } from "@/data/mock_medicine";
4
+ import { mockCalculusUnit } from "@/data/mock_calculus";
5
+
6
+ /* ═══════════════════ Types ═══════════════════ */
7
+
8
+ export interface StageData {
9
+ stageId: string;
10
+ topic: string;
11
+ module: string;
12
+ component: string;
13
+ skin: string;
14
+ config: {
15
+ data: Record<string, unknown>;
16
+ initialState: Record<string, unknown>;
17
+ };
18
+ validation: { type: string; condition: Record<string, unknown> };
19
+ feedback: { success: string; error: string; hint: string };
20
+ }
21
+
22
+ export interface CourseNode {
23
+ id: string;
24
+ title: string;
25
+ description: string;
26
+ type: string;
27
+ status: "completed" | "available" | "locked";
28
+ stages: StageData[];
29
+ }
30
+
31
+ export interface Course {
32
+ unitId: string;
33
+ unitTitle: string;
34
+ nodes: CourseNode[];
35
+ }
36
+
37
+ export interface RemedyNode {
38
+ id: string;
39
+ courseId: string;
40
+ sourceNodeId: string;
41
+ title: string;
42
+ status: "available" | "completed";
43
+ }
44
+
45
+ export interface CourseState {
46
+ /* ── Data ── */
47
+ courses: Record<string, Course>; // keyed by unitId
48
+ /* ── Progress ── */
49
+ nodeStatuses: Record<string, "completed" | "available" | "locked">; // keyed by node id
50
+ completedNodes: string[];
51
+ /* ── Remedy ── */
52
+ remedyNodes: RemedyNode[];
53
+ }
54
+
55
+ export interface CourseActions {
56
+ /* ── Load ── */
57
+ loadMockCourses: () => void;
58
+ /* ── Query ── */
59
+ getCourse: (unitId: string) => Course | undefined;
60
+ getNode: (nodeId: string) => CourseNode | undefined;
61
+ getNodeStatus: (nodeId: string) => "completed" | "available" | "locked";
62
+ getNodesForCourse: (unitId: string) => CourseNode[];
63
+ getCourseProgress: (unitId: string) => number; // 0-100
64
+ /* ── Mutations ── */
65
+ completeNode: (nodeId: string) => void;
66
+ unlockNode: (nodeId: string) => void;
67
+ resetProgress: () => void;
68
+ /* ── Remedy ── */
69
+ addRemedyNode: (courseId: string, sourceNodeId: string, sourceTitle: string) => void;
70
+ completeRemedyNode: (remedyId: string) => void;
71
+ getRemedyNodesForCourse: (courseId: string) => RemedyNode[];
72
+ }
73
+
74
+ /* ═══════════════════ Helpers ═══════════════════ */
75
+
76
+ function buildInitialStatuses(courses: Record<string, Course>): Record<string, string> {
77
+ const statuses: Record<string, string> = {};
78
+ for (const course of Object.values(courses)) {
79
+ for (const node of course.nodes) {
80
+ statuses[node.id] = node.status;
81
+ }
82
+ }
83
+ return statuses;
84
+ }
85
+
86
+ /* ═══════════════════ Store ═══════════════════ */
87
+
88
+ const useCourseStore = create<CourseState & CourseActions>()(
89
+ persist(
90
+ (set, get) => ({
91
+ courses: {},
92
+ nodeStatuses: {},
93
+ completedNodes: [],
94
+ remedyNodes: [],
95
+
96
+ /* ── Load mock data ── */
97
+ loadMockCourses: () => {
98
+ const medCourse = mockMedicineUnit as unknown as Course;
99
+ const calcCourse = mockCalculusUnit as unknown as Course;
100
+
101
+ const courses: Record<string, Course> = {
102
+ [medCourse.unitId]: medCourse,
103
+ [calcCourse.unitId]: calcCourse,
104
+ };
105
+
106
+ // Only initialize statuses if they haven't been set yet
107
+ const currentStatuses = get().nodeStatuses;
108
+ const hasExisting = Object.keys(currentStatuses).length > 0;
109
+
110
+ set({
111
+ courses,
112
+ nodeStatuses: hasExisting
113
+ ? currentStatuses
114
+ : (buildInitialStatuses(courses) as Record<string, "completed" | "available" | "locked">),
115
+ });
116
+ },
117
+
118
+ /* ── Query ── */
119
+ getCourse: (unitId) => get().courses[unitId],
120
+
121
+ getNode: (nodeId) => {
122
+ for (const course of Object.values(get().courses)) {
123
+ const node = course.nodes.find((n) => n.id === nodeId);
124
+ if (node) {
125
+ // Merge stored status
126
+ return {
127
+ ...node,
128
+ status: get().nodeStatuses[node.id] ?? node.status,
129
+ };
130
+ }
131
+ }
132
+ return undefined;
133
+ },
134
+
135
+ getNodeStatus: (nodeId) =>
136
+ get().nodeStatuses[nodeId] ?? "locked",
137
+
138
+ getNodesForCourse: (unitId) => {
139
+ const course = get().courses[unitId];
140
+ if (!course) return [];
141
+ return course.nodes.map((n) => ({
142
+ ...n,
143
+ status: get().nodeStatuses[n.id] ?? n.status,
144
+ }));
145
+ },
146
+
147
+ getCourseProgress: (unitId) => {
148
+ const course = get().courses[unitId];
149
+ if (!course || course.nodes.length === 0) return 0;
150
+ const completed = course.nodes.filter(
151
+ (n) => get().nodeStatuses[n.id] === "completed"
152
+ ).length;
153
+ return Math.round((completed / course.nodes.length) * 100);
154
+ },
155
+
156
+ /* ── Mutations ── */
157
+ completeNode: (nodeId) => {
158
+ const state = get();
159
+
160
+ // Always mark remedy nodes for this source as completed (regardless of courses loaded)
161
+ const updatedRemedyNodes = state.remedyNodes.map((r) =>
162
+ r.sourceNodeId === nodeId && r.status === "available"
163
+ ? { ...r, status: "completed" as const }
164
+ : r
165
+ );
166
+ const remedyChanged = updatedRemedyNodes.some(
167
+ (r, i) => r.status !== state.remedyNodes[i].status
168
+ );
169
+ if (remedyChanged) {
170
+ set({ remedyNodes: updatedRemedyNodes });
171
+ }
172
+
173
+ // Find which course this node belongs to
174
+ let courseId: string | null = null;
175
+ let nodeIndex = -1;
176
+ for (const course of Object.values(state.courses)) {
177
+ const idx = course.nodes.findIndex((n) => n.id === nodeId);
178
+ if (idx !== -1) {
179
+ courseId = course.unitId;
180
+ nodeIndex = idx;
181
+ break;
182
+ }
183
+ }
184
+
185
+ if (!courseId || nodeIndex === -1) return;
186
+
187
+ const course = state.courses[courseId];
188
+ const newStatuses = { ...state.nodeStatuses, [nodeId]: "completed" as const };
189
+ const newCompleted = [...state.completedNodes];
190
+ if (!newCompleted.includes(nodeId)) {
191
+ newCompleted.push(nodeId);
192
+ }
193
+
194
+ // Auto-unlock the next node in the same course
195
+ if (nodeIndex < course.nodes.length - 1) {
196
+ const nextNode = course.nodes[nodeIndex + 1];
197
+ if (newStatuses[nextNode.id] === "locked") {
198
+ newStatuses[nextNode.id] = "available";
199
+ }
200
+ }
201
+
202
+ set({
203
+ nodeStatuses: newStatuses,
204
+ completedNodes: newCompleted,
205
+ remedyNodes: updatedRemedyNodes,
206
+ });
207
+ },
208
+
209
+ unlockNode: (nodeId) =>
210
+ set((s) => ({
211
+ nodeStatuses: { ...s.nodeStatuses, [nodeId]: "available" },
212
+ })),
213
+
214
+ resetProgress: () => {
215
+ const courses = get().courses;
216
+ set({
217
+ nodeStatuses: buildInitialStatuses(courses) as Record<string, "completed" | "available" | "locked">,
218
+ completedNodes: [],
219
+ remedyNodes: [],
220
+ });
221
+ },
222
+
223
+ /* ── Remedy ── */
224
+ addRemedyNode: (courseId, sourceNodeId, sourceTitle) => {
225
+ const existing = get().remedyNodes;
226
+ // Don't add duplicate remedy for same source
227
+ if (existing.some((r) => r.sourceNodeId === sourceNodeId && r.status === "available")) return;
228
+ const id = `remedy-${sourceNodeId}-${Date.now()}`;
229
+ set({
230
+ remedyNodes: [
231
+ ...existing,
232
+ { id, courseId, sourceNodeId, title: `📚 補強: ${sourceTitle}`, status: "available" },
233
+ ],
234
+ });
235
+ },
236
+
237
+ completeRemedyNode: (remedyId) => {
238
+ set({
239
+ remedyNodes: get().remedyNodes.map((r) =>
240
+ r.id === remedyId ? { ...r, status: "completed" } : r
241
+ ),
242
+ });
243
+ },
244
+
245
+ getRemedyNodesForCourse: (courseId) =>
246
+ get().remedyNodes.filter((r) => r.courseId === courseId),
247
+ }),
248
+ {
249
+ name: "learn8-courses",
250
+ partialize: (state) => ({
251
+ nodeStatuses: state.nodeStatuses,
252
+ completedNodes: state.completedNodes,
253
+ remedyNodes: state.remedyNodes,
254
+ }),
255
+ }
256
+ )
257
+ );
258
+
259
+ export default useCourseStore;
stores/useUserStore.ts ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { create } from "zustand";
2
+ import { persist } from "zustand/middleware";
3
+
4
+ /* ═══════════════════ Types ═══════════════════ */
5
+
6
+ export interface UserPreferences {
7
+ soundOn: boolean;
8
+ darkGlass: boolean;
9
+ difficulty: number; // 0-100
10
+ }
11
+
12
+ export interface UserState {
13
+ /* ── Identity ── */
14
+ name: string;
15
+ title: string;
16
+ /* ── Progression ── */
17
+ xp: number;
18
+ level: number;
19
+ xpToNextLevel: number;
20
+ streak: number;
21
+ longestStreak: number;
22
+ gems: number;
23
+ coursesCompleted: number;
24
+ /* ── Navigation ── */
25
+ lastActiveCourseId: string | null;
26
+ lastActiveNodeId: string | null;
27
+ /* ── Preferences ── */
28
+ preferences: UserPreferences;
29
+ /* ── Onboarding ── */
30
+ onboarded: boolean;
31
+ selectedTopics: string[];
32
+ dailyGoal: string;
33
+ }
34
+
35
+ export interface UserActions {
36
+ /* ── Identity ── */
37
+ setName: (name: string) => void;
38
+ setTitle: (title: string) => void;
39
+ /* ── XP & Leveling ── */
40
+ addXp: (amount: number) => void;
41
+ /* ── Gems ── */
42
+ addGems: (amount: number) => void;
43
+ spendGems: (amount: number) => boolean; // returns false if insufficient
44
+ /* ── Streak ── */
45
+ incrementStreak: () => void;
46
+ resetStreak: () => void;
47
+ /* ── Courses ── */
48
+ incrementCoursesCompleted: () => void;
49
+ /* ── Navigation ── */
50
+ setLastActiveCourse: (courseId: string) => void;
51
+ setLastActiveNode: (nodeId: string) => void;
52
+ /* ── Preferences ── */
53
+ setPreferences: (prefs: Partial<UserPreferences>) => void;
54
+ /* ── Onboarding ── */
55
+ completeOnboarding: (data: {
56
+ name: string;
57
+ topics: string[];
58
+ goal: string;
59
+ }) => void;
60
+ /* ── Reset ── */
61
+ logout: () => void;
62
+ }
63
+
64
+ /* ═══════════════════ Helpers ═══════════════════ */
65
+
66
+ /** XP required for a given level */
67
+ function xpForLevel(level: number): number {
68
+ return 100 + (level - 1) * 50; // Level 1 = 100, Level 2 = 150, etc.
69
+ }
70
+
71
+ const INITIAL_STATE: UserState = {
72
+ name: "Explorer",
73
+ title: "Novice Learner",
74
+ xp: 0,
75
+ level: 1,
76
+ xpToNextLevel: xpForLevel(1),
77
+ streak: 12,
78
+ longestStreak: 28,
79
+ gems: 1500,
80
+ coursesCompleted: 3,
81
+ lastActiveCourseId: null,
82
+ lastActiveNodeId: null,
83
+ preferences: {
84
+ soundOn: true,
85
+ darkGlass: true,
86
+ difficulty: 50,
87
+ },
88
+ onboarded: false,
89
+ selectedTopics: [],
90
+ dailyGoal: "",
91
+ };
92
+
93
+ /* ═══════════════════ Store ═══════════════════ */
94
+
95
+ const useUserStore = create<UserState & UserActions>()(
96
+ persist(
97
+ (set, get) => ({
98
+ ...INITIAL_STATE,
99
+
100
+ /* ── Identity ── */
101
+ setName: (name) => set({ name }),
102
+ setTitle: (title) => set({ title }),
103
+
104
+ /* ── XP & Leveling ── */
105
+ addXp: (amount) => {
106
+ const state = get();
107
+ let newXp = state.xp + amount;
108
+ let newLevel = state.level;
109
+ let xpNeeded = state.xpToNextLevel;
110
+ let newTitle = state.title;
111
+
112
+ // Level-up loop (handle multi-level jumps)
113
+ while (newXp >= xpNeeded) {
114
+ newXp -= xpNeeded;
115
+ newLevel += 1;
116
+ xpNeeded = xpForLevel(newLevel);
117
+ // Update title based on level
118
+ if (newLevel >= 20) newTitle = "Grandmaster Scholar";
119
+ else if (newLevel >= 15) newTitle = "Master Scholar";
120
+ else if (newLevel >= 10) newTitle = "Expert Scholar";
121
+ else if (newLevel >= 5) newTitle = "Quantum Scholar";
122
+ else if (newLevel >= 3) newTitle = "Apprentice Scholar";
123
+ else newTitle = "Novice Learner";
124
+ }
125
+
126
+ set({
127
+ xp: newXp,
128
+ level: newLevel,
129
+ xpToNextLevel: xpNeeded,
130
+ title: newTitle,
131
+ });
132
+ },
133
+
134
+ /* ── Gems ── */
135
+ addGems: (amount) => set((s) => ({ gems: s.gems + amount })),
136
+ spendGems: (amount) => {
137
+ const state = get();
138
+ if (state.gems < amount) return false;
139
+ set({ gems: state.gems - amount });
140
+ return true;
141
+ },
142
+
143
+ /* ── Streak ── */
144
+ incrementStreak: () =>
145
+ set((s) => ({
146
+ streak: s.streak + 1,
147
+ longestStreak: Math.max(s.longestStreak, s.streak + 1),
148
+ })),
149
+ resetStreak: () => set({ streak: 0 }),
150
+
151
+ /* ── Courses ── */
152
+ incrementCoursesCompleted: () =>
153
+ set((s) => ({ coursesCompleted: s.coursesCompleted + 1 })),
154
+
155
+ /* ── Navigation ── */
156
+ setLastActiveCourse: (courseId) =>
157
+ set({ lastActiveCourseId: courseId }),
158
+ setLastActiveNode: (nodeId) => set({ lastActiveNodeId: nodeId }),
159
+
160
+ /* ── Preferences ── */
161
+ setPreferences: (prefs) =>
162
+ set((s) => ({
163
+ preferences: { ...s.preferences, ...prefs },
164
+ })),
165
+
166
+ /* ── Onboarding ── */
167
+ completeOnboarding: ({ name, topics, goal }) =>
168
+ set({
169
+ name,
170
+ selectedTopics: topics,
171
+ dailyGoal: goal,
172
+ onboarded: true,
173
+ }),
174
+
175
+ /* ── Reset ── */
176
+ logout: () => set(INITIAL_STATE),
177
+ }),
178
+ {
179
+ name: "learn8-user",
180
+ }
181
+ )
182
+ );
183
+
184
+ export default useUserStore;
tailwind.config.ts ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Config } from "tailwindcss";
2
+
3
+ const config: Config = {
4
+ content: [
5
+ "./app/**/*.{js,ts,jsx,tsx,mdx}",
6
+ "./components/**/*.{js,ts,jsx,tsx,mdx}",
7
+ ],
8
+ theme: {
9
+ extend: {
10
+ colors: {
11
+ brand: {
12
+ green: "#58CC02",
13
+ "green-dark": "#46a302",
14
+ coral: "#E8734A",
15
+ "coral-dark": "#d4623c",
16
+ teal: "#7AC7C4",
17
+ "teal-light": "#B8E6E3",
18
+ "teal-bg": "#E8F5F4",
19
+ gray: {
20
+ 50: "#F7F7F7",
21
+ 100: "#EEEEEE",
22
+ 200: "#E2E2E2",
23
+ 300: "#CBCBCB",
24
+ 400: "#AFAFAF",
25
+ 500: "#6B6B6B",
26
+ 600: "#545454",
27
+ 700: "#333333",
28
+ },
29
+ },
30
+ },
31
+ fontFamily: {
32
+ heading: ["'Nunito'", "sans-serif"],
33
+ body: ["'Open Sans'", "sans-serif"],
34
+ },
35
+ },
36
+ },
37
+ plugins: [],
38
+ };
39
+ export default config;
tsconfig.json ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "lib": ["dom", "dom.iterable", "esnext"],
4
+ "allowJs": true,
5
+ "skipLibCheck": true,
6
+ "strict": true,
7
+ "noEmit": true,
8
+ "esModuleInterop": true,
9
+ "module": "esnext",
10
+ "moduleResolution": "bundler",
11
+ "resolveJsonModule": true,
12
+ "isolatedModules": true,
13
+ "jsx": "preserve",
14
+ "incremental": true,
15
+ "plugins": [{ "name": "next" }],
16
+ "paths": {
17
+ "@/*": ["./*"]
18
+ }
19
+ },
20
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
21
+ "exclude": ["node_modules"]
22
+ }