trip) adding support for lodgings

This commit is contained in:
2026-02-18 23:17:34 -05:00
parent 1068145261
commit 50e216daec
244 changed files with 29012 additions and 11 deletions

View File

@@ -0,0 +1,10 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { searchAirlines } from '$lib/server/flights.js';
export const GET: RequestHandler = ({ url }) => {
const q = url.searchParams.get('q') ?? '';
const airlines = searchAirlines(q);
return json(airlines);
};

View File

@@ -0,0 +1,10 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { searchAirports } from '$lib/server/flights.js';
export const GET: RequestHandler = ({ url }) => {
const q = url.searchParams.get('q') ?? '';
const airports = searchAirports(q);
return json(airports);
};

View File

@@ -4,6 +4,7 @@ import { searchCities } from '$lib/server/plans.js';
export const GET: RequestHandler = ({ url }) => {
const q = url.searchParams.get('q') ?? '';
const cities = searchCities(q);
const countryCode = url.searchParams.get('country_code') ?? undefined;
const cities = searchCities(q, countryCode);
return json(cities);
};

View File

@@ -0,0 +1,9 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getCountries } from '$lib/server/plans.js';
export const GET: RequestHandler = ({ url }) => {
const q = url.searchParams.get('q') ?? undefined;
const countries = getCountries(q);
return json(countries);
};

View File

@@ -1,7 +1,14 @@
import { error, fail } from '@sveltejs/kit';
import { getTripById, updateTrip } from '$lib/server/trips.js';
import { createDestination, getPlansForTrip, deletePlan } from '$lib/server/plans.js';
import { addTraveller, getTravellersForTrip, getPeopleForUser, removeTravellerFromTrip } from '$lib/server/travellers.js';
import {
addTraveller,
getTravellersForTrip,
getPeopleForUser,
removeTravellerFromTrip
} from '$lib/server/travellers.js';
import { createFlight, updateFlight, getFlightBookingsForTrip } from '$lib/server/flights.js';
import { createLodging, updateLodging, getLodgingsForTrip } from '$lib/server/lodgings.js';
import type { PageServerLoad, Actions } from './$types';
export const load: PageServerLoad = async (event) => {
@@ -15,8 +22,19 @@ export const load: PageServerLoad = async (event) => {
const plans = getPlansForTrip(trip.id, userId);
const travellers = getTravellersForTrip(trip.id, userId);
const people = getPeopleForUser(userId);
const flightBookings = getFlightBookingsForTrip(trip.id, userId);
const lodgings = getLodgingsForTrip(trip.id, userId);
return { trip, plans, planCount: plans.length, travellers, travellerCount: travellers.length, people };
return {
trip,
plans,
planCount: plans.length,
travellers,
travellerCount: travellers.length,
people,
flightBookings,
lodgings
};
};
export const actions: Actions = {
@@ -128,7 +146,9 @@ export const actions: Actions = {
removeTravellerFromTrip(trip.id, personId, userId);
return { success: true };
} catch (err) {
return fail(400, { error: err instanceof Error ? err.message : 'Failed to remove traveller' });
return fail(400, {
error: err instanceof Error ? err.message : 'Failed to remove traveller'
});
}
},
@@ -153,5 +173,350 @@ export const actions: Actions = {
} catch (err) {
return fail(400, { error: err instanceof Error ? err.message : 'Failed to remove plan' });
}
},
addFlight: async (event) => {
const session = await event.locals.auth();
const userId = session?.user?.id;
if (!userId) return fail(401, { error: 'Not authenticated' });
const trip = getTripById(event.params.id, userId);
if (!trip) return fail(404, { error: 'Trip not found' });
const data = await event.request.formData();
const confirmationNumber = (data.get('confirmation_number') as string)?.trim() || undefined;
const priceRaw = (data.get('price') as string)?.trim();
const price = priceRaw ? parseFloat(priceRaw) : undefined;
const currency = (data.get('currency') as string)?.trim() || 'USD';
const status = data.get('status') as string as 'idea' | 'tentative' | 'confirmed';
const passengerIds = data.getAll('passenger_ids[]') as string[];
// Parse segments - form data comes as segments[0][field], segments[1][field], etc.
const segments: Array<{
departureDate: string;
airlineId?: number;
airlineIata?: string;
airlineName?: string;
flightNumber: string;
route?: {
departureAirportId?: number;
departureAirportCode?: string;
arrivalAirportId?: number;
arrivalAirportCode?: string;
departureDatetime?: string;
arrivalDatetime?: string;
};
}> = [];
// Collect all segment indices
const segmentIndices = new Set<number>();
for (const key of data.keys()) {
const match = key.match(/^segments\[(\d+)\]/);
if (match) {
segmentIndices.add(parseInt(match[1]));
}
}
// Build segments array
for (const index of Array.from(segmentIndices).sort((a, b) => a - b)) {
const departureDate = (data.get(`segments[${index}][departure_date]`) as string)?.trim();
const airlineIdRaw = (data.get(`segments[${index}][airline_id]`) as string)?.trim();
const airlineIata = (data.get(`segments[${index}][airline_iata]`) as string)?.trim();
const airlineName = (data.get(`segments[${index}][airline_name]`) as string)?.trim();
const flightNumber = (data.get(`segments[${index}][flight_number]`) as string)?.trim();
const departureAirportIdRaw = (
data.get(`segments[${index}][departure_airport_id]`) as string
)?.trim();
const departureAirportCode = (
data.get(`segments[${index}][departure_airport_code]`) as string
)?.trim();
const arrivalAirportIdRaw = (
data.get(`segments[${index}][arrival_airport_id]`) as string
)?.trim();
const arrivalAirportCode = (
data.get(`segments[${index}][arrival_airport_code]`) as string
)?.trim();
const departureDatetime =
(data.get(`segments[${index}][departure_datetime]`) as string)?.trim() || undefined;
const arrivalDatetime =
(data.get(`segments[${index}][arrival_datetime]`) as string)?.trim() || undefined;
if (!departureDate || !flightNumber) {
return fail(400, { error: 'All segments must have a departure date and flight number' });
}
const hasRoute =
departureAirportIdRaw ||
departureAirportCode ||
arrivalAirportIdRaw ||
arrivalAirportCode ||
departureDatetime ||
arrivalDatetime;
segments.push({
departureDate,
airlineId: airlineIdRaw ? parseInt(airlineIdRaw) : undefined,
airlineIata: airlineIata || undefined,
airlineName: airlineName || undefined,
flightNumber,
route: hasRoute
? {
departureAirportId: departureAirportIdRaw
? parseInt(departureAirportIdRaw)
: undefined,
departureAirportCode: departureAirportCode || undefined,
arrivalAirportId: arrivalAirportIdRaw ? parseInt(arrivalAirportIdRaw) : undefined,
arrivalAirportCode: arrivalAirportCode || undefined,
departureDatetime,
arrivalDatetime
}
: undefined
});
}
if (segments.length === 0) {
return fail(400, { error: 'At least one flight segment is required' });
}
try {
createFlight({
tripId: trip.id,
userId,
confirmationNumber,
price,
currency,
status: ['idea', 'tentative', 'confirmed'].includes(status) ? status : 'idea',
segments,
passengerIds: passengerIds.length > 0 ? passengerIds : undefined
});
return { success: true };
} catch (err) {
return fail(400, { error: err instanceof Error ? err.message : 'Failed to create flight' });
}
},
editFlight: async (event) => {
const session = await event.locals.auth();
const userId = session?.user?.id;
if (!userId) return fail(401, { error: 'Not authenticated' });
const trip = getTripById(event.params.id, userId);
if (!trip) return fail(404, { error: 'Trip not found' });
const data = await event.request.formData();
const bookingId = (data.get('booking_id') as string)?.trim();
if (!bookingId) return fail(400, { error: 'Booking ID is required' });
const confirmationNumber = (data.get('confirmation_number') as string)?.trim() || undefined;
const priceRaw = (data.get('price') as string)?.trim();
const price = priceRaw ? parseFloat(priceRaw) : undefined;
const currency = (data.get('currency') as string)?.trim() || 'USD';
const status = data.get('status') as string as 'idea' | 'tentative' | 'confirmed';
const passengerIds = data.getAll('passenger_ids[]') as string[];
const segments: Array<{
departureDate: string;
airlineId?: number;
airlineIata?: string;
airlineName?: string;
flightNumber: string;
route?: {
departureAirportId?: number;
departureAirportCode?: string;
arrivalAirportId?: number;
arrivalAirportCode?: string;
departureDatetime?: string;
arrivalDatetime?: string;
};
}> = [];
const segmentIndices = new Set<number>();
for (const key of data.keys()) {
const match = key.match(/^segments\[(\d+)\]/);
if (match) segmentIndices.add(parseInt(match[1]));
}
for (const index of Array.from(segmentIndices).sort((a, b) => a - b)) {
const departureDate = (data.get(`segments[${index}][departure_date]`) as string)?.trim();
const airlineIdRaw = (data.get(`segments[${index}][airline_id]`) as string)?.trim();
const airlineIata = (data.get(`segments[${index}][airline_iata]`) as string)?.trim();
const airlineName = (data.get(`segments[${index}][airline_name]`) as string)?.trim();
const flightNumber = (data.get(`segments[${index}][flight_number]`) as string)?.trim();
const departureAirportIdRaw = (
data.get(`segments[${index}][departure_airport_id]`) as string
)?.trim();
const departureAirportCode = (
data.get(`segments[${index}][departure_airport_code]`) as string
)?.trim();
const arrivalAirportIdRaw = (
data.get(`segments[${index}][arrival_airport_id]`) as string
)?.trim();
const arrivalAirportCode = (
data.get(`segments[${index}][arrival_airport_code]`) as string
)?.trim();
const departureDatetime =
(data.get(`segments[${index}][departure_datetime]`) as string)?.trim() || undefined;
const arrivalDatetime =
(data.get(`segments[${index}][arrival_datetime]`) as string)?.trim() || undefined;
if (!departureDate || !flightNumber) {
return fail(400, { error: 'All segments must have a departure date and flight number' });
}
const hasRoute =
departureAirportIdRaw ||
departureAirportCode ||
arrivalAirportIdRaw ||
arrivalAirportCode ||
departureDatetime ||
arrivalDatetime;
segments.push({
departureDate,
airlineId: airlineIdRaw ? parseInt(airlineIdRaw) : undefined,
airlineIata: airlineIata || undefined,
airlineName: airlineName || undefined,
flightNumber,
route: hasRoute
? {
departureAirportId: departureAirportIdRaw
? parseInt(departureAirportIdRaw)
: undefined,
departureAirportCode: departureAirportCode || undefined,
arrivalAirportId: arrivalAirportIdRaw ? parseInt(arrivalAirportIdRaw) : undefined,
arrivalAirportCode: arrivalAirportCode || undefined,
departureDatetime,
arrivalDatetime
}
: undefined
});
}
if (segments.length === 0) {
return fail(400, { error: 'At least one flight segment is required' });
}
try {
updateFlight({
bookingId,
userId,
confirmationNumber,
price,
currency,
status: ['idea', 'tentative', 'confirmed'].includes(status) ? status : 'idea',
segments,
passengerIds: passengerIds.length > 0 ? passengerIds : undefined
});
return { success: true };
} catch (err) {
return fail(400, { error: err instanceof Error ? err.message : 'Failed to update flight' });
}
},
addLodging: async (event) => {
const session = await event.locals.auth();
const userId = session?.user?.id;
if (!userId) return fail(401, { error: 'Not authenticated' });
const trip = getTripById(event.params.id, userId);
if (!trip) return fail(404, { error: 'Trip not found' });
const data = await event.request.formData();
const name = (data.get('name') as string)?.trim();
if (!name) return fail(400, { error: 'Lodging name is required' });
const str = (key: string) => (data.get(key) as string)?.trim() || undefined;
const num = (key: string) => {
const v = str(key);
return v ? parseFloat(v) : undefined;
};
const status = data.get('status') as string as 'idea' | 'tentative' | 'confirmed';
const guestIds = data.getAll('guest_ids[]') as string[];
try {
createLodging({
tripId: trip.id,
userId,
status: ['idea', 'tentative', 'confirmed'].includes(status) ? status : 'idea',
name,
chain: str('chain'),
checkInDate: str('check_in_date'),
checkInTime: str('check_in_time'),
checkInTimezone: str('check_in_timezone'),
checkOutDate: str('check_out_date'),
checkOutTime: str('check_out_time'),
checkOutTimezone: str('check_out_timezone'),
addressLine1: str('address_line1'),
addressLine2: str('address_line2'),
cityName: str('city_name'),
country: str('country'),
countryCode: str('country_code'),
postalCode: str('postal_code'),
confirmationNumber: str('confirmation_number'),
website: str('website'),
phone: str('phone'),
price: num('price'),
currency: str('currency') ?? 'USD',
guestIds: guestIds.length > 0 ? guestIds : undefined
});
return { success: true };
} catch (err) {
return fail(400, { error: err instanceof Error ? err.message : 'Failed to create lodging' });
}
},
editLodging: async (event) => {
const session = await event.locals.auth();
const userId = session?.user?.id;
if (!userId) return fail(401, { error: 'Not authenticated' });
const trip = getTripById(event.params.id, userId);
if (!trip) return fail(404, { error: 'Trip not found' });
const data = await event.request.formData();
const lodgingId = (data.get('lodging_id') as string)?.trim();
if (!lodgingId) return fail(400, { error: 'Lodging ID is required' });
const name = (data.get('name') as string)?.trim();
if (!name) return fail(400, { error: 'Lodging name is required' });
const str = (key: string) => (data.get(key) as string)?.trim() || undefined;
const num = (key: string) => {
const v = str(key);
return v ? parseFloat(v) : undefined;
};
const status = data.get('status') as string as 'idea' | 'tentative' | 'confirmed';
const guestIds = data.getAll('guest_ids[]') as string[];
try {
updateLodging({
lodgingId,
userId,
status: ['idea', 'tentative', 'confirmed'].includes(status) ? status : 'idea',
name,
chain: str('chain'),
checkInDate: str('check_in_date'),
checkInTime: str('check_in_time'),
checkInTimezone: str('check_in_timezone'),
checkOutDate: str('check_out_date'),
checkOutTime: str('check_out_time'),
checkOutTimezone: str('check_out_timezone'),
addressLine1: str('address_line1'),
addressLine2: str('address_line2'),
cityName: str('city_name'),
country: str('country'),
countryCode: str('country_code'),
postalCode: str('postal_code'),
confirmationNumber: str('confirmation_number'),
website: str('website'),
phone: str('phone'),
price: num('price'),
currency: str('currency') ?? 'USD',
guestIds: guestIds.length > 0 ? guestIds : undefined
});
return { success: true };
} catch (err) {
return fail(400, { error: err instanceof Error ? err.message : 'Failed to update lodging' });
}
}
};

View File

@@ -3,7 +3,13 @@
import TripWelcome from '$lib/components/TripWelcome.svelte';
import AddDestinationModal from '$lib/components/AddDestinationModal.svelte';
import AddTravellerModal from '$lib/components/AddTravellerModal.svelte';
import AddFlightModal from '$lib/components/AddFlightModal.svelte';
import EditFlightModal from '$lib/components/EditFlightModal.svelte';
import AddLodgingModal from '$lib/components/AddLodgingModal.svelte';
import EditLodgingModal from '$lib/components/EditLodgingModal.svelte';
import PlanCard from '$lib/components/PlanCard.svelte';
import FlightCard from '$lib/components/FlightCard.svelte';
import LodgingCard from '$lib/components/LodgingCard.svelte';
import TravellerChip from '$lib/components/TravellerChip.svelte';
let { data, form } = $props();
@@ -14,10 +20,19 @@
let travellerCount = $derived(data.travellerCount ?? 0);
let people = $derived(data.people ?? []);
let tripTravellerIds = $derived(travellers.map((t) => t.id));
let flightBookings = $derived(data.flightBookings ?? []);
let lodgings = $derived(data.lodgings ?? []);
let editing = $state(false);
let showAddDestination = $state(false);
let showAddTraveller = $state(false);
let showAddFlight = $state(false);
let showAddLodging = $state(false);
let showAddMenu = $state(false);
let editingFlight = $state<(typeof flightBookings)[0] | null>(null);
let editingFlightPlan = $derived(
editingFlight ? (plans.find((p) => p.id === editingFlight!.plan_id) ?? null) : null
);
let editingLodging = $state<(typeof lodgings)[0] | null>(null);
const menuItems = [
{
@@ -39,6 +54,7 @@
label: 'Transportation',
icon: `<svg width="16" height="16" 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: () => {
showAddFlight = true;
showAddMenu = false;
}
},
@@ -46,6 +62,7 @@
label: 'Lodgings',
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#A855F7" stroke-width="1.5"><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: () => {
showAddLodging = true;
showAddMenu = false;
}
},
@@ -100,6 +117,33 @@
{people}
{tripTravellerIds}
/>
<AddFlightModal
open={showAddFlight}
onclose={() => (showAddFlight = false)}
{people}
{tripTravellerIds}
/>
<EditFlightModal
open={!!editingFlight}
flightBooking={editingFlight}
planStatus={editingFlightPlan?.status}
onclose={() => (editingFlight = null)}
{people}
{tripTravellerIds}
/>
<AddLodgingModal
open={showAddLodging}
onclose={() => (showAddLodging = false)}
{people}
{tripTravellerIds}
/>
<EditLodgingModal
open={!!editingLodging}
lodging={editingLodging}
onclose={() => (editingLodging = null)}
{people}
{tripTravellerIds}
/>
<div class="mx-auto max-w-2xl">
<!-- Header -->
@@ -389,6 +433,8 @@
tripName={trip.name}
onAddDestination={() => (showAddDestination = true)}
onAddTraveller={() => (showAddTraveller = true)}
onAddFlight={() => (showAddFlight = true)}
onAddLodging={() => (showAddLodging = true)}
/>
{:else}
<!-- Plans list -->
@@ -420,6 +466,86 @@
{/each}
</div>
</div>
<!-- Flights section -->
{#if flightBookings.length > 0}
<div class="mt-8">
<h2 class="mb-3 text-sm font-semibold tracking-wider text-gray-400 uppercase">
Transportation
</h2>
<div class="flex flex-col gap-3">
{#each flightBookings as flightBooking (flightBooking.id)}
{@const plan = plans.find((p) => p.id === flightBooking.plan_id)}
{#if plan}
{@const formId = `remove-plan-${plan.id}`}
{@const submitForm = () => {
const form = document.getElementById(formId) as HTMLFormElement;
form?.requestSubmit();
}}
<form
id={formId}
method="POST"
action="?/removePlan"
use:enhance={() => {
return ({ update }) => {
update();
};
}}
class="contents"
>
<input type="hidden" name="plan_id" value={plan.id} />
<FlightCard
{plan}
{flightBooking}
onEdit={() => (editingFlight = flightBooking)}
onDelete={submitForm}
/>
</form>
{/if}
{/each}
</div>
</div>
{/if}
<!-- Lodgings section -->
{#if lodgings.length > 0}
<div class="mt-8">
<h2 class="mb-3 text-sm font-semibold tracking-wider text-gray-400 uppercase">
Lodgings
</h2>
<div class="flex flex-col gap-3">
{#each lodgings as lodging (lodging.id)}
{@const plan = plans.find((p) => p.id === lodging.plan_id)}
{#if plan}
{@const formId = `remove-plan-${plan.id}`}
{@const submitForm = () => {
const form = document.getElementById(formId) as HTMLFormElement;
form?.requestSubmit();
}}
<form
id={formId}
method="POST"
action="?/removePlan"
use:enhance={() => {
return ({ update }) => {
update();
};
}}
class="contents"
>
<input type="hidden" name="plan_id" value={plan.id} />
<LodgingCard
{plan}
{lodging}
onEdit={() => (editingLodging = lodging)}
onDelete={submitForm}
/>
</form>
{/if}
{/each}
</div>
</div>
{/if}
{/if}
{/if}
</div>