Initial commit: CSV Vie Locale
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
uploads/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -0,0 +1,87 @@
|
||||
# CSV - Plateforme de Vie Locale
|
||||
|
||||
Application communautaire : articles, événements, petites annonces (listings), demandes d'aide, annuaire d'entreprises locales, avec comptes utilisateurs et panneau d'administration.
|
||||
|
||||
## Stack technique
|
||||
|
||||
| Composant | Technologie |
|
||||
|-----------|------------|
|
||||
| Backend | Node.js + Express (ESM) |
|
||||
| Frontend | React 18 + Vite 5 + Tailwind 3 |
|
||||
| Base de données | MariaDB (instance distante, LXC 102) |
|
||||
| Authentification | JWT (jsonwebtoken) + bcryptjs |
|
||||
| Uploads | Multer |
|
||||
|
||||
## Déploiement
|
||||
|
||||
```bash
|
||||
cd /opt/csv
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
- **Backend** : http://localhost:3002
|
||||
- **Frontend** : http://localhost:3003
|
||||
|
||||
La BDD est auto-créée au démarrage du backend (tables `users`, `articles`, `events`, `listings`, `help_requests`, `businesses`, `categories`).
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
/opt/csv/
|
||||
├── backend/
|
||||
│ ├── src/
|
||||
│ │ ├── index.js # Point d'entrée Express + auto-create tables
|
||||
│ │ ├── config/db.js # Pool MySQL
|
||||
│ │ ├── routes/
|
||||
│ │ │ ├── auth.js # Inscription, connexion, profil
|
||||
│ │ │ ├── articles.js # CRUD articles
|
||||
│ │ │ ├── events.js # CRUD événements
|
||||
│ │ │ ├── listings.js # Petites annonces
|
||||
│ │ │ ├── helpRequests.js # Demandes d'aide
|
||||
│ │ │ └── businesses.js # Annuaire entreprises
|
||||
│ │ └── uploads/ # Fichiers uploadés
|
||||
│ └── package.json
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
│ │ ├── App.jsx # Routeur React
|
||||
│ │ ├── components/ # Composants UI
|
||||
│ │ └── pages/ # Pages
|
||||
│ └── package.json
|
||||
├── docker-compose.yml
|
||||
└── nginx.conf
|
||||
```
|
||||
|
||||
## API Routes
|
||||
|
||||
| Méthode | Route | Description |
|
||||
|---------|-------|-------------|
|
||||
| POST | `/api/auth/register` | Inscription |
|
||||
| POST | `/api/auth/login` | Connexion |
|
||||
| GET/POST | `/api/articles` | Liste/Création articles |
|
||||
| GET/POST | `/api/events` | Liste/Création événements |
|
||||
| GET/POST | `/api/listings` | Liste/Création petites annonces |
|
||||
| GET/POST | `/api/help-requests` | Liste/Création demandes d'aide |
|
||||
| GET/POST | `/api/businesses` | Liste/Création entreprises |
|
||||
|
||||
## Variables d'environnement
|
||||
|
||||
| Variable | Description | Valeur |
|
||||
|----------|-------------|--------|
|
||||
| `PORT` | Port backend | `3002` |
|
||||
| `DB_HOST` | Host MariaDB | `10.10.10.11` |
|
||||
| `DB_PORT` | Port MariaDB | `3306` |
|
||||
| `DB_USER` | Utilisateur BDD | `csvusr` |
|
||||
| `DB_PASSWORD` | Mot de passe BDD | `Fdp1989Dor@` |
|
||||
| `DB_NAME` | Nom de la BDD | `csv` |
|
||||
| `JWT_SECRET` | Secret JWT | `csv-secret-key-change-in-production` |
|
||||
|
||||
> **Sécurité** : Le JWT_SECRET est une valeur par défaut. Il doit être changé en production.
|
||||
|
||||
## Domaine de production
|
||||
|
||||
- **URL** : https://csv.secourit.fr
|
||||
- **LXC** : 106 (IP 10.10.10.23)
|
||||
- **Backend** : port 3002
|
||||
- **Frontend** : port 3003
|
||||
- **SSL** : Let's Encrypt via Nginx Proxy Manager
|
||||
- **BDD** : MariaDB sur LXC 102 (10.10.10.11), base `csv`
|
||||
@@ -0,0 +1,7 @@
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
COPY package.json .
|
||||
RUN npm install --production
|
||||
COPY . .
|
||||
EXPOSE 3002
|
||||
CMD ["node", "src/index.js"]
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "csv-backend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node --watch src/index.js",
|
||||
"start": "node src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.21.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"mysql2": "^3.11.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
|
||||
const pool = mysql.createPool({
|
||||
host: process.env.DB_HOST || '127.0.0.1',
|
||||
port: parseInt(process.env.DB_PORT || '3306'),
|
||||
user: process.env.DB_USER || 'csvusr',
|
||||
password: process.env.DB_PASSWORD || 'Fdp1989Dor@',
|
||||
database: process.env.DB_NAME || 'csv',
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
charset: 'utf8mb4'
|
||||
});
|
||||
|
||||
export default pool;
|
||||
@@ -0,0 +1,154 @@
|
||||
import express from 'express';
|
||||
import multer from 'multer';
|
||||
import cors from 'cors';
|
||||
import dotenv from 'dotenv';
|
||||
import pool from './config/db.js';
|
||||
import authRoutes from './routes/auth.js';
|
||||
import articleRoutes from './routes/articles.js';
|
||||
import eventRoutes from './routes/events.js';
|
||||
import listingRoutes from './routes/listings.js';
|
||||
import helpRoutes from './routes/helpRequests.js';
|
||||
import businessRoutes from './routes/businesses.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3002;
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use('/uploads', express.static('uploads'));
|
||||
app.use('/api/uploads', express.static('uploads'));
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, 'uploads/'),
|
||||
filename: (req, file, cb) => cb(null, Date.now() + '-' + file.originalname)
|
||||
});
|
||||
const upload = multer({ storage });
|
||||
app.use('/api/uploads', express.static('uploads'));
|
||||
|
||||
const initDB = async () => {
|
||||
const conn = await pool.getConnection();
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
password VARCHAR(255) NOT NULL,
|
||||
phone VARCHAR(20),
|
||||
address VARCHAR(255),
|
||||
role ENUM('user','admin') DEFAULT 'user',
|
||||
avatar VARCHAR(255),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS articles (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
image VARCHAR(255),
|
||||
author_id INT,
|
||||
published TINYINT(1) DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
)
|
||||
`);
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
date DATE NOT NULL,
|
||||
time TIME,
|
||||
location VARCHAR(255),
|
||||
image VARCHAR(255),
|
||||
category VARCHAR(50) DEFAULT 'autre',
|
||||
author_id INT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
)
|
||||
`);
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL UNIQUE,
|
||||
type ENUM('listing','event') DEFAULT 'listing'
|
||||
)
|
||||
`);
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS listings (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
price DECIMAL(10,2) DEFAULT 0,
|
||||
type ENUM('don','vente','troc') DEFAULT 'don',
|
||||
category_id INT,
|
||||
images TEXT,
|
||||
status ENUM('disponible','archive','vendu') DEFAULT 'disponible',
|
||||
approved TINYINT(1) DEFAULT 1,
|
||||
author_id INT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL
|
||||
)
|
||||
`);
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS help_requests (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
category ENUM('course','bricolage','jardinage','transport','numerique','autre') DEFAULT 'autre',
|
||||
status ENUM('ouvert','en_cours','resolu') DEFAULT 'ouvert',
|
||||
approved TINYINT(1) DEFAULT 1,
|
||||
urgent TINYINT(1) DEFAULT 0,
|
||||
author_id INT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS businesses (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
category VARCHAR(100),
|
||||
address VARCHAR(255),
|
||||
phone VARCHAR(20),
|
||||
website VARCHAR(255),
|
||||
email VARCHAR(255),
|
||||
image VARCHAR(255),
|
||||
owner_id INT,
|
||||
approved TINYINT(1) DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
await conn.query(`
|
||||
INSERT IGNORE INTO categories (name, type) VALUES
|
||||
('Meuble','listing'), ('Electromenager','listing'), ('Vetement','listing'),
|
||||
('Livres','listing'), ('Jouets','listing'), ('Bricolage','listing'),
|
||||
('Jardinage','listing'), ('Animaux','listing'), ('Autre','listing')
|
||||
`);
|
||||
conn.release();
|
||||
console.log('DB initialized');
|
||||
};
|
||||
|
||||
initDB().catch(console.error);
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/articles', articleRoutes);
|
||||
app.use('/api/events', eventRoutes);
|
||||
app.use('/api/listings', listingRoutes);
|
||||
app.use('/api/help', helpRoutes);
|
||||
app.use('/api/businesses', businessRoutes);
|
||||
|
||||
app.get('/api/health', (_, res) => res.json({ status: 'ok' }));
|
||||
app.post('/api/upload', upload.single('file'), (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'Aucun fichier' });
|
||||
res.json({ url: '/api/uploads/' + req.file.filename });
|
||||
});
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log('CSV API running on port', PORT);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'csv-secret-key-change-in-production';
|
||||
|
||||
export const authenticate = (req, res, next) => {
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
if (!token) return res.status(401).json({ error: 'Authentification requise' });
|
||||
try {
|
||||
req.user = jwt.verify(token, JWT_SECRET);
|
||||
next();
|
||||
} catch {
|
||||
res.status(401).json({ error: 'Token invalide' });
|
||||
}
|
||||
};
|
||||
|
||||
export const optionalAuth = (req, res, next) => {
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
if (token) {
|
||||
try { req.user = jwt.verify(token, JWT_SECRET); } catch {}
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
export const requireAdmin = (req, res, next) => {
|
||||
if (!req.user || req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Accès réservé aux administrateurs' });
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
export { JWT_SECRET };
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Router } from 'express';
|
||||
import pool from '../config/db.js';
|
||||
import { authenticate, requireAdmin, optionalAuth } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', optionalAuth, async (req, res) => {
|
||||
try {
|
||||
const [rows] = await pool.execute(
|
||||
'SELECT a.*, u.name as author_name FROM articles a LEFT JOIN users u ON a.author_id = u.id WHERE a.published = 1 OR (a.author_id = ? AND a.published = 0) ORDER BY a.created_at DESC',
|
||||
[req.user?.id || 0]
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.get('/:id', optionalAuth, async (req, res) => {
|
||||
try {
|
||||
const [rows] = await pool.execute(
|
||||
'SELECT a.*, u.name as author_name FROM articles a LEFT JOIN users u ON a.author_id = u.id WHERE a.id = ? AND (a.published = 1 OR a.author_id = ?)',
|
||||
[req.params.id, req.user?.id || 0]
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Article introuvable' });
|
||||
res.json(rows[0]);
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.post('/', authenticate, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { title, content, image } = req.body;
|
||||
if (!title || !content) return res.status(400).json({ error: 'Titre et contenu requis' });
|
||||
const [result] = await pool.execute(
|
||||
'INSERT INTO articles (title, content, image, author_id) VALUES (?, ?, ?, ?)',
|
||||
[title, content, image || null, req.user.id]
|
||||
);
|
||||
res.status(201).json({ id: result.insertId, message: 'Article créé' });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.put('/:id', authenticate, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { title, content, image, published } = req.body;
|
||||
await pool.execute(
|
||||
'UPDATE articles SET title = ?, content = ?, image = ?, published = ? WHERE id = ?',
|
||||
[title, content, image || null, published ?? 1, req.params.id]
|
||||
);
|
||||
res.json({ message: 'Article mis à jour' });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.delete('/:id', authenticate, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
await pool.execute('DELETE FROM articles WHERE id = ?', [req.params.id]);
|
||||
res.json({ message: 'Article supprimé' });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Router } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import pool from '../config/db.js';
|
||||
import { authenticate, JWT_SECRET } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post('/register', async (req, res) => {
|
||||
try {
|
||||
const { name, email, password, phone, address } = req.body;
|
||||
if (!name || !email || !password) {
|
||||
return res.status(400).json({ error: 'Nom, email et mot de passe requis' });
|
||||
}
|
||||
if (password.length < 6) {
|
||||
return res.status(400).json({ error: 'Mot de passe trop court (6 car. min)' });
|
||||
}
|
||||
const [existing] = await pool.execute('SELECT id FROM users WHERE email = ?', [email]);
|
||||
if (existing.length) {
|
||||
return res.status(409).json({ error: 'Cet email est déjà utilisé' });
|
||||
}
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
const [result] = await pool.execute(
|
||||
'INSERT INTO users (name, email, password, phone, address) VALUES (?, ?, ?, ?, ?)',
|
||||
[name, email, hash, phone || null, address || null]
|
||||
);
|
||||
const token = jwt.sign({ id: result.insertId, name, email, role: 'user' }, JWT_SECRET, { expiresIn: '30d' });
|
||||
res.status(201).json({ token, user: { id: result.insertId, name, email, role: 'user' } });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ error: 'Email et mot de passe requis' });
|
||||
}
|
||||
const [users] = await pool.execute('SELECT * FROM users WHERE email = ?', [email]);
|
||||
if (!users.length) {
|
||||
return res.status(401).json({ error: 'Email ou mot de passe incorrect' });
|
||||
}
|
||||
const user = users[0];
|
||||
const valid = await bcrypt.compare(password, user.password);
|
||||
if (!valid) {
|
||||
return res.status(401).json({ error: 'Email ou mot de passe incorrect' });
|
||||
}
|
||||
const token = jwt.sign(
|
||||
{ id: user.id, name: user.name, email: user.email, role: user.role },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '30d' }
|
||||
);
|
||||
res.json({
|
||||
token,
|
||||
user: { id: user.id, name: user.name, email: user.email, role: user.role, phone: user.phone, address: user.address }
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/me', authenticate, async (req, res) => {
|
||||
try {
|
||||
const [users] = await pool.execute('SELECT id, name, email, role, phone, address, avatar, created_at FROM users WHERE id = ?', [req.user.id]);
|
||||
if (!users.length) return res.status(404).json({ error: 'Utilisateur introuvable' });
|
||||
res.json(users[0]);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/profile', authenticate, async (req, res) => {
|
||||
try {
|
||||
const { name, phone, address } = req.body;
|
||||
await pool.execute('UPDATE users SET name = ?, phone = ?, address = ? WHERE id = ?', [name, phone, address, req.user.id]);
|
||||
res.json({ message: 'Profil mis à jour' });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Router } from "express";
|
||||
import pool from "../config/db.js";
|
||||
import { authenticate, requireAdmin, optionalAuth } from "../middleware/auth.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/", async (req, res) => {
|
||||
try {
|
||||
const [rows] = await pool.execute(
|
||||
"SELECT * FROM businesses WHERE approved = 1 ORDER BY name"
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.get("/pending", authenticate, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const [rows] = await pool.execute("SELECT * FROM businesses WHERE approved = 0 ORDER BY created_at DESC");
|
||||
res.json(rows);
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.get("/mine", authenticate, async (req, res) => {
|
||||
try {
|
||||
const [rows] = await pool.execute("SELECT * FROM businesses WHERE owner_id = ? ORDER BY created_at DESC", [req.user.id]);
|
||||
res.json(rows);
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.get("/:id", async (req, res) => {
|
||||
try {
|
||||
const [rows] = await pool.execute("SELECT * FROM businesses WHERE id = ?", [req.params.id]);
|
||||
if (!rows.length) return res.status(404).json({ error: "Commerce introuvable" });
|
||||
res.json(rows[0]);
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.post("/", authenticate, async (req, res) => {
|
||||
try {
|
||||
const { name, description, category, address, phone, website, email, image } = req.body;
|
||||
if (!name) return res.status(400).json({ error: "Nom requis" });
|
||||
const [result] = await pool.execute(
|
||||
"INSERT INTO businesses (name, description, category, address, phone, website, email, image, owner_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
[name, description || null, category || null, address || null, phone || null, website || null, email || null, image || null, req.user.id]
|
||||
);
|
||||
res.status(201).json({ id: result.insertId, message: "Commerce soumis, en attente d'approbation" });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.put("/:id", authenticate, async (req, res) => {
|
||||
try {
|
||||
const [businesses] = await pool.execute("SELECT * FROM businesses WHERE id = ?", [req.params.id]);
|
||||
if (!businesses.length) return res.status(404).json({ error: "Commerce introuvable" });
|
||||
if (businesses[0].owner_id !== req.user.id && req.user.role !== "admin") {
|
||||
return res.status(403).json({ error: "Action non autorisee" });
|
||||
}
|
||||
const { name, description, category, address, phone, website, email, image } = req.body;
|
||||
await pool.execute(
|
||||
"UPDATE businesses SET name=?, description=?, category=?, address=?, phone=?, website=?, email=?, image=? WHERE id=?",
|
||||
[name, description, category, address, phone, website, email, image, req.params.id]
|
||||
);
|
||||
res.json({ message: "Commerce mis a jour" });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.put("/:id/approve", authenticate, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
await pool.execute("UPDATE businesses SET approved = 1 WHERE id = ?", [req.params.id]);
|
||||
res.json({ message: "Commerce approuve" });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.delete("/:id", authenticate, async (req, res) => {
|
||||
try {
|
||||
const [businesses] = await pool.execute("SELECT * FROM businesses WHERE id = ?", [req.params.id]);
|
||||
if (!businesses.length) return res.status(404).json({ error: "Commerce introuvable" });
|
||||
if (businesses[0].owner_id !== req.user.id && req.user.role !== "admin") {
|
||||
return res.status(403).json({ error: "Action non autorisee" });
|
||||
}
|
||||
await pool.execute("DELETE FROM businesses WHERE id = ?", [req.params.id]);
|
||||
res.json({ message: "Commerce supprime" });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Router } from "express";
|
||||
import pool from "../config/db.js";
|
||||
import { authenticate, requireAdmin } from "../middleware/auth.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/", async (req, res) => {
|
||||
try {
|
||||
const [rows] = await pool.execute(
|
||||
"SELECT e.*, u.name as author_name FROM events e LEFT JOIN users u ON e.author_id = u.id ORDER BY e.date DESC"
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.get("/:id", async (req, res) => {
|
||||
try {
|
||||
const [rows] = await pool.execute(
|
||||
"SELECT e.*, u.name as author_name FROM events e LEFT JOIN users u ON e.author_id = u.id WHERE e.id = ?",
|
||||
[req.params.id]
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: "Evenement introuvable" });
|
||||
res.json(rows[0]);
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.post("/", authenticate, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { title, description, date, time, location, image, category } = req.body;
|
||||
if (!title || !date) return res.status(400).json({ error: "Titre et date requis" });
|
||||
const [result] = await pool.execute(
|
||||
"INSERT INTO events (title, description, date, time, location, image, category, author_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
[title, description || null, date, time || null, location || null, image || null, category || "autre", req.user.id]
|
||||
);
|
||||
res.status(201).json({ id: result.insertId, message: "Evenement cree" });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.put("/:id", authenticate, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { title, description, date, time, location, image, category } = req.body;
|
||||
await pool.execute(
|
||||
"UPDATE events SET title=?, description=?, date=?, time=?, location=?, image=?, category=? WHERE id=?",
|
||||
[title, description, date, time, location, image, category, req.params.id]
|
||||
);
|
||||
res.json({ message: "Evenement mis a jour" });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.delete("/:id", authenticate, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
await pool.execute("DELETE FROM events WHERE id = ?", [req.params.id]);
|
||||
res.json({ message: "Evenement supprime" });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Router } from "express";
|
||||
import pool from "../config/db.js";
|
||||
import { authenticate, requireAdmin } from "../middleware/auth.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/", async (req, res) => {
|
||||
try {
|
||||
const [rows] = await pool.execute(
|
||||
"SELECT h.*, u.name as author_name, u.phone as author_phone FROM help_requests h LEFT JOIN users u ON h.author_id = u.id WHERE h.status != 'resolu' AND h.approved = 1 ORDER BY h.urgent DESC, h.created_at DESC"
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.get("/pending", authenticate, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const [rows] = await pool.execute(
|
||||
"SELECT h.*, u.name as author_name FROM help_requests h LEFT JOIN users u ON h.author_id = u.id WHERE h.approved = 0 ORDER BY h.created_at DESC"
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.get("/:id", async (req, res) => {
|
||||
try {
|
||||
const [rows] = await pool.execute(
|
||||
"SELECT h.*, u.name as author_name, u.phone as author_phone FROM help_requests h LEFT JOIN users u ON h.author_id = u.id WHERE h.id = ?",
|
||||
[req.params.id]
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: "Demande introuvable" });
|
||||
res.json(rows[0]);
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.post("/", authenticate, async (req, res) => {
|
||||
try {
|
||||
const { title, description, category, urgent, image } = req.body;
|
||||
if (!title || !description) return res.status(400).json({ error: "Titre et description requis" });
|
||||
const [result] = await pool.execute(
|
||||
"INSERT INTO help_requests (title, description, category, urgent, image, author_id) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
[title, description, category || "autre", urgent ? 1 : 0, image || null, req.user.id]
|
||||
);
|
||||
res.status(201).json({ id: result.insertId, message: "Demande soumise, en attente d'approbation" });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.put("/:id", authenticate, async (req, res) => {
|
||||
try {
|
||||
const [requests] = await pool.execute("SELECT * FROM help_requests WHERE id = ?", [req.params.id]);
|
||||
if (!requests.length) return res.status(404).json({ error: "Demande introuvable" });
|
||||
if (requests[0].author_id !== req.user.id && req.user.role !== "admin") {
|
||||
return res.status(403).json({ error: "Action non autorisee" });
|
||||
}
|
||||
const { title, description, category, urgent, status, image } = req.body;
|
||||
await pool.execute(
|
||||
"UPDATE help_requests SET title=?, description=?, category=?, urgent=?, status=?, image=? WHERE id=?",
|
||||
[title, description, category, urgent ? 1 : 0, status || "ouvert", image || null, req.params.id]
|
||||
);
|
||||
res.json({ message: "Demande mise a jour" });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.put("/:id/approve", authenticate, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
await pool.execute("UPDATE help_requests SET approved = 1 WHERE id = ?", [req.params.id]);
|
||||
res.json({ message: "Demande approuvee" });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.delete("/:id", authenticate, async (req, res) => {
|
||||
try {
|
||||
const [requests] = await pool.execute("SELECT * FROM help_requests WHERE id = ?", [req.params.id]);
|
||||
if (!requests.length) return res.status(404).json({ error: "Demande introuvable" });
|
||||
if (requests[0].author_id !== req.user.id && req.user.role !== "admin") {
|
||||
return res.status(403).json({ error: "Action non autorisee" });
|
||||
}
|
||||
await pool.execute("DELETE FROM help_requests WHERE id = ?", [req.params.id]);
|
||||
res.json({ message: "Demande supprimee" });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Router } from "express";
|
||||
import pool from "../config/db.js";
|
||||
import { authenticate, requireAdmin, optionalAuth } from "../middleware/auth.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/", optionalAuth, async (req, res) => {
|
||||
try {
|
||||
const { type, category, search } = req.query;
|
||||
let sql = "SELECT l.*, u.name as author_name, u.phone as author_phone, c.name as category_name FROM listings l LEFT JOIN users u ON l.author_id = u.id LEFT JOIN categories c ON l.category_id = c.id WHERE l.status = 'disponible' AND l.approved = 1";
|
||||
const params = [];
|
||||
if (type && type !== "all") { sql += " AND l.type = ?"; params.push(type); }
|
||||
if (category) { sql += " AND l.category_id = ?"; params.push(category); }
|
||||
if (search) { sql += " AND (l.title LIKE ? OR l.description LIKE ?)"; params.push("%" + search + "%", "%" + search + "%"); }
|
||||
sql += " ORDER BY l.created_at DESC";
|
||||
const [rows] = await pool.execute(sql, params);
|
||||
res.json(rows);
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.get("/mine", authenticate, async (req, res) => {
|
||||
try {
|
||||
const [rows] = await pool.execute(
|
||||
"SELECT l.*, c.name as category_name FROM listings l LEFT JOIN categories c ON l.category_id = c.id WHERE l.author_id = ? ORDER BY l.created_at DESC",
|
||||
[req.user.id]
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.get("/pending", authenticate, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const [rows] = await pool.execute(
|
||||
"SELECT l.*, u.name as author_name FROM listings l LEFT JOIN users u ON l.author_id = u.id WHERE l.approved = 0 ORDER BY l.created_at DESC"
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.get("/:id", async (req, res) => {
|
||||
try {
|
||||
const [rows] = await pool.execute(
|
||||
"SELECT l.*, u.name as author_name, u.phone as author_phone, c.name as category_name FROM listings l LEFT JOIN users u ON l.author_id = u.id LEFT JOIN categories c ON l.category_id = c.id WHERE l.id = ?",
|
||||
[req.params.id]
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: "Annonce introuvable" });
|
||||
res.json(rows[0]);
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.post("/", authenticate, async (req, res) => {
|
||||
try {
|
||||
const { title, description, price, type, category_id, image } = req.body;
|
||||
if (!title) return res.status(400).json({ error: "Titre requis" });
|
||||
const [result] = await pool.execute(
|
||||
"INSERT INTO listings (title, description, price, type, category_id, image, author_id) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
[title, description || null, price || 0, type || "don", category_id || null, image || null, req.user.id]
|
||||
);
|
||||
res.status(201).json({ id: result.insertId, message: "Annonce soumise, en attente d'approbation" });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.put("/:id", authenticate, async (req, res) => {
|
||||
try {
|
||||
const [listings] = await pool.execute("SELECT * FROM listings WHERE id = ?", [req.params.id]);
|
||||
if (!listings.length) return res.status(404).json({ error: "Annonce introuvable" });
|
||||
if (listings[0].author_id !== req.user.id && req.user.role !== "admin") {
|
||||
return res.status(403).json({ error: "Action non autorisee" });
|
||||
}
|
||||
const { title, description, price, type, category_id, image, status } = req.body;
|
||||
await pool.execute(
|
||||
"UPDATE listings SET title=?, description=?, price=?, type=?, category_id=?, image=?, status=? WHERE id=?",
|
||||
[title, description, price, type, category_id, image, status || "disponible", req.params.id]
|
||||
);
|
||||
res.json({ message: "Annonce mise a jour" });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.put("/:id/approve", authenticate, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
await pool.execute("UPDATE listings SET approved = 1 WHERE id = ?", [req.params.id]);
|
||||
res.json({ message: "Annonce approuvee" });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.delete("/:id", authenticate, async (req, res) => {
|
||||
try {
|
||||
const [listings] = await pool.execute("SELECT * FROM listings WHERE id = ?", [req.params.id]);
|
||||
if (!listings.length) return res.status(404).json({ error: "Annonce introuvable" });
|
||||
if (listings[0].author_id !== req.user.id && req.user.role !== "admin") {
|
||||
return res.status(403).json({ error: "Action non autorisee" });
|
||||
}
|
||||
await pool.execute("DELETE FROM listings WHERE id = ?", [req.params.id]);
|
||||
res.json({ message: "Annonce supprimee" });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.get("/categories/all", async (req, res) => {
|
||||
try {
|
||||
const [rows] = await pool.execute("SELECT * FROM categories WHERE type = 'listing' ORDER BY name");
|
||||
res.json(rows);
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,24 @@
|
||||
services:
|
||||
backend:
|
||||
build: ./backend
|
||||
container_name: csv-backend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 3002:3002
|
||||
environment:
|
||||
- PORT=3002
|
||||
- DB_HOST=10.10.10.11
|
||||
- DB_PORT=3306
|
||||
- DB_USER=csvusr
|
||||
- DB_PASSWORD=Fdp1989Dor@
|
||||
- DB_NAME=csv
|
||||
- JWT_SECRET=csv-secret-key-change-in-production
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
container_name: csv-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 3003:80
|
||||
depends_on:
|
||||
- backend
|
||||
@@ -0,0 +1,12 @@
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package.json .
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CSV - Condé Sur Vesgre</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,18 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
location /api/ {
|
||||
set $upstream_backend http://backend:3002;
|
||||
proxy_pass $upstream_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "csv-frontend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.7",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.12",
|
||||
"vite": "^5.4.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns=http://www.w3.org/2000/svg viewBox=0 0 100 100>
|
||||
<rect width=100 height=100 rx=20 fill=#16a34a/>
|
||||
<text x=50 y=65 font-size=40 font-weight=bold fill=white text-anchor=middle font-family=Arial>CSV</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 223 B |
@@ -0,0 +1,37 @@
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import Navbar from "./components/Navbar";
|
||||
import Footer from "./components/Footer";
|
||||
import Home from "./pages/Home";
|
||||
import Login from "./pages/Login";
|
||||
import Register from "./pages/Register";
|
||||
import Articles from "./pages/Articles";
|
||||
import Events from "./pages/Events";
|
||||
import Listings from "./pages/Listings";
|
||||
import HelpRequests from "./pages/HelpRequests";
|
||||
import Businesses from "./pages/Businesses";
|
||||
import Admin from "./pages/Admin";
|
||||
import Profile from "./pages/Profile";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Navbar />
|
||||
<main className="flex-1">
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/articles" element={<Articles />} />
|
||||
<Route path="/articles/:id" element={<Articles />} />
|
||||
<Route path="/events" element={<Events />} />
|
||||
<Route path="/listings" element={<Listings />} />
|
||||
<Route path="/help" element={<HelpRequests />} />
|
||||
<Route path="/businesses" element={<Businesses />} />
|
||||
<Route path="/admin" element={<Admin />} />
|
||||
<Route path="/profile" element={<Profile />} />
|
||||
</Routes>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import axios from "axios";
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: "/api",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem("token");
|
||||
if (token) config.headers.Authorization = "Bearer " + token;
|
||||
return config;
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(res) => res,
|
||||
(err) => {
|
||||
if (err.response?.status === 401) {
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("user");
|
||||
}
|
||||
return Promise.reject(err);
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="bg-white border-t mt-12">
|
||||
<div className="max-w-6xl mx-auto px-4 py-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div>
|
||||
<h3 className="font-bold text-csv-700 text-lg mb-2">CSV</h3>
|
||||
<p className="text-sm text-gray-500">Condé Sur Vesgre - L'application citoyenne du village.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-700 mb-2">Liens</h4>
|
||||
<div className="space-y-1 text-sm text-gray-500">
|
||||
<Link to="/articles" className="block hover:text-csv-600">Actualités</Link>
|
||||
<Link to="/events" className="block hover:text-csv-600">Agenda</Link>
|
||||
<Link to="/listings" className="block hover:text-csv-600">Annonces</Link>
|
||||
<Link to="/help" className="block hover:text-csv-600">Entraide</Link>
|
||||
<Link to="/businesses" className="block hover:text-csv-600">Commerces</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-700 mb-2">À propos</h4>
|
||||
<p className="text-sm text-gray-500">
|
||||
Application citoyenne libre et gratuite pour les habitants de Condé Sur Vesgre.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t mt-6 pt-4 text-center text-xs text-gray-400">
|
||||
© {new Date().getFullYear()} CSV - Condé Sur Vesgre
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function Navbar() {
|
||||
const { user, logout } = useAuth();
|
||||
const location = useLocation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const links = [
|
||||
{ to: "/", label: "Accueil" },
|
||||
{ to: "/articles", label: "Actualités" },
|
||||
{ to: "/events", label: "Agenda" },
|
||||
{ to: "/listings", label: "Annonces" },
|
||||
{ to: "/help", label: "Entraide" },
|
||||
{ to: "/businesses", label: "Commerces" },
|
||||
];
|
||||
|
||||
const isActive = (path) =>
|
||||
location.pathname === path ? "text-csv-600 font-semibold" : "text-gray-600 hover:text-csv-600";
|
||||
|
||||
return (
|
||||
<nav className="bg-white shadow-sm border-b sticky top-0 z-50">
|
||||
<div className="max-w-6xl mx-auto px-4">
|
||||
<div className="flex justify-between h-16">
|
||||
<Link to="/" className="flex items-center gap-2">
|
||||
<img src="/api/uploads/logo.png" alt="Condé sur Vesgre" className="h-10 w-10 rounded-full object-cover" />
|
||||
<span className="text-lg font-bold text-csv-700 leading-tight">
|
||||
Condé<br /><span className="text-sm font-normal">sur Vesgre</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="hidden md:flex items-center gap-6 text-sm">
|
||||
{links.map((l) => (
|
||||
<Link key={l.to} to={l.to} className={isActive(l.to)}>
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{user ? (
|
||||
<>
|
||||
<Link to="/profile" className="text-sm text-gray-600 hover:text-csv-600">
|
||||
{user.name}
|
||||
</Link>
|
||||
{user.role === "admin" && (
|
||||
<Link to="/admin" className="text-xs bg-csv-100 text-csv-700 px-2 py-1 rounded">
|
||||
Admin
|
||||
</Link>
|
||||
)}
|
||||
<button onClick={logout} className="text-sm text-red-500 hover:text-red-700">
|
||||
Quitter
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link to="/login" className="text-sm text-csv-600 hover:text-csv-700">Connexion</Link>
|
||||
<Link to="/register" className="text-sm bg-csv-600 text-white px-3 py-1.5 rounded hover:bg-csv-700">Inscription</Link>
|
||||
</>
|
||||
)}
|
||||
<button className="md:hidden p-2" onClick={() => setOpen(!open)}>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{open ? (
|
||||
<path strokeLinecap="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
) : (
|
||||
<path strokeLinecap="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="md:hidden pb-4 space-y-2">
|
||||
{links.map((l) => (
|
||||
<Link key={l.to} to={l.to} className={`block px-3 py-2 rounded text-sm ${isActive(l.to)}`} onClick={() => setOpen(false)}>
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { createContext, useContext, useState, useEffect } from "react";
|
||||
import api from "../api";
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem("token");
|
||||
if (token) {
|
||||
api.get("/auth/me")
|
||||
.then((res) => setUser(res.data))
|
||||
.catch(() => { localStorage.removeItem("token"); })
|
||||
.finally(() => setLoading(false));
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const login = async (email, password) => {
|
||||
const res = await api.post("/auth/login", { email, password });
|
||||
localStorage.setItem("token", res.data.token);
|
||||
setUser(res.data.user);
|
||||
return res.data;
|
||||
};
|
||||
|
||||
const register = async (data) => {
|
||||
const res = await api.post("/auth/register", data);
|
||||
localStorage.setItem("token", res.data.token);
|
||||
setUser(res.data.user);
|
||||
return res.data;
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem("token");
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, register, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useAuth = () => useContext(AuthContext);
|
||||
@@ -0,0 +1,7 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
@apply bg-gray-50 text-gray-900 antialiased;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import { AuthProvider } from "./contexts/AuthContext";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import api from "../api";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
|
||||
export default function Admin() {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [tab, setTab] = useState("articles");
|
||||
const [articles, setArticles] = useState([]);
|
||||
const [events, setEvents] = useState([]);
|
||||
const [pendingBiz, setPendingBiz] = useState([]);
|
||||
const [pendingListings, setPendingListings] = useState([]);
|
||||
const [pendingHelp, setPendingHelp] = useState([]);
|
||||
const [editing, setEditing] = useState(null);
|
||||
const [form, setForm] = useState({ title: "", content: "", image: "" });
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || user.role !== "admin") { navigate("/"); return; }
|
||||
api.get("/articles").then((r) => setArticles(r.data)).catch(() => {});
|
||||
api.get("/events").then((r) => setEvents(r.data)).catch(() => {});
|
||||
api.get("/businesses/pending").then((r) => setPendingBiz(r.data)).catch(() => {});
|
||||
api.get("/listings/pending").then((r) => setPendingListings(r.data)).catch(() => {});
|
||||
api.get("/help/pending").then((r) => setPendingHelp(r.data)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const createArticle = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api.post("/articles", form);
|
||||
setForm({ title: "", content: "", image: "" });
|
||||
const r = await api.get("/articles");
|
||||
setArticles(r.data);
|
||||
} catch (err) { alert(err.response?.data?.error || "Erreur"); }
|
||||
};
|
||||
|
||||
const deleteArticle = async (id) => {
|
||||
if (!confirm("Supprimer cet article ?")) return;
|
||||
await api.delete("/articles/" + id);
|
||||
setArticles(articles.filter((a) => a.id !== id));
|
||||
};
|
||||
|
||||
const approveListing = async (id) => {
|
||||
await api.put("/listings/" + id + "/approve");
|
||||
setPendingListings(pendingListings.filter((l) => l.id !== id));
|
||||
};
|
||||
|
||||
const approveHelp = async (id) => {
|
||||
await api.put("/help/" + id + "/approve");
|
||||
setPendingHelp(pendingHelp.filter((h) => h.id !== id));
|
||||
};
|
||||
|
||||
const approveBiz = async (id) => {
|
||||
try {
|
||||
await api.put("/businesses/" + id + "/approve");
|
||||
setPendingBiz(pendingBiz.filter((b) => b.id !== id));
|
||||
} catch (err) {
|
||||
alert(err.response?.data?.error || "Erreur lors de l'approbation");
|
||||
}
|
||||
};
|
||||
|
||||
const deleteEvent = async (id) => {
|
||||
if (!confirm("Supprimer cet événement ?")) return;
|
||||
await api.delete("/events/" + id);
|
||||
setEvents(events.filter((e) => e.id !== id));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||
<h1 className="text-2xl font-bold mb-6">Administration</h1>
|
||||
|
||||
<div className="flex gap-2 mb-6 border-b pb-2">
|
||||
{["articles", "events", "listings", "help", "businesses"].map((t) => (
|
||||
<button key={t} onClick={() => setTab(t)}
|
||||
className={"px-4 py-2 text-sm rounded-t " + (tab === t ? "bg-csv-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200")}>
|
||||
{t === "articles" ? "Actualités" : t === "events" ? "Événements" : t === "listings" ? "Annonces" : t === "help" ? "Entraide" : "Commerces"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "articles" && (
|
||||
<div>
|
||||
<form onSubmit={createArticle} className="border rounded-lg p-4 mb-6 space-y-3 bg-white">
|
||||
<h2 className="font-semibold">Nouvel article</h2>
|
||||
<input placeholder="Titre *" required value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })}
|
||||
className="w-full border rounded px-3 py-2 text-sm outline-none" />
|
||||
<textarea placeholder="Contenu *" required rows={6} value={form.content} onChange={(e) => setForm({ ...form, content: e.target.value })}
|
||||
className="w-full border rounded px-3 py-2 text-sm outline-none font-mono" />
|
||||
<input placeholder="URL image (optionnelle)" value={form.image} onChange={(e) => setForm({ ...form, image: e.target.value })}
|
||||
className="w-full border rounded px-3 py-2 text-sm outline-none" />
|
||||
<button type="submit" className="bg-csv-600 text-white px-4 py-2 rounded text-sm">Publier</button>
|
||||
</form>
|
||||
<div className="space-y-2">
|
||||
{articles.map((a) => (
|
||||
<div key={a.id} className="flex justify-between items-center border rounded p-3 bg-white">
|
||||
<div>
|
||||
<span className="font-medium">{a.title}</span>
|
||||
<span className={"ml-2 text-xs px-2 py-0.5 rounded " + (a.published ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-500")}>
|
||||
{a.published ? "Publié" : "Brouillon"}
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={() => deleteArticle(a.id)} className="text-red-500 text-sm hover:text-red-700">Supprimer</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "events" && (
|
||||
<div className="space-y-2">
|
||||
{events.map((e) => (
|
||||
<div key={e.id} className="flex justify-between items-center border rounded p-3 bg-white">
|
||||
<div>
|
||||
<span className="font-medium">{e.title}</span>
|
||||
<span className="ml-2 text-xs text-gray-400">{new Date(e.date).toLocaleDateString("fr-FR")}</span>
|
||||
</div>
|
||||
<button onClick={() => deleteEvent(e.id)} className="text-red-500 text-sm hover:text-red-700">Supprimer</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "listings" && (
|
||||
<div>
|
||||
{pendingListings.length > 0 ? (
|
||||
<div>
|
||||
<h2 className="font-semibold mb-3">Annonces en attente d'approbation</h2>
|
||||
<div className="space-y-2">
|
||||
{pendingListings.map((l) => (
|
||||
<div key={l.id} className="flex justify-between items-center border rounded p-3 bg-white">
|
||||
<div>
|
||||
<span className="font-medium">{l.title}</span>
|
||||
<p className="text-sm text-gray-500">par {l.author_name}</p>
|
||||
</div>
|
||||
<button onClick={() => approveListing(l.id)} className="bg-csv-600 text-white px-3 py-1 rounded text-sm">Approuver</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-400">Aucune annonce en attente.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "help" && (
|
||||
<div>
|
||||
{pendingHelp.length > 0 ? (
|
||||
<div>
|
||||
<h2 className="font-semibold mb-3">Demandes d'entraide en attente d'approbation</h2>
|
||||
<div className="space-y-2">
|
||||
{pendingHelp.map((h) => (
|
||||
<div key={h.id} className="flex justify-between items-center border rounded p-3 bg-white">
|
||||
<div>
|
||||
<span className="font-medium">{h.title}</span>
|
||||
<p className="text-sm text-gray-500">par {h.author_name}</p>
|
||||
</div>
|
||||
<button onClick={() => approveHelp(h.id)} className="bg-csv-600 text-white px-3 py-1 rounded text-sm">Approuver</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-400">Aucune demande en attente.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "businesses" && (
|
||||
<div>
|
||||
{pendingBiz.length > 0 ? (
|
||||
<div>
|
||||
<h2 className="font-semibold mb-3">En attente d'approbation</h2>
|
||||
<div className="space-y-2">
|
||||
{pendingBiz.map((b) => (
|
||||
<div key={b.id} className="flex justify-between items-center border rounded p-3 bg-white">
|
||||
<div>
|
||||
<span className="font-medium">{b.name}</span>
|
||||
<p className="text-sm text-gray-500">{b.description}</p>
|
||||
</div>
|
||||
<button onClick={() => approveBiz(b.id)} className="bg-csv-600 text-white px-3 py-1 rounded text-sm">Approuver</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-400">Aucun commerce en attente.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams, Link, useNavigate } from "react-router-dom";
|
||||
import api from "../api";
|
||||
|
||||
export default function Articles() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [articles, setArticles] = useState([]);
|
||||
const [article, setArticle] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
api.get("/articles/" + id).then((r) => setArticle(r.data)).catch(() => {});
|
||||
} else {
|
||||
setArticle(null);
|
||||
api.get("/articles").then((r) => setArticles(r.data)).catch(() => {});
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
if (article) {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-8">
|
||||
<button onClick={() => navigate("/articles")} className="text-sm text-csv-600 hover:underline mb-4 inline-block">← Retour aux actualités</button>
|
||||
<h1 className="text-2xl font-bold mb-2">{article.title}</h1>
|
||||
<p className="text-sm text-gray-400 mb-4">{new Date(article.created_at).toLocaleDateString("fr-FR")} par {article.author_name}</p>
|
||||
<div className="prose max-w-none whitespace-pre-wrap text-gray-700">{article.content}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
<h1 className="text-2xl font-bold mb-6">Actualités du village</h1>
|
||||
{articles.length === 0 ? (
|
||||
<p className="text-gray-400">Aucune actualité pour le moment.</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{articles.map((a) => (
|
||||
<Link key={a.id} to={"/articles/" + a.id} className="block border rounded-lg p-5 hover:shadow-md">
|
||||
<p className="text-xs text-gray-400 mb-1">{new Date(a.created_at).toLocaleDateString("fr-FR")}</p>
|
||||
<h2 className="text-lg font-semibold mb-1">{a.title}</h2>
|
||||
<p className="text-sm text-gray-500 line-clamp-2">{a.content}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import api from "../api";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
|
||||
export default function Businesses() {
|
||||
const { user } = useAuth();
|
||||
const [businesses, setBusinesses] = useState([]);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [form, setForm] = useState({ name: "", description: "", category: "", address: "", phone: "", website: "", email: "", image: "" });
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const load = () => {
|
||||
api.get("/businesses").then((r) => setBusinesses(r.data)).catch(() => {});
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api.post("/businesses", form);
|
||||
setShowForm(false);
|
||||
setForm({ name: "", description: "", category: "", address: "", phone: "", website: "", email: "", image: "" });
|
||||
alert("Commerce soumis ! En attente d'approbation par un administrateur.");
|
||||
} catch (err) { alert(err.response?.data?.error || "Erreur"); }
|
||||
};
|
||||
|
||||
const handleImageUpload = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
try {
|
||||
const res = await api.post("/upload", fd, { headers: { "Content-Type": "multipart/form-data" } });
|
||||
setForm({ ...form, image: res.data.url });
|
||||
} catch (err) { alert("Erreur upload"); }
|
||||
setUploading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Commerces & Artisans</h1>
|
||||
<p className="text-sm text-gray-500">Découvrez les commerces et artisans de Condé Sur Vesgre</p>
|
||||
</div>
|
||||
{user && (
|
||||
<button onClick={() => setShowForm(!showForm)} className="bg-csv-600 text-white px-4 py-2 rounded-lg text-sm hover:bg-csv-700">
|
||||
{showForm ? "Annuler" : "Ajouter mon commerce"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<form onSubmit={handleSubmit} className="border rounded-lg p-4 mb-6 space-y-3 bg-white">
|
||||
<input placeholder="Nom du commerce *" required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
className="w-full border rounded px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-csv-500" />
|
||||
<textarea placeholder="Description" rows={2} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
className="w-full border rounded px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-csv-500" />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<input placeholder="Catégorie" value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })}
|
||||
className="border rounded px-3 py-2 text-sm outline-none" />
|
||||
<input placeholder="Adresse" value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })}
|
||||
className="border rounded px-3 py-2 text-sm outline-none" />
|
||||
<input placeholder="Téléphone" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
||||
className="border rounded px-3 py-2 text-sm outline-none" />
|
||||
<input placeholder="Site web" value={form.website} onChange={(e) => setForm({ ...form, website: e.target.value })}
|
||||
className="border rounded px-3 py-2 text-sm outline-none" />
|
||||
<input placeholder="Email" type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
className="border rounded px-3 py-2 text-sm outline-none col-span-2" />
|
||||
</div>
|
||||
{form.image && <img src={form.image} alt="" className="h-20 rounded" />}
|
||||
<input type="file" accept="image/*" onChange={handleImageUpload} className="text-sm" />
|
||||
{uploading && <span className="text-xs text-gray-400">Upload...</span>}
|
||||
<button type="submit" className="bg-csv-600 text-white px-4 py-2 rounded text-sm hover:bg-csv-700">Soumettre</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{businesses.length === 0 ? (
|
||||
<p className="text-gray-400 text-center py-8">Aucun commerce référencé pour le moment.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{businesses.map((b) => (
|
||||
<div key={b.id} className="border rounded-lg p-4 bg-white">
|
||||
{b.image && <img src={b.image} alt={b.name} className="w-full h-36 object-cover rounded mb-2" />}
|
||||
<h3 className="font-semibold text-lg">{b.name}</h3>
|
||||
{b.category && <span className="text-xs bg-csv-100 text-csv-700 px-2 py-0.5 rounded-full">{b.category}</span>}
|
||||
{b.description && <p className="text-sm text-gray-600 mt-2">{b.description}</p>}
|
||||
<div className="mt-3 space-y-1 text-sm text-gray-500">
|
||||
{b.address && <p>📍 {b.address}</p>}
|
||||
{b.phone && <p>📞 {b.phone}</p>}
|
||||
{b.website && <a href={b.website} target="_blank" rel="noopener noreferrer" className="text-csv-600 hover:underline block">🌐 {b.website}</a>}
|
||||
{b.email && <p>✉ {b.email}</p>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import api from "../api";
|
||||
|
||||
export default function Events() {
|
||||
const [events, setEvents] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
api.get("/events").then((r) => setEvents(r.data)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const categoryColors = {
|
||||
brocante: "bg-orange-100 text-orange-700",
|
||||
marche: "bg-green-100 text-green-700",
|
||||
culture: "bg-purple-100 text-purple-700",
|
||||
sport: "bg-blue-100 text-blue-700",
|
||||
reunion: "bg-yellow-100 text-yellow-700",
|
||||
autre: "bg-gray-100 text-gray-700",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
<h1 className="text-2xl font-bold mb-6">Agenda du village</h1>
|
||||
{events.length === 0 ? (
|
||||
<p className="text-gray-400">Aucun événement à venir.</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{events.map((e) => (
|
||||
<div key={e.id} className="border rounded-lg p-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="text-center min-w-[60px]">
|
||||
<p className="text-2xl font-bold text-csv-600">{new Date(e.date).getDate()}</p>
|
||||
<p className="text-xs text-gray-500 uppercase">{new Date(e.date).toLocaleDateString("fr-FR", { month: "short" })}</p>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="font-semibold text-lg">{e.title}</h2>
|
||||
{e.category && (
|
||||
<span className={"inline-block text-xs px-2 py-0.5 rounded-full mt-1 " + (categoryColors[e.category] || categoryColors.autre)}>
|
||||
{e.category}
|
||||
</span>
|
||||
)}
|
||||
{e.description && <p className="text-sm text-gray-600 mt-2">{e.description}</p>}
|
||||
{(e.location || e.time) && (
|
||||
<div className="flex gap-4 mt-2 text-xs text-gray-400">
|
||||
{e.location && <span>📍 {e.location}</span>}
|
||||
{e.time && <span>🕔 {e.time.slice(0, 5)}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import api from "../api";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
|
||||
export default function HelpRequests() {
|
||||
const { user } = useAuth();
|
||||
const [requests, setRequests] = useState([]);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [form, setForm] = useState({ title: "", description: "", category: "autre", urgent: false, image: "" });
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const load = () => {
|
||||
api.get("/help").then((r) => setRequests(r.data)).catch(() => {});
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm("Supprimer cette demande ?")) return;
|
||||
try {
|
||||
await api.delete("/help/" + id);
|
||||
load();
|
||||
} catch (err) { alert(err.response?.data?.error || "Erreur"); }
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (editingId) {
|
||||
await api.put("/help/" + editingId, form);
|
||||
} else {
|
||||
await api.post("/help", form);
|
||||
}
|
||||
setShowForm(false);
|
||||
setEditingId(null);
|
||||
setForm({ title: "", description: "", category: "autre", urgent: false, image: "" });
|
||||
setEditingId(null);
|
||||
load();
|
||||
} catch (err) { alert(err.response?.data?.error || "Erreur"); }
|
||||
};
|
||||
|
||||
const handleEdit = (r) => {
|
||||
setForm({ title: r.title, description: r.description, category: r.category, urgent: r.urgent === 1, image: r.image || "" });
|
||||
setEditingId(r.id);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleImageUpload = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
try {
|
||||
const res = await api.post("/upload", fd, { headers: { "Content-Type": "multipart/form-data" } });
|
||||
setForm({ ...form, image: res.data.url });
|
||||
} catch (err) { alert("Erreur upload"); }
|
||||
setUploading(false);
|
||||
};
|
||||
|
||||
const categoryLabels = { course: "Courses", bricolage: "Bricolage", jardinage: "Jardinage", transport: "Transport", numerique: "Numérique", autre: "Autre" };
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Entraide</h1>
|
||||
<p className="text-sm text-gray-500">Demander ou proposer de l'aide aux habitants</p>
|
||||
</div>
|
||||
{user && (
|
||||
<button onClick={() => { setShowForm(!showForm); setEditingId(null); setForm({ title: "", description: "", category: "autre", urgent: false, image: "" }); }} className="bg-csv-600 text-white px-4 py-2 rounded-lg text-sm hover:bg-csv-700">
|
||||
{showForm ? "Annuler" : "Demander de l'aide"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<form onSubmit={handleSubmit} className="border rounded-lg p-4 mb-6 space-y-3 bg-white">
|
||||
<input placeholder="Titre *" required value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })}
|
||||
className="w-full border rounded px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-csv-500" />
|
||||
<textarea placeholder="Description *" required rows={3} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
className="w-full border rounded px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-csv-500" />
|
||||
<div className="flex gap-3 items-center">
|
||||
<select value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })}
|
||||
className="border rounded px-3 py-2 text-sm outline-none">
|
||||
{Object.entries(categoryLabels).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={form.urgent} onChange={(e) => setForm({ ...form, urgent: e.target.checked })} />
|
||||
Urgent
|
||||
</label>
|
||||
</div>
|
||||
{form.image && <img src={form.image} alt="" className="h-20 rounded" />}
|
||||
<div>
|
||||
<input type="file" accept="image/*" onChange={handleImageUpload} className="text-sm" />
|
||||
{uploading && <span className="text-xs text-gray-400 ml-2">Upload...</span>}
|
||||
</div>
|
||||
<button type="submit" className="bg-csv-600 text-white px-4 py-2 rounded text-sm hover:bg-csv-700">
|
||||
{editingId ? "Mettre a jour" : "Publier"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{requests.length === 0 ? (
|
||||
<p className="text-gray-400 text-center py-8">Aucune demande d'aide pour le moment.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{requests.map((r) => (
|
||||
<div key={r.id} className={"border rounded-lg p-4 bg-white " + (r.urgent ? "border-red-300" : "")}>
|
||||
{r.image && <img src={r.image} alt={r.title} className="w-full h-36 object-cover rounded mb-2" />}
|
||||
<div className="flex items-start gap-3">
|
||||
{r.urgent && <span className="bg-red-500 text-white text-xs px-2 py-0.5 rounded-full mt-0.5">Urgent</span>}
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold">{r.title}</h3>
|
||||
<p className="text-sm text-gray-600 mt-1">{r.description}</p>
|
||||
<div className="flex gap-3 mt-2 text-xs text-gray-400">
|
||||
<span className="bg-gray-100 px-2 py-0.5 rounded">{categoryLabels[r.category] || r.category}</span>
|
||||
<span>par {r.author_name}</span>
|
||||
{r.author_phone && <span>📞 {r.author_phone}</span>}
|
||||
{user && r.author_id === user.id && (
|
||||
<>
|
||||
<button onClick={() => handleEdit(r)} className="text-blue-500 hover:text-blue-700 ml-auto mr-2">Modifier</button>
|
||||
<button onClick={() => handleDelete(r.id)} className="text-red-500 hover:text-red-700">Supprimer</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import api from "../api";
|
||||
|
||||
export default function Home() {
|
||||
const [articles, setArticles] = useState([]);
|
||||
const [events, setEvents] = useState([]);
|
||||
const [listings, setListings] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
api.get("/articles").then((r) => setArticles(r.data.slice(0, 3))).catch(() => {});
|
||||
api.get("/events").then((r) => setEvents(r.data.slice(0, 3))).catch(() => {});
|
||||
api.get("/listings").then((r) => setListings(r.data.slice(0, 4))).catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<section className="bg-gradient-to-br from-csv-600 to-csv-800 text-white">
|
||||
<div className="max-w-6xl mx-auto px-4 py-16 md:py-24">
|
||||
<h1 className="text-3xl md:text-5xl font-bold mb-4">Condé Sur Vesgre</h1>
|
||||
<p className="text-lg md:text-xl text-csv-100 mb-8 max-w-2xl">
|
||||
L'application citoyenne pour tout savoir sur le village : actualités, événements, annonces, entraide et commerces.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link to="/register" className="bg-white text-csv-700 px-6 py-3 rounded-lg font-semibold hover:bg-csv-50">
|
||||
Rejoindre la communauté
|
||||
</Link>
|
||||
<Link to="/articles" className="border border-white/30 text-white px-6 py-3 rounded-lg font-semibold hover:bg-white/10">
|
||||
Voir les actualités
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="max-w-6xl mx-auto px-4 py-12">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12">
|
||||
{[
|
||||
{ title: "Actualités", desc: "Infos et annonces du village", to: "/articles", color: "bg-blue-50 text-blue-700" },
|
||||
{ title: "Agenda", desc: "Tous les événements à venir", to: "/events", color: "bg-orange-50 text-orange-700" },
|
||||
{ title: "Annonces", desc: "Don, vente, troc entre habitants", to: "/listings", color: "bg-green-50 text-green-700" },
|
||||
{ title: "Entraide", desc: "Demander ou proposer de l'aide", to: "/help", color: "bg-red-50 text-red-700" },
|
||||
{ title: "Commerces", desc: "Artisans et commerces du village", to: "/businesses", color: "bg-purple-50 text-purple-700" },
|
||||
].map((item) => (
|
||||
<Link key={item.title} to={item.to} className="p-6 rounded-xl border hover:shadow-md transition-shadow">
|
||||
<h3 className={`inline-block px-3 py-1 rounded-full text-sm font-semibold mb-3 ${item.color}`}>{item.title}</h3>
|
||||
<p className="text-gray-600 text-sm">{item.desc}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{articles.length > 0 && (
|
||||
<section className="mb-12">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-bold">Dernières actualités</h2>
|
||||
<Link to="/articles" className="text-sm text-csv-600 hover:underline">Tout voir</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{articles.map((a) => (
|
||||
<Link key={a.id} to={"/articles/" + a.id} className="border rounded-lg p-4 hover:shadow-md">
|
||||
<p className="text-xs text-gray-400 mb-1">{new Date(a.created_at).toLocaleDateString("fr-FR")}</p>
|
||||
<h3 className="font-semibold mb-1">{a.title}</h3>
|
||||
<p className="text-sm text-gray-500 line-clamp-2">{a.content}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{listings.length > 0 && (
|
||||
<section className="mb-12">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-bold">Dernières annonces</h2>
|
||||
<Link to="/listings" className="text-sm text-csv-600 hover:underline">Tout voir</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{listings.map((l) => (
|
||||
<div key={l.id} className="border rounded-lg p-3">
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-csv-100 text-csv-700">{l.type}</span>
|
||||
<h3 className="font-medium text-sm mt-1">{l.title}</h3>
|
||||
{l.price > 0 && <p className="text-csv-600 font-semibold text-sm">{l.price}€</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import api from "../api";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
|
||||
export default function Listings() {
|
||||
const { user } = useAuth();
|
||||
const [listings, setListings] = useState([]);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [filter, setFilter] = useState({ type: "all", category: "", search: "" });
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [form, setForm] = useState({ title: "", description: "", price: "", type: "don", category_id: "", image: "" });
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const load = () => {
|
||||
const params = new URLSearchParams();
|
||||
if (filter.type && filter.type !== "all") params.set("type", filter.type);
|
||||
if (filter.category) params.set("category", filter.category);
|
||||
if (filter.search) params.set("search", filter.search);
|
||||
api.get("/listings?" + params.toString()).then((r) => setListings(r.data)).catch(() => {});
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, [filter]);
|
||||
useEffect(() => { api.get("/listings/categories/all").then((r) => setCategories(r.data)).catch(() => {}); }, []);
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm("Supprimer cette annonce ?")) return;
|
||||
try {
|
||||
await api.delete("/listings/" + id);
|
||||
load();
|
||||
} catch (err) { alert(err.response?.data?.error || "Erreur"); }
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (editingId) {
|
||||
await api.put("/listings/" + editingId, form);
|
||||
} else {
|
||||
await api.post("/listings", form);
|
||||
}
|
||||
setShowForm(false);
|
||||
setEditingId(null);
|
||||
setForm({ title: "", description: "", price: "", type: "don", category_id: "", image: "" });
|
||||
load();
|
||||
} catch (err) { alert(err.response?.data?.error || "Erreur"); }
|
||||
};
|
||||
|
||||
const handleEdit = (l) => {
|
||||
setForm({ title: l.title, description: l.description, price: l.price || "", type: l.type, category_id: l.category_id || "", image: l.image || "" });
|
||||
setEditingId(l.id);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleImageUpload = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
try {
|
||||
const res = await api.post("/upload", fd, { headers: { "Content-Type": "multipart/form-data" } });
|
||||
setForm({ ...form, image: res.data.url });
|
||||
} catch (err) { alert("Erreur upload"); }
|
||||
setUploading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold">Annonces entre habitants</h1>
|
||||
{user && (
|
||||
<button onClick={() => { setShowForm(!showForm); setEditingId(null); setForm({ title: "", description: "", price: "", type: "don", category_id: "", image: "" }); }} className="bg-csv-600 text-white px-4 py-2 rounded-lg text-sm hover:bg-csv-700">
|
||||
{showForm ? "Annuler" : "Publier une annonce"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<form onSubmit={handleSubmit} className="border rounded-lg p-4 mb-6 space-y-3 bg-white">
|
||||
<input placeholder="Titre *" required value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })}
|
||||
className="w-full border rounded px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-csv-500" />
|
||||
<textarea placeholder="Description" rows={3} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
className="w-full border rounded px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-csv-500" />
|
||||
<div className="flex gap-3">
|
||||
<select value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })}
|
||||
className="border rounded px-3 py-2 text-sm outline-none">
|
||||
<option value="don">Don</option>
|
||||
<option value="vente">Vente</option>
|
||||
<option value="troc">Troc</option>
|
||||
</select>
|
||||
<select value={form.category_id} onChange={(e) => setForm({ ...form, category_id: e.target.value })}
|
||||
className="border rounded px-3 py-2 text-sm outline-none">
|
||||
<option value="">Catégorie</option>
|
||||
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
{form.type === "vente" && (
|
||||
<input type="number" step="0.01" placeholder="Prix" value={form.price} onChange={(e) => setForm({ ...form, price: e.target.value })}
|
||||
className="border rounded px-3 py-2 text-sm w-24 outline-none" />
|
||||
)}
|
||||
</div>
|
||||
{form.image && <img src={form.image} alt="" className="h-20 rounded" />}
|
||||
<input type="file" accept="image/*" onChange={handleImageUpload} className="text-sm" />
|
||||
{uploading && <span className="text-xs text-gray-400">Upload...</span>}
|
||||
<button type="submit" className="bg-csv-600 text-white px-4 py-2 rounded text-sm hover:bg-csv-700">
|
||||
{editingId ? "Mettre a jour" : "Publier"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-6">
|
||||
<select value={filter.type} onChange={(e) => setFilter({ ...filter, type: e.target.value })}
|
||||
className="border rounded px-3 py-1.5 text-sm outline-none">
|
||||
<option value="all">Tous</option>
|
||||
<option value="don">Dons</option>
|
||||
<option value="vente">Ventes</option>
|
||||
<option value="troc">Troc</option>
|
||||
</select>
|
||||
<select value={filter.category} onChange={(e) => setFilter({ ...filter, category: e.target.value })}
|
||||
className="border rounded px-3 py-1.5 text-sm outline-none">
|
||||
<option value="">Toutes catégories</option>
|
||||
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
<input placeholder="Rechercher..." value={filter.search} onChange={(e) => setFilter({ ...filter, search: e.target.value })}
|
||||
className="border rounded px-3 py-1.5 text-sm outline-none flex-1 min-w-[200px]" />
|
||||
</div>
|
||||
|
||||
{listings.length === 0 ? (
|
||||
<p className="text-gray-400 text-center py-8">Aucune annonce pour le moment.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{listings.map((l) => (
|
||||
<div key={l.id} className="border rounded-lg p-4 bg-white">
|
||||
{l.image && <img src={l.image} alt={l.title} className="w-full h-36 object-cover rounded mb-2" />}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={"text-xs px-2 py-0.5 rounded-full " + (l.type === "don" ? "bg-green-100 text-green-700" : l.type === "vente" ? "bg-blue-100 text-blue-700" : "bg-purple-100 text-purple-700")}>
|
||||
{l.type === "don" ? "Don" : l.type === "vente" ? "Vente" : "Troc"}
|
||||
</span>
|
||||
{l.category_name && <span className="text-xs text-gray-400">{l.category_name}</span>}
|
||||
</div>
|
||||
<h3 className="font-semibold">{l.title}</h3>
|
||||
{l.description && <p className="text-sm text-gray-500 mt-1 line-clamp-2">{l.description}</p>}
|
||||
{l.price > 0 && <p className="text-csv-600 font-bold mt-2">{l.price} €</p>}
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<p className="text-xs text-gray-400">par {l.author_name}</p>
|
||||
{user && l.author_id === user.id && (
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => handleEdit(l)} className="text-xs text-blue-500 hover:text-blue-700">Modifier</button>
|
||||
<button onClick={() => handleDelete(l.id)} className="text-xs text-red-500 hover:text-red-700">Supprimer</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
|
||||
export default function Login() {
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [form, setForm] = useState({ email: "", password: "" });
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await login(form.email, form.password);
|
||||
navigate("/");
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || "Erreur de connexion");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto px-4 py-12">
|
||||
<h1 className="text-2xl font-bold mb-6">Connexion</h1>
|
||||
{error && <p className="bg-red-50 text-red-600 p-3 rounded mb-4 text-sm">{error}</p>}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||
<input type="email" required value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-csv-500 focus:border-csv-500 outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Mot de passe</label>
|
||||
<input type="password" required value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-csv-500 focus:border-csv-500 outline-none" />
|
||||
</div>
|
||||
<button type="submit" className="w-full bg-csv-600 text-white py-2 rounded-lg hover:bg-csv-700 font-medium">
|
||||
Se connecter
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-center text-sm text-gray-500 mt-4">
|
||||
Pas encore inscrit ? <Link to="/register" className="text-csv-600 hover:underline">Créer un compte</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import api from "../api";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
|
||||
export default function Profile() {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [listings, setListings] = useState([]);
|
||||
const [myBiz, setMyBiz] = useState([]);
|
||||
const [form, setForm] = useState({ name: "", phone: "", address: "" });
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) { navigate("/login"); return; }
|
||||
setForm({ name: user.name || "", phone: user.phone || "", address: user.address || "" });
|
||||
api.get("/listings/mine").then((r) => setListings(r.data)).catch(() => {});
|
||||
api.get("/businesses/mine").then((r) => setMyBiz(r.data)).catch(() => {});
|
||||
}, [user]);
|
||||
|
||||
const updateProfile = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api.put("/auth/profile", form);
|
||||
alert("Profil mis à jour");
|
||||
} catch (err) { alert(err.response?.data?.error || "Erreur"); }
|
||||
};
|
||||
|
||||
const deleteBiz = async (id) => {
|
||||
if (!confirm("Supprimer ce commerce ?")) return;
|
||||
try {
|
||||
await api.delete("/businesses/" + id);
|
||||
setMyBiz(myBiz.filter((b) => b.id !== id));
|
||||
} catch (err) { alert(err.response?.data?.error || "Erreur"); }
|
||||
};
|
||||
|
||||
const archiveListing = async (id) => {
|
||||
await api.put("/listings/" + id, { status: "archive" });
|
||||
setListings(listings.map((l) => l.id === id ? { ...l, status: "archive" } : l));
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
<h1 className="text-2xl font-bold mb-6">Mon profil</h1>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<form onSubmit={updateProfile} className="border rounded-lg p-4 space-y-3 bg-white">
|
||||
<h2 className="font-semibold">Informations</h2>
|
||||
<input placeholder="Nom" required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
className="w-full border rounded px-3 py-2 text-sm outline-none" />
|
||||
<input placeholder="Téléphone" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
||||
className="w-full border rounded px-3 py-2 text-sm outline-none" />
|
||||
<input placeholder="Adresse" value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })}
|
||||
className="w-full border rounded px-3 py-2 text-sm outline-none" />
|
||||
<button type="submit" className="bg-csv-600 text-white px-4 py-2 rounded text-sm">Enregistrer</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="border rounded-lg p-4 bg-white">
|
||||
<h2 className="font-semibold mb-3">Mes annonces</h2>
|
||||
{listings.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">Aucune annonce</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{listings.map((l) => (
|
||||
<div key={l.id} className="flex justify-between items-center border-b pb-2">
|
||||
<div>
|
||||
<span className="text-sm font-medium">{l.title}</span>
|
||||
<span className={"ml-2 text-xs px-2 py-0.5 rounded " + (l.status === "disponible" ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-500")}>
|
||||
{l.status}
|
||||
</span>
|
||||
</div>
|
||||
{l.status === "disponible" && (
|
||||
<button onClick={() => archiveListing(l.id)} className="text-xs text-gray-500 hover:text-red-600">Archiver</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{myBiz.length > 0 && (
|
||||
<div className="mt-6 border rounded-lg p-4 bg-white">
|
||||
<h2 className="font-semibold mb-3">Mes commerces</h2>
|
||||
<div className="space-y-2">
|
||||
{myBiz.map((b) => (
|
||||
<div key={b.id} className="flex justify-between items-center border-b pb-2">
|
||||
<div>
|
||||
<span className="text-sm font-medium">{b.name}</span>
|
||||
<span className={"ml-2 text-xs px-2 py-0.5 rounded " + (b.approved ? "bg-green-100 text-green-700" : "bg-yellow-100 text-yellow-700")}>
|
||||
{b.approved ? "Approuvé" : "En attente"}
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={() => deleteBiz(b.id)} className="text-xs text-red-500 hover:text-red-700">Supprimer</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
|
||||
export default function Register() {
|
||||
const { register } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [form, setForm] = useState({ name: "", email: "", password: "", phone: "", address: "" });
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await register(form);
|
||||
navigate("/");
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || "Erreur d'inscription");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto px-4 py-12">
|
||||
<h1 className="text-2xl font-bold mb-6">Inscription</h1>
|
||||
{error && <p className="bg-red-50 text-red-600 p-3 rounded mb-4 text-sm">{error}</p>}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Nom complet *</label>
|
||||
<input type="text" required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-csv-500 outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Email *</label>
|
||||
<input type="email" required value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-csv-500 outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Mot de passe * (6 car. min)</label>
|
||||
<input type="password" required minLength={6} value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-csv-500 outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Téléphone</label>
|
||||
<input type="tel" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
||||
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-csv-500 outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Adresse (Condé sur Vesgre)</label>
|
||||
<input type="text" value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })}
|
||||
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-csv-500 outline-none" />
|
||||
</div>
|
||||
<button type="submit" className="w-full bg-csv-600 text-white py-2 rounded-lg hover:bg-csv-700 font-medium">
|
||||
Créer mon compte
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-center text-sm text-gray-500 mt-4">
|
||||
Déjà inscrit ? <Link to="/login" className="text-csv-600 hover:underline">Se connecter</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/** @type {import("tailwindcss").Config} */
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{js,jsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
csv: {
|
||||
50: "#f0fdf4", 100: "#dcfce7", 200: "#bbf7d0", 300: "#86efac",
|
||||
400: "#4ade80", 500: "#22c55e", 600: "#16a34a", 700: "#15803d",
|
||||
800: "#166534", 900: "#14532d",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: { host: "0.0.0.0", port: 5173 },
|
||||
});
|
||||
Reference in New Issue
Block a user