diff --git a/backend/src/db/api/artisan_profiles.js b/backend/src/db/api/artisan_profiles.js
index 65318e6..be3d7fa 100644
--- a/backend/src/db/api/artisan_profiles.js
+++ b/backend/src/db/api/artisan_profiles.js
@@ -1,7 +1,6 @@
const db = require('../models');
const FileDBApi = require('./file');
-const crypto = require('crypto');
const Utils = require('../utils');
@@ -31,6 +30,11 @@ module.exports = class Artisan_profilesDBApi {
null
,
+ city_region: data.city_region
+ ||
+ null
+ ,
+
website_url: data.website_url
||
null
@@ -88,6 +92,11 @@ module.exports = class Artisan_profilesDBApi {
specialty: item.specialty
||
null
+ ,
+
+ city_region: item.city_region
+ ||
+ null
,
website_url: item.website_url
@@ -145,6 +154,9 @@ module.exports = class Artisan_profilesDBApi {
if (data.specialty !== undefined) updatePayload.specialty = data.specialty;
+ if (data.city_region !== undefined) updatePayload.city_region = data.city_region;
+
+
if (data.website_url !== undefined) updatePayload.website_url = data.website_url;
@@ -292,11 +304,7 @@ module.exports = class Artisan_profilesDBApi {
offset = currentPage * limit;
- const orderBy = null;
-
- const transaction = (options && options.transaction) || undefined;
-
- let include = [
+ let include = [
{
model: db.users,
@@ -355,6 +363,17 @@ module.exports = class Artisan_profilesDBApi {
};
}
+ if (filter.city_region) {
+ where = {
+ ...where,
+ [Op.and]: Utils.ilike(
+ 'artisan_profiles',
+ 'city_region',
+ filter.city_region,
+ ),
+ };
+ }
+
if (filter.website_url) {
where = {
...where,
diff --git a/backend/src/db/api/users.js b/backend/src/db/api/users.js
index 49e0ac8..4522bfb 100644
--- a/backend/src/db/api/users.js
+++ b/backend/src/db/api/users.js
@@ -479,11 +479,7 @@ module.exports = class UsersDBApi {
offset = currentPage * limit;
- const orderBy = null;
-
- const transaction = (options && options.transaction) || undefined;
-
- let include = [
+ let include = [
{
model: db.roles,
@@ -815,6 +811,7 @@ module.exports = class UsersDBApi {
{
email: data.email,
firstName: data.firstName,
+ lastName: data.lastName || null,
authenticationUid: data.authenticationUid,
password: data.password,
@@ -823,7 +820,7 @@ module.exports = class UsersDBApi {
);
const app_role = await db.roles.findOne({
- where: { name: config.roles?.user || "User" },
+ where: { name: data.role || config.roles?.user || "User" },
});
if (app_role?.id) {
await users.setApp_role(app_role?.id || null, {
diff --git a/backend/src/db/migrations/20260501010000-add-city-region-to-artisan-profiles.js b/backend/src/db/migrations/20260501010000-add-city-region-to-artisan-profiles.js
new file mode 100644
index 0000000..0c52312
--- /dev/null
+++ b/backend/src/db/migrations/20260501010000-add-city-region-to-artisan-profiles.js
@@ -0,0 +1,14 @@
+'use strict';
+
+module.exports = {
+ async up(queryInterface, Sequelize) {
+ await queryInterface.addColumn('artisan_profiles', 'city_region', {
+ type: Sequelize.TEXT,
+ allowNull: true,
+ });
+ },
+
+ async down(queryInterface) {
+ await queryInterface.removeColumn('artisan_profiles', 'city_region');
+ },
+};
diff --git a/backend/src/db/models/artisan_profiles.js b/backend/src/db/models/artisan_profiles.js
index ec5e27a..f24d5fa 100644
--- a/backend/src/db/models/artisan_profiles.js
+++ b/backend/src/db/models/artisan_profiles.js
@@ -1,8 +1,3 @@
-const config = require('../../config');
-const providers = config.providers;
-const crypto = require('crypto');
-const bcrypt = require('bcrypt');
-const moment = require('moment');
module.exports = function(sequelize, DataTypes) {
const artisan_profiles = sequelize.define(
@@ -26,6 +21,13 @@ specialty: {
+ },
+
+city_region: {
+ type: DataTypes.TEXT,
+
+
+
},
website_url: {
diff --git a/backend/src/index.js b/backend/src/index.js
index 1f5bcda..0055e2a 100644
--- a/backend/src/index.js
+++ b/backend/src/index.js
@@ -6,7 +6,6 @@ const passport = require('passport');
const path = require('path');
const fs = require('fs');
const bodyParser = require('body-parser');
-const db = require('./db/models');
const config = require('./config');
const swaggerUI = require('swagger-ui-express');
const swaggerJsDoc = require('swagger-jsdoc');
@@ -18,7 +17,7 @@ const sqlRoutes = require('./routes/sql');
const pexelsRoutes = require('./routes/pexels');
const openaiRoutes = require('./routes/openai');
-
+const contactFormRoutes = require('./routes/contactForm');
const usersRoutes = require('./routes/users');
@@ -104,6 +103,7 @@ app.use(bodyParser.json());
app.use('/api/auth', authRoutes);
app.use('/api/file', fileRoutes);
app.use('/api/pexels', pexelsRoutes);
+app.use('/api/contact-form', contactFormRoutes);
app.enable('trust proxy');
diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js
index d6f29e8..2906c0a 100644
--- a/backend/src/routes/auth.js
+++ b/backend/src/routes/auth.js
@@ -140,11 +140,10 @@ router.post('/send-password-reset-email', wrapAsync(async (req, res) => {
*/
router.post('/signup', wrapAsync(async (req, res) => {
- const link = new URL(req.headers.referer);
+ const referer = req.headers.referer || `${req.protocol}://${req.get('host')}`;
+ const link = new URL(referer);
const payload = await AuthService.signup(
- req.body.email,
- req.body.password,
-
+ req.body,
req,
link.host,
)
diff --git a/backend/src/routes/contactForm.js b/backend/src/routes/contactForm.js
index e69de29..76b819e 100644
--- a/backend/src/routes/contactForm.js
+++ b/backend/src/routes/contactForm.js
@@ -0,0 +1,92 @@
+const express = require('express');
+
+const Contact_submissionsService = require('../services/contact_submissions');
+const EmailSender = require('../services/email');
+const config = require('../config');
+const wrapAsync = require('../helpers').wrapAsync;
+
+const router = express.Router();
+
+const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+
+function trimValue(value) {
+ return typeof value === 'string' ? value.trim() : '';
+}
+
+function createBadRequest(message) {
+ const error = new Error(message);
+ error.code = 400;
+ return error;
+}
+
+function escapeHtml(value) {
+ return String(value)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+router.post(
+ '/',
+ wrapAsync(async (req, res) => {
+ const first_name = trimValue(req.body.first_name);
+ const last_name = trimValue(req.body.last_name);
+ const email = trimValue(req.body.email).toLowerCase();
+ const phone = trimValue(req.body.phone) || null;
+ const city_region = trimValue(req.body.city_region);
+ const subject = trimValue(req.body.subject) || null;
+ const message = trimValue(req.body.message);
+
+ if (!first_name || !last_name || !email || !city_region || !message) {
+ throw createBadRequest('Veuillez compléter tous les champs requis.');
+ }
+
+ if (!emailRegex.test(email)) {
+ throw createBadRequest('Veuillez saisir une adresse email valide.');
+ }
+
+ const payload = {
+ first_name,
+ last_name,
+ email,
+ phone,
+ city_region,
+ subject,
+ message,
+ status: 'new',
+ submitted_at: new Date(),
+ };
+
+ await Contact_submissionsService.create(payload, req.currentUser || { id: null });
+
+ if (EmailSender.isConfigured) {
+ await new EmailSender({
+ to: config.admin_email,
+ subject: `Nouveau message Artisan Marketplace${subject ? ` — ${subject}` : ''}`,
+ html: async () => `
+
+
Nouveau message reçu
+
Prénom : ${escapeHtml(first_name)}
+
Nom : ${escapeHtml(last_name)}
+
Email : ${escapeHtml(email)}
+
Téléphone : ${escapeHtml(phone || '—')}
+
Ville / Région : ${escapeHtml(city_region)}
+
Sujet : ${escapeHtml(subject || '—')}
+
Message :
+
${escapeHtml(message).replace(/\n/g, ' ')}
+
+ `,
+ }).send();
+ } else {
+ console.warn('Contact form email notification skipped because email is not configured.');
+ }
+
+ res.status(200).send({ success: true });
+ }),
+);
+
+router.use('/', require('../helpers').commonErrorHandler);
+
+module.exports = router;
diff --git a/backend/src/services/auth.js b/backend/src/services/auth.js
index 2862da4..0d04853 100644
--- a/backend/src/services/auth.js
+++ b/backend/src/services/auth.js
@@ -1,4 +1,6 @@
+const db = require('../db/models');
const UsersDBApi = require('../db/api/users');
+const Artisan_profilesDBApi = require('../db/api/artisan_profiles');
const ValidationError = require('./notifications/errors/validation');
const ForbiddenError = require('./notifications/errors/forbidden');
const bcrypt = require('bcrypt');
@@ -10,8 +12,19 @@ const config = require('../config');
const helpers = require('../helpers');
class Auth {
- static async signup(email, password, options = {}, host) {
- const user = await UsersDBApi.findBy({email});
+ static async signup(payload = {}, options = {}, host) {
+ const email = String(payload.email || '').trim().toLowerCase();
+ const password = payload.password;
+ const role = payload.role;
+ const firstName = String(payload.firstName || '').trim() || email.split('@')[0];
+ const lastName = String(payload.lastName || '').trim() || null;
+ const cityRegion = String(payload.city_region || payload.cityRegion || '').trim() || null;
+
+ if (!email || !password) {
+ throw new ValidationError();
+ }
+
+ const user = await UsersDBApi.findBy({ email });
const hashedPassword = await bcrypt.hash(
password,
@@ -54,34 +67,60 @@ class Auth {
return helpers.jwtSign(data);
}
- const newUser = await UsersDBApi.createFromAuth(
- {
- firstName: email.split('@')[0],
- password: hashedPassword,
- email: email,
-
- },
- options,
- );
+ const transaction = await db.sequelize.transaction();
- if (EmailSender.isConfigured) {
- await this.sendEmailAddressVerificationEmail(
- newUser.email,
- host,
+ try {
+ const newUser = await UsersDBApi.createFromAuth(
+ {
+ firstName,
+ lastName,
+ password: hashedPassword,
+ email,
+ role,
+ },
+ {
+ ...options,
+ transaction,
+ },
);
- }
- const data = {
- user: {
- id: newUser.id,
- email: newUser.email
+ if (role === 'artisan_seller') {
+ await Artisan_profilesDBApi.create(
+ {
+ user: newUser.id,
+ city_region: cityRegion,
+ },
+ {
+ currentUser: { id: newUser.id },
+ transaction,
+ },
+ );
}
- };
- return helpers.jwtSign(data);
+ await transaction.commit();
+
+ if (EmailSender.isConfigured) {
+ await this.sendEmailAddressVerificationEmail(
+ newUser.email,
+ host,
+ );
+ }
+
+ const data = {
+ user: {
+ id: newUser.id,
+ email: newUser.email
+ }
+ };
+
+ return helpers.jwtSign(data);
+ } catch (error) {
+ await transaction.rollback();
+ throw error;
+ }
}
- static async signin(email, password, options = {}) {
+ static async signin(email, password) {
const user = await UsersDBApi.findBy({email});
if (!user) {
diff --git a/frontend/src/components/ArtisanStudioSection.tsx b/frontend/src/components/ArtisanStudioSection.tsx
new file mode 100644
index 0000000..1044ea6
--- /dev/null
+++ b/frontend/src/components/ArtisanStudioSection.tsx
@@ -0,0 +1,415 @@
+/* eslint-disable @next/next/no-img-element */
+import Link from 'next/link';
+import axios from 'axios';
+import React, { useEffect, useMemo, useState } from 'react';
+import { useRouter } from 'next/router';
+import {
+ mdiAccountCircleOutline,
+ mdiArrowRight,
+ mdiCreditCardOutline,
+ mdiEmailOutline,
+ mdiPackageVariantClosed,
+ mdiPaletteOutline,
+} from '@mdi/js';
+import BaseButton from './BaseButton';
+import BaseIcon from './BaseIcon';
+import CardBox from './CardBox';
+import ImageField from './ImageField';
+import { hasPermission } from '../helpers/userPermissions';
+import { serviceCards } from '../data/artisanMarketplace';
+
+type StudioTab = 'products' | 'profile' | 'services' | 'contact';
+
+type StudioProduct = {
+ id: string;
+ name: string;
+ description?: string;
+ price_eur?: string | number;
+ status?: 'online' | 'draft' | 'archived';
+ stock_qty?: number;
+ photos?: Array<{ publicUrl: string }>;
+};
+
+type StudioProfile = {
+ id: string;
+ workshop_bio?: string;
+ specialty?: string;
+ city_region?: string;
+ website_url?: string;
+ social_links?: string;
+ profile_photo?: Array<{ publicUrl: string }>;
+};
+
+type Props = {
+ currentUser: any;
+};
+
+const tabs: Array<{ id: StudioTab; label: string }> = [
+ { id: 'products', label: 'Mes produits' },
+ { id: 'profile', label: 'Mon profil' },
+ { id: 'services', label: 'Nos services' },
+ { id: 'contact', label: 'Contact' },
+];
+
+const savedMessages: Record = {
+ product: 'Votre produit a bien été enregistré.',
+ profile: 'Votre profil atelier a bien été mis à jour.',
+ account: 'Les informations de votre compte ont été mises à jour.',
+};
+
+function formatPrice(value?: string | number) {
+ const numericValue = Number(value || 0);
+
+ return new Intl.NumberFormat('fr-FR', {
+ style: 'currency',
+ currency: 'EUR',
+ maximumFractionDigits: 0,
+ }).format(Number.isNaN(numericValue) ? 0 : numericValue);
+}
+
+function getSavedValue(saved: string | string[] | undefined) {
+ if (Array.isArray(saved)) {
+ return saved[0] || '';
+ }
+
+ return saved || '';
+}
+
+export default function ArtisanStudioSection({ currentUser }: Props) {
+ const router = useRouter();
+ const canManageProducts = hasPermission(currentUser, 'CREATE_PRODUCTS') || hasPermission(currentUser, 'UPDATE_PRODUCTS');
+ const canManageProfile = hasPermission(currentUser, 'UPDATE_ARTISAN_PROFILES');
+ const canSeeStudio = canManageProducts || canManageProfile;
+ const [activeTab, setActiveTab] = useState('products');
+ const [loading, setLoading] = useState(false);
+ const [studioError, setStudioError] = useState('');
+ const [products, setProducts] = useState([]);
+ const [workshopProfile, setWorkshopProfile] = useState(null);
+
+ useEffect(() => {
+ const queryTab = Array.isArray(router.query.tab) ? router.query.tab[0] : router.query.tab;
+
+ if (queryTab && tabs.some((tab) => tab.id === queryTab)) {
+ setActiveTab(queryTab as StudioTab);
+ }
+ }, [router.query.tab]);
+
+ useEffect(() => {
+ if (!canSeeStudio || !currentUser?.id) {
+ return;
+ }
+
+ const loadStudioData = async () => {
+ setLoading(true);
+ setStudioError('');
+
+ try {
+ const requests = [
+ canManageProducts
+ ? axios.get(`/products?limit=6&page=0&artisan=${currentUser.id}`)
+ : Promise.resolve({ data: { rows: [] } }),
+ canManageProfile
+ ? axios.get(`/artisan_profiles?limit=1&page=0&user=${currentUser.id}`)
+ : Promise.resolve({ data: { rows: [] } }),
+ ];
+
+ const [productsResponse, profileResponse] = await Promise.all(requests);
+ setProducts(Array.isArray(productsResponse.data?.rows) ? productsResponse.data.rows : []);
+ setWorkshopProfile(Array.isArray(profileResponse.data?.rows) ? profileResponse.data.rows[0] || null : null);
+ } catch (error) {
+ console.error('Failed to load artisan studio data', error);
+ setStudioError('Impossible de charger les informations du studio pour le moment.');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ loadStudioData();
+ }, [canManageProducts, canManageProfile, canSeeStudio, currentUser?.id]);
+
+ const savedKey = getSavedValue(router.query.saved);
+ const savedMessage = savedMessages[savedKey];
+ const onlineProducts = useMemo(
+ () => products.filter((product) => product.status === 'online').length,
+ [products],
+ );
+
+ if (!canSeeStudio) {
+ return null;
+ }
+
+ const profileEditHref = '/artisan/profile';
+
+ return (
+
+
+
+
+
Studio artisan
+
Un espace clair pour publier, raconter votre atelier et demander de l’aide.
+
+ Cette première itération relie votre dashboard aux actions utiles du quotidien : ajouter un produit, enrichir votre profil public et déclencher le formulaire partagé avec le bon contexte.
+
+
+
+
+
+
Produits publiés
+
{products.length}
+
{onlineProducts} en ligne
+
+
+
Profil atelier
+
{workshopProfile?.city_region ? 'Prêt' : 'À compléter'}
+
visibilité & confiance
+
+
+
+
+ {savedMessage && (
+
+ {savedMessage}
+
+ )}
+
+ {studioError && (
+
+ {studioError}
+
+ )}
+
+
+ {tabs.map((tab) => (
+ setActiveTab(tab.id)}
+ className={`rounded-full border px-4 py-2 text-sm font-medium transition ${
+ activeTab === tab.id
+ ? 'border-[#D4A94E] bg-[#D4A94E] text-[#111111]'
+ : 'border-white/10 bg-white/5 text-white/70 hover:border-white/25 hover:text-white'
+ }`}
+ >
+ {tab.label}
+
+ ))}
+
+
+
+ {activeTab === 'products' && (
+
+
+
+
+
Mes produits
+
Les produits créés depuis votre atelier
+
+
+
+
+ {loading ? (
+
+ Chargement de vos produits...
+
+ ) : products.length === 0 ? (
+
+
Aucun produit pour l’instant
+
+ Créez votre première fiche produit pour commencer à structurer votre catalogue artisan.
+
+
+ ) : (
+
+ {products.map((product) => {
+ const statusStyles =
+ product.status === 'online'
+ ? 'border-emerald-400/20 bg-emerald-400/10 text-emerald-100'
+ : product.status === 'draft'
+ ? 'border-amber-400/20 bg-amber-400/10 text-amber-100'
+ : 'border-slate-400/20 bg-slate-400/10 text-slate-100';
+ const statusLabel =
+ product.status === 'online'
+ ? 'En ligne'
+ : product.status === 'draft'
+ ? 'Brouillon'
+ : 'Archivé';
+
+ return (
+
+
+
+ {product.photos?.[0]?.publicUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+
{product.name}
+
+ {statusLabel}
+
+
+
+ {product.description || 'Ajoutez une description claire pour rassurer vos acheteurs.'}
+
+
+ {formatPrice(product.price_eur)}
+ Stock : {product.stock_qty || 0}
+
+
+
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+
+
+
+
+
Conseil pratique
+
Faites de chaque produit une mini vitrine.
+
+
+ • Un nom simple + une matière claire = meilleure compréhension immédiate.
+ • Les statuts vous aident à préparer un brouillon avant la mise en ligne.
+ • Une photo soignée suffit pour démarrer cette première version.
+
+
+
+
+ )}
+
+ {activeTab === 'profile' && (
+
+
+
+
+
+
+
+
+
Compte
+
+ {[currentUser?.firstName, currentUser?.lastName].filter(Boolean).join(' ') || currentUser?.email}
+
+
{currentUser?.email}
+
+
+
+
+
+
+
Rôle
+
{currentUser?.app_role?.name || 'Utilisateur'}
+
+
+
Téléphone
+
{currentUser?.phoneNumber || 'À renseigner'}
+
+
+
+
+
+
+
+
+
+
+
+
Profil public
+
{workshopProfile?.specialty || 'Présentez votre spécialité'}
+
{workshopProfile?.city_region || 'Ville / Région à compléter'}
+
+
+
+
+
+
+
{workshopProfile?.workshop_bio || 'Ajoutez une bio courte pour raconter votre atelier et rassurer les visiteurs.'}
+
+
+
Site web
+
{workshopProfile?.website_url || 'Optionnel'}
+
+
+
Réseaux sociaux
+
{workshopProfile?.social_links || 'Optionnel'}
+
+
+
+
+
+ )}
+
+ {activeTab === 'services' && (
+
+ {serviceCards.map((service) => (
+
+
+
+
Service
+
{service.title}
+
+
+
+
+
+ {service.description}
+
+ Nous contacter
+
+
+
+ ))}
+
+ )}
+
+ {activeTab === 'contact' && (
+
+
+
+
+
+
+
Contact direct
+
Un raccourci vers le formulaire partagé.
+
+
+
+ Utilisez ce point d’entrée pour les demandes générales liées à votre activité artisanale, aux services ou à un besoin opérationnel ponctuel.
+
+
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx
index 55559d2..93ddba0 100644
--- a/frontend/src/components/NavBarItem.tsx
+++ b/frontend/src/components/NavBarItem.tsx
@@ -1,6 +1,5 @@
-import React, {useEffect, useRef} from 'react'
+import React, { useEffect, useRef, useState } from 'react'
import Link from 'next/link'
-import { useState } from 'react'
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
import BaseDivider from './BaseDivider'
import BaseIcon from './BaseIcon'
diff --git a/frontend/src/components/PublicSiteFooter.tsx b/frontend/src/components/PublicSiteFooter.tsx
new file mode 100644
index 0000000..31e62a3
--- /dev/null
+++ b/frontend/src/components/PublicSiteFooter.tsx
@@ -0,0 +1,31 @@
+import Link from 'next/link';
+
+export default function PublicSiteFooter() {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/PublicSiteHeader.tsx b/frontend/src/components/PublicSiteHeader.tsx
new file mode 100644
index 0000000..3ab1365
--- /dev/null
+++ b/frontend/src/components/PublicSiteHeader.tsx
@@ -0,0 +1,66 @@
+import Link from 'next/link';
+import { useRouter } from 'next/router';
+import BaseIcon from './BaseIcon';
+import { mdiStorefrontOutline } from '@mdi/js';
+
+const links = [
+ { href: '/marketplace', label: 'Marketplace' },
+ { href: '/contact', label: 'Contact' },
+];
+
+export default function PublicSiteHeader() {
+ const router = useRouter();
+
+ return (
+
+ );
+}
diff --git a/frontend/src/data/artisanMarketplace.ts b/frontend/src/data/artisanMarketplace.ts
new file mode 100644
index 0000000..5378057
--- /dev/null
+++ b/frontend/src/data/artisanMarketplace.ts
@@ -0,0 +1,128 @@
+export type FeaturedProduct = {
+ id: string;
+ slug: string;
+ name: string;
+ artisan: string;
+ category: string;
+ city: string;
+ price: number;
+ description: string;
+ highlight: string;
+ image: string;
+};
+
+export type ServiceCard = {
+ id: string;
+ title: string;
+ description: string;
+ subject: string;
+};
+
+export const featuredProducts: FeaturedProduct[] = [
+ {
+ id: 'ceramic-bowl-sable',
+ slug: 'ceramic-bowl-sable',
+ name: 'Bol en céramique Sable',
+ artisan: 'Atelier Lune Claire',
+ category: 'Céramique',
+ city: 'Lyon',
+ price: 38,
+ description:
+ 'Bol façonné à la main avec émail mat naturel, idéal pour les petits-déjeuners ou les apéritifs du quotidien.',
+ highlight: 'Édition en petite série',
+ image:
+ 'https://images.unsplash.com/photo-1612196808214-b7e239e5a6d8?auto=format&fit=crop&w=1200&q=80',
+ },
+ {
+ id: 'woven-bag-sunset',
+ slug: 'woven-bag-sunset',
+ name: 'Sac tissé Sunset',
+ artisan: 'Maison Tressage',
+ category: 'Mode',
+ city: 'Marseille',
+ price: 74,
+ description:
+ 'Sac souple tissé à la main en fibres naturelles avec finitions cuir végétal et bandoulière réglable.',
+ highlight: 'Fabrication responsable',
+ image:
+ 'https://images.unsplash.com/photo-1523381210434-271e8be1f52b?auto=format&fit=crop&w=1200&q=80',
+ },
+ {
+ id: 'linen-table-runner',
+ slug: 'linen-table-runner',
+ name: 'Chemin de table en lin brut',
+ artisan: 'Fil & Matière',
+ category: 'Maison',
+ city: 'Nantes',
+ price: 42,
+ description:
+ 'Textile lavé en lin épais, cousu dans un atelier nantais pour des tables chaleureuses et durables.',
+ highlight: 'Coupe et couture locale',
+ image:
+ 'https://images.unsplash.com/photo-1517705008128-361805f42e86?auto=format&fit=crop&w=1200&q=80',
+ },
+ {
+ id: 'wood-lamp-atelier',
+ slug: 'wood-lamp-atelier',
+ name: 'Lampe Atelier en chêne',
+ artisan: 'Bois Vivant',
+ category: 'Décoration',
+ city: 'Bordeaux',
+ price: 129,
+ description:
+ 'Lampe sculptée dans du chêne français avec un abat-jour texturé pour une lumière douce et contemporaine.',
+ highlight: 'Pièce signée',
+ image:
+ 'https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?auto=format&fit=crop&w=1200&q=80',
+ },
+ {
+ id: 'silver-earrings-echo',
+ slug: 'silver-earrings-echo',
+ name: 'Boucles d’oreilles Echo',
+ artisan: 'Studio Argent',
+ category: 'Bijoux',
+ city: 'Paris',
+ price: 56,
+ description:
+ 'Boucles martelées à la main en argent recyclé, légères à porter et parfaites pour une silhouette minimaliste.',
+ highlight: 'Argent recyclé',
+ image:
+ 'https://images.unsplash.com/photo-1617038220319-276d3cfab638?auto=format&fit=crop&w=1200&q=80',
+ },
+ {
+ id: 'candle-fig-tree',
+ slug: 'candle-fig-tree',
+ name: 'Bougie Figuier & atelier',
+ artisan: 'Flamme Botanique',
+ category: 'Bien-être',
+ city: 'Toulouse',
+ price: 26,
+ description:
+ 'Bougie coulée à la main dans un contenant en verre soufflé, parfum figuier avec cire végétale.',
+ highlight: 'Cire végétale & parfum de Grasse',
+ image:
+ 'https://images.unsplash.com/photo-1603006905003-be475563bc59?auto=format&fit=crop&w=1200&q=80',
+ },
+];
+
+export const marketplaceCategories = [
+ 'Toutes',
+ ...Array.from(new Set(featuredProducts.map((product) => product.category))),
+];
+
+export const serviceCards: ServiceCard[] = [
+ {
+ id: 'marketing',
+ title: 'Marketing digital pour artisans',
+ description:
+ 'Réseaux sociaux, publicité et visibilité en ligne pour faire rayonner votre atelier.',
+ subject: 'Marketing digital pour artisans',
+ },
+ {
+ id: 'formation',
+ title: 'Formation artisans',
+ description:
+ 'Vente en ligne, photographie produit et gestion commerciale pour structurer vos ventes.',
+ subject: 'Formation artisans',
+ },
+];
diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx
index 1b9907d..73d8391 100644
--- a/frontend/src/layouts/Authenticated.tsx
+++ b/frontend/src/layouts/Authenticated.tsx
@@ -1,5 +1,4 @@
-import React, { ReactNode, useEffect } from 'react'
-import { useState } from 'react'
+import React, { ReactNode, useEffect, useState } from 'react'
import jwt from 'jsonwebtoken';
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
import menuAside from '../menuAside'
diff --git a/frontend/src/pages/artisan-auth.tsx b/frontend/src/pages/artisan-auth.tsx
new file mode 100644
index 0000000..ab8e5c3
--- /dev/null
+++ b/frontend/src/pages/artisan-auth.tsx
@@ -0,0 +1,288 @@
+import Head from 'next/head';
+import React, { ReactElement, useEffect, useState } from 'react';
+import { Field, Form, Formik } from 'formik';
+import Link from 'next/link';
+import axios from 'axios';
+import jwt from 'jsonwebtoken';
+import CardBox from '../components/CardBox';
+import FormField from '../components/FormField';
+import BaseButton from '../components/BaseButton';
+import LayoutGuest from '../layouts/Guest';
+import PublicSiteHeader from '../components/PublicSiteHeader';
+import PublicSiteFooter from '../components/PublicSiteFooter';
+import { getPageTitle } from '../config';
+import { findMe, loginUser } from '../stores/authSlice';
+import { useAppDispatch } from '../stores/hooks';
+import { useRouter } from 'next/router';
+
+type AuthTab = 'login' | 'create';
+
+const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+
+function getFriendlyError(error: any) {
+ return error?.response?.data || 'Une erreur est survenue. Merci de réessayer.';
+}
+
+export default function ArtisanAuthPage() {
+ const dispatch = useAppDispatch();
+ const router = useRouter();
+ const [activeTab, setActiveTab] = useState('login');
+ const [loginError, setLoginError] = useState('');
+ const [signupError, setSignupError] = useState('');
+
+ useEffect(() => {
+ if (typeof window !== 'undefined' && localStorage.getItem('token')) {
+ router.replace('/dashboard?tab=products');
+ }
+ }, [router]);
+
+ return (
+ <>
+
+ {getPageTitle('Espace artisan')}
+
+
+
+
+
+
+
+ Connexion & création
+
+ Devenir un artisan et piloter son atelier depuis un espace dédié.
+
+
+ Le tableau de bord concentre la publication produit, la mise à jour du profil public, les services d’accompagnement et le formulaire partagé.
+
+
+
+
+
Après la connexion
+
+ Vous arrivez directement sur votre dashboard artisan pour gérer vos produits, votre profil et vos demandes d’accompagnement.
+
+
+
+
Créer un compte artisan
+
+ Le compte crée automatiquement l’accès vendeur, avec un profil atelier prêt à être complété dans le dashboard.
+
+
+
+ Aller vers la page de login admin →
+
+
+
+
+
+
+
+ {([
+ { id: 'login', label: 'Se connecter' },
+ { id: 'create', label: 'Créer mon compte' },
+ ] as Array<{ id: AuthTab; label: string }>).map((tab) => (
+ setActiveTab(tab.id)}
+ className={`rounded-full px-4 py-3 text-sm font-semibold transition ${
+ activeTab === tab.id
+ ? 'bg-[#D4A94E] text-[#111111]'
+ : 'text-white/65 hover:bg-white/5 hover:text-white'
+ }`}
+ >
+ {tab.label}
+
+ ))}
+
+
+
+
+ {activeTab === 'login' ? (
+
{
+ const errors: Record = {};
+
+ if (!values.email.trim()) {
+ errors.email = 'Champ requis';
+ } else if (!emailRegex.test(values.email.trim())) {
+ errors.email = 'Email invalide';
+ }
+
+ if (!values.password.trim()) {
+ errors.password = 'Champ requis';
+ }
+
+ return errors;
+ }}
+ onSubmit={async (values, helpers) => {
+ setLoginError('');
+ const action = await dispatch(loginUser(values));
+ helpers.setSubmitting(false);
+
+ if (loginUser.fulfilled.match(action)) {
+ router.push('/dashboard?tab=products');
+ return;
+ }
+
+ setLoginError(String(action.payload || 'Connexion impossible.'));
+ }}
+ >
+ {({ errors, touched, isSubmitting }) => (
+
+ )}
+
+ ) : (
+
{
+ const errors: Record = {};
+
+ if (!values.firstName.trim()) {
+ errors.firstName = 'Champ requis';
+ }
+ if (!values.lastName.trim()) {
+ errors.lastName = 'Champ requis';
+ }
+ if (!values.email.trim()) {
+ errors.email = 'Champ requis';
+ } else if (!emailRegex.test(values.email.trim())) {
+ errors.email = 'Email invalide';
+ }
+ if (!values.password.trim()) {
+ errors.password = 'Champ requis';
+ }
+ if (!values.city_region.trim()) {
+ errors.city_region = 'Champ requis';
+ }
+
+ return errors;
+ }}
+ onSubmit={async (values, helpers) => {
+ setSignupError('');
+
+ try {
+ const response = await axios.post('/auth/signup', {
+ ...values,
+ role: 'artisan_seller',
+ });
+ const token = response.data;
+ const user = jwt.decode(token);
+
+ localStorage.setItem('token', token);
+ localStorage.setItem('user', JSON.stringify(user));
+ axios.defaults.headers.common.Authorization = `Bearer ${token}`;
+
+ await dispatch(findMe());
+ router.push('/dashboard?tab=products');
+ } catch (error) {
+ console.error('Artisan signup failed', error);
+ setSignupError(getFriendlyError(error));
+ } finally {
+ helpers.setSubmitting(false);
+ }
+ }}
+ >
+ {({ errors, touched, isSubmitting }) => (
+
+ )}
+
+ )}
+
+
+
+
+
+
+ >
+ );
+}
+
+ArtisanAuthPage.getLayout = function getLayout(page: ReactElement) {
+ return {page} ;
+};
diff --git a/frontend/src/pages/artisan/account.tsx b/frontend/src/pages/artisan/account.tsx
new file mode 100644
index 0000000..2c62f2b
--- /dev/null
+++ b/frontend/src/pages/artisan/account.tsx
@@ -0,0 +1,141 @@
+import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js';
+import Head from 'next/head';
+import React, { ReactElement, useMemo } from 'react';
+import { Field, Form, Formik } from 'formik';
+import axios from 'axios';
+import CardBox from '../../components/CardBox';
+import FormField from '../../components/FormField';
+import BaseButton from '../../components/BaseButton';
+import BaseButtons from '../../components/BaseButtons';
+import BaseDivider from '../../components/BaseDivider';
+import FormImagePicker from '../../components/FormImagePicker';
+import LayoutAuthenticated from '../../layouts/Authenticated';
+import SectionMain from '../../components/SectionMain';
+import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
+import { getPageTitle } from '../../config';
+import { findMe } from '../../stores/authSlice';
+import { useAppDispatch, useAppSelector } from '../../stores/hooks';
+import { useRouter } from 'next/router';
+
+export default function ArtisanAccountPage() {
+ const router = useRouter();
+ const dispatch = useAppDispatch();
+ const { currentUser } = useAppSelector((state) => state.auth);
+
+ const initialValues = useMemo(
+ () => ({
+ firstName: currentUser?.firstName || '',
+ lastName: currentUser?.lastName || '',
+ phoneNumber: currentUser?.phoneNumber || '',
+ avatar: currentUser?.avatar || [],
+ }),
+ [currentUser?.avatar, currentUser?.firstName, currentUser?.lastName, currentUser?.phoneNumber],
+ );
+
+ return (
+ <>
+
+ {getPageTitle('Compte artisan')}
+
+
+
+ {''}
+
+
+
+ {
+ const errors: Record = {};
+
+ if (!values.firstName.trim()) {
+ errors.firstName = 'Champ requis';
+ }
+ if (!values.lastName.trim()) {
+ errors.lastName = 'Champ requis';
+ }
+
+ return errors;
+ }}
+ onSubmit={async (values, helpers) => {
+ try {
+ await axios.put('/auth/profile', {
+ profile: {
+ ...values,
+ email: currentUser?.email,
+ },
+ });
+ await dispatch(findMe());
+ router.push('/dashboard?tab=profile&saved=account');
+ } catch (error) {
+ console.error('Failed to update artisan account', error);
+ helpers.setStatus('Impossible de mettre à jour votre compte.');
+ } finally {
+ helpers.setSubmitting(false);
+ }
+ }}
+ >
+ {({ errors, touched, isSubmitting, status }) => (
+
+ )}
+
+
+
+ >
+ );
+}
+
+ArtisanAccountPage.getLayout = function getLayout(page: ReactElement) {
+ return {page} ;
+};
diff --git a/frontend/src/pages/artisan/products/edit.tsx b/frontend/src/pages/artisan/products/edit.tsx
new file mode 100644
index 0000000..3af695c
--- /dev/null
+++ b/frontend/src/pages/artisan/products/edit.tsx
@@ -0,0 +1,214 @@
+import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js';
+import Head from 'next/head';
+import React, { ReactElement, useEffect, useState } from 'react';
+import { Field, Form, Formik } from 'formik';
+import axios from 'axios';
+import CardBox from '../../../components/CardBox';
+import FormField from '../../../components/FormField';
+import BaseButton from '../../../components/BaseButton';
+import BaseButtons from '../../../components/BaseButtons';
+import BaseDivider from '../../../components/BaseDivider';
+import FormImagePicker from '../../../components/FormImagePicker';
+import { SelectField } from '../../../components/SelectField';
+import { RichTextField } from '../../../components/RichTextField';
+import LayoutAuthenticated from '../../../layouts/Authenticated';
+import SectionMain from '../../../components/SectionMain';
+import SectionTitleLineWithButton from '../../../components/SectionTitleLineWithButton';
+import { getPageTitle } from '../../../config';
+import { update } from '../../../stores/products/productsSlice';
+import { useAppDispatch, useAppSelector } from '../../../stores/hooks';
+import { useRouter } from 'next/router';
+
+type ProductValues = {
+ artisan: string;
+ category: any;
+ name: string;
+ description: string;
+ price_eur: string;
+ photos: any[];
+ status: string;
+ stock_qty: string;
+};
+
+const defaultValues: ProductValues = {
+ artisan: '',
+ category: null,
+ name: '',
+ description: '',
+ price_eur: '',
+ photos: [],
+ status: 'draft',
+ stock_qty: '',
+};
+
+export default function ArtisanProductEditPage() {
+ const router = useRouter();
+ const dispatch = useAppDispatch();
+ const { currentUser } = useAppSelector((state) => state.auth);
+ const [loading, setLoading] = useState(true);
+ const [initialValues, setInitialValues] = useState(defaultValues);
+ const productId = Array.isArray(router.query.id) ? router.query.id[0] : router.query.id;
+
+ useEffect(() => {
+ if (!productId) {
+ return;
+ }
+
+ const loadProduct = async () => {
+ setLoading(true);
+
+ try {
+ const response = await axios.get(`/products/${productId}`);
+ const product = response.data;
+ setInitialValues({
+ artisan: currentUser?.id || product.artisanId || '',
+ category: product.category || null,
+ name: product.name || '',
+ description: product.description || '',
+ price_eur: String(product.price_eur || ''),
+ photos: product.photos || [],
+ status: product.status || 'draft',
+ stock_qty: String(product.stock_qty || ''),
+ });
+ } catch (error) {
+ console.error('Failed to load artisan product', error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ loadProduct();
+ }, [currentUser?.id, productId]);
+
+ return (
+ <>
+
+ {getPageTitle('Modifier un produit')}
+
+
+
+ {''}
+
+
+
+ {loading ? (
+ Chargement du produit...
+ ) : (
+ {
+ const errors: Record = {};
+
+ if (!values.name.trim()) {
+ errors.name = 'Champ requis';
+ }
+ if (!values.description.trim()) {
+ errors.description = 'Champ requis';
+ }
+ if (!values.category) {
+ errors.category = 'Champ requis';
+ }
+ if (!values.price_eur) {
+ errors.price_eur = 'Champ requis';
+ }
+
+ return errors;
+ }}
+ onSubmit={async (values, helpers) => {
+ try {
+ await dispatch(
+ update({
+ id: productId,
+ data: {
+ ...values,
+ artisan: currentUser?.id,
+ },
+ }),
+ ).unwrap();
+ router.push('/dashboard?tab=products&saved=product');
+ } catch (error) {
+ console.error('Failed to update artisan product', error);
+ helpers.setStatus('Impossible de mettre à jour ce produit.');
+ } finally {
+ helpers.setSubmitting(false);
+ }
+ }}
+ >
+ {({ errors, touched, isSubmitting, status }) => (
+
+ )}
+
+ )}
+
+
+ >
+ );
+}
+
+ArtisanProductEditPage.getLayout = function getLayout(page: ReactElement) {
+ return {page} ;
+};
diff --git a/frontend/src/pages/artisan/products/new.tsx b/frontend/src/pages/artisan/products/new.tsx
new file mode 100644
index 0000000..83e6ce6
--- /dev/null
+++ b/frontend/src/pages/artisan/products/new.tsx
@@ -0,0 +1,157 @@
+import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js';
+import Head from 'next/head';
+import React, { ReactElement, useMemo } from 'react';
+import { Field, Form, Formik } from 'formik';
+import CardBox from '../../../components/CardBox';
+import FormField from '../../../components/FormField';
+import BaseButton from '../../../components/BaseButton';
+import BaseButtons from '../../../components/BaseButtons';
+import BaseDivider from '../../../components/BaseDivider';
+import FormImagePicker from '../../../components/FormImagePicker';
+import { SelectField } from '../../../components/SelectField';
+import { RichTextField } from '../../../components/RichTextField';
+import LayoutAuthenticated from '../../../layouts/Authenticated';
+import SectionMain from '../../../components/SectionMain';
+import SectionTitleLineWithButton from '../../../components/SectionTitleLineWithButton';
+import { getPageTitle } from '../../../config';
+import { create } from '../../../stores/products/productsSlice';
+import { useAppDispatch, useAppSelector } from '../../../stores/hooks';
+import { useRouter } from 'next/router';
+
+export default function ArtisanProductNewPage() {
+ const router = useRouter();
+ const dispatch = useAppDispatch();
+ const { currentUser } = useAppSelector((state) => state.auth);
+
+ const initialValues = useMemo(
+ () => ({
+ artisan: currentUser?.id || '',
+ category: '',
+ name: '',
+ description: '',
+ price_eur: '',
+ photos: [],
+ status: 'online',
+ stock_qty: '',
+ }),
+ [currentUser?.id],
+ );
+
+ return (
+ <>
+
+ {getPageTitle('Nouveau produit')}
+
+
+
+ {''}
+
+
+
+ {
+ const errors: Record = {};
+
+ if (!values.name.trim()) {
+ errors.name = 'Champ requis';
+ }
+ if (!values.description.trim()) {
+ errors.description = 'Champ requis';
+ }
+ if (!values.category) {
+ errors.category = 'Champ requis';
+ }
+ if (!values.price_eur) {
+ errors.price_eur = 'Champ requis';
+ }
+
+ return errors;
+ }}
+ onSubmit={async (values, helpers) => {
+ try {
+ await dispatch(
+ create({
+ ...values,
+ artisan: currentUser?.id,
+ }),
+ ).unwrap();
+ router.push('/dashboard?tab=products&saved=product');
+ } catch (error) {
+ console.error('Failed to create artisan product', error);
+ helpers.setStatus('Impossible de créer ce produit.');
+ } finally {
+ helpers.setSubmitting(false);
+ }
+ }}
+ >
+ {({ errors, touched, isSubmitting, status }) => (
+
+ )}
+
+
+
+ >
+ );
+}
+
+ArtisanProductNewPage.getLayout = function getLayout(page: ReactElement) {
+ return {page} ;
+};
diff --git a/frontend/src/pages/artisan/profile.tsx b/frontend/src/pages/artisan/profile.tsx
new file mode 100644
index 0000000..04ccf34
--- /dev/null
+++ b/frontend/src/pages/artisan/profile.tsx
@@ -0,0 +1,201 @@
+import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js';
+import Head from 'next/head';
+import React, { ReactElement, useEffect, useState } from 'react';
+import { Field, Form, Formik } from 'formik';
+import axios from 'axios';
+import CardBox from '../../components/CardBox';
+import FormField from '../../components/FormField';
+import BaseButton from '../../components/BaseButton';
+import BaseButtons from '../../components/BaseButtons';
+import BaseDivider from '../../components/BaseDivider';
+import FormImagePicker from '../../components/FormImagePicker';
+import LayoutAuthenticated from '../../layouts/Authenticated';
+import SectionMain from '../../components/SectionMain';
+import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
+import { RichTextField } from '../../components/RichTextField';
+import { getPageTitle } from '../../config';
+import { useAppSelector } from '../../stores/hooks';
+import { useRouter } from 'next/router';
+
+type ProfileValues = {
+ workshop_bio: string;
+ specialty: string;
+ city_region: string;
+ profile_photo: any[];
+ website_url: string;
+ social_links: string;
+};
+
+const defaultValues: ProfileValues = {
+ workshop_bio: '',
+ specialty: '',
+ city_region: '',
+ profile_photo: [],
+ website_url: '',
+ social_links: '',
+};
+
+export default function ArtisanProfilePage() {
+ const router = useRouter();
+ const { currentUser } = useAppSelector((state) => state.auth);
+ const [loading, setLoading] = useState(true);
+ const [profileId, setProfileId] = useState(null);
+ const [initialValues, setInitialValues] = useState(defaultValues);
+ const [loadError, setLoadError] = useState('');
+
+ useEffect(() => {
+ if (!currentUser?.id) {
+ return;
+ }
+
+ const loadProfile = async () => {
+ setLoading(true);
+ setLoadError('');
+
+ try {
+ const response = await axios.get(`/artisan_profiles?limit=1&page=0&user=${currentUser.id}`);
+ const profile = Array.isArray(response.data?.rows) ? response.data.rows[0] : null;
+
+ if (profile) {
+ setProfileId(profile.id);
+ setInitialValues({
+ workshop_bio: profile.workshop_bio || '',
+ specialty: profile.specialty || '',
+ city_region: profile.city_region || '',
+ profile_photo: profile.profile_photo || [],
+ website_url: profile.website_url || '',
+ social_links: profile.social_links || '',
+ });
+ }
+ } catch (error) {
+ console.error('Failed to load artisan profile', error);
+ setLoadError('Impossible de charger votre profil public.');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ loadProfile();
+ }, [currentUser?.id]);
+
+ return (
+ <>
+
+ {getPageTitle('Profil artisan')}
+
+
+
+ {''}
+
+
+
+ {loading ? (
+ Chargement du profil atelier...
+ ) : (
+ {
+ const errors: Record = {};
+
+ if (!values.specialty.trim()) {
+ errors.specialty = 'Champ requis';
+ }
+ if (!values.city_region.trim()) {
+ errors.city_region = 'Champ requis';
+ }
+ if (!values.workshop_bio.trim()) {
+ errors.workshop_bio = 'Champ requis';
+ }
+
+ return errors;
+ }}
+ onSubmit={async (values, helpers) => {
+ try {
+ const payload = {
+ ...values,
+ user: currentUser?.id,
+ };
+
+ if (profileId) {
+ await axios.put(`/artisan_profiles/${profileId}`, { id: profileId, data: payload });
+ } else {
+ await axios.post('/artisan_profiles', { data: payload });
+ }
+
+ router.push('/dashboard?tab=profile&saved=profile');
+ } catch (error) {
+ console.error('Failed to save artisan profile', error);
+ helpers.setStatus('Impossible de sauvegarder votre profil public.');
+ } finally {
+ helpers.setSubmitting(false);
+ }
+ }}
+ >
+ {({ errors, touched, isSubmitting, status }) => (
+
+ )}
+
+ )}
+
+
+ >
+ );
+}
+
+ArtisanProfilePage.getLayout = function getLayout(page: ReactElement) {
+ return {page} ;
+};
diff --git a/frontend/src/pages/contact.tsx b/frontend/src/pages/contact.tsx
new file mode 100644
index 0000000..4057f20
--- /dev/null
+++ b/frontend/src/pages/contact.tsx
@@ -0,0 +1,239 @@
+import Head from 'next/head';
+import React, { ReactElement, useMemo, useState } from 'react';
+import { Field, Form, Formik } from 'formik';
+import axios from 'axios';
+import { useRouter } from 'next/router';
+import CardBox from '../components/CardBox';
+import FormField from '../components/FormField';
+import BaseButton from '../components/BaseButton';
+import LayoutGuest from '../layouts/Guest';
+import PublicSiteHeader from '../components/PublicSiteHeader';
+import PublicSiteFooter from '../components/PublicSiteFooter';
+import { getPageTitle } from '../config';
+
+const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+
+function getSingleQueryValue(value: string | string[] | undefined) {
+ if (Array.isArray(value)) {
+ return value[0] || '';
+ }
+
+ return value || '';
+}
+
+export default function ContactPage() {
+ const router = useRouter();
+ const [submitError, setSubmitError] = useState('');
+ const [isSuccess, setIsSuccess] = useState(false);
+
+ const initialValues = useMemo(
+ () => ({
+ first_name: '',
+ last_name: '',
+ email: '',
+ phone: '',
+ city_region: '',
+ subject: getSingleQueryValue(router.query.subject),
+ message: getSingleQueryValue(router.query.message),
+ }),
+ [router.query.message, router.query.subject],
+ );
+
+ const validate = (values: typeof initialValues) => {
+ const errors: Partial> = {};
+
+ if (!values.first_name.trim()) {
+ errors.first_name = 'Champ requis';
+ }
+
+ if (!values.last_name.trim()) {
+ errors.last_name = 'Champ requis';
+ }
+
+ if (!values.email.trim()) {
+ errors.email = 'Champ requis';
+ } else if (!emailRegex.test(values.email.trim())) {
+ errors.email = 'Email invalide';
+ }
+
+ if (!values.city_region.trim()) {
+ errors.city_region = 'Champ requis';
+ }
+
+ if (!values.message.trim()) {
+ errors.message = 'Champ requis';
+ }
+
+ return errors;
+ };
+
+ return (
+ <>
+
+ {getPageTitle('Contact')}
+
+
+
+
+
+
+
+
+
+
Formulaire partagé
+
+ Une seule porte d’entrée pour les demandes générales, les services artisans et les intentions de commande.
+
+
+ Le champ Sujet se remplit automatiquement lorsqu’un visiteur arrive depuis le marketplace ou depuis les cartes de services.
+
+
+
+
+
+
+
+
+
+
À quoi sert ce formulaire ?
+
Centraliser les demandes sans perdre le contexte.
+
+
+ • Les artisans peuvent demander un accompagnement marketing ou formation.
+ • Les visiteurs peuvent exprimer une intention de commande depuis le marketplace.
+ • Les demandes générales restent accessibles depuis le footer public.
+
+ {initialValues.subject && (
+
+
Sujet pré-rempli
+
{initialValues.subject}
+
+ )}
+
+
+
+
+ {
+ setSubmitError('');
+
+ try {
+ await axios.post('/contact-form', values);
+ setIsSuccess(true);
+ helpers.resetForm();
+ } catch (error: any) {
+ console.error('Failed to submit contact form', error);
+ setIsSuccess(false);
+ setSubmitError(error?.response?.data || 'Une erreur est survenue. Merci de réessayer.');
+ } finally {
+ helpers.setSubmitting(false);
+ }
+ }}
+ >
+ {({ errors, touched, isSubmitting }) => (
+
+ )}
+
+
+
+
+
+
+
+ >
+ );
+}
+
+ContactPage.getLayout = function getLayout(page: ReactElement) {
+ return {page} ;
+};
diff --git a/frontend/src/pages/dashboard.tsx b/frontend/src/pages/dashboard.tsx
index 3d5eae3..e78c57b 100644
--- a/frontend/src/pages/dashboard.tsx
+++ b/frontend/src/pages/dashboard.tsx
@@ -16,6 +16,8 @@ import { WidgetCreator } from '../components/WidgetCreator/WidgetCreator';
import { SmartWidget } from '../components/SmartWidget/SmartWidget';
import { useAppDispatch, useAppSelector } from '../stores/hooks';
+import ArtisanStudioSection from '../components/ArtisanStudioSection';
+
const Dashboard = () => {
const dispatch = useAppDispatch();
const iconsColor = useAppSelector((state) => state.style.iconsColor);
@@ -104,6 +106,8 @@ const Dashboard = () => {
main>
{''}
+
+
{hasPermission(currentUser, 'CREATE_ROLES') && state.style.linkColor);
-
- const title = 'Artisan Marketplace'
-
- // Fetch Pexels image/video
- useEffect(() => {
- async function fetchData() {
- const image = await getPexelsImage();
- const video = await getPexelsVideo();
- setIllustrationImage(image);
- setIllustrationVideo(video);
- }
- fetchData();
- }, []);
-
- const imageBlock = (image) => (
-
- );
-
- const videoBlock = (video) => {
- if (video?.video_files?.length > 0) {
- return (
-
-
-
- Your browser does not support the video tag.
-
-
-
)
- }
- };
+const featured = featuredProducts.slice(0, 3);
+export default function HomePage() {
return (
-
+ <>
-
{getPageTitle('Starter Page')}
+
{getPageTitle('Artisan Marketplace')}
-
-
- {contentType === 'image' && contentPosition !== 'background'
- ? imageBlock(illustrationImage)
- : null}
- {contentType === 'video' && contentPosition !== 'background'
- ? videoBlock(illustrationVideo)
- : null}
-
-
-
-
-
-
This is a React.js/Node.js app generated by the Flatlogic Web App Generator
-
For guides and documentation please check
- your local README.md and the Flatlogic documentation
+
+
+
+
+
+
+
+
+
+ Artisan Marketplace
+
+
+ La place de marché élégante pour acheter, vendre et faire rayonner l’artisanat.
+
+
+ Une expérience claire pour les acheteurs curieux, et un espace dédié pour les artisans qui veulent publier leurs produits, raconter leur atelier et demander un accompagnement.
+
+
+
+
+ Acheter
+
+
+
+ Devenir un artisan
+
+
+
+
+
+
+
2
+
profils utilisateurs distincts : acheteurs & artisans
+
+
+
1
+
formulaire partagé pour les demandes, services et intentions de commande
+
+
+
FR
+
première langue et ton moderne, accessible, craft-focused
+
+
+
+
+
+ {featured.map((product) => (
+
+
+
+
+
+
+
+ {product.category}
+
+ {product.city}
+
+
+
{product.name}
+
par {product.artisan}
+
+
+
{product.price} €
+
+ Voir le catalogue
+
+
+
+
+ ))}
+
-
-
-
+
-
-
-
-
-
-
-
© 2026 {title} . All rights reserved
-
- Privacy Policy
-
-
+
+
+
Première valeur livrée
+
Une vitrine premium avec un vrai parcours dès la première itération.
+
-
+
+ {highlights.map((item) => (
+
+
+
+
+
{item.title}
+
{item.description}
+
+ ))}
+
+
+
+
+
+
+
+
Accompagnement artisans
+
Deux offres claires pour aider les créateurs à vendre plus sereinement.
+
+
+ Nous contacter →
+
+
+
+
+ {serviceCards.map((service) => (
+
+ {service.title}
+ {service.description}
+
+ Nous contacter
+
+
+
+ ))}
+
+
+
+
+
+
+
+ >
);
}
-Starter.getLayout = function getLayout(page: ReactElement) {
+HomePage.getLayout = function getLayout(page: ReactElement) {
return {page} ;
};
-
diff --git a/frontend/src/pages/marketplace.tsx b/frontend/src/pages/marketplace.tsx
new file mode 100644
index 0000000..63d3628
--- /dev/null
+++ b/frontend/src/pages/marketplace.tsx
@@ -0,0 +1,413 @@
+/* eslint-disable @next/next/no-img-element */
+import Head from 'next/head';
+import Link from 'next/link';
+import React, { ReactElement, useEffect, useMemo, useState } from 'react';
+import {
+ mdiCash,
+ mdiCreditCardOutline,
+ mdiPackageVariantClosed,
+ mdiTagMultiple,
+} from '@mdi/js';
+import BaseIcon from '../components/BaseIcon';
+import LayoutGuest from '../layouts/Guest';
+import PublicSiteHeader from '../components/PublicSiteHeader';
+import PublicSiteFooter from '../components/PublicSiteFooter';
+import { getPageTitle } from '../config';
+import { FeaturedProduct, featuredProducts, marketplaceCategories } from '../data/artisanMarketplace';
+
+const CART_STORAGE_KEY = 'artisan-marketplace-cart';
+
+type CartState = Record;
+type PaymentMethod = 'cash_on_delivery' | 'stripe';
+
+function formatPrice(value: number) {
+ return new Intl.NumberFormat('fr-FR', {
+ style: 'currency',
+ currency: 'EUR',
+ maximumFractionDigits: 0,
+ }).format(value);
+}
+
+export default function MarketplacePage() {
+ const [searchTerm, setSearchTerm] = useState('');
+ const [selectedCategory, setSelectedCategory] = useState('Toutes');
+ const [selectedProduct, setSelectedProduct] = useState(featuredProducts[0] || null);
+ const [cart, setCart] = useState({});
+ const [paymentMethod, setPaymentMethod] = useState('cash_on_delivery');
+
+ useEffect(() => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+
+ const storedCart = window.localStorage.getItem(CART_STORAGE_KEY);
+
+ if (storedCart) {
+ try {
+ setCart(JSON.parse(storedCart));
+ } catch (error) {
+ console.error('Failed to parse marketplace cart from localStorage', error);
+ }
+ }
+ }, []);
+
+ useEffect(() => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+
+ window.localStorage.setItem(CART_STORAGE_KEY, JSON.stringify(cart));
+ }, [cart]);
+
+ const filteredProducts = useMemo(() => {
+ const normalizedSearch = searchTerm.trim().toLowerCase();
+
+ return featuredProducts.filter((product) => {
+ const matchesCategory = selectedCategory === 'Toutes' || product.category === selectedCategory;
+ const matchesSearch =
+ normalizedSearch.length === 0 ||
+ [product.name, product.artisan, product.category, product.city, product.description]
+ .join(' ')
+ .toLowerCase()
+ .includes(normalizedSearch);
+
+ return matchesCategory && matchesSearch;
+ });
+ }, [searchTerm, selectedCategory]);
+
+ useEffect(() => {
+ if (!selectedProduct || !filteredProducts.some((product) => product.id === selectedProduct.id)) {
+ setSelectedProduct(filteredProducts[0] || null);
+ }
+ }, [filteredProducts, selectedProduct]);
+
+ const cartItems = useMemo(
+ () =>
+ Object.entries(cart)
+ .map(([productId, quantity]) => {
+ const product = featuredProducts.find((item) => item.id === productId);
+
+ if (!product || quantity <= 0) {
+ return null;
+ }
+
+ return {
+ ...product,
+ quantity,
+ lineTotal: product.price * quantity,
+ };
+ })
+ .filter(Boolean) as Array,
+ [cart],
+ );
+
+ const cartCount = cartItems.reduce((total, item) => total + item.quantity, 0);
+ const subtotal = cartItems.reduce((total, item) => total + item.lineTotal, 0);
+ const paymentLabel = paymentMethod === 'stripe' ? 'Paiement Stripe' : 'Paiement à la livraison';
+ const checkoutSubject = `Commande marketplace — ${paymentLabel}`;
+ const checkoutMessage =
+ cartItems.length > 0
+ ? `Bonjour, je souhaite continuer ma commande via Artisan Marketplace.\n\nMode de paiement souhaité : ${paymentLabel}.\n\nArticles :\n${cartItems
+ .map((item) => `- ${item.name} x${item.quantity} (${formatPrice(item.lineTotal)})`)
+ .join('\n')}\n\nSous-total estimé : ${formatPrice(subtotal)}.`
+ : 'Bonjour, je souhaite être recontacté pour finaliser une commande sur Artisan Marketplace.';
+
+ const updateQuantity = (productId: string, nextQuantity: number) => {
+ setCart((currentCart) => {
+ const nextCart = { ...currentCart };
+
+ if (nextQuantity <= 0) {
+ delete nextCart[productId];
+ } else {
+ nextCart[productId] = nextQuantity;
+ }
+
+ return nextCart;
+ });
+ };
+
+ const addToCart = (product: FeaturedProduct) => {
+ setSelectedProduct(product);
+ setCart((currentCart) => ({
+ ...currentCart,
+ [product.id]: (currentCart[product.id] || 0) + 1,
+ }));
+ };
+
+ return (
+ <>
+
+ {getPageTitle('Marketplace')}
+
+
+
+
+
+
+
+
+
+
+
Marketplace public
+
+ Explorez des pièces fabriquées à la main, filtrez par univers et préparez votre commande.
+
+
+ Cette première version met en avant la découverte produit, un panier d’intention et la capture du mode de paiement souhaité avant la finalisation complète de la commande.
+
+
+
+
+
Panier en cours
+
+
+
+
+
+
{cartCount}
+
article{cartCount > 1 ? 's' : ''}
+
+
+
+
+
+
+
+ Rechercher un produit
+ setSearchTerm(event.target.value)}
+ placeholder="Rechercher un produit, une catégorie ou un artisan"
+ className="w-full border-0 bg-transparent text-sm text-white placeholder:text-white/40 focus:outline-none"
+ />
+
+
+ {marketplaceCategories.map((category) => (
+ setSelectedCategory(category)}
+ className={`rounded-full border px-4 py-2 text-sm font-medium transition ${
+ selectedCategory === category
+ ? 'border-[#D4A94E] bg-[#D4A94E] text-[#111111]'
+ : 'border-white/10 bg-white/5 text-white/70 hover:border-white/30 hover:text-white'
+ }`}
+ >
+ {category}
+
+ ))}
+
+
+
+
+
+
+
+
+
+ {filteredProducts.length} résultat{filteredProducts.length > 1 ? 's' : ''}
+
+
+ {filteredProducts.length === 0 ? (
+
+
Aucun produit pour ce filtre
+
+ Essayez une autre catégorie ou une recherche plus large pour retrouver l’inspiration artisanale.
+
+
+ ) : (
+
+ {filteredProducts.map((product) => (
+
+ setSelectedProduct(product)}>
+
+
+
+
+
+
+
+
+ {product.category}
+
+
{product.name}
+
{product.artisan} · {product.city}
+
+
{formatPrice(product.price)}
+
+
{product.description}
+
+ {product.highlight}
+ addToCart(product)}
+ className="rounded-full bg-[#D4A94E] px-4 py-2 text-sm font-semibold text-[#111111] transition hover:bg-[#E4BD6B]"
+ >
+ Ajouter au panier
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
+ {selectedProduct ? (
+ <>
+
+
+
+
+
+
Fiche produit
+
{selectedProduct.name}
+
{selectedProduct.artisan} · {selectedProduct.city}
+
+
{formatPrice(selectedProduct.price)}
+
+
{selectedProduct.description}
+
+
Pourquoi on l’aime
+
{selectedProduct.highlight}
+
+
+ addToCart(selectedProduct)}
+ className="rounded-full bg-[#D4A94E] px-5 py-3 text-sm font-semibold text-[#111111] transition hover:bg-[#E4BD6B]"
+ >
+ Ajouter au panier
+
+
+ Poser une question
+
+
+ >
+ ) : (
+
+ Sélectionnez un produit pour voir ses détails.
+
+ )}
+
+
+
+
+
+
Panier
+
{cartCount} article{cartCount > 1 ? 's' : ''}
+
+
{formatPrice(subtotal)}
+
+
+ {cartItems.length === 0 ? (
+
+ Ajoutez quelques pièces au panier pour préparer votre demande de commande.
+
+ ) : (
+
+ {cartItems.map((item) => (
+
+
+
+
{item.name}
+
{item.artisan}
+
+
{formatPrice(item.lineTotal)}
+
+
+ updateQuantity(item.id, item.quantity - 1)}
+ className="flex h-9 w-9 items-center justify-center rounded-full border border-white/15 text-white transition hover:border-white/30"
+ >
+ −
+
+ {item.quantity}
+ updateQuantity(item.id, item.quantity + 1)}
+ className="flex h-9 w-9 items-center justify-center rounded-full border border-white/15 text-white transition hover:border-white/30"
+ >
+ +
+
+
+
+ ))}
+
+ )}
+
+
+
Mode de paiement souhaité
+
+
setPaymentMethod('cash_on_delivery')}
+ className={`rounded-2xl border p-4 text-left transition ${
+ paymentMethod === 'cash_on_delivery'
+ ? 'border-[#D4A94E] bg-[#D4A94E]/10 text-white'
+ : 'border-white/10 bg-white/5 text-white/70 hover:border-white/25'
+ }`}
+ >
+
+ Paiement à la livraison
+ Capture le besoin et confirme la suite avec l’équipe.
+
+
setPaymentMethod('stripe')}
+ className={`rounded-2xl border p-4 text-left transition ${
+ paymentMethod === 'stripe'
+ ? 'border-[#D4A94E] bg-[#D4A94E]/10 text-white'
+ : 'border-white/10 bg-white/5 text-white/70 hover:border-white/25'
+ }`}
+ >
+
+ Stripe
+ Prépare le contexte de paiement pour le prochain échange.
+
+
+
+
+
0
+ ? 'bg-[#D4A94E] text-[#111111] hover:bg-[#E4BD6B]'
+ : 'cursor-not-allowed border border-white/10 text-white/30'
+ }`}
+ aria-disabled={cartItems.length === 0}
+ onClick={(event) => {
+ if (cartItems.length === 0) {
+ event.preventDefault();
+ }
+ }}
+ >
+ Continuer vers la demande de commande
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+MarketplacePage.getLayout = function getLayout(page: ReactElement) {
+ return {page} ;
+};