agep-cargo/src/pages/BookingPage.vue
2025-10-12 21:10:43 +02:00

289 lines
13 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
<section class="grid gap-6">
<div class="card">
<div class="card-header space-y-4">
<h1 class="card-title">Réserver un Cargobike</h1>
<p class="text-sm text-slate-500">Remplissez les informations ci-dessous pour soumettre votre demande de réservation.</p>
</div>
<div class="card-content">
<form @submit.prevent="onSubmit" class="grid gap-5">
<!-- Association (mapped to backend: name) -->
<div class="grid gap-2">
<label for="association" class="label">Nom de l'association</label>
<input id="association" v-model.trim="form.association" type="text" class="input" placeholder="Nom de lassociation" />
<p v-if="errors.association" class="helper text-red-600">{{ errors.association }}</p>
</div>
<!-- Bike size/type as two columns of buttons (multi-select) -->
<div class="grid gap-2">
<span class="label">Taille de vélo souhaitée</span>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 items-start">
<div class="grid gap-2 self-start">
<div class="text-xs text-slate-500">Grands cargos</div>
<button v-for="b in bigBikes" :key="b" type="button"
class="btn-outline h-10 rounded-md w-full text-left px-4"
:class="buttonClass(b)"
:aria-pressed="isSelected(b)"
@click="toggleBike(b)">
{{ b }}
</button>
</div>
<div class="grid gap-2 self-start">
<div class="text-xs text-slate-500">Petits cargos</div>
<button v-for="b in smallBikes" :key="b" type="button"
class="btn-outline h-10 rounded-md w-full text-left px-4"
:class="buttonClass(b)"
:aria-pressed="isSelected(b)"
@click="toggleBike(b)">
{{ b }}
</button>
</div>
</div>
<div class="text-xs text-slate-600" v-if="form.bikeTypes.length">Sélection : <span class="font-medium">{{ form.bikeTypes.join(', ') }}</span></div>
<p v-if="errors.bikeTypes" class="helper text-red-600">{{ errors.bikeTypes }}</p>
</div>
<!-- Period (separate date + 24h time selects) -->
<div class="grid gap-4">
<div class="grid gap-3 md:grid-cols-2">
<div class="grid gap-2">
<label class="label">Date de début (jj/mm/aaaa)</label>
<div class="flex gap-2">
<select v-model="form.startDay" class="select" aria-label="Jour de début">
<option value="" disabled>JJ</option>
<option v-for="d in startDays" :key="`sd-${d}`" :value="d">{{ d }}</option>
</select>
<select v-model="form.startMonth" class="select" aria-label="Mois de début">
<option value="" disabled>MM</option>
<option v-for="m in months" :key="`sm-${m}`" :value="m">{{ m }}</option>
</select>
<select v-model="form.startYear" class="select" aria-label="Année de début">
<option value="" disabled>AAAA</option>
<option v-for="y in years" :key="`sy-${y}`" :value="String(y)">{{ y }}</option>
</select>
</div>
<p v-if="errors.startDate" class="helper text-red-600">{{ errors.startDate }}</p>
</div>
<div class="grid gap-2">
<label class="label">Heure de début</label>
<div class="flex gap-2">
<select v-model="form.startHour" class="select" aria-label="Heure de début (heures)">
<option value="" disabled>HH</option>
<option v-for="h in hours" :key="h" :value="h">{{ h }}</option>
</select>
<span class="self-center">:</span>
<select v-model="form.startMinute" class="select" aria-label="Heure de début (minutes)">
<option value="" disabled>MM</option>
<option v-for="m in minutes" :key="m" :value="m">{{ m }}</option>
</select>
</div>
</div>
</div>
<div class="grid gap-3 md:grid-cols-2">
<div class="grid gap-2">
<label class="label">Date de fin (jj/mm/aaaa)</label>
<div class="flex gap-2">
<select v-model="form.endDay" class="select" aria-label="Jour de fin">
<option value="" disabled>JJ</option>
<option v-for="d in endDays" :key="`ed-${d}`" :value="d">{{ d }}</option>
</select>
<select v-model="form.endMonth" class="select" aria-label="Mois de fin">
<option value="" disabled>MM</option>
<option v-for="m in months" :key="`em-${m}`" :value="m">{{ m }}</option>
</select>
<select v-model="form.endYear" class="select" aria-label="Année de fin">
<option value="" disabled>AAAA</option>
<option v-for="y in years" :key="`ey-${y}`" :value="String(y)">{{ y }}</option>
</select>
</div>
<p v-if="errors.endDate" class="helper text-red-600">{{ errors.endDate }}</p>
</div>
<div class="grid gap-2">
<label class="label">Heure de fin</label>
<div class="flex gap-2">
<select v-model="form.endHour" class="select" aria-label="Heure de fin (heures)">
<option value="" disabled>HH</option>
<option v-for="h in hours" :key="`eh-${h}`" :value="h">{{ h }}</option>
</select>
<span class="self-center">:</span>
<select v-model="form.endMinute" class="select" aria-label="Heure de fin (minutes)">
<option value="" disabled>MM</option>
<option v-for="m in minutes" :key="`em-${m}`" :value="m">{{ m }}</option>
</select>
</div>
<p v-if="errors.time" class="helper text-red-600">{{ errors.time }}</p>
</div>
</div>
</div>
<!-- Linka Go emails (multiple) -->
<div class="grid gap-2">
<span class="label">Adresses mail du/des comptes Linka Go à autoriser</span>
<div class="grid gap-2">
<div v-for="(email, idx) in form.emails" :key="idx" class="flex gap-2">
<input :id="`email-${idx}`" v-model.trim="form.emails[idx]" type="email" class="input" placeholder="prenom.nom@exemple.com" />
<button type="button" class="btn btn-outline h-10 px-3" @click="removeEmail(idx)" v-if="form.emails.length > 1">Retirer</button>
</div>
</div>
<div class="flex gap-2">
<button type="button" class="btn btn-secondary h-9 px-3" @click="addEmail">Ajouter un email</button>
</div>
<p v-if="errors.emails" class="helper text-red-600">{{ errors.emails }}</p>
</div>
<div class="flex items-center gap-3">
<button type="submit" class="btn w-full sm:w-auto" :disabled="submitting">{{ submitting ? 'Envoi' : 'Envoyer la demande' }}</button>
<button type="button" class="btn btn-secondary w-full sm:w-auto" @click="resetForm" :disabled="submitting">Réinitialiser</button>
</div>
<p v-if="status.success" class="text-green-700 text-sm">{{ status.message }}</p>
<p v-if="status.error" class="text-red-700 text-sm">{{ status.error }}</p>
</form>
</div>
</div>
<div class="text-xs text-slate-500">
En soumettant, vous acceptez que lAGEP traite ces données pour gérer votre réservation. Vous recevrez une confirmation par email si nécessaire.
</div>
</section>
</template>
<script setup>
import { reactive, ref, computed } from 'vue'
import { api } from '@/services/api'
const hours = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'))
const minutes = ['00', '15', '30', '45']
const bigBikes = [1000, 2000]
const smallBikes = [3000, 4000, 5000]
const submitting = ref(false)
const status = reactive({ success: false, message: '', error: '' })
const form = reactive({
association: '',
bikeTypes: [],
startDate: '',
startHour: '',
startMinute: '',
endDate: '',
endHour: '',
endMinute: '',
emails: [''],
})
const errors = reactive({
association: '',
bikeTypes: '',
startDate: '',
endDate: '',
time: '',
emails: '',
})
const years = computed(() => {
const y = new Date().getFullYear()
return [y, y + 1, y + 2]
})
const months = Array.from({ length: 12 }, (_, i) => String(i + 1).padStart(2, '0'))
function daysInMonth(year, month) {
if (!year || !month) return 31
return new Date(Number(year), Number(month), 0).getDate()
}
const startDays = computed(() => {
const n = daysInMonth(form.startYear, form.startMonth)
return Array.from({ length: n }, (_, i) => String(i + 1).padStart(2, '0'))
})
const endDays = computed(() => {
const n = daysInMonth(form.endYear, form.endMonth)
return Array.from({ length: n }, (_, i) => String(i + 1).padStart(2, '0'))
})
function isSelected(b) { return form.bikeTypes.includes(b) }
function toggleBike(b) {
const i = form.bikeTypes.indexOf(b)
if (i >= 0) form.bikeTypes.splice(i, 1)
else form.bikeTypes.push(b)
}
function buttonClass(b) {
return isSelected(b)
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))] border-transparent ring-2 ring-[hsl(var(--ring))]'
: 'hover:bg-[hsl(var(--muted))]'
}
function addEmail() { form.emails.push('') }
function removeEmail(idx) { if (form.emails.length > 1) form.emails.splice(idx, 1) }
function resetForm() {
Object.assign(form, {
association: '', bikeTypes: [], startDate: '', startHour: '', startMinute: '', endDate: '', endHour: '', endMinute: '', emails: ['']
})
Object.keys(errors).forEach(k => errors[k] = '')
Object.assign(status, { success: false, message: '', error: '' })
}
function asISO(y, m, d) {
if (!y || !m || !d) return ''
return `${y}-${m}-${d}`
}
function isDateValid(y, m, d) {
if (!y || !m || !d) return false
const dd = Number(d), mm = Number(m), yy = Number(y)
const dt = new Date(yy, mm - 1, dd)
return dt.getFullYear() === yy && dt.getMonth() === mm - 1 && dt.getDate() === dd
}
function validate() {
Object.keys(errors).forEach(k => errors[k] = '')
let ok = true
if (!form.association || form.association.length < 2) { errors.association = 'Renseignez le nom de lassociation.'; ok = false }
if (!form.bikeTypes.length) { errors.bikeTypes = 'Sélectionnez au moins un vélo.'; ok = false }
if (!isDateValid(form.startYear, form.startMonth, form.startDay)) { errors.startDate = 'Sélectionnez une date de début valide (jj/mm/aaaa).'; ok = false }
if (!isDateValid(form.endYear, form.endMonth, form.endDay)) { errors.endDate = 'Sélectionnez une date de fin valide (jj/mm/aaaa).'; ok = false }
if (!form.startHour || !form.startMinute || !form.endHour || !form.endMinute) { errors.time = 'Renseignez les heures de début et de fin.'; ok = false }
if (ok) {
const startISO = asISO(form.startYear, form.startMonth, form.startDay)
const endISO = asISO(form.endYear, form.endMonth, form.endDay)
if (endISO < startISO) { errors.endDate = 'La date de fin doit être après la date de début.'; ok = false }
if (endISO === startISO) {
const s = Number(form.startHour) * 60 + Number(form.startMinute)
const e = Number(form.endHour) * 60 + Number(form.endMinute)
if (e <= s) { errors.time = 'Heure de fin après lheure de début requise.'; ok = false }
}
}
const validEmails = form.emails.map(e => e.trim()).filter(Boolean)
if (validEmails.length === 0) { errors.emails = 'Renseignez au moins une adresse email.'; ok = false }
if (validEmails.some(e => !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e))) { errors.emails = 'Une ou plusieurs adresses email sont invalides.'; ok = false }
return ok
}
async function onSubmit() {
if (!validate()) return
submitting.value = true
status.success = false
status.error = ''
status.message = ''
try {
const emailStr = form.emails.map(e => e.trim()).filter(Boolean).join(',')
const payload = {
bike_types: [...form.bikeTypes],
start_date: asISO(form.startYear, form.startMonth, form.startDay),
start_time: `${form.startHour}:${form.startMinute}`,
end_date: asISO(form.endYear, form.endMonth, form.endDay),
end_time: `${form.endHour}:${form.endMinute}`,
name: form.association,
email: emailStr,
}
const booking = await api.createBooking(payload)
status.success = true
status.message = `Votre demande a été enregistrée (n° ${booking.id}).`
resetForm()
} catch (e) {
status.error = 'Échec de lenregistrement. Réessayez plus tard.'
} finally {
submitting.value = false
}
}
</script>