✨ 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:
@@ -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
|
||||
|
||||
34
migrations/Version20251119115552.php
Normal file
34
migrations/Version20251119115552.php
Normal 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');
|
||||
}
|
||||
}
|
||||
32
migrations/Version20251119120837.php
Normal file
32
migrations/Version20251119120837.php
Normal 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');
|
||||
}
|
||||
}
|
||||
31
migrations/Version20251119120904.php
Normal file
31
migrations/Version20251119120904.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
314
src/Entity/Products.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
93
src/Form/ProductsType.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
43
src/Repository/ProductsRepository.php
Normal file
43
src/Repository/ProductsRepository.php
Normal 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()
|
||||
// ;
|
||||
// }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
82
templates/admin/products.twig
Normal file
82
templates/admin/products.twig
Normal 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 %}
|
||||
122
templates/admin/products/add.twig
Normal file
122
templates/admin/products/add.twig
Normal 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 %}
|
||||
@@ -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>
|
||||
@@ -195,5 +180,4 @@
|
||||
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user