✨ 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
|
inject_on_load: true
|
||||||
delete_on_update: true
|
delete_on_update: true
|
||||||
delete_on_remove: 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:
|
#mappings:
|
||||||
# products:
|
# products:
|
||||||
# uri_prefix: /images/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\Account;
|
||||||
use App\Entity\AccountResetPasswordRequest;
|
use App\Entity\AccountResetPasswordRequest;
|
||||||
use App\Entity\Members;
|
use App\Entity\Members;
|
||||||
|
use App\Entity\Products;
|
||||||
use App\Form\MembersType;
|
use App\Form\MembersType;
|
||||||
|
use App\Form\ProductsType;
|
||||||
use App\Form\RequestPasswordConfirmType;
|
use App\Form\RequestPasswordConfirmType;
|
||||||
use App\Form\RequestPasswordRequestType;
|
use App\Form\RequestPasswordRequestType;
|
||||||
use App\Repository\MembersRepository;
|
use App\Repository\MembersRepository;
|
||||||
|
use App\Repository\ProductsRepository;
|
||||||
use App\Service\ResetPassword\Event\ResetPasswordConfirmEvent;
|
use App\Service\ResetPassword\Event\ResetPasswordConfirmEvent;
|
||||||
use App\Service\ResetPassword\Event\ResetPasswordEvent;
|
use App\Service\ResetPassword\Event\ResetPasswordEvent;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
@@ -31,6 +34,59 @@ class AdminController extends AbstractController
|
|||||||
return $this->render('admin/dashboard.twig', [
|
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'])]
|
#[Route(path: '/admin/members', name: 'admin_members', options: ['sitemap' => false], methods: ['GET'])]
|
||||||
public function adminMembers(MembersRepository $membersRepository): Response
|
public function adminMembers(MembersRepository $membersRepository): Response
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ namespace App\Controller;
|
|||||||
|
|
||||||
use App\Entity\Account;
|
use App\Entity\Account;
|
||||||
use App\Entity\AccountResetPasswordRequest;
|
use App\Entity\AccountResetPasswordRequest;
|
||||||
|
use App\Entity\Products;
|
||||||
use App\Form\RequestPasswordConfirmType;
|
use App\Form\RequestPasswordConfirmType;
|
||||||
use App\Form\RequestPasswordRequestType;
|
use App\Form\RequestPasswordRequestType;
|
||||||
|
use App\Repository\ProductsRepository;
|
||||||
use App\Service\ResetPassword\Event\ResetPasswordConfirmEvent;
|
use App\Service\ResetPassword\Event\ResetPasswordConfirmEvent;
|
||||||
use App\Service\ResetPassword\Event\ResetPasswordEvent;
|
use App\Service\ResetPassword\Event\ResetPasswordEvent;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
@@ -27,12 +29,12 @@ class ShopController extends AbstractController
|
|||||||
|
|
||||||
|
|
||||||
#[Route(path: '/boutique', name: 'app_shop', options: ['sitemap' => false], methods: ['GET'])]
|
#[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'
|
// Correction du nom du template de 'shop.twig' à 'shop/index.html.twig'
|
||||||
// et passage des données centralisées.
|
// et passage des données centralisées.
|
||||||
return $this->render('shop.twig', [
|
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;
|
namespace App\EventSubscriber;
|
||||||
|
|
||||||
|
use App\Repository\ProductsRepository;
|
||||||
|
use Cocur\Slugify\Slugify;
|
||||||
use Liip\ImagineBundle\Imagine\Cache\CacheManager;
|
use Liip\ImagineBundle\Imagine\Cache\CacheManager;
|
||||||
use Presta\SitemapBundle\Event\SitemapPopulateEvent;
|
use Presta\SitemapBundle\Event\SitemapPopulateEvent;
|
||||||
use Presta\SitemapBundle\Sitemap\Url\GoogleImage;
|
use Presta\SitemapBundle\Sitemap\Url\GoogleImage;
|
||||||
@@ -14,7 +16,7 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
|||||||
#[AsEventListener(event: SitemapPopulateEvent::class, method: 'onSitemapPopulate', priority: 10)]
|
#[AsEventListener(event: SitemapPopulateEvent::class, method: 'onSitemapPopulate', priority: 10)]
|
||||||
class SitemapSubscriber
|
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');
|
$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 %}">
|
{% endif %}">
|
||||||
Membres
|
Membres
|
||||||
</a>
|
</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 #}
|
{# ÉVÉNEMENTS #}
|
||||||
<a href="{{ path('admin_events') }}"
|
<a href="{{ path('admin_events') }}"
|
||||||
class="block px-3 py-2 rounded-lg transition duration-150 ease-in-out text-white
|
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>
|
</script>
|
||||||
{% for product in featuredProducts %}
|
{% 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">
|
<script type="application/ld+json">
|
||||||
{
|
{
|
||||||
"@context": "https://schema.org/",
|
"@context": "https://schema.org/",
|
||||||
"@type": "Product",
|
"@type": "Product",
|
||||||
"name": "{{ product.name }}",
|
"name": "{{ product.name }}",
|
||||||
"image": "{{ product.image }}",
|
"image": "{{ vich_uploader_asset(product,'image') | imagine_filter('webp') }}",
|
||||||
"description": "{{ product.short_desc }}",
|
"description": "{{ product.shortDescription }}",
|
||||||
"sku": "EC-{{ product.id }}",
|
"sku": "EC-{{ product.id }}",
|
||||||
"brand": {
|
"brand": {
|
||||||
"@type": "Brand",
|
"@type": "Brand",
|
||||||
@@ -76,9 +91,9 @@
|
|||||||
"hasMerchantReturnPolicy": {
|
"hasMerchantReturnPolicy": {
|
||||||
"@type": "MerchantReturnPolicy",
|
"@type": "MerchantReturnPolicy",
|
||||||
"applicableCountry": "FR",
|
"applicableCountry": "FR",
|
||||||
"returnPolicyCategory": "https://schema.org/{% if product.is_handmade %}MerchantReturnNotPermitted {% else %}MerchantReturnFiniteReturnWindow{% endif %}",
|
"returnPolicyCategory": "https://schema.org/{% if product.custom %}MerchantReturnNotPermitted {% else %}MerchantReturnFiniteReturnWindow{% endif %}",
|
||||||
"merchantReturnDays": {% if product.is_handmade %}0{%else%}14{% endif %},
|
"merchantReturnDays": {% if product.custom %}0{%else%}14{% endif %},
|
||||||
"returnFees": "https://schema.org/{% if product.is_handmade %}ReturnFeesCustomerResponsibility{%else%}ReturnFeesCustomerResponsibility{% endif %}",
|
"returnFees": "https://schema.org/{% if product.custom %}ReturnFeesCustomerResponsibility{%else%}ReturnFeesCustomerResponsibility{% endif %}",
|
||||||
"returnMethod": "https://schema.org/ReturnByMail"
|
"returnMethod": "https://schema.org/ReturnByMail"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,50 +107,20 @@
|
|||||||
<h1 class="text-4xl font-extrabold text-gray-900 mb-8 text-center">
|
<h1 class="text-4xl font-extrabold text-gray-900 mb-8 text-center">
|
||||||
{{ 'shop.welcome_title'|trans }}
|
{{ 'shop.welcome_title'|trans }}
|
||||||
</h1>
|
</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 --- #}
|
{# --- 2. CONTENU PRINCIPAL / AFFICHAGE DES PRODUITS --- #}
|
||||||
<main class="md:w-3/4">
|
<main>
|
||||||
<p class="text-xl text-gray-600 mb-8">
|
<p class="text-xl text-gray-600 mb-8 text-center">
|
||||||
{{ 'shop.description'|trans }}
|
{{ 'shop.description'|trans }}
|
||||||
</p>
|
</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 %}
|
{% 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">
|
<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) #}
|
{# É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">
|
<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 }}
|
{{ 'shop.tag_promo'|trans }}
|
||||||
</div>
|
</div>
|
||||||
@@ -143,7 +128,7 @@
|
|||||||
|
|
||||||
{# Image et Étiquette de Préférence (État) #}
|
{# Image et Étiquette de Préférence (État) #}
|
||||||
<div class="relative">
|
<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) #}
|
{# É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">
|
<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 #}
|
{# TAGS SUPPLÉMENTAIRES #}
|
||||||
<div class="flex gap-2 mb-3 flex-wrap">
|
<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">
|
<span class="text-xs font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded-full">
|
||||||
{{ 'shop.tag_handmade'|trans }}
|
{{ 'shop.tag_handmade'|trans }}
|
||||||
</span>
|
</span>
|
||||||
@@ -173,8 +158,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# DESCRIPTION COURTE #}
|
{# DESCRIPTION COURTE #}
|
||||||
<p class="text-sm text-gray-600 mb-4 line-clamp-2" title="{{ product.short_desc }}">
|
<p class="text-sm text-gray-600 mb-4 line-clamp-2" title="{{ product.shortDescription }}">
|
||||||
{{ product.short_desc }}
|
{{ product.shortDescription }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{# PRIX TTC et Bouton #}
|
{# PRIX TTC et Bouton #}
|
||||||
@@ -182,7 +167,7 @@
|
|||||||
<span class="text-2xl font-extrabold text-indigo-600">
|
<span class="text-2xl font-extrabold text-indigo-600">
|
||||||
{{ product.price | number_format(2, ',', ' ') }} € TTC
|
{{ product.price | number_format(2, ',', ' ') }} € TTC
|
||||||
</span>
|
</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
|
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>
|
<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>
|
</a>
|
||||||
@@ -194,6 +179,5 @@
|
|||||||
|
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user