auto winn
This commit is contained in:
@@ -0,0 +1,186 @@
|
|||||||
|
# Coding Standards — Tour Planner App
|
||||||
|
|
||||||
|
Aturan ini berlaku untuk semua kode yang ditulis dalam proyek ini.
|
||||||
|
AI agent wajib mengikuti seluruh aturan di bawah ini.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Struktur Komponen
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// ✅ BENAR — Functional component dengan arrow function
|
||||||
|
const MyComponent = ({ prop1, prop2 }) => {
|
||||||
|
// hooks di atas
|
||||||
|
// logic di tengah
|
||||||
|
// return di bawah
|
||||||
|
return <div />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MyComponent;
|
||||||
|
|
||||||
|
// ❌ SALAH — Jangan gunakan class component
|
||||||
|
class MyComponent extends React.Component { }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Styling
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// ✅ BENAR — CSS variables dari design system
|
||||||
|
<div style={{ color: 'var(--color-text-primary)', padding: 'var(--space-4)' }}>
|
||||||
|
|
||||||
|
// ✅ BENAR — className dengan class yang didefinisikan di CSS
|
||||||
|
<div className="glass section-title">
|
||||||
|
|
||||||
|
// ❌ SALAH — Hardcode warna
|
||||||
|
<div style={{ color: '#ffffff', backgroundColor: '#1a1a2e' }}>
|
||||||
|
|
||||||
|
// ❌ SALAH — TailwindCSS
|
||||||
|
<div className="text-white bg-gray-900 p-4">
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. State Management
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// ✅ BENAR — Gunakan Zustand store untuk state yang di-share
|
||||||
|
const { schedule, addActivity } = useTourStore();
|
||||||
|
|
||||||
|
// ✅ BENAR — useState untuk state lokal (form, toggle)
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
// ❌ SALAH — Prop drilling lebih dari 2 level
|
||||||
|
<A> <B prop={x}> <C prop={x}> <D prop={x} />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Animasi
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// ✅ BENAR — Selalu gunakan Framer Motion
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
|
||||||
|
|
||||||
|
// ❌ SALAH — Animasi manual
|
||||||
|
setTimeout(() => setVisible(true), 100);
|
||||||
|
element.style.transition = 'opacity 0.3s';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Icons
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// ✅ BENAR — Lucide React
|
||||||
|
import { MapPin, Calendar, Plane } from 'lucide-react';
|
||||||
|
<MapPin size={20} />
|
||||||
|
|
||||||
|
// ❌ SALAH — Emoji sebagai icon UI
|
||||||
|
<span>📍</span>
|
||||||
|
|
||||||
|
// ❌ SALAH — SVG inline tanpa abstraksi
|
||||||
|
<svg xmlns="..." viewBox="..."><path d="..." /></svg>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Data & Constants
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// ✅ BENAR — Data di src/data/
|
||||||
|
import { destinations } from '../data/destinations';
|
||||||
|
import { CATEGORY_COLORS } from '../data/constants';
|
||||||
|
|
||||||
|
// ❌ SALAH — Data hardcode di dalam komponen
|
||||||
|
const destinations = [{ name: 'Bali', ... }]; // dalam file .jsx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. File Naming
|
||||||
|
|
||||||
|
| Tipe | Convention | Contoh |
|
||||||
|
|------|------------|--------|
|
||||||
|
| Component | PascalCase | `TripCard.jsx` |
|
||||||
|
| Hook | camelCase dengan "use" prefix | `useBudget.js` |
|
||||||
|
| Utility | camelCase | `formatCurrency.js` |
|
||||||
|
| Data file | camelCase | `destinations.js` |
|
||||||
|
| CSS | kebab-case | `trip-card.css` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Import Order
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 1. React & hooks
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
// 2. Third-party libraries
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { MapPin } from 'lucide-react';
|
||||||
|
|
||||||
|
// 3. Internal store & hooks
|
||||||
|
import { useTourStore } from '../store/tourStore';
|
||||||
|
import { useBudget } from '../hooks/useBudget';
|
||||||
|
|
||||||
|
// 4. Components
|
||||||
|
import Button from '../components/ui/Button';
|
||||||
|
import TripCard from '../components/trip/TripCard';
|
||||||
|
|
||||||
|
// 5. Data & utils
|
||||||
|
import { destinations } from '../data/destinations';
|
||||||
|
import { formatIDR } from '../utils/formatCurrency';
|
||||||
|
|
||||||
|
// 6. Styles (jika ada)
|
||||||
|
import './ComponentName.css';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Performance
|
||||||
|
|
||||||
|
- Gunakan `useMemo` untuk kalkulasi berat (total budget, statistik packing)
|
||||||
|
- Gunakan `useCallback` untuk handler yang di-pass sebagai prop
|
||||||
|
- Gunakan `React.memo` untuk komponen list item yang berat
|
||||||
|
- Jangan lakukan side effect tanpa `useEffect`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Batasan Fitur (TIDAK BOLEH DILANGGAR)
|
||||||
|
|
||||||
|
| Larangan | Alasan |
|
||||||
|
|----------|--------|
|
||||||
|
| ❌ Login / Logout | Aplikasi ini sepenuhnya static & lokal |
|
||||||
|
| ❌ API call ke backend | Tidak ada server, semua data lokal |
|
||||||
|
| ❌ Menyimpan password atau data sensitif | Tidak ada autentikasi |
|
||||||
|
| ❌ TailwindCSS | Gunakan vanilla CSS + CSS variables |
|
||||||
|
| ❌ jQuery atau manipulasi DOM manual | Gunakan React state |
|
||||||
|
| ❌ `console.log` di production code | Hapus sebelum commit |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Aksesibilitas (Minimum Requirements)
|
||||||
|
|
||||||
|
- Semua tombol punya `aria-label` jika tidak ada teks
|
||||||
|
- Semua form field punya `<label>` yang terhubung
|
||||||
|
- Warna kontras minimum 4.5:1 untuk teks
|
||||||
|
- Keyboard navigable untuk modal (focus trap)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Error Handling
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Selalu handle edge case
|
||||||
|
const totalDays = trip?.totalDays ?? 0;
|
||||||
|
const activities = schedule[dayIndex]?.activities ?? [];
|
||||||
|
|
||||||
|
// Jangan biarkan crash karena undefined
|
||||||
|
// ❌
|
||||||
|
const name = trip.name.toUpperCase(); // crash jika trip.name null
|
||||||
|
// ✅
|
||||||
|
const name = trip?.name?.toUpperCase() ?? 'Unnamed Trip';
|
||||||
|
```
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
# Phase Workflow Rules — Tour Planner App
|
||||||
|
|
||||||
|
> Aturan ini mengatur bagaimana setiap fitur harus dikerjakan secara berurutan.
|
||||||
|
> AI agent WAJIB mengikuti fase ini dan TIDAK BOLEH melompat fase tanpa alasan yang jelas.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Prinsip Utama
|
||||||
|
|
||||||
|
1. **Satu fase harus selesai sebelum masuk ke fase berikutnya.**
|
||||||
|
2. **Setiap akhir fase, update `task.md`** untuk mencatat progress.
|
||||||
|
3. **Jangan mulai phase baru jika phase sebelumnya masih ada item `[ ]`.**
|
||||||
|
4. **Validasi output setiap phase sebelum lanjut.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗂️ Phase 0 — Project Setup (WAJIB DIKERJAKAN PERTAMA)
|
||||||
|
|
||||||
|
> Fondasi project. Tidak ada komponen atau page yang boleh dibuat sebelum phase ini selesai.
|
||||||
|
|
||||||
|
### Checklist Phase 0
|
||||||
|
- [ ] **0.1** — Inisialisasi Vite + React
|
||||||
|
```bash
|
||||||
|
npm create vite@latest . -- --template react
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
- [ ] **0.2** — Install semua dependencies sekaligus
|
||||||
|
```bash
|
||||||
|
npm install react-router-dom zustand framer-motion lucide-react recharts @dnd-kit/core @dnd-kit/sortable date-fns
|
||||||
|
```
|
||||||
|
- [ ] **0.3** — Buat `src/index.css` dengan SEMUA CSS variables (lihat skill `ui-design`)
|
||||||
|
- [ ] **0.4** — Buat `src/store/tourStore.js` dengan full Zustand store + persist middleware
|
||||||
|
- [ ] **0.5** — Setup React Router di `src/App.jsx` dengan semua 4 routes
|
||||||
|
- [ ] **0.6** — Buat file data dummy:
|
||||||
|
- `src/data/destinations.js` (min. 10 destinasi)
|
||||||
|
- `src/data/activities.js` (template aktivitas)
|
||||||
|
- `src/data/packingTemplates.js` (template per tipe trip)
|
||||||
|
- [ ] **0.7** — Buat utility functions:
|
||||||
|
- `src/utils/formatCurrency.js`
|
||||||
|
- `src/utils/dateHelpers.js`
|
||||||
|
- `src/utils/helpers.js` (generateId, dll.)
|
||||||
|
|
||||||
|
### Validasi Phase 0
|
||||||
|
- `npm run dev` harus berjalan tanpa error
|
||||||
|
- Route `/`, `/schedule`, `/packing`, `/budget` harus bisa diakses
|
||||||
|
- Zustand store harus bisa di-import dari semua page
|
||||||
|
- CSS variables harus aktif (cek di browser DevTools)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧱 Phase 1 — Layout & UI Components
|
||||||
|
|
||||||
|
> Membangun blok bangunan dasar yang akan dipakai semua page.
|
||||||
|
> Jangan buat page dulu sebelum komponen UI dasar selesai.
|
||||||
|
|
||||||
|
### Checklist Phase 1
|
||||||
|
|
||||||
|
#### 1A — Layout Components
|
||||||
|
- [ ] **1A.1** — `src/components/layout/Navbar.jsx`
|
||||||
|
- Logo + nama app
|
||||||
|
- Link navigasi ke 4 halaman
|
||||||
|
- Indikator halaman aktif
|
||||||
|
- Responsif (hamburger menu di mobile)
|
||||||
|
- [ ] **1A.2** — `src/components/layout/PageWrapper.jsx`
|
||||||
|
- Wrapper umum untuk semua page
|
||||||
|
- Padding, max-width, fade-in animation
|
||||||
|
|
||||||
|
#### 1B — UI Components Dasar
|
||||||
|
- [ ] **1B.1** — `src/components/ui/Button.jsx`
|
||||||
|
- Props: `variant` (primary/secondary/ghost/danger), `size` (sm/md/lg), `icon`, `loading`, `disabled`
|
||||||
|
- [ ] **1B.2** — `src/components/ui/Card.jsx`
|
||||||
|
- Props: `glassmorphism`, `hoverable`, `onClick`
|
||||||
|
- [ ] **1B.3** — `src/components/ui/Modal.jsx`
|
||||||
|
- Props: `isOpen`, `onClose`, `title`
|
||||||
|
- Focus trap, backdrop click close, escape key
|
||||||
|
- Framer Motion animation (scalePop)
|
||||||
|
- [ ] **1B.4** — `src/components/ui/Badge.jsx`
|
||||||
|
- Props: `variant` (primary/success/warning/danger)
|
||||||
|
- [ ] **1B.5** — `src/components/ui/ProgressBar.jsx`
|
||||||
|
- Props: `value` (0-100), `label`, `showPercent`, `color`
|
||||||
|
- Animated fill dengan Framer Motion
|
||||||
|
- [ ] **1B.6** — `src/components/ui/EmptyState.jsx`
|
||||||
|
- Tampil saat list kosong, dengan ilustrasi dan CTA
|
||||||
|
|
||||||
|
### Validasi Phase 1
|
||||||
|
- Semua komponen bisa di-render tanpa crash
|
||||||
|
- Tidak ada hardcode warna (semua pakai CSS variables)
|
||||||
|
- Modal bisa dibuka dan ditutup dengan keyboard (Escape)
|
||||||
|
- Navbar menampilkan halaman aktif dengan benar
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏠 Phase 2 — Home Page (Trip Selection)
|
||||||
|
|
||||||
|
> Feature pertama. Harus selesai sebelum page lain karena trip yang dipilih
|
||||||
|
> menentukan data yang dipakai di semua page lainnya.
|
||||||
|
|
||||||
|
### Checklist Phase 2
|
||||||
|
|
||||||
|
#### 2A — Hero Section
|
||||||
|
- [ ] **2A.1** — Hero dengan background gradient + animated particles atau subtle pattern
|
||||||
|
- [ ] **2A.2** — Headline besar dengan `Playfair Display` font
|
||||||
|
- [ ] **2A.3** — Subtitle dan CTA button "Mulai Rencanakan"
|
||||||
|
- [ ] **2A.4** — Search bar dengan placeholder animasi
|
||||||
|
|
||||||
|
#### 2B — Filter & Grid Destinasi
|
||||||
|
- [ ] **2B.1** — `FilterBar.jsx` — filter pills: Semua / Pantai / Gunung / Kota / Budaya
|
||||||
|
- [ ] **2B.2** — `TripCard.jsx` — card dengan:
|
||||||
|
- Gambar destinasi (16:9 ratio)
|
||||||
|
- Nama, negara, rating
|
||||||
|
- Estimasi budget range
|
||||||
|
- Durasi populer
|
||||||
|
- Tags/badge kategori
|
||||||
|
- Hover effect (scale + overlay)
|
||||||
|
- [ ] **2B.3** — Grid responsif 4→3→2→1 kolom
|
||||||
|
- [ ] **2B.4** — Animasi stagger pada grid item
|
||||||
|
|
||||||
|
#### 2C — Trip Configuration Modal
|
||||||
|
- [ ] **2C.1** — Modal terbuka saat klik TripCard
|
||||||
|
- [ ] **2C.2** — Form: Nama Trip, Tanggal Mulai, Tanggal Selesai
|
||||||
|
- [ ] **2C.3** — Auto-hitung total hari dari range tanggal
|
||||||
|
- [ ] **2C.4** — Tombol "Buat Trip Kustom" (tanpa pilih destinasi preset)
|
||||||
|
- [ ] **2C.5** — Konfirmasi → update Zustand store → redirect ke `/schedule`
|
||||||
|
|
||||||
|
### Validasi Phase 2
|
||||||
|
- Semua destinasi tampil di grid
|
||||||
|
- Filter berfungsi (klik kategori → grid berubah)
|
||||||
|
- Search berfungsi (filter by name)
|
||||||
|
- Modal terbuka dengan animasi
|
||||||
|
- Setelah konfirmasi, `useTourStore().trip` terisi dengan benar
|
||||||
|
- Data trip tersimpan di localStorage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📅 Phase 3 — Schedule Builder Page
|
||||||
|
|
||||||
|
> Fitur drag & drop. Ini yang paling complex — kerjakan dengan hati-hati.
|
||||||
|
|
||||||
|
### Checklist Phase 3
|
||||||
|
|
||||||
|
#### 3A — Day Navigation
|
||||||
|
- [ ] **3A.1** — `DayTabs.jsx` — tabs untuk setiap hari
|
||||||
|
- Day 1, Day 2, ..., Day N (sesuai `trip.totalDays`)
|
||||||
|
- Tampilkan tanggal di bawah label hari
|
||||||
|
- Active state yang jelas
|
||||||
|
- Scrollable jika banyak hari
|
||||||
|
- [ ] **3A.2** — Guard: Jika trip belum dikonfigurasi, tampilkan prompt ke Home
|
||||||
|
|
||||||
|
#### 3B — Timeline View
|
||||||
|
- [ ] **3B.1** — `ScheduleTimeline.jsx` — timeline vertikal
|
||||||
|
- Jam dari 06:00 - 23:00 di sisi kiri
|
||||||
|
- Aktivitas diposisikan sesuai waktu
|
||||||
|
- [ ] **3B.2** — `ActivityItem.jsx` — satu item:
|
||||||
|
- Warna berdasarkan kategori
|
||||||
|
- Waktu, nama, lokasi, durasi
|
||||||
|
- Drag handle (ikon GripVertical)
|
||||||
|
- Tombol edit & hapus
|
||||||
|
- [ ] **3B.3** — Placeholder "Belum ada aktivitas" dengan CTA tambah
|
||||||
|
|
||||||
|
#### 3C — Add/Edit Activity
|
||||||
|
- [ ] **3C.1** — `AddActivityModal.jsx` — form:
|
||||||
|
- Time picker (HH:MM)
|
||||||
|
- Nama aktivitas
|
||||||
|
- Lokasi
|
||||||
|
- Durasi (menit)
|
||||||
|
- Kategori (dropdown/pills)
|
||||||
|
- Catatan (textarea)
|
||||||
|
- [ ] **3C.2** — Template aktivitas cepat (chip/button, klik → auto-isi form)
|
||||||
|
- [ ] **3C.3** — Edit mode: modal ter-preisi dengan data yang ada
|
||||||
|
|
||||||
|
#### 3D — Drag & Drop
|
||||||
|
- [ ] **3D.1** — Sortable dalam satu hari (reorder aktivitas)
|
||||||
|
- [ ] **3D.2** — Update state setelah drag selesai
|
||||||
|
- [ ] **3D.3** — Visual feedback saat dragging (opacity, scale)
|
||||||
|
|
||||||
|
### Validasi Phase 3
|
||||||
|
- Tabs menampilkan jumlah hari yang benar sesuai trip
|
||||||
|
- Tambah aktivitas → muncul di timeline
|
||||||
|
- Hapus aktivitas → hilang dari timeline
|
||||||
|
- Drag & drop mengubah urutan dan tersimpan
|
||||||
|
- Data persist setelah refresh browser
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎒 Phase 4 — Packing List Page
|
||||||
|
|
||||||
|
> Fitur yang relatif straightforward. Fokus pada UX checklist yang satisfying.
|
||||||
|
|
||||||
|
### Checklist Phase 4
|
||||||
|
|
||||||
|
#### 4A — Overview & Progress
|
||||||
|
- [ ] **4A.1** — `PackingOverview.jsx`:
|
||||||
|
- Total item / item yang sudah dipak
|
||||||
|
- ProgressBar keseluruhan (animated)
|
||||||
|
- Badge status: "Belum dimulai" / "Sedang dikemas" / "Siap Berangkat!"
|
||||||
|
- [ ] **4A.2** — Tombol "Load Template" berdasarkan tipe trip
|
||||||
|
|
||||||
|
#### 4B — Kategori & Item
|
||||||
|
- [ ] **4B.1** — `PackingCategory.jsx`:
|
||||||
|
- Header kategori dengan icon dan jumlah item
|
||||||
|
- Collapsible (klik header → expand/collapse)
|
||||||
|
- Progress mini per kategori
|
||||||
|
- [ ] **4B.2** — `PackingItem.jsx`:
|
||||||
|
- Checkbox dengan animasi checkmark (Framer Motion)
|
||||||
|
- Nama item (strikethrough jika checked)
|
||||||
|
- Quantity counter (- / angka / +)
|
||||||
|
- Tombol hapus
|
||||||
|
- [ ] **4B.3** — Inline add item per kategori (input + tombol "+")
|
||||||
|
|
||||||
|
#### 4C — Interaksi & UX
|
||||||
|
- [ ] **4C.1** — "Check All" / "Uncheck All" per kategori
|
||||||
|
- [ ] **4C.2** — Sort: unchecked items tampil di atas
|
||||||
|
- [ ] **4C.3** — Animasi item saat di-check (smooth height collapse jika sorted)
|
||||||
|
- [ ] **4C.4** — Konfetti/celebrasi kecil saat 100% selesai
|
||||||
|
|
||||||
|
### Validasi Phase 4
|
||||||
|
- Load template mengisi item sesuai tipe trip
|
||||||
|
- Checkbox berfungsi dan tersimpan di localStorage
|
||||||
|
- Progress bar update real-time saat item di-check
|
||||||
|
- Tambah item baru muncul di kategori yang tepat
|
||||||
|
- Quantity counter mengubah nilai dengan benar
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💰 Phase 5 — Budget Calculator Page
|
||||||
|
|
||||||
|
> Fitur terakhir. Fokus pada visual chart dan feedback yang jelas.
|
||||||
|
|
||||||
|
### Checklist Phase 5
|
||||||
|
|
||||||
|
#### 5A — Budget Overview Header
|
||||||
|
- [ ] **5A.1** — Input total budget (editable, format IDR)
|
||||||
|
- [ ] **5A.2** — 3 stat card: Total Budget / Total Terpakai / Sisa
|
||||||
|
- Warna Sisa: hijau (cukup), kuning (hampir habis), merah (over)
|
||||||
|
- [ ] **5A.3** — Progress bar: % budget terpakai
|
||||||
|
- [ ] **5A.4** — Warning banner jika over budget
|
||||||
|
|
||||||
|
#### 5B — Chart Visualisasi
|
||||||
|
- [ ] **5B.1** — `PieChart` — distribusi per kategori (Recharts)
|
||||||
|
- Custom tooltip dengan format IDR
|
||||||
|
- Legend dengan warna kategori
|
||||||
|
- [ ] **5B.2** — `BarChart` — pengeluaran per hari (Recharts)
|
||||||
|
- X axis: hari ke-N
|
||||||
|
- Y axis: jumlah IDR
|
||||||
|
|
||||||
|
#### 5C — Expense Management
|
||||||
|
- [ ] **5C.1** — List semua pengeluaran (grouped by kategori)
|
||||||
|
- [ ] **5C.2** — `AddExpenseModal.jsx`:
|
||||||
|
- Nama pengeluaran
|
||||||
|
- Jumlah (IDR)
|
||||||
|
- Kategori
|
||||||
|
- Hari (dropdown: Hari 1, Hari 2, ... atau "Umum")
|
||||||
|
- Catatan
|
||||||
|
- [ ] **5C.3** — Edit & hapus pengeluaran
|
||||||
|
- [ ] **5C.4** — Currency toggle (IDR / USD / EUR) dengan konversi sederhana
|
||||||
|
|
||||||
|
### Validasi Phase 5
|
||||||
|
- Tambah expense → stat card dan chart update real-time
|
||||||
|
- Over budget → warning tampil dengan warna merah
|
||||||
|
- Pie chart menampilkan semua kategori dengan warna yang benar
|
||||||
|
- Bar chart menampilkan per-hari dengan benar
|
||||||
|
- Data tersimpan setelah refresh
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ Phase 6 — Polish & Optimization (FASE TERAKHIR)
|
||||||
|
|
||||||
|
> Hanya dikerjakan setelah Phase 2-5 semuanya selesai dan tervalidasi.
|
||||||
|
|
||||||
|
### Checklist Phase 6
|
||||||
|
|
||||||
|
#### 6A — Animasi & Transisi
|
||||||
|
- [ ] **6A.1** — Page transition (fade antar route)
|
||||||
|
- [ ] **6A.2** — Scroll-triggered animations (IntersectionObserver via Framer Motion)
|
||||||
|
- [ ] **6A.3** — Loading states yang smooth
|
||||||
|
- [ ] **6A.4** — Hover dan tap feedback di semua elemen interaktif
|
||||||
|
|
||||||
|
#### 6B — Responsive Design Audit
|
||||||
|
- [ ] **6B.1** — Test di 320px (iPhone SE)
|
||||||
|
- [ ] **6B.2** — Test di 768px (tablet)
|
||||||
|
- [ ] **6B.3** — Test di 1280px (desktop)
|
||||||
|
- [ ] **6B.4** — Navbar collapse ke hamburger di mobile
|
||||||
|
|
||||||
|
#### 6C — UX Improvement
|
||||||
|
- [ ] **6C.1** — Toast notification (sukses tambah item, hapus, dll.)
|
||||||
|
- [ ] **6C.2** — Konfirmasi sebelum hapus item penting
|
||||||
|
- [ ] **6C.3** — Empty states yang informatif di semua halaman
|
||||||
|
- [ ] **6C.4** — Tombol "Reset Trip" dengan konfirmasi
|
||||||
|
|
||||||
|
#### 6D — Code Quality
|
||||||
|
- [ ] **6D.1** — Hapus semua `console.log`
|
||||||
|
- [ ] **6D.2** — Pastikan tidak ada prop drilling lebih dari 2 level
|
||||||
|
- [ ] **6D.3** — Cek semua edge case (trip belum dibuat, schedule kosong, dll.)
|
||||||
|
|
||||||
|
### Validasi Phase 6
|
||||||
|
- Aplikasi berjalan mulus di semua ukuran layar
|
||||||
|
- Tidak ada error di console browser
|
||||||
|
- localStorage berfungsi: data tetap ada setelah refresh
|
||||||
|
- Semua animasi smooth (tidak ada jank/lag)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Template Update task.md per Phase
|
||||||
|
|
||||||
|
Saat memulai pengerjaan, buat file `task.md` dengan format ini:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Task Progress — Tour Planner App
|
||||||
|
|
||||||
|
## Phase 0 — Setup [DONE/IN PROGRESS/TODO]
|
||||||
|
- [x] 0.1 Init Vite
|
||||||
|
- [x] 0.2 Install dependencies
|
||||||
|
...
|
||||||
|
|
||||||
|
## Phase 1 — Layout & UI [TODO]
|
||||||
|
- [ ] 1A.1 Navbar
|
||||||
|
...
|
||||||
|
|
||||||
|
## Phase 2 — Home Page [TODO]
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Update status item:**
|
||||||
|
- `[ ]` → belum dikerjakan
|
||||||
|
- `[/]` → sedang dikerjakan
|
||||||
|
- `[x]` → selesai dan tervalidasi
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚦 Decision Tree untuk Agent Baru
|
||||||
|
|
||||||
|
```
|
||||||
|
Masuk ke project
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Baca AGENTS.md + task.md
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Phase 0 selesai? ──NO──→ Kerjakan Phase 0 dulu
|
||||||
|
│
|
||||||
|
YES
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Phase 1 selesai? ──NO──→ Kerjakan Phase 1 dulu
|
||||||
|
│
|
||||||
|
YES
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Phase mana yang belum selesai? → Kerjakan sesuai urutan (2→3→4→5→6)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Semua done? → Lakukan validasi akhir & update walkthrough.md
|
||||||
|
```
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
---
|
||||||
|
name: feature-guide
|
||||||
|
description: Panduan implementasi masing-masing fitur utama Tour Planner App (Trip Selection, Schedule Builder, Packing List, Budget Calculator). Gunakan skill ini saat memulai atau melanjutkan pengerjaan satu halaman/fitur tertentu.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Skill: Feature Guide — Tour Planner App
|
||||||
|
|
||||||
|
## Fitur 1: Trip Selection (Home Page)
|
||||||
|
|
||||||
|
**File:** `src/pages/Home.jsx`
|
||||||
|
|
||||||
|
### Alur User
|
||||||
|
1. User membuka aplikasi → landing di halaman Home
|
||||||
|
2. Bisa search destinasi atau browse dari grid card
|
||||||
|
3. Filter berdasarkan kategori dan budget
|
||||||
|
4. Klik card → modal konfirmasi trip (isi tanggal, nama trip)
|
||||||
|
5. Confirm → redirect ke `/schedule`
|
||||||
|
|
||||||
|
### Data yang Diperlukan
|
||||||
|
```javascript
|
||||||
|
// src/data/destinations.js
|
||||||
|
export const destinations = [
|
||||||
|
{
|
||||||
|
id: 'bali-001',
|
||||||
|
name: 'Bali',
|
||||||
|
country: 'Indonesia',
|
||||||
|
category: 'pantai', // 'pantai' | 'gunung' | 'kota' | 'budaya'
|
||||||
|
description: 'Surga tropis...',
|
||||||
|
highlights: ['Tanah Lot', 'Ubud', 'Kuta Beach'],
|
||||||
|
estimatedBudget: { min: 2000000, max: 5000000 },
|
||||||
|
popularDuration: 4, // hari
|
||||||
|
rating: 4.8,
|
||||||
|
reviewCount: 12400,
|
||||||
|
image: '/assets/bali.jpg', // atau URL picsum
|
||||||
|
tags: ['romantis', 'budaya', 'alam'],
|
||||||
|
bestTime: 'April - Oktober',
|
||||||
|
},
|
||||||
|
// minimal 10 destinasi
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### State yang Diupdate
|
||||||
|
```javascript
|
||||||
|
setTrip({
|
||||||
|
id: destination.id,
|
||||||
|
name: tripName, // input user
|
||||||
|
destination: destination.name,
|
||||||
|
startDate: startDate, // dari date picker
|
||||||
|
endDate: endDate,
|
||||||
|
totalDays: diffInDays(startDate, endDate) + 1,
|
||||||
|
coverImage: destination.image,
|
||||||
|
type: 'preset',
|
||||||
|
category: destination.category,
|
||||||
|
});
|
||||||
|
initSchedule(totalDays, startDate); // inisialisasi array schedule
|
||||||
|
```
|
||||||
|
|
||||||
|
### Komponen yang Dibutuhkan
|
||||||
|
- `TripCard.jsx` — card destinasi dengan image, info, hover effect
|
||||||
|
- `TripSelector.jsx` — modal form input tanggal & nama trip
|
||||||
|
- `SearchBar.jsx` — search dengan debounce
|
||||||
|
- `FilterBar.jsx` — filter kategori (pills/tabs)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fitur 2: Daily Schedule Builder (Schedule Page)
|
||||||
|
|
||||||
|
**File:** `src/pages/Schedule.jsx`
|
||||||
|
|
||||||
|
### Alur User
|
||||||
|
1. User masuk ke `/schedule`
|
||||||
|
2. Melihat tab per hari (Day 1, Day 2, ...)
|
||||||
|
3. Klik "+ Tambah Aktivitas" → modal form
|
||||||
|
4. Aktivitas ditampilkan di timeline vertikal
|
||||||
|
5. Bisa drag & drop untuk reorder
|
||||||
|
|
||||||
|
### Komponen yang Dibutuhkan
|
||||||
|
- `DayTabs.jsx` — tab navigasi per hari
|
||||||
|
- `ScheduleTimeline.jsx` — timeline visual
|
||||||
|
- `ActivityItem.jsx` — satu item aktivitas (draggable)
|
||||||
|
- `AddActivityModal.jsx` — form tambah/edit aktivitas
|
||||||
|
|
||||||
|
### Implementasi Drag & Drop
|
||||||
|
```jsx
|
||||||
|
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
|
||||||
|
import { SortableContext, verticalListSortingStrategy, arrayMove } from '@dnd-kit/sortable';
|
||||||
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
|
||||||
|
// Di ActivityItem — tambahkan useSortable
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: activity.id });
|
||||||
|
const style = { transform: CSS.Transform.toString(transform), transition };
|
||||||
|
|
||||||
|
// Di ScheduleTimeline — wrap dengan DndContext
|
||||||
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
|
<SortableContext items={activityIds} strategy={verticalListSortingStrategy}>
|
||||||
|
{activities.map(a => <ActivityItem key={a.id} activity={a} />)}
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Template Aktivitas Cepat
|
||||||
|
```javascript
|
||||||
|
// src/data/activities.js
|
||||||
|
export const activityTemplates = [
|
||||||
|
{ name: 'Sarapan', category: 'makan', duration: 60, defaultTime: '07:00' },
|
||||||
|
{ name: 'Check-in Hotel', category: 'check-in', duration: 30, defaultTime: '14:00' },
|
||||||
|
{ name: 'Makan Siang', category: 'makan', duration: 60, defaultTime: '12:00' },
|
||||||
|
{ name: 'Makan Malam', category: 'makan', duration: 90, defaultTime: '18:30' },
|
||||||
|
{ name: 'Kunjungan Wisata', category: 'wisata', duration: 120, defaultTime: '10:00' },
|
||||||
|
{ name: 'Belanja Oleh-oleh', category: 'lainnya', duration: 60, defaultTime: '16:00' },
|
||||||
|
{ name: 'Berangkat/Transportasi', category: 'transportasi', duration: 180, defaultTime: '06:00' },
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fitur 3: Packing List Manager (PackingList Page)
|
||||||
|
|
||||||
|
**File:** `src/pages/PackingList.jsx`
|
||||||
|
|
||||||
|
### Alur User
|
||||||
|
1. User masuk ke `/packing`
|
||||||
|
2. Melihat daftar per kategori dengan progress
|
||||||
|
3. Centang item yang sudah dipak
|
||||||
|
4. Tambah item baru di kategori tertentu
|
||||||
|
5. Load template sesuai tipe trip
|
||||||
|
|
||||||
|
### Template Packing
|
||||||
|
```javascript
|
||||||
|
// src/data/packingTemplates.js
|
||||||
|
export const packingTemplates = {
|
||||||
|
pantai: {
|
||||||
|
Pakaian: [
|
||||||
|
{ name: 'Baju pantai', qty: 3 },
|
||||||
|
{ name: 'Celana pendek', qty: 2 },
|
||||||
|
{ name: 'Kacamata hitam', qty: 1 },
|
||||||
|
],
|
||||||
|
// ...
|
||||||
|
},
|
||||||
|
gunung: { /* ... */ },
|
||||||
|
kota: { /* ... */ },
|
||||||
|
budaya: { /* ... */ },
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Komponen yang Dibutuhkan
|
||||||
|
- `PackingOverview.jsx` — statistik total item & progress keseluruhan
|
||||||
|
- `PackingCategory.jsx` — section per kategori dengan sub-list
|
||||||
|
- `PackingItem.jsx` — checkbox item + qty counter + delete
|
||||||
|
|
||||||
|
### Logic Progress
|
||||||
|
```javascript
|
||||||
|
const getTotalStats = (packingList) => {
|
||||||
|
let total = 0, checked = 0;
|
||||||
|
Object.values(packingList).forEach(items => {
|
||||||
|
items.forEach(item => {
|
||||||
|
total++;
|
||||||
|
if (item.checked) checked++;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return { total, checked, percent: total > 0 ? Math.round((checked/total)*100) : 0 };
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fitur 4: Budget Calculator (Budget Page)
|
||||||
|
|
||||||
|
**File:** `src/pages/Budget.jsx`
|
||||||
|
|
||||||
|
### Alur User
|
||||||
|
1. User masuk ke `/budget`
|
||||||
|
2. Set total budget di bagian atas
|
||||||
|
3. Tambah pengeluaran satu per satu
|
||||||
|
4. Melihat ringkasan dan chart
|
||||||
|
5. Warning merah jika over budget
|
||||||
|
|
||||||
|
### Komponen yang Dibutuhkan
|
||||||
|
- `BudgetHeader.jsx` — input total budget + overview card (total, spent, remaining)
|
||||||
|
- `BudgetChart.jsx` — Recharts PieChart + BarChart
|
||||||
|
- `ExpenseList.jsx` — list semua pengeluaran
|
||||||
|
- `AddExpenseModal.jsx` — form tambah pengeluaran
|
||||||
|
|
||||||
|
### Recharts Integration
|
||||||
|
```jsx
|
||||||
|
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer, BarChart, Bar, XAxis, YAxis } from 'recharts';
|
||||||
|
|
||||||
|
// Data untuk pie chart
|
||||||
|
const pieData = Object.entries(byCategory).map(([name, value]) => ({ name, value }));
|
||||||
|
|
||||||
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie data={pieData} cx="50%" cy="50%" outerRadius={80} dataKey="value">
|
||||||
|
{pieData.map((entry, index) => (
|
||||||
|
<Cell key={index} fill={BUDGET_CATEGORY_COLORS[entry.name]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip formatter={(value) => formatIDR(value)} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Format Currency
|
||||||
|
```javascript
|
||||||
|
// src/utils/formatCurrency.js
|
||||||
|
export const formatIDR = (amount) =>
|
||||||
|
new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', minimumFractionDigits: 0 }).format(amount);
|
||||||
|
|
||||||
|
export const formatUSD = (amount) =>
|
||||||
|
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Navigasi Antar Halaman
|
||||||
|
|
||||||
|
```
|
||||||
|
/ (Home) → pilih trip
|
||||||
|
/schedule → jadwal harian
|
||||||
|
/packing → packing list
|
||||||
|
/budget → kalkulasi budget
|
||||||
|
```
|
||||||
|
|
||||||
|
Navbar harus selalu tampil dan menampilkan:
|
||||||
|
- Logo / nama app
|
||||||
|
- Link ke semua 4 halaman
|
||||||
|
- Indikator halaman aktif
|
||||||
|
- Badge progress (% packing done, budget status)
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
---
|
||||||
|
name: react-component
|
||||||
|
description: Panduan membuat React component untuk Tour Planner App. Gunakan skill ini ketika membuat komponen baru, baik UI umum maupun komponen spesifik fitur (trip, schedule, packing, budget).
|
||||||
|
---
|
||||||
|
|
||||||
|
# Skill: React Component — Tour Planner App
|
||||||
|
|
||||||
|
## Aturan Dasar
|
||||||
|
|
||||||
|
Semua komponen HARUS mengikuti pola berikut:
|
||||||
|
- Functional component dengan arrow function
|
||||||
|
- Props didestruktur di parameter
|
||||||
|
- Menggunakan CSS variables dari design system (lihat `src/index.css`)
|
||||||
|
- Menggunakan Framer Motion untuk animasi
|
||||||
|
- Menggunakan Lucide React untuk icon
|
||||||
|
|
||||||
|
## Template Dasar Komponen
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { IconName } from 'lucide-react';
|
||||||
|
import styles from './ComponentName.module.css'; // opsional
|
||||||
|
|
||||||
|
const ComponentName = ({ prop1, prop2, onAction }) => {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="component-name"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
{/* content */}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ComponentName;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Komponen UI yang Tersedia
|
||||||
|
|
||||||
|
### Button
|
||||||
|
```jsx
|
||||||
|
<Button variant="primary|secondary|ghost|danger" size="sm|md|lg" icon={IconComponent} onClick={fn}>
|
||||||
|
Label
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Card
|
||||||
|
```jsx
|
||||||
|
<Card glassmorphism hoverable>
|
||||||
|
{/* content */}
|
||||||
|
</Card>
|
||||||
|
```
|
||||||
|
Variants: `glassmorphism` (default), `elevated`, `outlined`
|
||||||
|
|
||||||
|
### Modal
|
||||||
|
```jsx
|
||||||
|
<Modal isOpen={bool} onClose={fn} title="Judul Modal">
|
||||||
|
{/* content */}
|
||||||
|
</Modal>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Badge
|
||||||
|
```jsx
|
||||||
|
<Badge variant="primary|success|warning|danger">{text}</Badge>
|
||||||
|
```
|
||||||
|
|
||||||
|
### ProgressBar
|
||||||
|
```jsx
|
||||||
|
<ProgressBar value={0-100} label="Teks" showPercent />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Animasi Standar
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Fade in dari bawah (card, item list)
|
||||||
|
const fadeInUp = {
|
||||||
|
initial: { opacity: 0, y: 20 },
|
||||||
|
animate: { opacity: 1, y: 0 },
|
||||||
|
transition: { duration: 0.3 }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stagger anak-anak (list items)
|
||||||
|
const containerVariants = {
|
||||||
|
animate: { transition: { staggerChildren: 0.08 } }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Scale on hover (interactive card)
|
||||||
|
const hoverScale = {
|
||||||
|
whileHover: { scale: 1.02 },
|
||||||
|
whileTap: { scale: 0.98 }
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## CSS Classes Tersedia (dari index.css)
|
||||||
|
|
||||||
|
```
|
||||||
|
.glass — glassmorphism card
|
||||||
|
.glass-dark — glassmorphism gelap
|
||||||
|
.btn-primary — tombol primary
|
||||||
|
.btn-secondary — tombol secondary
|
||||||
|
.btn-ghost — tombol ghost
|
||||||
|
.text-gradient — teks dengan gradient
|
||||||
|
.badge — badge dasar
|
||||||
|
.input-field — input form
|
||||||
|
.section-title — heading section
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Baru Selalu di Folder yang Tepat
|
||||||
|
|
||||||
|
| Tipe | Lokasi |
|
||||||
|
|------|--------|
|
||||||
|
| Layout component | `src/components/layout/` |
|
||||||
|
| UI generic | `src/components/ui/` |
|
||||||
|
| Trip feature | `src/components/trip/` |
|
||||||
|
| Schedule feature | `src/components/schedule/` |
|
||||||
|
| Packing feature | `src/components/packing/` |
|
||||||
|
| Budget feature | `src/components/budget/` |
|
||||||
|
| Full page | `src/pages/` |
|
||||||
|
|
||||||
|
## Contoh: Membuat Activity Item
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// src/components/schedule/ActivityItem.jsx
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Clock, MapPin, GripVertical, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
const ActivityItem = ({ activity, onDelete, dragHandleProps }) => {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="activity-item"
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: 20 }}
|
||||||
|
>
|
||||||
|
<div className="activity-drag-handle" {...dragHandleProps}>
|
||||||
|
<GripVertical size={16} />
|
||||||
|
</div>
|
||||||
|
<div className="activity-time">
|
||||||
|
<Clock size={14} />
|
||||||
|
<span>{activity.time}</span>
|
||||||
|
</div>
|
||||||
|
<div className="activity-content">
|
||||||
|
<h4>{activity.name}</h4>
|
||||||
|
<div className="activity-location">
|
||||||
|
<MapPin size={12} />
|
||||||
|
<span>{activity.location}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => onDelete(activity.id)}>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ActivityItem;
|
||||||
|
```
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
---
|
||||||
|
name: state-management
|
||||||
|
description: Panduan lengkap penggunaan Zustand store untuk Tour Planner App. Gunakan skill ini ketika membaca, menulis, atau menambahkan state baru ke global store, atau saat membuat custom hooks.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Skill: State Management — Zustand Store
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Aplikasi ini menggunakan Zustand dengan middleware `persist` untuk menyimpan state ke localStorage.
|
||||||
|
File utama: `src/store/tourStore.js`
|
||||||
|
localStorage key: `tour-planner-storage`
|
||||||
|
|
||||||
|
## Full Store Shape
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
// === TRIP INFO ===
|
||||||
|
trip: {
|
||||||
|
id: null, // string | null
|
||||||
|
name: '', // nama trip user
|
||||||
|
destination: '', // nama destinasi
|
||||||
|
startDate: null, // ISO string
|
||||||
|
endDate: null, // ISO string
|
||||||
|
totalDays: 0, // number
|
||||||
|
coverImage: '', // URL atau path lokal
|
||||||
|
type: 'custom', // 'preset' | 'custom'
|
||||||
|
category: '', // 'pantai' | 'gunung' | 'kota' | 'budaya'
|
||||||
|
},
|
||||||
|
|
||||||
|
// === SCHEDULE ===
|
||||||
|
// Array of days, index = hari ke-N (0-based)
|
||||||
|
schedule: [
|
||||||
|
{
|
||||||
|
dayNumber: 1, // display number
|
||||||
|
date: '', // ISO string
|
||||||
|
activities: [
|
||||||
|
{
|
||||||
|
id: '', // uuid
|
||||||
|
time: '09:00', // HH:mm
|
||||||
|
name: '',
|
||||||
|
location: '',
|
||||||
|
duration: 60, // menit
|
||||||
|
category: '', // 'wisata' | 'makan' | 'transportasi' | 'check-in' | 'lainnya'
|
||||||
|
notes: '',
|
||||||
|
color: '', // CSS color untuk timeline
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
// === PACKING LIST ===
|
||||||
|
packingList: {
|
||||||
|
Pakaian: [
|
||||||
|
{ id: '', name: '', qty: 1, checked: false }
|
||||||
|
],
|
||||||
|
Dokumen: [],
|
||||||
|
Elektronik: [],
|
||||||
|
'Obat-obatan': [],
|
||||||
|
Toiletries: [],
|
||||||
|
Lainnya: [],
|
||||||
|
},
|
||||||
|
|
||||||
|
// === BUDGET ===
|
||||||
|
budget: {
|
||||||
|
total: 0, // number (IDR)
|
||||||
|
currency: 'IDR', // 'IDR' | 'USD' | 'EUR'
|
||||||
|
expenses: [
|
||||||
|
{
|
||||||
|
id: '', // uuid
|
||||||
|
name: '',
|
||||||
|
amount: 0,
|
||||||
|
category: '', // 'transportasi' | 'akomodasi' | 'makan' | 'aktivitas' | 'oleh-oleh' | 'lainnya'
|
||||||
|
day: null, // number (hari ke berapa) | null (umum)
|
||||||
|
date: '', // ISO string opsional
|
||||||
|
notes: '',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Actions API
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const {
|
||||||
|
// Trip actions
|
||||||
|
setTrip, // (tripData: Partial<trip>) => void
|
||||||
|
resetTrip, // () => void
|
||||||
|
|
||||||
|
// Schedule actions
|
||||||
|
initSchedule, // (totalDays: number, startDate: string) => void
|
||||||
|
addActivity, // (dayIndex: number, activity: Activity) => void
|
||||||
|
updateActivity, // (dayIndex: number, activityId: string, data: Partial<Activity>) => void
|
||||||
|
removeActivity, // (dayIndex: number, activityId: string) => void
|
||||||
|
reorderActivities,// (dayIndex: number, oldIndex: number, newIndex: number) => void
|
||||||
|
moveActivity, // (fromDay: number, toDay: number, activityId: string) => void
|
||||||
|
|
||||||
|
// Packing actions
|
||||||
|
addPackingItem, // (category: string, item: PackingItem) => void
|
||||||
|
updatePackingItem,// (category: string, id: string, data: Partial<PackingItem>) => void
|
||||||
|
togglePackingItem,// (category: string, id: string) => void
|
||||||
|
removePackingItem,// (category: string, id: string) => void
|
||||||
|
loadPackingTemplate, // (tripType: string) => void
|
||||||
|
|
||||||
|
// Budget actions
|
||||||
|
setTotalBudget, // (amount: number) => void
|
||||||
|
addExpense, // (expense: Expense) => void
|
||||||
|
updateExpense, // (id: string, data: Partial<Expense>) => 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
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
```
|
||||||
@@ -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:
|
||||||
|
<div className="page-wrapper">
|
||||||
|
<div className="page-header">
|
||||||
|
<h1 className="page-title">Judul</h1>
|
||||||
|
<p className="page-subtitle">Deskripsi</p>
|
||||||
|
</div>
|
||||||
|
<div className="page-content">
|
||||||
|
{/* konten */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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 }
|
||||||
|
};
|
||||||
|
```
|
||||||
+24
@@ -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?
|
||||||
@@ -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 }]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.*
|
||||||
@@ -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.*
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="id">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="Ready to Plan Tour (RPT) — Rencanakan perjalanan wisata impian Anda, susun jadwal harian interaktif, kelola checklist barang bawaan, dan kalkulasi budget secara cerdas." />
|
||||||
|
<title>Ready to Plan Tour (RPT) — Smart Trip & Budget Planner</title>
|
||||||
|
<!-- Google Fonts -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="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" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1925
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
+32
@@ -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 (
|
||||||
|
<Router>
|
||||||
|
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column', backgroundColor: 'var(--color-bg-base)' }}>
|
||||||
|
<Navbar />
|
||||||
|
<main style={{ flex: 1 }}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Home />} />
|
||||||
|
<Route path="/schedule" element={<Schedule />} />
|
||||||
|
<Route path="/packing" element={<PackingList />} />
|
||||||
|
<Route path="/budget" element={<Budget />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
<ToastContainer />
|
||||||
|
</div>
|
||||||
|
</Router>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -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 (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={isEditing ? 'Edit Catatan Pengeluaran' : 'Catat Pengeluaran Baru'}
|
||||||
|
maxWidth="md"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
icon={isEditing ? Check : Plus}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
{isEditing ? 'Simpan Perubahan' : 'Catat Pengeluaran'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||||||
|
{/* Expense Name */}
|
||||||
|
<div className="input-group">
|
||||||
|
<label className="input-label">Nama Pengeluaran / Item</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input-field"
|
||||||
|
placeholder="e.g. Tiket Pesawat, Sewa Mobil, Makan Malam Seafood..."
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Amount */}
|
||||||
|
<div className="input-group">
|
||||||
|
<label className="input-label" style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
<DollarSign size={14} style={{ color: 'var(--color-success)' }} /> Jumlah Biaya ({currency})
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1000"
|
||||||
|
className="input-field"
|
||||||
|
placeholder="e.g. 250000"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{amount && (
|
||||||
|
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--color-accent-light)' }}>
|
||||||
|
Terbaca: {formatCurrency(amount, currency)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category & Day Grid */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 'var(--space-3)' }}>
|
||||||
|
<div className="input-group">
|
||||||
|
<label className="input-label" style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
<Tag size={14} style={{ color: 'var(--color-text-secondary)' }} /> Kategori
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="select-field"
|
||||||
|
value={category}
|
||||||
|
onChange={(e) => setCategory(e.target.value)}
|
||||||
|
>
|
||||||
|
{BUDGET_CATEGORY_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.id} value={opt.id} style={{ background: 'var(--color-bg-elevated)', color: '#fff' }}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="input-group">
|
||||||
|
<label className="input-label" style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
<Calendar size={14} style={{ color: 'var(--color-primary-light)' }} /> Hari ke-
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="select-field"
|
||||||
|
value={day}
|
||||||
|
onChange={(e) => setDay(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="" style={{ background: 'var(--color-bg-elevated)', color: '#fff' }}>
|
||||||
|
Umum / Seluruh Trip
|
||||||
|
</option>
|
||||||
|
{Array.from({ length: Math.max(1, totalDays) }, (_, i) => (
|
||||||
|
<option key={i + 1} value={i + 1} style={{ background: 'var(--color-bg-elevated)', color: '#fff' }}>
|
||||||
|
Hari ke-{i + 1}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<div className="input-group">
|
||||||
|
<label className="input-label" style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
<FileText size={14} style={{ color: 'var(--color-text-secondary)' }} /> Catatan / Keterangan (Opsional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
className="textarea-field"
|
||||||
|
rows={2}
|
||||||
|
placeholder="e.g. Pembayaran via kartu kredit, patungan 2 orang..."
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddExpenseModal;
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className="glass-dark"
|
||||||
|
style={{
|
||||||
|
padding: 'var(--space-2) var(--space-4)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
boxShadow: 'var(--shadow-md)',
|
||||||
|
fontSize: 'var(--text-xs)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 700, color: '#ffffff', marginBottom: '2px' }}>
|
||||||
|
{data.payload.name || data.name}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: 'var(--color-accent-light)', fontWeight: 600 }}>
|
||||||
|
{formatCurrency(data.value, currency)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className="glass"
|
||||||
|
style={{
|
||||||
|
borderRadius: 'var(--radius-2xl)',
|
||||||
|
padding: 'var(--space-6)',
|
||||||
|
marginBottom: 'var(--space-8)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Chart Header & Toggle Switch */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 'var(--space-3)',
|
||||||
|
marginBottom: 'var(--space-6)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '36px',
|
||||||
|
height: '36px',
|
||||||
|
borderRadius: 'var(--radius-lg)',
|
||||||
|
backgroundColor: 'var(--color-primary-glow)',
|
||||||
|
color: 'var(--color-primary-light)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{chartView === 'category' ? <PieIcon size={18} /> : <BarChart3 size={18} />}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 style={{ fontSize: 'var(--text-lg)', color: '#ffffff' }}>
|
||||||
|
Visualisasi Distribusi Pengeluaran
|
||||||
|
</h3>
|
||||||
|
<p style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-secondary)' }}>
|
||||||
|
{chartView === 'category' ? 'Berdasarkan pos kategori pengeluaran' : 'Berdasarkan pengeluaran per hari'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* View Toggle */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
backgroundColor: 'hsla(220, 15%, 15%, 0.8)',
|
||||||
|
padding: '3px',
|
||||||
|
borderRadius: 'var(--radius-full)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => 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)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tag size={12} />
|
||||||
|
<span>Kategori</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => 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)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Calendar size={12} />
|
||||||
|
<span>Per Hari</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chart Canvas */}
|
||||||
|
<div style={{ width: '100%', height: 260 }}>
|
||||||
|
{chartView === 'category' ? (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={pieData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={55}
|
||||||
|
outerRadius={95}
|
||||||
|
paddingAngle={4}
|
||||||
|
dataKey="value"
|
||||||
|
>
|
||||||
|
{pieData.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={entry.color} stroke="none" />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip content={<CustomTooltip currency={currency} />} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={barData} margin={{ top: 10, right: 10, left: 10, bottom: 20 }}>
|
||||||
|
<XAxis dataKey="name" stroke="var(--color-text-muted)" fontSize={11} tickLine={false} />
|
||||||
|
<YAxis
|
||||||
|
stroke="var(--color-text-muted)"
|
||||||
|
fontSize={10}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickFormatter={(val) => (val >= 1000000 ? `${(val / 1000000).toFixed(1)}M` : `${(val / 1000).toFixed(0)}k`)}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip currency={currency} />} />
|
||||||
|
<Bar dataKey="amount" fill="var(--color-primary-light)" radius={[6, 6, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Legends for Pie */}
|
||||||
|
{chartView === 'category' && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 'var(--space-3)',
|
||||||
|
marginTop: 'var(--space-4)',
|
||||||
|
borderTop: '1px solid var(--color-border)',
|
||||||
|
paddingTop: 'var(--space-4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pieData.map((item) => {
|
||||||
|
const pct = Math.round((item.value / totalSpent) * 100);
|
||||||
|
return (
|
||||||
|
<div key={item.key} style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: 'var(--text-xs)' }}>
|
||||||
|
<span style={{ width: '10px', height: '10px', borderRadius: 'var(--radius-sm)', backgroundColor: item.color }} />
|
||||||
|
<span style={{ color: 'var(--color-text-primary)', fontWeight: 500 }}>{item.name}</span>
|
||||||
|
<span style={{ color: 'var(--color-text-muted)' }}>({pct}%)</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BudgetChart;
|
||||||
@@ -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 (
|
||||||
|
<div style={{ marginBottom: 'var(--space-8)' }}>
|
||||||
|
{/* 3 Overview Stat Cards Grid */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
|
||||||
|
gap: 'var(--space-4)',
|
||||||
|
marginBottom: 'var(--space-6)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Card 1: Total Budget */}
|
||||||
|
<div
|
||||||
|
className="glass"
|
||||||
|
style={{
|
||||||
|
padding: 'var(--space-5) var(--space-6)',
|
||||||
|
borderRadius: 'var(--radius-xl)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-2)' }}>
|
||||||
|
<div style={{ fontSize: 'var(--text-xs)', textTransform: 'uppercase', color: 'var(--color-text-muted)', fontWeight: 700, letterSpacing: '0.04em' }}>
|
||||||
|
Target Total Budget
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
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',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil size={12} /> {isEditingBudget ? 'Tutup' : 'Ubah'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEditingBudget ? (
|
||||||
|
<form onSubmit={handleSaveBudget} style={{ display: 'flex', gap: 'var(--space-2)', marginTop: 'var(--space-2)' }}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="100000"
|
||||||
|
className="input-field"
|
||||||
|
value={budgetInput}
|
||||||
|
onChange={(e) => setBudgetInput(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
style={{ padding: 'var(--space-2) var(--space-3)', fontSize: 'var(--text-sm)' }}
|
||||||
|
/>
|
||||||
|
<Button type="submit" variant="primary" size="sm" icon={Check}>
|
||||||
|
Simpan
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--text-2xl)',
|
||||||
|
fontWeight: 800,
|
||||||
|
color: '#ffffff',
|
||||||
|
fontFamily: 'var(--font-display)',
|
||||||
|
letterSpacing: '-0.02em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatCurrency(totalBudget, currency)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-secondary)', marginTop: 'var(--space-2)' }}>
|
||||||
|
Alokasi dana keseluruhan trip
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card 2: Total Spent */}
|
||||||
|
<div
|
||||||
|
className="glass"
|
||||||
|
style={{
|
||||||
|
padding: 'var(--space-5) var(--space-6)',
|
||||||
|
borderRadius: 'var(--radius-xl)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-2)' }}>
|
||||||
|
<div style={{ fontSize: 'var(--text-xs)', textTransform: 'uppercase', color: 'var(--color-text-muted)', fontWeight: 700, letterSpacing: '0.04em' }}>
|
||||||
|
Total Terpakai
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'hsla(38, 95%, 55%, 0.15)',
|
||||||
|
color: 'var(--color-accent-light)',
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: 'var(--radius-full)',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{percentUsed}% Terpakai
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--text-2xl)',
|
||||||
|
fontWeight: 800,
|
||||||
|
color: 'var(--color-accent-light)',
|
||||||
|
fontFamily: 'var(--font-display)',
|
||||||
|
letterSpacing: '-0.02em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatCurrency(totalSpent, currency)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-secondary)', marginTop: 'var(--space-2)' }}>
|
||||||
|
Pengeluaran yang tercatat saat ini
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card 3: Remaining Budget */}
|
||||||
|
<div
|
||||||
|
className="glass"
|
||||||
|
style={{
|
||||||
|
padding: 'var(--space-5) var(--space-6)',
|
||||||
|
borderRadius: 'var(--radius-xl)',
|
||||||
|
border: isOverBudget ? '1px solid var(--color-danger)' : '1px solid var(--color-border)',
|
||||||
|
boxShadow: isOverBudget ? '0 0 20px hsla(0, 75%, 55%, 0.25)' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-2)' }}>
|
||||||
|
<div style={{ fontSize: 'var(--text-xs)', textTransform: 'uppercase', color: 'var(--color-text-muted)', fontWeight: 700, letterSpacing: '0.04em' }}>
|
||||||
|
Sisa Budget
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: remainingStatus.bg,
|
||||||
|
color: remainingStatus.color,
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: 'var(--radius-full)',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{remainingStatus.text}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--text-2xl)',
|
||||||
|
fontWeight: 800,
|
||||||
|
color: isOverBudget ? 'var(--color-danger)' : 'var(--color-success)',
|
||||||
|
fontFamily: 'var(--font-display)',
|
||||||
|
letterSpacing: '-0.02em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatCurrency(remainingBudget, currency)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-secondary)', marginTop: 'var(--space-2)' }}>
|
||||||
|
{isOverBudget ? 'Melebihi alokasi anggaran!' : 'Sisa saldo yang dapat dibelanjakan'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress & Alert Bar */}
|
||||||
|
<div
|
||||||
|
className="glass-dark"
|
||||||
|
style={{
|
||||||
|
padding: 'var(--space-5) var(--space-6)',
|
||||||
|
borderRadius: 'var(--radius-xl)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-3)' }}>
|
||||||
|
<div style={{ fontSize: 'var(--text-sm)', fontWeight: 600, color: 'var(--color-text-primary)' }}>
|
||||||
|
Persentase Anggaran Terpakai
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
icon={Plus}
|
||||||
|
onClick={onOpenAddExpense}
|
||||||
|
>
|
||||||
|
Tambah Pengeluaran
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
value={percentUsed}
|
||||||
|
showPercent={false}
|
||||||
|
height={10}
|
||||||
|
variant={isOverBudget ? 'danger' : percentUsed >= 80 ? 'warning' : 'primary'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Warning Banner if Over Budget */}
|
||||||
|
{isOverBudget && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 5 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
style={{
|
||||||
|
marginTop: 'var(--space-4)',
|
||||||
|
padding: 'var(--space-3) var(--space-4)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
backgroundColor: 'var(--color-danger-bg)',
|
||||||
|
border: '1px solid hsla(0, 75%, 55%, 0.3)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-3)',
|
||||||
|
color: 'var(--color-danger)',
|
||||||
|
fontSize: 'var(--text-xs)',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertTriangle size={16} flexShrink={0} />
|
||||||
|
<span>
|
||||||
|
Perhatian: Pengeluaran Anda melebihi target anggaran sebesar{' '}
|
||||||
|
<strong>{formatCurrency(Math.abs(remainingBudget), currency)}</strong>. Evaluasi kembali pos belanja Anda.
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BudgetOverview;
|
||||||
@@ -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 (
|
||||||
|
<div className="glass" style={{ borderRadius: 'var(--radius-2xl)', padding: 'var(--space-6)', border: '1px solid var(--color-border)' }}>
|
||||||
|
{/* Header & Filter Pills */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 'var(--space-3)',
|
||||||
|
marginBottom: 'var(--space-6)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '36px',
|
||||||
|
height: '36px',
|
||||||
|
borderRadius: 'var(--radius-lg)',
|
||||||
|
backgroundColor: 'var(--color-accent-glow)',
|
||||||
|
color: 'var(--color-accent)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<WalletCards size={18} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 style={{ fontSize: 'var(--text-lg)', color: '#ffffff' }}>
|
||||||
|
Daftar Rincian Pengeluaran
|
||||||
|
</h3>
|
||||||
|
<p style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-secondary)' }}>
|
||||||
|
{expenses.length} transaksi tercatat
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
icon={Plus}
|
||||||
|
onClick={onOpenAddExpense}
|
||||||
|
>
|
||||||
|
Catat Pengeluaran
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter Category Tabs */}
|
||||||
|
{expenses.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
overflowX: 'auto',
|
||||||
|
paddingBottom: 'var(--space-3)',
|
||||||
|
marginBottom: 'var(--space-4)',
|
||||||
|
scrollbarWidth: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => 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})
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{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 (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => 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})
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Expenses Items List */}
|
||||||
|
{filteredExpenses.length > 0 ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-3)' }}>
|
||||||
|
<AnimatePresence>
|
||||||
|
{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 (
|
||||||
|
<motion.div
|
||||||
|
key={exp.id}
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
|
className="glass-dark"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: 'var(--space-4) var(--space-5)',
|
||||||
|
borderRadius: 'var(--radius-xl)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
gap: 'var(--space-4)',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Left info: Icon & Name */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)', flex: 1, minWidth: '220px' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '40px',
|
||||||
|
height: '40px',
|
||||||
|
borderRadius: 'var(--radius-lg)',
|
||||||
|
backgroundColor: `${catColor}25`,
|
||||||
|
color: catColor,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
border: `1px solid ${catColor}40`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={18} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 style={{ fontSize: 'var(--text-sm)', fontWeight: 600, color: '#ffffff', lineHeight: 1.3 }}>
|
||||||
|
{exp.name}
|
||||||
|
</h4>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', marginTop: '2px', fontSize: '11px', color: 'var(--color-text-secondary)' }}>
|
||||||
|
<span style={{ color: catColor, fontWeight: 600 }}>{catLabel}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{exp.day ? `Hari ke-${exp.day}` : 'Pengeluaran Umum'}</span>
|
||||||
|
{exp.notes && (
|
||||||
|
<>
|
||||||
|
<span>•</span>
|
||||||
|
<span style={{ color: 'var(--color-text-muted)', maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{exp.notes}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right info: Amount & Actions */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-4)' }}>
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--text-base)',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--color-accent-light)',
|
||||||
|
fontFamily: 'var(--font-display)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatCurrency(exp.amount, currency)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-1)' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => 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';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil size={15} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => 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';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
icon={DollarSign}
|
||||||
|
title="Belum Ada Catatan Pengeluaran"
|
||||||
|
description="Catat pengeluaran tiket, hotel, makanan, atau oleh-oleh untuk melihat grafik kalkulasi anggaran Anda."
|
||||||
|
actionText="Catat Pengeluaran Pertama"
|
||||||
|
actionIcon={Plus}
|
||||||
|
onAction={onOpenAddExpense}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExpenseList;
|
||||||
@@ -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 (
|
||||||
|
<footer
|
||||||
|
style={{
|
||||||
|
borderTop: '1px solid var(--color-border)',
|
||||||
|
backgroundColor: 'hsla(222, 28%, 6%, 0.95)',
|
||||||
|
padding: 'var(--space-10) 0 var(--space-8)',
|
||||||
|
marginTop: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="container">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))',
|
||||||
|
gap: 'var(--space-8)',
|
||||||
|
marginBottom: 'var(--space-8)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Brand Info */}
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)', marginBottom: 'var(--space-3)' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '34px',
|
||||||
|
height: '34px',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
background: 'linear-gradient(135deg, var(--color-primary), var(--color-accent))',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: '#fff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Compass size={18} />
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--font-display)',
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: 'var(--text-lg)',
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ready to <span style={{ color: 'var(--color-accent)' }}>Plan Tour</span> (RPT)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 'var(--text-sm)', color: 'var(--color-text-secondary)', maxWidth: '320px' }}>
|
||||||
|
Platform interaktif perencanaan perjalanan wisata tanpa batas. Rencanakan jadwal harian, checklist packing, dan kendalikan estimasi budget.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigasi Cepat */}
|
||||||
|
<div>
|
||||||
|
<h4
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--text-sm)',
|
||||||
|
fontWeight: 700,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
marginBottom: 'var(--space-3)',
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Fitur Utama
|
||||||
|
</h4>
|
||||||
|
<ul style={{ listStyle: 'none', display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
|
||||||
|
<li>
|
||||||
|
<Link to="/" style={{ fontSize: 'var(--text-sm)', color: 'var(--color-text-secondary)', display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
Trip Selection <ArrowUpRight size={13} />
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link to="/schedule" style={{ fontSize: 'var(--text-sm)', color: 'var(--color-text-secondary)', display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
Daily Schedule Builder <ArrowUpRight size={13} />
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link to="/packing" style={{ fontSize: 'var(--text-sm)', color: 'var(--color-text-secondary)', display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
Packing List Manager <ArrowUpRight size={13} />
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link to="/budget" style={{ fontSize: 'var(--text-sm)', color: 'var(--color-text-secondary)', display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
Budget Calculator <ArrowUpRight size={13} />
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active Trip Status */}
|
||||||
|
<div>
|
||||||
|
<h4
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--text-sm)',
|
||||||
|
fontWeight: 700,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
marginBottom: 'var(--space-3)',
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Trip Anda Saat Ini
|
||||||
|
</h4>
|
||||||
|
{trip.destination ? (
|
||||||
|
<div className="glass" style={{ padding: 'var(--space-3) var(--space-4)', borderRadius: 'var(--radius-md)' }}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 'var(--text-sm)', color: 'var(--color-accent-light)', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||||
|
<MapPin size={14} /> {trip.destination}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-muted)', marginTop: '2px' }}>
|
||||||
|
{trip.name || 'Trip Wisata'} • {trip.totalDays} Hari
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-muted)' }}>
|
||||||
|
Belum ada trip yang dipilih. Mulai pilih destinasi di halaman utama.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom bar */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderTop: '1px solid var(--color-border)',
|
||||||
|
paddingTop: 'var(--space-6)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 'var(--space-4)',
|
||||||
|
fontSize: 'var(--text-xs)',
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||||
|
<span>© {new Date().getFullYear()} Ready to Plan Tour (RPT). Static Web Application.</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', color: 'var(--color-text-secondary)' }}>
|
||||||
|
<span>Made with</span>
|
||||||
|
<Heart size={13} style={{ color: 'var(--color-danger)' }} />
|
||||||
|
<span>& React.js</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Footer;
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<header
|
||||||
|
className="glass-dark"
|
||||||
|
style={{
|
||||||
|
position: 'sticky',
|
||||||
|
top: 0,
|
||||||
|
zIndex: 'var(--z-sticky)',
|
||||||
|
borderBottom: '1px solid var(--color-border)',
|
||||||
|
padding: 'var(--space-3) 0',
|
||||||
|
backdropFilter: 'blur(20px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="container flex-between" style={{ position: 'relative' }}>
|
||||||
|
{/* Brand */}
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)', textDecoration: 'none' }}
|
||||||
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '40px',
|
||||||
|
height: '40px',
|
||||||
|
borderRadius: 'var(--radius-lg)',
|
||||||
|
background: 'linear-gradient(135deg, var(--color-primary), var(--color-accent))',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: '#fff',
|
||||||
|
boxShadow: 'var(--shadow-glow-primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Compass size={22} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--font-display)',
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: 'var(--text-lg)',
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
letterSpacing: '-0.02em',
|
||||||
|
lineHeight: 1.1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ready to <span style={{ color: 'var(--color-accent)' }}>Plan Tour</span>
|
||||||
|
</div>
|
||||||
|
{trip.destination ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--text-xs)',
|
||||||
|
color: 'var(--color-accent-light)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MapPin size={11} /> {trip.destination} ({trip.totalDays} Hari)
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-muted)', fontWeight: 600 }}>
|
||||||
|
RPT • Static Trip Planner
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Desktop Navigation Links */}
|
||||||
|
<nav
|
||||||
|
style={{
|
||||||
|
display: 'none',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
}}
|
||||||
|
className="desktop-nav"
|
||||||
|
>
|
||||||
|
{navLinks.map((link) => {
|
||||||
|
const Icon = link.icon;
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={link.to}
|
||||||
|
to={link.to}
|
||||||
|
end={link.end}
|
||||||
|
style={({ isActive }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
padding: 'var(--space-2) var(--space-4)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
fontSize: 'var(--text-sm)',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: isActive ? 'var(--color-primary-light)' : 'var(--color-text-secondary)',
|
||||||
|
background: isActive ? 'var(--color-primary-glow)' : 'transparent',
|
||||||
|
border: isActive ? '1px solid hsla(220, 90%, 56%, 0.3)' : '1px solid transparent',
|
||||||
|
transition: 'all var(--transition-fast)',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Icon size={16} />
|
||||||
|
<span>{link.label}</span>
|
||||||
|
{link.badge && (
|
||||||
|
<span className={`badge ${link.badgeVariant}`} style={{ fontSize: '10px', padding: '1px 6px' }}>
|
||||||
|
{link.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Reset Trip Button in Navbar if trip is active */}
|
||||||
|
{trip.destination && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsResetModalOpen(true)}
|
||||||
|
title="Reset seluruh rencana perjalanan"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
padding: 'var(--space-2) var(--space-3)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
fontSize: 'var(--text-xs)',
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
border: '1px solid transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all var(--transition-fast)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.color = 'var(--color-danger)';
|
||||||
|
e.currentTarget.style.backgroundColor = 'var(--color-danger-bg)';
|
||||||
|
e.currentTarget.style.borderColor = 'hsla(0, 75%, 55%, 0.3)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.color = 'var(--color-text-muted)';
|
||||||
|
e.currentTarget.style.backgroundColor = 'transparent';
|
||||||
|
e.currentTarget.style.borderColor = 'transparent';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RotateCcw size={13} />
|
||||||
|
<span>Reset</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Mobile Hamburger Button */}
|
||||||
|
<button
|
||||||
|
className="mobile-menu-btn"
|
||||||
|
aria-label="Toggle Menu"
|
||||||
|
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||||
|
style={{
|
||||||
|
display: 'none',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '40px',
|
||||||
|
height: '40px',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
backgroundColor: 'var(--color-bg-elevated)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isMobileMenuOpen ? <X size={20} /> : <Menu size={20} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Drawer Navigation */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isMobileMenuOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
transition={{ duration: 0.25 }}
|
||||||
|
style={{
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderTop: '1px solid var(--color-border)',
|
||||||
|
marginTop: 'var(--space-3)',
|
||||||
|
backgroundColor: 'hsla(222, 28%, 9%, 0.98)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="container" style={{ padding: 'var(--space-4) var(--space-6)' }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
|
||||||
|
{navLinks.map((link) => {
|
||||||
|
const Icon = link.icon;
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={link.to}
|
||||||
|
to={link.to}
|
||||||
|
end={link.end}
|
||||||
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
|
style={({ isActive }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: 'var(--space-3) var(--space-4)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
fontSize: 'var(--text-sm)',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: isActive ? 'var(--color-primary-light)' : 'var(--color-text-secondary)',
|
||||||
|
background: isActive ? 'var(--color-primary-glow)' : 'hsla(222, 18%, 18%, 0.5)',
|
||||||
|
border: isActive ? '1px solid hsla(220, 90%, 56%, 0.3)' : '1px solid var(--color-border)',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
|
||||||
|
<Icon size={18} />
|
||||||
|
<span>{link.label}</span>
|
||||||
|
</div>
|
||||||
|
{link.badge && (
|
||||||
|
<span className={`badge ${link.badgeVariant}`} style={{ fontSize: '10px', padding: '2px 8px' }}>
|
||||||
|
{link.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{trip.destination && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setIsMobileMenuOpen(false);
|
||||||
|
setIsResetModalOpen(true);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-3)',
|
||||||
|
padding: 'var(--space-3) var(--space-4)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
fontSize: 'var(--text-sm)',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--color-danger)',
|
||||||
|
backgroundColor: 'var(--color-danger-bg)',
|
||||||
|
border: '1px solid hsla(0, 75%, 55%, 0.3)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
marginTop: 'var(--space-2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RotateCcw size={16} />
|
||||||
|
<span>Reset Seluruh Data Trip</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.desktop-nav { display: flex !important; }
|
||||||
|
.mobile-menu-btn { display: none !important; }
|
||||||
|
}
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.desktop-nav { display: none !important; }
|
||||||
|
.mobile-menu-btn { display: flex !important; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Confirmation Modal for Reset */}
|
||||||
|
<Modal
|
||||||
|
isOpen={isResetModalOpen}
|
||||||
|
onClose={() => setIsResetModalOpen(false)}
|
||||||
|
title="Konfirmasi Reset Rencana Perjalanan"
|
||||||
|
maxWidth="sm"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={() => setIsResetModalOpen(false)}>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" icon={RotateCcw} onClick={handleConfirmReset}>
|
||||||
|
Ya, Reset Semua
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-3)' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '48px',
|
||||||
|
height: '48px',
|
||||||
|
borderRadius: 'var(--radius-full)',
|
||||||
|
backgroundColor: 'var(--color-danger-bg)',
|
||||||
|
color: 'var(--color-danger)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
margin: '0 auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertTriangle size={24} />
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 'var(--text-sm)', color: 'var(--color-text-secondary)', textAlign: 'center' }}>
|
||||||
|
Apakah Anda yakin ingin mereset rencana perjalanan ke <strong>{trip.destination}</strong>?
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-muted)', textAlign: 'center' }}>
|
||||||
|
Tindakan ini akan menghapus jadwal harian, checklist barang, dan catatan pengeluaran budget yang tersimpan di browser Anda.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Navbar;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
const PageWrapper = ({ children, className = '', style = {}, fullWidth = false }) => {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 15 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -15 }}
|
||||||
|
transition={{ duration: 0.25, ease: 'easeOut' }}
|
||||||
|
className={`page-wrapper ${fullWidth ? '' : 'container'} ${className}`}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PageWrapper;
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className="glass"
|
||||||
|
style={{
|
||||||
|
borderRadius: 'var(--radius-xl)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
marginBottom: 'var(--space-4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Category Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: 'var(--space-4) var(--space-6)',
|
||||||
|
backgroundColor: 'hsla(222, 22%, 16%, 0.9)',
|
||||||
|
borderBottom: isExpanded ? '1px solid var(--color-border)' : 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '36px',
|
||||||
|
height: '36px',
|
||||||
|
borderRadius: 'var(--radius-lg)',
|
||||||
|
backgroundColor: 'var(--color-primary-glow)',
|
||||||
|
color: 'var(--color-primary-light)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CategoryIcon size={18} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<h3 style={{ fontSize: 'var(--text-base)', color: 'var(--color-text-primary)', fontWeight: 700 }}>
|
||||||
|
{category}
|
||||||
|
</h3>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 600,
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: 'var(--radius-full)',
|
||||||
|
backgroundColor: categoryPercent === 100 ? 'var(--color-success-bg)' : 'hsla(220, 15%, 25%, 0.6)',
|
||||||
|
color: categoryPercent === 100 ? 'var(--color-success)' : 'var(--color-text-secondary)',
|
||||||
|
border: categoryPercent === 100 ? '1px solid hsla(142, 70%, 45%, 0.3)' : '1px solid var(--color-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{packedCount}/{totalCount} item ({categoryPercent}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Controls: Check All button & Expand/Collapse Toggle */}
|
||||||
|
<div
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{totalCount > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => 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 ? <CheckSquare size={13} /> : <Square size={13} />}
|
||||||
|
<span>{isAllChecked ? 'Batalkan Semua' : 'Centang Semua'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
style={{
|
||||||
|
padding: '6px',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isExpanded ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Accordion Content */}
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{isExpanded && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<div style={{ padding: 'var(--space-5) var(--space-6)' }}>
|
||||||
|
{/* Item List */}
|
||||||
|
{items.length > 0 ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)', marginBottom: 'var(--space-4)' }}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<PackingItem
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
category={category}
|
||||||
|
onToggle={onToggleItem}
|
||||||
|
onUpdateQty={onUpdateQty}
|
||||||
|
onDelete={onDeleteItem}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-muted)', marginBottom: 'var(--space-4)', fontStyle: 'italic' }}>
|
||||||
|
Belum ada item di kategori ini. Tambahkan di bawah.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Inline Add Form */}
|
||||||
|
<form
|
||||||
|
onSubmit={handleAddNewItem}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingTop: 'var(--space-2)',
|
||||||
|
borderTop: '1px dashed var(--color-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input-field"
|
||||||
|
placeholder={`+ Tambah barang ke ${category}...`}
|
||||||
|
value={newItemName}
|
||||||
|
onChange={(e) => setNewItemName(e.target.value)}
|
||||||
|
style={{ flex: 1, padding: 'var(--space-2) var(--space-3)', fontSize: 'var(--text-xs)' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
className="input-field"
|
||||||
|
title="Jumlah / Quantity"
|
||||||
|
value={newItemQty}
|
||||||
|
onChange={(e) => 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)' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
icon={Plus}
|
||||||
|
>
|
||||||
|
Tambah
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PackingCategory;
|
||||||
@@ -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 (
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: 'var(--space-3) var(--space-4)',
|
||||||
|
borderRadius: 'var(--radius-lg)',
|
||||||
|
backgroundColor: isChecked ? 'hsla(222, 28%, 10%, 0.5)' : 'hsla(222, 22%, 18%, 0.6)',
|
||||||
|
border: isChecked ? '1px solid hsla(142, 70%, 45%, 0.25)' : '1px solid var(--color-border)',
|
||||||
|
transition: 'all var(--transition-fast)',
|
||||||
|
gap: 'var(--space-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Checkbox & Item Name */}
|
||||||
|
<div
|
||||||
|
onClick={() => onToggle(category, item.id)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-3)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Custom Animated Checkbox */}
|
||||||
|
<motion.div
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
whileTap={{ scale: 0.9 }}
|
||||||
|
style={{
|
||||||
|
width: '22px',
|
||||||
|
height: '22px',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
border: isChecked ? '2px solid var(--color-success)' : '2px solid var(--color-border-light)',
|
||||||
|
backgroundColor: isChecked ? 'var(--color-success)' : 'transparent',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: '#ffffff',
|
||||||
|
transition: 'background-color var(--transition-fast), border-color var(--transition-fast)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isChecked && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0, rotate: -45 }}
|
||||||
|
animate={{ scale: 1, rotate: 0 }}
|
||||||
|
transition={{ type: 'spring', damping: 15, stiffness: 400 }}
|
||||||
|
>
|
||||||
|
<Check size={14} strokeWidth={3} />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Item Label */}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--text-sm)',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: isChecked ? 'var(--color-text-muted)' : 'var(--color-text-primary)',
|
||||||
|
textDecoration: isChecked ? 'line-through' : 'none',
|
||||||
|
transition: 'all var(--transition-fast)',
|
||||||
|
lineHeight: 1.3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quantity Counter & Delete Button */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
|
||||||
|
{/* Quantity Controls */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: 'hsla(220, 15%, 15%, 0.8)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
padding: '2px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => 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)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Minus size={12} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
minWidth: '24px',
|
||||||
|
textAlign: 'center',
|
||||||
|
fontSize: 'var(--text-xs)',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#ffffff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.qty || 1}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => 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)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => 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';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PackingItem;
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className="glass-dark"
|
||||||
|
style={{
|
||||||
|
borderRadius: 'var(--radius-2xl)',
|
||||||
|
padding: 'var(--space-6)',
|
||||||
|
marginBottom: 'var(--space-8)',
|
||||||
|
border: isComplete ? '1px solid var(--color-success)' : '1px solid var(--color-border)',
|
||||||
|
boxShadow: isComplete ? 'var(--shadow-lg), 0 0 25px hsla(142, 70%, 45%, 0.2)' : 'var(--shadow-md)',
|
||||||
|
transition: 'all var(--transition-base)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 'var(--space-4)',
|
||||||
|
marginBottom: 'var(--space-5)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-4)' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '48px',
|
||||||
|
height: '48px',
|
||||||
|
borderRadius: 'var(--radius-xl)',
|
||||||
|
background: isComplete
|
||||||
|
? 'linear-gradient(135deg, var(--color-success), hsl(142, 70%, 55%))'
|
||||||
|
: 'linear-gradient(135deg, var(--color-primary), var(--color-accent))',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: '#ffffff',
|
||||||
|
boxShadow: isComplete ? '0 0 20px hsla(142, 70%, 45%, 0.4)' : 'var(--shadow-glow-primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isComplete ? <CheckCircle2 size={26} /> : <Luggage size={24} />}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<h2 style={{ fontSize: 'var(--text-xl)', color: '#ffffff' }}>
|
||||||
|
Progress Pengepakan Barang
|
||||||
|
</h2>
|
||||||
|
<span className={`badge badge-${status.variant}`} style={{ fontSize: '11px' }}>
|
||||||
|
{status.text}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-secondary)', marginTop: '2px' }}>
|
||||||
|
{packedItems} dari {totalItems} barang sudah siap di dalam koper / tas
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
icon={Layers}
|
||||||
|
onClick={onOpenTemplateModal}
|
||||||
|
>
|
||||||
|
Ganti Template
|
||||||
|
</Button>
|
||||||
|
{totalItems > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
icon={RefreshCw}
|
||||||
|
onClick={onResetAll}
|
||||||
|
title="Reset status centang"
|
||||||
|
>
|
||||||
|
Reset Centang
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Progress Bar */}
|
||||||
|
<ProgressBar
|
||||||
|
value={percentPacked}
|
||||||
|
showPercent={true}
|
||||||
|
height={10}
|
||||||
|
variant={isComplete ? 'success' : percentPacked > 50 ? 'accent' : 'primary'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Celebration Message */}
|
||||||
|
{isComplete && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
style={{
|
||||||
|
marginTop: 'var(--space-4)',
|
||||||
|
padding: 'var(--space-3) var(--space-4)',
|
||||||
|
borderRadius: 'var(--radius-lg)',
|
||||||
|
backgroundColor: 'var(--color-success-bg)',
|
||||||
|
border: '1px solid hsla(142, 70%, 45%, 0.3)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-3)',
|
||||||
|
color: 'var(--color-success)',
|
||||||
|
fontSize: 'var(--text-sm)',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Sparkles size={18} />
|
||||||
|
<span>Luar biasa! Semua perlengkapan sudah lengkap dipak. Anda siap berangkat liburan! 🏖️🚀</span>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PackingOverview;
|
||||||
@@ -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 (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Pilih Template Bawaan Perjalanan"
|
||||||
|
maxWidth="md"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
icon={Check}
|
||||||
|
onClick={handleApply}
|
||||||
|
>
|
||||||
|
Terapkan Template
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-secondary)', marginBottom: 'var(--space-4)' }}>
|
||||||
|
Pilih template bawaan sesuai jenis destinasi Anda untuk memuat daftar checklist barang secara otomatis.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-3)' }}>
|
||||||
|
{templateOptions.map((opt) => {
|
||||||
|
const Icon = opt.icon;
|
||||||
|
const isSelected = selectedTemplate === opt.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={opt.id}
|
||||||
|
onClick={() => 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)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '42px',
|
||||||
|
height: '42px',
|
||||||
|
borderRadius: 'var(--radius-lg)',
|
||||||
|
backgroundColor: isSelected ? 'var(--color-primary)' : 'hsla(220, 15%, 22%, 0.6)',
|
||||||
|
color: '#ffffff',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={20} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: 'var(--text-sm)', color: '#ffffff' }}>
|
||||||
|
{opt.name}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-secondary)', marginTop: '2px' }}>
|
||||||
|
{opt.desc}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '20px',
|
||||||
|
height: '20px',
|
||||||
|
borderRadius: 'var(--radius-full)',
|
||||||
|
border: isSelected ? '6px solid var(--color-primary-light)' : '2px solid var(--color-border-light)',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
transition: 'all var(--transition-fast)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 'var(--space-4)',
|
||||||
|
padding: 'var(--space-3) var(--space-4)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
backgroundColor: 'var(--color-warning-bg)',
|
||||||
|
border: '1px solid hsla(38, 95%, 55%, 0.3)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
fontSize: 'var(--text-xs)',
|
||||||
|
color: 'var(--color-warning)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertTriangle size={15} flexShrink={0} />
|
||||||
|
<span>Menerapkan template baru akan memperbarui checklist barang bawaan saat ini.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TemplateModal;
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className="activity-item-wrapper"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, x: -15 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: 15 }}
|
||||||
|
className="glass"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'stretch',
|
||||||
|
borderRadius: 'var(--radius-xl)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: isDragging ? '1px solid var(--color-primary-light)' : '1px solid var(--color-border)',
|
||||||
|
backgroundColor: isDragging ? 'hsla(222, 28%, 20%, 0.95)' : 'hsla(222, 22%, 15%, 0.8)',
|
||||||
|
boxShadow: isDragging ? 'var(--shadow-lg), var(--shadow-glow-primary)' : 'var(--shadow-sm)',
|
||||||
|
transition: 'border-color var(--transition-fast), background-color var(--transition-fast)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Drag Handle */}
|
||||||
|
<div
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
title="Tarik untuk memindahkan urutan"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '0 var(--space-3)',
|
||||||
|
backgroundColor: 'hsla(222, 28%, 10%, 0.5)',
|
||||||
|
borderRight: '1px solid var(--color-border)',
|
||||||
|
cursor: 'grab',
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
touchAction: 'none',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-primary-light)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--color-text-muted)')}
|
||||||
|
>
|
||||||
|
<GripVertical size={18} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time Pillar */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 'var(--space-4) var(--space-4)',
|
||||||
|
backgroundColor: 'hsla(220, 15%, 15%, 0.4)',
|
||||||
|
borderRight: '1px solid var(--color-border)',
|
||||||
|
minWidth: '85px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--text-base)',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#ffffff',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
letterSpacing: '-0.02em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activity.time || '09:00'}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '2px',
|
||||||
|
marginTop: '2px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Clock size={11} /> {activity.duration || 60}m
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Accent Stripe */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '4px',
|
||||||
|
backgroundColor: catConfig.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main Details */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: 'var(--space-3) var(--space-4)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 'var(--space-1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header Row: Category Badge */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: 'var(--radius-full)',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: 700,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
backgroundColor: catConfig.bgColor,
|
||||||
|
color: catConfig.color,
|
||||||
|
border: `1px solid ${catConfig.color}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CategoryIcon size={11} />
|
||||||
|
{catConfig.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Activity Name */}
|
||||||
|
<h4
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--text-base)',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
lineHeight: 1.3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activity.name}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{/* Location & Notes */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-4)',
|
||||||
|
fontSize: 'var(--text-xs)',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activity.location && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
<MapPin size={12} style={{ color: 'var(--color-accent)' }} />
|
||||||
|
<span>{activity.location}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activity.notes && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', color: 'var(--color-text-muted)' }}>
|
||||||
|
<FileText size={12} />
|
||||||
|
<span style={{ maxWidth: '300px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{activity.notes}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons: Edit & Delete */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '0 var(--space-3)',
|
||||||
|
gap: 'var(--space-1)',
|
||||||
|
borderLeft: '1px solid var(--color-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => 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';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil size={15} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => 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';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ActivityItem;
|
||||||
@@ -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 (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={isEditing ? `Edit Aktivitas — Hari ${dayNumber}` : `Tambah Aktivitas — Hari ${dayNumber}`}
|
||||||
|
maxWidth="md"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
icon={isEditing ? Check : Plus}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
{isEditing ? 'Simpan Perubahan' : 'Tambah ke Jadwal'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||||||
|
{/* Quick template selector (only when adding new) */}
|
||||||
|
{!isEditing && (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: 'var(--text-xs)', color: 'var(--color-accent-light)', marginBottom: 'var(--space-2)', fontWeight: 600 }}>
|
||||||
|
<Sparkles size={13} /> Template Aktivitas Cepat:
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '6px',
|
||||||
|
overflowX: 'auto',
|
||||||
|
paddingBottom: 'var(--space-2)',
|
||||||
|
scrollbarWidth: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{quickActivityTemplates.slice(0, 6).map((tpl, i) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={i}
|
||||||
|
onClick={() => 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] || ''}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Activity Name */}
|
||||||
|
<div className="input-group">
|
||||||
|
<label className="input-label">Nama Aktivitas / Tempat Kunjungan</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input-field"
|
||||||
|
placeholder="e.g. Snorkeling di Manta Point, Makan Siang di Warung Bu Made..."
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time & Duration row */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 'var(--space-3)' }}>
|
||||||
|
<div className="input-group">
|
||||||
|
<label className="input-label" style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
<Clock size={14} style={{ color: 'var(--color-primary-light)' }} /> Jam Mulai
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
className="input-field"
|
||||||
|
value={time}
|
||||||
|
onChange={(e) => setTime(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="input-group">
|
||||||
|
<label className="input-label" style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
<Clock size={14} style={{ color: 'var(--color-accent-light)' }} /> Estimasi Durasi (Menit)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="15"
|
||||||
|
step="15"
|
||||||
|
className="input-field"
|
||||||
|
value={duration}
|
||||||
|
onChange={(e) => setDuration(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Pill Selector */}
|
||||||
|
<div className="input-group">
|
||||||
|
<label className="input-label" style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
<Tag size={14} style={{ color: 'var(--color-text-secondary)' }} /> Kategori
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||||
|
{Object.entries(ACTIVITY_CATEGORIES).map(([key, cat]) => {
|
||||||
|
const isSelected = category === key;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => 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)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: '8px',
|
||||||
|
height: '8px',
|
||||||
|
borderRadius: 'var(--radius-full)',
|
||||||
|
backgroundColor: cat.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{cat.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Location */}
|
||||||
|
<div className="input-group">
|
||||||
|
<label className="input-label" style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
<MapPin size={14} style={{ color: 'var(--color-accent)' }} /> Lokasi / Alamat (Opsional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input-field"
|
||||||
|
placeholder="e.g. Jl. Pantai Kuta No. 10 / Puncak Pulau Padar"
|
||||||
|
value={location}
|
||||||
|
onChange={(e) => setLocation(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<div className="input-group">
|
||||||
|
<label className="input-label" style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
<FileText size={14} style={{ color: 'var(--color-text-secondary)' }} /> Catatan Khusus / Tips (Opsional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
className="textarea-field"
|
||||||
|
rows={2}
|
||||||
|
placeholder="e.g. Pakai pakaian hangat, bawa uang tunai, tiket sudah dipesan..."
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddActivityModal;
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
overflowX: 'auto',
|
||||||
|
paddingBottom: 'var(--space-3)',
|
||||||
|
marginBottom: 'var(--space-6)',
|
||||||
|
scrollbarWidth: 'thin',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{schedule.map((day, index) => {
|
||||||
|
const isSelected = selectedDayIndex === index;
|
||||||
|
const activityCount = day.activities?.length || 0;
|
||||||
|
const formattedDate = day.date ? formatDateShort(day.date) : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
key={index}
|
||||||
|
whileHover={{ y: -2 }}
|
||||||
|
whileTap={{ scale: 0.97 }}
|
||||||
|
onClick={() => 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',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%', marginBottom: '2px' }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: 'var(--text-sm)',
|
||||||
|
color: isSelected ? '#ffffff' : 'var(--color-text-primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Hari {day.dayNumber || index + 1}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 600,
|
||||||
|
padding: '1px 6px',
|
||||||
|
borderRadius: 'var(--radius-full)',
|
||||||
|
backgroundColor: isSelected ? 'hsla(0, 0%, 100%, 0.25)' : 'hsla(220, 15%, 25%, 0.6)',
|
||||||
|
color: isSelected ? '#ffffff' : 'var(--color-text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activityCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--text-xs)',
|
||||||
|
color: isSelected ? 'hsla(0, 0%, 100%, 0.85)' : 'var(--color-text-muted)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formattedDate ? (
|
||||||
|
<span>{formattedDate}</span>
|
||||||
|
) : (
|
||||||
|
<span>Jadwal Harian</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Button to add one more day */}
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ y: -2 }}
|
||||||
|
whileTap={{ scale: 0.97 }}
|
||||||
|
onClick={onAddDay}
|
||||||
|
className="glass"
|
||||||
|
title="Tambah Hari ke Jadwal"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
padding: 'var(--space-3) var(--space-4)',
|
||||||
|
borderRadius: 'var(--radius-xl)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
border: '1px dashed var(--color-border-light)',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
height: '62px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus size={16} style={{ color: 'var(--color-accent)' }} />
|
||||||
|
<span style={{ fontSize: 'var(--text-xs)', fontWeight: 600 }}>Tambah Hari</span>
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DayTabs;
|
||||||
@@ -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 (
|
||||||
|
<div className="schedule-timeline-container">
|
||||||
|
{/* Day Header Info Bar */}
|
||||||
|
<div
|
||||||
|
className="glass"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 'var(--space-3)',
|
||||||
|
padding: 'var(--space-4) var(--space-6)',
|
||||||
|
borderRadius: 'var(--radius-xl)',
|
||||||
|
marginBottom: 'var(--space-6)',
|
||||||
|
borderLeft: '4px solid var(--color-primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<h3 style={{ fontSize: 'var(--text-xl)', color: '#ffffff' }}>
|
||||||
|
Hari ke-{day?.dayNumber || dayIndex + 1}
|
||||||
|
</h3>
|
||||||
|
<span className="badge badge-primary" style={{ fontSize: '11px' }}>
|
||||||
|
{activities.length} Aktivitas
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{day?.date && (
|
||||||
|
<p style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-secondary)', marginTop: '2px' }}>
|
||||||
|
{formatDateIndo(day.date)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
icon={Plus}
|
||||||
|
onClick={onAddActivity}
|
||||||
|
>
|
||||||
|
Tambah Aktivitas
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Activity Timeline List with DnD */}
|
||||||
|
{activities.length > 0 ? (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={activityIds}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 'var(--space-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activities.map((activity) => (
|
||||||
|
<ActivityItem
|
||||||
|
key={activity.id}
|
||||||
|
activity={activity}
|
||||||
|
onEdit={onEditActivity}
|
||||||
|
onDelete={onDeleteActivity}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
icon={Clock}
|
||||||
|
title="Belum Ada Rencana di Hari Ini"
|
||||||
|
description="Tambahkan rencana kunjungan wisata, kuliner, hotel, atau waktu santai untuk menyusun itinerary hari ini."
|
||||||
|
actionText="Tambah Aktivitas Pertama"
|
||||||
|
actionIcon={Plus}
|
||||||
|
onAction={onAddActivity}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ScheduleTimeline;
|
||||||
@@ -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 (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Rancang Trip Kustom Sendiri"
|
||||||
|
maxWidth="md"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="accent"
|
||||||
|
icon={ArrowRight}
|
||||||
|
iconPosition="right"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
Buat & Rencanakan Jadwal
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||||||
|
{/* Destinasi Tujuan */}
|
||||||
|
<div className="input-group">
|
||||||
|
<label className="input-label" style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
<MapPin size={14} style={{ color: 'var(--color-accent)' }} /> Destinasi / Kota Tujuan
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input-field"
|
||||||
|
placeholder="e.g. Karimunjawa, Raja Ampat, Tokyo..."
|
||||||
|
value={destinationName}
|
||||||
|
onChange={(e) => setDestinationName(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nama Trip */}
|
||||||
|
<div className="input-group">
|
||||||
|
<label className="input-label">Judul Rencana Perjalanan</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input-field"
|
||||||
|
placeholder="e.g. Roadtrip Seru Akhir Pekan"
|
||||||
|
value={tripName}
|
||||||
|
onChange={(e) => setTripName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Kategori Perjalanan */}
|
||||||
|
<div className="input-group">
|
||||||
|
<label className="input-label">Kategori / Tipe Trip</label>
|
||||||
|
<select
|
||||||
|
className="select-field"
|
||||||
|
value={category}
|
||||||
|
onChange={(e) => setCategory(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="pantai">Pantai & Kepulauan</option>
|
||||||
|
<option value="gunung">Gunung & Alam Terbuka</option>
|
||||||
|
<option value="kota">Kota & Metropolitan</option>
|
||||||
|
<option value="budaya">Budaya & Sejarah</option>
|
||||||
|
</select>
|
||||||
|
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-muted)' }}>
|
||||||
|
*Kategori ini akan otomatis menyiapkan template packing list yang sesuai.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date Row */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 'var(--space-3)' }}>
|
||||||
|
<div className="input-group">
|
||||||
|
<label className="input-label" style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
<Calendar size={14} style={{ color: 'var(--color-primary-light)' }} /> Tanggal Berangkat
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="input-field"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="input-group">
|
||||||
|
<label className="input-label" style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
<Calendar size={14} style={{ color: 'var(--color-accent-light)' }} /> Tanggal Kembali
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="input-field"
|
||||||
|
value={endDate}
|
||||||
|
min={startDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Total Days */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: 'var(--space-3) var(--space-4)',
|
||||||
|
backgroundColor: 'hsla(38, 95%, 55%, 0.12)',
|
||||||
|
border: '1px solid hsla(38, 95%, 55%, 0.3)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: 'var(--text-sm)', color: 'var(--color-accent-light)' }}>
|
||||||
|
<Clock size={16} />
|
||||||
|
<span>Total Durasi Terhitung:</span>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontWeight: 700, fontSize: 'var(--text-base)', color: '#ffffff' }}>
|
||||||
|
{totalDays} Hari
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Budget */}
|
||||||
|
<div className="input-group">
|
||||||
|
<label className="input-label" style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
<DollarSign size={14} style={{ color: 'var(--color-success)' }} /> Alokasi Target Budget (IDR)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="input-field"
|
||||||
|
placeholder="e.g. 5000000"
|
||||||
|
value={budgetAmount}
|
||||||
|
onChange={(e) => setBudgetAmount(e.target.value)}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-muted)' }}>
|
||||||
|
{budgetAmount ? `Setara: ${formatIDR(budgetAmount)}` : 'Bisa disesuaikan nanti di halaman Budget.'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom Image URL (optional) */}
|
||||||
|
<div className="input-group">
|
||||||
|
<label className="input-label" style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
<ImageIcon size={14} style={{ color: 'var(--color-text-secondary)' }} /> Link URL Foto Sampul (Opsional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
className="input-field"
|
||||||
|
placeholder="https://images.unsplash.com/..."
|
||||||
|
value={customCoverUrl}
|
||||||
|
onChange={(e) => setCustomCoverUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomTripModal;
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 'var(--space-4)',
|
||||||
|
marginBottom: 'var(--space-8)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Top row: Search Bar & Sort Dropdown */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 'var(--space-3)',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Search Bar */}
|
||||||
|
<div
|
||||||
|
className="glass"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-3)',
|
||||||
|
padding: 'var(--space-2) var(--space-4)',
|
||||||
|
flex: '1 1 300px',
|
||||||
|
maxWidth: '500px',
|
||||||
|
borderRadius: 'var(--radius-xl)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Search size={18} style={{ color: 'var(--color-primary-light)' }} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Cari destinasi wisata, kota, atau aktivitas..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
outline: 'none',
|
||||||
|
width: '100%',
|
||||||
|
fontSize: 'var(--text-sm)',
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
onClick={() => onSearchChange('')}
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sort Select */}
|
||||||
|
<div
|
||||||
|
className="glass"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
padding: 'var(--space-2) var(--space-4)',
|
||||||
|
borderRadius: 'var(--radius-xl)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SlidersHorizontal size={16} style={{ color: 'var(--color-accent)' }} />
|
||||||
|
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-secondary)', fontWeight: 600 }}>
|
||||||
|
Urutkan:
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(e) => onSortChange(e.target.value)}
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
outline: 'none',
|
||||||
|
fontSize: 'var(--text-sm)',
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="popular" style={{ background: 'var(--color-bg-elevated)', color: '#fff' }}>
|
||||||
|
Paling Populer
|
||||||
|
</option>
|
||||||
|
<option value="rating" style={{ background: 'var(--color-bg-elevated)', color: '#fff' }}>
|
||||||
|
Rating Tertinggi (★)
|
||||||
|
</option>
|
||||||
|
<option value="budget-low" style={{ background: 'var(--color-bg-elevated)', color: '#fff' }}>
|
||||||
|
Budget Terendah
|
||||||
|
</option>
|
||||||
|
<option value="duration" style={{ background: 'var(--color-bg-elevated)', color: '#fff' }}>
|
||||||
|
Durasi Singkat
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Pills Bar */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
overflowX: 'auto',
|
||||||
|
paddingBottom: 'var(--space-2)',
|
||||||
|
scrollbarWidth: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{categories.map((cat) => {
|
||||||
|
const Icon = cat.icon;
|
||||||
|
const isActive = activeCategory === cat.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={cat.id}
|
||||||
|
onClick={() => 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',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={15} style={{ color: isActive ? '#fff' : 'var(--color-primary-light)' }} />
|
||||||
|
<span>{cat.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FilterBar;
|
||||||
@@ -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 (
|
||||||
|
<section
|
||||||
|
className="bg-gradient-hero"
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
padding: 'var(--space-16) 0 var(--space-12)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderBottom: '1px solid var(--color-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Background glow element */}
|
||||||
|
<div
|
||||||
|
className="animate-pulse-slow"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-10%',
|
||||||
|
right: '10%',
|
||||||
|
width: '400px',
|
||||||
|
height: '400px',
|
||||||
|
borderRadius: 'var(--radius-full)',
|
||||||
|
background: 'radial-gradient(circle, hsla(220, 90%, 56%, 0.15) 0%, transparent 70%)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
filter: 'blur(40px)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="container" style={{ position: 'relative', zIndex: 1 }}>
|
||||||
|
<div style={{ maxWidth: '850px', margin: '0 auto', textAlign: 'center' }}>
|
||||||
|
{/* Top Pill Badge */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -15 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
style={{ display: 'inline-flex', marginBottom: 'var(--space-4)' }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="badge badge-accent"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
padding: '6px 14px',
|
||||||
|
fontSize: 'var(--text-xs)',
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Sparkles size={14} /> RPT • Ready to Plan Tour
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Main Hero Headline with Playfair Display */}
|
||||||
|
<motion.h1
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.1 }}
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--font-hero)',
|
||||||
|
fontSize: 'clamp(2rem, 5vw, 3.75rem)',
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1.15,
|
||||||
|
marginBottom: 'var(--space-5)',
|
||||||
|
letterSpacing: '-0.02em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Rencanakan Perjalanan Wisata <br />
|
||||||
|
<span className="text-gradient">Secara Lengkap & Terstruktur</span>
|
||||||
|
</motion.h1>
|
||||||
|
|
||||||
|
{/* Subtitle */}
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.2 }}
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--text-lg)',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
lineHeight: 1.6,
|
||||||
|
marginBottom: 'var(--space-8)',
|
||||||
|
maxWidth: '680px',
|
||||||
|
margin: '0 auto var(--space-8)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Pilih destinasi populer, susun timeline aktivitas harian per jam, kelola checklist barang bawaan, dan pantau kalkulasi anggaran budget Anda secara real-time.
|
||||||
|
</motion.p>
|
||||||
|
|
||||||
|
{/* CTA Buttons */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.3 }}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 'var(--space-4)',
|
||||||
|
marginBottom: 'var(--space-10)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
icon={Compass}
|
||||||
|
onClick={onExploreClick}
|
||||||
|
style={{ boxShadow: 'var(--shadow-glow-primary)' }}
|
||||||
|
>
|
||||||
|
Jelajahi Destinasi
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="accent"
|
||||||
|
size="lg"
|
||||||
|
icon={Plus}
|
||||||
|
onClick={onCreateCustomClick}
|
||||||
|
>
|
||||||
|
Buat Trip Kustom
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Key Feature Badges Grid */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.4 }}
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||||
|
gap: 'var(--space-4)',
|
||||||
|
borderTop: '1px solid var(--color-border)',
|
||||||
|
paddingTop: 'var(--space-6)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px', color: 'var(--color-text-secondary)', fontSize: 'var(--text-sm)' }}>
|
||||||
|
<MapPin size={18} style={{ color: 'var(--color-primary-light)' }} />
|
||||||
|
<span>10+ Destinasi Pilihan</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px', color: 'var(--color-text-secondary)', fontSize: 'var(--text-sm)' }}>
|
||||||
|
<CalendarCheck size={18} style={{ color: 'var(--color-accent)' }} />
|
||||||
|
<span>Drag & Drop Schedule</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px', color: 'var(--color-text-secondary)', fontSize: 'var(--text-sm)' }}>
|
||||||
|
<ShieldCheck size={18} style={{ color: 'var(--color-success)' }} />
|
||||||
|
<span>100% Offline & Auto-Save</span>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeroSection;
|
||||||
@@ -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 (
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
|
whileHover={{ y: -6 }}
|
||||||
|
transition={{ duration: 0.25 }}
|
||||||
|
className="glass-card"
|
||||||
|
style={{
|
||||||
|
borderRadius: 'var(--radius-xl)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Image Container */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
paddingTop: '60%', // 16:10 aspect ratio
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={image}
|
||||||
|
alt={name}
|
||||||
|
loading="lazy"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
transition: 'transform 0.5s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.transform = 'scale(1.08)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.transform = 'scale(1.0)')}
|
||||||
|
/>
|
||||||
|
{/* Dark overlay gradient */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
background: 'linear-gradient(to top, hsla(222, 28%, 10%, 0.95) 0%, transparent 65%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Top Badges */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 'var(--space-3)',
|
||||||
|
left: 'var(--space-3)',
|
||||||
|
right: 'var(--space-3)',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Badge variant={getCategoryBadgeVariant()}>{category}</Badge>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
backgroundColor: 'hsla(222, 28%, 8%, 0.85)',
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
padding: '3px 8px',
|
||||||
|
borderRadius: 'var(--radius-full)',
|
||||||
|
fontSize: 'var(--text-xs)',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--color-accent-light)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Star size={12} fill="var(--color-accent)" color="var(--color-accent)" />
|
||||||
|
<span>{rating}</span>
|
||||||
|
<span style={{ color: 'var(--color-text-muted)', fontWeight: 400 }}>
|
||||||
|
({reviewCount ? (reviewCount > 1000 ? `${(reviewCount / 1000).toFixed(1)}k` : reviewCount) : 0})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Location & Title over image */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 'var(--space-3)',
|
||||||
|
left: 'var(--space-4)',
|
||||||
|
right: 'var(--space-4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
fontSize: 'var(--text-xs)',
|
||||||
|
marginBottom: '2px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MapPin size={12} style={{ color: 'var(--color-accent)' }} />
|
||||||
|
<span>{location}</span>
|
||||||
|
</div>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--text-lg)',
|
||||||
|
fontFamily: 'var(--font-display)',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#ffffff',
|
||||||
|
lineHeight: 1.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card Body */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: 'var(--space-4)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 'var(--space-4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Description & Highlights */}
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--text-xs)',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
marginBottom: 'var(--space-3)',
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Highlights pills */}
|
||||||
|
{highlights.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px', marginBottom: 'var(--space-2)' }}>
|
||||||
|
{highlights.slice(0, 3).map((hl, idx) => (
|
||||||
|
<span
|
||||||
|
key={idx}
|
||||||
|
style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
padding: '2px 7px',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
backgroundColor: 'hsla(220, 15%, 22%, 0.6)',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hl}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Info & CTA */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderTop: '1px solid var(--color-border)',
|
||||||
|
paddingTop: 'var(--space-3)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '10px', textTransform: 'uppercase', color: 'var(--color-text-muted)', fontWeight: 600 }}>
|
||||||
|
Estimasi Budget / Durasi
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 'var(--text-sm)', fontWeight: 700, color: 'var(--color-accent-light)' }}>
|
||||||
|
{estimatedBudget ? formatIDR(estimatedBudget.min) : '-'}
|
||||||
|
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-secondary)', fontWeight: 400 }}>
|
||||||
|
{' '}• {popularDuration} Hari
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
icon={ArrowRight}
|
||||||
|
iconPosition="right"
|
||||||
|
onClick={() => onSelect(destination)}
|
||||||
|
>
|
||||||
|
Pilih
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TripCard;
|
||||||
@@ -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 (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={`Konfigurasi Perjalanan`}
|
||||||
|
maxWidth="md"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
icon={ArrowRight}
|
||||||
|
iconPosition="right"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
Mulai Buat Jadwal
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||||||
|
{/* Destination preview card */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
borderRadius: 'var(--radius-lg)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
height: '140px',
|
||||||
|
marginBottom: 'var(--space-2)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={destination.image}
|
||||||
|
alt={destination.name}
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
background: 'linear-gradient(to top, hsla(222, 28%, 8%, 0.95), transparent 70%)',
|
||||||
|
padding: 'var(--space-3) var(--space-4)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: 'var(--text-xs)', color: 'var(--color-accent-light)' }}>
|
||||||
|
<MapPin size={12} /> {destination.location}
|
||||||
|
</div>
|
||||||
|
<h4 style={{ fontSize: 'var(--text-lg)', color: '#ffffff' }}>{destination.name}</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input: Trip Name */}
|
||||||
|
<div className="input-group">
|
||||||
|
<label className="input-label">Nama Rencana Perjalanan</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input-field"
|
||||||
|
placeholder="e.g. Liburan Seru Bareng Sahabat"
|
||||||
|
value={tripName}
|
||||||
|
onChange={(e) => setTripName(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date Row */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 'var(--space-3)' }}>
|
||||||
|
<div className="input-group">
|
||||||
|
<label className="input-label" style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
<Calendar size={14} style={{ color: 'var(--color-primary-light)' }} /> Tanggal Mulai
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="input-field"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="input-group">
|
||||||
|
<label className="input-label" style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
<Calendar size={14} style={{ color: 'var(--color-accent-light)' }} /> Tanggal Selesai
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="input-field"
|
||||||
|
value={endDate}
|
||||||
|
min={startDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Duration badge summary */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: 'var(--space-3) var(--space-4)',
|
||||||
|
backgroundColor: 'hsla(220, 90%, 56%, 0.12)',
|
||||||
|
border: '1px solid hsla(220, 90%, 56%, 0.3)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: 'var(--text-sm)', color: 'var(--color-primary-light)' }}>
|
||||||
|
<Clock size={16} />
|
||||||
|
<span>Total Durasi Perjalanan:</span>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontWeight: 700, fontSize: 'var(--text-base)', color: '#ffffff' }}>
|
||||||
|
{totalDays} Hari
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Initial Budget Input */}
|
||||||
|
<div className="input-group">
|
||||||
|
<label className="input-label" style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
<DollarSign size={14} style={{ color: 'var(--color-success)' }} /> Alokasi Target Budget (Opsional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="input-field"
|
||||||
|
placeholder="e.g. 5000000"
|
||||||
|
value={customBudget}
|
||||||
|
onChange={(e) => setCustomBudget(e.target.value)}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-muted)' }}>
|
||||||
|
{customBudget ? `Setara: ${formatIDR(customBudget)}` : `Rekomendasi destinasi ini: ${destination.estimatedBudget ? formatIDR(destination.estimatedBudget.min) : '-'}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TripModal;
|
||||||
@@ -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 (
|
||||||
|
<span
|
||||||
|
className={`badge ${getVariantClass()} ${className}`}
|
||||||
|
style={{
|
||||||
|
fontSize: isSmall ? '10px' : 'var(--text-xs)',
|
||||||
|
padding: isSmall ? '2px 8px' : 'var(--space-1) var(--space-3)',
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{Icon && <Icon size={isSmall ? 11 : 13} />}
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Badge;
|
||||||
@@ -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 (
|
||||||
|
<motion.button
|
||||||
|
type={type}
|
||||||
|
className={`btn ${getVariantClass()} ${getSizeClass()} ${className}`}
|
||||||
|
onClick={isDisabled ? undefined : onClick}
|
||||||
|
disabled={isDisabled}
|
||||||
|
whileHover={isDisabled ? undefined : { scale: 1.02 }}
|
||||||
|
whileTap={isDisabled ? undefined : { scale: 0.98 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
style={{
|
||||||
|
width: fullWidth ? '100%' : 'auto',
|
||||||
|
opacity: isDisabled ? 0.6 : 1,
|
||||||
|
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={size === 'sm' ? 14 : size === 'lg' ? 20 : 16} className="animate-spin" />
|
||||||
|
<span>Memuat...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{Icon && iconPosition === 'left' && <Icon size={size === 'sm' ? 14 : size === 'lg' ? 20 : 16} />}
|
||||||
|
{children && <span>{children}</span>}
|
||||||
|
{Icon && iconPosition === 'right' && <Icon size={size === 'sm' ? 14 : size === 'lg' ? 20 : 16} />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Button;
|
||||||
@@ -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 (
|
||||||
|
<motion.div
|
||||||
|
className={`${glassmorphism ? 'glass-card' : 'glass'} ${className}`}
|
||||||
|
onClick={onClick}
|
||||||
|
whileHover={
|
||||||
|
hoverable || isClickable
|
||||||
|
? {
|
||||||
|
y: -4,
|
||||||
|
boxShadow: glow
|
||||||
|
? 'var(--shadow-lg), var(--shadow-glow-primary)'
|
||||||
|
: 'var(--shadow-lg)',
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
style={{
|
||||||
|
padding: 'var(--space-6)',
|
||||||
|
cursor: isClickable ? 'pointer' : 'default',
|
||||||
|
background: gradient
|
||||||
|
? 'linear-gradient(135deg, hsla(222, 22%, 18%, 0.9), hsla(222, 22%, 12%, 0.95))'
|
||||||
|
: undefined,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Card;
|
||||||
@@ -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 (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 15 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className={`glass flex-center flex-col text-center ${className}`}
|
||||||
|
style={{
|
||||||
|
padding: 'var(--space-12) var(--space-6)',
|
||||||
|
borderRadius: 'var(--radius-xl)',
|
||||||
|
border: '1px dashed var(--color-border-light)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Icon && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '64px',
|
||||||
|
height: '64px',
|
||||||
|
borderRadius: 'var(--radius-full)',
|
||||||
|
backgroundColor: 'var(--color-primary-glow)',
|
||||||
|
color: 'var(--color-primary-light)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: 'var(--space-4)',
|
||||||
|
border: '1px solid hsla(220, 90%, 56%, 0.3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={32} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--text-lg)',
|
||||||
|
fontFamily: 'var(--font-display)',
|
||||||
|
marginBottom: 'var(--space-2)',
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--text-sm)',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
maxWidth: '420px',
|
||||||
|
marginBottom: actionText && onAction ? 'var(--space-6)' : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{actionText && onAction && (
|
||||||
|
<Button variant="primary" icon={actionIcon} onClick={onAction}>
|
||||||
|
{actionText}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EmptyState;
|
||||||
@@ -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 (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 'var(--z-modal-backdrop)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 'var(--space-4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
backgroundColor: 'var(--color-bg-overlay)',
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
WebkitBackdropFilter: 'blur(10px)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal Card */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.92, y: 15 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.92, y: 15 }}
|
||||||
|
transition={{ type: 'spring', damping: 25, stiffness: 350 }}
|
||||||
|
className={`glass-dark ${className}`}
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 'var(--z-modal)',
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: getMaxWidth(),
|
||||||
|
maxHeight: '90vh',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
borderRadius: 'var(--radius-2xl)',
|
||||||
|
border: '1px solid var(--color-border-light)',
|
||||||
|
boxShadow: 'var(--shadow-lg), 0 0 35px hsla(220, 90%, 56%, 0.15)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Modal Header */}
|
||||||
|
{title && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: 'var(--space-5) var(--space-6)',
|
||||||
|
borderBottom: '1px solid var(--color-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--text-xl)',
|
||||||
|
fontFamily: 'var(--font-display)',
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Tutup Modal"
|
||||||
|
style={{
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
backgroundColor: 'hsla(220, 15%, 25%, 0.4)',
|
||||||
|
transition: 'all var(--transition-fast)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
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)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modal Body */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: 'var(--space-6)',
|
||||||
|
overflowY: 'auto',
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Footer */}
|
||||||
|
{footer && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: 'var(--space-4) var(--space-6)',
|
||||||
|
borderTop: '1px solid var(--color-border)',
|
||||||
|
backgroundColor: 'hsla(222, 28%, 8%, 0.7)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
gap: 'var(--space-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Modal;
|
||||||
@@ -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 (
|
||||||
|
<div className={`progress-container ${className}`} style={{ width: '100%', ...style }}>
|
||||||
|
{(label || showPercent) && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 'var(--space-1)',
|
||||||
|
fontSize: 'var(--text-xs)',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label && <span>{label}</span>}
|
||||||
|
{showPercent && <span style={{ color: 'var(--color-text-primary)' }}>{clampedValue}%</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: `${height}px`,
|
||||||
|
backgroundColor: 'hsla(220, 15%, 20%, 0.8)',
|
||||||
|
borderRadius: 'var(--radius-full)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: `${clampedValue}%` }}
|
||||||
|
transition={{ duration: 0.5, ease: 'easeOut' }}
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
background: getGradient(),
|
||||||
|
borderRadius: 'var(--radius-full)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProgressBar;
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 'var(--space-6)',
|
||||||
|
right: 'var(--space-6)',
|
||||||
|
zIndex: 'var(--z-toast)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
maxWidth: '380px',
|
||||||
|
width: 'calc(100% - 32px)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AnimatePresence>
|
||||||
|
{toasts.map((toast) => {
|
||||||
|
const Icon = getToastIcon(toast.type);
|
||||||
|
const styleConfig = getToastStyles(toast.type);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={toast.id}
|
||||||
|
initial={{ opacity: 0, y: 20, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, x: 50, scale: 0.9 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
style={{
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: 'var(--space-3) var(--space-4)',
|
||||||
|
borderRadius: 'var(--radius-xl)',
|
||||||
|
backgroundColor: 'hsla(222, 28%, 10%, 0.95)',
|
||||||
|
backdropFilter: 'blur(16px)',
|
||||||
|
WebkitBackdropFilter: 'blur(16px)',
|
||||||
|
border: styleConfig.border,
|
||||||
|
boxShadow: 'var(--shadow-lg)',
|
||||||
|
gap: 'var(--space-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: styleConfig.color,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={20} />
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 'var(--text-xs)', fontWeight: 600, color: 'var(--color-text-primary)', lineHeight: 1.4 }}>
|
||||||
|
{toast.message}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => 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)')}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ToastContainer;
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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' },
|
||||||
|
];
|
||||||
@@ -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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
+483
@@ -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;
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -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 (
|
||||||
|
<PageWrapper>
|
||||||
|
{/* Top Banner: Trip Info & Currency Selector */}
|
||||||
|
<div
|
||||||
|
className="glass-dark"
|
||||||
|
style={{
|
||||||
|
borderRadius: 'var(--radius-2xl)',
|
||||||
|
padding: 'var(--space-6)',
|
||||||
|
marginBottom: 'var(--space-8)',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 'var(--space-6)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-4)' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '56px',
|
||||||
|
height: '56px',
|
||||||
|
borderRadius: 'var(--radius-xl)',
|
||||||
|
background: 'linear-gradient(135deg, var(--color-accent), var(--color-primary))',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: '#ffffff',
|
||||||
|
boxShadow: 'var(--shadow-glow-accent)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<WalletCards size={28} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: 'var(--text-xs)', color: 'var(--color-accent-light)', fontWeight: 600 }}>
|
||||||
|
<MapPin size={13} /> {trip.destination ? `${trip.destination} (${trip.totalDays || 1} Hari)` : 'Kalkulator Anggaran'}
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: 'clamp(1.25rem, 3vw, 1.75rem)',
|
||||||
|
fontFamily: 'var(--font-display)',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#ffffff',
|
||||||
|
lineHeight: 1.2,
|
||||||
|
marginTop: '2px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Kalkulator & Estimasi Budget
|
||||||
|
</h1>
|
||||||
|
<p style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-secondary)', marginTop: '4px' }}>
|
||||||
|
Pantau arus pengeluaran liburan agar tetap sesuai dengan batas anggaran.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Currency Toggle & Quick Nav Shortcuts */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)', flexWrap: 'wrap' }}>
|
||||||
|
{/* Currency Toggle Selector */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
backgroundColor: 'hsla(220, 15%, 15%, 0.8)',
|
||||||
|
padding: '3px',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{['IDR', 'USD', 'EUR'].map((curr) => (
|
||||||
|
<button
|
||||||
|
key={curr}
|
||||||
|
type="button"
|
||||||
|
onClick={() => 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}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
icon={CalendarDays}
|
||||||
|
onClick={() => navigate('/schedule')}
|
||||||
|
>
|
||||||
|
Jadwal
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
icon={Luggage}
|
||||||
|
onClick={() => navigate('/packing')}
|
||||||
|
>
|
||||||
|
Packing List
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
icon={Plus}
|
||||||
|
onClick={handleOpenAddModal}
|
||||||
|
>
|
||||||
|
Tambah Biaya
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Budget Overview Widget (Cards & Progress) */}
|
||||||
|
<BudgetOverview
|
||||||
|
totalBudget={totalBudget}
|
||||||
|
totalSpent={totalSpent}
|
||||||
|
remainingBudget={remainingBudget}
|
||||||
|
percentUsed={percentUsed}
|
||||||
|
isOverBudget={isOverBudget}
|
||||||
|
currency={currency}
|
||||||
|
onUpdateTotalBudget={handleUpdateTarget}
|
||||||
|
onOpenAddExpense={handleOpenAddModal}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Recharts Visual Charts (Pie & Bar) */}
|
||||||
|
<BudgetChart
|
||||||
|
categoryBreakdown={categoryBreakdown}
|
||||||
|
dayBreakdown={dayBreakdown}
|
||||||
|
currency={currency}
|
||||||
|
totalSpent={totalSpent}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Expense List and Categorized Items */}
|
||||||
|
<ExpenseList
|
||||||
|
expenses={expenses}
|
||||||
|
currency={currency}
|
||||||
|
onEditExpense={handleOpenEditModal}
|
||||||
|
onDeleteExpense={handleDeleteExpense}
|
||||||
|
onOpenAddExpense={handleOpenAddModal}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Add / Edit Expense Modal */}
|
||||||
|
<AddExpenseModal
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
onClose={() => setIsModalOpen(false)}
|
||||||
|
onSave={handleSaveExpense}
|
||||||
|
initialData={editingExpense}
|
||||||
|
totalDays={trip.totalDays || 1}
|
||||||
|
currency={currency}
|
||||||
|
/>
|
||||||
|
</PageWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Budget;
|
||||||
@@ -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 (
|
||||||
|
<div>
|
||||||
|
{/* Hero Section */}
|
||||||
|
<HeroSection
|
||||||
|
onExploreClick={handleExploreScroll}
|
||||||
|
onCreateCustomClick={() => setIsCustomModalOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main Content Area */}
|
||||||
|
<div className="container" ref={exploreSectionRef} style={{ padding: 'var(--space-10) var(--space-4) var(--space-16)' }}>
|
||||||
|
{/* Active Trip Banner if configured */}
|
||||||
|
{trip.id && trip.destination && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="glass"
|
||||||
|
style={{
|
||||||
|
padding: 'var(--space-4) var(--space-6)',
|
||||||
|
borderRadius: 'var(--radius-xl)',
|
||||||
|
marginBottom: 'var(--space-8)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 'var(--space-4)',
|
||||||
|
border: '1px solid hsla(38, 95%, 55%, 0.4)',
|
||||||
|
background: 'linear-gradient(135deg, hsla(222, 22%, 18%, 0.9), hsla(38, 95%, 55%, 0.1))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '42px',
|
||||||
|
height: '42px',
|
||||||
|
borderRadius: 'var(--radius-lg)',
|
||||||
|
backgroundColor: 'var(--color-accent-glow)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'var(--color-accent)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CalendarDays size={22} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-accent)', fontWeight: 700, textTransform: 'uppercase' }}>
|
||||||
|
Trip Sedang Aktif
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 'var(--text-base)', fontWeight: 700, color: '#ffffff' }}>
|
||||||
|
{trip.name || trip.destination} • {trip.totalDays} Hari
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
|
||||||
|
<Button
|
||||||
|
variant="accent"
|
||||||
|
size="sm"
|
||||||
|
icon={ArrowRight}
|
||||||
|
iconPosition="right"
|
||||||
|
onClick={() => navigate('/schedule')}
|
||||||
|
>
|
||||||
|
Buka Jadwal Saya
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Section Header */}
|
||||||
|
<div className="page-header" style={{ marginBottom: 'var(--space-6)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 'var(--space-3)' }}>
|
||||||
|
<div>
|
||||||
|
<h2 className="page-title">
|
||||||
|
<Compass className="text-gradient" size={28} />
|
||||||
|
<span>Pilih Destinasi Wisata</span>
|
||||||
|
</h2>
|
||||||
|
<p className="page-subtitle">
|
||||||
|
Temukan {destinations.length} destinasi unggulan atau rancang rencana perjalanan sendiri.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
icon={Plus}
|
||||||
|
onClick={() => setIsCustomModalOpen(true)}
|
||||||
|
>
|
||||||
|
Buat Trip Kustom
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search & Filter Component */}
|
||||||
|
<FilterBar
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
onSearchChange={setSearchQuery}
|
||||||
|
activeCategory={activeCategory}
|
||||||
|
onCategoryChange={setActiveCategory}
|
||||||
|
sortBy={sortBy}
|
||||||
|
onSortChange={setSortBy}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Destinations Grid */}
|
||||||
|
{filteredDestinations.length > 0 ? (
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
className="grid-4"
|
||||||
|
style={{ gap: 'var(--space-6)' }}
|
||||||
|
>
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
{filteredDestinations.map((destination) => (
|
||||||
|
<TripCard
|
||||||
|
key={destination.id}
|
||||||
|
destination={destination}
|
||||||
|
onSelect={handleSelectDestination}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
icon={Compass}
|
||||||
|
title="Tidak Ada Destinasi yang Cocok"
|
||||||
|
description={`Tidak menemukan destinasi dengan kata kunci "${searchQuery}". Coba ubah kata kunci atau buat trip kustom sendiri.`}
|
||||||
|
actionText="Buat Trip Kustom"
|
||||||
|
actionIcon={Plus}
|
||||||
|
onAction={() => setIsCustomModalOpen(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preset Trip Configuration Modal */}
|
||||||
|
<TripModal
|
||||||
|
isOpen={isPresetModalOpen}
|
||||||
|
onClose={() => setIsPresetModalOpen(false)}
|
||||||
|
destination={selectedDestination}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Custom Trip Modal */}
|
||||||
|
<CustomTripModal
|
||||||
|
isOpen={isCustomModalOpen}
|
||||||
|
onClose={() => setIsCustomModalOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Home;
|
||||||
@@ -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 (
|
||||||
|
<PageWrapper>
|
||||||
|
{/* Top Banner: Trip Info Header */}
|
||||||
|
<div
|
||||||
|
className="glass-dark"
|
||||||
|
style={{
|
||||||
|
borderRadius: 'var(--radius-2xl)',
|
||||||
|
padding: 'var(--space-6)',
|
||||||
|
marginBottom: 'var(--space-8)',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 'var(--space-6)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-4)' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '56px',
|
||||||
|
height: '56px',
|
||||||
|
borderRadius: 'var(--radius-xl)',
|
||||||
|
background: 'linear-gradient(135deg, var(--color-primary), var(--color-accent))',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: '#ffffff',
|
||||||
|
boxShadow: 'var(--shadow-glow-primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Luggage size={28} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: 'var(--text-xs)', color: 'var(--color-accent-light)', fontWeight: 600 }}>
|
||||||
|
<MapPin size={13} /> {trip.destination ? `${trip.destination} (${trip.totalDays || 1} Hari)` : 'Daftar Perlengkapan'}
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: 'clamp(1.25rem, 3vw, 1.75rem)',
|
||||||
|
fontFamily: 'var(--font-display)',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#ffffff',
|
||||||
|
lineHeight: 1.2,
|
||||||
|
marginTop: '2px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Packing List & Bawaan
|
||||||
|
</h1>
|
||||||
|
<p style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-secondary)', marginTop: '4px' }}>
|
||||||
|
Pastikan dokumen penting, pakaian, dan peralatan esensial sudah masuk koper.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Nav Shortcuts */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)', flexWrap: 'wrap' }}>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
icon={CalendarDays}
|
||||||
|
onClick={() => navigate('/schedule')}
|
||||||
|
>
|
||||||
|
Lihat Jadwal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
icon={WalletCards}
|
||||||
|
onClick={() => navigate('/budget')}
|
||||||
|
>
|
||||||
|
Lihat Budget
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="accent"
|
||||||
|
size="sm"
|
||||||
|
icon={Layers}
|
||||||
|
onClick={() => setIsTemplateModalOpen(true)}
|
||||||
|
>
|
||||||
|
Template Bawaan
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Overview Widget */}
|
||||||
|
<PackingOverview
|
||||||
|
totalItems={totalItems}
|
||||||
|
packedItems={packedItems}
|
||||||
|
percentPacked={percentPacked}
|
||||||
|
isComplete={isComplete}
|
||||||
|
onOpenTemplateModal={() => setIsTemplateModalOpen(true)}
|
||||||
|
onResetAll={handleResetChecklist}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Search Filter Bar */}
|
||||||
|
<div
|
||||||
|
className="glass"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-3)',
|
||||||
|
padding: 'var(--space-3) var(--space-4)',
|
||||||
|
borderRadius: 'var(--radius-xl)',
|
||||||
|
marginBottom: 'var(--space-6)',
|
||||||
|
maxWidth: '450px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Search size={18} style={{ color: 'var(--color-primary-light)' }} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input-field"
|
||||||
|
placeholder="Cari barang bawaan (e.g. Paspor, Sunscreen, Charger)..."
|
||||||
|
value={searchFilter}
|
||||||
|
onChange={(e) => setSearchFilter(e.target.value)}
|
||||||
|
style={{ background: 'transparent', border: 'none', padding: 0 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Categories List */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||||||
|
{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 (
|
||||||
|
<PackingCategory
|
||||||
|
key={categoryName}
|
||||||
|
category={categoryName}
|
||||||
|
items={categoryItems}
|
||||||
|
onToggleItem={togglePackingItem}
|
||||||
|
onUpdateQty={handleUpdateQty}
|
||||||
|
onDeleteItem={handleDeleteItem}
|
||||||
|
onAddItem={handleAddItem}
|
||||||
|
onToggleAll={toggleAllInCategory}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Template Selection Modal */}
|
||||||
|
<TemplateModal
|
||||||
|
isOpen={isTemplateModalOpen}
|
||||||
|
onClose={() => setIsTemplateModalOpen(false)}
|
||||||
|
onApplyTemplate={handleApplyTemplate}
|
||||||
|
currentCategory={trip.category}
|
||||||
|
/>
|
||||||
|
</PageWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PackingList;
|
||||||
@@ -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 (
|
||||||
|
<PageWrapper>
|
||||||
|
<div className="page-header text-center" style={{ maxWidth: '600px', margin: '0 auto var(--space-8)' }}>
|
||||||
|
<h1 className="page-title flex-center">
|
||||||
|
<CalendarDays className="text-gradient" size={32} />
|
||||||
|
<span>Jadwal Harian Perjalanan</span>
|
||||||
|
</h1>
|
||||||
|
<p className="page-subtitle" style={{ margin: '0 auto' }}>
|
||||||
|
Anda belum memilih destinasi wisata untuk membuat itinerary.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EmptyState
|
||||||
|
icon={Compass}
|
||||||
|
title="Mulai dengan Memilih Destinasi"
|
||||||
|
description="Pilih salah satu destinasi wisata populer atau buat trip kustom sendiri untuk mulai menyusun jadwal aktivitas harian."
|
||||||
|
actionText="Pilih Destinasi Sekarang"
|
||||||
|
actionIcon={ArrowRight}
|
||||||
|
onAction={() => navigate('/')}
|
||||||
|
/>
|
||||||
|
</PageWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<PageWrapper>
|
||||||
|
{/* Top Banner Header: Trip Info & Summary Stats */}
|
||||||
|
<div
|
||||||
|
className="glass-dark"
|
||||||
|
style={{
|
||||||
|
borderRadius: 'var(--radius-2xl)',
|
||||||
|
padding: 'var(--space-6)',
|
||||||
|
marginBottom: 'var(--space-8)',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 'var(--space-6)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Left Side: Destination Info */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-4)' }}>
|
||||||
|
{trip.coverImage && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '72px',
|
||||||
|
height: '72px',
|
||||||
|
borderRadius: 'var(--radius-xl)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
flexShrink: 0,
|
||||||
|
border: '2px solid var(--color-border-light)',
|
||||||
|
boxShadow: 'var(--shadow-md)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={trip.coverImage}
|
||||||
|
alt={trip.destination}
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: 'var(--text-xs)', color: 'var(--color-accent-light)', fontWeight: 600 }}>
|
||||||
|
<MapPin size={13} /> {trip.destination}
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: 'clamp(1.25rem, 3vw, 1.75rem)',
|
||||||
|
fontFamily: 'var(--font-display)',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#ffffff',
|
||||||
|
lineHeight: 1.2,
|
||||||
|
marginTop: '2px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{trip.name || `Itinerary ${trip.destination}`}
|
||||||
|
</h1>
|
||||||
|
<p style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-secondary)', marginTop: '4px' }}>
|
||||||
|
{trip.totalDays} Hari Total • {totalActivities} Aktivitas Terjadwal
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Side: Quick Action Pills to Packing & Budget */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)', flexWrap: 'wrap' }}>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
icon={Luggage}
|
||||||
|
onClick={() => navigate('/packing')}
|
||||||
|
>
|
||||||
|
Lihat Packing List
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
icon={WalletCards}
|
||||||
|
onClick={() => navigate('/budget')}
|
||||||
|
>
|
||||||
|
Lihat Budget
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
icon={Plus}
|
||||||
|
onClick={handleOpenAddModal}
|
||||||
|
>
|
||||||
|
Tambah Aktivitas
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day Navigation Tabs */}
|
||||||
|
<DayTabs
|
||||||
|
schedule={schedule}
|
||||||
|
selectedDayIndex={selectedDayIndex}
|
||||||
|
onSelectDay={setSelectedDayIndex}
|
||||||
|
onAddDay={handleAddDay}
|
||||||
|
totalDays={trip.totalDays}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Daily Timeline */}
|
||||||
|
<ScheduleTimeline
|
||||||
|
day={currentDay}
|
||||||
|
dayIndex={selectedDayIndex}
|
||||||
|
onAddActivity={handleOpenAddModal}
|
||||||
|
onEditActivity={handleOpenEditModal}
|
||||||
|
onDeleteActivity={handleDeleteActivity}
|
||||||
|
onReorderActivities={reorderActivities}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Add / Edit Activity Modal */}
|
||||||
|
<AddActivityModal
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
onClose={() => setIsModalOpen(false)}
|
||||||
|
onSave={handleSaveActivity}
|
||||||
|
initialData={editingActivity}
|
||||||
|
dayNumber={selectedDayIndex + 1}
|
||||||
|
/>
|
||||||
|
</PageWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Schedule;
|
||||||
@@ -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 }),
|
||||||
|
};
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -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');
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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));
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user