feat: improve agep page

This commit is contained in:
Antoine Pelletier 2025-10-12 21:10:43 +02:00
parent 47e036e8be
commit 1ed2a64854
21 changed files with 1999 additions and 652 deletions

View file

@ -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)
} }
} }

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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
View file

@ -0,0 +1,7 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View file

@ -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> </nav>
</div> </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>
</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>

View file

@ -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: [] }
}
}

View file

@ -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%;

View file

@ -1,185 +0,0 @@
<template>
<form @submit.prevent="submit">
<h2>Réserver un cargobike</h2>
<label>
Lassociation 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>
Ladresse(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>

View file

@ -1,44 +0,0 @@
<script setup>
defineProps({
msg: {
type: String,
required: true,
},
})
</script>
<template>
<div class="greetings">
<h1 class="green">{{ msg }}</h1>
<h3>
Youve 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>

View file

@ -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>
Vues
<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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -1,74 +1,123 @@
<template> <template>
<div> <section class="grid gap-6">
<h1>Gestion des réservations</h1> <div class="card">
<p>Liste des dernières réservations</p> <div class="card-header flex items-center justify-between">
<div>
<button @click="load" :disabled="loading">{{ loading ? 'Chargement...' : 'Rafraîchir' }}</button> <h1 class="card-title">Administration</h1>
<p class="text-sm text-slate-500">Gérer les réservations et les cargos.</p>
<p v-if="loadError" style="color:crimson;margin-top:1rem">Erreur lors du chargement des réservations: {{ loadError }}</p> </div>
<div class="flex gap-2">
<table v-if="bookings.length" style="width:100%;margin-top:1rem;border-collapse:collapse"> <button class="btn btn-secondary" @click="load">Rafraîchir</button>
<thead> </div>
<tr> </div>
<th style="border-bottom:1px solid #ddd;text-align:left;padding:0.5rem">ID</th> <div class="card-content">
<th style="border-bottom:1px solid #ddd;text-align:left;padding:0.5rem">Vélo</th> <div v-if="error" class="text-sm text-red-700 mb-3">{{ error }}</div>
<th style="border-bottom:1px solid #ddd;text-align:left;padding:0.5rem">Période</th> <div class="overflow-x-auto">
<th style="border-bottom:1px solid #ddd;text-align:left;padding:0.5rem">Association</th> <table class="w-full text-sm">
<th style="border-bottom:1px solid #ddd;text-align:left;padding:0.5rem">Email(s) Linka</th> <thead class="text-left text-slate-500">
<th style="border-bottom:1px solid #ddd;text-align:left;padding:0.5rem">Créé</th> <tr>
</tr> <th class="py-2 pr-4">#</th>
</thead> <th class="py-2 pr-4">Association</th>
<tbody> <th class="py-2 pr-4">Taille(s)</th>
<tr v-for="b in bookings" :key="b.id"> <th class="py-2 pr-4">Période</th>
<td style="padding:0.5rem;border-top:1px solid #f1f1f1">{{ b.id }}</td> <th class="py-2 pr-4">Emails</th>
<td style="padding:0.5rem;border-top:1px solid #f1f1f1">{{ b.bike_type }}</td> <th class="py-2 pr-4">Créé</th>
<td style="padding:0.5rem;border-top:1px solid #f1f1f1">{{ b.start_date }} {{ b.end_date }}</td> <th class="py-2 pr-0 text-right">Actions</th>
<td style="padding:0.5rem;border-top:1px solid #f1f1f1">{{ b.name }}</td> </tr>
<td style="padding:0.5rem;border-top:1px solid #f1f1f1"> </thead>
<ul style="margin:0;padding-left:1rem;"> <tbody>
<li v-for="e in parseEmails(b.email)" :key="e" style="list-style:disc">{{ e }}</li> <tr v-for="b in bookings" :key="b.id" class="border-t border-border">
</ul> <td class="py-2 pr-4">{{ b.id }}</td>
</td> <td class="py-2 pr-4">{{ b.name }}</td>
<td style="padding:0.5rem;border-top:1px solid #f1f1f1">{{ b.created_at }}</td> <td class="py-2 pr-4">
</tr> <template v-if="sizes(b).length">
</tbody> <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">
</table> {{ sz }}
</span>
<p v-else style="color:#666;margin-top:1rem">Aucune réservation trouvée.</p> </template>
</div> <template v-else>
<span class="text-slate-500"></span>
</template>
</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>
</tbody>
</table>
</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>

View file

@ -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>
</div> <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> </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 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> </script>
<style scoped>
h1 { margin-bottom: 0.25rem }
p { color: #666; margin-bottom: 1rem }
</style>

44
src/services/api.js Normal file
View 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
View 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: [],
}