Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be4dab6f01 |
@ -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,11 +304,7 @@ module.exports = class Artisan_profilesDBApi {
|
|||||||
|
|
||||||
offset = currentPage * limit;
|
offset = currentPage * limit;
|
||||||
|
|
||||||
const orderBy = null;
|
let include = [
|
||||||
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
let include = [
|
|
||||||
|
|
||||||
{
|
{
|
||||||
model: db.users,
|
model: db.users,
|
||||||
@ -355,6 +363,17 @@ module.exports = class Artisan_profilesDBApi {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filter.city_region) {
|
||||||
|
where = {
|
||||||
|
...where,
|
||||||
|
[Op.and]: Utils.ilike(
|
||||||
|
'artisan_profiles',
|
||||||
|
'city_region',
|
||||||
|
filter.city_region,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (filter.website_url) {
|
if (filter.website_url) {
|
||||||
where = {
|
where = {
|
||||||
...where,
|
...where,
|
||||||
|
|||||||
@ -479,11 +479,7 @@ module.exports = class UsersDBApi {
|
|||||||
|
|
||||||
offset = currentPage * limit;
|
offset = currentPage * limit;
|
||||||
|
|
||||||
const orderBy = null;
|
let include = [
|
||||||
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
let include = [
|
|
||||||
|
|
||||||
{
|
{
|
||||||
model: db.roles,
|
model: db.roles,
|
||||||
@ -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, {
|
||||||
|
|||||||
@ -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');
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -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: {
|
||||||
|
|||||||
@ -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');
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -0,0 +1,92 @@
|
|||||||
|
const express = require('express');
|
||||||
|
|
||||||
|
const Contact_submissionsService = require('../services/contact_submissions');
|
||||||
|
const EmailSender = require('../services/email');
|
||||||
|
const config = require('../config');
|
||||||
|
const wrapAsync = require('../helpers').wrapAsync;
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
function trimValue(value) {
|
||||||
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBadRequest(message) {
|
||||||
|
const error = new Error(message);
|
||||||
|
error.code = 400;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/',
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
const first_name = trimValue(req.body.first_name);
|
||||||
|
const last_name = trimValue(req.body.last_name);
|
||||||
|
const email = trimValue(req.body.email).toLowerCase();
|
||||||
|
const phone = trimValue(req.body.phone) || null;
|
||||||
|
const city_region = trimValue(req.body.city_region);
|
||||||
|
const subject = trimValue(req.body.subject) || null;
|
||||||
|
const message = trimValue(req.body.message);
|
||||||
|
|
||||||
|
if (!first_name || !last_name || !email || !city_region || !message) {
|
||||||
|
throw createBadRequest('Veuillez compléter tous les champs requis.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!emailRegex.test(email)) {
|
||||||
|
throw createBadRequest('Veuillez saisir une adresse email valide.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
first_name,
|
||||||
|
last_name,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
city_region,
|
||||||
|
subject,
|
||||||
|
message,
|
||||||
|
status: 'new',
|
||||||
|
submitted_at: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await Contact_submissionsService.create(payload, req.currentUser || { id: null });
|
||||||
|
|
||||||
|
if (EmailSender.isConfigured) {
|
||||||
|
await new EmailSender({
|
||||||
|
to: config.admin_email,
|
||||||
|
subject: `Nouveau message Artisan Marketplace${subject ? ` — ${subject}` : ''}`,
|
||||||
|
html: async () => `
|
||||||
|
<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;
|
||||||
@ -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,8 +12,19 @@ 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 user = await UsersDBApi.findBy({email});
|
const email = String(payload.email || '').trim().toLowerCase();
|
||||||
|
const password = payload.password;
|
||||||
|
const role = payload.role;
|
||||||
|
const firstName = String(payload.firstName || '').trim() || email.split('@')[0];
|
||||||
|
const lastName = String(payload.lastName || '').trim() || null;
|
||||||
|
const cityRegion = String(payload.city_region || payload.cityRegion || '').trim() || null;
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
throw new ValidationError();
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await UsersDBApi.findBy({ email });
|
||||||
|
|
||||||
const hashedPassword = await bcrypt.hash(
|
const hashedPassword = await bcrypt.hash(
|
||||||
password,
|
password,
|
||||||
@ -54,34 +67,60 @@ class Auth {
|
|||||||
return helpers.jwtSign(data);
|
return helpers.jwtSign(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newUser = await UsersDBApi.createFromAuth(
|
const transaction = await db.sequelize.transaction();
|
||||||
{
|
|
||||||
firstName: email.split('@')[0],
|
|
||||||
password: hashedPassword,
|
|
||||||
email: email,
|
|
||||||
|
|
||||||
},
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (EmailSender.isConfigured) {
|
try {
|
||||||
await this.sendEmailAddressVerificationEmail(
|
const newUser = await UsersDBApi.createFromAuth(
|
||||||
newUser.email,
|
{
|
||||||
host,
|
firstName,
|
||||||
|
lastName,
|
||||||
|
password: hashedPassword,
|
||||||
|
email,
|
||||||
|
role,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
transaction,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
const data = {
|
if (role === 'artisan_seller') {
|
||||||
user: {
|
await Artisan_profilesDBApi.create(
|
||||||
id: newUser.id,
|
{
|
||||||
email: newUser.email
|
user: newUser.id,
|
||||||
|
city_region: cityRegion,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
currentUser: { id: newUser.id },
|
||||||
|
transaction,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
return helpers.jwtSign(data);
|
await transaction.commit();
|
||||||
|
|
||||||
|
if (EmailSender.isConfigured) {
|
||||||
|
await this.sendEmailAddressVerificationEmail(
|
||||||
|
newUser.email,
|
||||||
|
host,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
user: {
|
||||||
|
id: newUser.id,
|
||||||
|
email: newUser.email
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return helpers.jwtSign(data);
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async signin(email, password, options = {}) {
|
static async signin(email, password) {
|
||||||
const user = await UsersDBApi.findBy({email});
|
const user = await UsersDBApi.findBy({email});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
|||||||
415
frontend/src/components/ArtisanStudioSection.tsx
Normal file
415
frontend/src/components/ArtisanStudioSection.tsx
Normal 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 l’aide.</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 l’instant</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 d’entré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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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'
|
||||||
|
|||||||
31
frontend/src/components/PublicSiteFooter.tsx
Normal file
31
frontend/src/components/PublicSiteFooter.tsx
Normal 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 d’entré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>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
frontend/src/components/PublicSiteHeader.tsx
Normal file
66
frontend/src/components/PublicSiteHeader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
frontend/src/data/artisanMarketplace.ts
Normal file
128
frontend/src/data/artisanMarketplace.ts
Normal 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 d’oreilles Echo',
|
||||||
|
artisan: 'Studio Argent',
|
||||||
|
category: 'Bijoux',
|
||||||
|
city: 'Paris',
|
||||||
|
price: 56,
|
||||||
|
description:
|
||||||
|
'Boucles martelées à la main en argent recyclé, légères à porter et parfaites pour une silhouette minimaliste.',
|
||||||
|
highlight: 'Argent recyclé',
|
||||||
|
image:
|
||||||
|
'https://images.unsplash.com/photo-1617038220319-276d3cfab638?auto=format&fit=crop&w=1200&q=80',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'candle-fig-tree',
|
||||||
|
slug: 'candle-fig-tree',
|
||||||
|
name: 'Bougie Figuier & atelier',
|
||||||
|
artisan: 'Flamme Botanique',
|
||||||
|
category: 'Bien-être',
|
||||||
|
city: 'Toulouse',
|
||||||
|
price: 26,
|
||||||
|
description:
|
||||||
|
'Bougie coulée à la main dans un contenant en verre soufflé, parfum figuier avec cire végétale.',
|
||||||
|
highlight: 'Cire végétale & parfum de Grasse',
|
||||||
|
image:
|
||||||
|
'https://images.unsplash.com/photo-1603006905003-be475563bc59?auto=format&fit=crop&w=1200&q=80',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const marketplaceCategories = [
|
||||||
|
'Toutes',
|
||||||
|
...Array.from(new Set(featuredProducts.map((product) => product.category))),
|
||||||
|
];
|
||||||
|
|
||||||
|
export const serviceCards: ServiceCard[] = [
|
||||||
|
{
|
||||||
|
id: 'marketing',
|
||||||
|
title: 'Marketing digital pour artisans',
|
||||||
|
description:
|
||||||
|
'Réseaux sociaux, publicité et visibilité en ligne pour faire rayonner votre atelier.',
|
||||||
|
subject: 'Marketing digital pour artisans',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'formation',
|
||||||
|
title: 'Formation artisans',
|
||||||
|
description:
|
||||||
|
'Vente en ligne, photographie produit et gestion commerciale pour structurer vos ventes.',
|
||||||
|
subject: 'Formation artisans',
|
||||||
|
},
|
||||||
|
];
|
||||||
@ -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'
|
||||||
|
|||||||
288
frontend/src/pages/artisan-auth.tsx
Normal file
288
frontend/src/pages/artisan-auth.tsx
Normal 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 d’accompagnement 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 d’accompagnement.
|
||||||
|
</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 l’accè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 d’Azur" />
|
||||||
|
</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>;
|
||||||
|
};
|
||||||
141
frontend/src/pages/artisan/account.tsx
Normal file
141
frontend/src/pages/artisan/account.tsx
Normal 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>;
|
||||||
|
};
|
||||||
214
frontend/src/pages/artisan/products/edit.tsx
Normal file
214
frontend/src/pages/artisan/products/edit.tsx
Normal 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>;
|
||||||
|
};
|
||||||
157
frontend/src/pages/artisan/products/new.tsx
Normal file
157
frontend/src/pages/artisan/products/new.tsx
Normal 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>;
|
||||||
|
};
|
||||||
201
frontend/src/pages/artisan/profile.tsx
Normal file
201
frontend/src/pages/artisan/profile.tsx
Normal 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 l’atelier" 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>;
|
||||||
|
};
|
||||||
239
frontend/src/pages/contact.tsx
Normal file
239
frontend/src/pages/contact.tsx
Normal 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 d’entré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 lorsqu’un 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 été 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>;
|
||||||
|
};
|
||||||
@ -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);
|
||||||
@ -104,6 +106,8 @@ const Dashboard = () => {
|
|||||||
main>
|
main>
|
||||||
{''}
|
{''}
|
||||||
</SectionTitleLineWithButton>
|
</SectionTitleLineWithButton>
|
||||||
|
|
||||||
|
<ArtisanStudioSection currentUser={currentUser} />
|
||||||
|
|
||||||
{hasPermission(currentUser, 'CREATE_ROLES') && <WidgetCreator
|
{hasPermission(currentUser, 'CREATE_ROLES') && <WidgetCreator
|
||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
|
|||||||
@ -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 l’univers de la plateforme avant de passer à l’action.',
|
||||||
|
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'
|
|
||||||
|
|
||||||
// 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 (
|
|
||||||
<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>)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
return (
|
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%)]" />
|
||||||
{contentType === 'image' && contentPosition !== 'background'
|
<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">
|
||||||
? imageBlock(illustrationImage)
|
<div className="max-w-2xl">
|
||||||
: null}
|
<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]">
|
||||||
{contentType === 'video' && contentPosition !== 'background'
|
Artisan Marketplace
|
||||||
? videoBlock(illustrationVideo)
|
</span>
|
||||||
: null}
|
<h1 className="mt-6 text-4xl font-semibold tracking-tight text-white sm:text-5xl lg:text-6xl">
|
||||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
La place de marché élégante pour acheter, vendre et faire rayonner l’artisanat.
|
||||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
</h1>
|
||||||
<CardBoxComponentTitle title="Welcome to your Artisan Marketplace app!"/>
|
<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.
|
||||||
<div className="space-y-3">
|
</p>
|
||||||
<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
|
<div className="mt-10 flex flex-col gap-4 sm:flex-row">
|
||||||
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
<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]"
|
||||||
|
>
|
||||||
|
Acheter
|
||||||
|
<BaseIcon path={mdiBasketOutline} size={18} />
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/artisan-auth"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Devenir un artisan
|
||||||
|
<BaseIcon path={mdiArrowRight} size={18} />
|
||||||
|
</Link>
|
||||||
|
</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 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>
|
</div>
|
||||||
|
</section>
|
||||||
<BaseButtons>
|
|
||||||
<BaseButton
|
|
||||||
href='/login'
|
|
||||||
label='Login'
|
|
||||||
color='info'
|
|
||||||
className='w-full'
|
|
||||||
/>
|
|
||||||
|
|
||||||
</BaseButtons>
|
<section className="mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:px-8">
|
||||||
</CardBox>
|
<div className="max-w-2xl">
|
||||||
</div>
|
<p className="text-sm font-semibold uppercase tracking-[0.3em] text-[#D4A94E]">Première valeur livrée</p>
|
||||||
</div>
|
<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>
|
||||||
</SectionFullScreen>
|
</div>
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</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>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
413
frontend/src/pages/marketplace.tsx
Normal file
413
frontend/src/pages/marketplace.tsx
Normal 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 d’intention 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 l’inspiration 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 l’aime</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>;
|
||||||
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user