Compare commits

...

1 Commits

Author SHA1 Message Date
Flatlogic Bot
be4dab6f01 V1 2026-05-01 10:26:35 +00:00
23 changed files with 2682 additions and 197 deletions

View File

@ -1,7 +1,6 @@
const db = require('../models'); const db = require('../models');
const FileDBApi = require('./file'); const FileDBApi = require('./file');
const crypto = require('crypto');
const Utils = require('../utils'); const Utils = require('../utils');
@ -31,6 +30,11 @@ module.exports = class Artisan_profilesDBApi {
null null
, ,
city_region: data.city_region
||
null
,
website_url: data.website_url website_url: data.website_url
|| ||
null null
@ -88,6 +92,11 @@ module.exports = class Artisan_profilesDBApi {
specialty: item.specialty specialty: item.specialty
|| ||
null null
,
city_region: item.city_region
||
null
, ,
website_url: item.website_url website_url: item.website_url
@ -145,6 +154,9 @@ module.exports = class Artisan_profilesDBApi {
if (data.specialty !== undefined) updatePayload.specialty = data.specialty; 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; if (data.website_url !== undefined) updatePayload.website_url = data.website_url;
@ -292,10 +304,6 @@ module.exports = class Artisan_profilesDBApi {
offset = currentPage * limit; offset = currentPage * limit;
const orderBy = null;
const transaction = (options && options.transaction) || undefined;
let include = [ let include = [
{ {
@ -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) { if (filter.website_url) {
where = { where = {
...where, ...where,

View File

@ -479,10 +479,6 @@ module.exports = class UsersDBApi {
offset = currentPage * limit; offset = currentPage * limit;
const orderBy = null;
const transaction = (options && options.transaction) || undefined;
let include = [ let include = [
{ {
@ -815,6 +811,7 @@ module.exports = class UsersDBApi {
{ {
email: data.email, email: data.email,
firstName: data.firstName, firstName: data.firstName,
lastName: data.lastName || null,
authenticationUid: data.authenticationUid, authenticationUid: data.authenticationUid,
password: data.password, password: data.password,
@ -823,7 +820,7 @@ module.exports = class UsersDBApi {
); );
const app_role = await db.roles.findOne({ 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) { if (app_role?.id) {
await users.setApp_role(app_role?.id || null, { await users.setApp_role(app_role?.id || null, {

View File

@ -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');
},
};

View File

@ -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) { module.exports = function(sequelize, DataTypes) {
const artisan_profiles = sequelize.define( const artisan_profiles = sequelize.define(
@ -26,6 +21,13 @@ specialty: {
},
city_region: {
type: DataTypes.TEXT,
}, },
website_url: { website_url: {

View File

@ -6,7 +6,6 @@ const passport = require('passport');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const bodyParser = require('body-parser'); const bodyParser = require('body-parser');
const db = require('./db/models');
const config = require('./config'); const config = require('./config');
const swaggerUI = require('swagger-ui-express'); const swaggerUI = require('swagger-ui-express');
const swaggerJsDoc = require('swagger-jsdoc'); const swaggerJsDoc = require('swagger-jsdoc');
@ -18,7 +17,7 @@ const sqlRoutes = require('./routes/sql');
const pexelsRoutes = require('./routes/pexels'); const pexelsRoutes = require('./routes/pexels');
const openaiRoutes = require('./routes/openai'); const openaiRoutes = require('./routes/openai');
const contactFormRoutes = require('./routes/contactForm');
const usersRoutes = require('./routes/users'); const usersRoutes = require('./routes/users');
@ -104,6 +103,7 @@ app.use(bodyParser.json());
app.use('/api/auth', authRoutes); app.use('/api/auth', authRoutes);
app.use('/api/file', fileRoutes); app.use('/api/file', fileRoutes);
app.use('/api/pexels', pexelsRoutes); app.use('/api/pexels', pexelsRoutes);
app.use('/api/contact-form', contactFormRoutes);
app.enable('trust proxy'); app.enable('trust proxy');

View File

@ -140,11 +140,10 @@ router.post('/send-password-reset-email', wrapAsync(async (req, res) => {
*/ */
router.post('/signup', 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( const payload = await AuthService.signup(
req.body.email, req.body,
req.body.password,
req, req,
link.host, link.host,
) )

View File

@ -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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
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 () => `
<div style="font-family: Arial, sans-serif; line-height: 1.6; color: #111827;">
<h2 style="margin-bottom: 16px;">Nouveau message reçu</h2>
<p><strong>Prénom :</strong> ${escapeHtml(first_name)}</p>
<p><strong>Nom :</strong> ${escapeHtml(last_name)}</p>
<p><strong>Email :</strong> ${escapeHtml(email)}</p>
<p><strong>Téléphone :</strong> ${escapeHtml(phone || '')}</p>
<p><strong>Ville / Région :</strong> ${escapeHtml(city_region)}</p>
<p><strong>Sujet :</strong> ${escapeHtml(subject || '')}</p>
<p><strong>Message :</strong></p>
<p>${escapeHtml(message).replace(/\n/g, '<br />')}</p>
</div>
`,
}).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;

View File

@ -1,4 +1,6 @@
const db = require('../db/models');
const UsersDBApi = require('../db/api/users'); const UsersDBApi = require('../db/api/users');
const Artisan_profilesDBApi = require('../db/api/artisan_profiles');
const ValidationError = require('./notifications/errors/validation'); const ValidationError = require('./notifications/errors/validation');
const ForbiddenError = require('./notifications/errors/forbidden'); const ForbiddenError = require('./notifications/errors/forbidden');
const bcrypt = require('bcrypt'); const bcrypt = require('bcrypt');
@ -10,7 +12,18 @@ const config = require('../config');
const helpers = require('../helpers'); const helpers = require('../helpers');
class Auth { class Auth {
static async signup(email, password, options = {}, host) { 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 user = await UsersDBApi.findBy({ email });
const hashedPassword = await bcrypt.hash( const hashedPassword = await bcrypt.hash(
@ -54,16 +67,38 @@ class Auth {
return helpers.jwtSign(data); return helpers.jwtSign(data);
} }
const transaction = await db.sequelize.transaction();
try {
const newUser = await UsersDBApi.createFromAuth( const newUser = await UsersDBApi.createFromAuth(
{ {
firstName: email.split('@')[0], firstName,
lastName,
password: hashedPassword, password: hashedPassword,
email: email, email,
role,
},
{
...options,
transaction,
}, },
options,
); );
if (role === 'artisan_seller') {
await Artisan_profilesDBApi.create(
{
user: newUser.id,
city_region: cityRegion,
},
{
currentUser: { id: newUser.id },
transaction,
},
);
}
await transaction.commit();
if (EmailSender.isConfigured) { if (EmailSender.isConfigured) {
await this.sendEmailAddressVerificationEmail( await this.sendEmailAddressVerificationEmail(
newUser.email, newUser.email,
@ -79,9 +114,13 @@ class Auth {
}; };
return helpers.jwtSign(data); 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}); const user = await UsersDBApi.findBy({email});
if (!user) { if (!user) {

View File

@ -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<string, string> = {
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<StudioTab>('products');
const [loading, setLoading] = useState(false);
const [studioError, setStudioError] = useState('');
const [products, setProducts] = useState<StudioProduct[]>([]);
const [workshopProfile, setWorkshopProfile] = useState<StudioProfile | null>(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 (
<div className="mb-8 space-y-6">
<CardBox isList className="border border-[#D4A94E]/20 bg-gradient-to-br from-[#16110A] via-[#141414] to-[#101010] text-white shadow-[0_30px_90px_rgba(0,0,0,0.25)]">
<div className="flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-3xl">
<p className="text-sm font-semibold uppercase tracking-[0.3em] text-[#D4A94E]">Studio artisan</p>
<h2 className="mt-4 text-3xl font-semibold text-white">Un espace clair pour publier, raconter votre atelier et demander de laide.</h2>
<p className="mt-4 max-w-2xl text-sm leading-7 text-white/72">
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.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:min-w-[320px]">
<div className="rounded-[1.5rem] border border-white/10 bg-white/5 p-4">
<p className="text-sm text-white/60">Produits publiés</p>
<p className="mt-2 text-3xl font-semibold text-white">{products.length}</p>
<p className="mt-1 text-xs uppercase tracking-[0.2em] text-[#F6E7C1]">{onlineProducts} en ligne</p>
</div>
<div className="rounded-[1.5rem] border border-white/10 bg-white/5 p-4">
<p className="text-sm text-white/60">Profil atelier</p>
<p className="mt-2 text-3xl font-semibold text-white">{workshopProfile?.city_region ? 'Prêt' : 'À compléter'}</p>
<p className="mt-1 text-xs uppercase tracking-[0.2em] text-[#F6E7C1]">visibilité & confiance</p>
</div>
</div>
</div>
{savedMessage && (
<div className="mt-6 rounded-[1.5rem] border border-emerald-400/20 bg-emerald-400/10 p-4 text-sm text-emerald-100">
{savedMessage}
</div>
)}
{studioError && (
<div className="mt-6 rounded-[1.5rem] border border-red-400/20 bg-red-400/10 p-4 text-sm text-red-100">
{studioError}
</div>
)}
<div className="mt-6 flex flex-wrap gap-3">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => 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}
</button>
))}
</div>
</CardBox>
{activeTab === 'products' && (
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
<CardBox isList className="border border-white/10 bg-[#151515] text-white">
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm uppercase tracking-[0.2em] text-[#D4A94E]">Mes produits</p>
<h3 className="mt-2 text-2xl font-semibold text-white">Les produits créés depuis votre atelier</h3>
</div>
<BaseButton href="/artisan/products/new" color="info" label="Ajouter un produit" />
</div>
{loading ? (
<div className="rounded-[1.5rem] border border-dashed border-white/10 p-8 text-sm text-white/60">
Chargement de vos produits...
</div>
) : products.length === 0 ? (
<div className="rounded-[1.5rem] border border-dashed border-white/10 p-8 text-center">
<p className="text-xl font-semibold text-white">Aucun produit pour linstant</p>
<p className="mt-3 text-sm leading-7 text-white/65">
Créez votre première fiche produit pour commencer à structurer votre catalogue artisan.
</p>
</div>
) : (
<div className="grid gap-4">
{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 (
<div key={product.id} className="overflow-hidden rounded-[1.75rem] border border-white/10 bg-white/5">
<div className="grid gap-4 p-5 lg:grid-cols-[180px_1fr] lg:items-center">
<div className="overflow-hidden rounded-[1.25rem] bg-[#1B1B1B]">
{product.photos?.[0]?.publicUrl ? (
<img src={product.photos[0].publicUrl} alt={product.name} className="h-40 w-full object-cover" />
) : (
<div className="flex h-40 items-center justify-center text-white/30">
<BaseIcon path={mdiPackageVariantClosed} size={40} />
</div>
)}
</div>
<div>
<div className="flex flex-wrap items-center gap-3">
<h4 className="text-xl font-semibold text-white">{product.name}</h4>
<span className={`rounded-full border px-3 py-1 text-xs font-medium ${statusStyles}`}>
{statusLabel}
</span>
</div>
<p className="mt-3 text-sm leading-7 text-white/68">
{product.description || 'Ajoutez une description claire pour rassurer vos acheteurs.'}
</p>
<div className="mt-4 flex flex-wrap items-center gap-4 text-sm text-white/65">
<span>{formatPrice(product.price_eur)}</span>
<span>Stock : {product.stock_qty || 0}</span>
</div>
<div className="mt-5 flex flex-wrap gap-3">
<BaseButton href={`/artisan/products/edit?id=${product.id}`} color="info" label="Modifier" />
<BaseButton href={`/products/products-view?id=${product.id}`} outline color="info" label="Voir la fiche" />
</div>
</div>
</div>
</div>
);
})}
</div>
)}
</CardBox>
<CardBox isList className="border border-white/10 bg-[#151515] text-white">
<div className="space-y-5">
<div>
<p className="text-sm uppercase tracking-[0.2em] text-[#D4A94E]">Conseil pratique</p>
<h3 className="mt-2 text-2xl font-semibold text-white">Faites de chaque produit une mini vitrine.</h3>
</div>
<ul className="space-y-4 text-sm leading-7 text-white/68">
<li> Un nom simple + une matière claire = meilleure compréhension immédiate.</li>
<li> Les statuts vous aident à préparer un brouillon avant la mise en ligne.</li>
<li> Une photo soignée suffit pour démarrer cette première version.</li>
</ul>
</div>
</CardBox>
</div>
)}
{activeTab === 'profile' && (
<div className="grid gap-6 lg:grid-cols-2">
<CardBox isList className="border border-white/10 bg-[#151515] text-white">
<div className="flex flex-col gap-5 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-center gap-4">
<div className="h-20 w-20 overflow-hidden rounded-3xl border border-white/10 bg-[#101010]">
<ImageField name="Avatar" image={currentUser?.avatar} className="h-full w-full" imageClassName="h-full w-full rounded-none object-cover" />
</div>
<div>
<p className="text-sm uppercase tracking-[0.2em] text-[#D4A94E]">Compte</p>
<h3 className="mt-2 text-2xl font-semibold text-white">
{[currentUser?.firstName, currentUser?.lastName].filter(Boolean).join(' ') || currentUser?.email}
</h3>
<p className="mt-2 text-sm text-white/65">{currentUser?.email}</p>
</div>
</div>
<BaseButton href="/artisan/account" color="info" label="Modifier le compte" />
</div>
<div className="mt-6 grid gap-4 sm:grid-cols-2">
<div className="rounded-[1.25rem] border border-white/10 bg-white/5 p-4">
<p className="text-sm text-white/60">Rôle</p>
<p className="mt-2 font-semibold text-white">{currentUser?.app_role?.name || 'Utilisateur'}</p>
</div>
<div className="rounded-[1.25rem] border border-white/10 bg-white/5 p-4">
<p className="text-sm text-white/60">Téléphone</p>
<p className="mt-2 font-semibold text-white">{currentUser?.phoneNumber || 'À renseigner'}</p>
</div>
</div>
</CardBox>
<CardBox isList className="border border-white/10 bg-[#151515] text-white">
<div className="flex flex-col gap-5 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-center gap-4">
<div className="h-20 w-20 overflow-hidden rounded-3xl border border-white/10 bg-[#101010]">
<ImageField
name="Profil atelier"
image={workshopProfile?.profile_photo}
className="h-full w-full"
imageClassName="h-full w-full rounded-none object-cover"
/>
</div>
<div>
<p className="text-sm uppercase tracking-[0.2em] text-[#D4A94E]">Profil public</p>
<h3 className="mt-2 text-2xl font-semibold text-white">{workshopProfile?.specialty || 'Présentez votre spécialité'}</h3>
<p className="mt-2 text-sm text-white/65">{workshopProfile?.city_region || 'Ville / Région à compléter'}</p>
</div>
</div>
<BaseButton href={profileEditHref} color="info" label="Compléter le profil" />
</div>
<div className="mt-6 space-y-4 text-sm leading-7 text-white/68">
<p>{workshopProfile?.workshop_bio || 'Ajoutez une bio courte pour raconter votre atelier et rassurer les visiteurs.'}</p>
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-[1.25rem] border border-white/10 bg-white/5 p-4">
<p className="text-sm text-white/60">Site web</p>
<p className="mt-2 font-semibold text-white">{workshopProfile?.website_url || 'Optionnel'}</p>
</div>
<div className="rounded-[1.25rem] border border-white/10 bg-white/5 p-4">
<p className="text-sm text-white/60">Réseaux sociaux</p>
<p className="mt-2 font-semibold text-white">{workshopProfile?.social_links || 'Optionnel'}</p>
</div>
</div>
</div>
</CardBox>
</div>
)}
{activeTab === 'services' && (
<div className="grid gap-6 lg:grid-cols-2">
{serviceCards.map((service) => (
<CardBox key={service.id} isList className="border border-white/10 bg-[#151515] text-white">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm uppercase tracking-[0.2em] text-[#D4A94E]">Service</p>
<h3 className="mt-2 text-2xl font-semibold text-white">{service.title}</h3>
</div>
<span className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#D4A94E]/10 text-[#D4A94E]">
<BaseIcon path={mdiPaletteOutline} size={22} />
</span>
</div>
<p className="mt-5 text-sm leading-7 text-white/68">{service.description}</p>
<Link
href={`/contact?subject=${encodeURIComponent(service.subject)}`}
className="mt-8 inline-flex items-center gap-2 text-sm font-semibold text-[#F6E7C1] transition hover:text-white"
>
Nous contacter
<BaseIcon path={mdiArrowRight} size={18} />
</Link>
</CardBox>
))}
</div>
)}
{activeTab === 'contact' && (
<CardBox isList className="border border-white/10 bg-[#151515] text-white">
<div className="grid gap-6 lg:grid-cols-[0.8fr_1.2fr] lg:items-center">
<div>
<span className="flex h-14 w-14 items-center justify-center rounded-2xl bg-[#D4A94E]/10 text-[#D4A94E]">
<BaseIcon path={mdiEmailOutline} size={24} />
</span>
<p className="mt-5 text-sm uppercase tracking-[0.2em] text-[#D4A94E]">Contact direct</p>
<h3 className="mt-2 text-2xl font-semibold text-white">Un raccourci vers le formulaire partagé.</h3>
</div>
<div>
<p className="text-sm leading-7 text-white/68">
Utilisez ce point dentrée pour les demandes générales liées à votre activité artisanale, aux services ou à un besoin opérationnel ponctuel.
</p>
<div className="mt-6 flex flex-wrap gap-3">
<BaseButton href="/contact" color="info" label="Ouvrir le formulaire" />
<BaseButton
href={`/contact?subject=${encodeURIComponent('Demande générale artisan')}`}
outline
color="info"
label="Pré-remplir un sujet"
/>
</div>
</div>
</div>
</CardBox>
)}
</div>
);
}

View File

@ -1,6 +1,5 @@
import React, {useEffect, useRef} from 'react' import React, { useEffect, useRef, useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { useState } from 'react'
import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
import BaseDivider from './BaseDivider' import BaseDivider from './BaseDivider'
import BaseIcon from './BaseIcon' import BaseIcon from './BaseIcon'

View File

@ -0,0 +1,31 @@
import Link from 'next/link';
export default function PublicSiteFooter() {
return (
<footer className="border-t border-white/10 bg-[#111111] text-white/70">
<div className="mx-auto flex max-w-7xl flex-col gap-4 px-4 py-8 sm:px-6 lg:flex-row lg:items-center lg:justify-between lg:px-8">
<div>
<p className="text-sm font-semibold text-white">Artisan Marketplace</p>
<p className="text-sm text-white/60">
Une vitrine moderne pour les créateurs et un point dentrée simple pour les acheteurs.
</p>
</div>
<div className="flex flex-wrap items-center gap-4 text-sm">
<Link href="/marketplace" className="transition hover:text-white">
Marketplace
</Link>
<Link href="/contact" className="transition hover:text-white">
Contact
</Link>
<Link href="/login" className="transition hover:text-white">
Espace admin
</Link>
<Link href="/privacy-policy" className="transition hover:text-white">
Privacy Policy
</Link>
</div>
</div>
</footer>
);
}

View File

@ -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 (
<header className="sticky top-0 z-40 border-b border-white/10 bg-[#111111]/90 backdrop-blur-xl">
<div className="mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 py-4 sm:px-6 lg:px-8">
<Link href="/" className="flex items-center gap-3 text-white">
<span className="flex h-11 w-11 items-center justify-center rounded-2xl border border-[#D4A94E]/30 bg-[#D4A94E]/10 text-[#D4A94E]">
<BaseIcon path={mdiStorefrontOutline} size={22} />
</span>
<span>
<span className="block text-xs uppercase tracking-[0.35em] text-[#D4A94E]">
Artisan Marketplace
</span>
<span className="block text-sm text-white/70">
Marketplace craft-focused & espace artisan
</span>
</span>
</Link>
<nav className="hidden items-center gap-6 md:flex">
{links.map((link) => {
const isActive = router.pathname === link.href;
return (
<Link
key={link.href}
href={link.href}
className={`text-sm font-medium transition ${
isActive ? 'text-[#F6E7C1]' : 'text-white/70 hover:text-white'
}`}
>
{link.label}
</Link>
);
})}
</nav>
<div className="flex items-center gap-3">
<Link
href="/login"
className="hidden rounded-full border border-white/15 px-4 py-2 text-sm font-medium text-white transition hover:border-white/30 hover:bg-white/5 sm:inline-flex"
>
Espace admin
</Link>
<Link
href="/artisan-auth"
className="inline-flex rounded-full bg-[#D4A94E] px-4 py-2 text-sm font-semibold text-[#111111] transition hover:bg-[#E4BD6B]"
>
Devenir un artisan
</Link>
</div>
</div>
</header>
);
}

View File

@ -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 doreilles 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',
},
];

View File

@ -1,5 +1,4 @@
import React, { ReactNode, useEffect } from 'react' import React, { ReactNode, useEffect, useState } from 'react'
import { useState } from 'react'
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
import menuAside from '../menuAside' import menuAside from '../menuAside'

View File

@ -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<AuthTab>('login');
const [loginError, setLoginError] = useState('');
const [signupError, setSignupError] = useState('');
useEffect(() => {
if (typeof window !== 'undefined' && localStorage.getItem('token')) {
router.replace('/dashboard?tab=products');
}
}, [router]);
return (
<>
<Head>
<title>{getPageTitle('Espace artisan')}</title>
</Head>
<div className="min-h-screen bg-[#0C0C0C] text-white">
<PublicSiteHeader />
<main className="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:grid lg:grid-cols-[0.9fr_1.1fr] lg:gap-8 lg:px-8 lg:py-16">
<section className="mb-8 lg:mb-0">
<p className="text-sm font-semibold uppercase tracking-[0.3em] text-[#D4A94E]">Connexion & création</p>
<h1 className="mt-4 text-4xl font-semibold tracking-tight text-white sm:text-5xl">
Devenir un artisan et piloter son atelier depuis un espace dédié.
</h1>
<p className="mt-5 max-w-xl text-lg leading-8 text-white/68">
Le tableau de bord concentre la publication produit, la mise à jour du profil public, les services daccompagnement et le formulaire partagé.
</p>
<div className="mt-8 space-y-4 rounded-[2rem] border border-white/10 bg-white/5 p-6 backdrop-blur">
<div>
<p className="text-base font-semibold text-white">Après la connexion</p>
<p className="mt-2 text-sm leading-7 text-white/65">
Vous arrivez directement sur votre dashboard artisan pour gérer vos produits, votre profil et vos demandes daccompagnement.
</p>
</div>
<div>
<p className="text-base font-semibold text-white">Créer un compte artisan</p>
<p className="mt-2 text-sm leading-7 text-white/65">
Le compte crée automatiquement laccès vendeur, avec un profil atelier prêt à être complété dans le dashboard.
</p>
</div>
<Link href="/login" className="inline-flex text-sm font-medium text-[#F6E7C1] transition hover:text-white">
Aller vers la page de login admin
</Link>
</div>
</section>
<CardBox isList className="border border-white/10 bg-[#151515] text-white">
<div className="rounded-full border border-white/10 bg-[#101010] p-1">
<div className="grid grid-cols-2 gap-1">
{([
{ id: 'login', label: 'Se connecter' },
{ id: 'create', label: 'Créer mon compte' },
] as Array<{ id: AuthTab; label: string }>).map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => 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}
</button>
))}
</div>
</div>
<div className="mt-8">
{activeTab === 'login' ? (
<Formik
initialValues={{ email: '', password: '' }}
validate={(values) => {
const errors: Record<string, string> = {};
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 }) => (
<Form>
{loginError && (
<div className="mb-6 rounded-[1.5rem] border border-red-400/20 bg-red-400/10 p-4 text-sm text-red-100">
{loginError}
</div>
)}
<FormField label="Email">
<Field type="email" name="email" placeholder="artisan@atelier.fr" />
</FormField>
{touched.email && errors.email && <p className="-mt-4 mb-6 text-xs text-red-300">{errors.email}</p>}
<FormField label="Mot de passe">
<Field type="password" name="password" placeholder="Votre mot de passe" />
</FormField>
{touched.password && errors.password && <p className="-mt-4 mb-6 text-xs text-red-300">{errors.password}</p>}
<div className="mb-6 flex justify-end">
<Link href="/forgot" className="text-sm font-medium text-[#F6E7C1] transition hover:text-white">
Mot de passe oublié ?
</Link>
</div>
<BaseButton
type="submit"
color="info"
label={isSubmitting ? 'Connexion...' : 'Se connecter'}
className="w-full justify-center bg-[#D4A94E] text-[#111111] hover:bg-[#E4BD6B]"
/>
</Form>
)}
</Formik>
) : (
<Formik
initialValues={{
firstName: '',
lastName: '',
email: '',
password: '',
city_region: '',
}}
validate={(values) => {
const errors: Record<string, string> = {};
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 }) => (
<Form>
{signupError && (
<div className="mb-6 rounded-[1.5rem] border border-red-400/20 bg-red-400/10 p-4 text-sm text-red-100">
{signupError}
</div>
)}
<div className="grid gap-4 md:grid-cols-2">
<div>
<FormField label="Prénom">
<Field name="firstName" placeholder="Prénom" />
</FormField>
{touched.firstName && errors.firstName && (
<p className="-mt-4 mb-6 text-xs text-red-300">{errors.firstName}</p>
)}
</div>
<div>
<FormField label="Nom">
<Field name="lastName" placeholder="Nom" />
</FormField>
{touched.lastName && errors.lastName && (
<p className="-mt-4 mb-6 text-xs text-red-300">{errors.lastName}</p>
)}
</div>
</div>
<FormField label="Email">
<Field type="email" name="email" placeholder="atelier@marque.fr" />
</FormField>
{touched.email && errors.email && <p className="-mt-4 mb-6 text-xs text-red-300">{errors.email}</p>}
<FormField label="Mot de passe">
<Field type="password" name="password" placeholder="Créez un mot de passe" />
</FormField>
{touched.password && errors.password && <p className="-mt-4 mb-6 text-xs text-red-300">{errors.password}</p>}
<FormField label="Ville / Région">
<Field name="city_region" placeholder="Ex. Nice / Provence-Alpes-Côte dAzur" />
</FormField>
{touched.city_region && errors.city_region && (
<p className="-mt-4 mb-6 text-xs text-red-300">{errors.city_region}</p>
)}
<BaseButton
type="submit"
color="info"
label={isSubmitting ? 'Création...' : 'Créer mon compte'}
className="w-full justify-center bg-[#D4A94E] text-[#111111] hover:bg-[#E4BD6B]"
/>
</Form>
)}
</Formik>
)}
</div>
</CardBox>
</main>
<PublicSiteFooter />
</div>
</>
);
}
ArtisanAuthPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -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 (
<>
<Head>
<title>{getPageTitle('Compte artisan')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Mon compte artisan" main>
{''}
</SectionTitleLineWithButton>
<CardBox isList className="border border-white/10 bg-[#151515] text-white">
<Formik
enableReinitialize
initialValues={initialValues}
validate={(values) => {
const errors: Record<string, string> = {};
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 }) => (
<Form>
<div className="mb-8">
<p className="text-sm uppercase tracking-[0.2em] text-[#D4A94E]">Compte</p>
<h2 className="mt-2 text-2xl font-semibold">Coordonnées du créateur</h2>
<p className="mt-2 text-sm text-gray-500">Ces informations alimentent votre présence dans le dashboard et facilitent le contact.</p>
</div>
{status && <p className="mb-6 rounded-xl bg-red-50 px-4 py-3 text-sm text-red-600">{status}</p>}
<FormField>
<Field
label="Photo de profil"
color="info"
icon={mdiUpload}
path={'users/avatar'}
name="avatar"
id="avatar"
schema={{ size: undefined, formats: undefined }}
component={FormImagePicker}
/>
</FormField>
<div className="grid gap-4 md:grid-cols-2">
<div>
<FormField label="Prénom">
<Field name="firstName" placeholder="Prénom" />
</FormField>
{touched.firstName && errors.firstName && <p className="-mt-4 mb-4 text-xs text-red-500">{errors.firstName}</p>}
</div>
<div>
<FormField label="Nom">
<Field name="lastName" placeholder="Nom" />
</FormField>
{touched.lastName && errors.lastName && <p className="-mt-4 mb-4 text-xs text-red-500">{errors.lastName}</p>}
</div>
</div>
<FormField label="Téléphone">
<Field name="phoneNumber" placeholder="Téléphone" />
</FormField>
<div className="mb-6 rounded-[1.25rem] border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-600">
<span className="font-semibold text-gray-900">Email :</span> {currentUser?.email}
</div>
<BaseDivider />
<BaseButtons>
<BaseButton type="submit" color="info" label={isSubmitting ? 'Enregistrement...' : 'Enregistrer'} />
<BaseButton href="/dashboard?tab=profile" outline color="info" label="Retour au dashboard" />
</BaseButtons>
</Form>
)}
</Formik>
</CardBox>
</SectionMain>
</>
);
}
ArtisanAccountPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};

View File

@ -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<ProductValues>(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 (
<>
<Head>
<title>{getPageTitle('Modifier un produit')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Modifier le produit" main>
{''}
</SectionTitleLineWithButton>
<CardBox isList className="border border-white/10 bg-[#151515] text-white">
{loading ? (
<p className="text-sm text-gray-500">Chargement du produit...</p>
) : (
<Formik
enableReinitialize
initialValues={initialValues}
validate={(values) => {
const errors: Record<string, string> = {};
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 }) => (
<Form>
{status && <p className="mb-6 rounded-xl bg-red-50 px-4 py-3 text-sm text-red-600">{status}</p>}
<FormField label="Catégorie" labelFor="category">
<Field
name="category"
id="category"
component={SelectField}
options={initialValues.category}
itemRef={'categories'}
showField={'name'}
/>
</FormField>
{touched.category && errors.category && <p className="-mt-4 mb-4 text-xs text-red-500">{errors.category}</p>}
<FormField label="Nom du produit">
<Field name="name" placeholder="Ex. Vase tourné main" />
</FormField>
{touched.name && errors.name && <p className="-mt-4 mb-4 text-xs text-red-500">{errors.name}</p>}
<FormField label="Description" hasTextareaHeight>
<Field name="description" id="description" component={RichTextField} />
</FormField>
{touched.description && errors.description && <p className="-mt-4 mb-4 text-xs text-red-500">{errors.description}</p>}
<div className="grid gap-4 md:grid-cols-2">
<div>
<FormField label="Prix (€)">
<Field type="number" min="0" step="0.01" name="price_eur" placeholder="58" />
</FormField>
{touched.price_eur && errors.price_eur && <p className="-mt-4 mb-4 text-xs text-red-500">{errors.price_eur}</p>}
</div>
<FormField label="Stock disponible">
<Field type="number" min="0" name="stock_qty" placeholder="3" />
</FormField>
</div>
<FormField>
<Field
label="Photo du produit"
color="info"
icon={mdiUpload}
path={'products/photos'}
name="photos"
id="photos"
schema={{ size: undefined, formats: undefined }}
component={FormImagePicker}
/>
</FormField>
<FormField label="Statut" labelFor="status">
<Field name="status" id="status" component="select">
<option value="online">En ligne</option>
<option value="draft">Brouillon</option>
<option value="archived">Archivé</option>
</Field>
</FormField>
<BaseDivider />
<BaseButtons>
<BaseButton type="submit" color="info" label={isSubmitting ? 'Enregistrement...' : 'Mettre à jour'} />
<BaseButton href="/dashboard?tab=products" outline color="info" label="Retour au dashboard" />
</BaseButtons>
</Form>
)}
</Formik>
)}
</CardBox>
</SectionMain>
</>
);
}
ArtisanProductEditPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated permission={'UPDATE_PRODUCTS'}>{page}</LayoutAuthenticated>;
};

View File

@ -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 (
<>
<Head>
<title>{getPageTitle('Nouveau produit')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Ajouter un produit" main>
{''}
</SectionTitleLineWithButton>
<CardBox isList className="border border-white/10 bg-[#151515] text-white">
<Formik
enableReinitialize
initialValues={initialValues}
validate={(values) => {
const errors: Record<string, string> = {};
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 }) => (
<Form>
{status && <p className="mb-6 rounded-xl bg-red-50 px-4 py-3 text-sm text-red-600">{status}</p>}
<FormField label="Catégorie" labelFor="category">
<Field name="category" id="category" component={SelectField} options={[]} itemRef={'categories'} />
</FormField>
{touched.category && errors.category && <p className="-mt-4 mb-4 text-xs text-red-500">{errors.category}</p>}
<FormField label="Nom du produit">
<Field name="name" placeholder="Ex. Vase tourné main" />
</FormField>
{touched.name && errors.name && <p className="-mt-4 mb-4 text-xs text-red-500">{errors.name}</p>}
<FormField label="Description" hasTextareaHeight>
<Field name="description" id="description" component={RichTextField} />
</FormField>
{touched.description && errors.description && <p className="-mt-4 mb-4 text-xs text-red-500">{errors.description}</p>}
<div className="grid gap-4 md:grid-cols-2">
<div>
<FormField label="Prix (€)">
<Field type="number" min="0" step="0.01" name="price_eur" placeholder="58" />
</FormField>
{touched.price_eur && errors.price_eur && <p className="-mt-4 mb-4 text-xs text-red-500">{errors.price_eur}</p>}
</div>
<FormField label="Stock disponible">
<Field type="number" min="0" name="stock_qty" placeholder="3" />
</FormField>
</div>
<FormField>
<Field
label="Photo du produit"
color="info"
icon={mdiUpload}
path={'products/photos'}
name="photos"
id="photos"
schema={{ size: undefined, formats: undefined }}
component={FormImagePicker}
/>
</FormField>
<FormField label="Statut" labelFor="status">
<Field name="status" id="status" component="select">
<option value="online">En ligne</option>
<option value="draft">Brouillon</option>
<option value="archived">Archivé</option>
</Field>
</FormField>
<BaseDivider />
<BaseButtons>
<BaseButton type="submit" color="info" label={isSubmitting ? 'Enregistrement...' : 'Publier le produit'} />
<BaseButton href="/dashboard?tab=products" outline color="info" label="Retour au dashboard" />
</BaseButtons>
</Form>
)}
</Formik>
</CardBox>
</SectionMain>
</>
);
}
ArtisanProductNewPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated permission={'CREATE_PRODUCTS'}>{page}</LayoutAuthenticated>;
};

View File

@ -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<string | null>(null);
const [initialValues, setInitialValues] = useState<ProfileValues>(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 (
<>
<Head>
<title>{getPageTitle('Profil artisan')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Mon profil public" main>
{''}
</SectionTitleLineWithButton>
<CardBox isList className="border border-white/10 bg-[#151515] text-white">
{loading ? (
<p className="text-sm text-gray-500">Chargement du profil atelier...</p>
) : (
<Formik
enableReinitialize
initialValues={initialValues}
validate={(values) => {
const errors: Record<string, string> = {};
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 }) => (
<Form>
{loadError && <p className="mb-6 rounded-xl bg-red-50 px-4 py-3 text-sm text-red-600">{loadError}</p>}
{status && <p className="mb-6 rounded-xl bg-red-50 px-4 py-3 text-sm text-red-600">{status}</p>}
<FormField>
<Field
label="Photo de profil"
color="info"
icon={mdiUpload}
path={'artisan_profiles/profile_photo'}
name="profile_photo"
id="profile_photo"
schema={{ size: undefined, formats: undefined }}
component={FormImagePicker}
/>
</FormField>
<div className="grid gap-4 md:grid-cols-2">
<div>
<FormField label="Spécialité artisanale">
<Field name="specialty" placeholder="Ex. céramique contemporaine" />
</FormField>
{touched.specialty && errors.specialty && <p className="-mt-4 mb-4 text-xs text-red-500">{errors.specialty}</p>}
</div>
<div>
<FormField label="Ville / Région">
<Field name="city_region" placeholder="Ex. Strasbourg / Grand Est" />
</FormField>
{touched.city_region && errors.city_region && <p className="-mt-4 mb-4 text-xs text-red-500">{errors.city_region}</p>}
</div>
</div>
<FormField label="Bio / description de latelier" hasTextareaHeight>
<Field name="workshop_bio" id="workshop_bio" component={RichTextField} />
</FormField>
{touched.workshop_bio && errors.workshop_bio && (
<p className="-mt-4 mb-4 text-xs text-red-500">{errors.workshop_bio}</p>
)}
<div className="grid gap-4 md:grid-cols-2">
<FormField label="Site web">
<Field name="website_url" placeholder="https://" />
</FormField>
<FormField label="Réseaux sociaux">
<Field name="social_links" placeholder="Instagram, Pinterest, TikTok..." />
</FormField>
</div>
<BaseDivider />
<BaseButtons>
<BaseButton type="submit" color="info" label={isSubmitting ? 'Enregistrement...' : 'Enregistrer le profil'} />
<BaseButton href="/dashboard?tab=profile" outline color="info" label="Retour au dashboard" />
</BaseButtons>
</Form>
)}
</Formik>
)}
</CardBox>
</SectionMain>
</>
);
}
ArtisanProfilePage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated permission={'UPDATE_ARTISAN_PROFILES'}>{page}</LayoutAuthenticated>;
};

View File

@ -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<Record<keyof typeof initialValues, string>> = {};
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 (
<>
<Head>
<title>{getPageTitle('Contact')}</title>
</Head>
<div className="min-h-screen bg-[#0B0B0B] text-white">
<PublicSiteHeader />
<main>
<section className="border-b border-white/10 bg-[radial-gradient(circle_at_top,_rgba(212,169,78,0.18),_transparent_36%),linear-gradient(180deg,#101010_0%,#0B0B0B_100%)]">
<div className="mx-auto max-w-7xl px-4 py-14 sm:px-6 lg:px-8 lg:py-16">
<div className="max-w-3xl">
<p className="text-sm font-semibold uppercase tracking-[0.3em] text-[#D4A94E]">Formulaire partagé</p>
<h1 className="mt-4 text-4xl font-semibold tracking-tight text-white sm:text-5xl">
Une seule porte dentrée pour les demandes générales, les services artisans et les intentions de commande.
</h1>
<p className="mt-4 text-lg leading-8 text-white/68">
Le champ Sujet se remplit automatiquement lorsquun visiteur arrive depuis le marketplace ou depuis les cartes de services.
</p>
</div>
</div>
</section>
<section className="mx-auto grid max-w-7xl gap-8 px-4 py-12 sm:px-6 lg:grid-cols-[0.85fr_1.15fr] lg:px-8">
<CardBox isList className="border border-white/10 bg-[#121212] text-white">
<div className="space-y-5">
<div>
<p className="text-sm uppercase tracking-[0.2em] text-[#D4A94E]">À quoi sert ce formulaire ?</p>
<h2 className="mt-3 text-2xl font-semibold text-white">Centraliser les demandes sans perdre le contexte.</h2>
</div>
<ul className="space-y-4 text-sm leading-7 text-white/68">
<li> Les artisans peuvent demander un accompagnement marketing ou formation.</li>
<li> Les visiteurs peuvent exprimer une intention de commande depuis le marketplace.</li>
<li> Les demandes générales restent accessibles depuis le footer public.</li>
</ul>
{initialValues.subject && (
<div className="rounded-[1.5rem] border border-[#D4A94E]/20 bg-[#D4A94E]/10 p-4 text-sm text-white/85">
<p className="font-semibold text-[#F6E7C1]">Sujet pré-rempli</p>
<p className="mt-2">{initialValues.subject}</p>
</div>
)}
</div>
</CardBox>
<CardBox isList className="border border-white/10 bg-[#151515] text-white">
<Formik
enableReinitialize
initialValues={initialValues}
validate={validate}
onSubmit={async (values, helpers) => {
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 }) => (
<Form>
<div className="mb-8 flex items-center justify-between gap-4">
<div>
<p className="text-sm uppercase tracking-[0.2em] text-[#D4A94E]">Formulaire</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Parlons de votre besoin</h2>
</div>
</div>
{isSuccess && (
<div className="mb-6 rounded-[1.5rem] border border-emerald-400/20 bg-emerald-400/10 p-4 text-sm text-emerald-100">
Merci, votre message a bien é envoyé. Notre équipe reviendra vers vous rapidement.
</div>
)}
{submitError && (
<div className="mb-6 rounded-[1.5rem] border border-red-400/20 bg-red-400/10 p-4 text-sm text-red-100">
{submitError}
</div>
)}
<div className="grid gap-4 md:grid-cols-2">
<div>
<FormField label="Prénom">
<Field name="first_name" placeholder="Votre prénom" />
</FormField>
{touched.first_name && errors.first_name && (
<p className="-mt-4 mb-4 text-xs text-red-300">{errors.first_name}</p>
)}
</div>
<div>
<FormField label="Nom">
<Field name="last_name" placeholder="Votre nom" />
</FormField>
{touched.last_name && errors.last_name && (
<p className="-mt-4 mb-4 text-xs text-red-300">{errors.last_name}</p>
)}
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<FormField label="Email">
<Field type="email" name="email" placeholder="vous@exemple.com" />
</FormField>
{touched.email && errors.email && (
<p className="-mt-4 mb-4 text-xs text-red-300">{errors.email}</p>
)}
</div>
<div>
<FormField label="Téléphone">
<Field name="phone" placeholder="Optionnel" />
</FormField>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<FormField label="Ville / Région">
<Field name="city_region" placeholder="Ex. Lille / Hauts-de-France" />
</FormField>
{touched.city_region && errors.city_region && (
<p className="-mt-4 mb-4 text-xs text-red-300">{errors.city_region}</p>
)}
</div>
<div>
<FormField label="Sujet">
<Field name="subject" placeholder="Se remplit automatiquement selon le contexte" />
</FormField>
</div>
</div>
<FormField label="Message" hasTextareaHeight>
<Field as="textarea" name="message" placeholder="Décrivez votre besoin" className="min-h-[180px]" />
</FormField>
{touched.message && errors.message && (
<p className="-mt-4 mb-6 text-xs text-red-300">{errors.message}</p>
)}
<div className="flex flex-col gap-3 sm:flex-row">
<BaseButton
type="submit"
color="info"
label={isSubmitting ? 'Envoi en cours...' : 'Envoyer le message'}
className="w-full justify-center bg-[#D4A94E] text-[#111111] hover:bg-[#E4BD6B]"
/>
<BaseButton href="/marketplace" outline color="info" label="Retour au marketplace" className="w-full justify-center border-white/15 text-white hover:bg-white/5" />
</div>
</Form>
)}
</Formik>
</CardBox>
</section>
</main>
<PublicSiteFooter />
</div>
</>
);
}
ContactPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -16,6 +16,8 @@ import { WidgetCreator } from '../components/WidgetCreator/WidgetCreator';
import { SmartWidget } from '../components/SmartWidget/SmartWidget'; import { SmartWidget } from '../components/SmartWidget/SmartWidget';
import { useAppDispatch, useAppSelector } from '../stores/hooks'; import { useAppDispatch, useAppSelector } from '../stores/hooks';
import ArtisanStudioSection from '../components/ArtisanStudioSection';
const Dashboard = () => { const Dashboard = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const iconsColor = useAppSelector((state) => state.style.iconsColor); const iconsColor = useAppSelector((state) => state.style.iconsColor);
@ -105,6 +107,8 @@ const Dashboard = () => {
{''} {''}
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<ArtisanStudioSection currentUser={currentUser} />
{hasPermission(currentUser, 'CREATE_ROLES') && <WidgetCreator {hasPermission(currentUser, 'CREATE_ROLES') && <WidgetCreator
currentUser={currentUser} currentUser={currentUser}
isFetchingQuery={isFetchingQuery} isFetchingQuery={isFetchingQuery}

View File

@ -1,166 +1,194 @@
import React, { useEffect, useState } from 'react';
import type { ReactElement } from 'react';
import Head from 'next/head'; import Head from 'next/head';
import Link from 'next/link'; import Link from 'next/link';
import BaseButton from '../components/BaseButton'; import type { ReactElement } from 'react';
import CardBox from '../components/CardBox'; import {
import SectionFullScreen from '../components/SectionFullScreen'; mdiArrowRight,
mdiBasketOutline,
mdiCompassOutline,
mdiPaletteOutline,
mdiStarFourPointsOutline,
} from '@mdi/js';
import BaseIcon from '../components/BaseIcon';
import LayoutGuest from '../layouts/Guest'; import LayoutGuest from '../layouts/Guest';
import BaseDivider from '../components/BaseDivider'; import PublicSiteHeader from '../components/PublicSiteHeader';
import BaseButtons from '../components/BaseButtons'; import PublicSiteFooter from '../components/PublicSiteFooter';
import { getPageTitle } from '../config'; import { getPageTitle } from '../config';
import { useAppSelector } from '../stores/hooks'; import { featuredProducts, serviceCards } from '../data/artisanMarketplace';
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
const highlights = [
{
title: 'Marketplace accessible sans compte',
description:
'Les visiteurs découvrent facilement les pièces, les ateliers et lunivers de la plateforme avant de passer à laction.',
icon: mdiCompassOutline,
},
{
title: 'Parcours artisan clair',
description:
'Une entrée dédiée pour créer son compte, enrichir son profil et publier ses produits sans friction.',
icon: mdiPaletteOutline,
},
{
title: 'Demandes centralisées',
description:
'Le même formulaire couvre les demandes générales, les services pour artisans et les intentions de commande.',
icon: mdiStarFourPointsOutline,
},
];
export default function Starter() { const featured = featuredProducts.slice(0, 3);
const [illustrationImage, setIllustrationImage] = useState({
src: undefined,
photographer: undefined,
photographer_url: undefined,
})
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
const [contentType, setContentType] = useState('video');
const [contentPosition, setContentPosition] = useState('left');
const textColor = useAppSelector((state) => state.style.linkColor);
const title = 'Artisan Marketplace' export default function HomePage() {
// Fetch Pexels image/video
useEffect(() => {
async function fetchData() {
const image = await getPexelsImage();
const video = await getPexelsVideo();
setIllustrationImage(image);
setIllustrationVideo(video);
}
fetchData();
}, []);
const imageBlock = (image) => (
<div
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
style={{
backgroundImage: `${
image
? `url(${image?.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}
>
<div className='flex justify-center w-full bg-blue-300/20'>
<a
className='text-[8px]'
href={image?.photographer_url}
target='_blank'
rel='noreferrer'
>
Photo by {image?.photographer} on Pexels
</a>
</div>
</div>
);
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return ( return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'> <>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video?.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video?.user?.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
};
return (
<div
style={
contentPosition === 'background'
? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}
: {}
}
>
<Head> <Head>
<title>{getPageTitle('Starter Page')}</title> <title>{getPageTitle('Artisan Marketplace')}</title>
</Head> </Head>
<SectionFullScreen bg='violet'> <div className="min-h-screen bg-[#0C0C0C] text-white">
<div <PublicSiteHeader />
className={`flex ${
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row' <main>
} min-h-screen w-full`} <section className="relative overflow-hidden border-b border-white/10">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,_rgba(212,169,78,0.18),_transparent_38%),radial-gradient(circle_at_bottom_right,_rgba(255,255,255,0.08),_transparent_30%)]" />
<div className="relative mx-auto grid max-w-7xl gap-12 px-4 py-16 sm:px-6 lg:grid-cols-[1.15fr_0.85fr] lg:px-8 lg:py-24">
<div className="max-w-2xl">
<span className="inline-flex rounded-full border border-[#D4A94E]/30 bg-[#D4A94E]/10 px-4 py-1 text-xs font-semibold uppercase tracking-[0.3em] text-[#F6E7C1]">
Artisan Marketplace
</span>
<h1 className="mt-6 text-4xl font-semibold tracking-tight text-white sm:text-5xl lg:text-6xl">
La place de marché élégante pour acheter, vendre et faire rayonner lartisanat.
</h1>
<p className="mt-6 max-w-2xl text-lg leading-8 text-white/72">
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.
</p>
<div className="mt-10 flex flex-col gap-4 sm:flex-row">
<Link
href="/marketplace"
className="inline-flex items-center justify-center gap-2 rounded-full bg-[#D4A94E] px-6 py-3 text-sm font-semibold text-[#111111] transition hover:bg-[#E4BD6B]"
> >
{contentType === 'image' && contentPosition !== 'background' Acheter
? imageBlock(illustrationImage) <BaseIcon path={mdiBasketOutline} size={18} />
: null} </Link>
{contentType === 'video' && contentPosition !== 'background' <Link
? videoBlock(illustrationVideo) href="/artisan-auth"
: null} className="inline-flex items-center justify-center gap-2 rounded-full border border-white/15 px-6 py-3 text-sm font-semibold text-white transition hover:border-white/30 hover:bg-white/5"
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'> >
<CardBox className='w-full md:w-3/5 lg:w-2/3'> Devenir un artisan
<CardBoxComponentTitle title="Welcome to your Artisan Marketplace app!"/> <BaseIcon path={mdiArrowRight} size={18} />
<div className="space-y-3">
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
<p className='text-center text-gray-500'>For guides and documentation please check
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
</div>
<BaseButtons>
<BaseButton
href='/login'
label='Login'
color='info'
className='w-full'
/>
</BaseButtons>
</CardBox>
</div>
</div>
</SectionFullScreen>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
Privacy Policy
</Link> </Link>
</div> </div>
<div className="mt-10 grid gap-4 sm:grid-cols-3">
<div className="rounded-3xl border border-white/10 bg-white/5 p-5 backdrop-blur">
<div className="text-3xl font-semibold text-[#F6E7C1]">2</div>
<p className="mt-2 text-sm text-white/70">profils utilisateurs distincts : acheteurs & artisans</p>
</div> </div>
<div className="rounded-3xl border border-white/10 bg-white/5 p-5 backdrop-blur">
<div className="text-3xl font-semibold text-[#F6E7C1]">1</div>
<p className="mt-2 text-sm text-white/70">formulaire partagé pour les demandes, services et intentions de commande</p>
</div>
<div className="rounded-3xl border border-white/10 bg-white/5 p-5 backdrop-blur">
<div className="text-3xl font-semibold text-[#F6E7C1]">FR</div>
<p className="mt-2 text-sm text-white/70">première langue et ton moderne, accessible, craft-focused</p>
</div>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-1">
{featured.map((product) => (
<article
key={product.id}
className="overflow-hidden rounded-[2rem] border border-white/10 bg-[#171717] shadow-[0_30px_80px_rgba(0,0,0,0.35)]"
>
<div className="h-52 overflow-hidden">
<img
src={product.image}
alt={product.name}
className="h-full w-full object-cover transition duration-500 hover:scale-105"
/>
</div>
<div className="space-y-3 p-6">
<div className="flex items-center justify-between gap-3">
<span className="rounded-full border border-[#D4A94E]/25 bg-[#D4A94E]/10 px-3 py-1 text-xs font-medium text-[#F6E7C1]">
{product.category}
</span>
<span className="text-sm text-white/60">{product.city}</span>
</div>
<div>
<h2 className="text-xl font-semibold text-white">{product.name}</h2>
<p className="mt-1 text-sm text-white/65">par {product.artisan}</p>
</div>
<div className="flex items-center justify-between">
<p className="text-lg font-semibold text-white">{product.price} </p>
<Link href="/marketplace" className="text-sm font-medium text-[#F6E7C1] transition hover:text-white">
Voir le catalogue
</Link>
</div>
</div>
</article>
))}
</div>
</div>
</section>
<section className="mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:px-8">
<div className="max-w-2xl">
<p className="text-sm font-semibold uppercase tracking-[0.3em] text-[#D4A94E]">Première valeur livrée</p>
<h2 className="mt-4 text-3xl font-semibold text-white">Une vitrine premium avec un vrai parcours dès la première itération.</h2>
</div>
<div className="mt-10 grid gap-6 md:grid-cols-3">
{highlights.map((item) => (
<div key={item.title} className="rounded-[2rem] border border-white/10 bg-[#141414] p-6">
<span className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#D4A94E]/10 text-[#D4A94E]">
<BaseIcon path={item.icon} size={22} />
</span>
<h3 className="mt-5 text-xl font-semibold text-white">{item.title}</h3>
<p className="mt-3 text-sm leading-7 text-white/68">{item.description}</p>
</div>
))}
</div>
</section>
<section className="border-y border-white/10 bg-[#101010]">
<div className="mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:px-8">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-2xl">
<p className="text-sm font-semibold uppercase tracking-[0.3em] text-[#D4A94E]">Accompagnement artisans</p>
<h2 className="mt-4 text-3xl font-semibold text-white">Deux offres claires pour aider les créateurs à vendre plus sereinement.</h2>
</div>
<Link href="/contact" className="text-sm font-medium text-[#F6E7C1] transition hover:text-white">
Nous contacter
</Link>
</div>
<div className="mt-10 grid gap-6 lg:grid-cols-2">
{serviceCards.map((service) => (
<article key={service.id} className="rounded-[2rem] border border-white/10 bg-white/5 p-8 backdrop-blur">
<h3 className="text-2xl font-semibold text-white">{service.title}</h3>
<p className="mt-4 max-w-xl text-sm leading-7 text-white/70">{service.description}</p>
<Link
href={`/contact?subject=${encodeURIComponent(service.subject)}`}
className="mt-8 inline-flex items-center gap-2 text-sm font-semibold text-[#F6E7C1] transition hover:text-white"
>
Nous contacter
<BaseIcon path={mdiArrowRight} size={18} />
</Link>
</article>
))}
</div>
</div>
</section>
</main>
<PublicSiteFooter />
</div>
</>
); );
} }
Starter.getLayout = function getLayout(page: ReactElement) { HomePage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>; return <LayoutGuest>{page}</LayoutGuest>;
}; };

View File

@ -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<string, number>;
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<FeaturedProduct | null>(featuredProducts[0] || null);
const [cart, setCart] = useState<CartState>({});
const [paymentMethod, setPaymentMethod] = useState<PaymentMethod>('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<FeaturedProduct & { quantity: number; lineTotal: number }>,
[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 (
<>
<Head>
<title>{getPageTitle('Marketplace')}</title>
</Head>
<div className="min-h-screen bg-[#0B0B0B] text-white">
<PublicSiteHeader />
<main>
<section className="border-b border-white/10 bg-[radial-gradient(circle_at_top,_rgba(212,169,78,0.16),_transparent_35%),linear-gradient(180deg,#111111_0%,#0B0B0B_100%)]">
<div className="mx-auto max-w-7xl px-4 py-14 sm:px-6 lg:px-8 lg:py-16">
<div className="flex flex-col gap-8 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-2xl">
<p className="text-sm font-semibold uppercase tracking-[0.3em] text-[#D4A94E]">Marketplace public</p>
<h1 className="mt-4 text-4xl font-semibold tracking-tight text-white sm:text-5xl">
Explorez des pièces fabriquées à la main, filtrez par univers et préparez votre commande.
</h1>
<p className="mt-4 text-lg leading-8 text-white/68">
Cette première version met en avant la découverte produit, un panier dintention et la capture du mode de paiement souhaité avant la finalisation complète de la commande.
</p>
</div>
<div className="rounded-[2rem] border border-white/10 bg-white/5 p-5 backdrop-blur lg:min-w-[280px]">
<p className="text-sm font-medium text-white/65">Panier en cours</p>
<div className="mt-3 flex items-center gap-3">
<span className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#D4A94E]/10 text-[#D4A94E]">
<BaseIcon path={mdiPackageVariantClosed} size={20} />
</span>
<div>
<p className="text-2xl font-semibold text-white">{cartCount}</p>
<p className="text-sm text-white/65">article{cartCount > 1 ? 's' : ''}</p>
</div>
</div>
</div>
</div>
<div className="mt-10 grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
<label className="block rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white/70 backdrop-blur">
<span className="sr-only">Rechercher un produit</span>
<input
value={searchTerm}
onChange={(event) => 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"
/>
</label>
<div className="flex flex-wrap gap-2">
{marketplaceCategories.map((category) => (
<button
key={category}
type="button"
onClick={() => 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}
</button>
))}
</div>
</div>
</div>
</section>
<section className="mx-auto grid max-w-7xl gap-8 px-4 py-12 sm:px-6 lg:grid-cols-[1.1fr_0.9fr] lg:px-8">
<div>
<div className="mb-5 flex items-center gap-2 text-sm text-white/60">
<BaseIcon path={mdiTagMultiple} size={18} />
<span>{filteredProducts.length} résultat{filteredProducts.length > 1 ? 's' : ''}</span>
</div>
{filteredProducts.length === 0 ? (
<div className="rounded-[2rem] border border-dashed border-white/15 bg-white/5 p-10 text-center">
<h2 className="text-2xl font-semibold text-white">Aucun produit pour ce filtre</h2>
<p className="mt-3 text-sm leading-7 text-white/65">
Essayez une autre catégorie ou une recherche plus large pour retrouver linspiration artisanale.
</p>
</div>
) : (
<div className="grid gap-6 md:grid-cols-2">
{filteredProducts.map((product) => (
<article
key={product.id}
className={`overflow-hidden rounded-[2rem] border transition ${
selectedProduct?.id === product.id
? 'border-[#D4A94E]/60 bg-[#171717] shadow-[0_20px_70px_rgba(212,169,78,0.08)]'
: 'border-white/10 bg-[#131313] hover:border-white/20'
}`}
>
<button type="button" className="block w-full text-left" onClick={() => setSelectedProduct(product)}>
<div className="h-64 overflow-hidden">
<img src={product.image} alt={product.name} className="h-full w-full object-cover" />
</div>
</button>
<div className="space-y-4 p-6">
<div className="flex items-start justify-between gap-3">
<div>
<span className="rounded-full border border-[#D4A94E]/25 bg-[#D4A94E]/10 px-3 py-1 text-xs font-medium text-[#F6E7C1]">
{product.category}
</span>
<h2 className="mt-3 text-xl font-semibold text-white">{product.name}</h2>
<p className="mt-1 text-sm text-white/60">{product.artisan} · {product.city}</p>
</div>
<span className="text-lg font-semibold text-white">{formatPrice(product.price)}</span>
</div>
<p className="text-sm leading-7 text-white/68">{product.description}</p>
<div className="flex items-center justify-between gap-3">
<span className="text-xs uppercase tracking-[0.2em] text-[#D4A94E]">{product.highlight}</span>
<button
type="button"
onClick={() => addToCart(product)}
className="rounded-full bg-[#D4A94E] px-4 py-2 text-sm font-semibold text-[#111111] transition hover:bg-[#E4BD6B]"
>
Ajouter au panier
</button>
</div>
</div>
</article>
))}
</div>
)}
</div>
<div className="space-y-6 lg:sticky lg:top-24 lg:self-start">
<div className="rounded-[2rem] border border-white/10 bg-[#141414] p-6">
{selectedProduct ? (
<>
<div className="overflow-hidden rounded-[1.5rem]">
<img src={selectedProduct.image} alt={selectedProduct.name} className="h-72 w-full object-cover" />
</div>
<div className="mt-6 flex items-center justify-between gap-3">
<div>
<p className="text-sm uppercase tracking-[0.2em] text-[#D4A94E]">Fiche produit</p>
<h2 className="mt-2 text-2xl font-semibold text-white">{selectedProduct.name}</h2>
<p className="mt-2 text-sm text-white/60">{selectedProduct.artisan} · {selectedProduct.city}</p>
</div>
<p className="text-xl font-semibold text-white">{formatPrice(selectedProduct.price)}</p>
</div>
<p className="mt-4 text-sm leading-7 text-white/70">{selectedProduct.description}</p>
<div className="mt-5 rounded-2xl border border-[#D4A94E]/20 bg-[#D4A94E]/8 p-4 text-sm text-white/75">
<p className="font-semibold text-[#F6E7C1]">Pourquoi on laime</p>
<p className="mt-2">{selectedProduct.highlight}</p>
</div>
<div className="mt-6 flex flex-wrap gap-3">
<button
type="button"
onClick={() => addToCart(selectedProduct)}
className="rounded-full bg-[#D4A94E] px-5 py-3 text-sm font-semibold text-[#111111] transition hover:bg-[#E4BD6B]"
>
Ajouter au panier
</button>
<Link
href={`/contact?subject=${encodeURIComponent(`Question produit — ${selectedProduct.name}`)}`}
className="rounded-full border border-white/15 px-5 py-3 text-sm font-semibold text-white transition hover:border-white/30 hover:bg-white/5"
>
Poser une question
</Link>
</div>
</>
) : (
<div className="rounded-[1.5rem] border border-dashed border-white/15 p-8 text-center text-sm text-white/60">
Sélectionnez un produit pour voir ses détails.
</div>
)}
</div>
<div className="rounded-[2rem] border border-white/10 bg-[#141414] p-6">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm uppercase tracking-[0.2em] text-[#D4A94E]">Panier</p>
<h3 className="mt-2 text-2xl font-semibold text-white">{cartCount} article{cartCount > 1 ? 's' : ''}</h3>
</div>
<p className="text-lg font-semibold text-white">{formatPrice(subtotal)}</p>
</div>
{cartItems.length === 0 ? (
<p className="mt-4 text-sm leading-7 text-white/65">
Ajoutez quelques pièces au panier pour préparer votre demande de commande.
</p>
) : (
<div className="mt-6 space-y-4">
{cartItems.map((item) => (
<div key={item.id} className="rounded-2xl border border-white/10 bg-white/5 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<p className="font-medium text-white">{item.name}</p>
<p className="text-sm text-white/60">{item.artisan}</p>
</div>
<p className="text-sm font-semibold text-white">{formatPrice(item.lineTotal)}</p>
</div>
<div className="mt-4 flex items-center gap-3">
<button
type="button"
onClick={() => 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"
>
</button>
<span className="min-w-8 text-center text-sm font-semibold text-white">{item.quantity}</span>
<button
type="button"
onClick={() => 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"
>
+
</button>
</div>
</div>
))}
</div>
)}
<div className="mt-6 rounded-[1.5rem] border border-white/10 bg-[#111111] p-5">
<p className="text-sm font-semibold text-white">Mode de paiement souhaité</p>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<button
type="button"
onClick={() => 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'
}`}
>
<BaseIcon path={mdiCash} size={18} />
<p className="mt-3 font-semibold">Paiement à la livraison</p>
<p className="mt-2 text-sm leading-6 text-inherit">Capture le besoin et confirme la suite avec léquipe.</p>
</button>
<button
type="button"
onClick={() => 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'
}`}
>
<BaseIcon path={mdiCreditCardOutline} size={18} />
<p className="mt-3 font-semibold">Stripe</p>
<p className="mt-2 text-sm leading-6 text-inherit">Prépare le contexte de paiement pour le prochain échange.</p>
</button>
</div>
</div>
<Link
href={`/contact?subject=${encodeURIComponent(checkoutSubject)}&message=${encodeURIComponent(checkoutMessage)}`}
className={`mt-6 inline-flex w-full items-center justify-center rounded-full px-5 py-3 text-sm font-semibold transition ${
cartItems.length > 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
</Link>
</div>
</div>
</section>
</main>
<PublicSiteFooter />
</div>
</>
);
}
MarketplacePage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};