Files
oysqn.app/app/pages/reservations/create.vue

449 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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,
onIonViewWillEnter,
} from '@ionic/vue'
import { timeOutline, warningOutline } from 'ionicons/icons'
import { useAuthStore } from '~/stores/auth'
import { toDateToronto } from '~/utils/toronto'
import type { Database } from '~/types/supabase'
import { useBookingDraft } from '~/composables/useBookingDraft'
import { useAppCache } from '~/composables/useAppCache'
import { useOfflineStatus } from '~/composables/useOfflineStatus'
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 { take: takeDraft } = useBookingDraft()
const cache = useAppCache()
const { isOnline } = useOfflineStatus()
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[]>([])
// onIonViewWillEnter fires every visit, including re-entry to a cached Ionic page
onIonViewWillEnter(async () => {
const draft = takeDraft()
if (draft) {
selectedBoat.value = draft.boat
selectedSlot.value = {
id: '',
boat_id: draft.boat.id,
start_time: draft.startTime,
end_time: draft.endTime,
user_id: null,
created_at: '',
}
selectedDate.value = toDateToronto(new Date(draft.startTime))
step.value = 2
return
}
// Normal flow: reset to step 1 and load slots for today
step.value = 1
selectedBoat.value = null
selectedSlot.value = null
selectedDate.value = todayIso()
await loadSlots()
})
watch(selectedDate, loadSlots)
async function loadSlots() {
loadingSlots.value = true
availableByBoat.value = []
const dayStart = selectedDate.value + 'T00:00:00Z'
const dayEnd = selectedDate.value + 'T23:59:59Z'
const wk = cache.weekKey(selectedDate.value + 'T12:00:00Z')
type BookedSlot = { boat_id: string; start_time: string; end_time: string }
let intervals: unknown[] | null = null
let bookedSlots: BookedSlot[] | null = null
if (!isOnline.value) {
const cachedIntervals = cache.peek<unknown[]>(`intervals:${wk}`)
const cachedSlots = cache.peek<BookedSlot[]>(`slots:${wk}`)
intervals = cachedIntervals?.filter(r => {
const row = r as { start_time: string }
return row.start_time >= dayStart && row.start_time <= dayEnd
}) ?? []
bookedSlots = cachedSlots?.filter(s => s.start_time >= dayStart && s.start_time <= dayEnd) ?? []
} else {
const { data: intData, 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 }
intervals = intData
const { data: slotData } = await supabase
.from('reservation_slots')
.select('boat_id, start_time, end_time')
.gte('start_time', dayStart)
.lte('start_time', dayEnd)
bookedSlots = slotData
}
const booked = new Set<string>()
for (const r of (bookedSlots ?? []) as BookedSlot[]) {
booked.add(`${r.boat_id}|${r.start_time}|${r.end_time}`)
}
const memberCerts: string[] = auth.member?.certifications ?? []
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 as { start_time: string }).start_time}|${(row as { end_time: string }).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
type ApiErrorBody = { error?: { code: string; message: string } }
// functions-js v2 sets data=null on non-2xx and stores the raw Response on error.context
let apiError: { code: string; message: string } | undefined =
(response.data as ApiErrorBody)?.error
if (!apiError && response.error) {
try {
const body: ApiErrorBody = await (response.error as { context?: Response }).context?.json()
apiError = body?.error
} catch { /* ignore */ }
}
if (apiError || response.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.',
historical_booking_not_allowed: apiError?.message ?? 'Can not book a reservation in the past.',
}
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>