trips) adding transportation, checklist, and experience planning

This commit is contained in:
2026-02-20 19:47:51 -05:00
parent cf2fd658f1
commit a348fb034d
34 changed files with 6499 additions and 2040 deletions

View File

@@ -84,13 +84,52 @@ export const actions: Actions = {
const dayId = parseInt(formData.get('dayId') as string);
const type = (formData.get('type') as string)?.trim();
const get = (k: string) => (formData.get(k) as string)?.trim() || undefined;
const title = get('name') || get('title');
const transportationType = (get('transportation_type') || get('transport_kind') || 'other') as
| 'other'
| 'flight'
| 'private_vehicle';
if (isNaN(dayId)) return fail(400, { error: 'Invalid day ID' });
if (type !== 'transport' && type !== 'lodging') return fail(400, { error: 'Invalid plan type' });
if (!title) return fail(400, { error: 'Name is required' });
if (
type !== 'transport' &&
type !== 'lodging' &&
type !== 'activity' &&
type !== 'restaurant' &&
type !== 'packing' &&
type !== 'todo'
)
return fail(400, { error: 'Invalid plan type' });
let title = get('name') || get('title');
const notes = get('notes') ?? undefined;
let transportFields:
| {
transport_kind: string;
start_date: string | null;
start_time: string | null;
start_timezone: string | null;
end_date: string | null;
end_time: string | null;
end_timezone: string | null;
}
| undefined;
let experienceFields:
| {
booking_id: string | null;
total_cost: number | null;
description: string | null;
website: string | null;
address: string | null;
contact_number: string | null;
start_date: string | null;
start_time: string | null;
start_timezone: string | null;
end_date: string | null;
end_time: string | null;
end_timezone: string | null;
}
| undefined;
let checklistItemsJson: string | null | undefined;
const lodgingFields =
type === 'lodging'
? {
@@ -103,14 +142,103 @@ export const actions: Actions = {
postal_code: get('postal_code') ?? null
}
: undefined;
if (type === 'transport') {
if (transportationType === 'flight') {
const segmentIndices = new Set<number>();
for (const key of formData.keys()) {
const match = key.match(/^segments\[(\d+)\]/);
if (match) segmentIndices.add(parseInt(match[1]));
}
const sorted = Array.from(segmentIndices).sort((a, b) => a - b);
const first = sorted[0];
const last = sorted[sorted.length - 1];
const firstAirline =
(first != null && get(`segments[${first}][airline_name]`)) ||
(first != null && get(`segments[${first}][airline_iata]`)) ||
'Flight';
const firstNumber = (first != null && get(`segments[${first}][flight_number]`)) || '';
if (!title) title = `${firstAirline} ${firstNumber}`.trim();
const depDate =
(first != null && get(`segments[${first}][departure_date]`)) ||
(first != null && get(`segments[${first}][departure_datetime]`)?.split('T')[0]) ||
null;
const depTime =
(first != null && get(`segments[${first}][departure_datetime]`)?.split('T')[1]) || null;
const arrDate =
(last != null && get(`segments[${last}][arrival_datetime]`)?.split('T')[0]) || null;
const arrTime =
(last != null && get(`segments[${last}][arrival_datetime]`)?.split('T')[1]) || null;
transportFields = {
transport_kind: 'flight',
start_date: depDate,
start_time: depTime,
start_timezone: null,
end_date: arrDate,
end_time: arrTime,
end_timezone: null
};
} else if (transportationType === 'private_vehicle') {
const startAddress = get('start_address');
const endAddress = get('end_address');
if (!title) title = `Private vehicle: ${startAddress ?? 'Start'} to ${endAddress ?? 'End'}`;
transportFields = {
transport_kind: 'private_vehicle',
start_date: get('departure_date') ?? null,
start_time: get('departure_time') ?? null,
start_timezone: get('departure_timezone') ?? null,
end_date: get('arrival_date') ?? null,
end_time: get('arrival_time') ?? null,
end_timezone: get('arrival_timezone') ?? null
};
} else {
transportFields = {
transport_kind: 'other',
start_date: get('start_date') ?? null,
start_time: get('start_time') ?? null,
start_timezone: get('start_timezone') ?? null,
end_date: get('end_date') ?? null,
end_time: get('end_time') ?? null,
end_timezone: get('end_timezone') ?? null
};
}
}
if (type === 'activity' || type === 'restaurant') {
const totalCostRaw = get('total_cost');
const totalCost = totalCostRaw ? parseFloat(totalCostRaw) : null;
experienceFields = {
booking_id: get('booking_id') ?? null,
total_cost: totalCost != null && Number.isFinite(totalCost) ? totalCost : null,
description: get('description') ?? null,
website: get('website') ?? null,
address: get('address') ?? null,
contact_number: get('contact_number') ?? null,
start_date: get('start_date') ?? null,
start_time: get('start_time') ?? null,
start_timezone: get('start_timezone') ?? null,
end_date: get('end_date') ?? null,
end_time: get('end_time') ?? null,
end_timezone: get('end_timezone') ?? null
};
}
if (type === 'packing' || type === 'todo') {
const items = formData
.getAll('items[]')
.map((item) => String(item).trim())
.filter(Boolean);
checklistItemsJson = JSON.stringify(items);
}
if (!title) return fail(400, { error: 'Name is required' });
try {
data.createOperatorTourDayPlan(
dayId,
type as 'transport' | 'lodging',
type as 'transport' | 'lodging' | 'activity' | 'restaurant' | 'packing' | 'todo',
title,
notes,
lodgingFields
lodgingFields,
transportFields,
experienceFields,
checklistItemsJson
);
return { success: true };
} catch (err) {
@@ -125,10 +253,25 @@ export const actions: Actions = {
const id = parseInt(formData.get('id') as string);
if (isNaN(id)) return fail(400, { error: 'Invalid plan ID' });
const title = (formData.get('name') as string)?.trim() || (formData.get('title') as string)?.trim();
const title =
(formData.get('name') as string)?.trim() || (formData.get('title') as string)?.trim();
if (!title) return fail(400, { error: 'Name is required' });
const get = (k: string) => (formData.get(k) as string)?.trim() || undefined;
const transportationType = (get('transportation_type') || get('transport_kind')) as
| 'other'
| 'flight'
| 'private_vehicle'
| undefined;
let checklistItemsJson: string | undefined;
const type = (formData.get('type') as string)?.trim();
if (type === 'packing' || type === 'todo') {
const items = formData
.getAll('items[]')
.map((item) => String(item).trim())
.filter(Boolean);
checklistItemsJson = JSON.stringify(items);
}
try {
data.updateOperatorTourDayPlan(id, {
title,
@@ -139,7 +282,26 @@ export const actions: Actions = {
city_name: get('city_name') ?? null,
country: get('country') ?? null,
country_code: get('country_code') ?? null,
postal_code: get('postal_code') ?? null
postal_code: get('postal_code') ?? null,
transport_kind: transportationType ?? get('transport_kind') ?? undefined,
start_date: get('start_date') ?? undefined,
start_time: get('start_time') ?? undefined,
start_timezone: get('start_timezone') ?? undefined,
end_date: get('end_date') ?? undefined,
end_time: get('end_time') ?? undefined,
end_timezone: get('end_timezone') ?? undefined,
booking_id: get('booking_id') ?? undefined,
total_cost: (() => {
const raw = get('total_cost');
if (!raw) return undefined;
const parsed = parseFloat(raw);
return Number.isFinite(parsed) ? parsed : undefined;
})(),
description: get('description') ?? undefined,
website: get('website') ?? undefined,
address: get('address') ?? undefined,
contact_number: get('contact_number') ?? undefined,
items_json: checklistItemsJson
});
return { success: true };
} catch (err) {

View File

@@ -1,7 +1,16 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { base } from '$app/paths';
import AddPlanMenu, { type AddPlanMenuItem } from '$lib/components/AddPlanMenu.svelte';
import { PLAN_TYPE_MAP } from '$lib/components/plan-types.js';
import LodgingCard from '$lib/components/LodgingCard.svelte';
import OtherTransportCard from '$lib/components/OtherTransportCard.svelte';
import ExperienceCard from '$lib/components/ExperienceCard.svelte';
import ExperienceModal from '$lib/components/ExperienceModal.svelte';
import ChecklistCard from '$lib/components/ChecklistCard.svelte';
import ChecklistModal from '$lib/components/ChecklistModal.svelte';
import AddTransportationModal from '$lib/components/AddTransportationModal.svelte';
import EditTransportationModal from '$lib/components/EditTransportationModal.svelte';
import EditLodgingModal from '$lib/components/EditLodgingModal.svelte';
import AddLodgingModal from '$lib/components/AddLodgingModal.svelte';
import type { PageData } from './$types';
@@ -16,7 +25,7 @@
let addTitle = $state('');
let addNotes = $state('');
// Day plans (transport/lodging) per day
// Day plans per day
let plansByDay = $derived.by(() => {
const map: Record<number, (typeof data.dayPlans)[number][]> = {};
for (const d of data.days) map[d.id] = [];
@@ -26,23 +35,20 @@
return map;
});
// Add day plan: transport inline form; lodging uses AddLodgingModal
let addPlanFor = $state<{ dayId: number; type: 'transport' | 'lodging' } | null>(null);
let addPlanTitle = $state('');
let addPlanNotes = $state('');
// Edit: transport inline; lodging uses EditLodgingModal
let editingPlanId = $state<number | null>(null);
let editPlanTitle = $state('');
let editPlanNotes = $state('');
let addingTransportDayId = $state<number | null>(null);
let editingTransportPlan = $state<(typeof data.dayPlans)[number] | null>(null);
let editingLodgingPlan = $state<(typeof data.dayPlans)[number] | null>(null);
let addingExperienceDay = $state<{ dayId: number; type: 'activity' | 'restaurant' } | null>(null);
let editingExperiencePlan = $state<(typeof data.dayPlans)[number] | null>(null);
let addingChecklistDay = $state<{ dayId: number; type: 'packing' | 'todo' } | null>(null);
let editingChecklistPlan = $state<(typeof data.dayPlans)[number] | null>(null);
let addLodgingDayId = $state<number | null>(null);
// Delete day plan form (for LodgingCard onDelete)
let deletePlanFormRef = $state<HTMLFormElement | null>(null);
let planToDeleteId = $state<number | null>(null);
function submitDeletePlan(planId: number) {
if (!confirm('Remove this lodging from the day?')) return;
if (!confirm('Remove this plan from the day?')) return;
planToDeleteId = planId;
setTimeout(() => deletePlanFormRef?.requestSubmit(), 0);
}
@@ -59,52 +65,99 @@
editNotes = '';
}
function openAddPlan(dayId: number, type: 'transport' | 'lodging') {
addPlanFor = { dayId, type };
addPlanTitle = '';
addPlanNotes = '';
function openAddPlan(
dayId: number,
type: 'transport' | 'lodging' | 'activity' | 'restaurant' | 'packing' | 'todo'
) {
if (type === 'transport') {
addingTransportDayId = dayId;
} else if (type === 'lodging') {
openAddLodging(dayId);
} else if (type === 'activity' || type === 'restaurant') {
addingExperienceDay = { dayId, type };
} else {
addingChecklistDay = { dayId, type };
}
}
function cancelAddPlan() {
addPlanFor = null;
addPlanTitle = '';
addPlanNotes = '';
addingTransportDayId = null;
addingExperienceDay = null;
addingChecklistDay = null;
}
function startEditPlan(plan: { id: number; title: string; notes: string | null; type: string }) {
if (plan.type === 'lodging') {
editingLodgingPlan = plan as (typeof data.dayPlans)[number];
} else if (plan.type === 'transport') {
editingTransportPlan = plan as (typeof data.dayPlans)[number];
} else if (plan.type === 'activity' || plan.type === 'restaurant') {
editingExperiencePlan = plan as (typeof data.dayPlans)[number];
} else {
editingPlanId = plan.id;
editPlanTitle = plan.title;
editPlanNotes = plan.notes ?? '';
editingChecklistPlan = plan as (typeof data.dayPlans)[number];
}
}
function cancelEditPlan() {
editingPlanId = null;
editPlanTitle = '';
editPlanNotes = '';
editingLodgingPlan = null;
}
function openAddLodging(dayId: number) {
addLodgingDayId = dayId;
addPlanFor = null;
addPlanTitle = '';
addPlanNotes = '';
addingTransportDayId = null;
addingExperienceDay = null;
addingChecklistDay = null;
}
function closeAddLodging() {
addLodgingDayId = null;
}
function dayAddPlanItems(dayId: number): AddPlanMenuItem[] {
return [
{
id: `${dayId}-transport`,
label: 'Transportation',
icon: PLAN_TYPE_MAP.transport.icon,
onclick: () => openAddPlan(dayId, 'transport')
},
{
id: `${dayId}-lodging`,
label: 'Lodging',
icon: PLAN_TYPE_MAP.lodging.icon,
onclick: () => openAddLodging(dayId)
},
{
id: `${dayId}-activity`,
label: 'Attractions & Activities',
icon: PLAN_TYPE_MAP.activity.icon,
onclick: () => openAddPlan(dayId, 'activity')
},
{
id: `${dayId}-restaurant`,
label: 'Restaurant',
icon: PLAN_TYPE_MAP.restaurant.icon,
onclick: () => openAddPlan(dayId, 'restaurant')
},
{
id: `${dayId}-packing`,
label: 'Packing List',
icon: PLAN_TYPE_MAP.packingList.icon,
onclick: () => openAddPlan(dayId, 'packing')
},
{
id: `${dayId}-todo`,
label: 'To-do',
icon: PLAN_TYPE_MAP.todo.icon,
onclick: () => openAddPlan(dayId, 'todo')
}
];
}
</script>
<form
method="POST"
action="?/deleteDayPlan"
bind:this={deletePlanFormRef}
use:enhance={() => ({ update }) => update()}
use:enhance={() =>
({ update }) =>
update()}
class="hidden"
aria-hidden="true"
>
@@ -119,6 +172,108 @@
formAction="?/editDayPlan"
/>
<EditTransportationModal
open={editingTransportPlan != null}
onclose={() => (editingTransportPlan = null)}
variant="template"
formAction="?/editDayPlan"
idFieldName="id"
idValue={editingTransportPlan?.id}
transportation={editingTransportPlan
? {
type: 'other',
planStatus: 'idea',
planTitle: editingTransportPlan.title,
planNotes: editingTransportPlan.notes,
transportKind: editingTransportPlan.transport_kind ?? 'other',
otherTransport: {
id: editingTransportPlan.id,
plan_id: editingTransportPlan.id,
start_date: editingTransportPlan.start_date ?? null,
start_time: editingTransportPlan.start_time ?? null,
start_timezone: editingTransportPlan.start_timezone ?? null,
end_date: editingTransportPlan.end_date ?? null,
end_time: editingTransportPlan.end_time ?? null,
end_timezone: editingTransportPlan.end_timezone ?? null,
created_at: '',
updated_at: ''
}
}
: null}
/>
<ExperienceModal
open={addingExperienceDay != null}
onclose={() => (addingExperienceDay = null)}
variant="template"
planType={addingExperienceDay?.type ?? 'activity'}
mode="add"
formAction="?/addDayPlan"
dayId={addingExperienceDay?.dayId}
/>
<ExperienceModal
open={editingExperiencePlan != null}
onclose={() => (editingExperiencePlan = null)}
variant="template"
planType={(editingExperiencePlan?.type as 'activity' | 'restaurant') ?? 'activity'}
mode="edit"
formAction="?/editDayPlan"
idFieldName="id"
idValue={editingExperiencePlan?.id}
initial={editingExperiencePlan
? {
name: editingExperiencePlan.title,
description: editingExperiencePlan.description ?? editingExperiencePlan.notes,
booking_id: editingExperiencePlan.booking_id,
total_cost: editingExperiencePlan.total_cost,
website: editingExperiencePlan.website,
address: editingExperiencePlan.address,
contact_number: editingExperiencePlan.contact_number,
start_date: editingExperiencePlan.start_date,
start_time: editingExperiencePlan.start_time,
start_timezone: editingExperiencePlan.start_timezone,
end_date: editingExperiencePlan.end_date,
end_time: editingExperiencePlan.end_time,
end_timezone: editingExperiencePlan.end_timezone
}
: null}
/>
<ChecklistModal
open={addingChecklistDay != null}
onclose={() => (addingChecklistDay = null)}
variant="template"
checklistType={addingChecklistDay?.type ?? 'packing'}
mode="add"
formAction="?/addDayPlan"
dayId={addingChecklistDay?.dayId}
/>
<ChecklistModal
open={editingChecklistPlan != null}
onclose={() => (editingChecklistPlan = null)}
variant="template"
checklistType={(editingChecklistPlan?.type as 'packing' | 'todo') ?? 'packing'}
mode="edit"
formAction="?/editDayPlan"
idFieldName="id"
idValue={editingChecklistPlan?.id}
initial={editingChecklistPlan
? {
name: editingChecklistPlan.title,
items: (() => {
try {
const parsed = JSON.parse(editingChecklistPlan.items_json ?? '[]');
return Array.isArray(parsed)
? parsed.map((item) => ({
content: typeof item === 'string' ? item : String(item?.content ?? '')
}))
: [];
} catch {
return [];
}
})()
}
: null}
/>
<AddLodgingModal
open={addLodgingDayId != null}
onclose={closeAddLodging}
@@ -126,6 +281,13 @@
dayId={addLodgingDayId ?? 0}
formAction="?/addDayPlan"
/>
<AddTransportationModal
open={addingTransportDayId != null}
onclose={cancelAddPlan}
variant="template"
dayId={addingTransportDayId ?? undefined}
formAction="?/addDayPlan"
/>
<svelte:head>
<title>{data.tour.name} Itinerary — Admin — Trips</title>
@@ -136,8 +298,9 @@
<nav class="mb-6 flex items-center gap-2 text-sm text-gray-500">
<a href="{base}/admin/tour-operators" class="hover:text-gray-700">Tour Operators</a>
<span></span>
<a href="{base}/admin/tour-operators/{data.operator.id}" class="text-gray-700 hover:text-gray-900"
>{data.operator.name}</a
<a
href="{base}/admin/tour-operators/{data.operator.id}"
class="text-gray-700 hover:text-gray-900">{data.operator.name}</a
>
<span></span>
<span class="font-medium text-gray-900">{data.tour.name} — Itinerary</span>
@@ -282,14 +445,11 @@
</div>
{/if}
<!-- Attached plans (transport / lodging) for this day -->
<!-- Attached plans for this day -->
<div class="mt-3 border-t border-gray-100 pt-3">
<p class="mb-2 text-xs font-medium uppercase tracking-wide text-gray-400">
Transport &amp; lodging
</p>
<p class="mb-2 text-xs font-medium tracking-wide text-gray-400 uppercase">Day Plans</p>
{#each dayPlanList as plan (plan.id)}
{#if plan.type === 'lodging'}
<!-- Lodging: same card + modal as trip page -->
<div class="mb-2">
<LodgingCard
templatePlan={plan}
@@ -297,167 +457,88 @@
onDelete={() => submitDeletePlan(plan.id)}
/>
</div>
{:else}
<!-- Transport: inline edit -->
{#if editingPlanId === plan.id}
<form
method="POST"
action="?/editDayPlan"
use:enhance={() => {
return ({ result, update }) => {
update();
if (result.type === 'success') cancelEditPlan();
};
{:else if plan.type === 'transport'}
<div class="mb-2">
<OtherTransportCard
variant="template"
plan={{
...plan,
status: 'idea'
}}
class="mb-2 rounded-md border border-gray-200 bg-gray-50/50 p-3"
>
<input type="hidden" name="id" value={plan.id} />
<div class="flex flex-col gap-2">
<input
name="name"
type="text"
bind:value={editPlanTitle}
placeholder="Title"
class="rounded border border-gray-300 px-2 py-1.5 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
<textarea
name="notes"
bind:value={editPlanNotes}
placeholder="Notes (optional)"
rows="2"
class="resize-none rounded border border-gray-300 px-2 py-1.5 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
></textarea>
<div class="flex gap-2">
<button
type="submit"
class="rounded bg-blue-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-700"
>
Save
</button>
<button
type="button"
onclick={cancelEditPlan}
class="rounded border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50"
>
Cancel
</button>
</div>
</div>
</form>
{:else}
<div
class="mb-2 flex items-start justify-between gap-2 rounded-md border border-gray-100 bg-gray-50/50 px-3 py-2"
>
<div class="min-w-0 flex-1">
<span class="mr-2 inline-block rounded px-1.5 py-0.5 text-xs font-medium bg-sky-100 text-sky-800">
Transport
</span>
<span class="font-medium text-gray-900">{plan.title}</span>
{#if plan.notes}
<p class="mt-0.5 text-sm text-gray-500">{plan.notes}</p>
{/if}
</div>
<div class="flex shrink-0 gap-0.5">
<button
type="button"
onclick={() => startEditPlan(plan)}
class="rounded p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-600"
title="Edit"
>
<svg width="14" height="14" 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>
<form method="POST" action="?/deleteDayPlan" use:enhance={() => ({ update }) => update()}>
<input type="hidden" name="id" value={plan.id} />
<button
type="submit"
onclick={(e) => {
if (!confirm('Remove this plan from the day?')) e.preventDefault();
}}
class="rounded p-1 text-gray-400 hover:bg-gray-200 hover:text-red-600"
title="Remove"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</svg>
</button>
</form>
</div>
</div>
{/if}
otherTransport={{
id: plan.id,
plan_id: String(plan.id),
start_date: plan.start_date ?? null,
start_time: plan.start_time ?? null,
start_timezone: plan.start_timezone ?? null,
end_date: plan.end_date ?? null,
end_time: plan.end_time ?? null,
end_timezone: plan.end_timezone ?? null,
created_at: '',
updated_at: ''
}}
onEdit={() => startEditPlan(plan)}
onDelete={() => submitDeletePlan(plan.id)}
/>
</div>
{:else if plan.type === 'activity' || plan.type === 'restaurant'}
<div class="mb-2">
<ExperienceCard
variant="template"
plan={{ title: plan.title, status: 'idea' }}
experience={{
booking_id: plan.booking_id ?? null,
total_cost: plan.total_cost ?? null,
description: plan.description ?? plan.notes,
website: plan.website ?? null,
address: plan.address ?? null,
contact_number: plan.contact_number ?? null,
start_date: plan.start_date ?? null,
start_time: plan.start_time ?? null,
start_timezone: plan.start_timezone ?? null,
end_date: plan.end_date ?? null,
end_time: plan.end_time ?? null,
end_timezone: plan.end_timezone ?? null
}}
onEdit={() => startEditPlan(plan)}
onDelete={() => submitDeletePlan(plan.id)}
/>
</div>
{:else if plan.type === 'packing' || plan.type === 'todo'}
<div class="mb-2">
<ChecklistCard
variant="template"
plan={{ title: plan.title, status: 'idea' }}
items={(() => {
try {
const parsed = JSON.parse(plan.items_json ?? '[]');
if (!Array.isArray(parsed)) return [];
return parsed.map((item, index) => ({
id: `${plan.id}-${index}`,
content: typeof item === 'string' ? item : String(item?.content ?? ''),
is_checked:
typeof item === 'object' && item && 'is_checked' in item
? Number(item.is_checked)
: 0
}));
} catch {
return [];
}
})()}
onEdit={() => startEditPlan(plan)}
onDelete={() => submitDeletePlan(plan.id)}
/>
</div>
{/if}
{/each}
{#if addPlanFor?.dayId === day.id && addPlanFor?.type === 'transport'}
<form
method="POST"
action="?/addDayPlan"
use:enhance={() => {
return ({ result, update }) => {
update();
if (result.type === 'success') cancelAddPlan();
};
}}
class="mb-2 rounded-md border border-dashed border-gray-200 bg-gray-50/30 p-3"
>
<input type="hidden" name="dayId" value={day.id} />
<input type="hidden" name="type" value="transport" />
<p class="mb-2 text-xs font-medium text-gray-500">Add transport</p>
<div class="flex flex-col gap-2">
<input
name="title"
type="text"
bind:value={addPlanTitle}
placeholder="e.g. Flight to Rome"
required
class="rounded border border-gray-300 px-2 py-1.5 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
<textarea
name="notes"
bind:value={addPlanNotes}
placeholder="Notes (optional)"
rows="2"
class="resize-none rounded border border-gray-300 px-2 py-1.5 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
></textarea>
<div class="flex gap-2">
<button
type="submit"
class="rounded bg-blue-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-700"
>
Add
</button>
<button
type="button"
onclick={cancelAddPlan}
class="rounded border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50"
>
Cancel
</button>
</div>
</div>
</form>
{:else}
<div class="flex gap-2">
<button
type="button"
onclick={() => openAddPlan(day.id, 'transport')}
class="rounded border border-dashed border-gray-200 px-2 py-1.5 text-xs text-gray-500 hover:border-sky-300 hover:bg-sky-50/50 hover:text-sky-700"
>
+ Transport
</button>
<button
type="button"
onclick={() => openAddLodging(day.id)}
class="rounded border border-dashed border-gray-200 px-2 py-1.5 text-xs text-gray-500 hover:border-amber-300 hover:bg-amber-50/50 hover:text-amber-700"
>
+ Lodging
</button>
</div>
{#if addingTransportDayId !== day.id && addLodgingDayId !== day.id && addingExperienceDay?.dayId !== day.id && addingChecklistDay?.dayId !== day.id}
<AddPlanMenu
buttonLabel="Add"
items={dayAddPlanItems(day.id)}
menuWidthClass="w-40"
showChevron={false}
/>
{/if}
</div>
</div>

View File

@@ -8,6 +8,16 @@ import {
removeTravellerFromTrip
} from '$lib/server/travellers.js';
import { createFlight, updateFlight, getFlightBookingsForTrip } from '$lib/server/flights.js';
import {
createPrivateVehicle,
updatePrivateVehicle,
getPrivateVehiclesForTrip
} from '$lib/server/private-vehicles.js';
import {
createOtherTransport,
updateOtherTransport,
getOtherTransportsForTrip
} from '$lib/server/other-transports.js';
import { createLodging, updateLodging, getLodgingsForTrip } from '$lib/server/lodgings.js';
import {
createPackageTour,
@@ -17,6 +27,17 @@ import {
updateTourDay,
cloneTemplateDaysToTour
} from '$lib/server/package-tours.js';
import {
createExperience,
updateExperience,
getExperiencesForTrip
} from '$lib/server/experiences.js';
import {
createChecklist,
updateChecklist,
getChecklistsForTrip,
toggleChecklistItem
} from '$lib/server/checklists.js';
import type { PageServerLoad, Actions } from './$types';
export const load: PageServerLoad = async (event) => {
@@ -31,8 +52,14 @@ export const load: PageServerLoad = async (event) => {
const travellers = getTravellersForTrip(trip.id, userId);
const people = getPeopleForUser(userId);
const flightBookings = getFlightBookingsForTrip(trip.id, userId);
const privateVehicles = getPrivateVehiclesForTrip(trip.id, userId);
const otherTransports = getOtherTransportsForTrip(trip.id, userId);
const lodgings = getLodgingsForTrip(trip.id, userId);
const packageTours = getPackageToursForTrip(trip.id, userId);
const activities = getExperiencesForTrip(trip.id, userId, 'activity');
const restaurants = getExperiencesForTrip(trip.id, userId, 'restaurant');
const packingLists = getChecklistsForTrip(trip.id, userId, 'packing');
const todos = getChecklistsForTrip(trip.id, userId, 'todo');
return {
trip,
@@ -42,8 +69,14 @@ export const load: PageServerLoad = async (event) => {
travellerCount: travellers.length,
people,
flightBookings,
privateVehicles,
otherTransports,
lodgings,
packageTours
packageTours,
activities,
restaurants,
packingLists,
todos
};
};
@@ -194,12 +227,94 @@ export const actions: Actions = {
if (!trip) return fail(404, { error: 'Trip not found' });
const data = await event.request.formData();
const transportationType = ((data.get('transportation_type') as string)?.trim() || 'flight') as
| 'flight'
| 'private_vehicle'
| 'other';
const parentPlanId = (data.get('parent_plan_id') as string)?.trim() || undefined;
const status = data.get('status') as string as 'idea' | 'tentative' | 'confirmed';
if (transportationType === 'private_vehicle') {
const startAddress = (data.get('start_address') as string)?.trim();
const endAddress = (data.get('end_address') as string)?.trim();
const departureDate = (data.get('departure_date') as string)?.trim() || undefined;
const departureTime = (data.get('departure_time') as string)?.trim() || undefined;
const departureTimezone = (data.get('departure_timezone') as string)?.trim() || undefined;
const arrivalDate = (data.get('arrival_date') as string)?.trim() || undefined;
const arrivalTime = (data.get('arrival_time') as string)?.trim() || undefined;
const arrivalTimezone = (data.get('arrival_timezone') as string)?.trim() || undefined;
const startPlanId = (data.get('start_plan_id') as string)?.trim() || undefined;
const endPlanId = (data.get('end_plan_id') as string)?.trim() || undefined;
if (!startAddress || !endAddress) {
return fail(400, { error: 'Start and end addresses are required' });
}
try {
createPrivateVehicle({
tripId: trip.id,
userId,
parentId: parentPlanId,
status: ['idea', 'tentative', 'confirmed'].includes(status) ? status : 'idea',
startAddress,
endAddress,
departureDate,
departureTime,
departureTimezone,
arrivalDate,
arrivalTime,
arrivalTimezone,
startPlanId,
endPlanId
});
return { success: true };
} catch (err) {
return fail(400, {
error:
err instanceof Error ? err.message : 'Failed to create private vehicle transportation'
});
}
}
if (transportationType === 'other') {
const title = (data.get('title') as string)?.trim();
const notes = (data.get('notes') as string)?.trim() || undefined;
const startDate = (data.get('start_date') as string)?.trim() || undefined;
const startTime = (data.get('start_time') as string)?.trim() || undefined;
const startTimezone = (data.get('start_timezone') as string)?.trim() || undefined;
const endDate = (data.get('end_date') as string)?.trim() || undefined;
const endTime = (data.get('end_time') as string)?.trim() || undefined;
const endTimezone = (data.get('end_timezone') as string)?.trim() || undefined;
if (!title) return fail(400, { error: 'Title is required' });
try {
createOtherTransport({
tripId: trip.id,
userId,
parentId: parentPlanId,
status: ['idea', 'tentative', 'confirmed'].includes(status) ? status : 'idea',
title,
notes,
startDate,
startTime,
startTimezone,
endDate,
endTime,
endTimezone
});
return { success: true };
} catch (err) {
return fail(400, {
error: err instanceof Error ? err.message : 'Failed to create transportation'
});
}
}
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.
@@ -316,6 +431,91 @@ export const actions: Actions = {
if (!trip) return fail(404, { error: 'Trip not found' });
const data = await event.request.formData();
const transportationType = ((data.get('transportation_type') as string)?.trim() || 'flight') as
| 'flight'
| 'private_vehicle'
| 'other';
const status = data.get('status') as string as 'idea' | 'tentative' | 'confirmed';
if (transportationType === 'private_vehicle') {
const privateVehicleId = (data.get('private_vehicle_id') as string)?.trim();
const startAddress = (data.get('start_address') as string)?.trim();
const endAddress = (data.get('end_address') as string)?.trim();
const departureDate = (data.get('departure_date') as string)?.trim() || undefined;
const departureTime = (data.get('departure_time') as string)?.trim() || undefined;
const departureTimezone = (data.get('departure_timezone') as string)?.trim() || undefined;
const arrivalDate = (data.get('arrival_date') as string)?.trim() || undefined;
const arrivalTime = (data.get('arrival_time') as string)?.trim() || undefined;
const arrivalTimezone = (data.get('arrival_timezone') as string)?.trim() || undefined;
const startPlanId = (data.get('start_plan_id') as string)?.trim() || undefined;
const endPlanId = (data.get('end_plan_id') as string)?.trim() || undefined;
if (!privateVehicleId) return fail(400, { error: 'Private vehicle ID is required' });
if (!startAddress || !endAddress) {
return fail(400, { error: 'Start and end addresses are required' });
}
try {
updatePrivateVehicle({
privateVehicleId,
userId,
status: ['idea', 'tentative', 'confirmed'].includes(status) ? status : 'idea',
startAddress,
endAddress,
departureDate,
departureTime,
departureTimezone,
arrivalDate,
arrivalTime,
arrivalTimezone,
startPlanId,
endPlanId
});
return { success: true };
} catch (err) {
return fail(400, {
error:
err instanceof Error ? err.message : 'Failed to update private vehicle transportation'
});
}
}
if (transportationType === 'other') {
const otherTransportId = (data.get('other_transport_id') as string)?.trim();
const title = (data.get('title') as string)?.trim();
const notes = (data.get('notes') as string)?.trim() || undefined;
const startDate = (data.get('start_date') as string)?.trim() || undefined;
const startTime = (data.get('start_time') as string)?.trim() || undefined;
const startTimezone = (data.get('start_timezone') as string)?.trim() || undefined;
const endDate = (data.get('end_date') as string)?.trim() || undefined;
const endTime = (data.get('end_time') as string)?.trim() || undefined;
const endTimezone = (data.get('end_timezone') as string)?.trim() || undefined;
if (!otherTransportId) return fail(400, { error: 'Other transportation ID is required' });
if (!title) return fail(400, { error: 'Title is required' });
try {
updateOtherTransport({
otherTransportId,
userId,
status: ['idea', 'tentative', 'confirmed'].includes(status) ? status : 'idea',
title,
notes,
startDate,
startTime,
startTimezone,
endDate,
endTime,
endTimezone
});
return { success: true };
} catch (err) {
return fail(400, {
error: err instanceof Error ? err.message : 'Failed to update transportation'
});
}
}
const bookingId = (data.get('booking_id') as string)?.trim();
if (!bookingId) return fail(400, { error: 'Booking ID is required' });
@@ -323,7 +523,6 @@ export const actions: Actions = {
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<{
@@ -534,6 +733,206 @@ export const actions: Actions = {
}
},
addExperience: 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 formData = await event.request.formData();
const type = (formData.get('type') as string)?.trim() as 'activity' | 'restaurant';
if (type !== 'activity' && type !== 'restaurant')
return fail(400, { error: 'Invalid plan type' });
const name = (formData.get('name') as string)?.trim();
if (!name) return fail(400, { error: 'Name is required' });
const str = (key: string) => (formData.get(key) as string)?.trim() || undefined;
const num = (key: string) => {
const v = str(key);
return v ? parseFloat(v) : undefined;
};
const parentPlanId = str('parent_plan_id');
const status = formData.get('status') as string as 'idea' | 'tentative' | 'confirmed';
try {
createExperience({
tripId: trip.id,
userId,
type,
name,
parentId: parentPlanId,
status: ['idea', 'tentative', 'confirmed'].includes(status) ? status : 'idea',
bookingId: str('booking_id'),
totalCost: num('total_cost'),
description: str('description'),
website: str('website'),
address: str('address'),
contactNumber: str('contact_number'),
startDate: str('start_date'),
startTime: str('start_time'),
startTimezone: str('start_timezone'),
endDate: str('end_date'),
endTime: str('end_time'),
endTimezone: str('end_timezone')
});
return { success: true };
} catch (err) {
return fail(400, {
error: err instanceof Error ? err.message : 'Failed to create plan'
});
}
},
editExperience: 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 formData = await event.request.formData();
const experienceId = (formData.get('experience_id') as string)?.trim();
if (!experienceId) return fail(400, { error: 'Plan ID is required' });
const name = (formData.get('name') as string)?.trim();
if (!name) return fail(400, { error: 'Name is required' });
const str = (key: string) => (formData.get(key) as string)?.trim() || undefined;
const num = (key: string) => {
const v = str(key);
return v ? parseFloat(v) : undefined;
};
const status = formData.get('status') as string as 'idea' | 'tentative' | 'confirmed';
try {
updateExperience({
experienceId,
userId,
name,
status: ['idea', 'tentative', 'confirmed'].includes(status) ? status : 'idea',
bookingId: str('booking_id'),
totalCost: num('total_cost'),
description: str('description'),
website: str('website'),
address: str('address'),
contactNumber: str('contact_number'),
startDate: str('start_date'),
startTime: str('start_time'),
startTimezone: str('start_timezone'),
endDate: str('end_date'),
endTime: str('end_time'),
endTimezone: str('end_timezone')
});
return { success: true };
} catch (err) {
return fail(400, {
error: err instanceof Error ? err.message : 'Failed to update plan'
});
}
},
addChecklist: 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 formData = await event.request.formData();
const type = (formData.get('type') as string)?.trim() as 'packing' | 'todo';
if (type !== 'packing' && type !== 'todo') return fail(400, { error: 'Invalid plan type' });
const name = (formData.get('name') as string)?.trim();
if (!name) return fail(400, { error: 'Name is required' });
const parentPlanId = (formData.get('parent_plan_id') as string)?.trim() || undefined;
const status = formData.get('status') as string as 'idea' | 'tentative' | 'confirmed';
const items = formData
.getAll('items[]')
.map((item) => String(item).trim())
.filter(Boolean);
try {
createChecklist({
tripId: trip.id,
userId,
type,
name,
parentId: parentPlanId,
status: ['idea', 'tentative', 'confirmed'].includes(status) ? status : 'idea',
items
});
return { success: true };
} catch (err) {
return fail(400, {
error: err instanceof Error ? err.message : 'Failed to create checklist'
});
}
},
editChecklist: 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 formData = await event.request.formData();
const checklistId = (formData.get('checklist_id') as string)?.trim();
if (!checklistId) return fail(400, { error: 'Checklist ID is required' });
const name = (formData.get('name') as string)?.trim();
if (!name) return fail(400, { error: 'Name is required' });
const status = formData.get('status') as string as 'idea' | 'tentative' | 'confirmed';
const items = formData
.getAll('items[]')
.map((item) => String(item).trim())
.filter(Boolean)
.map((content) => ({ content, isChecked: false }));
try {
updateChecklist({
checklistId,
userId,
name,
status: ['idea', 'tentative', 'confirmed'].includes(status) ? status : 'idea',
items
});
return { success: true };
} catch (err) {
return fail(400, {
error: err instanceof Error ? err.message : 'Failed to update checklist'
});
}
},
toggleChecklistItem: 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 formData = await event.request.formData();
const itemId = (formData.get('item_id') as string)?.trim();
if (!itemId) return fail(400, { error: 'Checklist item ID is required' });
const isChecked = (formData.get('is_checked') as string)?.trim() === '1';
try {
toggleChecklistItem({ itemId, userId, isChecked });
return { success: true };
} catch (err) {
return fail(400, {
error: err instanceof Error ? err.message : 'Failed to update checklist item'
});
}
},
addPackageTour: async (event) => {
const session = await event.locals.auth();
const userId = session?.user?.id;

View File

@@ -3,15 +3,23 @@
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 AddTransportationModal from '$lib/components/AddTransportationModal.svelte';
import EditTransportationModal from '$lib/components/EditTransportationModal.svelte';
import AddLodgingModal from '$lib/components/AddLodgingModal.svelte';
import EditLodgingModal from '$lib/components/EditLodgingModal.svelte';
import AddPackageTourModal from '$lib/components/AddPackageTourModal.svelte';
import EditPackageTourModal from '$lib/components/EditPackageTourModal.svelte';
import ExperienceModal from '$lib/components/ExperienceModal.svelte';
import ExperienceCard from '$lib/components/ExperienceCard.svelte';
import ChecklistModal from '$lib/components/ChecklistModal.svelte';
import ChecklistCard from '$lib/components/ChecklistCard.svelte';
import PackageTourCard from '$lib/components/PackageTourCard.svelte';
import AddPlanMenu, { type AddPlanMenuItem } from '$lib/components/AddPlanMenu.svelte';
import { PLAN_TYPE_DEFINITIONS, type PlanTypeId } from '$lib/components/plan-types.js';
import PlanCard from '$lib/components/PlanCard.svelte';
import FlightCard from '$lib/components/FlightCard.svelte';
import OtherTransportCard from '$lib/components/OtherTransportCard.svelte';
import PrivateVehicleCard from '$lib/components/PrivateVehicleCard.svelte';
import LodgingCard from '$lib/components/LodgingCard.svelte';
import TravellerChip from '$lib/components/TravellerChip.svelte';
@@ -24,85 +32,96 @@
let people = $derived(data.people ?? []);
let tripTravellerIds = $derived(travellers.map((t) => t.id));
let flightBookings = $derived(data.flightBookings ?? []);
let privateVehicles = $derived(data.privateVehicles ?? []);
let otherTransports = $derived(data.otherTransports ?? []);
let lodgings = $derived(data.lodgings ?? []);
let packageTours = $derived(data.packageTours ?? []);
let activities = $derived(data.activities ?? []);
let restaurants = $derived(data.restaurants ?? []);
let packingLists = $derived(data.packingLists ?? []);
let todos = $derived(data.todos ?? []);
let editing = $state(false);
let showAddDestination = $state(false);
let showAddTraveller = $state(false);
let showAddFlight = $state(false);
let showAddTransportation = $state(false);
let showAddLodging = $state(false);
let showAddPackageTour = $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 showAddActivity = $state(false);
let showAddRestaurant = $state(false);
let showAddPackingList = $state(false);
let showAddTodo = $state(false);
let editingTransportation = $state<
| {
type: 'flight';
planStatus: 'idea' | 'tentative' | 'confirmed';
flightBooking: (typeof flightBookings)[0];
}
| {
type: 'private_vehicle';
planStatus: 'idea' | 'tentative' | 'confirmed';
privateVehicle: (typeof privateVehicles)[0];
}
| {
type: 'other';
planStatus: 'idea' | 'tentative' | 'confirmed';
planTitle: string;
planNotes: string | null;
otherTransport: (typeof otherTransports)[0];
}
| null
>(null);
let editingLodging = $state<(typeof lodgings)[0] | null>(null);
let editingTour = $state<(typeof packageTours)[0] | null>(null);
let editingActivity = $state<{
plan: (typeof plans)[0];
experience: (typeof activities)[0];
} | null>(null);
let editingRestaurant = $state<{
plan: (typeof plans)[0];
experience: (typeof restaurants)[0];
} | null>(null);
let editingPackingList = $state<{
plan: (typeof plans)[0];
list: (typeof packingLists)[0];
} | null>(null);
let editingTodo = $state<{ plan: (typeof plans)[0]; list: (typeof todos)[0] } | null>(null);
let checklistToggleFormRef = $state<HTMLFormElement | null>(null);
let togglingChecklistItemId = $state<string | null>(null);
let togglingChecklistItemChecked = $state<'0' | '1'>('0');
let addingChildToPlanId = $state<string | null>(null);
const menuItems = [
let transportationLocationOptions = $derived(
plans
.filter((plan) => plan.type !== 'transport' && plan.type !== 'day')
.map((plan) => ({ id: plan.id, label: plan.title }))
);
const addPlanHandlers: Partial<Record<PlanTypeId, () => void>> = {
destination: () => (showAddDestination = true),
activity: () => (showAddActivity = true),
transport: () => (showAddTransportation = true),
lodging: () => (showAddLodging = true),
restaurant: () => (showAddRestaurant = true),
packageTour: () => (showAddPackageTour = true),
packingList: () => (showAddPackingList = true),
todo: () => (showAddTodo = true)
};
const addToTripMenuItems = $derived<AddPlanMenuItem[]>([
{
label: 'Destinations',
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#3B82F6" stroke-width="1.5"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/><circle cx="12" cy="9" r="2.5"/></svg>`,
onclick: () => {
showAddDestination = true;
showAddMenu = false;
}
id: 'travellers',
label: 'Travellers',
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#6366F1" stroke-width="1.5"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" /></svg>`,
onclick: () => (showAddTraveller = true)
},
{
label: 'Attractions & Activities',
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#22C55E" stroke-width="1.5"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>`,
onclick: () => {
showAddMenu = false;
}
},
{
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;
}
},
{
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;
}
},
{
label: 'Restaurants',
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#F43F5E" stroke-width="1.5"><path d="M18 8h1a4 4 0 0 1 0 8h-1"/><path d="M2 8h16v9a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8z"/><line x1="6" y1="1" x2="6" y2="4"/><line x1="10" y1="1" x2="10" y2="4"/><line x1="14" y1="1" x2="14" y2="4"/></svg>`,
onclick: () => {
showAddMenu = false;
}
},
{
label: 'Package Tours',
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#F59E0B" stroke-width="1.5"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/><line x1="12" y1="12" x2="12" y2="16"/><line x1="10" y1="14" x2="14" y2="14"/></svg>`,
onclick: () => {
showAddPackageTour = true;
showAddMenu = false;
}
},
{
label: 'Packing List',
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#14B8A6" stroke-width="1.5"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>`,
onclick: () => {
showAddMenu = false;
}
},
{
label: 'To-dos',
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#64748B" stroke-width="1.5"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>`,
onclick: () => {
showAddMenu = false;
}
}
];
...PLAN_TYPE_DEFINITIONS.map((definition, index) => ({
id: definition.id,
label: definition.label,
icon: definition.icon,
dividerBefore: index === 0,
onclick: () => addPlanHandlers[definition.id]?.()
}))
]);
function formatDate(d: string | null) {
if (!d) return 'TBD';
@@ -112,6 +131,12 @@
year: 'numeric'
});
}
function toggleChecklistItem(itemId: string | number, nextChecked: boolean) {
togglingChecklistItemId = String(itemId);
togglingChecklistItemChecked = nextChecked ? '1' : '0';
setTimeout(() => checklistToggleFormRef?.requestSubmit(), 0);
}
</script>
<svelte:head>
@@ -125,23 +150,24 @@
{people}
{tripTravellerIds}
/>
<AddFlightModal
open={showAddFlight}
<AddTransportationModal
open={showAddTransportation}
onclose={() => {
showAddFlight = false;
showAddTransportation = false;
addingChildToPlanId = null;
}}
{people}
{tripTravellerIds}
planOptions={transportationLocationOptions}
parentPlanId={addingChildToPlanId ?? undefined}
/>
<EditFlightModal
open={!!editingFlight}
flightBooking={editingFlight}
planStatus={editingFlightPlan?.status}
onclose={() => (editingFlight = null)}
<EditTransportationModal
open={!!editingTransportation}
transportation={editingTransportation}
onclose={() => (editingTransportation = null)}
{people}
{tripTravellerIds}
planOptions={transportationLocationOptions}
/>
<AddLodgingModal
open={showAddLodging}
@@ -173,6 +199,123 @@
{people}
{tripTravellerIds}
/>
<ExperienceModal
open={showAddActivity}
onclose={() => {
showAddActivity = false;
addingChildToPlanId = null;
}}
planType="activity"
mode="add"
formAction="?/addExperience"
parentPlanId={addingChildToPlanId ?? undefined}
/>
<ExperienceModal
open={showAddRestaurant}
onclose={() => {
showAddRestaurant = false;
addingChildToPlanId = null;
}}
planType="restaurant"
mode="add"
formAction="?/addExperience"
parentPlanId={addingChildToPlanId ?? undefined}
/>
<ExperienceModal
open={editingActivity != null}
onclose={() => (editingActivity = null)}
planType="activity"
mode="edit"
formAction="?/editExperience"
idValue={editingActivity?.experience.id}
initial={editingActivity
? {
name: editingActivity.plan.title,
status: editingActivity.plan.status,
...editingActivity.experience
}
: null}
/>
<ExperienceModal
open={editingRestaurant != null}
onclose={() => (editingRestaurant = null)}
planType="restaurant"
mode="edit"
formAction="?/editExperience"
idValue={editingRestaurant?.experience.id}
initial={editingRestaurant
? {
name: editingRestaurant.plan.title,
status: editingRestaurant.plan.status,
...editingRestaurant.experience
}
: null}
/>
<ChecklistModal
open={showAddPackingList}
onclose={() => {
showAddPackingList = false;
addingChildToPlanId = null;
}}
checklistType="packing"
mode="add"
formAction="?/addChecklist"
parentPlanId={addingChildToPlanId ?? undefined}
/>
<ChecklistModal
open={showAddTodo}
onclose={() => {
showAddTodo = false;
addingChildToPlanId = null;
}}
checklistType="todo"
mode="add"
formAction="?/addChecklist"
parentPlanId={addingChildToPlanId ?? undefined}
/>
<ChecklistModal
open={editingPackingList != null}
onclose={() => (editingPackingList = null)}
checklistType="packing"
mode="edit"
formAction="?/editChecklist"
idValue={editingPackingList?.list.id}
initial={editingPackingList
? {
name: editingPackingList.plan.title,
status: editingPackingList.plan.status,
items: editingPackingList.list.items.map((item) => ({ content: item.content }))
}
: null}
/>
<ChecklistModal
open={editingTodo != null}
onclose={() => (editingTodo = null)}
checklistType="todo"
mode="edit"
formAction="?/editChecklist"
idValue={editingTodo?.list.id}
initial={editingTodo
? {
name: editingTodo.plan.title,
status: editingTodo.plan.status,
items: editingTodo.list.items.map((item) => ({ content: item.content }))
}
: null}
/>
<form
method="POST"
action="?/toggleChecklistItem"
bind:this={checklistToggleFormRef}
use:enhance={() =>
({ update }) =>
update()}
class="hidden"
aria-hidden="true"
>
<input type="hidden" name="item_id" value={togglingChecklistItemId ?? ''} />
<input type="hidden" name="is_checked" value={togglingChecklistItemChecked} />
</form>
<div class="mx-auto max-w-2xl">
<!-- Header -->
@@ -183,84 +326,13 @@
<h1 class="text-2xl font-bold text-gray-900">{trip.name}</h1>
<div class="flex shrink-0 items-center gap-2">
{#if planCount > 0 || travellerCount > 0}
<div class="relative">
<button
onclick={() => (showAddMenu = !showAddMenu)}
class="flex items-center gap-1.5 rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Add to trip
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
{#if showAddMenu}
<div
class="fixed inset-0 z-10"
role="button"
tabindex="-1"
onclick={() => (showAddMenu = false)}
onkeydown={(e) => e.key === 'Escape' && (showAddMenu = false)}
></div>
<div
class="absolute right-0 z-20 mt-1 w-56 overflow-hidden rounded-lg border border-gray-200 bg-white py-1 shadow-lg"
>
<!-- Travellers — separate from plan types -->
<button
onclick={() => {
showAddTraveller = true;
showAddMenu = false;
}}
class="flex w-full items-center gap-3 px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="#6366F1"
stroke-width="1.5"
><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" /><circle
cx="9"
cy="7"
r="4"
/><path d="M23 21v-2a4 4 0 0 0-3-3.87" /><path
d="M16 3.13a4 4 0 0 1 0 7.75"
/></svg
>
Travellers
</button>
<hr class="my-1 border-gray-100" />
{#each menuItems as item}
<button
onclick={item.onclick}
class="flex w-full items-center gap-3 px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
>
{@html item.icon}
{item.label}
</button>
{/each}
</div>
{/if}
</div>
<AddPlanMenu
buttonLabel="Add to trip"
items={addToTripMenuItems}
variant="primary"
align="right"
menuWidthClass="w-56"
/>
{/if}
<button
onclick={() => (editing = true)}
@@ -462,8 +534,13 @@
tripName={trip.name}
onAddDestination={() => (showAddDestination = true)}
onAddTraveller={() => (showAddTraveller = true)}
onAddFlight={() => (showAddFlight = true)}
onAddActivity={() => (showAddActivity = true)}
onAddTransportation={() => (showAddTransportation = true)}
onAddLodging={() => (showAddLodging = true)}
onAddRestaurant={() => (showAddRestaurant = true)}
onAddPackageTour={() => (showAddPackageTour = true)}
onAddPackingList={() => (showAddPackingList = true)}
onAddTodo={() => (showAddTodo = true)}
/>
{:else}
<!-- Plans list -->
@@ -496,15 +573,17 @@
</div>
</div>
<!-- Flights section -->
{#if flightBookings.length > 0}
<!-- Transportation section -->
{#if flightBookings.length > 0 || privateVehicles.length > 0 || otherTransports.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)}
{#each plans.filter((p) => p.type === 'transport') as plan (plan.id)}
{@const flightBooking = flightBookings.find((b) => b.plan_id === plan.id)}
{@const privateVehicle = privateVehicles.find((pv) => pv.plan_id === plan.id)}
{@const otherTransport = otherTransports.find((ot) => ot.plan_id === plan.id)}
{#if plan}
{@const formId = `remove-plan-${plan.id}`}
{@const submitForm = () => {
@@ -523,10 +602,83 @@
class="contents"
>
<input type="hidden" name="plan_id" value={plan.id} />
<FlightCard
{#if flightBooking}
<FlightCard
{plan}
{flightBooking}
onEdit={() =>
(editingTransportation = {
type: 'flight',
flightBooking,
planStatus: plan.status
})}
onDelete={submitForm}
/>
{:else if privateVehicle}
<PrivateVehicleCard
{plan}
{privateVehicle}
onEdit={() =>
(editingTransportation = {
type: 'private_vehicle',
privateVehicle,
planStatus: plan.status
})}
onDelete={submitForm}
/>
{:else if otherTransport}
<OtherTransportCard
{plan}
{otherTransport}
onEdit={() =>
(editingTransportation = {
type: 'other',
otherTransport,
planStatus: plan.status,
planTitle: plan.title,
planNotes: plan.notes
})}
onDelete={submitForm}
/>
{/if}
</form>
{/if}
{/each}
</div>
</div>
{/if}
<!-- Activities section -->
{#if activities.length > 0}
<div class="mt-8">
<h2 class="mb-3 text-sm font-semibold tracking-wider text-gray-400 uppercase">
Attractions & Activities
</h2>
<div class="flex flex-col gap-3">
{#each activities as activity (activity.id)}
{@const plan = plans.find((p) => p.id === activity.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} />
<ExperienceCard
{plan}
{flightBooking}
onEdit={() => (editingFlight = flightBooking)}
experience={activity}
onEdit={() => (editingActivity = { plan, experience: activity })}
onDelete={submitForm}
/>
</form>
@@ -536,6 +688,126 @@
</div>
{/if}
<!-- Restaurants section -->
{#if restaurants.length > 0}
<div class="mt-8">
<h2 class="mb-3 text-sm font-semibold tracking-wider text-gray-400 uppercase">
Restaurants
</h2>
<div class="flex flex-col gap-3">
{#each restaurants as restaurant (restaurant.id)}
{@const plan = plans.find((p) => p.id === restaurant.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} />
<ExperienceCard
{plan}
experience={restaurant}
onEdit={() => (editingRestaurant = { plan, experience: restaurant })}
onDelete={submitForm}
/>
</form>
{/if}
{/each}
</div>
</div>
{/if}
<!-- Packing list section -->
{#if packingLists.length > 0}
<div class="mt-8">
<h2 class="mb-3 text-sm font-semibold tracking-wider text-gray-400 uppercase">
Packing Lists
</h2>
<div class="flex flex-col gap-3">
{#each packingLists as list (list.id)}
{@const plan = plans.find((p) => p.id === list.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} />
<ChecklistCard
{plan}
items={list.items}
onEdit={() => (editingPackingList = { plan, list })}
onDelete={submitForm}
onToggleItem={toggleChecklistItem}
/>
</form>
{/if}
{/each}
</div>
</div>
{/if}
<!-- To-dos section -->
{#if todos.length > 0}
<div class="mt-8">
<h2 class="mb-3 text-sm font-semibold tracking-wider text-gray-400 uppercase">To-dos</h2>
<div class="flex flex-col gap-3">
{#each todos as list (list.id)}
{@const plan = plans.find((p) => p.id === list.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} />
<ChecklistCard
{plan}
items={list.items}
onEdit={() => (editingTodo = { plan, list })}
onDelete={submitForm}
onToggleItem={toggleChecklistItem}
/>
</form>
{/if}
{/each}
</div>
</div>
{/if}
<!-- Lodgings section -->
{#if lodgings.length > 0}
<div class="mt-8">
@@ -608,17 +880,17 @@
{tour}
onEdit={() => (editingTour = tour)}
onDelete={submitForm}
onAddFlight={() => {
onAddTransportation={() => {
addingChildToPlanId = plan.id;
showAddFlight = true;
showAddTransportation = true;
}}
onAddLodging={() => {
addingChildToPlanId = plan.id;
showAddLodging = true;
}}
onAddFlightToDay={(dayPlanId) => {
onAddTransportationToDay={(dayPlanId) => {
addingChildToPlanId = dayPlanId;
showAddFlight = true;
showAddTransportation = true;
}}
onAddLodgingToDay={(dayPlanId) => {
addingChildToPlanId = dayPlanId;