345 lines
12 KiB
Vue
345 lines
12 KiB
Vue
<script setup lang="ts">
|
|
import { computed, reactive, ref, shallowRef, watch } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { Plus, TriangleAlert, X } from '@lucide/vue'
|
|
import { getLocalTimeZone, today, type DateValue } from '@internationalized/date'
|
|
import { toast } from 'vue-sonner'
|
|
|
|
import DatePicker from '@/components/DatePicker.vue'
|
|
import TelegramInput from '@/components/TelegramInput.vue'
|
|
import UnitPicker, { type UnitChoice } from '@/components/UnitPicker.vue'
|
|
import TimePicker from '@/components/TimePicker.vue'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import { useBikes } from '@/services/api/bikes'
|
|
import { useSession } from '@/services/api/auth'
|
|
import type { Bike } from '@/utils/types'
|
|
|
|
const { t } = useI18n()
|
|
|
|
const WIKI_URL = 'https://go.agepoly.ch/cargobikes'
|
|
const TELEGRAM_RE = /^@[A-Za-z][A-Za-z0-9_]{4,31}$/
|
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
|
|
type Form = {
|
|
association: UnitChoice | undefined
|
|
reason: string
|
|
startTime: string | undefined
|
|
endTime: string | undefined
|
|
bikes: number[]
|
|
telegram: string
|
|
emails: string[]
|
|
}
|
|
|
|
function emptyForm(): Form {
|
|
return {
|
|
association: undefined,
|
|
reason: '',
|
|
startTime: undefined,
|
|
endTime: undefined,
|
|
bikes: [],
|
|
telegram: '',
|
|
emails: [''],
|
|
}
|
|
}
|
|
|
|
const form = reactive<Form>(emptyForm())
|
|
|
|
const startDate = shallowRef<DateValue>()
|
|
const endDate = shallowRef<DateValue>()
|
|
const errors = reactive<Record<string, string>>({})
|
|
const submitted = ref(false)
|
|
|
|
const minDate = today(getLocalTimeZone())
|
|
/** Bookings open one month ahead, no further */
|
|
const maxDate = minDate.add({ months: 1 })
|
|
|
|
const { user } = useSession()
|
|
const units = computed(() => user.value?.units ?? [])
|
|
|
|
function toDate(date: DateValue | undefined, time: string | undefined): Date | null {
|
|
if (!date || !time) return null
|
|
const [hours, minutes] = time.split(':').map(Number)
|
|
const local = date.toDate(getLocalTimeZone())
|
|
local.setHours(hours, minutes, 0, 0)
|
|
return local
|
|
}
|
|
|
|
const start = computed(() => toDate(startDate.value, form.startTime))
|
|
const end = computed(() => toDate(endDate.value, form.endTime))
|
|
const periodPicked = computed(() => start.value !== null && end.value !== null)
|
|
|
|
const { data: bikes, isPending: bikesPending, isError: bikesError } = useBikes()
|
|
|
|
function isUnavailable(bike: Bike) {
|
|
return bike.status === 'out_of_service'
|
|
}
|
|
|
|
const availableBikes = computed(() => (bikes.value ?? []).filter((bike) => !isUnavailable(bike)))
|
|
|
|
watch(availableBikes, (available) => {
|
|
const ids = new Set(available.map((bike) => bike.id))
|
|
form.bikes = form.bikes.filter((id) => ids.has(id))
|
|
})
|
|
|
|
function toggleBike(bike: Bike) {
|
|
if (isUnavailable(bike)) return
|
|
const index = form.bikes.indexOf(bike.id)
|
|
if (index >= 0) form.bikes.splice(index, 1)
|
|
else form.bikes.push(bike.id)
|
|
}
|
|
|
|
function addEmail() {
|
|
form.emails.push('')
|
|
}
|
|
|
|
function removeEmail(index: number) {
|
|
form.emails.splice(index, 1)
|
|
if (form.emails.length === 0) form.emails.push('')
|
|
}
|
|
|
|
function validate(): boolean {
|
|
Object.keys(errors).forEach((key) => delete errors[key])
|
|
|
|
if (!form.association) errors.association = t('reservation.error-required')
|
|
if (!form.reason.trim()) errors.reason = t('reservation.error-required')
|
|
if (!start.value) errors.start = t('reservation.error-datetime-required')
|
|
if (!end.value) errors.end = t('reservation.error-datetime-required')
|
|
// Strictly after: a reservation of zero length is not one
|
|
if (start.value && end.value && end.value <= start.value) {
|
|
errors.end = t('reservation.error-end-before-start')
|
|
}
|
|
// The pickers already refuse these dates; re-checked in case the model was
|
|
// filled another way
|
|
if (startDate.value && startDate.value.compare(maxDate) > 0) {
|
|
errors.start = t('reservation.error-too-far')
|
|
}
|
|
if (endDate.value && endDate.value.compare(maxDate) > 0) {
|
|
errors.end = t('reservation.error-too-far')
|
|
}
|
|
if (form.bikes.length === 0) errors.bikes = t('reservation.error-no-bike')
|
|
if (!form.telegram) errors.telegram = t('reservation.error-required')
|
|
else if (!TELEGRAM_RE.test(`@${form.telegram}`)) {
|
|
errors.telegram = t('reservation.error-telegram')
|
|
}
|
|
|
|
const emails = form.emails.map((email) => email.trim()).filter(Boolean)
|
|
if (emails.length === 0) errors.emails = t('reservation.error-required')
|
|
else if (!emails.every((email) => EMAIL_RE.test(email))) {
|
|
errors.emails = t('reservation.error-email')
|
|
}
|
|
|
|
return Object.keys(errors).length === 0
|
|
}
|
|
|
|
function reset() {
|
|
Object.assign(form, emptyForm())
|
|
startDate.value = undefined
|
|
endDate.value = undefined
|
|
Object.keys(errors).forEach((key) => delete errors[key])
|
|
submitted.value = false
|
|
}
|
|
|
|
function submit() {
|
|
submitted.value = true
|
|
if (!validate()) {
|
|
toast.error(t('reservation.error-form'))
|
|
return
|
|
}
|
|
|
|
// TODO: replace with a `useCreateReservation` mutation once
|
|
// `POST /api/reservations` exists.
|
|
toast.info(t('reservation.not-implemented'))
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="mx-auto w-full max-w-2xl">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle class="text-xl">{{ $t('reservation.title') }}</CardTitle>
|
|
<CardDescription>
|
|
{{ $t('reservation.intro') }}
|
|
<a
|
|
:href="WIKI_URL"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="text-primary underline underline-offset-2"
|
|
>
|
|
go.agepoly.ch/cargobikes
|
|
</a>
|
|
</CardDescription>
|
|
</CardHeader>
|
|
|
|
<CardContent>
|
|
<form class="grid gap-5" novalidate @submit.prevent="submit">
|
|
<!-- Association -->
|
|
<div class="grid gap-2">
|
|
<Label for="association">{{ $t('reservation.association') }}</Label>
|
|
<UnitPicker
|
|
id="association"
|
|
v-model="form.association"
|
|
:units="units"
|
|
:invalid="!!errors.association"
|
|
/>
|
|
<p v-if="errors.association" class="text-destructive text-xs">
|
|
{{ errors.association }}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Reason -->
|
|
<div class="grid gap-2">
|
|
<Label for="reason">{{ $t('reservation.reason') }}</Label>
|
|
<Textarea
|
|
id="reason"
|
|
v-model.trim="form.reason"
|
|
:placeholder="$t('reservation.reason-placeholder')"
|
|
:aria-invalid="!!errors.reason || undefined"
|
|
rows="2"
|
|
/>
|
|
<p v-if="errors.reason" class="text-destructive text-xs">{{ errors.reason }}</p>
|
|
</div>
|
|
|
|
<!-- Start -->
|
|
<div class="grid gap-2">
|
|
<Label for="start-date">{{ $t('reservation.start') }}</Label>
|
|
<div class="grid gap-2 sm:grid-cols-2">
|
|
<DatePicker
|
|
id="start-date"
|
|
v-model="startDate"
|
|
:min-value="minDate"
|
|
:max-value="maxDate"
|
|
:invalid="!!errors.start"
|
|
/>
|
|
<TimePicker id="start-time" v-model="form.startTime" :invalid="!!errors.start" />
|
|
</div>
|
|
<p v-if="errors.start" class="text-destructive text-xs">{{ errors.start }}</p>
|
|
</div>
|
|
|
|
<!-- End -->
|
|
<div class="grid gap-2">
|
|
<Label for="end-date">{{ $t('reservation.end') }}</Label>
|
|
<div class="grid gap-2 sm:grid-cols-2">
|
|
<DatePicker
|
|
id="end-date"
|
|
v-model="endDate"
|
|
:min-value="startDate ?? minDate"
|
|
:max-value="maxDate"
|
|
:invalid="!!errors.end"
|
|
/>
|
|
<TimePicker id="end-time" v-model="form.endTime" :invalid="!!errors.end" />
|
|
</div>
|
|
<p v-if="errors.end" class="text-destructive text-xs">{{ errors.end }}</p>
|
|
</div>
|
|
|
|
<!-- Bikes -->
|
|
<div class="grid gap-2">
|
|
<span class="text-sm font-medium">{{ $t('reservation.bikes') }}</span>
|
|
|
|
<div
|
|
v-if="!periodPicked"
|
|
class="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900/60 dark:bg-amber-950/40 dark:text-amber-100"
|
|
>
|
|
{{ $t('reservation.bikes-pick-period') }}
|
|
</div>
|
|
|
|
<div v-else-if="bikesPending" class="grid gap-2 sm:grid-cols-2">
|
|
<Skeleton class="h-10 w-full" />
|
|
<Skeleton class="h-10 w-full" />
|
|
</div>
|
|
|
|
<div
|
|
v-else-if="bikesError"
|
|
class="border-destructive/50 text-destructive flex items-center gap-2 rounded-md border p-3 text-sm"
|
|
>
|
|
<TriangleAlert class="size-4 shrink-0" />
|
|
{{ $t('reservation.bikes-error') }}
|
|
</div>
|
|
|
|
<p v-else-if="availableBikes.length === 0" class="text-muted-foreground text-sm">
|
|
{{ $t('reservation.bikes-empty') }}
|
|
</p>
|
|
|
|
<div v-else class="grid gap-2 sm:grid-cols-2">
|
|
<button
|
|
v-for="bike in bikes"
|
|
:key="bike.id"
|
|
type="button"
|
|
:disabled="isUnavailable(bike)"
|
|
:aria-pressed="form.bikes.includes(bike.id)"
|
|
class="flex h-10 items-center justify-between gap-2 rounded-md border px-3 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
|
:class="
|
|
form.bikes.includes(bike.id)
|
|
? 'border-primary bg-primary/10 text-foreground'
|
|
: 'hover:bg-accent hover:text-accent-foreground'
|
|
"
|
|
@click="toggleBike(bike)"
|
|
>
|
|
<span class="truncate">{{ bike.name }}</span>
|
|
<span
|
|
v-if="isUnavailable(bike)"
|
|
class="text-destructive shrink-0 rounded px-1.5 py-0.5 text-[0.65rem] font-bold uppercase"
|
|
>
|
|
{{ $t('reservation.bike-out-of-service') }}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
|
|
<p v-if="errors.bikes" class="text-destructive text-xs">{{ errors.bikes }}</p>
|
|
</div>
|
|
|
|
<!-- Telegram -->
|
|
<div class="grid gap-2">
|
|
<Label for="telegram">{{ $t('reservation.telegram') }}</Label>
|
|
<TelegramInput id="telegram" v-model="form.telegram" :invalid="!!errors.telegram" />
|
|
<p v-if="errors.telegram" class="text-destructive text-xs">{{ errors.telegram }}</p>
|
|
</div>
|
|
|
|
<!-- Linka Go emails -->
|
|
<div class="grid gap-2">
|
|
<span class="text-sm font-medium">{{ $t('reservation.emails') }}</span>
|
|
<div v-for="(_, index) in form.emails" :key="index" class="flex gap-2">
|
|
<Input
|
|
v-model.trim="form.emails[index]"
|
|
type="email"
|
|
inputmode="email"
|
|
:aria-label="$t('reservation.email-nth', { n: index + 1 })"
|
|
placeholder="prenom.nom@exemple.com"
|
|
:aria-invalid="!!errors.emails || undefined"
|
|
/>
|
|
<Button
|
|
v-if="form.emails.length > 1"
|
|
type="button"
|
|
variant="outline"
|
|
size="icon"
|
|
:aria-label="$t('reservation.remove-email')"
|
|
@click="removeEmail(index)"
|
|
>
|
|
<X class="size-4" />
|
|
</Button>
|
|
</div>
|
|
<p v-if="errors.emails" class="text-destructive text-xs">{{ errors.emails }}</p>
|
|
<div>
|
|
<Button type="button" variant="secondary" size="sm" @click="addEmail()">
|
|
<Plus class="size-4" />
|
|
{{ $t('reservation.add-email') }}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Actions -->
|
|
<div class="flex flex-wrap gap-2">
|
|
<Button type="submit">{{ $t('reservation.submit') }}</Button>
|
|
<Button type="button" variant="outline" @click="reset()">
|
|
{{ $t('reservation.reset') }}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</template>
|