289 lines
13 KiB
Vue
289 lines
13 KiB
Vue
<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 l’association" />
|
||
<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 e‑mail</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 l’AGEP traite ces données pour gérer votre réservation. Vous recevrez une confirmation par e‑mail 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 l’association.'; 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 l’heure 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 e‑mail.'; ok = false }
|
||
if (validEmails.some(e => !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e))) { errors.emails = 'Une ou plusieurs adresses e‑mail 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 l’enregistrement. Réessayez plus tard.'
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
</script>
|