trips) adding package-tours option

This commit is contained in:
2026-02-19 13:08:25 -05:00
parent 50e216daec
commit dba9aa0416
51 changed files with 5581 additions and 57 deletions

View File

@@ -1,5 +1,6 @@
import { redirect } from '@sveltejs/kit';
import { base } from '$app/paths';
import { env } from '$env/dynamic/private';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async (event) => {
@@ -7,5 +8,12 @@ export const load: LayoutServerLoad = async (event) => {
if (!session?.user) {
redirect(303, `${base}/login`);
}
return { session };
const adminIds = (env.ADMIN_USER_IDS ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const isAdmin = adminIds.length > 0 && session.user.id && adminIds.includes(session.user.id);
return { session, isAdmin };
};

View File

@@ -1,13 +1,28 @@
<script lang="ts">
import { page } from '$app/stores';
import { base } from '$app/paths';
import Sidebar from '$lib/components/Sidebar.svelte';
import NavMenu from '$lib/components/NavMenu.svelte';
import AdminNavMenu from '$lib/components/AdminNavMenu.svelte';
let { children, data } = $props();
const isAdminRoute = $derived(
$page.url.pathname === `${base}/admin` || $page.url.pathname.startsWith(`${base}/admin/`)
);
</script>
<div class="flex h-screen overflow-hidden">
<Sidebar userName={data.session.user?.name ?? data.session.user?.email} />
<NavMenu />
<Sidebar
userName={data.session.user?.name ?? data.session.user?.email}
isAdmin={data.isAdmin}
isAdminRoute={isAdminRoute}
/>
{#if isAdminRoute}
<AdminNavMenu />
{:else}
<NavMenu />
{/if}
<main class="flex-1 overflow-auto bg-white p-8">
{@render children()}
</main>

View File

@@ -0,0 +1,11 @@
import { redirect } from '@sveltejs/kit';
import { base } from '$app/paths';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async (event) => {
const parentData = await event.parent();
if (!parentData?.isAdmin) {
redirect(303, `${base}/dashboard`);
}
return {};
};

View File

@@ -0,0 +1,7 @@
import { redirect } from '@sveltejs/kit';
import { base } from '$app/paths';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
redirect(303, `${base}/admin/general`);
};

View File

@@ -0,0 +1 @@
<!-- Redirects to /admin/general via +page.server.ts load -->

View File

@@ -0,0 +1,316 @@
<script lang="ts">
import { base } from '$app/paths';
interface Airline {
id: number;
iata_code: string | null;
icao_code: string | null;
name: string;
country: string | null;
country_code: string | null;
}
let items = $state<Airline[]>([]);
let loading = $state(true);
let search = $state('');
let adding = $state(false);
let editing = $state<Airline | null>(null);
let newName = $state('');
let newCountry = $state('');
let newCode = $state('');
let newIata = $state('');
let newIcao = $state('');
let error = $state('');
let searchDebounce: ReturnType<typeof setTimeout>;
async function loadItems() {
loading = true;
try {
const params = search.trim() ? `?q=${encodeURIComponent(search.trim())}` : '';
const res = await fetch(`${base}/admin/api/airlines${params}`);
items = await res.json();
} finally {
loading = false;
}
}
$effect(() => {
search;
clearTimeout(searchDebounce);
searchDebounce = setTimeout(loadItems, 200);
});
function startAdd() {
adding = true;
editing = null;
newName = '';
newCountry = '';
newCode = '';
newIata = '';
newIcao = '';
error = '';
}
function startEdit(item: Airline) {
editing = item;
adding = false;
newName = item.name;
newCountry = item.country ?? '';
newCode = item.country_code ?? '';
newIata = item.iata_code ?? '';
newIcao = item.icao_code ?? '';
error = '';
}
function cancelForm() {
adding = false;
editing = null;
newName = '';
newCountry = '';
newCode = '';
newIata = '';
newIcao = '';
error = '';
}
async function submitAdd() {
error = '';
if (!newName.trim()) {
error = 'Name is required';
return;
}
try {
const res = await fetch(`${base}/admin/api/airlines`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: newName.trim(),
country: newCountry.trim() || null,
country_code: newCode.trim() || null,
iata_code: newIata.trim() || null,
icao_code: newIcao.trim() || null
})
});
if (!res.ok) {
const data = await res.json();
error = data.error ?? 'Failed to add';
return;
}
cancelForm();
loadItems();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to add';
}
}
async function submitEdit() {
if (!editing) return;
error = '';
if (!newName.trim()) {
error = 'Name is required';
return;
}
try {
const res = await fetch(`${base}/admin/api/airlines`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: editing.id,
name: newName.trim(),
country: newCountry.trim() || null,
country_code: newCode.trim() || null,
iata_code: newIata.trim() || null,
icao_code: newIcao.trim() || null
})
});
if (!res.ok) {
const data = await res.json();
error = data.error ?? 'Failed to update';
return;
}
cancelForm();
loadItems();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update';
}
}
async function remove(id: number) {
if (!confirm('Delete this airline?')) return;
try {
const res = await fetch(`${base}/admin/api/airlines?id=${id}`, { method: 'DELETE' });
if (!res.ok) {
const data = await res.json();
alert(data.error ?? 'Failed to delete');
return;
}
cancelForm();
loadItems();
} catch (e) {
alert(e instanceof Error ? e.message : 'Failed to delete');
}
}
</script>
<svelte:head>
<title>Airlines — Admin — Trips</title>
</svelte:head>
<div class="mx-auto max-w-4xl">
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">Airlines</h1>
<p class="mt-1 text-sm text-gray-500">Manage the list of airlines used for flight planning.</p>
</div>
<div class="flex flex-col gap-4">
<!-- Toolbar -->
<div class="flex items-center gap-3">
<input
type="text"
bind:value={search}
placeholder="Search airlines..."
class="w-64 rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
<button
onclick={startAdd}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Add airline
</button>
</div>
<!-- Inline form -->
{#if adding || editing}
<div class="rounded-lg border border-gray-200 bg-gray-50 p-5">
<h3 class="mb-4 text-sm font-semibold text-gray-700">
{editing ? 'Edit airline' : 'Add airline'}
</h3>
{#if error}
<p class="mb-3 text-sm text-red-600">{error}</p>
{/if}
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
<div class="flex flex-col gap-1.5 lg:col-span-2">
<label class="text-xs font-medium text-gray-500"
>Name <span class="text-red-500">*</span></label
>
<input
type="text"
bind:value={newName}
placeholder="Singapore Airlines"
class="rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium text-gray-500">IATA</label>
<input
type="text"
bind:value={newIata}
placeholder="SQ"
maxlength="2"
class="rounded-md border border-gray-300 px-3 py-2 text-sm uppercase focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium text-gray-500">ICAO</label>
<input
type="text"
bind:value={newIcao}
placeholder="SIA"
maxlength="3"
class="rounded-md border border-gray-300 px-3 py-2 text-sm uppercase focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium text-gray-500">Country</label>
<input
type="text"
bind:value={newCountry}
placeholder="Singapore"
class="rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
</div>
<div class="mt-4 flex gap-2">
<button
onclick={editing ? submitEdit : submitAdd}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
{editing ? 'Save changes' : 'Add airline'}
</button>
<button
onclick={cancelForm}
class="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
Cancel
</button>
</div>
</div>
{/if}
<!-- Table -->
<div
class="max-h-[calc(100vh-320px)] overflow-x-auto overflow-y-auto rounded-lg border border-gray-200"
>
{#if loading}
<p class="p-8 text-center text-sm text-gray-500">Loading...</p>
{:else if items.length === 0}
<p class="p-8 text-center text-sm text-gray-500">No airlines found.</p>
{:else}
<table class="min-w-full divide-y divide-gray-200">
<thead class="sticky top-0 bg-gray-50">
<tr>
<th
class="px-4 py-3 text-left text-xs font-semibold tracking-wide text-gray-500 uppercase"
>IATA</th
>
<th
class="px-4 py-3 text-left text-xs font-semibold tracking-wide text-gray-500 uppercase"
>Name</th
>
<th
class="px-4 py-3 text-left text-xs font-semibold tracking-wide text-gray-500 uppercase"
>Country</th
>
<th
class="px-4 py-3 text-right text-xs font-semibold tracking-wide text-gray-500 uppercase"
>Actions</th
>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#each items as item (item.id)}
<tr class="hover:bg-gray-50">
<td class="px-4 py-3">
{#if item.iata_code}
<span class="rounded bg-gray-100 px-2 py-0.5 font-mono text-xs text-gray-700"
>{item.iata_code}</span
>
{:else}
<span class="text-sm text-gray-400"></span>
{/if}
</td>
<td class="px-4 py-3 text-sm font-medium text-gray-900">{item.name}</td>
<td class="px-4 py-3 text-sm text-gray-600">{item.country ?? '—'}</td>
<td class="px-4 py-3 text-right">
<button
onclick={() => startEdit(item)}
class="text-sm text-blue-600 hover:text-blue-800"
>
Edit
</button>
<span class="mx-2 text-gray-300">·</span>
<button
onclick={() => remove(item.id)}
class="text-sm text-red-500 hover:text-red-700"
>
Delete
</button>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
</div>
</div>

View File

@@ -0,0 +1,342 @@
<script lang="ts">
import { base } from '$app/paths';
interface Airport {
id: number;
iata_code: string | null;
icao_code: string | null;
name: string;
city: string | null;
country: string;
country_code: string;
latitude: number | null;
longitude: number | null;
timezone: string | null;
}
let items = $state<Airport[]>([]);
let loading = $state(true);
let search = $state('');
let adding = $state(false);
let editing = $state<Airport | null>(null);
let newName = $state('');
let newCountry = $state('');
let newCode = $state('');
let newIata = $state('');
let newIcao = $state('');
let newCity = $state('');
let error = $state('');
let searchDebounce: ReturnType<typeof setTimeout>;
async function loadItems() {
loading = true;
try {
const params = search.trim() ? `?q=${encodeURIComponent(search.trim())}` : '';
const res = await fetch(`${base}/admin/api/airports${params}`);
items = await res.json();
} finally {
loading = false;
}
}
$effect(() => {
search;
clearTimeout(searchDebounce);
searchDebounce = setTimeout(loadItems, 200);
});
function startAdd() {
adding = true;
editing = null;
newName = '';
newCountry = '';
newCode = '';
newIata = '';
newIcao = '';
newCity = '';
error = '';
}
function startEdit(item: Airport) {
editing = item;
adding = false;
newName = item.name;
newCountry = item.country;
newCode = item.country_code;
newIata = item.iata_code ?? '';
newIcao = item.icao_code ?? '';
newCity = item.city ?? '';
error = '';
}
function cancelForm() {
adding = false;
editing = null;
newName = '';
newCountry = '';
newCode = '';
newIata = '';
newIcao = '';
newCity = '';
error = '';
}
async function submitAdd() {
error = '';
if (!newName.trim() || !newCountry.trim() || !newCode.trim()) {
error = 'Name, country and country code are required';
return;
}
try {
const res = await fetch(`${base}/admin/api/airports`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: newName.trim(),
country: newCountry.trim(),
country_code: newCode.trim().toUpperCase(),
iata_code: newIata.trim() || null,
icao_code: newIcao.trim() || null,
city: newCity.trim() || null
})
});
if (!res.ok) {
const data = await res.json();
error = data.error ?? 'Failed to add';
return;
}
cancelForm();
loadItems();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to add';
}
}
async function submitEdit() {
if (!editing) return;
error = '';
if (!newName.trim() || !newCountry.trim() || !newCode.trim()) {
error = 'Name, country and country code are required';
return;
}
try {
const res = await fetch(`${base}/admin/api/airports`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: editing.id,
name: newName.trim(),
country: newCountry.trim(),
country_code: newCode.trim().toUpperCase(),
iata_code: newIata.trim() || null,
icao_code: newIcao.trim() || null,
city: newCity.trim() || null
})
});
if (!res.ok) {
const data = await res.json();
error = data.error ?? 'Failed to update';
return;
}
cancelForm();
loadItems();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update';
}
}
async function remove(id: number) {
if (!confirm('Delete this airport?')) return;
try {
const res = await fetch(`${base}/admin/api/airports?id=${id}`, { method: 'DELETE' });
if (!res.ok) {
const data = await res.json();
alert(data.error ?? 'Failed to delete');
return;
}
cancelForm();
loadItems();
} catch (e) {
alert(e instanceof Error ? e.message : 'Failed to delete');
}
}
</script>
<svelte:head>
<title>Airports — Admin — Trips</title>
</svelte:head>
<div class="mx-auto max-w-4xl">
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">Airports</h1>
<p class="mt-1 text-sm text-gray-500">Manage the list of airports used for flight planning.</p>
</div>
<div class="flex flex-col gap-4">
<!-- Toolbar -->
<div class="flex items-center gap-3">
<input
type="text"
bind:value={search}
placeholder="Search airports..."
class="w-64 rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
<button
onclick={startAdd}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Add airport
</button>
</div>
<!-- Inline form -->
{#if adding || editing}
<div class="rounded-lg border border-gray-200 bg-gray-50 p-5">
<h3 class="mb-4 text-sm font-semibold text-gray-700">
{editing ? 'Edit airport' : 'Add airport'}
</h3>
{#if error}
<p class="mb-3 text-sm text-red-600">{error}</p>
{/if}
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
<div class="flex flex-col gap-1.5 lg:col-span-2">
<label class="text-xs font-medium text-gray-500"
>Name <span class="text-red-500">*</span></label
>
<input
type="text"
bind:value={newName}
placeholder="Changi Airport"
class="rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium text-gray-500">IATA</label>
<input
type="text"
bind:value={newIata}
placeholder="SIN"
maxlength="3"
class="rounded-md border border-gray-300 px-3 py-2 text-sm uppercase focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium text-gray-500">ICAO</label>
<input
type="text"
bind:value={newIcao}
placeholder="WSSS"
maxlength="4"
class="rounded-md border border-gray-300 px-3 py-2 text-sm uppercase focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium text-gray-500">City</label>
<input
type="text"
bind:value={newCity}
placeholder="Singapore"
class="rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium text-gray-500"
>Country <span class="text-red-500">*</span></label
>
<input
type="text"
bind:value={newCountry}
placeholder="Singapore"
class="rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
</div>
<div class="mt-4 flex gap-2">
<button
onclick={editing ? submitEdit : submitAdd}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
{editing ? 'Save changes' : 'Add airport'}
</button>
<button
onclick={cancelForm}
class="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
Cancel
</button>
</div>
</div>
{/if}
<!-- Table -->
<div
class="max-h-[calc(100vh-320px)] overflow-x-auto overflow-y-auto rounded-lg border border-gray-200"
>
{#if loading}
<p class="p-8 text-center text-sm text-gray-500">Loading...</p>
{:else if items.length === 0}
<p class="p-8 text-center text-sm text-gray-500">No airports found.</p>
{:else}
<table class="min-w-full divide-y divide-gray-200">
<thead class="sticky top-0 bg-gray-50">
<tr>
<th
class="px-4 py-3 text-left text-xs font-semibold tracking-wide text-gray-500 uppercase"
>IATA</th
>
<th
class="px-4 py-3 text-left text-xs font-semibold tracking-wide text-gray-500 uppercase"
>Name</th
>
<th
class="px-4 py-3 text-left text-xs font-semibold tracking-wide text-gray-500 uppercase"
>City</th
>
<th
class="px-4 py-3 text-left text-xs font-semibold tracking-wide text-gray-500 uppercase"
>Country</th
>
<th
class="px-4 py-3 text-right text-xs font-semibold tracking-wide text-gray-500 uppercase"
>Actions</th
>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#each items as item (item.id)}
<tr class="hover:bg-gray-50">
<td class="px-4 py-3">
{#if item.iata_code}
<span class="rounded bg-gray-100 px-2 py-0.5 font-mono text-xs text-gray-700"
>{item.iata_code}</span
>
{:else}
<span class="text-sm text-gray-400"></span>
{/if}
</td>
<td class="px-4 py-3 text-sm font-medium text-gray-900">{item.name}</td>
<td class="px-4 py-3 text-sm text-gray-600">{item.city ?? '—'}</td>
<td class="px-4 py-3 text-sm text-gray-600">{item.country}</td>
<td class="px-4 py-3 text-right">
<button
onclick={() => startEdit(item)}
class="text-sm text-blue-600 hover:text-blue-800"
>
Edit
</button>
<span class="mx-2 text-gray-300">·</span>
<button
onclick={() => remove(item.id)}
class="text-sm text-red-500 hover:text-red-700"
>
Delete
</button>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
</div>
</div>

View File

@@ -0,0 +1,63 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAdmin } from '$lib/server/admin/auth.js';
import * as data from '$lib/server/admin/data.js';
export const GET: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
const q = event.url.searchParams.get('q') ?? undefined;
return json(data.listAirlines(q));
};
export const POST: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
const body = await event.request.json();
const { name, country, country_code, iata_code, icao_code } = body as {
name?: string;
country?: string | null;
country_code?: string | null;
iata_code?: string | null;
icao_code?: string | null;
};
if (!name?.trim()) return json({ error: 'name required' }, { status: 400 });
return json(
data.createAirline(
name,
country ?? null,
country_code ?? null,
iata_code ?? null,
icao_code ?? null
)
);
};
export const PATCH: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
const body = await event.request.json();
const { id, name, country, country_code, iata_code, icao_code } = body as {
id?: number;
name?: string;
country?: string | null;
country_code?: string | null;
iata_code?: string | null;
icao_code?: string | null;
};
if (id == null || !name?.trim()) return json({ error: 'id and name required' }, { status: 400 });
data.updateAirline(
id,
name,
country ?? null,
country_code ?? null,
iata_code ?? null,
icao_code ?? null
);
return json({ ok: true });
};
export const DELETE: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
const id = parseInt(event.url.searchParams.get('id') ?? '');
if (isNaN(id)) return json({ error: 'id required' }, { status: 400 });
data.deleteAirline(id);
return json({ ok: true });
};

View File

@@ -0,0 +1,95 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAdmin } from '$lib/server/admin/auth.js';
import * as data from '$lib/server/admin/data.js';
export const GET: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
const q = event.url.searchParams.get('q') ?? undefined;
return json(data.listAirports(q));
};
export const POST: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
const body = await event.request.json();
const { name, country, country_code, iata_code, icao_code, city, latitude, longitude, timezone } =
body as {
name?: string;
country?: string;
country_code?: string;
iata_code?: string | null;
icao_code?: string | null;
city?: string | null;
latitude?: number | null;
longitude?: number | null;
timezone?: string | null;
};
if (!name?.trim() || !country?.trim() || !country_code?.trim()) {
return json({ error: 'name, country and country_code required' }, { status: 400 });
}
return json(
data.createAirport(
name,
country,
country_code,
iata_code ?? null,
icao_code ?? null,
city ?? null,
latitude ?? null,
longitude ?? null,
timezone ?? null
)
);
};
export const PATCH: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
const body = await event.request.json();
const {
id,
name,
country,
country_code,
iata_code,
icao_code,
city,
latitude,
longitude,
timezone
} = body as {
id?: number;
name?: string;
country?: string;
country_code?: string;
iata_code?: string | null;
icao_code?: string | null;
city?: string | null;
latitude?: number | null;
longitude?: number | null;
timezone?: string | null;
};
if (id == null || !name?.trim() || !country?.trim() || !country_code?.trim()) {
return json({ error: 'id, name, country and country_code required' }, { status: 400 });
}
data.updateAirport(
id,
name,
country,
country_code,
iata_code ?? null,
icao_code ?? null,
city ?? null,
latitude ?? null,
longitude ?? null,
timezone ?? null
);
return json({ ok: true });
};
export const DELETE: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
const id = parseInt(event.url.searchParams.get('id') ?? '');
if (isNaN(id)) return json({ error: 'id required' }, { status: 400 });
data.deleteAirport(id);
return json({ ok: true });
};

View File

@@ -0,0 +1,51 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAdmin } from '$lib/server/admin/auth.js';
import * as data from '$lib/server/admin/data.js';
export const GET: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
const q = event.url.searchParams.get('q') ?? undefined;
const countryCode = event.url.searchParams.get('country_code') ?? undefined;
return json(data.listCities(q, countryCode));
};
export const POST: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
const body = await event.request.json();
const { name, country, country_code, population } = body as {
name?: string;
country?: string;
country_code?: string;
population?: number | null;
};
if (!name?.trim() || !country?.trim() || !country_code?.trim()) {
return json({ error: 'name, country and country_code required' }, { status: 400 });
}
return json(data.createCity(name, country, country_code, population ?? null));
};
export const PATCH: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
const body = await event.request.json();
const { id, name, country, country_code, population } = body as {
id?: number;
name?: string;
country?: string;
country_code?: string;
population?: number | null;
};
if (id == null || !name?.trim() || !country?.trim() || !country_code?.trim()) {
return json({ error: 'id, name, country and country_code required' }, { status: 400 });
}
data.updateCity(id, name, country, country_code, population ?? null);
return json({ ok: true });
};
export const DELETE: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
const id = parseInt(event.url.searchParams.get('id') ?? '');
if (isNaN(id)) return json({ error: 'id required' }, { status: 400 });
data.deleteCity(id);
return json({ ok: true });
};

View File

@@ -0,0 +1,39 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAdmin } from '$lib/server/admin/auth.js';
import * as data from '$lib/server/admin/data.js';
export const GET: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
const q = event.url.searchParams.get('q') ?? undefined;
return json(data.listCountries(q));
};
export const POST: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
const body = await event.request.json();
const { name, country_code } = body as { name?: string; country_code?: string };
if (!name?.trim() || !country_code?.trim()) {
return json({ error: 'name and country_code required' }, { status: 400 });
}
return json(data.createCountry(name, country_code));
};
export const PATCH: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
const body = await event.request.json();
const { id, name, country_code } = body as { id?: number; name?: string; country_code?: string };
if (id == null || !name?.trim() || !country_code?.trim()) {
return json({ error: 'id, name and country_code required' }, { status: 400 });
}
data.updateCountry(id, name, country_code);
return json({ ok: true });
};
export const DELETE: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
const id = parseInt(event.url.searchParams.get('id') ?? '');
if (isNaN(id)) return json({ error: 'id required' }, { status: 400 });
data.deleteCountry(id);
return json({ ok: true });
};

View File

@@ -0,0 +1,48 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAdmin } from '$lib/server/admin/auth.js';
import * as data from '$lib/server/admin/data.js';
export const GET: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
const q = event.url.searchParams.get('q') ?? undefined;
return json(data.listTourOperators(q));
};
export const POST: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
const body = await event.request.json();
const { name, website, highlight_color } = body as {
name?: string;
website?: string | null;
highlight_color?: string | null;
};
if (!name?.trim()) {
return json({ error: 'name is required' }, { status: 400 });
}
return json(data.createTourOperator(name, website, highlight_color));
};
export const PATCH: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
const body = await event.request.json();
const { id, name, website, highlight_color } = body as {
id?: number;
name?: string;
website?: string | null;
highlight_color?: string | null;
};
if (id == null || !name?.trim()) {
return json({ error: 'id and name are required' }, { status: 400 });
}
data.updateTourOperator(id, name, website, highlight_color);
return json({ ok: true });
};
export const DELETE: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
const id = parseInt(event.url.searchParams.get('id') ?? '');
if (isNaN(id)) return json({ error: 'id required' }, { status: 400 });
data.deleteTourOperator(id);
return json({ ok: true });
};

View File

@@ -0,0 +1,317 @@
<script lang="ts">
import { base } from '$app/paths';
interface City {
id: number;
name: string;
country: string;
country_code: string;
population: number | null;
}
let items = $state<City[]>([]);
let loading = $state(true);
let search = $state('');
let adding = $state(false);
let editing = $state<City | null>(null);
let newName = $state('');
let newCountry = $state('');
let newCode = $state('');
let newPopulation = $state('');
let error = $state('');
let searchDebounce: ReturnType<typeof setTimeout>;
async function loadItems() {
loading = true;
try {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const params = new URLSearchParams();
if (search.trim()) params.set('q', search.trim());
const res = await fetch(`${base}/admin/api/cities?${params}`);
items = await res.json();
} finally {
loading = false;
}
}
$effect(() => {
search;
clearTimeout(searchDebounce);
searchDebounce = setTimeout(loadItems, 200);
});
function startAdd() {
adding = true;
editing = null;
newName = '';
newCountry = '';
newCode = '';
newPopulation = '';
error = '';
}
function startEdit(item: City) {
editing = item;
adding = false;
newName = item.name;
newCountry = item.country;
newCode = item.country_code;
newPopulation = item.population != null ? String(item.population) : '';
error = '';
}
function cancelForm() {
adding = false;
editing = null;
newName = '';
newCountry = '';
newCode = '';
newPopulation = '';
error = '';
}
async function submitAdd() {
error = '';
if (!newName.trim() || !newCountry.trim() || !newCode.trim()) {
error = 'Name, country and country code are required';
return;
}
try {
const res = await fetch(`${base}/admin/api/cities`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: newName.trim(),
country: newCountry.trim(),
country_code: newCode.trim().toUpperCase(),
population: newPopulation.trim() ? parseInt(newPopulation) : null
})
});
if (!res.ok) {
const data = await res.json();
error = data.error ?? 'Failed to add';
return;
}
cancelForm();
loadItems();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to add';
}
}
async function submitEdit() {
if (!editing) return;
error = '';
if (!newName.trim() || !newCountry.trim() || !newCode.trim()) {
error = 'Name, country and country code are required';
return;
}
try {
const res = await fetch(`${base}/admin/api/cities`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: editing.id,
name: newName.trim(),
country: newCountry.trim(),
country_code: newCode.trim().toUpperCase(),
population: newPopulation.trim() ? parseInt(newPopulation) : null
})
});
if (!res.ok) {
const data = await res.json();
error = data.error ?? 'Failed to update';
return;
}
cancelForm();
loadItems();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update';
}
}
async function remove(id: number) {
if (!confirm('Delete this city?')) return;
try {
const res = await fetch(`${base}/admin/api/cities?id=${id}`, { method: 'DELETE' });
if (!res.ok) {
const data = await res.json();
alert(data.error ?? 'Failed to delete');
return;
}
cancelForm();
loadItems();
} catch (e) {
alert(e instanceof Error ? e.message : 'Failed to delete');
}
}
</script>
<svelte:head>
<title>Cities — Admin — Trips</title>
</svelte:head>
<div class="mx-auto max-w-4xl">
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">Cities</h1>
<p class="mt-1 text-sm text-gray-500">Manage the list of cities used across the app.</p>
</div>
<div class="flex flex-col gap-4">
<!-- Toolbar -->
<div class="flex items-center gap-3">
<input
type="text"
bind:value={search}
placeholder="Search cities..."
class="w-64 rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
<button
onclick={startAdd}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Add city
</button>
</div>
<!-- Inline form -->
{#if adding || editing}
<div class="rounded-lg border border-gray-200 bg-gray-50 p-5">
<h3 class="mb-4 text-sm font-semibold text-gray-700">
{editing ? 'Edit city' : 'Add city'}
</h3>
{#if error}
<p class="mb-3 text-sm text-red-600">{error}</p>
{/if}
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium text-gray-500"
>Name <span class="text-red-500">*</span></label
>
<input
type="text"
bind:value={newName}
placeholder="Singapore"
class="rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium text-gray-500"
>Country <span class="text-red-500">*</span></label
>
<input
type="text"
bind:value={newCountry}
placeholder="Singapore"
class="rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium text-gray-500"
>Country code <span class="text-red-500">*</span></label
>
<input
type="text"
bind:value={newCode}
placeholder="SG"
maxlength="2"
class="rounded-md border border-gray-300 px-3 py-2 text-sm uppercase focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium text-gray-500">Population</label>
<input
type="number"
bind:value={newPopulation}
placeholder="Optional"
class="rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
</div>
<div class="mt-4 flex gap-2">
<button
onclick={editing ? submitEdit : submitAdd}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
{editing ? 'Save changes' : 'Add city'}
</button>
<button
onclick={cancelForm}
class="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
Cancel
</button>
</div>
</div>
{/if}
<!-- Table -->
<div
class="max-h-[calc(100vh-320px)] overflow-x-auto overflow-y-auto rounded-lg border border-gray-200"
>
{#if loading}
<p class="p-8 text-center text-sm text-gray-500">Loading...</p>
{:else if items.length === 0}
<p class="p-8 text-center text-sm text-gray-500">No cities found.</p>
{:else}
<table class="min-w-full divide-y divide-gray-200">
<thead class="sticky top-0 bg-gray-50">
<tr>
<th
class="px-4 py-3 text-left text-xs font-semibold tracking-wide text-gray-500 uppercase"
>Name</th
>
<th
class="px-4 py-3 text-left text-xs font-semibold tracking-wide text-gray-500 uppercase"
>Country</th
>
<th
class="px-4 py-3 text-left text-xs font-semibold tracking-wide text-gray-500 uppercase"
>Code</th
>
<th
class="px-4 py-3 text-left text-xs font-semibold tracking-wide text-gray-500 uppercase"
>Population</th
>
<th
class="px-4 py-3 text-right text-xs font-semibold tracking-wide text-gray-500 uppercase"
>Actions</th
>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#each items as item (item.id)}
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 text-sm font-medium text-gray-900">{item.name}</td>
<td class="px-4 py-3 text-sm text-gray-600">{item.country}</td>
<td class="px-4 py-3">
<span class="rounded bg-gray-100 px-2 py-0.5 font-mono text-xs text-gray-700"
>{item.country_code}</span
>
</td>
<td class="px-4 py-3 text-sm text-gray-500">
{item.population != null ? item.population.toLocaleString() : '—'}
</td>
<td class="px-4 py-3 text-right">
<button
onclick={() => startEdit(item)}
class="text-sm text-blue-600 hover:text-blue-800"
>
Edit
</button>
<span class="mx-2 text-gray-300">·</span>
<button
onclick={() => remove(item.id)}
class="text-sm text-red-500 hover:text-red-700"
>
Delete
</button>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
</div>
</div>

View File

@@ -0,0 +1,264 @@
<script lang="ts">
import { base } from '$app/paths';
interface Country {
id: number;
name: string;
country_code: string;
}
let items = $state<Country[]>([]);
let loading = $state(true);
let search = $state('');
let adding = $state(false);
let editing = $state<Country | null>(null);
let newName = $state('');
let newCode = $state('');
let error = $state('');
let searchDebounce: ReturnType<typeof setTimeout>;
async function loadItems() {
loading = true;
try {
const params = search.trim() ? `?q=${encodeURIComponent(search.trim())}` : '';
const res = await fetch(`${base}/admin/api/countries${params}`);
items = await res.json();
} finally {
loading = false;
}
}
$effect(() => {
search;
clearTimeout(searchDebounce);
searchDebounce = setTimeout(loadItems, 200);
});
function startAdd() {
adding = true;
editing = null;
newName = '';
newCode = '';
error = '';
}
function startEdit(item: Country) {
editing = item;
adding = false;
newName = item.name;
newCode = item.country_code;
error = '';
}
function cancelForm() {
adding = false;
editing = null;
newName = '';
newCode = '';
error = '';
}
async function submitAdd() {
error = '';
if (!newName.trim() || !newCode.trim()) {
error = 'Name and country code are required';
return;
}
try {
const res = await fetch(`${base}/admin/api/countries`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName.trim(), country_code: newCode.trim().toUpperCase() })
});
if (!res.ok) {
const data = await res.json();
error = data.error ?? 'Failed to add';
return;
}
cancelForm();
loadItems();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to add';
}
}
async function submitEdit() {
if (!editing) return;
error = '';
if (!newName.trim() || !newCode.trim()) {
error = 'Name and country code are required';
return;
}
try {
const res = await fetch(`${base}/admin/api/countries`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: editing.id,
name: newName.trim(),
country_code: newCode.trim().toUpperCase()
})
});
if (!res.ok) {
const data = await res.json();
error = data.error ?? 'Failed to update';
return;
}
cancelForm();
loadItems();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update';
}
}
async function remove(id: number) {
if (!confirm('Delete this country?')) return;
try {
const res = await fetch(`${base}/admin/api/countries?id=${id}`, { method: 'DELETE' });
if (!res.ok) {
const data = await res.json();
alert(data.error ?? 'Failed to delete');
return;
}
cancelForm();
loadItems();
} catch (e) {
alert(e instanceof Error ? e.message : 'Failed to delete');
}
}
</script>
<svelte:head>
<title>Countries — Admin — Trips</title>
</svelte:head>
<div class="mx-auto max-w-3xl">
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">Countries</h1>
<p class="mt-1 text-sm text-gray-500">Manage the list of countries used across the app.</p>
</div>
<div class="flex flex-col gap-4">
<!-- Toolbar -->
<div class="flex items-center gap-3">
<input
type="text"
bind:value={search}
placeholder="Search countries..."
class="w-64 rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
<button
onclick={startAdd}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Add country
</button>
</div>
<!-- Inline form -->
{#if adding || editing}
<div class="rounded-lg border border-gray-200 bg-gray-50 p-5">
<h3 class="mb-4 text-sm font-semibold text-gray-700">
{editing ? 'Edit country' : 'Add country'}
</h3>
{#if error}
<p class="mb-3 text-sm text-red-600">{error}</p>
{/if}
<div class="grid grid-cols-[1fr_auto_auto] items-end gap-3">
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium text-gray-500"
>Name <span class="text-red-500">*</span></label
>
<input
type="text"
bind:value={newName}
placeholder="United States"
class="rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium text-gray-500"
>Code <span class="text-red-500">*</span></label
>
<input
type="text"
bind:value={newCode}
placeholder="US"
maxlength="2"
class="w-20 rounded-md border border-gray-300 px-3 py-2 text-sm uppercase focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div class="flex gap-2">
<button
onclick={editing ? submitEdit : submitAdd}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
{editing ? 'Save' : 'Add'}
</button>
<button
onclick={cancelForm}
class="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
Cancel
</button>
</div>
</div>
</div>
{/if}
<!-- Table -->
<div class="overflow-hidden rounded-lg border border-gray-200">
{#if loading}
<p class="p-8 text-center text-sm text-gray-500">Loading...</p>
{:else if items.length === 0}
<p class="p-8 text-center text-sm text-gray-500">No countries found.</p>
{:else}
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th
class="px-4 py-3 text-left text-xs font-semibold tracking-wide text-gray-500 uppercase"
>Name</th
>
<th
class="px-4 py-3 text-left text-xs font-semibold tracking-wide text-gray-500 uppercase"
>Code</th
>
<th
class="px-4 py-3 text-right text-xs font-semibold tracking-wide text-gray-500 uppercase"
>Actions</th
>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#each items as item (item.id)}
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 text-sm font-medium text-gray-900">{item.name}</td>
<td class="px-4 py-3">
<span class="rounded bg-gray-100 px-2 py-0.5 font-mono text-xs text-gray-700"
>{item.country_code}</span
>
</td>
<td class="px-4 py-3 text-right">
<button
onclick={() => startEdit(item)}
class="text-sm text-blue-600 hover:text-blue-800"
>
Edit
</button>
<span class="mx-2 text-gray-300">·</span>
<button
onclick={() => remove(item.id)}
class="text-sm text-red-500 hover:text-red-700"
>
Delete
</button>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
</div>
</div>

View File

@@ -0,0 +1,16 @@
<script lang="ts"></script>
<svelte:head>
<title>General — Admin — Trips</title>
</svelte:head>
<div class="mx-auto max-w-3xl">
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">General</h1>
<p class="mt-1 text-sm text-gray-500">General administration settings.</p>
</div>
<div class="rounded-lg border border-gray-200 bg-gray-50 p-6 text-sm text-gray-500">
Administration settings will go here.
</div>
</div>

View File

@@ -0,0 +1,487 @@
<script lang="ts">
import { base } from '$app/paths';
interface TourOperator {
id: number;
name: string;
website: string | null;
highlight_color: string | null;
}
// Known operators for autocomplete — same list used in the add modal
const KNOWN_OPERATORS = [
'AAT Kings',
'Abercrombie & Kent',
'Avalon Waterways',
'Brendan Vacations',
'Contiki',
'Cox & Kings',
'Crystal Cruises',
'Exodus Travels',
'G Adventures',
'Gate 1 Travel',
'Globus',
'Insight Vacations',
'Intrepid Travel',
'Monograms',
'Newmarket Holidays',
'Oceania Cruises',
'On The Go Tours',
'Overseas Adventure Travel',
'Regent Seven Seas',
'Road Scholar',
'Rocky Mountaineer',
'Scenic',
'Seabourn',
'Silversea',
'Tauck',
'Trafalgar',
'TUI',
'Uniworld Boutique River Cruises',
'Viking',
'Wendy Wu Tours'
];
// Derive logo filename slug from operator name
function logoSlug(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
}
function squareLogo(name: string): string {
return `${base}/package-tour-logos/${logoSlug(name)}_square.svg`;
}
function wideLogo(name: string): string {
return `${base}/package-tour-logos/${logoSlug(name)}_wide.svg`;
}
// Track which logo URLs actually exist (checked on render)
let logoCache = $state<Record<string, 'square' | 'wide' | 'none'>>({});
async function checkLogo(name: string) {
if (name in logoCache) return;
// Try square first, then wide
const sq = squareLogo(name);
const wd = wideLogo(name);
try {
const r = await fetch(sq, { method: 'HEAD' });
if (r.ok) { logoCache = { ...logoCache, [name]: 'square' }; return; }
} catch { /* ignore */ }
try {
const r = await fetch(wd, { method: 'HEAD' });
if (r.ok) { logoCache = { ...logoCache, [name]: 'wide' }; return; }
} catch { /* ignore */ }
logoCache = { ...logoCache, [name]: 'none' };
}
function getLogoUrl(name: string): string | null {
const t = logoCache[name];
if (t === 'square') return squareLogo(name);
if (t === 'wide') return wideLogo(name);
return null;
}
let items = $state<TourOperator[]>([]);
let loading = $state(true);
let search = $state('');
let adding = $state(false);
let editing = $state<TourOperator | null>(null);
let error = $state('');
let searchDebounce: ReturnType<typeof setTimeout>;
// Form fields
let newName = $state('');
let newWebsite = $state('');
let newColor = $state('#F59E0B');
// Operator name search dropdown
let nameQuery = $state('');
let showNameDropdown = $state(false);
let filteredOperators = $derived(
nameQuery.trim().length > 0
? KNOWN_OPERATORS.filter((op) => op.toLowerCase().includes(nameQuery.toLowerCase()))
: KNOWN_OPERATORS
);
async function loadItems() {
loading = true;
try {
const params = search.trim() ? `?q=${encodeURIComponent(search.trim())}` : '';
const res = await fetch(`${base}/admin/api/tour-operators${params}`);
items = await res.json();
// Check logos for all loaded items
for (const item of items) {
checkLogo(item.name);
}
} finally {
loading = false;
}
}
$effect(() => {
search;
clearTimeout(searchDebounce);
searchDebounce = setTimeout(loadItems, 200);
});
function startAdd() {
adding = true;
editing = null;
newName = '';
nameQuery = '';
newWebsite = '';
newColor = '#F59E0B';
error = '';
}
function startEdit(item: TourOperator) {
editing = item;
adding = false;
newName = item.name;
nameQuery = item.name;
newWebsite = item.website ?? '';
newColor = item.highlight_color ?? '#F59E0B';
error = '';
}
function cancelForm() {
adding = false;
editing = null;
newName = '';
nameQuery = '';
newWebsite = '';
newColor = '#F59E0B';
error = '';
showNameDropdown = false;
}
function selectOperator(name: string) {
newName = name;
nameQuery = name;
showNameDropdown = false;
checkLogo(name);
}
function onNameInput(value: string) {
newName = value;
nameQuery = value;
showNameDropdown = true;
}
async function submitAdd() {
error = '';
if (!newName.trim()) { error = 'Operator name is required'; return; }
try {
const res = await fetch(`${base}/admin/api/tour-operators`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: newName.trim(),
website: newWebsite.trim() || null,
highlight_color: newColor || null
})
});
if (!res.ok) {
const d = await res.json();
error = d.error ?? 'Failed to add';
return;
}
cancelForm();
loadItems();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to add';
}
}
async function submitEdit() {
if (!editing) return;
error = '';
if (!newName.trim()) { error = 'Operator name is required'; return; }
try {
const res = await fetch(`${base}/admin/api/tour-operators`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: editing.id,
name: newName.trim(),
website: newWebsite.trim() || null,
highlight_color: newColor || null
})
});
if (!res.ok) {
const d = await res.json();
error = d.error ?? 'Failed to update';
return;
}
cancelForm();
loadItems();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update';
}
}
async function remove(id: number, name: string) {
if (!confirm(`Delete "${name}"?`)) return;
try {
const res = await fetch(`${base}/admin/api/tour-operators?id=${id}`, { method: 'DELETE' });
if (!res.ok) {
const d = await res.json();
alert(d.error ?? 'Failed to delete');
return;
}
cancelForm();
loadItems();
} catch (e) {
alert(e instanceof Error ? e.message : 'Failed to delete');
}
}
// Preview logo for the name currently being typed in the form
let previewLogoChecked = $state(false);
$effect(() => {
if (newName && !previewLogoChecked) {
checkLogo(newName);
previewLogoChecked = true;
}
if (!newName) previewLogoChecked = false;
});
</script>
<svelte:head>
<title>Tour Operators — Admin — Trips</title>
</svelte:head>
<div class="mx-auto max-w-4xl">
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">Tour Operators</h1>
<p class="mt-1 text-sm text-gray-500">
Manage known tour operators, their websites, and highlight colours for plan cards.
</p>
</div>
<div class="flex flex-col gap-4">
<!-- Toolbar -->
<div class="flex items-center gap-3">
<input
type="text"
bind:value={search}
placeholder="Search operators..."
class="w-64 rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
<button
onclick={startAdd}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Add operator
</button>
</div>
<!-- Inline form -->
{#if adding || editing}
<div class="rounded-lg border border-gray-200 bg-gray-50 p-5">
<h3 class="mb-4 text-sm font-semibold text-gray-700">
{editing ? 'Edit operator' : 'Add operator'}
</h3>
{#if error}
<p class="mb-3 text-sm text-red-600">{error}</p>
{/if}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-[1fr_1fr_auto_auto]">
<!-- Operator name with dropdown -->
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium text-gray-500">Operator name <span class="text-red-500">*</span></label>
<div class="relative">
<input
type="text"
value={nameQuery}
oninput={(e) => onNameInput(e.currentTarget.value)}
onfocus={() => (showNameDropdown = true)}
onblur={() => setTimeout(() => (showNameDropdown = false), 150)}
placeholder="Search or type operator name"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
{#if showNameDropdown && filteredOperators.length > 0}
<ul class="absolute z-10 mt-1 max-h-52 w-full overflow-auto rounded-md border border-gray-200 bg-white shadow-lg">
{#each filteredOperators as op}
<li>
<button
type="button"
onclick={() => selectOperator(op)}
class="flex w-full items-center gap-3 px-3 py-2 text-left text-sm hover:bg-gray-50"
>
{#if logoCache[op] && logoCache[op] !== 'none'}
<img
src={getLogoUrl(op)}
alt={op}
class="h-5 w-5 shrink-0 object-contain"
/>
{:else}
<div class="h-5 w-5 shrink-0 rounded bg-amber-100 flex items-center justify-center">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#F59E0B" stroke-width="2">
<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"/>
</svg>
</div>
{/if}
{op}
</button>
</li>
{/each}
</ul>
{/if}
</div>
</div>
<!-- Website -->
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium text-gray-500">Website</label>
<input
type="url"
bind:value={newWebsite}
placeholder="https://www.example.com"
class="rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<!-- Colour picker -->
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium text-gray-500">Highlight colour</label>
<div class="flex items-center gap-2">
<input
type="color"
bind:value={newColor}
class="h-[38px] w-12 cursor-pointer rounded-md border border-gray-300 p-0.5"
/>
<input
type="text"
bind:value={newColor}
placeholder="#F59E0B"
maxlength="7"
class="w-24 rounded-md border border-gray-300 px-3 py-2 font-mono text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
</div>
<!-- Logo preview -->
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium text-gray-500">Logo preview</span>
<div class="flex h-[38px] w-16 items-center justify-center rounded-md border border-gray-200 bg-white">
{#if newName && logoCache[newName] && logoCache[newName] !== 'none'}
<img src={getLogoUrl(newName)} alt={newName} class="h-8 w-12 object-contain" />
{:else}
<span class="text-xs text-gray-400">None</span>
{/if}
</div>
</div>
</div>
<div class="mt-4 flex gap-2">
<button
onclick={editing ? submitEdit : submitAdd}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
{editing ? 'Save changes' : 'Add operator'}
</button>
<button
onclick={cancelForm}
class="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
Cancel
</button>
</div>
</div>
{/if}
<!-- Operator cards -->
{#if loading}
<p class="py-8 text-center text-sm text-gray-500">Loading...</p>
{:else if items.length === 0}
<div class="rounded-lg border border-dashed border-gray-300 p-10 text-center">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="mx-auto mb-3 text-gray-300">
<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>
<p class="text-sm text-gray-500">No tour operators yet. Add one to get started.</p>
</div>
{:else}
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{#each items as item (item.id)}
{@const logoType = logoCache[item.name]}
{@const logoUrl = getLogoUrl(item.name)}
{@const borderColor = item.highlight_color ?? '#E5E7EB'}
<div
class="group relative flex flex-col gap-3 rounded-xl border-2 bg-white p-4 shadow-sm transition-shadow hover:shadow-md"
style="border-color: {borderColor}"
>
<!-- Logo + name -->
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-gray-100 bg-gray-50">
{#if logoType && logoType !== 'none' && logoUrl}
<img src={logoUrl} alt={item.name} class="h-8 w-8 object-contain" />
{:else}
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="{borderColor}" 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>
{/if}
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-gray-900">{item.name}</p>
{#if item.website}
<a
href={item.website}
target="_blank"
rel="noopener noreferrer"
class="truncate text-xs text-blue-600 hover:text-blue-800"
>
{item.website.replace(/^https?:\/\//, '')}
</a>
{:else}
<p class="text-xs text-gray-400">No website</p>
{/if}
</div>
</div>
<!-- Colour swatch -->
<div class="flex items-center gap-2">
<div
class="h-4 w-4 rounded-full border border-gray-200"
style="background-color: {borderColor}"
></div>
<span class="font-mono text-xs text-gray-500">{borderColor}</span>
{#if logoType === 'square' || logoType === 'wide'}
<span class="ml-auto rounded-full bg-green-50 px-2 py-0.5 text-xs font-medium text-green-700">
{logoType} logo
</span>
{:else if logoType === 'none'}
<span class="ml-auto rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-500">
No logo
</span>
{/if}
</div>
<!-- Actions -->
<div class="flex gap-2 border-t border-gray-100 pt-3">
<button
onclick={() => startEdit(item)}
class="flex-1 rounded-md border border-gray-200 px-3 py-1.5 text-xs font-medium text-gray-600 hover:bg-gray-50 hover:text-gray-900"
>
Edit
</button>
<button
onclick={() => remove(item.id, item.name)}
class="rounded-md border border-red-100 px-3 py-1.5 text-xs font-medium text-red-500 hover:bg-red-50 hover:text-red-700"
>
Delete
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>

View File

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

View File

@@ -9,6 +9,11 @@ import {
} from '$lib/server/travellers.js';
import { createFlight, updateFlight, getFlightBookingsForTrip } from '$lib/server/flights.js';
import { createLodging, updateLodging, getLodgingsForTrip } from '$lib/server/lodgings.js';
import {
createPackageTour,
updatePackageTour,
getPackageToursForTrip
} from '$lib/server/package-tours.js';
import type { PageServerLoad, Actions } from './$types';
export const load: PageServerLoad = async (event) => {
@@ -24,6 +29,7 @@ export const load: PageServerLoad = async (event) => {
const people = getPeopleForUser(userId);
const flightBookings = getFlightBookingsForTrip(trip.id, userId);
const lodgings = getLodgingsForTrip(trip.id, userId);
const packageTours = getPackageToursForTrip(trip.id, userId);
return {
trip,
@@ -33,7 +39,8 @@ export const load: PageServerLoad = async (event) => {
travellerCount: travellers.length,
people,
flightBookings,
lodgings
lodgings,
packageTours
};
};
@@ -184,6 +191,7 @@ export const actions: Actions = {
if (!trip) return fail(404, { error: 'Trip not found' });
const data = await event.request.formData();
const parentPlanId = (data.get('parent_plan_id') as string)?.trim() || undefined;
const confirmationNumber = (data.get('confirmation_number') as string)?.trim() || undefined;
const priceRaw = (data.get('price') as string)?.trim();
const price = priceRaw ? parseFloat(priceRaw) : undefined;
@@ -282,6 +290,7 @@ export const actions: Actions = {
createFlight({
tripId: trip.id,
userId,
parentId: parentPlanId,
confirmationNumber,
price,
currency,
@@ -430,6 +439,7 @@ export const actions: Actions = {
const v = str(key);
return v ? parseFloat(v) : undefined;
};
const parentPlanId = str('parent_plan_id');
const status = data.get('status') as string as 'idea' | 'tentative' | 'confirmed';
const guestIds = data.getAll('guest_ids[]') as string[];
@@ -437,6 +447,7 @@ export const actions: Actions = {
createLodging({
tripId: trip.id,
userId,
parentId: parentPlanId,
status: ['idea', 'tentative', 'confirmed'].includes(status) ? status : 'idea',
name,
chain: str('chain'),
@@ -518,5 +529,94 @@ export const actions: Actions = {
} catch (err) {
return fail(400, { error: err instanceof Error ? err.message : 'Failed to update lodging' });
}
},
addPackageTour: 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 operatorName = (data.get('operator_name') as string)?.trim();
if (!operatorName) return fail(400, { error: 'Operator 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 travellerIds = data.getAll('traveller_ids[]') as string[];
try {
createPackageTour({
tripId: trip.id,
userId,
status: ['idea', 'tentative', 'confirmed'].includes(status) ? status : 'idea',
operatorName,
confirmationNumber: str('confirmation_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'),
price: num('price'),
currency: str('currency') ?? 'USD',
travellerIds: travellerIds.length > 0 ? travellerIds : undefined
});
return { success: true };
} catch (err) {
return fail(400, { error: err instanceof Error ? err.message : 'Failed to create tour' });
}
},
editPackageTour: 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 tourId = (data.get('tour_id') as string)?.trim();
if (!tourId) return fail(400, { error: 'Tour ID is required' });
const operatorName = (data.get('operator_name') as string)?.trim();
if (!operatorName) return fail(400, { error: 'Operator 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 travellerIds = data.getAll('traveller_ids[]') as string[];
try {
updatePackageTour({
tourId,
userId,
status: ['idea', 'tentative', 'confirmed'].includes(status) ? status : 'idea',
operatorName,
confirmationNumber: str('confirmation_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'),
price: num('price'),
currency: str('currency') ?? 'USD',
travellerIds: travellerIds.length > 0 ? travellerIds : undefined
});
return { success: true };
} catch (err) {
return fail(400, { error: err instanceof Error ? err.message : 'Failed to update tour' });
}
}
};

View File

@@ -7,6 +7,9 @@
import EditFlightModal from '$lib/components/EditFlightModal.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 PackageTourCard from '$lib/components/PackageTourCard.svelte';
import PlanCard from '$lib/components/PlanCard.svelte';
import FlightCard from '$lib/components/FlightCard.svelte';
import LodgingCard from '$lib/components/LodgingCard.svelte';
@@ -22,17 +25,21 @@
let tripTravellerIds = $derived(travellers.map((t) => t.id));
let flightBookings = $derived(data.flightBookings ?? []);
let lodgings = $derived(data.lodgings ?? []);
let packageTours = $derived(data.packageTours ?? []);
let editing = $state(false);
let showAddDestination = $state(false);
let showAddTraveller = $state(false);
let showAddFlight = $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 editingLodging = $state<(typeof lodgings)[0] | null>(null);
let editingTour = $state<(typeof packageTours)[0] | null>(null);
let addingChildToPlanId = $state<string | null>(null);
const menuItems = [
{
@@ -77,6 +84,7 @@
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;
}
},
@@ -119,9 +127,13 @@
/>
<AddFlightModal
open={showAddFlight}
onclose={() => (showAddFlight = false)}
onclose={() => {
showAddFlight = false;
addingChildToPlanId = null;
}}
{people}
{tripTravellerIds}
parentPlanId={addingChildToPlanId ?? undefined}
/>
<EditFlightModal
open={!!editingFlight}
@@ -133,9 +145,13 @@
/>
<AddLodgingModal
open={showAddLodging}
onclose={() => (showAddLodging = false)}
onclose={() => {
showAddLodging = false;
addingChildToPlanId = null;
}}
{people}
{tripTravellerIds}
parentPlanId={addingChildToPlanId ?? undefined}
/>
<EditLodgingModal
open={!!editingLodging}
@@ -144,6 +160,19 @@
{people}
{tripTravellerIds}
/>
<AddPackageTourModal
open={showAddPackageTour}
onclose={() => (showAddPackageTour = false)}
{people}
{tripTravellerIds}
/>
<EditPackageTourModal
open={!!editingTour}
tour={editingTour}
onclose={() => (editingTour = null)}
{people}
{tripTravellerIds}
/>
<div class="mx-auto max-w-2xl">
<!-- Header -->
@@ -546,6 +575,54 @@
</div>
</div>
{/if}
<!-- Package Tours section -->
{#if packageTours.length > 0}
<div class="mt-8">
<h2 class="mb-3 text-sm font-semibold tracking-wider text-gray-400 uppercase">
Package Tours
</h2>
<div class="flex flex-col gap-3">
{#each packageTours as tour (tour.id)}
{@const plan = plans.find((p) => p.id === tour.plan_id)}
{#if plan}
{@const formId = `remove-plan-${plan.id}`}
{@const submitForm = () => {
const f = document.getElementById(formId) as HTMLFormElement;
f?.requestSubmit();
}}
<form
id={formId}
method="POST"
action="?/removePlan"
use:enhance={() => {
return ({ update }) => {
update();
};
}}
class="contents"
>
<input type="hidden" name="plan_id" value={plan.id} />
<PackageTourCard
{plan}
{tour}
onEdit={() => (editingTour = tour)}
onDelete={submitForm}
onAddFlight={() => {
addingChildToPlanId = plan.id;
showAddFlight = true;
}}
onAddLodging={() => {
addingChildToPlanId = plan.id;
showAddLodging = true;
}}
/>
</form>
{/if}
{/each}
</div>
</div>
{/if}
{/if}
{/if}
</div>