feat: improve agep page

This commit is contained in:
Antoine Pelletier 2025-10-11 03:30:14 +02:00
parent feab348528
commit 47e036e8be
7 changed files with 179 additions and 49 deletions

1
.gitignore vendored
View file

@ -32,3 +32,4 @@ yarn-error.log*
# Others # Others
public/*.tmp public/*.tmp
/backend/migrations/create_bookings.sql

View file

@ -2,8 +2,6 @@ import express from 'express'
import cors from 'cors' import cors from 'cors'
import dotenv from 'dotenv' import dotenv from 'dotenv'
import { pool } from './db.js' import { pool } from './db.js'
import fs from 'fs'
import path from 'path'
dotenv.config() dotenv.config()
@ -24,13 +22,13 @@ app.get('/api/bikes', async (req, res) => {
// Create a booking // Create a booking
app.post('/api/bookings', async (req, res) => { app.post('/api/bookings', async (req, res) => {
try { try {
const { bike_type, start_date, end_date, name, email } = req.body const { bike_type, start_date, end_date, start_time, end_time, name, email } = req.body
if (!bike_type || !start_date || !end_date || !name || !email) { if (!bike_type || !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, end_date, name, email) VALUES($1, $2, $3, $4, $5) RETURNING *` 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 values = [bike_type, start_date, end_date, name, email] const values = [bike_type, 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) {
@ -57,7 +55,9 @@ async function ensureTables() {
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
bike_type INTEGER NOT NULL, bike_type INTEGER NOT NULL,
start_date DATE NOT NULL, start_date DATE NOT NULL,
start_time TIME,
end_date DATE NOT NULL, end_date DATE NOT NULL,
end_time TIME,
name TEXT NOT NULL, name TEXT NOT NULL,
email TEXT NOT NULL, email TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() created_at TIMESTAMP WITH TIME ZONE DEFAULT now()
@ -66,6 +66,9 @@ 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
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;`)
console.log('Database: bookings table is present (created if it did not exist)') console.log('Database: bookings table is present (created if it did not exist)')
} catch (err) { } catch (err) {
// If DATABASE_URL is not configured or DB not reachable, warn but still allow server to start for frontend dev // If DATABASE_URL is not configured or DB not reachable, warn but still allow server to start for frontend dev

View file

@ -29,7 +29,7 @@ function toggleTheme() {
<nav class="container"> <nav class="container">
<div class="brand"> <div class="brand">
<img alt="AGEPOLY logo" src="./assets/logo-agep.png" class="logo" /> <img alt="AGEPOLY logo" src="./assets/logo-agep.png" class="logo" />
<div class="title">AGEPOLY Cargobike Booking</div> <div class="title">Cargobike Booking</div>
</div> </div>
<div class="links"> <div class="links">
@ -56,7 +56,7 @@ function toggleTheme() {
.site-header { width: 100%; } .site-header { width: 100%; }
.container { display:flex; align-items:center; gap:1rem; max-width:1280px; margin:0 auto; padding:0.5rem 1rem; } .container { display:flex; align-items:center; gap:1rem; max-width:1280px; margin:0 auto; padding:0.5rem 1rem; }
.brand { display:flex; align-items:center; gap:0.75rem } .brand { display:flex; align-items:center; gap:0.75rem }
.logo { width:48px; height:auto } .logo { width:120px; height:auto }
.title { font-weight:600; color:var(--color-heading) } .title { font-weight:600; color:var(--color-heading) }
.links { display:flex; gap:0.5rem } .links { display:flex; gap:0.5rem }
.nav-link { color:var(--color-text); text-decoration:none; padding:6px 8px; border-radius:6px } .nav-link { color:var(--color-text); text-decoration:none; padding:6px 8px; border-radius:6px }

View file

@ -33,9 +33,39 @@
--color-heading: var(--vt-c-text-light-1); --color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1); --color-text: var(--vt-c-text-light-1);
/* default form background (can be overridden per theme) */
--color-form-bg: var(--color-background-soft);
--section-gap: 160px; --section-gap: 160px;
} }
/* Explicit theme override when user toggles theme (data-theme on html element) */
:root[data-theme="light"] {
--color-background: #ffffff;
--color-background-soft: #f6f8fb;
--color-form-bg: #ffffff; /* form card slightly white on light theme */
--color-background-mute: #eef2f6;
--color-border: rgba(30, 41, 59, 0.06);
--color-border-hover: rgba(30, 41, 59, 0.10);
--color-heading: #0f1724; /* dark slate */
--color-text: #334155;
}
:root[data-theme="dark"] {
--color-background: #071025;
--color-background-soft: #0b1723;
--color-form-bg: #071829; /* slightly different card bg for dark */
--color-background-mute: #0f2633;
--color-border: rgba(255,255,255,0.06);
--color-border-hover: rgba(255,255,255,0.10);
--color-heading: #e6eef8;
--color-text: #cbd5e1;
}
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
--color-background: var(--vt-c-black); --color-background: var(--vt-c-black);
@ -63,8 +93,8 @@ body {
color: var(--color-text); color: var(--color-text);
background: var(--color-background); background: var(--color-background);
transition: transition:
color 0.5s, color 0.25s ease,
background-color 0.5s; background-color 0.25s ease;
line-height: 1.6; line-height: 1.6;
font-family: font-family:
Inter, Inter,

View file

@ -1,9 +1,10 @@
@import './base.css'; @import './base.css';
#app { #app {
max-width: 1280px; /* allow the app to use full width; the header/nav control max width via `.container` */
margin: 0 auto; max-width: 100%;
padding: 2rem; margin: 0;
padding: 0;
font-weight: normal; font-weight: normal;
} }
@ -23,13 +24,12 @@ a,
@media (min-width: 1024px) { @media (min-width: 1024px) {
body { body {
display: flex; /* avoid forcing body into a flex/grid that constrains the header */
place-items: center; display: block;
} }
#app { #app {
display: grid; /* keep a comfortable inner padding on wide screens */
grid-template-columns: 1fr 1fr; padding: 1.5rem 2rem;
padding: 0 2rem;
} }
} }

View file

@ -14,22 +14,40 @@
</select> </select>
</label> </label>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.5rem">
<label> <label>
La période de location - début Début de la location
<input type="date" v-model="form.start_date" required /> <input type="date" v-model="form.start_date" required />
</label> </label>
<label> <label>
La période de location - fin Fin de la location
<input type="date" v-model="form.end_date" required /> <input type="date" v-model="form.end_date" required />
</label> </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> <label>
Ladresse mail associée au compte Linka Go à autoriser Heure de fin
<input type="email" v-model="form.linka_email" required /> <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> </label>
<div style="display:flex;gap:0.5rem;align-items:center">
<button :disabled="loading">{{ loading ? 'Envoi...' : 'Réserver' }}</button> <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="error" style="color:crimson">{{ error }}</p>
<p v-if="success" style="color:green">Réservation créée (id: {{ success.id }})</p> <p v-if="success" style="color:green">Réservation créée (id: {{ success.id }})</p>
@ -37,16 +55,20 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted, computed } from 'vue'
import { getBikes, createBooking } from '@/api' import { getBikes, createBooking } from '@/api'
const bikes = ref([1000,2000,3000,4000,5000]) const bikes = ref()
// map: association -> name column in DB, linka_email -> email column in DB // Added start_time and end_time fields
const form = ref({ association: '', bike_type: 1000, start_date: '', end_date: '', linka_email: '' }) const form = ref({ association: '', bike_type: 1000, start_date: '', end_date: '', start_time: '', end_time: '', linka_emails_text: '' })
const loading = ref(false) const loading = ref(false)
const error = ref('') const error = ref('')
const success = ref(null) 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 () => { onMounted(async () => {
try { try {
const data = await getBikes() const data = await getBikes()
@ -57,23 +79,86 @@ onMounted(async () => {
} }
}) })
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() { async function submit() {
error.value = '' error.value = ''
success.value = null 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 loading.value = true
try { try {
// Map form to backend expected fields: name,email // Map form to backend expected fields; include start_time/end_time as separate fields
const payload = { const payload = {
bike_type: form.value.bike_type, bike_type: form.value.bike_type,
start_date: form.value.start_date, start_date: form.value.start_date, // keep date-only for now
end_date: form.value.end_date, end_date: form.value.end_date,
start_time: form.value.start_time,
end_time: form.value.end_time,
name: form.value.association, name: form.value.association,
email: form.value.linka_email, // join emails into a single string (backend stores in email TEXT column)
email: emails.join(',')
} }
const res = await createBooking(payload) const res = await createBooking(payload)
success.value = res.booking success.value = res.booking
// reset form // reset form
form.value = { association: '', bike_type: bikes.value[0] || 1000, start_date: '', end_date: '', linka_email: '' } form.value = { association: '', bike_type: bikes.value[0] || 1000, start_date: '', end_date: '', start_time: '', end_time: '', linka_emails_text: '' }
} catch (err) { } catch (err) {
error.value = err.message || String(err) error.value = err.message || String(err)
} finally { } finally {
@ -86,13 +171,15 @@ async function submit() {
form { form {
display: grid; display: grid;
gap: 0.5rem; gap: 0.5rem;
max-width: 420px; max-width: 520px;
margin: 1rem auto; margin: 1rem auto;
padding: 1rem; padding: 1rem;
border: 1px solid #ddd; border: 1px solid var(--color-border);
border-radius: 6px; border-radius: 8px;
background: var(--color-form-bg);
} }
label { display:block } label { display:block }
input, select { width:100%; padding:0.4rem; margin-top:0.2rem } 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) }
button { padding:0.6rem 1rem } 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> </style>

View file

@ -14,7 +14,7 @@
<th style="border-bottom:1px solid #ddd;text-align:left;padding:0.5rem">Vélo</th> <th style="border-bottom:1px solid #ddd;text-align:left;padding:0.5rem">Vélo</th>
<th style="border-bottom:1px solid #ddd;text-align:left;padding:0.5rem">Période</th> <th style="border-bottom:1px solid #ddd;text-align:left;padding:0.5rem">Période</th>
<th style="border-bottom:1px solid #ddd;text-align:left;padding:0.5rem">Association</th> <th style="border-bottom:1px solid #ddd;text-align:left;padding:0.5rem">Association</th>
<th style="border-bottom:1px solid #ddd;text-align:left;padding:0.5rem">Email Linka</th> <th style="border-bottom:1px solid #ddd;text-align:left;padding:0.5rem">Email(s) Linka</th>
<th style="border-bottom:1px solid #ddd;text-align:left;padding:0.5rem">Créé</th> <th style="border-bottom:1px solid #ddd;text-align:left;padding:0.5rem">Créé</th>
</tr> </tr>
</thead> </thead>
@ -24,7 +24,11 @@
<td style="padding:0.5rem;border-top:1px solid #f1f1f1">{{ b.bike_type }}</td> <td style="padding:0.5rem;border-top:1px solid #f1f1f1">{{ b.bike_type }}</td>
<td style="padding:0.5rem;border-top:1px solid #f1f1f1">{{ b.start_date }} {{ b.end_date }}</td> <td style="padding:0.5rem;border-top:1px solid #f1f1f1">{{ b.start_date }} {{ b.end_date }}</td>
<td style="padding:0.5rem;border-top:1px solid #f1f1f1">{{ b.name }}</td> <td style="padding:0.5rem;border-top:1px solid #f1f1f1">{{ b.name }}</td>
<td style="padding:0.5rem;border-top:1px solid #f1f1f1">{{ b.email }}</td> <td style="padding:0.5rem;border-top:1px solid #f1f1f1">
<ul style="margin:0;padding-left:1rem;">
<li v-for="e in parseEmails(b.email)" :key="e" style="list-style:disc">{{ e }}</li>
</ul>
</td>
<td style="padding:0.5rem;border-top:1px solid #f1f1f1">{{ b.created_at }}</td> <td style="padding:0.5rem;border-top:1px solid #f1f1f1">{{ b.created_at }}</td>
</tr> </tr>
</tbody> </tbody>
@ -42,6 +46,11 @@ const bookings = ref([])
const loading = ref(false) const loading = ref(false)
const loadError = ref('') const loadError = ref('')
function parseEmails(text) {
if (!text) return []
return String(text).split(/[,\n;]+/).map(s => s.trim()).filter(Boolean)
}
async function load() { async function load() {
loading.value = true loading.value = true
loadError.value = '' loadError.value = ''