cargagep-v2/frontend/src/views/ReservationView.vue
2026-08-24 11:09:59 +02:00

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>