feat(shop/products): Ajoute l'affichage des produits en boutique et un CRUD admin.

Ajoute un CRUD pour les produits en admin.
Affiche les produits en boutique.
Ajoute les schemas JSON-LD pour chaque produit.
This commit is contained in:
Serreau Jovann
2025-11-19 13:20:22 +01:00
parent c16f7433fe
commit 702b235299
14 changed files with 874 additions and 49 deletions

View File

@@ -17,6 +17,13 @@ vich_uploader:
inject_on_load: true
delete_on_update: true
delete_on_remove: true
product:
uri_prefix: /storage/product
upload_destination: '%kernel.project_dir%/public/storage/product'
namer: Vich\UploaderBundle\Naming\UniqidNamer # Replaced namer
inject_on_load: true
delete_on_update: true
delete_on_remove: true
#mappings:
# products:
# uri_prefix: /images/products

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251119115552 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE products (id SERIAL NOT NULL, name VARCHAR(255) NOT NULL, is_active BOOLEAN NOT NULL, create_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, update_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, price DOUBLE PRECISION NOT NULL, is_promo BOOLEAN NOT NULL, is_handmade BOOLEAN NOT NULL, is_custom BOOLEAN NOT NULL, state VARCHAR(255) NOT NULL, short_description VARCHAR(255) DEFAULT NULL, long_description TEXT DEFAULT NULL, image_file_name VARCHAR(255) DEFAULT NULL, image_dimensions JSON DEFAULT NULL, image_size VARCHAR(255) DEFAULT NULL, image_mine_type VARCHAR(255) DEFAULT NULL, image_original_name VARCHAR(255) DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('COMMENT ON COLUMN products.create_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN products.update_at IS \'(DC2Type:datetime_immutable)\'');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('DROP TABLE products');
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251119120837 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE products ADD ref VARCHAR(255) NOT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE products DROP ref');
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251119120904 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
}
}

View File

@@ -5,10 +5,13 @@ namespace App\Controller\Admin;
use App\Entity\Account;
use App\Entity\AccountResetPasswordRequest;
use App\Entity\Members;
use App\Entity\Products;
use App\Form\MembersType;
use App\Form\ProductsType;
use App\Form\RequestPasswordConfirmType;
use App\Form\RequestPasswordRequestType;
use App\Repository\MembersRepository;
use App\Repository\ProductsRepository;
use App\Service\ResetPassword\Event\ResetPasswordConfirmEvent;
use App\Service\ResetPassword\Event\ResetPasswordEvent;
use Doctrine\ORM\EntityManagerInterface;
@@ -31,6 +34,59 @@ class AdminController extends AbstractController
return $this->render('admin/dashboard.twig', [
]);
}
#[Route(path: '/admin/products', name: 'admin_products', options: ['sitemap' => false], methods: ['GET'])]
public function adminProducts(Request $request,EntityManagerInterface $entityManager,ProductsRepository $productsRepository): Response
{
if($request->query->has('id')){
$pc = $entityManager->getRepository(Products::class)->find($request->query->get('id'));
$entityManager->remove($pc);
$entityManager->flush();
return $this->redirectToRoute('admin_products');
}
return $this->render('admin/products.twig', [
'products' => $productsRepository->findAll(),
]);
}
#[Route(path: '/admin/products/{id}', name: 'admin_product_edit', options: ['sitemap' => false], methods: ['GET','POST'], priority: 5)]
public function adminProductEdit(?Products $product,Request $request,EntityManagerInterface $entityManager): Response
{
$form = $this->createForm(ProductsType::class, $product);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$product->setImage($request->files->all()['products']['image']);
$entityManager->persist($product);
$entityManager->flush();
return $this->redirectToRoute('admin_product_edit', ['id' => $product->getId()]);
}
return $this->render('admin/products/add.twig', [
'form' => $form->createView(),
]);
}
#[Route(path: '/admin/products/add', name: 'admin_product_create', options: ['sitemap' => false], methods: ['GET','POST'], priority: 5)]
public function adminProductCreate(Request $request,EntityManagerInterface $entityManager): Response
{
$product = new Products();
$product->setUpdateAt(new \DateTimeImmutable());
$product->setCreateAt(new \DateTimeImmutable());
$product->setState("new");
$product->setIsActive(true);
$product->setIsPromo(false);
$form = $this->createForm(ProductsType::class, $product);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$product->setImage($request->files->all()['products']['image']);
$entityManager->persist($product);
$entityManager->flush();
return $this->redirectToRoute('admin_products');
}
return $this->render('admin/products/add.twig', [
'form' => $form->createView(),
]);
}
#[Route(path: '/admin/members', name: 'admin_members', options: ['sitemap' => false], methods: ['GET'])]
public function adminMembers(MembersRepository $membersRepository): Response

View File

@@ -4,8 +4,10 @@ namespace App\Controller;
use App\Entity\Account;
use App\Entity\AccountResetPasswordRequest;
use App\Entity\Products;
use App\Form\RequestPasswordConfirmType;
use App\Form\RequestPasswordRequestType;
use App\Repository\ProductsRepository;
use App\Service\ResetPassword\Event\ResetPasswordConfirmEvent;
use App\Service\ResetPassword\Event\ResetPasswordEvent;
use Doctrine\ORM\EntityManagerInterface;
@@ -27,12 +29,12 @@ class ShopController extends AbstractController
#[Route(path: '/boutique', name: 'app_shop', options: ['sitemap' => false], methods: ['GET'])]
public function index(): Response
public function index(ProductsRepository $productsRepository): Response
{
// Correction du nom du template de 'shop.twig' à 'shop/index.html.twig'
// et passage des données centralisées.
return $this->render('shop.twig', [
'featuredProducts' => []
'featuredProducts' => $productsRepository->findAll()
]);
}

314
src/Entity/Products.php Normal file
View File

@@ -0,0 +1,314 @@
<?php
namespace App\Entity;
use App\Repository\ProductsRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\HttpFoundation\File\File;
use Vich\UploaderBundle\Mapping\Annotation as Vich;
#[ORM\Entity(repositoryClass: ProductsRepository::class)]
#[Vich\Uploadable()]
class Products
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private ?string $name = null;
#[ORM\Column]
private ?bool $isActive = null;
#[ORM\Column]
private ?\DateTimeImmutable $createAt = null;
#[ORM\Column]
private ?\DateTimeImmutable $updateAt = null;
#[ORM\Column]
private ?float $price = null;
#[ORM\Column]
private ?bool $isPromo = null;
#[ORM\Column]
private ?bool $isHandmade = null;
#[ORM\Column]
private ?bool $isCustom = null;
#[ORM\Column(length: 255)]
private ?string $state = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $shortDescription = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $longDescription = null;
#[Vich\UploadableField(mapping: 'product',fileNameProperty: 'imageFileName', size: 'imageSize', mimeType: 'imageMineType', originalName: 'imageOriginalName',dimensions: 'imageDimensions')]
private ?File $image = null;
#[ORM\Column(nullable: true)]
private ?string $imageFileName = null;
#[ORM\Column(nullable: true)]
private ?array $imageDimensions = [];
#[ORM\Column(length: 255,nullable: true)]
private ?string $imageSize = null;
#[ORM\Column(length: 255,nullable: true)]
private ?string $imageMineType = null;
#[ORM\Column(length: 255,nullable: true)]
private ?string $imageOriginalName = null;
#[ORM\Column(length: 255)]
private ?string $ref = null;
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function isActive(): ?bool
{
return $this->isActive;
}
public function setIsActive(bool $isActive): static
{
$this->isActive = $isActive;
return $this;
}
public function getCreateAt(): ?\DateTimeImmutable
{
return $this->createAt;
}
public function setCreateAt(\DateTimeImmutable $createAt): static
{
$this->createAt = $createAt;
return $this;
}
public function getUpdateAt(): ?\DateTimeImmutable
{
return $this->updateAt;
}
public function setUpdateAt(\DateTimeImmutable $updateAt): static
{
$this->updateAt = $updateAt;
return $this;
}
public function getPrice(): ?float
{
return $this->price;
}
public function setPrice(float $price): static
{
$this->price = $price;
return $this;
}
public function isPromo(): ?bool
{
return $this->isPromo;
}
public function setIsPromo(bool $isPromo): static
{
$this->isPromo = $isPromo;
return $this;
}
public function isHandmade(): ?bool
{
return $this->isHandmade;
}
public function setIsHandmade(bool $isHandmade): static
{
$this->isHandmade = $isHandmade;
return $this;
}
public function isCustom(): ?bool
{
return $this->isCustom;
}
public function setIsCustom(bool $isCustom): static
{
$this->isCustom = $isCustom;
return $this;
}
public function getState(): ?string
{
return $this->state;
}
public function setState(string $state): static
{
$this->state = $state;
return $this;
}
public function getShortDescription(): ?string
{
return $this->shortDescription;
}
public function setShortDescription(?string $shortDescription): static
{
$this->shortDescription = $shortDescription;
return $this;
}
public function getLongDescription(): ?string
{
return $this->longDescription;
}
public function setLongDescription(?string $longDescription): static
{
$this->longDescription = $longDescription;
return $this;
}
/**
* @return File|null
*/
public function getImage(): ?File
{
return $this->image;
}
/**
* @return array|null
*/
public function getImageDimensions(): ?array
{
return $this->imageDimensions;
}
/**
* @return string|null
*/
public function getImageFileName(): ?string
{
return $this->imageFileName;
}
/**
* @return string|null
*/
public function getImageMineType(): ?string
{
return $this->imageMineType;
}
/**
* @return string|null
*/
public function getImageOriginalName(): ?string
{
return $this->imageOriginalName;
}
/**
* @return string|null
*/
public function getImageSize(): ?string
{
return $this->imageSize;
}
/**
* @param File|null $image
*/
public function setImage(?File $image): void
{
$this->image = $image;
}
/**
* @param array|null $imageDimensions
*/
public function setImageDimensions(?array $imageDimensions): void
{
$this->imageDimensions = $imageDimensions;
}
/**
* @param string|null $imageFileName
*/
public function setImageFileName(?string $imageFileName): void
{
$this->imageFileName = $imageFileName;
}
/**
* @param string|null $imageMineType
*/
public function setImageMineType(?string $imageMineType): void
{
$this->imageMineType = $imageMineType;
}
/**
* @param string|null $imageOriginalName
*/
public function setImageOriginalName(?string $imageOriginalName): void
{
$this->imageOriginalName = $imageOriginalName;
}
/**
* @param string|null $imageSize
*/
public function setImageSize(?string $imageSize): void
{
$this->imageSize = $imageSize;
}
public function getRef(): ?string
{
return $this->ref;
}
public function setRef(string $ref): static
{
$this->ref = $ref;
return $this;
}
}

View File

@@ -2,6 +2,8 @@
namespace App\EventSubscriber;
use App\Repository\ProductsRepository;
use Cocur\Slugify\Slugify;
use Liip\ImagineBundle\Imagine\Cache\CacheManager;
use Presta\SitemapBundle\Event\SitemapPopulateEvent;
use Presta\SitemapBundle\Sitemap\Url\GoogleImage;
@@ -14,7 +16,7 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
#[AsEventListener(event: SitemapPopulateEvent::class, method: 'onSitemapPopulate', priority: 10)]
class SitemapSubscriber
{
public function __construct(private CacheManager $cacheManager)
public function __construct(private readonly ProductsRepository $productsRepository,private CacheManager $cacheManager)
{
}
@@ -91,5 +93,19 @@ class SitemapSubscriber
}
$urlContainer->addUrl($decoratedUrlAbout, 'default');
$s = new Slugify();
foreach ($this->productsRepository->findAll() as $product) {
$slug = $s->slugify($product->getName()."-".$product->getId());
$urlAbout = new UrlConcrete($urlGenerator->generate('app_product_show', ['slug'=>$slug], UrlGeneratorInterface::ABSOLUTE_URL));
$decoratedUrlAbout = new GoogleImageUrlDecorator($urlAbout);
$decoratedUrlAbout->addImage(new GoogleImage($this->cacheManager->resolve('assets/images/logo.jpg','webp')));
$decoratedUrlAbout->addImage(new GoogleImage($this->cacheManager->resolve($product->getImage(),'webp')));
$decoratedUrlAbout = new GoogleMultilangUrlDecorator($decoratedUrlAbout);
foreach ($langs as $lang) {
$decoratedUrlAbout->addLink($urlGenerator->generate('app_product_show',['lang'=>$lang,'slug'=>$slug], UrlGeneratorInterface::ABSOLUTE_URL), $lang);
}
$urlContainer->addUrl($decoratedUrlAbout, 'products');
}
}
}

93
src/Form/ProductsType.php Normal file
View File

@@ -0,0 +1,93 @@
<?php
namespace App\Form;
use App\Entity\Products; // Assurez-vous que c'est le bon nom d'entité
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ProductsType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('name', TextType::class, [
'label' => 'Nom du produit',
'required' => true,
])
->add('ref', TextType::class, [
'label' => 'Référence du produit',
'required' => true,
])
->add('shortDescription', TextareaType::class, [
'label' => 'Description courte (Max 255 caractères)',
// La description courte est utilisée pour le JSON-LD, assurez-vous qu'elle est concise.
])
->add('longDescription', TextareaType::class, [
'label' => 'Description complète du produit',
])
->add('price', NumberType::class, [
'label' => 'Prix (€ TTC)',
'scale' => 2, // Permet deux décimales pour les centimes
])
->add('image', FileType::class, [
'label' => 'Photo du produit (Max 2Mo)',
'required' => false,
'mapped' => false,
'attr' => [
'placeholder' => 'Choisir un fichier...',
]
])
->add('state', ChoiceType::class, [
'label' => 'Statut du produit',
'required' => true,
'choices' => [
// Correction ici pour inclure 'new' et 'used' pour correspondre au JSON-LD
'Neuf' => 'new',
'Occasion' => 'used',
'Reconditionné' => 'refurbished',
],
])
->add('isPromo', ChoiceType::class, [
'label' => 'Mettre en promotion ?',
'choices' => [
'Non' => false,
'Oui' => true,
],
'required' => true,
'multiple' => false,
])
->add('isHandmade', ChoiceType::class, [
'label' => 'Article fait-main ?',
'choices' => [
'Non' => false,
'Oui' => true,
],
'required' => true,
'multiple' => false,
])
->add('isCustom', ChoiceType::class, [
'label' => 'Article personnalisable/customisé ?',
'choices' => [
'Non' => false,
'Oui' => true,
],
'required' => true,
'multiple' => false,
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Products::class,
]);
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Repository;
use App\Entity\Products;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Products>
*/
class ProductsRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Products::class);
}
// /**
// * @return Products[] Returns an array of Products objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('p')
// ->andWhere('p.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('p.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?Products
// {
// return $this->createQueryBuilder('p')
// ->andWhere('p.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}

View File

@@ -57,7 +57,16 @@
{% endif %}">
Membres
</a>
{# MEMBRES #}
<a href="{{ path('admin_products') }}"
class="block px-3 py-2 rounded-lg transition duration-150 ease-in-out text-white
{% if 'product' in app.request.attributes.get('_route') %}
bg-indigo-500 font-bold
{% else %}
hover:bg-gray-700
{% endif %}">
Produits
</a>
{# ÉVÉNEMENTS #}
<a href="{{ path('admin_events') }}"
class="block px-3 py-2 rounded-lg transition duration-150 ease-in-out text-white

View File

@@ -0,0 +1,82 @@
{% extends 'admin/base.twig' %}
{% block title %}Produit(s){% endblock %}
{% block page_title %}Liste des Produits{% endblock %}
{% block body %}
<style>
.dz{
display: block;
text-align: center;
width: 100%;
}
</style>
<div class="flex justify-end">
<a href="{{ path('admin_product_create') }}"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium
shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2
focus:ring-offset-2 focus:ring-indigo-500 transition duration-150 dz">
{# Vous pouvez ajouter ici une icône si vous le souhaitez, par exemple : [Icône] #}
Créer un Produit
</a>
</div>
<div class="overflow-x-auto bg-gray-700">
<table class="min-w-full divide-y divide-gray-200">
{# --- EN-TÊTE DU TABLEAU (HEAD) --- #}
<thead class="bg-gray-800">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-white uppercase tracking-wider">
REF
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-white uppercase tracking-wider">
Name
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-white uppercase tracking-wider">
Prix
</th>
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-white uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
{# --- CORPS DU TABLEAU (BODY) --- #}
<tbody class="bg-gray-600 divide-y divide-gray-200">
{# Démonstration: Boucle sur une liste de membres (members) passée par votre contrôleur #}
{% if products is not empty %}
{% for product in products %}
<tr>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-white">{{ product.ref }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-white">{{ product.name }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-white">{{ product.price|format_currency('EUR') }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<a href="{{ path('admin_product_edit', {id: product.id}) }}" class="text-indigo-900 hover:text-indigo-900 mr-4">
Éditer
</a>
<a href="{{ path('admin_products', {id: product.id}) }}" class="text-red-900 hover:text-red-900">
Supprimer
</a>
</td>
</tr>
{% endfor %}
{% else %}
{# Message si la liste est vide #}
<tr>
<td colspan="4" class="px-6 py-12 text-center text-gray-500">
Aucun produit(s) trouvé.
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
{% endblock %}

View File

@@ -0,0 +1,122 @@
{% extends 'admin/base.twig' %}
{# Définition dynamique du titre de la page #}
{% set is_edit = form.vars.value.id is not null %}
{% block title %}
{{ is_edit ? 'Éditer le Produit: ' ~ form.vars.value.name : 'Ajouter un nouveau Produit' }}
{% endblock %}
{% block page_title %}
{{ is_edit ? 'Éditer le Produit' : 'Créer un nouveau Produit' }}
{% endblock %}
{% block body %}
<div class="mx-auto bg-white p-8 rounded-xl shadow-lg border border-gray-100">
{# Début du formulaire #}
{{ form_start(form, {'attr': {'class': 'space-y-6', 'enctype': 'multipart/form-data'}}) }}
{# IMPORTANT : Ajouter enctype="multipart/form-data" pour l'upload de fichier #}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
{# Colonne 1 : Infos de base #}
<div class="space-y-6">
{{ form_row(form.name) }}
{{ form_row(form.ref) }}
{{ form_row(form.price) }}
{{ form_row(form.state) }}
{# --- DÉBUT : CHAMP D'UPLOAD D'IMAGE STYLISÉ --- #}
<div class="py-4 space-y-2">
<label class="block text-sm font-medium text-gray-700">
{{ form.image.vars.label }}
</label>
<div class="flex items-center gap-4">
{# Le champ de type Fichier doit rester masqué #}
{{ form_widget(form.image, {'attr': {'class': 'hidden', 'id': form.image.vars.id}}) }}
{# L'élément LABEL est stylisé pour devenir le cercle cliquable #}
<label for="{{ form.image.vars.id }}"
class="relative cursor-pointer bg-gray-200 rounded-xl w-32 h-32
flex items-center justify-center border-2 border-dashed border-gray-400
hover:border-indigo-600 transition duration-300 overflow-hidden shadow-inner">
{# L'entité Product est accessible via form.vars.value #}
{% set product = form.vars.value %}
{# 1. AFFICHAGE DE L'IMAGE EXISTANTE (si présente) #}
{# J'utilise 'products_image' comme nom de mapping par défaut. Ajustez si nécessaire ! #}
{% set currentImageUrl = product.image ? vich_uploader_asset(product, 'image') : null %}
{% if currentImageUrl %}
<img src="{{ asset(currentImageUrl) }}"
alt="Image produit actuelle"
class="w-full h-full object-cover">
{# Overlay pour indiquer que c'est cliquable #}
<div class="absolute inset-0 bg-black bg-opacity-30 flex items-center justify-center
opacity-0 hover:opacity-100 transition duration-300 text-white font-bold text-xs p-2 text-center">
Cliquer pour changer l'image
</div>
{% else %}
{# 2. AFFICHAGE DE L'ICÔNE PAR DÉFAUT (si aucune image) #}
<svg class="h-10 w-10 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 18m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
{% endif %}
</label>
{# Afficher le nom du fichier actuel ou un message d'aide #}
<div class="text-sm text-gray-500">
{% if currentImageUrl %}
Image actuelle : <span class="font-medium text-gray-700">{{ product.imageFileName }}</span>
{% else %}
Aucune image sélectionnée
{% endif %}
<p class="text-xs mt-1 text-gray-400">{{ form.image.vars.help|default('(Max 2Mo, JPG, PNG)') }}</p>
</div>
</div>
</div>
{# --- FIN : CHAMP D'UPLOAD D'IMAGE STYLISÉ --- #}
<div class="pt-4 border-t">
<h3 class="font-semibold text-gray-700 mb-3">Options produit</h3>
<div class="flex flex-wrap gap-4">
{# Les ChoiceType Expanded pour les booléens #}
<div class="w-full md:w-auto flex-grow">{{ form_row(form.isHandmade) }}</div>
<div class="w-full md:w-auto flex-grow">{{ form_row(form.isCustom) }}</div>
<div class="w-full md:w-auto flex-grow">{{ form_row(form.isPromo) }}</div>
</div>
</div>
</div>
{# Colonne 2 : Descriptions #}
<div class="space-y-6">
{{ form_row(form.shortDescription) }}
{{ form_row(form.longDescription) }}
</div>
</div>
{# --- BOUTONS D'ACTION (En bas) --- #}
<div class="flex justify-end gap-3 pt-6 border-t border-gray-200 mt-8">
{# Bouton de retour vers la liste (à adapter selon votre route) #}
<a href="{{ path('admin_products') | default('#') }}" class="px-6 py-3 border border-gray-300 text-gray-700 rounded-lg shadow-sm hover:bg-gray-50 transition">
Annuler
</a>
{# Bouton de soumission (CRUCIAL) #}
<button type="submit" class="px-6 py-3 bg-indigo-600 text-white font-semibold rounded-lg shadow-md hover:bg-indigo-700 transition transform hover:scale-[1.01]">
{{ is_edit ? 'Enregistrer les modifications' : 'Créer le Produit' }}
</button>
</div>
{# Rendu des erreurs et champs cachés générés par Symfony #}
{{ form_widget(form._token) }}
{# Appel unique et correct de form_end #}
{{ form_end(form, {'render_rest': false}) }}
</div>
{% endblock %}

View File

@@ -27,13 +27,28 @@
}
</script>
{% for product in featuredProducts %}
<script type="application/ld+json">
{
"@context": "https://schema.org/",
"@type": "ImageObject",
"contentUrl": "{{ vich_uploader_asset(product,'image') | imagine_filter('webp') }}",
"license": "https://example.com/license",
"acquireLicensePage": "{{ app.request.schemeAndHttpHost}}{{ path('app_legal') }}",
"creditText": "E-Cosplay",
"creator": {
"@type": "Person",
"name": "E-Cosplay"
},
"copyrightNotice": "E-Cosplay"
}}
</script>
<script type="application/ld+json">
{
"@context": "https://schema.org/",
"@type": "Product",
"name": "{{ product.name }}",
"image": "{{ product.image }}",
"description": "{{ product.short_desc }}",
"image": "{{ vich_uploader_asset(product,'image') | imagine_filter('webp') }}",
"description": "{{ product.shortDescription }}",
"sku": "EC-{{ product.id }}",
"brand": {
"@type": "Brand",
@@ -76,9 +91,9 @@
"hasMerchantReturnPolicy": {
"@type": "MerchantReturnPolicy",
"applicableCountry": "FR",
"returnPolicyCategory": "https://schema.org/{% if product.is_handmade %}MerchantReturnNotPermitted {% else %}MerchantReturnFiniteReturnWindow{% endif %}",
"merchantReturnDays": {% if product.is_handmade %}0{%else%}14{% endif %},
"returnFees": "https://schema.org/{% if product.is_handmade %}ReturnFeesCustomerResponsibility{%else%}ReturnFeesCustomerResponsibility{% endif %}",
"returnPolicyCategory": "https://schema.org/{% if product.custom %}MerchantReturnNotPermitted {% else %}MerchantReturnFiniteReturnWindow{% endif %}",
"merchantReturnDays": {% if product.custom %}0{%else%}14{% endif %},
"returnFees": "https://schema.org/{% if product.custom %}ReturnFeesCustomerResponsibility{%else%}ReturnFeesCustomerResponsibility{% endif %}",
"returnMethod": "https://schema.org/ReturnByMail"
}
}
@@ -92,50 +107,20 @@
<h1 class="text-4xl font-extrabold text-gray-900 mb-8 text-center">
{{ 'shop.welcome_title'|trans }}
</h1>
{# --- LAYOUT PRINCIPAL : SIDEBAR & CONTENU --- #}
<div class="flex flex-col md:flex-row gap-8">
{# --- 1. SIDEBAR / NAVIGATION DES CATÉGORIES (Mobile: Top, Desktop: Left) --- #}
<aside class="md:w-1/4 bg-white p-6 rounded-xl shadow-lg border border-gray-100 h-fit">
<h3 class="text-xl font-bold text-gray-800 mb-4 border-b pb-2">
{{ 'shop.categories_title'|trans }}
</h3>
<nav class="space-y-2">
{% set categories = [
{'key': 'cosplay', 'route': 'app_shop_category', 'icon': ''},
{'key': 'wig', 'route': 'app_shop_category', 'icon': ''},
{'key': 'props', 'route': 'app_shop_category', 'icon': ''},
{'key': 'retouches', 'route': 'app_shop_category', 'icon': ''},
] %}
{% for category in categories %}
<a href="{{ url(category.route, {'slug': category.key}) }}"
class="flex items-center gap-3 p-3 rounded-lg text-gray-700 hover:bg-indigo-50 hover:text-indigo-600 transition duration-150 group">
{{ category.icon|raw }}
<span class="font-medium">
{{ ('shop.category_' ~ category.key)|trans }}
</span>
</a>
{% endfor %}
</nav>
</aside>
{# --- 2. CONTENU PRINCIPAL / AFFICHAGE DES PRODUITS --- #}
<main class="md:w-3/4">
<p class="text-xl text-gray-600 mb-8">
<main>
<p class="text-xl text-gray-600 mb-8 text-center">
{{ 'shop.description'|trans }}
</p>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{% for product in featuredProducts %}
<div class="bg-white rounded-xl shadow-md overflow-hidden hover:shadow-xl transition duration-300 transform hover:-translate-y-1 border border-gray-100 relative">
{# ÉTIQUETTE PROMO (Absolute positioning) #}
{% if product.is_promo %}
{% if product.promo %}
<div class="absolute top-2 left-2 bg-red-600 text-white text-xs font-bold px-3 py-1 rounded-full shadow-lg z-10">
{{ 'shop.tag_promo'|trans }}
</div>
@@ -143,7 +128,7 @@
{# Image et Étiquette de Préférence (État) #}
<div class="relative">
<img src="{{ product.image }}" alt="Image de {{ product.name }}" class="w-full h-48 object-cover">
<img src="{{ vich_uploader_asset(product,'image') | imagine_filter('webp')}}" alt="Image de {{ product.name }}" class="w-full h-48 object-cover">
{# Étiquette de préférence (Neuf/Occasion) #}
<div class="absolute bottom-0 right-0 bg-gray-900 text-white text-xs font-semibold px-2 py-1 rounded-tl-lg opacity-80">
@@ -159,7 +144,7 @@
{# TAGS SUPPLÉMENTAIRES #}
<div class="flex gap-2 mb-3 flex-wrap">
{% if product.is_handmade %}
{% if product.handmade %}
<span class="text-xs font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded-full">
{{ 'shop.tag_handmade'|trans }}
</span>
@@ -173,8 +158,8 @@
</div>
{# DESCRIPTION COURTE #}
<p class="text-sm text-gray-600 mb-4 line-clamp-2" title="{{ product.short_desc }}">
{{ product.short_desc }}
<p class="text-sm text-gray-600 mb-4 line-clamp-2" title="{{ product.shortDescription }}">
{{ product.shortDescription }}
</p>
{# PRIX TTC et Bouton #}
@@ -182,7 +167,7 @@
<span class="text-2xl font-extrabold text-indigo-600">
{{ product.price | number_format(2, ',', ' ') }} € TTC
</span>
<a href="{{ path('app_product_show', {'slug': product.name|lower|replace({' ': '-'})}) }}" class="text-indigo-600 hover:text-indigo-800 text-sm font-semibold inline-flex items-center group">
<a style="display: none" href="{{ path('app_product_show', {'slug': product.name|lower|replace({' ': '-'})}) }}" class="text-indigo-600 hover:text-indigo-800 text-sm font-semibold inline-flex items-center group">
En savoir plus
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-right ml-1 group-hover:translate-x-0.5 transition-transform"><path d="m9 18l6-6-6-6"/></svg>
</a>
@@ -194,6 +179,5 @@
</main>
</div>
</div>
{% endblock %}