feat: add phone version

This commit is contained in:
Antoine Pelletier 2025-10-14 03:23:20 +02:00
parent 926b1917fc
commit a05fc140d2
5 changed files with 99 additions and 35 deletions

View file

@ -9,6 +9,61 @@ const app = express()
app.use(cors())
app.use(express.json())
// Helper: normalize booking row fields to stable strings (avoid timezone surprises)
function sanitizeBooking(row) {
const normDate = (v) => {
if (!v) return v
if (typeof v === 'string') {
// If it's a plain ISO date (no time), keep as-is
const exact = /^(\d{4})-(\d{2})-(\d{2})$/.exec(v)
if (exact) return `${exact[1]}-${exact[2]}-${exact[3]}`
// If string contains time part, parse and convert to local date components
if (v.includes('T')) {
const d = new Date(v)
if (!isNaN(d.getTime())) {
const yyyy = d.getFullYear()
const mm = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
return `${yyyy}-${mm}-${dd}`
}
}
// Try generic parse fallback
const d = new Date(v)
if (!isNaN(d.getTime())) {
const yyyy = d.getFullYear()
const mm = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
return `${yyyy}-${mm}-${dd}`
}
return v
}
if (v instanceof Date) {
const yyyy = v.getFullYear()
const mm = String(v.getMonth() + 1).padStart(2, '0')
const dd = String(v.getDate()).padStart(2, '0')
return `${yyyy}-${mm}-${dd}`
}
return v
}
const normTime = (v) => {
if (!v) return v
if (typeof v === 'string') {
// Expect 'HH:MM:SS' or 'HH:MM'
const parts = v.split(':')
if (parts.length >= 2) return `${parts[0].padStart(2,'0')}:${parts[1].padStart(2,'0')}`
return v
}
return v
}
return {
...row,
start_date: normDate(row.start_date),
end_date: normDate(row.end_date),
start_time: normTime(row.start_time),
end_time: normTime(row.end_time),
}
}
// Simple health
app.get('/api/health', (req, res) => res.json({ ok: true }))
@ -36,7 +91,7 @@ app.post('/api/bookings', async (req, res) => {
VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`
const values = [single, multi.length ? multi.join(',') : null, start_date, start_time || null, end_date, end_time || null, name, email, 'pending']
const result = await pool.query(text, values)
res.status(201).json({ booking: result.rows[0] })
res.status(201).json({ booking: sanitizeBooking(result.rows[0]) })
} catch (err) {
console.error(err)
res.status(500).json({ error: 'Server error' })
@ -47,7 +102,7 @@ app.post('/api/bookings', async (req, res) => {
app.get('/api/bookings', async (req, res) => {
try {
const result = await pool.query('SELECT * FROM bookings ORDER BY created_at DESC LIMIT 100')
res.json({ bookings: result.rows })
res.json({ bookings: result.rows.map(sanitizeBooking) })
} catch (err) {
console.error(err)
res.status(500).json({ error: 'Server error' })
@ -75,7 +130,7 @@ app.patch('/api/bookings/:id/status', async (req, res) => {
const result = await pool.query('UPDATE bookings SET status = $1 WHERE id = $2 RETURNING *', [status, id])
if (result.rowCount === 0) return res.status(404).json({ error: 'Booking not found' })
res.json({ booking: result.rows[0] })
res.json({ booking: sanitizeBooking(result.rows[0]) })
} catch (err) {
console.error(err)
res.status(500).json({ error: 'Server error' })
@ -89,7 +144,7 @@ app.patch('/api/bookings/:id', async (req, res) => {
if (!Number.isFinite(id)) return res.status(400).json({ error: 'Invalid id' })
const allowed = ['bike_type', 'bike_types', 'start_date', 'start_time', 'end_date', 'end_time', 'name', 'email']
const entries = Object.entries(req.body || {}).filter(([k, v]) => allowed.includes(k))
const entries = Object.entries(req.body || {}).filter(([k]) => allowed.includes(k))
if (entries.length === 0) return res.status(400).json({ error: 'No valid fields to update' })
@ -107,7 +162,7 @@ app.patch('/api/bookings/:id', async (req, res) => {
const result = await pool.query(sql, values)
if (result.rowCount === 0) return res.status(404).json({ error: 'Booking not found' })
res.json({ booking: result.rows[0] })
res.json({ booking: sanitizeBooking(result.rows[0]) })
} catch (err) {
console.error(err)
res.status(500).json({ error: 'Server error' })
@ -123,7 +178,7 @@ app.delete('/api/bookings/:id', async (req, res) => {
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] })
res.json({ ok: true, booking: sanitizeBooking(result.rows[0]) })
} catch (err) {
console.error(err)
res.status(500).json({ error: 'Server error' })

View file

@ -1,13 +1,14 @@
<template>
<div class="min-h-screen bg-[hsl(var(--background))] text-[hsl(var(--foreground))]">
<header class="border-b border-border">
<div class="container flex h-14 items-center justify-between">
<div class="flex items-center gap-3">
<img :src="logo" alt="AGEP" class="h-8 w-auto object-contain" />
<div class="container flex flex-wrap items-center gap-3 justify-center sm:justify-between py-2">
<div class="flex items-center gap-3 justify-center sm:justify-start w-full sm:w-auto min-w-0">
<img :src="logo" alt="AGEP" class="h-8 w-auto object-contain shrink-0" />
<RouterLink to="/" class="text-base font-semibold text-blue-700 hover:text-blue-800 hover:bg-transparent">Cargobikes</RouterLink>
<img :src="cargoIcon" alt="Cargobike Icon" class="h-6 w-auto object-contain shrink-0 hidden sm:block" />
</div>
<div class="flex items-center gap-4 text-sm">
<nav>
<div class="flex items-center gap-4 text-sm justify-center sm:justify-end w-full sm:w-auto">
<nav class="flex gap-3">
<RouterLink to="/" class="text-blue-700 hover:text-blue-800 hover:bg-transparent">Réserver</RouterLink>
<RouterLink to="/admin" class="text-blue-700 hover:text-blue-800 hover:bg-transparent">Admin</RouterLink>
</nav>
@ -25,5 +26,6 @@
<script setup>
import { RouterLink, RouterView } from 'vue-router'
import logo from '@/assets/logo-agep.png'
import cargoIcon from '@/assets/cargobike-icon.png'
import DarkMode from "@/components/DarkMode.vue";
</script>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5 KiB

View file

@ -126,26 +126,26 @@ const degMinute = (m) => m * 6 + 270 // 360/60 = 6°
const outerRing = 85;
const innerRing = 55;
function transHour(h) {
if (h==0) return innerRing;
if (h===0) return innerRing;
return h <= 12 ? outerRing : innerRing;
}
</script>
<template>
<div class="flex flex-row items-center gap-3">
<div class="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 sm:gap-3 w-full">
<!-- Date -->
<Popover v-model:open="dateOpen">
<PopoverTrigger as-child>
<Button
type="button"
variant="outline"
:class="cn('w-[280px] justify-start text-left font-normal', !date && 'text-muted-foreground')"
:class="cn('w-full sm:w-[280px] justify-start text-left font-normal', !date && 'text-muted-foreground')"
>
<CalendarIcon class="mr-2 h-4 w-4" />
{{ formattedDate }}
</Button>
</PopoverTrigger>
<PopoverContent class="z-50 w-auto p-0">
<PopoverContent class="z-50 w-auto max-w-[95vw] p-0">
<Calendar
v-model="calendarDay"
mode="single"
@ -162,16 +162,16 @@ function transHour(h) {
<Button
type="button"
variant="outline"
:class="cn('w-[280px] justify-start text-left font-normal', hour24 === null && 'text-muted-foreground')"
:class="cn('w-full sm:w-[280px] justify-start text-left font-normal', hour24 === null && 'text-muted-foreground')"
>
<Clock class="mr-2 h-4 w-4" />
{{ formattedTime }}
</Button>
</PopoverTrigger>
<PopoverContent class="z-50 w-[300px] flex flex-col items-center p-4">
<PopoverContent class="z-50 w-[300px] max-w-[95vw] flex flex-col items-center p-4">
<!-- Hour ring (0..23) with 00 at top -->
<div v-if="!showMinutes" class="relative w-[220px] h-[220px] flex items-center justify-center":style="{border: 'solid',borderRadius: '50%'}">
<div v-if="!showMinutes" class="relative w-[220px] h-[220px] flex items-center justify-center" :style="{border: 'solid', borderRadius: '50%'}">
<div class="absolute text-lg font-semibold">
{{ hour24 ?? 'HH' }}
</div>

View file

@ -1,24 +1,26 @@
<template>
<section class="grid gap-6">
<section class="grid gap-6 max-w-md mx-auto"> <!-- Constrain page to form width and center -->
<div class="card">
<div class="card-header space-y-4">
<h1 class="card-title">Réserver un Cargobike</h1>
<p class="text-sm text-slate-500">Remplissez les informations ci-dessous pour soumettre votre demande de réservation.</p>
</div>
<div class="card-content">
<form @submit.prevent="onSubmit" class="grid gap-5">
<!-- Form gets the single shared max width -->
<form @submit.prevent="onSubmit" class="grid gap-5 w-full"> <!-- let section control max width -->
<!-- Association (mapped to backend: name) -->
<div class="grid gap-2">
<div class="grid gap-2 w-full">
<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" />
<input id="association" v-model.trim="form.association" type="text" class="input w-full" 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-5">
<!-- Removed local max-w-md so it inherits the form's width -->
<div class="grid gap-5 w-full">
<span class="label">Taille de vélo souhaitée</span>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 items-start">
<div class="grid gap-2 self-start">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 items-start w-full">
<div class="grid gap-2 self-start w-full">
<div class="text-xs text-slate-500">Grands cargos</div>
<button
v-for="b in bigBikes"
@ -32,7 +34,7 @@
{{ b }}
</button>
</div>
<div class="grid gap-2 self-start">
<div class="grid gap-2 self-start w-full">
<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"
@ -48,25 +50,30 @@
</div>
<!-- Period: start -->
<div class="grid gap-2">
<div class="grid gap-2 w-full">
<span class="label">Début de la réservation</span>
<DateTimePicker v-model="startPicked" :use24h="true" :minute-step="5" locale="fr-FR" />
<!-- Ensure DateTimePicker fills the width -->
<div class="w-full">
<DateTimePicker class="w-full" v-model="startPicked" :use24h="true" :minute-step="5" locale="fr-FR" />
</div>
<p v-if="errors.start" class="helper text-red-600">{{ errors.start }}</p>
</div>
<!-- Period: end -->
<div class="grid gap-2">
<div class="grid gap-2 w-full">
<span class="label">Fin de la réservation</span>
<DateTimePicker v-model="endPicked" :use24h="true" :minute-step="5" locale="fr-FR" />
<div class="w-full">
<DateTimePicker class="w-full" v-model="endPicked" :use24h="true" :minute-step="5" locale="fr-FR" />
</div>
<p v-if="errors.end" class="helper text-red-600">{{ errors.end }}</p>
</div>
<!-- Linka Go emails (multiple) -->
<div class="grid gap-2">
<div class="grid gap-2 w-full">
<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" />
<div v-for="(_, idx) in form.emails" :key="idx" class="flex gap-2">
<input :id="`email-${idx}`" v-model.trim="form.emails[idx]" type="email" class="input w-full" 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>
@ -162,8 +169,8 @@ function toggleBike(b) {
}
function buttonClass(b) {
return isSelected(b)
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))] border-transparent'
: 'hover:bg-[hsl(var(--selected))]'
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))] border-transparent'
: 'hover:bg-[hsl(var(--selected))]'
}
function addEmail() { form.emails.push('') }