feat: Initial release of LOADOUT - Travel Manifest & Bag Allocation System
This commit is contained in:
commit
805f3fe97a
26 files changed
+7618
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.DS_Store
|
||||||
|
*.local
|
||||||
|
data/
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY . ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=80
|
||||||
|
ENV DATA_DIR=/app/data
|
||||||
|
|
||||||
|
# Install production dependencies
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install --omit=dev
|
||||||
|
|
||||||
|
# Copy backend server code
|
||||||
|
COPY server/ ./server/
|
||||||
|
|
||||||
|
# Copy built frontend assets
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
|
# Create persistent storage directories
|
||||||
|
RUN mkdir -p /app/data/uploads
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=15s --timeout=5s --start-period=5s --retries=3 \
|
||||||
|
CMD wget --quiet --tries=1 --spider http://127.0.0.1:80/api/health || exit 1
|
||||||
|
|
||||||
|
CMD ["node", "server/index.js"]
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# LOADOUT // Travel Manifest & Bag Allocation System
|
||||||
|
|
||||||
|
> **LOADOUT** is a tactile, retro-ephemera inspired travel packing manifest and luggage allocation system designed for purposeful travelers. Built with a boarding pass and cargo ticket aesthetic, continuous post-trip debrief learning, and modular bag management ("What goes in what bag").
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
### 1. Boarding Pass & Retro Ephemera Aesthetic
|
||||||
|
* **Distinctive Swiss Grotesk & Telemetry Monospace**: Uses `Bebas Neue` uppercase departure board headers, `Space Grotesk` structural typography, and `IBM Plex Mono` for flight codes, weights, and timestamps.
|
||||||
|
* **Tactile Perforated Stubs**: Items are rendered as tear-off baggage coupons with circular notch punchouts and dashed perforation lines.
|
||||||
|
* **Canary Yellow & Gate Orange Accents**: High-contrast utilitarian colorway with rubber stamp badges (`[STOWED]`, `[EXCLUDED]`, `[WISHLIST]`).
|
||||||
|
* **Built-in Tactile Web Audio**: Physical paper tear sounds upon checking off items and mechanical stamp thuds when stowing bags or filing debriefs—zero audio file downloads required.
|
||||||
|
|
||||||
|
### 2. Luggage Allocation ("What Goes In What Bag")
|
||||||
|
* Assign and visualize items across distinct containers:
|
||||||
|
* 🎒 **Personal Item / Daypack**: Under-seat essentials (passport, power bank, medications, AirPods, Kindle, cables).
|
||||||
|
* 🧳 **Carry-On Roller**: Overhead compartment (clothes packing cubes, 3-1-1 TSA compliant liquids pouch).
|
||||||
|
* 📦 **Checked Luggage**: Cargo hold (full-size liquids > 100ml, trekking poles, pocket knives, heavy boots).
|
||||||
|
* 🧥 **Worn on Person**: Travel day outfit (heaviest jacket, boots, watch to minimize bag weight).
|
||||||
|
* Dedicated visual bag overview cards with individual capacity meters and TSA advisory reminders.
|
||||||
|
* One-click re-allocation to move any item between bags on the fly.
|
||||||
|
|
||||||
|
### 3. Continuous Learning Loop (Post-Trip Debrief)
|
||||||
|
* At the conclusion of your voyage, launch the **Trip Debrief**:
|
||||||
|
* **Identify Deadweight (What Should Have Been Excluded)**: Tap items you packed but never used. The engine remembers this and penalizes or omits them on future matching voyages.
|
||||||
|
* **Log Missing Items (What Should Have Been Included)**: Record items you wished you had on the road. The engine automatically suggests them when planning future trips of that archetype.
|
||||||
|
* **Flight Log Archive**: Inspect accumulated packing wisdom, deadweight statistics, and learned discovery rules anytime.
|
||||||
|
|
||||||
|
### 4. Smart Heuristic Engine & AI Booster
|
||||||
|
* **Deterministic Multiplier Formulas**: Calculates clothing requirements dynamically based on days, climate, and mid-trip laundry access (e.g., 7 days with laundry caps duplicate clothing to ~4 days).
|
||||||
|
* **Optional AI Dispatcher**: Connect your local Ollama instance running on `vloser` or a cloud API key for customized destination recommendations that blend with your personal learned rules.
|
||||||
|
* **100% Offline Capability**: Full client-side fallback via `localStorage`—works seamlessly in airplane mode or remote backcountry camps.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Hosting with Docker & Traefik on `vloser`
|
||||||
|
|
||||||
|
Loadout is packaged as a lightweight Alpine Node container with persistent JSON storage:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compose Configuration (`docker-compose.yml`)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
loadout:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: loadout
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- /home/chrisf/docker/appdata/loadout/data:/app/data
|
||||||
|
networks:
|
||||||
|
- t3_proxy
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8478:80"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget --quiet --tries=1 --spider http://127.0.0.1:80/api/health || exit 1"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 5s
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.loadout.rule=Host(`loadout.vloser.us`)"
|
||||||
|
- "traefik.http.routers.loadout.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.loadout.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.loadout.loadbalancer.server.port=80"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
t3_proxy:
|
||||||
|
external: true
|
||||||
|
```
|
||||||
|
|
||||||
|
Data is stored persistently in `/app/data`:
|
||||||
|
* `trips.json` — All past, active, and upcoming travel manifests
|
||||||
|
* `memory.json` — Learned exclusions, inclusions, and debrief statistics
|
||||||
|
* `settings.json` — Audio preferences and optional AI endpoint configuration
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
services:
|
||||||
|
loadout:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: loadout
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- /home/chrisf/docker/appdata/loadout/data:/app/data
|
||||||
|
networks:
|
||||||
|
- t3_proxy
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8478:80"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget --quiet --tries=1 --spider http://127.0.0.1:80/api/health || exit 1"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 5s
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.loadout.rule=Host(`loadout.vloser.us`)"
|
||||||
|
- "traefik.http.routers.loadout.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.loadout.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.loadout.loadbalancer.server.port=80"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
t3_proxy:
|
||||||
|
external: true
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<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" />
|
||||||
|
<title>LOADOUT // Travel Manifest & Bag Allocation</title>
|
||||||
|
<!-- 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.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Plus+Jakarta+Sans:ital,wght@0,400;0,500;0,600;0,700;0,800;1,400&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body class="bg-[#F4F1EA] text-[#141518] antialiased selection:bg-[#FFD000] selection:text-black">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+3853
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "loadout",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"start": "node server/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"cors": "^2.8.6",
|
||||||
|
"express": "^5.3.0",
|
||||||
|
"lucide-react": "^1.16.0",
|
||||||
|
"multer": "^2.4.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"tailwind-merge": "^3.0.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cors": "^2.8.19",
|
||||||
|
"@types/express": "^5.0.6",
|
||||||
|
"@types/multer": "^2.3.0",
|
||||||
|
"@types/node": "^22.13.9",
|
||||||
|
"@types/react": "^19.0.10",
|
||||||
|
"@types/react-dom": "^19.0.4",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.5.3",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"typescript": "^5.8.2",
|
||||||
|
"vite": "^6.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
+270
@@ -0,0 +1,270 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 80;
|
||||||
|
const DATA_DIR = process.env.DATA_DIR || path.resolve(__dirname, '../data');
|
||||||
|
|
||||||
|
// Storage file paths
|
||||||
|
const TRIPS_FILE = path.join(DATA_DIR, 'trips.json');
|
||||||
|
const MEMORY_FILE = path.join(DATA_DIR, 'memory.json');
|
||||||
|
const SETTINGS_FILE = path.join(DATA_DIR, 'settings.json');
|
||||||
|
|
||||||
|
// Ensure storage directory exists
|
||||||
|
if (!fs.existsSync(DATA_DIR)) {
|
||||||
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers for safe file I/O
|
||||||
|
function readJson(file, defaultVal) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(file)) {
|
||||||
|
const data = fs.readFileSync(file, 'utf8');
|
||||||
|
return JSON.parse(data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error reading ${file}:`, err);
|
||||||
|
}
|
||||||
|
return defaultVal;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeJson(file, data) {
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(file, JSON.stringify(data, null, 2), 'utf8');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error writing ${file}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed default memory if empty
|
||||||
|
if (!fs.existsSync(MEMORY_FILE)) {
|
||||||
|
writeJson(MEMORY_FILE, {
|
||||||
|
exclusions: [
|
||||||
|
{ itemName: 'Heavy winter coat', tripType: 'weekend_getaway', count: 1, lastDate: '2026-08-10' },
|
||||||
|
{ itemName: 'Full size hair dryer', tripType: 'beach', count: 2, lastDate: '2026-07-14' },
|
||||||
|
{ itemName: 'Extra pair of dress shoes', tripType: 'business', count: 1, lastDate: '2026-09-02' },
|
||||||
|
],
|
||||||
|
inclusions: [
|
||||||
|
{
|
||||||
|
itemName: 'Packable laundry detergent sheets',
|
||||||
|
category: 'clothing',
|
||||||
|
defaultBag: 'carry_on',
|
||||||
|
tripType: 'international_urban',
|
||||||
|
count: 2,
|
||||||
|
lastDate: '2026-06-20',
|
||||||
|
reason: 'Saves hotel laundry fees; perfect for sink-washing socks/shirts',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
itemName: 'Compact 3-prong outlet splitter',
|
||||||
|
category: 'tech',
|
||||||
|
defaultBag: 'personal_item',
|
||||||
|
tripType: 'business',
|
||||||
|
count: 2,
|
||||||
|
lastDate: '2026-09-02',
|
||||||
|
reason: 'Airport and hotel outlets are always limited or loose',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalTripsDebriefed: 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json({ limit: '10mb' }));
|
||||||
|
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||||
|
|
||||||
|
// Healthcheck endpoint for Docker & Traefik
|
||||||
|
app.get('/api/health', (_req, res) => {
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
app: 'loadout',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
uptimeSeconds: Math.floor(process.uptime()),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/trips
|
||||||
|
app.get('/api/trips', (_req, res) => {
|
||||||
|
const trips = readJson(TRIPS_FILE, []);
|
||||||
|
res.json(trips);
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/trips
|
||||||
|
app.post('/api/trips', (req, res) => {
|
||||||
|
const trip = req.body;
|
||||||
|
if (!trip || !trip.id) {
|
||||||
|
return res.status(400).json({ error: 'Invalid trip data' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const trips = readJson(TRIPS_FILE, []);
|
||||||
|
const index = trips.findIndex((t) => t.id === trip.id);
|
||||||
|
if (index >= 0) {
|
||||||
|
trips[index] = trip;
|
||||||
|
} else {
|
||||||
|
trips.unshift(trip);
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJson(TRIPS_FILE, trips);
|
||||||
|
res.json({ success: true, trip });
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/trips/:id
|
||||||
|
app.delete('/api/trips/:id', (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
let trips = readJson(TRIPS_FILE, []);
|
||||||
|
trips = trips.filter((t) => t.id !== id);
|
||||||
|
writeJson(TRIPS_FILE, trips);
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/memory
|
||||||
|
app.get('/api/memory', (_req, res) => {
|
||||||
|
const memory = readJson(MEMORY_FILE, { exclusions: [], inclusions: [], totalTripsDebriefed: 0 });
|
||||||
|
res.json(memory);
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/memory/debrief
|
||||||
|
app.post('/api/memory/debrief', (req, res) => {
|
||||||
|
const { tripId, debrief } = req.body;
|
||||||
|
if (!tripId || !debrief) {
|
||||||
|
return res.status(400).json({ error: 'Missing tripId or debrief data' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const trips = readJson(TRIPS_FILE, []);
|
||||||
|
const trip = trips.find((t) => t.id === tripId);
|
||||||
|
if (!trip) {
|
||||||
|
return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
trip.debrief = debrief;
|
||||||
|
writeJson(TRIPS_FILE, trips);
|
||||||
|
|
||||||
|
// Update memory
|
||||||
|
const memory = readJson(MEMORY_FILE, { exclusions: [], inclusions: [], totalTripsDebriefed: 0 });
|
||||||
|
memory.totalTripsDebriefed = (memory.totalTripsDebriefed || 0) + 1;
|
||||||
|
|
||||||
|
// Record deadweight exclusions
|
||||||
|
if (Array.isArray(debrief.excludedItemNames)) {
|
||||||
|
for (const itemName of debrief.excludedItemNames) {
|
||||||
|
const existing = memory.exclusions.find(
|
||||||
|
(e) => e.tripType === trip.tripType && e.itemName.toLowerCase() === itemName.toLowerCase()
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
existing.count += 1;
|
||||||
|
existing.lastDate = new Date().toISOString();
|
||||||
|
} else {
|
||||||
|
memory.exclusions.push({
|
||||||
|
itemName,
|
||||||
|
tripType: trip.tripType,
|
||||||
|
count: 1,
|
||||||
|
lastDate: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record wishlist inclusions
|
||||||
|
if (Array.isArray(debrief.includedItemNames)) {
|
||||||
|
for (const itemName of debrief.includedItemNames) {
|
||||||
|
const existing = memory.inclusions.find(
|
||||||
|
(i) => i.tripType === trip.tripType && i.itemName.toLowerCase() === itemName.toLowerCase()
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
existing.count += 1;
|
||||||
|
existing.lastDate = new Date().toISOString();
|
||||||
|
} else {
|
||||||
|
memory.inclusions.push({
|
||||||
|
itemName,
|
||||||
|
category: 'gear',
|
||||||
|
defaultBag: 'carry_on',
|
||||||
|
tripType: trip.tripType,
|
||||||
|
count: 1,
|
||||||
|
lastDate: new Date().toISOString(),
|
||||||
|
reason: `Wished for after trip: ${trip.title}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJson(MEMORY_FILE, memory);
|
||||||
|
res.json({ success: true, memory });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/settings
|
||||||
|
app.get('/api/settings', (_req, res) => {
|
||||||
|
const settings = readJson(SETTINGS_FILE, {
|
||||||
|
soundEnabled: true,
|
||||||
|
aiEnabled: false,
|
||||||
|
aiProvider: 'ollama',
|
||||||
|
ollamaUrl: 'http://localhost:11434',
|
||||||
|
ollamaModel: 'llama3.2',
|
||||||
|
apiKey: '',
|
||||||
|
});
|
||||||
|
res.json(settings);
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/settings
|
||||||
|
app.post('/api/settings', (req, res) => {
|
||||||
|
const settings = req.body;
|
||||||
|
writeJson(SETTINGS_FILE, settings);
|
||||||
|
res.json({ success: true, settings });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/ai/suggest
|
||||||
|
app.post('/api/ai/suggest', async (req, res) => {
|
||||||
|
const { prompt, tripDetails } = req.body;
|
||||||
|
const settings = readJson(SETTINGS_FILE, {});
|
||||||
|
|
||||||
|
if (!settings.aiEnabled) {
|
||||||
|
return res.status(400).json({ error: 'AI features are disabled in settings' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (settings.aiProvider === 'ollama') {
|
||||||
|
const ollamaUrl = (settings.ollamaUrl || 'http://localhost:11434').replace(/\/$/, '');
|
||||||
|
const response = await fetch(`${ollamaUrl}/api/generate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: settings.ollamaModel || 'llama3.2',
|
||||||
|
prompt: `You are a travel packing specialist. Based on this trip: ${JSON.stringify(tripDetails)}, suggest 3 to 5 hyper-specific, high-value packing items or travel wisdom tips. Respond in clear bullet points with concise reasoning.`,
|
||||||
|
stream: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Ollama returned status ${response.status}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
return res.json({ suggestion: data.response });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(400).json({ error: 'Configured AI provider not supported directly yet' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('AI suggest error:', err);
|
||||||
|
return res.status(500).json({ error: 'Failed to communicate with AI provider: ' + err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve frontend static build in production
|
||||||
|
const DIST_DIR = path.resolve(__dirname, '../dist');
|
||||||
|
if (fs.existsSync(DIST_DIR)) {
|
||||||
|
app.use(express.static(DIST_DIR));
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
if (req.method === 'GET' && !req.path.startsWith('/api')) {
|
||||||
|
return res.sendFile(path.join(DIST_DIR, 'index.html'));
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
|
console.log(`[LOADOUT] Server listening on port ${PORT}`);
|
||||||
|
console.log(`[LOADOUT] Data directory: ${DATA_DIR}`);
|
||||||
|
});
|
||||||
+510
@@ -0,0 +1,510 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Trip,
|
||||||
|
PackingItem,
|
||||||
|
BagType,
|
||||||
|
Category,
|
||||||
|
PackMemory,
|
||||||
|
AppSettings,
|
||||||
|
TripDebrief
|
||||||
|
} from './types';
|
||||||
|
import { StorageClient } from './utils/storage';
|
||||||
|
import { sound } from './utils/audio';
|
||||||
|
import { BoardingHeader } from './components/BoardingHeader';
|
||||||
|
import { BagOverview } from './components/BagOverview';
|
||||||
|
import { ItemRow } from './components/ItemRow';
|
||||||
|
import { TripModal } from './components/TripModal';
|
||||||
|
import { DebriefModal } from './components/DebriefModal';
|
||||||
|
import { MemoryDrawer } from './components/MemoryDrawer';
|
||||||
|
import { SettingsModal } from './components/SettingsModal';
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Filter,
|
||||||
|
CheckCircle2,
|
||||||
|
Luggage,
|
||||||
|
Sparkles,
|
||||||
|
Trash2,
|
||||||
|
Printer,
|
||||||
|
Share2,
|
||||||
|
RotateCcw,
|
||||||
|
Plane
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
const CATEGORY_TABS: { id: Category | 'all'; label: string }[] = [
|
||||||
|
{ id: 'all', label: 'ALL GEAR' },
|
||||||
|
{ id: 'clothing', label: 'CLOTHING' },
|
||||||
|
{ id: 'toiletries', label: 'TOILETRIES' },
|
||||||
|
{ id: 'tech', label: 'TECH & POWER' },
|
||||||
|
{ id: 'documents', label: 'DOCUMENTS' },
|
||||||
|
{ id: 'gear', label: 'EXPEDITION GEAR' },
|
||||||
|
{ id: 'wellness', label: 'WELLNESS / MED' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const [trips, setTrips] = useState<Trip[]>([]);
|
||||||
|
const [activeTripId, setActiveTripId] = useState<string | null>(null);
|
||||||
|
const [memory, setMemory] = useState<PackMemory>({
|
||||||
|
exclusions: [],
|
||||||
|
inclusions: [],
|
||||||
|
totalTripsDebriefed: 0,
|
||||||
|
});
|
||||||
|
const [settings, setSettings] = useState<AppSettings>(StorageClient.getSettings());
|
||||||
|
|
||||||
|
// UI Filter states
|
||||||
|
const [selectedBag, setSelectedBag] = useState<BagType | 'all'>('all');
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<Category | 'all'>('all');
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [filterMode, setFilterMode] = useState<'all' | 'unpacked' | 'packed'>('all');
|
||||||
|
|
||||||
|
// Quick item entry
|
||||||
|
const [newItemName, setNewItemName] = useState('');
|
||||||
|
const [newItemCategory, setNewItemCategory] = useState<Category>('clothing');
|
||||||
|
const [newItemBag, setNewItemBag] = useState<BagType>('carry_on');
|
||||||
|
|
||||||
|
// Modals
|
||||||
|
const [isTripModalOpen, setIsTripModalOpen] = useState(false);
|
||||||
|
const [isDebriefModalOpen, setIsDebriefModalOpen] = useState(false);
|
||||||
|
const [isMemoryOpen, setIsMemoryOpen] = useState(false);
|
||||||
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||||
|
|
||||||
|
// Load initial data
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadData() {
|
||||||
|
const loadedTrips = await StorageClient.getTrips();
|
||||||
|
const loadedMemory = await StorageClient.getMemory();
|
||||||
|
const loadedSettings = StorageClient.getSettings();
|
||||||
|
|
||||||
|
setTrips(loadedTrips);
|
||||||
|
setMemory(loadedMemory);
|
||||||
|
setSettings(loadedSettings);
|
||||||
|
sound.enabled = loadedSettings.soundEnabled;
|
||||||
|
|
||||||
|
const savedActiveId = StorageClient.getActiveTripId();
|
||||||
|
if (savedActiveId && loadedTrips.some((t) => t.id === savedActiveId)) {
|
||||||
|
setActiveTripId(savedActiveId);
|
||||||
|
} else if (loadedTrips.length > 0) {
|
||||||
|
setActiveTripId(loadedTrips[0].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const activeTrip = trips.find((t) => t.id === activeTripId) || null;
|
||||||
|
|
||||||
|
const handleSelectTrip = (id: string) => {
|
||||||
|
setActiveTripId(id);
|
||||||
|
StorageClient.setActiveTripId(id);
|
||||||
|
setSelectedBag('all');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveTrip = async (trip: Trip) => {
|
||||||
|
await StorageClient.saveTrip(trip);
|
||||||
|
const updatedTrips = await StorageClient.getTrips();
|
||||||
|
setTrips(updatedTrips);
|
||||||
|
setActiveTripId(trip.id);
|
||||||
|
StorageClient.setActiveTripId(trip.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteTrip = async (tripId: string) => {
|
||||||
|
if (!window.confirm('Are you sure you want to delete this trip manifest?')) return;
|
||||||
|
sound.playClick();
|
||||||
|
await StorageClient.deleteTrip(tripId);
|
||||||
|
const updatedTrips = await StorageClient.getTrips();
|
||||||
|
setTrips(updatedTrips);
|
||||||
|
if (activeTripId === tripId) {
|
||||||
|
setActiveTripId(updatedTrips[0]?.id || null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Item actions
|
||||||
|
const handleTogglePacked = async (itemId: string) => {
|
||||||
|
if (!activeTrip) return;
|
||||||
|
const updatedItems = activeTrip.items.map((i) =>
|
||||||
|
i.id === itemId ? { ...i, packed: !i.packed } : i
|
||||||
|
);
|
||||||
|
const updatedTrip = { ...activeTrip, items: updatedItems };
|
||||||
|
await StorageClient.saveTrip(updatedTrip);
|
||||||
|
setTrips((prev) => prev.map((t) => (t.id === activeTrip.id ? updatedTrip : t)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateQuantity = async (itemId: string, delta: number) => {
|
||||||
|
if (!activeTrip) return;
|
||||||
|
const updatedItems = activeTrip.items.map((i) => {
|
||||||
|
if (i.id === itemId) {
|
||||||
|
const nextQty = Math.max(1, i.quantity + delta);
|
||||||
|
return { ...i, quantity: nextQty };
|
||||||
|
}
|
||||||
|
return i;
|
||||||
|
});
|
||||||
|
const updatedTrip = { ...activeTrip, items: updatedItems };
|
||||||
|
await StorageClient.saveTrip(updatedTrip);
|
||||||
|
setTrips((prev) => prev.map((t) => (t.id === activeTrip.id ? updatedTrip : t)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateBag = async (itemId: string, newBag: BagType) => {
|
||||||
|
if (!activeTrip) return;
|
||||||
|
const updatedItems = activeTrip.items.map((i) =>
|
||||||
|
i.id === itemId ? { ...i, bag: newBag } : i
|
||||||
|
);
|
||||||
|
const updatedTrip = { ...activeTrip, items: updatedItems };
|
||||||
|
await StorageClient.saveTrip(updatedTrip);
|
||||||
|
setTrips((prev) => prev.map((t) => (t.id === activeTrip.id ? updatedTrip : t)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteItem = async (itemId: string) => {
|
||||||
|
if (!activeTrip) return;
|
||||||
|
const updatedItems = activeTrip.items.filter((i) => i.id !== itemId);
|
||||||
|
const updatedTrip = { ...activeTrip, items: updatedItems };
|
||||||
|
await StorageClient.saveTrip(updatedTrip);
|
||||||
|
setTrips((prev) => prev.map((t) => (t.id === activeTrip.id ? updatedTrip : t)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddItem = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!activeTrip || !newItemName.trim()) return;
|
||||||
|
|
||||||
|
sound.playStamp();
|
||||||
|
const newItem: PackingItem = {
|
||||||
|
id: `item_${Date.now()}_${Math.random().toString(36).substring(2, 6)}`,
|
||||||
|
name: newItemName.trim(),
|
||||||
|
category: newItemCategory,
|
||||||
|
quantity: 1,
|
||||||
|
bag: newItemBag,
|
||||||
|
packed: false,
|
||||||
|
source: 'user',
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedTrip = { ...activeTrip, items: [newItem, ...activeTrip.items] };
|
||||||
|
await StorageClient.saveTrip(updatedTrip);
|
||||||
|
setTrips((prev) => prev.map((t) => (t.id === activeTrip.id ? updatedTrip : t)));
|
||||||
|
setNewItemName('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveDebrief = async (debrief: TripDebrief) => {
|
||||||
|
if (!activeTrip) return;
|
||||||
|
const updated = await StorageClient.recordDebrief(activeTrip.id, debrief);
|
||||||
|
if (updated) {
|
||||||
|
setTrips((prev) => prev.map((t) => (t.id === updated.id ? updated : t)));
|
||||||
|
const refreshedMemory = await StorageClient.getMemory();
|
||||||
|
setMemory(refreshedMemory);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearMemoryItem = async (type: 'exclusion' | 'inclusion', itemName: string) => {
|
||||||
|
const updated = { ...memory };
|
||||||
|
if (type === 'exclusion') {
|
||||||
|
updated.exclusions = updated.exclusions.filter((e) => e.itemName !== itemName);
|
||||||
|
} else {
|
||||||
|
updated.inclusions = updated.inclusions.filter((i) => i.itemName !== itemName);
|
||||||
|
}
|
||||||
|
setMemory(updated);
|
||||||
|
localStorage.setItem('loadout_memory_v1', JSON.stringify(updated));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveSettings = (newSettings: AppSettings) => {
|
||||||
|
setSettings(newSettings);
|
||||||
|
StorageClient.saveSettings(newSettings);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filtered items
|
||||||
|
const filteredItems = (activeTrip?.items || []).filter((item) => {
|
||||||
|
if (selectedBag !== 'all' && item.bag !== selectedBag) return false;
|
||||||
|
if (selectedCategory !== 'all' && item.category !== selectedCategory) return false;
|
||||||
|
if (filterMode === 'unpacked' && item.packed) return false;
|
||||||
|
if (filterMode === 'packed' && !item.packed) return false;
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
return (
|
||||||
|
item.name.toLowerCase().includes(q) ||
|
||||||
|
(item.notes && item.notes.toLowerCase().includes(q))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#F4F1EA] text-[#141518] flex flex-col font-sans">
|
||||||
|
{/* Boarding Pass Telemetry Header */}
|
||||||
|
<BoardingHeader
|
||||||
|
activeTrip={activeTrip}
|
||||||
|
trips={trips}
|
||||||
|
onSelectTrip={handleSelectTrip}
|
||||||
|
onNewTrip={() => setIsTripModalOpen(true)}
|
||||||
|
onOpenDebrief={() => setIsDebriefModalOpen(true)}
|
||||||
|
onOpenMemory={() => setIsMemoryOpen(true)}
|
||||||
|
onOpenSettings={() => setIsSettingsOpen(true)}
|
||||||
|
soundEnabled={settings.soundEnabled}
|
||||||
|
onToggleSound={() => {
|
||||||
|
const next = !settings.soundEnabled;
|
||||||
|
sound.enabled = next;
|
||||||
|
handleSaveSettings({ ...settings, soundEnabled: next });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main Content Area */}
|
||||||
|
<main className="flex-1 max-w-6xl w-full mx-auto px-3 sm:px-6 py-6">
|
||||||
|
{activeTrip ? (
|
||||||
|
<>
|
||||||
|
{/* Bag Allocation Gauges ("What Goes in What Bag") */}
|
||||||
|
<BagOverview
|
||||||
|
items={activeTrip.items}
|
||||||
|
selectedBag={selectedBag}
|
||||||
|
onSelectBag={setSelectedBag}
|
||||||
|
luggageConfig={activeTrip.luggageConfig}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Quick Add Perforated Coupon Strip */}
|
||||||
|
<div className="mb-6 bg-ticket-100 border-2 border-ink p-3 shadow-card-hard-sm">
|
||||||
|
<div className="flex items-center gap-2 mb-2 font-mono text-xs font-bold uppercase text-ink">
|
||||||
|
<Plus size={14} className="text-gate-orange" />
|
||||||
|
<span>EXPEDITION ADDITION // FAST ITEM STOWAGE</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleAddItem} className="flex flex-col sm:flex-row gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newItemName}
|
||||||
|
onChange={(e) => setNewItemName(e.target.value)}
|
||||||
|
placeholder="Enter item name (e.g. Passport pouch, extra camera lens, dry bag)..."
|
||||||
|
className="flex-1 bg-white border-2 border-ink px-3 py-2 text-xs font-grotesk font-bold text-ink shadow-xs focus:outline-none focus:ring-2 focus:ring-canary"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={newItemCategory}
|
||||||
|
onChange={(e) => setNewItemCategory(e.target.value as Category)}
|
||||||
|
className="bg-white border-2 border-ink px-2 py-2 font-mono text-xs font-bold shadow-xs cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value="clothing">Clothing</option>
|
||||||
|
<option value="toiletries">Toiletries</option>
|
||||||
|
<option value="tech">Tech & Power</option>
|
||||||
|
<option value="documents">Documents</option>
|
||||||
|
<option value="gear">Expedition Gear</option>
|
||||||
|
<option value="wellness">Wellness / Med</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={newItemBag}
|
||||||
|
onChange={(e) => setNewItemBag(e.target.value as BagType)}
|
||||||
|
className="bg-white border-2 border-ink px-2 py-2 font-mono text-xs font-bold shadow-xs cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value="personal_item">Personal Item (Cabin)</option>
|
||||||
|
<option value="carry_on">Carry-On Roller</option>
|
||||||
|
<option value="checked">Checked Bag</option>
|
||||||
|
<option value="worn">Worn on Person</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="bg-canary hover:bg-canary-hover border-2 border-ink px-4 py-2 font-grotesk font-bold text-xs uppercase shadow-card-hard-sm active:translate-x-0.5 active:translate-y-0.5"
|
||||||
|
>
|
||||||
|
STOW ITEM
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter & Search Bar */}
|
||||||
|
<div className="mb-4 flex flex-col md:flex-row md:items-center justify-between gap-3 border-b-2 border-ink pb-3">
|
||||||
|
{/* Category Pills */}
|
||||||
|
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 max-w-full">
|
||||||
|
{CATEGORY_TABS.map((cat) => (
|
||||||
|
<button
|
||||||
|
key={cat.id}
|
||||||
|
onClick={() => {
|
||||||
|
sound.playClick();
|
||||||
|
setSelectedCategory(cat.id);
|
||||||
|
}}
|
||||||
|
className={`font-mono text-xs font-bold px-2.5 py-1 border border-ink whitespace-nowrap transition-all ${
|
||||||
|
selectedCategory === cat.id
|
||||||
|
? 'bg-ink text-ticket-100 shadow-card-hard-sm'
|
||||||
|
: 'bg-white text-ink hover:bg-ticket-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cat.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search & State Filter */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative">
|
||||||
|
<Search
|
||||||
|
size={14}
|
||||||
|
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-ink-muted"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Search manifest..."
|
||||||
|
className="pl-8 pr-3 py-1 bg-white border border-ink font-mono text-xs w-36 sm:w-48 focus:outline-none focus:ring-1 focus:ring-ink"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex border border-ink bg-white font-mono text-xs">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
sound.playClick();
|
||||||
|
setFilterMode('all');
|
||||||
|
}}
|
||||||
|
className={`px-2 py-1 ${filterMode === 'all' ? 'bg-ink text-ticket-100 font-bold' : 'text-ink'}`}
|
||||||
|
>
|
||||||
|
ALL
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
sound.playClick();
|
||||||
|
setFilterMode('unpacked');
|
||||||
|
}}
|
||||||
|
className={`px-2 py-1 border-l border-ink ${filterMode === 'unpacked' ? 'bg-ink text-ticket-100 font-bold' : 'text-ink'}`}
|
||||||
|
>
|
||||||
|
UNPACKED
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
sound.playClick();
|
||||||
|
setFilterMode('packed');
|
||||||
|
}}
|
||||||
|
className={`px-2 py-1 border-l border-ink ${filterMode === 'packed' ? 'bg-ink text-ticket-100 font-bold' : 'text-ink'}`}
|
||||||
|
>
|
||||||
|
STOWED
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Manifest Item Count & Bag Filter Notice */}
|
||||||
|
<div className="flex items-center justify-between text-xs font-mono text-ink-muted mb-3">
|
||||||
|
<span>
|
||||||
|
DISPLAYING {filteredItems.length} ITEM(S)
|
||||||
|
{selectedBag !== 'all' && (
|
||||||
|
<span className="text-gate-orange font-bold ml-1">
|
||||||
|
(FILTERED TO {selectedBag.toUpperCase()})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => window.print()}
|
||||||
|
className="hover:text-ink flex items-center gap-1 font-bold"
|
||||||
|
title="Print paper boarding manifest"
|
||||||
|
>
|
||||||
|
<Printer size={13} />
|
||||||
|
<span>PRINT MANIFEST</span>
|
||||||
|
</button>
|
||||||
|
<span>•</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteTrip(activeTrip.id)}
|
||||||
|
className="hover:text-stamp-red flex items-center gap-1 font-bold text-stamp-red/70"
|
||||||
|
title="Delete voyage"
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
<span>DELETE VOYAGE</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List of Perforated Stubs */}
|
||||||
|
{filteredItems.length === 0 ? (
|
||||||
|
<div className="p-12 border-2 border-dashed border-ink/40 text-center font-mono text-ink-muted bg-ticket-50/50">
|
||||||
|
<p className="text-sm font-bold uppercase text-ink mb-1">
|
||||||
|
NO ITEMS MATCHING THIS FILTER
|
||||||
|
</p>
|
||||||
|
<p className="text-xs">
|
||||||
|
Adjust your bag or category selection above, or add a new custom item.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{filteredItems.map((item) => (
|
||||||
|
<ItemRow
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
onTogglePacked={handleTogglePacked}
|
||||||
|
onUpdateQuantity={handleUpdateQuantity}
|
||||||
|
onUpdateBag={handleUpdateBag}
|
||||||
|
onDelete={handleDeleteItem}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
/* Empty State: Terminal Welcome */
|
||||||
|
<div className="max-w-xl mx-auto my-12 p-8 bg-ticket-100 border-2 border-ink shadow-card-hard text-center">
|
||||||
|
<div className="w-16 h-16 bg-canary border-2 border-ink mx-auto flex items-center justify-center shadow-card-hard-sm mb-4">
|
||||||
|
<Plane className="text-ink -rotate-45" size={32} />
|
||||||
|
</div>
|
||||||
|
<h2 className="font-display text-3xl tracking-wide uppercase text-ink mb-2">
|
||||||
|
DISPATCH TERMINAL READY
|
||||||
|
</h2>
|
||||||
|
<p className="font-sans text-sm text-ink-muted mb-6">
|
||||||
|
You currently have no active packing manifests. Create a new trip to generate an intelligent list, assign items to your bags, and begin your loadout.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
sound.playClick();
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Plus size={18} strokeWidth={3} />
|
||||||
|
<span>DISPATCH FIRST VOYAGE</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="border-t-2 border-ink bg-ticket-200 py-3 px-4 sm:px-6 text-center font-mono text-xs text-ink-muted">
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-between max-w-6xl mx-auto gap-2">
|
||||||
|
<span>LOADOUT // TRAVEL MANIFEST & BAG ALLOCATION SYSTEM</span>
|
||||||
|
<span>SELF-HOSTED AT VLOSER.US • DEBRIEF-DRIVEN INTELLIGENCE</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
|
<TripModal
|
||||||
|
isOpen={isTripModalOpen}
|
||||||
|
onClose={() => setIsTripModalOpen(false)}
|
||||||
|
onSaveTrip={handleSaveTrip}
|
||||||
|
memory={memory}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{activeTrip && (
|
||||||
|
<DebriefModal
|
||||||
|
isOpen={isDebriefModalOpen}
|
||||||
|
onClose={() => setIsDebriefModalOpen(false)}
|
||||||
|
trip={activeTrip}
|
||||||
|
onSaveDebrief={handleSaveDebrief}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MemoryDrawer
|
||||||
|
isOpen={isMemoryOpen}
|
||||||
|
onClose={() => setIsMemoryOpen(false)}
|
||||||
|
memory={memory}
|
||||||
|
onClearMemoryItem={handleClearMemoryItem}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SettingsModal
|
||||||
|
isOpen={isSettingsOpen}
|
||||||
|
onClose={() => setIsSettingsOpen(false)}
|
||||||
|
settings={settings}
|
||||||
|
onSaveSettings={handleSaveSettings}
|
||||||
|
trips={trips}
|
||||||
|
memory={memory}
|
||||||
|
onImportData={({ trips: impTrips, memory: impMemory }) => {
|
||||||
|
setTrips(impTrips);
|
||||||
|
setMemory(impMemory);
|
||||||
|
localStorage.setItem('loadout_trips_v1', JSON.stringify(impTrips));
|
||||||
|
localStorage.setItem('loadout_memory_v1', JSON.stringify(impMemory));
|
||||||
|
if (impTrips.length > 0) setActiveTripId(impTrips[0].id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { BagType, PackingItem, LuggageConfig } from '../types';
|
||||||
|
import { sound } from '../utils/audio';
|
||||||
|
import { Backpack, Briefcase, Package, UserCheck, Layers, CheckCircle2 } from 'lucide-react';
|
||||||
|
|
||||||
|
interface BagOverviewProps {
|
||||||
|
items: PackingItem[];
|
||||||
|
selectedBag: BagType | 'all';
|
||||||
|
onSelectBag: (bag: BagType | 'all') => void;
|
||||||
|
luggageConfig: LuggageConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BagOverview: React.FC<BagOverviewProps> = ({
|
||||||
|
items,
|
||||||
|
selectedBag,
|
||||||
|
onSelectBag,
|
||||||
|
luggageConfig,
|
||||||
|
}) => {
|
||||||
|
const bags: {
|
||||||
|
type: BagType;
|
||||||
|
label: string;
|
||||||
|
sub: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
tsaTip: string;
|
||||||
|
tagCode: string;
|
||||||
|
visible: boolean;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
type: 'personal_item',
|
||||||
|
label: 'PERSONAL ITEM',
|
||||||
|
sub: 'Under-Seat / Daypack',
|
||||||
|
icon: <Backpack size={20} strokeWidth={2.2} />,
|
||||||
|
tsaTip: 'Cabin only: Passports, power banks, essential meds, laptop',
|
||||||
|
tagCode: 'CABIN-01',
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'carry_on',
|
||||||
|
label: 'CARRY-ON ROLLER',
|
||||||
|
sub: 'Overhead Compartment',
|
||||||
|
icon: <Briefcase size={20} strokeWidth={2.2} />,
|
||||||
|
tsaTip: 'Max 3.4oz / 100ml liquids pouch. Clothes cubes & toiletries',
|
||||||
|
tagCode: 'CABIN-02',
|
||||||
|
visible: luggageConfig !== 'backpack_only',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'checked',
|
||||||
|
label: 'CHECKED LUGGAGE',
|
||||||
|
sub: 'Cargo Hold',
|
||||||
|
icon: <Package size={20} strokeWidth={2.2} />,
|
||||||
|
tsaTip: 'Liquids > 100ml, trekking poles, pocket knives, heavy layers',
|
||||||
|
tagCode: 'HOLD-03',
|
||||||
|
visible: luggageConfig === 'checked_bag',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'worn',
|
||||||
|
label: 'WORN ON PERSON',
|
||||||
|
sub: 'Travel Day Outfit',
|
||||||
|
icon: <UserCheck size={20} strokeWidth={2.2} />,
|
||||||
|
tsaTip: 'Heaviest boots & airport jacket to minimize luggage bulk',
|
||||||
|
tagCode: 'PAX-WORN',
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-xs uppercase font-bold text-ink tracking-wider">
|
||||||
|
LUGGAGE ALLOCATION // "WHAT GOES IN WHAT BAG"
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
sound.playClick();
|
||||||
|
onSelectBag('all');
|
||||||
|
}}
|
||||||
|
className={`font-mono text-xs font-bold px-2.5 py-1 border border-ink transition-all flex items-center gap-1.5 ${
|
||||||
|
selectedBag === 'all'
|
||||||
|
? 'bg-ink text-ticket-100 shadow-card-hard-sm'
|
||||||
|
: 'bg-ticket-100 text-ink hover:bg-ticket-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Layers size={13} />
|
||||||
|
<span>VIEW ALL BAGS ({items.length})</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
|
{bags
|
||||||
|
.filter((b) => b.visible)
|
||||||
|
.map((b) => {
|
||||||
|
const bagItems = items.filter((i) => i.bag === b.type);
|
||||||
|
const bagPacked = bagItems.filter((i) => i.packed).length;
|
||||||
|
const bagTotal = bagItems.length;
|
||||||
|
const pct = bagTotal > 0 ? Math.round((bagPacked / bagTotal) * 100) : 0;
|
||||||
|
const isSelected = selectedBag === b.type;
|
||||||
|
const isFull = bagTotal > 0 && bagPacked === bagTotal;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={b.type}
|
||||||
|
onClick={() => {
|
||||||
|
sound.playClick();
|
||||||
|
onSelectBag(isSelected ? 'all' : b.type);
|
||||||
|
}}
|
||||||
|
className={`relative text-left p-3.5 border-2 border-ink transition-all flex flex-col justify-between ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-canary-light shadow-card-hard border-ink translate-x-0.5 translate-y-0.5'
|
||||||
|
: 'bg-ticket-50 hover:bg-ticket-100 shadow-card-hard-sm'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Perforation holes on card edges */}
|
||||||
|
<div className="ticket-notch-left" />
|
||||||
|
<div className="ticket-notch-right" />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{/* Top Barcode Header */}
|
||||||
|
<div className="flex items-center justify-between border-b border-ink/20 pb-2 mb-2 font-mono text-[10px]">
|
||||||
|
<span className="bg-ink text-ticket-100 px-1 py-0.5 font-bold uppercase">
|
||||||
|
{b.tagCode}
|
||||||
|
</span>
|
||||||
|
<span className="text-ink-muted tracking-tight">
|
||||||
|
{b.sub}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title & Icon */}
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-1">
|
||||||
|
<div className="flex items-center gap-2 text-ink">
|
||||||
|
<span className="text-ink">{b.icon}</span>
|
||||||
|
<span className="font-display text-xl tracking-wide uppercase leading-tight">
|
||||||
|
{b.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isFull && (
|
||||||
|
<CheckCircle2 size={18} className="text-stamp-green shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TSA Advisory Tip */}
|
||||||
|
<p className="font-sans text-[11px] text-ink-muted leading-tight mb-3">
|
||||||
|
{b.tsaTip}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Meter and Stowed Count */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between font-mono text-xs font-bold mb-1">
|
||||||
|
<span className="text-ink">
|
||||||
|
{bagPacked}/{bagTotal} ITEMS
|
||||||
|
</span>
|
||||||
|
<span className={isFull ? 'text-stamp-green' : 'text-ink-muted'}>
|
||||||
|
{pct}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full bg-ticket-200 border border-ink h-2.5 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full transition-all duration-200 ${
|
||||||
|
isFull ? 'bg-stamp-green' : 'bg-gate-orange'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Trip } from '../types';
|
||||||
|
import { sound } from '../utils/audio';
|
||||||
|
import {
|
||||||
|
Plane,
|
||||||
|
Luggage,
|
||||||
|
Volume2,
|
||||||
|
VolumeX,
|
||||||
|
BrainCircuit,
|
||||||
|
Settings as SettingsIcon,
|
||||||
|
Plus,
|
||||||
|
RotateCcw,
|
||||||
|
Sparkles
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface BoardingHeaderProps {
|
||||||
|
activeTrip: Trip | null;
|
||||||
|
trips: Trip[];
|
||||||
|
onSelectTrip: (tripId: string) => void;
|
||||||
|
onNewTrip: () => void;
|
||||||
|
onOpenDebrief: () => void;
|
||||||
|
onOpenMemory: () => void;
|
||||||
|
onOpenSettings: () => void;
|
||||||
|
soundEnabled: boolean;
|
||||||
|
onToggleSound: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BoardingHeader: React.FC<BoardingHeaderProps> = ({
|
||||||
|
activeTrip,
|
||||||
|
trips,
|
||||||
|
onSelectTrip,
|
||||||
|
onNewTrip,
|
||||||
|
onOpenDebrief,
|
||||||
|
onOpenMemory,
|
||||||
|
onOpenSettings,
|
||||||
|
soundEnabled,
|
||||||
|
onToggleSound,
|
||||||
|
}) => {
|
||||||
|
const totalItems = activeTrip ? activeTrip.items.length : 0;
|
||||||
|
const packedItems = activeTrip ? activeTrip.items.filter((i) => i.packed).length : 0;
|
||||||
|
const percentPacked = totalItems > 0 ? Math.round((packedItems / totalItems) * 100) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="border-b-2 border-ink bg-ticket-100 shadow-card-hard">
|
||||||
|
{/* Top Telemetry Flight Ticker */}
|
||||||
|
<div className="bg-ink text-ticket-100 px-3 py-1.5 flex items-center justify-between text-xs font-mono tracking-wider border-b border-ink-light">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="inline-block w-2 h-2 rounded-full bg-canary animate-pulse" />
|
||||||
|
<span className="font-bold uppercase tracking-widest text-canary">LOADOUT // MANIFEST</span>
|
||||||
|
<span className="hidden sm:inline text-ink-faint">|</span>
|
||||||
|
<span className="hidden sm:inline text-ticket-200">
|
||||||
|
{activeTrip ? `REF: ${activeTrip.routeCode}` : 'DISPATCH: READY'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onToggleSound();
|
||||||
|
sound.playClick();
|
||||||
|
}}
|
||||||
|
title={soundEnabled ? 'Mute sound effects' : 'Enable tactile sounds'}
|
||||||
|
className="flex items-center gap-1 hover:text-canary transition-colors"
|
||||||
|
>
|
||||||
|
{soundEnabled ? <Volume2 size={13} /> : <VolumeX size={13} />}
|
||||||
|
<span className="hidden md:inline">{soundEnabled ? 'AUDIO: ON' : 'AUDIO: OFF'}</span>
|
||||||
|
</button>
|
||||||
|
<span className="text-ink-faint">|</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
sound.playClick();
|
||||||
|
onOpenMemory();
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1 text-canary hover:underline"
|
||||||
|
title="View accumulated packing wisdom & debrief history"
|
||||||
|
>
|
||||||
|
<BrainCircuit size={13} />
|
||||||
|
<span className="font-bold">FLIGHT LOG & MEMORY</span>
|
||||||
|
</button>
|
||||||
|
<span className="text-ink-faint">|</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
sound.playClick();
|
||||||
|
onOpenSettings();
|
||||||
|
}}
|
||||||
|
className="hover:text-canary transition-colors"
|
||||||
|
title="App settings & AI dispatch"
|
||||||
|
>
|
||||||
|
<SettingsIcon size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Boarding Pass Header Card */}
|
||||||
|
<div className="px-3 sm:px-6 py-4 flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
{/* Left: Voyage & Route Code */}
|
||||||
|
<div className="flex items-start sm:items-center gap-3">
|
||||||
|
<div className="w-12 h-12 bg-canary border-2 border-ink flex items-center justify-center shadow-card-hard-sm shrink-0">
|
||||||
|
<Plane className="text-ink transform -rotate-45" size={26} strokeWidth={2.5} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<h1 className="font-display text-3xl sm:text-4xl tracking-wide uppercase leading-none text-ink">
|
||||||
|
{activeTrip ? activeTrip.title : 'NO VOYAGE SELECTED'}
|
||||||
|
</h1>
|
||||||
|
{activeTrip?.debrief && (
|
||||||
|
<span className="stamp-badge stamp-badge-packed text-[11px] py-0.5">
|
||||||
|
DEBRIEFED
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mt-1 font-mono text-xs text-ink-muted flex-wrap">
|
||||||
|
{activeTrip ? (
|
||||||
|
<>
|
||||||
|
<span className="bg-ticket-200 border border-ticket-border px-1.5 py-0.5 font-bold text-ink">
|
||||||
|
{activeTrip.routeCode}
|
||||||
|
</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{activeTrip.destination}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>
|
||||||
|
{activeTrip.durationDays}D / {activeTrip.durationNights}N
|
||||||
|
</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span className="uppercase text-gate-orange font-bold">
|
||||||
|
{activeTrip.tripType.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
{activeTrip.hasLaundry && (
|
||||||
|
<span className="bg-stamp-green-light text-stamp-green border border-stamp-green/30 px-1 py-0.5 text-[10px] uppercase font-bold">
|
||||||
|
LAUNDRY ACCESS
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span>CREATE A NEW TRAVEL MANIFEST TO BEGIN PACKING</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Center / Right: Voyage switcher and Actions */}
|
||||||
|
<div className="flex items-center gap-2 sm:gap-3 flex-wrap">
|
||||||
|
{trips.length > 0 && (
|
||||||
|
<select
|
||||||
|
value={activeTrip?.id || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
sound.playClick();
|
||||||
|
onSelectTrip(e.target.value);
|
||||||
|
}}
|
||||||
|
className="bg-white border-2 border-ink font-mono text-xs px-2.5 py-2 shadow-card-hard-sm focus:outline-none focus:ring-2 focus:ring-canary"
|
||||||
|
>
|
||||||
|
{trips.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>
|
||||||
|
{t.routeCode} — {t.title} ({t.durationDays}D)
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
sound.playClick();
|
||||||
|
onNewTrip();
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1.5 bg-canary hover:bg-canary-hover text-ink font-grotesk font-bold text-xs uppercase px-3.5 py-2 border-2 border-ink shadow-card-hard-sm active:translate-x-0.5 active:translate-y-0.5 transition-all"
|
||||||
|
>
|
||||||
|
<Plus size={15} strokeWidth={3} />
|
||||||
|
<span>NEW VOYAGE</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{activeTrip && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
sound.playStamp();
|
||||||
|
onOpenDebrief();
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1.5 bg-gate-orange hover:bg-gate-hover text-white font-grotesk font-bold text-xs uppercase px-3 py-2 border-2 border-ink shadow-card-hard-sm active:translate-x-0.5 active:translate-y-0.5 transition-all"
|
||||||
|
title="Conduct post-trip debrief to log what to exclude/include next time"
|
||||||
|
>
|
||||||
|
<RotateCcw size={14} strokeWidth={2.5} />
|
||||||
|
<span>{activeTrip.debrief ? 'EDIT DEBRIEF' : 'TRIP DEBRIEF'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress & Stowing Tally Bar */}
|
||||||
|
{activeTrip && (
|
||||||
|
<div className="bg-ticket-200 border-t border-ticket-border px-3 sm:px-6 py-2 flex flex-col sm:flex-row sm:items-center justify-between gap-2 text-xs font-mono">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="font-bold uppercase text-ink">
|
||||||
|
TOTAL MANIFEST LOAD:
|
||||||
|
</span>
|
||||||
|
<span className="bg-ink text-ticket-100 px-2 py-0.5 font-bold">
|
||||||
|
{packedItems} / {totalItems} STOWED
|
||||||
|
</span>
|
||||||
|
<span className="font-bold text-ink">
|
||||||
|
({percentPacked}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 w-full sm:w-64">
|
||||||
|
<div className="w-full bg-ticket-50 border border-ink h-3.5 relative overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full transition-all duration-300 ${
|
||||||
|
percentPacked === 100 ? 'bg-stamp-green' : 'bg-canary'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${percentPacked}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{percentPacked === 100 && (
|
||||||
|
<span className="stamp-badge stamp-badge-packed text-[10px] py-0 px-1 font-bold whitespace-nowrap">
|
||||||
|
ALL STOWED
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Trip, TripDebrief, Category, BagType } from '../types';
|
||||||
|
import { sound } from '../utils/audio';
|
||||||
|
import { X, Check, AlertTriangle, Plus, Trash2, Star, Sparkles, CheckCircle2 } from 'lucide-react';
|
||||||
|
|
||||||
|
interface DebriefModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
trip: Trip;
|
||||||
|
onSaveDebrief: (debrief: TripDebrief) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DebriefModal: React.FC<DebriefModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
trip,
|
||||||
|
onSaveDebrief,
|
||||||
|
}) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
// Initialize with existing debrief data if already debriefed
|
||||||
|
const [excludedItems, setExcludedItems] = useState<string[]>(
|
||||||
|
trip.debrief?.excludedItemNames || []
|
||||||
|
);
|
||||||
|
const [includedItems, setIncludedItems] = useState<string[]>(
|
||||||
|
trip.debrief?.includedItemNames || []
|
||||||
|
);
|
||||||
|
const [newMissingItem, setNewMissingItem] = useState('');
|
||||||
|
const [notes, setNotes] = useState(trip.debrief?.notes || '');
|
||||||
|
const [rating, setRating] = useState(trip.debrief?.rating || 4);
|
||||||
|
|
||||||
|
const toggleDeadweight = (itemName: string) => {
|
||||||
|
sound.playClick();
|
||||||
|
if (excludedItems.includes(itemName)) {
|
||||||
|
setExcludedItems(excludedItems.filter((i) => i !== itemName));
|
||||||
|
} else {
|
||||||
|
setExcludedItems([...excludedItems, itemName]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddMissingItem = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const clean = newMissingItem.trim();
|
||||||
|
if (!clean) return;
|
||||||
|
if (!includedItems.includes(clean)) {
|
||||||
|
sound.playClick();
|
||||||
|
setIncludedItems([...includedItems, clean]);
|
||||||
|
}
|
||||||
|
setNewMissingItem('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveMissingItem = (name: string) => {
|
||||||
|
sound.playClick();
|
||||||
|
setIncludedItems(includedItems.filter((i) => i !== name));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleComplete = () => {
|
||||||
|
sound.playStamp();
|
||||||
|
const debriefData: TripDebrief = {
|
||||||
|
notes,
|
||||||
|
rating,
|
||||||
|
completedAt: new Date().toISOString(),
|
||||||
|
excludedItemNames: excludedItems,
|
||||||
|
includedItemNames: includedItems,
|
||||||
|
};
|
||||||
|
onSaveDebrief(debriefData);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
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="bg-ticket-100 border-2 border-ink shadow-card-hard max-w-2xl w-full my-auto overflow-hidden animate-in fade-in zoom-in-95 duration-150">
|
||||||
|
{/* Top Header */}
|
||||||
|
<div className="bg-ink text-ticket-100 p-4 flex items-center justify-between border-b-2 border-gate-orange">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-3 h-3 bg-gate-orange rotate-45" />
|
||||||
|
<div>
|
||||||
|
<h2 className="font-display text-2xl tracking-wide uppercase text-white">
|
||||||
|
VOYAGE DEBRIEF & LEARNING LOOP
|
||||||
|
</h2>
|
||||||
|
<p className="font-mono text-xs text-ticket-200">
|
||||||
|
{trip.routeCode} — {trip.title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
sound.playClick();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="text-ticket-200 hover:text-white p-1"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Advisory banner */}
|
||||||
|
<div className="bg-canary-light border-b-2 border-ink px-4 py-2.5 flex items-start gap-2 text-xs font-sans text-ink">
|
||||||
|
<Sparkles size={16} className="text-gate-orange shrink-0 mt-0.5" />
|
||||||
|
<p>
|
||||||
|
<strong>Continuous Learning Engine:</strong> Record what you packed but never used (to exclude or reduce next time), and what you wished you had packed (to automatically suggest in future {trip.tripType.replace('_', ' ')} voyages).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 sm:p-6 max-h-[75vh] overflow-y-auto space-y-6">
|
||||||
|
{/* SECTION 1: WHAT TO EXCLUDE (DEADWEIGHT) */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<label className="font-mono text-xs font-bold uppercase text-stamp-red flex items-center gap-1.5">
|
||||||
|
<AlertTriangle size={14} />
|
||||||
|
<span>1. WHAT SHOULD HAVE BEEN EXCLUDED? ("DEADWEIGHT")</span>
|
||||||
|
</label>
|
||||||
|
<span className="font-mono text-xs font-bold bg-stamp-red text-white px-2 py-0.5">
|
||||||
|
{excludedItems.length} MARKED
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="font-sans text-xs text-ink-muted mb-3">
|
||||||
|
Tap any item you brought on this trip but never touched. The engine will mark these as deadweight and prevent overpacking next time.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 max-h-56 overflow-y-auto p-2 bg-ticket-50 border-2 border-ink">
|
||||||
|
{trip.items.map((item) => {
|
||||||
|
const isExcluded = excludedItems.includes(item.name);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => toggleDeadweight(item.name)}
|
||||||
|
className={`p-2 border text-left flex items-center justify-between text-xs font-grotesk font-bold transition-all ${
|
||||||
|
isExcluded
|
||||||
|
? 'bg-stamp-red-light border-stamp-red text-stamp-red shadow-xs'
|
||||||
|
: 'bg-white border-ink/30 text-ink hover:border-ink'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="truncate mr-2">{item.name}</span>
|
||||||
|
{isExcluded ? (
|
||||||
|
<span className="stamp-badge stamp-badge-excluded text-[9px] py-0 px-1 shrink-0">
|
||||||
|
EXCLUDE
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="font-mono text-[10px] text-ink/40 shrink-0">
|
||||||
|
[ USED ]
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SECTION 2: WHAT TO INCLUDE (WISHLIST / MISSING) */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<label className="font-mono text-xs font-bold uppercase text-gate-orange flex items-center gap-1.5">
|
||||||
|
<Plus size={15} />
|
||||||
|
<span>2. WHAT SHOULD HAVE BEEN INCLUDED? ("WISH I HAD BROUGHT")</span>
|
||||||
|
</label>
|
||||||
|
<span className="font-mono text-xs font-bold bg-gate-orange text-white px-2 py-0.5">
|
||||||
|
{includedItems.length} ADDED
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="font-sans text-xs text-ink-muted mb-2">
|
||||||
|
What did you find yourself needing on the trip that wasn't in your bags?
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleAddMissingItem} className="flex gap-2 mb-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newMissingItem}
|
||||||
|
onChange={(e) => setNewMissingItem(e.target.value)}
|
||||||
|
placeholder="e.g. Travel extension cord, blister tape, laundry sheets..."
|
||||||
|
className="flex-1 bg-white border-2 border-ink px-3 py-2 text-xs font-grotesk font-bold text-ink shadow-card-hard-sm focus:outline-none focus:ring-2 focus:ring-gate-orange"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="bg-gate-orange hover:bg-gate-hover text-white px-4 py-2 border-2 border-ink font-grotesk font-bold text-xs uppercase shadow-card-hard-sm active:translate-x-0.5 active:translate-y-0.5"
|
||||||
|
>
|
||||||
|
ADD ITEM
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{includedItems.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 p-2 bg-ticket-50 border-2 border-ink">
|
||||||
|
{includedItems.map((name) => (
|
||||||
|
<span
|
||||||
|
key={name}
|
||||||
|
className="bg-white border border-ink px-2.5 py-1 text-xs font-mono font-bold text-ink flex items-center gap-2 shadow-xs"
|
||||||
|
>
|
||||||
|
<span>💡 {name}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveMissingItem(name)}
|
||||||
|
className="text-ink/40 hover:text-stamp-red"
|
||||||
|
title="Remove"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SECTION 3: NOTES & RATING */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 bg-ticket-200/60 p-3.5 border-2 border-ink">
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label className="block font-mono text-xs font-bold uppercase text-ink mb-1">
|
||||||
|
Trip Reflection / Packing Debrief Notes
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
rows={2}
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
placeholder="e.g. Weather was 5 degrees colder than expected in the mornings; 1 pair of shoes was plenty..."
|
||||||
|
className="w-full bg-white border-2 border-ink p-2 font-sans text-xs text-ink shadow-card-hard-sm focus:outline-none focus:ring-2 focus:ring-canary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block font-mono text-xs font-bold uppercase text-ink mb-1.5">
|
||||||
|
Packing Efficiency Rating
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-1.5 items-center mt-2">
|
||||||
|
{[1, 2, 3, 4, 5].map((val) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={val}
|
||||||
|
onClick={() => {
|
||||||
|
sound.playClick();
|
||||||
|
setRating(val);
|
||||||
|
}}
|
||||||
|
className={`p-1 border border-ink ${
|
||||||
|
rating >= val ? 'bg-canary text-ink' : 'bg-white text-ink/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Star size={16} fill={rating >= val ? '#141518' : 'none'} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-[10px] text-ink-muted mt-1 block">
|
||||||
|
{rating === 5 ? 'Perfect loadout' : rating >= 3 ? 'Good packing' : 'Needs tuning'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="border-t-2 border-ink pt-4 flex items-center justify-between gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
sound.playClick();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 border-2 border-ink font-grotesk font-bold text-xs uppercase bg-white hover:bg-ticket-200"
|
||||||
|
>
|
||||||
|
CANCEL
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleComplete}
|
||||||
|
className="px-6 py-2.5 border-2 border-ink font-grotesk font-bold text-sm uppercase bg-stamp-green hover:bg-green-700 text-white shadow-card-hard active:translate-x-0.5 active:translate-y-0.5 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<CheckCircle2 size={16} />
|
||||||
|
<span>STAMP DEBRIEF & UPDATE ENGINE</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { PackingItem, BagType, Category } from '../types';
|
||||||
|
import { sound } from '../utils/audio';
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
Trash2,
|
||||||
|
Sparkles,
|
||||||
|
AlertCircle,
|
||||||
|
Backpack,
|
||||||
|
Briefcase,
|
||||||
|
Package,
|
||||||
|
UserCheck,
|
||||||
|
Plus,
|
||||||
|
Minus
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface ItemRowProps {
|
||||||
|
item: PackingItem;
|
||||||
|
onTogglePacked: (id: string) => void;
|
||||||
|
onUpdateQuantity: (id: string, delta: number) => void;
|
||||||
|
onUpdateBag: (id: string, bag: BagType) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BAG_OPTIONS: { type: BagType; label: string; icon: React.ReactNode; short: string }[] = [
|
||||||
|
{ type: 'personal_item', label: 'Personal Item', short: 'PERS', icon: <Backpack size={12} /> },
|
||||||
|
{ type: 'carry_on', label: 'Carry-On Roller', short: 'CARRY', icon: <Briefcase size={12} /> },
|
||||||
|
{ type: 'checked', label: 'Checked Bag', short: 'CHKD', icon: <Package size={12} /> },
|
||||||
|
{ type: 'worn', label: 'Worn on Person', short: 'WORN', icon: <UserCheck size={12} /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CATEGORY_COLORS: Record<Category, { bg: string; text: string; label: string }> = {
|
||||||
|
clothing: { bg: 'bg-[#E3EAF7]', text: 'text-[#1D3E74]', label: 'CLOTHING' },
|
||||||
|
toiletries: { bg: 'bg-[#E3F5EC]', text: 'text-[#15663F]', label: 'TOILETRIES' },
|
||||||
|
tech: { bg: 'bg-[#FBEAE3]', text: 'text-[#873215]', label: 'TECH & POWER' },
|
||||||
|
documents: { bg: 'bg-[#FFF6D1]', text: 'text-[#7D5700]', label: 'DOCS / IDENTITY' },
|
||||||
|
gear: { bg: 'bg-[#ECEAF7]', text: 'text-[#483B89]', label: 'EXPEDITION GEAR' },
|
||||||
|
wellness: { bg: 'bg-[#FCE8EE]', text: 'text-[#85183E]', label: 'MED / WELLNESS' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ItemRow: React.FC<ItemRowProps> = ({
|
||||||
|
item,
|
||||||
|
onTogglePacked,
|
||||||
|
onUpdateQuantity,
|
||||||
|
onUpdateBag,
|
||||||
|
onDelete,
|
||||||
|
}) => {
|
||||||
|
const catStyle = CATEGORY_COLORS[item.category] || CATEGORY_COLORS.gear;
|
||||||
|
const currentBag = BAG_OPTIONS.find((b) => b.type === item.bag) || BAG_OPTIONS[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`group relative flex flex-col sm:flex-row items-stretch border-2 border-ink transition-all mb-2 shadow-card-hard-sm ${
|
||||||
|
item.packed
|
||||||
|
? 'bg-[#ECE8DC]/70 border-ink/70'
|
||||||
|
: 'bg-ticket-50 hover:bg-ticket-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Left Tear-Off Coupon Stub: Checkbox Stamp */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
sound.playTear();
|
||||||
|
onTogglePacked(item.id);
|
||||||
|
}}
|
||||||
|
className={`w-full sm:w-16 flex items-center justify-center p-3 border-b-2 sm:border-b-0 sm:border-r-2 border-dashed border-ink/40 transition-colors shrink-0 ${
|
||||||
|
item.packed
|
||||||
|
? 'bg-stamp-green-light text-stamp-green'
|
||||||
|
: 'hover:bg-canary-light text-ink/30 hover:text-ink'
|
||||||
|
}`}
|
||||||
|
title={item.packed ? 'Mark as unpacked' : 'Tear & stow in luggage'}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`w-7 h-7 rounded border-2 flex items-center justify-center font-mono font-bold transition-transform ${
|
||||||
|
item.packed
|
||||||
|
? 'border-stamp-green bg-stamp-green text-white rotate-[-4deg] scale-105'
|
||||||
|
: 'border-ink/60 bg-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.packed ? <Check size={18} strokeWidth={3} /> : null}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Main Body of Ticket Stub */}
|
||||||
|
<div className="flex-1 p-3 flex flex-col justify-between gap-2">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||||
|
{/* Category Pill */}
|
||||||
|
<span
|
||||||
|
className={`font-mono text-[10px] font-bold px-1.5 py-0.5 border border-ink/20 ${catStyle.bg} ${catStyle.text}`}
|
||||||
|
>
|
||||||
|
{catStyle.label}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Essential Badge */}
|
||||||
|
{item.essential && (
|
||||||
|
<span className="font-mono text-[9px] font-bold bg-gate-orange text-white px-1 py-0.5 uppercase tracking-wider">
|
||||||
|
ESSENTIAL
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Learned Wisdom Badge */}
|
||||||
|
{item.source === 'learned' && (
|
||||||
|
<span className="font-mono text-[9px] font-bold bg-purple-100 text-purple-900 border border-purple-300 px-1 py-0.5 flex items-center gap-1 uppercase">
|
||||||
|
<Sparkles size={10} />
|
||||||
|
DEBRIEF MEMORY
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Item Name */}
|
||||||
|
<h3
|
||||||
|
className={`font-grotesk font-bold text-base text-ink leading-snug transition-all ${
|
||||||
|
item.packed ? 'line-through text-ink/60' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Notes or TSA Warning */}
|
||||||
|
{item.notes && (
|
||||||
|
<p className="font-sans text-xs text-ink-muted mt-0.5 flex items-center gap-1">
|
||||||
|
<AlertCircle size={11} className="text-gate-orange shrink-0" />
|
||||||
|
<span>{item.notes}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Learned Reason Details */}
|
||||||
|
{item.learnedReason && (
|
||||||
|
<p className="font-mono text-[11px] text-purple-800 bg-purple-50 border border-purple-200 px-2 py-0.5 mt-1 rounded-sm">
|
||||||
|
💡 {item.learnedReason}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stamped Status if Packed */}
|
||||||
|
{item.packed && (
|
||||||
|
<span className="stamp-badge stamp-badge-packed text-[10px] py-0.5 px-2 shrink-0 hidden sm:inline-flex">
|
||||||
|
STOWED
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Bar: Quantity & Bag Switcher */}
|
||||||
|
<div className="flex items-center justify-between border-t border-ink/10 pt-2 mt-1 flex-wrap gap-2 text-xs font-mono">
|
||||||
|
{/* Quantity Controls */}
|
||||||
|
<div className="flex items-center gap-1.5 bg-ticket-200 border border-ink/30 px-1.5 py-0.5">
|
||||||
|
<span className="text-[10px] text-ink-muted uppercase mr-1">QTY:</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
sound.playClick();
|
||||||
|
onUpdateQuantity(item.id, -1);
|
||||||
|
}}
|
||||||
|
className="w-5 h-5 bg-white border border-ink/40 flex items-center justify-center hover:bg-ticket-50 active:scale-95"
|
||||||
|
title="Decrease quantity"
|
||||||
|
>
|
||||||
|
<Minus size={10} />
|
||||||
|
</button>
|
||||||
|
<span className="font-bold text-ink w-5 text-center">
|
||||||
|
{item.quantity}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
sound.playClick();
|
||||||
|
onUpdateQuantity(item.id, 1);
|
||||||
|
}}
|
||||||
|
className="w-5 h-5 bg-white border border-ink/40 flex items-center justify-center hover:bg-ticket-50 active:scale-95"
|
||||||
|
title="Increase quantity"
|
||||||
|
>
|
||||||
|
<Plus size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bag Allocation Switcher */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-[10px] text-ink-muted uppercase hidden sm:inline">
|
||||||
|
ASSIGNED TO:
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={item.bag}
|
||||||
|
onChange={(e) => {
|
||||||
|
sound.playClick();
|
||||||
|
onUpdateBag(item.id, e.target.value as BagType);
|
||||||
|
}}
|
||||||
|
className="bg-white border border-ink font-mono text-[11px] font-bold px-2 py-1 shadow-card-hard-sm cursor-pointer hover:bg-canary-light transition-colors"
|
||||||
|
>
|
||||||
|
{BAG_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.type} value={opt.type}>
|
||||||
|
{opt.label} ({opt.short})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Delete Item */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
sound.playClick();
|
||||||
|
onDelete(item.id);
|
||||||
|
}}
|
||||||
|
className="p-1 text-ink/40 hover:text-stamp-red transition-colors ml-1"
|
||||||
|
title="Remove from manifest"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { PackMemory } from '../types';
|
||||||
|
import { sound } from '../utils/audio';
|
||||||
|
import { X, BrainCircuit, Trash2, AlertTriangle, Sparkles, Award } from 'lucide-react';
|
||||||
|
|
||||||
|
interface MemoryDrawerProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
memory: PackMemory;
|
||||||
|
onClearMemoryItem: (type: 'exclusion' | 'inclusion', itemName: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MemoryDrawer: React.FC<MemoryDrawerProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
memory,
|
||||||
|
onClearMemoryItem,
|
||||||
|
}) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
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="bg-ticket-100 border-2 border-ink shadow-card-hard max-w-2xl w-full my-auto overflow-hidden animate-in fade-in zoom-in-95 duration-150">
|
||||||
|
{/* Top Header */}
|
||||||
|
<div className="bg-ink text-ticket-100 p-4 flex items-center justify-between border-b-2 border-canary">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BrainCircuit size={22} className="text-canary" />
|
||||||
|
<div>
|
||||||
|
<h2 className="font-display text-2xl tracking-wide uppercase text-canary">
|
||||||
|
FLIGHT LOG // PACKING WISDOM & MEMORY
|
||||||
|
</h2>
|
||||||
|
<p className="font-mono text-xs text-ticket-200">
|
||||||
|
CONTINUOUS FEEDBACK ARCHIVE FROM YOUR PAST TRIP DEBRIEFS
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
sound.playClick();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="text-ticket-200 hover:text-white p-1"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Strip */}
|
||||||
|
<div className="bg-ticket-200 border-b-2 border-ink p-3 flex items-center justify-around font-mono text-xs text-ink">
|
||||||
|
<div className="text-center">
|
||||||
|
<span className="text-[10px] text-ink-muted uppercase block">VOYAGES DEBRIEFED</span>
|
||||||
|
<span className="font-bold text-base text-ink">{memory.totalTripsDebriefed}</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-px h-6 bg-ink/20" />
|
||||||
|
<div className="text-center">
|
||||||
|
<span className="text-[10px] text-ink-muted uppercase block">DEADWEIGHT RULES</span>
|
||||||
|
<span className="font-bold text-base text-stamp-red">{memory.exclusions.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-px h-6 bg-ink/20" />
|
||||||
|
<div className="text-center">
|
||||||
|
<span className="text-[10px] text-ink-muted uppercase block">LEARNED INCLUSIONS</span>
|
||||||
|
<span className="font-bold text-base text-purple-700">{memory.inclusions.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 sm:p-6 max-h-[70vh] overflow-y-auto space-y-6">
|
||||||
|
{/* Section: Deadweight Rules */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-2 font-mono text-xs font-bold text-stamp-red">
|
||||||
|
<AlertTriangle size={15} />
|
||||||
|
<span>OVERPACKED / DEADWEIGHT RULES (PENALIZED OR OMITTED)</span>
|
||||||
|
</div>
|
||||||
|
<p className="font-sans text-xs text-ink-muted mb-3">
|
||||||
|
Items you repeatedly marked as unused on previous trips. The generator penalizes or removes these automatically for matching trip types.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{memory.exclusions.length === 0 ? (
|
||||||
|
<div className="p-4 border-2 border-dashed border-ink/30 text-center font-mono text-xs text-ink-muted">
|
||||||
|
No deadweight items recorded yet. Complete a trip debrief to log overpacked items.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{memory.exclusions.map((exc) => (
|
||||||
|
<div
|
||||||
|
key={`${exc.tripType}-${exc.itemName}`}
|
||||||
|
className="p-2.5 bg-white border border-ink/40 flex items-center justify-between text-xs font-grotesk shadow-xs"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-bold text-ink">{exc.itemName}</span>
|
||||||
|
<span className="font-mono text-[10px] bg-stamp-red-light text-stamp-red px-1.5 py-0.2 border border-stamp-red/30 uppercase font-bold">
|
||||||
|
{exc.tripType.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-[10px] text-ink-muted">
|
||||||
|
Excluded {exc.count} time(s) • Last flagged {exc.lastDate?.split('T')[0]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
sound.playClick();
|
||||||
|
onClearMemoryItem('exclusion', exc.itemName);
|
||||||
|
}}
|
||||||
|
className="text-ink/40 hover:text-stamp-red p-1"
|
||||||
|
title="Clear this learned exclusion"
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section: Learned Inclusions */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-2 font-mono text-xs font-bold text-purple-800">
|
||||||
|
<Sparkles size={15} />
|
||||||
|
<span>WISHLIST INCLUSIONS (AUTO-INJECTED ON MATCHING TRIPS)</span>
|
||||||
|
</div>
|
||||||
|
<p className="font-sans text-xs text-ink-muted mb-3">
|
||||||
|
Items you wished you had packed during past debriefs. These are automatically recommended when planning new trips of the same archetype.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{memory.inclusions.length === 0 ? (
|
||||||
|
<div className="p-4 border-2 border-dashed border-ink/30 text-center font-mono text-xs text-ink-muted">
|
||||||
|
No wishlist inclusions recorded yet. Add items during your trip debriefs.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{memory.inclusions.map((inc) => (
|
||||||
|
<div
|
||||||
|
key={`${inc.tripType}-${inc.itemName}`}
|
||||||
|
className="p-2.5 bg-white border border-ink/40 flex items-center justify-between text-xs font-grotesk shadow-xs"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-bold text-ink">{inc.itemName}</span>
|
||||||
|
<span className="font-mono text-[10px] bg-purple-100 text-purple-900 px-1.5 py-0.2 border border-purple-300 uppercase font-bold">
|
||||||
|
{inc.tripType.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[10px] bg-ticket-200 text-ink px-1 py-0.2 border border-ink/20 uppercase">
|
||||||
|
{inc.defaultBag}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{inc.reason && (
|
||||||
|
<p className="font-sans text-[11px] text-ink-muted mt-0.5">
|
||||||
|
"{inc.reason}"
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
sound.playClick();
|
||||||
|
onClearMemoryItem('inclusion', inc.itemName);
|
||||||
|
}}
|
||||||
|
className="text-ink/40 hover:text-stamp-red p-1"
|
||||||
|
title="Clear this learned inclusion"
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="p-4 border-t-2 border-ink bg-ticket-200 flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
sound.playClick();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="px-5 py-2 border-2 border-ink font-grotesk font-bold text-xs uppercase bg-ink text-ticket-100 shadow-card-hard-sm"
|
||||||
|
>
|
||||||
|
CLOSE FLIGHT LOG
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { AppSettings, Trip, PackMemory } from '../types';
|
||||||
|
import { sound } from '../utils/audio';
|
||||||
|
import { X, Settings as SettingsIcon, Volume2, Bot, Download, Upload, Check } from 'lucide-react';
|
||||||
|
|
||||||
|
interface SettingsModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
settings: AppSettings;
|
||||||
|
onSaveSettings: (settings: AppSettings) => void;
|
||||||
|
trips: Trip[];
|
||||||
|
memory: PackMemory;
|
||||||
|
onImportData: (data: { trips: Trip[]; memory: PackMemory }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SettingsModal: React.FC<SettingsModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
settings,
|
||||||
|
onSaveSettings,
|
||||||
|
trips,
|
||||||
|
memory,
|
||||||
|
onImportData,
|
||||||
|
}) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const [current, setCurrent] = useState<AppSettings>({ ...settings });
|
||||||
|
const [importStatus, setImportStatus] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSave = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
sound.playStamp();
|
||||||
|
sound.enabled = current.soundEnabled;
|
||||||
|
onSaveSettings(current);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
sound.playClick();
|
||||||
|
const exportBundle = {
|
||||||
|
app: 'loadout',
|
||||||
|
version: '1.0.0',
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
trips,
|
||||||
|
memory,
|
||||||
|
settings: current,
|
||||||
|
};
|
||||||
|
|
||||||
|
const blob = new Blob([JSON.stringify(exportBundle, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `loadout_backup_${new Date().toISOString().split('T')[0]}.json`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImport = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(event.target?.result as string);
|
||||||
|
if (parsed.trips && Array.isArray(parsed.trips)) {
|
||||||
|
sound.playStamp();
|
||||||
|
onImportData({
|
||||||
|
trips: parsed.trips,
|
||||||
|
memory: parsed.memory || { exclusions: [], inclusions: [], totalTripsDebriefed: 0 },
|
||||||
|
});
|
||||||
|
setImportStatus('Backup successfully restored!');
|
||||||
|
setTimeout(() => setImportStatus(null), 3000);
|
||||||
|
} else {
|
||||||
|
setImportStatus('Invalid backup file format');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setImportStatus('Error reading file');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
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="bg-ticket-100 border-2 border-ink shadow-card-hard max-w-xl w-full my-auto overflow-hidden animate-in fade-in zoom-in-95 duration-150">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-ink text-ticket-100 p-4 flex items-center justify-between border-b-2 border-canary">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<SettingsIcon size={20} className="text-canary" />
|
||||||
|
<h2 className="font-display text-2xl tracking-wide uppercase text-canary">
|
||||||
|
DISPATCH TERMINAL SETTINGS
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
sound.playClick();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="text-ticket-200 hover:text-white p-1"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSave} className="p-4 sm:p-6 max-h-[75vh] overflow-y-auto space-y-6">
|
||||||
|
{/* Audio Synthesizer */}
|
||||||
|
<div className="bg-white border-2 border-ink p-3.5 shadow-card-hard-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Volume2 size={18} className="text-gate-orange" />
|
||||||
|
<div>
|
||||||
|
<span className="font-mono text-xs font-bold uppercase text-ink block">
|
||||||
|
TACTILE AUDIO SYNTHESIZER
|
||||||
|
</span>
|
||||||
|
<span className="font-sans text-[11px] text-ink-muted">
|
||||||
|
Paper tear, luggage stamp, and flight terminal sounds
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const next = !current.soundEnabled;
|
||||||
|
setCurrent({ ...current, soundEnabled: next });
|
||||||
|
sound.enabled = next;
|
||||||
|
if (next) sound.playStamp();
|
||||||
|
}}
|
||||||
|
className={`font-mono text-xs font-bold px-3 py-1 border-2 border-ink ${
|
||||||
|
current.soundEnabled
|
||||||
|
? 'bg-stamp-green-light text-stamp-green border-stamp-green'
|
||||||
|
: 'bg-ticket-200 text-ink-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{current.soundEnabled ? 'ENABLED' : 'MUTED'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI Configuration */}
|
||||||
|
<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 border-b border-ink/20 pb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Bot size={18} className="text-purple-700" />
|
||||||
|
<div>
|
||||||
|
<span className="font-mono text-xs font-bold uppercase text-ink block">
|
||||||
|
OPTIONAL AI FLIGHT DISPATCHER
|
||||||
|
</span>
|
||||||
|
<span className="font-sans text-[11px] text-ink-muted">
|
||||||
|
Supports local Ollama on vloser or cloud API
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
sound.playClick();
|
||||||
|
setCurrent({ ...current, aiEnabled: !current.aiEnabled });
|
||||||
|
}}
|
||||||
|
className={`font-mono text-xs font-bold px-3 py-1 border-2 border-ink ${
|
||||||
|
current.aiEnabled
|
||||||
|
? 'bg-purple-100 text-purple-900 border-purple-400'
|
||||||
|
: 'bg-ticket-200 text-ink-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{current.aiEnabled ? 'AI ACTIVE' : 'OFFLINE ONLY'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{current.aiEnabled && (
|
||||||
|
<div className="space-y-3 pt-2">
|
||||||
|
<div>
|
||||||
|
<label className="block font-mono text-[11px] font-bold uppercase text-ink mb-1">
|
||||||
|
Provider
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={current.aiProvider}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCurrent({
|
||||||
|
...current,
|
||||||
|
aiProvider: e.target.value as 'ollama' | 'gemini' | 'openai',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-full bg-ticket-50 border border-ink font-mono text-xs px-2.5 py-1.5"
|
||||||
|
>
|
||||||
|
<option value="ollama">Local Ollama (Self-Hosted on vloser)</option>
|
||||||
|
<option value="gemini">Google Gemini API</option>
|
||||||
|
<option value="openai">OpenAI API</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{current.aiProvider === 'ollama' ? (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="block font-mono text-[11px] font-bold uppercase text-ink mb-1">
|
||||||
|
Ollama Endpoint URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={current.ollamaUrl}
|
||||||
|
onChange={(e) => setCurrent({ ...current, ollamaUrl: e.target.value })}
|
||||||
|
placeholder="http://localhost:11434"
|
||||||
|
className="w-full bg-ticket-50 border border-ink font-mono text-xs px-2 py-1.5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block font-mono text-[11px] font-bold uppercase text-ink mb-1">
|
||||||
|
Model Tag
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={current.ollamaModel}
|
||||||
|
onChange={(e) => setCurrent({ ...current, ollamaModel: e.target.value })}
|
||||||
|
placeholder="llama3.2 or mistral"
|
||||||
|
className="w-full bg-ticket-50 border border-ink font-mono text-xs px-2 py-1.5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<label className="block font-mono text-[11px] font-bold uppercase text-ink mb-1">
|
||||||
|
API Key
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={current.apiKey}
|
||||||
|
onChange={(e) => setCurrent({ ...current, apiKey: e.target.value })}
|
||||||
|
placeholder="sk-..."
|
||||||
|
className="w-full bg-ticket-50 border border-ink font-mono text-xs px-2 py-1.5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Backup & Restore */}
|
||||||
|
<div className="bg-ticket-200/60 border-2 border-ink p-3.5 space-y-2">
|
||||||
|
<span className="font-mono text-xs font-bold uppercase text-ink block">
|
||||||
|
DATA ARCHIVE & RESTORE
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleExport}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-white border border-ink font-mono text-xs font-bold hover:bg-ticket-50"
|
||||||
|
>
|
||||||
|
<Download size={13} />
|
||||||
|
<span>EXPORT MANIFEST BACKUP (.JSON)</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-1.5 px-3 py-1.5 bg-white border border-ink font-mono text-xs font-bold hover:bg-ticket-50 cursor-pointer">
|
||||||
|
<Upload size={13} />
|
||||||
|
<span>IMPORT BACKUP</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".json"
|
||||||
|
onChange={handleImport}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{importStatus && (
|
||||||
|
<p className="font-mono text-xs font-bold text-stamp-green mt-1">
|
||||||
|
{importStatus}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="border-t-2 border-ink pt-4 flex items-center justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
sound.playClick();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 border-2 border-ink font-grotesk font-bold text-xs uppercase bg-white hover:bg-ticket-200"
|
||||||
|
>
|
||||||
|
CANCEL
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-6 py-2.5 border-2 border-ink font-grotesk font-bold text-xs uppercase bg-ink text-ticket-100 shadow-card-hard active:translate-x-0.5 active:translate-y-0.5"
|
||||||
|
>
|
||||||
|
SAVE SETTINGS
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { TripType, Climate, LuggageConfig, Trip, PackMemory } from '../types';
|
||||||
|
import { generateAirportCode, generatePackingList } from '../utils/generator';
|
||||||
|
import { sound } from '../utils/audio';
|
||||||
|
import { X, Plane, Calendar, Cloud, Luggage, Sparkles, Check } from 'lucide-react';
|
||||||
|
|
||||||
|
interface TripModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaveTrip: (trip: Trip) => void;
|
||||||
|
memory: PackMemory;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRIP_TYPES: { id: TripType; label: string; desc: string }[] = [
|
||||||
|
{ id: 'international_urban', label: 'International City', desc: 'Urban walking, adapters, transit cards' },
|
||||||
|
{ id: 'beach', label: 'Beach & Tropical', desc: 'Swimwear, reef sunscreen, UV protection' },
|
||||||
|
{ id: 'alpine_cold', label: 'Cold / Alpine Ski', desc: 'Merino base layers, down coat, thermal gear' },
|
||||||
|
{ id: 'hiking_camping', label: 'Hiking & Outdoor', desc: 'Trail boots, daypack, headlamp, blister kit' },
|
||||||
|
{ id: 'business', label: 'Business / Conference', desc: 'Blazer, dress shoes, laptop, power cords' },
|
||||||
|
{ id: 'weekend_getaway', label: 'Weekend Break', desc: 'Minimalist 2-3 day capsule wardrobe' },
|
||||||
|
{ id: 'road_trip', label: 'Road Trip / Overland', desc: 'Car gear, casual flex, comfort layers' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CLIMATES: { id: Climate; label: string; icon: string }[] = [
|
||||||
|
{ id: 'hot', label: 'Hot / Sunny', icon: '☀️' },
|
||||||
|
{ id: 'mild', label: 'Mild / Temperate', icon: '⛅' },
|
||||||
|
{ id: 'cold', label: 'Cold / Freezing', icon: '❄️' },
|
||||||
|
{ id: 'rainy', label: 'Rainy / Variable', icon: '🌧️' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const LUGGAGE_MODES: { id: LuggageConfig; label: string; desc: string }[] = [
|
||||||
|
{ id: 'carry_on_only', label: 'Carry-On Only', desc: 'Personal item + overhead roller (TSA 3-1-1 liquids rule enforced)' },
|
||||||
|
{ id: 'checked_bag', label: 'Checked Bag Included', desc: 'Cargo hold: full liquids allowed, trekking poles, heavy winter boots' },
|
||||||
|
{ id: 'backpack_only', label: 'One Backpack Only', desc: 'Minimalist 25-35L pack for ultra-mobile travel' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ACTIVITIES = [
|
||||||
|
{ id: 'formal', label: 'Formal / Fine Dining', icon: '👔' },
|
||||||
|
{ id: 'swimming', label: 'Swimming / Water', icon: '🏊' },
|
||||||
|
{ id: 'hiking', label: 'Trail Hiking', icon: '🥾' },
|
||||||
|
{ id: 'gym', label: 'Gym / Fitness', icon: '🏋️' },
|
||||||
|
{ id: 'remote_work', label: 'Remote Work / Tech', icon: '💻' },
|
||||||
|
{ id: 'photography', label: 'Photography', icon: '📷' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TripModal: React.FC<TripModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSaveTrip,
|
||||||
|
memory,
|
||||||
|
}) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const [destination, setDestination] = useState('');
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [tripType, setTripType] = useState<TripType>('international_urban');
|
||||||
|
const [durationDays, setDurationDays] = useState(7);
|
||||||
|
const [climate, setClimate] = useState<Climate>('mild');
|
||||||
|
const [luggageConfig, setLuggageConfig] = useState<LuggageConfig>('carry_on_only');
|
||||||
|
const [hasLaundry, setHasLaundry] = useState(true);
|
||||||
|
const [selectedActivities, setSelectedActivities] = useState<string[]>(['remote_work']);
|
||||||
|
|
||||||
|
const destinationAirportCode = generateAirportCode(destination || 'DEST');
|
||||||
|
const routeCode = `DEP ➔ ${destinationAirportCode}`;
|
||||||
|
|
||||||
|
const toggleActivity = (id: string) => {
|
||||||
|
sound.playClick();
|
||||||
|
if (selectedActivities.includes(id)) {
|
||||||
|
setSelectedActivities(selectedActivities.filter((a) => a !== id));
|
||||||
|
} else {
|
||||||
|
setSelectedActivities([...selectedActivities, id]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerate = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
sound.playStamp();
|
||||||
|
|
||||||
|
const tripTitle = title.trim() || `${destination.trim() || 'Voyage'} Expedition`;
|
||||||
|
const tripId = `trip_${Date.now()}`;
|
||||||
|
|
||||||
|
const 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 = {
|
||||||
|
id: tripId,
|
||||||
|
title: tripTitle,
|
||||||
|
destination: destination.trim() || 'New Destination',
|
||||||
|
routeCode,
|
||||||
|
tripType,
|
||||||
|
durationDays,
|
||||||
|
durationNights: Math.max(1, durationDays - 1),
|
||||||
|
climate,
|
||||||
|
luggageConfig,
|
||||||
|
hasLaundry,
|
||||||
|
activities: selectedActivities,
|
||||||
|
items,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
onSaveTrip(newTrip);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Count relevant memory items
|
||||||
|
const relevantInclusions = memory.inclusions.filter((i) => i.tripType === tripType).length;
|
||||||
|
const relevantExclusions = memory.exclusions.filter((e) => e.tripType === tripType).length;
|
||||||
|
|
||||||
|
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="bg-ticket-100 border-2 border-ink shadow-card-hard max-w-2xl w-full my-auto overflow-hidden animate-in fade-in zoom-in-95 duration-150">
|
||||||
|
{/* Top Boarding Pass Header Strip */}
|
||||||
|
<div className="bg-ink text-ticket-100 p-4 flex items-center justify-between border-b-2 border-canary">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-3 h-3 bg-canary rotate-45" />
|
||||||
|
<h2 className="font-display text-2xl tracking-wide uppercase text-canary">
|
||||||
|
DISPATCH NEW TRAVEL MANIFEST
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
sound.playClick();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="text-ticket-200 hover:text-white p-1"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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">
|
||||||
|
<span>ROUTING TELEMETRY:</span>
|
||||||
|
<span className="bg-ink text-canary px-2 py-0.5 tracking-widest text-sm">
|
||||||
|
{routeCode}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleGenerate} className="p-4 sm:p-6 max-h-[80vh] overflow-y-auto space-y-5">
|
||||||
|
{/* Destination & Title */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block font-mono text-xs font-bold uppercase text-ink mb-1">
|
||||||
|
Destination City / Country
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={destination}
|
||||||
|
onChange={(e) => setDestination(e.target.value)}
|
||||||
|
placeholder="e.g. Reykjavik, Iceland or Tokyo"
|
||||||
|
className="w-full bg-white border-2 border-ink px-3 py-2 font-grotesk font-bold text-ink text-sm shadow-card-hard-sm focus:outline-none focus:ring-2 focus:ring-canary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block font-mono text-xs font-bold uppercase text-ink mb-1">
|
||||||
|
Trip Name / Manifest Title
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="e.g. Autumn Highlands Trek"
|
||||||
|
className="w-full bg-white border-2 border-ink px-3 py-2 font-grotesk font-bold text-ink text-sm shadow-card-hard-sm focus:outline-none focus:ring-2 focus:ring-canary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Trip Type */}
|
||||||
|
<div>
|
||||||
|
<label className="block font-mono text-xs font-bold uppercase text-ink mb-1.5">
|
||||||
|
Trip Archetype
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
{TRIP_TYPES.map((t) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => {
|
||||||
|
sound.playClick();
|
||||||
|
setTripType(t.id);
|
||||||
|
}}
|
||||||
|
className={`text-left p-2.5 border-2 border-ink transition-all flex flex-col ${
|
||||||
|
tripType === t.id
|
||||||
|
? 'bg-canary-light shadow-card-hard border-ink'
|
||||||
|
: 'bg-white hover:bg-ticket-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="font-grotesk font-bold text-sm text-ink uppercase">
|
||||||
|
{t.label}
|
||||||
|
</span>
|
||||||
|
<span className="font-sans text-[11px] text-ink-muted">
|
||||||
|
{t.desc}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Duration & Laundry Access */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 bg-ticket-200/60 p-3.5 border-2 border-ink">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<label className="font-mono text-xs font-bold uppercase text-ink">
|
||||||
|
Duration (Days)
|
||||||
|
</label>
|
||||||
|
<span className="font-mono text-xs font-bold bg-ink text-ticket-100 px-1.5 py-0.5">
|
||||||
|
{durationDays} DAYS / {Math.max(1, durationDays - 1)} NIGHTS
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="1"
|
||||||
|
max="30"
|
||||||
|
value={durationDays}
|
||||||
|
onChange={(e) => setDurationDays(parseInt(e.target.value))}
|
||||||
|
className="w-full accent-gate-orange cursor-pointer"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-1.5 mt-2">
|
||||||
|
{[3, 5, 7, 10, 14].map((d) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={d}
|
||||||
|
onClick={() => {
|
||||||
|
sound.playClick();
|
||||||
|
setDurationDays(d);
|
||||||
|
}}
|
||||||
|
className={`font-mono text-[10px] font-bold px-2 py-0.5 border border-ink ${
|
||||||
|
durationDays === d ? 'bg-ink text-ticket-100' : 'bg-white text-ink'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{d}D
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block font-mono text-xs font-bold uppercase text-ink mb-1">
|
||||||
|
Laundry Facility Access
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
sound.playClick();
|
||||||
|
setHasLaundry(!hasLaundry);
|
||||||
|
}}
|
||||||
|
className={`w-full p-2 border-2 border-ink font-mono text-xs text-left flex items-start gap-2 ${
|
||||||
|
hasLaundry
|
||||||
|
? 'bg-stamp-green-light border-stamp-green text-stamp-green font-bold'
|
||||||
|
: 'bg-white text-ink-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="mt-0.5">
|
||||||
|
<div
|
||||||
|
className={`w-4 h-4 border border-ink flex items-center justify-center ${
|
||||||
|
hasLaundry ? 'bg-stamp-green text-white' : 'bg-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{hasLaundry && <Check size={12} strokeWidth={3} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-ink font-bold">
|
||||||
|
{hasLaundry ? 'LAUNDRY AVAILABLE' : 'NO LAUNDRY (FULL CLOTHING)'}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-ink-muted font-sans leading-tight mt-0.5">
|
||||||
|
{hasLaundry
|
||||||
|
? 'Engine caps clothes count to ~4 days, saving massive bag space'
|
||||||
|
: 'Packs full daily allocation for all days'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Climate Selection */}
|
||||||
|
<div>
|
||||||
|
<label className="block font-mono text-xs font-bold uppercase text-ink mb-1.5">
|
||||||
|
Climate & Weather Forecast
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||||
|
{CLIMATES.map((c) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={c.id}
|
||||||
|
onClick={() => {
|
||||||
|
sound.playClick();
|
||||||
|
setClimate(c.id);
|
||||||
|
}}
|
||||||
|
className={`p-2 border-2 border-ink flex items-center gap-2 font-mono text-xs font-bold ${
|
||||||
|
climate === c.id
|
||||||
|
? 'bg-canary shadow-card-hard-sm border-ink'
|
||||||
|
: 'bg-white hover:bg-ticket-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-base">{c.icon}</span>
|
||||||
|
<span className="uppercase text-[11px]">{c.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Luggage Mode */}
|
||||||
|
<div>
|
||||||
|
<label className="block font-mono text-xs font-bold uppercase text-ink mb-1.5">
|
||||||
|
Baggage Allowance Mode
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||||
|
{LUGGAGE_MODES.map((m) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={m.id}
|
||||||
|
onClick={() => {
|
||||||
|
sound.playClick();
|
||||||
|
setLuggageConfig(m.id);
|
||||||
|
}}
|
||||||
|
className={`p-2.5 border-2 border-ink text-left flex flex-col ${
|
||||||
|
luggageConfig === m.id
|
||||||
|
? 'bg-gate-light border-gate-orange shadow-card-hard-sm'
|
||||||
|
: 'bg-white hover:bg-ticket-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="font-grotesk font-bold text-xs uppercase text-ink">
|
||||||
|
{m.label}
|
||||||
|
</span>
|
||||||
|
<span className="font-sans text-[10px] text-ink-muted leading-tight mt-0.5">
|
||||||
|
{m.desc}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Activities Badges */}
|
||||||
|
<div>
|
||||||
|
<label className="block font-mono text-xs font-bold uppercase text-ink mb-1.5">
|
||||||
|
Planned Activities / Special Gear
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{ACTIVITIES.map((a) => {
|
||||||
|
const active = selectedActivities.includes(a.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={a.id}
|
||||||
|
onClick={() => toggleActivity(a.id)}
|
||||||
|
className={`px-2.5 py-1.5 border-2 border-ink font-mono text-xs font-bold flex items-center gap-1.5 transition-all ${
|
||||||
|
active
|
||||||
|
? 'bg-ink text-ticket-100 shadow-card-hard-sm'
|
||||||
|
: 'bg-white text-ink hover:bg-ticket-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{a.icon}</span>
|
||||||
|
<span>{a.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</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 */}
|
||||||
|
<div className="border-t-2 border-ink pt-4 flex items-center justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
sound.playClick();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 border-2 border-ink font-grotesk font-bold text-xs uppercase bg-white hover:bg-ticket-200"
|
||||||
|
>
|
||||||
|
CANCEL
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
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} />
|
||||||
|
<span>STAMP & ISSUE LOADOUT MANIFEST</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+120
@@ -0,0 +1,120 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.font-display {
|
||||||
|
font-family: 'Bebas Neue', Impact, sans-serif;
|
||||||
|
}
|
||||||
|
.font-grotesk {
|
||||||
|
font-family: 'Space Grotesk', sans-serif;
|
||||||
|
}
|
||||||
|
.font-mono {
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
}
|
||||||
|
.font-sans {
|
||||||
|
font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Perforated ticket edge punch-outs */
|
||||||
|
.ticket-notch-left {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.ticket-notch-left::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -10px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background-color: #F4F1EA;
|
||||||
|
border-radius: 50%;
|
||||||
|
border-right: 1.5px dashed #D0C8B5;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-notch-right {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.ticket-notch-right::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
right: -10px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background-color: #F4F1EA;
|
||||||
|
border-radius: 50%;
|
||||||
|
border-left: 1.5px dashed #D0C8B5;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rubber stamp visual style */
|
||||||
|
.stamp-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
font-weight: 700;
|
||||||
|
border: 2px solid currentColor;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
transform: rotate(-2deg);
|
||||||
|
mask-image: radial-gradient(circle, black 70%, rgba(0,0,0,0.8) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stamp-badge-packed {
|
||||||
|
color: #147D52;
|
||||||
|
background-color: rgba(20, 125, 82, 0.08);
|
||||||
|
border-color: #147D52;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stamp-badge-excluded {
|
||||||
|
color: #D92D20;
|
||||||
|
background-color: rgba(217, 45, 32, 0.08);
|
||||||
|
border-color: #D92D20;
|
||||||
|
transform: rotate(3deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stamp-badge-wishlist {
|
||||||
|
color: #0E7490;
|
||||||
|
background-color: rgba(14, 116, 144, 0.08);
|
||||||
|
border-color: #0E7490;
|
||||||
|
transform: rotate(-1.5deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
background-color: #F4F1EA;
|
||||||
|
color: #141518;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom sleek manifest scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #EFE9DA;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #C4BAA3;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #9EA4B1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
export type BagType = 'personal_item' | 'carry_on' | 'checked' | 'worn';
|
||||||
|
|
||||||
|
export type Category = 'clothing' | 'toiletries' | 'tech' | 'documents' | 'gear' | 'wellness';
|
||||||
|
|
||||||
|
export type TripType =
|
||||||
|
| 'weekend_getaway'
|
||||||
|
| 'business'
|
||||||
|
| 'beach'
|
||||||
|
| 'alpine_cold'
|
||||||
|
| 'hiking_camping'
|
||||||
|
| 'international_urban'
|
||||||
|
| 'road_trip';
|
||||||
|
|
||||||
|
export type Climate = 'hot' | 'mild' | 'cold' | 'rainy';
|
||||||
|
|
||||||
|
export type LuggageConfig = 'carry_on_only' | 'checked_bag' | 'backpack_only';
|
||||||
|
|
||||||
|
export type ItemSource = 'generated' | 'user' | 'learned' | 'ai';
|
||||||
|
|
||||||
|
export interface PackingItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
category: Category;
|
||||||
|
quantity: number;
|
||||||
|
bag: BagType;
|
||||||
|
packed: boolean;
|
||||||
|
essential?: boolean;
|
||||||
|
notes?: string;
|
||||||
|
source?: ItemSource;
|
||||||
|
learnedReason?: string;
|
||||||
|
debriefStatus?: 'used' | 'deadweight' | 'wishlist';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TripDebrief {
|
||||||
|
notes: string;
|
||||||
|
rating: number; // 1 to 5
|
||||||
|
completedAt: string;
|
||||||
|
excludedItemNames: string[]; // Packed but never used ("Deadweight")
|
||||||
|
includedItemNames: string[]; // Missing items ("Wish I brought")
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Trip {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
destination: string;
|
||||||
|
routeCode: string; // e.g. "SFO ➔ NRT"
|
||||||
|
tripType: TripType;
|
||||||
|
durationDays: number;
|
||||||
|
durationNights: number;
|
||||||
|
climate: Climate;
|
||||||
|
luggageConfig: LuggageConfig;
|
||||||
|
hasLaundry: boolean;
|
||||||
|
activities: string[]; // 'formal', 'swimming', 'hiking', 'gym', 'remote_work', 'photography'
|
||||||
|
items: PackingItem[];
|
||||||
|
createdAt: string;
|
||||||
|
debrief?: TripDebrief;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemoryExclusion {
|
||||||
|
itemName: string;
|
||||||
|
tripType: TripType;
|
||||||
|
count: number;
|
||||||
|
lastDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemoryInclusion {
|
||||||
|
itemName: string;
|
||||||
|
category: Category;
|
||||||
|
defaultBag: BagType;
|
||||||
|
tripType: TripType;
|
||||||
|
count: number;
|
||||||
|
lastDate: string;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PackMemory {
|
||||||
|
exclusions: MemoryExclusion[];
|
||||||
|
inclusions: MemoryInclusion[];
|
||||||
|
totalTripsDebriefed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppSettings {
|
||||||
|
soundEnabled: boolean;
|
||||||
|
aiEnabled: boolean;
|
||||||
|
aiProvider: 'ollama' | 'gemini' | 'openai';
|
||||||
|
ollamaUrl: string;
|
||||||
|
ollamaModel: string;
|
||||||
|
apiKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BagMeta {
|
||||||
|
type: BagType;
|
||||||
|
label: string;
|
||||||
|
code: string;
|
||||||
|
icon: string;
|
||||||
|
description: string;
|
||||||
|
tsaRule?: string;
|
||||||
|
color: string;
|
||||||
|
borderClass: string;
|
||||||
|
bgClass: string;
|
||||||
|
badgeBg: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
// Web Audio API tactile feedback synthesizer for Loadout
|
||||||
|
// Generates physical ticket tears, stamps, and clicks without external audio files
|
||||||
|
|
||||||
|
class AudioEngine {
|
||||||
|
private ctx: AudioContext | null = null;
|
||||||
|
public enabled: boolean = true;
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
if (!this.ctx && typeof window !== 'undefined') {
|
||||||
|
const AudioCtx = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
||||||
|
if (AudioCtx) {
|
||||||
|
this.ctx = new AudioCtx();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.ctx && this.ctx.state === 'suspended') {
|
||||||
|
this.ctx.resume().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crisp ticket perforation tear sound when packing/checking off an item
|
||||||
|
playTear() {
|
||||||
|
if (!this.enabled) return;
|
||||||
|
try {
|
||||||
|
this.init();
|
||||||
|
if (!this.ctx) return;
|
||||||
|
|
||||||
|
const now = this.ctx.currentTime;
|
||||||
|
const bufferSize = this.ctx.sampleRate * 0.08;
|
||||||
|
const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
|
||||||
|
const data = buffer.getChannelData(0);
|
||||||
|
|
||||||
|
// Generate crackly paper friction noise
|
||||||
|
for (let i = 0; i < bufferSize; i++) {
|
||||||
|
data[i] = (Math.random() * 2 - 1) * Math.exp(-i / (bufferSize * 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
const noise = this.ctx.createBufferSource();
|
||||||
|
noise.buffer = buffer;
|
||||||
|
|
||||||
|
const filter = this.ctx.createBiquadFilter();
|
||||||
|
filter.type = 'bandpass';
|
||||||
|
filter.frequency.setValueAtTime(1800, now);
|
||||||
|
filter.Q.setValueAtTime(3, now);
|
||||||
|
|
||||||
|
const gain = this.ctx.createGain();
|
||||||
|
gain.gain.setValueAtTime(0.18, now);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.08);
|
||||||
|
|
||||||
|
noise.connect(filter);
|
||||||
|
filter.connect(gain);
|
||||||
|
gain.connect(this.ctx.destination);
|
||||||
|
|
||||||
|
noise.start(now);
|
||||||
|
} catch {
|
||||||
|
// Ignore audio context errors gracefully
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heavy mechanical stamp thud (for debrief stamping or trip creation)
|
||||||
|
playStamp() {
|
||||||
|
if (!this.enabled) return;
|
||||||
|
try {
|
||||||
|
this.init();
|
||||||
|
if (!this.ctx) return;
|
||||||
|
|
||||||
|
const now = this.ctx.currentTime;
|
||||||
|
|
||||||
|
// Low frequency thump
|
||||||
|
const osc = this.ctx.createOscillator();
|
||||||
|
const gain = this.ctx.createGain();
|
||||||
|
|
||||||
|
osc.type = 'triangle';
|
||||||
|
osc.frequency.setValueAtTime(130, now);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(35, now + 0.12);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.3, now);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.12);
|
||||||
|
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(this.ctx.destination);
|
||||||
|
|
||||||
|
osc.start(now);
|
||||||
|
osc.stop(now + 0.12);
|
||||||
|
|
||||||
|
// Paper slap friction
|
||||||
|
const bufferSize = this.ctx.sampleRate * 0.05;
|
||||||
|
const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
|
||||||
|
const data = buffer.getChannelData(0);
|
||||||
|
for (let i = 0; i < bufferSize; i++) {
|
||||||
|
data[i] = (Math.random() * 2 - 1) * Math.exp(-i / (bufferSize * 0.2));
|
||||||
|
}
|
||||||
|
const noise = this.ctx.createBufferSource();
|
||||||
|
noise.buffer = buffer;
|
||||||
|
const noiseGain = this.ctx.createGain();
|
||||||
|
noiseGain.gain.setValueAtTime(0.12, now);
|
||||||
|
noiseGain.gain.exponentialRampToValueAtTime(0.001, now + 0.05);
|
||||||
|
|
||||||
|
noise.connect(noiseGain);
|
||||||
|
noiseGain.connect(this.ctx.destination);
|
||||||
|
noise.start(now);
|
||||||
|
} catch {
|
||||||
|
// Ignore audio context errors gracefully
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crisp digital boarding pass scanner beep/click
|
||||||
|
playClick() {
|
||||||
|
if (!this.enabled) return;
|
||||||
|
try {
|
||||||
|
this.init();
|
||||||
|
if (!this.ctx) return;
|
||||||
|
|
||||||
|
const now = this.ctx.currentTime;
|
||||||
|
const osc = this.ctx.createOscillator();
|
||||||
|
const gain = this.ctx.createGain();
|
||||||
|
|
||||||
|
osc.type = 'sine';
|
||||||
|
osc.frequency.setValueAtTime(1400, now);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(900, now + 0.03);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.08, now);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.03);
|
||||||
|
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(this.ctx.destination);
|
||||||
|
|
||||||
|
osc.start(now);
|
||||||
|
osc.stop(now + 0.03);
|
||||||
|
} catch {
|
||||||
|
// Ignore audio context errors gracefully
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sound = new AudioEngine();
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import { PackingItem, TripType, Climate, LuggageConfig, Category, BagType, PackMemory } from '../types';
|
||||||
|
|
||||||
|
interface GenerationParams {
|
||||||
|
title: string;
|
||||||
|
destination: string;
|
||||||
|
tripType: TripType;
|
||||||
|
durationDays: number;
|
||||||
|
durationNights: number;
|
||||||
|
climate: Climate;
|
||||||
|
luggageConfig: LuggageConfig;
|
||||||
|
hasLaundry: boolean;
|
||||||
|
activities: string[];
|
||||||
|
memory?: PackMemory;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate an authentic airport IATA-style 3-letter code from a city name
|
||||||
|
export function generateAirportCode(city: string): string {
|
||||||
|
if (!city || city.trim().length === 0) return 'DEP';
|
||||||
|
const clean = city.trim().toUpperCase().replace(/[^A-Z]/g, '');
|
||||||
|
if (clean.length <= 3) return clean.padEnd(3, 'X');
|
||||||
|
|
||||||
|
// Well known city shortcuts
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
PARIS: 'CDG',
|
||||||
|
LONDON: 'LHR',
|
||||||
|
TOKYO: 'HND',
|
||||||
|
NEWYORK: 'JFK',
|
||||||
|
CHICAGO: 'ORD',
|
||||||
|
SANFRANCISCO: 'SFO',
|
||||||
|
SEATTLE: 'SEA',
|
||||||
|
LOSANGELES: 'LAX',
|
||||||
|
BERLIN: 'BER',
|
||||||
|
AMSTERDAM: 'AMS',
|
||||||
|
ROME: 'FCO',
|
||||||
|
DENVER: 'DEN',
|
||||||
|
MIAMI: 'MIA',
|
||||||
|
HONOLULU: 'HNL',
|
||||||
|
BARCELONA: 'BCN',
|
||||||
|
SYDNEY: 'SYD',
|
||||||
|
TORONTO: 'YYZ',
|
||||||
|
VANCOUVER: 'YVR',
|
||||||
|
'SALT LAKE': 'SLC',
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [key, code] of Object.entries(map)) {
|
||||||
|
if (clean.includes(key)) return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: First letter + middle letter + last letter
|
||||||
|
return `${clean[0]}${clean[Math.floor(clean.length / 2)]}${clean[clean.length - 1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generatePackingList(params: GenerationParams): PackingItem[] {
|
||||||
|
const {
|
||||||
|
tripType,
|
||||||
|
durationDays,
|
||||||
|
climate,
|
||||||
|
luggageConfig,
|
||||||
|
hasLaundry,
|
||||||
|
activities,
|
||||||
|
memory,
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
const items: PackingItem[] = [];
|
||||||
|
|
||||||
|
const addItem = (
|
||||||
|
name: string,
|
||||||
|
category: Category,
|
||||||
|
quantity: number,
|
||||||
|
bag: BagType,
|
||||||
|
essential: boolean = false,
|
||||||
|
notes?: string,
|
||||||
|
source: 'generated' | 'user' | 'learned' = 'generated',
|
||||||
|
learnedReason?: string
|
||||||
|
) => {
|
||||||
|
// Check if user previously marked this item as deadweight for this trip type
|
||||||
|
if (memory && source === 'generated') {
|
||||||
|
const exclusion = memory.exclusions.find(
|
||||||
|
(e) => e.tripType === tripType && e.itemName.toLowerCase() === name.toLowerCase()
|
||||||
|
);
|
||||||
|
if (exclusion && exclusion.count >= 2) {
|
||||||
|
// Excluded 2+ times: skip completely to prevent repeating past packing mistakes!
|
||||||
|
return;
|
||||||
|
} else if (exclusion && exclusion.count === 1) {
|
||||||
|
// Excluded once: reduce quantity if > 1 or mark note
|
||||||
|
quantity = Math.max(1, Math.floor(quantity * 0.7));
|
||||||
|
learnedReason = `Reduced quantity (marked deadweight on previous ${tripType.replace('_', ' ')})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
id: `item_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`,
|
||||||
|
name,
|
||||||
|
category,
|
||||||
|
quantity,
|
||||||
|
bag: luggageConfig === 'backpack_only' && bag === 'checked' ? 'carry_on' : bag,
|
||||||
|
packed: false,
|
||||||
|
essential,
|
||||||
|
notes,
|
||||||
|
source,
|
||||||
|
learnedReason,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. CRITICAL TRAVEL DOCUMENTS & VALUE (Always Personal Item)
|
||||||
|
addItem('Passport / Government ID', 'documents', 1, 'personal_item', true, 'Physical original + photo backup on phone');
|
||||||
|
addItem('Wallet (Credit cards, debit, minimal cash)', 'documents', 1, 'personal_item', true);
|
||||||
|
addItem('Boarding passes & hotel reservations (offline)', 'documents', 1, 'personal_item', true);
|
||||||
|
if (tripType === 'international_urban' || tripType === 'beach') {
|
||||||
|
addItem('Health / Travel insurance card', 'documents', 1, 'personal_item', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. TECH & ESSENTIAL ELECTRONICS (TSA requires lithium batteries in cabin!)
|
||||||
|
addItem('Smartphone & heavy-duty cable', 'tech', 1, 'personal_item', true);
|
||||||
|
addItem('Power Bank (10,000–20,000 mAh)', 'tech', 1, 'personal_item', true, 'FAA/TSA rule: Must be in personal/cabin bag, never checked');
|
||||||
|
addItem('Noise-canceling headphones / earbuds', 'tech', 1, 'personal_item', false, 'For flight/transit');
|
||||||
|
addItem('Multi-port USB-C GaN fast wall charger', 'tech', 1, 'personal_item', true);
|
||||||
|
|
||||||
|
if (tripType === 'international_urban') {
|
||||||
|
addItem('Universal international travel plug adapter', 'tech', 1, 'personal_item', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activities.includes('remote_work') || tripType === 'business') {
|
||||||
|
addItem('Laptop & power brick', 'tech', 1, 'personal_item', true);
|
||||||
|
addItem('Wireless travel mouse & pad', 'tech', 1, 'personal_item');
|
||||||
|
addItem('Laptop stand / portable keyboard', 'tech', 1, 'carry_on');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activities.includes('photography')) {
|
||||||
|
addItem('Camera body & primary lens', 'tech', 1, 'personal_item', true);
|
||||||
|
addItem('Extra SD cards & spare battery', 'tech', 1, 'personal_item', true);
|
||||||
|
addItem('Lens cloth & rocket blower', 'tech', 1, 'personal_item');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. MEDICINE & WELLNESS
|
||||||
|
addItem('Prescription medications (full trip + 2 days reserve)', 'wellness', 1, 'personal_item', true, 'Keep in cabin bag in case luggage is delayed');
|
||||||
|
addItem('Mini medical kit (ibuprofen, band-aids, antacids, allergy)', 'wellness', 1, 'personal_item', false);
|
||||||
|
addItem('Electrolyte hydration packets', 'wellness', Math.min(durationDays, 5), 'personal_item');
|
||||||
|
addItem('Lip balm with SPF', 'wellness', 1, 'personal_item');
|
||||||
|
|
||||||
|
// 4. CLOTHING FORMULAS
|
||||||
|
// Laundry factor: if laundry is available, max out at ~4 days worth regardless of trip length!
|
||||||
|
const clothingMultiplier = hasLaundry ? Math.min(durationDays, 4) : Math.min(durationDays, 14);
|
||||||
|
|
||||||
|
// Underwear & socks: 1 per day + 1 buffer (or capped with laundry)
|
||||||
|
const underwearQty = hasLaundry ? Math.min(durationDays + 1, 5) : durationDays + 1;
|
||||||
|
const socksQty = hasLaundry ? Math.min(durationDays + 1, 5) : durationDays + 1;
|
||||||
|
|
||||||
|
addItem('Underwear', 'clothing', underwearQty, 'carry_on', true, hasLaundry ? 'Capped for mid-trip laundry cycle' : undefined);
|
||||||
|
addItem('Pairs of socks', 'clothing', socksQty, 'carry_on', true);
|
||||||
|
|
||||||
|
// Tops & shirts
|
||||||
|
const shirtsQty = hasLaundry ? Math.min(Math.ceil(durationDays * 0.6), 4) : Math.min(durationDays, 8);
|
||||||
|
addItem('T-shirts / casual tops', 'clothing', shirtsQty, 'carry_on');
|
||||||
|
|
||||||
|
// Bottoms
|
||||||
|
const pantsQty = Math.max(1, Math.min(Math.ceil(durationDays / 3), 3));
|
||||||
|
addItem('Pants / Jeans / Chinos', 'clothing', pantsQty, 'carry_on');
|
||||||
|
|
||||||
|
// Travel Day Outfit (Worn)
|
||||||
|
addItem('Travel day comfortable shoes', 'clothing', 1, 'worn', true, 'Wear heaviest shoes on plane to save luggage weight');
|
||||||
|
addItem('Comfortable travel pants / joggers', 'clothing', 1, 'worn', true);
|
||||||
|
addItem('Transit layer (soft hoodie or light sweater)', 'clothing', 1, 'worn', false, 'Airplanes are often cold');
|
||||||
|
|
||||||
|
// Sleepwear
|
||||||
|
addItem('Sleepwear / lounge set', 'clothing', hasLaundry ? 2 : Math.min(Math.ceil(durationDays / 3), 3), 'carry_on');
|
||||||
|
|
||||||
|
// 5. CLIMATE-SPECIFIC ADJUSTMENTS
|
||||||
|
if (climate === 'cold' || tripType === 'alpine_cold') {
|
||||||
|
addItem('Packable down / insulated winter jacket', 'clothing', 1, 'worn', true, 'Wear onto plane to save bag space');
|
||||||
|
addItem('Thermal base layer top & bottoms (merino wool)', 'clothing', 2, 'carry_on', true);
|
||||||
|
addItem('Warm wool beanie & neck gaiter', 'clothing', 1, 'personal_item');
|
||||||
|
addItem('Insulated windproof gloves', 'clothing', 1, 'personal_item');
|
||||||
|
addItem('Heavy wool boot socks', 'clothing', 3, 'carry_on');
|
||||||
|
addItem('Hand warmers', 'gear', 4, 'carry_on');
|
||||||
|
} else if (climate === 'hot' || tripType === 'beach') {
|
||||||
|
addItem('Lightweight linen / breathable shirts', 'clothing', Math.min(durationDays, 4), 'carry_on');
|
||||||
|
addItem('Casual shorts', 'clothing', Math.max(2, Math.min(durationDays, 4)), 'carry_on');
|
||||||
|
addItem('UV protection sunglasses', 'gear', 1, 'personal_item', true);
|
||||||
|
addItem('Wide-brim sun hat or baseball cap', 'clothing', 1, 'personal_item');
|
||||||
|
addItem('Reef-safe sunscreen SPF 50', 'toiletries', 1, luggageConfig === 'checked_bag' ? 'checked' : 'carry_on', true, luggageConfig === 'checked_bag' ? 'Full size in checked' : 'TSA 3.4oz limit');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (climate === 'rainy') {
|
||||||
|
addItem('Ultralight packable rain shell / jacket', 'clothing', 1, 'personal_item', true);
|
||||||
|
addItem('Compact windproof travel umbrella', 'gear', 1, 'personal_item');
|
||||||
|
addItem('Waterproof phone pouch / dry bag', 'gear', 1, 'carry_on');
|
||||||
|
addItem('Quick-dry moisture-wicking socks', 'clothing', 2, 'carry_on');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. ACTIVITY ADJUSTMENTS
|
||||||
|
if (activities.includes('swimming') || tripType === 'beach') {
|
||||||
|
addItem('Swimsuits / swim trunks', 'clothing', 2, 'carry_on', true);
|
||||||
|
addItem('Packable quick-dry microfiber towel', 'gear', 1, 'carry_on');
|
||||||
|
addItem('Waterproof wet bag for damp clothes', 'gear', 1, 'carry_on');
|
||||||
|
addItem('Waterproof sandals / flip-flops', 'clothing', 1, 'carry_on');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activities.includes('formal') || tripType === 'business') {
|
||||||
|
addItem('Tailored blazer / suit jacket', 'clothing', 1, 'carry_on', true);
|
||||||
|
addItem('Dress button-down shirts', 'clothing', Math.min(durationDays, 3), 'carry_on', true);
|
||||||
|
addItem('Dress belt', 'clothing', 1, 'carry_on');
|
||||||
|
addItem('Dress shoes / oxfords / heels', 'clothing', 1, 'carry_on', true);
|
||||||
|
if (tripType === 'business') {
|
||||||
|
addItem('Business cards & notebook / pen', 'documents', 1, 'personal_item');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activities.includes('hiking') || tripType === 'hiking_camping') {
|
||||||
|
addItem('Trail runners / hiking boots', 'clothing', 1, 'worn', true, 'Break them in before trip');
|
||||||
|
addItem('Lightweight packable daypack (15-20L)', 'gear', 1, 'carry_on', true);
|
||||||
|
addItem('Water filtration bottle / reservoir', 'gear', 1, 'carry_on', true);
|
||||||
|
addItem('Headlamp with extra batteries', 'gear', 1, 'personal_item', true);
|
||||||
|
addItem('Blister treatment & moleskin tape', 'wellness', 1, 'personal_item', true);
|
||||||
|
addItem('Insect repellent spray / wipes', 'toiletries', 1, 'carry_on');
|
||||||
|
if (luggageConfig === 'checked_bag') {
|
||||||
|
addItem('Collapsible trekking poles', 'gear', 1, 'checked', false, 'TSA banned in carry-on!');
|
||||||
|
addItem('Pocket knife / multi-tool', 'gear', 1, 'checked', false, 'TSA strictly prohibited in cabin');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activities.includes('gym')) {
|
||||||
|
addItem('Workout athletic shirts', 'clothing', 2, 'carry_on');
|
||||||
|
addItem('Athletic gym shorts / leggings', 'clothing', 2, 'carry_on');
|
||||||
|
addItem('Lightweight training shoes', 'clothing', 1, 'carry_on');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. TOILETRIES (TSA 3-1-1 liquids rule enforced if carry-on)
|
||||||
|
const toiletryBag: BagType = luggageConfig === 'checked_bag' ? 'checked' : 'carry_on';
|
||||||
|
const liquidNote = luggageConfig === 'checked_bag' ? 'Full size allowed' : 'Must be <= 3.4oz (100ml) in 1-quart transparent pouch';
|
||||||
|
|
||||||
|
addItem('Toothbrush & travel toothpaste', 'toiletries', 1, 'carry_on', true, liquidNote);
|
||||||
|
addItem('Dental floss', 'toiletries', 1, 'carry_on');
|
||||||
|
addItem('Deodorant', 'toiletries', 1, 'carry_on', true);
|
||||||
|
addItem('Face wash & moisturizer', 'toiletries', 1, toiletryBag, false, liquidNote);
|
||||||
|
addItem('Shampoo & conditioner bar or travel bottles', 'toiletries', 1, toiletryBag, false, liquidNote);
|
||||||
|
addItem('Razor & shave cream', 'toiletries', 1, toiletryBag);
|
||||||
|
addItem('Nail clippers & tweezers', 'toiletries', 1, toiletryBag);
|
||||||
|
|
||||||
|
// 8. TRANSIT CONVENIENCE & GEAR
|
||||||
|
addItem('Inflatable neck pillow & eye mask', 'gear', 1, 'personal_item');
|
||||||
|
addItem('Silicone earplugs (airplane cabin pressure & hotel noise)', 'gear', 1, 'personal_item');
|
||||||
|
addItem('Pen for customs / immigration arrival cards', 'documents', 1, 'personal_item', true);
|
||||||
|
addItem('Empty reusable water bottle (fill after airport security)', 'gear', 1, 'personal_item', true);
|
||||||
|
addItem('Compact nylon laundry bag / compression sack', 'gear', 1, 'carry_on');
|
||||||
|
|
||||||
|
// 9. CONTINUOUS LEARNING INJECTION (From User's Past Debriefs)
|
||||||
|
if (memory && memory.inclusions && memory.inclusions.length > 0) {
|
||||||
|
const relevantInclusions = memory.inclusions.filter((inc) => inc.tripType === tripType);
|
||||||
|
for (const inc of relevantInclusions) {
|
||||||
|
// Don't add duplicate if already present
|
||||||
|
const existing = items.find((i) => i.name.toLowerCase() === inc.itemName.toLowerCase());
|
||||||
|
if (!existing) {
|
||||||
|
addItem(
|
||||||
|
inc.itemName,
|
||||||
|
inc.category || 'gear',
|
||||||
|
1,
|
||||||
|
inc.defaultBag || 'carry_on',
|
||||||
|
false,
|
||||||
|
inc.reason || 'Added from your past trip debrief feedback',
|
||||||
|
'learned',
|
||||||
|
`Learned wisdom: You previously wished for this on a ${tripType.replace('_', ' ')}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import { Trip, PackMemory, AppSettings, TripDebrief, TripType } from '../types';
|
||||||
|
|
||||||
|
const STORAGE_KEYS = {
|
||||||
|
TRIPS: 'loadout_trips_v1',
|
||||||
|
MEMORY: 'loadout_memory_v1',
|
||||||
|
SETTINGS: 'loadout_settings_v1',
|
||||||
|
ACTIVE_TRIP_ID: 'loadout_active_trip_id_v1',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial default memory data so the app has smart travel wisdom right out of the box
|
||||||
|
const DEFAULT_MEMORY: PackMemory = {
|
||||||
|
exclusions: [
|
||||||
|
{ itemName: 'Heavy winter coat', tripType: 'weekend_getaway', count: 1, lastDate: '2026-08-10' },
|
||||||
|
{ itemName: 'Full size hair dryer', tripType: 'beach', count: 2, lastDate: '2026-07-14' },
|
||||||
|
{ itemName: 'Extra pair of dress shoes', tripType: 'business', count: 1, lastDate: '2026-09-02' },
|
||||||
|
],
|
||||||
|
inclusions: [
|
||||||
|
{
|
||||||
|
itemName: 'Packable laundry detergent sheets',
|
||||||
|
category: 'clothing',
|
||||||
|
defaultBag: 'carry_on',
|
||||||
|
tripType: 'international_urban',
|
||||||
|
count: 2,
|
||||||
|
lastDate: '2026-06-20',
|
||||||
|
reason: 'Saves hotel laundry fees; perfect for sink-washing socks/shirts',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
itemName: 'Compact 3-prong outlet splitter',
|
||||||
|
category: 'tech',
|
||||||
|
defaultBag: 'personal_item',
|
||||||
|
tripType: 'business',
|
||||||
|
count: 2,
|
||||||
|
lastDate: '2026-09-02',
|
||||||
|
reason: 'Airport and hotel outlets are always limited or loose',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
itemName: 'High SPF reef-safe mineral stick',
|
||||||
|
category: 'toiletries',
|
||||||
|
defaultBag: 'personal_item',
|
||||||
|
tripType: 'beach',
|
||||||
|
count: 3,
|
||||||
|
lastDate: '2026-07-14',
|
||||||
|
reason: 'Easy to reapply on the go without greasy hands',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalTripsDebriefed: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS: AppSettings = {
|
||||||
|
soundEnabled: true,
|
||||||
|
aiEnabled: false,
|
||||||
|
aiProvider: 'ollama',
|
||||||
|
ollamaUrl: 'http://localhost:11434',
|
||||||
|
ollamaModel: 'llama3.2',
|
||||||
|
apiKey: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Seed sample trip if user has none yet
|
||||||
|
function getInitialTrips(): Trip[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'trip_demo_tokyo',
|
||||||
|
title: 'Autumn in Tokyo',
|
||||||
|
destination: 'Tokyo, Japan',
|
||||||
|
routeCode: 'SEA ➔ HND',
|
||||||
|
tripType: 'international_urban',
|
||||||
|
durationDays: 7,
|
||||||
|
durationNights: 6,
|
||||||
|
climate: 'mild',
|
||||||
|
luggageConfig: 'carry_on_only',
|
||||||
|
hasLaundry: true,
|
||||||
|
activities: ['remote_work', 'formal'],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
items: [
|
||||||
|
{ id: 't1', name: 'Passport & IC Transit Card (Suica)', category: 'documents', quantity: 1, bag: 'personal_item', packed: true, essential: true },
|
||||||
|
{ id: 't2', name: 'Credit card (no foreign transaction fee)', category: 'documents', quantity: 1, bag: 'personal_item', packed: true, essential: true },
|
||||||
|
{ id: 't3', name: 'Laptop & 65W GaN USB-C charger', category: 'tech', quantity: 1, bag: 'personal_item', packed: true, essential: true },
|
||||||
|
{ id: 't4', name: 'Power bank 10,000mAh', category: 'tech', quantity: 1, bag: 'personal_item', packed: true, essential: true, notes: 'Cabin only' },
|
||||||
|
{ id: 't5', name: 'Noise-canceling headphones', category: 'tech', quantity: 1, bag: 'personal_item', packed: false },
|
||||||
|
{ id: 't6', name: 'Packable laundry detergent sheets', category: 'clothing', quantity: 1, bag: 'carry_on', packed: true, source: 'learned', learnedReason: 'Suggested from previous debrief' },
|
||||||
|
{ id: 't7', name: 'Merino wool t-shirts', category: 'clothing', quantity: 3, bag: 'carry_on', packed: true },
|
||||||
|
{ id: 't8', name: 'Comfortable walking sneakers', category: 'clothing', quantity: 1, bag: 'worn', packed: true, essential: true, notes: 'Tokyo averages 15k+ steps/day' },
|
||||||
|
{ id: 't9', name: 'Compact travel umbrella', category: 'gear', quantity: 1, bag: 'personal_item', packed: false },
|
||||||
|
{ id: 't10', name: 'Slip-on dress shoes for dinners', category: 'clothing', quantity: 1, bag: 'carry_on', packed: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StorageClient = {
|
||||||
|
// Fetch trips (tries backend API first, falls back to local storage)
|
||||||
|
async getTrips(): Promise<Trip[]> {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/trips');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
if (Array.isArray(data) && data.length > 0) {
|
||||||
|
localStorage.setItem(STORAGE_KEYS.TRIPS, JSON.stringify(data));
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Offline fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const local = localStorage.getItem(STORAGE_KEYS.TRIPS);
|
||||||
|
if (local) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(local);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const initial = getInitialTrips();
|
||||||
|
localStorage.setItem(STORAGE_KEYS.TRIPS, JSON.stringify(initial));
|
||||||
|
return initial;
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveTrip(trip: Trip): Promise<void> {
|
||||||
|
const trips = await this.getTrips();
|
||||||
|
const index = trips.findIndex((t) => t.id === trip.id);
|
||||||
|
let updated: Trip[];
|
||||||
|
if (index >= 0) {
|
||||||
|
updated = [...trips];
|
||||||
|
updated[index] = trip;
|
||||||
|
} else {
|
||||||
|
updated = [trip, ...trips];
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(STORAGE_KEYS.TRIPS, JSON.stringify(updated));
|
||||||
|
|
||||||
|
// Async sync to backend
|
||||||
|
try {
|
||||||
|
await fetch('/api/trips', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(trip),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Offline queued
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteTrip(tripId: string): Promise<void> {
|
||||||
|
const trips = await this.getTrips();
|
||||||
|
const updated = trips.filter((t) => t.id !== tripId);
|
||||||
|
localStorage.setItem(STORAGE_KEYS.TRIPS, JSON.stringify(updated));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`/api/trips/${tripId}`, { method: 'DELETE' });
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Active trip ID
|
||||||
|
getActiveTripId(): string | null {
|
||||||
|
return localStorage.getItem(STORAGE_KEYS.ACTIVE_TRIP_ID);
|
||||||
|
},
|
||||||
|
|
||||||
|
setActiveTripId(id: string): void {
|
||||||
|
localStorage.setItem(STORAGE_KEYS.ACTIVE_TRIP_ID, id);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Memory & Learning
|
||||||
|
async getMemory(): Promise<PackMemory> {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/memory');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
if (data && data.exclusions) {
|
||||||
|
localStorage.setItem(STORAGE_KEYS.MEMORY, JSON.stringify(data));
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const local = localStorage.getItem(STORAGE_KEYS.MEMORY);
|
||||||
|
if (local) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(local);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(STORAGE_KEYS.MEMORY, JSON.stringify(DEFAULT_MEMORY));
|
||||||
|
return DEFAULT_MEMORY;
|
||||||
|
},
|
||||||
|
|
||||||
|
async recordDebrief(tripId: string, debrief: TripDebrief): Promise<Trip | null> {
|
||||||
|
const trips = await this.getTrips();
|
||||||
|
const trip = trips.find((t) => t.id === tripId);
|
||||||
|
if (!trip) return null;
|
||||||
|
|
||||||
|
trip.debrief = debrief;
|
||||||
|
await this.saveTrip(trip);
|
||||||
|
|
||||||
|
// Update memory
|
||||||
|
const memory = await this.getMemory();
|
||||||
|
memory.totalTripsDebriefed += 1;
|
||||||
|
|
||||||
|
// Record deadweight exclusions
|
||||||
|
for (const itemName of debrief.excludedItemNames) {
|
||||||
|
const existing = memory.exclusions.find(
|
||||||
|
(e) => e.tripType === trip.tripType && e.itemName.toLowerCase() === itemName.toLowerCase()
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
existing.count += 1;
|
||||||
|
existing.lastDate = new Date().toISOString();
|
||||||
|
} else {
|
||||||
|
memory.exclusions.push({
|
||||||
|
itemName,
|
||||||
|
tripType: trip.tripType,
|
||||||
|
count: 1,
|
||||||
|
lastDate: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record wishlist inclusions
|
||||||
|
for (const itemName of debrief.includedItemNames) {
|
||||||
|
const existing = memory.inclusions.find(
|
||||||
|
(i) => i.tripType === trip.tripType && i.itemName.toLowerCase() === itemName.toLowerCase()
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
existing.count += 1;
|
||||||
|
existing.lastDate = new Date().toISOString();
|
||||||
|
} else {
|
||||||
|
memory.inclusions.push({
|
||||||
|
itemName,
|
||||||
|
category: 'gear',
|
||||||
|
defaultBag: 'carry_on',
|
||||||
|
tripType: trip.tripType,
|
||||||
|
count: 1,
|
||||||
|
lastDate: new Date().toISOString(),
|
||||||
|
reason: `Wished for after trip: ${trip.title}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(STORAGE_KEYS.MEMORY, JSON.stringify(memory));
|
||||||
|
|
||||||
|
// Sync to backend
|
||||||
|
try {
|
||||||
|
await fetch('/api/memory/debrief', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ tripId, debrief }),
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return trip;
|
||||||
|
},
|
||||||
|
|
||||||
|
// App Settings
|
||||||
|
getSettings(): AppSettings {
|
||||||
|
const local = localStorage.getItem(STORAGE_KEYS.SETTINGS);
|
||||||
|
if (local) {
|
||||||
|
try {
|
||||||
|
return { ...DEFAULT_SETTINGS, ...JSON.parse(local) };
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return DEFAULT_SETTINGS;
|
||||||
|
},
|
||||||
|
|
||||||
|
saveSettings(settings: AppSettings): void {
|
||||||
|
localStorage.setItem(STORAGE_KEYS.SETTINGS, JSON.stringify(settings));
|
||||||
|
try {
|
||||||
|
fetch('/api/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(settings),
|
||||||
|
}).catch(() => {});
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
display: ['"Bebas Neue"', 'Impact', 'sans-serif'],
|
||||||
|
grotesk: ['"Space Grotesk"', 'sans-serif'],
|
||||||
|
sans: ['"Plus Jakarta Sans"', 'system-ui', 'sans-serif'],
|
||||||
|
mono: ['"IBM Plex Mono"', 'monospace'],
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
ticket: {
|
||||||
|
50: '#FDFBF7',
|
||||||
|
100: '#F7F4EB',
|
||||||
|
200: '#EFE9DA',
|
||||||
|
300: '#E2D9C3',
|
||||||
|
400: '#D1C4A5',
|
||||||
|
border: '#D0C8B5',
|
||||||
|
},
|
||||||
|
ink: {
|
||||||
|
DEFAULT: '#141518',
|
||||||
|
light: '#2B2D33',
|
||||||
|
muted: '#666C79',
|
||||||
|
faint: '#9EA4B1',
|
||||||
|
},
|
||||||
|
canary: {
|
||||||
|
DEFAULT: '#FFD100',
|
||||||
|
hover: '#E6BC00',
|
||||||
|
light: '#FFF6D1',
|
||||||
|
},
|
||||||
|
gate: {
|
||||||
|
orange: '#FF4D1A',
|
||||||
|
hover: '#E53E0D',
|
||||||
|
light: '#FFEBE5',
|
||||||
|
},
|
||||||
|
stamp: {
|
||||||
|
green: '#147D52',
|
||||||
|
'green-light': '#EBFBF3',
|
||||||
|
red: '#D92D20',
|
||||||
|
'red-light': '#FEECEB',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
'stub': '2px 3px 0px rgba(20,21,24,0.12)',
|
||||||
|
'stub-hover': '3px 5px 0px rgba(20,21,24,0.18)',
|
||||||
|
'card-hard': '4px 4px 0px #141518',
|
||||||
|
'card-hard-sm': '2px 2px 0px #141518',
|
||||||
|
'stamp': 'inset 0 0 0 2px #FF4D1A',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
host: true
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in new issue
Block a user