feat: add basis of project

This commit is contained in:
Antoine Pelletier 2025-10-11 03:21:04 +02:00
parent 0487f5c416
commit feab348528
16 changed files with 4740 additions and 38 deletions

31
backend/README.md Normal file
View file

@ -0,0 +1,31 @@
# agep-cargo Backend
This is a minimal Express backend for the AGEPOLY cargobike booking platform.
Setup
1. Copy the example env file and update it:
cp .env.example .env
# then edit .env to set DATABASE_URL
2. Install dependencies
npm install
3. Run migration (using psql or your database tool) to create the bookings table:
psql "$DATABASE_URL" -f migrations/create_bookings.sql
4. Start the server
npm run dev
API
- GET /api/health - health check
- GET /api/bikes - list available bike types
- POST /api/bookings - create a booking (bike_type, start_date, end_date, name, email)
- GET /api/bookings - list recent bookings

19
backend/db.js Normal file
View file

@ -0,0 +1,19 @@
import dotenv from 'dotenv'
import pkg from 'pg'
const { Pool } = pkg
dotenv.config()
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
// Optional SSL setting for hosted DBs (uncomment when needed)
// ssl: { rejectUnauthorized: false }
})
pool.on('error', (err) => {
console.error('Unexpected error on idle client', err)
process.exit(-1)
})
export { pool }

1392
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

20
backend/package.json Normal file
View file

@ -0,0 +1,20 @@
{
"name": "agep-cargo-backend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "nodemon server.js",
"start": "node server.js"
},
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"pg": "^8.11.0"
},
"devDependencies": {
"nodemon": "^3.0.1"
}
}

82
backend/server.js Normal file
View file

@ -0,0 +1,82 @@
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()
const app = express()
app.use(cors())
app.use(express.json())
// Simple health
app.get('/api/health', (req, res) => res.json({ ok: true }))
// List available cargobikes
app.get('/api/bikes', async (req, res) => {
// fixed bike types
const bikes = [1000, 2000, 3000, 4000, 5000]
res.json({ bikes })
})
// Create a booking
app.post('/api/bookings', async (req, res) => {
try {
const { bike_type, start_date, end_date, 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 result = await pool.query(text, values)
res.status(201).json({ booking: result.rows[0] })
} catch (err) {
console.error(err)
res.status(500).json({ error: 'Server error' })
}
})
// List bookings (simple)
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 })
} catch (err) {
console.error(err)
res.status(500).json({ error: 'Server error' })
}
})
// --- ensure DB tables exist (auto-migration for dev) ---
async function ensureTables() {
const createSql = `
CREATE TABLE IF NOT EXISTS bookings (
id SERIAL PRIMARY KEY,
bike_type INTEGER NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
name TEXT NOT NULL,
email TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now()
);
`
try {
await pool.query(createSql)
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
console.warn('Warning: could not ensure bookings table (DB may be unavailable).', err.message)
}
}
const port = process.env.PORT || 3000
// Run migrations (if possible) then start server
;(async () => {
await ensureTables()
app.listen(port, () => console.log(`Backend listening on port ${port}`))
})()

2873
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -12,7 +12,8 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"vue": "^3.5.22" "vue": "^3.5.22",
"vue-router": "^4.2.1"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^6.0.1", "@vitejs/plugin-vue": "^6.0.1",

View file

@ -1,47 +1,71 @@
<script setup> <script setup>
import HelloWorld from './components/HelloWorld.vue' import { ref, onMounted } from 'vue'
import TheWelcome from './components/TheWelcome.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> </script>
<template> <template>
<header> <div>
<img alt="Vue logo" class="logo" src="./assets/logo.svg" width="125" height="125" /> <header class="site-header">
<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>
<div class="wrapper"> <div class="links">
<HelloWorld msg="You did it!" /> <router-link to="/" class="nav-link">Réserver</router-link>
</div> <router-link to="/admin" class="nav-link">Gestion</router-link>
</header> </div>
<main> <div class="spacer"></div>
<TheWelcome />
</main> <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>
<main style="padding:1.5rem;max-width:980px;margin:0 auto">
<router-view />
</main>
</div>
</template> </template>
<style scoped> <style scoped>
header { .site-header { width: 100%; }
line-height: 1.5; .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 { .title { font-weight:600; color:var(--color-heading) }
display: block; .links { display:flex; gap:0.5rem }
margin: 0 auto 2rem; .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 }
@media (min-width: 1024px) { .theme-toggle { background:transparent; border:1px solid var(--color-border); padding:6px 8px; border-radius:6px; cursor:pointer }
header { @media (max-width:640px) {
display: flex; .links { display:none }
place-items: center; .title { font-size:0.95rem }
padding-right: calc(var(--section-gap) / 2); .logo { width:40px }
}
.logo {
margin: 0 2rem 0 0;
}
header .wrapper {
display: flex;
place-items: flex-start;
flex-wrap: wrap;
}
} }
</style> </style>

55
src/api.js Normal file
View file

@ -0,0 +1,55 @@
// 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: [] }
}
}

BIN
src/assets/logo-agep.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

View file

@ -0,0 +1,98 @@
<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>
<label>
La période de location - début
<input type="date" v-model="form.start_date" required />
</label>
<label>
La période de location - fin
<input type="date" v-model="form.end_date" required />
</label>
<label>
Ladresse mail associée au compte Linka Go à autoriser
<input type="email" v-model="form.linka_email" required />
</label>
<button :disabled="loading">{{ loading ? 'Envoi...' : 'Réserver' }}</button>
<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 } 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 loading = ref(false)
const error = ref('')
const success = ref(null)
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
}
})
async function submit() {
error.value = ''
success.value = null
loading.value = true
try {
// Map form to backend expected fields: name,email
const payload = {
bike_type: form.value.bike_type,
start_date: form.value.start_date,
end_date: form.value.end_date,
name: form.value.association,
email: form.value.linka_email,
}
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: '' }
} catch (err) {
error.value = err.message || String(err)
} finally {
loading.value = false
}
}
</script>
<style scoped>
form {
display: grid;
gap: 0.5rem;
max-width: 420px;
margin: 1rem auto;
padding: 1rem;
border: 1px solid #ddd;
border-radius: 6px;
}
label { display:block }
input, select { width:100%; padding:0.4rem; margin-top:0.2rem }
button { padding:0.6rem 1rem }
</style>

View file

@ -2,5 +2,8 @@ import './assets/main.css'
import { createApp } from 'vue' import { createApp } from 'vue'
import App from './App.vue' import App from './App.vue'
import router from './router'
createApp(App).mount('#app') const app = createApp(App)
app.use(router)
app.mount('#app')

65
src/pages/AdminPage.vue Normal file
View file

@ -0,0 +1,65 @@
<template>
<div>
<h1>Gestion des réservations</h1>
<p>Liste des dernières réservations</p>
<button @click="load" :disabled="loading">{{ loading ? 'Chargement...' : 'Rafraîchir' }}</button>
<p v-if="loadError" style="color:crimson;margin-top:1rem">Erreur lors du chargement des réservations: {{ loadError }}</p>
<table v-if="bookings.length" style="width:100%;margin-top:1rem;border-collapse:collapse">
<thead>
<tr>
<th style="border-bottom:1px solid #ddd;text-align:left;padding:0.5rem">ID</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">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">Créé</th>
</tr>
</thead>
<tbody>
<tr v-for="b in bookings" :key="b.id">
<td style="padding:0.5rem;border-top:1px solid #f1f1f1">{{ b.id }}</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.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">{{ b.created_at }}</td>
</tr>
</tbody>
</table>
<p v-else style="color:#666;margin-top:1rem">Aucune réservation trouvée.</p>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { listBookings } from '@/api'
const bookings = ref([])
const loading = ref(false)
const loadError = ref('')
async function load() {
loading.value = true
loadError.value = ''
try {
const res = await listBookings()
bookings.value = res.bookings || []
} catch (err) {
console.error(err)
bookings.value = []
loadError.value = err.message || String(err)
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<style scoped>
th, td { font-size: 0.95rem }
</style>

17
src/pages/BookingPage.vue Normal file
View file

@ -0,0 +1,17 @@
<template>
<div>
<h1>Réservation</h1>
<p>Utilisez ce formulaire pour réserver un cargobike AGEPOLY.</p>
<BookingForm />
</div>
</template>
<script setup>
import BookingForm from '@/components/BookingForm.vue'
</script>
<style scoped>
h1 { margin-bottom: 0.25rem }
p { color: #666; margin-bottom: 1rem }
</style>

16
src/router.js Normal file
View file

@ -0,0 +1,16 @@
import { createRouter, createWebHistory } from 'vue-router'
import BookingPage from './pages/BookingPage.vue'
import AdminPage from './pages/AdminPage.vue'
const routes = [
{ path: '/', name: 'Booking', component: BookingPage },
{ path: '/admin', name: 'Admin', component: AdminPage },
]
const router = createRouter({
history: createWebHistory(),
routes,
})
export default router

View file

@ -15,4 +15,10 @@ export default defineConfig({
'@': fileURLToPath(new URL('./src', import.meta.url)) '@': fileURLToPath(new URL('./src', import.meta.url))
}, },
}, },
// Proxy API requests during development to the backend server
server: {
proxy: {
'/api': 'http://localhost:3000'
}
}
}) })