feat: add phone version
This commit is contained in:
parent
926b1917fc
commit
a05fc140d2
5 changed files with 99 additions and 35 deletions
|
|
@ -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' })
|
||||
|
|
|
|||
12
src/App.vue
12
src/App.vue
|
|
@ -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>
|
||||
|
|
|
|||
BIN
src/assets/cargobike-icon.png
Normal file
BIN
src/assets/cargobike-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5 KiB |
|
|
@ -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,14 +162,14 @@ 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 class="absolute text-lg font-semibold">
|
||||
|
|
|
|||
|
|
@ -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 l’association" />
|
||||
<input id="association" v-model.trim="form.association" type="text" class="input w-full" 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-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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue