auto winn

This commit is contained in:
Faizulhaq F.N
2026-08-27 19:45:15 +07:00
commit f5a633227f
66 changed files with 11576 additions and 0 deletions
+186
View File
@@ -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';
```
+353
View File
@@ -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
```
+229
View File
@@ -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)
+160
View File
@@ -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;
```
+212
View File
@@ -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
)
}));
```
+268
View File
@@ -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
View File
@@ -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?
+8
View File
@@ -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 }]
}
}
+318
View File
@@ -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 25 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.*
+356
View File
@@ -0,0 +1,356 @@
# 🗺️ Ready to Plan Tour (RPT)
[![React](https://img.shields.io/badge/React-18.x-61DAFB?style=flat&logo=react&logoColor=black)](https://react.dev/)
[![Vite](https://img.shields.io/badge/Vite-5.x-646CFF?style=flat&logo=vite&logoColor=white)](https://vitejs.dev/)
[![Zustand](https://img.shields.io/badge/State-Zustand%20%2B%20Persist-443e38?style=flat)](https://zustand-demo.pmnd.rs/)
[![Framer Motion](https://img.shields.io/badge/Animations-Framer%20Motion-0055FF?style=flat&logo=framer&logoColor=white)](https://www.framer.com/motion/)
[![Recharts](https://img.shields.io/badge/Charts-Recharts-22b5bf?style=flat)](https://recharts.org/)
[![Dnd Kit](https://img.shields.io/badge/Drag%20%26%20Drop-@dnd--kit-FC4A60?style=flat)](https://dndkit.com/)
[![License](https://img.shields.io/badge/License-MIT-green.svg)](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
View File
@@ -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>
+1925
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -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

+24
View File
@@ -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
View File
@@ -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

+1
View File
@@ -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

+180
View File
@@ -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;
+256
View File
@@ -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;
+281
View File
@@ -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;
+330
View File
@@ -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>&bull;</span>
<span>{exp.day ? `Hari ke-${exp.day}` : 'Pengeluaran Umum'}</span>
{exp.notes && (
<>
<span>&bull;</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;
+157
View File
@@ -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'} &bull; {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;
+366
View File
@@ -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 &bull; 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;
+19
View File
@@ -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;
+255
View File
@@ -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;
+181
View File
@@ -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;
+147
View File
@@ -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;
+136
View File
@@ -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;
+299
View File
@@ -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;
+127
View File
@@ -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;
+216
View File
@@ -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;
+172
View File
@@ -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;
+157
View File
@@ -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 &bull; 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;
+249
View File
@@ -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 }}>
{' '}&bull; {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;
+203
View File
@@ -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;
+48
View File
@@ -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;
+83
View File
@@ -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;
+47
View File
@@ -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;
+75
View File
@@ -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;
+180
View File
@@ -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;
+76
View File
@@ -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;
+138
View File
@@ -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;
+122
View File
@@ -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',
},
];
+185
View File
@@ -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' },
];
+168
View File
@@ -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 },
],
},
},
};
+47
View File
@@ -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,
};
};
+42
View File
@@ -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,
};
};
+29
View File
@@ -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,
};
};
+18
View File
@@ -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
View File
@@ -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;
}
+10
View File
@@ -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>,
)
+244
View File
@@ -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;
+220
View File
@@ -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} &bull; {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;
+224
View File
@@ -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;
+225
View File
@@ -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 &bull; {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;
+31
View File
@@ -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 }),
};
+372
View File
@@ -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),
}
)
);
+69
View File
@@ -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');
};
+53
View File
@@ -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;
};
+19
View File
@@ -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));
+81
View File
@@ -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)
+7
View File
@@ -0,0 +1,7 @@
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})