From 702b235299a6e15a9b7a404fb73e19595773e619 Mon Sep 17 00:00:00 2001 From: Serreau Jovann Date: Wed, 19 Nov 2025 13:20:22 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(shop/products):=20Ajoute=20l'a?= =?UTF-8?q?ffichage=20des=20produits=20en=20boutique=20et=20un=20CRUD=20ad?= =?UTF-8?q?min.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute un CRUD pour les produits en admin. Affiche les produits en boutique. Ajoute les schemas JSON-LD pour chaque produit. --- config/packages/vich_uploader.yaml | 7 + migrations/Version20251119115552.php | 34 +++ migrations/Version20251119120837.php | 32 +++ migrations/Version20251119120904.php | 31 +++ src/Controller/Admin/AdminController.php | 56 ++++ src/Controller/ShopController.php | 6 +- src/Entity/Products.php | 314 ++++++++++++++++++++++ src/EventSubscriber/SitemapSubscriber.php | 18 +- src/Form/ProductsType.php | 93 +++++++ src/Repository/ProductsRepository.php | 43 +++ templates/admin/base.twig | 11 +- templates/admin/products.twig | 82 ++++++ templates/admin/products/add.twig | 122 +++++++++ templates/shop.twig | 74 ++--- 14 files changed, 874 insertions(+), 49 deletions(-) create mode 100644 migrations/Version20251119115552.php create mode 100644 migrations/Version20251119120837.php create mode 100644 migrations/Version20251119120904.php create mode 100644 src/Entity/Products.php create mode 100644 src/Form/ProductsType.php create mode 100644 src/Repository/ProductsRepository.php create mode 100644 templates/admin/products.twig create mode 100644 templates/admin/products/add.twig diff --git a/config/packages/vich_uploader.yaml b/config/packages/vich_uploader.yaml index 868b8bf..2fb495b 100644 --- a/config/packages/vich_uploader.yaml +++ b/config/packages/vich_uploader.yaml @@ -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 diff --git a/migrations/Version20251119115552.php b/migrations/Version20251119115552.php new file mode 100644 index 0000000..d4fd1db --- /dev/null +++ b/migrations/Version20251119115552.php @@ -0,0 +1,34 @@ +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'); + } +} diff --git a/migrations/Version20251119120837.php b/migrations/Version20251119120837.php new file mode 100644 index 0000000..90630ca --- /dev/null +++ b/migrations/Version20251119120837.php @@ -0,0 +1,32 @@ +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'); + } +} diff --git a/migrations/Version20251119120904.php b/migrations/Version20251119120904.php new file mode 100644 index 0000000..a9a9db2 --- /dev/null +++ b/migrations/Version20251119120904.php @@ -0,0 +1,31 @@ +addSql('CREATE SCHEMA public'); + } +} diff --git a/src/Controller/Admin/AdminController.php b/src/Controller/Admin/AdminController.php index e2e9752..8ac0120 100644 --- a/src/Controller/Admin/AdminController.php +++ b/src/Controller/Admin/AdminController.php @@ -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 diff --git a/src/Controller/ShopController.php b/src/Controller/ShopController.php index 7ac6780..a137b83 100644 --- a/src/Controller/ShopController.php +++ b/src/Controller/ShopController.php @@ -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() ]); } diff --git a/src/Entity/Products.php b/src/Entity/Products.php new file mode 100644 index 0000000..894cc80 --- /dev/null +++ b/src/Entity/Products.php @@ -0,0 +1,314 @@ +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; + } +} diff --git a/src/EventSubscriber/SitemapSubscriber.php b/src/EventSubscriber/SitemapSubscriber.php index fc10f0d..1af949d 100644 --- a/src/EventSubscriber/SitemapSubscriber.php +++ b/src/EventSubscriber/SitemapSubscriber.php @@ -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'); + } + } } diff --git a/src/Form/ProductsType.php b/src/Form/ProductsType.php new file mode 100644 index 0000000..0ca08a0 --- /dev/null +++ b/src/Form/ProductsType.php @@ -0,0 +1,93 @@ +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, + ]); + } +} diff --git a/src/Repository/ProductsRepository.php b/src/Repository/ProductsRepository.php new file mode 100644 index 0000000..6a21a3f --- /dev/null +++ b/src/Repository/ProductsRepository.php @@ -0,0 +1,43 @@ + + */ +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() + // ; + // } +} diff --git a/templates/admin/base.twig b/templates/admin/base.twig index 18da1b4..bf6f428 100644 --- a/templates/admin/base.twig +++ b/templates/admin/base.twig @@ -57,7 +57,16 @@ {% endif %}"> Membres - + {# MEMBRES #} + + Produits + {# ÉVÉNEMENTS #} + + {# Vous pouvez ajouter ici une icône si vous le souhaitez, par exemple : [Icône] #} + Créer un Produit + + +
+ + + {# --- EN-TÊTE DU TABLEAU (HEAD) --- #} + + + + + + + + + + {# --- CORPS DU TABLEAU (BODY) --- #} + + + {# Démonstration: Boucle sur une liste de membres (members) passée par votre contrôleur #} + {% if products is not empty %} + {% for product in products %} + + + + + + + {% endfor %} + {% else %} + {# Message si la liste est vide #} + + + + {% endif %} + + +
+ REF + + Name + + Prix + + Actions +
+
{{ product.ref }}
+
+
{{ product.name }}
+
+
{{ product.price|format_currency('EUR') }}
+
+ + Éditer + + + Supprimer + +
+ Aucun produit(s) trouvé. +
+
+ +{% endblock %} diff --git a/templates/admin/products/add.twig b/templates/admin/products/add.twig new file mode 100644 index 0000000..7f5c764 --- /dev/null +++ b/templates/admin/products/add.twig @@ -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 %} + +
+ + {# 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 #} + +
+ + {# Colonne 1 : Infos de base #} +
+ {{ form_row(form.name) }} + {{ form_row(form.ref) }} + {{ form_row(form.price) }} + {{ form_row(form.state) }} + + {# --- DÉBUT : CHAMP D'UPLOAD D'IMAGE STYLISÉ --- #} +
+ +
+ {# 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 #} + + + {# Afficher le nom du fichier actuel ou un message d'aide #} +
+ {% if currentImageUrl %} + Image actuelle : {{ product.imageFileName }} + {% else %} + Aucune image sélectionnée + {% endif %} +

{{ form.image.vars.help|default('(Max 2Mo, JPG, PNG)') }}

+
+
+
+ {# --- FIN : CHAMP D'UPLOAD D'IMAGE STYLISÉ --- #} + +
+

Options produit

+
+ {# Les ChoiceType Expanded pour les booléens #} +
{{ form_row(form.isHandmade) }}
+
{{ form_row(form.isCustom) }}
+
{{ form_row(form.isPromo) }}
+
+
+
+ + {# Colonne 2 : Descriptions #} +
+ {{ form_row(form.shortDescription) }} + {{ form_row(form.longDescription) }} +
+
+ + {# --- BOUTONS D'ACTION (En bas) --- #} +
+ {# Bouton de retour vers la liste (à adapter selon votre route) #} + + Annuler + + + {# Bouton de soumission (CRUCIAL) #} + +
+ + {# 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}) }} + +
+ +{% endblock %} diff --git a/templates/shop.twig b/templates/shop.twig index f443c23..ac5784c 100644 --- a/templates/shop.twig +++ b/templates/shop.twig @@ -27,13 +27,28 @@ } {% for product in featuredProducts %} +