trip) adding support for lodgings
This commit is contained in:
674
src/lib/components/AddFlightModal.svelte
Normal file
674
src/lib/components/AddFlightModal.svelte
Normal file
@@ -0,0 +1,674 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { base } from '$app/paths';
|
||||
import type { Person } from '$lib/server/travellers.js';
|
||||
|
||||
interface Airport {
|
||||
id: number;
|
||||
iata_code: string | null;
|
||||
icao_code: string | null;
|
||||
name: string;
|
||||
city: string | null;
|
||||
country: string;
|
||||
country_code: string;
|
||||
}
|
||||
|
||||
interface Airline {
|
||||
id: number;
|
||||
iata_code: string | null;
|
||||
icao_code: string | null;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface FlightSegment {
|
||||
departureDate: string;
|
||||
departureDatetime: string;
|
||||
arrivalDatetime: string;
|
||||
airlineId: number | null;
|
||||
airlineIata: string;
|
||||
airlineName: string;
|
||||
flightNumber: string;
|
||||
departureAirportId: number | null;
|
||||
departureAirportCode: string;
|
||||
arrivalAirportId: number | null;
|
||||
arrivalAirportCode: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onclose: () => void;
|
||||
people: Person[];
|
||||
tripTravellerIds: string[];
|
||||
}
|
||||
|
||||
let { open, onclose, people = [], tripTravellerIds = [] }: Props = $props();
|
||||
|
||||
let confirmationNumber = $state('');
|
||||
let price = $state('');
|
||||
let currency = $state('USD');
|
||||
let status = $state<'idea' | 'tentative' | 'confirmed'>('idea');
|
||||
let selectedPassengers = $state<string[]>([]);
|
||||
let segments = $state<FlightSegment[]>([
|
||||
{
|
||||
departureDate: '',
|
||||
departureDatetime: '',
|
||||
arrivalDatetime: '',
|
||||
airlineId: null,
|
||||
airlineIata: '',
|
||||
airlineName: '',
|
||||
flightNumber: '',
|
||||
departureAirportId: null,
|
||||
departureAirportCode: '',
|
||||
arrivalAirportId: null,
|
||||
arrivalAirportCode: ''
|
||||
}
|
||||
]);
|
||||
|
||||
// Search states
|
||||
let airportQuery = $state('');
|
||||
let airports = $state<Airport[]>([]);
|
||||
let airlineQuery = $state('');
|
||||
let airlines = $state<Airline[]>([]);
|
||||
let loadingAirports = $state(false);
|
||||
let loadingAirlines = $state(false);
|
||||
let activeSearchField: {
|
||||
segmentIndex: number;
|
||||
field: 'departure' | 'arrival' | 'airline';
|
||||
} | null = $state(null);
|
||||
|
||||
let airportDebounce: ReturnType<typeof setTimeout>;
|
||||
let airlineDebounce: ReturnType<typeof setTimeout>;
|
||||
|
||||
function searchAirports(query: string) {
|
||||
clearTimeout(airportDebounce);
|
||||
if (!query.trim()) {
|
||||
airports = [];
|
||||
return;
|
||||
}
|
||||
airportDebounce = setTimeout(async () => {
|
||||
loadingAirports = true;
|
||||
try {
|
||||
const res = await fetch(`${base}/api/airports?q=${encodeURIComponent(query)}`);
|
||||
airports = await res.json();
|
||||
} finally {
|
||||
loadingAirports = false;
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function searchAirlines(query: string) {
|
||||
clearTimeout(airlineDebounce);
|
||||
if (!query.trim()) {
|
||||
airlines = [];
|
||||
return;
|
||||
}
|
||||
airlineDebounce = setTimeout(async () => {
|
||||
loadingAirlines = true;
|
||||
try {
|
||||
const res = await fetch(`${base}/api/airlines?q=${encodeURIComponent(query)}`);
|
||||
airlines = await res.json();
|
||||
} finally {
|
||||
loadingAirlines = false;
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function selectAirport(segmentIndex: number, field: 'departure' | 'arrival', airport: Airport) {
|
||||
if (field === 'departure') {
|
||||
segments[segmentIndex].departureAirportId = airport.id;
|
||||
segments[segmentIndex].departureAirportCode = airport.iata_code || airport.icao_code || '';
|
||||
} else {
|
||||
segments[segmentIndex].arrivalAirportId = airport.id;
|
||||
segments[segmentIndex].arrivalAirportCode = airport.iata_code || airport.icao_code || '';
|
||||
}
|
||||
airports = [];
|
||||
activeSearchField = null;
|
||||
}
|
||||
|
||||
function selectAirline(segmentIndex: number, airline: Airline) {
|
||||
segments[segmentIndex].airlineId = airline.id;
|
||||
segments[segmentIndex].airlineIata = airline.iata_code || '';
|
||||
segments[segmentIndex].airlineName = airline.name;
|
||||
airlines = [];
|
||||
activeSearchField = null;
|
||||
}
|
||||
|
||||
function addSegment() {
|
||||
segments = [
|
||||
...segments,
|
||||
{
|
||||
departureDate: '',
|
||||
departureDatetime: '',
|
||||
arrivalDatetime: '',
|
||||
airlineId: null,
|
||||
airlineIata: '',
|
||||
airlineName: '',
|
||||
flightNumber: '',
|
||||
departureAirportId: null,
|
||||
departureAirportCode: '',
|
||||
arrivalAirportId: null,
|
||||
arrivalAirportCode: ''
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function removeSegment(index: number) {
|
||||
segments = segments.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
confirmationNumber = '';
|
||||
price = '';
|
||||
currency = 'USD';
|
||||
status = 'idea';
|
||||
selectedPassengers = [];
|
||||
segments = [
|
||||
{
|
||||
departureDate: '',
|
||||
departureDatetime: '',
|
||||
arrivalDatetime: '',
|
||||
airlineId: null,
|
||||
airlineIata: '',
|
||||
airlineName: '',
|
||||
flightNumber: '',
|
||||
departureAirportId: null,
|
||||
departureAirportCode: '',
|
||||
arrivalAirportId: null,
|
||||
arrivalAirportCode: ''
|
||||
}
|
||||
];
|
||||
airports = [];
|
||||
airlines = [];
|
||||
activeSearchField = null;
|
||||
}
|
||||
|
||||
function togglePassenger(personId: string) {
|
||||
if (selectedPassengers.includes(personId)) {
|
||||
selectedPassengers = selectedPassengers.filter((id) => id !== personId);
|
||||
} else {
|
||||
selectedPassengers = [...selectedPassengers, personId];
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
reset();
|
||||
onclose();
|
||||
}
|
||||
|
||||
let canSubmit = $derived(
|
||||
segments.length > 0 &&
|
||||
segments.every(
|
||||
(s) => s.departureDate && s.flightNumber && (s.airlineId || s.airlineIata || s.airlineName)
|
||||
)
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 z-40 bg-black/30"
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
onclick={handleClose}
|
||||
onkeydown={(e) => e.key === 'Escape' && handleClose()}
|
||||
></div>
|
||||
|
||||
<!-- Panel -->
|
||||
<div
|
||||
class="fixed top-0 right-0 z-50 flex h-full w-full max-w-2xl flex-col bg-white shadow-xl"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Add flight"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-gray-200 px-6 py-4">
|
||||
<h2 class="text-base font-semibold text-gray-900">Add flight</h2>
|
||||
<button
|
||||
onclick={handleClose}
|
||||
class="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<form
|
||||
method="POST"
|
||||
action="?/addFlight"
|
||||
class="flex flex-1 flex-col overflow-y-auto"
|
||||
use:enhance={() => {
|
||||
return ({ result, update }) => {
|
||||
update();
|
||||
if (result.type === 'success') handleClose();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-1 flex-col gap-6 px-6 py-6">
|
||||
<!-- Booking Details -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="confirmation_number" class="text-sm font-medium text-gray-700">
|
||||
Confirmation number
|
||||
</label>
|
||||
<input
|
||||
id="confirmation_number"
|
||||
name="confirmation_number"
|
||||
type="text"
|
||||
bind:value={confirmationNumber}
|
||||
placeholder="ABC123"
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="price" class="text-sm font-medium text-gray-700">Price</label>
|
||||
<div class="flex gap-2">
|
||||
<select
|
||||
name="currency"
|
||||
bind:value={currency}
|
||||
class="rounded-md border border-gray-300 px-2 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="USD">USD</option>
|
||||
<option value="EUR">EUR</option>
|
||||
<option value="GBP">GBP</option>
|
||||
<option value="CAD">CAD</option>
|
||||
<option value="JPY">JPY</option>
|
||||
<option value="AUD">AUD</option>
|
||||
</select>
|
||||
<input
|
||||
id="price"
|
||||
name="price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
bind:value={price}
|
||||
placeholder="0.00"
|
||||
class="flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="text-sm font-medium text-gray-700">Status</span>
|
||||
<div class="flex gap-2">
|
||||
{#each [{ value: 'idea', label: 'Idea', color: 'bg-gray-100 text-gray-700 border-gray-300' }, { value: 'tentative', label: 'Tentative', color: 'bg-yellow-50 text-yellow-800 border-yellow-300' }, { value: 'confirmed', label: 'Confirmed', color: 'bg-green-50 text-green-800 border-green-300' }] as opt}
|
||||
<label
|
||||
class="flex cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm transition-all {status ===
|
||||
opt.value
|
||||
? opt.color +
|
||||
' ring-2 ring-offset-1 ' +
|
||||
(opt.value === 'idea'
|
||||
? 'ring-gray-400'
|
||||
: opt.value === 'tentative'
|
||||
? 'ring-yellow-400'
|
||||
: 'ring-green-500')
|
||||
: 'border-gray-200 bg-white text-gray-600 hover:bg-gray-50'}"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="status"
|
||||
value={opt.value}
|
||||
bind:group={status}
|
||||
class="sr-only"
|
||||
/>
|
||||
{opt.label}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Passengers -->
|
||||
{#if people.length > 0 && tripTravellerIds.length > 0}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="text-sm font-medium text-gray-700">Passengers</span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each people.filter((p) => tripTravellerIds.includes(p.id)) as person}
|
||||
<label
|
||||
class="flex cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm {selectedPassengers.includes(
|
||||
person.id
|
||||
)
|
||||
? 'border-blue-500 bg-blue-50 text-blue-700'
|
||||
: 'border-gray-200 bg-white text-gray-600 hover:bg-gray-50'}"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedPassengers.includes(person.id)}
|
||||
onchange={() => togglePassenger(person.id)}
|
||||
class="sr-only"
|
||||
/>
|
||||
{person.first_name}
|
||||
{person.last_name}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#each selectedPassengers as personId}
|
||||
<input type="hidden" name="passenger_ids[]" value={personId} />
|
||||
{/each}
|
||||
|
||||
<!-- Flight Segments -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-700">Flight segments</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={addSegment}
|
||||
class="text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
+ Add segment
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#each segments as segment, segmentIndex (segmentIndex)}
|
||||
<div class="rounded-lg border border-gray-200 p-4">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-gray-500">Segment {segmentIndex + 1}</span>
|
||||
{#if segments.length > 1}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeSegment(segmentIndex)}
|
||||
class="text-xs text-red-600 hover:text-red-700"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Departure Date -->
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-xs font-medium text-gray-700">Departure date</label>
|
||||
<input
|
||||
type="date"
|
||||
bind:value={segment.departureDate}
|
||||
name="segments[{segmentIndex}][departure_date]"
|
||||
required
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Airline -->
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-xs font-medium text-gray-700">Airline</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={segment.airlineIata}
|
||||
oninput={(e) => {
|
||||
searchAirlines(e.currentTarget.value);
|
||||
activeSearchField = { segmentIndex, field: 'airline' };
|
||||
}}
|
||||
placeholder="Search airline or enter code"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
{#if loadingAirlines && activeSearchField?.segmentIndex === segmentIndex && activeSearchField?.field === 'airline'}
|
||||
<div class="absolute top-2.5 right-3 text-gray-400">
|
||||
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
{#if airlines.length > 0 && activeSearchField?.segmentIndex === segmentIndex && activeSearchField?.field === 'airline'}
|
||||
<ul
|
||||
class="absolute z-10 mt-1 w-full overflow-hidden rounded-md border border-gray-200 bg-white shadow-lg"
|
||||
>
|
||||
{#each airlines as airline}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => selectAirline(segmentIndex, airline)}
|
||||
class="flex w-full items-center gap-3 px-3 py-2.5 text-left text-sm hover:bg-gray-50"
|
||||
>
|
||||
<span class="font-medium text-gray-900">{airline.name}</span>
|
||||
{#if airline.iata_code}
|
||||
<span class="text-gray-500">({airline.iata_code})</span>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{#if segment.airlineId}
|
||||
<input
|
||||
type="hidden"
|
||||
name="segments[{segmentIndex}][airline_id]"
|
||||
value={segment.airlineId}
|
||||
/>
|
||||
{/if}
|
||||
{#if segment.airlineIata}
|
||||
<input
|
||||
type="hidden"
|
||||
name="segments[{segmentIndex}][airline_iata]"
|
||||
value={segment.airlineIata}
|
||||
/>
|
||||
{/if}
|
||||
{#if segment.airlineName}
|
||||
<input
|
||||
type="hidden"
|
||||
name="segments[{segmentIndex}][airline_name]"
|
||||
value={segment.airlineName}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Flight Number -->
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-xs font-medium text-gray-700">Flight number</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={segment.flightNumber}
|
||||
name="segments[{segmentIndex}][flight_number]"
|
||||
required
|
||||
placeholder="1234"
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Airports -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-xs font-medium text-gray-700">Departure airport</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={segment.departureAirportCode}
|
||||
oninput={(e) => {
|
||||
searchAirports(e.currentTarget.value);
|
||||
activeSearchField = { segmentIndex, field: 'departure' };
|
||||
}}
|
||||
placeholder="Code or search"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
{#if loadingAirports && activeSearchField?.segmentIndex === segmentIndex && activeSearchField?.field === 'departure'}
|
||||
<div class="absolute top-2.5 right-3 text-gray-400">
|
||||
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
{#if airports.length > 0 && activeSearchField?.segmentIndex === segmentIndex && activeSearchField?.field === 'departure'}
|
||||
<ul
|
||||
class="absolute z-10 mt-1 w-full overflow-hidden rounded-md border border-gray-200 bg-white shadow-lg"
|
||||
>
|
||||
{#each airports as airport}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => selectAirport(segmentIndex, 'departure', airport)}
|
||||
class="flex w-full items-center gap-3 px-3 py-2.5 text-left text-sm hover:bg-gray-50"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-gray-900">{airport.name}</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{airport.iata_code || airport.icao_code} • {airport.city}, {airport.country}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{#if segment.departureAirportId}
|
||||
<input
|
||||
type="hidden"
|
||||
name="segments[{segmentIndex}][departure_airport_id]"
|
||||
value={segment.departureAirportId}
|
||||
/>
|
||||
{/if}
|
||||
{#if segment.departureAirportCode}
|
||||
<input
|
||||
type="hidden"
|
||||
name="segments[{segmentIndex}][departure_airport_code]"
|
||||
value={segment.departureAirportCode}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-xs font-medium text-gray-700">Arrival airport</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={segment.arrivalAirportCode}
|
||||
oninput={(e) => {
|
||||
searchAirports(e.currentTarget.value);
|
||||
activeSearchField = { segmentIndex, field: 'arrival' };
|
||||
}}
|
||||
placeholder="Code or search"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
{#if loadingAirports && activeSearchField?.segmentIndex === segmentIndex && activeSearchField?.field === 'arrival'}
|
||||
<div class="absolute top-2.5 right-3 text-gray-400">
|
||||
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
{#if airports.length > 0 && activeSearchField?.segmentIndex === segmentIndex && activeSearchField?.field === 'arrival'}
|
||||
<ul
|
||||
class="absolute z-10 mt-1 w-full overflow-hidden rounded-md border border-gray-200 bg-white shadow-lg"
|
||||
>
|
||||
{#each airports as airport}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => selectAirport(segmentIndex, 'arrival', airport)}
|
||||
class="flex w-full items-center gap-3 px-3 py-2.5 text-left text-sm hover:bg-gray-50"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-gray-900">{airport.name}</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{airport.iata_code || airport.icao_code} • {airport.city}, {airport.country}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{#if segment.arrivalAirportId}
|
||||
<input
|
||||
type="hidden"
|
||||
name="segments[{segmentIndex}][arrival_airport_id]"
|
||||
value={segment.arrivalAirportId}
|
||||
/>
|
||||
{/if}
|
||||
{#if segment.arrivalAirportCode}
|
||||
<input
|
||||
type="hidden"
|
||||
name="segments[{segmentIndex}][arrival_airport_code]"
|
||||
value={segment.arrivalAirportCode}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Times -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-xs font-medium text-gray-700">Departure time</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
bind:value={segment.departureDatetime}
|
||||
name="segments[{segmentIndex}][departure_datetime]"
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-xs font-medium text-gray-700">Arrival time</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
bind:value={segment.arrivalDatetime}
|
||||
name="segments[{segmentIndex}][arrival_datetime]"
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex justify-end gap-3 border-t border-gray-200 px-6 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleClose}
|
||||
class="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Add flight
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
682
src/lib/components/AddLodgingModal.svelte
Normal file
682
src/lib/components/AddLodgingModal.svelte
Normal file
@@ -0,0 +1,682 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { base } from '$app/paths';
|
||||
import type { Person } from '$lib/server/travellers.js';
|
||||
|
||||
interface City {
|
||||
id: number;
|
||||
name: string;
|
||||
country: string;
|
||||
country_code: string;
|
||||
population: number | null;
|
||||
}
|
||||
|
||||
interface Country {
|
||||
name: string;
|
||||
country_code: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onclose: () => void;
|
||||
people: Person[];
|
||||
tripTravellerIds: string[];
|
||||
}
|
||||
|
||||
let { open, onclose, people = [], tripTravellerIds = [] }: Props = $props();
|
||||
|
||||
const TIMEZONES = [
|
||||
{ label: 'UTC (UTC+0)', value: 'UTC' },
|
||||
{ label: 'London (GMT/BST, UTC+0/+1)', value: 'Europe/London' },
|
||||
{ label: 'Dublin (IST, UTC+0/+1)', value: 'Europe/Dublin' },
|
||||
{ label: 'Lisbon (WET/WEST, UTC+0/+1)', value: 'Europe/Lisbon' },
|
||||
{ label: 'Paris / Berlin / Rome (CET, UTC+1/+2)', value: 'Europe/Paris' },
|
||||
{ label: 'Helsinki / Athens (EET, UTC+2/+3)', value: 'Europe/Helsinki' },
|
||||
{ label: 'Istanbul (TRT, UTC+3)', value: 'Europe/Istanbul' },
|
||||
{ label: 'Moscow (MSK, UTC+3)', value: 'Europe/Moscow' },
|
||||
{ label: 'Dubai (GST, UTC+4)', value: 'Asia/Dubai' },
|
||||
{ label: 'Karachi (PKT, UTC+5)', value: 'Asia/Karachi' },
|
||||
{ label: 'Kolkata (IST, UTC+5:30)', value: 'Asia/Kolkata' },
|
||||
{ label: 'Dhaka (BST, UTC+6)', value: 'Asia/Dhaka' },
|
||||
{ label: 'Bangkok (ICT, UTC+7)', value: 'Asia/Bangkok' },
|
||||
{ label: 'Singapore / Kuala Lumpur (SGT/MYT, UTC+8)', value: 'Asia/Singapore' },
|
||||
{ label: 'Hong Kong (HKT, UTC+8)', value: 'Asia/Hong_Kong' },
|
||||
{ label: 'Shanghai / Beijing (CST, UTC+8)', value: 'Asia/Shanghai' },
|
||||
{ label: 'Perth (AWST, UTC+8)', value: 'Australia/Perth' },
|
||||
{ label: 'Tokyo / Seoul (JST/KST, UTC+9)', value: 'Asia/Tokyo' },
|
||||
{ label: 'Darwin (ACST, UTC+9:30)', value: 'Australia/Darwin' },
|
||||
{ label: 'Brisbane (AEST, UTC+10)', value: 'Australia/Brisbane' },
|
||||
{ label: 'Sydney / Melbourne (AEST/AEDT, UTC+10/+11)', value: 'Australia/Sydney' },
|
||||
{ label: 'Auckland (NZST/NZDT, UTC+12/+13)', value: 'Pacific/Auckland' },
|
||||
{ label: 'Fiji (FJT, UTC+12)', value: 'Pacific/Fiji' },
|
||||
{ label: 'Azores (AZOT/AZOST, UTC-1/0)', value: 'Atlantic/Azores' },
|
||||
{ label: 'Cape Verde (CVT, UTC-1)', value: 'Atlantic/Cape_Verde' },
|
||||
{ label: 'Buenos Aires (ART, UTC-3)', value: 'America/Argentina/Buenos_Aires' },
|
||||
{ label: 'Sao Paulo (BRT/BRST, UTC-3/−2)', value: 'America/Sao_Paulo' },
|
||||
{ label: 'Halifax (AST/ADT, UTC-4/−3)', value: 'America/Halifax' },
|
||||
{ label: 'New York / Toronto (EST/EDT, UTC-5/−4)', value: 'America/New_York' },
|
||||
{ label: 'Chicago / Mexico City (CST/CDT, UTC-6/−5)', value: 'America/Chicago' },
|
||||
{ label: 'Denver (MST/MDT, UTC-7/−6)', value: 'America/Denver' },
|
||||
{ label: 'Phoenix (MST, UTC-7)', value: 'America/Phoenix' },
|
||||
{ label: 'Los Angeles / Vancouver (PST/PDT, UTC-8/−7)', value: 'America/Los_Angeles' },
|
||||
{ label: 'Anchorage (AKST/AKDT, UTC-9/−8)', value: 'America/Anchorage' },
|
||||
{ label: 'Honolulu (HST, UTC-10)', value: 'Pacific/Honolulu' }
|
||||
];
|
||||
|
||||
let name = $state('');
|
||||
let chain = $state('');
|
||||
let status = $state<'idea' | 'tentative' | 'confirmed'>('idea');
|
||||
let checkInDate = $state('');
|
||||
let checkInTime = $state('');
|
||||
let checkInTimezone = $state('');
|
||||
let checkOutDate = $state('');
|
||||
let checkOutTime = $state('');
|
||||
let checkOutTimezone = $state('');
|
||||
let addressLine1 = $state('');
|
||||
let addressLine2 = $state('');
|
||||
let cityName = $state('');
|
||||
let country = $state('');
|
||||
let countryCode = $state('');
|
||||
let postalCode = $state('');
|
||||
let cityQuery = $state('');
|
||||
let cities = $state<City[]>([]);
|
||||
let loadingCities = $state(false);
|
||||
let countryQuery = $state('');
|
||||
let countries = $state<Country[]>([]);
|
||||
let loadingCountries = $state(false);
|
||||
let showCountryDropdown = $state(false);
|
||||
let showCityDropdown = $state(false);
|
||||
let cityDebounce: ReturnType<typeof setTimeout>;
|
||||
let countryDebounce: ReturnType<typeof setTimeout>;
|
||||
|
||||
function flagEmoji(code: string): string {
|
||||
if (!code || code.length !== 2) return '';
|
||||
return code
|
||||
.toUpperCase()
|
||||
.split('')
|
||||
.map((c) => String.fromCodePoint(0x1f1e6 + c.charCodeAt(0) - 65))
|
||||
.join('');
|
||||
}
|
||||
|
||||
function searchCities(query: string) {
|
||||
clearTimeout(cityDebounce);
|
||||
if (!query.trim()) {
|
||||
cities = [];
|
||||
return;
|
||||
}
|
||||
cityDebounce = setTimeout(async () => {
|
||||
loadingCities = true;
|
||||
try {
|
||||
const params = new URLSearchParams({ q: query.trim() });
|
||||
if (countryCode) params.set('country_code', countryCode);
|
||||
const res = await fetch(`${base}/api/cities?${params}`);
|
||||
cities = await res.json();
|
||||
} finally {
|
||||
loadingCities = false;
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function searchCountries(query: string) {
|
||||
clearTimeout(countryDebounce);
|
||||
countryDebounce = setTimeout(async () => {
|
||||
loadingCountries = true;
|
||||
try {
|
||||
const params = query.trim() ? `?q=${encodeURIComponent(query.trim())}` : '';
|
||||
const res = await fetch(`${base}/api/countries${params}`);
|
||||
countries = await res.json();
|
||||
} finally {
|
||||
loadingCountries = false;
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function selectCity(city: City) {
|
||||
cityName = city.name;
|
||||
country = city.country;
|
||||
countryCode = city.country_code;
|
||||
cities = [];
|
||||
showCityDropdown = false;
|
||||
}
|
||||
|
||||
function selectCountry(c: Country) {
|
||||
country = c.name;
|
||||
countryCode = c.country_code;
|
||||
countries = [];
|
||||
showCountryDropdown = false;
|
||||
}
|
||||
|
||||
function onCityInput() {
|
||||
showCityDropdown = true;
|
||||
searchCities(cityQuery || cityName);
|
||||
}
|
||||
|
||||
function onCountryInput() {
|
||||
showCountryDropdown = true;
|
||||
searchCountries(countryQuery || country);
|
||||
}
|
||||
let confirmationNumber = $state('');
|
||||
let website = $state('');
|
||||
let phone = $state('');
|
||||
let price = $state('');
|
||||
let currency = $state('USD');
|
||||
let selectedGuests = $state<string[]>([]);
|
||||
|
||||
function toggleGuest(personId: string) {
|
||||
if (selectedGuests.includes(personId)) {
|
||||
selectedGuests = selectedGuests.filter((id) => id !== personId);
|
||||
} else {
|
||||
selectedGuests = [...selectedGuests, personId];
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
name = '';
|
||||
chain = '';
|
||||
status = 'idea';
|
||||
checkInDate = '';
|
||||
checkInTime = '';
|
||||
checkInTimezone = '';
|
||||
checkOutDate = '';
|
||||
checkOutTime = '';
|
||||
checkOutTimezone = '';
|
||||
addressLine1 = '';
|
||||
addressLine2 = '';
|
||||
cityName = '';
|
||||
country = '';
|
||||
countryCode = '';
|
||||
cityQuery = '';
|
||||
countryQuery = '';
|
||||
cities = [];
|
||||
countries = [];
|
||||
showCityDropdown = false;
|
||||
showCountryDropdown = false;
|
||||
postalCode = '';
|
||||
confirmationNumber = '';
|
||||
website = '';
|
||||
phone = '';
|
||||
price = '';
|
||||
currency = 'USD';
|
||||
selectedGuests = [];
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
reset();
|
||||
onclose();
|
||||
}
|
||||
|
||||
let canSubmit = $derived(name.trim().length > 0);
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 z-40 bg-black/30"
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
onclick={handleClose}
|
||||
onkeydown={(e) => e.key === 'Escape' && handleClose()}
|
||||
></div>
|
||||
|
||||
<!-- Panel -->
|
||||
<div
|
||||
class="fixed top-0 right-0 z-50 flex h-full w-full max-w-2xl flex-col bg-white shadow-xl"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Add lodging"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-gray-200 px-6 py-4">
|
||||
<h2 class="text-base font-semibold text-gray-900">Add lodging</h2>
|
||||
<button
|
||||
onclick={handleClose}
|
||||
class="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<form
|
||||
method="POST"
|
||||
action="?/addLodging"
|
||||
class="flex flex-1 flex-col overflow-y-auto"
|
||||
use:enhance={() => {
|
||||
return ({ result, update }) => {
|
||||
update();
|
||||
if (result.type === 'success') handleClose();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-1 flex-col gap-6 px-6 py-6">
|
||||
<!-- Property details -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="lodging_name" class="text-sm font-medium text-gray-700">
|
||||
Name <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="lodging_name"
|
||||
name="name"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
required
|
||||
placeholder="Grand Hyatt"
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="lodging_chain" class="text-sm font-medium text-gray-700">
|
||||
Chain / Brand
|
||||
</label>
|
||||
<input
|
||||
id="lodging_chain"
|
||||
name="chain"
|
||||
type="text"
|
||||
bind:value={chain}
|
||||
placeholder="Hyatt Hotels"
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="text-sm font-medium text-gray-700">Status</span>
|
||||
<div class="flex gap-2">
|
||||
{#each [{ value: 'idea', label: 'Idea', color: 'bg-gray-100 text-gray-700 border-gray-300' }, { value: 'tentative', label: 'Tentative', color: 'bg-yellow-50 text-yellow-800 border-yellow-300' }, { value: 'confirmed', label: 'Confirmed', color: 'bg-green-50 text-green-800 border-green-300' }] as opt}
|
||||
<label
|
||||
class="flex cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm transition-all {status ===
|
||||
opt.value
|
||||
? opt.color +
|
||||
' ring-2 ring-offset-1 ' +
|
||||
(opt.value === 'idea'
|
||||
? 'ring-gray-400'
|
||||
: opt.value === 'tentative'
|
||||
? 'ring-yellow-400'
|
||||
: 'ring-green-500')
|
||||
: 'border-gray-200 bg-white text-gray-600 hover:bg-gray-50'}"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="status"
|
||||
value={opt.value}
|
||||
bind:group={status}
|
||||
class="sr-only"
|
||||
/>
|
||||
{opt.label}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Check-in / Check-out -->
|
||||
<div class="grid grid-cols-2 gap-6">
|
||||
<!-- Check-in -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<span class="text-sm font-medium text-gray-700">Check-in</span>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="check_in_date" class="text-xs font-medium text-gray-500">Date</label>
|
||||
<input
|
||||
id="check_in_date"
|
||||
name="check_in_date"
|
||||
type="date"
|
||||
bind:value={checkInDate}
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="check_in_time" class="text-xs font-medium text-gray-500">Time</label>
|
||||
<input
|
||||
id="check_in_time"
|
||||
name="check_in_time"
|
||||
type="time"
|
||||
bind:value={checkInTime}
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="check_in_timezone" class="text-xs font-medium text-gray-500"
|
||||
>Timezone</label
|
||||
>
|
||||
<select
|
||||
id="check_in_timezone"
|
||||
name="check_in_timezone"
|
||||
bind:value={checkInTimezone}
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">— select —</option>
|
||||
{#each TIMEZONES as tz}
|
||||
<option value={tz.value}>{tz.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Check-out -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<span class="text-sm font-medium text-gray-700">Check-out</span>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="check_out_date" class="text-xs font-medium text-gray-500">Date</label>
|
||||
<input
|
||||
id="check_out_date"
|
||||
name="check_out_date"
|
||||
type="date"
|
||||
bind:value={checkOutDate}
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="check_out_time" class="text-xs font-medium text-gray-500">Time</label>
|
||||
<input
|
||||
id="check_out_time"
|
||||
name="check_out_time"
|
||||
type="time"
|
||||
bind:value={checkOutTime}
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="check_out_timezone" class="text-xs font-medium text-gray-500"
|
||||
>Timezone</label
|
||||
>
|
||||
<select
|
||||
id="check_out_timezone"
|
||||
name="check_out_timezone"
|
||||
bind:value={checkOutTimezone}
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">— select —</option>
|
||||
{#each TIMEZONES as tz}
|
||||
<option value={tz.value}>{tz.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Address -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<span class="text-sm font-medium text-gray-700">Address</span>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="address_line1" class="text-xs font-medium text-gray-500"
|
||||
>Address line 1</label
|
||||
>
|
||||
<input
|
||||
id="address_line1"
|
||||
name="address_line1"
|
||||
type="text"
|
||||
bind:value={addressLine1}
|
||||
placeholder="10 Scotts Road"
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="address_line2" class="text-xs font-medium text-gray-500"
|
||||
>Address line 2</label
|
||||
>
|
||||
<input
|
||||
id="address_line2"
|
||||
name="address_line2"
|
||||
type="text"
|
||||
bind:value={addressLine2}
|
||||
placeholder="Level 3"
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="city_name" class="text-xs font-medium text-gray-500">City</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="city_name"
|
||||
name="city_name"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
bind:value={cityName}
|
||||
oninput={(e) => {
|
||||
cityName = e.currentTarget.value;
|
||||
cityQuery = e.currentTarget.value;
|
||||
onCityInput();
|
||||
}}
|
||||
onfocus={() => {
|
||||
if (cityName) onCityInput();
|
||||
}}
|
||||
onblur={() => setTimeout(() => (showCityDropdown = false), 150)}
|
||||
placeholder="Search or type city name"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
{#if loadingCities}
|
||||
<div class="absolute top-2.5 right-3 text-gray-400">
|
||||
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
{#if showCityDropdown && cities.length > 0}
|
||||
<ul
|
||||
class="absolute z-10 mt-1 max-h-48 w-full overflow-auto rounded-md border border-gray-200 bg-white shadow-lg"
|
||||
>
|
||||
{#each cities as city}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => selectCity(city)}
|
||||
class="flex w-full items-center gap-3 px-3 py-2.5 text-left text-sm hover:bg-gray-50"
|
||||
>
|
||||
<span class="text-lg leading-none">{flagEmoji(city.country_code)}</span>
|
||||
<div>
|
||||
<span class="font-medium text-gray-900">{city.name}</span>
|
||||
<span class="ml-1.5 text-gray-500">{city.country}</span>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="country" class="text-xs font-medium text-gray-500">Country</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="country"
|
||||
name="country"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
bind:value={country}
|
||||
oninput={(e) => {
|
||||
country = e.currentTarget.value;
|
||||
countryCode = '';
|
||||
countryQuery = e.currentTarget.value;
|
||||
onCountryInput();
|
||||
}}
|
||||
onfocus={() => {
|
||||
showCountryDropdown = true;
|
||||
if (!countries.length) searchCountries(country || '');
|
||||
}}
|
||||
onblur={() => setTimeout(() => (showCountryDropdown = false), 150)}
|
||||
placeholder="Search or type country"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
{#if loadingCountries}
|
||||
<div class="absolute top-2.5 right-3 text-gray-400">
|
||||
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
{#if showCountryDropdown && countries.length > 0}
|
||||
<ul
|
||||
class="absolute z-10 mt-1 max-h-48 w-full overflow-auto rounded-md border border-gray-200 bg-white shadow-lg"
|
||||
>
|
||||
{#each countries as c}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => selectCountry(c)}
|
||||
class="flex w-full items-center gap-3 px-3 py-2.5 text-left text-sm hover:bg-gray-50"
|
||||
>
|
||||
<span class="text-lg leading-none">{flagEmoji(c.country_code)}</span>
|
||||
<span class="font-medium text-gray-900">{c.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{#if countryCode}
|
||||
<input type="hidden" name="country_code" value={countryCode} />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="postal_code" class="text-xs font-medium text-gray-500">Postal code</label
|
||||
>
|
||||
<input
|
||||
id="postal_code"
|
||||
name="postal_code"
|
||||
type="text"
|
||||
bind:value={postalCode}
|
||||
placeholder="228211"
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Booking details -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="confirmation_number" class="text-sm font-medium text-gray-700">
|
||||
Confirmation number
|
||||
</label>
|
||||
<input
|
||||
id="confirmation_number"
|
||||
name="confirmation_number"
|
||||
type="text"
|
||||
bind:value={confirmationNumber}
|
||||
placeholder="XYZ999"
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="website" class="text-sm font-medium text-gray-700">Website</label>
|
||||
<input
|
||||
id="website"
|
||||
name="website"
|
||||
type="url"
|
||||
bind:value={website}
|
||||
placeholder="https://..."
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact + Cost -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="phone" class="text-sm font-medium text-gray-700">Phone</label>
|
||||
<input
|
||||
id="phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
bind:value={phone}
|
||||
placeholder="+65 6416 7000"
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="price" class="text-sm font-medium text-gray-700">Price</label>
|
||||
<div class="flex gap-2">
|
||||
<select
|
||||
name="currency"
|
||||
bind:value={currency}
|
||||
class="rounded-md border border-gray-300 px-2 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="USD">USD</option>
|
||||
<option value="EUR">EUR</option>
|
||||
<option value="GBP">GBP</option>
|
||||
<option value="CAD">CAD</option>
|
||||
<option value="JPY">JPY</option>
|
||||
<option value="AUD">AUD</option>
|
||||
<option value="SGD">SGD</option>
|
||||
<option value="HKD">HKD</option>
|
||||
<option value="NZD">NZD</option>
|
||||
</select>
|
||||
<input
|
||||
id="price"
|
||||
name="price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
bind:value={price}
|
||||
placeholder="0.00"
|
||||
class="flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Guests -->
|
||||
{#if people.length > 0 && tripTravellerIds.length > 0}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="text-sm font-medium text-gray-700">Guests</span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each people.filter((p) => tripTravellerIds.includes(p.id)) as person}
|
||||
<label
|
||||
class="flex cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm {selectedGuests.includes(
|
||||
person.id
|
||||
)
|
||||
? 'border-blue-500 bg-blue-50 text-blue-700'
|
||||
: 'border-gray-200 bg-white text-gray-600 hover:bg-gray-50'}"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedGuests.includes(person.id)}
|
||||
onchange={() => toggleGuest(person.id)}
|
||||
class="sr-only"
|
||||
/>
|
||||
{person.first_name}
|
||||
{person.last_name}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#each selectedGuests as personId}
|
||||
<input type="hidden" name="guest_ids[]" value={personId} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex justify-end gap-3 border-t border-gray-200 px-6 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleClose}
|
||||
class="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Add lodging
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
697
src/lib/components/EditFlightModal.svelte
Normal file
697
src/lib/components/EditFlightModal.svelte
Normal file
@@ -0,0 +1,697 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { base } from '$app/paths';
|
||||
import type { Person } from '$lib/server/travellers.js';
|
||||
import type { FlightBooking, FlightSegment, FlightRoute } from '$lib/server/flights.js';
|
||||
|
||||
interface Airport {
|
||||
id: number;
|
||||
iata_code: string | null;
|
||||
icao_code: string | null;
|
||||
name: string;
|
||||
city: string | null;
|
||||
country: string;
|
||||
country_code: string;
|
||||
}
|
||||
|
||||
interface Airline {
|
||||
id: number;
|
||||
iata_code: string | null;
|
||||
icao_code: string | null;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface FlightSegmentState {
|
||||
departureDate: string;
|
||||
departureDatetime: string;
|
||||
arrivalDatetime: string;
|
||||
airlineId: number | null;
|
||||
airlineIata: string;
|
||||
airlineName: string;
|
||||
flightNumber: string;
|
||||
departureAirportId: number | null;
|
||||
departureAirportCode: string;
|
||||
arrivalAirportId: number | null;
|
||||
arrivalAirportCode: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onclose: () => void;
|
||||
flightBooking:
|
||||
| (FlightBooking & {
|
||||
segments: Array<FlightSegment & { route: FlightRoute | null }>;
|
||||
passengerIds: string[];
|
||||
})
|
||||
| null;
|
||||
planStatus?: 'idea' | 'tentative' | 'confirmed';
|
||||
people: Person[];
|
||||
tripTravellerIds: string[];
|
||||
}
|
||||
|
||||
let {
|
||||
open,
|
||||
onclose,
|
||||
flightBooking,
|
||||
planStatus = 'idea',
|
||||
people = [],
|
||||
tripTravellerIds = []
|
||||
}: Props = $props();
|
||||
|
||||
function emptySegment(): FlightSegmentState {
|
||||
return {
|
||||
departureDate: '',
|
||||
departureDatetime: '',
|
||||
arrivalDatetime: '',
|
||||
airlineId: null,
|
||||
airlineIata: '',
|
||||
airlineName: '',
|
||||
flightNumber: '',
|
||||
departureAirportId: null,
|
||||
departureAirportCode: '',
|
||||
arrivalAirportId: null,
|
||||
arrivalAirportCode: ''
|
||||
};
|
||||
}
|
||||
|
||||
function bookingToState(booking: NonNullable<Props['flightBooking']>): {
|
||||
confirmationNumber: string;
|
||||
price: string;
|
||||
currency: string;
|
||||
status: 'idea' | 'tentative' | 'confirmed';
|
||||
selectedPassengers: string[];
|
||||
segments: FlightSegmentState[];
|
||||
} {
|
||||
return {
|
||||
confirmationNumber: booking.confirmation_number ?? '',
|
||||
price: booking.price != null ? String(booking.price) : '',
|
||||
currency: booking.currency ?? 'USD',
|
||||
status: 'idea', // will be overridden by plan status passed separately
|
||||
selectedPassengers: booking.passengerIds ?? [],
|
||||
segments: booking.segments.map((seg) => ({
|
||||
departureDate: seg.departure_date ?? '',
|
||||
departureDatetime: seg.route?.departure_datetime ?? '',
|
||||
arrivalDatetime: seg.route?.arrival_datetime ?? '',
|
||||
airlineId: seg.airline_id ?? null,
|
||||
airlineIata: seg.airline_iata ?? '',
|
||||
airlineName: seg.airline_name ?? '',
|
||||
flightNumber: seg.flight_number ?? '',
|
||||
departureAirportId: seg.route?.departure_airport_id ?? null,
|
||||
departureAirportCode: seg.route?.departure_airport_code ?? '',
|
||||
arrivalAirportId: seg.route?.arrival_airport_id ?? null,
|
||||
arrivalAirportCode: seg.route?.arrival_airport_code ?? ''
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
let confirmationNumber = $state('');
|
||||
let price = $state('');
|
||||
let currency = $state('USD');
|
||||
let status = $state<'idea' | 'tentative' | 'confirmed'>('idea');
|
||||
let selectedPassengers = $state<string[]>([]);
|
||||
let segments = $state<FlightSegmentState[]>([emptySegment()]);
|
||||
|
||||
// Pre-populate when the modal opens with a booking
|
||||
$effect(() => {
|
||||
if (open && flightBooking) {
|
||||
const s = bookingToState(flightBooking);
|
||||
confirmationNumber = s.confirmationNumber;
|
||||
price = s.price;
|
||||
currency = s.currency;
|
||||
status = planStatus;
|
||||
selectedPassengers = s.selectedPassengers;
|
||||
segments = s.segments;
|
||||
}
|
||||
});
|
||||
|
||||
// Search states
|
||||
let airportQuery = $state('');
|
||||
let airports = $state<Airport[]>([]);
|
||||
let airlineQuery = $state('');
|
||||
let airlines = $state<Airline[]>([]);
|
||||
let loadingAirports = $state(false);
|
||||
let loadingAirlines = $state(false);
|
||||
let activeSearchField: {
|
||||
segmentIndex: number;
|
||||
field: 'departure' | 'arrival' | 'airline';
|
||||
} | null = $state(null);
|
||||
|
||||
let airportDebounce: ReturnType<typeof setTimeout>;
|
||||
let airlineDebounce: ReturnType<typeof setTimeout>;
|
||||
|
||||
function searchAirports(query: string) {
|
||||
clearTimeout(airportDebounce);
|
||||
if (!query.trim()) {
|
||||
airports = [];
|
||||
return;
|
||||
}
|
||||
airportDebounce = setTimeout(async () => {
|
||||
loadingAirports = true;
|
||||
try {
|
||||
const res = await fetch(`${base}/api/airports?q=${encodeURIComponent(query)}`);
|
||||
airports = await res.json();
|
||||
} finally {
|
||||
loadingAirports = false;
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function searchAirlines(query: string) {
|
||||
clearTimeout(airlineDebounce);
|
||||
if (!query.trim()) {
|
||||
airlines = [];
|
||||
return;
|
||||
}
|
||||
airlineDebounce = setTimeout(async () => {
|
||||
loadingAirlines = true;
|
||||
try {
|
||||
const res = await fetch(`${base}/api/airlines?q=${encodeURIComponent(query)}`);
|
||||
airlines = await res.json();
|
||||
} finally {
|
||||
loadingAirlines = false;
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function selectAirport(segmentIndex: number, field: 'departure' | 'arrival', airport: Airport) {
|
||||
if (field === 'departure') {
|
||||
segments[segmentIndex].departureAirportId = airport.id;
|
||||
segments[segmentIndex].departureAirportCode = airport.iata_code || airport.icao_code || '';
|
||||
} else {
|
||||
segments[segmentIndex].arrivalAirportId = airport.id;
|
||||
segments[segmentIndex].arrivalAirportCode = airport.iata_code || airport.icao_code || '';
|
||||
}
|
||||
airports = [];
|
||||
activeSearchField = null;
|
||||
}
|
||||
|
||||
function selectAirline(segmentIndex: number, airline: Airline) {
|
||||
segments[segmentIndex].airlineId = airline.id;
|
||||
segments[segmentIndex].airlineIata = airline.iata_code || '';
|
||||
segments[segmentIndex].airlineName = airline.name;
|
||||
airlines = [];
|
||||
activeSearchField = null;
|
||||
}
|
||||
|
||||
function addSegment() {
|
||||
segments = [...segments, emptySegment()];
|
||||
}
|
||||
|
||||
function removeSegment(index: number) {
|
||||
segments = segments.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
function togglePassenger(personId: string) {
|
||||
if (selectedPassengers.includes(personId)) {
|
||||
selectedPassengers = selectedPassengers.filter((id) => id !== personId);
|
||||
} else {
|
||||
selectedPassengers = [...selectedPassengers, personId];
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
airports = [];
|
||||
airlines = [];
|
||||
activeSearchField = null;
|
||||
onclose();
|
||||
}
|
||||
|
||||
let canSubmit = $derived(
|
||||
segments.length > 0 &&
|
||||
segments.every(
|
||||
(s) => s.departureDate && s.flightNumber && (s.airlineId || s.airlineIata || s.airlineName)
|
||||
)
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if open && flightBooking}
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 z-40 bg-black/30"
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
onclick={handleClose}
|
||||
onkeydown={(e) => e.key === 'Escape' && handleClose()}
|
||||
></div>
|
||||
|
||||
<!-- Panel -->
|
||||
<div
|
||||
class="fixed top-0 right-0 z-50 flex h-full w-full max-w-2xl flex-col bg-white shadow-xl"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Edit flight"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-gray-200 px-6 py-4">
|
||||
<h2 class="text-base font-semibold text-gray-900">Edit flight</h2>
|
||||
<button
|
||||
onclick={handleClose}
|
||||
class="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<form
|
||||
method="POST"
|
||||
action="?/editFlight"
|
||||
class="flex flex-1 flex-col overflow-y-auto"
|
||||
use:enhance={() => {
|
||||
return ({ result, update }) => {
|
||||
update();
|
||||
if (result.type === 'success') handleClose();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="booking_id" value={flightBooking.id} />
|
||||
|
||||
<div class="flex flex-1 flex-col gap-6 px-6 py-6">
|
||||
<!-- Booking Details -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="edit_confirmation_number" class="text-sm font-medium text-gray-700">
|
||||
Confirmation number
|
||||
</label>
|
||||
<input
|
||||
id="edit_confirmation_number"
|
||||
name="confirmation_number"
|
||||
type="text"
|
||||
bind:value={confirmationNumber}
|
||||
placeholder="ABC123"
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="edit_price" class="text-sm font-medium text-gray-700">Price</label>
|
||||
<div class="flex gap-2">
|
||||
<select
|
||||
name="currency"
|
||||
bind:value={currency}
|
||||
class="rounded-md border border-gray-300 px-2 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="USD">USD</option>
|
||||
<option value="EUR">EUR</option>
|
||||
<option value="GBP">GBP</option>
|
||||
<option value="CAD">CAD</option>
|
||||
<option value="JPY">JPY</option>
|
||||
<option value="AUD">AUD</option>
|
||||
</select>
|
||||
<input
|
||||
id="edit_price"
|
||||
name="price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
bind:value={price}
|
||||
placeholder="0.00"
|
||||
class="flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="text-sm font-medium text-gray-700">Status</span>
|
||||
<div class="flex gap-2">
|
||||
{#each [{ value: 'idea', label: 'Idea', color: 'bg-gray-100 text-gray-700 border-gray-300' }, { value: 'tentative', label: 'Tentative', color: 'bg-yellow-50 text-yellow-800 border-yellow-300' }, { value: 'confirmed', label: 'Confirmed', color: 'bg-green-50 text-green-800 border-green-300' }] as opt}
|
||||
<label
|
||||
class="flex cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm transition-all {status ===
|
||||
opt.value
|
||||
? opt.color +
|
||||
' ring-2 ring-offset-1 ' +
|
||||
(opt.value === 'idea'
|
||||
? 'ring-gray-400'
|
||||
: opt.value === 'tentative'
|
||||
? 'ring-yellow-400'
|
||||
: 'ring-green-500')
|
||||
: 'border-gray-200 bg-white text-gray-600 hover:bg-gray-50'}"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="status"
|
||||
value={opt.value}
|
||||
bind:group={status}
|
||||
class="sr-only"
|
||||
/>
|
||||
{opt.label}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Passengers -->
|
||||
{#if people.length > 0 && tripTravellerIds.length > 0}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="text-sm font-medium text-gray-700">Passengers</span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each people.filter((p) => tripTravellerIds.includes(p.id)) as person}
|
||||
<label
|
||||
class="flex cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm {selectedPassengers.includes(
|
||||
person.id
|
||||
)
|
||||
? 'border-blue-500 bg-blue-50 text-blue-700'
|
||||
: 'border-gray-200 bg-white text-gray-600 hover:bg-gray-50'}"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedPassengers.includes(person.id)}
|
||||
onchange={() => togglePassenger(person.id)}
|
||||
class="sr-only"
|
||||
/>
|
||||
{person.first_name}
|
||||
{person.last_name}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#each selectedPassengers as personId}
|
||||
<input type="hidden" name="passenger_ids[]" value={personId} />
|
||||
{/each}
|
||||
|
||||
<!-- Flight Segments -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-700">Flight segments</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={addSegment}
|
||||
class="text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
+ Add segment
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#each segments as segment, segmentIndex (segmentIndex)}
|
||||
<div class="rounded-lg border border-gray-200 p-4">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-gray-500">Segment {segmentIndex + 1}</span>
|
||||
{#if segments.length > 1}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeSegment(segmentIndex)}
|
||||
class="text-xs text-red-600 hover:text-red-700"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Departure Date -->
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-xs font-medium text-gray-700">Departure date</label>
|
||||
<input
|
||||
type="date"
|
||||
bind:value={segment.departureDate}
|
||||
name="segments[{segmentIndex}][departure_date]"
|
||||
required
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Airline -->
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-xs font-medium text-gray-700">Airline</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={segment.airlineIata}
|
||||
oninput={(e) => {
|
||||
searchAirlines(e.currentTarget.value);
|
||||
activeSearchField = { segmentIndex, field: 'airline' };
|
||||
}}
|
||||
placeholder="Search airline or enter code"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
{#if loadingAirlines && activeSearchField?.segmentIndex === segmentIndex && activeSearchField?.field === 'airline'}
|
||||
<div class="absolute top-2.5 right-3 text-gray-400">
|
||||
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
{#if airlines.length > 0 && activeSearchField?.segmentIndex === segmentIndex && activeSearchField?.field === 'airline'}
|
||||
<ul
|
||||
class="absolute z-10 mt-1 w-full overflow-hidden rounded-md border border-gray-200 bg-white shadow-lg"
|
||||
>
|
||||
{#each airlines as airline}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => selectAirline(segmentIndex, airline)}
|
||||
class="flex w-full items-center gap-3 px-3 py-2.5 text-left text-sm hover:bg-gray-50"
|
||||
>
|
||||
<span class="font-medium text-gray-900">{airline.name}</span>
|
||||
{#if airline.iata_code}
|
||||
<span class="text-gray-500">({airline.iata_code})</span>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{#if segment.airlineId}
|
||||
<input
|
||||
type="hidden"
|
||||
name="segments[{segmentIndex}][airline_id]"
|
||||
value={segment.airlineId}
|
||||
/>
|
||||
{/if}
|
||||
{#if segment.airlineIata}
|
||||
<input
|
||||
type="hidden"
|
||||
name="segments[{segmentIndex}][airline_iata]"
|
||||
value={segment.airlineIata}
|
||||
/>
|
||||
{/if}
|
||||
{#if segment.airlineName}
|
||||
<input
|
||||
type="hidden"
|
||||
name="segments[{segmentIndex}][airline_name]"
|
||||
value={segment.airlineName}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Flight Number -->
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-xs font-medium text-gray-700">Flight number</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={segment.flightNumber}
|
||||
name="segments[{segmentIndex}][flight_number]"
|
||||
required
|
||||
placeholder="1234"
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Airports -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-xs font-medium text-gray-700">Departure airport</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={segment.departureAirportCode}
|
||||
oninput={(e) => {
|
||||
searchAirports(e.currentTarget.value);
|
||||
activeSearchField = { segmentIndex, field: 'departure' };
|
||||
}}
|
||||
placeholder="Code or search"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
{#if loadingAirports && activeSearchField?.segmentIndex === segmentIndex && activeSearchField?.field === 'departure'}
|
||||
<div class="absolute top-2.5 right-3 text-gray-400">
|
||||
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
{#if airports.length > 0 && activeSearchField?.segmentIndex === segmentIndex && activeSearchField?.field === 'departure'}
|
||||
<ul
|
||||
class="absolute z-10 mt-1 w-full overflow-hidden rounded-md border border-gray-200 bg-white shadow-lg"
|
||||
>
|
||||
{#each airports as airport}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => selectAirport(segmentIndex, 'departure', airport)}
|
||||
class="flex w-full items-center gap-3 px-3 py-2.5 text-left text-sm hover:bg-gray-50"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-gray-900">{airport.name}</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{airport.iata_code || airport.icao_code} • {airport.city}, {airport.country}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{#if segment.departureAirportId}
|
||||
<input
|
||||
type="hidden"
|
||||
name="segments[{segmentIndex}][departure_airport_id]"
|
||||
value={segment.departureAirportId}
|
||||
/>
|
||||
{/if}
|
||||
{#if segment.departureAirportCode}
|
||||
<input
|
||||
type="hidden"
|
||||
name="segments[{segmentIndex}][departure_airport_code]"
|
||||
value={segment.departureAirportCode}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-xs font-medium text-gray-700">Arrival airport</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={segment.arrivalAirportCode}
|
||||
oninput={(e) => {
|
||||
searchAirports(e.currentTarget.value);
|
||||
activeSearchField = { segmentIndex, field: 'arrival' };
|
||||
}}
|
||||
placeholder="Code or search"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
{#if loadingAirports && activeSearchField?.segmentIndex === segmentIndex && activeSearchField?.field === 'arrival'}
|
||||
<div class="absolute top-2.5 right-3 text-gray-400">
|
||||
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
{#if airports.length > 0 && activeSearchField?.segmentIndex === segmentIndex && activeSearchField?.field === 'arrival'}
|
||||
<ul
|
||||
class="absolute z-10 mt-1 w-full overflow-hidden rounded-md border border-gray-200 bg-white shadow-lg"
|
||||
>
|
||||
{#each airports as airport}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => selectAirport(segmentIndex, 'arrival', airport)}
|
||||
class="flex w-full items-center gap-3 px-3 py-2.5 text-left text-sm hover:bg-gray-50"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-gray-900">{airport.name}</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{airport.iata_code || airport.icao_code} • {airport.city}, {airport.country}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{#if segment.arrivalAirportId}
|
||||
<input
|
||||
type="hidden"
|
||||
name="segments[{segmentIndex}][arrival_airport_id]"
|
||||
value={segment.arrivalAirportId}
|
||||
/>
|
||||
{/if}
|
||||
{#if segment.arrivalAirportCode}
|
||||
<input
|
||||
type="hidden"
|
||||
name="segments[{segmentIndex}][arrival_airport_code]"
|
||||
value={segment.arrivalAirportCode}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Times -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-xs font-medium text-gray-700">Departure time</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
bind:value={segment.departureDatetime}
|
||||
name="segments[{segmentIndex}][departure_datetime]"
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-xs font-medium text-gray-700">Arrival time</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
bind:value={segment.arrivalDatetime}
|
||||
name="segments[{segmentIndex}][arrival_datetime]"
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex justify-end gap-3 border-t border-gray-200 px-6 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleClose}
|
||||
class="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Save changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
688
src/lib/components/EditLodgingModal.svelte
Normal file
688
src/lib/components/EditLodgingModal.svelte
Normal file
@@ -0,0 +1,688 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { base } from '$app/paths';
|
||||
import type { Person } from '$lib/server/travellers.js';
|
||||
import type { Lodging } from '$lib/server/lodgings.js';
|
||||
import type { PlanStatus } from '$lib/server/plans.js';
|
||||
|
||||
interface City {
|
||||
id: number;
|
||||
name: string;
|
||||
country: string;
|
||||
country_code: string;
|
||||
population: number | null;
|
||||
}
|
||||
|
||||
interface Country {
|
||||
name: string;
|
||||
country_code: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onclose: () => void;
|
||||
lodging: (Lodging & { guestIds: string[]; planStatus: PlanStatus }) | null;
|
||||
people: Person[];
|
||||
tripTravellerIds: string[];
|
||||
}
|
||||
|
||||
let { open, onclose, lodging, people = [], tripTravellerIds = [] }: Props = $props();
|
||||
|
||||
const TIMEZONES = [
|
||||
{ label: 'UTC (UTC+0)', value: 'UTC' },
|
||||
{ label: 'London (GMT/BST, UTC+0/+1)', value: 'Europe/London' },
|
||||
{ label: 'Dublin (IST, UTC+0/+1)', value: 'Europe/Dublin' },
|
||||
{ label: 'Lisbon (WET/WEST, UTC+0/+1)', value: 'Europe/Lisbon' },
|
||||
{ label: 'Paris / Berlin / Rome (CET, UTC+1/+2)', value: 'Europe/Paris' },
|
||||
{ label: 'Helsinki / Athens (EET, UTC+2/+3)', value: 'Europe/Helsinki' },
|
||||
{ label: 'Istanbul (TRT, UTC+3)', value: 'Europe/Istanbul' },
|
||||
{ label: 'Moscow (MSK, UTC+3)', value: 'Europe/Moscow' },
|
||||
{ label: 'Dubai (GST, UTC+4)', value: 'Asia/Dubai' },
|
||||
{ label: 'Karachi (PKT, UTC+5)', value: 'Asia/Karachi' },
|
||||
{ label: 'Kolkata (IST, UTC+5:30)', value: 'Asia/Kolkata' },
|
||||
{ label: 'Dhaka (BST, UTC+6)', value: 'Asia/Dhaka' },
|
||||
{ label: 'Bangkok (ICT, UTC+7)', value: 'Asia/Bangkok' },
|
||||
{ label: 'Singapore / Kuala Lumpur (SGT/MYT, UTC+8)', value: 'Asia/Singapore' },
|
||||
{ label: 'Hong Kong (HKT, UTC+8)', value: 'Asia/Hong_Kong' },
|
||||
{ label: 'Shanghai / Beijing (CST, UTC+8)', value: 'Asia/Shanghai' },
|
||||
{ label: 'Perth (AWST, UTC+8)', value: 'Australia/Perth' },
|
||||
{ label: 'Tokyo / Seoul (JST/KST, UTC+9)', value: 'Asia/Tokyo' },
|
||||
{ label: 'Darwin (ACST, UTC+9:30)', value: 'Australia/Darwin' },
|
||||
{ label: 'Brisbane (AEST, UTC+10)', value: 'Australia/Brisbane' },
|
||||
{ label: 'Sydney / Melbourne (AEST/AEDT, UTC+10/+11)', value: 'Australia/Sydney' },
|
||||
{ label: 'Auckland (NZST/NZDT, UTC+12/+13)', value: 'Pacific/Auckland' },
|
||||
{ label: 'Fiji (FJT, UTC+12)', value: 'Pacific/Fiji' },
|
||||
{ label: 'Azores (AZOT/AZOST, UTC-1/0)', value: 'Atlantic/Azores' },
|
||||
{ label: 'Cape Verde (CVT, UTC-1)', value: 'Atlantic/Cape_Verde' },
|
||||
{ label: 'Buenos Aires (ART, UTC-3)', value: 'America/Argentina/Buenos_Aires' },
|
||||
{ label: 'Sao Paulo (BRT/BRST, UTC-3/−2)', value: 'America/Sao_Paulo' },
|
||||
{ label: 'Halifax (AST/ADT, UTC-4/−3)', value: 'America/Halifax' },
|
||||
{ label: 'New York / Toronto (EST/EDT, UTC-5/−4)', value: 'America/New_York' },
|
||||
{ label: 'Chicago / Mexico City (CST/CDT, UTC-6/−5)', value: 'America/Chicago' },
|
||||
{ label: 'Denver (MST/MDT, UTC-7/−6)', value: 'America/Denver' },
|
||||
{ label: 'Phoenix (MST, UTC-7)', value: 'America/Phoenix' },
|
||||
{ label: 'Los Angeles / Vancouver (PST/PDT, UTC-8/−7)', value: 'America/Los_Angeles' },
|
||||
{ label: 'Anchorage (AKST/AKDT, UTC-9/−8)', value: 'America/Anchorage' },
|
||||
{ label: 'Honolulu (HST, UTC-10)', value: 'Pacific/Honolulu' }
|
||||
];
|
||||
|
||||
let name = $state('');
|
||||
let chain = $state('');
|
||||
let status = $state<'idea' | 'tentative' | 'confirmed'>('idea');
|
||||
let checkInDate = $state('');
|
||||
let checkInTime = $state('');
|
||||
let checkInTimezone = $state('');
|
||||
let checkOutDate = $state('');
|
||||
let checkOutTime = $state('');
|
||||
let checkOutTimezone = $state('');
|
||||
let addressLine1 = $state('');
|
||||
let addressLine2 = $state('');
|
||||
let cityName = $state('');
|
||||
let country = $state('');
|
||||
let countryCode = $state('');
|
||||
let postalCode = $state('');
|
||||
let cityQuery = $state('');
|
||||
let cities = $state<City[]>([]);
|
||||
let loadingCities = $state(false);
|
||||
let countryQuery = $state('');
|
||||
let countries = $state<Country[]>([]);
|
||||
let loadingCountries = $state(false);
|
||||
let showCountryDropdown = $state(false);
|
||||
let showCityDropdown = $state(false);
|
||||
let cityDebounce: ReturnType<typeof setTimeout>;
|
||||
let countryDebounce: ReturnType<typeof setTimeout>;
|
||||
|
||||
function flagEmoji(code: string): string {
|
||||
if (!code || code.length !== 2) return '';
|
||||
return code
|
||||
.toUpperCase()
|
||||
.split('')
|
||||
.map((c) => String.fromCodePoint(0x1f1e6 + c.charCodeAt(0) - 65))
|
||||
.join('');
|
||||
}
|
||||
|
||||
function searchCities(query: string) {
|
||||
clearTimeout(cityDebounce);
|
||||
if (!query.trim()) {
|
||||
cities = [];
|
||||
return;
|
||||
}
|
||||
cityDebounce = setTimeout(async () => {
|
||||
loadingCities = true;
|
||||
try {
|
||||
const params = new URLSearchParams({ q: query.trim() });
|
||||
if (countryCode) params.set('country_code', countryCode);
|
||||
const res = await fetch(`${base}/api/cities?${params}`);
|
||||
cities = await res.json();
|
||||
} finally {
|
||||
loadingCities = false;
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function searchCountries(query: string) {
|
||||
clearTimeout(countryDebounce);
|
||||
countryDebounce = setTimeout(async () => {
|
||||
loadingCountries = true;
|
||||
try {
|
||||
const params = query.trim() ? `?q=${encodeURIComponent(query.trim())}` : '';
|
||||
const res = await fetch(`${base}/api/countries${params}`);
|
||||
countries = await res.json();
|
||||
} finally {
|
||||
loadingCountries = false;
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function selectCity(city: City) {
|
||||
cityName = city.name;
|
||||
country = city.country;
|
||||
countryCode = city.country_code;
|
||||
cities = [];
|
||||
showCityDropdown = false;
|
||||
}
|
||||
|
||||
function selectCountry(c: Country) {
|
||||
country = c.name;
|
||||
countryCode = c.country_code;
|
||||
countries = [];
|
||||
showCountryDropdown = false;
|
||||
}
|
||||
|
||||
function onCityInput() {
|
||||
showCityDropdown = true;
|
||||
searchCities(cityQuery || cityName);
|
||||
}
|
||||
|
||||
function onCountryInput() {
|
||||
showCountryDropdown = true;
|
||||
searchCountries(countryQuery || country);
|
||||
}
|
||||
let confirmationNumber = $state('');
|
||||
let website = $state('');
|
||||
let phone = $state('');
|
||||
let price = $state('');
|
||||
let currency = $state('USD');
|
||||
let selectedGuests = $state<string[]>([]);
|
||||
|
||||
// Pre-populate when the modal opens with a lodging
|
||||
$effect(() => {
|
||||
if (open && lodging) {
|
||||
name = lodging.name;
|
||||
chain = lodging.chain ?? '';
|
||||
status = lodging.planStatus;
|
||||
checkInDate = lodging.check_in_date ?? '';
|
||||
checkInTime = lodging.check_in_time ?? '';
|
||||
checkInTimezone = lodging.check_in_timezone ?? '';
|
||||
checkOutDate = lodging.check_out_date ?? '';
|
||||
checkOutTime = lodging.check_out_time ?? '';
|
||||
checkOutTimezone = lodging.check_out_timezone ?? '';
|
||||
addressLine1 = lodging.address_line1 ?? '';
|
||||
addressLine2 = lodging.address_line2 ?? '';
|
||||
cityName = lodging.city_name ?? '';
|
||||
country = lodging.country ?? '';
|
||||
countryCode = lodging.country_code ?? '';
|
||||
postalCode = lodging.postal_code ?? '';
|
||||
confirmationNumber = lodging.confirmation_number ?? '';
|
||||
website = lodging.website ?? '';
|
||||
phone = lodging.phone ?? '';
|
||||
price = lodging.price != null ? String(lodging.price) : '';
|
||||
currency = lodging.currency ?? 'USD';
|
||||
selectedGuests = [...(lodging.guestIds ?? [])];
|
||||
}
|
||||
});
|
||||
|
||||
function toggleGuest(personId: string) {
|
||||
if (selectedGuests.includes(personId)) {
|
||||
selectedGuests = selectedGuests.filter((id) => id !== personId);
|
||||
} else {
|
||||
selectedGuests = [...selectedGuests, personId];
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
onclose();
|
||||
}
|
||||
|
||||
let canSubmit = $derived(name.trim().length > 0);
|
||||
</script>
|
||||
|
||||
{#if open && lodging}
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 z-40 bg-black/30"
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
onclick={handleClose}
|
||||
onkeydown={(e) => e.key === 'Escape' && handleClose()}
|
||||
></div>
|
||||
|
||||
<!-- Panel -->
|
||||
<div
|
||||
class="fixed top-0 right-0 z-50 flex h-full w-full max-w-2xl flex-col bg-white shadow-xl"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Edit lodging"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-gray-200 px-6 py-4">
|
||||
<h2 class="text-base font-semibold text-gray-900">Edit lodging</h2>
|
||||
<button
|
||||
onclick={handleClose}
|
||||
class="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<form
|
||||
method="POST"
|
||||
action="?/editLodging"
|
||||
class="flex flex-1 flex-col overflow-y-auto"
|
||||
use:enhance={() => {
|
||||
return ({ result, update }) => {
|
||||
update();
|
||||
if (result.type === 'success') handleClose();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="lodging_id" value={lodging.id} />
|
||||
|
||||
<div class="flex flex-1 flex-col gap-6 px-6 py-6">
|
||||
<!-- Property details -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="edit_lodging_name" class="text-sm font-medium text-gray-700">
|
||||
Name <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="edit_lodging_name"
|
||||
name="name"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
required
|
||||
placeholder="Grand Hyatt"
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="edit_lodging_chain" class="text-sm font-medium text-gray-700">
|
||||
Chain / Brand
|
||||
</label>
|
||||
<input
|
||||
id="edit_lodging_chain"
|
||||
name="chain"
|
||||
type="text"
|
||||
bind:value={chain}
|
||||
placeholder="Hyatt Hotels"
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="text-sm font-medium text-gray-700">Status</span>
|
||||
<div class="flex gap-2">
|
||||
{#each [{ value: 'idea', label: 'Idea', color: 'bg-gray-100 text-gray-700 border-gray-300' }, { value: 'tentative', label: 'Tentative', color: 'bg-yellow-50 text-yellow-800 border-yellow-300' }, { value: 'confirmed', label: 'Confirmed', color: 'bg-green-50 text-green-800 border-green-300' }] as opt}
|
||||
<label
|
||||
class="flex cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm transition-all {status ===
|
||||
opt.value
|
||||
? opt.color +
|
||||
' ring-2 ring-offset-1 ' +
|
||||
(opt.value === 'idea'
|
||||
? 'ring-gray-400'
|
||||
: opt.value === 'tentative'
|
||||
? 'ring-yellow-400'
|
||||
: 'ring-green-500')
|
||||
: 'border-gray-200 bg-white text-gray-600 hover:bg-gray-50'}"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="status"
|
||||
value={opt.value}
|
||||
bind:group={status}
|
||||
class="sr-only"
|
||||
/>
|
||||
{opt.label}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Check-in / Check-out -->
|
||||
<div class="grid grid-cols-2 gap-6">
|
||||
<!-- Check-in -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<span class="text-sm font-medium text-gray-700">Check-in</span>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="edit_check_in_date" class="text-xs font-medium text-gray-500">Date</label>
|
||||
<input
|
||||
id="edit_check_in_date"
|
||||
name="check_in_date"
|
||||
type="date"
|
||||
bind:value={checkInDate}
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="edit_check_in_time" class="text-xs font-medium text-gray-500">Time</label>
|
||||
<input
|
||||
id="edit_check_in_time"
|
||||
name="check_in_time"
|
||||
type="time"
|
||||
bind:value={checkInTime}
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="edit_check_in_timezone" class="text-xs font-medium text-gray-500"
|
||||
>Timezone</label
|
||||
>
|
||||
<select
|
||||
id="edit_check_in_timezone"
|
||||
name="check_in_timezone"
|
||||
bind:value={checkInTimezone}
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">— select —</option>
|
||||
{#each TIMEZONES as tz}
|
||||
<option value={tz.value}>{tz.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Check-out -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<span class="text-sm font-medium text-gray-700">Check-out</span>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="edit_check_out_date" class="text-xs font-medium text-gray-500"
|
||||
>Date</label
|
||||
>
|
||||
<input
|
||||
id="edit_check_out_date"
|
||||
name="check_out_date"
|
||||
type="date"
|
||||
bind:value={checkOutDate}
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="edit_check_out_time" class="text-xs font-medium text-gray-500"
|
||||
>Time</label
|
||||
>
|
||||
<input
|
||||
id="edit_check_out_time"
|
||||
name="check_out_time"
|
||||
type="time"
|
||||
bind:value={checkOutTime}
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="edit_check_out_timezone" class="text-xs font-medium text-gray-500"
|
||||
>Timezone</label
|
||||
>
|
||||
<select
|
||||
id="edit_check_out_timezone"
|
||||
name="check_out_timezone"
|
||||
bind:value={checkOutTimezone}
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">— select —</option>
|
||||
{#each TIMEZONES as tz}
|
||||
<option value={tz.value}>{tz.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Address -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<span class="text-sm font-medium text-gray-700">Address</span>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="edit_address_line1" class="text-xs font-medium text-gray-500"
|
||||
>Address line 1</label
|
||||
>
|
||||
<input
|
||||
id="edit_address_line1"
|
||||
name="address_line1"
|
||||
type="text"
|
||||
bind:value={addressLine1}
|
||||
placeholder="10 Scotts Road"
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="edit_address_line2" class="text-xs font-medium text-gray-500"
|
||||
>Address line 2</label
|
||||
>
|
||||
<input
|
||||
id="edit_address_line2"
|
||||
name="address_line2"
|
||||
type="text"
|
||||
bind:value={addressLine2}
|
||||
placeholder="Level 3"
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="edit_city_name" class="text-xs font-medium text-gray-500">City</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="edit_city_name"
|
||||
name="city_name"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
bind:value={cityName}
|
||||
oninput={(e) => {
|
||||
cityName = e.currentTarget.value;
|
||||
cityQuery = e.currentTarget.value;
|
||||
onCityInput();
|
||||
}}
|
||||
onfocus={() => {
|
||||
if (cityName) onCityInput();
|
||||
}}
|
||||
onblur={() => setTimeout(() => (showCityDropdown = false), 150)}
|
||||
placeholder="Search or type city name"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
{#if loadingCities}
|
||||
<div class="absolute top-2.5 right-3 text-gray-400">
|
||||
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
{#if showCityDropdown && cities.length > 0}
|
||||
<ul
|
||||
class="absolute z-10 mt-1 max-h-48 w-full overflow-auto rounded-md border border-gray-200 bg-white shadow-lg"
|
||||
>
|
||||
{#each cities as city}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => selectCity(city)}
|
||||
class="flex w-full items-center gap-3 px-3 py-2.5 text-left text-sm hover:bg-gray-50"
|
||||
>
|
||||
<span class="text-lg leading-none">{flagEmoji(city.country_code)}</span>
|
||||
<div>
|
||||
<span class="font-medium text-gray-900">{city.name}</span>
|
||||
<span class="ml-1.5 text-gray-500">{city.country}</span>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="edit_country" class="text-xs font-medium text-gray-500">Country</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="edit_country"
|
||||
name="country"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
bind:value={country}
|
||||
oninput={(e) => {
|
||||
country = e.currentTarget.value;
|
||||
countryCode = '';
|
||||
countryQuery = e.currentTarget.value;
|
||||
onCountryInput();
|
||||
}}
|
||||
onfocus={() => {
|
||||
showCountryDropdown = true;
|
||||
if (!countries.length) searchCountries(country || '');
|
||||
}}
|
||||
onblur={() => setTimeout(() => (showCountryDropdown = false), 150)}
|
||||
placeholder="Search or type country"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
{#if loadingCountries}
|
||||
<div class="absolute top-2.5 right-3 text-gray-400">
|
||||
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
{#if showCountryDropdown && countries.length > 0}
|
||||
<ul
|
||||
class="absolute z-10 mt-1 max-h-48 w-full overflow-auto rounded-md border border-gray-200 bg-white shadow-lg"
|
||||
>
|
||||
{#each countries as c}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => selectCountry(c)}
|
||||
class="flex w-full items-center gap-3 px-3 py-2.5 text-left text-sm hover:bg-gray-50"
|
||||
>
|
||||
<span class="text-lg leading-none">{flagEmoji(c.country_code)}</span>
|
||||
<span class="font-medium text-gray-900">{c.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{#if countryCode}
|
||||
<input type="hidden" name="country_code" value={countryCode} />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="edit_postal_code" class="text-xs font-medium text-gray-500"
|
||||
>Postal code</label
|
||||
>
|
||||
<input
|
||||
id="edit_postal_code"
|
||||
name="postal_code"
|
||||
type="text"
|
||||
bind:value={postalCode}
|
||||
placeholder="228211"
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Booking details -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="edit_confirmation_number" class="text-sm font-medium text-gray-700">
|
||||
Confirmation number
|
||||
</label>
|
||||
<input
|
||||
id="edit_confirmation_number"
|
||||
name="confirmation_number"
|
||||
type="text"
|
||||
bind:value={confirmationNumber}
|
||||
placeholder="XYZ999"
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="edit_website" class="text-sm font-medium text-gray-700">Website</label>
|
||||
<input
|
||||
id="edit_website"
|
||||
name="website"
|
||||
type="url"
|
||||
bind:value={website}
|
||||
placeholder="https://..."
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact + Cost -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="edit_phone" class="text-sm font-medium text-gray-700">Phone</label>
|
||||
<input
|
||||
id="edit_phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
bind:value={phone}
|
||||
placeholder="+65 6416 7000"
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="edit_price" class="text-sm font-medium text-gray-700">Price</label>
|
||||
<div class="flex gap-2">
|
||||
<select
|
||||
name="currency"
|
||||
bind:value={currency}
|
||||
class="rounded-md border border-gray-300 px-2 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="USD">USD</option>
|
||||
<option value="EUR">EUR</option>
|
||||
<option value="GBP">GBP</option>
|
||||
<option value="CAD">CAD</option>
|
||||
<option value="JPY">JPY</option>
|
||||
<option value="AUD">AUD</option>
|
||||
<option value="SGD">SGD</option>
|
||||
<option value="HKD">HKD</option>
|
||||
<option value="NZD">NZD</option>
|
||||
</select>
|
||||
<input
|
||||
id="edit_price"
|
||||
name="price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
bind:value={price}
|
||||
placeholder="0.00"
|
||||
class="flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Guests -->
|
||||
{#if people.length > 0 && tripTravellerIds.length > 0}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="text-sm font-medium text-gray-700">Guests</span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each people.filter((p) => tripTravellerIds.includes(p.id)) as person}
|
||||
<label
|
||||
class="flex cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm {selectedGuests.includes(
|
||||
person.id
|
||||
)
|
||||
? 'border-blue-500 bg-blue-50 text-blue-700'
|
||||
: 'border-gray-200 bg-white text-gray-600 hover:bg-gray-50'}"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedGuests.includes(person.id)}
|
||||
onchange={() => toggleGuest(person.id)}
|
||||
class="sr-only"
|
||||
/>
|
||||
{person.first_name}
|
||||
{person.last_name}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#each selectedGuests as personId}
|
||||
<input type="hidden" name="guest_ids[]" value={personId} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex justify-end gap-3 border-t border-gray-200 px-6 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleClose}
|
||||
class="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Save changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
289
src/lib/components/FlightCard.svelte
Normal file
289
src/lib/components/FlightCard.svelte
Normal file
@@ -0,0 +1,289 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths';
|
||||
import type { Plan } from '$lib/server/plans.js';
|
||||
import type { FlightBooking, FlightSegment, FlightRoute } from '$lib/server/flights.js';
|
||||
|
||||
interface Props {
|
||||
plan: Plan;
|
||||
flightBooking: FlightBooking & {
|
||||
segments: Array<FlightSegment & { route: FlightRoute | null }>;
|
||||
};
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
let { plan, flightBooking, onEdit, onDelete }: Props = $props();
|
||||
|
||||
function formatDate(d: string | null): string {
|
||||
if (!d) return '';
|
||||
return new Date(d + 'T00:00:00').toLocaleDateString(undefined, {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
function formatDuration(mins: number): string {
|
||||
const h = Math.floor(mins / 60);
|
||||
const m = mins % 60;
|
||||
return m === 0 ? `${h}h` : `${h}h ${m}m`;
|
||||
}
|
||||
|
||||
function formatTime(dt: string | null): string {
|
||||
if (!dt) return '';
|
||||
// datetime-local values are stored as "YYYY-MM-DDTHH:MM"
|
||||
// Extract just the time portion for display
|
||||
const timePart = dt.includes('T') ? dt.split('T')[1] : dt;
|
||||
// Format HH:MM — strip seconds if present
|
||||
return timePart.slice(0, 5);
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
idea: { label: 'Idea', class: 'bg-gray-100 text-gray-600' },
|
||||
tentative: { label: 'Tentative', class: 'bg-yellow-100 text-yellow-800' },
|
||||
confirmed: { label: 'Confirmed', class: 'bg-green-100 text-green-800' }
|
||||
};
|
||||
|
||||
// Derive the airline IATA from the first segment (for the header label)
|
||||
const airlineName = $derived(
|
||||
flightBooking.segments[0]?.airline_name || flightBooking.segments[0]?.airline_iata || 'Flight'
|
||||
);
|
||||
|
||||
// True when every segment is operated by the same airline — logo goes in the header
|
||||
const singleAirline = $derived(
|
||||
flightBooking.segments.length > 0 &&
|
||||
flightBooking.segments.every((s) => s.airline_iata === flightBooking.segments[0].airline_iata)
|
||||
);
|
||||
const sharedIata = $derived(
|
||||
singleAirline ? (flightBooking.segments[0].airline_iata ?? null) : null
|
||||
);
|
||||
|
||||
// Per-segment logo error state (used when airlines differ across segments)
|
||||
let logoErrors = $state<Record<number, boolean>>({});
|
||||
let headerLogoError = $state(false);
|
||||
</script>
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<!-- Header -->
|
||||
<div class="mb-4 flex items-start justify-between gap-3">
|
||||
<div class="flex min-w-0 flex-1 items-start gap-3">
|
||||
<!-- Shared airline logo (shown here only when all segments are the same airline) -->
|
||||
{#if sharedIata && !headerLogoError}
|
||||
<img
|
||||
src="{base}/airline-logos/{sharedIata}.svg"
|
||||
alt="{airlineName} logo"
|
||||
width="80"
|
||||
height="40"
|
||||
class="mt-0.5 h-10 w-20 shrink-0 object-contain"
|
||||
onerror={() => (headerLogoError = true)}
|
||||
/>
|
||||
{:else if !sharedIata}
|
||||
<!-- Different airlines per segment — no header logo, segments show their own -->
|
||||
{:else}
|
||||
<!-- Logo load failed — fall back to plane icon -->
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
class="mt-1 shrink-0 text-gray-300"
|
||||
>
|
||||
<path
|
||||
d="M17.8 19.2L16 11l3.5-3.5C21 6 21 4 19.5 2.5S18 2 16.5 3.5L13 7 4.8 5.2l-1.7 1.7 5.5 3.8-2.5 2.5-2.1-.5-1.5 1.5 3.8 2.2 2.2 3.8 1.5-1.5-.5-2.1 2.5-2.5 3.8 5.5z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="font-medium text-gray-900">
|
||||
{airlineName}
|
||||
{flightBooking.segments[0]?.flight_number ?? ''}
|
||||
{#if flightBooking.segments.length > 1}
|
||||
<span class="text-sm font-normal text-gray-500">
|
||||
+{flightBooking.segments.length - 1} more
|
||||
</span>
|
||||
{/if}
|
||||
</p>
|
||||
<span
|
||||
class="shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium {statusConfig[
|
||||
plan.status
|
||||
].class}"
|
||||
>
|
||||
{statusConfig[plan.status].label}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-0.5 flex flex-wrap gap-x-3 gap-y-0.5 text-xs text-gray-500">
|
||||
{#if flightBooking.confirmation_number}
|
||||
<span>Confirmation: {flightBooking.confirmation_number}</span>
|
||||
{/if}
|
||||
{#if flightBooking.price}
|
||||
<span>{flightBooking.currency} {flightBooking.price.toFixed(2)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
{#if onEdit}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onEdit}
|
||||
class="rounded-md p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
aria-label="Edit flight"
|
||||
>
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
{#if onDelete}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onDelete}
|
||||
class="rounded-md p-1.5 text-gray-400 hover:bg-gray-100 hover:text-red-600"
|
||||
aria-label="Remove flight"
|
||||
>
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Segments -->
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each flightBooking.segments as segment, index}
|
||||
{#if index > 0}
|
||||
<div class="border-t border-gray-100"></div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Segment content -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<!-- Route row -->
|
||||
{#if segment.route?.departure_airport_code || segment.route?.arrival_airport_code}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-lg font-semibold tracking-wide text-gray-900">
|
||||
{segment.route.departure_airport_code || '—'}
|
||||
</span>
|
||||
<svg
|
||||
width="32"
|
||||
height="14"
|
||||
viewBox="0 0 32 14"
|
||||
fill="none"
|
||||
class="shrink-0 text-gray-400"
|
||||
>
|
||||
<line x1="0" y1="7" x2="24" y2="7" stroke="currentColor" stroke-width="1.5" />
|
||||
<polyline
|
||||
points="18,2 24,7 18,12"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
fill="none"
|
||||
/>
|
||||
<circle cx="10" cy="7" r="2" fill="currentColor" />
|
||||
</svg>
|
||||
<span class="text-lg font-semibold tracking-wide text-gray-900">
|
||||
{segment.route.arrival_airport_code || '—'}
|
||||
</span>
|
||||
{#if flightBooking.segments.length > 1}
|
||||
<span class="ml-1 text-xs text-gray-400">
|
||||
{segment.airline_name || segment.airline_iata || ''}
|
||||
{segment.flight_number}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Times row -->
|
||||
{#if segment.route.departure_datetime || segment.route.arrival_datetime}
|
||||
<div class="mt-1 flex items-baseline gap-4 text-sm text-gray-700">
|
||||
{#if segment.route.departure_datetime}
|
||||
<span class="font-medium">{formatTime(segment.route.departure_datetime)}</span>
|
||||
{/if}
|
||||
{#if segment.route.departure_datetime && segment.route.arrival_datetime}
|
||||
<span class="text-gray-300">→</span>
|
||||
{/if}
|
||||
{#if segment.route.arrival_datetime}
|
||||
<span class="font-medium">{formatTime(segment.route.arrival_datetime)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if flightBooking.segments.length > 1}
|
||||
<div class="text-sm font-medium text-gray-700">
|
||||
{segment.airline_name || segment.airline_iata || 'Flight'}
|
||||
{segment.flight_number}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Date -->
|
||||
{#if segment.departure_date}
|
||||
<div class="mt-0.5 text-xs text-gray-500">{formatDate(segment.departure_date)}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Distance + duration stats -->
|
||||
{#if segment.route?.distanceKm || segment.route?.durationMins}
|
||||
<div class="shrink-0 text-right">
|
||||
{#if segment.route.distanceKm}
|
||||
<div class="text-xs text-gray-400">
|
||||
{segment.route.distanceKm.toLocaleString()} km
|
||||
</div>
|
||||
{/if}
|
||||
{#if segment.route.durationMins}
|
||||
<div class="text-xs text-gray-400">{formatDuration(segment.route.durationMins)}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Per-segment airline logo (only when airlines differ across segments) -->
|
||||
{#if !singleAirline}
|
||||
<div class="shrink-0">
|
||||
{#if segment.airline_iata && !logoErrors[index]}
|
||||
<img
|
||||
src="{base}/airline-logos/{segment.airline_iata}.svg"
|
||||
alt="{segment.airline_name || segment.airline_iata} logo"
|
||||
width="80"
|
||||
height="40"
|
||||
class="h-10 w-20 object-contain"
|
||||
onerror={() => (logoErrors[index] = true)}
|
||||
/>
|
||||
{:else}
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
class="text-gray-300"
|
||||
>
|
||||
<path
|
||||
d="M17.8 19.2L16 11l3.5-3.5C21 6 21 4 19.5 2.5S18 2 16.5 3.5L13 7 4.8 5.2l-1.7 1.7 5.5 3.8-2.5 2.5-2.1-.5-1.5 1.5 3.8 2.2 2.2 3.8 1.5-1.5-.5-2.1 2.5-2.5 3.8 5.5z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
215
src/lib/components/LodgingCard.svelte
Normal file
215
src/lib/components/LodgingCard.svelte
Normal file
@@ -0,0 +1,215 @@
|
||||
<script lang="ts">
|
||||
import type { Plan } from '$lib/server/plans.js';
|
||||
import type { Lodging } from '$lib/server/lodgings.js';
|
||||
|
||||
interface Props {
|
||||
plan: Plan;
|
||||
lodging: Lodging & { guestIds: string[] };
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
let { plan, lodging, onEdit, onDelete }: Props = $props();
|
||||
|
||||
function formatDate(d: string | null): string {
|
||||
if (!d) return '';
|
||||
return new Date(d + 'T00:00:00').toLocaleDateString(undefined, {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
function formatTime(t: string | null): string {
|
||||
if (!t) return '';
|
||||
return t.slice(0, 5);
|
||||
}
|
||||
|
||||
// Short timezone label from IANA (e.g. Asia/Singapore -> Singapore)
|
||||
function tzLabel(tz: string | null): string {
|
||||
if (!tz) return '';
|
||||
const parts = tz.split('/');
|
||||
return parts[parts.length - 1]?.replace(/_/g, ' ') ?? tz;
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
idea: { label: 'Idea', class: 'bg-gray-100 text-gray-600' },
|
||||
tentative: { label: 'Tentative', class: 'bg-yellow-100 text-yellow-800' },
|
||||
confirmed: { label: 'Confirmed', class: 'bg-green-100 text-green-800' }
|
||||
};
|
||||
|
||||
const hasAddress = $derived(
|
||||
lodging.address_line1 ||
|
||||
lodging.address_line2 ||
|
||||
lodging.city_name ||
|
||||
lodging.country ||
|
||||
lodging.postal_code
|
||||
);
|
||||
|
||||
const addressParts = $derived(
|
||||
[
|
||||
lodging.address_line1,
|
||||
lodging.address_line2,
|
||||
[lodging.city_name, lodging.postal_code].filter(Boolean).join(' '),
|
||||
lodging.country
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
);
|
||||
|
||||
const hasCheckIn = $derived(lodging.check_in_date || lodging.check_in_time);
|
||||
const hasCheckOut = $derived(lodging.check_out_date || lodging.check_out_time);
|
||||
</script>
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<!-- Header -->
|
||||
<div class="mb-4 flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
class="shrink-0 text-gray-400"
|
||||
>
|
||||
<path
|
||||
d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"
|
||||
/>
|
||||
<polyline points="9 22 9 12 15 12 15 22" />
|
||||
</svg>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="font-medium text-gray-900">{lodging.name}</p>
|
||||
<span
|
||||
class="shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium {statusConfig[
|
||||
plan.status
|
||||
].class}"
|
||||
>
|
||||
{statusConfig[plan.status].label}
|
||||
</span>
|
||||
</div>
|
||||
{#if lodging.chain}
|
||||
<p class="mt-0.5 text-sm text-gray-500">{lodging.chain}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
{#if onEdit}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onEdit}
|
||||
class="rounded-md p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
aria-label="Edit lodging"
|
||||
>
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
{#if onDelete}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onDelete}
|
||||
class="rounded-md p-1.5 text-gray-400 hover:bg-gray-100 hover:text-red-600"
|
||||
aria-label="Remove lodging"
|
||||
>
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Check-in / Check-out -->
|
||||
{#if hasCheckIn || hasCheckOut}
|
||||
<div class="grid grid-cols-2 gap-4 border-t border-gray-100 py-4">
|
||||
<div>
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-gray-400">Check-in</p>
|
||||
{#if lodging.check_in_date || lodging.check_in_time}
|
||||
<p class="mt-1 text-sm font-medium text-gray-900">
|
||||
{#if lodging.check_in_date}
|
||||
{formatDate(lodging.check_in_date)}
|
||||
{#if lodging.check_in_time}
|
||||
· {formatTime(lodging.check_in_time)}
|
||||
{/if}
|
||||
{:else}
|
||||
{formatTime(lodging.check_in_time)}
|
||||
{/if}
|
||||
</p>
|
||||
{#if lodging.check_in_timezone}
|
||||
<p class="text-xs text-gray-500">({tzLabel(lodging.check_in_timezone)})</p>
|
||||
{/if}
|
||||
{#if lodging.city_name}
|
||||
<p class="text-xs text-gray-500">{lodging.city_name}</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="mt-1 text-sm text-gray-400">—</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-gray-400">Check-out</p>
|
||||
{#if lodging.check_out_date || lodging.check_out_time}
|
||||
<p class="mt-1 text-sm font-medium text-gray-900">
|
||||
{#if lodging.check_out_date}
|
||||
{formatDate(lodging.check_out_date)}
|
||||
{#if lodging.check_out_time}
|
||||
· {formatTime(lodging.check_out_time)}
|
||||
{/if}
|
||||
{:else}
|
||||
{formatTime(lodging.check_out_time)}
|
||||
{/if}
|
||||
</p>
|
||||
{#if lodging.check_out_timezone}
|
||||
<p class="text-xs text-gray-500">({tzLabel(lodging.check_out_timezone)})</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="mt-1 text-sm text-gray-400">—</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Address -->
|
||||
{#if hasAddress && addressParts}
|
||||
<div class="border-t border-gray-100 py-4">
|
||||
<p class="text-sm text-gray-700">{addressParts}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Confirmation + Price -->
|
||||
{#if lodging.confirmation_number || (lodging.price != null && lodging.price > 0)}
|
||||
<div class="flex flex-wrap gap-x-3 gap-y-0.5 border-t border-gray-100 pt-4 text-xs text-gray-500">
|
||||
{#if lodging.confirmation_number}
|
||||
<span>Confirmation: {lodging.confirmation_number}</span>
|
||||
{/if}
|
||||
{#if lodging.confirmation_number && lodging.price != null && lodging.price > 0}
|
||||
<span>·</span>
|
||||
{/if}
|
||||
{#if lodging.price != null && lodging.price > 0}
|
||||
<span>{lodging.currency} {lodging.price.toFixed(2)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -11,8 +11,10 @@
|
||||
tripName: string;
|
||||
onAddDestination?: () => void;
|
||||
onAddTraveller?: () => void;
|
||||
onAddFlight?: () => void;
|
||||
onAddLodging?: () => void;
|
||||
}
|
||||
let { tripName, onAddDestination, onAddTraveller }: Props = $props();
|
||||
let { tripName, onAddDestination, onAddTraveller, onAddFlight, onAddLodging }: Props = $props();
|
||||
|
||||
const actions: Action[] = [
|
||||
{
|
||||
@@ -41,7 +43,7 @@
|
||||
icon: `<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#F97316" stroke-width="1.5">
|
||||
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-3.99-3.99A19.79 19.79 0 0 1 4.1 6.18 2 2 0 0 1 6.08 4h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L10.09 11a16 16 0 0 0 5.91 5.91l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 24 18z"/>
|
||||
</svg>`,
|
||||
onclick: () => {}
|
||||
onclick: () => onAddFlight?.()
|
||||
},
|
||||
{
|
||||
label: 'Lodgings',
|
||||
@@ -51,7 +53,7 @@
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||
<polyline points="9 22 9 12 15 12 15 22"/>
|
||||
</svg>`,
|
||||
onclick: () => {}
|
||||
onclick: () => onAddLodging?.()
|
||||
},
|
||||
{
|
||||
label: 'Restaurants',
|
||||
|
||||
59
src/lib/server/data/README.md
Normal file
59
src/lib/server/data/README.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Airport and Airline Data
|
||||
|
||||
This directory should contain OpenFlights data files for importing airports and airlines.
|
||||
|
||||
## Download Instructions
|
||||
|
||||
1. **Download airports.dat**:
|
||||
- Visit: https://raw.githubusercontent.com/jpatokal/openflights/master/data/airports.dat
|
||||
- Save as: `airports.dat` in this directory
|
||||
|
||||
2. **Download airlines.dat**:
|
||||
- Visit: https://raw.githubusercontent.com/jpatokal/openflights/master/data/airlines.dat
|
||||
- Save as: `airlines.dat` in this directory
|
||||
|
||||
## File Format
|
||||
|
||||
### airports.dat
|
||||
CSV format with the following columns:
|
||||
- Airport ID
|
||||
- Name
|
||||
- City
|
||||
- Country
|
||||
- IATA code (3-letter)
|
||||
- ICAO code (4-letter)
|
||||
- Latitude
|
||||
- Longitude
|
||||
- Altitude
|
||||
- Timezone (offset from UTC)
|
||||
- DST (Daylight Saving Time)
|
||||
- Tz database time zone
|
||||
- Type
|
||||
- Source
|
||||
|
||||
### airlines.dat
|
||||
CSV format with the following columns:
|
||||
- Airline ID
|
||||
- Name
|
||||
- Alias
|
||||
- IATA code (2-letter)
|
||||
- ICAO code (3-letter)
|
||||
- Callsign
|
||||
- Country
|
||||
- Active (Y/N)
|
||||
|
||||
## Import
|
||||
|
||||
The data will be automatically imported when the database is initialized (on first run or when tables are empty).
|
||||
|
||||
If you need to re-import:
|
||||
1. Delete the database file
|
||||
2. Restart the application
|
||||
3. The migration will run and import the data
|
||||
|
||||
## Notes
|
||||
|
||||
- Only airports with valid IATA/ICAO codes and coordinates are imported
|
||||
- Only active airlines (Active = 'Y') are imported
|
||||
- The import process skips invalid or duplicate entries
|
||||
|
||||
6162
src/lib/server/data/airlines.dat
Normal file
6162
src/lib/server/data/airlines.dat
Normal file
File diff suppressed because it is too large
Load Diff
7698
src/lib/server/data/airports.dat
Normal file
7698
src/lib/server/data/airports.dat
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,40 @@ import type { Database } from './types.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Helper function to parse CSV line with proper quote handling
|
||||
function parseCSVLine(line: string): string[] {
|
||||
const fields: string[] = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const char = line[i];
|
||||
const nextChar = line[i + 1];
|
||||
|
||||
if (char === '"') {
|
||||
if (inQuotes && nextChar === '"') {
|
||||
// Escaped quote
|
||||
current += '"';
|
||||
i++; // Skip next quote
|
||||
} else {
|
||||
// Toggle quote state
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
} else if (char === ',' && !inQuotes) {
|
||||
// Field separator
|
||||
fields.push(current.trim());
|
||||
current = '';
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
|
||||
// Add last field
|
||||
fields.push(current.trim());
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
export function runMigrations(db: Database): void {
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS trips (
|
||||
@@ -51,6 +85,7 @@ export function runMigrations(db: Database): void {
|
||||
)
|
||||
`);
|
||||
|
||||
// Legacy table — superseded by `people` + `trip_travellers`. Kept for migration safety on existing DBs.
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS travellers (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -86,11 +121,150 @@ export function runMigrations(db: Database): void {
|
||||
)
|
||||
`);
|
||||
|
||||
// Airports table
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS airports (
|
||||
id INTEGER PRIMARY KEY,
|
||||
iata_code TEXT UNIQUE,
|
||||
icao_code TEXT UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
city TEXT,
|
||||
country TEXT NOT NULL,
|
||||
country_code TEXT NOT NULL,
|
||||
latitude REAL,
|
||||
longitude REAL,
|
||||
timezone TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
|
||||
// Airlines table
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS airlines (
|
||||
id INTEGER PRIMARY KEY,
|
||||
iata_code TEXT,
|
||||
icao_code TEXT,
|
||||
name TEXT NOT NULL,
|
||||
country TEXT,
|
||||
country_code TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
|
||||
// Flight bookings table - links to a plan
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS flight_bookings (
|
||||
id TEXT PRIMARY KEY,
|
||||
plan_id TEXT NOT NULL REFERENCES plans(id) ON DELETE CASCADE,
|
||||
confirmation_number TEXT,
|
||||
price REAL,
|
||||
currency TEXT DEFAULT 'USD',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
|
||||
// Flight segments table - individual flights within a booking
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS flight_segments (
|
||||
id TEXT PRIMARY KEY,
|
||||
flight_booking_id TEXT NOT NULL REFERENCES flight_bookings(id) ON DELETE CASCADE,
|
||||
departure_date TEXT NOT NULL,
|
||||
airline_id INTEGER REFERENCES airlines(id),
|
||||
airline_iata TEXT,
|
||||
airline_icao TEXT,
|
||||
airline_name TEXT,
|
||||
flight_number TEXT NOT NULL,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
|
||||
// Flight routes table - detailed route info for each segment
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS flight_routes (
|
||||
id TEXT PRIMARY KEY,
|
||||
flight_segment_id TEXT NOT NULL REFERENCES flight_segments(id) ON DELETE CASCADE,
|
||||
departure_airport_id INTEGER REFERENCES airports(id),
|
||||
departure_airport_code TEXT,
|
||||
departure_terminal TEXT,
|
||||
departure_gate TEXT,
|
||||
departure_datetime TEXT,
|
||||
departure_timezone TEXT,
|
||||
arrival_airport_id INTEGER REFERENCES airports(id),
|
||||
arrival_airport_code TEXT,
|
||||
arrival_terminal TEXT,
|
||||
arrival_gate TEXT,
|
||||
arrival_datetime TEXT,
|
||||
arrival_timezone TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
|
||||
// Flight booking passengers - links people to flight bookings
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS flight_booking_passengers (
|
||||
flight_booking_id TEXT NOT NULL REFERENCES flight_bookings(id) ON DELETE CASCADE,
|
||||
person_id TEXT NOT NULL REFERENCES people(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (flight_booking_id, person_id)
|
||||
)
|
||||
`);
|
||||
|
||||
// Lodgings table - links to a plan
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS lodgings (
|
||||
id TEXT PRIMARY KEY,
|
||||
plan_id TEXT NOT NULL REFERENCES plans(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
chain TEXT,
|
||||
check_in_date TEXT,
|
||||
check_in_time TEXT,
|
||||
check_in_timezone TEXT,
|
||||
check_out_date TEXT,
|
||||
check_out_time TEXT,
|
||||
check_out_timezone TEXT,
|
||||
address_line1 TEXT,
|
||||
address_line2 TEXT,
|
||||
city_name TEXT,
|
||||
country TEXT,
|
||||
country_code TEXT,
|
||||
postal_code TEXT,
|
||||
confirmation_number TEXT,
|
||||
website TEXT,
|
||||
phone TEXT,
|
||||
price REAL,
|
||||
currency TEXT NOT NULL DEFAULT 'USD',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
|
||||
// Lodging guests - links people to lodgings
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS lodging_guests (
|
||||
lodging_id TEXT NOT NULL REFERENCES lodgings(id) ON DELETE CASCADE,
|
||||
person_id TEXT NOT NULL REFERENCES people(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (lodging_id, person_id)
|
||||
)
|
||||
`);
|
||||
|
||||
// Seed cities table on first run
|
||||
const row = db.get<{ count: number }>('SELECT COUNT(*) as count FROM cities');
|
||||
if ((row?.count ?? 0) === 0) {
|
||||
seedCities(db);
|
||||
}
|
||||
|
||||
// Seed airports and airlines on first run only
|
||||
const airportsRow = db.get<{ count: number }>('SELECT COUNT(*) as count FROM airports');
|
||||
if ((airportsRow?.count ?? 0) === 0) {
|
||||
seedAirports(db);
|
||||
}
|
||||
|
||||
const airlinesRow = db.get<{ count: number }>('SELECT COUNT(*) as count FROM airlines');
|
||||
if ((airlinesRow?.count ?? 0) === 0) {
|
||||
seedAirlines(db);
|
||||
}
|
||||
}
|
||||
|
||||
function seedCities(db: Database): void {
|
||||
@@ -110,3 +284,504 @@ function seedCities(db: Database): void {
|
||||
console.error('[db] Failed to seed cities:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function seedAirports(db: Database): void {
|
||||
try {
|
||||
// Try to load from OpenFlights airports.dat file
|
||||
// Format: Airport ID, Name, City, Country, IATA, ICAO, Latitude, Longitude, Altitude, Timezone, DST, Tz database time zone, Type, Source
|
||||
const airportsPath = join(__dirname, '../data/airports.dat');
|
||||
try {
|
||||
const data = readFileSync(airportsPath, 'utf-8');
|
||||
const lines = data.split('\n').filter((line) => line.trim());
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
// Skip comments
|
||||
if (line.startsWith('#')) continue;
|
||||
|
||||
// Parse CSV with proper quote handling
|
||||
const fields = parseCSVLine(line);
|
||||
|
||||
// OpenFlights format: ID, Name, City, Country, IATA, ICAO, Lat, Lon, Alt, TZ, DST, TZ_DB, Type, Source
|
||||
if (fields.length < 8) continue;
|
||||
|
||||
const [idStr, name, city, country, iata, icao, latStr, lonStr, , , , tzDb] = fields;
|
||||
|
||||
// Skip if missing essential data
|
||||
if (!name || !country) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const lat = latStr ? parseFloat(latStr) : null;
|
||||
const lon = lonStr ? parseFloat(lonStr) : null;
|
||||
|
||||
// Only import airports with valid coordinates and IATA/ICAO codes
|
||||
if ((!iata && !icao) || lat === null || lon === null || isNaN(lat) || isNaN(lon)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get country code from country name (simplified - you might want a mapping)
|
||||
const countryCode = getCountryCode(country);
|
||||
|
||||
try {
|
||||
// Use IATA/ICAO code as unique identifier, let ID auto-increment
|
||||
db.run(
|
||||
`INSERT OR IGNORE INTO airports (iata_code, icao_code, name, city, country, country_code, latitude, longitude, timezone)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
iata || null,
|
||||
icao || null,
|
||||
name,
|
||||
city || null,
|
||||
country,
|
||||
countryCode,
|
||||
lat,
|
||||
lon,
|
||||
tzDb || null
|
||||
]
|
||||
);
|
||||
imported++;
|
||||
} catch (err) {
|
||||
// Skip duplicates or invalid data
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
console.log(`[db] Seeded ${imported} airports from airports.dat (${skipped} skipped)`);
|
||||
return;
|
||||
} catch (fileError) {
|
||||
// File doesn't exist, fall back to minimal seed only if table is empty
|
||||
const airportsRow = db.get<{ count: number }>('SELECT COUNT(*) as count FROM airports');
|
||||
if ((airportsRow?.count ?? 0) === 0) {
|
||||
console.log('[db] airports.dat not found, using minimal airport seed');
|
||||
} else {
|
||||
console.log('[db] airports.dat not found, skipping seed (table already has data)');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: minimal seed if file doesn't exist
|
||||
const majorAirports = [
|
||||
{
|
||||
iata: 'JFK',
|
||||
icao: 'KJFK',
|
||||
name: 'John F. Kennedy International Airport',
|
||||
city: 'New York',
|
||||
country: 'United States',
|
||||
country_code: 'US',
|
||||
lat: 40.6398,
|
||||
lon: -73.7789,
|
||||
tz: 'America/New_York'
|
||||
},
|
||||
{
|
||||
iata: 'LAX',
|
||||
icao: 'KLAX',
|
||||
name: 'Los Angeles International Airport',
|
||||
city: 'Los Angeles',
|
||||
country: 'United States',
|
||||
country_code: 'US',
|
||||
lat: 33.9425,
|
||||
lon: -118.4081,
|
||||
tz: 'America/Los_Angeles'
|
||||
},
|
||||
{
|
||||
iata: 'LHR',
|
||||
icao: 'EGLL',
|
||||
name: 'London Heathrow Airport',
|
||||
city: 'London',
|
||||
country: 'United Kingdom',
|
||||
country_code: 'GB',
|
||||
lat: 51.47,
|
||||
lon: -0.4543,
|
||||
tz: 'Europe/London'
|
||||
},
|
||||
{
|
||||
iata: 'CDG',
|
||||
icao: 'LFPG',
|
||||
name: 'Charles de Gaulle Airport',
|
||||
city: 'Paris',
|
||||
country: 'France',
|
||||
country_code: 'FR',
|
||||
lat: 49.0097,
|
||||
lon: 2.5479,
|
||||
tz: 'Europe/Paris'
|
||||
},
|
||||
{
|
||||
iata: 'DXB',
|
||||
icao: 'OMDB',
|
||||
name: 'Dubai International Airport',
|
||||
city: 'Dubai',
|
||||
country: 'United Arab Emirates',
|
||||
country_code: 'AE',
|
||||
lat: 25.2532,
|
||||
lon: 55.3657,
|
||||
tz: 'Asia/Dubai'
|
||||
},
|
||||
{
|
||||
iata: 'SYD',
|
||||
icao: 'YSSY',
|
||||
name: 'Sydney Kingsford Smith Airport',
|
||||
city: 'Sydney',
|
||||
country: 'Australia',
|
||||
country_code: 'AU',
|
||||
lat: -33.9399,
|
||||
lon: 151.1753,
|
||||
tz: 'Australia/Sydney'
|
||||
},
|
||||
{
|
||||
iata: 'NRT',
|
||||
icao: 'RJAA',
|
||||
name: 'Narita International Airport',
|
||||
city: 'Tokyo',
|
||||
country: 'Japan',
|
||||
country_code: 'JP',
|
||||
lat: 35.772,
|
||||
lon: 140.3929,
|
||||
tz: 'Asia/Tokyo'
|
||||
},
|
||||
{
|
||||
iata: 'SIN',
|
||||
icao: 'WSSS',
|
||||
name: 'Singapore Changi Airport',
|
||||
city: 'Singapore',
|
||||
country: 'Singapore',
|
||||
country_code: 'SG',
|
||||
lat: 1.3644,
|
||||
lon: 103.9915,
|
||||
tz: 'Asia/Singapore'
|
||||
}
|
||||
];
|
||||
|
||||
let id = 1;
|
||||
for (const airport of majorAirports) {
|
||||
db.run(
|
||||
`INSERT OR IGNORE INTO airports (id, iata_code, icao_code, name, city, country, country_code, latitude, longitude, timezone)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
id++,
|
||||
airport.iata,
|
||||
airport.icao,
|
||||
airport.name,
|
||||
airport.city,
|
||||
airport.country,
|
||||
airport.country_code,
|
||||
airport.lat,
|
||||
airport.lon,
|
||||
airport.tz
|
||||
]
|
||||
);
|
||||
}
|
||||
console.log(`[db] Seeded ${majorAirports.length} airports`);
|
||||
} catch (e) {
|
||||
console.error('[db] Failed to seed airports:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function seedAirlines(db: Database): void {
|
||||
try {
|
||||
// Try to load from OpenFlights airlines.dat file
|
||||
// Format: Airline ID, Name, Alias, IATA, ICAO, Callsign, Country, Active
|
||||
const airlinesPath = join(__dirname, '../data/airlines.dat');
|
||||
try {
|
||||
const data = readFileSync(airlinesPath, 'utf-8');
|
||||
const lines = data.split('\n').filter((line) => line.trim());
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
// Skip comments
|
||||
if (line.startsWith('#')) continue;
|
||||
|
||||
// Parse CSV with proper quote handling
|
||||
const fields = parseCSVLine(line);
|
||||
|
||||
// OpenFlights format: ID, Name, Alias, IATA, ICAO, Callsign, Country, Active
|
||||
if (fields.length < 7) continue;
|
||||
|
||||
const [idStr, name, , iata, icao, , country, active] = fields;
|
||||
|
||||
// Skip if missing essential data or inactive
|
||||
if (!name || !country || active !== 'Y') {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only import airlines with IATA or ICAO codes
|
||||
if (!iata && !icao) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get country code from country name
|
||||
const countryCode = getCountryCode(country);
|
||||
|
||||
try {
|
||||
// Use IATA/ICAO code as unique identifier, let ID auto-increment
|
||||
db.run(
|
||||
`INSERT OR IGNORE INTO airlines (iata_code, icao_code, name, country, country_code)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[iata || null, icao || null, name, country, countryCode]
|
||||
);
|
||||
imported++;
|
||||
} catch (err) {
|
||||
// Skip duplicates or invalid data
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
console.log(`[db] Seeded ${imported} airlines from airlines.dat (${skipped} skipped)`);
|
||||
return;
|
||||
} catch (fileError) {
|
||||
// File doesn't exist, fall back to minimal seed only if table is empty
|
||||
const airlinesRow = db.get<{ count: number }>('SELECT COUNT(*) as count FROM airlines');
|
||||
if ((airlinesRow?.count ?? 0) === 0) {
|
||||
console.log('[db] airlines.dat not found, using minimal airline seed');
|
||||
} else {
|
||||
console.log('[db] airlines.dat not found, skipping seed (table already has data)');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: minimal seed if file doesn't exist
|
||||
const majorAirlines = [
|
||||
{
|
||||
iata: 'AA',
|
||||
icao: 'AAL',
|
||||
name: 'American Airlines',
|
||||
country: 'United States',
|
||||
country_code: 'US'
|
||||
},
|
||||
{
|
||||
iata: 'UA',
|
||||
icao: 'UAL',
|
||||
name: 'United Airlines',
|
||||
country: 'United States',
|
||||
country_code: 'US'
|
||||
},
|
||||
{
|
||||
iata: 'DL',
|
||||
icao: 'DAL',
|
||||
name: 'Delta Air Lines',
|
||||
country: 'United States',
|
||||
country_code: 'US'
|
||||
},
|
||||
{
|
||||
iata: 'BA',
|
||||
icao: 'BAW',
|
||||
name: 'British Airways',
|
||||
country: 'United Kingdom',
|
||||
country_code: 'GB'
|
||||
},
|
||||
{ iata: 'AF', icao: 'AFR', name: 'Air France', country: 'France', country_code: 'FR' },
|
||||
{ iata: 'LH', icao: 'DLH', name: 'Lufthansa', country: 'Germany', country_code: 'DE' },
|
||||
{
|
||||
iata: 'EK',
|
||||
icao: 'UAE',
|
||||
name: 'Emirates',
|
||||
country: 'United Arab Emirates',
|
||||
country_code: 'AE'
|
||||
},
|
||||
{ iata: 'QF', icao: 'QFA', name: 'Qantas', country: 'Australia', country_code: 'AU' },
|
||||
{ iata: 'JL', icao: 'JAL', name: 'Japan Airlines', country: 'Japan', country_code: 'JP' },
|
||||
{
|
||||
iata: 'SQ',
|
||||
icao: 'SIA',
|
||||
name: 'Singapore Airlines',
|
||||
country: 'Singapore',
|
||||
country_code: 'SG'
|
||||
}
|
||||
];
|
||||
|
||||
let id = 1;
|
||||
for (const airline of majorAirlines) {
|
||||
db.run(
|
||||
`INSERT OR IGNORE INTO airlines (id, iata_code, icao_code, name, country, country_code)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[id++, airline.iata, airline.icao, airline.name, airline.country, airline.country_code]
|
||||
);
|
||||
}
|
||||
console.log(`[db] Seeded ${majorAirlines.length} airlines`);
|
||||
} catch (e) {
|
||||
console.error('[db] Failed to seed airlines:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to map country names to ISO country codes
|
||||
// This is a simplified mapping - for production, consider using a comprehensive library
|
||||
function getCountryCode(countryName: string): string {
|
||||
// Common countries mapping (most frequently used in aviation)
|
||||
const countryMap: Record<string, string> = {
|
||||
'United States': 'US',
|
||||
'United Kingdom': 'GB',
|
||||
France: 'FR',
|
||||
Germany: 'DE',
|
||||
Japan: 'JP',
|
||||
China: 'CN',
|
||||
Canada: 'CA',
|
||||
Australia: 'AU',
|
||||
Brazil: 'BR',
|
||||
India: 'IN',
|
||||
Russia: 'RU',
|
||||
Mexico: 'MX',
|
||||
Spain: 'ES',
|
||||
Italy: 'IT',
|
||||
Netherlands: 'NL',
|
||||
Sweden: 'SE',
|
||||
Norway: 'NO',
|
||||
Denmark: 'DK',
|
||||
Finland: 'FI',
|
||||
Poland: 'PL',
|
||||
Turkey: 'TR',
|
||||
'South Korea': 'KR',
|
||||
Indonesia: 'ID',
|
||||
Thailand: 'TH',
|
||||
Malaysia: 'MY',
|
||||
Philippines: 'PH',
|
||||
Vietnam: 'VN',
|
||||
Singapore: 'SG',
|
||||
'United Arab Emirates': 'AE',
|
||||
'Saudi Arabia': 'SA',
|
||||
Israel: 'IL',
|
||||
'South Africa': 'ZA',
|
||||
Egypt: 'EG',
|
||||
Argentina: 'AR',
|
||||
Chile: 'CL',
|
||||
Colombia: 'CO',
|
||||
'New Zealand': 'NZ',
|
||||
Ireland: 'IE',
|
||||
Switzerland: 'CH',
|
||||
Austria: 'AT',
|
||||
Belgium: 'BE',
|
||||
Portugal: 'PT',
|
||||
Greece: 'GR',
|
||||
'Czech Republic': 'CZ',
|
||||
Hungary: 'HU',
|
||||
Romania: 'RO',
|
||||
Bulgaria: 'BG',
|
||||
Croatia: 'HR',
|
||||
Serbia: 'RS',
|
||||
Ukraine: 'UA',
|
||||
Belarus: 'BY',
|
||||
Kazakhstan: 'KZ',
|
||||
Pakistan: 'PK',
|
||||
Bangladesh: 'BD',
|
||||
'Sri Lanka': 'LK',
|
||||
Myanmar: 'MM',
|
||||
Cambodia: 'KH',
|
||||
Laos: 'LA',
|
||||
Nepal: 'NP',
|
||||
Afghanistan: 'AF',
|
||||
Iran: 'IR',
|
||||
Iraq: 'IQ',
|
||||
Jordan: 'JO',
|
||||
Lebanon: 'LB',
|
||||
Syria: 'SY',
|
||||
Yemen: 'YE',
|
||||
Oman: 'OM',
|
||||
Kuwait: 'KW',
|
||||
Qatar: 'QA',
|
||||
Bahrain: 'BH',
|
||||
Cyprus: 'CY',
|
||||
Morocco: 'MA',
|
||||
Algeria: 'DZ',
|
||||
Tunisia: 'TN',
|
||||
Libya: 'LY',
|
||||
Sudan: 'SD',
|
||||
Ethiopia: 'ET',
|
||||
Kenya: 'KE',
|
||||
Tanzania: 'TZ',
|
||||
Uganda: 'UG',
|
||||
Rwanda: 'RW',
|
||||
Ghana: 'GH',
|
||||
Nigeria: 'NG',
|
||||
Senegal: 'SN',
|
||||
'Ivory Coast': 'CI',
|
||||
Cameroon: 'CM',
|
||||
Angola: 'AO',
|
||||
Zambia: 'ZM',
|
||||
Zimbabwe: 'ZW',
|
||||
Botswana: 'BW',
|
||||
Namibia: 'NA',
|
||||
Mozambique: 'MZ',
|
||||
Madagascar: 'MG',
|
||||
Mauritius: 'MU',
|
||||
Peru: 'PE',
|
||||
Ecuador: 'EC',
|
||||
Venezuela: 'VE',
|
||||
Uruguay: 'UY',
|
||||
Paraguay: 'PY',
|
||||
Bolivia: 'BO',
|
||||
Panama: 'PA',
|
||||
'Costa Rica': 'CR',
|
||||
Nicaragua: 'NI',
|
||||
Honduras: 'HN',
|
||||
Guatemala: 'GT',
|
||||
Belize: 'BZ',
|
||||
'El Salvador': 'SV',
|
||||
Cuba: 'CU',
|
||||
Jamaica: 'JM',
|
||||
Haiti: 'HT',
|
||||
'Dominican Republic': 'DO',
|
||||
'Puerto Rico': 'PR',
|
||||
'Trinidad and Tobago': 'TT',
|
||||
Barbados: 'BB',
|
||||
Bahamas: 'BS',
|
||||
Iceland: 'IS',
|
||||
Luxembourg: 'LU',
|
||||
Malta: 'MT',
|
||||
Albania: 'AL',
|
||||
'Bosnia and Herzegovina': 'BA',
|
||||
'North Macedonia': 'MK',
|
||||
Montenegro: 'ME',
|
||||
Slovenia: 'SI',
|
||||
Slovakia: 'SK',
|
||||
Estonia: 'EE',
|
||||
Latvia: 'LV',
|
||||
Lithuania: 'LT',
|
||||
Moldova: 'MD',
|
||||
Armenia: 'AM',
|
||||
Georgia: 'GE',
|
||||
Azerbaijan: 'AZ',
|
||||
Kyrgyzstan: 'KG',
|
||||
Tajikistan: 'TJ',
|
||||
Turkmenistan: 'TM',
|
||||
Mongolia: 'MN',
|
||||
'North Korea': 'KP',
|
||||
Taiwan: 'TW',
|
||||
'Hong Kong': 'HK',
|
||||
Macau: 'MO',
|
||||
Brunei: 'BN',
|
||||
'Papua New Guinea': 'PG',
|
||||
Fiji: 'FJ',
|
||||
'New Caledonia': 'NC',
|
||||
'French Polynesia': 'PF',
|
||||
Samoa: 'WS',
|
||||
Tonga: 'TO',
|
||||
Palau: 'PW',
|
||||
Micronesia: 'FM',
|
||||
'Marshall Islands': 'MH',
|
||||
Kiribati: 'KI',
|
||||
Tuvalu: 'TV',
|
||||
Nauru: 'NR',
|
||||
'Cook Islands': 'CK',
|
||||
Niue: 'NU',
|
||||
Antarctica: 'AQ'
|
||||
};
|
||||
|
||||
// Try exact match first
|
||||
if (countryMap[countryName]) {
|
||||
return countryMap[countryName];
|
||||
}
|
||||
|
||||
// Try case-insensitive match
|
||||
const normalized = countryName.toLowerCase();
|
||||
for (const [key, value] of Object.entries(countryMap)) {
|
||||
if (key.toLowerCase() === normalized) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown country — return sentinel rather than a misleading partial string
|
||||
return 'XX';
|
||||
}
|
||||
|
||||
599
src/lib/server/flights.ts
Normal file
599
src/lib/server/flights.ts
Normal file
@@ -0,0 +1,599 @@
|
||||
import { db } from './db/index.js';
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { PlanStatus } from './plans.js';
|
||||
|
||||
// --- Geo utilities ---
|
||||
|
||||
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
const R = 6371;
|
||||
const dLat = ((lat2 - lat1) * Math.PI) / 180;
|
||||
const dLon = ((lon2 - lon1) * Math.PI) / 180;
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLon / 2) ** 2;
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
/** Convert a "YYYY-MM-DDTHH:MM" local datetime in an IANA timezone to a UTC ms timestamp. */
|
||||
function tzLocalToUtcMs(localDt: string, tz: string): number {
|
||||
const [datePart, timePart] = localDt.split('T');
|
||||
const [y, mo, d] = datePart.split('-').map(Number);
|
||||
const [h, mi] = timePart.split(':').map(Number);
|
||||
// Treat the local time as UTC first, then correct for the timezone offset.
|
||||
const candidate = Date.UTC(y, mo - 1, d, h, mi);
|
||||
const fmt = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: tz,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
const parts = fmt.formatToParts(new Date(candidate));
|
||||
const get = (type: string) => Number(parts.find((p) => p.type === type)?.value ?? '0');
|
||||
const shownH = get('hour');
|
||||
const shownMi = get('minute');
|
||||
// Offset = difference between what we intended and what the TZ shows at that UTC moment
|
||||
const offsetMins = (h - shownH) * 60 + (mi - shownMi);
|
||||
return candidate + offsetMins * 60_000;
|
||||
}
|
||||
|
||||
function durationMinutes(
|
||||
depDatetime: string,
|
||||
depTz: string,
|
||||
arrDatetime: string,
|
||||
arrTz: string
|
||||
): number | null {
|
||||
try {
|
||||
const depMs = tzLocalToUtcMs(depDatetime, depTz);
|
||||
const arrMs = tzLocalToUtcMs(arrDatetime, arrTz);
|
||||
const mins = Math.round((arrMs - depMs) / 60_000);
|
||||
return mins > 0 ? mins : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Look up the IANA timezone for an airport by its DB id. */
|
||||
function airportTimezone(airportId: number | null | undefined): string | null {
|
||||
if (!airportId) return null;
|
||||
return (
|
||||
db.get<{ timezone: string | null }>('SELECT timezone FROM airports WHERE id = ?', [airportId])
|
||||
?.timezone ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export interface Airport {
|
||||
id: number;
|
||||
iata_code: string | null;
|
||||
icao_code: string | null;
|
||||
name: string;
|
||||
city: string | null;
|
||||
country: string;
|
||||
country_code: string;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
timezone: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Airline {
|
||||
id: number;
|
||||
iata_code: string | null;
|
||||
icao_code: string | null;
|
||||
name: string;
|
||||
country: string | null;
|
||||
country_code: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface FlightBooking {
|
||||
id: string;
|
||||
plan_id: string;
|
||||
confirmation_number: string | null;
|
||||
price: number | null;
|
||||
currency: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface FlightSegment {
|
||||
id: string;
|
||||
flight_booking_id: string;
|
||||
departure_date: string;
|
||||
airline_id: number | null;
|
||||
airline_iata: string | null;
|
||||
airline_icao: string | null;
|
||||
airline_name: string | null;
|
||||
flight_number: string;
|
||||
position: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface FlightRoute {
|
||||
id: string;
|
||||
flight_segment_id: string;
|
||||
departure_airport_id: number | null;
|
||||
departure_airport_code: string | null;
|
||||
departure_terminal: string | null;
|
||||
departure_gate: string | null;
|
||||
departure_datetime: string | null;
|
||||
departure_timezone: string | null;
|
||||
arrival_airport_id: number | null;
|
||||
arrival_airport_code: string | null;
|
||||
arrival_terminal: string | null;
|
||||
arrival_gate: string | null;
|
||||
arrival_datetime: string | null;
|
||||
arrival_timezone: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateFlightInput {
|
||||
tripId: string;
|
||||
userId: string;
|
||||
confirmationNumber?: string;
|
||||
price?: number;
|
||||
currency?: string;
|
||||
status?: PlanStatus;
|
||||
segments: Array<{
|
||||
departureDate: string;
|
||||
airlineId?: number;
|
||||
airlineIata?: string;
|
||||
airlineIcao?: string;
|
||||
airlineName?: string;
|
||||
flightNumber: string;
|
||||
route?: {
|
||||
departureAirportId?: number;
|
||||
departureAirportCode?: string;
|
||||
departureTerminal?: string;
|
||||
departureGate?: string;
|
||||
departureDatetime?: string;
|
||||
departureTimezone?: string;
|
||||
arrivalAirportId?: number;
|
||||
arrivalAirportCode?: string;
|
||||
arrivalTerminal?: string;
|
||||
arrivalGate?: string;
|
||||
arrivalDatetime?: string;
|
||||
arrivalTimezone?: string;
|
||||
};
|
||||
}>;
|
||||
passengerIds?: string[];
|
||||
}
|
||||
|
||||
export function searchAirports(query: string): Airport[] {
|
||||
if (!query.trim()) return [];
|
||||
const pattern = `%${query.trim()}%`;
|
||||
return db.all<Airport>(
|
||||
`SELECT * FROM airports
|
||||
WHERE name LIKE ? COLLATE NOCASE
|
||||
OR iata_code LIKE ? COLLATE NOCASE
|
||||
OR icao_code LIKE ? COLLATE NOCASE
|
||||
OR city LIKE ? COLLATE NOCASE
|
||||
ORDER BY
|
||||
CASE WHEN iata_code LIKE ? COLLATE NOCASE THEN 0 ELSE 1 END,
|
||||
CASE WHEN name LIKE ? COLLATE NOCASE THEN 0 ELSE 1 END
|
||||
LIMIT 20`,
|
||||
[pattern, pattern, pattern, pattern, `${query.trim()}%`, `${query.trim()}%`]
|
||||
);
|
||||
}
|
||||
|
||||
export function searchAirlines(query: string): Airline[] {
|
||||
if (!query.trim()) return [];
|
||||
const pattern = `%${query.trim()}%`;
|
||||
return db.all<Airline>(
|
||||
`SELECT * FROM airlines
|
||||
WHERE name LIKE ? COLLATE NOCASE
|
||||
OR iata_code LIKE ? COLLATE NOCASE
|
||||
OR icao_code LIKE ? COLLATE NOCASE
|
||||
ORDER BY
|
||||
CASE WHEN iata_code LIKE ? COLLATE NOCASE THEN 0 ELSE 1 END,
|
||||
CASE WHEN name LIKE ? COLLATE NOCASE THEN 0 ELSE 1 END
|
||||
LIMIT 20`,
|
||||
[pattern, pattern, pattern, `${query.trim()}%`, `${query.trim()}%`]
|
||||
);
|
||||
}
|
||||
|
||||
export function getAirportById(id: number): Airport | undefined {
|
||||
return db.get<Airport>('SELECT * FROM airports WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
export function getAirportByCode(code: string): Airport | undefined {
|
||||
return db.get<Airport>('SELECT * FROM airports WHERE iata_code = ? OR icao_code = ?', [
|
||||
code.toUpperCase(),
|
||||
code.toUpperCase()
|
||||
]);
|
||||
}
|
||||
|
||||
export function getAirlineById(id: number): Airline | undefined {
|
||||
return db.get<Airline>('SELECT * FROM airlines WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
export function getAirlineByCode(code: string): Airline | undefined {
|
||||
return db.get<Airline>('SELECT * FROM airlines WHERE iata_code = ? OR icao_code = ?', [
|
||||
code.toUpperCase(),
|
||||
code.toUpperCase()
|
||||
]);
|
||||
}
|
||||
|
||||
export function createFlight(input: CreateFlightInput): FlightBooking {
|
||||
// First, create a plan entry for the flight
|
||||
const planId = randomUUID();
|
||||
const maxPos = db.get<{ pos: number }>(
|
||||
`SELECT COALESCE(MAX(position), -1) + 1 as pos FROM plans WHERE trip_id = ? AND user_id = ?`,
|
||||
[input.tripId, input.userId]
|
||||
);
|
||||
const position = maxPos?.pos ?? 0;
|
||||
|
||||
// Generate a title from the first segment
|
||||
const firstSegment = input.segments[0];
|
||||
const title = firstSegment
|
||||
? `${firstSegment.airlineName || firstSegment.airlineIata || 'Flight'} ${firstSegment.flightNumber}`
|
||||
: 'Flight';
|
||||
|
||||
db.run(
|
||||
`INSERT INTO plans (id, trip_id, user_id, type, status, title, position)
|
||||
VALUES (?, ?, ?, 'transport', ?, ?, ?)`,
|
||||
[planId, input.tripId, input.userId, input.status ?? 'idea', title, position]
|
||||
);
|
||||
|
||||
// Create the flight booking
|
||||
const bookingId = randomUUID();
|
||||
db.run(
|
||||
`INSERT INTO flight_bookings (id, plan_id, confirmation_number, price, currency)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[
|
||||
bookingId,
|
||||
planId,
|
||||
input.confirmationNumber ?? null,
|
||||
input.price ?? null,
|
||||
input.currency ?? 'USD'
|
||||
]
|
||||
);
|
||||
|
||||
// Create flight segments
|
||||
let segmentPosition = 0;
|
||||
for (const segment of input.segments) {
|
||||
const segmentId = randomUUID();
|
||||
|
||||
// Airline data comes fully resolved from the caller — no re-query needed
|
||||
const airlineIata = segment.airlineIata;
|
||||
const airlineIcao = segment.airlineIcao;
|
||||
const airlineName = segment.airlineName;
|
||||
|
||||
db.run(
|
||||
`INSERT INTO flight_segments (id, flight_booking_id, departure_date, airline_id, airline_iata, airline_icao, airline_name, flight_number, position)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
segmentId,
|
||||
bookingId,
|
||||
segment.departureDate,
|
||||
segment.airlineId ?? null,
|
||||
airlineIata ?? null,
|
||||
airlineIcao ?? null,
|
||||
airlineName ?? null,
|
||||
segment.flightNumber,
|
||||
segmentPosition++
|
||||
]
|
||||
);
|
||||
|
||||
// Create route if provided
|
||||
if (segment.route) {
|
||||
const routeId = randomUUID();
|
||||
db.run(
|
||||
`INSERT INTO flight_routes (
|
||||
id, flight_segment_id,
|
||||
departure_airport_id, departure_airport_code, departure_terminal, departure_gate,
|
||||
departure_datetime, departure_timezone,
|
||||
arrival_airport_id, arrival_airport_code, arrival_terminal, arrival_gate,
|
||||
arrival_datetime, arrival_timezone
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
routeId,
|
||||
segmentId,
|
||||
segment.route.departureAirportId ?? null,
|
||||
segment.route.departureAirportCode ?? null,
|
||||
segment.route.departureTerminal ?? null,
|
||||
segment.route.departureGate ?? null,
|
||||
segment.route.departureDatetime ?? null,
|
||||
segment.route.departureTimezone ??
|
||||
airportTimezone(segment.route.departureAirportId) ??
|
||||
null,
|
||||
segment.route.arrivalAirportId ?? null,
|
||||
segment.route.arrivalAirportCode ?? null,
|
||||
segment.route.arrivalTerminal ?? null,
|
||||
segment.route.arrivalGate ?? null,
|
||||
segment.route.arrivalDatetime ?? null,
|
||||
segment.route.arrivalTimezone ?? airportTimezone(segment.route.arrivalAirportId) ?? null
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Link passengers if provided
|
||||
if (input.passengerIds && input.passengerIds.length > 0) {
|
||||
for (const personId of input.passengerIds) {
|
||||
db.run(
|
||||
`INSERT OR IGNORE INTO flight_booking_passengers (flight_booking_id, person_id)
|
||||
VALUES (?, ?)`,
|
||||
[bookingId, personId]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return db.get<FlightBooking>('SELECT * FROM flight_bookings WHERE id = ?', [bookingId])!;
|
||||
}
|
||||
|
||||
function enrichRoute(
|
||||
route: FlightRoute | undefined
|
||||
): (FlightRoute & { distanceKm: number | null; durationMins: number | null }) | null {
|
||||
if (!route) return null;
|
||||
|
||||
let distanceKm: number | null = null;
|
||||
let durationMins: number | null = null;
|
||||
|
||||
if (route.departure_airport_id && route.arrival_airport_id) {
|
||||
const dep = db.get<{ latitude: number | null; longitude: number | null }>(
|
||||
'SELECT latitude, longitude FROM airports WHERE id = ?',
|
||||
[route.departure_airport_id]
|
||||
);
|
||||
const arr = db.get<{ latitude: number | null; longitude: number | null }>(
|
||||
'SELECT latitude, longitude FROM airports WHERE id = ?',
|
||||
[route.arrival_airport_id]
|
||||
);
|
||||
if (dep?.latitude && dep?.longitude && arr?.latitude && arr?.longitude) {
|
||||
distanceKm = Math.round(
|
||||
haversineKm(dep.latitude, dep.longitude, arr.latitude, arr.longitude)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
route.departure_datetime &&
|
||||
route.arrival_datetime &&
|
||||
route.departure_timezone &&
|
||||
route.arrival_timezone
|
||||
) {
|
||||
durationMins = durationMinutes(
|
||||
route.departure_datetime,
|
||||
route.departure_timezone,
|
||||
route.arrival_datetime,
|
||||
route.arrival_timezone
|
||||
);
|
||||
}
|
||||
|
||||
return { ...route, distanceKm, durationMins };
|
||||
}
|
||||
|
||||
export function getFlightBookingByPlanId(
|
||||
planId: string,
|
||||
userId: string
|
||||
):
|
||||
| (FlightBooking & {
|
||||
segments: Array<
|
||||
FlightSegment & {
|
||||
route: (FlightRoute & { distanceKm: number | null; durationMins: number | null }) | null;
|
||||
}
|
||||
>;
|
||||
passengerIds: string[];
|
||||
})
|
||||
| undefined {
|
||||
const booking = db.get<FlightBooking>(
|
||||
`SELECT fb.* FROM flight_bookings fb
|
||||
JOIN plans p ON p.id = fb.plan_id
|
||||
WHERE fb.plan_id = ? AND p.user_id = ?`,
|
||||
[planId, userId]
|
||||
);
|
||||
|
||||
if (!booking) return undefined;
|
||||
|
||||
const segments = db.all<FlightSegment>(
|
||||
`SELECT * FROM flight_segments
|
||||
WHERE flight_booking_id = ?
|
||||
ORDER BY position ASC`,
|
||||
[booking.id]
|
||||
);
|
||||
|
||||
const segmentsWithRoutes = segments.map((segment) => {
|
||||
const route = db.get<FlightRoute>('SELECT * FROM flight_routes WHERE flight_segment_id = ?', [
|
||||
segment.id
|
||||
]);
|
||||
return { ...segment, route: enrichRoute(route) };
|
||||
});
|
||||
|
||||
const passengerIds = db
|
||||
.all<{
|
||||
person_id: string;
|
||||
}>('SELECT person_id FROM flight_booking_passengers WHERE flight_booking_id = ?', [booking.id])
|
||||
.map((r) => r.person_id);
|
||||
|
||||
return { ...booking, segments: segmentsWithRoutes, passengerIds };
|
||||
}
|
||||
|
||||
export function getFlightBookingsForTrip(
|
||||
tripId: string,
|
||||
userId: string
|
||||
): Array<
|
||||
FlightBooking & {
|
||||
segments: Array<
|
||||
FlightSegment & {
|
||||
route: (FlightRoute & { distanceKm: number | null; durationMins: number | null }) | null;
|
||||
}
|
||||
>;
|
||||
passengerIds: string[];
|
||||
}
|
||||
> {
|
||||
const bookings = db.all<FlightBooking>(
|
||||
`SELECT fb.* FROM flight_bookings fb
|
||||
JOIN plans p ON p.id = fb.plan_id
|
||||
WHERE p.trip_id = ? AND p.user_id = ? AND p.type = 'transport'
|
||||
ORDER BY p.position ASC, fb.created_at ASC`,
|
||||
[tripId, userId]
|
||||
);
|
||||
|
||||
return bookings.map((booking) => {
|
||||
const segments = db.all<FlightSegment>(
|
||||
`SELECT * FROM flight_segments
|
||||
WHERE flight_booking_id = ?
|
||||
ORDER BY position ASC`,
|
||||
[booking.id]
|
||||
);
|
||||
|
||||
const segmentsWithRoutes = segments.map((segment) => {
|
||||
const route = db.get<FlightRoute>('SELECT * FROM flight_routes WHERE flight_segment_id = ?', [
|
||||
segment.id
|
||||
]);
|
||||
return { ...segment, route: enrichRoute(route) };
|
||||
});
|
||||
|
||||
const passengerIds = db
|
||||
.all<{
|
||||
person_id: string;
|
||||
}>('SELECT person_id FROM flight_booking_passengers WHERE flight_booking_id = ?', [
|
||||
booking.id
|
||||
])
|
||||
.map((r) => r.person_id);
|
||||
|
||||
return { ...booking, segments: segmentsWithRoutes, passengerIds };
|
||||
});
|
||||
}
|
||||
|
||||
export interface UpdateFlightInput {
|
||||
bookingId: string;
|
||||
userId: string;
|
||||
confirmationNumber?: string;
|
||||
price?: number;
|
||||
currency?: string;
|
||||
status?: PlanStatus;
|
||||
segments: Array<{
|
||||
departureDate: string;
|
||||
airlineId?: number;
|
||||
airlineIata?: string;
|
||||
airlineIcao?: string;
|
||||
airlineName?: string;
|
||||
flightNumber: string;
|
||||
route?: {
|
||||
departureAirportId?: number;
|
||||
departureAirportCode?: string;
|
||||
departureTerminal?: string;
|
||||
departureGate?: string;
|
||||
departureDatetime?: string;
|
||||
departureTimezone?: string;
|
||||
arrivalAirportId?: number;
|
||||
arrivalAirportCode?: string;
|
||||
arrivalTerminal?: string;
|
||||
arrivalGate?: string;
|
||||
arrivalDatetime?: string;
|
||||
arrivalTimezone?: string;
|
||||
};
|
||||
}>;
|
||||
passengerIds?: string[];
|
||||
}
|
||||
|
||||
export function updateFlight(input: UpdateFlightInput): void {
|
||||
// Verify booking belongs to user via plans table
|
||||
const booking = db.get<FlightBooking & { plan_id: string }>(
|
||||
`SELECT fb.* FROM flight_bookings fb
|
||||
JOIN plans p ON p.id = fb.plan_id
|
||||
WHERE fb.id = ? AND p.user_id = ?`,
|
||||
[input.bookingId, input.userId]
|
||||
);
|
||||
if (!booking) throw new Error('Flight booking not found or not authorized');
|
||||
|
||||
// Derive new title from first segment
|
||||
const firstSegment = input.segments[0];
|
||||
const title = firstSegment
|
||||
? `${firstSegment.airlineName || firstSegment.airlineIata || 'Flight'} ${firstSegment.flightNumber}`
|
||||
: 'Flight';
|
||||
|
||||
// Update the plan row (status + title)
|
||||
db.run(`UPDATE plans SET status = ?, title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, [
|
||||
input.status ?? 'idea',
|
||||
title,
|
||||
booking.plan_id
|
||||
]);
|
||||
|
||||
// Update the booking row
|
||||
db.run(
|
||||
`UPDATE flight_bookings SET confirmation_number = ?, price = ?, currency = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
[
|
||||
input.confirmationNumber ?? null,
|
||||
input.price ?? null,
|
||||
input.currency ?? 'USD',
|
||||
input.bookingId
|
||||
]
|
||||
);
|
||||
|
||||
// Replace segments and routes wholesale
|
||||
const existingSegments = db.all<{ id: string }>(
|
||||
'SELECT id FROM flight_segments WHERE flight_booking_id = ?',
|
||||
[input.bookingId]
|
||||
);
|
||||
for (const seg of existingSegments) {
|
||||
db.run('DELETE FROM flight_routes WHERE flight_segment_id = ?', [seg.id]);
|
||||
}
|
||||
db.run('DELETE FROM flight_segments WHERE flight_booking_id = ?', [input.bookingId]);
|
||||
|
||||
let segmentPosition = 0;
|
||||
for (const segment of input.segments) {
|
||||
const segmentId = randomUUID();
|
||||
db.run(
|
||||
`INSERT INTO flight_segments (id, flight_booking_id, departure_date, airline_id, airline_iata, airline_icao, airline_name, flight_number, position)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
segmentId,
|
||||
input.bookingId,
|
||||
segment.departureDate,
|
||||
segment.airlineId ?? null,
|
||||
segment.airlineIata ?? null,
|
||||
segment.airlineIcao ?? null,
|
||||
segment.airlineName ?? null,
|
||||
segment.flightNumber,
|
||||
segmentPosition++
|
||||
]
|
||||
);
|
||||
|
||||
if (segment.route) {
|
||||
const routeId = randomUUID();
|
||||
db.run(
|
||||
`INSERT INTO flight_routes (
|
||||
id, flight_segment_id,
|
||||
departure_airport_id, departure_airport_code, departure_terminal, departure_gate,
|
||||
departure_datetime, departure_timezone,
|
||||
arrival_airport_id, arrival_airport_code, arrival_terminal, arrival_gate,
|
||||
arrival_datetime, arrival_timezone
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
routeId,
|
||||
segmentId,
|
||||
segment.route.departureAirportId ?? null,
|
||||
segment.route.departureAirportCode ?? null,
|
||||
segment.route.departureTerminal ?? null,
|
||||
segment.route.departureGate ?? null,
|
||||
segment.route.departureDatetime ?? null,
|
||||
segment.route.departureTimezone ??
|
||||
airportTimezone(segment.route.departureAirportId) ??
|
||||
null,
|
||||
segment.route.arrivalAirportId ?? null,
|
||||
segment.route.arrivalAirportCode ?? null,
|
||||
segment.route.arrivalTerminal ?? null,
|
||||
segment.route.arrivalGate ?? null,
|
||||
segment.route.arrivalDatetime ?? null,
|
||||
segment.route.arrivalTimezone ?? airportTimezone(segment.route.arrivalAirportId) ?? null
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Replace passengers
|
||||
db.run('DELETE FROM flight_booking_passengers WHERE flight_booking_id = ?', [input.bookingId]);
|
||||
if (input.passengerIds && input.passengerIds.length > 0) {
|
||||
for (const personId of input.passengerIds) {
|
||||
db.run(
|
||||
`INSERT OR IGNORE INTO flight_booking_passengers (flight_booking_id, person_id) VALUES (?, ?)`,
|
||||
[input.bookingId, personId]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
245
src/lib/server/lodgings.ts
Normal file
245
src/lib/server/lodgings.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { db } from './db/index.js';
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { PlanStatus } from './plans.js';
|
||||
|
||||
export interface Lodging {
|
||||
id: string;
|
||||
plan_id: string;
|
||||
name: string;
|
||||
chain: string | null;
|
||||
check_in_date: string | null;
|
||||
check_in_time: string | null;
|
||||
check_in_timezone: string | null;
|
||||
check_out_date: string | null;
|
||||
check_out_time: string | null;
|
||||
check_out_timezone: string | null;
|
||||
address_line1: string | null;
|
||||
address_line2: string | null;
|
||||
city_name: string | null;
|
||||
country: string | null;
|
||||
country_code: string | null;
|
||||
postal_code: string | null;
|
||||
confirmation_number: string | null;
|
||||
website: string | null;
|
||||
phone: string | null;
|
||||
price: number | null;
|
||||
currency: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateLodgingInput {
|
||||
tripId: string;
|
||||
userId: string;
|
||||
status?: PlanStatus;
|
||||
name: string;
|
||||
chain?: string;
|
||||
checkInDate?: string;
|
||||
checkInTime?: string;
|
||||
checkInTimezone?: string;
|
||||
checkOutDate?: string;
|
||||
checkOutTime?: string;
|
||||
checkOutTimezone?: string;
|
||||
addressLine1?: string;
|
||||
addressLine2?: string;
|
||||
cityName?: string;
|
||||
country?: string;
|
||||
countryCode?: string;
|
||||
postalCode?: string;
|
||||
confirmationNumber?: string;
|
||||
website?: string;
|
||||
phone?: string;
|
||||
price?: number;
|
||||
currency?: string;
|
||||
guestIds?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateLodgingInput {
|
||||
lodgingId: string;
|
||||
userId: string;
|
||||
status?: PlanStatus;
|
||||
name: string;
|
||||
chain?: string;
|
||||
checkInDate?: string;
|
||||
checkInTime?: string;
|
||||
checkInTimezone?: string;
|
||||
checkOutDate?: string;
|
||||
checkOutTime?: string;
|
||||
checkOutTimezone?: string;
|
||||
addressLine1?: string;
|
||||
addressLine2?: string;
|
||||
cityName?: string;
|
||||
country?: string;
|
||||
countryCode?: string;
|
||||
postalCode?: string;
|
||||
confirmationNumber?: string;
|
||||
website?: string;
|
||||
phone?: string;
|
||||
price?: number;
|
||||
currency?: string;
|
||||
guestIds?: string[];
|
||||
}
|
||||
|
||||
export function createLodging(input: CreateLodgingInput): Lodging {
|
||||
const planId = randomUUID();
|
||||
const maxPos = db.get<{ pos: number }>(
|
||||
`SELECT COALESCE(MAX(position), -1) + 1 as pos FROM plans WHERE trip_id = ? AND user_id = ?`,
|
||||
[input.tripId, input.userId]
|
||||
);
|
||||
const position = maxPos?.pos ?? 0;
|
||||
|
||||
db.run(
|
||||
`INSERT INTO plans (id, trip_id, user_id, type, status, title, city_name, country, country_code, position)
|
||||
VALUES (?, ?, ?, 'lodging', ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
planId,
|
||||
input.tripId,
|
||||
input.userId,
|
||||
input.status ?? 'idea',
|
||||
input.name,
|
||||
input.cityName ?? null,
|
||||
input.country ?? null,
|
||||
input.countryCode ?? null,
|
||||
position
|
||||
]
|
||||
);
|
||||
|
||||
const lodgingId = randomUUID();
|
||||
db.run(
|
||||
`INSERT INTO lodgings (
|
||||
id, plan_id, name, chain,
|
||||
check_in_date, check_in_time, check_in_timezone,
|
||||
check_out_date, check_out_time, check_out_timezone,
|
||||
address_line1, address_line2, city_name, country, country_code, postal_code,
|
||||
confirmation_number, website, phone, price, currency
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
lodgingId,
|
||||
planId,
|
||||
input.name,
|
||||
input.chain ?? null,
|
||||
input.checkInDate ?? null,
|
||||
input.checkInTime ?? null,
|
||||
input.checkInTimezone ?? null,
|
||||
input.checkOutDate ?? null,
|
||||
input.checkOutTime ?? null,
|
||||
input.checkOutTimezone ?? null,
|
||||
input.addressLine1 ?? null,
|
||||
input.addressLine2 ?? null,
|
||||
input.cityName ?? null,
|
||||
input.country ?? null,
|
||||
input.countryCode ?? null,
|
||||
input.postalCode ?? null,
|
||||
input.confirmationNumber ?? null,
|
||||
input.website ?? null,
|
||||
input.phone ?? null,
|
||||
input.price ?? null,
|
||||
input.currency ?? 'USD'
|
||||
]
|
||||
);
|
||||
|
||||
if (input.guestIds && input.guestIds.length > 0) {
|
||||
for (const personId of input.guestIds) {
|
||||
db.run(
|
||||
`INSERT OR IGNORE INTO lodging_guests (lodging_id, person_id) VALUES (?, ?)`,
|
||||
[lodgingId, personId]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return db.get<Lodging>('SELECT * FROM lodgings WHERE id = ?', [lodgingId])!;
|
||||
}
|
||||
|
||||
export function getLodgingsForTrip(
|
||||
tripId: string,
|
||||
userId: string
|
||||
): Array<Lodging & { guestIds: string[]; planStatus: PlanStatus }> {
|
||||
const lodgings = db.all<Lodging & { plan_status: string }>(
|
||||
`SELECT l.*, p.status as plan_status
|
||||
FROM lodgings l
|
||||
JOIN plans p ON p.id = l.plan_id
|
||||
WHERE p.trip_id = ? AND p.user_id = ? AND p.type = 'lodging'
|
||||
ORDER BY p.position ASC, l.created_at ASC`,
|
||||
[tripId, userId]
|
||||
);
|
||||
|
||||
return lodgings.map((lodging) => {
|
||||
const guestIds = db
|
||||
.all<{ person_id: string }>(
|
||||
'SELECT person_id FROM lodging_guests WHERE lodging_id = ?',
|
||||
[lodging.id]
|
||||
)
|
||||
.map((r) => r.person_id);
|
||||
const { plan_status, ...rest } = lodging;
|
||||
return { ...rest, guestIds, planStatus: plan_status as PlanStatus };
|
||||
});
|
||||
}
|
||||
|
||||
export function updateLodging(input: UpdateLodgingInput): void {
|
||||
// Verify ownership via plans table
|
||||
const lodging = db.get<{ id: string; plan_id: string }>(
|
||||
`SELECT l.id, l.plan_id FROM lodgings l
|
||||
JOIN plans p ON p.id = l.plan_id
|
||||
WHERE l.id = ? AND p.user_id = ?`,
|
||||
[input.lodgingId, input.userId]
|
||||
);
|
||||
if (!lodging) throw new Error('Lodging not found or not authorized');
|
||||
|
||||
// Update plans row
|
||||
db.run(
|
||||
`UPDATE plans SET status = ?, title = ?, city_name = ?, country = ?, country_code = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
[
|
||||
input.status ?? 'idea',
|
||||
input.name,
|
||||
input.cityName ?? null,
|
||||
input.country ?? null,
|
||||
input.countryCode ?? null,
|
||||
lodging.plan_id
|
||||
]
|
||||
);
|
||||
|
||||
// Update lodgings row
|
||||
db.run(
|
||||
`UPDATE lodgings SET
|
||||
name = ?, chain = ?,
|
||||
check_in_date = ?, check_in_time = ?, check_in_timezone = ?,
|
||||
check_out_date = ?, check_out_time = ?, check_out_timezone = ?,
|
||||
address_line1 = ?, address_line2 = ?, city_name = ?, country = ?, country_code = ?, postal_code = ?,
|
||||
confirmation_number = ?, website = ?, phone = ?, price = ?, currency = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
[
|
||||
input.name,
|
||||
input.chain ?? null,
|
||||
input.checkInDate ?? null,
|
||||
input.checkInTime ?? null,
|
||||
input.checkInTimezone ?? null,
|
||||
input.checkOutDate ?? null,
|
||||
input.checkOutTime ?? null,
|
||||
input.checkOutTimezone ?? null,
|
||||
input.addressLine1 ?? null,
|
||||
input.addressLine2 ?? null,
|
||||
input.cityName ?? null,
|
||||
input.country ?? null,
|
||||
input.countryCode ?? null,
|
||||
input.postalCode ?? null,
|
||||
input.confirmationNumber ?? null,
|
||||
input.website ?? null,
|
||||
input.phone ?? null,
|
||||
input.price ?? null,
|
||||
input.currency ?? 'USD',
|
||||
input.lodgingId
|
||||
]
|
||||
);
|
||||
|
||||
// Replace guests
|
||||
db.run('DELETE FROM lodging_guests WHERE lodging_id = ?', [input.lodgingId]);
|
||||
if (input.guestIds && input.guestIds.length > 0) {
|
||||
for (const personId of input.guestIds) {
|
||||
db.run(
|
||||
`INSERT OR IGNORE INTO lodging_guests (lodging_id, person_id) VALUES (?, ?)`,
|
||||
[input.lodgingId, personId]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,9 +99,43 @@ export function getPlanCountForTrip(tripId: string, userId: string): number {
|
||||
return row?.count ?? 0;
|
||||
}
|
||||
|
||||
export function searchCities(query: string): City[] {
|
||||
export interface Country {
|
||||
name: string;
|
||||
country_code: string;
|
||||
}
|
||||
|
||||
export function getCountries(query?: string): Country[] {
|
||||
if (!query?.trim()) {
|
||||
return db.all<Country>(
|
||||
`SELECT DISTINCT country as name, country_code FROM cities ORDER BY country`
|
||||
);
|
||||
}
|
||||
const pattern = `%${query.trim()}%`;
|
||||
return db.all<Country>(
|
||||
`SELECT DISTINCT country as name, country_code
|
||||
FROM cities
|
||||
WHERE country LIKE ? COLLATE NOCASE
|
||||
ORDER BY country
|
||||
LIMIT 50`,
|
||||
[pattern]
|
||||
);
|
||||
}
|
||||
|
||||
export function searchCities(query: string, countryCode?: string): City[] {
|
||||
if (!query.trim()) return [];
|
||||
const pattern = `%${query.trim()}%`;
|
||||
if (countryCode?.trim()) {
|
||||
return db.all<City>(
|
||||
`SELECT id, name, country, country_code, population
|
||||
FROM cities
|
||||
WHERE name LIKE ? COLLATE NOCASE AND country_code = ?
|
||||
ORDER BY
|
||||
CASE WHEN name LIKE ? COLLATE NOCASE THEN 0 ELSE 1 END,
|
||||
population DESC NULLS LAST
|
||||
LIMIT 10`,
|
||||
[pattern, countryCode.trim().toUpperCase(), `${query.trim()}%`]
|
||||
);
|
||||
}
|
||||
return db.all<City>(
|
||||
`SELECT id, name, country, country_code, population
|
||||
FROM cities
|
||||
|
||||
@@ -72,7 +72,14 @@ export function getPeopleForUser(userId: string): Person[] {
|
||||
// Trip assignment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function addPersonToTrip(tripId: string, personId: string): void {
|
||||
export function addPersonToTrip(tripId: string, personId: string, userId: string): void {
|
||||
// Verify the person belongs to this user before linking
|
||||
const person = db.get<{ id: string }>(`SELECT id FROM people WHERE id = ? AND user_id = ?`, [
|
||||
personId,
|
||||
userId
|
||||
]);
|
||||
if (!person) throw new Error('Person not found or not authorized');
|
||||
|
||||
const maxPos = db.get<{ pos: number }>(
|
||||
`SELECT COALESCE(MAX(position), -1) + 1 as pos FROM trip_travellers WHERE trip_id = ?`,
|
||||
[tripId]
|
||||
@@ -103,8 +110,8 @@ export function addTraveller(input: {
|
||||
personId = person.id;
|
||||
}
|
||||
|
||||
// Add the person to the trip
|
||||
addPersonToTrip(input.tripId, personId);
|
||||
// Add the person to the trip (ownership verified inside)
|
||||
addPersonToTrip(input.tripId, personId, input.userId);
|
||||
}
|
||||
|
||||
export function getTravellersForTrip(tripId: string, userId: string): Person[] {
|
||||
|
||||
Reference in New Issue
Block a user