419 lines
13 KiB
Vue
419 lines
13 KiB
Vue
<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>
|