) => void
+ removeExpense, // (id: string) => void
+
+ // Global
+ resetAll, // () => void — reset semua state
+
+} = useTourStore();
+```
+
+## Custom Hooks
+
+Gunakan custom hooks untuk encapsulate logic:
+
+### useTripStore.js
+```javascript
+import { useTourStore } from '../store/tourStore';
+
+export const useTripStore = () => {
+ const { trip, setTrip, resetTrip } = useTourStore();
+
+ const updateTrip = (data) => setTrip({ ...trip, ...data });
+ const isConfigured = trip.id !== null;
+
+ return { trip, updateTrip, resetTrip, isConfigured };
+};
+```
+
+### useSchedule.js
+```javascript
+import { useTourStore } from '../store/tourStore';
+
+export const useSchedule = (dayIndex) => {
+ const { schedule, addActivity, removeActivity, reorderActivities } = useTourStore();
+
+ const day = schedule[dayIndex];
+ const activities = day?.activities ?? [];
+ const sortedActivities = [...activities].sort((a, b) => a.time.localeCompare(b.time));
+
+ return { day, activities: sortedActivities, addActivity: (a) => addActivity(dayIndex, a), removeActivity: (id) => removeActivity(dayIndex, id) };
+};
+```
+
+### useBudget.js
+```javascript
+import { useTourStore } from '../store/tourStore';
+
+export const useBudget = () => {
+ const { budget, setTotalBudget, addExpense, removeExpense } = useTourStore();
+
+ const totalSpent = budget.expenses.reduce((sum, e) => sum + e.amount, 0);
+ const remaining = budget.total - totalSpent;
+ const isOverBudget = remaining < 0;
+ const percentUsed = budget.total > 0 ? (totalSpent / budget.total) * 100 : 0;
+
+ const byCategory = budget.expenses.reduce((acc, e) => {
+ acc[e.category] = (acc[e.category] || 0) + e.amount;
+ return acc;
+ }, {});
+
+ return { budget, totalSpent, remaining, isOverBudget, percentUsed, byCategory, setTotalBudget, addExpense, removeExpense };
+};
+```
+
+## Cara Membuat UUID
+
+```javascript
+// Di utils/helpers.js
+export const generateId = () => crypto.randomUUID();
+```
+
+## Contoh Penggunaan di Komponen
+
+```jsx
+import { useTourStore } from '../store/tourStore';
+
+const SchedulePage = () => {
+ const { schedule, addActivity } = useTourStore();
+
+ const handleAddActivity = (dayIndex, activityData) => {
+ addActivity(dayIndex, {
+ id: crypto.randomUUID(),
+ ...activityData
+ });
+ };
+
+ return ( /* ... */ );
+};
+```
+
+## Penting: Jangan Mutasi State Langsung
+
+```javascript
+// ❌ SALAH
+state.schedule[0].activities.push(newActivity);
+
+// ✅ BENAR (dalam store action)
+set(state => ({
+ schedule: state.schedule.map((day, i) =>
+ i === dayIndex
+ ? { ...day, activities: [...day.activities, newActivity] }
+ : day
+ )
+}));
+```
diff --git a/.agents/skills/ui-design/SKILL.md b/.agents/skills/ui-design/SKILL.md
new file mode 100644
index 0000000..ed847be
--- /dev/null
+++ b/.agents/skills/ui-design/SKILL.md
@@ -0,0 +1,268 @@
+---
+name: ui-design
+description: Panduan design system, CSS variables, layout, dan visual styling untuk Tour Planner App. Gunakan skill ini ketika menulis CSS, membangun layout, atau memastikan konsistensi visual antar halaman.
+---
+
+# Skill: UI Design — Tour Planner App
+
+## Design System Lengkap
+
+Semua token harus didefinisikan di `src/index.css` di dalam `:root`.
+
+```css
+/* === src/index.css === */
+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@400;500;600;700;800&family=Playfair+Display:ital,wght@0,600;1,600&display=swap');
+
+:root {
+ /* Colors */
+ --color-primary: hsl(220, 90%, 56%);
+ --color-primary-dark: hsl(220, 90%, 42%);
+ --color-primary-light: hsl(220, 90%, 70%);
+ --color-primary-glow: hsl(220, 90%, 56%, 0.3);
+
+ --color-accent: hsl(38, 95%, 55%);
+ --color-accent-dark: hsl(38, 95%, 40%);
+ --color-accent-light: hsl(38, 95%, 70%);
+
+ --color-bg-base: hsl(222, 28%, 8%);
+ --color-bg-surface: hsl(222, 22%, 13%);
+ --color-bg-elevated: hsl(222, 18%, 18%);
+ --color-bg-overlay: hsla(222, 28%, 5%, 0.8);
+
+ --color-text-primary: hsl(210, 40%, 96%);
+ --color-text-secondary: hsl(210, 20%, 65%);
+ --color-text-muted: hsl(210, 15%, 45%);
+
+ --color-border: hsl(220, 15%, 22%);
+ --color-border-light: hsl(220, 15%, 28%);
+
+ --color-success: hsl(142, 70%, 45%);
+ --color-success-bg: hsl(142, 70%, 45%, 0.15);
+ --color-warning: hsl(38, 95%, 55%);
+ --color-warning-bg: hsl(38, 95%, 55%, 0.15);
+ --color-danger: hsl(0, 75%, 55%);
+ --color-danger-bg: hsl(0, 75%, 55%, 0.15);
+
+ /* Typography */
+ --font-body: 'Inter', -apple-system, sans-serif;
+ --font-display: 'Outfit', sans-serif;
+ --font-hero: 'Playfair Display', serif;
+
+ --text-xs: 0.75rem;
+ --text-sm: 0.875rem;
+ --text-base: 1rem;
+ --text-lg: 1.125rem;
+ --text-xl: 1.25rem;
+ --text-2xl: 1.5rem;
+ --text-3xl: 1.875rem;
+ --text-4xl: 2.25rem;
+ --text-5xl: 3rem;
+
+ /* Spacing */
+ --space-1: 0.25rem;
+ --space-2: 0.5rem;
+ --space-3: 0.75rem;
+ --space-4: 1rem;
+ --space-5: 1.25rem;
+ --space-6: 1.5rem;
+ --space-8: 2rem;
+ --space-10: 2.5rem;
+ --space-12: 3rem;
+ --space-16: 4rem;
+ --space-20: 5rem;
+
+ /* Border Radius */
+ --radius-sm: 0.375rem;
+ --radius-md: 0.5rem;
+ --radius-lg: 0.75rem;
+ --radius-xl: 1rem;
+ --radius-2xl: 1.5rem;
+ --radius-full: 9999px;
+
+ /* Shadows */
+ --shadow-sm: 0 1px 3px hsla(0,0%,0%,0.3);
+ --shadow-md: 0 4px 16px hsla(0,0%,0%,0.35);
+ --shadow-lg: 0 8px 32px hsla(0,0%,0%,0.4);
+ --shadow-glow: 0 0 24px var(--color-primary-glow);
+
+ /* Transitions */
+ --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
+ --transition-base: 300ms cubic-bezier(0.4, 0, 0.2, 1);
+ --transition-slow: 500ms cubic-bezier(0.4, 0, 0.2, 1);
+
+ /* Z-Index */
+ --z-dropdown: 100;
+ --z-modal: 200;
+ --z-toast: 300;
+ --z-tooltip: 400;
+}
+```
+
+## CSS Utility Classes Wajib
+
+```css
+/* Base reset */
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+body {
+ font-family: var(--font-body);
+ background-color: var(--color-bg-base);
+ color: var(--color-text-primary);
+ line-height: 1.6;
+}
+
+/* Glassmorphism */
+.glass {
+ background: hsla(222, 22%, 18%, 0.7);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-xl);
+}
+
+/* Gradient text */
+.text-gradient {
+ background: linear-gradient(135deg, var(--color-primary-light), var(--color-accent));
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+
+/* Layout container */
+.container {
+ width: 100%;
+ max-width: 1280px;
+ margin: 0 auto;
+ padding: 0 var(--space-4);
+}
+
+/* Grid layouts */
+.grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--space-4); }
+.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-4); }
+.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--space-4); }
+
+/* Responsive grid */
+@media (max-width: 1024px) {
+ .grid-4 { grid-template-columns: repeat(2, 1fr); }
+ .grid-3 { grid-template-columns: repeat(2, 1fr); }
+}
+@media (max-width: 640px) {
+ .grid-4, .grid-3, .grid-2 { grid-template-columns: 1fr; }
+}
+```
+
+## Pola Layout Halaman
+
+```jsx
+// Setiap page menggunakan struktur ini:
+
+```
+
+## Komponen Hero (Home Page)
+
+```css
+.hero {
+ min-height: 100vh;
+ background:
+ radial-gradient(ellipse at 20% 50%, hsl(220, 90%, 20%, 0.4) 0%, transparent 60%),
+ radial-gradient(ellipse at 80% 20%, hsl(38, 95%, 20%, 0.3) 0%, transparent 60%),
+ var(--color-bg-base);
+ display: flex;
+ align-items: center;
+ position: relative;
+ overflow: hidden;
+}
+
+.hero::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background-image: url("data:image/svg+xml,..."); /* subtle grid pattern */
+ opacity: 0.03;
+}
+```
+
+## Warna Kategori Aktivitas
+
+```javascript
+export const CATEGORY_COLORS = {
+ wisata: 'hsl(220, 90%, 56%)',
+ makan: 'hsl(142, 70%, 45%)',
+ transportasi: 'hsl(280, 70%, 60%)',
+ 'check-in': 'hsl(38, 95%, 55%)',
+ lainnya: 'hsl(210, 20%, 55%)',
+};
+```
+
+## Warna Kategori Budget
+
+```javascript
+export const BUDGET_CATEGORY_COLORS = {
+ transportasi: '#4f8ef7',
+ akomodasi: '#a78bfa',
+ makan: '#34d399',
+ aktivitas: '#fbbf24',
+ 'oleh-oleh': '#f87171',
+ lainnya: '#94a3b8',
+};
+```
+
+## Breakpoints
+
+```css
+/* Mobile first */
+/* xs: 0+ (default) */
+/* sm: 640+ */
+@media (min-width: 640px) { }
+/* md: 768+ */
+@media (min-width: 768px) { }
+/* lg: 1024+ */
+@media (min-width: 1024px) { }
+/* xl: 1280+ */
+@media (min-width: 1280px) { }
+```
+
+## Animasi Framer Motion Standar
+
+```javascript
+// Import di setiap komponen yang butuh animasi
+import { motion, AnimatePresence } from 'framer-motion';
+
+// Fade in dari bawah (item list, card)
+export const fadeInUp = {
+ initial: { opacity: 0, y: 24 },
+ animate: { opacity: 1, y: 0 },
+ exit: { opacity: 0, y: -12 },
+ transition: { duration: 0.3 }
+};
+
+// Stagger container
+export const staggerContainer = {
+ animate: {
+ transition: { staggerChildren: 0.08, delayChildren: 0.1 }
+ }
+};
+
+// Scale pop (modal, tooltip)
+export const scalePop = {
+ initial: { opacity: 0, scale: 0.92 },
+ animate: { opacity: 1, scale: 1 },
+ exit: { opacity: 0, scale: 0.92 },
+ transition: { type: 'spring', stiffness: 300, damping: 24 }
+};
+
+// Slide in dari kiri (sidebar)
+export const slideInLeft = {
+ initial: { x: -300, opacity: 0 },
+ animate: { x: 0, opacity: 1 },
+ exit: { x: -300, opacity: 0 },
+ transition: { type: 'spring', stiffness: 260, damping: 28 }
+};
+```
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..a547bf3
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/.oxlintrc.json b/.oxlintrc.json
new file mode 100644
index 0000000..1255078
--- /dev/null
+++ b/.oxlintrc.json
@@ -0,0 +1,8 @@
+{
+ "$schema": "./node_modules/oxlint/configuration_schema.json",
+ "plugins": ["react", "oxc"],
+ "rules": {
+ "react/rules-of-hooks": "error",
+ "react/only-export-components": ["warn", { "allowConstantExport": true }]
+ }
+}
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..d515fcb
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,318 @@
+# 🗺️ AGENTS.md — Ready to Plan Tour (RPT)
+
+> **Dokumen ini adalah panduan utama untuk semua AI agent yang bekerja pada proyek ini.**
+> Jika model sebelumnya kehabisan token, baca dokumen ini terlebih dahulu sebelum melanjutkan pekerjaan.
+
+---
+
+## 📌 Konteks Proyek
+
+**Nama Aplikasi:** Ready to Plan Tour (RPT)
+**Singkatan:** RPT
+**Tipe:** Static Web Application (tidak ada backend, tidak ada login/logout)
+**Framework:** React.js (dengan Vite sebagai bundler)
+**Target:** Aplikasi perencanaan perjalanan wisata yang interaktif, kreatif, dan mandiri
+
+---
+
+## 🎯 Tujuan Aplikasi
+
+Membantu pengguna merencanakan perjalanan wisata secara lengkap dengan fitur:
+
+1. **Trip Selection** — Memilih destinasi wisata dan tipe perjalanan
+2. **Daily Schedule Builder** — Membuat jadwal harian aktivitas perjalanan (per hari)
+3. **Packing List Manager** — Mendaftar barang-barang yang akan dibawa
+4. **Budget Calculator** — Menghitung dan memantau anggaran perjalanan
+
+---
+
+## 🏗️ Arsitektur Aplikasi
+
+```
+tour-destination/
+├── AGENTS.md ← Dokumen ini
+├── .agents/
+│ ├── skills/
+│ │ ├── react-component/SKILL.md
+│ │ ├── state-management/SKILL.md
+│ │ ├── ui-design/SKILL.md
+│ │ └── feature-guide/SKILL.md
+│ └── rules/
+│ └── coding-standards.md
+├── public/
+│ └── assets/
+├── src/
+│ ├── main.jsx
+│ ├── App.jsx
+│ ├── index.css
+│ ├── components/
+│ │ ├── layout/
+│ │ ├── ui/
+│ │ ├── trip/
+│ │ ├── schedule/
+│ │ ├── packing/
+│ │ └── budget/
+│ ├── pages/
+│ │ ├── Home.jsx
+│ │ ├── TripDetail.jsx
+│ │ ├── Schedule.jsx
+│ │ ├── PackingList.jsx
+│ │ └── Budget.jsx
+│ ├── hooks/
+│ ├── store/
+│ │ └── tourStore.js
+│ ├── data/
+│ │ ├── destinations.js
+│ │ ├── activities.js
+│ │ └── packingTemplates.js
+│ └── utils/
+├── package.json
+└── vite.config.js
+```
+
+---
+
+## 🎨 Design System
+
+### Palet Warna (Dark Mode Default)
+```css
+--color-primary: hsl(220, 90%, 56%);
+--color-primary-dark: hsl(220, 90%, 42%);
+--color-accent: hsl(38, 95%, 55%);
+--color-bg-base: hsl(222, 28%, 8%);
+--color-bg-surface: hsl(222, 22%, 13%);
+--color-bg-elevated: hsl(222, 18%, 18%);
+--color-text-primary: hsl(210, 40%, 96%);
+--color-text-secondary: hsl(210, 20%, 65%);
+--color-success: hsl(142, 70%, 45%);
+--color-warning: hsl(38, 95%, 55%);
+--color-danger: hsl(0, 75%, 55%);
+```
+
+### Tipografi
+- Font Utama: Inter / Outfit (Google Fonts)
+- Font Display: Playfair Display (untuk hero heading)
+
+### Efek Visual
+- Glassmorphism pada card & modal
+- Gradient pada hero section
+- Micro-animations dengan Framer Motion
+- Smooth transitions: `cubic-bezier(0.4, 0, 0.2, 1)`
+
+---
+
+## 🔧 Tech Stack
+
+| Package | Versi | Kegunaan |
+|---|---|---|
+| react | ^18.x | Core framework |
+| react-dom | ^18.x | DOM rendering |
+| react-router-dom | ^6.x | Client-side routing |
+| zustand | ^4.x | Global state management |
+| framer-motion | ^11.x | Animasi |
+| lucide-react | latest | Icon library |
+| recharts | ^2.x | Chart budget |
+| @dnd-kit/core | ^6.x | Drag & drop jadwal |
+| @dnd-kit/sortable | ^7.x | Sortable list |
+| date-fns | ^3.x | Date utilities |
+
+**Bundler:** Vite ^5.x dengan plugin @vitejs/plugin-react
+
+---
+
+## 📋 Fitur Detail
+
+### 1. 🗺️ Trip Selection (Home Page)
+- Grid card destinasi wisata populer
+- Filter: kategori, durasi, budget range
+- Card: foto, nama, estimasi budget, rating, durasi populer
+- Tombol "Buat Trip Sendiri" untuk trip kustom
+- Search bar dengan autocomplete
+- State: `trip.id`, `trip.name`, `trip.destination`, `trip.startDate`, `trip.endDate`, `trip.totalDays`
+
+### 2. 📅 Daily Schedule Builder (Schedule Page)
+- Timeline visual per hari
+- Drag & drop aktivitas menggunakan @dnd-kit
+- Setiap aktivitas: waktu, nama, lokasi, durasi, kategori, catatan
+- Template aktivitas cepat
+- Modal form tambah aktivitas
+- State: `schedule[dayIndex][activities[]]`
+
+### 3. 🎒 Packing List Manager (PackingList Page)
+- Kategori: Pakaian, Dokumen, Elektronik, Obat-obatan, Toiletries, Lainnya
+- Template berdasarkan tipe trip
+- Checkbox checked/unchecked
+- Progress bar persentase sudah dipak
+- Quantity counter per item
+- State: `packingList[category][items[{name, qty, checked}]]`
+
+### 4. 💰 Budget Calculator (Budget Page)
+- Overview: Total Budget vs Pengeluaran vs Sisa
+- Kategori: Transportasi, Akomodasi, Makan, Aktivitas, Oleh-oleh, Lainnya
+- Pie chart per kategori, Bar chart per hari
+- Warning jika over budget
+- Currency: IDR default
+- State: `budget.total`, `budget.expenses[]`
+
+---
+
+## 🔄 Zustand Store Schema
+
+```javascript
+// src/store/tourStore.js — shape lengkap:
+{
+ trip: {
+ id: null,
+ name: '',
+ destination: '',
+ startDate: null,
+ endDate: null,
+ totalDays: 0,
+ coverImage: '',
+ type: 'custom', // 'preset' | 'custom'
+ },
+ schedule: [], // Array per hari, tiap hari punya activities[]
+ packingList: {
+ Pakaian: [],
+ Dokumen: [],
+ Elektronik: [],
+ 'Obat-obatan': [],
+ Toiletries: [],
+ Lainnya: [],
+ },
+ budget: {
+ total: 0,
+ currency: 'IDR',
+ expenses: [],
+ },
+ // Semua state di-persist ke localStorage via zustand/middleware/persist
+ // key: 'tour-planner-storage'
+}
+```
+
+---
+
+## 🚀 Setup Commands
+
+```bash
+# Inisialisasi
+npm create vite@latest . -- --template react
+npm install
+
+# Install semua dependencies
+npm install react-router-dom zustand framer-motion lucide-react recharts @dnd-kit/core @dnd-kit/sortable date-fns
+
+# Dev server
+npm run dev
+```
+
+---
+
+## 📏 Coding Standards
+
+Aturan lengkap di `.agents/rules/coding-standards.md`
+
+### Ringkasan Wajib:
+- ✅ Functional component + hooks (bukan class component)
+- ✅ CSS Variables dari design system (bukan Tailwind)
+- ✅ Zustand untuk state yang di-share
+- ✅ Framer Motion untuk semua animasi
+- ✅ Lucide React untuk semua icon
+- ✅ Data dummy di `src/data/`, bukan di dalam komponen
+- ✅ Mobile-first responsive (640 / 768 / 1024 / 1280px)
+- ❌ Jangan hardcode warna langsung di JSX
+- ❌ Jangan pakai emoji sebagai icon UI
+- ❌ Jangan pakai TailwindCSS
+
+---
+
+## 🚀 Phase Workflow (WAJIB DIBACA)
+
+Setiap fitur dikerjakan dalam fase yang berurutan dan tidak boleh dilompati.
+Aturan lengkap fase ada di `.agents/rules/phase-workflow.md`
+
+| Phase | Scope | Prasyarat |
+|-------|-------|-----------|
+| **Phase 0** | Project setup, dependencies, store, routing | — |
+| **Phase 1** | Layout & UI components dasar | Phase 0 selesai |
+| **Phase 2** | Home Page — Trip Selection | Phase 1 selesai |
+| **Phase 3** | Schedule Builder Page | Phase 2 selesai |
+| **Phase 4** | Packing List Page | Phase 3 selesai |
+| **Phase 5** | Budget Calculator Page | Phase 4 selesai |
+| **Phase 6** | Polish, responsive, animasi final | Phase 2–5 selesai |
+
+> ⚠️ Jika kamu agent baru: cek `task.md` untuk tahu fase mana yang sedang berjalan,
+> lalu baca `phase-workflow.md` untuk detail checklist fase tersebut.
+
+---
+
+## 🔁 Handoff Context (Untuk AI Agent Baru)
+
+> **BACA BAGIAN INI JIKA KAMU MELANJUTKAN PEKERJAAN YANG BELUM SELESAI**
+
+### Langkah 1 — Orientasi Cepat
+```bash
+# Lihat file yang sudah ada
+ls src/
+ls src/pages/
+ls src/components/
+cat src/store/tourStore.js
+```
+
+### Langkah 2 — Cek Status Task
+- Baca `task.md` di direktori artifacts agent
+- Baca `walkthrough.md` untuk ringkasan perubahan
+
+### Langkah 3 — Validasi Sebelum Lanjut
+Jawab dulu pertanyaan ini:
+1. Apakah `src/index.css` sudah punya CSS variables design system?
+2. Apakah `src/store/tourStore.js` sudah lengkap?
+3. Apakah React Router sudah dikonfigurasi di `src/App.jsx`?
+4. Page apa yang sudah selesai? (Home / Schedule / PackingList / Budget)
+
+### Prinsip TIDAK BOLEH Dilanggar:
+- ❌ Tidak ada fitur login/logout
+- ❌ Tidak ada API call ke backend
+- ❌ Tidak ada TailwindCSS
+- ❌ Tidak mengubah design token tanpa alasan jelas
+- ✅ Dark mode sebagai default
+- ✅ Animasi dengan Framer Motion
+- ✅ State persist ke localStorage
+
+---
+
+## 📝 Urutan Pengerjaan
+
+```
+Phase 1: Setup & Foundation
+ [ ] Init Vite + React
+ [ ] Install dependencies
+ [ ] Design system di index.css (CSS variables, typography, utilities)
+ [ ] Zustand store (tourStore.js)
+ [ ] React Router setup (App.jsx)
+
+Phase 2: Data Layer
+ [ ] src/data/destinations.js (min 10 destinasi)
+ [ ] src/data/activities.js (template aktivitas)
+ [ ] src/data/packingTemplates.js (template per tipe trip)
+
+Phase 3: Layout & UI Components
+ [ ] Navbar.jsx (dengan navigasi antar page)
+ [ ] Button.jsx, Card.jsx, Modal.jsx, Badge.jsx, ProgressBar.jsx
+
+Phase 4: Pages
+ [ ] Home.jsx (trip selection + search + filter)
+ [ ] Schedule.jsx (timeline + drag&drop)
+ [ ] PackingList.jsx (checklist + progress)
+ [ ] Budget.jsx (calculator + chart)
+
+Phase 5: Polish
+ [ ] Framer Motion animations
+ [ ] Responsive layout
+ [ ] localStorage persistence test
+```
+
+---
+
+*Versi: 1.0 | Dibuat: 2026-08-27*
+*Update dokumen ini jika ada perubahan arsitektur besar.*
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..4450181
--- /dev/null
+++ b/README.md
@@ -0,0 +1,356 @@
+# 🗺️ Ready to Plan Tour (RPT)
+
+[](https://react.dev/)
+[](https://vitejs.dev/)
+[](https://zustand-demo.pmnd.rs/)
+[](https://www.framer.com/motion/)
+[](https://recharts.org/)
+[](https://dndkit.com/)
+[](LICENSE)
+
+**Ready to Plan Tour (RPT)** adalah aplikasi *static web* perencanaan perjalanan wisata yang modern, kreatif, dan interaktif. Aplikasi ini dirancang untuk mempermudah pengguna menyusun seluruh aspek liburan—mulai dari memilih destinasi, menyusun jadwal harian per jam, mengelola daftar barang bawaan (*packing list*), hingga menghitung estimasi anggaran budget secara *real-time*.
+
+Aplikasi ini berjalan **100% di sisi klien (static web app tanpa backend, tanpa login/logout)** dengan sistem penyimpanan data otomatis ke `localStorage` browser.
+
+---
+
+## 📌 Daftar Isi
+1. [Fitur Utama](#-fitur-utama)
+2. [Detail Teknologi yang Digunakan](#-detail-teknologi-yang-digunakan)
+3. [Arsitektur & Struktur Folder](#-arsitektur--struktur-folder)
+4. [Persyaratan Sistem (Prerequisites)](#-persyaratan-sistem-prerequisites)
+5. [Cara Instalasi & Menjalankan Aplikasi](#-cara-instalasi--menjalankan-aplikasi)
+6. [Alur State Management & Penyimpanan Data](#-alur-state-management--penyimpanan-data)
+7. [Panduan Penggunaan Fitur](#-panduan-penggunaan-fitur)
+
+---
+
+## ✨ Fitur Utama
+
+### 1. 🧭 Trip Selection & Exploration (Home Page)
+- **Hero Section Visual:** Tipografi artistik Google Font *Playfair Display*, background *radial glow gradients*, dan lencana sorotan fitur.
+- **Koleksi 10+ Destinasi Populer:** Menampilkan destinasi eksotis Indonesia (Bali, Labuan Bajo, Jogja, Bromo, Raja Ampat, Bandung, Lombok) dan internasional (Tokyo, Seoul, Swiss Alps).
+- **Pencarian & Filter Cepat:** Filter instan berdasarkan kategori (*Pantai & Laut*, *Gunung & Alam*, *Budaya & Heritage*, *Kota Metropolitan*) serta sorting (*Paling Populer*, *Rating Tertinggi*, *Budget Terendah*, *Durasi Singkat*).
+- **Konfigurasi Trip Dinamis:** Form pengaturan nama trip, pemilihan rentang tanggal (auto-kalkulasi durasi hari), dan estimasi alokasi target budget.
+- **Trip Kustom Bebas:** Opsi merancang rencana perjalanan ke destinasi mana pun dengan cover foto dan preferensi kustom.
+
+### 2. 📅 Daily Schedule Builder (`/schedule`)
+- **Navigasi Tab Per Hari:** Tab interaktif (*Hari 1, Hari 2, ... Hari N*) dengan badge tanggal terformat dalam bahasa Indonesia dan counter aktivitas.
+- **Drag & Drop Interaktif (`@dnd-kit`):** Mengurutkan dan memindahkan jadwal aktivitas dengan sentuhan atau klik mouse (terproteksi sensor threshold 5px).
+- **Detail Aktivitas Kaya:** Jam mulai, durasi menit, garis warna & badge kategori (*Wisata, Kuliner, Transportasi, Hotel, Belanja, Santai, Lainnya*), lokasi kunjungan, dan catatan khusus.
+- **Template Aktivitas Cepat:** Menambahkan agenda umum hanya dalam 1 klik (*Sarapan Pagi, Check-in Hotel, Kunjungan Wisata, Makan Siang, Sunset Golden Hour, Makan Malam, Belanja Oleh-oleh*).
+- **Perpanjangan Hari Dinamis:** Tombol *"+ Tambah Hari"* untuk menambah durasi hari liburan kapan saja.
+
+### 3. 🎒 Packing List Manager (`/packing`)
+- **Visual Progress Bar Dinamis:** Menghitung persentase kesiapan barang secara *real-time* lengkap dengan status dinamis dan pesan selebrasi otomatis saat mencapai 100%.
+- **4 Template Bawaan Sesuai Tipe Trip:** Memuat template packing bawaan khusus *Trip Pantai*, *Trip Gunung*, *Trip Kota*, atau *Trip Budaya*.
+- **6 Kategori Perlengkapan:** *Pakaian, Dokumen, Elektronik, Obat-obatan, Toiletries, Lainnya*.
+- **Kontrol Interaktif:** Checkbox animasi spring Framer Motion, efek strikethrough, quantity counter (`- / +`), tombol *Centang Semua / Batalkan Semua*, dan form inline tambah item baru.
+- **Live Search Filter:** Pencarian nama barang secara instan.
+
+### 4. 💰 Budget Calculator (`/budget`)
+- **3 Kartu Ringkasan Anggaran:** *Target Total Budget* (dapat diedit langsung), *Total Pengeluaran Terpakai*, dan *Sisa Budget* dengan indikator warna cerdas:
+ - 🟢 **Hijau (*Aman & Terkendali*)** saat dana masih mencukupi.
+ - 🟡 **Kuning (*Mendekati Batas*)** saat pengeluaran mencapai $\ge 80\%$.
+ - 🔴 **Merah (*Over Budget!*)** saat pengeluaran melampaui batas anggaran.
+- **Banner Peringatan Defisit:** Muncul otomatis dengan nominal defisit saat over-budget.
+- **Visualisasi Grafik Recharts:**
+ - 🥧 **Donut / Pie Chart:** Proporsi pengeluaran berdasarkan pos kategori dengan legend persentase & custom glassmorphism tooltip.
+ - 📊 **Bar Chart:** Distribusi pengeluaran per hari perjalanan (*Hari ke-1, Hari ke-2*, dst.).
+- **Multi-Currency Toggle:** Kemudahan beralih antara mata uang **IDR** (Rupiah), **USD** (\$), dan **EUR** (€).
+- **Riwayat Pengeluaran:** Filter kategori pengeluaran, pengelompokan hari ke-N, modal edit, dan hapus transaksi.
+
+### 5. 🔔 Toast Notification & Utility System
+- **Global Toast Notification:** Notifikasi mengambang (*Floating Toast*) dengan animasi geser untuk feedback aksi sukses, info, peringatan, atau penghapusan data.
+- **Modal Konfirmasi Reset Trip:** Fitur reset data yang aman dengan dialog konfirmasi sebelum menghapus memori `localStorage`.
+- **Navigasi Sticky Glassmorphism:** Header navigasi transparan dengan drawer menu responsif di smartphone/tablet.
+
+---
+
+## 🛠️ Detail Teknologi yang Digunakan
+
+Aplikasi ini dibangun menggunakan arsitektur modern web berbasis komponen tanpa ketergantungan pada backend server:
+
+| Teknologi / Library | Versi | Peran & Alasan Penggunaan |
+|---|---|---|
+| **React.js** | `^18.x` | Library inti antarmuka berbasis komponen modular (*functional components* dan *React Hooks*). |
+| **Vite** | `^5.x` | Build tool dan bundler super cepat dengan fitur *Hot Module Replacement (HMR)* instan untuk pengembangan frontend modern. |
+| **React Router DOM** | `^6.x` | Pengatur navigasi dan routing multi-halaman sisi klien (*Client-Side Routing*) tanpa *page reload*. |
+| **Zustand** | `^4.x` | Library manajemen state global yang ringan, fleksibel, dan memiliki performa tinggi tanpa boilerplate seperti Redux. |
+| **Zustand Persist Middleware** | Bawaan Zustand | Menyimpan dan menyinkronkan seluruh state aplikasi ke `localStorage` secara otomatis agar data tidak hilang saat browser di-refresh. |
+| **Framer Motion** | `^11.x` | Mesin animasi deklaratif untuk transisi halaman, efek hover (*micro-interactions*), modal pop-up spring, dan animasi checkbox. |
+| **@dnd-kit/core & @dnd-kit/sortable** | `^6.x / ^7.x` | Library drag and drop modern, teroptimasi performa, dan mendukung aksesibilitas keyboard serta touch screen di mobile. |
+| **Recharts** | `^2.x` | Library visualisasi data berbasis SVG deklaratif untuk menggambar Donut/Pie Chart kategori dan Bar Chart anggaran per hari. |
+| **Lucide React** | Latest | Kumpulan ikon SVG modern, konsisten, tajam, dan ringan. |
+| **date-fns** | `^3.x` | Library utilitas manipulasi tanggal untuk format tanggal bahasa Indonesia (`date-fns/locale/id`) dan perhitungan selisih hari. |
+| **CSS Variables & Glassmorphism** | Vanilla CSS | Desain token HSL murni, efek *backdrop-filter blur*, bayangan glow, dan tata letak CSS Grid/Flexbox yang responsif tanpa dependensi CSS framework berat. |
+
+---
+
+## 📁 Arsitektur & Struktur Folder
+
+```
+tour-destination/
+├── index.html # File HTML utama, memuat Google Fonts (Inter, Outfit, Playfair Display)
+├── package.json # Konfigurasi dependensi project dan script npm
+├── vite.config.js # Konfigurasi Vite & React plugin
+├── README.md # Dokumentasi lengkap proyek
+├── AGENTS.md # Pedoman dan konteks pengerjaan AI Agent
+├── task.md # Tracking status fase pengerjaan (Phase 0 - 6)
+│
+├── .agents/ # Direktori aturan dan skill AI
+│ ├── rules/
+│ │ ├── coding-standards.md # Aturan baku penulisan kode
+│ │ └── phase-workflow.md # Alur pengerjaan berfase
+│ └── skills/ # Skill cheatsheets (react-component, state-management, ui-design, feature-guide)
+│
+├── public/ # File aset statis publik (favicon, svg icons)
+│
+└── src/
+ ├── main.jsx # Entry point aplikasi React
+ ├── App.jsx # Root component, konfigurasi React Router & Global Toast
+ ├── index.css # Design System utama (CSS Variables, Typography, Glassmorphism, Utilities)
+ │
+ ├── components/ # Komponen antarmuka modular
+ │ ├── layout/
+ │ │ ├── Navbar.jsx # Header sticky glassmorphism + mobile drawer + Reset Modal
+ │ │ ├── Footer.jsx # Footer branding, link navigasi, dan info trip aktif
+ │ │ └── PageWrapper.jsx # Wrapper transisi halaman Framer Motion
+ │ │
+ │ ├── ui/ # Komponen UI atomik
+ │ │ ├── Button.jsx # Tombol multi-variant, loading spinner, whileHover & whileTap
+ │ │ ├── Card.jsx # Kartu glassmorphism dengan efek hover lift
+ │ │ ├── Modal.jsx # Modal dialog interaktif dengan focus trap & escape key
+ │ │ ├── Badge.jsx # Badge status dan kategori
+ │ │ ├── ProgressBar.jsx # Progress bar animasi dengan gradient dinamis
+ │ │ ├── EmptyState.jsx # Tampilan placeholder saat data kosong
+ │ │ └── ToastContainer.jsx # Wadah notifikasi mengambang (Floating Toasts)
+ │ │
+ │ ├── trip/ # Komponen fitur Trip Selection
+ │ │ ├── HeroSection.jsx # Banner hero dengan tipografi Playfair Display & CTA
+ │ │ ├── FilterBar.jsx # Search bar, filter pills kategori, dan dropdown sorting
+ │ │ ├── TripCard.jsx # Kartu destinasi 16:10 dengan overlay & hover effects
+ │ │ ├── TripModal.jsx # Modal konfigurasi destinasi preset
+ │ │ └── CustomTripModal.jsx# Modal pembuatan rencana trip kustom
+ │ │
+ │ ├── schedule/ # Komponen fitur Daily Schedule
+ │ │ ├── DayTabs.jsx # Tab navigasi hari + tombol tambah hari
+ │ │ ├── ScheduleTimeline.jsx # Timeline vertikal terintegrasi DnD Kit
+ │ │ ├── ActivityItem.jsx # Item aktivitas draggable dengan kategori stripe & actions
+ │ │ └── AddActivityModal.jsx# Modal form tambah & edit aktivitas + template cepat
+ │ │
+ │ ├── packing/ # Komponen fitur Packing List
+ │ │ ├── PackingOverview.jsx# Widget ringkasan progress % & status selebrasi
+ │ │ ├── PackingCategory.jsx# Accordion kategori collapsible dengan form inline
+ │ │ ├── PackingItem.jsx # Item checklist dengan animasi checkbox & counter kuantitas
+ │ │ └── TemplateModal.jsx # Modal pemilihan template bawaan (Pantai, Gunung, Kota, Budaya)
+ │ │
+ │ └── budget/ # Komponen fitur Budget Calculator
+ │ ├── BudgetOverview.jsx # 3 Kartu statistik alokasi & status over-budget
+ │ ├── BudgetChart.jsx # Donut Pie Chart & Bar Chart Recharts
+ │ ├── AddExpenseModal.jsx# Form modal catat & edit pengeluaran
+ │ └── ExpenseList.jsx # Daftar rincian pengeluaran dengan filter kategori
+ │
+ ├── pages/ # Halaman rute utama
+ │ ├── Home.jsx # Halaman pilih destinasi & eksplorasi trip
+ │ ├── Schedule.jsx # Halaman susun jadwal harian (Drag & Drop)
+ │ ├── PackingList.jsx # Halaman checklist barang bawaan
+ │ └── Budget.jsx # Halaman kalkulator & grafik pengeluaran
+ │
+ ├── store/ # Global State Management (Zustand)
+ │ ├── tourStore.js # Store utama (trip, schedule, packingList, budget) + localStorage persist
+ │ └── toastStore.js # Store notifikasi toast
+ │
+ ├── hooks/ # Custom React Hooks
+ │ ├── useTripStore.js # Hook pengelolaan data trip
+ │ ├── useSchedule.js # Hook manipulasi jadwal & aktivitas
+ │ ├── usePackingList.js # Hook pengelolaan packing list & kalkulasi persentase
+ │ └── useBudget.js # Hook kalkulasi budget, pengeluaran, dan breakdown
+ │
+ ├── data/ # Dataset dummy & template bawaan
+ │ ├── destinations.js # 10 data destinasi unggulan beserta foto & info
+ │ ├── activities.js # Kategori aktivitas, warna, dan template cepat
+ │ └── packingTemplates.js # Template packing (pantai, gunung, kota, budaya)
+ │
+ └── utils/ # Fungsi utilitas pembantu
+ ├── formatCurrency.js # Formatter IDR, USD, EUR
+ ├── dateHelpers.js # Format tanggal Indonesia & kalkulasi durasi hari
+ └── helpers.js # Generator ID unik, clamp, dan truncate text
+```
+
+---
+
+## 💻 Persyaratan Sistem (Prerequisites)
+
+Sebelum menjalankan aplikasi, pastikan komputer Anda telah terinstal:
+- **Node.js**: Versi `18.x` atau lebih baru (Disarankan versi LTS).
+- **npm** (Node Package Manager) versi `9.x` atau yang lebih baru (otomatis terpasang bersama Node.js) atau **yarn** / **pnpm**.
+- **Browser Modern**: Google Chrome, Microsoft Edge, Mozilla Firefox, Brave, atau Safari versi terbaru.
+
+Untuk mengecek instalasi Node.js dan npm di terminal:
+```bash
+node -v
+npm -v
+```
+
+---
+
+## 🚀 Cara Instalasi & Menjalankan Aplikasi
+
+Ikuti langkah-langkah mudah berikut untuk menjalankan aplikasi di komputer lokal:
+
+### 1. Masuk ke Direktori Project
+Buka Terminal / Command Prompt / PowerShell, lalu arahkan ke folder proyek:
+```bash
+cd "lokasi proyek"
+```
+
+### 2. Install Dependensi Proyek
+Jalankan perintah berikut untuk mengunduh seluruh package yang dibutuhkan:
+```bash
+npm install
+```
+
+### 3. Jalankan Development Server
+Mulai server pengembangan lokal dengan perintah:
+```bash
+npm run dev
+```
+
+Output di terminal akan menampilkan URL lokal seperti berikut:
+```text
+ VITE v5.x.x ready in 250 ms
+
+ ➜ Local: http://localhost:5173/
+ ➜ Network: use --host to expose
+```
+
+### 4. Buka Aplikasi di Browser
+Buka peramban web Anda dan akses alamat:
+👉 **[http://localhost:5173/](http://localhost:5173/)**
+
+---
+
+## 📦 Build untuk Produksi (Production Build)
+
+Jika Anda ingin membuat bundle produksi yang telah dioptimasi (*minified & compressed*):
+
+```bash
+# Membuat bundle produksi di folder /dist
+npm run build
+
+# Menjalankan preview lokal dari hasil build produksi
+npm run preview
+```
+
+Hasil build di folder `dist/` berupa file HTML, JS, dan CSS statis murni yang siap di-*deploy* ke hosting statis seperti **Vercel**, **Netlify**, **GitHub Pages**, atau **Cloudflare Pages**.
+
+---
+
+## 🔄 Alur State Management & Penyimpanan Data
+
+Aplikasi menggunakan **Zustand** yang dikombinasikan dengan middleware `persist`. Seluruh perubahan data langsung disinkronkan ke `localStorage` browser pengguna dengan kunci:
+`tour-planner-storage`
+
+```mermaid
+graph TD
+ A[Pengguna Berinteraksi di UI] --> B[Memanggil Action di Zustand Store]
+ B --> C[State Terupdate Secara Reaktif di Komponen]
+ B --> D[Middleware Persist Menyimpan State ke localStorage]
+ D --> E[(Browser localStorage)]
+ E -.->|Saat Halaman Direfresh / Dibuka Kembali| B
+```
+
+### Struktur Skema Data:
+```javascript
+{
+ trip: {
+ id: 'dest-bali-01',
+ name: 'Liburan Impian ke Bali',
+ destination: 'Bali (Island of Gods)',
+ location: 'Bali, Indonesia',
+ startDate: '2026-08-27',
+ endDate: '2026-08-30',
+ totalDays: 4,
+ coverImage: 'https://images.unsplash.com/...',
+ type: 'preset', // 'preset' | 'custom'
+ category: 'pantai'
+ },
+ schedule: [
+ {
+ dayNumber: 1,
+ date: '2026-08-27',
+ activities: [
+ {
+ id: 'act-uuid',
+ time: '08:00',
+ name: 'Sarapan Pagi di Kafe Pantai',
+ location: 'Sanur Beach',
+ duration: 60,
+ category: 'makan',
+ notes: 'Mencoba menu lokal'
+ }
+ ]
+ }
+ ],
+ packingList: {
+ Pakaian: [{ id: 'item-1', name: 'Baju renang', qty: 2, checked: true }],
+ Dokumen: [{ id: 'item-2', name: 'KTP / Paspor', qty: 1, checked: false }],
+ Elektronik: [],
+ 'Obat-obatan': [],
+ Toiletries: [],
+ Lainnya: []
+ },
+ budget: {
+ total: 5000000,
+ currency: 'IDR',
+ expenses: [
+ {
+ id: 'exp-1',
+ name: 'Tiket Pesawat PP',
+ amount: 1800000,
+ category: 'transportasi',
+ day: 1,
+ notes: 'Penerbangan pagi'
+ }
+ ]
+ }
+}
+```
+
+---
+
+## 📖 Panduan Penggunaan Fitur
+
+1. **Memulai Perjalanan Baru:**
+ - Masuk ke halaman utama (*Home*).
+ - Pilih salah satu destinasi kartu wisata atau klik tombol **"Buat Trip Kustom"**.
+ - Tentukan nama rencana trip, tanggal mulai & selesai (durasi hari otomatis dihitung), dan target budget.
+ - Klik **"Mulai Buat Jadwal"**.
+
+2. **Menyusun Jadwal Harian:**
+ - Pilih tab **Hari 1, Hari 2, dst.**
+ - Klik tombol **"Tambah Aktivitas"** atau klik **Template Cepat** (*Sarapan, Check-in, Wisata, dll.*).
+ - Ubah urutan aktivitas dengan cara men-drag icon titik di sebelah kiri kartu aktivitas.
+
+3. **Mengelola Checklist Barang Bawaan:**
+ - Buka menu **Packing List**.
+ - Template barang akan otomatis terisi sesuai kategori trip (dapat diganti melalui tombol *Template Bawaan*).
+ - Klik pada item untuk menandai barang yang sudah masuk ke koper.
+ - Gunakan tombol `+` / `-` untuk menyesuaikan jumlah barang.
+
+4. **Memantau Pengeluaran & Anggaran:**
+ - Buka menu **Budget**.
+ - Klik tombol **"Ubah"** pada Target Total Budget untuk menyesuaikan batas anggaran.
+ - Klik **"Tambah Biaya"** untuk mencatat tiket, hotel, makanan, atau belanja oleh-oleh.
+ - Pantau grafik lingkaran (*Pie Chart*) untuk melihat kategori yang paling banyak menyerap anggaran.
+
+---
+
+## 📄 Lisensi
+
+Proyek ini dilisensikan di bawah **MIT License**. Bebas dikembangkan dan dimodifikasi untuk keperluan edukasi, portofolio, maupun produksi.
+
+---
+
+*Ready to Plan Tour (RPT) — Rencanakan Liburan Impianmu dengan Mudah, Cerdas, dan Terstruktur.*
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..cb61356
--- /dev/null
+++ b/index.html
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+ Ready to Plan Tour (RPT) — Smart Trip & Budget Planner
+
+
+
+
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..6f4f9da
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1925 @@
+{
+ "name": "temp-init",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "temp-init",
+ "version": "0.0.0",
+ "dependencies": {
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
+ "date-fns": "^4.4.0",
+ "framer-motion": "^13.1.1",
+ "lucide-react": "^1.34.0",
+ "react": "^19.2.8",
+ "react-dom": "^19.2.8",
+ "react-router-dom": "^7.18.2",
+ "recharts": "^3.10.1",
+ "zustand": "^5.0.15"
+ },
+ "devDependencies": {
+ "@types/react": "^19.2.18",
+ "@types/react-dom": "^19.2.4",
+ "@vitejs/plugin-react": "^6.1.0",
+ "oxlint": "^1.79.0",
+ "vite": "^8.2.2"
+ }
+ },
+ "node_modules/@dnd-kit/accessibility": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
+ "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/core": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
+ "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/accessibility": "^3.1.1",
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/sortable": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
+ "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "@dnd-kit/core": "^6.3.0",
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/utilities": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
+ "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.147.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz",
+ "integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@oxlint/binding-android-arm-eabi": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.80.0.tgz",
+ "integrity": "sha512-RM3Plj+biQpxa5d1GOOX6ciDlcUROmm4OZ/pLTpitkQt2mJv4jhtY4cbgaetOm5UKWZe05/TGQ6o1Vl8EOHkrA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-android-arm64": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.80.0.tgz",
+ "integrity": "sha512-YlO5JEf0Yr2bUUlu8O8daVcUxtcGGbcSmyV7E7nSbJbfAdxTE0PFPwgnIlw7wXJaTYjb+qs5hI5q3jxUkI7cAw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-darwin-arm64": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.80.0.tgz",
+ "integrity": "sha512-BULDOyO3AhsmdWfQeIUCykDt3dd7XZBGLhp1eIh56skRv01O+cNjNPwXMIbeW1x4+pxcln5if72wcRgViVo7PA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-darwin-x64": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.80.0.tgz",
+ "integrity": "sha512-YJ4JzLw7N5TDSQFlA0hAQGHvnDZgyypm1yunObVWcWiF9KM7eGCJKYKLgTC2Fi/57OdnBhbj4OkzPGdFQJ6HyA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-freebsd-x64": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.80.0.tgz",
+ "integrity": "sha512-AYUIk5QnL0s8oWAYsREZwkRYy1SupJTXALo93J1TgzHywxQtdM99FecRMQ87MXEdPQ0j1TmEpeeq3fGNkpvMqg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-arm-gnueabihf": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.80.0.tgz",
+ "integrity": "sha512-9hBZVANupQ89W9dXyE0n8doCyaW5pDyGn3y6XlIMPZ+rIKuyqkr3SNUXmVJIhuvUq0NBU3RBiSXXE69l4XI6KA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-arm-musleabihf": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.80.0.tgz",
+ "integrity": "sha512-SvS2uKqzY+pbfuvAHzH4338R6Zwo805GAwrIMVvK1KxoOWCIjZUdfzTCvilD7z6JK91v011+zYMryabhDo2AsQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-arm64-gnu": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.80.0.tgz",
+ "integrity": "sha512-tCLadyqRVL3pQTRPNg7cjXKvcvS4fbyXeQHhKk5BTJ1oftQln5/yIIWbu/Xom/DX41zv2P9QGt6+D/TtQVtY3A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-arm64-musl": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.80.0.tgz",
+ "integrity": "sha512-XfpCNRlOPcLlJl4Bn/FUhjqlR6BVavEykERBf/MV7YA9VZDa5g5znVqYhyviMafcxS9Pe/i/kPvHNO0U6svEHQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-ppc64-gnu": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.80.0.tgz",
+ "integrity": "sha512-3I4yMwcFG9NeO8ioY6JBBuKsIm5GL/x7MATt1S4tVWaxPu5HcJ+XnLUbcVBTxG8q2Wu56HSj+NmXQiVYb1lp6A==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-riscv64-gnu": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.80.0.tgz",
+ "integrity": "sha512-E1wAKymkpe1/E8helzBKdm81OBOF+ezxRyXRMEuik3ZpWDER5CPOKZwF66RsdwW98uwZv8UTFremUQtC1CzdJA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-riscv64-musl": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.80.0.tgz",
+ "integrity": "sha512-+gLRGD4sIo3+VA++iham5UxD9tKSoJ/VOrROCEXIcknrYtQg6iIQgvjN0cpiRF7N6UYC7pJbvHJlDnMge5LRpQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-s390x-gnu": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.80.0.tgz",
+ "integrity": "sha512-aR0PrzHj9leW3NmzBAAP4EzdoBNoJcs9sjnIQPIwyRnBGYrRbXUIpEB5Q39AqK3PLY5JK5uEhDQDiUa1QSAstw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-x64-gnu": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.80.0.tgz",
+ "integrity": "sha512-vSVh5cSo3Xxs6ghBCcFJlpbkbENzDog1qXtoXLa/HC3aCrR4XO76GZbXmQoCPHnu99nQpdCeC3H9tdNICfDh7A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-x64-musl": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.80.0.tgz",
+ "integrity": "sha512-FfzBXpNQ8u7/ZI/p8bl73MeZ508Ax3hxWp3SiJpEFiC+BB9XcXy5FAZHTLKDPSzrUpxQZSZJAVdDmuJp/+HDBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-openharmony-arm64": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.80.0.tgz",
+ "integrity": "sha512-zMzbkumtmprCgRwoYNzcB3iC39fXdJIMLMU33KdCjEGLlJGOEt1+LwQ4LF8ndLzAEKVz4BR0y3V6Xrkk3Nm3yA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-win32-arm64-msvc": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.80.0.tgz",
+ "integrity": "sha512-ib6iRcrXsk4t1fm3iKcwksyWh1ZkZXC/2mEzakl0ai2+6HZunf1WWMZ/xP9EJAvw9g9K4UVTC3NF/+G2qLrbTQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-win32-ia32-msvc": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.80.0.tgz",
+ "integrity": "sha512-xhRWBMpLxZvgKAH6+DJZmpP+W8Y8UdQOSU1JfxSWNXsaBaRGW77j+1hCuNHlzj7OH4SPN8fYd1q0o2qrDtoVyw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-win32-x64-msvc": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.80.0.tgz",
+ "integrity": "sha512-yAnO7lwBYQnz2pcfBPIGQQZWIX5zd5R/1aAKIF3oE+TVj7IhoHcROjOkz3sRDngzqhfPKfFaXqug5j5rE5dn6Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@reduxjs/toolkit": {
+ "version": "2.12.0",
+ "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
+ "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.0.0",
+ "@standard-schema/utils": "^0.3.0",
+ "immer": "^11.0.0",
+ "redux": "^5.0.1",
+ "redux-thunk": "^3.1.0",
+ "reselect": "^5.1.0"
+ },
+ "peerDependencies": {
+ "react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
+ "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-redux": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm-eabi": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz",
+ "integrity": "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.6.tgz",
+ "integrity": "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.6.tgz",
+ "integrity": "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.6.tgz",
+ "integrity": "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.6.tgz",
+ "integrity": "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.6.tgz",
+ "integrity": "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.6.tgz",
+ "integrity": "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.6.tgz",
+ "integrity": "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.6.tgz",
+ "integrity": "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.6.tgz",
+ "integrity": "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.6.tgz",
+ "integrity": "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.6.tgz",
+ "integrity": "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.6.tgz",
+ "integrity": "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.6.tgz",
+ "integrity": "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.6.tgz",
+ "integrity": "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/utils": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
+ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-array": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
+ "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-color": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-ease": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
+ "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-interpolate": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+ "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-color": "*"
+ }
+ },
+ "node_modules/@types/d3-path": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
+ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-scale": {
+ "version": "4.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
+ "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-time": "*"
+ }
+ },
+ "node_modules/@types/d3-shape": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz",
+ "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-path": "*"
+ }
+ },
+ "node_modules/@types/d3-time": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
+ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-timer": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
+ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.18",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
+ "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.5",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.5.tgz",
+ "integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/@types/use-sync-external-store": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
+ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
+ "license": "MIT"
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.0.tgz",
+ "integrity": "sha512-qd2BzUBehkov86WFhg0JkEFEYyCLG9uPCe6qWTY/kRlss9OvJrOF2UbIWT7p+8IzZHkEu0DNGHc4HSv+JdDLsw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
+ "babel-plugin-react-compiler": "^1.0.0",
+ "oxc-transform-react": "^0.145.0",
+ "vite": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@rolldown/plugin-babel": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ },
+ "oxc-transform-react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/d3-array": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
+ "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
+ "license": "ISC",
+ "dependencies": {
+ "internmap": "1 - 2"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-color": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-ease": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+ "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-format": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
+ "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-interpolate": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-path": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
+ "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-scale": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
+ "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2.10.0 - 3",
+ "d3-format": "1 - 3",
+ "d3-interpolate": "1.2.0 - 3",
+ "d3-time": "2.1.1 - 3",
+ "d3-time-format": "2 - 4"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-shape": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
+ "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-path": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
+ "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time-format": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
+ "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-time": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-timer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/date-fns": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz",
+ "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/kossnocorp"
+ }
+ },
+ "node_modules/decimal.js-light": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
+ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
+ "license": "MIT"
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/es-toolkit": {
+ "version": "1.51.0",
+ "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.51.0.tgz",
+ "integrity": "sha512-zC2lQGkM7QX+Gm6iM3+WIdZJzthsEd14LvRNJneSO2hzyz/zNBENR8+YXWo1cKxgPBtV6ksPYHELbcwBRzmdCw==",
+ "license": "MIT",
+ "workspaces": [
+ "docs",
+ "benchmarks",
+ "tests/types",
+ "tests/browser-compat"
+ ]
+ },
+ "node_modules/eventemitter3": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
+ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
+ "license": "MIT"
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/framer-motion": {
+ "version": "13.1.1",
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-13.1.1.tgz",
+ "integrity": "sha512-B/xn2TPS4f61cEBLFjiYlQFnBZUW1YVj/LM+C+N4OP8Rs95VLEI2ot/RlfBg111la/EiyECFaJJi/A3FWA8MUA==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-dom": "^13.1.1",
+ "motion-utils": "^13.0.0",
+ "tslib": "^2.4.0"
+ },
+ "peerDependencies": {
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/immer": {
+ "version": "11.1.18",
+ "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz",
+ "integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/immer"
+ }
+ },
+ "node_modules/internmap": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
+ "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.33.0",
+ "lightningcss-darwin-arm64": "1.33.0",
+ "lightningcss-darwin-x64": "1.33.0",
+ "lightningcss-freebsd-x64": "1.33.0",
+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
+ "lightningcss-linux-arm64-gnu": "1.33.0",
+ "lightningcss-linux-arm64-musl": "1.33.0",
+ "lightningcss-linux-x64-gnu": "1.33.0",
+ "lightningcss-linux-x64-musl": "1.33.0",
+ "lightningcss-win32-arm64-msvc": "1.33.0",
+ "lightningcss-win32-x64-msvc": "1.33.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lucide-react": {
+ "version": "1.34.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.34.0.tgz",
+ "integrity": "sha512-vnjGJNI7Htk5+oWW8gXGuaLgwgAb0T6/iZbBrp9JCfRFwdNWZ0YTm3eyxjOLgwN6r8iyAf3UA70zNmBRBNv7yg==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/motion-dom": {
+ "version": "13.1.1",
+ "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-13.1.1.tgz",
+ "integrity": "sha512-XSf8VYWSB6G/0IY3rWVbyLcxWXtAVHkN1PQE2agTaCv3u8RGvbwu56TyyR/MNzBqqNavEBTZzErcxI1TxBrjcA==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-utils": "^13.0.0"
+ }
+ },
+ "node_modules/motion-utils": {
+ "version": "13.0.0",
+ "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-13.0.0.tgz",
+ "integrity": "sha512-7DnN7TmbLcYXcG4RVadXIihWlyuM9afoUww8Y5Agg431kGKiuL2/OMyP4mJ5wLz+pvN3t5ySClLOaVXJ+wekRQ==",
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.18",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
+ "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/oxlint": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.80.0.tgz",
+ "integrity": "sha512-5nTiSps4qdbCWLbxzuO00alHkEO2exR9YMN/ig6QXWrLsYSG0KaObOAM+l6oU2LcKPWoSAGYbkZIGEu1ViiWKA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "oxlint": "bin/oxlint"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ },
+ "optionalDependencies": {
+ "@oxlint/binding-android-arm-eabi": "1.80.0",
+ "@oxlint/binding-android-arm64": "1.80.0",
+ "@oxlint/binding-darwin-arm64": "1.80.0",
+ "@oxlint/binding-darwin-x64": "1.80.0",
+ "@oxlint/binding-freebsd-x64": "1.80.0",
+ "@oxlint/binding-linux-arm-gnueabihf": "1.80.0",
+ "@oxlint/binding-linux-arm-musleabihf": "1.80.0",
+ "@oxlint/binding-linux-arm64-gnu": "1.80.0",
+ "@oxlint/binding-linux-arm64-musl": "1.80.0",
+ "@oxlint/binding-linux-ppc64-gnu": "1.80.0",
+ "@oxlint/binding-linux-riscv64-gnu": "1.80.0",
+ "@oxlint/binding-linux-riscv64-musl": "1.80.0",
+ "@oxlint/binding-linux-s390x-gnu": "1.80.0",
+ "@oxlint/binding-linux-x64-gnu": "1.80.0",
+ "@oxlint/binding-linux-x64-musl": "1.80.0",
+ "@oxlint/binding-openharmony-arm64": "1.80.0",
+ "@oxlint/binding-win32-arm64-msvc": "1.80.0",
+ "@oxlint/binding-win32-ia32-msvc": "1.80.0",
+ "@oxlint/binding-win32-x64-msvc": "1.80.0"
+ },
+ "peerDependencies": {
+ "oxlint-tsgolint": ">=7.0.2001",
+ "vite-plus": "*"
+ },
+ "peerDependenciesMeta": {
+ "oxlint-tsgolint": {
+ "optional": true
+ },
+ "vite-plus": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.26",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
+ "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.17",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
+ "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
+ "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.8"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz",
+ "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==",
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/react-redux": {
+ "version": "9.3.0",
+ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
+ "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/use-sync-external-store": "^0.0.6",
+ "use-sync-external-store": "^1.4.0"
+ },
+ "peerDependencies": {
+ "@types/react": "^18.2.25 || ^19",
+ "react": "^18.0 || ^19",
+ "redux": "^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "redux": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-router": {
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
+ "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "^1.0.1",
+ "set-cookie-parser": "^2.6.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz",
+ "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==",
+ "license": "MIT",
+ "dependencies": {
+ "react-router": "7.18.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ }
+ },
+ "node_modules/recharts": {
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz",
+ "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==",
+ "license": "MIT",
+ "workspaces": [
+ "www"
+ ],
+ "dependencies": {
+ "@reduxjs/toolkit": "^1.9.0 || 2.x.x",
+ "clsx": "^2.1.1",
+ "decimal.js-light": "^2.5.1",
+ "es-toolkit": "^1.39.3",
+ "eventemitter3": "^5.0.1",
+ "immer": "^11.1.8",
+ "react-redux": "8.x.x || 9.x.x",
+ "reselect": "5.2.0",
+ "tiny-invariant": "^1.3.3",
+ "use-sync-external-store": "^1.2.2",
+ "victory-vendor": "^37.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/redux": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
+ "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
+ "license": "MIT"
+ },
+ "node_modules/redux-thunk": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
+ "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "redux": "^5.0.0"
+ }
+ },
+ "node_modules/reselect": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
+ "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
+ "license": "MIT"
+ },
+ "node_modules/rolldown": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz",
+ "integrity": "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.147.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm-eabi": "1.2.6",
+ "@rolldown/binding-android-arm64": "1.2.6",
+ "@rolldown/binding-darwin-arm64": "1.2.6",
+ "@rolldown/binding-darwin-x64": "1.2.6",
+ "@rolldown/binding-freebsd-x64": "1.2.6",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.6",
+ "@rolldown/binding-linux-arm64-gnu": "1.2.6",
+ "@rolldown/binding-linux-arm64-musl": "1.2.6",
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.6",
+ "@rolldown/binding-linux-s390x-gnu": "1.2.6",
+ "@rolldown/binding-linux-x64-gnu": "1.2.6",
+ "@rolldown/binding-linux-x64-musl": "1.2.6",
+ "@rolldown/binding-openharmony-arm64": "1.2.6",
+ "@rolldown/binding-win32-arm64-msvc": "1.2.6",
+ "@rolldown/binding-win32-x64-msvc": "1.2.6"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/set-cookie-parser": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
+ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
+ "license": "MIT"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tiny-invariant": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
+ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
+ "license": "MIT"
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/victory-vendor": {
+ "version": "37.3.6",
+ "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
+ "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
+ "license": "MIT AND ISC",
+ "dependencies": {
+ "@types/d3-array": "^3.0.3",
+ "@types/d3-ease": "^3.0.0",
+ "@types/d3-interpolate": "^3.0.1",
+ "@types/d3-scale": "^4.0.2",
+ "@types/d3-shape": "^3.1.0",
+ "@types/d3-time": "^3.0.0",
+ "@types/d3-timer": "^3.0.0",
+ "d3-array": "^3.1.6",
+ "d3-ease": "^3.0.1",
+ "d3-interpolate": "^3.0.1",
+ "d3-scale": "^4.0.2",
+ "d3-shape": "^3.1.0",
+ "d3-time": "^3.0.0",
+ "d3-timer": "^3.0.1"
+ }
+ },
+ "node_modules/vite": {
+ "version": "8.2.2",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz",
+ "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.33.0",
+ "picomatch": "^4.0.5",
+ "postcss": "^8.5.26",
+ "rolldown": "~1.2.4",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.4.0 || ^0.5.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/zustand": {
+ "version": "5.0.15",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz",
+ "integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18.0.0",
+ "immer": ">=9.0.6",
+ "react": ">=18.0.0",
+ "use-sync-external-store": ">=1.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "use-sync-external-store": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..d0dfe57
--- /dev/null
+++ b/package.json
@@ -0,0 +1,32 @@
+{
+ "name": "temp-init",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "lint": "oxlint",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
+ "date-fns": "^4.4.0",
+ "framer-motion": "^13.1.1",
+ "lucide-react": "^1.34.0",
+ "react": "^19.2.8",
+ "react-dom": "^19.2.8",
+ "react-router-dom": "^7.18.2",
+ "recharts": "^3.10.1",
+ "zustand": "^5.0.15"
+ },
+ "devDependencies": {
+ "@types/react": "^19.2.18",
+ "@types/react-dom": "^19.2.4",
+ "@vitejs/plugin-react": "^6.1.0",
+ "oxlint": "^1.79.0",
+ "vite": "^8.2.2"
+ }
+}
diff --git a/public/favicon.svg b/public/favicon.svg
new file mode 100644
index 0000000..6893eb1
--- /dev/null
+++ b/public/favicon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/icons.svg b/public/icons.svg
new file mode 100644
index 0000000..e952219
--- /dev/null
+++ b/public/icons.svg
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/App.jsx b/src/App.jsx
new file mode 100644
index 0000000..178dad6
--- /dev/null
+++ b/src/App.jsx
@@ -0,0 +1,32 @@
+import React from 'react';
+import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
+import Navbar from './components/layout/Navbar';
+import Footer from './components/layout/Footer';
+import ToastContainer from './components/ui/ToastContainer';
+import Home from './pages/Home';
+import Schedule from './pages/Schedule';
+import PackingList from './pages/PackingList';
+import Budget from './pages/Budget';
+
+function App() {
+ return (
+
+
+
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+
+
+
+ );
+}
+
+export default App;
diff --git a/src/assets/hero.png b/src/assets/hero.png
new file mode 100644
index 0000000..02251f4
Binary files /dev/null and b/src/assets/hero.png differ
diff --git a/src/assets/react.svg b/src/assets/react.svg
new file mode 100644
index 0000000..6c87de9
--- /dev/null
+++ b/src/assets/react.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/vite.svg b/src/assets/vite.svg
new file mode 100644
index 0000000..5101b67
--- /dev/null
+++ b/src/assets/vite.svg
@@ -0,0 +1 @@
+Vite
diff --git a/src/components/budget/AddExpenseModal.jsx b/src/components/budget/AddExpenseModal.jsx
new file mode 100644
index 0000000..6cc4a9a
--- /dev/null
+++ b/src/components/budget/AddExpenseModal.jsx
@@ -0,0 +1,180 @@
+import React, { useState, useEffect } from 'react';
+import { DollarSign, Tag, Calendar, FileText, Plus, Check } from 'lucide-react';
+import Modal from '../ui/Modal';
+import Button from '../ui/Button';
+import { formatCurrency } from '../../utils/formatCurrency';
+
+const BUDGET_CATEGORY_OPTIONS = [
+ { id: 'transportasi', label: 'Transportasi' },
+ { id: 'akomodasi', label: 'Akomodasi & Hotel' },
+ { id: 'makan', label: 'Kuliner & Makan' },
+ { id: 'aktivitas', label: 'Tiket & Aktivitas' },
+ { id: 'oleholeh', label: 'Oleh-oleh & Belanja' },
+ { id: 'lainnya', label: 'Lain-lain' },
+];
+
+const AddExpenseModal = ({
+ isOpen,
+ onClose,
+ onSave,
+ initialData = null,
+ totalDays = 1,
+ currency = 'IDR',
+}) => {
+ const isEditing = Boolean(initialData?.id);
+
+ const [name, setName] = useState('');
+ const [amount, setAmount] = useState('');
+ const [category, setCategory] = useState('makan');
+ const [day, setDay] = useState(''); // '' means General / Umum
+ const [notes, setNotes] = useState('');
+
+ useEffect(() => {
+ if (initialData) {
+ setName(initialData.name || '');
+ setAmount(initialData.amount ? initialData.amount.toString() : '');
+ setCategory(initialData.category || 'makan');
+ setDay(initialData.day ? initialData.day.toString() : '');
+ setNotes(initialData.notes || '');
+ } else {
+ setName('');
+ setAmount('');
+ setCategory('makan');
+ setDay('');
+ setNotes('');
+ }
+ }, [initialData, isOpen]);
+
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ if (!name.trim() || !amount) return;
+
+ onSave({
+ id: initialData?.id,
+ name: name.trim(),
+ amount: Number(amount) || 0,
+ category,
+ day: day ? Number(day) : null,
+ notes: notes.trim(),
+ });
+
+ onClose();
+ };
+
+ return (
+
+
+ Batal
+
+
+ {isEditing ? 'Simpan Perubahan' : 'Catat Pengeluaran'}
+
+ >
+ }
+ >
+
+
+ );
+};
+
+export default AddExpenseModal;
diff --git a/src/components/budget/BudgetChart.jsx b/src/components/budget/BudgetChart.jsx
new file mode 100644
index 0000000..dd566f6
--- /dev/null
+++ b/src/components/budget/BudgetChart.jsx
@@ -0,0 +1,256 @@
+import React, { useState } from 'react';
+import {
+ ResponsiveContainer,
+ PieChart,
+ Pie,
+ Cell,
+ Tooltip,
+ BarChart,
+ Bar,
+ XAxis,
+ YAxis,
+} from 'recharts';
+import { PieChart as PieIcon, BarChart3, Tag, Calendar } from 'lucide-react';
+import { formatCurrency } from '../../utils/formatCurrency';
+
+const CATEGORY_COLORS = {
+ transportasi: '#3b82f6',
+ akomodasi: '#a855f7',
+ makan: '#22c55e',
+ aktivitas: '#eab308',
+ oleholeh: '#ec4899',
+ lainnya: '#94a3b8',
+};
+
+const CATEGORY_LABELS = {
+ transportasi: 'Transportasi',
+ akomodasi: 'Akomodasi',
+ makan: 'Kuliner & Makan',
+ aktivitas: 'Tiket & Aktivitas',
+ oleholeh: 'Oleh-oleh',
+ lainnya: 'Lain-lain',
+};
+
+const CustomTooltip = ({ active, payload, currency = 'IDR' }) => {
+ if (active && payload && payload.length) {
+ const data = payload[0];
+ return (
+
+
+ {data.payload.name || data.name}
+
+
+ {formatCurrency(data.value, currency)}
+
+
+ );
+ }
+ return null;
+};
+
+const BudgetChart = ({
+ categoryBreakdown = {},
+ dayBreakdown = {},
+ currency = 'IDR',
+ totalSpent = 0,
+}) => {
+ const [chartView, setChartView] = useState('category'); // 'category' | 'day'
+
+ // Prepare Pie Chart data
+ const pieData = Object.entries(categoryBreakdown)
+ .filter(([_, value]) => value > 0)
+ .map(([key, value]) => ({
+ key,
+ name: CATEGORY_LABELS[key] || key,
+ value: Number(value),
+ color: CATEGORY_COLORS[key] || '#94a3b8',
+ }));
+
+ // Prepare Bar Chart data
+ const barData = Object.entries(dayBreakdown).map(([dayKey, value]) => ({
+ name: dayKey,
+ amount: Number(value),
+ }));
+
+ if (totalSpent === 0) {
+ return null;
+ }
+
+ return (
+
+ {/* Chart Header & Toggle Switch */}
+
+
+
+ {chartView === 'category' ?
:
}
+
+
+
+ Visualisasi Distribusi Pengeluaran
+
+
+ {chartView === 'category' ? 'Berdasarkan pos kategori pengeluaran' : 'Berdasarkan pengeluaran per hari'}
+
+
+
+
+ {/* View Toggle */}
+
+ setChartView('category')}
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ gap: '4px',
+ padding: '4px 12px',
+ borderRadius: 'var(--radius-full)',
+ fontSize: 'var(--text-xs)',
+ fontWeight: 600,
+ cursor: 'pointer',
+ transition: 'all var(--transition-fast)',
+ backgroundColor: chartView === 'category' ? 'var(--color-primary)' : 'transparent',
+ color: chartView === 'category' ? '#ffffff' : 'var(--color-text-secondary)',
+ }}
+ >
+
+ Kategori
+
+
+ setChartView('day')}
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ gap: '4px',
+ padding: '4px 12px',
+ borderRadius: 'var(--radius-full)',
+ fontSize: 'var(--text-xs)',
+ fontWeight: 600,
+ cursor: 'pointer',
+ transition: 'all var(--transition-fast)',
+ backgroundColor: chartView === 'day' ? 'var(--color-primary)' : 'transparent',
+ color: chartView === 'day' ? '#ffffff' : 'var(--color-text-secondary)',
+ }}
+ >
+
+ Per Hari
+
+
+
+
+ {/* Chart Canvas */}
+
+ {chartView === 'category' ? (
+
+
+
+ {pieData.map((entry, index) => (
+ |
+ ))}
+
+ } />
+
+
+ ) : (
+
+
+
+ (val >= 1000000 ? `${(val / 1000000).toFixed(1)}M` : `${(val / 1000).toFixed(0)}k`)}
+ />
+ } />
+
+
+
+ )}
+
+
+ {/* Category Legends for Pie */}
+ {chartView === 'category' && (
+
+ {pieData.map((item) => {
+ const pct = Math.round((item.value / totalSpent) * 100);
+ return (
+
+
+ {item.name}
+ ({pct}%)
+
+ );
+ })}
+
+ )}
+
+ );
+};
+
+export default BudgetChart;
diff --git a/src/components/budget/BudgetOverview.jsx b/src/components/budget/BudgetOverview.jsx
new file mode 100644
index 0000000..94ad98e
--- /dev/null
+++ b/src/components/budget/BudgetOverview.jsx
@@ -0,0 +1,281 @@
+import React, { useState } from 'react';
+import { motion } from 'framer-motion';
+import {
+ Wallet,
+ TrendingDown,
+ PiggyBank,
+ AlertTriangle,
+ Pencil,
+ Check,
+ Plus,
+ ArrowUpRight,
+} from 'lucide-react';
+import ProgressBar from '../ui/ProgressBar';
+import Button from '../ui/Button';
+import { formatCurrency } from '../../utils/formatCurrency';
+
+const BudgetOverview = ({
+ totalBudget = 0,
+ totalSpent = 0,
+ remainingBudget = 0,
+ percentUsed = 0,
+ isOverBudget = false,
+ currency = 'IDR',
+ onUpdateTotalBudget,
+ onOpenAddExpense,
+}) => {
+ const [isEditingBudget, setIsEditingBudget] = useState(false);
+ const [budgetInput, setBudgetInput] = useState(totalBudget.toString());
+
+ const handleSaveBudget = (e) => {
+ e.preventDefault();
+ const val = Number(budgetInput) || 0;
+ onUpdateTotalBudget(val);
+ setIsEditingBudget(false);
+ };
+
+ const getRemainingStatus = () => {
+ if (isOverBudget) {
+ return { text: 'Over Budget!', color: 'var(--color-danger)', bg: 'var(--color-danger-bg)' };
+ }
+ if (percentUsed >= 80) {
+ return { text: 'Mendekati Batas', color: 'var(--color-warning)', bg: 'var(--color-warning-bg)' };
+ }
+ return { text: 'Aman & Terkendali', color: 'var(--color-success)', bg: 'var(--color-success-bg)' };
+ };
+
+ const remainingStatus = getRemainingStatus();
+
+ return (
+
+ {/* 3 Overview Stat Cards Grid */}
+
+ {/* Card 1: Total Budget */}
+
+
+
+ Target Total Budget
+
+
{
+ setBudgetInput(totalBudget.toString());
+ setIsEditingBudget(!isEditingBudget);
+ }}
+ style={{
+ color: 'var(--color-primary-light)',
+ backgroundColor: 'hsla(220, 90%, 56%, 0.15)',
+ padding: '4px 8px',
+ borderRadius: 'var(--radius-md)',
+ fontSize: '11px',
+ fontWeight: 600,
+ display: 'flex',
+ alignItems: 'center',
+ gap: '4px',
+ }}
+ >
+ {isEditingBudget ? 'Tutup' : 'Ubah'}
+
+
+
+ {isEditingBudget ? (
+
+ setBudgetInput(e.target.value)}
+ autoFocus
+ style={{ padding: 'var(--space-2) var(--space-3)', fontSize: 'var(--text-sm)' }}
+ />
+
+ Simpan
+
+
+ ) : (
+
+ {formatCurrency(totalBudget, currency)}
+
+ )}
+
+
+ Alokasi dana keseluruhan trip
+
+
+
+ {/* Card 2: Total Spent */}
+
+
+
+ Total Terpakai
+
+
+ {percentUsed}% Terpakai
+
+
+
+
+ {formatCurrency(totalSpent, currency)}
+
+
+
+ Pengeluaran yang tercatat saat ini
+
+
+
+ {/* Card 3: Remaining Budget */}
+
+
+
+ Sisa Budget
+
+
+ {remainingStatus.text}
+
+
+
+
+ {formatCurrency(remainingBudget, currency)}
+
+
+
+ {isOverBudget ? 'Melebihi alokasi anggaran!' : 'Sisa saldo yang dapat dibelanjakan'}
+
+
+
+
+ {/* Progress & Alert Bar */}
+
+
+
+ Persentase Anggaran Terpakai
+
+
+ Tambah Pengeluaran
+
+
+
+
= 80 ? 'warning' : 'primary'}
+ />
+
+ {/* Warning Banner if Over Budget */}
+ {isOverBudget && (
+
+
+
+ Perhatian: Pengeluaran Anda melebihi target anggaran sebesar{' '}
+ {formatCurrency(Math.abs(remainingBudget), currency)} . Evaluasi kembali pos belanja Anda.
+
+
+ )}
+
+
+ );
+};
+
+export default BudgetOverview;
diff --git a/src/components/budget/ExpenseList.jsx b/src/components/budget/ExpenseList.jsx
new file mode 100644
index 0000000..eb2e1fb
--- /dev/null
+++ b/src/components/budget/ExpenseList.jsx
@@ -0,0 +1,330 @@
+import React, { useState } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import {
+ WalletCards,
+ Pencil,
+ Trash2,
+ Calendar,
+ Tag,
+ FileText,
+ Plus,
+ Plane,
+ Hotel,
+ Utensils,
+ Ticket,
+ ShoppingBag,
+ MoreHorizontal,
+ DollarSign,
+} from 'lucide-react';
+import Button from '../ui/Button';
+import EmptyState from '../ui/EmptyState';
+import { formatCurrency } from '../../utils/formatCurrency';
+
+const CATEGORY_ICONS = {
+ transportasi: Plane,
+ akomodasi: Hotel,
+ makan: Utensils,
+ aktivitas: Ticket,
+ oleholeh: ShoppingBag,
+ lainnya: MoreHorizontal,
+};
+
+const CATEGORY_COLORS = {
+ transportasi: '#3b82f6',
+ akomodasi: '#a855f7',
+ makan: '#22c55e',
+ aktivitas: '#eab308',
+ oleholeh: '#ec4899',
+ lainnya: '#94a3b8',
+};
+
+const CATEGORY_LABELS = {
+ transportasi: 'Transportasi',
+ akomodasi: 'Akomodasi',
+ makan: 'Makan',
+ aktivitas: 'Aktivitas',
+ oleholeh: 'Oleh-oleh',
+ lainnya: 'Lainnya',
+};
+
+const ExpenseList = ({
+ expenses = [],
+ currency = 'IDR',
+ onEditExpense,
+ onDeleteExpense,
+ onOpenAddExpense,
+}) => {
+ const [filterCategory, setFilterCategory] = useState('semua');
+
+ const filteredExpenses = expenses.filter((exp) => {
+ if (filterCategory === 'semua') return true;
+ return exp.category === filterCategory;
+ });
+
+ return (
+
+ {/* Header & Filter Pills */}
+
+
+
+
+
+
+
+ Daftar Rincian Pengeluaran
+
+
+ {expenses.length} transaksi tercatat
+
+
+
+
+
+ Catat Pengeluaran
+
+
+
+ {/* Filter Category Tabs */}
+ {expenses.length > 0 && (
+
+ setFilterCategory('semua')}
+ style={{
+ padding: '4px 12px',
+ borderRadius: 'var(--radius-full)',
+ fontSize: 'var(--text-xs)',
+ fontWeight: 600,
+ cursor: 'pointer',
+ whiteSpace: 'nowrap',
+ transition: 'all var(--transition-fast)',
+ backgroundColor: filterCategory === 'semua' ? 'var(--color-primary)' : 'hsla(222, 18%, 18%, 0.6)',
+ color: filterCategory === 'semua' ? '#ffffff' : 'var(--color-text-secondary)',
+ border: filterCategory === 'semua' ? '1px solid var(--color-primary-light)' : '1px solid var(--color-border)',
+ }}
+ >
+ Semua ({expenses.length})
+
+
+ {Object.entries(CATEGORY_LABELS).map(([key, label]) => {
+ const count = expenses.filter((e) => e.category === key).length;
+ if (count === 0) return null;
+
+ const isSelected = filterCategory === key;
+ const color = CATEGORY_COLORS[key] || 'var(--color-primary)';
+
+ return (
+ setFilterCategory(key)}
+ style={{
+ padding: '4px 12px',
+ borderRadius: 'var(--radius-full)',
+ fontSize: 'var(--text-xs)',
+ fontWeight: 600,
+ cursor: 'pointer',
+ whiteSpace: 'nowrap',
+ transition: 'all var(--transition-fast)',
+ backgroundColor: isSelected ? color : 'hsla(222, 18%, 18%, 0.6)',
+ color: isSelected ? '#ffffff' : 'var(--color-text-secondary)',
+ border: isSelected ? `1px solid ${color}` : '1px solid var(--color-border)',
+ }}
+ >
+ {label} ({count})
+
+ );
+ })}
+
+ )}
+
+ {/* Expenses Items List */}
+ {filteredExpenses.length > 0 ? (
+
+
+ {filteredExpenses.map((exp) => {
+ const Icon = CATEGORY_ICONS[exp.category] || MoreHorizontal;
+ const catColor = CATEGORY_COLORS[exp.category] || '#94a3b8';
+ const catLabel = CATEGORY_LABELS[exp.category] || exp.category;
+
+ return (
+
+ {/* Left info: Icon & Name */}
+
+
+
+
+
+
+
+ {exp.name}
+
+
+ {catLabel}
+ •
+ {exp.day ? `Hari ke-${exp.day}` : 'Pengeluaran Umum'}
+ {exp.notes && (
+ <>
+ •
+
+ {exp.notes}
+
+ >
+ )}
+
+
+
+
+ {/* Right info: Amount & Actions */}
+
+
+
+ {formatCurrency(exp.amount, currency)}
+
+
+
+
+
onEditExpense(exp)}
+ title="Edit Pengeluaran"
+ style={{
+ width: '32px',
+ height: '32px',
+ borderRadius: 'var(--radius-md)',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ color: 'var(--color-text-secondary)',
+ backgroundColor: 'transparent',
+ transition: 'all var(--transition-fast)',
+ }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.color = 'var(--color-primary-light)';
+ e.currentTarget.style.backgroundColor = 'var(--color-primary-glow)';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.color = 'var(--color-text-secondary)';
+ e.currentTarget.style.backgroundColor = 'transparent';
+ }}
+ >
+
+
+
+
onDeleteExpense(exp.id)}
+ title="Hapus Pengeluaran"
+ style={{
+ width: '32px',
+ height: '32px',
+ borderRadius: 'var(--radius-md)',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ color: 'var(--color-text-muted)',
+ backgroundColor: 'transparent',
+ transition: 'all var(--transition-fast)',
+ }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.color = 'var(--color-danger)';
+ e.currentTarget.style.backgroundColor = 'var(--color-danger-bg)';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.color = 'var(--color-text-muted)';
+ e.currentTarget.style.backgroundColor = 'transparent';
+ }}
+ >
+
+
+
+
+
+ );
+ })}
+
+
+ ) : (
+
+ )}
+
+ );
+};
+
+export default ExpenseList;
diff --git a/src/components/layout/Footer.jsx b/src/components/layout/Footer.jsx
new file mode 100644
index 0000000..79061be
--- /dev/null
+++ b/src/components/layout/Footer.jsx
@@ -0,0 +1,157 @@
+import React from 'react';
+import { Link } from 'react-router-dom';
+import { Compass, Heart, Sparkles, MapPin, ArrowUpRight } from 'lucide-react';
+import { useTourStore } from '../../store/tourStore';
+
+const Footer = () => {
+ const trip = useTourStore((state) => state.trip);
+
+ return (
+
+
+
+ {/* Brand Info */}
+
+
+
+
+
+
+ Ready to Plan Tour (RPT)
+
+
+
+ Platform interaktif perencanaan perjalanan wisata tanpa batas. Rencanakan jadwal harian, checklist packing, dan kendalikan estimasi budget.
+
+
+
+ {/* Navigasi Cepat */}
+
+
+ Fitur Utama
+
+
+
+
+ Trip Selection
+
+
+
+
+ Daily Schedule Builder
+
+
+
+
+ Packing List Manager
+
+
+
+
+ Budget Calculator
+
+
+
+
+
+ {/* Active Trip Status */}
+
+
+ Trip Anda Saat Ini
+
+ {trip.destination ? (
+
+
+ {trip.destination}
+
+
+ {trip.name || 'Trip Wisata'} • {trip.totalDays} Hari
+
+
+ ) : (
+
+ Belum ada trip yang dipilih. Mulai pilih destinasi di halaman utama.
+
+ )}
+
+
+
+ {/* Bottom bar */}
+
+
+ © {new Date().getFullYear()} Ready to Plan Tour (RPT). Static Web Application.
+
+
+ Made with
+
+ & React.js
+
+
+
+
+ );
+};
+
+export default Footer;
diff --git a/src/components/layout/Navbar.jsx b/src/components/layout/Navbar.jsx
new file mode 100644
index 0000000..457d7c8
--- /dev/null
+++ b/src/components/layout/Navbar.jsx
@@ -0,0 +1,366 @@
+import React, { useState } from 'react';
+import { NavLink, Link, useNavigate } from 'react-router-dom';
+import { motion, AnimatePresence } from 'framer-motion';
+import {
+ Compass,
+ CalendarDays,
+ Luggage,
+ WalletCards,
+ MapPin,
+ Menu,
+ X,
+ RotateCcw,
+ AlertTriangle,
+} from 'lucide-react';
+import Modal from '../ui/Modal';
+import Button from '../ui/Button';
+import { useTourStore } from '../../store/tourStore';
+import { usePackingList } from '../../hooks/usePackingList';
+import { useBudget } from '../../hooks/useBudget';
+import { toast } from '../../store/toastStore';
+
+const Navbar = () => {
+ const navigate = useNavigate();
+ const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
+ const [isResetModalOpen, setIsResetModalOpen] = useState(false);
+
+ const trip = useTourStore((state) => state.trip);
+ const resetAll = useTourStore((state) => state.resetAll);
+ const { percentPacked } = usePackingList();
+ const { percentUsed, isOverBudget } = useBudget();
+
+ const navLinks = [
+ { to: '/', label: 'Pilih Trip', icon: Compass, end: true },
+ { to: '/schedule', label: 'Jadwal Harian', icon: CalendarDays },
+ {
+ to: '/packing',
+ label: 'Packing List',
+ icon: Luggage,
+ badge: percentPacked > 0 ? `${percentPacked}%` : null,
+ badgeVariant: percentPacked === 100 ? 'badge-success' : 'badge-primary',
+ },
+ {
+ to: '/budget',
+ label: 'Budget',
+ icon: WalletCards,
+ badge: isOverBudget ? 'Over!' : percentUsed > 0 ? `${percentUsed}%` : null,
+ badgeVariant: isOverBudget ? 'badge-danger' : 'badge-accent',
+ },
+ ];
+
+ const handleConfirmReset = () => {
+ resetAll();
+ setIsResetModalOpen(false);
+ setIsMobileMenuOpen(false);
+ toast.info('Seluruh data rencana perjalanan telah direset.');
+ navigate('/');
+ };
+
+ return (
+ <>
+
+
+ {/* Confirmation Modal for Reset */}
+ setIsResetModalOpen(false)}
+ title="Konfirmasi Reset Rencana Perjalanan"
+ maxWidth="sm"
+ footer={
+ <>
+ setIsResetModalOpen(false)}>
+ Batal
+
+
+ Ya, Reset Semua
+
+ >
+ }
+ >
+
+
+
+ Apakah Anda yakin ingin mereset rencana perjalanan ke {trip.destination} ?
+
+
+ Tindakan ini akan menghapus jadwal harian, checklist barang, dan catatan pengeluaran budget yang tersimpan di browser Anda.
+
+
+
+ >
+ );
+};
+
+export default Navbar;
diff --git a/src/components/layout/PageWrapper.jsx b/src/components/layout/PageWrapper.jsx
new file mode 100644
index 0000000..cefc7bb
--- /dev/null
+++ b/src/components/layout/PageWrapper.jsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import { motion } from 'framer-motion';
+
+const PageWrapper = ({ children, className = '', style = {}, fullWidth = false }) => {
+ return (
+
+ {children}
+
+ );
+};
+
+export default PageWrapper;
diff --git a/src/components/packing/PackingCategory.jsx b/src/components/packing/PackingCategory.jsx
new file mode 100644
index 0000000..2c95516
--- /dev/null
+++ b/src/components/packing/PackingCategory.jsx
@@ -0,0 +1,255 @@
+import React, { useState } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import {
+ ChevronDown,
+ ChevronUp,
+ Shirt,
+ FileText,
+ Smartphone,
+ HeartPulse,
+ Sparkles,
+ Package,
+ Plus,
+ CheckSquare,
+ Square,
+} from 'lucide-react';
+import PackingItem from './PackingItem';
+import Button from '../ui/Button';
+
+const getCategoryIcon = (category) => {
+ switch (category) {
+ case 'Pakaian':
+ return Shirt;
+ case 'Dokumen':
+ return FileText;
+ case 'Elektronik':
+ return Smartphone;
+ case 'Obat-obatan':
+ return HeartPulse;
+ case 'Toiletries':
+ return Sparkles;
+ default:
+ return Package;
+ }
+};
+
+const PackingCategory = ({
+ category,
+ items = [],
+ onToggleItem,
+ onUpdateQty,
+ onDeleteItem,
+ onAddItem,
+ onToggleAll,
+}) => {
+ const [isExpanded, setIsExpanded] = useState(true);
+ const [newItemName, setNewItemName] = useState('');
+ const [newItemQty, setNewItemQty] = useState(1);
+
+ const CategoryIcon = getCategoryIcon(category);
+ const totalCount = items.length;
+ const packedCount = items.filter((i) => i.checked).length;
+ const isAllChecked = totalCount > 0 && packedCount === totalCount;
+ const categoryPercent = totalCount > 0 ? Math.round((packedCount / totalCount) * 100) : 0;
+
+ const handleAddNewItem = (e) => {
+ e.preventDefault();
+ if (!newItemName.trim()) return;
+
+ onAddItem(category, {
+ name: newItemName.trim(),
+ qty: Number(newItemQty) || 1,
+ checked: false,
+ });
+
+ setNewItemName('');
+ setNewItemQty(1);
+ };
+
+ return (
+
+ {/* Category Header */}
+
setIsExpanded(!isExpanded)}
+ >
+
+
+
+
+
+
+
+ {category}
+
+
+ {packedCount}/{totalCount} item ({categoryPercent}%)
+
+
+
+
+
+ {/* Right Controls: Check All button & Expand/Collapse Toggle */}
+
e.stopPropagation()}
+ >
+ {totalCount > 0 && (
+ onToggleAll(category, !isAllChecked)}
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ gap: '4px',
+ padding: '4px 10px',
+ borderRadius: 'var(--radius-md)',
+ fontSize: 'var(--text-xs)',
+ fontWeight: 600,
+ color: isAllChecked ? 'var(--color-success)' : 'var(--color-text-secondary)',
+ backgroundColor: 'hsla(220, 15%, 20%, 0.6)',
+ border: '1px solid var(--color-border)',
+ cursor: 'pointer',
+ transition: 'all var(--transition-fast)',
+ }}
+ onMouseEnter={(e) => (e.currentTarget.style.color = '#ffffff')}
+ onMouseLeave={(e) => (e.currentTarget.style.color = isAllChecked ? 'var(--color-success)' : 'var(--color-text-secondary)')}
+ >
+ {isAllChecked ? : }
+ {isAllChecked ? 'Batalkan Semua' : 'Centang Semua'}
+
+ )}
+
+ setIsExpanded(!isExpanded)}
+ style={{
+ padding: '6px',
+ borderRadius: 'var(--radius-md)',
+ color: 'var(--color-text-muted)',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ }}
+ >
+ {isExpanded ? : }
+
+
+
+
+ {/* Accordion Content */}
+
+ {isExpanded && (
+
+
+ {/* Item List */}
+ {items.length > 0 ? (
+
+ {items.map((item) => (
+
+ ))}
+
+ ) : (
+
+ Belum ada item di kategori ini. Tambahkan di bawah.
+
+ )}
+
+ {/* Inline Add Form */}
+
+ setNewItemName(e.target.value)}
+ style={{ flex: 1, padding: 'var(--space-2) var(--space-3)', fontSize: 'var(--text-xs)' }}
+ />
+
+ setNewItemQty(Math.max(1, parseInt(e.target.value, 10) || 1))}
+ style={{ width: '60px', padding: 'var(--space-2) var(--space-2)', textAlign: 'center', fontSize: 'var(--text-xs)' }}
+ />
+
+
+ Tambah
+
+
+
+
+ )}
+
+
+ );
+};
+
+export default PackingCategory;
diff --git a/src/components/packing/PackingItem.jsx b/src/components/packing/PackingItem.jsx
new file mode 100644
index 0000000..f1f6d31
--- /dev/null
+++ b/src/components/packing/PackingItem.jsx
@@ -0,0 +1,181 @@
+import React from 'react';
+import { motion } from 'framer-motion';
+import { Check, Plus, Minus, Trash2 } from 'lucide-react';
+
+const PackingItem = ({
+ item,
+ category,
+ onToggle,
+ onUpdateQty,
+ onDelete,
+}) => {
+ const isChecked = Boolean(item.checked);
+
+ return (
+
+ {/* Checkbox & Item Name */}
+ onToggle(category, item.id)}
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ gap: 'var(--space-3)',
+ cursor: 'pointer',
+ flex: 1,
+ }}
+ >
+ {/* Custom Animated Checkbox */}
+
+ {isChecked && (
+
+
+
+ )}
+
+
+ {/* Item Label */}
+
+ {item.name}
+
+
+
+ {/* Quantity Counter & Delete Button */}
+
+ {/* Quantity Controls */}
+
+
onUpdateQty(category, item.id, Math.max(1, (item.qty || 1) - 1))}
+ disabled={(item.qty || 1) <= 1}
+ style={{
+ width: '22px',
+ height: '22px',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ color: (item.qty || 1) <= 1 ? 'var(--color-text-muted)' : 'var(--color-text-secondary)',
+ cursor: (item.qty || 1) <= 1 ? 'not-allowed' : 'pointer',
+ borderRadius: 'var(--radius-xs)',
+ }}
+ >
+
+
+
+
+ {item.qty || 1}
+
+
+
onUpdateQty(category, item.id, (item.qty || 1) + 1)}
+ style={{
+ width: '22px',
+ height: '22px',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ color: 'var(--color-text-secondary)',
+ cursor: 'pointer',
+ borderRadius: 'var(--radius-xs)',
+ }}
+ >
+
+
+
+
+ {/* Delete button */}
+
onDelete(category, item.id)}
+ title="Hapus barang"
+ aria-label="Hapus barang"
+ style={{
+ width: '28px',
+ height: '28px',
+ borderRadius: 'var(--radius-md)',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ color: 'var(--color-text-muted)',
+ transition: 'all var(--transition-fast)',
+ backgroundColor: 'transparent',
+ }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.color = 'var(--color-danger)';
+ e.currentTarget.style.backgroundColor = 'var(--color-danger-bg)';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.color = 'var(--color-text-muted)';
+ e.currentTarget.style.backgroundColor = 'transparent';
+ }}
+ >
+
+
+
+
+ );
+};
+
+export default PackingItem;
diff --git a/src/components/packing/PackingOverview.jsx b/src/components/packing/PackingOverview.jsx
new file mode 100644
index 0000000..739552d
--- /dev/null
+++ b/src/components/packing/PackingOverview.jsx
@@ -0,0 +1,147 @@
+import React from 'react';
+import { motion } from 'framer-motion';
+import { Luggage, CheckCircle2, AlertCircle, Sparkles, RefreshCw, Layers } from 'lucide-react';
+import ProgressBar from '../ui/ProgressBar';
+import Button from '../ui/Button';
+
+const PackingOverview = ({
+ totalItems = 0,
+ packedItems = 0,
+ percentPacked = 0,
+ isComplete = false,
+ onOpenTemplateModal,
+ onResetAll,
+}) => {
+ const getStatusBadge = () => {
+ if (totalItems === 0) {
+ return { text: 'Daftar Kosong', variant: 'neutral' };
+ }
+ if (isComplete) {
+ return { text: '🎉 Siap Berangkat!', variant: 'success' };
+ }
+ if (percentPacked > 50) {
+ return { text: 'Hampir Selesai Dikemas', variant: 'accent' };
+ }
+ if (percentPacked > 0) {
+ return { text: 'Sedang Dikemas', variant: 'primary' };
+ }
+ return { text: 'Belum Dimulai', variant: 'warning' };
+ };
+
+ const status = getStatusBadge();
+
+ return (
+
+
+
+
+ {isComplete ? : }
+
+
+
+
+ Progress Pengepakan Barang
+
+
+ {status.text}
+
+
+
+ {packedItems} dari {totalItems} barang sudah siap di dalam koper / tas
+
+
+
+
+ {/* Action Buttons */}
+
+
+ Ganti Template
+
+ {totalItems > 0 && (
+
+ Reset Centang
+
+ )}
+
+
+
+ {/* Main Progress Bar */}
+
50 ? 'accent' : 'primary'}
+ />
+
+ {/* Celebration Message */}
+ {isComplete && (
+
+
+ Luar biasa! Semua perlengkapan sudah lengkap dipak. Anda siap berangkat liburan! 🏖️🚀
+
+ )}
+
+ );
+};
+
+export default PackingOverview;
diff --git a/src/components/packing/TemplateModal.jsx b/src/components/packing/TemplateModal.jsx
new file mode 100644
index 0000000..3c94be4
--- /dev/null
+++ b/src/components/packing/TemplateModal.jsx
@@ -0,0 +1,136 @@
+import React, { useState } from 'react';
+import { Palmtree, Mountain, Building2, Landmark, Check, AlertTriangle } from 'lucide-react';
+import Modal from '../ui/Modal';
+import Button from '../ui/Button';
+import { packingTemplates } from '../../data/packingTemplates';
+
+const templateOptions = [
+ { id: 'pantai', name: 'Trip Pantai & Kepulauan', icon: Palmtree, desc: 'Baju renang, sunscreen SPF 50+, kacamata hitam, dry bag, dll.' },
+ { id: 'gunung', name: 'Trip Gunung & Alam Sejuk', icon: Mountain, desc: 'Jaket windbreaker, sepatu trekking, tolak angin, heat pack, dll.' },
+ { id: 'kota', name: 'Trip Kota & Metropolitan', icon: Building2, desc: 'Outfit smart casual, sneakers empuk, e-money transit, adaptor, dll.' },
+ { id: 'budaya', name: 'Trip Budaya & Heritage', icon: Landmark, desc: 'Pakaian sopan tertutup, selendang, slip-on shoes, lotion nyamuk, dll.' },
+];
+
+const TemplateModal = ({
+ isOpen,
+ onClose,
+ onApplyTemplate,
+ currentCategory = 'pantai',
+}) => {
+ const [selectedTemplate, setSelectedTemplate] = useState(currentCategory || 'pantai');
+
+ const handleApply = () => {
+ onApplyTemplate(selectedTemplate);
+ onClose();
+ };
+
+ return (
+
+
+ Batal
+
+
+ Terapkan Template
+
+ >
+ }
+ >
+
+
+ Pilih template bawaan sesuai jenis destinasi Anda untuk memuat daftar checklist barang secara otomatis.
+
+
+
+ {templateOptions.map((opt) => {
+ const Icon = opt.icon;
+ const isSelected = selectedTemplate === opt.id;
+
+ return (
+
setSelectedTemplate(opt.id)}
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ gap: 'var(--space-4)',
+ padding: 'var(--space-4)',
+ borderRadius: 'var(--radius-xl)',
+ backgroundColor: isSelected ? 'var(--color-primary-glow)' : 'hsla(222, 22%, 16%, 0.6)',
+ border: isSelected ? '1px solid var(--color-primary-light)' : '1px solid var(--color-border)',
+ cursor: 'pointer',
+ transition: 'all var(--transition-fast)',
+ }}
+ >
+
+
+
+
+
+
+ {opt.name}
+
+
+ {opt.desc}
+
+
+
+
+
+ );
+ })}
+
+
+
+
+
Menerapkan template baru akan memperbarui checklist barang bawaan saat ini.
+
+
+
+ );
+};
+
+export default TemplateModal;
diff --git a/src/components/schedule/ActivityItem.jsx b/src/components/schedule/ActivityItem.jsx
new file mode 100644
index 0000000..7fa86c1
--- /dev/null
+++ b/src/components/schedule/ActivityItem.jsx
@@ -0,0 +1,299 @@
+import React from 'react';
+import { useSortable } from '@dnd-kit/sortable';
+import { CSS } from '@dnd-kit/utilities';
+import { motion } from 'framer-motion';
+import {
+ GripVertical,
+ Clock,
+ MapPin,
+ FileText,
+ Pencil,
+ Trash2,
+ Compass,
+ Utensils,
+ Plane,
+ Hotel,
+ ShoppingBag,
+ Coffee,
+ MoreHorizontal,
+} from 'lucide-react';
+import Badge from '../ui/Badge';
+import { ACTIVITY_CATEGORIES } from '../../data/activities';
+
+const getCategoryIcon = (category) => {
+ switch (category) {
+ case 'makan':
+ return Utensils;
+ case 'transportasi':
+ return Plane;
+ case 'checkin':
+ return Hotel;
+ case 'belanja':
+ return ShoppingBag;
+ case 'santai':
+ return Coffee;
+ case 'wisata':
+ return Compass;
+ default:
+ return MoreHorizontal;
+ }
+};
+
+const ActivityItem = ({
+ activity,
+ onEdit,
+ onDelete,
+}) => {
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({ id: activity.id });
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ opacity: isDragging ? 0.4 : 1,
+ zIndex: isDragging ? 999 : 1,
+ };
+
+ const catConfig = ACTIVITY_CATEGORIES[activity.category] || ACTIVITY_CATEGORIES.lainnya;
+ const CategoryIcon = getCategoryIcon(activity.category);
+
+ return (
+
+
+ {/* Drag Handle */}
+ (e.currentTarget.style.color = 'var(--color-primary-light)')}
+ onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--color-text-muted)')}
+ >
+
+
+
+ {/* Time Pillar */}
+
+
+ {activity.time || '09:00'}
+
+
+ {activity.duration || 60}m
+
+
+
+ {/* Category Accent Stripe */}
+
+
+ {/* Main Details */}
+
+ {/* Header Row: Category Badge */}
+
+
+
+ {catConfig.label}
+
+
+
+ {/* Activity Name */}
+
+ {activity.name}
+
+
+ {/* Location & Notes */}
+
+ {activity.location && (
+
+
+ {activity.location}
+
+ )}
+ {activity.notes && (
+
+
+
+ {activity.notes}
+
+
+ )}
+
+
+
+ {/* Action Buttons: Edit & Delete */}
+
+
onEdit(activity)}
+ title="Edit Aktivitas"
+ aria-label="Edit Aktivitas"
+ style={{
+ width: '32px',
+ height: '32px',
+ borderRadius: 'var(--radius-md)',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ color: 'var(--color-text-secondary)',
+ backgroundColor: 'transparent',
+ transition: 'all var(--transition-fast)',
+ }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.color = 'var(--color-primary-light)';
+ e.currentTarget.style.backgroundColor = 'var(--color-primary-glow)';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.color = 'var(--color-text-secondary)';
+ e.currentTarget.style.backgroundColor = 'transparent';
+ }}
+ >
+
+
+
+
onDelete(activity.id)}
+ title="Hapus Aktivitas"
+ aria-label="Hapus Aktivitas"
+ style={{
+ width: '32px',
+ height: '32px',
+ borderRadius: 'var(--radius-md)',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ color: 'var(--color-text-muted)',
+ backgroundColor: 'transparent',
+ transition: 'all var(--transition-fast)',
+ }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.color = 'var(--color-danger)';
+ e.currentTarget.style.backgroundColor = 'var(--color-danger-bg)';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.color = 'var(--color-text-muted)';
+ e.currentTarget.style.backgroundColor = 'transparent';
+ }}
+ >
+
+
+
+
+
+ );
+};
+
+export default ActivityItem;
diff --git a/src/components/schedule/AddActivityModal.jsx b/src/components/schedule/AddActivityModal.jsx
new file mode 100644
index 0000000..c574a1c
--- /dev/null
+++ b/src/components/schedule/AddActivityModal.jsx
@@ -0,0 +1,256 @@
+import React, { useState, useEffect } from 'react';
+import { Clock, MapPin, Tag, FileText, Sparkles, Plus, Check } from 'lucide-react';
+import Modal from '../ui/Modal';
+import Button from '../ui/Button';
+import { ACTIVITY_CATEGORIES, quickActivityTemplates } from '../../data/activities';
+
+const AddActivityModal = ({
+ isOpen,
+ onClose,
+ onSave,
+ initialData = null,
+ dayNumber = 1,
+}) => {
+ const isEditing = Boolean(initialData?.id);
+
+ const [name, setName] = useState('');
+ const [time, setTime] = useState('09:00');
+ const [duration, setDuration] = useState(60);
+ const [location, setLocation] = useState('');
+ const [category, setCategory] = useState('wisata');
+ const [notes, setNotes] = useState('');
+
+ useEffect(() => {
+ if (initialData) {
+ setName(initialData.name || '');
+ setTime(initialData.time || '09:00');
+ setDuration(initialData.duration || 60);
+ setLocation(initialData.location || '');
+ setCategory(initialData.category || 'wisata');
+ setNotes(initialData.notes || '');
+ } else {
+ setName('');
+ setTime('09:00');
+ setDuration(60);
+ setLocation('');
+ setCategory('wisata');
+ setNotes('');
+ }
+ }, [initialData, isOpen]);
+
+ const handleApplyTemplate = (template) => {
+ setName(template.name);
+ setCategory(template.category);
+ setTime(template.time || '09:00');
+ setDuration(template.duration || 60);
+ setLocation(template.location || '');
+ setNotes(template.notes || '');
+ };
+
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ if (!name.trim()) return;
+
+ onSave({
+ id: initialData?.id,
+ name: name.trim(),
+ time,
+ duration: Number(duration) || 60,
+ location: location.trim(),
+ category,
+ notes: notes.trim(),
+ });
+
+ onClose();
+ };
+
+ return (
+
+
+ Batal
+
+
+ {isEditing ? 'Simpan Perubahan' : 'Tambah ke Jadwal'}
+
+ >
+ }
+ >
+
+ {/* Quick template selector (only when adding new) */}
+ {!isEditing && (
+
+
+ Template Aktivitas Cepat:
+
+
+ {quickActivityTemplates.slice(0, 6).map((tpl, i) => (
+ handleApplyTemplate(tpl)}
+ style={{
+ fontSize: '11px',
+ padding: '4px 10px',
+ borderRadius: 'var(--radius-full)',
+ backgroundColor: 'hsla(222, 18%, 20%, 0.8)',
+ border: '1px solid var(--color-border)',
+ color: 'var(--color-text-secondary)',
+ whiteSpace: 'nowrap',
+ cursor: 'pointer',
+ transition: 'all var(--transition-fast)',
+ }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.color = '#fff';
+ e.currentTarget.style.borderColor = 'var(--color-accent)';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.color = 'var(--color-text-secondary)';
+ e.currentTarget.style.borderColor = 'var(--color-border)';
+ }}
+ >
+ {tpl.name.split(' ')[0]} {tpl.name.split(' ')[1] || ''}
+
+ ))}
+
+
+ )}
+
+ {/* Activity Name */}
+
+ Nama Aktivitas / Tempat Kunjungan
+ setName(e.target.value)}
+ required
+ autoFocus
+ />
+
+
+ {/* Time & Duration row */}
+
+
+ {/* Category Pill Selector */}
+
+
+ Kategori
+
+
+ {Object.entries(ACTIVITY_CATEGORIES).map(([key, cat]) => {
+ const isSelected = category === key;
+ return (
+ setCategory(key)}
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ gap: '4px',
+ padding: '4px 10px',
+ borderRadius: 'var(--radius-md)',
+ fontSize: 'var(--text-xs)',
+ fontWeight: 600,
+ cursor: 'pointer',
+ transition: 'all var(--transition-fast)',
+ backgroundColor: isSelected ? cat.bgColor : 'hsla(222, 18%, 18%, 0.5)',
+ color: isSelected ? cat.color : 'var(--color-text-secondary)',
+ border: isSelected ? `1px solid ${cat.color}` : '1px solid var(--color-border)',
+ }}
+ >
+
+ {cat.label}
+
+ );
+ })}
+
+
+
+ {/* Location */}
+
+
+ Lokasi / Alamat (Opsional)
+
+ setLocation(e.target.value)}
+ />
+
+
+ {/* Notes */}
+
+
+ Catatan Khusus / Tips (Opsional)
+
+ setNotes(e.target.value)}
+ />
+
+
+
+ );
+};
+
+export default AddActivityModal;
diff --git a/src/components/schedule/DayTabs.jsx b/src/components/schedule/DayTabs.jsx
new file mode 100644
index 0000000..e145391
--- /dev/null
+++ b/src/components/schedule/DayTabs.jsx
@@ -0,0 +1,127 @@
+import React from 'react';
+import { motion } from 'framer-motion';
+import { Calendar, Plus, Sparkles } from 'lucide-react';
+import { formatDateShort } from '../../utils/dateHelpers';
+
+const DayTabs = ({
+ schedule = [],
+ selectedDayIndex = 0,
+ onSelectDay,
+ onAddDay,
+ totalDays = 0,
+}) => {
+ return (
+
+ {schedule.map((day, index) => {
+ const isSelected = selectedDayIndex === index;
+ const activityCount = day.activities?.length || 0;
+ const formattedDate = day.date ? formatDateShort(day.date) : '';
+
+ return (
+
onSelectDay(index)}
+ className="glass"
+ style={{
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'flex-start',
+ padding: 'var(--space-3) var(--space-5)',
+ borderRadius: 'var(--radius-xl)',
+ minWidth: '130px',
+ cursor: 'pointer',
+ transition: 'all var(--transition-fast)',
+ backgroundColor: isSelected
+ ? 'var(--color-primary)'
+ : 'hsla(222, 22%, 16%, 0.7)',
+ border: isSelected
+ ? '1px solid var(--color-primary-light)'
+ : '1px solid var(--color-border)',
+ boxShadow: isSelected ? '0 4px 16px var(--color-primary-glow)' : 'none',
+ textAlign: 'left',
+ }}
+ >
+
+
+ Hari {day.dayNumber || index + 1}
+
+
+ {activityCount}
+
+
+
+
+ {formattedDate ? (
+ {formattedDate}
+ ) : (
+ Jadwal Harian
+ )}
+
+
+ );
+ })}
+
+ {/* Button to add one more day */}
+
+
+ Tambah Hari
+
+
+ );
+};
+
+export default DayTabs;
diff --git a/src/components/schedule/ScheduleTimeline.jsx b/src/components/schedule/ScheduleTimeline.jsx
new file mode 100644
index 0000000..9c10ce9
--- /dev/null
+++ b/src/components/schedule/ScheduleTimeline.jsx
@@ -0,0 +1,145 @@
+import React from 'react';
+import {
+ DndContext,
+ closestCenter,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+} from '@dnd-kit/core';
+import {
+ SortableContext,
+ sortableKeyboardCoordinates,
+ verticalListSortingStrategy,
+ arrayMove,
+} from '@dnd-kit/sortable';
+import { Plus, Calendar, Clock, Sparkles } from 'lucide-react';
+import ActivityItem from './ActivityItem';
+import Button from '../ui/Button';
+import EmptyState from '../ui/EmptyState';
+import { formatDateIndo } from '../../utils/dateHelpers';
+
+const ScheduleTimeline = ({
+ day,
+ dayIndex = 0,
+ onAddActivity,
+ onEditActivity,
+ onDeleteActivity,
+ onReorderActivities,
+}) => {
+ const activities = day?.activities || [];
+ const activityIds = activities.map((a) => a.id);
+
+ // Configure pointer sensor with activation distance to avoid accidental drags when clicking buttons
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: {
+ distance: 5,
+ },
+ }),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: sortableKeyboardCoordinates,
+ })
+ );
+
+ const handleDragEnd = (event) => {
+ const { active, over } = event;
+
+ if (over && active.id !== over.id) {
+ const oldIndex = activities.findIndex((item) => item.id === active.id);
+ const newIndex = activities.findIndex((item) => item.id === over.id);
+
+ if (oldIndex !== -1 && newIndex !== -1) {
+ const reordered = arrayMove(activities, oldIndex, newIndex);
+ onReorderActivities(dayIndex, reordered);
+ }
+ }
+ };
+
+ return (
+
+ {/* Day Header Info Bar */}
+
+
+
+
+ Hari ke-{day?.dayNumber || dayIndex + 1}
+
+
+ {activities.length} Aktivitas
+
+
+ {day?.date && (
+
+ {formatDateIndo(day.date)}
+
+ )}
+
+
+
+ Tambah Aktivitas
+
+
+
+ {/* Activity Timeline List with DnD */}
+ {activities.length > 0 ? (
+
+
+
+ {activities.map((activity) => (
+
+ ))}
+
+
+
+ ) : (
+
+ )}
+
+ );
+};
+
+export default ScheduleTimeline;
diff --git a/src/components/trip/CustomTripModal.jsx b/src/components/trip/CustomTripModal.jsx
new file mode 100644
index 0000000..bc451cf
--- /dev/null
+++ b/src/components/trip/CustomTripModal.jsx
@@ -0,0 +1,216 @@
+import React, { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { PlusCircle, Calendar, MapPin, DollarSign, Clock, ArrowRight, Image as ImageIcon } from 'lucide-react';
+import Modal from '../ui/Modal';
+import Button from '../ui/Button';
+import { useTourStore } from '../../store/tourStore';
+import { calculateTotalDays, getTodayString, getFutureDateString } from '../../utils/dateHelpers';
+import { generateId } from '../../utils/helpers';
+import { formatIDR } from '../../utils/formatCurrency';
+import { toast } from '../../store/toastStore';
+
+const DEFAULT_COVER_IMAGES = {
+ pantai: 'https://images.unsplash.com/photo-1507525428034-b723cf961d3e?auto=format&fit=crop&w=1000&q=80',
+ gunung: 'https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?auto=format&fit=crop&w=1000&q=80',
+ kota: 'https://images.unsplash.com/photo-1477959858617-67f30bc75b82?auto=format&fit=crop&w=1000&q=80',
+ budaya: 'https://images.unsplash.com/photo-1590073242678-70ee3fc28e8e?auto=format&fit=crop&w=1000&q=80',
+};
+
+const CustomTripModal = ({ isOpen, onClose }) => {
+ const navigate = useNavigate();
+ const setTrip = useTourStore((state) => state.setTrip);
+ const setTotalBudget = useTourStore((state) => state.setTotalBudget);
+
+ const [destinationName, setDestinationName] = useState('');
+ const [tripName, setTripName] = useState('');
+ const [category, setCategory] = useState('pantai');
+ const [startDate, setStartDate] = useState(getTodayString());
+ const [endDate, setEndDate] = useState(getFutureDateString(3));
+ const [customCoverUrl, setCustomCoverUrl] = useState('');
+ const [budgetAmount, setBudgetAmount] = useState('3000000');
+
+ const totalDays = calculateTotalDays(startDate, endDate);
+
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ if (!destinationName.trim()) return;
+
+ const coverImage = customCoverUrl.trim() || DEFAULT_COVER_IMAGES[category] || DEFAULT_COVER_IMAGES.pantai;
+
+ setTrip({
+ id: generateId(),
+ name: tripName.trim() || `Rencana Perjalanan ke ${destinationName.trim()}`,
+ destination: destinationName.trim(),
+ location: destinationName.trim(),
+ startDate,
+ endDate,
+ totalDays,
+ coverImage,
+ type: 'custom',
+ category,
+ });
+
+ if (budgetAmount) {
+ setTotalBudget(Number(budgetAmount));
+ }
+
+ toast.success(`Trip custom ke ${destinationName.trim()} berhasil dibuat!`);
+ onClose();
+ navigate('/schedule');
+ };
+
+ return (
+
+
+ Batal
+
+
+ Buat & Rencanakan Jadwal
+
+ >
+ }
+ >
+
+ {/* Destinasi Tujuan */}
+
+
+ Destinasi / Kota Tujuan
+
+ setDestinationName(e.target.value)}
+ required
+ />
+
+
+ {/* Nama Trip */}
+
+ Judul Rencana Perjalanan
+ setTripName(e.target.value)}
+ />
+
+
+ {/* Kategori Perjalanan */}
+
+ Kategori / Tipe Trip
+ setCategory(e.target.value)}
+ >
+ Pantai & Kepulauan
+ Gunung & Alam Terbuka
+ Kota & Metropolitan
+ Budaya & Sejarah
+
+
+ *Kategori ini akan otomatis menyiapkan template packing list yang sesuai.
+
+
+
+ {/* Date Row */}
+
+
+ {/* Total Days */}
+
+
+
+ Total Durasi Terhitung:
+
+
+ {totalDays} Hari
+
+
+
+ {/* Budget */}
+
+
+ Alokasi Target Budget (IDR)
+
+ setBudgetAmount(e.target.value)}
+ />
+
+ {budgetAmount ? `Setara: ${formatIDR(budgetAmount)}` : 'Bisa disesuaikan nanti di halaman Budget.'}
+
+
+
+ {/* Custom Image URL (optional) */}
+
+
+ Link URL Foto Sampul (Opsional)
+
+ setCustomCoverUrl(e.target.value)}
+ />
+
+
+
+ );
+};
+
+export default CustomTripModal;
diff --git a/src/components/trip/FilterBar.jsx b/src/components/trip/FilterBar.jsx
new file mode 100644
index 0000000..d7296d3
--- /dev/null
+++ b/src/components/trip/FilterBar.jsx
@@ -0,0 +1,172 @@
+import React from 'react';
+import { Search, Compass, Palmtree, Mountain, Landmark, Building2, SlidersHorizontal, X } from 'lucide-react';
+
+const categories = [
+ { id: 'semua', label: 'Semua Destinasi', icon: Compass },
+ { id: 'pantai', label: 'Pantai & Laut', icon: Palmtree },
+ { id: 'gunung', label: 'Gunung & Alam', icon: Mountain },
+ { id: 'budaya', label: 'Budaya & Heritage', icon: Landmark },
+ { id: 'kota', label: 'Kota Metropolitan', icon: Building2 },
+];
+
+const FilterBar = ({
+ searchQuery,
+ onSearchChange,
+ activeCategory,
+ onCategoryChange,
+ sortBy,
+ onSortChange,
+}) => {
+ return (
+
+ {/* Top row: Search Bar & Sort Dropdown */}
+
+ {/* Search Bar */}
+
+
+ onSearchChange(e.target.value)}
+ style={{
+ background: 'transparent',
+ border: 'none',
+ outline: 'none',
+ width: '100%',
+ fontSize: 'var(--text-sm)',
+ color: 'var(--color-text-primary)',
+ }}
+ />
+ {searchQuery && (
+ onSearchChange('')}
+ style={{
+ background: 'transparent',
+ color: 'var(--color-text-muted)',
+ display: 'flex',
+ alignItems: 'center',
+ }}
+ >
+
+
+ )}
+
+
+ {/* Sort Select */}
+
+
+
+ Urutkan:
+
+ onSortChange(e.target.value)}
+ style={{
+ background: 'transparent',
+ border: 'none',
+ outline: 'none',
+ fontSize: 'var(--text-sm)',
+ color: 'var(--color-text-primary)',
+ cursor: 'pointer',
+ }}
+ >
+
+ Paling Populer
+
+
+ Rating Tertinggi (★)
+
+
+ Budget Terendah
+
+
+ Durasi Singkat
+
+
+
+
+
+ {/* Category Pills Bar */}
+
+ {categories.map((cat) => {
+ const Icon = cat.icon;
+ const isActive = activeCategory === cat.id;
+
+ return (
+ onCategoryChange(cat.id)}
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ gap: 'var(--space-2)',
+ padding: 'var(--space-2) var(--space-5)',
+ borderRadius: 'var(--radius-full)',
+ fontSize: 'var(--text-sm)',
+ fontWeight: 600,
+ whiteSpace: 'nowrap',
+ transition: 'all var(--transition-fast)',
+ cursor: 'pointer',
+ backgroundColor: isActive ? 'var(--color-primary)' : 'hsla(222, 22%, 18%, 0.6)',
+ color: isActive ? '#ffffff' : 'var(--color-text-secondary)',
+ border: isActive
+ ? '1px solid var(--color-primary-light)'
+ : '1px solid var(--color-border)',
+ boxShadow: isActive ? '0 4px 14px var(--color-primary-glow)' : 'none',
+ }}
+ >
+
+ {cat.label}
+
+ );
+ })}
+
+
+ );
+};
+
+export default FilterBar;
diff --git a/src/components/trip/HeroSection.jsx b/src/components/trip/HeroSection.jsx
new file mode 100644
index 0000000..001c212
--- /dev/null
+++ b/src/components/trip/HeroSection.jsx
@@ -0,0 +1,157 @@
+import React from 'react';
+import { motion } from 'framer-motion';
+import { Compass, Sparkles, MapPin, Plus, CalendarCheck, ShieldCheck, ArrowDown } from 'lucide-react';
+import Button from '../ui/Button';
+
+const HeroSection = ({ onExploreClick, onCreateCustomClick }) => {
+ return (
+
+ {/* Background glow element */}
+
+
+
+
+ {/* Top Pill Badge */}
+
+
+ RPT • Ready to Plan Tour
+
+
+
+ {/* Main Hero Headline with Playfair Display */}
+
+ Rencanakan Perjalanan Wisata
+ Secara Lengkap & Terstruktur
+
+
+ {/* Subtitle */}
+
+ Pilih destinasi populer, susun timeline aktivitas harian per jam, kelola checklist barang bawaan, dan pantau kalkulasi anggaran budget Anda secara real-time.
+
+
+ {/* CTA Buttons */}
+
+
+ Jelajahi Destinasi
+
+
+ Buat Trip Kustom
+
+
+
+ {/* Key Feature Badges Grid */}
+
+
+
+ 10+ Destinasi Pilihan
+
+
+
+ Drag & Drop Schedule
+
+
+
+ 100% Offline & Auto-Save
+
+
+
+
+
+ );
+};
+
+export default HeroSection;
diff --git a/src/components/trip/TripCard.jsx b/src/components/trip/TripCard.jsx
new file mode 100644
index 0000000..1401da9
--- /dev/null
+++ b/src/components/trip/TripCard.jsx
@@ -0,0 +1,249 @@
+import React from 'react';
+import { motion } from 'framer-motion';
+import { MapPin, Star, Calendar, ArrowRight, Sparkles } from 'lucide-react';
+import Badge from '../ui/Badge';
+import Button from '../ui/Button';
+import { formatIDR } from '../../utils/formatCurrency';
+
+const TripCard = ({ destination, onSelect }) => {
+ const {
+ name,
+ location,
+ category,
+ description,
+ highlights = [],
+ estimatedBudget,
+ popularDuration,
+ rating,
+ reviewCount,
+ image,
+ tags = [],
+ } = destination;
+
+ const getCategoryBadgeVariant = () => {
+ switch (category) {
+ case 'pantai':
+ return 'primary';
+ case 'gunung':
+ case 'alam':
+ return 'success';
+ case 'budaya':
+ return 'accent';
+ case 'kota':
+ return 'warning';
+ default:
+ return 'neutral';
+ }
+ };
+
+ return (
+
+ {/* Image Container */}
+
+
(e.currentTarget.style.transform = 'scale(1.08)')}
+ onMouseLeave={(e) => (e.currentTarget.style.transform = 'scale(1.0)')}
+ />
+ {/* Dark overlay gradient */}
+
+
+ {/* Top Badges */}
+
+
{category}
+
+
+ {rating}
+
+ ({reviewCount ? (reviewCount > 1000 ? `${(reviewCount / 1000).toFixed(1)}k` : reviewCount) : 0})
+
+
+
+
+ {/* Location & Title over image */}
+
+
+
+ {location}
+
+
+ {name}
+
+
+
+
+ {/* Card Body */}
+
+ {/* Description & Highlights */}
+
+
+ {description}
+
+
+ {/* Highlights pills */}
+ {highlights.length > 0 && (
+
+ {highlights.slice(0, 3).map((hl, idx) => (
+
+ {hl}
+
+ ))}
+
+ )}
+
+
+ {/* Footer Info & CTA */}
+
+
+
+ Estimasi Budget / Durasi
+
+
+ {estimatedBudget ? formatIDR(estimatedBudget.min) : '-'}
+
+ {' '}• {popularDuration} Hari
+
+
+
+
+
onSelect(destination)}
+ >
+ Pilih
+
+
+
+
+ );
+};
+
+export default TripCard;
diff --git a/src/components/trip/TripModal.jsx b/src/components/trip/TripModal.jsx
new file mode 100644
index 0000000..4833276
--- /dev/null
+++ b/src/components/trip/TripModal.jsx
@@ -0,0 +1,203 @@
+import React, { useState, useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { Calendar, MapPin, Sparkles, DollarSign, Clock, ArrowRight } from 'lucide-react';
+import Modal from '../ui/Modal';
+import Button from '../ui/Button';
+import { useTourStore } from '../../store/tourStore';
+import { calculateTotalDays, getTodayString, getFutureDateString } from '../../utils/dateHelpers';
+import { formatIDR } from '../../utils/formatCurrency';
+import { toast } from '../../store/toastStore';
+
+const TripModal = ({ isOpen, onClose, destination }) => {
+ const navigate = useNavigate();
+ const setTrip = useTourStore((state) => state.setTrip);
+ const setTotalBudget = useTourStore((state) => state.setTotalBudget);
+
+ const [tripName, setTripName] = useState('');
+ const [startDate, setStartDate] = useState(getTodayString());
+ const [endDate, setEndDate] = useState(getFutureDateString(destination?.popularDuration ? destination.popularDuration - 1 : 3));
+ const [customBudget, setCustomBudget] = useState('');
+
+ useEffect(() => {
+ if (destination) {
+ setTripName(`Liburan Impian ke ${destination.name}`);
+ const duration = destination.popularDuration || 3;
+ const today = getTodayString();
+ setStartDate(today);
+ setEndDate(getFutureDateString(duration - 1));
+ setCustomBudget(destination.estimatedBudget?.min || 3000000);
+ }
+ }, [destination]);
+
+ if (!destination) return null;
+
+ const totalDays = calculateTotalDays(startDate, endDate);
+
+ const handleSubmit = (e) => {
+ e.preventDefault();
+
+ setTrip({
+ id: destination.id,
+ name: tripName.trim() || `Trip ke ${destination.name}`,
+ destination: destination.name,
+ location: destination.location,
+ startDate,
+ endDate,
+ totalDays,
+ coverImage: destination.image,
+ type: 'preset',
+ category: destination.category,
+ });
+
+ if (customBudget) {
+ setTotalBudget(Number(customBudget));
+ }
+
+ toast.success(`Trip ke ${destination.name} berhasil dibuat!`);
+ onClose();
+ navigate('/schedule');
+ };
+
+ return (
+
+
+ Batal
+
+
+ Mulai Buat Jadwal
+
+ >
+ }
+ >
+
+ {/* Destination preview card */}
+
+
+
+
+ {destination.location}
+
+
{destination.name}
+
+
+
+ {/* Input: Trip Name */}
+
+ Nama Rencana Perjalanan
+ setTripName(e.target.value)}
+ required
+ />
+
+
+ {/* Date Row */}
+
+
+ {/* Duration badge summary */}
+
+
+
+ Total Durasi Perjalanan:
+
+
+ {totalDays} Hari
+
+
+
+ {/* Initial Budget Input */}
+
+
+ Alokasi Target Budget (Opsional)
+
+ setCustomBudget(e.target.value)}
+ />
+
+ {customBudget ? `Setara: ${formatIDR(customBudget)}` : `Rekomendasi destinasi ini: ${destination.estimatedBudget ? formatIDR(destination.estimatedBudget.min) : '-'}`}
+
+
+
+
+ );
+};
+
+export default TripModal;
diff --git a/src/components/ui/Badge.jsx b/src/components/ui/Badge.jsx
new file mode 100644
index 0000000..5288330
--- /dev/null
+++ b/src/components/ui/Badge.jsx
@@ -0,0 +1,48 @@
+import React from 'react';
+
+const Badge = ({
+ children,
+ variant = 'primary',
+ icon: Icon,
+ size = 'md',
+ className = '',
+ style = {},
+ ...props
+}) => {
+ const getVariantClass = () => {
+ switch (variant) {
+ case 'accent':
+ return 'badge-accent';
+ case 'success':
+ return 'badge-success';
+ case 'warning':
+ return 'badge-warning';
+ case 'danger':
+ return 'badge-danger';
+ case 'neutral':
+ return 'badge-neutral';
+ case 'primary':
+ default:
+ return 'badge-primary';
+ }
+ };
+
+ const isSmall = size === 'sm';
+
+ return (
+
+ {Icon && }
+ {children}
+
+ );
+};
+
+export default Badge;
diff --git a/src/components/ui/Button.jsx b/src/components/ui/Button.jsx
new file mode 100644
index 0000000..a77b163
--- /dev/null
+++ b/src/components/ui/Button.jsx
@@ -0,0 +1,83 @@
+import React from 'react';
+import { motion } from 'framer-motion';
+import { Loader2 } from 'lucide-react';
+
+const Button = ({
+ children,
+ variant = 'primary',
+ size = 'md',
+ icon: Icon,
+ iconPosition = 'left',
+ loading = false,
+ disabled = false,
+ onClick,
+ type = 'button',
+ fullWidth = false,
+ className = '',
+ style = {},
+ ...props
+}) => {
+ const getVariantClass = () => {
+ switch (variant) {
+ case 'accent':
+ return 'btn-accent';
+ case 'secondary':
+ return 'btn-secondary';
+ case 'ghost':
+ return 'btn-ghost';
+ case 'danger':
+ return 'btn-danger';
+ case 'primary':
+ default:
+ return 'btn-primary';
+ }
+ };
+
+ const getSizeClass = () => {
+ switch (size) {
+ case 'sm':
+ return 'btn-sm';
+ case 'lg':
+ return 'btn-lg';
+ case 'md':
+ default:
+ return '';
+ }
+ };
+
+ const isDisabled = disabled || loading;
+
+ return (
+
+ {loading ? (
+ <>
+
+ Memuat...
+ >
+ ) : (
+ <>
+ {Icon && iconPosition === 'left' && }
+ {children && {children} }
+ {Icon && iconPosition === 'right' && }
+ >
+ )}
+
+ );
+};
+
+export default Button;
diff --git a/src/components/ui/Card.jsx b/src/components/ui/Card.jsx
new file mode 100644
index 0000000..acce1bf
--- /dev/null
+++ b/src/components/ui/Card.jsx
@@ -0,0 +1,47 @@
+import React from 'react';
+import { motion } from 'framer-motion';
+
+const Card = ({
+ children,
+ glassmorphism = true,
+ hoverable = false,
+ onClick,
+ gradient = false,
+ glow = false,
+ className = '',
+ style = {},
+ ...props
+}) => {
+ const isClickable = Boolean(onClick);
+
+ return (
+
+ {children}
+
+ );
+};
+
+export default Card;
diff --git a/src/components/ui/EmptyState.jsx b/src/components/ui/EmptyState.jsx
new file mode 100644
index 0000000..9027a1a
--- /dev/null
+++ b/src/components/ui/EmptyState.jsx
@@ -0,0 +1,75 @@
+import React from 'react';
+import { motion } from 'framer-motion';
+import Button from './Button';
+
+const EmptyState = ({
+ icon: Icon,
+ title = 'Belum Ada Data',
+ description = 'Tambahkan item baru untuk mulai mengisi bagian ini.',
+ actionText = '',
+ onAction = null,
+ actionIcon = null,
+ className = '',
+}) => {
+ return (
+
+ {Icon && (
+
+
+
+ )}
+
+
+ {title}
+
+
+
+ {description}
+
+
+ {actionText && onAction && (
+
+ {actionText}
+
+ )}
+
+ );
+};
+
+export default EmptyState;
diff --git a/src/components/ui/Modal.jsx b/src/components/ui/Modal.jsx
new file mode 100644
index 0000000..fd45f8d
--- /dev/null
+++ b/src/components/ui/Modal.jsx
@@ -0,0 +1,180 @@
+import React, { useEffect } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { X } from 'lucide-react';
+
+const Modal = ({
+ isOpen = false,
+ onClose,
+ title = '',
+ children,
+ footer = null,
+ maxWidth = 'md', // sm (420px), md (540px), lg (680px), xl (860px)
+ className = '',
+}) => {
+ // Close on Escape key
+ useEffect(() => {
+ const handleKeyDown = (e) => {
+ if (e.key === 'Escape' && isOpen && onClose) {
+ onClose();
+ }
+ };
+
+ if (isOpen) {
+ document.addEventListener('keydown', handleKeyDown);
+ document.body.style.overflow = 'hidden';
+ }
+
+ return () => {
+ document.removeEventListener('keydown', handleKeyDown);
+ document.body.style.overflow = 'unset';
+ };
+ }, [isOpen, onClose]);
+
+ const getMaxWidth = () => {
+ switch (maxWidth) {
+ case 'sm':
+ return '420px';
+ case 'lg':
+ return '680px';
+ case 'xl':
+ return '860px';
+ case 'md':
+ default:
+ return '540px';
+ }
+ };
+
+ return (
+
+ {isOpen && (
+
+ {/* Backdrop */}
+
+
+ {/* Modal Card */}
+
+ {/* Modal Header */}
+ {title && (
+
+
+ {title}
+
+ {
+ e.currentTarget.style.color = '#fff';
+ e.currentTarget.style.backgroundColor = 'var(--color-danger)';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.color = 'var(--color-text-secondary)';
+ e.currentTarget.style.backgroundColor = 'hsla(220, 15%, 25%, 0.4)';
+ }}
+ >
+
+
+
+ )}
+
+ {/* Modal Body */}
+
+ {children}
+
+
+ {/* Modal Footer */}
+ {footer && (
+
+ {footer}
+
+ )}
+
+
+ )}
+
+ );
+};
+
+export default Modal;
diff --git a/src/components/ui/ProgressBar.jsx b/src/components/ui/ProgressBar.jsx
new file mode 100644
index 0000000..ff03a55
--- /dev/null
+++ b/src/components/ui/ProgressBar.jsx
@@ -0,0 +1,76 @@
+import React from 'react';
+import { motion } from 'framer-motion';
+
+const ProgressBar = ({
+ value = 0,
+ label = '',
+ showPercent = true,
+ variant = 'primary',
+ color = '',
+ height = 8,
+ className = '',
+ style = {},
+}) => {
+ const clampedValue = Math.min(100, Math.max(0, Math.round(value)));
+
+ const getGradient = () => {
+ if (color) return color;
+ switch (variant) {
+ case 'accent':
+ return 'linear-gradient(90deg, var(--color-accent), var(--color-accent-light))';
+ case 'success':
+ return 'linear-gradient(90deg, var(--color-success), hsl(142, 70%, 55%))';
+ case 'warning':
+ return 'linear-gradient(90deg, var(--color-warning), hsl(38, 95%, 65%))';
+ case 'danger':
+ return 'linear-gradient(90deg, var(--color-danger), hsl(0, 75%, 65%))';
+ case 'primary':
+ default:
+ return 'linear-gradient(90deg, var(--color-primary), var(--color-primary-light))';
+ }
+ };
+
+ return (
+
+ {(label || showPercent) && (
+
+ {label && {label} }
+ {showPercent && {clampedValue}% }
+
+ )}
+
+
+
+
+ );
+};
+
+export default ProgressBar;
diff --git a/src/components/ui/ToastContainer.jsx b/src/components/ui/ToastContainer.jsx
new file mode 100644
index 0000000..40670a6
--- /dev/null
+++ b/src/components/ui/ToastContainer.jsx
@@ -0,0 +1,138 @@
+import React from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { CheckCircle2, Info, AlertTriangle, XCircle, X } from 'lucide-react';
+import { useToastStore } from '../../store/toastStore';
+
+const getToastIcon = (type) => {
+ switch (type) {
+ case 'info':
+ return Info;
+ case 'warning':
+ return AlertTriangle;
+ case 'danger':
+ return XCircle;
+ case 'success':
+ default:
+ return CheckCircle2;
+ }
+};
+
+const getToastStyles = (type) => {
+ switch (type) {
+ case 'info':
+ return {
+ border: '1px solid hsla(199, 89%, 48%, 0.4)',
+ color: 'var(--color-info)',
+ bg: 'hsla(199, 89%, 48%, 0.15)',
+ };
+ case 'warning':
+ return {
+ border: '1px solid hsla(38, 95%, 55%, 0.4)',
+ color: 'var(--color-warning)',
+ bg: 'hsla(38, 95%, 55%, 0.15)',
+ };
+ case 'danger':
+ return {
+ border: '1px solid hsla(0, 75%, 55%, 0.4)',
+ color: 'var(--color-danger)',
+ bg: 'hsla(0, 75%, 55%, 0.15)',
+ };
+ case 'success':
+ default:
+ return {
+ border: '1px solid hsla(142, 70%, 45%, 0.4)',
+ color: 'var(--color-success)',
+ bg: 'hsla(142, 70%, 45%, 0.15)',
+ };
+ }
+};
+
+const ToastContainer = () => {
+ const toasts = useToastStore((state) => state.toasts);
+ const removeToast = useToastStore((state) => state.removeToast);
+
+ return (
+
+
+ {toasts.map((toast) => {
+ const Icon = getToastIcon(toast.type);
+ const styleConfig = getToastStyles(toast.type);
+
+ return (
+
+
+
+
+
+
+ {toast.message}
+
+
+
+ removeToast(toast.id)}
+ style={{
+ color: 'var(--color-text-muted)',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: '2px',
+ borderRadius: 'var(--radius-sm)',
+ backgroundColor: 'transparent',
+ }}
+ onMouseEnter={(e) => (e.currentTarget.style.color = '#ffffff')}
+ onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--color-text-muted)')}
+ >
+
+
+
+ );
+ })}
+
+
+ );
+};
+
+export default ToastContainer;
diff --git a/src/data/activities.js b/src/data/activities.js
new file mode 100644
index 0000000..ab5ed58
--- /dev/null
+++ b/src/data/activities.js
@@ -0,0 +1,122 @@
+/**
+ * Activities Dataset & Presets
+ */
+
+export const ACTIVITY_CATEGORIES = {
+ wisata: {
+ id: 'wisata',
+ label: 'Wisata & Atraksi',
+ color: 'hsl(220, 90%, 56%)',
+ bgColor: 'hsla(220, 90%, 56%, 0.15)',
+ icon: 'Compass',
+ },
+ makan: {
+ id: 'makan',
+ label: 'Kuliner & Makan',
+ color: 'hsl(142, 70%, 45%)',
+ bgColor: 'hsla(142, 70%, 45%, 0.15)',
+ icon: 'Utensils',
+ },
+ transportasi: {
+ id: 'transportasi',
+ label: 'Transportasi & Perjalanan',
+ color: 'hsl(280, 70%, 60%)',
+ bgColor: 'hsla(280, 70%, 60%, 0.15)',
+ icon: 'Plane',
+ },
+ checkin: {
+ id: 'checkin',
+ label: 'Hotel & Check-in',
+ color: 'hsl(38, 95%, 55%)',
+ bgColor: 'hsla(38, 95%, 55%, 0.15)',
+ icon: 'Hotel',
+ },
+ belanja: {
+ id: 'belanja',
+ label: 'Belanja & Oleh-oleh',
+ color: 'hsl(340, 75%, 58%)',
+ bgColor: 'hsla(340, 75%, 58%, 0.15)',
+ icon: 'ShoppingBag',
+ },
+ santai: {
+ id: 'santai',
+ label: 'Istirahat & Santai',
+ color: 'hsl(190, 80%, 50%)',
+ bgColor: 'hsla(190, 80%, 50%, 0.15)',
+ icon: 'Coffee',
+ },
+ lainnya: {
+ id: 'lainnya',
+ label: 'Lain-lain',
+ color: 'hsl(210, 20%, 55%)',
+ bgColor: 'hsla(210, 20%, 55%, 0.15)',
+ icon: 'MoreHorizontal',
+ },
+};
+
+export const quickActivityTemplates = [
+ {
+ name: 'Sarapan Pagi di Hotel / Kafe Lokal',
+ category: 'makan',
+ duration: 60,
+ time: '08:00',
+ location: 'Area Penginapan',
+ notes: 'Nikmati sarapan khas lokal',
+ },
+ {
+ name: 'Check-in Penginapan / Hotel',
+ category: 'checkin',
+ duration: 30,
+ time: '14:00',
+ location: 'Hotel',
+ notes: 'Simpan koper dan istirahat sejenak',
+ },
+ {
+ name: 'Kunjungan Destinasi Utama & Foto',
+ category: 'wisata',
+ duration: 120,
+ time: '09:30',
+ location: 'Spot Wisata Populer',
+ notes: 'Siapkan kamera dan outfit terbaik',
+ },
+ {
+ name: 'Makan Siang Kuliner Khas Daerah',
+ category: 'makan',
+ duration: 75,
+ time: '12:30',
+ location: 'Restoran / Warung Rekomendasi',
+ notes: 'Cicipi menu spesial daerah',
+ },
+ {
+ name: 'Menikmati Sunset & Santai Sore',
+ category: 'santai',
+ duration: 90,
+ time: '17:00',
+ location: 'Viewpoint / Pantai / Rooftop',
+ notes: 'Waktu terbaik untuk golden hour',
+ },
+ {
+ name: 'Makan Malam & Night Market Tour',
+ category: 'makan',
+ duration: 90,
+ time: '19:00',
+ location: 'Pasar Malam / Pusat Kuliner',
+ notes: 'Cari street food lokal',
+ },
+ {
+ name: 'Belanja Souvenir & Oleh-oleh Khas',
+ category: 'belanja',
+ duration: 60,
+ time: '16:00',
+ location: 'Pusat Oleh-oleh',
+ notes: 'Camilan dan cinderamata untuk keluarga',
+ },
+ {
+ name: 'Perjalanan Bandara / Stasiun',
+ category: 'transportasi',
+ duration: 120,
+ time: '06:00',
+ location: 'Bandara / Stasiun Kereta',
+ notes: 'Tiba minimal 2 jam sebelum keberangkatan',
+ },
+];
diff --git a/src/data/destinations.js b/src/data/destinations.js
new file mode 100644
index 0000000..830f7e4
--- /dev/null
+++ b/src/data/destinations.js
@@ -0,0 +1,185 @@
+/**
+ * Destinations Dataset
+ * Comprehensive popular destinations in Indonesia & International
+ */
+
+export const destinations = [
+ {
+ id: 'dest-bali-01',
+ name: 'Bali (Island of Gods)',
+ location: 'Bali, Indonesia',
+ country: 'Indonesia',
+ category: 'pantai',
+ description: 'Surga tropis dengan perpaduan magis pantai eksotis, sawah berundak Ubud, pura bersejarah, dan budaya yang memikat.',
+ highlights: ['Pura Tanah Lot', 'Tegalalang Rice Terrace', 'Pantai Nusa Dua', 'Uluwatu Sunset Dance'],
+ estimatedBudget: { min: 2500000, max: 7000000 },
+ popularDuration: 4,
+ rating: 4.9,
+ reviewCount: 18450,
+ image: 'https://images.unsplash.com/photo-1537996194471-e657df975ab4?auto=format&fit=crop&w=1000&q=80',
+ tags: ['Pantai', 'Budaya', 'Surfing', 'Kuliner', 'Romantis'],
+ bestTime: 'April - Oktober',
+ suggestedPackingType: 'pantai',
+ },
+ {
+ id: 'dest-labuanbajo-02',
+ name: 'Labuan Bajo & Komodo',
+ location: 'Nusa Tenggara Timur, Indonesia',
+ country: 'Indonesia',
+ category: 'alam',
+ description: 'Gerbang menuju Taman Nasional Komodo dengan panorama Pulau Padar, Pink Beach, dan pengalaman sailing kapal pinisi yang tak terlupakan.',
+ highlights: ['Puncak Pulau Padar', 'Taman Nasional Komodo', 'Pink Beach', 'Manta Point Snorkeling'],
+ estimatedBudget: { min: 4500000, max: 12000000 },
+ popularDuration: 3,
+ rating: 4.9,
+ reviewCount: 9240,
+ image: 'https://images.unsplash.com/photo-1518548419970-58e3b4079ab2?auto=format&fit=crop&w=1000&q=80',
+ tags: ['Sailing', 'Diving', 'Komodo', 'Eksotis', 'Petualangan'],
+ bestTime: 'Mei - September',
+ suggestedPackingType: 'pantai',
+ },
+ {
+ id: 'dest-jogja-03',
+ name: 'Yogyakarta Istimewa',
+ location: 'D.I. Yogyakarta, Indonesia',
+ country: 'Indonesia',
+ category: 'budaya',
+ description: 'Pusat kebudayaan Jawa dengan kemegahan Candi Borobudur & Prambanan, kuliner gudeg legendaris, dan suasana Malioboro yang hangat.',
+ highlights: ['Candi Borobudur', 'Keraton Yogyakarta', 'Candi Prambanan', 'Jalan Malioboro'],
+ estimatedBudget: { min: 1200000, max: 4000000 },
+ popularDuration: 3,
+ rating: 4.8,
+ reviewCount: 22100,
+ image: 'https://images.unsplash.com/photo-1584810359583-96fc3448beaa?auto=format&fit=crop&w=1000&q=80',
+ tags: ['Candi', 'Kuliner', 'Heritage', 'Seni & Batik'],
+ bestTime: 'Sepanjang Tahun',
+ suggestedPackingType: 'budaya',
+ },
+ {
+ id: 'dest-bromo-04',
+ name: 'Gunung Bromo & Ijen',
+ location: 'Jawa Timur, Indonesia',
+ country: 'Indonesia',
+ category: 'gunung',
+ description: 'Sensasi sunrise magis di atas lautan pasir Bromo, kawah aktif bergemuruh, dan keajaiban api biru (Blue Fire) di Kawah Ijen.',
+ highlights: ['Penanjakan Sunrise Viewpoint', 'Kawah Bromo & Pasir Berbisik', 'Bukit Teletubbies', 'Blue Fire Kawah Ijen'],
+ estimatedBudget: { min: 1800000, max: 4500000 },
+ popularDuration: 3,
+ rating: 4.8,
+ reviewCount: 14320,
+ image: 'https://images.unsplash.com/photo-1588668214407-6ea9a6d8c272?auto=format&fit=crop&w=1000&q=80',
+ tags: ['Trekking', 'Sunrise', 'Dingin', 'Fotografi'],
+ bestTime: 'Juli - Oktober',
+ suggestedPackingType: 'gunung',
+ },
+ {
+ id: 'dest-rajaampat-05',
+ name: 'Raja Ampat (The Last Paradise)',
+ location: 'Papua Barat Daya, Indonesia',
+ country: 'Indonesia',
+ category: 'pantai',
+ description: 'Mahakarya alam bawah laut terbaik dunia dengan gugusan pulau karang karst ikonik di Pianemo dan Wayag.',
+ highlights: ['Pianemo Geosite', 'Wayag Lagoon', 'Pasir Timbul', 'Manta Sandy Diving'],
+ estimatedBudget: { min: 9000000, max: 22000000 },
+ popularDuration: 5,
+ rating: 5.0,
+ reviewCount: 6150,
+ image: 'https://images.unsplash.com/photo-1516690561799-46d8f74f9abf?auto=format&fit=crop&w=1000&q=80',
+ tags: ['Diving', 'Karst Islands', 'Eksklusif', 'Alam Liar'],
+ bestTime: 'Oktober - April',
+ suggestedPackingType: 'pantai',
+ },
+ {
+ id: 'dest-tokyo-06',
+ name: 'Tokyo Metropolis',
+ location: 'Tokyo, Jepang',
+ country: 'Jepang',
+ category: 'kota',
+ description: 'Peleburan futuristik gedung pencakar langit Shibuya & Shinjuku dengan kuil kuno Asakusa dan surga kuliner ramen dunia.',
+ highlights: ['Shibuya Crossing & Sky', 'Senso-ji Temple Asakusa', 'Akihabara Tech & Anime', 'TeamLab Planets'],
+ estimatedBudget: { min: 12000000, max: 30000000 },
+ popularDuration: 6,
+ rating: 4.9,
+ reviewCount: 31000,
+ image: 'https://images.unsplash.com/photo-1503899036084-c55cdd92da26?auto=format&fit=crop&w=1000&q=80',
+ tags: ['Metropolitan', 'Belanja', 'Anime', 'Kuliner Jepang'],
+ bestTime: 'Maret - Mei & Okt - Nov',
+ suggestedPackingType: 'kota',
+ },
+ {
+ id: 'dest-bandung-07',
+ name: 'Bandung Paris van Java',
+ location: 'Jawa Barat, Indonesia',
+ country: 'Indonesia',
+ category: 'kota',
+ description: 'Kota sejuk dengan pesona wisata kawah Lembang, kafe-kafe estetik Dago, belanja factory outlet, dan kuliner khas Sunda.',
+ highlights: ['Kawah Putih Ciwidey', 'Tangkuban Perahu', 'Floating Market Lembang', 'Jalan Braga & Asia Afrika'],
+ estimatedBudget: { min: 800000, max: 2800000 },
+ popularDuration: 2,
+ rating: 4.7,
+ reviewCount: 19800,
+ image: 'https://images.unsplash.com/photo-1601058268499-e52658b8bb88?auto=format&fit=crop&w=1000&q=80',
+ tags: ['Sejuk', 'Kafe Estetik', 'Kuliner', 'Family Friendly'],
+ bestTime: 'Sepanjang Tahun',
+ suggestedPackingType: 'kota',
+ },
+ {
+ id: 'dest-lombok-08',
+ name: 'Lombok & Gili Islands',
+ location: 'Nusa Tenggara Barat, Indonesia',
+ country: 'Indonesia',
+ category: 'pantai',
+ description: 'Keindahan pantai Kuta Mandalika, trekking Gunung Rinjani, dan ketenangan pulau tanpa kendaraan bermotor di Gili Trawangan.',
+ highlights: ['Gili Trawangan & Meno', 'Sirkuit Mandalika & Tanjung Aan', 'Bukit Merese', 'Desa Adat Sade'],
+ estimatedBudget: { min: 2200000, max: 6500000 },
+ popularDuration: 4,
+ rating: 4.8,
+ reviewCount: 11200,
+ image: 'https://images.unsplash.com/photo-1570789210967-2cac24afeb00?auto=format&fit=crop&w=1000&q=80',
+ tags: ['Pantai', 'Snorkeling', 'Santai', 'Budaya Sasak'],
+ bestTime: 'Mei - September',
+ suggestedPackingType: 'pantai',
+ },
+ {
+ id: 'dest-seoul-09',
+ name: 'Seoul Urban & K-Culture',
+ location: 'Seoul, Korea Selatan',
+ country: 'Korea Selatan',
+ category: 'kota',
+ description: 'Jelajahi istana megah Gyeongbokgung dengan Hanbok, street food Myeongdong, kafe viral Seongsu-dong, dan gemerlap K-Wave.',
+ highlights: ['Gyeongbokgung Palace', 'Myeongdong Night Market', 'N Seoul Tower', 'Bukchon Hanok Village'],
+ estimatedBudget: { min: 10000000, max: 25000000 },
+ popularDuration: 5,
+ rating: 4.9,
+ reviewCount: 24500,
+ image: 'https://images.unsplash.com/photo-1538485399081-7191377e8241?auto=format&fit=crop&w=1000&q=80',
+ tags: ['K-Pop', 'Street Food', 'Shopping', 'Hanbok'],
+ bestTime: 'April - Mei & Sep - Nov',
+ suggestedPackingType: 'kota',
+ },
+ {
+ id: 'dest-swiss-10',
+ name: 'Swiss Alps & Interlaken',
+ location: 'Bernese Oberland, Swiss',
+ country: 'Swiss',
+ category: 'gunung',
+ description: 'Pemandangan puncak Alpen yang dramatis, desa dongeng Lauterbrunnen, kereta gantung ke Jungfraujoch, dan danau biru kristal.',
+ highlights: ['Jungfraujoch Top of Europe', 'Lauterbrunnen Valley', 'Lake Brienz Cruise', 'Grindelwald First Cliff Walk'],
+ estimatedBudget: { min: 25000000, max: 60000000 },
+ popularDuration: 6,
+ rating: 5.0,
+ reviewCount: 8900,
+ image: 'https://images.unsplash.com/photo-1530122037265-a5f1f91d3b99?auto=format&fit=crop&w=1000&q=80',
+ tags: ['Alpen', 'Pemandangan Spektakuler', 'Kereta Wisata', 'Salju'],
+ bestTime: 'Juni - September / Des - Feb',
+ suggestedPackingType: 'gunung',
+ },
+];
+
+export const destinationCategories = [
+ { id: 'semua', label: 'Semua Destinasi', icon: 'Compass' },
+ { id: 'pantai', label: 'Pantai & Kepulauan', icon: 'Palmtree' },
+ { id: 'gunung', label: 'Gunung & Alam', icon: 'Mountain' },
+ { id: 'budaya', label: 'Budaya & Sejarah', icon: 'Landmark' },
+ { id: 'kota', label: 'Kota & Metropolitan', icon: 'Building2' },
+];
diff --git a/src/data/packingTemplates.js b/src/data/packingTemplates.js
new file mode 100644
index 0000000..c198099
--- /dev/null
+++ b/src/data/packingTemplates.js
@@ -0,0 +1,168 @@
+/**
+ * Packing List Templates based on Trip Category
+ */
+
+export const PACKING_CATEGORIES = [
+ 'Pakaian',
+ 'Dokumen',
+ 'Elektronik',
+ 'Obat-obatan',
+ 'Toiletries',
+ 'Lainnya',
+];
+
+export const packingTemplates = {
+ pantai: {
+ name: 'Trip Pantai & Kepulauan',
+ items: {
+ Pakaian: [
+ { name: 'Baju santai / Kaos katun ringan', qty: 4, checked: false },
+ { name: 'Celana pendek / Beach shorts', qty: 3, checked: false },
+ { name: 'Baju renang / Swimwear', qty: 2, checked: false },
+ { name: 'Topi pantai / Bucket hat', qty: 1, checked: false },
+ { name: 'Kacamata hitam (UV Protection)', qty: 1, checked: false },
+ { name: 'Sandal jepit / Water shoes', qty: 1, checked: false },
+ ],
+ Dokumen: [
+ { name: 'KTP / Paspor & Visa', qty: 1, checked: false },
+ { name: 'Tiket pesawat & booking hotel (Digital/Print)', qty: 1, checked: false },
+ { name: 'Uang tunai secukupnya & kartu debit/kredit', qty: 1, checked: false },
+ ],
+ Elektronik: [
+ { name: 'Smartphone & Charger', qty: 1, checked: false },
+ { name: 'Powerbank 10.000+ mAh', qty: 1, checked: false },
+ { name: 'Waterproof pouch untuk HP', qty: 1, checked: false },
+ { name: 'Action camera / Kamera & baterai cadangan', qty: 1, checked: false },
+ ],
+ 'Obat-obatan': [
+ { name: 'Obat mabuk laut / perjalanan', qty: 1, checked: false },
+ { name: 'Obat flu, batuk, & pereda nyeri', qty: 1, checked: false },
+ { name: 'Minyak kayu putih / Roll-on aromaterapi', qty: 1, checked: false },
+ { name: 'Plester luka & antiseptik', qty: 1, checked: false },
+ ],
+ Toiletries: [
+ { name: 'Sunscreen SPF 50+ (Reef-safe)', qty: 1, checked: false },
+ { name: 'After-sun aloe vera gel', qty: 1, checked: false },
+ { name: 'Sabun, sampo & sikat gigi travel size', qty: 1, checked: false },
+ { name: 'Handuk microfiber cepat kering', qty: 1, checked: false },
+ ],
+ Lainnya: [
+ { name: 'Dry bag (10L / 20L)', qty: 1, checked: false },
+ { name: 'Botol minum tumbler (Eco-friendly)', qty: 1, checked: false },
+ { name: 'Kantong plastik / laundry bag untuk baju basah', qty: 2, checked: false },
+ ],
+ },
+ },
+ gunung: {
+ name: 'Trip Gunung & Alam Sejuk',
+ items: {
+ Pakaian: [
+ { name: 'Jaket windbreaker / Down jacket tebal', qty: 1, checked: false },
+ { name: 'Baju termal / Long john', qty: 2, checked: false },
+ { name: 'Celana trekking / kargo elastis', qty: 2, checked: false },
+ { name: 'Sarung tangan wol & Kupluk / Beanie', qty: 1, checked: false },
+ { name: 'Kaus kaki tebal cadangan', qty: 3, checked: false },
+ { name: 'Sepatu trekking / Sepatu olahraga anti-slip', qty: 1, checked: false },
+ ],
+ Dokumen: [
+ { name: 'KTP / Surat izin masuk kawasan konservasi', qty: 1, checked: false },
+ { name: 'Surat keterangan sehat (jika pendakian)', qty: 1, checked: false },
+ { name: 'Uang tunai (ATM jarang di gunung)', qty: 1, checked: false },
+ ],
+ Elektronik: [
+ { name: 'Smartphone & kabel charger tahan dingin', qty: 1, checked: false },
+ { name: 'Powerbank kapasitas besar', qty: 1, checked: false },
+ { name: 'Headlamp / Senter & baterai cadangan', qty: 1, checked: false },
+ ],
+ 'Obat-obatan': [
+ { name: 'Obat masuk angin / Tolak angin', qty: 5, checked: false },
+ { name: 'Balsem otot / Salep pegal', qty: 1, checked: false },
+ { name: 'Obat asma / Obat pribadi khusus', qty: 1, checked: false },
+ { name: 'Penghangat tubuh instant (Heat pack)', qty: 4, checked: false },
+ ],
+ Toiletries: [
+ { name: 'Lip balm pelembab bibir', qty: 1, checked: false },
+ { name: 'Moisturizer / Hand cream anti-kering', qty: 1, checked: false },
+ { name: 'Tisu basah & tisu kering travel pack', qty: 2, checked: false },
+ { name: 'Sikat gigi & pasta gigi travel', qty: 1, checked: false },
+ ],
+ Lainnya: [
+ { name: 'Jas hujan ponco / Raincoat ringan', qty: 1, checked: false },
+ { name: 'Thermos air panas mini', qty: 1, checked: false },
+ { name: 'Camilan berenergi (Cokelat/Energi bar)', qty: 3, checked: false },
+ ],
+ },
+ },
+ kota: {
+ name: 'Trip Kota & Metropolitan',
+ items: {
+ Pakaian: [
+ { name: 'Pakaian smart casual / OOTD estetik', qty: 4, checked: false },
+ { name: 'Jaket ringan / Cardigan', qty: 1, checked: false },
+ { name: 'Celana jeans / Chino nyaman', qty: 2, checked: false },
+ { name: 'Sepatu sneakers empuk untuk banyak jalan kaki', qty: 1, checked: false },
+ ],
+ Dokumen: [
+ { name: 'KTP / Paspor & Boarding pass', qty: 1, checked: false },
+ { name: 'Kartu e-Money / Transit card (MRT/Bus)', qty: 1, checked: false },
+ { name: 'Kartu kredit / E-wallet terisi saldo', qty: 1, checked: false },
+ ],
+ Elektronik: [
+ { name: 'Smartphone & Charger cepat', qty: 1, checked: false },
+ { name: 'Powerbank compact & ringan', qty: 1, checked: false },
+ { name: 'TWS Earbuds / Headphone', qty: 1, checked: false },
+ { name: 'Universal travel adapter plug', qty: 1, checked: false },
+ ],
+ 'Obat-obatan': [
+ { name: 'Obat maag & pencernaan', qty: 1, checked: false },
+ { name: 'Obat sakit kepala & pereda demam', qty: 1, checked: false },
+ { name: 'Vitamin C booster harian', qty: 1, checked: false },
+ ],
+ Toiletries: [
+ { name: 'Skincare travel size (Facial wash, serum, sunblock)', qty: 1, checked: false },
+ { name: 'Parfum / Cologne mini decant', qty: 1, checked: false },
+ { name: 'Deodorant & sisir rambut', qty: 1, checked: false },
+ ],
+ Lainnya: [
+ { name: 'Tote bag lipat / Shopping bag untuk belanja', qty: 1, checked: false },
+ { name: 'Payung lipat kecil uv-block', qty: 1, checked: false },
+ { name: 'Hand sanitizer pocket size', qty: 1, checked: false },
+ ],
+ },
+ },
+ budaya: {
+ name: 'Trip Budaya & Heritage',
+ items: {
+ Pakaian: [
+ { name: 'Pakaian sopan tertutup untuk masuk tempat ibadah/candi', qty: 3, checked: false },
+ { name: 'Kain selendang / Sarung batik', qty: 1, checked: false },
+ { name: 'Kaus katun adem yang menyerap keringat', qty: 3, checked: false },
+ { name: 'Topi pelindung matahari & kacamata', qty: 1, checked: false },
+ { name: 'Sepatu slip-on yang mudah dilepas pasang', qty: 1, checked: false },
+ ],
+ Dokumen: [
+ { name: 'KTP / Identitas diri', qty: 1, checked: false },
+ { name: 'Tiket masuk situs budaya / booking tour guide', qty: 1, checked: false },
+ { name: 'Uang pecahan kecil untuk donasi / suvenir', qty: 1, checked: false },
+ ],
+ Elektronik: [
+ { name: 'Smartphone untuk audio guide & foto', qty: 1, checked: false },
+ { name: 'Powerbank', qty: 1, checked: false },
+ ],
+ 'Obat-obatan': [
+ { name: 'Obat pereda nyeri sendi & pegal', qty: 1, checked: false },
+ { name: 'Koyo pereda pegal', qty: 1, checked: false },
+ { name: 'Minyak angin & obat diare', qty: 1, checked: false },
+ ],
+ Toiletries: [
+ { name: 'Sunscreen & lotion anti-nyamuk', qty: 1, checked: false },
+ { name: 'Tisu basah antibakteri', qty: 1, checked: false },
+ { name: 'Perlengkapan mandi dasar', qty: 1, checked: false },
+ ],
+ Lainnya: [
+ { name: 'Kipas portable mini', qty: 1, checked: false },
+ { name: 'Botol minum isi ulang', qty: 1, checked: false },
+ ],
+ },
+ },
+};
diff --git a/src/hooks/useBudget.js b/src/hooks/useBudget.js
new file mode 100644
index 0000000..a62d445
--- /dev/null
+++ b/src/hooks/useBudget.js
@@ -0,0 +1,47 @@
+import { useTourStore } from '../store/tourStore';
+
+export const useBudget = () => {
+ const budget = useTourStore((state) => state.budget);
+ const setTotalBudget = useTourStore((state) => state.setTotalBudget);
+ const setCurrency = useTourStore((state) => state.setCurrency);
+ const addExpense = useTourStore((state) => state.addExpense);
+ const updateExpense = useTourStore((state) => state.updateExpense);
+ const removeExpense = useTourStore((state) => state.removeExpense);
+
+ const totalSpent = budget.expenses.reduce((sum, item) => sum + (Number(item.amount) || 0), 0);
+ const remainingBudget = (budget.total || 0) - totalSpent;
+ const isOverBudget = remainingBudget < 0;
+ const percentUsed = budget.total > 0 ? Math.min(100, Math.round((totalSpent / budget.total) * 100)) : 0;
+
+ // Breakdown by category
+ const categoryBreakdown = budget.expenses.reduce((acc, item) => {
+ const cat = item.category || 'lainnya';
+ acc[cat] = (acc[cat] || 0) + (Number(item.amount) || 0);
+ return acc;
+ }, {});
+
+ // Breakdown by day
+ const dayBreakdown = budget.expenses.reduce((acc, item) => {
+ const dayKey = item.day ? `Hari ${item.day}` : 'Umum';
+ acc[dayKey] = (acc[dayKey] || 0) + (Number(item.amount) || 0);
+ return acc;
+ }, {});
+
+ return {
+ budget,
+ totalBudget: budget.total,
+ currency: budget.currency,
+ expenses: budget.expenses,
+ totalSpent,
+ remainingBudget,
+ isOverBudget,
+ percentUsed,
+ categoryBreakdown,
+ dayBreakdown,
+ setTotalBudget,
+ setCurrency,
+ addExpense,
+ updateExpense,
+ removeExpense,
+ };
+};
diff --git a/src/hooks/usePackingList.js b/src/hooks/usePackingList.js
new file mode 100644
index 0000000..ba09ae0
--- /dev/null
+++ b/src/hooks/usePackingList.js
@@ -0,0 +1,42 @@
+import { useTourStore } from '../store/tourStore';
+
+export const usePackingList = () => {
+ const packingList = useTourStore((state) => state.packingList);
+ const addPackingItem = useTourStore((state) => state.addPackingItem);
+ const updatePackingItem = useTourStore((state) => state.updatePackingItem);
+ const togglePackingItem = useTourStore((state) => state.togglePackingItem);
+ const removePackingItem = useTourStore((state) => state.removePackingItem);
+ const toggleAllInCategory = useTourStore((state) => state.toggleAllInCategory);
+ const loadPackingTemplate = useTourStore((state) => state.loadPackingTemplate);
+ const clearPackingList = useTourStore((state) => state.clearPackingList);
+
+ let totalItems = 0;
+ let packedItems = 0;
+
+ Object.values(packingList).forEach((items) => {
+ if (Array.isArray(items)) {
+ items.forEach((item) => {
+ totalItems++;
+ if (item.checked) packedItems++;
+ });
+ }
+ });
+
+ const percentPacked = totalItems > 0 ? Math.round((packedItems / totalItems) * 100) : 0;
+ const isComplete = totalItems > 0 && packedItems === totalItems;
+
+ return {
+ packingList,
+ totalItems,
+ packedItems,
+ percentPacked,
+ isComplete,
+ addPackingItem,
+ updatePackingItem,
+ togglePackingItem,
+ removePackingItem,
+ toggleAllInCategory,
+ loadPackingTemplate,
+ clearPackingList,
+ };
+};
diff --git a/src/hooks/useSchedule.js b/src/hooks/useSchedule.js
new file mode 100644
index 0000000..46f4205
--- /dev/null
+++ b/src/hooks/useSchedule.js
@@ -0,0 +1,29 @@
+import { useTourStore } from '../store/tourStore';
+
+export const useSchedule = (dayIndex = 0) => {
+ const schedule = useTourStore((state) => state.schedule);
+ const trip = useTourStore((state) => state.trip);
+ const addActivity = useTourStore((state) => state.addActivity);
+ const updateActivity = useTourStore((state) => state.updateActivity);
+ const removeActivity = useTourStore((state) => state.removeActivity);
+ const reorderActivities = useTourStore((state) => state.reorderActivities);
+ const moveActivity = useTourStore((state) => state.moveActivity);
+
+ const currentDay = schedule[dayIndex] || null;
+ const activities = currentDay?.activities || [];
+
+ const totalActivities = schedule.reduce((sum, d) => sum + (d.activities?.length || 0), 0);
+
+ return {
+ schedule,
+ currentDay,
+ activities,
+ totalDays: trip.totalDays || schedule.length,
+ totalActivities,
+ addActivity: (act) => addActivity(dayIndex, act),
+ updateActivity: (id, act) => updateActivity(dayIndex, id, act),
+ removeActivity: (id) => removeActivity(dayIndex, id),
+ reorderActivities: (newActs) => reorderActivities(dayIndex, newActs),
+ moveActivity,
+ };
+};
diff --git a/src/hooks/useTripStore.js b/src/hooks/useTripStore.js
new file mode 100644
index 0000000..2a8ea8c
--- /dev/null
+++ b/src/hooks/useTripStore.js
@@ -0,0 +1,18 @@
+import { useTourStore } from '../store/tourStore';
+
+export const useTrip = () => {
+ const trip = useTourStore((state) => state.trip);
+ const setTrip = useTourStore((state) => state.setTrip);
+ const updateTrip = useTourStore((state) => state.updateTrip);
+ const resetTrip = useTourStore((state) => state.resetTrip);
+
+ const isConfigured = Boolean(trip.id && trip.destination);
+
+ return {
+ trip,
+ isConfigured,
+ setTrip,
+ updateTrip,
+ resetTrip,
+ };
+};
diff --git a/src/index.css b/src/index.css
new file mode 100644
index 0000000..137526b
--- /dev/null
+++ b/src/index.css
@@ -0,0 +1,483 @@
+/* === Tour Planner Design System === */
+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@400;500;600;700;800&family=Playfair+Display:ital,wght@0,600;0,700;1,600&display=swap');
+
+:root {
+ /* Core Colors */
+ --color-primary: hsl(220, 90%, 56%);
+ --color-primary-dark: hsl(220, 90%, 42%);
+ --color-primary-light: hsl(220, 90%, 70%);
+ --color-primary-glow: hsla(220, 90%, 56%, 0.35);
+
+ --color-accent: hsl(38, 95%, 55%);
+ --color-accent-dark: hsl(38, 95%, 40%);
+ --color-accent-light: hsl(38, 95%, 70%);
+ --color-accent-glow: hsla(38, 95%, 55%, 0.3);
+
+ /* Background (Dark Mode Default) */
+ --color-bg-base: hsl(222, 28%, 8%);
+ --color-bg-surface: hsl(222, 22%, 13%);
+ --color-bg-elevated: hsl(222, 18%, 18%);
+ --color-bg-card: hsla(222, 22%, 15%, 0.85);
+ --color-bg-overlay: hsla(222, 28%, 5%, 0.82);
+
+ /* Text */
+ --color-text-primary: hsl(210, 40%, 96%);
+ --color-text-secondary: hsl(210, 20%, 68%);
+ --color-text-muted: hsl(210, 15%, 48%);
+
+ /* Borders */
+ --color-border: hsl(220, 15%, 22%);
+ --color-border-light: hsl(220, 15%, 30%);
+ --color-border-focus: hsl(220, 90%, 60%);
+
+ /* Status Colors */
+ --color-success: hsl(142, 70%, 45%);
+ --color-success-bg: hsla(142, 70%, 45%, 0.15);
+ --color-warning: hsl(38, 95%, 55%);
+ --color-warning-bg: hsla(38, 95%, 55%, 0.15);
+ --color-danger: hsl(0, 75%, 55%);
+ --color-danger-bg: hsla(0, 75%, 55%, 0.15);
+ --color-info: hsl(199, 89%, 48%);
+ --color-info-bg: hsla(199, 89%, 48%, 0.15);
+
+ /* Category Colors */
+ --color-cat-wisata: hsl(220, 90%, 56%);
+ --color-cat-makan: hsl(142, 70%, 45%);
+ --color-cat-transportasi: hsl(280, 70%, 60%);
+ --color-cat-checkin: hsl(38, 95%, 55%);
+ --color-cat-akomodasi: hsl(265, 80%, 65%);
+ --color-cat-belanja: hsl(340, 75%, 58%);
+ --color-cat-lainnya: hsl(210, 20%, 55%);
+
+ /* Typography */
+ --font-body: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ --font-display: 'Outfit', sans-serif;
+ --font-hero: 'Playfair Display', serif;
+
+ --text-xs: 0.75rem; /* 12px */
+ --text-sm: 0.875rem; /* 14px */
+ --text-base: 1rem; /* 16px */
+ --text-lg: 1.125rem; /* 18px */
+ --text-xl: 1.25rem; /* 20px */
+ --text-2xl: 1.5rem; /* 24px */
+ --text-3xl: 1.875rem; /* 30px */
+ --text-4xl: 2.25rem; /* 36px */
+ --text-5xl: 3rem; /* 48px */
+
+ /* Spacing */
+ --space-1: 0.25rem;
+ --space-2: 0.5rem;
+ --space-3: 0.75rem;
+ --space-4: 1rem;
+ --space-5: 1.25rem;
+ --space-6: 1.5rem;
+ --space-8: 2rem;
+ --space-10: 2.5rem;
+ --space-12: 3rem;
+ --space-16: 4rem;
+ --space-20: 5rem;
+
+ /* Border Radius */
+ --radius-xs: 0.25rem;
+ --radius-sm: 0.375rem;
+ --radius-md: 0.5rem;
+ --radius-lg: 0.75rem;
+ --radius-xl: 1rem;
+ --radius-2xl: 1.5rem;
+ --radius-full: 9999px;
+
+ /* Shadows & Glow */
+ --shadow-sm: 0 2px 4px hsla(0, 0%, 0%, 0.3);
+ --shadow-md: 0 6px 18px hsla(0, 0%, 0%, 0.4);
+ --shadow-lg: 0 12px 36px hsla(0, 0%, 0%, 0.5);
+ --shadow-glow-primary: 0 0 25px var(--color-primary-glow);
+ --shadow-glow-accent: 0 0 25px var(--color-accent-glow);
+
+ /* Transitions */
+ --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
+ --transition-base: 300ms cubic-bezier(0.4, 0, 0.2, 1);
+ --transition-slow: 500ms cubic-bezier(0.4, 0, 0.2, 1);
+
+ /* Z-Index */
+ --z-dropdown: 100;
+ --z-sticky: 150;
+ --z-modal-backdrop: 200;
+ --z-modal: 210;
+ --z-toast: 300;
+ --z-tooltip: 400;
+}
+
+/* === CSS Reset & Base Rules === */
+*, *::before, *::after {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+html {
+ scroll-behavior: smooth;
+}
+
+body {
+ font-family: var(--font-body);
+ background-color: var(--color-bg-base);
+ color: var(--color-text-primary);
+ line-height: 1.6;
+ min-height: 100vh;
+ overflow-x: hidden;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+/* Custom Scrollbar */
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: var(--color-bg-base);
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--color-border);
+ border-radius: var(--radius-full);
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: var(--color-border-light);
+}
+
+/* Typography Hierarchy */
+h1, h2, h3, h4, h5, h6 {
+ font-family: var(--font-display);
+ font-weight: 700;
+ line-height: 1.25;
+ color: var(--color-text-primary);
+}
+
+h1 { font-size: var(--text-4xl); }
+h2 { font-size: var(--text-3xl); }
+h3 { font-size: var(--text-2xl); }
+h4 { font-size: var(--text-xl); }
+h5 { font-size: var(--text-lg); }
+h6 { font-size: var(--text-base); }
+
+p {
+ color: var(--color-text-secondary);
+ font-size: var(--text-base);
+}
+
+a {
+ color: var(--color-primary-light);
+ text-decoration: none;
+ transition: color var(--transition-fast);
+}
+
+a:hover {
+ color: var(--color-accent);
+}
+
+button {
+ font-family: var(--font-body);
+ cursor: pointer;
+ border: none;
+ outline: none;
+ background: none;
+}
+
+input, select, textarea {
+ font-family: var(--font-body);
+ color: var(--color-text-primary);
+}
+
+/* === Utility Classes === */
+.container {
+ width: 100%;
+ max-width: 1280px;
+ margin: 0 auto;
+ padding: 0 var(--space-6);
+}
+
+.page-wrapper {
+ min-height: calc(100vh - 80px);
+ padding: var(--space-8) 0 var(--space-16);
+}
+
+.page-header {
+ margin-bottom: var(--space-8);
+}
+
+.page-title {
+ font-size: var(--text-3xl);
+ font-family: var(--font-display);
+ margin-bottom: var(--space-2);
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+}
+
+.page-subtitle {
+ font-size: var(--text-base);
+ color: var(--color-text-secondary);
+ max-width: 650px;
+}
+
+/* Glassmorphism */
+.glass {
+ background: hsla(222, 22%, 15%, 0.75);
+ backdrop-filter: blur(16px);
+ -webkit-backdrop-filter: blur(16px);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-xl);
+ box-shadow: var(--shadow-md);
+}
+
+.glass-dark {
+ background: hsla(222, 28%, 10%, 0.85);
+ backdrop-filter: blur(20px);
+ -webkit-backdrop-filter: blur(20px);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-xl);
+}
+
+.glass-card {
+ background: linear-gradient(135deg, hsla(222, 22%, 18%, 0.8), hsla(222, 22%, 12%, 0.9));
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-xl);
+ transition: transform var(--transition-base), border-color var(--transition-base), box-shadow var(--transition-base);
+}
+
+.glass-card:hover {
+ border-color: var(--color-border-light);
+ box-shadow: var(--shadow-lg), var(--shadow-glow-primary);
+ transform: translateY(-4px);
+}
+
+/* Gradient Utilities */
+.text-gradient {
+ background: linear-gradient(135deg, var(--color-primary-light) 0%, var(--color-accent) 100%);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+
+.text-gradient-hero {
+ background: linear-gradient(135deg, hsl(0, 0%, 100%) 30%, var(--color-accent-light) 100%);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+
+.bg-gradient-hero {
+ background:
+ radial-gradient(ellipse at 15% 30%, hsla(220, 90%, 40%, 0.25) 0%, transparent 60%),
+ radial-gradient(ellipse at 85% 20%, hsla(38, 95%, 45%, 0.18) 0%, transparent 50%),
+ radial-gradient(ellipse at 50% 80%, hsla(280, 70%, 40%, 0.12) 0%, transparent 60%),
+ var(--color-bg-base);
+}
+
+/* Grid System */
+.grid-1 { display: grid; grid-template-columns: 1fr; gap: var(--space-6); }
+.grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--space-6); }
+.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-6); }
+.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--space-6); }
+
+/* Responsive Grids */
+@media (max-width: 1100px) {
+ .grid-4 { grid-template-columns: repeat(3, 1fr); }
+}
+
+@media (max-width: 860px) {
+ .grid-4 { grid-template-columns: repeat(2, 1fr); }
+ .grid-3 { grid-template-columns: repeat(2, 1fr); }
+}
+
+@media (max-width: 600px) {
+ .grid-4, .grid-3, .grid-2 { grid-template-columns: 1fr; }
+ .container { padding: 0 var(--space-4); }
+ h1 { font-size: var(--text-3xl); }
+ .page-title { font-size: var(--text-2xl); }
+}
+
+/* Flex Utilities */
+.flex-center {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.flex-between {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.flex-gap-2 { display: flex; gap: var(--space-2); }
+.flex-gap-4 { display: flex; gap: var(--space-4); }
+.flex-col { display: flex; flex-direction: column; }
+
+/* Form Controls */
+.input-field, .select-field, .textarea-field {
+ width: 100%;
+ background: hsla(222, 28%, 10%, 0.8);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ padding: var(--space-3) var(--space-4);
+ font-size: var(--text-sm);
+ color: var(--color-text-primary);
+ transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
+}
+
+.input-field:focus, .select-field:focus, .textarea-field:focus {
+ border-color: var(--color-border-focus);
+ box-shadow: 0 0 0 3px hsla(220, 90%, 56%, 0.2);
+ outline: none;
+}
+
+.input-field::placeholder, .textarea-field::placeholder {
+ color: var(--color-text-muted);
+}
+
+.input-group {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ margin-bottom: var(--space-4);
+}
+
+.input-label {
+ font-size: var(--text-sm);
+ font-weight: 500;
+ color: var(--color-text-secondary);
+}
+
+/* Badges */
+.badge {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1);
+ padding: var(--space-1) var(--space-3);
+ border-radius: var(--radius-full);
+ font-size: var(--text-xs);
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ text-transform: uppercase;
+}
+
+.badge-primary { background: var(--color-primary-glow); color: var(--color-primary-light); border: 1px solid hsla(220, 90%, 56%, 0.3); }
+.badge-accent { background: var(--color-accent-glow); color: var(--color-accent-light); border: 1px solid hsla(38, 95%, 55%, 0.3); }
+.badge-success { background: var(--color-success-bg); color: var(--color-success); border: 1px solid hsla(142, 70%, 45%, 0.3); }
+.badge-warning { background: var(--color-warning-bg); color: var(--color-warning); border: 1px solid hsla(38, 95%, 55%, 0.3); }
+.badge-danger { background: var(--color-danger-bg); color: var(--color-danger); border: 1px solid hsla(0, 75%, 55%, 0.3); }
+.badge-neutral { background: hsla(220, 15%, 25%, 0.6); color: var(--color-text-secondary); border: 1px solid var(--color-border); }
+
+/* Buttons */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-2);
+ padding: var(--space-3) var(--space-5);
+ font-size: var(--text-sm);
+ font-weight: 600;
+ border-radius: var(--radius-md);
+ transition: all var(--transition-fast);
+ cursor: pointer;
+ user-select: none;
+ white-space: nowrap;
+}
+
+.btn-primary {
+ background: linear-gradient(135deg, var(--color-primary), var(--color-primary-dark));
+ color: #ffffff;
+ box-shadow: 0 4px 14px var(--color-primary-glow);
+}
+
+.btn-primary:hover {
+ background: linear-gradient(135deg, var(--color-primary-light), var(--color-primary));
+ box-shadow: 0 6px 20px hsla(220, 90%, 56%, 0.5);
+ transform: translateY(-1px);
+}
+
+.btn-primary:active {
+ transform: translateY(0);
+}
+
+.btn-accent {
+ background: linear-gradient(135deg, var(--color-accent), var(--color-accent-dark));
+ color: hsl(222, 28%, 8%);
+ font-weight: 700;
+ box-shadow: 0 4px 14px var(--color-accent-glow);
+}
+
+.btn-accent:hover {
+ background: linear-gradient(135deg, var(--color-accent-light), var(--color-accent));
+ box-shadow: 0 6px 20px hsla(38, 95%, 55%, 0.5);
+ transform: translateY(-1px);
+}
+
+.btn-secondary {
+ background: var(--color-bg-elevated);
+ color: var(--color-text-primary);
+ border: 1px solid var(--color-border);
+}
+
+.btn-secondary:hover {
+ background: hsla(222, 18%, 24%, 1);
+ border-color: var(--color-border-light);
+ transform: translateY(-1px);
+}
+
+.btn-ghost {
+ background: transparent;
+ color: var(--color-text-secondary);
+}
+
+.btn-ghost:hover {
+ background: hsla(220, 15%, 25%, 0.4);
+ color: var(--color-text-primary);
+}
+
+.btn-danger {
+ background: var(--color-danger-bg);
+ color: var(--color-danger);
+ border: 1px solid hsla(0, 75%, 55%, 0.3);
+}
+
+.btn-danger:hover {
+ background: var(--color-danger);
+ color: #ffffff;
+}
+
+.btn-sm {
+ padding: var(--space-1) var(--space-3);
+ font-size: var(--text-xs);
+ border-radius: var(--radius-sm);
+}
+
+.btn-lg {
+ padding: var(--space-4) var(--space-8);
+ font-size: var(--text-base);
+ border-radius: var(--radius-lg);
+}
+
+.btn-icon {
+ padding: var(--space-2);
+ border-radius: var(--radius-md);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+/* Animations Keyframes */
+@keyframes pulse-slow {
+ 0%, 100% { opacity: 0.6; transform: scale(1); }
+ 50% { opacity: 0.9; transform: scale(1.05); }
+}
+
+.animate-pulse-slow {
+ animation: pulse-slow 6s ease-in-out infinite;
+}
diff --git a/src/main.jsx b/src/main.jsx
new file mode 100644
index 0000000..b9a1a6d
--- /dev/null
+++ b/src/main.jsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import './index.css'
+import App from './App.jsx'
+
+createRoot(document.getElementById('root')).render(
+
+
+ ,
+)
diff --git a/src/pages/Budget.jsx b/src/pages/Budget.jsx
new file mode 100644
index 0000000..66319b2
--- /dev/null
+++ b/src/pages/Budget.jsx
@@ -0,0 +1,244 @@
+import React, { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import {
+ WalletCards,
+ Plus,
+ MapPin,
+ CalendarDays,
+ Luggage,
+ DollarSign,
+ TrendingUp,
+ CreditCard,
+} from 'lucide-react';
+import PageWrapper from '../components/layout/PageWrapper';
+import BudgetOverview from '../components/budget/BudgetOverview';
+import BudgetChart from '../components/budget/BudgetChart';
+import ExpenseList from '../components/budget/ExpenseList';
+import AddExpenseModal from '../components/budget/AddExpenseModal';
+import Button from '../components/ui/Button';
+import { useTourStore } from '../store/tourStore';
+import { useBudget } from '../hooks/useBudget';
+import { toast } from '../store/toastStore';
+
+const Budget = () => {
+ const navigate = useNavigate();
+ const trip = useTourStore((state) => state.trip);
+ const {
+ budget,
+ totalBudget,
+ currency,
+ expenses,
+ totalSpent,
+ remainingBudget,
+ isOverBudget,
+ percentUsed,
+ categoryBreakdown,
+ dayBreakdown,
+ setTotalBudget,
+ setCurrency,
+ addExpense,
+ updateExpense,
+ removeExpense,
+ } = useBudget();
+
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const [editingExpense, setEditingExpense] = useState(null);
+
+ const handleOpenAddModal = () => {
+ setEditingExpense(null);
+ setIsModalOpen(true);
+ };
+
+ const handleOpenEditModal = (expense) => {
+ setEditingExpense(expense);
+ setIsModalOpen(true);
+ };
+
+ const handleSaveExpense = (expenseData) => {
+ if (expenseData.id) {
+ updateExpense(expenseData.id, expenseData);
+ toast.success(`Pengeluaran "${expenseData.name}" berhasil diperbarui!`);
+ } else {
+ addExpense(expenseData);
+ toast.success(`Pengeluaran "${expenseData.name}" berhasil dicatat!`);
+ if (totalBudget > 0 && totalSpent + expenseData.amount > totalBudget) {
+ toast.warning('Perhatian: Total pengeluaran kini melebihi target anggaran!');
+ }
+ }
+ };
+
+ const handleDeleteExpense = (id) => {
+ removeExpense(id);
+ toast.info('Pengeluaran berhasil dihapus.');
+ };
+
+ const handleCurrencyChange = (newCurr) => {
+ setCurrency(newCurr);
+ toast.info(`Mata uang diubah ke ${newCurr}`);
+ };
+
+ const handleUpdateTarget = (val) => {
+ setTotalBudget(val);
+ toast.success('Target anggaran berhasil diperbarui!');
+ };
+
+ return (
+
+ {/* Top Banner: Trip Info & Currency Selector */}
+
+
+
+
+
+
+
+ {trip.destination ? `${trip.destination} (${trip.totalDays || 1} Hari)` : 'Kalkulator Anggaran'}
+
+
+ Kalkulator & Estimasi Budget
+
+
+ Pantau arus pengeluaran liburan agar tetap sesuai dengan batas anggaran.
+
+
+
+
+ {/* Currency Toggle & Quick Nav Shortcuts */}
+
+ {/* Currency Toggle Selector */}
+
+ {['IDR', 'USD', 'EUR'].map((curr) => (
+ handleCurrencyChange(curr)}
+ style={{
+ padding: '4px 10px',
+ borderRadius: 'var(--radius-sm)',
+ fontSize: '11px',
+ fontWeight: 700,
+ cursor: 'pointer',
+ backgroundColor: currency === curr ? 'var(--color-primary)' : 'transparent',
+ color: currency === curr ? '#ffffff' : 'var(--color-text-secondary)',
+ transition: 'all var(--transition-fast)',
+ }}
+ >
+ {curr}
+
+ ))}
+
+
+
navigate('/schedule')}
+ >
+ Jadwal
+
+
+
navigate('/packing')}
+ >
+ Packing List
+
+
+
+ Tambah Biaya
+
+
+
+
+ {/* Budget Overview Widget (Cards & Progress) */}
+
+
+ {/* Recharts Visual Charts (Pie & Bar) */}
+
+
+ {/* Expense List and Categorized Items */}
+
+
+ {/* Add / Edit Expense Modal */}
+ setIsModalOpen(false)}
+ onSave={handleSaveExpense}
+ initialData={editingExpense}
+ totalDays={trip.totalDays || 1}
+ currency={currency}
+ />
+
+ );
+};
+
+export default Budget;
diff --git a/src/pages/Home.jsx b/src/pages/Home.jsx
new file mode 100644
index 0000000..aaadff0
--- /dev/null
+++ b/src/pages/Home.jsx
@@ -0,0 +1,220 @@
+import React, { useState, useMemo, useRef } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { Compass, Sparkles, MapPin, Plus, ArrowRight, RefreshCw, CalendarDays } from 'lucide-react';
+import HeroSection from '../components/trip/HeroSection';
+import FilterBar from '../components/trip/FilterBar';
+import TripCard from '../components/trip/TripCard';
+import TripModal from '../components/trip/TripModal';
+import CustomTripModal from '../components/trip/CustomTripModal';
+import EmptyState from '../components/ui/EmptyState';
+import Button from '../components/ui/Button';
+import { destinations } from '../data/destinations';
+import { useTourStore } from '../store/tourStore';
+import { useNavigate } from 'react-router-dom';
+
+const Home = () => {
+ const navigate = useNavigate();
+ const trip = useTourStore((state) => state.trip);
+
+ const [searchQuery, setSearchQuery] = useState('');
+ const [activeCategory, setActiveCategory] = useState('semua');
+ const [sortBy, setSortBy] = useState('popular');
+ const [selectedDestination, setSelectedDestination] = useState(null);
+ const [isPresetModalOpen, setIsPresetModalOpen] = useState(false);
+ const [isCustomModalOpen, setIsCustomModalOpen] = useState(false);
+
+ const exploreSectionRef = useRef(null);
+
+ const handleExploreScroll = () => {
+ exploreSectionRef.current?.scrollIntoView({ behavior: 'smooth' });
+ };
+
+ const handleSelectDestination = (dest) => {
+ setSelectedDestination(dest);
+ setIsPresetModalOpen(true);
+ };
+
+ // Filter & Sort Logic
+ const filteredDestinations = useMemo(() => {
+ return destinations
+ .filter((dest) => {
+ // Category filter
+ const matchesCategory =
+ activeCategory === 'semua' ||
+ dest.category.toLowerCase() === activeCategory.toLowerCase() ||
+ (activeCategory === 'gunung' && dest.category === 'alam');
+
+ // Search query
+ const query = searchQuery.toLowerCase().trim();
+ const matchesSearch =
+ !query ||
+ dest.name.toLowerCase().includes(query) ||
+ dest.location.toLowerCase().includes(query) ||
+ dest.description.toLowerCase().includes(query) ||
+ (dest.tags && dest.tags.some((t) => t.toLowerCase().includes(query))) ||
+ (dest.highlights && dest.highlights.some((h) => h.toLowerCase().includes(query)));
+
+ return matchesCategory && matchesSearch;
+ })
+ .sort((a, b) => {
+ if (sortBy === 'rating') {
+ return b.rating - a.rating;
+ }
+ if (sortBy === 'budget-low') {
+ return (a.estimatedBudget?.min || 0) - (b.estimatedBudget?.min || 0);
+ }
+ if (sortBy === 'duration') {
+ return (a.popularDuration || 0) - (b.popularDuration || 0);
+ }
+ // default 'popular' -> reviewCount
+ return (b.reviewCount || 0) - (a.reviewCount || 0);
+ });
+ }, [searchQuery, activeCategory, sortBy]);
+
+ return (
+
+ {/* Hero Section */}
+
setIsCustomModalOpen(true)}
+ />
+
+ {/* Main Content Area */}
+
+ {/* Active Trip Banner if configured */}
+ {trip.id && trip.destination && (
+
+
+
+
+
+
+
+ Trip Sedang Aktif
+
+
+ {trip.name || trip.destination} • {trip.totalDays} Hari
+
+
+
+
+
+ navigate('/schedule')}
+ >
+ Buka Jadwal Saya
+
+
+
+ )}
+
+ {/* Section Header */}
+
+
+
+
+
+ Pilih Destinasi Wisata
+
+
+ Temukan {destinations.length} destinasi unggulan atau rancang rencana perjalanan sendiri.
+
+
+
+
setIsCustomModalOpen(true)}
+ >
+ Buat Trip Kustom
+
+
+
+
+ {/* Search & Filter Component */}
+
+
+ {/* Destinations Grid */}
+ {filteredDestinations.length > 0 ? (
+
+
+ {filteredDestinations.map((destination) => (
+
+ ))}
+
+
+ ) : (
+
setIsCustomModalOpen(true)}
+ />
+ )}
+
+
+ {/* Preset Trip Configuration Modal */}
+ setIsPresetModalOpen(false)}
+ destination={selectedDestination}
+ />
+
+ {/* Custom Trip Modal */}
+ setIsCustomModalOpen(false)}
+ />
+
+ );
+};
+
+export default Home;
diff --git a/src/pages/PackingList.jsx b/src/pages/PackingList.jsx
new file mode 100644
index 0000000..f6cbd0d
--- /dev/null
+++ b/src/pages/PackingList.jsx
@@ -0,0 +1,224 @@
+import React, { useState, useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { Luggage, Search, Plus, Sparkles, MapPin, CalendarDays, WalletCards, ArrowRight, Layers, CheckCircle2 } from 'lucide-react';
+import PageWrapper from '../components/layout/PageWrapper';
+import PackingOverview from '../components/packing/PackingOverview';
+import PackingCategory from '../components/packing/PackingCategory';
+import TemplateModal from '../components/packing/TemplateModal';
+import Button from '../components/ui/Button';
+import EmptyState from '../components/ui/EmptyState';
+import { useTourStore } from '../store/tourStore';
+import { usePackingList } from '../hooks/usePackingList';
+import { PACKING_CATEGORIES } from '../data/packingTemplates';
+import { toast } from '../store/toastStore';
+
+const PackingList = () => {
+ const navigate = useNavigate();
+ const trip = useTourStore((state) => state.trip);
+ const {
+ packingList,
+ totalItems,
+ packedItems,
+ percentPacked,
+ isComplete,
+ addPackingItem,
+ updatePackingItem,
+ togglePackingItem,
+ removePackingItem,
+ toggleAllInCategory,
+ loadPackingTemplate,
+ clearPackingList,
+ } = usePackingList();
+
+ const [isTemplateModalOpen, setIsTemplateModalOpen] = useState(false);
+ const [searchFilter, setSearchFilter] = useState('');
+
+ // If packing list is empty, auto-load template according to trip category
+ useEffect(() => {
+ if (totalItems === 0) {
+ loadPackingTemplate(trip.category || 'pantai');
+ }
+ }, [totalItems, trip.category]);
+
+ const handleResetChecklist = () => {
+ PACKING_CATEGORIES.forEach((cat) => {
+ toggleAllInCategory(cat, false);
+ });
+ toast.info('Status centang packing list direset.');
+ };
+
+ const handleUpdateQty = (category, itemId, newQty) => {
+ updatePackingItem(category, itemId, { qty: newQty });
+ };
+
+ const handleAddItem = (category, itemData) => {
+ addPackingItem(category, itemData);
+ toast.success(`"${itemData.name}" ditambahkan ke ${category}!`);
+ };
+
+ const handleDeleteItem = (category, itemId) => {
+ removePackingItem(category, itemId);
+ toast.info('Barang berhasil dihapus.');
+ };
+
+ const handleApplyTemplate = (templateKey) => {
+ loadPackingTemplate(templateKey);
+ toast.success(`Template ${templateKey} berhasil dimuat!`);
+ };
+
+ return (
+
+ {/* Top Banner: Trip Info Header */}
+
+
+
+
+
+
+
+ {trip.destination ? `${trip.destination} (${trip.totalDays || 1} Hari)` : 'Daftar Perlengkapan'}
+
+
+ Packing List & Bawaan
+
+
+ Pastikan dokumen penting, pakaian, dan peralatan esensial sudah masuk koper.
+
+
+
+
+ {/* Quick Nav Shortcuts */}
+
+ navigate('/schedule')}
+ >
+ Lihat Jadwal
+
+ navigate('/budget')}
+ >
+ Lihat Budget
+
+ setIsTemplateModalOpen(true)}
+ >
+ Template Bawaan
+
+
+
+
+ {/* Progress Overview Widget */}
+ setIsTemplateModalOpen(true)}
+ onResetAll={handleResetChecklist}
+ />
+
+ {/* Search Filter Bar */}
+
+
+ setSearchFilter(e.target.value)}
+ style={{ background: 'transparent', border: 'none', padding: 0 }}
+ />
+
+
+ {/* Categories List */}
+
+ {PACKING_CATEGORIES.map((categoryName) => {
+ const categoryItems = (packingList[categoryName] || []).filter((item) => {
+ if (!searchFilter.trim()) return true;
+ return item.name.toLowerCase().includes(searchFilter.toLowerCase().trim());
+ });
+
+ // Skip empty categories when search query is active and nothing matches
+ if (searchFilter.trim() && categoryItems.length === 0) return null;
+
+ return (
+
+ );
+ })}
+
+
+ {/* Template Selection Modal */}
+ setIsTemplateModalOpen(false)}
+ onApplyTemplate={handleApplyTemplate}
+ currentCategory={trip.category}
+ />
+
+ );
+};
+
+export default PackingList;
diff --git a/src/pages/Schedule.jsx b/src/pages/Schedule.jsx
new file mode 100644
index 0000000..4c1fcbc
--- /dev/null
+++ b/src/pages/Schedule.jsx
@@ -0,0 +1,225 @@
+import React, { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { motion } from 'framer-motion';
+import {
+ CalendarDays,
+ MapPin,
+ Clock,
+ Plus,
+ Compass,
+ ArrowRight,
+ Luggage,
+ WalletCards,
+ Sparkles,
+ Layers,
+} from 'lucide-react';
+import PageWrapper from '../components/layout/PageWrapper';
+import DayTabs from '../components/schedule/DayTabs';
+import ScheduleTimeline from '../components/schedule/ScheduleTimeline';
+import AddActivityModal from '../components/schedule/AddActivityModal';
+import Button from '../components/ui/Button';
+import EmptyState from '../components/ui/EmptyState';
+import { useTourStore } from '../store/tourStore';
+import { useSchedule } from '../hooks/useSchedule';
+import { toast } from '../store/toastStore';
+
+const Schedule = () => {
+ const navigate = useNavigate();
+ const trip = useTourStore((state) => state.trip);
+ const schedule = useTourStore((state) => state.schedule);
+ const addActivity = useTourStore((state) => state.addActivity);
+ const updateActivity = useTourStore((state) => state.updateActivity);
+ const removeActivity = useTourStore((state) => state.removeActivity);
+ const reorderActivities = useTourStore((state) => state.reorderActivities);
+ const updateTrip = useTourStore((state) => state.updateTrip);
+ const initSchedule = useTourStore((state) => state.initSchedule);
+
+ const [selectedDayIndex, setSelectedDayIndex] = useState(0);
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const [editingActivity, setEditingActivity] = useState(null);
+
+ const currentDay = schedule[selectedDayIndex] || schedule[0];
+ const totalActivities = schedule.reduce((sum, d) => sum + (d.activities?.length || 0), 0);
+
+ // If no trip configured yet
+ if (!trip.id || !trip.destination) {
+ return (
+
+
+
+
+ Jadwal Harian Perjalanan
+
+
+ Anda belum memilih destinasi wisata untuk membuat itinerary.
+
+
+
+ navigate('/')}
+ />
+
+ );
+ }
+
+ const handleOpenAddModal = () => {
+ setEditingActivity(null);
+ setIsModalOpen(true);
+ };
+
+ const handleOpenEditModal = (activity) => {
+ setEditingActivity(activity);
+ setIsModalOpen(true);
+ };
+
+ const handleSaveActivity = (activityData) => {
+ if (activityData.id) {
+ updateActivity(selectedDayIndex, activityData.id, activityData);
+ toast.success(`Aktivitas "${activityData.name}" berhasil diperbarui!`);
+ } else {
+ addActivity(selectedDayIndex, activityData);
+ toast.success(`Aktivitas "${activityData.name}" ditambahkan ke Hari ${selectedDayIndex + 1}!`);
+ }
+ };
+
+ const handleDeleteActivity = (activityId) => {
+ removeActivity(selectedDayIndex, activityId);
+ toast.info('Aktivitas berhasil dihapus.');
+ };
+
+ const handleAddDay = () => {
+ const newTotalDays = (trip.totalDays || schedule.length) + 1;
+ updateTrip({ totalDays: newTotalDays });
+ initSchedule(newTotalDays, trip.startDate);
+ setSelectedDayIndex(newTotalDays - 1);
+ toast.success(`Hari ke-${newTotalDays} berhasil ditambahkan ke jadwal!`);
+ };
+
+ return (
+
+ {/* Top Banner Header: Trip Info & Summary Stats */}
+
+ {/* Left Side: Destination Info */}
+
+ {trip.coverImage && (
+
+
+
+ )}
+
+
+ {trip.destination}
+
+
+ {trip.name || `Itinerary ${trip.destination}`}
+
+
+ {trip.totalDays} Hari Total • {totalActivities} Aktivitas Terjadwal
+
+
+
+
+ {/* Right Side: Quick Action Pills to Packing & Budget */}
+
+ navigate('/packing')}
+ >
+ Lihat Packing List
+
+ navigate('/budget')}
+ >
+ Lihat Budget
+
+
+ Tambah Aktivitas
+
+
+
+
+ {/* Day Navigation Tabs */}
+
+
+ {/* Daily Timeline */}
+
+
+ {/* Add / Edit Activity Modal */}
+ setIsModalOpen(false)}
+ onSave={handleSaveActivity}
+ initialData={editingActivity}
+ dayNumber={selectedDayIndex + 1}
+ />
+
+ );
+};
+
+export default Schedule;
diff --git a/src/store/toastStore.js b/src/store/toastStore.js
new file mode 100644
index 0000000..f58896b
--- /dev/null
+++ b/src/store/toastStore.js
@@ -0,0 +1,31 @@
+import { create } from 'zustand';
+
+export const useToastStore = create((set, get) => ({
+ toasts: [],
+ addToast: ({ message, type = 'success', duration = 3000 }) => {
+ const id = 'toast-' + Math.random().toString(36).substring(2, 9) + '-' + Date.now();
+ const newToast = { id, message, type };
+
+ set((state) => ({
+ toasts: [...state.toasts, newToast],
+ }));
+
+ if (duration > 0) {
+ setTimeout(() => {
+ get().removeToast(id);
+ }, duration);
+ }
+ },
+ removeToast: (id) => {
+ set((state) => ({
+ toasts: state.toasts.filter((t) => t.id !== id),
+ }));
+ },
+}));
+
+export const toast = {
+ success: (msg, duration) => useToastStore.getState().addToast({ message: msg, type: 'success', duration }),
+ info: (msg, duration) => useToastStore.getState().addToast({ message: msg, type: 'info', duration }),
+ warning: (msg, duration) => useToastStore.getState().addToast({ message: msg, type: 'warning', duration }),
+ danger: (msg, duration) => useToastStore.getState().addToast({ message: msg, type: 'danger', duration }),
+};
diff --git a/src/store/tourStore.js b/src/store/tourStore.js
new file mode 100644
index 0000000..d957d9d
--- /dev/null
+++ b/src/store/tourStore.js
@@ -0,0 +1,372 @@
+import { create } from 'zustand';
+import { persist, createJSONStorage } from 'zustand/middleware';
+import { generateId } from '../utils/helpers';
+import { getDateForDayIndex, calculateTotalDays } from '../utils/dateHelpers';
+import { packingTemplates } from '../data/packingTemplates';
+
+const initialTripState = {
+ id: null,
+ name: '',
+ destination: '',
+ location: '',
+ startDate: null,
+ endDate: null,
+ totalDays: 0,
+ coverImage: '',
+ type: 'custom', // 'preset' | 'custom'
+ category: 'pantai',
+};
+
+const initialPackingState = {
+ Pakaian: [],
+ Dokumen: [],
+ Elektronik: [],
+ 'Obat-obatan': [],
+ Toiletries: [],
+ Lainnya: [],
+};
+
+const initialBudgetState = {
+ total: 0,
+ currency: 'IDR',
+ expenses: [],
+};
+
+export const useTourStore = create(
+ persist(
+ (set, get) => ({
+ // State
+ trip: initialTripState,
+ schedule: [],
+ packingList: initialPackingState,
+ budget: initialBudgetState,
+
+ // === TRIP ACTIONS ===
+ setTrip: (tripData) => {
+ const totalDays = tripData.startDate && tripData.endDate
+ ? calculateTotalDays(tripData.startDate, tripData.endDate)
+ : (tripData.totalDays || 1);
+
+ const newTrip = {
+ ...get().trip,
+ ...tripData,
+ totalDays,
+ };
+
+ set({ trip: newTrip });
+
+ // Auto-initialize schedule if empty or if day count changed
+ const currentSchedule = get().schedule;
+ if (currentSchedule.length !== totalDays) {
+ get().initSchedule(totalDays, newTrip.startDate);
+ }
+
+ // Auto-load template packing if packingList is completely empty
+ const isPackingEmpty = Object.values(get().packingList).every((arr) => arr.length === 0);
+ if (isPackingEmpty && newTrip.category) {
+ get().loadPackingTemplate(newTrip.category);
+ }
+ },
+
+ updateTrip: (data) => {
+ set((state) => ({
+ trip: { ...state.trip, ...data },
+ }));
+ },
+
+ resetTrip: () => {
+ set({ trip: initialTripState, schedule: [] });
+ },
+
+ // === SCHEDULE ACTIONS ===
+ initSchedule: (totalDays, startDate) => {
+ const currentSchedule = get().schedule;
+ const newSchedule = [];
+
+ for (let i = 0; i < totalDays; i++) {
+ const existingDay = currentSchedule[i];
+ const dayDate = getDateForDayIndex(startDate, i);
+
+ newSchedule.push({
+ dayNumber: i + 1,
+ date: dayDate,
+ activities: existingDay ? existingDay.activities : [],
+ });
+ }
+
+ set({ schedule: newSchedule });
+ },
+
+ setSchedule: (newSchedule) => {
+ set({ schedule: newSchedule });
+ },
+
+ addActivity: (dayIndex, activityData) => {
+ const newActivity = {
+ id: activityData.id || generateId(),
+ time: activityData.time || '09:00',
+ name: activityData.name || 'Aktivitas Baru',
+ location: activityData.location || '',
+ duration: activityData.duration || 60,
+ category: activityData.category || 'wisata',
+ notes: activityData.notes || '',
+ color: activityData.color || '',
+ };
+
+ set((state) => {
+ const updatedSchedule = [...state.schedule];
+ if (!updatedSchedule[dayIndex]) {
+ // ensure day exists
+ updatedSchedule[dayIndex] = {
+ dayNumber: dayIndex + 1,
+ date: getDateForDayIndex(state.trip.startDate, dayIndex),
+ activities: [],
+ };
+ }
+
+ const dayActivities = [...(updatedSchedule[dayIndex].activities || []), newActivity];
+ // Sort by time
+ dayActivities.sort((a, b) => (a.time || '').localeCompare(b.time || ''));
+
+ updatedSchedule[dayIndex] = {
+ ...updatedSchedule[dayIndex],
+ activities: dayActivities,
+ };
+
+ return { schedule: updatedSchedule };
+ });
+ },
+
+ updateActivity: (dayIndex, activityId, updatedData) => {
+ set((state) => {
+ const updatedSchedule = [...state.schedule];
+ if (!updatedSchedule[dayIndex]) return state;
+
+ const dayActivities = updatedSchedule[dayIndex].activities.map((act) =>
+ act.id === activityId ? { ...act, ...updatedData } : act
+ );
+
+ dayActivities.sort((a, b) => (a.time || '').localeCompare(b.time || ''));
+
+ updatedSchedule[dayIndex] = {
+ ...updatedSchedule[dayIndex],
+ activities: dayActivities,
+ };
+
+ return { schedule: updatedSchedule };
+ });
+ },
+
+ removeActivity: (dayIndex, activityId) => {
+ set((state) => {
+ const updatedSchedule = [...state.schedule];
+ if (!updatedSchedule[dayIndex]) return state;
+
+ updatedSchedule[dayIndex] = {
+ ...updatedSchedule[dayIndex],
+ activities: updatedSchedule[dayIndex].activities.filter((act) => act.id !== activityId),
+ };
+
+ return { schedule: updatedSchedule };
+ });
+ },
+
+ reorderActivities: (dayIndex, newActivities) => {
+ set((state) => {
+ const updatedSchedule = [...state.schedule];
+ if (!updatedSchedule[dayIndex]) return state;
+
+ updatedSchedule[dayIndex] = {
+ ...updatedSchedule[dayIndex],
+ activities: newActivities,
+ };
+
+ return { schedule: updatedSchedule };
+ });
+ },
+
+ moveActivity: (fromDayIndex, toDayIndex, activityId) => {
+ set((state) => {
+ const updatedSchedule = [...state.schedule];
+ const fromDay = updatedSchedule[fromDayIndex];
+ const toDay = updatedSchedule[toDayIndex];
+ if (!fromDay || !toDay) return state;
+
+ const activityToMove = fromDay.activities.find((a) => a.id === activityId);
+ if (!activityToMove) return state;
+
+ fromDay.activities = fromDay.activities.filter((a) => a.id !== activityId);
+ toDay.activities = [...toDay.activities, activityToMove];
+ toDay.activities.sort((a, b) => (a.time || '').localeCompare(b.time || ''));
+
+ return { schedule: updatedSchedule };
+ });
+ },
+
+ // === PACKING LIST ACTIONS ===
+ addPackingItem: (category, itemData) => {
+ const newItem = {
+ id: itemData.id || generateId(),
+ name: itemData.name,
+ qty: Math.max(1, itemData.qty || 1),
+ checked: !!itemData.checked,
+ };
+
+ set((state) => {
+ const currentCategoryItems = state.packingList[category] || [];
+ return {
+ packingList: {
+ ...state.packingList,
+ [category]: [...currentCategoryItems, newItem],
+ },
+ };
+ });
+ },
+
+ updatePackingItem: (category, itemId, updatedData) => {
+ set((state) => {
+ const currentCategoryItems = state.packingList[category] || [];
+ return {
+ packingList: {
+ ...state.packingList,
+ [category]: currentCategoryItems.map((item) =>
+ item.id === itemId ? { ...item, ...updatedData } : item
+ ),
+ },
+ };
+ });
+ },
+
+ togglePackingItem: (category, itemId) => {
+ set((state) => {
+ const currentCategoryItems = state.packingList[category] || [];
+ return {
+ packingList: {
+ ...state.packingList,
+ [category]: currentCategoryItems.map((item) =>
+ item.id === itemId ? { ...item, checked: !item.checked } : item
+ ),
+ },
+ };
+ });
+ },
+
+ removePackingItem: (category, itemId) => {
+ set((state) => {
+ const currentCategoryItems = state.packingList[category] || [];
+ return {
+ packingList: {
+ ...state.packingList,
+ [category]: currentCategoryItems.filter((item) => item.id !== itemId),
+ },
+ };
+ });
+ },
+
+ toggleAllInCategory: (category, shouldCheck) => {
+ set((state) => {
+ const currentCategoryItems = state.packingList[category] || [];
+ return {
+ packingList: {
+ ...state.packingList,
+ [category]: currentCategoryItems.map((item) => ({ ...item, checked: shouldCheck })),
+ },
+ };
+ });
+ },
+
+ loadPackingTemplate: (tripType = 'pantai') => {
+ const template = packingTemplates[tripType] || packingTemplates.pantai;
+ if (!template || !template.items) return;
+
+ const formattedList = {};
+ Object.entries(template.items).forEach(([catName, items]) => {
+ formattedList[catName] = items.map((item) => ({
+ id: generateId(),
+ name: item.name,
+ qty: item.qty || 1,
+ checked: false,
+ }));
+ });
+
+ set({ packingList: formattedList });
+ },
+
+ clearPackingList: () => {
+ set({ packingList: initialPackingState });
+ },
+
+ // === BUDGET ACTIONS ===
+ setTotalBudget: (amount) => {
+ set((state) => ({
+ budget: {
+ ...state.budget,
+ total: Math.max(0, Number(amount) || 0),
+ },
+ }));
+ },
+
+ setCurrency: (currency) => {
+ set((state) => ({
+ budget: {
+ ...state.budget,
+ currency: currency || 'IDR',
+ },
+ }));
+ },
+
+ addExpense: (expenseData) => {
+ const newExpense = {
+ id: expenseData.id || generateId(),
+ name: expenseData.name || 'Pengeluaran',
+ amount: Math.max(0, Number(expenseData.amount) || 0),
+ category: expenseData.category || 'lainnya',
+ day: expenseData.day ?? null, // number or null
+ date: expenseData.date || '',
+ notes: expenseData.notes || '',
+ };
+
+ set((state) => ({
+ budget: {
+ ...state.budget,
+ expenses: [newExpense, ...state.budget.expenses],
+ },
+ }));
+ },
+
+ updateExpense: (id, updatedData) => {
+ set((state) => ({
+ budget: {
+ ...state.budget,
+ expenses: state.budget.expenses.map((exp) =>
+ exp.id === id ? { ...exp, ...updatedData } : exp
+ ),
+ },
+ }));
+ },
+
+ removeExpense: (id) => {
+ set((state) => ({
+ budget: {
+ ...state.budget,
+ expenses: state.budget.expenses.filter((exp) => exp.id !== id),
+ },
+ }));
+ },
+
+ // === GLOBAL RESET ===
+ resetAll: () => {
+ set({
+ trip: initialTripState,
+ schedule: [],
+ packingList: initialPackingState,
+ budget: initialBudgetState,
+ });
+ },
+ }),
+ {
+ name: 'tour-planner-storage',
+ storage: createJSONStorage(() => localStorage),
+ }
+ )
+);
diff --git a/src/utils/dateHelpers.js b/src/utils/dateHelpers.js
new file mode 100644
index 0000000..d92085b
--- /dev/null
+++ b/src/utils/dateHelpers.js
@@ -0,0 +1,69 @@
+import { format, parseISO, isValid, differenceInCalendarDays, addDays } from 'date-fns';
+import { id } from 'date-fns/locale';
+
+/**
+ * Format ISO date string to readable Indonesian date
+ * e.g., "Senin, 15 Okt 2026"
+ */
+export const formatDateIndo = (dateInput, pattern = 'EEEE, d MMM yyyy') => {
+ if (!dateInput) return '';
+ try {
+ const dateObj = typeof dateInput === 'string' ? parseISO(dateInput) : dateInput;
+ if (!isValid(dateObj)) return '';
+ return format(dateObj, pattern, { locale: id });
+ } catch {
+ return '';
+ }
+};
+
+/**
+ * Format date for short badge e.g. "15 Okt"
+ */
+export const formatDateShort = (dateInput) => {
+ return formatDateIndo(dateInput, 'd MMM');
+};
+
+/**
+ * Calculate total days inclusive (Start: Day 1, End: Day 3 => 3 days)
+ */
+export const calculateTotalDays = (startDate, endDate) => {
+ if (!startDate || !endDate) return 1;
+ try {
+ const start = typeof startDate === 'string' ? parseISO(startDate) : startDate;
+ const end = typeof endDate === 'string' ? parseISO(endDate) : endDate;
+ if (!isValid(start) || !isValid(end)) return 1;
+ const diff = differenceInCalendarDays(end, start);
+ return Math.max(1, diff + 1);
+ } catch {
+ return 1;
+ }
+};
+
+/**
+ * Get date for specific day index (0-based)
+ */
+export const getDateForDayIndex = (startDate, dayIndex) => {
+ if (!startDate) return '';
+ try {
+ const start = typeof startDate === 'string' ? parseISO(startDate) : startDate;
+ if (!isValid(start)) return '';
+ const targetDate = addDays(start, dayIndex);
+ return format(targetDate, 'yyyy-MM-dd');
+ } catch {
+ return '';
+ }
+};
+
+/**
+ * Get ISO string for today in YYYY-MM-DD
+ */
+export const getTodayString = () => {
+ return format(new Date(), 'yyyy-MM-dd');
+};
+
+/**
+ * Get ISO string for tomorrow in YYYY-MM-DD
+ */
+export const getFutureDateString = (daysAhead = 3) => {
+ return format(addDays(new Date(), daysAhead), 'yyyy-MM-dd');
+};
diff --git a/src/utils/formatCurrency.js b/src/utils/formatCurrency.js
new file mode 100644
index 0000000..fb513c4
--- /dev/null
+++ b/src/utils/formatCurrency.js
@@ -0,0 +1,53 @@
+/**
+ * Currency Formatting Utilities
+ */
+
+export const formatIDR = (amount) => {
+ const value = Number(amount) || 0;
+ return new Intl.NumberFormat('id-ID', {
+ style: 'currency',
+ currency: 'IDR',
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 0,
+ }).format(value);
+};
+
+export const formatUSD = (amount) => {
+ const value = Number(amount) || 0;
+ return new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency: 'USD',
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 2,
+ }).format(value);
+};
+
+export const formatEUR = (amount) => {
+ const value = Number(amount) || 0;
+ return new Intl.NumberFormat('de-DE', {
+ style: 'currency',
+ currency: 'EUR',
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 2,
+ }).format(value);
+};
+
+export const formatCurrency = (amount, currency = 'IDR') => {
+ switch (currency.toUpperCase()) {
+ case 'USD':
+ return formatUSD(amount);
+ case 'EUR':
+ return formatEUR(amount);
+ case 'IDR':
+ default:
+ return formatIDR(amount);
+ }
+};
+
+export const parseCurrencyInput = (value) => {
+ if (typeof value === 'number') return value;
+ if (!value) return 0;
+ // Remove non-digit characters except decimals
+ const cleaned = value.toString().replace(/[^0-9]/g, '');
+ return parseInt(cleaned, 10) || 0;
+};
diff --git a/src/utils/helpers.js b/src/utils/helpers.js
new file mode 100644
index 0000000..a4409a1
--- /dev/null
+++ b/src/utils/helpers.js
@@ -0,0 +1,19 @@
+/**
+ * General helper functions
+ */
+
+export const generateId = () => {
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
+ return crypto.randomUUID();
+ }
+ return 'id-' + Math.random().toString(36).substring(2, 9) + '-' + Date.now().toString(36);
+};
+
+export const clamp = (val, min, max) => Math.min(Math.max(val, min), max);
+
+export const truncateText = (text, maxLength = 100) => {
+ if (!text || text.length <= maxLength) return text;
+ return text.slice(0, maxLength) + '...';
+};
+
+export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
diff --git a/task.md b/task.md
new file mode 100644
index 0000000..1d1c8e7
--- /dev/null
+++ b/task.md
@@ -0,0 +1,81 @@
+# 📋 Task Progress — Tour Destination Planner
+
+## Phase 0 — Project Setup & Foundation [DONE]
+- [x] **0.1** — Inisialisasi Vite + React
+- [x] **0.2** — Install dependencies (`react-router-dom`, `zustand`, `framer-motion`, `lucide-react`, `recharts`, `@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/utilities`, `date-fns`)
+- [x] **0.3** — Setup design system di `src/index.css` (CSS variables, glassmorphism, buttons, badges, forms)
+- [x] **0.4** — Setup Zustand store di `src/store/tourStore.js` dengan persist middleware ke localStorage
+- [x] **0.5** — Setup React Router di `src/App.jsx` dengan 4 rute utama (`/`, `/schedule`, `/packing`, `/budget`)
+- [x] **0.6** — Buat data dummy di `src/data/` (`destinations.js`, `activities.js`, `packingTemplates.js`)
+- [x] **0.7** — Buat utility functions di `src/utils/` (`formatCurrency.js`, `dateHelpers.js`, `helpers.js`) dan custom hooks di `src/hooks/`
+- [x] **0.8** — Validasi build (`npm run build`) sukses tanpa error
+
+---
+
+## Phase 1 — Layout & UI Components [DONE]
+- [x] **1A.1** — `Navbar.jsx` (Sticky glassmorphism, responsive mobile drawer menu, dynamic badge indicators)
+- [x] **1A.2** — `Footer.jsx` (Branding, quick links, active trip widget) & `PageWrapper.jsx` (framer-motion page transition)
+- [x] **1B.1** — `Button.jsx` (variants: primary/secondary/accent/ghost/danger, sizes, loading spinner, whileHover & whileTap)
+- [x] **1B.2** — `Card.jsx` (glassmorphism card, hoverable glow lift effect)
+- [x] **1B.3** — `Modal.jsx` (accessible focus & escape key trap, Framer Motion scalePop spring, custom footers)
+- [x] **1B.4** — `Badge.jsx` (primary/accent/success/warning/danger/neutral badges)
+- [x] **1B.5** — `ProgressBar.jsx` (animated fill, custom gradients, label & percentage indicator)
+- [x] **1B.6** — `EmptyState.jsx` (empty view illustration, responsive text, call-to-action button)
+- [x] **1B.7** — Validasi build (`npm run build`) 100% sukses tanpa error
+
+---
+
+## Phase 2 — Home Page (Trip Selection) [DONE]
+- [x] **2A.1** — `HeroSection.jsx` dengan typography Playfair Display, pulsing background glow, CTA buttons & key feature badges
+- [x] **2A.2** — `FilterBar.jsx` dengan search input real-time, clear button, dan filter pills kategori (Pantai, Gunung, Budaya, Kota)
+- [x] **2A.3** — Sorting mode dropdown (Paling Populer, Rating Tertinggi, Budget Terendah, Durasi Singkat)
+- [x] **2B.1** — `TripCard.jsx` dengan image overlay, rating badge, tag highlights, budget range, dan hover lift effect
+- [x] **2B.2** — Responsive grid destinasi (4→3→2→1 kolom) dengan layout animation & AnimatePresence
+- [x] **2B.3** — `EmptyState.jsx` saat pencarian tidak ditemukan
+- [x] **2C.1** — `TripModal.jsx` untuk preset destination (nama trip, tanggal mulai/selesai, auto-calc durasi hari, target budget)
+- [x] **2C.2** — `CustomTripModal.jsx` untuk membuat trip bebas (custom nama destinasi, kategori, cover image, durasi, budget)
+- [x] **2C.3** — Banner info trip aktif saat user sudah memilih trip sebelumnya
+- [x] **2C.4** — Validasi build (`npm run build`) 100% sukses tanpa error
+
+---
+
+## Phase 3 — Schedule Builder Page [DONE]
+- [x] **3A.1** — `DayTabs.jsx` navigasi per hari (Day 1..Day N) dengan badge jumlah aktivitas, tanggal format Indonesia, dan tombol tambah hari
+- [x] **3A.2** — Guard check & Empty state jika belum ada destinasi yang dipilih
+- [x] **3B.1** — `ScheduleTimeline.jsx` dengan header ringkasan hari dan list timeline vertikal
+- [x] **3B.2** — `ActivityItem.jsx` dengan drag handle, kategori icon & stripe color, waktu, durasi, lokasi, catatan, tombol edit & hapus
+- [x] **3C.1** — `AddActivityModal.jsx` (mode tambah & mode edit aktivitas)
+- [x] **3C.2** — Quick activity template chips (Sarapan, Check-in, Sunset, Wisata, Kuliner, Belanja, dll.)
+- [x] **3D.1** — Drag & drop reordering menggunakan `@dnd-kit/core` & `@dnd-kit/sortable`
+- [x] **3D.2** — Validasi build (`npm run build`) 100% sukses tanpa error
+
+---
+
+## Phase 4 — Packing List Page [DONE]
+- [x] **4A.1** — `PackingOverview.jsx` (total item, item terkemas, persentase packing bar dinamis, badge status & pesan selebrasi saat 100%)
+- [x] **4A.2** — `TemplateModal.jsx` untuk memuat template bawaan per kategori trip (*Pantai*, *Gunung*, *Kota*, *Budaya*)
+- [x] **4B.1** — `PackingCategory.jsx` (kategori collapsible dengan badge count, toggle Check All / Uncheck All, dan form inline tambah item)
+- [x] **4B.2** — `PackingItem.jsx` (custom animated checkbox spring, coret nama barang, quantity counter minus/plus, tombol hapus)
+- [x] **4C.1** — Search filter barang bawaan real-time
+- [x] **4C.2** — Auto-load template berdasarkan trip yang dipilih
+- [x] **4C.3** — Validasi build (`npm run build`) 100% sukses tanpa error
+
+---
+
+## Phase 5 — Budget Calculator Page [DONE]
+- [x] **5A.1** — `BudgetOverview.jsx` (Target total budget editable, total terpakai, sisa budget dengan status warna dinamis)
+- [x] **5A.2** — Progress bar alokasi budget terpakai & banner peringatan saat over-budget
+- [x] **5B.1** — `BudgetChart.jsx` dengan Recharts PieChart (proporsi per kategori) & BarChart (pengeluaran per hari)
+- [x] **5B.2** — Custom dark glassmorphism tooltips & legend persentase kategori
+- [x] **5C.1** — `AddExpenseModal.jsx` (form tambah & edit biaya: nominal, kategori, hari ke-N, catatan)
+- [x] **5C.2** — `ExpenseList.jsx` (list pengeluaran dengan filter kategori, sorting, edit & hapus item)
+- [x] **5C.3** — Multi-currency toggle (IDR, USD, EUR)
+- [x] **5C.4** — Validasi build (`npm run build`) 100% sukses tanpa error
+
+---
+
+## Phase 6 — Polish, Animations & Responsive [DONE]
+- [x] **6A.1** — `ToastContainer.jsx` & `toastStore.js` (notifikasi mengambang interaktif sukses/info/warning/danger di semua aksi pengguna)
+- [x] **6B.1** — Responsiveness audit & mobile drawer navigation menu di Navbar
+- [x] **6C.1** — Dialog modal konfirmasi "Reset Trip" dengan visual peringatan
+- [x] **6D.1** — Production build testing (`npm run build`) 100% sukses tanpa error (732ms)
diff --git a/vite.config.js b/vite.config.js
new file mode 100644
index 0000000..9982072
--- /dev/null
+++ b/vite.config.js
@@ -0,0 +1,7 @@
+import react from '@vitejs/plugin-react'
+import { defineConfig } from 'vite'
+
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [react()],
+})