feat: improve agep page
This commit is contained in:
parent
feab348528
commit
47e036e8be
7 changed files with 179 additions and 49 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -32,3 +32,4 @@ yarn-error.log*
|
|||
# Others
|
||||
public/*.tmp
|
||||
|
||||
/backend/migrations/create_bookings.sql
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@ import express from 'express'
|
|||
import cors from 'cors'
|
||||
import dotenv from 'dotenv'
|
||||
import { pool } from './db.js'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
dotenv.config()
|
||||
|
||||
|
|
@ -24,13 +22,13 @@ app.get('/api/bikes', async (req, res) => {
|
|||
// Create a booking
|
||||
app.post('/api/bookings', async (req, res) => {
|
||||
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) {
|
||||
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 values = [bike_type, start_date, end_date, name, email]
|
||||
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, start_time || null, end_date, end_time || null, name, email]
|
||||
const result = await pool.query(text, values)
|
||||
res.status(201).json({ booking: result.rows[0] })
|
||||
} catch (err) {
|
||||
|
|
@ -57,7 +55,9 @@ async function ensureTables() {
|
|||
id SERIAL PRIMARY KEY,
|
||||
bike_type INTEGER NOT NULL,
|
||||
start_date DATE NOT NULL,
|
||||
start_time TIME,
|
||||
end_date DATE NOT NULL,
|
||||
end_time TIME,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT now()
|
||||
|
|
@ -66,6 +66,9 @@ async function ensureTables() {
|
|||
|
||||
try {
|
||||
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)')
|
||||
} catch (err) {
|
||||
// If DATABASE_URL is not configured or DB not reachable, warn but still allow server to start for frontend dev
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ function toggleTheme() {
|
|||
<nav class="container">
|
||||
<div class="brand">
|
||||
<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 class="links">
|
||||
|
|
@ -56,7 +56,7 @@ function toggleTheme() {
|
|||
.site-header { width: 100%; }
|
||||
.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 }
|
||||
.logo { width:48px; height:auto }
|
||||
.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 }
|
||||
|
|
|
|||
|
|
@ -33,9 +33,39 @@
|
|||
--color-heading: 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;
|
||||
}
|
||||
|
||||
/* 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) {
|
||||
:root {
|
||||
--color-background: var(--vt-c-black);
|
||||
|
|
@ -63,8 +93,8 @@ body {
|
|||
color: var(--color-text);
|
||||
background: var(--color-background);
|
||||
transition:
|
||||
color 0.5s,
|
||||
background-color 0.5s;
|
||||
color 0.25s ease,
|
||||
background-color 0.25s ease;
|
||||
line-height: 1.6;
|
||||
font-family:
|
||||
Inter,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
@import './base.css';
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
/* allow the app to use full width; the header/nav control max width via `.container` */
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
|
|
@ -23,13 +24,12 @@ a,
|
|||
|
||||
@media (min-width: 1024px) {
|
||||
body {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
/* avoid forcing body into a flex/grid that constrains the header */
|
||||
display: block;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
padding: 0 2rem;
|
||||
/* keep a comfortable inner padding on wide screens */
|
||||
padding: 1.5rem 2rem;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,22 +14,40 @@
|
|||
</select>
|
||||
</label>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.5rem">
|
||||
<label>
|
||||
La période de location - début
|
||||
Début de la location
|
||||
<input type="date" v-model="form.start_date" required />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
La période de location - fin
|
||||
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>
|
||||
L’adresse mail associée au compte Linka Go à autoriser
|
||||
<input type="email" v-model="form.linka_email" required />
|
||||
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>
|
||||
|
|
@ -37,16 +55,20 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { getBikes, createBooking } from '@/api'
|
||||
|
||||
const bikes = ref([1000,2000,3000,4000,5000])
|
||||
// map: association -> name column in DB, linka_email -> email column in DB
|
||||
const form = ref({ association: '', bike_type: 1000, start_date: '', end_date: '', linka_email: '' })
|
||||
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()
|
||||
|
|
@ -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() {
|
||||
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: name,email
|
||||
// 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,
|
||||
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,
|
||||
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)
|
||||
success.value = res.booking
|
||||
// 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) {
|
||||
error.value = err.message || String(err)
|
||||
} finally {
|
||||
|
|
@ -86,13 +171,15 @@ async function submit() {
|
|||
form {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
max-width: 420px;
|
||||
max-width: 520px;
|
||||
margin: 1rem auto;
|
||||
padding: 1rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-form-bg);
|
||||
}
|
||||
label { display:block }
|
||||
input, select { width:100%; padding:0.4rem; margin-top:0.2rem }
|
||||
button { padding:0.6rem 1rem }
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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">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">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>
|
||||
</tr>
|
||||
</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.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.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>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
|
@ -42,6 +46,11 @@ const bookings = ref([])
|
|||
const loading = ref(false)
|
||||
const loadError = ref('')
|
||||
|
||||
function parseEmails(text) {
|
||||
if (!text) return []
|
||||
return String(text).split(/[,\n;]+/).map(s => s.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue