From be4dab6f0144fabdd1863bb69c1c8bc1a88f007a Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Fri, 1 May 2026 10:26:35 +0000 Subject: [PATCH] V1 --- backend/src/db/api/artisan_profiles.js | 31 +- backend/src/db/api/users.js | 9 +- ...000-add-city-region-to-artisan-profiles.js | 14 + backend/src/db/models/artisan_profiles.js | 12 +- backend/src/index.js | 4 +- backend/src/routes/auth.js | 7 +- backend/src/routes/contactForm.js | 92 ++++ backend/src/services/auth.js | 85 +++- .../src/components/ArtisanStudioSection.tsx | 415 ++++++++++++++++++ frontend/src/components/NavBarItem.tsx | 3 +- frontend/src/components/PublicSiteFooter.tsx | 31 ++ frontend/src/components/PublicSiteHeader.tsx | 66 +++ frontend/src/data/artisanMarketplace.ts | 128 ++++++ frontend/src/layouts/Authenticated.tsx | 3 +- frontend/src/pages/artisan-auth.tsx | 288 ++++++++++++ frontend/src/pages/artisan/account.tsx | 141 ++++++ frontend/src/pages/artisan/products/edit.tsx | 214 +++++++++ frontend/src/pages/artisan/products/new.tsx | 157 +++++++ frontend/src/pages/artisan/profile.tsx | 201 +++++++++ frontend/src/pages/contact.tsx | 239 ++++++++++ frontend/src/pages/dashboard.tsx | 4 + frontend/src/pages/index.tsx | 322 +++++++------- frontend/src/pages/marketplace.tsx | 413 +++++++++++++++++ 23 files changed, 2682 insertions(+), 197 deletions(-) create mode 100644 backend/src/db/migrations/20260501010000-add-city-region-to-artisan-profiles.js create mode 100644 frontend/src/components/ArtisanStudioSection.tsx create mode 100644 frontend/src/components/PublicSiteFooter.tsx create mode 100644 frontend/src/components/PublicSiteHeader.tsx create mode 100644 frontend/src/data/artisanMarketplace.ts create mode 100644 frontend/src/pages/artisan-auth.tsx create mode 100644 frontend/src/pages/artisan/account.tsx create mode 100644 frontend/src/pages/artisan/products/edit.tsx create mode 100644 frontend/src/pages/artisan/products/new.tsx create mode 100644 frontend/src/pages/artisan/profile.tsx create mode 100644 frontend/src/pages/contact.tsx create mode 100644 frontend/src/pages/marketplace.tsx 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) => ( + + ))} +
+
+ + {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} + ) : ( +
+ +
+ )} +
+ +
+
+

{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 ( +
+
+
+

Artisan Marketplace

+

+ Une vitrine moderne pour les créateurs et un point d’entrée simple pour les acheteurs. +

+
+ +
+ + Marketplace + + + Contact + + + Espace admin + + + Privacy Policy + +
+
+
+ ); +} 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 ( +
+
+ + + + + + + Artisan Marketplace + + + Marketplace craft-focused & espace artisan + + + + + + +
+ + Espace admin + + + Devenir un artisan + +
+
+
+ ); +} 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) => ( + + ))} +
+
+ +
+ {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 }) => ( +
+ {loginError && ( +
+ {loginError} +
+ )} + + + + + {touched.email && errors.email &&

{errors.email}

} + + + + + {touched.password && errors.password &&

{errors.password}

} + +
+ + Mot de passe oublié ? + +
+ + + + )} +
+ ) : ( + { + 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 }) => ( +
+ {signupError && ( +
+ {signupError} +
+ )} + +
+
+ + + + {touched.firstName && errors.firstName && ( +

{errors.firstName}

+ )} +
+
+ + + + {touched.lastName && errors.lastName && ( +

{errors.lastName}

+ )} +
+
+ + + + + {touched.email && errors.email &&

{errors.email}

} + + + + + {touched.password && errors.password &&

{errors.password}

} + + + + + {touched.city_region && errors.city_region && ( +

{errors.city_region}

+ )} + + + + )} +
+ )} +
+
+
+ + +
+ + ); +} + +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 }) => ( +
+
+

Compte

+

Coordonnées du créateur

+

Ces informations alimentent votre présence dans le dashboard et facilitent le contact.

+
+ + {status &&

{status}

} + + + + + +
+
+ + + + {touched.firstName && errors.firstName &&

{errors.firstName}

} +
+
+ + + + {touched.lastName && errors.lastName &&

{errors.lastName}

} +
+
+ + + + + +
+ Email : {currentUser?.email} +
+ + + + + + + + )} +
+
+
+ + ); +} + +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 }) => ( +
+ {status &&

{status}

} + + + + + {touched.category && errors.category &&

{errors.category}

} + + + + + {touched.name && errors.name &&

{errors.name}

} + + + + + {touched.description && errors.description &&

{errors.description}

} + +
+
+ + + + {touched.price_eur && errors.price_eur &&

{errors.price_eur}

} +
+ + + +
+ + + + + + + + + + + + + + + + + + + + )} +
+ )} +
+
+ + ); +} + +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 }) => ( +
+ {status &&

{status}

} + + + + + {touched.category && errors.category &&

{errors.category}

} + + + + + {touched.name && errors.name &&

{errors.name}

} + + + + + {touched.description && errors.description &&

{errors.description}

} + +
+
+ + + + {touched.price_eur && errors.price_eur &&

{errors.price_eur}

} +
+ + + +
+ + + + + + + + + + + + + + + + + + + + )} +
+
+
+ + ); +} + +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 }) => ( +
+ {loadError &&

{loadError}

} + {status &&

{status}

} + + + + + +
+
+ + + + {touched.specialty && errors.specialty &&

{errors.specialty}

} +
+
+ + + + {touched.city_region && errors.city_region &&

{errors.city_region}

} +
+
+ + + + + {touched.workshop_bio && errors.workshop_bio && ( +

{errors.workshop_bio}

+ )} + +
+ + + + + + +
+ + + + + + + + )} +
+ )} +
+
+ + ); +} + +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 }) => ( +
+
+
+

Formulaire

+

Parlons de votre besoin

+
+
+ + {isSuccess && ( +
+ Merci, votre message a bien été envoyé. Notre équipe reviendra vers vous rapidement. +
+ )} + + {submitError && ( +
+ {submitError} +
+ )} + +
+
+ + + + {touched.first_name && errors.first_name && ( +

{errors.first_name}

+ )} +
+
+ + + + {touched.last_name && errors.last_name && ( +

{errors.last_name}

+ )} +
+
+ +
+
+ + + + {touched.email && errors.email && ( +

{errors.email}

+ )} +
+
+ + + +
+
+ +
+
+ + + + {touched.city_region && errors.city_region && ( +

{errors.city_region}

+ )} +
+
+ + + +
+
+ + + + + {touched.message && errors.message && ( +

{errors.message}

+ )} + +
+ + +
+
+ )} +
+
+
+
+ + +
+ + ); +} + +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 ( -
- - -
) - } - }; +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.name} +
+
+
+ + {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' : ''}

+
+
+
+
+ +
+ +
+ {marketplaceCategories.map((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) => ( +
+ +
+
+
+ + {product.category} + +

{product.name}

+

{product.artisan} · {product.city}

+
+ {formatPrice(product.price)} +
+

{product.description}

+
+ {product.highlight} + +
+
+
+ ))} +
+ )} +
+ +
+
+ {selectedProduct ? ( + <> +
+ {selectedProduct.name} +
+
+
+

Fiche produit

+

{selectedProduct.name}

+

{selectedProduct.artisan} · {selectedProduct.city}

+
+

{formatPrice(selectedProduct.price)}

+
+

{selectedProduct.description}

+
+

Pourquoi on l’aime

+

{selectedProduct.highlight}

+
+
+ + + 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)}

+
+
+ + {item.quantity} + +
+
+ ))} +
+ )} + +
+

Mode de paiement souhaité

+
+ + +
+
+ + 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}; +};