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

419 lines
13 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,
} 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'
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 route = useRoute()
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[]>([])
// Pre-populate from schedule deep-link: ?boatId=&startTime=&endTime=
onMounted(async () => {
const { boatId, startTime, endTime } = route.query as Record<string, string>
if (boatId && startTime && endTime) {
const { data: boat } = await supabase.from('boats').select('*').eq('id', boatId).single()
if (boat) {
selectedBoat.value = boat as Boat
selectedSlot.value = {
id: '',
boat_id: boatId,
start_time: startTime,
end_time: endTime,
user_id: null,
created_at: '',
}
// Set the date strip to the slot's date so Back returns to correct day
selectedDate.value = toDateToronto(new Date(startTime))
step.value = 2
return
}
}
// Normal flow: load slots for today
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'
// 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>