feat: add basis of project
This commit is contained in:
parent
0487f5c416
commit
feab348528
16 changed files with 4740 additions and 38 deletions
31
backend/README.md
Normal file
31
backend/README.md
Normal 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
19
backend/db.js
Normal 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
1392
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
20
backend/package.json
Normal file
20
backend/package.json
Normal 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
82
backend/server.js
Normal 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
2873
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -12,7 +12,8 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.22"
|
||||
"vue": "^3.5.22",
|
||||
"vue-router": "^4.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
|
|
|
|||
92
src/App.vue
92
src/App.vue
|
|
@ -1,47 +1,71 @@
|
|||
<script setup>
|
||||
import HelloWorld from './components/HelloWorld.vue'
|
||||
import TheWelcome from './components/TheWelcome.vue'
|
||||
import { ref, onMounted } from '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>
|
||||
|
||||
<template>
|
||||
<header>
|
||||
<img alt="Vue logo" class="logo" src="./assets/logo.svg" width="125" height="125" />
|
||||
|
||||
<div class="wrapper">
|
||||
<HelloWorld msg="You did it!" />
|
||||
<div>
|
||||
<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="links">
|
||||
<router-link to="/" class="nav-link">Réserver</router-link>
|
||||
<router-link to="/admin" class="nav-link">Gestion</router-link>
|
||||
</div>
|
||||
|
||||
<div class="spacer"></div>
|
||||
|
||||
<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>
|
||||
<TheWelcome />
|
||||
<main style="padding:1.5rem;max-width:980px;margin:0 auto">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
header {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: block;
|
||||
margin: 0 auto 2rem;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
header {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
padding-right: calc(var(--section-gap) / 2);
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin: 0 2rem 0 0;
|
||||
}
|
||||
|
||||
header .wrapper {
|
||||
display: flex;
|
||||
place-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.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 }
|
||||
.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 }
|
||||
.nav-link:hover { background:var(--color-background-mute) }
|
||||
.spacer { flex:1 }
|
||||
.theme-toggle { background:transparent; border:1px solid var(--color-border); padding:6px 8px; border-radius:6px; cursor:pointer }
|
||||
@media (max-width:640px) {
|
||||
.links { display:none }
|
||||
.title { font-size:0.95rem }
|
||||
.logo { width:40px }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
55
src/api.js
Normal file
55
src/api.js
Normal 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
BIN
src/assets/logo-agep.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
98
src/components/BookingForm.vue
Normal file
98
src/components/BookingForm.vue
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
<template>
|
||||
<form @submit.prevent="submit">
|
||||
<h2>Réserver un cargobike</h2>
|
||||
|
||||
<label>
|
||||
L’association 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>
|
||||
L’adresse 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>
|
||||
|
|
@ -2,5 +2,8 @@ import './assets/main.css'
|
|||
|
||||
import { createApp } from '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
65
src/pages/AdminPage.vue
Normal 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
17
src/pages/BookingPage.vue
Normal 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
16
src/router.js
Normal 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
|
||||
|
||||
|
|
@ -15,4 +15,10 @@ export default defineConfig({
|
|||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
},
|
||||
},
|
||||
// Proxy API requests during development to the backend server
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3000'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue