feat: Add trip cloning from past voyages, PWA manifest, and batch pack/share actions
This commit is contained in:
1 parent
d275808711
commit
253b7f3329
5 files changed
+440
-62
No files matched your search
+7
-1
@@ -2,8 +2,14 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='70' height='70' x='15' y='15' fill='%23FFE279' rx='4' transform='rotate(6 50 50)'/><rect width='65' height='65' x='18' y='18' fill='%23FFF9A6' rx='3' transform='rotate(-4 50 50)'/></svg>" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
|
<meta name="theme-color" content="#141518" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Loadout" />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<link rel="apple-touch-icon" href="/icon.svg" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||||
<title>LOADOUT // Travel Manifest & Bag Allocation</title>
|
<title>LOADOUT // Travel Manifest & Bag Allocation</title>
|
||||||
<!-- Google Fonts for retro travel ephemera, boarding pass typography, and tactile monospace -->
|
<!-- Google Fonts for retro travel ephemera, boarding pass typography, and tactile monospace -->
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Loadout">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#1E2633"/>
|
||||||
|
<stop offset="100%" stop-color="#0F141C"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="luggage" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#FF5A26"/>
|
||||||
|
<stop offset="100%" stop-color="#D93510"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="tag" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#FFE853"/>
|
||||||
|
<stop offset="100%" stop-color="#FFC700"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="64" height="64" rx="16" fill="url(#bg)"/>
|
||||||
|
<rect x="1" y="1" width="62" height="62" rx="15" fill="none" stroke="#FFFFFF" stroke-opacity="0.08" stroke-width="1"/>
|
||||||
|
|
||||||
|
<!-- Luggage Handle -->
|
||||||
|
<path d="M25 21 V15 C25 13.5 26.5 12 28 12 H36 C37.5 12 39 13.5 39 15 V21" fill="none" stroke="#CBD5E1" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
<rect x="23" y="19" width="4" height="4" rx="1" fill="#475569"/>
|
||||||
|
<rect x="37" y="19" width="4" height="4" rx="1" fill="#475569"/>
|
||||||
|
|
||||||
|
<!-- Suitcase Body -->
|
||||||
|
<rect x="13" y="21" width="38" height="29" rx="5" fill="url(#luggage)"/>
|
||||||
|
|
||||||
|
<!-- Suitcase Accents & Straps -->
|
||||||
|
<rect x="13" y="33" width="38" height="4" fill="#872205" opacity="0.6"/>
|
||||||
|
<rect x="22" y="21" width="3" height="29" fill="#1E2633" opacity="0.45"/>
|
||||||
|
<rect x="39" y="21" width="3" height="29" fill="#1E2633" opacity="0.45"/>
|
||||||
|
|
||||||
|
<!-- Wheels -->
|
||||||
|
<circle cx="20" cy="51" r="2.5" fill="#0F172A"/>
|
||||||
|
<circle cx="44" cy="51" r="2.5" fill="#0F172A"/>
|
||||||
|
|
||||||
|
<!-- Dangling Baggage Tag -->
|
||||||
|
<g transform="rotate(-15 36 21)">
|
||||||
|
<path d="M37 17 L37 23" stroke="#FFEBA8" stroke-width="1.3" stroke-linecap="round"/>
|
||||||
|
<polygon points="32,23 42,23 45,38 29,38" fill="url(#tag)"/>
|
||||||
|
<circle cx="37" cy="25.5" r="1.5" fill="#8B4513"/>
|
||||||
|
<line x1="32" y1="30" x2="42" y2="30" stroke="#1E2633" stroke-width="1.2" stroke-dasharray="2 1 1 1.5 2"/>
|
||||||
|
<line x1="33" y1="33" x2="41" y2="33" stroke="#1E2633" stroke-width="1.2" stroke-dasharray="1 1.5 2 1"/>
|
||||||
|
<line x1="33" y1="36" x2="41" y2="36" stroke="#1E2633" stroke-width="0.8"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Stamped Verified Badge -->
|
||||||
|
<circle cx="48" cy="46" r="6.5" fill="#FFD100" stroke="#1E2633" stroke-width="1"/>
|
||||||
|
<path d="M45.5 46.2 L47.2 47.8 L50.5 44.5" fill="none" stroke="#141518" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "LOADOUT // Travel Manifest & Bag Allocation",
|
||||||
|
"short_name": "Loadout",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#F4F1EA",
|
||||||
|
"theme_color": "#141518",
|
||||||
|
"description": "Tactile travel packing manifest and luggage allocation system.",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon.svg",
|
||||||
|
"sizes": "64x64",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+155
-25
@@ -32,7 +32,9 @@ import {
|
|||||||
RotateCcw,
|
RotateCcw,
|
||||||
Plane,
|
Plane,
|
||||||
List,
|
List,
|
||||||
LayoutList
|
LayoutList,
|
||||||
|
Copy,
|
||||||
|
CheckCheck
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const CATEGORY_TABS: { id: Category | 'all'; label: string }[] = [
|
const CATEGORY_TABS: { id: Category | 'all'; label: string }[] = [
|
||||||
@@ -55,6 +57,8 @@ export function App() {
|
|||||||
});
|
});
|
||||||
const [settings, setSettings] = useState<AppSettings>(StorageClient.getSettings());
|
const [settings, setSettings] = useState<AppSettings>(StorageClient.getSettings());
|
||||||
const [compactView, setCompactView] = useState<boolean>(false);
|
const [compactView, setCompactView] = useState<boolean>(false);
|
||||||
|
const [cloneSourceTripId, setCloneSourceTripId] = useState<string | null>(null);
|
||||||
|
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
// UI Filter states
|
// UI Filter states
|
||||||
const [selectedBag, setSelectedBag] = useState<BagType | 'all'>('all');
|
const [selectedBag, setSelectedBag] = useState<BagType | 'all'>('all');
|
||||||
@@ -74,6 +78,11 @@ export function App() {
|
|||||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||||
const [settingsTab, setSettingsTab] = useState<'general' | 'bags' | 'archetypes'>('general');
|
const [settingsTab, setSettingsTab] = useState<'general' | 'bags' | 'archetypes'>('general');
|
||||||
|
|
||||||
|
const showToast = (msg: string) => {
|
||||||
|
setToastMessage(msg);
|
||||||
|
setTimeout(() => setToastMessage(null), 2800);
|
||||||
|
};
|
||||||
|
|
||||||
// Load initial data
|
// Load initial data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
@@ -115,6 +124,7 @@ export function App() {
|
|||||||
setTrips(updatedTrips);
|
setTrips(updatedTrips);
|
||||||
setActiveTripId(trip.id);
|
setActiveTripId(trip.id);
|
||||||
StorageClient.setActiveTripId(trip.id);
|
StorageClient.setActiveTripId(trip.id);
|
||||||
|
showToast(`Voyage "${trip.title}" manifest issued!`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteTrip = async (tripId: string) => {
|
const handleDeleteTrip = async (tripId: string) => {
|
||||||
@@ -126,6 +136,14 @@ export function App() {
|
|||||||
if (activeTripId === tripId) {
|
if (activeTripId === tripId) {
|
||||||
setActiveTripId(updatedTrips[0]?.id || null);
|
setActiveTripId(updatedTrips[0]?.id || null);
|
||||||
}
|
}
|
||||||
|
showToast('Voyage manifest deleted');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloneCurrentTrip = () => {
|
||||||
|
if (!activeTrip) return;
|
||||||
|
sound.playClick();
|
||||||
|
setCloneSourceTripId(activeTrip.id);
|
||||||
|
setIsTripModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Item actions
|
// Item actions
|
||||||
@@ -192,6 +210,55 @@ export function App() {
|
|||||||
setNewItemName('');
|
setNewItemName('');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBatchTogglePacked = async (packedState: boolean) => {
|
||||||
|
if (!activeTrip || filteredItems.length === 0) return;
|
||||||
|
sound.playStamp();
|
||||||
|
const targetItemIds = new Set(filteredItems.map((i) => i.id));
|
||||||
|
const updatedItems = activeTrip.items.map((item) =>
|
||||||
|
targetItemIds.has(item.id) ? { ...item, packed: packedState } : item
|
||||||
|
);
|
||||||
|
const updatedTrip = { ...activeTrip, items: updatedItems };
|
||||||
|
await StorageClient.saveTrip(updatedTrip);
|
||||||
|
setTrips((prev) => prev.map((t) => (t.id === activeTrip.id ? updatedTrip : t)));
|
||||||
|
showToast(
|
||||||
|
packedState
|
||||||
|
? `Marked ${targetItemIds.size} item(s) as stowed!`
|
||||||
|
: `Reset ${targetItemIds.size} item(s) to unpacked.`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyTextManifest = () => {
|
||||||
|
if (!activeTrip) return;
|
||||||
|
sound.playClick();
|
||||||
|
|
||||||
|
let text = `LOADOUT MANIFEST // ${activeTrip.routeCode}: ${activeTrip.title}\n`;
|
||||||
|
text += `Destination: ${activeTrip.destination} • ${activeTrip.durationDays}D / ${activeTrip.durationNights}N • Archetype: ${activeTrip.tripType.toUpperCase()}\n`;
|
||||||
|
text += `==========================================================\n\n`;
|
||||||
|
|
||||||
|
for (const bag of settings.customBags) {
|
||||||
|
const bagItems = activeTrip.items.filter((i) => i.bag === bag.id);
|
||||||
|
if (bagItems.length > 0) {
|
||||||
|
text += `[${bag.tagCode}] ${bag.label} (${bag.sub}):\n`;
|
||||||
|
for (const item of bagItems) {
|
||||||
|
const check = item.packed ? '[✓]' : '[ ]';
|
||||||
|
text += ` ${check} ${item.name} (${item.quantity}x)${item.essential ? ' [ESSENTIAL]' : ''}${
|
||||||
|
item.notes ? ` — Note: ${item.notes}` : ''
|
||||||
|
}\n`;
|
||||||
|
}
|
||||||
|
text += `\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(text)
|
||||||
|
.then(() => {
|
||||||
|
showToast('Text manifest copied to clipboard!');
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
showToast('Could not copy to clipboard.');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleSaveDebrief = async (debrief: TripDebrief) => {
|
const handleSaveDebrief = async (debrief: TripDebrief) => {
|
||||||
if (!activeTrip) return;
|
if (!activeTrip) return;
|
||||||
const updated = await StorageClient.recordDebrief(activeTrip.id, debrief);
|
const updated = await StorageClient.recordDebrief(activeTrip.id, debrief);
|
||||||
@@ -199,6 +266,7 @@ export function App() {
|
|||||||
setTrips((prev) => prev.map((t) => (t.id === updated.id ? updated : t)));
|
setTrips((prev) => prev.map((t) => (t.id === updated.id ? updated : t)));
|
||||||
const refreshedMemory = await StorageClient.getMemory();
|
const refreshedMemory = await StorageClient.getMemory();
|
||||||
setMemory(refreshedMemory);
|
setMemory(refreshedMemory);
|
||||||
|
showToast('Debrief saved & engine memory updated!');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -243,12 +311,23 @@ export function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F4F1EA] text-[#141518] flex flex-col font-sans">
|
<div className="min-h-screen bg-[#F4F1EA] text-[#141518] flex flex-col font-sans">
|
||||||
|
{/* Toast Feedback Notification */}
|
||||||
|
{toastMessage && (
|
||||||
|
<div className="fixed bottom-5 right-5 z-50 bg-ink text-ticket-100 border-2 border-canary px-4 py-2 font-mono text-xs font-bold shadow-card-hard animate-in fade-in slide-in-from-bottom-2 duration-150 flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-canary animate-ping" />
|
||||||
|
<span>{toastMessage}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Boarding Pass Telemetry Header */}
|
{/* Boarding Pass Telemetry Header */}
|
||||||
<BoardingHeader
|
<BoardingHeader
|
||||||
activeTrip={activeTrip}
|
activeTrip={activeTrip}
|
||||||
trips={trips}
|
trips={trips}
|
||||||
onSelectTrip={handleSelectTrip}
|
onSelectTrip={handleSelectTrip}
|
||||||
onNewTrip={() => setIsTripModalOpen(true)}
|
onNewTrip={() => {
|
||||||
|
setCloneSourceTripId(null);
|
||||||
|
setIsTripModalOpen(true);
|
||||||
|
}}
|
||||||
onOpenDebrief={() => setIsDebriefModalOpen(true)}
|
onOpenDebrief={() => setIsDebriefModalOpen(true)}
|
||||||
onOpenMemory={() => setIsMemoryOpen(true)}
|
onOpenMemory={() => setIsMemoryOpen(true)}
|
||||||
onOpenSettings={() => {
|
onOpenSettings={() => {
|
||||||
@@ -432,39 +511,87 @@ export function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Manifest Item Count & Bag Filter Notice */}
|
{/* Manifest Item Count, Batch Actions & Toolbar */}
|
||||||
<div className="flex items-center justify-between text-xs font-mono text-ink-muted mb-3">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between text-xs font-mono text-ink-muted mb-3 gap-2">
|
||||||
<span>
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
DISPLAYING {filteredItems.length} ITEM(S)
|
<span>
|
||||||
{selectedBag !== 'all' && (
|
DISPLAYING {filteredItems.length} ITEM(S)
|
||||||
<span className="text-gate-orange font-bold ml-1">
|
{selectedBag !== 'all' && (
|
||||||
(FILTERED TO {selectedBag.toUpperCase()})
|
<span className="text-gate-orange font-bold ml-1">
|
||||||
</span>
|
(FILTERED TO {selectedBag.toUpperCase()})
|
||||||
)}
|
</span>
|
||||||
{compactView && (
|
)}
|
||||||
<span className="text-blue-700 font-bold ml-1">
|
{compactView && (
|
||||||
[COMPACT MODE]
|
<span className="text-blue-700 font-bold ml-1">
|
||||||
</span>
|
[COMPACT MODE]
|
||||||
)}
|
</span>
|
||||||
</span>
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
{/* Batch Stow / Unpack Buttons */}
|
||||||
|
{filteredItems.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1 ml-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleBatchTogglePacked(true)}
|
||||||
|
className="px-1.5 py-0.5 border border-ink bg-white hover:bg-stamp-green-light text-stamp-green font-bold flex items-center gap-1 shadow-xs text-[10px]"
|
||||||
|
title="Mark all currently displayed items as stowed"
|
||||||
|
>
|
||||||
|
<CheckCheck size={11} />
|
||||||
|
<span>PACK ALL</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleBatchTogglePacked(false)}
|
||||||
|
className="px-1.5 py-0.5 border border-ink bg-white hover:bg-ticket-200 text-ink-muted font-bold flex items-center gap-1 shadow-xs text-[10px]"
|
||||||
|
title="Reset all displayed items to unpacked"
|
||||||
|
>
|
||||||
|
<RotateCcw size={11} />
|
||||||
|
<span>UNPACK ALL</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 sm:gap-3 flex-wrap">
|
||||||
<button
|
<button
|
||||||
onClick={() => window.print()}
|
type="button"
|
||||||
className="hover:text-ink flex items-center gap-1 font-bold"
|
onClick={handleCloneCurrentTrip}
|
||||||
title="Print paper boarding manifest"
|
className="hover:text-ink flex items-center gap-1 font-bold text-ink"
|
||||||
|
title="Use this trip's packing list as the basis for a new voyage"
|
||||||
>
|
>
|
||||||
<Printer size={13} />
|
<Copy size={12} />
|
||||||
<span>PRINT MANIFEST</span>
|
<span>CLONE TO NEW VOYAGE</span>
|
||||||
</button>
|
</button>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopyTextManifest}
|
||||||
|
className="hover:text-ink flex items-center gap-1 font-bold text-ink"
|
||||||
|
title="Copy formatted text packing list to clipboard"
|
||||||
|
>
|
||||||
|
<Share2 size={12} />
|
||||||
|
<span>SHARE / COPY TEXT</span>
|
||||||
|
</button>
|
||||||
|
<span>•</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => window.print()}
|
||||||
|
className="hover:text-ink flex items-center gap-1 font-bold text-ink"
|
||||||
|
title="Print paper boarding manifest"
|
||||||
|
>
|
||||||
|
<Printer size={12} />
|
||||||
|
<span className="hidden sm:inline">PRINT</span>
|
||||||
|
</button>
|
||||||
|
<span>•</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => handleDeleteTrip(activeTrip.id)}
|
onClick={() => handleDeleteTrip(activeTrip.id)}
|
||||||
className="hover:text-stamp-red flex items-center gap-1 font-bold text-stamp-red/70"
|
className="hover:text-stamp-red flex items-center gap-1 font-bold text-stamp-red/70"
|
||||||
title="Delete voyage"
|
title="Delete voyage"
|
||||||
>
|
>
|
||||||
<Trash2 size={13} />
|
<Trash2 size={12} />
|
||||||
<span>DELETE VOYAGE</span>
|
<span className="hidden sm:inline">DELETE</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -511,6 +638,7 @@ export function App() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
sound.playClick();
|
sound.playClick();
|
||||||
|
setCloneSourceTripId(null);
|
||||||
setIsTripModalOpen(true);
|
setIsTripModalOpen(true);
|
||||||
}}
|
}}
|
||||||
className="bg-canary hover:bg-canary-hover text-ink font-grotesk font-bold text-sm uppercase px-6 py-3 border-2 border-ink shadow-card-hard active:translate-x-0.5 active:translate-y-0.5 inline-flex items-center gap-2"
|
className="bg-canary hover:bg-canary-hover text-ink font-grotesk font-bold text-sm uppercase px-6 py-3 border-2 border-ink shadow-card-hard active:translate-x-0.5 active:translate-y-0.5 inline-flex items-center gap-2"
|
||||||
@@ -541,6 +669,8 @@ export function App() {
|
|||||||
setSettingsTab('archetypes');
|
setSettingsTab('archetypes');
|
||||||
setIsSettingsOpen(true);
|
setIsSettingsOpen(true);
|
||||||
}}
|
}}
|
||||||
|
trips={trips}
|
||||||
|
initialCloneTripId={cloneSourceTripId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{activeTrip && (
|
{activeTrip && (
|
||||||
|
|||||||
+212
-36
@@ -1,8 +1,21 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { TripType, Climate, LuggageConfig, Trip, PackMemory, CustomArchetype } from '../types';
|
import { TripType, Climate, LuggageConfig, Trip, PackMemory, CustomArchetype, PackingItem } from '../types';
|
||||||
import { generateAirportCode, generatePackingList } from '../utils/generator';
|
import { generateAirportCode, generatePackingList } from '../utils/generator';
|
||||||
import { sound } from '../utils/audio';
|
import { sound } from '../utils/audio';
|
||||||
import { X, Plane, Calendar, Cloud, Luggage, Sparkles, Check, Settings2 } from 'lucide-react';
|
import {
|
||||||
|
X,
|
||||||
|
Plane,
|
||||||
|
Calendar,
|
||||||
|
Cloud,
|
||||||
|
Luggage,
|
||||||
|
Sparkles,
|
||||||
|
Check,
|
||||||
|
Settings2,
|
||||||
|
Copy,
|
||||||
|
Wand2,
|
||||||
|
ArrowRight,
|
||||||
|
AlertTriangle
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface TripModalProps {
|
interface TripModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -11,6 +24,8 @@ interface TripModalProps {
|
|||||||
memory: PackMemory;
|
memory: PackMemory;
|
||||||
customArchetypes: CustomArchetype[];
|
customArchetypes: CustomArchetype[];
|
||||||
onEditArchetypes: () => void;
|
onEditArchetypes: () => void;
|
||||||
|
trips: Trip[];
|
||||||
|
initialCloneTripId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CLIMATES: { id: Climate; label: string; icon: string }[] = [
|
const CLIMATES: { id: Climate; label: string; icon: string }[] = [
|
||||||
@@ -42,9 +57,19 @@ export const TripModal: React.FC<TripModalProps> = ({
|
|||||||
memory,
|
memory,
|
||||||
customArchetypes,
|
customArchetypes,
|
||||||
onEditArchetypes,
|
onEditArchetypes,
|
||||||
|
trips,
|
||||||
|
initialCloneTripId = null,
|
||||||
}) => {
|
}) => {
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const [creationMode, setCreationMode] = useState<'generate' | 'clone'>(
|
||||||
|
initialCloneTripId ? 'clone' : 'generate'
|
||||||
|
);
|
||||||
|
const [selectedCloneId, setSelectedCloneId] = useState<string>(
|
||||||
|
initialCloneTripId || (trips[0]?.id || '')
|
||||||
|
);
|
||||||
|
const [applyDebriefLearnings, setApplyDebriefLearnings] = useState<boolean>(true);
|
||||||
|
|
||||||
const [destination, setDestination] = useState('');
|
const [destination, setDestination] = useState('');
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [tripType, setTripType] = useState<TripType>(customArchetypes[0]?.id || 'international_urban');
|
const [tripType, setTripType] = useState<TripType>(customArchetypes[0]?.id || 'international_urban');
|
||||||
@@ -54,6 +79,41 @@ export const TripModal: React.FC<TripModalProps> = ({
|
|||||||
const [hasLaundry, setHasLaundry] = useState(true);
|
const [hasLaundry, setHasLaundry] = useState(true);
|
||||||
const [selectedActivities, setSelectedActivities] = useState<string[]>(['remote_work']);
|
const [selectedActivities, setSelectedActivities] = useState<string[]>(['remote_work']);
|
||||||
|
|
||||||
|
// Handle pre-populating when cloning from a trip
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialCloneTripId) {
|
||||||
|
setCreationMode('clone');
|
||||||
|
setSelectedCloneId(initialCloneTripId);
|
||||||
|
const src = trips.find((t) => t.id === initialCloneTripId);
|
||||||
|
if (src) {
|
||||||
|
setDestination(src.destination);
|
||||||
|
setTitle(`${src.title} (Copy)`);
|
||||||
|
setTripType(src.tripType);
|
||||||
|
setDurationDays(src.durationDays);
|
||||||
|
setClimate(src.climate);
|
||||||
|
setLuggageConfig(src.luggageConfig);
|
||||||
|
setHasLaundry(src.hasLaundry);
|
||||||
|
setSelectedActivities(src.activities || []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [initialCloneTripId, isOpen]);
|
||||||
|
|
||||||
|
const handleSelectSourceTrip = (tripId: string) => {
|
||||||
|
setSelectedCloneId(tripId);
|
||||||
|
const src = trips.find((t) => t.id === tripId);
|
||||||
|
if (src) {
|
||||||
|
sound.playClick();
|
||||||
|
setDestination(src.destination);
|
||||||
|
setTitle(`${src.title} (Repack)`);
|
||||||
|
setTripType(src.tripType);
|
||||||
|
setDurationDays(src.durationDays);
|
||||||
|
setClimate(src.climate);
|
||||||
|
setLuggageConfig(src.luggageConfig);
|
||||||
|
setHasLaundry(src.hasLaundry);
|
||||||
|
setSelectedActivities(src.activities || []);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const destinationAirportCode = generateAirportCode(destination || 'DEST');
|
const destinationAirportCode = generateAirportCode(destination || 'DEST');
|
||||||
const routeCode = `DEP ➔ ${destinationAirportCode}`;
|
const routeCode = `DEP ➔ ${destinationAirportCode}`;
|
||||||
|
|
||||||
@@ -73,18 +133,58 @@ export const TripModal: React.FC<TripModalProps> = ({
|
|||||||
const tripTitle = title.trim() || `${destination.trim() || 'Voyage'} Expedition`;
|
const tripTitle = title.trim() || `${destination.trim() || 'Voyage'} Expedition`;
|
||||||
const tripId = `trip_${Date.now()}`;
|
const tripId = `trip_${Date.now()}`;
|
||||||
|
|
||||||
const items = generatePackingList({
|
let items: PackingItem[] = [];
|
||||||
title: tripTitle,
|
|
||||||
destination: destination.trim() || 'New Destination',
|
if (creationMode === 'clone') {
|
||||||
tripType,
|
const sourceTrip = trips.find((t) => t.id === selectedCloneId);
|
||||||
durationDays,
|
if (sourceTrip) {
|
||||||
durationNights: Math.max(1, durationDays - 1),
|
// Clone items with reset packed checkboxes and new IDs
|
||||||
climate,
|
items = sourceTrip.items.map((item) => ({
|
||||||
luggageConfig,
|
...item,
|
||||||
hasLaundry,
|
id: `item_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`,
|
||||||
activities: selectedActivities,
|
packed: false,
|
||||||
memory,
|
}));
|
||||||
});
|
|
||||||
|
// Apply debrief learnings if selected and available
|
||||||
|
if (applyDebriefLearnings && sourceTrip.debrief) {
|
||||||
|
const excludedLower = sourceTrip.debrief.excludedItemNames.map((n) => n.toLowerCase());
|
||||||
|
// Remove deadweight items
|
||||||
|
items = items.filter((item) => !excludedLower.includes(item.name.toLowerCase()));
|
||||||
|
|
||||||
|
// Inject wishlist items
|
||||||
|
for (const wishlistName of sourceTrip.debrief.includedItemNames) {
|
||||||
|
if (!items.some((i) => i.name.toLowerCase() === wishlistName.toLowerCase())) {
|
||||||
|
items.push({
|
||||||
|
id: `item_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`,
|
||||||
|
name: wishlistName,
|
||||||
|
category: 'gear',
|
||||||
|
quantity: 1,
|
||||||
|
bag: 'carry_on',
|
||||||
|
packed: false,
|
||||||
|
source: 'learned',
|
||||||
|
learnedReason: `Included from past debrief of ${sourceTrip.title}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback or generator mode
|
||||||
|
if (items.length === 0) {
|
||||||
|
items = generatePackingList({
|
||||||
|
title: tripTitle,
|
||||||
|
destination: destination.trim() || 'New Destination',
|
||||||
|
tripType,
|
||||||
|
durationDays,
|
||||||
|
durationNights: Math.max(1, durationDays - 1),
|
||||||
|
climate,
|
||||||
|
luggageConfig,
|
||||||
|
hasLaundry,
|
||||||
|
activities: selectedActivities,
|
||||||
|
memory,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const newTrip: Trip = {
|
const newTrip: Trip = {
|
||||||
id: tripId,
|
id: tripId,
|
||||||
@@ -106,9 +206,7 @@ export const TripModal: React.FC<TripModalProps> = ({
|
|||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Count relevant memory items
|
const selectedCloneTrip = trips.find((t) => t.id === selectedCloneId);
|
||||||
const relevantInclusions = memory.inclusions.filter((i) => i.tripType === tripType).length;
|
|
||||||
const relevantExclusions = memory.exclusions.filter((e) => e.tripType === tripType).length;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 bg-ink/70 backdrop-blur-xs flex items-center justify-center p-3 sm:p-6 overflow-y-auto">
|
<div className="fixed inset-0 z-50 bg-ink/70 backdrop-blur-xs flex items-center justify-center p-3 sm:p-6 overflow-y-auto">
|
||||||
@@ -132,6 +230,45 @@ export const TripModal: React.FC<TripModalProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Creation Mode Tabs */}
|
||||||
|
<div className="bg-ticket-200 border-b-2 border-ink px-4 pt-2 flex gap-1 font-mono text-xs font-bold">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
sound.playClick();
|
||||||
|
setCreationMode('generate');
|
||||||
|
}}
|
||||||
|
className={`px-3 py-2 border-t-2 border-l-2 border-r-2 border-ink transition-all flex items-center gap-1.5 ${
|
||||||
|
creationMode === 'generate'
|
||||||
|
? 'bg-ticket-100 text-ink shadow-xs -mb-[2px] z-10'
|
||||||
|
: 'bg-white/60 text-ink-muted hover:bg-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Wand2 size={13} />
|
||||||
|
<span>SMART HEURISTIC GENERATOR</span>
|
||||||
|
</button>
|
||||||
|
{trips.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
sound.playClick();
|
||||||
|
setCreationMode('clone');
|
||||||
|
if (!selectedCloneId && trips[0]) {
|
||||||
|
handleSelectSourceTrip(trips[0].id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`px-3 py-2 border-t-2 border-l-2 border-r-2 border-ink transition-all flex items-center gap-1.5 ${
|
||||||
|
creationMode === 'clone'
|
||||||
|
? 'bg-ticket-100 text-ink shadow-xs -mb-[2px] z-10'
|
||||||
|
: 'bg-white/60 text-ink-muted hover:bg-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Copy size={13} />
|
||||||
|
<span>CLONE FROM PAST VOYAGE ({trips.length})</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Route Code Banner */}
|
{/* Route Code Banner */}
|
||||||
<div className="bg-canary border-b-2 border-ink px-4 py-2 flex items-center justify-between font-mono text-xs font-bold text-ink">
|
<div className="bg-canary border-b-2 border-ink px-4 py-2 flex items-center justify-between font-mono text-xs font-bold text-ink">
|
||||||
<span>ROUTING TELEMETRY:</span>
|
<span>ROUTING TELEMETRY:</span>
|
||||||
@@ -140,7 +277,57 @@ export const TripModal: React.FC<TripModalProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleGenerate} className="p-4 sm:p-6 max-h-[80vh] overflow-y-auto space-y-5">
|
<form onSubmit={handleGenerate} className="p-4 sm:p-6 max-h-[75vh] overflow-y-auto space-y-5">
|
||||||
|
{/* CLONE SELECTION SECTION (When cloning from a past trip) */}
|
||||||
|
{creationMode === 'clone' && (
|
||||||
|
<div className="bg-white border-2 border-ink p-3.5 shadow-card-hard-sm space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-mono text-xs font-bold uppercase text-ink flex items-center gap-1.5">
|
||||||
|
<Copy size={14} className="text-gate-orange" />
|
||||||
|
<span>SELECT BASE VOYAGE TO RE-FLY</span>
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[10px] bg-ticket-200 border border-ink/30 px-1.5 py-0.5 text-ink">
|
||||||
|
{selectedCloneTrip?.items.length || 0} ITEMS IN BASE
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={selectedCloneId}
|
||||||
|
onChange={(e) => handleSelectSourceTrip(e.target.value)}
|
||||||
|
className="w-full bg-ticket-50 border-2 border-ink font-mono text-xs font-bold p-2 shadow-xs cursor-pointer"
|
||||||
|
>
|
||||||
|
{trips.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>
|
||||||
|
{t.routeCode} — {t.title} ({t.items.length} items, {t.durationDays}D)
|
||||||
|
{t.debrief ? ' [DEBRIEFED]' : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Debrief Learning Filter Toggle */}
|
||||||
|
{selectedCloneTrip?.debrief && (
|
||||||
|
<div className="p-2.5 bg-purple-50 border border-purple-300 font-mono text-xs">
|
||||||
|
<label className="flex items-start gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={applyDebriefLearnings}
|
||||||
|
onChange={(e) => setApplyDebriefLearnings(e.target.checked)}
|
||||||
|
className="mt-0.5 accent-purple-700"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="font-bold text-purple-900 block">
|
||||||
|
APPLY PAST DEBRIEF REFINEMENTS
|
||||||
|
</span>
|
||||||
|
<span className="font-sans text-[11px] text-purple-800 leading-tight block mt-0.5">
|
||||||
|
Automatically omit {selectedCloneTrip.debrief.excludedItemNames.length} deadweight item(s) you never used, and automatically inject {selectedCloneTrip.debrief.includedItemNames.length} wishlist item(s) you missed!
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Destination & Title */}
|
{/* Destination & Title */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -171,7 +358,7 @@ export const TripModal: React.FC<TripModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Trip Type */}
|
{/* Trip Type (Archetypes) */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
<label className="font-mono text-xs font-bold uppercase text-ink">
|
<label className="font-mono text-xs font-bold uppercase text-ink">
|
||||||
@@ -378,21 +565,6 @@ export const TripModal: React.FC<TripModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Learning Memory Signal */}
|
|
||||||
{(relevantInclusions > 0 || relevantExclusions > 0) && (
|
|
||||||
<div className="bg-purple-50 border-2 border-purple-300 p-3 flex items-start gap-2.5 font-mono text-xs text-purple-900">
|
|
||||||
<Sparkles size={16} className="text-purple-600 shrink-0 mt-0.5" />
|
|
||||||
<div>
|
|
||||||
<span className="font-bold">CONTINUOUS LEARNING ACTIVE:</span>
|
|
||||||
<p className="font-sans text-[11px] text-purple-800 mt-0.5">
|
|
||||||
Your past debriefs have taught the engine for {tripType.replace('_', ' ')}:
|
|
||||||
{relevantInclusions > 0 && ` ${relevantInclusions} wishlist item(s) will be automatically injected.`}
|
|
||||||
{relevantExclusions > 0 && ` ${relevantExclusions} frequently unused deadweight item(s) will be omitted or reduced.`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Form Actions */}
|
{/* Form Actions */}
|
||||||
<div className="border-t-2 border-ink pt-4 flex items-center justify-end gap-3">
|
<div className="border-t-2 border-ink pt-4 flex items-center justify-end gap-3">
|
||||||
<button
|
<button
|
||||||
@@ -410,7 +582,11 @@ export const TripModal: React.FC<TripModalProps> = ({
|
|||||||
className="px-6 py-2.5 border-2 border-ink font-grotesk font-bold text-sm uppercase bg-gate-orange hover:bg-gate-hover text-white shadow-card-hard active:translate-x-0.5 active:translate-y-0.5 flex items-center gap-2"
|
className="px-6 py-2.5 border-2 border-ink font-grotesk font-bold text-sm uppercase bg-gate-orange hover:bg-gate-hover text-white shadow-card-hard active:translate-x-0.5 active:translate-y-0.5 flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<Plane size={16} />
|
<Plane size={16} />
|
||||||
<span>STAMP & ISSUE LOADOUT MANIFEST</span>
|
<span>
|
||||||
|
{creationMode === 'clone'
|
||||||
|
? 'CLONE & ISSUE MANIFEST'
|
||||||
|
: 'STAMP & ISSUE LOADOUT MANIFEST'}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Reference in new issue
Block a user