feat: improve agep page
This commit is contained in:
parent
47e036e8be
commit
1ed2a64854
21 changed files with 1999 additions and 652 deletions
|
|
@ -19,16 +19,21 @@ app.get('/api/bikes', async (req, res) => {
|
||||||
res.json({ bikes })
|
res.json({ bikes })
|
||||||
})
|
})
|
||||||
|
|
||||||
// Create a booking
|
// Create a booking (supports single bike_type or multiple bike_types)
|
||||||
app.post('/api/bookings', async (req, res) => {
|
app.post('/api/bookings', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { bike_type, start_date, end_date, start_time, end_time, name, email } = req.body
|
const { bike_type, bike_types, start_date, end_date, start_time, end_time, name, email } = req.body
|
||||||
if (!bike_type || !start_date || !end_date || !name || !email) {
|
|
||||||
|
const multi = Array.isArray(bike_types) ? bike_types.filter((v) => Number.isFinite(Number(v))).map(Number) : []
|
||||||
|
const single = Number.isFinite(Number(bike_type)) ? Number(bike_type) : null
|
||||||
|
|
||||||
|
if ((!single && multi.length === 0) || !start_date || !end_date || !name || !email) {
|
||||||
return res.status(400).json({ error: 'Missing fields' })
|
return res.status(400).json({ error: 'Missing fields' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = `INSERT INTO bookings(bike_type, start_date, start_time, end_date, end_time, name, email) VALUES($1, $2, $3, $4, $5, $6, $7) RETURNING *`
|
const text = `INSERT INTO bookings(bike_type, bike_types, start_date, start_time, end_date, end_time, name, email)
|
||||||
const values = [bike_type, start_date, start_time || null, end_date, end_time || null, name, email]
|
VALUES($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`
|
||||||
|
const values = [single, multi.length ? multi.join(',') : null, start_date, start_time || null, end_date, end_time || null, name, email]
|
||||||
const result = await pool.query(text, values)
|
const result = await pool.query(text, values)
|
||||||
res.status(201).json({ booking: result.rows[0] })
|
res.status(201).json({ booking: result.rows[0] })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -48,12 +53,29 @@ app.get('/api/bookings', async (req, res) => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Delete a booking by id
|
||||||
|
app.delete('/api/bookings/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = Number(req.params.id)
|
||||||
|
if (!Number.isFinite(id)) return res.status(400).json({ error: 'Invalid id' })
|
||||||
|
|
||||||
|
const result = await pool.query('DELETE FROM bookings WHERE id = $1 RETURNING *', [id])
|
||||||
|
if (result.rowCount === 0) return res.status(404).json({ error: 'Booking not found' })
|
||||||
|
|
||||||
|
res.json({ ok: true, booking: result.rows[0] })
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
res.status(500).json({ error: 'Server error' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// --- ensure DB tables exist (auto-migration for dev) ---
|
// --- ensure DB tables exist (auto-migration for dev) ---
|
||||||
async function ensureTables() {
|
async function ensureTables() {
|
||||||
const createSql = `
|
const createSql = `
|
||||||
CREATE TABLE IF NOT EXISTS bookings (
|
CREATE TABLE IF NOT EXISTS bookings (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
bike_type INTEGER NOT NULL,
|
bike_type INTEGER,
|
||||||
|
bike_types TEXT,
|
||||||
start_date DATE NOT NULL,
|
start_date DATE NOT NULL,
|
||||||
start_time TIME,
|
start_time TIME,
|
||||||
end_date DATE NOT NULL,
|
end_date DATE NOT NULL,
|
||||||
|
|
@ -66,12 +88,13 @@ async function ensureTables() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await pool.query(createSql)
|
await pool.query(createSql)
|
||||||
// Ensure new columns exist if the table was created earlier without time columns
|
// Ensure columns and constraints for backward compatibility
|
||||||
await pool.query(`ALTER TABLE bookings ADD COLUMN IF NOT EXISTS start_time TIME;`)
|
await pool.query(`ALTER TABLE bookings ADD COLUMN IF NOT EXISTS start_time TIME;`)
|
||||||
await pool.query(`ALTER TABLE bookings ADD COLUMN IF NOT EXISTS end_time TIME;`)
|
await pool.query(`ALTER TABLE bookings ADD COLUMN IF NOT EXISTS end_time TIME;`)
|
||||||
console.log('Database: bookings table is present (created if it did not exist)')
|
await pool.query(`ALTER TABLE bookings ADD COLUMN IF NOT EXISTS bike_types TEXT;`)
|
||||||
|
await pool.query(`ALTER TABLE bookings ALTER COLUMN bike_type DROP NOT NULL;`)
|
||||||
|
console.log('Database: bookings table is present (created/migrated if needed)')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// If DATABASE_URL is not configured or DB not reachable, warn but still allow server to start for frontend dev
|
|
||||||
console.warn('Warning: could not ensure bookings table (DB may be unavailable).', err.message)
|
console.warn('Warning: could not ensure bookings table (DB may be unavailable).', err.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="">
|
<html lang="fr">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<link rel="icon" href="/favicon.ico">
|
<link rel="icon" href="/favicon.ico">
|
||||||
|
|
|
||||||
1357
package-lock.json
generated
1357
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -18,6 +18,9 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^6.0.1",
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
"vite": "^7.1.7",
|
"vite": "^7.1.7",
|
||||||
"vite-plugin-vue-devtools": "^8.0.2"
|
"vite-plugin-vue-devtools": "^8.0.2",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.47",
|
||||||
|
"tailwindcss": "^3.4.14"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
7
postcss.config.js
Normal file
7
postcss.config.js
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
78
src/App.vue
78
src/App.vue
|
|
@ -1,71 +1,25 @@
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
|
|
||||||
const theme = ref('')
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
// prefer saved preference, fall back to system preference
|
|
||||||
const saved = localStorage.getItem('theme')
|
|
||||||
if (saved) {
|
|
||||||
theme.value = saved
|
|
||||||
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
|
||||||
theme.value = 'dark'
|
|
||||||
} else {
|
|
||||||
theme.value = 'light'
|
|
||||||
}
|
|
||||||
document.documentElement.setAttribute('data-theme', theme.value)
|
|
||||||
})
|
|
||||||
|
|
||||||
function toggleTheme() {
|
|
||||||
theme.value = theme.value === 'dark' ? 'light' : 'dark'
|
|
||||||
document.documentElement.setAttribute('data-theme', theme.value)
|
|
||||||
localStorage.setItem('theme', theme.value)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="min-h-screen bg-[hsl(var(--background))] text-[hsl(var(--foreground))]">
|
||||||
<header class="site-header">
|
<header class="border-b border-border">
|
||||||
<nav class="container">
|
<div class="container flex h-14 items-center justify-between">
|
||||||
<div class="brand">
|
<div class="flex items-center gap-3">
|
||||||
<img alt="AGEPOLY logo" src="./assets/logo-agep.png" class="logo" />
|
<img :src="logo" alt="AGEP" class="h-8 w-auto object-contain" />
|
||||||
<div class="title">Cargobike Booking</div>
|
<RouterLink to="/" class="text-base font-semibold text-blue-700 hover:text-blue-800 hover:bg-transparent">Cargobikes</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
<nav class="flex items-center gap-4 text-sm">
|
||||||
<div class="links">
|
<RouterLink to="/" class="text-blue-700 hover:text-blue-800 hover:bg-transparent">Réserver</RouterLink>
|
||||||
<router-link to="/" class="nav-link">Réserver</router-link>
|
<RouterLink to="/admin" class="text-blue-700 hover:text-blue-800 hover:bg-transparent">Admin</RouterLink>
|
||||||
<router-link to="/admin" class="nav-link">Gestion</router-link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="spacer"></div>
|
|
||||||
|
|
||||||
<button class="theme-toggle" @click="toggleTheme" :aria-pressed="theme === 'dark'" aria-label="Basculer thème">
|
|
||||||
<span v-if="theme === 'dark'">☀️</span>
|
|
||||||
<span v-else>🌙</span>
|
|
||||||
</button>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main style="padding:1.5rem;max-width:980px;margin:0 auto">
|
<main class="container py-6">
|
||||||
<router-view />
|
<RouterView />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<script setup>
|
||||||
.site-header { width: 100%; }
|
import { RouterLink, RouterView } from 'vue-router'
|
||||||
.container { display:flex; align-items:center; gap:1rem; max-width:1280px; margin:0 auto; padding:0.5rem 1rem; }
|
import logo from '@/assets/logo-agep.png'
|
||||||
.brand { display:flex; align-items:center; gap:0.75rem }
|
</script>
|
||||||
.logo { width:120px; height:auto }
|
|
||||||
.title { font-weight:600; color:var(--color-heading) }
|
|
||||||
.links { display:flex; gap:0.5rem }
|
|
||||||
.nav-link { color:var(--color-text); text-decoration:none; padding:6px 8px; border-radius:6px }
|
|
||||||
.nav-link:hover { background:var(--color-background-mute) }
|
|
||||||
.spacer { flex:1 }
|
|
||||||
.theme-toggle { background:transparent; border:1px solid var(--color-border); padding:6px 8px; border-radius:6px; cursor:pointer }
|
|
||||||
@media (max-width:640px) {
|
|
||||||
.links { display:none }
|
|
||||||
.title { font-size:0.95rem }
|
|
||||||
.logo { width:40px }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
|
||||||
55
src/api.js
55
src/api.js
|
|
@ -1,55 +0,0 @@
|
||||||
// Simple frontend API helper with graceful fallbacks when the backend is unavailable (useful during frontend-only development)
|
|
||||||
|
|
||||||
const DEFAULT_BIKES = [1000,2000,3000,4000,5000]
|
|
||||||
|
|
||||||
export async function getBikes() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/bikes')
|
|
||||||
if (!res.ok) throw new Error('Failed to fetch bikes')
|
|
||||||
return res.json()
|
|
||||||
} catch (err) {
|
|
||||||
// Fallback to defaults when backend is not reachable
|
|
||||||
console.warn('getBikes: using fallback bikes because backend is unavailable', err)
|
|
||||||
return { bikes: DEFAULT_BIKES }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createBooking(payload) {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/bookings', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
})
|
|
||||||
const data = await res.json()
|
|
||||||
if (!res.ok) throw new Error(data?.error || 'Failed to create booking')
|
|
||||||
return data
|
|
||||||
} catch (err) {
|
|
||||||
// Fallback: simulate a successful booking locally for frontend development
|
|
||||||
console.warn('createBooking: backend unavailable, creating a local mock booking', err)
|
|
||||||
const mock = {
|
|
||||||
booking: {
|
|
||||||
id: Math.floor(Math.random() * 100000) + 100,
|
|
||||||
bike_type: payload.bike_type,
|
|
||||||
start_date: payload.start_date,
|
|
||||||
end_date: payload.end_date,
|
|
||||||
name: payload.name,
|
|
||||||
email: payload.email,
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return mock
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listBookings() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/bookings')
|
|
||||||
if (!res.ok) throw new Error('Failed to list bookings')
|
|
||||||
return res.json()
|
|
||||||
} catch (err) {
|
|
||||||
// Fallback: return an empty list or a small mock so Admin page can render
|
|
||||||
console.warn('listBookings: using fallback empty list because backend is unavailable', err)
|
|
||||||
return { bookings: [] }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,88 @@
|
||||||
@import './base.css';
|
@import './base.css';
|
||||||
|
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* shadcn-like design tokens */
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--primary: 221 83% 53%; /* slate-600/blue-ish */
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--secondary: 210 40% 96.1%;
|
||||||
|
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--muted: 210 40% 96.1%;
|
||||||
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
|
|
||||||
|
--accent: 210 40% 96.1%;
|
||||||
|
--accent-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--border: 214.3 31.8% 91.4%;
|
||||||
|
--input: 214.3 31.8% 91.4%;
|
||||||
|
--ring: 221.2 83.2% 53.3%;
|
||||||
|
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root.dark {
|
||||||
|
--background: 224 71% 4%;
|
||||||
|
--foreground: 213 31% 91%;
|
||||||
|
|
||||||
|
--card: 224 71% 4%;
|
||||||
|
--card-foreground: 213 31% 91%;
|
||||||
|
|
||||||
|
--popover: 224 71% 4%;
|
||||||
|
--popover-foreground: 213 31% 91%;
|
||||||
|
|
||||||
|
--primary: 217.2 91.2% 59.8%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--secondary: 215 27.9% 16.9%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--muted: 215 27.9% 16.9%;
|
||||||
|
--muted-foreground: 217.9 10.6% 64.9%;
|
||||||
|
|
||||||
|
--accent: 215 27.9% 16.9%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--border: 215 27.9% 16.9%;
|
||||||
|
--input: 215 27.9% 16.9%;
|
||||||
|
--ring: 224 71% 4%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* App container helpers */
|
||||||
|
.container { @apply mx-auto max-w-3xl px-4; }
|
||||||
|
.card { @apply rounded-lg border border-border bg-[hsl(var(--card))] text-[hsl(var(--card-foreground))] shadow-sm; }
|
||||||
|
.card-header { @apply p-6 pb-3; }
|
||||||
|
.card-title { @apply text-xl font-semibold leading-none tracking-tight; }
|
||||||
|
.card-content { @apply p-6 pt-0; }
|
||||||
|
|
||||||
|
.btn { @apply inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--ring))] focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-10 px-4 py-2 bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))] hover:opacity-90; }
|
||||||
|
.btn-secondary { @apply bg-[hsl(var(--secondary))] text-[hsl(var(--secondary-foreground))]; }
|
||||||
|
.btn-outline { @apply border border-border bg-transparent text-[hsl(var(--foreground))]; }
|
||||||
|
|
||||||
|
.input { @apply flex h-10 w-full rounded-md border border-border bg-[hsl(var(--background))] px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--ring))] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50; }
|
||||||
|
.label { @apply text-sm font-medium leading-none; }
|
||||||
|
.select { @apply input; }
|
||||||
|
.helper { @apply text-xs text-slate-500; }
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
/* allow the app to use full width; the header/nav control max width via `.container` */
|
/* allow the app to use full width; the header/nav control max width via `.container` */
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
|
|
||||||
|
|
@ -1,185 +0,0 @@
|
||||||
<template>
|
|
||||||
<form @submit.prevent="submit">
|
|
||||||
<h2>Réserver un cargobike</h2>
|
|
||||||
|
|
||||||
<label>
|
|
||||||
L’association emprunteuse
|
|
||||||
<input type="text" v-model="form.association" required />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label>
|
|
||||||
La taille de vélo souhaitée
|
|
||||||
<select v-model.number="form.bike_type">
|
|
||||||
<option v-for="b in bikes" :key="b" :value="b">{{ b }}</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.5rem">
|
|
||||||
<label>
|
|
||||||
Début de la location
|
|
||||||
<input type="date" v-model="form.start_date" required />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label>
|
|
||||||
Fin de la location
|
|
||||||
<input type="date" v-model="form.end_date" required />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.5rem;margin-top:0.25rem">
|
|
||||||
<label>
|
|
||||||
Heure de début
|
|
||||||
<input type="time" v-model="form.start_time" required />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label>
|
|
||||||
Heure de fin
|
|
||||||
<input type="time" v-model="form.end_time" required />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label>
|
|
||||||
L’adresse(s) mail associée(s) au compte Linka Go à autoriser
|
|
||||||
<textarea v-model="form.linka_emails_text" placeholder="Entrez plusieurs emails, séparés par des virgules ou des lignes" required rows="3"></textarea>
|
|
||||||
<small style="color:#666">Ex: admin@asso.org, contact@asso.org</small>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div style="display:flex;gap:0.5rem;align-items:center">
|
|
||||||
<button :disabled="loading">{{ loading ? 'Envoi...' : 'Réserver' }}</button>
|
|
||||||
<div style="color:#666;font-size:0.9rem">{{ hint }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-if="error" style="color:crimson">{{ error }}</p>
|
|
||||||
<p v-if="success" style="color:green">Réservation créée (id: {{ success.id }})</p>
|
|
||||||
</form>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted, computed } from 'vue'
|
|
||||||
import { getBikes, createBooking } from '@/api'
|
|
||||||
|
|
||||||
const bikes = ref()
|
|
||||||
// Added start_time and end_time fields
|
|
||||||
const form = ref({ association: '', bike_type: 1000, start_date: '', end_date: '', start_time: '', end_time: '', linka_emails_text: '' })
|
|
||||||
const loading = ref(false)
|
|
||||||
const error = ref('')
|
|
||||||
const success = ref(null)
|
|
||||||
|
|
||||||
const hint = computed(() => {
|
|
||||||
return 'Les horaires seront envoyés au backend. Saisissez plusieurs emails séparés par des virgules ou des retours à la ligne.'
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
const data = await getBikes()
|
|
||||||
if (data?.bikes) bikes.value = data.bikes
|
|
||||||
if (bikes.value.length) form.value.bike_type = bikes.value[0]
|
|
||||||
} catch (err) {
|
|
||||||
// ignore - use defaults
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function makeDateTime(dateStr, timeStr) {
|
|
||||||
if (!dateStr) return null
|
|
||||||
// If time is empty, return date-only (ISO date)
|
|
||||||
if (!timeStr) return dateStr
|
|
||||||
// Ensure time has seconds if needed
|
|
||||||
return `${dateStr}T${timeStr}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateRange(startDate, startTime, endDate, endTime) {
|
|
||||||
const s = makeDateTime(startDate, startTime)
|
|
||||||
const e = makeDateTime(endDate, endTime)
|
|
||||||
try {
|
|
||||||
const sd = new Date(s)
|
|
||||||
const ed = new Date(e)
|
|
||||||
if (isNaN(sd) || isNaN(ed)) return false
|
|
||||||
return sd <= ed
|
|
||||||
} catch (err) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseEmails(text) {
|
|
||||||
if (!text) return []
|
|
||||||
// split by commas or whitespace/newlines
|
|
||||||
return text
|
|
||||||
.split(/[,\n;]+/)
|
|
||||||
.map(s => s.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isValidEmail(email) {
|
|
||||||
// simple email validation
|
|
||||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
error.value = ''
|
|
||||||
success.value = null
|
|
||||||
|
|
||||||
// Basic validation: all required fields
|
|
||||||
if (!form.value.association || !form.value.start_date || !form.value.end_date || !form.value.linka_emails_text) {
|
|
||||||
error.value = 'Veuillez remplir tous les champs requis.'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// validate date/time range
|
|
||||||
if (!validateRange(form.value.start_date, form.value.start_time, form.value.end_date, form.value.end_time)) {
|
|
||||||
error.value = 'La période de début doit être antérieure ou égale à la période de fin.'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// parse and validate emails
|
|
||||||
const emails = parseEmails(form.value.linka_emails_text)
|
|
||||||
if (!emails.length) {
|
|
||||||
error.value = 'Veuillez fournir au moins une adresse email valide.'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const invalid = emails.filter(e => !isValidEmail(e))
|
|
||||||
if (invalid.length) {
|
|
||||||
error.value = `Adresses invalides: ${invalid.join(', ')}`
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
// Map form to backend expected fields; include start_time/end_time as separate fields
|
|
||||||
const payload = {
|
|
||||||
bike_type: form.value.bike_type,
|
|
||||||
start_date: form.value.start_date, // keep date-only for now
|
|
||||||
end_date: form.value.end_date,
|
|
||||||
start_time: form.value.start_time,
|
|
||||||
end_time: form.value.end_time,
|
|
||||||
name: form.value.association,
|
|
||||||
// join emails into a single string (backend stores in email TEXT column)
|
|
||||||
email: emails.join(',')
|
|
||||||
}
|
|
||||||
const res = await createBooking(payload)
|
|
||||||
success.value = res.booking
|
|
||||||
// reset form
|
|
||||||
form.value = { association: '', bike_type: bikes.value[0] || 1000, start_date: '', end_date: '', start_time: '', end_time: '', linka_emails_text: '' }
|
|
||||||
} catch (err) {
|
|
||||||
error.value = err.message || String(err)
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
form {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.5rem;
|
|
||||||
max-width: 520px;
|
|
||||||
margin: 1rem auto;
|
|
||||||
padding: 1rem;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--color-form-bg);
|
|
||||||
}
|
|
||||||
label { display:block }
|
|
||||||
input, select { width:100%; padding:0.5rem; margin-top:0.2rem; border:1px solid var(--color-border); border-radius:6px; background:transparent; color:var(--color-text) }
|
|
||||||
textarea { width:100%; padding:0.5rem; margin-top:0.2rem; border:1px solid var(--color-border); border-radius:6px; background:transparent; color:var(--color-text); resize:vertical }
|
|
||||||
button { padding:0.6rem 1rem; border-radius:6px; border:1px solid var(--color-border); background:var(--color-background); color:var(--color-text); cursor:pointer }
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
<script setup>
|
|
||||||
defineProps({
|
|
||||||
msg: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="greetings">
|
|
||||||
<h1 class="green">{{ msg }}</h1>
|
|
||||||
<h3>
|
|
||||||
You’ve successfully created a project with
|
|
||||||
<a href="https://vite.dev/" target="_blank" rel="noopener">Vite</a> +
|
|
||||||
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>.
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
h1 {
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 2.6rem;
|
|
||||||
position: relative;
|
|
||||||
top: -10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.greetings h1,
|
|
||||||
.greetings h3 {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.greetings h1,
|
|
||||||
.greetings h3 {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
<script setup>
|
|
||||||
import WelcomeItem from './WelcomeItem.vue'
|
|
||||||
import DocumentationIcon from './icons/IconDocumentation.vue'
|
|
||||||
import ToolingIcon from './icons/IconTooling.vue'
|
|
||||||
import EcosystemIcon from './icons/IconEcosystem.vue'
|
|
||||||
import CommunityIcon from './icons/IconCommunity.vue'
|
|
||||||
import SupportIcon from './icons/IconSupport.vue'
|
|
||||||
|
|
||||||
const openReadmeInEditor = () => fetch('/__open-in-editor?file=README.md')
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<WelcomeItem>
|
|
||||||
<template #icon>
|
|
||||||
<DocumentationIcon />
|
|
||||||
</template>
|
|
||||||
<template #heading>Documentation</template>
|
|
||||||
|
|
||||||
Vue’s
|
|
||||||
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
|
|
||||||
provides you with all information you need to get started.
|
|
||||||
</WelcomeItem>
|
|
||||||
|
|
||||||
<WelcomeItem>
|
|
||||||
<template #icon>
|
|
||||||
<ToolingIcon />
|
|
||||||
</template>
|
|
||||||
<template #heading>Tooling</template>
|
|
||||||
|
|
||||||
This project is served and bundled with
|
|
||||||
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
|
|
||||||
recommended IDE setup is
|
|
||||||
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
|
|
||||||
+
|
|
||||||
<a href="https://github.com/vuejs/language-tools" target="_blank" rel="noopener">Vue - Official</a>. If
|
|
||||||
you need to test your components and web pages, check out
|
|
||||||
<a href="https://vitest.dev/" target="_blank" rel="noopener">Vitest</a>
|
|
||||||
and
|
|
||||||
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
|
|
||||||
/
|
|
||||||
<a href="https://playwright.dev/" target="_blank" rel="noopener">Playwright</a>.
|
|
||||||
|
|
||||||
<br />
|
|
||||||
|
|
||||||
More instructions are available in
|
|
||||||
<a href="javascript:void(0)" @click="openReadmeInEditor"><code>README.md</code></a
|
|
||||||
>.
|
|
||||||
</WelcomeItem>
|
|
||||||
|
|
||||||
<WelcomeItem>
|
|
||||||
<template #icon>
|
|
||||||
<EcosystemIcon />
|
|
||||||
</template>
|
|
||||||
<template #heading>Ecosystem</template>
|
|
||||||
|
|
||||||
Get official tools and libraries for your project:
|
|
||||||
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
|
|
||||||
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
|
|
||||||
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
|
|
||||||
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
|
|
||||||
you need more resources, we suggest paying
|
|
||||||
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
|
|
||||||
a visit.
|
|
||||||
</WelcomeItem>
|
|
||||||
|
|
||||||
<WelcomeItem>
|
|
||||||
<template #icon>
|
|
||||||
<CommunityIcon />
|
|
||||||
</template>
|
|
||||||
<template #heading>Community</template>
|
|
||||||
|
|
||||||
Got stuck? Ask your question on
|
|
||||||
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>
|
|
||||||
(our official Discord server), or
|
|
||||||
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
|
|
||||||
>StackOverflow</a
|
|
||||||
>. You should also follow the official
|
|
||||||
<a href="https://bsky.app/profile/vuejs.org" target="_blank" rel="noopener">@vuejs.org</a>
|
|
||||||
Bluesky account or the
|
|
||||||
<a href="https://x.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
|
|
||||||
X account for latest news in the Vue world.
|
|
||||||
</WelcomeItem>
|
|
||||||
|
|
||||||
<WelcomeItem>
|
|
||||||
<template #icon>
|
|
||||||
<SupportIcon />
|
|
||||||
</template>
|
|
||||||
<template #heading>Support Vue</template>
|
|
||||||
|
|
||||||
As an independent project, Vue relies on community backing for its sustainability. You can help
|
|
||||||
us by
|
|
||||||
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
|
|
||||||
</WelcomeItem>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="item">
|
|
||||||
<i>
|
|
||||||
<slot name="icon"></slot>
|
|
||||||
</i>
|
|
||||||
<div class="details">
|
|
||||||
<h3>
|
|
||||||
<slot name="heading"></slot>
|
|
||||||
</h3>
|
|
||||||
<slot></slot>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.item {
|
|
||||||
margin-top: 2rem;
|
|
||||||
display: flex;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.details {
|
|
||||||
flex: 1;
|
|
||||||
margin-left: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
i {
|
|
||||||
display: flex;
|
|
||||||
place-items: center;
|
|
||||||
place-content: center;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 0.4rem;
|
|
||||||
color: var(--color-heading);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.item {
|
|
||||||
margin-top: 0;
|
|
||||||
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
i {
|
|
||||||
top: calc(50% - 25px);
|
|
||||||
left: -26px;
|
|
||||||
position: absolute;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
background: var(--color-background);
|
|
||||||
border-radius: 8px;
|
|
||||||
width: 50px;
|
|
||||||
height: 50px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item:before {
|
|
||||||
content: ' ';
|
|
||||||
border-left: 1px solid var(--color-border);
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
bottom: calc(50% + 25px);
|
|
||||||
height: calc(50% - 25px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.item:after {
|
|
||||||
content: ' ';
|
|
||||||
border-left: 1px solid var(--color-border);
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: calc(50% + 25px);
|
|
||||||
height: calc(50% - 25px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.item:first-of-type:before {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item:last-of-type:after {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
<template>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
|
||||||
<path
|
|
||||||
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
<template>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
|
|
||||||
<path
|
|
||||||
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
<template>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
|
|
||||||
<path
|
|
||||||
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
<template>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
|
||||||
<path
|
|
||||||
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
|
|
||||||
<template>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
|
||||||
aria-hidden="true"
|
|
||||||
role="img"
|
|
||||||
class="iconify iconify--mdi"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
preserveAspectRatio="xMidYMid meet"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
|
|
||||||
fill="currentColor"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,74 +1,123 @@
|
||||||
<template>
|
<template>
|
||||||
|
<section class="grid gap-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1>Gestion des réservations</h1>
|
<h1 class="card-title">Administration</h1>
|
||||||
<p>Liste des dernières réservations</p>
|
<p class="text-sm text-slate-500">Gérer les réservations et les cargos.</p>
|
||||||
|
</div>
|
||||||
<button @click="load" :disabled="loading">{{ loading ? 'Chargement...' : 'Rafraîchir' }}</button>
|
<div class="flex gap-2">
|
||||||
|
<button class="btn btn-secondary" @click="load">Rafraîchir</button>
|
||||||
<p v-if="loadError" style="color:crimson;margin-top:1rem">Erreur lors du chargement des réservations: {{ loadError }}</p>
|
</div>
|
||||||
|
</div>
|
||||||
<table v-if="bookings.length" style="width:100%;margin-top:1rem;border-collapse:collapse">
|
<div class="card-content">
|
||||||
<thead>
|
<div v-if="error" class="text-sm text-red-700 mb-3">{{ error }}</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="text-left text-slate-500">
|
||||||
<tr>
|
<tr>
|
||||||
<th style="border-bottom:1px solid #ddd;text-align:left;padding:0.5rem">ID</th>
|
<th class="py-2 pr-4">#</th>
|
||||||
<th style="border-bottom:1px solid #ddd;text-align:left;padding:0.5rem">Vélo</th>
|
<th class="py-2 pr-4">Association</th>
|
||||||
<th style="border-bottom:1px solid #ddd;text-align:left;padding:0.5rem">Période</th>
|
<th class="py-2 pr-4">Taille(s)</th>
|
||||||
<th style="border-bottom:1px solid #ddd;text-align:left;padding:0.5rem">Association</th>
|
<th class="py-2 pr-4">Période</th>
|
||||||
<th style="border-bottom:1px solid #ddd;text-align:left;padding:0.5rem">Email(s) Linka</th>
|
<th class="py-2 pr-4">Emails</th>
|
||||||
<th style="border-bottom:1px solid #ddd;text-align:left;padding:0.5rem">Créé</th>
|
<th class="py-2 pr-4">Créé</th>
|
||||||
|
<th class="py-2 pr-0 text-right">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="b in bookings" :key="b.id">
|
<tr v-for="b in bookings" :key="b.id" class="border-t border-border">
|
||||||
<td style="padding:0.5rem;border-top:1px solid #f1f1f1">{{ b.id }}</td>
|
<td class="py-2 pr-4">{{ b.id }}</td>
|
||||||
<td style="padding:0.5rem;border-top:1px solid #f1f1f1">{{ b.bike_type }}</td>
|
<td class="py-2 pr-4">{{ b.name }}</td>
|
||||||
<td style="padding:0.5rem;border-top:1px solid #f1f1f1">{{ b.start_date }} → {{ b.end_date }}</td>
|
<td class="py-2 pr-4">
|
||||||
<td style="padding:0.5rem;border-top:1px solid #f1f1f1">{{ b.name }}</td>
|
<template v-if="sizes(b).length">
|
||||||
<td style="padding:0.5rem;border-top:1px solid #f1f1f1">
|
<span v-for="(sz, i) in sizes(b)" :key="i" class="inline-flex items-center rounded border border-border px-2 py-0.5 mr-1 mb-1">
|
||||||
<ul style="margin:0;padding-left:1rem;">
|
{{ sz }}
|
||||||
<li v-for="e in parseEmails(b.email)" :key="e" style="list-style:disc">{{ e }}</li>
|
</span>
|
||||||
</ul>
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span class="text-slate-500">—</span>
|
||||||
|
</template>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding:0.5rem;border-top:1px solid #f1f1f1">{{ b.created_at }}</td>
|
<td class="py-2 pr-4">
|
||||||
|
<span>{{ fmtDate(b.start_date) }}<span v-if="b.start_time"> {{ b.start_time.slice(0,5) }}</span></span>
|
||||||
|
<span class="mx-1">→</span>
|
||||||
|
<span>{{ fmtDate(b.end_date) }}<span v-if="b.end_time"> {{ b.end_time.slice(0,5) }}</span></span>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 pr-4">
|
||||||
|
<span v-for="(em, i) in splitEmails(b.email)" :key="i" class="inline-flex items-center rounded border border-border px-2 py-0.5 mr-1 mb-1">
|
||||||
|
{{ em }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 pr-4">{{ fmtDateTime(b.created_at) }}</td>
|
||||||
|
<td class="py-2 pr-0 text-right">
|
||||||
|
<button class="btn btn-outline h-8 px-3" @click="remove(b.id)" :disabled="busyIds.has(b.id)">Supprimer</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!loading && bookings.length === 0">
|
||||||
|
<td colspan="7" class="py-4 text-center text-slate-500">Aucune réservation pour le moment.</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<p v-else style="color:#666;margin-top:1rem">Aucune réservation trouvée.</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import { listBookings } from '@/api'
|
import { api } from '@/services/api'
|
||||||
|
|
||||||
const bookings = ref([])
|
const bookings = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const loadError = ref('')
|
const error = ref('')
|
||||||
|
const busyIds = ref(new Set())
|
||||||
|
|
||||||
function parseEmails(text) {
|
function fmtDate(d) {
|
||||||
if (!text) return []
|
if (!d) return ''
|
||||||
return String(text).split(/[,\n;]+/).map(s => s.trim()).filter(Boolean)
|
const s = String(d).slice(0, 10) // YYYY-MM-DD
|
||||||
|
const [y, m, dd] = s.split('-')
|
||||||
|
return `${dd}/${m}/${y}`
|
||||||
|
}
|
||||||
|
function fmtDateTime(dt) {
|
||||||
|
if (!dt) return ''
|
||||||
|
const s = String(dt).replace('T', ' ').slice(0, 16) // YYYY-MM-DD HH:MM
|
||||||
|
const [date, hm] = s.split(' ')
|
||||||
|
return `${fmtDate(date)} ${hm}`
|
||||||
|
}
|
||||||
|
function splitEmails(s) { return s ? String(s).split(',').map(x => x.trim()).filter(Boolean) : [] }
|
||||||
|
function sizes(b) {
|
||||||
|
const multi = b.bike_types ? String(b.bike_types).split(',').map(x => x.trim()).filter(Boolean) : []
|
||||||
|
if (multi.length) return multi
|
||||||
|
return b.bike_type != null ? [String(b.bike_type)] : []
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
loadError.value = ''
|
error.value = ''
|
||||||
try {
|
try {
|
||||||
const res = await listBookings()
|
bookings.value = await api.listBookings()
|
||||||
bookings.value = res.bookings || []
|
} catch (e) {
|
||||||
} catch (err) {
|
error.value = 'Impossible de charger les réservations.'
|
||||||
console.error(err)
|
|
||||||
bookings.value = []
|
|
||||||
loadError.value = err.message || String(err)
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function remove(id) {
|
||||||
|
if (!confirm('Supprimer cette réservation ?')) return
|
||||||
|
busyIds.value.add(id)
|
||||||
|
try {
|
||||||
|
await api.deleteBooking(id)
|
||||||
|
bookings.value = bookings.value.filter(b => b.id !== id)
|
||||||
|
} catch (e) {
|
||||||
|
alert('Échec de la suppression')
|
||||||
|
} finally {
|
||||||
|
busyIds.value.delete(id)
|
||||||
|
busyIds.value = new Set(busyIds.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
th, td { font-size: 0.95rem }
|
|
||||||
</style>
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,289 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<section class="grid gap-6">
|
||||||
<h1>Réservation</h1>
|
<div class="card">
|
||||||
<p>Utilisez ce formulaire pour réserver un cargobike AGEPOLY.</p>
|
<div class="card-header space-y-4">
|
||||||
<BookingForm />
|
<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>
|
||||||
|
<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>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import BookingForm from '@/components/BookingForm.vue'
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
h1 { margin-bottom: 0.25rem }
|
|
||||||
p { color: #666; margin-bottom: 1rem }
|
|
||||||
</style>
|
|
||||||
|
|
||||||
|
|
|
||||||
44
src/services/api.js
Normal file
44
src/services/api.js
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
export async function apiGet(path) {
|
||||||
|
const res = await fetch(path)
|
||||||
|
if (!res.ok) throw new Error(await safeText(res))
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiPost(path, body) {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(await safeText(res))
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiDelete(path) {
|
||||||
|
const res = await fetch(path, { method: 'DELETE' })
|
||||||
|
if (!res.ok) throw new Error(await safeText(res))
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeText(res) {
|
||||||
|
try { return await res.text() } catch { return `HTTP ${res.status}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
async getBikes() {
|
||||||
|
const { bikes } = await apiGet('/api/bikes')
|
||||||
|
return bikes
|
||||||
|
},
|
||||||
|
async createBooking(payload) {
|
||||||
|
const { booking } = await apiPost('/api/bookings', payload)
|
||||||
|
return booking
|
||||||
|
},
|
||||||
|
async listBookings() {
|
||||||
|
const { bookings } = await apiGet('/api/bookings')
|
||||||
|
return bookings
|
||||||
|
},
|
||||||
|
async deleteBooking(id) {
|
||||||
|
return apiDelete(`/api/bookings/${id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
67
tailwind.config.js
Normal file
67
tailwind.config.js
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
'./index.html',
|
||||||
|
'./src/**/*.{vue,js,ts,jsx,tsx}',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: 'hsl(var(--border))',
|
||||||
|
input: 'hsl(var(--input))',
|
||||||
|
ring: 'hsl(var(--ring))',
|
||||||
|
background: 'hsl(var(--background))',
|
||||||
|
foreground: 'hsl(var(--foreground))',
|
||||||
|
primary: {
|
||||||
|
DEFAULT: 'hsl(var(--primary))',
|
||||||
|
foreground: 'hsl(var(--primary-foreground))',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: 'hsl(var(--secondary))',
|
||||||
|
foreground: 'hsl(var(--secondary-foreground))',
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: 'hsl(var(--destructive))',
|
||||||
|
foreground: 'hsl(var(--destructive-foreground))',
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: 'hsl(var(--muted))',
|
||||||
|
foreground: 'hsl(var(--muted-foreground))',
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: 'hsl(var(--accent))',
|
||||||
|
foreground: 'hsl(var(--accent-foreground))',
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: 'hsl(var(--popover))',
|
||||||
|
foreground: 'hsl(var(--popover-foreground))',
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: 'hsl(var(--card))',
|
||||||
|
foreground: 'hsl(var(--card-foreground))',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: 'var(--radius)',
|
||||||
|
md: 'calc(var(--radius) - 2px)',
|
||||||
|
sm: 'calc(var(--radius) - 4px)',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
'accordion-down': {
|
||||||
|
from: { height: 0 },
|
||||||
|
to: { height: 'var(--radix-accordion-content-height)' },
|
||||||
|
},
|
||||||
|
'accordion-up': {
|
||||||
|
from: { height: 'var(--radix-accordion-content-height)' },
|
||||||
|
to: { height: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||||
|
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Add table
Reference in a new issue