fix(edge-fn): replace getClaims with adminClient.auth.getUser(token)

fix(edge-fn): use user.id instead of claims.sub; fixes 500s and false cert_required
fix(migrations): drop broad reservations SELECT policy; add reservation_slots view with security_invoker=false
fix(tests): correct weekSlot() keys from start/end to start_time/end_time
fix(tests): spread overlap test slots across separate ISO weeks
fix(tests): update e2e assertion to match actual authenticated home text
fix(app): hide IonMenu before user is authenticated
feat(dx): add test:all script running unit, integration, and e2e in sequence
docs(claude-md): document SELinux fix, Edge Function auth pattern, security_invoker behaviour
This commit is contained in:
2026-04-20 14:32:37 -04:00
parent d07a02c9dc
commit 108c042921
33 changed files with 2745 additions and 12 deletions

278
app/pages/admin/config.vue Normal file
View File

@@ -0,0 +1,278 @@
<template>
<IonPage>
<IonHeader>
<IonToolbar color="primary">
<IonButtons slot="start">
<IonMenuButton />
</IonButtons>
<IonTitle>Booking Rules</IonTitle>
<IonButtons slot="end">
<IonButton :disabled="!dirty || saving" @click="saveAll">
<IonSpinner v-if="saving" name="crescent" slot="icon-only" />
<IonIcon v-else slot="icon-only" :icon="saveOutline" />
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent class="ion-padding">
<div v-if="loading" class="ion-text-center ion-padding">
<IonSpinner name="crescent" />
</div>
<template v-else>
<IonCard>
<IonCardHeader>
<IonCardTitle>Weekly Limits</IonCardTitle>
</IonCardHeader>
<IonCardContent>
<IonList lines="full">
<IonItem>
<IonLabel>
<h3>Max sessions per week</h3>
<p>Pre-booked sessions allowed per member per ISO week (MonSun)</p>
</IonLabel>
<IonInput
slot="end"
type="number"
:value="local.max_sessions_per_week"
class="config-input"
:min="1"
:max="14"
@ion-input="set('max_sessions_per_week', $event.detail.value)"
/>
</IonItem>
</IonList>
</IonCardContent>
</IonCard>
<IonCard>
<IonCardHeader>
<IonCardTitle>Weekend / Holiday Limits</IonCardTitle>
</IonCardHeader>
<IonCardContent>
<IonList lines="full">
<IonItem>
<IonLabel>
<h3>Max weekend sessions per period</h3>
<p>Max Sat/Sun/holiday pre-bookings per alternating period</p>
</IonLabel>
<IonInput
slot="end"
type="number"
:value="local.max_weekend_sessions_per_period"
class="config-input"
:min="1"
:max="10"
@ion-input="set('max_weekend_sessions_per_period', $event.detail.value)"
/>
</IonItem>
<IonItem>
<IonLabel>
<h3>Weekend period length (weeks)</h3>
<p>Number of weeks in each alternating period (default: 2 = every other weekend)</p>
</IonLabel>
<IonInput
slot="end"
type="number"
:value="local.weekend_period_weeks"
class="config-input"
:min="1"
:max="8"
@ion-input="set('weekend_period_weeks', $event.detail.value)"
/>
</IonItem>
</IonList>
</IonCardContent>
</IonCard>
<IonCard>
<IonCardHeader>
<IonCardTitle>Open Session Window</IonCardTitle>
</IonCardHeader>
<IonCardContent>
<IonList lines="full">
<IonItem>
<IonLabel>
<h3>Advance hours for open sessions</h3>
<p>Pre-booking limits are waived for sessions starting within this many hours</p>
</IonLabel>
<IonInput
slot="end"
type="number"
:value="local.open_session_advance_hours"
class="config-input"
:min="1"
:max="72"
@ion-input="set('open_session_advance_hours', $event.detail.value)"
/>
</IonItem>
</IonList>
</IonCardContent>
</IonCard>
<IonCard>
<IonCardHeader>
<IonCardTitle>Holidays</IonCardTitle>
<IonCardSubtitle>Counted as weekend days for booking limits</IonCardSubtitle>
</IonCardHeader>
<IonCardContent>
<IonList lines="full">
<IonItem v-for="h in holidays" :key="h.date">
<IonLabel>
<h3>{{ h.name }}</h3>
<p>{{ h.date }}</p>
</IonLabel>
<IonButton slot="end" fill="clear" color="danger" @click="deleteHoliday(h.date)">
<IonIcon slot="icon-only" :icon="trashOutline" />
</IonButton>
</IonItem>
</IonList>
<div class="add-holiday-row">
<IonInput v-model="newHoliday.date" type="date" placeholder="Date" class="holiday-date-input" />
<IonInput v-model="newHoliday.name" placeholder="Name (e.g. Canada Day)" class="holiday-name-input" />
<IonButton @click="addHoliday" :disabled="!newHoliday.date || !newHoliday.name">
<IonIcon slot="icon-only" :icon="addOutline" />
</IonButton>
</div>
</IonCardContent>
</IonCard>
</template>
<IonToast
v-model:is-open="toast.show"
:message="toast.message"
:color="toast.color"
:duration="2500"
position="bottom"
/>
</IonContent>
</IonPage>
</template>
<script setup lang="ts">
import {
IonPage, IonHeader, IonToolbar, IonTitle, IonContent, IonButtons,
IonMenuButton, IonButton, IonIcon, IonCard, IonCardHeader, IonCardTitle,
IonCardSubtitle, IonCardContent, IonList, IonItem, IonLabel, IonInput,
IonSpinner, IonToast,
} from '@ionic/vue'
import { saveOutline, trashOutline, addOutline } from 'ionicons/icons'
import type { Database } from '~/types/supabase'
type ConfigKey = 'max_sessions_per_week' | 'max_weekend_sessions_per_period' | 'weekend_period_weeks' | 'open_session_advance_hours'
type Holiday = { date: string; name: string }
definePageMeta({ layout: false, middleware: ['auth'] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const supabase = useSupabaseClient() as any
const loading = ref(true)
const saving = ref(false)
const dirty = ref(false)
const toast = reactive({ show: false, message: '', color: 'success' })
const holidays = ref<Holiday[]>([])
const newHoliday = reactive({ date: '', name: '' })
const local = reactive<Record<ConfigKey, number>>({
max_sessions_per_week: 2,
max_weekend_sessions_per_period: 1,
weekend_period_weeks: 2,
open_session_advance_hours: 24,
})
const original = reactive<Record<ConfigKey, number>>({ ...local })
onMounted(async () => {
await Promise.all([fetchConfig(), fetchHolidays()])
loading.value = false
})
async function fetchConfig() {
const { data } = await supabase
.from('booking_config')
.select('key, value')
for (const row of (data ?? []) as { key: string; value: unknown }[]) {
const k = row.key as ConfigKey
if (k in local) {
local[k] = Number(row.value)
original[k] = Number(row.value)
}
}
}
async function fetchHolidays() {
const { data } = await supabase
.from('holidays')
.select('date, name')
.order('date', { ascending: true })
holidays.value = (data ?? []) as Holiday[]
}
function set(key: ConfigKey, value: string | number | null | undefined) {
const n = Number(value)
if (!isNaN(n) && n > 0) {
local[key] = n
dirty.value = true
}
}
async function saveAll() {
saving.value = true
const upserts = (Object.keys(local) as ConfigKey[]).map(k => ({ key: k, value: local[k] }))
const { error } = await supabase
.from('booking_config')
.upsert(upserts, { onConflict: 'key' })
saving.value = false
if (error) { showToast('Save failed: ' + error.message, 'danger'); return }
Object.assign(original, local)
dirty.value = false
showToast('Booking rules saved', 'success')
}
async function addHoliday() {
const { error } = await supabase
.from('holidays')
.insert({ date: newHoliday.date, name: newHoliday.name })
if (error) { showToast('Failed: ' + error.message, 'danger'); return }
newHoliday.date = ''
newHoliday.name = ''
await fetchHolidays()
showToast('Holiday added', 'success')
}
async function deleteHoliday(date: string) {
const { error } = await supabase
.from('holidays')
.delete()
.eq('date', date)
if (error) { showToast('Failed: ' + error.message, 'danger'); return }
await fetchHolidays()
showToast('Holiday removed', 'success')
}
function showToast(message: string, color: string) {
toast.message = message
toast.color = color
toast.show = true
}
</script>
<style scoped>
.config-input {
text-align: right;
max-width: 80px;
}
.add-holiday-row {
display: flex;
gap: 0.5rem;
align-items: center;
margin-top: 0.75rem;
}
.holiday-date-input { flex: 0 0 160px; }
.holiday-name-input { flex: 1; }
</style>

View File

@@ -0,0 +1,351 @@
<template>
<IonPage>
<IonHeader>
<IonToolbar color="primary">
<IonButtons slot="start">
<IonMenuButton />
</IonButtons>
<IonTitle>Manage Slots</IonTitle>
<IonButtons slot="end">
<IonButton @click="showAddSlot = true">
<IonIcon slot="icon-only" :icon="addOutline" />
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent class="ion-padding">
<!-- Date selector -->
<h3 class="section-title">Date</h3>
<div class="date-strip">
<button
v-for="d in dateOptions"
:key="d.iso"
class="date-chip"
:class="{ active: selectedDate === d.iso }"
@click="selectedDate = d.iso"
>
<span class="day-name">{{ d.dayName }}</span>
<span class="day-num">{{ d.dayNum }}</span>
<span class="month">{{ d.month }}</span>
</button>
</div>
<div v-if="loading" class="ion-text-center ion-padding">
<IonSpinner name="crescent" />
</div>
<template v-else>
<IonCard v-for="boat in boats" :key="boat.id" class="boat-card">
<IonCardHeader>
<div class="boat-header">
<div>
<IonCardTitle>{{ boat.display_name || boat.name }}</IonCardTitle>
<IonCardSubtitle v-if="boat.class">{{ boat.class }}</IonCardSubtitle>
</div>
<IonToggle
:checked="boat.booking_available"
:enable-on-off-labels="true"
color="success"
@ion-change="toggleBoatAvailability(boat, $event.detail.checked)"
/>
</div>
</IonCardHeader>
<IonCardContent>
<p v-if="!boat.booking_available" class="out-of-service">
<IonIcon :icon="constructOutline" /> Out of service
</p>
<div class="apply-template-row">
<IonSelect
placeholder="Apply template..."
interface="action-sheet"
@ion-change="applyTemplate(boat.id, $event.detail.value)"
>
<IonSelectOption v-for="t in templates" :key="t.id" :value="t.id">
{{ t.name }}
</IonSelectOption>
</IonSelect>
</div>
<p v-if="slotsByBoat[boat.id]?.length === 0" class="empty-text">No slots for this date.</p>
<IonList v-else lines="full">
<IonItem v-for="slot in slotsByBoat[boat.id]" :key="slot.id">
<IonIcon slot="start" :icon="timeOutline" />
<IonLabel>{{ formatTime(slot.start_time) }} {{ formatTime(slot.end_time) }}</IonLabel>
<IonButton
slot="end"
fill="clear"
color="danger"
@click="deleteSlot(slot.id)"
>
<IonIcon slot="icon-only" :icon="trashOutline" />
</IonButton>
</IonItem>
</IonList>
</IonCardContent>
</IonCard>
</template>
<!-- Add slot modal -->
<IonModal :is-open="showAddSlot" @did-dismiss="showAddSlot = false">
<IonHeader>
<IonToolbar>
<IonTitle>Add Slot</IonTitle>
<IonButtons slot="end">
<IonButton @click="showAddSlot = false">Cancel</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent class="ion-padding">
<IonList lines="full">
<IonItem>
<IonLabel position="stacked">Boat</IonLabel>
<IonSelect v-model="newSlot.boatId" placeholder="Select boat" interface="action-sheet">
<IonSelectOption v-for="b in boats" :key="b.id" :value="b.id">
{{ b.display_name || b.name }}
</IonSelectOption>
</IonSelect>
</IonItem>
<IonItem>
<IonLabel position="stacked">Start Time</IonLabel>
<IonInput v-model="newSlot.startTime" type="time" />
</IonItem>
<IonItem>
<IonLabel position="stacked">End Time</IonLabel>
<IonInput v-model="newSlot.endTime" type="time" />
</IonItem>
</IonList>
<IonButton
expand="block"
class="ion-margin-top"
:disabled="!newSlot.boatId || !newSlot.startTime || !newSlot.endTime || addingSlot"
@click="addSlot"
>
<IonSpinner v-if="addingSlot" name="crescent" slot="start" />
Add Slot
</IonButton>
</IonContent>
</IonModal>
<IonToast
v-model:is-open="toast.show"
:message="toast.message"
:color="toast.color"
:duration="2500"
position="bottom"
/>
</IonContent>
</IonPage>
</template>
<script setup lang="ts">
import {
IonPage, IonHeader, IonToolbar, IonTitle, IonContent, IonButtons,
IonMenuButton, IonButton, IonIcon, IonCard, IonCardHeader, IonCardTitle,
IonCardSubtitle, IonCardContent, IonList, IonItem, IonLabel, IonToggle,
IonSelect, IonSelectOption, IonSpinner, IonModal, IonInput, IonToast,
} from '@ionic/vue'
import {
addOutline, timeOutline, trashOutline, constructOutline,
} from 'ionicons/icons'
import type { Database, TimeTuple } from '~/types/supabase'
type Boat = Database['public']['Tables']['boats']['Row']
type Interval = Database['public']['Tables']['intervals']['Row']
type Template = Database['public']['Tables']['interval_templates']['Row']
definePageMeta({ layout: false, middleware: ['auth'] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const supabase = useSupabaseClient() as any
const loading = ref(false)
const addingSlot = ref(false)
const showAddSlot = ref(false)
const selectedDate = ref(todayIso())
const boats = ref<Boat[]>([])
const templates = ref<Template[]>([])
const slotsByBoat = ref<Record<string, Interval[]>>({})
const toast = reactive({ show: false, message: '', color: 'success' })
const newSlot = reactive({ boatId: '', startTime: '08:00', endTime: '12:00' })
const dateOptions = computed(() => {
const out = []
const base = new Date()
for (let i = 0; i < 14; i++) {
const d = new Date(base)
d.setDate(base.getDate() + i)
out.push({
iso: toIso(d),
dayName: d.toLocaleDateString('en-CA', { weekday: 'short' }),
dayNum: d.getDate(),
month: d.toLocaleDateString('en-CA', { month: 'short' }),
})
}
return out
})
onMounted(async () => {
await Promise.all([fetchBoats(), fetchTemplates()])
await fetchSlots()
})
watch(selectedDate, fetchSlots)
async function fetchBoats() {
const { data } = await supabase.from('boats').select('*').order('name')
boats.value = data ?? []
}
async function fetchTemplates() {
const { data } = await supabase.from('interval_templates').select('*').order('name')
templates.value = data ?? []
}
async function fetchSlots() {
loading.value = true
const dayStart = selectedDate.value + 'T00:00:00Z'
const dayEnd = selectedDate.value + 'T23:59:59Z'
const { data } = await supabase
.from('intervals')
.select('*')
.gte('start_time', dayStart)
.lte('start_time', dayEnd)
.order('start_time', { ascending: true })
const grouped: Record<string, Interval[]> = {}
for (const boat of boats.value) grouped[boat.id] = []
for (const slot of (data ?? []) as Interval[]) {
if (!grouped[slot.boat_id]) grouped[slot.boat_id] = []
grouped[slot.boat_id]!.push(slot)
}
slotsByBoat.value = grouped
loading.value = false
}
async function addSlot() {
addingSlot.value = true
const start = new Date(`${selectedDate.value}T${newSlot.startTime}:00`)
const end = new Date(`${selectedDate.value}T${newSlot.endTime}:00`)
const { error } = await supabase.from('intervals').insert({
boat_id: newSlot.boatId,
start_time: start.toISOString(),
end_time: end.toISOString(),
})
addingSlot.value = false
if (error) {
showToast('Failed to add slot: ' + error.message, 'danger')
return
}
showAddSlot.value = false
newSlot.boatId = ''
await fetchSlots()
showToast('Slot added', 'success')
}
async function deleteSlot(id: string) {
const { error } = await supabase.from('intervals').delete().eq('id', id)
if (error) { showToast('Failed to delete: ' + error.message, 'danger'); return }
await fetchSlots()
showToast('Slot removed', 'success')
}
async function toggleBoatAvailability(boat: Boat, available: boolean) {
const { error } = await supabase
.from('boats')
.update({ booking_available: available })
.eq('id', boat.id)
if (error) { showToast('Update failed: ' + error.message, 'danger'); return }
boat.booking_available = available
showToast(available ? 'Boat back in service' : 'Boat out of service', available ? 'success' : 'warning')
}
async function applyTemplate(boatId: string, templateId: string) {
const template = templates.value.find(t => t.id === templateId)
if (!template) return
const inserts = (template.time_tuples as TimeTuple[]).map(([start, end]) => ({
boat_id: boatId,
start_time: new Date(`${selectedDate.value}T${start}:00`).toISOString(),
end_time: new Date(`${selectedDate.value}T${end}:00`).toISOString(),
}))
const { error } = await supabase.from('intervals').insert(inserts)
if (error) { showToast('Failed to apply template: ' + error.message, 'danger'); return }
await fetchSlots()
showToast(`Template "${template.name}" applied`, 'success')
}
function showToast(message: string, color: string) {
toast.message = message
toast.color = color
toast.show = true
}
function todayIso() { return toIso(new Date()) }
function toIso(d: Date) { return d.toISOString().slice(0, 10) }
function formatTime(iso: string) {
return new Date(iso).toLocaleTimeString('en-CA', { hour: '2-digit', minute: '2-digit', hour12: false })
}
</script>
<style scoped>
.section-title { font-size: 1rem; font-weight: 600; margin: 0 0 0.5rem; }
.date-strip {
display: flex;
gap: 0.5rem;
overflow-x: auto;
padding-bottom: 0.5rem;
scrollbar-width: none;
margin-bottom: 1rem;
}
.date-strip::-webkit-scrollbar { display: none; }
.date-chip {
display: flex;
flex-direction: column;
align-items: center;
min-width: 52px;
padding: 0.4rem 0.6rem;
border: 1px solid var(--ion-color-light-shade);
border-radius: 0.5rem;
background: var(--ion-color-light);
cursor: pointer;
font-size: 0.75rem;
gap: 2px;
flex-shrink: 0;
}
.date-chip.active {
background: var(--ion-color-primary);
color: white;
border-color: var(--ion-color-primary);
}
.day-name { font-weight: 600; text-transform: uppercase; font-size: 0.65rem; }
.day-num { font-size: 1.1rem; font-weight: 700; }
.month { font-size: 0.65rem; }
.boat-card { margin: 0 0 1rem; }
.boat-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.out-of-service {
display: flex;
align-items: center;
gap: 0.4rem;
color: var(--ion-color-danger);
font-size: 0.85rem;
margin-bottom: 0.5rem;
}
.apply-template-row { margin-bottom: 0.75rem; }
.empty-text { color: var(--ion-color-medium); font-size: 0.9rem; }
</style>

View File

@@ -0,0 +1,262 @@
<template>
<IonPage>
<IonHeader>
<IonToolbar color="primary">
<IonButtons slot="start">
<IonMenuButton />
</IonButtons>
<IonTitle>Interval Templates</IonTitle>
<IonButtons slot="end">
<IonButton @click="openCreate">
<IonIcon slot="icon-only" :icon="addOutline" />
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent class="ion-padding">
<div v-if="loading" class="ion-text-center ion-padding">
<IonSpinner name="crescent" />
</div>
<p v-else-if="templates.length === 0" class="empty-text">No templates yet. Create one to get started.</p>
<IonCard v-else v-for="t in templates" :key="t.id" class="template-card">
<IonCardHeader>
<div class="template-header">
<IonCardTitle>{{ t.name }}</IonCardTitle>
<div class="header-actions">
<IonButton fill="clear" @click="openEdit(t)">
<IonIcon slot="icon-only" :icon="pencilOutline" />
</IonButton>
<IonButton fill="clear" color="danger" @click="confirmDelete(t)">
<IonIcon slot="icon-only" :icon="trashOutline" />
</IonButton>
</div>
</div>
</IonCardHeader>
<IonCardContent>
<div class="time-tuple-list">
<span v-for="(tuple, i) in (t.time_tuples as TimeTuple[])" :key="i" class="time-tuple-chip">
{{ tuple[0] }}{{ tuple[1] }}
</span>
</div>
</IonCardContent>
</IonCard>
<!-- Create/Edit modal -->
<IonModal :is-open="showModal" @did-dismiss="closeModal">
<IonHeader>
<IonToolbar>
<IonTitle>{{ editing ? 'Edit Template' : 'New Template' }}</IonTitle>
<IonButtons slot="end">
<IonButton @click="closeModal">Cancel</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent class="ion-padding">
<IonList lines="full">
<IonItem>
<IonLabel position="stacked">Template Name</IonLabel>
<IonInput v-model="form.name" placeholder="e.g. Weekday Standard" />
</IonItem>
</IonList>
<h4 class="section-title ion-margin-top">Time Slots</h4>
<div
v-for="(tuple, i) in form.tuples"
:key="i"
class="tuple-row"
>
<IonInput
class="time-input"
type="time"
:value="tuple[0]"
@ion-input="tuple[0] = ($event.detail.value as string)"
/>
<span class="tuple-dash"></span>
<IonInput
class="time-input"
type="time"
:value="tuple[1]"
@ion-input="tuple[1] = ($event.detail.value as string)"
/>
<IonButton fill="clear" color="danger" @click="removeTuple(i)">
<IonIcon slot="icon-only" :icon="closeOutline" />
</IonButton>
</div>
<IonButton expand="block" fill="outline" class="ion-margin-top" @click="addTuple">
<IonIcon slot="start" :icon="addOutline" />
Add Time Slot
</IonButton>
<IonButton
expand="block"
class="ion-margin-top"
:disabled="!form.name || form.tuples.length === 0 || saving"
@click="saveTemplate"
>
<IonSpinner v-if="saving" name="crescent" slot="start" />
Save Template
</IonButton>
</IonContent>
</IonModal>
<IonAlert
:is-open="deleteAlert.show"
header="Delete Template"
:message="`Delete '${deleteAlert.name}'? This cannot be undone.`"
:buttons="[
{ text: 'Cancel', role: 'cancel', handler: () => { deleteAlert.show = false } },
{ text: 'Delete', role: 'destructive', handler: () => deleteTemplate() },
]"
@did-dismiss="deleteAlert.show = false"
/>
<IonToast
v-model:is-open="toast.show"
:message="toast.message"
:color="toast.color"
:duration="2500"
position="bottom"
/>
</IonContent>
</IonPage>
</template>
<script setup lang="ts">
import {
IonPage, IonHeader, IonToolbar, IonTitle, IonContent, IonButtons,
IonMenuButton, IonButton, IonIcon, IonCard, IonCardHeader, IonCardTitle,
IonCardContent, IonList, IonItem, IonLabel, IonInput, IonSpinner,
IonModal, IonAlert, IonToast,
} from '@ionic/vue'
import { addOutline, pencilOutline, trashOutline, closeOutline } from 'ionicons/icons'
import type { Database, TimeTuple } from '~/types/supabase'
type Template = Database['public']['Tables']['interval_templates']['Row']
definePageMeta({ layout: false, middleware: ['auth'] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const supabase = useSupabaseClient() as any
const loading = ref(true)
const saving = ref(false)
const showModal = ref(false)
const editing = ref<Template | null>(null)
const templates = ref<Template[]>([])
const toast = reactive({ show: false, message: '', color: 'success' })
const deleteAlert = reactive({ show: false, id: '', name: '' })
const form = reactive<{ name: string; tuples: [string, string][] }>({
name: '',
tuples: [['08:00', '12:00']],
})
onMounted(fetchTemplates)
async function fetchTemplates() {
loading.value = true
const { data } = await supabase.from('interval_templates').select('*').order('name')
templates.value = data ?? []
loading.value = false
}
function openCreate() {
editing.value = null
form.name = ''
form.tuples = [['08:00', '12:00']]
showModal.value = true
}
function openEdit(t: Template) {
editing.value = t
form.name = t.name
form.tuples = (t.time_tuples as TimeTuple[]).map(tuple => [tuple[0], tuple[1]])
showModal.value = true
}
function closeModal() {
showModal.value = false
editing.value = null
}
function addTuple() {
form.tuples.push(['13:00', '17:00'])
}
function removeTuple(i: number) {
form.tuples.splice(i, 1)
}
async function saveTemplate() {
saving.value = true
const payload = {
name: form.name,
time_tuples: form.tuples as TimeTuple[],
}
let error
if (editing.value) {
;({ error } = await supabase.from('interval_templates').update(payload).eq('id', editing.value.id))
} else {
;({ error } = await supabase.from('interval_templates').insert(payload))
}
saving.value = false
if (error) { showToast('Save failed: ' + error.message, 'danger'); return }
closeModal()
await fetchTemplates()
showToast(editing.value ? 'Template updated' : 'Template created', 'success')
}
function confirmDelete(t: Template) {
deleteAlert.id = t.id
deleteAlert.name = t.name
deleteAlert.show = true
}
async function deleteTemplate() {
const { error } = await supabase.from('interval_templates').delete().eq('id', deleteAlert.id)
deleteAlert.show = false
if (error) { showToast('Delete failed: ' + error.message, 'danger'); return }
await fetchTemplates()
showToast('Template deleted', 'success')
}
function showToast(message: string, color: string) {
toast.message = message
toast.color = color
toast.show = true
}
</script>
<style scoped>
.template-card { margin: 0 0 1rem; }
.template-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.header-actions { display: flex; }
.time-tuple-list { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.time-tuple-chip {
padding: 0.3rem 0.75rem;
border-radius: 1rem;
background: var(--ion-color-light);
border: 1px solid var(--ion-color-light-shade);
font-size: 0.85rem;
font-weight: 500;
}
.section-title { font-size: 0.95rem; font-weight: 600; margin: 0 0 0.5rem; }
.tuple-row {
display: flex;
align-items: center;
gap: 0.4rem;
margin-bottom: 0.5rem;
}
.time-input { flex: 1; }
.tuple-dash { font-weight: 600; }
.empty-text { color: var(--ion-color-medium); text-align: center; padding: 2rem 0; }
</style>

View File

@@ -0,0 +1,391 @@
<template>
<IonPage>
<IonHeader>
<IonToolbar color="primary">
<IonButtons slot="start">
<IonBackButton default-href="/" />
</IonButtons>
<IonTitle>New Reservation</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent class="ion-padding">
<!-- Step 1: Pick a date and select a slot -->
<template v-if="step === 1">
<h3 class="section-title">Select Date</h3>
<!-- Horizontal date strip -->
<div class="date-strip">
<button
v-for="d in dateOptions"
:key="d.iso"
class="date-chip"
:class="{ active: selectedDate === d.iso }"
@click="selectedDate = d.iso"
>
<span class="day-name">{{ d.dayName }}</span>
<span class="day-num">{{ d.dayNum }}</span>
<span class="month">{{ d.month }}</span>
</button>
</div>
<h3 class="section-title ion-margin-top">Available Slots</h3>
<div v-if="loadingSlots" class="ion-text-center ion-padding">
<IonSpinner name="crescent" />
</div>
<p v-else-if="availableByBoat.length === 0" class="empty-text">
No available slots for this date.
</p>
<template v-else>
<IonCard
v-for="entry in availableByBoat"
:key="entry.boat.id"
class="boat-card"
>
<IonCardHeader>
<IonCardTitle>{{ entry.boat.display_name || entry.boat.name }}</IonCardTitle>
<IonCardSubtitle v-if="entry.boat.class">{{ entry.boat.class }}</IonCardSubtitle>
</IonCardHeader>
<IonCardContent>
<div v-if="!entry.certified" class="cert-warning">
<IonIcon :icon="warningOutline" color="warning" />
<span>You are not certified for this boat.</span>
</div>
<div class="slot-chips">
<button
v-for="slot in entry.slots"
:key="slot.id"
class="slot-chip"
:disabled="!entry.certified"
@click="selectSlot(entry.boat, slot)"
>
{{ formatSlotTime(slot.start_time) }}{{ formatSlotTime(slot.end_time) }}
</button>
</div>
</IonCardContent>
</IonCard>
</template>
</template>
<!-- Step 2: Confirm details -->
<template v-else>
<IonCard>
<IonCardHeader>
<IonCardTitle>{{ selectedBoat?.display_name || selectedBoat?.name }}</IonCardTitle>
<IonCardSubtitle v-if="selectedBoat?.class">{{ selectedBoat.class }}</IonCardSubtitle>
</IonCardHeader>
<IonCardContent>
<div class="time-summary">
<div class="time-row">
<IonIcon :icon="timeOutline" />
<span>{{ formatFull(selectedSlot!.start_time) }} {{ formatSlotTime(selectedSlot!.end_time) }}</span>
</div>
</div>
</IonCardContent>
</IonCard>
<IonList lines="full" class="ion-margin-top">
<IonItem>
<IonLabel position="stacked">Reason</IonLabel>
<IonSelect v-model="form.reason" placeholder="Select reason" interface="action-sheet">
<IonSelectOption v-for="r in reasonOptions" :key="r" :value="r">{{ r }}</IonSelectOption>
</IonSelect>
</IonItem>
<IonItem>
<IonLabel position="stacked">Additional Comments (optional)</IonLabel>
<IonTextarea v-model="form.comment" :rows="3" placeholder="Any notes..." />
</IonItem>
</IonList>
<div class="form-actions">
<IonButton fill="outline" @click="step = 1">Back</IonButton>
<IonButton
:disabled="!form.reason || submitting"
@click="submitReservation"
>
<IonSpinner v-if="submitting" name="crescent" slot="start" />
Confirm Booking
</IonButton>
</div>
<IonToast
v-model:is-open="toast.show"
:message="toast.message"
:color="toast.color"
:duration="3000"
position="bottom"
/>
</template>
</IonContent>
</IonPage>
</template>
<script setup lang="ts">
import {
IonPage, IonHeader, IonToolbar, IonTitle, IonContent, IonButtons,
IonBackButton, IonCard, IonCardHeader, IonCardTitle, IonCardSubtitle,
IonCardContent, IonList, IonItem, IonLabel, IonSelect, IonSelectOption,
IonTextarea, IonButton, IonSpinner, IonIcon, IonToast,
} from '@ionic/vue'
import { timeOutline, warningOutline } from 'ionicons/icons'
import { useAuthStore } from '~/stores/auth'
import type { Database } from '~/types/supabase'
type Boat = Database['public']['Tables']['boats']['Row']
type Interval = Database['public']['Tables']['intervals']['Row']
definePageMeta({ layout: false })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const supabase = useSupabaseClient() as any
const auth = useAuthStore()
const router = useRouter()
const step = ref<1 | 2>(1)
const loadingSlots = ref(false)
const submitting = ref(false)
const selectedDate = ref(todayIso())
const selectedBoat = ref<Boat | null>(null)
const selectedSlot = ref<Interval | null>(null)
const toast = reactive({ show: false, message: '', color: 'success' })
const form = reactive({ reason: '', comment: '' })
const reasonOptions = ['Open Sail', 'Private Sail', 'Racing', 'Training', 'Other']
// 14-day date strip starting today
const dateOptions = computed(() => {
const out = []
const base = new Date()
for (let i = 0; i < 14; i++) {
const d = new Date(base)
d.setDate(base.getDate() + i)
out.push({
iso: toIso(d),
dayName: d.toLocaleDateString('en-CA', { weekday: 'short' }),
dayNum: d.getDate(),
month: d.toLocaleDateString('en-CA', { month: 'short' }),
})
}
return out
})
interface BoatSlotEntry {
boat: Boat
slots: Interval[]
certified: boolean
}
const availableByBoat = ref<BoatSlotEntry[]>([])
watch(selectedDate, loadSlots, { immediate: true })
async function loadSlots() {
loadingSlots.value = true
availableByBoat.value = []
const dayStart = selectedDate.value + 'T00:00:00Z'
const dayEnd = selectedDate.value + 'T23:59:59Z'
// Load available intervals + boats (booking_available = true) for selected date
const { data: intervals, error } = await supabase
.from('intervals')
.select('*, boats!inner(id, name, display_name, class, img_src, booking_available, required_certs, max_passengers, defects, year, icon_src, created_at)')
.gte('start_time', dayStart)
.lte('start_time', dayEnd)
.eq('boats.booking_available', true)
.order('start_time', { ascending: true })
if (error) { loadingSlots.value = false; return }
// Load booked slots via reservation_slots view (hides personal details)
const { data: bookedSlots } = await supabase
.from('reservation_slots')
.select('boat_id, start_time, end_time')
.gte('start_time', dayStart)
.lte('start_time', dayEnd)
const booked = new Set<string>()
for (const r of (bookedSlots ?? []) as { boat_id: string; start_time: string; end_time: string }[]) {
booked.add(`${r.boat_id}|${r.start_time}|${r.end_time}`)
}
const memberCerts: string[] = auth.member?.certifications ?? []
// Group by boat, filtering out booked slots
const byBoat = new Map<string, BoatSlotEntry>()
for (const row of intervals ?? []) {
const boat = (row as unknown as { boats: Boat }).boats
const key = `${boat.id}|${row.start_time}|${row.end_time}`
if (booked.has(key)) continue
if (!byBoat.has(boat.id)) {
byBoat.set(boat.id, {
boat,
slots: [],
certified: boat.required_certs.length === 0 ||
boat.required_certs.every(c => memberCerts.includes(c)),
})
}
byBoat.get(boat.id)!.slots.push(row as Interval)
}
availableByBoat.value = Array.from(byBoat.values())
loadingSlots.value = false
}
function selectSlot(boat: Boat, slot: Interval) {
selectedBoat.value = boat
selectedSlot.value = slot
form.reason = ''
form.comment = ''
step.value = 2
}
async function submitReservation() {
if (!auth.user || !selectedBoat.value || !selectedSlot.value) return
submitting.value = true
const { data: sessionData } = await supabase.auth.getSession()
const accessToken = sessionData.session?.access_token ?? ''
const response = await supabase.functions.invoke('create-reservation', {
body: {
boat_id: selectedBoat.value.id,
start_time: selectedSlot.value.start_time,
end_time: selectedSlot.value.end_time,
reason: form.reason,
comment: form.comment,
},
headers: { Authorization: `Bearer ${accessToken}` },
})
submitting.value = false
if (response.error || (response.data as { error?: { code: string; message: string } })?.error) {
const apiError = (response.data as { error?: { code: string; message: string } })?.error
const codeMessages: Record<string, string> = {
cert_required: 'You are not certified for this boat.',
slot_taken: 'This slot was just booked by someone else.',
booking_limit_weekly: apiError?.message ?? 'Weekly booking limit reached.',
booking_limit_weekend: apiError?.message ?? 'Weekend booking limit reached.',
boat_unavailable: 'This boat is currently out of service.',
}
toast.message = (apiError?.code && codeMessages[apiError.code]) || apiError?.message || 'Failed to create reservation.'
toast.color = 'danger'
toast.show = true
return
}
toast.message = 'Reservation created!'
toast.color = 'success'
toast.show = true
setTimeout(() => router.push('/'), 1500)
}
function todayIso() {
return toIso(new Date())
}
function toIso(d: Date) {
return d.toISOString().slice(0, 10)
}
function formatSlotTime(iso: string) {
return new Date(iso).toLocaleTimeString('en-CA', { hour: '2-digit', minute: '2-digit', hour12: false })
}
function formatFull(iso: string) {
return new Date(iso).toLocaleDateString('en-CA', { weekday: 'short', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
}
</script>
<style scoped>
.section-title {
font-size: 1rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.date-strip {
display: flex;
gap: 0.5rem;
overflow-x: auto;
padding-bottom: 0.5rem;
scrollbar-width: none;
}
.date-strip::-webkit-scrollbar { display: none; }
.date-chip {
display: flex;
flex-direction: column;
align-items: center;
min-width: 52px;
padding: 0.4rem 0.6rem;
border: 1px solid var(--ion-color-light-shade);
border-radius: 0.5rem;
background: var(--ion-color-light);
cursor: pointer;
font-size: 0.75rem;
gap: 2px;
flex-shrink: 0;
}
.date-chip.active {
background: var(--ion-color-primary);
color: white;
border-color: var(--ion-color-primary);
}
.day-name { font-weight: 600; text-transform: uppercase; font-size: 0.65rem; }
.day-num { font-size: 1.1rem; font-weight: 700; }
.month { font-size: 0.65rem; }
.boat-card { margin: 0 0 1rem; }
.cert-warning {
display: flex;
align-items: center;
gap: 0.4rem;
color: var(--ion-color-warning-shade);
font-size: 0.85rem;
margin-bottom: 0.5rem;
}
.slot-chips {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.slot-chip {
padding: 0.4rem 0.75rem;
border-radius: 1rem;
border: 1px solid var(--ion-color-primary);
background: transparent;
color: var(--ion-color-primary);
font-size: 0.85rem;
cursor: pointer;
font-weight: 500;
}
.slot-chip:disabled {
border-color: var(--ion-color-medium);
color: var(--ion-color-medium);
cursor: not-allowed;
}
.slot-chip:not(:disabled):hover {
background: var(--ion-color-primary);
color: white;
}
.time-summary { display: flex; flex-direction: column; gap: 0.4rem; }
.time-row { display: flex; align-items: center; gap: 0.5rem; font-size: 0.95rem; }
.empty-text { color: var(--ion-color-medium); text-align: center; padding: 2rem 0; }
.form-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
margin-top: 1.5rem;
}
</style>