diff --git a/.env.example b/.env.example index a4a5fe3..ebcc086 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,4 @@ ADMIN_USER_IDS= # SQLite (default): file:trips.db # Postgres (future): postgresql://user:password@host:5432/trips DATABASE_URL=file:trips.db +GADVENTURES_API_KEY= diff --git a/src/lib/components/AddPackageTourModal.svelte b/src/lib/components/AddPackageTourModal.svelte index 64244a8..1fa699f 100644 --- a/src/lib/components/AddPackageTourModal.svelte +++ b/src/lib/components/AddPackageTourModal.svelte @@ -57,6 +57,11 @@ { label: 'Honolulu (HST, UTC-10)', value: 'Pacific/Honolulu' } ]; + interface PredefinedTour { + id: number; + name: string; + } + // Operator search let operatorName = $state(''); let operatorQuery = $state(''); @@ -66,6 +71,9 @@ let operatorDebounce: ReturnType; // Logo for the selected operator let operatorLogoUrl = $state(null); + // Predefined tours for the selected operator + let predefinedTours = $state([]); + let selectedOperatorTourId = $state(null); function logoSlug(name: string): string { return name @@ -120,14 +128,27 @@ searchOperators(value); } + async function loadToursForOperator(operatorId: number) { + try { + const res = await fetch(`${base}/api/operator-tours?operator_id=${operatorId}`); + predefinedTours = await res.json(); + } catch { + predefinedTours = []; + } + } + function selectOperator(op: TourOperator) { operatorName = op.name; operatorQuery = op.name; showOperatorDropdown = false; checkOperatorLogo(op.name); + loadToursForOperator(op.id); + selectedOperatorTourId = null; + tourName = ''; } // Core form state + let tourName = $state(''); let status = $state<'idea' | 'tentative' | 'confirmed'>('idea'); let confirmationNumber = $state(''); let startDate = $state(''); @@ -154,6 +175,9 @@ operators = []; showOperatorDropdown = false; operatorLogoUrl = null; + predefinedTours = []; + selectedOperatorTourId = null; + tourName = ''; status = 'idea'; confirmationNumber = ''; startDate = ''; @@ -307,6 +331,45 @@ + +
+ + {#if predefinedTours.length > 0} + + {/if} + 0 ? 'Or type a custom name' : 'e.g. Peru'} + 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" + /> + {#if selectedOperatorTourId} + + {/if} +
+
Status diff --git a/src/lib/components/EditPackageTourModal.svelte b/src/lib/components/EditPackageTourModal.svelte index 82ee3ac..d27320b 100644 --- a/src/lib/components/EditPackageTourModal.svelte +++ b/src/lib/components/EditPackageTourModal.svelte @@ -66,6 +66,11 @@ { label: 'Honolulu (HST, UTC-10)', value: 'Pacific/Honolulu' } ]; + interface PredefinedTour { + id: number; + name: string; + } + // Operator search state let operatorName = $state(''); let operatorQuery = $state(''); @@ -74,6 +79,8 @@ let showOperatorDropdown = $state(false); let operatorDebounce: ReturnType; let operatorLogoUrl = $state(null); + // Predefined tours for the selected operator + let predefinedTours = $state([]); function logoSlug(name: string): string { return name @@ -128,14 +135,40 @@ searchOperators(value); } + async function loadToursForOperator(operatorId: number) { + try { + const res = await fetch(`${base}/api/operator-tours?operator_id=${operatorId}`); + predefinedTours = await res.json(); + } catch { + predefinedTours = []; + } + } + + async function loadToursForOperatorByName(name: string) { + if (!name) { + predefinedTours = []; + return; + } + try { + const res = await fetch( + `${base}/api/operator-tours?operator_name=${encodeURIComponent(name)}` + ); + predefinedTours = await res.json(); + } catch { + predefinedTours = []; + } + } + function selectOperator(op: TourOperator) { operatorName = op.name; operatorQuery = op.name; showOperatorDropdown = false; checkOperatorLogo(op.name); + loadToursForOperator(op.id); } // Core form state + let tourName = $state(''); let status = $state<'idea' | 'tentative' | 'confirmed'>('idea'); let confirmationNumber = $state(''); let startDate = $state(''); @@ -153,6 +186,7 @@ if (open && tour) { operatorName = tour.operator_name; operatorQuery = tour.operator_name; + tourName = tour.tour_name ?? ''; status = tour.planStatus; confirmationNumber = tour.confirmation_number ?? ''; startDate = tour.start_date ?? ''; @@ -164,9 +198,10 @@ price = tour.price != null ? String(tour.price) : ''; currency = tour.currency; selectedTravellers = [...tour.travellerIds]; - // Load operators list and check for logo + // Load operators list, logo, and predefined tours searchOperators(''); checkOperatorLogo(tour.operator_name); + loadToursForOperatorByName(tour.operator_name); } }); @@ -312,6 +347,35 @@
+ +
+ + {#if predefinedTours.length > 0} + + {/if} + 0 ? 'Or type a custom tour name' : 'e.g. Peru'} + 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" + /> +
+
Status diff --git a/src/lib/components/PackageTourCard.svelte b/src/lib/components/PackageTourCard.svelte index 9ce9c98..c052210 100644 --- a/src/lib/components/PackageTourCard.svelte +++ b/src/lib/components/PackageTourCard.svelte @@ -1,23 +1,64 @@ + + + {data.operator.name} — Tours — Admin — Trips + + +
+ + + + +
+
+

{data.operator.name}

+

Predefined tours for this operator.

+
+
+ {#if data.hasProvider} + + {/if} + +
+
+ + +
+ {#if data.tours.length === 0 && !showAddForm} +
+ + + + + + +

No tours yet. Add one or import from a provider.

+
+ {:else} + + + + + + + + + {#each data.tours as tour (tour.id)} + {#if editingId === tour.id} + + + + + {:else} + + + + + + {/if} + {/each} + + + {#if showAddForm} + + + + {/if} + +
Tour nameActions
+
{ + return ({ result, update }) => { + update(); + if (result.type === 'success') cancelEdit(); + }; + }} + class="flex items-center gap-2" + > + + { if (e.key === 'Escape') cancelEdit(); }} + /> + + +
+ {#if editError}

{editError}

{/if} +
+ {tour.name} + +
+ Itinerary → + +
{ + return ({ result, update }) => { + if (result.type === 'success') update(); + }; + }} + > + + +
+
+
+
{ + return ({ result, update }) => { + update(); + if (result.type === 'success') { + showAddForm = false; + addName = ''; + addError = ''; + } + }; + }} + class="flex items-center gap-2" + > + { if (e.key === 'Escape') { showAddForm = false; addName = ''; } }} + /> + + +
+ {#if addError}

{addError}

{/if} +
+ {/if} +
+
+ + +{#if importDrawerOpen} + +
e.key === 'Escape' && closeImportDrawer()} + >
+ + + +{/if} diff --git a/src/routes/(protected)/admin/tour-operators/[operatorId]/tours/[tourId]/+page.server.ts b/src/routes/(protected)/admin/tour-operators/[operatorId]/tours/[tourId]/+page.server.ts new file mode 100644 index 0000000..9de1f48 --- /dev/null +++ b/src/routes/(protected)/admin/tour-operators/[operatorId]/tours/[tourId]/+page.server.ts @@ -0,0 +1,78 @@ +import { error, fail } from '@sveltejs/kit'; +import { requireAdmin } from '$lib/server/admin/auth.js'; +import * as data from '$lib/server/admin/data.js'; +import type { PageServerLoad, Actions } from './$types'; + +export const load: PageServerLoad = async (event) => { + requireAdmin((await event.locals.auth())?.user?.id); + + const operatorId = parseInt(event.params.operatorId); + const tourId = parseInt(event.params.tourId); + + if (isNaN(operatorId) || isNaN(tourId)) error(404, 'Not found'); + + const operators = data.listTourOperators(); + const operator = operators.find((o) => o.id === operatorId); + if (!operator) error(404, 'Operator not found'); + + const tours = data.listToursForOperator(operatorId); + const tour = tours.find((t) => t.id === tourId); + if (!tour) error(404, 'Tour not found'); + + const days = data.listDaysForOperatorTour(tourId); + + return { operator, tour, days }; +}; + +export const actions: Actions = { + addDay: async (event) => { + requireAdmin((await event.locals.auth())?.user?.id); + + const tourId = parseInt(event.params.tourId); + if (isNaN(tourId)) return fail(400, { error: 'Invalid tour ID' }); + + const formData = await event.request.formData(); + const title = (formData.get('title') as string)?.trim() || undefined; + const notes = (formData.get('notes') as string)?.trim() || undefined; + + try { + data.createOperatorTourDay(tourId, title, notes); + return { success: true }; + } catch (err) { + return fail(400, { error: err instanceof Error ? err.message : 'Failed to add day' }); + } + }, + + editDay: async (event) => { + requireAdmin((await event.locals.auth())?.user?.id); + + const formData = await event.request.formData(); + const id = parseInt(formData.get('id') as string); + if (isNaN(id)) return fail(400, { error: 'Invalid day ID' }); + + const title = (formData.get('title') as string)?.trim() || undefined; + const notes = (formData.get('notes') as string)?.trim() || undefined; + + try { + data.updateOperatorTourDay(id, title, notes); + return { success: true }; + } catch (err) { + return fail(400, { error: err instanceof Error ? err.message : 'Failed to update day' }); + } + }, + + deleteDay: async (event) => { + requireAdmin((await event.locals.auth())?.user?.id); + + const formData = await event.request.formData(); + const id = parseInt(formData.get('id') as string); + if (isNaN(id)) return fail(400, { error: 'Invalid day ID' }); + + try { + data.deleteOperatorTourDay(id); + return { success: true }; + } catch (err) { + return fail(400, { error: err instanceof Error ? err.message : 'Failed to delete day' }); + } + } +}; diff --git a/src/routes/(protected)/admin/tour-operators/[operatorId]/tours/[tourId]/+page.svelte b/src/routes/(protected)/admin/tour-operators/[operatorId]/tours/[tourId]/+page.svelte new file mode 100644 index 0000000..79d10f2 --- /dev/null +++ b/src/routes/(protected)/admin/tour-operators/[operatorId]/tours/[tourId]/+page.svelte @@ -0,0 +1,275 @@ + + + + {data.tour.name} Itinerary — Admin — Trips + + +
+ + + +
+

{data.tour.name}

+

+ Template itinerary for {data.operator.name}. Days are + automatically copied to a user's trip when they select this tour. +

+
+ + +
+ {#each data.days as day (day.id)} +
+ {#if editingDayId === day.id} + +
{ + return ({ result, update }) => { + update(); + if (result.type === 'success') cancelEdit(); + }; + }} + class="flex flex-col gap-3" + > + +
+ + +
+
+ + +
+
+ + +
+
+ {:else} + +
+
+ {day.day_number} +
+
+

+ {#if day.title}{day.title}{:else}Untitled day{/if} +

+ {#if day.notes} +

{day.notes}

+ {/if} +
+
+ +
{ + return ({ result, update }) => { + if (result.type === 'success') update(); + }; + }} + > + + +
+
+
+ {/if} +
+ {/each} + + + {#if data.days.length === 0 && !showAddForm} +
+

No days yet. Add the first day to build the itinerary.

+
+ {/if} + + + {#if showAddForm} +
{ + return ({ result, update }) => { + update(); + if (result.type === 'success') { + showAddForm = false; + addTitle = ''; + addNotes = ''; + } + }; + }} + class="rounded-lg border border-blue-200 bg-blue-50/50 p-4" + > +

+ Day {data.days.length + 1} +

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ {:else} + + {/if} +
+
diff --git a/src/routes/(protected)/api/operator-tours/+server.ts b/src/routes/(protected)/api/operator-tours/+server.ts new file mode 100644 index 0000000..e2070b8 --- /dev/null +++ b/src/routes/(protected)/api/operator-tours/+server.ts @@ -0,0 +1,20 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { listToursForOperator, listToursForOperatorByName } from '$lib/server/admin/data.js'; + +export const GET: RequestHandler = ({ url }) => { + const operatorIdParam = url.searchParams.get('operator_id'); + const operatorName = url.searchParams.get('operator_name'); + + if (operatorIdParam != null) { + const operatorId = parseInt(operatorIdParam); + if (isNaN(operatorId)) return json({ error: 'operator_id must be a number' }, { status: 400 }); + return json(listToursForOperator(operatorId)); + } + + if (operatorName) { + return json(listToursForOperatorByName(operatorName)); + } + + return json({ error: 'operator_id or operator_name required' }, { status: 400 }); +}; diff --git a/src/routes/(protected)/trips/[id]/+page.server.ts b/src/routes/(protected)/trips/[id]/+page.server.ts index 8d0d93b..e00b933 100644 --- a/src/routes/(protected)/trips/[id]/+page.server.ts +++ b/src/routes/(protected)/trips/[id]/+page.server.ts @@ -12,7 +12,10 @@ import { createLodging, updateLodging, getLodgingsForTrip } from '$lib/server/lo import { createPackageTour, updatePackageTour, - getPackageToursForTrip + getPackageToursForTrip, + createTourDay, + updateTourDay, + cloneTemplateDaysToTour } from '$lib/server/package-tours.js'; import type { PageServerLoad, Actions } from './$types'; @@ -552,11 +555,12 @@ export const actions: Actions = { const travellerIds = data.getAll('traveller_ids[]') as string[]; try { - createPackageTour({ + const newTour = createPackageTour({ tripId: trip.id, userId, status: ['idea', 'tentative', 'confirmed'].includes(status) ? status : 'idea', operatorName, + tourName: str('tour_name'), confirmationNumber: str('confirmation_number'), startDate: str('start_date'), startTime: str('start_time'), @@ -568,6 +572,18 @@ export const actions: Actions = { currency: str('currency') ?? 'USD', travellerIds: travellerIds.length > 0 ? travellerIds : undefined }); + + const operatorTourIdRaw = data.get('operator_tour_id') as string; + const operatorTourId = operatorTourIdRaw ? parseInt(operatorTourIdRaw) : NaN; + if (!isNaN(operatorTourId)) { + cloneTemplateDaysToTour({ + operatorTourId, + tripId: trip.id, + userId, + tourPlanId: newTour.plan_id + }); + } + return { success: true }; } catch (err) { return fail(400, { error: err instanceof Error ? err.message : 'Failed to create tour' }); @@ -603,6 +619,7 @@ export const actions: Actions = { userId, status: ['idea', 'tentative', 'confirmed'].includes(status) ? status : 'idea', operatorName, + tourName: str('tour_name'), confirmationNumber: str('confirmation_number'), startDate: str('start_date'), startTime: str('start_time'), @@ -618,5 +635,48 @@ export const actions: Actions = { } catch (err) { return fail(400, { error: err instanceof Error ? err.message : 'Failed to update tour' }); } + }, + + addTourDay: 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 tourPlanId = (data.get('tour_plan_id') as string)?.trim(); + if (!tourPlanId) return fail(400, { error: 'Tour plan ID is required' }); + + const title = (data.get('title') as string)?.trim() || undefined; + const notes = (data.get('notes') as string)?.trim() || undefined; + + try { + createTourDay({ tripId: trip.id, userId, tourPlanId, title, notes }); + return { success: true }; + } catch (err) { + return fail(400, { error: err instanceof Error ? err.message : 'Failed to add day' }); + } + }, + + editTourDay: async (event) => { + const session = await event.locals.auth(); + const userId = session?.user?.id; + if (!userId) return fail(401, { error: 'Not authenticated' }); + + const data = await event.request.formData(); + const dayPlanId = (data.get('day_plan_id') as string)?.trim(); + if (!dayPlanId) return fail(400, { error: 'Day plan ID is required' }); + + const title = (data.get('title') as string)?.trim() || undefined; + const notes = (data.get('notes') as string)?.trim() || undefined; + + try { + updateTourDay({ dayPlanId, userId, title, notes }); + return { success: true }; + } catch (err) { + return fail(400, { error: err instanceof Error ? err.message : 'Failed to update day' }); + } } }; diff --git a/src/routes/(protected)/trips/[id]/+page.svelte b/src/routes/(protected)/trips/[id]/+page.svelte index 2962129..5e5da10 100644 --- a/src/routes/(protected)/trips/[id]/+page.svelte +++ b/src/routes/(protected)/trips/[id]/+page.svelte @@ -616,6 +616,14 @@ addingChildToPlanId = plan.id; showAddLodging = true; }} + onAddFlightToDay={(dayPlanId) => { + addingChildToPlanId = dayPlanId; + showAddFlight = true; + }} + onAddLodgingToDay={(dayPlanId) => { + addingChildToPlanId = dayPlanId; + showAddLodging = true; + }} /> {/if}