`F+$hY!+Q2Ndsx=U+bjTiAy7djLji>7k%k`$9&--f<*BNA3Hy&ZrHH|4
zG5H&9cB?O#zI1_OOf0Ce%mDfQxdtp3vU%(iY6yji3iISS61XLv#z|!zI_sZqza@B+
zyu9st5-h+`H7QUKx9}3w@oU@EO}&cEzG?fu!!bLO->%zkcg;i9^j`S~=WKMnDi1f=
P00000NkvXXu0mjft=yBf
literal 0
HcmV?d00001
diff --git a/src/assets/react.svg b/src/assets/react.svg
new file mode 100644
index 0000000..6c87de9
--- /dev/null
+++ b/src/assets/react.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/vite.svg b/src/assets/vite.svg
new file mode 100644
index 0000000..5101b67
--- /dev/null
+++ b/src/assets/vite.svg
@@ -0,0 +1 @@
+
diff --git a/src/components/budget/AddExpenseModal.jsx b/src/components/budget/AddExpenseModal.jsx
new file mode 100644
index 0000000..6cc4a9a
--- /dev/null
+++ b/src/components/budget/AddExpenseModal.jsx
@@ -0,0 +1,180 @@
+import React, { useState, useEffect } from 'react';
+import { DollarSign, Tag, Calendar, FileText, Plus, Check } from 'lucide-react';
+import Modal from '../ui/Modal';
+import Button from '../ui/Button';
+import { formatCurrency } from '../../utils/formatCurrency';
+
+const BUDGET_CATEGORY_OPTIONS = [
+ { id: 'transportasi', label: 'Transportasi' },
+ { id: 'akomodasi', label: 'Akomodasi & Hotel' },
+ { id: 'makan', label: 'Kuliner & Makan' },
+ { id: 'aktivitas', label: 'Tiket & Aktivitas' },
+ { id: 'oleholeh', label: 'Oleh-oleh & Belanja' },
+ { id: 'lainnya', label: 'Lain-lain' },
+];
+
+const AddExpenseModal = ({
+ isOpen,
+ onClose,
+ onSave,
+ initialData = null,
+ totalDays = 1,
+ currency = 'IDR',
+}) => {
+ const isEditing = Boolean(initialData?.id);
+
+ const [name, setName] = useState('');
+ const [amount, setAmount] = useState('');
+ const [category, setCategory] = useState('makan');
+ const [day, setDay] = useState(''); // '' means General / Umum
+ const [notes, setNotes] = useState('');
+
+ useEffect(() => {
+ if (initialData) {
+ setName(initialData.name || '');
+ setAmount(initialData.amount ? initialData.amount.toString() : '');
+ setCategory(initialData.category || 'makan');
+ setDay(initialData.day ? initialData.day.toString() : '');
+ setNotes(initialData.notes || '');
+ } else {
+ setName('');
+ setAmount('');
+ setCategory('makan');
+ setDay('');
+ setNotes('');
+ }
+ }, [initialData, isOpen]);
+
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ if (!name.trim() || !amount) return;
+
+ onSave({
+ id: initialData?.id,
+ name: name.trim(),
+ amount: Number(amount) || 0,
+ category,
+ day: day ? Number(day) : null,
+ notes: notes.trim(),
+ });
+
+ onClose();
+ };
+
+ return (
+
+
+
+ >
+ }
+ >
+
+
+ );
+};
+
+export default AddExpenseModal;
diff --git a/src/components/budget/BudgetChart.jsx b/src/components/budget/BudgetChart.jsx
new file mode 100644
index 0000000..dd566f6
--- /dev/null
+++ b/src/components/budget/BudgetChart.jsx
@@ -0,0 +1,256 @@
+import React, { useState } from 'react';
+import {
+ ResponsiveContainer,
+ PieChart,
+ Pie,
+ Cell,
+ Tooltip,
+ BarChart,
+ Bar,
+ XAxis,
+ YAxis,
+} from 'recharts';
+import { PieChart as PieIcon, BarChart3, Tag, Calendar } from 'lucide-react';
+import { formatCurrency } from '../../utils/formatCurrency';
+
+const CATEGORY_COLORS = {
+ transportasi: '#3b82f6',
+ akomodasi: '#a855f7',
+ makan: '#22c55e',
+ aktivitas: '#eab308',
+ oleholeh: '#ec4899',
+ lainnya: '#94a3b8',
+};
+
+const CATEGORY_LABELS = {
+ transportasi: 'Transportasi',
+ akomodasi: 'Akomodasi',
+ makan: 'Kuliner & Makan',
+ aktivitas: 'Tiket & Aktivitas',
+ oleholeh: 'Oleh-oleh',
+ lainnya: 'Lain-lain',
+};
+
+const CustomTooltip = ({ active, payload, currency = 'IDR' }) => {
+ if (active && payload && payload.length) {
+ const data = payload[0];
+ return (
+
+
+ {data.payload.name || data.name}
+
+
+ {formatCurrency(data.value, currency)}
+
+
+ );
+ }
+ return null;
+};
+
+const BudgetChart = ({
+ categoryBreakdown = {},
+ dayBreakdown = {},
+ currency = 'IDR',
+ totalSpent = 0,
+}) => {
+ const [chartView, setChartView] = useState('category'); // 'category' | 'day'
+
+ // Prepare Pie Chart data
+ const pieData = Object.entries(categoryBreakdown)
+ .filter(([_, value]) => value > 0)
+ .map(([key, value]) => ({
+ key,
+ name: CATEGORY_LABELS[key] || key,
+ value: Number(value),
+ color: CATEGORY_COLORS[key] || '#94a3b8',
+ }));
+
+ // Prepare Bar Chart data
+ const barData = Object.entries(dayBreakdown).map(([dayKey, value]) => ({
+ name: dayKey,
+ amount: Number(value),
+ }));
+
+ if (totalSpent === 0) {
+ return null;
+ }
+
+ return (
+
+ {/* Chart Header & Toggle Switch */}
+
+
+
+ {chartView === 'category' ?
:
}
+
+
+
+ Visualisasi Distribusi Pengeluaran
+
+
+ {chartView === 'category' ? 'Berdasarkan pos kategori pengeluaran' : 'Berdasarkan pengeluaran per hari'}
+
+
+
+
+ {/* View Toggle */}
+
+
+
+
+
+
+
+ {/* Chart Canvas */}
+
+ {chartView === 'category' ? (
+
+
+
+ {pieData.map((entry, index) => (
+ |
+ ))}
+
+ } />
+
+
+ ) : (
+
+
+
+ (val >= 1000000 ? `${(val / 1000000).toFixed(1)}M` : `${(val / 1000).toFixed(0)}k`)}
+ />
+ } />
+
+
+
+ )}
+
+
+ {/* Category Legends for Pie */}
+ {chartView === 'category' && (
+
+ {pieData.map((item) => {
+ const pct = Math.round((item.value / totalSpent) * 100);
+ return (
+
+
+ {item.name}
+ ({pct}%)
+
+ );
+ })}
+
+ )}
+
+ );
+};
+
+export default BudgetChart;
diff --git a/src/components/budget/BudgetOverview.jsx b/src/components/budget/BudgetOverview.jsx
new file mode 100644
index 0000000..94ad98e
--- /dev/null
+++ b/src/components/budget/BudgetOverview.jsx
@@ -0,0 +1,281 @@
+import React, { useState } from 'react';
+import { motion } from 'framer-motion';
+import {
+ Wallet,
+ TrendingDown,
+ PiggyBank,
+ AlertTriangle,
+ Pencil,
+ Check,
+ Plus,
+ ArrowUpRight,
+} from 'lucide-react';
+import ProgressBar from '../ui/ProgressBar';
+import Button from '../ui/Button';
+import { formatCurrency } from '../../utils/formatCurrency';
+
+const BudgetOverview = ({
+ totalBudget = 0,
+ totalSpent = 0,
+ remainingBudget = 0,
+ percentUsed = 0,
+ isOverBudget = false,
+ currency = 'IDR',
+ onUpdateTotalBudget,
+ onOpenAddExpense,
+}) => {
+ const [isEditingBudget, setIsEditingBudget] = useState(false);
+ const [budgetInput, setBudgetInput] = useState(totalBudget.toString());
+
+ const handleSaveBudget = (e) => {
+ e.preventDefault();
+ const val = Number(budgetInput) || 0;
+ onUpdateTotalBudget(val);
+ setIsEditingBudget(false);
+ };
+
+ const getRemainingStatus = () => {
+ if (isOverBudget) {
+ return { text: 'Over Budget!', color: 'var(--color-danger)', bg: 'var(--color-danger-bg)' };
+ }
+ if (percentUsed >= 80) {
+ return { text: 'Mendekati Batas', color: 'var(--color-warning)', bg: 'var(--color-warning-bg)' };
+ }
+ return { text: 'Aman & Terkendali', color: 'var(--color-success)', bg: 'var(--color-success-bg)' };
+ };
+
+ const remainingStatus = getRemainingStatus();
+
+ return (
+
+ {/* 3 Overview Stat Cards Grid */}
+
+ {/* Card 1: Total Budget */}
+
+
+
+ Target Total Budget
+
+
+
+
+ {isEditingBudget ? (
+
+ ) : (
+
+ {formatCurrency(totalBudget, currency)}
+
+ )}
+
+
+ Alokasi dana keseluruhan trip
+
+
+
+ {/* Card 2: Total Spent */}
+
+
+
+ Total Terpakai
+
+
+ {percentUsed}% Terpakai
+
+
+
+
+ {formatCurrency(totalSpent, currency)}
+
+
+
+ Pengeluaran yang tercatat saat ini
+
+
+
+ {/* Card 3: Remaining Budget */}
+
+
+
+ Sisa Budget
+
+
+ {remainingStatus.text}
+
+
+
+
+ {formatCurrency(remainingBudget, currency)}
+
+
+
+ {isOverBudget ? 'Melebihi alokasi anggaran!' : 'Sisa saldo yang dapat dibelanjakan'}
+
+
+
+
+ {/* Progress & Alert Bar */}
+
+
+
+ Persentase Anggaran Terpakai
+
+
+
+
+
= 80 ? 'warning' : 'primary'}
+ />
+
+ {/* Warning Banner if Over Budget */}
+ {isOverBudget && (
+
+
+
+ Perhatian: Pengeluaran Anda melebihi target anggaran sebesar{' '}
+ {formatCurrency(Math.abs(remainingBudget), currency)}. Evaluasi kembali pos belanja Anda.
+
+
+ )}
+
+
+ );
+};
+
+export default BudgetOverview;
diff --git a/src/components/budget/ExpenseList.jsx b/src/components/budget/ExpenseList.jsx
new file mode 100644
index 0000000..eb2e1fb
--- /dev/null
+++ b/src/components/budget/ExpenseList.jsx
@@ -0,0 +1,330 @@
+import React, { useState } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import {
+ WalletCards,
+ Pencil,
+ Trash2,
+ Calendar,
+ Tag,
+ FileText,
+ Plus,
+ Plane,
+ Hotel,
+ Utensils,
+ Ticket,
+ ShoppingBag,
+ MoreHorizontal,
+ DollarSign,
+} from 'lucide-react';
+import Button from '../ui/Button';
+import EmptyState from '../ui/EmptyState';
+import { formatCurrency } from '../../utils/formatCurrency';
+
+const CATEGORY_ICONS = {
+ transportasi: Plane,
+ akomodasi: Hotel,
+ makan: Utensils,
+ aktivitas: Ticket,
+ oleholeh: ShoppingBag,
+ lainnya: MoreHorizontal,
+};
+
+const CATEGORY_COLORS = {
+ transportasi: '#3b82f6',
+ akomodasi: '#a855f7',
+ makan: '#22c55e',
+ aktivitas: '#eab308',
+ oleholeh: '#ec4899',
+ lainnya: '#94a3b8',
+};
+
+const CATEGORY_LABELS = {
+ transportasi: 'Transportasi',
+ akomodasi: 'Akomodasi',
+ makan: 'Makan',
+ aktivitas: 'Aktivitas',
+ oleholeh: 'Oleh-oleh',
+ lainnya: 'Lainnya',
+};
+
+const ExpenseList = ({
+ expenses = [],
+ currency = 'IDR',
+ onEditExpense,
+ onDeleteExpense,
+ onOpenAddExpense,
+}) => {
+ const [filterCategory, setFilterCategory] = useState('semua');
+
+ const filteredExpenses = expenses.filter((exp) => {
+ if (filterCategory === 'semua') return true;
+ return exp.category === filterCategory;
+ });
+
+ return (
+
+ {/* Header & Filter Pills */}
+
+
+
+
+
+
+
+ Daftar Rincian Pengeluaran
+
+
+ {expenses.length} transaksi tercatat
+
+
+
+
+
+
+
+ {/* Filter Category Tabs */}
+ {expenses.length > 0 && (
+
+
+
+ {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 (
+
+ );
+ })}
+
+ )}
+
+ {/* Expenses Items List */}
+ {filteredExpenses.length > 0 ? (
+
+
+ {filteredExpenses.map((exp) => {
+ const Icon = CATEGORY_ICONS[exp.category] || MoreHorizontal;
+ const catColor = CATEGORY_COLORS[exp.category] || '#94a3b8';
+ const catLabel = CATEGORY_LABELS[exp.category] || exp.category;
+
+ return (
+
+ {/* Left info: Icon & Name */}
+
+
+
+
+
+
+
+ {exp.name}
+
+
+ {catLabel}
+ •
+ {exp.day ? `Hari ke-${exp.day}` : 'Pengeluaran Umum'}
+ {exp.notes && (
+ <>
+ •
+
+ {exp.notes}
+
+ >
+ )}
+
+
+
+
+ {/* Right info: Amount & Actions */}
+
+
+
+ {formatCurrency(exp.amount, currency)}
+
+
+
+
+
+
+
+
+
+
+ );
+ })}
+
+
+ ) : (
+
+ )}
+
+ );
+};
+
+export default ExpenseList;
diff --git a/src/components/layout/Footer.jsx b/src/components/layout/Footer.jsx
new file mode 100644
index 0000000..79061be
--- /dev/null
+++ b/src/components/layout/Footer.jsx
@@ -0,0 +1,157 @@
+import React from 'react';
+import { Link } from 'react-router-dom';
+import { Compass, Heart, Sparkles, MapPin, ArrowUpRight } from 'lucide-react';
+import { useTourStore } from '../../store/tourStore';
+
+const Footer = () => {
+ const trip = useTourStore((state) => state.trip);
+
+ return (
+
+ );
+};
+
+export default Footer;
diff --git a/src/components/layout/Navbar.jsx b/src/components/layout/Navbar.jsx
new file mode 100644
index 0000000..457d7c8
--- /dev/null
+++ b/src/components/layout/Navbar.jsx
@@ -0,0 +1,366 @@
+import React, { useState } from 'react';
+import { NavLink, Link, useNavigate } from 'react-router-dom';
+import { motion, AnimatePresence } from 'framer-motion';
+import {
+ Compass,
+ CalendarDays,
+ Luggage,
+ WalletCards,
+ MapPin,
+ Menu,
+ X,
+ RotateCcw,
+ AlertTriangle,
+} from 'lucide-react';
+import Modal from '../ui/Modal';
+import Button from '../ui/Button';
+import { useTourStore } from '../../store/tourStore';
+import { usePackingList } from '../../hooks/usePackingList';
+import { useBudget } from '../../hooks/useBudget';
+import { toast } from '../../store/toastStore';
+
+const Navbar = () => {
+ const navigate = useNavigate();
+ const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
+ const [isResetModalOpen, setIsResetModalOpen] = useState(false);
+
+ const trip = useTourStore((state) => state.trip);
+ const resetAll = useTourStore((state) => state.resetAll);
+ const { percentPacked } = usePackingList();
+ const { percentUsed, isOverBudget } = useBudget();
+
+ const navLinks = [
+ { to: '/', label: 'Pilih Trip', icon: Compass, end: true },
+ { to: '/schedule', label: 'Jadwal Harian', icon: CalendarDays },
+ {
+ to: '/packing',
+ label: 'Packing List',
+ icon: Luggage,
+ badge: percentPacked > 0 ? `${percentPacked}%` : null,
+ badgeVariant: percentPacked === 100 ? 'badge-success' : 'badge-primary',
+ },
+ {
+ to: '/budget',
+ label: 'Budget',
+ icon: WalletCards,
+ badge: isOverBudget ? 'Over!' : percentUsed > 0 ? `${percentUsed}%` : null,
+ badgeVariant: isOverBudget ? 'badge-danger' : 'badge-accent',
+ },
+ ];
+
+ const handleConfirmReset = () => {
+ resetAll();
+ setIsResetModalOpen(false);
+ setIsMobileMenuOpen(false);
+ toast.info('Seluruh data rencana perjalanan telah direset.');
+ navigate('/');
+ };
+
+ return (
+ <>
+
+
+ {/* Confirmation Modal for Reset */}
+ setIsResetModalOpen(false)}
+ title="Konfirmasi Reset Rencana Perjalanan"
+ maxWidth="sm"
+ footer={
+ <>
+
+
+ >
+ }
+ >
+
+
+
+ Apakah Anda yakin ingin mereset rencana perjalanan ke {trip.destination}?
+
+
+ Tindakan ini akan menghapus jadwal harian, checklist barang, dan catatan pengeluaran budget yang tersimpan di browser Anda.
+
+
+
+ >
+ );
+};
+
+export default Navbar;
diff --git a/src/components/layout/PageWrapper.jsx b/src/components/layout/PageWrapper.jsx
new file mode 100644
index 0000000..cefc7bb
--- /dev/null
+++ b/src/components/layout/PageWrapper.jsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import { motion } from 'framer-motion';
+
+const PageWrapper = ({ children, className = '', style = {}, fullWidth = false }) => {
+ return (
+
+ {children}
+
+ );
+};
+
+export default PageWrapper;
diff --git a/src/components/packing/PackingCategory.jsx b/src/components/packing/PackingCategory.jsx
new file mode 100644
index 0000000..2c95516
--- /dev/null
+++ b/src/components/packing/PackingCategory.jsx
@@ -0,0 +1,255 @@
+import React, { useState } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import {
+ ChevronDown,
+ ChevronUp,
+ Shirt,
+ FileText,
+ Smartphone,
+ HeartPulse,
+ Sparkles,
+ Package,
+ Plus,
+ CheckSquare,
+ Square,
+} from 'lucide-react';
+import PackingItem from './PackingItem';
+import Button from '../ui/Button';
+
+const getCategoryIcon = (category) => {
+ switch (category) {
+ case 'Pakaian':
+ return Shirt;
+ case 'Dokumen':
+ return FileText;
+ case 'Elektronik':
+ return Smartphone;
+ case 'Obat-obatan':
+ return HeartPulse;
+ case 'Toiletries':
+ return Sparkles;
+ default:
+ return Package;
+ }
+};
+
+const PackingCategory = ({
+ category,
+ items = [],
+ onToggleItem,
+ onUpdateQty,
+ onDeleteItem,
+ onAddItem,
+ onToggleAll,
+}) => {
+ const [isExpanded, setIsExpanded] = useState(true);
+ const [newItemName, setNewItemName] = useState('');
+ const [newItemQty, setNewItemQty] = useState(1);
+
+ const CategoryIcon = getCategoryIcon(category);
+ const totalCount = items.length;
+ const packedCount = items.filter((i) => i.checked).length;
+ const isAllChecked = totalCount > 0 && packedCount === totalCount;
+ const categoryPercent = totalCount > 0 ? Math.round((packedCount / totalCount) * 100) : 0;
+
+ const handleAddNewItem = (e) => {
+ e.preventDefault();
+ if (!newItemName.trim()) return;
+
+ onAddItem(category, {
+ name: newItemName.trim(),
+ qty: Number(newItemQty) || 1,
+ checked: false,
+ });
+
+ setNewItemName('');
+ setNewItemQty(1);
+ };
+
+ return (
+
+ {/* Category Header */}
+
setIsExpanded(!isExpanded)}
+ >
+
+
+
+
+
+
+
+ {category}
+
+
+ {packedCount}/{totalCount} item ({categoryPercent}%)
+
+
+
+
+
+ {/* Right Controls: Check All button & Expand/Collapse Toggle */}
+
e.stopPropagation()}
+ >
+ {totalCount > 0 && (
+
+ )}
+
+
+
+
+
+ {/* Accordion Content */}
+
+ {isExpanded && (
+
+
+ {/* Item List */}
+ {items.length > 0 ? (
+
+ {items.map((item) => (
+
+ ))}
+
+ ) : (
+
+ Belum ada item di kategori ini. Tambahkan di bawah.
+
+ )}
+
+ {/* Inline Add Form */}
+
+
+
+ )}
+
+
+ );
+};
+
+export default PackingCategory;
diff --git a/src/components/packing/PackingItem.jsx b/src/components/packing/PackingItem.jsx
new file mode 100644
index 0000000..f1f6d31
--- /dev/null
+++ b/src/components/packing/PackingItem.jsx
@@ -0,0 +1,181 @@
+import React from 'react';
+import { motion } from 'framer-motion';
+import { Check, Plus, Minus, Trash2 } from 'lucide-react';
+
+const PackingItem = ({
+ item,
+ category,
+ onToggle,
+ onUpdateQty,
+ onDelete,
+}) => {
+ const isChecked = Boolean(item.checked);
+
+ return (
+
+ {/* Checkbox & Item Name */}
+ onToggle(category, item.id)}
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ gap: 'var(--space-3)',
+ cursor: 'pointer',
+ flex: 1,
+ }}
+ >
+ {/* Custom Animated Checkbox */}
+
+ {isChecked && (
+
+
+
+ )}
+
+
+ {/* Item Label */}
+
+ {item.name}
+
+
+
+ {/* Quantity Counter & Delete Button */}
+
+ {/* Quantity Controls */}
+
+
+
+
+ {item.qty || 1}
+
+
+
+
+
+ {/* Delete button */}
+
+
+
+ );
+};
+
+export default PackingItem;
diff --git a/src/components/packing/PackingOverview.jsx b/src/components/packing/PackingOverview.jsx
new file mode 100644
index 0000000..739552d
--- /dev/null
+++ b/src/components/packing/PackingOverview.jsx
@@ -0,0 +1,147 @@
+import React from 'react';
+import { motion } from 'framer-motion';
+import { Luggage, CheckCircle2, AlertCircle, Sparkles, RefreshCw, Layers } from 'lucide-react';
+import ProgressBar from '../ui/ProgressBar';
+import Button from '../ui/Button';
+
+const PackingOverview = ({
+ totalItems = 0,
+ packedItems = 0,
+ percentPacked = 0,
+ isComplete = false,
+ onOpenTemplateModal,
+ onResetAll,
+}) => {
+ const getStatusBadge = () => {
+ if (totalItems === 0) {
+ return { text: 'Daftar Kosong', variant: 'neutral' };
+ }
+ if (isComplete) {
+ return { text: '🎉 Siap Berangkat!', variant: 'success' };
+ }
+ if (percentPacked > 50) {
+ return { text: 'Hampir Selesai Dikemas', variant: 'accent' };
+ }
+ if (percentPacked > 0) {
+ return { text: 'Sedang Dikemas', variant: 'primary' };
+ }
+ return { text: 'Belum Dimulai', variant: 'warning' };
+ };
+
+ const status = getStatusBadge();
+
+ return (
+
+
+
+
+ {isComplete ? : }
+
+
+
+
+ Progress Pengepakan Barang
+
+
+ {status.text}
+
+
+
+ {packedItems} dari {totalItems} barang sudah siap di dalam koper / tas
+
+
+
+
+ {/* Action Buttons */}
+
+
+ {totalItems > 0 && (
+
+ )}
+
+
+
+ {/* Main Progress Bar */}
+
50 ? 'accent' : 'primary'}
+ />
+
+ {/* Celebration Message */}
+ {isComplete && (
+
+
+ Luar biasa! Semua perlengkapan sudah lengkap dipak. Anda siap berangkat liburan! 🏖️🚀
+
+ )}
+
+ );
+};
+
+export default PackingOverview;
diff --git a/src/components/packing/TemplateModal.jsx b/src/components/packing/TemplateModal.jsx
new file mode 100644
index 0000000..3c94be4
--- /dev/null
+++ b/src/components/packing/TemplateModal.jsx
@@ -0,0 +1,136 @@
+import React, { useState } from 'react';
+import { Palmtree, Mountain, Building2, Landmark, Check, AlertTriangle } from 'lucide-react';
+import Modal from '../ui/Modal';
+import Button from '../ui/Button';
+import { packingTemplates } from '../../data/packingTemplates';
+
+const templateOptions = [
+ { id: 'pantai', name: 'Trip Pantai & Kepulauan', icon: Palmtree, desc: 'Baju renang, sunscreen SPF 50+, kacamata hitam, dry bag, dll.' },
+ { id: 'gunung', name: 'Trip Gunung & Alam Sejuk', icon: Mountain, desc: 'Jaket windbreaker, sepatu trekking, tolak angin, heat pack, dll.' },
+ { id: 'kota', name: 'Trip Kota & Metropolitan', icon: Building2, desc: 'Outfit smart casual, sneakers empuk, e-money transit, adaptor, dll.' },
+ { id: 'budaya', name: 'Trip Budaya & Heritage', icon: Landmark, desc: 'Pakaian sopan tertutup, selendang, slip-on shoes, lotion nyamuk, dll.' },
+];
+
+const TemplateModal = ({
+ isOpen,
+ onClose,
+ onApplyTemplate,
+ currentCategory = 'pantai',
+}) => {
+ const [selectedTemplate, setSelectedTemplate] = useState(currentCategory || 'pantai');
+
+ const handleApply = () => {
+ onApplyTemplate(selectedTemplate);
+ onClose();
+ };
+
+ return (
+
+
+
+ >
+ }
+ >
+
+
+ Pilih template bawaan sesuai jenis destinasi Anda untuk memuat daftar checklist barang secara otomatis.
+
+
+
+ {templateOptions.map((opt) => {
+ const Icon = opt.icon;
+ const isSelected = selectedTemplate === opt.id;
+
+ return (
+
setSelectedTemplate(opt.id)}
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ gap: 'var(--space-4)',
+ padding: 'var(--space-4)',
+ borderRadius: 'var(--radius-xl)',
+ backgroundColor: isSelected ? 'var(--color-primary-glow)' : 'hsla(222, 22%, 16%, 0.6)',
+ border: isSelected ? '1px solid var(--color-primary-light)' : '1px solid var(--color-border)',
+ cursor: 'pointer',
+ transition: 'all var(--transition-fast)',
+ }}
+ >
+
+
+
+
+
+
+ {opt.name}
+
+
+ {opt.desc}
+
+
+
+
+
+ );
+ })}
+
+
+
+
+
Menerapkan template baru akan memperbarui checklist barang bawaan saat ini.
+
+
+
+ );
+};
+
+export default TemplateModal;
diff --git a/src/components/schedule/ActivityItem.jsx b/src/components/schedule/ActivityItem.jsx
new file mode 100644
index 0000000..7fa86c1
--- /dev/null
+++ b/src/components/schedule/ActivityItem.jsx
@@ -0,0 +1,299 @@
+import React from 'react';
+import { useSortable } from '@dnd-kit/sortable';
+import { CSS } from '@dnd-kit/utilities';
+import { motion } from 'framer-motion';
+import {
+ GripVertical,
+ Clock,
+ MapPin,
+ FileText,
+ Pencil,
+ Trash2,
+ Compass,
+ Utensils,
+ Plane,
+ Hotel,
+ ShoppingBag,
+ Coffee,
+ MoreHorizontal,
+} from 'lucide-react';
+import Badge from '../ui/Badge';
+import { ACTIVITY_CATEGORIES } from '../../data/activities';
+
+const getCategoryIcon = (category) => {
+ switch (category) {
+ case 'makan':
+ return Utensils;
+ case 'transportasi':
+ return Plane;
+ case 'checkin':
+ return Hotel;
+ case 'belanja':
+ return ShoppingBag;
+ case 'santai':
+ return Coffee;
+ case 'wisata':
+ return Compass;
+ default:
+ return MoreHorizontal;
+ }
+};
+
+const ActivityItem = ({
+ activity,
+ onEdit,
+ onDelete,
+}) => {
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({ id: activity.id });
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ opacity: isDragging ? 0.4 : 1,
+ zIndex: isDragging ? 999 : 1,
+ };
+
+ const catConfig = ACTIVITY_CATEGORIES[activity.category] || ACTIVITY_CATEGORIES.lainnya;
+ const CategoryIcon = getCategoryIcon(activity.category);
+
+ return (
+
+
+ {/* Drag Handle */}
+ (e.currentTarget.style.color = 'var(--color-primary-light)')}
+ onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--color-text-muted)')}
+ >
+
+
+
+ {/* Time Pillar */}
+
+
+ {activity.time || '09:00'}
+
+
+ {activity.duration || 60}m
+
+
+
+ {/* Category Accent Stripe */}
+
+
+ {/* Main Details */}
+
+ {/* Header Row: Category Badge */}
+
+
+
+ {catConfig.label}
+
+
+
+ {/* Activity Name */}
+
+ {activity.name}
+
+
+ {/* Location & Notes */}
+
+ {activity.location && (
+
+
+ {activity.location}
+
+ )}
+ {activity.notes && (
+
+
+
+ {activity.notes}
+
+
+ )}
+
+
+
+ {/* Action Buttons: Edit & Delete */}
+
+
+
+
+
+
+
+ );
+};
+
+export default ActivityItem;
diff --git a/src/components/schedule/AddActivityModal.jsx b/src/components/schedule/AddActivityModal.jsx
new file mode 100644
index 0000000..c574a1c
--- /dev/null
+++ b/src/components/schedule/AddActivityModal.jsx
@@ -0,0 +1,256 @@
+import React, { useState, useEffect } from 'react';
+import { Clock, MapPin, Tag, FileText, Sparkles, Plus, Check } from 'lucide-react';
+import Modal from '../ui/Modal';
+import Button from '../ui/Button';
+import { ACTIVITY_CATEGORIES, quickActivityTemplates } from '../../data/activities';
+
+const AddActivityModal = ({
+ isOpen,
+ onClose,
+ onSave,
+ initialData = null,
+ dayNumber = 1,
+}) => {
+ const isEditing = Boolean(initialData?.id);
+
+ const [name, setName] = useState('');
+ const [time, setTime] = useState('09:00');
+ const [duration, setDuration] = useState(60);
+ const [location, setLocation] = useState('');
+ const [category, setCategory] = useState('wisata');
+ const [notes, setNotes] = useState('');
+
+ useEffect(() => {
+ if (initialData) {
+ setName(initialData.name || '');
+ setTime(initialData.time || '09:00');
+ setDuration(initialData.duration || 60);
+ setLocation(initialData.location || '');
+ setCategory(initialData.category || 'wisata');
+ setNotes(initialData.notes || '');
+ } else {
+ setName('');
+ setTime('09:00');
+ setDuration(60);
+ setLocation('');
+ setCategory('wisata');
+ setNotes('');
+ }
+ }, [initialData, isOpen]);
+
+ const handleApplyTemplate = (template) => {
+ setName(template.name);
+ setCategory(template.category);
+ setTime(template.time || '09:00');
+ setDuration(template.duration || 60);
+ setLocation(template.location || '');
+ setNotes(template.notes || '');
+ };
+
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ if (!name.trim()) return;
+
+ onSave({
+ id: initialData?.id,
+ name: name.trim(),
+ time,
+ duration: Number(duration) || 60,
+ location: location.trim(),
+ category,
+ notes: notes.trim(),
+ });
+
+ onClose();
+ };
+
+ return (
+
+
+
+ >
+ }
+ >
+
+
+ );
+};
+
+export default AddActivityModal;
diff --git a/src/components/schedule/DayTabs.jsx b/src/components/schedule/DayTabs.jsx
new file mode 100644
index 0000000..e145391
--- /dev/null
+++ b/src/components/schedule/DayTabs.jsx
@@ -0,0 +1,127 @@
+import React from 'react';
+import { motion } from 'framer-motion';
+import { Calendar, Plus, Sparkles } from 'lucide-react';
+import { formatDateShort } from '../../utils/dateHelpers';
+
+const DayTabs = ({
+ schedule = [],
+ selectedDayIndex = 0,
+ onSelectDay,
+ onAddDay,
+ totalDays = 0,
+}) => {
+ return (
+
+ {schedule.map((day, index) => {
+ const isSelected = selectedDayIndex === index;
+ const activityCount = day.activities?.length || 0;
+ const formattedDate = day.date ? formatDateShort(day.date) : '';
+
+ return (
+
onSelectDay(index)}
+ className="glass"
+ style={{
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'flex-start',
+ padding: 'var(--space-3) var(--space-5)',
+ borderRadius: 'var(--radius-xl)',
+ minWidth: '130px',
+ cursor: 'pointer',
+ transition: 'all var(--transition-fast)',
+ backgroundColor: isSelected
+ ? 'var(--color-primary)'
+ : 'hsla(222, 22%, 16%, 0.7)',
+ border: isSelected
+ ? '1px solid var(--color-primary-light)'
+ : '1px solid var(--color-border)',
+ boxShadow: isSelected ? '0 4px 16px var(--color-primary-glow)' : 'none',
+ textAlign: 'left',
+ }}
+ >
+
+
+ Hari {day.dayNumber || index + 1}
+
+
+ {activityCount}
+
+
+
+
+ {formattedDate ? (
+ {formattedDate}
+ ) : (
+ Jadwal Harian
+ )}
+
+
+ );
+ })}
+
+ {/* Button to add one more day */}
+
+
+ Tambah Hari
+
+
+ );
+};
+
+export default DayTabs;
diff --git a/src/components/schedule/ScheduleTimeline.jsx b/src/components/schedule/ScheduleTimeline.jsx
new file mode 100644
index 0000000..9c10ce9
--- /dev/null
+++ b/src/components/schedule/ScheduleTimeline.jsx
@@ -0,0 +1,145 @@
+import React from 'react';
+import {
+ DndContext,
+ closestCenter,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+} from '@dnd-kit/core';
+import {
+ SortableContext,
+ sortableKeyboardCoordinates,
+ verticalListSortingStrategy,
+ arrayMove,
+} from '@dnd-kit/sortable';
+import { Plus, Calendar, Clock, Sparkles } from 'lucide-react';
+import ActivityItem from './ActivityItem';
+import Button from '../ui/Button';
+import EmptyState from '../ui/EmptyState';
+import { formatDateIndo } from '../../utils/dateHelpers';
+
+const ScheduleTimeline = ({
+ day,
+ dayIndex = 0,
+ onAddActivity,
+ onEditActivity,
+ onDeleteActivity,
+ onReorderActivities,
+}) => {
+ const activities = day?.activities || [];
+ const activityIds = activities.map((a) => a.id);
+
+ // Configure pointer sensor with activation distance to avoid accidental drags when clicking buttons
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: {
+ distance: 5,
+ },
+ }),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: sortableKeyboardCoordinates,
+ })
+ );
+
+ const handleDragEnd = (event) => {
+ const { active, over } = event;
+
+ if (over && active.id !== over.id) {
+ const oldIndex = activities.findIndex((item) => item.id === active.id);
+ const newIndex = activities.findIndex((item) => item.id === over.id);
+
+ if (oldIndex !== -1 && newIndex !== -1) {
+ const reordered = arrayMove(activities, oldIndex, newIndex);
+ onReorderActivities(dayIndex, reordered);
+ }
+ }
+ };
+
+ return (
+
+ {/* Day Header Info Bar */}
+
+
+
+
+ Hari ke-{day?.dayNumber || dayIndex + 1}
+
+
+ {activities.length} Aktivitas
+
+
+ {day?.date && (
+
+ {formatDateIndo(day.date)}
+
+ )}
+
+
+
+
+
+ {/* Activity Timeline List with DnD */}
+ {activities.length > 0 ? (
+
+
+
+ {activities.map((activity) => (
+
+ ))}
+
+
+
+ ) : (
+
+ )}
+
+ );
+};
+
+export default ScheduleTimeline;
diff --git a/src/components/trip/CustomTripModal.jsx b/src/components/trip/CustomTripModal.jsx
new file mode 100644
index 0000000..bc451cf
--- /dev/null
+++ b/src/components/trip/CustomTripModal.jsx
@@ -0,0 +1,216 @@
+import React, { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { PlusCircle, Calendar, MapPin, DollarSign, Clock, ArrowRight, Image as ImageIcon } from 'lucide-react';
+import Modal from '../ui/Modal';
+import Button from '../ui/Button';
+import { useTourStore } from '../../store/tourStore';
+import { calculateTotalDays, getTodayString, getFutureDateString } from '../../utils/dateHelpers';
+import { generateId } from '../../utils/helpers';
+import { formatIDR } from '../../utils/formatCurrency';
+import { toast } from '../../store/toastStore';
+
+const DEFAULT_COVER_IMAGES = {
+ pantai: 'https://images.unsplash.com/photo-1507525428034-b723cf961d3e?auto=format&fit=crop&w=1000&q=80',
+ gunung: 'https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?auto=format&fit=crop&w=1000&q=80',
+ kota: 'https://images.unsplash.com/photo-1477959858617-67f30bc75b82?auto=format&fit=crop&w=1000&q=80',
+ budaya: 'https://images.unsplash.com/photo-1590073242678-70ee3fc28e8e?auto=format&fit=crop&w=1000&q=80',
+};
+
+const CustomTripModal = ({ isOpen, onClose }) => {
+ const navigate = useNavigate();
+ const setTrip = useTourStore((state) => state.setTrip);
+ const setTotalBudget = useTourStore((state) => state.setTotalBudget);
+
+ const [destinationName, setDestinationName] = useState('');
+ const [tripName, setTripName] = useState('');
+ const [category, setCategory] = useState('pantai');
+ const [startDate, setStartDate] = useState(getTodayString());
+ const [endDate, setEndDate] = useState(getFutureDateString(3));
+ const [customCoverUrl, setCustomCoverUrl] = useState('');
+ const [budgetAmount, setBudgetAmount] = useState('3000000');
+
+ const totalDays = calculateTotalDays(startDate, endDate);
+
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ if (!destinationName.trim()) return;
+
+ const coverImage = customCoverUrl.trim() || DEFAULT_COVER_IMAGES[category] || DEFAULT_COVER_IMAGES.pantai;
+
+ setTrip({
+ id: generateId(),
+ name: tripName.trim() || `Rencana Perjalanan ke ${destinationName.trim()}`,
+ destination: destinationName.trim(),
+ location: destinationName.trim(),
+ startDate,
+ endDate,
+ totalDays,
+ coverImage,
+ type: 'custom',
+ category,
+ });
+
+ if (budgetAmount) {
+ setTotalBudget(Number(budgetAmount));
+ }
+
+ toast.success(`Trip custom ke ${destinationName.trim()} berhasil dibuat!`);
+ onClose();
+ navigate('/schedule');
+ };
+
+ return (
+
+
+
+ >
+ }
+ >
+
+
+ );
+};
+
+export default CustomTripModal;
diff --git a/src/components/trip/FilterBar.jsx b/src/components/trip/FilterBar.jsx
new file mode 100644
index 0000000..d7296d3
--- /dev/null
+++ b/src/components/trip/FilterBar.jsx
@@ -0,0 +1,172 @@
+import React from 'react';
+import { Search, Compass, Palmtree, Mountain, Landmark, Building2, SlidersHorizontal, X } from 'lucide-react';
+
+const categories = [
+ { id: 'semua', label: 'Semua Destinasi', icon: Compass },
+ { id: 'pantai', label: 'Pantai & Laut', icon: Palmtree },
+ { id: 'gunung', label: 'Gunung & Alam', icon: Mountain },
+ { id: 'budaya', label: 'Budaya & Heritage', icon: Landmark },
+ { id: 'kota', label: 'Kota Metropolitan', icon: Building2 },
+];
+
+const FilterBar = ({
+ searchQuery,
+ onSearchChange,
+ activeCategory,
+ onCategoryChange,
+ sortBy,
+ onSortChange,
+}) => {
+ return (
+
+ {/* Top row: Search Bar & Sort Dropdown */}
+
+ {/* Search Bar */}
+
+
+ onSearchChange(e.target.value)}
+ style={{
+ background: 'transparent',
+ border: 'none',
+ outline: 'none',
+ width: '100%',
+ fontSize: 'var(--text-sm)',
+ color: 'var(--color-text-primary)',
+ }}
+ />
+ {searchQuery && (
+
+ )}
+
+
+ {/* Sort Select */}
+
+
+
+ Urutkan:
+
+
+
+
+
+ {/* Category Pills Bar */}
+
+ {categories.map((cat) => {
+ const Icon = cat.icon;
+ const isActive = activeCategory === cat.id;
+
+ return (
+
+ );
+ })}
+
+
+ );
+};
+
+export default FilterBar;
diff --git a/src/components/trip/HeroSection.jsx b/src/components/trip/HeroSection.jsx
new file mode 100644
index 0000000..001c212
--- /dev/null
+++ b/src/components/trip/HeroSection.jsx
@@ -0,0 +1,157 @@
+import React from 'react';
+import { motion } from 'framer-motion';
+import { Compass, Sparkles, MapPin, Plus, CalendarCheck, ShieldCheck, ArrowDown } from 'lucide-react';
+import Button from '../ui/Button';
+
+const HeroSection = ({ onExploreClick, onCreateCustomClick }) => {
+ return (
+
+ {/* Background glow element */}
+
+
+
+
+ {/* Top Pill Badge */}
+
+
+ RPT • Ready to Plan Tour
+
+
+
+ {/* Main Hero Headline with Playfair Display */}
+
+ Rencanakan Perjalanan Wisata
+ Secara Lengkap & Terstruktur
+
+
+ {/* Subtitle */}
+
+ Pilih destinasi populer, susun timeline aktivitas harian per jam, kelola checklist barang bawaan, dan pantau kalkulasi anggaran budget Anda secara real-time.
+
+
+ {/* CTA Buttons */}
+
+
+
+
+
+ {/* Key Feature Badges Grid */}
+
+
+
+ 10+ Destinasi Pilihan
+
+
+
+ Drag & Drop Schedule
+
+
+
+ 100% Offline & Auto-Save
+
+
+
+
+
+ );
+};
+
+export default HeroSection;
diff --git a/src/components/trip/TripCard.jsx b/src/components/trip/TripCard.jsx
new file mode 100644
index 0000000..1401da9
--- /dev/null
+++ b/src/components/trip/TripCard.jsx
@@ -0,0 +1,249 @@
+import React from 'react';
+import { motion } from 'framer-motion';
+import { MapPin, Star, Calendar, ArrowRight, Sparkles } from 'lucide-react';
+import Badge from '../ui/Badge';
+import Button from '../ui/Button';
+import { formatIDR } from '../../utils/formatCurrency';
+
+const TripCard = ({ destination, onSelect }) => {
+ const {
+ name,
+ location,
+ category,
+ description,
+ highlights = [],
+ estimatedBudget,
+ popularDuration,
+ rating,
+ reviewCount,
+ image,
+ tags = [],
+ } = destination;
+
+ const getCategoryBadgeVariant = () => {
+ switch (category) {
+ case 'pantai':
+ return 'primary';
+ case 'gunung':
+ case 'alam':
+ return 'success';
+ case 'budaya':
+ return 'accent';
+ case 'kota':
+ return 'warning';
+ default:
+ return 'neutral';
+ }
+ };
+
+ return (
+
+ {/* Image Container */}
+
+

(e.currentTarget.style.transform = 'scale(1.08)')}
+ onMouseLeave={(e) => (e.currentTarget.style.transform = 'scale(1.0)')}
+ />
+ {/* Dark overlay gradient */}
+
+
+ {/* Top Badges */}
+
+
{category}
+
+
+ {rating}
+
+ ({reviewCount ? (reviewCount > 1000 ? `${(reviewCount / 1000).toFixed(1)}k` : reviewCount) : 0})
+
+
+
+
+ {/* Location & Title over image */}
+
+
+
+ {location}
+
+
+ {name}
+
+
+
+
+ {/* Card Body */}
+
+ {/* Description & Highlights */}
+
+
+ {description}
+
+
+ {/* Highlights pills */}
+ {highlights.length > 0 && (
+
+ {highlights.slice(0, 3).map((hl, idx) => (
+
+ {hl}
+
+ ))}
+
+ )}
+
+
+ {/* Footer Info & CTA */}
+
+
+
+ Estimasi Budget / Durasi
+
+
+ {estimatedBudget ? formatIDR(estimatedBudget.min) : '-'}
+
+ {' '}• {popularDuration} Hari
+
+
+
+
+
+
+
+
+ );
+};
+
+export default TripCard;
diff --git a/src/components/trip/TripModal.jsx b/src/components/trip/TripModal.jsx
new file mode 100644
index 0000000..4833276
--- /dev/null
+++ b/src/components/trip/TripModal.jsx
@@ -0,0 +1,203 @@
+import React, { useState, useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { Calendar, MapPin, Sparkles, DollarSign, Clock, ArrowRight } from 'lucide-react';
+import Modal from '../ui/Modal';
+import Button from '../ui/Button';
+import { useTourStore } from '../../store/tourStore';
+import { calculateTotalDays, getTodayString, getFutureDateString } from '../../utils/dateHelpers';
+import { formatIDR } from '../../utils/formatCurrency';
+import { toast } from '../../store/toastStore';
+
+const TripModal = ({ isOpen, onClose, destination }) => {
+ const navigate = useNavigate();
+ const setTrip = useTourStore((state) => state.setTrip);
+ const setTotalBudget = useTourStore((state) => state.setTotalBudget);
+
+ const [tripName, setTripName] = useState('');
+ const [startDate, setStartDate] = useState(getTodayString());
+ const [endDate, setEndDate] = useState(getFutureDateString(destination?.popularDuration ? destination.popularDuration - 1 : 3));
+ const [customBudget, setCustomBudget] = useState('');
+
+ useEffect(() => {
+ if (destination) {
+ setTripName(`Liburan Impian ke ${destination.name}`);
+ const duration = destination.popularDuration || 3;
+ const today = getTodayString();
+ setStartDate(today);
+ setEndDate(getFutureDateString(duration - 1));
+ setCustomBudget(destination.estimatedBudget?.min || 3000000);
+ }
+ }, [destination]);
+
+ if (!destination) return null;
+
+ const totalDays = calculateTotalDays(startDate, endDate);
+
+ const handleSubmit = (e) => {
+ e.preventDefault();
+
+ setTrip({
+ id: destination.id,
+ name: tripName.trim() || `Trip ke ${destination.name}`,
+ destination: destination.name,
+ location: destination.location,
+ startDate,
+ endDate,
+ totalDays,
+ coverImage: destination.image,
+ type: 'preset',
+ category: destination.category,
+ });
+
+ if (customBudget) {
+ setTotalBudget(Number(customBudget));
+ }
+
+ toast.success(`Trip ke ${destination.name} berhasil dibuat!`);
+ onClose();
+ navigate('/schedule');
+ };
+
+ return (
+
+
+
+ >
+ }
+ >
+
+
+ );
+};
+
+export default TripModal;
diff --git a/src/components/ui/Badge.jsx b/src/components/ui/Badge.jsx
new file mode 100644
index 0000000..5288330
--- /dev/null
+++ b/src/components/ui/Badge.jsx
@@ -0,0 +1,48 @@
+import React from 'react';
+
+const Badge = ({
+ children,
+ variant = 'primary',
+ icon: Icon,
+ size = 'md',
+ className = '',
+ style = {},
+ ...props
+}) => {
+ const getVariantClass = () => {
+ switch (variant) {
+ case 'accent':
+ return 'badge-accent';
+ case 'success':
+ return 'badge-success';
+ case 'warning':
+ return 'badge-warning';
+ case 'danger':
+ return 'badge-danger';
+ case 'neutral':
+ return 'badge-neutral';
+ case 'primary':
+ default:
+ return 'badge-primary';
+ }
+ };
+
+ const isSmall = size === 'sm';
+
+ return (
+
+ {Icon && }
+ {children}
+
+ );
+};
+
+export default Badge;
diff --git a/src/components/ui/Button.jsx b/src/components/ui/Button.jsx
new file mode 100644
index 0000000..a77b163
--- /dev/null
+++ b/src/components/ui/Button.jsx
@@ -0,0 +1,83 @@
+import React from 'react';
+import { motion } from 'framer-motion';
+import { Loader2 } from 'lucide-react';
+
+const Button = ({
+ children,
+ variant = 'primary',
+ size = 'md',
+ icon: Icon,
+ iconPosition = 'left',
+ loading = false,
+ disabled = false,
+ onClick,
+ type = 'button',
+ fullWidth = false,
+ className = '',
+ style = {},
+ ...props
+}) => {
+ const getVariantClass = () => {
+ switch (variant) {
+ case 'accent':
+ return 'btn-accent';
+ case 'secondary':
+ return 'btn-secondary';
+ case 'ghost':
+ return 'btn-ghost';
+ case 'danger':
+ return 'btn-danger';
+ case 'primary':
+ default:
+ return 'btn-primary';
+ }
+ };
+
+ const getSizeClass = () => {
+ switch (size) {
+ case 'sm':
+ return 'btn-sm';
+ case 'lg':
+ return 'btn-lg';
+ case 'md':
+ default:
+ return '';
+ }
+ };
+
+ const isDisabled = disabled || loading;
+
+ return (
+
+ {loading ? (
+ <>
+
+ Memuat...
+ >
+ ) : (
+ <>
+ {Icon && iconPosition === 'left' && }
+ {children && {children}}
+ {Icon && iconPosition === 'right' && }
+ >
+ )}
+
+ );
+};
+
+export default Button;
diff --git a/src/components/ui/Card.jsx b/src/components/ui/Card.jsx
new file mode 100644
index 0000000..acce1bf
--- /dev/null
+++ b/src/components/ui/Card.jsx
@@ -0,0 +1,47 @@
+import React from 'react';
+import { motion } from 'framer-motion';
+
+const Card = ({
+ children,
+ glassmorphism = true,
+ hoverable = false,
+ onClick,
+ gradient = false,
+ glow = false,
+ className = '',
+ style = {},
+ ...props
+}) => {
+ const isClickable = Boolean(onClick);
+
+ return (
+
+ {children}
+
+ );
+};
+
+export default Card;
diff --git a/src/components/ui/EmptyState.jsx b/src/components/ui/EmptyState.jsx
new file mode 100644
index 0000000..9027a1a
--- /dev/null
+++ b/src/components/ui/EmptyState.jsx
@@ -0,0 +1,75 @@
+import React from 'react';
+import { motion } from 'framer-motion';
+import Button from './Button';
+
+const EmptyState = ({
+ icon: Icon,
+ title = 'Belum Ada Data',
+ description = 'Tambahkan item baru untuk mulai mengisi bagian ini.',
+ actionText = '',
+ onAction = null,
+ actionIcon = null,
+ className = '',
+}) => {
+ return (
+
+ {Icon && (
+
+
+
+ )}
+
+
+ {title}
+
+
+
+ {description}
+
+
+ {actionText && onAction && (
+
+ )}
+
+ );
+};
+
+export default EmptyState;
diff --git a/src/components/ui/Modal.jsx b/src/components/ui/Modal.jsx
new file mode 100644
index 0000000..fd45f8d
--- /dev/null
+++ b/src/components/ui/Modal.jsx
@@ -0,0 +1,180 @@
+import React, { useEffect } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { X } from 'lucide-react';
+
+const Modal = ({
+ isOpen = false,
+ onClose,
+ title = '',
+ children,
+ footer = null,
+ maxWidth = 'md', // sm (420px), md (540px), lg (680px), xl (860px)
+ className = '',
+}) => {
+ // Close on Escape key
+ useEffect(() => {
+ const handleKeyDown = (e) => {
+ if (e.key === 'Escape' && isOpen && onClose) {
+ onClose();
+ }
+ };
+
+ if (isOpen) {
+ document.addEventListener('keydown', handleKeyDown);
+ document.body.style.overflow = 'hidden';
+ }
+
+ return () => {
+ document.removeEventListener('keydown', handleKeyDown);
+ document.body.style.overflow = 'unset';
+ };
+ }, [isOpen, onClose]);
+
+ const getMaxWidth = () => {
+ switch (maxWidth) {
+ case 'sm':
+ return '420px';
+ case 'lg':
+ return '680px';
+ case 'xl':
+ return '860px';
+ case 'md':
+ default:
+ return '540px';
+ }
+ };
+
+ return (
+
+ {isOpen && (
+
+ {/* Backdrop */}
+
+
+ {/* Modal Card */}
+
+ {/* Modal Header */}
+ {title && (
+
+
+ {title}
+
+
+
+ )}
+
+ {/* Modal Body */}
+
+ {children}
+
+
+ {/* Modal Footer */}
+ {footer && (
+
+ {footer}
+
+ )}
+
+
+ )}
+
+ );
+};
+
+export default Modal;
diff --git a/src/components/ui/ProgressBar.jsx b/src/components/ui/ProgressBar.jsx
new file mode 100644
index 0000000..ff03a55
--- /dev/null
+++ b/src/components/ui/ProgressBar.jsx
@@ -0,0 +1,76 @@
+import React from 'react';
+import { motion } from 'framer-motion';
+
+const ProgressBar = ({
+ value = 0,
+ label = '',
+ showPercent = true,
+ variant = 'primary',
+ color = '',
+ height = 8,
+ className = '',
+ style = {},
+}) => {
+ const clampedValue = Math.min(100, Math.max(0, Math.round(value)));
+
+ const getGradient = () => {
+ if (color) return color;
+ switch (variant) {
+ case 'accent':
+ return 'linear-gradient(90deg, var(--color-accent), var(--color-accent-light))';
+ case 'success':
+ return 'linear-gradient(90deg, var(--color-success), hsl(142, 70%, 55%))';
+ case 'warning':
+ return 'linear-gradient(90deg, var(--color-warning), hsl(38, 95%, 65%))';
+ case 'danger':
+ return 'linear-gradient(90deg, var(--color-danger), hsl(0, 75%, 65%))';
+ case 'primary':
+ default:
+ return 'linear-gradient(90deg, var(--color-primary), var(--color-primary-light))';
+ }
+ };
+
+ return (
+
+ {(label || showPercent) && (
+
+ {label && {label}}
+ {showPercent && {clampedValue}%}
+
+ )}
+
+
+
+
+ );
+};
+
+export default ProgressBar;
diff --git a/src/components/ui/ToastContainer.jsx b/src/components/ui/ToastContainer.jsx
new file mode 100644
index 0000000..40670a6
--- /dev/null
+++ b/src/components/ui/ToastContainer.jsx
@@ -0,0 +1,138 @@
+import React from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { CheckCircle2, Info, AlertTriangle, XCircle, X } from 'lucide-react';
+import { useToastStore } from '../../store/toastStore';
+
+const getToastIcon = (type) => {
+ switch (type) {
+ case 'info':
+ return Info;
+ case 'warning':
+ return AlertTriangle;
+ case 'danger':
+ return XCircle;
+ case 'success':
+ default:
+ return CheckCircle2;
+ }
+};
+
+const getToastStyles = (type) => {
+ switch (type) {
+ case 'info':
+ return {
+ border: '1px solid hsla(199, 89%, 48%, 0.4)',
+ color: 'var(--color-info)',
+ bg: 'hsla(199, 89%, 48%, 0.15)',
+ };
+ case 'warning':
+ return {
+ border: '1px solid hsla(38, 95%, 55%, 0.4)',
+ color: 'var(--color-warning)',
+ bg: 'hsla(38, 95%, 55%, 0.15)',
+ };
+ case 'danger':
+ return {
+ border: '1px solid hsla(0, 75%, 55%, 0.4)',
+ color: 'var(--color-danger)',
+ bg: 'hsla(0, 75%, 55%, 0.15)',
+ };
+ case 'success':
+ default:
+ return {
+ border: '1px solid hsla(142, 70%, 45%, 0.4)',
+ color: 'var(--color-success)',
+ bg: 'hsla(142, 70%, 45%, 0.15)',
+ };
+ }
+};
+
+const ToastContainer = () => {
+ const toasts = useToastStore((state) => state.toasts);
+ const removeToast = useToastStore((state) => state.removeToast);
+
+ return (
+
+
+ {toasts.map((toast) => {
+ const Icon = getToastIcon(toast.type);
+ const styleConfig = getToastStyles(toast.type);
+
+ return (
+
+
+
+
+
+
+ {toast.message}
+
+
+
+
+
+ );
+ })}
+
+
+ );
+};
+
+export default ToastContainer;
diff --git a/src/data/activities.js b/src/data/activities.js
new file mode 100644
index 0000000..ab5ed58
--- /dev/null
+++ b/src/data/activities.js
@@ -0,0 +1,122 @@
+/**
+ * Activities Dataset & Presets
+ */
+
+export const ACTIVITY_CATEGORIES = {
+ wisata: {
+ id: 'wisata',
+ label: 'Wisata & Atraksi',
+ color: 'hsl(220, 90%, 56%)',
+ bgColor: 'hsla(220, 90%, 56%, 0.15)',
+ icon: 'Compass',
+ },
+ makan: {
+ id: 'makan',
+ label: 'Kuliner & Makan',
+ color: 'hsl(142, 70%, 45%)',
+ bgColor: 'hsla(142, 70%, 45%, 0.15)',
+ icon: 'Utensils',
+ },
+ transportasi: {
+ id: 'transportasi',
+ label: 'Transportasi & Perjalanan',
+ color: 'hsl(280, 70%, 60%)',
+ bgColor: 'hsla(280, 70%, 60%, 0.15)',
+ icon: 'Plane',
+ },
+ checkin: {
+ id: 'checkin',
+ label: 'Hotel & Check-in',
+ color: 'hsl(38, 95%, 55%)',
+ bgColor: 'hsla(38, 95%, 55%, 0.15)',
+ icon: 'Hotel',
+ },
+ belanja: {
+ id: 'belanja',
+ label: 'Belanja & Oleh-oleh',
+ color: 'hsl(340, 75%, 58%)',
+ bgColor: 'hsla(340, 75%, 58%, 0.15)',
+ icon: 'ShoppingBag',
+ },
+ santai: {
+ id: 'santai',
+ label: 'Istirahat & Santai',
+ color: 'hsl(190, 80%, 50%)',
+ bgColor: 'hsla(190, 80%, 50%, 0.15)',
+ icon: 'Coffee',
+ },
+ lainnya: {
+ id: 'lainnya',
+ label: 'Lain-lain',
+ color: 'hsl(210, 20%, 55%)',
+ bgColor: 'hsla(210, 20%, 55%, 0.15)',
+ icon: 'MoreHorizontal',
+ },
+};
+
+export const quickActivityTemplates = [
+ {
+ name: 'Sarapan Pagi di Hotel / Kafe Lokal',
+ category: 'makan',
+ duration: 60,
+ time: '08:00',
+ location: 'Area Penginapan',
+ notes: 'Nikmati sarapan khas lokal',
+ },
+ {
+ name: 'Check-in Penginapan / Hotel',
+ category: 'checkin',
+ duration: 30,
+ time: '14:00',
+ location: 'Hotel',
+ notes: 'Simpan koper dan istirahat sejenak',
+ },
+ {
+ name: 'Kunjungan Destinasi Utama & Foto',
+ category: 'wisata',
+ duration: 120,
+ time: '09:30',
+ location: 'Spot Wisata Populer',
+ notes: 'Siapkan kamera dan outfit terbaik',
+ },
+ {
+ name: 'Makan Siang Kuliner Khas Daerah',
+ category: 'makan',
+ duration: 75,
+ time: '12:30',
+ location: 'Restoran / Warung Rekomendasi',
+ notes: 'Cicipi menu spesial daerah',
+ },
+ {
+ name: 'Menikmati Sunset & Santai Sore',
+ category: 'santai',
+ duration: 90,
+ time: '17:00',
+ location: 'Viewpoint / Pantai / Rooftop',
+ notes: 'Waktu terbaik untuk golden hour',
+ },
+ {
+ name: 'Makan Malam & Night Market Tour',
+ category: 'makan',
+ duration: 90,
+ time: '19:00',
+ location: 'Pasar Malam / Pusat Kuliner',
+ notes: 'Cari street food lokal',
+ },
+ {
+ name: 'Belanja Souvenir & Oleh-oleh Khas',
+ category: 'belanja',
+ duration: 60,
+ time: '16:00',
+ location: 'Pusat Oleh-oleh',
+ notes: 'Camilan dan cinderamata untuk keluarga',
+ },
+ {
+ name: 'Perjalanan Bandara / Stasiun',
+ category: 'transportasi',
+ duration: 120,
+ time: '06:00',
+ location: 'Bandara / Stasiun Kereta',
+ notes: 'Tiba minimal 2 jam sebelum keberangkatan',
+ },
+];
diff --git a/src/data/destinations.js b/src/data/destinations.js
new file mode 100644
index 0000000..830f7e4
--- /dev/null
+++ b/src/data/destinations.js
@@ -0,0 +1,185 @@
+/**
+ * Destinations Dataset
+ * Comprehensive popular destinations in Indonesia & International
+ */
+
+export const destinations = [
+ {
+ id: 'dest-bali-01',
+ name: 'Bali (Island of Gods)',
+ location: 'Bali, Indonesia',
+ country: 'Indonesia',
+ category: 'pantai',
+ description: 'Surga tropis dengan perpaduan magis pantai eksotis, sawah berundak Ubud, pura bersejarah, dan budaya yang memikat.',
+ highlights: ['Pura Tanah Lot', 'Tegalalang Rice Terrace', 'Pantai Nusa Dua', 'Uluwatu Sunset Dance'],
+ estimatedBudget: { min: 2500000, max: 7000000 },
+ popularDuration: 4,
+ rating: 4.9,
+ reviewCount: 18450,
+ image: 'https://images.unsplash.com/photo-1537996194471-e657df975ab4?auto=format&fit=crop&w=1000&q=80',
+ tags: ['Pantai', 'Budaya', 'Surfing', 'Kuliner', 'Romantis'],
+ bestTime: 'April - Oktober',
+ suggestedPackingType: 'pantai',
+ },
+ {
+ id: 'dest-labuanbajo-02',
+ name: 'Labuan Bajo & Komodo',
+ location: 'Nusa Tenggara Timur, Indonesia',
+ country: 'Indonesia',
+ category: 'alam',
+ description: 'Gerbang menuju Taman Nasional Komodo dengan panorama Pulau Padar, Pink Beach, dan pengalaman sailing kapal pinisi yang tak terlupakan.',
+ highlights: ['Puncak Pulau Padar', 'Taman Nasional Komodo', 'Pink Beach', 'Manta Point Snorkeling'],
+ estimatedBudget: { min: 4500000, max: 12000000 },
+ popularDuration: 3,
+ rating: 4.9,
+ reviewCount: 9240,
+ image: 'https://images.unsplash.com/photo-1518548419970-58e3b4079ab2?auto=format&fit=crop&w=1000&q=80',
+ tags: ['Sailing', 'Diving', 'Komodo', 'Eksotis', 'Petualangan'],
+ bestTime: 'Mei - September',
+ suggestedPackingType: 'pantai',
+ },
+ {
+ id: 'dest-jogja-03',
+ name: 'Yogyakarta Istimewa',
+ location: 'D.I. Yogyakarta, Indonesia',
+ country: 'Indonesia',
+ category: 'budaya',
+ description: 'Pusat kebudayaan Jawa dengan kemegahan Candi Borobudur & Prambanan, kuliner gudeg legendaris, dan suasana Malioboro yang hangat.',
+ highlights: ['Candi Borobudur', 'Keraton Yogyakarta', 'Candi Prambanan', 'Jalan Malioboro'],
+ estimatedBudget: { min: 1200000, max: 4000000 },
+ popularDuration: 3,
+ rating: 4.8,
+ reviewCount: 22100,
+ image: 'https://images.unsplash.com/photo-1584810359583-96fc3448beaa?auto=format&fit=crop&w=1000&q=80',
+ tags: ['Candi', 'Kuliner', 'Heritage', 'Seni & Batik'],
+ bestTime: 'Sepanjang Tahun',
+ suggestedPackingType: 'budaya',
+ },
+ {
+ id: 'dest-bromo-04',
+ name: 'Gunung Bromo & Ijen',
+ location: 'Jawa Timur, Indonesia',
+ country: 'Indonesia',
+ category: 'gunung',
+ description: 'Sensasi sunrise magis di atas lautan pasir Bromo, kawah aktif bergemuruh, dan keajaiban api biru (Blue Fire) di Kawah Ijen.',
+ highlights: ['Penanjakan Sunrise Viewpoint', 'Kawah Bromo & Pasir Berbisik', 'Bukit Teletubbies', 'Blue Fire Kawah Ijen'],
+ estimatedBudget: { min: 1800000, max: 4500000 },
+ popularDuration: 3,
+ rating: 4.8,
+ reviewCount: 14320,
+ image: 'https://images.unsplash.com/photo-1588668214407-6ea9a6d8c272?auto=format&fit=crop&w=1000&q=80',
+ tags: ['Trekking', 'Sunrise', 'Dingin', 'Fotografi'],
+ bestTime: 'Juli - Oktober',
+ suggestedPackingType: 'gunung',
+ },
+ {
+ id: 'dest-rajaampat-05',
+ name: 'Raja Ampat (The Last Paradise)',
+ location: 'Papua Barat Daya, Indonesia',
+ country: 'Indonesia',
+ category: 'pantai',
+ description: 'Mahakarya alam bawah laut terbaik dunia dengan gugusan pulau karang karst ikonik di Pianemo dan Wayag.',
+ highlights: ['Pianemo Geosite', 'Wayag Lagoon', 'Pasir Timbul', 'Manta Sandy Diving'],
+ estimatedBudget: { min: 9000000, max: 22000000 },
+ popularDuration: 5,
+ rating: 5.0,
+ reviewCount: 6150,
+ image: 'https://images.unsplash.com/photo-1516690561799-46d8f74f9abf?auto=format&fit=crop&w=1000&q=80',
+ tags: ['Diving', 'Karst Islands', 'Eksklusif', 'Alam Liar'],
+ bestTime: 'Oktober - April',
+ suggestedPackingType: 'pantai',
+ },
+ {
+ id: 'dest-tokyo-06',
+ name: 'Tokyo Metropolis',
+ location: 'Tokyo, Jepang',
+ country: 'Jepang',
+ category: 'kota',
+ description: 'Peleburan futuristik gedung pencakar langit Shibuya & Shinjuku dengan kuil kuno Asakusa dan surga kuliner ramen dunia.',
+ highlights: ['Shibuya Crossing & Sky', 'Senso-ji Temple Asakusa', 'Akihabara Tech & Anime', 'TeamLab Planets'],
+ estimatedBudget: { min: 12000000, max: 30000000 },
+ popularDuration: 6,
+ rating: 4.9,
+ reviewCount: 31000,
+ image: 'https://images.unsplash.com/photo-1503899036084-c55cdd92da26?auto=format&fit=crop&w=1000&q=80',
+ tags: ['Metropolitan', 'Belanja', 'Anime', 'Kuliner Jepang'],
+ bestTime: 'Maret - Mei & Okt - Nov',
+ suggestedPackingType: 'kota',
+ },
+ {
+ id: 'dest-bandung-07',
+ name: 'Bandung Paris van Java',
+ location: 'Jawa Barat, Indonesia',
+ country: 'Indonesia',
+ category: 'kota',
+ description: 'Kota sejuk dengan pesona wisata kawah Lembang, kafe-kafe estetik Dago, belanja factory outlet, dan kuliner khas Sunda.',
+ highlights: ['Kawah Putih Ciwidey', 'Tangkuban Perahu', 'Floating Market Lembang', 'Jalan Braga & Asia Afrika'],
+ estimatedBudget: { min: 800000, max: 2800000 },
+ popularDuration: 2,
+ rating: 4.7,
+ reviewCount: 19800,
+ image: 'https://images.unsplash.com/photo-1601058268499-e52658b8bb88?auto=format&fit=crop&w=1000&q=80',
+ tags: ['Sejuk', 'Kafe Estetik', 'Kuliner', 'Family Friendly'],
+ bestTime: 'Sepanjang Tahun',
+ suggestedPackingType: 'kota',
+ },
+ {
+ id: 'dest-lombok-08',
+ name: 'Lombok & Gili Islands',
+ location: 'Nusa Tenggara Barat, Indonesia',
+ country: 'Indonesia',
+ category: 'pantai',
+ description: 'Keindahan pantai Kuta Mandalika, trekking Gunung Rinjani, dan ketenangan pulau tanpa kendaraan bermotor di Gili Trawangan.',
+ highlights: ['Gili Trawangan & Meno', 'Sirkuit Mandalika & Tanjung Aan', 'Bukit Merese', 'Desa Adat Sade'],
+ estimatedBudget: { min: 2200000, max: 6500000 },
+ popularDuration: 4,
+ rating: 4.8,
+ reviewCount: 11200,
+ image: 'https://images.unsplash.com/photo-1570789210967-2cac24afeb00?auto=format&fit=crop&w=1000&q=80',
+ tags: ['Pantai', 'Snorkeling', 'Santai', 'Budaya Sasak'],
+ bestTime: 'Mei - September',
+ suggestedPackingType: 'pantai',
+ },
+ {
+ id: 'dest-seoul-09',
+ name: 'Seoul Urban & K-Culture',
+ location: 'Seoul, Korea Selatan',
+ country: 'Korea Selatan',
+ category: 'kota',
+ description: 'Jelajahi istana megah Gyeongbokgung dengan Hanbok, street food Myeongdong, kafe viral Seongsu-dong, dan gemerlap K-Wave.',
+ highlights: ['Gyeongbokgung Palace', 'Myeongdong Night Market', 'N Seoul Tower', 'Bukchon Hanok Village'],
+ estimatedBudget: { min: 10000000, max: 25000000 },
+ popularDuration: 5,
+ rating: 4.9,
+ reviewCount: 24500,
+ image: 'https://images.unsplash.com/photo-1538485399081-7191377e8241?auto=format&fit=crop&w=1000&q=80',
+ tags: ['K-Pop', 'Street Food', 'Shopping', 'Hanbok'],
+ bestTime: 'April - Mei & Sep - Nov',
+ suggestedPackingType: 'kota',
+ },
+ {
+ id: 'dest-swiss-10',
+ name: 'Swiss Alps & Interlaken',
+ location: 'Bernese Oberland, Swiss',
+ country: 'Swiss',
+ category: 'gunung',
+ description: 'Pemandangan puncak Alpen yang dramatis, desa dongeng Lauterbrunnen, kereta gantung ke Jungfraujoch, dan danau biru kristal.',
+ highlights: ['Jungfraujoch Top of Europe', 'Lauterbrunnen Valley', 'Lake Brienz Cruise', 'Grindelwald First Cliff Walk'],
+ estimatedBudget: { min: 25000000, max: 60000000 },
+ popularDuration: 6,
+ rating: 5.0,
+ reviewCount: 8900,
+ image: 'https://images.unsplash.com/photo-1530122037265-a5f1f91d3b99?auto=format&fit=crop&w=1000&q=80',
+ tags: ['Alpen', 'Pemandangan Spektakuler', 'Kereta Wisata', 'Salju'],
+ bestTime: 'Juni - September / Des - Feb',
+ suggestedPackingType: 'gunung',
+ },
+];
+
+export const destinationCategories = [
+ { id: 'semua', label: 'Semua Destinasi', icon: 'Compass' },
+ { id: 'pantai', label: 'Pantai & Kepulauan', icon: 'Palmtree' },
+ { id: 'gunung', label: 'Gunung & Alam', icon: 'Mountain' },
+ { id: 'budaya', label: 'Budaya & Sejarah', icon: 'Landmark' },
+ { id: 'kota', label: 'Kota & Metropolitan', icon: 'Building2' },
+];
diff --git a/src/data/packingTemplates.js b/src/data/packingTemplates.js
new file mode 100644
index 0000000..c198099
--- /dev/null
+++ b/src/data/packingTemplates.js
@@ -0,0 +1,168 @@
+/**
+ * Packing List Templates based on Trip Category
+ */
+
+export const PACKING_CATEGORIES = [
+ 'Pakaian',
+ 'Dokumen',
+ 'Elektronik',
+ 'Obat-obatan',
+ 'Toiletries',
+ 'Lainnya',
+];
+
+export const packingTemplates = {
+ pantai: {
+ name: 'Trip Pantai & Kepulauan',
+ items: {
+ Pakaian: [
+ { name: 'Baju santai / Kaos katun ringan', qty: 4, checked: false },
+ { name: 'Celana pendek / Beach shorts', qty: 3, checked: false },
+ { name: 'Baju renang / Swimwear', qty: 2, checked: false },
+ { name: 'Topi pantai / Bucket hat', qty: 1, checked: false },
+ { name: 'Kacamata hitam (UV Protection)', qty: 1, checked: false },
+ { name: 'Sandal jepit / Water shoes', qty: 1, checked: false },
+ ],
+ Dokumen: [
+ { name: 'KTP / Paspor & Visa', qty: 1, checked: false },
+ { name: 'Tiket pesawat & booking hotel (Digital/Print)', qty: 1, checked: false },
+ { name: 'Uang tunai secukupnya & kartu debit/kredit', qty: 1, checked: false },
+ ],
+ Elektronik: [
+ { name: 'Smartphone & Charger', qty: 1, checked: false },
+ { name: 'Powerbank 10.000+ mAh', qty: 1, checked: false },
+ { name: 'Waterproof pouch untuk HP', qty: 1, checked: false },
+ { name: 'Action camera / Kamera & baterai cadangan', qty: 1, checked: false },
+ ],
+ 'Obat-obatan': [
+ { name: 'Obat mabuk laut / perjalanan', qty: 1, checked: false },
+ { name: 'Obat flu, batuk, & pereda nyeri', qty: 1, checked: false },
+ { name: 'Minyak kayu putih / Roll-on aromaterapi', qty: 1, checked: false },
+ { name: 'Plester luka & antiseptik', qty: 1, checked: false },
+ ],
+ Toiletries: [
+ { name: 'Sunscreen SPF 50+ (Reef-safe)', qty: 1, checked: false },
+ { name: 'After-sun aloe vera gel', qty: 1, checked: false },
+ { name: 'Sabun, sampo & sikat gigi travel size', qty: 1, checked: false },
+ { name: 'Handuk microfiber cepat kering', qty: 1, checked: false },
+ ],
+ Lainnya: [
+ { name: 'Dry bag (10L / 20L)', qty: 1, checked: false },
+ { name: 'Botol minum tumbler (Eco-friendly)', qty: 1, checked: false },
+ { name: 'Kantong plastik / laundry bag untuk baju basah', qty: 2, checked: false },
+ ],
+ },
+ },
+ gunung: {
+ name: 'Trip Gunung & Alam Sejuk',
+ items: {
+ Pakaian: [
+ { name: 'Jaket windbreaker / Down jacket tebal', qty: 1, checked: false },
+ { name: 'Baju termal / Long john', qty: 2, checked: false },
+ { name: 'Celana trekking / kargo elastis', qty: 2, checked: false },
+ { name: 'Sarung tangan wol & Kupluk / Beanie', qty: 1, checked: false },
+ { name: 'Kaus kaki tebal cadangan', qty: 3, checked: false },
+ { name: 'Sepatu trekking / Sepatu olahraga anti-slip', qty: 1, checked: false },
+ ],
+ Dokumen: [
+ { name: 'KTP / Surat izin masuk kawasan konservasi', qty: 1, checked: false },
+ { name: 'Surat keterangan sehat (jika pendakian)', qty: 1, checked: false },
+ { name: 'Uang tunai (ATM jarang di gunung)', qty: 1, checked: false },
+ ],
+ Elektronik: [
+ { name: 'Smartphone & kabel charger tahan dingin', qty: 1, checked: false },
+ { name: 'Powerbank kapasitas besar', qty: 1, checked: false },
+ { name: 'Headlamp / Senter & baterai cadangan', qty: 1, checked: false },
+ ],
+ 'Obat-obatan': [
+ { name: 'Obat masuk angin / Tolak angin', qty: 5, checked: false },
+ { name: 'Balsem otot / Salep pegal', qty: 1, checked: false },
+ { name: 'Obat asma / Obat pribadi khusus', qty: 1, checked: false },
+ { name: 'Penghangat tubuh instant (Heat pack)', qty: 4, checked: false },
+ ],
+ Toiletries: [
+ { name: 'Lip balm pelembab bibir', qty: 1, checked: false },
+ { name: 'Moisturizer / Hand cream anti-kering', qty: 1, checked: false },
+ { name: 'Tisu basah & tisu kering travel pack', qty: 2, checked: false },
+ { name: 'Sikat gigi & pasta gigi travel', qty: 1, checked: false },
+ ],
+ Lainnya: [
+ { name: 'Jas hujan ponco / Raincoat ringan', qty: 1, checked: false },
+ { name: 'Thermos air panas mini', qty: 1, checked: false },
+ { name: 'Camilan berenergi (Cokelat/Energi bar)', qty: 3, checked: false },
+ ],
+ },
+ },
+ kota: {
+ name: 'Trip Kota & Metropolitan',
+ items: {
+ Pakaian: [
+ { name: 'Pakaian smart casual / OOTD estetik', qty: 4, checked: false },
+ { name: 'Jaket ringan / Cardigan', qty: 1, checked: false },
+ { name: 'Celana jeans / Chino nyaman', qty: 2, checked: false },
+ { name: 'Sepatu sneakers empuk untuk banyak jalan kaki', qty: 1, checked: false },
+ ],
+ Dokumen: [
+ { name: 'KTP / Paspor & Boarding pass', qty: 1, checked: false },
+ { name: 'Kartu e-Money / Transit card (MRT/Bus)', qty: 1, checked: false },
+ { name: 'Kartu kredit / E-wallet terisi saldo', qty: 1, checked: false },
+ ],
+ Elektronik: [
+ { name: 'Smartphone & Charger cepat', qty: 1, checked: false },
+ { name: 'Powerbank compact & ringan', qty: 1, checked: false },
+ { name: 'TWS Earbuds / Headphone', qty: 1, checked: false },
+ { name: 'Universal travel adapter plug', qty: 1, checked: false },
+ ],
+ 'Obat-obatan': [
+ { name: 'Obat maag & pencernaan', qty: 1, checked: false },
+ { name: 'Obat sakit kepala & pereda demam', qty: 1, checked: false },
+ { name: 'Vitamin C booster harian', qty: 1, checked: false },
+ ],
+ Toiletries: [
+ { name: 'Skincare travel size (Facial wash, serum, sunblock)', qty: 1, checked: false },
+ { name: 'Parfum / Cologne mini decant', qty: 1, checked: false },
+ { name: 'Deodorant & sisir rambut', qty: 1, checked: false },
+ ],
+ Lainnya: [
+ { name: 'Tote bag lipat / Shopping bag untuk belanja', qty: 1, checked: false },
+ { name: 'Payung lipat kecil uv-block', qty: 1, checked: false },
+ { name: 'Hand sanitizer pocket size', qty: 1, checked: false },
+ ],
+ },
+ },
+ budaya: {
+ name: 'Trip Budaya & Heritage',
+ items: {
+ Pakaian: [
+ { name: 'Pakaian sopan tertutup untuk masuk tempat ibadah/candi', qty: 3, checked: false },
+ { name: 'Kain selendang / Sarung batik', qty: 1, checked: false },
+ { name: 'Kaus katun adem yang menyerap keringat', qty: 3, checked: false },
+ { name: 'Topi pelindung matahari & kacamata', qty: 1, checked: false },
+ { name: 'Sepatu slip-on yang mudah dilepas pasang', qty: 1, checked: false },
+ ],
+ Dokumen: [
+ { name: 'KTP / Identitas diri', qty: 1, checked: false },
+ { name: 'Tiket masuk situs budaya / booking tour guide', qty: 1, checked: false },
+ { name: 'Uang pecahan kecil untuk donasi / suvenir', qty: 1, checked: false },
+ ],
+ Elektronik: [
+ { name: 'Smartphone untuk audio guide & foto', qty: 1, checked: false },
+ { name: 'Powerbank', qty: 1, checked: false },
+ ],
+ 'Obat-obatan': [
+ { name: 'Obat pereda nyeri sendi & pegal', qty: 1, checked: false },
+ { name: 'Koyo pereda pegal', qty: 1, checked: false },
+ { name: 'Minyak angin & obat diare', qty: 1, checked: false },
+ ],
+ Toiletries: [
+ { name: 'Sunscreen & lotion anti-nyamuk', qty: 1, checked: false },
+ { name: 'Tisu basah antibakteri', qty: 1, checked: false },
+ { name: 'Perlengkapan mandi dasar', qty: 1, checked: false },
+ ],
+ Lainnya: [
+ { name: 'Kipas portable mini', qty: 1, checked: false },
+ { name: 'Botol minum isi ulang', qty: 1, checked: false },
+ ],
+ },
+ },
+};
diff --git a/src/hooks/useBudget.js b/src/hooks/useBudget.js
new file mode 100644
index 0000000..a62d445
--- /dev/null
+++ b/src/hooks/useBudget.js
@@ -0,0 +1,47 @@
+import { useTourStore } from '../store/tourStore';
+
+export const useBudget = () => {
+ const budget = useTourStore((state) => state.budget);
+ const setTotalBudget = useTourStore((state) => state.setTotalBudget);
+ const setCurrency = useTourStore((state) => state.setCurrency);
+ const addExpense = useTourStore((state) => state.addExpense);
+ const updateExpense = useTourStore((state) => state.updateExpense);
+ const removeExpense = useTourStore((state) => state.removeExpense);
+
+ const totalSpent = budget.expenses.reduce((sum, item) => sum + (Number(item.amount) || 0), 0);
+ const remainingBudget = (budget.total || 0) - totalSpent;
+ const isOverBudget = remainingBudget < 0;
+ const percentUsed = budget.total > 0 ? Math.min(100, Math.round((totalSpent / budget.total) * 100)) : 0;
+
+ // Breakdown by category
+ const categoryBreakdown = budget.expenses.reduce((acc, item) => {
+ const cat = item.category || 'lainnya';
+ acc[cat] = (acc[cat] || 0) + (Number(item.amount) || 0);
+ return acc;
+ }, {});
+
+ // Breakdown by day
+ const dayBreakdown = budget.expenses.reduce((acc, item) => {
+ const dayKey = item.day ? `Hari ${item.day}` : 'Umum';
+ acc[dayKey] = (acc[dayKey] || 0) + (Number(item.amount) || 0);
+ return acc;
+ }, {});
+
+ return {
+ budget,
+ totalBudget: budget.total,
+ currency: budget.currency,
+ expenses: budget.expenses,
+ totalSpent,
+ remainingBudget,
+ isOverBudget,
+ percentUsed,
+ categoryBreakdown,
+ dayBreakdown,
+ setTotalBudget,
+ setCurrency,
+ addExpense,
+ updateExpense,
+ removeExpense,
+ };
+};
diff --git a/src/hooks/usePackingList.js b/src/hooks/usePackingList.js
new file mode 100644
index 0000000..ba09ae0
--- /dev/null
+++ b/src/hooks/usePackingList.js
@@ -0,0 +1,42 @@
+import { useTourStore } from '../store/tourStore';
+
+export const usePackingList = () => {
+ const packingList = useTourStore((state) => state.packingList);
+ const addPackingItem = useTourStore((state) => state.addPackingItem);
+ const updatePackingItem = useTourStore((state) => state.updatePackingItem);
+ const togglePackingItem = useTourStore((state) => state.togglePackingItem);
+ const removePackingItem = useTourStore((state) => state.removePackingItem);
+ const toggleAllInCategory = useTourStore((state) => state.toggleAllInCategory);
+ const loadPackingTemplate = useTourStore((state) => state.loadPackingTemplate);
+ const clearPackingList = useTourStore((state) => state.clearPackingList);
+
+ let totalItems = 0;
+ let packedItems = 0;
+
+ Object.values(packingList).forEach((items) => {
+ if (Array.isArray(items)) {
+ items.forEach((item) => {
+ totalItems++;
+ if (item.checked) packedItems++;
+ });
+ }
+ });
+
+ const percentPacked = totalItems > 0 ? Math.round((packedItems / totalItems) * 100) : 0;
+ const isComplete = totalItems > 0 && packedItems === totalItems;
+
+ return {
+ packingList,
+ totalItems,
+ packedItems,
+ percentPacked,
+ isComplete,
+ addPackingItem,
+ updatePackingItem,
+ togglePackingItem,
+ removePackingItem,
+ toggleAllInCategory,
+ loadPackingTemplate,
+ clearPackingList,
+ };
+};
diff --git a/src/hooks/useSchedule.js b/src/hooks/useSchedule.js
new file mode 100644
index 0000000..46f4205
--- /dev/null
+++ b/src/hooks/useSchedule.js
@@ -0,0 +1,29 @@
+import { useTourStore } from '../store/tourStore';
+
+export const useSchedule = (dayIndex = 0) => {
+ const schedule = useTourStore((state) => state.schedule);
+ const trip = useTourStore((state) => state.trip);
+ const addActivity = useTourStore((state) => state.addActivity);
+ const updateActivity = useTourStore((state) => state.updateActivity);
+ const removeActivity = useTourStore((state) => state.removeActivity);
+ const reorderActivities = useTourStore((state) => state.reorderActivities);
+ const moveActivity = useTourStore((state) => state.moveActivity);
+
+ const currentDay = schedule[dayIndex] || null;
+ const activities = currentDay?.activities || [];
+
+ const totalActivities = schedule.reduce((sum, d) => sum + (d.activities?.length || 0), 0);
+
+ return {
+ schedule,
+ currentDay,
+ activities,
+ totalDays: trip.totalDays || schedule.length,
+ totalActivities,
+ addActivity: (act) => addActivity(dayIndex, act),
+ updateActivity: (id, act) => updateActivity(dayIndex, id, act),
+ removeActivity: (id) => removeActivity(dayIndex, id),
+ reorderActivities: (newActs) => reorderActivities(dayIndex, newActs),
+ moveActivity,
+ };
+};
diff --git a/src/hooks/useTripStore.js b/src/hooks/useTripStore.js
new file mode 100644
index 0000000..2a8ea8c
--- /dev/null
+++ b/src/hooks/useTripStore.js
@@ -0,0 +1,18 @@
+import { useTourStore } from '../store/tourStore';
+
+export const useTrip = () => {
+ const trip = useTourStore((state) => state.trip);
+ const setTrip = useTourStore((state) => state.setTrip);
+ const updateTrip = useTourStore((state) => state.updateTrip);
+ const resetTrip = useTourStore((state) => state.resetTrip);
+
+ const isConfigured = Boolean(trip.id && trip.destination);
+
+ return {
+ trip,
+ isConfigured,
+ setTrip,
+ updateTrip,
+ resetTrip,
+ };
+};
diff --git a/src/index.css b/src/index.css
new file mode 100644
index 0000000..137526b
--- /dev/null
+++ b/src/index.css
@@ -0,0 +1,483 @@
+/* === Tour Planner Design System === */
+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@400;500;600;700;800&family=Playfair+Display:ital,wght@0,600;0,700;1,600&display=swap');
+
+:root {
+ /* Core Colors */
+ --color-primary: hsl(220, 90%, 56%);
+ --color-primary-dark: hsl(220, 90%, 42%);
+ --color-primary-light: hsl(220, 90%, 70%);
+ --color-primary-glow: hsla(220, 90%, 56%, 0.35);
+
+ --color-accent: hsl(38, 95%, 55%);
+ --color-accent-dark: hsl(38, 95%, 40%);
+ --color-accent-light: hsl(38, 95%, 70%);
+ --color-accent-glow: hsla(38, 95%, 55%, 0.3);
+
+ /* Background (Dark Mode Default) */
+ --color-bg-base: hsl(222, 28%, 8%);
+ --color-bg-surface: hsl(222, 22%, 13%);
+ --color-bg-elevated: hsl(222, 18%, 18%);
+ --color-bg-card: hsla(222, 22%, 15%, 0.85);
+ --color-bg-overlay: hsla(222, 28%, 5%, 0.82);
+
+ /* Text */
+ --color-text-primary: hsl(210, 40%, 96%);
+ --color-text-secondary: hsl(210, 20%, 68%);
+ --color-text-muted: hsl(210, 15%, 48%);
+
+ /* Borders */
+ --color-border: hsl(220, 15%, 22%);
+ --color-border-light: hsl(220, 15%, 30%);
+ --color-border-focus: hsl(220, 90%, 60%);
+
+ /* Status Colors */
+ --color-success: hsl(142, 70%, 45%);
+ --color-success-bg: hsla(142, 70%, 45%, 0.15);
+ --color-warning: hsl(38, 95%, 55%);
+ --color-warning-bg: hsla(38, 95%, 55%, 0.15);
+ --color-danger: hsl(0, 75%, 55%);
+ --color-danger-bg: hsla(0, 75%, 55%, 0.15);
+ --color-info: hsl(199, 89%, 48%);
+ --color-info-bg: hsla(199, 89%, 48%, 0.15);
+
+ /* Category Colors */
+ --color-cat-wisata: hsl(220, 90%, 56%);
+ --color-cat-makan: hsl(142, 70%, 45%);
+ --color-cat-transportasi: hsl(280, 70%, 60%);
+ --color-cat-checkin: hsl(38, 95%, 55%);
+ --color-cat-akomodasi: hsl(265, 80%, 65%);
+ --color-cat-belanja: hsl(340, 75%, 58%);
+ --color-cat-lainnya: hsl(210, 20%, 55%);
+
+ /* Typography */
+ --font-body: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ --font-display: 'Outfit', sans-serif;
+ --font-hero: 'Playfair Display', serif;
+
+ --text-xs: 0.75rem; /* 12px */
+ --text-sm: 0.875rem; /* 14px */
+ --text-base: 1rem; /* 16px */
+ --text-lg: 1.125rem; /* 18px */
+ --text-xl: 1.25rem; /* 20px */
+ --text-2xl: 1.5rem; /* 24px */
+ --text-3xl: 1.875rem; /* 30px */
+ --text-4xl: 2.25rem; /* 36px */
+ --text-5xl: 3rem; /* 48px */
+
+ /* Spacing */
+ --space-1: 0.25rem;
+ --space-2: 0.5rem;
+ --space-3: 0.75rem;
+ --space-4: 1rem;
+ --space-5: 1.25rem;
+ --space-6: 1.5rem;
+ --space-8: 2rem;
+ --space-10: 2.5rem;
+ --space-12: 3rem;
+ --space-16: 4rem;
+ --space-20: 5rem;
+
+ /* Border Radius */
+ --radius-xs: 0.25rem;
+ --radius-sm: 0.375rem;
+ --radius-md: 0.5rem;
+ --radius-lg: 0.75rem;
+ --radius-xl: 1rem;
+ --radius-2xl: 1.5rem;
+ --radius-full: 9999px;
+
+ /* Shadows & Glow */
+ --shadow-sm: 0 2px 4px hsla(0, 0%, 0%, 0.3);
+ --shadow-md: 0 6px 18px hsla(0, 0%, 0%, 0.4);
+ --shadow-lg: 0 12px 36px hsla(0, 0%, 0%, 0.5);
+ --shadow-glow-primary: 0 0 25px var(--color-primary-glow);
+ --shadow-glow-accent: 0 0 25px var(--color-accent-glow);
+
+ /* Transitions */
+ --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
+ --transition-base: 300ms cubic-bezier(0.4, 0, 0.2, 1);
+ --transition-slow: 500ms cubic-bezier(0.4, 0, 0.2, 1);
+
+ /* Z-Index */
+ --z-dropdown: 100;
+ --z-sticky: 150;
+ --z-modal-backdrop: 200;
+ --z-modal: 210;
+ --z-toast: 300;
+ --z-tooltip: 400;
+}
+
+/* === CSS Reset & Base Rules === */
+*, *::before, *::after {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+html {
+ scroll-behavior: smooth;
+}
+
+body {
+ font-family: var(--font-body);
+ background-color: var(--color-bg-base);
+ color: var(--color-text-primary);
+ line-height: 1.6;
+ min-height: 100vh;
+ overflow-x: hidden;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+/* Custom Scrollbar */
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: var(--color-bg-base);
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--color-border);
+ border-radius: var(--radius-full);
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: var(--color-border-light);
+}
+
+/* Typography Hierarchy */
+h1, h2, h3, h4, h5, h6 {
+ font-family: var(--font-display);
+ font-weight: 700;
+ line-height: 1.25;
+ color: var(--color-text-primary);
+}
+
+h1 { font-size: var(--text-4xl); }
+h2 { font-size: var(--text-3xl); }
+h3 { font-size: var(--text-2xl); }
+h4 { font-size: var(--text-xl); }
+h5 { font-size: var(--text-lg); }
+h6 { font-size: var(--text-base); }
+
+p {
+ color: var(--color-text-secondary);
+ font-size: var(--text-base);
+}
+
+a {
+ color: var(--color-primary-light);
+ text-decoration: none;
+ transition: color var(--transition-fast);
+}
+
+a:hover {
+ color: var(--color-accent);
+}
+
+button {
+ font-family: var(--font-body);
+ cursor: pointer;
+ border: none;
+ outline: none;
+ background: none;
+}
+
+input, select, textarea {
+ font-family: var(--font-body);
+ color: var(--color-text-primary);
+}
+
+/* === Utility Classes === */
+.container {
+ width: 100%;
+ max-width: 1280px;
+ margin: 0 auto;
+ padding: 0 var(--space-6);
+}
+
+.page-wrapper {
+ min-height: calc(100vh - 80px);
+ padding: var(--space-8) 0 var(--space-16);
+}
+
+.page-header {
+ margin-bottom: var(--space-8);
+}
+
+.page-title {
+ font-size: var(--text-3xl);
+ font-family: var(--font-display);
+ margin-bottom: var(--space-2);
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+}
+
+.page-subtitle {
+ font-size: var(--text-base);
+ color: var(--color-text-secondary);
+ max-width: 650px;
+}
+
+/* Glassmorphism */
+.glass {
+ background: hsla(222, 22%, 15%, 0.75);
+ backdrop-filter: blur(16px);
+ -webkit-backdrop-filter: blur(16px);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-xl);
+ box-shadow: var(--shadow-md);
+}
+
+.glass-dark {
+ background: hsla(222, 28%, 10%, 0.85);
+ backdrop-filter: blur(20px);
+ -webkit-backdrop-filter: blur(20px);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-xl);
+}
+
+.glass-card {
+ background: linear-gradient(135deg, hsla(222, 22%, 18%, 0.8), hsla(222, 22%, 12%, 0.9));
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-xl);
+ transition: transform var(--transition-base), border-color var(--transition-base), box-shadow var(--transition-base);
+}
+
+.glass-card:hover {
+ border-color: var(--color-border-light);
+ box-shadow: var(--shadow-lg), var(--shadow-glow-primary);
+ transform: translateY(-4px);
+}
+
+/* Gradient Utilities */
+.text-gradient {
+ background: linear-gradient(135deg, var(--color-primary-light) 0%, var(--color-accent) 100%);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+
+.text-gradient-hero {
+ background: linear-gradient(135deg, hsl(0, 0%, 100%) 30%, var(--color-accent-light) 100%);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+
+.bg-gradient-hero {
+ background:
+ radial-gradient(ellipse at 15% 30%, hsla(220, 90%, 40%, 0.25) 0%, transparent 60%),
+ radial-gradient(ellipse at 85% 20%, hsla(38, 95%, 45%, 0.18) 0%, transparent 50%),
+ radial-gradient(ellipse at 50% 80%, hsla(280, 70%, 40%, 0.12) 0%, transparent 60%),
+ var(--color-bg-base);
+}
+
+/* Grid System */
+.grid-1 { display: grid; grid-template-columns: 1fr; gap: var(--space-6); }
+.grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--space-6); }
+.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-6); }
+.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--space-6); }
+
+/* Responsive Grids */
+@media (max-width: 1100px) {
+ .grid-4 { grid-template-columns: repeat(3, 1fr); }
+}
+
+@media (max-width: 860px) {
+ .grid-4 { grid-template-columns: repeat(2, 1fr); }
+ .grid-3 { grid-template-columns: repeat(2, 1fr); }
+}
+
+@media (max-width: 600px) {
+ .grid-4, .grid-3, .grid-2 { grid-template-columns: 1fr; }
+ .container { padding: 0 var(--space-4); }
+ h1 { font-size: var(--text-3xl); }
+ .page-title { font-size: var(--text-2xl); }
+}
+
+/* Flex Utilities */
+.flex-center {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.flex-between {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.flex-gap-2 { display: flex; gap: var(--space-2); }
+.flex-gap-4 { display: flex; gap: var(--space-4); }
+.flex-col { display: flex; flex-direction: column; }
+
+/* Form Controls */
+.input-field, .select-field, .textarea-field {
+ width: 100%;
+ background: hsla(222, 28%, 10%, 0.8);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ padding: var(--space-3) var(--space-4);
+ font-size: var(--text-sm);
+ color: var(--color-text-primary);
+ transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
+}
+
+.input-field:focus, .select-field:focus, .textarea-field:focus {
+ border-color: var(--color-border-focus);
+ box-shadow: 0 0 0 3px hsla(220, 90%, 56%, 0.2);
+ outline: none;
+}
+
+.input-field::placeholder, .textarea-field::placeholder {
+ color: var(--color-text-muted);
+}
+
+.input-group {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ margin-bottom: var(--space-4);
+}
+
+.input-label {
+ font-size: var(--text-sm);
+ font-weight: 500;
+ color: var(--color-text-secondary);
+}
+
+/* Badges */
+.badge {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1);
+ padding: var(--space-1) var(--space-3);
+ border-radius: var(--radius-full);
+ font-size: var(--text-xs);
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ text-transform: uppercase;
+}
+
+.badge-primary { background: var(--color-primary-glow); color: var(--color-primary-light); border: 1px solid hsla(220, 90%, 56%, 0.3); }
+.badge-accent { background: var(--color-accent-glow); color: var(--color-accent-light); border: 1px solid hsla(38, 95%, 55%, 0.3); }
+.badge-success { background: var(--color-success-bg); color: var(--color-success); border: 1px solid hsla(142, 70%, 45%, 0.3); }
+.badge-warning { background: var(--color-warning-bg); color: var(--color-warning); border: 1px solid hsla(38, 95%, 55%, 0.3); }
+.badge-danger { background: var(--color-danger-bg); color: var(--color-danger); border: 1px solid hsla(0, 75%, 55%, 0.3); }
+.badge-neutral { background: hsla(220, 15%, 25%, 0.6); color: var(--color-text-secondary); border: 1px solid var(--color-border); }
+
+/* Buttons */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-2);
+ padding: var(--space-3) var(--space-5);
+ font-size: var(--text-sm);
+ font-weight: 600;
+ border-radius: var(--radius-md);
+ transition: all var(--transition-fast);
+ cursor: pointer;
+ user-select: none;
+ white-space: nowrap;
+}
+
+.btn-primary {
+ background: linear-gradient(135deg, var(--color-primary), var(--color-primary-dark));
+ color: #ffffff;
+ box-shadow: 0 4px 14px var(--color-primary-glow);
+}
+
+.btn-primary:hover {
+ background: linear-gradient(135deg, var(--color-primary-light), var(--color-primary));
+ box-shadow: 0 6px 20px hsla(220, 90%, 56%, 0.5);
+ transform: translateY(-1px);
+}
+
+.btn-primary:active {
+ transform: translateY(0);
+}
+
+.btn-accent {
+ background: linear-gradient(135deg, var(--color-accent), var(--color-accent-dark));
+ color: hsl(222, 28%, 8%);
+ font-weight: 700;
+ box-shadow: 0 4px 14px var(--color-accent-glow);
+}
+
+.btn-accent:hover {
+ background: linear-gradient(135deg, var(--color-accent-light), var(--color-accent));
+ box-shadow: 0 6px 20px hsla(38, 95%, 55%, 0.5);
+ transform: translateY(-1px);
+}
+
+.btn-secondary {
+ background: var(--color-bg-elevated);
+ color: var(--color-text-primary);
+ border: 1px solid var(--color-border);
+}
+
+.btn-secondary:hover {
+ background: hsla(222, 18%, 24%, 1);
+ border-color: var(--color-border-light);
+ transform: translateY(-1px);
+}
+
+.btn-ghost {
+ background: transparent;
+ color: var(--color-text-secondary);
+}
+
+.btn-ghost:hover {
+ background: hsla(220, 15%, 25%, 0.4);
+ color: var(--color-text-primary);
+}
+
+.btn-danger {
+ background: var(--color-danger-bg);
+ color: var(--color-danger);
+ border: 1px solid hsla(0, 75%, 55%, 0.3);
+}
+
+.btn-danger:hover {
+ background: var(--color-danger);
+ color: #ffffff;
+}
+
+.btn-sm {
+ padding: var(--space-1) var(--space-3);
+ font-size: var(--text-xs);
+ border-radius: var(--radius-sm);
+}
+
+.btn-lg {
+ padding: var(--space-4) var(--space-8);
+ font-size: var(--text-base);
+ border-radius: var(--radius-lg);
+}
+
+.btn-icon {
+ padding: var(--space-2);
+ border-radius: var(--radius-md);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+/* Animations Keyframes */
+@keyframes pulse-slow {
+ 0%, 100% { opacity: 0.6; transform: scale(1); }
+ 50% { opacity: 0.9; transform: scale(1.05); }
+}
+
+.animate-pulse-slow {
+ animation: pulse-slow 6s ease-in-out infinite;
+}
diff --git a/src/main.jsx b/src/main.jsx
new file mode 100644
index 0000000..b9a1a6d
--- /dev/null
+++ b/src/main.jsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import './index.css'
+import App from './App.jsx'
+
+createRoot(document.getElementById('root')).render(
+
+
+ ,
+)
diff --git a/src/pages/Budget.jsx b/src/pages/Budget.jsx
new file mode 100644
index 0000000..66319b2
--- /dev/null
+++ b/src/pages/Budget.jsx
@@ -0,0 +1,244 @@
+import React, { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import {
+ WalletCards,
+ Plus,
+ MapPin,
+ CalendarDays,
+ Luggage,
+ DollarSign,
+ TrendingUp,
+ CreditCard,
+} from 'lucide-react';
+import PageWrapper from '../components/layout/PageWrapper';
+import BudgetOverview from '../components/budget/BudgetOverview';
+import BudgetChart from '../components/budget/BudgetChart';
+import ExpenseList from '../components/budget/ExpenseList';
+import AddExpenseModal from '../components/budget/AddExpenseModal';
+import Button from '../components/ui/Button';
+import { useTourStore } from '../store/tourStore';
+import { useBudget } from '../hooks/useBudget';
+import { toast } from '../store/toastStore';
+
+const Budget = () => {
+ const navigate = useNavigate();
+ const trip = useTourStore((state) => state.trip);
+ const {
+ budget,
+ totalBudget,
+ currency,
+ expenses,
+ totalSpent,
+ remainingBudget,
+ isOverBudget,
+ percentUsed,
+ categoryBreakdown,
+ dayBreakdown,
+ setTotalBudget,
+ setCurrency,
+ addExpense,
+ updateExpense,
+ removeExpense,
+ } = useBudget();
+
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const [editingExpense, setEditingExpense] = useState(null);
+
+ const handleOpenAddModal = () => {
+ setEditingExpense(null);
+ setIsModalOpen(true);
+ };
+
+ const handleOpenEditModal = (expense) => {
+ setEditingExpense(expense);
+ setIsModalOpen(true);
+ };
+
+ const handleSaveExpense = (expenseData) => {
+ if (expenseData.id) {
+ updateExpense(expenseData.id, expenseData);
+ toast.success(`Pengeluaran "${expenseData.name}" berhasil diperbarui!`);
+ } else {
+ addExpense(expenseData);
+ toast.success(`Pengeluaran "${expenseData.name}" berhasil dicatat!`);
+ if (totalBudget > 0 && totalSpent + expenseData.amount > totalBudget) {
+ toast.warning('Perhatian: Total pengeluaran kini melebihi target anggaran!');
+ }
+ }
+ };
+
+ const handleDeleteExpense = (id) => {
+ removeExpense(id);
+ toast.info('Pengeluaran berhasil dihapus.');
+ };
+
+ const handleCurrencyChange = (newCurr) => {
+ setCurrency(newCurr);
+ toast.info(`Mata uang diubah ke ${newCurr}`);
+ };
+
+ const handleUpdateTarget = (val) => {
+ setTotalBudget(val);
+ toast.success('Target anggaran berhasil diperbarui!');
+ };
+
+ return (
+
+ {/* Top Banner: Trip Info & Currency Selector */}
+
+
+
+
+
+
+
+ {trip.destination ? `${trip.destination} (${trip.totalDays || 1} Hari)` : 'Kalkulator Anggaran'}
+
+
+ Kalkulator & Estimasi Budget
+
+
+ Pantau arus pengeluaran liburan agar tetap sesuai dengan batas anggaran.
+
+
+
+
+ {/* Currency Toggle & Quick Nav Shortcuts */}
+
+ {/* Currency Toggle Selector */}
+
+ {['IDR', 'USD', 'EUR'].map((curr) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ {/* Budget Overview Widget (Cards & Progress) */}
+
+
+ {/* Recharts Visual Charts (Pie & Bar) */}
+
+
+ {/* Expense List and Categorized Items */}
+
+
+ {/* Add / Edit Expense Modal */}
+ setIsModalOpen(false)}
+ onSave={handleSaveExpense}
+ initialData={editingExpense}
+ totalDays={trip.totalDays || 1}
+ currency={currency}
+ />
+
+ );
+};
+
+export default Budget;
diff --git a/src/pages/Home.jsx b/src/pages/Home.jsx
new file mode 100644
index 0000000..aaadff0
--- /dev/null
+++ b/src/pages/Home.jsx
@@ -0,0 +1,220 @@
+import React, { useState, useMemo, useRef } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { Compass, Sparkles, MapPin, Plus, ArrowRight, RefreshCw, CalendarDays } from 'lucide-react';
+import HeroSection from '../components/trip/HeroSection';
+import FilterBar from '../components/trip/FilterBar';
+import TripCard from '../components/trip/TripCard';
+import TripModal from '../components/trip/TripModal';
+import CustomTripModal from '../components/trip/CustomTripModal';
+import EmptyState from '../components/ui/EmptyState';
+import Button from '../components/ui/Button';
+import { destinations } from '../data/destinations';
+import { useTourStore } from '../store/tourStore';
+import { useNavigate } from 'react-router-dom';
+
+const Home = () => {
+ const navigate = useNavigate();
+ const trip = useTourStore((state) => state.trip);
+
+ const [searchQuery, setSearchQuery] = useState('');
+ const [activeCategory, setActiveCategory] = useState('semua');
+ const [sortBy, setSortBy] = useState('popular');
+ const [selectedDestination, setSelectedDestination] = useState(null);
+ const [isPresetModalOpen, setIsPresetModalOpen] = useState(false);
+ const [isCustomModalOpen, setIsCustomModalOpen] = useState(false);
+
+ const exploreSectionRef = useRef(null);
+
+ const handleExploreScroll = () => {
+ exploreSectionRef.current?.scrollIntoView({ behavior: 'smooth' });
+ };
+
+ const handleSelectDestination = (dest) => {
+ setSelectedDestination(dest);
+ setIsPresetModalOpen(true);
+ };
+
+ // Filter & Sort Logic
+ const filteredDestinations = useMemo(() => {
+ return destinations
+ .filter((dest) => {
+ // Category filter
+ const matchesCategory =
+ activeCategory === 'semua' ||
+ dest.category.toLowerCase() === activeCategory.toLowerCase() ||
+ (activeCategory === 'gunung' && dest.category === 'alam');
+
+ // Search query
+ const query = searchQuery.toLowerCase().trim();
+ const matchesSearch =
+ !query ||
+ dest.name.toLowerCase().includes(query) ||
+ dest.location.toLowerCase().includes(query) ||
+ dest.description.toLowerCase().includes(query) ||
+ (dest.tags && dest.tags.some((t) => t.toLowerCase().includes(query))) ||
+ (dest.highlights && dest.highlights.some((h) => h.toLowerCase().includes(query)));
+
+ return matchesCategory && matchesSearch;
+ })
+ .sort((a, b) => {
+ if (sortBy === 'rating') {
+ return b.rating - a.rating;
+ }
+ if (sortBy === 'budget-low') {
+ return (a.estimatedBudget?.min || 0) - (b.estimatedBudget?.min || 0);
+ }
+ if (sortBy === 'duration') {
+ return (a.popularDuration || 0) - (b.popularDuration || 0);
+ }
+ // default 'popular' -> reviewCount
+ return (b.reviewCount || 0) - (a.reviewCount || 0);
+ });
+ }, [searchQuery, activeCategory, sortBy]);
+
+ return (
+
+ {/* Hero Section */}
+
setIsCustomModalOpen(true)}
+ />
+
+ {/* Main Content Area */}
+
+ {/* Active Trip Banner if configured */}
+ {trip.id && trip.destination && (
+
+
+
+
+
+
+
+ Trip Sedang Aktif
+
+
+ {trip.name || trip.destination} • {trip.totalDays} Hari
+
+
+
+
+
+
+
+
+ )}
+
+ {/* Section Header */}
+
+
+
+
+
+ Pilih Destinasi Wisata
+
+
+ Temukan {destinations.length} destinasi unggulan atau rancang rencana perjalanan sendiri.
+
+
+
+
+
+
+
+ {/* Search & Filter Component */}
+
+
+ {/* Destinations Grid */}
+ {filteredDestinations.length > 0 ? (
+
+
+ {filteredDestinations.map((destination) => (
+
+ ))}
+
+
+ ) : (
+
setIsCustomModalOpen(true)}
+ />
+ )}
+
+
+ {/* Preset Trip Configuration Modal */}
+ setIsPresetModalOpen(false)}
+ destination={selectedDestination}
+ />
+
+ {/* Custom Trip Modal */}
+ setIsCustomModalOpen(false)}
+ />
+
+ );
+};
+
+export default Home;
diff --git a/src/pages/PackingList.jsx b/src/pages/PackingList.jsx
new file mode 100644
index 0000000..f6cbd0d
--- /dev/null
+++ b/src/pages/PackingList.jsx
@@ -0,0 +1,224 @@
+import React, { useState, useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { Luggage, Search, Plus, Sparkles, MapPin, CalendarDays, WalletCards, ArrowRight, Layers, CheckCircle2 } from 'lucide-react';
+import PageWrapper from '../components/layout/PageWrapper';
+import PackingOverview from '../components/packing/PackingOverview';
+import PackingCategory from '../components/packing/PackingCategory';
+import TemplateModal from '../components/packing/TemplateModal';
+import Button from '../components/ui/Button';
+import EmptyState from '../components/ui/EmptyState';
+import { useTourStore } from '../store/tourStore';
+import { usePackingList } from '../hooks/usePackingList';
+import { PACKING_CATEGORIES } from '../data/packingTemplates';
+import { toast } from '../store/toastStore';
+
+const PackingList = () => {
+ const navigate = useNavigate();
+ const trip = useTourStore((state) => state.trip);
+ const {
+ packingList,
+ totalItems,
+ packedItems,
+ percentPacked,
+ isComplete,
+ addPackingItem,
+ updatePackingItem,
+ togglePackingItem,
+ removePackingItem,
+ toggleAllInCategory,
+ loadPackingTemplate,
+ clearPackingList,
+ } = usePackingList();
+
+ const [isTemplateModalOpen, setIsTemplateModalOpen] = useState(false);
+ const [searchFilter, setSearchFilter] = useState('');
+
+ // If packing list is empty, auto-load template according to trip category
+ useEffect(() => {
+ if (totalItems === 0) {
+ loadPackingTemplate(trip.category || 'pantai');
+ }
+ }, [totalItems, trip.category]);
+
+ const handleResetChecklist = () => {
+ PACKING_CATEGORIES.forEach((cat) => {
+ toggleAllInCategory(cat, false);
+ });
+ toast.info('Status centang packing list direset.');
+ };
+
+ const handleUpdateQty = (category, itemId, newQty) => {
+ updatePackingItem(category, itemId, { qty: newQty });
+ };
+
+ const handleAddItem = (category, itemData) => {
+ addPackingItem(category, itemData);
+ toast.success(`"${itemData.name}" ditambahkan ke ${category}!`);
+ };
+
+ const handleDeleteItem = (category, itemId) => {
+ removePackingItem(category, itemId);
+ toast.info('Barang berhasil dihapus.');
+ };
+
+ const handleApplyTemplate = (templateKey) => {
+ loadPackingTemplate(templateKey);
+ toast.success(`Template ${templateKey} berhasil dimuat!`);
+ };
+
+ return (
+
+ {/* Top Banner: Trip Info Header */}
+
+
+
+
+
+
+
+ {trip.destination ? `${trip.destination} (${trip.totalDays || 1} Hari)` : 'Daftar Perlengkapan'}
+
+
+ Packing List & Bawaan
+
+
+ Pastikan dokumen penting, pakaian, dan peralatan esensial sudah masuk koper.
+
+
+
+
+ {/* Quick Nav Shortcuts */}
+
+
+
+
+
+
+
+ {/* Progress Overview Widget */}
+ setIsTemplateModalOpen(true)}
+ onResetAll={handleResetChecklist}
+ />
+
+ {/* Search Filter Bar */}
+
+
+ setSearchFilter(e.target.value)}
+ style={{ background: 'transparent', border: 'none', padding: 0 }}
+ />
+
+
+ {/* Categories List */}
+
+ {PACKING_CATEGORIES.map((categoryName) => {
+ const categoryItems = (packingList[categoryName] || []).filter((item) => {
+ if (!searchFilter.trim()) return true;
+ return item.name.toLowerCase().includes(searchFilter.toLowerCase().trim());
+ });
+
+ // Skip empty categories when search query is active and nothing matches
+ if (searchFilter.trim() && categoryItems.length === 0) return null;
+
+ return (
+
+ );
+ })}
+
+
+ {/* Template Selection Modal */}
+ setIsTemplateModalOpen(false)}
+ onApplyTemplate={handleApplyTemplate}
+ currentCategory={trip.category}
+ />
+
+ );
+};
+
+export default PackingList;
diff --git a/src/pages/Schedule.jsx b/src/pages/Schedule.jsx
new file mode 100644
index 0000000..4c1fcbc
--- /dev/null
+++ b/src/pages/Schedule.jsx
@@ -0,0 +1,225 @@
+import React, { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { motion } from 'framer-motion';
+import {
+ CalendarDays,
+ MapPin,
+ Clock,
+ Plus,
+ Compass,
+ ArrowRight,
+ Luggage,
+ WalletCards,
+ Sparkles,
+ Layers,
+} from 'lucide-react';
+import PageWrapper from '../components/layout/PageWrapper';
+import DayTabs from '../components/schedule/DayTabs';
+import ScheduleTimeline from '../components/schedule/ScheduleTimeline';
+import AddActivityModal from '../components/schedule/AddActivityModal';
+import Button from '../components/ui/Button';
+import EmptyState from '../components/ui/EmptyState';
+import { useTourStore } from '../store/tourStore';
+import { useSchedule } from '../hooks/useSchedule';
+import { toast } from '../store/toastStore';
+
+const Schedule = () => {
+ const navigate = useNavigate();
+ const trip = useTourStore((state) => state.trip);
+ const schedule = useTourStore((state) => state.schedule);
+ const addActivity = useTourStore((state) => state.addActivity);
+ const updateActivity = useTourStore((state) => state.updateActivity);
+ const removeActivity = useTourStore((state) => state.removeActivity);
+ const reorderActivities = useTourStore((state) => state.reorderActivities);
+ const updateTrip = useTourStore((state) => state.updateTrip);
+ const initSchedule = useTourStore((state) => state.initSchedule);
+
+ const [selectedDayIndex, setSelectedDayIndex] = useState(0);
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const [editingActivity, setEditingActivity] = useState(null);
+
+ const currentDay = schedule[selectedDayIndex] || schedule[0];
+ const totalActivities = schedule.reduce((sum, d) => sum + (d.activities?.length || 0), 0);
+
+ // If no trip configured yet
+ if (!trip.id || !trip.destination) {
+ return (
+
+
+
+
+ Jadwal Harian Perjalanan
+
+
+ Anda belum memilih destinasi wisata untuk membuat itinerary.
+
+
+
+ navigate('/')}
+ />
+
+ );
+ }
+
+ const handleOpenAddModal = () => {
+ setEditingActivity(null);
+ setIsModalOpen(true);
+ };
+
+ const handleOpenEditModal = (activity) => {
+ setEditingActivity(activity);
+ setIsModalOpen(true);
+ };
+
+ const handleSaveActivity = (activityData) => {
+ if (activityData.id) {
+ updateActivity(selectedDayIndex, activityData.id, activityData);
+ toast.success(`Aktivitas "${activityData.name}" berhasil diperbarui!`);
+ } else {
+ addActivity(selectedDayIndex, activityData);
+ toast.success(`Aktivitas "${activityData.name}" ditambahkan ke Hari ${selectedDayIndex + 1}!`);
+ }
+ };
+
+ const handleDeleteActivity = (activityId) => {
+ removeActivity(selectedDayIndex, activityId);
+ toast.info('Aktivitas berhasil dihapus.');
+ };
+
+ const handleAddDay = () => {
+ const newTotalDays = (trip.totalDays || schedule.length) + 1;
+ updateTrip({ totalDays: newTotalDays });
+ initSchedule(newTotalDays, trip.startDate);
+ setSelectedDayIndex(newTotalDays - 1);
+ toast.success(`Hari ke-${newTotalDays} berhasil ditambahkan ke jadwal!`);
+ };
+
+ return (
+
+ {/* Top Banner Header: Trip Info & Summary Stats */}
+
+ {/* Left Side: Destination Info */}
+
+ {trip.coverImage && (
+
+

+
+ )}
+
+
+ {trip.destination}
+
+
+ {trip.name || `Itinerary ${trip.destination}`}
+
+
+ {trip.totalDays} Hari Total • {totalActivities} Aktivitas Terjadwal
+
+
+
+
+ {/* Right Side: Quick Action Pills to Packing & Budget */}
+
+
+
+
+
+
+
+ {/* Day Navigation Tabs */}
+
+
+ {/* Daily Timeline */}
+
+
+ {/* Add / Edit Activity Modal */}
+ setIsModalOpen(false)}
+ onSave={handleSaveActivity}
+ initialData={editingActivity}
+ dayNumber={selectedDayIndex + 1}
+ />
+
+ );
+};
+
+export default Schedule;
diff --git a/src/store/toastStore.js b/src/store/toastStore.js
new file mode 100644
index 0000000..f58896b
--- /dev/null
+++ b/src/store/toastStore.js
@@ -0,0 +1,31 @@
+import { create } from 'zustand';
+
+export const useToastStore = create((set, get) => ({
+ toasts: [],
+ addToast: ({ message, type = 'success', duration = 3000 }) => {
+ const id = 'toast-' + Math.random().toString(36).substring(2, 9) + '-' + Date.now();
+ const newToast = { id, message, type };
+
+ set((state) => ({
+ toasts: [...state.toasts, newToast],
+ }));
+
+ if (duration > 0) {
+ setTimeout(() => {
+ get().removeToast(id);
+ }, duration);
+ }
+ },
+ removeToast: (id) => {
+ set((state) => ({
+ toasts: state.toasts.filter((t) => t.id !== id),
+ }));
+ },
+}));
+
+export const toast = {
+ success: (msg, duration) => useToastStore.getState().addToast({ message: msg, type: 'success', duration }),
+ info: (msg, duration) => useToastStore.getState().addToast({ message: msg, type: 'info', duration }),
+ warning: (msg, duration) => useToastStore.getState().addToast({ message: msg, type: 'warning', duration }),
+ danger: (msg, duration) => useToastStore.getState().addToast({ message: msg, type: 'danger', duration }),
+};
diff --git a/src/store/tourStore.js b/src/store/tourStore.js
new file mode 100644
index 0000000..d957d9d
--- /dev/null
+++ b/src/store/tourStore.js
@@ -0,0 +1,372 @@
+import { create } from 'zustand';
+import { persist, createJSONStorage } from 'zustand/middleware';
+import { generateId } from '../utils/helpers';
+import { getDateForDayIndex, calculateTotalDays } from '../utils/dateHelpers';
+import { packingTemplates } from '../data/packingTemplates';
+
+const initialTripState = {
+ id: null,
+ name: '',
+ destination: '',
+ location: '',
+ startDate: null,
+ endDate: null,
+ totalDays: 0,
+ coverImage: '',
+ type: 'custom', // 'preset' | 'custom'
+ category: 'pantai',
+};
+
+const initialPackingState = {
+ Pakaian: [],
+ Dokumen: [],
+ Elektronik: [],
+ 'Obat-obatan': [],
+ Toiletries: [],
+ Lainnya: [],
+};
+
+const initialBudgetState = {
+ total: 0,
+ currency: 'IDR',
+ expenses: [],
+};
+
+export const useTourStore = create(
+ persist(
+ (set, get) => ({
+ // State
+ trip: initialTripState,
+ schedule: [],
+ packingList: initialPackingState,
+ budget: initialBudgetState,
+
+ // === TRIP ACTIONS ===
+ setTrip: (tripData) => {
+ const totalDays = tripData.startDate && tripData.endDate
+ ? calculateTotalDays(tripData.startDate, tripData.endDate)
+ : (tripData.totalDays || 1);
+
+ const newTrip = {
+ ...get().trip,
+ ...tripData,
+ totalDays,
+ };
+
+ set({ trip: newTrip });
+
+ // Auto-initialize schedule if empty or if day count changed
+ const currentSchedule = get().schedule;
+ if (currentSchedule.length !== totalDays) {
+ get().initSchedule(totalDays, newTrip.startDate);
+ }
+
+ // Auto-load template packing if packingList is completely empty
+ const isPackingEmpty = Object.values(get().packingList).every((arr) => arr.length === 0);
+ if (isPackingEmpty && newTrip.category) {
+ get().loadPackingTemplate(newTrip.category);
+ }
+ },
+
+ updateTrip: (data) => {
+ set((state) => ({
+ trip: { ...state.trip, ...data },
+ }));
+ },
+
+ resetTrip: () => {
+ set({ trip: initialTripState, schedule: [] });
+ },
+
+ // === SCHEDULE ACTIONS ===
+ initSchedule: (totalDays, startDate) => {
+ const currentSchedule = get().schedule;
+ const newSchedule = [];
+
+ for (let i = 0; i < totalDays; i++) {
+ const existingDay = currentSchedule[i];
+ const dayDate = getDateForDayIndex(startDate, i);
+
+ newSchedule.push({
+ dayNumber: i + 1,
+ date: dayDate,
+ activities: existingDay ? existingDay.activities : [],
+ });
+ }
+
+ set({ schedule: newSchedule });
+ },
+
+ setSchedule: (newSchedule) => {
+ set({ schedule: newSchedule });
+ },
+
+ addActivity: (dayIndex, activityData) => {
+ const newActivity = {
+ id: activityData.id || generateId(),
+ time: activityData.time || '09:00',
+ name: activityData.name || 'Aktivitas Baru',
+ location: activityData.location || '',
+ duration: activityData.duration || 60,
+ category: activityData.category || 'wisata',
+ notes: activityData.notes || '',
+ color: activityData.color || '',
+ };
+
+ set((state) => {
+ const updatedSchedule = [...state.schedule];
+ if (!updatedSchedule[dayIndex]) {
+ // ensure day exists
+ updatedSchedule[dayIndex] = {
+ dayNumber: dayIndex + 1,
+ date: getDateForDayIndex(state.trip.startDate, dayIndex),
+ activities: [],
+ };
+ }
+
+ const dayActivities = [...(updatedSchedule[dayIndex].activities || []), newActivity];
+ // Sort by time
+ dayActivities.sort((a, b) => (a.time || '').localeCompare(b.time || ''));
+
+ updatedSchedule[dayIndex] = {
+ ...updatedSchedule[dayIndex],
+ activities: dayActivities,
+ };
+
+ return { schedule: updatedSchedule };
+ });
+ },
+
+ updateActivity: (dayIndex, activityId, updatedData) => {
+ set((state) => {
+ const updatedSchedule = [...state.schedule];
+ if (!updatedSchedule[dayIndex]) return state;
+
+ const dayActivities = updatedSchedule[dayIndex].activities.map((act) =>
+ act.id === activityId ? { ...act, ...updatedData } : act
+ );
+
+ dayActivities.sort((a, b) => (a.time || '').localeCompare(b.time || ''));
+
+ updatedSchedule[dayIndex] = {
+ ...updatedSchedule[dayIndex],
+ activities: dayActivities,
+ };
+
+ return { schedule: updatedSchedule };
+ });
+ },
+
+ removeActivity: (dayIndex, activityId) => {
+ set((state) => {
+ const updatedSchedule = [...state.schedule];
+ if (!updatedSchedule[dayIndex]) return state;
+
+ updatedSchedule[dayIndex] = {
+ ...updatedSchedule[dayIndex],
+ activities: updatedSchedule[dayIndex].activities.filter((act) => act.id !== activityId),
+ };
+
+ return { schedule: updatedSchedule };
+ });
+ },
+
+ reorderActivities: (dayIndex, newActivities) => {
+ set((state) => {
+ const updatedSchedule = [...state.schedule];
+ if (!updatedSchedule[dayIndex]) return state;
+
+ updatedSchedule[dayIndex] = {
+ ...updatedSchedule[dayIndex],
+ activities: newActivities,
+ };
+
+ return { schedule: updatedSchedule };
+ });
+ },
+
+ moveActivity: (fromDayIndex, toDayIndex, activityId) => {
+ set((state) => {
+ const updatedSchedule = [...state.schedule];
+ const fromDay = updatedSchedule[fromDayIndex];
+ const toDay = updatedSchedule[toDayIndex];
+ if (!fromDay || !toDay) return state;
+
+ const activityToMove = fromDay.activities.find((a) => a.id === activityId);
+ if (!activityToMove) return state;
+
+ fromDay.activities = fromDay.activities.filter((a) => a.id !== activityId);
+ toDay.activities = [...toDay.activities, activityToMove];
+ toDay.activities.sort((a, b) => (a.time || '').localeCompare(b.time || ''));
+
+ return { schedule: updatedSchedule };
+ });
+ },
+
+ // === PACKING LIST ACTIONS ===
+ addPackingItem: (category, itemData) => {
+ const newItem = {
+ id: itemData.id || generateId(),
+ name: itemData.name,
+ qty: Math.max(1, itemData.qty || 1),
+ checked: !!itemData.checked,
+ };
+
+ set((state) => {
+ const currentCategoryItems = state.packingList[category] || [];
+ return {
+ packingList: {
+ ...state.packingList,
+ [category]: [...currentCategoryItems, newItem],
+ },
+ };
+ });
+ },
+
+ updatePackingItem: (category, itemId, updatedData) => {
+ set((state) => {
+ const currentCategoryItems = state.packingList[category] || [];
+ return {
+ packingList: {
+ ...state.packingList,
+ [category]: currentCategoryItems.map((item) =>
+ item.id === itemId ? { ...item, ...updatedData } : item
+ ),
+ },
+ };
+ });
+ },
+
+ togglePackingItem: (category, itemId) => {
+ set((state) => {
+ const currentCategoryItems = state.packingList[category] || [];
+ return {
+ packingList: {
+ ...state.packingList,
+ [category]: currentCategoryItems.map((item) =>
+ item.id === itemId ? { ...item, checked: !item.checked } : item
+ ),
+ },
+ };
+ });
+ },
+
+ removePackingItem: (category, itemId) => {
+ set((state) => {
+ const currentCategoryItems = state.packingList[category] || [];
+ return {
+ packingList: {
+ ...state.packingList,
+ [category]: currentCategoryItems.filter((item) => item.id !== itemId),
+ },
+ };
+ });
+ },
+
+ toggleAllInCategory: (category, shouldCheck) => {
+ set((state) => {
+ const currentCategoryItems = state.packingList[category] || [];
+ return {
+ packingList: {
+ ...state.packingList,
+ [category]: currentCategoryItems.map((item) => ({ ...item, checked: shouldCheck })),
+ },
+ };
+ });
+ },
+
+ loadPackingTemplate: (tripType = 'pantai') => {
+ const template = packingTemplates[tripType] || packingTemplates.pantai;
+ if (!template || !template.items) return;
+
+ const formattedList = {};
+ Object.entries(template.items).forEach(([catName, items]) => {
+ formattedList[catName] = items.map((item) => ({
+ id: generateId(),
+ name: item.name,
+ qty: item.qty || 1,
+ checked: false,
+ }));
+ });
+
+ set({ packingList: formattedList });
+ },
+
+ clearPackingList: () => {
+ set({ packingList: initialPackingState });
+ },
+
+ // === BUDGET ACTIONS ===
+ setTotalBudget: (amount) => {
+ set((state) => ({
+ budget: {
+ ...state.budget,
+ total: Math.max(0, Number(amount) || 0),
+ },
+ }));
+ },
+
+ setCurrency: (currency) => {
+ set((state) => ({
+ budget: {
+ ...state.budget,
+ currency: currency || 'IDR',
+ },
+ }));
+ },
+
+ addExpense: (expenseData) => {
+ const newExpense = {
+ id: expenseData.id || generateId(),
+ name: expenseData.name || 'Pengeluaran',
+ amount: Math.max(0, Number(expenseData.amount) || 0),
+ category: expenseData.category || 'lainnya',
+ day: expenseData.day ?? null, // number or null
+ date: expenseData.date || '',
+ notes: expenseData.notes || '',
+ };
+
+ set((state) => ({
+ budget: {
+ ...state.budget,
+ expenses: [newExpense, ...state.budget.expenses],
+ },
+ }));
+ },
+
+ updateExpense: (id, updatedData) => {
+ set((state) => ({
+ budget: {
+ ...state.budget,
+ expenses: state.budget.expenses.map((exp) =>
+ exp.id === id ? { ...exp, ...updatedData } : exp
+ ),
+ },
+ }));
+ },
+
+ removeExpense: (id) => {
+ set((state) => ({
+ budget: {
+ ...state.budget,
+ expenses: state.budget.expenses.filter((exp) => exp.id !== id),
+ },
+ }));
+ },
+
+ // === GLOBAL RESET ===
+ resetAll: () => {
+ set({
+ trip: initialTripState,
+ schedule: [],
+ packingList: initialPackingState,
+ budget: initialBudgetState,
+ });
+ },
+ }),
+ {
+ name: 'tour-planner-storage',
+ storage: createJSONStorage(() => localStorage),
+ }
+ )
+);
diff --git a/src/utils/dateHelpers.js b/src/utils/dateHelpers.js
new file mode 100644
index 0000000..d92085b
--- /dev/null
+++ b/src/utils/dateHelpers.js
@@ -0,0 +1,69 @@
+import { format, parseISO, isValid, differenceInCalendarDays, addDays } from 'date-fns';
+import { id } from 'date-fns/locale';
+
+/**
+ * Format ISO date string to readable Indonesian date
+ * e.g., "Senin, 15 Okt 2026"
+ */
+export const formatDateIndo = (dateInput, pattern = 'EEEE, d MMM yyyy') => {
+ if (!dateInput) return '';
+ try {
+ const dateObj = typeof dateInput === 'string' ? parseISO(dateInput) : dateInput;
+ if (!isValid(dateObj)) return '';
+ return format(dateObj, pattern, { locale: id });
+ } catch {
+ return '';
+ }
+};
+
+/**
+ * Format date for short badge e.g. "15 Okt"
+ */
+export const formatDateShort = (dateInput) => {
+ return formatDateIndo(dateInput, 'd MMM');
+};
+
+/**
+ * Calculate total days inclusive (Start: Day 1, End: Day 3 => 3 days)
+ */
+export const calculateTotalDays = (startDate, endDate) => {
+ if (!startDate || !endDate) return 1;
+ try {
+ const start = typeof startDate === 'string' ? parseISO(startDate) : startDate;
+ const end = typeof endDate === 'string' ? parseISO(endDate) : endDate;
+ if (!isValid(start) || !isValid(end)) return 1;
+ const diff = differenceInCalendarDays(end, start);
+ return Math.max(1, diff + 1);
+ } catch {
+ return 1;
+ }
+};
+
+/**
+ * Get date for specific day index (0-based)
+ */
+export const getDateForDayIndex = (startDate, dayIndex) => {
+ if (!startDate) return '';
+ try {
+ const start = typeof startDate === 'string' ? parseISO(startDate) : startDate;
+ if (!isValid(start)) return '';
+ const targetDate = addDays(start, dayIndex);
+ return format(targetDate, 'yyyy-MM-dd');
+ } catch {
+ return '';
+ }
+};
+
+/**
+ * Get ISO string for today in YYYY-MM-DD
+ */
+export const getTodayString = () => {
+ return format(new Date(), 'yyyy-MM-dd');
+};
+
+/**
+ * Get ISO string for tomorrow in YYYY-MM-DD
+ */
+export const getFutureDateString = (daysAhead = 3) => {
+ return format(addDays(new Date(), daysAhead), 'yyyy-MM-dd');
+};
diff --git a/src/utils/formatCurrency.js b/src/utils/formatCurrency.js
new file mode 100644
index 0000000..fb513c4
--- /dev/null
+++ b/src/utils/formatCurrency.js
@@ -0,0 +1,53 @@
+/**
+ * Currency Formatting Utilities
+ */
+
+export const formatIDR = (amount) => {
+ const value = Number(amount) || 0;
+ return new Intl.NumberFormat('id-ID', {
+ style: 'currency',
+ currency: 'IDR',
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 0,
+ }).format(value);
+};
+
+export const formatUSD = (amount) => {
+ const value = Number(amount) || 0;
+ return new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency: 'USD',
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 2,
+ }).format(value);
+};
+
+export const formatEUR = (amount) => {
+ const value = Number(amount) || 0;
+ return new Intl.NumberFormat('de-DE', {
+ style: 'currency',
+ currency: 'EUR',
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 2,
+ }).format(value);
+};
+
+export const formatCurrency = (amount, currency = 'IDR') => {
+ switch (currency.toUpperCase()) {
+ case 'USD':
+ return formatUSD(amount);
+ case 'EUR':
+ return formatEUR(amount);
+ case 'IDR':
+ default:
+ return formatIDR(amount);
+ }
+};
+
+export const parseCurrencyInput = (value) => {
+ if (typeof value === 'number') return value;
+ if (!value) return 0;
+ // Remove non-digit characters except decimals
+ const cleaned = value.toString().replace(/[^0-9]/g, '');
+ return parseInt(cleaned, 10) || 0;
+};
diff --git a/src/utils/helpers.js b/src/utils/helpers.js
new file mode 100644
index 0000000..a4409a1
--- /dev/null
+++ b/src/utils/helpers.js
@@ -0,0 +1,19 @@
+/**
+ * General helper functions
+ */
+
+export const generateId = () => {
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
+ return crypto.randomUUID();
+ }
+ return 'id-' + Math.random().toString(36).substring(2, 9) + '-' + Date.now().toString(36);
+};
+
+export const clamp = (val, min, max) => Math.min(Math.max(val, min), max);
+
+export const truncateText = (text, maxLength = 100) => {
+ if (!text || text.length <= maxLength) return text;
+ return text.slice(0, maxLength) + '...';
+};
+
+export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
diff --git a/task.md b/task.md
new file mode 100644
index 0000000..1d1c8e7
--- /dev/null
+++ b/task.md
@@ -0,0 +1,81 @@
+# 📋 Task Progress — Tour Destination Planner
+
+## Phase 0 — Project Setup & Foundation [DONE]
+- [x] **0.1** — Inisialisasi Vite + React
+- [x] **0.2** — Install dependencies (`react-router-dom`, `zustand`, `framer-motion`, `lucide-react`, `recharts`, `@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/utilities`, `date-fns`)
+- [x] **0.3** — Setup design system di `src/index.css` (CSS variables, glassmorphism, buttons, badges, forms)
+- [x] **0.4** — Setup Zustand store di `src/store/tourStore.js` dengan persist middleware ke localStorage
+- [x] **0.5** — Setup React Router di `src/App.jsx` dengan 4 rute utama (`/`, `/schedule`, `/packing`, `/budget`)
+- [x] **0.6** — Buat data dummy di `src/data/` (`destinations.js`, `activities.js`, `packingTemplates.js`)
+- [x] **0.7** — Buat utility functions di `src/utils/` (`formatCurrency.js`, `dateHelpers.js`, `helpers.js`) dan custom hooks di `src/hooks/`
+- [x] **0.8** — Validasi build (`npm run build`) sukses tanpa error
+
+---
+
+## Phase 1 — Layout & UI Components [DONE]
+- [x] **1A.1** — `Navbar.jsx` (Sticky glassmorphism, responsive mobile drawer menu, dynamic badge indicators)
+- [x] **1A.2** — `Footer.jsx` (Branding, quick links, active trip widget) & `PageWrapper.jsx` (framer-motion page transition)
+- [x] **1B.1** — `Button.jsx` (variants: primary/secondary/accent/ghost/danger, sizes, loading spinner, whileHover & whileTap)
+- [x] **1B.2** — `Card.jsx` (glassmorphism card, hoverable glow lift effect)
+- [x] **1B.3** — `Modal.jsx` (accessible focus & escape key trap, Framer Motion scalePop spring, custom footers)
+- [x] **1B.4** — `Badge.jsx` (primary/accent/success/warning/danger/neutral badges)
+- [x] **1B.5** — `ProgressBar.jsx` (animated fill, custom gradients, label & percentage indicator)
+- [x] **1B.6** — `EmptyState.jsx` (empty view illustration, responsive text, call-to-action button)
+- [x] **1B.7** — Validasi build (`npm run build`) 100% sukses tanpa error
+
+---
+
+## Phase 2 — Home Page (Trip Selection) [DONE]
+- [x] **2A.1** — `HeroSection.jsx` dengan typography Playfair Display, pulsing background glow, CTA buttons & key feature badges
+- [x] **2A.2** — `FilterBar.jsx` dengan search input real-time, clear button, dan filter pills kategori (Pantai, Gunung, Budaya, Kota)
+- [x] **2A.3** — Sorting mode dropdown (Paling Populer, Rating Tertinggi, Budget Terendah, Durasi Singkat)
+- [x] **2B.1** — `TripCard.jsx` dengan image overlay, rating badge, tag highlights, budget range, dan hover lift effect
+- [x] **2B.2** — Responsive grid destinasi (4→3→2→1 kolom) dengan layout animation & AnimatePresence
+- [x] **2B.3** — `EmptyState.jsx` saat pencarian tidak ditemukan
+- [x] **2C.1** — `TripModal.jsx` untuk preset destination (nama trip, tanggal mulai/selesai, auto-calc durasi hari, target budget)
+- [x] **2C.2** — `CustomTripModal.jsx` untuk membuat trip bebas (custom nama destinasi, kategori, cover image, durasi, budget)
+- [x] **2C.3** — Banner info trip aktif saat user sudah memilih trip sebelumnya
+- [x] **2C.4** — Validasi build (`npm run build`) 100% sukses tanpa error
+
+---
+
+## Phase 3 — Schedule Builder Page [DONE]
+- [x] **3A.1** — `DayTabs.jsx` navigasi per hari (Day 1..Day N) dengan badge jumlah aktivitas, tanggal format Indonesia, dan tombol tambah hari
+- [x] **3A.2** — Guard check & Empty state jika belum ada destinasi yang dipilih
+- [x] **3B.1** — `ScheduleTimeline.jsx` dengan header ringkasan hari dan list timeline vertikal
+- [x] **3B.2** — `ActivityItem.jsx` dengan drag handle, kategori icon & stripe color, waktu, durasi, lokasi, catatan, tombol edit & hapus
+- [x] **3C.1** — `AddActivityModal.jsx` (mode tambah & mode edit aktivitas)
+- [x] **3C.2** — Quick activity template chips (Sarapan, Check-in, Sunset, Wisata, Kuliner, Belanja, dll.)
+- [x] **3D.1** — Drag & drop reordering menggunakan `@dnd-kit/core` & `@dnd-kit/sortable`
+- [x] **3D.2** — Validasi build (`npm run build`) 100% sukses tanpa error
+
+---
+
+## Phase 4 — Packing List Page [DONE]
+- [x] **4A.1** — `PackingOverview.jsx` (total item, item terkemas, persentase packing bar dinamis, badge status & pesan selebrasi saat 100%)
+- [x] **4A.2** — `TemplateModal.jsx` untuk memuat template bawaan per kategori trip (*Pantai*, *Gunung*, *Kota*, *Budaya*)
+- [x] **4B.1** — `PackingCategory.jsx` (kategori collapsible dengan badge count, toggle Check All / Uncheck All, dan form inline tambah item)
+- [x] **4B.2** — `PackingItem.jsx` (custom animated checkbox spring, coret nama barang, quantity counter minus/plus, tombol hapus)
+- [x] **4C.1** — Search filter barang bawaan real-time
+- [x] **4C.2** — Auto-load template berdasarkan trip yang dipilih
+- [x] **4C.3** — Validasi build (`npm run build`) 100% sukses tanpa error
+
+---
+
+## Phase 5 — Budget Calculator Page [DONE]
+- [x] **5A.1** — `BudgetOverview.jsx` (Target total budget editable, total terpakai, sisa budget dengan status warna dinamis)
+- [x] **5A.2** — Progress bar alokasi budget terpakai & banner peringatan saat over-budget
+- [x] **5B.1** — `BudgetChart.jsx` dengan Recharts PieChart (proporsi per kategori) & BarChart (pengeluaran per hari)
+- [x] **5B.2** — Custom dark glassmorphism tooltips & legend persentase kategori
+- [x] **5C.1** — `AddExpenseModal.jsx` (form tambah & edit biaya: nominal, kategori, hari ke-N, catatan)
+- [x] **5C.2** — `ExpenseList.jsx` (list pengeluaran dengan filter kategori, sorting, edit & hapus item)
+- [x] **5C.3** — Multi-currency toggle (IDR, USD, EUR)
+- [x] **5C.4** — Validasi build (`npm run build`) 100% sukses tanpa error
+
+---
+
+## Phase 6 — Polish, Animations & Responsive [DONE]
+- [x] **6A.1** — `ToastContainer.jsx` & `toastStore.js` (notifikasi mengambang interaktif sukses/info/warning/danger di semua aksi pengguna)
+- [x] **6B.1** — Responsiveness audit & mobile drawer navigation menu di Navbar
+- [x] **6C.1** — Dialog modal konfirmasi "Reset Trip" dengan visual peringatan
+- [x] **6D.1** — Production build testing (`npm run build`) 100% sukses tanpa error (732ms)
diff --git a/vite.config.js b/vite.config.js
new file mode 100644
index 0000000..9982072
--- /dev/null
+++ b/vite.config.js
@@ -0,0 +1,7 @@
+import react from '@vitejs/plugin-react'
+import { defineConfig } from 'vite'
+
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [react()],
+})